Skip to content

Commit 63d587e

Browse files
authoredApr 4, 2024··
ai/core anthropic provider (no tool calls) (#1260)
1 parent b7732d7 commit 63d587e

25 files changed

+1036
-9
lines changed
 

‎.changeset/nine-pears-applaud.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'ai': patch
3+
---
4+
5+
Add Anthropic provider for ai/core functions (no tool calling).

‎.changeset/six-years-share.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'ai': patch
3+
---
4+
5+
Add automatic mime type detection for images in ai/core prompts.

‎docs/pages/docs/ai-core/_meta.json

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"openai": "OpenAI Provider",
1212
"mistral": "Mistral Provider",
1313
"google": "Google Provider",
14+
"anthropic": "Anthropic Provider",
1415
"custom-provider": "Custom Providers",
1516
"generate-text": "generateText API",
1617
"stream-text": "streamText API",

‎docs/pages/docs/ai-core/anthropic.mdx

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
title: Anthropic Provider
3+
---
4+
5+
import { Callout } from 'nextra-theme-docs';
6+
7+
# Anthropic Provider
8+
9+
<Callout>The Anthropic provider does not support tool calling yet.</Callout>
10+
11+
The Anthropic provider contains language model support for the [Anthropic Messages API](https://docs.anthropic.com/claude/reference/messages_post).
12+
It creates language model objects that can be used with the `generateText` and `streamText`AI functions.
13+
14+
## Provider Instance
15+
16+
You can import `Anthropic` from `ai/anthropic` and initialize a provider instance with various settings:
17+
18+
```ts
19+
import { Anthropic } from 'ai/anthropic';
20+
21+
const anthropic = new Anthropic({
22+
baseUrl: '', // optional base URL for proxies etc.
23+
apiKey: '', // optional API key, default to env property ANTHROPIC_API_KEY
24+
});
25+
```
26+
27+
The AI SDK also provides a shorthand `anthropic` import with a Anthropic provider instance that uses defaults:
28+
29+
```ts
30+
import { anthropic } from 'ai/anthropic';
31+
```
32+
33+
## Messages Models
34+
35+
You can create models that call the [Anthropic Messages API](https://docs.anthropic.com/claude/reference/messages_post) using the `.messages()` factory method.
36+
The first argument is the model id, e.g. `claude-3-haiku-20240307`.
37+
Some models have multi-modal capabilities.
38+
39+
```ts
40+
const model = anthropic.messages('claude-3-haiku-20240307');
41+
```
42+
43+
Anthropic Messages` models support also some model specific settings that are not part of the [standard call settings](/docs/ai-core/settings).
44+
You can pass them as an options argument:
45+
46+
```ts
47+
const model = anthropic.messages('claude-3-haiku-20240307', {
48+
topK: 0.2,
49+
});
50+
```

‎docs/pages/docs/ai-core/index.mdx

+1
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ The AI SDK contains the following providers:
7575
- [OpenAI Provider](/docs/ai-core/openai) (`ai/openai`)
7676
- [Mistral Provider](/docs/ai-core/mistral) (`ai/mistral`)
7777
- [Google Provider](/docs/ai-core/google) (`ai/google`)
78+
- [Anthropic Provider](/docs/ai-core/anthropic) (`ai/anthropic`)
7879

7980
The AI SDK also provides a [language model specification](https://github.com/vercel/ai/tree/main/packages/core/spec/language-model/v1) that you can use to implement [custom providers](/docs/ai-core/custom-provider).
8081

‎examples/ai-core/.env.example

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
ANTHROPIC_API_KEY=""
12
OPENAI_API_KEY=""
23
MISTRAL_API_KEY=""
3-
GOOGLE_GENERATIVE_AI_API_KEY=""
4+
GOOGLE_GENERATIVE_AI_API_KEY=""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { experimental_generateText } from 'ai';
2+
import { anthropic } from 'ai/anthropic';
3+
import dotenv from 'dotenv';
4+
import fs from 'node:fs';
5+
6+
dotenv.config();
7+
8+
async function main() {
9+
const result = await experimental_generateText({
10+
model: anthropic.messages('claude-3-haiku-20240307'),
11+
maxTokens: 512,
12+
messages: [
13+
{
14+
role: 'user',
15+
content: [
16+
{ type: 'text', text: 'Describe the image in detail.' },
17+
{ type: 'image', image: fs.readFileSync('./data/comic-cat.png') },
18+
],
19+
},
20+
],
21+
});
22+
23+
console.log(result.text);
24+
}
25+
26+
main();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { experimental_generateText } from 'ai';
2+
import { anthropic } from 'ai/anthropic';
3+
import dotenv from 'dotenv';
4+
5+
dotenv.config();
6+
7+
async function main() {
8+
const result = await experimental_generateText({
9+
model: anthropic.messages('claude-3-haiku-20240307'),
10+
prompt: 'Invent a new holiday and describe its traditions.',
11+
});
12+
13+
console.log(result.text);
14+
console.log();
15+
console.log('Token usage:', result.usage);
16+
console.log('Finish reason:', result.finishReason);
17+
}
18+
19+
main();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { experimental_streamText } from 'ai';
2+
import { anthropic } from 'ai/anthropic';
3+
import dotenv from 'dotenv';
4+
import fs from 'node:fs';
5+
6+
dotenv.config();
7+
8+
async function main() {
9+
const result = await experimental_streamText({
10+
model: anthropic.messages('claude-3-haiku-20240307'),
11+
maxTokens: 512,
12+
messages: [
13+
{
14+
role: 'user',
15+
content: [
16+
{ type: 'text', text: 'Describe the image in detail.' },
17+
{ type: 'image', image: fs.readFileSync('./data/comic-cat.png') },
18+
],
19+
},
20+
],
21+
});
22+
23+
for await (const textPart of result.textStream) {
24+
process.stdout.write(textPart);
25+
}
26+
}
27+
28+
main();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { experimental_streamText } from 'ai';
2+
import { anthropic } from 'ai/anthropic';
3+
import dotenv from 'dotenv';
4+
5+
dotenv.config();
6+
7+
async function main() {
8+
const result = await experimental_streamText({
9+
model: anthropic.messages('claude-3-haiku-20240307'),
10+
prompt: 'Invent a new holiday and describe its traditions.',
11+
});
12+
13+
for await (const textPart of result.textStream) {
14+
process.stdout.write(textPart);
15+
}
16+
}
17+
18+
main();
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { z } from 'zod';
2+
import { createJsonErrorResponseHandler } from '../spec';
3+
4+
const anthropicErrorDataSchema = z.object({
5+
type: z.literal('error'),
6+
error: z.object({
7+
type: z.string(),
8+
message: z.string(),
9+
}),
10+
});
11+
12+
export type AnthropicErrorData = z.infer<typeof anthropicErrorDataSchema>;
13+
14+
export const anthropicFailedResponseHandler = createJsonErrorResponseHandler({
15+
errorSchema: anthropicErrorDataSchema,
16+
errorToMessage: data => data.error.message,
17+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { generateId, loadApiKey } from '../spec';
2+
import { AnthropicMessagesLanguageModel } from './anthropic-messages-language-model';
3+
import {
4+
AnthropicMessagesModelId,
5+
AnthropicMessagesSettings,
6+
} from './anthropic-messages-settings';
7+
8+
/**
9+
* Anthropic provider.
10+
*/
11+
export class Anthropic {
12+
readonly baseUrl?: string;
13+
readonly apiKey?: string;
14+
15+
private readonly generateId: () => string;
16+
17+
constructor(
18+
options: {
19+
baseUrl?: string;
20+
apiKey?: string;
21+
generateId?: () => string;
22+
} = {},
23+
) {
24+
this.baseUrl = options.baseUrl;
25+
this.apiKey = options.apiKey;
26+
this.generateId = options.generateId ?? generateId;
27+
}
28+
29+
private get baseConfig() {
30+
return {
31+
baseUrl: this.baseUrl ?? 'https://api.anthropic.com/v1',
32+
headers: () => ({
33+
'anthropic-version': '2023-06-01',
34+
'x-api-key': loadApiKey({
35+
apiKey: this.apiKey,
36+
environmentVariableName: 'ANTHROPIC_API_KEY',
37+
description: 'Anthropic',
38+
}),
39+
}),
40+
};
41+
}
42+
43+
messages(
44+
modelId: AnthropicMessagesModelId,
45+
settings: AnthropicMessagesSettings = {},
46+
) {
47+
return new AnthropicMessagesLanguageModel(modelId, settings, {
48+
provider: 'anthropic.messages',
49+
...this.baseConfig,
50+
generateId: this.generateId,
51+
});
52+
}
53+
}
54+
55+
/**
56+
* Default Anthropic provider instance.
57+
*/
58+
export const anthropic = new Anthropic();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { LanguageModelV1Prompt } from '../spec';
2+
import { convertStreamToArray } from '../spec/test/convert-stream-to-array';
3+
import { JsonTestServer } from '../spec/test/json-test-server';
4+
import { StreamingTestServer } from '../spec/test/streaming-test-server';
5+
import { Anthropic } from './anthropic-facade';
6+
7+
const TEST_PROMPT: LanguageModelV1Prompt = [
8+
{ role: 'user', content: [{ type: 'text', text: 'Hello' }] },
9+
];
10+
11+
const anthropic = new Anthropic({
12+
apiKey: 'test-api-key',
13+
});
14+
15+
const model = anthropic.messages('claude-3-haiku-20240307');
16+
17+
describe('doGenerate', () => {
18+
const server = new JsonTestServer('https://api.anthropic.com/v1/messages');
19+
20+
server.setupTestEnvironment();
21+
22+
function prepareJsonResponse({
23+
content = '',
24+
usage = {
25+
input_tokens: 4,
26+
output_tokens: 30,
27+
},
28+
}: {
29+
content?: string;
30+
usage?: {
31+
input_tokens: number;
32+
output_tokens: number;
33+
};
34+
}) {
35+
server.responseBodyJson = {
36+
id: 'msg_017TfcQ4AgGxKyBduUpqYPZn',
37+
type: 'message',
38+
role: 'assistant',
39+
content: [{ type: 'text', text: content }],
40+
model: 'claude-3-haiku-20240307',
41+
stop_reason: 'end_turn',
42+
stop_sequence: null,
43+
usage,
44+
};
45+
}
46+
47+
it('should extract text response', async () => {
48+
prepareJsonResponse({ content: 'Hello, World!' });
49+
50+
const { text } = await anthropic.messages('gpt-3.5-turbo').doGenerate({
51+
inputFormat: 'prompt',
52+
mode: { type: 'regular' },
53+
prompt: TEST_PROMPT,
54+
});
55+
56+
expect(text).toStrictEqual('Hello, World!');
57+
});
58+
59+
it('should extract usage', async () => {
60+
prepareJsonResponse({
61+
content: '',
62+
usage: { input_tokens: 20, output_tokens: 5 },
63+
});
64+
65+
const { usage } = await model.doGenerate({
66+
inputFormat: 'prompt',
67+
mode: { type: 'regular' },
68+
prompt: TEST_PROMPT,
69+
});
70+
71+
expect(usage).toStrictEqual({
72+
promptTokens: 20,
73+
completionTokens: 5,
74+
});
75+
});
76+
77+
it('should pass the model and the messages', async () => {
78+
prepareJsonResponse({ content: '' });
79+
80+
await model.doGenerate({
81+
inputFormat: 'prompt',
82+
mode: { type: 'regular' },
83+
prompt: TEST_PROMPT,
84+
});
85+
86+
expect(await server.getRequestBodyJson()).toStrictEqual({
87+
model: 'claude-3-haiku-20240307',
88+
max_tokens: 4096, // default value
89+
messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }],
90+
});
91+
});
92+
93+
it('should pass the api key as Authorization header', async () => {
94+
prepareJsonResponse({ content: '' });
95+
96+
const anthropic = new Anthropic({
97+
apiKey: 'test-api-key',
98+
});
99+
100+
await anthropic.messages('claude-3-haiku-20240307').doGenerate({
101+
inputFormat: 'prompt',
102+
mode: { type: 'regular' },
103+
prompt: TEST_PROMPT,
104+
});
105+
106+
expect((await server.getRequestHeaders()).get('x-api-key')).toStrictEqual(
107+
'test-api-key',
108+
);
109+
});
110+
});
111+
112+
describe('doStream', () => {
113+
const server = new StreamingTestServer(
114+
'https://api.anthropic.com/v1/messages',
115+
);
116+
117+
server.setupTestEnvironment();
118+
119+
function prepareStreamResponse({ content }: { content: string[] }) {
120+
server.responseChunks = [
121+
`data: {"type":"message_start","message":{"id":"msg_01KfpJoAEabmH2iHRRFjQMAG","type":"message","role":"assistant","content":[],"model":"claude-3-haiku-20240307","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":17,"output_tokens":1}} }\n\n`,
122+
`data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""} }\n\n`,
123+
`data: {"type": "ping"}\n\n`,
124+
...content.map(text => {
125+
return `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"${text}"} }\n\n`;
126+
}),
127+
`data: {"type":"content_block_stop","index":0 }\n\n`,
128+
`data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":227} }\n\n`,
129+
`data: {"type":"message_stop" }\n\n`,
130+
];
131+
}
132+
133+
it('should stream text deltas', async () => {
134+
prepareStreamResponse({ content: ['Hello', ', ', 'World!'] });
135+
136+
const { stream } = await model.doStream({
137+
inputFormat: 'prompt',
138+
mode: { type: 'regular' },
139+
prompt: TEST_PROMPT,
140+
});
141+
142+
// note: space moved to last chunk bc of trimming
143+
expect(await convertStreamToArray(stream)).toStrictEqual([
144+
{ type: 'text-delta', textDelta: 'Hello' },
145+
{ type: 'text-delta', textDelta: ', ' },
146+
{ type: 'text-delta', textDelta: 'World!' },
147+
{
148+
type: 'finish',
149+
finishReason: 'stop',
150+
usage: { promptTokens: 17, completionTokens: 227 },
151+
},
152+
]);
153+
});
154+
155+
it('should pass the messages and the model', async () => {
156+
prepareStreamResponse({ content: [] });
157+
158+
await model.doStream({
159+
inputFormat: 'prompt',
160+
mode: { type: 'regular' },
161+
prompt: TEST_PROMPT,
162+
});
163+
164+
expect(await server.getRequestBodyJson()).toStrictEqual({
165+
stream: true,
166+
model: 'claude-3-haiku-20240307',
167+
max_tokens: 4096, // default value
168+
messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }],
169+
});
170+
});
171+
172+
it.skip('should scale the temperature', async () => {
173+
prepareStreamResponse({ content: [] });
174+
175+
await model.doStream({
176+
inputFormat: 'prompt',
177+
mode: { type: 'regular' },
178+
prompt: TEST_PROMPT,
179+
temperature: 0.5,
180+
});
181+
182+
expect((await server.getRequestBodyJson()).temperature).toBeCloseTo(1, 5);
183+
});
184+
185+
it.skip('should scale the frequency penalty', async () => {
186+
prepareStreamResponse({ content: [] });
187+
188+
await model.doStream({
189+
inputFormat: 'prompt',
190+
mode: { type: 'regular' },
191+
prompt: TEST_PROMPT,
192+
frequencyPenalty: 0.2,
193+
});
194+
195+
expect((await server.getRequestBodyJson()).frequency_penalty).toBeCloseTo(
196+
0.4,
197+
5,
198+
);
199+
});
200+
201+
it.skip('should scale the presence penalty', async () => {
202+
prepareStreamResponse({ content: [] });
203+
204+
await model.doStream({
205+
inputFormat: 'prompt',
206+
mode: { type: 'regular' },
207+
prompt: TEST_PROMPT,
208+
presencePenalty: -0.9,
209+
});
210+
211+
expect((await server.getRequestBodyJson()).presence_penalty).toBeCloseTo(
212+
-1.8,
213+
5,
214+
);
215+
});
216+
217+
it('should pass the api key as Authorization header', async () => {
218+
prepareStreamResponse({ content: [] });
219+
220+
const anthropic = new Anthropic({
221+
apiKey: 'test-api-key',
222+
});
223+
224+
await anthropic.messages('claude-3-haiku-2024').doStream({
225+
inputFormat: 'prompt',
226+
mode: { type: 'regular' },
227+
prompt: TEST_PROMPT,
228+
});
229+
230+
expect((await server.getRequestHeaders()).get('x-api-key')).toStrictEqual(
231+
'test-api-key',
232+
);
233+
});
234+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
import { z } from 'zod';
2+
import {
3+
LanguageModelV1,
4+
LanguageModelV1CallWarning,
5+
LanguageModelV1FinishReason,
6+
LanguageModelV1StreamPart,
7+
ParseResult,
8+
UnsupportedFunctionalityError,
9+
createEventSourceResponseHandler,
10+
createJsonResponseHandler,
11+
postJsonToApi,
12+
} from '../spec';
13+
import { anthropicFailedResponseHandler } from './anthropic-error';
14+
import {
15+
AnthropicMessagesModelId,
16+
AnthropicMessagesSettings,
17+
} from './anthropic-messages-settings';
18+
import { mapAnthropicFinishReason } from './map-anthropic-finish-reason';
19+
import { convertToAnthropicMessagesPrompt } from './convert-to-anthropic-messages-prompt';
20+
21+
type AnthropicMessagesConfig = {
22+
provider: string;
23+
baseUrl: string;
24+
headers: () => Record<string, string | undefined>;
25+
generateId: () => string;
26+
};
27+
28+
export class AnthropicMessagesLanguageModel implements LanguageModelV1 {
29+
readonly specificationVersion = 'v1';
30+
readonly defaultObjectGenerationMode = 'json';
31+
32+
readonly modelId: AnthropicMessagesModelId;
33+
readonly settings: AnthropicMessagesSettings;
34+
35+
private readonly config: AnthropicMessagesConfig;
36+
37+
constructor(
38+
modelId: AnthropicMessagesModelId,
39+
settings: AnthropicMessagesSettings,
40+
config: AnthropicMessagesConfig,
41+
) {
42+
this.modelId = modelId;
43+
this.settings = settings;
44+
this.config = config;
45+
}
46+
47+
get provider(): string {
48+
return this.config.provider;
49+
}
50+
51+
private getArgs({
52+
mode,
53+
prompt,
54+
maxTokens,
55+
temperature,
56+
topP,
57+
frequencyPenalty,
58+
presencePenalty,
59+
seed,
60+
}: Parameters<LanguageModelV1['doGenerate']>[0]) {
61+
const type = mode.type;
62+
63+
const warnings: LanguageModelV1CallWarning[] = [];
64+
65+
if (frequencyPenalty != null) {
66+
warnings.push({
67+
type: 'unsupported-setting',
68+
setting: 'frequencyPenalty',
69+
});
70+
}
71+
72+
if (presencePenalty != null) {
73+
warnings.push({
74+
type: 'unsupported-setting',
75+
setting: 'presencePenalty',
76+
});
77+
}
78+
79+
if (seed != null) {
80+
warnings.push({
81+
type: 'unsupported-setting',
82+
setting: 'seed',
83+
});
84+
}
85+
86+
const messagesPrompt = convertToAnthropicMessagesPrompt({
87+
provider: this.provider,
88+
prompt,
89+
});
90+
91+
const baseArgs = {
92+
// model id:
93+
model: this.modelId,
94+
95+
// model specific settings:
96+
top_k: this.settings.topK,
97+
98+
// standardized settings:
99+
max_tokens: maxTokens ?? 4096, // 4096: max model output tokens
100+
temperature, // uses 0..1 scale
101+
top_p: topP,
102+
103+
// prompt:
104+
system: messagesPrompt.system,
105+
messages: messagesPrompt.messages,
106+
};
107+
108+
switch (type) {
109+
case 'regular': {
110+
// when the tools array is empty, change it to undefined to prevent OpenAI errors:
111+
const tools = mode.tools?.length ? mode.tools : undefined;
112+
113+
return {
114+
args: {
115+
...baseArgs,
116+
tools: tools?.map(tool => ({
117+
type: 'function',
118+
function: {
119+
name: tool.name,
120+
description: tool.description,
121+
parameters: tool.parameters,
122+
},
123+
})),
124+
},
125+
warnings,
126+
};
127+
}
128+
129+
case 'object-json': {
130+
return {
131+
args: {
132+
...baseArgs,
133+
response_format: { type: 'json_object' },
134+
},
135+
warnings,
136+
};
137+
}
138+
139+
case 'object-tool': {
140+
return {
141+
args: {
142+
...baseArgs,
143+
tool_choice: 'any',
144+
tools: [{ type: 'function', function: mode.tool }],
145+
},
146+
warnings,
147+
};
148+
}
149+
150+
case 'object-grammar': {
151+
throw new UnsupportedFunctionalityError({
152+
functionality: 'object-grammar mode',
153+
provider: this.provider,
154+
});
155+
}
156+
157+
default: {
158+
const _exhaustiveCheck: never = type;
159+
throw new Error(`Unsupported type: ${_exhaustiveCheck}`);
160+
}
161+
}
162+
}
163+
164+
async doGenerate(
165+
options: Parameters<LanguageModelV1['doGenerate']>[0],
166+
): Promise<Awaited<ReturnType<LanguageModelV1['doGenerate']>>> {
167+
const { args, warnings } = this.getArgs(options);
168+
169+
const response = await postJsonToApi({
170+
url: `${this.config.baseUrl}/messages`,
171+
headers: this.config.headers(),
172+
body: args,
173+
failedResponseHandler: anthropicFailedResponseHandler,
174+
successfulResponseHandler: createJsonResponseHandler(
175+
anthropicMessagesResponseSchema,
176+
),
177+
abortSignal: options.abortSignal,
178+
});
179+
180+
const { messages: rawPrompt, ...rawSettings } = args;
181+
182+
return {
183+
text: response.content.map(({ text }) => text).join(''),
184+
finishReason: mapAnthropicFinishReason(response.stop_reason),
185+
usage: {
186+
promptTokens: response.usage.input_tokens,
187+
completionTokens: response.usage.output_tokens,
188+
},
189+
rawCall: { rawPrompt, rawSettings },
190+
warnings,
191+
};
192+
}
193+
194+
async doStream(
195+
options: Parameters<LanguageModelV1['doStream']>[0],
196+
): Promise<Awaited<ReturnType<LanguageModelV1['doStream']>>> {
197+
const { args, warnings } = this.getArgs(options);
198+
199+
const response = await postJsonToApi({
200+
url: `${this.config.baseUrl}/messages`,
201+
headers: this.config.headers(),
202+
body: {
203+
...args,
204+
stream: true,
205+
},
206+
failedResponseHandler: anthropicFailedResponseHandler,
207+
successfulResponseHandler: createEventSourceResponseHandler(
208+
anthropicMessagesChunkSchema,
209+
),
210+
abortSignal: options.abortSignal,
211+
});
212+
213+
const { messages: rawPrompt, ...rawSettings } = args;
214+
215+
let finishReason: LanguageModelV1FinishReason = 'other';
216+
const usage: { promptTokens: number; completionTokens: number } = {
217+
promptTokens: Number.NaN,
218+
completionTokens: Number.NaN,
219+
};
220+
221+
const generateId = this.config.generateId;
222+
223+
return {
224+
stream: response.pipeThrough(
225+
new TransformStream<
226+
ParseResult<z.infer<typeof anthropicMessagesChunkSchema>>,
227+
LanguageModelV1StreamPart
228+
>({
229+
transform(chunk, controller) {
230+
if (!chunk.success) {
231+
controller.enqueue({ type: 'error', error: chunk.error });
232+
return;
233+
}
234+
235+
const value = chunk.value;
236+
237+
switch (value.type) {
238+
case 'ping':
239+
case 'content_block_start':
240+
case 'content_block_stop': {
241+
return; // ignored
242+
}
243+
244+
case 'content_block_delta': {
245+
controller.enqueue({
246+
type: 'text-delta',
247+
textDelta: value.delta.text,
248+
});
249+
return;
250+
}
251+
252+
case 'message_start': {
253+
usage.promptTokens = value.message.usage.input_tokens;
254+
usage.completionTokens = value.message.usage.output_tokens;
255+
return;
256+
}
257+
258+
case 'message_delta': {
259+
usage.completionTokens = value.usage.output_tokens;
260+
finishReason = mapAnthropicFinishReason(
261+
value.delta.stop_reason,
262+
);
263+
return;
264+
}
265+
266+
case 'message_stop': {
267+
controller.enqueue({ type: 'finish', finishReason, usage });
268+
return;
269+
}
270+
271+
default: {
272+
const _exhaustiveCheck: never = value;
273+
throw new Error(`Unsupported chunk type: ${_exhaustiveCheck}`);
274+
}
275+
}
276+
},
277+
}),
278+
),
279+
rawCall: { rawPrompt, rawSettings },
280+
warnings,
281+
};
282+
}
283+
}
284+
285+
// limited version of the schema, focussed on what is needed for the implementation
286+
// this approach limits breakages when the API changes and increases efficiency
287+
const anthropicMessagesResponseSchema = z.object({
288+
type: z.literal('message'),
289+
content: z.array(
290+
z.object({
291+
type: z.literal('text'),
292+
text: z.string(),
293+
}),
294+
),
295+
stop_reason: z.string().optional().nullable(),
296+
usage: z.object({
297+
input_tokens: z.number(),
298+
output_tokens: z.number(),
299+
}),
300+
});
301+
302+
// limited version of the schema, focussed on what is needed for the implementation
303+
// this approach limits breakages when the API changes and increases efficiency
304+
const anthropicMessagesChunkSchema = z.discriminatedUnion('type', [
305+
z.object({
306+
type: z.literal('message_start'),
307+
message: z.object({
308+
usage: z.object({
309+
input_tokens: z.number(),
310+
output_tokens: z.number(),
311+
}),
312+
}),
313+
}),
314+
z.object({
315+
type: z.literal('content_block_start'),
316+
index: z.number(),
317+
content_block: z.object({
318+
type: z.literal('text'),
319+
text: z.string(),
320+
}),
321+
}),
322+
z.object({
323+
type: z.literal('content_block_delta'),
324+
index: z.number(),
325+
delta: z.object({
326+
type: z.literal('text_delta'),
327+
text: z.string(),
328+
}),
329+
}),
330+
z.object({
331+
type: z.literal('content_block_stop'),
332+
index: z.number(),
333+
}),
334+
z.object({
335+
type: z.literal('message_delta'),
336+
delta: z.object({ stop_reason: z.string().optional().nullable() }),
337+
usage: z.object({ output_tokens: z.number() }),
338+
}),
339+
z.object({
340+
type: z.literal('message_stop'),
341+
}),
342+
z.object({
343+
type: z.literal('ping'),
344+
}),
345+
]);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export type AnthropicMessagesPrompt = {
2+
system?: string;
3+
messages: AnthropicMessage[];
4+
};
5+
6+
export type AnthropicMessage = AnthropicUserMessage | AnthropicAssistantMessage;
7+
8+
export interface AnthropicUserMessage {
9+
role: 'user';
10+
content: Array<AnthropicUserContent>;
11+
}
12+
13+
export type AnthropicUserContent =
14+
| AnthropicUserTextContent
15+
| AnthropicUserImageContent;
16+
17+
export interface AnthropicUserTextContent {
18+
type: 'text';
19+
text: string;
20+
}
21+
22+
export interface AnthropicUserImageContent {
23+
type: 'image';
24+
source: {
25+
type: 'base64';
26+
media_type: string;
27+
data: string;
28+
};
29+
}
30+
31+
export interface AnthropicAssistantMessage {
32+
role: 'assistant';
33+
content: string;
34+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// https://docs.anthropic.com/claude/docs/models-overview
2+
export type AnthropicMessagesModelId =
3+
| 'claude-3-opus-20240229'
4+
| 'claude-3-sonnet-20240229'
5+
| 'claude-3-haiku-20240307'
6+
| (string & {});
7+
8+
export interface AnthropicMessagesSettings {
9+
/**
10+
Only sample from the top K options for each subsequent token.
11+
12+
Used to remove "long tail" low probability responses.
13+
Recommended for advanced use cases only. You usually only need to use temperature.
14+
*/
15+
topK?: number;
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import {
2+
LanguageModelV1Prompt,
3+
UnsupportedFunctionalityError,
4+
convertUint8ArrayToBase64,
5+
} from '../spec';
6+
import {
7+
AnthropicMessage,
8+
AnthropicMessagesPrompt,
9+
} from './anthropic-messages-prompt';
10+
11+
export function convertToAnthropicMessagesPrompt({
12+
prompt,
13+
provider,
14+
}: {
15+
prompt: LanguageModelV1Prompt;
16+
provider: string;
17+
}): AnthropicMessagesPrompt {
18+
let system: string | undefined;
19+
const messages: AnthropicMessage[] = [];
20+
21+
for (const { role, content } of prompt) {
22+
switch (role) {
23+
case 'system': {
24+
system = content;
25+
break;
26+
}
27+
28+
case 'user': {
29+
messages.push({
30+
role: 'user',
31+
content: content.map(part => {
32+
switch (part.type) {
33+
case 'text': {
34+
return { type: 'text', text: part.text };
35+
}
36+
case 'image': {
37+
if (part.image instanceof URL) {
38+
throw new UnsupportedFunctionalityError({
39+
provider,
40+
functionality: 'URL image parts',
41+
});
42+
} else {
43+
return {
44+
type: 'image',
45+
source: {
46+
type: 'base64',
47+
media_type: part.mimeType ?? 'image/jpeg',
48+
data: convertUint8ArrayToBase64(part.image),
49+
},
50+
};
51+
}
52+
}
53+
}
54+
}),
55+
});
56+
break;
57+
}
58+
59+
case 'assistant': {
60+
let text = '';
61+
62+
for (const part of content) {
63+
switch (part.type) {
64+
case 'text': {
65+
text += part.text;
66+
break;
67+
}
68+
case 'tool-call': {
69+
throw new UnsupportedFunctionalityError({
70+
provider,
71+
functionality: 'tool-call-part',
72+
});
73+
}
74+
default: {
75+
const _exhaustiveCheck: never = part;
76+
throw new Error(`Unsupported part: ${_exhaustiveCheck}`);
77+
}
78+
}
79+
}
80+
81+
messages.push({
82+
role: 'assistant',
83+
content: text,
84+
});
85+
86+
break;
87+
}
88+
case 'tool': {
89+
throw new UnsupportedFunctionalityError({
90+
provider,
91+
functionality: 'tool-role',
92+
});
93+
}
94+
default: {
95+
const _exhaustiveCheck: never = role;
96+
throw new Error(`Unsupported role: ${_exhaustiveCheck}`);
97+
}
98+
}
99+
}
100+
101+
return {
102+
system,
103+
messages,
104+
};
105+
}

‎packages/core/anthropic/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './anthropic-facade';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { LanguageModelV1FinishReason } from '../spec';
2+
3+
export function mapAnthropicFinishReason(
4+
finishReason: string | null | undefined,
5+
): LanguageModelV1FinishReason {
6+
switch (finishReason) {
7+
case 'end_turn':
8+
case 'stop_sequence':
9+
return 'stop';
10+
case 'max_tokens':
11+
return 'length';
12+
default:
13+
return 'other';
14+
}
15+
}

‎packages/core/core/prompt/convert-to-language-model-prompt.ts

+16-5
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
LanguageModelV1Prompt,
55
LanguageModelV1TextPart,
66
} from '../../spec';
7+
import { detectImageMimeType } from '../util/detect-image-mimetype';
78
import { convertDataContentToUint8Array } from './data-content';
89
import { ValidatedPrompt } from './get-validated-prompt';
910

@@ -49,13 +50,23 @@ export function convertToLanguageModelPrompt(
4950
}
5051

5152
case 'image': {
53+
if (part.image instanceof URL) {
54+
return {
55+
type: 'image',
56+
image: part.image,
57+
mimeType: part.mimeType,
58+
};
59+
}
60+
61+
const imageUint8 = convertDataContentToUint8Array(
62+
part.image,
63+
);
64+
5265
return {
5366
type: 'image',
54-
image:
55-
part.image instanceof URL
56-
? part.image
57-
: convertDataContentToUint8Array(part.image),
58-
mimeType: part.mimeType,
67+
image: imageUint8,
68+
mimeType:
69+
part.mimeType ?? detectImageMimeType(imageUint8),
5970
};
6071
}
6172
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
const mimeTypeSignatures = [
2+
{ mimeType: 'image/gif' as const, bytes: [0x47, 0x49, 0x46] },
3+
{ mimeType: 'image/png' as const, bytes: [0x89, 0x50, 0x4e, 0x47] },
4+
{ mimeType: 'image/jpeg' as const, bytes: [0xff, 0xd8] },
5+
{ mimeType: 'image/webp' as const, bytes: [0x52, 0x49, 0x46, 0x46] },
6+
];
7+
8+
export function detectImageMimeType(
9+
image: Uint8Array,
10+
): 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp' | undefined {
11+
for (const { bytes, mimeType } of mimeTypeSignatures) {
12+
if (
13+
image.length >= bytes.length &&
14+
bytes.every((byte, index) => image[index] === byte)
15+
) {
16+
return mimeType;
17+
}
18+
}
19+
20+
return undefined;
21+
}

