UNPKG

@ai-sdk/provider-utils

Version:
535 lines (471 loc) • 17.1 kB
import { InvalidResponseDataError, type LanguageModelV4StreamPart, type SharedV4ProviderMetadata, } from '@ai-sdk/provider'; import { generateId as defaultGenerateId } from './generate-id'; import { startsWithStructuredValue, StreamingToolCallArgumentState, } from './streaming-tool-call-argument-state'; /** * Minimal interface for a streaming tool call delta from an OpenAI-compatible API. */ export interface StreamingToolCallDelta { index?: number | null; id?: string | null; type?: string | null; function?: { name?: string | null; arguments?: string | null; } | null; } export interface StreamingToolCallTrackerOptions< DELTA extends StreamingToolCallDelta = StreamingToolCallDelta, > { /** * ID generator function for tool call IDs. * Blank or repeated outputs are converted to usable unique IDs. * Defaults to the standard generateId. */ generateId?: () => string; /** * How to validate the `type` field on new tool call deltas. * - `'none'`: no validation (default) * - `'if-present'`: throw if type is present and not `'function'` * - `'required'`: throw if type is not exactly `'function'` */ typeValidation?: 'none' | 'if-present' | 'required'; /** * Extract provider-specific metadata from a tool call delta. * Called once when a new tool call is detected. * The returned metadata is stored on the tool call and passed to * `buildToolCallProviderMetadata` when the tool call is finalized. */ extractMetadata?: (delta: DELTA) => SharedV4ProviderMetadata | undefined; /** * Build the `providerMetadata` object for a `tool-call` event. * Receives the metadata previously extracted via `extractMetadata`. * If `undefined` is returned, no `providerMetadata` is included in the event. */ buildToolCallProviderMetadata?: ( metadata: SharedV4ProviderMetadata | undefined, ) => SharedV4ProviderMetadata | undefined; } interface TrackedToolCall { id: string; index?: number; sequence: number; type: 'function'; function: { name: string; arguments: string }; argumentState: StreamingToolCallArgumentState; hasFinished: boolean; metadata?: SharedV4ProviderMetadata; } type ToolCallResolution = | { kind: 'existing'; toolCall: TrackedToolCall } | { kind: 'new' } | { kind: 'ambiguous' }; type StreamingToolCallTrackerController = Pick< TransformStreamDefaultController<LanguageModelV4StreamPart>, 'enqueue' >; /** * Tracks streaming tool call state across multiple deltas from an * OpenAI-compatible chat completion stream. Handles argument accumulation, * emits tool-input-start/delta/end and tool-call events, and finalizes * unfinished tool calls on flush. * * Used by openai, openai-compatible, groq, deepseek, alibaba, mistral, and * moonshotai providers. */ export class StreamingToolCallTracker< DELTA extends StreamingToolCallDelta = StreamingToolCallDelta, > { private toolCalls: TrackedToolCall[] = []; private toolCallsById = new Map<string, Set<TrackedToolCall>>(); private toolCallsByIndex = new Map<number, Set<TrackedToolCall>>(); private usedToolCallIds = new Set<string>(); private nextGeneratedIdSuffixes = new Map<string, number>(); private readonly controller: StreamingToolCallTrackerController; private readonly _generateId: () => string; private readonly typeValidation: 'none' | 'if-present' | 'required'; private readonly extractMetadata?: ( delta: DELTA, ) => SharedV4ProviderMetadata | undefined; private readonly buildToolCallProviderMetadata?: ( metadata: SharedV4ProviderMetadata | undefined, ) => SharedV4ProviderMetadata | undefined; constructor( controller: StreamingToolCallTrackerController, options: StreamingToolCallTrackerOptions<DELTA> = {}, ) { this.controller = controller; this._generateId = options.generateId ?? defaultGenerateId; this.typeValidation = options.typeValidation ?? 'none'; this.extractMetadata = options.extractMetadata; this.buildToolCallProviderMetadata = options.buildToolCallProviderMetadata; } /** * Process a tool call delta from a streaming response chunk. * Emits tool-input-start, tool-input-delta, tool-input-end, and tool-call * events as appropriate. */ processDelta(toolCallDelta: DELTA): void { const wireName = toolCallDelta.function?.name; const hasBlankName = typeof wireName === 'string' && wireName.trim().length === 0; const wireId = this.getNonBlankString(toolCallDelta.id); const name = this.getNonBlankString(wireName); const argumentsDelta = toolCallDelta.function?.arguments; const { index } = toolCallDelta; const resolution = this.resolveToolCall({ wireId, index, name, hasExplicitCallStart: name != null && startsWithStructuredValue(argumentsDelta), hasEmptyArguments: argumentsDelta == null || argumentsDelta.trim().length === 0, }); if (resolution.kind === 'ambiguous') { return; } let toolCall: TrackedToolCall; if (resolution.kind === 'new') { // Blank names cannot start a usable call, but some providers repeat a // blank name on continuations. Correlate those continuations first, then // ignore only the unmatched blank-name delta. if (hasBlankName) { return; } toolCall = this.processNewToolCall(toolCallDelta, { wireId, index, name, }); } else { toolCall = resolution.toolCall; if (wireId != null) { this.associateWireId(toolCall, wireId); } this.processExistingToolCall(toolCall, toolCallDelta); } if (index != null) { this.associateIndex(toolCall, index); } } /** * Finalize any unfinished tool calls. Should be called during the stream's * flush handler to ensure all tool calls are properly completed. */ flush(): void { // Index order is reliable only when every call has an index. For mixed // streams, keep insertion order rather than moving all index-less calls // behind indexed calls. const toolCalls = this.toolCalls.every(toolCall => toolCall.index != null) ? [...this.toolCalls].sort( (a, b) => a.index! - b.index! || a.sequence - b.sequence, ) : this.toolCalls; for (const toolCall of toolCalls) { if (!toolCall.hasFinished) { this.finishToolCall(toolCall); } } } /** * Correlation precedence for streamed deltas: * * | ID evidence | index/name evidence | start evidence | resolution | * | --- | --- | --- | --- | * | known | matching | any | matching call, new call, or ambiguity | * | known | conflicting | named | new call | * | unseen | matching | structured start | new call | * | unseen | matching | named empty arguments | incomplete call, new call, or ambiguity | * | unseen | matching | continuation | matching call or ambiguity | * | absent | matching | any | matching call, new call, or ambiguity | * | absent | absent | named | new call | * | absent | absent | unnamed | sole unfinished call, new call, or ambiguity | */ private resolveToolCall({ wireId, index, name, hasExplicitCallStart, hasEmptyArguments, }: { wireId: string | undefined; index: number | null | undefined; name: string | undefined; hasExplicitCallStart: boolean; hasEmptyArguments: boolean; }): ToolCallResolution { const indexedToolCalls = index != null ? this.toolCallsByIndex.get(index) : undefined; const matchingIndexedToolCalls = this.filterToolCallsByName( indexedToolCalls, name, ); if (wireId != null) { const toolCallsWithId = this.toolCallsById.get(wireId); if (toolCallsWithId != null) { if (index != null) { const matchingToolCalls = matchingIndexedToolCalls.filter(toolCall => toolCallsWithId.has(toolCall), ); const matchingToolCall = this.resolveMatchingToolCall( matchingToolCalls, hasExplicitCallStart, ); if (matchingToolCall.kind !== 'new') { return matchingToolCall; } // A named delta with a distinct index starts a new call even when its // wire ID and function name repeat. Providers may reuse IDs across // parallel calls, so the ID/name pair cannot override index evidence. if (name != null) { return { kind: 'new' }; } // Conflicting labels on a continuation cannot be resolved safely. if (indexedToolCalls != null) { return { kind: 'ambiguous' }; } return this.resolveMatchingToolCall([...toolCallsWithId], false); } if (name != null) { const matchingToolCalls = [...toolCallsWithId].filter( toolCall => toolCall.function.name === name, ); return this.resolveMatchingToolCall( matchingToolCalls, hasExplicitCallStart, ); } return this.resolveMatchingToolCall([...toolCallsWithId], false); } if (matchingIndexedToolCalls.length > 0) { // A named structured start with an unseen ID identifies a new call. // Empty arguments can also occur when a continuation changes its ID. // Keep the incomplete matching call in that case, and start a new call // only after the matching structured arguments are complete. return hasExplicitCallStart ? { kind: 'new' } : this.resolveMatchingToolCall( matchingIndexedToolCalls, name != null && hasEmptyArguments, ); } return { kind: 'new' }; } if (indexedToolCalls != null) { // Repeated names are valid on continuations. A different name at the // same index is evidence of a new call from a provider that reuses // indices across parallel calls. return this.resolveMatchingToolCall( matchingIndexedToolCalls, hasExplicitCallStart, ); } if (name != null) { return { kind: 'new' }; } const unfinishedToolCalls = this.toolCalls.filter( toolCall => !toolCall.hasFinished, ); return this.resolveMatchingToolCall(unfinishedToolCalls, false); } private filterToolCallsByName( toolCalls: Set<TrackedToolCall> | undefined, name: string | undefined, ): TrackedToolCall[] { if (toolCalls == null) { return []; } return [...toolCalls].filter( toolCall => name == null || toolCall.function.name === name, ); } private resolveMatchingToolCall( toolCalls: TrackedToolCall[], hasExplicitCallStart: boolean, ): ToolCallResolution { if (toolCalls.length === 0) { return { kind: 'new' }; } // A repeated name can occur on continuations. A fresh structured // argument prefix is evidence of another call only after the matching call // has completed its own structured argument payload. When labels are // missing, a sole call without a complete structured payload is the only // viable continuation target. const continuableToolCalls = toolCalls.filter( toolCall => !toolCall.argumentState.hasCompleteStructuredValue, ); if (!hasExplicitCallStart && toolCalls.length === 1) { return { kind: 'existing', toolCall: toolCalls[0] }; } if (continuableToolCalls.length === 1) { return { kind: 'existing', toolCall: continuableToolCalls[0] }; } if (continuableToolCalls.length > 1 || !hasExplicitCallStart) { return { kind: 'ambiguous' }; } return { kind: 'new' }; } private processNewToolCall( toolCallDelta: DELTA, { wireId, index, name, }: { wireId: string | undefined; index: number | null | undefined; name: string | undefined; }, ): TrackedToolCall { if (this.typeValidation === 'required') { if (toolCallDelta.type !== 'function') { throw new InvalidResponseDataError({ data: toolCallDelta, message: `Expected 'function' type.`, }); } } else if (this.typeValidation === 'if-present') { if (toolCallDelta.type != null && toolCallDelta.type !== 'function') { throw new InvalidResponseDataError({ data: toolCallDelta, message: `Expected 'function' type.`, }); } } if (name == null) { throw new InvalidResponseDataError({ data: toolCallDelta, message: `Expected 'function.name' to be a string.`, }); } const id = this.createToolCallId(wireId); this.controller.enqueue({ type: 'tool-input-start', id, toolName: name, }); const metadata = this.extractMetadata?.(toolCallDelta); const initialArguments = toolCallDelta.function?.arguments ?? ''; const toolCall: TrackedToolCall = { id, index: index ?? undefined, sequence: this.toolCalls.length, type: 'function', function: { name, arguments: initialArguments, }, argumentState: new StreamingToolCallArgumentState(initialArguments), hasFinished: false, metadata, }; this.toolCalls.push(toolCall); if (wireId != null) { this.associateWireId(toolCall, wireId); } // Emit initial delta if arguments already present if (toolCall.function.arguments.length > 0) { this.controller.enqueue({ type: 'tool-input-delta', id: toolCall.id, delta: toolCall.function.arguments, }); } // Tool calls must not finalize before the stream ends: a parsable // argument buffer can still be the prefix of a longer argument string, // so acting on it early would use truncated inputs (see #13137). // Finalization happens in flush(). return toolCall; } private associateWireId(toolCall: TrackedToolCall, wireId: string): void { let toolCallsWithId = this.toolCallsById.get(wireId); if (toolCallsWithId == null) { toolCallsWithId = new Set(); this.toolCallsById.set(wireId, toolCallsWithId); } toolCallsWithId.add(toolCall); } private associateIndex(toolCall: TrackedToolCall, index: number): void { let toolCallsWithIndex = this.toolCallsByIndex.get(index); if (toolCallsWithIndex == null) { toolCallsWithIndex = new Set(); this.toolCallsByIndex.set(index, toolCallsWithIndex); } toolCallsWithIndex.add(toolCall); } private createToolCallId(wireId: string | undefined): string { if (wireId != null && !this.usedToolCallIds.has(wireId)) { this.usedToolCallIds.add(wireId); return wireId; } const generatedId = this.getNonBlankString(this._generateId()) ?? 'tool-call'; if (!this.usedToolCallIds.has(generatedId)) { this.usedToolCallIds.add(generatedId); return generatedId; } // Resume after the last suffix checked for this generated value. This // keeps deterministic generators bounded without repeatedly rescanning // the same occupied suffixes. const initialSuffix = this.nextGeneratedIdSuffixes.get(generatedId) ?? 1; const maximumSuffix = initialSuffix + this.usedToolCallIds.size; for (let suffix = initialSuffix; suffix <= maximumSuffix; suffix++) { const suffixedId = `${generatedId}-${suffix}`; if (!this.usedToolCallIds.has(suffixedId)) { this.usedToolCallIds.add(suffixedId); this.nextGeneratedIdSuffixes.set(generatedId, suffix + 1); return suffixedId; } } // The bounded search above is guaranteed to return by the pigeonhole // principle. This guards against future changes invalidating that // invariant without restoring an unbounded retry loop. throw new Error('Failed to create a unique tool call ID.'); } private getNonBlankString( value: string | null | undefined, ): string | undefined { return value != null && value.trim().length > 0 ? value : undefined; } private processExistingToolCall( toolCall: TrackedToolCall, toolCallDelta: DELTA, ): void { if (toolCall.hasFinished) { return; } if (toolCallDelta.function?.arguments != null) { toolCall.argumentState.append(toolCallDelta.function.arguments); toolCall.function.arguments += toolCallDelta.function.arguments; this.controller.enqueue({ type: 'tool-input-delta', id: toolCall.id, delta: toolCallDelta.function.arguments, }); } } private finishToolCall(toolCall: TrackedToolCall): void { this.controller.enqueue({ type: 'tool-input-end', id: toolCall.id, }); const providerMetadata = this.buildToolCallProviderMetadata?.( toolCall.metadata, ); this.controller.enqueue({ type: 'tool-call', toolCallId: toolCall.id, toolName: toolCall.function.name, input: toolCall.function.arguments, ...(providerMetadata ? { providerMetadata } : {}), }); toolCall.hasFinished = true; } }