Skip to content

Commit 8d253e0

Browse files
committed
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.
1 parent c423b91 commit 8d253e0

4 files changed

Lines changed: 78 additions & 12 deletions

File tree

src/provider/codex-responses-adapter.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,27 @@ type ResponsesInputItem =
5656
| { type: "function_call_output"; call_id: string; output: string }
5757
| { type: "reasoning"; summary: never[]; encrypted_content: string };
5858

59+
// A thinking block's `signature` is opaque ciphertext a specific backend
60+
// issued for a specific model; only that backend can decrypt it. `turn.model`
61+
// records which model produced the turn, so comparing it against the model
62+
// this request is being built for is enough provenance to tell whether a
63+
// signature is safe to replay — no separate provenance field is needed.
64+
// Switching models means turns from the old model simply stop qualifying, so
65+
// a poisoned history self-heals on the very next request instead of being
66+
// replayed forever.
67+
export function signatureForModel(turn: ConversationTurn, requestModel: string, signature: string): string | undefined {
68+
return turn.model === requestModel ? signature : undefined;
69+
}
70+
5971
// Map one internal turn to zero or more Responses items. Assistant text uses
6072
// `output_text` parts; user/system text uses `input_text`. Tool calls become
6173
// `function_call` items (arguments serialized to a JSON string) and tool
6274
// results become `function_call_output` items. Reasoning blocks are echoed
6375
// back only when they carry the opaque `encrypted_content` the backend issued
64-
// (held in a thinking block's signature), which is required for multi-turn
65-
// reasoning continuity.
66-
function toResponsesItems(turn: ConversationTurn): ResponsesInputItem[] {
76+
// (held in a thinking block's signature) AND that backend is the one this
77+
// request is going to — replaying it to a different provider gets a 400 it
78+
// cannot recover from.
79+
function toResponsesItems(turn: ConversationTurn, requestModel: string): ResponsesInputItem[] {
6780
const items: ResponsesInputItem[] = [];
6881
const textKind: "input_text" | "output_text" = turn.role === "assistant" ? "output_text" : "input_text";
6982
const textParts: ResponsesContentPart[] = [];
@@ -99,7 +112,10 @@ function toResponsesItems(turn: ConversationTurn): ResponsesInputItem[] {
99112
items.push({ type: "function_call_output", call_id: block.callId, output: toolResultText(block) });
100113
} else if (block.type === "thinking" && typeof block.signature === "string" && block.signature.length > 0) {
101114
flushText();
102-
items.push({ type: "reasoning", summary: [], encrypted_content: block.signature });
115+
const encryptedContent = signatureForModel(turn, requestModel, block.signature);
116+
if (encryptedContent !== undefined) {
117+
items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent });
118+
}
103119
}
104120
}
105121
flushText();
@@ -154,7 +170,7 @@ function buildRequest(
154170
model: string,
155171
options: InferenceOptions,
156172
): BuiltRequest {
157-
const conversation = messages.flatMap(toResponsesItems);
173+
const conversation = messages.flatMap((turn) => toResponsesItems(turn, model));
158174
// Corbits Code's prompt cannot live in `instructions` (the backend pins that to
159175
// the official Codex prompt), so it leads the input as a developer message.
160176
const input =

src/provider/grok-responses-adapter.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
XAI_CLIENT_VERSION,
1616
XAI_USER_AGENT,
1717
} from "../auth/xai/constants.js";
18-
import { createResponsesBlockIndexer, parseResponse } from "./codex-responses-adapter.js";
18+
import { createResponsesBlockIndexer, parseResponse, signatureForModel } from "./codex-responses-adapter.js";
1919

2020
// Adapter for the grok-cli OAuth proxy (cli-chat-proxy.grok.com), which serves
2121
// the OpenAI Responses API at /v1/responses. The request shape mirrors the grok
@@ -52,7 +52,7 @@ function toolResultText(block: Extract<ContentBlock, { type: "tool_result" }>):
5252
// Map one internal turn to Responses items. Text-only messages keep the string
5353
// shape grok sends; messages with image blocks switch to Responses content parts
5454
// so the model receives the actual pixels instead of only a text placeholder.
55-
function toResponsesItems(turn: ConversationTurn): ResponsesInputItem[] {
55+
function toResponsesItems(turn: ConversationTurn, requestModel: string): ResponsesInputItem[] {
5656
const items: ResponsesInputItem[] = [];
5757
const role = turn.role;
5858
const parts: ResponsesInputContentPart[] = [];
@@ -95,7 +95,10 @@ function toResponsesItems(turn: ConversationTurn): ResponsesInputItem[] {
9595
items.push({ type: "function_call_output", call_id: block.callId, output: toolResultText(block) });
9696
} else if (block.type === "thinking" && typeof block.signature === "string" && block.signature.length > 0) {
9797
flushMessage();
98-
items.push({ type: "reasoning", summary: [], encrypted_content: block.signature });
98+
const encryptedContent = signatureForModel(turn, requestModel, block.signature);
99+
if (encryptedContent !== undefined) {
100+
items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent });
101+
}
99102
}
100103
}
101104
flushMessage();
@@ -135,7 +138,7 @@ function buildRequest(
135138
model: string,
136139
options: InferenceOptions,
137140
): BuiltRequest {
138-
const conversation = dedupeToolOutputs(messages.flatMap(toResponsesItems));
141+
const conversation = dedupeToolOutputs(messages.flatMap((turn) => toResponsesItems(turn, model)));
139142
const systemMessage: ResponsesInputItem | undefined =
140143
options.systemPrompt !== undefined
141144
? { type: "message", role: "system", content: options.systemPrompt }

src/provider/openai-responses-adapter.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
createResponsesBlockIndexer,
1414
isResponsesStreamTerminal,
1515
parseResponse,
16+
signatureForModel,
1617
} from "./codex-responses-adapter.js";
1718

