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
1 change: 1 addition & 0 deletions changelog.d/fixes/16490-openai-overlong-tool-call-ids.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(sse):** normalize tool-call IDs longer than 64 characters before sending to OpenAI, keeping assistant calls and tool results linked ([#16490](https://github.com/diegosouzapw/OmniRoute/pull/16490)) — thanks @mwpn
6 changes: 3 additions & 3 deletions open-sse/executors/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts";
import { resolveZaiUrl } from "./default/zaiFormatOverride.ts";
import { normalizePoolConfig, rejectStrictPool } from "./default/poolConfig.ts";
import { acquireNvidiaConcurrencySlot } from "./default/nvidiaConcurrencyGate.ts";
import { normalizeOpenAIToolCallIds } from "./default/openaiToolCallIds.ts";
import { resolveAlibabaProviderBaseUrl } from "@/shared/constants/alibabaProviderRegions";
import { xiaomiAlternateUrl, xiaomiMimoChatUrl } from "./default/xiaomiTokenPlan.ts";
import { usesCcWireImage } from "../services/ccWireImageBuiltins.ts";
Expand Down Expand Up @@ -821,9 +822,8 @@ export class DefaultExecutor extends BaseExecutor {
withDefaults = defaultPerplexityAgentMaxOutputTokens(withDefaults);
}

if (this.provider === "nvidia") {
normalizeNvidiaToolCallIds(withDefaults);
}
if (this.provider === "nvidia") normalizeNvidiaToolCallIds(withDefaults);
if (this.provider === "openai") withDefaults = normalizeOpenAIToolCallIds(withDefaults);

// Port of decolua/9router commit d652300e:
// Cerebras returns 400 (wrong_api_format), Mistral returns 422
Expand Down
79 changes: 79 additions & 0 deletions open-sse/executors/default/openaiToolCallIds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { createHash } from "node:crypto";

/**
* OpenAI Chat Completions rejects tool-call IDs longer than 64 characters
* (`Invalid 'messages[N].tool_calls[0].id': string too long`). Clients such as
* Cursor replay IDs minted elsewhere that exceed that cap.
*
* Every distinct overlong ID is rewritten once per request to
* `<sanitized 20-char prefix>_<sha256 base64url>` (≤ 64 chars, `[A-Za-z0-9_-]`),
* so `assistant.tool_calls[].id` and `tool.tool_call_id` stay linked regardless of
* encounter order. The digest covers the full ID, so IDs that only differ past the
* retained prefix never collide, and the mapping is deterministic across turns.
* IDs of 64 characters or fewer are left untouched.
*/
export const OPENAI_TOOL_CALL_ID_MAX_LENGTH = 64;
const OPENAI_TOOL_CALL_ID_PREFIX_LENGTH = 20;

type JsonRecord = Record<string, unknown>;

function isRecord(value: unknown): value is JsonRecord {
return !!value && typeof value === "object" && !Array.isArray(value);
}

export function normalizeOpenAIToolCallId(id: string): string {
if (id.length <= OPENAI_TOOL_CALL_ID_MAX_LENGTH) return id;
const prefix =
id.replace(/[^A-Za-z0-9_-]/g, "").slice(0, OPENAI_TOOL_CALL_ID_PREFIX_LENGTH) || "call";
const digest = createHash("sha256").update(id).digest("base64url");
return `${prefix}_${digest}`;
}

function isOverlongId(id: unknown): id is string {
return typeof id === "string" && id.length > OPENAI_TOOL_CALL_ID_MAX_LENGTH;
}

/**
* Copy-on-write: returns the original body when nothing needs rewriting; otherwise a
* shallow copy whose touched messages / tool calls are fresh objects, so the caller's
* body (shared with logging, replay and combo fallback paths) is never mutated.
*/
export function normalizeOpenAIToolCallIds<T>(body: T): T {
if (!isRecord(body) || !Array.isArray(body.messages)) return body;

const cache = new Map<string, string>();
const normalize = (id: string): string => {
let normalized = cache.get(id);
if (normalized === undefined) {
normalized = normalizeOpenAIToolCallId(id);
cache.set(id, normalized);
}
return normalized;
};

let changed = false;
const messages = body.messages.map((message: unknown) => {
if (!isRecord(message)) return message;
let next: JsonRecord | null = null;

if (message.role === "assistant" && Array.isArray(message.tool_calls)) {
let callsChanged = false;
const toolCalls = message.tool_calls.map((toolCall: unknown) => {
if (!isRecord(toolCall) || !isOverlongId(toolCall.id)) return toolCall;
callsChanged = true;
return { ...toolCall, id: normalize(toolCall.id) };
});
if (callsChanged) next = { ...message, tool_calls: toolCalls };
}

if (message.role === "tool" && isOverlongId(message.tool_call_id)) {
next = { ...(next ?? message), tool_call_id: normalize(message.tool_call_id) };
}

if (!next) return message;
changed = true;
return next;
});

return changed ? ({ ...body, messages } as T) : body;
}
169 changes: 169 additions & 0 deletions tests/unit/openai-tool-call-id-normalization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import assert from "node:assert/strict";
import test from "node:test";

import { DefaultExecutor } from "../../open-sse/executors/default.ts";

type ToolCall = { id: string; type: string; function: { name: string; arguments: string } };
type Message = {
role: string;
content?: string | null;
tool_calls?: ToolCall[];
tool_call_id?: string;
};
type ChatBody = { model: string; messages: Message[]; stream: boolean };

const VALID_TOOL_CALL_ID = /^[A-Za-z0-9_-]+$/;
const OVERLONG_ID = `call_${"a".repeat(81)}`;

function toolCall(id: string, name = "lookup"): ToolCall {
return { id, type: "function", function: { name, arguments: "{}" } };
}

function request(messages: Message[]): ChatBody {
return { model: "gpt-5.6-luna", messages, stream: false };
}

function transform(provider: string, body: ChatBody): ChatBody {
return new DefaultExecutor(provider).transformRequest(
"gpt-5.6-luna",
body,
true,
null
) as ChatBody;
}

test("openai: normalizes an 86-char tool-call ID and keeps call/result linked", () => {
const shortId = "call_short_1";
const out = transform(
"openai",
request([
{ role: "assistant", content: null, tool_calls: [toolCall(shortId)] },
{ role: "tool", tool_call_id: shortId, content: "short result" },
{ role: "assistant", content: null, tool_calls: [toolCall(OVERLONG_ID)] },
{ role: "tool", tool_call_id: OVERLONG_ID, content: "long result" },
])
);

const normalized = out.messages[2].tool_calls?.[0].id ?? "";
assert.equal(OVERLONG_ID.length, 86);
assert.equal(normalized.length, 64);
assert.match(normalized, VALID_TOOL_CALL_ID);
assert.equal(out.messages[3].tool_call_id, normalized);
assert.equal(out.messages[0].tool_calls?.[0].id, shortId);
assert.equal(out.messages[1].tool_call_id, shortId);
});

test("openai: IDs of exactly 64 characters are left untouched", () => {
const id64 = `call_${"b".repeat(59)}`;
assert.equal(id64.length, 64);
const out = transform(
"openai",
request([
{ role: "assistant", content: null, tool_calls: [toolCall(id64)] },
{ role: "tool", tool_call_id: id64, content: "ok" },
])
);
assert.equal(out.messages[0].tool_calls?.[0].id, id64);
assert.equal(out.messages[1].tool_call_id, id64);
});

test("openai: repeated occurrences of the same overlong ID normalize identically", () => {
const out = transform(
"openai",
request([
{ role: "assistant", content: null, tool_calls: [toolCall(OVERLONG_ID, "first")] },
{ role: "tool", tool_call_id: OVERLONG_ID, content: "first result" },
{ role: "assistant", content: null, tool_calls: [toolCall(OVERLONG_ID, "replayed")] },
{ role: "tool", tool_call_id: OVERLONG_ID, content: "replayed result" },
])
);
const ids = [
out.messages[0].tool_calls?.[0].id,
out.messages[1].tool_call_id,
out.messages[2].tool_calls?.[0].id,
out.messages[3].tool_call_id,
];
assert.equal(new Set(ids).size, 1);
assert.notEqual(ids[0], OVERLONG_ID);
});

test("openai: a tool result before its assistant call still matches", () => {
const out = transform(
"openai",
request([
{ role: "tool", tool_call_id: OVERLONG_ID, content: "result first" },
{ role: "assistant", content: null, tool_calls: [toolCall(OVERLONG_ID)] },
])
);
assert.equal(out.messages[0].tool_call_id, out.messages[1].tool_calls?.[0].id);
assert.notEqual(out.messages[0].tool_call_id, OVERLONG_ID);
});

test("openai: IDs sharing the first 64 chars stay distinct", () => {
const sharedPrefix = `call_${"z".repeat(80)}`;
const firstId = `${sharedPrefix}x`;
const secondId = `${sharedPrefix}y`;
const out = transform(
"openai",
request([
{ role: "assistant", content: null, tool_calls: [toolCall(firstId), toolCall(secondId)] },
{ role: "tool", tool_call_id: firstId, content: "first" },
{ role: "tool", tool_call_id: secondId, content: "second" },
])
);
const [first, second] = (out.messages[0].tool_calls ?? []).map(({ id }) => id);
assert.equal(firstId.slice(0, 64), secondId.slice(0, 64));
assert.notEqual(first, second);
assert.equal(first.length, 64);
assert.equal(second.length, 64);
assert.match(first, VALID_TOOL_CALL_ID);
assert.match(second, VALID_TOOL_CALL_ID);
assert.equal(out.messages[1].tool_call_id, first);
assert.equal(out.messages[2].tool_call_id, second);
});

test("openai: unsafe characters are stripped from the retained prefix", () => {
const unsafeId = `toolu:01/${"x".repeat(80)}`;
const out = transform(
"openai",
request([{ role: "assistant", content: null, tool_calls: [toolCall(unsafeId)] }])
);
const normalized = out.messages[0].tool_calls?.[0].id ?? "";
assert.ok(normalized.length <= 64);
assert.match(normalized, VALID_TOOL_CALL_ID);
assert.ok(normalized.startsWith("toolu01"));
});

test("openai: normalization is deterministic across independent requests", () => {
const normalize = () =>
transform(
"openai",
request([{ role: "assistant", content: null, tool_calls: [toolCall(OVERLONG_ID)] }])
).messages[0].tool_calls?.[0].id;
assert.equal(normalize(), normalize());
});

test("openai: the caller's message objects are not mutated", () => {
const body = request([
{ role: "assistant", content: null, tool_calls: [toolCall(OVERLONG_ID)] },
{ role: "tool", tool_call_id: OVERLONG_ID, content: "result" },
]);
const snapshot = structuredClone(body);
const out = transform("openai", body);
assert.notEqual(out.messages[0].tool_calls?.[0].id, OVERLONG_ID);
assert.deepEqual(body, snapshot);
});

for (const provider of ["openrouter", "openai-compatible-custom"]) {
test(`${provider}: overlong tool-call IDs are not normalized`, () => {
const out = transform(
provider,
request([
{ role: "assistant", content: null, tool_calls: [toolCall(OVERLONG_ID)] },
{ role: "tool", tool_call_id: OVERLONG_ID, content: "result" },
])
);
assert.equal(out.messages[0].tool_calls?.[0].id, OVERLONG_ID);
assert.equal(out.messages[1].tool_call_id, OVERLONG_ID);
});
}
Loading