Skip to content

Commit 40e6c0d

Browse files
committed
Key reasoning-signature replay on the issuing provider
signatureForModel compared only turn.model against the current request's model, but provider is a fixed adapter tag while model is arbitrary catalog/user-supplied text — two distinct backends can declare the same literal model name (proxy aliases, two OpenAI-compatible endpoints both configured as gpt-4o), and the equality check would treat a foreign signature as safe to replay, reproducing the original decrypt-failure bug. ConversationTurn carries no field recording which provider produced it, so provenance now rides inside the signature string itself: capture tags it provider:ciphertext, and replay only unwraps the ciphertext when both the tagged provider and the model match. Keying on provider rather than the per-account source id means a live account switch on the same backend (two ChatGPT accounts through the same Codex service) still preserves reasoning continuity, since the decrypting backend is shared across accounts.
1 parent 98a417b commit 40e6c0d

5 files changed

Lines changed: 102 additions & 25 deletions

File tree

src/provider/codex-responses-adapter.ts

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,44 @@ type ResponsesInputItem =
5757
| { type: "reasoning"; summary: never[]; encrypted_content: string };
5858

5959
// 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;
60+
// issued for a specific model; only that backend can decrypt it. `model` is
61+
// arbitrary catalog/user-supplied text — nothing stops two distinct backends
62+
// (proxy aliases, two OpenAI-compatible endpoints) from declaring the same
63+
// literal model name, so comparing `turn.model` alone treats a foreign
64+
// signature as safe to replay. `ConversationTurn` carries no field for which
65+
// provider produced it, so provenance rides inside the signature string
66+
// itself: capture tags it `<provider>:<ciphertext>` (see `tagSignature`),
67+
// and replay only unwraps the ciphertext when both the tagged provider and
68+
// the model match the current request.
69+
//
70+
// Provider, not the per-account source id, is the unit of decrypt
71+
// capability — a Codex backend shared across ChatGPT accounts can decrypt a
72+
// signature issued to any of them, so keying on provider (rather than source
73+
// id) is what lets an account switch keep reasoning continuity while a
74+
// genuine cross-provider collision still gets dropped. A poisoned history
75+
// self-heals on the next request instead of being replayed forever.
76+
const SIGNATURE_TAG_SEPARATOR = ":";
77+
78+
export function tagSignature(provider: string, encryptedContent: string): string {
79+
return `${provider}${SIGNATURE_TAG_SEPARATOR}${encryptedContent}`;
80+
}
81+
82+
function untagSignature(tagged: string): { provider: string; encryptedContent: string } | undefined {
83+
const idx = tagged.indexOf(SIGNATURE_TAG_SEPARATOR);
84+
if (idx === -1) return undefined;
85+
return { provider: tagged.slice(0, idx), encryptedContent: tagged.slice(idx + 1) };
86+
}
87+
88+
export function signatureForModel(
89+
turn: ConversationTurn,
90+
requestModel: string,
91+
requestProvider: string,
92+
signature: string,
93+
): string | undefined {
94+
if (turn.model !== requestModel) return undefined;
95+
const tagged = untagSignature(signature);
96+
if (tagged === undefined) return undefined;
97+
return tagged.provider === requestProvider ? tagged.encryptedContent : undefined;
6998
}
7099

