From 8d253e0da76a672736a2d06e21efea3ff2002292 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 7 Aug 2026 00:09:32 -0700 Subject: [PATCH] Drop reasoning signatures issued by a different model The Responses-style adapters (Codex, Grok, generic OpenAI Responses) echo a thinking block's encrypted signature back to keep reasoning continuity within one provider. The signature is opaque ciphertext a specific backend issued for a specific model, so replaying it after an operator switches models sends one provider's blob to another, which cannot decrypt it and 400s every subsequent turn. Each assistant turn already records which model produced it. Compare that against the model the current request targets and only replay a signature when they match; otherwise the reasoning item is dropped and the rest of the turn is sent unchanged. Because the check runs at every request, an already-poisoned history heals itself on the next turn instead of failing forever. --- src/provider/codex-responses-adapter.ts | 26 ++++++++++--- src/provider/grok-responses-adapter.ts | 11 ++++-- src/provider/openai-responses-adapter.ts | 10 +++-- tests/unit/codex-responses-adapter.test.ts | 43 ++++++++++++++++++++++ 4 files changed, 78 insertions(+), 12 deletions(-) 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();