Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions devlog/_plan/261012_6991_6989_anthropic_pool_quota/010_plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# 010 — Anthropic pool quota exhaustion: terminal usage limit (#6991) and fresh-read recovery (#6989)

Base: origin/dev dc45bb2ca9. Branch: codex/6989-6991-anthropic-pool-quota-261012.

## Evidence on dev

- #6991. Local pool refusals become a retryable 429 `rate_limit_error` / `rate_limit_exceeded` at four sites:
`src/server/responses/request-transport.ts:786-793` (pool-enabled initial selection, `all-cooled`),
`src/server/responses/adapter-dispatch.ts:528-530` and `:788-790` (`AnthropicAccountCooldownError` after pacing / on the recovery leg),
`src/server/responses/passthrough-dispatch.ts:951-953`. `formatErrorResponse` (`src/bridge/errors.ts:19`) classifies every 429 as
`rate_limit_exceeded` (`src/lib/errors.ts:448-458`) and has no slot for `resets_at`. The first exhausted request returns the upstream
429 as `Provider error 429: ...` with `rate_limit_exceeded` (`adapter-dispatch.ts:1679-1722`) after `rotateAnthropicAccountOnRefusal`
finds no replacement (`src/oauth/anthropic-routing.ts:1062-1073`).
- Codex (openai/codex 6c372fbe, `codex-api/src/api_bridge.rs:181-218`) treats HTTP 429 with `error.type == "usage_limit_reached"` as
non-retryable `UsageLimitReached` and reads `resets_at` as integer epoch seconds; a wrapped WebSocket `error` frame reuses the same
classifier (`responses_websocket.rs:591-662`). OpenCodex's WS bridge keeps the nested error object (`src/server/ws-bridge.ts:369-403`).
Without `plan_type` Codex prints its generic usage-limit copy; a ChatGPT plan type would print ChatGPT upgrade copy, so none is sent.
- #6989. `captureAnthropicCooldownRecovery` and `settleAnthropicCooldownRecovery` (`anthropic-routing.ts:379-412`) only claim
`cooldownSource === "reset-derived"`. A shared-quota 429 that carries Retry-After records source `retry-after` (`:1131-1141`), so a fresh
usage read showing headroom never lifts it. #6953 already records `refusedQuotaWindows` for every 429 regardless of deadline source and
its reset-grant admission documents this gap (`src/providers/anthropic-reset-admission.ts:4-8`).

## Classification rule (#6991)

`recordAnthropicAccountRefusal` only records a 429 health entry for a `shared-quota` classification; transient throttles become admission
pauses and family-only rejections become family evidence. For each account the pool would otherwise consider (same filter as
`getAnthropicPoolRetryAfterSeconds`, including route/fallback expansion), collect its blocks:

| Block | Until | Quota? |
|---|---|---|
| live health entry, refusalStatus 429 | cooldownUntil | yes (shared-quota by construction, any deadline source) |
| live health entry, refusalStatus 403 | cooldownUntil | no (entitlement) |
| admission pause (transient Retry-After) | pause until | no |
| family exclusion, not revalidating | family retry time | yes |
| family exclusion, revalidation in flight (busy) | now + 1 s | no |

The account is quota-held when its latest block is a quota block (ties favor transient). The pool answers `usage_limit_reached` only when
no route candidate is eligible and every blocked account is quota-held. `resets_at` (epoch seconds) is computed apart from the
admission order: per account, the latest upstream window reset over all its quota holds (unknown if any rejected window lacks a
reset header or a family has no reset), then the earliest across accounts, omitted when any held account is unknown. Retry-After
stays the admission deadline. Anything else keeps today's `rate_limit_error` / `rate_limit_exceeded` with Retry-After.

## Recovery rule (#6989)

A cooldown is claimable by a fresh usage read when it is live, `refusalStatus === 429`, and either source is `reset-derived` (unchanged) or
the refusal named at least one rejected window (`refusedQuotaWindows`). Settlement requires the same generation, `quota.updatedAt >=
claimedAt` (unchanged stale-read fence) and `percent < 100` for every window in `rejectedQuotaWindows` if present, else
`refusedQuotaWindows`. 403 refusals and windowless non-reset-derived 429s are never claimed; a reset-derived aggregate refusal stays
claimable as before but is always retained. Claimed probes already force a fresh dispatch
(`requiresFreshDispatch`), so a cached or joined older read cannot settle.

## Changes (as implemented)

