From aeeee32fcf1d1fa0b6c0eb3bebdd04598285278d Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:38:03 +0900 Subject: [PATCH 1/4] fix(responses): disable Spark Lite in WebSocket metadata --- .../content/docs/fr/reference/architecture.md | 7 +++ .../content/docs/ja/reference/architecture.md | 7 +++ .../content/docs/ko/reference/architecture.md | 7 +++ .../content/docs/reference/architecture.md | 6 ++ .../content/docs/ru/reference/architecture.md | 7 +++ .../content/docs/tr/reference/architecture.md | 8 ++- .../docs/zh-cn/reference/architecture.md | 5 ++ .../docs/zh-tw/reference/architecture.md | 6 ++ src/adapters/openai-responses.ts | 5 +- .../codex-metadata-integrity.test.ts | 58 +++++++++++++++++-- tests/responses/ws-upstream-reuse.test.ts | 30 ++++++++++ 11 files changed, 137 insertions(+), 9 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/architecture.md b/docs-site/src/content/docs/fr/reference/architecture.md index f197d85c11..4d8352655a 100644 --- a/docs-site/src/content/docs/fr/reference/architecture.md +++ b/docs-site/src/content/docs/fr/reference/architecture.md @@ -89,6 +89,13 @@ Par défaut, `server/index.ts` sert HTTP/SSE sur `/v1/responses`. Si Codex tente Indépendamment de ce réglage côté client, les requêtes canoniques transmises à ChatGPT avec `stream: true` à la racine peuvent utiliser le transport WebSocket en amont de Codex avec une version stable de Bun 1.4.0 ou ultérieure. La version intégrée Bun 1.3.14, les préversions et les identités de runtime impossibles à vérifier utilisent HTTP/SSE. Les réponses WS en amont qui réussissent conservent le contrat SSE en aval et contournent `tee()` au moyen d’un relais borné à lecteur unique et avide (4 MiB par trame brute/enveloppée et une file de production de 8 MiB). Le dépassement de la file ferme la connexion en amont et émet en aval un événement terminal `response.failed`, suivi de `[DONE]`. +Pour le modèle sortant final `gpt-5.3-codex-spark`, la transmission canonique à ChatGPT +désactive explicitement Responses Lite dans l’en-tête HTTP et les métadonnées natives des +trames WS, même lorsqu’un alias sélectionne Spark. Un changement d’identité Lite retire +l’ancien socket ; les requêtes admissibles suivantes ayant la même identité peuvent réutiliser +le nouveau socket. Les autres modèles et passerelles conservent leur politique Lite. +Des métadonnées natives mal formées entraînent toujours un repli HTTP, sans modifier le corps. + Le compactage du contexte Codex fonctionne avec les modèles routés. `server/responses/compact.ts` traite `POST /v1/responses/compact` en exécutant un tour interne de synthèse routé et en renvoyant un historique compacté, tandis que `responses/parser.ts` et `bridge.ts` traitent les tours de compactage distant v2 `compaction_trigger` en émettant exactement un élément de sortie synthétique `compaction`. ## Mise en cache et catalogue diff --git a/docs-site/src/content/docs/ja/reference/architecture.md b/docs-site/src/content/docs/ja/reference/architecture.md index cd2ce3db56..bd22709ff1 100644 --- a/docs-site/src/content/docs/ja/reference/architecture.md +++ b/docs-site/src/content/docs/ja/reference/architecture.md @@ -97,6 +97,13 @@ HTTP の境界は `server/index.ts` が担い、Responses データプレーン `server/index.ts` はデフォルトで `/v1/responses` を HTTP/SSE で提供します。`websockets` が `false` の状態で Codex が Responses WebSocket アップグレードを試みると、opencodex は `426 upgrade_required` を返し、Codex はそのセッションで HTTP にフォールバックします。`"websockets": true` を設定すると同じエンドポイントがアップグレードを受け入れ WebSocket ブリッジを使います。 +最終送信モデルが `gpt-5.3-codex-spark` の場合、canonical ChatGPT 転送は HTTP ヘッダーと +ネイティブ WS フレームのメタデータの両方で Responses Lite を明示的に無効にします。 +エイリアスで Spark を選択した場合も同様です。Lite の識別値が変わると古いソケットは退役し、 +以後の条件を満たす同じ識別値のリクエストは新しいソケットを再利用できます。他のモデルと +ゲートウェイの Lite ポリシーは維持されます。不正なネイティブメタデータは引き続き、 +本文を変更せずに HTTP にフォールバックします。 + Codex コンテキスト compaction はルーティングされたモデルでも動作します。`server/responses/compact.ts` は `POST /v1/responses/compact` を内部ルーティング要約ターンとして扱い、圧縮されたヒストリーを返します。 `responses/parser.ts` と `bridge.ts` は remote compaction v2 の `compaction_trigger` ターンを扱い、合成 `compaction` 出力項目を正確に 1 つ送ります。 diff --git a/docs-site/src/content/docs/ko/reference/architecture.md b/docs-site/src/content/docs/ko/reference/architecture.md index fa3056fb49..3714675b40 100644 --- a/docs-site/src/content/docs/ko/reference/architecture.md +++ b/docs-site/src/content/docs/ko/reference/architecture.md @@ -127,6 +127,13 @@ envelope를 각각 4 MiB로 제한하고 8 MiB producer queue 상한이 있는 b relay를 거칩니다. queue overflow 시 업스트림을 닫고 downstream에는 terminal `response.failed` 이벤트와 `[DONE]`을 내보냅니다. +최종 전송 모델이 `gpt-5.3-codex-spark`이면 canonical ChatGPT forward 경로는 HTTP 헤더와 +네이티브 WS 프레임 메타데이터 모두에서 Responses Lite를 명시적으로 끕니다. 별칭으로 Spark를 +선택해도 동일합니다. Lite 식별값이 바뀌면 기존 소켓은 사용을 종료하며, 이후 같은 식별값으로 +재사용 조건을 충족하는 요청은 새 소켓을 재사용할 수 있습니다. 다른 모델과 게이트웨이는 기존 +Lite 정책을 유지합니다. 네이티브 메타데이터 형식이 잘못된 경우에는 본문을 바꾸지 않고 +기존처럼 HTTP로 폴백합니다. + Codex 컨텍스트 compaction은 라우팅된 모델에서도 동작합니다. `server/responses/compact.ts`는 `POST /v1/responses/compact`를 내부 라우팅 요약 턴으로 처리해 압축된 히스토리를 반환합니다. `responses/parser.ts`와 `bridge.ts`는 remote compaction v2의 `compaction_trigger` 턴을 처리해 diff --git a/docs-site/src/content/docs/reference/architecture.md b/docs-site/src/content/docs/reference/architecture.md index e0fbc8bcba..d9028d13be 100644 --- a/docs-site/src/content/docs/reference/architecture.md +++ b/docs-site/src/content/docs/reference/architecture.md @@ -157,6 +157,12 @@ upstream WS responses keep the downstream SSE contract and bypass `tee()` throug single-reader relay (4 MiB per raw/enveloped frame and an 8 MiB producer queue). Queue overflow closes the upstream and emits a terminal downstream `response.failed` event followed by `[DONE]`. +For the final outgoing model `gpt-5.3-codex-spark`, canonical ChatGPT forwarding explicitly +disables Responses Lite in both the HTTP header and native WS frame metadata, including when +an alias selects Spark. A changed Lite identity retires the old socket; subsequent eligible +requests with the same identity can reuse the new socket. Other models and gateways keep +their existing Lite policy. Malformed native metadata still falls back to HTTP with its body unchanged. + When a provider rejects a streaming request with HTTP 413 before SSE begins, OpenCodex emits one terminal `response.failed` event with `context_length_exceeded` instead of relaying the retryable unknown status. This lets Codex stop its reconnect loop and apply its own context-compaction policy diff --git a/docs-site/src/content/docs/ru/reference/architecture.md b/docs-site/src/content/docs/ru/reference/architecture.md index c46da773c0..02d42290e4 100644 --- a/docs-site/src/content/docs/ru/reference/architecture.md +++ b/docs-site/src/content/docs/ru/reference/architecture.md @@ -151,6 +151,13 @@ loopback; настроенные записи `corsAllowOrigins` расширя `426 upgrade_required`; Codex тогда откатывается на HTTP для этой сессии. Когда установлено `"websockets": true`, та же конечная точка принимает апгрейд и использует WebSocket-мост. +Для итоговой исходящей модели `gpt-5.3-codex-spark` каноническая пересылка в ChatGPT явно +отключает Responses Lite в HTTP-заголовке и нативных метаданных WS-кадра, в том числе при +выборе Spark через псевдоним. Изменение идентичности Lite выводит старый сокет из использования; +последующие подходящие запросы с той же идентичностью могут повторно использовать новый сокет. +Другие модели и шлюзы сохраняют прежнюю политику Lite. Некорректные нативные метаданные +по-прежнему приводят к откату на HTTP без изменения тела запроса. + Compaction контекста Codex работает для маршрутизируемых моделей. `server/responses/compact.ts` обрабатывает `POST /v1/responses/compact`, выполняя внутренний маршрутизируемый ход суммаризации и возвращая сжатую историю, а `responses/parser.ts` и `bridge.ts` обрабатывают ходы diff --git a/docs-site/src/content/docs/tr/reference/architecture.md b/docs-site/src/content/docs/tr/reference/architecture.md index 24d2bbb7aa..0fc8821990 100644 --- a/docs-site/src/content/docs/tr/reference/architecture.md +++ b/docs-site/src/content/docs/tr/reference/architecture.md @@ -170,6 +170,13 @@ opencodex `426 upgrade_required` döndürür; Codex daha sonra bu oturum için HTTP'ye geri döner. `"websockets": true` ayarlandığında aynı uç nokta yükseltmeyi kabul eder ve WebSocket köprüsünü kullanır. +Son gönderilen model `gpt-5.3-codex-spark` olduğunda, kanonik ChatGPT iletimi HTTP başlığında +ve yerel WS çerçevesi meta verilerinde Responses Lite'ı açıkça kapatır; Spark bir takma adla +seçildiğinde de bu geçerlidir. Lite kimliği değişince eski soket kullanım dışı bırakılır; +aynı kimliğe sahip sonraki uygun istekler yeni soketi yeniden kullanabilir. Diğer modeller ve +ağ geçitleri mevcut Lite politikalarını korur. Bozuk yerel meta verilerde, istek gövdesi +değiştirilmeden HTTP'ye geri dönülmeye devam edilir. + Codex bağlam sıkıştırması yönlendirilen modeller için çalışır. `server/responses/compact.ts`, dahili bir yönlendirilen özetleme turu çalıştırarak ve sıkıştırılmış geçmişi döndürerek `POST /v1/responses/compact`'ı @@ -220,4 +227,3 @@ Dahili model `types.ts` içinde yer alır: `OcxParsedRequest`, `OcxContext`, `OcxProviderConfig`). İki yardımcı yaygın olarak kullanılır: `namespacedToolName()` ve `modelInList()` (`noVisionModels` / `noReasoningModels` için toleranslı `:size` etiketi eşleştirmesi). - diff --git a/docs-site/src/content/docs/zh-cn/reference/architecture.md b/docs-site/src/content/docs/zh-cn/reference/architecture.md index 94c5eb8ea0..7cf05726a0 100644 --- a/docs-site/src/content/docs/zh-cn/reference/architecture.md +++ b/docs-site/src/content/docs/zh-cn/reference/architecture.md @@ -134,6 +134,11 @@ thread affinity 位于 `codex/` 下,不会出现在管理 API 响应中。请 session 中回退到 HTTP。设置 `"websockets": true` 后,同一 endpoint 会接受 upgrade 并使用 WebSocket bridge。 +当最终发送的模型为 `gpt-5.3-codex-spark` 时,canonical ChatGPT 转发会在 HTTP 请求头和 +原生 WS 帧元数据中明确关闭 Responses Lite,通过别名选择 Spark 时也一样。Lite 标识变化时, +旧 socket 会退出使用;后续标识相同且满足复用条件的请求可以复用新 socket。其他模型和网关 +保留原有 Lite 策略。原生元数据格式不合法时,仍会回退到 HTTP,并保持请求正文不变。 + Codex context compaction 同样适用于路由模型。`server/responses/compact.ts` 处理 `POST /v1/responses/compact`,运行一次内部路由 summarization turn 并返回压缩后的历史; `responses/parser.ts` 与 `bridge.ts` 则处理 remote compaction v2 的 `compaction_trigger` turn, diff --git a/docs-site/src/content/docs/zh-tw/reference/architecture.md b/docs-site/src/content/docs/zh-tw/reference/architecture.md index daf9bc9080..967bd0d767 100644 --- a/docs-site/src/content/docs/zh-tw/reference/architecture.md +++ b/docs-site/src/content/docs/zh-tw/reference/architecture.md @@ -134,6 +134,12 @@ thread affinity 位於 `codex/` 下,不會出現在管理 API 回應中。請 session 中回退到 HTTP。設定 `"websockets": true` 後,同一 endpoint 會接受 upgrade 並使用 WebSocket bridge。 +當最終傳送的模型為 `gpt-5.3-codex-spark` 時,canonical ChatGPT 轉送會在 HTTP 請求標頭與 +原生 WS 訊框中繼資料中明確關閉 Responses Lite,透過別名選擇 Spark 時也一樣。Lite 識別值 +改變時,舊 socket 會停止使用;後續識別值相同且符合重用條件的請求可以重用新 socket。 +其他模型與閘道保留既有 Lite 政策。原生中繼資料格式不合法時,仍會退回 HTTP,並保持 +請求本文不變。 + Codex context compaction 同樣適用於路由模型。`server/responses/compact.ts` 處理 `POST /v1/responses/compact`,執行一次內部路由 summarization turn 並回傳壓縮後的歷史; `responses/parser.ts` 與 `bridge.ts` 則處理 remote compaction v2 的 `compaction_trigger` turn, diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index c4aa523ee6..599c88aa6f 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -2515,12 +2515,13 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): parsed.modelId, ); if (isCanonicalOpenAiForwardProvider(provider)) { - // Spark closes Responses Lite streams before a terminal completion. Select compatibility - // from the final wire model so aliases cannot leave the caller or a static header enabled. + // Select Spark's Lite compatibility from the final wire model, including aliases. + // Explicit false also overrides native WS metadata; deleting the header leaves it enabled. if (isPlainObject(finalBody) && finalBody.model === "gpt-5.3-codex-spark") { for (const name of Object.keys(headers)) { if (name.toLowerCase() === CODEX_RESPONSES_LITE_HEADER) delete headers[name]; } + headers[CODEX_RESPONSES_LITE_HEADER] = "false"; } const routingHeaders = new Headers(headers); applyCodexRoutingHint(routingHeaders, finalBody); diff --git a/tests/codex-integration/codex-metadata-integrity.test.ts b/tests/codex-integration/codex-metadata-integrity.test.ts index c03d03eb8c..b37cadb877 100644 --- a/tests/codex-integration/codex-metadata-integrity.test.ts +++ b/tests/codex-integration/codex-metadata-integrity.test.ts @@ -208,33 +208,76 @@ describe("Codex request transport metadata", () => { expect(new Headers(dropped.headers).get(hintHeader)).toBe("model=gpt-5.6-sol"); }); - test("canonical adapter drops Lite only for the Spark wire model", async () => { + test("canonical adapter disables Spark Lite in HTTP headers and WS metadata without mutating input", async () => { + const { prepareCodexWsRequest } = await import("../../src/server/responses/codex-ws-request"); const adapter = createResponsesPassthroughAdapter({ adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", headers: { "X-OpenAI-Internal-Codex-Responses-Lite": "true" }, }); for (const [model, incomingLite, expectedLite] of [ - ["gpt-5.3-codex-spark", "true", null], - ["gpt-5.3-codex-spark", undefined, null], + ["gpt-5.3-codex-spark", "true", "false"], + ["gpt-5.3-codex-spark", "false", "false"], + ["gpt-5.3-codex-spark", undefined, "false"], ["gpt-5.6-sol", "true", "true"], + ["gpt-5.6-sol", "false", "false"], + ["gpt-5.6-sol", undefined, "true"], ] as const) { const parsed = minimalParsed(); parsed.modelId = model; - parsed._rawBody = { model, input: [], stream: true }; + parsed._rawBody = { model, input: [], stream: true, + client_metadata: { [liteKey]: "true", other: "preserved" } }; + const before = JSON.stringify(parsed._rawBody); const incoming = new Headers(); if (incomingLite !== undefined) incoming.set(liteHeader, incomingLite); const request = await adapter.buildRequest(parsed, { headers: incoming, }); expect(new Headers(request.headers).get(liteHeader)).toBe(expectedLite); + const prepared = prepareCodexWsRequest(url, { body: request.body, headers: request.headers })!; + expect(JSON.parse(prepared.frameText).client_metadata).toEqual({ + [liteKey]: expectedLite, other: "preserved", + }); + expect(prepared.httpInit.body).toBe(request.body); + expect(JSON.stringify(parsed._rawBody)).toBe(before); + expect(incoming.get(liteHeader)).toBe(incomingLite ?? null); } const routed = minimalParsed(); routed.modelId = "spark-alias"; routed._rawBody = { model: "gpt-5.3-codex-spark", input: [], stream: true }; const request = await adapter.buildRequest(routed, { headers: new Headers({ [liteHeader]: "true" }) }); - expect(new Headers(request.headers).get(liteHeader)).toBeNull(); + expect(new Headers(request.headers).get(liteHeader)).toBe("false"); + const prepared = prepareCodexWsRequest(url, { body: request.body, headers: request.headers })!; + expect(JSON.parse(prepared.frameText).client_metadata[liteKey]).toBe("false"); + + routed.modelId = "gpt-5.3-codex-spark"; + routed._rawBody = { model: "gpt-5.6-sol", input: [], stream: true }; + const otherWireModel = await adapter.buildRequest(routed, { headers: new Headers({ [liteHeader]: "true" }) }); + expect(new Headers(otherWireModel.headers).get(liteHeader)).toBe("true"); + }); + + test("Spark disables Lite without configured headers and retains malformed-metadata HTTP fallback", async () => { + const { prepareCodexWsRequest } = await import("../../src/server/responses/codex-ws-request"); + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", + }); + for (const client_metadata of [undefined, {}, null, [], { [liteKey]: true }]) { + const parsed = minimalParsed(); + parsed._rawBody = { model: "gpt-5.3-codex-spark", input: [], stream: true, + ...(client_metadata === undefined ? {} : { client_metadata }) }; + const before = JSON.stringify(parsed._rawBody); + const request = await adapter.buildRequest(parsed, { headers: new Headers() }); + expect(new Headers(request.headers).get(liteHeader)).toBe("false"); + const prepared = prepareCodexWsRequest(url, { body: request.body, headers: request.headers }); + if (client_metadata === undefined || JSON.stringify(client_metadata) === "{}") { + expect(JSON.parse(prepared!.frameText).client_metadata).toEqual({ [liteKey]: "false" }); + } else { + expect(prepared).toBeNull(); + expect(JSON.parse(request.body).client_metadata).toEqual(client_metadata); + } + expect(JSON.stringify(parsed._rawBody)).toBe(before); + } }); test("noncanonical adapters neither forward caller Lite nor synthesize a routing hint", async () => { @@ -243,7 +286,10 @@ describe("Codex request transport metadata", () => { adapter: "openai-responses", authMode, baseUrl: "https://gateway.example/v1", headers: { [hintHeader]: "operator-owned" }, }); - const request = await adapter.buildRequest(minimalParsed(), { + const parsed = minimalParsed(); + parsed.modelId = "gpt-5.3-codex-spark"; + parsed._rawBody = { model: parsed.modelId, input: [] }; + const request = await adapter.buildRequest(parsed, { headers: new Headers({ [liteHeader]: "true", [hintHeader]: "caller-owned" }), }); expect(new Headers(request.headers).has(liteHeader)).toBe(false); diff --git a/tests/responses/ws-upstream-reuse.test.ts b/tests/responses/ws-upstream-reuse.test.ts index b957fdb317..48b720d2ea 100644 --- a/tests/responses/ws-upstream-reuse.test.ts +++ b/tests/responses/ws-upstream-reuse.test.ts @@ -3,6 +3,8 @@ import { codexWsUpstreamFetch } from "../../src/server/responses/ws-upstream"; import { runOptionalShutdownHooks } from "../../src/lib/optional-shutdown-hooks"; import { CodexWsPool, codexWsPool } from "../../src/server/responses/codex-ws-pool"; import { prepareCodexWsRequest } from "../../src/server/responses/codex-ws-request"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; const URL = "https://chatgpt.com/backend-api/codex/responses"; const realWebSocket = globalThis.WebSocket; @@ -307,3 +309,31 @@ test("a Lite mode change retires the old handshake", async () => { expect(Socket.all).toHaveLength(2); expect(Socket.all[0]!.readyState).toBe(3); }); + +test("adapter Spark Lite override retires a legacy socket and reuses the disabled identity", async () => { + const liteHeader = "x-openai-internal-codex-responses-lite"; + const liteKey = "ws_request_header_x_openai_internal_codex_responses_lite"; + const options = init(); + const rawBody = { ...JSON.parse(options.body as string), model: "gpt-5.3-codex-spark", + client_metadata: { thread_id: "fixture-thread", turn_id: "fixture-turn", [liteKey]: "true" } }; + const before = JSON.stringify(rawBody); + const adapter = withTestTranslatorBudget(createResponsesPassthroughAdapter({ + adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", + })); + const built = await adapter.buildRequest({ modelId: "spark-alias", context: { messages: [] }, + stream: true, options: {}, _rawBody: rawBody, + }, { headers: new Headers(options.headers) }); + const current = { ...options, body: built.body, headers: built.headers }; + // Keep the exact same Spark model/scope/headers; only the old delete-only Lite policy differs. + const legacyHeaders = new Headers(current.headers); + legacyHeaders.delete(liteHeader); + await drain({ ...current, headers: legacyHeaders }); + await drain(current); + await drain(current); + expect(Socket.all).toHaveLength(2); + expect(Socket.all.map(socket => socket.readyState)).toEqual([3, 1]); + expect(Socket.all.map(socket => socket.frames.map(frame => + (frame.client_metadata as Record)[liteKey]))).toEqual([["true"], ["false", "false"]]); + expect(Socket.all.flatMap(socket => socket.frames).every(frame => frame.model === rawBody.model)).toBe(true); + expect(JSON.stringify(rawBody)).toBe(before); +}); From 823343ec0d19105142291625f79efd8a6c5ac8a7 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:49:33 +0900 Subject: [PATCH 2/4] fix(responses): keep Spark Lite metadata when tools ride the Lite shape The synchronized catalog keeps use_responses_lite: true for Spark because it selects tool delivery: the client catalog arrives as an additional_tools input item rather than top-level tools, and stripSparkCompatibility filters that group in place instead of promoting it. Advertising non-Lite while the body still carries additional_tools would leave Spark unable to see the client tools, so scope the Lite stream fix to turns that carry no Lite tool shape. --- src/adapters/openai-responses.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 599c88aa6f..fffb2866b5 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -864,6 +864,21 @@ function promoteClientLoadedTools(body: unknown): unknown { } const MAX_RESPONSES_CALL_ID_LENGTH = 64; + +/** + * Whether the outgoing body still delivers tools through the responses-lite shape. + * + * Lite carries the client catalog as an `additional_tools` input item; the non-Lite wire shape + * expects top-level `tools`. Anything that flips the Lite advertisement has to agree with the + * shape actually being sent, or the destination silently loses the tool surface. + */ +function bodyCarriesLiteToolShape(body: Record): boolean { + if (!Array.isArray(body.input)) return false; + return body.input.some(item => + isPlainObject(item) && item.type === "additional_tools" + && Array.isArray(item.tools) && item.tools.length > 0 + ); +} const REPAIRED_CALL_ID_PREFIX = "call_ocx_"; const REPAIRED_CALL_ID_DIGEST_LENGTH = MAX_RESPONSES_CALL_ID_LENGTH - REPAIRED_CALL_ID_PREFIX.length; @@ -2517,7 +2532,15 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (isCanonicalOpenAiForwardProvider(provider)) { // Select Spark's Lite compatibility from the final wire model, including aliases. // Explicit false also overrides native WS metadata; deleting the header leaves it enabled. - if (isPlainObject(finalBody) && finalBody.model === "gpt-5.3-codex-spark") { + // + // Only turns that do NOT carry the Lite tool shape may be downgraded. The synchronized + // catalog keeps `use_responses_lite: true` for Spark precisely because it selects tool + // delivery (`input[].additional_tools` instead of top-level `tools`), and + // stripSparkCompatibility filters that group in place rather than promoting it. Advertising + // non-Lite while the body still carries `additional_tools` would leave Spark unable to see + // the client tools, so the Lite stream fix stays scoped to tool-less turns. + if (isPlainObject(finalBody) && finalBody.model === "gpt-5.3-codex-spark" + && !bodyCarriesLiteToolShape(finalBody)) { for (const name of Object.keys(headers)) { if (name.toLowerCase() === CODEX_RESPONSES_LITE_HEADER) delete headers[name]; } From 1a1a85a5975a30e017673766e859bb1ab7b7451f Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:55:32 +0900 Subject: [PATCH 3/4] docs(structure): move the Spark Lite metadata note to its new SOT home dev restructured structure/ and deleted 04_transports-and-sidecars.md, so the note now lives in structure/transports/responses.md and states the narrowed rule: the Lite header is only forced false when the body does not deliver tools through the additional_tools Lite shape. --- structure/transports/responses.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/structure/transports/responses.md b/structure/transports/responses.md index b667d10b53..fdb545abac 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -355,6 +355,16 @@ final outgoing model/tier. No caller identity is synthesized. Noncanonical opt-in gateways keep their own metadata policy. Oversized/unsupported-runtime HTTP fallback preserves the original HTTP body and Lite header. +For the final wire model `gpt-5.3-codex-spark`, the canonical forward adapter sets the +Lite header to `false`, overriding caller/configured headers and stale native WS Lite +metadata, but only when the outgoing body does not deliver tools through the Lite shape. +A body still carrying an `additional_tools` input item keeps Lite metadata, because that +item IS the Lite tool-delivery format and the non-Lite wire shape expects top-level +`tools`; advertising non-Lite over it would hide the client tool surface. A changed Lite +identity retires the previous socket; subsequent eligible Spark requests with the same +disabled identity can reuse the new socket. Malformed native metadata retains HTTP +fallback eligibility without rewriting its body. + Canonical WS quota and response metadata preceding the first Responses event are projected into bounded, allowlisted HTTP headers before the response is committed. Later quota observations update only the captured serving account; From 5d56f5461ea3d18668b85f6bb0d8a523920f2536 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:03:56 +0900 Subject: [PATCH 4/4] fix(responses): derive Spark's Lite header from the body it sends The previous guard only skipped the downgrade for Lite-shaped bodies, so a forwarded or configured use_responses_lite: false survived on exactly those requests: prepareCodexWsRequest then stamped the native metadata false too, advertising non-Lite while the tools existed only in input[].additional_tools. Normalize the header from the body in both directions instead. A nonempty additional_tools group pins Lite on; any other Spark body is downgraded, which is what the stream-close fix needs. Qualify the architecture pages and the transports SOT accordingly. --- .../content/docs/fr/reference/architecture.md | 4 +- .../content/docs/ja/reference/architecture.md | 4 +- .../content/docs/ko/reference/architecture.md | 4 +- .../content/docs/reference/architecture.md | 5 ++- .../content/docs/ru/reference/architecture.md | 4 +- .../content/docs/tr/reference/architecture.md | 4 +- .../docs/zh-cn/reference/architecture.md | 4 +- .../docs/zh-tw/reference/architecture.md | 4 +- src/adapters/openai-responses.ts | 23 +++++------ structure/transports/responses.md | 18 ++++----- .../codex-metadata-integrity.test.ts | 39 +++++++++++++++++++ 11 files changed, 85 insertions(+), 28 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/architecture.md b/docs-site/src/content/docs/fr/reference/architecture.md index 4d8352655a..04c4e591b8 100644 --- a/docs-site/src/content/docs/fr/reference/architecture.md +++ b/docs-site/src/content/docs/fr/reference/architecture.md @@ -91,7 +91,9 @@ Indépendamment de ce réglage côté client, les requêtes canoniques transmise Pour le modèle sortant final `gpt-5.3-codex-spark`, la transmission canonique à ChatGPT désactive explicitement Responses Lite dans l’en-tête HTTP et les métadonnées natives des -trames WS, même lorsqu’un alias sélectionne Spark. Un changement d’identité Lite retire +trames WS, même lorsqu’un alias sélectionne Spark — uniquement si le corps sortant ne porte pas +de groupe `additional_tools`. Ce groupe EST la forme Lite de livraison des outils : un corps Spark +qui l’utilise conserve Lite ACTIF même si un en-tête appelant ou configuré disait l’inverse. Un changement d’identité Lite retire l’ancien socket ; les requêtes admissibles suivantes ayant la même identité peuvent réutiliser le nouveau socket. Les autres modèles et passerelles conservent leur politique Lite. Des métadonnées natives mal formées entraînent toujours un repli HTTP, sans modifier le corps. diff --git a/docs-site/src/content/docs/ja/reference/architecture.md b/docs-site/src/content/docs/ja/reference/architecture.md index bd22709ff1..d0241494b4 100644 --- a/docs-site/src/content/docs/ja/reference/architecture.md +++ b/docs-site/src/content/docs/ja/reference/architecture.md @@ -99,7 +99,9 @@ HTTP の境界は `server/index.ts` が担い、Responses データプレーン 最終送信モデルが `gpt-5.3-codex-spark` の場合、canonical ChatGPT 転送は HTTP ヘッダーと ネイティブ WS フレームのメタデータの両方で Responses Lite を明示的に無効にします。 -エイリアスで Spark を選択した場合も同様です。Lite の識別値が変わると古いソケットは退役し、 +エイリアスで Spark を選択した場合も同様です。ただし無効化は、送信本文が `additional_tools` +グループを持たない場合に限ります。このグループ自体が Lite のツール受け渡し形式なので、それを +使う Spark 本文は呼び出し元や設定のヘッダーに関わらず Lite を有効のまま保ちます。Lite の識別値が変わると古いソケットは退役し、 以後の条件を満たす同じ識別値のリクエストは新しいソケットを再利用できます。他のモデルと ゲートウェイの Lite ポリシーは維持されます。不正なネイティブメタデータは引き続き、 本文を変更せずに HTTP にフォールバックします。 diff --git a/docs-site/src/content/docs/ko/reference/architecture.md b/docs-site/src/content/docs/ko/reference/architecture.md index 3714675b40..9d93b9278e 100644 --- a/docs-site/src/content/docs/ko/reference/architecture.md +++ b/docs-site/src/content/docs/ko/reference/architecture.md @@ -129,7 +129,9 @@ terminal `response.failed` 이벤트와 `[DONE]`을 내보냅니다. 최종 전송 모델이 `gpt-5.3-codex-spark`이면 canonical ChatGPT forward 경로는 HTTP 헤더와 네이티브 WS 프레임 메타데이터 모두에서 Responses Lite를 명시적으로 끕니다. 별칭으로 Spark를 -선택해도 동일합니다. Lite 식별값이 바뀌면 기존 소켓은 사용을 종료하며, 이후 같은 식별값으로 +선택해도 동일합니다. 다만 이 비활성화는 전송 본문에 `additional_tools` 그룹이 없을 때만 +적용됩니다. 이 그룹 자체가 Lite의 도구 전달 형식이므로, 그것을 사용하는 Spark 본문은 호출자나 +설정 헤더가 무엇이든 Lite를 켠 상태로 유지합니다. Lite 식별값이 바뀌면 기존 소켓은 사용을 종료하며, 이후 같은 식별값으로 재사용 조건을 충족하는 요청은 새 소켓을 재사용할 수 있습니다. 다른 모델과 게이트웨이는 기존 Lite 정책을 유지합니다. 네이티브 메타데이터 형식이 잘못된 경우에는 본문을 바꾸지 않고 기존처럼 HTTP로 폴백합니다. diff --git a/docs-site/src/content/docs/reference/architecture.md b/docs-site/src/content/docs/reference/architecture.md index d9028d13be..774606f1d6 100644 --- a/docs-site/src/content/docs/reference/architecture.md +++ b/docs-site/src/content/docs/reference/architecture.md @@ -159,7 +159,10 @@ closes the upstream and emits a terminal downstream `response.failed` event foll For the final outgoing model `gpt-5.3-codex-spark`, canonical ChatGPT forwarding explicitly disables Responses Lite in both the HTTP header and native WS frame metadata, including when -an alias selects Spark. A changed Lite identity retires the old socket; subsequent eligible +an alias selects Spark — but only when the outgoing body carries no `additional_tools` group. +That group IS the Lite tool-delivery shape, so a Spark body that still uses it keeps Lite ON even +if a caller or configured header said otherwise; otherwise the frame would advertise non-Lite +while the tools exist only in the Lite shape. A changed Lite identity retires the old socket; subsequent eligible requests with the same identity can reuse the new socket. Other models and gateways keep their existing Lite policy. Malformed native metadata still falls back to HTTP with its body unchanged. diff --git a/docs-site/src/content/docs/ru/reference/architecture.md b/docs-site/src/content/docs/ru/reference/architecture.md index 02d42290e4..bf2e015716 100644 --- a/docs-site/src/content/docs/ru/reference/architecture.md +++ b/docs-site/src/content/docs/ru/reference/architecture.md @@ -153,7 +153,9 @@ loopback; настроенные записи `corsAllowOrigins` расширя Для итоговой исходящей модели `gpt-5.3-codex-spark` каноническая пересылка в ChatGPT явно отключает Responses Lite в HTTP-заголовке и нативных метаданных WS-кадра, в том числе при -выборе Spark через псевдоним. Изменение идентичности Lite выводит старый сокет из использования; +выборе Spark через псевдоним — но только если в исходящем теле нет группы `additional_tools`. +Эта группа и ЕСТЬ Lite-форма доставки инструментов, поэтому тело Spark, которое её использует, +сохраняет Lite ВКЛЮЧЁННЫМ независимо от заголовка вызывающего клиента или конфигурации. Изменение идентичности Lite выводит старый сокет из использования; последующие подходящие запросы с той же идентичностью могут повторно использовать новый сокет. Другие модели и шлюзы сохраняют прежнюю политику Lite. Некорректные нативные метаданные по-прежнему приводят к откату на HTTP без изменения тела запроса. diff --git a/docs-site/src/content/docs/tr/reference/architecture.md b/docs-site/src/content/docs/tr/reference/architecture.md index 0fc8821990..a1632b8504 100644 --- a/docs-site/src/content/docs/tr/reference/architecture.md +++ b/docs-site/src/content/docs/tr/reference/architecture.md @@ -172,7 +172,9 @@ yükseltmeyi kabul eder ve WebSocket köprüsünü kullanır. Son gönderilen model `gpt-5.3-codex-spark` olduğunda, kanonik ChatGPT iletimi HTTP başlığında ve yerel WS çerçevesi meta verilerinde Responses Lite'ı açıkça kapatır; Spark bir takma adla -seçildiğinde de bu geçerlidir. Lite kimliği değişince eski soket kullanım dışı bırakılır; +seçildiğinde de bu geçerlidir — ancak yalnızca giden gövde bir `additional_tools` grubu +taşımıyorsa. Bu grup Lite'ın araç teslim biçiminin kendisidir; onu kullanan bir Spark gövdesi, +çağıran veya yapılandırılmış başlık ne derse desin Lite'ı AÇIK tutar. Lite kimliği değişince eski soket kullanım dışı bırakılır; aynı kimliğe sahip sonraki uygun istekler yeni soketi yeniden kullanabilir. Diğer modeller ve ağ geçitleri mevcut Lite politikalarını korur. Bozuk yerel meta verilerde, istek gövdesi değiştirilmeden HTTP'ye geri dönülmeye devam edilir. diff --git a/docs-site/src/content/docs/zh-cn/reference/architecture.md b/docs-site/src/content/docs/zh-cn/reference/architecture.md index 7cf05726a0..25aa1f33a8 100644 --- a/docs-site/src/content/docs/zh-cn/reference/architecture.md +++ b/docs-site/src/content/docs/zh-cn/reference/architecture.md @@ -135,7 +135,9 @@ session 中回退到 HTTP。设置 `"websockets": true` 后,同一 endpoint WebSocket bridge。 当最终发送的模型为 `gpt-5.3-codex-spark` 时,canonical ChatGPT 转发会在 HTTP 请求头和 -原生 WS 帧元数据中明确关闭 Responses Lite,通过别名选择 Spark 时也一样。Lite 标识变化时, +原生 WS 帧元数据中明确关闭 Responses Lite,通过别名选择 Spark 时也一样;但这仅适用于发送正文 +不含 `additional_tools` 分组的情况。该分组本身就是 Lite 的工具投递形态,因此仍使用它的 Spark +正文会保持 Lite 开启,无论调用方或配置的请求头如何。Lite 标识变化时, 旧 socket 会退出使用;后续标识相同且满足复用条件的请求可以复用新 socket。其他模型和网关 保留原有 Lite 策略。原生元数据格式不合法时,仍会回退到 HTTP,并保持请求正文不变。 diff --git a/docs-site/src/content/docs/zh-tw/reference/architecture.md b/docs-site/src/content/docs/zh-tw/reference/architecture.md index 967bd0d767..02a61af6b1 100644 --- a/docs-site/src/content/docs/zh-tw/reference/architecture.md +++ b/docs-site/src/content/docs/zh-tw/reference/architecture.md @@ -135,7 +135,9 @@ session 中回退到 HTTP。設定 `"websockets": true` 後,同一 endpoint WebSocket bridge。 當最終傳送的模型為 `gpt-5.3-codex-spark` 時,canonical ChatGPT 轉送會在 HTTP 請求標頭與 -原生 WS 訊框中繼資料中明確關閉 Responses Lite,透過別名選擇 Spark 時也一樣。Lite 識別值 +原生 WS 訊框中繼資料中明確關閉 Responses Lite,透過別名選擇 Spark 時也一樣;但僅限於傳送本文 +不含 `additional_tools` 群組的情況。該群組本身就是 Lite 的工具傳遞形態,因此仍使用它的 Spark +本文會保持 Lite 開啟,無論呼叫端或設定的標頭為何。Lite 識別值 改變時,舊 socket 會停止使用;後續識別值相同且符合重用條件的請求可以重用新 socket。 其他模型與閘道保留既有 Lite 政策。原生中繼資料格式不合法時,仍會退回 HTTP,並保持 請求本文不變。 diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index fffb2866b5..8fbe43816d 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -2530,21 +2530,22 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): parsed.modelId, ); if (isCanonicalOpenAiForwardProvider(provider)) { - // Select Spark's Lite compatibility from the final wire model, including aliases. - // Explicit false also overrides native WS metadata; deleting the header leaves it enabled. + // Select Spark's Lite compatibility from the final wire model, including aliases, and + // let the BODY decide it. The header also overrides native WS metadata downstream, so a + // forwarded or statically configured value must never contradict the shape being sent. // - // Only turns that do NOT carry the Lite tool shape may be downgraded. The synchronized - // catalog keeps `use_responses_lite: true` for Spark precisely because it selects tool - // delivery (`input[].additional_tools` instead of top-level `tools`), and - // stripSparkCompatibility filters that group in place rather than promoting it. Advertising - // non-Lite while the body still carries `additional_tools` would leave Spark unable to see - // the client tools, so the Lite stream fix stays scoped to tool-less turns. - if (isPlainObject(finalBody) && finalBody.model === "gpt-5.3-codex-spark" - && !bodyCarriesLiteToolShape(finalBody)) { + // The synchronized catalog keeps `use_responses_lite: true` for Spark precisely because + // it selects tool delivery (`input[].additional_tools` instead of top-level `tools`), and + // stripSparkCompatibility filters that group in place rather than promoting it. So a + // Lite-shaped body is pinned back ON — otherwise an inherited `false` advertises non-Lite + // while the tools exist only in the Lite shape, and Spark loses the tool surface. Only a + // body with no Lite tool group is downgraded, which is what the stream fix needs. + if (isPlainObject(finalBody) && finalBody.model === "gpt-5.3-codex-spark") { + const liteShaped = bodyCarriesLiteToolShape(finalBody); for (const name of Object.keys(headers)) { if (name.toLowerCase() === CODEX_RESPONSES_LITE_HEADER) delete headers[name]; } - headers[CODEX_RESPONSES_LITE_HEADER] = "false"; + headers[CODEX_RESPONSES_LITE_HEADER] = liteShaped ? "true" : "false"; } const routingHeaders = new Headers(headers); applyCodexRoutingHint(routingHeaders, finalBody); diff --git a/structure/transports/responses.md b/structure/transports/responses.md index fdb545abac..95d316ed3e 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -355,15 +355,15 @@ final outgoing model/tier. No caller identity is synthesized. Noncanonical opt-in gateways keep their own metadata policy. Oversized/unsupported-runtime HTTP fallback preserves the original HTTP body and Lite header. -For the final wire model `gpt-5.3-codex-spark`, the canonical forward adapter sets the -Lite header to `false`, overriding caller/configured headers and stale native WS Lite -metadata, but only when the outgoing body does not deliver tools through the Lite shape. -A body still carrying an `additional_tools` input item keeps Lite metadata, because that -item IS the Lite tool-delivery format and the non-Lite wire shape expects top-level -`tools`; advertising non-Lite over it would hide the client tool surface. A changed Lite -identity retires the previous socket; subsequent eligible Spark requests with the same -disabled identity can reuse the new socket. Malformed native metadata retains HTTP -fallback eligibility without rewriting its body. +For the final wire model `gpt-5.3-codex-spark`, the canonical forward adapter normalizes the +Lite header from the BODY, overriding caller/configured headers and stale native WS Lite +metadata in both directions. A body carrying a nonempty `additional_tools` input item is +pinned to `true`: that item IS the Lite tool-delivery format and the non-Lite wire shape +expects top-level `tools`, so an inherited `false` would advertise non-Lite while the tools +exist only in the Lite shape and hide the client tool surface. Any other Spark body is set to +`false`, which is the stream-close fix. A changed Lite identity retires the previous socket; +subsequent eligible Spark requests with the same identity can reuse the new socket. Malformed +native metadata retains HTTP fallback eligibility without rewriting its body. Canonical WS quota and response metadata preceding the first Responses event are projected into bounded, allowlisted HTTP headers before the response is diff --git a/tests/codex-integration/codex-metadata-integrity.test.ts b/tests/codex-integration/codex-metadata-integrity.test.ts index b37cadb877..8033b8eeea 100644 --- a/tests/codex-integration/codex-metadata-integrity.test.ts +++ b/tests/codex-integration/codex-metadata-integrity.test.ts @@ -257,6 +257,45 @@ describe("Codex request transport metadata", () => { expect(new Headers(otherWireModel.headers).get(liteHeader)).toBe("true"); }); + test("a Lite-shaped Spark body pins Lite back on, whatever the inherited header said", async () => { + const { prepareCodexWsRequest } = await import("../../src/server/responses/codex-ws-request"); + // The catalog keeps use_responses_lite: true for Spark because it selects tool DELIVERY: + // the client catalog rides `input[].additional_tools`, not top-level `tools`. A forwarded or + // configured `false` must not survive on such a body, or the frame advertises non-Lite while + // the tools exist only in the Lite shape and Spark loses them. + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", + headers: { "X-OpenAI-Internal-Codex-Responses-Lite": "false" }, + }); + const liteShapedInput = [ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + { type: "additional_tools", tools: [{ type: "function", name: "shell", parameters: {} }] }, + ]; + + for (const incomingLite of ["false", "true", undefined] as const) { + const parsed = minimalParsed(); + parsed.modelId = "gpt-5.3-codex-spark"; + parsed._rawBody = { model: "gpt-5.3-codex-spark", input: liteShapedInput, stream: true, + client_metadata: { [liteKey]: "false", other: "preserved" } }; + const incoming = new Headers(); + if (incomingLite !== undefined) incoming.set(liteHeader, incomingLite); + const request = await adapter.buildRequest(parsed, { headers: incoming }); + expect(new Headers(request.headers).get(liteHeader)).toBe("true"); + const prepared = prepareCodexWsRequest(url, { body: request.body, headers: request.headers })!; + expect(JSON.parse(prepared.frameText).client_metadata).toEqual({ + [liteKey]: "true", other: "preserved", + }); + } + + // An empty group is not a Lite tool surface, so the stream fix still applies. + const toolless = minimalParsed(); + toolless.modelId = "gpt-5.3-codex-spark"; + toolless._rawBody = { model: "gpt-5.3-codex-spark", stream: true, + input: [{ type: "additional_tools", tools: [] }] }; + const downgraded = await adapter.buildRequest(toolless, { headers: new Headers() }); + expect(new Headers(downgraded.headers).get(liteHeader)).toBe("false"); + }); + test("Spark disables Lite without configured headers and retains malformed-metadata HTTP fallback", async () => { const { prepareCodexWsRequest } = await import("../../src/server/responses/codex-ws-request"); const adapter = createResponsesPassthroughAdapter({