diff --git a/.changeset/zai-chat-completions-adapter.md b/.changeset/zai-chat-completions-adapter.md new file mode 100644 index 0000000000..e0069fa67d --- /dev/null +++ b/.changeset/zai-chat-completions-adapter.md @@ -0,0 +1,7 @@ +--- +'@tanstack/ai-zai': minor +--- + +Add the Z.AI adapter. It drives Z.AI's OpenAI-compatible Chat Completions API through `@tanstack/openai-base` and ships the GLM-5 and GLM-4 model catalog (`glm-5.3`, `glm-5.3-flash`, and `glm-5.2` first), `createZAIText`/`zaiText`, `createZAISummarize`/`zaiSummarize`, the branded `zaiWebSearchTool`, and a `zaiByok` descriptor at `@tanstack/ai-zai/byok` so a browser-supplied key wins over `ZAI_API_KEY`. + +The adapter sets the coding-plan base URL when `coding: true` is passed. It sends `json_object` plus a schema system prompt because Z.AI rejects `json_schema`. Z.AI accepts a `tool_choice` of `auto` only, so `toolChoice: 'none'` sends no tools and `'required'` or a named tool warns once and falls back to `auto`. It streams `delta.reasoning_content` as AG-UI reasoning events. diff --git a/docs/adapters/zai.md b/docs/adapters/zai.md new file mode 100644 index 0000000000..613684a452 --- /dev/null +++ b/docs/adapters/zai.md @@ -0,0 +1,426 @@ +--- +title: "Z.AI" +id: zai-adapter +order: 9 +description: "Use Z.AI GLM models in TanStack AI with @tanstack/ai-zai: streaming chat, tool calling, web search, reasoning effort, vision, and summarization over the OpenAI-compatible Chat Completions API." +keywords: + - tanstack ai + - zai + - z.ai + - glm + - glm-5.3 + - adapter + - reasoning + - web search +--- + +The Z.AI adapter calls the GLM chat models. It uses Z.AI's OpenAI-compatible +Chat Completions endpoint. + +## 1. Install + + + +react: @tanstack/ai-zai +vue: @tanstack/ai-zai +solid: @tanstack/ai-zai +svelte: @tanstack/ai-zai +preact: @tanstack/ai-zai +angular: @tanstack/ai-zai +vanilla: @tanstack/ai-zai +octane: @tanstack/ai-zai + + + +## 2. Set the API key + +Put the key in the environment of your server: + +```bash +ZAI_API_KEY=... +``` + +`zaiText` reads `ZAI_API_KEY`. To pass the key in code, use `createZAIText`: + +```typescript +import { createZAIText } from "@tanstack/ai-zai"; + +const adapter = createZAIText("glm-5.3", process.env.ZAI_API_KEY!); +``` + +## 3. Stream a chat from the server + +```typescript +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; +import { zaiText } from "@tanstack/ai-zai"; + +export async function POST(request: Request) { + const { messages, threadId, runId } = await chatParamsFromRequest(request); + + const stream = chat({ + adapter: zaiText("glm-5.3"), + messages, + threadId, + runId, + }); + + return toServerSentEventsResponse(stream); +} +``` + +## 4. Render the chat + +The client is the same for every provider. Point `useChat` at your route: + +```tsx +import { useState } from "react"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; + +export function Chat() { + const [input, setInput] = useState(""); + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/chat"), + threadId: "zai-chat", + }); + + return ( +
{ + event.preventDefault(); + sendMessage(input); + setInput(""); + }} + > + {messages.map((message) => ( +

+ {message.parts + .map((part) => (part.type === "text" ? part.content : "")) + .join("")} +

+ ))} + setInput(event.target.value)} /> +
+ ); +} +``` + +For Vue, Solid, Svelte, Angular, and the other frameworks, see +[Quick Start](../getting-started/quick-start#3-render-the-chat). + +You now have a streaming chat on Z.AI. [Tools](../tools/tools) and +[Structured Outputs](../structured-outputs/overview) work the same as with +every adapter. For the Z.AI details, see +[Formats the endpoint rejects](#formats-the-endpoint-rejects). + +## Models and options + +### Model options + +Provider options go in `modelOptions`. The sampling options are `temperature`, +`top_p`, `seed`, `stop`, `frequency_penalty`, `presence_penalty`, and +`max_tokens`: + +```typescript +import { chat } from "@tanstack/ai"; +import { zaiText } from "@tanstack/ai-zai"; + +const stream = chat({ + adapter: zaiText("glm-5.3"), + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", + modelOptions: { + temperature: 0.7, + max_tokens: 1024, + }, +}); +``` + +Sampling options do not go at the root of `chat()`. See +[Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options). + +### Reasoning + +Set how hard a reasoning model thinks with `reasoning` on `chat()`: + +```typescript +import { chat } from "@tanstack/ai"; +import { zaiText } from "@tanstack/ai-zai"; + +const stream = chat({ + adapter: zaiText("glm-5.3"), + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", + reasoning: "high", +}); +``` + +The adapter sends the level as `reasoning_effort`. It clamps a level the model +does not have to the nearest one that it does: + +- `glm-5.2`: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. +- `glm-5.3` and `glm-5.3-flash`: `low`, `high`, `max`. A lower level becomes + `low`. + +GLM-4.7 and GLM-4.6 have no `reasoning_effort`. Turn their thinking on with the +provider option instead: + +```typescript +import { chat } from "@tanstack/ai"; +import { zaiText } from "@tanstack/ai-zai"; + +const stream = chat({ + adapter: zaiText("glm-4.7"), + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", + modelOptions: { thinking: { type: "enabled", clear_thinking: false } }, +}); +``` + +`clear_thinking: false` keeps the reasoning of earlier turns. The thinking text +arrives as `delta.reasoning_content`, and the adapter emits it as AG-UI +reasoning events. + +### Structured output + +`chat({ outputSchema })` works on every model in the catalog. Z.AI rejects +`response_format: { type: "json_schema" }`, so the adapter sends JSON mode and +adds your schema to the system prompt. It then parses the reply with your +schema. Read +[When strict JSON schema is rejected](../structured-outputs/overview.md#when-strict-json-schema-is-rejected). +Without `stream: true`, `chat()` resolves to the parsed object, so await it: + +```typescript +import { chat } from "@tanstack/ai"; +import { zaiText } from "@tanstack/ai-zai"; +import { z } from "zod"; + +const orderSchema = z.object({ + status: z.enum(["open", "closed"]), + total: z.number(), +}); + +const order = await chat({ + adapter: zaiText("glm-5.3"), + messages: [{ role: "user", content: "Summarize my open orders" }], + threadId: "thread-1", + outputSchema: orderSchema, +}); +``` + +### Tools + +Function tools work on every model. Z.AI accepts a `tool_choice` of `auto` +only, so the adapter cannot force a call. With `toolChoice: 'none'` it sends +no tools at all, which gives you the text-only answer you asked for. With +`'required'` or a named tool it warns once and sends `auto`, so the model +decides. See [Choose when the model calls a tool](../tools/tools#choose-when-the-model-calls-a-tool). + +Z.AI also runs a search on the provider side. Add it with `zaiWebSearchTool`: + +```typescript +import { chat } from "@tanstack/ai"; +import { zaiText, zaiWebSearchTool } from "@tanstack/ai-zai"; + +const stream = chat({ + adapter: zaiText("glm-5.3"), + messages: [{ role: "user", content: "What is new in TanStack AI?" }], + threadId: "thread-1", + tools: [zaiWebSearchTool({ search_result: true })], +}); +``` + +GLM-4.7 and newer can stream tool-call arguments as the model writes them. Set +`tool_stream: true` in `modelOptions`. + +### Images and video + +The vision models take `image` and `video` content parts: + +```typescript +import { chat } from "@tanstack/ai"; +import { zaiText } from "@tanstack/ai-zai"; + +const stream = chat({ + adapter: zaiText("glm-5.3-flash"), + messages: [ + { + role: "user", + content: [ + { type: "text", content: "What is in this image?" }, + { type: "image", source: { type: "url", value: "https://example.com/guitar.jpg" } }, + ], + }, + ], + threadId: "thread-1", +}); +``` + +Vision models: `glm-5.3-flash`, `glm-5v-turbo`, `glm-4.6v`. +A video source must be an HTTP(S) URL. Z.AI has no base64 video input, so a +`data` source throws before the request. +`adapter.inputModalities` lists the input kinds of the selected model. See +[Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts). + +### Summarization + +```typescript +import { summarize } from "@tanstack/ai"; +import { zaiSummarize } from "@tanstack/ai-zai"; + +const result = await summarize({ + adapter: zaiSummarize("glm-5.3"), + text: "Your long text to summarize...", + threadId: "thread-1", + maxLength: 100, + style: "concise", // "concise" | "bullet-points" | "paragraph" +}); + +console.log(result.summary); +``` + +### Coding endpoint + +A coding-plan key uses a different host. Pass `coding: true`: + +```typescript +import { zaiText } from "@tanstack/ai-zai"; + +const adapter = zaiText("glm-5.3", { coding: true }); +``` + +That selects `https://api.z.ai/api/coding/paas/v4`. A `baseURL` you set wins. + +### BYOK + +Import `zaiByok` from `@tanstack/ai-zai/byok` and add it to your store: + +```typescript +import { defaultByokStorage, defineByok } from "@tanstack/ai-react/byok"; +import { zaiByok } from "@tanstack/ai-zai/byok"; + +export const byok = defineByok({ + storage: defaultByokStorage(), + providers: [zaiByok], +}); +``` + +Registering `zaiByok` is the Z.AI part. Save the key with +`byok.update("zai", value)` and pass the store to `useChat`, with +`forwardedProps.provider` set to `"zai"` so the send carries that key and +nothing else: + +```tsx +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; +import { byok } from "./byok"; + +export function Chat() { + const { sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/chat"), + threadId: "zai-chat", + byok, + forwardedProps: { provider: "zai" }, + }); + + return ( + + ); +} +``` + +Save a key, build a dialog, and use the server fallback in +[BYOK](../advanced/byok). + +On the server, one call reads the key. The `x-byok-zai` header from the browser +wins over `ZAI_API_KEY`: + +```typescript +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; +import { byokMissing, getByokKey } from "@tanstack/ai/byok/server"; +import { createZAIText } from "@tanstack/ai-zai"; +import { zaiByok } from "@tanstack/ai-zai/byok"; + +export async function POST(request: Request) { + const apiKey = getByokKey(request, zaiByok); + if (!apiKey) return byokMissing(zaiByok); + + const { messages, threadId, runId } = await chatParamsFromRequest(request); + + const stream = chat({ + adapter: createZAIText("glm-5.3", apiKey), + messages, + threadId, + runId, + }); + + return toServerSentEventsResponse(stream); +} +``` + +### Model list + +The package exports the list as `ZAI_CHAT_MODELS` and the full metadata, +including pricing, as `ZAI_MODEL_META`. + +| Group | Models | +| --- | --- | +| GLM-5.3 | `glm-5.3` (reasoning), `glm-5.3-flash` (reasoning, vision) | +| GLM-5 | `glm-5.2` (reasoning), `glm-5.1`, `glm-5-turbo`, `glm-5`, `glm-5v-turbo` (vision) | +| GLM-4 | `glm-4.7`, `glm-4.6`, `glm-4.6v` (vision) | + +Every model streams, calls tools, and accepts JSON mode. The largest context +window is 1,000,000 tokens on `glm-5.3`, `glm-5.3-flash`, and `glm-5.2`. + +## Advanced + +### Formats the endpoint rejects + +The adapter hides two endpoint limits, so you do not hit them by hand: + +- `response_format` accepts `json_object` but not `json_schema`. Structured + output uses JSON mode plus a schema system prompt. +- `tool_choice` accepts `auto` only. A named or required choice fails the + request. + +### Configuration + +```typescript +import { createZAIText, type ZAITextConfig } from "@tanstack/ai-zai"; + +const config: Omit = { + baseURL: "https://api.z.ai/api/paas/v4", // Optional, for a custom endpoint + timeout: 60_000, + maxRetries: 2, +}; + +const adapter = createZAIText("glm-5.3", process.env.ZAI_API_KEY!, config); +``` + +The config extends the OpenAI SDK client options, because the adapter drives +that SDK against the Z.AI base URL. Need a header on each request, or a log of +each model call? This adapter supports +[`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). + +### API reference + +Each factory pair has the same shape. The short factory reads `ZAI_API_KEY` +from the environment. The `create*` factory takes an explicit API key. Both +take `model` as the first argument. + +| Factories | Creates | +| --- | --- | +| `zaiText(model, config?)` / `createZAIText(model, apiKey, config?)` | Chat adapter | +| `zaiSummarize(model, config?)` / `createZAISummarize(model, apiKey, config?)` | Summarization adapter | +| `zaiWebSearchTool(config?)` | Provider tool: Z.AI runs the web search | + +`zaiByok` at `@tanstack/ai-zai/byok` is the BYOK descriptor for the key dialog +and the relay header. diff --git a/docs/config.json b/docs/config.json index 5ebdf1256b..c1d76581f9 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1887,6 +1887,11 @@ "label": "BytePlus: Advanced", "to": "adapters/byteplus-advanced", "addedAt": "2026-10-21" + }, + { + "label": "Z.AI", + "to": "adapters/zai", + "addedAt": "2026-10-30" } ], "tab": "adapters" diff --git a/examples/ts-code-mode-web/.env.example b/examples/ts-code-mode-web/.env.example index 33c2ca8eae..3da8a967c5 100644 --- a/examples/ts-code-mode-web/.env.example +++ b/examples/ts-code-mode-web/.env.example @@ -7,3 +7,4 @@ GITHUB_TOKEN=ghp_your_token_here ANTHROPIC_API_KEY=sk-ant-your_key_here OPENAI_API_KEY=sk-your_key_here GEMINI_API_KEY=your_key_here +ZAI_API_KEY= diff --git a/examples/ts-code-mode-web/package.json b/examples/ts-code-mode-web/package.json index 970f1fa451..3e3ca30e27 100644 --- a/examples/ts-code-mode-web/package.json +++ b/examples/ts-code-mode-web/package.json @@ -32,6 +32,7 @@ "@tanstack/ai-ollama": "workspace:*", "@tanstack/ai-openai": "workspace:*", "@tanstack/ai-react": "workspace:*", + "@tanstack/ai-zai": "workspace:*", "@tanstack/react-router": "^1.170.41", "@tanstack/react-start": "^1.168.60", "@tanstack/router-plugin": "^1.168.42", diff --git a/examples/ts-code-mode-web/src/routes/_banking-demo/api.banking-demo.ts b/examples/ts-code-mode-web/src/routes/_banking-demo/api.banking-demo.ts index 3139c57b15..9106016c86 100644 --- a/examples/ts-code-mode-web/src/routes/_banking-demo/api.banking-demo.ts +++ b/examples/ts-code-mode-web/src/routes/_banking-demo/api.banking-demo.ts @@ -4,6 +4,7 @@ import { createCodeMode } from '@tanstack/ai-code-mode' import { anthropicText } from '@tanstack/ai-anthropic' import { openaiText } from '@tanstack/ai-openai' import { geminiText } from '@tanstack/ai-gemini' +import { zaiText } from '@tanstack/ai-zai' import type { AnyTextAdapter } from '@tanstack/ai' import { maxTokensModelOptions } from '@/lib/max-tokens-model-options' @@ -12,7 +13,7 @@ import { CODE_MODE_SYSTEM_PROMPT } from '@/lib/prompts' import { reportTools } from '@/lib/reports/tools' import { createReportBindings } from '@/lib/reports/create-report-bindings' -type Provider = 'anthropic' | 'openai' | 'gemini' +type Provider = 'anthropic' | 'openai' | 'gemini' | 'zai' function getAdapter(provider: Provider, model?: string): AnyTextAdapter { switch (provider) { @@ -20,6 +21,8 @@ function getAdapter(provider: Provider, model?: string): AnyTextAdapter { return openaiText((model || 'gpt-4o') as 'gpt-4o') case 'gemini': return geminiText((model || 'gemini-2.5-flash') as 'gemini-2.5-flash') + case 'zai': + return zaiText((model || 'glm-4.7') as 'glm-4.7') case 'anthropic': default: return anthropicText((model || 'claude-haiku-4-5') as 'claude-haiku-4-5') diff --git a/examples/ts-code-mode-web/src/routes/_banking-demo/banking-demo.tsx b/examples/ts-code-mode-web/src/routes/_banking-demo/banking-demo.tsx index 54bdcbeca5..7e27b69e51 100644 --- a/examples/ts-code-mode-web/src/routes/_banking-demo/banking-demo.tsx +++ b/examples/ts-code-mode-web/src/routes/_banking-demo/banking-demo.tsx @@ -32,7 +32,7 @@ export const Route = createFileRoute('/_banking-demo/banking-demo')({ component: BankingDemoPage, }) -type Provider = 'anthropic' | 'openai' | 'gemini' +type Provider = 'anthropic' | 'openai' | 'gemini' | 'zai' interface ModelOption { provider: Provider @@ -255,6 +255,8 @@ const MODEL_OPTIONS: Array = [ }, { provider: 'openai', model: 'gpt-4o', label: 'GPT-4o' }, { provider: 'gemini', model: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, + { provider: 'zai', model: 'glm-4.7', label: 'Z.AI GLM-4.7' }, + { provider: 'zai', model: 'glm-5-turbo', label: 'Z.AI GLM-5 Turbo' }, ] interface ToastItem { diff --git a/examples/ts-code-mode-web/src/routes/_database-demo/api.database-demo.ts b/examples/ts-code-mode-web/src/routes/_database-demo/api.database-demo.ts index 67b0a1bd7f..ca77d9e141 100644 --- a/examples/ts-code-mode-web/src/routes/_database-demo/api.database-demo.ts +++ b/examples/ts-code-mode-web/src/routes/_database-demo/api.database-demo.ts @@ -13,13 +13,14 @@ import { createFileSnippetStorage } from '@tanstack/ai-code-mode-snippets/storag import { anthropicText } from '@tanstack/ai-anthropic' import { openaiText } from '@tanstack/ai-openai' import { geminiText } from '@tanstack/ai-gemini' +import { zaiText } from '@tanstack/ai-zai' import type { AnyServerTool, AnyTextAdapter, StreamChunk } from '@tanstack/ai' import type { IsolateDriver } from '@tanstack/ai-code-mode' import { databaseTools, getSchemaInfoTool } from '@/lib/tools/database-tools' import { maxTokensModelOptions } from '@/lib/max-tokens-model-options' -type Provider = 'anthropic' | 'openai' | 'gemini' +type Provider = 'anthropic' | 'openai' | 'gemini' | 'zai' function getAdapter(provider: Provider, model?: string): AnyTextAdapter { switch (provider) { @@ -27,6 +28,8 @@ function getAdapter(provider: Provider, model?: string): AnyTextAdapter { return openaiText((model || 'gpt-4o') as 'gpt-4o') case 'gemini': return geminiText((model || 'gemini-2.5-flash') as 'gemini-2.5-flash') + case 'zai': + return zaiText((model || 'glm-4.7') as 'glm-4.7') case 'anthropic': default: return anthropicText((model || 'claude-haiku-4-5') as 'claude-haiku-4-5') diff --git a/examples/ts-code-mode-web/src/routes/_database-demo/database-demo.tsx b/examples/ts-code-mode-web/src/routes/_database-demo/database-demo.tsx index 4b54dca060..1f2532ac2f 100644 --- a/examples/ts-code-mode-web/src/routes/_database-demo/database-demo.tsx +++ b/examples/ts-code-mode-web/src/routes/_database-demo/database-demo.tsx @@ -29,7 +29,7 @@ export const Route = createFileRoute('/_database-demo/database-demo')({ component: DatabaseDemoPage, }) -type Provider = 'anthropic' | 'openai' | 'gemini' +type Provider = 'anthropic' | 'openai' | 'gemini' | 'zai' interface ModelOption { provider: Provider @@ -49,11 +49,9 @@ const MODEL_OPTIONS: Array = [ label: 'Claude Haiku 4', }, { provider: 'openai', model: 'gpt-4o', label: 'GPT-4o' }, - { - provider: 'gemini', - model: 'gemini-2.5-flash', - label: 'Gemini 2.5 Flash', - }, + { provider: 'gemini', model: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, + { provider: 'zai', model: 'glm-4.7', label: 'Z.AI GLM-4.7' }, + { provider: 'zai', model: 'glm-5-turbo', label: 'Z.AI GLM-5 Turbo' }, ] interface SnippetWithCode { diff --git a/examples/ts-code-mode-web/src/routes/_home/api.product-codemode.ts b/examples/ts-code-mode-web/src/routes/_home/api.product-codemode.ts index 6efb62fe43..9bbce202a9 100644 --- a/examples/ts-code-mode-web/src/routes/_home/api.product-codemode.ts +++ b/examples/ts-code-mode-web/src/routes/_home/api.product-codemode.ts @@ -6,6 +6,7 @@ import { createCodeMode } from '@tanstack/ai-code-mode' import { anthropicText } from '@tanstack/ai-anthropic' import { openaiText } from '@tanstack/ai-openai' import { geminiText } from '@tanstack/ai-gemini' +import { zaiText } from '@tanstack/ai-zai' import { createAlwaysTrustedStrategy, createSnippetManagementTools, @@ -18,7 +19,7 @@ import type { AnyServerTool, AnyTextAdapter, StreamChunk } from '@tanstack/ai' import type { IsolateDriver } from '@tanstack/ai-code-mode' import { productTools } from '@/lib/tools/product-tools' -type Provider = 'anthropic' | 'openai' | 'gemini' +type Provider = 'anthropic' | 'openai' | 'gemini' | 'zai' function getAdapter(provider: Provider, model?: string): AnyTextAdapter { switch (provider) { @@ -26,6 +27,8 @@ function getAdapter(provider: Provider, model?: string): AnyTextAdapter { return openaiText((model || 'gpt-4o') as 'gpt-4o') case 'gemini': return geminiText((model || 'gemini-2.5-flash') as 'gemini-2.5-flash') + case 'zai': + return zaiText((model || 'glm-4.7') as 'glm-4.7') case 'anthropic': default: return anthropicText((model || 'claude-haiku-4-5') as 'claude-haiku-4-5') diff --git a/examples/ts-code-mode-web/src/routes/_home/api.product-regular.ts b/examples/ts-code-mode-web/src/routes/_home/api.product-regular.ts index 219e3aaf40..829ef7f807 100644 --- a/examples/ts-code-mode-web/src/routes/_home/api.product-regular.ts +++ b/examples/ts-code-mode-web/src/routes/_home/api.product-regular.ts @@ -3,11 +3,12 @@ import { chat, maxIterations, toServerSentEventsStream } from '@tanstack/ai' import { anthropicText } from '@tanstack/ai-anthropic' import { openaiText } from '@tanstack/ai-openai' import { geminiText } from '@tanstack/ai-gemini' +import { zaiText } from '@tanstack/ai-zai' import type { AnyTextAdapter, StreamChunk } from '@tanstack/ai' import { productTools } from '@/lib/tools/product-tools' import { maxTokensModelOptions } from '@/lib/max-tokens-model-options' -type Provider = 'anthropic' | 'openai' | 'gemini' +type Provider = 'anthropic' | 'openai' | 'gemini' | 'zai' function getAdapter(provider: Provider, model?: string): AnyTextAdapter { switch (provider) { @@ -15,6 +16,8 @@ function getAdapter(provider: Provider, model?: string): AnyTextAdapter { return openaiText((model || 'gpt-4o') as 'gpt-4o') case 'gemini': return geminiText((model || 'gemini-2.5-flash') as 'gemini-2.5-flash') + case 'zai': + return zaiText((model || 'glm-4.7') as 'glm-4.7') case 'anthropic': default: return anthropicText((model || 'claude-haiku-4-5') as 'claude-haiku-4-5') diff --git a/examples/ts-code-mode-web/src/routes/_home/index.tsx b/examples/ts-code-mode-web/src/routes/_home/index.tsx index cf6e1098c0..d31a46190b 100644 --- a/examples/ts-code-mode-web/src/routes/_home/index.tsx +++ b/examples/ts-code-mode-web/src/routes/_home/index.tsx @@ -24,7 +24,7 @@ export const Route = createFileRoute('/_home/')({ component: ProductDemoPage, }) -type Provider = 'anthropic' | 'openai' | 'gemini' +type Provider = 'anthropic' | 'openai' | 'gemini' | 'zai' interface ModelOption { provider: Provider @@ -59,6 +59,8 @@ const MODEL_OPTIONS: Array = [ model: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash', }, + { provider: 'zai', model: 'glm-4.7', label: 'Z.AI GLM-4.7' }, + { provider: 'zai', model: 'glm-5-turbo', label: 'Z.AI GLM-5 Turbo' }, ] const PROMPT_SUGGESTIONS = [ diff --git a/examples/ts-code-mode-web/src/routes/_npm-github-chat/api.codemode.ts b/examples/ts-code-mode-web/src/routes/_npm-github-chat/api.codemode.ts index 40a68b7ec9..aba52d8219 100644 --- a/examples/ts-code-mode-web/src/routes/_npm-github-chat/api.codemode.ts +++ b/examples/ts-code-mode-web/src/routes/_npm-github-chat/api.codemode.ts @@ -4,13 +4,14 @@ import { createCodeMode } from '@tanstack/ai-code-mode' import { anthropicText } from '@tanstack/ai-anthropic' import { openaiText } from '@tanstack/ai-openai' import { geminiText } from '@tanstack/ai-gemini' +import { zaiText } from '@tanstack/ai-zai' import type { AnyTextAdapter, StreamChunk } from '@tanstack/ai' import { allTools } from '@/lib/tools' import { CODE_MODE_SYSTEM_PROMPT } from '@/lib/prompts' import { exportConversationToPdfTool } from '@/lib/tools/export-pdf-tool' import { maxTokensModelOptions } from '@/lib/max-tokens-model-options' -type Provider = 'anthropic' | 'openai' | 'gemini' +type Provider = 'anthropic' | 'openai' | 'gemini' | 'zai' function getAdapter(provider: Provider, model?: string): AnyTextAdapter { switch (provider) { @@ -18,6 +19,8 @@ function getAdapter(provider: Provider, model?: string): AnyTextAdapter { return openaiText((model || 'gpt-4o') as 'gpt-4o') case 'gemini': return geminiText((model || 'gemini-2.5-flash') as 'gemini-2.5-flash') + case 'zai': + return zaiText((model || 'glm-4.7') as 'glm-4.7') case 'anthropic': default: return anthropicText((model || 'claude-haiku-4-5') as 'claude-haiku-4-5') diff --git a/examples/ts-code-mode-web/src/routes/_npm-github-chat/npm-github-chat.tsx b/examples/ts-code-mode-web/src/routes/_npm-github-chat/npm-github-chat.tsx index 3a511a3918..56c5d7e135 100644 --- a/examples/ts-code-mode-web/src/routes/_npm-github-chat/npm-github-chat.tsx +++ b/examples/ts-code-mode-web/src/routes/_npm-github-chat/npm-github-chat.tsx @@ -34,7 +34,7 @@ export const Route = createFileRoute('/_npm-github-chat/npm-github-chat')({ component: CodeModePage, }) -type Provider = 'anthropic' | 'openai' | 'gemini' +type Provider = 'anthropic' | 'openai' | 'gemini' | 'zai' interface ModelOption { provider: Provider @@ -55,6 +55,8 @@ const MODEL_OPTIONS: Array = [ }, { provider: 'openai', model: 'gpt-4o', label: 'GPT-4o' }, { provider: 'gemini', model: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, + { provider: 'zai', model: 'glm-4.7', label: 'Z.AI GLM-4.7' }, + { provider: 'zai', model: 'glm-5-turbo', label: 'Z.AI GLM-5 Turbo' }, ] const PROMPT_SUGGESTIONS = [ diff --git a/examples/ts-code-mode-web/src/routes/_reporting/api.reports.ts b/examples/ts-code-mode-web/src/routes/_reporting/api.reports.ts index 1179da218f..b46c4723ab 100644 --- a/examples/ts-code-mode-web/src/routes/_reporting/api.reports.ts +++ b/examples/ts-code-mode-web/src/routes/_reporting/api.reports.ts @@ -4,6 +4,7 @@ import { createCodeMode } from '@tanstack/ai-code-mode' import { anthropicText } from '@tanstack/ai-anthropic' import { openaiText } from '@tanstack/ai-openai' import { geminiText } from '@tanstack/ai-gemini' +import { zaiText } from '@tanstack/ai-zai' import type { AnyTextAdapter } from '@tanstack/ai' import { allTools } from '@/lib/tools' @@ -12,7 +13,7 @@ import { reportTools } from '@/lib/reports/tools' import { createReportBindings } from '@/lib/reports/create-report-bindings' import { maxTokensModelOptions } from '@/lib/max-tokens-model-options' -type Provider = 'anthropic' | 'openai' | 'gemini' +type Provider = 'anthropic' | 'openai' | 'gemini' | 'zai' function getAdapter(provider: Provider, model?: string): AnyTextAdapter { switch (provider) { @@ -20,6 +21,8 @@ function getAdapter(provider: Provider, model?: string): AnyTextAdapter { return openaiText((model || 'gpt-4o') as 'gpt-4o') case 'gemini': return geminiText((model || 'gemini-2.5-flash') as 'gemini-2.5-flash') + case 'zai': + return zaiText((model || 'glm-4.7') as 'glm-4.7') case 'anthropic': default: return anthropicText((model || 'claude-haiku-4-5') as 'claude-haiku-4-5') diff --git a/examples/ts-code-mode-web/src/routes/_reporting/reporting-agent.tsx b/examples/ts-code-mode-web/src/routes/_reporting/reporting-agent.tsx index f8e4a996b7..a2ad5f7233 100644 --- a/examples/ts-code-mode-web/src/routes/_reporting/reporting-agent.tsx +++ b/examples/ts-code-mode-web/src/routes/_reporting/reporting-agent.tsx @@ -32,7 +32,7 @@ export const Route = createFileRoute('/_reporting/reporting-agent')({ component: ReportingAgentPage, }) -type Provider = 'anthropic' | 'openai' | 'gemini' +type Provider = 'anthropic' | 'openai' | 'gemini' | 'zai' interface ModelOption { provider: Provider diff --git a/examples/ts-code-mode-web/src/routes/_structured-output/api.structured-output.ts b/examples/ts-code-mode-web/src/routes/_structured-output/api.structured-output.ts index 3cea32624c..57ac8e3dd5 100644 --- a/examples/ts-code-mode-web/src/routes/_structured-output/api.structured-output.ts +++ b/examples/ts-code-mode-web/src/routes/_structured-output/api.structured-output.ts @@ -7,6 +7,7 @@ import { createFileSnippetStorage } from '@tanstack/ai-code-mode-snippets/storag import { anthropicText } from '@tanstack/ai-anthropic' import { openaiText } from '@tanstack/ai-openai' import { geminiText } from '@tanstack/ai-gemini' +import { zaiText } from '@tanstack/ai-zai' import { z } from 'zod' import type { AnyTextAdapter } from '@tanstack/ai' @@ -15,7 +16,7 @@ import type { IsolateDriver } from '@tanstack/ai-code-mode' import { cityTools } from '@/lib/tools/city-tools' import { structuredOutput } from '@/lib/structured-output' -type Provider = 'anthropic' | 'openai' | 'gemini' +type Provider = 'anthropic' | 'openai' | 'gemini' | 'zai' const TravelReportSchema = z.object({ title: z.string().describe('Short title for the report'), @@ -45,6 +46,8 @@ function getAdapter(provider: Provider, model?: string): AnyTextAdapter { return openaiText((model || 'gpt-4o') as 'gpt-4o') case 'gemini': return geminiText((model || 'gemini-2.5-flash') as 'gemini-2.5-flash') + case 'zai': + return zaiText((model || 'glm-4.7') as 'glm-4.7') case 'anthropic': default: return anthropicText( diff --git a/examples/ts-react-chat/package.json b/examples/ts-react-chat/package.json index 13ddbcc526..e3a605f28c 100644 --- a/examples/ts-react-chat/package.json +++ b/examples/ts-react-chat/package.json @@ -49,6 +49,7 @@ "@tanstack/ai-sandbox-local-process": "workspace:*", "@tanstack/ai-sandbox-railway": "workspace:*", "@tanstack/ai-sandbox-vercel": "workspace:*", + "@tanstack/ai-zai": "workspace:*", "@tanstack/react-devtools": "^0.9.10", "@tanstack/react-router": "^1.170.41", "@tanstack/react-router-devtools": "^1.158.4", diff --git a/examples/ts-react-chat/src/lib/byok.ts b/examples/ts-react-chat/src/lib/byok.ts index 418931bc8c..7e49879d9c 100644 --- a/examples/ts-react-chat/src/lib/byok.ts +++ b/examples/ts-react-chat/src/lib/byok.ts @@ -10,6 +10,7 @@ import { grokByok } from '@tanstack/ai-grok/byok' import { groqByok } from '@tanstack/ai-groq/byok' import { openaiByok } from '@tanstack/ai-openai/byok' import { openrouterByok } from '@tanstack/ai-openrouter/byok' +import { zaiByok } from '@tanstack/ai-zai/byok' import { createServerFn } from '@tanstack/react-start' import type { ProviderId } from '@tanstack/ai/byok' import type { Provider } from '@/lib/model-selection' @@ -25,6 +26,7 @@ export const KEYED_PROVIDERS = [ byteplusVoiceByok, cloudflareByok, cloudflareAccountByok, + zaiByok, ] as const /** diff --git a/examples/ts-react-chat/src/lib/model-selection.ts b/examples/ts-react-chat/src/lib/model-selection.ts index bf175dce10..1b5ec5dee7 100644 --- a/examples/ts-react-chat/src/lib/model-selection.ts +++ b/examples/ts-react-chat/src/lib/model-selection.ts @@ -10,6 +10,7 @@ export type Provider = | 'bedrock' | 'byteplus' | 'cloudflare' + | 'zai' export interface ModelOption { provider: Provider @@ -56,6 +57,12 @@ export const MODEL_OPTIONS: Array = [ label: 'Cloudflare Gateway - GPT-5.5', }, + // Z.AI direct API. BYOK key from the browser, ZAI_API_KEY in .env as fallback. + { provider: 'zai', model: 'glm-5.3', label: 'Z.AI - GLM-5.3' }, + { provider: 'zai', model: 'glm-5.3-flash', label: 'Z.AI - GLM-5.3 Flash' }, + { provider: 'zai', model: 'glm-5.2', label: 'Z.AI - GLM-5.2' }, + { provider: 'zai', model: 'glm-4.7', label: 'Z.AI - GLM-4.7' }, + // Anthropic { provider: 'anthropic', diff --git a/examples/ts-react-chat/src/routes/api.tanchat.ts b/examples/ts-react-chat/src/routes/api.tanchat.ts index 3f05b9ab37..c7a79bc23e 100644 --- a/examples/ts-react-chat/src/routes/api.tanchat.ts +++ b/examples/ts-react-chat/src/routes/api.tanchat.ts @@ -30,6 +30,8 @@ import { import { BEDROCK_CONVERSE_MODELS, bedrockText } from '@tanstack/ai-bedrock' import { BYTEPLUS_CHAT_MODELS, createBytePlusText } from '@tanstack/ai-byteplus' import { byteplusByok } from '@tanstack/ai-byteplus/byok' +import { createZAIText, ZAI_CHAT_MODELS } from '@tanstack/ai-zai' +import { zaiByok } from '@tanstack/ai-zai/byok' import { byokMissing, getByokKey } from '@tanstack/ai/byok/server' import { z } from 'zod' import type { AnyTextAdapter, ChatMiddleware } from '@tanstack/ai' @@ -104,6 +106,10 @@ const modelSelectionSchema = z provider: z.literal('cloudflare'), model: z.string().catch('@cf/zai-org/glm-5.3-flash'), }), + z.object({ + provider: z.literal('zai'), + model: z.enum(ZAI_CHAT_MODELS).catch('glm-5.3'), + }), ]) .catch({ provider: 'openai', model: 'gpt-5.2' }) @@ -119,6 +125,7 @@ const BYOK_PROVIDERS: Partial> = { grok: grokByok, byteplus: byteplusByok, cloudflare: cloudflareByok, + zai: zaiByok, } function chatByokProvider(provider: Provider): ByokProvider | undefined { @@ -445,6 +452,13 @@ export const Route = createFileRoute('/api/tanchat')({ modelOptions: { options: { top_k: 1 } }, reasoning: 'low', }) + case 'zai': + return createChatOptions({ + threadId: params.threadId, + // Z.AI direct: the browser key wins, ZAI_API_KEY in .env is the + // server fallback. + adapter: createZAIText(model, requireApiKey(apiKey)), + }) case 'openai': return createChatOptions({ threadId: params.threadId, diff --git a/examples/ts-react-search/.env.example b/examples/ts-react-search/.env.example new file mode 100644 index 0000000000..9b674287cd --- /dev/null +++ b/examples/ts-react-search/.env.example @@ -0,0 +1 @@ +ZAI_API_KEY= diff --git a/examples/ts-react-search/package.json b/examples/ts-react-search/package.json index 2c377f7b1a..23cc59fe57 100644 --- a/examples/ts-react-search/package.json +++ b/examples/ts-react-search/package.json @@ -13,8 +13,8 @@ "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/vite": "^4.1.18", "@tanstack/ai": "workspace:*", - "@tanstack/ai-groq": "workspace:*", "@tanstack/ai-react": "workspace:*", + "@tanstack/ai-zai": "workspace:*", "@tanstack/query-db-collection": "^1.0.6", "@tanstack/react-db": "^0.1.55", "@tanstack/react-devtools": "^0.9.10", diff --git a/examples/ts-react-search/src/routes/api/search.ts b/examples/ts-react-search/src/routes/api/search.ts index 6e65b722c7..dd987c8895 100644 --- a/examples/ts-react-search/src/routes/api/search.ts +++ b/examples/ts-react-search/src/routes/api/search.ts @@ -1,6 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' import { chat } from '@tanstack/ai' -import { groqText } from '@tanstack/ai-groq' +import { zaiText } from '@tanstack/ai-zai' import z from 'zod' import { ORDER_STATUS_MAP, @@ -11,7 +11,6 @@ import { DISPUTE_STATUS_MAP, } from '@/features/disputes/constants' import { SETTLEMENT_CURRENCY_MAP } from '@/features/settlements/constants' -import toGroqCompatibleSchema from '@/utils/toGroqCompatibleSchema' const ORDER_STATUS_KEYS = Object.keys(ORDER_STATUS_MAP) const PAYMENT_METHOD_KEYS = Object.keys(PAYMENT_METHOD_MAP) @@ -51,10 +50,6 @@ const outputSchema = z.object({ ]), }) -// Only needed for Groq, since it doesn't support additionalProperties:false on anyOf (union types) -// Otherwise just use the outputSchema directly -const groqOutputSchema = toGroqCompatibleSchema(z.toJSONSchema(outputSchema)) - const SYSTEM_PROMPT = ` JSON API: Convert prompts to structured data. No prose, fences, or comments. @@ -75,10 +70,10 @@ export const Route = createFileRoute('/api/search')({ server: { handlers: { POST: async ({ request }) => { - if (!process.env.GROQ_API_KEY) { + if (!process.env.ZAI_API_KEY) { return new Response( JSON.stringify({ - error: 'GROQ_API_KEY not configured', + error: 'ZAI_API_KEY not configured', }), { status: 500, @@ -91,11 +86,11 @@ export const Route = createFileRoute('/api/search')({ try { const response = await chat({ - adapter: groqText('openai/gpt-oss-20b'), + adapter: zaiText('glm-5.3'), threadId: 'search', messages: [{ role: 'user', content }], systemPrompts: [SYSTEM_PROMPT], - outputSchema: groqOutputSchema, + outputSchema, }) return new Response(JSON.stringify(response), { diff --git a/examples/ts-react-search/src/utils/toGroqCompatibleSchema.ts b/examples/ts-react-search/src/utils/toGroqCompatibleSchema.ts deleted file mode 100644 index f6aeb775e9..0000000000 --- a/examples/ts-react-search/src/utils/toGroqCompatibleSchema.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Groq structured output requires additionalProperties:false on every object, including -// those nested inside anyOf. The @tanstack/ai-groq adapter only recurses into direct -// object properties, not anyOf/oneOf/allOf, so we pre-process the JSON Schema here. -function toGroqCompatibleSchema( - schema: Record, -): Record { - if (typeof schema !== 'object' || Array.isArray(schema)) return schema - const result = { ...schema } - - for (const combiner of ['anyOf', 'oneOf', 'allOf']) { - if (Array.isArray(result[combiner])) { - result[combiner] = result[combiner].map((s: any) => - toGroqCompatibleSchema(s), - ) - } - } - - if (result.$defs) { - result.$defs = Object.fromEntries( - Object.entries(result.$defs).map(([k, v]) => [ - k, - toGroqCompatibleSchema(v as any), - ]), - ) - } - - if (result.type === 'object') { - const properties = Object.fromEntries( - Object.entries(result.properties ?? {}).map(([k, v]) => [ - k, - toGroqCompatibleSchema(v as any), - ]), - ) - result.properties = properties - result.required = Object.keys(properties) - result.additionalProperties = false - } - - if (result.type === 'array' && result.items) { - result.items = toGroqCompatibleSchema(result.items) - } - - return result -} - -export default toGroqCompatibleSchema diff --git a/examples/ts-solid-chat/.env.example b/examples/ts-solid-chat/.env.example index 613cb664b2..b8ea691f2c 100644 --- a/examples/ts-solid-chat/.env.example +++ b/examples/ts-solid-chat/.env.example @@ -1,3 +1,18 @@ # OpenAI API Key # Get yours at: https://platform.openai.com/api-keys -OPENAI_API_KEY=sk-... \ No newline at end of file +OPENAI_API_KEY=sk-... + +# Anthropic API Key +# Get yours at: https://console.anthropic.com/ +ANTHROPIC_API_KEY=sk-ant-... + +# Gemini API Key +# Get yours at: https://aistudio.google.com/ +GEMINI_API_KEY=... + +# Z.AI API Key +# Get yours at: https://open.bigmodel.cn/ +ZAI_API_KEY=... + +# Ollama (local, no API key needed) +# Make sure Ollama is running: ollama serve \ No newline at end of file diff --git a/examples/ts-solid-chat/package.json b/examples/ts-solid-chat/package.json index ebdeb83aac..9d848a5ed5 100644 --- a/examples/ts-solid-chat/package.json +++ b/examples/ts-solid-chat/package.json @@ -16,9 +16,13 @@ "@tanstack/ai-client": "workspace:*", "@tanstack/ai-devtools-core": "workspace:*", "@tanstack/ai-gemini": "workspace:*", + "@tanstack/ai-grok": "workspace:*", + "@tanstack/ai-groq": "workspace:*", "@tanstack/ai-ollama": "workspace:*", "@tanstack/ai-openai": "workspace:*", + "@tanstack/ai-openrouter": "workspace:*", "@tanstack/ai-solid": "workspace:*", + "@tanstack/ai-zai": "workspace:*", "@tanstack/nitro-v2-vite-plugin": "^1.155.0", "@tanstack/router-plugin": "^1.168.42", "@tanstack/solid-ai-devtools": "workspace:*", diff --git a/examples/ts-solid-chat/src/lib/model-selection.ts b/examples/ts-solid-chat/src/lib/model-selection.ts new file mode 100644 index 0000000000..e8d994cc15 --- /dev/null +++ b/examples/ts-solid-chat/src/lib/model-selection.ts @@ -0,0 +1,183 @@ +export type Provider = + | 'openai' + | 'anthropic' + | 'gemini' + | 'ollama' + | 'grok' + | 'groq' + | 'openrouter' + | 'zai' + +export interface ModelOption { + provider: Provider + model: string + label: string +} + +export const MODEL_OPTIONS: Array = [ + { provider: 'openai', model: 'gpt-4o', label: 'OpenAI - GPT-4o' }, + { provider: 'openai', model: 'gpt-4o-mini', label: 'OpenAI - GPT-4o Mini' }, + { provider: 'openai', model: 'gpt-5', label: 'OpenAI - GPT-5' }, + + { + provider: 'anthropic', + model: 'claude-sonnet-4-6', + label: 'Anthropic - Claude Sonnet 4.6', + }, + { + provider: 'anthropic', + model: 'claude-sonnet-4-5-20250929', + label: 'Anthropic - Claude Sonnet 4.5', + }, + { + provider: 'anthropic', + model: 'claude-opus-4-5-20251101', + label: 'Anthropic - Claude Opus 4.5', + }, + { + provider: 'anthropic', + model: 'claude-haiku-4-0-20250514', + label: 'Anthropic - Claude Haiku 4.0', + }, + + { + provider: 'gemini', + model: 'gemini-2.0-flash', + label: 'Gemini - 2.0 Flash', + }, + { + provider: 'gemini', + model: 'gemini-2.5-flash', + label: 'Gemini - 2.5 Flash', + }, + { + provider: 'gemini', + model: 'gemini-2.5-pro', + label: 'Gemini - 2.5 Pro', + }, + + { + provider: 'ollama', + model: 'mistral:7b', + label: 'Ollama - Mistral 7B', + }, + { + provider: 'ollama', + model: 'mistral', + label: 'Ollama - Mistral', + }, + { + provider: 'ollama', + model: 'gpt-oss:20b', + label: 'Ollama - GPT-OSS 20B', + }, + { + provider: 'ollama', + model: 'granite4:3b', + label: 'Ollama - Granite4 3B', + }, + { + provider: 'ollama', + model: 'smollm', + label: 'Ollama - SmolLM', + }, + + { provider: 'zai', model: 'glm-5.3', label: 'Z.AI - GLM-5.3' }, + { provider: 'zai', model: 'glm-5.3-flash', label: 'Z.AI - GLM-5.3 Flash' }, + { provider: 'zai', model: 'glm-5.2', label: 'Z.AI - GLM-5.2' }, + { provider: 'zai', model: 'glm-5.1', label: 'Z.AI - GLM-5.1' }, + { provider: 'zai', model: 'glm-5-turbo', label: 'Z.AI - GLM-5 Turbo' }, + { provider: 'zai', model: 'glm-5', label: 'Z.AI - GLM-5' }, + { provider: 'zai', model: 'glm-5v-turbo', label: 'Z.AI - GLM-5V Turbo' }, + { provider: 'zai', model: 'glm-4.7', label: 'Z.AI - GLM-4.7' }, + { provider: 'zai', model: 'glm-4.6v', label: 'Z.AI - GLM-4.6V' }, + { provider: 'zai', model: 'glm-4.6', label: 'Z.AI - GLM-4.6' }, + + { + provider: 'openrouter', + model: 'openai/chatgpt-4o-latest', + label: 'Openrouter - ChatGPT 4o Latest', + }, + { + provider: 'openrouter', + model: 'openai/chatgpt-4o-mini', + label: 'Openrouter - ChatGPT 4o Mini', + }, + + { + provider: 'groq', + model: 'llama-3.3-70b-versatile', + label: 'Groq - Llama 3.3 70B', + }, + { + provider: 'groq', + model: 'meta-llama/llama-4-maverick-17b-128e-instruct', + label: 'Groq - Llama 4 Maverick', + }, + { + provider: 'groq', + model: 'meta-llama/llama-4-scout-17b-16e-instruct', + label: 'Groq - Llama 4 Scout', + }, + + { + provider: 'grok', + model: 'grok-4', + label: 'Grok - Grok 4', + }, + { + provider: 'grok', + model: 'grok-4-fast-non-reasoning', + label: 'Grok - Grok 4 Fast', + }, + { + provider: 'grok', + model: 'grok-3', + label: 'Grok - Grok 3', + }, + { + provider: 'grok', + model: 'grok-3-mini', + label: 'Grok - Grok 3 Mini', + }, +] + +export const DEFAULT_MODEL_OPTION = MODEL_OPTIONS[0] + +const STORAGE_KEY = 'tanstack-ai-model-preference' + +export function getStoredModelPreference(): ModelOption | null { + if (typeof window === 'undefined') return null + + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (!stored) return null + + const parsed = JSON.parse(stored) as { provider: Provider; model: string } + const option = MODEL_OPTIONS.find( + (opt) => opt.provider === parsed.provider && opt.model === parsed.model, + ) + + return option || null + } catch { + return null + } +} + +export function setStoredModelPreference(option: ModelOption): void { + if (typeof window === 'undefined') return + + try { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ provider: option.provider, model: option.model }), + ) + } catch { + return + } +} + +export function getDefaultModelOption(): ModelOption { + const stored = getStoredModelPreference() + return stored || MODEL_OPTIONS[0] +} diff --git a/examples/ts-solid-chat/src/routes/api.chat.ts b/examples/ts-solid-chat/src/routes/api.chat.ts index 29bb834637..e77d777dc2 100644 --- a/examples/ts-solid-chat/src/routes/api.chat.ts +++ b/examples/ts-solid-chat/src/routes/api.chat.ts @@ -2,12 +2,22 @@ import { createFileRoute } from '@tanstack/solid-router' import { chat, chatParamsFromRequestBody, + createChatOptions, maxIterations, mergeAgentTools, toServerSentEventsResponse, } from '@tanstack/ai' 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 { groqText } from '@tanstack/ai-groq' +import { ollamaText } from '@tanstack/ai-ollama' +import { openaiText } from '@tanstack/ai-openai' +import { zaiText } from '@tanstack/ai-zai' import { serverTools } from '@/lib/guitar-tools' +import type { Provider } from '@/lib/model-selection' +import type { AnyTextAdapter } from '@tanstack/ai' const SYSTEM_PROMPT = `You are a helpful assistant for a guitar store. @@ -32,23 +42,51 @@ Step 2: Call recommendGuitar(id: "6") Step 3: Done - do NOT add any text after calling recommendGuitar ` +const createProviderAdapters = ( + model: string, +): Record { adapter: AnyTextAdapter }> => ({ + anthropic: () => + createChatOptions({ + adapter: anthropicText( + (model || 'claude-sonnet-4-5') as 'claude-sonnet-4-5', + ), + }), + openrouter: () => + createChatOptions({ + adapter: openRouterText('openai/gpt-5.1'), + }), + gemini: () => + createChatOptions({ + adapter: geminiText((model || 'gemini-2.5-flash') as 'gemini-2.5-flash'), + }), + grok: () => + createChatOptions({ + adapter: grokText((model || 'grok-build-0.1') as 'grok-build-0.1'), + }), + groq: () => + createChatOptions({ + adapter: groqText( + (model || 'openai/gpt-oss-120b') as 'openai/gpt-oss-120b', + ), + }), + ollama: () => + createChatOptions({ + adapter: ollamaText((model || 'gpt-oss:20b') as 'gpt-oss:20b'), + }), + openai: () => + createChatOptions({ + adapter: openaiText((model || 'gpt-4o') as 'gpt-4o'), + }), + zai: () => + createChatOptions({ + adapter: zaiText((model || 'glm-5.3') as 'glm-5.3'), + }), +}) + export const Route = createFileRoute('/api/chat')({ server: { handlers: { POST: async ({ request }) => { - if (!process.env.ANTHROPIC_API_KEY) { - return new Response( - JSON.stringify({ - error: - 'ANTHROPIC_API_KEY not configured. Please add it to .env or .env.local', - }), - { - status: 500, - headers: { 'Content-Type': 'application/json' }, - }, - ) - } - // Capture request signal before reading body (it may be aborted after body is consumed) const requestSignal = request.signal @@ -70,17 +108,28 @@ export const Route = createFileRoute('/api/chat')({ } try { + const model = + typeof params.forwardedProps.model === 'string' + ? params.forwardedProps.model + : '' + const providerAdapters = createProviderAdapters(model) + const provider: Provider = + typeof params.forwardedProps.provider === 'string' && + Object.hasOwn(providerAdapters, params.forwardedProps.provider) + ? (params.forwardedProps.provider as Provider) + : 'openai' + const mergedTools = mergeAgentTools(serverTools, params.tools) - // Use the stream abort signal for proper cancellation handling + const options = providerAdapters[provider]() + const stream = chat({ - adapter: anthropicText('claude-sonnet-4-5'), + ...options, tools: mergedTools, systemPrompts: [SYSTEM_PROMPT], agentLoopStrategy: maxIterations(20), messages: params.messages, threadId: params.threadId, runId: params.runId, - reasoning: { level: 'high', budgetTokens: 10000 }, abortController, }) diff --git a/examples/ts-solid-chat/src/routes/index.tsx b/examples/ts-solid-chat/src/routes/index.tsx index b7bacf1bd1..91cbd8308f 100644 --- a/examples/ts-solid-chat/src/routes/index.tsx +++ b/examples/ts-solid-chat/src/routes/index.tsx @@ -4,7 +4,7 @@ import Square from 'lucide-solid/icons/square' import { fetchServerSentEvents, useChat } from '@tanstack/ai-solid' import { createChatClientOptions } from '@tanstack/ai-client' import { TextPart, ThinkingPart } from '@tanstack/ai-solid/ui' -import { For, createSignal } from 'solid-js' +import { For, createSignal, onMount } from 'solid-js' import type { BoundInterrupts, @@ -15,6 +15,13 @@ import type { import type { JSXElement } from 'solid-js' import GuitarRecommendation from '@/components/example-GuitarRecommendation' import { clientTools } from '@/lib/guitar-tools' +import { + DEFAULT_MODEL_OPTION, + MODEL_OPTIONS, + getDefaultModelOption, + setStoredModelPreference, + type ModelOption, +} from '@/lib/model-selection' // Create typed chat options for type inference const chatOptions = createChatClientOptions({ @@ -298,6 +305,20 @@ function DebugPanel(props: { function ChatPage() { const [chunks, setChunks] = createSignal>([]) + const [selectedModel, setSelectedModel] = + createSignal(DEFAULT_MODEL_OPTION) + + onMount(() => setSelectedModel(getDefaultModelOption())) + + const chooseModel = (option: ModelOption) => { + setSelectedModel(option) + setStoredModelPreference(option) + } + + const bodyData = () => ({ + provider: selectedModel().provider, + model: selectedModel().model, + }) const { messages, sendMessage, isLoading, interrupts, stop } = useChat({ ...chatOptions, @@ -313,10 +334,39 @@ function ChatPage() {
{/* Left side - Chat (1/4 width) */}
-
-

+
+

TanStack AI on SolidJS

+
+
+ + +
+
@@ -352,7 +402,7 @@ function ChatPage() { onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey && input().trim()) { e.preventDefault() - sendMessage(input()) + sendMessage(input(), { body: bodyData() }) setInput('') } }} @@ -360,7 +410,7 @@ function ChatPage() {