Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { isRecord } from "@/util/record"
import { withStatics } from "@/util/schema"
import { coerceNumericToolCallIds, transformSSEStream } from "@/util/coerce-tool-call-ids"
Comment thread
Qiiks marked this conversation as resolved.
Outdated

import * as ProviderTransform from "./transform"
import { ModelID, ProviderID } from "./schema"
Expand Down
13 changes: 10 additions & 3 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,13 +666,20 @@ const info = (row: typeof MessageTable.$inferSelect) =>
sessionID: row.session_id,
}) as Info

const part = (row: typeof PartTable.$inferSelect) =>
({
const part = (row: typeof PartTable.$inferSelect) => {
// Backwards compat: coerce numeric tool call IDs from non-compliant providers
// stored before the fetch-level fix
const data = row.data as Record<string, unknown>
if (data.type === "tool" && typeof data.callID === "number") {
data.callID = String(data.callID)
}
return {
...row.data,
id: row.id,
sessionID: row.session_id,
messageID: row.message_id,
}) as Part
} as Part
}

const older = (row: Cursor) =>
or(lt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id)))
Expand Down
71 changes: 71 additions & 0 deletions packages/opencode/src/util/coerce-tool-call-ids.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Some OpenAI-compatible providers (e.g. NVIDIA NIM) violate the spec by
// returning numeric tool call IDs. The AI SDK requires strings.

export function coerceNumericToolCallIds(obj: unknown): void {
if (!obj || typeof obj !== "object") return
if (Array.isArray(obj)) {
for (const item of obj) coerceNumericToolCallIds(item)
return
}
const record = obj as Record<string, unknown>
if ("tool_calls" in record && Array.isArray(record.tool_calls)) {
for (const tc of record.tool_calls) {
if (tc && typeof tc === "object" && "id" in tc && typeof tc.id === "number") {
tc.id = String(tc.id)
}
}
}
if ("delta" in record && record.delta && typeof record.delta === "object") {
const delta = record.delta as Record<string, unknown>
if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) {
for (const tc of delta.tool_calls) {
if (tc && typeof tc === "object" && "id" in tc && typeof tc.id === "number") {
tc.id = String(tc.id)
}
}
}
}
for (const value of Object.values(record)) {
coerceNumericToolCallIds(value)
}
}

export function transformSSEStream(body: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
const reader = body.getReader()
const decoder = new TextDecoder()
const encoder = new TextEncoder()
let buffer = ""

return new ReadableStream<Uint8Array>({
async pull(controller) {
const { done, value } = await reader.read()
if (done) {
if (buffer) controller.enqueue(encoder.encode(buffer))
controller.close()
return
}
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() ?? ""
for (const line of lines) {
if (line.startsWith("data: ")) {
const jsonStr = line.slice(6)
if (jsonStr === "[DONE]") {
controller.enqueue(encoder.encode(line + "\n"))
continue
}
try {
const json = JSON.parse(jsonStr)
coerceNumericToolCallIds(json)
controller.enqueue(encoder.encode("data: " + JSON.stringify(json) + "\n"))
continue
} catch {
// If parsing fails, pass through unchanged
}
}
controller.enqueue(encoder.encode(line + "\n"))
}
},
})
}

134 changes: 134 additions & 0 deletions packages/opencode/test/util/coerce-tool-call-ids.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { test, expect } from "bun:test"
import { coerceNumericToolCallIds, transformSSEStream } from "../../src/util/coerce-tool-call-ids"

test("coerceNumericToolCallIds: coerces numeric id in tool_calls", () => {
const obj: Record<string, unknown> = { tool_calls: [{ id: 123, function: { name: "read" } }] }
coerceNumericToolCallIds(obj)
expect((obj.tool_calls as Record<string, unknown>[])[0].id).toBe("123")
})

test("coerceNumericToolCallIds: coerces numeric id in delta.tool_calls", () => {
const obj: Record<string, unknown> = { delta: { tool_calls: [{ id: 456, function: { name: "write" } }] } }
coerceNumericToolCallIds(obj)
const delta = obj.delta as Record<string, unknown>
expect((delta.tool_calls as Record<string, unknown>[])[0].id).toBe("456")
})

test("coerceNumericToolCallIds: leaves string ids unchanged", () => {
const obj: Record<string, unknown> = { tool_calls: [{ id: "call_abc123", function: { name: "read" } }] }
coerceNumericToolCallIds(obj)
expect((obj.tool_calls as Record<string, unknown>[])[0].id).toBe("call_abc123")
})

