diff --git a/README.md b/README.md index 3c408547..24303301 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,6 @@ await anthropic.completions.create( maxRetries: 5, }, ); - ``` ### Timeouts @@ -247,7 +246,6 @@ await anthropic.completions.create( timeout: 5 * 1000, }, ); - ``` On timeout, an `APIConnectionTimeoutError` is thrown. @@ -283,7 +281,6 @@ await anthropic.completions.create( httpAgent: new http.Agent({ keepAlive: false }), }, ); - ``` ## Status diff --git a/examples/cancellation.ts b/examples/cancellation.ts index 896ec4e7..93c2737a 100755 --- a/examples/cancellation.ts +++ b/examples/cancellation.ts @@ -17,7 +17,7 @@ async function main() { const stream = await client.completions.create({ prompt: `${Anthropic.HUMAN_PROMPT}${question}${Anthropic.AI_PROMPT}:`, - model: 'claude-v1', + model: 'claude-2', stream: true, max_tokens_to_sample: 500, }); diff --git a/examples/demo.ts b/examples/demo.ts index 226cff55..7bc41d72 100755 --- a/examples/demo.ts +++ b/examples/demo.ts @@ -7,7 +7,7 @@ const client = new Anthropic(); // gets API Key from environment variable ANTHRO async function main() { const result = await client.completions.create({ prompt: `${Anthropic.HUMAN_PROMPT} how does a court case get to the Supreme Court? ${Anthropic.AI_PROMPT}`, - model: 'claude-v1.3', + model: 'claude-2', max_tokens_to_sample: 300, }); console.log(result.completion); diff --git a/examples/streaming.ts b/examples/streaming.ts index 7f1313ea..bb05ca45 100755 --- a/examples/streaming.ts +++ b/examples/streaming.ts @@ -9,7 +9,7 @@ async function main() { const stream = await client.completions.create({ prompt: `${Anthropic.HUMAN_PROMPT}${question}${Anthropic.AI_PROMPT}:`, - model: 'claude-v1', + model: 'claude-2', stream: true, max_tokens_to_sample: 500, }); diff --git a/src/_shims/formdata.node.d.ts b/src/_shims/formdata.node.d.ts index feea1ff9..30eceea8 100644 --- a/src/_shims/formdata.node.d.ts +++ b/src/_shims/formdata.node.d.ts @@ -10,6 +10,7 @@ type EndingType = 'native' | 'transparent'; export interface BlobPropertyBag { endings?: EndingType; + /** MIME type, e.g., "text/plain" */ type?: string; } diff --git a/src/core.ts b/src/core.ts index d4019229..d5d0c043 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,7 +1,7 @@ import * as qs from 'qs'; import { VERSION } from './version'; import { Stream } from './streaming'; -import { APIError, APIConnectionError, APIConnectionTimeoutError } from './error'; +import { APIError, APIConnectionError, APIConnectionTimeoutError, APIUserAbortError } from './error'; import type { Readable } from '@anthropic-ai/sdk/_shims/node-readable'; import { getDefaultAgent, type Agent } from '@anthropic-ai/sdk/_shims/agent'; import { @@ -183,6 +183,9 @@ export abstract class APIClient { ...(body && { body: body as any }), headers: reqHeaders, ...(httpAgent && { agent: httpAgent }), + // @ts-ignore node-fetch uses a custom AbortSignal type that is + // not compatible with standard web types + signal: options.signal ?? null, }; this.validateHeaders(reqHeaders, headers); @@ -220,8 +223,15 @@ export abstract class APIClient { const response = await this.fetchWithTimeout(url, req, timeout, controller).catch(castToError); if (response instanceof Error) { - if (retriesRemaining) return this.retryRequest(options, retriesRemaining); - if (response.name === 'AbortError') throw new APIConnectionTimeoutError(); + if (options.signal?.aborted) { + throw new APIUserAbortError(); + } + if (retriesRemaining) { + return this.retryRequest(options, retriesRemaining); + } + if (response.name === 'AbortError') { + throw new APIConnectionTimeoutError(); + } throw new APIConnectionError({ cause: response }); } @@ -245,7 +255,7 @@ export abstract class APIClient { if (options.stream) { // Note: there is an invariant here that isn't represented in the type system // that if you set `stream: true` the response type must also be `Stream` - return new Stream(response, controller) as any; + return new Stream(response, controller) as any; } const contentType = response.headers.get('content-type'); @@ -561,6 +571,7 @@ export type RequestOptions | Readable> stream?: boolean | undefined; timeout?: number; httpAgent?: Agent; + signal?: AbortSignal | undefined | null; idempotencyKey?: string; }; @@ -578,6 +589,7 @@ const requestOptionsKeys: KeysEnum = { stream: true, timeout: true, httpAgent: true, + signal: true, idempotencyKey: true, }; @@ -805,3 +817,19 @@ export const getHeader = (headers: HeadersLike, key: string): string | null | un } return value; }; + +/** + * Encodes a string to Base64 format. + */ +export const toBase64 = (str: string | null | undefined): string => { + if (!str) return ''; + if (typeof Buffer !== 'undefined') { + return Buffer.from(str).toString('base64'); + } + + if (typeof btoa !== 'undefined') { + return btoa(str); + } + + throw new Error('Cannot generate b64 string; Expected `Buffer` or `btoa` to be defined'); +}; diff --git a/src/error.ts b/src/error.ts index fa360e6f..45971af2 100644 --- a/src/error.ts +++ b/src/error.ts @@ -67,6 +67,14 @@ export class APIError extends Error { } } +export class APIUserAbortError extends APIError { + override readonly status: undefined = undefined; + + constructor({ message }: { message?: string } = {}) { + super(undefined, undefined, message || 'Request was aborted.', undefined); + } +} + export class APIConnectionError extends APIError { override readonly status: undefined = undefined; diff --git a/src/index.ts b/src/index.ts index 68d5a269..552a67fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -167,6 +167,7 @@ export class Anthropic extends Core.APIClient { static APIError = Errors.APIError; static APIConnectionError = Errors.APIConnectionError; static APIConnectionTimeoutError = Errors.APIConnectionTimeoutError; + static APIUserAbortError = Errors.APIUserAbortError; static NotFoundError = Errors.NotFoundError; static ConflictError = Errors.ConflictError; static RateLimitError = Errors.RateLimitError; @@ -183,6 +184,7 @@ export const { APIError, APIConnectionError, APIConnectionTimeoutError, + APIUserAbortError, NotFoundError, ConflictError, RateLimitError, diff --git a/src/streaming.ts b/src/streaming.ts index fd629c8a..bc9c2c18 100644 --- a/src/streaming.ts +++ b/src/streaming.ts @@ -51,6 +51,7 @@ export class Stream implements AsyncIterable, APIResponse { + let done = false; try { for await (const sse of this.iterMessages()) { if (sse.event === 'completion') { @@ -75,13 +76,14 @@ export class Stream implements AsyncIterable, APIResponse | AsyncIterable /** * Helper for creating a {@link File} to pass to an SDK upload method from a variety of different data formats - * @param bits the raw content of the file. Can be an {@link Uploadable}, {@link BlobPart}, or {@link AsyncIterable} of {@link BlobPart}s + * @param value the raw content of the file. Can be an {@link Uploadable}, {@link BlobPart}, or {@link AsyncIterable} of {@link BlobPart}s * @param {string=} name the name of the file. If omitted, toFile will try to determine a file name from bits if possible * @param {Object=} options additional properties * @param {string=} options.type the MIME type of the content @@ -100,6 +100,9 @@ export async function toFile( name?: string | null | undefined, options: FilePropertyBag | undefined = {}, ): Promise { + // If it's a promise, resolve it. + value = await value; + if (isResponseLike(value)) { const blob = await value.blob(); name ||= new URL(value.url).pathname.split(/[\\/]/).pop() ?? 'unknown_file'; @@ -121,10 +124,7 @@ export async function toFile( return new File(bits, name, options); } -async function getBytes(value: ToFileInput | PromiseLike): Promise> { - // resolve input promise or promiselike object - value = await value; - +async function getBytes(value: ToFileInput): Promise> { let parts: Array = []; if ( typeof value === 'string' || diff --git a/tests/index.test.ts b/tests/index.test.ts index e7bc3c09..7f504fa5 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,8 +1,9 @@ // File generated from our OpenAPI spec by Stainless. -import { Headers } from '@anthropic-ai/sdk/core'; import Anthropic from '@anthropic-ai/sdk'; -import { Response } from '@anthropic-ai/sdk/_shims/fetch'; +import { APIUserAbortError } from '@anthropic-ai/sdk'; +import { Headers } from '@anthropic-ai/sdk/core'; +import { Response, fetch as defaultFetch } from '@anthropic-ai/sdk/_shims/fetch'; describe('instantiate client', () => { const env = process.env; @@ -95,6 +96,32 @@ describe('instantiate client', () => { expect(response).toEqual({ url: 'http://localhost:5000/foo', custom: true }); }); + test('custom signal', async () => { + const client = new Anthropic({ + baseURL: 'http://127.0.0.1:4010', + apiKey: 'my api key', + fetch: (...args) => { + return new Promise((resolve, reject) => + setTimeout( + () => + defaultFetch(...args) + .then(resolve) + .catch(reject), + 300, + ), + ); + }, + }); + + const controller = new AbortController(); + setTimeout(() => controller.abort(), 200); + + const spy = jest.spyOn(client, 'request'); + + await expect(client.get('/foo', { signal: controller.signal })).rejects.toThrowError(APIUserAbortError); + expect(spy).toHaveBeenCalledTimes(1); + }); + describe('baseUrl', () => { test('trailing slash', () => { const client = new Anthropic({ baseURL: 'http://localhost:5000/custom/path/', apiKey: 'my api key' });