71100
// Map one internal turn to zero or more Responses items. Assistant text uses
@@ -76,7 +105,7 @@ export function signatureForModel(turn: ConversationTurn, requestModel: string,
76105
// (held in a thinking block's signature) AND that backend is the one this
77106
// request is going to — replaying it to a different provider gets a 400 it
78107
// cannot recover from.
79-
function toResponsesItems(turn: ConversationTurn, requestModel: string): ResponsesInputItem[] {
108+
function toResponsesItems(turn: ConversationTurn, requestModel: string, requestProvider: string): ResponsesInputItem[] {
80109
const items: ResponsesInputItem[] = [];
81110
const textKind: "input_text" | "output_text" = turn.role === "assistant" ? "output_text" : "input_text";
82111
const textParts: ResponsesContentPart[] = [];
@@ -112,7 +141,7 @@ function toResponsesItems(turn: ConversationTurn, requestModel: string): Respons
112141
items.push({ type: "function_call_output", call_id: block.callId, output: toolResultText(block) });
113142
} else if (block.type === "thinking" && typeof block.signature === "string" && block.signature.length > 0) {
114143
flushText();
115-
const encryptedContent = signatureForModel(turn, requestModel, block.signature);
144+
const encryptedContent = signatureForModel(turn, requestModel, requestProvider, block.signature);
116145
if (encryptedContent !== undefined) {
117146
items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent });
118147
}
@@ -169,8 +198,9 @@ function buildRequest(
169198
messages: ConversationTurn[],
170199
model: string,
171200
options: InferenceOptions,
201+
requestProvider: string,
172202
): BuiltRequest {
173-
const conversation = messages.flatMap((turn) => toResponsesItems(turn, model));
203+
const conversation = messages.flatMap((turn) => toResponsesItems(turn, model, requestProvider));
174204
// Corbits Code's prompt cannot live in `instructions` (the backend pins that to
175205
// the official Codex prompt), so it leads the input as a developer message.
176206
const input =
@@ -377,7 +407,7 @@ export function parseResponse(
377407
events.push({
378408
type: "inference.thinking.signature",
379409
seq,
380-
data: { signature: item["encrypted_content"], index },
410+
data: { signature: tagSignature(source.provider, item["encrypted_content"] as string), index },
381411
});
382412
}
383413
return events;
@@ -457,7 +487,7 @@ export function createCodexResponsesAdapter(source: LastCycleSource): ProviderAd
457487
items: new Map<string, { index: number; kind: CodexBlockKind }>(),
458488
};
459489
return {
460-
buildRequest,
490+
buildRequest: (messages, model, options) => buildRequest(messages, model, options, source.provider),
461491
parseResponse: (sseData) => parseResponse(sseData, indexer, source),
462492
isStreamTerminal: isResponsesStreamTerminal,
463493
};

src/provider/grok-responses-adapter.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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, requestModel: string): ResponsesInputItem[] {
55+
function toResponsesItems(turn: ConversationTurn, requestModel: string, requestProvider: string): ResponsesInputItem[] {
5656
const items: ResponsesInputItem[] = [];
5757
const role = turn.role;
5858
const parts: ResponsesInputContentPart[] = [];
@@ -95,7 +95,7 @@ function toResponsesItems(turn: ConversationTurn, requestModel: string): Respons
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-
const encryptedContent = signatureForModel(turn, requestModel, block.signature);
98+
const encryptedContent = signatureForModel(turn, requestModel, requestProvider, block.signature);
9999
if (encryptedContent !== undefined) {
100100
items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent });
101101
}
@@ -137,8 +137,9 @@ function buildRequest(
137137
messages: ConversationTurn[],
138138
model: string,
139139
options: InferenceOptions,
140+
requestProvider: string,
140141
): BuiltRequest {
141-
const conversation = dedupeToolOutputs(messages.flatMap((turn) => toResponsesItems(turn, model)));
142+
const conversation = dedupeToolOutputs(messages.flatMap((turn) => toResponsesItems(turn, model, requestProvider)));
142143
const systemMessage: ResponsesInputItem | undefined =
143144
options.systemPrompt !== undefined
144145
? { type: "message", role: "system", content: options.systemPrompt }
@@ -177,7 +178,7 @@ function buildRequest(
177178
export function createGrokResponsesAdapter(source: LastCycleSource): ProviderAdapter {
178179
const indexer = createResponsesBlockIndexer();
179180
return {
180-
buildRequest,
181+
buildRequest: (messages, model, options) => buildRequest(messages, model, options, source.provider),
181182
parseResponse: (sseData) => parseResponse(sseData, indexer, source, GROK_RESPONSES_PROVIDER),
182183
};
183184
}

src/provider/openai-responses-adapter.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ function toolResultText(block: Extract<ContentBlock, { type: "tool_result" }>):
4242
return parts.join("");
4343
}
4444

45-
function toResponsesItems(turn: ConversationTurn, requestModel: string): ResponsesInputItem[] {
45+
function toResponsesItems(turn: ConversationTurn, requestModel: string, requestProvider: string): ResponsesInputItem[] {
4646
const items: ResponsesInputItem[] = [];
4747
const role = turn.role;
4848
const parts: ResponsesInputContentPart[] = [];
@@ -91,7 +91,7 @@ function toResponsesItems(turn: ConversationTurn, requestModel: string): Respons
9191
items.push({ type: "function_call_output", call_id: block.callId, output: toolResultText(block) });
9292
} else if (block.type === "thinking" && typeof block.signature === "string" && block.signature.length > 0) {
9393
flushMessage();
94-
const encryptedContent = signatureForModel(turn, requestModel, block.signature);
94+
const encryptedContent = signatureForModel(turn, requestModel, requestProvider, block.signature);
9595
if (encryptedContent !== undefined) {
9696
items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent });
9797
}
@@ -128,8 +128,9 @@ function buildRequest(
128128
messages: ConversationTurn[],
129129
model: string,
130130
options: InferenceOptions,
131+
requestProvider: string,
131132
): BuiltRequest {
132-
const conversation = dedupeToolOutputs(messages.flatMap((turn) => toResponsesItems(turn, model)));
133+
const conversation = dedupeToolOutputs(messages.flatMap((turn) => toResponsesItems(turn, model, requestProvider)));
133134
const systemMessage: ResponsesInputItem | undefined =
134135
options.systemPrompt !== undefined
135136
? { type: "message", role: "system", content: options.systemPrompt }
@@ -166,7 +167,7 @@ function buildRequest(
166167
export function createOpenAIResponsesAdapter(source: LastCycleSource): ProviderAdapter {
167168
const indexer = createResponsesBlockIndexer();
168169
return {
169-
buildRequest,
170+
buildRequest: (messages, model, options) => buildRequest(messages, model, options, source.provider),
170171
parseResponse: (sseData) => parseResponse(sseData, indexer, source, OPENAI_RESPONSES_PROVIDER),
171172
isStreamTerminal: isResponsesStreamTerminal,
172173
};

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

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { test, expect, describe } from "bun:test";
22
import {
33
createCodexResponsesAdapter,
44
tagSignature,
5+
signatureForModel,
56
CODEX_ACCOUNT_ID_OPTION,
67
CODEX_SESSION_ID_OPTION,
78
CODEX_RESPONSES_PROVIDER,
@@ -220,6 +221,43 @@ describe("codex-responses buildRequest", () => {
220221
}
221222
});
222223

224+
test("drops a bare, untagged legacy signature instead of misparsing it as ciphertext", () => {
225+
// Signatures captured before this change carry no "<provider>:" prefix.
226+
// untagSignature must recognize the absence of a separator and refuse to
227+
// treat any part of the raw string as ciphertext, rather than replaying
228+
// a truncated or garbled blob the backend cannot decrypt.
229+
const turns: ConversationTurn[] = [
230+
userTurn("solve the hard problem"),
231+
{
232+
role: "assistant",
233+
model: "gpt-5-codex",
234+
timestamp: 0,
235+
content: [
236+
{ type: "thinking", thinking: "internal steps...", signature: "QUJDREVGRzEyMzQ1Njc4OTAtXy8rPQ==" },
237+
{ type: "text", text: "The answer is 42." },
238+
],
239+
},
240+
];
241+
const body = JSON.parse(adapter().buildRequest(turns, "gpt-5-codex", baseOptions).body) as Record<string, unknown>;
242+
const input = body["input"] as Array<Record<string, unknown>>;
243+
expect(input.some((item) => item["type"] === "reasoning")).toBe(false);
244+
});
245+
246+
test("signatureForModel returns undefined for an untagged signature", () => {
247+
const turn: ConversationTurn = { role: "assistant", model: "gpt-5-codex", timestamp: 0, content: [] };
248+
expect(signatureForModel(turn, "gpt-5-codex", CODEX_RESPONSES_PROVIDER, "QUJDREVGRzEyMzQ1Njc4OTAtXy8rPQ==")).toBeUndefined();
249+
});
250+
251+
test("tagSignature/signatureForModel round-trips ciphertext containing embedded colons byte-exact", () => {
252+
// untagSignature splits on the FIRST colon (indexOf, not split(":")),
253+
// so ciphertext that itself contains colons must survive intact. A
254+
// naive split(":")[1] would truncate this to "part2".
255+
const ciphertext = "part1:part2:part3==";
256+
const turn: ConversationTurn = { role: "assistant", model: "gpt-5-codex", timestamp: 0, content: [] };
257+
const tagged = tagSignature(CODEX_RESPONSES_PROVIDER, ciphertext);
258+
expect(signatureForModel(turn, "gpt-5-codex", CODEX_RESPONSES_PROVIDER, tagged)).toBe(ciphertext);
259+
});
260+
223261
test("omits the account-id header when no account id is supplied", () => {
224262
const req = adapter().buildRequest([userTurn("x")], "gpt-5-codex", { providerOptions: { [CODEX_SESSION_ID_OPTION]: "s" } });
225263
expect(req.headers["chatgpt-account-id"]).toBeUndefined();
@@ -285,7 +323,10 @@ describe("codex-responses parseResponse", () => {
285323
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1", encrypted_content: "ENC_BLOB" } },
286324
]);
287325
expect(out[0]).toMatchObject({ type: "inference.thinking.delta", data: { index: 0 } });
288-
expect(out[1]).toMatchObject({ type: "inference.thinking.signature", data: { signature: "ENC_BLOB", index: 0 } });
326+
expect(out[1]).toMatchObject({
327+
type: "inference.thinking.signature",
328+
data: { signature: tagSignature(CODEX_RESPONSES_PROVIDER, "ENC_BLOB"), index: 0 },
329+
});
289330
});
290331

291332
test("emits empty thinking delta + signature when done provides encrypted_content with no prior delta (pure-encrypted reasoning)", () => {
@@ -297,7 +338,10 @@ describe("codex-responses parseResponse", () => {
297338
]);
298339
expect(out).toHaveLength(2);
299340
expect(out[0]).toMatchObject({ type: "inference.thinking.delta", data: { token: "", index: 0 } });
300-
expect(out[1]).toMatchObject({ type: "inference.thinking.signature", data: { signature: "ENC", index: 0 } });
341+
expect(out[1]).toMatchObject({
342+
type: "inference.thinking.signature",
343+
data: { signature: tagSignature(CODEX_RESPONSES_PROVIDER, "ENC"), index: 0 },
344+
});
301345
});
302346

303347
test("keys blocks by item_id so interleaved reasoning and tool calls keep distinct indices", () => {

tests/unit/codex-sse-fixtures.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { join } from "node:path";
1010
import {
1111
createCodexResponsesAdapter,
1212
isResponsesStreamTerminal,
13+
tagSignature,
1314
} from "../../src/provider/codex-responses-adapter.js";
1415
import type { InferenceEvent, LastCycleSource } from "@intx/types/runtime";
1516
import { ProtocolMismatchError } from "@intx/inference";
@@ -95,7 +96,7 @@ describe("codex-sse fixtures (golden parse)", () => {
9596
});
9697
expect(out[3]).toMatchObject({
9798
type: "inference.thinking.signature",
98-
data: { signature: "ENC_FIXTURE_BLOB_NOT_REAL", index: 0 },
99+
data: { signature: tagSignature(SOURCE.provider, "ENC_FIXTURE_BLOB_NOT_REAL"), index: 0 },
99100
});
100101
expect(out[4]).toMatchObject({
101102
type: "inference.text.delta",

0 commit comments

Comments
 (0)