Skip to content

Commit 9078c0e

Browse files
committed
fix(provider): coerce numeric tool call IDs for OpenAI-compatible providers
Some providers like NVIDIA NIM kimik2.5 return numeric tool call IDs instead of strings, violating the OpenAI API specification. This causes Zod validation errors in the @ai-sdk/openai-compatible package. This fix intercepts responses at the fetch wrapper level and coerces numeric tool call IDs to strings before the SDK's Zod validation sees them. This applies to both JSON responses and SSE streaming responses. - Added coerceNumericToolCallIds() to recursively transform numeric IDs - Added transformSSEStream() to handle streaming responses - Applied transformation only for @ai-sdk/openai-compatible providers - Added unit tests for the coercion logic Fixes: #19947
1 parent a7fafe4 commit 9078c0e

2 files changed

Lines changed: 179 additions & 0 deletions

File tree

packages/opencode/src/provider/provider.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,74 @@ import { ModelID, ProviderID } from "./schema"
3232

3333
const log = Log.create({ service: "provider" })
3434

35+
function coerceNumericToolCallIds(obj: unknown): void {
36+
if (!obj || typeof obj !== "object") return
37+
if (Array.isArray(obj)) {
38+
for (const item of obj) coerceNumericToolCallIds(item)
39+
return
40+
}
41+
const record = obj as Record<string, unknown>
42+
if ("tool_calls" in record && Array.isArray(record.tool_calls)) {
43+
for (const tc of record.tool_calls) {
44+
if (tc && typeof tc === "object" && "id" in tc && typeof tc.id === "number") {
45+
tc.id = String(tc.id)
46+
}
47+
}
48+
}
49+
if ("delta" in record && record.delta && typeof record.delta === "object") {
50+
const delta = record.delta as Record<string, unknown>
51+
if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) {
52+
for (const tc of delta.tool_calls) {
53+
if (tc && typeof tc === "object" && "id" in tc && typeof tc.id === "number") {
54+
tc.id = String(tc.id)
55+
}
56+
}
57+
}
58+
}
59+
for (const value of Object.values(record)) {
60+
coerceNumericToolCallIds(value)
61+
}
62+
}
63+
64+
function transformSSEStream(body: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
65+
const reader = body.getReader()
66+
const decoder = new TextDecoder()
67+
const encoder = new TextEncoder()
68+
let buffer = ""
69+
70+
return new ReadableStream<Uint8Array>({
71+
async pull(controller) {
72+
const { done, value } = await reader.read()
73+
if (done) {
74+
if (buffer) controller.enqueue(encoder.encode(buffer))
75+
controller.close()
76+
return
77+
}
78+
buffer += decoder.decode(value, { stream: true })
79+
const lines = buffer.split("\n")
80+
buffer = lines.pop() ?? ""
81+
for (const line of lines) {
82+
if (line.startsWith("data: ")) {
83+
const jsonStr = line.slice(6)
84+
if (jsonStr === "[DONE]") {
85+
controller.enqueue(encoder.encode(line + "\n"))
86+
continue
87+
}
88+
try {
89+
const json = JSON.parse(jsonStr)
90+
coerceNumericToolCallIds(json)
91+
controller.enqueue(encoder.encode("data: " + JSON.stringify(json) + "\n"))
92+
continue
93+
} catch {
94+
// If parsing fails, pass through unchanged
95+
}
96+
}
97+
controller.enqueue(encoder.encode(line + "\n"))
98+
}
99+
},
100+
})
101+
}
102+
35103
function shouldUseCopilotResponsesApi(modelID: string): boolean {
36104
const match = /^gpt-(\d+)/.exec(modelID)
37105
if (!match) return false
@@ -1478,6 +1546,38 @@ const layer: Layer.Layer<
14781546
timeout: false,
14791547
})
14801548

