Skip to content

fix(providers): harden OpenRouter quota reset cooldowns - #4980

Merged
lidge-jun merged 4 commits into
devfrom
codex/carry-4733-openrouter-quota-cooldown
Sep 18, 2026
Merged

lidge-jun merged 4 commits into
devfrom
codex/carry-4733-openrouter-quota-cooldown

Conversation

@lidge-jun

Copy link
Copy Markdown
Owner

Summary

Verification

  • Local verification was not run because this lane forbids running any local suite, typecheck, build, or install. Hosted CI is the executable verification for this change.
  • Static reasoning traced client abort from adapter dispatch through the bounded body reader and confirmed rotation and config persistence occur only after the abort checks.
  • git diff --check origin/dev...HEAD completed with no findings.
  • jq empty scripts/test-layout/layout.json tests/fixtures/test-layout-expected.json completed successfully.
  • The test-layout SSOT and expected fixture both include the new provider test.
  • Added focused coverage for a chunk larger than the scan cap, full response replay, timeout cancellation, reader unlocking, unrelated-provider prose, and cancellation before key cooldown or persistence.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.

…ared

A free-tier quota exhaustion is dated by the upstream, but OpenRouter puts
that date in the 429 BODY and sends no Retry-After. parseRetryAfterMs only
reads the header, so the key was parked for DEFAULT_COOLDOWN_MS, came back,
took another 429, and repeated for the rest of the quota window.

parseQuotaResetAt reads `... will reset at <date>` out of a bounded 4KB
prefix; readQuotaResetAt pulls it off a cloned response so the caller can
still cancel the original to release the socket. When present it outranks
both the header and the default, because it is the only one of the three
that knows when the quota actually returns.

MAX_QUOTA_COOLDOWN_MS is 32 days, separate from the 10-minute cap on
undated 429s. That cap stays short on purpose: an undated 429 is a guess.
A dated one is not. 32 rather than 8 days because the wording parsed is
"Weekly/Monthly Limit Exhausted" — an 8-day cap silently clamped every
monthly reset ~23 days early, back into the loop this removes. Caught by
the cap's own test.

Anything unreadable returns undefined and leaves today's behaviour exactly
as it was: no body, consumed body, no match, unparsable date, a date
already past, or one past the cap.

Closes #4024
Review follow-up from @lidge-jun on #4733: readQuotaResetAt called
.text() on a clone and sliced 4KB afterwards, so the parse was bounded
and the read was not. The PR claimed a bounded prefix; that was wrong.

Fixing it surfaced something worse than the unbounded read. clone() tees
the body, and the caller leaves the original branch undrained while this
runs — so the tee stalls once its buffer fills. A 5MB error body hangs
the rotation path outright. Reproduced: the first bounded version still
timed out at 5s against a finite 5MB stream.

So it no longer clones. It pulls a bounded prefix from the original and
returns a Response that replays those bytes ahead of the remainder, which
the caller can read or cancel exactly as before. The signature is now
{ at, response } and adapter-dispatch rebinds upstreamResponse — the
response is still needed on the !rotated path, so consuming it outright
was not an option either.

New test counts bytes actually PULLED, not bytes parsed: the two were
different before, which is the whole point.
…in the dispatch path

Two CodeRabbit findings.

`Date.parse` does not reject an out-of-range day. Measured on Bun,
`2026-02-30T00:00:00Z` yields March 2 and `2026-04-31T00:00:00Z` yields
May 1, so a malformed upstream body parked a key past the instant it
actually named. Only the month was caught (`2026-13-01` is NaN).
Validated on the date text rather than by round-tripping the parsed
instant, because a value carrying an explicit offset legitimately lands
on a different UTC day than the one written. Century leap rule included:
2000 is a leap year, 2100 is not.

The other finding is that nothing exercised the wiring. The unit tests
covered parseQuotaResetAt and readQuotaResetAt in isolation, so a change
that dropped quotaResetAt before rotateProviderTransportOn429, or flipped
the precedence against Retry-After, kept every test green while the key
came back after the header's 30s and took the same 429 again. Added an
end-to-end test that serves a 429 carrying both a Retry-After and a body
date and asserts the failed key is parked to the body's instant.

