diff --git a/devlog/_plan/260912_cache_lane/000_plan.md b/devlog/_plan/260912_cache_lane/000_plan.md new file mode 100644 index 0000000000..ac31af2b2d --- /dev/null +++ b/devlog/_plan/260912_cache_lane/000_plan.md @@ -0,0 +1,23 @@ +# Cache lane roadmap + +Three independent fixes address optional helper admission, final OpenCode Go conversation affinity, and explicitly enabled Claude instruction stabilization. Hermes cache observations are investigated separately: missing inbound identity is not proof of proxy loss, and a shared prefix is not a conversation. + +Satisfy-spec HOTL, triggered by the authorized cache lane assignment. Scope: PRs #4118/#4050/#4052 and issue #3433. No local tests of any size, build/typecheck/install, service changes, merges, closures, releases, workflow or permission changes. Commits and --no-verify pushes plus ordinary PR creation are authorized. Existing tool/account scope only; no user-set time/token/agent cap. Main implements; inherited-model subagents review. Native architect selection is unavailable; supported independent design review records that limitation. + +Verification: git diff --check for textual integrity; independent source review; GitHub hosted Cross-platform CI at each independent final PR tip. Local product checks are NOT RUN. Source/applicability checks do not prove runtime behavior. Stop after concrete dispositions, final hosted evidence and durable handoff; field evidence or review/access gaps remain explicit, never a false fix. Tool gate denial is reported without bypass. Two failed independent reviewer contexts return work to main; implementation remains main-owned. + +Existing layout: src/server (wire bridges), src/claude (translator), src/providers (Go transport), tests/{responses,providers,claude-integration,codex-integration}, structure (contracts), docs-site (user guidance). Reuse these owners; no new framework or runtime abstraction. + +Work phases, each a full P-A-B-C-D cycle: +- roadmap: docs only; lock all following plans. +- claim: 010, independent dev PR for #4118. +- affinity: 020, independent dev PR for #4050; prerequisite request-lane allocator is already on dev. +- prefix: 030, independent dev PR for #4052 with an actual default-off configuration boundary. +- hermes: 040, independent contract evidence for #3433, no invented identity. +- verify: 050, inspect hosted results, repair confirmed scoped failures in added cycles, hand off exact heads. + +The implementation order is a work ledger, not a false PR dependency. No native stack requested. Each independent PR is its own final tip. A repair that depends on a delivered implementation may be a child layer. + +Source inventory and raw latest GitHub evidence stay in .tmp/cache-handoff/. Public source PRs are the provenance; measurements are author-reported and are not reproduced here. Unpublished security notes stay in scratch. Source ownership updates accompany each actual patch. + +Design disposition: accept CACHE-D01 through D05. D04 uses the existing Claude configuration argument as its single control; no separate conflicting translator option. D05 covers underscore session_id and hyphenated session/thread pair separately. Native architect role not selected; inherited supported subagent performed actual design review. diff --git a/devlog/_plan/260912_cache_lane/010_claim.md b/devlog/_plan/260912_cache_lane/010_claim.md new file mode 100644 index 0000000000..afb97b03b1 --- /dev/null +++ b/devlog/_plan/260912_cache_lane/010_claim.md @@ -0,0 +1,28 @@ +# Claim deferral + +Prerequisite: roadmap; origin/dev baseline. Independent PR. Carry source #4118 at fc8c03833e9ffd0f2bfd30f5ef7de19425c87645, preserving author trailers. + +- MODIFY `docs-site/src/content/docs/fr/reference/proxy-formats.md` +- MODIFY `docs-site/src/content/docs/ja/reference/proxy-formats.md` +- MODIFY `docs-site/src/content/docs/ko/reference/proxy-formats.md` +- MODIFY `docs-site/src/content/docs/reference/proxy-formats.md` +- MODIFY `docs-site/src/content/docs/ru/reference/proxy-formats.md` +- MODIFY `docs-site/src/content/docs/tr/reference/proxy-formats.md` +- MODIFY `docs-site/src/content/docs/zh-cn/reference/proxy-formats.md` +- MODIFY `docs-site/src/content/docs/zh-tw/reference/proxy-formats.md` +- MODIFY `src/server/chat-completions.ts` +- MODIFY `src/server/responses/core.ts` +- MODIFY `src/vision/plan.ts` +- MODIFY `src/web-search/index.ts` +- MODIFY `structure/providers/openai-tiers.md` +- MODIFY `tests/codex-integration/bearer-admission-routed-provider.test.ts` +- MODIFY `tests/vision/vision-cache.test.ts` +- MODIFY `tests/web-search/web-search.test.ts` + +Before: caller-auth noncanonical Chat eagerly claims stored main; helper admission does not share all terminal/routed/search exclusions. After: only non-caller-auth keeps early enrichment; carry `allowStoredOpenAiSidecarAuth` privately, then claim before reading main only when a canonical Direct helper candidate is actually needed. Snapshot stays separate from primary/retry credentials. Share routed-vision eligibility and tool-choice exclusions. Preserve loopback hostname/listener fields. + +Activation: held keyless Cursor request without helper leaves main request count zero and profile switch succeeds; Direct helper carries main only to helper wire; Pool/exact account and excluded tool choices retain behavior. Auth review required. + +Exact executable delta is the public diff at https://github.com/lidge-jun/opencodex/pull/4118.diff captured locally in .tmp/cache-handoff/pr-4118.diff; git apply --check exited 0 on baseline. Read and adapt source context before application. No source deletion. Add concise current-contract references to all mapped source ownership docs, with canonical details in structure/data-planes/inbound-compat.md and structure/providers/openai-tiers.md (claim) or structure/transports/responses.md (affinity). + +C: git diff --check plus independent review; local tests NOT RUN. Runtime acceptance deferred to final hosted tip CI. D records implementation and pending remote evidence, not test success. diff --git a/devlog/_plan/260912_cache_lane/020_affinity.md b/devlog/_plan/260912_cache_lane/020_affinity.md new file mode 100644 index 0000000000..8ccf8fc363 --- /dev/null +++ b/devlog/_plan/260912_cache_lane/020_affinity.md @@ -0,0 +1,16 @@ +# Final Go affinity + +Prerequisite: roadmap; origin/dev baseline. Independent PR. Carry source #4050 at e5c2411f7b35c6265aacce19f66f13eace544579, preserving author trailers. + +- MODIFY `docs-site/src/content/docs/guides/providers.md` +- MODIFY `src/server/claude-messages.ts` +- MODIFY `src/server/responses/core.ts` +- MODIFY `tests/providers/opencode-go-session-header.test.ts` + +Before: preliminary Claude route injects Go identity into replay headers. After: derive validated lane with explicit session > Go header > valid Claude metadata > original request allocation, carry `claudeGoAffinity` in HandleResponsesOptions through combo recursion and consume only at final Go normalization. Never synthesize shared system hash identity or leak Go-only headers to non-Go. + +Activation: existing two-wire/random/failover matrix gains metadata, explicit-header precedence, malformed/shared identity and independent sessionless controls; operator override wins. No public option or serialization: private in-memory options, recursion spreads options, final transport consumes. + +Exact executable delta is the public diff at https://github.com/lidge-jun/opencodex/pull/4050.diff captured locally in .tmp/cache-handoff/pr-4050.diff; git apply --check exited 0 on baseline. Read and adapt source context before application. No source deletion. Add concise current-contract references to all mapped source ownership docs, with canonical details in structure/data-planes/inbound-compat.md and structure/providers/openai-tiers.md (claim) or structure/transports/responses.md (affinity). + +C: git diff --check plus independent review; local tests NOT RUN. Runtime acceptance deferred to final hosted tip CI. D records implementation and pending remote evidence, not test success. diff --git a/devlog/_plan/260912_cache_lane/030_prefix.md b/devlog/_plan/260912_cache_lane/030_prefix.md new file mode 100644 index 0000000000..fead938ce8 --- /dev/null +++ b/devlog/_plan/260912_cache_lane/030_prefix.md @@ -0,0 +1,13 @@ +# Explicit Claude prefix stabilization + +Prerequisite roadmap; independent dev PR. Reimplement #4052 at 43def4039ba60039df9a2a91fb6352b91ba74d70; preserve Warexpor and Cursor Agent credit. Do not copy binary paper or unverified measurements. + +NEW src/claude/inbound-cache-stabilize.ts: adopt source helper's complete trailing exact total_tokens/two TaskCreate matchers and fenced-range parser, including unclosed fence through EOF. Source full text is in .tmp/cache-handoff/pr-4052.diff. MODIFY src/claude/inbound.ts: read `cc?.stabilizePromptCache === true` from the existing Claude config parameter, defaulting stabilization off, relocate only when true, append latest dynamic notice as user input, use stabilized instructions for opted-in Desktop cache key; preserve original systemParts hashing otherwise. + +MODIFY src/types/config.ts OcxClaudeCodeConfig: add `stabilizePromptCache?: boolean` with default false and role-change warning. Serialization/deserialization: existing config JSON save/load retains the boolean; no new wire option; malformed non-true values do not activate. KEEP src/server/claude-messages.ts existing three-argument translation call, which already passes config.claudeCode. Never use unconditional true or infer opt-in from metadata, endpoint or text. Configuration is operator-owned and opt-in applies to translated Messages traffic; native passthrough stays unchanged. + +NEW tests/claude-integration/claude-inbound-cache-stabilize.test.ts: adopt translator/helper controls; replace source-phrase assertion with real handler outbound capture proving default/unset/false retain exact suffix and original key, true relocates, fences preserve content, both TaskCreate shapes peel, metadata session key stays stable. MODIFY scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json to register new file. Add save/load and malformed-value control where existing config fixture permits. + +MODIFY docs-site/src/content/docs/guides/claude-code.md and applicable translated pages: document default-off claudeCode.stabilizePromptCache, changed message role/key scope and no hit-rate guarantee. MODIFY structure/data-planes/inbound-compat.md/config.md and other mapped source-owner docs with canonical contract references. No GUI control, new dependency, automatic user config edit or cache-key-to-session synthesis. + +C: textual diff/source review only locally; all product tests NOT RUN. Hosted tests must observe actual outbound instructions/input/key, not phrase presence. Independent reviewer must confirm that earlier #4052 HTTP activation blocker is resolved. D publishes exact implementation with remote evidence pending until verification phase. diff --git a/devlog/_plan/260912_cache_lane/040_hermes.md b/devlog/_plan/260912_cache_lane/040_hermes.md new file mode 100644 index 0000000000..cc75d79180 --- /dev/null +++ b/devlog/_plan/260912_cache_lane/040_hermes.md @@ -0,0 +1,9 @@ +# Hermes identity boundary + +Prerequisite roadmap; independent path from Claude changes. Latest issue #3433 comment 5556427205 and controlled sample 5551855276 establish no measured inbound identity, not a dropped value. Preserve issue OPEN disposition. + +MODIFY tests/responses/chat-completions-endpoint.test.ts or a registered adjacent contract file: use the existing real Chat handler + mocked Responses upstream. Send synthetic session A on two growth turns and session B on a fresh turn; cross body prompt_cache_key present/absent with session_id present/absent. Assert captured outbound session_id and body key are exactly caller supplied; absent remains absent; shared key is not converted into session_id. Use fixture identity distinct from raw personal data, and compare at actual adapter fetch boundary. Existing src/chat/inbound.ts copies prompt_cache_key; Chat FORWARD_HEADERS and openai-responses adapter forward session_id. No runtime mutation unless this controlled contract reveals a specific defect. + +MODIFY canonical inbound contract docs to distinguish stable client conversation identity, request-scoped lane and prompt prefix. Durable scratch evidence names public comment URLs, actual test command coverage and limitations. Real Hermes same-conversation/fresh-session identifier and outbound capture from its running client are unavailable unless provided by existing public evidence; synthetic regression proves transport contract only. Do not claim actual client identity was observed, cache hits improved or #3433 solved. + +C hosted final tip executes the contract; local suite NOT RUN. D records exactly what is proven and remaining controlled live-client comparison. diff --git a/devlog/_plan/260912_cache_lane/050_verify.md b/devlog/_plan/260912_cache_lane/050_verify.md new file mode 100644 index 0000000000..8b174b37b5 --- /dev/null +++ b/devlog/_plan/260912_cache_lane/050_verify.md @@ -0,0 +1,7 @@ +# Hosted verification and delivery + +Prerequisites: independent implementation PRs. NEW durable .tmp/cache-handoff/050_handoff.md records actual worktree, branches/PR URLs/full head SHA, source dispositions/credits, cycle receipts, remaining acceptance and reviews. Capture gh pr view/checks and gh run view JSON at each final independent tip; ordinary manual children only for real correction dependencies. No native membership mutation. + +No product source changes planned here. If CI exposes a scoped defect, append a numbered repair plan and full PABCD cycle before implementation, then verify new exact head. Hosted workflow definition determines jobs actually executed; skipped/cancelled runs are never passes. No automatic workflow cancellation or protection edit. Local tests/build/typecheck/install remain NOT RUN. Only source/diff checks may be wrapped in cxc receipt and must retain their true label. + +C: final head matches hosted run headSha; successful required jobs and skipped jobs recorded individually. D: finish handoff with source review gaps and Hermes field residual, no merge/issue closure. Parent decides integration. diff --git a/docs-site/src/content/docs/fr/reference/proxy-formats.md b/docs-site/src/content/docs/fr/reference/proxy-formats.md index 7cc7d977e4..04637428e1 100644 --- a/docs-site/src/content/docs/fr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/fr/reference/proxy-formats.md @@ -332,6 +332,8 @@ Les requêtes Responses et Chat acceptent une clé du proxy dans l’en-tête d Une route Cursor sans clé et sans OAuth peut utiliser ce bearer distinct de l’appelant, mais jamais un secret du proxy ni l’authentification ChatGPT main ajoutée automatiquement. La sélection Combo/policy et les réécritures effectives shadow/thread-spawn ne transmettent pas les identifiants bruts de l’appelant aux nouvelles cibles. Le routage OpenAI canonique peut restaurer l’unique bearer de l’appelant qui n’est pas une clé du proxy après un changement de route interne uniquement si son JWT contient un claim de compte ChatGPT et si tout en-tête de compte explicite correspond à ce claim. La transmission de l’authentification de l’appelant aux sidecars OpenAI facultatifs exige un unique JWT et un `chatgpt-account-id` explicite et correspondant. Les bearers opaques ne sont pas restaurés lors des changements de route, même avec un en-tête de compte explicite. Dans les autres cas, la cible finale doit disposer de son propre identifiant configuré, OAuth ou stocké ; sinon, la requête échoue localement. Un simple marqueur thread-spawn sans changement de route ne supprime pas les identifiants. +Pour une requête Chat vers Cursor sans clé configurée, l’enrichissement facultatif par l’authentification main stockée est différé jusqu’à ce qu’un auxiliaire OpenAI soit réellement prévu et qu’un candidat Direct canonique soit disponible. Une requête Cursor indépendante ne réserve donc pas native main par cette voie et ne retarde pas le changement de profil. Les identifiants auxiliaires respectent les protections de démarrage et de changement de profil et restent séparés du bearer Cursor. Les auxiliaires Pool ou associés à un compte précis conservent leur sélection de compte. + Le replay Claude ne conserve l’authentification main que dans un snapshot en mémoire dont le turn a acquis la propriété, et ne la reconstruit que pour une route ChatGPT canonique finale. :::caution diff --git a/docs-site/src/content/docs/ja/reference/proxy-formats.md b/docs-site/src/content/docs/ja/reference/proxy-formats.md index a04596512a..7f875af2f5 100644 --- a/docs-site/src/content/docs/ja/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ja/reference/proxy-formats.md @@ -249,6 +249,8 @@ Responses 系列と Chat のリクエストは、専用ヘッダーまたは Bea キーがなく OAuth を使用しない Cursor ルートは、別途指定された呼び出し元 bearer を使用できますが、プロキシ secret や自動補完された ChatGPT main 認証は使用しません。Combo/policy の選択と実際の shadow/thread-spawn ルート変更では、呼び出し元の生の認証情報を新しい対象へ渡しません。正規の OpenAI ルーティングでは、JWT に ChatGPT アカウントの claim が含まれ、明示的なアカウントヘッダーがある場合はその claim と一致するときに限り、内部ルート変更後にプロキシキーではない呼び出し元の単一 bearer を復元できます。 オプションの OpenAI sidecar に呼び出し元の認証を転送するには、単一の JWT とそれに一致する明示的な `chatgpt-account-id` が必要です。Opaque bearer は、明示的なアカウントヘッダーがあっても、ルート変更をまたいで復元されません。 それ以外の最終対象には自身の設定済み・OAuth・保存済み認証情報が必要で、なければローカルで失敗します。ルート変更のない thread-spawn マーカーだけでは認証情報を削除しません。 +設定済みキーのない Cursor への Chat リクエストでは、保存済み main 認証による任意の補完を、OpenAI 補助呼び出しが実際に計画され、canonical Direct の候補が利用可能になるまで延期します。無関係な Cursor リクエストはこの経路で native main を占有せず、プロファイル切り替えを遅らせません。補助認証は起動時と切り替え時の保護に従い、Cursor bearer とは分離されます。Pool およびアカウント指定の補助呼び出しは既存のアカウント選択を維持します。 + Claude replay は、その turn が所有権を確保した main 認証だけをメモリ内 snapshot に保持し、最終対象が正規の ChatGPT ルートである場合にのみ復元します。 :::caution diff --git a/docs-site/src/content/docs/ko/reference/proxy-formats.md b/docs-site/src/content/docs/ko/reference/proxy-formats.md index fbabaf0cb3..a537d77a73 100644 --- a/docs-site/src/content/docs/ko/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ko/reference/proxy-formats.md @@ -314,6 +314,8 @@ Responses 계열과 Chat 요청은 전용 헤더 또는 Bearer 필드의 프록 키가 없고 OAuth를 쓰지 않는 Cursor 경로는 별도의 호출자 bearer를 사용할 수 있지만, 프록시 secret이나 자동으로 보충한 ChatGPT main 인증은 사용할 수 없습니다. Combo/policy 선택과 실제 shadow/thread-spawn 경로 변경은 호출자의 원본 자격 증명을 새 대상으로 넘기지 않습니다. 정규 OpenAI 라우팅은 JWT에 ChatGPT 계정 claim이 포함되어 있고 명시적 계정 헤더가 있으면 그 claim과 일치하는 경우에만, 내부 경로 변경 후 프록시 키가 아닌 호출자의 단일 bearer를 복원할 수 있습니다. 선택적 OpenAI sidecar에 호출자 인증을 전달하려면 단일 JWT와 이에 일치하는 명시적 `chatgpt-account-id`가 필요합니다. Opaque bearer는 명시적 계정 헤더가 있어도 경로 변경을 거쳐 복원되지 않습니다. 그 외의 최종 대상에는 자체 설정·OAuth·저장 자격 증명이 필요하며, 없으면 로컬에서 실패합니다. thread-spawn 표지만 있고 경로가 바뀌지 않으면 자격 증명을 제거하지 않습니다. +설정된 키가 없고 OAuth를 쓰지 않는 Cursor Chat 요청의 선택적 저장 main 인증 보강은 실제 OpenAI 보조 호출이 계획되고 canonical Direct 대상이 있을 때까지 미룹니다. 무관한 Cursor 요청은 이 과정에서 native main을 점유하지 않아 프로필 전환을 지연시키지 않습니다. 보조 호출 인증은 시작·전환 소유권 차단을 따르며 Cursor bearer와 분리됩니다. Pool 및 계정을 지정한 보조 호출은 기존 계정 선택을 유지합니다. + Claude replay는 해당 turn이 소유권을 확보한 main 인증만 메모리 snapshot으로 유지하며, 최종 대상이 정규 ChatGPT 경로일 때만 복원합니다. :::caution diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index ed856803ad..8975c944cf 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -487,6 +487,8 @@ Responses-family and Chat requests accept a proxy key in the dedicated header or A keyless, non-OAuth Cursor route may use that separate caller bearer, but never a proxy secret or automatic ChatGPT-main enrichment. Combo/policy selection and actual shadow/thread-spawn rewrites do not transfer raw caller credentials to new targets. Canonical OpenAI routing can restore the caller’s single non-proxy bearer after an internal route change only when its JWT carries a ChatGPT account claim and any explicit account header matches that claim. Forwarding caller authentication to optional OpenAI sidecars requires a single JWT and a matching explicit `chatgpt-account-id`. Opaque bearers are not restored across route changes, even with an explicit account header. Otherwise, the final target needs its own configured, OAuth, or stored credential; otherwise it fails locally. A thread-spawn marker alone does not strip credentials. +Chat's optional stored-main enrichment for a keyless Cursor request is deferred until an OpenAI helper is actually planned and a canonical Direct candidate is available. An unrelated Cursor request does not acquire a native-main claim through this enrichment, so it does not delay profile switching. Helper credentials still obey startup and switch fences and remain separate from the Cursor bearer. Pool and account-qualified helpers retain their existing account selection. + Claude replay retains main auth only as a turn-claimed in-memory snapshot and reconstructs it only for a final canonical ChatGPT route. :::caution diff --git a/docs-site/src/content/docs/ru/reference/proxy-formats.md b/docs-site/src/content/docs/ru/reference/proxy-formats.md index d87a28c08f..04934d0c50 100644 --- a/docs-site/src/content/docs/ru/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ru/reference/proxy-formats.md @@ -316,6 +316,8 @@ conversation. Маршрут Cursor без ключа и без OAuth может использовать отдельный bearer вызывающей стороны, но не секрет прокси и не автоматически добавленную аутентификацию ChatGPT main. Выбор Combo/policy и фактические изменения маршрута shadow/thread-spawn не передают исходные учётные данные вызывающей стороны новым целям. Каноническая маршрутизация OpenAI может восстановить единственный bearer вызывающей стороны, не являющийся ключом прокси, после внутреннего изменения маршрута только если его JWT содержит claim учётной записи ChatGPT и любой явно указанный заголовок учётной записи соответствует этому claim. Для передачи аутентификации вызывающей стороны в необязательные sidecar OpenAI необходимы один JWT и явно указанный соответствующий `chatgpt-account-id`. Непрозрачные bearer не восстанавливаются после изменения маршрута даже при наличии явно указанного заголовка учётной записи. В остальных случаях конечной цели нужны собственные настроенные, OAuth или сохранённые учётные данные; иначе запрос завершается локальной ошибкой. Один маркер thread-spawn без изменения маршрута не удаляет учётные данные. +Для Chat-запроса к Cursor без настроенного ключа необязательное дополнение сохранённой аутентификацией main откладывается до фактического планирования помощника OpenAI с доступным каноническим кандидатом Direct. Независимый запрос Cursor не удерживает native main через этот механизм и не задерживает смену профиля. Учётные данные помощника подчиняются ограничениям запуска и смены профиля и остаются отдельно от bearer Cursor. Помощники Pool и помощники с явно указанным аккаунтом сохраняют прежний выбор аккаунта. + Claude replay сохраняет аутентификацию main только в снимке в памяти, владение которым обеспечено текущим turn, и восстанавливает её только для конечного канонического маршрута ChatGPT. :::caution diff --git a/docs-site/src/content/docs/tr/reference/proxy-formats.md b/docs-site/src/content/docs/tr/reference/proxy-formats.md index fed53c1afb..84ad0cf5e1 100644 --- a/docs-site/src/content/docs/tr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/tr/reference/proxy-formats.md @@ -341,6 +341,8 @@ Responses ailesi ve Chat istekleri, özel başlıkta veya Bearer alanında bir p Anahtarı olmayan ve OAuth kullanmayan bir Cursor rotası, çağıranın ayrı bearer’ını kullanabilir; proxy sırrını veya otomatik eklenen ChatGPT main kimlik bilgisini kullanamaz. Combo/policy seçimi ve gerçekleşen shadow/thread-spawn rota değişiklikleri, çağıranın ham kimlik bilgilerini yeni hedeflere aktarmaz. Kanonik OpenAI yönlendirmesi, dahili rota değişikliğinden sonra çağıranın proxy anahtarı olmayan tek bearer’ını yalnızca JWT’si bir ChatGPT hesap claim’i içeriyorsa ve açıkça belirtilmiş herhangi bir hesap başlığı bu claim ile eşleşiyorsa geri yükleyebilir. Çağıranın kimlik doğrulamasını isteğe bağlı OpenAI sidecar’larına iletmek için tek bir JWT ve onunla eşleşen, açıkça belirtilmiş bir `chatgpt-account-id` gerekir. Opaque bearer’lar, açıkça belirtilmiş bir hesap başlığı olsa bile rota değişikliklerinden sonra geri yüklenmez. Diğer durumlarda son hedefin kendi yapılandırılmış, OAuth veya kayıtlı kimlik bilgisi bulunmalıdır; aksi hâlde istek yerel olarak başarısız olur. Rota değişmeden yalnızca thread-spawn işaretinin bulunması kimlik bilgilerini kaldırmaz. +Yapılandırılmış anahtarı olmayan Cursor'a gönderilen Chat isteğinde, kayıtlı main kimliğiyle isteğe bağlı tamamlama ancak bir OpenAI yardımcısı gerçekten planlandığında ve kanonik Direct adayı bulunduğunda yapılır. İlgisiz bir Cursor isteği bu yoldan native main üzerinde sahiplik almaz ve profil geçişini geciktirmez. Yardımcı kimlik bilgileri başlangıç ve profil geçişi korumalarına uyar ve Cursor bearer'ından ayrı tutulur. Pool ve belirli hesaba yönelen yardımcılar mevcut hesap seçimini korur. + Claude replay, main kimlik bilgisini yalnızca ilgili turn tarafından sahipliği alınmış bir bellek snapshot’ında tutar ve yalnızca son hedef kanonik bir ChatGPT rotasıysa geri yükler. :::caution diff --git a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md index 8e3d4d8714..9f83638d3c 100644 --- a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md @@ -262,6 +262,8 @@ Responses 系列和 Chat 请求接受专用标头或 Bearer 字段中的代理 没有密钥且不使用 OAuth 的 Cursor 路由可以使用调用方单独提供的 bearer,但不能使用代理 secret 或自动补充的 ChatGPT main 凭据。Combo/policy 选择以及实际发生的 shadow/thread-spawn 路由改写不会将调用方的原始凭据传递给新目标。规范 OpenAI 路由仅在 JWT 包含 ChatGPT 账户声明,且任何显式账户标头都与该声明匹配时,才可在内部路由变更后恢复调用方的单个非代理密钥 bearer。 向可选的 OpenAI sidecar 转发调用方认证时,需要单个 JWT 以及显式提供且匹配的 `chatgpt-account-id`。即使提供了显式账户标头,opaque bearer 也不会跨路由变更恢复。 除此之外,最终目标必须拥有自己的配置、OAuth 或已保存凭据,否则请求会在本地失败。只有 thread-spawn 标记而没有路由变化时,不会移除凭据。 +对于未配置密钥的 Cursor Chat 请求,只有实际计划了 OpenAI 辅助调用且存在规范的 Direct 候选时,才会补充已存储的 main 身份验证。无关的 Cursor 请求不会通过此流程占用 native main,因此不会延迟配置文件切换。辅助调用凭据仍受启动和切换保护限制,并与 Cursor bearer 分离。Pool 及明确指定账号的辅助调用保留现有账号选择。 + Claude replay 只会以当前 turn 已取得所有权的内存 snapshot 保留 main 凭据,并且仅在最终目标为规范 ChatGPT 路由时恢复它。 :::caution diff --git a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md index 361a5892be..483319ac58 100644 --- a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md @@ -239,6 +239,8 @@ Responses 系列和 Chat 請求接受專用標頭或 Bearer 欄位中的代理 沒有金鑰且不使用 OAuth 的 Cursor 路由可以使用呼叫端另外提供的 bearer,但不能使用代理 secret 或自動補入的 ChatGPT main 憑證。Combo/policy 選擇及實際發生的 shadow/thread-spawn 路由改寫不會將呼叫端的原始憑證傳遞給新目標。正規 OpenAI 路由僅在 JWT 包含 ChatGPT 帳戶宣告,且任何明確提供的帳戶標頭都與該宣告相符時,才可在內部路由變更後還原呼叫端的單一非代理金鑰 bearer。 將呼叫端驗證轉送至選用的 OpenAI sidecar 時,需要單一 JWT,以及明確提供且相符的 `chatgpt-account-id`。即使明確提供了帳戶標頭,opaque bearer 也不會跨路由變更還原。 除此之外,最終目標必須擁有自己的設定、OAuth 或已儲存憑證,否則請求會在本機失敗。只有 thread-spawn 標記而沒有路由變更時,不會移除憑證。 +對於未設定金鑰的 Cursor Chat 請求,只有實際規劃了 OpenAI 輔助呼叫且存在標準的 Direct 候選時,才會補充已儲存的 main 驗證。無關的 Cursor 請求不會透過此流程佔用 native main,因此不會延遲設定檔切換。輔助呼叫憑證仍受啟動和切換保護限制,並與 Cursor bearer 分離。Pool 及明確指定帳號的輔助呼叫保留現有帳號選擇。 + Claude replay 只會以目前 turn 已取得所有權的記憶體 snapshot 保留 main 憑證,並且僅在最終目標為正規 ChatGPT 路由時還原它。 :::caution diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 44409a660c..3234dfed00 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -260,9 +260,9 @@ async function handleChatCompletionsWithBudget( const value = req.headers.get(name); if (value) headers.set(name, value); } - // A noncanonical caller-auth route can use stored main auth only through a sidecar snapshot. - // Later shadow/thread rewrites strip primary credentials at the actual Responses boundary. - if (!callerAuthorizationRoute || (settledRoute && !isCanonicalOpenAiForwardProvider(settledRoute.provider))) { + // Existing primary enrichment stays on non-caller-auth routes. Caller-auth routes defer + // optional stored sidecar auth until the final helper plan actually needs it. + if (!callerAuthorizationRoute) { // This enrichment is optional for routed/non-main providers. If native main // is fenced, omit it and let auth-context reject only a final physical-main // selection while healthy pool/provider routes continue. @@ -317,6 +317,8 @@ async function handleChatCompletionsWithBudget( }; const upstream = await handleResponses(internalReq, config, logCtx, { openAiSidecarAuth, + allowStoredOpenAiSidecarAuth: !!(callerAuthorizationRoute && settledRoute + && !isCanonicalOpenAiForwardProvider(settledRoute.provider)), nativeCallerAuth, callerDirectAuth, ...(logIds?.turnAdmissionLease ? { turnAdmissionLease: logIds.turnAdmissionLease } : {}), diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 1f78570eae..424dc7ba8f 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -293,6 +293,7 @@ import { upstreamErrorMessageFromPayload, } from "../../lib/errors"; import type { AdmissionLease } from "../../lib/admission"; +import { tryClaimNativeMainProfileForTurn as tryClaimStoredSidecarMainProfile } from "../../codex/native-main-admission"; import { prepareEffortNormalization, supportedLadderFor } from "../effort-policy"; import { isThreadSpawnRequest } from "../effort-policy"; import { @@ -1738,6 +1739,8 @@ export interface HandleResponsesOptions { trustedClaudeMainAuth?: { authorization: string; chatgptAccountId?: string }; /** Sidecar-only auth captured before route changes; null means no usable original pair. */ openAiSidecarAuth?: ExplicitOpenAiCallerAuth | null; + /** Internal Chat bridge permission to obtain claimed stored auth only for a final Direct sidecar. */ + allowStoredOpenAiSidecarAuth?: boolean; /** Original caller-owned native pair; separate from any claimed sidecar enrichment. */ nativeCallerAuth?: ExplicitOpenAiCallerAuth | null; /** Caller Direct credential under Direct\'s own predicate; restored only for the canonical OpenAI final route. */ @@ -4564,21 +4567,42 @@ async function handleResponsesInner( } let openAiSidecar: ResolvedOpenAiForwardSidecar | undefined; - const needsOpenAiVision = shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed); - const needsOpenAiSearch = shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough); + const visionDescribeTerminal = options.visionDescribeTerminal === true; + const routedCompaction = parsed._compactionRequest === true + && !isCanonicalOpenAiForwardProvider(route.provider); + const needsOpenAiVision = !visionDescribeTerminal + && shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed); + const needsOpenAiSearch = !routedCompaction && !adapter.runTurn + && shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough); if (needsOpenAiVision || needsOpenAiSearch) { try { + const candidates = listOpenAiForwardSidecarCandidates(config); + let sidecarAuth = options.openAiSidecarAuth; + if (!sidecarAuth && options.allowStoredOpenAiSidecarAuth === true + && route.codexAccountId === undefined + && candidates.some(candidate => candidate.accountMode === "direct") + && tryClaimStoredSidecarMainProfile(options.turnAdmissionLease)) { + // Request-local helper authority only: never promote this pair to caller, primary, + // or retry credentials. Claim before reading so profile switches remain fenced. + try { + const { getMainAccountToken } = await import("../../codex/main-account"); + const token = getMainAccountToken(); + if (token) sidecarAuth = captureExplicitOpenAiCallerAuth(new Headers({ + authorization: `Bearer ${token.accessToken}`, "chatgpt-account-id": token.chatgptAccountId, + }), config); + } catch { /* stored enrichment is optional */ } + } // Preserve explicit OpenAI helper auth across route changes without returning it to // primary-provider headers or alternate-main retry. The resolver revalidates scope. const sidecarHeaders = new Headers(req.headers); sidecarHeaders.delete("authorization"); sidecarHeaders.delete("chatgpt-account-id"); - if (options.openAiSidecarAuth) { - sidecarHeaders.set("authorization", options.openAiSidecarAuth.authorization); - sidecarHeaders.set("chatgpt-account-id", options.openAiSidecarAuth.chatgptAccountId); + if (sidecarAuth) { + sidecarHeaders.set("authorization", sidecarAuth.authorization); + sidecarHeaders.set("chatgpt-account-id", sidecarAuth.chatgptAccountId); } openAiSidecar = await resolveFirstUsableOpenAiSidecar( - listOpenAiForwardSidecarCandidates(config), + candidates, sidecarHeaders, config, { @@ -4613,7 +4637,6 @@ async function handleResponsesInner( // call must never plan another describe. The flag arrives from the Chat // surface (whose bridge rebuilds headers) or as the raw header for native // Responses callers. Marked + text-only routed model → strip, depth cap 1. - const visionDescribeTerminal = options.visionDescribeTerminal === true; const visionPlan = visionDescribeTerminal ? undefined : planVisionSidecar(config, route.provider, route.modelId, parsed, openAiSidecar, { @@ -4675,8 +4698,6 @@ async function handleResponsesInner( // `compaction_trigger` item — only the canonical ChatGPT backend speaks that // contract. An API-key gateway would receive the trigger, answer with an ordinary // message, and leave Codex fataling on a missing compaction item (#422). - const routedCompaction = parsed._compactionRequest === true - && !isCanonicalOpenAiForwardProvider(route.provider); const commitReasoningReplayServingRoute = (): void => { commitReasoningReplayServingIdentity(parsed._reasoningReplayScope); }; diff --git a/src/vision/plan.ts b/src/vision/plan.ts index 408c88533d..fb2aae6d49 100644 --- a/src/vision/plan.ts +++ b/src/vision/plan.ts @@ -92,6 +92,20 @@ function messagesHaveImage(parsed: OcxParsedRequest): boolean { carriesImages(m.role) && Array.isArray(m.content) && (m.content as OcxContentPart[]).some(p => p.type === "image")); } +/** Shared by auth admission and planning so a routed describer never borrows OpenAI auth. */ +function usableRoutedVisionModel(config: OcxConfig): string | undefined { + const cfg = config.visionSidecar; + if (cfg?.backend !== "routed") return undefined; + const routedModel = cfg.model; + const sep = routedModel ? routedModel.indexOf("/") : -1; + if (!routedModel || sep <= 0) return undefined; + const targetProvider = routedModel.slice(0, sep); + const targetId = routedModel.slice(sep + 1); + const targetProviderConfig = config.providers?.[targetProvider]; + return modelAcceptsImageInput(config, { provider: targetProvider, id: targetId }) !== false + && !(targetProviderConfig && isModelTextOnly(targetProviderConfig, targetId)) ? routedModel : undefined; +} + export function shouldResolveOpenAiVisionSidecar( config: OcxConfig, provider: OcxProviderConfig, @@ -101,6 +115,7 @@ export function shouldResolveOpenAiVisionSidecar( if (!isModelTextOnly(provider, modelId) || !messagesHaveImage(parsed)) return false; const cfg = config.visionSidecar ?? {}; if (cfg.enabled === false) return false; + if (usableRoutedVisionModel(config)) return false; return resolveVisionBackend(cfg.backend, findAnthropicVisionProvider(config)) === "openai"; } @@ -140,43 +155,31 @@ export function planVisionSidecar( // fence: the target must not be provably blind, and must not itself be a // model this planner would re-enter for (belt; the terminal marker on the // loopback request is the braces). - if (cfg.backend === "routed") { - const routedModel = cfg.model; - const sep = routedModel ? routedModel.indexOf("/") : -1; - if (routedModel && sep > 0) { - const targetProvider = routedModel.slice(0, sep); - const targetId = routedModel.slice(sep + 1); - const targetProviderConfig = config.providers?.[targetProvider]; - const targetVisible = modelAcceptsImageInput(config, { provider: targetProvider, id: targetId }) !== false - && !(targetProviderConfig && isModelTextOnly(targetProviderConfig, targetId)); - if (targetVisible) { - return { - backend: "routed", - routedModel, - routedConfig: { - port: config.port, - ...(config.apiKeys ? { apiKeys: config.apiKeys } : {}), - // The self-fetch has to honor the unauthenticated loopback listener AND, with no - // listener, the bind address — so BOTH fields the destination resolver reads have to - // survive the narrowing or it silently resolves to the wrong local socket (#4236). - ...(config.hostname ? { hostname: config.hostname } : {}), - ...(config.unauthenticatedLoopbackListener - ? { unauthenticatedLoopbackListener: config.unauthenticatedLoopbackListener } - : {}), - }, - settings: { - model: routedModel, - reasoning: DEFAULT_REASONING, - timeoutMs: resolveVisionTimeoutMs(cfg.timeoutMs), - }, - maxDescriptionsPerTurn: resolveMaxDescriptionsPerTurn(cfg.maxDescriptionsPerTurn), - }; - } - } - // Misconfigured routed backend (bare id, unknown provider, or provably - // blind target): fall through to the legacy default order below rather - // than dispatching a describe that cannot work. + const routedModel = usableRoutedVisionModel(config); + if (routedModel) { + return { + backend: "routed", + routedModel, + routedConfig: { + port: config.port, + ...(config.apiKeys ? { apiKeys: config.apiKeys } : {}), + // The self-fetch has to honor the unauthenticated loopback listener AND, with no + // listener, the bind address — so BOTH fields the destination resolver reads have to + // survive the narrowing or it silently resolves to the wrong local socket (#4236). + ...(config.hostname ? { hostname: config.hostname } : {}), + ...(config.unauthenticatedLoopbackListener + ? { unauthenticatedLoopbackListener: config.unauthenticatedLoopbackListener } + : {}), + }, + settings: { + model: routedModel, + reasoning: DEFAULT_REASONING, + timeoutMs: resolveVisionTimeoutMs(cfg.timeoutMs), + }, + maxDescriptionsPerTurn: resolveMaxDescriptionsPerTurn(cfg.maxDescriptionsPerTurn), + }; } + // A non-dispatchable routed configuration keeps the legacy backend fallback below. const anthropicSidecar = findAnthropicVisionProvider(config); const backend = resolveVisionBackend(cfg.backend, anthropicSidecar); diff --git a/src/web-search/index.ts b/src/web-search/index.ts index e6bb7e3dbe..8b73b95645 100644 --- a/src/web-search/index.ts +++ b/src/web-search/index.ts @@ -201,6 +201,7 @@ export function shouldResolveOpenAiWebSearchSidecar( isPassthrough: boolean, ): boolean { if (!parsed._webSearch || isPassthrough) return false; + if (!toolChoiceToolPredicate(parsed.options.toolChoice)(buildWebSearchTool())) return false; const cfg = config.webSearchSidecar ?? {}; return cfg.enabled !== false && resolveSidecarBackend(cfg.backend) === "openai"; } diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index dd98190184..6ea36b9428 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -52,3 +52,7 @@ request when a node carries both. Codex's own deferred tool catalog emits exactl so the schema is not something a user can fix from configuration (issue #2673). > Decision record: [ADR-0093](../decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md) + +Chat helper admission in `src/server/responses/core.ts` follows the +[deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper +claims stored main, after terminal vision, routed vision and search exclusions. diff --git a/structure/catalog.md b/structure/catalog.md index 174e506470..91d7734848 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -264,3 +264,7 @@ provider wire mapping; unpinned native requests retain their existing pass-throu Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +Chat helper admission in `src/server/responses/core.ts` follows the +[deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper +claims stored main, after terminal vision, routed vision and search exclusions. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index f8e3691f38..4c86504a08 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -75,3 +75,7 @@ away from. Resolution stays a pure function of (env, platform, home) so the Wind testable on any host: stubbing `process.platform` does not propagate to `os.platform()` under Bun. > Decision record: [ADR-0046](../decisions/ADR-0046-claude-desktop-config-library-resolution.md) + +Chat helper admission in `src/server/responses/core.ts` follows the +[deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper +claims stored main, after terminal vision, routed vision and search exclusions. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 270e6c38de..952f6ab06f 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -69,3 +69,7 @@ injects summary generation into a request, and config validation rejects a deliv conflicts with `modelSupportsReasoningSummaries: false` for the same model. > Decision record: [ADR-0045](../decisions/ADR-0045-standalone-images.md) + +Chat helper admission in `src/server/responses/core.ts` follows the +[deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper +claims stored main, after terminal vision, routed vision and search exclusions. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 7c90c2f74d..5fbc419557 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -89,3 +89,7 @@ copies an authoritative catalog context window into `limit.context` and a nonemp reasoning ladder into `thinking.effortOptions`. Missing capabilities stay absent instead of falling back to OpenCodex guesses, and the integration does not write the removed `thinking.effort` / `defaultEffort` fields because MCode owns the active effort per session. + +Chat helper admission in `src/server/responses/core.ts` follows the +[deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper +claims stored main, after terminal vision, routed vision and search exclusions. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index f4c3e27d6c..8da70bdb1d 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -511,3 +511,7 @@ converge the Codex catalog once and return its disposition. The Models UI owns a picker data resource so failure cannot erase the ordinary model inventory; Apply publishes through the resource's generation fence, and Most used reads usage only on explicit Apply. Stored mode survives availability drift, while complete/native custom orders await explicit replacement. + +Chat helper admission in `src/server/responses/core.ts` follows the +[deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper +claims stored main, after terminal vision, routed vision and search exclusions. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index dd5f58e345..e4d2b41958 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -132,3 +132,7 @@ Binary detection decodes only the supplied buffer view; malformed UTF-8 can itse so the flag does not identify the peer responsible for corruption. Existing diagnostic files are not rewritten. Audio devices, WebRTC media negotiation, captions and spoken handoff delivery remain client responsibilities. + +Chat helper admission in `src/server/responses/core.ts` follows the +[deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper +claims stored main, after terminal vision, routed vision and search exclusions. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 9320132a04..b8d1d56675 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -51,6 +51,15 @@ alternate-main retry authentication; the original caller's native snapshot is se Optional Chat/Claude stored-main enrichment still requires the native-main turn claim. +Chat's noncanonical caller-auth branch passes only an internal permission to resolve stored +sidecar auth later. Final helper planning checks vision terminal/routed-backend and search +tool-choice/compaction/runTurn exclusions before requesting a canonical Direct candidate. +Only that concrete need, without an existing explicit snapshot or exact-account selection, +can acquire a startup- and drain-fenced native-main claim and read the stored token. The pair +stays local to sidecar headers; caller, primary, and retry authority are unchanged. Unrelated +Cursor turns therefore leave native main switchable while their upstream remains active. +Pool and exact-account sidecars continue through their existing account-selection path. + The two routes also keep separate request-compatibility contracts. The canonical ChatGPT Codex forward destination removes public `prompt_cache_options` because that backend rejects the field before inference; `prompt_cache_key` remains supported. `openai-apikey` and noncanonical/custom diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index d9155185fc..22105b20c8 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -49,3 +49,7 @@ malformed, gapped, oversized, contradictory, failed, or incomplete streams stay - **Authentication:** `Authorization: Bearer ` + `X-XAI-Token-Auth: xai-grok-cli`. No cookies required. - **Safety & Idempotency:** Managed via `src/grok/reset-coupon-ledger.ts` using UUIDv4 operation tracking before upstream dispatch to prevent duplicate consumption during network flakes. - **Surfaces:** `ocx account grok-reset-coupons` in the terminal, and the dashboard at Providers > xAI Grok > Accounts, where each OAuth row carries a ticket badge with its remaining count and opens a redemption dialog (`gui/src/hooks/useGrokResetCoupons.ts`, `gui/src/components/provider-workspace/GrokResetCoupons.tsx`). The dashboard reads one `GET /api/grok/reset-coupons` per account with at most three in flight, always sends an explicit `tokenId` and a client-minted `operationId`, and treats redemption truth as the settled `code` rather than HTTP 200 — a replayed *failure* returns 200 with `replayed: true`. After a request times out it issues no further consume call, because a redemption whose ledger record is still `open` re-executes. + +Chat helper admission in `src/server/responses/core.ts` follows the +[deferred stored-main contract](openai-tiers.md): only a needed Direct OpenAI helper +claims stored main, after terminal vision, routed vision and search exclusions. diff --git a/structure/runtime.md b/structure/runtime.md index 49a5fb6483..495745051e 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -188,3 +188,7 @@ not an authentication or entitlement decision. Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +Chat helper admission in `src/server/responses/core.ts` follows the +[deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper +claims stored main, after terminal vision, routed vision and search exclusions. diff --git a/structure/subagents.md b/structure/subagents.md index d9e6eaf1d2..3f00e96302 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -198,3 +198,7 @@ Native Codex advertisements still follow display priority; private guidance rank Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +Chat helper admission in `src/server/responses/core.ts` follows the +[deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper +claims stored main, after terminal vision, routed vision and search exclusions. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 5acafbf63b..66f80525ad 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -57,3 +57,7 @@ does not cover ordinary requests, streaming, retries, or per-hop redirect review Caller-owned `provider.fetch` executors are also deferred: they receive literal/config checks and redirect blocking, but cannot inherit DNS classification or peer pinning without a verified-peer executor contract. Main-request migration must not treat that branch as fixed-transport equivalent. + +Chat helper admission in `src/server/responses/core.ts` follows the +[deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper +claims stored main, after terminal vision, routed vision and search exclusions. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 2d7bd85db6..178211347d 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -507,3 +507,7 @@ deprecated, sunset, decommissioned, or no longer available). An unrelated applic not retried. > Decision record: [ADR-0071](../decisions/ADR-0071-combo-streaming-commit-boundary.md) + +Chat helper admission in `src/server/responses/core.ts` follows the +[deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper +claims stored main, after terminal vision, routed vision and search exclusions. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 42d3442e99..f90334d5b4 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -189,3 +189,7 @@ WebSocket clients observe the same canonical lifecycle. `ws-bridge.ts` preserves upstream `failed` and `incomplete` status values in the final WebSocket frame rather than always emitting `response.completed`. If the response status is `failed`, a `response.failed` frame is sent; otherwise `response.completed` carries through the original status. + +Chat helper admission in `src/server/responses/core.ts` follows the +[deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper +claims stored main, after terminal vision, routed vision and search exclusions. diff --git a/tests/codex-integration/bearer-admission-routed-provider.test.ts b/tests/codex-integration/bearer-admission-routed-provider.test.ts index 5b87318626..277f790bd5 100644 --- a/tests/codex-integration/bearer-admission-routed-provider.test.ts +++ b/tests/codex-integration/bearer-admission-routed-provider.test.ts @@ -20,6 +20,8 @@ import { ownedServiceHomeInspection } from "../helpers/owned-service-home-inspec import { removeTreeWithRetry } from "../helpers/remove-tree"; import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; import { resetVisionDescriptionCache } from "../../src/vision"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; /** * Issue #2132: bearer admission must not require a stored ChatGPT credential. @@ -116,6 +118,7 @@ function cursorForwardConfig(baseUrl: string, apiKey?: string): OcxConfig { async function withCursorCaptureServer( run: (baseUrl: string, capturedAuth: Array) => Promise, + beforeResponse?: () => Promise, ): Promise { const capturedAuth: Array = []; const sessions = new Set(); @@ -127,11 +130,16 @@ async function withCursorCaptureServer( server.on("stream", (stream, headers) => { const auth = headers.authorization; capturedAuth.push(typeof auth === "string" ? auth : null); - stream.respond({ - ":status": typeof auth === "string" ? 200 : 401, - "content-type": "application/connect+proto", - }); - stream.end(); + const respond = () => { + if (stream.destroyed || stream.closed) return; + stream.respond({ + ":status": typeof auth === "string" ? 200 : 401, + "content-type": "application/connect+proto", + }); + stream.end(); + }; + if (beforeResponse) void beforeResponse().then(respond).catch(error => stream.destroy(error)); + else respond(); }); await new Promise((resolve, reject) => { const onError = (error: Error) => reject(error); @@ -432,16 +440,107 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => }); }); - test.each(["owned", "fenced"])("Chat Cursor keeps stored vision auth off its primary wire (%s)", async ownership => { + test.each([ + "configured-vision-text", "default-sidecars", "no-stored-main", "disabled-vision", + "terminal-vision", "routed-vision", "anthropic-vision", "missing-openai", + "noncanonical-openai", "search-runTurn", "search-tool-choice-none", + ])("Chat Cursor leaves native main switchable without a planned OpenAI helper (%s)", async scenario => { + let signalStarted!: () => void; + const started = new Promise(resolve => { signalStarted = resolve; }); + let releaseUpstream!: () => void; + const upstreamGate = new Promise(resolve => { releaseUpstream = resolve; }); await withCursorCaptureServer(async (baseUrl, capturedAuth) => { const config = cursorForwardConfig(baseUrl); + config.openaiProviderTierVersion = 2; config.providers.cursorcustom!.noVisionModels = ["auto"]; config.providers.openai = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct", }; + config.visionSidecar = { enabled: true, backend: "openai", model: "gpt-5.6-luna" }; + if (scenario === "default-sidecars") delete config.visionSidecar; + if (scenario === "disabled-vision") config.visionSidecar.enabled = false; + if (scenario === "routed-vision") { + config.providers.gateway = { ...mixedConfig().providers.gateway!, models: ["vision-model"] }; + config.visionSidecar = { enabled: true, backend: "routed", model: "gateway/vision-model" }; + } + if (scenario === "anthropic-vision") config.visionSidecar.backend = "anthropic"; + if (scenario === "missing-openai") delete config.providers.openai; + if (scenario === "noncanonical-openai") { + // Keep the reserved OpenAI row absent; a custom forward provider is valid config + // but is not authority to inject stored ChatGPT credentials into a sidecar. + config.providers.mirror = { adapter: "openai-responses", authMode: "forward", baseUrl: "https://mirror.example.com/v1" }; + delete config.providers.openai; + } + const search = scenario.startsWith("search-"); + if (search) config.webSearchSidecar = { enabled: true, backend: "openai", model: "gpt-5.6-luna" }; + const withImage = ["disabled-vision", "terminal-vision", "routed-vision", "anthropic-vision", "missing-openai", "noncanonical-openai"].includes(scenario); + saveConfig(config); + const stored = fakeChatGptJwt({ chatgpt_account_id: "stored_main_acc", exp: Math.floor(Date.now() / 1000) + 3600 }); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: scenario === "no-stored-main" ? {} : { access_token: stored, account_id: "stored_main_acc" }, + })); + const server = await startOwnedServer(); + let settled = false; + const pending = originalFetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json", "x-opencodex-api-key": ADMISSION_SECRET, + authorization: "Bearer cursor-upstream-token", + ...(scenario === "terminal-vision" ? { "x-opencodex-vision-describe": "1" } : {}), + }, + body: JSON.stringify({ model: "cursorcustom/auto", stream: false, messages: [{ role: "user", + content: withImage ? [{ type: "text", text: "Describe this image" }, + { type: "image_url", image_url: { url: "data:image/png;base64,aGVsbG8taW1hZ2UtYnl0ZXM=" } }] : "hi", + }], ...(search ? { tools: [{ type: "web_search" }] } : {}), + ...(scenario === "search-tool-choice-none" ? { tool_choice: "none" } : {}), + }), + }).then(response => { settled = true; return response; }); + try { + await Promise.race([started, pending.then(async response => { + const result = await response.clone().json().catch(() => ({})) as { error?: { code?: string; message?: string } }; + throw new Error(`Cursor response settled before its held upstream: HTTP ${response.status}, ${result.error?.code ?? ""}: ${result.error?.message ?? ""}`); + })]); + expect(settled).toBe(false); + expect(capturedAuth).toEqual(["Bearer cursor-upstream-token"]); + expect(getNativeMainProfileRequestCount()).toBe(0); + let switches = 0; + const manager = { switch: async () => { switches += 1; return { ok: true }; } } as unknown as NativeProfileManager; + const switchUrl = new URL("http://localhost/api/native-main-profiles/switch"); + const switched = await handleNativeProfileAPI(new Request(switchUrl, { + method: "POST", body: JSON.stringify({ target: "target", confirmedStopped: true }), + }), switchUrl, config, { manager, drainTimeoutMs: 0 }); + expect(switched?.status).toBe(200); + expect(switches).toBe(1); + expect(settled).toBe(false); + expect(nativeAuth).toEqual([]); + } finally { + releaseUpstream(); + try { await (await pending).text(); } finally { await server.stop(true); } + } + expect(getNativeMainProfileRequestCount()).toBe(0); + }, async () => { signalStarted(); await upstreamGate; }); + }, SERVER_BUDGET_MS); + + test.each(["owned", "fenced", "pool", "pool-fenced"])("Chat Cursor keeps stored vision auth off its primary wire (%s)", async ownership => { + await withCursorCaptureServer(async (baseUrl, capturedAuth) => { + const config = cursorForwardConfig(baseUrl); + const pool = ownership.startsWith("pool"); + config.providers.cursorcustom!.noVisionModels = ["auto"]; + config.providers.openai = { + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", codexAccountMode: pool ? "pool" : "direct", + }; // Keep this auth fixture independent of the legacy sidecar model migration. config.visionSidecar = { enabled: true, backend: "openai", model: "gpt-5.6-luna" }; + if (pool) { + config.codexAccounts = [{ id: "sidecar-pool", label: "sidecar pool", isMain: false, chatgptAccountId: "sidecar_pool_acc" }]; + config.activeCodexAccountId = "sidecar-pool"; + config.autoSwitchThreshold = 0; + saveCodexAccountCredential("sidecar-pool", { + accessToken: "sidecar-pool-token", refreshToken: "sidecar-pool-refresh", + expiresAt: Date.now() + 3_600_000, chatgptAccountId: "sidecar_pool_acc", + }); + } saveConfig(config); const stored = fakeChatGptJwt({ chatgpt_account_id: "stored_main_acc", exp: Math.floor(Date.now() / 1000) + 3600 }); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ @@ -451,6 +550,13 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => globalThis.fetch = (async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.hostname === "chatgpt.com") { + // Pool admission can probe quota independently of the vision helper. + if (url.pathname === "/backend-api/wham/usage") return Response.json({ + rate_limit: { allowed: true, limit_reached: false, + primary_window: { used_percent: 0, reset_at: Math.floor(Date.now() / 1000) + 3600, limit_window_seconds: 18000 }, + secondary_window: { used_percent: 0, reset_at: Math.floor(Date.now() / 1000) + 86400, limit_window_seconds: 604800 }, + }, + }); const headers = new Headers(input instanceof Request ? input.headers : init?.headers); sidecar.push({ authorization: headers.get("authorization"), account: headers.get("chatgpt-account-id"), claimed: getNativeMainProfileRequestCount() > 0 }); @@ -460,11 +566,12 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => } return originalFetch(input, init); }) as typeof fetch; - const server = ownership === "owned" ? await startOwnedServer() : startServer(0, { + const fenced = ownership.endsWith("fenced"); + const server = !fenced ? await startOwnedServer() : startServer(0, { inspectNativeCodexOwnership: () => ({ ownership: "foreign", reason: "fixture owned by another service" }), }); try { - if (ownership === "fenced") expect(await waitForNativeMainStartupGate()).toMatchObject({ status: "blocked" }); + if (fenced) expect(await waitForNativeMainStartupGate()).toMatchObject({ status: "blocked" }); const response = await originalFetch(new URL("/v1/chat/completions", server.url), { method: "POST", headers: { "content-type": "application/json", "x-opencodex-api-key": ADMISSION_SECRET, @@ -477,8 +584,9 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => await response.text(); // The capture-only Cursor fixture ends without a completion frame. expect(response.status).toBe(502); - expect(sidecar).toEqual(ownership === "owned" - ? [{ authorization: `Bearer ${stored}`, account: "stored_main_acc", claimed: true }] : []); + expect(sidecar).toEqual(ownership === "fenced" ? [] + : [{ authorization: pool ? "Bearer sidecar-pool-token" : `Bearer ${stored}`, + account: pool ? "sidecar_pool_acc" : "stored_main_acc", claimed: !pool }]); expect(capturedAuth).toEqual(["Bearer cursor-upstream-token"]); } finally { await server.stop(true); diff --git a/tests/vision/vision-cache.test.ts b/tests/vision/vision-cache.test.ts index bb6323425d..6e482d4255 100644 --- a/tests/vision/vision-cache.test.ts +++ b/tests/vision/vision-cache.test.ts @@ -17,6 +17,7 @@ import { setVisionDescriptionCache, setVisionDescriptionCacheLimitsForTests, shouldResolveOpenAiVisionSidecar, + planVisionSidecar, visionDescriptionRetainedStoreSnapshot, type VisionPlan, } from "../../src/vision"; @@ -75,6 +76,20 @@ test("vision sidecar auth stays lazy for no-image and disabled branches", () => expect(shouldResolveOpenAiVisionSidecar(cfg, textOnlyProvider, "text-model", withImage)).toBe(true); }); +test("vision auth and planning agree on a routed describer and its legacy fallback", () => { + const cfg: OcxConfig = { port: 10100, defaultProvider: "routed", providers: { + routed: textOnlyProvider, sighted: { adapter: "openai-chat", baseUrl: "https://vision.test/v1", apiKey: "vision-key" }, + }, visionSidecar: { enabled: true, backend: "routed", model: "sighted/vision-model" } }; + const request = parseRequest({ model: "routed/text-model", + input: [{ type: "message", role: "user", content: [{ type: "input_image", image_url: DATA_A }] }], + }); + expect(planVisionSidecar(cfg, textOnlyProvider, "text-model", request)?.backend).toBe("routed"); + expect(shouldResolveOpenAiVisionSidecar(cfg, textOnlyProvider, "text-model", request)).toBe(false); + cfg.visionSidecar!.model = "legacy-bare-model"; + expect(shouldResolveOpenAiVisionSidecar(cfg, textOnlyProvider, "text-model", request)).toBe(true); + expect(planVisionSidecar(cfg, textOnlyProvider, "text-model", request, plan().forwardSidecar)?.backend).toBe("openai"); +}); + function parsed(parts: Array>) { return parseRequest({ model: "routed/blind", diff --git a/tests/web-search/web-search.test.ts b/tests/web-search/web-search.test.ts index 81a06febfa..e182308a0d 100644 --- a/tests/web-search/web-search.test.ts +++ b/tests/web-search/web-search.test.ts @@ -304,6 +304,18 @@ describe("web-search sidecar planning", () => { expect(shouldResolveOpenAiWebSearchSidecar(config(), parsed, false)).toBe(true); }); + test("a forbidden hosted search does not resolve OpenAI auth", () => { + for (const toolChoice of ["none", { type: "function", name: "read_file" }]) { + const parsed = parseRequest({ model: "routed/model", input: "hi", + tools: [{ type: "web_search" }, { type: "function", name: "read_file", parameters: { type: "object" } }], + tool_choice: toolChoice, + }); + expect(parsed._webSearch).toBeDefined(); + expect(shouldResolveOpenAiWebSearchSidecar(config(), parsed, false)).toBe(false); + expect(planWebSearch(config(), parsed, false, routedProvider, "model")).toBeUndefined(); + } + }); + test("parseRequest stashes hosted web_search while keeping normal tools", () => { const parsed = parsedWithWebSearch();