Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 1591a3c

Browse files
committed
fix(huggingface): handle non-streaming tool calls without UI duplication
- Add createAiSdkToolStreamProcessor() to handle tool call deduplication - Tracks tool IDs seen via tool-input-start streaming events - Emits tool-call events only for tools that weren't streamed - Converts tool-call to start/delta/end for UI consistency - Update HuggingFace provider to use the new processor - Add comprehensive tests for the new functionality
1 parent 5e4c11f commit 1591a3c

4 files changed

Lines changed: 300 additions & 9 deletions

File tree

src/api/providers/__tests__/huggingface.spec.ts

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -550,7 +550,9 @@ describe("HuggingFaceHandler", () => {
550550
expect(toolCallEndChunks[0].id).toBe("tool-call-1")
551551
})
552552

553-
it("should ignore tool-call events to prevent duplicate tools in UI", async () => {
553+
it("should process tool-call events for non-streaming providers", async () => {
554+
// HuggingFace doesn't stream tool inputs, it only emits tool-call events
555+
// The processor should convert tool-call to start/delta/end events
554556
async function* mockFullStream() {
555557
yield {
556558
type: "tool-call",
@@ -579,11 +581,65 @@ describe("HuggingFaceHandler", () => {
579581
chunks.push(chunk)
580582
}
581583

582-
// tool-call events should be ignored (only tool-input-start/delta/end are processed)
583-
const toolCallChunks = chunks.filter(
584-
(c) => c.type === "tool_call_start" || c.type === "tool_call_delta" || c.type === "tool_call_end",
585-
)
586-
expect(toolCallChunks.length).toBe(0)
584+
// tool-call events should be converted to start/delta/end for consistency
585+
const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start")
586+
const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta")
587+
const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end")
588+
589+
expect(toolCallStartChunks.length).toBe(1)
590+
expect(toolCallStartChunks[0].id).toBe("tool-call-1")
591+
expect(toolCallStartChunks[0].name).toBe("read_file")
592+
593+
expect(toolCallDeltaChunks.length).toBe(1)
594+
expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}')
595+
596+
expect(toolCallEndChunks.length).toBe(1)
597+
expect(toolCallEndChunks[0].id).toBe("tool-call-1")
598+
})
599+
600+
it("should ignore tool-call events when tool was already streamed", async () => {
601+
// When a provider streams tool inputs AND sends tool-call, we should not duplicate
602+
async function* mockFullStream() {
603+
// First, streaming events
604+
yield { type: "tool-input-start", id: "tool-call-1", toolName: "read_file" }
605+
yield { type: "tool-input-delta", id: "tool-call-1", delta: '{"path":"test.ts"}' }
606+
yield { type: "tool-input-end", id: "tool-call-1" }
607+
// Then the tool-call event (should be ignored)
608+
yield {
609+
type: "tool-call",
610+
toolCallId: "tool-call-1",
611+
toolName: "read_file",
612+
input: { path: "test.ts" },
613+
}
614+
}
615+
616+
const mockUsage = Promise.resolve({
617+
inputTokens: 10,
618+
outputTokens: 5,
619+
})
620+
621+
const mockProviderMetadata = Promise.resolve({})
622+
623+
mockStreamText.mockReturnValue({
624+
fullStream: mockFullStream(),
625+
usage: mockUsage,
626+
providerMetadata: mockProviderMetadata,
627+
})
628+
629+
const stream = handler.createMessage(systemPrompt, messages)
630+
const chunks: any[] = []
631+
for await (const chunk of stream) {
632+
chunks.push(chunk)
633+
}
634+
635+
// Should have exactly 1 of each (not duplicated)
636+
const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start")
637+
const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta")
638+
const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end")
639+
640+
expect(toolCallStartChunks.length).toBe(1)
641+
expect(toolCallDeltaChunks.length).toBe(1)
642+
expect(toolCallEndChunks.length).toBe(1)
587643
})
588644
})
589645
})

src/api/providers/huggingface.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { ApiHandlerOptions } from "../../shared/api"
99
import {
1010
convertToAiSdkMessages,
1111
convertToolsForAiSdk,
12-
processAiSdkStreamPart,
12+
createAiSdkToolStreamProcessor,
1313
mapToolChoice,
1414
handleAiSdkError,
1515
} from "../transform/ai-sdk"
@@ -168,9 +168,12 @@ export class HuggingFaceHandler extends BaseProvider implements SingleCompletion
168168
const result = streamText(requestOptions)
169169

