diff --git a/.changeset/create-model-capabilities.md b/.changeset/create-model-capabilities.md new file mode 100644 index 0000000000..74b454d686 --- /dev/null +++ b/.changeset/create-model-capabilities.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai': minor +--- + +`createModel` now accepts a capabilities object — `createModel(name, { input, features, tools, modelOptions })` — in addition to the existing `createModel(name, input)` form. `ExtendedModelDef` gains optional `features` and `tools` fields. diff --git a/.changeset/openai-compatible-adapter.md b/.changeset/openai-compatible-adapter.md new file mode 100644 index 0000000000..ed641ec1c3 --- /dev/null +++ b/.changeset/openai-compatible-adapter.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-openai': minor +--- + +Add `openaiCompatible({ baseURL, apiKey, models })` provider-factory and `openaiCompatibleText` one-shot helper (exported from `@tanstack/ai-openai/compatible`) for any OpenAI-Chat-Completions-compatible endpoint — DeepSeek, Moonshot/Kimi, Together, Fireworks, Cerebras, Qwen, Perplexity, local servers, and more. Per-model type safety via a hybrid `models` array (bare strings get optimistic defaults; `createModel()` defs declare precise capabilities), with an optional `api: 'responses'` toggle. diff --git a/docs/adapters/openai-compatible.md b/docs/adapters/openai-compatible.md new file mode 100644 index 0000000000..05fdbf897f --- /dev/null +++ b/docs/adapters/openai-compatible.md @@ -0,0 +1,236 @@ +--- +title: OpenAI-Compatible Adapter +id: openai-compatible-adapter +description: "Use any OpenAI-compatible provider (DeepSeek, Moonshot/Kimi, Together, Fireworks, Cerebras, Qwen, Perplexity, local servers, and more) in TanStack AI with one generic adapter." +keywords: + - tanstack ai + - openai compatible + - deepseek + - moonshot + - kimi + - together + - fireworks + - cerebras + - qwen + - perplexity + - lm studio + - vllm + - adapter +--- + +Many providers expose the OpenAI **Chat Completions** API (`/chat/completions`) — DeepSeek, Moonshot/Kimi, Together, Fireworks, Cerebras, Alibaba Qwen, Perplexity, NVIDIA NIM, and local servers like LM Studio, Ollama, and vLLM. Instead of a dedicated package per provider, TanStack AI ships one generic adapter: point it at any compatible `baseURL`, give it your models, and you get the same type-safe `chat()` experience as the first-class adapters. + +Use this when your provider speaks the OpenAI Chat Completions wire format but doesn't have its own `@tanstack/ai-*` package. If a dedicated adapter exists (OpenAI, Grok, Groq, OpenRouter), prefer it — those carry curated per-model metadata. + +## Installation + +The adapter ships inside `@tanstack/ai-openai` under the `/compatible` subpath — no extra install: + +```bash +npm install @tanstack/ai-openai +``` + +## Basic Usage + +Configure the provider once with `openaiCompatible({ baseURL, apiKey, models })`, then select a model per call. The returned model name is a type-safe union of the models you declared: + +```typescript +import { chat } from "@tanstack/ai"; +import { openaiCompatible } from "@tanstack/ai-openai/compatible"; + +const deepseek = openaiCompatible({ + name: "deepseek", // optional label shown in devtools/errors (default: "openai-compatible") + baseURL: "https://api.deepseek.com/v1", + apiKey: process.env.DEEPSEEK_API_KEY!, + models: ["deepseek-chat", "deepseek-reasoner"], +}); + +const stream = chat({ + adapter: deepseek("deepseek-chat"), + messages: [{ role: "user", content: "Hello!" }], +}); +``` + +`deepseek("deepseek-reasoner")` is valid; `deepseek("gpt-4o")` is a type error — only declared models are accepted. + +## One-Shot Usage + +For a single model, skip the provider-factory and build the adapter inline with `openaiCompatibleText`: + +```typescript +import { chat } from "@tanstack/ai"; +import { openaiCompatibleText } from "@tanstack/ai-openai/compatible"; + +const stream = chat({ + adapter: openaiCompatibleText("deepseek-chat", { + baseURL: "https://api.deepseek.com/v1", + apiKey: process.env.DEEPSEEK_API_KEY!, + }), + messages: [{ role: "user", content: "Hello!" }], +}); +``` + +## Declaring Models + +The `models` array accepts two forms, which you can mix: + +- **A bare string** — gets optimistic defaults: `text` + `image` input, with `streaming`, `function_calling`, and `structured_outputs` support. Good for mainstream chat models. +- **A `createModel(name, capabilities)` definition** — declares precise per-model capabilities so the types match reality (e.g. a reasoning model with no image input). + +```typescript +import { openaiCompatible } from "@tanstack/ai-openai/compatible"; +import { createModel } from "@tanstack/ai"; + +const provider = openaiCompatible({ + baseURL: "https://api.deepseek.com/v1", + apiKey: process.env.DEEPSEEK_API_KEY!, + models: [ + "deepseek-chat", // string → optimistic defaults + createModel("deepseek-reasoner", { + input: ["text"], // text only + features: ["reasoning", "structured_outputs"], + }), + ], +}); +``` + +> Capabilities are enforced at the type level. If a provider rejects a feature at runtime (e.g. tools on a model that doesn't support them), declare that model with `createModel` and omit the unsupported feature so the types stop you from calling it. + +## Configuration + +`openaiCompatible` accepts every OpenAI SDK `ClientOptions` field besides `apiKey`/`baseURL` (which are required and promoted to the top level). The most useful are `defaultHeaders` and `defaultQuery`, for providers that need extra auth or routing parameters: + +```typescript +const provider = openaiCompatible({ + baseURL: "https://api.example.com/v1", + apiKey: process.env.EXAMPLE_API_KEY!, + models: ["some-model"], + defaultHeaders: { "X-Custom-Header": "value" }, + defaultQuery: { "api-version": "2026-01-01" }, +}); +``` + +## Chat Completions vs Responses + +By default the adapter targets the **Chat Completions** API (`/chat/completions`) — the surface virtually every compatible provider implements. For the rare provider that also implements OpenAI's **Responses** API (e.g. Azure OpenAI), opt in with `api: "responses"`: + +```typescript +const provider = openaiCompatible({ + baseURL: "https://my-resource.openai.azure.com/openai/v1", + apiKey: process.env.AZURE_OPENAI_API_KEY!, + models: ["gpt-4o"], + api: "responses", // default is "chat-completions" +}); +``` + +## Supported Providers + +Any provider implementing the OpenAI Chat Completions API works. Common ones are below — **verify the `baseURL` and model ids against each provider's current docs**, since they change over time. Set the API key via the provider's own environment variable and pass it as `apiKey`. + +| Provider | `baseURL` | Example model | +| --- | --- | --- | +| DeepSeek | `https://api.deepseek.com/v1` | `deepseek-chat`, `deepseek-reasoner` | +| Moonshot / Kimi | `https://api.moonshot.ai/v1` | `kimi-k2-0711-preview` | +| Alibaba Qwen (DashScope, intl) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen-max`, `qwen-plus` | +| Alibaba Qwen (DashScope, China) | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `qwen-max` | +| Together AI | `https://api.together.xyz/v1` | `meta-llama/Llama-3.3-70B-Instruct-Turbo` | +| Fireworks AI | `https://api.fireworks.ai/inference/v1` | `accounts/fireworks/models/llama-v3p3-70b-instruct` | +| Cerebras | `https://api.cerebras.ai/v1` | `llama-3.3-70b` | +| DeepInfra | `https://api.deepinfra.com/v1/openai` | `meta-llama/Llama-3.3-70B-Instruct` | +| Perplexity | `https://api.perplexity.ai` | `sonar`, `sonar-pro` | +| Mistral | `https://api.mistral.ai/v1` | `mistral-large-latest` | +| Nebius | `https://api.studio.nebius.ai/v1` | `meta-llama/Llama-3.3-70B-Instruct` | +| Z.AI (GLM) | `https://api.z.ai/api/paas/v4` | `glm-4.6` | +| Baseten | `https://inference.baseten.co/v1` | model-dependent | +| Hugging Face (router) | `https://router.huggingface.co/v1` | `meta-llama/Llama-3.3-70B-Instruct` | +| NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | `meta/llama-3.3-70b-instruct` | + +## Local & Self-Hosted Servers + +Point the adapter at any local OpenAI-compatible server. The API key is usually a placeholder: + +```typescript +import { openaiCompatible } from "@tanstack/ai-openai/compatible"; + +// LM Studio +const lmstudio = openaiCompatible({ + name: "lmstudio", + baseURL: "http://localhost:1234/v1", + apiKey: "lm-studio", + models: ["local-model"], +}); + +// vLLM +const vllm = openaiCompatible({ + name: "vllm", + baseURL: "http://localhost:8000/v1", + apiKey: "not-needed", + models: ["meta-llama/Llama-3.3-70B-Instruct"], +}); + +// Ollama's OpenAI-compatible endpoint +const ollama = openaiCompatible({ + name: "ollama", + baseURL: "http://localhost:11434/v1", + apiKey: "ollama", + models: ["llama3.3"], +}); +``` + +> Ollama also has a dedicated adapter, [`@tanstack/ai-ollama`](./ollama), which understands its native API. Use `openaiCompatible` only if you specifically want Ollama's OpenAI-compatible surface. + +## Azure OpenAI + +Azure uses a resource-scoped URL and a separate API-version. Use the `/openai/v1` endpoint with `defaultQuery` for the version and `defaultHeaders` for the `api-key` header: + +```typescript +const azure = openaiCompatible({ + name: "azure", + baseURL: "https://YOUR_RESOURCE.openai.azure.com/openai/v1", + apiKey: process.env.AZURE_OPENAI_API_KEY!, // also sent as Bearer; Azure accepts the api-key header below + models: ["gpt-4o"], // your Azure deployment name + defaultQuery: { "api-version": "2026-01-01-preview" }, + defaultHeaders: { "api-key": process.env.AZURE_OPENAI_API_KEY! }, +}); +``` + +> Confirm the current `api-version` and endpoint shape in Azure's documentation — Azure's API surface evolves independently of OpenAI's. + +## Example: With Tools + +Tools work exactly as they do with any other adapter, for models that support function calling: + +```typescript +import { chat, toolDefinition } from "@tanstack/ai"; +import { openaiCompatible } from "@tanstack/ai-openai/compatible"; +import { z } from "zod"; + +const getWeatherDef = toolDefinition({ + name: "get_weather", + description: "Get the current weather", + inputSchema: z.object({ location: z.string() }), +}); + +const getWeather = getWeatherDef.server(async ({ location }) => { + return { temperature: 72, conditions: "sunny" }; +}); + +const deepseek = openaiCompatible({ + baseURL: "https://api.deepseek.com/v1", + apiKey: process.env.DEEPSEEK_API_KEY!, + models: ["deepseek-chat"], +}); + +const stream = chat({ + adapter: deepseek("deepseek-chat"), + messages: [{ role: "user", content: "What's the weather in Tokyo?" }], + tools: [getWeather], +}); +``` + +## Next Steps + +- [OpenAI Adapter](./openai) - The first-class OpenAI adapter +- [OpenRouter Adapter](./openrouter) - Access 300+ models through one gateway +- [Tools Guide](../tools/tools) - Learn about tools +- [Extending Adapters](../advanced/extend-adapter) - Add custom models to any adapter diff --git a/docs/adapters/openai.md b/docs/adapters/openai.md index e780a9a0eb..e7c109aa43 100644 --- a/docs/adapters/openai.md +++ b/docs/adapters/openai.md @@ -17,6 +17,8 @@ keywords: The OpenAI adapter provides access to OpenAI's models, including GPT-4o, GPT-5, image generation (DALL-E), text-to-speech (TTS), and audio transcription (Whisper). +> Using a third-party provider that speaks the OpenAI API (DeepSeek, Moonshot/Kimi, Together, Fireworks, a local LM Studio/vLLM server, …)? See the [OpenAI-Compatible Adapter](./openai-compatible) for a generic `openaiCompatible({ baseURL, apiKey, models })` factory. + ## Installation ```bash diff --git a/docs/config.json b/docs/config.json index 41282332ab..097ecdc414 100644 --- a/docs/config.json +++ b/docs/config.json @@ -326,6 +326,10 @@ { "label": "OpenRouter Adapter", "to": "adapters/openrouter" + }, + { + "label": "OpenAI-Compatible", + "to": "adapters/openai-compatible" } ] }, diff --git a/packages/ai-openai/package.json b/packages/ai-openai/package.json index 765f7e6f3e..16287edbb0 100644 --- a/packages/ai-openai/package.json +++ b/packages/ai-openai/package.json @@ -17,6 +17,10 @@ "types": "./dist/esm/index.d.ts", "import": "./dist/esm/index.js" }, + "./compatible": { + "types": "./dist/esm/compatible/index.d.ts", + "import": "./dist/esm/compatible/index.js" + }, "./tools": { "types": "./dist/esm/tools/index.d.ts", "import": "./dist/esm/tools/index.js" diff --git a/packages/ai-openai/src/compatible/adapter.ts b/packages/ai-openai/src/compatible/adapter.ts new file mode 100644 index 0000000000..0be679c809 --- /dev/null +++ b/packages/ai-openai/src/compatible/adapter.ts @@ -0,0 +1,55 @@ +import { + OpenAIBaseChatCompletionsTextAdapter, + OpenAIBaseResponsesTextAdapter, +} from '@tanstack/openai-base' +import type OpenAI from 'openai' +import type { Modality } from '@tanstack/ai' +import type { OpenAIMessageMetadataByModality } from '../message-types' + +/** + * Generic OpenAI-compatible adapter over the Chat Completions API + * (`{baseURL}/chat/completions`). Capability type-args are supplied by the + * `openaiCompatible` factory from the user's `models` tuple. + */ +export class OpenAICompatibleChatAdapter< + TModel extends string, + TProviderOptions extends Record = Record, + TInputModalities extends ReadonlyArray = ReadonlyArray, + TToolCapabilities extends ReadonlyArray = ReadonlyArray, +> extends OpenAIBaseChatCompletionsTextAdapter< + TModel, + TProviderOptions, + TInputModalities, + OpenAIMessageMetadataByModality, + TToolCapabilities +> { + override readonly kind = 'text' as const + + constructor(client: OpenAI, model: TModel, name: string) { + super(model, name, client) + } +} + +/** + * Generic OpenAI-compatible adapter over the Responses API + * (`{baseURL}/responses`). For the rare compatible provider that implements + * Responses (e.g. Azure OpenAI). + */ +export class OpenAICompatibleResponsesAdapter< + TModel extends string, + TProviderOptions extends Record = Record, + TInputModalities extends ReadonlyArray = ReadonlyArray, + TToolCapabilities extends ReadonlyArray = ReadonlyArray, +> extends OpenAIBaseResponsesTextAdapter< + TModel, + TProviderOptions, + TInputModalities, + OpenAIMessageMetadataByModality, + TToolCapabilities +> { + override readonly kind = 'text' as const + + constructor(client: OpenAI, model: TModel, name: string) { + super(model, name, client) + } +} diff --git a/packages/ai-openai/src/compatible/index.ts b/packages/ai-openai/src/compatible/index.ts new file mode 100644 index 0000000000..84a1844217 --- /dev/null +++ b/packages/ai-openai/src/compatible/index.ts @@ -0,0 +1,103 @@ +import OpenAI from 'openai' +import { + OpenAICompatibleChatAdapter, + OpenAICompatibleResponsesAdapter, +} from './adapter' +import type { + CompatibleModelInput, + ModelNameOf, + OpenAICompatibleConfig, + OpenAICompatibleTextConfig, + ResolveCompatInput, + ResolveCompatOptions, + ResolveCompatTools, +} from './types' + +export { + OpenAICompatibleChatAdapter, + OpenAICompatibleResponsesAdapter, +} from './adapter' +export type { + CompatibleApi, + CompatibleModelInput, + ModelNameOf, + OpenAICompatibleConfig, + OpenAICompatibleTextConfig, +} from './types' + +const DEFAULT_NAME = 'openai-compatible' + +/** + * Configure an OpenAI-compatible provider once, then select a model per call. + * + * @example + * ```ts + * const deepseek = openaiCompatible({ + * name: 'deepseek', + * baseURL: 'https://api.deepseek.com/v1', + * apiKey: process.env.DEEPSEEK_KEY!, + * models: ['deepseek-chat', 'deepseek-reasoner'], + * }) + * chat({ adapter: deepseek('deepseek-chat'), messages }) + * ``` + */ +export function openaiCompatible< + const TModels extends ReadonlyArray, +>(config: OpenAICompatibleConfig) { + // `name`, `models`, and `api` are TanStack-level config; everything else + // (incl. the required `apiKey` / `baseURL`) is OpenAI SDK ClientOptions. + const { + name = DEFAULT_NAME, + models: _models, + api = 'chat-completions', + ...clientOptions + } = config + const client = new OpenAI(clientOptions) + + return >(model: TModelName) => { + if (api === 'responses') { + return new OpenAICompatibleResponsesAdapter< + TModelName, + ResolveCompatOptions, + ResolveCompatInput, + ResolveCompatTools + >(client, model, name) + } + return new OpenAICompatibleChatAdapter< + TModelName, + ResolveCompatOptions, + ResolveCompatInput, + ResolveCompatTools + >(client, model, name) + } +} + +/** + * One-shot helper: build a single-model OpenAI-compatible adapter inline. + * + * @example + * ```ts + * chat({ + * adapter: openaiCompatibleText('deepseek-chat', { + * baseURL: 'https://api.deepseek.com/v1', + * apiKey: process.env.DEEPSEEK_KEY!, + * }), + * messages, + * }) + * ``` + */ +export function openaiCompatibleText( + model: TModelName, + config: OpenAICompatibleTextConfig, +) { + const { + name = DEFAULT_NAME, + api = 'chat-completions', + ...clientOptions + } = config + const client = new OpenAI(clientOptions) + if (api === 'responses') { + return new OpenAICompatibleResponsesAdapter(client, model, name) + } + return new OpenAICompatibleChatAdapter(client, model, name) +} diff --git a/packages/ai-openai/src/compatible/types.ts b/packages/ai-openai/src/compatible/types.ts new file mode 100644 index 0000000000..0e0dececc4 --- /dev/null +++ b/packages/ai-openai/src/compatible/types.ts @@ -0,0 +1,95 @@ +import type { ExtendedModelDef } from '@tanstack/ai' +import type { ClientOptions } from 'openai' + +/** A model entry: either a bare id string or a rich createModel() def. */ +export type CompatibleModelInput = string | ExtendedModelDef + +/** + * Optimistic default input modalities for bare-string models. (Function + * calling and structured output are always available on the Chat Completions + * path, so they need no separate type-level flag; `TToolCapabilities` + * represents provider *built-in* tools, which bare strings don't declare.) + */ +export type DefaultCompatInput = readonly ['text', 'image'] + +/** Union of all selectable model names from a `models` tuple. */ +export type ModelNameOf> = { + [I in keyof TModels]: TModels[I] extends string + ? TModels[I] + : TModels[I] extends ExtendedModelDef + ? TName + : never +}[number] + +/** Extract the rich def (if any) for model name `M`. */ +type FindDef< + TModels extends ReadonlyArray, + TModelName extends string, +> = Extract, { name: TModelName }> + +/** Resolve input modalities for model `M`. */ +export type ResolveCompatInput< + TModels extends ReadonlyArray, + TModelName extends string, +> = [FindDef] extends [never] + ? DefaultCompatInput + : FindDef extends ExtendedModelDef + ? TInput + : DefaultCompatInput + +/** Resolve provider options for model `M`. */ +export type ResolveCompatOptions< + TModels extends ReadonlyArray, + TModelName extends string, +> = [FindDef] extends [never] + ? Record + : FindDef extends ExtendedModelDef< + any, + any, + infer TOptions + > + ? TOptions extends Record + ? TOptions + : Record + : Record + +/** Resolve provider tool capabilities for model `M`. */ +export type ResolveCompatTools< + TModels extends ReadonlyArray, + TModelName extends string, +> = [FindDef] extends [never] + ? readonly [] + : FindDef extends ExtendedModelDef< + any, + any, + any, + any, + infer TTools + > + ? TTools + : readonly [] + +/** Which underlying OpenAI API the endpoint speaks. */ +export type CompatibleApi = 'chat-completions' | 'responses' + +/** Provider-factory configuration. */ +export interface OpenAICompatibleConfig< + TModels extends ReadonlyArray, +> extends Omit { + name?: string + baseURL: string + apiKey: string + models: TModels + api?: CompatibleApi +} + +/** One-shot helper configuration (single model). */ +export interface OpenAICompatibleTextConfig extends Omit< + ClientOptions, + 'apiKey' | 'baseURL' +> { + name?: string + baseURL: string + apiKey: string + api?: CompatibleApi +} diff --git a/packages/ai-openai/tests/compatible-types.test.ts b/packages/ai-openai/tests/compatible-types.test.ts new file mode 100644 index 0000000000..05644dfcd5 --- /dev/null +++ b/packages/ai-openai/tests/compatible-types.test.ts @@ -0,0 +1,38 @@ +import { expectTypeOf, test } from 'vitest' +import { createModel } from '@tanstack/ai' +import { openaiCompatible } from '../src/compatible/index' +import type { ModelNameOf, ResolveCompatInput } from '../src/compatible/types' + +const models = [ + 'plain-model', + createModel('reasoner', { input: ['text'], features: ['reasoning'] }), +] as const + +test('ModelNameOf unions bare strings and def names', () => { + expectTypeOf>().toEqualTypeOf< + 'plain-model' | 'reasoner' + >() +}) + +test('ResolveCompatInput uses optimistic default for bare strings', () => { + expectTypeOf< + ResolveCompatInput + >().toEqualTypeOf() +}) + +test('ResolveCompatInput uses declared input for rich defs', () => { + expectTypeOf>().toEqualTypeOf< + readonly ['text'] + >() +}) + +test('provider rejects unknown model names', () => { + const p = openaiCompatible({ + baseURL: 'https://x/v1', + apiKey: 'k', + models: ['known'] as const, + }) + // @ts-expect-error 'nope' is not a declared model + p('nope') + p('known') +}) diff --git a/packages/ai-openai/tests/compatible.test.ts b/packages/ai-openai/tests/compatible.test.ts new file mode 100644 index 0000000000..a582dd13e0 --- /dev/null +++ b/packages/ai-openai/tests/compatible.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest' + +const ctor = vi.fn() +vi.mock('openai', () => ({ + default: class { + constructor(opts: unknown) { + ctor(opts) + } + chat = { completions: { create: vi.fn() } } + responses = { create: vi.fn() } + }, +})) + +/* eslint-disable import/first -- imports intentionally follow the vi.mock setup above */ +import { + OpenAICompatibleChatAdapter, + OpenAICompatibleResponsesAdapter, +} from '../src/compatible/adapter' +import { openaiCompatible, openaiCompatibleText } from '../src/compatible/index' +/* eslint-enable import/first */ + +describe('openaiCompatible', () => { + it('builds the OpenAI client once with baseURL + apiKey + extra options', () => { + ctor.mockClear() + const provider = openaiCompatible({ + baseURL: 'https://api.example.com/v1', + apiKey: 'sk-test', + models: ['model-a', 'model-b'], + defaultHeaders: { 'X-Title': 'demo' }, + }) + expect(ctor).toHaveBeenCalledTimes(1) + expect(ctor).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: 'https://api.example.com/v1', + apiKey: 'sk-test', + defaultHeaders: { 'X-Title': 'demo' }, + }), + ) + provider('model-a') + provider('model-b') + expect(ctor).toHaveBeenCalledTimes(1) + }) + + it('returns a Chat Completions adapter by default', () => { + const provider = openaiCompatible({ + baseURL: 'https://api.example.com/v1', + apiKey: 'sk-test', + models: ['model-a'], + }) + expect(provider('model-a')).toBeInstanceOf(OpenAICompatibleChatAdapter) + }) + + it('returns a Responses adapter when api: "responses"', () => { + const provider = openaiCompatible({ + baseURL: 'https://api.example.com/v1', + apiKey: 'sk-test', + models: ['model-a'], + api: 'responses', + }) + expect(provider('model-a')).toBeInstanceOf(OpenAICompatibleResponsesAdapter) + }) + + it('uses the provided name (default "openai-compatible")', () => { + const provider = openaiCompatible({ + name: 'deepseek', + baseURL: 'https://api.example.com/v1', + apiKey: 'sk-test', + models: ['model-a'], + }) + expect(provider('model-a').name).toBe('deepseek') + + const unnamed = openaiCompatible({ + baseURL: 'https://api.example.com/v1', + apiKey: 'sk-test', + models: ['model-a'], + }) + expect(unnamed('model-a').name).toBe('openai-compatible') + }) +}) + +describe('openaiCompatibleText', () => { + it('builds a single-model Chat Completions adapter', () => { + const adapter = openaiCompatibleText('model-a', { + baseURL: 'https://api.example.com/v1', + apiKey: 'sk-test', + }) + expect(adapter).toBeInstanceOf(OpenAICompatibleChatAdapter) + expect(adapter.model).toBe('model-a') + }) +}) diff --git a/packages/ai-openai/vite.config.ts b/packages/ai-openai/vite.config.ts index 0e7e7eaea6..f6d5c2e8f8 100644 --- a/packages/ai-openai/vite.config.ts +++ b/packages/ai-openai/vite.config.ts @@ -29,7 +29,11 @@ const config = defineConfig({ export default mergeConfig( config, tanstackViteConfig({ - entry: ['./src/index.ts', './src/tools/index.ts'], + entry: [ + './src/index.ts', + './src/compatible/index.ts', + './src/tools/index.ts', + ], srcDir: './src', cjs: false, }), diff --git a/packages/ai/skills/ai-core/adapter-configuration/SKILL.md b/packages/ai/skills/ai-core/adapter-configuration/SKILL.md index 621bd1c2c8..2e86fd3768 100644 --- a/packages/ai/skills/ai-core/adapter-configuration/SKILL.md +++ b/packages/ai/skills/ai-core/adapter-configuration/SKILL.md @@ -2,9 +2,11 @@ name: ai-core/adapter-configuration description: > Provider adapter selection and configuration: openaiText, anthropicText, - geminiText, ollamaText, grokText, groqText, openRouterText. Per-model + geminiText, ollamaText, grokText, groqText, openRouterText, openaiCompatible. Per-model type safety with modelOptions, reasoning/thinking configuration, runtime adapter switching, extendAdapter() for custom models, createModel(). + Generic OpenAI-compatible providers (DeepSeek, Together, Fireworks, etc.) via + openaiCompatible({ baseURL, apiKey, models }) from @tanstack/ai-openai/compatible. API key env vars: OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY/GEMINI_API_KEY, XAI_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY, OLLAMA_HOST. type: sub-skill @@ -60,15 +62,16 @@ into the factory, not into `chat()`. Each provider has a dedicated package with tree-shakeable adapter factories. The text adapter is the primary one for chat/completions: -| Provider | Package | Factory | Env Var | -| ---------- | ------------------------- | ---------------- | ------------------------------------------------- | -| OpenAI | `@tanstack/ai-openai` | `openaiText` | `OPENAI_API_KEY` | -| Anthropic | `@tanstack/ai-anthropic` | `anthropicText` | `ANTHROPIC_API_KEY` | -| Gemini | `@tanstack/ai-gemini` | `geminiText` | `GOOGLE_API_KEY` or `GEMINI_API_KEY` | -| Grok (xAI) | `@tanstack/ai-grok` | `grokText` | `XAI_API_KEY` | -| Groq | `@tanstack/ai-groq` | `groqText` | `GROQ_API_KEY` | -| OpenRouter | `@tanstack/ai-openrouter` | `openRouterText` | `OPENROUTER_API_KEY` | -| Ollama | `@tanstack/ai-ollama` | `ollamaText` | `OLLAMA_HOST` (default: `http://localhost:11434`) | +| Provider | Package | Factory | Env Var | +| ----------------- | -------------------------------- | ------------------------------------------- | ------------------------------------------------- | +| OpenAI | `@tanstack/ai-openai` | `openaiText` | `OPENAI_API_KEY` | +| Anthropic | `@tanstack/ai-anthropic` | `anthropicText` | `ANTHROPIC_API_KEY` | +| Gemini | `@tanstack/ai-gemini` | `geminiText` | `GOOGLE_API_KEY` or `GEMINI_API_KEY` | +| Grok (xAI) | `@tanstack/ai-grok` | `grokText` | `XAI_API_KEY` | +| Groq | `@tanstack/ai-groq` | `groqText` | `GROQ_API_KEY` | +| OpenRouter | `@tanstack/ai-openrouter` | `openRouterText` | `OPENROUTER_API_KEY` | +| Ollama | `@tanstack/ai-ollama` | `ollamaText` | `OLLAMA_HOST` (default: `http://localhost:11434`) | +| OpenAI-compatible | `@tanstack/ai-openai/compatible` | `openaiCompatible` / `openaiCompatibleText` | provider-specific (passed via `apiKey`) | ```typescript // Each factory takes model as first arg, optional config as second @@ -252,6 +255,63 @@ Subclasses can override to narrow the capability. When extending an adapter for a custom model that doesn't support the combination, return `false` explicitly. +### 6. OpenAI-Compatible Providers + +Any provider that implements the OpenAI **Chat Completions** API (DeepSeek, +Moonshot/Kimi, Together, Fireworks, Cerebras, Qwen/DashScope, Perplexity, +NVIDIA NIM, LM Studio, etc.) can be used through the generic +`openaiCompatible` factory from `@tanstack/ai-openai/compatible` — no +dedicated package required. + +```typescript +import { openaiCompatible } from '@tanstack/ai-openai/compatible' +import { createModel } from '@tanstack/ai' + +// Provider-factory: configure baseURL + apiKey + models ONCE, +// then select a model per call (the model arg is a type-safe union). +const deepseek = openaiCompatible({ + name: 'deepseek', // optional label for devtools/errors (default 'openai-compatible') + baseURL: 'https://api.deepseek.com/v1', + apiKey: process.env.DEEPSEEK_API_KEY!, + models: [ + 'deepseek-chat', // bare string → optimistic defaults: text/image in, streaming, tools, structured output + createModel('deepseek-reasoner', { + // rich def → precise per-model capabilities + input: ['text'], + features: ['reasoning', 'structured_outputs'], + }), + ], +}) + +chat({ adapter: deepseek('deepseek-chat'), messages }) +chat({ adapter: deepseek('deepseek-reasoner'), messages }) +``` + +`config` also accepts any OpenAI SDK `ClientOptions` (notably `defaultHeaders` +and `defaultQuery`) for providers that need extra auth headers or query params. + +For a single model, use the one-shot helper: + +```typescript +import { openaiCompatibleText } from '@tanstack/ai-openai/compatible' + +chat({ + adapter: openaiCompatibleText('deepseek-chat', { + baseURL: 'https://api.deepseek.com/v1', + apiKey: process.env.DEEPSEEK_API_KEY!, + }), + messages, +}) +``` + +Pass `api: 'responses'` to target the OpenAI **Responses** API instead of Chat +Completions (only for the rare compatible provider that implements it, e.g. +Azure OpenAI); the default is `'chat-completions'`, which is what nearly all +compatible providers speak. + +> Verify the provider's current `baseURL` and model ids against its live docs — +> they drift. See `docs/adapters/openai-compatible.md` for the full provider table. + ## Common Mistakes ### a. HIGH: Confusing legacy monolithic with tree-shakeable adapter diff --git a/packages/ai/src/extend-adapter.ts b/packages/ai/src/extend-adapter.ts index c689f7ed42..e2e904161e 100644 --- a/packages/ai/src/extend-adapter.ts +++ b/packages/ai/src/extend-adapter.ts @@ -22,6 +22,8 @@ export interface ExtendedModelDef< TName extends string = string, TInput extends ReadonlyArray = ReadonlyArray, TOptions = unknown, + TFeatures extends ReadonlyArray = ReadonlyArray, + TTools extends ReadonlyArray = ReadonlyArray, > { /** The model name identifier */ name: TName @@ -29,6 +31,23 @@ export interface ExtendedModelDef< input: TInput /** Type brand for provider options - use `{} as YourOptionsType` */ modelOptions: TOptions + /** Optional declared features (e.g. 'reasoning', 'structured_outputs') */ + features?: TFeatures + /** Optional declared provider tools (e.g. 'web_search') */ + tools?: TTools +} + +/** Capability bag accepted by the object form of `createModel`. */ +export interface ModelCapabilities< + TInput extends ReadonlyArray = ReadonlyArray, + TFeatures extends ReadonlyArray = ReadonlyArray, + TTools extends ReadonlyArray = ReadonlyArray, + TOptions = unknown, +> { + input?: TInput + features?: TFeatures + tools?: TTools + modelOptions?: TOptions } /** @@ -57,15 +76,57 @@ export interface ExtendedModelDef< * * const myOpenai = extendAdapter(openaiText, customModels) * ``` + * + * @example + * ```typescript + * // Capabilities object form - declare features and provider tools + * const reasoner = createModel('reasoner', { + * input: ['text'], + * features: ['reasoning', 'structured_outputs'], + * tools: ['web_search'], + * }) + * ``` */ +// Overload 1 — legacy positional input array (unchanged behavior) export function createModel< const TName extends string, const TInput extends ReadonlyArray, ->(name: TName, input: TInput): ExtendedModelDef { +>(name: TName, input: TInput): ExtendedModelDef +// Overload 2 — capabilities object +export function createModel< + const TName extends string, + const TCaps extends ModelCapabilities, +>( + name: TName, + capabilities: TCaps, +): ExtendedModelDef< + TName, + TCaps['input'] extends ReadonlyArray + ? TCaps['input'] + : ReadonlyArray, + TCaps['modelOptions'], + TCaps['features'] extends ReadonlyArray + ? TCaps['features'] + : ReadonlyArray, + TCaps['tools'] extends ReadonlyArray + ? TCaps['tools'] + : ReadonlyArray +> +// Implementation +export function createModel( + name: string, + second: ReadonlyArray | ModelCapabilities, +): ExtendedModelDef { + if (Array.isArray(second)) { + return { name, input: second, modelOptions: {} } + } + const caps = second as ModelCapabilities return { name, - input, - modelOptions: {}, + input: caps.input ?? (['text'] as ReadonlyArray), + modelOptions: caps.modelOptions ?? {}, + features: caps.features, + tools: caps.tools, } } diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 4a8e4ac8ab..0df61da100 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -197,7 +197,7 @@ export { // Adapter extension utilities export { createModel, extendAdapter } from './extend-adapter' -export type { ExtendedModelDef } from './extend-adapter' +export type { ExtendedModelDef, ModelCapabilities } from './extend-adapter' // Logger export type { diff --git a/packages/ai/tests/extend-adapter.test.ts b/packages/ai/tests/extend-adapter.test.ts index 1e946f2f2b..0bdbd888b1 100644 --- a/packages/ai/tests/extend-adapter.test.ts +++ b/packages/ai/tests/extend-adapter.test.ts @@ -10,6 +10,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { createModel, extendAdapter } from '../src/extend-adapter' import { BaseTextAdapter } from '../src/activities/chat/adapter' +import type { ExtendedModelDef } from '../src/extend-adapter' import { chat } from '../src/activities/chat' import { EventType } from '../src/types' import type { StreamChunk, TextOptions } from '../src/types' @@ -252,3 +253,41 @@ describe('extendAdapter', () => { }) }) }) + +describe('createModel capabilities overload', () => { + it('(name, inputArray) still infers name + input (backward compat)', () => { + const m = createModel('legacy-model', ['text', 'image']) + expectTypeOf(m.name).toEqualTypeOf<'legacy-model'>() + expectTypeOf(m.input).toEqualTypeOf() + }) + + it('(name, capabilities) captures features and tools', () => { + const m = createModel('reasoner', { + input: ['text'], + features: ['reasoning', 'structured_outputs'], + tools: ['web_search'], + }) + expectTypeOf(m.name).toEqualTypeOf<'reasoner'>() + expectTypeOf(m.input).toEqualTypeOf() + expectTypeOf(m.features).toEqualTypeOf< + readonly ['reasoning', 'structured_outputs'] | undefined + >() + expectTypeOf(m.tools).toEqualTypeOf() + }) + + it('capabilities-form model is still an ExtendedModelDef', () => { + const m = createModel('reasoner', { input: ['text'] }) + expectTypeOf(m).toMatchTypeOf() + }) + + it('maps modelOptions through to the def', () => { + interface MyOpts { + reasoningEffort: 'low' | 'high' + } + const m = createModel('reasoner', { + input: ['text'], + modelOptions: {} as MyOpts, + }) + expectTypeOf(m.modelOptions).toEqualTypeOf() + }) +}) diff --git a/packages/openai-base/tests/chat-completions-empty-choices.test.ts b/packages/openai-base/tests/chat-completions-empty-choices.test.ts new file mode 100644 index 0000000000..e04bddef72 --- /dev/null +++ b/packages/openai-base/tests/chat-completions-empty-choices.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { OpenAIBaseChatCompletionsTextAdapter } from '../src/adapters/chat-completions-text' +import OpenAI from 'openai' +import { EventType } from '@tanstack/ai' +import { resolveDebugOption } from '@tanstack/ai/adapter-internals' +import type { StreamChunk, Tool } from '@tanstack/ai' + +const testLogger = resolveDebugOption(false) + +/** + * Narrowed signature for the OpenAI SDK's `chat.completions.create` — see the + * sibling chat-completions-text.test.ts for the full rationale. The streaming / + * non-streaming overload union is awkward to `mockImplementation`, and the + * adapter's behaviour is validated by the AG-UI events it emits rather than by + * SDK structural typing. + */ +type MockChatCompletionCreate = ( + params: OpenAI.Chat.Completions.ChatCompletionCreateParams, + options?: OpenAI.RequestOptions, +) => unknown + +let mockCreate: ReturnType> + +function makeStubClient(): OpenAI { + const client = new OpenAI({ apiKey: 'test-api-key' }) + client.chat.completions.create = (( + params: OpenAI.Chat.Completions.ChatCompletionCreateParams, + options?: OpenAI.RequestOptions, + ) => mockCreate(params, options)) as typeof client.chat.completions.create + return client +} + +class TestChatCompletionsAdapter extends OpenAIBaseChatCompletionsTextAdapter { + constructor(_config: unknown, model: string, name = 'openai-base') { + super(model, name, makeStubClient()) + } +} + +function createAsyncIterable(chunks: Array): AsyncIterable { + return { + [Symbol.asyncIterator]() { + let index = 0 + return { + async next() { + if (index < chunks.length) { + return { value: chunks[index++]!, done: false } + } + return { value: undefined as T, done: true } + }, + } + }, + } +} + +function setupMockSdkClient(streamChunks: Array>) { + mockCreate = vi.fn().mockImplementation((params) => { + if (params.stream) { + return Promise.resolve(createAsyncIterable(streamChunks)) + } + return Promise.resolve(undefined) + }) +} + +const testConfig = { + apiKey: 'test-api-key', + baseURL: 'https://api.test-provider.com/v1', +} + +const weatherTool: Tool = { + name: 'get_weather', + description: 'Return the forecast for a location', +} + +/** + * Regression guard for issue #371 (OpenRouter) and the broader class of + * OpenAI-compatible providers (DeepSeek, Together, Fireworks) that deliver the + * terminal token-usage payload on a separate chunk whose `choices` array is + * empty (`choices: []`). A naive `const choice = chunk.choices[0]; if (!choice) + * continue` skips that chunk entirely, which — depending on where the provider + * placed `finish_reason` — can strand an in-progress tool call so it never + * emits TOOL_CALL_END and the tool never executes. + */ +describe('OpenAIBaseChatCompletionsTextAdapter — usage-only terminal chunk', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('finalizes a tool call when finish_reason arrives with the tool-call chunk and usage arrives on a separate empty-choices chunk', async () => { + const streamChunks = [ + // Chunk 1: opens the tool call AND carries finish_reason on the same + // chunk (the common shape — finish_reason lands on the last choice chunk). + { + id: 'chatcmpl-empty-1', + model: 'test-model', + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: 'call_1', + type: 'function', + function: { name: 'get_weather', arguments: '{}' }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + }, + // Final chunk: empty choices, usage only (DeepSeek / Together / Fireworks + // / OpenRouter terminal shape). + { + id: 'chatcmpl-empty-1', + model: 'test-model', + choices: [], + usage: { prompt_tokens: 12, completion_tokens: 3, total_tokens: 15 }, + }, + ] + + setupMockSdkClient(streamChunks) + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Weather in Berlin?' }], + tools: [weatherTool], + })) { + chunks.push(chunk) + } + + const toolEnd = chunks.find((c) => c.type === EventType.TOOL_CALL_END) + expect(toolEnd).toBeDefined() + if (toolEnd?.type === EventType.TOOL_CALL_END) { + expect(toolEnd.toolCallId).toBe('call_1') + expect(toolEnd.toolName).toBe('get_weather') + } + + const runFinished = chunks.find((c) => c.type === EventType.RUN_FINISHED) + expect(runFinished).toBeDefined() + if (runFinished?.type === EventType.RUN_FINISHED) { + expect(runFinished.finishReason).toBe('tool_calls') + expect(runFinished.usage).toMatchObject({ + promptTokens: 12, + completionTokens: 3, + totalTokens: 15, + }) + } + }) + + it('finalizes a tool call when NO finish_reason ever arrives on a choice and the stream ends with a usage-only empty-choices chunk (issue #371)', async () => { + // The strict #371 repro: the tool call is opened, but the provider never + // delivers `finish_reason` on a populated choice — the only terminal signal + // is a `choices: []` usage chunk. The post-loop drain must still close the + // started tool call so downstream tool execution is triggered. + const streamChunks = [ + { + id: 'chatcmpl-empty-2', + model: 'test-model', + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: 'call_1', + type: 'function', + function: { + name: 'get_weather', + arguments: '{"location":"Berlin"}', + }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-empty-2', + model: 'test-model', + choices: [], + usage: { prompt_tokens: 8, completion_tokens: 4, total_tokens: 12 }, + }, + ] + + setupMockSdkClient(streamChunks) + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Weather in Berlin?' }], + tools: [weatherTool], + })) { + chunks.push(chunk) + } + + const toolEnd = chunks.find((c) => c.type === EventType.TOOL_CALL_END) + expect(toolEnd).toBeDefined() + if (toolEnd?.type === EventType.TOOL_CALL_END) { + expect(toolEnd.toolCallId).toBe('call_1') + expect(toolEnd.toolName).toBe('get_weather') + expect(toolEnd.input).toEqual({ location: 'Berlin' }) + } + + const runFinished = chunks.find((c) => c.type === EventType.RUN_FINISHED) + expect(runFinished).toBeDefined() + if (runFinished?.type === EventType.RUN_FINISHED) { + // A started/ended tool-call pair was emitted, so the finish reason must + // surface as `tool_calls` regardless of the missing upstream signal. + expect(runFinished.finishReason).toBe('tool_calls') + expect(runFinished.usage).toMatchObject({ + promptTokens: 8, + completionTokens: 4, + totalTokens: 12, + }) + } + }) +}) diff --git a/testing/e2e/src/lib/feature-support.ts b/testing/e2e/src/lib/feature-support.ts index 49aa747089..6d6b950bd7 100644 --- a/testing/e2e/src/lib/feature-support.ts +++ b/testing/e2e/src/lib/feature-support.ts @@ -16,6 +16,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openai-compatible', ]), 'one-shot-text': new Set([ 'openai', @@ -25,6 +26,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openai-compatible', ]), reasoning: new Set(['openai', 'anthropic', 'gemini']), 'multi-turn': new Set([ @@ -35,6 +37,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openai-compatible', ]), 'tool-calling': new Set([ 'openai', @@ -44,6 +47,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openai-compatible', ]), 'parallel-tool-calls': new Set([ 'openai', @@ -52,6 +56,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openai-compatible', ]), // Gemini excluded: approval flow timing issues with Gemini's streaming format 'tool-approval': new Set([ @@ -61,6 +66,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openai-compatible', ]), // Ollama excluded: aimock doesn't support content+toolCalls for /api/chat format 'text-tool-text': new Set([ @@ -70,6 +76,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openai-compatible', ]), 'structured-output': new Set([ 'openai', @@ -79,12 +86,19 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openai-compatible', ]), // Streaming structured output: only providers with native streaming JSON // schema support are listed here. Other providers fall back to the // activity-layer `fallbackStructuredOutputStream` (which wraps the // non-streaming `structuredOutput`) but aren't exercised by E2E yet. - 'structured-output-stream': new Set(['openai', 'groq', 'grok', 'openrouter']), + 'structured-output-stream': new Set([ + 'openai', + 'groq', + 'grok', + 'openrouter', + 'openai-compatible', + ]), // Multi-turn structured output: every turn produces its own typed // `structured-output` part on the assistant message, and historical // turns stay renderable. Works for every provider that supports both @@ -110,6 +124,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openai-compatible', ]), 'agentic-structured': new Set([ 'openai', @@ -119,6 +134,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openai-compatible', ]), // Native-combined-mode adapters only. Each provider's default test model // (or per-feature override in `features.ts`) must opt into combined mode diff --git a/testing/e2e/src/lib/providers.ts b/testing/e2e/src/lib/providers.ts index fe80ed5e48..4edbd52194 100644 --- a/testing/e2e/src/lib/providers.ts +++ b/testing/e2e/src/lib/providers.ts @@ -7,6 +7,7 @@ import { createGeminiTextInteractions } from '@tanstack/ai-gemini/experimental' import { createOllamaChat } from '@tanstack/ai-ollama' import { createGroqText } from '@tanstack/ai-groq' import { createGrokText } from '@tanstack/ai-grok' +import { openaiCompatibleText } from '@tanstack/ai-openai/compatible' import { createOpenRouterResponsesText, createOpenRouterText, @@ -26,6 +27,7 @@ const defaultModels: Record = { grok: 'grok-3', openrouter: 'openai/gpt-4o', 'openrouter-responses': 'openai/gpt-4o', + 'openai-compatible': 'gpt-4o', // ElevenLabs has no chat/text model — the support matrix already filters // it out of text features, but we still need an entry to satisfy the // Record constraint. @@ -154,6 +156,14 @@ export function createTextAdapter( ), }) }, + 'openai-compatible': () => + createChatOptions({ + adapter: openaiCompatibleText(model, { + baseURL: openaiUrl, + apiKey: DUMMY_KEY, + defaultHeaders: testHeaders, + }), + }), elevenlabs: () => { throw new Error( 'ElevenLabs has no text/chat adapter — use createTTSAdapter or createTranscriptionAdapter.', diff --git a/testing/e2e/src/lib/types.ts b/testing/e2e/src/lib/types.ts index a8dbd0cf1d..018e7744f1 100644 --- a/testing/e2e/src/lib/types.ts +++ b/testing/e2e/src/lib/types.ts @@ -9,6 +9,7 @@ export type Provider = | 'groq' | 'openrouter' | 'openrouter-responses' + | 'openai-compatible' | 'elevenlabs' export type Feature = @@ -46,6 +47,7 @@ export const ALL_PROVIDERS: Provider[] = [ 'groq', 'openrouter', 'openrouter-responses', + 'openai-compatible', 'elevenlabs', ] diff --git a/testing/e2e/tests/test-matrix.ts b/testing/e2e/tests/test-matrix.ts index f48dcebc04..58a166a3ae 100644 --- a/testing/e2e/tests/test-matrix.ts +++ b/testing/e2e/tests/test-matrix.ts @@ -22,6 +22,7 @@ export const providers: Provider[] = [ 'grok', 'openrouter', 'openrouter-responses', + 'openai-compatible', 'elevenlabs', ]