1819
// Generic OpenAI Responses API adapter (POST /responses). Used by OpenCode Go
@@ -41,7 +42,7 @@ function toolResultText(block: Extract<ContentBlock, { type: "tool_result" }>):
4142
return parts.join("");
4243
}
4344

44-
function toResponsesItems(turn: ConversationTurn): ResponsesInputItem[] {
45+
function toResponsesItems(turn: ConversationTurn, requestModel: string): ResponsesInputItem[] {
4546
const items: ResponsesInputItem[] = [];
4647
const role = turn.role;
4748
const parts: ResponsesInputContentPart[] = [];
@@ -90,7 +91,10 @@ function toResponsesItems(turn: ConversationTurn): ResponsesInputItem[] {
9091
items.push({ type: "function_call_output", call_id: block.callId, output: toolResultText(block) });
9192
} else if (block.type === "thinking" && typeof block.signature === "string" && block.signature.length > 0) {
9293
flushMessage();
93-
items.push({ type: "reasoning", summary: [], encrypted_content: block.signature });
94+
const encryptedContent = signatureForModel(turn, requestModel, block.signature);
95+
if (encryptedContent !== undefined) {
96+
items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent });
97+
}
9498
}
9599
}
96100
flushMessage();
@@ -125,7 +129,7 @@ function buildRequest(
125129
model: string,
126130
options: InferenceOptions,
127131
): BuiltRequest {
128-
const conversation = dedupeToolOutputs(messages.flatMap(toResponsesItems));
132+
const conversation = dedupeToolOutputs(messages.flatMap((turn) => toResponsesItems(turn, model)));
129133
const systemMessage: ResponsesInputItem | undefined =
130134
options.systemPrompt !== undefined
131135
? { type: "message", role: "system", content: options.systemPrompt }

tests/unit/codex-responses-adapter.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ describe("codex-responses buildRequest", () => {
110110
userTurn("solve the hard problem"),
111111
{
112112
role: "assistant",
113+
model: "gpt-5-codex",
113114
timestamp: 0,
114115
content: [
115116
{ type: "thinking", thinking: "internal steps...", signature: "ENC_BLOB_123" },
@@ -125,6 +126,48 @@ describe("codex-responses buildRequest", () => {
125126
]);
126127
});
127128

129+
test("drops a reasoning signature issued for a different model after a provider switch", () => {
130+
// The signature was minted by grok-4.5; the request now targets a Codex
131+
// model. Replaying it would 400 with an undecryptable-content error, so
132+
// the reasoning item must be omitted while the surrounding turn survives.
133+
const turns: ConversationTurn[] = [
134+
userTurn("solve the hard problem"),
135+
{
136+
role: "assistant",
137+
model: "grok-4.5",
138+
timestamp: 0,
139+
content: [
140+
{ type: "thinking", thinking: "internal steps...", signature: "FOREIGN_BLOB" },
141+
{ type: "text", text: "The answer is 42." },
142+
],
143+
},
144+
];
145+
const body = JSON.parse(adapter().buildRequest(turns, "gpt-5-codex", baseOptions).body) as Record<string, unknown>;
146+
expect(body["input"]).toEqual([
147+
{ type: "message", role: "user", content: [{ type: "input_text", text: "solve the hard problem" }] },
148+
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "The answer is 42." }] },
149+
]);
150+
});
151+
152+
test("recovers an already-poisoned session: a foreign signature is dropped on every subsequent request", () => {
153+
const poisonedHistory: ConversationTurn[] = [
154+
userTurn("turn 1"),
155+
{
156+
role: "assistant",
157+
model: "grok-4.5",
158+
timestamp: 0,
159+
content: [{ type: "thinking", thinking: "...", signature: "FOREIGN_BLOB" }, { type: "text", text: "ok" }],
160+
},
161+
userTurn("turn 2"),
162+
];
163+
const firstRetry = JSON.parse(adapter().buildRequest(poisonedHistory, "gpt-5-codex", baseOptions).body) as Record<string, unknown>;
164+
const secondRetry = JSON.parse(adapter().buildRequest(poisonedHistory, "gpt-5-codex", baseOptions).body) as Record<string, unknown>;
165+
for (const body of [firstRetry, secondRetry]) {
166+
const input = body["input"] as Array<Record<string, unknown>>;
167+
expect(input.some((item) => item["type"] === "reasoning")).toBe(false);
168+
}
169+
});
170+
128171
test("omits the account-id header when no account id is supplied", () => {
129172
const req = adapter().buildRequest([userTurn("x")], "gpt-5-codex", { providerOptions: { [CODEX_SESSION_ID_OPTION]: "s" } });
130173
expect(req.headers["chatgpt-account-id"]).toBeUndefined();

0 commit comments

Comments
 (0)