Skip to content

Commit 44297aa

Browse files
Merge foreign reasoning signature drop
2 parents 4d6a6cb + 8d253e0 commit 44297aa

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)