Skip to content

fix(billing): refuse EU OpenRouter keys whose plan cannot use the EU endpoint - #1401

Merged
Makisuo merged 4 commits into
mainfrom
fix/openrouter-byok-eu-plan-check
Oct 13, 2026
Merged

Makisuo merged 4 commits into
mainfrom
fix/openrouter-byok-eu-plan-check

Conversation

@Makisuo

@Makisuo Makisuo commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

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 against GET /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:

InsufficientPermissions: Your API key lacks required permissions. Regional routing requires a Business or Enterprise plan. ... [code: 403]

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.cloud SDKs, on @distilled.cloud/core:

  • a Credentials service (key and base URL), resolved per request;
  • a REST protocol (makeRestProtocol) that reads OpenRouter's { error: { code, message } } error body;
  • two operations made with API.make: getKey (GET /key) and createChatCompletion (POST /chat/completions);
  • error classes per status (Unauthorized 401, PaymentRequired 402, Forbidden 403), matched with applyErrorMatchers.

OrgOpenRouterKeyService.set calls getKey. On the EU instance it then sends a one-token completion to the in-region host. toKeyError turns each error into what the settings page shows:

  • 401: "OpenRouter rejected this key."
  • 403 on EU: refused with a message saying EU workspaces need a Business or Enterprise plan.
  • 402: "This OpenRouter key has no credits left."
  • Anything else, or a timeout: the existing "try again" persistence error.

US saves make the same single /key call as before.

Reviewer notes

  • The check costs the customer one token on their own key, once per save.
  • The check uses the model openai/gpt-6-luna, the EU default. packages/backend can't import apps/ai, so the name is written out again with a comment saying to keep it in step with Llm.ts. If that model leaves the EU catalogue, EU saves will fail with "try again" until the constant is updated.
  • The 403 shape was taken from production traces. I haven't run a non-Business key against the endpoint myself.
  • A key already saved before this change isn't re-checked. The affected org has to upgrade its plan or remove the key.
  • Not in this PR: the EU chat worker reports "Billing is not configured", so the add-on check counts every org as entitled, and on EU a saved key is used whether or not the org bought the add-on.

Tests

OrgOpenRouterKeyService.test.ts now:

  • expects the EU save to call /key and then the in-region chat endpoint;
  • covers a 403 from the in-region endpoint: the save is refused with the plan message and nothing is stored.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Devin Review

Summary by CodeRabbit

  • Improvements
    • EU OpenRouter key setup now checks that the key can make chat-completion requests, in addition to validating the key itself.
    • If the EU endpoint requires a Business or Enterprise plan, or the account has no credits, setup reports the relevant issue and leaves the key unconfigured.
    • Other errors during the EU chat-completion check are reported, and the key is not saved.
    • Non-EU key validation continues to check the key before setup.

…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-bot

maple-review-bot Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Maple review

🟢 Confidence 8/10 · likely safe to merge
Save path is small and covered for the happy path and 403; the probe body and its model coupling are the parts no test or live call has exercised.
quality 96/100 · 2 notes · tests partial · risk medium · 1/1 new units observable

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 BillingNotConfiguredError issue is unaddressed.

  • OrgOpenRouterKeyService.set now probes eu.openrouter.ai/api/v1/chat/completions on EU, refusing 403 and 402 keys
  • Shared unreachable() mapper replaces the inline duplicate in validateWithOpenRouter

Before merge

  • Manual · Have the affected EU org re-save (or remove) its OpenRouter key: an already-stored key is never re-checked

Findings

🔵 Note · F1 · checkEuPlan's 402 and unexpected-status branches are untested

tests · packages/backend/src/services/org/OrgOpenRouterKeyService.ts:176-186

The new tests cover the EU happy path and the 403 refusal, but not the 402 branch or the fallback branch that turns any other status into "OpenRouter answered N while checking this key. Try again." — the two branches that decide whether an EU save is refused as the customer's key problem or blamed on a transient failure, the same distinction every other branch in this file's tests pins (401, 256-char key, lapsed add-on).

Add two cases to `OrgOpenRouterKeyService.test.ts` with `euChatStatus: 402` and e.g. `euChatStatus: 500`, asserting the message and that nothing is stored for 402.
🔵 Note · F2 · EU probe model is a second copy of Llm.ts's EU default

