From 3a6e600edae61c429152b2d55d1906048bf658af Mon Sep 17 00:00:00 2001 From: Beda Schmid Date: Thu, 27 Aug 2026 19:51:38 -0300 Subject: [PATCH] fix(codex): support keyring-managed native passthrough --- .../docs/fr/guides/codex-integration.md | 4 +- .../content/docs/fr/reference/architecture.md | 2 +- .../docs/fr/reference/proxy-formats.md | 9 +- .../content/docs/guides/codex-integration.md | 15 +- .../docs/ja/guides/codex-integration.md | 2 +- .../content/docs/ja/reference/architecture.md | 2 +- .../docs/ja/reference/proxy-formats.md | 4 +- .../docs/ko/guides/codex-integration.md | 2 +- .../content/docs/ko/reference/architecture.md | 8 +- .../docs/ko/reference/proxy-formats.md | 7 +- .../content/docs/reference/architecture.md | 8 +- .../content/docs/reference/proxy-formats.md | 9 +- .../docs/ru/guides/codex-integration.md | 4 +- .../content/docs/ru/reference/architecture.md | 9 +- .../docs/ru/reference/proxy-formats.md | 9 +- .../docs/tr/guides/codex-integration.md | 4 +- .../content/docs/tr/reference/architecture.md | 11 +- .../docs/tr/reference/proxy-formats.md | 10 +- .../docs/zh-cn/guides/codex-integration.md | 4 +- .../docs/zh-cn/reference/architecture.md | 8 +- .../docs/zh-cn/reference/proxy-formats.md | 8 +- .../docs/zh-tw/guides/codex-integration.md | 2 +- .../docs/zh-tw/reference/architecture.md | 8 +- .../docs/zh-tw/reference/proxy-formats.md | 6 +- .../components/codex-account-pool-helpers.tsx | 18 +- .../codex-account-pool-main-card.tsx | 25 ++- .../ProviderCapacityQuota.tsx | 16 +- .../provider-workspace/ProviderModels.tsx | 71 ++++++- gui/src/hooks/useCodexAccountPool.ts | 2 + gui/src/i18n/de.ts | 9 +- gui/src/i18n/en.ts | 9 +- gui/src/i18n/fr.ts | 9 +- gui/src/i18n/ja.ts | 9 +- gui/src/i18n/ko.ts | 9 +- gui/src/i18n/ru.ts | 9 +- gui/src/i18n/tr.ts | 9 +- gui/src/i18n/zh-TW.ts | 9 +- gui/src/i18n/zh.ts | 9 +- gui/src/pages/Providers.tsx | 92 ++++++++- gui/src/provider-workspace/report.ts | 8 + gui/src/styles/provider-workspace-shell.css | 30 +++ .../codex-account-pool-pinned-badge.test.tsx | 38 ++++ gui/tests/provider-capacity-shell.test.tsx | 10 +- gui/tests/provider-capacity.test.ts | 8 + gui/tests/provider-model-custom-add.test.tsx | 51 +++++ .../provider-revalidation-policy.test.tsx | 98 ++++++++- src/codex/account-usability.ts | 7 + src/codex/auth-api.ts | 138 ++++++++++--- src/codex/auth-context.ts | 77 +++++++- src/codex/catalog/metadata.ts | 8 +- src/codex/main-account-cache.ts | 68 ++++++- src/codex/main-account-observation.ts | 165 ++++++++++++++++ src/codex/model-entitlements.ts | 186 +++++++++++++++++- src/codex/quota.ts | 113 ++++++++++- src/codex/routing.ts | 6 +- src/providers/openai-sidecar.ts | 7 +- src/server/auth-cors.ts | 18 ++ src/server/index.ts | 18 +- src/server/management/context.ts | 3 + src/server/management/model-routes.ts | 43 +++- src/server/responses/compact.ts | 30 ++- src/server/responses/core.ts | 94 +++++++-- src/server/responses/fetch-helpers.ts | 6 +- src/server/responses/ws-upstream.ts | 29 ++- structure/04_transports-and-sidecars.md | 8 +- structure/05_gui-and-management-api.md | 9 +- structure/08_openai-provider-tiers.md | 30 ++- tests/codex-auth-api.test.ts | 48 ++++- tests/codex-auth-context.test.ts | 103 ++++++++++ tests/codex-catalog.test.ts | 43 ++++ tests/codex-main-account-observation.test.ts | 109 ++++++++++ tests/codex-model-entitlements.test.ts | 133 +++++++++++++ .../codex-retained-root-serialization.test.ts | 3 + tests/codex-routing.test.ts | 84 +++++++- tests/provider-quota.test.ts | 37 +++- tests/reasoning-replay-scope-source.test.ts | 3 +- tests/server-auth.test.ts | 104 ++++++++-- tests/ws-upstream.test.ts | 16 +- 78 files changed, 2211 insertions(+), 238 deletions(-) create mode 100644 src/codex/main-account-observation.ts create mode 100644 tests/codex-main-account-observation.test.ts diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md index 6b81c25608..090c17ff4c 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -180,8 +180,8 @@ d'abord depuis le shell d'origine. Définissez ensuite `CODEX_HOME` sur le répe En mode fournisseur dédié, `requires_openai_auth = true` maintient les surfaces de l'application et de la TUI Codex soumises à un compte, comme dans Codex natif. opencodex sert également `/v1/responses` par WebSocket. Le fournisseur dédié n'annonce `supports_websockets = true` que lorsque `"websockets": true`. Sur l'interface de -bouclage, le fournisseur intégré de Codex peut tenter WebSocket en premier ; si cette fonction est désactivée, -le proxy renvoie `426` et Codex se rabat sur HTTP/SSE. +bouclage, le fournisseur intégré de Codex peut tenter WebSocket indépendamment de cette annonce ; le proxy +accepte donc une mise à niveau valide dans les deux modes. ## Identité et historique du fil de discussion diff --git a/docs-site/src/content/docs/fr/reference/architecture.md b/docs-site/src/content/docs/fr/reference/architecture.md index f197d85c11..b0ab22a10e 100644 --- a/docs-site/src/content/docs/fr/reference/architecture.md +++ b/docs-site/src/content/docs/fr/reference/architecture.md @@ -85,7 +85,7 @@ Les implémentations OAuth se trouvent dans `oauth/`. Les jetons d’accès sont ## Transport et compactage -Par défaut, `server/index.ts` sert HTTP/SSE sur `/v1/responses`. Si Codex tente une mise à niveau WebSocket de Responses alors que `websockets` vaut `false`, opencodex renvoie `426 upgrade_required` ; Codex revient alors à HTTP pour cette session. Lorsque `"websockets": true` est défini, le même point de terminaison accepte la mise à niveau et utilise le pont WebSocket. +`server/index.ts` sert HTTP/SSE et les mises à niveau WebSocket sur `/v1/responses`. Le réglage `websockets` contrôle l’annonce de cette capacité pour les lignes routées du catalogue et des fournisseurs. Le fournisseur OpenAI intégré de Codex peut tenter une mise à niveau dès que `openai_base_url` pointe vers le proxy ; les mises à niveau valides restent donc acceptées lorsque l’annonce est désactivée. `426 upgrade_required` est réservé à un échec de mise à niveau. Indépendamment de ce réglage côté client, les requêtes canoniques transmises à ChatGPT avec `stream: true` à la racine peuvent utiliser le transport WebSocket en amont de Codex avec une version stable de Bun 1.4.0 ou ultérieure. La version intégrée Bun 1.3.14, les préversions et les identités de runtime impossibles à vérifier utilisent HTTP/SSE. Les réponses WS en amont qui réussissent conservent le contrat SSE en aval et contournent `tee()` au moyen d’un relais borné à lecteur unique et avide (4 MiB par trame brute/enveloppée et une file de production de 8 MiB). Le dépassement de la file ferme la connexion en amont et émet en aval un événement terminal `response.failed`, suivi de `[DONE]`. diff --git a/docs-site/src/content/docs/fr/reference/proxy-formats.md b/docs-site/src/content/docs/fr/reference/proxy-formats.md index 4886a05926..149c3909b0 100644 --- a/docs-site/src/content/docs/fr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/fr/reference/proxy-formats.md @@ -143,9 +143,10 @@ Une trame d'échauffement avec `generate: false` n'appelle pas d'amont. Il renvo `response.created` suivi de `response.completed`, tous deux avec un identifiant de réponse vide et aucune sortie. :::note -Lorsque les WebSockets sont désactivés, une tentative de mise à niveau reçoit HTTP 426 avec le code -`upgrade_required`. Codex traite ce résultat de poignée de main comme un signal de retour à HTTP pour le -session. Il ne s’agit pas d’un échec du modèle routé. +Lorsque l’annonce WebSocket est désactivée, les lignes routées du catalogue omettent l’indicateur de +capacité. Le fournisseur OpenAI intégré peut néanmoins tenter une mise à niveau après le remplacement +de `openai_base_url` ; le proxy accepte donc les mises à niveau valides dans les deux modes. HTTP 426 +est réservé à une mise à niveau qui ne peut pas être effectuée. ::: ## `POST /v1/chat/completions` @@ -295,7 +296,7 @@ Les erreurs utilisent l'enveloppe du dialecte client lorsque cela est nécessair | 403 | `origin_rejected` | Une demande de plan de données Réponses/OpenAI ou une mise à niveau WebSocket provient d'une origine non autorisée | | 503 | `combo_unavailable` | Chaque cible du combo sélectionné est indisponible, en temps de recharge, désactivée ou autrement inéligible | | 400 | `unreadable_encrypted_agent_task` | Une tâche de travail v2 chiffrée n'a pas de cible native éligible pouvant la consommer | -| 426 | `upgrade_required` | Le transport Réponses WebSocket est désactivé ou la mise à niveau a échoué ; utiliser HTTP | +| 426 | `upgrade_required` | Une mise à niveau WebSocket de Responses n’a pas pu être effectuée ; utiliser HTTP | Les échecs d'origine Anthropic sont restitués dans l'enveloppe d'erreur de Anthropic, donc le rejet d'origine est un 403 `permission_error` sur ce dialecte plutôt que sur le corps `origin_rejected` de style OpenAI. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 1209f96b44..781b8c8f9a 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -12,6 +12,17 @@ plus `openai-apikey/` for the configured API key. Pool includes main plus Direct uses only the caller/main bearer. The routes do not fall back to one another. Shipped v1 configs migrate to marker 2 and preserve `config.json.pre-openai-tiers-v2.bak` for manual restore. +Codex may keep its main ChatGPT login in the operating-system keyring instead of +`$CODEX_HOME/auth.json`. OpenCodex does not inspect that keyring or launch Codex to infer login +presence. Before the first successful request through the proxy, the dashboard therefore explains +that account type and quota are not available yet instead of claiming either authentication or +sign-out. In Pool mode, a native Codex request uses the caller-owned bearer already attached to that +request for the main account. After a successful upstream response, OpenCodex keeps only non-secret +display metadata and parsed quota values in memory. It can make one bounded, read-only usage lookup +with that request-scoped bearer to learn fields absent from response headers, such as reset-credit +count, but it never retains the credential. Reset-credit actions still require a file-backed main +credential. Other clients still need a file-backed main credential or an added Pool account. + ## Config injection `ocx init`, `ocx start`, and `ocx sync` call the injector. On the default loopback bind, it keeps @@ -173,8 +184,8 @@ home, unset `ORCA_CODEX_HOME`, rerun sync/restore, and install the service again In dedicated-provider mode, `requires_openai_auth = true` keeps Codex App/TUI account-gated surfaces aligned with native Codex. opencodex also serves `/v1/responses` over WebSocket. The dedicated provider advertises `supports_websockets = true` only when `"websockets": true`; on loopback Codex's -built-in provider may try WebSocket first, and a disabled proxy returns `426` so Codex falls back to -HTTP/SSE. +built-in provider may try WebSocket regardless of that advertisement, so the proxy accepts a valid +upgrade in either mode. ## Thread identity and history diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index a4e7816b27..8584f9f986 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -112,7 +112,7 @@ WSL では、`CODEX_HOME` が設定されておらず、Linux `~/.codex/config.t Windows では、ChatGPT/Codex アプリが `%USERPROFILE%\\.codex` を読み取りながら、Orca シェルは `CODEX_HOME` と `ORCA_CODEX_HOME` の両方を Orca のバンドルされたランタイム ホームに設定できます。 `ocx status` および `ocx doctor` は、この正確な不一致について警告し、編集されたターゲット パスを出力します。バックグラウンド サービスが Orca シェルからインストールされている場合は、最初に元のシェルからアンインストールし、次に `CODEX_HOME` をアプリ ホームに設定し、`ORCA_CODEX_HOME` の設定を解除し、同期/復元を再実行して、サービスを再度インストールします。 -専用プロバイダー モードでは、`requires_openai_auth = true` は Codex App/TUI アカウント ゲート サーフェスをネイティブ Codex と一致させます。 opencodex は WebSocket 経由で `/v1/responses` も提供します。専用プロバイダーは、`"websockets": true` の場合にのみ `supports_websockets = true` をアドバタイズします。ループバック時 Codex の組み込みプロバイダーは最初に WebSocket を試行し、無効になったプロキシが `426` を返すため、Codex は HTTP/SSE にフォールバックします。 +専用プロバイダー モードでは、`requires_openai_auth = true` は Codex App/TUI アカウント ゲート サーフェスをネイティブ Codex と一致させます。opencodex は WebSocket 経由で `/v1/responses` も提供します。専用プロバイダーは、`"websockets": true` の場合にのみ `supports_websockets = true` をアドバタイズしますが、ループバックでは Codex の組み込みプロバイダーがその通知に関係なく WebSocket を試行することがあります。そのため、プロキシはどちらのモードでも有効なアップグレードを受け入れます。 ## スレッドのアイデンティティと履歴 diff --git a/docs-site/src/content/docs/ja/reference/architecture.md b/docs-site/src/content/docs/ja/reference/architecture.md index cd2ce3db56..225248a74e 100644 --- a/docs-site/src/content/docs/ja/reference/architecture.md +++ b/docs-site/src/content/docs/ja/reference/architecture.md @@ -95,7 +95,7 @@ HTTP の境界は `server/index.ts` が担い、Responses データプレーン ## 伝送と compaction -`server/index.ts` はデフォルトで `/v1/responses` を HTTP/SSE で提供します。`websockets` が `false` の状態で Codex が Responses WebSocket アップグレードを試みると、opencodex は `426 upgrade_required` を返し、Codex はそのセッションで HTTP にフォールバックします。`"websockets": true` を設定すると同じエンドポイントがアップグレードを受け入れ WebSocket ブリッジを使います。 +`server/index.ts` は `/v1/responses` で HTTP/SSE と WebSocket アップグレードを提供します。`websockets` 設定は、ルーティングされたカタログおよびプロバイダー行での機能通知を制御します。Codex の組み込み OpenAI プロバイダーは `openai_base_url` がプロキシを指すとアップグレードを試行することがあるため、通知が無効でも有効なアップグレードは受け入れられます。`426 upgrade_required` はアップグレードに失敗した場合にのみ使用されます。 Codex コンテキスト compaction はルーティングされたモデルでも動作します。`server/responses/compact.ts` は `POST /v1/responses/compact` を内部ルーティング要約ターンとして扱い、圧縮されたヒストリーを返します。 diff --git a/docs-site/src/content/docs/ja/reference/proxy-formats.md b/docs-site/src/content/docs/ja/reference/proxy-formats.md index ff329cf29f..c8ee1299e6 100644 --- a/docs-site/src/content/docs/ja/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ja/reference/proxy-formats.md @@ -108,7 +108,7 @@ provider events → internal adapter events → client dialect `generate: false` のウォームアップ フレームはアップストリームを呼び出しません。これは、合成 `response.created` に続いて `response.completed` を返します。両方とも空の応答 ID を持ち、出力はありません。 :::note -WebSocket が無効になっている場合、アップグレード試行ではコード `upgrade_required` の HTTP 426 を受信します。 Codex は、そのハンドシェイクの結果を、セッションの HTTP にフォールバックする信号として扱います。失敗したモデルターンではありません。 +WebSocket の通知が無効な場合、ルーティングされたカタログ行では機能フラグが省略されます。ただし、`openai_base_url` の上書き後は組み込み OpenAI プロバイダーがアップグレードを試行することがあるため、プロキシはどちらのモードでも有効なアップグレードを受け入れます。HTTP 426 はアップグレードを完了できない場合にのみ使用されます。 ::: ## `POST /v1/chat/completions` @@ -214,7 +214,7 @@ Responses-family および Chat リクエストは、プロバイダーまたは | 403 | `origin_rejected` | Responses/OpenAI データプレーン リクエストまたは WebSocket アップグレードが、許可されていないオリジンから送信されました。 | 503 | `combo_unavailable` |選択したコンボ内のすべてのターゲットは使用不可、クールダウン中、無効、またはその他の理由で不適格です。 | 400 | `unreadable_encrypted_agent_task` |暗号化された v2 ワーカー タスクには、それを使用できる適格なネイティブ ChatGPT ターゲットがありません。 -| 426 | `upgrade_required` |応答 WebSocket トランスポートが無効になっているか、アップグレードが失敗しました。 HTTP を使用する | +| 426 | `upgrade_required` | Responses WebSocket のアップグレードを完了できませんでした。HTTP を使用してください | Anthropic オリジンの失敗は Anthropic のエラー エンベロープでレンダリングされるため、オリジンの拒否は OpenAI スタイルの `origin_rejected` 本体ではなく、その方言上の 403 `permission_error` になります。 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 5c008ff1a8..de512671a0 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -104,7 +104,7 @@ WSL에서는 `CODEX_HOME`이 비어 있고 Linux `~/.codex/config.toml`도 없 Windows에서 Orca shell은 `CODEX_HOME`과 `ORCA_CODEX_HOME`을 Orca의 번들 런타임 home으로 설정할 수 있지만, ChatGPT/Codex app은 여전히 `%USERPROFILE%\\.codex`를 읽습니다. `ocx status`와 `ocx doctor`는 이 정확한 불일치를 경고하고, 경로는 가린 채 대상 home을 출력합니다. 해당 Orca shell에서 background service를 설치했다면 먼저 원래 shell에서 uninstall하고, `CODEX_HOME`을 app home으로 설정한 뒤 `ORCA_CODEX_HOME`을 해제하고, sync/restore를 다시 실행한 다음 service를 다시 설치하세요. -전용 provider 모드의 `requires_openai_auth = true`는 Codex App/TUI의 계정 게이트 화면을 네이티브 Codex와 같은 조건으로 맞춥니다. opencodex는 `/v1/responses`도 WebSocket으로 제공합니다. 전용 provider는 `"websockets": true`일 때만 `supports_websockets = true`를 광고합니다. loopback에서는 Codex의 빌트인 provider가 먼저 WebSocket을 시도할 수 있으며, 비활성화된 proxy는 `426`을 반환해서 Codex가 HTTP/SSE로 fallback합니다. +전용 provider 모드의 `requires_openai_auth = true`는 Codex App/TUI의 계정 게이트 화면을 네이티브 Codex와 같은 조건으로 맞춥니다. opencodex는 `/v1/responses`도 WebSocket으로 제공합니다. 전용 provider는 `"websockets": true`일 때만 `supports_websockets = true`를 광고하지만, loopback에서는 Codex의 빌트인 provider가 이 광고와 무관하게 WebSocket을 시도할 수 있으므로 proxy는 두 모드 모두에서 유효한 업그레이드를 허용합니다. ## 스레드 식별자와 대화 기록 diff --git a/docs-site/src/content/docs/ko/reference/architecture.md b/docs-site/src/content/docs/ko/reference/architecture.md index fa3056fb49..cbe5435b4c 100644 --- a/docs-site/src/content/docs/ko/reference/architecture.md +++ b/docs-site/src/content/docs/ko/reference/architecture.md @@ -114,10 +114,10 @@ Responses 항목 타입으로 구분됩니다 — 따라서 MCP 네임스페이 ## 전송과 compaction -`server/index.ts`는 기본적으로 `/v1/responses`를 HTTP/SSE로 제공합니다. `websockets`가 `false`인 -상태에서 Codex가 Responses WebSocket 업그레이드를 시도하면 opencodex는 `426 upgrade_required`를 -반환하고, Codex는 해당 세션에서 HTTP로 폴백합니다. `"websockets": true`가 설정되면 같은 -엔드포인트가 업그레이드를 받아들이고 WebSocket 브리지를 사용합니다. +`server/index.ts`는 `/v1/responses`에서 HTTP/SSE와 WebSocket 업그레이드를 제공합니다. `websockets` +설정은 라우팅된 카탈로그 및 provider 행의 기능 광고를 제어합니다. Codex의 내장 OpenAI provider는 +`openai_base_url`이 proxy를 가리키면 업그레이드를 시도할 수 있으므로 광고가 꺼져 있어도 유효한 +업그레이드는 허용됩니다. `426 upgrade_required`는 업그레이드에 실패했을 때만 사용됩니다. 이 클라이언트 설정과 별개로, 루트 `stream: true`인 canonical ChatGPT forward 요청은 stable Bun 1.4.0 이상에서 Codex 업스트림 WebSocket을 사용할 수 있습니다. 번들 Bun 1.3.14, diff --git a/docs-site/src/content/docs/ko/reference/proxy-formats.md b/docs-site/src/content/docs/ko/reference/proxy-formats.md index 1f000d992b..00f44de70e 100644 --- a/docs-site/src/content/docs/ko/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ko/reference/proxy-formats.md @@ -138,8 +138,9 @@ queue overflow 시 downstream에는 terminal `response.failed` 이벤트와 `[DO `response.created` 뒤에 `response.completed`를 반환합니다. :::note -WebSockets가 비활성화되어 있으면 업그레이드 시도는 `upgrade_required` 코드와 함께 HTTP 426을 받습니다. -Codex는 그 핸드셰이크 결과를 해당 세션에서 HTTP로 되돌아가라는 신호로 처리합니다. 모델 턴 실패가 아닙니다. +WebSocket 광고가 비활성화되면 라우팅된 카탈로그 행에서 기능 플래그가 생략됩니다. 하지만 +`openai_base_url`이 재정의된 뒤에는 내장 OpenAI provider가 업그레이드를 시도할 수 있으므로 proxy는 +두 모드 모두에서 유효한 업그레이드를 허용합니다. HTTP 426은 업그레이드를 완료할 수 없을 때만 사용됩니다. ::: ## `POST /v1/chat/completions` @@ -266,7 +267,7 @@ data-plane key는 management credential이 아닙니다. management API는 별 | 403 | `origin_rejected` | Responses/OpenAI data-plane 요청 또는 WebSocket 업그레이드가 허용되지 않은 origin에서 들어왔습니다 | | 503 | `combo_unavailable` | 선택한 combo의 모든 대상이 사용할 수 없거나, cooldown 중이거나, 비활성화되어 있거나, 다른 이유로 부적합합니다 | | 400 | `unreadable_encrypted_agent_task` | 암호화된 v2 worker task를 소비할 수 있는 적격 네이티브 ChatGPT 대상이 없습니다 | -| 426 | `upgrade_required` | Responses WebSocket transport가 비활성화되어 있거나 업그레이드에 실패했습니다. HTTP를 사용하십시오 | +| 426 | `upgrade_required` | Responses WebSocket 업그레이드를 완료할 수 없습니다. HTTP를 사용하십시오 | Anthropic-origin 실패는 Anthropic의 error envelope로 렌더링됩니다. 따라서 해당 방언에서 origin 거부는 OpenAI 스타일 `origin_rejected` body가 아니라 403 `permission_error`입니다. diff --git a/docs-site/src/content/docs/reference/architecture.md b/docs-site/src/content/docs/reference/architecture.md index 2aecc1e18a..7e387777f0 100644 --- a/docs-site/src/content/docs/reference/architecture.md +++ b/docs-site/src/content/docs/reference/architecture.md @@ -138,10 +138,10 @@ diagnostics. ## Transport and compaction -`server/index.ts` serves HTTP/SSE on `/v1/responses` by default. If Codex attempts a Responses -WebSocket upgrade while `websockets` is `false`, opencodex returns `426 upgrade_required`; Codex then -falls back to HTTP for that session. When `"websockets": true` is set, the same endpoint accepts the -upgrade and uses the WebSocket bridge. +`server/index.ts` serves HTTP/SSE and WebSocket upgrades on `/v1/responses`. The `websockets` setting +controls capability advertisement for routed catalog/provider rows. Codex's built-in OpenAI +provider may attempt an upgrade whenever `openai_base_url` points at the proxy, so valid upgrades +remain accepted when advertisement is off; `426 upgrade_required` is reserved for a failed upgrade. Independently of that client-facing setting, canonical ChatGPT forward requests with root-level `stream: true` may use Codex's upstream WebSocket transport on stable Bun 1.4.0 or newer. diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 1a68e78436..73b998b707 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -164,9 +164,10 @@ A warmup frame with `generate: false` does not call an upstream. It returns a sy `response.created` followed by `response.completed`, both with an empty response id and no output. :::note -When WebSockets are disabled, an upgrade attempt receives HTTP 426 with code -`upgrade_required`. Codex treats that handshake result as a signal to fall back to HTTP for the -session. It is not a failed model turn. +When WebSocket advertisement is disabled, routed catalog rows omit the capability flag. The +built-in OpenAI provider can still attempt an upgrade after `openai_base_url` is overridden, so the +proxy accepts valid upgrades in either mode. HTTP 426 is reserved for an upgrade that cannot be +completed. ::: ## `POST /v1/chat/completions` @@ -316,7 +317,7 @@ Errors use the client dialect's envelope where needed, but these status/code mea | 403 | `origin_rejected` | A Responses/OpenAI data-plane request or WebSocket upgrade came from a disallowed origin | | 503 | `combo_unavailable` | Every target in the selected combo is unavailable, in cooldown, disabled, or otherwise ineligible | | 400 | `unreadable_encrypted_agent_task` | An encrypted v2 worker task has no eligible native ChatGPT target that can consume it | -| 426 | `upgrade_required` | The Responses WebSocket transport is disabled or the upgrade failed; use HTTP | +| 426 | `upgrade_required` | A Responses WebSocket upgrade could not be completed; use HTTP | Anthropic-origin failures are rendered in Anthropic's error envelope, so the origin rejection is a 403 `permission_error` on that dialect rather than the OpenAI-style `origin_rejected` body. diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index 395413fd84..898a304cb5 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -171,8 +171,8 @@ runtime-home Orca, тогда как приложение ChatGPT/Codex всё В режиме выделенного провайдера `requires_openai_auth = true` держит account-gated surface App/TUI в согласии с нативным Codex. opencodex также обслуживает `/v1/responses` по WebSocket. Выделенный провайдер объявляет `supports_websockets = true` только когда `"websockets": true`; на -loopback встроенный провайдер Codex может сначала пробовать WebSocket, и отключённый прокси -ответит `426`, после чего Codex откатится на HTTP/SSE. +loopback встроенный провайдер Codex может пробовать WebSocket независимо от этого объявления, +поэтому прокси принимает корректный upgrade в обоих режимах. ## Идентичность тредов и история diff --git a/docs-site/src/content/docs/ru/reference/architecture.md b/docs-site/src/content/docs/ru/reference/architecture.md index c46da773c0..bea3640e81 100644 --- a/docs-site/src/content/docs/ru/reference/architecture.md +++ b/docs-site/src/content/docs/ru/reference/architecture.md @@ -146,10 +146,11 @@ loopback; настроенные записи `corsAllowOrigins` расширя ## Транспорт и compaction -`server/index.ts` по умолчанию обслуживает HTTP/SSE на `/v1/responses`. Если Codex пытается -выполнить WebSocket-апгрейд Responses, пока `websockets` равно `false`, opencodex возвращает -`426 upgrade_required`; Codex тогда откатывается на HTTP для этой сессии. Когда установлено -`"websockets": true`, та же конечная точка принимает апгрейд и использует WebSocket-мост. +`server/index.ts` обслуживает HTTP/SSE и WebSocket-upgrade на `/v1/responses`. Параметр `websockets` +управляет объявлением этой возможности для маршрутизируемых строк каталога и провайдеров. Встроенный +провайдер OpenAI в Codex может попытаться выполнить upgrade, когда `openai_base_url` указывает на +прокси, поэтому корректные upgrade принимаются и при выключенном объявлении. `426 upgrade_required` +используется только при неудачном upgrade. Compaction контекста Codex работает для маршрутизируемых моделей. `server/responses/compact.ts` обрабатывает `POST /v1/responses/compact`, выполняя внутренний маршрутизируемый ход суммаризации diff --git a/docs-site/src/content/docs/ru/reference/proxy-formats.md b/docs-site/src/content/docs/ru/reference/proxy-formats.md index 9163bec4f2..305d6e3085 100644 --- a/docs-site/src/content/docs/ru/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ru/reference/proxy-formats.md @@ -135,9 +135,10 @@ Warmup-frame с `generate: false` upstream не вызывает. Он возв `response.created` и `response.completed` с пустым response id и без output. :::note -Когда WebSocket отключён, попытка upgrade получает HTTP 426 с кодом `upgrade_required`. Codex -трактует результат такого handshake как сигнал откатиться к HTTP для этой сессии. Это не сбой -хода модели. +Когда объявление WebSocket отключено, у маршрутизируемых строк каталога нет флага этой возможности. +Однако встроенный провайдер OpenAI всё равно может попытаться выполнить upgrade после подмены +`openai_base_url`, поэтому прокси принимает корректный upgrade в обоих режимах. HTTP 426 используется +только тогда, когда upgrade невозможно завершить. ::: ## `POST /v1/chat/completions` @@ -275,7 +276,7 @@ Direct, поэтому remote proxy key здесь обязан идти чер | 403 | `origin_rejected` | Data-plane запрос или WebSocket-upgrade Responses/OpenAI пришёл с запрещённого origin | | 503 | `combo_unavailable` | Все цели выбранной combo недоступны, в cooldown, отключены или иным образом не подходят | | 400 | `unreadable_encrypted_agent_task` | У шифрованной задачи воркера v2 нет подходящей нативной цели ChatGPT, способной её прочитать | -| 426 | `upgrade_required` | Транспорт Responses WebSocket выключен или upgrade не удался; используйте HTTP | +| 426 | `upgrade_required` | Не удалось завершить upgrade Responses WebSocket; используйте HTTP | Сбои, пришедшие с Anthropic-side, отрисовываются в error envelope Anthropic, поэтому отклонение origin превращается в 403 `permission_error`, а не в OpenAI-style body `origin_rejected`. diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md index ebd67f9af2..414d051547 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -201,8 +201,8 @@ senkronizasyon/geri yüklemeyi yeniden çalıştırın ve servisi tekrar kurun. geçişli yüzeylerini yerel Codex ile hizalı tutar. opencodex ayrıca WebSocket üzerinden `/v1/responses` sunar. Özel sağlayıcı yalnızca `"websockets": true` olduğunda `supports_websockets = true` bildirir; geri döngüde Codex'in yerleşik -sağlayıcısı önce WebSocket'i deneyebilir ve devre dışı bırakılmış bir proxy -`426` döndürür, böylece Codex HTTP/SSE'ye geri döner. +sağlayıcısı bu bildirimden bağımsız olarak WebSocket'i deneyebilir, bu nedenle +proxy her iki modda da geçerli bir yükseltmeyi kabul eder. ## İş parçacığı kimliği ve geçmişi diff --git a/docs-site/src/content/docs/tr/reference/architecture.md b/docs-site/src/content/docs/tr/reference/architecture.md index 24d2bbb7aa..2684ed7252 100644 --- a/docs-site/src/content/docs/tr/reference/architecture.md +++ b/docs-site/src/content/docs/tr/reference/architecture.md @@ -164,11 +164,11 @@ tanılamaları için `usage/` tarafından toplanır. ## Aktarım ve sıkıştırma -`server/index.ts` varsayılan olarak `/v1/responses` üzerinde HTTP/SSE sunar. -Codex `websockets` `false` iken bir Responses WebSocket yükseltmesi denerse -opencodex `426 upgrade_required` döndürür; Codex daha sonra bu oturum için -HTTP'ye geri döner. `"websockets": true` ayarlandığında aynı uç nokta -yükseltmeyi kabul eder ve WebSocket köprüsünü kullanır. +`server/index.ts`, `/v1/responses` üzerinde HTTP/SSE ve WebSocket yükseltmelerini sunar. +`websockets` ayarı, yönlendirilmiş katalog ve sağlayıcı satırlarındaki yetenek bildirimini denetler. +Codex'in yerleşik OpenAI sağlayıcısı `openai_base_url` proxy'yi gösterdiğinde yükseltme deneyebilir; +bu nedenle bildirim kapalıyken de geçerli yükseltmeler kabul edilir. `426 upgrade_required` yalnızca +yükseltme başarısız olduğunda kullanılır. Codex bağlam sıkıştırması yönlendirilen modeller için çalışır. `server/responses/compact.ts`, dahili bir yönlendirilen özetleme turu @@ -220,4 +220,3 @@ Dahili model `types.ts` içinde yer alır: `OcxParsedRequest`, `OcxContext`, `OcxProviderConfig`). İki yardımcı yaygın olarak kullanılır: `namespacedToolName()` ve `modelInList()` (`noVisionModels` / `noReasoningModels` için toleranslı `:size` etiketi eşleştirmesi). - diff --git a/docs-site/src/content/docs/tr/reference/proxy-formats.md b/docs-site/src/content/docs/tr/reference/proxy-formats.md index 8b53d1bd51..a852a88223 100644 --- a/docs-site/src/content/docs/tr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/tr/reference/proxy-formats.md @@ -145,9 +145,10 @@ yanıt kimliği ve çıktı içermeyen sentetik bir `response.created` ve ardın `response.completed` döndürür. :::note -WebSockets devre dışı bırakıldığında bir yükseltme denemesi `upgrade_required` -koduyla HTTP 426 alır. Codex bu el sıkışma sonucunu oturum için HTTP'ye geri -dönme sinyali olarak değerlendirir. Başarısız bir model turu değildir. +WebSocket bildirimi kapalıyken yönlendirilmiş katalog satırları yetenek bayrağını içermez. Ancak +`openai_base_url` değiştirildikten sonra yerleşik OpenAI sağlayıcısı yine de yükseltme deneyebilir; +bu nedenle proxy her iki modda da geçerli yükseltmeleri kabul eder. HTTP 426 yalnızca yükseltme +tamamlanamadığında kullanılır. ::: ## `POST /v1/chat/completions` @@ -307,7 +308,7 @@ anlamları kararlıdır: | 403 | `origin_rejected` | Bir Responses/OpenAI veri düzlemi isteği veya WebSocket yükseltmesi izin verilmeyen bir kaynaktan geldi | | 503 | `combo_unavailable` | Seçilen komdodaki her hedef kullanılamaz, soğumada, devre dışı veya başka şekilde uygun değil | | 400 | `unreadable_encrypted_agent_task` | Şifrelenmiş bir v2 çalışan görevinin onu tüketebilecek uygun yerel bir ChatGPT hedefi yok | -| 426 | `upgrade_required` | Responses WebSocket aktarımı devre dışı bırakıldı veya yükseltme başarısız oldu; HTTP kullanın | +| 426 | `upgrade_required` | Responses WebSocket yükseltmesi tamamlanamadı; HTTP kullanın | Anthropic kaynaklı arızalar Anthropic'in hata zarfında işlenir, bu nedenle kaynak reddi OpenAI tarzı `origin_rejected` gövdesi yerine bu lehçede 403 @@ -330,4 +331,3 @@ okuyamazsa opencodex bu sağlayıcıya okunamayan baytlar göndermek yerine etrafındaki istemci davranışı için [Alt Ajan Arayüzü](/tr/guides/sub-agent-surface/) sayfasına bakın. - diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 69e829046f..6f8c005af5 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -153,8 +153,8 @@ $CODEX_HOME/models_cache.json 在专用 provider 模式下,`requires_openai_auth = true` 会让 Codex App/TUI 中受账号门控的界面与原生 Codex 保持一致。 opencodex 也会通过 WebSocket 提供 `/v1/responses`。专用 provider 只有在 `"websockets": true` 时才会声明 -`supports_websockets = true`;在 loopback 情况下,Codex 的内置 provider 可能会先尝试 WebSocket,而关闭的 proxy -会返回 `426`,从而让 Codex fallback 到 HTTP/SSE。 +`supports_websockets = true`;在 loopback 情况下,Codex 的内置 provider 可能不考虑该声明而尝试 WebSocket, +因此 proxy 在两种模式下都会接受有效的 upgrade。 ## 线程标识与历史记录 diff --git a/docs-site/src/content/docs/zh-cn/reference/architecture.md b/docs-site/src/content/docs/zh-cn/reference/architecture.md index 94c5eb8ea0..3f46397475 100644 --- a/docs-site/src/content/docs/zh-cn/reference/architecture.md +++ b/docs-site/src/content/docs/zh-cn/reference/architecture.md @@ -129,10 +129,10 @@ thread affinity 位于 `codex/` 下,不会出现在管理 API 响应中。请 ## 传输与 compaction -`server/index.ts` 默认在 `/v1/responses` 上提供 HTTP/SSE。当 `websockets` 为 `false` 而 Codex -尝试 Responses WebSocket upgrade 时,opencodex 会返回 `426 upgrade_required`,Codex 随后在该 -session 中回退到 HTTP。设置 `"websockets": true` 后,同一 endpoint 会接受 upgrade 并使用 -WebSocket bridge。 +`server/index.ts` 在 `/v1/responses` 上提供 HTTP/SSE 和 WebSocket upgrade。`websockets` 设置 +控制路由 catalog/provider 行是否声明该能力。只要 `openai_base_url` 指向 proxy,Codex 的内置 +OpenAI provider 仍可能尝试 upgrade,因此关闭声明时也会接受有效的 upgrade; +`426 upgrade_required` 仅用于 upgrade 失败。 Codex context compaction 同样适用于路由模型。`server/responses/compact.ts` 处理 `POST /v1/responses/compact`,运行一次内部路由 summarization turn 并返回压缩后的历史; diff --git a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md index 0d18903ecd..ab3d0db377 100644 --- a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md @@ -120,9 +120,9 @@ Responses 表示是这座桥的中心。原生兼容的路由可以跳过部分 `response.created`,随后是 `response.completed`,两者都带空的 response id 且没有输出。 :::note -当 WebSocket 被禁用时,升级尝试会收到 HTTP 426,错误码为 -`upgrade_required`。Codex 会把该握手结果视为会话回退到 HTTP 的信号。 -这不是一次失败的模型轮次。 +关闭 WebSocket 能力声明时,路由 catalog 行会省略该能力标记。但覆盖 `openai_base_url` 后, +内置 OpenAI provider 仍可能尝试 upgrade,因此 proxy 在两种模式下都会接受有效的 upgrade。 +HTTP 426 仅用于无法完成 upgrade 的情况。 ::: ## `POST /v1/chat/completions` @@ -235,7 +235,7 @@ Responses 家族和 Chat 请求会把 `Authorization` 留给提供方或 Codex D | 403 | `origin_rejected` | 一条 Responses/OpenAI 数据平面请求或 WebSocket 升级来自不允许的 origin | | 503 | `combo_unavailable` | 所选 combo 中的所有目标都不可用、处于冷却、已禁用或以其他方式不具备资格 | | 400 | `unreadable_encrypted_agent_task` | 一个加密的 v2 worker task 没有任何可消费它的合格原生 ChatGPT 目标 | -| 426 | `upgrade_required` | Responses WebSocket 传输被禁用,或升级失败;请改用 HTTP | +| 426 | `upgrade_required` | 无法完成 Responses WebSocket upgrade;请改用 HTTP | Anthropic 来源的失败会以 Anthropic 的错误封装呈现,因此该方言中的 origin 拒绝会是 403 `permission_error`,而不是 OpenAI 风格的 `origin_rejected` body。 diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index 24767de948..69f6da0011 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -160,7 +160,7 @@ home,而 ChatGPT/Codex App 仍讀取 `%USERPROFILE%\\.codex`。`ocx status` 在專用 provider 模式下,`requires_openai_auth = true` 會讓 Codex App/TUI 的帳號門控介面與原生 Codex 保持一致。opencodex 也透過 WebSocket 提供 `/v1/responses`。專用 provider 只會在 `"websockets": true` 時宣告 `supports_websockets = true`;loopback 模式下,Codex 的內建 provider -可能先嘗試 WebSocket,若 proxy 未啟用此功能則回傳 `426`,讓 Codex fallback 到 HTTP/SSE。 +可能不考慮此宣告而嘗試 WebSocket,因此 proxy 在兩種模式下都會接受有效的 upgrade。 ## Thread identity 與歷史記錄 diff --git a/docs-site/src/content/docs/zh-tw/reference/architecture.md b/docs-site/src/content/docs/zh-tw/reference/architecture.md index daf9bc9080..9b81205f80 100644 --- a/docs-site/src/content/docs/zh-tw/reference/architecture.md +++ b/docs-site/src/content/docs/zh-tw/reference/architecture.md @@ -129,10 +129,10 @@ thread affinity 位於 `codex/` 下,不會出現在管理 API 回應中。請 ## 傳輸與 compaction -`server/index.ts` 預設在 `/v1/responses` 上提供 HTTP/SSE。當 `websockets` 為 `false` 而 Codex -嘗試 Responses WebSocket upgrade 時,opencodex 會回傳 `426 upgrade_required`,Codex 隨後在該 -session 中回退到 HTTP。設定 `"websockets": true` 後,同一 endpoint 會接受 upgrade 並使用 -WebSocket bridge。 +`server/index.ts` 在 `/v1/responses` 上提供 HTTP/SSE 與 WebSocket upgrade。`websockets` 設定 +控制路由 catalog/provider row 是否宣告此能力。只要 `openai_base_url` 指向 proxy,Codex 的內建 +OpenAI provider 仍可能嘗試 upgrade,因此關閉宣告時也會接受有效的 upgrade; +`426 upgrade_required` 僅用於 upgrade 失敗。 Codex context compaction 同樣適用於路由模型。`server/responses/compact.ts` 處理 `POST /v1/responses/compact`,執行一次內部路由 summarization turn 並回傳壓縮後的歷史; diff --git a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md index 64852d205c..6091f0f49a 100644 --- a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md @@ -104,7 +104,9 @@ Responses 表示是橋接的中心。原生相容的路由可跳過部分轉譯 `response.created` 後接 `response.completed`,兩者皆有空的回應 id 且無輸出。 :::note -當 WebSocket 停用時,升級嘗試收到附帶代碼 `upgrade_required` 的 HTTP 426。Codex 將該握手結果視為回退到該 session 的 HTTP 的信號。它不是失敗的模型回合。 +停用 WebSocket 能力宣告時,路由 catalog row 會省略該能力標記。但覆寫 `openai_base_url` 後, +內建 OpenAI provider 仍可能嘗試 upgrade,因此 proxy 在兩種模式下都會接受有效的 upgrade。 +HTTP 426 僅用於無法完成 upgrade 的情況。 ::: ## `POST /v1/chat/completions` @@ -214,7 +216,7 @@ Data-plane 金鑰不是管理憑證。管理 API 使用獨立的管理秘密; | 403 | `origin_rejected` | Responses/OpenAI data-plane 請求或 WebSocket 升級來自不允許的來源 | | 503 | `combo_unavailable` | 所選組合中的每個目標都不可用、在冷卻中、停用或因其他原因不合格 | | 400 | `unreadable_encrypted_agent_task` | 加密的 v2 worker task 沒有可消耗它的合格原生 ChatGPT 目標 | -| 426 | `upgrade_required` | Responses WebSocket 傳輸被停用或升級失敗;請使用 HTTP | +| 426 | `upgrade_required` | 無法完成 Responses WebSocket upgrade;請使用 HTTP | Anthropic 來源的失敗以 Anthropic 的錯誤封裝渲染,因此該方言上的來源拒絕是 403 `permission_error`,而非 OpenAI 風格的 `origin_rejected` body。 diff --git a/gui/src/components/codex-account-pool-helpers.tsx b/gui/src/components/codex-account-pool-helpers.tsx index 7567f3b767..f93388622f 100644 --- a/gui/src/components/codex-account-pool-helpers.tsx +++ b/gui/src/components/codex-account-pool-helpers.tsx @@ -25,7 +25,7 @@ export function CodexCreditItem({ index, grantedAt, expiresAt, isNext, locale, t ); } -export function CodexTicketBadge({ account, onClick, t }: { account: CodexAccountEntry; onClick: () => void; t: TFn }) { +export function CodexTicketBadge({ account, onClick, t }: { account: CodexAccountEntry; onClick?: () => void; t: TFn }) { const credits = account.quota?.resetCredits; // Reserve badge width while WHAM quota is still null so the card-head does not grow // when resetCredits arrives (0 or N). Quota loaded without resetCredits → no badge. @@ -38,11 +38,25 @@ export function CodexTicketBadge({ account, onClick, t }: { account: CodexAccoun } if (credits === undefined) return null; const hasCredits = typeof credits === "number" && credits > 0; + const label = t("codexAuth.resetCreditsAria", { count: String(credits) }); + if (!onClick) { + return ( + + + ); + } return ( {isDefault ? {t("prov.defaultBadge")} : null} {isSelected ? {t("pws.selected")} : null} + {customModel ? {t("models.customBadge")} : null} + {customModel?.id ? ( + + ) : null} ); })} diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index d9613643a1..489053c38b 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -38,6 +38,8 @@ export interface CodexAccountEntry { /** Selection order; higher is used earlier. Always present, 0 when unset. */ priority: number; hasCredential: boolean; + authStatus?: "authenticated" | "logged-out" | "unavailable"; + credentialSource?: "auth-file" | "codex-managed"; quota: AccountQuota | null; needsReauth?: boolean; health?: { status: "healthy" | "cooldown" | "reauth_required" | "warning"; reason?: string; until?: string }; diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 5bea652194..fa17aaeb40 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1135,6 +1135,8 @@ export const de: Record = { "codexAuth.logLabel": "Log-Kennung", "codexAuth.codexApp": "Codex App", "codexAuth.appLogin": "App-Login", + "codexAuth.managedByCodex": "Von Codex verwaltet", + "codexAuth.keyringDetailsPending": "OpenCodex kann die Codex-Anmeldung im Schlüsselbund nicht lesen. Kontotyp und Kontingent werden nach der ersten erfolgreichen Anfrage über diesen Proxy angezeigt.", "codexAuth.accountPool": "Kontopool", "codexAuth.accountModeTitle": "OpenAI-Kontomodus", "codexAuth.accountModePool": "Pool-Modus", @@ -1931,7 +1933,12 @@ export const de: Record = { "pws.capacity.currentAccount": "Aktuelles effektives Konto", "pws.capacity.nextRecovery": "Nächste Kapazitätswiederherstellung", "pws.capacity.recoveryShare": "+{percent} % Pool-Kapazität", - "pws.capacity.incomplete": "Unvollständige Abdeckung: {excluded} Konten ausgeschlossen, davon {unknown} mit unbekanntem Tarif", + "pws.capacity.incomplete": "Unvollständige Abdeckung: {excluded} Konto/Konten ausgeschlossen.", + "pws.capacity.missingQuota": "Keine aktuellen Kontingentdaten: {count} Konto/Konten.", + "pws.capacity.unknownPlan": "Unbekannter Tarif: {count} Konto/Konten.", + "pws.capacity.pausedAccounts": "Pausiert: {count} Konto/Konten.", + "pws.capacity.reauthAccounts": "Erneute Anmeldung erforderlich: {count} Konto/Konten.", + "pws.capacity.staleQuota": "Veraltete Kontingentdaten: {count} Konto/Konten.", "pws.capacity.partial": "Teilweise Fensterabdeckung: {count} Konten melden nicht jedes angezeigte Limitfenster", "pws.capacity.windowPartial": "Teilweise", "pws.capacity.windowPartialA11y": "{window}: unvollständige Kontoabdeckung", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 0e27ca29e2..e175b3e161 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1277,7 +1277,12 @@ export const en = { "pws.capacity.currentAccount": "Current effective account", "pws.capacity.nextRecovery": "Next capacity recovery", "pws.capacity.recoveryShare": "+{percent}% pool capacity", - "pws.capacity.incomplete": "Incomplete coverage: {excluded} account(s) excluded, including {unknown} unknown plan(s)", + "pws.capacity.incomplete": "Incomplete coverage: {excluded} account(s) excluded.", + "pws.capacity.missingQuota": "No current quota data: {count} account(s).", + "pws.capacity.unknownPlan": "Unknown plan: {count} account(s).", + "pws.capacity.pausedAccounts": "Paused: {count} account(s).", + "pws.capacity.reauthAccounts": "Reauthentication required: {count} account(s).", + "pws.capacity.staleQuota": "Stale quota data: {count} account(s).", "pws.capacity.partial": "Partial window coverage: {count} account(s) do not report every displayed limit window", "pws.capacity.windowPartial": "Partial", "pws.capacity.windowPartialA11y": "{window}: incomplete account coverage", @@ -1629,6 +1634,8 @@ export const en = { "codexAuth.logLabel": "Log label", "codexAuth.codexApp": "Codex App", "codexAuth.appLogin": "App login", + "codexAuth.managedByCodex": "Managed by Codex", + "codexAuth.keyringDetailsPending": "OpenCodex cannot read Codex's keyring login. Account type and quota appear after the first successful request through this proxy.", "codexAuth.accountPool": "Account Pool", "codexAuth.accountModeTitle": "OpenAI account mode", "codexAuth.accountModePool": "Pool mode", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 4c5292014b..af370efbd3 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1250,7 +1250,12 @@ export const fr: Record = { "pws.capacity.currentAccount": "Compte effectif actuel", "pws.capacity.nextRecovery": "Prochaine récupération de capacité", "pws.capacity.recoveryShare": "+{percent}% de capacité du groupe", - "pws.capacity.incomplete": "Couverture incomplète : {excluded} compte(s) exclus, dont {unknown} forfait(s) inconnu(s)", + "pws.capacity.incomplete": "Couverture incomplète : {excluded} compte(s) exclus.", + "pws.capacity.missingQuota": "Aucune donnée de quota actuelle : {count} compte(s).", + "pws.capacity.unknownPlan": "Forfait inconnu : {count} compte(s).", + "pws.capacity.pausedAccounts": "En pause : {count} compte(s).", + "pws.capacity.reauthAccounts": "Réauthentification requise : {count} compte(s).", + "pws.capacity.staleQuota": "Données de quota obsolètes : {count} compte(s).", "pws.capacity.partial": "Couverture partielle des fenêtres : {count} compte(s) ne signalent pas toutes les fenêtres de limite affichées", "pws.capacity.windowPartial": "Partielle", "pws.capacity.windowPartialA11y": "{window} : couverture incomplète des comptes", @@ -1602,6 +1607,8 @@ export const fr: Record = { "codexAuth.logLabel": "Libellé du journal", "codexAuth.codexApp": "Application Codex", "codexAuth.appLogin": "Connexion à l’application", + "codexAuth.managedByCodex": "Géré par Codex", + "codexAuth.keyringDetailsPending": "OpenCodex ne peut pas lire la connexion Codex stockée dans le trousseau. Le type de compte et le quota apparaissent après la première requête réussie via ce proxy.", "codexAuth.accountPool": "Groupe de comptes", "codexAuth.accountModeTitle": "Mode de compte OpenAI", "codexAuth.accountModePool": "Mode Groupe", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 5be35ce9e4..5e1c5e6aa3 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1210,7 +1210,12 @@ export const ja: Record = { "pws.capacity.currentAccount": "現在の有効アカウント", "pws.capacity.nextRecovery": "次の容量回復", "pws.capacity.recoveryShare": "+{percent}% のプール容量", - "pws.capacity.incomplete": "対象範囲が不完全です: {excluded} 件を除外(不明なプラン {unknown} 件)", + "pws.capacity.incomplete": "対象範囲が不完全です:{excluded} 件のアカウントを除外しました。", + "pws.capacity.missingQuota": "現在のクォータデータなし:{count} 件のアカウント。", + "pws.capacity.unknownPlan": "不明なプラン:{count} 件のアカウント。", + "pws.capacity.pausedAccounts": "一時停止中:{count} 件のアカウント。", + "pws.capacity.reauthAccounts": "再認証が必要:{count} 件のアカウント。", + "pws.capacity.staleQuota": "古いクォータデータ:{count} 件のアカウント。", "pws.capacity.partial": "一部の期間の対象範囲が不完全です: {count} 件のアカウントでは表示中のすべての制限期間を取得できません", "pws.capacity.windowPartial": "一部のみ", "pws.capacity.windowPartialA11y": "{window}: アカウントの対象範囲が不完全です", @@ -1562,6 +1567,8 @@ export const ja: Record = { "codexAuth.logLabel": "ログラベル", "codexAuth.codexApp": "Codex App", "codexAuth.appLogin": "アプリログイン", + "codexAuth.managedByCodex": "Codex が管理", + "codexAuth.keyringDetailsPending": "OpenCodex は Codex のキーチェーン認証を読み取れません。アカウント種別とクォータは、このプロキシ経由で最初のリクエストが成功した後に表示されます。", "codexAuth.accountPool": "アカウントプール", "codexAuth.accountModeTitle": "OpenAI アカウントモード", "codexAuth.accountModePool": "プールモード", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index d7def6f1c7..d311879b1d 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1159,6 +1159,8 @@ export const ko: Record = { "codexAuth.logLabel": "로그 라벨", "codexAuth.codexApp": "Codex App", "codexAuth.appLogin": "앱 로그인", + "codexAuth.managedByCodex": "Codex에서 관리", + "codexAuth.keyringDetailsPending": "OpenCodex는 Codex의 키체인 로그인을 읽을 수 없습니다. 계정 유형과 할당량은 이 프록시를 통한 첫 번째 요청이 성공한 후 표시됩니다.", "codexAuth.accountPool": "계정 풀", "codexAuth.accountModeTitle": "OpenAI 계정 모드", "codexAuth.accountModePool": "풀 모드", @@ -1958,7 +1960,12 @@ export const ko: Record = { "pws.capacity.currentAccount": "현재 유효 계정", "pws.capacity.nextRecovery": "다음 용량 회복", "pws.capacity.recoveryShare": "+{percent}% 풀 용량", - "pws.capacity.incomplete": "불완전한 범위: {excluded}개 계정 제외, 알 수 없는 요금제 {unknown}개 포함", + "pws.capacity.incomplete": "불완전한 범위: {excluded}개 계정이 제외되었습니다.", + "pws.capacity.missingQuota": "현재 할당량 데이터 없음: {count}개 계정.", + "pws.capacity.unknownPlan": "알 수 없는 요금제: {count}개 계정.", + "pws.capacity.pausedAccounts": "일시 중지됨: {count}개 계정.", + "pws.capacity.reauthAccounts": "재인증 필요: {count}개 계정.", + "pws.capacity.staleQuota": "오래된 할당량 데이터: {count}개 계정.", "pws.capacity.partial": "일부 기간의 범위가 불완전합니다: {count}개 계정에서 표시된 모든 한도 기간을 확인할 수 없습니다", "pws.capacity.windowPartial": "일부만", "pws.capacity.windowPartialA11y": "{window}: 계정 범위가 불완전합니다", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 97f77fcdca..54c428b14a 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1261,7 +1261,12 @@ export const ru: Record = { "pws.capacity.currentAccount": "Текущая активная учётная запись", "pws.capacity.nextRecovery": "Следующее восстановление ёмкости", "pws.capacity.recoveryShare": "+{percent}% ёмкости пула", - "pws.capacity.incomplete": "Неполное покрытие: исключено аккаунтов: {excluded}, в том числе с неизвестным планом: {unknown}", + "pws.capacity.incomplete": "Неполное покрытие: исключено аккаунтов: {excluded}.", + "pws.capacity.missingQuota": "Нет актуальных данных о квоте: {count} аккаунт(а/ов).", + "pws.capacity.unknownPlan": "Неизвестный план: {count} аккаунт(а/ов).", + "pws.capacity.pausedAccounts": "Приостановлено: {count} аккаунт(а/ов).", + "pws.capacity.reauthAccounts": "Требуется повторная аутентификация: {count} аккаунт(а/ов).", + "pws.capacity.staleQuota": "Устаревшие данные о квоте: {count} аккаунт(а/ов).", "pws.capacity.partial": "Частичное покрытие окон: для {count} аккаунтов доступны не все показанные окна лимитов", "pws.capacity.windowPartial": "Частично", "pws.capacity.windowPartialA11y": "{window}: неполное покрытие аккаунтов", @@ -1613,6 +1618,8 @@ export const ru: Record = { "codexAuth.logLabel": "Метка журнала", "codexAuth.codexApp": "Codex App", "codexAuth.appLogin": "Вход через приложение", + "codexAuth.managedByCodex": "Управляется Codex", + "codexAuth.keyringDetailsPending": "OpenCodex не может прочитать данные входа Codex из системного хранилища ключей. Тип аккаунта и квота появятся после первого успешного запроса через этот прокси.", "codexAuth.accountPool": "Пул аккаунтов", "codexAuth.accountModeTitle": "Режим аккаунта OpenAI", "codexAuth.accountModePool": "Режим пула", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 57e40312d0..cc6b7aa1ff 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1268,7 +1268,12 @@ export const tr: Record = { "pws.capacity.currentAccount": "Mevcut geçerli hesap", "pws.capacity.nextRecovery": "Sonraki kapasite yenilenmesi", "pws.capacity.recoveryShare": "+%{percent} havuz kapasitesi", - "pws.capacity.incomplete": "Kısmi pencere kapsamı ({unknown} bilinmeyen, {excluded} hariç tutuldu)", + "pws.capacity.incomplete": "Eksik kapsam: {excluded} hesap hariç tutuldu.", + "pws.capacity.missingQuota": "Güncel kota verisi yok: {count} hesap.", + "pws.capacity.unknownPlan": "Bilinmeyen plan: {count} hesap.", + "pws.capacity.pausedAccounts": "Duraklatılmış: {count} hesap.", + "pws.capacity.reauthAccounts": "Yeniden kimlik doğrulama gerekli: {count} hesap.", + "pws.capacity.staleQuota": "Eski kota verisi: {count} hesap.", "pws.capacity.partial": "Kısmi ({count} hesap kota metriği bildiriyor)", "pws.capacity.windowPartial": "Kısmi", "pws.capacity.windowPartialA11y": "{window}: eksik hesap kapsamı", @@ -1620,6 +1625,8 @@ export const tr: Record = { "codexAuth.logLabel": "Günlük etiketi", "codexAuth.codexApp": "Codex Uygulaması", "codexAuth.appLogin": "Uygulama girişi", + "codexAuth.managedByCodex": "Codex tarafından yönetiliyor", + "codexAuth.keyringDetailsPending": "OpenCodex, Codex'in anahtar zincirindeki oturumunu okuyamaz. Hesap türü ve kota, bu proxy üzerinden yapılan ilk başarılı isteğin ardından görünür.", "codexAuth.accountPool": "Hesap Havuzu", "codexAuth.accountModeTitle": "OpenAI hesap modu", "codexAuth.accountModePool": "Havuz modu", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 36d4e6b6d2..cc63f5727f 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1261,6 +1261,8 @@ export const zhTW: Record = { "codexAuth.codexApp": "Codex App", "codexAuth.logLabel": "日誌標籤", "codexAuth.appLogin": "應用登入", + "codexAuth.managedByCodex": "由 Codex 管理", + "codexAuth.keyringDetailsPending": "OpenCodex 無法讀取 Codex 的系統鑰匙圈登入。帳戶類型與配額會在第一次透過此代理成功送出請求後顯示。", "codexAuth.accountPool": "帳號池", "codexAuth.accountModeTitle": "OpenAI 帳號模式", "codexAuth.accountModePool": "帳號池模式", @@ -2005,7 +2007,12 @@ export const zhTW: Record = { "pws.capacity.currentAccount": "目前有效帳號", "pws.capacity.nextRecovery": "下一次容量復原", "pws.capacity.recoveryShare": "+{percent}% 帳號池容量", - "pws.capacity.incomplete": "覆蓋不完整:已排除 {excluded} 個帳號,其中 {unknown} 個方案未知", + "pws.capacity.incomplete": "覆蓋不完整:已排除 {excluded} 個帳號。", + "pws.capacity.missingQuota": "沒有目前的配額資料:{count} 個帳號。", + "pws.capacity.unknownPlan": "方案未知:{count} 個帳號。", + "pws.capacity.pausedAccounts": "已暫停:{count} 個帳號。", + "pws.capacity.reauthAccounts": "需要重新驗證:{count} 個帳號。", + "pws.capacity.staleQuota": "配額資料已過期:{count} 個帳號。", "pws.capacity.partial": "部分視窗覆蓋:{count} 個帳號未回報所有顯示的限額視窗", "pws.capacity.windowPartial": "部分", "pws.capacity.windowPartialA11y": "{window}:帳號覆蓋不完整", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 894c9a07ca..217f3b307f 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1152,6 +1152,8 @@ export const zh: Record = { "codexAuth.logLabel": "日志标签", "codexAuth.codexApp": "Codex App", "codexAuth.appLogin": "应用登录", + "codexAuth.managedByCodex": "由 Codex 管理", + "codexAuth.keyringDetailsPending": "OpenCodex 无法读取 Codex 的系统钥匙串登录。账户类型和配额将在首次通过此代理成功请求后显示。", "codexAuth.accountPool": "账号池", "codexAuth.accountModeTitle": "OpenAI 账户模式", "codexAuth.accountModePool": "账户池模式", @@ -1951,7 +1953,12 @@ export const zh: Record = { "pws.capacity.currentAccount": "当前有效账户", "pws.capacity.nextRecovery": "下一次容量恢复", "pws.capacity.recoveryShare": "+{percent}% 账户池容量", - "pws.capacity.incomplete": "覆盖不完整:已排除 {excluded} 个账户,其中 {unknown} 个套餐未知", + "pws.capacity.incomplete": "覆盖不完整:已排除 {excluded} 个账户。", + "pws.capacity.missingQuota": "无当前配额数据:{count} 个账户。", + "pws.capacity.unknownPlan": "套餐未知:{count} 个账户。", + "pws.capacity.pausedAccounts": "已暂停:{count} 个账户。", + "pws.capacity.reauthAccounts": "需要重新认证:{count} 个账户。", + "pws.capacity.staleQuota": "配额数据已过期:{count} 个账户。", "pws.capacity.partial": "部分窗口覆盖不完整:{count} 个账户未报告所有显示的限额窗口", "pws.capacity.windowPartial": "部分", "pws.capacity.windowPartialA11y": "{window}:账户覆盖不完整", diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 158497a36c..8c32cfa70d 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -9,7 +9,11 @@ import { ToastNotice, type NoticeTone } from "../ui"; import { IconPlus } from "../icons"; import { useT } from "../i18n/shared"; import { useProviderAccountPools } from "../hooks/useProviderAccountPools"; -import { useCodexAccountPool } from "../hooks/useCodexAccountPool"; +import { + useCodexAccountPool, + type CodexAccountEntry, + type CodexAccountLoadState, +} from "../hooks/useCodexAccountPool"; import { useJsonConfigEditor } from "../hooks/useJsonConfigEditor"; import { useKeyedClientResource } from "../client-resource"; import { readSessionListCache } from "../session-list-cache"; @@ -21,6 +25,29 @@ import { ProvidersPageModals } from "./providers-page-modals"; import { buildAccountLoginStatus, buildAddModalAccountRows } from "./providers-page-utils"; import type { CodexAccountMutationCompletion } from "../codex-account-mutation"; +function codexCapacitySignature( + accounts: readonly CodexAccountEntry[], + activeId: string | null, +): { value: string; hasObservedState: boolean } { + const rows = accounts.map(account => JSON.stringify({ + isMain: account.isMain, + active: account.id === activeId || (account.isMain && (activeId === null || activeId === "__main__")), + plan: account.plan?.trim().toLowerCase() || null, + paused: account.paused, + needsReauth: account.needsReauth === true, + quota: account.quota, + })).sort(); + return { + value: JSON.stringify(rows), + hasObservedState: accounts.some(account => ( + Boolean(account.plan?.trim()) + || account.quota !== null + || account.paused + || account.needsReauth === true + )), + }; +} + export default function Providers({ apiBase }: { apiBase: string }) { const t = useT(); const configCacheKey = `ocx.providers.config.v1:${apiBase}`; @@ -54,6 +81,13 @@ export default function Providers({ apiBase }: { apiBase: string }) { // effect and its deferred load is deliberately uncancellable, so the guard lives here. const bootstrapKeyRef = useRef(null); const removeBusyRef = useRef(false); + const codexCapacityRef = useRef<{ + key: string; + value: string; + hasObservedState: boolean; + loadState: CodexAccountLoadState; + quotaEpoch: number; + } | null>(null); const notify = useCallback((msg: string, ok: boolean = true) => { setStatus(msg); @@ -130,8 +164,9 @@ export default function Providers({ apiBase }: { apiBase: string }) { * quota effect re-ran with it. Measured on this checkout: six `/api/provider-quotas` reads * inside 15ms where one answers the question. * - * A counter only moves when something actually invalidates the quotas, so account arrival - * is silent while every real mutation path still forces a re-read. + * A counter only moves when something actually invalidates the quotas. Generic OAuth + * account arrival stays silent; the Codex-only effect below also bumps it when an observed + * plan/quota changes the aggregate report. */ const [quotaRefresh, setQuotaRefresh] = useState({ epoch: 0, force: false }); const invalidateProviderQuotas = useCallback((force = false) => { @@ -150,13 +185,62 @@ export default function Providers({ apiBase }: { apiBase: string }) { // Single source for Codex reauth health: the controller derives it from the same // accounts/active pair this page used to poll on its own 30s timer. const codexActiveNeedsReauth = codexPool.activeNeedsReauth; + const openAiAccountState = config + ? openAiAccountProviderState(config.providers.openai) + : "absent"; + const codexCapacity = useMemo( + () => codexCapacitySignature(codexPool.accounts, codexPool.activeId), + [codexPool.accounts, codexPool.activeId], + ); + + useEffect(() => { + if (openAiAccountState !== "ready") { + codexCapacityRef.current = null; + return; + } + const key = apiBase; + const previous = codexCapacityRef.current; + codexCapacityRef.current = { + key, + value: codexCapacity.value, + hasObservedState: codexCapacity.hasObservedState, + loadState: codexPool.loadState, + quotaEpoch: quotaRefresh.epoch, + }; + + // The account list and provider quota report are deliberately separate reads. A + // successful proxy request can make the former observe plan/quota after the latter + // already returned its coverage-only snapshot. Re-read exactly once when that + // presentation-relevant Codex state arrives or changes. This is scoped to the Codex + // pool, so staggered OAuth account responses cannot recreate the old fetch storm. + const stateChanged = previous?.key === key && previous.value !== codexCapacity.value; + const initialUnobservedLoad = previous?.loadState !== "ready" + && codexPool.loadState === "ready" + && !previous?.hasObservedState + && !codexCapacity.hasObservedState; + const reportChanged = stateChanged && !initialUnobservedLoad; + const alreadyInvalidated = reportChanged && previous.quotaEpoch !== quotaRefresh.epoch; + if (codexPool.loadState === "ready" && reportChanged && !alreadyInvalidated) { + invalidateProviderQuotas(false); + } + }, [ + apiBase, + codexCapacity.hasObservedState, + codexCapacity.value, + codexPool.loadState, + invalidateProviderQuotas, + openAiAccountState, + quotaRefresh.epoch, + ]); // Derive openai login status from the shared Codex controller (no duplicate /accounts). const oauthStatusWithCodex = useMemo(() => { const accounts = codexPool.accounts; if (accounts.length === 0 && codexPool.loadState === "loading") return oauthStatus; const main = accounts.find(a => a.isMain) ?? accounts[0]; - const mainIsReal = !!main && !!main.email && main.email !== "Codex App login"; + const mainIsReal = main?.authStatus === "authenticated" + || Boolean(main?.hasCredential) + || (!!main && !!main.email && main.email !== "Codex App login"); const poolLoggedIn = accounts.some(a => !a.isMain && (a.hasCredential || a.email)); const codexLoggedIn = mainIsReal || poolLoggedIn; const codexEmail = mainIsReal diff --git a/gui/src/provider-workspace/report.ts b/gui/src/provider-workspace/report.ts index 539fbba2f2..b0e8e15890 100644 --- a/gui/src/provider-workspace/report.ts +++ b/gui/src/provider-workspace/report.ts @@ -27,6 +27,10 @@ export interface ProviderCapacityAggregationView { incomplete: boolean; excludedAccounts: number; unknownPlanAccounts: number; + missingQuotaAccounts: number; + pausedAccounts: number; + reauthAccounts: number; + staleQuotaAccounts: number; partialWindowAccounts: number; fiveHour?: CapacityWindowView; weekly?: CapacityWindowView; @@ -148,6 +152,10 @@ export function capacityAggregationFromReport(report?: ProviderQuotaReportView): incomplete: row.incomplete, excludedAccounts, unknownPlanAccounts, + missingQuotaAccounts: finite(row.missingQuotaAccounts) ?? 0, + pausedAccounts: finite(row.pausedAccounts) ?? 0, + reauthAccounts: finite(row.reauthAccounts) ?? 0, + staleQuotaAccounts: finite(row.staleQuotaAccounts) ?? 0, partialWindowAccounts: finite(row.partialWindowAccounts) ?? 0, ...(fiveHour ? { fiveHour } : {}), ...(weekly ? { weekly } : {}), diff --git a/gui/src/styles/provider-workspace-shell.css b/gui/src/styles/provider-workspace-shell.css index 1fe15f95fb..bc1f929e22 100644 --- a/gui/src/styles/provider-workspace-shell.css +++ b/gui/src/styles/provider-workspace-shell.css @@ -975,6 +975,36 @@ border-radius: 4px; } +.pws-model-chip-delete { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 24px; + height: 24px; + margin: -4px -4px -4px 0; + padding: 0; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--red); + cursor: pointer; +} + +.pws-model-chip-delete:hover { + background: color-mix(in oklab, var(--red) 12%, transparent); +} + +.pws-model-chip-delete:focus-visible { + outline: 2px solid var(--accent-ring); + outline-offset: 1px; +} + +.pws-model-chip-delete:disabled { + cursor: default; + opacity: 0.45; +} + .pws-model-id { font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); font-size: 0.8rem; diff --git a/gui/tests/codex-account-pool-pinned-badge.test.tsx b/gui/tests/codex-account-pool-pinned-badge.test.tsx index 9702d7aff9..3d9b88d249 100644 --- a/gui/tests/codex-account-pool-pinned-badge.test.tsx +++ b/gui/tests/codex-account-pool-pinned-badge.test.tsx @@ -218,6 +218,44 @@ test("an active unpinned app login keeps the manual pin action", async () => { expect(action!.textContent).toContain(en["codexAuth.setAsNext"]); }); +test("a keyring-backed app login is shown as Codex-managed, not expired", async () => { + const managedMain: CodexAccountEntry = { + ...mainAccount, + plan: "pro", + quota: { weeklyPercent: 5, resetCredits: 2, updatedAt: 1 }, + authStatus: "authenticated", + credentialSource: "codex-managed", + needsReauth: false, + }; + await mountPool(makeController({ accounts: [managedMain, account] })); + + const main = cardFor("main@example.test"); + expect(main.textContent).toContain(en["codexAuth.managedByCodex"]); + expect(main.textContent).not.toContain(en["codexAuth.needsReauth"]); + expect(main.textContent).not.toContain(en["codexAuth.mainTokenExpired"]); + expect([...main.querySelectorAll(".badge-green")].some(badge => badge.textContent === "pro")).toBe(true); + const resetCredits = main.querySelector('[aria-label="2 reset credit(s)"]'); + expect(resetCredits?.tagName).toBe("SPAN"); + expect(main.querySelector('button[aria-label="2 reset credit(s)"]')).toBeNull(); +}); + +test("an unobserved keyring login explains when account details become available", async () => { + const unavailableMain: CodexAccountEntry = { + ...mainAccount, + hasCredential: false, + authStatus: "unavailable", + needsReauth: false, + }; + await mountPool(makeController({ accounts: [unavailableMain, account] })); + + const main = cardFor("main@example.test"); + expect(main.textContent).toContain(en["codexAuth.keyringDetailsPending"]); + expect(main.textContent).not.toContain(en["codexAuth.managedByCodex"]); + expect(main.textContent).not.toContain(en["codexAuth.needsReauth"]); + expect(main.textContent).not.toContain(en["codexAuth.mainTokenExpired"]); + expect(main.querySelector(".codex-ticket-badge-slot")).toBeNull(); +}); + test("an active account that already owns the pin hides the redundant action", async () => { await mountPool(makeController({ activeId: "pool-1", activePinnedId: "pool-1" })); diff --git a/gui/tests/provider-capacity-shell.test.tsx b/gui/tests/provider-capacity-shell.test.tsx index 315d47f857..3c558d2145 100644 --- a/gui/tests/provider-capacity-shell.test.tsx +++ b/gui/tests/provider-capacity-shell.test.tsx @@ -211,7 +211,8 @@ test("provider quota fetch preserves aggregate capacity through shell state and expect(text).toContain("31% used"); expect(text).toContain("Current effective account · pro"); expect(text).toContain("8%"); - expect(text).toContain("Incomplete coverage: 1 account(s) excluded, including 1 unknown plan(s)"); + expect(text).toContain("Incomplete coverage: 1 account(s) excluded."); + expect(text).toContain("Unknown plan: 1 account(s)."); expect(text).toContain("Next capacity recovery"); expect(text).toContain("+19.2% pool capacity"); const expectedRecoveryAt = new Intl.DateTimeFormat("en", { @@ -309,6 +310,7 @@ test("all-stale response renders coverage only without a numeric fallback", asyn includedAccounts: 0, excludedAccounts: 2, unknownPlanAccounts: 0, + staleQuotaAccounts: 2, incomplete: true, partialWindowAccounts: 0, currentAccount: { isMain: true, plan: "pro", quota: null }, @@ -323,6 +325,7 @@ test("all-stale response renders coverage only without a numeric fallback", asyn expect(text).not.toContain("Current effective account"); expect(text).not.toContain("80% used"); expect(text).toContain("Incomplete coverage: 2 account(s) excluded"); + expect(text).toContain("Stale quota data: 2 account(s)."); }); test("coverage-only API report remains visible in the rate-limit overview", async () => { @@ -340,6 +343,7 @@ test("coverage-only API report remains visible in the rate-limit overview", asyn includedAccounts: 0, excludedAccounts: 3, unknownPlanAccounts: 1, + missingQuotaAccounts: 2, partialWindowAccounts: 0, incomplete: true, }, @@ -350,7 +354,9 @@ test("coverage-only API report remains visible in the rate-limit overview", asyn const text = host.textContent ?? ""; expect(text).toContain("OpenAI (Codex login)"); - expect(text).toContain("Incomplete coverage: 3 account(s) excluded, including 1 unknown plan(s)"); + expect(text).toContain("Incomplete coverage: 3 account(s) excluded."); + expect(text).toContain("No current quota data: 2 account(s)."); + expect(text).toContain("Unknown plan: 1 account(s)."); expect(text).not.toContain("No rate-limit data yet"); expect(text).not.toMatch(/\d+(?:\.\d+)?% used/); }); diff --git a/gui/tests/provider-capacity.test.ts b/gui/tests/provider-capacity.test.ts index 3916b3eadb..b459bb8c1b 100644 --- a/gui/tests/provider-capacity.test.ts +++ b/gui/tests/provider-capacity.test.ts @@ -73,6 +73,10 @@ test("capacity metadata preserves estimate, raw current quota, recovery percent, incomplete: true, excludedAccounts: 2, unknownPlanAccounts: 1, + missingQuotaAccounts: 1, + pausedAccounts: 1, + reauthAccounts: 0, + staleQuotaAccounts: 1, partialWindowAccounts: 0, weekly: { usedPercent: 30.769230769, @@ -90,6 +94,10 @@ test("capacity metadata preserves estimate, raw current quota, recovery percent, incomplete: true, excludedAccounts: 2, unknownPlanAccounts: 1, + missingQuotaAccounts: 1, + pausedAccounts: 1, + reauthAccounts: 0, + staleQuotaAccounts: 1, partialWindowAccounts: 0, weekly: { usedPercent: 30.769230769, nextRecoveryPercent: 19.23076923 }, currentAccount: { plan: "pro", quota: { weeklyPercent: 10 } }, diff --git a/gui/tests/provider-model-custom-add.test.tsx b/gui/tests/provider-model-custom-add.test.tsx index 6ebc04134a..96c96adf0e 100644 --- a/gui/tests/provider-model-custom-add.test.tsx +++ b/gui/tests/provider-model-custom-add.test.tsx @@ -265,6 +265,57 @@ test("successful quick-add appears immediately when catalog refresh is unavailab await act(async () => { root.unmount(); }); }); +test("a custom pill can be deleted without hiding the same live model", async () => { + const deleted: string[] = []; + globalThis.fetch = (async (input, init) => { + if (!init?.method || init.method === "GET") { + return Response.json([{ + id: "custom-1", + provider: "AiCodeWith", + modelId: "claude-opus-5", + }]); + } + if (init.method === "DELETE") { + deleted.push(String(input)); + return Response.json({ ok: true }); + } + return new Response(null, { status: 405 }); + }) as typeof fetch; + Object.defineProperty(testWindow, "confirm", { + configurable: true, + value: () => true, + }); + + let refreshes = 0; + const { root, container } = await mountProviderModels( + ["claude-opus-5"], + () => { refreshes += 1; }, + ); + await act(async () => { await Promise.resolve(); }); + + expect(container.textContent).toContain("Custom"); + const deleteButton = container.querySelector( + 'button[aria-label="Delete: claude-opus-5"]', + )!; + expect(deleteButton).toBeTruthy(); + + await act(async () => { + deleteButton.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(deleted).toEqual([ + "http://localhost:10100/api/custom-models/custom-1", + ]); + expect(refreshes).toBe(1); + expect(container.querySelector(".pws-model-id")?.textContent).toBe("claude-opus-5"); + expect(container.querySelector('button[aria-label="Delete: claude-opus-5"]')).toBeNull(); + expect(container.querySelector('[role="status"]')?.textContent).toContain("Custom model deleted"); + + await act(async () => { root.unmount(); }); +}); + test("quick-add waits for custom-model duplicate knowledge", async () => { let resolveLookup!: (response: Response) => void; const lookup = new Promise(resolve => { resolveLookup = resolve; }); diff --git a/gui/tests/provider-revalidation-policy.test.tsx b/gui/tests/provider-revalidation-policy.test.tsx index 5bbbdb9f7e..a6f12edc10 100644 --- a/gui/tests/provider-revalidation-policy.test.tsx +++ b/gui/tests/provider-revalidation-policy.test.tsx @@ -24,8 +24,45 @@ let testWindow: Window; let container: HTMLElement; let root: Root | null = null; let quotaCalls: string[] = []; +let apiBase = ""; +let apiBaseSequence = 0; +let configuredProviders: Record>; +let codexAccountReads = 0; +let codexAccountsForRead: (read: number) => unknown; +let codexAccountDelayMs = 0; const PROVIDERS = ["anthropic", "cursor", "kimi"]; +const OPENAI_PROVIDER = { + adapter: "openai-responses", + authMode: "forward", + baseUrl: "https://chatgpt.com/backend-api/codex", +}; + +function codexMainAccounts(observed: boolean) { + return { + accounts: [{ + id: "__main__", + email: "Codex App login", + isMain: true, + paused: false, + priority: 0, + hasCredential: true, + authStatus: "authenticated", + credentialSource: "codex-managed", + ...(observed + ? { + plan: "pro", + quota: { + weeklyPercent: 5, + weeklyResetAt: 1_788_369_220, + updatedAt: 1_788_000_000, + }, + } + : { quota: null }), + }], + mode: "pool", + }; +} beforeEach(() => { previousGlobals = Object.fromEntries(globals.map(k => [k, Reflect.get(globalThis, k)])) as typeof previousGlobals; @@ -42,6 +79,13 @@ beforeEach(() => { (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; quotaCalls = []; + apiBase = `/provider-revalidation-${++apiBaseSequence}`; + configuredProviders = Object.fromEntries( + PROVIDERS.map(p => [p, { authMode: "oauth", hasApiKey: false }]), + ); + codexAccountReads = 0; + codexAccountDelayMs = 0; + codexAccountsForRead = () => ({ accounts: [], mode: "single" }); Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: string, init?: RequestInit) => { @@ -76,13 +120,19 @@ beforeEach(() => { // `authMode: "oauth"` is what makes the page read account sets for these providers. // With an empty provider map no account read happens at all and the churn this test // exists to catch never occurs. - return ok({ - providers: Object.fromEntries(PROVIDERS.map(p => [p, { authMode: "oauth", hasApiKey: false }])), - }); + return ok({ providers: configuredProviders }); } if (url.includes("/api/selected-models")) return ok({ models: {} }); if (url.includes("/api/usage")) return ok({ providers: [] }); if (url.includes("/api/provider-presets")) return ok({ presets: [] }); + if (url.includes("/api/codex-auth/accounts")) { + codexAccountReads += 1; + if (codexAccountDelayMs > 0) { + await new Promise(r => setTimeout(r, codexAccountDelayMs)); + } + return ok(codexAccountsForRead(codexAccountReads)); + } + if (url.includes("/api/codex-auth/active")) return ok({ activeCodexAccountId: null }); if (url.includes("/api/codex-auth")) return ok({ accounts: [], mode: "single" }); return ok({}); }, @@ -104,13 +154,13 @@ afterEach(async () => { } }); -async function mount() { +async function mount(settleMs = 120) { await act(async () => { root = createRoot(container); - root.render(); + root.render(); }); // Account responses land across several microtask/macrotask turns. - await act(async () => { await new Promise(r => setTimeout(r, 120)); }); + await act(async () => { await new Promise(r => setTimeout(r, settleMs)); }); } test("account data arriving per provider does not re-read the quota endpoint", async () => { @@ -128,6 +178,42 @@ test("the cold read stays single even after every provider has settled", async ( expect(quotaCalls.length).toBe(1); }); +test("a newly observed Codex main account revalidates the overview quota once", async () => { + configuredProviders = { openai: OPENAI_PROVIDER }; + codexAccountsForRead = read => codexMainAccounts(read > 1); + + await mount(); + expect(quotaCalls.length).toBe(1); + + // The account controller retries a credentialed row whose observation has not landed + // yet. Once that retry sees plan/quota, Overview must re-read its separate aggregate; + // a second inference or a manual dashboard action must not be necessary. + await act(async () => { await new Promise(r => setTimeout(r, 500)); }); + await act(async () => { await new Promise(r => setTimeout(r, 30)); }); + + expect(codexAccountReads).toBeGreaterThanOrEqual(2); + expect(quotaCalls.length).toBe(2); + expect(quotaCalls.every(url => !url.includes("refresh=1"))).toBe(true); + + await act(async () => { await new Promise(r => setTimeout(r, 500)); }); + expect(quotaCalls.length).toBe(2); +}); + +test("a first observed Codex response closes the initial overview race", async () => { + configuredProviders = { openai: OPENAI_PROVIDER }; + codexAccountDelayMs = 80; + codexAccountsForRead = () => codexMainAccounts(true); + + await mount(10); + expect(quotaCalls.length).toBe(1); + await act(async () => { await new Promise(r => setTimeout(r, 120)); }); + await act(async () => { await new Promise(r => setTimeout(r, 30)); }); + + expect(codexAccountReads).toBe(1); + expect(quotaCalls.length).toBe(2); + expect(quotaCalls.every(url => !url.includes("refresh=1"))).toBe(true); +}); + // Guard the other half of the contract: the base account read still happens before the quota // probe, so account controls paint without waiting on a slow provider usage endpoint. The // plan originally proposed merging these two reads; that would have hidden the controls diff --git a/src/codex/account-usability.ts b/src/codex/account-usability.ts index d508e19f4d..98bbc920c0 100644 --- a/src/codex/account-usability.ts +++ b/src/codex/account-usability.ts @@ -10,6 +10,8 @@ export interface CodexAccountUsabilityOptions { nativeMainSelectionOnly?: boolean; /** Test seam for proving whether routing attempted a physical native-token read. */ isMainAccountTokenLive?: typeof isMainAccountTokenLive; + /** A validated native Codex bearer is available for this request only. */ + requestScopedMainCredential?: boolean; /** Confirmed account ids for an account-gated model; omitted for ordinary native models. */ modelEligibleAccountIds?: ReadonlySet; } @@ -27,6 +29,11 @@ export function isCodexAccountUsable( // A legacy pool row with the sentinel makes an active `__main__` ambiguous. // Fail closed until the authenticated compatibility-delete path removes it. if (hasLegacyMainCodexPoolAccount(config.codexAccounts)) return false; + // Codex-managed keyring credentials are intentionally not extractable. Native Codex attaches + // the current access token to each request, so that validated request can use main without + // turning its bearer into process-global Pool state. It also supersedes a stale proxy-owned + // reauth mark: Codex may have rotated the credential since the prior request. + if (options.requestScopedMainCredential) return true; if (isAccountNeedsReauth(accountId)) return false; // A selection-only caller owns the recovery/drain fence and will reject main // before reservation or token materialization. Treat cached main as a routing diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index c70ebf79a7..87aad6f264 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -89,11 +89,13 @@ import { reconcileLiveStateStores } from "../lib/state-store-registrations"; import { captureMainAccountIdentityGeneration, clearMainAccountInfoCache, - getMainAccountCredentialPresence, + getMainAccountCredentialState, getMainAccountInfoCache, isMainAccountIdentityGenerationLive, - setMainAccountCredentialPresence, + setMainAccountCredentialState, setMainAccountInfoCache, + type MainAccountAuthStatus, + type MainAccountCredentialSource, type MainAccountInfo, } from "./main-account-cache"; export { clearMainAccountInfoCache } from "./main-account-cache"; @@ -651,6 +653,10 @@ interface MainAccountInfoFetchResult { credentialChecked: boolean; /** Meaningful only when credentialChecked is true. */ hasCredential: boolean; + /** Tri-state auth observation; unavailable is never promoted to signed-out. */ + authStatus: MainAccountAuthStatus; + /** Which owner can provide the credential. Omitted unless authenticated. */ + credentialSource?: MainAccountCredentialSource; /** Main identity generation captured while the native-main claim was held. */ identityGeneration?: number; /** Present only when this call freshly parsed a WHAM usage response. */ @@ -689,7 +695,13 @@ async function retryMainAccountInfoIfIdentityChanged( reconcileMainCodexAccountRuntimeState(); return retriesRemaining > 0 ? fetchMainAccountInfoWhileOwned(true, retriesRemaining - 1, nativeMainLease, explicitRefresh) - : { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true }; + : { + info: EMPTY_MAIN_ACCOUNT_INFO, + credentialChecked: true, + hasCredential: true, + authStatus: "authenticated", + credentialSource: "auth-file", + }; } async function fetchMainAccountInfoAttempt( @@ -700,16 +712,19 @@ async function fetchMainAccountInfoAttempt( ): Promise { const nativeMainLease = existingNativeMainLease ?? tryAcquireNativeMainProfileClaim(); if (!nativeMainLease) { + const cachedCredential = getMainAccountCredentialState() ?? { status: "unavailable" as const }; return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: false, - hasCredential: false, + hasCredential: cachedCredential.status === "authenticated", + authStatus: cachedCredential.status, + ...(cachedCredential.status === "authenticated" ? { credentialSource: cachedCredential.source } : {}), identityGeneration: captureMainAccountIdentityGeneration(), }; } try { const operation = async () => ({ - ...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease), + ...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease, forceRefresh), identityGeneration: captureMainAccountIdentityGeneration(), }); if (nativeMainSharedClaimHeld) return await operation(); @@ -717,10 +732,13 @@ async function fetchMainAccountInfoAttempt( return await withNativeMainCredentialClaim(operation); } catch (error) { if (isNativeMainClaimUnavailable(error)) { + const cachedCredential = getMainAccountCredentialState() ?? { status: "unavailable" as const }; return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: false, - hasCredential: false, + hasCredential: cachedCredential.status === "authenticated", + authStatus: cachedCredential.status, + ...(cachedCredential.status === "authenticated" ? { credentialSource: cachedCredential.source } : {}), identityGeneration: captureMainAccountIdentityGeneration(), }; } @@ -746,22 +764,45 @@ async function fetchMainAccountInfoWhileOwned( const writerGeneration = captureConfigGeneration(); reconcileMainCodexAccountRuntimeState(); const tokenRead = readCodexTokensResult(); - setMainAccountCredentialPresence(tokenRead.status === "ok"); if (tokenRead.status !== "ok") { - // A local read failure is NOT proof of sign-out: a missing file can be a non-atomic rewrite - // gap, and malformed JSON can be a half-written file. Clearing the cache and marking the - // account for reauth here destroyed healthy email/plan/quota state and pinned a working - // account as unusable. Preserve what we already know and let the caller retry; request - // routing stays fail-closed because getMainAccountToken() re-reads the file itself, and the - // account DTO still reports hasCredential=false while the file is unreadable. + // Modern Codex commonly stores ChatGPT auth in the OS keyring, leaving no auth.json to read. + // OpenCodex deliberately does not inspect that keyring or launch Codex merely to infer login + // presence. Only a successful request that already carries Codex's caller-owned bearer can + // establish managed auth and populate non-secret identity/quota metadata. + const cachedCredential = getMainAccountCredentialState(); const preserved = getMainAccountInfoCache(); - return { info: preserved ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: false }; + if (cachedCredential?.status === "authenticated" + && cachedCredential.source === "codex-managed") { + return { + info: preserved ?? EMPTY_MAIN_ACCOUNT_INFO, + credentialChecked: true, + hasCredential: true, + authStatus: "authenticated", + credentialSource: "codex-managed", + }; + } + // A missing/malformed/unreadable auth.json is not proof of sign-out when the selected Codex + // credential store may be the keyring. Preserve display metadata and report uncertainty. + setMainAccountCredentialState({ status: "unavailable" }); + return { + info: preserved ?? EMPTY_MAIN_ACCOUNT_INFO, + credentialChecked: true, + hasCredential: false, + authStatus: "unavailable", + }; } + setMainAccountCredentialState({ status: "authenticated", source: "auth-file" }); const tokens = tokenRead.tokens; const requestAccountId = extractAccountId(tokens.id_token, tokens.access_token) ?? (tokens.account_id || null); const cached = getMainAccountInfoCache(); if (!forceRefresh && cached && Date.now() - cached.ts < MAIN_CACHE_TTL) { - return { info: cached, credentialChecked: true, hasCredential: true }; + return { + info: cached, + credentialChecked: true, + hasCredential: true, + authStatus: "authenticated", + credentialSource: "auth-file", + }; } try { const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { @@ -776,7 +817,13 @@ async function fetchMainAccountInfoWhileOwned( clearMainAccountInfoCache(); markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration); } - return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true }; + return { + info: EMPTY_MAIN_ACCOUNT_INFO, + credentialChecked: true, + hasCredential: true, + authStatus: "authenticated", + credentialSource: "auth-file", + }; } const data = (await resp.json()) as WhamUsageResponse; const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); @@ -811,12 +858,20 @@ async function fetchMainAccountInfoWhileOwned( info: result, credentialChecked: true, hasCredential: true, + authStatus: "authenticated", + credentialSource: "auth-file", ...(quota ? { freshQuota: quota } : {}), ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), }; } catch { const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); - return retried ?? { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true }; + return retried ?? { + info: EMPTY_MAIN_ACCOUNT_INFO, + credentialChecked: true, + hasCredential: true, + authStatus: "authenticated", + credentialSource: "auth-file", + }; } } @@ -891,6 +946,10 @@ export interface CodexAuthAccountDto { quota: (StoredAccountQuota | (Omit & { updatedAt: number })) | null; needsReauth?: boolean; hasCredential: boolean; + /** Main-account auth observation. Pool rows omit this field. */ + authStatus?: MainAccountAuthStatus; + /** Main credential owner; `codex-managed` means request-scoped passthrough, not extraction. */ + credentialSource?: MainAccountCredentialSource; health: OAuthAccountHealth; healthLabel: OAuthHealthLabel; healthSummary: string; @@ -1297,15 +1356,36 @@ export async function listCodexAuthAccountsSnapshot( const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration(); const mainSnapshotLive = isMainAccountIdentityGenerationLive(fetchedMainGeneration); const mainInfo = mainSnapshotLive ? mainResult.info : EMPTY_MAIN_ACCOUNT_INFO; - const hasMainCredential = mainSnapshotLive && mainResult.credentialChecked - ? mainResult.hasCredential - : getMainAccountCredentialPresence() ?? false; - const mainNeedsReauth = (mainSnapshotLive && mainResult.credentialChecked && !hasMainCredential) - || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + const cachedCredential = getMainAccountCredentialState() ?? { status: "unavailable" as const }; + const mainAuthStatus = mainSnapshotLive && mainResult.credentialChecked + ? mainResult.authStatus + : cachedCredential.status; + const mainCredentialSource = mainSnapshotLive && mainResult.credentialChecked + ? mainResult.credentialSource + : cachedCredential.status === "authenticated" + ? cachedCredential.source + : undefined; + const hasMainCredential = mainAuthStatus === "authenticated"; + // A Codex-managed credential can rotate on the next request without OpenCodex seeing or + // storing it. A stale proxy-owned quarantine therefore cannot prove that the next caller bearer + // needs reauthentication. Only file-backed auth can be quarantined outside a live request. + const mainNeedsReauth = mainAuthStatus === "logged-out" + || (mainAuthStatus === "authenticated" + && mainCredentialSource === "auth-file" + && isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)); const mainHealth = projectCodexAccountHealth({ accountId: MAIN_CODEX_ACCOUNT_ID, needsReauth: mainNeedsReauth, }); + // A quota row belongs to the identity generation that produced this snapshot. If publication + // observed an intervening identity clear/switch, discard it with the rest of the stale main + // projection instead of attaching old usage to the replacement account. + const storedMainQuota = mainSnapshotLive && mainAuthStatus === "authenticated" + ? getAccountQuota(MAIN_CODEX_ACCOUNT_ID) + : undefined; + const displayedMainQuota = mainInfo.quota + ? { ...mainInfo.quota, updatedAt: storedMainQuota?.updatedAt ?? Date.now() } + : storedMainQuota; const main: CodexAuthAccountDto = { id: MAIN_CODEX_ACCOUNT_ID, email: maskEmail(mainInfo.email) ?? "Codex App login", @@ -1315,13 +1395,10 @@ export async function listCodexAuthAccountsSnapshot( paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), hasCredential: hasMainCredential, + authStatus: mainAuthStatus, + ...(mainCredentialSource ? { credentialSource: mainCredentialSource } : {}), needsReauth: mainNeedsReauth, - quota: mainInfo.quota ? { - ...quotaForPlan({ - ...mainInfo.quota, - updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(), - }, mainInfo.plan), - } : null, + quota: displayedMainQuota ? { ...quotaForPlan(displayedMainQuota, mainInfo.plan) } : null, ...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth), }; return { @@ -1332,7 +1409,10 @@ export async function listCodexAuthAccountsSnapshot( }; } -export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = false): Promise { +export async function listCodexAuthAccounts( + config: OcxConfig, + forceRefresh = false, +): Promise { return (await listCodexAuthAccountsSnapshot(config, forceRefresh)).accounts; } diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index f3c357d4c3..cb1cedf50e 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -28,7 +28,9 @@ import { import { entitledCodexAccountIdsForModel, isDirectCallerEntitledToCodexModel, + isRequestScopedMainCallerEntitledToCodexModel, resolveCodexModelEntitlements, + SYNTHETIC_MAIN_MODEL_CREDENTIAL_PREFIX, } from "./model-entitlements"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models"; import type { CodexCooldownSource, CodexQuotaScope } from "./routing"; @@ -39,6 +41,7 @@ import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types"; import { FORWARD_HEADERS } from "../adapters/openai-responses"; import { captureConfigGeneration } from "../lib/state-store-sweeper"; import { retainedUtf8Bytes } from "../lib/admission"; +import { extractAccountId } from "../oauth/chatgpt"; const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512; const CODEX_APP_AFFINITY_KEY = randomBytes(32); @@ -95,14 +98,12 @@ export type CodexAuthContext = /** Scope that owns `probeLeaseId`, when it is a scoped recovery probe. */ probeQuotaScope?: CodexQuotaScope; } - | { + | ({ // Main Codex account participating in rotation: token injected from ~/.codex/auth.json - // (Option A). Distinct from "main" (passthrough fallback that forwards the client token). + // or forwarded from this native Codex request. Distinct from "main" (Direct mode). kind: "main-pool"; accountId: string; writerGeneration: number; - accessToken: string; - chatgptAccountId: string; /** Bypass Pool selection and suppress quota/transient failover for an exact selector. */ fixedAccount?: boolean; /** See `pool.affinityKey`. */ @@ -111,7 +112,18 @@ export type CodexAuthContext = probeLeaseId?: string; quotaScope?: CodexQuotaScope; probeQuotaScope?: CodexQuotaScope; - }; + } & ( + | { + /** Legacy/file-backed main credential owned by OpenCodex for this turn. */ + credentialSource?: "auth-file"; + accessToken: string; + chatgptAccountId: string; + } + | { + /** Bearer remains owned by Codex and is valid only for this inbound request. */ + credentialSource: "caller"; + } + )); /** Probe lease carried by this context, when it holds one. */ export function codexProbeLeaseId(ctx: CodexAuthContext | undefined): string | undefined { @@ -336,8 +348,15 @@ export interface ResolveCodexAuthContextOptions { resolveCodexModelEntitlements?: typeof resolveCodexModelEntitlements; /** Direct requests admitted with a proxy bearer substitute the stored native-main credential. */ substituteMainCredentialForDirect?: boolean; + /** + * The caller bearer was checked against every OpenCodex admission-secret class and may be used + * for `__main__` on this request. The credential is never copied into Pool state. + */ + requestScopedMainCredential?: boolean; /** Test seam for a Direct request's own forwarded ChatGPT credential. */ isDirectCallerEntitledToCodexModel?: (headers: Headers, modelId: string) => Promise; + /** Test seam for a Pool request's keyring-owned main credential. */ + isRequestScopedMainCallerEntitledToCodexModel?: (headers: Headers, modelId: string) => Promise; } export interface CodexAccountSelectionAdmission { @@ -353,6 +372,8 @@ export async function resolveCodexAuthContext( options: ResolveCodexAuthContextOptions = {}, ): Promise { const writerGeneration = captureConfigGeneration(); + const requestScopedMainCredential = options.requestScopedMainCredential === true + && hasCallerCodexBearer(headers); const fixedAccountId = options.accountId; if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) { throw new Error("Codex auth context cannot select and exclude an account simultaneously"); @@ -398,12 +419,27 @@ export async function resolveCodexAuthContext( const modelEligibleAccountIds = entitledAccountIds ? new Set([...entitledAccountIds].filter(candidate => !excludeAccountIds?.has(candidate))) : undefined; + const syntheticMainEntitlement = entitlementSnapshot?.credentialIdentities + .get(MAIN_CODEX_ACCOUNT_ID) + ?.startsWith(SYNTHETIC_MAIN_MODEL_CREDENTIAL_PREFIX) === true; + if (modelEligibleAccountIds + && requestScopedMainCredential + && !excludeAccountIds?.has(MAIN_CODEX_ACCOUNT_ID) + && (syntheticMainEntitlement || !modelEligibleAccountIds.has(MAIN_CODEX_ACCOUNT_ID))) { + const callerEntitled = await ( + options.isRequestScopedMainCallerEntitledToCodexModel + ?? isRequestScopedMainCallerEntitledToCodexModel + )(headers, options.modelId!); + if (callerEntitled) modelEligibleAccountIds.add(MAIN_CODEX_ACCOUNT_ID); + else if (syntheticMainEntitlement) modelEligibleAccountIds.delete(MAIN_CODEX_ACCOUNT_ID); + } const selectionOptions = { // Temporary switch drain keeps the candidate until the atomic claim rejects // it. Retained recovery makes main wholly ineligible so pool routing continues. nativeMainSelectionOnly: !nativeMainTrafficBlocked && selectionAdmission?.mainProfileDraining === true, isMainAccountTokenLive: options.isMainAccountTokenLive, + requestScopedMainCredential, modelEligibleAccountIds, }; // A pre-drain selector reserves the native identity while reconciliation and @@ -474,7 +510,8 @@ export async function resolveCodexAuthContext( if (isCodexAccountPaused(config, accountId)) { throw new CodexPoolAuthenticationError("Selected Codex account is unavailable"); } - if (isAccountNeedsReauth(accountId)) { + if (isAccountNeedsReauth(accountId) + && !(accountId === MAIN_CODEX_ACCOUNT_ID && requestScopedMainCredential)) { throw new CodexPoolAuthenticationError("Selected Codex account needs reauthentication"); } if (!isCodexAccountUsable(config, accountId, selectionOptions)) { @@ -523,6 +560,19 @@ export async function resolveCodexAuthContext( } if (accountId === MAIN_CODEX_ACCOUNT_ID) { + if (requestScopedMainCredential) { + return { + kind: "main-pool", + accountId, + writerGeneration, + credentialSource: "caller", + ...(fixedAccountId !== undefined ? { fixedAccount: true } : {}), + ...(affinityKey ? { affinityKey } : {}), + ...(quotaScope ? { quotaScope } : {}), + ...(probeLeaseId ? { probeLeaseId } : {}), + ...(probeQuotaScope ? { probeQuotaScope } : {}), + }; + } // Main account in rotation: inject the read-only auth.json token and fail closed if it vanished. const token = (options.getMainAccountToken ?? getMainAccountToken)(); if (!token) { @@ -588,6 +638,7 @@ export function applyCodexAuthContextToProvider( mode: CodexAccountMode | undefined, ): OcxRuntimeProviderConfig { if (mode !== "pool" || (ctx.kind !== "pool" && ctx.kind !== "main-pool") || provider.authMode !== "forward") return provider; + if (ctx.kind === "main-pool" && ctx.credentialSource === "caller") return provider; return { ...provider, _codexAccountOverride: { @@ -610,8 +661,9 @@ export class CodexMainSubstitutionUnavailableError extends Error { * * The two credential domains meet here, and only here: * - * - `pool` / `main-pool` always OVERWRITE with the stored account credential. Whatever the - * caller sent is irrelevant to what we send upstream. + * - `pool` and file-backed `main-pool` OVERWRITE with the stored account credential. + * - caller-backed `main-pool` keeps Codex's curated bearer/account headers for this request + * only; the credential never enters provider configuration or the account store. * - `main` with an admission-bearer caller (#1686) must substitute the stored main credential. * The caller proved admission with one of OUR secrets, which must never leave the process, so * the only two acceptable outcomes are replaced-with-stored-main or fail-before-any-IO. @@ -629,11 +681,17 @@ export function materializeCodexUpstreamAuth( const value = headers.get(name); if (value) selected.set(name, value); } - if (ctx.kind === "pool" || ctx.kind === "main-pool") { + if (ctx.kind === "pool" || (ctx.kind === "main-pool" && ctx.credentialSource !== "caller")) { selected.set("authorization", `Bearer ${ctx.accessToken}`); selected.set("chatgpt-account-id", ctx.chatgptAccountId); return selected; } + if (ctx.kind === "main-pool" && ctx.credentialSource === "caller" + && !selected.has("chatgpt-account-id")) { + const bearer = selected.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const accountId = bearer ? extractAccountId(undefined, bearer) : undefined; + if (accountId) selected.set("chatgpt-account-id", accountId); + } if (ctx.kind === "main" && options.substituteMainCredential === true) { const stored = getMainAccountToken(); // Fail BEFORE any upstream I/O. Falling through here would send the admission secret. @@ -654,6 +712,7 @@ export function headersForCodexAuthContext(headers: Headers, ctx: CodexAuthConte export function isCodexAuthContextUsable(ctx: CodexAuthContext, config: OcxConfig): boolean { if (ctx.kind === "main") return true; + if (ctx.kind === "main-pool" && ctx.credentialSource === "caller") return true; if (ctx.kind === "main-pool") return isCodexAccountUsable(config, ctx.accountId); return isCodexAccountUsable(config, ctx.accountId) && isCodexAccountGenerationLive(ctx.accountId, ctx.generation); } diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index 5494d12ddd..228f0da9a2 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -411,7 +411,10 @@ export function desktopVisibleNativeSlugs( ]); } -export function nativeModelRows(config: Pick): Array<{ slug: string; disabled: boolean; contextWindow?: number; maxInputTokens?: number; autoCompactTokenLimit?: number }> { +export function nativeModelRows( + config: Pick, + options: { availableGatedModels?: ReadonlySet } = {}, +): Array<{ slug: string; disabled: boolean; contextWindow?: number; maxInputTokens?: number; autoCompactTokenLimit?: number }> { const disabled = disabledNativeSlugs(config); const shadowed = configuredNativeAliasSlugs(config); // Both user levers, not just the cap: a per-model window set from the dashboard has to show @@ -421,7 +424,8 @@ export function nativeModelRows(config: Pick !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableGated.has(slug)) .filter(slug => !shadowed.has(slug)).map(slug => { diff --git a/src/codex/main-account-cache.ts b/src/codex/main-account-cache.ts index 0eb837b4f8..025bbbf6f4 100644 --- a/src/codex/main-account-cache.ts +++ b/src/codex/main-account-cache.ts @@ -13,8 +13,18 @@ export interface CachedMainAccountInfo extends MainAccountInfo { ts: number; } +export type MainAccountAuthStatus = "authenticated" | "logged-out" | "unavailable"; +export type MainAccountCredentialSource = "auth-file" | "codex-managed"; + +export type MainAccountCredentialState = + | { status: "authenticated"; source: MainAccountCredentialSource } + | { status: "logged-out" | "unavailable" }; + let cachedMainAccountInfo: CachedMainAccountInfo | null = null; -let cachedMainCredentialPresence: boolean | null = null; +let cachedMainCredentialState: MainAccountCredentialState | null = null; +// Identity only; never a bearer or refresh token. This lets a successful native Codex request +// replace stale display/quota metadata when the keyring login changes accounts. +let cachedCodexManagedAccountId: string | null = null; let mainAccountIdentityGeneration = 0; export function captureMainAccountIdentityGeneration(): number { @@ -42,15 +52,63 @@ export function clearMainAccountInfoCache(): void { mainAccountIdentityGeneration += 1; } -/** Last physical credential presence observed while native-main ownership was held. */ +/** + * Commit metadata proven by a successful request carrying Codex's keyring-owned credential. + * Returns true when the observed account identity changed. + */ +export function setCodexManagedMainAccountObservation(value: { + accountId: string | null; + email: string | null; + plan: string | null; + ts?: number; +}): boolean { + const accountId = value.accountId + ? truncateRetainedUtf8(value.accountId, MAX_DIAGNOSTIC_VALUE_BYTES) + : null; + // The first request-scoped identity in this process is an identity boundary too: any main + // metadata/quota already in memory may belong to an earlier file-backed login. Once a managed + // identity is known, subsequent requests for the same id preserve its cache normally. + const identityChanged = accountId !== null + && (cachedCodexManagedAccountId === null + || cachedCodexManagedAccountId !== accountId); + if (identityChanged) { + cachedMainAccountInfo = null; + mainAccountIdentityGeneration += 1; + } + cachedCodexManagedAccountId = accountId; + const prior = cachedMainAccountInfo; + setMainAccountInfoCache({ + email: value.email ?? prior?.email ?? null, + plan: value.plan ?? prior?.plan ?? null, + quota: identityChanged ? null : (prior?.quota ?? null), + ts: value.ts ?? Date.now(), + }); + cachedMainCredentialState = { status: "authenticated", source: "codex-managed" }; + return identityChanged; +} + +/** Last credential state observed while native-main ownership was held. */ export function getMainAccountCredentialPresence(): boolean | null { - return cachedMainCredentialPresence; + if (cachedMainCredentialState?.status === "authenticated") return true; + if (cachedMainCredentialState?.status === "logged-out") return false; + return null; } export function setMainAccountCredentialPresence(present: boolean): void { - cachedMainCredentialPresence = present; + cachedMainCredentialState = present + ? { status: "authenticated", source: "auth-file" } + : { status: "logged-out" }; +} + +export function getMainAccountCredentialState(): MainAccountCredentialState | null { + return cachedMainCredentialState; +} + +export function setMainAccountCredentialState(state: MainAccountCredentialState): void { + cachedMainCredentialState = state; } export function clearMainAccountCredentialPresence(): void { - cachedMainCredentialPresence = null; + cachedMainCredentialState = null; + cachedCodexManagedAccountId = null; } diff --git a/src/codex/main-account-observation.ts b/src/codex/main-account-observation.ts new file mode 100644 index 0000000000..1b7f2f3711 --- /dev/null +++ b/src/codex/main-account-observation.ts @@ -0,0 +1,165 @@ +import { extractAccountId, extractEmail } from "../oauth/chatgpt"; +import { readBoundedResponseBody } from "../lib/bounded-body"; +import { captureConfigGeneration } from "../lib/state-store-sweeper"; +import { codexPlanValue, extractChatgptPlanType } from "./plan"; +import { clearAccountNeedsReauth } from "./account-runtime-state"; +import { + clearAccountQuota, + parseUsageQuota, + setAccountQuotaFromParsed, + type WhamUsageResponse, +} from "./quota"; +import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account"; +import { + captureMainAccountIdentityGeneration, + getMainAccountInfoCache, + isMainAccountIdentityGenerationLive, + setCodexManagedMainAccountObservation, +} from "./main-account-cache"; + +const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"; +const MANAGED_USAGE_SUCCESS_TTL_MS = 5 * 60_000; +const MANAGED_USAGE_FAILURE_TTL_MS = 15_000; +const MANAGED_USAGE_TIMEOUT_MS = 8_000; +const MANAGED_USAGE_MAX_BYTES = 256 * 1024; + +interface CodexManagedRequestCredential { + accessToken: string; + accountId: string | null; + email: string | null; + plan: string | null; +} + +interface CodexManagedUsageOptions { + fetcher?: typeof fetch; + now?: number; +} + +let managedUsageFlight: { generation: number; promise: Promise } | null = null; +let managedUsageNextProbe: { generation: number; at: number } | null = null; + +function codexManagedRequestCredential(headers: Headers): CodexManagedRequestCredential | null { + const match = /^Bearer\s+(\S+)$/i.exec(headers.get("authorization")?.trim() ?? ""); + if (!match) return null; + const accessToken = match[1]!; + return { + accessToken, + accountId: headers.get("chatgpt-account-id")?.trim() + || extractAccountId(undefined, accessToken) + || null, + email: extractEmail(undefined, accessToken) ?? null, + plan: extractChatgptPlanType(undefined, accessToken) ?? null, + }; +} + +function commitCodexManagedMainIdentity(credential: CodexManagedRequestCredential): number { + const identityChanged = setCodexManagedMainAccountObservation({ + accountId: credential.accountId, + email: credential.email, + plan: credential.plan, + }); + if (identityChanged) clearAccountQuota(MAIN_CODEX_ACCOUNT_ID); + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + setMainAccountPlan(getMainAccountInfoCache()?.plan ?? null); + return captureMainAccountIdentityGeneration(); +} + +/** + * Learn non-secret main-account metadata from a successful native Codex request. + * + * Codex keeps modern ChatGPT credentials in the OS keyring and attaches the current bearer to + * each request. OpenCodex must use that bearer only for the request that carried it; this observer + * retains email/plan/account identity decoded from the JWT, never the bearer itself. + */ +export function observeSuccessfulCodexManagedMainRequest(headers: Headers): boolean { + const credential = codexManagedRequestCredential(headers); + if (!credential) return false; + commitCodexManagedMainIdentity(credential); + return true; +} + +/** + * Learn the WHAM-only quota fields (notably reset-credit count) while Codex's keyring bearer is + * already present on a successful native request. The bearer lives only in this bounded probe and + * is never copied into config, the account store, the main-account cache, or a management DTO. + */ +export function observeSuccessfulCodexManagedMainUsage( + headers: Headers, + options: CodexManagedUsageOptions = {}, +): Promise { + const credential = codexManagedRequestCredential(headers); + if (!credential) return Promise.resolve(false); + const generation = commitCodexManagedMainIdentity(credential); + // A stable ChatGPT account id is required to reject a late WHAM response after the native + // keyring login switches accounts. A plan-bearing JWT without that identity can still populate + // the plan badge through the synchronous observation above, but cannot authorize quota commit. + if (!credential.accountId) return Promise.resolve(false); + + const now = options.now ?? Date.now(); + if (managedUsageNextProbe?.generation === generation && managedUsageNextProbe.at > now) { + return Promise.resolve(false); + } + if (managedUsageFlight?.generation === generation) return managedUsageFlight.promise; + + const writerGeneration = captureConfigGeneration(); + const fetcher = options.fetcher ?? fetch; + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(new DOMException("Codex managed usage probe timed out", "TimeoutError")); + }, MANAGED_USAGE_TIMEOUT_MS); + const promise = (async (): Promise => { + try { + const response = await fetcher(CODEX_USAGE_URL, { + headers: { + Authorization: `Bearer ${credential.accessToken}`, + "ChatGPT-Account-Id": credential.accountId!, + Accept: "application/json", + }, + redirect: "error", + signal: controller.signal, + }); + if (!response.ok) { + await response.body?.cancel().catch(() => undefined); + return false; + } + const body = await readBoundedResponseBody(response, { + signal: controller.signal, + maxBytes: MANAGED_USAGE_MAX_BYTES, + fatalUtf8: true, + }); + if (!body.displaySafe || body.truncated) return false; + const parsed: unknown = JSON.parse(body.text); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false; + if (!isMainAccountIdentityGenerationLive(generation)) return false; + + const data = parsed as WhamUsageResponse; + const whamPlan = codexPlanValue(data.plan_type); + if (whamPlan) { + setCodexManagedMainAccountObservation({ + accountId: credential.accountId, + email: credential.email, + plan: whamPlan, + }); + setMainAccountPlan(whamPlan); + } + const quota = parseUsageQuota(data); + if (quota) setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, quota, writerGeneration); + return true; + } catch { + return false; + } finally { + clearTimeout(timer); + } + })(); + + managedUsageFlight = { generation, promise }; + void promise.then(success => { + if (managedUsageFlight?.promise === promise) managedUsageFlight = null; + managedUsageNextProbe = { + generation, + at: (options.now ?? Date.now()) + + (success ? MANAGED_USAGE_SUCCESS_TTL_MS : MANAGED_USAGE_FAILURE_TTL_MS), + }; + }); + return promise; +} diff --git a/src/codex/model-entitlements.ts b/src/codex/model-entitlements.ts index 5a649d335a..eb675b55b5 100644 --- a/src/codex/model-entitlements.ts +++ b/src/codex/model-entitlements.ts @@ -1,10 +1,13 @@ import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { isAbsolute, join, resolve } from "node:path"; import { readBoundedResponseBody } from "../lib/bounded-body"; import type { OcxConfig } from "../types"; import { isSelectableCodexPoolAccount } from "./account-id"; import { getValidCodexToken, readCodexAccountRecord } from "./account-store"; import { getMainAccountToken, MAIN_CODEX_ACCOUNT_ID } from "./main-account"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models"; +import { getCodexHome, readRootTomlString } from "./paths"; const CODEX_MODELS_URL = "https://chatgpt.com/backend-api/codex/models?client_version=0.0.0"; const MODEL_ROSTER_TTL_MS = 5 * 60_000; @@ -13,6 +16,8 @@ const MODEL_ROSTER_TIMEOUT_MS = 8_000; const MODEL_ROSTER_MAX_BYTES = 2 * 1024 * 1024; const MODEL_ROSTER_CACHE_MAX = 64; const DIRECT_CALLER_ACCOUNT_PREFIX = "__direct_codex__:"; +const OPEN_CODEX_CACHE_TIMESTAMP = "2000-01-01T00:00:00Z"; +export const SYNTHETIC_MAIN_MODEL_CREDENTIAL_PREFIX = "synthetic-cache:"; export interface CodexModelEntitlementCredentialSnapshot { readonly accountId: string; @@ -44,6 +49,8 @@ export interface CodexModelEntitlementResolveOptions { readonly credentialSnapshot?: typeof accountCredentialSnapshot; /** Accounts whose credentials must not be read while another lifecycle owns them. */ readonly excludeAccountIds?: ReadonlySet; + /** Focused test seam for Codex's authenticated native models cache. */ + readonly nativeMainModels?: readonly string[] | null; } const accountModelsCache = new Map(); @@ -85,13 +92,139 @@ function currentCredentialIdentity(accountId: string): string | undefined { } if (accountId === MAIN_CODEX_ACCOUNT_ID) { const token = getMainAccountToken(); - return token ? `main:${token.chatgptAccountId}` : undefined; + return token + ? `main:${token.chatgptAccountId}` + : accountModelsCache.get(MAIN_CODEX_ACCOUNT_ID)?.credentialIdentity; } const record = readCodexAccountRecord(accountId); if (!record?.credential || record.deletedAt != null) return undefined; return `pool:${record.generation}:${record.credential.chatgptAccountId}`; } +function validatedAccountGatedModels(rows: unknown): ReadonlySet | null { + if (!Array.isArray(rows)) return null; + return new Set(rows.flatMap(entry => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return []; + const row = entry as { + slug?: unknown; + visibility?: unknown; + supported_in_api?: unknown; + supported_reasoning_levels?: unknown; + model_messages?: unknown; + }; + if (typeof row.slug !== "string" + || !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(row.slug) + || row.visibility === "hide" + || row.supported_in_api !== true + || !Array.isArray(row.supported_reasoning_levels) + || row.supported_reasoning_levels.length === 0 + || typeof row.model_messages !== "object" + || row.model_messages === null) return []; + return [row.slug]; + })); +} + +function sameModels(left: ReadonlySet, right: ReadonlySet): boolean { + return left.size === right.size && [...left].every(model => right.has(model)); +} + +function configuredCatalogPath(codexHome: string): string { + try { + const config = readFileSync(join(codexHome, "config.toml"), "utf8"); + const configured = readRootTomlString(config, "model_catalog_json")?.trim(); + if (configured) return isAbsolute(configured) ? resolve(configured) : resolve(codexHome, configured); + } catch { /* A missing or unreadable config uses the managed default catalog. */ } + return join(codexHome, "opencodex-catalog.json"); +} + +function corroboratedSyntheticMainModels( + cacheRaw: string, + cacheModels: ReadonlySet, + codexHome: string, +): CachedAccountModels | null { + if (cacheModels.size === 0) return null; + let catalogRaw: string; + try { + catalogRaw = readFileSync(configuredCatalogPath(codexHome), "utf8"); + } catch { + return null; + } + try { + const catalog = JSON.parse(catalogRaw) as { models?: unknown }; + const catalogModels = validatedAccountGatedModels(catalog.models); + if (!catalogModels || !sameModels(cacheModels, catalogModels)) return null; + return { + credentialIdentity: `${SYNTHETIC_MAIN_MODEL_CREDENTIAL_PREFIX}${createHash("sha256") + .update(cacheRaw) + .update("\0") + .update(catalogRaw) + .digest("hex")}`, + expiresAt: 0, + models: cacheModels, + confirmed: true, + }; + } catch { + return null; + } +} + +/** + * Read model availability that the installed Codex client fetched while authenticated. + * + * A real Codex cache is direct startup evidence. OpenCodex's `client_version=0.0.0` wrapper is + * accepted only when its sentinel timestamp and gated roster match the managed catalog it was + * generated from. That preserves a previously verified keyring roster across an OpenCodex restart + * without treating an arbitrary synthetic cache as fresh account evidence. Request routing treats + * this persisted wrapper as provisional and verifies it against the current caller before use. + */ +function nativeCodexMainModelsCache(now: number, codexHome = getCodexHome()): CachedAccountModels | null { + let raw: string; + try { + raw = readFileSync(join(codexHome, "models_cache.json"), "utf8"); + } catch { + return null; + } + try { + const parsed = JSON.parse(raw) as { fetched_at?: unknown; client_version?: unknown; models?: unknown }; + if (typeof parsed.client_version !== "string" + || parsed.client_version.trim() === "" + || !Array.isArray(parsed.models)) return null; + const models = validatedAccountGatedModels(parsed.models); + if (!models) return null; + if (parsed.client_version.trim() === "0.0.0") { + if (parsed.fetched_at !== OPEN_CODEX_CACHE_TIMESTAMP) return null; + const synthetic = corroboratedSyntheticMainModels(raw, models, codexHome); + return synthetic ? { ...synthetic, expiresAt: now + MODEL_ROSTER_TTL_MS } : null; + } + return { + credentialIdentity: `native-cache:${createHash("sha256").update(raw).digest("hex")}`, + expiresAt: now + MODEL_ROSTER_TTL_MS, + models, + confirmed: true, + }; + } catch { + return null; + } +} + +/** + * Preserve native Codex's authenticated roster before server startup rewrites models_cache.json. + * The startup cache rewrite is synchronous and deliberately precedes the later catalog gather; + * without this snapshot the gather sees only OpenCodex's synthetic `client_version=0.0.0` cache. + */ +export function seedMainCodexModelEntitlementsFromNativeCache(options: { + readonly codexHome?: string; + readonly now?: number; +} = {}): boolean { + const now = options.now ?? Date.now(); + const existing = accountModelsCache.get(MAIN_CODEX_ACCOUNT_ID); + if (existing && existing.expiresAt > now) return existing.confirmed; + const native = nativeCodexMainModelsCache(now, options.codexHome); + if (!native) return false; + boundedCacheSet(MAIN_CODEX_ACCOUNT_ID, native); + return true; +} + async function accountCredentialSnapshot(accountId: string): Promise { if (accountId === MAIN_CODEX_ACCOUNT_ID) { const token = getMainAccountToken(); @@ -267,10 +400,32 @@ export async function resolveCodexModelEntitlements( credential, result: await modelsForCredential(credential, fetcher, now), }))); + const resolved = new Map(results.map(({ credential, result }) => [credential.accountId, result])); + // Keyring-managed main auth has no credential snapshot for OpenCodex to read. Reuse a recent + // live-request observation, or seed it from Codex's own non-synthetic authenticated cache. + if (options.credentials === undefined + && allowedAccountIds.includes(MAIN_CODEX_ACCOUNT_ID) + && !resolved.has(MAIN_CODEX_ACCOUNT_ID)) { + let cached = accountModelsCache.get(MAIN_CODEX_ACCOUNT_ID); + if (!cached || cached.expiresAt <= now) { + cached = options.nativeMainModels !== undefined + ? options.nativeMainModels === null + ? undefined + : { + credentialIdentity: "native-cache:test", + expiresAt: now + MODEL_ROSTER_TTL_MS, + models: new Set(options.nativeMainModels), + confirmed: true, + } + : nativeCodexMainModelsCache(now) ?? undefined; + if (cached) boundedCacheSet(MAIN_CODEX_ACCOUNT_ID, cached); + } + if (cached && cached.expiresAt > now) resolved.set(MAIN_CODEX_ACCOUNT_ID, cached); + } return { - modelsByAccount: new Map(results.map(({ credential, result }) => [credential.accountId, result.models])), - confirmedAccountIds: new Set(results.flatMap(({ credential, result }) => result.confirmed ? [credential.accountId] : [])), - credentialIdentities: new Map(results.map(({ credential }) => [credential.accountId, credential.credentialIdentity])), + modelsByAccount: new Map([...resolved].map(([accountId, result]) => [accountId, result.models])), + confirmedAccountIds: new Set([...resolved].flatMap(([accountId, result]) => result.confirmed ? [accountId] : [])), + credentialIdentities: new Map([...resolved].map(([accountId, result]) => [accountId, result.credentialIdentity])), }; } @@ -291,6 +446,29 @@ export async function isDirectCallerEntitledToCodexModel( return result.confirmed && result.models.has(modelId); } +/** Verify and remember the gated roster carried by a request-scoped keyring credential. */ +export async function isRequestScopedMainCallerEntitledToCodexModel( + headers: Headers, + modelId: string, + options: Pick = {}, +): Promise { + if (!ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(modelId)) return true; + const credential = directCallerCredential(headers); + if (!credential) return false; + const result = await modelsForCredential( + credential, + options.fetcher ?? fetch, + options.now ?? Date.now(), + ); + if (result.confirmed) { + boundedCacheSet(MAIN_CODEX_ACCOUNT_ID, { + ...result, + credentialIdentity: `caller:${credential.credentialIdentity.slice("direct:".length)}`, + }); + } + return result.confirmed && result.models.has(modelId); +} + export function entitledCodexAccountIdsForModel( snapshot: CodexModelEntitlementSnapshot, modelId: string | undefined, diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 3248186375..9d1674b6d0 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper"; +import { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; import { isThirtyDayOnlyCodexPlan } from "./plan"; export type StoredAccountQuota = { @@ -38,7 +39,7 @@ export type StoredAccountQuota = { /** Disk snapshot under OPENCODEX_HOME — usage percents only (no emails/tokens). */ const QUOTA_CACHE_FILENAME = "codex-quota-cache.json"; -/** Keep last-known bars across restarts; WHAM still refreshes on TTL in live/prime paths. */ +/** Keep pool-account bars across restarts; the unidentifiable keyring main stays process-local. */ const QUOTA_DISK_MAX_AGE_MS = 6 * 60 * 60_000; const QUOTA_PERSIST_DEBOUNCE_MS = 250; @@ -65,6 +66,19 @@ export type WhamUsageResponse = { additional_rate_limits?: WhamAdditionalRateLimit[] | null; }; +/** + * WebSocket-only quota update emitted by the canonical ChatGPT Codex backend. + * + * Keep this wire shape local to the quota parser. In particular, the full event may + * acquire account-identifying fields over time; callers pass it as `unknown`, and only + * the bounded numeric quota fields below are retained. + */ +type CodexRateLimitEventWindow = { + used_percent?: unknown; + window_minutes?: unknown; + reset_at?: unknown; +}; + type WhamAdditionalRateLimit = { limit_name?: unknown; metered_feature?: unknown; @@ -417,6 +431,98 @@ export function applyAccountQuotaFromUpstreamHeaders( setAccountQuotaFromParsed(accountId, quota, writerGeneration); } +function rateLimitEventRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function rateLimitEventWindow(value: unknown): WhamUsageWindow | null { + const record = rateLimitEventRecord(value); + if (!record) return null; + const row = record as CodexRateLimitEventWindow; + const usedPercent = normalizeUsagePercent(row.used_percent); + if (usedPercent === undefined) return null; + const resetAt = normalizeResetAt(row.reset_at); + const windowMinutes = windowMinutes_(row.window_minutes); + return { + used_percent: usedPercent, + ...(resetAt !== undefined ? { reset_at: resetAt } : {}), + ...(windowMinutes !== undefined && windowMinutes > 0 + ? { limit_window_seconds: Math.round(windowMinutes * 60) } + : {}), + }; +} + +/** + * Parse the official `codex.rate_limits` WebSocket event into the same quota shape used by + * WHAM and response headers. Unknown event kinds and unknown metered buckets fail closed. + * + * The canonical `codex` bucket supplies the account's ordinary burst/weekly/monthly windows. + * Bengalfox is the separately metered Spark bucket and must never overwrite those windows. + */ +export function parseCodexRateLimitEventQuota( + value: unknown, +): Omit | null { + const event = rateLimitEventRecord(value); + if (!event || event.type !== "codex.rate_limits") return null; + const details = rateLimitEventRecord(event.rate_limits); + if (!details) return null; + const primary = rateLimitEventWindow(details.primary); + const secondary = rateLimitEventWindow(details.secondary); + if (!primary && !secondary) return null; + + const rawLimitId = typeof event.metered_limit_name === "string" + ? event.metered_limit_name + : typeof event.limit_name === "string" + ? event.limit_name + : "codex"; + const limitId = rawLimitId.trim().toLowerCase().replace(/-/g, "_") || "codex"; + const planType = typeof event.plan_type === "string" ? event.plan_type : undefined; + const rateLimit = { + ...(primary ? { primary_window: primary } : {}), + ...(secondary ? { secondary_window: secondary } : {}), + }; + + if (limitId === "codex") { + return parseUsageQuota({ + ...(planType ? { plan_type: planType } : {}), + rate_limit: rateLimit, + }); + } + if (limitId === "codex_bengalfox") { + return parseUsageQuota({ + ...(planType ? { plan_type: planType } : {}), + rate_limit: {}, + additional_rate_limits: [{ + limit_name: "GPT-5.3-Codex-Spark", + metered_feature: limitId, + rate_limit: rateLimit, + }], + }); + } + return null; +} + +/** Store only the parsed quota observation; no bearer or raw event field is retained. */ +export function applyAccountQuotaFromRateLimitEvent( + accountId: string, + event: unknown, + writerGeneration = captureConfigGeneration(), +): void { + const quota = parseCodexRateLimitEventQuota(event); + if (!quota) return; + // Each WebSocket event describes one named metered bucket, not the complete account + // snapshot. In particular, the ordinary `codex` event does not repeat the separately + // metered Bengalfox/Spark window. Preserve that last observation until its own event or + // an authoritative WHAM snapshot replaces it. + const existingCustomWindows = accountQuota.get(accountId)?.customWindows; + const partialQuota = quota.customWindows === undefined && existingCustomWindows !== undefined + ? { ...quota, customWindows: existingCustomWindows } + : quota; + setAccountQuotaFromParsed(accountId, partialQuota, writerGeneration); +} + export function updateAccountQuota( accountId: string, weekly: unknown, @@ -481,6 +587,10 @@ function hydrateAccountQuotasFromDisk(): void { if (!parsed || parsed.version !== 1 || !parsed.quotas || typeof parsed.quotas !== "object") return; const now = Date.now(); for (const [accountId, quota] of Object.entries(parsed.quotas)) { + // `__main__` can name a different OS-keyring account after restart. Without persisting an + // account identifier (which this cache intentionally does not), its old quota cannot safely + // be displayed or used for routing before the current request proves the identity. + if (accountId === MAIN_CODEX_ACCOUNT_ID) continue; if (!quota || typeof quota !== "object" || typeof quota.updatedAt !== "number") continue; if (now - quota.updatedAt > QUOTA_DISK_MAX_AGE_MS) continue; if (!accountQuota.has(accountId)) accountQuota.set(accountId, quota); @@ -497,6 +607,7 @@ function schedulePersistAccountQuotas(): void { try { const quotas: Record = {}; for (const [accountId, quota] of accountQuota.entries()) { + if (accountId === MAIN_CODEX_ACCOUNT_ID) continue; quotas[accountId] = quota; } const body: QuotaDiskFile = { version: 1, quotas }; diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 10160fe913..9ef130170f 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -951,7 +951,11 @@ function getEligiblePoolAccounts( if ( excludeId !== MAIN_CODEX_ACCOUNT_ID && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) - && !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) + // A request-scoped credential is newer evidence than a proxy-owned reauth mark: Codex + // may have rotated its keyring token since an earlier turn failed. The usability check + // below applies the same exception without weakening stored pool-account quarantine. + && (!isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) + || selectionOptions?.requestScopedMainCredential === true) && getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null && !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now) && isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions) diff --git a/src/providers/openai-sidecar.ts b/src/providers/openai-sidecar.ts index 892b08462b..3bce9a2a12 100644 --- a/src/providers/openai-sidecar.ts +++ b/src/providers/openai-sidecar.ts @@ -110,6 +110,9 @@ export async function resolveFirstUsableOpenAiSidecar( if (!(error instanceof ForwardAdmissionCredentialError)) throw error; callerBearerMayBeForwarded = false; } + const callerMainHeaders = callerBearerMayBeForwarded + ? directSidecarHeaders(incomingHeaders) + : undefined; for (const candidate of candidates) { if (exactAccount) { // An account-qualified model is an explicit user choice. Resolve the stored @@ -118,6 +121,7 @@ export async function resolveFirstUsableOpenAiSidecar( const authContext = await resolveCodexAuthContext(incomingHeaders, config, "pool", { accountId: exactAccount.accountId, modelId: exactAccount.modelId, + requestScopedMainCredential: callerMainHeaders !== undefined, beginCodexAccountSelection: options.beginCodexAccountSelection, }); if ((authContext.kind !== "pool" && authContext.kind !== "main-pool") @@ -146,7 +150,7 @@ export async function resolveFirstUsableOpenAiSidecar( } if (candidate.accountMode === "direct") { if (!callerBearerMayBeForwarded || !hasCallerCodexBearer(incomingHeaders)) continue; - const headers = directSidecarHeaders(incomingHeaders); + const headers = callerMainHeaders; if (!headers) continue; return { ...candidate, @@ -155,6 +159,7 @@ export async function resolveFirstUsableOpenAiSidecar( }; } const authContext = await resolveCodexAuthContext(incomingHeaders, config, candidate.accountMode, { + requestScopedMainCredential: callerMainHeaders !== undefined, beginCodexAccountSelection: options.beginCodexAccountSelection, }); if (!isCodexAuthContextUsable(authContext, config)) continue; diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 0d62f232c9..105cdc19a5 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -29,6 +29,7 @@ import { openRouterRoutingConfigError } from "../providers/openrouter-routing"; import { modelAutoCompactTokenLimitsConfigError } from "../providers/auto-compact-budget"; import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; import { xaiResponsesOptInState } from "../providers/xai-responses-opt-in"; +import { extractAccountId } from "../oauth/chatgpt"; let _corsOrigin = "http://localhost:10100"; export function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; } @@ -431,6 +432,23 @@ export function validateForwardAdmissionCredential(headers: Headers, config: Ocx if (bearer && isProxyAdmissionSecret(bearer, config)) throw new ForwardAdmissionCredentialError(); } +/** + * Whether Authorization carries a caller-owned bearer that may leave the process. + * + * This is intentionally stricter than "a bearer exists": Pool can use a native Codex request's + * keyring-managed token, but an OpenCodex data/admin/session credential must still be substituted + * or rejected and can never become a request-scoped main credential. + */ +export function hasForwardableCodexBearer(headers: Headers, config: OcxConfig): boolean { + const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + // Codex versions differ on whether ChatGPT-Account-Id accompanies the first request after a + // keyring login. The access-token claim can supply it at materialization time; requiring both + // headers here made OpenCodex silently fall back to a stale auth.json credential. + const accountId = headers.get("chatgpt-account-id")?.trim() + || (bearer ? extractAccountId(undefined, bearer) : undefined); + return !!bearer && !!accountId && !isProxyAdmissionSecret(bearer, config); +} + /** * Resolving form of `hasValidApiAuth`: identical header precedence, identical * decision, but it names the admission instead of collapsing it to a boolean. diff --git a/src/server/index.ts b/src/server/index.ts index af7b2dbf91..58b0f1b218 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -65,6 +65,7 @@ import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; import { availableAccountGatedNativeModels, resolveCodexModelEntitlements, + seedMainCodexModelEntitlementsFromNativeCache, } from "../codex/model-entitlements"; export { clearThreadAccountMap, @@ -586,6 +587,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server void; toggleDefaultModeRequestUserInput?: (enabled: boolean) => void; createManagementConvergeCodex?: (config: Readonly) => ConvergeCodex; + /** Authenticated native-roster seam; route tests must not inspect the developer's credentials. */ + resolveCodexModelEntitlements?: typeof resolveCodexModelEntitlements; /** Startup-health seam keeps route tests from launching platform probes. */ getCachedStartupHealth?: (config: Pick) => Promise; /** diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index ec5498bfa6..a489d6f860 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -103,9 +103,14 @@ import { effectiveModelAliases, MODEL_ALIAS_PATTERN } from "../../providers/defa import { comboPublicModelId } from "../../combos/types"; import { COMBO_NAMESPACE, comboDisabledModelSelectors, comboModelId, preservesPhysicalComboProvider } from "../../combos"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; -import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; +import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; +import { + availableAccountGatedNativeModels, + resolveCodexModelEntitlements, +} from "../../codex/model-entitlements"; +import { MAIN_CODEX_ACCOUNT_ID } from "../../codex/main-account"; import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; import { resolveCodexHomeDir } from "../../codex/home"; import { readUsageEntries } from "../../usage/log"; @@ -692,19 +697,49 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise = {}; for (const m of models) (available[m.provider] ??= []).push(m.id); + const bareEligibleAccountIds = providerCodexAccountMode( + OPENAI_CODEX_PROVIDER_ID, + config.providers[OPENAI_CODEX_PROVIDER_ID], + ) === "direct" ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined; + const availableGatedModels = modelEntitlements + ? availableAccountGatedNativeModels(modelEntitlements, bareEligibleAccountIds) + : new Set(); + const nativeOpenAiModels = includeNativeOpenAi + ? nativeModelRows(config, { availableGatedModels }).map(row => row.slug) + : []; + if (nativeOpenAiModels.length > 0) { + available[OPENAI_CODEX_PROVIDER_ID] = [...new Set([ + ...nativeOpenAiModels, + ...(available[OPENAI_CODEX_PROVIDER_ID] ?? []), + ])]; + } const selected: Record = {}; - // Live-catalog provenance. The GUI cannot infer this by subtracting known custom ids: an id - // that is both custom and discovered would make a real live catalog look custom-only. + // Authoritative-catalog provenance. The GUI cannot infer this by subtracting known custom + // ids: an id that is both custom and discovered would make a real catalog look custom-only. + // Native OpenAI rows are equally authoritative even though they are entitlement-derived. const liveModelCounts: Record = {}; for (const [name, prov] of Object.entries(config.providers)) { if (Array.isArray(prov.selectedModels) && prov.selectedModels.length > 0) selected[name] = [...prov.selectedModels]; const liveCount = getProviderLiveModelCount(name); if (liveCount !== undefined) liveModelCounts[name] = liveCount; } + if (nativeOpenAiModels.length > 0) { + liveModelCounts[OPENAI_CODEX_PROVIDER_ID] = Math.max( + liveModelCounts[OPENAI_CODEX_PROVIDER_ID] ?? 0, + nativeOpenAiModels.length, + ); + } return jsonResponse({ selected, available, liveModelCounts }); } if (url.pathname === "/api/model-presets" && req.method === "GET") { diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 4416173312..281e2b4dac 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -74,10 +74,15 @@ import { upstreamHostHealthKey, type UpstreamHostAdmissionLease, } from "../../codex/upstream-host-health"; -import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors"; +import { + ForwardAdmissionCredentialError, + hasForwardableCodexBearer, + validateForwardAdmissionCredential, +} from "../auth-cors"; import type { DataPlaneAdmission } from "../auth-cors"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers"; +import { observeSuccessfulCodexManagedMainUsage } from "../../codex/main-account-observation"; import { slugsEquivalent } from "../../providers/slug-codec"; import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models"; import { isUsageDebugEnabled } from "../../usage/debug"; @@ -157,14 +162,24 @@ async function resolveAlternateCompactContext(args: { route: { provider: OcxProviderConfig; codexAccountMode?: CodexAccountMode }; selectedModelId: string | undefined; excludeAccountId: string | null; + requestScopedMainCredential: boolean; turnAdmissionLease?: AdmissionLease; }): Promise<{ authCtx: CodexAuthContext; provider: OcxProviderConfig; headers: Headers } | null> { - const { req, config, route, selectedModelId, excludeAccountId, turnAdmissionLease } = args; + const { + req, + config, + route, + selectedModelId, + excludeAccountId, + requestScopedMainCredential, + turnAdmissionLease, + } = args; if (!route.codexAccountMode || !excludeAccountId) return null; try { const authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { ...(selectedModelId ? { modelId: selectedModelId } : {}), excludeAccountId, + requestScopedMainCredential, beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease), }); if (!authCtx.accountId || authCtx.accountId === excludeAccountId) return null; @@ -326,6 +341,9 @@ export async function handleResponsesCompact( // consume that credential. See the longer note in core.ts resolveResponsesCodexAuth. const substituteMainCredential = admission?.source === "bearer" && route.codexAccountMode !== undefined; + const requestScopedMainCredential = route.codexAccountMode !== undefined + && !substituteMainCredential + && hasForwardableCodexBearer(req.headers, config); if (route.codexAccountMode === "direct" && !substituteMainCredential) { try { validateForwardAdmissionCredential(req.headers, config); } catch (err) { @@ -372,6 +390,7 @@ export async function handleResponsesCompact( accountId: route.codexAccountId, modelId: selectedModelId, substituteMainCredentialForDirect: substituteMainCredential, + requestScopedMainCredential, beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease), }); logCtx.accountLogLabel = codexAuthContextLogLabel(authCtx, config); @@ -568,6 +587,7 @@ export async function handleResponsesCompact( route, selectedModelId, excludeAccountId: authCtx.accountId, + requestScopedMainCredential, turnAdmissionLease, }); // Resolution can await a credential refresh, so the client may have gone away @@ -634,6 +654,12 @@ export async function handleResponsesCompact( upstream.headers.get("x-codex-secondary-reset-at"), upstream.headers.get("x-codex-tertiary-reset-at"), ].filter(Boolean); + if (upstream.ok + && outcomeCtx.kind === "main-pool" + && outcomeCtx.credentialSource === "caller" + && isCanonicalOpenAiForwardProvider(route.provider)) { + void observeSuccessfulCodexManagedMainUsage(req.headers); + } const buffered = await bufferCompactResponse(upstream, req.signal); // Record pool health only after the body is fully delivered (or definitively failed). // A premature 200 would clear soft-avoid while the client still sees a buffer 502. diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 75d728f24e..2b5d6cbe8d 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -151,6 +151,8 @@ import { } from "../../codex/model-entitlements"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-models"; import { MAIN_CODEX_ACCOUNT_ID } from "../../codex/main-account"; +import { applyAccountQuotaFromRateLimitEvent } from "../../codex/quota"; +import { observeSuccessfulCodexManagedMainUsage } from "../../codex/main-account-observation"; import { captureCodexAffinityDiagnostic } from "../../codex/affinity-debug"; import { computeQuotaCooldown, @@ -167,7 +169,11 @@ import { fetchWithTransientRetry, prepareSameTarget429Wait, } from "../../lib/upstream-retry"; -import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors"; +import { + ForwardAdmissionCredentialError, + hasForwardableCodexBearer, + validateForwardAdmissionCredential, +} from "../auth-cors"; import type { DataPlaneAdmission } from "../auth-cors"; import { createTranslatorBudget, isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; @@ -473,11 +479,15 @@ function bindRouteReasoningReplayScope(args: { || args.codexAuthContext?.kind === "main-pool" ? args.codexAuthContext : undefined; + const storedPoolContext = poolContext?.kind === "main-pool" + && poolContext.credentialSource === "caller" + ? undefined + : poolContext; credentialIdentity = reasoningReplayCodexCredentialIdentity({ - authorization: poolContext - ? `Bearer ${poolContext.accessToken}` + authorization: storedPoolContext + ? `Bearer ${storedPoolContext.accessToken}` : args.forwardHeaders?.get("authorization"), - chatgptAccountId: poolContext?.chatgptAccountId + chatgptAccountId: storedPoolContext?.chatgptAccountId ?? args.forwardHeaders?.get("chatgpt-account-id"), accountId: poolContext?.accountId, credentialGeneration: poolContext?.kind === "pool" @@ -486,13 +496,11 @@ function bindRouteReasoningReplayScope(args: { writerGeneration: poolContext?.writerGeneration, headers: provider.headers, }); - // Durable identity requires a STABLE, TRUSTED account handle. Pool context comes from - // our own account store; a client-supplied chatgpt-account-id header is attacker - // -influenceable bucket selection and a bearer alone is rotating material — both are - // refused, so direct-forward turns get no durable scope (fail closed; the in-process - // cache still covers same-process replay). + // Durable identity requires a STABLE, TRUSTED account handle. Pool selection supplies + // that handle even when Codex owns the request-scoped bearer; a client-supplied + // chatgpt-account-id header is never used as the durable handle by itself. const codexDurableHandle = poolContext?.accountId - ?? poolContext?.chatgptAccountId + ?? storedPoolContext?.chatgptAccountId ?? undefined; credentialDurableIdentity = durableReplayCredentialIdentity( "codex", @@ -803,6 +811,25 @@ export function usesCodexForwardPoolAuth( && provider.authMode === "forward" && provider.adapter === "openai-responses"; } +/** + * Bind a quota-only WebSocket frame to the credential context that created that socket. + * Capture the local account id/generation now: a later same-request failover must not be able + * to reattribute an earlier account's frame. The parser retains only numeric quota windows. + */ +function codexRateLimitObserver( + authCtx: CodexAuthContext, + provider: OcxProviderConfig, +): ((event: unknown) => void) | undefined { + if (!isCanonicalOpenAiForwardProvider(provider)) return undefined; + const accountId = authCtx.kind === "pool" || authCtx.kind === "main-pool" + ? authCtx.accountId + : MAIN_CODEX_ACCOUNT_ID; + const writerGeneration = authCtx.kind === "pool" || authCtx.kind === "main-pool" + ? authCtx.writerGeneration + : undefined; + return event => applyAccountQuotaFromRateLimitEvent(accountId, event, writerGeneration); +} + export function preAuthUpstreamHostCircuitKey( route: Pick, config: OcxConfig, @@ -1022,15 +1049,16 @@ async function retryCodexPoolOnAlternateAccount( if (!retryAuthCtx && firstAuthCtx.fixedAccount) return { kind: "no-alternate" }; try { retryAuthCtx ??= await resolveCodexAuthContext( - req.headers, - config, - "pool", - { - excludeAccountId: firstAuthCtx.accountId, - modelId: route.modelId, - beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease), - }, - ); + req.headers, + config, + "pool", + { + excludeAccountId: firstAuthCtx.accountId, + modelId: route.modelId, + requestScopedMainCredential: hasForwardableCodexBearer(req.headers, config), + beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease), + }, + ); } catch (error) { if ( !(error instanceof CodexPoolAuthenticationError) @@ -1139,6 +1167,7 @@ async function retryCodexPoolOnAlternateAccount( providerFetch(route.provider, options.codexWsRuntimeIdentity, { providerName: route.providerName, modelId: route.modelId, + onCodexRateLimits: codexRateLimitObserver(retryAuthCtx, route.provider), }), // Credential-bearing forward send: never follow a redirect into a // dead-host rejection after the credential was seen (#914). @@ -1587,6 +1616,9 @@ async function resolveResponsesCodexAuth( // no-ChatGPT-login install keeps working. const substituteMainCredential = options.admission?.source === "bearer" && (route.codexAccountMode !== undefined || isCanonicalOpenAiForwardProvider(route.provider)); + const requestScopedMainCredential = route.codexAccountMode !== undefined + && !substituteMainCredential + && hasForwardableCodexBearer(req.headers, config); if (route.codexAccountMode === "direct" && !substituteMainCredential) { validateForwardAdmissionCredential(req.headers, config); } @@ -1596,6 +1628,7 @@ async function resolveResponsesCodexAuth( accountId: route.codexAccountId, modelId: route.modelId, substituteMainCredentialForDirect: substituteMainCredential, + requestScopedMainCredential, beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease), resolveCodexModelEntitlements: options.resolveCodexModelEntitlements, }); @@ -3417,6 +3450,7 @@ async function handleResponsesInner( providerFetch(route.provider, options.codexWsRuntimeIdentity, { providerName: route.providerName, modelId: route.modelId, + onCodexRateLimits: codexRateLimitObserver(authCtx, route.provider), }), route.provider.authMode === "forward") // Every real attempt response — including an intermediate 5xx the @@ -3487,6 +3521,7 @@ async function handleResponsesInner( providerFetch(route.provider, options.codexWsRuntimeIdentity, { providerName: route.providerName, modelId: route.modelId, + onCodexRateLimits: codexRateLimitObserver(authCtx, route.provider), }), route.provider.authMode === "forward") .then(response => { @@ -3590,6 +3625,7 @@ async function handleResponsesInner( providerFetch(route.provider, options.codexWsRuntimeIdentity, { providerName: route.providerName, modelId: route.modelId, + onCodexRateLimits: codexRateLimitObserver(authCtx, route.provider), }), route.provider.authMode === "forward") .then(res => { @@ -3652,6 +3688,7 @@ async function handleResponsesInner( providerFetch(route.provider, options.codexWsRuntimeIdentity, { providerName: route.providerName, modelId: route.modelId, + onCodexRateLimits: codexRateLimitObserver(authCtx, route.provider), }), route.provider.authMode === "forward") .then(res => { @@ -3756,6 +3793,12 @@ async function handleResponsesInner( } break; } + if (upstreamResponse.ok + && authCtx.kind === "main-pool" + && authCtx.credentialSource === "caller" + && isCanonicalOpenAiForwardProvider(route.provider)) { + void observeSuccessfulCodexManagedMainUsage(req.headers); + } const headers = sanitizePassthroughHeaders(upstreamResponse.headers); const resolvedModel = headers.get("openai-model")?.trim(); if (resolvedModel && !logCtx.preserveResolvedModelFromRoute) logCtx.resolvedModel = resolvedModel; @@ -4396,7 +4439,11 @@ async function handleResponsesInner( const imageProviderFetch = providerFetch( route.provider, options.codexWsRuntimeIdentity, - { providerName: route.providerName, modelId: route.modelId }, + { + providerName: route.providerName, + modelId: route.modelId, + onCodexRateLimits: codexRateLimitObserver(authCtx, route.provider), + }, ); const imgResponse = await runWithImageBridge({ parsed, adapter, @@ -4572,6 +4619,7 @@ async function handleResponsesInner( // Cursor HTTP/1.1 consumes it for RunSSE; every BidiAppend and redial then waits on // the same provider queue through this stateful wrapper. pacingSlotAcquired: true, + onCodexRateLimits: codexRateLimitObserver(authCtx, route.provider), }, ); await runTurnAdapter.runTurn?.( @@ -4905,6 +4953,7 @@ async function handleResponsesInner( executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, { providerName: route.providerName, modelId: route.modelId, + onCodexRateLimits: codexRateLimitObserver(authCtx, route.provider), }), }); } else { @@ -4924,6 +4973,7 @@ async function handleResponsesInner( providerFetch(route.provider, options.codexWsRuntimeIdentity, { providerName: route.providerName, modelId: route.modelId, + onCodexRateLimits: codexRateLimitObserver(authCtx, route.provider), })); }, { abortSignal: upstream.signal, label: safeHostLabel(builtInitialRequest.url) }, @@ -5012,6 +5062,7 @@ async function handleResponsesInner( executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, { providerName: route.providerName, modelId: route.modelId, + onCodexRateLimits: codexRateLimitObserver(authCtx, route.provider), }), }); } @@ -5021,6 +5072,7 @@ async function handleResponsesInner( providerFetch(route.provider, options.codexWsRuntimeIdentity, { providerName: route.providerName, modelId: route.modelId, + onCodexRateLimits: codexRateLimitObserver(authCtx, route.provider), })); } finally { retryRequest.releaseBodyObservation?.(); @@ -5422,6 +5474,7 @@ async function handleResponsesInner( executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, { providerName: route.providerName, modelId: nextParsed.modelId, + onCodexRateLimits: codexRateLimitObserver(authCtx, route.provider), }), }); } @@ -5444,6 +5497,7 @@ async function handleResponsesInner( providerFetch(route.provider, options.codexWsRuntimeIdentity, { providerName: route.providerName, modelId: nextParsed.modelId, + onCodexRateLimits: codexRateLimitObserver(authCtx, route.provider), }), ); }, diff --git a/src/server/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index 898275e6fc..3940fc1f18 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -56,6 +56,8 @@ export interface ProviderFetchOptions { modelId?: string; /** One pacing slot was acquired immediately before this fetch wrapper was created. */ pacingSlotAcquired?: boolean; + /** Canonical Codex WS quota observation; the transport never forwards this frame. */ + onCodexRateLimits?: (event: unknown) => void; } export function providerFetch( @@ -81,7 +83,9 @@ export function providerFetch( // used, protocol pin included: a WS turn that falls back is serving the // request over HTTP, and dropping the provider's `upstreamHttpVersion` // there would silently negotiate a transport the operator ruled out. - return codexWsUpstreamFetch(input, init, httpFetch, runtime); + return codexWsUpstreamFetch(input, init, httpFetch, runtime, { + onRateLimits: options.onCodexRateLimits, + }); } return httpFetch(input, init); }; diff --git a/src/server/responses/ws-upstream.ts b/src/server/responses/ws-upstream.ts index 275eff6dfb..fd6abbdfe1 100644 --- a/src/server/responses/ws-upstream.ts +++ b/src/server/responses/ws-upstream.ts @@ -55,6 +55,11 @@ export type BunRuntimeIdentity = { export type BunRuntimeGateInput = string | BunRuntimeIdentity; +export interface CodexWsUpstreamOptions { + /** Observe quota-only frames without adding them to the downstream SSE surface. */ + onRateLimits?: (event: unknown) => void; +} + const codexWsUpstreamResponses = new WeakSet(); /** True only for a successful Codex WebSocket upgrade, never an HTTP fallback. */ @@ -172,6 +177,7 @@ export function codexWsUpstreamFetch( init: RequestInit, sseFallback: typeof globalThis.fetch, runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(), + options: CodexWsUpstreamOptions = {}, ): Promise { if (!bunSupportsBoundedCodexWsRelay(runtime)) { return sseFallback(url, init); @@ -292,17 +298,17 @@ export function codexWsUpstreamFetch( }, new ByteLengthQueuingStrategy({ highWaterMark: MAX_CODEX_WS_QUEUE_BYTES })); const response = new Response(stream, { status: 200, - // The 101 response headers (x-codex-*-reset-at quota hints) are not - // exposed by Bun's WebSocket; the periodic quota poller covers those. + // The 101 response headers are not exposed by Bun's WebSocket. Quota is observed + // from the backend's dedicated rate-limit frame instead. headers: { "content-type": "text/event-stream; charset=utf-8" }, }); codexWsUpstreamResponses.add(response); resolve(response); }); - ws.addEventListener("message", (event) => { + ws.addEventListener("message", (messageEvent) => { if (!controller || terminal) return; - const text = typeof event.data === "string" ? event.data : ""; + const text = typeof messageEvent.data === "string" ? messageEvent.data : ""; if (!text) return; // UTF-8 byte length is always at least the JS string length. Reject this // cheap lower bound before parsing so an obviously oversized frame does @@ -316,9 +322,22 @@ export function codexWsUpstreamFetch( failStream("codex websocket frame exceeds the response size limit"); return; } + let parsedEvent: unknown; let type: unknown; - try { type = (JSON.parse(text) as { type?: unknown }).type; } catch { return; } + try { + parsedEvent = JSON.parse(text) as unknown; + type = parsedEvent && typeof parsedEvent === "object" && !Array.isArray(parsedEvent) + ? (parsedEvent as { type?: unknown }).type + : undefined; + } catch { return; } if (typeof type !== "string") return; + if (type === "codex.rate_limits") { + // This event is useful to the proxy's account/quota observer but is not part of the + // Responses SSE contract exposed to downstream clients. An observer is best-effort: + // malformed or future payloads must never break an otherwise healthy inference stream. + try { options.onRateLimits?.(parsedEvent); } catch { /* observation failure is non-fatal */ } + return; + } // Relay only the event surface the SSE path produces today. WS-only // frames (codex.rate_limits, responsesapi.websocket_timing) are dropped // so downstream clients see exactly the stream shape they always got. diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 150f7b781a..9d2efb7740 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -502,10 +502,10 @@ The WebSocket endpoint exists at `/v1/responses`, but discovery is opt-in: ``` `websocketsEnabled(config)` is true only for an explicit `true`. When false, opencodex removes -`supports_websockets` from injected provider tables and routed catalog entries, keeping Codex on -HTTP/SSE. When true, Codex may use Responses WebSocket frames handled by `src/server/ws-bridge.ts`. -If Codex still attempts a WebSocket upgrade while the feature is disabled, `/v1/responses` rejects -the upgrade with 426 so Codex falls back to HTTP cleanly. +`supports_websockets` from injected provider tables and routed catalog entries. Codex's built-in +`openai` provider nevertheless attempts a Responses WebSocket whenever `openai_base_url` points at +the proxy, so `/v1/responses` accepts that upgrade for compatibility even when advertising is off. +When true, routed rows may opt into the same frames handled by `src/server/ws-bridge.ts`. That setting controls the client-facing upgrade only. The transparent upstream ChatGPT WS optimization described above is selected independently and still diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 4294a24551..7dd9032816 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -79,8 +79,9 @@ wins when both are present, and `x-api-key` is still refused there. That admissi because `materializeCodexUpstreamAuth` SUBSTITUTES the stored main credential for it and throws before any upstream I/O when none is usable — the forwarding guard is NOT relaxed, and widening admission without guaranteed substitution would create exactly the leak it prevents. A bearer that -is not one of our secrets stays unadmitted and remains Codex Direct passthrough, so the two bearer -domains never mix. +is not one of our secrets never proves proxy admission. When a dedicated header independently +admits the request, that caller-owned bearer can remain Codex Direct passthrough or serve as +request-scoped `__main__` Pool auth; it is never persisted. The two bearer domains never mix. Audit item #16 remains partially deferred. This credential split protects new WebSocket handshakes, but the following established-connection controls are intentionally outside this batch and must not @@ -110,7 +111,7 @@ this document owns is which module holds which area and what invariant that area | Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. | | OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, and remove one. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. | | Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | -| OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`08_openai-provider-tiers.md`](08_openai-provider-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | +| OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs expose tri-state `authStatus` and optional `credentialSource`: a successful native request proves Codex-managed state without retaining token material, while absence of `auth.json` is never presented as sign-out. The same successful request may start one bounded, read-only WHAM usage probe per five-minute window so the managed row can learn WHAM-only plan/quota fields such as reset-credit count; only parsed metadata is retained, the bearer is discarded when that request-scoped probe settles, and reset-credit mutation remains unavailable without a file-backed credential. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`08_openai-provider-tiers.md`](08_openai-provider-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | | Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | | V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. | | Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). | @@ -121,7 +122,7 @@ this document owns is which module holds which area and what invariant that area | Sidecar/shadow-call settings | `src/server/management/config-routes.ts` — `GET/PUT /api/sidecar-settings` and `GET/PUT /api/shadow-call-settings`. PUT accepts model and backend (web-search union: openai/anthropic/xai/gemini/exa; xAI is live through stored Grok OAuth, while Gemini/Exa remain inert until their executors ship) plus validated `webSearch.xSearch`, optional `webSearch.exaApiKey` (write/clear only — never echoed by GET or the PUT response; redact.ts strips it from logs), `webSearch.reasoning`, `vision.reasoning`, `vision.enabled`, `vision.maxDescriptionsPerTurn`, and `vision.timeoutMs`; the read and PUT-response payload reports model, backend, reasoning, enabled, the vision per-turn limit, and timeout. `timeoutMs` is validated against the runtime integer bounds in `src/vision/timeout-bounds.ts`. Provider/OAuth credentials live in their stores; `exaApiKey` is the one sidecar-owned secret and follows the write-only contract above. Both shadow-call responses also report the resolved `sourceModels` — the prefixes the runtime actually intercepts (`src/lib/shadow-call.ts`, default `gpt-5.4-mini` + `gpt-5.6-luna`), so no client hard-codes a helper slug that a Codex release can invalidate. | | Storage | `src/server/management/logs-usage-routes.ts` — `GET /api/storage`, `POST /api/storage/cleanup/preview` and `/api/storage/cleanup`, `GET /api/storage/trash`, `POST /api/storage/trash/restore`, and `GET/PUT /api/storage/cleanup-policy` plus `POST /api/storage/cleanup-policy/run`. `GET /api/storage/cleanup-policy/test-stream` and `GET /api/storage/trash/restore/test-stream` exist for progress-stream testing. Cleanup takes an explicit `mode`: `quarantine` moves to trash and is restorable, `permanent` is not. The caller must name the mode — there is no default that silently deletes. | | Provider quotas and tests | `src/server/management/provider-routes.ts` — `GET /api/provider-quotas`, `POST /api/providers/test`, `GET/PUT /api/provider-context-caps`, `GET /api/provider-presets`. A quota read may be served from cache or force-refreshed; absent quota data is reported as unknown rather than as a measured zero. | -| Models and visibility | `src/server/management/model-routes.ts` — `GET /api/models`, `PUT /api/disabled-models`, `PUT /api/model-visibility`, `PUT /api/selected-models`, `GET/POST /api/custom-models`. Visibility writes trigger catalog sync through the owning server path. | +| Models and visibility | `src/server/management/model-routes.ts` — `GET /api/models`, `PUT /api/disabled-models`, `PUT /api/model-visibility`, `GET/PUT /api/selected-models`, `GET/POST /api/custom-models`. The selected-models read resolves the shared authenticated entitlement snapshot before projecting the bare native catalog into the canonical OpenAI provider as an authoritative model source; it does not use provider discovery or rewrite Codex's catalog. Visibility writes trigger catalog sync through the owning server path. | | Effort and fallback | `src/server/management/agent-settings-routes.ts` — `GET/PUT /api/effort-caps`, `/api/subagent-models`, `/api/subagent-model-fallback`. Caps clamp; they do not reject. | | Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply`, `GET /api/claude-desktop/status`, `GET/PUT /api/claude-code`. Apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`04_transports-and-sidecars.md`](04_transports-and-sidecars.md)). | | Combos | `src/server/management/combo-routes.ts` — `GET/PUT/DELETE /api/combos` own provider combination and failover definitions. | diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index ab15088fb4..ea70449301 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -18,6 +18,27 @@ engine. Direct short-circuits that engine before pool state is read or mutated a current caller/main-login bearer. Neither mode may fall through to `openai-apikey`, and the API provider may not fall through to Codex-login credentials. +The main login has two credential-owner forms. A legacy/file-backed Codex login can still be read +from `$CODEX_HOME/auth.json`. A modern Codex login can be owned by Codex in the operating-system +keyring, where OpenCodex neither can nor should extract a reusable token. For that form: + +- dashboard polling does not inspect the keyring or launch Codex to infer login presence. Until a + successful native request arrives, the management DTO reports keyring visibility as unavailable + and the dashboard explains that account details are pending. A successful native request records + only decoded email/plan metadata plus parsed numeric quota windows from upstream + headers or the WebSocket-only `codex.rate_limits` frame. It may also use that request's bearer for + one bounded, read-only WHAM usage probe per five-minute window to learn fields absent from those + transports, including reset-credit count. The raw frame, WHAM body, and token material are not + retained; +- the management DTO distinguishes `authenticated`, `logged-out`, and `unavailable`. Missing + `auth.json` is unavailable, not sign-out, until a native request proves the keyring credential; +- a native Codex request admitted independently of its `Authorization` header may use that + caller-owned bearer for `__main__` in Pool. The bearer and account header remain scoped to that + request and never enter config, provider overrides, or the account store; +- every OpenCodex data/admin/session admission secret is ineligible for that path. A non-Codex + client that supplies no caller-owned bearer still needs a file-backed main credential or a + separately added Pool account. + The two routes also keep separate request-compatibility contracts. The canonical ChatGPT Codex forward destination removes public `prompt_cache_options` because that backend rejects the field before inference; `prompt_cache_key` remains supported. `openai-apikey` and noncanonical/custom @@ -273,7 +294,10 @@ Models always shows one bare OpenAI group. Disabled or absent canonical `openai` restored from the Accounts picker or Codex Auth through gated recovery: missing rows are created from the canonical preset, disabled canonical rows are re-enabled without replacing saved mode or model settings, and noncanonical `openai` rows never receive that recovery path. +The provider-scoped Models tab reads that same entitlement-backed bare native catalog through +`GET /api/selected-models`; provider discovery is not the authority for Codex-login models. -`GET /api/codex-auth/accounts?refresh=1` treats missing main credentials, HTTP 401, and allowlisted -terminal 403 codes as `needsReauth`; generic permission failures remain non-terminal, and a -successful main usage refresh clears the runtime mark. +`GET /api/codex-auth/accounts?refresh=1` treats a file-backed main HTTP 401 and allowlisted terminal +403 codes as `needsReauth`; unavailable keyring state and generic permission failures remain +non-terminal. A successful file-backed usage refresh or native keyring-authenticated request clears +the runtime mark. Management reads never force token refresh or quota access. diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index c8e552771f..ed658c792b 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -71,6 +71,7 @@ import { resolveFirstUsableOpenAiSidecar, } from "../src/providers/openai-sidecar"; import { BOUNDED_BODY_MAX_BYTES } from "../src/lib/bounded-body"; +import { observeSuccessfulCodexManagedMainRequest } from "../src/codex/main-account-observation"; const TEST_DIR = join(import.meta.dir, ".tmp-codex-auth-api-test"); const TEST_CODEX_HOME = join(TEST_DIR, "codex"); @@ -217,10 +218,11 @@ async function completeMockCodexOAuth(options: { } } -function chatgptPlanJwt(plan: string, accountId = "acct"): string { +function chatgptPlanJwt(plan: string, accountId = "acct", email?: string): string { const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"); const body = Buffer.from(JSON.stringify({ chatgpt_account_id: accountId, + ...(email ? { email } : {}), "https://api.openai.com/auth": { chatgpt_account_id: accountId, chatgpt_plan_type: plan }, })).toString("base64url"); return `${header}.${body}.sig`; @@ -741,10 +743,50 @@ describe("codex-auth API", () => { expect(resp).not.toBeNull(); const data = await resp!.json() as { accounts: unknown[] }; expect(Array.isArray(data.accounts)).toBe(true); - const main = (data.accounts as { isMain: boolean; hasCredential: boolean; needsReauth?: boolean }[]).find(a => a.isMain); + const main = (data.accounts as { + isMain: boolean; + hasCredential: boolean; + needsReauth?: boolean; + authStatus?: string; + }[]).find(a => a.isMain); expect(main).toBeTruthy(); expect(main?.hasCredential).toBe(false); - expect(main?.needsReauth).toBe(true); + expect(main?.needsReauth).toBe(false); + expect(main?.authStatus).toBe("unavailable"); + }); + + test("main account projects auth proven by a successful Codex-managed request", async () => { + markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 99); + const token = chatgptPlanJwt("pro", "managed-account", "managed@example.test"); + expect(observeSuccessfulCodexManagedMainRequest(new Headers({ + authorization: `Bearer ${token}`, + }))).toBe(true); + // The first request-scoped identity must discard any quota left by an earlier file login. + // Core applies the current response's quota headers immediately after this observation. + expect(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)).toBeNull(); + const accounts = await listCodexAuthAccounts(makeConfig()); + const main = accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID); + expect(main).toMatchObject({ + plan: "pro", + hasCredential: true, + needsReauth: false, + authStatus: "authenticated", + credentialSource: "codex-managed", + }); + expect(main?.email).not.toContain("managed@example.test"); + }); + + test("missing keyring visibility stays unavailable instead of becoming reauthentication", async () => { + markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 73); + const unavailable = await listCodexAuthAccounts(makeConfig(), true); + expect(unavailable.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)).toMatchObject({ + hasCredential: false, + needsReauth: false, + authStatus: "unavailable", + quota: null, + }); }); test("main account 401 with an undecodable-exp token is terminal and marks needsReauth (#327, #1932)", async () => { diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index da4d6cb5d0..514b02f474 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -66,6 +66,7 @@ import { tryAdmitTurn, } from "../src/server/lifecycle"; import type { CodexModelEntitlementSnapshot } from "../src/codex/model-entitlements"; +import { fakeChatGptJwt } from "./helpers/fake-chatgpt-jwt"; let testDir: string; let previousOpencodexHome: string | undefined; @@ -420,6 +421,42 @@ describe("Codex auth context", () => { }); }); + test("revalidates a persisted synthetic main grant against the request-scoped caller", async () => { + const headers = new Headers({ + authorization: "Bearer caller-native-token", + "chatgpt-account-id": "caller-native-account", + }); + const entitlementSnapshot: CodexModelEntitlementSnapshot = { + modelsByAccount: new Map([[MAIN_CODEX_ACCOUNT_ID, new Set(["gpt-daybreak-blue-latest"])]]), + confirmedAccountIds: new Set([MAIN_CODEX_ACCOUNT_ID]), + credentialIdentities: new Map([[MAIN_CODEX_ACCOUNT_ID, "synthetic-cache:test"]]), + }; + let callerEntitled = false; + let callerChecks = 0; + const options = { + accountId: MAIN_CODEX_ACCOUNT_ID, + modelId: "gpt-daybreak-blue-latest", + isMainAccountTokenLive: () => false, + requestScopedMainCredential: true, + resolveCodexModelEntitlements: async () => entitlementSnapshot, + isRequestScopedMainCallerEntitledToCodexModel: async () => { + callerChecks += 1; + return callerEntitled; + }, + }; + + await expect(resolveCodexAuthContext(headers, config(), "pool", options)) + .rejects.toThrow("Selected Codex account does not support this model"); + callerEntitled = true; + await expect(resolveCodexAuthContext(headers, config(), "pool", options)) + .resolves.toMatchObject({ + kind: "main-pool", + accountId: MAIN_CODEX_ACCOUNT_ID, + credentialSource: "caller", + }); + expect(callerChecks).toBe(2); + }); + test("exact account-gated routing fails closed for an unentitled account", async () => { const cfg = config(); saveCodexAccountCredential("pool-a", { @@ -745,6 +782,72 @@ describe("Codex auth context", () => { }); expect(cfg.activeCodexAccountId).toBe("pool-a"); }); + + test("Pool can use the native caller bearer for main without persisting an override", async () => { + const cfg = config(); + const inbound = new Headers({ + authorization: "Bearer caller-native-token", + "chatgpt-account-id": "caller-native-account", + "openai-beta": "responses=experimental", + }); + markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + + try { + // Merely carrying a bearer is not enough: the server must first classify it as + // forwardable rather than one of OpenCodex's own admission secrets. + await expect(resolveCodexAuthContext(inbound, cfg, "pool", { + accountId: MAIN_CODEX_ACCOUNT_ID, + isMainAccountTokenLive: () => false, + })).rejects.toBeInstanceOf(CodexPoolAuthenticationError); + + const ctx = await resolveCodexAuthContext(inbound, cfg, "pool", { + accountId: MAIN_CODEX_ACCOUNT_ID, + isMainAccountTokenLive: () => false, + requestScopedMainCredential: true, + }); + expect(ctx).toMatchObject({ + kind: "main-pool", + accountId: MAIN_CODEX_ACCOUNT_ID, + credentialSource: "caller", + fixedAccount: true, + }); + expect(ctx).not.toHaveProperty("accessToken"); + expect(ctx).not.toHaveProperty("chatgptAccountId"); + + expect(applyCodexAuthContextToProvider(forwardProvider, ctx, "pool")) + .not.toHaveProperty("_codexAccountOverride"); + const upstream = materializeCodexUpstreamAuth(inbound, ctx); + expect(upstream.get("authorization")).toBe("Bearer caller-native-token"); + expect(upstream.get("chatgpt-account-id")).toBe("caller-native-account"); + expect(upstream.get("openai-beta")).toBe("responses=experimental"); + + const jwt = fakeChatGptJwt({ + "https://api.openai.com/auth": { chatgpt_account_id: "jwt-native-account" }, + }); + const jwtUpstream = materializeCodexUpstreamAuth(new Headers({ + authorization: `Bearer ${jwt}`, + }), ctx); + expect(jwtUpstream.get("authorization")).toBe(`Bearer ${jwt}`); + expect(jwtUpstream.get("chatgpt-account-id")).toBe("jwt-native-account"); + + const selected = await resolveCodexAuthContext(inbound, { + ...cfg, + codexAccounts: [], + activeCodexAccountId: undefined, + }, "pool", { + isMainAccountTokenLive: () => false, + requestScopedMainCredential: true, + }); + expect(selected).toMatchObject({ + kind: "main-pool", + accountId: MAIN_CODEX_ACCOUNT_ID, + credentialSource: "caller", + }); + } finally { + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + } + }); + test("selects pool auth independently of the routed provider", async () => { saveCodexAccountCredential("pool-a", { accessToken: "pool_token", diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index a2f5b52fdc..30206e838f 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -156,6 +156,49 @@ describe("live model provenance (#448 custom-model misclassification)", () => { expect(await liveModelCountAfterDiscovery("prov-stale", ["a", "b"])).toBe(2); expect(await liveModelCountAfterDiscovery("prov-stale", "fail")).toBe(2); }); + + test("the OpenAI provider dashboard receives the entitlement-backed native catalog", async () => { + const config = { + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }, + }, + } as OcxConfig; + const gatedModels = [ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-daybreak-blue-latest", + ]; + let entitlementReads = 0; + + const url = new URL("http://127.0.0.1/api/selected-models"); + const response = await handleManagementAPI(new Request(url), url, config, { + resolveCodexModelEntitlements: async () => { + entitlementReads += 1; + return { + modelsByAccount: new Map([["account-a", new Set(gatedModels)]]), + confirmedAccountIds: new Set(["account-a"]), + credentialIdentities: new Map([["account-a", "test:account-a"]]), + }; + }, + }); + const body = await response!.json() as { + available?: Record; + liveModelCounts?: Record; + }; + const expected = [...NATIVE_OPENAI_MODELS]; + + expect(entitlementReads).toBe(1); + expect(body.available?.openai).toEqual(expected); + expect(body.liveModelCounts?.openai).toBe(expected.length); + }); }); describe("combo catalog capability intersection", () => { diff --git a/tests/codex-main-account-observation.test.ts b/tests/codex-main-account-observation.test.ts new file mode 100644 index 0000000000..70b267b124 --- /dev/null +++ b/tests/codex-main-account-observation.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { clearAccountNeedsReauth } from "../src/codex/account-runtime-state"; +import { + observeSuccessfulCodexManagedMainRequest, + observeSuccessfulCodexManagedMainUsage, +} from "../src/codex/main-account-observation"; +import { + clearMainAccountInfoCache, + getMainAccountInfoCache, +} from "../src/codex/main-account-cache"; +import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; +import { clearAccountQuota, getAccountQuota } from "../src/codex/quota"; + +function chatgptPlanJwt(plan: string, accountId: string): string { + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"); + const body = Buffer.from(JSON.stringify({ + "https://api.openai.com/auth": { + chatgpt_account_id: accountId, + chatgpt_plan_type: plan, + }, + })).toString("base64url"); + return `${header}.${body}.sig`; +} + +function managedHeaders(accountId: string, plan = "pro"): Headers { + return new Headers({ + authorization: `Bearer ${chatgptPlanJwt(plan, accountId)}`, + "chatgpt-account-id": accountId, + }); +} + +beforeEach(() => { + clearMainAccountInfoCache(); + clearAccountQuota(MAIN_CODEX_ACCOUNT_ID); + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); +}); + +describe("Codex-managed main usage observation", () => { + test("retains parsed plan/quota fields without retaining the request bearer", async () => { + let authorization = ""; + let chatgptAccountId = ""; + const observed = await observeSuccessfulCodexManagedMainUsage( + managedHeaders("managed-pro"), + { + now: 1_000, + fetcher: (async (_input, init) => { + const headers = new Headers(init?.headers); + authorization = headers.get("authorization") ?? ""; + chatgptAccountId = headers.get("chatgpt-account-id") ?? ""; + return Response.json({ + plan_type: "pro", + rate_limit: { + primary_window: { + used_percent: 5, + reset_at: 1_783_000_000, + limit_window_seconds: 604_800, + }, + }, + rate_limit_reset_credits: { available_count: 2 }, + }); + }) as typeof fetch, + }, + ); + + expect(observed).toBe(true); + expect(authorization).toStartWith("Bearer "); + expect(chatgptAccountId).toBe("managed-pro"); + expect(getMainAccountInfoCache()).toMatchObject({ plan: "pro" }); + expect(getMainAccountInfoCache()).not.toHaveProperty("accessToken"); + expect(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)).toMatchObject({ + weeklyPercent: 5, + weeklyResetAt: 1_783_000_000, + resetCredits: 2, + }); + }); + + test("coalesces the managed WHAM observation behind its five-minute success cache", async () => { + let calls = 0; + const fetcher = (async () => { + calls += 1; + return Response.json({ rate_limit_reset_credits: { available_count: 0 } }); + }) as typeof fetch; + const headers = managedHeaders("managed-cache"); + + expect(await observeSuccessfulCodexManagedMainUsage(headers, { fetcher, now: 2_000 })).toBe(true); + expect(await observeSuccessfulCodexManagedMainUsage(headers, { fetcher, now: 2_001 })).toBe(false); + expect(calls).toBe(1); + expect(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.resetCredits).toBe(0); + }); + + test("drops a late usage response after Codex switches its keyring account", async () => { + let release!: (response: Response) => void; + const pendingResponse = new Promise(resolve => { release = resolve; }); + const oldProbe = observeSuccessfulCodexManagedMainUsage(managedHeaders("managed-old"), { + now: 3_000, + fetcher: (async () => pendingResponse) as typeof fetch, + }); + + expect(observeSuccessfulCodexManagedMainRequest(managedHeaders("managed-new", "plus"))).toBe(true); + release(Response.json({ + plan_type: "pro", + rate_limit_reset_credits: { available_count: 9 }, + })); + + expect(await oldProbe).toBe(false); + expect(getMainAccountInfoCache()).toMatchObject({ plan: "plus" }); + expect(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)).toBeNull(); + }); +}); diff --git a/tests/codex-model-entitlements.test.ts b/tests/codex-model-entitlements.test.ts index ca4e631da4..a9cf6db017 100644 --- a/tests/codex-model-entitlements.test.ts +++ b/tests/codex-model-entitlements.test.ts @@ -1,11 +1,16 @@ import { beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { availableAccountGatedNativeModels, cachedAvailableAccountGatedNativeModels, entitledCodexAccountIdsForModel, isDirectCallerEntitledToCodexModel, + isRequestScopedMainCallerEntitledToCodexModel, resetCodexModelEntitlementCacheForTests, resolveCodexModelEntitlements, + seedMainCodexModelEntitlementsFromNativeCache, seedCodexModelEntitlementsForTests, type CodexModelEntitlementCredentialSnapshot, } from "../src/codex/model-entitlements"; @@ -113,6 +118,121 @@ describe("Codex account model entitlements", () => { expect([...supplied.modelsByAccount.keys()]).toEqual(["pool-c"]); }); + test("uses Codex's authenticated native cache for a keyring-managed main account", async () => { + const snapshot = await resolveCodexModelEntitlements({ codexAccounts: [] }, { + credentialSnapshot: async () => null, + nativeMainModels: [SOL, TERRA, LUNA, DAYBREAK], + now: 1_000, + }); + + expect(snapshot.confirmedAccountIds.has(MAIN_CODEX_ACCOUNT_ID)).toBe(true); + expect([...entitledCodexAccountIdsForModel(snapshot, DAYBREAK)!]) + .toEqual([MAIN_CODEX_ACCOUNT_ID]); + }); + + test("reads a real Codex cache shape but rejects OpenCodex's synthetic cache", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-native-model-cache-")); + const previousCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = home; + const cachePath = join(home, "models_cache.json"); + const cache = (clientVersion: string) => ({ + client_version: clientVersion, + models: [SOL, TERRA, LUNA, DAYBREAK].map(slug => ({ + slug, + visibility: "list", + supported_in_api: true, + supported_reasoning_levels: [{ effort: "high" }], + model_messages: {}, + })), + }); + try { + writeFileSync(cachePath, JSON.stringify(cache("0.150.1"))); + const native = await resolveCodexModelEntitlements({ codexAccounts: [] }, { + credentialSnapshot: async () => null, + now: 1_000, + }); + expect([...availableAccountGatedNativeModels(native)]) + .toEqual([SOL, TERRA, LUNA, DAYBREAK]); + + resetCodexModelEntitlementCacheForTests(); + writeFileSync(cachePath, JSON.stringify(cache("0.0.0"))); + const synthetic = await resolveCodexModelEntitlements({ codexAccounts: [] }, { + credentialSnapshot: async () => null, + now: 2_000, + }); + expect(synthetic.confirmedAccountIds.has(MAIN_CODEX_ACCOUNT_ID)).toBe(false); + } finally { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + rmSync(home, { recursive: true, force: true }); + } + }); + + test("restores a generated roster across restart only when cache and catalog corroborate", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-generated-model-cache-")); + const rows = [SOL, TERRA, LUNA, DAYBREAK].map(slug => ({ + slug, + visibility: "list", + supported_in_api: true, + supported_reasoning_levels: [{ effort: "high" }], + model_messages: {}, + })); + const writeSyntheticCache = () => writeFileSync(join(home, "models_cache.json"), JSON.stringify({ + fetched_at: "2000-01-01T00:00:00Z", + client_version: "0.0.0", + models: rows, + })); + try { + writeFileSync(join(home, "opencodex-catalog.json"), JSON.stringify({ models: rows })); + writeSyntheticCache(); + expect(seedMainCodexModelEntitlementsFromNativeCache({ codexHome: home, now: 1_000 })) + .toBe(true); + const restored = await resolveCodexModelEntitlements({ codexAccounts: [] }, { + credentialSnapshot: async () => null, + now: 1_001, + }); + expect([...availableAccountGatedNativeModels(restored)]) + .toEqual([SOL, TERRA, LUNA, DAYBREAK]); + expect(restored.credentialIdentities.get(MAIN_CODEX_ACCOUNT_ID)?.startsWith("synthetic-cache:")) + .toBe(true); + + resetCodexModelEntitlementCacheForTests(); + writeFileSync(join(home, "opencodex-catalog.json"), JSON.stringify({ models: rows.slice(0, -1) })); + writeSyntheticCache(); + expect(seedMainCodexModelEntitlementsFromNativeCache({ codexHome: home, now: 2_000 })) + .toBe(false); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("startup preserves the native roster before replacing models_cache", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-native-model-seed-")); + const cachePath = join(home, "models_cache.json"); + const rows = [SOL, TERRA, LUNA, DAYBREAK].map(slug => ({ + slug, + visibility: "list", + supported_in_api: true, + supported_reasoning_levels: [{ effort: "high" }], + model_messages: {}, + })); + try { + writeFileSync(cachePath, JSON.stringify({ client_version: "0.150.1", models: rows })); + expect(seedMainCodexModelEntitlementsFromNativeCache({ codexHome: home, now: 1_000 })) + .toBe(true); + writeFileSync(cachePath, JSON.stringify({ client_version: "0.0.0", models: [] })); + + const snapshot = await resolveCodexModelEntitlements({ codexAccounts: [] }, { + credentialSnapshot: async () => null, + now: 2_000, + }); + expect([...availableAccountGatedNativeModels(snapshot)]) + .toEqual([SOL, TERRA, LUNA, DAYBREAK]); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + test("checks a Direct caller's own bearer instead of a local Pool account", async () => { let seenAuthorization = ""; let seenAccount = ""; @@ -149,6 +269,19 @@ describe("Codex account model entitlements", () => { )).resolves.toBe(false); }); + test("request-scoped keyring roster is promoted to main without retaining its bearer", async () => { + await expect(isRequestScopedMainCallerEntitledToCodexModel( + new Headers({ + authorization: "Bearer caller-token", + "chatgpt-account-id": "caller-account", + }), + DAYBREAK, + { fetcher: (async () => roster(SOL, DAYBREAK)) as typeof fetch, now: 1_000 }, + )).resolves.toBe(true); + + expect([...cachedAvailableAccountGatedNativeModels(1_000)]).toContain(DAYBREAK); + }); + test("Direct-caller rosters do not evict main/Pool entitlement evidence", async () => { // The catalog projects ONLY from main/Pool keys. Under a single shared LRU, a burst of // distinct Direct callers pushed those out and the gated row vanished from the catalog until diff --git a/tests/codex-retained-root-serialization.test.ts b/tests/codex-retained-root-serialization.test.ts index 4386799151..0dc60a160d 100644 --- a/tests/codex-retained-root-serialization.test.ts +++ b/tests/codex-retained-root-serialization.test.ts @@ -205,7 +205,10 @@ test("startup and CLI sync-cache cannot write models_cache while another process const startupStart = startup.indexOf("const startupCodexHome"); const startupRoot = startup.slice(startupStart, startup.indexOf("armClaudeCodeBaseline", startupStart)); expect(startupRoot).toContain("withCatalogWriteSerialization(startupCodexHome"); + expect(startupRoot).toContain("seedMainCodexModelEntitlementsFromNativeCache"); expect(startupRoot).toContain("invalidateCodexModelsCacheWithPermit"); + expect(startupRoot.indexOf("seedMainCodexModelEntitlementsFromNativeCache")) + .toBeLessThan(startupRoot.indexOf("invalidateCodexModelsCacheWithPermit")); } finally { holder.release(); expect(await holder.child.exited).toBe(0); diff --git a/tests/codex-routing.test.ts b/tests/codex-routing.test.ts index 4bb678a95f..796019543b 100644 --- a/tests/codex-routing.test.ts +++ b/tests/codex-routing.test.ts @@ -45,7 +45,12 @@ import { setAccountQuotaFromParsed, updateAccountQuota, } from "../src/codex/auth-api"; -import { CODEX_UNKNOWN_USAGE_SCORE, isCodexQuotaExhausted } from "../src/codex/quota"; +import { + applyAccountQuotaFromRateLimitEvent, + CODEX_UNKNOWN_USAGE_SCORE, + isCodexQuotaExhausted, + parseCodexRateLimitEventQuota, +} from "../src/codex/quota"; import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; import { routeModel } from "../src/router"; import { consumeForInspection } from "../src/server/relay"; @@ -1279,6 +1284,83 @@ describe("codex routing", () => { }); }); + test("codex.rate_limits preserves the official burst and weekly window semantics", () => { + const event = { + type: "codex.rate_limits", + plan_type: "plus", + rate_limits: { + primary: { used_percent: 11, reset_at: 1, window_minutes: 5 * 60 }, + secondary: { used_percent: 22, reset_at: 2, window_minutes: 7 * 24 * 60 }, + }, + }; + + expect(parseCodexRateLimitEventQuota(event)).toEqual({ + shortPercent: 11, + shortResetAt: 1, + shortWindowSeconds: 5 * 60 * 60, + weeklyPercent: 22, + weeklyResetAt: 2, + }); + + applyAccountQuotaFromRateLimitEvent("a", event); + expect(getAccountQuota("a")).toMatchObject({ + shortPercent: 11, + weeklyPercent: 22, + }); + }); + + test("codex.rate_limits fails closed for unrelated frames and unknown metered buckets", () => { + expect(parseCodexRateLimitEventQuota({ + type: "response.created", + rate_limits: { primary: { used_percent: 1 } }, + })).toBeNull(); + expect(parseCodexRateLimitEventQuota({ + type: "codex.rate_limits", + metered_limit_name: "future-secret-bucket", + rate_limits: { primary: { used_percent: 1 } }, + })).toBeNull(); + }); + + test("a Spark codex.rate_limits frame cannot overwrite ordinary account quota", () => { + setAccountQuotaFromParsed("a", { weeklyPercent: 44, weeklyResetAt: 4 }); + applyAccountQuotaFromRateLimitEvent("a", { + type: "codex.rate_limits", + metered_limit_name: "codex_bengalfox", + rate_limits: { + primary: { used_percent: 33, reset_at: 3, window_minutes: 7 * 24 * 60 }, + }, + }); + + expect(getAccountQuota("a")).toMatchObject({ + weeklyPercent: 44, + weeklyResetAt: 4, + customWindows: [{ label: "GPT-5.3-Codex-Spark Weekly", percent: 33, resetAt: 3 }], + }); + }); + + test("an ordinary codex.rate_limits frame cannot overwrite Spark quota", () => { + applyAccountQuotaFromRateLimitEvent("a", { + type: "codex.rate_limits", + metered_limit_name: "codex_bengalfox", + rate_limits: { + primary: { used_percent: 33, reset_at: 3, window_minutes: 7 * 24 * 60 }, + }, + }); + applyAccountQuotaFromRateLimitEvent("a", { + type: "codex.rate_limits", + metered_limit_name: "codex", + rate_limits: { + primary: { used_percent: 44, reset_at: 4, window_minutes: 7 * 24 * 60 }, + }, + }); + + expect(getAccountQuota("a")).toMatchObject({ + weeklyPercent: 44, + weeklyResetAt: 4, + customWindows: [{ label: "GPT-5.3-Codex-Spark Weekly", percent: 33, resetAt: 3 }], + }); + }); + test("a Spark-only WHAM snapshot preserves the stored monthly window", () => { setAccountQuotaFromParsed("a", { monthlyPercent: 44, diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index 6d0e395f3d..d882d83b69 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -5,7 +5,8 @@ import { join } from "node:path"; import * as authApi from "../src/codex/auth-api"; import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../src/codex/account-runtime-state"; import { clearMainAccountInfoCache } from "../src/codex/main-account-cache"; -import { clearAccountQuota, updateAccountQuota } from "../src/codex/quota"; +import { clearAccountQuota, getAccountQuota, updateAccountQuota } from "../src/codex/quota"; +import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/account-id"; import { clearCodexUpstreamHealth } from "../src/codex/routing"; import { saveCodexAccountCredential } from "../src/codex/account-store"; import { saveCredential } from "../src/oauth/store"; @@ -99,6 +100,31 @@ afterEach(() => { }); describe("fetchProviderQuotaReports", () => { + test("keyring main quota stays process-local and legacy disk entries are ignored", async () => { + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 17); + updateAccountQuota("pool-account", 29); + await Bun.sleep(300); + + const cachePath = join(opencodexHome, "codex-quota-cache.json"); + const persisted = JSON.parse(readFileSync(cachePath, "utf8")) as { + quotas: Record; + }; + expect(persisted.quotas[MAIN_CODEX_ACCOUNT_ID]).toBeUndefined(); + expect(persisted.quotas["pool-account"]?.weeklyPercent).toBe(29); + + clearAccountQuota(); + writeFileSync(cachePath, JSON.stringify({ + version: 1, + quotas: { + [MAIN_CODEX_ACCOUNT_ID]: { weeklyPercent: 83, updatedAt: Date.now() }, + "pool-account": { weeklyPercent: 31, updatedAt: Date.now() }, + }, + })); + + expect(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)).toBeNull(); + expect(getAccountQuota("pool-account")?.weeklyPercent).toBe(31); + }); + test("provider quota probes have no direct Response.json calls", () => { const source = readFileSync(join(import.meta.dir, "../src/providers/quota.ts"), "utf8"); expect(source).not.toMatch(/\.\s*json\s*\(/); @@ -1876,7 +1902,8 @@ describe("fetchProviderQuotaReports", () => { presentation: "coverage-only", includedAccounts: 0, excludedAccounts: 2, - reauthAccounts: 2, + reauthAccounts: 1, + unknownPlanAccounts: 1, incomplete: true, }); }); @@ -1910,8 +1937,10 @@ describe("fetchProviderQuotaReports", () => { expect(openai?.aggregation).toMatchObject({ presentation: "coverage-only", includedAccounts: 0, - staleQuotaAccounts: 1, - missingQuotaAccounts: 1, + // Both the pooled account and main's last request-observed quota are retained as stale + // evidence. Neither may be restamped or projected as current numeric capacity. + staleQuotaAccounts: 2, + missingQuotaAccounts: 0, unknownPlanAccounts: 1, incomplete: true, currentAccount: { plan: "prolite", quota: null }, diff --git a/tests/reasoning-replay-scope-source.test.ts b/tests/reasoning-replay-scope-source.test.ts index 8db776ae30..5facd51df9 100644 --- a/tests/reasoning-replay-scope-source.test.ts +++ b/tests/reasoning-replay-scope-source.test.ts @@ -21,7 +21,8 @@ describe("reasoning replay scope propagation", () => { expect(core).toContain("accountId: refreshed.accountId"); expect(core).toContain("generation: refreshed.generation"); expect(core).toContain("reasoningReplayCodexCredentialIdentity({"); - expect(core).toContain("authorization: poolContext"); + expect(core).toContain("authorization: storedPoolContext"); + expect(core).toContain('poolContext.credentialSource === "caller"'); expect(core).toContain("accountId: poolContext?.accountId"); expect(core).toContain("credentialGeneration: poolContext?.kind === \"pool\""); expect(core).toContain("writerGeneration: poolContext?.writerGeneration"); diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index c9bec1df66..9a2c85c2fd 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -22,6 +22,11 @@ import { loadConfig, saveConfig } from "../src/config"; import { clearUpstreamHostHealth, getUpstreamHostHealth, recordUpstreamHostFailure, upstreamHostHealthKey } from "../src/codex/upstream-host-health"; import { deriveProviderPresets } from "../src/providers/derive"; import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; +import { + clearMainAccountCredentialPresence, + clearMainAccountInfoCache, + getMainAccountCredentialState, +} from "../src/codex/main-account-cache"; import { assertServerAuthConfig, corsHeaders, @@ -144,6 +149,8 @@ afterEach(() => { clearAccountNeedsReauth("pool-a"); clearAccountNeedsReauth("pool-b"); clearAccountQuota(); + clearMainAccountInfoCache(); + clearMainAccountCredentialPresence(); resetCodexModelEntitlementCacheForTests(); resetDebugSettingsForTests(); resetDebugLogBufferForTests(); @@ -1136,7 +1143,7 @@ describe("server local API auth", () => { } }); - test("websocket upgrade returns 426 when the WS transport is disabled", async () => { + test("built-in OpenAI websocket is accepted when catalog WS advertisement is disabled", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); process.env.OPENCODEX_HOME = TEST_DIR; @@ -1145,25 +1152,27 @@ describe("server local API auth", () => { const server = startServer(0); try { - // codex-rs maps a connect-time 426 to a clean session-scoped HTTP fallback - // (WebsocketStreamOutcome::FallbackToHttp) — this must NOT accept the socket. - const response = await fetch(new URL("/v1/responses", server.url), { - method: "GET", - headers: { - connection: "Upgrade", - upgrade: "websocket", - }, - }); - expect(response.status).toBe(426); - expect(await response.json()).toMatchObject({ - error: { type: "upgrade_required" }, + const url = new URL("/v1/responses", server.url); + url.protocol = "ws:"; + const ws = new WebSocket(url); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("websocket open timed out")), 5_000); + ws.addEventListener("open", () => { + clearTimeout(timer); + ws.close(); + resolve(); + }, { once: true }); + ws.addEventListener("error", () => { + clearTimeout(timer); + reject(new Error("websocket open failed")); + }, { once: true }); }); } finally { await server.stop(true); } }); - test("after a 426'd upgrade the same client can immediately fall back to HTTP POST", async () => { + test("HTTP POST remains available when catalog WS advertisement is disabled", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); process.env.OPENCODEX_HOME = TEST_DIR; @@ -1188,12 +1197,6 @@ describe("server local API auth", () => { const server = startServer(0); try { - // codex-rs FallbackToHttp: the 426 must leave the connection/session fully usable for HTTP. - const upgrade = await fetch(new URL("/v1/responses", server.url), { - method: "GET", - headers: { connection: "Upgrade", upgrade: "websocket" }, - }); - expect(upgrade.status).toBe(426); const post = await fetch(new URL("/v1/responses", server.url), { method: "POST", headers: { "content-type": "application/json" }, @@ -1512,6 +1515,67 @@ describe("server local API auth", () => { clearCodexUpstreamHealth(); rmSync(join(isolatedCodexHome!.path, "auth.json"), { force: true }); + const nativeCallerConfig = { + ...mainOnlyConfig(), + hostname: "0.0.0.0", + } as OcxConfig; + saveConfig(nativeCallerConfig); + const beforeNativeCaller = seen.length; + const nativeCaller = startServer(0, { inspectNativeCodexOwnership }); + try { + await waitForNativeMainStartupGate(); + markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + + // The dedicated admission header grants access to the local proxy, but it is not an + // upstream ChatGPT credential. Without Codex's own bearer, Pool still fails closed. + expect((await request(nativeCaller)).status).toBe(401); + expect((await compact(nativeCaller)).status).toBe(401); + expect(await wsTurn(nativeCaller)).toContain("401"); + expect(seen).toHaveLength(beforeNativeCaller); + + // A proxy admission secret placed in Authorization is equally ineligible for forwarding. + expect((await request(nativeCaller, { authorization: "Bearer local-secret" })).status).toBe(401); + expect(seen).toHaveLength(beforeNativeCaller); + + // An arbitrary caller bearer is not enough to identify native Codex Pool traffic. + // Codex's request-scoped credential arrives with its ChatGPT account id. + expect((await request(nativeCaller, { authorization: "Bearer unrelated-caller-token" })).status).toBe(401); + expect(seen).toHaveLength(beforeNativeCaller); + + const nativeHeaders = { + authorization: "Bearer caller-keyring-token", + "chatgpt-account-id": "caller-keyring-account", + }; + expect((await request(nativeCaller, nativeHeaders)).status).toBe(200); + expect((await compact(nativeCaller, nativeHeaders)).status).toBe(200); + expect(await wsTurn(nativeCaller, nativeHeaders)).toContain("resp_tier"); + const jwt = fakeChatGptJwt({ + "https://api.openai.com/auth": { chatgpt_account_id: "jwt-keyring-account" }, + }); + expect((await request(nativeCaller, { authorization: `Bearer ${jwt}` })).status).toBe(200); + expect(seen.slice(beforeNativeCaller)).toEqual([ + ...Array.from({ length: 3 }, () => ({ + host: "chatgpt.com", + authorization: "Bearer caller-keyring-token", + chatgptAccountId: "caller-keyring-account", + })), + { + host: "chatgpt.com", + authorization: `Bearer ${jwt}`, + chatgptAccountId: "jwt-keyring-account", + }, + ]); + expect(getMainAccountCredentialState()).toEqual({ + status: "authenticated", + source: "codex-managed", + }); + } finally { + await nativeCaller.stop(true); + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + clearMainAccountInfoCache(); + clearMainAccountCredentialPresence(); + } + saveConfig({ port: 0, hostname: "0.0.0.0", diff --git a/tests/ws-upstream.test.ts b/tests/ws-upstream.test.ts index c16060c9f5..1f6c5c1425 100644 --- a/tests/ws-upstream.test.ts +++ b/tests/ws-upstream.test.ts @@ -14,6 +14,7 @@ import { MAX_CODEX_WS_FRAME_BYTES, MAX_CODEX_WS_QUEUE_BYTES, shouldUseCodexWsUpstream as rawShouldUseCodexWsUpstream, + type CodexWsUpstreamOptions, } from "../src/server/responses/ws-upstream"; import type { OcxProviderConfig } from "../src/types"; import type { OcxConfig } from "../src/types"; @@ -40,8 +41,9 @@ function codexWsUpstreamFetch( url: string, init: RequestInit, fallback: typeof fetch, + options: CodexWsUpstreamOptions = {}, ): Promise { - return rawCodexWsUpstreamFetch(url, init, fallback, BOUNDED_WS_RUNTIME); + return rawCodexWsUpstreamFetch(url, init, fallback, BOUNDED_WS_RUNTIME, options); } function streamingInit(body: Record = {}): RequestInit { @@ -413,15 +415,22 @@ describe("isWin32EagerRewrite", () => { describe("codexWsUpstreamFetch", () => { test("relays event frames as an SSE response and sends one response.create frame", async () => { + const quotaEvent = { type: "codex.rate_limits", rate_limits: { primary: { used_percent: 12 } } }; + const observedQuotaEvents: unknown[] = []; installFake(ws => { ws.emit("open", {}); - ws.emit("message", { data: JSON.stringify({ type: "codex.rate_limits", limits: {} }) }); + ws.emit("message", { data: JSON.stringify(quotaEvent) }); ws.emit("message", { data: JSON.stringify({ type: "response.created", response: { id: "r1" } }) }); ws.emit("message", { data: JSON.stringify({ type: "response.output_text.delta", delta: "hi" }) }); ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: { id: "r1" } }) }); }); const fallback = () => { throw new Error("fallback must not run"); }; - const response = await codexWsUpstreamFetch(CODEX_URL, streamingInit(), fallback as unknown as typeof fetch); + const response = await codexWsUpstreamFetch( + CODEX_URL, + streamingInit(), + fallback as unknown as typeof fetch, + { onRateLimits: event => observedQuotaEvents.push(event) }, + ); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain("text/event-stream"); @@ -429,6 +438,7 @@ describe("codexWsUpstreamFetch", () => { const text = await response.text(); // WS-only frames are dropped so clients see the exact SSE surface they always got. expect(text).not.toContain("codex.rate_limits"); + expect(observedQuotaEvents).toEqual([quotaEvent]); expect(text).toContain("event: response.created"); expect(text).toContain('data: {"type":"response.output_text.delta","delta":"hi"}'); expect(text).toContain("event: response.completed");