diff --git a/.github/workflows/handle-release-pr-title-edit.yml b/.github/workflows/handle-release-pr-title-edit.yml index 9a69e0e..1ad3056 100644 --- a/.github/workflows/handle-release-pr-title-edit.yml +++ b/.github/workflows/handle-release-pr-title-edit.yml @@ -14,6 +14,7 @@ jobs: startsWith(github.event.pull_request.head.ref, 'release-please--') && github.event.pull_request.state == 'open' && github.event.sender.login != 'stainless-bot' && + github.event.sender.login != 'stainless-app' && github.repository == 'anthropics/anthropic-sdk-typescript' runs-on: ubuntu-latest steps: diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 38acea6..18eef3d 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.12.1" + ".": "0.12.2" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1554ef1..f82b161 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 0.12.2 (2024-01-18) + +Full Changelog: [v0.12.1...v0.12.2](https://github.com/anthropics/anthropic-sdk-typescript/compare/v0.12.1...v0.12.2) + +### Bug Fixes + +* **ci:** ignore stainless-app edits to release PR title ([#258](https://github.com/anthropics/anthropic-sdk-typescript/issues/258)) ([87e4ba8](https://github.com/anthropics/anthropic-sdk-typescript/commit/87e4ba82c5b498f881db9590edbfd68c8aba0930)) +* **types:** accept undefined for optional client options ([#257](https://github.com/anthropics/anthropic-sdk-typescript/issues/257)) ([a0e2c4a](https://github.com/anthropics/anthropic-sdk-typescript/commit/a0e2c4a4c4a269ad011d9a6c717c1ded2405711b)) +* use default base url if BASE_URL env var is blank ([#250](https://github.com/anthropics/anthropic-sdk-typescript/issues/250)) ([e38f32f](https://github.com/anthropics/anthropic-sdk-typescript/commit/e38f32f52398f3a082eb745e85179242ecee7663)) + + +### Chores + +* **internal:** debug logging for retries; speculative retry-after-ms support ([#256](https://github.com/anthropics/anthropic-sdk-typescript/issues/256)) ([b4b70fd](https://github.com/anthropics/anthropic-sdk-typescript/commit/b4b70fdbee45dd2a68e46135db45b61381538ae8)) +* **internal:** narrow type into stringifyQuery ([#253](https://github.com/anthropics/anthropic-sdk-typescript/issues/253)) ([3f42e07](https://github.com/anthropics/anthropic-sdk-typescript/commit/3f42e0702ab55cd841c0dc186732028d2fb9f5bb)) + + +### Documentation + +* fix missing async in readme code sample ([#255](https://github.com/anthropics/anthropic-sdk-typescript/issues/255)) ([553fb37](https://github.com/anthropics/anthropic-sdk-typescript/commit/553fb37159a9424a40df1e0f6bb36962ba9f5be8)) +* **readme:** improve api reference ([#254](https://github.com/anthropics/anthropic-sdk-typescript/issues/254)) ([3721927](https://github.com/anthropics/anthropic-sdk-typescript/commit/3721927e895d42c167e2464f30f7f2addb690ec6)) + ## 0.12.1 (2024-01-08) Full Changelog: [v0.12.0...v0.12.1](https://github.com/anthropics/anthropic-sdk-typescript/compare/v0.12.0...v0.12.1) diff --git a/README.md b/README.md index dce1e86..3ff6091 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ This library provides convenient access to the Anthropic REST API from server-si For the AWS Bedrock API, see [`@anthropic-ai/bedrock-sdk`](https://github.com/anthropics/anthropic-bedrock-typescript). -The API documentation can be found [here](https://docs.anthropic.com/claude/reference/). +The REST API documentation can be found [on docs.anthropic.com](https://docs.anthropic.com/claude/reference/). The full API of this library can be found in [api.md](https://www.github.com/anthropics/anthropic-sdk-typescript/blob/main/api.md). ## Installation @@ -304,8 +304,8 @@ import { fetch } from 'undici'; // as one example import Anthropic from '@anthropic-ai/sdk'; const client = new Anthropic({ - fetch: (url: RequestInfo, init?: RequestInfo): Response => { - console.log('About to make request', url, init); + fetch: async (url: RequestInfo, init?: RequestInfo): Promise => { + console.log('About to make a request', url, init); const response = await fetch(url, init); console.log('Got response', response); return response; diff --git a/package.json b/package.json index 450c6e5..293c8ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@anthropic-ai/sdk", - "version": "0.12.1", + "version": "0.12.2", "description": "The official TypeScript library for the Anthropic API", "author": "Anthropic ", "types": "dist/index.d.ts", diff --git a/src/core.ts b/src/core.ts index ab80c66..d5a3900 100644 --- a/src/core.ts +++ b/src/core.ts @@ -417,14 +417,17 @@ export abstract class APIClient { if (!response.ok) { if (retriesRemaining && this.shouldRetry(response)) { + const retryMessage = `retrying, ${retriesRemaining} attempts remaining`; + debug(`response (error; ${retryMessage})`, response.status, url, responseHeaders); return this.retryRequest(options, retriesRemaining, responseHeaders); } const errText = await response.text().catch((e) => castToError(e).message); const errJSON = safeJSON(errText); const errMessage = errJSON ? undefined : errText; + const retryMessage = retriesRemaining ? `(error; no more retries left)` : `(error; not retryable)`; - debug('response', response.status, url, responseHeaders, errMessage); + debug(`response (error; ${retryMessage})`, response.status, url, responseHeaders, errMessage); const err = this.makeStatusError(response.status, errJSON, errMessage, responseHeaders); throw err; @@ -452,8 +455,8 @@ export abstract class APIClient { query = { ...defaultQuery, ...query } as Req; } - if (query) { - url.search = this.stringifyQuery(query); + if (typeof query === 'object' && query && !Array.isArray(query)) { + url.search = this.stringifyQuery(query as Record); } return url.toString(); @@ -529,11 +532,21 @@ export abstract class APIClient { retriesRemaining: number, responseHeaders?: Headers | undefined, ): Promise { - // About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After let timeoutMillis: number | undefined; + + // Note the `retry-after-ms` header may not be standard, but is a good idea and we'd like proactive support for it. + const retryAfterMillisHeader = responseHeaders?.['retry-after-ms']; + if (retryAfterMillisHeader) { + const timeoutMs = parseFloat(retryAfterMillisHeader); + if (!Number.isNaN(timeoutMs)) { + timeoutMillis = timeoutMs; + } + } + + // About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After const retryAfterHeader = responseHeaders?.['retry-after']; - if (retryAfterHeader) { - const timeoutSeconds = parseInt(retryAfterHeader); + if (retryAfterHeader && !timeoutMillis) { + const timeoutSeconds = parseFloat(retryAfterHeader); if (!Number.isNaN(timeoutSeconds)) { timeoutMillis = timeoutSeconds * 1000; } else { @@ -543,12 +556,7 @@ export abstract class APIClient { // If the API asks us to wait a certain amount of time (and it's a reasonable amount), // just do what it says, but otherwise calculate a default - if ( - !timeoutMillis || - !Number.isInteger(timeoutMillis) || - timeoutMillis <= 0 || - timeoutMillis > 60 * 1000 - ) { + if (!(timeoutMillis && 0 <= timeoutMillis && timeoutMillis < 60 * 1000)) { const maxRetries = options.maxRetries ?? this.maxRetries; timeoutMillis = this.calculateDefaultRetryTimeoutMillis(retriesRemaining, maxRetries); } @@ -961,14 +969,16 @@ export const ensurePresent = (value: T | null | undefined): T => { /** * Read an environment variable. * + * Trims beginning and trailing whitespace. + * * Will return undefined if the environment variable doesn't exist or cannot be accessed. */ export const readEnv = (env: string): string | undefined => { if (typeof process !== 'undefined') { - return process.env?.[env] ?? undefined; + return process.env?.[env]?.trim() ?? undefined; } if (typeof Deno !== 'undefined') { - return Deno.env?.get?.(env); + return Deno.env?.get?.(env)?.trim(); } return undefined; }; diff --git a/src/index.ts b/src/index.ts index 9cc5681..6c3c495 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,12 +10,12 @@ export interface ClientOptions { /** * Defaults to process.env['ANTHROPIC_API_KEY']. */ - apiKey?: string | null; + apiKey?: string | null | undefined; /** * Defaults to process.env['ANTHROPIC_AUTH_TOKEN']. */ - authToken?: string | null; + authToken?: string | null | undefined; /** * Override the default base URL for the API, e.g., "https://api.example.com/v2/" @@ -84,8 +84,8 @@ export class Anthropic extends Core.APIClient { /** * API Client for interfacing with the Anthropic API. * - * @param {string | null} [opts.apiKey=process.env['ANTHROPIC_API_KEY'] ?? null] - * @param {string | null} [opts.authToken=process.env['ANTHROPIC_AUTH_TOKEN'] ?? null] + * @param {string | null | undefined} [opts.apiKey=process.env['ANTHROPIC_API_KEY'] ?? null] + * @param {string | null | undefined} [opts.authToken=process.env['ANTHROPIC_AUTH_TOKEN'] ?? null] * @param {string} [opts.baseURL=process.env['ANTHROPIC_BASE_URL'] ?? https://api.anthropic.com] - Override the default base URL for the API. * @param {number} [opts.timeout=10 minutes] - The maximum amount of time (in milliseconds) the client will wait for a response before timing out. * @param {number} [opts.httpAgent] - An HTTP agent used to manage HTTP(s) connections. @@ -104,7 +104,7 @@ export class Anthropic extends Core.APIClient { apiKey, authToken, ...opts, - baseURL: baseURL ?? `https://api.anthropic.com`, + baseURL: baseURL || `https://api.anthropic.com`, }; super({ diff --git a/src/version.ts b/src/version.ts index cee26bb..db09de5 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.12.1'; // x-release-please-version +export const VERSION = '0.12.2'; // x-release-please-version diff --git a/tests/index.test.ts b/tests/index.test.ts index 7b72baf..5ebbeef 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -140,7 +140,7 @@ describe('instantiate client', () => { }); afterEach(() => { - process.env['SINK_BASE_URL'] = undefined; + process.env['ANTHROPIC_BASE_URL'] = undefined; }); test('explicit option', () => { @@ -153,6 +153,18 @@ describe('instantiate client', () => { const client = new Anthropic({ apiKey: 'my-anthropic-api-key' }); expect(client.baseURL).toEqual('https://example.com/from_env'); }); + + test('empty env variable', () => { + process.env['ANTHROPIC_BASE_URL'] = ''; // empty + const client = new Anthropic({ apiKey: 'my-anthropic-api-key' }); + expect(client.baseURL).toEqual('https://api.anthropic.com'); + }); + + test('blank env variable', () => { + process.env['ANTHROPIC_BASE_URL'] = ' '; // blank + const client = new Anthropic({ apiKey: 'my-anthropic-api-key' }); + expect(client.baseURL).toEqual('https://api.anthropic.com'); + }); }); test('maxRetries option is correctly set', () => { @@ -211,17 +223,18 @@ describe('request building', () => { }); describe('retries', () => { - test('single retry', async () => { + test('retry on timeout', async () => { let count = 0; const testFetch = async (url: RequestInfo, { signal }: RequestInit = {}): Promise => { - if (!count++) + if (count++ === 0) { return new Promise( (resolve, reject) => signal?.addEventListener('abort', () => reject(new Error('timed out'))), ); + } return new Response(JSON.stringify({ a: 1 }), { headers: { 'Content-Type': 'application/json' } }); }; - const client = new Anthropic({ apiKey: 'my-anthropic-api-key', timeout: 2000, fetch: testFetch }); + const client = new Anthropic({ apiKey: 'my-anthropic-api-key', timeout: 10, fetch: testFetch }); expect(await client.request({ path: '/foo', method: 'get' })).toEqual({ a: 1 }); expect(count).toEqual(2); @@ -232,5 +245,59 @@ describe('retries', () => { .then((r) => r.text()), ).toEqual(JSON.stringify({ a: 1 })); expect(count).toEqual(3); - }, 10000); + }); + + test('retry on 429 with retry-after', async () => { + let count = 0; + const testFetch = async (url: RequestInfo, { signal }: RequestInit = {}): Promise => { + if (count++ === 0) { + return new Response(undefined, { + status: 429, + headers: { + 'Retry-After': '0.1', + }, + }); + } + return new Response(JSON.stringify({ a: 1 }), { headers: { 'Content-Type': 'application/json' } }); + }; + + const client = new Anthropic({ apiKey: 'my-anthropic-api-key', fetch: testFetch }); + + expect(await client.request({ path: '/foo', method: 'get' })).toEqual({ a: 1 }); + expect(count).toEqual(2); + expect( + await client + .request({ path: '/foo', method: 'get' }) + .asResponse() + .then((r) => r.text()), + ).toEqual(JSON.stringify({ a: 1 })); + expect(count).toEqual(3); + }); + + test('retry on 429 with retry-after-ms', async () => { + let count = 0; + const testFetch = async (url: RequestInfo, { signal }: RequestInit = {}): Promise => { + if (count++ === 0) { + return new Response(undefined, { + status: 429, + headers: { + 'Retry-After-Ms': '10', + }, + }); + } + return new Response(JSON.stringify({ a: 1 }), { headers: { 'Content-Type': 'application/json' } }); + }; + + const client = new Anthropic({ apiKey: 'my-anthropic-api-key', fetch: testFetch }); + + expect(await client.request({ path: '/foo', method: 'get' })).toEqual({ a: 1 }); + expect(count).toEqual(2); + expect( + await client + .request({ path: '/foo', method: 'get' }) + .asResponse() + .then((r) => r.text()), + ).toEqual(JSON.stringify({ a: 1 })); + expect(count).toEqual(3); + }); });