1. `src/oauth/anthropic-routing.ts`: `AnthropicAccountCooldownError` gains `usageLimit: { resetsAt: number | null } | null` and
`retryableMessage`; its message names the usage limit and, when known, the ISO reset. 429 health entries record `quotaResetAt`
via `rejectedWindowsResetAt` (only when every rejected window carries a reset). `anthropicAccountHold` returns the admission winner
plus the account's quota reset computed over all quota holds. `anthropicPoolCooldownError(now, decision, model)` builds the refusal;
`getAnthropicPoolRetryAfterSeconds` delegates to it; `resolveAnthropicDispatchAccountId` throws it.
`anthropicPoolUsageLimitAfterRefusal` returns it only for a usage limit. Recovery claim/settle widened as above.
2. `src/oauth/anthropic-model-quota.ts`: `anthropicFamilyHoldState` (revalidating flag and family window reset).
3. `src/bridge/errors.ts`: `formatUsageLimitReachedResponse(message, resetsAtMs | null, retryAfterSeconds)`.
4. `src/server/responses/core-errors.ts`: `anthropicPoolCooldownResponse(refusal, { comboAttempt, logCtx })` — usage limit for direct
requests (sets `logCtx.errorCode`), the old `rate_limit_error` body and wording for combo children.
5. Sites: `request-transport.ts` pool-enabled `all-cooled` refusal; both `AnthropicAccountCooldownError` catches in
`adapter-dispatch.ts`; the passthrough catch; the final non-combo upstream 429 in `adapter-dispatch.ts`; the first-iteration 429 of a
routed web-search loop in `sidecar-execution.ts`.
6. Native Messages keeps Anthropic `rate_limit_error`; its message carries the usage-limit wording.
7. Docs: `src/providers/anthropic-reset-admission.ts` comment, `structure/providers/anthropic-account-pool.md` (new section),
`structure/transports/responses-failover.md`, docs-site `reference/configuration/providers.md`.

## Boundaries (unchanged identity)

Image bridge terminal, opt-in empty-completion continuation, Chat Completions translation, combo final-error identity
(`core-combo-failure.ts`), Codex-account cooldown path (`src/codex/auth-context.ts`), dashboard reason label for retry-after quota
cooldowns (`src/oauth/health.ts:224`). #6962 shares `adapter-dispatch.ts`, `request-transport.ts` and `messages-native.ts` but not the
edited statements; rerun billing tests on the union once it lands.

## Tests

- `tests/adapters/anthropic/anthropic-pool-usage-limit.test.ts` (12): quota-held pool answers `usage_limit_reached` with `resets_at`
(pool on and off); unequal Retry-After/reset; unknown reset omits `resets_at`; partial reset headers; reset independent of admission
winner; transient throttle and 403 keep `rate_limit_exceeded`; last upstream refusal answers the usage limit; throttled sibling keeps
the provider rate limit; family quota vs revalidation; routed web-search loop; combo child body byte-identical; no account material
in bodies or warn logs.
- `tests/adapters/anthropic/anthropic-quota-cooldown-recovery.test.ts` (11): Retry-After quota cooldown lifted by a fresh read (both
instances); partial window retained; stale and superseded claims; default-source recovery; 403 and windowless never claimed;
aggregate reset-derived retained; read started before a refusal cannot lift it; instance isolation; tie with a transient pause.
- Updated: `anthropic-cooldown-recovery.test.ts` (named-window Retry-After/default now clear; windowless/403 do not),
`anthropic-model-routes.test.ts` (helper-named spent window answers `usage_limit_reached`).

## Verification

`bun run typecheck`, `bun test --isolate` over `tests/adapters/anthropic/`, `tests/oauth/`, layout/lab/core-module/reset-grant files,
`bun run test:changed`, `bun run structure:check`, `bun run privacy:scan`, `git diff --check`.

## Independent audit (GPT-6.1-Sol, builder Claude)

- r1 FAIL: reset vs admission deadline, combo prose, race tests, surfaces, docs — folded except image/continuation/Chat (boundary).
- r2 FAIL: partial reset headers, reset vs admission winner, routed web-search terminal, stale test expectation, plan text — all folded.
- r3 NEAR-PASS: no runtime defect; low finding on this document's stale text, fixed here.
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,10 @@ rotation automatique peut déclencher des restrictions du fournisseur.
Seul un 429 confirmant le rejet d'un quota partagé de cinq heures ou hebdomadaire déclenche la temporisation et le basculement du compte. Une limitation temporaire suspend son admission en conservant l'affinité : au plus un bref nouvel essai sur le même compte et un détour vers un autre compte admissible par requête. Sans en-tête probant, un 429 permet un seul bref nouvel essai sur le même compte, sans refroidir le compte ni inventer Retry-After. Le comportement par défaut avec un seul compte reste identique. Un rejet propre à Fable laisse Sonnet admissible ; choix manuel et affinité vérifient aussi les quotas partagés et ceux de la famille demandée. Les observations passives de famille expirent après trente minutes ou à leur réinitialisation connue et sont revalidées par une seule requête à la fois. Les seuils restent des préférences souples avec le repli existant lorsque tous les candidats sont épuisés, sans plafond strict d'utilisation ou de facturation. L'affinité est locale au processus et de taille bornée. Les erreurs de renouvellement du jeton suivent les règles de réauthentification existantes. Un 403 confirmé lié à un abonnement ou à la facturation du compte permet un basculement avant la sortie, avec une temporisation selon `Retry-After` ou de dix minutes par défaut. Un refus d’autorisation ordinaire ne déclenche pas de basculement. Si tous les comptes admissibles sont en temporisation, les clients reçoivent un 429 accompagné de
`Retry-After` lorsqu'il est connu, et non une erreur d'authentification.