maintainability · packages/backend/src/services/org/OrgOpenRouterKeyService.ts:40-44

EU_PLAN_PROBE_BODY hardcodes openai/gpt-6-luna, the value of EU_DEFAULT_OPENROUTER_MODEL in apps/ai/src/platform/Llm.ts:83; when that default changes or leaves the EU catalogue, every EU key save fails with the misleading "Could not reach OpenRouter… Try again" persistence error and no key can be saved on the EU instance. A comment does not keep the two in step.

Export the EU default model id from a package both sides import (e.g. alongside the region helpers in `@maple/domain`) and read it here, so one edit moves both.
🤖 Prompt to fix all 2 findings with an AI agent
Findings from an automated review of commit 578acc3e13e9508e46c7bc95bc64c100ec83664f. Verify each one against the current code before changing anything, fix only those that still apply, and keep each fix to the lines it names.

---

F1 · Note · tests · packages/backend/src/services/org/OrgOpenRouterKeyService.ts:176-186
`checkEuPlan`'s 402 and unexpected-status branches are untested
The new tests cover the EU happy path and the 403 refusal, but not the 402 branch or the fallback branch that turns any other status into "OpenRouter answered N while checking this key. Try again." — the two branches that decide whether an EU save is refused as the customer's key problem or blamed on a transient failure, the same distinction every other branch in this file's tests pins (401, 256-char key, lapsed add-on).
Suggested fix: Add two cases to `OrgOpenRouterKeyService.test.ts` with `euChatStatus: 402` and e.g. `euChatStatus: 500`, asserting the message and that nothing is stored for 402.

---

F2 · Note · maintainability · packages/backend/src/services/org/OrgOpenRouterKeyService.ts:40-44
EU probe model is a second copy of `Llm.ts`'s EU default
`EU_PLAN_PROBE_BODY` hardcodes `openai/gpt-6-luna`, the value of `EU_DEFAULT_OPENROUTER_MODEL` in `apps/ai/src/platform/Llm.ts:83`; when that default changes or leaves the EU catalogue, every EU key save fails with the misleading "Could not reach OpenRouter… Try again" persistence error and no key can be saved on the EU instance. A comment does not keep the two in step.
Suggested fix: Export the EU default model id from a package both sides import (e.g. alongside the region helpers in `@maple/domain`) and read it here, so one edit moves both.

Production impact

Open errors in the changed files
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: checkEuPlan gated on MAPLE_REGION === "eu", pinned by the region: "us" test's URL assertion
  • Probe failures are typed (OpenRouterKeyPersistenceError for non-200/403/402) and mapped before the flatMap, so refusals are not turned into "try again"
  • The EU host and path match apps/ai/src/platform/Llm.ts's OPENROUTER_API_URLS.eu and its EU default model openai/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.

@coderabbitai

coderabbitai Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: b7894940-87c0-47f5-8cfd-3c05e35fe52f
📥 Commits

Reviewing files that changed from the base of the PR and between 578acc3 and b41f320.

📒 Files selected for processing (5)
  • apps/ai/src/platform/Llm.ts
  • packages/backend/src/services/org/OrgOpenRouterKeyService.test.ts
  • packages/backend/src/services/org/OrgOpenRouterKeyService.ts
  • packages/backend/src/services/org/openrouter-api.ts
  • packages/domain/src/organization-regions.ts

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 2 remain after this review.

📝 Walkthrough

EU OpenRouter key setup uses a typed API client to validate the key. For EU keys, setup also sends a one-token chat-completions request using the shared EU model. The service maps 403 and 402 responses to validation errors and maps other failures to persistence errors.

Changes: EU OpenRouter key validation

Layer / File(s) Summary
Define the typed OpenRouter API and shared EU model
packages/backend/src/services/org/openrouter-api.ts, packages/domain/src/organization-regions.ts, apps/ai/src/platform/Llm.ts
The API client defines regional URLs, credentials, typed errors, and key and chat-completion operations. The domain exports the EU default model, which the AI platform uses as its review-model default.
Integrate and test EU key validation
packages/backend/src/services/org/OrgOpenRouterKeyService.ts, packages/backend/src/services/org/OrgOpenRouterKeyService.test.ts
The service calls the key endpoint and, for EU keys, sends a one-token chat-completion request. Tests cover successful requests, 403 and 402 validation errors, and persistence errors for other failures.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant OrgOpenRouterKeyService
  participant OpenRouterApi
  participant EUOpenRouter
  OrgOpenRouterKeyService->>OpenRouterApi: Call getKey
  OpenRouterApi->>EUOpenRouter: GET /key
  EUOpenRouter-->>OpenRouterApi: Key response
  OrgOpenRouterKeyService->>OpenRouterApi: Call createChatCompletion with one token
  OpenRouterApi->>EUOpenRouter: POST /chat/completions
  EUOpenRouter-->>OpenRouterApi: Chat-completion response
  OpenRouterApi-->>OrgOpenRouterKeyService: Typed result or error
