Skip to content

Commit 3ba8daf

Browse files
committed
fix(provider): coerce numeric tool call IDs for OpenAI-compatible providers
Some OpenAI-compatible providers (e.g. NVIDIA NIM kimi-k2.5) return numeric tool call IDs instead of strings, violating the OpenAI API spec. This causes Zod validation errors when the AI SDK processes responses. - Extract coerceNumericToolCallIds and transformSSEStream to shared utility - Apply coercion at fetch interceptor for @ai-sdk/openai-compatible responses - Coerce numeric callID on part hydration for backwards compat with existing sessions that cached numeric IDs before this fix Refs: #23886
1 parent 97dde89 commit 3ba8daf

4 files changed

Lines changed: 216 additions & 71 deletions

File tree

packages/opencode/src/provider/provider.ts

Lines changed: 1 addition & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -26,80 +26,13 @@ import { InstanceState } from "@/effect"
2626
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
2727
import { isRecord } from "@/util/record"
2828
import { withStatics } from "@/util/schema"
29+
import { coerceNumericToolCallIds, transformSSEStream } from "@/util/coerce-tool-call-ids"
2930

3031
import * as ProviderTransform from "./transform"
3132
import { ModelID, ProviderID } from "./schema"
3233

3334
const log = Log.create({ service: "provider" })
3435

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-
10336
function shouldUseCopilotResponsesApi(modelID: string): boolean {
10437
const match = /^gpt-(\d+)/.exec(modelID)
10538
if (!match) return false

packages/opencode/src/session/message-v2.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -660,13 +660,20 @@ const info = (row: typeof MessageTable.$inferSelect) =>
660660
sessionID: row.session_id,
661661
}) as Info
662662

663-
const part = (row: typeof PartTable.$inferSelect) =>
664-
({
663+
const part = (row: typeof PartTable.$inferSelect) => {
664+
// Backwards compat: coerce numeric tool call IDs from non-compliant providers
665+
// stored before the fetch-level fix
666+
const data = row.data as Record<string, unknown>
667+
if (data.type === "tool" && typeof data.callID === "number") {
668+
data.callID = String(data.callID)
669+
}
670+
return {
665671
...row.data,
666672
id: row.id,
667673
sessionID: row.session_id,
668674
messageID: row.message_id,
669-
}) as Part
675+
} as Part
676+
}
670677

