Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 44 additions & 14 deletions src/provider/codex-responses-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,44 @@ type ResponsesInputItem =
| { 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;
// issued for a specific model; only that backend can decrypt it. `model` is
// arbitrary catalog/user-supplied text — nothing stops two distinct backends
// (proxy aliases, two OpenAI-compatible endpoints) from declaring the same
// literal model name, so comparing `turn.model` alone treats a foreign
// signature as safe to replay. `ConversationTurn` carries no field for which
// provider produced it, so provenance rides inside the signature string
// itself: capture tags it `<provider>:<ciphertext>` (see `tagSignature`),
// and replay only unwraps the ciphertext when both the tagged provider and
// the model match the current request.
//
// Provider, not the per-account source id, is the unit of decrypt
// capability — a Codex backend shared across ChatGPT accounts can decrypt a
// signature issued to any of them, so keying on provider (rather than source
// id) is what lets an account switch keep reasoning continuity while a
// genuine cross-provider collision still gets dropped. A poisoned history
// self-heals on the next request instead of being replayed forever.
const SIGNATURE_TAG_SEPARATOR = ":";

export function tagSignature(provider: string, encryptedContent: string): string {
return `${provider}${SIGNATURE_TAG_SEPARATOR}${encryptedContent}`;
}

function untagSignature(tagged: string): { provider: string; encryptedContent: string } | undefined {
const idx = tagged.indexOf(SIGNATURE_TAG_SEPARATOR);
if (idx === -1) return undefined;
return { provider: tagged.slice(0, idx), encryptedContent: tagged.slice(idx + 1) };
}

export function signatureForModel(
turn: ConversationTurn,
requestModel: string,
requestProvider: string,
signature: string,
): string | undefined {
if (turn.model !== requestModel) return undefined;
const tagged = untagSignature(signature);
if (tagged === undefined) return undefined;
return tagged.provider === requestProvider ? tagged.encryptedContent : undefined;
}

// Map one internal turn to zero or more Responses items. Assistant text uses
Expand All @@ -76,7 +105,7 @@ export function signatureForModel(turn: ConversationTurn, requestModel: string,
// (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[] {
function toResponsesItems(turn: ConversationTurn, requestModel: string, requestProvider: string): ResponsesInputItem[] {
const items: ResponsesInputItem[] = [];
const textKind: "input_text" | "output_text" = turn.role === "assistant" ? "output_text" : "input_text";
const textParts: ResponsesContentPart[] = [];
Expand Down Expand Up @@ -112,7 +141,7 @@ function toResponsesItems(turn: ConversationTurn, requestModel: string): Respons
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();
const encryptedContent = signatureForModel(turn, requestModel, block.signature);
const encryptedContent = signatureForModel(turn, requestModel, requestProvider, block.signature);
if (encryptedContent !== undefined) {
items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent });
}
Expand Down Expand Up @@ -169,8 +198,9 @@ function buildRequest(
messages: ConversationTurn[],
model: string,
options: InferenceOptions,
requestProvider: string,
): BuiltRequest {
const conversation = messages.flatMap((turn) => toResponsesItems(turn, model));
const conversation = messages.flatMap((turn) => toResponsesItems(turn, model, requestProvider));
// 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 =
Expand Down Expand Up @@ -377,7 +407,7 @@ export function parseResponse(
events.push({
type: "inference.thinking.signature",
seq,
data: { signature: item["encrypted_content"], index },
data: { signature: tagSignature(source.provider, item["encrypted_content"] as string), index },
});
}
return events;
Expand Down Expand Up @@ -457,7 +487,7 @@ export function createCodexResponsesAdapter(source: LastCycleSource): ProviderAd
items: new Map<string, { index: number; kind: CodexBlockKind }>(),
};
return {
buildRequest,
buildRequest: (messages, model, options) => buildRequest(messages, model, options, source.provider),
parseResponse: (sseData) => parseResponse(sseData, indexer, source),
isStreamTerminal: isResponsesStreamTerminal,
};
Expand Down
9 changes: 5 additions & 4 deletions src/provider/grok-responses-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ function toolResultText(block: Extract<ContentBlock, { type: "tool_result" }>):
// 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, requestModel: string): ResponsesInputItem[] {
function toResponsesItems(turn: ConversationTurn, requestModel: string, requestProvider: string): ResponsesInputItem[] {
const items: ResponsesInputItem[] = [];
const role = turn.role;
const parts: ResponsesInputContentPart[] = [];
Expand Down Expand Up @@ -95,7 +95,7 @@ function toResponsesItems(turn: ConversationTurn, requestModel: string): Respons
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();
const encryptedContent = signatureForModel(turn, requestModel, block.signature);
const encryptedContent = signatureForModel(turn, requestModel, requestProvider, block.signature);
if (encryptedContent !== undefined) {
items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent });
}
Expand Down Expand Up @@ -137,8 +137,9 @@ function buildRequest(
messages: ConversationTurn[],
model: string,
options: InferenceOptions,
requestProvider: string,
): BuiltRequest {
const conversation = dedupeToolOutputs(messages.flatMap((turn) => toResponsesItems(turn, model)));
const conversation = dedupeToolOutputs(messages.flatMap((turn) => toResponsesItems(turn, model, requestProvider)));
const systemMessage: ResponsesInputItem | undefined =
options.systemPrompt !== undefined
? { type: "message", role: "system", content: options.systemPrompt }
Expand Down Expand Up @@ -177,7 +178,7 @@ function buildRequest(
export function createGrokResponsesAdapter(source: LastCycleSource): ProviderAdapter {
const indexer = createResponsesBlockIndexer();
return {
buildRequest,
buildRequest: (messages, model, options) => buildRequest(messages, model, options, source.provider),
parseResponse: (sseData) => parseResponse(sseData, indexer, source, GROK_RESPONSES_PROVIDER),
};
}
9 changes: 5 additions & 4 deletions src/provider/openai-responses-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function toolResultText(block: Extract<ContentBlock, { type: "tool_result" }>):
return parts.join("");
}

function toResponsesItems(turn: ConversationTurn, requestModel: string): ResponsesInputItem[] {
function toResponsesItems(turn: ConversationTurn, requestModel: string, requestProvider: string): ResponsesInputItem[] {
const items: ResponsesInputItem[] = [];
const role = turn.role;
const parts: ResponsesInputContentPart[] = [];
Expand Down Expand Up @@ -91,7 +91,7 @@ function toResponsesItems(turn: ConversationTurn, requestModel: string): Respons
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();
const encryptedContent = signatureForModel(turn, requestModel, block.signature);
const encryptedContent = signatureForModel(turn, requestModel, requestProvider, block.signature);
if (encryptedContent !== undefined) {
items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent });
}
Expand Down Expand Up @@ -128,8 +128,9 @@ function buildRequest(
messages: ConversationTurn[],
model: string,
options: InferenceOptions,
requestProvider: string,
): BuiltRequest {
const conversation = dedupeToolOutputs(messages.flatMap((turn) => toResponsesItems(turn, model)));
const conversation = dedupeToolOutputs(messages.flatMap((turn) => toResponsesItems(turn, model, requestProvider)));
const systemMessage: ResponsesInputItem | undefined =
options.systemPrompt !== undefined
? { type: "message", role: "system", content: options.systemPrompt }
Expand Down Expand Up @@ -166,7 +167,7 @@ function buildRequest(
export function createOpenAIResponsesAdapter(source: LastCycleSource): ProviderAdapter {
const indexer = createResponsesBlockIndexer();
return {
buildRequest,
buildRequest: (messages, model, options) => buildRequest(messages, model, options, source.provider),
parseResponse: (sseData) => parseResponse(sseData, indexer, source, OPENAI_RESPONSES_PROVIDER),
isStreamTerminal: isResponsesStreamTerminal,
};
Expand Down
106 changes: 101 additions & 5 deletions tests/unit/codex-responses-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { test, expect, describe } from "bun:test";
import {
createCodexResponsesAdapter,
tagSignature,
signatureForModel,
CODEX_ACCOUNT_ID_OPTION,
CODEX_SESSION_ID_OPTION,
CODEX_RESPONSES_PROVIDER,
} from "../../src/provider/codex-responses-adapter.js";
import { GROK_RESPONSES_PROVIDER } from "../../src/provider/grok-responses-adapter.js";
import { BEARER_CREDENTIAL_SENTINEL } from "@intx/inference";
import type { ConversationTurn, InferenceOptions, LastCycleSource } from "@intx/types/runtime";

Expand Down Expand Up @@ -113,7 +117,7 @@ describe("codex-responses buildRequest", () => {
model: "gpt-5-codex",
timestamp: 0,
content: [
{ type: "thinking", thinking: "internal steps...", signature: "ENC_BLOB_123" },
{ type: "thinking", thinking: "internal steps...", signature: tagSignature(CODEX_RESPONSES_PROVIDER, "ENC_BLOB_123") },
{ type: "text", text: "The answer is 42." },
],
},
Expand All @@ -126,6 +130,28 @@ describe("codex-responses buildRequest", () => {
]);
});

test("a second account on the same provider still replays the signature", () => {
// codex/personal and codex/work are two ChatGPT accounts routed through the
// same Codex backend (same provider, different InferenceSource.id). The
// backend can decrypt a signature issued to either account, so a live
// account switch must not poison reasoning continuity.
const turns: ConversationTurn[] = [
userTurn("solve the hard problem"),
{
role: "assistant",
model: "gpt-5-codex",
timestamp: 0,
content: [
{ type: "thinking", thinking: "internal steps...", signature: tagSignature(CODEX_RESPONSES_PROVIDER, "ENC_BLOB_123") },
{ type: "text", text: "The answer is 42." },
],
},
];
const workAdapter = createCodexResponsesAdapter({ sourceId: "codex/work", provider: "codex-responses", model: "gpt-5-codex" });
const body = JSON.parse(workAdapter.buildRequest(turns, "gpt-5-codex", baseOptions).body) as Record<string, unknown>;
expect(body["input"]).toContainEqual({ type: "reasoning", summary: [], encrypted_content: "ENC_BLOB_123" });
});

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
Expand All @@ -137,7 +163,31 @@ describe("codex-responses buildRequest", () => {
model: "grok-4.5",
timestamp: 0,
content: [
{ type: "thinking", thinking: "internal steps...", signature: "FOREIGN_BLOB" },
{ type: "thinking", thinking: "internal steps...", signature: tagSignature(GROK_RESPONSES_PROVIDER, "FOREIGN_BLOB") },
{ type: "text", text: "The answer is 42." },
],
},
];
const body = JSON.parse(adapter().buildRequest(turns, "gpt-5-codex", baseOptions).body) as Record<string, unknown>;
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("drops a reasoning signature issued by a different provider even when the model string matches", () => {
// Two distinct backends (e.g. proxy aliases) can declare the identical
// literal model name. Nothing but the tagged provider on the signature
// itself distinguishes them, since InferenceSource.model is arbitrary
// catalog text and turn.model alone cannot tell them apart.
const turns: ConversationTurn[] = [
userTurn("solve the hard problem"),
{
role: "assistant",
model: "gpt-5-codex",
timestamp: 0,
content: [
{ type: "thinking", thinking: "internal steps...", signature: tagSignature(GROK_RESPONSES_PROVIDER, "FOREIGN_BLOB") },
{ type: "text", text: "The answer is 42." },
],
},
Expand All @@ -156,7 +206,10 @@ describe("codex-responses buildRequest", () => {
role: "assistant",
model: "grok-4.5",
timestamp: 0,
content: [{ type: "thinking", thinking: "...", signature: "FOREIGN_BLOB" }, { type: "text", text: "ok" }],
content: [
{ type: "thinking", thinking: "...", signature: tagSignature(GROK_RESPONSES_PROVIDER, "FOREIGN_BLOB") },
{ type: "text", text: "ok" },
],
},
userTurn("turn 2"),
];
Expand All @@ -168,6 +221,43 @@ describe("codex-responses buildRequest", () => {
}
});

test("drops a bare, untagged legacy signature instead of misparsing it as ciphertext", () => {
// Signatures captured before this change carry no "<provider>:" prefix.
// untagSignature must recognize the absence of a separator and refuse to
// treat any part of the raw string as ciphertext, rather than replaying
// a truncated or garbled blob the backend cannot decrypt.
const turns: ConversationTurn[] = [
userTurn("solve the hard problem"),
{
role: "assistant",
model: "gpt-5-codex",
timestamp: 0,
content: [
{ type: "thinking", thinking: "internal steps...", signature: "QUJDREVGRzEyMzQ1Njc4OTAtXy8rPQ==" },
{ type: "text", text: "The answer is 42." },
],
},
];
const body = JSON.parse(adapter().buildRequest(turns, "gpt-5-codex", baseOptions).body) as Record<string, unknown>;
const input = body["input"] as Array<Record<string, unknown>>;
expect(input.some((item) => item["type"] === "reasoning")).toBe(false);
});

test("signatureForModel returns undefined for an untagged signature", () => {
const turn: ConversationTurn = { role: "assistant", model: "gpt-5-codex", timestamp: 0, content: [] };
expect(signatureForModel(turn, "gpt-5-codex", CODEX_RESPONSES_PROVIDER, "QUJDREVGRzEyMzQ1Njc4OTAtXy8rPQ==")).toBeUndefined();
});

test("tagSignature/signatureForModel round-trips ciphertext containing embedded colons byte-exact", () => {
// untagSignature splits on the FIRST colon (indexOf, not split(":")),
// so ciphertext that itself contains colons must survive intact. A
// naive split(":")[1] would truncate this to "part2".
const ciphertext = "part1:part2:part3==";
const turn: ConversationTurn = { role: "assistant", model: "gpt-5-codex", timestamp: 0, content: [] };
const tagged = tagSignature(CODEX_RESPONSES_PROVIDER, ciphertext);
expect(signatureForModel(turn, "gpt-5-codex", CODEX_RESPONSES_PROVIDER, tagged)).toBe(ciphertext);
});

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();
Expand Down Expand Up @@ -233,7 +323,10 @@ describe("codex-responses parseResponse", () => {
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1", encrypted_content: "ENC_BLOB" } },
]);
expect(out[0]).toMatchObject({ type: "inference.thinking.delta", data: { index: 0 } });
expect(out[1]).toMatchObject({ type: "inference.thinking.signature", data: { signature: "ENC_BLOB", index: 0 } });
expect(out[1]).toMatchObject({
type: "inference.thinking.signature",
data: { signature: tagSignature(CODEX_RESPONSES_PROVIDER, "ENC_BLOB"), index: 0 },
});
});

test("emits empty thinking delta + signature when done provides encrypted_content with no prior delta (pure-encrypted reasoning)", () => {
Expand All @@ -245,7 +338,10 @@ describe("codex-responses parseResponse", () => {
]);
expect(out).toHaveLength(2);
expect(out[0]).toMatchObject({ type: "inference.thinking.delta", data: { token: "", index: 0 } });
expect(out[1]).toMatchObject({ type: "inference.thinking.signature", data: { signature: "ENC", index: 0 } });
expect(out[1]).toMatchObject({
type: "inference.thinking.signature",
data: { signature: tagSignature(CODEX_RESPONSES_PROVIDER, "ENC"), index: 0 },
});
});

test("keys blocks by item_id so interleaved reasoning and tool calls keep distinct indices", () => {
Expand Down
3 changes: 2 additions & 1 deletion tests/unit/codex-sse-fixtures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { join } from "node:path";
import {
createCodexResponsesAdapter,
isResponsesStreamTerminal,
tagSignature,
} from "../../src/provider/codex-responses-adapter.js";
import type { InferenceEvent, LastCycleSource } from "@intx/types/runtime";
import { ProtocolMismatchError } from "@intx/inference";
Expand Down Expand Up @@ -95,7 +96,7 @@ describe("codex-sse fixtures (golden parse)", () => {
});
expect(out[3]).toMatchObject({
type: "inference.thinking.signature",
data: { signature: "ENC_FIXTURE_BLOB_NOT_REAL", index: 0 },
data: { signature: tagSignature(SOURCE.provider, "ENC_FIXTURE_BLOB_NOT_REAL"), index: 0 },
});
expect(out[4]).toMatchObject({
type: "inference.text.delta",
Expand Down
Loading