Loading
Merge Risk: ⚪ Minimal · up to b41f3

EU OpenRouter keys on plans that cannot use the EU endpoint are now refused at save time, while US saves are unchanged. No merge-blocking risk was identified in the supplied change.

Pre-merge checks · Passed 5
Passed Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 5…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: rejecting EU OpenRouter keys when the associated plan cannot use the EU endpoint.
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Devin Review: 2 flags

Not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

…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.
@maple-review-bot

maple-review-bot Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Note

A newer push replaced df7f814 before its review finished. The latest commit is reviewed in a new comment.

…/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-bot

maple-review-bot Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Maple review

🟢 Confidence 8/10 · likely safe to merge
The EU gate rests on @distilled.cloud/core mapping 403/402 as assumed; the tests pin that against a stubbed fetch, which I could not run here.
quality 100/100 · no findings · tests covered · risk medium · 2/2 new units observable

EU saves now refuse an OpenRouter key whose plan cannot use the in-region host, by probing one token at /chat/completions; US saves are unchanged. The code is contained and tested, and the open BillingNotConfiguredError from callAutumn is not addressed by this diff.

  • OrgOpenRouterKeyService.set reads credentials per request and, on EU, probes the in-region chat endpoint
  • EU 403 maps to a plan-specific validation error, 402 to a no-credits error, everything else to retry
  • openrouter-api.ts adds a distilled-style OpenRouter client with per-status error classes
  • EU_DEFAULT_OPENROUTER_MODEL moves to @maple/domain/organization-regions and is shared with Llm.ts

Before merge

  • Manual · The affected EU org must upgrade to Business/Enterprise or remove its stored key: an already-saved key is never re-checked

Fixed since the last review

  • ✅ F1 · checkEuPlan's 402 and unexpected-status branches are untested
  • ✅ F2 · EU probe model is a second copy of Llm.ts's EU default

Production impact

Open errors in the changed files
Issue Service Occurrences File
@maple/http/errors/BillingNotConfiguredError maple-chat 11 packages/backend/src/services/org/OrgOpenRouterKeyService.ts

After this merges, Maple checks whether they stop.

Production traffic of the changed files (last 7 days)
File Calls/day Busiest operations
packages/backend/src/services/org/openrouter-api.ts 1.4k POST 603/day, 0.1% err
GET 457/day, 1.5% err
GET 173/day, 0.0% err
packages/backend/src/services/org/OrgOpenRouterKeyService.ts 1 OrgOpenRouterKeyService.get 1/day, 0.0% err
What was checked
  • EU save calls /key then /chat/completions on the EU host, asserted by URL (test.ts:151)
  • A US save still makes the single /key call (test.ts:128)
  • 403, 402 and 500 branches map to the intended errors and store nothing (test.ts:159-226)
Observability coverage: 2 of 2 changes observable
Change Kind Observable Evidence
OpenRouter key check (GET /key) outbound HTTP yes Runs inside OrgOpenRouterKeyService.validateWithOpenRouter (OrgOpenRouterKeyService.ts:178), the span the previous code used
EU plan probe (POST /chat/completions) outbound HTTP yes Same span covers it (OrgOpenRouterKeyService.ts:162-178); no separate Client span, as elsewhere in the repo for OpenRouter calls

b41f320 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@Makisuo
Makisuo merged commit b0a1628 into main Oct 13, 2026
41 checks passed
@Makisuo
Makisuo deleted the fix/openrouter-byok-eu-plan-check branch October 13, 2026 10:34
@maple-review-bot

Copy link
Copy Markdown

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.

  • Manual · The affected EU org must upgrade to Business/Enterprise or remove its stored key: an already-saved key is never re-checked

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant