From 3b120a24893ead23843cafe077f5b75719549796 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 14:17:38 -0700 Subject: [PATCH 1/3] Add the shared Codex Responses provider package --- bun.lock | 8 +++++++- package.json | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 090174961..b47b0eb6b 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "@corbits/code", "dependencies": { + "@corbits/codex-provider": "github:corbitsdev/corbits-codex-provider", "@corbits/openai-responses": "github:corbitsdev/corbits-openai-responses", "@corbits/xai-provider": "github:corbitsdev/corbits-xai-provider", "@intx/agent": "workspace:*", @@ -205,6 +206,8 @@ "@ark/util": ["@ark/util@0.56.2", "", {}, "sha512-9kU2sUE38FZEGG7l3hamYMBieLYEJh2L1mrYD2eXpT+78EnQSV1bhjxJhnxGBMSTbtwpBSDNSK+K60WvaI/DTQ=="], + "@corbits/codex-provider": ["@corbits/codex-provider@github:corbitsdev/corbits-codex-provider#bf07850", { "dependencies": { "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core", "@corbits/openai-responses": "github:corbitsdev/corbits-openai-responses", "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-codex-provider-bf07850", "sha512-5dVtVXd/CjWcXp4AitJAhcxky5mjhAq6G77q0t0iWs1bnms54VDLDnjN9oSqPJiD3nEiXmAoRbxiH1CxJkwTrA=="], + "@corbits/first-class-providers": ["@corbits/first-class-providers@workspace:packages/first-class-providers"], "@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e1e69e6", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-e1e69e6", "sha512-wUrD73iVyk/Dtb4yRn3hCh6N8syfsEvAkRz4XYqT0FIB3sYDSMYEWHcDMJx9nq64my/HBOeN1J6PHIOtpjhtPg=="], @@ -215,7 +218,6 @@ "@corbits/xai-provider": ["@corbits/xai-provider@github:corbitsdev/corbits-xai-provider#cadf9b9", { "dependencies": { "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core", "@corbits/openai-responses": "github:corbitsdev/corbits-openai-responses", "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-xai-provider-cadf9b9", "sha512-dVY4lQJWdCVPxwnK4pQNc3JgfgASaNj/GACKTnR22Y/s6CKSwlcZyGzZ0QEjIr21SZRk4+QP6ehOBf/OtHSJRw=="], - "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], "@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="], @@ -772,6 +774,10 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "@corbits/codex-provider/@corbits/openai-responses": ["@corbits/openai-responses@github:corbitsdev/corbits-openai-responses#1d20dbb", { "dependencies": { "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-openai-responses-1d20dbb", "sha512-lSJz1KkKD8mEUYiQ57dJprD9JavdJGt3OBRa42tvXLsikt1fJ6qX2mGCwaEXzkfZNc2XY5o7ZpxK5swSJX+LnA=="], + + "@corbits/xai-provider/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e1e69e6", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-e1e69e6", "sha512-wUrD73iVyk/Dtb4yRn3hCh6N8syfsEvAkRz4XYqT0FIB3sYDSMYEWHcDMJx9nq64my/HBOeN1J6PHIOtpjhtPg=="], + "@npmcli/agent/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], diff --git a/package.json b/package.json index 462be039f..5e55c6d65 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "tar": "^7.5.1" }, "dependencies": { + "@corbits/codex-provider": "github:corbitsdev/corbits-codex-provider", "@corbits/openai-responses": "github:corbitsdev/corbits-openai-responses", "@corbits/xai-provider": "github:corbitsdev/corbits-xai-provider", "@intx/agent": "workspace:*", From 4628119cae26573f5d20c7c7a93e115d540fd59c Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 14:17:49 -0700 Subject: [PATCH 2/3] Serve Codex through the shared Responses adapter --- docs/IMPLEMENTATION.md | 2 +- src/config/index.ts | 2 +- src/provider/codex-responses.test.ts | 81 ++++++++++++++++++++++ src/provider/codex-responses.ts | 59 ++++++++++++++++ src/provider/inference-dependencies.ts | 8 +-- src/provider/replay-sanitizer.test.ts | 5 +- tests/fixtures/codex-sse/README.md | 3 +- tests/unit/codex-sse-fixtures.test.ts | 26 ++++--- tests/unit/inference-response-kind.test.ts | 2 +- 9 files changed, 168 insertions(+), 20 deletions(-) create mode 100644 src/provider/codex-responses.test.ts create mode 100644 src/provider/codex-responses.ts diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 5da158a62..55a90096c 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -393,7 +393,7 @@ Positional arguments after flags are joined into the optional initial task deliv - OpenAI-compatible chat completions, streamed via `@intx/inference` - JSON-schema tool definitions for director-layer tools (`ask_operator`, `present`, `submit_output`) and agent tools (`manage_tasks`, `tool_search`, `use_skill`, `search_agents`, …) -- Codex Responses (`codex-responses-adapter.ts` `buildRequest`): ChatGPT Codex is Responses-only. `instructions` is exactly the supplied Corbits system prompt (including an empty string), omitted when unset. `input` contains only the converted conversation turns, preserving their roles and order without a synthetic developer bridge. There is no official-prompt fetch, cache, or startup refresh. It requires `store: false` (`store: true` → 400) and rejects `previous_response_id`. Multi-turn continuity is full `input` replay; encrypted reasoning captured via `include: ["reasoning.encrypted_content"]` is resent as a `reasoning` item. `prompt_cache_key` (session id) is the cache-routing signal. `parallel_tool_calls` is sent `false` (serial at this surface); the reactor already fans out a multi-call batch concurrently. `max_output_tokens` is omitted (backend rejects it). +- Codex Responses (`@corbits/codex-provider` via `codex-responses.ts`): ChatGPT Codex is Responses-only. The host system prompt is wrapped with product identity as a leading `developer` input item; conversation turns follow in order. There is no official-prompt fetch, cache, or startup refresh. It requires `store: false` (`store: true` → 400) and rejects `previous_response_id`. Multi-turn continuity is full `input` replay; encrypted reasoning captured via `include: ["reasoning.encrypted_content"]` is resent as a `reasoning` item. `prompt_cache_key` (session id) is the cache-routing signal. `parallel_tool_calls` is sent `false` (serial at this surface); the reactor already fans out a multi-call batch concurrently. `max_output_tokens` is omitted (backend rejects it). ### State Persistence diff --git a/src/config/index.ts b/src/config/index.ts index d42fcf50c..c39dcc7c4 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -41,7 +41,7 @@ import { CODEX_RESPONSES_PROVIDER, CODEX_ACCOUNT_ID_OPTION, CODEX_SESSION_ID_OPTION, -} from "../provider/codex-responses-adapter.js"; +} from "../provider/codex-responses.js"; import { GROK_RESPONSES_PROVIDER, GROK_SESSION_ID_OPTION, diff --git a/src/provider/codex-responses.test.ts b/src/provider/codex-responses.test.ts new file mode 100644 index 000000000..cb8b8facd --- /dev/null +++ b/src/provider/codex-responses.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test"; +import { BEARER_CREDENTIAL_SENTINEL } from "@intx/inference"; +import type { ConversationTurn, LastCycleSource } from "@intx/types/runtime"; +import { ENVIRONMENT_TAG_NAME, PRODUCT_NAME } from "../branding.js"; +import { + CODEX_ACCOUNT_ID_OPTION, + CODEX_RESPONSES_PROVIDER, + CODEX_SESSION_ID_OPTION, + createCodexResponsesAdapter, +} from "./codex-responses.js"; + +const source: LastCycleSource = { + sourceId: "codex/test", + provider: CODEX_RESPONSES_PROVIDER, + model: "gpt-5.1-codex", +}; + +function userTurn(text: string): ConversationTurn { + return { role: "user", timestamp: 0, content: [{ type: "text", text }] }; +} + +describe("createCodexResponsesAdapter", () => { + test("forwards providerOptions.reasoning_effort onto reasoning.effort", () => { + const adapter = createCodexResponsesAdapter(source); + const request = adapter.buildRequest([userTurn("hello")], "gpt-5.1-codex", { + providerOptions: { reasoning_effort: "high" }, + }); + const body = JSON.parse(request.body) as { + reasoning?: { effort?: string }; + }; + expect(body.reasoning).toEqual({ effort: "high" }); + }); + + test("omits reasoning when effort is none or unset", () => { + const adapter = createCodexResponsesAdapter(source); + const unset = JSON.parse( + adapter.buildRequest([userTurn("hello")], "gpt-5.1-codex", {}).body, + ) as { reasoning?: unknown }; + const none = JSON.parse( + adapter.buildRequest([userTurn("hello")], "gpt-5.1-codex", { + providerOptions: { reasoning_effort: "none" }, + }).body, + ) as { reasoning?: unknown }; + expect(unset.reasoning).toBeUndefined(); + expect(none.reasoning).toBeUndefined(); + }); + + test("lifts host account and session option keys into Codex headers", () => { + const adapter = createCodexResponsesAdapter(source); + const request = adapter.buildRequest([userTurn("hi")], "gpt-5.1-codex", { + providerOptions: { + [CODEX_ACCOUNT_ID_OPTION]: "acct-1", + [CODEX_SESSION_ID_OPTION]: "sess-1", + }, + }); + expect(request.url).toBe("/codex/responses"); + expect(request.headers["authorization"]).toBe(BEARER_CREDENTIAL_SENTINEL); + expect(request.headers["chatgpt-account-id"]).toBe("acct-1"); + expect(request.headers["session_id"]).toBe("sess-1"); + const body = JSON.parse(request.body) as { prompt_cache_key?: string }; + expect(body.prompt_cache_key).toBe("sess-1"); + }); + + test("wraps the system prompt with host product identity as a developer item", () => { + const adapter = createCodexResponsesAdapter(source); + const request = adapter.buildRequest([userTurn("x")], "gpt-5.1-codex", { + systemPrompt: "be terse", + }); + const body = JSON.parse(request.body) as { + instructions?: unknown; + input: { role?: string; content?: { text?: string }[] }[]; + }; + expect(body.instructions).toBeUndefined(); + expect(body.input[0]?.role).toBe("developer"); + const wrapped = body.input[0]?.content?.[0]?.text ?? ""; + expect(wrapped).toContain(`<${ENVIRONMENT_TAG_NAME} priority="0">`); + expect(wrapped).toContain(`${PRODUCT_NAME} is the harness`); + expect(wrapped).toContain("be terse"); + expect(body.input[1]?.role).toBe("user"); + }); +}); diff --git a/src/provider/codex-responses.ts b/src/provider/codex-responses.ts new file mode 100644 index 000000000..67520a344 --- /dev/null +++ b/src/provider/codex-responses.ts @@ -0,0 +1,59 @@ +import { + createCodexResponsesAdapter as createPackageCodexResponsesAdapter, + CODEX_ACCOUNT_ID_OPTION, + CODEX_REASONING_EFFORT_OPTION, + CODEX_SESSION_ID_OPTION, + withCodexContentTypeRepair, +} from "@corbits/codex-provider"; +import type { AdapterFactory, ProviderAdapter } from "@intx/inference"; +import type { InferenceOptions } from "@intx/types/runtime"; +import { ENVIRONMENT_TAG_NAME, PRODUCT_NAME } from "../branding.js"; + +export const CODEX_RESPONSES_PROVIDER = "codex-responses"; + +export { + CODEX_ACCOUNT_ID_OPTION, + CODEX_SESSION_ID_OPTION, + withCodexContentTypeRepair, +}; + +const CODEX_QUIRKS = { + productName: PRODUCT_NAME, + environmentTagName: ENVIRONMENT_TAG_NAME, +}; + +const HOST_REASONING_EFFORT_OPTION = "reasoning_effort"; + +function remapReasoningEffort(options: InferenceOptions): InferenceOptions { + const providerOptions = options.providerOptions; + if (providerOptions === undefined) return options; + const hostEffort = providerOptions[HOST_REASONING_EFFORT_OPTION]; + if ( + hostEffort === undefined || + providerOptions[CODEX_REASONING_EFFORT_OPTION] !== undefined + ) { + return options; + } + return { + ...options, + providerOptions: { + ...providerOptions, + [CODEX_REASONING_EFFORT_OPTION]: hostEffort, + }, + }; +} + +function withHostReasoningEffort(adapter: ProviderAdapter): ProviderAdapter { + return { + ...adapter, + buildRequest: (messages, model, options) => + adapter.buildRequest(messages, model, remapReasoningEffort(options)), + }; +} + +// CodexQuirks are required by the package factory; Responses option keys are +// not part of that bag, so host reasoning_effort is aliased at buildRequest. +export const createCodexResponsesAdapter: AdapterFactory = (source) => + withHostReasoningEffort( + createPackageCodexResponsesAdapter(source, CODEX_QUIRKS), + ); diff --git a/src/provider/inference-dependencies.ts b/src/provider/inference-dependencies.ts index b3f5634dd..0c9238941 100644 --- a/src/provider/inference-dependencies.ts +++ b/src/provider/inference-dependencies.ts @@ -6,7 +6,7 @@ import { import { loadAdapterRegistry } from "@intx/inference/providers"; import * as openaiCompatible from "./openai-compatible-adapter.js"; import * as opencodeGo from "./opencode-go-adapter.js"; -import * as codexResponses from "./codex-responses-adapter.js"; +import * as codexResponses from "./codex-responses.js"; import * as grokResponses from "./grok-responses.js"; import * as bifrostAdapter from "./bifrost-adapter.js"; import * as openaiResponses from "./openai-responses.js"; @@ -14,7 +14,7 @@ import * as opencodeGoAnthropic from "./opencode-go-anthropic-adapter.js"; import { CODEX_RESPONSES_PROVIDER, withCodexContentTypeRepair, -} from "./codex-responses-adapter.js"; +} from "./codex-responses.js"; import { GROK_RESPONSES_PROVIDER } from "./grok-responses.js"; import { withReplaySanitizer } from "./replay-sanitizer.js"; import { OPENCODE_GO_PROVIDER_ID } from "../../packages/opencode-go/src/index.js"; @@ -39,7 +39,7 @@ const manifest: AdapterManifest = [ }, { provider: CODEX_RESPONSES_PROVIDER, - specifier: "codex-responses-adapter", + specifier: "codex-responses", export: "createCodexResponsesAdapter", }, { @@ -67,7 +67,7 @@ const manifest: AdapterManifest = [ const localModules: Record = { "openai-compatible-adapter": openaiCompatible, "opencode-go-adapter": opencodeGo, - "codex-responses-adapter": codexResponses, + "codex-responses": codexResponses, "grok-responses": grokResponses, "bifrost-adapter": bifrostAdapter, "openai-responses": openaiResponses, diff --git a/src/provider/replay-sanitizer.test.ts b/src/provider/replay-sanitizer.test.ts index 0586fd7f5..57996f4ad 100644 --- a/src/provider/replay-sanitizer.test.ts +++ b/src/provider/replay-sanitizer.test.ts @@ -6,8 +6,7 @@ import type { ConversationTurn, LastCycleSource } from "@intx/types/runtime"; import { CODEX_RESPONSES_PROVIDER, createCodexResponsesAdapter, - tagSignature, -} from "./codex-responses-adapter.js"; +} from "./codex-responses.js"; import { createGrokResponsesAdapter } from "./grok-responses.js"; import { createOpenAICompatibleAdapter } from "./openai-compatible-adapter.js"; import { @@ -417,7 +416,7 @@ describe("withReplaySanitizer", () => { provider: CODEX_RESPONSES_PROVIDER, model: "gpt-5.1-codex", }); - const signature = tagSignature(CODEX_RESPONSES_PROVIDER, "cipher"); + const signature = `${CODEX_RESPONSES_PROVIDER}:cipher`; const turns: ConversationTurn[] = [ { role: "user", content: [{ type: "text", text: "hi" }], timestamp: 1 }, { diff --git a/tests/fixtures/codex-sse/README.md b/tests/fixtures/codex-sse/README.md index b7bd6309a..f93b84f60 100644 --- a/tests/fixtures/codex-sse/README.md +++ b/tests/fixtures/codex-sse/README.md @@ -1,7 +1,8 @@ # Codex / Responses SSE fixtures Sanitized multi-event streams for golden tests of `parseResponse` in -`src/provider/codex-responses-adapter.ts`. +the Codex Responses adapter (`src/provider/codex-responses.ts`, served by +`@corbits/codex-provider`). Each `*.json` file is a JSON array of Responses SSE **data payloads** (the object after `data: ` on each SSE line). No real tokens, prompts, account IDs, diff --git a/tests/unit/codex-sse-fixtures.test.ts b/tests/unit/codex-sse-fixtures.test.ts index 3aaff27cf..4c0e7583f 100644 --- a/tests/unit/codex-sse-fixtures.test.ts +++ b/tests/unit/codex-sse-fixtures.test.ts @@ -7,11 +7,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { - createCodexResponsesAdapter, - isResponsesStreamTerminal, - tagSignature, -} from "../../src/provider/codex-responses-adapter.js"; +import { createCodexResponsesAdapter } from "../../src/provider/codex-responses.js"; import type { InferenceEvent, LastCycleSource } from "@intx/types/runtime"; import { defined } from "../helpers/defined.js"; import { ProtocolMismatchError } from "@intx/inference"; @@ -101,7 +97,7 @@ describe("codex-sse fixtures (golden parse)", () => { expect(out[3]).toMatchObject({ type: "inference.block.signature", data: { - signature: tagSignature(SOURCE.provider, "ENC_FIXTURE_BLOB_NOT_REAL"), + signature: `${SOURCE.provider}:ENC_FIXTURE_BLOB_NOT_REAL`, index: 0, }, }); @@ -146,7 +142,11 @@ describe("codex-sse fixtures (golden parse)", () => { loadFixture("interleaved-reasoning-text-tools.json").at(-1), "last payload", ); - expect(isResponsesStreamTerminal(JSON.stringify(lastPayload))).toBe(true); + expect( + createCodexResponsesAdapter(SOURCE).isStreamTerminal?.( + JSON.stringify(lastPayload), + ), + ).toBe(true); }); test("incomplete stream emits partial text and is terminal without usage", () => { @@ -171,7 +171,11 @@ describe("codex-sse fixtures (golden parse)", () => { loadFixture("incomplete.json").at(-1), "last payload", ); - expect(isResponsesStreamTerminal(JSON.stringify(lastPayload))).toBe(true); + expect( + createCodexResponsesAdapter(SOURCE).isStreamTerminal?.( + JSON.stringify(lastPayload), + ), + ).toBe(true); }); test("failed response throws ProtocolMismatchError after prior deltas", () => { @@ -204,6 +208,10 @@ describe("codex-sse fixtures (golden parse)", () => { "last payload", ); expect((lastPayload as { type: string }).type).toBe("response.done"); - expect(isResponsesStreamTerminal(JSON.stringify(lastPayload))).toBe(true); + expect( + createCodexResponsesAdapter(SOURCE).isStreamTerminal?.( + JSON.stringify(lastPayload), + ), + ).toBe(true); }); }); diff --git a/tests/unit/inference-response-kind.test.ts b/tests/unit/inference-response-kind.test.ts index eb36bea3e..9752ea569 100644 --- a/tests/unit/inference-response-kind.test.ts +++ b/tests/unit/inference-response-kind.test.ts @@ -22,7 +22,7 @@ import { createInferenceDependencies } from "../../src/provider/inference-depend import { CODEX_RESPONSES_PROVIDER, withCodexContentTypeRepair, -} from "../../src/provider/codex-responses-adapter.js"; +} from "../../src/provider/codex-responses.js"; import { CODEX_RESPONSES_PATH } from "../../src/auth/codex/constants.js"; const CODEX_URL = `https://chatgpt.com/backend-api${CODEX_RESPONSES_PATH}`; From e28538becbeac7eac49287ded1289222e2641daf Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 14:21:25 -0700 Subject: [PATCH 3/3] Remove the in-tree Codex Responses adapter --- src/provider/codex-responses-adapter.test.ts | 435 ----------- src/provider/codex-responses-adapter.ts | 754 ------------------ tests/unit/codex-responses-adapter.test.ts | 767 ------------------- 3 files changed, 1956 deletions(-) delete mode 100644 src/provider/codex-responses-adapter.test.ts delete mode 100644 src/provider/codex-responses-adapter.ts delete mode 100644 tests/unit/codex-responses-adapter.test.ts diff --git a/src/provider/codex-responses-adapter.test.ts b/src/provider/codex-responses-adapter.test.ts deleted file mode 100644 index aab4316af..000000000 --- a/src/provider/codex-responses-adapter.test.ts +++ /dev/null @@ -1,435 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { - ConversationTurn, - LastCycleSource, - TokenUsage, -} from "@intx/types/runtime"; -import { - createCodexResponsesAdapter, - isResponsesStreamTerminal, - signatureForModel, - tagSignature, -} from "./codex-responses-adapter.js"; -import { contextTokensFromUsage } from "./context-window.js"; - -const source: LastCycleSource = { - sourceId: "codex/test", - provider: "codex-responses", - model: "gpt-5.1-codex", -}; - -describe("createCodexResponsesAdapter", () => { - test("sends user image blocks as Responses input_image parts", () => { - const adapter = createCodexResponsesAdapter(source); - const turns: ConversationTurn[] = [ - { - role: "user", - timestamp: 0, - content: [ - { type: "text", text: "what is this?" }, - { - type: "image", - source: { kind: "base64", mimeType: "image/png", data: "aW1hZ2U=" }, - }, - ], - }, - ]; - - const request = adapter.buildRequest(turns, "gpt-5.1-codex", {}); - const body = JSON.parse(request.body) as { - input: { type: string; role?: string; content?: unknown }[]; - }; - - expect(body.input[0]).toEqual({ - type: "message", - role: "user", - content: [ - { type: "input_text", text: "what is this?" }, - { type: "input_image", image_url: "data:image/png;base64,aW1hZ2U=" }, - ], - }); - }); - - test("keeps text-only messages as Responses text parts", () => { - const adapter = createCodexResponsesAdapter(source); - const turns: ConversationTurn[] = [ - { - role: "user", - timestamp: 0, - content: [{ type: "text", text: "hello" }], - }, - ]; - - const request = adapter.buildRequest(turns, "gpt-5.1-codex", {}); - const body = JSON.parse(request.body) as { input: { content?: unknown }[] }; - - expect(body.input[0]?.content).toEqual([ - { type: "input_text", text: "hello" }, - ]); - }); - - test("reports the adapter as terminating on response.completed", () => { - const adapter = createCodexResponsesAdapter(source); - expect(adapter.isStreamTerminal).toBe(isResponsesStreamTerminal); - }); - - test("extracts Retry-After pacing from response headers", () => { - const adapter = createCodexResponsesAdapter(source); - expect( - adapter.extractRetryAfterMs?.(new Headers({ "retry-after": "7" })), - ).toBe(7_000); - expect( - adapter.extractRetryAfterMs?.(new Headers({ "retry-after-ms": "1500" })), - ).toBe(1_500); - expect(adapter.extractRetryAfterMs?.(new Headers({}))).toBeUndefined(); - }); -}); - -describe("createCodexResponsesAdapter usage parsing", () => { - // The Responses API reports `input_tokens` as the full prompt count with - // `cached_tokens` as a subset. Downstream consumers (context meter, - // compaction governor, faremeter) sum input + cacheRead + cacheWrite, so - // emitting the raw wire counts would double-count every cached token. - const completedUsage = ( - adapter: ReturnType, - sseData: string, - ): { usage: TokenUsage; source: LastCycleSource } => { - const event = adapter - .parseResponse(sseData) - .find((e) => e.type === "inference.usage"); - if (event === undefined) - throw new Error("stream carried no inference.usage event"); - return event.data as { usage: TokenUsage; source: LastCycleSource }; - }; - - test("subtracts cached_tokens from input_tokens so usage fields do not overlap", () => { - const adapter = createCodexResponsesAdapter(source); - const sseData = JSON.stringify({ - type: "response.completed", - response: { - usage: { - input_tokens: 1000, - input_tokens_details: { cached_tokens: 800 }, - output_tokens: 50, - output_tokens_details: { reasoning_tokens: 5 }, - }, - }, - }); - - expect(completedUsage(adapter, sseData)).toEqual({ - usage: { - input: 200, - output: 50, - cacheRead: 800, - cacheWrite: 0, - thinking: 5, - }, - source, - }); - }); - - test("keeps the context occupancy sum equal to the wire prompt token count", () => { - const adapter = createCodexResponsesAdapter(source); - const sseData = JSON.stringify({ - type: "response.completed", - response: { - usage: { - input_tokens: 1000, - input_tokens_details: { cached_tokens: 940 }, - output_tokens: 50, - }, - }, - }); - - const { usage } = completedUsage(adapter, sseData); - - expect(contextTokensFromUsage(usage)).toBe(1000); - }); - - test("reports input unchanged when the provider omits input_tokens_details", () => { - const adapter = createCodexResponsesAdapter(source); - const sseData = JSON.stringify({ - type: "response.completed", - response: { - usage: { - input_tokens: 100, - output_tokens: 50, - }, - }, - }); - - expect(completedUsage(adapter, sseData)).toEqual({ - usage: { - input: 100, - output: 50, - cacheRead: 0, - cacheWrite: 0, - thinking: 0, - }, - source, - }); - }); - - test("clamps input to zero when cached_tokens exceeds input_tokens", () => { - const adapter = createCodexResponsesAdapter(source); - const sseData = JSON.stringify({ - type: "response.completed", - response: { - usage: { - input_tokens: 10, - input_tokens_details: { cached_tokens: 25 }, - output_tokens: 50, - }, - }, - }); - - expect(completedUsage(adapter, sseData)).toEqual({ - usage: { - input: 0, - output: 50, - cacheRead: 25, - cacheWrite: 0, - thinking: 0, - }, - source, - }); - }); - - test("maps a nonzero cache_creation_tokens count through to cacheWrite", () => { - const adapter = createCodexResponsesAdapter(source); - const sseData = JSON.stringify({ - type: "response.completed", - response: { - usage: { - input_tokens: 100, - input_tokens_details: { - cached_tokens: 20, - cache_creation_tokens: 15, - }, - output_tokens: 50, - output_tokens_details: { reasoning_tokens: 5 }, - }, - }, - }); - - expect(completedUsage(adapter, sseData)).toEqual({ - usage: { - input: 80, - output: 50, - cacheRead: 20, - cacheWrite: 15, - thinking: 5, - }, - source, - }); - }); -}); - -describe("signatureForModel", () => { - const turnWithModel = (model: string | undefined): ConversationTurn => - ({ - role: "assistant", - model, - content: [], - timestamp: 0, - }) as unknown as ConversationTurn; - - test("replays a signature on a turn with no persisted model", () => { - const signature = tagSignature("codex-responses", "cipher"); - const result = signatureForModel( - turnWithModel(undefined), - "gpt-5.1-codex", - "codex-responses", - signature, - ); - expect(result).toBe("cipher"); - }); - - test("drops a signature when the turn's model genuinely differs", () => { - const signature = tagSignature("codex-responses", "cipher"); - const result = signatureForModel( - turnWithModel("gpt-5.0-codex"), - "gpt-5.1-codex", - "codex-responses", - signature, - ); - expect(result).toBeUndefined(); - }); - - test("replays a signature when the model matches", () => { - const signature = tagSignature("codex-responses", "cipher"); - const result = signatureForModel( - turnWithModel("gpt-5.1-codex"), - "gpt-5.1-codex", - "codex-responses", - signature, - ); - expect(result).toBe("cipher"); - }); -}); - -describe("createCodexResponsesAdapter orphaned function_call suppression", () => { - test("drops a function_call whose reasoning signature could not be replayed", () => { - const adapter = createCodexResponsesAdapter(source); - const turns: ConversationTurn[] = [ - { role: "user", timestamp: 0, content: [{ type: "text", text: "hi" }] }, - { - role: "assistant", - model: "gpt-5.0-codex", - timestamp: 0, - content: [ - { - type: "thinking", - thinking: "ponder", - signature: tagSignature("codex-responses", "c"), - }, - { type: "tool_call", id: "call_1", name: "shell", arguments: {} }, - ], - }, - ] as unknown as ConversationTurn[]; - - const request = adapter.buildRequest(turns, "gpt-5.1-codex", {}); - const body = JSON.parse(request.body) as { input: { type: string }[] }; - - expect(body.input.some((item) => item.type === "reasoning")).toBe(false); - expect(body.input.some((item) => item.type === "function_call")).toBe( - false, - ); - }); - - test("keeps the function_call when its reasoning signature replays cleanly", () => { - const adapter = createCodexResponsesAdapter(source); - const turns: ConversationTurn[] = [ - { role: "user", timestamp: 0, content: [{ type: "text", text: "hi" }] }, - { - role: "assistant", - model: "gpt-5.1-codex", - timestamp: 0, - content: [ - { - type: "thinking", - thinking: "ponder", - signature: tagSignature("codex-responses", "c"), - }, - { type: "tool_call", id: "call_1", name: "shell", arguments: {} }, - ], - }, - ] as unknown as ConversationTurn[]; - - const request = adapter.buildRequest(turns, "gpt-5.1-codex", {}); - const body = JSON.parse(request.body) as { input: { type: string }[] }; - - expect(body.input.some((item) => item.type === "reasoning")).toBe(true); - expect(body.input.some((item) => item.type === "function_call")).toBe(true); - }); -}); - -describe("createCodexResponsesAdapter tool-name codec", () => { - test("encodes a non-wire-safe tool name on the outgoing function tool definition", () => { - const adapter = createCodexResponsesAdapter(source); - const turns: ConversationTurn[] = [ - { role: "user", timestamp: 0, content: [{ type: "text", text: "hi" }] }, - ]; - - const request = adapter.buildRequest(turns, "gpt-5.1-codex", { - tools: [ - { - name: "@intx/tools-posix/sidecar-bundle:run_shell", - description: "run a shell command", - inputSchema: {}, - }, - ], - } as never); - const body = JSON.parse(request.body) as { tools: { name: string }[] }; - - expect(body.tools[0]?.name).toMatch(/^[A-Za-z_][A-Za-z0-9_-]*$/); - expect(body.tools[0]?.name).not.toBe( - "@intx/tools-posix/sidecar-bundle:run_shell", - ); - }); - - test("decodes an encoded tool_call.start name back to the internal id", () => { - const adapter = createCodexResponsesAdapter(source); - const encoded = "IX_-40intx-2Ftools-2Dposix-2Fsidecar-2Dbundle-3Arun_shell"; - const sseData = JSON.stringify({ - type: "response.output_item.added", - item: { - type: "function_call", - id: "item_1", - call_id: "call_1", - name: encoded, - }, - }); - - const events = adapter.parseResponse(sseData); - const start = events.find((e) => e.type === "inference.tool_call.start"); - - expect((start?.data as { name?: string })?.name).not.toBe(encoded); - }); -}); - -describe("createCodexResponsesAdapter block indexer reset", () => { - test("resets block indices on a new buildRequest instead of accumulating across requests", () => { - const adapter = createCodexResponsesAdapter(source); - const turns: ConversationTurn[] = [ - { role: "user", timestamp: 0, content: [{ type: "text", text: "hi" }] }, - ]; - - adapter.buildRequest(turns, "gpt-5.1-codex", {}); - adapter.parseResponse( - JSON.stringify({ - type: "response.output_text.delta", - item_id: "item_1", - delta: "a", - }), - ); - adapter.parseResponse( - JSON.stringify({ - type: "response.output_text.delta", - item_id: "item_2", - delta: "b", - }), - ); - - // A new request (a fresh HTTP round trip) with a brand-new item id should - // start indexing from 0 again, not continue accumulating from the prior - // request's indexer state. - adapter.buildRequest(turns, "gpt-5.1-codex", {}); - const secondRequestDelta = adapter.parseResponse( - JSON.stringify({ - type: "response.output_text.delta", - item_id: "item_3", - delta: "c", - }), - ); - expect((secondRequestDelta[0]?.data as { index?: number })?.index).toBe(0); - }); -}); - -describe("isResponsesStreamTerminal", () => { - test("is true for the Responses end-of-turn events", () => { - for (const type of [ - "response.completed", - "response.incomplete", - "response.done", - ]) { - expect(isResponsesStreamTerminal(JSON.stringify({ type }))).toBe(true); - } - }); - - test("is false for streaming and lifecycle events", () => { - for (const type of [ - "response.output_text.delta", - "response.created", - "response.in_progress", - ]) { - expect(isResponsesStreamTerminal(JSON.stringify({ type }))).toBe(false); - } - }); - - test("is false for malformed or non-object payloads", () => { - expect(isResponsesStreamTerminal("{not json")).toBe(false); - expect(isResponsesStreamTerminal("null")).toBe(false); - expect(isResponsesStreamTerminal('"just a string"')).toBe(false); - }); -}); diff --git a/src/provider/codex-responses-adapter.ts b/src/provider/codex-responses-adapter.ts deleted file mode 100644 index 2245c37d4..000000000 --- a/src/provider/codex-responses-adapter.ts +++ /dev/null @@ -1,754 +0,0 @@ -import { - BEARER_CREDENTIAL_SENTINEL, - ProtocolMismatchError, - decodeToolName, - encodeToolName, - type BuiltRequest, - type ProviderAdapter, - type ToolNameLimit, -} from "@intx/inference"; -import type { - ContentBlock, - ConversationTurn, - InferenceEvent, - InferenceOptions, - LastCycleSource, - PartialMessage, - TokenUsage, -} from "@intx/types/runtime"; -import { - CODEX_RESPONSES_PATH, - CODEX_AUTHORIZE_EXTRA_PARAMS, -} from "../auth/codex/constants.js"; - -// Adapter for the OpenAI Responses API as served by the Codex backend -// (chatgpt.com/backend-api/codex/responses). The Codex backend does NOT speak -// Chat Completions: requests use Responses `input` items + flat tools, and the -// stream is the Responses SSE event protocol. Registered under the provider id -// "codex-responses"; sources for `codex/` providers are built with -// that id so the harness routes them here instead of the OpenAI adapter. -// -// Credentials and the chatgpt-account-id ride through differently: the access -// token is injected by the harness via the bearer sentinel, while the account -// id and session id travel in `source.defaults.providerOptions` (merged into -// InferenceOptions.providerOptions by the harness). Account id is headers-only -// (`chatgpt-account-id`). Session id is the `session_id` header and -// `prompt_cache_key` in the body — the only cache-routing signal under -// `store: false`. -// -// Continuity is not Responses store chaining. The ChatGPT Codex backend -// requires `store: false` (`store: true` → 400) and rejects -// `previous_response_id`. Every turn resends the full `input`; encrypted -// reasoning captured from the prior stream is resent as a `reasoning` item. - -export const CODEX_RESPONSES_PROVIDER = "codex-responses"; - -// Keys the source stashes in defaults.providerOptions for this adapter. -export const CODEX_ACCOUNT_ID_OPTION = "codexAccountId"; -export const CODEX_SESSION_ID_OPTION = "codexSessionId"; - -const EMPTY_PARTIAL: PartialMessage = { text: "" }; - -type FetchLike = ( - input: string | URL | Request, - init?: RequestInit, -) => Promise; - -function requestURL(input: string | URL | Request): string { - if (typeof input === "string") return input; - if (input instanceof URL) return input.toString(); - return input.url; -} - -// Content type the request's accept header committed to, or null when the -// commitment is ambiguous. Reads init headers first, falling back to a -// Request object's own headers so both fetch calling conventions are -// honored. Media types are prefix-matched per comma-separated entry so -// parameters do not defeat the match; a list naming BOTH supported -// protocols is ambiguous and yields null. -function acceptedContentType( - input: string | URL | Request, - init: RequestInit | undefined, -): string | null { - const headers = - init?.headers !== undefined - ? new Headers(init.headers) - : input instanceof Request - ? input.headers - : undefined; - const accept = headers?.get("accept"); - if (accept === undefined || accept === null) return null; - const supported = new Set(); - for (const entry of accept.toLowerCase().split(",")) { - const media = entry.trim(); - if (media.startsWith("text/event-stream")) - supported.add("text/event-stream"); - else if (media.startsWith("application/json")) - supported.add("application/json"); - } - if (supported.size !== 1) return null; - return [...supported][0] ?? null; -} - -// The Codex backend omits the Content-Type header entirely on some model -// streams (observed live with the gpt-5.6 family) while the body is a valid -// SSE stream. The vendored harness detects the response protocol from that -// header alone and fails the turn when it is absent, so the header is -// restored here — at the fetch boundary Corbits owns, scoped to Codex -// responses requests — from the protocol the request's accept header -// declared. Responses that declare any Content-Type, non-2xx responses, and -// requests whose accept header is ambiguous pass through untouched, keeping -// the harness's loud protocol-mismatch failure for genuine violations. -export function withCodexContentTypeRepair(fetchImpl: FetchLike): FetchLike { - return async (input, init) => { - const response = await fetchImpl(input, init); - if (!requestURL(input).endsWith(CODEX_RESPONSES_PATH)) return response; - if (!response.ok) return response; - if (response.headers.get("content-type") !== null) return response; - const declared = acceptedContentType(input, init); - if (declared === null) return response; - const headers = new Headers(response.headers); - headers.set("content-type", declared); - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); - }; -} - -// --------------------------------------------------------------------------- -// Request building — internal turns → Responses `input` items -// --------------------------------------------------------------------------- - -type ResponsesContentPart = - | { type: "input_text"; text: string } - | { type: "output_text"; text: string } - | { type: "input_image"; image_url: string }; - -type ResponsesInputItem = - | { - type: "message"; - role: "user" | "assistant" | "system" | "developer"; - content: ResponsesContentPart[]; - } - | { type: "function_call"; name: string; arguments: string; call_id: string } - | { type: "function_call_output"; call_id: string; output: string } - | { type: "reasoning"; summary: never[]; encrypted_content: string }; - -// A thinking block's `signature` is opaque ciphertext a specific backend -// issued for a specific model; only that backend can decrypt it. `model` is -// arbitrary catalog/user-supplied text — nothing stops two distinct backends -// (proxy aliases, two OpenAI-compatible endpoints) from declaring the same -// literal model name, so comparing `turn.model` alone treats a foreign -// signature as safe to replay. `ConversationTurn` carries no field for which -// provider produced it, so provenance rides inside the signature string -// itself: capture tags it `:` (see `tagSignature`), -// and replay only unwraps the ciphertext when both the tagged provider and -// the model match the current request. -// -// Provider, not the per-account source id, is the unit of decrypt -// capability — a Codex backend shared across ChatGPT accounts can decrypt a -// signature issued to any of them, so keying on provider (rather than source -// id) is what lets an account switch keep reasoning continuity while a -// genuine cross-provider collision still gets dropped. A poisoned history -// self-heals on the next request instead of being replayed forever. -const SIGNATURE_TAG_SEPARATOR = ":"; - -export function tagSignature( - provider: string, - encryptedContent: string, -): string { - return `${provider}${SIGNATURE_TAG_SEPARATOR}${encryptedContent}`; -} - -function untagSignature( - tagged: string, -): { provider: string; encryptedContent: string } | undefined { - const idx = tagged.indexOf(SIGNATURE_TAG_SEPARATOR); - if (idx === -1) return undefined; - return { - provider: tagged.slice(0, idx), - encryptedContent: tagged.slice(idx + 1), - }; -} - -export function signatureForModel( - turn: ConversationTurn, - requestModel: string, - requestProvider: string, - signature: string, -): string | undefined { - // `model` is optional on the persisted turn schema; a turn saved before that - // field existed (or otherwise missing it) is not evidence of a model - // switch — treat the absence as benign and fall through to the provider - // check, rather than dropping reasoning that never actually crossed models. - if (turn.model !== undefined && turn.model !== requestModel) return undefined; - const tagged = untagSignature(signature); - if (tagged === undefined) return undefined; - return tagged.provider === requestProvider - ? tagged.encryptedContent - : undefined; -} - -// Map one internal turn to zero or more Responses items. Assistant text uses -// `output_text` parts; user/system text uses `input_text`. Tool calls become -// `function_call` items (arguments serialized to a JSON string) and tool -// results become `function_call_output` items. Reasoning blocks are echoed -// back only when they carry the opaque `encrypted_content` the backend issued -// (held in a thinking block's signature) AND that backend is the one this -// request is going to — replaying it to a different provider gets a 400 it -// cannot recover from. -// Wire-charset limit for function names on the Responses surface (Codex, -// Grok, and the generic OpenAI Responses adapter all share OpenAI's -// `^[a-zA-Z0-9_-]{1,64}$` function-name charset). -export const RESPONSES_TOOL_NAME_LIMIT: ToolNameLimit = { - provider: "responses", - maxLength: 64, -}; - -function toResponsesItems( - turn: ConversationTurn, - requestModel: string, - requestProvider: string, -): ResponsesInputItem[] { - const items: ResponsesInputItem[] = []; - const textKind: "input_text" | "output_text" = - turn.role === "assistant" ? "output_text" : "input_text"; - const textParts: ResponsesContentPart[] = []; - // A reasoning block whose signature we could not replay (foreign provider, - // model switch, or a missing/untagged signature) leaves any function_call - // it produced without the reasoning item the Responses API expects to - // precede it — the exact orphaned shape that degenerates reasoning models. - // Suppress function_call items until the next text or successfully-replayed - // reasoning item re-establishes a clean turn shape; tool results are - // unaffected since they never need a preceding reasoning item. - let suppressOrphanedCalls = false; - - const flushText = (): void => { - if (textParts.length > 0) { - items.push({ type: "message", role: turn.role, content: [...textParts] }); - textParts.length = 0; - suppressOrphanedCalls = false; - } - }; - - for (const block of turn.content) { - if (block.type === "text") { - textParts.push({ - type: textKind, - text: block.text, - } as ResponsesContentPart); - } else if (block.type === "image") { - if (block.source.kind === "base64") { - textParts.push({ - type: "input_image", - image_url: `data:${block.source.mimeType};base64,${block.source.data}`, - }); - } else if (block.source.kind === "url") { - textParts.push({ type: "input_image", image_url: block.source.url }); - } else { - textParts.push({ - type: textKind, - text: `[Unsupported image reference omitted: ${block.source.reference}]`, - } as ResponsesContentPart); - } - } else if (block.type === "tool_call") { - if (suppressOrphanedCalls) continue; - flushText(); - items.push({ - type: "function_call", - name: encodeToolName(block.name, RESPONSES_TOOL_NAME_LIMIT), - arguments: JSON.stringify(block.arguments ?? {}), - call_id: block.id, - }); - } else if (block.type === "tool_result") { - flushText(); - suppressOrphanedCalls = false; - items.push({ - type: "function_call_output", - call_id: block.callId, - output: toolResultText(block), - }); - } else if ( - block.type === "thinking" && - typeof block.signature === "string" && - block.signature.length > 0 - ) { - flushText(); - const encryptedContent = signatureForModel( - turn, - requestModel, - requestProvider, - block.signature, - ); - if (encryptedContent !== undefined) { - items.push({ - type: "reasoning", - summary: [], - encrypted_content: encryptedContent, - }); - suppressOrphanedCalls = false; - } else { - suppressOrphanedCalls = true; - } - } - } - flushText(); - return items; -} - -// Tool results carry a content array; the Responses API wants a string. Join -// the text parts; non-text content (images, etc.) is not representable here and -// is dropped with a marker so the model is not misled into thinking it is -// missing silently. -function toolResultText( - block: Extract, -): string { - const parts: string[] = []; - for (const c of block.content) { - if (c.type === "text") parts.push(c.text); - else parts.push(`[unsupported ${c.type} content omitted]`); - } - return parts.join(""); -} - -function toResponsesTools(options: InferenceOptions): unknown[] | undefined { - if (options.tools === undefined || options.tools.length === 0) - return undefined; - // Responses function tools are FLAT — name/description/parameters sit beside - // `type`, not nested under a `function` key (unlike Chat Completions). - return options.tools.map((t) => ({ - type: "function", - name: encodeToolName(t.name, RESPONSES_TOOL_NAME_LIMIT), - description: t.description, - parameters: t.inputSchema, - })); -} - -function optionString( - options: InferenceOptions, - key: string, -): string | undefined { - const value = options.providerOptions?.[key]; - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -function buildRequest( - messages: ConversationTurn[], - model: string, - options: InferenceOptions, - requestProvider: string, -): BuiltRequest { - const input = messages.flatMap((turn) => - toResponsesItems(turn, model, requestProvider), - ); - const tools = toResponsesTools(options); - const accountId = optionString(options, CODEX_ACCOUNT_ID_OPTION); - const sessionId = optionString(options, CODEX_SESSION_ID_OPTION); - - const body: Record = { - model, - input, - // The Codex ChatGPT backend requires `store: false` (store:true → 400) and - // rejects `previous_response_id` as an unsupported parameter. Multi-turn - // continuity is full input plus encrypted reasoning round-trip only — do - // not attempt response-id chaining on this surface. - store: false, - stream: true, - include: ["reasoning.encrypted_content"], - // Serial at the request layer. The reactor already executes a multi-call - // batch concurrently; this flag is what the ChatGPT Codex backend is sent. - // Do not flip without verifying the backend accepts true — unlike store / - // previous_response_id there is no recorded 400. - parallel_tool_calls: false, - }; - if (options.systemPrompt !== undefined) { - body["instructions"] = options.systemPrompt; - } - // The Codex backend rejects `max_output_tokens`; it is intentionally omitted. - if (tools !== undefined) { - body["tools"] = tools; - body["tool_choice"] = "auto"; - } - // reasoning_effort rides in providerOptions (same place the OpenAI-compatible - // path reads it); map it onto the Responses `reasoning.effort` field. - // ChatGPT Codex rejects summary:"auto" for gpt-5.6-terra / gpt-5.3-codex - // family (HTTP 400; supported: concise | detailed | none). Codex CLI catalog - // default_reasoning_summary is none — send effort only (CL-6893). - const effort = options.providerOptions?.["reasoning_effort"]; - if (typeof effort === "string" && effort !== "none") { - body["reasoning"] = { effort }; - } - if (sessionId !== undefined) body["prompt_cache_key"] = sessionId; - - const headers: Record = { - "content-type": "application/json", - accept: "text/event-stream", - authorization: BEARER_CREDENTIAL_SENTINEL, - "openai-beta": "responses=experimental", - originator: CODEX_AUTHORIZE_EXTRA_PARAMS["originator"] ?? "codex_cli_rs", - }; - if (accountId !== undefined) headers["chatgpt-account-id"] = accountId; - if (sessionId !== undefined) headers["session_id"] = sessionId; - - return { url: CODEX_RESPONSES_PATH, headers, body: JSON.stringify(body) }; -} - -// --------------------------------------------------------------------------- -// Response parsing — Responses SSE events → internal inference events -// --------------------------------------------------------------------------- - -// Per-request block indexing. The Responses stream tags every streaming item -// with an `item_id`, so we allocate one content-block index per distinct item -// id (regardless of kind). Keying by item id — rather than one sticky index per -// kind — preserves true arrival order when reasoning, text, and tool calls -// interleave, and lets `response.output_item.done` attach an encrypted-reasoning -// signature to the exact thinking block it belongs to. `kind` is recorded so a -// signature is only emitted against a real thinking block. -type CodexBlockKind = "text" | "thinking" | "tool_call"; -export interface CodexBlockIndexer { - nextIndex: number; - items: Map; -} - -// Both the Codex and grok backends speak the same Responses SSE protocol, so -// the parser is shared. Each adapter creates its own indexer per request. -export function createResponsesBlockIndexer(): CodexBlockIndexer { - return { - nextIndex: 0, - items: new Map(), - }; -} - -function blockIndexFor( - state: CodexBlockIndexer, - itemId: string, - kind: CodexBlockKind, -): number { - const existing = state.items.get(itemId); - if (existing !== undefined) return existing.index; - const index = state.nextIndex; - state.nextIndex += 1; - state.items.set(itemId, { index, kind }); - return index; -} - -function usageFromResponse( - response: Record, -): TokenUsage | undefined { - const usage = response["usage"]; - if (typeof usage !== "object" || usage === null) return undefined; - const u = usage as Record; - const num = (v: unknown): number => (typeof v === "number" ? v : 0); - const inputDetails = u["input_tokens_details"] as - | Record - | undefined; - const outputDetails = u["output_tokens_details"] as - | Record - | undefined; - // Responses-API `input_tokens` counts the full prompt and `cached_tokens` - // is a subset of it. Downstream consumers (context meter, compaction - // governor, faremeter) treat the TokenUsage fields as non-overlapping and - // sum them, so the cached subset must be split out of input here — emitting - // the wire counts verbatim double-counts every cached token and inflates - // context occupancy up to ~2x on high cache-hit sessions. - const totalInputTokens = num(u["input_tokens"]); - const cachedTokens = num(inputDetails?.["cached_tokens"]); - return { - input: Math.max(0, totalInputTokens - cachedTokens), - output: num(u["output_tokens"]), - cacheRead: cachedTokens, - // OpenAI does not charge for writing to the prompt cache, so the public - // Responses API usually omits a write count; read it defensively under - // `cache_creation_tokens` in case a gateway/proxy in front of this - // OpenAI-shaped endpoint (Codex, Grok) reports one, rather than always - // hardcoding zero. - cacheWrite: num(inputDetails?.["cache_creation_tokens"]), - thinking: num(outputDetails?.["reasoning_tokens"]), - }; -} - -export function parseResponse( - sseData: string, - indexer: CodexBlockIndexer, - source: LastCycleSource, - label = "codex-responses", -): InferenceEvent[] { - let parsed: unknown; - try { - parsed = JSON.parse(sseData); - } catch (cause) { - throw new ProtocolMismatchError( - `${label} parseResponse: malformed JSON in SSE data payload: ${cause instanceof Error ? cause.message : String(cause)}`, - sseData, - ); - } - if (typeof parsed !== "object" || parsed === null) return []; - const event = parsed as Record; - const eventType = event["type"]; - if (typeof eventType !== "string") return []; - - const seq = 0; - const events: InferenceEvent[] = []; - - switch (eventType) { - case "response.output_text.delta": { - const token = event["delta"]; - const itemId = - typeof event["item_id"] === "string" - ? (event["item_id"] as string) - : "__text__"; - if (typeof token === "string" && token.length > 0) { - events.push({ - type: "inference.text.delta", - seq, - data: { - token, - partial: EMPTY_PARTIAL, - index: blockIndexFor(indexer, itemId, "text"), - }, - }); - } - return events; - } - case "response.reasoning_summary_text.delta": - case "response.reasoning_text.delta": { - // Always register the block and emit a thinking delta (even for empty - // tokens). This ensures a preceding thinking block exists for any - // subsequent signature, supporting reasoning items whose visible - // summary may be empty or delivered only via the done envelope. - const token = event["delta"]; - const itemId = - typeof event["item_id"] === "string" - ? (event["item_id"] as string) - : "__thinking__"; - const index = blockIndexFor(indexer, itemId, "thinking"); - const tok = typeof token === "string" ? token : ""; - events.push({ - type: "inference.thinking.delta", - seq, - data: { token: tok, partial: EMPTY_PARTIAL, index }, - }); - return events; - } - case "response.output_item.added": { - const item = event["item"]; - if (typeof item === "object" && item !== null) { - const it = item as Record; - if (it["type"] === "function_call") { - const itemId = typeof it["id"] === "string" ? it["id"] : undefined; - const callId = it["call_id"]; - const name = it["name"]; - if ( - itemId !== undefined && - typeof callId === "string" && - typeof name === "string" - ) { - events.push({ - type: "inference.tool_call.start", - seq, - data: { - callId, - name: decodeToolName(name), - partial: EMPTY_PARTIAL, - index: blockIndexFor(indexer, itemId, "tool_call"), - }, - }); - } - } else if (it["type"] === "reasoning") { - // Pre-register reasoning items on added so the index is stable - // even if no text deltas follow (pure-encrypted case). - const itemId = - typeof it["id"] === "string" ? (it["id"] as string) : undefined; - if (itemId !== undefined) { - const index = blockIndexFor(indexer, itemId, "thinking"); - events.push({ - type: "inference.thinking.delta", - seq, - data: { token: "", partial: EMPTY_PARTIAL, index }, - }); - } - } - } - return events; - } - case "response.output_item.done": { - // Capture the encrypted reasoning blob (signature) so it can be echoed - // back on the next turn. Required for multi-turn continuity when the - // backend uses store:false + reasoning.encrypted_content. - // We ensure a thinking block exists (emitting an empty delta if this - // is the first signal for the item) so the harness can attach the - // signature without ProtocolMismatchError. - const item = event["item"] as Record | undefined; - if ( - item?.["type"] === "reasoning" && - typeof item["id"] === "string" && - typeof item["encrypted_content"] === "string" - ) { - const itemId = item["id"] as string; - const hadPrior = indexer.items.has(itemId); - const index = blockIndexFor(indexer, itemId, "thinking"); - if (!hadPrior) { - events.push({ - type: "inference.thinking.delta", - seq, - data: { token: "", partial: EMPTY_PARTIAL, index }, - }); - } - events.push({ - type: "inference.block.signature", - seq, - data: { - signature: tagSignature( - source.provider, - item["encrypted_content"] as string, - ), - index, - }, - }); - } - return events; - } - case "response.function_call_arguments.delta": { - const itemId = event["item_id"]; - const fragment = event["delta"]; - if ( - typeof itemId === "string" && - typeof fragment === "string" && - fragment.length > 0 - ) { - const blockIndex = blockIndexFor(indexer, itemId, "tool_call"); - events.push({ - type: "inference.tool_call.delta", - seq, - // The harness routes argument fragments by a per-stream placeholder - // keyed to the block index registered on the start event. - data: { - callId: String(blockIndex), - argumentFragment: fragment, - partial: EMPTY_PARTIAL, - index: blockIndex, - }, - }); - } - return events; - } - case "response.completed": { - const response = event["response"]; - if (typeof response === "object" && response !== null) { - const usage = usageFromResponse(response as Record); - if (usage !== undefined) { - events.push({ - type: "inference.usage", - seq, - data: { usage, source }, - }); - } - } - return events; - } - case "response.failed": { - const response = event["response"] as Record | undefined; - const error = response?.["error"] as Record | undefined; - const message = - typeof error?.["message"] === "string" - ? error["message"] - : "response failed"; - throw new ProtocolMismatchError(`${label}: ${message}`, parsed); - } - case "error": { - const message = - typeof event["message"] === "string" - ? event["message"] - : "stream error"; - throw new ProtocolMismatchError(`${label}: ${message}`, parsed); - } - default: - // Lifecycle envelopes (response.created, response.in_progress, - // content_part.*, *_text.done) carry no incremental payload the harness - // needs; ignore them. - return events; - } -} - -// The Responses stream ends on a semantic lifecycle event, not `[DONE]` or a -// socket close: `response.completed` on success, `response.incomplete` when the -// backend truncates, `response.done` as an alias some backends emit. The -// harness reads this to stop the loop once the terminal event is processed; -// failure envelopes (`response.failed`, `error`) already throw in -// `parseResponse`, which terminates the loop through the harness's catch. -const RESPONSES_TERMINAL_EVENTS = new Set([ - "response.completed", - "response.incomplete", - "response.done", -]); - -// The Responses adapters in this file always request `stream: true` -// (buildRequest sets it unconditionally), so a non-streaming JSON body -// reaching the harness means the response kind was misdetected or the -// provider ignored the streaming request — a protocol violation, not a -// supported code path to parse. -export function parseJSONResponse(): never { - throw new ProtocolMismatchError( - "responses adapter: received a non-streaming JSON response, but this adapter always requests stream: true", - ); -} - -export function isResponsesStreamTerminal(sseData: string): boolean { - let parsed: unknown; - try { - parsed = JSON.parse(sseData); - } catch { - // parseResponse re-parses the same payload and raises the protocol error; - // reporting "not terminal" here defers to that single throw site. - return false; - } - if (typeof parsed !== "object" || parsed === null) return false; - const eventType = (parsed as Record)["type"]; - return ( - typeof eventType === "string" && RESPONSES_TERMINAL_EVENTS.has(eventType) - ); -} - -// Responses backends (Codex, Grok, OpenAI) signal 429 pacing with the same -// `retry-after` / `retry-after-ms` headers the Chat Completions adapter -// already reads. The shared Responses adapters never extracted them, so -// every 429 arrived with retryAfterMs undefined and the retry policy fell -// back to blind fixed backoff instead of waiting out the server's window. -export function extractResponsesRetryAfterMs( - headers: Headers, -): number | undefined { - const retryMs = headers.get("retry-after-ms"); - if (retryMs !== null) { - const ms = Number(retryMs); - if (Number.isFinite(ms) && ms > 0) return Math.ceil(ms); - } - const raw = headers.get("retry-after"); - if (raw !== null) { - const seconds = Number(raw); - if (Number.isFinite(seconds) && seconds > 0) { - return Math.ceil(seconds * 1000); - } - } - return undefined; -} - -export function createCodexResponsesAdapter( - source: LastCycleSource, -): ProviderAdapter { - // Re-created per request in buildRequest, not just once here — otherwise - // block indices accumulate across every request the adapter instance ever - // serves, growing the map for the life of the conversation. - let indexer: CodexBlockIndexer = createResponsesBlockIndexer(); - return { - buildRequest: (messages, model, options) => { - indexer = createResponsesBlockIndexer(); - return buildRequest(messages, model, options, source.provider); - }, - parseResponse: (sseData) => parseResponse(sseData, indexer, source), - parseJSONResponse, - isStreamTerminal: isResponsesStreamTerminal, - extractRetryAfterMs: extractResponsesRetryAfterMs, - }; -} diff --git a/tests/unit/codex-responses-adapter.test.ts b/tests/unit/codex-responses-adapter.test.ts deleted file mode 100644 index dccfb88f5..000000000 --- a/tests/unit/codex-responses-adapter.test.ts +++ /dev/null @@ -1,767 +0,0 @@ -import { test, expect, describe } from "bun:test"; -import { - createCodexResponsesAdapter, - tagSignature, - signatureForModel, - CODEX_ACCOUNT_ID_OPTION, - CODEX_SESSION_ID_OPTION, - CODEX_RESPONSES_PROVIDER, -} from "../../src/provider/codex-responses-adapter.js"; -import { GROK_RESPONSES_PROVIDER } from "../../src/provider/grok-responses.js"; -import { COMPACTED_PREFIX } from "../../src/session/compactor.js"; -import { BEARER_CREDENTIAL_SENTINEL } from "@intx/inference"; -import type { - ConversationTurn, - InferenceOptions, - LastCycleSource, -} from "@intx/types/runtime"; - -const SOURCE: LastCycleSource = { - sourceId: "codex/personal", - provider: "codex-responses", - model: "gpt-5-codex", -}; - -function adapter() { - return createCodexResponsesAdapter(SOURCE); -} - -function userTurn(text: string): ConversationTurn { - return { role: "user", content: [{ type: "text", text }], timestamp: 0 }; -} - -describe("codex-responses buildRequest", () => { - const baseOptions: InferenceOptions = { - providerOptions: { - [CODEX_ACCOUNT_ID_OPTION]: "acct-1", - [CODEX_SESSION_ID_OPTION]: "sess-1", - }, - }; - - test("targets the Responses path with the required Codex headers", () => { - const req = adapter().buildRequest( - [userTurn("hi")], - "gpt-5-codex", - baseOptions, - ); - expect(req.url).toBe("/codex/responses"); - expect(req.headers["authorization"]).toBe(BEARER_CREDENTIAL_SENTINEL); - expect(req.headers["openai-beta"]).toBe("responses=experimental"); - expect(req.headers["chatgpt-account-id"]).toBe("acct-1"); - expect(req.headers["session_id"]).toBe("sess-1"); - expect(req.headers["originator"]).toBe("codex_cli_rs"); - expect(req.headers["accept"]).toBe("text/event-stream"); - }); - - test("builds a Responses body with input items, store off, and reasoning encrypted include", () => { - const req = adapter().buildRequest( - [userTurn("hello")], - "gpt-5-codex", - baseOptions, - ); - const body = JSON.parse(req.body) as Record; - expect(body["model"]).toBe("gpt-5-codex"); - expect(body["stream"]).toBe(true); - expect(body["store"]).toBe(false); - expect(body).not.toHaveProperty("previous_response_id"); - expect(body["parallel_tool_calls"]).toBe(false); - expect(body["include"]).toEqual(["reasoning.encrypted_content"]); - expect(body["prompt_cache_key"]).toBe("sess-1"); - expect(body).not.toHaveProperty("instructions"); - expect(body["input"]).toEqual([ - { - type: "message", - role: "user", - content: [{ type: "input_text", text: "hello" }], - }, - ]); - }); - - test("sends the system prompt as instructions without injecting a developer message", () => { - const options: InferenceOptions = { - ...baseOptions, - systemPrompt: "be terse", - tools: [ - { - name: "read_file", - description: "read a file", - inputSchema: { type: "object", properties: {} }, - }, - ], - }; - const body = JSON.parse( - adapter().buildRequest([userTurn("x")], "gpt-5-codex", options).body, - ) as Record; - expect(body["instructions"]).toBe(options.systemPrompt); - expect(body["input"]).toEqual([ - { - type: "message", - role: "user", - content: [{ type: "input_text", text: "x" }], - }, - ]); - expect(body["tools"]).toEqual([ - { - type: "function", - name: "read_file", - description: "read a file", - parameters: { type: "object", properties: {} }, - }, - ]); - expect(body["tool_choice"]).toBe("auto"); - }); - - // Adapter mapping only: compacted history still uses instructions, not a - // developer item. ChatDirector tests own the lock that infer carries the - // constructor systemPrompt after compaction or recovery. - test("sends compacted history with the system prompt as instructions and no developer item", () => { - const systemPrompt = "Corbits operating prompt"; - const turns: ConversationTurn[] = [ - userTurn(`${COMPACTED_PREFIX}\nPrior work summarized.`), - { - role: "assistant", - timestamp: 0, - content: [{ type: "text", text: "ok" }], - }, - userTurn("continue"), - ]; - const body = JSON.parse( - adapter().buildRequest(turns, "gpt-5-codex", { - ...baseOptions, - systemPrompt, - }).body, - ) as Record; - expect(body["instructions"]).toBe(systemPrompt); - const input = body["input"] as { - role?: string; - content?: { text?: string }[]; - }[]; - expect(input).toHaveLength(3); - expect(input[0]?.role).toBe("user"); - expect(input[1]?.role).toBe("assistant"); - expect(input[2]?.role).toBe("user"); - expect(input[0]?.content?.[0]?.text?.startsWith(COMPACTED_PREFIX)).toBe( - true, - ); - expect(input.every((item) => item.role !== "developer")).toBe(true); - expect( - input.some((item) => - item.content?.some((block) => block.text === systemPrompt), - ), - ).toBe(false); - }); - - test.each([undefined, "", "Corbits operating prompt"])( - "preserves conversation order and system turns with systemPrompt %j", - (systemPrompt) => { - const turns: ConversationTurn[] = [ - userTurn("first"), - { - role: "system", - timestamp: 0, - content: [{ type: "text", text: "real instruction" }], - }, - { - role: "assistant", - timestamp: 0, - content: [{ type: "text", text: "reply" }], - }, - userTurn("next"), - ]; - const options = - systemPrompt === undefined - ? baseOptions - : { ...baseOptions, systemPrompt }; - const body = JSON.parse( - adapter().buildRequest(turns, "gpt-5-codex", options).body, - ); - if (systemPrompt === undefined) { - expect(body).not.toHaveProperty("instructions"); - } else { - expect(body.instructions).toBe(systemPrompt); - } - expect(body.input).toEqual([ - { - type: "message", - role: "user", - content: [{ type: "input_text", text: "first" }], - }, - { - type: "message", - role: "system", - content: [{ type: "input_text", text: "real instruction" }], - }, - { - type: "message", - role: "assistant", - content: [{ type: "output_text", text: "reply" }], - }, - { - type: "message", - role: "user", - content: [{ type: "input_text", text: "next" }], - }, - ]); - }, - ); - - test("encodes assistant tool calls and tool results as Responses items", () => { - const turns: ConversationTurn[] = [ - userTurn("run it"), - { - role: "assistant", - timestamp: 0, - content: [ - { type: "text", text: "calling" }, - { - type: "tool_call", - id: "call_1", - name: "read_file", - arguments: { path: "a.ts" }, - }, - ], - }, - { - role: "user", - timestamp: 0, - content: [ - { - type: "tool_result", - callId: "call_1", - content: [{ type: "text", text: "contents" }], - }, - ], - }, - ]; - const body = JSON.parse( - adapter().buildRequest(turns, "gpt-5-codex", baseOptions).body, - ) as Record; - expect(body["input"]).toEqual([ - { - type: "message", - role: "user", - content: [{ type: "input_text", text: "run it" }], - }, - { - type: "message", - role: "assistant", - content: [{ type: "output_text", text: "calling" }], - }, - { - type: "function_call", - name: "read_file", - arguments: '{"path":"a.ts"}', - call_id: "call_1", - }, - { type: "function_call_output", call_id: "call_1", output: "contents" }, - ]); - }); - - test("maps reasoning_effort to the Responses reasoning config", () => { - const options: InferenceOptions = { - providerOptions: { - ...baseOptions.providerOptions, - reasoning_effort: "high", - }, - }; - const body = JSON.parse( - adapter().buildRequest([userTurn("x")], "gpt-5-codex", options).body, - ) as Record; - expect(body["reasoning"]).toEqual({ effort: "high" }); - }); - - // CL-6893: ChatGPT Codex rejects reasoning.summary "auto" for the gpt-5.6-terra / - // gpt-5.3-codex family (HTTP 400). Codex CLI catalog default is none — omit summary - // (effort only) so Terra/Luna request bodies never send summary:"auto". - test.each(["gpt-5.6-terra", "gpt-5.6-luna"] as const)( - "omits reasoning.summary auto for %s (CL-6893)", - (model) => { - const options: InferenceOptions = { - providerOptions: { - ...baseOptions.providerOptions, - reasoning_effort: "high", - }, - }; - const body = JSON.parse( - adapter().buildRequest([userTurn("x")], model, options).body, - ) as Record; - expect(body["reasoning"]).toEqual({ effort: "high" }); - expect(body["reasoning"]).not.toHaveProperty("summary"); - }, - ); - - test("roundtrips encrypted reasoning signature from prior assistant turn into Responses reasoning item", () => { - // Prior turn's assistant content included a thinking block with signature. - // buildRequest must emit the "reasoning" item (with encrypted_content) before - // the assistant message so the backend can continue its hidden reasoning state. - const turns: ConversationTurn[] = [ - userTurn("solve the hard problem"), - { - role: "assistant", - model: "gpt-5-codex", - timestamp: 0, - content: [ - { - type: "thinking", - thinking: "internal steps...", - signature: tagSignature(CODEX_RESPONSES_PROVIDER, "ENC_BLOB_123"), - }, - { type: "text", text: "The answer is 42." }, - ], - }, - ]; - const body = JSON.parse( - adapter().buildRequest(turns, "gpt-5-codex", baseOptions).body, - ) as Record; - expect(body["input"]).toEqual([ - { - type: "message", - role: "user", - content: [{ type: "input_text", text: "solve the hard problem" }], - }, - { type: "reasoning", summary: [], encrypted_content: "ENC_BLOB_123" }, - { - type: "message", - role: "assistant", - content: [{ type: "output_text", text: "The answer is 42." }], - }, - ]); - }); - - test("a second account on the same provider still replays the signature", () => { - // codex/personal and codex/work are two ChatGPT accounts routed through the - // same Codex backend (same provider, different InferenceSource.id). The - // backend can decrypt a signature issued to either account, so a live - // account switch must not poison reasoning continuity. - const turns: ConversationTurn[] = [ - userTurn("solve the hard problem"), - { - role: "assistant", - model: "gpt-5-codex", - timestamp: 0, - content: [ - { - type: "thinking", - thinking: "internal steps...", - signature: tagSignature(CODEX_RESPONSES_PROVIDER, "ENC_BLOB_123"), - }, - { type: "text", text: "The answer is 42." }, - ], - }, - ]; - const workAdapter = createCodexResponsesAdapter({ - sourceId: "codex/work", - provider: "codex-responses", - model: "gpt-5-codex", - }); - const body = JSON.parse( - workAdapter.buildRequest(turns, "gpt-5-codex", baseOptions).body, - ) as Record; - expect(body["input"]).toContainEqual({ - type: "reasoning", - summary: [], - encrypted_content: "ENC_BLOB_123", - }); - }); - - test("drops a reasoning signature issued for a different model after a provider switch", () => { - // The signature was minted by grok-4.5; the request now targets a Codex - // model. Replaying it would 400 with an undecryptable-content error, so - // the reasoning item must be omitted while the surrounding turn survives. - const turns: ConversationTurn[] = [ - userTurn("solve the hard problem"), - { - role: "assistant", - model: "grok-4.5", - timestamp: 0, - content: [ - { - type: "thinking", - thinking: "internal steps...", - signature: tagSignature(GROK_RESPONSES_PROVIDER, "FOREIGN_BLOB"), - }, - { type: "text", text: "The answer is 42." }, - ], - }, - ]; - const body = JSON.parse( - adapter().buildRequest(turns, "gpt-5-codex", baseOptions).body, - ) as Record; - expect(body["input"]).toEqual([ - { - type: "message", - role: "user", - content: [{ type: "input_text", text: "solve the hard problem" }], - }, - { - type: "message", - role: "assistant", - content: [{ type: "output_text", text: "The answer is 42." }], - }, - ]); - }); - - test("drops a reasoning signature issued by a different provider even when the model string matches", () => { - // Two distinct backends (e.g. proxy aliases) can declare the identical - // literal model name. Nothing but the tagged provider on the signature - // itself distinguishes them, since InferenceSource.model is arbitrary - // catalog text and turn.model alone cannot tell them apart. - const turns: ConversationTurn[] = [ - userTurn("solve the hard problem"), - { - role: "assistant", - model: "gpt-5-codex", - timestamp: 0, - content: [ - { - type: "thinking", - thinking: "internal steps...", - signature: tagSignature(GROK_RESPONSES_PROVIDER, "FOREIGN_BLOB"), - }, - { type: "text", text: "The answer is 42." }, - ], - }, - ]; - const body = JSON.parse( - adapter().buildRequest(turns, "gpt-5-codex", baseOptions).body, - ) as Record; - expect(body["input"]).toEqual([ - { - type: "message", - role: "user", - content: [{ type: "input_text", text: "solve the hard problem" }], - }, - { - type: "message", - role: "assistant", - content: [{ type: "output_text", text: "The answer is 42." }], - }, - ]); - }); - - test("recovers an already-poisoned session: a foreign signature is dropped on every subsequent request", () => { - const poisonedHistory: ConversationTurn[] = [ - userTurn("turn 1"), - { - role: "assistant", - model: "grok-4.5", - timestamp: 0, - content: [ - { - type: "thinking", - thinking: "...", - signature: tagSignature(GROK_RESPONSES_PROVIDER, "FOREIGN_BLOB"), - }, - { type: "text", text: "ok" }, - ], - }, - userTurn("turn 2"), - ]; - const firstRetry = JSON.parse( - adapter().buildRequest(poisonedHistory, "gpt-5-codex", baseOptions).body, - ) as Record; - const secondRetry = JSON.parse( - adapter().buildRequest(poisonedHistory, "gpt-5-codex", baseOptions).body, - ) as Record; - for (const body of [firstRetry, secondRetry]) { - const input = body["input"] as Record[]; - expect(input.some((item) => item["type"] === "reasoning")).toBe(false); - } - }); - - test("drops a bare, untagged legacy signature instead of misparsing it as ciphertext", () => { - // Signatures captured before this change carry no ":" prefix. - // untagSignature must recognize the absence of a separator and refuse to - // treat any part of the raw string as ciphertext, rather than replaying - // a truncated or garbled blob the backend cannot decrypt. - const turns: ConversationTurn[] = [ - userTurn("solve the hard problem"), - { - role: "assistant", - model: "gpt-5-codex", - timestamp: 0, - content: [ - { - type: "thinking", - thinking: "internal steps...", - signature: "QUJDREVGRzEyMzQ1Njc4OTAtXy8rPQ==", - }, - { type: "text", text: "The answer is 42." }, - ], - }, - ]; - const body = JSON.parse( - adapter().buildRequest(turns, "gpt-5-codex", baseOptions).body, - ) as Record; - const input = body["input"] as Record[]; - expect(input.some((item) => item["type"] === "reasoning")).toBe(false); - }); - - test("signatureForModel returns undefined for an untagged signature", () => { - const turn: ConversationTurn = { - role: "assistant", - model: "gpt-5-codex", - timestamp: 0, - content: [], - }; - expect( - signatureForModel( - turn, - "gpt-5-codex", - CODEX_RESPONSES_PROVIDER, - "QUJDREVGRzEyMzQ1Njc4OTAtXy8rPQ==", - ), - ).toBeUndefined(); - }); - - test("tagSignature/signatureForModel round-trips ciphertext containing embedded colons byte-exact", () => { - // untagSignature splits on the FIRST colon (indexOf, not split(":")), - // so ciphertext that itself contains colons must survive intact. A - // naive split(":")[1] would truncate this to "part2". - const ciphertext = "part1:part2:part3=="; - const turn: ConversationTurn = { - role: "assistant", - model: "gpt-5-codex", - timestamp: 0, - content: [], - }; - const tagged = tagSignature(CODEX_RESPONSES_PROVIDER, ciphertext); - expect( - signatureForModel(turn, "gpt-5-codex", CODEX_RESPONSES_PROVIDER, tagged), - ).toBe(ciphertext); - }); - - test("omits the account-id header when no account id is supplied", () => { - const req = adapter().buildRequest([userTurn("x")], "gpt-5-codex", { - providerOptions: { [CODEX_SESSION_ID_OPTION]: "s" }, - }); - expect(req.headers["chatgpt-account-id"]).toBeUndefined(); - }); -}); - -describe("codex-responses parseResponse", () => { - function parse( - events: object[], - ): ReturnType["parseResponse"]> { - const a = adapter(); - return events.flatMap((e) => a.parseResponse(JSON.stringify(e))); - } - - test("emits text deltas", () => { - const out = parse([ - { type: "response.output_text.delta", delta: "hel" }, - { type: "response.output_text.delta", delta: "lo" }, - ]); - expect(out).toHaveLength(2); - expect(out[0]).toMatchObject({ - type: "inference.text.delta", - data: { token: "hel", index: 0 }, - }); - expect(out[1]).toMatchObject({ - type: "inference.text.delta", - data: { token: "lo", index: 0 }, - }); - }); - - test("emits thinking deltas for reasoning summary text", () => { - const out = parse([ - { type: "response.reasoning_summary_text.delta", delta: "ponder" }, - ]); - expect(out[0]).toMatchObject({ - type: "inference.thinking.delta", - data: { token: "ponder" }, - }); - }); - - test("emits tool_call start then argument deltas with a shared block index", () => { - const out = parse([ - { - type: "response.output_item.added", - item: { - type: "function_call", - id: "fc_1", - call_id: "call_9", - name: "read_file", - }, - }, - { - type: "response.function_call_arguments.delta", - item_id: "fc_1", - delta: '{"path":', - }, - { - type: "response.function_call_arguments.delta", - item_id: "fc_1", - delta: '"a.ts"}', - }, - ]); - expect(out[0]).toMatchObject({ - type: "inference.tool_call.start", - data: { callId: "call_9", name: "read_file", index: 0 }, - }); - expect(out[1]).toMatchObject({ - type: "inference.tool_call.delta", - data: { callId: "0", argumentFragment: '{"path":', index: 0 }, - }); - expect(out[2]).toMatchObject({ - type: "inference.tool_call.delta", - data: { argumentFragment: '"a.ts"}', index: 0 }, - }); - }); - - test("text then tool call get distinct block indices", () => { - const out = parse([ - { type: "response.output_text.delta", delta: "hi" }, - { - type: "response.output_item.added", - item: { type: "function_call", id: "fc_1", call_id: "c1", name: "t" }, - }, - ]); - expect(out[0]).toMatchObject({ data: { index: 0 } }); - expect(out[1]).toMatchObject({ data: { index: 1 } }); - }); - - test("emits usage from response.completed only", () => { - const out = parse([ - { - type: "response.completed", - response: { - usage: { - input_tokens: 100, - output_tokens: 20, - input_tokens_details: { cached_tokens: 64 }, - output_tokens_details: { reasoning_tokens: 8 }, - }, - }, - }, - ]); - expect(out[0]).toMatchObject({ - type: "inference.usage", - data: { - usage: { - input: 36, - output: 20, - cacheRead: 64, - cacheWrite: 0, - thinking: 8, - }, - }, - }); - }); - - test("captures encrypted reasoning content as a thinking signature for round-trip", () => { - const out = parse([ - { - type: "response.reasoning_summary_text.delta", - item_id: "rs_1", - delta: "thinking...", - }, - { - type: "response.output_item.done", - item: { type: "reasoning", id: "rs_1", encrypted_content: "ENC_BLOB" }, - }, - ]); - expect(out[0]).toMatchObject({ - type: "inference.thinking.delta", - data: { index: 0 }, - }); - expect(out[1]).toMatchObject({ - type: "inference.block.signature", - data: { - signature: tagSignature(CODEX_RESPONSES_PROVIDER, "ENC_BLOB"), - index: 0, - }, - }); - }); - - test("emits empty thinking delta + signature when done provides encrypted_content with no prior delta (pure-encrypted reasoning)", () => { - // This supports the case where the backend surfaces only the encrypted blob - // (no reasoning_text or summary deltas) and we must still round-trip the - // signature for follow-up turns. - const out = parse([ - { - type: "response.output_item.done", - item: { type: "reasoning", id: "rs_solo", encrypted_content: "ENC" }, - }, - ]); - expect(out).toHaveLength(2); - expect(out[0]).toMatchObject({ - type: "inference.thinking.delta", - data: { token: "", index: 0 }, - }); - expect(out[1]).toMatchObject({ - type: "inference.block.signature", - data: { - signature: tagSignature(CODEX_RESPONSES_PROVIDER, "ENC"), - index: 0, - }, - }); - }); - - test("keys blocks by item_id so interleaved reasoning and tool calls keep distinct indices", () => { - const out = parse([ - { - type: "response.reasoning_summary_text.delta", - item_id: "rs_1", - delta: "a", - }, - { - type: "response.output_item.added", - item: { type: "function_call", id: "fc_1", call_id: "c1", name: "t" }, - }, - { - type: "response.reasoning_summary_text.delta", - item_id: "rs_2", - delta: "b", - }, - ]); - expect(out[0]).toMatchObject({ - type: "inference.thinking.delta", - data: { index: 0 }, - }); - expect(out[1]).toMatchObject({ - type: "inference.tool_call.start", - data: { index: 1 }, - }); - // A second distinct reasoning item gets its own index, not merged into the first. - expect(out[2]).toMatchObject({ - type: "inference.thinking.delta", - data: { index: 2 }, - }); - }); - - test("omits max_output_tokens (the Codex backend rejects it)", () => { - const body = JSON.parse( - adapter().buildRequest([userTurn("x")], "gpt-5-codex", { - maxTokens: 4096, - providerOptions: { [CODEX_SESSION_ID_OPTION]: "s" }, - }).body, - ) as Record; - expect(body["max_output_tokens"]).toBeUndefined(); - }); - - test("ignores lifecycle envelopes", () => { - expect( - parse([ - { type: "response.created", response: {} }, - { type: "response.in_progress" }, - ]), - ).toHaveLength(0); - }); - - test("throws ProtocolMismatchError on a failed response", () => { - const a = adapter(); - expect(() => - a.parseResponse( - JSON.stringify({ - type: "response.failed", - response: { error: { message: "boom" } }, - }), - ), - ).toThrow(/boom/); - }); - - test("throws on malformed JSON", () => { - expect(() => adapter().parseResponse("{not json")).toThrow(); - }); -});