diff --git a/docs-site/src/content/docs/ja/reference/configuration.md b/docs-site/src/content/docs/ja/reference/configuration.md index ed1c42511..60ada5bba 100644 --- a/docs-site/src/content/docs/ja/reference/configuration.md +++ b/docs-site/src/content/docs/ja/reference/configuration.md @@ -52,7 +52,7 @@ namespaced selected id を bare id に変えます。 | `syncResumeHistory?` | `boolean` | `true` | 戻せる Codex App 履歴互換モード。opencodex は元の Codex thread metadata をバックアップし、旧 OpenAI interactive row を `opencodex` に再マッピングし、opencodex が作成した `exec` row を App に見えるソースとして一時的に昇格します。`ocx stop` / `ocx restore` はバックアップした OpenAI row を復元し、残った opencodex user thread を OpenAI に戻し、ネイティブ Codex が `config.toml` からプロキシを削除した後でも開き続けられるようにします。オフにするには `false` に設定します。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth ダッシュボードが管理する ChatGPT/Codex pool アカウント metadata。secret は `codex-accounts.json` に別途置きます。 | | `pausedCodexAccountIds?` | `string[]` | `[]` | Codex Auth で再開するまで、今後のすべての Pool 選択から除外するアカウント ID。メインを一時停止した場合は `__main__` も含みます。 | -| `codexAccountNamespaces?` | `Record` | — | 公開 model selector namespace から保存済み Codex アカウント target への任意 map。この foundation layer は map を検証・保存しますが、picker row の追加や routing の変更は行いません。 | +| `codexAccountNamespaces?` | `Record` | — | 公開 model selector namespace から保存済み Codex アカウント target への任意 map。`/` は対応するアカウントだけに routing され、この設定自体は model picker row を追加しません。 | | `activeCodexAccountId?` | `string` | — | 手動選択した pool アカウント。既存 thread affinity を消去して次のリクエストから適用し、処理中のリクエストは現在のアカウントを維持します。 | | `autoSwitchThreshold?` | `number` | `80` | 新しいセッション自動切替用の使用量百分率 threshold。既知の 5 時間、週次、30 日 quota window のうち最も高いスコアを使います。`0` なら quota 自動切替をオフにします。`quota` 戦略と `fill-first` の drain threshold にも使います。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Codex pool の新しいセッション rotation 戦略。**新しいセッションのみ**に適用され、既存 thread id は affinity を維持します。`quota`(既定)— アクティブアカウントが `autoSwitchThreshold` を超えたら既知 usage 最小を選択。`round-robin` — 適格アカウント間を smooth weighted で均等分散。`fill-first` — cooldown、使用不可、または(設定時)`autoSwitchThreshold` までアクティブアカウントを使い切り(未知 usage は強制切替しない)、安定ソート順で次へ。 | @@ -72,8 +72,11 @@ namespaced selected id を bare id に変えます。 `"@main"` です。provider と予約済み `openai` / `combo` との衝突は大文字小文字を区別せず検査され、 namespace 付き combo alias はその namespace prefix に selector を再利用できません。設定済み pool id や他の selector target も selector と再利用できません。raw account id と email は -非公開のままにし、selector を公開名として使ってください。この foundation layer では map は inert で、 -model picker entry の作成、session の固定、Pool / Direct routing の変更は行いません。 +非公開のままにし、selector を公開名として使ってください。`side/gpt-5.6-sol` のような request は、 +`openai` が Direct mode の場合でも `side` に対応するアカウントだけを使用し、上流には +`gpt-5.6-sol` を送信します。target を利用できない場合は別のアカウントへ切り替えず fail closed し、 +active Pool account も変更しません。bare native model は通常の Pool / Direct routing を維持します。 +この map 自体は model picker entry を作成しません。 `maxConcurrentThreadsPerSession` は `config.json` キーではなく `PUT /api/v2` で使う camel-case フィールドです。`ocx v2 threads ` は対応する `max_concurrent_threads_per_session` 値を Codex の diff --git a/docs-site/src/content/docs/ko/reference/configuration.md b/docs-site/src/content/docs/ko/reference/configuration.md index cb947d866..6be9743bb 100644 --- a/docs-site/src/content/docs/ko/reference/configuration.md +++ b/docs-site/src/content/docs/ko/reference/configuration.md @@ -53,7 +53,7 @@ namespaced selected id를 bare id로 바꿉니다. | `syncResumeHistory?` | `boolean` | `true` | 되돌릴 수 있는 Codex App 기록 호환 모드. opencodex가 원래 Codex thread metadata를 백업하고, 예전 OpenAI interactive row를 `opencodex`로 재매핑하며, opencodex가 만든 `exec` row를 App에 보이는 source로 잠시 승격합니다. `ocx stop` / `ocx restore`는 백업한 OpenAI row를 복원하고 남은 opencodex user thread를 OpenAI로 돌려 네이티브 Codex가 `config.toml`에서 프록시를 제거한 뒤에도 이어서 열 수 있게 합니다. 끄려면 `false`로 설정합니다. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 대시보드에서 관리하는 ChatGPT/Codex pool 계정 metadata. secret은 `codex-accounts.json`에 따로 둡니다. | | `pausedCodexAccountIds?` | `string[]` | `[]` | Codex Auth에서 재개할 때까지 이후의 모든 Pool 선택에서 제외할 계정 ID. 메인 계정을 일시 중지하면 `__main__`도 포함됩니다. | -| `codexAccountNamespaces?` | `Record` | — | 공개 model selector namespace에서 저장된 Codex 계정 target으로 연결하는 선택적 map입니다. 이 foundation layer는 map을 검증하고 저장하지만 picker row를 추가하거나 routing을 변경하지 않습니다. | +| `codexAccountNamespaces?` | `Record` | — | 공개 model selector namespace에서 저장된 Codex 계정 target으로 연결하는 선택적 map입니다. `/`은 매핑된 계정으로만 routing되며, 이 설정 자체는 model picker row를 추가하지 않습니다. | | `activeCodexAccountId?` | `string` | — | 수동으로 선택한 pool 계정. 선택 시 기존 thread affinity를 지우고 다음 요청부터 적용하며, 진행 중인 요청은 기존 계정을 유지합니다. | | `autoSwitchThreshold?` | `number` | `80` | 새 세션 자동 전환용 사용량 백분율 threshold. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`이면 quota 자동 전환을 끕니다. `quota` 전략과 `fill-first` drain threshold에도 사용됩니다. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Codex pool의 새 세션 rotation 전략. **새 세션에만** 적용되며 기존 thread id는 affinity를 유지합니다. `quota`(기본) — 활성 계정이 `autoSwitchThreshold`를 넘으면 알려진 usage가 가장 낮은 계정 선택. `round-robin` — 적격 계정 간 smooth weighted 균등 분배. `fill-first` — cooldown, 사용 불가 또는(설정 시) `autoSwitchThreshold`까지 활성 계정을 소진(알 수 없는 usage는 강제 전환하지 않음)한 뒤 안정 정렬 순으로 다음 계정. | @@ -73,8 +73,11 @@ namespaced selected id를 bare id로 바꿉니다. provider 및 예약된 `openai` / `combo` 충돌은 대소문자를 구분하지 않고 검사하며, namespace가 있는 combo alias는 selector를 namespace prefix로 재사용할 수 없습니다. 설정된 pool id와 다른 selector target도 selector로 재사용할 수 없습니다. raw account id와 email은 비공개로 -유지하고 selector를 공개 이름으로 사용하세요. 이 foundation layer에서 map은 inert하며 model picker -entry 생성, session 고정, Pool / Direct routing 변경을 수행하지 않습니다. +유지하고 selector를 공개 이름으로 사용하세요. `side/gpt-5.6-sol` 같은 요청은 `openai`가 Direct +mode여도 `side`에 매핑된 계정만 사용하고 업스트림에는 `gpt-5.6-sol`을 보냅니다. target을 사용할 수 +없으면 다른 계정으로 전환하지 않고 fail closed하며 active Pool account도 변경하지 않습니다. bare +native model은 기존 Pool / Direct routing을 유지합니다. 이 map 자체는 model picker entry를 만들지 +않습니다. `maxConcurrentThreadsPerSession`은 `config.json` 키가 아니라 `PUT /api/v2`에서 쓰는 camel-case 필드입니다. `ocx v2 threads `은 대응하는 `max_concurrent_threads_per_session` 값을 Codex의 diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 0050fa718..4e5d53d6d 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -58,7 +58,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility mode. opencodex backs up original Codex thread metadata, remaps old OpenAI interactive rows to `opencodex`, and temporarily promotes opencodex-created `exec` rows to an app-visible source. `ocx stop` / `ocx restore` restore backed-up OpenAI rows and eject remaining opencodex user threads to OpenAI so native Codex can resume them after the proxy is removed from `config.toml`. Set `false` to opt out. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by the Codex Auth dashboard. Secrets live separately in `codex-accounts.json`. | | `pausedCodexAccountIds?` | `string[]` | `[]` | Accounts excluded from every future Pool selection until resumed in Codex Auth. Includes the main `__main__` account when paused. | -| `codexAccountNamespaces?` | `Record` | — | Optional public model-selector namespace → stored Codex account target map. This foundation layer validates and persists the map but does not add picker rows or change routing. | +| `codexAccountNamespaces?` | `Record` | — | Optional public model-selector namespace → stored Codex account target map. `/` routes using exactly the mapped account; this setting does not add model-picker rows. | | `activeCodexAccountId?` | `string` | — | Manually selected Pool account. Selection clears existing thread affinity and applies to the next request; in-flight requests keep their captured account. | | `autoSwitchThreshold?` | `number` | `80` | Usage percent threshold for new-session auto-switching. The score uses the hottest known 5h, weekly, or 30d quota window. Set `0` to disable quota auto-switching. Used by the `quota` strategy and as the drain threshold for `fill-first`. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | New-session rotation strategy for the Codex pool. Applies to **new sessions only**; existing thread ids keep affinity. `quota` — today's default: pick the lowest known usage when the active account crosses `autoSwitchThreshold`. `round-robin` — even spread across eligible accounts via smooth weighted selection. `fill-first` — keep the active account until it cools down, becomes unusable, or crosses `autoSwitchThreshold` when set (unknown usage does not force a switch), then advance to the next eligible account in stable sorted order. | @@ -79,8 +79,11 @@ names are rejected. Each value is either a valid pool-account id (never the inte `"@main"` for the Codex Desktop account. Provider and reserved `openai` / `combo` collisions are checked 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. In this foundation layer the map is -inert: it does not create model-picker entries, pin sessions, or alter Pool or Direct routing. +account ids and emails private—the selector is the public name. A request such as +`side/gpt-5.6-sol` uses only the account mapped by `side`, even when `openai` is in Direct mode, and +sends `gpt-5.6-sol` upstream. It fails closed instead of switching accounts and does not change the +active Pool account. Bare native models retain normal Pool/Direct routing. The map itself does not +create model-picker entries. `maxConcurrentThreadsPerSession` is the camel-case field used by `PUT /api/v2`, not a `config.json` key. `ocx v2 threads ` persists the corresponding diff --git a/docs-site/src/content/docs/ru/reference/configuration.md b/docs-site/src/content/docs/ru/reference/configuration.md index 93e58f6df..af8a788d5 100644 --- a/docs-site/src/content/docs/ru/reference/configuration.md +++ b/docs-site/src/content/docs/ru/reference/configuration.md @@ -57,7 +57,7 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е | `syncResumeHistory?` | `boolean` | `true` | Обратимый режим совместимости истории Codex App. opencodex резервирует исходные метаданные потоков Codex, переназначает старые интерактивные строки OpenAI на `opencodex` и временно повышает созданные opencodex строки `exec` до видимого в приложении источника. `ocx stop` / `ocx restore` восстанавливают зарезервированные строки OpenAI и возвращают оставшиеся пользовательские потоки opencodex обратно к OpenAI, чтобы нативный Codex мог возобновлять их после удаления прокси из `config.toml`. Установите `false`, чтобы отказаться. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Метаданные аккаунтов пула ChatGPT/Codex, управляемые дашбордом Codex Auth. Секреты хранятся отдельно в `codex-accounts.json`. | | `pausedCodexAccountIds?` | `string[]` | `[]` | ID аккаунтов, исключённых из всех будущих выборов Pool до возобновления в Codex Auth. При паузе основного аккаунта включает `__main__`. | -| `codexAccountNamespaces?` | `Record` | — | Необязательная map публичного namespace селектора модели на сохранённую цель аккаунта Codex. Этот foundation layer проверяет и сохраняет map, но не добавляет строки picker и не меняет routing. | +| `codexAccountNamespaces?` | `Record` | — | Необязательное сопоставление публичного namespace селектора модели с сохранённым аккаунтом Codex. `/` направляется только в сопоставленный аккаунт; сама настройка не добавляет строки в model picker. | | `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт пула. Выбор очищает существующие привязки потоков и действует со следующего запроса; выполняющиеся запросы сохраняют захваченный аккаунт. | | `autoSwitchThreshold?` | `number` | `80` | Порог процента использования для автопереключения новых сессий. Оценка использует самое «горячее» из известных окон квоты — 5-часовое, недельное или 30-дневное. Установите `0`, чтобы отключить автопереключение по квоте. Используется стратегией `quota` и как порог исчерпания для `fill-first`. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия ротации новых сессий для пула Codex. Применяется **только к новым сессиям**; существующие id потоков сохраняют affinity. `quota` (по умолчанию) — выбор наименьшего известного usage, когда активный аккаунт превышает `autoSwitchThreshold`. `round-robin` — равномерное распределение между подходящими аккаунтами через smooth weighted selection. `fill-first` — использовать активный аккаунт до cooldown, недоступности или (если задано) `autoSwitchThreshold` (неизвестный usage не принуждает к переключению), затем переход к следующему подходящему аккаунту в стабильном отсортированном порядке. | @@ -77,9 +77,11 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е либо `"@main"` для аккаунта Codex Desktop. Коллизии с provider и зарезервированными `openai` / `combo` проверяются без учёта регистра; namespace-префикс namespaced combo alias не может повторять селектор. Настроенные id пула и цели других селекторов также нельзя повторно использовать как селектор. Сохраняйте -raw id аккаунтов и email приватными, а селектор используйте как публичное имя. -В этом foundation layer map инертна: она не создаёт записи model picker, не закрепляет сессии и не меняет -маршрутизацию Pool / Direct. +raw id аккаунтов и email приватными, а селектор используйте как публичное имя. Запрос вроде +`side/gpt-5.6-sol` использует только аккаунт, сопоставленный с `side`, даже когда `openai` работает в режиме +Direct, и отправляет вышестоящей стороне `gpt-5.6-sol`. Если целевой аккаунт недоступен, запрос завершается +ошибкой без переключения на другой аккаунт и без изменения active Pool account. Голые нативные модели +сохраняют обычную маршрутизацию Pool / Direct. Само сопоставление не создаёт записи model picker. `maxConcurrentThreadsPerSession` — это camelCase-поле, используемое `PUT /api/v2`, а не ключ `config.json`. `ocx v2 threads ` сохраняет соответствующее значение diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index 17c2249bb..d7a20e02b 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -51,7 +51,7 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `syncResumeHistory?` | `boolean` | `true` | 可逆的 Codex App 历史兼容模式。opencodex 会备份原始 Codex thread metadata,把旧 OpenAI interactive row 重映射到 `opencodex`,并暂时把 opencodex 创建的 `exec` row 提升成 App 可见 source。`ocx stop` / `ocx restore` 会恢复已备份的 OpenAI row,并把剩余 opencodex user thread 转回 OpenAI,使原生 Codex 在从 `config.toml` 移除代理后仍能继续这些 thread。设为 `false` 可退出该模式。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 仪表盘管理的 ChatGPT/Codex pool account metadata。secret 单独存放在 `codex-accounts.json`。 | | `pausedCodexAccountIds?` | `string[]` | `[]` | 在 Codex Auth 中恢复前,不参与任何后续 Pool 选择的账号 ID。暂停主账号时也包含 `__main__`。 | -| `codexAccountNamespaces?` | `Record` | — | 可选的公开 model selector namespace 到已保存 Codex account target 的映射。此 foundation layer 只验证并持久化该映射,不会添加 picker row 或改变 routing。 | +| `codexAccountNamespaces?` | `Record` | — | 可选的公开 model selector namespace 到已保存 Codex account target 的映射。`/` 只会路由到映射账号;此设置本身不会添加 model picker row。 | | `activeCodexAccountId?` | `string` | — | 手动选择的 pool account。选择时清除已有 thread affinity,并从下一次请求开始生效;进行中的请求保留原账号。 | | `autoSwitchThreshold?` | `number` | `80` | 新 session 自动切换的 usage 百分比 threshold。分数取已知 5 小时、周或 30 天 quota window 中最高的一项。设为 `0` 可禁用 quota 自动切换。`quota` 策略和 `fill-first` 的耗尽 threshold 都会用到。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Codex pool 的新 session 轮换策略。仅适用于**新 session**;已有 thread id 保留 affinity。`quota`(默认):活跃账号超过 `autoSwitchThreshold` 时选已知 usage 最低者。`round-robin`:在合格账号间平滑加权均分。`fill-first`:持续使用活跃账号,直到 cooldown、不可用或(如已设置)超过 `autoSwitchThreshold`(未知 usage 不会强制切换),再按稳定排序进入下一个合格账号。 | @@ -70,8 +70,10 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 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 才是公开名称。在此 foundation layer 中, -该映射是 inert 的:不会创建 model picker entry、固定 session,也不会改变 Pool / Direct routing。 +与 email 应保持私密,selector 才是公开名称。像 `side/gpt-5.6-sol` 这样的请求即使在 `openai` 处于 +Direct mode 时也只使用 `side` 映射的账号,并向上游发送 `gpt-5.6-sol`。target 不可用时,请求会 +fail closed,不会切换到其他账号,也不会改变 active Pool account。bare native model 保持原有的 +Pool / Direct routing。此映射本身不会创建 model picker entry。 `maxConcurrentThreadsPerSession` 是 `PUT /api/v2` 使用的 camel-case 字段,不是 `config.json` key。 `ocx v2 threads ` 会把对应的 `max_concurrent_threads_per_session` 值写入 Codex 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 5757d28cd..152da238c 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, @@ -1021,9 +1022,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 fabbc6ad8..cb8f290b2 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), @@ -1777,7 +1810,7 @@ async function handleResponsesInner( || logCtx.terminalHttpStatus === 402 ? (httpStatusOverride ?? logCtx.terminalHttpStatus) : undefined; - if (quotaFailureMessage !== undefined) { + if (!isFixedCodexAccount(authCtx) && quotaFailureMessage !== undefined) { recordSubagentQuotaFailureForThreadSpawn( req.headers, subagentQuotaFailureModel, @@ -1848,7 +1881,7 @@ async function handleResponsesInner( || logCtx.terminalHttpStatus === 402 ? (httpStatusOverride ?? logCtx.terminalHttpStatus) : undefined; - if (quotaFailureMessage !== undefined) { + if (!isFixedCodexAccount(authCtx) && quotaFailureMessage !== undefined) { recordSubagentQuotaFailureForThreadSpawn( req.headers, subagentQuotaFailureModel, @@ -2451,15 +2484,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 de61b78b5..cd33c29b2 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 { @@ -175,6 +176,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", @@ -242,7 +300,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", @@ -269,10 +327,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" }), @@ -818,6 +958,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,