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
26 changes: 21 additions & 5 deletions src/provider/codex-responses-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,27 @@ type ResponsesInputItem =
| { type: "function_call_output"; call_id: string; output: string }
| { 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;
}

// Map one internal turn to zero or more Responses items. Assistant text uses
// `output_text` parts; user/system text uses `input_text`. Tool calls become
// `function_call` items (arguments serialized to a JSON string) and tool
// results become `function_call_output` items. Reasoning blocks are echoed
// back only when they carry the opaque `encrypted_content` the backend issued
// (held in a thinking block's signature), which is required for multi-turn
// reasoning continuity.
function toResponsesItems(turn: ConversationTurn): ResponsesInputItem[] {
// (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[] {
const items: ResponsesInputItem[] = [];
const textKind: "input_text" | "output_text" = turn.role === "assistant" ? "output_text" : "input_text";
const textParts: ResponsesContentPart[] = [];
Expand Down Expand Up @@ -99,7 +112,10 @@ function toResponsesItems(turn: ConversationTurn): ResponsesInputItem[] {
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();
items.push({ type: "reasoning", summary: [], encrypted_content: block.signature });
const encryptedContent = signatureForModel(turn, requestModel, block.signature);
if (encryptedContent !== undefined) {
items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent });
}
}
}
flushText();
Expand Down Expand Up @@ -154,7 +170,7 @@ function buildRequest(
model: string,
options: InferenceOptions,
): BuiltRequest {
const conversation = messages.flatMap(toResponsesItems);
const conversation = messages.flatMap((turn) => toResponsesItems(turn, model));
// 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
11 changes: 7 additions & 4 deletions src/provider/grok-responses-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
XAI_CLIENT_VERSION,
XAI_USER_AGENT,
} from "../auth/xai/constants.js";
import { createResponsesBlockIndexer, parseResponse } from "./codex-responses-adapter.js";
import { createResponsesBlockIndexer, parseResponse, signatureForModel } from "./codex-responses-adapter.js";

// Adapter for the grok-cli OAuth proxy (cli-chat-proxy.grok.com), which serves
// the OpenAI Responses API at /v1/responses. The request shape mirrors the grok
Expand Down 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): ResponsesInputItem[] {
function toResponsesItems(turn: ConversationTurn, requestModel: string): ResponsesInputItem[] {
const items: ResponsesInputItem[] = [];
const role = turn.role;
const parts: ResponsesInputContentPart[] = [];
Expand Down Expand Up @@ -95,7 +95,10 @@ function toResponsesItems(turn: ConversationTurn): ResponsesInputItem[] {
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();
items.push({ type: "reasoning", summary: [], encrypted_content: block.signature });
const encryptedContent = signatureForModel(turn, requestModel, block.signature);
if (encryptedContent !== undefined) {
items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent });
}
}
}
flushMessage();
Expand Down Expand Up @@ -135,7 +138,7 @@ function buildRequest(
model: string,
options: InferenceOptions,
): BuiltRequest {
const conversation = dedupeToolOutputs(messages.flatMap(toResponsesItems));
const conversation = dedupeToolOutputs(messages.flatMap((turn) => toResponsesItems(turn, model)));
const systemMessage: ResponsesInputItem | undefined =
options.systemPrompt !== undefined
? { type: "message", role: "system", content: options.systemPrompt }
Expand Down
10 changes: 7 additions & 3 deletions src/provider/openai-responses-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
createResponsesBlockIndexer,
isResponsesStreamTerminal,
parseResponse,
signatureForModel,
} from "./codex-responses-adapter.js";

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

function toResponsesItems(turn: ConversationTurn): ResponsesInputItem[] {
function toResponsesItems(turn: ConversationTurn, requestModel: string): ResponsesInputItem[] {
const items: ResponsesInputItem[] = [];
const role = turn.role;
const parts: ResponsesInputContentPart[] = [];
Expand Down Expand Up @@ -90,7 +91,10 @@ function toResponsesItems(turn: ConversationTurn): ResponsesInputItem[] {
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();
items.push({ type: "reasoning", summary: [], encrypted_content: block.signature });
const encryptedContent = signatureForModel(turn, requestModel, block.signature);
if (encryptedContent !== undefined) {
items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent });
}
}
}
flushMessage();
Expand Down Expand Up @@ -125,7 +129,7 @@ function buildRequest(
model: string,
options: InferenceOptions,
): BuiltRequest {
const conversation = dedupeToolOutputs(messages.flatMap(toResponsesItems));
const conversation = dedupeToolOutputs(messages.flatMap((turn) => toResponsesItems(turn, model)));
const systemMessage: ResponsesInputItem | undefined =
options.systemPrompt !== undefined
? { type: "message", role: "system", content: options.systemPrompt }
Expand Down
43 changes: 43 additions & 0 deletions tests/unit/codex-responses-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ describe("codex-responses buildRequest", () => {
userTurn("solve the hard problem"),
{
role: "assistant",
model: "gpt-5-codex",
timestamp: 0,
content: [
{ type: "thinking", thinking: "internal steps...", signature: "ENC_BLOB_123" },
Expand All @@ -125,6 +126,48 @@ describe("codex-responses buildRequest", () => {
]);
});

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
// the reasoning item must be omitted while the surrounding turn survives.
const turns: ConversationTurn[] = [
userTurn("solve the hard problem"),
{
role: "assistant",
model: "grok-4.5",
timestamp: 0,
content: [
{ type: "thinking", thinking: "internal steps...", signature: "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("recovers an already-poisoned session: a foreign signature is dropped on every subsequent request", () => {
const poisonedHistory: ConversationTurn[] = [
userTurn("turn 1"),
{
role: "assistant",
model: "grok-4.5",
timestamp: 0,
content: [{ type: "thinking", thinking: "...", signature: "FOREIGN_BLOB" }, { type: "text", text: "ok" }],
},
userTurn("turn 2"),
];
const firstRetry = JSON.parse(adapter().buildRequest(poisonedHistory, "gpt-5-codex", baseOptions).body) as Record<string, unknown>;
const secondRetry = JSON.parse(adapter().buildRequest(poisonedHistory, "gpt-5-codex", baseOptions).body) as Record<string, unknown>;
for (const body of [firstRetry, secondRetry]) {
const input = body["input"] as Array<Record<string, unknown>>;
expect(input.some((item) => item["type"] === "reasoning")).toBe(false);
}
});

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
Loading