A rate limit says when it stops being true; we were not listening - #276
Merged
Conversation
Three OpenRouter items, in the order their evidence was available. **429 is not 5xx.** `sendWithRetry` treated them alike: 500 ms, then 1 s, then give up — all three attempts inside a second and a half, which is shorter than any rate limit worth the name. So the caller was told "call failed with status 429" about a request that would have been accepted a few seconds later, having been refused three times to get there. `Retry-After` is now read first, in both its legal forms, then OpenRouter's `X-RateLimit-Reset`, which is an instant rather than a delay. Bounded by 30 s and by the call's own timeout; a delay beyond that is refused immediately *naming it*, since spending the remaining attempts arrives at the same answer more slowly. Anything unusable answers -1 and selects the ordinary backoff, so a gateway that says nothing is exactly as it was. The parsing is pure and takes its clock as a parameter. **The shortlist led with the models the default policy cannot route.** It is sorted cheapest-first, so free models arrive at the top — and under the shipped `openrouter-data-collection: DENY` those are usually the unroutable ones, the free endpoints being the ones that train. The first rows offered were the ones that answer 404, explained only afterwards by `explainRoutingRefusal`. Marked now, keyed on a published price of zero (a real measurement) and never on the `:free` suffix (a naming convention), and only under the policy that is actually running — read off `inForce`, not off the form. **And the contract script reaches the completion request.** It covered the catalogue only; the body of a completion is where `provider` and `usage` ride on the assumption that an unrecognised field is refused rather than ignored, which is the wrong way round — an ignored field costs nothing and says nothing, so a privacy restriction that never applied and an accounting field that never came back both look exactly like success. Behind `--chat`, because these are billed calls. The assertion that can change what the client sends is `usage.cost`: `LlmUsage.costUsd` reads it and `claude.session-cost-limit-usd` is enforced from it, so if it must be asked for, every cost shown on this application's own default provider is null while the live session announces that the provider reports none — false about OpenRouter rather than unknown. Not run from here: `openrouter.ai` is unreachable in this environment, which is why this script exists in the first place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0119y8rLek8kEbt6Ym1xZ8N1
…than counted Suggestions 5 and 6. (4, the rate-limit schedule, went in with the previous push.) **The credit was consulted nowhere.** A key running out answers 402, which `remedyFor` already reads correctly — but only after an analysis has failed. It is published, so the press that already asks what the model can do asks this too, under the catalogue's four rules: OpenRouter's own host, best-effort, no retry, never on the analysis path. It is also the number `session-cost-limit-usd` had been missing, that cap shipping disabled because any figure would be arbitrary while nothing on screen says what the budget is. Three states, and the third is the one to get right: a limited key with credit left, a limited key with none, and a key with no limit at all — which has a known usage and no remainder, because "unlimited minus what you spent" is not a number and rendering it as 0 would announce an exhausted key to somebody who has an unlimited one. `error` is a fourth thing and renders nothing: it says the question could not be asked. **A price ceiling is the enforceable half of the spend cap.** That one accumulates and stops the session afterwards, so the expensive route has been paid for by the time it notices; `provider.max_price` is refused before the tokens exist, the same way `data_collection` turns a privacy warning into a property. One value against both published prices, in the unit the catalogue publishes and the picker renders. Off by default, on the rule its two siblings follow — every provider over the ceiling makes the model unroutable, arriving as the same "no endpoints found". **And a 404 now names all three things it can mean**, pointing at the button that separates them. Deliberately not a catalogue lookup from the analysis path: that read is a considered gesture, and a live session that had begun failing would make one request per window. The contract script gains the two assertions these rest on — that `max_price` *binds* rather than being tolerated (a ceiling below every price must refuse the route; a 200 would mean the setting promises a bound it does not deliver), and what `/models/user` is relative to `/models`. That last one is why the shortlist does not yet mark rows a key cannot reach: labelling a usable model unusable is a worse lie than the silence it would replace, and nothing has established which of the two lists it is. Also here, from the same list: the catalogue now builds its HTTP client through `LlmHttpSupport.newClient` instead of restating the connect-timeout rule — one definition, the argument that produced SecureXml and v1Url. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0119y8rLek8kEbt6Ym1xZ8N1
17 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Four things about the OpenRouter integration, found by reading it end to end. Split out of #275, which is back to its own subject.
A 429 was handled like a 5xx.
sendWithRetryretried both on the same backoff — 500 ms, then 1 s, then give up: all three attempts inside a second and a half, which is shorter than any rate limit worth the name. So the caller was told "call failed with status 429" about a request that would have been accepted a few seconds later, having been refused three times to get there. The two are not alike: a 5xx may well pass half a second later, while a rate limit states when it stops being true.Retry-Afteris now read first and in both its legal forms (delta-seconds and an HTTP date), then OpenRouter'sX-RateLimit-Reset, which is an instant rather than a delay and is taken as seconds or milliseconds on the same 10^10 thresholdsetup-demo.shuses. Bounded twice — 30 s, and never past the call's own request timeout — and a delay beyond that is a refusal naming it rather than three more attempts arriving at the same answer, since nothing here can shorten it. Everything unusable (an unparsable header, a negative delay, a reset already past) answers-1, which selects the ordinary backoff: a gateway that says nothing behaves exactly as before.The model shortlist led with the models the default policy cannot route. It is sorted cheapest-first, so free models arrive at the top — and under the shipped
openrouter-data-collection: DENYthose are usually the unroutable ones, the free endpoints being the ones that train. The first rows offered were therefore the ones that answer 404, qualified only afterwards byexplainRoutingRefusal. They now carry the caveat. It warns rather than deciding — which providers serve a model today is the routing's business — and it keys on the published price being zero, a real measurement, never on the:freesuffix, which is a naming convention; an absent price staysnulland produces nothing. The flag comes from the policy that is running, never from the form.The key's remaining credit was consulted nowhere. A key running out answers 402, which
LlmHttpSupport.remedyForalready reads correctly — but only after an analysis has failed. It is published, so the press that already asks what the model can do asks this too, under the catalogue's four rules (OpenRouter's own host, best-effort, no retry, never on the analysis path). It is also the numberclaude.session-cost-limit-usdhad been missing: that cap ships disabled because "any figure chosen here would be arbitrary", and arbitrary is what a cap is while nothing on screen says what the budget is. Three states are kept apart, and the third is the one to get right: a limited key with credit left, a limited key with none (a 402 waiting to happen), and a key with no limit at all, which has a known usage and no remainder — "unlimited minus what you spent" is not a number, and rendering it as0would announce an exhausted key to somebody who has an unlimited one.erroris a fourth thing entirely and renders nothing: it says the question could not be asked.claude.openrouter-max-price-usd-per-millionis the enforceable half ofsession-cost-limit-usd. That one is an accumulator: it adds up what has already been spent and stops the session afterwards, which is the right shape for a running total and the wrong one for a surprise — a model routed to an expensive provider has already been paid for by the time the cap notices. A price ceiling is refused at the routing layer, before the tokens exist, the same waydata_collectionturns a privacy warning into a property. One value against both published prices, in the unit the catalogue publishes and the picker renders.0and off by default, on the rule its two siblings follow: a model every provider prices above the ceiling becomes unroutable, arriving as the same "no endpoints found" a restrictive data policy produces.Two smaller ones from the same reading. A 404 now names all three things it can mean on this gateway — a slug that does not exist, one that no longer does, a key not entitled to it, plus the routing policy when one is set — and points at the Test button, which is the one place that asks the catalogue. Deliberately not a lookup from the analysis path: that read is a considered gesture, and a live session that had begun failing would otherwise make one request per window. And
OpenRouterModelCatalognow builds its HTTP client throughLlmHttpSupport.newClientinstead of restating the connect-timeout rule — one definition, the argument that producedSecureXmlandv1Url.docs/verify-openrouter-contract.pyreaches the completion request, behind--chatbecause those are real billed calls. It covered the catalogue only, while the body of a completion is whereproviderandusageride on the assumption that an unrecognised field is refused rather than ignored — which is the wrong way round: an ignored field costs nothing and says nothing, so a privacy restriction that never applied and an accounting field that never came back both look exactly like success. Its new assertions: whetherusage.costneedsusage: {include: true}; thatprovider.max_pricebinds rather than being tolerated (a ceiling below every published price must refuse the route — a 200 there would mean the setting promises a bound it does not deliver); and what/models/useris relative to/models.That last one is why the shortlist does not mark the rows a key cannot reach, though it easily could: nothing has established whether that endpoint lists what a key is entitled to or something narrower, and labelling a usable model unusable is a worse lie than the silence it would replace.
Related Issue
None — found by reading the OpenRouter integration end to end, at the user's request.
Type of Change
LlmTestResponsegains a component and keeps its previous constructor;describeOptiongains an optional second argument defaulting to today's behaviour. One new setting,claude.openrouter-max-price-usd-per-million, defaults to0= off, so nothing changes for an existing deployment.CLAUDE.md,CHANGELOG.md,docs/DOCKERHUB.mdanddocs/LLM-PROVIDERS.mdare updated.How Has This Been Tested?
packages.confluent.ioanswers 403 through this environment's proxy — the caseverify-offline.shexists for — so the backend was compiled and run through that harness rather thanmvn verify. CI is the authority.KafkaClusterIntegrationTestexcluded: it needs Testcontainers).LlmRateLimitTest(new): both header forms, the seconds/milliseconds threshold,Retry-Afterwinning over the reset instant, and every unusable form falling back to the ordinary backoff — all pure, with the clock as a parameter, so none of it waits. Then, against a real loopback HTTP server: a short delay waited out in one retry, a long one refused at once without a second call, a gateway sending no header behaving as before, and the cap following the call's own timeout when that is the smaller of the two.OpenRouterModelCatalogTest: the key's three states, including an unlimited key that must not read as an exhausted one, the remainder derived only as a fallback and only from two real numbers, and a body carrying nothing about the key reported as unavailable rather than as an empty key.npx tsc --noEmitclean,npm run lintclean (--max-warnings 0).docs/check-*.pypass — includingcheck-config-table.py, which resolves the new setting's environment-variable form against the code.docs/verify-openrouter-contract.py --chathas NOT been run.openrouter.aiis unreachable from this environment (403 on the CONNECT tunnel), which is the very reason that script exists. Itsusage.costassertion decides whether the client should start sendingusage: {include: true}, and itsmax_priceassertion decides whether the new ceiling actually binds. Somebody with a key and a network should run it before either is trusted — nothing in the client was changed on the strength of an unverified answer, which is why the cost field is read exactly as before.Checklist:
🤖 Generated with Claude Code
https://claude.ai/code/session_0119y8rLek8kEbt6Ym1xZ8N1
Generated by Claude Code