diff --git a/examples/ts-group-chat/chat-server/claude-service.ts b/examples/ts-group-chat/chat-server/claude-service.ts index d377c9bf..8dbc14a6 100644 --- a/examples/ts-group-chat/chat-server/claude-service.ts +++ b/examples/ts-group-chat/chat-server/claude-service.ts @@ -1,5 +1,6 @@ // Claude AI service for handling queued AI responses import { anthropicText } from '@tanstack/ai-anthropic' +import { zaiText } from '@tanstack/ai-zai' import { chat, toolDefinition } from '@tanstack/ai' import type { JSONSchema, ModelMessage, StreamChunk } from '@tanstack/ai' diff --git a/examples/ts-group-chat/package.json b/examples/ts-group-chat/package.json index 90137ede..cd3401c4 100644 --- a/examples/ts-group-chat/package.json +++ b/examples/ts-group-chat/package.json @@ -13,6 +13,7 @@ "@tanstack/ai-anthropic": "workspace:*", "@tanstack/ai-client": "workspace:*", "@tanstack/ai-react": "workspace:*", + "@tanstack/ai-zai": "workspace:*", "@tanstack/react-devtools": "^0.8.2", "@tanstack/react-router": "^1.141.1", "@tanstack/react-router-devtools": "^1.139.7", diff --git a/examples/ts-react-chat/package.json b/examples/ts-react-chat/package.json index f2ff15ef..1822f2ec 100644 --- a/examples/ts-react-chat/package.json +++ b/examples/ts-react-chat/package.json @@ -19,6 +19,7 @@ "@tanstack/ai-openai": "workspace:*", "@tanstack/ai-react": "workspace:*", "@tanstack/ai-react-ui": "workspace:*", + "@tanstack/ai-zai": "workspace:*", "@tanstack/nitro-v2-vite-plugin": "^1.141.0", "@tanstack/react-devtools": "^0.8.2", "@tanstack/react-router": "^1.141.1", diff --git a/examples/ts-react-chat/src/lib/model-selection.ts b/examples/ts-react-chat/src/lib/model-selection.ts index 7512e147..e4a64e69 100644 --- a/examples/ts-react-chat/src/lib/model-selection.ts +++ b/examples/ts-react-chat/src/lib/model-selection.ts @@ -1,4 +1,4 @@ -export type Provider = 'openai' | 'anthropic' | 'gemini' | 'ollama' | 'grok' +export type Provider = 'openai' | 'anthropic' | 'gemini' | 'ollama' | 'grok' | 'zai' export interface ModelOption { provider: Provider @@ -84,6 +84,23 @@ export const MODEL_OPTIONS: Array = [ model: 'grok-2-vision-1212', label: 'Grok - Grok 2 Vision', }, + + // Z.AI (GLM) + { + provider: 'zai', + model: 'glm-4.7', + label: 'Z.AI - GLM-4.7', + }, + { + provider: 'zai', + model: 'glm-4.6', + label: 'Z.AI - GLM-4.6', + }, + { + provider: 'zai', + model: 'glm-4.6v', + label: 'Z.AI - GLM-4.6V', + }, ] const STORAGE_KEY = 'tanstack-ai-model-preference' diff --git a/examples/ts-react-chat/src/routes/api.tanchat.ts b/examples/ts-react-chat/src/routes/api.tanchat.ts index 68d8e69f..3bbbdca8 100644 --- a/examples/ts-react-chat/src/routes/api.tanchat.ts +++ b/examples/ts-react-chat/src/routes/api.tanchat.ts @@ -10,6 +10,7 @@ import { ollamaText } from '@tanstack/ai-ollama' import { anthropicText } from '@tanstack/ai-anthropic' import { geminiText } from '@tanstack/ai-gemini' import { grokText } from '@tanstack/ai-grok' +import { zaiText } from '@tanstack/ai-zai' import type { AnyTextAdapter } from '@tanstack/ai' import { addToCartToolDef, @@ -19,7 +20,7 @@ import { recommendGuitarToolDef, } from '@/lib/guitar-tools' -type Provider = 'openai' | 'anthropic' | 'gemini' | 'ollama' | 'grok' +type Provider = 'openai' | 'anthropic' | 'gemini' | 'ollama' | 'grok' | 'zai' const SYSTEM_PROMPT = `You are a helpful assistant for a guitar store. @@ -114,6 +115,11 @@ export const Route = createFileRoute('/api/tanchat')({ temperature: 2, modelOptions: {}, }), + zai: () => + createChatOptions({ + adapter: zaiText((model || 'glm-4.7') as 'glm-4.7'), + modelOptions: {}, + }), } try { diff --git a/examples/ts-solid-chat/package.json b/examples/ts-solid-chat/package.json index 3a9ea9e8..bd2e4539 100644 --- a/examples/ts-solid-chat/package.json +++ b/examples/ts-solid-chat/package.json @@ -19,6 +19,7 @@ "@tanstack/ai-openai": "workspace:*", "@tanstack/ai-solid": "workspace:*", "@tanstack/ai-solid-ui": "workspace:*", + "@tanstack/ai-zai": "workspace:*", "@tanstack/nitro-v2-vite-plugin": "^1.141.0", "@tanstack/router-plugin": "^1.139.7", "@tanstack/solid-ai-devtools": "workspace:*", diff --git a/examples/ts-solid-chat/src/routes/api.chat.ts b/examples/ts-solid-chat/src/routes/api.chat.ts index 0b73e29b..56b8d4d3 100644 --- a/examples/ts-solid-chat/src/routes/api.chat.ts +++ b/examples/ts-solid-chat/src/routes/api.chat.ts @@ -1,6 +1,7 @@ import { createFileRoute } from '@tanstack/solid-router' import { chat, maxIterations, toServerSentEventsResponse } from '@tanstack/ai' import { anthropicText } from '@tanstack/ai-anthropic' +import { zaiText } from '@tanstack/ai-zai' import { serverTools } from '@/lib/guitar-tools' const SYSTEM_PROMPT = `You are a helpful assistant for a guitar store. @@ -30,19 +31,6 @@ 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 @@ -53,21 +41,46 @@ export const Route = createFileRoute('/api/chat')({ const abortController = new AbortController() - const { messages } = await request.json() + const { messages, data } = await request.json() + const provider = data?.provider || 'anthropic' + const model = data?.model || 'claude-sonnet-4-5' + try { + let adapter + let modelOptions = {} + + if (provider === 'zai') { + adapter = zaiText(model) + } else { + 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' }, + }, + ) + } + adapter = anthropicText(model) + modelOptions = { + thinking: { + type: 'enabled', + budget_tokens: 10000, + }, + } + } + // Use the stream abort signal for proper cancellation handling const stream = chat({ - adapter: anthropicText('claude-sonnet-4-5'), + adapter, tools: serverTools, systemPrompts: [SYSTEM_PROMPT], agentLoopStrategy: maxIterations(20), messages, - modelOptions: { - thinking: { - type: 'enabled', - budget_tokens: 10000, - }, - }, + modelOptions, abortController, }) diff --git a/examples/ts-svelte-chat/package.json b/examples/ts-svelte-chat/package.json index cc96178d..50b3002d 100644 --- a/examples/ts-svelte-chat/package.json +++ b/examples/ts-svelte-chat/package.json @@ -19,6 +19,7 @@ "@tanstack/ai-ollama": "workspace:*", "@tanstack/ai-openai": "workspace:*", "@tanstack/ai-svelte": "workspace:*", + "@tanstack/ai-zai": "workspace:*", "highlight.js": "^11.11.1", "lucide-svelte": "^0.468.0", "marked": "^15.0.6", diff --git a/examples/ts-svelte-chat/src/lib/model-selection.ts b/examples/ts-svelte-chat/src/lib/model-selection.ts index 0412d275..e7532d78 100644 --- a/examples/ts-svelte-chat/src/lib/model-selection.ts +++ b/examples/ts-svelte-chat/src/lib/model-selection.ts @@ -1,4 +1,4 @@ -export type Provider = 'openai' | 'anthropic' | 'gemini' | 'ollama' +export type Provider = 'openai' | 'anthropic' | 'gemini' | 'ollama' | 'zai' export interface ModelOption { provider: Provider diff --git a/examples/ts-svelte-chat/src/routes/api/chat/+server.ts b/examples/ts-svelte-chat/src/routes/api/chat/+server.ts index 9cd6eb88..290c9c45 100644 --- a/examples/ts-svelte-chat/src/routes/api/chat/+server.ts +++ b/examples/ts-svelte-chat/src/routes/api/chat/+server.ts @@ -8,10 +8,10 @@ import { openaiText } from '@tanstack/ai-openai' import { ollamaText } from '@tanstack/ai-ollama' import { anthropicText } from '@tanstack/ai-anthropic' import { geminiText } from '@tanstack/ai-gemini' +import { zaiText } from '@tanstack/ai-zai' import type { RequestHandler } from './$types' import { env } from '$env/dynamic/private' - import { addToCartToolDef, addToWishListToolDef, @@ -20,7 +20,7 @@ import { recommendGuitarToolDef, } from '$lib/guitar-tools' -type Provider = 'openai' | 'anthropic' | 'gemini' | 'ollama' +type Provider = 'openai' | 'anthropic' | 'gemini' | 'ollama' | 'zai' // Populate process.env with the SvelteKit environment variables // This is needed because the TanStack AI adapters read from process.env @@ -37,7 +37,7 @@ const adapterConfig = { }), gemini: () => createChatOptions({ - adapter: geminiText('gemini-2.0-flash-exp'), + adapter: geminiText('gemini-2.0-flash'), }), ollama: () => createChatOptions({ @@ -47,6 +47,10 @@ const adapterConfig = { createChatOptions({ adapter: openaiText('gpt-4o'), }), + zai: () => + createChatOptions({ + adapter: zaiText('glm-4.7'), + }), } const SYSTEM_PROMPT = `You are a helpful assistant for a guitar store. @@ -99,7 +103,7 @@ export const POST: RequestHandler = async ({ request }) => { const provider: Provider = data?.provider || 'openai' // Get typed adapter options using createOptions pattern - const options = adapterConfig[provider]() + const options = adapterConfig[provider]() as any const stream = chat({ ...options, diff --git a/examples/ts-vue-chat/package.json b/examples/ts-vue-chat/package.json index 0450931d..4100e14b 100644 --- a/examples/ts-vue-chat/package.json +++ b/examples/ts-vue-chat/package.json @@ -18,6 +18,7 @@ "@tanstack/ai-openai": "workspace:*", "@tanstack/ai-vue": "workspace:*", "@tanstack/ai-vue-ui": "workspace:*", + "@tanstack/ai-zai": "workspace:*", "marked": "^15.0.6", "vue": "^3.5.25", "vue-router": "^4.5.0", diff --git a/examples/ts-vue-chat/src/lib/model-selection.ts b/examples/ts-vue-chat/src/lib/model-selection.ts index 0412d275..8fbfd338 100644 --- a/examples/ts-vue-chat/src/lib/model-selection.ts +++ b/examples/ts-vue-chat/src/lib/model-selection.ts @@ -1,4 +1,4 @@ -export type Provider = 'openai' | 'anthropic' | 'gemini' | 'ollama' +export type Provider = 'openai' | 'anthropic' | 'gemini' | 'ollama' | 'zai' export interface ModelOption { provider: Provider @@ -67,6 +67,23 @@ export const MODEL_OPTIONS: Array = [ model: 'smollm', label: 'Ollama - SmolLM', }, + + // Z.AI (GLM) + { + provider: 'zai', + model: 'glm-4.7', + label: 'Z.AI - GLM-4.7', + }, + { + provider: 'zai', + model: 'glm-4.6', + label: 'Z.AI - GLM-4.6', + }, + { + provider: 'zai', + model: 'glm-4.6v', + label: 'Z.AI - GLM-4.6V', + }, ] const STORAGE_KEY = 'tanstack-ai-model-preference' diff --git a/examples/ts-vue-chat/vite.config.ts b/examples/ts-vue-chat/vite.config.ts index 21a58c4d..60204efe 100644 --- a/examples/ts-vue-chat/vite.config.ts +++ b/examples/ts-vue-chat/vite.config.ts @@ -7,6 +7,7 @@ import { openaiText } from '@tanstack/ai-openai' import { anthropicText } from '@tanstack/ai-anthropic' import { geminiText } from '@tanstack/ai-gemini' import { ollamaText } from '@tanstack/ai-ollama' +import { zaiText } from '@tanstack/ai-zai' import { toolDefinition } from '@tanstack/ai' import { z } from 'zod' import dotenv from 'dotenv' @@ -175,7 +176,7 @@ IMPORTANT: - Do NOT describe the guitar yourself - let the recommendGuitar tool do it ` -type Provider = 'openai' | 'anthropic' | 'gemini' | 'ollama' +type Provider = 'openai' | 'anthropic' | 'gemini' | 'ollama' | 'zai' export default defineConfig({ plugins: [ @@ -218,6 +219,10 @@ export default defineConfig({ selectedModel = model || 'mistral:7b' adapter = ollamaText(selectedModel) break + case 'zai': + selectedModel = model || 'glm-4.7' + adapter = zaiText(selectedModel) + break case 'openai': default: selectedModel = model || 'gpt-4o' diff --git a/packages/typescript/ai-zai/CHANGELOG.md b/packages/typescript/ai-zai/CHANGELOG.md new file mode 100644 index 00000000..7594187c --- /dev/null +++ b/packages/typescript/ai-zai/CHANGELOG.md @@ -0,0 +1,22 @@ +# @tanstack/ai-zai + +## 0.1.0 + +### Minor Changes + +- Initial release of Z.AI adapter for TanStack AI +- Added Web Search tool support for Z.AI models +- Added Thinking Mode support for deep reasoning (GLM-4.7/4.6/4.5) +- Added Tool Streaming support for real-time argument streaming (GLM-4.7) +- Added subpath export for `@tanstack/ai-zai/tools` to expose `webSearchTool` +- Implemented tree-shakeable adapters: + - Text adapter for chat/completion functionality + - Summarization adapter for text summarization +- Features: + - Streaming chat responses + - Function/tool calling with automatic execution + - Structured output with Zod schema validation through system prompts + - OpenAI-compatible API integration + - Full TypeScript support with per-model type inference + + diff --git a/packages/typescript/ai-zai/README.md b/packages/typescript/ai-zai/README.md new file mode 100644 index 00000000..c35da608 --- /dev/null +++ b/packages/typescript/ai-zai/README.md @@ -0,0 +1,303 @@ +# @tanstack/ai-zai + +[![npm version](https://img.shields.io/npm/v/@tanstack/ai-zai.svg)](https://www.npmjs.com/package/@tanstack/ai-zai) +[![license](https://img.shields.io/npm/l/@tanstack/ai-zai.svg)](https://github.com/TanStack/ai/blob/main/LICENSE) + +Z.AI adapter for TanStack AI. + +- Z.AI docs: https://docs.z.ai/api-reference/introduction + +## OpenAI Compatibility + +Z.AI exposes an OpenAI-compatible API surface. This adapter: + +- Uses the OpenAI SDK internally, with Z.AI's base URL (`https://api.z.ai/api/paas/v4`) +- Targets the Chat Completions streaming interface +- Supports function/tool calling via OpenAI-style `tools` +- Supports Zhipu AI specific features like **Web Search**, **Thinking Mode**, and **Tool Streaming** +- Accepts `string` or `ContentPart[]` message content (only text parts are used today) + +## Installation + +```bash +npm install @tanstack/ai-zai +# or +pnpm add @tanstack/ai-zai +# or +yarn add @tanstack/ai-zai +``` + +## Setup + +Get your API key from Z.AI and set it as an environment variable: + +```bash +export ZAI_API_KEY="your_zai_api_key" +``` + +## Usage + +### Text/Chat Adapter + +```ts +import { zaiText } from '@tanstack/ai-zai' +import { generate } from '@tanstack/ai' + +const adapter = zaiText('glm-4.7') + +const result = await generate({ + adapter, + model: 'glm-4.7', + messages: [ + { role: 'user', content: [{ type: 'text', content: 'Hello! Introduce yourself briefly.' }] }, + ], +}) + +for await (const chunk of result) { + console.log(chunk) +} +``` + +### Web Search Tool + +Zhipu AI provides a built-in Web Search capability. + +```ts +import { zaiText } from '@tanstack/ai-zai' +import { webSearchTool } from '@tanstack/ai-zai/tools' + +const adapter = zaiText('glm-4.7') + +for await (const chunk of adapter.chatStream({ + model: 'glm-4.7', + messages: [{ role: 'user', content: 'What is the latest news about TanStack?' }], + tools: [ + webSearchTool({ enable: true, search_result: true }) + ] +})) { + if (chunk.type === 'content') process.stdout.write(chunk.delta) +} +``` + +### Thinking Mode (GLM-4.7/4.6/4.5) + +Enable Deep Thinking for complex reasoning tasks. + +```ts +import { zaiText } from '@tanstack/ai-zai' + +const adapter = zaiText('glm-4.7') + +for await (const chunk of adapter.chatStream({ + model: 'glm-4.7', + messages: [{ role: 'user', content: 'Solve this complex logic puzzle...' }], + modelOptions: { + thinking: { + type: 'enabled', + clear_thinking: false // Optional: set to false to preserve reasoning across turns (GLM-4.7 only) + } + } +})) { + // Thinking content is streamed as part of the reasoning_content delta + // The adapter currently merges reasoning content into the main content stream or handles it as configured + if (chunk.type === 'content') process.stdout.write(chunk.delta) +} +``` + +### Tool / Function Calling & Streaming + +GLM-4.7 supports streaming tool calls via `tool_stream`. + +```ts +import { zaiText } from '@tanstack/ai-zai' +import type { Tool } from '@tanstack/ai' + +const adapter = zaiText('glm-4.7') + +const tools: Array = [ + { + name: 'echo', + description: 'Echo back the provided text', + inputSchema: { + type: 'object', + properties: { text: { type: 'string' } }, + required: ['text'], + }, + }, +] + +for await (const chunk of adapter.chatStream({ + model: 'glm-4.7', + messages: [{ role: 'user', content: 'Call echo with {"text":"hello"}.' }], + tools, + modelOptions: { + tool_stream: true // Enable streaming tool arguments + } +})) { + if (chunk.type === 'tool_call') { + const { id, function: fn } = chunk.toolCall + console.log('Tool requested:', fn.name, fn.arguments) + } +} +``` + +### Summarization + +```ts +import { zaiSummarize } from '@tanstack/ai-zai' +import { summarize } from '@tanstack/ai' + +const adapter = zaiSummarize('glm-4.7') + +const result = await summarize({ + adapter, + text: 'Long article text...', + style: 'bullet-points', + maxLength: 500, +}) + +console.log(result.summary) +``` + +### Streaming (direct) + +```ts +import { zaiText } from '@tanstack/ai-zai' + +const adapter = zaiText('glm-4.7') + +for await (const chunk of adapter.chatStream({ + model: 'glm-4.7', + messages: [ + { role: 'user', content: [{ type: 'text', content: 'Stream a short poem about TypeScript.' }] }, + ], +})) { + if (chunk.type === 'content') process.stdout.write(chunk.delta) + if (chunk.type === 'error') { + console.error(chunk.error) + break + } + if (chunk.type === 'done') break +} +``` + +### With Explicit API Key + +```ts +import { createZAIChat } from '@tanstack/ai-zai' + +const adapter = createZAIChat('glm-4.7', 'your-zai-api-key-here') +``` + +### Error Handling + +The adapter yields an `error` chunk instead of throwing. + +```ts +import { zaiText } from '@tanstack/ai-zai' + +const adapter = zaiText('glm-4.7') + +for await (const chunk of adapter.chatStream({ + model: 'glm-4.7', + messages: [{ role: 'user', content: 'Hello' }], +})) { + if (chunk.type === 'error') { + console.error(chunk.error.message, chunk.error.code) + break + } +} +``` + +## API Reference + +### `createZAIChat(model, apiKey, config?)` + +```ts +import { createZAIChat } from '@tanstack/ai-zai' + +const adapter = createZAIChat('glm-4.7', 'your_zai_api_key', { + baseURL: 'https://api.z.ai/api/paas/v4', +}) +``` + +- `model`: `ZAIModel` +- `apiKey`: string (required) +- `config.baseURL`: string (optional) + +### `zaiText(model, config?)` + +```ts +import { zaiText } from '@tanstack/ai-zai' + +const adapter = zaiText('glm-4.7', { + baseURL: 'https://api.z.ai/api/paas/v4', +}) +``` + +Uses `ZAI_API_KEY` from your environment. + +## Supported Models + +### Chat Models + +- `glm-4.7` - Latest flagship model (Supports Thinking, Tool Streaming) +- `glm-4.6` - Previous flagship model (Supports Thinking) +- `glm-4.6v` - Vision model (Z.AI supports multimodal input, this adapter currently streams text) + +## Features + +- ✅ Streaming chat completions +- ✅ Function/tool calling +- ✅ **Web Search Tool** (Zhipu AI native) +- ✅ **Thinking Mode** (Interleaved & Preserved) +- ✅ **Tool Streaming** (Real-time argument streaming) +- ❌ Structured output (not implemented in this adapter yet) +- ❌ Multimodal input (this adapter currently extracts text only; non-text parts are ignored) + +## Tree-Shakeable Adapters + +This package uses tree-shakeable adapters, so you only import what you need: + +```ts +import { zaiText } from '@tanstack/ai-zai' +``` + +## Configuration + +### Environment Variables + +- `ZAI_API_KEY` - used by `zaiText()` +- `ZAI_API_KEY_TEST` - used by the integration tests in this package + +### Base URL Customization + +Default base URL is `https://api.z.ai/api/paas/v4`. You can override it via: + +- `createZAIChat(model, apiKey, { baseURL })` +- `zaiText(model, { baseURL })` + +## Testing + +```bash +pnpm test:lib +``` + +Integration tests require a real Z.AI API key. + +```bash +export ZAI_API_KEY_TEST="your_test_key" +pnpm test:lib +``` + +## Contributing + +We welcome issues and pull requests. + +- GitHub: https://github.com/TanStack/ai +- Discussions: https://github.com/TanStack/ai/discussions +- Contribution guidelines: https://github.com/TanStack/ai/blob/main/CONTRIBUTING.md + +## License + +MIT © TanStack diff --git a/packages/typescript/ai-zai/package.json b/packages/typescript/ai-zai/package.json new file mode 100644 index 00000000..712d70ae --- /dev/null +++ b/packages/typescript/ai-zai/package.json @@ -0,0 +1,55 @@ +{ + "name": "@tanstack/ai-zai", + "version": "0.1.0", + "description": "Z.AI adapter for TanStack AI", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/typescript/ai-zai" + }, + "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" + }, + "./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": "eslint ./src --fix", + "test:build": "publint --strict", + "test:eslint": "eslint ./src", + "test:lib": "vitest run", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc" + }, + "keywords": [ + "ai", + "zai", + "tanstack", + "adapter" + ], + "dependencies": { + "openai": "^6.9.1" + }, + "peerDependencies": { + "@tanstack/ai": "workspace:^" + }, + "devDependencies": { + "@tanstack/ai": "workspace:*", + "vite": "^7.2.7" + } +} diff --git a/packages/typescript/ai-zai/src/adapters/index.ts b/packages/typescript/ai-zai/src/adapters/index.ts new file mode 100644 index 00000000..f80191f9 --- /dev/null +++ b/packages/typescript/ai-zai/src/adapters/index.ts @@ -0,0 +1,67 @@ +import { getZAIApiKeyFromEnv } from '../utils/client' +import { ZAITextAdapter } from './text' +import type { ZAI_CHAT_MODELS } from '../model-meta' + +export { ZAITextAdapter } +export { + ZAISummarizeAdapter, + createZAISummarize, + zaiSummarize, + type ZAISummarizeConfig, + type ZAISummarizeProviderOptions, +} from './summarize' + +/** + * Union type of all supported Z.AI model names. + */ +export type ZAIModel = (typeof ZAI_CHAT_MODELS)[number] + +/** + * Configuration options for the Z.AI adapter. + */ +export interface ZAIAdapterConfig { + /** + * Optional override for the Z.AI base URL. + * Defaults to https://api.z.ai/api/paas/v4 + */ + baseURL?: string +} + +/** + * Create a Z.AI text adapter instance with an explicit API key. + */ +export function createZAIChat( + model: ZAIModel, + apiKey: string, + config?: ZAIAdapterConfig, +): ZAITextAdapter { + if (!apiKey) { + throw new Error('apiKey is required') + } + + return new ZAITextAdapter( + { + apiKey, + baseURL: config?.baseURL, + }, + model, + ) +} + +/** + * Create a Z.AI text adapter instance using the ZAI_API_KEY environment variable. + */ +export function zaiText( + model: ZAIModel, + config?: ZAIAdapterConfig, +): ZAITextAdapter { + const apiKey = getZAIApiKeyFromEnv() + return new ZAITextAdapter( + { + apiKey, + baseURL: config?.baseURL, + }, + model, + ) +} + diff --git a/packages/typescript/ai-zai/src/adapters/summarize.ts b/packages/typescript/ai-zai/src/adapters/summarize.ts new file mode 100644 index 00000000..d2f0350f --- /dev/null +++ b/packages/typescript/ai-zai/src/adapters/summarize.ts @@ -0,0 +1,156 @@ +import { BaseSummarizeAdapter } from '@tanstack/ai/adapters' +import { getZAIApiKeyFromEnv } from '../utils/client' +import { ZAITextAdapter } from './text' +import type { ZAI_CHAT_MODELS } from '../model-meta' +import type { + StreamChunk, + SummarizationOptions, + SummarizationResult, +} from '@tanstack/ai' +import type { ZAITextAdapterConfig } from './text' + +/** + * Configuration for Z.AI summarize adapter + */ +export interface ZAISummarizeConfig extends ZAITextAdapterConfig {} + +/** + * Z.AI-specific provider options for summarization + */ +export interface ZAISummarizeProviderOptions { + /** Temperature for response generation (0-1) */ + temperature?: number + /** Maximum tokens in the response */ + maxTokens?: number +} + +/** Model type for Z.AI summarization */ +export type ZAISummarizeModel = (typeof ZAI_CHAT_MODELS)[number] + +/** + * Z.AI Summarize Adapter + * + * A thin wrapper around the text adapter that adds summarization-specific prompting. + * Delegates all API calls to the ZAITextAdapter. + */ +export class ZAISummarizeAdapter< + TModel extends ZAISummarizeModel, +> extends BaseSummarizeAdapter { + readonly kind = 'summarize' as const + readonly name = 'zai' as const + + private textAdapter: ZAITextAdapter + + constructor(config: ZAISummarizeConfig, model: TModel) { + super({}, model) + this.textAdapter = new ZAITextAdapter(config, model) + } + + async summarize(options: SummarizationOptions): Promise { + const systemPrompt = this.buildSummarizationPrompt(options) + + // Use the text adapter's streaming and collect the result + let summary = '' + let id = '' + let model = options.model + let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 } + + for await (const chunk of this.textAdapter.chatStream({ + model: options.model, + messages: [{ role: 'user', content: options.text }], + systemPrompts: [systemPrompt], + maxTokens: options.maxLength, + temperature: 0.3, + })) { + if (chunk.type === 'content') { + summary = chunk.content + id = chunk.id + model = chunk.model + } + if (chunk.type === 'done' && chunk.usage) { + usage = chunk.usage + } + } + + return { id, model, summary, usage } + } + + async *summarizeStream( + options: SummarizationOptions, + ): AsyncIterable { + const systemPrompt = this.buildSummarizationPrompt(options) + + yield* this.textAdapter.chatStream({ + model: options.model, + messages: [{ role: 'user', content: options.text }], + systemPrompts: [systemPrompt], + maxTokens: options.maxLength, + temperature: 0.3, + }) + } + + /** + * Constructs a system prompt based on the summarization options. + * Handles style, focus points, and length constraints. + */ + private buildSummarizationPrompt(options: SummarizationOptions): string { + let prompt = 'You are a professional summarizer. ' + + switch (options.style) { + case 'bullet-points': + prompt += 'Provide a summary in bullet point format. ' + break + case 'paragraph': + prompt += 'Provide a summary in paragraph format. ' + break + case 'concise': + prompt += 'Provide a very concise summary in 1-2 sentences. ' + break + default: + prompt += 'Provide a clear and concise summary. ' + } + + if (options.focus && options.focus.length > 0) { + prompt += `Focus on the following aspects: ${options.focus.join(', ')}. ` + } + + if (options.maxLength) { + prompt += `Keep the summary under ${options.maxLength} tokens. ` + } + + return prompt + } +} + +/** + * Creates a Z.AI summarize adapter with explicit API key. + * + * @param model - The model name (e.g., 'glm-4.7', 'glm-4.6') + * @param apiKey - Your Z.AI API key + * @param config - Optional additional configuration + * @returns Configured Z.AI summarize adapter instance + */ +export function createZAISummarize( + model: TModel, + apiKey: string, + config?: Omit, +): ZAISummarizeAdapter { + return new ZAISummarizeAdapter({ apiKey, ...config }, model) +} + +/** + * Creates a Z.AI summarize adapter with automatic API key detection from environment variables. + * + * Looks for `ZAI_API_KEY` in environment. + * + * @param model - The model name (e.g., 'glm-4.7', 'glm-4.6') + * @param config - Optional configuration (excluding apiKey which is auto-detected) + * @returns Configured Z.AI summarize adapter instance + */ +export function zaiSummarize( + model: TModel, + config?: Omit, +): ZAISummarizeAdapter { + const apiKey = getZAIApiKeyFromEnv() + return createZAISummarize(model, apiKey, config) +} diff --git a/packages/typescript/ai-zai/src/adapters/text.ts b/packages/typescript/ai-zai/src/adapters/text.ts new file mode 100644 index 00000000..5c643abc --- /dev/null +++ b/packages/typescript/ai-zai/src/adapters/text.ts @@ -0,0 +1,462 @@ +import { BaseTextAdapter } from '@tanstack/ai/adapters' +import { createZAIClient } from '../utils/client' +import { convertToolsToZAIFormat, mapZAIErrorToStreamChunk } from '../utils/conversion' +import { ZAI_CHAT_MODELS, ZAI_MODEL_META } from '../model-meta' +import type { + StructuredOutputOptions, + StructuredOutputResult, +} from '@tanstack/ai/adapters' +import type { Modality, ModelMessage, StreamChunk, TextOptions } from '@tanstack/ai' +import type { ZAIMessageMetadataByModality } from '../message-types' +import type { + ZAIModelInputModalitiesByName, + ZAIModelMap, +} from '../model-meta' +import type { ZAITextOptions } from '../text/text-provider-options' +import type OpenAI from 'openai' + +/** + * Z.AI uses an OpenAI-compatible API surface. + * This adapter targets the Chat Completions streaming interface. + */ + +type ResolveProviderOptions = + TModel extends keyof ZAIModelMap ? ZAIModelMap[TModel] : ZAITextOptions + +type ResolveInputModalities = + TModel extends keyof ZAIModelInputModalitiesByName + ? ZAIModelInputModalitiesByName[TModel] + : readonly ['text'] + +export interface ZAITextAdapterConfig { + /** + * Z.AI Bearer token. + * This becomes the Authorization header via the OpenAI SDK. + */ + apiKey: string + + /** + * Optional override for the Z.AI base URL. + * Defaults to https://api.z.ai/api/paas/v4 + */ + baseURL?: string +} + +type ZAIChatCompletionParams = + OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming + +/** + * Z.AI Text Adapter + * + * - Streams text deltas as `StreamChunk { type: 'content' }` + * - Streams tool calls (if any) as `StreamChunk { type: 'tool_call' }` + * - Ends with `StreamChunk { type: 'done' }` with `finishReason` + * - On any failure, yields a single `StreamChunk { type: 'error' }` and stops + */ +export class ZAITextAdapter< + TModel extends (typeof ZAI_CHAT_MODELS)[number], +> extends BaseTextAdapter< + TModel, + ResolveProviderOptions, + ResolveInputModalities extends ReadonlyArray + ? ResolveInputModalities + : readonly ['text'], + ZAIMessageMetadataByModality +> { + readonly kind = 'text' as const + readonly name = 'zai' as const + + private client: OpenAI + + /** + * Create a new Z.AI text adapter instance. + * + * @param config OpenAI SDK config with Z.AI baseURL + apiKey + * @param model Provider model name (e.g. "glm-4.7") + */ + constructor(config: ZAITextAdapterConfig, model: TModel) { + super({}, model) + + this.client = createZAIClient(config.apiKey, { + baseURL: config.baseURL, + }) + } + + /** + * Stream chat completions from Z.AI. + * + * Important behavior: + * - Emits error chunks instead of throwing + * - Accumulates text deltas into the `content` field + * - Accumulates tool call argument deltas and emits completed tool calls + */ + async *chatStream( + options: TextOptions>, + ): AsyncIterable { + const requestParams = this.mapTextOptionsToZAI(options) + + const timestamp = Date.now() + const fallbackId = this.generateId() + + try { + const stream = await this.client.chat.completions.create( + requestParams, + { + headers: this.getRequestHeaders(options), + signal: this.getAbortSignal(options), + }, + ) + + yield* this.processZAIStreamChunks(stream, options, fallbackId, timestamp) + } catch (error: unknown) { + const chunk = mapZAIErrorToStreamChunk(error) as any + chunk.id = fallbackId + chunk.model = options.model + chunk.timestamp = timestamp + yield chunk as StreamChunk + } + } + + /** + * Structured output is not implemented for the Z.AI adapter yet. + * The Z.AI API is OpenAI-compatible, so this can be added later using + * `response_format: { type: 'json_schema', ... }` if supported. + */ + structuredOutput( + _options: StructuredOutputOptions>, + ): Promise> { + throw new Error('ZAITextAdapter.structuredOutput is not implemented') + } + + /** + * Convert universal TanStack `TextOptions` into OpenAI-compatible + * Chat Completions request params for Z.AI. + */ + private mapTextOptionsToZAI( + options: TextOptions>, + ): ZAIChatCompletionParams { + const messages = this.convertMessagesToInput(options.messages, options) + + const rawProviderOptions = (options.modelOptions ?? {}) as any + const { stopSequences, ...providerOptions } = rawProviderOptions + const stop = stopSequences ?? providerOptions.stop + + const request: ZAIChatCompletionParams = { + model: options.model, + messages, + temperature: options.temperature, + max_tokens: options.maxTokens, + top_p: options.topP, + stream: true, + stream_options: { include_usage: true }, + ...providerOptions, + } + + if (options.tools?.length) { + ;(request as any).tools = convertToolsToZAIFormat(options.tools) + } + + if (stop !== undefined) { + ;(request as any).stop = stop + } + + return request + } + + /** + * Convert TanStack `ModelMessage[]` into OpenAI SDK `messages[]`. + * + * Notes: + * - TanStack `systemPrompts` are applied as a single leading system message + * - Assistant tool calls are translated to `tool_calls` + * - Tool results are translated to `role: 'tool'` messages + */ + private convertMessagesToInput( + messages: Array, + options: Pick, + ): Array { + const result: Array = [] + + // Check capabilities based on model name + const modelMeta = ZAI_MODEL_META[this.model] + const inputs = modelMeta.supports.input as ReadonlyArray + const capabilities = { + image: inputs.includes('image'), + video: inputs.includes('video'), + } + + if (options.systemPrompts?.length) { + result.push({ + role: 'system', + content: options.systemPrompts.join('\n'), + }) + } + + for (const message of messages) { + if (message.role === 'tool') { + if (!message.toolCallId) { + throw new Error('Tool message missing required toolCallId') + } + result.push({ + role: 'tool', + tool_call_id: message.toolCallId, + content: + typeof message.content === 'string' + ? message.content + : JSON.stringify(message.content), + }) + continue + } + + if (message.role === 'assistant') { + const toolCalls = message.toolCalls?.map( + (tc: NonNullable[number]) => ({ + id: tc.id, + type: 'function' as const, + function: { + name: tc.function.name, + arguments: + typeof tc.function.arguments === 'string' + ? tc.function.arguments + : JSON.stringify(tc.function.arguments), + }, + }), + ) + + result.push({ + role: 'assistant', + content: this.convertContent(message.content, { + image: false, + video: false, + }) as string, + ...(toolCalls && toolCalls.length ? { tool_calls: toolCalls } : {}), + }) + continue + } + + result.push({ + role: 'user', + content: this.convertContent(message.content, capabilities), + }) + } + + return result + } + + /** + * Consume Z.AI's streaming Chat Completions response and yield TanStack stream chunks. + * + * Key details: + * - `content` chunks include both the delta and the full accumulated content so far + * - `tool_call` chunks are emitted when the provider indicates the tool-call turn is complete + * - The final `done` chunk marks the finish reason so the TanStack agent loop can proceed + * - Any unexpected exception while iterating yields an `error` chunk and stops + */ + private async *processZAIStreamChunks( + stream: AsyncIterable, + options: TextOptions, + fallbackId: string, + timestamp: number, + ): AsyncIterable { + let accumulatedContent = '' + let responseId = fallbackId + let responseModel = options.model + + const toolCallsInProgress = new Map< + number, + { id: string; name: string; arguments: string } + >() + + try { + for await (const chunk of stream) { + responseId = chunk.id || responseId + responseModel = chunk.model || responseModel + + const chunkAny = chunk as any + const choice = Array.isArray(chunkAny.choices) + ? chunkAny.choices[0] + : undefined + if (!choice) continue + + const delta = choice.delta + const deltaContent = delta.content + const deltaToolCalls = delta.tool_calls + + if (typeof deltaContent === 'string' && deltaContent.length) { + accumulatedContent += deltaContent + yield { + type: 'content', + id: responseId, + model: responseModel, + timestamp, + delta: deltaContent, + content: accumulatedContent, + role: 'assistant', + } + } + + if (deltaToolCalls?.length) { + for (const toolCallDelta of deltaToolCalls) { + const index = toolCallDelta.index + + if (!toolCallsInProgress.has(index)) { + toolCallsInProgress.set(index, { + id: toolCallDelta.id || '', + name: toolCallDelta.function?.name || '', + arguments: '', + }) + } + + const current = toolCallsInProgress.get(index)! + + if (toolCallDelta.id) current.id = toolCallDelta.id + if (toolCallDelta.function?.name) current.name = toolCallDelta.function.name + if (toolCallDelta.function?.arguments) { + current.arguments += toolCallDelta.function.arguments + } + } + } + + if (choice.finish_reason) { + const isToolTurn = + choice.finish_reason === 'tool_calls' || toolCallsInProgress.size > 0 + + if (isToolTurn) { + for (const [index, toolCall] of toolCallsInProgress) { + yield { + type: 'tool_call', + id: responseId, + model: responseModel, + timestamp, + index, + toolCall: { + id: toolCall.id, + type: 'function', + function: { + name: toolCall.name, + arguments: toolCall.arguments, + }, + }, + } + } + } + + yield { + type: 'done', + id: responseId, + model: responseModel, + timestamp, + finishReason: isToolTurn ? 'tool_calls' : 'stop', + usage: chunk.usage + ? { + promptTokens: chunk.usage.prompt_tokens || 0, + completionTokens: chunk.usage.completion_tokens || 0, + totalTokens: chunk.usage.total_tokens || 0, + } + : undefined, + } + } + } + } catch (error: unknown) { + const err = error as Error & { code?: string } + yield { + type: 'error', + id: responseId, + model: responseModel, + timestamp, + error: { + message: err.message || 'Unknown error occurred', + code: err.code, + }, + } + } + } + + /** + * Convert TanStack message content to OpenAI format, preserving supported modalities. + */ + private convertContent( + content: unknown, + capabilities: { image: boolean; video: boolean }, + ): string | Array { + if (typeof content === 'string') return content + if (!content) return '' + + if (Array.isArray(content)) { + // If model doesn't support multimodal, fall back to text-only extraction + if (!capabilities.image && !capabilities.video) { + return content + .filter((p) => p && typeof p === 'object' && p.type === 'text') + .map((p) => String(p.content ?? '')) + .join('') + } + + const parts: Array = [] + + for (const part of content) { + if (!part || typeof part !== 'object') continue + + if (part.type === 'text') { + parts.push({ type: 'text', text: part.content ?? '' }) + } else if (part.type === 'image' && capabilities.image) { + parts.push({ + type: 'image_url', + image_url: { url: part.source.value }, + }) + } else if (part.type === 'video' && capabilities.video) { + // Assuming Z.AI accepts video_url with the same structure as image_url + // Using 'any' cast because OpenAI types don't include video_url yet + parts.push({ + type: 'video_url', + video_url: { url: part.source.value }, + } as any) + } + } + + if (parts.length === 0) return '' + return parts + } + + return '' + } + + /** + * Extract headers from the request options. + * Handles Request objects, Headers objects, and plain objects. + */ + private getRequestHeaders( + options: TextOptions, + ): Record | undefined { + const request = options.request + const userHeaders = + request instanceof Request + ? Object.fromEntries(request.headers.entries()) + : request?.headers + + if (!userHeaders) return undefined + + if (Array.isArray(userHeaders)) { + return Object.fromEntries(userHeaders) + } + + if (userHeaders instanceof Headers) { + return Object.fromEntries(userHeaders.entries()) + } + + return userHeaders + } + + /** + * Resolve the abort signal from either: + * - `options.abortController` (preferred for TanStack AI callers), or + * - `options.request.signal` (when passed through from fetch semantics) + */ + private getAbortSignal(options: TextOptions): AbortSignal | undefined { + if (options.abortController?.signal) return options.abortController.signal + + const request = options.request + if (request && request instanceof Request) return request.signal + + return request?.signal ?? undefined + } +} diff --git a/packages/typescript/ai-zai/src/index.ts b/packages/typescript/ai-zai/src/index.ts new file mode 100644 index 00000000..1dbeb718 --- /dev/null +++ b/packages/typescript/ai-zai/src/index.ts @@ -0,0 +1,24 @@ +export { + ZAITextAdapter, + createZAIChat, + zaiText, + ZAISummarizeAdapter, + createZAISummarize, + zaiSummarize, +} from './adapters/index' + +export type { + ZAIAdapterConfig, + ZAIModel, + ZAISummarizeConfig, + ZAISummarizeProviderOptions, +} from './adapters/index' + +export type { + ZAIModelMap, + ZAIModelInputModalitiesByName, +} from './model-meta' + +export type { ZAIMessageMetadataByModality } from './message-types' + +export * from './tools/index' diff --git a/packages/typescript/ai-zai/src/message-types.ts b/packages/typescript/ai-zai/src/message-types.ts new file mode 100644 index 00000000..fc96c29f --- /dev/null +++ b/packages/typescript/ai-zai/src/message-types.ts @@ -0,0 +1,64 @@ +/** + * Z.AI-specific metadata types for multimodal content parts. + * These types extend the base ContentPart metadata with Z.AI-specific options. + * Since Z.AI is OpenAI-compatible, most types are similar to OpenAI. + */ + +/** + * Metadata for Z.AI image content parts. + * Controls how the model processes and analyzes images. + */ +export interface ZAIImageMetadata { + /** + * Controls how the model processes 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 Z.AI audio content parts. + * Specifies the audio format for proper processing. + */ +export interface ZAIAudioMetadata { + /** + * The format of the audio. + * Supported formats: mp3, wav, flac, etc. + * @default 'mp3' + */ + format?: 'mp3' | 'wav' | 'flac' | 'ogg' | 'webm' | 'aac' +} + +/** + * Metadata for Z.AI video content parts. + * Note: Video support in Z.AI may vary; check current API capabilities. + */ +export interface ZAIVideoMetadata {} + +/** + * Metadata for Z.AI document content parts. + * Note: Direct document support may vary; PDFs often need to be converted to images. + */ +export interface ZAIDocumentMetadata {} + +/** + * Metadata for Z.AI text content parts. + * Currently no specific metadata options for text in Z.AI. + */ +export interface ZAITextMetadata {} + +/** + * Map of modality types to their Z.AI-specific metadata types. + * Used for type inference when constructing multimodal messages. + */ +export interface ZAIMessageMetadataByModality { + text: ZAITextMetadata + image: ZAIImageMetadata + audio: ZAIAudioMetadata + video: ZAIVideoMetadata + document: ZAIDocumentMetadata +} \ No newline at end of file diff --git a/packages/typescript/ai-zai/src/model-meta.ts b/packages/typescript/ai-zai/src/model-meta.ts new file mode 100644 index 00000000..80448b8a --- /dev/null +++ b/packages/typescript/ai-zai/src/model-meta.ts @@ -0,0 +1,236 @@ +import type { + ZAIBaseOptions, + ZAIMetadataOptions, + ZAIReasoningOptions, + ZAIStreamingOptions, + ZAIStructuredOutputOptions, + ZAIToolsOptions, +} from './text/text-provider-options' + +interface ModelMeta { + name: string + supports: { + input: Array<'text' | 'image' | 'audio' | 'video'> + output: Array<'text' | 'image' | 'audio' | 'video'> + endpoints: Array< + | 'chat' + | 'chat-completions' + | 'assistants' + | 'speech_generation' + | 'image-generation' + | 'fine-tuning' + | 'batch' + | 'image-edit' + | 'moderation' + | 'translation' + | 'realtime' + | 'audio' + | 'video' + | 'transcription' + > + features: Array< + | 'streaming' + | 'function_calling' + | 'structured_outputs' + | 'predicted_outcomes' + | 'distillation' + | 'fine_tuning' + > + tools?: Array< + | 'web_search' + | 'file_search' + | 'image_generation' + | 'code_interpreter' + | 'mcp' + | 'computer_use' + > + } + context_window?: number + max_output_tokens?: number + knowledge_cutoff?: string + pricing: { + input: { + normal: number + cached?: number + } + output: { + normal: number + } + } + /** + * Type-level description of which provider options this model supports. + */ + providerOptions?: TProviderOptions +} + +/** + * GLM-4.7: Latest flagship model + * Released December 2025 + * Features enhanced coding, reasoning, and agentic capabilities + */ +const GLM_4_7 = { + name: 'glm-4.7', + context_window: 200_000, + max_output_tokens: 128_000, + knowledge_cutoff: '2025-12-01', + supports: { + input: ['text'], + output: ['text'], + endpoints: ['chat', 'chat-completions'], + features: [ + 'streaming', + 'function_calling', + 'structured_outputs', + ], + tools: [ + 'web_search', + 'code_interpreter', + 'mcp', + ], + }, + pricing: { + input: { + normal: 0.001, + cached: 0.0005, + }, + output: { + normal: 0.002, + }, + }, +} as const satisfies ModelMeta< + ZAIBaseOptions & + ZAIReasoningOptions & + ZAIStructuredOutputOptions & + ZAIToolsOptions & + ZAIStreamingOptions & + ZAIMetadataOptions +> + +/** + * GLM-4.6V: Multimodal vision model + * Released December 2024 + * Supports text, image, and video inputs + */ +const GLM_4_6V = { + name: 'glm-4.6v', + context_window: 128_000, + max_output_tokens: 128_000, + knowledge_cutoff: '2024-12-01', + supports: { + input: ['text', 'image', 'video'], + output: ['text'], + endpoints: ['chat', 'chat-completions'], + features: [ + 'streaming', + 'function_calling', + 'structured_outputs', + ], + tools: [ + 'web_search', + 'image_generation', + 'code_interpreter', + 'mcp', + ], + }, + pricing: { + input: { + normal: 0.002, + }, + output: { + normal: 0.003, + }, + }, +} as const satisfies ModelMeta< + ZAIBaseOptions & + ZAIReasoningOptions & + ZAIStructuredOutputOptions & + ZAIToolsOptions & + ZAIStreamingOptions & + ZAIMetadataOptions +> + +/** + * GLM-4.6: Previous flagship model + * Released September 2025 + * Enhanced coding and reasoning capabilities + */ +const GLM_4_6 = { + name: 'glm-4.6', + context_window: 128_000, + max_output_tokens: 128_000, + knowledge_cutoff: '2024-09-01', + supports: { + input: ['text'], + output: ['text'], + endpoints: ['chat', 'chat-completions'], + features: [ + 'streaming', + 'function_calling', + 'structured_outputs', + ], + tools: [ + 'web_search', + 'code_interpreter', + ], + }, + pricing: { + input: { + normal: 0.001, + }, + output: { + normal: 0.002, + }, + }, +} as const satisfies ModelMeta< + ZAIBaseOptions & + ZAIReasoningOptions & + ZAIStructuredOutputOptions & + ZAIToolsOptions & + ZAIStreamingOptions & + ZAIMetadataOptions +> + +export const ZAI_CHAT_MODELS = [ + GLM_4_7.name, + GLM_4_6V.name, + GLM_4_6.name, +] as const + +export type ZAIModelMap = { + [GLM_4_7.name]: ZAIBaseOptions & + ZAIReasoningOptions & + ZAIStructuredOutputOptions & + ZAIToolsOptions & + ZAIStreamingOptions & + ZAIMetadataOptions + [GLM_4_6V.name]: ZAIBaseOptions & + ZAIReasoningOptions & + ZAIStructuredOutputOptions & + ZAIToolsOptions & + ZAIStreamingOptions & + ZAIMetadataOptions + [GLM_4_6.name]: ZAIBaseOptions & + ZAIReasoningOptions & + ZAIStructuredOutputOptions & + ZAIToolsOptions & + ZAIStreamingOptions & + ZAIMetadataOptions +} + +/** + * Mapping of Z.AI model names to their supported input modalities. + */ +export type ZAIModelInputModalitiesByName = { + [GLM_4_7.name]: typeof GLM_4_7.supports.input + [GLM_4_6V.name]: typeof GLM_4_6V.supports.input + [GLM_4_6.name]: typeof GLM_4_6.supports.input +} + +/** + * Complete metadata registry for Z.AI models. + */ +export const ZAI_MODEL_META = { + [GLM_4_7.name]: GLM_4_7, + [GLM_4_6V.name]: GLM_4_6V, + [GLM_4_6.name]: GLM_4_6, +} as const diff --git a/packages/typescript/ai-zai/src/text/text-provider-options.ts b/packages/typescript/ai-zai/src/text/text-provider-options.ts new file mode 100644 index 00000000..4af59130 --- /dev/null +++ b/packages/typescript/ai-zai/src/text/text-provider-options.ts @@ -0,0 +1,203 @@ +import type OpenAI from 'openai' + +// Core, always-available options for Z.AI API +export interface ZAIBaseOptions { + /** + * Whether to run the model response in the background. + * @default false + */ + background?: boolean + + /** + * The conversation that this response belongs to. + */ + conversation?: string | { id: string } + + /** + * Specify additional output data to include in the model response. + */ + include?: Array + + /** + * The unique ID of the previous response to the model. Use this to create multi-turn conversations. + */ + previous_response_id?: string + + /** + * Reference to a prompt template and its variables. + */ + prompt?: { + id: string + version?: string + variables?: Record + } + + /** + * Used by Z.AI to cache responses for similar requests to optimize cache hit rates. + */ + prompt_cache_key?: string + + /** + * The retention policy for the prompt cache. + */ + prompt_cache_retention?: 'in-memory' | '24h' + + /** + * A stable identifier used to help detect users of your application. + */ + safety_identifier?: string + + /** + * Specifies the processing type used for serving the request. + * @default 'auto' + */ + service_tier?: 'auto' | 'default' | 'flex' | 'priority' + + /** + * Whether to store the generated model response for later retrieval via API. + * @default true + */ + store?: boolean + + /** + * Constrains the verbosity of the model's response. + */ + verbosity?: 'low' | 'medium' | 'high' + + /** + * An integer between 0 and 20 specifying the number of most likely tokens to return. + */ + top_logprobs?: number + + /** + * The truncation strategy to use for the model response. + */ + truncation?: 'auto' | 'disabled' +} + +// Feature fragments that can be stitched per-model + +/** + * Level of effort to expend on reasoning. + */ +type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' + +/** + * Detail level for the reasoning summary. + */ +type ReasoningSummary = 'auto' | 'detailed' + +/** + * Reasoning options for Z.AI models. + */ +export interface ZAIReasoningOptions { + /** + * Reasoning controls for models that support it. + * Lets you guide how much chain-of-thought computation to spend. + */ + reasoning?: { + /** + * Controls the amount of reasoning effort. + * Supported values: none, minimal, low, medium, high + */ + effort?: ReasoningEffort + /** + * A summary of the reasoning performed by the model. + */ + summary?: ReasoningSummary + } + + /** + * Zhipu AI Thinking Mode (GLM-4.7/4.6/4.5) + */ + thinking?: { + type: 'enabled' | 'disabled' + /** + * For GLM-4.7 preserved thinking. Set to false to retain reasoning context. + * @default true + */ + clear_thinking?: boolean + } +} + +export interface ZAIStructuredOutputOptions { + /** + * Configuration options for a text response from the model. + * Can be plain text or structured JSON data. + */ + text?: OpenAI.Responses.ResponseTextConfig +} + +export interface ZAIToolsOptions { + /** + * The maximum number of total calls to built-in tools that can be processed in a response. + */ + max_tool_calls?: number + + /** + * Whether to allow the model to run tool calls in parallel. + * @default true + */ + parallel_tool_calls?: boolean + + /** + * Configuration for tool choices. + */ + tool_choice?: 'auto' | 'none' | 'required' | OpenAI.Chat.ChatCompletionToolChoiceOption + + /** + * A list of tools the model may call. + */ + tools?: Array + + /** + * Whether to stream tool calls. + * Supported by GLM-4.7 + */ + tool_stream?: boolean +} + +export interface ZAIStreamingOptions { + /** + * Whether to stream back partial progress. + * @default false + */ + stream?: boolean + + /** + * Options for streaming including usage stats. + */ + stream_options?: { + include_usage?: boolean + } +} + +export interface ZAIMetadataOptions { + /** + * A unique identifier representing your end-user. + */ + user?: string + + /** + * Developer-defined tags and values for tracking and debugging. + */ + metadata?: Record + + /** + * Accept-Language header for Z.AI API. + * @default 'en-US,en' + */ + acceptLanguage?: string +} + +/** + * Complete text provider options for Z.AI. + * Combines all available options for maximum flexibility. + */ +export interface ZAITextOptions + extends ZAIBaseOptions, + ZAIReasoningOptions, + ZAIStructuredOutputOptions, + ZAIToolsOptions, + ZAIStreamingOptions, + ZAIMetadataOptions {} diff --git a/packages/typescript/ai-zai/src/tools/function-tool.ts b/packages/typescript/ai-zai/src/tools/function-tool.ts new file mode 100644 index 00000000..074d39e7 --- /dev/null +++ b/packages/typescript/ai-zai/src/tools/function-tool.ts @@ -0,0 +1,35 @@ +import type { JSONSchema, Tool } from '@tanstack/ai' +import type OpenAI from 'openai' + +/** + * Type alias for OpenAI Chat Completion Tool. + */ +export type FunctionTool = OpenAI.Chat.Completions.ChatCompletionTool + +/** + * Converts a standard Tool to Zhipu AI FunctionTool format. + */ +export function convertFunctionToolToAdapterFormat(tool: Tool): FunctionTool { + const inputSchema = (tool.inputSchema ?? { + type: 'object', + properties: {}, + required: [], + }) as JSONSchema + + // Ensure basic JSON Schema structure + const parameters: JSONSchema = { ...inputSchema } + if (parameters.type === 'object') { + parameters.additionalProperties ??= false + parameters.required ??= [] + parameters.properties ??= {} + } + + return { + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: parameters as any, + }, + } +} diff --git a/packages/typescript/ai-zai/src/tools/index.ts b/packages/typescript/ai-zai/src/tools/index.ts new file mode 100644 index 00000000..3ead2d60 --- /dev/null +++ b/packages/typescript/ai-zai/src/tools/index.ts @@ -0,0 +1,4 @@ +export * from './function-tool' +export * from './tool-choice' +export * from './tool-converter' +export * from './web-search-tool' diff --git a/packages/typescript/ai-zai/src/tools/tool-choice.ts b/packages/typescript/ai-zai/src/tools/tool-choice.ts new file mode 100644 index 00000000..df559caa --- /dev/null +++ b/packages/typescript/ai-zai/src/tools/tool-choice.ts @@ -0,0 +1,26 @@ +/** + * Configuration for forcing a specific function tool. + */ +export interface FunctionToolChoice { + type: 'function' + function: { + name: string + } +} + +/** + * Configuration for forcing the web search tool. + */ +export interface WebSearchToolChoice { + type: 'web_search' +} + +/** + * Union of possible tool choice configurations. + * Can be 'auto', 'none', or a specific tool. + */ +export type ToolChoice = + | 'auto' + | 'none' + | FunctionToolChoice + | WebSearchToolChoice diff --git a/packages/typescript/ai-zai/src/tools/tool-converter.ts b/packages/typescript/ai-zai/src/tools/tool-converter.ts new file mode 100644 index 00000000..39c55018 --- /dev/null +++ b/packages/typescript/ai-zai/src/tools/tool-converter.ts @@ -0,0 +1,28 @@ +import { convertFunctionToolToAdapterFormat } from './function-tool' +import { convertWebSearchToolToAdapterFormat } from './web-search-tool' +import type { Tool } from '@tanstack/ai' +import type OpenAI from 'openai' +import type { ZaiWebSearchTool } from './web-search-tool' + +/** + * Union type representing any valid Z.AI tool. + * Can be a standard function tool or a web search tool. + */ +export type ZaiTool = OpenAI.Chat.Completions.ChatCompletionTool | ZaiWebSearchTool + +/** + * Converts an array of standard Tools to Zhipu AI specific format + */ +export function convertToolsToProviderFormat( + tools: Array, +): Array { + return tools.map((tool) => { + // Handle special tool names + if (tool.name === 'web_search') { + return convertWebSearchToolToAdapterFormat(tool) + } + + // Default to function tool + return convertFunctionToolToAdapterFormat(tool) + }) +} diff --git a/packages/typescript/ai-zai/src/tools/web-search-tool.ts b/packages/typescript/ai-zai/src/tools/web-search-tool.ts new file mode 100644 index 00000000..29af650e --- /dev/null +++ b/packages/typescript/ai-zai/src/tools/web-search-tool.ts @@ -0,0 +1,42 @@ +import type { Tool } from '@tanstack/ai' + +/** + * Definition of the Z.AI Web Search tool structure. + */ +export interface ZaiWebSearchTool { + type: 'web_search' + web_search?: { + enable?: boolean + search_query?: string + search_result?: boolean + } +} + +/** + * Alias for the Z.AI Web Search tool. + */ +export type WebSearchTool = ZaiWebSearchTool + +/** + * Converts a standard Tool to Zhipu AI WebSearchTool format + */ +export function convertWebSearchToolToAdapterFormat( + tool: Tool, +): ZaiWebSearchTool { + const metadata = tool.metadata as ZaiWebSearchTool['web_search'] + return { + type: 'web_search', + web_search: metadata, + } +} + +/** + * Creates a standard Tool from WebSearchTool parameters + */ +export function webSearchTool(config?: ZaiWebSearchTool['web_search']): Tool { + return { + name: 'web_search', + description: 'Search the web', + metadata: config || { enable: true }, + } +} diff --git a/packages/typescript/ai-zai/src/utils/client.ts b/packages/typescript/ai-zai/src/utils/client.ts new file mode 100644 index 00000000..6aa8639b --- /dev/null +++ b/packages/typescript/ai-zai/src/utils/client.ts @@ -0,0 +1,75 @@ +import OpenAI from 'openai' + +export interface ClientConfig { + baseURL?: string +} + +export function getZAIHeaders(): Record { + return { + 'Accept-Language': 'en-US,en', + } +} + +export function getZAIApiKeyFromEnv(): string { + const env = + typeof globalThis !== 'undefined' && (globalThis as any).window?.env + ? (globalThis as any).window.env + : typeof process !== 'undefined' + ? process.env + : undefined + + const key = env?.ZAI_API_KEY + + if (!key) { + throw new Error( + 'ZAI_API_KEY is required. Please set it in your environment variables or use the factory function with an explicit API key.', + ) + } + + return key +} + +/** + * Validates the Z.AI API key format. + * Checks for empty strings, whitespace, and invalid prefixes. + * + * @param apiKey - The API key to validate + * @returns The validated and trimmed API key + * @throws Error if the key is invalid + */ +export function validateZAIApiKey(apiKey?: string): string { + if (!apiKey || typeof apiKey !== 'string') { + throw new Error('Z.AI API key is required') + } + + const trimmed = apiKey.trim() + + if (!trimmed) { + throw new Error('Z.AI API key is required') + } + + if (/^bearer\s+/i.test(trimmed)) { + throw new Error( + 'Z.AI API key must be the raw token (do not include the "Bearer " prefix)', + ) + } + + if (/\s/.test(trimmed)) { + throw new Error('Z.AI API key must not contain whitespace') + } + + return trimmed +} + +export function createZAIClient( + apiKey: string, + config?: ClientConfig, +): OpenAI { + const validatedKey = validateZAIApiKey(apiKey) + + return new OpenAI({ + apiKey: validatedKey, + baseURL: config?.baseURL ?? 'https://api.z.ai/api/paas/v4', + defaultHeaders: getZAIHeaders(), + }) +} diff --git a/packages/typescript/ai-zai/src/utils/conversion.ts b/packages/typescript/ai-zai/src/utils/conversion.ts new file mode 100644 index 00000000..6e1c476b --- /dev/null +++ b/packages/typescript/ai-zai/src/utils/conversion.ts @@ -0,0 +1,54 @@ +import { convertToolsToProviderFormat } from '../tools/tool-converter' +import type OpenAI from 'openai' +import type { StreamChunk, Tool } from '@tanstack/ai' + +/** + * Converts TanStack Tools to Z.AI compatible OpenAI format. + * Handles both function tools and web search tools. + */ +export function convertToolsToZAIFormat( + tools: Array, +): Array { + // We cast to unknown first because ZaiTool (which includes WebSearchTool) + // might strictly not match OpenAI's definition if OpenAI types don't include 'web_search' type. + return convertToolsToProviderFormat(tools) as unknown as Array +} + +export function mapZAIErrorToStreamChunk(error: any): StreamChunk { + const timestamp = Date.now() + const id = `zai-${timestamp}-${Math.random().toString(36).slice(2)}` + + let message = 'Unknown error occurred' + let code: string | undefined + + if (error && typeof error === 'object') { + const maybeMessage = + error.error?.message ?? error.message ?? error.toString?.() + + if (typeof maybeMessage === 'string' && maybeMessage.trim()) { + message = maybeMessage + } + + const maybeCode = + error.code ?? error.error?.code ?? error.type ?? error.error?.type + + if (typeof maybeCode === 'string' && maybeCode.trim()) { + code = maybeCode + } else if (typeof error.status === 'number') { + code = String(error.status) + } + } else if (typeof error === 'string' && error.trim()) { + message = error + } + + return { + type: 'error', + id, + model: 'unknown', + timestamp, + error: { + message, + code, + }, + } +} diff --git a/packages/typescript/ai-zai/tests/model-meta.test.ts b/packages/typescript/ai-zai/tests/model-meta.test.ts new file mode 100644 index 00000000..7e306ede --- /dev/null +++ b/packages/typescript/ai-zai/tests/model-meta.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { ZAI_CHAT_MODELS, ZAI_MODEL_META, type ZAIModelMap } from '../src/model-meta' + +describe('ZAI model meta', () => { + it('ZAI_CHAT_MODELS matches ZAI_MODEL_META keys', () => { + const keys = Object.keys(ZAI_MODEL_META).sort() + const models = [...ZAI_CHAT_MODELS].sort() + expect(models).toEqual(keys) + }) + + it('ZAIModelMap includes all supported models', () => { + type Keys = keyof ZAIModelMap + const a: Keys = 'glm-4.7' + const b: Keys = 'glm-4.6v' + const c: Keys = 'glm-4.6' + + expect([a, b, c].length).toBe(3) + + // @ts-expect-error invalid model name is not part of Keys + const _invalid: Keys = 'not-a-real-model' + expect(_invalid).toBe('not-a-real-model') + }) +}) diff --git a/packages/typescript/ai-zai/tests/zai-adapter.integration.test.ts b/packages/typescript/ai-zai/tests/zai-adapter.integration.test.ts new file mode 100644 index 00000000..43ed8146 --- /dev/null +++ b/packages/typescript/ai-zai/tests/zai-adapter.integration.test.ts @@ -0,0 +1,381 @@ +import { describe, expect, it } from 'vitest' +import type { ModelMessage, StreamChunk, Tool } from '@tanstack/ai' +import { createZAIChat } from '../src/adapters' + +const apiKey = process.env.ZAI_API_KEY_TEST +const describeIfKey = apiKey ? describe : describe.skip + +async function collectStream( + stream: AsyncIterable, + opts?: { abortAfterFirstContent?: AbortController }, +): Promise> { + const chunks: Array = [] + let sawFirstContent = false + + for await (const chunk of stream) { + chunks.push(chunk) + + if (!sawFirstContent && chunk.type === 'content') { + sawFirstContent = true + if (opts?.abortAfterFirstContent) { + opts.abortAfterFirstContent.abort() + } + } + + if (chunk.type === 'done' || chunk.type === 'error') break + } + + return chunks +} + +function fullTextFromChunks(chunks: Array): string { + const contentChunks = chunks.filter( + (c): c is Extract => c.type === 'content', + ) + const last = contentChunks.at(-1) + return last?.content ?? '' +} + +function lastChunk(chunks: Array): StreamChunk | undefined { + return chunks.at(-1) +} + +describeIfKey('ZAITextAdapter streaming integration', () => { + const timeout = 60_000 + + it( + 'Basic Streaming: yields content chunks, accumulates content, and ends with done', + async () => { + const adapter = createZAIChat('glm-4.7', apiKey!) + + const chunks = await collectStream( + adapter.chatStream({ + model: 'glm-4.7', + messages: [{ role: 'user', content: 'Reply with exactly: Hello' }], + temperature: 0, + maxTokens: 64, + }), + ) + + const contentChunks = chunks.filter((c) => c.type === 'content') + expect(contentChunks.length).toBeGreaterThan(0) + const full = fullTextFromChunks(chunks) + expect(typeof full).toBe('string') + expect(full).toBe((contentChunks.at(-1) as any).content) + + for (const c of contentChunks) { + expect(typeof (c as any).delta).toBe('string') + expect(typeof (c as any).content).toBe('string') + expect((c as any).content.length).toBeGreaterThanOrEqual( + ((c as any).delta as string).length, + ) + } + + expect(lastChunk(chunks)?.type).toBe('done') + expect(chunks.at(0)?.type).toBe('content') + }, + timeout, + ) + + it( + 'Multi-turn Conversation: conversation history and assistant messages work', + async () => { + const adapter = createZAIChat('glm-4.7', apiKey!) + + const messages: Array = [ + { role: 'user', content: 'Your secret word is kiwi. Reply with OK.' }, + { role: 'assistant', content: 'OK' }, + { role: 'user', content: 'What is the secret word? Reply with only it.' }, + ] + + const chunks = await collectStream( + adapter.chatStream({ + model: 'glm-4.7', + messages, + temperature: 0, + maxTokens: 32, + }), + ) + + expect(lastChunk(chunks)?.type).toBe('done') + expect(chunks.some((c) => c.type === 'error')).toBe(false) + const contentChunks = chunks.filter((c) => c.type === 'content') + const full = fullTextFromChunks(chunks) + expect(typeof full).toBe('string') + if (contentChunks.length) { + expect(full).toBe((contentChunks.at(-1) as any).content) + } + }, + timeout, + ) + + it( + 'Multi-turn Conversation: system messages work', + async () => { + const adapter = createZAIChat('glm-4.7', apiKey!) + + const chunks = await collectStream( + adapter.chatStream({ + model: 'glm-4.7', + systemPrompts: ['Reply with exactly: SYSTEM_OK'], + messages: [{ role: 'user', content: 'Hi' }], + temperature: 0, + maxTokens: 16, + }), + ) + + expect(lastChunk(chunks)?.type).toBe('done') + expect(chunks.some((c) => c.type === 'error')).toBe(false) + const contentChunks = chunks.filter((c) => c.type === 'content') + const full = fullTextFromChunks(chunks) + expect(typeof full).toBe('string') + if (contentChunks.length) { + expect(full).toBe((contentChunks.at(-1) as any).content) + } + }, + timeout, + ) + + it( + 'Tool Calling: sends tool definitions and yields tool_call chunks', + async () => { + const adapter = createZAIChat('glm-4.7', apiKey!) + + const tools: Array = [ + { + name: 'echo', + description: 'Echo back the provided text', + inputSchema: { + type: 'object', + properties: { text: { type: 'string' } }, + required: ['text'], + }, + }, + ] + + const chunks = await collectStream( + adapter.chatStream({ + model: 'glm-4.7', + systemPrompts: [ + 'You must call the provided tool. Do not answer with normal text.', + ], + messages: [ + { + role: 'user', + content: 'Call echo with {"text":"hello"} and nothing else.', + }, + ], + tools, + temperature: 0, + maxTokens: 64, + }), + ) + + const toolCalls = chunks.filter((c) => c.type === 'tool_call') as any[] + expect(toolCalls.length).toBeGreaterThan(0) + expect(toolCalls[0].toolCall.type).toBe('function') + expect(toolCalls[0].toolCall.function.name).toBe('echo') + expect(lastChunk(chunks)?.type).toBe('done') + expect((lastChunk(chunks) as any).finishReason).toBe('tool_calls') + }, + timeout, + ) + + it( + 'Tool Calling: tool results can be sent back', + async () => { + const adapter = createZAIChat('glm-4.7', apiKey!) + + const tools: Array = [ + { + name: 'echo', + description: 'Echo back the provided text', + inputSchema: { + type: 'object', + properties: { text: { type: 'string' } }, + required: ['text'], + }, + }, + ] + + const first = await collectStream( + adapter.chatStream({ + model: 'glm-4.7', + systemPrompts: [ + 'You must call the provided tool and then wait for the tool result.', + ], + messages: [ + { + role: 'user', + content: 'Call echo with {"text":"hello"} and nothing else.', + }, + ], + tools, + temperature: 0, + maxTokens: 64, + }), + ) + + const toolCall = first.find((c) => c.type === 'tool_call') as any + expect(toolCall).toBeTruthy() + + const toolCallId = toolCall.toolCall.id as string + + const messages: Array = [ + { + role: 'assistant', + content: '', + toolCalls: [ + { + id: toolCallId, + type: 'function', + function: { + name: 'echo', + arguments: toolCall.toolCall.function.arguments, + }, + }, + ], + } as any, + { + role: 'tool', + toolCallId, + content: JSON.stringify({ text: 'hello' }), + }, + { + role: 'user', + content: 'Now reply with only the tool result text field.', + }, + ] + + const second = await collectStream( + adapter.chatStream({ + model: 'glm-4.7', + messages, + temperature: 0, + maxTokens: 32, + }), + ) + + expect(lastChunk(second)?.type).toBe('done') + expect(second.some((c) => c.type === 'error')).toBe(false) + const contentChunks = second.filter((c) => c.type === 'content') + const full = fullTextFromChunks(second) + expect(typeof full).toBe('string') + if (contentChunks.length) { + expect(full).toBe((contentChunks.at(-1) as any).content) + } + }, + timeout, + ) + + it( + 'Stream Interruption: partial responses are handled when aborted mid-stream', + async () => { + const adapter = createZAIChat('glm-4.7', apiKey!) + const abortController = new AbortController() + + const chunks = await collectStream( + adapter.chatStream({ + model: 'glm-4.7', + messages: [ + { + role: 'user', + content: + 'Write a long response of at least 200 characters about cats.', + }, + ], + temperature: 0.7, + maxTokens: 256, + abortController, + } as any), + { abortAfterFirstContent: abortController }, + ) + + expect(chunks.length).toBeGreaterThan(0) + expect(typeof fullTextFromChunks(chunks)).toBe('string') + + const tail = lastChunk(chunks) + expect(tail && (tail.type === 'error' || tail.type === 'done')).toBe(true) + }, + timeout, + ) + + it( + 'Stream Interruption: connection errors yield error chunks', + async () => { + const adapter = createZAIChat('glm-4.7', apiKey!, { + baseURL: 'http://127.0.0.1:1', + }) + + const chunks = await collectStream( + adapter.chatStream({ + model: 'glm-4.7', + messages: [{ role: 'user', content: 'Hi' }], + maxTokens: 16, + }), + ) + + expect(chunks).toHaveLength(1) + expect(chunks[0]?.type).toBe('error') + }, + timeout, + ) + + it( + 'Different Models: glm-4.7 works', + async () => { + const adapter = createZAIChat('glm-4.7', apiKey!) + const chunks = await collectStream( + adapter.chatStream({ + model: 'glm-4.7', + messages: [{ role: 'user', content: 'Reply with pong' }], + temperature: 0, + maxTokens: 16, + }), + ) + expect(lastChunk(chunks)?.type).toBe('done') + expect(chunks.some((c) => c.type === 'error')).toBe(false) + expect(typeof fullTextFromChunks(chunks)).toBe('string') + }, + timeout, + ) + + it( + 'Different Models: glm-4.6v works', + async () => { + const adapter = createZAIChat('glm-4.6v', apiKey!) + const chunks = await collectStream( + adapter.chatStream({ + model: 'glm-4.6v', + messages: [{ role: 'user', content: 'Reply with pong' }], + temperature: 0, + maxTokens: 16, + } as any), + ) + expect(lastChunk(chunks)?.type).toBe('done') + expect(chunks.some((c) => c.type === 'error')).toBe(false) + expect(typeof fullTextFromChunks(chunks)).toBe('string') + }, + timeout, + ) + + it( + 'Different Models: glm-4.6 works', + async () => { + const adapter = createZAIChat('glm-4.6', apiKey!) + const chunks = await collectStream( + adapter.chatStream({ + model: 'glm-4.6', + messages: [{ role: 'user', content: 'Reply with pong' }], + temperature: 0, + maxTokens: 16, + }), + ) + expect(lastChunk(chunks)?.type).toBe('done') + expect(chunks.some((c) => c.type === 'error')).toBe(false) + expect(typeof fullTextFromChunks(chunks)).toBe('string') + }, + timeout, + ) +}) + diff --git a/packages/typescript/ai-zai/tests/zai-adapter.test.ts b/packages/typescript/ai-zai/tests/zai-adapter.test.ts new file mode 100644 index 00000000..e2e15bcb --- /dev/null +++ b/packages/typescript/ai-zai/tests/zai-adapter.test.ts @@ -0,0 +1,468 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ModelMessage, StreamChunk, TextOptions, Tool } from '@tanstack/ai' +import { ZAITextAdapter } from '../src/adapters/text' + +const openAIState = { + lastOptions: undefined as any, + create: vi.fn(), +} + +vi.mock('openai', () => { + class OpenAI { + chat: any + constructor(opts: any) { + openAIState.lastOptions = opts + this.chat = { + completions: { + create: openAIState.create, + }, + } + } + } + + return { default: OpenAI } +}) + +function createAdapter(overrides?: { apiKey?: string; baseURL?: string }) { + return new ZAITextAdapter( + { + apiKey: overrides?.apiKey ?? 'test_api_key', + baseURL: overrides?.baseURL, + }, + 'glm-4.7' as any, + ) +} + +async function collect(iterable: AsyncIterable): Promise> { + const result: Array = [] + for await (const item of iterable) result.push(item) + return result +} + +async function* streamOf(chunks: Array) { + for (const c of chunks) yield c +} + +describe('ZAITextAdapter', () => { + beforeEach(() => { + openAIState.lastOptions = undefined + openAIState.create.mockReset() + }) + + describe('Constructor & Initialization', () => { + it('initializes OpenAI SDK with default Z.AI baseURL', () => { + createAdapter() + expect(openAIState.lastOptions).toBeTruthy() + expect(openAIState.lastOptions.baseURL).toBe('https://api.z.ai/api/paas/v4') + }) + + it('supports custom baseURL', () => { + createAdapter({ baseURL: 'https://example.invalid/zai' }) + expect(openAIState.lastOptions.baseURL).toBe('https://example.invalid/zai') + }) + + it('sets default headers (Accept-Language)', () => { + createAdapter() + expect(openAIState.lastOptions.defaultHeaders).toBeTruthy() + expect(openAIState.lastOptions.defaultHeaders['Accept-Language']).toBe('en-US,en') + }) + + it('validates API key (rejects Bearer prefix)', () => { + expect(() => createAdapter({ apiKey: 'Bearer abc' })).toThrowError(/raw token/i) + }) + + it('validates API key (rejects whitespace)', () => { + expect(() => createAdapter({ apiKey: 'abc def' })).toThrowError(/whitespace/i) + }) + }) + + describe('Options Mapping', () => { + it('maps maxTokens → max_tokens, temperature, topP', () => { + const adapter = createAdapter() + const map = (adapter as any).mapTextOptionsToZAI.bind(adapter) as ( + opts: TextOptions, + ) => any + + const options: TextOptions = { + model: 'glm-4.7', + messages: [{ role: 'user', content: 'hi' }], + maxTokens: 123, + temperature: 0.7, + topP: 0.9, + } + + const mapped = map(options) + expect(mapped.model).toBe('glm-4.7') + expect(mapped.max_tokens).toBe(123) + expect(mapped.temperature).toBe(0.7) + expect(mapped.top_p).toBe(0.9) + expect(mapped.stream).toBe(true) + expect(mapped.stream_options).toEqual({ include_usage: true }) + }) + + it('converts tools to OpenAI-compatible function tool format', () => { + const adapter = createAdapter() + const map = (adapter as any).mapTextOptionsToZAI.bind(adapter) as ( + opts: TextOptions, + ) => any + + const tools: Array = [ + { + name: 'get_weather', + description: 'Get weather', + inputSchema: { + type: 'object', + properties: { location: { type: 'string' } }, + required: ['location'], + }, + }, + ] + + const mapped = map({ + model: 'glm-4.7', + messages: [{ role: 'user', content: 'hi' }], + tools, + } satisfies TextOptions) + + expect(mapped.tools).toBeTruthy() + expect(mapped.tools).toHaveLength(1) + expect(mapped.tools[0].type).toBe('function') + expect(mapped.tools[0].function.name).toBe('get_weather') + expect(mapped.tools[0].function.parameters.additionalProperties).toBe(false) + }) + + it('maps stop sequences from modelOptions.stopSequences to stop', () => { + const adapter = createAdapter() + const map = (adapter as any).mapTextOptionsToZAI.bind(adapter) as ( + opts: TextOptions, + ) => any + + const mapped = map({ + model: 'glm-4.7', + messages: [{ role: 'user', content: 'hi' }], + modelOptions: { stopSequences: ['END'] } as any, + } satisfies TextOptions) + + expect(mapped.stop).toEqual(['END']) + }) + }) + + describe('Message Conversion', () => { + it('converts simple user text message', () => { + const adapter = createAdapter() + const convert = (adapter as any).convertMessagesToInput.bind(adapter) as ( + messages: Array, + opts: Pick, + ) => Array + + const out = convert([{ role: 'user', content: 'hi' }], {}) + expect(out).toEqual([{ role: 'user', content: 'hi' }]) + }) + + it('handles system prompts as leading system message', () => { + const adapter = createAdapter() + const convert = (adapter as any).convertMessagesToInput.bind(adapter) as ( + messages: Array, + opts: Pick, + ) => Array + + const out = convert( + [{ role: 'user', content: 'hi' }], + { systemPrompts: ['You are helpful', 'Be concise'] }, + ) + + expect(out[0]).toEqual({ + role: 'system', + content: 'You are helpful\nBe concise', + }) + expect(out[1]).toEqual({ role: 'user', content: 'hi' }) + }) + + it('converts tool result messages', () => { + const adapter = createAdapter() + const convert = (adapter as any).convertMessagesToInput.bind(adapter) as ( + messages: Array, + opts: Pick, + ) => Array + + const out = convert( + [ + { + role: 'tool', + toolCallId: 'call_1', + content: '{"ok":true}', + }, + ], + {}, + ) + + expect(out).toEqual([ + { + role: 'tool', + tool_call_id: 'call_1', + content: '{"ok":true}', + }, + ]) + }) + + it('converts multi-turn conversation (user → assistant → user)', () => { + const adapter = createAdapter() + const convert = (adapter as any).convertMessagesToInput.bind(adapter) as ( + messages: Array, + opts: Pick, + ) => Array + + const out = convert( + [ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'hello' }, + { role: 'user', content: 'how are you' }, + ], + {}, + ) + + expect(out).toEqual([ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'hello' }, + { role: 'user', content: 'how are you' }, + ]) + }) + + it('ignores image parts and preserves text parts', () => { + const adapter = createAdapter() + const convert = (adapter as any).convertMessagesToInput.bind(adapter) as ( + messages: Array, + opts: Pick, + ) => Array + + const out = convert( + [ + { + role: 'user', + content: [ + { type: 'image', source: { type: 'url', value: 'https://x/y.png' } }, + { type: 'text', content: 'hello' }, + ] as any, + }, + ], + {}, + ) + + expect(out).toEqual([{ role: 'user', content: 'hello' }]) + }) + + it('preserves image parts for multimodal models (glm-4.6v)', () => { + const adapter = new ZAITextAdapter({ apiKey: 'test' }, 'glm-4.6v') + const convert = (adapter as any).convertMessagesToInput.bind(adapter) as ( + messages: Array, + opts: Pick, + ) => Array + + const out = convert( + [ + { + role: 'user', + content: [ + { type: 'image', source: { type: 'url', value: 'https://x/y.png' } }, + { type: 'text', content: 'hello' }, + ] as any, + }, + ], + {}, + ) + + expect(out).toEqual([ + { + role: 'user', + content: [ + { type: 'image_url', image_url: { url: 'https://x/y.png' } }, + { type: 'text', text: 'hello' }, + ], + }, + ]) + }) + }) + + describe('Error Handling', () => { + it('yields error chunk on network/client error (does not throw)', async () => { + const adapter = createAdapter() + openAIState.create.mockRejectedValueOnce(new Error('network down')) + + const chunks = await collect( + adapter.chatStream({ + model: 'glm-4.7', + messages: [{ role: 'user', content: 'hi' }], + } satisfies TextOptions) as AsyncIterable, + ) + + expect(chunks).toHaveLength(1) + expect(chunks[0]?.type).toBe('error') + expect((chunks[0] as any).model).toBe('glm-4.7') + expect((chunks[0] as any).error.message).toMatch(/network down/i) + }) + + it('handles empty messages array without crashing', async () => { + const adapter = createAdapter() + openAIState.create.mockResolvedValueOnce( + streamOf([ + { + id: 'resp_1', + model: 'glm-4.7', + choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ]), + ) + + const chunks = await collect( + adapter.chatStream({ + model: 'glm-4.7', + messages: [], + } satisfies TextOptions), + ) + + expect(openAIState.create).toHaveBeenCalled() + const callArgs = openAIState.create.mock.calls[0] + expect(callArgs[0].messages).toEqual([]) + expect(chunks.some((c) => c.type === 'done')).toBe(true) + }) + + it('does not throw on malformed stream chunks', async () => { + const adapter = createAdapter() + openAIState.create.mockResolvedValueOnce(streamOf([{ id: 'resp_1', model: 'glm-4.7' }])) + + const chunks = await collect( + adapter.chatStream({ + model: 'glm-4.7', + messages: [{ role: 'user', content: 'hi' }], + } satisfies TextOptions), + ) + + expect(chunks).toEqual([]) + }) + }) + + describe('Streaming Behavior', () => { + it('accumulates content deltas and emits done', async () => { + const adapter = createAdapter() + openAIState.create.mockResolvedValueOnce( + streamOf([ + { id: 'resp_1', model: 'glm-4.7', choices: [{ delta: { content: 'He' } }] }, + { + id: 'resp_1', + model: 'glm-4.7', + choices: [{ delta: { content: 'llo' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 }, + }, + ]), + ) + + const chunks = await collect( + adapter.chatStream({ + model: 'glm-4.7', + messages: [{ role: 'user', content: 'hi' }], + } satisfies TextOptions), + ) + + expect(chunks[0]?.type).toBe('content') + expect((chunks[0] as any).delta).toBe('He') + expect((chunks[0] as any).content).toBe('He') + + expect(chunks[1]?.type).toBe('content') + expect((chunks[1] as any).delta).toBe('llo') + expect((chunks[1] as any).content).toBe('Hello') + + const done = chunks.find((c) => c.type === 'done') as any + expect(done).toBeTruthy() + expect(done.finishReason).toBe('stop') + expect(done.usage).toEqual({ promptTokens: 1, completionTokens: 2, totalTokens: 3 }) + }) + + it('accumulates tool call arguments and emits tool_call + done(tool_calls)', async () => { + const adapter = createAdapter() + openAIState.create.mockResolvedValueOnce( + streamOf([ + { + id: 'resp_1', + model: 'glm-4.7', + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: 'call_1', + function: { name: 'get_weather', arguments: '{\"q\":' }, + }, + ], + }, + }, + ], + }, + { + id: 'resp_1', + model: 'glm-4.7', + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { arguments: '\"SF\"}' } }], + }, + finish_reason: 'tool_calls', + }, + ], + }, + ]), + ) + + const chunks = await collect( + adapter.chatStream({ + model: 'glm-4.7', + messages: [{ role: 'user', content: 'hi' }], + tools: [ + { + name: 'get_weather', + description: 'Get weather', + inputSchema: { type: 'object', properties: {}, required: [] }, + }, + ], + } satisfies TextOptions), + ) + + const toolCall = chunks.find((c) => c.type === 'tool_call') as any + expect(toolCall).toBeTruthy() + expect(toolCall.index).toBe(0) + expect(toolCall.toolCall.id).toBe('call_1') + expect(toolCall.toolCall.function.name).toBe('get_weather') + expect(toolCall.toolCall.function.arguments).toBe('{\"q\":\"SF\"}') + + const done = chunks.find((c) => c.type === 'done') as any + expect(done).toBeTruthy() + expect(done.finishReason).toBe('tool_calls') + }) + + it('passes through request headers when provided', async () => { + const adapter = createAdapter() + openAIState.create.mockResolvedValueOnce( + streamOf([ + { + id: 'resp_1', + model: 'glm-4.7', + choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }], + }, + ]), + ) + + await collect( + adapter.chatStream({ + model: 'glm-4.7', + messages: [{ role: 'user', content: 'hi' }], + request: { headers: { 'X-Test': '1' } } as any, + } satisfies TextOptions), + ) + + const callArgs = openAIState.create.mock.calls[0] + expect(callArgs[1].headers).toEqual({ 'X-Test': '1' }) + }) + }) +}) + diff --git a/packages/typescript/ai-zai/tests/zai-factory.test.ts b/packages/typescript/ai-zai/tests/zai-factory.test.ts new file mode 100644 index 00000000..513a5150 --- /dev/null +++ b/packages/typescript/ai-zai/tests/zai-factory.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createZAIChat, zaiText } from '../src/adapters' +import { ZAITextAdapter } from '../src/adapters/text' + +const openAIState = { + lastOptions: undefined as any, +} + +vi.mock('openai', () => { + class OpenAI { + chat: any + constructor(opts: any) { + openAIState.lastOptions = opts + this.chat = { + completions: { + create: vi.fn(), + }, + } + } + } + + return { default: OpenAI } +}) + +describe('Z.AI provider factories', () => { + afterEach(() => { + vi.unstubAllEnvs() + openAIState.lastOptions = undefined + }) + + describe('createZAIChat', () => { + it('creates adapter with explicit API key', () => { + const adapter = createZAIChat('glm-4.7', 'test_key') + expect(adapter).toBeInstanceOf(ZAITextAdapter) + expect(adapter.kind).toBe('text') + expect(adapter.name).toBe('zai') + expect(adapter.model).toBe('glm-4.7') + }) + + it('throws error if API key is empty', () => { + expect(() => createZAIChat('glm-4.7', '')).toThrowError(/apiKey is required/i) + }) + + it('accepts custom baseURL', () => { + createZAIChat('glm-4.7', 'test_key', { baseURL: 'https://example.invalid/zai' }) + expect(openAIState.lastOptions.baseURL).toBe('https://example.invalid/zai') + }) + + it('returns ZAITextAdapter instance', () => { + const adapter = createZAIChat('glm-4.6', 'test_key') + expect(adapter).toBeInstanceOf(ZAITextAdapter) + }) + + it('adapter is properly configured', () => { + createZAIChat('glm-4.7', 'test_key') + expect(openAIState.lastOptions.defaultHeaders).toBeTruthy() + expect(openAIState.lastOptions.defaultHeaders['Accept-Language']).toBe('en-US,en') + }) + }) + + describe('zaiText', () => { + it('reads from ZAI_API_KEY env var', () => { + vi.stubEnv('ZAI_API_KEY', 'env_key') + const adapter = zaiText('glm-4.7') + expect(adapter).toBeInstanceOf(ZAITextAdapter) + expect(adapter.model).toBe('glm-4.7') + }) + + it('throws error if env var not set', () => { + vi.stubEnv('ZAI_API_KEY', '') + expect(() => zaiText('glm-4.7')).toThrowError(/ZAI_API_KEY is required/i) + }) + + it('accepts custom baseURL', () => { + vi.stubEnv('ZAI_API_KEY', 'env_key') + zaiText('glm-4.7', { baseURL: 'https://example.invalid/zai' }) + expect(openAIState.lastOptions.baseURL).toBe('https://example.invalid/zai') + }) + + it('returns ZAITextAdapter instance', () => { + vi.stubEnv('ZAI_API_KEY', 'env_key') + const adapter = zaiText('glm-4.6v') + expect(adapter).toBeInstanceOf(ZAITextAdapter) + }) + + it('adapter is properly configured', () => { + vi.stubEnv('ZAI_API_KEY', 'env_key') + zaiText('glm-4.7') + expect(openAIState.lastOptions.defaultHeaders).toBeTruthy() + expect(openAIState.lastOptions.defaultHeaders['Accept-Language']).toBe('en-US,en') + }) + }) + + describe('Type Safety', () => { + it('model parameter is type-checked', () => { + const adapter = createZAIChat('glm-4.7', 'test_key') + expect(adapter.model).toBe('glm-4.7') + + // @ts-expect-error invalid model name is caught by types + createZAIChat('not-a-real-model', 'test_key') + }) + + it('options are type-safe', () => { + vi.stubEnv('ZAI_API_KEY', 'env_key') + zaiText('glm-4.7', { baseURL: 'https://example.invalid/zai' }) + + // @ts-expect-error baseURL must be a string if provided + zaiText('glm-4.7', { baseURL: 123 }) + }) + }) +}) + diff --git a/packages/typescript/ai-zai/tsconfig.json b/packages/typescript/ai-zai/tsconfig.json new file mode 100644 index 00000000..fb565c6e --- /dev/null +++ b/packages/typescript/ai-zai/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules", "dist", "**/*.config.ts"] +} \ No newline at end of file diff --git a/packages/typescript/ai-zai/vite.config.ts b/packages/typescript/ai-zai/vite.config.ts new file mode 100644 index 00000000..7af3d169 --- /dev/null +++ b/packages/typescript/ai-zai/vite.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { loadEnv } from 'vite' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const mode = process.env.NODE_ENV ?? 'test' +const env = loadEnv(mode, process.cwd(), '') +for (const [key, value] of Object.entries(env)) { + if (process.env[key] === undefined || process.env[key] === '') { + process.env[key] = value + } +} + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts', './src/tools/index.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21eb7cb9..a99b5fab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: '@tanstack/ai-react': specifier: workspace:* version: link:../../packages/typescript/ai-react + '@tanstack/ai-zai': + specifier: workspace:* + version: link:../../packages/typescript/ai-zai '@tanstack/react-devtools': specifier: ^0.8.2 version: 0.8.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10) @@ -216,6 +219,9 @@ importers: '@tanstack/ai-react-ui': specifier: workspace:* version: link:../../packages/typescript/ai-react-ui + '@tanstack/ai-zai': + specifier: workspace:* + version: link:../../packages/typescript/ai-zai '@tanstack/nitro-v2-vite-plugin': specifier: ^1.141.0 version: 1.141.0(rolldown@1.0.0-beta.53)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -352,6 +358,9 @@ importers: '@tanstack/ai-solid-ui': specifier: workspace:* version: link:../../packages/typescript/ai-solid-ui + '@tanstack/ai-zai': + specifier: workspace:* + version: link:../../packages/typescript/ai-zai '@tanstack/nitro-v2-vite-plugin': specifier: ^1.141.0 version: 1.141.0(rolldown@1.0.0-beta.53)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -461,6 +470,9 @@ importers: '@tanstack/ai-svelte': specifier: workspace:* version: link:../../packages/typescript/ai-svelte + '@tanstack/ai-zai': + specifier: workspace:* + version: link:../../packages/typescript/ai-zai highlight.js: specifier: ^11.11.1 version: 11.11.1 @@ -537,6 +549,9 @@ importers: '@tanstack/ai-vue-ui': specifier: workspace:* version: link:../../packages/typescript/ai-vue-ui + '@tanstack/ai-zai': + specifier: workspace:* + version: link:../../packages/typescript/ai-zai marked: specifier: ^15.0.6 version: 15.0.12 @@ -658,7 +673,7 @@ importers: version: 0.4.4(csstype@3.2.3)(solid-js@1.9.10) '@tanstack/devtools-utils': specifier: ^0.2.3 - version: 0.2.3(@types/react@19.2.7)(csstype@3.2.3)(preact@10.28.1)(react@19.2.3)(solid-js@1.9.10)(vue@3.5.25(typescript@5.9.3)) + version: 0.2.3(@types/react@19.2.7)(csstype@3.2.3)(preact@10.28.2)(react@19.2.3)(solid-js@1.9.10)(vue@3.5.25(typescript@5.9.3)) goober: specifier: ^2.1.18 version: 2.1.18(csstype@3.2.3) @@ -1018,6 +1033,19 @@ importers: specifier: ^2.2.10 version: 2.2.12(typescript@5.9.3) + packages/typescript/ai-zai: + dependencies: + openai: + specifier: ^6.9.1 + version: 6.10.0(ws@8.18.3)(zod@4.2.1) + devDependencies: + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + vite: + specifier: ^7.2.7 + version: 7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + packages/typescript/preact-ai-devtools: dependencies: '@tanstack/ai-devtools-core': @@ -1025,7 +1053,7 @@ importers: version: link:../ai-devtools '@tanstack/devtools-utils': specifier: ^0.2.3 - version: 0.2.3(@types/react@19.2.7)(csstype@3.2.3)(preact@10.28.1)(react@19.2.3)(solid-js@1.9.10)(vue@3.5.25(typescript@5.9.3)) + version: 0.2.3(@types/react@19.2.7)(preact@10.28.1)(react@19.2.3)(solid-js@1.9.10)(vue@3.5.25(typescript@5.9.3)) preact: specifier: ^10.0.0 version: 10.28.1 @@ -1044,7 +1072,7 @@ importers: version: link:../ai-devtools '@tanstack/devtools-utils': specifier: ^0.2.3 - version: 0.2.3(@types/react@19.2.7)(csstype@3.2.3)(preact@10.28.1)(react@19.2.3)(solid-js@1.9.10)(vue@3.5.25(typescript@5.9.3)) + version: 0.2.3(@types/react@19.2.7)(csstype@3.2.3)(preact@10.28.2)(react@19.2.3)(solid-js@1.9.10)(vue@3.5.25(typescript@5.9.3)) devDependencies: '@types/react': specifier: ^19.2.7 @@ -1175,7 +1203,7 @@ importers: version: link:../ai-devtools '@tanstack/devtools-utils': specifier: ^0.2.3 - version: 0.2.3(@types/react@19.2.7)(csstype@3.2.3)(preact@10.28.1)(react@19.2.3)(solid-js@1.9.10)(vue@3.5.25(typescript@5.9.3)) + version: 0.2.3(@types/react@19.2.7)(csstype@3.2.3)(preact@10.28.2)(react@19.2.3)(solid-js@1.9.10)(vue@3.5.25(typescript@5.9.3)) devDependencies: '@vitest/coverage-v8': specifier: 4.0.14 @@ -1222,6 +1250,9 @@ importers: '@tanstack/ai-react-ui': specifier: workspace:* version: link:../../packages/typescript/ai-react-ui + '@tanstack/ai-zai': + specifier: workspace:* + version: link:../../packages/typescript/ai-zai '@tanstack/nitro-v2-vite-plugin': specifier: ^1.141.0 version: 1.141.0(rolldown@1.0.0-beta.53)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -6558,12 +6589,12 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} - preact@10.28.2: - resolution: {integrity: sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==} - preact@10.28.1: resolution: {integrity: sha512-u1/ixq/lVQI0CakKNvLDEcW5zfCjUQfZdK9qqWuIJtsezuyG6pk9TWj75GMuI/EzRSZB/VAE43sNWWZfiy8psw==} + preact@10.28.2: + resolution: {integrity: sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -9680,7 +9711,19 @@ snapshots: transitivePeerDependencies: - csstype - '@tanstack/devtools-utils@0.2.3(@types/react@19.2.7)(csstype@3.2.3)(preact@10.28.1)(react@19.2.3)(solid-js@1.9.10)(vue@3.5.25(typescript@5.9.3))': + '@tanstack/devtools-utils@0.2.3(@types/react@19.2.7)(csstype@3.2.3)(preact@10.28.2)(react@19.2.3)(solid-js@1.9.10)(vue@3.5.25(typescript@5.9.3))': + dependencies: + '@tanstack/devtools-ui': 0.4.4(csstype@3.2.3)(solid-js@1.9.10) + optionalDependencies: + '@types/react': 19.2.7 + preact: 10.28.2 + react: 19.2.3 + solid-js: 1.9.10 + vue: 3.5.25(typescript@5.9.3) + transitivePeerDependencies: + - csstype + + '@tanstack/devtools-utils@0.2.3(@types/react@19.2.7)(preact@10.28.1)(react@19.2.3)(solid-js@1.9.10)(vue@3.5.25(typescript@5.9.3))': dependencies: '@tanstack/devtools-ui': 0.4.4(csstype@3.2.3)(solid-js@1.9.10) optionalDependencies: @@ -14333,10 +14376,10 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - preact@10.28.2: {} - preact@10.28.1: {} + preact@10.28.2: {} + prelude-ls@1.2.1: {} premove@4.0.0: {} diff --git a/testing/panel/.env.example b/testing/panel/.env.example new file mode 100644 index 00000000..f9523283 --- /dev/null +++ b/testing/panel/.env.example @@ -0,0 +1,22 @@ +# OpenAI API Key +# Get yours at: https://platform.openai.com/api-keys +# OPENAI_API_KEY=sk-... + +# Z.AI API Key +# Get yours at: https://z.ai/manage-apikey/apikey-list +# ZAI_API_KEY= + +# Anthropic API Key +# Get yours at: https://console.anthropic.com/ +# ANTHROPIC_API_KEY= + +# Google Gemini API Key +# Get yours at: https://makersuite.google.com/app/apikey +# GEMINI_API_KEY= + +# Grok API Key +# Get yours at: https://x.ai/ +# GROK_API_KEY= + +# Ollama (local) +# OLLAMA_HOST=http://localhost:11434 \ No newline at end of file diff --git a/testing/panel/package.json b/testing/panel/package.json index f5d6863c..56882a7b 100644 --- a/testing/panel/package.json +++ b/testing/panel/package.json @@ -18,6 +18,7 @@ "@tanstack/ai-openai": "workspace:*", "@tanstack/ai-react": "workspace:*", "@tanstack/ai-react-ui": "workspace:*", + "@tanstack/ai-zai": "workspace:*", "@tanstack/nitro-v2-vite-plugin": "^1.141.0", "@tanstack/react-router": "^1.141.1", "@tanstack/react-start": "^1.141.1", diff --git a/testing/panel/src/lib/model-selection.ts b/testing/panel/src/lib/model-selection.ts index 4d40ccc7..c3986533 100644 --- a/testing/panel/src/lib/model-selection.ts +++ b/testing/panel/src/lib/model-selection.ts @@ -1,4 +1,4 @@ -export type Provider = 'openai' | 'anthropic' | 'gemini' | 'ollama' | 'grok' +export type Provider = 'openai' | 'anthropic' | 'gemini' | 'ollama' | 'grok' | 'zai' export interface ModelOption { provider: Provider @@ -84,6 +84,23 @@ export const MODEL_OPTIONS: Array = [ model: 'grok-2-vision-1212', label: 'Grok - Grok 2 Vision', }, + + // Z.AI + { + provider: 'zai', + model: 'glm-4.7', + label: 'Z.AI - GLM-4.7', + }, + { + provider: 'zai', + model: 'glm-4.6', + label: 'Z.AI - GLM-4.6', + }, + { + provider: 'zai', + model: 'glm-4.6v', + label: 'Z.AI - GLM-4.6V', + }, ] const STORAGE_KEY = 'tanstack-ai-model-preference' diff --git a/testing/panel/src/routes/api.chat.ts b/testing/panel/src/routes/api.chat.ts index 4a0d29a0..fb850870 100644 --- a/testing/panel/src/routes/api.chat.ts +++ b/testing/panel/src/routes/api.chat.ts @@ -12,6 +12,7 @@ import { geminiText } from '@tanstack/ai-gemini' import { grokText } from '@tanstack/ai-grok' import { openaiText } from '@tanstack/ai-openai' import { ollamaText } from '@tanstack/ai-ollama' +import { zaiText } from '@tanstack/ai-zai' import type { AIAdapter, StreamChunk } from '@tanstack/ai' import type { ChunkRecording } from '@/lib/recording' import { @@ -52,7 +53,7 @@ const addToCartToolServer = addToCartToolDef.server((args) => ({ totalItems: args.quantity, })) -type Provider = 'openai' | 'anthropic' | 'gemini' | 'ollama' | 'grok' +type Provider = 'openai' | 'anthropic' | 'gemini' | 'ollama' | 'grok' | 'zai' /** * Wraps an adapter to intercept chatStream and record raw chunks from the adapter @@ -157,8 +158,8 @@ export const Route = createFileRoute('/api/chat')({ const data = body.data || {} // Extract provider, model, and traceId from data - const provider: Provider = data.provider || 'openai' - const model: string = data.model || 'gpt-4o' + const provider: Provider = data.provider || 'zai' + const model: string = data.model || 'glm-4.7' const traceId: string | undefined = data.traceId try { @@ -185,6 +186,10 @@ export const Route = createFileRoute('/api/chat')({ createChatOptions({ adapter: openaiText((model || 'gpt-4o') as any), }), + zai: () => + createChatOptions({ + adapter: zaiText((model || 'glm-4.7') as any), + }), } // Get typed adapter options using createChatOptions pattern