Skip to content

Commit 4c1d9ff

Browse files
committed
Key reasoning-signature replay on the issuing provider, not just the model string
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 ddedba7 commit 4c1d9ff

5 files changed

Lines changed: 64 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: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,10 @@ describe("codex-responses parseResponse", () => {
285285
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1", encrypted_content: "ENC_BLOB" } },
286286
]);
287287
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 } });
288+
expect(out[1]).toMatchObject({
289+
type: "inference.thinking.signature",
290+
data: { signature: tagSignature(CODEX_RESPONSES_PROVIDER, "ENC_BLOB"), index: 0 },
291+
});
289292
});
290293

291294
test("emits empty thinking delta + signature when done provides encrypted_content with no prior delta (pure-encrypted reasoning)", () => {
@@ -297,7 +300,10 @@ describe("codex-responses parseResponse", () => {
297300
]);
298301
expect(out).toHaveLength(2);
299302
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 } });
303+
expect(out[1]).toMatchObject({
304+
type: "inference.thinking.signature",
305+
data: { signature: tagSignature(CODEX_RESPONSES_PROVIDER, "ENC"), index: 0 },
306+
});
301307
});
302308

303309
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)