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
151 changes: 108 additions & 43 deletions src/gateway.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { mkdirSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { isDeepStrictEqual } from "node:util";
import { addressedProviderOf, allProfiles, PROVIDER_SEPARATOR, bareModelId, modelEntryFor, profileById, providerForModel, upstreamTargetFor } from "./profiles.mjs";
import { compressConversation } from "./compress.mjs";
import { normalizeOllamaBase } from "./ollama.mjs";
Expand All @@ -15,7 +16,7 @@ import { stateDir } from "./state-dir.mjs";
import { customEndpointFor } from "./custom-endpoint-routing.mjs";
import { historicalImageSpawnHint, hasOpaqueCollaboration, isOpaqueEncryptedContent, promoteCollaborationNewTask } from "./subagent-guidance.mjs";
import { createUsageTee, forEachSseEvent, parseSseData } from "./sse.mjs";
import { chatCompletionToResponse, normalizeLlamaServerTimings, pipeChatCompletionStream, responsesToChat } from "./local-chat-bridge.mjs";
import { chatCompletionToResponse, chatReasoningText, normalizeLlamaServerTimings, pipeChatCompletionStream, responsesToChat } from "./local-chat-bridge.mjs";
import { MIN_IMAGE_TRANSPORT_WIRE_BYTES } from "./image-transport.mjs";
import { NATIVE_CODEX_BASE } from "./native-endpoint.mjs";
import { NATIVE_PROVIDER_ID } from "./native-provider.mjs";
Expand Down Expand Up @@ -609,63 +610,127 @@ function isToolOutputItem(item) {
return item?.type === "function_call_output" || item?.type === "custom_tool_call_output";
}

function chatToolCallId(call) {
if (!call || typeof call !== "object") return undefined;
const id = call.id ?? call.call_id;
return typeof id === "string" && id ? id : undefined;
}

// Based on jt-wang's PR #36: reuse after a completed pair is a new invocation,
// not a duplicate stream item. Chat rows are flattened before this pass, so
// there is only one pairing dialect. A duplicate pending call gets no second
// queue entry, and first-seen ids win to keep earlier request prefixes stable.
export function uniquifyReusedToolCallIds(input) {
if (!Array.isArray(input)) return input;
const used = new Set();
const itemIds = new Set();
const counts = new Map();
const byOriginal = new Map();
let changed = false;
const out = input.map((item) => {
const original = item?.call_id;
if ((!isToolCallItem(item) && !isToolOutputItem(item)) || typeof original !== "string" || !original) return item;
const repeatedItemId = item.id && itemIds.has(item.id);
if (item.id) itemIds.add(item.id);
let entry = byOriginal.get(original);
if (isToolCallItem(item)) {
// Chat and Responses copies may carry object versus serialized arguments.
// Compare their meaning without changing the original wire payload.
let args = item.arguments;
if (typeof args === "string") {
try { args = JSON.parse(args); } catch { /* Keep freeform arguments exact. */ }
}
const signature = [item.type, item.namespace, item.name, args, item.input];
if (entry && !entry.closed) {
if (!isDeepStrictEqual(entry.signature, signature)) throw new Error("Ambiguous tool history: different pending calls share call_id " + original + ".");
} else {
let alias = original;
let n = counts.get(original) || 1;
while (used.has(alias)) alias = original + "__" + (++n);
counts.set(original, n);
used.add(alias);
entry = { alias, signature, closed: false };
byOriginal.set(original, entry);
}
} else if (entry) {
entry.closed = true;
}
// A leading orphan output is left for the existing pairing pass, not
// interpreted as a completed invocation that never appeared in the input.
if (!entry || entry.alias === original) return item;
changed = true;
const next = { ...item, call_id: entry.alias };
if (repeatedItemId) {
next.id = nativeResponsesItemId({ ...next, id: "reused:" + item.id + ":" + entry.alias }, 0);
}
return next;
});
return changed ? out : input;
}

// PR #36's Chat-to-Responses boundary, without a second output lookup/pairing
// map. Keep results in place and preserve structured content; only the common
// pairing pass below decides which result belongs to which invocation.
export function flattenChatToolCallsToResponses(input) {
if (!Array.isArray(input)) return input;
let changed = false;
const out = [];
for (const item of input) {
if (item?.type === "message" && item?.role === "tool") {
changed = true;
const { type, role, tool_call_id, content, name, ...rest } = item;
out.push({ ...rest, type: "function_call_output", call_id: tool_call_id, output: item.output ?? content ?? "" });
continue;
}
if (item?.type === "message" && item?.role === "assistant" && Array.isArray(item.tool_calls) && item.tool_calls.length) {
changed = true;
const { tool_calls, reasoning_content, reasoning, reasoning_text, ...assistant } = item;
const thought = chatReasoningText(item);
if (thought) out.push({ type: "reasoning", summary: [], content: [{ type: "reasoning_text", text: thought }] });
const hasContent = Array.isArray(item.content) ? item.content.length > 0 : typeof item.content === "string" && item.content.trim() !== "";
if (hasContent) out.push(assistant);
for (const call of tool_calls) {
const id = chatToolCallId(call);
if (!id) continue;
out.push({
type: "function_call",
call_id: id,
name: call?.function?.name ?? call?.name,
arguments: call?.function?.arguments ?? call?.arguments ?? "{}",
...(call.namespace ? { namespace: call.namespace } : {}),
});
}
continue;
}
out.push(item);
}
return changed ? out : input;
}

// Go (Console Go) validates tool pairing strictly and rejects the whole request
// when a tool call has no matching output ("No tool output found for tool call
// ..."). Codex genuinely produces such orphans - a remote compact task slices
// history and can sever a call from its output at the cut. Both dialects Codex
// emits are paired here: the Responses shape (top-level function_call /
// custom_tool_call items with function_call_output / custom_tool_call_output)
// and the chat shape (an assistant message carrying a `tool_calls` array whose
// results are role:"tool" messages with tool_call_id). The unpaired side is
// dropped in both directions so the turn survives; paired history is untouched.
// results are role:"tool" messages with tool_call_id). Normalize the dialect
// and reused identities once, then drop only the unpaired side. Valid Responses
// pairs are unchanged; mixed history is represented as canonical Responses pairs.
export function dropUnpairedToolItems(input) {
if (!Array.isArray(input)) return input;
input = uniquifyReusedToolCallIds(flattenChatToolCallsToResponses(input));
const callIds = new Set();
const outputIds = new Set();
for (const item of input) {
if (isToolCallItem(item)) callIds.add(item.call_id);
if (isToolOutputItem(item)) outputIds.add(item.call_id);
if (item?.type === "message" && item?.role === "assistant" && Array.isArray(item.tool_calls)) {
for (const call of item.tool_calls) {
const id = typeof call === "object" && call !== null ? (call.id ?? call.call_id) : undefined;
if (typeof id === "string" && id) callIds.add(id);
}
}
if (item?.type === "message" && item?.role === "tool" && typeof item.tool_call_id === "string" && item.tool_call_id) {
outputIds.add(item.tool_call_id);
}
}
const paired = input
.map((item) => {
if (isToolCallItem(item)) {
return outputIds.has(item.call_id) ? item : null;
}
if (isToolOutputItem(item)) {
return callIds.has(item.call_id) ? item : null;
}
if (item?.type === "message" && item?.role === "tool") {
return callIds.has(item.tool_call_id) ? item : null;
}
if (item?.type === "message" && item?.role === "assistant" && Array.isArray(item.tool_calls)) {
const kept = item.tool_calls.filter((call) => {
const id = typeof call === "object" && call !== null ? (call.id ?? call.call_id) : undefined;
return outputIds.has(id);
});
if (kept.length === item.tool_calls.length) return item;
// A message whose calls all got severed and that carries no other text
// would reach the upstream as an empty assistant turn, which strict
// upstreams reject ("content or tool_calls must be set"). Drop it.
const hasContent = Array.isArray(item.content)
? item.content.length > 0
: typeof item.content === "string" && item.content.trim() !== "";
if (kept.length === 0 && !hasContent) return null;
const next = { ...item, tool_calls: kept };
if (kept.length === 0) delete next.tool_calls;
return next;
}
return item;
})
.filter((item) => item !== null);
const paired = input.filter((item) => {
if (isToolCallItem(item)) return outputIds.has(item.call_id);
if (isToolOutputItem(item)) return callIds.has(item.call_id);
return true;
});
return relocateToolOutputs(paired);
}

Expand Down
2 changes: 1 addition & 1 deletion src/local-chat-bridge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ export function normalizeLlamaServerTimings(value) {
});
}

