From ab8dd43ed421ced08d0f16015f75a3b4c24b1473 Mon Sep 17 00:00:00 2001 From: bangla24bdrang-lab Date: Sat, 29 Aug 2026 06:44:16 +0000 Subject: [PATCH] feat: add OrcaRouter provider adapter Signed-off-by: bangla24bdrang-lab --- .changeset/quick-kiwis-jump.md | 7 + README.md | 1 + docs/adapters/orcarouter.md | 167 ++++++ docs/config.json | 5 + docs/getting-started/overview.md | 1 + docs/structured-outputs/streaming.md | 1 + packages/ai-orcarouter/LICENSE | 21 + packages/ai-orcarouter/README.md | 95 +++ packages/ai-orcarouter/package.json | 77 +++ .../ai-orcarouter/src/adapters/summarize.ts | 83 +++ packages/ai-orcarouter/src/adapters/text.ts | 121 ++++ packages/ai-orcarouter/src/index.ts | 53 ++ packages/ai-orcarouter/src/message-types.ts | 131 ++++ packages/ai-orcarouter/src/model-meta.ts | 314 ++++++++++ .../src/text/text-provider-options.ts | 127 ++++ packages/ai-orcarouter/src/utils/client.ts | 35 ++ packages/ai-orcarouter/src/utils/index.ts | 5 + .../tests/orcarouter-adapter.test.ts | 567 ++++++++++++++++++ packages/ai-orcarouter/tsconfig.json | 8 + packages/ai-orcarouter/vite.config.ts | 36 ++ packages/ai-orcarouter/vitest.config.ts | 22 + .../summarize/chat-stream-summarize.ts | 3 + testing/e2e/README.md | 2 +- testing/e2e/package.json | 1 + testing/e2e/src/lib/feature-support.ts | 16 + testing/e2e/src/lib/providers.ts | 9 + testing/e2e/src/lib/types.ts | 2 + testing/e2e/src/routes/api.summarize.ts | 6 + testing/e2e/tests/test-matrix.ts | 1 + 29 files changed, 1916 insertions(+), 1 deletion(-) create mode 100644 .changeset/quick-kiwis-jump.md create mode 100644 docs/adapters/orcarouter.md create mode 100644 packages/ai-orcarouter/LICENSE create mode 100644 packages/ai-orcarouter/README.md create mode 100644 packages/ai-orcarouter/package.json create mode 100644 packages/ai-orcarouter/src/adapters/summarize.ts create mode 100644 packages/ai-orcarouter/src/adapters/text.ts create mode 100644 packages/ai-orcarouter/src/index.ts create mode 100644 packages/ai-orcarouter/src/message-types.ts create mode 100644 packages/ai-orcarouter/src/model-meta.ts create mode 100644 packages/ai-orcarouter/src/text/text-provider-options.ts create mode 100644 packages/ai-orcarouter/src/utils/client.ts create mode 100644 packages/ai-orcarouter/src/utils/index.ts create mode 100644 packages/ai-orcarouter/tests/orcarouter-adapter.test.ts create mode 100644 packages/ai-orcarouter/tsconfig.json create mode 100644 packages/ai-orcarouter/vite.config.ts create mode 100644 packages/ai-orcarouter/vitest.config.ts diff --git a/.changeset/quick-kiwis-jump.md b/.changeset/quick-kiwis-jump.md new file mode 100644 index 0000000000..adab041b52 --- /dev/null +++ b/.changeset/quick-kiwis-jump.md @@ -0,0 +1,7 @@ +--- +'@tanstack/ai-orcarouter': minor +--- + +Add OrcaRouter provider adapter + +New `@tanstack/ai-orcarouter` package with tree-shakeable `orcaRouterText` (chat) and `orcaRouterSummarize` adapters. OrcaRouter is an OpenAI-compatible gateway exposing a provider/model namespace across many models with adaptive routing, automatic failover, zero-markup inference, observability, guardrails, and agent-tool governance on the same endpoint. diff --git a/README.md b/README.md index c1ceee5ef8..23b5beb12c 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,7 @@ Official adapters include: | Package | Use it for | | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | | [`@tanstack/ai-openrouter`](https://tanstack.com/ai/latest/docs/adapters/openrouter) | 300+ models through one OpenRouter API, with per-request cost tracking | +| [`@tanstack/ai-orcarouter`](https://tanstack.com/ai/latest/docs/adapters/orcarouter) | OrcaRouter chat, tool calling, and structured outputs with adaptive routing, failover, and gateway security | | [`@tanstack/ai-openai`](https://tanstack.com/ai/latest/docs/adapters/openai) | OpenAI chat, image, video, speech, transcription, realtime, and provider tools | | [`@tanstack/ai-anthropic`](https://tanstack.com/ai/latest/docs/adapters/anthropic) | Anthropic Claude chat, thinking, tools, structured outputs, and Vertex Claude | | [`@tanstack/ai-gemini`](https://tanstack.com/ai/latest/docs/adapters/gemini) | Google Gemini chat, image, speech, and audio generation | diff --git a/docs/adapters/orcarouter.md b/docs/adapters/orcarouter.md new file mode 100644 index 0000000000..3302e764a2 --- /dev/null +++ b/docs/adapters/orcarouter.md @@ -0,0 +1,167 @@ +--- +title: OrcaRouter +id: orcarouter-adapter +description: "Route chat, tool calling, and structured outputs across many models with adaptive routing, automatic failover, zero-markup inference, observability, guardrails, and agent-tool governance through OrcaRouter's single OpenAI-compatible endpoint in TanStack AI." +keywords: + - tanstack ai + - orcarouter + - ai gateway + - multi-provider + - unified api + - model router + - agent governance + - adapter +--- + +[OrcaRouter](https://www.orcarouter.ai) is an OpenAI-compatible AI gateway built for both models and agents. Like OpenRouter, it exposes a provider/model namespace across many models — but it also combines adaptive routing, automatic failover, zero-markup inference, observability, guardrails, and agent-tool governance behind the same endpoint. Use the hosted gateway at `api.orcarouter.ai` or point the adapter at your own deployment. + +## Installation + +```bash +npm install @tanstack/ai-orcarouter +``` + +## Basic Usage + +```typescript +import { chat } from "@tanstack/ai"; +import { orcaRouterText } from "@tanstack/ai-orcarouter"; + +const stream = chat({ + adapter: orcaRouterText("openai/gpt-5.5-pro"), + messages: [{ role: "user", content: "Hello!" }], +}); +``` + +`orcaRouterText` reads your API key from the `ORCAROUTER_API_KEY` environment variable. Use `createOrcaRouterText` to pass it explicitly. + +## Configuration + +```typescript +import { createOrcaRouterText } from "@tanstack/ai-orcarouter"; + +const adapter = createOrcaRouterText( + "openai/gpt-5.5-pro", + process.env.ORCAROUTER_API_KEY!, + { + baseURL: "https://api.orcarouter.ai/v1", // Optional — set for self-hosted deployments + }, +); +``` + +OrcaRouter is self-hostable; point `baseURL` at your own deployment to keep the same adapter surface. + +## Available Models + +Any model listed at [orcarouter.ai/models](https://www.orcarouter.ai) works — pass its id as the model name. Model ids use the `provider/model` prefix to pin routing to a specific provider, and the `orcarouter/fusion` family enables adaptive automatic routing across fallback models: + +```text +model: "orcarouter/fusion" // adaptive routing across fallback models +model: "openai/gpt-5.5-pro" // always routed to OpenAI +model: "anthropic/claude-opus-4.8" // always routed to Anthropic +model: "deepseek/deepseek-v4-pro-0813" // always routed to DeepSeek +``` + +A curated set of flagship models (see `ORCAROUTER_CHAT_MODELS`) additionally carries per-model type metadata — input modalities and provider options — with editor autocomplete. Uncurated ids still work and fall back to text-only input with the generic options. + +## Example: Chat Completion + +```typescript +import { chat, toServerSentEventsResponse } from "@tanstack/ai"; +import { orcaRouterText } from "@tanstack/ai-orcarouter"; + +export async function POST(request: Request) { + const { messages } = await request.json(); + + const stream = chat({ + adapter: orcaRouterText("openai/gpt-5.5-pro"), + messages, + }); + + return toServerSentEventsResponse(stream); +} +``` + +## Example: With Tools + +```typescript +import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai"; +import { orcaRouterText } from "@tanstack/ai-orcarouter"; +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" }; +}); + +export async function POST(request: Request) { + const { messages } = await request.json(); + + const stream = chat({ + adapter: orcaRouterText("openai/gpt-5.5-pro"), + messages, + tools: [getWeather], + }); + + return toServerSentEventsResponse(stream); +} +``` + +## Model Options + +The gateway accepts the standard Chat Completions parameters and forwards them to the routed provider (parameters a provider doesn't support are stripped server-side). Sampling parameters live in `modelOptions`: + +```typescript +import { chat } from "@tanstack/ai"; +import { orcaRouterText } from "@tanstack/ai-orcarouter"; + +const stream = chat({ + adapter: orcaRouterText("deepseek/deepseek-v4-pro-0813"), + messages: [{ role: "user", content: "Hello!" }], + modelOptions: { + temperature: 0.7, + max_completion_tokens: 4096, + reasoning_effort: "high", + }, +}); +``` + +`reasoning_effort` accepts the extended scale `none` / `minimal` / `low` / `medium` / `high` / `xhigh` / `max` in addition to OpenAI's standard tiers — which tiers a model honors depends on the model and provider it is routed to. + +Reasoning models stream their thinking as `reasoning_content` deltas, which the adapter surfaces as AG-UI `REASONING_*` events. + +## Summarization + +```typescript +import { summarize } from "@tanstack/ai"; +import { orcaRouterSummarize } from "@tanstack/ai-orcarouter"; + +const result = await summarize({ + adapter: orcaRouterSummarize("openai/gpt-5.5-pro"), + text: "Long article text...", + stream: false, +}); + +console.log(result.summary); +``` + +## Gateway Security + +OrcaRouter also runs gateway-level, zero-trust security for AI agents on the same endpoint — screening every prompt/response and governing every tool call on a default-deny basis, with no application code changes. + +## Environment Variables + +Set your API key in environment variables: + +```bash +ORCAROUTER_API_KEY=sk-orca_your-api-key +``` + +Get an API key from the [OrcaRouter dashboard](https://www.orcarouter.ai). diff --git a/docs/config.json b/docs/config.json index 9ce3b5b915..f2d231191b 100644 --- a/docs/config.json +++ b/docs/config.json @@ -928,6 +928,11 @@ "to": "adapters/llmgateway", "addedAt": "2026-07-29" }, + { + "label": "OrcaRouter Adapter", + "to": "adapters/orcarouter", + "addedAt": "2026-08-29" + }, { "label": "Claude Code", "to": "adapters/claude-code", diff --git a/docs/getting-started/overview.md b/docs/getting-started/overview.md index 33e6e765d8..e8f655d3e8 100644 --- a/docs/getting-started/overview.md +++ b/docs/getting-started/overview.md @@ -125,6 +125,7 @@ With the help of adapters, TanStack AI can connect to various LLM providers. Ava - **@tanstack/ai-byteplus** - BytePlus (Seed chat, Seedance video, Seedream image, Seed Speech) - **@tanstack/ai-fal** - fal (image & video generation) - **@tanstack/ai-llmgateway** - LLM Gateway (hundreds of models via one OpenAI-compatible endpoint, self-hostable) +- **@tanstack/ai-orcarouter** - OrcaRouter (chat, tool calling, and structured outputs with adaptive routing, failover, and gateway security) - **@tanstack/ai-lovable** - Lovable AI Gateway (Google and OpenAI chat, image, video, embeddings, and speech via one project key) ## Next Steps diff --git a/docs/structured-outputs/streaming.md b/docs/structured-outputs/streaming.md index 6041557a4a..a93ec52f48 100644 --- a/docs/structured-outputs/streaming.md +++ b/docs/structured-outputs/streaming.md @@ -187,6 +187,7 @@ Streaming structured output works with **every adapter**, but only some support | `@tanstack/ai-bedrock` | Native stream through Converse or an OpenAI-compatible API | | `@tanstack/ai-byteplus` | Native single-request stream on supported models; unsupported models emit `RUN_ERROR` | | `@tanstack/ai-llmgateway` | Native single-request stream (Chat Completions, `response_format: json_schema`) | +| `@tanstack/ai-orcarouter` | Native single-request stream (Chat Completions, `response_format: json_schema`) | | `@tanstack/ai-lovable` | Native single-request stream (Responses or Chat Completions) | | Other adapters (anthropic, gemini, ollama, …) | Fallback: runs non-streaming `structuredOutput` and emits the final object as one `structured-output.complete` event | diff --git a/packages/ai-orcarouter/LICENSE b/packages/ai-orcarouter/LICENSE new file mode 100644 index 0000000000..8ce4739a8f --- /dev/null +++ b/packages/ai-orcarouter/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Tanner Linsley + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/ai-orcarouter/README.md b/packages/ai-orcarouter/README.md new file mode 100644 index 0000000000..1c1942f64c --- /dev/null +++ b/packages/ai-orcarouter/README.md @@ -0,0 +1,95 @@ +# @tanstack/ai-orcarouter + +[OrcaRouter](https://www.orcarouter.ai) adapter for TanStack AI — one OpenAI-compatible endpoint that routes chat, tool calling, and structured outputs across many models with adaptive routing, automatic failover, zero-markup inference, observability, guardrails, and agent-tool governance. + +## Installation + +```bash +npm install @tanstack/ai-orcarouter +# or +pnpm add @tanstack/ai-orcarouter +# or +yarn add @tanstack/ai-orcarouter +``` + +## Setup + +Get your API key from the [OrcaRouter dashboard](https://www.orcarouter.ai) and set it as an environment variable: + +```bash +export ORCAROUTER_API_KEY="sk-orca_..." +``` + +## Usage + +### Text/Chat Adapter + +```typescript +import { orcaRouterText } from '@tanstack/ai-orcarouter' +import { chat } from '@tanstack/ai' + +const stream = chat({ + adapter: orcaRouterText('openai/gpt-5.5-pro'), + messages: [ + { role: 'user', content: 'Explain quantum computing in simple terms' }, + ], +}) +``` + +### With Explicit API Key + +```typescript +import { createOrcaRouterText } from '@tanstack/ai-orcarouter' + +const adapter = createOrcaRouterText('openai/gpt-5.5-pro', 'sk-orca_api_key') +``` + +### Self-Hosted Gateways + +OrcaRouter is self-hostable — point `baseURL` at your own deployment: + +```typescript +import { createOrcaRouterText } from '@tanstack/ai-orcarouter' + +const adapter = createOrcaRouterText('openai/gpt-5.5-pro', 'sk-orca_api_key', { + baseURL: 'https://gateway.example.com/v1', +}) +``` + +## Models + +Any model listed on [orcarouter.ai/models](https://www.orcarouter.ai) works — pass its id as the model name. A curated set of flagship models additionally carries per-model type metadata (input modalities, provider options) with autocomplete, including `openai/gpt-5.5-pro`, `anthropic/claude-opus-4.8`, `google/gemini-3.1-pro-preview`, `deepseek/deepseek-v4-pro-0813`, and more (see `ORCAROUTER_CHAT_MODELS`). + +Model ids use the `provider/model` prefix to pin routing to a specific provider, and `orcarouter/fusion` enables adaptive automatic routing across fallback models: + +```typescript +orcaRouterText('orcarouter/fusion') // adaptive routing across fallback models +orcaRouterText('openai/gpt-5.5-pro') // pinned to OpenAI +``` + +## Features + +- ✅ Streaming chat completions +- ✅ Structured output (JSON Schema) +- ✅ Function/tool calling +- ✅ Multimodal input (text + images for vision models) +- ✅ Reasoning output (`reasoning_content` deltas from reasoning models) +- ✅ Summarization (`orcaRouterSummarize`) +- ✅ Adaptive routing, automatic failover, observability, guardrails, and agent-tool governance — applied gateway-side, no application code changes + +## Tree-Shakeable Adapters + +This package uses tree-shakeable adapters, so you only import what you need: + +```typescript +// Text/chat only +import { orcaRouterText } from '@tanstack/ai-orcarouter' + +// Summarization only +import { orcaRouterSummarize } from '@tanstack/ai-orcarouter' +``` + +## Documentation + +- [TanStack AI Documentation](https://tanstack.com/ai) +- [OrcaRouter Documentation](https://www.orcarouter.ai) diff --git a/packages/ai-orcarouter/package.json b/packages/ai-orcarouter/package.json new file mode 100644 index 0000000000..d666614200 --- /dev/null +++ b/packages/ai-orcarouter/package.json @@ -0,0 +1,77 @@ +{ + "name": "@tanstack/ai-orcarouter", + "version": "0.1.0", + "description": "OrcaRouter adapter for TanStack AI — adaptive routing, automatic failover, zero-markup inference, observability, guardrails, and agent-tool governance through one OpenAI-compatible gateway.", + "author": "Tanner Linsley", + "license": "MIT", + "homepage": "https://tanstack.com/ai", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-orcarouter" + }, + "bugs": { + "url": "https://github.com/TanStack/ai/issues" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + }, + "./byok": { + "types": "./dist/esm/byok.d.ts", + "import": "./dist/esm/byok.js" + }, + "./tools": { + "types": "./dist/esm/tools/index.d.ts", + "import": "./dist/esm/tools/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "lint:fix": "oxlint src --type-aware --fix", + "test:build": "publint --strict", + "test:oxlint": "oxlint src --type-aware", + "test:lib": "vitest run", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc" + }, + "keywords": [ + "ai", + "ai-sdk", + "typescript", + "tanstack", + "orcarouter", + "adapter", + "llm", + "chat", + "gateway", + "tool-calling", + "structured-outputs", + "model-router" + ], + "devDependencies": { + "@vitest/coverage-v8": "4.1.10", + "vite": "^8.2.1" + }, + "peerDependencies": { + "@tanstack/ai": "workspace:^" + }, + "dependencies": { + "@tanstack/ai-utils": "workspace:^", + "@tanstack/openai-base": "workspace:^", + "openai": "^6.41.0" + } +} diff --git a/packages/ai-orcarouter/src/adapters/summarize.ts b/packages/ai-orcarouter/src/adapters/summarize.ts new file mode 100644 index 0000000000..c8d9643949 --- /dev/null +++ b/packages/ai-orcarouter/src/adapters/summarize.ts @@ -0,0 +1,83 @@ +import { ChatStreamSummarizeAdapter } from '@tanstack/ai/adapters' +import { getOrcaRouterApiKeyFromEnv } from '../utils/client' +import { OrcaRouterTextAdapter } from './text' +import type { InferTextProviderOptions } from '@tanstack/ai/adapters' +import type { OrcaRouterModelId } from '../model-meta' +import type { OrcaRouterClientConfig } from '../utils/client' + +/** + * Configuration for the OrcaRouter summarize adapter + */ +export interface OrcaRouterSummarizeConfig extends OrcaRouterClientConfig {} + +/** Model type for OrcaRouter summarization */ +export type OrcaRouterSummarizeModel = OrcaRouterModelId + +/** + * Creates an OrcaRouter summarize adapter with explicit API key. + * Type resolution happens here at the call site. + * + * @param model - The model id (e.g., 'openai/gpt-5.5') + * @param apiKey - Your OrcaRouter API key + * @param config - Optional additional configuration + * @returns Configured OrcaRouter summarize adapter instance with resolved types + * + * @example + * ```typescript + * const adapter = createOrcaRouterSummarize('openai/gpt-5.5', "sk-orca-..."); + * ``` + */ +export function createOrcaRouterSummarize< + TModel extends OrcaRouterSummarizeModel, +>( + model: TModel, + apiKey: string, + config?: Omit, +): ChatStreamSummarizeAdapter< + TModel, + InferTextProviderOptions> +> { + return new ChatStreamSummarizeAdapter( + new OrcaRouterTextAdapter({ apiKey, ...config }, model), + model, + 'orcarouter', + ) +} + +/** + * Creates an OrcaRouter summarize adapter with automatic API key detection + * from environment variables. Type resolution happens here at the call site. + * + * Looks for `ORCAROUTER_API_KEY` in: + * - `process.env` (Node.js) + * - `window.env` (Browser with injected env) + * + * @param model - The model id (e.g., 'openai/gpt-5.5') + * @param config - Optional configuration (excluding apiKey which is auto-detected) + * @returns Configured OrcaRouter summarize adapter instance with resolved types + * @throws Error if ORCAROUTER_API_KEY is not found in environment + * + * @example + * ```typescript + * // Automatically uses ORCAROUTER_API_KEY from environment + * const adapter = orcaRouterSummarize('openai/gpt-5.5'); + * + * await summarize({ + * adapter, + * text: "Long article text..." + * }); + * ``` + */ +export function orcaRouterSummarize( + model: TModel, + config?: Omit, +): ChatStreamSummarizeAdapter< + TModel, + InferTextProviderOptions> +> { + return createOrcaRouterSummarize( + model, + getOrcaRouterApiKeyFromEnv(), + config, + ) +} diff --git a/packages/ai-orcarouter/src/adapters/text.ts b/packages/ai-orcarouter/src/adapters/text.ts new file mode 100644 index 0000000000..2e8c667ebc --- /dev/null +++ b/packages/ai-orcarouter/src/adapters/text.ts @@ -0,0 +1,121 @@ +import OpenAI from 'openai' +import { OpenAIBaseChatCompletionsTextAdapter } from '@tanstack/openai-base' +import { + getOrcaRouterApiKeyFromEnv, + withOrcaRouterDefaults, +} from '../utils/client' +import type { Modality } from '@tanstack/ai' +import type { + OrcaRouterChatModelToolCapabilitiesByName, + OrcaRouterModelId, + ResolveInputModalities, + ResolveProviderOptions, +} from '../model-meta' +import type { OrcaRouterMessageMetadataByModality } from '../message-types' +import type { OrcaRouterClientConfig } from '../utils/client' + +type ResolveToolCapabilities = + TModel extends keyof OrcaRouterChatModelToolCapabilitiesByName + ? NonNullable + : readonly [] + +/** + * Configuration for the OrcaRouter text adapter + */ +export interface OrcaRouterTextConfig extends OrcaRouterClientConfig {} + +/** + * Re-export of the public provider options type + */ +export type { ExternalTextProviderOptions as OrcaRouterTextProviderOptions } from '../text/text-provider-options' + +/** + * OrcaRouter Text (Chat) Adapter + * + * Tree-shakeable adapter for OrcaRouter chat/text completion. OrcaRouter + * exposes one OpenAI-compatible Chat Completions endpoint that routes to + * many models across providers — with adaptive routing, automatic + * failover, observability, guardrails, and agent-tool governance applied + * gateway-side — so the adapter drives it with the OpenAI SDK via a + * `baseURL` override (the same pattern as `ai-groq`, `ai-grok`, and + * `ai-llmgateway`). + * + * Model ids are open-ended: curated ids get per-model type metadata, and + * any other id from https://www.orcarouter.ai works with text-only + * defaults. A `provider/model` id (e.g. `openai/gpt-5.5`) pins routing to + * that provider; a bare id lets the gateway route adaptively. + */ +export class OrcaRouterTextAdapter< + TModel extends OrcaRouterModelId, + TProviderOptions extends Record = ResolveProviderOptions, + TInputModalities extends ReadonlyArray = + ResolveInputModalities, + TToolCapabilities extends ReadonlyArray = + ResolveToolCapabilities, +> extends OpenAIBaseChatCompletionsTextAdapter< + TModel, + TProviderOptions, + TInputModalities, + OrcaRouterMessageMetadataByModality, + TToolCapabilities +> { + override readonly kind = 'text' as const + override readonly name = 'orcarouter' as const + + constructor(config: OrcaRouterTextConfig, model: TModel) { + super(model, 'orcarouter', new OpenAI(withOrcaRouterDefaults(config))) + } + + /** + * Surfaces reasoning deltas during streaming. OrcaRouter normalizes + * upstream reasoning output to `delta.reasoning_content` on the OpenAI + * Chat Completions wire format (the field most OpenAI-compatible + * providers emit); some routed providers emit `delta.reasoning` instead, + * so both are read. + */ + protected override extractReasoning( + chunk: OpenAI.Chat.Completions.ChatCompletionChunk, + ): { text: string } | undefined { + const delta = chunk.choices[0]?.delta as + | { reasoning?: unknown; reasoning_content?: unknown } + | undefined + const raw = delta?.reasoning_content ?? delta?.reasoning + if (typeof raw === 'string' && raw.length > 0) { + return { text: raw } + } + return undefined + } +} + +/** + * Creates an OrcaRouter text adapter with explicit API key. + * + * @example + * ```typescript + * const adapter = createOrcaRouterText('openai/gpt-5.5', "sk-orca-..."); + * ``` + */ +export function createOrcaRouterText( + model: TModel, + apiKey: string, + config?: Omit, +): OrcaRouterTextAdapter { + return new OrcaRouterTextAdapter({ apiKey, ...config }, model) +} + +/** + * Creates an OrcaRouter text adapter with API key from + * `ORCAROUTER_API_KEY`. + * + * @example + * ```typescript + * const adapter = orcaRouterText('openai/gpt-5.5'); + * ``` + */ +export function orcaRouterText( + model: TModel, + config?: Omit, +): OrcaRouterTextAdapter { + const apiKey = getOrcaRouterApiKeyFromEnv() + return createOrcaRouterText(model, apiKey, config) +} diff --git a/packages/ai-orcarouter/src/index.ts b/packages/ai-orcarouter/src/index.ts new file mode 100644 index 0000000000..153601a37b --- /dev/null +++ b/packages/ai-orcarouter/src/index.ts @@ -0,0 +1,53 @@ +/** + * @module @tanstack/ai-orcarouter + * + * OrcaRouter provider adapter for TanStack AI. + * Provides tree-shakeable adapters for OrcaRouter's OpenAI-compatible Chat + * Completions API, which routes one endpoint to many models across providers + * with adaptive routing, automatic failover, zero-markup inference, + * observability, guardrails, and agent-tool governance. + */ + +// Text (Chat) adapter +export { + OrcaRouterTextAdapter, + createOrcaRouterText, + orcaRouterText, + type OrcaRouterTextConfig, + type OrcaRouterTextProviderOptions, +} from './adapters/text' + +// Summarize - thin factory functions over @tanstack/ai's ChatStreamSummarizeAdapter +export { + createOrcaRouterSummarize, + orcaRouterSummarize, + type OrcaRouterSummarizeConfig, + type OrcaRouterSummarizeModel, +} from './adapters/summarize' + +// Types +export type { + OrcaRouterChatModelProviderOptionsByName, + OrcaRouterChatModelToolCapabilitiesByName, + OrcaRouterModelInputModalitiesByName, + ResolveProviderOptions, + ResolveInputModalities, + OrcaRouterChatModels, + OrcaRouterModelId, +} from './model-meta' +export { ORCAROUTER_CHAT_MODELS } from './model-meta' +export type { + OrcaRouterTextMetadata, + OrcaRouterImageMetadata, + OrcaRouterAudioMetadata, + OrcaRouterVideoMetadata, + OrcaRouterDocumentMetadata, + OrcaRouterMessageMetadataByModality, +} from './message-types' + +// Utils +export { + getOrcaRouterApiKeyFromEnv, + withOrcaRouterDefaults, + type OrcaRouterClientConfig, +} from './utils/client' diff --git a/packages/ai-orcarouter/src/message-types.ts b/packages/ai-orcarouter/src/message-types.ts new file mode 100644 index 0000000000..b70156f892 --- /dev/null +++ b/packages/ai-orcarouter/src/message-types.ts @@ -0,0 +1,131 @@ +/** + * OrcaRouter-specific message types for the Chat Completions API. + * + * OrcaRouter's wire format is OpenAI Chat Completions — the gateway + * translates it to each routed provider's native format server-side, and + * applies adaptive routing, failover, and guardrails in the same pass. + * These type definitions describe that wire shape directly; the adapter + * drives the endpoint with the OpenAI SDK pointed at OrcaRouter's base URL. + * + * @see https://www.orcarouter.ai + */ + +export interface ChatCompletionNamedToolChoice { + /** Always `function` for a named tool choice. */ + type: 'function' + function: { + /** The name of the function to call. */ + name: string + } +} + +/** + * Controls which (if any) tool is called by the model. + * + * - `none` — the model will not call any tool and instead generates a message + * - `auto` — the model can pick between generating a message or calling tools + * - `required` — the model must call one or more tools + * - Named tool choice — forces the model to call a specific tool + */ +export type ChatCompletionToolChoiceOption = + | 'none' + | 'auto' + | 'required' + | ChatCompletionNamedToolChoice + +export interface ResponseFormatText { + /** The type of response format being defined. Always `text`. */ + type: 'text' +} + +export interface ResponseFormatJsonSchemaJsonSchema { + /** + * The name of the response format. Must be a-z, A-Z, 0-9, or contain + * underscores and dashes, with a maximum length of 64. + */ + name: string + + /** + * A description of what the response format is for, used by the model to + * determine how to respond in the format. + */ + description?: string + + /** + * The schema for the response format, described as a JSON Schema object. + * @see https://json-schema.org/ + */ + schema?: { [key: string]: unknown } + + /** + * Whether to enable strict schema adherence when generating the output. If + * set to true, the model will always follow the exact schema defined in the + * `schema` field. Only a subset of JSON Schema is supported when `strict` + * is `true`. + */ + strict?: boolean | null +} + +export interface ResponseFormatJsonSchema { + /** Structured Outputs configuration options, including a JSON Schema. */ + json_schema: ResponseFormatJsonSchemaJsonSchema + + /** The type of response format being defined. Always `json_schema`. */ + type: 'json_schema' +} + +export interface ResponseFormatJsonObject { + /** The type of response format being defined. Always `json_object`. */ + type: 'json_object' +} + +/** + * Metadata for OrcaRouter document content parts. + */ +export interface OrcaRouterDocumentMetadata {} + +/** + * Metadata for OrcaRouter text content parts. + * Currently no specific metadata options for text. + */ +export interface OrcaRouterTextMetadata {} + +/** + * Metadata for OrcaRouter image content parts. + * Controls how the model processes and analyzes images. + */ +export interface OrcaRouterImageMetadata { + /** + * Specifies the detail level of the image. + * - 'auto': Let the model decide based on image size and content + * - 'low': Use low resolution processing (faster, cheaper, less detail) + * - 'high': Use high resolution processing (slower, more expensive, more detail) + * + * @default 'auto' + */ + detail?: 'auto' | 'low' | 'high' +} + +/** + * Metadata for OrcaRouter audio content parts. + * Note: audio input support depends on the routed model. + */ +export interface OrcaRouterAudioMetadata {} + +/** + * Metadata for OrcaRouter video content parts. + * Note: video input support depends on the routed model. + */ +export interface OrcaRouterVideoMetadata {} + +/** + * Map of modality types to their OrcaRouter-specific metadata types. + * Used for type inference when constructing multimodal messages. + */ +export interface OrcaRouterMessageMetadataByModality { + text: OrcaRouterTextMetadata + image: OrcaRouterImageMetadata + audio: OrcaRouterAudioMetadata + video: OrcaRouterVideoMetadata + document: OrcaRouterDocumentMetadata +} diff --git a/packages/ai-orcarouter/src/model-meta.ts b/packages/ai-orcarouter/src/model-meta.ts new file mode 100644 index 0000000000..8cc4e9d898 --- /dev/null +++ b/packages/ai-orcarouter/src/model-meta.ts @@ -0,0 +1,314 @@ +import type { OrcaRouterTextProviderOptions } from './text/text-provider-options' + +/** + * Internal metadata structure describing an OrcaRouter model's capabilities + * and pricing. + * + * OrcaRouter routes hundreds of models from many providers through one + * OpenAI-compatible endpoint. This file curates a set of flagship models + * with per-model metadata for type safety; any model listed on + * https://www.orcarouter.ai works at runtime — pass its id with a type + * assertion, or prefer a curated model for full type support. Prices are + * USD per million tokens and follow the gateway's provider-passthrough + * pricing (they may drift; the models page is the source of truth). + * + * Model ids use a `provider/model` prefix (e.g. `openai/gpt-5.5-pro`) to + * pin routing to a specific provider — the same convention as OpenRouter. + * The `orcarouter/fusion` family enables automatic adaptive routing across + * fallback models. + */ +interface ModelMeta { + name: string + context_window?: number + max_completion_tokens?: number + pricing: { + input?: { normal: number; cached?: number } + output?: { normal: number } + } + supports: { + input: Array<'text' | 'image' | 'audio'> + output: Array<'text'> + endpoints: Array<'chat'> + features: Array< + | 'streaming' + | 'tools' + | 'json_object' + | 'json_schema' + | 'reasoning' + | 'vision' + > + tools?: ReadonlyArray + } + /** + * Type-level description of which provider options this model supports. + */ + providerOptions?: TProviderOptions +} + +const ORCAROUTER_FUSION = { + name: 'orcarouter/fusion', + context_window: 1_000_000, + max_completion_tokens: 128_000, + pricing: { + input: { + normal: 0, + }, + output: { + normal: 0, + }, + }, + supports: { + input: ['text', 'image'], + output: ['text'], + endpoints: ['chat'], + features: [ + 'streaming', + 'tools', + 'json_object', + 'json_schema', + 'reasoning', + 'vision', + ], + tools: [] as const, + }, +} as const satisfies ModelMeta + +const GPT_5_5_PRO = { + name: 'openai/gpt-5.5-pro', + context_window: 1_050_000, + max_completion_tokens: 128_000, + pricing: { + input: { + normal: 2.5, + cached: 0.25, + }, + output: { + normal: 15, + }, + }, + supports: { + input: ['text', 'image'], + output: ['text'], + endpoints: ['chat'], + features: [ + 'streaming', + 'tools', + 'json_object', + 'json_schema', + 'reasoning', + 'vision', + ], + tools: [] as const, + }, +} as const satisfies ModelMeta + +const CLAUDE_OPUS_4_8 = { + name: 'anthropic/claude-opus-4.8', + context_window: 1_000_000, + max_completion_tokens: 128_000, + pricing: { + input: { + normal: 5, + cached: 0.5, + }, + output: { + normal: 25, + }, + }, + supports: { + input: ['text', 'image'], + output: ['text'], + endpoints: ['chat'], + features: ['streaming', 'tools', 'reasoning', 'vision'], + tools: [] as const, + }, +} as const satisfies ModelMeta + +const CLAUDE_SONNET_5 = { + name: 'anthropic/claude-sonnet-5', + context_window: 1_000_000, + max_completion_tokens: 128_000, + pricing: { + input: { + normal: 2, + cached: 0.2, + }, + output: { + normal: 10, + }, + }, + supports: { + input: ['text', 'image'], + output: ['text'], + endpoints: ['chat'], + features: ['streaming', 'tools', 'reasoning', 'vision'], + tools: [] as const, + }, +} as const satisfies ModelMeta + +const GEMINI_3_1_PRO_PREVIEW = { + name: 'google/gemini-3.1-pro-preview', + context_window: 1_048_576, + max_completion_tokens: 65_536, + pricing: { + input: { + normal: 2, + cached: 0.2, + }, + output: { + normal: 12, + }, + }, + supports: { + input: ['text', 'image'], + output: ['text'], + endpoints: ['chat'], + features: [ + 'streaming', + 'tools', + 'json_object', + 'json_schema', + 'reasoning', + 'vision', + ], + tools: [] as const, + }, +} as const satisfies ModelMeta + +const DEEPSEEK_V4_PRO = { + name: 'deepseek/deepseek-v4-pro-0813', + context_window: 1_050_000, + max_completion_tokens: 393_216, + pricing: { + input: { + normal: 0.435, + }, + output: { + normal: 0.87, + }, + }, + supports: { + input: ['text'], + output: ['text'], + endpoints: ['chat'], + features: ['streaming', 'tools', 'json_object', 'json_schema', 'reasoning'], + tools: [] as const, + }, +} as const satisfies ModelMeta + +const GROK_4_3 = { + name: 'grok/grok-4.3', + context_window: 500_000, + pricing: { + input: { + normal: 2, + cached: 0.5, + }, + output: { + normal: 6, + }, + }, + supports: { + input: ['text', 'image'], + output: ['text'], + endpoints: ['chat'], + features: [ + 'streaming', + 'tools', + 'json_object', + 'json_schema', + 'reasoning', + 'vision', + ], + tools: [] as const, + }, +} as const satisfies ModelMeta + +/** + * Curated OrcaRouter chat model identifiers. + * + * Any model on https://www.orcarouter.ai works at runtime; these curated + * entries carry per-model type metadata (input modalities, provider + * options). + */ +export const ORCAROUTER_CHAT_MODELS = [ + ORCAROUTER_FUSION.name, + GPT_5_5_PRO.name, + CLAUDE_OPUS_4_8.name, + CLAUDE_SONNET_5.name, + GEMINI_3_1_PRO_PREVIEW.name, + DEEPSEEK_V4_PRO.name, + GROK_4_3.name, +] as const + +/** + * Union type of all curated OrcaRouter chat model names. + */ +export type OrcaRouterChatModels = (typeof ORCAROUTER_CHAT_MODELS)[number] + +/** + * Model id accepted by the OrcaRouter adapters: a curated model name (with + * autocomplete and per-model type metadata) or any other model id from + * https://www.orcarouter.ai, prefixed with `provider/` to pin routing to a + * specific provider. `orcarouter/fusion` enables adaptive automatic routing. + * Uncurated ids fall back to text-only input and the generic provider + * options. + */ +export type OrcaRouterModelId = OrcaRouterChatModels | (string & {}) + +/** + * Type-only map from OrcaRouter chat model name to its supported input + * modalities. + */ +export type OrcaRouterModelInputModalitiesByName = { + [ORCAROUTER_FUSION.name]: typeof ORCAROUTER_FUSION.supports.input + [GPT_5_5_PRO.name]: typeof GPT_5_5_PRO.supports.input + [CLAUDE_OPUS_4_8.name]: typeof CLAUDE_OPUS_4_8.supports.input + [CLAUDE_SONNET_5.name]: typeof CLAUDE_SONNET_5.supports.input + [GEMINI_3_1_PRO_PREVIEW.name]: typeof GEMINI_3_1_PRO_PREVIEW.supports.input + [DEEPSEEK_V4_PRO.name]: typeof DEEPSEEK_V4_PRO.supports.input + [GROK_4_3.name]: typeof GROK_4_3.supports.input +} + +/** + * Type-only map from OrcaRouter chat model name to its provider options + * type. + */ +export type OrcaRouterChatModelProviderOptionsByName = { + [K in (typeof ORCAROUTER_CHAT_MODELS)[number]]: OrcaRouterTextProviderOptions +} + +/** + * Type-only map from OrcaRouter chat model name to its supported provider + * tools. OrcaRouter exposes no provider-specific tool factories, so every + * model gets an empty tuple. This ensures that passing an Anthropic/OpenAI + * ProviderTool to an OrcaRouter adapter produces a compile-time type error. + */ +export type OrcaRouterChatModelToolCapabilitiesByName = { + [ORCAROUTER_FUSION.name]: typeof ORCAROUTER_FUSION.supports.tools + [GPT_5_5_PRO.name]: typeof GPT_5_5_PRO.supports.tools + [CLAUDE_OPUS_4_8.name]: typeof CLAUDE_OPUS_4_8.supports.tools + [CLAUDE_SONNET_5.name]: typeof CLAUDE_SONNET_5.supports.tools + [GEMINI_3_1_PRO_PREVIEW.name]: typeof GEMINI_3_1_PRO_PREVIEW.supports.tools + [DEEPSEEK_V4_PRO.name]: typeof DEEPSEEK_V4_PRO.supports.tools + [GROK_4_3.name]: typeof GROK_4_3.supports.tools +} + +/** + * Resolves the provider options type for a specific OrcaRouter model. + * Falls back to the generic options for uncurated model ids. + */ +export type ResolveProviderOptions = + TModel extends keyof OrcaRouterChatModelProviderOptionsByName + ? OrcaRouterChatModelProviderOptionsByName[TModel] + : OrcaRouterTextProviderOptions + +/** + * Resolve input modalities for a specific model. + * If the model has explicit modalities in the map, use those; otherwise use + * text only. + */ +export type ResolveInputModalities = + TModel extends keyof OrcaRouterModelInputModalitiesByName + ? OrcaRouterModelInputModalitiesByName[TModel] + : readonly ['text'] diff --git a/packages/ai-orcarouter/src/text/text-provider-options.ts b/packages/ai-orcarouter/src/text/text-provider-options.ts new file mode 100644 index 0000000000..bcdc653bc4 --- /dev/null +++ b/packages/ai-orcarouter/src/text/text-provider-options.ts @@ -0,0 +1,127 @@ +import type { + ChatCompletionToolChoiceOption, + ResponseFormatJsonObject, + ResponseFormatJsonSchema, + ResponseFormatText, +} from '../message-types' + +/** + * OrcaRouter provider options for text/chat models. + * + * OrcaRouter exposes the OpenAI Chat Completions wire format and routes + * each request to the underlying provider, so these are the standard Chat + * Completions parameters. Parameters a routed provider doesn't support are + * stripped by the gateway before the request is forwarded upstream. + * + * @see https://www.orcarouter.ai + */ +export interface OrcaRouterTextProviderOptions { + /** + * Number between -2.0 and 2.0. Positive values penalize new tokens based on + * their existing frequency in the text so far, decreasing the model's + * likelihood to repeat the same line verbatim. + */ + frequency_penalty?: number | null + + /** + * The maximum number of tokens that can be generated in the chat + * completion. Deprecated by OpenAI in favor of `max_completion_tokens`, + * but still accepted by the gateway and translated per provider. + */ + max_tokens?: number | null + + /** + * An upper bound for the number of tokens that can be generated for a + * completion, including visible output tokens and reasoning tokens. + */ + max_completion_tokens?: number | null + + /** Whether to enable parallel function calling during tool use. */ + parallel_tool_calls?: boolean | null + + /** + * Number between -2.0 and 2.0. Positive values penalize new tokens based on + * whether they appear in the text so far, increasing the model's likelihood + * to talk about new topics. + */ + presence_penalty?: number | null + + /** + * Controls reasoning effort for reasoning-capable models. + * + * The gateway accepts the extended effort scale in addition to OpenAI's + * `low` / `medium` / `high`; which tiers a given model honors depends on + * the model and the provider it is routed to. + */ + reasoning_effort?: + | 'none' + | 'minimal' + | 'low' + | 'medium' + | 'high' + | 'xhigh' + | 'max' + | null + + /** + * An object specifying the format that the model must output. + * + * - `json_schema` — enables Structured Outputs (preferred) + * - `json_object` — enables the older JSON mode + * - `text` — plain text output (default) + */ + response_format?: + | ResponseFormatText + | ResponseFormatJsonSchema + | ResponseFormatJsonObject + | null + + /** + * If specified, the gateway forwards the seed so providers that support it + * can sample deterministically. Determinism is not guaranteed. + */ + seed?: number | null + + /** + * Up to 4 sequences where the API will stop generating further tokens. + * The returned text will not contain the stop sequence. + */ + stop?: string | null | Array + + /** + * Sampling temperature between 0 and 2. Higher values like 0.8 make the + * output more random, while lower values like 0.2 make it more focused and + * deterministic. We generally recommend altering this or `top_p` but not + * both. + */ + temperature?: number | null + + /** + * Controls which (if any) tool is called by the model. + * + * - `none` — never call tools + * - `auto` — model decides (default when tools are present) + * - `required` — model must call tools + * - Named choice — forces a specific tool + */ + tool_choice?: ChatCompletionToolChoiceOption | null + + /** + * An alternative to sampling with temperature, called nucleus sampling, + * where the model considers the results of the tokens with top_p + * probability mass. So 0.1 means only the tokens comprising the top 10% + * probability mass are considered. + */ + top_p?: number | null + + /** + * A unique identifier representing your end-user, which can help monitor + * and detect abuse. + */ + user?: string | null +} + +/** + * External provider options (what users pass in) + */ +export type ExternalTextProviderOptions = OrcaRouterTextProviderOptions diff --git a/packages/ai-orcarouter/src/utils/client.ts b/packages/ai-orcarouter/src/utils/client.ts new file mode 100644 index 0000000000..8a2598cfca --- /dev/null +++ b/packages/ai-orcarouter/src/utils/client.ts @@ -0,0 +1,35 @@ +import { getApiKeyFromEnv } from '@tanstack/ai-utils' +import type { ClientOptions } from 'openai' + +export interface OrcaRouterClientConfig extends Omit { + apiKey: string +} + +/** + * Gets the OrcaRouter API key from environment variables. + * @throws Error if ORCAROUTER_API_KEY is not found + */ +export function getOrcaRouterApiKeyFromEnv(): string { + try { + return getApiKeyFromEnv('ORCAROUTER_API_KEY') + } catch { + throw new Error( + 'ORCAROUTER_API_KEY is required. Please set it in your environment variables or use the factory function with an explicit API key.', + ) + } +} + +/** + * Returns an OrcaRouter client config with OrcaRouter's OpenAI-compatible + * base URL applied when not already set. OrcaRouter exposes the OpenAI Chat + * Completions wire format verbatim, so the adapter drives it with the OpenAI + * SDK and this baseURL — the same pattern as the Groq and Grok adapters. + */ +export function withOrcaRouterDefaults( + config: OrcaRouterClientConfig, +): OrcaRouterClientConfig { + return { + ...config, + baseURL: config.baseURL || 'https://api.orcarouter.ai/v1', + } +} diff --git a/packages/ai-orcarouter/src/utils/index.ts b/packages/ai-orcarouter/src/utils/index.ts new file mode 100644 index 0000000000..d81b4caddf --- /dev/null +++ b/packages/ai-orcarouter/src/utils/index.ts @@ -0,0 +1,5 @@ +export { + getOrcaRouterApiKeyFromEnv, + withOrcaRouterDefaults, + type OrcaRouterClientConfig, +} from './client' diff --git a/packages/ai-orcarouter/tests/orcarouter-adapter.test.ts b/packages/ai-orcarouter/tests/orcarouter-adapter.test.ts new file mode 100644 index 0000000000..93186f68fb --- /dev/null +++ b/packages/ai-orcarouter/tests/orcarouter-adapter.test.ts @@ -0,0 +1,567 @@ +import { + describe, + it, + expect, + vi, + afterEach, + beforeEach, + type Mock, +} from 'vitest' +import { resolveDebugOption } from '@tanstack/ai/adapter-internals' +import { + createOrcaRouterText as _realCreateOrcaRouterText, + orcaRouterText as _realOrcaRouterText, +} from '../src/adapters/text' +import { createOrcaRouterSummarize } from '../src/adapters/summarize' +import { withOrcaRouterDefaults } from '../src/utils/client' +import type { StreamChunk } from '@tanstack/ai' +import type { OrcaRouterTextProviderOptions } from '../src/index' + +// Test helper: a silent logger for test chatStream calls. +const testLogger = resolveDebugOption(false) + +// Stub the OpenAI SDK so adapter construction doesn't open a real network +// handle. The per-test mock client is injected post-construction via +// `setupMockSdkClient` (mirrors the ai-groq/ai-llmgateway pattern). We avoid +// relying on vi.mock to intercept transitive openai imports — the built +// openai-base dist resolves `openai` independently and is unaffected by +// vi.mock here. +vi.mock('openai', () => { + return { + default: class { + chat = { + completions: { + create: vi.fn(), + }, + } + }, + } +}) + +// Helper to create async iterable from chunks +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 } + }, + } + }, + } +} + +// Sets up a mock client on the most recently created adapter. Tests use the +// existing call order: `setupMockSdkClient(chunks)` first, then `const +// adapter = createOrcaRouterText(...)`. The wrapped factories below apply +// the pending mock to the returned adapter so it intercepts subsequent +// chatStream/structuredOutput calls. +let pendingMockCreate: Mock<(...args: Array) => unknown> | undefined + +function setupMockSdkClient( + streamChunks: Array>, + nonStreamResponse?: Record, +): Mock<(...args: Array) => unknown> { + pendingMockCreate = vi.fn().mockImplementation((params) => { + if (params.stream) { + return Promise.resolve(createAsyncIterable(streamChunks)) + } + return Promise.resolve(nonStreamResponse) + }) + return pendingMockCreate +} + +function applyPendingMock(adapter: T): T { + if (pendingMockCreate) { + ;(adapter as any).client = { + chat: { completions: { create: pendingMockCreate } }, + } + pendingMockCreate = undefined + } + return adapter +} +const createOrcaRouterText: typeof _realCreateOrcaRouterText = ( + model, + apiKey, + config, +) => applyPendingMock(_realCreateOrcaRouterText(model, apiKey, config)) +const orcaRouterText: typeof _realOrcaRouterText = (model, config) => + applyPendingMock(_realOrcaRouterText(model, config)) + +// `createOrcaRouterSummarize` builds its own `OrcaRouterTextAdapter` +// internally, so the wrapped text factories above can't reach it. Apply the +// pending mock to the wrapper's private `textAdapter` instead, so summarize +// tests can assert on the request that actually goes out on the wire. +function applyPendingMockToSummarize(adapter: T): T { + const inner = (adapter as any).textAdapter + if (inner) applyPendingMock(inner) + return adapter +} + +describe('OrcaRouter adapters', () => { + // Reset the module-level `pendingMockCreate` between tests so a previous + // test's setupMockSdkClient call can't leak into a later test that + // instantiates the adapter without setting up a mock. + beforeEach(() => { + pendingMockCreate = undefined + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + describe('Client config', () => { + it('defaults baseURL to the hosted gateway', () => { + const config = withOrcaRouterDefaults({ apiKey: 'sk-orca_test' }) + expect(config.baseURL).toBe('https://api.orcarouter.ai/v1') + }) + + it('keeps an explicit baseURL', () => { + const config = withOrcaRouterDefaults({ + apiKey: 'sk-orca_test', + baseURL: 'https://gateway.example.com/v1', + }) + expect(config.baseURL).toBe('https://gateway.example.com/v1') + }) + }) + + describe('Text adapter', () => { + it('creates a text adapter with explicit API key', () => { + const adapter = createOrcaRouterText('openai/gpt-5.5-pro', 'test-api-key') + + expect(adapter).toBeDefined() + expect(adapter.kind).toBe('text') + expect(adapter.name).toBe('orcarouter') + expect(adapter.model).toBe('openai/gpt-5.5-pro') + }) + + it('creates a text adapter from environment variable', () => { + vi.stubEnv('ORCAROUTER_API_KEY', 'env-api-key') + + const adapter = orcaRouterText('anthropic/claude-sonnet-5') + + expect(adapter).toBeDefined() + expect(adapter.kind).toBe('text') + expect(adapter.model).toBe('anthropic/claude-sonnet-5') + }) + + it('throws if ORCAROUTER_API_KEY is not set when using orcaRouterText', () => { + vi.stubEnv('ORCAROUTER_API_KEY', '') + + expect(() => orcaRouterText('openai/gpt-5.5-pro')).toThrow( + 'ORCAROUTER_API_KEY', + ) + }) + + it('accepts uncurated and adaptive-routing model ids', () => { + // OrcaRouter routes hundreds of models; ids outside the curated list + // (including `provider/model` pins and `orcarouter/fusion`) must remain + // valid at both type level and runtime. + const adapter = createOrcaRouterText( + 'orcarouter/fusion', + 'test-api-key', + ) + + expect(adapter).toBeDefined() + expect(adapter.model).toBe('orcarouter/fusion') + }) + + it('allows custom baseURL override', () => { + const adapter = createOrcaRouterText( + 'openai/gpt-5.5-pro', + 'test-api-key', + { + baseURL: 'https://gateway.example.com/v1', + }, + ) + + expect(adapter).toBeDefined() + }) + + it('forwards sampling options from modelOptions', async () => { + const streamChunks = [ + { + id: 'chatcmpl-sampling', + model: 'openai/gpt-5.5-pro', + choices: [{ delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 0, total_tokens: 1 }, + }, + ] + + const mockCreate = setupMockSdkClient(streamChunks) + const adapter = createOrcaRouterText( + 'openai/gpt-5.5-pro', + 'test-api-key', + ) + + const modelOptions: OrcaRouterTextProviderOptions = { + temperature: 0.5, + top_p: 0.8, + max_completion_tokens: 128, + reasoning_effort: 'high', + } + + for await (const _ of adapter.chatStream({ + model: 'openai/gpt-5.5-pro', + messages: [{ role: 'user', content: 'Hello' }], + modelOptions, + logger: testLogger, + })) { + // consume stream + } + + expect(mockCreate).toHaveBeenCalledTimes(1) + expect(mockCreate.mock.calls[0]?.[0]).toMatchObject({ + temperature: 0.5, + top_p: 0.8, + max_completion_tokens: 128, + reasoning_effort: 'high', + }) + }) + }) + + describe('Summarize adapter', () => { + it('creates a summarize adapter wrapping the text adapter', () => { + const adapter = createOrcaRouterSummarize( + 'openai/gpt-5.5-pro', + 'test-api-key', + ) + + expect(adapter).toBeDefined() + expect(adapter.model).toBe('openai/gpt-5.5-pro') + }) + + // The summarize wrapper resolves `maxLength` to a token cap via its own + // adapter `name`. OrcaRouter's OpenAI-compatible Chat Completions surface + // reads `max_tokens`, so an unregistered name would silently drop the cap + // and bill an unbounded completion. + it('forwards maxLength to the wire as max_tokens', async () => { + const mockCreate = setupMockSdkClient([ + { + id: 'chatcmpl-summary', + model: 'openai/gpt-5.5-pro', + choices: [ + { + delta: { content: 'A short summary.' }, + finish_reason: 'stop', + }, + ], + }, + ]) + const adapter = applyPendingMockToSummarize( + createOrcaRouterSummarize('openai/gpt-5.5-pro', 'test-api-key'), + ) + + await adapter.summarize({ + model: 'openai/gpt-5.5-pro', + text: 'Some long article text that needs summarizing.', + maxLength: 100, + logger: testLogger, + }) + + expect(mockCreate).toHaveBeenCalledTimes(1) + expect(mockCreate.mock.calls[0]?.[0]).toMatchObject({ max_tokens: 100 }) + }) + }) +}) + +describe('OrcaRouter AG-UI event emission', () => { + beforeEach(() => { + vi.clearAllMocks() + pendingMockCreate = undefined + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('emits RUN_STARTED as the first event', async () => { + const streamChunks = [ + { + id: 'chatcmpl-123', + model: 'openai/gpt-5.5-pro', + choices: [ + { + delta: { content: 'Hello' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-123', + model: 'openai/gpt-5.5-pro', + choices: [ + { + delta: {}, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 5, + completion_tokens: 1, + total_tokens: 6, + }, + }, + ] + + setupMockSdkClient(streamChunks) + const adapter = createOrcaRouterText( + 'openai/gpt-5.5-pro', + 'test-api-key', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + model: 'openai/gpt-5.5-pro', + messages: [{ role: 'user', content: 'Hello' }], + logger: testLogger, + })) { + chunks.push(chunk) + } + + expect(chunks[0]?.type).toBe('RUN_STARTED') + if (chunks[0]?.type === 'RUN_STARTED') { + expect(chunks[0].runId).toBeDefined() + expect(chunks[0].model).toBe('openai/gpt-5.5-pro') + } + }) + + it('emits TEXT_MESSAGE_START before TEXT_MESSAGE_CONTENT and finishes with usage', async () => { + const streamChunks = [ + { + id: 'chatcmpl-123', + model: 'openai/gpt-5.5-pro', + choices: [ + { + delta: { content: 'Hello' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-123', + model: 'openai/gpt-5.5-pro', + choices: [ + { + delta: {}, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 5, + completion_tokens: 1, + total_tokens: 6, + }, + }, + ] + + setupMockSdkClient(streamChunks) + const adapter = createOrcaRouterText( + 'openai/gpt-5.5-pro', + 'test-api-key', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + model: 'openai/gpt-5.5-pro', + messages: [{ role: 'user', content: 'Hello' }], + logger: testLogger, + })) { + chunks.push(chunk) + } + + const textStartIndex = chunks.findIndex( + (c) => c.type === 'TEXT_MESSAGE_START', + ) + const textContentIndex = chunks.findIndex( + (c) => c.type === 'TEXT_MESSAGE_CONTENT', + ) + + expect(textStartIndex).toBeGreaterThan(-1) + expect(textContentIndex).toBeGreaterThan(-1) + expect(textStartIndex).toBeLessThan(textContentIndex) + + const runFinishedChunk = chunks.find((c) => c.type === 'RUN_FINISHED') + expect(runFinishedChunk).toBeDefined() + if (runFinishedChunk?.type === 'RUN_FINISHED') { + expect(runFinishedChunk.finishReason).toBe('stop') + expect(runFinishedChunk.usage).toMatchObject({ + promptTokens: 5, + completionTokens: 1, + totalTokens: 6, + }) + } + }) + + it('surfaces reasoning_content deltas as REASONING events', async () => { + const streamChunks = [ + { + id: 'chatcmpl-reasoning', + model: 'deepseek/deepseek-v4-pro-0813', + choices: [ + { + delta: { reasoning_content: 'Thinking about it...' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-reasoning', + model: 'deepseek/deepseek-v4-pro-0813', + choices: [ + { + delta: { content: 'The answer is 4.' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-reasoning', + model: 'deepseek/deepseek-v4-pro-0813', + choices: [ + { + delta: {}, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 5, + completion_tokens: 10, + total_tokens: 15, + }, + }, + ] + + setupMockSdkClient(streamChunks) + const adapter = createOrcaRouterText( + 'deepseek/deepseek-v4-pro-0813', + 'test-api-key', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + model: 'deepseek/deepseek-v4-pro-0813', + messages: [{ role: 'user', content: 'What is 2+2?' }], + logger: testLogger, + })) { + chunks.push(chunk) + } + + const reasoningContent = chunks.find( + (c) => c.type === 'REASONING_MESSAGE_CONTENT', + ) + expect(reasoningContent).toBeDefined() + if (reasoningContent?.type === 'REASONING_MESSAGE_CONTENT') { + expect(reasoningContent.delta).toBe('Thinking about it...') + } + + // Reasoning must close before the visible text message starts. + const reasoningEndIndex = chunks.findIndex( + (c) => c.type === 'REASONING_MESSAGE_END', + ) + const textStartIndex = chunks.findIndex( + (c) => c.type === 'TEXT_MESSAGE_START', + ) + expect(reasoningEndIndex).toBeGreaterThan(-1) + expect(textStartIndex).toBeGreaterThan(reasoningEndIndex) + }) + + it('emits AG-UI tool call events', async () => { + const streamChunks = [ + { + id: 'chatcmpl-456', + model: 'openai/gpt-5.5-pro', + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: 'call_abc', + type: 'function', + function: { + name: 'lookup_weather', + arguments: '{"location":', + }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-456', + model: 'openai/gpt-5.5-pro', + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + function: { arguments: '"Paris"}' }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-456', + model: 'openai/gpt-5.5-pro', + choices: [ + { + delta: {}, + finish_reason: 'tool_calls', + }, + ], + usage: { + prompt_tokens: 12, + completion_tokens: 8, + total_tokens: 20, + }, + }, + ] + + setupMockSdkClient(streamChunks) + const adapter = createOrcaRouterText( + 'openai/gpt-5.5-pro', + 'test-api-key', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + model: 'openai/gpt-5.5-pro', + messages: [{ role: 'user', content: 'Weather in Paris?' }], + logger: testLogger, + })) { + chunks.push(chunk) + } + + const toolStart = chunks.find((c) => c.type === 'TOOL_CALL_START') + expect(toolStart).toBeDefined() + if (toolStart?.type === 'TOOL_CALL_START') { + expect(toolStart.toolCallName).toBe('lookup_weather') + } + + const toolArgs = chunks + .filter((c) => c.type === 'TOOL_CALL_ARGS') + .map((c) => (c.type === 'TOOL_CALL_ARGS' ? c.delta : '')) + .join('') + expect(toolArgs).toBe('{"location":"Paris"}') + + const toolEnd = chunks.find((c) => c.type === 'TOOL_CALL_END') + expect(toolEnd).toBeDefined() + + const runFinishedChunk = chunks.find((c) => c.type === 'RUN_FINISHED') + expect(runFinishedChunk).toBeDefined() + if (runFinishedChunk?.type === 'RUN_FINISHED') { + expect(runFinishedChunk.finishReason).toBe('tool_calls') + } + }) +}) diff --git a/packages/ai-orcarouter/tsconfig.json b/packages/ai-orcarouter/tsconfig.json new file mode 100644 index 0000000000..c38689f4ea --- /dev/null +++ b/packages/ai-orcarouter/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-orcarouter/vite.config.ts b/packages/ai-orcarouter/vite.config.ts new file mode 100644 index 0000000000..77bcc2e60b --- /dev/null +++ b/packages/ai-orcarouter/vite.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/packages/ai-orcarouter/vitest.config.ts b/packages/ai-orcarouter/vitest.config.ts new file mode 100644 index 0000000000..fa25317431 --- /dev/null +++ b/packages/ai-orcarouter/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) diff --git a/packages/ai/src/activities/summarize/chat-stream-summarize.ts b/packages/ai/src/activities/summarize/chat-stream-summarize.ts index c3619191c9..c9820806a1 100644 --- a/packages/ai/src/activities/summarize/chat-stream-summarize.ts +++ b/packages/ai/src/activities/summarize/chat-stream-summarize.ts @@ -95,6 +95,9 @@ const MAX_TOKENS_KEY_BY_ADAPTER: Record = { // LLM Gateway exposes an OpenAI-compatible Chat Completions surface whose // only output cap is `max_tokens` — it does not read `max_completion_tokens`. llmgateway: 'max_tokens', + // OrcaRouter exposes the same OpenAI-compatible Chat Completions surface + // and reads `max_tokens` as its output cap. + orcarouter: 'max_tokens', } /** diff --git a/testing/e2e/README.md b/testing/e2e/README.md index 95914ffc31..69dfc7c18c 100644 --- a/testing/e2e/README.md +++ b/testing/e2e/README.md @@ -4,7 +4,7 @@ End-to-end tests for TanStack AI using Playwright and [aimock](https://github.co **Architecture:** Playwright drives a TanStack Start app (`testing/e2e/`) which routes requests through provider adapters pointing at aimock. Fixtures define mock responses. No real API keys needed. All scenarios (including tool execution flows) use aimock fixtures. Tests run in parallel with per-test `X-Test-Id` isolation. -**Providers tested:** openai, anthropic, gemini, vertex, vertex-grok, vertex-mistral, ollama, groq, grok, openrouter, openrouter-responses, vercel-gateway, vercel-gateway-responses, lovable, lovable-responses, bedrock, bedrock-responses, openai-compatible, openai-compatible-legacy, mistral, byteplus, elevenlabs, llmgateway +**Providers tested:** openai, anthropic, gemini, vertex, vertex-grok, vertex-mistral, ollama, groq, grok, openrouter, openrouter-responses, vercel-gateway, vercel-gateway-responses, lovable, lovable-responses, bedrock, bedrock-responses, openai-compatible, openai-compatible-legacy, mistral, byteplus, elevenlabs, llmgateway, orcarouter > **Claude Code (`@tanstack/ai-claude-code`) is excluded from the standard matrix.** It's a harness adapter that spawns the Claude Code runtime as a subprocess, so aimock's per-test `X-Test-Id` header isolation can't be injected into its requests. It's covered by unit tests in the package plus a gated live smoke test in `tests/claude-code.spec.ts` — run it with `CLAUDE_CODE_E2E=1` and an `ANTHROPIC_API_KEY` (or a local `claude login`). diff --git a/testing/e2e/package.json b/testing/e2e/package.json index e2db43e8d0..4aa5fd6bef 100644 --- a/testing/e2e/package.json +++ b/testing/e2e/package.json @@ -30,6 +30,7 @@ "@tanstack/ai-grok": "workspace:*", "@tanstack/ai-groq": "workspace:*", "@tanstack/ai-llmgateway": "workspace:*", + "@tanstack/ai-orcarouter": "workspace:*", "@tanstack/ai-lovable": "workspace:*", "@tanstack/ai-mcp": "workspace:*", "@tanstack/ai-memory": "workspace:*", diff --git a/testing/e2e/src/lib/feature-support.ts b/testing/e2e/src/lib/feature-support.ts index 206367cf8b..b7629a5755 100644 --- a/testing/e2e/src/lib/feature-support.ts +++ b/testing/e2e/src/lib/feature-support.ts @@ -27,6 +27,7 @@ export const matrix: Record> = { 'mistral', 'byteplus', 'llmgateway', + 'orcarouter', ]), 'one-shot-text': new Set([ 'openai', @@ -47,6 +48,7 @@ export const matrix: Record> = { 'mistral', 'byteplus', 'llmgateway', + 'orcarouter', ]), // BytePlus and LLM Gateway both stream their reasoning trace as // `delta.reasoning_content`, which is exactly the field aimock's @@ -65,6 +67,7 @@ export const matrix: Record> = { 'mistral', 'byteplus', 'llmgateway', + 'orcarouter', 'openai-compatible-legacy', ]), 'multi-turn': new Set([ @@ -86,6 +89,7 @@ export const matrix: Record> = { 'mistral', 'byteplus', 'llmgateway', + 'orcarouter', ]), 'tool-calling': new Set([ 'openai', @@ -109,6 +113,7 @@ export const matrix: Record> = { 'mistral', 'byteplus', 'llmgateway', + 'orcarouter', ]), 'parallel-tool-calls': new Set([ 'openai', @@ -128,6 +133,7 @@ export const matrix: Record> = { 'mistral', 'byteplus', 'llmgateway', + 'orcarouter', ]), // Gemini excluded: approval flow timing issues with Gemini's streaming format // Vertex uses the same Gemini stream, so it is excluded for the same reason. @@ -148,6 +154,7 @@ export const matrix: Record> = { 'mistral', 'byteplus', 'llmgateway', + 'orcarouter', ]), // Ollama excluded: aimock doesn't support content+toolCalls for /api/chat format 'text-tool-text': new Set([ @@ -168,6 +175,7 @@ export const matrix: Record> = { 'mistral', 'byteplus', 'llmgateway', + 'orcarouter', ]), 'structured-output': new Set([ 'openai', @@ -188,6 +196,7 @@ export const matrix: Record> = { 'mistral', 'byteplus', 'llmgateway', + 'orcarouter', ]), // Streaming structured output: only providers with native streaming JSON // schema support are listed here. Other providers fall back to the @@ -206,6 +215,7 @@ export const matrix: Record> = { 'openai-compatible', 'byteplus', 'llmgateway', + 'orcarouter', ]), // Multi-turn structured output: every turn produces its own typed // `structured-output` part on the assistant message, and historical @@ -241,6 +251,7 @@ export const matrix: Record> = { 'openai-compatible', 'byteplus', 'llmgateway', + 'orcarouter', ]), 'agentic-structured': new Set([ 'openai', @@ -261,6 +272,7 @@ export const matrix: Record> = { 'mistral', 'byteplus', 'llmgateway', + 'orcarouter', ]), // Native-combined-mode adapters only. Each provider's default test model // (or per-feature override in `features.ts`) must opt into combined mode @@ -295,6 +307,7 @@ export const matrix: Record> = { 'openrouter', 'byteplus', 'llmgateway', + 'orcarouter', ]), // OpenAI only: this feature exercises the Responses adapter's PDF // `input_file` conversion (base64 `file_data` + filename). @@ -311,6 +324,7 @@ export const matrix: Record> = { 'openrouter', 'byteplus', 'llmgateway', + 'orcarouter', ]), // byteplus excluded: @tanstack/ai-byteplus ships no summarize adapter — // Ark has no summarization endpoint, and api.summarize.ts builds a @@ -333,6 +347,7 @@ export const matrix: Record> = { 'lovable', 'mistral', 'llmgateway', + 'orcarouter', ]), 'summarize-stream': new Set([ 'openai', @@ -351,6 +366,7 @@ export const matrix: Record> = { 'lovable', 'mistral', 'llmgateway', + 'orcarouter', ]), // Embedding (Promise-based `embed()` activity, no streaming). aimock 1.34 // natively mocks OpenAI's /v1/embeddings (JSON fixture in diff --git a/testing/e2e/src/lib/providers.ts b/testing/e2e/src/lib/providers.ts index 6bc692b310..41db852f8f 100644 --- a/testing/e2e/src/lib/providers.ts +++ b/testing/e2e/src/lib/providers.ts @@ -22,6 +22,7 @@ import { createLovableText } from '@tanstack/ai-lovable' import { createMistralText } from '@tanstack/ai-mistral' import { createBytePlusText } from '@tanstack/ai-byteplus' import { createLLMGatewayText } from '@tanstack/ai-llmgateway' +import { createOrcaRouterText } from '@tanstack/ai-orcarouter' import { HTTPClient } from '@openrouter/sdk' import type { AnyTextAdapter } from '@tanstack/ai' import type { BytePlusChatModel } from '@tanstack/ai-byteplus' @@ -112,6 +113,7 @@ const defaultModels: Record = { // Record constraint. elevenlabs: '', llmgateway: 'gpt-5.6-terra', + orcarouter: 'openai/gpt-5.5-pro', } export function createTextAdapter( @@ -389,6 +391,13 @@ export function createTextAdapter( defaultHeaders: testHeaders, }), }), + orcarouter: () => + createChatOptions({ + adapter: createOrcaRouterText(model as 'openai/gpt-5.5-pro', DUMMY_KEY, { + baseURL: openaiUrl, + defaultHeaders: testHeaders, + }), + }), } return factories[provider]() diff --git a/testing/e2e/src/lib/types.ts b/testing/e2e/src/lib/types.ts index 04239dd708..59f291271c 100644 --- a/testing/e2e/src/lib/types.ts +++ b/testing/e2e/src/lib/types.ts @@ -24,6 +24,7 @@ export type Provider = | 'byteplus' | 'elevenlabs' | 'llmgateway' + | 'orcarouter' export type Feature = | 'chat' @@ -81,6 +82,7 @@ export const ALL_PROVIDERS: Provider[] = [ 'byteplus', 'elevenlabs', 'llmgateway', + 'orcarouter', ] export const ALL_FEATURES: Feature[] = [ diff --git a/testing/e2e/src/routes/api.summarize.ts b/testing/e2e/src/routes/api.summarize.ts index 0b527d85f1..ab89c2be55 100644 --- a/testing/e2e/src/routes/api.summarize.ts +++ b/testing/e2e/src/routes/api.summarize.ts @@ -10,6 +10,7 @@ import { createGroqSummarize } from '@tanstack/ai-groq' import { createGrokSummarize } from '@tanstack/ai-grok' import { grokVertexSummarize } from '@tanstack/ai-grok/vertex' import { createLLMGatewaySummarize } from '@tanstack/ai-llmgateway' +import { createOrcaRouterSummarize } from '@tanstack/ai-orcarouter' import { createOpenRouterSummarize } from '@tanstack/ai-openrouter' import { createVercelGatewaySummarize } from '@tanstack/ai-vercel-gateway' import { createLovableSummarize } from '@tanstack/ai-lovable' @@ -102,6 +103,11 @@ function createSummarizeAdapter( baseURL: openaiUrl(aimockPort), defaultHeaders: headers, }), + orcarouter: () => + createOrcaRouterSummarize('openai/gpt-5.5-pro', DUMMY_KEY, { + baseURL: openaiUrl(aimockPort), + defaultHeaders: headers, + }), // Both OpenRouter provider rows use the OpenRouter summarize adapter: // `createOpenRouterSummarize` wraps the OpenRouter chat-completions // text adapter regardless of whether the caller selected the Chat diff --git a/testing/e2e/tests/test-matrix.ts b/testing/e2e/tests/test-matrix.ts index a5bd508969..d72e49cf07 100644 --- a/testing/e2e/tests/test-matrix.ts +++ b/testing/e2e/tests/test-matrix.ts @@ -37,6 +37,7 @@ export const providers: Provider[] = [ 'byteplus', 'elevenlabs', 'llmgateway', + 'orcarouter', ] export { isSupported }