-
Notifications
You must be signed in to change notification settings - Fork 17.9k
fix(provider): coerce numeric tool call IDs for OpenAI-compatible providers #24026
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Qiiks
wants to merge
2
commits into
anomalyco:dev
Choose a base branch
from
Qiiks:fix/nvidia-nim-numeric-tool-call-ids-v2
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+215
−3
Open
Changes from 1 commit
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
134
packages/opencode/test/util/coerce-tool-call-ids.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
| }) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.