Skip to content

chore: unreleased changes #49

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jul 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,6 @@ await anthropic.completions.create(
maxRetries: 5,
},
);

```

### Timeouts
Expand All @@ -247,7 +246,6 @@ await anthropic.completions.create(
timeout: 5 * 1000,
},
);

```

On timeout, an `APIConnectionTimeoutError` is thrown.
Expand Down Expand Up @@ -283,7 +281,6 @@ await anthropic.completions.create(
httpAgent: new http.Agent({ keepAlive: false }),
},
);

```

## Status
Expand Down
2 changes: 1 addition & 1 deletion examples/cancellation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
2 changes: 1 addition & 1 deletion examples/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion examples/streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
1 change: 1 addition & 0 deletions src/_shims/formdata.node.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type EndingType = 'native' | 'transparent';

export interface BlobPropertyBag {
endings?: EndingType;
/** MIME type, e.g., "text/plain" */
type?: string;
}

Expand Down
36 changes: 32 additions & 4 deletions src/core.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
}

Expand All @@ -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<T>`
return new Stream<Rsp>(response, controller) as any;
return new Stream(response, controller) as any;
}

const contentType = response.headers.get('content-type');
Expand Down Expand Up @@ -561,6 +571,7 @@ export type RequestOptions<Req extends {} = Record<string, unknown> | Readable>
stream?: boolean | undefined;
timeout?: number;
httpAgent?: Agent;
signal?: AbortSignal | undefined | null;
idempotencyKey?: string;
};

Expand All @@ -578,6 +589,7 @@ const requestOptionsKeys: KeysEnum<RequestOptions> = {
stream: true,
timeout: true,
httpAgent: true,
signal: true,
idempotencyKey: true,
};

Expand Down Expand Up @@ -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');
};
8 changes: 8 additions & 0 deletions src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -183,6 +184,7 @@ export const {
APIError,
APIConnectionError,
APIConnectionTimeoutError,
APIUserAbortError,
NotFoundError,
ConflictError,
RateLimitError,
Expand Down
4 changes: 3 additions & 1 deletion src/streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export class Stream<Item> implements AsyncIterable<Item>, APIResponse<Stream<Ite
}

async *[Symbol.asyncIterator](): AsyncIterator<Item, any, undefined> {
let done = false;
try {
for await (const sse of this.iterMessages()) {
if (sse.event === 'completion') {
Expand All @@ -75,13 +76,14 @@ export class Stream<Item> implements AsyncIterable<Item>, APIResponse<Stream<Ite
throw APIError.generate(undefined, errJSON, errMessage, this.responseHeaders);
}
}
done = true;
} catch (e) {
// If the user calls `stream.controller.abort()`, we should exit without throwing.
if (e instanceof Error && e.name === 'AbortError') return;
throw e;
} finally {
// If the user `break`s, abort the ongoing request.
this.controller.abort();
if (!done) this.controller.abort();
}
}
}
Expand Down
10 changes: 5 additions & 5 deletions src/uploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export type ToFileInput = Uploadable | Exclude<BlobPart, string> | 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
Expand All @@ -100,6 +100,9 @@ export async function toFile(
name?: string | null | undefined,
options: FilePropertyBag | undefined = {},
): Promise<FileLike> {
// 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';
Expand All @@ -121,10 +124,7 @@ export async function toFile(
return new File(bits, name, options);
}

async function getBytes(value: ToFileInput | PromiseLike<ToFileInput>): Promise<Array<BlobPart>> {
// resolve input promise or promiselike object
value = await value;

async function getBytes(value: ToFileInput): Promise<Array<BlobPart>> {
let parts: Array<BlobPart> = [];
if (
typeof value === 'string' ||
Expand Down
31 changes: 29 additions & 2 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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' });
Expand Down