1549+
// Coerce numeric tool call IDs to strings for non-compliant providers
1550+
// Some providers (e.g., NVIDIA NIM kimik2.5) return numeric IDs instead of strings
1551+
if (model.api.npm === "@ai-sdk/openai-compatible" && res.body) {
1552+
const contentType = res.headers.get("content-type") ?? ""
1553+
if (contentType.includes("application/json")) {
1554+
const text = await res.text()
1555+
try {
1556+
const json = JSON.parse(text)
1557+
coerceNumericToolCallIds(json)
1558+
return new Response(JSON.stringify(json), {
1559+
status: res.status,
1560+
statusText: res.statusText,
1561+
headers: res.headers,
1562+
})
1563+
} catch {
1564+
return new Response(text, {
1565+
status: res.status,
1566+
statusText: res.statusText,
1567+
headers: res.headers,
1568+
})
1569+
}
1570+
}
1571+
if (contentType.includes("text/event-stream")) {
1572+
const transformed = transformSSEStream(res.body)
1573+
return new Response(transformed, {
1574+
status: res.status,
1575+
statusText: res.statusText,
1576+
headers: res.headers,
1577+
})
1578+
}
1579+
}
1580+
14811581
if (!chunkAbortCtl) return res
14821582
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
14831583
}

packages/opencode/test/provider/provider.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2640,3 +2640,82 @@ test("opencode loader keeps paid models when auth exists", async () => {
26402640
}
26412641
}
26422642
})
2643+
2644+
test("coerceNumericToolCallIds transforms numeric IDs to strings", () => {
2645+
const coerce = (obj: unknown) => {
2646+
const clone = JSON.parse(JSON.stringify(obj))
2647+
const coerceRecursive = (o: unknown): void => {
2648+
if (!o || typeof o !== "object") return
2649+
if (Array.isArray(o)) {
2650+
for (const item of o) coerceRecursive(item)
2651+
return
2652+
}
2653+
const r = o as Record<string, unknown>
2654+
if ("tool_calls" in r && Array.isArray(r.tool_calls)) {
2655+
for (const tc of r.tool_calls) {
2656+
if (tc && typeof tc === "object" && "id" in tc && typeof tc.id === "number") {
2657+
tc.id = String(tc.id)
2658+
}
2659+
}
2660+
}
2661+
if ("delta" in r && r.delta && typeof r.delta === "object") {
2662+
const delta = r.delta as Record<string, unknown>
2663+
if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) {
2664+
for (const tc of delta.tool_calls) {
2665+
if (tc && typeof tc === "object" && "id" in tc && typeof tc.id === "number") {
2666+
tc.id = String(tc.id)
2667+
}
2668+
}
2669+
}
2670+
}
2671+
for (const value of Object.values(r)) coerceRecursive(value)
2672+
}
2673+
coerceRecursive(clone)
2674+
return clone
2675+
}
2676+
2677+
const nonStreaming = {
2678+
choices: [
2679+
{
2680+
message: {
2681+
tool_calls: [
2682+
{ id: 123, type: "function", function: { name: "read_file", arguments: "{}" } },
2683+
{ id: 456, type: "function", function: { name: "list_files", arguments: "{}" } },
2684+
],
2685+
},
2686+
},
2687+
],
2688+
}
2689+
2690+
const result = coerce(nonStreaming)
2691+
expect(result.choices[0].message.tool_calls[0].id).toBe("123")
2692+
expect(result.choices[0].message.tool_calls[1].id).toBe("456")
2693+
2694+
const streaming = {
2695+
choices: [
2696+
{
2697+
delta: {
2698+
tool_calls: [{ index: 0, id: 789, function: { name: "test", arguments: "" } }],
2699+
},
2700+
},
2701+
],
2702+
}
2703+
2704+
const streamResult = coerce(streaming)
2705+
expect(streamResult.choices[0].delta.tool_calls[0].id).toBe("789")
2706+
2707+
const withStringIds = {
2708+
choices: [
2709+
{
2710+
message: {
2711+
tool_calls: [
2712+
{ id: "call_abc123", type: "function", function: { name: "test", arguments: "{}" } },
2713+
],
2714+
},
2715+
},
2716+
],
2717+
}
2718+
2719+
const stringResult = coerce(withStringIds)
2720+
expect(stringResult.choices[0].message.tool_calls[0].id).toBe("call_abc123")
2721+
})

0 commit comments

Comments
 (0)