diff --git a/src/provider/codex-responses-adapter.ts b/src/provider/codex-responses-adapter.ts index 06d36d9b3..2d3b2ffb3 100644 --- a/src/provider/codex-responses-adapter.ts +++ b/src/provider/codex-responses-adapter.ts @@ -56,14 +56,27 @@ type ResponsesInputItem = | { 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. `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; +} + // 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), which is required for multi-turn -// reasoning continuity. -function toResponsesItems(turn: ConversationTurn): ResponsesInputItem[] { +// (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[] { const items: ResponsesInputItem[] = []; const textKind: "input_text" | "output_text" = turn.role === "assistant" ? "output_text" : "input_text"; const textParts: ResponsesContentPart[] = []; @@ -99,7 +112,10 @@ function toResponsesItems(turn: ConversationTurn): ResponsesInputItem[] { 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(); - items.push({ type: "reasoning", summary: [], encrypted_content: block.signature }); + const encryptedContent = signatureForModel(turn, requestModel, block.signature); + if (encryptedContent !== undefined) { + items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent }); + } } } flushText(); @@ -154,7 +170,7 @@ function buildRequest( model: string, options: InferenceOptions, ): BuiltRequest { - const conversation = messages.flatMap(toResponsesItems); + const conversation = messages.flatMap((turn) => toResponsesItems(turn, model)); // 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 = diff --git a/src/provider/grok-responses-adapter.ts b/src/provider/grok-responses-adapter.ts index 5dcc5ba94..c0787925f 100644 --- a/src/provider/grok-responses-adapter.ts +++ b/src/provider/grok-responses-adapter.ts @@ -15,7 +15,7 @@ import { XAI_CLIENT_VERSION, XAI_USER_AGENT, } from "../auth/xai/constants.js"; -import { createResponsesBlockIndexer, parseResponse } from "./codex-responses-adapter.js"; +import { createResponsesBlockIndexer, parseResponse, signatureForModel } from "./codex-responses-adapter.js"; // Adapter for the grok-cli OAuth proxy (cli-chat-proxy.grok.com), which serves // the OpenAI Responses API at /v1/responses. The request shape mirrors the grok @@ -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): ResponsesInputItem[] { +function toResponsesItems(turn: ConversationTurn, requestModel: string): ResponsesInputItem[] { const items: ResponsesInputItem[] = []; const role = turn.role; const parts: ResponsesInputContentPart[] = []; @@ -95,7 +95,10 @@ function toResponsesItems(turn: ConversationTurn): ResponsesInputItem[] { 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(); - items.push({ type: "reasoning", summary: [], encrypted_content: block.signature }); + const encryptedContent = signatureForModel(turn, requestModel, block.signature); + if (encryptedContent !== undefined) { + items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent }); + } } } flushMessage(); @@ -135,7 +138,7 @@ function buildRequest( model: string, options: InferenceOptions, ): BuiltRequest { - const conversation = dedupeToolOutputs(messages.flatMap(toResponsesItems)); + const conversation = dedupeToolOutputs(messages.flatMap((turn) => toResponsesItems(turn, model))); const systemMessage: ResponsesInputItem | undefined = options.systemPrompt !== undefined ? { type: "message", role: "system", content: options.systemPrompt } diff --git a/src/provider/openai-responses-adapter.ts b/src/provider/openai-responses-adapter.ts index e68ce9494..27661cb92 100644 --- a/src/provider/openai-responses-adapter.ts +++ b/src/provider/openai-responses-adapter.ts @@ -13,6 +13,7 @@ import { createResponsesBlockIndexer, isResponsesStreamTerminal, parseResponse, + signatureForModel, } from "./codex-responses-adapter.js"; // Generic OpenAI Responses API adapter (POST /responses). Used by OpenCode Go @@ -41,7 +42,7 @@ function toolResultText(block: Extract): return parts.join(""); } -function toResponsesItems(turn: ConversationTurn): ResponsesInputItem[] { +function toResponsesItems(turn: ConversationTurn, requestModel: string): ResponsesInputItem[] { const items: ResponsesInputItem[] = []; const role = turn.role; const parts: ResponsesInputContentPart[] = []; @@ -90,7 +91,10 @@ function toResponsesItems(turn: ConversationTurn): ResponsesInputItem[] { 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(); - items.push({ type: "reasoning", summary: [], encrypted_content: block.signature }); + const encryptedContent = signatureForModel(turn, requestModel, block.signature); + if (encryptedContent !== undefined) { + items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent }); + } } } flushMessage(); @@ -125,7 +129,7 @@ function buildRequest( model: string, options: InferenceOptions, ): BuiltRequest { - const conversation = dedupeToolOutputs(messages.flatMap(toResponsesItems)); + const conversation = dedupeToolOutputs(messages.flatMap((turn) => toResponsesItems(turn, model))); const systemMessage: ResponsesInputItem | undefined = options.systemPrompt !== undefined ? { type: "message", role: "system", content: options.systemPrompt } diff --git a/tests/unit/codex-responses-adapter.test.ts b/tests/unit/codex-responses-adapter.test.ts index ee933a7b7..4ca6ea417 100644 --- a/tests/unit/codex-responses-adapter.test.ts +++ b/tests/unit/codex-responses-adapter.test.ts @@ -110,6 +110,7 @@ describe("codex-responses buildRequest", () => { userTurn("solve the hard problem"), { role: "assistant", + model: "gpt-5-codex", timestamp: 0, content: [ { type: "thinking", thinking: "internal steps...", signature: "ENC_BLOB_123" }, @@ -125,6 +126,48 @@ describe("codex-responses buildRequest", () => { ]); }); + 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: "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: "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 Array>; + expect(input.some((item) => item["type"] === "reasoning")).toBe(false); + } + }); + 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();