Repository navigation
fix(billing): refuse EU OpenRouter keys whose plan cannot use the EU endpoint - #1401
Conversation
…endpoint The EU instance sends every model call to eu.openrouter.ai, which only serves chat to Business or Enterprise plans. The save-time check called /key, which answers 200 for any live key, so a key on a lower plan saved fine and then every agent turn failed with a 403. On the EU instance, saving now also asks the in-region chat endpoint for one token: a 403 is refused with a message naming the plan requirement, a 402 as out of credits.
Maple review🟢 Confidence 8/10 · likely safe to merge On EU, a key is now saved only after a one-token chat call proves the plan can use the in-region endpoint; the logic is sound and US is untouched. Two gaps: a stored key is still never re-checked, and the open
Before merge
Findings🔵 Note · F1 ·
|
| Issue | Service | Occurrences | File |
|---|---|---|---|
| @maple/http/errors/BillingNotConfiguredError | maple-chat | 7 | packages/backend/src/services/org/OrgOpenRouterKeyService.ts |
After this merges, Maple checks whether they stop.
Telemetry this change adds and removes (1)
- ➕ span name
OrgOpenRouterKeyService.checkEuPlan·packages/backend/src/services/org/OrgOpenRouterKeyService.ts:188
What was checked
- US path stays single-call:
checkEuPlangated onMAPLE_REGION === "eu", pinned by theregion: "us"test's URL assertion - Probe failures are typed (
OpenRouterKeyPersistenceErrorfor non-200/403/402) and mapped before theflatMap, so refusals are not turned into "try again" - The EU host and path match
apps/ai/src/platform/Llm.ts'sOPENROUTER_API_URLS.euand its EU default modelopenai/gpt-6-luna
Observability coverage: 1 of 1 changes observable
| Change | Kind | Observable | Evidence |
|---|---|---|---|
| EU one-token chat completion to eu.openrouter.ai on key save | outbound HTTP | yes | Span OrgOpenRouterKeyService.checkEuPlan (packages/backend/src/services/org/OrgOpenRouterKeyService.ts:188); Internal span with no peer.service, as the file's existing validateWithOpenRouter outbound span |
578acc3 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
📝 Walkthrough
Merge Risk: ⚪ Minimal · up to
|
There was a problem hiding this comment.
🔍 Devin Review: 2 flags
Not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)
…ient
The key check now goes through two typed operations (getKey,
createChatCompletion) built on @distilled.cloud/core: a credentials service
resolved per request, a REST protocol reading OpenRouter's
{ error: { code, message } } body, and per-status error classes
(Unauthorized, PaymentRequired, Forbidden). The service maps those tags to
what the settings page shows instead of branching on raw status codes.
|
Note A newer push replaced |
…/5xx - EU_DEFAULT_OPENROUTER_MODEL moves to @maple/domain/organization-regions; the agents and the EU key check both read it, so a model change cannot leave EU key saves failing on a model the catalogue dropped - Cover the EU check's 402 refusal and its retryable upstream failure
Maple review🟢 Confidence 8/10 · likely safe to merge EU saves now refuse an OpenRouter key whose plan cannot use the in-region host, by probing one token at
Before merge
Fixed since the last review
Production impactOpen errors in the changed files
After this merges, Maple checks whether they stop. Production traffic of the changed files (last 7 days)
What was checked
Observability coverage: 2 of 2 changes observable
|
|
Merged with 1 step from "Before merge" still open. If it is done, tick it on the review comment; if not, now is the time.
|
Problem
The EU instance sends every model call to
eu.openrouter.ai, which serves chat only to OpenRouter Business or Enterprise plans. When a key is saved, we check it againstGET /api/v1/key, and that endpoint answers 200 for any live key without looking at the plan. So a key on a lower plan saves without complaint, and then every agent turn on it fails:This is happening in production right now: an EU org saved its key and every PR review since has failed this way.
Change
The key check now runs through a small OpenRouter client in
packages/backend/src/services/org/openrouter-api.ts, built the same way as the@distilled.cloudSDKs, on@distilled.cloud/core:Credentialsservice (key and base URL), resolved per request;makeRestProtocol) that reads OpenRouter's{ error: { code, message } }error body;API.make:getKey(GET /key) andcreateChatCompletion(POST /chat/completions);Unauthorized401,PaymentRequired402,Forbidden403), matched withapplyErrorMatchers.OrgOpenRouterKeyService.setcallsgetKey. On the EU instance it then sends a one-token completion to the in-region host.toKeyErrorturns each error into what the settings page shows:US saves make the same single
/keycall as before.Reviewer notes
openai/gpt-6-luna, the EU default.packages/backendcan't importapps/ai, so the name is written out again with a comment saying to keep it in step withLlm.ts. If that model leaves the EU catalogue, EU saves will fail with "try again" until the constant is updated.Tests
OrgOpenRouterKeyService.test.tsnow:/keyand then the in-region chat endpoint;🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need.Summary by CodeRabbit