diff --git a/.changeset/giant-garlics-crash.md b/.changeset/giant-garlics-crash.md new file mode 100644 index 000000000..f468f7848 --- /dev/null +++ b/.changeset/giant-garlics-crash.md @@ -0,0 +1,15 @@ +--- +'@tanstack/ai-openrouter': patch +--- + +- Upgrade `@openrouter/sdk` to 0.12.14 (from 0.3.15) +- Migrate adapters and tests to the SDK's renamed chat types (`ChatGenerationParams` → `ChatRequest`, `ChatResponse` → `ChatResult`) and the renamed `chatRequest` key on `chat.send` +- Derive `text-provider-options` types from the SDK's `ChatRequest` so provider options stay in lockstep with the SDK surface +- Drop `topK` / `topA` / `minP` / `repetitionPenalty` / `includeReasoning` / `verbosity` / `webSearchOptions` from `OpenRouterBaseOptions` now that the SDK narrows `ChatRequest` to OpenAI-compatible fields — callers passing these previously saw them silently stripped at runtime, and now get a TypeScript error instead +- Add those same keys to `excludedParams` in the model-catalog generator so future syncs don't reintroduce them +- Switch `structuredOutput` to the SDK's native `responseFormat: { type: 'json_schema' }` instead of coercing the model via a forced `structured_output` tool call — the schema now flows straight to the provider, yielding cleaner responses and dropping the dummy-tool round-trip +- Apply the OpenAI-strict schema transformation (`additionalProperties: false`, all properties required, optional fields widened to include `null`) inside `structuredOutput` before forwarding — without this, upstream OpenAI rejected the request with "Provider returned error" for schemas generated by Zod / ArkType / Valibot +- Refactor options passthrough to use camelCase naming convention +- Refresh the model catalog with the latest OpenRouter models (Opus 4.6, Sonnet 4.6, Gemini 3.1 Pro, etc.) and remove the deprecated `openrouter/auto` model +- Unify the outbound request payload envelope +- Improve error handling and add tests for nested payloads, structured output parsing, and error cases diff --git a/.changeset/sync-models.md b/.changeset/sync-models.md index 63eef764c..738c1d701 100644 --- a/.changeset/sync-models.md +++ b/.changeset/sync-models.md @@ -1,4 +1,5 @@ --- +'@tanstack/ai-anthropic': patch '@tanstack/ai-openrouter': patch --- diff --git a/examples/ts-react-chat/src/components/Header.tsx b/examples/ts-react-chat/src/components/Header.tsx index 0b28cbc48..d1c432232 100644 --- a/examples/ts-react-chat/src/components/Header.tsx +++ b/examples/ts-react-chat/src/components/Header.tsx @@ -2,6 +2,7 @@ import { Link } from '@tanstack/react-router' import { useState } from 'react' import { + Braces, FileAudio, FileText, Guitar, @@ -138,6 +139,19 @@ export default function Header() { Video Generation + setIsOpen(false)} + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-1" + activeProps={{ + className: + 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-1', + }} + > + + Structured Output (OpenRouter) + +

diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index 490145527..f0a2fab4b 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -15,11 +15,13 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as GenerationsVideoRouteImport } from './routes/generations.video' import { Route as GenerationsTranscriptionRouteImport } from './routes/generations.transcription' import { Route as GenerationsSummarizeRouteImport } from './routes/generations.summarize' +import { Route as GenerationsStructuredOutputRouteImport } from './routes/generations.structured-output' import { Route as GenerationsSpeechRouteImport } from './routes/generations.speech' import { Route as GenerationsImageRouteImport } from './routes/generations.image' import { Route as ApiTranscribeRouteImport } from './routes/api.transcribe' import { Route as ApiTanchatRouteImport } from './routes/api.tanchat' import { Route as ApiSummarizeRouteImport } from './routes/api.summarize' +import { Route as ApiStructuredOutputRouteImport } from './routes/api.structured-output' import { Route as ApiImageGenRouteImport } from './routes/api.image-gen' import { Route as ExampleGuitarsIndexRouteImport } from './routes/example.guitars/index' import { Route as ExampleGuitarsGuitarIdRouteImport } from './routes/example.guitars/$guitarId' @@ -58,6 +60,12 @@ const GenerationsSummarizeRoute = GenerationsSummarizeRouteImport.update({ path: '/generations/summarize', getParentRoute: () => rootRouteImport, } as any) +const GenerationsStructuredOutputRoute = + GenerationsStructuredOutputRouteImport.update({ + id: '/generations/structured-output', + path: '/generations/structured-output', + getParentRoute: () => rootRouteImport, + } as any) const GenerationsSpeechRoute = GenerationsSpeechRouteImport.update({ id: '/generations/speech', path: '/generations/speech', @@ -83,6 +91,11 @@ const ApiSummarizeRoute = ApiSummarizeRouteImport.update({ path: '/api/summarize', getParentRoute: () => rootRouteImport, } as any) +const ApiStructuredOutputRoute = ApiStructuredOutputRouteImport.update({ + id: '/api/structured-output', + path: '/api/structured-output', + getParentRoute: () => rootRouteImport, +} as any) const ApiImageGenRoute = ApiImageGenRouteImport.update({ id: '/api/image-gen', path: '/api/image-gen', @@ -119,11 +132,13 @@ export interface FileRoutesByFullPath { '/image-gen': typeof ImageGenRoute '/realtime': typeof RealtimeRoute '/api/image-gen': typeof ApiImageGenRoute + '/api/structured-output': typeof ApiStructuredOutputRoute '/api/summarize': typeof ApiSummarizeRoute '/api/tanchat': typeof ApiTanchatRoute '/api/transcribe': typeof ApiTranscribeRoute '/generations/image': typeof GenerationsImageRoute '/generations/speech': typeof GenerationsSpeechRoute + '/generations/structured-output': typeof GenerationsStructuredOutputRoute '/generations/summarize': typeof GenerationsSummarizeRoute '/generations/transcription': typeof GenerationsTranscriptionRoute '/generations/video': typeof GenerationsVideoRoute @@ -138,11 +153,13 @@ export interface FileRoutesByTo { '/image-gen': typeof ImageGenRoute '/realtime': typeof RealtimeRoute '/api/image-gen': typeof ApiImageGenRoute + '/api/structured-output': typeof ApiStructuredOutputRoute '/api/summarize': typeof ApiSummarizeRoute '/api/tanchat': typeof ApiTanchatRoute '/api/transcribe': typeof ApiTranscribeRoute '/generations/image': typeof GenerationsImageRoute '/generations/speech': typeof GenerationsSpeechRoute + '/generations/structured-output': typeof GenerationsStructuredOutputRoute '/generations/summarize': typeof GenerationsSummarizeRoute '/generations/transcription': typeof GenerationsTranscriptionRoute '/generations/video': typeof GenerationsVideoRoute @@ -158,11 +175,13 @@ export interface FileRoutesById { '/image-gen': typeof ImageGenRoute '/realtime': typeof RealtimeRoute '/api/image-gen': typeof ApiImageGenRoute + '/api/structured-output': typeof ApiStructuredOutputRoute '/api/summarize': typeof ApiSummarizeRoute '/api/tanchat': typeof ApiTanchatRoute '/api/transcribe': typeof ApiTranscribeRoute '/generations/image': typeof GenerationsImageRoute '/generations/speech': typeof GenerationsSpeechRoute + '/generations/structured-output': typeof GenerationsStructuredOutputRoute '/generations/summarize': typeof GenerationsSummarizeRoute '/generations/transcription': typeof GenerationsTranscriptionRoute '/generations/video': typeof GenerationsVideoRoute @@ -179,11 +198,13 @@ export interface FileRouteTypes { | '/image-gen' | '/realtime' | '/api/image-gen' + | '/api/structured-output' | '/api/summarize' | '/api/tanchat' | '/api/transcribe' | '/generations/image' | '/generations/speech' + | '/generations/structured-output' | '/generations/summarize' | '/generations/transcription' | '/generations/video' @@ -198,11 +219,13 @@ export interface FileRouteTypes { | '/image-gen' | '/realtime' | '/api/image-gen' + | '/api/structured-output' | '/api/summarize' | '/api/tanchat' | '/api/transcribe' | '/generations/image' | '/generations/speech' + | '/generations/structured-output' | '/generations/summarize' | '/generations/transcription' | '/generations/video' @@ -217,11 +240,13 @@ export interface FileRouteTypes { | '/image-gen' | '/realtime' | '/api/image-gen' + | '/api/structured-output' | '/api/summarize' | '/api/tanchat' | '/api/transcribe' | '/generations/image' | '/generations/speech' + | '/generations/structured-output' | '/generations/summarize' | '/generations/transcription' | '/generations/video' @@ -237,11 +262,13 @@ export interface RootRouteChildren { ImageGenRoute: typeof ImageGenRoute RealtimeRoute: typeof RealtimeRoute ApiImageGenRoute: typeof ApiImageGenRoute + ApiStructuredOutputRoute: typeof ApiStructuredOutputRoute ApiSummarizeRoute: typeof ApiSummarizeRoute ApiTanchatRoute: typeof ApiTanchatRoute ApiTranscribeRoute: typeof ApiTranscribeRoute GenerationsImageRoute: typeof GenerationsImageRoute GenerationsSpeechRoute: typeof GenerationsSpeechRoute + GenerationsStructuredOutputRoute: typeof GenerationsStructuredOutputRoute GenerationsSummarizeRoute: typeof GenerationsSummarizeRoute GenerationsTranscriptionRoute: typeof GenerationsTranscriptionRoute GenerationsVideoRoute: typeof GenerationsVideoRoute @@ -296,6 +323,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof GenerationsSummarizeRouteImport parentRoute: typeof rootRouteImport } + '/generations/structured-output': { + id: '/generations/structured-output' + path: '/generations/structured-output' + fullPath: '/generations/structured-output' + preLoaderRoute: typeof GenerationsStructuredOutputRouteImport + parentRoute: typeof rootRouteImport + } '/generations/speech': { id: '/generations/speech' path: '/generations/speech' @@ -331,6 +365,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiSummarizeRouteImport parentRoute: typeof rootRouteImport } + '/api/structured-output': { + id: '/api/structured-output' + path: '/api/structured-output' + fullPath: '/api/structured-output' + preLoaderRoute: typeof ApiStructuredOutputRouteImport + parentRoute: typeof rootRouteImport + } '/api/image-gen': { id: '/api/image-gen' path: '/api/image-gen' @@ -381,11 +422,13 @@ const rootRouteChildren: RootRouteChildren = { ImageGenRoute: ImageGenRoute, RealtimeRoute: RealtimeRoute, ApiImageGenRoute: ApiImageGenRoute, + ApiStructuredOutputRoute: ApiStructuredOutputRoute, ApiSummarizeRoute: ApiSummarizeRoute, ApiTanchatRoute: ApiTanchatRoute, ApiTranscribeRoute: ApiTranscribeRoute, GenerationsImageRoute: GenerationsImageRoute, GenerationsSpeechRoute: GenerationsSpeechRoute, + GenerationsStructuredOutputRoute: GenerationsStructuredOutputRoute, GenerationsSummarizeRoute: GenerationsSummarizeRoute, GenerationsTranscriptionRoute: GenerationsTranscriptionRoute, GenerationsVideoRoute: GenerationsVideoRoute, diff --git a/examples/ts-react-chat/src/routes/api.structured-output.ts b/examples/ts-react-chat/src/routes/api.structured-output.ts new file mode 100644 index 000000000..aa1d045f2 --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.structured-output.ts @@ -0,0 +1,58 @@ +import { createFileRoute } from '@tanstack/react-router' +import { chat } from '@tanstack/ai' +import { openRouterText } from '@tanstack/ai-openrouter' +import { z } from 'zod' + +const GuitarRecommendationSchema = z.object({ + title: z.string().describe('Short headline for the recommendation'), + summary: z.string().describe('One paragraph summary'), + recommendations: z + .array( + z.object({ + name: z.string(), + brand: z.string(), + type: z.enum(['acoustic', 'electric', 'bass', 'classical']), + priceRangeUsd: z.object({ min: z.number(), max: z.number() }), + reason: z.string(), + }), + ) + .min(1) + .describe('Guitar recommendations with reasons'), + nextSteps: z.array(z.string()).describe('Practical follow-up actions'), +}) + +export const Route = createFileRoute('/api/structured-output')({ + server: { + handlers: { + POST: async ({ request }) => { + const body = await request.json() + const { prompt, model } = body as { + prompt: string + model?: string + } + + try { + const result = await chat({ + adapter: openRouterText( + (model || 'openai/gpt-5.2') as 'openai/gpt-5.2', + ), + messages: [{ role: 'user', content: prompt }], + outputSchema: GuitarRecommendationSchema, + }) + + return new Response(JSON.stringify({ data: result }), { + headers: { 'Content-Type': 'application/json' }, + }) + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : 'An error occurred' + console.error('[api/structured-output] Error:', error) + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } + }, + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/api.tanchat.ts b/examples/ts-react-chat/src/routes/api.tanchat.ts index a1eb8ee02..f571fd9c7 100644 --- a/examples/ts-react-chat/src/routes/api.tanchat.ts +++ b/examples/ts-react-chat/src/routes/api.tanchat.ts @@ -11,8 +11,8 @@ import { anthropicText } from '@tanstack/ai-anthropic' import { geminiText } from '@tanstack/ai-gemini' import { openRouterText } from '@tanstack/ai-openrouter' import { grokText } from '@tanstack/ai-grok' -import type { AnyTextAdapter, ChatMiddleware } from '@tanstack/ai' import { groqText } from '@tanstack/ai-groq' +import type { AnyTextAdapter, ChatMiddleware } from '@tanstack/ai' import { addToCartToolDef, addToWishListToolDef, @@ -146,8 +146,6 @@ export const Route = createFileRoute('/api/tanchat')({ createChatOptions({ adapter: openRouterText('openai/gpt-5.1'), modelOptions: { - models: ['openai/chatgpt-4o-latest'], - route: 'fallback', reasoning: { effort: 'medium', }, diff --git a/examples/ts-react-chat/src/routes/generations.structured-output.tsx b/examples/ts-react-chat/src/routes/generations.structured-output.tsx new file mode 100644 index 000000000..9b123308f --- /dev/null +++ b/examples/ts-react-chat/src/routes/generations.structured-output.tsx @@ -0,0 +1,191 @@ +import { useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' + +const SAMPLE_PROMPT = + 'I play indie rock and have a $1500 budget. Recommend two electric guitars and one acoustic to round out my rig.' + +const OPENROUTER_MODELS = [ + { value: 'openai/gpt-5.2', label: 'OpenAI GPT-5.2' }, + { value: 'openai/gpt-5.2-pro', label: 'OpenAI GPT-5.2 Pro' }, + { value: 'openai/gpt-5.1', label: 'OpenAI GPT-5.1' }, + { value: 'anthropic/claude-opus-4.7', label: 'Claude Opus 4.7' }, + { value: 'anthropic/claude-sonnet-4.6', label: 'Claude Sonnet 4.6' }, + { value: 'google/gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro (Preview)' }, + { value: 'x-ai/grok-4.1-fast', label: 'Grok 4.1 Fast' }, +] as const + +interface RecommendationResult { + title: string + summary: string + recommendations: Array<{ + name: string + brand: string + type: 'acoustic' | 'electric' | 'bass' | 'classical' + priceRangeUsd: { min: number; max: number } + reason: string + }> + nextSteps: Array +} + +function StructuredOutputPage() { + const [prompt, setPrompt] = useState(SAMPLE_PROMPT) + const [model, setModel] = useState(OPENROUTER_MODELS[0].value) + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const handleGenerate = async () => { + if (!prompt.trim()) return + setIsLoading(true) + setError(null) + setResult(null) + + try { + const response = await fetch('/api/structured-output', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: prompt.trim(), model }), + }) + const payload = await response.json() + if (!response.ok) { + throw new Error(payload.error || 'Request failed') + } + setResult(payload.data as RecommendationResult) + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + } finally { + setIsLoading(false) + } + } + + return ( +

+
+

+ Structured Output (OpenRouter) +

+

+ Calls chat() with an{' '} + outputSchema via the{' '} + openRouterText adapter and + parses the JSON result. +

+
+ +
+
+
+ + +
+ +
+ +