Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
9 changes: 7 additions & 2 deletions packages/ai/src/protocols/utils/tool-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@ export const start = <K extends StreamKey>(
* identity on the first delta instead of a separate start event. OpenAI Chat has
* this shape: `tool_calls[].index` is the stream key, and `id` / `name` may only
* appear on the first delta for that index.
*
* Some OpenAI-compatible APIs (e.g. DashScope token-plan) send empty strings
* for `id` / `name` on subsequent deltas instead of omitting them. Treat
* empty strings as absent so the accumulated identity from the first delta
* is preserved.
*/
export const appendOrStart = <K extends StreamKey>(
route: string,
Expand All @@ -140,8 +145,8 @@ export const appendOrStart = <K extends StreamKey>(
missingToolMessage: string,
): AppendOutcome<K> | LLMError => {
const current = tools[key]
const id = delta.id ?? current?.id
const name = delta.name ?? current?.name
const id = (delta.id || undefined) ?? current?.id
const name = (delta.name || undefined) ?? current?.name
if (!id || !name) return eventError(route, missingToolMessage)

const tool = {
Expand Down
38 changes: 38 additions & 0 deletions packages/ai/test/tool-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,44 @@ describe("ToolStream", () => {
}),
)

it.effect("tolerates empty-string id and name on subsequent deltas", () =>
Effect.gen(function* () {
const first = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "calculator", text: "{" },
"missing tool",
)
if (ToolStream.isError(first)) return yield* first
// DashScope token-plan sends id:"" and name:"" on continuation deltas
const second = ToolStream.appendOrStart(
ADAPTER,
first.tools,
0,
{ id: "", name: "", text: '"expression": "2+3"}' },
"missing tool",
)
if (ToolStream.isError(second)) return yield* second
const finished = yield* ToolStream.finish(ADAPTER, second.tools, 0)

expect(first.events).toEqual([
{ type: "tool-input-start", id: "call_1", name: "calculator" },
{ type: "tool-input-delta", id: "call_1", name: "calculator", text: "{" },
])
expect(second.events).toEqual([
{ type: "tool-input-delta", id: "call_1", name: "calculator", text: '"expression": "2+3"}' },
])
expect(finished).toEqual({
tools: {},
events: [
{ type: "tool-input-end", id: "call_1", name: "calculator" },
{ type: "tool-call", id: "call_1", name: "calculator", input: { expression: "2+3" } },
],
})
}),
)

it.effect("fails appendExisting when the provider skipped the tool start", () =>
Effect.gen(function* () {
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
Expand Down
Loading