170170
try {
171-
// Process the full stream to get all events including reasoning
171+
// Use the stateful processor to handle tool call deduplication
172+
// HuggingFace doesn't emit streaming tool events (tool-input-start/delta/end),
173+
// only the final tool-call event, so we need the processor to handle this
174+
const processStreamPart = createAiSdkToolStreamProcessor()
172175
for await (const part of result.fullStream) {
173-
for (const chunk of processAiSdkStreamPart(part)) {
176+
for (const chunk of processStreamPart(part)) {
174177
yield chunk
175178
}
176179
}

src/api/transform/__tests__/ai-sdk.spec.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
convertToAiSdkMessages,
55
convertToolsForAiSdk,
66
processAiSdkStreamPart,
7+
createAiSdkToolStreamProcessor,
78
mapToolChoice,
89
extractAiSdkErrorMessage,
910
handleAiSdkError,
@@ -495,6 +496,148 @@ describe("AI SDK conversion utilities", () => {
495496
})
496497
})
497498

499+
describe("createAiSdkToolStreamProcessor", () => {
500+
it("processes text-delta chunks like processAiSdkStreamPart", () => {
501+
const processor = createAiSdkToolStreamProcessor()
502+
const part = { type: "text-delta" as const, id: "1", text: "Hello" }
503+
const chunks = [...processor(part)]
504+
505+
expect(chunks).toHaveLength(1)
506+
expect(chunks[0]).toEqual({ type: "text", text: "Hello" })
507+
})
508+
509+
it("processes tool-input-start/delta/end events (streaming tools)", () => {
510+
const processor = createAiSdkToolStreamProcessor()
511+
512+
// Simulate streaming tool events
513+
const startChunks = [
514+
...processor({ type: "tool-input-start" as const, id: "call_1", toolName: "read_file" }),
515+
]
516+
const deltaChunks = [...processor({ type: "tool-input-delta" as const, id: "call_1", delta: '{"path":' })]
517+
const delta2Chunks = [
518+
...processor({ type: "tool-input-delta" as const, id: "call_1", delta: '"test.ts"}' }),
519+
]
520+
const endChunks = [...processor({ type: "tool-input-end" as const, id: "call_1" })]
521+
522+
expect(startChunks).toHaveLength(1)
523+
expect(startChunks[0]).toEqual({ type: "tool_call_start", id: "call_1", name: "read_file" })
524+
525+
expect(deltaChunks).toHaveLength(1)
526+
expect(deltaChunks[0]).toEqual({ type: "tool_call_delta", id: "call_1", delta: '{"path":' })
527+
528+
expect(delta2Chunks).toHaveLength(1)
529+
expect(delta2Chunks[0]).toEqual({ type: "tool_call_delta", id: "call_1", delta: '"test.ts"}' })
530+
531+
expect(endChunks).toHaveLength(1)
532+
expect(endChunks[0]).toEqual({ type: "tool_call_end", id: "call_1" })
533+
})
534+
535+
it("ignores tool-call events when tool was already streamed", () => {
536+
const processor = createAiSdkToolStreamProcessor()
537+
538+
// Process streaming events first (consume the generator to update state)
539+
Array.from(processor({ type: "tool-input-start" as const, id: "call_1", toolName: "read_file" }))
540+
Array.from(processor({ type: "tool-input-delta" as const, id: "call_1", delta: '{"path":"test.ts"}' }))
541+
Array.from(processor({ type: "tool-input-end" as const, id: "call_1" }))
542+
543+
// Now the tool-call event for the same tool should be ignored
544+
const toolCallChunks = [
545+
...processor({
546+
type: "tool-call" as const,
547+
toolCallId: "call_1",
548+
toolName: "read_file",
549+
input: { path: "test.ts" },
550+
} as any),
551+
]
552+
553+
expect(toolCallChunks).toHaveLength(0)
554+
})
555+
556+
it("processes tool-call events for non-streaming providers", () => {
557+
const processor = createAiSdkToolStreamProcessor()
558+
559+
// Directly process a tool-call event (no streaming events first)
560+
const chunks = [
561+
...processor({
562+
type: "tool-call" as const,
563+
toolCallId: "call_1",
564+
toolName: "read_file",
565+
input: { path: "test.ts" },
566+
} as any),
567+
]
568+
569+
// Should emit start/delta/end events
570+
expect(chunks).toHaveLength(3)
571+
expect(chunks[0]).toEqual({ type: "tool_call_start", id: "call_1", name: "read_file" })
572+
expect(chunks[1]).toEqual({ type: "tool_call_delta", id: "call_1", delta: '{"path":"test.ts"}' })
573+
expect(chunks[2]).toEqual({ type: "tool_call_end", id: "call_1" })
574+
})
575+
576+
it("handles multiple tool calls correctly", () => {
577+
const processor = createAiSdkToolStreamProcessor()
578+
579+
// First tool is streamed
580+
Array.from(processor({ type: "tool-input-start" as const, id: "call_1", toolName: "read_file" }))
581+
Array.from(processor({ type: "tool-input-end" as const, id: "call_1" }))
582+
583+
// Second tool is not streamed (non-streaming provider behavior)
584+
const chunks = [
585+
...processor({
586+
type: "tool-call" as const,
587+
toolCallId: "call_2",
588+
toolName: "write_to_file",
589+
input: { path: "output.ts", content: "test" },
590+
} as any),
591+
]
592+
593+
// Second tool should be emitted
594+
expect(chunks).toHaveLength(3)
595+
expect(chunks[0]).toEqual({ type: "tool_call_start", id: "call_2", name: "write_to_file" })
596+
597+
// First tool's tool-call should be ignored
598+
const ignoredChunks = [
599+
...processor({
600+
type: "tool-call" as const,
601+
toolCallId: "call_1",
602+
toolName: "read_file",
603+
input: {},
604+
} as any),
605+
]
606+
expect(ignoredChunks).toHaveLength(0)
607+
})
608+
609+
it("maintains separate state per processor instance", () => {
610+
const processor1 = createAiSdkToolStreamProcessor()
611+
const processor2 = createAiSdkToolStreamProcessor()
612+
613+
// Stream a tool with processor1
614+
Array.from(processor1({ type: "tool-input-start" as const, id: "call_1", toolName: "test" }))
615+
Array.from(processor1({ type: "tool-input-end" as const, id: "call_1" }))
616+
617+
// processor1 should ignore tool-call for call_1
618+
const p1Chunks = [
619+
...processor1({
620+
type: "tool-call" as const,
621+
toolCallId: "call_1",
622+
toolName: "test",
623+
input: {},
624+
} as any),
625+
]
626+
expect(p1Chunks).toHaveLength(0)
627+
628+
// processor2 should emit tool-call for call_1 (it has its own state)
629+
const p2Chunks = [
630+
...processor2({
631+
type: "tool-call" as const,
632+
toolCallId: "call_1",
633+
toolName: "test",
634+
input: {},
635+
} as any),
636+
]
637+
expect(p2Chunks).toHaveLength(3)
638+
})
639+
})
640+
498641
describe("mapToolChoice", () => {
499642
it("should return undefined for null or undefined", () => {
500643
expect(mapToolChoice(null)).toBeUndefined()

src/api/transform/ai-sdk.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,95 @@ export function* processAiSdkStreamPart(part: ExtendedStreamPart): Generator<Api
364364
}
365365
}
366366

367+
/**
368+
* Creates a stateful stream processor that handles tool call deduplication.
369+
* Some AI SDK providers (like HuggingFace) don't emit streaming tool events
370+
* (tool-input-start/delta/end), only the final tool-call event. This function
371+
* returns a processor that tracks which tools have been processed via streaming
372+
* events and emits tool-call events only for tools that weren't streamed.
373+
*
374+
* Usage:
375+
* ```typescript
376+
* const processStreamPart = createAiSdkToolStreamProcessor()
377+
* for await (const part of result.fullStream) {
378+
* for (const chunk of processStreamPart(part)) {
379+
* yield chunk
380+
* }
381+
* }
382+
* ```
383+
*
384+
* @returns A generator function that processes stream parts with tool deduplication
385+
*/
386+
export function createAiSdkToolStreamProcessor(): (
387+
part: ExtendedStreamPart,
388+
) => Generator<ApiStreamChunk, void, unknown> {
389+
// Track tool IDs that have been processed via streaming events
390+
const streamedToolIds = new Set<string>()
391+
392+
return function* processStreamPart(part: ExtendedStreamPart): Generator<ApiStreamChunk> {
393+
switch (part.type) {
394+
case "tool-input-start":
395+
// Track that this tool has streaming events
396+
streamedToolIds.add(part.id)
397+
yield {
398+
type: "tool_call_start",
399+
id: part.id,
400+
name: part.toolName,
401+
}
402+
break
403+
404+
case "tool-input-delta":
405+
yield {
406+
type: "tool_call_delta",
407+
id: part.id,
408+
delta: part.delta,
409+
}
410+
break
411+
412+
case "tool-input-end":
413+
yield {
414+
type: "tool_call_end",
415+
id: part.id,
416+
}
417+
break
418+
419+
case "tool-call": {
420+
// Only emit tool-call if this tool wasn't already processed via streaming
421+
const toolCallPart = part as {
422+
type: "tool-call"
423+
toolCallId: string
424+
toolName: string
425+
input: unknown
426+
}
427+
if (!streamedToolIds.has(toolCallPart.toolCallId)) {
428+
// Emit as start/delta/end for consistency with streaming providers
429+
const args = JSON.stringify(toolCallPart.input)
430+
yield {
431+
type: "tool_call_start",
432+
id: toolCallPart.toolCallId,
433+
name: toolCallPart.toolName,
434+
}
435+
yield {
436+
type: "tool_call_delta",
437+
id: toolCallPart.toolCallId,
438+
delta: args,
439+
}
440+
yield {
441+
type: "tool_call_end",
442+
id: toolCallPart.toolCallId,
443+
}
444+
}
445+
break
446+
}
447+
448+
// Handle all other events with the stateless processor
449+
default:
450+
yield* processAiSdkStreamPart(part)
451+
break
452+
}
453+
}
454+
}
455+
367456
/**
368457
* Type for AI SDK tool choice format.
369458
*/

0 commit comments

Comments
 (0)