671678
const older = (row: Cursor) =>
672679
or(lt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id)))
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Some OpenAI-compatible providers (e.g. NVIDIA NIM) violate the spec by
2+
// returning numeric tool call IDs. The AI SDK requires strings.
3+
4+
export function coerceNumericToolCallIds(obj: unknown): void {
5+
if (!obj || typeof obj !== "object") return
6+
if (Array.isArray(obj)) {
7+
for (const item of obj) coerceNumericToolCallIds(item)
8+
return
9+
}
10+
const record = obj as Record<string, unknown>
11+
if ("tool_calls" in record && Array.isArray(record.tool_calls)) {
12+
for (const tc of record.tool_calls) {
13+
if (tc && typeof tc === "object" && "id" in tc && typeof tc.id === "number") {
14+
tc.id = String(tc.id)
15+
}
16+
}
17+
}
18+
if ("delta" in record && record.delta && typeof record.delta === "object") {
19+
const delta = record.delta as Record<string, unknown>
20+
if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) {
21+
for (const tc of delta.tool_calls) {
22+
if (tc && typeof tc === "object" && "id" in tc && typeof tc.id === "number") {
23+
tc.id = String(tc.id)
24+
}
25+
}
26+
}
27+
}
28+
for (const value of Object.values(record)) {
29+
coerceNumericToolCallIds(value)
30+
}
31+
}
32+
33+
export function transformSSEStream(body: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
34+
const reader = body.getReader()
35+
const decoder = new TextDecoder()
36+
const encoder = new TextEncoder()
37+
let buffer = ""
38+
39+
return new ReadableStream<Uint8Array>({
40+
async pull(controller) {
41+
const { done, value } = await reader.read()
42+
if (done) {
43+
if (buffer) controller.enqueue(encoder.encode(buffer))
44+
controller.close()
45+
return
46+
}
47+
buffer += decoder.decode(value, { stream: true })
48+
const lines = buffer.split("\n")
49+
buffer = lines.pop() ?? ""
50+
for (const line of lines) {
51+
if (line.startsWith("data: ")) {
52+
const jsonStr = line.slice(6)
53+
if (jsonStr === "[DONE]") {
54+
controller.enqueue(encoder.encode(line + "\n"))
55+
continue
56+
}
57+
try {
58+
const json = JSON.parse(jsonStr)
59+
coerceNumericToolCallIds(json)
60+
controller.enqueue(encoder.encode("data: " + JSON.stringify(json) + "\n"))
61+
continue
62+
} catch {
63+
// If parsing fails, pass through unchanged
64+
}
65+
}
66+
controller.enqueue(encoder.encode(line + "\n"))
67+
}
68+
},
69+
})
70+
}
71+
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { test, expect } from "bun:test"
2+
import { coerceNumericToolCallIds, transformSSEStream } from "../../src/util/coerce-tool-call-ids"
3+
4+
test("coerceNumericToolCallIds: coerces numeric id in tool_calls", () => {
5+
const obj: Record<string, unknown> = { tool_calls: [{ id: 123, function: { name: "read" } }] }
6+
coerceNumericToolCallIds(obj)
7+
expect((obj.tool_calls as Record<string, unknown>[])[0].id).toBe("123")
8+
})
9+
10+
test("coerceNumericToolCallIds: coerces numeric id in delta.tool_calls", () => {
11+
const obj: Record<string, unknown> = { delta: { tool_calls: [{ id: 456, function: { name: "write" } }] } }
12+
coerceNumericToolCallIds(obj)
13+
const delta = obj.delta as Record<string, unknown>
14+
expect((delta.tool_calls as Record<string, unknown>[])[0].id).toBe("456")
15+
})
16+
17+
test("coerceNumericToolCallIds: leaves string ids unchanged", () => {
18+
const obj: Record<string, unknown> = { tool_calls: [{ id: "call_abc123", function: { name: "read" } }] }
19+
coerceNumericToolCallIds(obj)
20+
expect((obj.tool_calls as Record<string, unknown>[])[0].id).toBe("call_abc123")
21+
})
22+
23+
test("coerceNumericToolCallIds: handles nested objects", () => {
24+
const obj: Record<string, unknown> = { choices: [{ message: { tool_calls: [{ id: 789 }] } }] }
25+
coerceNumericToolCallIds(obj)
26+
const choice = (obj.choices as Record<string, unknown>[])[0] as Record<string, unknown>
27+
const message = choice.message as Record<string, unknown>
28+
expect((message.tool_calls as Record<string, unknown>[])[0].id).toBe("789")
29+
})
30+
31+
test("coerceNumericToolCallIds: handles null and undefined inputs", () => {
32+
expect(() => coerceNumericToolCallIds(null)).not.toThrow()
33+
expect(() => coerceNumericToolCallIds(undefined)).not.toThrow()
34+
})
35+
36+
test("coerceNumericToolCallIds: handles empty objects", () => {
37+
expect(() => coerceNumericToolCallIds({})).not.toThrow()
38+
})
39+
40+
test("coerceNumericToolCallIds: handles arrays of tool calls", () => {
41+
const obj: Record<string, unknown> = { tool_calls: [{ id: 1 }, { id: 2 }, { id: 3 }] }
42+
coerceNumericToolCallIds(obj)
43+
const tcs = obj.tool_calls as Record<string, unknown>[]
44+
expect(tcs[0].id).toBe("1")
45+
expect(tcs[1].id).toBe("2")
46+
expect(tcs[2].id).toBe("3")
47+
})
48+
49+
test("coerceNumericToolCallIds: mixed numeric and string ids", () => {
50+
const obj: Record<string, unknown> = { tool_calls: [{ id: 42 }, { id: "call_existing" }] }
51+
coerceNumericToolCallIds(obj)
52+
const tcs = obj.tool_calls as Record<string, unknown>[]
53+
expect(tcs[0].id).toBe("42")
54+
expect(tcs[1].id).toBe("call_existing")
55+
})
56+
57+
test("coerceNumericToolCallIds: handles tool_calls with non-object entries", () => {
58+
const obj = { tool_calls: [null, undefined, "string", 42] }
59+
expect(() => coerceNumericToolCallIds(obj)).not.toThrow()
60+
})
61+
62+
test("coerceNumericToolCallIds: handles primitive inputs", () => {
63+
expect(() => coerceNumericToolCallIds(42)).not.toThrow()
64+
expect(() => coerceNumericToolCallIds("string")).not.toThrow()
65+
expect(() => coerceNumericToolCallIds(true)).not.toThrow()
66+
})
67+
68+
function toStream(chunks: string[]): ReadableStream<Uint8Array> {
69+
const encoder = new TextEncoder()
70+
return new ReadableStream({
71+
start(controller) {
72+
for (const chunk of chunks) {
73+
controller.enqueue(encoder.encode(chunk))
74+
}
75+
controller.close()
76+
},
77+
})
78+
}
79+
80+
async function collect(stream: ReadableStream<Uint8Array>): Promise<string> {
81+
const reader = stream.getReader()
82+
const decoder = new TextDecoder()
83+
let result = ""
84+
while (true) {
85+
const { done, value } = await reader.read()
86+
if (done) break
87+
result += decoder.decode(value, { stream: true })
88+
}
89+
return result
90+
}
91+
92+
test("transformSSEStream: coerces numeric tool call IDs in SSE data", async () => {
93+
const input = toStream(['data: {"tool_calls":[{"id":123}]}\n\n'])
94+
const output = await collect(transformSSEStream(input))
95+
const parsed = JSON.parse(output.trim().slice(6))
96+
expect(parsed.tool_calls[0].id).toBe("123")
97+
})
98+
99+
test("transformSSEStream: passes through [DONE] unchanged", async () => {
100+
const input = toStream(["data: [DONE]\n\n"])
101+
const output = await collect(transformSSEStream(input))
102+
expect(output).toBe("data: [DONE]\n\n")
103+
})
104+
105+
test("transformSSEStream: passes through invalid JSON unchanged", async () => {
106+
const input = toStream(["data: {not valid json}\n\n"])
107+
const output = await collect(transformSSEStream(input))
108+
expect(output).toBe("data: {not valid json}\n\n")
109+
})
110+
111+
test("transformSSEStream: passes through non-data lines unchanged", async () => {
112+
const input = toStream(["event: ping\n\n"])
113+
const output = await collect(transformSSEStream(input))
114+
expect(output).toBe("event: ping\n\n")
115+
})
116+
117+
test("transformSSEStream: handles multiple SSE events in one chunk", async () => {
118+
const input = toStream([
119+
'data: {"tool_calls":[{"id":1}]}\ndata: {"tool_calls":[{"id":2}]}\n\n',
120+
])
121+
const output = await collect(transformSSEStream(input))
122+
const lines = output.split("\n").filter((l) => l.startsWith("data: "))
123+
const first = JSON.parse(lines[0].slice(6))
124+
const second = JSON.parse(lines[1].slice(6))
125+
expect(first.tool_calls[0].id).toBe("1")
126+
expect(second.tool_calls[0].id).toBe("2")
127+
})
128+
129+
test("transformSSEStream: coerces numeric IDs in delta.tool_calls", async () => {
130+
const input = toStream(['data: {"delta":{"tool_calls":[{"id":999}]}}\n\n'])
131+
const output = await collect(transformSSEStream(input))
132+
const parsed = JSON.parse(output.trim().slice(6))
133+
expect(parsed.delta.tool_calls[0].id).toBe("999")
134+
})

0 commit comments

Comments
 (0)