Co-authored-by: Abhishek Sharma <abhicse24@gmail.com>
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 17, 2026 23:34
@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 27 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 95adcdc5-1217-4518-b76d-739f77f43060

📥 Commits

Reviewing files that changed from the base of the PR and between ad9eab3 and 613e224.

📒 Files selected for processing (7)
  • scripts/test-layout/layout.json
  • src/providers/key-failover.ts
  • src/server/responses/adapter-dispatch.ts
  • structure/transports/responses.md
  • tests/fixtures/test-layout-expected.json
  • tests/providers/openrouter-quota-reset-cooldown-4024.test.ts
  • tests/server/server-key-failover-e2e.test.ts

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.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-17T23:39:35.376092Z 316cf38 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 76 / 80

이 PR은 OpenRouter 무료 티어가 주간/월간 한도를 다 썼을 때, OCX가 키를 너무 짧게만 식혔다가 다시 집어 들어 같은 429를 반복하는 문제를 고칩니다. 지금 dev(61ee64747, tip #4948, package 2.59.0)의 src/providers/key-failover.ts는 429 쿨다운을 Retry-After 헤더(없으면 기본 60초, 상한 10분)로만 잡습니다. 그런데 OpenRouter 쿼타 고갈은 날짜를 헤더가 아니라 본문(Weekly/Monthly Limit Exhausted ... will reset at <날짜>)에 넣고, Retry-After는 안 보냅니다. 그래서 키는 10분마다 다시 살아나고, 또 429를 먹고, 또 돌아가며 한도 창이 끝날 때까지 같은 세금을 냅니다. 이게 #4024의 키 주차 절반입니다.

원본은 기여자 @abhisheksharma2411#4733(초안)입니다. 그 PR은 아이디어는 맞았지만, 본문을 읽을 때 상한 없이 clone().text()에 기대거나 스트림을 제대로 닫지 않는 등 안전 문제가 기록되어 있었습니다. 이번 #4980은 그 결함을 고친 메인테이너 캐리입니다. readQuotaResetAt은 4 KiB만 훑고, 경계 청크 넘친 바이트는 다시 재생하며, 클라이언트 abort와 250ms 기한으로 peek를 끊고, 타임아웃·실패·취소마다 reader를 cancel/release합니다. parseQuotaResetAt은 OpenRouter 쿼타 JSON 모양(rate_limit_error 또는 숫자 429 + Weekly/Monthly Limit Exhausted)만 날짜로 인정하고, 다른 제공자 문장은 기존 Retry-After 경로로 둡니다. 월간 창을 위해 상한은 32일(이전 8일 클램프가 월간을 일찍 깨던 함정)이고, 적대적 2999-01-01 같은 값은 그 상한으로 자릅니다. 달력에 없는 날(2/30 등)은 Date.parse가 롤오버하는 함정을 isRealCalendarDate로 거절합니다.

배선은 src/server/responses/adapter-dispatch.ts의 키풀 429 루프에 quotaResetAt을 넘기는 쪽입니다. 단위 테스트(tests/providers/openrouter-quota-reset-cooldown-4024.test.ts)와 e2e(tests/server/server-key-failover-e2e.test.ts)가 본문 날짜가 헤더 30초를 이기고, peek 중 취소 시 회전·영속이 안 일어남을 잡고, test-layout SSOT도 새 파일을 넣었습니다. docs는 structure/transports/responses.md에 peek 계약을 적었습니다. #4024는 일부만 다루므로 Closes를 의도적으로 빼 두었고(콤보 타깃·설정 손잡이는 밖), 원본 #4733은 머지 후 landed-via로 정리할 대상입니다.

라인 - src/server/chat-native.tsrotateProviderTransportOn429 호출과 adapter-continuation.ts / sidecar-execution.ts 같은 다른 429 회전 자리에는 아직 readQuotaResetAt peek가 없다. Responses 본선만 고치면 Codex 주 경로엔 충분할 수 있지만, Chat Completions·continuation·sidecar로 OpenRouter 키풀을 쓰는 경우엔 여전히 본문 날짜를 못 읽고 짧은 쿨다운으로 돌아간다.

키-failover.ts 중복 JSDoc - parseQuotaResetAt용으로 보이는 설명 블록이 isRealCalendarDate 바로 위에 한 번 더 남아 있다. 동작엔 영향 없고 정리만 하면 된다.

테스트 레이아웃 - scripts/test-layout/layout.jsontests/fixtures/test-layout-expected.jsonopenrouter-quota-reset-cooldown-4024.test.ts가 providers로 들어가 있어 SSOT는 맞다.

메인테이너의 판단이 필요한 지점

  • chat-native / adapter-continuation / sidecar에도 같은 peek를 지금 넣을지, Responses 본선만으로 [Feature]: OpenRouter — automatic key rotation & model failover when the free-tier quota is exhausted #4024 키 절반을 닫을지
  • 머지 후 원본 #4733에 Landed via #4980 at <commit> + landed-via-maintainer로 닫을지(초안·Closes #4024를 그대로 두면 이슈가 잘못 닫힐 수 있음)
  • #4024를 키 주차만으로 부분 닫을지, 콤보·설정 손잡이까지 남을 때까지 열어 둘지(본 PR 본문은 후자)

너의 추천
CI(지금 QUEUED, mergeable blocked)가 초록이면 머지해도 된다. 캐리 품질은 원본 #4733의 스트림 안전 결함을 실제로 고쳤고, 스코프도 #4024를 과하게 닫지 않는다. 머지 직후 #4733을 landed-via로 정리하고, chat-native 등 나머지 회전 자리는 후속 PR로 남겨도 된다. #4024는 열어 둔 채 Refs만 유지하는 쪽이 본문 의도와 맞다.

이 댓글은 grok-bot이 작성했습니다

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 316cf3800d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +87 to +90
export async function readQuotaResetAt(
response: Response,
nowOrOptions: number | QuotaResetReadOptions = {},
): Promise<{ at: number | undefined; response: Response }> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Update every owner document for the changed source areas

This changes both src/providers/ and src/server/, but the commit updates only structure/transports/responses.md; structure/INDEX.md maps src/providers/ to four documents and src/server/ to multiple additional documents. The repository’s ownership contract requires every document listed for a changed area to be updated in the same change, so update the remaining mapped documents or narrow the manifest ownership where those documents do not actually describe this behavior.

AGENTS.md reference: structure/AGENTS.md:L44-L50

Useful? React with 👍 / 👎.

Comment on lines +684 to +686
let peeked: Awaited<ReturnType<typeof readQuotaResetAt>>;
try {
peeked = await readQuotaResetAt(upstreamResponse, { signal: options.abortSignal });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Apply dated cooldowns to every 429 rotation path

The body peek is wired only into the initial Responses adapter-dispatch loop. An ordinary OpenRouter /v1/chat/completions request takes the native fast path and reaches the separate loop in src/server/chat-native.ts:406-413, while terminal continuations rotate in src/server/responses/adapter-continuation.ts:318-324; both still call rotateProviderTransportOn429 without reading or passing quotaResetAt. If either path receives the documented dated quota response, the failed key is parked only for Retry-After or the one-minute default and resumes the recurring 429 loop, so the body parsing should be shared by all raw-response key-rotation sites.

Useful? React with 👍 / 👎.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Sep 17, 2026

@Ingwannu Ingwannu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I verified this against exact head . The cooldown parser itself is bounded, but the behavior is incomplete at two runtime boundaries:\n\n1. Dated OpenRouter reset evidence is consumed only in the initial Responses adapter-dispatch loop. The native OpenAI Chat path in and terminal continuation rotation in still call without . Those paths therefore fall back to Retry-After/one minute and can re-enter the same recurring 429 loop. Share the bounded peek/parse behavior across every raw-response 429 rotation boundary and add focused tests for native Chat and continuation.\n2. The source change spans and , while the ownership documentation update is incomplete for the mapped areas. Reconcile the required structure owners or narrow the ownership mapping with an explicit reason.\n\nThe current hosted suite also has real failures, so this is not mergeable yet. Please re-request review after these paths and CI are corrected.

@Ingwannu
Ingwannu dismissed their stale review September 18, 2026 00:00

Replacing this review because shell quoting stripped inline code formatting from the submitted body.

@Ingwannu Ingwannu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I verified this against exact head 316cf3800d. The cooldown parser itself is bounded, but the behavior is incomplete at two runtime boundaries:

  1. Dated OpenRouter reset evidence is consumed only in the initial Responses adapter-dispatch loop. The native OpenAI Chat path in src/server/chat-native.ts and terminal continuation rotation in src/server/responses/adapter-continuation.ts still call rotateProviderTransportOn429 without quotaResetAt. Those paths therefore fall back to Retry-After/one minute and can re-enter the same recurring 429 loop. Share the bounded peek/parse behavior across every raw-response 429 rotation boundary and add focused tests for native Chat and continuation.
  2. The source change spans src/providers/ and src/server/, while the ownership documentation update is incomplete for the mapped areas. Reconcile the required structure owners or narrow the ownership mapping with an explicit reason.

The current hosted suite also has real failures, so this is not mergeable yet. Please re-request review after these paths and CI are corrected.

Implements the reviewed fixes for PR #4733 while preserving the original monthly-window and UTC contracts.

Co-authored-by: Abhishek Sharma <abhicse24@gmail.com>
@lidge-jun
lidge-jun force-pushed the codex/carry-4733-openrouter-quota-cooldown branch from 316cf38 to 613e224 Compare September 18, 2026 00:01
@lidge-jun

Copy link
Copy Markdown
Owner Author

Merging with macOS legs outstanding, and recording why rather than leaving it implicit.

At this exact head the full Linux suite (test 1/4 through 4/4), gates, storage policy, enforce-target, the docs build, and the keyring and npm-global smokes are green. The macOS legs are queued behind a saturated hosted-runner pool shared by several concurrent lanes, and the sharded macOS legs are separately known to go silent mid-suite and be cancelled at their job budget — a long-standing defect recorded with six occurrences in #4956, including two from the 2.58.0 round that were previously written off as capacity.

This change is platform-neutral, so waiting on a queue that is both saturated and known-unreliable would delay the work without adding information. The evidence that governs the release is not per-PR macOS legs; it is the full-platform lane=all dispatch at the frozen release candidate, which is held until #4956 has a named cause. Nothing is promoted on the strength of this merge.

Stating the boundary plainly: this is merged on Linux, gates and cross-platform smoke evidence at its exact head, with macOS coverage deferred to the candidate run rather than claimed here.

@lidge-jun
lidge-jun merged commit e1717d6 into dev Sep 18, 2026
27 of 29 checks passed
@lidge-jun
lidge-jun deleted the codex/carry-4733-openrouter-quota-cooldown branch September 18, 2026 00:31
@abhisheksharma2411

Copy link
Copy Markdown
Contributor

Agreeing with this carry too, and the provider-scope fix is the one I should have made myself.

Accepting dated cooldowns only from the canonical OpenRouter quota-error JSON shape is right, and I had no business matching on prose alone. parseQuotaResetAt ran a regex over any provider's 429 body and treated a date near the words "reset at" as a park-until instant. That is over-broad in the same way I spent yesterday flagging in someone else's parser on OmniRoute #13895 — a usage statement read as a ceiling — and I did not turn the question on my own code. Scoping by the error shape rather than by phrasing removes the class, not just the instance.

The stream-safety fixes are the half I got genuinely wrong. I bounded the parse and then claimed the read was bounded, and when I did bound the read I still left the boundary-chunk overflow unreplayed and the reader unreleased on some exits. Replaying the overflow, bounding the peek with cancellation and a deadline, and releasing or cancelling on every exit is the correct shape for reading a prefix of an upstream-controlled stream on the rotation path. The rotation-and-persistence-after-abort ordering is a real hazard I hadn't considered at all.

Two things I'd flag, neither a disagreement:

1. Durability is still unstated in-product. Cooldowns live in keyCooldowns (process memory), so a 32-day park evaporates on restart or in a second instance. At the old 10-minute cap losing it cost one wasted request; at 32 days the operator expectation — "this key is parked for the month" — is not what the process guarantees. Not a blocker and not something this PR should take on, but worth an issue so it is tracked rather than implied. Happy to open it against the #4024/#3376 durable-quota thread unless you would rather fold it in.

2. Dropping the closing reference was the right call given this is only the key-parking half. Worth a line on #4024 saying "keys landed, combo targets open" so the issue does not read as untouched.

No objection to anything on top of my work. Thanks for carrying it rather than leaving it to rot on a round trip — and for the attribution.

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

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants