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
4 changes: 4 additions & 0 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ provider — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (local & cloud),

- Converts internal messages to OpenAI roles; maps tools to `{type:"function", function:{…}}` and
`tool_choice` (`auto`/`none`/`required` or a named function).
- Keeps function names within the 64-character ASCII wire limit using request-local,
collision-safe aliases. Declarations, named tool choice and conversation history share the
same mapping; streamed and non-streamed replies restore the original tool names without
changing call IDs or arguments. Already-valid names stay unchanged.
- **Rewrites Codex's GPT-5 identity prompt** to a model-agnostic intro so routed models don't claim to
be OpenAI.
- **Clamps `reasoning_effort`** to the model's advertised subset when an exact tier is unavailable;
Expand Down
56 changes: 56 additions & 0 deletions src/adapters/openai-chat-tool-names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { createHash } from "node:crypto";

const WIRE_NAME = /^[A-Za-z0-9_-]{1,64}$/;

/** Request-local aliases: reserve valid originals before generating any aliases. */
export function chatToolNameCodec(names: readonly string[]): {
toWire: (name: string) => string;
fromWire: (name: string) => string;
} {
const originals = [...new Set(names)];
const used = new Set(originals.filter(name => WIRE_NAME.test(name)));
const encoded = new Map<string, string>();
const decoded = new Map<string, string>();
// Sorting also makes collision resolution independent of declaration/history order.
for (const name of originals.sort()) {
let wire = name;
if (!WIRE_NAME.test(name)) {
const prefix = (name.replace(/[^A-Za-z0-9_-]/g, "_") || "tool").slice(0, 55);
for (let salt = 0; ; salt++) {
const input = salt === 0 ? name : `${name}#${salt}`;
const suffix = createHash("sha256").update(input).digest("hex").slice(0, 8);
wire = `${prefix}_${suffix}`;
if (!used.has(wire)) break;
}
}
used.add(wire);
encoded.set(name, wire);
decoded.set(wire, name);
}
return {
toWire: name => encoded.get(name) ?? name,
fromWire: name => decoded.get(name) ?? name,
};
}

/** Rewrite only Chat function-name fields, never arguments, descriptions or call IDs. */
export function encodeChatToolNames(messages: unknown[], tools: unknown[] | undefined, toolChoice: unknown): (name: string) => string {
const functions: Record<string, unknown>[] = [];
const collect = (entry: unknown) => {
if (!entry || typeof entry !== "object") return;
const fn = (entry as { function?: unknown }).function;
if (fn && typeof fn === "object" && typeof (fn as { name?: unknown }).name === "string") {
functions.push(fn as Record<string, unknown>);
}
};
for (const tool of tools ?? []) collect(tool);
collect(toolChoice);
for (const message of messages) {
if (!message || typeof message !== "object") continue;
const calls = (message as { tool_calls?: unknown }).tool_calls;
if (Array.isArray(calls)) for (const call of calls) collect(call);
}
const codec = chatToolNameCodec(functions.map(fn => fn.name as string));
for (const fn of functions) fn.name = codec.toWire(fn.name as string);
return codec.fromWire;
}
15 changes: 9 additions & 6 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge";
import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing";
import { resolveProviderCompat } from "../providers/compat";
import { encodeChatToolNames } from "./openai-chat-tool-names";

// Providers may opt into stripping one trailing "[...]" group from the wire model id.
// Z.AI needs this because its OpenAI path rejects glm-5.2[1m] with 400 code 1211;
Expand Down Expand Up @@ -245,7 +246,7 @@
// WS turns can arrive with only tool outputs; chat-completions providers reject a bare
// role:"tool" message unless an assistant tool_call with the same id immediately precedes it.
flushPendingToolCalls();
const name = safeToolName(msg.toolName);
const name = namespacedToolName(msg.toolNamespace, safeToolName(msg.toolName));
out.push({
role: "assistant",
content: "",
Expand Down Expand Up @@ -275,9 +276,8 @@
}

function safeToolName(name: string | undefined): string {
const raw = name && name.trim().length > 0 ? name : "tool_result";
const sanitized = raw.replace(/[^A-Za-z0-9_-]/g, "_");
return sanitized;
// Encoding happens with the full request name set, so it remains reversible.
return name && name.trim().length > 0 ? name : "tool_result";
}

const ZEN_SCHEMA_MAP_KEYS = new Set(["properties", "$defs", "definitions"]);
Expand Down Expand Up @@ -614,6 +614,8 @@
}

export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAdapter {
// resolveAdapter creates one adapter per request, as for Google/Kiro codecs.
let restoreToolName = (name: string): string => name;
return {
name: "openai-chat",

Expand All @@ -628,6 +630,7 @@
const messages = messagesToChatFormat(parsed, provider);
const tools = toolsToChatFormatForProvider(parsed, provider);
const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools);
restoreToolName = encodeChatToolNames(messages, tools, toolChoice);

const body: Record<string, unknown> = {
model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(parsed.modelId) : parsed.modelId,
Expand Down Expand Up @@ -778,7 +781,7 @@
const flushToolCalls = function* (): Generator<AdapterEvent> {
for (const call of pendingToolCalls) {
if (!call.id) call.id = `call_${++toolCallSeq}`;
yield { type: "tool_call_start", id: call.id, name: call.name };
yield { type: "tool_call_start", id: call.id, name: restoreToolName(call.name) };
if (call.args.length > 0) yield { type: "tool_call_delta", arguments: call.args };
yield { type: "tool_call_end" };
}
Expand All @@ -795,104 +798,104 @@
// a final frame is parsed identically wherever it lands (no duplicated, drift-prone parsing).
// Yields adapter events and returns "terminate" for a terminal frame ([DONE] / error) that
// must end the stream, or "continue" otherwise. Mutates the closure's terminal-signal state.
const handleDataLine = function* (line: string): Generator<AdapterEvent, "continue" | "terminate"> {
if (!line.startsWith("data: ")) return "continue";
const payload = line.slice(6).trim();
if (payload === "[DONE]") {
yield* flushToolCalls();
const stopReason = finishReason === "length"
? "max_tokens"
: finishReason === "content_filter"
? "content_filter"
: undefined;
yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) };
return "terminate";
}

let chunk: Record<string, unknown>;
try {
chunk = JSON.parse(payload) as Record<string, unknown>;
} catch {
yield { type: "error", message: "malformed upstream SSE data frame" };
return "terminate";
}

// A 200/OK chat-completions stream may carry an inline provider error envelope
// instead of a clean [DONE]. Surface it as a terminal error so the bridge emits a
// classified response.failed (bridge case "error") — never a truncated completion.
if (chunk.error) {
const err = chunk.error as { message?: string; code?: string; type?: string; status?: number } | undefined;
const message = err?.message ?? "upstream error";
debugProviderDiagnostic("openai-chat", "stream-error", { message });
yield* flushToolCalls();
yield {
type: "error",
message,
...(typeof err?.code === "string" ? { code: err.code } : {}),
...(typeof err?.type === "string" ? { errorType: err.type } : {}),
...(isCyberPolicyCode(err?.code)
? { status: 400 }
: typeof err?.status === "number" && Number.isInteger(err.status)
? { status: err.status }
: {}),
};
return "terminate";
}

if (chunk.usage) {
// Record usage but keep parsing: some providers send usage and the final content
// delta in the SAME chunk; a bail here would drop that content. The choices
// guard below no-ops a usage-only chunk.
pendingUsage = usageFromOpenAIChat(chunk.usage as Record<string, unknown>);
}

const choices = chunk.choices as { delta?: Record<string, unknown>; finish_reason?: string }[] | undefined;
if (!choices || choices.length === 0) return "continue";
// Observe the terminator BEFORE the delta guard: a finish-only chunk (finish_reason set,
// no delta) is a graceful close and must record finishReason even though we skip it below.
if (typeof choices[0].finish_reason === "string" && choices[0].finish_reason) {
finishReason = choices[0].finish_reason;
}
const delta = choices[0].delta;
if (delta) {
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
yield { type: "reasoning_raw_delta", text: delta.reasoning_content };
}
if (typeof delta.content === "string" && delta.content.length > 0) {
yield { type: "text_delta", text: delta.content };
}

const toolCalls = delta.tool_calls as { index?: number; id?: string; function?: { name?: string; arguments?: string } }[] | undefined;
if (toolCalls) {
for (const tc of toolCalls) {
const key = typeof tc.index === "number"
? `i:${tc.index}`
: tc.id
? `id:${tc.id}`
: pendingToolCalls[pendingToolCalls.length - 1]?.key;
let call = key !== undefined ? pendingToolCalls.find(c => c.key === key) : undefined;
// Mixed keying rescue: a call opened under an index key must still absorb an
// id-only continuation for the same provider id (and vice versa) instead of
// splitting into two calls that share one call_id downstream.
if (!call && tc.id) call = pendingToolCalls.find(c => c.id === tc.id);
if (!call) {
call = { key: key ?? `seq:${pendingToolCalls.length}`, id: "", name: "", args: "" };
pendingToolCalls.push(call);
}
if (tc.id && !call.id) call.id = tc.id;
if (tc.function?.name && !call.name) call.name = tc.function.name;
if (tc.function?.arguments) call.args += tc.function.arguments;
}
}
}

// Any non-empty finish_reason ends the generation: flush assembled tool calls as
// atomic sequences (covers "tool_calls" AND providers that close tool turns with "stop").
if (typeof choices[0].finish_reason === "string" && choices[0].finish_reason) {
yield* flushToolCalls();
}
return "continue";
};

Check notice on line 898 in src/adapters/openai-chat.ts

View check run for this annotation

codefactor.io / CodeFactor

src/adapters/openai-chat.ts#L801-L898

Complex Method

try {
while (true) {
Expand Down Expand Up @@ -979,7 +982,7 @@
const toolCalls = msg.tool_calls as { id: string; function: { name: string; arguments: string } }[] | undefined;
if (toolCalls) {
for (const tc of toolCalls) {
events.push({ type: "tool_call_start", id: tc.id, name: tc.function.name });
events.push({ type: "tool_call_start", id: tc.id, name: restoreToolName(tc.function.name) });
events.push({ type: "tool_call_delta", arguments: tc.function.arguments });
events.push({ type: "tool_call_end" });
}
Expand Down
14 changes: 13 additions & 1 deletion tests/adapter-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,14 +372,26 @@ describe("openai-chat tool history repair", () => {
tool_calls: [{
id: "call_1",
type: "function",
function: { name: "codex_list_mcp_resources", arguments: "{}" },
function: { name: "codex_list_mcp_resources_9f17b48d", arguments: "{}" },
}],
});
expect(body.messages[1]).toMatchObject({
role: "tool",
tool_call_id: "call_1",
content: '{"resources":[]}',
});
const [call] = (body.messages[0].tool_calls as {
id: string; function: { name: string; arguments: string };
}[]);
expect(call.function.name).toMatch(/^[A-Za-z0-9_-]{1,64}$/);
const restored = await adapter.parseResponse!(Response.json({
choices: [{ message: { tool_calls: [call] } }],
}));
expect(restored.slice(0, 3)).toEqual([
{ type: "tool_call_start", id: "call_1", name: "codex.list_mcp_resources" },
{ type: "tool_call_delta", arguments: "{}" },
{ type: "tool_call_end" },
]);
});

test("keeps paired tool results attached to the prior assistant tool_call", async () => {
Expand Down
120 changes: 120 additions & 0 deletions tests/openai-chat-tool-names.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, expect, test } from "bun:test";
import { createOpenAIChatAdapter } from "../src/adapters/openai-chat";
import { chatToolNameCodec } from "../src/adapters/openai-chat-tool-names";
import { buildToolBridgeMaps } from "../src/server/responses/collaboration";
import type { AdapterEvent, OcxParsedRequest } from "../src/types";

const longName = `search_${"a".repeat(80)}`;
const adapter = () => createOpenAIChatAdapter({ adapter: "openai-chat", baseUrl: "https://example.test/v1" });
function request(namespace?: string): OcxParsedRequest {
return {
modelId: "test", stream: true,
options: { toolChoice: { name: namespace ? `${namespace}.${longName}` : longName } },
context: {
tools: [{ name: longName, namespace, description: "test", parameters: { type: "object" } }],
messages: [
{ role: "assistant", timestamp: 0, content: [{ type: "toolCall", id: "call_original", name: longName, namespace, arguments: { name: longName } }] },
{ role: "toolResult", timestamp: 0, toolCallId: "call_original", toolName: longName, toolNamespace: namespace, content: "ok", isError: false },
],
},
};
}

describe("Chat tool-name codec", () => {
test("preserves 64 valid ASCII characters and aliases 65, empty and invalid names", () => {
const names = ["a".repeat(64), "a".repeat(65), "with.dot/space é", "", "9-valid_name"];
const codec = chatToolNameCodec(names);
expect(codec.toWire(names[0])).toBe(names[0]);
expect(codec.toWire(names[4])).toBe(names[4]);
for (const name of names) {
expect(codec.toWire(name)).toMatch(/^[A-Za-z0-9_-]{1,64}$/);
expect(codec.fromWire(codec.toWire(name))).toBe(name);
}
expect(codec.fromWire("unknown_tool")).toBe("unknown_tool");
});

test("separates identical cleaned prefixes and reserves conforming originals regardless of order", () => {
const other = `${longName}b`;
const initial = chatToolNameCodec([longName]).toWire(longName);
const second = chatToolNameCodec([longName, initial]).toWire(longName);
// Force two consecutive salt candidates to collide with real, valid tool names.
const names = [longName, other, initial, second, longName];
const codec = chatToolNameCodec(names);
const reversed = chatToolNameCodec([...names].reverse());
expect(codec.toWire(initial)).toBe(initial);
expect(codec.toWire(second)).toBe(second);
expect(codec.toWire(longName)).not.toBe(initial);
expect(codec.toWire(longName)).not.toBe(second);
expect(new Set(names.map(name => codec.toWire(name))).size).toBe(4);
for (const name of names) {
expect(codec.fromWire(codec.toWire(name))).toBe(name);
expect(codec.toWire(name)).toBe(reversed.toWire(name));
}
});
});

describe("Chat adapter reversible tool-name wire contract", () => {
for (const namespace of [undefined, "mcp_namespace"]) {
test(`declaration, forced choice, history and JSON response agree (${namespace ?? "bare"})`, async () => {
const parsed = request(namespace);
const before = JSON.stringify(parsed);
const instance = adapter();
const built = await instance.buildRequest(parsed);
const wire = JSON.parse(built.body as string);
const alias = wire.tools[0].function.name;
const history = wire.messages.filter((message: { role: string }) => message.role === "assistant" || message.role === "tool");
expect(alias).toMatch(/^[A-Za-z0-9_-]{1,64}$/);
expect(wire.tool_choice.function.name).toBe(alias);
expect(history[0].tool_calls[0].function.name).toBe(alias);
expect(history[0].tool_calls[0].function.arguments).toBe(JSON.stringify({ name: longName }));
expect(history[0].tool_calls[0].id).toBe("call_original");
expect(history[1].tool_call_id).toBe("call_original");
expect(JSON.stringify(parsed)).toBe(before);
const events = await instance.parseResponse!(Response.json({ choices: [{ message: { tool_calls: [
{ id: "response_call", function: { name: alias, arguments: "{}" } },
] } }] }));
const start = events.find(event => event.type === "tool_call_start");
const restored = namespace ? `${namespace}__${longName}` : longName;
expect(start).toEqual({ type: "tool_call_start", id: "response_call", name: restored });
if (namespace) expect(buildToolBridgeMaps(parsed).toolNsMap.get(restored)).toEqual({ namespace, name: longName });
});
}

test("streamed parallel calls retain ID association and restore bare/namespaced names", async () => {
const instance = adapter();
const parsed = request("mcp");
parsed.context.tools!.push({ name: "invalid.bare", description: "test", parameters: { type: "object" } });
const built = await instance.buildRequest(parsed);
const wire = JSON.parse(built.body as string);
const calls = wire.tools.map((tool: { function: { name: string } }, index: number) => ({ index, id: `call_${index}`, function: { name: tool.function.name, arguments: "{" } }));
const frames = [
{ choices: [{ delta: { tool_calls: calls } }] },
{ choices: [{ delta: { tool_calls: [{ index: 1, function: { arguments: '"second":true}' } }, { index: 0, function: { arguments: '"first":true}' } }] }, finish_reason: "tool_calls" }] },
];
const response = new Response(frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n");
const events: AdapterEvent[] = [];
for await (const event of instance.parseStream(response)) events.push(event);
expect(events.filter(event => event.type === "tool_call_start")).toEqual([
{ type: "tool_call_start", id: "call_0", name: `mcp__${longName}` },
{ type: "tool_call_start", id: "call_1", name: "invalid.bare" },
]);
expect(events.filter(event => event.type === "tool_call_delta")).toEqual([
{ type: "tool_call_delta", arguments: '{"first":true}' },
{ type: "tool_call_delta", arguments: '{"second":true}' },
]);
});

test("orphan namespaced results and history-only names use the same bounded alias", async () => {
const parsed = request("mcp");
parsed.context.messages.shift();
const instance = adapter();
const wire = JSON.parse((await instance.buildRequest(parsed)).body as string);
const history = wire.messages.filter((message: { role: string }) => message.role === "assistant" || message.role === "tool");
expect(history[0].tool_calls[0].function.name).toBe(wire.tools[0].function.name);
expect(history[0].tool_calls[0].id).toBe(history[1].tool_call_id);
parsed.context.tools = [];
parsed.options = {};
const historyOnly = JSON.parse((await instance.buildRequest(parsed)).body as string);
expect(historyOnly.messages[0].tool_calls[0].function.name).toMatch(/^[A-Za-z0-9_-]{1,64}$/);
});
});
Loading