test("coerceNumericToolCallIds: handles nested objects", () => {
const obj: Record<string, unknown> = { choices: [{ message: { tool_calls: [{ id: 789 }] } }] }
coerceNumericToolCallIds(obj)
const choice = (obj.choices as Record<string, unknown>[])[0] as Record<string, unknown>
const message = choice.message as Record<string, unknown>
expect((message.tool_calls as Record<string, unknown>[])[0].id).toBe("789")
})

test("coerceNumericToolCallIds: handles null and undefined inputs", () => {
expect(() => coerceNumericToolCallIds(null)).not.toThrow()
expect(() => coerceNumericToolCallIds(undefined)).not.toThrow()
})

test("coerceNumericToolCallIds: handles empty objects", () => {
expect(() => coerceNumericToolCallIds({})).not.toThrow()
})

test("coerceNumericToolCallIds: handles arrays of tool calls", () => {
const obj: Record<string, unknown> = { tool_calls: [{ id: 1 }, { id: 2 }, { id: 3 }] }
coerceNumericToolCallIds(obj)
const tcs = obj.tool_calls as Record<string, unknown>[]
expect(tcs[0].id).toBe("1")
expect(tcs[1].id).toBe("2")
expect(tcs[2].id).toBe("3")
})

test("coerceNumericToolCallIds: mixed numeric and string ids", () => {
const obj: Record<string, unknown> = { tool_calls: [{ id: 42 }, { id: "call_existing" }] }
coerceNumericToolCallIds(obj)
const tcs = obj.tool_calls as Record<string, unknown>[]
expect(tcs[0].id).toBe("42")
expect(tcs[1].id).toBe("call_existing")
})

test("coerceNumericToolCallIds: handles tool_calls with non-object entries", () => {
const obj = { tool_calls: [null, undefined, "string", 42] }
expect(() => coerceNumericToolCallIds(obj)).not.toThrow()
})

test("coerceNumericToolCallIds: handles primitive inputs", () => {
expect(() => coerceNumericToolCallIds(42)).not.toThrow()
expect(() => coerceNumericToolCallIds("string")).not.toThrow()
expect(() => coerceNumericToolCallIds(true)).not.toThrow()
})

function toStream(chunks: string[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder()
return new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk))
}
controller.close()
},
})
}

async function collect(stream: ReadableStream<Uint8Array>): Promise<string> {
const reader = stream.getReader()
const decoder = new TextDecoder()
let result = ""
while (true) {
const { done, value } = await reader.read()
if (done) break
result += decoder.decode(value, { stream: true })
}
return result
}

test("transformSSEStream: coerces numeric tool call IDs in SSE data", async () => {
const input = toStream(['data: {"tool_calls":[{"id":123}]}\n\n'])
const output = await collect(transformSSEStream(input))
const parsed = JSON.parse(output.trim().slice(6))
expect(parsed.tool_calls[0].id).toBe("123")
})

test("transformSSEStream: passes through [DONE] unchanged", async () => {
const input = toStream(["data: [DONE]\n\n"])
const output = await collect(transformSSEStream(input))
expect(output).toBe("data: [DONE]\n\n")
})

test("transformSSEStream: passes through invalid JSON unchanged", async () => {
const input = toStream(["data: {not valid json}\n\n"])
const output = await collect(transformSSEStream(input))
expect(output).toBe("data: {not valid json}\n\n")
})

test("transformSSEStream: passes through non-data lines unchanged", async () => {
const input = toStream(["event: ping\n\n"])
const output = await collect(transformSSEStream(input))
expect(output).toBe("event: ping\n\n")
})

test("transformSSEStream: handles multiple SSE events in one chunk", async () => {
const input = toStream([
'data: {"tool_calls":[{"id":1}]}\ndata: {"tool_calls":[{"id":2}]}\n\n',
])
const output = await collect(transformSSEStream(input))
const lines = output.split("\n").filter((l) => l.startsWith("data: "))
const first = JSON.parse(lines[0].slice(6))
const second = JSON.parse(lines[1].slice(6))
expect(first.tool_calls[0].id).toBe("1")
expect(second.tool_calls[0].id).toBe("2")
})

test("transformSSEStream: coerces numeric IDs in delta.tool_calls", async () => {
const input = toStream(['data: {"delta":{"tool_calls":[{"id":999}]}}\n\n'])
const output = await collect(transformSSEStream(input))
const parsed = JSON.parse(output.trim().slice(6))
expect(parsed.delta.tool_calls[0].id).toBe("999")
})
Loading