Lorsque chaque compte utilisable par la requête est retenu sur un quota épuisé (une fenêtre partagée de cinq heures ou hebdomadaire, ou la fenêtre hebdomadaire de la famille du modèle demandé), ce 429 est une limite d'usage et non une limite de débit : le corps Responses porte `error.type` et `error.code` `usage_limit_reached` et, si la réinitialisation amont de chaque compte retenu est connue, `resets_at` (secondes epoch de la plus proche). Codex s'arrête alors et indique quand réessayer au lieu de réessayer jusqu'à « exceeded retry limit ». Le dernier refus amont pour quota épuisé reçoit la même réponse quand aucun remplaçant ne reste. Si un compte retenu est seulement limité en débit, revalide une famille de modèles ou est refusé pour droits d'accès, la réponse reste `rate_limit_exceeded`, réessayable. Les cibles de combo gardent la forme de limite de débit pour passer à la cible suivante. `/v1/messages` natif garde le type `rate_limit_error` d'Anthropic, avec un message qui nomme la limite d'usage.

Une temporisation de quota se lève plus tôt dès qu'une lecture d'usage récente (page Fournisseurs du tableau de bord, ou `ocx account list anthropic --quota --refresh`) montre sous 100 % chaque fenêtre rejetée, qu'elle vienne de `Retry-After` ou d'une réinitialisation de fenêtre. Cela couvre une montée d'offre ou une fenêtre rouverte plus tôt. Une lecture antérieure au refus ne la lève jamais, et les temporisations 403 de droits d'accès attendent leur échéance.

:::caution[Expérimental]
Laissez cette option désactivée, sauf si vous comprenez les risques liés aux règles d'Anthropic concernant les comptes. En cas de doute,
préférez le changement manuel avec `ocx account use anthropic <id>`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,10 @@ affinity を維持します。これらの戦略は provider enforcement を回

共有の5時間・週次クォータが拒否された429だけがアカウントのクールダウンと切り替えを行います。一時的な速度制限はアフィニティを維持して送信受付だけを停止し、リクエストごとに短い同一アカウント再試行1回と別の対象アカウントへの切り替え1回までです。根拠ヘッダーのない429は同一アカウントで1回だけ再試行し、アカウントを冷却せず、Retry-Afterも生成しません。既定の単一アカウント動作は変わりません。Fable専用の拒否はSonnetを制限しません。手動選択とアフィニティもリクエストモデルの共有・ファミリークォータを確認します。パッシブなファミリー情報は30分または既知のリセットで期限切れとなり、1つの送信リクエストで再確認します。使用量しきい値はソフトな優先設定で、全候補消耗時のフォールバックを維持します。使用量・課金のハード上限ではありません。アフィニティはプロセスローカルであり、サイズ制限があります。トークン更新の失敗は既存の再認証ルールに従います。確認済みの契約・アカウント請求の 403 は出力前に別アカウントへ切り替え、`Retry-After` または既定の10分間のクールダウンを記録します。一般的な権限拒否では切り替えません。すべての対象となるアカウントが冷却されている場合、クライアントは、既知の場合、認証エラーではなく、`Retry-After` を含む 429 を受け取ります。

リクエストが使えるすべてのアカウントが使い切ったクォータ(共有の5時間・週間ウィンドウ、または要求モデルのファミリー週間ウィンドウ)で保留されている場合、その 429 はレート制限ではなく使用量上限です。Responses の本文は `error.type` と `error.code` に `usage_limit_reached` を持ち、保留中の全アカウントの上流リセット時刻が分かる場合は `resets_at`(最も早いリセットのエポック秒)を含みます。Codex は「exceeded retry limit」まで再試行せず、いつ再試行できるかを表示して停止します。最後のアカウントがクォータ切れで拒否し、代替が残っていない場合も同じ応答になります。保留中のアカウントのいずれかが一時的なスロットリング、モデルファミリーの再検証中、または権限による拒否である場合は、再試行可能な `rate_limit_exceeded` のままです。Combo ターゲットは次のターゲットへ進めるようレート制限の形を保ちます。ネイティブ `/v1/messages` は Anthropic の `rate_limit_error` 型を保ち、メッセージで使用量上限を示します。

