UNPKG

@anthropic-ai/sdk

Version:
168 lines (155 loc) • 5.98 kB
import type { Anthropic } from '../../client'; import { STAINLESS_HELPER_METHOD_HEADER } from '../../internal/stainless-helper-header'; import { type RequestOptions } from '../../internal/request-options'; import type { BetaMessage, BetaRawMessageStreamEvent, BetaToolUseBlock, MessageCreateParamsBase, } from '../../resources/beta/messages/messages'; import { BetaMessageStream } from '../BetaMessageStream'; /** * The stream a tool runner yields. It hands each `tool_use` call to `onToolCall` once the model has moved on * from it, and the caller's listeners and `for await` loops over the stream have handled the event that shows it. */ export class BetaToolRunnerStream extends BetaMessageStream { #onToolCall: (toolUse: BetaToolUseBlock) => void; /** The number of stream events emitted so far */ #emitted = 0; /** The `for await` loops over the stream that are still reading it */ #readers: Reader[] = []; /** The `tool_use` blocks that have finished streaming, in the model's order */ #toolCalls: BetaToolUseBlock[] = []; /** The last `tool_use` block to close, until the model moves on from it */ #closed: BetaToolUseBlock | undefined; /** The calls the model has moved on from, in its order, until every reader has caught up with them */ #ready: ReadyCall[] = []; /** Whether a refusal has handed the reply to a fallback model */ #fallback = false; constructor( params: MessageCreateParamsBase, onToolCall: (toolUse: BetaToolUseBlock) => void, client: Anthropic, ) { super(params, { client }); this.#onToolCall = onToolCall; } /** The `tool_use` blocks that have finished streaming, in the model's order */ get toolCalls(): readonly BetaToolUseBlock[] { return this.#toolCalls; } /** Sends the request as `client.beta.messages.stream()` does. */ static start( client: Anthropic, params: MessageCreateParamsBase, options: RequestOptions | undefined, onToolCall: (toolUse: BetaToolUseBlock) => void, ): BetaToolRunnerStream { const stream = new BetaToolRunnerStream({ ...params, stream: true }, onToolCall, client); for (const message of params.messages) { stream._addMessageParam(message); } stream._run(() => stream._createMessage( client.beta.messages, { ...params, stream: true }, { ...options, headers: { ...options?.headers, [STAINLESS_HELPER_METHOD_HEADER]: 'stream' } }, ), ); return stream; } protected override _deliverEvent(event: BetaRawMessageStreamEvent, snapshot: BetaMessage | undefined) { // Counted first, so that a reader created by a listener of this event starts after it. this.#emitted++; const block = event.type === 'content_block_stop' ? snapshot?.content[event.index] : undefined; const closed = block?.type === 'tool_use' ? block : undefined; if (closed) { this.#toolCalls.push(closed); } super._deliverEvent(event, snapshot); this.#track(event, closed); this.#release(); } override [Symbol.asyncIterator](): AsyncIterator<BetaRawMessageStreamEvent> { const iterator = super[Symbol.asyncIterator](); const reader: Reader = { handled: this.#emitted }; this.#readers.push(reader); let holdsEvent = false; return { next: async () => { // Asking for the next event is how a `for await` loop says it is done with the one it holds. if (holdsEvent) { holdsEvent = false; reader.handled++; this.#release(); } try { const result = await iterator.next(); holdsEvent = !result.done; if (result.done) { this.#removeReader(reader); } return result; } catch (error) { this.#removeReader(reader); throw error; } }, return: async () => { // Leaving the loop aborts the stream, which has to come first so that no call starts after it. const result = iterator.return?.(); this.#removeReader(reader); return (await result) ?? { value: undefined, done: true }; }, }; } #track(event: BetaRawMessageStreamEvent, closed: BetaToolUseBlock | undefined): void { if (this.#fallback) { return; } if (event.type === 'content_block_start' && event.content_block.type === 'fallback') { // The refusal that led to the fallback may have cut the call before it off. this.#fallback = true; this.#closed = undefined; return; } // A call's own `content_block_stop` isn't enough, because a reply that is cut off closes its last call too. const movedOn = event.type === 'content_block_start' || (event.type === 'message_delta' && event.delta.stop_reason === 'tool_use'); if (this.#closed && movedOn) { this.#ready.push({ toolUse: this.#closed, event: this.#emitted }); this.#closed = undefined; } if (closed) { this.#closed = closed; } } /** Hands over the calls that the listeners and every reader are done with. */ #release(): void { // Events that were already read still arrive after the stream has failed or been aborted. if (this.errored || this.controller.signal.aborted) { return; } const handled = Math.min(this.#emitted, ...this.#readers.map((reader) => reader.handled)); while (this.#ready[0] && this.#ready[0].event <= handled) { this.#onToolCall(this.#ready.shift()!.toolUse); } } #removeReader(reader: Reader): void { const index = this.#readers.indexOf(reader); if (index >= 0) { this.#readers.splice(index, 1); this.#release(); } } } type Reader = { /** The number of stream events the loop is done with, counting the ones emitted before it started */ handled: number; }; type ReadyCall = { toolUse: BetaToolUseBlock; /** The number of the event that showed the model had moved on from the call */ event: number; };