diff --git a/.changeset/fast-books-fail.md b/.changeset/fast-books-fail.md new file mode 100644 index 000000000000..0794fd2fc4aa --- /dev/null +++ b/.changeset/fast-books-fail.md @@ -0,0 +1,5 @@ +--- +'@ai-sdk/togetherai': patch +--- + +feat (examples): Use shared base e2e feature test suite. diff --git a/content/providers/02-openai-compatible-providers/01-custom-providers.mdx b/content/providers/02-openai-compatible-providers/01-custom-providers.mdx index 5a953c2408f2..a68c8de1a4ee 100644 --- a/content/providers/02-openai-compatible-providers/01-custom-providers.mdx +++ b/content/providers/02-openai-compatible-providers/01-custom-providers.mdx @@ -229,3 +229,13 @@ const { text } = await generateText({ ``` This structure provides a clean, type-safe implementation that leverages the OpenAI Compatible package while maintaining consistency with the usage of other AI SDK providers. + +### Internal API + +As you work on your provider you may need to use some of the internal API of the OpenAI Compatible package. You can import these from the `@ai-sdk/openai-compatible/internal` package, for example: + +```ts +import { convertToOpenAICompatibleChatMessages } from '@ai-sdk/openai-compatible/internal'; +``` + +You can see the latest available exports in the AI SDK [GitHub repository](https://github.com/vercel/ai/blob/main/packages/openai-compatible/src/internal/index.ts). diff --git a/examples/ai-core/package.json b/examples/ai-core/package.json index f09cda9a9d4a..637af35b2ff0 100644 --- a/examples/ai-core/package.json +++ b/examples/ai-core/package.json @@ -7,17 +7,17 @@ "@ai-sdk/anthropic": "1.0.6", "@ai-sdk/azure": "1.0.13", "@ai-sdk/cohere": "1.0.6", - "@ai-sdk/deepinfra": "0.0.2", - "@ai-sdk/fireworks": "0.0.5", + "@ai-sdk/deepinfra": "0.0.4", + "@ai-sdk/fireworks": "0.0.7", "@ai-sdk/google": "1.0.12", "@ai-sdk/google-vertex": "2.0.12", "@ai-sdk/groq": "1.0.9", "@ai-sdk/mistral": "1.0.6", "@ai-sdk/openai": "1.0.11", - "@ai-sdk/openai-compatible": "0.0.11", + "@ai-sdk/openai-compatible": "0.0.13", "@ai-sdk/provider": "1.0.3", - "@ai-sdk/togetherai": "0.0.12", - "@ai-sdk/xai": "1.0.12", + "@ai-sdk/togetherai": "0.0.14", + "@ai-sdk/xai": "1.0.14", "@opentelemetry/sdk-node": "0.54.2", "@opentelemetry/auto-instrumentations-node": "0.54.0", "@opentelemetry/sdk-trace-node": "1.28.0", diff --git a/examples/ai-core/src/e2e/deepinfra.test.ts b/examples/ai-core/src/e2e/deepinfra.test.ts index e03d2552a422..45d45ec13637 100644 --- a/examples/ai-core/src/e2e/deepinfra.test.ts +++ b/examples/ai-core/src/e2e/deepinfra.test.ts @@ -1,379 +1,48 @@ import 'dotenv/config'; -import { describe, it, expect, vi } from 'vitest'; -import { createDeepInfra, DeepInfraErrorData } from '@ai-sdk/deepinfra'; -import { z } from 'zod'; -import { - generateText, - generateObject, - streamText, - streamObject, - embed, - embedMany, - APICallError, -} from 'ai'; -import fs from 'fs'; - -const LONG_TEST_MILLIS = 30000; - -// Model variants to test against -const MODEL_VARIANTS = { - chat: [ - // 'google/codegemma-7b-it', // no tools, objects, or images - // 'google/gemma-2-9b-it', // no tools, objects, or images - 'meta-llama/Llama-3.2-11B-Vision-Instruct', // no tools, *does* support images - // 'meta-llama/Llama-3.2-90B-Vision-Instruct', // no tools, *does* support images - // 'meta-llama/Llama-3.3-70B-Instruct-Turbo', // no image input - // 'meta-llama/Llama-3.3-70B-Instruct', // no image input - // 'meta-llama/Meta-Llama-3.1-405B-Instruct', // no image input - // 'meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo', // no image input - // 'meta-llama/Meta-Llama-3.1-70B-Instruct', // no image input - // 'meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo', // no *streaming* tools, no image input - // 'meta-llama/Meta-Llama-3.1-8B-Instruct', // no image input - // 'microsoft/WizardLM-2-8x22B', // no objects, tools, or images - 'mistralai/Mixtral-8x7B-Instruct-v0.1', // no *streaming* tools, no image input - // 'nvidia/Llama-3.1-Nemotron-70B-Instruct', // no images - // 'Qwen/Qwen2-7B-Instruct', // no tools, no image input - 'Qwen/Qwen2.5-72B-Instruct', // no images - // 'Qwen/Qwen2.5-Coder-32B-Instruct', // no tool calls, no image input - // 'Qwen/QwQ-32B-Preview', // no tools, no image input - ], - completion: [ - 'meta-llama/Meta-Llama-3.1-8B-Instruct', - 'Qwen/Qwen2-7B-Instruct', - ], - embedding: [ - 'BAAI/bge-base-en-v1.5', - 'intfloat/e5-base-v2', - 'sentence-transformers/all-mpnet-base-v2', - ], -} as const; - -describe('DeepInfra E2E Tests', () => { - vi.setConfig({ testTimeout: LONG_TEST_MILLIS }); - const provider = createDeepInfra(); - - describe.each(MODEL_VARIANTS.chat)('Chat Model: %s', modelId => { - const model = provider(modelId); - - it('should generate text', async () => { - const result = await generateText({ - model, - prompt: 'Write a haiku about programming.', - }); - - expect(result.text).toBeTruthy(); - expect(result.usage?.totalTokens).toBeGreaterThan(0); - }); - - it('should generate text with tool calls', async () => { - const result = await generateText({ - model, - prompt: 'What is 2+2? Use the calculator tool to compute this.', - tools: { - calculator: { - description: 'A calculator tool', - parameters: z.object({ - expression: z - .string() - .describe('The mathematical expression to evaluate'), - }), - execute: async ({ expression }) => eval(expression).toString(), - }, - }, - }); - - expect(result.toolCalls).toBeTruthy(); - expect(result.toolResults).toBeTruthy(); - expect(result.usage?.totalTokens).toBeGreaterThan(0); - }); - - it('should generate object', async () => { - const result = await generateObject({ - model, - schema: z.object({ - title: z.string(), - tags: z.array(z.string()), - }), - prompt: 'Generate metadata for a blog post about TypeScript.', - }); - - expect(result.object.title).toBeTruthy(); - expect(Array.isArray(result.object.tags)).toBe(true); - expect(result.usage?.totalTokens).toBeGreaterThan(0); - }); - - it('should stream text', async () => { - const result = streamText({ - model, - prompt: 'Count from 1 to 5 slowly.', - }); - - const chunks: string[] = []; - for await (const chunk of result.textStream) { - chunks.push(chunk); - } - - expect(chunks.length).toBeGreaterThan(0); - expect((await result.usage)?.totalTokens).toBeGreaterThan(0); - }); - - it('should stream text with tool calls', async () => { - const result = streamText({ - model, - prompt: 'Calculate 5+7 and 3*4 using the calculator tool.', - tools: { - calculator: { - description: 'A calculator tool', - parameters: z.object({ - expression: z.string(), - }), - execute: async ({ expression }) => eval(expression).toString(), - }, - }, - }); - - const parts = []; - for await (const part of result.fullStream) { - parts.push(part); - } - - expect(parts.some(part => part.type === 'tool-call')).toBe(true); - expect((await result.usage).totalTokens).toBeGreaterThan(0); - }); - - it('should stream object', async () => { - const result = streamObject({ - model, - schema: z.object({ - characters: z.array( - z.object({ - name: z.string(), - class: z - .string() - .describe('Character class, e.g. warrior, mage, or thief.'), - description: z.string(), - }), - ), - }), - prompt: 'Generate 3 RPG character descriptions.', - }); - - const parts = []; - for await (const part of result.partialObjectStream) { - parts.push(part); - } - - expect(parts.length).toBeGreaterThan(0); - expect((await result.usage).totalTokens).toBeGreaterThan(0); - }); - - it('should generate text with image URL input', async () => { - const result = await generateText({ - model, - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'Describe the image in detail.' }, - { - type: 'image', - image: - 'https://github.com/vercel/ai/blob/main/examples/ai-core/data/comic-cat.png?raw=true', - }, - ], - }, - ], - }); - - expect(result.text).toBeTruthy(); - expect(result.text.toLowerCase()).toContain('cat'); - expect(result.usage?.totalTokens).toBeGreaterThan(0); - }); - - it('should generate text with image input', async () => { - const result = await generateText({ - model, - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'Describe the image in detail.' }, - { - type: 'image', - image: fs - .readFileSync('./data/comic-cat.png') - .toString('base64'), - }, - ], - }, - ], - }); - - expect(result.text.toLowerCase()).toContain('cat'); - expect(result.text).toBeTruthy(); - expect(result.usage?.totalTokens).toBeGreaterThan(0); - }); - - it('should stream text with image URL input', async () => { - const result = streamText({ - model, - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'Describe the image in detail.' }, - { - type: 'image', - image: - 'https://github.com/vercel/ai/blob/main/examples/ai-core/data/comic-cat.png?raw=true', - }, - ], - }, - ], - }); - - const chunks: string[] = []; - for await (const chunk of result.textStream) { - chunks.push(chunk); - } - - const fullText = chunks.join(''); - expect(chunks.length).toBeGreaterThan(0); - expect(fullText.toLowerCase()).toContain('cat'); - expect((await result.usage)?.totalTokens).toBeGreaterThan(0); - }); - - it('should stream text with image input', async () => { - const result = streamText({ - model, - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'Describe the image in detail.' }, - { - type: 'image', - image: fs - .readFileSync('./data/comic-cat.png') - .toString('base64'), - }, - ], - }, - ], - }); - - const chunks: string[] = []; - for await (const chunk of result.textStream) { - chunks.push(chunk); - } - - const fullText = chunks.join(''); - expect(fullText.toLowerCase()).toContain('cat'); - expect(chunks.length).toBeGreaterThan(0); - expect((await result.usage)?.totalTokens).toBeGreaterThan(0); - }); - - it('should throw error on generate text attempt with invalid model ID', async () => { - const invalidModel = provider('no-such-model'); - - try { - await generateText({ - model: invalidModel, - prompt: 'This should fail', - }); - // If we reach here, the test should fail - expect(true).toBe(false); // Force test to fail if no error is thrown - } catch (error) { - expect(error).toBeInstanceOf(APICallError); - expect( - ((error as APICallError).data as DeepInfraErrorData).error.message === - 'The model `no-such-model` does not exist', - ).toBe(true); - } - }); - - it('should throw error on stream text attempt with invalid model ID', async () => { - const invalidModel = provider('no-such-model'); - - try { - const result = streamText({ - model: invalidModel, - prompt: 'This should fail', - }); - - // Try to consume the stream to trigger the error - for await (const _ of result.textStream) { - // Do nothing with the chunks - } - - // If we reach here, the test should fail - expect(true).toBe(false); // Force test to fail if no error is thrown - } catch (error) { - expect(error).toBeInstanceOf(APICallError); - expect( - ((error as APICallError).data as DeepInfraErrorData).error.message === - 'The model `no-such-model` does not exist', - ).toBe(true); - } - }); - }); - - describe.each(MODEL_VARIANTS.embedding)('Embedding Model: %s', modelId => { - const model = provider.textEmbeddingModel(modelId); - - it('should generate single embedding', async () => { - const result = await embed({ - model, - value: 'This is a test sentence for embedding.', - }); - - expect(Array.isArray(result.embedding)).toBe(true); - expect(result.embedding.length).toBeGreaterThan(0); - expect(result.usage.tokens).toBeGreaterThan(0); - }); - - it('should generate multiple embeddings', async () => { - const result = await embedMany({ - model, - values: [ - 'First test sentence.', - 'Second test sentence.', - 'Third test sentence.', - ], - }); - - expect(Array.isArray(result.embeddings)).toBe(true); - expect(result.embeddings.length).toBe(3); - expect(result.usage.tokens).toBeGreaterThan(0); - }); - }); - - describe.each(MODEL_VARIANTS.completion)('Completion Model: %s', modelId => { - const model = provider(modelId); - - it('should generate text', async () => { - const result = await generateText({ - model, - prompt: 'Complete this code: function fibonacci(n) {', - }); - - expect(result.text).toBeTruthy(); - expect(result.usage?.totalTokens).toBeGreaterThan(0); - }); - - it('should stream text', async () => { - const result = streamText({ - model, - prompt: 'Write a Python function that sorts a list:', - }); - - const chunks: string[] = []; - for await (const chunk of result.textStream) { - chunks.push(chunk); - } - - expect(chunks.length).toBeGreaterThan(0); - expect((await result.usage)?.totalTokens).toBeGreaterThan(0); - }); - }); -}); +import { expect } from 'vitest'; +import { deepinfra as provider, DeepInfraErrorData } from '@ai-sdk/deepinfra'; +import { APICallError } from 'ai'; +import { createFeatureTestSuite } from './feature-test-suite'; + +createFeatureTestSuite({ + name: 'DeepInfra', + models: { + invalidModel: provider.chatModel('no-such-model'), + languageModels: [ + provider.chatModel('google/codegemma-7b-it'), // no tools, objects, or images + provider.chatModel('google/gemma-2-9b-it'), // no tools, objects, or images + provider.chatModel('meta-llama/Llama-3.2-11B-Vision-Instruct'), // no tools, *does* support images + provider.chatModel('meta-llama/Llama-3.2-90B-Vision-Instruct'), // no tools, *does* support images + provider.chatModel('meta-llama/Llama-3.3-70B-Instruct-Turbo'), // no image input + provider.chatModel('meta-llama/Llama-3.3-70B-Instruct'), // no image input + provider.chatModel('meta-llama/Meta-Llama-3.1-405B-Instruct'), // no image input + provider.chatModel('meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo'), // no image input + provider.chatModel('meta-llama/Meta-Llama-3.1-70B-Instruct'), // no image input + provider.chatModel('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo'), // no *streaming* tools, no image input + provider.chatModel('meta-llama/Meta-Llama-3.1-8B-Instruct'), // no image input + provider.chatModel('microsoft/WizardLM-2-8x22B'), // no objects, tools, or images + provider.chatModel('mistralai/Mixtral-8x7B-Instruct-v0.1'), // no *streaming* tools, no image input + provider.chatModel('nvidia/Llama-3.1-Nemotron-70B-Instruct'), // no images + provider.chatModel('Qwen/Qwen2-7B-Instruct'), // no tools, no image input + provider.chatModel('Qwen/Qwen2.5-72B-Instruct'), // no images + provider.chatModel('Qwen/Qwen2.5-Coder-32B-Instruct'), // no tool calls, no image input + provider.chatModel('Qwen/QwQ-32B-Preview'), // no tools, no image input + provider.completionModel('meta-llama/Meta-Llama-3.1-8B-Instruct'), + provider.completionModel('Qwen/Qwen2-7B-Instruct'), + ], + embeddingModels: [ + provider.textEmbeddingModel('BAAI/bge-base-en-v1.5'), + provider.textEmbeddingModel('intfloat/e5-base-v2'), + provider.textEmbeddingModel('sentence-transformers/all-mpnet-base-v2'), + ], + }, + timeout: 10000, + customAssertions: { + errorValidator: (error: APICallError) => { + expect( + (error.data as DeepInfraErrorData).error.message === + 'The model `no-such-model` does not exist', + ).toBe(true); + }, + }, +})(); diff --git a/examples/ai-core/src/e2e/feature-test-suite.ts b/examples/ai-core/src/e2e/feature-test-suite.ts new file mode 100644 index 000000000000..13820446de62 --- /dev/null +++ b/examples/ai-core/src/e2e/feature-test-suite.ts @@ -0,0 +1,373 @@ +import { z } from 'zod'; +import { + generateText, + generateObject, + streamText, + streamObject, + embed, + embedMany, + APICallError, +} from 'ai'; +import fs from 'fs'; +import { describe, expect, it, vi } from 'vitest'; +import type { EmbeddingModelV1, LanguageModelV1 } from '@ai-sdk/provider'; + +export interface ModelVariants { + invalidModel?: LanguageModelV1; + languageModels?: LanguageModelV1[]; + embeddingModels?: EmbeddingModelV1[]; +} + +export interface TestSuiteOptions { + name: string; + models: ModelVariants; + timeout?: number; + customAssertions?: { + skipUsage?: boolean; + errorValidator?: (error: APICallError) => void; + }; +} + +const createModelObjects = ( + models: T[] | undefined, +) => + models?.map(model => ({ + modelId: model.modelId, + model, + })) || []; + +export function createFeatureTestSuite({ + name, + models, + timeout = 10000, + customAssertions = { skipUsage: false }, +}: TestSuiteOptions) { + return () => { + const errorValidator = + customAssertions.errorValidator || + ((error: APICallError) => { + throw new Error('errorValidator not implemented'); + }); + + describe(`${name} Feature Test Suite`, () => { + vi.setConfig({ testTimeout: timeout }); + + describe.each(createModelObjects(models.languageModels))( + 'Language Model: $modelId', + ({ model }) => { + it('should generate text', async () => { + const result = await generateText({ + model, + prompt: 'Write a haiku about programming.', + }); + + expect(result.text).toBeTruthy(); + if (!customAssertions.skipUsage) { + expect(result.usage?.totalTokens).toBeGreaterThan(0); + } + }); + + it('should generate text with tool calls', async () => { + const result = await generateText({ + model, + prompt: 'What is 2+2? Use the calculator tool to compute this.', + tools: { + calculator: { + parameters: z.object({ + expression: z + .string() + .describe('The mathematical expression to evaluate'), + }), + execute: async ({ expression }) => + eval(expression).toString(), + }, + }, + }); + + expect(result.toolCalls?.[0]).toMatchObject({ + toolName: 'calculator', + args: { expression: '2+2' }, + }); + expect(result.toolResults?.[0].result).toBe('4'); + if (!customAssertions.skipUsage) { + expect(result.usage?.totalTokens).toBeGreaterThan(0); + } + }); + + it('should generate object', async () => { + const result = await generateObject({ + model, + schema: z.object({ + title: z.string(), + tags: z.array(z.string()), + }), + prompt: 'Generate metadata for a blog post about TypeScript.', + }); + + expect(result.object.title).toBeTruthy(); + expect(Array.isArray(result.object.tags)).toBe(true); + if (!customAssertions.skipUsage) { + expect(result.usage?.totalTokens).toBeGreaterThan(0); + } + }); + + it('should stream text', async () => { + const result = streamText({ + model, + prompt: 'Count from 1 to 5 slowly.', + }); + + const chunks: string[] = []; + for await (const chunk of result.textStream) { + chunks.push(chunk); + } + + expect(chunks.length).toBeGreaterThan(0); + if (!customAssertions.skipUsage) { + expect((await result.usage)?.totalTokens).toBeGreaterThan(0); + } + }); + + it('should stream text with tool calls', async () => { + const result = streamText({ + model, + prompt: 'Calculate 5+7 and 3*4 using the calculator tool.', + tools: { + calculator: { + parameters: z.object({ + expression: z.string(), + }), + execute: async ({ expression }) => + eval(expression).toString(), + }, + }, + }); + + const parts = []; + for await (const part of result.fullStream) { + parts.push(part); + } + + expect(parts.some(part => part.type === 'tool-call')).toBe(true); + if (!customAssertions.skipUsage) { + expect((await result.usage).totalTokens).toBeGreaterThan(0); + } + }); + + it('should stream object', async () => { + const result = streamObject({ + model, + schema: z.object({ + characters: z.array( + z.object({ + name: z.string(), + class: z + .string() + .describe( + 'Character class, e.g. warrior, mage, or thief.', + ), + description: z.string(), + }), + ), + }), + prompt: 'Generate 3 RPG character descriptions.', + }); + + const parts = []; + for await (const part of result.partialObjectStream) { + parts.push(part); + } + + expect(parts.length).toBeGreaterThan(0); + if (!customAssertions.skipUsage) { + expect((await result.usage).totalTokens).toBeGreaterThan(0); + } + }); + + it('should generate text with image URL input', async () => { + const result = await generateText({ + model, + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'Describe the image in detail.' }, + { + type: 'image', + image: + 'https://github.com/vercel/ai/blob/main/examples/ai-core/data/comic-cat.png?raw=true', + }, + ], + }, + ], + }); + + expect(result.text).toBeTruthy(); + expect(result.text.toLowerCase()).toContain('cat'); + if (!customAssertions.skipUsage) { + expect(result.usage?.totalTokens).toBeGreaterThan(0); + } + }); + + it('should generate text with image input', async () => { + const result = await generateText({ + model, + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'Describe the image in detail.' }, + { + type: 'image', + // TODO(shaper): Some tests omit the .toString() below. + image: fs + .readFileSync('./data/comic-cat.png') + .toString('base64'), + }, + ], + }, + ], + }); + + expect(result.text.toLowerCase()).toContain('cat'); + if (!customAssertions.skipUsage) { + expect(result.usage?.totalTokens).toBeGreaterThan(0); + } + }); + + it('should stream text with image URL input', async () => { + const result = streamText({ + model, + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'Describe the image in detail.' }, + { + type: 'image', + image: + 'https://github.com/vercel/ai/blob/main/examples/ai-core/data/comic-cat.png?raw=true', + }, + ], + }, + ], + }); + + const chunks: string[] = []; + for await (const chunk of result.textStream) { + chunks.push(chunk); + } + + const fullText = chunks.join(''); + expect(chunks.length).toBeGreaterThan(0); + expect(fullText.toLowerCase()).toContain('cat'); + expect((await result.usage)?.totalTokens).toBeGreaterThan(0); + }); + + it('should stream text with image input', async () => { + const result = streamText({ + model, + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'Describe the image in detail.' }, + { + type: 'image', + image: fs.readFileSync('./data/comic-cat.png'), + }, + ], + }, + ], + }); + + const chunks: string[] = []; + for await (const chunk of result.textStream) { + chunks.push(chunk); + } + + const fullText = chunks.join(''); + expect(fullText.toLowerCase()).toContain('cat'); + expect(chunks.length).toBeGreaterThan(0); + if (!customAssertions.skipUsage) { + expect((await result.usage)?.totalTokens).toBeGreaterThan(0); + } + }); + }, + ); + + if (models.invalidModel) { + describe('Chat Model Error Handling:', () => { + const invalidModel = models.invalidModel!; + + it('should throw error on generate text attempt with invalid model ID', async () => { + try { + await generateText({ + model: invalidModel, + prompt: 'This should fail', + }); + } catch (error) { + expect(error).toBeInstanceOf(APICallError); + errorValidator(error as APICallError); + } + }); + + it('should throw error on stream text attempt with invalid model ID', async () => { + try { + const result = streamText({ + model: invalidModel, + prompt: 'This should fail', + }); + + // Try to consume the stream to trigger the error + for await (const _ of result.textStream) { + // Do nothing with the chunks + } + + // If we reach here, the test should fail + expect(true).toBe(false); // Force test to fail if no error is thrown + } catch (error) { + expect(error).toBeInstanceOf(APICallError); + errorValidator(error as APICallError); + } + }); + }); + + describe.each(createModelObjects(models.embeddingModels))( + 'Embedding Model: $modelId', + ({ model }) => { + it('should generate single embedding', async () => { + const result = await embed({ + model, + value: 'This is a test sentence for embedding.', + }); + + expect(Array.isArray(result.embedding)).toBe(true); + expect(result.embedding.length).toBeGreaterThan(0); + if (!customAssertions.skipUsage) { + expect(result.usage?.tokens).toBeGreaterThan(0); + } + }); + + it('should generate multiple embeddings', async () => { + const result = await embedMany({ + model, + values: [ + 'First test sentence.', + 'Second test sentence.', + 'Third test sentence.', + ], + }); + + expect(Array.isArray(result.embeddings)).toBe(true); + expect(result.embeddings.length).toBe(3); + if (!customAssertions.skipUsage) { + expect(result.usage?.tokens).toBeGreaterThan(0); + } + }); + }, + ); + } + }); + }; +} diff --git a/examples/ai-core/src/e2e/fireworks.test.ts b/examples/ai-core/src/e2e/fireworks.test.ts index 32837d0739fd..af100d555956 100644 --- a/examples/ai-core/src/e2e/fireworks.test.ts +++ b/examples/ai-core/src/e2e/fireworks.test.ts @@ -1,398 +1,33 @@ import 'dotenv/config'; -import { describe, it, expect, vi } from 'vitest'; +import { expect } from 'vitest'; import { fireworks as provider, FireworksErrorData } from '@ai-sdk/fireworks'; -import { z } from 'zod'; -import { - generateText, - generateObject, - streamText, - streamObject, - embed, - embedMany, -} from 'ai'; import { APICallError } from '@ai-sdk/provider'; -import fs from 'fs'; - -const LONG_TEST_MILLIS = 10000; - -// Model variants to test against -const MODEL_VARIANTS = { - chat: [ - 'accounts/fireworks/models/firefunction-v2', - 'accounts/fireworks/models/llama-v3p3-70b-instruct', - 'accounts/fireworks/models/mixtral-8x7b-instruct', - 'accounts/fireworks/models/qwen2p5-72b-instruct', - ], - completion: [ - 'accounts/fireworks/models/llama-v3-8b-instruct', - 'accounts/fireworks/models/llama-v2-34b-code', - ], - embedding: ['nomic-ai/nomic-embed-text-v1.5'], -} as const; - -describe('Fireworks E2E Tests', () => { - vi.setConfig({ testTimeout: LONG_TEST_MILLIS }); - - describe.each(MODEL_VARIANTS.chat)('Chat Model: %s', modelId => { - const model = provider(modelId); - - it('should generate text', async () => { - const result = await generateText({ - model, - prompt: 'Write a haiku about programming.', - }); - - expect(result.text).toBeTruthy(); - expect(result.usage?.totalTokens).toBeGreaterThan(0); - }); - - it('should generate text with tool calls', async () => { - const result = await generateText({ - model, - prompt: 'What is 2+2? Use the calculator tool to compute this.', - tools: { - calculator: { - parameters: z.object({ - expression: z - .string() - .describe('The mathematical expression to evaluate'), - }), - execute: async ({ expression }) => eval(expression).toString(), - }, - }, - }); - - expect(result.toolCalls).toBeTruthy(); - expect(result.toolResults).toBeTruthy(); - expect(result.usage?.totalTokens).toBeGreaterThan(0); - }); - - it('should throw error on generate text attempt with invalid model ID', async () => { - const invalidModel = provider('no-such-model'); - - try { - await generateText({ - model: invalidModel, - prompt: 'This should fail', - }); - // If we reach here, the test should fail - expect(true).toBe(false); // Force test to fail if no error is thrown - } catch (error) { - expect(error).toBeInstanceOf(APICallError); - expect(((error as APICallError).data as FireworksErrorData).error).toBe( - 'Model not found, inaccessible, and/or not deployed', - ); - } - }); - - it('should generate object', async () => { - const result = await generateObject({ - model, - schema: z.object({ - title: z.string(), - tags: z.array(z.string()), - }), - prompt: 'Generate metadata for a blog post about TypeScript.', - }); - - expect(result.object.title).toBeTruthy(); - expect(Array.isArray(result.object.tags)).toBe(true); - expect(result.usage?.totalTokens).toBeGreaterThan(0); - }); - - it('should stream text', async () => { - const result = streamText({ - model, - prompt: 'Count from 1 to 5 slowly.', - }); - - const chunks: string[] = []; - for await (const chunk of result.textStream) { - chunks.push(chunk); - } - - expect(chunks.length).toBeGreaterThan(0); - expect((await result.usage)?.totalTokens).toBeGreaterThan(0); - }); - - it('should stream text with tool calls', async () => { - const result = streamText({ - model, - prompt: 'Calculate 5+7 and 3*4 using the calculator tool.', - tools: { - calculator: { - parameters: z.object({ - expression: z.string(), - }), - execute: async ({ expression }) => eval(expression).toString(), - }, - }, - }); - - const parts = []; - for await (const part of result.fullStream) { - parts.push(part); - } - - expect(parts.some(part => part.type === 'tool-call')).toBe(true); - expect((await result.usage)?.totalTokens).toBeGreaterThan(0); - }); - - it('should stream object', async () => { - const result = streamObject({ - model, - schema: z.object({ - characters: z.array( - z.object({ - name: z.string(), - class: z - .string() - .describe('Character class, e.g. warrior, mage, or thief.'), - description: z.string(), - }), - ), - }), - prompt: 'Generate 3 RPG character descriptions.', - }); - - const parts = []; - for await (const part of result.partialObjectStream) { - parts.push(part); - } - - expect(parts.length).toBeGreaterThan(0); - expect((await result.usage)?.totalTokens).toBeGreaterThan(0); - }); - - it('should throw error on stream text attempt with invalid model ID', async () => { - const invalidModel = provider('no-such-model'); - - try { - const result = streamText({ - model: invalidModel, - prompt: 'This should fail', - }); - - // Try to consume the stream to trigger the error - for await (const _ of result.textStream) { - // Do nothing with the chunks - } - - // If we reach here, the test should fail - expect(true).toBe(false); // Force test to fail if no error is thrown - } catch (error) { - expect(error).toBeInstanceOf(APICallError); - expect(((error as APICallError).data as FireworksErrorData).error).toBe( - 'Model not found, inaccessible, and/or not deployed', - ); - } - }); - - it('should generate text with image URL input', async () => { - const result = await generateText({ - model, - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'Describe the image in detail.' }, - { - type: 'image', - image: - 'https://github.com/vercel/ai/blob/main/examples/ai-core/data/comic-cat.png?raw=true', - }, - ], - }, - ], - }); - - expect(result.text).toBeTruthy(); - expect(result.text.toLowerCase()).toContain('cat'); - expect(result.usage?.totalTokens).toBeGreaterThan(0); - }); - - it('should generate text with image input', async () => { - const result = await generateText({ - model, - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'Describe the image in detail.' }, - { - type: 'image', - image: fs - .readFileSync('./data/comic-cat.png') - .toString('base64'), - }, - ], - }, - ], - }); - - expect(result.text.toLowerCase()).toContain('cat'); - expect(result.text).toBeTruthy(); - expect(result.usage?.totalTokens).toBeGreaterThan(0); - }); - - it('should stream text with image URL input', async () => { - const result = streamText({ - model, - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'Describe the image in detail.' }, - { - type: 'image', - image: - 'https://github.com/vercel/ai/blob/main/examples/ai-core/data/comic-cat.png?raw=true', - }, - ], - }, - ], - }); - - const chunks: string[] = []; - for await (const chunk of result.textStream) { - chunks.push(chunk); - } - - const fullText = chunks.join(''); - expect(chunks.length).toBeGreaterThan(0); - expect(fullText.toLowerCase()).toContain('cat'); - expect((await result.usage)?.totalTokens).toBeGreaterThan(0); - }); - - it('should stream text with image input', async () => { - const result = streamText({ - model, - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'Describe the image in detail.' }, - { - type: 'image', - image: fs - .readFileSync('./data/comic-cat.png') - .toString('base64'), - }, - ], - }, - ], - }); - - const chunks: string[] = []; - for await (const chunk of result.textStream) { - chunks.push(chunk); - } - - const fullText = chunks.join(''); - expect(fullText.toLowerCase()).toContain('cat'); - expect(chunks.length).toBeGreaterThan(0); - expect((await result.usage)?.totalTokens).toBeGreaterThan(0); - }); - }); - - describe.each(MODEL_VARIANTS.embedding)('Embedding Model: %s', modelId => { - const model = provider.textEmbeddingModel(modelId); - - it('should generate single embedding', async () => { - const result = await embed({ - model, - value: 'This is a test sentence for embedding.', - }); - - expect(Array.isArray(result.embedding)).toBe(true); - expect(result.embedding.length).toBeGreaterThan(0); - expect(result.usage?.tokens).toBeGreaterThan(0); - }); - - it('should generate multiple embeddings', async () => { - const result = await embedMany({ - model, - values: [ - 'First test sentence.', - 'Second test sentence.', - 'Third test sentence.', - ], - }); - - expect(Array.isArray(result.embeddings)).toBe(true); - expect(result.embeddings.length).toBe(3); - expect(result.usage?.tokens).toBeGreaterThan(0); - }); - }); - - describe.each(MODEL_VARIANTS.completion)('Completion Model: %s', modelId => { - const model = provider(modelId); - - it('should generate text', async () => { - const result = await generateText({ - model, - prompt: 'Complete this code: function fibonacci(n) {', - }); - - expect(result.text).toBeTruthy(); - expect(result.usage?.totalTokens).toBeGreaterThan(0); - }); - - it('should stream text', async () => { - const result = streamText({ - model, - prompt: 'Write a Python function that sorts a list:', - }); - - const chunks: string[] = []; - for await (const chunk of result.textStream) { - chunks.push(chunk); - } - - expect(chunks.length).toBeGreaterThan(0); - expect((await result.usage)?.totalTokens).toBeGreaterThan(0); - }); - - it('should throw error on generate text attempt with invalid model ID', async () => { - const invalidModel = provider('no-such-model'); - - try { - await generateText({ - model: invalidModel, - prompt: 'This should fail', - }); - // If we reach here, the test should fail - expect(true).toBe(false); - } catch (error) { - expect(error).toBeInstanceOf(APICallError); - expect(((error as APICallError).data as FireworksErrorData).error).toBe( - 'Model not found, inaccessible, and/or not deployed', - ); - } - }); - - it('should throw error on stream text attempt with invalid model ID', async () => { - const invalidModel = provider('no-such-model'); - - try { - const result = streamText({ - model: invalidModel, - prompt: 'This should fail', - }); - - // Try to consume the stream to trigger the error - for await (const _ of result.textStream) { - // Do nothing with the chunks - } - - // If we reach here, the test should fail - expect(true).toBe(false); - } catch (error) { - expect(error).toBeInstanceOf(APICallError); - expect(((error as APICallError).data as FireworksErrorData).error).toBe( - 'Model not found, inaccessible, and/or not deployed', - ); - } - }); - }); -}); +import { createFeatureTestSuite } from './feature-test-suite'; + +createFeatureTestSuite({ + name: 'Fireworks', + models: { + invalidModel: provider.chatModel('no-such-model'), + languageModels: [ + provider.chatModel('accounts/fireworks/models/firefunction-v2'), + provider.chatModel('accounts/fireworks/models/llama-v3p3-70b-instruct'), + provider.chatModel('accounts/fireworks/models/mixtral-8x7b-instruct'), + provider.chatModel('accounts/fireworks/models/qwen2p5-72b-instruct'), + provider.completionModel( + 'accounts/fireworks/models/llama-v3-8b-instruct', + ), + provider.completionModel('accounts/fireworks/models/llama-v2-34b-code'), + ], + embeddingModels: [ + provider.textEmbeddingModel('nomic-ai/nomic-embed-text-v1.5'), + ], + }, + timeout: 10000, + customAssertions: { + errorValidator: (error: APICallError) => { + expect((error.data as FireworksErrorData).error).toBe( + 'Model not found, inaccessible, and/or not deployed', + ); + }, + }, +})(); diff --git a/examples/ai-core/src/e2e/togetherai.test.ts b/examples/ai-core/src/e2e/togetherai.test.ts index 808ae55c657e..f3bcaa1abff0 100644 --- a/examples/ai-core/src/e2e/togetherai.test.ts +++ b/examples/ai-core/src/e2e/togetherai.test.ts @@ -1,369 +1,38 @@ import 'dotenv/config'; -import { describe, it, expect, vi } from 'vitest'; -import { createTogetherAI } from '@ai-sdk/togetherai'; -import { z } from 'zod'; +import { expect } from 'vitest'; import { - generateText, - generateObject, - streamText, - streamObject, - embed, - embedMany, -} from 'ai'; -import fs from 'fs'; - -const LONG_TEST_MILLIS = 10000; - -// Model variants to test against -const MODEL_VARIANTS = { - chat: [ - 'meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo', - 'mistralai/Mistral-7B-Instruct-v0.1', // tool-call supported, our generateObject test script works - 'google/gemma-2b-it', - 'meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo', - 'mistralai/Mixtral-8x7B-Instruct-v0.1', - 'Qwen/Qwen2.5-72B-Instruct-Turbo', - 'databricks/dbrx-instruct', - ], - completion: [ - 'codellama/CodeLlama-34b-Instruct-hf', - 'Qwen/Qwen2.5-Coder-32B-Instruct', - ], - embedding: [ - 'togethercomputer/m2-bert-80M-8k-retrieval', - 'BAAI/bge-base-en-v1.5', - ], -} as const; - -describe('TogetherAI E2E Tests', () => { - vi.setConfig({ testTimeout: LONG_TEST_MILLIS }); - const provider = createTogetherAI(); - - describe.each(MODEL_VARIANTS.chat)('Chat Model: %s', modelId => { - const model = provider(modelId); - - it('should generate text', async () => { - const result = await generateText({ - model, - prompt: 'Write a haiku about programming.', - }); - - expect(result.text).toBeTruthy(); - expect(result.usage?.totalTokens).toBeGreaterThan(0); - }); - - it.skipIf( - [ - 'google/gemma-2b-it', - 'meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo', - 'Qwen/Qwen2.5-72B-Instruct-Turbo', - 'databricks/dbrx-instruct', - ].includes(modelId), - )('should generate text with tool calls', async () => { - const result = await generateText({ - model, - prompt: 'What is 2+2? Use the calculator tool to compute this.', - tools: { - calculator: { - parameters: z.object({ - expression: z - .string() - .describe('The mathematical expression to evaluate'), - }), - execute: async ({ expression }) => eval(expression).toString(), - }, - }, - }); - - expect(result.toolCalls).toBeTruthy(); - expect(result.toolResults).toBeTruthy(); - expect(result.usage?.totalTokens).toBeGreaterThan(0); - }); - - it.skipIf( - [ - 'meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo', - 'google/gemma-2b-it', - 'meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo', - 'Qwen/Qwen2.5-72B-Instruct-Turbo', - 'databricks/dbrx-instruct', - ].includes(modelId), - )('should generate object', async () => { - // NOTE(shaper): Works with 'mistralai/Mistral-7B-Instruct-v0.1' in tool mode. - - // TODO(shaper): Not currently operational iterating on - // https://docs.together.ai/docs/json-mode with 'meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo' - // which is in the list of function-calling models https://docs.together.ai/docs/function-calling#supported-models - // - 'json' mode produces JSON-markdown-formatted response which fails to parse - // - 'tool' mode produces response wrapped with '...' which fails to parse - - const result = await generateObject({ - model, - // mode: 'json', - schema: z.object({ - title: z.string(), - tags: z.array(z.string()), - }), - prompt: 'Generate metadata for a blog post about TypeScript.', - }); - - expect(result.object.title).toBeTruthy(); - expect(Array.isArray(result.object.tags)).toBe(true); - expect(result.usage?.totalTokens).toBeGreaterThan(0); - }); - - it('should stream text', async () => { - const result = streamText({ - model, - prompt: 'Count from 1 to 5 slowly.', - }); - - const chunks: string[] = []; - for await (const chunk of result.textStream) { - chunks.push(chunk); - } - - expect(chunks.length).toBeGreaterThan(0); - expect((await result.usage)?.totalTokens).toBeGreaterThan(0); - }); - - it.skipIf( - [ - 'google/gemma-2b-it', - 'meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo', - 'Qwen/Qwen2.5-72B-Instruct-Turbo', - 'databricks/dbrx-instruct', - ].includes(modelId), - )('should stream text with tool calls', async () => { - const result = streamText({ - model, - prompt: 'Calculate 5+7 and 3*4 using the calculator tool.', - tools: { - calculator: { - parameters: z.object({ - expression: z.string(), - }), - execute: async ({ expression }) => eval(expression).toString(), - }, - }, - }); - - const parts = []; - for await (const part of result.fullStream) { - parts.push(part); - } - - expect(parts.some(part => part.type === 'tool-call')).toBe(true); - // API docs show only `null` for `usage` in sample streaming chunk responses. - // expect((await result.usage).totalTokens).toBeGreaterThan(0); - }); - - it.skipIf( - [ - 'meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo', - 'google/gemma-2b-it', - 'meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo', - 'Qwen/Qwen2.5-72B-Instruct-Turbo', - 'databricks/dbrx-instruct', - ].includes(modelId), - )('should stream object', async () => { - // TODO(shaper): Not currently operational: - // - 'meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo' reports type validation failure around `invalid_union`. - const result = streamObject({ - model, - schema: z.object({ - characters: z.array( - z.object({ - name: z.string(), - class: z - .string() - .describe('Character class, e.g. warrior, mage, or thief.'), - description: z.string(), - }), - ), - }), - prompt: 'Generate 3 RPG character descriptions.', - }); - - const parts = []; - for await (const part of result.partialObjectStream) { - parts.push(part); - } - - expect(parts.length).toBeGreaterThan(0); - // TogetherAI API does not return usage data. - // expect((await result.usage).totalTokens).toBeGreaterThan(0); - }); - - it.skip('should generate text with image URL input', async () => { - // NOTE(shaper): None of the models tested support image input. - const result = await generateText({ - model, - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'Describe the image in detail.' }, - { - type: 'image', - image: - 'https://github.com/vercel/ai/blob/main/examples/ai-core/data/comic-cat.png?raw=true', - }, - ], - }, - ], - }); - - expect(result.text).toBeTruthy(); - expect(result.text.toLowerCase()).toContain('cat'); - expect(result.usage?.totalTokens).toBeGreaterThan(0); - }); - - it.skip('should generate text with image input', async () => { - // NOTE(shaper): None of the models tested support image input. - const result = await generateText({ - model, - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'Describe the image in detail.' }, - { - type: 'image', - image: fs - .readFileSync('./data/comic-cat.png') - .toString('base64'), - }, - ], - }, - ], - }); - - expect(result.text.toLowerCase()).toContain('cat'); - expect(result.text).toBeTruthy(); - expect(result.usage?.totalTokens).toBeGreaterThan(0); - }); - - it.skip('should stream text with image URL input', async () => { - // NOTE(shaper): None of the models tested support image input. - const result = streamText({ - model, - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'Describe the image in detail.' }, - { - type: 'image', - image: - 'https://github.com/vercel/ai/blob/main/examples/ai-core/data/comic-cat.png?raw=true', - }, - ], - }, - ], - }); - - const chunks: string[] = []; - for await (const chunk of result.textStream) { - chunks.push(chunk); - } - - const fullText = chunks.join(''); - expect(chunks.length).toBeGreaterThan(0); - expect(fullText.toLowerCase()).toContain('cat'); - expect((await result.usage)?.totalTokens).toBeGreaterThan(0); - }); - - it.skip('should stream text with image input', async () => { - // NOTE(shaper): None of the models tested support image input. - const result = streamText({ - model, - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'Describe the image in detail.' }, - { - type: 'image', - image: fs - .readFileSync('./data/comic-cat.png') - .toString('base64'), - }, - ], - }, - ], - }); - - const chunks: string[] = []; - for await (const chunk of result.textStream) { - chunks.push(chunk); - } - - const fullText = chunks.join(''); - expect(fullText.toLowerCase()).toContain('cat'); - expect(chunks.length).toBeGreaterThan(0); - expect((await result.usage)?.totalTokens).toBeGreaterThan(0); - }); - }); - - describe.each(MODEL_VARIANTS.embedding)('Embedding Model: %s', modelId => { - const model = provider.textEmbeddingModel(modelId); - - it('should generate single embedding', async () => { - const result = await embed({ - model, - value: 'This is a test sentence for embedding.', - }); - - expect(Array.isArray(result.embedding)).toBe(true); - expect(result.embedding.length).toBeGreaterThan(0); - // TogetherAI API does not return usage data. - // expect(result.usage.tokens).toBeGreaterThan(0); - }); - - it('should generate multiple embeddings', async () => { - const result = await embedMany({ - model, - values: [ - 'First test sentence.', - 'Second test sentence.', - 'Third test sentence.', - ], - }); - - expect(Array.isArray(result.embeddings)).toBe(true); - expect(result.embeddings.length).toBe(3); - // TogetherAI API does not return usage data. - // expect(result.usage.tokens).toBeGreaterThan(0); - }); - }); - - describe.each(MODEL_VARIANTS.completion)('Completion Model: %s', modelId => { - const model = provider(modelId); - - it('should generate text', async () => { - const result = await generateText({ - model, - prompt: 'Complete this code: function fibonacci(n) {', - }); - - expect(result.text).toBeTruthy(); - expect(result.usage?.totalTokens).toBeGreaterThan(0); - }); - - it('should stream text', async () => { - const result = streamText({ - model, - prompt: 'Write a Python function that sorts a list:', - }); - - const chunks: string[] = []; - for await (const chunk of result.textStream) { - chunks.push(chunk); - } - - expect(chunks.length).toBeGreaterThan(0); - expect((await result.usage)?.totalTokens).toBeGreaterThan(0); - }); - }); -}); + togetherai as provider, + TogetherAIErrorData, +} from '@ai-sdk/togetherai'; +import { APICallError } from 'ai'; +import { createFeatureTestSuite } from './feature-test-suite'; + +createFeatureTestSuite({ + name: 'TogetherAI', + models: { + invalidModel: provider.chatModel('no-such-model'), + languageModels: [ + provider.chatModel('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo'), + provider.chatModel('mistralai/Mistral-7B-Instruct-v0.1'), + provider.chatModel('google/gemma-2b-it'), + provider.chatModel('meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo'), + provider.chatModel('mistralai/Mixtral-8x7B-Instruct-v0.1'), + provider.chatModel('Qwen/Qwen2.5-72B-Instruct-Turbo'), + provider.chatModel('databricks/dbrx-instruct'), + provider.completionModel('Qwen/Qwen2.5-Coder-32B-Instruct'), + ], + embeddingModels: [ + provider.textEmbeddingModel('togethercomputer/m2-bert-80M-8k-retrieval'), + provider.textEmbeddingModel('BAAI/bge-base-en-v1.5'), + ], + }, + timeout: 10000, + customAssertions: { + skipUsage: true, + errorValidator: (error: APICallError) => { + expect((error.data as TogetherAIErrorData).error.message).toMatch( + /^Unable to access model/, + ); + }, + }, +})(); diff --git a/examples/ai-core/src/e2e/xai.test.ts b/examples/ai-core/src/e2e/xai.test.ts index c5938e1ab8f8..6a563d367fc0 100644 --- a/examples/ai-core/src/e2e/xai.test.ts +++ b/examples/ai-core/src/e2e/xai.test.ts @@ -1,293 +1,30 @@ import 'dotenv/config'; -import { describe, it, expect, vi } from 'vitest'; -import { createXai, XaiErrorData } from '@ai-sdk/xai'; -import { z } from 'zod'; -import { generateText, generateObject, streamText, streamObject } from 'ai'; -import fs from 'fs'; +import { expect } from 'vitest'; +import { xai as provider, XaiErrorData } from '@ai-sdk/xai'; +import { createFeatureTestSuite } from './feature-test-suite'; import { APICallError } from '@ai-sdk/provider'; -const LONG_TEST_MILLIS = 10000; - -// Model variants to test against -const MODEL_VARIANTS = { - chat: ['grok-beta', 'grok-2-1212'], - vision: ['grok-vision-beta', 'grok-2-vision-1212'], -} as const; - -describe('xAI E2E Tests', () => { - vi.setConfig({ testTimeout: LONG_TEST_MILLIS }); - const provider = createXai(); - - describe.each(MODEL_VARIANTS.chat)('Chat Model: %s', modelId => { - const model = provider(modelId); - - it('should generate text', async () => { - const result = await generateText({ - model, - prompt: 'Write a haiku about programming.', - }); - - expect(result.text).toBeTruthy(); - expect(result.usage?.totalTokens).toBeGreaterThan(0); - }); - - it('should generate text with tool calls', async () => { - const result = await generateText({ - model, - prompt: 'What is 2+2? Use the calculator tool to compute this.', - tools: { - calculator: { - parameters: z.object({ - expression: z - .string() - .describe('The mathematical expression to evaluate'), - }), - execute: async ({ expression }) => eval(expression).toString(), - }, - }, - }); - - expect(result.toolCalls).toBeTruthy(); - expect(result.toolResults).toBeTruthy(); - expect(result.usage?.totalTokens).toBeGreaterThan(0); - }); - - it('should generate object', async () => { - const result = await generateObject({ - model, - schema: z.object({ - title: z.string(), - tags: z.array(z.string()), - }), - prompt: 'Generate metadata for a blog post about TypeScript.', - }); - - expect(result.object.title).toBeTruthy(); - expect(Array.isArray(result.object.tags)).toBe(true); - expect(result.usage?.totalTokens).toBeGreaterThan(0); - }); - - it('should stream text', async () => { - const result = streamText({ - model, - prompt: 'Count from 1 to 5 slowly.', - }); - - const chunks: string[] = []; - for await (const chunk of result.textStream) { - chunks.push(chunk); - } - - expect(chunks.length).toBeGreaterThan(0); - expect((await result.usage)?.totalTokens).toBeGreaterThan(0); - }); - - it('should stream text with tool calls', async () => { - const result = streamText({ - model, - prompt: 'Calculate 5+7 and 3*4 using the calculator tool.', - tools: { - calculator: { - parameters: z.object({ - expression: z.string(), - }), - execute: async ({ expression }) => eval(expression).toString(), - }, - }, - }); - - const parts = []; - for await (const part of result.fullStream) { - parts.push(part); - } - - expect(parts.some(part => part.type === 'tool-call')).toBe(true); - }); - - it('should stream object', async () => { - const result = streamObject({ - model, - schema: z.object({ - characters: z.array( - z.object({ - name: z.string(), - class: z - .string() - .describe('Character class, e.g. warrior, mage, or thief.'), - description: z.string(), - }), - ), - }), - prompt: 'Generate 3 RPG character descriptions.', - }); - - const parts = []; - for await (const part of result.partialObjectStream) { - parts.push(part); - } - - expect(parts.length).toBeGreaterThan(0); - }); - - it('should throw error on generate text attempt with invalid model ID', async () => { - const invalidModel = provider('no-such-model'); - - try { - await generateText({ - model: invalidModel, - prompt: 'This should fail', - }); - // If we reach here, the test should fail - expect(true).toBe(false); // Force test to fail if no error is thrown - } catch (error) { - expect(error).toBeInstanceOf(APICallError); - expect(((error as APICallError).data as XaiErrorData).code).toBe( - 'Some requested entity was not found', - ); - expect(((error as APICallError).data as XaiErrorData).error).toContain( - 'does not exist or your team', - ); - } - }); - - it('should throw error on stream text attempt with invalid model ID', async () => { - const invalidModel = provider('no-such-model'); - - try { - const result = streamText({ - model: invalidModel, - prompt: 'This should fail', - }); - - // Try to consume the stream to trigger the error - for await (const _ of result.textStream) { - // Do nothing with the chunks - } - - // If we reach here, the test should fail - expect(true).toBe(false); // Force test to fail if no error is thrown - } catch (error) { - expect(error).toBeInstanceOf(APICallError); - expect(((error as APICallError).data as XaiErrorData).code).toBe( - 'Some requested entity was not found', - ); - expect(((error as APICallError).data as XaiErrorData).error).toContain( - 'does not exist or your team', - ); - } - }); - }); - - describe.each(MODEL_VARIANTS.vision)('Vision Model: %s', modelId => { - const model = provider(modelId); - - it('should generate text with image URL input', async () => { - const result = await generateText({ - model, - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'Describe the image in detail.' }, - { - type: 'image', - image: - 'https://github.com/vercel/ai/blob/main/examples/ai-core/data/comic-cat.png?raw=true', - }, - ], - }, - ], - }); - - expect(result.text).toBeTruthy(); - expect(result.text.toLowerCase()).toContain('cat'); - expect(result.usage?.totalTokens).toBeGreaterThan(0); - }); - - it('should generate text with image input', async () => { - const result = await generateText({ - model, - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'Describe the image in detail.' }, - { - type: 'image', - image: fs.readFileSync('./data/comic-cat.png'), - }, - ], - }, - ], - }); - - expect(result.text.toLowerCase()).toContain('cat'); - expect(result.text).toBeTruthy(); - expect(result.usage?.totalTokens).toBeGreaterThan(0); - }); - - it('should stream text with image URL input', async () => { - const result = streamText({ - model, - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'Describe the image in detail.' }, - { - type: 'image', - image: - 'https://github.com/vercel/ai/blob/main/examples/ai-core/data/comic-cat.png?raw=true', - }, - ], - }, - ], - }); - - const chunks: string[] = []; - for await (const chunk of result.textStream) { - chunks.push(chunk); - } - - const fullText = chunks.join(''); - expect(chunks.length).toBeGreaterThan(0); - expect(fullText.toLowerCase()).toContain('cat'); - expect((await result.usage)?.totalTokens).toBeGreaterThan(0); - }); - - it('should stream text with image input', async () => { - const result = streamText({ - model, - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'Describe the image in detail.' }, - { - type: 'image', - image: fs.readFileSync('./data/comic-cat.png'), - }, - ], - }, - ], - }); - - const chunks: string[] = []; - for await (const chunk of result.textStream) { - chunks.push(chunk); - } - - const fullText = chunks.join(''); - expect(fullText.toLowerCase()).toContain('cat'); - expect(chunks.length).toBeGreaterThan(0); - expect((await result.usage)?.totalTokens).toBeGreaterThan(0); - }); - }); - - // Note: xAI doesn't support embedding models, so we don't include those tests - // The provider.textEmbeddingModel throws NoSuchModelError - - it('should throw error for embedding model', () => { - expect(() => provider.textEmbeddingModel('grok-1')).toThrow(); - }); -}); +createFeatureTestSuite({ + name: 'xAI', + models: { + invalidModel: provider.chat('no-such-model'), + languageModels: [ + provider.chat('grok-beta'), + provider.chat('grok-2-1212'), + provider.chat('grok-vision-beta'), + provider.chat('grok-2-vision-1212'), + provider.languageModel('grok-beta'), + provider.languageModel('grok-2-1212'), + provider.languageModel('grok-vision-beta'), + provider.languageModel('grok-2-vision-1212'), + ], + }, + timeout: 30000, + customAssertions: { + errorValidator: (error: APICallError) => { + expect((error.data as XaiErrorData).code).toBe( + 'Some requested entity was not found', + ); + }, + }, +})(); diff --git a/examples/ai-core/src/generate-text/openai-compatible-litellm-anthropic-cache-control.ts b/examples/ai-core/src/generate-text/openai-compatible-litellm-anthropic-cache-control.ts new file mode 100644 index 000000000000..0b1c4ad72259 --- /dev/null +++ b/examples/ai-core/src/generate-text/openai-compatible-litellm-anthropic-cache-control.ts @@ -0,0 +1,48 @@ +import 'dotenv/config'; +import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; +import { generateText } from 'ai'; + +async function main() { + // See ../../../litellm/README.md for instructions on how to run a LiteLLM + // proxy locally configured to interface with Anthropic. + const litellmAnthropic = createOpenAICompatible({ + baseURL: 'http://0.0.0.0:4000', + name: 'litellm-anthropic', + }); + const model = litellmAnthropic.chatModel('claude-3-5-sonnet-20240620'); + const result = await generateText({ + model, + messages: [ + { + role: 'system', + // https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#cache-limitations + // The cache content must be of a meaningful size (e.g. 1024 tokens, see + // above for detail) and will only be cached for a moderate period of + // time e.g. 5 minutes. + content: + "You are an AI assistant tasked with analyzing this story: The ancient clocktower stood sentinel over Millbrook Valley, its weathered copper face gleaming dully in the late afternoon sun. Sarah Chen adjusted her backpack and gazed up at the structure that had fascinated her since childhood. At thirteen stories tall, it had been the highest building in town for over a century, though now it was dwarfed by the glass and steel office buildings that had sprung up around it.\n\nThe door creaked as she pushed it open, sending echoes through the dusty entrance hall. Her footsteps on the marble floor seemed unnaturally loud in the empty space. The restoration project wouldn't officially begin for another week, but as the lead architectural historian, she had permission to start her preliminary survey early.\n\nThe building had been abandoned for twenty years, ever since the great earthquake of 2003 had damaged the clock mechanism. The city had finally approved funding to restore it to working order, but Sarah suspected there was more to the clocktower than anyone realized. Her research had uncovered hints that its architect, Theodore Hammond, had built secret rooms and passages throughout the structure.\n\nShe clicked on her flashlight and began climbing the main staircase. The emergency lights still worked on the lower floors, but she'd need the extra illumination higher up. The air grew mustier as she ascended, thick with decades of undisturbed dust. Her hand traced along the ornate brass railings, feeling the intricate patterns worked into the metal.\n\nOn the seventh floor, something caught her eye - a slight irregularity in the wall paneling that didn't match the blueprints she'd memorized. Sarah ran her fingers along the edge of the wood, pressing gently until she felt a click. A hidden door swung silently open, revealing a narrow passage.\n\nHer heart pounding with excitement, she squeezed through the opening. The passage led to a small octagonal room she estimated to be directly behind the clock face. Gears and mechanisms filled the space, all connected to a central shaft that rose up through the ceiling. But it was the walls that drew her attention - they were covered in elaborate astronomical charts and mathematical formulas.\n\n\"It's not just a clock,\" she whispered to herself. \"It's an orrery - a mechanical model of the solar system!\"\n\nThe complexity of the mechanism was far beyond what should have existed in the 1890s when the tower was built. Some of the mathematical notations seemed to describe orbital mechanics that wouldn't be discovered for decades after Hammond's death. Sarah's mind raced as she documented everything with her camera.\n\nA loud grinding sound from above made her jump. The central shaft began to rotate slowly, setting the gears in motion. She watched in amazement as the astronomical models came to life, planets and moons tracking across their metal orbits. But something was wrong - the movements didn't match any normal celestial patterns she knew.\n\nThe room grew noticeably colder. Sarah's breath frosted in the air as the mechanism picked up speed. The walls seemed to shimmer, becoming translucent. Through them, she could see not the expected view of downtown Millbrook, but a star-filled void that made her dizzy to look at.\n\nShe scrambled back toward the hidden door, but it had vanished. The room was spinning now, or maybe reality itself was spinning around it. Sarah grabbed onto a support beam as her stomach lurched. The stars beyond the walls wheeled and danced in impossible patterns.\n\nJust when she thought she couldn't take anymore, everything stopped. The mechanism ground to a halt. The walls solidified. The temperature returned to normal. Sarah's hands shook as she checked her phone - no signal, but the time display showed she had lost three hours.\n\nThe hidden door was back, and she practically fell through it in her haste to exit. She ran down all thirteen flights of stairs without stopping, bursting out into the street. The sun was setting now, painting the sky in deep purples and reds. Everything looked normal, but she couldn't shake the feeling that something was subtly different.\n\nBack in her office, Sarah pored over the photos she'd taken. The astronomical charts seemed to change slightly each time she looked at them, the mathematical formulas rearranging themselves when viewed from different angles. None of her colleagues believed her story about what had happened in the clocktower, but she knew what she had experienced was real.\n\nOver the next few weeks, she threw herself into research, trying to learn everything she could about Theodore Hammond. His personal papers revealed an obsession with time and dimensional theory far ahead of his era. There were references to experiments with \"temporal architecture\" and \"geometric manipulation of spacetime.\"\n\nThe restoration project continued, but Sarah made sure the hidden room remained undiscovered. Whatever Hammond had built, whatever portal or mechanism he had created, she wasn't sure the world was ready for it. But late at night, she would return to the clocktower and study the mysterious device, trying to understand its secrets.\n\nSometimes, when the stars aligned just right, she could hear the gears beginning to turn again, and feel reality starting to bend around her. And sometimes, in her dreams, she saw Theodore Hammond himself, standing at a drawing board, sketching plans for a machine that could fold space and time like paper - a machine that looked exactly like the one hidden in the heart of his clocktower.\n\nThe mystery of what Hammond had truly built, and why, consumed her thoughts. But with each new piece of evidence she uncovered, Sarah became more certain of one thing - the clocktower was more than just a timepiece. It was a key to understanding the very nature of time itself, and its secrets were only beginning to be revealed.\n", + experimental_providerMetadata: { + openaiCompatible: { + cache_control: { + type: 'ephemeral', + }, + }, + }, + }, + { + role: 'user', + content: 'What are the key narrative points made in this story?', + }, + ], + }); + + console.log(result.text); + console.log(); + // Note the cache-specific token usage information is not yet available in the + // AI SDK. We plan to make it available in the response through the + // `experimental_providerMetadata` field in the future. + console.log('Token usage:', result.usage); + console.log('Finish reason:', result.finishReason); +} + +main().catch(console.error); diff --git a/examples/ai-core/src/stream-text/openai-compatible-litellm-anthropic-cache-control.ts b/examples/ai-core/src/stream-text/openai-compatible-litellm-anthropic-cache-control.ts new file mode 100644 index 000000000000..3e5d9da4a987 --- /dev/null +++ b/examples/ai-core/src/stream-text/openai-compatible-litellm-anthropic-cache-control.ts @@ -0,0 +1,51 @@ +import 'dotenv/config'; +import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; +import { streamText } from 'ai'; + +async function main() { + // See ../../../litellm/README.md for instructions on how to run a LiteLLM + // proxy locally configured to interface with Anthropic. + const litellmAnthropic = createOpenAICompatible({ + baseURL: 'http://0.0.0.0:4000', + name: 'litellm-anthropic', + }); + const model = litellmAnthropic.chatModel('claude-3-5-sonnet-20240620'); + const result = streamText({ + model, + messages: [ + { + role: 'system', + // https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#cache-limitations + // The cache content must be of a meaningful size (e.g. 1024 tokens, see + // above for detail) and will only be cached for a moderate period of + // time e.g. 5 minutes. + content: + "You are an AI assistant tasked with analyzing this story: The ancient clocktower stood sentinel over Millbrook Valley, its weathered copper face gleaming dully in the late afternoon sun. Sarah Chen adjusted her backpack and gazed up at the structure that had fascinated her since childhood. At thirteen stories tall, it had been the highest building in town for over a century, though now it was dwarfed by the glass and steel office buildings that had sprung up around it.\n\nThe door creaked as she pushed it open, sending echoes through the dusty entrance hall. Her footsteps on the marble floor seemed unnaturally loud in the empty space. The restoration project wouldn't officially begin for another week, but as the lead architectural historian, she had permission to start her preliminary survey early.\n\nThe building had been abandoned for twenty years, ever since the great earthquake of 2003 had damaged the clock mechanism. The city had finally approved funding to restore it to working order, but Sarah suspected there was more to the clocktower than anyone realized. Her research had uncovered hints that its architect, Theodore Hammond, had built secret rooms and passages throughout the structure.\n\nShe clicked on her flashlight and began climbing the main staircase. The emergency lights still worked on the lower floors, but she'd need the extra illumination higher up. The air grew mustier as she ascended, thick with decades of undisturbed dust. Her hand traced along the ornate brass railings, feeling the intricate patterns worked into the metal.\n\nOn the seventh floor, something caught her eye - a slight irregularity in the wall paneling that didn't match the blueprints she'd memorized. Sarah ran her fingers along the edge of the wood, pressing gently until she felt a click. A hidden door swung silently open, revealing a narrow passage.\n\nHer heart pounding with excitement, she squeezed through the opening. The passage led to a small octagonal room she estimated to be directly behind the clock face. Gears and mechanisms filled the space, all connected to a central shaft that rose up through the ceiling. But it was the walls that drew her attention - they were covered in elaborate astronomical charts and mathematical formulas.\n\n\"It's not just a clock,\" she whispered to herself. \"It's an orrery - a mechanical model of the solar system!\"\n\nThe complexity of the mechanism was far beyond what should have existed in the 1890s when the tower was built. Some of the mathematical notations seemed to describe orbital mechanics that wouldn't be discovered for decades after Hammond's death. Sarah's mind raced as she documented everything with her camera.\n\nA loud grinding sound from above made her jump. The central shaft began to rotate slowly, setting the gears in motion. She watched in amazement as the astronomical models came to life, planets and moons tracking across their metal orbits. But something was wrong - the movements didn't match any normal celestial patterns she knew.\n\nThe room grew noticeably colder. Sarah's breath frosted in the air as the mechanism picked up speed. The walls seemed to shimmer, becoming translucent. Through them, she could see not the expected view of downtown Millbrook, but a star-filled void that made her dizzy to look at.\n\nShe scrambled back toward the hidden door, but it had vanished. The room was spinning now, or maybe reality itself was spinning around it. Sarah grabbed onto a support beam as her stomach lurched. The stars beyond the walls wheeled and danced in impossible patterns.\n\nJust when she thought she couldn't take anymore, everything stopped. The mechanism ground to a halt. The walls solidified. The temperature returned to normal. Sarah's hands shook as she checked her phone - no signal, but the time display showed she had lost three hours.\n\nThe hidden door was back, and she practically fell through it in her haste to exit. She ran down all thirteen flights of stairs without stopping, bursting out into the street. The sun was setting now, painting the sky in deep purples and reds. Everything looked normal, but she couldn't shake the feeling that something was subtly different.\n\nBack in her office, Sarah pored over the photos she'd taken. The astronomical charts seemed to change slightly each time she looked at them, the mathematical formulas rearranging themselves when viewed from different angles. None of her colleagues believed her story about what had happened in the clocktower, but she knew what she had experienced was real.\n\nOver the next few weeks, she threw herself into research, trying to learn everything she could about Theodore Hammond. His personal papers revealed an obsession with time and dimensional theory far ahead of his era. There were references to experiments with \"temporal architecture\" and \"geometric manipulation of spacetime.\"\n\nThe restoration project continued, but Sarah made sure the hidden room remained undiscovered. Whatever Hammond had built, whatever portal or mechanism he had created, she wasn't sure the world was ready for it. But late at night, she would return to the clocktower and study the mysterious device, trying to understand its secrets.\n\nSometimes, when the stars aligned just right, she could hear the gears beginning to turn again, and feel reality starting to bend around her. And sometimes, in her dreams, she saw Theodore Hammond himself, standing at a drawing board, sketching plans for a machine that could fold space and time like paper - a machine that looked exactly like the one hidden in the heart of his clocktower.\n\nThe mystery of what Hammond had truly built, and why, consumed her thoughts. But with each new piece of evidence she uncovered, Sarah became more certain of one thing - the clocktower was more than just a timepiece. It was a key to understanding the very nature of time itself, and its secrets were only beginning to be revealed.\n", + experimental_providerMetadata: { + openaiCompatible: { + cache_control: { + type: 'ephemeral', + }, + }, + }, + }, + { + role: 'user', + content: 'What are the key narrative points made in this story?', + }, + ], + }); + + for await (const textPart of result.textStream) { + process.stdout.write(textPart); + } + + console.log(); + // Note the cache-specific token usage information is not yet available in the + // AI SDK. We plan to make it available in the response through the + // `experimental_providerMetadata` field in the future. + console.log('Token usage:', result.usage); + console.log('Finish reason:', result.finishReason); +} + +main().catch(console.error); diff --git a/packages/deepinfra/CHANGELOG.md b/packages/deepinfra/CHANGELOG.md index 775fd3542319..b3636c1227bd 100644 --- a/packages/deepinfra/CHANGELOG.md +++ b/packages/deepinfra/CHANGELOG.md @@ -1,5 +1,19 @@ # @ai-sdk/deepinfra +## 0.0.4 + +### Patch Changes + +- Updated dependencies [6564812] + - @ai-sdk/openai-compatible@0.0.13 + +## 0.0.3 + +### Patch Changes + +- Updated dependencies [70003b8] + - @ai-sdk/openai-compatible@0.0.12 + ## 0.0.2 ### Patch Changes diff --git a/packages/deepinfra/package.json b/packages/deepinfra/package.json index ce385137cb9a..34d11246e735 100644 --- a/packages/deepinfra/package.json +++ b/packages/deepinfra/package.json @@ -1,6 +1,6 @@ { "name": "@ai-sdk/deepinfra", - "version": "0.0.2", + "version": "0.0.4", "license": "Apache-2.0", "sideEffects": false, "main": "./dist/index.js", @@ -30,7 +30,7 @@ } }, "dependencies": { - "@ai-sdk/openai-compatible": "0.0.11", + "@ai-sdk/openai-compatible": "0.0.13", "@ai-sdk/provider": "1.0.3", "@ai-sdk/provider-utils": "2.0.5" }, diff --git a/packages/fireworks/CHANGELOG.md b/packages/fireworks/CHANGELOG.md index 1a2117eaf7d9..f23a5613fe50 100644 --- a/packages/fireworks/CHANGELOG.md +++ b/packages/fireworks/CHANGELOG.md @@ -1,5 +1,19 @@ # @ai-sdk/fireworks +## 0.0.7 + +### Patch Changes + +- Updated dependencies [6564812] + - @ai-sdk/openai-compatible@0.0.13 + +## 0.0.6 + +### Patch Changes + +- Updated dependencies [70003b8] + - @ai-sdk/openai-compatible@0.0.12 + ## 0.0.5 ### Patch Changes diff --git a/packages/fireworks/package.json b/packages/fireworks/package.json index 18fa9bb7bf7d..ee0bb395af54 100644 --- a/packages/fireworks/package.json +++ b/packages/fireworks/package.json @@ -1,6 +1,6 @@ { "name": "@ai-sdk/fireworks", - "version": "0.0.5", + "version": "0.0.7", "license": "Apache-2.0", "sideEffects": false, "main": "./dist/index.js", @@ -30,7 +30,7 @@ } }, "dependencies": { - "@ai-sdk/openai-compatible": "0.0.11", + "@ai-sdk/openai-compatible": "0.0.13", "@ai-sdk/provider": "1.0.3", "@ai-sdk/provider-utils": "2.0.5" }, diff --git a/packages/openai-compatible/CHANGELOG.md b/packages/openai-compatible/CHANGELOG.md index 9b5e7da3c8f2..2d8a661a1e04 100644 --- a/packages/openai-compatible/CHANGELOG.md +++ b/packages/openai-compatible/CHANGELOG.md @@ -1,5 +1,17 @@ # @ai-sdk/openai-compatible +## 0.0.13 + +### Patch Changes + +- 6564812: feat (provider/openai-compatible): Add'l exports for customization. + +## 0.0.12 + +### Patch Changes + +- 70003b8: feat (provider/openai-compatible): Allow extending messages via metadata. + ## 0.0.11 ### Patch Changes diff --git a/packages/openai-compatible/package.json b/packages/openai-compatible/package.json index 94c3546e15c5..8613e9f9efb1 100644 --- a/packages/openai-compatible/package.json +++ b/packages/openai-compatible/package.json @@ -1,6 +1,6 @@ { "name": "@ai-sdk/openai-compatible", - "version": "0.0.11", + "version": "0.0.13", "license": "Apache-2.0", "sideEffects": false, "main": "./dist/index.js", @@ -8,12 +8,13 @@ "types": "./dist/index.d.ts", "files": [ "dist/**/*", + "internal/dist/**/*", "CHANGELOG.md" ], "scripts": { "build": "tsup", "build:watch": "tsup --watch", - "clean": "rm -rf dist", + "clean": "rm -rf dist && rm -rf internal/dist", "lint": "eslint \"./**/*.ts*\"", "type-check": "tsc --noEmit", "prettier-check": "prettier --check \"./**/*.ts*\"", @@ -27,6 +28,12 @@ "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" + }, + "./internal": { + "types": "./internal/dist/index.d.ts", + "import": "./internal/dist/index.mjs", + "module": "./internal/dist/index.mjs", + "require": "./internal/dist/index.js" } }, "dependencies": { diff --git a/packages/openai-compatible/src/convert-to-openai-compatible-chat-messages.test.ts b/packages/openai-compatible/src/convert-to-openai-compatible-chat-messages.test.ts index 9e2fdd961ef9..b5956b46f5df 100644 --- a/packages/openai-compatible/src/convert-to-openai-compatible-chat-messages.test.ts +++ b/packages/openai-compatible/src/convert-to-openai-compatible-chat-messages.test.ts @@ -119,3 +119,493 @@ describe('tool calls', () => { ]); }); }); + +describe('provider-specific metadata merging', () => { + it('should merge system message metadata', async () => { + const result = convertToOpenAICompatibleChatMessages([ + { + role: 'system', + content: 'You are a helpful assistant.', + providerMetadata: { + openaiCompatible: { + cacheControl: { type: 'ephemeral' }, + }, + }, + }, + ]); + + expect(result).toEqual([ + { + role: 'system', + content: 'You are a helpful assistant.', + cacheControl: { type: 'ephemeral' }, + }, + ]); + }); + + it('should merge user message content metadata', async () => { + const result = convertToOpenAICompatibleChatMessages([ + { + role: 'user', + content: [ + { + type: 'text', + text: 'Hello', + providerMetadata: { + openaiCompatible: { + cacheControl: { type: 'ephemeral' }, + }, + }, + }, + ], + }, + ]); + + expect(result).toEqual([ + { + role: 'user', + content: 'Hello', + cacheControl: { type: 'ephemeral' }, + }, + ]); + }); + + it('should prioritize content-level metadata when merging', async () => { + const result = convertToOpenAICompatibleChatMessages([ + { + role: 'user', + providerMetadata: { + openaiCompatible: { + messageLevel: true, + }, + }, + content: [ + { + type: 'text', + text: 'Hello', + providerMetadata: { + openaiCompatible: { + contentLevel: true, + }, + }, + }, + ], + }, + ]); + + expect(result).toEqual([ + { + role: 'user', + content: 'Hello', + contentLevel: true, + }, + ]); + }); + + it('should handle tool calls with metadata', async () => { + const result = convertToOpenAICompatibleChatMessages([ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'call1', + toolName: 'calculator', + args: { x: 1, y: 2 }, + providerMetadata: { + openaiCompatible: { + cacheControl: { type: 'ephemeral' }, + }, + }, + }, + ], + }, + ]); + + expect(result).toEqual([ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call1', + type: 'function', + function: { + name: 'calculator', + arguments: JSON.stringify({ x: 1, y: 2 }), + }, + cacheControl: { type: 'ephemeral' }, + }, + ], + }, + ]); + }); + + it('should handle image content with metadata', async () => { + const imageUrl = new URL('https://example.com/image.jpg'); + const result = convertToOpenAICompatibleChatMessages([ + { + role: 'user', + content: [ + { + type: 'image', + image: imageUrl, + mimeType: 'image/jpeg', + providerMetadata: { + openaiCompatible: { + cacheControl: { type: 'ephemeral' }, + }, + }, + }, + ], + }, + ]); + + expect(result).toEqual([ + { + role: 'user', + content: [ + { + type: 'image_url', + image_url: { url: imageUrl.toString() }, + cacheControl: { type: 'ephemeral' }, + }, + ], + }, + ]); + }); + + it('should omit non-openaiCompatible metadata', async () => { + const result = convertToOpenAICompatibleChatMessages([ + { + role: 'system', + content: 'Hello', + providerMetadata: { + someOtherProvider: { + shouldBeIgnored: true, + }, + }, + }, + ]); + + expect(result).toEqual([ + { + role: 'system', + content: 'Hello', + }, + ]); + }); + + it('should handle a user message with multiple content parts (text + image)', () => { + const result = convertToOpenAICompatibleChatMessages([ + { + role: 'user', + content: [ + { + type: 'text', + text: 'Hello from part 1', + providerMetadata: { + openaiCompatible: { sentiment: 'positive' }, + leftoverKey: { foo: 'some leftover data' }, + }, + }, + { + type: 'image', + image: new Uint8Array([0, 1, 2, 3]), + mimeType: 'image/png', + providerMetadata: { + openaiCompatible: { alt_text: 'A sample image' }, + }, + }, + ], + providerMetadata: { + openaiCompatible: { priority: 'high' }, + }, + }, + ]); + + expect(result).toEqual([ + { + role: 'user', + priority: 'high', // hoisted from message-level providerMetadata + content: [ + { + type: 'text', + text: 'Hello from part 1', + sentiment: 'positive', // hoisted from part-level openaiCompatible + }, + { + type: 'image_url', + image_url: { + url: 'data:image/png;base64,AAECAw==', + }, + alt_text: 'A sample image', + }, + ], + }, + ]); + }); + + it('should handle a user message with multiple text parts (flattening disabled)', () => { + const result = convertToOpenAICompatibleChatMessages([ + { + role: 'user', + content: [ + { type: 'text', text: 'Part 1' }, + { type: 'text', text: 'Part 2' }, + ], + }, + ]); + + // Because there are multiple text parts, the converter won't flatten them + expect(result).toEqual([ + { + role: 'user', + content: [ + { type: 'text', text: 'Part 1' }, + { type: 'text', text: 'Part 2' }, + ], + }, + ]); + }); + + it('should handle an assistant message with text plus multiple tool calls', () => { + const result = convertToOpenAICompatibleChatMessages([ + { + role: 'assistant', + content: [ + { type: 'text', text: 'Checking that now...' }, + { + type: 'tool-call', + toolCallId: 'call1', + toolName: 'searchTool', + args: { query: 'Weather' }, + providerMetadata: { + openaiCompatible: { function_call_reason: 'user request' }, + }, + }, + { type: 'text', text: 'Almost there...' }, + { + type: 'tool-call', + toolCallId: 'call2', + toolName: 'mapsTool', + args: { location: 'Paris' }, + }, + ], + }, + ]); + + expect(result).toEqual([ + { + role: 'assistant', + content: 'Checking that now...Almost there...', + tool_calls: [ + { + id: 'call1', + type: 'function', + function: { + name: 'searchTool', + arguments: JSON.stringify({ query: 'Weather' }), + }, + function_call_reason: 'user request', + }, + { + id: 'call2', + type: 'function', + function: { + name: 'mapsTool', + arguments: JSON.stringify({ location: 'Paris' }), + }, + }, + ], + }, + ]); + }); + + it('should handle a single tool role message with multiple tool-result parts', () => { + const result = convertToOpenAICompatibleChatMessages([ + { + role: 'tool', + providerMetadata: { + // this just gets omitted as we prioritize content-level metadata + openaiCompatible: { responseTier: 'detailed' }, + }, + content: [ + { + type: 'tool-result', + toolCallId: 'call123', + toolName: 'calculator', + result: { stepOne: 'data chunk 1' }, + }, + { + type: 'tool-result', + toolCallId: 'call123', + toolName: 'calculator', + providerMetadata: { + openaiCompatible: { partial: true }, + }, + result: { stepTwo: 'data chunk 2' }, + }, + ], + }, + ]); + + expect(result).toEqual([ + { + role: 'tool', + tool_call_id: 'call123', + content: JSON.stringify({ stepOne: 'data chunk 1' }), + }, + { + role: 'tool', + tool_call_id: 'call123', + content: JSON.stringify({ stepTwo: 'data chunk 2' }), + partial: true, + }, + ]); + }); + + it('should handle multiple content parts with multiple metadata layers', () => { + const result = convertToOpenAICompatibleChatMessages([ + { + role: 'user', + providerMetadata: { + openaiCompatible: { messageLevel: 'global-metadata' }, + leftoverForMessage: { x: 123 }, + }, + content: [ + { + type: 'text', + text: 'Part A', + providerMetadata: { + openaiCompatible: { textPartLevel: 'localized' }, + leftoverForText: { info: 'text leftover' }, + }, + }, + { + type: 'image', + image: new Uint8Array([9, 8, 7, 6]), + mimeType: 'image/png', + providerMetadata: { + openaiCompatible: { imagePartLevel: 'image-data' }, + }, + }, + ], + }, + ]); + + expect(result).toEqual([ + { + role: 'user', + messageLevel: 'global-metadata', + content: [ + { + type: 'text', + text: 'Part A', + textPartLevel: 'localized', + }, + { + type: 'image_url', + image_url: { + url: 'data:image/png;base64,CQgHBg==', + }, + imagePartLevel: 'image-data', + }, + ], + }, + ]); + }); + + it('should handle different tool metadata vs. message-level metadata', () => { + const result = convertToOpenAICompatibleChatMessages([ + { + role: 'assistant', + providerMetadata: { + openaiCompatible: { globalPriority: 'high' }, + }, + content: [ + { type: 'text', text: 'Initiating tool calls...' }, + { + type: 'tool-call', + toolCallId: 'callXYZ', + toolName: 'awesomeTool', + args: { param: 'someValue' }, + providerMetadata: { + openaiCompatible: { + toolPriority: 'critical', + }, + }, + }, + ], + }, + ]); + + expect(result).toEqual([ + { + role: 'assistant', + globalPriority: 'high', + content: 'Initiating tool calls...', + tool_calls: [ + { + id: 'callXYZ', + type: 'function', + function: { + name: 'awesomeTool', + arguments: JSON.stringify({ param: 'someValue' }), + }, + toolPriority: 'critical', + }, + ], + }, + ]); + }); + + it('should handle metadata collisions and overwrites in tool calls', () => { + const result = convertToOpenAICompatibleChatMessages([ + { + role: 'assistant', + providerMetadata: { + openaiCompatible: { + cacheControl: { type: 'default' }, + sharedKey: 'assistantLevel', + }, + }, + content: [ + { + type: 'tool-call', + toolCallId: 'collisionToolCall', + toolName: 'collider', + args: { num: 42 }, + providerMetadata: { + openaiCompatible: { + cacheControl: { type: 'ephemeral' }, // overwrites top-level + sharedKey: 'toolLevel', + }, + }, + }, + ], + }, + ]); + + expect(result).toEqual([ + { + role: 'assistant', + cacheControl: { type: 'default' }, + sharedKey: 'assistantLevel', + content: '', + tool_calls: [ + { + id: 'collisionToolCall', + type: 'function', + function: { + name: 'collider', + arguments: JSON.stringify({ num: 42 }), + }, + cacheControl: { type: 'ephemeral' }, + sharedKey: 'toolLevel', + }, + ], + }, + ]); + }); +}); diff --git a/packages/openai-compatible/src/convert-to-openai-compatible-chat-messages.ts b/packages/openai-compatible/src/convert-to-openai-compatible-chat-messages.ts index 2c9441d26a67..95a262648868 100644 --- a/packages/openai-compatible/src/convert-to-openai-compatible-chat-messages.ts +++ b/packages/openai-compatible/src/convert-to-openai-compatible-chat-messages.ts @@ -1,34 +1,46 @@ import { LanguageModelV1Prompt, + LanguageModelV1ProviderMetadata, UnsupportedFunctionalityError, } from '@ai-sdk/provider'; import { convertUint8ArrayToBase64 } from '@ai-sdk/provider-utils'; import { OpenAICompatibleChatPrompt } from './openai-compatible-api-types'; +function getOpenAIMetadata(message: { + providerMetadata?: LanguageModelV1ProviderMetadata; +}) { + return message?.providerMetadata?.openaiCompatible ?? {}; +} + export function convertToOpenAICompatibleChatMessages( prompt: LanguageModelV1Prompt, ): OpenAICompatibleChatPrompt { const messages: OpenAICompatibleChatPrompt = []; - - for (const { role, content } of prompt) { + for (const { role, content, ...message } of prompt) { + const metadata = getOpenAIMetadata({ ...message }); switch (role) { case 'system': { - messages.push({ role: 'system', content }); + messages.push({ role: 'system', content, ...metadata }); break; } case 'user': { if (content.length === 1 && content[0].type === 'text') { - messages.push({ role: 'user', content: content[0].text }); + messages.push({ + role: 'user', + content: content[0].text, + ...getOpenAIMetadata(content[0]), + }); break; } messages.push({ role: 'user', content: content.map(part => { + const partMetadata = getOpenAIMetadata(part); switch (part.type) { case 'text': { - return { type: 'text', text: part.text }; + return { type: 'text', text: part.text, ...partMetadata }; } case 'image': { return { @@ -41,6 +53,7 @@ export function convertToOpenAICompatibleChatMessages( part.mimeType ?? 'image/jpeg' };base64,${convertUint8ArrayToBase64(part.image)}`, }, + ...partMetadata, }; } case 'file': { @@ -50,6 +63,7 @@ export function convertToOpenAICompatibleChatMessages( } } }), + ...metadata, }); break; @@ -64,6 +78,7 @@ export function convertToOpenAICompatibleChatMessages( }> = []; for (const part of content) { + const partMetadata = getOpenAIMetadata(part); switch (part.type) { case 'text': { text += part.text; @@ -77,6 +92,7 @@ export function convertToOpenAICompatibleChatMessages( name: part.toolName, arguments: JSON.stringify(part.args), }, + ...partMetadata, }); break; } @@ -91,6 +107,7 @@ export function convertToOpenAICompatibleChatMessages( role: 'assistant', content: text, tool_calls: toolCalls.length > 0 ? toolCalls : undefined, + ...metadata, }); break; @@ -98,10 +115,12 @@ export function convertToOpenAICompatibleChatMessages( case 'tool': { for (const toolResponse of content) { + const toolResponseMetadata = getOpenAIMetadata(toolResponse); messages.push({ role: 'tool', tool_call_id: toolResponse.toolCallId, content: JSON.stringify(toolResponse.result), + ...toolResponseMetadata, }); } break; diff --git a/packages/openai-compatible/src/internal/index.ts b/packages/openai-compatible/src/internal/index.ts new file mode 100644 index 000000000000..9b6e5c20a618 --- /dev/null +++ b/packages/openai-compatible/src/internal/index.ts @@ -0,0 +1,4 @@ +export { convertToOpenAICompatibleChatMessages } from '../convert-to-openai-compatible-chat-messages'; +export { mapOpenAICompatibleFinishReason } from '../map-openai-compatible-finish-reason'; +export { getResponseMetadata } from '../get-response-metadata'; +export type { OpenAICompatibleChatConfig } from '../openai-compatible-chat-language-model'; diff --git a/packages/openai-compatible/src/openai-compatible-api-types.ts b/packages/openai-compatible/src/openai-compatible-api-types.ts index 4c0ed3b7201c..f9914eb7368a 100644 --- a/packages/openai-compatible/src/openai-compatible-api-types.ts +++ b/packages/openai-compatible/src/openai-compatible-api-types.ts @@ -1,3 +1,5 @@ +import { JSONValue } from '@ai-sdk/provider'; + export type OpenAICompatibleChatPrompt = Array; export type OpenAICompatibleMessage = @@ -6,12 +8,20 @@ export type OpenAICompatibleMessage = | OpenAICompatibleAssistantMessage | OpenAICompatibleToolMessage; -export interface OpenAICompatibleSystemMessage { +// Allow for arbitrary additional properties for general purpose +// provider-metadata-specific extensibility. +type JsonRecord = Record< + string, + JSONValue | JSONValue[] | T | T[] | undefined +>; + +export interface OpenAICompatibleSystemMessage extends JsonRecord { role: 'system'; content: string; } -export interface OpenAICompatibleUserMessage { +export interface OpenAICompatibleUserMessage + extends JsonRecord { role: 'user'; content: string | Array; } @@ -20,23 +30,24 @@ export type OpenAICompatibleContentPart = | OpenAICompatibleContentPartText | OpenAICompatibleContentPartImage; -export interface OpenAICompatibleContentPartImage { +export interface OpenAICompatibleContentPartImage extends JsonRecord { type: 'image_url'; image_url: { url: string }; } -export interface OpenAICompatibleContentPartText { +export interface OpenAICompatibleContentPartText extends JsonRecord { type: 'text'; text: string; } -export interface OpenAICompatibleAssistantMessage { +export interface OpenAICompatibleAssistantMessage + extends JsonRecord { role: 'assistant'; content?: string | null; tool_calls?: Array; } -export interface OpenAICompatibleMessageToolCall { +export interface OpenAICompatibleMessageToolCall extends JsonRecord { type: 'function'; id: string; function: { @@ -46,7 +57,7 @@ export interface OpenAICompatibleMessageToolCall { }; } -export interface OpenAICompatibleToolMessage { +export interface OpenAICompatibleToolMessage extends JsonRecord { role: 'tool'; content: string; tool_call_id: string; diff --git a/packages/openai-compatible/tsup.config.ts b/packages/openai-compatible/tsup.config.ts index 3f92041b987c..1085a16f678a 100644 --- a/packages/openai-compatible/tsup.config.ts +++ b/packages/openai-compatible/tsup.config.ts @@ -7,4 +7,11 @@ export default defineConfig([ dts: true, sourcemap: true, }, + { + entry: ['src/internal/index.ts'], + outDir: 'internal/dist', + format: ['cjs', 'esm'], + dts: true, + sourcemap: true, + }, ]); diff --git a/packages/togetherai/CHANGELOG.md b/packages/togetherai/CHANGELOG.md index 94e6c41df558..dd7f1b9a8bc3 100644 --- a/packages/togetherai/CHANGELOG.md +++ b/packages/togetherai/CHANGELOG.md @@ -1,5 +1,19 @@ # @ai-sdk/togetherai +## 0.0.14 + +### Patch Changes + +- Updated dependencies [6564812] + - @ai-sdk/openai-compatible@0.0.13 + +## 0.0.13 + +### Patch Changes + +- Updated dependencies [70003b8] + - @ai-sdk/openai-compatible@0.0.12 + ## 0.0.12 ### Patch Changes diff --git a/packages/togetherai/package.json b/packages/togetherai/package.json index a20df358a16e..edaf1acd5cef 100644 --- a/packages/togetherai/package.json +++ b/packages/togetherai/package.json @@ -1,6 +1,6 @@ { "name": "@ai-sdk/togetherai", - "version": "0.0.12", + "version": "0.0.14", "license": "Apache-2.0", "sideEffects": false, "main": "./dist/index.js", @@ -30,7 +30,7 @@ } }, "dependencies": { - "@ai-sdk/openai-compatible": "0.0.11", + "@ai-sdk/openai-compatible": "0.0.13", "@ai-sdk/provider": "1.0.3", "@ai-sdk/provider-utils": "2.0.5" }, diff --git a/packages/togetherai/src/index.ts b/packages/togetherai/src/index.ts index 53931dbe2f35..00170cb32954 100644 --- a/packages/togetherai/src/index.ts +++ b/packages/togetherai/src/index.ts @@ -3,3 +3,4 @@ export type { TogetherAIProvider, TogetherAIProviderSettings, } from './togetherai-provider'; +export type { OpenAICompatibleErrorData as TogetherAIErrorData } from '@ai-sdk/openai-compatible'; diff --git a/packages/xai/CHANGELOG.md b/packages/xai/CHANGELOG.md index 1288657163c5..154252cfea62 100644 --- a/packages/xai/CHANGELOG.md +++ b/packages/xai/CHANGELOG.md @@ -1,5 +1,19 @@ # @ai-sdk/xai +## 1.0.14 + +### Patch Changes + +- Updated dependencies [6564812] + - @ai-sdk/openai-compatible@0.0.13 + +## 1.0.13 + +### Patch Changes + +- Updated dependencies [70003b8] + - @ai-sdk/openai-compatible@0.0.12 + ## 1.0.12 ### Patch Changes diff --git a/packages/xai/package.json b/packages/xai/package.json index 0488e07d7f3f..434f1113648a 100644 --- a/packages/xai/package.json +++ b/packages/xai/package.json @@ -1,6 +1,6 @@ { "name": "@ai-sdk/xai", - "version": "1.0.12", + "version": "1.0.14", "license": "Apache-2.0", "sideEffects": false, "main": "./dist/index.js", @@ -30,7 +30,7 @@ } }, "dependencies": { - "@ai-sdk/openai-compatible": "0.0.11", + "@ai-sdk/openai-compatible": "0.0.13", "@ai-sdk/provider": "1.0.3", "@ai-sdk/provider-utils": "2.0.5" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c1f3b3fd0f3..ecc2e50f281e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,10 +69,10 @@ importers: specifier: 1.0.6 version: link:../../packages/cohere '@ai-sdk/deepinfra': - specifier: 0.0.2 + specifier: 0.0.4 version: link:../../packages/deepinfra '@ai-sdk/fireworks': - specifier: 0.0.5 + specifier: 0.0.7 version: link:../../packages/fireworks '@ai-sdk/google': specifier: 1.0.12 @@ -90,16 +90,16 @@ importers: specifier: 1.0.11 version: link:../../packages/openai '@ai-sdk/openai-compatible': - specifier: 0.0.11 + specifier: 0.0.13 version: link:../../packages/openai-compatible '@ai-sdk/provider': specifier: 1.0.3 version: link:../../packages/provider '@ai-sdk/togetherai': - specifier: 0.0.12 + specifier: 0.0.14 version: link:../../packages/togetherai '@ai-sdk/xai': - specifier: 1.0.12 + specifier: 1.0.14 version: link:../../packages/xai '@google/generative-ai': specifier: 0.21.0 @@ -1297,7 +1297,7 @@ importers: packages/deepinfra: dependencies: '@ai-sdk/openai-compatible': - specifier: 0.0.11 + specifier: 0.0.13 version: link:../openai-compatible '@ai-sdk/provider': specifier: 1.0.3 @@ -1325,7 +1325,7 @@ importers: packages/fireworks: dependencies: '@ai-sdk/openai-compatible': - specifier: 0.0.11 + specifier: 0.0.13 version: link:../openai-compatible '@ai-sdk/provider': specifier: 1.0.3 @@ -1746,7 +1746,7 @@ importers: packages/togetherai: dependencies: '@ai-sdk/openai-compatible': - specifier: 0.0.11 + specifier: 0.0.13 version: link:../openai-compatible '@ai-sdk/provider': specifier: 1.0.3 @@ -1863,7 +1863,7 @@ importers: packages/xai: dependencies: '@ai-sdk/openai-compatible': - specifier: 0.0.11 + specifier: 0.0.13 version: link:../openai-compatible '@ai-sdk/provider': specifier: 1.0.3