From 263a442a6c9874342fffc0a3e062641d6fd4ee7e Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:39:00 -0500 Subject: [PATCH] fix(ai): normalize chat tool call ids (#45056) --- packages/ai/src/protocols/openai-chat.ts | 33 ++++++++++------ packages/ai/test/provider/openai-chat.test.ts | 29 ++++++++++++++ .../provider/openai-compatible-chat.test.ts | 38 +++++++++++++++++++ 3 files changed, 88 insertions(+), 12 deletions(-) diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index 1eefc7e0300..14d9bdc1ecc 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -278,6 +278,7 @@ interface LoweringOptions { readonly cacheControl?: ( cache: CacheHint | undefined, ) => Schema.Schema.Type | undefined + readonly toolCallID?: (id: string) => string } const lowerTool = ( @@ -304,8 +305,8 @@ const lowerToolChoice = (toolChoice: NonNullable) => tool: (name) => ({ type: "function" as const, function: { name } }), }) -const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({ - id: part.id, +const lowerToolCall = (part: ToolCallPart, options: LoweringOptions): OpenAIChatAssistantToolCall => ({ + id: options.toolCallID?.(part.id) ?? part.id, type: "function", function: { name: part.name, @@ -381,7 +382,7 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func continue } if (part.type === "tool-call") { - toolCalls.push(lowerToolCall(part)) + toolCalls.push(lowerToolCall(part, options)) continue } } @@ -427,7 +428,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* ( if (part.result.type !== "content") { messages.push({ role: "tool", - tool_call_id: part.id, + tool_call_id: options.toolCallID?.(part.id) ?? part.id, content: ProviderShared.toolResultText(part), cache_control: options.cacheControl?.(part.cache), }) @@ -437,7 +438,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* ( const text = content.filter((item) => item.type === "text").map((item) => item.text) messages.push({ role: "tool", - tool_call_id: part.id, + tool_call_id: options.toolCallID?.(part.id) ?? part.id, content: text.join("\n"), cache_control: options.cacheControl?.(part.cache), }) @@ -478,11 +479,19 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: ] : [{ role: "system", content: ProviderShared.joinText(request.system) }] const messages = [...system] - const requireAssistantAfterTool = - request.model.compatibility?.requireAssistantAfterTool ?? - ["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => - request.model.id.toLowerCase().includes(family), - ) + const modelID = request.model.id.toLowerCase() + const mistral = ["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => modelID.includes(family)) + const lowering = { + ...options, + toolCallID: (id: string) => { + if (mistral) return id.replace(/[^a-zA-Z0-9]/g, "").slice(0, 9).padEnd(9, "0") + if (modelID.includes("claude")) return id.replace(/[^a-zA-Z0-9_-]/g, "_") + if (request.model.provider === "openai" || request.model.provider === "azure" || modelID.startsWith("openai/")) + return id.slice(0, 40) + return id + }, + } + const requireAssistantAfterTool = request.model.compatibility?.requireAssistantAfterTool ?? mistral const bridgeTools = () => { if (requireAssistantAfterTool && messages.at(-1)?.role === "tool") messages.push({ role: "assistant", content: "Done." }) } @@ -539,13 +548,13 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: if (message.role === "assistant" && message.content.every((part) => part.type === "text" && part.text.trim() === "")) continue if (message.role === "tool") { - const lowered = yield* lowerToolMessages(message, options) + const lowered = yield* lowerToolMessages(message, lowering) messages.push(...lowered.messages) pendingImages.push(...lowered.images) continue } flushImages() - messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, options))) + messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, lowering))) } flushImages() return messages diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index f24b9f5a5de..4bb79ef2e8e 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -388,6 +388,35 @@ describe("OpenAI Chat route", () => { }), ) + it.effect("limits OpenAI and Azure Chat tool call IDs to 40 characters", () => + Effect.gen(function* () { + const id = `call_${"a".repeat(48)}` + const models = [ + model, + Azure.configure({ baseURL: "https://opencode-test.openai.azure.com/openai/", apiKey: "test" }).chat("gpt-4o"), + ] + + yield* Effect.forEach(models, (selected) => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model: selected, + messages: [ + Message.assistant([ToolCallPart.make({ id, name: "lookup", input: {} })]), + Message.tool({ id, name: "lookup", result: "Sunny" }), + ], + }), + ) + + expect(prepared.body.messages).toMatchObject([ + { role: "assistant", tool_calls: [{ id: id.slice(0, 40) }] }, + { role: "tool", tool_call_id: id.slice(0, 40) }, + ]) + }), + ) + }), + ) + it.effect("preserves structured tool errors for the model", () => Effect.gen(function* () { const error = { error: { type: "unknown", message: "Tool execution interrupted" } } diff --git a/packages/ai/test/provider/openai-compatible-chat.test.ts b/packages/ai/test/provider/openai-compatible-chat.test.ts index c2748ae0135..7f801ca6759 100644 --- a/packages/ai/test/provider/openai-compatible-chat.test.ts +++ b/packages/ai/test/provider/openai-compatible-chat.test.ts @@ -238,6 +238,44 @@ describe("OpenAI-compatible Chat route", () => { }), ) + it.effect("normalizes tool call IDs for the selected model family", () => + Effect.gen(function* () { + const longID = `call_${"a".repeat(48)}` + const cases = [ + { provider: "custom", model: "mistral-small", id: "toolu_01CBhTTz95qkd9LJMdC9sf8t", expected: "toolu01CB" }, + { provider: "custom", model: "devstral-small", id: "abc", expected: "abc000000" }, + { provider: "custom", model: "codestral-latest", id: "toolu_01CBhTTz95", expected: "toolu01CB" }, + { provider: "custom", model: "pixtral-large", id: "toolu_01CBhTTz95", expected: "toolu01CB" }, + { provider: "custom", model: "open-mixtral-8x22b", id: "toolu_01CBhTTz95", expected: "toolu01CB" }, + { provider: "gateway", model: "anthropic/claude-sonnet-4", id: "call|item/+", expected: "call_item__" }, + { provider: "gateway", model: "openai/gpt-4o", id: longID, expected: longID.slice(0, 40) }, + { provider: "custom", model: "ordinary-model", id: "call|item/+", expected: "call|item/+" }, + { provider: "mistral", model: "zai-glm-5-2", id: "call_long_identifier", expected: "call_long_identifier" }, + ] + + yield* Effect.forEach(cases, (item) => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model: OpenAICompatibleChat.route + .with({ provider: item.provider, endpoint: { baseURL: "https://api.custom.test/v1" } }) + .model({ id: item.model }), + messages: [ + Message.assistant([ToolCallPart.make({ id: item.id, name: "lookup", input: {} })]), + Message.tool({ id: item.id, name: "lookup", result: { type: "content", value: [] } }), + ], + }), + ) + + expect(prepared.body.messages).toMatchObject([ + { role: "assistant", tool_calls: [{ id: item.expected }] }, + { role: "tool", tool_call_id: item.expected }, + ]) + }), + ) + }), + ) + it.effect("bridges tool results for Mistral-family models and honors compatibility overrides", () => Effect.gen(function* () { const cases = [