diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 258342d8..d9d5699b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.5.2" + ".": "0.5.3" } diff --git a/src/core.ts b/src/core.ts index 8458f21a..d4019229 100644 --- a/src/core.ts +++ b/src/core.ts @@ -21,7 +21,7 @@ export { const MAX_RETRIES = 2; -type Fetch = (url: RequestInfo, init?: RequestInit) => Promise; +export type Fetch = (url: RequestInfo, init?: RequestInit) => Promise; export abstract class APIClient { baseURL: string; @@ -37,18 +37,20 @@ export abstract class APIClient { maxRetries, timeout = 60 * 1000, // 60s httpAgent, + fetch: overridenFetch, }: { baseURL: string; maxRetries?: number | undefined; timeout: number | undefined; httpAgent: Agent | undefined; + fetch: Fetch | undefined; }) { this.baseURL = baseURL; this.maxRetries = validatePositiveInteger('maxRetries', maxRetries ?? MAX_RETRIES); this.timeout = validatePositiveInteger('timeout', timeout); this.httpAgent = httpAgent; - this.fetch = fetch; + this.fetch = overridenFetch ?? fetch; } protected authHeaders(): Headers { @@ -120,6 +122,20 @@ export abstract class APIClient { return this.requestAPIList(Page, { method: 'get', path, ...opts }); } + private calculateContentLength(body: unknown): string | null { + if (typeof body === 'string') { + if (typeof Buffer !== 'undefined') { + return Buffer.byteLength(body, 'utf8').toString(); + } + + const encoder = new TextEncoder(); + const encoded = encoder.encode(body); + return encoded.length.toString(); + } + + return null; + } + buildRequest( options: FinalRequestOptions, ): { req: RequestInit; url: string; timeout: number } { @@ -129,7 +145,7 @@ export abstract class APIClient { isMultipartBody(options.body) ? options.body.body : options.body ? JSON.stringify(options.body, null, 2) : null; - const contentLength = typeof body === 'string' ? body.length.toString() : null; + const contentLength = this.calculateContentLength(body); const url = this.buildURL(path!, query); if ('timeout' in options) validatePositiveInteger('timeout', options.timeout); diff --git a/src/index.ts b/src/index.ts index ea4a2f0d..68d5a269 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,14 @@ type Config = { */ httpAgent?: Agent; + /** + * Specify a custom `fetch` function implementation. + * + * If not provided, we use `node-fetch` on Node.js and otherwise expect that `fetch` is + * defined globally. + */ + fetch?: Core.Fetch | undefined; + /** * The maximum number of times that the client will retry a request in case of a * temporary failure, like a network error or a 5XX error from the server. @@ -81,6 +89,7 @@ export class Anthropic extends Core.APIClient { timeout: options.timeout, httpAgent: options.httpAgent, maxRetries: options.maxRetries, + fetch: options.fetch, }); this.apiKey = options.apiKey || null; this._options = options; diff --git a/src/streaming.ts b/src/streaming.ts index 15651545..fd629c8a 100644 --- a/src/streaming.ts +++ b/src/streaming.ts @@ -259,19 +259,27 @@ function partition(str: string, delimiter: string): [string, string, string] { * 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-1624185965 + * This polyfill was pulled from https://github.com/MattiasBuelens/web-streams-polyfill/pull/122#issuecomment-1627354490 */ function readableStreamAsyncIterable(stream: any): AsyncIterableIterator { if (stream[Symbol.asyncIterator]) return stream; const reader = stream.getReader(); return { - next() { - return reader.read(); + 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() { - reader.cancel(); + const cancelPromise = reader.cancel(); reader.releaseLock(); + await cancelPromise; return { done: true, value: undefined }; }, [Symbol.asyncIterator]() { diff --git a/tests/index.test.ts b/tests/index.test.ts index 49cc85cb..e7bc3c09 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -2,6 +2,7 @@ import { Headers } from '@anthropic-ai/sdk/core'; import Anthropic from '@anthropic-ai/sdk'; +import { Response } from '@anthropic-ai/sdk/_shims/fetch'; describe('instantiate client', () => { const env = process.env; @@ -77,6 +78,23 @@ describe('instantiate client', () => { }); }); + test('custom fetch', async () => { + const client = new Anthropic({ + baseURL: 'http://localhost:5000/', + apiKey: 'my api key', + fetch: (url) => { + return Promise.resolve( + new Response(JSON.stringify({ url, custom: true }), { + headers: { 'Content-Type': 'application/json' }, + }), + ); + }, + }); + + const response = await client.get('/foo'); + expect(response).toEqual({ url: 'http://localhost:5000/foo', custom: true }); + }); + describe('baseUrl', () => { test('trailing slash', () => { const client = new Anthropic({ baseURL: 'http://localhost:5000/custom/path/', apiKey: 'my api key' }); @@ -126,3 +144,19 @@ describe('instantiate client', () => { expect(client.apiKey).toBeNull(); }); }); + +describe('request building', () => { + const client = new Anthropic({ apiKey: 'my api key' }); + + describe('Content-Length', () => { + test('handles multi-byte characters', () => { + const { req } = client.buildRequest({ path: '/foo', method: 'post', body: { value: '—' } }); + expect((req.headers as Record)['Content-Length']).toEqual('20'); + }); + + test('handles standard characters', () => { + const { req } = client.buildRequest({ path: '/foo', method: 'post', body: { value: 'hello' } }); + expect((req.headers as Record)['Content-Length']).toEqual('22'); + }); + }); +});