diff --git a/src/provider/codex-responses-adapter.ts b/src/provider/codex-responses-adapter.ts index 2d3b2ffb3..1ef11a251 100644 --- a/src/provider/codex-responses-adapter.ts +++ b/src/provider/codex-responses-adapter.ts @@ -57,15 +57,44 @@ type ResponsesInputItem = | { 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. `turn.model` -// records which model produced the turn, so comparing it against the model -// this request is being built for is enough provenance to tell whether a -// signature is safe to replay — no separate provenance field is needed. -// Switching models means turns from the old model simply stop qualifying, so -// a poisoned history self-heals on the very next request instead of being -// replayed forever. -export function signatureForModel(turn: ConversationTurn, requestModel: string, signature: string): string | undefined { - return turn.model === requestModel ? signature : undefined; +// 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 { + if (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 @@ -76,7 +105,7 @@ export function signatureForModel(turn: ConversationTurn, requestModel: string, // (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. -function toResponsesItems(turn: ConversationTurn, requestModel: string): ResponsesInputItem[] { +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[] = []; @@ -112,7 +141,7 @@ function toResponsesItems(turn: ConversationTurn, requestModel: string): Respons 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, block.signature); + const encryptedContent = signatureForModel(turn, requestModel, requestProvider, block.signature); if (encryptedContent !== undefined) { items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent }); } @@ -169,8 +198,9 @@ function buildRequest( messages: ConversationTurn[], model: string, options: InferenceOptions, + requestProvider: string, ): BuiltRequest { - const conversation = messages.flatMap((turn) => toResponsesItems(turn, model)); + const conversation = messages.flatMap((turn) => toResponsesItems(turn, model, requestProvider)); // Corbits Code's prompt cannot live in `instructions` (the backend pins that to // the official Codex prompt), so it leads the input as a developer message. const input = @@ -377,7 +407,7 @@ export function parseResponse( events.push({ type: "inference.thinking.signature", seq, - data: { signature: item["encrypted_content"], index }, + data: { signature: tagSignature(source.provider, item["encrypted_content"] as string), index }, }); } return events; @@ -457,7 +487,7 @@ export function createCodexResponsesAdapter(source: LastCycleSource): ProviderAd items: new Map(), }; return { - buildRequest, + buildRequest: (messages, model, options) => buildRequest(messages, model, options, source.provider), parseResponse: (sseData) => parseResponse(sseData, indexer, source), isStreamTerminal: isResponsesStreamTerminal, }; diff --git a/src/provider/grok-responses-adapter.ts b/src/provider/grok-responses-adapter.ts index c0787925f..f99962526 100644 --- a/src/provider/grok-responses-adapter.ts +++ b/src/provider/grok-responses-adapter.ts @@ -52,7 +52,7 @@ function toolResultText(block: Extract): // Map one internal turn to Responses items. Text-only messages keep the string // shape grok sends; messages with image blocks switch to Responses content parts // so the model receives the actual pixels instead of only a text placeholder. -function toResponsesItems(turn: ConversationTurn, requestModel: string): ResponsesInputItem[] { +function toResponsesItems(turn: ConversationTurn, requestModel: string, requestProvider: string): ResponsesInputItem[] { const items: ResponsesInputItem[] = []; const role = turn.role; const parts: ResponsesInputContentPart[] = []; @@ -95,7 +95,7 @@ function toResponsesItems(turn: ConversationTurn, requestModel: string): Respons 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) { flushMessage(); - const encryptedContent = signatureForModel(turn, requestModel, block.signature); + const encryptedContent = signatureForModel(turn, requestModel, requestProvider, block.signature); if (encryptedContent !== undefined) { items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent }); } @@ -137,8 +137,9 @@ function buildRequest( messages: ConversationTurn[], model: string, options: InferenceOptions, + requestProvider: string, ): BuiltRequest { - const conversation = dedupeToolOutputs(messages.flatMap((turn) => toResponsesItems(turn, model))); + const conversation = dedupeToolOutputs(messages.flatMap((turn) => toResponsesItems(turn, model, requestProvider))); const systemMessage: ResponsesInputItem | undefined = options.systemPrompt !== undefined ? { type: "message", role: "system", content: options.systemPrompt } @@ -177,7 +178,7 @@ function buildRequest( export function createGrokResponsesAdapter(source: LastCycleSource): ProviderAdapter { const indexer = createResponsesBlockIndexer(); return { - buildRequest, + buildRequest: (messages, model, options) => buildRequest(messages, model, options, source.provider), parseResponse: (sseData) => parseResponse(sseData, indexer, source, GROK_RESPONSES_PROVIDER), }; } diff --git a/src/provider/openai-responses-adapter.ts b/src/provider/openai-responses-adapter.ts index 27661cb92..c9d7548d4 100644 --- a/src/provider/openai-responses-adapter.ts +++ b/src/provider/openai-responses-adapter.ts @@ -42,7 +42,7 @@ function toolResultText(block: Extract): return parts.join(""); } -function toResponsesItems(turn: ConversationTurn, requestModel: string): ResponsesInputItem[] { +function toResponsesItems(turn: ConversationTurn, requestModel: string, requestProvider: string): ResponsesInputItem[] { const items: ResponsesInputItem[] = []; const role = turn.role; const parts: ResponsesInputContentPart[] = []; @@ -91,7 +91,7 @@ function toResponsesItems(turn: ConversationTurn, requestModel: string): Respons 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) { flushMessage(); - const encryptedContent = signatureForModel(turn, requestModel, block.signature); + const encryptedContent = signatureForModel(turn, requestModel, requestProvider, block.signature); if (encryptedContent !== undefined) { items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent }); } @@ -128,8 +128,9 @@ function buildRequest( messages: ConversationTurn[], model: string, options: InferenceOptions, + requestProvider: string, ): BuiltRequest { - const conversation = dedupeToolOutputs(messages.flatMap((turn) => toResponsesItems(turn, model))); + const conversation = dedupeToolOutputs(messages.flatMap((turn) => toResponsesItems(turn, model, requestProvider))); const systemMessage: ResponsesInputItem | undefined = options.systemPrompt !== undefined ? { type: "message", role: "system", content: options.systemPrompt } @@ -166,7 +167,7 @@ function buildRequest( export function createOpenAIResponsesAdapter(source: LastCycleSource): ProviderAdapter { const indexer = createResponsesBlockIndexer(); return { - buildRequest, + buildRequest: (messages, model, options) => buildRequest(messages, model, options, source.provider), parseResponse: (sseData) => parseResponse(sseData, indexer, source, OPENAI_RESPONSES_PROVIDER), isStreamTerminal: isResponsesStreamTerminal, }; diff --git a/tests/unit/codex-responses-adapter.test.ts b/tests/unit/codex-responses-adapter.test.ts index 4ca6ea417..6f588a596 100644 --- a/tests/unit/codex-responses-adapter.test.ts +++ b/tests/unit/codex-responses-adapter.test.ts @@ -1,9 +1,13 @@ 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-adapter.js"; import { BEARER_CREDENTIAL_SENTINEL } from "@intx/inference"; import type { ConversationTurn, InferenceOptions, LastCycleSource } from "@intx/types/runtime"; @@ -113,7 +117,7 @@ describe("codex-responses buildRequest", () => { model: "gpt-5-codex", timestamp: 0, content: [ - { type: "thinking", thinking: "internal steps...", signature: "ENC_BLOB_123" }, + { type: "thinking", thinking: "internal steps...", signature: tagSignature(CODEX_RESPONSES_PROVIDER, "ENC_BLOB_123") }, { type: "text", text: "The answer is 42." }, ], }, @@ -126,6 +130,28 @@ describe("codex-responses buildRequest", () => { ]); }); + 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 @@ -137,7 +163,31 @@ describe("codex-responses buildRequest", () => { model: "grok-4.5", timestamp: 0, content: [ - { type: "thinking", thinking: "internal steps...", signature: "FOREIGN_BLOB" }, + { 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." }, ], }, @@ -156,7 +206,10 @@ describe("codex-responses buildRequest", () => { role: "assistant", model: "grok-4.5", timestamp: 0, - content: [{ type: "thinking", thinking: "...", signature: "FOREIGN_BLOB" }, { type: "text", text: "ok" }], + content: [ + { type: "thinking", thinking: "...", signature: tagSignature(GROK_RESPONSES_PROVIDER, "FOREIGN_BLOB") }, + { type: "text", text: "ok" }, + ], }, userTurn("turn 2"), ]; @@ -168,6 +221,43 @@ describe("codex-responses buildRequest", () => { } }); + 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 Array>; + 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(); @@ -233,7 +323,10 @@ describe("codex-responses parseResponse", () => { { 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.thinking.signature", data: { signature: "ENC_BLOB", index: 0 } }); + expect(out[1]).toMatchObject({ + type: "inference.thinking.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)", () => { @@ -245,7 +338,10 @@ describe("codex-responses parseResponse", () => { ]); expect(out).toHaveLength(2); expect(out[0]).toMatchObject({ type: "inference.thinking.delta", data: { token: "", index: 0 } }); - expect(out[1]).toMatchObject({ type: "inference.thinking.signature", data: { signature: "ENC", index: 0 } }); + expect(out[1]).toMatchObject({ + type: "inference.thinking.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", () => { diff --git a/tests/unit/codex-sse-fixtures.test.ts b/tests/unit/codex-sse-fixtures.test.ts index fb1624cfd..b675caffa 100644 --- a/tests/unit/codex-sse-fixtures.test.ts +++ b/tests/unit/codex-sse-fixtures.test.ts @@ -10,6 +10,7 @@ import { join } from "node:path"; import { createCodexResponsesAdapter, isResponsesStreamTerminal, + tagSignature, } from "../../src/provider/codex-responses-adapter.js"; import type { InferenceEvent, LastCycleSource } from "@intx/types/runtime"; import { ProtocolMismatchError } from "@intx/inference"; @@ -95,7 +96,7 @@ describe("codex-sse fixtures (golden parse)", () => { }); expect(out[3]).toMatchObject({ type: "inference.thinking.signature", - data: { signature: "ENC_FIXTURE_BLOB_NOT_REAL", index: 0 }, + data: { signature: tagSignature(SOURCE.provider, "ENC_FIXTURE_BLOB_NOT_REAL"), index: 0 }, }); expect(out[4]).toMatchObject({ type: "inference.text.delta",