diff --git a/packages/bedrock-sdk/src/core/streaming.ts b/packages/bedrock-sdk/src/core/streaming.ts index dc027556..01891cdd 100644 --- a/packages/bedrock-sdk/src/core/streaming.ts +++ b/packages/bedrock-sdk/src/core/streaming.ts @@ -3,7 +3,7 @@ import { fromBase64, toBase64 } from '@smithy/util-base64'; import { streamCollector } from '@smithy/fetch-http-handler'; import { EventStreamSerdeContext, SerdeContext } from '@smithy/types'; import { Stream as CoreStream, ServerSentEvent } from '@anthropic-ai/sdk/streaming'; -import { AnthropicError } from '@anthropic-ai/sdk/error'; +import { AnthropicError, isAbortError } from '@anthropic-ai/sdk/error'; import { APIError, BaseAnthropic } from '@anthropic-ai/sdk'; import { de_ResponseStream } from '../AWS_restJson1'; import { ReadableStreamToAsyncIterable } from '../internal/shims'; @@ -105,13 +105,3 @@ export class Stream extends CoreStream { } } -function isAbortError(err: unknown) { - return ( - typeof err === 'object' && - err !== null && - // Spec-compliant fetch implementations - (('name' in err && (err as any).name === 'AbortError') || - // Expo fetch - ('message' in err && String((err as any).message).includes('FetchRequestCanceledException'))) - ); -} diff --git a/src/core/error.ts b/src/core/error.ts index 1e4e63b5..1739bd42 100644 --- a/src/core/error.ts +++ b/src/core/error.ts @@ -1,6 +1,7 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. import { castToError } from '../internal/errors'; +export { isAbortError } from '../internal/errors'; import type { ErrorType } from '../resources/shared'; export class AnthropicError extends Error {} diff --git a/src/core/streaming.ts b/src/core/streaming.ts index a789212d..64c7b7c0 100644 --- a/src/core/streaming.ts +++ b/src/core/streaming.ts @@ -1,7 +1,7 @@ import { AnthropicError } from './error'; import { type ReadableStream } from '../internal/shim-types'; import { makeReadableStream } from '../internal/shims'; -import { findDoubleNewlineIndex, LineDecoder } from '../internal/decoders/line'; +import { findDoubleNewlineIndex, LineDecoder, type Bytes } from '../internal/decoders/line'; import { ReadableStreamToAsyncIterable } from '../internal/shims'; import { isAbortError } from '../internal/errors'; import { safeJSON } from '../internal/utils/values'; @@ -12,8 +12,6 @@ import type { BaseAnthropic } from '../client'; import { APIError } from './error'; import type { ErrorType } from '../resources/shared'; -type Bytes = string | ArrayBuffer | Uint8Array | null | undefined; - export type ServerSentEvent = { event: string | null; data: string; diff --git a/src/helpers/beta/json-schema.ts b/src/helpers/beta/json-schema.ts index f7b74965..44439907 100644 --- a/src/helpers/beta/json-schema.ts +++ b/src/helpers/beta/json-schema.ts @@ -5,7 +5,6 @@ import { AutoParseableBetaOutputFormat } from '../../lib/beta-parser'; import { AnthropicError } from '../..'; import { transformJSONSchema } from '../../lib/transform-json-schema'; -type NoInfer = T extends infer R ? R : never; /** * Creates a Tool with a provided JSON schema that can be passed diff --git a/src/helpers/json-schema.ts b/src/helpers/json-schema.ts index db23fa7e..631ad82d 100644 --- a/src/helpers/json-schema.ts +++ b/src/helpers/json-schema.ts @@ -3,7 +3,6 @@ import { AutoParseableOutputFormat } from '../lib/parser'; import { AnthropicError } from '../core/error'; import { transformJSONSchema } from '../lib/transform-json-schema'; -type NoInfer = T extends infer R ? R : never; /** * Creates a JSON schema output format object from the given JSON schema. diff --git a/src/internal/stream-utils.ts b/src/internal/stream-utils.ts deleted file mode 100644 index 37f7793c..00000000 --- a/src/internal/stream-utils.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Most browsers don't yet have async iterable support for ReadableStream, - * and Node has a very different way of reading bytes from its "ReadableStream". - * - * This polyfill was pulled from https://github.com/MattiasBuelens/web-streams-polyfill/pull/122#issuecomment-1627354490 - */ -export function ReadableStreamToAsyncIterable(stream: any): AsyncIterableIterator { - if (stream[Symbol.asyncIterator]) return stream; - - const reader = stream.getReader(); - return { - async next() { - try { - const result = await reader.read(); - if (result?.done) reader.releaseLock(); // release lock when stream becomes closed - return result; - } catch (e) { - reader.releaseLock(); // release lock when stream becomes errored - throw e; - } - }, - async return() { - const cancelPromise = reader.cancel(); - reader.releaseLock(); - await cancelPromise; - return { done: true, value: undefined }; - }, - [Symbol.asyncIterator]() { - return this; - }, - }; -} diff --git a/src/lib/beta-parser.ts b/src/lib/beta-parser.ts index 4a666379..f6710f7f 100644 --- a/src/lib/beta-parser.ts +++ b/src/lib/beta-parser.ts @@ -1,5 +1,5 @@ import type { Logger } from '../client'; -import { AnthropicError } from '../core/error'; +import { parseAccumulatedFormat } from './core-parser'; import { BetaContentBlock, BetaJSONOutputFormat, @@ -9,8 +9,7 @@ import { MessageCreateParams, } from '../resources/beta/messages/messages'; -// vendored from typefest just to make things look a bit nicer on hover -type Simplify = { [KeyType in keyof T]: T[KeyType] } & {}; +import { Simplify } from './type-utils'; type AutoParseableBetaOutputConfig = Omit & { format?: BetaJSONOutputFormat | AutoParseableBetaOutputFormat | null; @@ -131,18 +130,8 @@ function parseBetaOutputFormat( params: Params, content: string, ): ExtractParsedContentFromBetaParams | null { - const outputFormat = getOutputFormat(params); - if (outputFormat?.type !== 'json_schema') { - return null; - } - - try { - if ('parse' in outputFormat) { - return outputFormat.parse(content); - } - - return JSON.parse(content); - } catch (error) { - throw new AnthropicError(`Failed to parse structured output: ${error}`); - } + return parseAccumulatedFormat( + getOutputFormat(params), + content, + ) as ExtractParsedContentFromBetaParams | null; } diff --git a/src/lib/core-parser.ts b/src/lib/core-parser.ts new file mode 100644 index 00000000..1dcdd053 --- /dev/null +++ b/src/lib/core-parser.ts @@ -0,0 +1,29 @@ +import { AnthropicError } from '../core/error'; + +/** + * Shared parse-step runtime used by both parser.ts (stable) and beta-parser.ts. + * + * Accepts the resolved format object (already extracted from params by the + * caller's getOutputFormat) and the accumulated text content. Returns the + * parsed value, or null when the format is absent or non-parseable. + * + * Throws AnthropicError if the format is json_schema but parsing fails — + * same behaviour that lived in each parser previously. + */ +export function parseAccumulatedFormat( + format: { type: string; parse?: (content: string) => unknown } | null | undefined, + content: string, +): unknown | null { + if (format?.type !== 'json_schema') { + return null; + } + + try { + if ('parse' in (format as object)) { + return (format as { parse: (c: string) => unknown }).parse(content); + } + return JSON.parse(content); + } catch (error) { + throw new AnthropicError(`Failed to parse structured output: ${error}`); + } +} diff --git a/src/lib/parser.ts b/src/lib/parser.ts index df1af481..5cd29d7a 100644 --- a/src/lib/parser.ts +++ b/src/lib/parser.ts @@ -1,5 +1,5 @@ import type { Logger } from '../client'; -import { AnthropicError } from '../core/error'; +import { parseAccumulatedFormat } from './core-parser'; import { ContentBlock, JSONOutputFormat, @@ -9,8 +9,7 @@ import { MessageCreateParams, } from '../resources/messages/messages'; -// vendored from typefest just to make things look a bit nicer on hover -type Simplify = { [KeyType in keyof T]: T[KeyType] } & {}; +import { Simplify } from './type-utils'; type AutoParseableOutputConfig = Omit & { format?: JSONOutputFormat | AutoParseableOutputFormat | null; @@ -108,18 +107,8 @@ function parseOutputFormat( params: Params, content: string, ): ExtractParsedContentFromParams | null { - const outputFormat = getOutputFormat(params); - if (outputFormat?.type !== 'json_schema') { - return null; - } - - try { - if ('parse' in outputFormat) { - return outputFormat.parse(content); - } - - return JSON.parse(content); - } catch (error) { - throw new AnthropicError(`Failed to parse structured output: ${error}`); - } + return parseAccumulatedFormat( + getOutputFormat(params), + content, + ) as ExtractParsedContentFromParams | null; } diff --git a/src/lib/tools/BetaRunnableTool.ts b/src/lib/tools/BetaRunnableTool.ts index 61cbda5e..6c84398d 100644 --- a/src/lib/tools/BetaRunnableTool.ts +++ b/src/lib/tools/BetaRunnableTool.ts @@ -13,7 +13,7 @@ import { BetaToolTextEditor20250728, } from '../../resources/beta'; -export type Promisable = T | Promise; +export type { Promisable } from '../type-utils'; /** * Tool types that can be implemented on the client. diff --git a/src/lib/tools/BetaToolRunner.ts b/src/lib/tools/BetaToolRunner.ts index 07397ea5..74e369bd 100644 --- a/src/lib/tools/BetaToolRunner.ts +++ b/src/lib/tools/BetaToolRunner.ts @@ -8,6 +8,7 @@ import { RequestOptions } from '../../internal/request-options'; import { buildHeaders } from '../../internal/headers'; import { CompactionControl, DEFAULT_SUMMARY_PROMPT, DEFAULT_TOKEN_THRESHOLD } from './CompactionControl'; import { collectStainlessHelpers } from '../stainless-helper-header'; +import { Simplify } from '../type-utils'; /** * Just Promise.withResolvers(), which is not available in all environments. @@ -467,8 +468,6 @@ async function generateToolResponse( }; } -// vendored from typefest just to make things look a bit nicer on hover -type Simplify = { [KeyType in keyof T]: T[KeyType] } & {}; /** * Parameters for creating a ToolRunner, extending MessageCreateParams with runnable tools. diff --git a/src/lib/tools/ToolRunner.ts b/src/lib/tools/ToolRunner.ts deleted file mode 100644 index 7719dab1..00000000 --- a/src/lib/tools/ToolRunner.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { BetaRunnableTool } from './BetaRunnableTool'; -import { ToolError } from './ToolError'; -import { Anthropic } from '../..'; -import { AnthropicError } from '../../core/error'; -import { BetaMessage, BetaMessageParam, BetaToolUnion, MessageCreateParams } from '../../resources/beta'; -import { BetaMessageStream } from '../BetaMessageStream'; - -/** - * Just Promise.withResolvers(), which is not available in all environments. - */ -function promiseWithResolvers(): { - promise: Promise; - resolve: (value: T) => void; - reject: (reason?: any) => void; -} { - let resolve: (value: T) => void; - let reject: (reason?: any) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve: resolve!, reject: reject! }; -} - -/** - * A ToolRunner handles the automatic conversation loop between the assistant and tools. - * - * A ToolRunner is an async iterable that yields either BetaMessage or BetaMessageStream objects - * depending on the streaming configuration. - */ -export class BetaToolRunner { - /** Whether the async iterator has been consumed */ - #consumed = false; - /** Whether parameters have been mutated since the last API call */ - #mutated = false; - /** Current state containing the request parameters */ - #state: { params: BetaToolRunnerParams }; - /** Promise for the last message received from the assistant */ - #message?: Promise | undefined; - /** Cached tool response to avoid redundant executions */ - #toolResponse?: Promise | undefined; - /** Promise resolvers for waiting on completion */ - #completion: { - promise: Promise; - resolve: (value: BetaMessage) => void; - reject: (reason?: any) => void; - }; - /** Number of iterations (API requests) made so far */ - #iterationCount = 0; - - constructor( - private client: Anthropic, - params: BetaToolRunnerParams, - ) { - this.#state = { - params: { - // You can't clone the entire params since there are functions as handlers. - // You also don't really need to clone params.messages, but it probably will prevent a foot gun - // somewhere. - ...params, - messages: structuredClone(params.messages), - }, - }; - - this.#completion = promiseWithResolvers(); - } - - async *[Symbol.asyncIterator](): AsyncIterator< - Stream extends true ? BetaMessageStream - : Stream extends false ? BetaMessage - : BetaMessage | BetaMessageStream - > { - if (this.#consumed) { - throw new AnthropicError('Cannot iterate over a consumed stream'); - } - - this.#consumed = true; - this.#mutated = true; - this.#toolResponse = undefined; - - try { - while (true) { - let stream; - try { - if ( - this.#state.params.max_iterations && - this.#iterationCount >= this.#state.params.max_iterations - ) { - break; - } - - this.#mutated = false; - this.#message = undefined; - this.#toolResponse = undefined; - this.#iterationCount++; - - const { max_iterations, ...params } = this.#state.params; - if (params.stream) { - stream = this.client.beta.messages.stream({ ...params }); - this.#message = stream.finalMessage(); - // Make sure that this promise doesn't throw before we get the option to do something about it. - // Error will be caught when we call await this.#message ultimately - this.#message.catch(() => {}); - yield stream as any; - } else { - this.#message = this.client.beta.messages.create({ ...params, stream: false }); - yield this.#message as any; - } - - if (!this.#mutated) { - const { role, content } = await this.#message; - this.#state.params.messages.push({ role, content }); - } - - const toolMessage = await this.#generateToolResponse(this.#state.params.messages.at(-1)!); - if (toolMessage) { - this.#state.params.messages.push(toolMessage); - } - - if (!toolMessage && !this.#mutated) { - break; - } - } finally { - if (stream) { - stream.abort(); - } - } - } - - if (!this.#message) { - throw new AnthropicError('ToolRunner concluded without a message from the server'); - } - - this.#completion.resolve(await this.#message); - } catch (error) { - this.#consumed = false; - // Silence unhandled promise errors - this.#completion.promise.catch(() => {}); - this.#completion.reject(error); - this.#completion = promiseWithResolvers(); - throw error; - } - } - - /** - * Update the parameters for the next API call. This invalidates any cached tool responses. - * - * @param paramsOrMutator - Either new parameters or a function to mutate existing parameters - * - * @example - * // Direct parameter update - * runner.setMessagesParams({ - * model: 'claude-haiku-4-5', - * max_tokens: 500, - * }); - * - * @example - * // Using a mutator function - * runner.setMessagesParams((params) => ({ - * ...params, - * max_tokens: 100, - * })); - */ - setMessagesParams(params: BetaToolRunnerParams): void; - setMessagesParams(mutator: (prevParams: BetaToolRunnerParams) => BetaToolRunnerParams): void; - setMessagesParams( - paramsOrMutator: BetaToolRunnerParams | ((prevParams: BetaToolRunnerParams) => BetaToolRunnerParams), - ) { - if (typeof paramsOrMutator === 'function') { - this.#state.params = paramsOrMutator(this.#state.params); - } else { - this.#state.params = paramsOrMutator; - } - this.#mutated = true; - // Invalidate cached tool response since parameters changed - this.#toolResponse = undefined; - } - - /** - * Get the tool response for the last message from the assistant. - * Avoids redundant tool executions by caching results. - * - * @returns A promise that resolves to a BetaMessageParam containing tool results, or null if no tools need to be executed - * - * @example - * const toolResponse = await runner.generateToolResponse(); - * if (toolResponse) { - * console.log('Tool results:', toolResponse.content); - * } - */ - async generateToolResponse() { - const message = (await this.#message) ?? this.params.messages.at(-1); - if (!message) { - return null; - } - return this.#generateToolResponse(message); - } - - async #generateToolResponse(lastMessage: BetaMessageParam) { - if (this.#toolResponse !== undefined) { - return this.#toolResponse; - } - this.#toolResponse = generateToolResponse(this.#state.params, lastMessage); - return this.#toolResponse; - } - - /** - * Wait for the async iterator to complete. This works even if the async iterator hasn't yet started, and - * will wait for an instance to start and go to completion. - * - * @returns A promise that resolves to the final BetaMessage when the iterator completes - * - * @example - * // Start consuming the iterator - * for await (const message of runner) { - * console.log('Message:', message.content); - * } - * - * // Meanwhile, wait for completion from another part of the code - * const finalMessage = await runner.done(); - * console.log('Final response:', finalMessage.content); - */ - done(): Promise { - return this.#completion.promise; - } - - /** - * Returns a promise indicating that the stream is done. Unlike .done(), this will eagerly read the stream: - * * If the iterator has not been consumed, consume the entire iterator and return the final message from the - * assistant. - * * If the iterator has been consumed, waits for it to complete and returns the final message. - * - * @returns A promise that resolves to the final BetaMessage from the conversation - * @throws {AnthropicError} If no messages were processed during the conversation - * - * @example - * const finalMessage = await runner.runUntilDone(); - * console.log('Final response:', finalMessage.content); - */ - async runUntilDone(): Promise { - // If not yet consumed, start consuming and wait for completion - if (!this.#consumed) { - for await (const _ of this) { - // Iterator naturally populates this.#message - } - } - - // If consumed but not completed, wait for completion - return this.done(); - } - - /** - * Get the current parameters being used by the ToolRunner. - * - * @returns A readonly view of the current ToolRunnerParams - * - * @example - * const currentParams = runner.params; - * console.log('Current model:', currentParams.model); - * console.log('Message count:', currentParams.messages.length); - */ - get params(): Readonly { - return this.#state.params as Readonly; - } - - /** - * Add one or more messages to the conversation history. - * - * @param messages - One or more BetaMessageParam objects to add to the conversation - * - * @example - * runner.pushMessages( - * { role: 'user', content: 'Also, what about the weather in NYC?' } - * ); - * - * @example - * // Adding multiple messages - * runner.pushMessages( - * { role: 'user', content: 'What about NYC?' }, - * { role: 'user', content: 'And Boston?' } - * ); - */ - pushMessages(...messages: BetaMessageParam[]) { - this.setMessagesParams((params) => ({ - ...params, - messages: [...params.messages, ...messages], - })); - } - - /** - * Makes the ToolRunner directly awaitable, equivalent to calling .runUntilDone() - * This allows using `await runner` instead of `await runner.runUntilDone()` - */ - then( - onfulfilled?: ((value: BetaMessage) => TResult1 | PromiseLike) | undefined | null, - onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null, - ): Promise { - return this.runUntilDone().then(onfulfilled, onrejected); - } -} - -async function generateToolResponse( - params: BetaToolRunnerParams, - lastMessage = params.messages.at(-1), -): Promise { - // Only process if the last message is from the assistant and has tool use blocks - if ( - !lastMessage || - lastMessage.role !== 'assistant' || - !lastMessage.content || - typeof lastMessage.content === 'string' - ) { - return null; - } - - const toolUseBlocks = lastMessage.content.filter((content) => content.type === 'tool_use'); - if (toolUseBlocks.length === 0) { - return null; - } - - const toolResults = await Promise.all( - toolUseBlocks.map(async (toolUse) => { - const tool = params.tools.find((t) => ('name' in t ? t.name : t.mcp_server_name) === toolUse.name); - if (!tool || !('run' in tool)) { - return { - type: 'tool_result' as const, - tool_use_id: toolUse.id, - content: `Error: Tool '${toolUse.name}' not found`, - is_error: true, - }; - } - - try { - let input = toolUse.input; - if ('parse' in tool && tool.parse) { - input = tool.parse(input); - } - - const result = await tool.run(input); - return { - type: 'tool_result' as const, - tool_use_id: toolUse.id, - content: result, - }; - } catch (error) { - return { - type: 'tool_result' as const, - tool_use_id: toolUse.id, - content: - error instanceof ToolError ? - error.content - : `Error: ${error instanceof Error ? error.message : String(error)}`, - is_error: true, - }; - } - }), - ); - - return { - role: 'user' as const, - content: toolResults, - }; -} - -// vendored from typefest just to make things look a bit nicer on hover -type Simplify = { [KeyType in keyof T]: T[KeyType] } & {}; - -/** - * Parameters for creating a ToolRunner, extending MessageCreateParams with runnable tools. - */ -export type BetaToolRunnerParams = Simplify< - Omit & { - tools: (BetaToolUnion | BetaRunnableTool)[]; - /** - * Maximum number of iterations (API requests) to make in the tool execution loop. - * Each iteration consists of: assistant response → tool execution → tool results. - * When exceeded, the loop will terminate even if tools are still being requested. - */ - max_iterations?: number; - } ->; diff --git a/src/lib/type-utils.ts b/src/lib/type-utils.ts new file mode 100644 index 00000000..5f7f0dcc --- /dev/null +++ b/src/lib/type-utils.ts @@ -0,0 +1,5 @@ +/** Vendored from typefest — flattens complex type hover display. */ +export type Simplify = { [KeyType in keyof T]: T[KeyType] } & {}; + +/** A value T or a Promise of T. */ +export type Promisable = T | Promise;