diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index c9875cd13..60b1d90d1 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -16,7 +16,7 @@ description: プロバイダー エントリ、認証、エンドポイント、 | `contextCapValue?` | `number` | `350000` |ダッシュボードのコンテキストキャップ コントロールで使用される値。これを変更すると、有効になっているすべての `providerContextCaps` エントリが更新されます。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex プール アカウントのメタデータは Codex Auth によって管理されます。秘密は`codex-accounts.json`に別に住んでいます。 | | `pausedCodexAccountIds?` | `string[]` | `[]` |再開するまでプールの選択から除外されるアカウント (一時停止時のメイン `__main__` アカウントを含む)。 | -| `codexAccountNamespaces?` | `Record` | — |保存された Codex アカウント ターゲットへのパブリック モデル セレクター ネームスペース。これによりマッピングが検証され、永続化されますが、それ自体ではピッカー行の追加やルーティングの変更は行われません。 | +| `codexAccountNamespaces?` | `Record` | — | 公開 model selector namespace から保存済み Codex アカウント target への任意 map。`/` は対応するアカウントだけに routing され、この設定自体は model picker row を追加しません。 | | `activeCodexAccountId?` | `string` | — |次のリクエスト用に手動で選択されたプール アカウント。選択するとスレッドのアフィニティがクリアされます。実行中のリクエストでは、取得された資格情報が保持されます。 | | `autoSwitchThreshold?` | `number` | `80` | 使用量ベースのプロアクティブ切り替えしきい値。`quota` は紐付け済み/未紐付けタスクの次のリクエストを再評価でき、`fill-first` は未紐付け割り当ての使い切り基準としてのみ使用し、通常の `round-robin` 選択は使用しません。既知の 5 時間、週次、30 日 quota window の最大スコアを使います。`0` は使用量ベースの切り替えだけを無効にし、未紐付け割り当てや障害回復は無効にしません。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新規/未紐付け Codex リクエストの割り当て戦略。live な `(parent thread id, quota scope)` affinity がなければ未紐付けで、プロキシ再起動や affinity リセット後は既存の表示タスクも未紐付けになり得ます。`quota` はアクティブアカウントがなければ既知 usage 最小の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。しきい値到達後は、未紐付けリクエストまたは紐付け済みタスクの次のリクエストを usage の低い適格アカウントへ移せます。`round-robin` は未紐付けリクエストを均等分散し、`fill-first` は cooldown、使用不可、または drain threshold までアクティブアカウントへ割り当てます。 | @@ -26,7 +26,14 @@ description: プロバイダー エントリ、認証、エンドポイント、 | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic プロンプト キャッシュ ポリシー: 無効、5 分間の一時的、または 1 時間の延長。 | | `tokenGuardian?` | `OcxTokenGuardianConfig` |オフ |オプションのプロアクティブな OAuth 更新および Codex アカウントのウォームアップ ポリシー。 | -`codexAccountNamespaces` キーはパブリック セレクターです。1 ~ 64 文字で、ASCII 文字または数字で始まり、終わり、文字、数字、`.`、`_`、または `-` が内部に含まれます。予約された JavaScript オブジェクト名は拒否されます。各値は、Codex デスクトップ アカウントの有効なプール アカウント ID (内部 `__main__` ではありません) または `"@main"` です。プロバイダーと予約された `openai` / `combo` の衝突は、大文字と小文字を区別せずにチェックされます。生のアカウント ID と電子メールを非公開にしておきます。セレクターはパブリック名です。 +`codexAccountNamespaces` のキーは公開 selector です。長さは 1〜64 文字、先頭と末尾は ASCII +英数字、内部には英数字、`.`、`_`、`-` を使用でき、予約済み JavaScript object 名は拒否されます。 +値は有効な pool account id(内部 `__main__` は不可)、または Codex Desktop アカウントを示す +`"@main"` です。provider と予約済み `openai` / `combo` との衝突は大文字小文字を区別せず検査され、 +namespace 付き combo alias はその namespace prefix に selector を再利用できません。設定済み pool id +や他の selector target も selector と再利用できません。raw account id と email は +非公開のままにし、selector を公開名として使ってください。明示的な選択の動作と優先順位は +[ルーティング構成](/reference/configuration/routing/)を参照してください。 ## 予約済み OpenAI プロバイダー diff --git a/docs-site/src/content/docs/ja/reference/configuration/routing.md b/docs-site/src/content/docs/ja/reference/configuration/routing.md index 783e5e354..ee23d0990 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ja/reference/configuration/routing.md @@ -16,17 +16,32 @@ description: デフォルトのプロバイダーの選択、モデルの解決 opencodex は、要求されたモデルを次の順序で解決します。 -1. 正規の `combo/` または構成されたコンボ エイリアス。正規 ID は、エイリアスが一致する前に優先されます。 -2. 構成されたプロバイダーを示すプレフィックスを持つ明示的な `/` 名前空間。 -3. `gpt-*`、`o1-*`、`o3-*`、`o4-*` などのベア ネイティブ OpenAI ファミリ ID。 +1. 設定済みの `/` namespace。対応する保存済み Codex アカウントだけに routing され、無効または利用不能な exact target は fail closed します。 +2. 正規の `combo/` または構成されたコンボ エイリアス。正規 ID は、エイリアスが一致する前に優先されます。 +3. 構成されたプロバイダーを示すプレフィックスを持つ明示的な `/` 名前空間。 +4. `gpt-*`、`o1-*`、`o3-*`、`o4-*` などのベア ネイティブ OpenAI ファミリ ID。 正規対応の `openai` プロバイダー。 -4. プロバイダーの `defaultModel` と完全に一致します。 -5. 既知のプロバイダー ファミリ モデル プレフィックス。 -6. プロバイダーの構成された `models` リスト内の正確なモデル。 -7. `defaultProvider`、要求されたモデル ID を保持します。 +5. プロバイダーの `defaultModel` と完全に一致します。 +6. 既知のプロバイダー ファミリ モデル プレフィックス。 +7. プロバイダーの構成された `models` リスト内の正確なモデル。 +8. `defaultProvider`、要求されたモデル ID を保持します。 無効なプロバイダーは除外されます。無効なプロバイダーの明示的な名前空間は、フォールスルーではなく失敗します。プロバイダー エントリは、複数のプロバイダーに一致する可能性のあるルールの JSON 挿入順序でチェックされるため、ベア モデルがあいまいな可能性がある場合は明示的な名前空間を使用します。 +## Codex アカウントの明示的な selector + +`codexAccountNamespaces` は `side` のような公開 selector を保存済み Codex アカウント 1 つに +対応付けます。`side/gpt-5.6-sol` のような request は、canonical `openai` provider が Direct mode +の場合でもそのアカウントだけを使用し、上流には bare な `gpt-5.6-sol` model id を送信します。 +selector の後には bare native OpenAI-family id だけを指定できます。 + +明示的な選択は Pool assignment strategy と通常の thread affinity を迂回します。対応する account が +存在しない、一時停止中、cooldown 中、利用不能、または再認証が必要な場合、request は別の account +へ切り替えず fail closed し、active Pool account も変更しません。bare native model id は通常の +Pool / Direct routing を維持します。namespace map 自体は model picker row を作成しません。 +selector の検証、衝突規則、privacy guidance は +[プロバイダーの構成](/reference/configuration/providers/)を参照してください。 + ## コンボ (`config.combos`) 各コンボ キーは `[A-Za-z0-9][A-Za-z0-9._-]{0,63}` に一致する ID です。これは常に `combo/` として直接アドレス指定可能であり、1 つの `alias` を公開することもあります。エイリアスは一意である必要があり、`combo/` 名前空間を占有することはできず、`gpt-*`、`o1-*`、`o3-*`、`o4-*`、または `codex-*` などの予約されたベア ネイティブ ファミリを使用することはできません。 diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index b3adefda8..3e8c3bb01 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -16,7 +16,7 @@ description: 공급자 항목, 인증, 엔드포인트, 모델 카탈로그, 할 | `contextCapValue?` | `number` | `350000` | 대시보드의 컨텍스트 상한 컨트롤이 사용하는 값입니다. 이 값을 바꾸면 활성화된 모든 `providerContextCaps` 항목이 함께 갱신됩니다. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth가 관리하는 ChatGPT/Codex 풀 계정 메타데이터입니다. 비밀 정보는 `codex-accounts.json`에 따로 저장됩니다. | | `pausedCodexAccountIds?` | `string[]` | `[]` | 일시 중지된 `__main__` 계정을 포함해, 재개될 때까지 Pool 선택에서 제외되는 계정입니다. | -| `codexAccountNamespaces?` | `Record` | — | 공개 모델 선택기 네임스페이스를 저장된 Codex 계정 대상으로 연결합니다. 이 설정은 매핑을 검증하고 저장하지만, 그 자체로 선택기 행을 추가하거나 라우팅을 바꾸지는 않습니다. | +| `codexAccountNamespaces?` | `Record` | — | 공개 model selector namespace에서 저장된 Codex 계정 target으로 연결하는 선택적 map입니다. `/`은 매핑된 계정으로만 routing되며, 이 설정 자체는 model picker row를 추가하지 않습니다. | | `activeCodexAccountId?` | `string` | — | 다음 요청에 수동으로 선택한 Pool 계정입니다. 선택하면 thread 결속이 해제되며, 진행 중인 요청은 캡처한 자격 증명을 유지합니다. | | `autoSwitchThreshold?` | `number` | `80` | 사용량 기반 선제 전환 임계값입니다. `quota`는 바인딩된 작업과 바인딩 없는 작업의 다음 요청을 모두 재평가할 수 있고, `fill-first`는 바인딩 없는 작업 배정의 소진 기준으로만 사용하며, 기본 `round-robin` 선택은 이 값을 사용하지 않습니다. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`은 사용량 기반 전환만 끄며 바인딩 없는 작업 배정이나 실패 복구는 끄지 않습니다. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 새 작업/바인딩 없는 Codex 요청의 계정 배정 전략입니다. `(parent thread id, quota scope)`의 live affinity가 없으면 바인딩 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤에는 기존에 보이던 작업도 바인딩이 없어질 수 있습니다. `quota`는 활성 계정이 없을 때 알려진 usage가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청이나 바인딩된 작업의 다음 요청을 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. `round-robin`은 바인딩 없는 요청을 균등 분배하고, `fill-first`는 cooldown, 사용 불가 또는 drain threshold까지 활성 계정에 배정합니다. | @@ -26,7 +26,14 @@ description: 공급자 항목, 인증, 엔드포인트, 모델 카탈로그, 할 | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic 프롬프트 캐시 정책입니다. 비활성, 5분짜리 임시, 1시간짜리 확장 중 하나입니다. | | `tokenGuardian?` | `OcxTokenGuardianConfig` | 꺼짐 | 선택적 선제 OAuth 갱신과 Codex 계정 워밍업 정책입니다. | -`codexAccountNamespaces`의 키는 공개 선택기입니다. 1–64자여야 하며, ASCII 문자 또는 숫자로 시작하고 끝나야 합니다. 내부에는 문자, 숫자, `.`, `_`, `-`를 넣을 수 있습니다. 예약된 JavaScript 객체 이름은 허용하지 않습니다. 각 값은 유효한 풀 계정 id여야 하며 내부 `__main__`은 안 됩니다. 또는 Codex Desktop 계정에는 `"@main"`을 쓸 수 있습니다. 공급자 이름과 예약된 `openai` / `combo` 충돌은 대소문자를 구분하지 않고 검사합니다. 원시 계정 id와 이메일은 비공개로 유지하고, 공개 이름은 선택기만 쓰십시오. +`codexAccountNamespaces` 키는 공개 selector입니다. 길이는 1~64자이고 시작과 끝은 ASCII 영숫자여야 +하며, 내부에는 영숫자, `.`, `_`, `-`를 사용할 수 있습니다. 예약된 JavaScript object 이름은 거부됩니다. +값은 유효한 pool account id(내부 `__main__` 제외)이거나 Codex Desktop 계정을 나타내는 `"@main"`입니다. +provider 및 예약된 `openai` / `combo` 충돌은 대소문자를 구분하지 않고 검사하며, namespace가 있는 +combo alias는 selector를 namespace prefix로 재사용할 수 없습니다. 설정된 pool id와 다른 selector +target도 selector로 재사용할 수 없습니다. raw account id와 email은 비공개로 +유지하고 selector를 공개 이름으로 사용하세요. 명시적 선택 동작과 우선순위는 +[라우팅 설정](/reference/configuration/routing/)을 참고하십시오. ## 예약된 OpenAI 공급자 diff --git a/docs-site/src/content/docs/ko/reference/configuration/routing.md b/docs-site/src/content/docs/ko/reference/configuration/routing.md index 3efbe7002..17bd27560 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ko/reference/configuration/routing.md @@ -16,16 +16,30 @@ description: 기본 provider 선택, model 해석 순서, combo 별칭, 대상 opencodex는 요청된 model을 다음 순서로 해석합니다: -1. 정규화된 `combo/` 또는 설정된 combo 별칭입니다. 정규화된 id가 별칭보다 먼저 적용됩니다. -2. 접두사가 설정된 provider를 가리키는 명시적 `/` 네임스페이스입니다. -3. `gpt-*`, `o1-*`, `o3-*`, `o4-*` 같은 bare native OpenAI 계열 id입니다. 이 경우 정규화된 활성 `openai` provider를 통해 라우팅합니다. -4. provider의 `defaultModel`과 정확히 일치합니다. -5. 알려진 provider-family model prefix입니다. -6. provider의 설정된 `models` 목록 안의 정확한 model입니다. -7. `defaultProvider`이며, 요청한 model id를 그대로 유지합니다. +1. 설정된 `/` 네임스페이스입니다. 매핑된 저장 Codex 계정으로만 routing하며, exact target이 잘못되었거나 사용할 수 없으면 fail closed합니다. +2. 정규화된 `combo/` 또는 설정된 combo 별칭입니다. 정규화된 id가 별칭보다 먼저 적용됩니다. +3. 접두사가 설정된 provider를 가리키는 명시적 `/` 네임스페이스입니다. +4. `gpt-*`, `o1-*`, `o3-*`, `o4-*` 같은 bare native OpenAI 계열 id입니다. 이 경우 정규화된 활성 `openai` provider를 통해 라우팅합니다. +5. provider의 `defaultModel`과 정확히 일치합니다. +6. 알려진 provider-family model prefix입니다. +7. provider의 설정된 `models` 목록 안의 정확한 model입니다. +8. `defaultProvider`이며, 요청한 model id를 그대로 유지합니다. 비활성화된 provider는 제외합니다. 비활성화된 provider의 명시적 네임스페이스는 다음 규칙으로 넘어가지 않고 실패합니다. 여러 provider에 걸쳐 일치할 수 있는 규칙은 JSON에 적힌 삽입 순서대로 provider 항목을 검사하므로, bare model이 애매할 수 있으면 명시적 네임스페이스를 사용하십시오. +## 명시적 Codex 계정 selector + +`codexAccountNamespaces`는 `side` 같은 공개 selector를 저장된 Codex 계정 하나에 매핑합니다. +`side/gpt-5.6-sol` 요청은 canonical `openai` provider가 Direct mode여도 그 계정만 사용하고, +upstream에는 bare `gpt-5.6-sol` model id를 보냅니다. selector 뒤에는 bare native OpenAI-family +id만 사용할 수 있습니다. + +명시적 선택은 Pool assignment strategy와 일반 thread affinity를 우회합니다. 매핑된 account가 없거나, +일시 중지되었거나, cooldown 중이거나, 사용할 수 없거나, 재인증이 필요하면 다른 account로 전환하지 +않고 fail closed하며 active Pool account도 변경하지 않습니다. bare native model id는 기존 Pool / +Direct routing을 유지합니다. namespace map 자체는 model picker row를 만들지 않습니다. selector 검증, +충돌 규칙, privacy guidance는 [공급자 설정](/reference/configuration/providers/)을 참고하십시오. + ## Combos (`config.combos`) 각 combo 키는 `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`에 맞는 id입니다. 항상 `combo/`로 직접 접근할 수 있고, 추가로 하나의 `alias`를 노출할 수 있습니다. alias는 유일해야 하고 `combo/` 네임스페이스를 차지할 수 없으며, `gpt-*`, `o1-*`, `o3-*`, `o4-*`, `codex-*` 같은 예약된 bare native family도 사용할 수 없습니다. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 5109a9d8e..96b905a6b 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -17,7 +17,7 @@ authenticated. | `contextCapValue?` | `number` | `350000` | Value used by the dashboard context-cap controls; changing it updates every enabled `providerContextCaps` entry. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by Codex Auth. Secrets live separately in `codex-accounts.json`. | | `pausedCodexAccountIds?` | `string[]` | `[]` | Accounts excluded from Pool selection until resumed, including the main `__main__` account when paused. | -| `codexAccountNamespaces?` | `Record` | — | Public model-selector namespace to stored Codex account target. This validates and persists mappings but does not itself add picker rows or change routing. | +| `codexAccountNamespaces?` | `Record` | — | Optional public model-selector namespace to stored Codex account target. `/` routes using exactly the mapped account; this setting does not add model-picker rows. | | `activeCodexAccountId?` | `string` | — | Manually selected Pool account for the next request. Selection clears thread affinity; in-flight requests keep captured credentials. | | `autoSwitchThreshold?` | `number` | `80` | Usage threshold for proactive switching. `quota` can re-evaluate both bound and unbound tasks on their next request; `fill-first` uses it only as the drain point for unbound assignment; normal `round-robin` selection does not use it. The score uses the hottest known 5h, weekly, or 30d quota window. `0` disables usage-based proactive switching only, not unbound assignment or failure recovery. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request or proactively rebind a bound task to a lower-usage eligible account. `round-robin` distributes unbound requests evenly; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. | @@ -31,7 +31,10 @@ authenticated. ASCII letter or number, with letters, numbers, `.`, `_`, or `-` inside. Reserved JavaScript object names are rejected. Each value is a valid pool-account id (never internal `__main__`) or `"@main"` for the Codex Desktop account. Provider and reserved `openai` / `combo` collisions are checked -case-insensitively. Keep raw account ids and emails private; the selector is the public name. +case-insensitively; a namespaced combo alias cannot reuse a selector as its namespace prefix, and +configured pool ids or selector targets also cannot reuse a selector. Keep raw account ids and +emails private; the selector is the public name. See [Routing Configuration](/reference/configuration/routing/) +for exact-selection behavior and precedence. ## Reserved OpenAI providers diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index 56bfc8cd6..fe2256114 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -16,19 +16,35 @@ Routing turns the model id sent by a client into one concrete provider and upstr opencodex resolves the requested model in this order: -1. A canonical `combo/` or configured combo alias. Canonical ids win before alias matching. -2. An explicit `/` namespace whose prefix names a configured provider. -3. A bare native OpenAI-family id such as `gpt-*`, `o1-*`, `o3-*`, or `o4-*`, routed through the +1. A configured `/` namespace, routed through exactly the + mapped stored Codex account. An invalid or unavailable exact target fails closed. +2. A canonical `combo/` or configured combo alias. Canonical ids win before alias matching. +3. An explicit `/` namespace whose prefix names a configured provider. +4. A bare native OpenAI-family id such as `gpt-*`, `o1-*`, `o3-*`, or `o4-*`, routed through the canonical enabled `openai` provider. -4. An exact match for a provider's `defaultModel`. -5. A known provider-family model prefix. -6. An exact model in a provider's configured `models` list. -7. `defaultProvider`, preserving the requested model id. +5. An exact match for a provider's `defaultModel`. +6. A known provider-family model prefix. +7. An exact model in a provider's configured `models` list. +8. `defaultProvider`, preserving the requested model id. Disabled providers are excluded. An explicit namespace for a disabled provider fails instead of falling through. Provider entries are checked in their JSON insertion order for rules that can match more than one provider, so use explicit namespaces when a bare model could be ambiguous. +## Exact Codex account selectors + +`codexAccountNamespaces` maps a public selector such as `side` to one stored Codex account. A +request for `side/gpt-5.6-sol` uses only that account, even when the canonical `openai` provider is +in Direct mode, and sends the bare `gpt-5.6-sol` model id upstream. Only bare native OpenAI-family +ids are valid after the selector. + +Exact selection bypasses Pool assignment strategy and ordinary thread affinity. If the mapped +account is missing, paused, cooling down, unusable, or requires reauthentication, the request fails +closed instead of switching accounts and does not change the active Pool account. Bare native model +ids retain normal Pool/Direct routing. The namespace map itself does not create model-picker rows. +Selector validation, collision rules, and privacy guidance are documented in +[Provider Configuration](/reference/configuration/providers/). + ## Combos (`config.combos`) Each combo key is an id matching `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`. It is always directly addressable diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 6805a6e55..52c50079e 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -17,7 +17,7 @@ description: Записи провайдеров, аутентификация, | `contextCapValue?` | `number` | `350000` | Значение, используемое элементами управления context-cap в дашборде; его изменение обновляет все включённые записи `providerContextCaps`. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Метаданные аккаунтов пула ChatGPT/Codex, которыми управляет Codex Auth. Секреты живут отдельно в `codex-accounts.json`. | | `pausedCodexAccountIds?` | `string[]` | `[]` | Аккаунты, исключённые из выбора Pool до снятия паузы, включая основной аккаунт `__main__`, если он поставлен на паузу. | -| `codexAccountNamespaces?` | `Record` | — | Публичное пространство имён селектора модели на сохранённую цель аккаунта Codex. Это поле валидирует и сохраняет mapping, но само по себе не добавляет строки в picker и не меняет routing. | +| `codexAccountNamespaces?` | `Record` | — | Необязательное сопоставление публичного namespace селектора модели с сохранённым аккаунтом Codex. `/` направляется только в сопоставленный аккаунт; сама настройка не добавляет строки в model picker. | | `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт Pool для следующего запроса. Выбор очищает thread affinity; in-flight-запросы сохраняют уже захваченные credential'ы. | | `autoSwitchThreshold?` | `number` | `80` | Порог проактивного переключения по использованию. `quota` может повторно оценить следующий запрос как привязанной, так и непривязанной задачи; `fill-first` использует его только как точку исчерпания для непривязанных назначений; обычный `round-robin` его не использует. Оценка берёт самое горячее из окон 5 часов, недели и 30 дней. `0` отключает только переключение по использованию, но не назначение непривязанных задач и не восстановление после сбоев. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия назначения для новых/непривязанных запросов Codex. Запрос непривязан, если у него нет live affinity `(parent thread id, quota scope)`; видимая существующая задача может стать непривязанной после перезапуска прокси или сброса affinity. `quota` выбирает подходящий аккаунт с наименьшим известным usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос или следующий запрос привязанной задачи на подходящий аккаунт с меньшим usage. `round-robin` равномерно распределяет непривязанные запросы; `fill-first` назначает их активному аккаунту до cooldown, недоступности или порога исчерпания. | @@ -27,13 +27,14 @@ description: Записи провайдеров, аутентификация, | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Политика prompt-cache Anthropic: отключено, 5-минутный ephemeral или 1-часовой extended. | | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | Необязательная политика proactive OAuth refresh и warmup'а аккаунтов Codex. | -Ключи `codexAccountNamespaces` — это публичные селекторы: 1–64 символа, начинаются и -заканчиваются ASCII-буквой или цифрой, а внутри могут содержать буквы, цифры, `.`, `_` или `-`. -Зарезервированные имена объектов JavaScript отклоняются. Каждое значение — это валидный id -аккаунта из пула (никогда не внутренний `__main__`) или `"@main"` для аккаунта Codex Desktop. -Конфликты с именами провайдеров и зарезервированными `openai` / `combo` проверяются -без учёта регистра. Сырые account-id и email должны оставаться приватными; селектор — это -публичное имя. +Ключи `codexAccountNamespaces` — публичные селекторы длиной 1–64 символа. Они должны начинаться и +заканчиваться ASCII-буквой или цифрой; внутри разрешены буквы, цифры, `.`, `_` и `-`. Зарезервированные +имена объектов JavaScript запрещены. Значение — допустимый id аккаунта пула (кроме внутреннего `__main__`) +либо `"@main"` для аккаунта Codex Desktop. Коллизии с provider и зарезервированными `openai` / `combo` +проверяются без учёта регистра; namespace-префикс namespaced combo alias не может повторять селектор. +Настроенные id пула и цели других селекторов также нельзя повторно использовать как селектор. Сохраняйте +raw id аккаунтов и email приватными, а селектор используйте как публичное имя. Поведение и приоритет +явного выбора описаны в разделе [Конфигурация маршрутизации](/reference/configuration/routing/). ## Зарезервированные провайдеры OpenAI diff --git a/docs-site/src/content/docs/ru/reference/configuration/routing.md b/docs-site/src/content/docs/ru/reference/configuration/routing.md index 538281cee..c5a21208b 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ru/reference/configuration/routing.md @@ -17,20 +17,36 @@ upstream. opencodex разрешает запрошенную модель в следующем порядке: -1. Канонический `combo/` или настроенный алиас combo. Канонические id проверяются до алиасов. -2. Явное пространство имён `/`, где префикс называет настроенного провайдера. -3. Голый id нативного семейства OpenAI, например `gpt-*`, `o1-*`, `o3-*` или `o4-*`, направляемый +1. Настроенное пространство имён `/`, направляемое только через + сопоставленный сохранённый аккаунт Codex. Некорректная или недоступная exact target завершается fail closed. +2. Канонический `combo/` или настроенный алиас combo. Канонические id проверяются до алиасов. +3. Явное пространство имён `/`, где префикс называет настроенного провайдера. +4. Голый id нативного семейства OpenAI, например `gpt-*`, `o1-*`, `o3-*` или `o4-*`, направляемый через канонического включённого провайдера `openai`. -4. Точное совпадение с `defaultModel` провайдера. -5. Известный префикс семейства моделей провайдера. -6. Точное совпадение с моделью в настроенном списке `models` провайдера. -7. `defaultProvider` с сохранением запрошенного id модели. +5. Точное совпадение с `defaultModel` провайдера. +6. Известный префикс семейства моделей провайдера. +7. Точное совпадение с моделью в настроенном списке `models` провайдера. +8. `defaultProvider` с сохранением запрошенного id модели. Отключённые провайдеры исключаются. Явное пространство имён отключённого провайдера завершается ошибкой, а не переходит к следующим правилам. Для правил, которые могут совпасть с несколькими провайдерами, записи проверяются в порядке их добавления в JSON. Поэтому используйте явные пространства имён, если голая модель может быть неоднозначной. +## Точные селекторы аккаунтов Codex + +`codexAccountNamespaces` сопоставляет публичный селектор, например `side`, с одним сохранённым +аккаунтом Codex. Запрос `side/gpt-5.6-sol` использует только этот аккаунт, даже если канонический +провайдер `openai` работает в режиме Direct, и отправляет upstream голый id модели `gpt-5.6-sol`. +После селектора допустимы только голые id нативного семейства OpenAI. + +Точный выбор обходит стратегию назначения Pool и обычную thread affinity. Если сопоставленный аккаунт +отсутствует, приостановлен, находится в cooldown, непригоден или требует повторной аутентификации, запрос +завершается ошибкой без переключения на другой аккаунт и без изменения active Pool account. Голые id +нативных моделей сохраняют обычную маршрутизацию Pool / Direct. Сам namespace map не создаёт строки +model picker. Проверка selector, правила коллизий и рекомендации по privacy описаны в разделе +[Конфигурация провайдеров](/reference/configuration/providers/). + ## Combo (`config.combos`) Каждый ключ combo — это id, соответствующий шаблону `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`. Он всегда diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 71f5b9218..1e3aadad1 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -16,7 +16,7 @@ description: 提供者条目、身份验证、端点、模型目录、配额、 | `contextCapValue?` | `number` | `350000` | 仪表板上下文上限控件使用的值;修改它会更新所有已启用的 `providerContextCaps` 条目。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | 由 Codex Auth 管理的 ChatGPT/Codex 池账户元数据。密钥单独存放在 `codex-accounts.json` 中。 | | `pausedCodexAccountIds?` | `string[]` | `[]` | 在恢复之前从 Pool 选择中排除的账户,包括被暂停时的主 `__main__` 账户。 | -| `codexAccountNamespaces?` | `Record` | — | 公共模型选择器命名空间到已存储 Codex 账户目标的映射。此项会校验并持久化映射,但不会自行添加选择器行或改变路由。 | +| `codexAccountNamespaces?` | `Record` | — | 可选的公开 model selector namespace 到已保存 Codex account target 的映射。`/` 只会路由到映射账号;此设置本身不会添加 model picker row。 | | `activeCodexAccountId?` | `string` | — | 为下一次请求手动选定的 Pool 账户。选择会清除线程亲和性;进行中的请求会保留捕获到的凭据。 | | `autoSwitchThreshold?` | `number` | `80` | 基于用量的主动切换阈值。`quota` 可在下一次请求中重新评估已绑定和未绑定任务;`fill-first` 仅把它用作未绑定分配的耗尽点;正常 `round-robin` 不使用它。分数取已知 5 小时、周或 30 天 quota window 的最高值。`0` 只关闭基于用量的主动切换,不关闭未绑定任务分配或故障恢复。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新建/未绑定 Codex 请求的分配策略。没有 live `(parent thread id, quota scope)` affinity 的请求属于未绑定;代理重启或 affinity 重置后,已有可见任务也可能未绑定。`quota` 在没有活跃账号时选择已知 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求或已绑定任务的下一次请求切换到 usage 更低的合格账号。`round-robin` 均匀分配未绑定请求;`fill-first` 在 cooldown、不可用或耗尽阈值前持续分配给活跃账号。 | @@ -26,7 +26,13 @@ description: 提供者条目、身份验证、端点、模型目录、配额、 | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic 提示缓存策略:禁用、5 分钟临时缓存,或 1 小时扩展缓存。 | | `tokenGuardian?` | `OcxTokenGuardianConfig` | 关闭 | 可选的主动 OAuth 刷新与 Codex 账户预热策略。 | -`codexAccountNamespaces` 的键是公共选择器:长度 1–64 个字符,以 ASCII 字母或数字开头和结尾,中间可包含字母、数字、`.`、`_` 或 `-`。会拒绝保留的 JavaScript 对象名称。每个值都必须是有效的池账户 id(永远不能是内部 `__main__`)或用于 Codex Desktop 账户的 `"@main"`。会对提供者名以及保留的 `openai` / `combo` 冲突进行不区分大小写的检查。请保持原始账户 id 和邮箱私密;选择器才是公开名称。 +`codexAccountNamespaces` 的 key 是公开 selector:长度为 1–64 个字符,首尾必须是 ASCII 字母或数字, +中间可使用字母、数字、`.`、`_` 或 `-`;保留的 JavaScript object 名称会被拒绝。value 必须是有效的 +pool account id(不能是内部 `__main__`),或用 `"@main"` 表示 Codex Desktop 账号。与 provider 及 +保留的 `openai` / `combo` 冲突时不区分大小写;带 namespace 的 combo alias 不能把 selector 复用为 +其 namespace prefix,已配置的 pool id 和其他 selector target 也不能复用为 selector。raw account id +与 email 应保持私密,selector 才是公开名称。明确选择的行为和优先级见 +[路由配置](/reference/configuration/routing/)。 ## 保留的 OpenAI 提供者 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md b/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md index 51947f295..b4bf5c8f5 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md @@ -16,19 +16,32 @@ description: 默认提供方选择、模型解析顺序、组合别名、目标 opencodex 按以下顺序解析请求的模型: -1. 规范化的 `combo/` 或已配置的 combo 别名。规范化 id 会优先于别名匹配。 -2. 显式的 `/` 命名空间,其前缀名称对应一个已配置的提供方。 -3. 诸如 `gpt-*`、`o1-*`、`o3-*` 或 `o4-*` 之类未带前缀的原生 OpenAI 系列 id,会通过 +1. 已配置的 `/` 命名空间,只会路由到映射的已存储 Codex + 账户。无效或不可用的精确目标会以 fail closed 方式失败。 +2. 规范化的 `combo/` 或已配置的 combo 别名。规范化 id 会优先于别名匹配。 +3. 显式的 `/` 命名空间,其前缀名称对应一个已配置的提供方。 +4. 诸如 `gpt-*`、`o1-*`、`o3-*` 或 `o4-*` 之类未带前缀的原生 OpenAI 系列 id,会通过 规范化且已启用的 `openai` 提供方进行路由。 -4. 与某个提供方的 `defaultModel` 完全匹配。 -5. 已知的提供方系列模型前缀。 -6. 与某个提供方配置的 `models` 列表中的模型完全匹配。 -7. `defaultProvider`,同时保留请求的 model id。 +5. 与某个提供方的 `defaultModel` 完全匹配。 +6. 已知的提供方系列模型前缀。 +7. 与某个提供方配置的 `models` 列表中的模型完全匹配。 +8. `defaultProvider`,同时保留请求的 model id。 已禁用的提供方会被排除在外。对已禁用提供方的显式命名空间会直接失败,而不会继续 向后回退。对于可能匹配多个提供方的规则,提供方条目会按照其 JSON 插入顺序进行检查, 因此当一个裸模型可能存在歧义时,请使用显式命名空间。 +## 精确 Codex 账户选择器 + +`codexAccountNamespaces` 会把 `side` 这样的公开 selector 映射到一个已存储 Codex 账户。 +`side/gpt-5.6-sol` 请求即使在规范 `openai` 提供方处于 Direct mode 时也只使用该账户,并向 +upstream 发送裸 `gpt-5.6-sol` model id。selector 后只能使用裸原生 OpenAI-family id。 + +精确选择会绕过 Pool 分配策略和普通 thread affinity。若映射账户不存在、已暂停、处于 cooldown、 +不可用或需要重新认证,请求会 fail closed,不会切换到其他账户,也不会改变 active Pool account。 +裸原生 model id 保持正常 Pool / Direct routing。namespace map 本身不会创建 model picker row。 +selector 校验、冲突规则和隐私说明见[提供方配置](/reference/configuration/providers/)。 + ## Combos (`config.combos`) 每个 combo 键都是一个符合 `[A-Za-z0-9][A-Za-z0-9._-]{0,63}` 的 id。它始终可以直接通过 diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 4993291ea..aabdb5be3 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -6,8 +6,9 @@ import { getValidCodexToken, isCodexAccountGenerationLive, } from "./account-store"; +import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; +import { isCodexAccountPaused } from "./account-pause"; import { ConfigMutationLockError } from "../config"; -import { markAccountNeedsReauth } from "./account-runtime-state"; import { isCodexAccountUsable } from "./account-usability"; import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle"; import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./main-account"; @@ -38,6 +39,8 @@ export type CodexAuthContext = generation: number; accessToken: string; chatgptAccountId: string; + /** Bypass Pool selection and suppress quota/transient failover for an exact selector. */ + fixedAccount?: boolean; /** * Set when this request was admitted through an active quota cooldown as * the account's single probe. Must be echoed into the upstream outcome so @@ -57,6 +60,8 @@ export type CodexAuthContext = writerGeneration: number; accessToken: string; chatgptAccountId: string; + /** Bypass Pool selection and suppress quota/transient failover for an exact selector. */ + fixedAccount?: boolean; /** See `pool.probeLeaseId`. */ probeLeaseId?: string; quotaScope?: CodexQuotaScope; @@ -100,8 +105,8 @@ export class CodexAuthContextError extends Error { } export class CodexPoolAuthenticationError extends Error { - constructor() { - super("OpenAI account pool has no usable account credential"); + constructor(message = "OpenAI account pool has no usable account credential") { + super(message); this.name = "CodexPoolAuthenticationError"; } } @@ -154,22 +159,31 @@ export function cooldownAccountLabel(accountId: string): string { * as HTTP. The bare "cooling down" string left users with no route but commenting out the * injected `openai_base_url` in config.toml. */ -export function cooldownErrorMessage(err: CodexAccountCooldownError): string { +export function cooldownErrorMessage(err: CodexAccountCooldownError, accountSelector?: string): string { const until = new Date(err.cooldownUntil).toISOString(); const scope = err.quotaScope === "spark" ? "Spark quota" : err.quotaScope === "shared" ? "shared native quota" : null; - return `Selected Codex account (${cooldownAccountLabel(err.accountId)})${scope ? ` ${scope} is` : " is"} cooling down until ${until}` - + ` (source: ${err.cooldownSource ?? "default"}).` - + ` Run 'ocx account list openai' to find the id, then` - + ` 'ocx account clear-cooldown openai ' to lift it, or switch accounts with 'ocx account use openai '.`; + const selected = accountSelector + ? `Selected Codex account selector (${accountSelector})` + : `Selected Codex account (${cooldownAccountLabel(err.accountId)})`; + const recovery = accountSelector + ? " This request is pinned to that selector and will not switch accounts; choose another account-qualified model or retry later." + : " Run 'ocx account list openai' to find the id, then" + + " 'ocx account clear-cooldown openai ' to lift it, or switch accounts with 'ocx account use openai '."; + return `${selected}${scope ? ` ${scope} is` : " is"} cooling down until ${until}` + + ` (source: ${err.cooldownSource ?? "default"}).${recovery}`; } /** HTTP form of {@link cooldownErrorMessage}, carrying Retry-After for well-behaved clients. */ -export function cooldownErrorResponse(err: CodexAccountCooldownError, now = Date.now()): Response { - const res = formatErrorResponse(429, "rate_limit_error", cooldownErrorMessage(err)); +export function cooldownErrorResponse( + err: CodexAccountCooldownError, + now = Date.now(), + accountSelector?: string, +): Response { + const res = formatErrorResponse(429, "rate_limit_error", cooldownErrorMessage(err, accountSelector)); const headers = new Headers(res.headers); headers.set("Retry-After", String(Math.max(1, Math.ceil((err.cooldownUntil - now) / 1000)))); return new Response(res.body, { status: res.status, headers }); @@ -195,6 +209,8 @@ export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown): export interface ResolveCodexAuthContextOptions { excludeAccountId?: string; + /** Resolve exactly this account without consulting or mutating Pool selection. */ + accountId?: string; /** Final native model selected for this request, used to select its quota group. */ modelId?: string; } @@ -206,14 +222,22 @@ export async function resolveCodexAuthContext( options: ResolveCodexAuthContextOptions = {}, ): Promise { const writerGeneration = captureConfigGeneration(); - if (mode === "direct") { + const fixedAccountId = options.accountId; + if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) { + throw new Error("Codex auth context cannot select and exclude an account simultaneously"); + } + // An explicit namespace binding is stronger than the provider's default mode. It must use the + // selected stored credential even while the canonical OpenAI provider is globally Direct. + if (mode === "direct" && fixedAccountId === undefined) { if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError(); return { kind: "main", accountId: null }; } reconcileMainCodexAccountRuntimeState(); const threadId = headers.get("x-codex-parent-thread-id"); const quotaScope = codexQuotaScopeForModel(options.modelId); - const resolution = options.excludeAccountId + const resolution = fixedAccountId !== undefined + ? { status: "selected" as const, accountId: fixedAccountId } + : options.excludeAccountId ? (() => { const accountId = pickAlternateCodexAccount(config, options.excludeAccountId!, Date.now(), quotaScope); return accountId @@ -223,13 +247,28 @@ export async function resolveCodexAuthContext( : resolveCodexAccountForThreadDetailed(threadId, config, Date.now(), quotaScope); if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId); let accountId = resolution.status === "selected" ? resolution.accountId : null; - if (!accountId) throw new CodexPoolAuthenticationError(); + if (!accountId) { + throw new CodexPoolAuthenticationError( + fixedAccountId !== undefined ? "Selected Codex account is unavailable" : undefined, + ); + } + if (fixedAccountId !== undefined) { + if (isCodexAccountPaused(config, accountId)) { + throw new CodexPoolAuthenticationError("Selected Codex account is unavailable"); + } + if (isAccountNeedsReauth(accountId)) { + throw new CodexPoolAuthenticationError("Selected Codex account needs reauthentication"); + } + if (!isCodexAccountUsable(config, accountId)) { + throw new CodexPoolAuthenticationError("Selected Codex account is unavailable"); + } + } // Lazy prime: if the selected account has no quota yet, the pool is likely // unprimed (dashboard never opened, or startup prime was blocked). Kick a // best-effort prime so the NEXT routing decision has real scores. This never // blocks the current request, and the helper's single-flight guard collapses // repeated triggers into one pass. - if (!getAccountQuota(accountId)) { + if (fixedAccountId === undefined && !getAccountQuota(accountId)) { import("./auth-api") .then(({ primeCodexPoolQuotas }) => primeCodexPoolQuotas(config, "pre-route")) .catch(() => {}); @@ -244,6 +283,11 @@ export async function resolveCodexAuthContext( let probeLeaseId: string | undefined; let probeQuotaScope: CodexQuotaScope | undefined; if (cooldownUntil) { + // Exact bindings are not Pool recovery traffic. Fail closed instead of consuming the Pool's + // one probe lease or selecting another account. + if (fixedAccountId !== undefined) { + throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource, cooldown?.quotaScope); + } probeQuotaScope = cooldown?.quotaScope; probeLeaseId = probeQuotaScope ? tryAcquireCodexQuotaScopeProbeLease(accountId, probeQuotaScope) ?? undefined @@ -260,7 +304,9 @@ export async function resolveCodexAuthContext( // Nothing will reach upstream, so give the probe back instead of burning it. if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId); else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId); - throw new CodexPoolAuthenticationError(); + throw new CodexPoolAuthenticationError( + fixedAccountId !== undefined ? "Selected Codex account is unavailable" : undefined, + ); } return { kind: "main-pool", @@ -268,6 +314,7 @@ export async function resolveCodexAuthContext( writerGeneration, accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId, + ...(fixedAccountId !== undefined ? { fixedAccount: true } : {}), ...(quotaScope ? { quotaScope } : {}), ...(probeLeaseId ? { probeLeaseId } : {}), ...(probeQuotaScope ? { probeQuotaScope } : {}), @@ -283,6 +330,7 @@ export async function resolveCodexAuthContext( generation: token.generation, accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId, + ...(fixedAccountId !== undefined ? { fixedAccount: true } : {}), ...(quotaScope ? { quotaScope } : {}), ...(probeLeaseId ? { probeLeaseId } : {}), ...(probeQuotaScope ? { probeQuotaScope } : {}), diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 269f321ac..1ed7ece50 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -177,6 +177,12 @@ export type CodexUpstreamOutcomeMeta = { modelId?: string; /** When set, clears affinity for this thread immediately on transient failure. */ threadId?: string | null; + /** + * Suppress Pool rotation and quota/transient affinity mutations for an account-qualified + * request. Credential failures still sweep stale affinities because reauthentication is + * account-wide. + */ + fixedAccount?: boolean; /** * Probe lease held by this request, when it was admitted through an active * quota cooldown. Only the outcome carrying the current lease may clear the @@ -1354,7 +1360,7 @@ export function recordCodexUpstreamOutcome( // The shared native scope is the existing account-wide native behavior: // threads must leave it and new requests should prefer an eligible account. // Spark remains isolated so a same-account Terra/Luna combo fallback can run. - if (quotaScope === "shared") { + if (quotaScope === "shared" && !meta.fixedAccount) { clearThreadAccountMapForAccount(accountId); notePoolRotationFailure(POOL_KEY_CODEX, accountId); if (getEffectiveActiveCodexAccountId(config) === accountId) { @@ -1399,17 +1405,19 @@ export function recordCodexUpstreamOutcome( ...(prior?.lastProbeAt !== undefined ? { lastProbeAt: prior.lastProbeAt } : {}), }), }); - clearThreadAccountMapForAccount(accountId); - notePoolRotationFailure(POOL_KEY_CODEX, accountId); - const effectiveActive = getEffectiveActiveCodexAccountId(config); - if (effectiveActive === accountId) { - // Same-request 429 retry already picked via excludeAccountId — reuse it so - // round-robin does not advance the ring a second time. - const reused = meta.promoteAccountId && meta.promoteAccountId !== accountId - ? meta.promoteAccountId - : null; - const fallback = reused ?? pickAlternateCodexAccount(config, accountId, now); - if (fallback) promoteActiveCodexAccount(config, fallback); + if (!meta.fixedAccount) { + clearThreadAccountMapForAccount(accountId); + notePoolRotationFailure(POOL_KEY_CODEX, accountId); + const effectiveActive = getEffectiveActiveCodexAccountId(config); + if (effectiveActive === accountId) { + // Same-request 429 retry already picked via excludeAccountId — reuse it so + // round-robin does not advance the ring a second time. + const reused = meta.promoteAccountId && meta.promoteAccountId !== accountId + ? meta.promoteAccountId + : null; + const fallback = reused ?? pickAlternateCodexAccount(config, accountId, now); + if (fallback) promoteActiveCodexAccount(config, fallback); + } } return; } @@ -1454,15 +1462,17 @@ export function recordCodexUpstreamOutcome( // thread is still pinned to the FAILING account — a late failure from account A // must not delete a newer healthy binding to account B (race: T→A, A fails, // T→B, late A failure must not delete B's mapping). - if (failoverReady && meta.threadId) { + if (!meta.fixedAccount && failoverReady && meta.threadId) { deleteThreadAffinitiesForAccount(meta.threadId, accountId); } // Once the account is past the failover streak, clear every thread still pinned // to it — matching 429 affinity behavior so "continue" cannot stay on a bad peer. - if (shouldFailover(config, accountId, now)) { + if (!meta.fixedAccount && shouldFailover(config, accountId, now)) { clearThreadAccountMapForAccount(accountId); } - if (getEffectiveActiveCodexAccountId(config) === accountId) applyFailureFailover(config, accountId, now); + if (!meta.fixedAccount && getEffectiveActiveCodexAccountId(config) === accountId) { + applyFailureFailover(config, accountId, now); + } } export function formatCodexProviderForLog(providerName: string, accountId: string | null, config: OcxConfig): string { diff --git a/src/providers/openai-sidecar.ts b/src/providers/openai-sidecar.ts index b3ca44509..1ab280768 100644 --- a/src/providers/openai-sidecar.ts +++ b/src/providers/openai-sidecar.ts @@ -1,5 +1,6 @@ import { resolveEnvValue } from "../config"; import { + CodexPoolAuthenticationError, headersForCodexAuthContext, hasCallerCodexBearer, isCodexAuthContextUsable, @@ -29,6 +30,17 @@ export interface ResolvedOpenAiForwardSidecar extends OpenAiForwardSidecarCandid recordOutcome?: (outcome: CodexUpstreamOutcome) => void; } +/** + * Server-resolved exact account selection for a ChatGPT sidecar call. + * `accountId` must come from a validated account-qualified route; request headers + * are never trusted as account ids. `modelId` is the sidecar's actual upstream + * model so cooldown admission and outcome recording use the correct quota scope. + */ +export interface ExactOpenAiSidecarAccount { + accountId: string; + modelId: string; +} + export interface OpenAiImagesProviderSelection { forwardCandidates: OpenAiForwardSidecarCandidate[]; keyed?: { @@ -41,11 +53,18 @@ export interface OpenAiImagesProviderSelection { export function listOpenAiForwardSidecarCandidates(config: OcxConfig): OpenAiForwardSidecarCandidate[] { const provider = config.providers[OPENAI_CODEX_PROVIDER_ID]; - if (!provider || provider.disabled === true || !isCanonicalOpenAiForwardProvider(provider)) return []; + if (!provider || provider.disabled === true) return []; + // The built-in registry defaults an omitted authMode to forward. Normalize only that + // missing field before the strict adapter/destination check; explicit key mode and + // noncanonical destinations remain ineligible for ChatGPT credential injection. + const canonicalProvider = provider.authMode === undefined + ? { ...provider, authMode: "forward" as const } + : provider; + if (!isCanonicalOpenAiForwardProvider(canonicalProvider)) return []; return [{ providerName: OPENAI_CODEX_PROVIDER_ID, - provider, - accountMode: providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, provider) ?? "pool", + provider: canonicalProvider, + accountMode: providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, canonicalProvider) ?? "pool", }]; } @@ -70,6 +89,7 @@ export async function resolveFirstUsableOpenAiSidecar( candidates: readonly OpenAiForwardSidecarCandidate[], incomingHeaders: Headers, config: OcxConfig, + exactAccount?: ExactOpenAiSidecarAccount, ): Promise { let callerBearerMayBeForwarded = true; try { @@ -79,6 +99,38 @@ export async function resolveFirstUsableOpenAiSidecar( callerBearerMayBeForwarded = false; } for (const candidate of candidates) { + if (exactAccount) { + // An account-qualified model is an explicit user choice. Resolve the stored + // credential directly even when the provider is globally Direct, and never + // consult Pool active state, affinity, probes, or alternates. + const authContext = await resolveCodexAuthContext(incomingHeaders, config, "pool", { + accountId: exactAccount.accountId, + modelId: exactAccount.modelId, + }); + if ((authContext.kind !== "pool" && authContext.kind !== "main-pool") + || !isCodexAuthContextUsable(authContext, config)) { + // Exact selection is fail-closed. A generation/runtime-state race must not fall through + // to the caller-bearer error or let a later candidate select another account. + throw new CodexPoolAuthenticationError("Selected Codex account is unavailable"); + } + return { + ...candidate, + authContext, + headers: headersForCodexAuthContext(incomingHeaders, authContext), + recordOutcome: (outcome: CodexUpstreamOutcome) => recordCodexUpstreamOutcome( + config, + authContext.accountId, + outcome, + { + modelId: exactAccount.modelId, + fixedAccount: true, + probeLeaseId: authContext.probeLeaseId, + probeQuotaScope: authContext.probeQuotaScope, + writerGeneration: authContext.writerGeneration, + }, + ), + }; + } if (candidate.accountMode === "direct") { if (!callerBearerMayBeForwarded || !hasCallerCodexBearer(incomingHeaders)) continue; const headers = directSidecarHeaders(incomingHeaders); diff --git a/src/router.ts b/src/router.ts index 0c562240c..b94dc29f9 100644 --- a/src/router.ts +++ b/src/router.ts @@ -4,15 +4,26 @@ import { hasOwnProvider, resolveEnvValue } from "./config"; import { assertProviderDestinationAllowed } from "./lib/destination-policy"; import { redactSecretString, redactUrlForLog } from "./lib/redact"; import { PROVIDER_REGISTRY, providerCodexAccountMode, providerMatchesRegistryTransport } from "./providers/registry"; -import { LEGACY_CHATGPT_PROVIDER_ID, LEGACY_OPENAI_MULTI_PROVIDER_ID, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers"; +import { + isCanonicalOpenAiForwardProvider, + LEGACY_CHATGPT_PROVIDER_ID, + LEGACY_OPENAI_MULTI_PROVIDER_ID, + OPENAI_API_PROVIDER_ID, + OPENAI_CODEX_PROVIDER_ID, +} from "./providers/openai-tiers"; import { decodeRoutedModelId, encodeRoutedModelId } from "./providers/slug-codec"; import { getStaleCached } from "./codex/model-cache"; +import { codexAccountNamespaceEntries } from "./codex/account-namespaces"; export interface RouteResult { providerName: string; provider: OcxProviderConfig; modelId: string; codexAccountMode?: CodexAccountMode; + /** Exact account selected by an account-qualified native model. */ + codexAccountId?: string; + /** Public namespace used by the account-qualified selector. */ + codexAccountNamespace?: string; combo?: ComboPick; } @@ -335,6 +346,39 @@ function routeResult(providerName: string, provider: OcxProviderConfig, modelId: } function routeModelInternal(config: OcxConfig, modelId: string, bypassCombos: boolean): RouteResult { + const slash = modelId.indexOf("/"); + if (slash > 0) { + const namespace = modelId.slice(0, slash); + const binding = codexAccountNamespaceEntries(config) + .find(([candidate]) => candidate === namespace); + if (binding) { + const nativeModelId = modelId.slice(slash + 1); + if (!isBareOpenAiFamilyModel(nativeModelId)) { + throw new Error(`Codex account namespace ${namespace} only supports native OpenAI model ids`); + } + const provider = config.providers[OPENAI_CODEX_PROVIDER_ID]; + if (!provider || provider.disabled === true) { + throw new NoEnabledOpenAiProviderError(nativeModelId); + } + // Registry routing backfills an omitted authMode on the built-in OpenAI row to forward. + // Mirror only that default here; explicit non-forward modes still fail closed. + const providerForCanonicalCheck = provider.authMode === undefined + ? { ...provider, authMode: "forward" as const } + : provider; + if (!isCanonicalOpenAiForwardProvider(providerForCanonicalCheck)) { + throw new NoEnabledOpenAiProviderError(nativeModelId); + } + return { + ...routeResult(OPENAI_CODEX_PROVIDER_ID, provider, nativeModelId), + // Exact account injection uses the pool credential machinery even when the canonical + // provider is globally Direct. The fixed id bypasses pool selection entirely. + codexAccountMode: "pool", + codexAccountId: binding[1], + codexAccountNamespace: namespace, + }; + } + } + if (!bypassCombos && !preservesPhysicalComboProvider(config)) { const combo = tryPickComboModel(config, modelId); if (combo) { @@ -350,7 +394,6 @@ function routeModelInternal(config: OcxConfig, modelId: string, bypassCombos: bo // Only triggers when the prefix matches a CONFIGURED provider, so genuine // slash-containing model ids (e.g. "anthropic/claude-...") fall through when // no such provider exists. - const slash = modelId.indexOf("/"); if (slash > 0) { const provName = modelId.slice(0, slash); if (provName === LEGACY_CHATGPT_PROVIDER_ID || provName === LEGACY_OPENAI_MULTI_PROVIDER_ID) { diff --git a/src/server/index.ts b/src/server/index.ts index c8d7968c1..85214800c 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -48,6 +48,7 @@ import { CodexAccountCooldownError, cooldownErrorMessage, } from "../codex/auth-context"; +import { codexAccountNamespaceForModel } from "../codex/account-namespace-match"; export { clearThreadAccountMap, formatCodexProviderForLog, @@ -1057,9 +1058,12 @@ export function startServer(port?: number) { finalizeLog(429); // Codex Desktop rides this WS transport, so it must carry the same // actionable text as HTTP; a frame has no headers, hence message-only. + const accountSelector = typeof payload.model === "string" + ? codexAccountNamespaceForModel(config.codexAccountNamespaces, payload.model) + : undefined; sendJsonFrame(ws, buildWsErrorFrame(429, { type: "rate_limit_error", - message: cooldownErrorMessage(err), + message: cooldownErrorMessage(err, accountSelector), })); return; } diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 25bfe5a4d..aa4ab2499 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -186,7 +186,9 @@ export async function handleResponsesCompact( const selectedModelId = route.modelId; logCtx.requestedModel = raw.model; logCtx.model = selectedModelId; - logCtx.provider = route.providerName; + logCtx.provider = route.codexAccountNamespace + ? `${route.providerName}-${route.codexAccountNamespace}` + : route.providerName; logCtx.providerAdapter = route.provider.adapter; const virtual = resolveOpenAiCompactModel(route.providerName, selectedModelId); if (virtual) { @@ -218,7 +220,10 @@ export async function handleResponsesCompact( const headers = new Headers({ "content-type": "application/json" }); try { if (route.codexAccountMode) { - authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { modelId: selectedModelId }); + authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { + accountId: route.codexAccountId, + modelId: selectedModelId, + }); const selected = headersForCodexAuthContext(req.headers, authCtx); compactProvider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode); for (const name of FORWARD_HEADERS) { @@ -233,7 +238,7 @@ export async function handleResponsesCompact( } } catch (err) { if (err instanceof CodexAccountCooldownError) { - return cooldownErrorResponse(err); + return cooldownErrorResponse(err, Date.now(), route.codexAccountNamespace); } if (err instanceof CodexThreadAffinityExpiredError) { return formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session"); @@ -247,7 +252,9 @@ export async function handleResponsesCompact( throw err; } const base = (compactProvider.baseUrl ?? "").replace(/\/$/, ""); - if (compactProvider.apiKey) headers.set("authorization", `Bearer ${resolveEnvValue(compactProvider.apiKey)}`); + if (compactProvider.authMode !== "forward" && compactProvider.apiKey) { + headers.set("authorization", `Bearer ${resolveEnvValue(compactProvider.apiKey)}`); + } const { reasoning: _reasoning, ...compactBodyRaw } = raw as typeof raw & { reasoning?: unknown }; // The regular /v1/responses path applies sanitizeReasoningInputContent via the adapter's // buildRequest, but the compact endpoint forwards directly. Apply the same sanitizer here @@ -264,6 +271,7 @@ export async function handleResponsesCompact( recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { ...meta, threadId: compactThreadId, + fixedAccount: authCtx.fixedAccount, modelId: selectedModelId, probeLeaseId: codexProbeLeaseId(authCtx), probeQuotaScope: codexProbeQuotaScope(authCtx), diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index e714b3d81..1138c1a77 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -59,7 +59,7 @@ import { } from "../../oauth/anthropic-routing"; import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search"; import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images"; -import { describeImagesInPlace, planVisionSidecar, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision"; +import { describeImagesInPlace, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision"; import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue"; import { applyCodexAuthContextToProvider, @@ -178,7 +178,9 @@ export function sidecarOutcomeRecorder( return authCtx.kind === "pool" || authCtx.kind === "main-pool" ? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { threadId, + fixedAccount: authCtx.fixedAccount, probeLeaseId: authCtx.probeLeaseId, + probeQuotaScope: authCtx.probeQuotaScope, writerGeneration: authCtx.writerGeneration, }) : undefined; @@ -196,6 +198,11 @@ export function codexLogAccountId(authCtx: CodexAuthContext): string | null { return authCtx.kind === "pool" || authCtx.kind === "main-pool" ? authCtx.accountId : null; } +function isFixedCodexAccount(authCtx: CodexAuthContext): boolean { + return (authCtx.kind === "pool" || authCtx.kind === "main-pool") + && authCtx.fixedAccount === true; +} + export function usesCodexForwardPoolAuth( @@ -326,6 +333,9 @@ async function retryCodexPoolOnAlternateAccount( req, config, route, parsed, logCtx, options, firstAuthCtx, firstResponse, outcomeStatus, upstream, connectMs, passthroughEstimate, stream, } = args; + // Defense in depth: exact account selectors must never reach alternate-account resolution, + // even if a future caller forgets to guard this helper. + if (firstAuthCtx.fixedAccount) return { kind: "no-alternate" }; const inboundWire = options.inboundWire ?? "responses"; let retryAuthCtx: CodexAuthContext | undefined; try { @@ -440,6 +450,7 @@ export function codexForwardTerminalOutcomeRecorder( // prior soft-avoid so a healthy account isn't stuck avoided. recordCodexUpstreamOutcome(config, authCtx.accountId, 200, { threadId, + fixedAccount: authCtx.fixedAccount, modelId, probeLeaseId: codexProbeLeaseId(authCtx), probeQuotaScope: codexProbeQuotaScope(authCtx), @@ -461,6 +472,7 @@ export function codexForwardTerminalOutcomeRecorder( : (httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502); recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { threadId, + fixedAccount: authCtx.fixedAccount, modelId, probeLeaseId: codexProbeLeaseId(authCtx), probeQuotaScope: codexProbeQuotaScope(authCtx), @@ -716,7 +728,10 @@ async function resolveResponsesCodexAuth( if (route.codexAccountMode === "direct") validateForwardAdmissionCredential(req.headers, config); let authCtx: CodexAuthContext; if (route.codexAccountMode) { - authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { modelId: route.modelId }); + authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { + accountId: route.codexAccountId, + modelId: route.modelId, + }); options.onCodexAuthContextResolved?.(authCtx); } else { authCtx = { kind: "main", accountId: null }; @@ -736,7 +751,7 @@ async function resolveResponsesCodexAuth( }; } catch (err) { if (err instanceof CodexAccountCooldownError) { - return { ok: false, response: cooldownErrorResponse(err) }; + return { ok: false, response: cooldownErrorResponse(err, Date.now(), route.codexAccountNamespace) }; } if (err instanceof CodexThreadAffinityExpiredError) { return { @@ -745,7 +760,9 @@ async function resolveResponsesCodexAuth( }; } if (err instanceof CodexAuthContextError) { - const safeAccountLabel = formatCodexProviderForLog(route.providerName, err.accountId, config); + const safeAccountLabel = route.codexAccountNamespace + ? `${route.providerName}-${route.codexAccountNamespace}` + : formatCodexProviderForLog(route.providerName, err.accountId, config); console.error(`[codex-auth] Pool account ${safeAccountLabel} token failed; reauthentication required`); return { ok: false, @@ -1276,10 +1293,12 @@ async function handleResponsesInner( const hasUnexpandedPreviousResponse = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true; - // A canonical replay miss must not poll quota upstream before the final fail-closed decision. - // Cached fallback state can still select a provider with native continuation support below. + // Exact account selectors are isolated from Pool-wide quota work. A canonical replay miss must + // also fail closed without polling quota upstream. Cached fallback state can still select a + // provider with native continuation support below. if ( isThreadSpawnRequest(req.headers) + && route.codexAccountId === undefined && !(hasUnexpandedPreviousResponse && isCanonicalOpenAiForwardProvider(route.provider)) ) { await maybePrimeSubagentQuota(config); @@ -1294,7 +1313,7 @@ async function handleResponsesInner( // normalization (virtual models, effort caps, service tier, wire protocol). // Preview the preferred Codex account without acquiring a probe lease or refreshing // tokens — auth is resolved only after the final route is selected. - if (isThreadSpawnRequest(req.headers) && !options.comboAttempt) { + if (isThreadSpawnRequest(req.headers) && !options.comboAttempt && route.codexAccountId === undefined) { const threadId = req.headers.get("x-codex-parent-thread-id"); const previewAccountId = previewCodexAccountForRequest(threadId, config); subagentFallbackAccountId = previewAccountId ?? config.activeCodexAccountId ?? null; @@ -1348,6 +1367,11 @@ async function handleResponsesInner( } await applyFinalRouteRequestNormalization({ parsed, route, config, req, logCtx, inboundWire }); + // Attribute local auth/cooldown failures to the public selector too; exact auth may fail before + // the normal post-resolution provider label is assigned. + if (route.codexAccountNamespace) { + logCtx.provider = `${route.providerName}-${route.codexAccountNamespace}`; + } { const finalAuth = await resolveResponsesCodexAuth(req, config, route, options); @@ -1357,7 +1381,9 @@ async function handleResponsesInner( } route.provider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode); - logCtx.provider = formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config); + logCtx.provider = route.codexAccountNamespace + ? `${route.providerName}-${route.codexAccountNamespace}` + : formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config); // Prefer Codex pool account as the Cursor thread namespace when present. Cursor routes without // codexAccountMode still get a credential-derived scope inside the Cursor adapter. const identityScope = codexLogAccountId(authCtx); @@ -1460,6 +1486,11 @@ async function handleResponsesInner( listOpenAiForwardSidecarCandidates(config), req.headers, config, + // Account-qualified native routes are passthrough, so their in-turn helper is vision. + // Scope its cooldown and outcome to the helper model, not the routed text model. + route.codexAccountId !== undefined + ? { accountId: route.codexAccountId, modelId: resolveOpenAiVisionModel(config) } + : undefined, ); } catch (err) { // Sidecars are optional helpers for an otherwise independent routed turn. @@ -1585,6 +1616,7 @@ async function handleResponsesInner( if (usesCodexForwardPoolAuth(authCtx, route.provider)) { recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { threadId: req.headers.get("x-codex-parent-thread-id"), + fixedAccount: authCtx.fixedAccount, modelId: route.modelId, probeLeaseId: codexProbeLeaseId(authCtx), probeQuotaScope: codexProbeQuotaScope(authCtx), @@ -1617,7 +1649,7 @@ async function handleResponsesInner( request.releaseBodyObservation?.(); } - if (usesCodexForwardPoolAuth(authCtx, route.provider)) { + if (usesCodexForwardPoolAuth(authCtx, route.provider) && !authCtx.fixedAccount) { let poolRetryOutcome: number | undefined; if (await shouldRetryCodexPoolAccountModel400( upstreamResponse, @@ -1701,7 +1733,7 @@ async function handleResponsesInner( || logCtx.terminalHttpStatus === 402 ? (httpStatusOverride ?? logCtx.terminalHttpStatus) : undefined; - if (quotaFailureMessage !== undefined) { + if (!isFixedCodexAccount(authCtx) && quotaFailureMessage !== undefined) { recordSubagentQuotaFailureForThreadSpawn( req.headers, subagentQuotaFailureModel, @@ -1720,6 +1752,7 @@ async function handleResponsesInner( recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, { ...quotaMeta, threadId: req.headers.get("x-codex-parent-thread-id"), + fixedAccount: authCtx.fixedAccount, modelId: route.modelId, probeLeaseId: codexProbeLeaseId(authCtx), probeQuotaScope: codexProbeQuotaScope(authCtx), @@ -1788,7 +1821,7 @@ async function handleResponsesInner( || logCtx.terminalHttpStatus === 402 ? (httpStatusOverride ?? logCtx.terminalHttpStatus) : undefined; - if (quotaFailureMessage !== undefined) { + if (!isFixedCodexAccount(authCtx) && quotaFailureMessage !== undefined) { recordSubagentQuotaFailureForThreadSpawn( req.headers, subagentQuotaFailureModel, @@ -1863,7 +1896,7 @@ async function handleResponsesInner( || logCtx.terminalHttpStatus === 402 ? (httpStatusOverride ?? logCtx.terminalHttpStatus) : undefined; - if (quotaFailureMessage !== undefined) { + if (!isFixedCodexAccount(authCtx) && quotaFailureMessage !== undefined) { recordSubagentQuotaFailureForThreadSpawn( req.headers, subagentQuotaFailureModel, @@ -2459,15 +2492,17 @@ async function handleResponsesInner( } const errorText = await upstreamResponse.text().catch(() => "unknown error"); cleanupUpstreamAbort(); - recordSubagentQuotaFailureForThreadSpawn( - req.headers, - subagentQuotaFailureModel, - upstreamResponse.status === 429 || upstreamResponse.status === 402 - ? upstreamResponse.status - : `Provider error ${upstreamResponse.status}: ${redactSecretString(errorText.slice(0, 500))}`, - config, - subagentFallbackAccountId, - ); + if (!isFixedCodexAccount(authCtx)) { + recordSubagentQuotaFailureForThreadSpawn( + req.headers, + subagentQuotaFailureModel, + upstreamResponse.status === 429 || upstreamResponse.status === 402 + ? upstreamResponse.status + : `Provider error ${upstreamResponse.status}: ${redactSecretString(errorText.slice(0, 500))}`, + config, + subagentFallbackAccountId, + ); + } // Upstreams occasionally echo request details in error bodies — scrub token-shaped // material before it reaches the client-facing error surface. const message = `Provider error ${upstreamResponse.status}: ${redactSecretString(errorText.slice(0, 500))}`; diff --git a/src/server/search.ts b/src/server/search.ts index 0e6767c13..7cdf595bd 100644 --- a/src/server/search.ts +++ b/src/server/search.ts @@ -16,11 +16,17 @@ import { CodexPoolAuthenticationError, CodexThreadAffinityExpiredError, } from "../codex/auth-context"; +import { codexAccountNamespaceForModel } from "../codex/account-namespace-match"; import { formatCodexProviderForLog } from "../codex/routing"; import { signalWithTimeout } from "../lib/abort"; import { sidecarEnter } from "../lib/sidecar-tracker"; import type { OcxConfig } from "../types"; -import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar } from "../providers/openai-sidecar"; +import { + listOpenAiForwardSidecarCandidates, + resolveFirstUsableOpenAiSidecar, + type ExactOpenAiSidecarAccount, +} from "../providers/openai-sidecar"; +import { routeModel } from "../router"; import { readJsonRequestBody } from "./request-decompress"; import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors"; import type { RequestLogContext } from "./request-log"; @@ -55,6 +61,31 @@ export async function handleSearch( const model = (body as { model?: unknown } | null)?.model; if (typeof model === "string" && model) logCtx.model = model; + let exactAccount: ExactOpenAiSidecarAccount | undefined; + let relayBody = body; + const accountNamespace = typeof model === "string" + ? codexAccountNamespaceForModel(config.codexAccountNamespaces, model) + : undefined; + if (accountNamespace && typeof model === "string") { + try { + const route = routeModel(config, model); + if (!route.codexAccountId || route.codexAccountNamespace !== accountNamespace) { + return formatErrorResponse(400, "invalid_request_error", "Invalid Codex account-qualified search model"); + } + exactAccount = { accountId: route.codexAccountId, modelId: route.modelId }; + logCtx.provider = `${route.providerName}-${accountNamespace}`; + // The ChatGPT search endpoint only understands the native model slug. The + // account namespace is proxy routing syntax and must not cross the wire. + relayBody = { ...(body as Record), model: route.modelId }; + } catch (err) { + return formatErrorResponse( + 400, + "invalid_request_error", + err instanceof Error ? err.message : "Invalid Codex account-qualified search model", + ); + } + } + const candidates = listOpenAiForwardSidecarCandidates(config); if (candidates.length === 0) { return formatErrorResponse( @@ -67,7 +98,7 @@ export async function handleSearch( let upstream: Awaited>; try { - upstream = await resolveFirstUsableOpenAiSidecar(candidates, req.headers, config); + upstream = await resolveFirstUsableOpenAiSidecar(candidates, req.headers, config, exactAccount); if (!upstream) { return formatErrorResponse( 401, @@ -75,16 +106,20 @@ export async function handleSearch( "web search relay needs ChatGPT auth (Authorization header)", ); } - logCtx.provider = formatCodexProviderForLog(upstream.providerName, codexLogAccountId(upstream.authContext), config); + logCtx.provider = accountNamespace + ? `${upstream.providerName}-${accountNamespace}` + : formatCodexProviderForLog(upstream.providerName, codexLogAccountId(upstream.authContext), config); } catch (err) { if (err instanceof CodexAccountCooldownError) { - return cooldownErrorResponse(err); + return cooldownErrorResponse(err, Date.now(), accountNamespace); } if (err instanceof CodexThreadAffinityExpiredError) { return formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session"); } if (err instanceof CodexAuthContextError) { - const safeAccountLabel = formatCodexProviderForLog("openai", err.accountId, config); + const safeAccountLabel = accountNamespace + ? `openai-${accountNamespace}` + : formatCodexProviderForLog("openai", err.accountId, config); console.error(`[search] Pool account ${safeAccountLabel} token failed; reauthentication required`); return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"); } @@ -103,7 +138,7 @@ export async function handleSearch( const upstreamResponse = await fetch(url, { method: "POST", headers, - body: JSON.stringify(body), + body: JSON.stringify(relayBody), signal: linkedSignal.signal, }); const payload = await upstreamResponse.arrayBuffer(); diff --git a/src/vision/index.ts b/src/vision/index.ts index c249d0af4..37dacc596 100644 --- a/src/vision/index.ts +++ b/src/vision/index.ts @@ -184,6 +184,11 @@ export function resolveVisionBackend( return anthropicSidecar ? "anthropic" : "openai"; } +/** Native model used by the OpenAI vision helper, including its bounded default. */ +export function resolveOpenAiVisionModel(config: Pick): string { + return config.visionSidecar?.model ?? DEFAULT_VISION_MODEL; +} + /** A user/developer/toolResult message can carry images (toolResult: e.g. Codex view_image output). */ function carriesImages(role: string): boolean { return role === "user" || role === "developer" || role === "toolResult"; @@ -249,7 +254,7 @@ export function planVisionSidecar( return { backend, forwardSidecar: openAiSidecar, - settings: { model: cfg.model ?? DEFAULT_VISION_MODEL, timeoutMs: cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS }, + settings: { model: resolveOpenAiVisionModel(config), timeoutMs: cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS }, maxDescriptionsPerTurn, }; } diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 5a39c59e2..fcc1c77b6 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -61,6 +61,24 @@ The two-shape contract is mirror-commented in `src/server/index.ts`; the real and the platform matrix lives in `tests/bun-stream-caps.test.ts`. Keep all three in lockstep with any passthrough-policy change. +## Standalone Search and exact account selectors + +`POST /v1/alpha/search` retains the selected model in its request body. When that value is an +account-qualified native selector, the server resolves the public namespace, uses only the mapped +stored Codex credential, and sends the bare native model upstream. That exact path is fail-closed: +it does not consult Pool active state or affinity when selecting, and its outcomes cannot rotate +the active Pool account. An account-wide credential failure still quarantines that credential and +clears stale ordinary Pool affinities so they cannot reappear after reauthentication. Quota and +transient outcomes from an exact request leave Pool affinities untouched. Ordinary search requests +keep the normal Direct/Pool sidecar behavior. + +Standalone Images and Live requests currently carry neither the account-qualified model selector +nor a trustworthy thread correlation from the Codex client. They therefore retain normal provider +routing. Do not infer an exact account from caller-supplied account headers, process-global last +selection, connection identity, or other ambient state; concurrent threads could cross-route +credentials. Extending exact routing to those endpoints requires an opaque client correlation that +can be bound server-side to a previously validated selector. + ## Standalone Images Codex's local `image_gen.imagegen` tool makes a second Images request after the model calls it: diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index f8b8ef34a..ec78cbbd4 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -52,6 +52,10 @@ import { setLiveStateStoreConfig, STATE_STORE_REGISTRATIONS, } from "../src/lib/state-store-registrations"; +import { + listOpenAiForwardSidecarCandidates, + resolveFirstUsableOpenAiSidecar, +} from "../src/providers/openai-sidecar"; const TEST_DIR = join(import.meta.dir, ".tmp-codex-auth-api-test"); const TEST_CODEX_HOME = join(TEST_DIR, "codex"); @@ -2524,6 +2528,14 @@ describe("codex-auth API", () => { const config = makeConfig({ activeCodexAccountId: "pool-delete", codexAccounts: [{ id: "pool-delete", email: "pool-delete@example.test", isMain: false }], + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + }, + }, }); saveCodexAccountCredential("pool-delete", { accessToken: "access-delete", @@ -2535,6 +2547,17 @@ describe("codex-auth API", () => { setLiveStateStoreConfig(config); reconcileLiveStateStores(); const preDeletionWriterGeneration = captureConfigGeneration(); + const exactSidecar = await resolveFirstUsableOpenAiSidecar( + listOpenAiForwardSidecarCandidates(config), + new Headers(), + config, + { accountId: "pool-delete", modelId: "gpt-5.6-sol" }, + ); + expect(exactSidecar?.authContext).toMatchObject({ + accountId: "pool-delete", + fixedAccount: true, + writerGeneration: preDeletionWriterGeneration, + }); updateAccountQuota("pool-delete", 70); expect(resolveCodexAccountForThread("delete-thread", config)).toBe("pool-delete"); recordCodexUpstreamOutcome(config, "pool-delete", 500); @@ -2580,6 +2603,12 @@ describe("codex-auth API", () => { expect(closed).toEqual([{ code: 4001, reason: "Codex account invalidated" }]); expect(getTrackedCodexWebSocketCountForAccount("pool-delete")).toBe(0); + // An exact sidecar can finish after its selected account was deleted. Its + // captured writer generation must keep that late outcome from recreating + // routing health for an owner that no longer exists. + exactSidecar?.recordOutcome?.(429); + expect(getCodexUpstreamHealth("pool-delete")).toBeNull(); + updateAccountQuota( "pool-delete", 99, diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index 494d3cdd6..ee64970d3 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -36,6 +36,7 @@ import { clearAccountQuota, handleCodexAuthAPI, isAccountNeedsReauth, + markAccountNeedsReauth, } from "../src/codex/auth-api"; import { __resetGuardianState, guardianSweep } from "../src/oauth/token-guardian"; import { @@ -181,6 +182,63 @@ describe("Codex auth context", () => { await expect(resolveCodexAuthContext(new Headers({ authorization: "Bearer " }), config(), "direct")) .rejects.toBeInstanceOf(CodexDirectAuthenticationError); }); + + test("exact account resolution overrides Direct without consulting Pool selection", async () => { + const cfg = config(); + cfg.activeCodexAccountId = "pool-b"; + cfg.codexAccounts?.push({ id: "pool-b", email: "pool-b@example.test", isMain: false }); + saveCodexAccountCredential("pool-a", { + accessToken: "fixed_pool_token", + refreshToken: "fixed_pool_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "fixed_pool_acc", + }); + saveCodexAccountCredential("pool-b", { + accessToken: "active_pool_token", + refreshToken: "active_pool_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "active_pool_acc", + }); + const headers = new Headers({ "x-codex-parent-thread-id": "exact-thread" }); + + const exactContext = await resolveCodexAuthContext(headers, cfg, "direct", { + accountId: "pool-a", + modelId: "gpt-5.6-sol", + }); + expect(exactContext).toMatchObject({ + kind: "pool", + accountId: "pool-a", + accessToken: "fixed_pool_token", + chatgptAccountId: "fixed_pool_acc", + fixedAccount: true, + }); + expect(applyCodexAuthContextToProvider(forwardProvider, exactContext, "pool")).toMatchObject({ + _codexAccountRequired: true, + _codexAccountOverride: { accessToken: "fixed_pool_token", chatgptAccountId: "fixed_pool_acc" }, + }); + expect(cfg.activeCodexAccountId).toBe("pool-b"); + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-sol" })) + .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); + }); + + test("exact main-account resolution reads auth.json without consulting the active Pool account", async () => { + const cfg = config(); + writeFileSync(join(testDir, "auth.json"), JSON.stringify({ + tokens: { access_token: "opaque-live-main-token", account_id: "main-chatgpt-account" }, + })); + + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + accountId: MAIN_CODEX_ACCOUNT_ID, + modelId: "gpt-5.6-sol", + })).resolves.toMatchObject({ + kind: "main-pool", + accountId: MAIN_CODEX_ACCOUNT_ID, + accessToken: "opaque-live-main-token", + chatgptAccountId: "main-chatgpt-account", + fixedAccount: true, + }); + expect(cfg.activeCodexAccountId).toBe("pool-a"); + }); test("selects pool auth independently of the routed provider", async () => { saveCodexAccountCredential("pool-a", { accessToken: "pool_token", @@ -248,7 +306,7 @@ describe("Codex auth context", () => { )).rejects.toBeInstanceOf(CodexPoolAuthenticationError); }); - test("pause excludes new auth selection without invalidating an in-flight context", async () => { + test("pause excludes new pool and exact selection without invalidating an in-flight context", async () => { const cfg = config(); cfg.codexAccounts?.push({ id: "pool-b", @@ -275,10 +333,92 @@ describe("Codex auth context", () => { cfg.pausedCodexAccountIds = ["pool-a"]; expect(isCodexAuthContextUsable(captured, cfg)).toBe(true); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + accountId: "pool-a", + modelId: "gpt-5.6-sol", + })).rejects.toThrow("Selected Codex account is unavailable"); + expect(cfg.activeCodexAccountId).toBe("pool-a"); await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")) .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); }); + test("exact selection reports reauthentication without falling back to the active Pool account", async () => { + const cfg = config(); + cfg.activeCodexAccountId = "pool-b"; + cfg.codexAccounts?.push({ id: "pool-b", email: "pool-b@example.test", isMain: false }); + saveCodexAccountCredential("pool-a", { + accessToken: "pool_a_token", + refreshToken: "pool_a_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_a_acc", + }); + saveCodexAccountCredential("pool-b", { + accessToken: "pool_b_token", + refreshToken: "pool_b_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_b_acc", + }); + markAccountNeedsReauth("pool-a"); + + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + accountId: "pool-a", + modelId: "gpt-5.6-sol", + })).rejects.toThrow("Selected Codex account needs reauthentication"); + expect(cfg.activeCodexAccountId).toBe("pool-b"); + }); + + test("exact account failure never falls back to another usable account", async () => { + const cfg = config(); + cfg.codexAccounts?.push({ id: "pool-b", email: "pool-b@example.test", isMain: false }); + saveCodexAccountCredential("pool-b", { + accessToken: "pool_b_token", + refreshToken: "pool_b_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_b_acc", + }); + + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + accountId: "pool-a", + modelId: "gpt-5.6-sol", + })).rejects.toThrow("Selected Codex account is unavailable"); + expect(cfg.activeCodexAccountId).toBe("pool-a"); + }); + + test("exact account never consumes a quota recovery probe", async () => { + const originalNow = Date.now; + const now = 1_800_000_000_000; + const cfg = config(); + saveCodexAccountCredential("pool-a", { + accessToken: "pool_a_token", + refreshToken: "pool_a_refresh", + expiresAt: now + 24 * 60 * 60_000, + chatgptAccountId: "pool_a_acc", + }); + try { + Date.now = () => now; + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + resetAt: Math.floor((now + 60 * 60_000) / 1_000), + now, + modelId: "gpt-5.6-sol", + fixedAccount: true, + }); + Date.now = () => now + CODEX_QUOTA_PROBE_INTERVAL_MS; + + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + accountId: "pool-a", + modelId: "gpt-5.6-sol", + })).rejects.toBeInstanceOf(CodexAccountCooldownError); + + const ordinaryProbe = await resolveCodexAuthContext(new Headers(), cfg, "pool", { + modelId: "gpt-5.6-sol", + }); + expect(ordinaryProbe).toMatchObject({ kind: "pool", accountId: "pool-a" }); + expect(ordinaryProbe.kind === "pool" ? ordinaryProbe.probeLeaseId : undefined).toBeTruthy(); + } finally { + Date.now = originalNow; + } + }); + test("selected pool headers replace inbound main auth", () => { const headers = headersForCodexAuthContext( new Headers({ authorization: "Bearer main_token", "chatgpt-account-id": "main_acc", "openai-beta": "responses=experimental" }), @@ -824,6 +964,7 @@ describe("Codex auth context", () => { const routed = { adapter: "openai-chat", baseUrl: "https://routed.test/v1", apiKey: "routed-key" }; expect(applyCodexAuthContextToProvider(routed, ctx, "pool")).toBe(routed); + expect(applyCodexAuthContextToProvider(forwardProvider, ctx, "direct")).toBe(forwardProvider); }); test("runtime provider metadata is stripped before persistence", () => { diff --git a/tests/codex-routing.test.ts b/tests/codex-routing.test.ts index a40d0c679..be0e8d23f 100644 --- a/tests/codex-routing.test.ts +++ b/tests/codex-routing.test.ts @@ -118,6 +118,60 @@ describe("codex routing", () => { expect(computeCodexUsageScore({ weeklyPercent: 15 })).toBe(15); }); + test("exact-account failures record health without rotating the active Pool account", () => { + const transient = makeConfig({ upstreamFailoverThreshold: 1, activeCodexAccountId: "a" }); + const transientThread = "fixed-transient-thread"; + expect(resolveCodexAccountForThread(transientThread, transient)).toBe("a"); + recordCodexUpstreamOutcome(transient, "a", 503, { + fixedAccount: true, + threadId: transientThread, + modelId: "gpt-5.6-sol", + }); + expect(getCodexUpstreamHealth("a")).toMatchObject({ consecutiveFailures: 1 }); + expect(transient.activeCodexAccountId).toBe("a"); + transient.activeCodexAccountId = "b"; + clearCodexUpstreamHealthForAccount("a"); + expect(resolveCodexAccountForThread(transientThread, transient)).toBe("a"); + + clearCodexUpstreamHealth(); + const quota = makeConfig({ activeCodexAccountId: "a" }); + const quotaThread = "fixed-quota-thread"; + expect(resolveCodexAccountForThread(quotaThread, quota)).toBe("a"); + recordCodexUpstreamOutcome(quota, "a", 429, { + fixedAccount: true, + threadId: quotaThread, + retryAfter: "60", + modelId: "gpt-5.6-sol", + }); + expect(getCodexAccountCooldownUntil("a")).toBeNumber(); + expect(quota.activeCodexAccountId).toBe("a"); + quota.activeCodexAccountId = "b"; + clearCodexUpstreamHealthForAccount("a"); + expect(resolveCodexAccountForThread(quotaThread, quota)).toBe("a"); + }); + + test("exact-account credential failure clears stale Pool affinity without rotating active", () => { + const config = makeConfig({ activeCodexAccountId: "a" }); + const threadId = "fixed-credential-thread"; + expect(resolveCodexAccountForThread(threadId, config)).toBe("a"); + + recordCodexUpstreamOutcome(config, "a", 401, { + fixedAccount: true, + threadId, + modelId: "gpt-5.6-sol", + }); + + expect(isAccountNeedsReauth("a")).toBe(true); + expect(config.activeCodexAccountId).toBe("a"); + + // Simulate successful reauthentication after the user manually selected B. The old ordinary + // Pool thread must not resurrect its pre-reauth A affinity. + config.activeCodexAccountId = "b"; + clearAccountNeedsReauth("a"); + clearCodexUpstreamHealthForAccount("a"); + expect(resolveCodexAccountForThread(threadId, config)).toBe("b"); + }); + test("go and free plans use only the 30d quota window", () => { expect(computeCodexUsageScore({ weeklyPercent: 99, monthlyPercent: 12 }, "go")).toBe(12); expect(computeCodexUsageScore({ weeklyPercent: 99, monthlyPercent: 13 }, "free")).toBe(13); diff --git a/tests/router.test.ts b/tests/router.test.ts index 14a263b67..c4d992516 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { mapReasoningEffort } from "../src/reasoning-effort"; import { NoEnabledOpenAiProviderError, routeModel } from "../src/router"; -import type { OcxConfig } from "../src/types"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; describe("routeModel registry effort defaults", () => { test("allows only opted-in OAuth presets to use explicit API-key billing", () => { @@ -94,6 +94,71 @@ describe("routeModel registry effort defaults", () => { }); }); + test("routes account-qualified native models to one exact Codex account", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + codexAccountMode: "direct", + }, + }, + codexAccountNamespaces: { desktop: "@main", side: "side-account-id" }, + }; + + expect(routeModel(config, "desktop/gpt-5.6-sol")).toMatchObject({ + providerName: "openai", + modelId: "gpt-5.6-sol", + codexAccountMode: "pool", + codexAccountId: "__main__", + codexAccountNamespace: "desktop", + }); + expect(routeModel(config, "side/gpt-5.5")).toMatchObject({ + providerName: "openai", + modelId: "gpt-5.5", + codexAccountMode: "pool", + codexAccountId: "side-account-id", + codexAccountNamespace: "side", + provider: { authMode: "forward" }, + }); + expect(routeModel(config, "gpt-5.5")).toMatchObject({ + providerName: "openai", + modelId: "gpt-5.5", + codexAccountMode: "direct", + }); + expect(() => routeModel(config, "side/claude-opus-4-6")) + .toThrow("only supports native OpenAI model ids"); + }); + + test("requires an enabled canonical OpenAI forward provider before exact credential injection", () => { + const providers: OcxProviderConfig[] = [ + { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "key" }, + { adapter: "openai-chat", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, + { adapter: "openai-responses", baseUrl: "https://proxy.example.test/v1", authMode: "forward" }, + { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", disabled: true }, + ]; + + for (const openai of providers) { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + providers: { openai }, + codexAccountNamespaces: { side: "side-account-id" }, + }; + expect(() => routeModel(config, "side/gpt-5.5")).toThrow(NoEnabledOpenAiProviderError); + } + + const withoutOpenAi: OcxConfig = { + port: 10100, + defaultProvider: "openai", + providers: {}, + codexAccountNamespaces: { side: "side-account-id" }, + }; + expect(() => routeModel(withoutOpenAi, "side/gpt-5.5")).toThrow(NoEnabledOpenAiProviderError); + }); + test("routes a self-namespaced native id whole instead of stripping to the remainder", () => { const config: OcxConfig = { port: 10100, diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 41ccefff9..068779f1d 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -30,6 +30,7 @@ import { safeConfigDTO, startServer, } from "../src/server"; +import { clearRequestLogsForTests, getRequestLogEntries } from "../src/server/request-log"; import { handleManagementAPI } from "../src/server/management-api"; import type { OcxConfig } from "../src/types"; import { fakeChatGptJwt } from "./helpers/fake-chatgpt-jwt"; @@ -145,7 +146,13 @@ function unsupportedModelBody(model = POOL_RETRY_MODEL): string { type PoolRetryHarness = { config: OcxConfig; dispatches: string[]; - request: (init?: { stream?: boolean; signal?: AbortSignal }) => Promise; + request: (init?: { + stream?: boolean; + signal?: AbortSignal; + model?: string; + path?: "/v1/responses" | "/v1/responses/compact"; + callerBearer?: boolean; + }) => Promise; restoreFetch: () => void; server: ReturnType; upstream: ReturnType; @@ -170,7 +177,17 @@ async function removeTestDirBestEffort(dir: string): Promise { async function startPoolRetryHarness( reply: (accountId: string, request: Request) => Response | Promise, - options: { secondAccount?: boolean; streamMode?: "legacy-tee" | "eager-relay" } = {}, + options: { + secondAccount?: boolean; + streamMode?: "legacy-tee" | "eager-relay"; + accountMode?: "direct" | "pool"; + activeAccountId?: string; + accountNamespaces?: Record; + noVisionModels?: string[]; + visionSidecarModel?: string; + websockets?: boolean; + forwardApiKey?: string; + } = {}, ): Promise { await removeTestDirBestEffort(TEST_DIR); mkdirSync(TEST_DIR, { recursive: true }); @@ -178,6 +195,7 @@ async function startPoolRetryHarness( clearCodexUpstreamHealth(); clearThreadAccountMap(); clearAccountQuota(); + clearRequestLogsForTests(); clearAccountNeedsReauth("pool-a"); clearAccountNeedsReauth("pool-b"); // The registry is process-global and survives a harness teardown. WS-REBIND-01 @@ -204,7 +222,14 @@ async function startPoolRetryHarness( port: 0, defaultProvider: "openai", openaiProviderTierVersion: 2, - providers: poolProviders(), + providers: { + openai: { + ...canonicalDirect, + codexAccountMode: options.accountMode ?? "pool", + ...(options.noVisionModels ? { noVisionModels: options.noVisionModels } : {}), + ...(options.forwardApiKey ? { apiKey: options.forwardApiKey } : {}), + }, + }, codexAccounts: [ { id: "main", email: "main@example.test", isMain: true }, { id: "pool-a", email: "pool-a@example.test", isMain: false, chatgptAccountId: "acct-pool-a" }, @@ -212,7 +237,10 @@ async function startPoolRetryHarness( ? [{ id: "pool-b", email: "pool-b@example.test", isMain: false, chatgptAccountId: "acct-pool-b" }] : []), ], - activeCodexAccountId: "pool-a", + activeCodexAccountId: options.activeAccountId ?? "pool-a", + ...(options.accountNamespaces ? { codexAccountNamespaces: options.accountNamespaces } : {}), + ...(options.visionSidecarModel ? { visionSidecar: { model: options.visionSidecarModel } } : {}), + ...(options.websockets ? { websockets: true } : {}), ...(options.streamMode ? { streamMode: options.streamMode } : {}), } as OcxConfig; saveConfig(config); @@ -242,15 +270,21 @@ async function startPoolRetryHarness( }, server, upstream, - request: ({ stream = false, signal } = {}) => originalGlobalFetch( - new URL("/v1/responses", server.url), - { - method: "POST", - headers: { "content-type": "application/json", authorization: "Bearer inbound-token" }, - body: JSON.stringify({ model: POOL_RETRY_MODEL, input: "hello", stream }), - signal, + request: ({ + stream = false, + signal, + model = POOL_RETRY_MODEL, + path = "/v1/responses", + callerBearer = true, + } = {}) => originalGlobalFetch(new URL(path, server.url), { + method: "POST", + headers: { + "content-type": "application/json", + ...(callerBearer ? { authorization: "Bearer inbound-token" } : {}), }, - ), + body: JSON.stringify({ model, input: path.endsWith("/compact") ? [] : "hello", stream }), + signal, + }), }; } @@ -1755,6 +1789,267 @@ describe("server local API auth", () => { } }); + test.each([400, 402, 429])("exact account selector preserves the original %d without switching accounts", async status => { + const body = status === 400 + ? unsupportedModelBody() + : JSON.stringify({ error: { message: "rate limited" } }); + const harness = await startPoolRetryHarness(() => new Response(body, { + status, + headers: { + "content-type": "application/json", + "x-exact-response": "original", + ...(status === 400 ? {} : { "retry-after": "60" }), + }, + }), { + accountMode: "direct", + activeAccountId: "pool-b", + accountNamespaces: { side: "pool-a" }, + }); + try { + const response = await harness.request({ model: `side/${POOL_RETRY_MODEL}`, callerBearer: false }); + expect(response.status).toBe(status); + expect(response.headers.get("x-exact-response")).toBe("original"); + expect(await response.text()).toBe(body); + expect(harness.dispatches).toEqual(["acct-pool-a"]); + expect(loadConfig().activeCodexAccountId).toBe("pool-b"); + if (status !== 400) { + const cooldown = await (await harness.request({ model: `side/${POOL_RETRY_MODEL}`, callerBearer: false })).text(); + expect(cooldown).toContain("selector (side)"); + expect(cooldown).not.toContain("pool-a"); + expect(harness.dispatches).toEqual(["acct-pool-a"]); + } + + const entry = getRequestLogEntries().findLast(log => log.requestedModel === `side/${POOL_RETRY_MODEL}`); + expect(entry?.provider).toBe("openai-side"); + const serialized = JSON.stringify(entry); + for (const privateValue of ["pool-a", "acct-pool-a", "pool-a-token", "pool-a-refresh"]) { + expect(serialized).not.toContain(privateValue); + } + } finally { + await stopPoolRetryHarness(harness); + } + }); + + test("inline vision sidecar preserves an exact account binding", async () => { + const caption = "caption from the exact account"; + const imageBytes = "ZXhhY3QtYWNjb3VudC1pbWFnZQ=="; + const upstreamBodies: string[] = []; + const authorizationHeaders: Array = []; + const harness = await startPoolRetryHarness(async (_accountId, request) => { + authorizationHeaders.push(request.headers.get("authorization")); + upstreamBodies.push(await request.text()); + if (upstreamBodies.length === 1) { + return new Response([ + `data: ${JSON.stringify({ type: "response.output_text.delta", delta: caption })}`, + "", + "data: [DONE]", + "", + "", + ].join("\n"), { headers: { "content-type": "text/event-stream" } }); + } + return Response.json({ id: "exact-vision", status: "completed", output: [] }); + }, { + activeAccountId: "pool-b", + accountNamespaces: { side: "pool-a" }, + noVisionModels: [POOL_RETRY_MODEL], + }); + try { + const response = await originalGlobalFetch(new URL("/v1/responses", harness.server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: `side/${POOL_RETRY_MODEL}`, + stream: false, + input: [{ + type: "message", + role: "user", + content: [{ type: "input_image", image_url: `data:image/png;base64,${imageBytes}` }], + }], + }), + }); + + expect(response.status).toBe(200); + expect(harness.dispatches).toEqual(["acct-pool-a", "acct-pool-a"]); + expect(authorizationHeaders).toEqual(["Bearer pool-a-token", "Bearer pool-a-token"]); + expect(upstreamBodies).toHaveLength(2); + expect(upstreamBodies[0]).toContain(imageBytes); + expect(JSON.parse(upstreamBodies[0]) as { model?: string }).not.toMatchObject({ model: POOL_RETRY_MODEL }); + expect(JSON.parse(upstreamBodies[1]) as { model?: string }).toMatchObject({ model: POOL_RETRY_MODEL }); + expect(loadConfig().activeCodexAccountId).toBe("pool-b"); + } finally { + await stopPoolRetryHarness(harness); + } + }); + + test("inline vision sidecar checks exact-account cooldown using the helper model", async () => { + const sidecarModel = "gpt-5.3-codex-spark"; + const upstreamModels: string[] = []; + const harness = await startPoolRetryHarness(async (_accountId, request) => { + const body = await request.json() as { model?: string }; + upstreamModels.push(body.model ?? "missing"); + if (body.model === sidecarModel) { + return new Response([ + `data: ${JSON.stringify({ type: "response.output_text.delta", delta: "unexpected sidecar dispatch" })}`, + "", + "data: [DONE]", + "", + "", + ].join("\n"), { headers: { "content-type": "text/event-stream" } }); + } + return Response.json({ id: "exact-vision-cooldown", status: "completed", output: [] }); + }, { + activeAccountId: "pool-b", + accountNamespaces: { side: "pool-a" }, + noVisionModels: [POOL_RETRY_MODEL], + visionSidecarModel: sidecarModel, + }); + try { + const now = Date.now(); + recordCodexUpstreamOutcome(harness.config, "pool-a", 429, { + now, + resetAt: now + 60_000, + modelId: sidecarModel, + fixedAccount: true, + }); + + const response = await originalGlobalFetch(new URL("/v1/responses", harness.server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: `side/${POOL_RETRY_MODEL}`, + stream: false, + input: [{ + type: "message", + role: "user", + content: [{ type: "input_image", image_url: "data:image/png;base64,Y29vbGRvd24=" }], + }], + }), + }); + + expect(response.status).toBe(200); + expect(harness.dispatches).toEqual(["acct-pool-a"]); + expect(upstreamModels).toEqual([POOL_RETRY_MODEL]); + expect(loadConfig().activeCodexAccountId).toBe("pool-b"); + } finally { + await stopPoolRetryHarness(harness); + } + }); + + test("compact and WebSocket transports preserve an exact account binding", async () => { + const wireModels: string[] = []; + const authorizationHeaders: string[] = []; + const harness = await startPoolRetryHarness(async (_accountId, request) => { + const body = await request.json() as { model?: string }; + wireModels.push(body.model ?? "missing"); + authorizationHeaders.push(request.headers.get("authorization") ?? "missing"); + if (request.url.endsWith("/responses/compact")) { + return Response.json({ output: [] }); + } + return new Response( + 'event: response.completed\ndata: {"type":"response.completed","response":{"id":"exact-ws","status":"completed","output":[]}}\n\n', + { headers: { "content-type": "text/event-stream" } }, + ); + }, { + accountMode: "direct", + activeAccountId: "pool-b", + accountNamespaces: { side: "pool-a" }, + websockets: true, + forwardApiKey: "configured-forward-key", + }); + let ws: WebSocket | undefined; + try { + const compact = await harness.request({ + model: `side/${POOL_RETRY_MODEL}`, + path: "/v1/responses/compact", + callerBearer: false, + }); + expect(compact.status).toBe(200); + expect(authorizationHeaders).toEqual(["Bearer pool-a-token"]); + + const wsUrl = new URL("/v1/responses", harness.server.url); + wsUrl.protocol = "ws:"; + ws = new WebSocket(wsUrl); + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("exact websocket timed out")), + INTERNAL_DEADLINE_MS, + ); + ws.addEventListener("open", () => { + ws.send(JSON.stringify({ type: "response.create", model: `side/${POOL_RETRY_MODEL}`, input: "hello" })); + }, { once: true }); + ws.addEventListener("message", event => { + if (!String(event.data).includes("response.completed")) return; + clearTimeout(timer); + resolve(); + }); + ws.addEventListener("error", () => reject(new Error("exact websocket failed")), { once: true }); + }); + + expect(harness.dispatches).toEqual(["acct-pool-a", "acct-pool-a"]); + expect(authorizationHeaders).toEqual(["Bearer pool-a-token", "Bearer pool-a-token"]); + expect(wireModels).toEqual([POOL_RETRY_MODEL, POOL_RETRY_MODEL]); + expect(loadConfig().activeCodexAccountId).toBe("pool-b"); + } finally { + ws?.close(); + await stopPoolRetryHarness(harness); + } + }, { timeout: SERVER_BUDGET_MS }); + + test("WebSocket exact-account cooldown errors expose only the public selector and never dispatch another account", async () => { + const harness = await startPoolRetryHarness( + () => Response.json({ id: "unexpected-dispatch", status: "completed", output: [] }), + { + accountMode: "direct", + activeAccountId: "pool-b", + accountNamespaces: { side: "pool-a" }, + websockets: true, + }, + ); + recordCodexUpstreamOutcome(harness.config, "pool-a", 429, { + retryAfter: "60", + fixedAccount: true, + }); + const wsUrl = new URL("/v1/responses", harness.server.url); + wsUrl.protocol = "ws:"; + const ws = new WebSocket(wsUrl); + try { + const frame = await new Promise>((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("exact websocket cooldown timed out")), + INTERNAL_DEADLINE_MS, + ); + ws.addEventListener("open", () => { + ws.send(JSON.stringify({ type: "response.create", model: `side/${POOL_RETRY_MODEL}`, input: "hello" })); + }, { once: true }); + ws.addEventListener("message", event => { + const candidate = JSON.parse(String(event.data)) as Record; + if (candidate.type !== "error") return; + clearTimeout(timer); + resolve(candidate); + }); + ws.addEventListener("error", () => reject(new Error("exact websocket cooldown failed")), { once: true }); + }); + + expect(frame).toMatchObject({ + type: "error", + status: 429, + error: { type: "rate_limit_error" }, + }); + const message = String((frame.error as { message?: unknown }).message); + expect(message).toContain("selector (side)"); + expect(message).toContain("pinned to that selector"); + for (const privateValue of ["pool-a", "acct-pool-a", "pool-a-token", "pool-a@example.test"]) { + expect(message).not.toContain(privateValue); + } + expect(harness.dispatches).toEqual([]); + expect(loadConfig().activeCodexAccountId).toBe("pool-b"); + expect(getCodexUpstreamHealth("pool-b")).toBeNull(); + } finally { + ws.close(); + await stopPoolRetryHarness(harness); + } + }, { timeout: SERVER_BUDGET_MS }); + test("#584: pre-stream 429 retries once on another eligible pool account", async () => { const harness = await startPoolRetryHarness(accountId => accountId === "acct-pool-a" ? new Response(JSON.stringify({ error: { message: "rate limited" } }), { diff --git a/tests/server-search.test.ts b/tests/server-search.test.ts index a10056b03..2a1e2f680 100644 --- a/tests/server-search.test.ts +++ b/tests/server-search.test.ts @@ -8,9 +8,16 @@ import { existsSync, mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { saveCodexAccountCredential } from "../src/codex/account-store"; import { clearAccountNeedsReauth, clearAccountQuota } from "../src/codex/auth-api"; -import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../src/codex/routing"; -import { saveConfig } from "../src/config"; +import { setCodexAccountPaused } from "../src/codex/account-pause"; +import { + clearCodexUpstreamHealth, + clearThreadAccountMap, + getCodexUpstreamHealth, + recordCodexUpstreamOutcome, +} from "../src/codex/routing"; +import { loadConfig, saveConfig } from "../src/config"; import { startServer } from "../src/server"; +import { clearRequestLogsForTests, getRequestLogEntries } from "../src/server/request-log"; import type { OcxConfig } from "../src/types"; import { fakeChatGptJwt } from "./helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; @@ -31,7 +38,9 @@ beforeEach(() => { clearCodexUpstreamHealth(); clearThreadAccountMap(); clearAccountNeedsReauth("pool-a"); + clearAccountNeedsReauth("pool-b"); clearAccountQuota(); + clearRequestLogsForTests(); globalThis.fetch = originalFetch; }); @@ -46,7 +55,9 @@ afterEach(() => { clearCodexUpstreamHealth(); clearThreadAccountMap(); clearAccountNeedsReauth("pool-a"); + clearAccountNeedsReauth("pool-b"); clearAccountQuota(); + clearRequestLogsForTests(); if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); }); @@ -100,6 +111,43 @@ function forwardConfig(_baseUrl = ""): OcxConfig { } as OcxConfig; } +function exactSearchConfig(): OcxConfig { + return { + ...forwardConfig(), + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + // Exercise the exact-account override of global Direct mode. + codexAccountMode: "direct", + }, + }, + codexAccounts: [ + { id: "main", email: "main@example.test", isMain: true }, + { id: "pool-a", email: "private-a@example.test", isMain: false, chatgptAccountId: "acct-pool-a" }, + { id: "pool-b", email: "private-b@example.test", isMain: false, chatgptAccountId: "acct-pool-b" }, + ], + activeCodexAccountId: "pool-b", + codexAccountNamespaces: { side: "pool-a" }, + } as OcxConfig; +} + +function saveExactSearchCredentials(): void { + saveCodexAccountCredential("pool-a", { + accessToken: "pool-a-token", + refreshToken: "pool-a-refresh", + expiresAt: Date.now() + 3_600_000, + chatgptAccountId: "acct-pool-a", + }); + saveCodexAccountCredential("pool-b", { + accessToken: "pool-b-token", + refreshToken: "pool-b-refresh", + expiresAt: Date.now() + 3_600_000, + chatgptAccountId: "acct-pool-b", + }); +} + test("POST /v1/alpha/search relays to the ChatGPT forward provider with forwarded auth", async () => { const captured: CapturedRequest[] = []; const upstream = fakeSearchUpstream(captured); @@ -173,6 +221,137 @@ test("a routed pool account's token overrides the caller bearer on the search re } }); +test("an account-qualified search model uses that exact account and sends the bare model upstream", async () => { + const captured: CapturedRequest[] = []; + const upstream = fakeSearchUpstream(captured); + saveConfig(exactSearchConfig()); + saveExactSearchCredentials(); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/alpha/search", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: "search-session", model: "side/gpt-test" }), + }); + expect(response.status).toBe(200); + expect(captured).toHaveLength(1); + expect(captured[0].headers.get("authorization")).toBe("Bearer pool-a-token"); + expect(captured[0].headers.get("chatgpt-account-id")).toBe("acct-pool-a"); + expect(captured[0].body).toMatchObject({ id: "search-session", model: "gpt-test" }); + expect(loadConfig().activeCodexAccountId).toBe("pool-b"); + + const entry = getRequestLogEntries().findLast(candidate => candidate.model === "side/gpt-test"); + expect(entry?.provider).toBe("openai-side"); + const serialized = JSON.stringify(entry); + for (const privateValue of ["pool-a", "acct-pool-a", "pool-a-token", "private-a@example.test"]) { + expect(serialized).not.toContain(privateValue); + } + } finally { + await server.stop(true); + await upstream.stop(true); + } +}); + +test("an exact search 429 never switches to the active Pool account and reports only its public selector", async () => { + const captured: CapturedRequest[] = []; + const upstream = fakeSearchUpstream(captured, 429, { error: { message: "rate limited" } }); + saveConfig(exactSearchConfig()); + saveExactSearchCredentials(); + + const server = startServer(0); + try { + const requestExactSearch = () => fetch(new URL("/v1/alpha/search", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: "search-session", model: "side/gpt-test" }), + }); + + const first = await requestExactSearch(); + expect(first.status).toBe(429); + expect(captured.map(request => request.headers.get("chatgpt-account-id"))).toEqual(["acct-pool-a"]); + expect(loadConfig().activeCodexAccountId).toBe("pool-b"); + + const second = await requestExactSearch(); + expect(second.status).toBe(429); + const message = ((await second.json()) as { error: { message: string } }).error.message; + expect(message).toContain("selector (side)"); + expect(message).toContain("pinned to that selector"); + for (const privateValue of ["pool-a", "acct-pool-a", "private-a@example.test"]) { + expect(message).not.toContain(privateValue); + } + expect(captured).toHaveLength(1); + expect(loadConfig().activeCodexAccountId).toBe("pool-b"); + expect(getCodexUpstreamHealth("pool-b")).toBeNull(); + const entry = getRequestLogEntries().findLast(candidate => candidate.model === "side/gpt-test"); + expect(entry?.provider).toBe("openai-side"); + expect(JSON.stringify(entry)).not.toContain("pool-a"); + } finally { + await server.stop(true); + await upstream.stop(true); + } +}); + +test("an unavailable exact search account fails closed without dispatching the active Pool account", async () => { + const captured: CapturedRequest[] = []; + const upstream = fakeSearchUpstream(captured); + const config = exactSearchConfig(); + setCodexAccountPaused(config, "pool-a", true); + saveConfig(config); + saveExactSearchCredentials(); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/alpha/search", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: "search-session", model: "side/gpt-test" }), + }); + expect(response.status).toBe(401); + expect(((await response.json()) as { error: { message: string } }).error.message) + .toBe("Selected Codex account is unavailable"); + expect(captured).toHaveLength(0); + expect(loadConfig().activeCodexAccountId).toBe("pool-b"); + expect(getCodexUpstreamHealth("pool-b")).toBeNull(); + } finally { + await server.stop(true); + await upstream.stop(true); + } +}); + +test("an exact search account needing reauthentication fails closed with an actionable error", async () => { + const captured: CapturedRequest[] = []; + const upstream = fakeSearchUpstream(captured); + const config = exactSearchConfig(); + saveConfig(config); + saveExactSearchCredentials(); + recordCodexUpstreamOutcome(config, "pool-a", 401, { + fixedAccount: true, + modelId: "gpt-test", + }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/alpha/search", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: "search-session", model: "side/gpt-test" }), + }); + expect(response.status).toBe(401); + const message = ((await response.json()) as { error: { message: string } }).error.message; + expect(message).toBe("Selected Codex account needs reauthentication"); + for (const privateValue of ["pool-a", "acct-pool-a", "private-a@example.test"]) { + expect(message).not.toContain(privateValue); + } + expect(captured).toHaveLength(0); + expect(loadConfig().activeCodexAccountId).toBe("pool-b"); + expect(getCodexUpstreamHealth("pool-b")).toBeNull(); + } finally { + await server.stop(true); + await upstream.stop(true); + } +}); + test("zstd-compressed search request bodies are decoded before the relay", async () => { const captured: CapturedRequest[] = []; const upstream = fakeSearchUpstream(captured); diff --git a/tests/subagent-fallback-handle-responses.test.ts b/tests/subagent-fallback-handle-responses.test.ts index f008eca0c..79744f588 100644 --- a/tests/subagent-fallback-handle-responses.test.ts +++ b/tests/subagent-fallback-handle-responses.test.ts @@ -25,6 +25,7 @@ import { import { isModelHealthBlocked, resetSubagentModelFallbackStateForTests, + setSubagentQuotaPrimeForTests, } from "../src/codex/subagent-model-fallback"; import type { CodexAuthContext } from "../src/codex/auth-context"; import { handleResponses } from "../src/server/responses"; @@ -198,6 +199,54 @@ async function postSpawn( } describe("subagent fallback without primary auth cooldown failure", () => { + test("exact account child bypasses quota priming and fallback on an empty 503", async () => { + const now = 1_800_000_000_000; + Date.now = () => now; + installPoolCredential("pool-a", "pool_acc", now); + installPoolCredential("pool-b", "pool_b_acc", now); + const cfg = poolNativePlusRoutedConfig({ + codexAccountNamespaces: { side: "pool-a" }, + subagentModelFallback: ["xai/grok-4.5"], + }); + cfg.codexAccounts?.push({ + id: "pool-b", + email: "pool-b@example.test", + isMain: false, + chatgptAccountId: "pool_b_acc", + }); + cfg.activeCodexAccountId = "pool-b"; + updateAccountQuota("pool-b", 95, undefined, 20); + + let quotaPrimes = 0; + setSubagentQuotaPrimeForTests(async () => { quotaPrimes += 1; }); + const urls: string[] = []; + const accounts: Array = []; + const models: string[] = []; + globalThis.fetch = (async (input, init) => { + urls.push(String(input)); + const headers = new Headers(init?.headers); + accounts.push(headers.get("chatgpt-account-id")); + models.push(JSON.parse(String(init?.body))?.model ?? "missing"); + return new Response(null, { status: 503, headers: { "retry-after": "0" } }); + }) as typeof fetch; + + const response = await postSpawn(cfg, { + model: "side/gpt-5.6-sol", + input: readableAgentInput(), + stream: false, + }); + + expect(response.status).toBe(503); + const error = await response.json() as { error?: { message?: string } }; + expect(error.error?.message?.trim().length).toBeGreaterThan(0); + expect(error.error?.message?.toLowerCase()).not.toBe("unknown error"); + expect(quotaPrimes).toBe(0); + expect(urls.length).toBeGreaterThan(0); + expect(urls.every(url => url.includes("chatgpt.com/backend-api/codex"))).toBe(true); + expect(new Set(accounts)).toEqual(new Set(["pool_acc"])); + expect(new Set(models)).toEqual(new Set(["gpt-5.6-sol"])); + }); + test("cooled primary with no probe lease selects healthy routed fallback", async () => { const now = 1_800_000_000_000; Date.now = () => now; diff --git a/tests/web-search.test.ts b/tests/web-search.test.ts index 6b6ebe8f0..6727fad22 100644 --- a/tests/web-search.test.ts +++ b/tests/web-search.test.ts @@ -60,6 +60,36 @@ function parsedWithWebSearch() { } describe("web-search sidecar planning", () => { + test("canonical sidecar discovery defaults only an omitted OpenAI auth mode to forward", () => { + const canonicalWithoutAuthMode: OcxConfig = { + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + codexAccountMode: "direct", + }, + }, + }; + expect(listOpenAiForwardSidecarCandidates(canonicalWithoutAuthMode)).toMatchObject([{ + providerName: "openai", + provider: { authMode: "forward" }, + accountMode: "direct", + }]); + + for (const openai of [ + { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "key" }, + { adapter: "openai-chat", baseUrl: "https://chatgpt.com/backend-api/codex" }, + { adapter: "openai-responses", baseUrl: "https://proxy.example.test/v1" }, + ] satisfies OcxProviderConfig[]) { + expect(listOpenAiForwardSidecarCandidates({ + ...canonicalWithoutAuthMode, + providers: { openai }, + })).toEqual([]); + } + }); + test("central Direct sidecar selection never treats a proxy admission bearer as Codex auth", async () => { const cfg: OcxConfig = { port: 10100,