function chatReasoningText(message) {
export function chatReasoningText(message) {
if (!message || typeof message !== "object") return "";
for (const field of ["reasoning_content", "reasoning", "reasoning_text"]) {
if (typeof message[field] === "string" && message[field]) return message[field];
Expand Down
91 changes: 88 additions & 3 deletions test/codex-wire-full-opencode-chat.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { gunzipSync } from "node:zlib";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const bundle = path.join(repoRoot, "dist", "modeldock.mjs");
const fixture = JSON.parse(gunzipSync(readFileSync(new URL("./fixtures/codex-xai-full-2026-08-21.json.gz", import.meta.url))).toString("utf8"));
const longFixture = JSON.parse(gunzipSync(readFileSync(new URL("./fixtures/voxel-commandcode-native-compact-2026-09-02.json.gz", import.meta.url))).toString("utf8"));

function listen(server) {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -112,6 +113,24 @@ function textStream(text) {
}]);
}

function assertChatPairs(messages) {
const used = new Set();
const pending = new Set();
for (const message of messages) {
if (message.role === "tool") {
assert.ok(pending.delete(message.tool_call_id), "result must belong to the immediately preceding call group");
} else {
assert.equal(pending.size, 0, "all parallel results must arrive before another message");
for (const call of message.tool_calls || []) {
assert.ok(!used.has(call.id), "each invocation must have a distinct id");
used.add(call.id);
pending.add(call.id);
}
}
}
assert.equal(pending.size, 0, "history must not end in an orphan call");
}

test("built bundle bridges the complete original Codex package to strict OpenCode Chat", async (t) => {
assert.equal(fixture.capture.kind, "full_original_codex_request");
assert.equal(fixture.capture.originalToolCount, 164);
Expand All @@ -124,6 +143,24 @@ test("built bundle bridges the complete original Codex package to strict OpenCod
for await (const chunk of req) chunks.push(chunk);
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
requests.push(body);
if (req.url === "/v1/responses") {
try {
assert.equal(body.model, "deepseek-v4-flash");
const calls = body.input.filter((item) => ["function_call", "custom_tool_call"].includes(item.type));
const results = body.input.filter((item) => ["function_call_output", "custom_tool_call_output"].includes(item.type));
assert.equal(new Set(calls.map((item) => item.call_id)).size, calls.length);
assert.deepEqual(results.map((item) => item.call_id), calls.map((item) => item.call_id));
assert.ok(body.input.every((item) => !item.tool_calls && item.role !== "tool"));
res.writeHead(200, { "content-type": "text/event-stream" });
res.end(sse([{ type: "response.completed", response: { id: "resp_history", status: "completed", output: [
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "RESPONSES_HISTORY_OK" }] },
] } }]));
} catch (error) {
res.writeHead(422, { "content-type": "application/json" });
res.end(JSON.stringify({ error: error.message }));
}
return;
}
if (req.url !== "/v1/chat/completions") {
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "expected Chat Completions endpoint" }));
Expand All @@ -144,6 +181,13 @@ test("built bundle bridges the complete original Codex package to strict OpenCod
res.end(JSON.stringify({ error: "wrong Go Chat model" }));
return;
}
try {
assertChatPairs(body.messages);
} catch (error) {
res.writeHead(422, { "content-type": "application/json" });
res.end(JSON.stringify({ error: error.message }));
return;
}
if (body.stream === false) {
if (body.tools?.length || body.tool_choice !== "none" || !body.messages.at(-1)?.content.includes("CONTEXT CHECKPOINT COMPACTION")) {
res.writeHead(400, { "content-type": "application/json" });
Expand Down Expand Up @@ -223,11 +267,11 @@ test("built bundle bridges the complete original Codex package to strict OpenCod
}
});
await waitForStatus(gatewayPort);
const send = async (input, sessionId, stream = true) => {
const send = async (input, sessionId, stream = true, model = "qwen3.8-flash@opencode-go") => {
const response = await fetch(`http://127.0.0.1:${gatewayPort}/v1/responses`, {
method: "POST",
headers: { "content-type": "application/json", "x-codex-session-id": sessionId },
body: JSON.stringify({ ...fixture.request, model: "qwen3.8-flash@opencode-go", stream, input }),
body: JSON.stringify({ ...fixture.request, model, stream, input }),
});
const text = await response.text();
const rejectedTools = requests.at(-1)?.tools || [];
Expand Down Expand Up @@ -267,7 +311,40 @@ test("built bundle bridges the complete original Codex package to strict OpenCod
], "full-go-chat-fixture");
assert.match(third, /GO_TOOL_LOOP_COMPLETE/);
assert.equal(requests[2].messages.find((message) => message.tool_calls?.some((call) => call.id === "call_go_c"))?.reasoning_content, "The first tools completed; continue the same task.");
const compact = await send([...fixture.request.input,
// Reuse the captured long-session history and complete tool table, not a
// shortened hand-authored envelope. The added rounds exercise #36's mixed
// dialect and reused-id transitions before and after compaction.
const history = [...longFixture.input.filter((item) => item.type !== "compaction_trigger"),
...firstTurn, ...secondOutput, { type: "function_call_output", call_id: "call_go_c", output: "done" }];
const markers = [];
for (let round = 1; round <= 8; round += 1) {
const id = round === 3 ? "reuse__2" : "reuse";
const call = { type: "function_call", call_id: id, name: "exec_command", arguments: JSON.stringify({ cmd: `echo round_${round}` }) };
const chat = { type: "message", role: "assistant", content: null, tool_calls: [
{ id, type: "function", function: { name: call.name, arguments: call.arguments } },
] };
const marker = `ROUND_${round}_RESULT`;
markers.push(marker);
history.push({ type: "message", role: "user", content: `Run round ${round}.` }, round % 2 ? chat : call);
if (round % 2) history.push(call); // Same pending invocation in both dialects.
history.push(round % 2
? { type: "function_call_output", call_id: id, output: marker }
: { type: "message", role: "tool", tool_call_id: id, content: marker });
history.push(
{ type: "custom_tool_call", call_id: "patch_reused", name: "apply_patch", input: `*** Begin Patch\n*** Add File: round${round}.txt\n+fixture\n*** End Patch` },
{ type: "custom_tool_call_output", call_id: "patch_reused", output: `PATCH_${round}_RESULT` },
);
await send(history, "full-go-chat-fixture");
const chatResults = requests.at(-1).messages.filter((item) => item.role === "tool").map((item) => item.content);
assert.deepEqual(chatResults.filter((text) => /^ROUND_\d+_RESULT$/.test(text)), markers);
assert.equal(chatResults.filter((text) => /^PATCH_\d+_RESULT$/.test(text)).length, round);
}
const switched = await send(history, "full-go-chat-fixture", true, "deepseek-v4-flash@opencode-go");
assert.match(switched, /RESPONSES_HISTORY_OK/);
const responseResults = requests.at(-1).input.filter((item) => item.type === "function_call_output").map((item) => item.output);
assert.deepEqual(responseResults.filter((text) => /^ROUND_\d+_RESULT$/.test(text)), markers);

const compact = await send([...history,
{ type: "message", role: "user", content: [{ type: "input_text", text: "Summarize the completed work." }] },
{ type: "compaction_trigger" },
], "full-go-chat-fixture", false);
Expand All @@ -280,4 +357,12 @@ test("built bundle bridges the complete original Codex package to strict OpenCod
{ type: "message", role: "user", content: [{ type: "input_text", text: "Resume after compaction." }] },
], "full-go-chat-fixture");
assert.match(resumed, /GO_COMPACTION_RESUMED/);
// The first post-compaction tool invocation may reuse a pre-compaction id.
await send([
...fixture.request.input, compacted.output[0],
{ type: "function_call", call_id: "reuse", name: "exec_command", arguments: '{"cmd":"echo after_compact"}' },
{ type: "function_call_output", call_id: "reuse", output: "AFTER_COMPACT_RESULT" },
], "full-go-chat-fixture");
const afterResults = requests.at(-1).messages.filter((item) => item.role === "tool").map((item) => item.content);
assert.deepEqual(afterResults, ["AFTER_COMPACT_RESULT"], "pre-compaction pairing state must not leak into the next history");
});
Loading
Loading