‎packages/core/mistral/mistral-chat-language-model.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ export class MistralChatLanguageModel implements LanguageModelV1 {
163163
body: args,
164164
failedResponseHandler: mistralFailedResponseHandler,
165165
successfulResponseHandler: createJsonResponseHandler(
166-
openAIChatResponseSchema,
166+
mistralChatResponseSchema,
167167
),
168168
abortSignal: options.abortSignal,
169169
});
@@ -296,7 +296,7 @@ export class MistralChatLanguageModel implements LanguageModelV1 {
296296

297297
// limited version of the schema, focussed on what is needed for the implementation
298298
// this approach limits breakages when the API changes and increases efficiency
299-
const openAIChatResponseSchema = z.object({
299+
const mistralChatResponseSchema = z.object({
300300
choices: z.array(
301301
z.object({
302302
message: z.object({

‎packages/core/package.json

+9-1
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@
1717
"spec/dist/**/*",
1818
"google/dist/**/*",
1919
"openai/dist/**/*",
20+
"anthropic/dist/**/*",
2021
"mistral/dist/**/*"
2122
],
2223
"scripts": {
2324
"build": "tsup && cat react/dist/index.server.d.ts >> react/dist/index.d.ts",
24-
"clean": "rm -rf dist && rm -rf core/dist && rm -rf google/dist && rm -rf openai/dist && rm -rf mistral/dist && rm -rf spec/dist && rm -rf react/dist && rm -rf svelte/dist && rm -rf vue/dist && rm -rf solid/dist && rm -rf rsc/dist",
25+
"clean": "rm -rf dist && rm -rf core/dist && rm -rf google/dist && rm -rf anthropic/dist && rm -rf openai/dist && rm -rf mistral/dist && rm -rf spec/dist && rm -rf react/dist && rm -rf svelte/dist && rm -rf vue/dist && rm -rf solid/dist && rm -rf rsc/dist",
2526
"dev": "tsup --watch",
2627
"lint": "eslint \"./**/*.ts*\"",
2728
"type-check": "tsc --noEmit",
@@ -30,6 +31,7 @@
3031
"test:edge": "vitest --config vitest.edge.config.js --run --threads=false",
3132
"test:node": "vitest --config vitest.node.config.js --run --threads=false",
3233
"test:node:core": "pnpm vitest --config vitest.node.config.js --run --threads=false ./core/",
34+
"test:node:anthropic": "pnpm vitest --config vitest.node.config.js --run --threads=false ./anthropic/",
3335
"test:node:google": "pnpm vitest --config vitest.node.config.js --run --threads=false ./google/",
3436
"test:node:openai": "pnpm vitest --config vitest.node.config.js --run --threads=false ./openai/",
3537
"test:node:mistral": "pnpm vitest --config vitest.node.config.js --run --threads=false ./mistral/",
@@ -56,6 +58,12 @@
5658
"module": "./spec/dist/index.mjs",
5759
"require": "./spec/dist/index.js"
5860
},
61+
"./anthropic": {
62+
"types": "./anthropic/dist/index.d.ts",
63+
"import": "./anthropic/dist/index.mjs",
64+
"module": "./anthropic/dist/index.mjs",
65+
"require": "./anthropic/dist/index.js"
66+
},
5967
"./google": {
6068
"types": "./google/dist/index.d.ts",
6169
"import": "./google/dist/index.mjs",

‎packages/core/spec/util/response-handler.ts

+1
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export const createEventSourceResponseHandler =
8181
.pipeThrough(
8282
new TransformStream<ParsedEvent, ParseResult<T>>({
8383
transform({ data }, controller) {
84+
// ignore the 'DONE' event that e.g. OpenAI sends:
8485
if (data === '[DONE]') {
8586
return;
8687
}

‎packages/core/tsup.config.ts

+7
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,13 @@ export default defineConfig([
105105
},
106106

107107
// AI Core: Providers
108+
{
109+
entry: ['anthropic/index.ts'],
110+
format: ['cjs', 'esm'],
111+
outDir: 'anthropic/dist',
112+
dts: true,
113+
sourcemap: true,
114+
},
108115
{
109116
entry: ['google/index.ts'],
110117
format: ['cjs', 'esm'],

0 commit comments

Comments
 (0)
Please sign in to comment.