新しい使用量の読み取り(ダッシュボードのプロバイダーページ、または `ocx account list anthropic --quota --refresh`)で、拒否された各ウィンドウが 100% 未満と分かれば、クォータのクールダウンは `Retry-After` 由来でもウィンドウのリセット由来でも早期に解除されます。プランのアップグレードや早期のウィンドウ再開がこれに当たります。拒否より前の読み取りでは解除されず、権限による 403 のクールダウンは期限まで待ちます。

:::caution[実験的]
Anthropic アカウント ポリシーのリスクを理解していない限り、これは無効のままにしてください。不明な場合は、`ocx account use anthropic <id>` を手動で切り替えることをお勧めします。
:::
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,10 @@ affinity 초기화 뒤의 기존 작업도 포함될 수 있습니다. 출력

공유 5시간/주간 할당량의 거부가 확인된 429만 계정 쿨다운과 전환을 유발합니다. 일시적인 속도 제한은 affinity를 유지하며 해당 계정의 전송만 잠시 중지합니다. 요청마다 짧은 동일 계정 재시도 한 번과 적격한 다른 계정 전환 한 번까지만 허용됩니다. 근거 헤더가 없는 429는 동일 계정에서 한 번만 짧게 재시도하며 계정 상태를 냉각하지 않고 Retry-After를 만들어 붙이지 않습니다. 기본 단일 계정 동작은 유지됩니다. Fable 전용 거부는 Sonnet을 제한하지 않습니다. 수동 선택과 affinity도 요청 모델의 공유/계열 할당량을 확인합니다. 수동 조회가 아닌 응답 헤더의 계열 근거는 30분 또는 알려진 리셋 시각에 만료되며 요청 하나로 재확인합니다. 사용량 임계값은 소프트 선호이고 모든 후보가 소진되면 기존 폴백을 유지합니다. 사용량 또는 결제 하드 캡이 아닙니다. 결속은 프로세스 로컬이며 크기가 제한됩니다. 토큰 갱신 실패는 기존 재인증 규칙을 따릅니다. 확인된 구독 또는 계정 결제 403은 출력 전에 다른 계정으로 전환하며 `Retry-After` 또는 기본 10분 동안 냉각합니다. 일반 권한 거부는 전환하지 않습니다. 적격한 계정이 모두 쿨다운 중이면, 클라이언트는 인증 오류가 아니라 알려진 경우 `Retry-After`가 포함된 429를 받습니다.

요청이 쓸 수 있는 모든 계정이 소진된 할당량(공유 5시간·주간 창, 또는 요청 모델 계열의 주간 창)으로 묶여 있으면 그 429는 속도 제한이 아니라 사용량 한도입니다. Responses 본문의 `error.type`과 `error.code`는 `usage_limit_reached`이고, 묶인 모든 계정의 업스트림 리셋 시각을 알면 `resets_at`(가장 이른 리셋의 epoch 초)도 담습니다. Codex는 "exceeded retry limit"까지 재시도하지 않고 언제 다시 시도할지 보여 주며 멈춥니다. 마지막 계정이 할당량 소진으로 거부하고 대체 계정이 없을 때도 같은 응답을 받습니다. 묶인 계정 중 하나라도 일시적 속도 제한, 모델 계열 재확인 중, 권한 거부 상태라면 재시도 가능한 `rate_limit_exceeded`를 유지합니다. Combo 대상은 다음 대상으로 넘어갈 수 있도록 속도 제한 형식을 유지합니다. 네이티브 `/v1/messages`는 Anthropic의 `rate_limit_error` 타입을 유지하고 메시지에서 사용량 한도를 알립니다.

새 사용량 조회(대시보드 프로바이더 페이지 또는 `ocx account list anthropic --quota --refresh`)에서 거부된 모든 창이 100% 미만으로 나오면, 할당량 쿨다운은 `Retry-After`에서 왔든 창 리셋에서 왔든 일찍 풀립니다. 플랜 업그레이드나 창이 일찍 다시 열린 경우가 여기에 해당합니다. 거부 이전에 시작된 조회는 쿨다운을 풀지 않으며, 권한 403 쿨다운은 기한까지 기다립니다.

:::caution[실험적 기능]
Anthropic 계정 정책 위험을 이해하지 못한다면 이 기능은 꺼두십시오. 확신이 없으면 수동 `ocx account use anthropic <id>` 전환을 우선하십시오.
:::
Expand Down
Loading
Loading