From c5ff4851676918e4c8941480a2ba2e09ab9b8299 Mon Sep 17 00:00:00 2001 From: shakibdshy Date: Thu, 15 Jan 2026 18:13:07 +0600 Subject: [PATCH 1/5] feat(zai): add Z.AI adapter for TanStack AI Add new @tanstack/ai-zai package with Z.AI adapter implementation. Includes: - Text adapter for GLM-4.7, GLM-4.6V and GLM-4.6 models - Streaming chat completions with content and tool call chunks - Error handling and type safety - Integration with existing TanStack AI ecosystem - Tests and documentation --- examples/ts-react-chat/package.json | 1 + .../ts-react-chat/src/lib/model-selection.ts | 19 +- .../ts-react-chat/src/routes/api.tanchat.ts | 8 +- packages/typescript/ai-zai/README.md | 217 +++++++++ packages/typescript/ai-zai/package.json | 52 +++ .../typescript/ai-zai/src/adapters/index.ts | 68 +++ .../typescript/ai-zai/src/adapters/text.ts | 381 +++++++++++++++ packages/typescript/ai-zai/src/index.ts | 14 + .../typescript/ai-zai/src/message-types.ts | 64 +++ packages/typescript/ai-zai/src/model-meta.ts | 230 +++++++++ .../ai-zai/src/text/text-provider-options.ts | 187 ++++++++ .../typescript/ai-zai/src/utils/client.ts | 48 ++ .../typescript/ai-zai/src/utils/conversion.ts | 75 +++ .../ai-zai/tests/model-meta.test.ts | 23 + .../tests/zai-adapter.integration.test.ts | 381 +++++++++++++++ .../ai-zai/tests/zai-adapter.test.ts | 437 ++++++++++++++++++ .../ai-zai/tests/zai-factory.test.ts | 112 +++++ packages/typescript/ai-zai/tsconfig.json | 9 + packages/typescript/ai-zai/vite.config.ts | 32 ++ pnpm-lock.yaml | 54 ++- testing/panel/.env.example | 22 + testing/panel/package.json | 1 + testing/panel/src/lib/model-selection.ts | 19 +- testing/panel/src/routes/api.chat.ts | 11 +- 24 files changed, 2449 insertions(+), 16 deletions(-) create mode 100644 packages/typescript/ai-zai/README.md create mode 100644 packages/typescript/ai-zai/package.json create mode 100644 packages/typescript/ai-zai/src/adapters/index.ts create mode 100644 packages/typescript/ai-zai/src/adapters/text.ts create mode 100644 packages/typescript/ai-zai/src/index.ts create mode 100644 packages/typescript/ai-zai/src/message-types.ts create mode 100644 packages/typescript/ai-zai/src/model-meta.ts create mode 100644 packages/typescript/ai-zai/src/text/text-provider-options.ts create mode 100644 packages/typescript/ai-zai/src/utils/client.ts create mode 100644 packages/typescript/ai-zai/src/utils/conversion.ts create mode 100644 packages/typescript/ai-zai/tests/model-meta.test.ts create mode 100644 packages/typescript/ai-zai/tests/zai-adapter.integration.test.ts create mode 100644 packages/typescript/ai-zai/tests/zai-adapter.test.ts create mode 100644 packages/typescript/ai-zai/tests/zai-factory.test.ts create mode 100644 packages/typescript/ai-zai/tsconfig.json create mode 100644 packages/typescript/ai-zai/vite.config.ts create mode 100644 testing/panel/.env.example diff --git a/examples/ts-react-chat/package.json b/examples/ts-react-chat/package.json index f2ff15ef..2c0e85d3 100644 --- a/examples/ts-react-chat/package.json +++ b/examples/ts-react-chat/package.json @@ -17,6 +17,7 @@ "@tanstack/ai-grok": "workspace:*", "@tanstack/ai-ollama": "workspace:*", "@tanstack/ai-openai": "workspace:*", + "@tanstack/ai-zai": "workspace:*", "@tanstack/ai-react": "workspace:*", "@tanstack/ai-react-ui": "workspace:*", "@tanstack/nitro-v2-vite-plugin": "^1.141.0", 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..cabda3b3 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 any), + modelOptions: {}, + }), } try { diff --git a/packages/typescript/ai-zai/README.md b/packages/typescript/ai-zai/README.md new file mode 100644 index 00000000..c7b47829 --- /dev/null +++ b/packages/typescript/ai-zai/README.md @@ -0,0 +1,217 @@ +# @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 + +## 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: 'Hello! Introduce yourself briefly.' }], +}) + +for await (const chunk of result) { + console.log(chunk) +} +``` + +### 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: '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') +``` + +### Tool / Function Calling + +```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, +})) { + if (chunk.type === 'tool_call') { + const { id, function: fn } = chunk.toolCall + console.log('Tool requested:', fn.name, fn.arguments) + } +} +``` + +### 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 +- `glm-4.6` - Previous flagship model +- `glm-4.6v` - Vision model (Z.AI supports multimodal input, this adapter currently streams text) + +## Features + +- ✅ Streaming chat completions +- ✅ Function/tool calling +- ❌ Structured output (not implemented in this adapter yet) +- ❌ Multimodal input (this adapter currently extracts text only) + +## 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..22c59f58 --- /dev/null +++ b/packages/typescript/ai-zai/package.json @@ -0,0 +1,52 @@ +{ + "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" + } + }, + "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:*", + "@vitest/coverage-v8": "4.0.14", + "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..5708c610 --- /dev/null +++ b/packages/typescript/ai-zai/src/adapters/index.ts @@ -0,0 +1,68 @@ +import { ZAITextAdapter } from './text' +import type { ZAI_CHAT_MODELS } from '../model-meta' + +export { ZAITextAdapter } + +export type ZAIModel = (typeof ZAI_CHAT_MODELS)[number] + +export interface ZAIAdapterConfig { + baseURL?: string +} + +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 createZAIChat with an explicit API key.', + ) + } + + return key +} + +/** + * 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/text.ts b/packages/typescript/ai-zai/src/adapters/text.ts new file mode 100644 index 00000000..ad5e1df3 --- /dev/null +++ b/packages/typescript/ai-zai/src/adapters/text.ts @@ -0,0 +1,381 @@ +import { BaseTextAdapter } from '@tanstack/ai/adapters' +import type OpenAI from 'openai' +import type { ModelMessage, StreamChunk, TextOptions } from '@tanstack/ai' +import type { ZAIMessageMetadataByModality } from '../message-types' +import { createZAIClient } from '../utils/client' +import { convertToolsToZAIFormat, mapZAIErrorToStreamChunk } from '../utils/conversion' + +/** + * Z.AI uses an OpenAI-compatible API surface. + * This adapter targets the Chat Completions streaming interface. + */ + +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 extends BaseTextAdapter< + TModel, + Record, + readonly ['text'], + ZAIMessageMetadataByModality +> { + 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. + */ + async structuredOutput(): Promise<{ data: unknown; rawText: string }> { + 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 = [] + + if (options.systemPrompts?.length) { + result.push({ + role: 'system', + content: options.systemPrompts.join('\n'), + }) + } + + for (const message of messages) { + if (message.role === 'tool') { + 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.extractTextContent(message.content), + ...(toolCalls && toolCalls.length ? { tool_calls: toolCalls } : {}), + }) + continue + } + + result.push({ + role: 'user', + content: this.extractTextContent(message.content), + }) + } + + 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 choice = chunk.choices?.[0] + 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, + }, + } + } + } + + /** + * Extract a plain string from TanStack message content. + * The core types allow either `string | null | ContentPart[]`. + */ + private extractTextContent(content: unknown): string { + if (typeof content === 'string') return content + if (!content) return '' + + if (Array.isArray(content)) { + return content + .filter((p) => p && typeof p === 'object' && (p as any).type === 'text') + .map((p) => String((p as any).content ?? '')) + .join('') + } + + return '' + } + + private getRequestHeaders( + options: TextOptions, + ): Record | undefined { + const request = options.request + const userHeaders = + request && request instanceof Request + ? Object.fromEntries(request.headers.entries()) + : (request as RequestInit | undefined)?.headers + + if (!userHeaders) return undefined + + if (Array.isArray(userHeaders)) { + return Object.fromEntries(userHeaders) + } + + if (userHeaders instanceof Headers) { + return Object.fromEntries(userHeaders.entries()) + } + + return userHeaders as Record + } + + /** + * 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 + + const init = request as RequestInit | undefined + return init?.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..9caeef18 --- /dev/null +++ b/packages/typescript/ai-zai/src/index.ts @@ -0,0 +1,14 @@ +export { + ZAITextAdapter, + createZAIChat, + zaiText, +} from './adapters/index' + +export type { ZAIAdapterConfig, ZAIModel } from './adapters/index' + +export type { + ZAIModelMap, + ZAIModelInputModalitiesByName, +} from './model-meta' + +export type { ZAIMessageMetadataByModality } from './message-types' 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..721e400c --- /dev/null +++ b/packages/typescript/ai-zai/src/model-meta.ts @@ -0,0 +1,230 @@ +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 +} + +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 +} + +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..3cf593e4 --- /dev/null +++ b/packages/typescript/ai-zai/src/text/text-provider-options.ts @@ -0,0 +1,187 @@ +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 + +type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' +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, low, medium, high + */ + effort?: ReasoningEffort + /** + * A summary of the reasoning performed by the model. + */ + summary?: ReasoningSummary + } +} + +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 +} + +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 {} + +/** + * Minimal text provider options for Z.AI. + * Includes only the most commonly used options. + */ +export interface ZAITextOptionsMinimal + extends ZAIBaseOptions, + ZAIStreamingOptions, + ZAIMetadataOptions {} \ No newline at end of file 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..2acc4af7 --- /dev/null +++ b/packages/typescript/ai-zai/src/utils/client.ts @@ -0,0 +1,48 @@ +import OpenAI from 'openai' + +export interface ClientConfig { + baseURL?: string +} + +export function getZAIHeaders(): Record { + return { + 'Accept-Language': 'en-US,en', + } +} + +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..66df2e0a --- /dev/null +++ b/packages/typescript/ai-zai/src/utils/conversion.ts @@ -0,0 +1,75 @@ +import type OpenAI from 'openai' +import type { JSONSchema, StreamChunk, Tool } from '@tanstack/ai' + +export function convertToolsToZAIFormat( + tools: Array, +): Array { + return tools.map((tool) => { + const inputSchema = (tool.inputSchema ?? { + type: 'object', + properties: {}, + required: [], + }) as JSONSchema + + 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, + }, + } + }) +} + +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 as any).error?.message ?? + (error as any).message ?? + (error as any).toString?.() + + if (typeof maybeMessage === 'string' && maybeMessage.trim()) { + message = maybeMessage + } + + const maybeCode = + (error as any).code ?? + (error as any).error?.code ?? + (error as any).type ?? + (error as any).error?.type + + if (typeof maybeCode === 'string' && maybeCode.trim()) { + code = maybeCode + } else if (typeof (error as any).status === 'number') { + code = String((error as any).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..489e3560 --- /dev/null +++ b/packages/typescript/ai-zai/tests/zai-adapter.test.ts @@ -0,0 +1,437 @@ +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' }]) + }) + }) + + 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..99cd64ee --- /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'], + srcDir: './src', + cjs: false, + }), +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21eb7cb9..af470ca1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -216,6 +216,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)) @@ -658,7 +661,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 +1021,22 @@ 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 + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(vitest@4.0.15(@types/node@25.0.1)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + 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 +1044,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 +1063,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 +1194,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 +1241,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 +6580,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 +9702,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 +14367,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..fa97c129 --- /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://docs.z.ai/ +# 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..13f1e94c 100644 --- a/testing/panel/package.json +++ b/testing/panel/package.json @@ -16,6 +16,7 @@ "@tanstack/ai-grok": "workspace:*", "@tanstack/ai-ollama": "workspace:*", "@tanstack/ai-openai": "workspace:*", + "@tanstack/ai-zai": "workspace:*", "@tanstack/ai-react": "workspace:*", "@tanstack/ai-react-ui": "workspace:*", "@tanstack/nitro-v2-vite-plugin": "^1.141.0", 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 From 84deb1516763d323ae991d39f759fc3e95733ccc Mon Sep 17 00:00:00 2001 From: shakibdshy Date: Thu, 15 Jan 2026 18:13:47 +0600 Subject: [PATCH 2/5] refactor(ai-zai): improve type safety and code quality - Replace unsafe type assertions with proper type guards and checks - Simplify error handling logic in mapZAIErrorToStreamChunk - Update README with clearer OpenAI compatibility details - Add proper type constraints for ZAITextAdapter model types - Clean up request header and signal handling --- .../ts-react-chat/src/routes/api.tanchat.ts | 2 +- packages/typescript/ai-zai/README.md | 19 ++++- .../typescript/ai-zai/src/adapters/text.ts | 69 ++++++++++++++----- .../typescript/ai-zai/src/utils/conversion.ts | 18 ++--- 4 files changed, 73 insertions(+), 35 deletions(-) diff --git a/examples/ts-react-chat/src/routes/api.tanchat.ts b/examples/ts-react-chat/src/routes/api.tanchat.ts index cabda3b3..3bbbdca8 100644 --- a/examples/ts-react-chat/src/routes/api.tanchat.ts +++ b/examples/ts-react-chat/src/routes/api.tanchat.ts @@ -117,7 +117,7 @@ export const Route = createFileRoute('/api/tanchat')({ }), zai: () => createChatOptions({ - adapter: zaiText((model || 'glm-4.7') as any), + adapter: zaiText((model || 'glm-4.7') as 'glm-4.7'), modelOptions: {}, }), } diff --git a/packages/typescript/ai-zai/README.md b/packages/typescript/ai-zai/README.md index c7b47829..fb3f9789 100644 --- a/packages/typescript/ai-zai/README.md +++ b/packages/typescript/ai-zai/README.md @@ -7,6 +7,15 @@ 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` +- Accepts `string` or `ContentPart[]` message content (only text parts are used today) + ## Installation ```bash @@ -38,7 +47,9 @@ const adapter = zaiText('glm-4.7') const result = await generate({ adapter, model: 'glm-4.7', - messages: [{ role: 'user', content: 'Hello! Introduce yourself briefly.' }], + messages: [ + { role: 'user', content: [{ type: 'text', content: 'Hello! Introduce yourself briefly.' }] }, + ], }) for await (const chunk of result) { @@ -55,7 +66,9 @@ const adapter = zaiText('glm-4.7') for await (const chunk of adapter.chatStream({ model: 'glm-4.7', - messages: [{ role: 'user', content: 'Stream a short poem about TypeScript.' }], + 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') { @@ -167,7 +180,7 @@ Uses `ZAI_API_KEY` from your environment. - ✅ Streaming chat completions - ✅ Function/tool calling - ❌ Structured output (not implemented in this adapter yet) -- ❌ Multimodal input (this adapter currently extracts text only) +- ❌ Multimodal input (this adapter currently extracts text only; non-text parts are ignored) ## Tree-Shakeable Adapters diff --git a/packages/typescript/ai-zai/src/adapters/text.ts b/packages/typescript/ai-zai/src/adapters/text.ts index ad5e1df3..300b61a2 100644 --- a/packages/typescript/ai-zai/src/adapters/text.ts +++ b/packages/typescript/ai-zai/src/adapters/text.ts @@ -1,15 +1,33 @@ import { BaseTextAdapter } from '@tanstack/ai/adapters' -import type OpenAI from 'openai' -import type { ModelMessage, StreamChunk, TextOptions } from '@tanstack/ai' -import type { ZAIMessageMetadataByModality } from '../message-types' import { createZAIClient } from '../utils/client' import { convertToolsToZAIFormat, mapZAIErrorToStreamChunk } from '../utils/conversion' +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, + ZAI_CHAT_MODELS, +} 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. @@ -35,12 +53,17 @@ type ZAIChatCompletionParams = * - Ends with `StreamChunk { type: 'done' }` with `finishReason` * - On any failure, yields a single `StreamChunk { type: 'error' }` and stops */ -export class ZAITextAdapter extends BaseTextAdapter< +export class ZAITextAdapter< + TModel extends (typeof ZAI_CHAT_MODELS)[number], +> extends BaseTextAdapter< TModel, - Record, - readonly ['text'], + ResolveProviderOptions, + ResolveInputModalities extends ReadonlyArray + ? ResolveInputModalities + : readonly ['text'], ZAIMessageMetadataByModality > { + readonly kind = 'text' as const readonly name = 'zai' as const private client: OpenAI @@ -67,7 +90,9 @@ export class ZAITextAdapter extends BaseTextAdapter< * - Accumulates text deltas into the `content` field * - Accumulates tool call argument deltas and emits completed tool calls */ - async *chatStream(options: TextOptions): AsyncIterable { + async *chatStream( + options: TextOptions>, + ): AsyncIterable { const requestParams = this.mapTextOptionsToZAI(options) const timestamp = Date.now() @@ -97,7 +122,9 @@ export class ZAITextAdapter extends BaseTextAdapter< * The Z.AI API is OpenAI-compatible, so this can be added later using * `response_format: { type: 'json_schema', ... }` if supported. */ - async structuredOutput(): Promise<{ data: unknown; rawText: string }> { + structuredOutput( + _options: StructuredOutputOptions>, + ): Promise> { throw new Error('ZAITextAdapter.structuredOutput is not implemented') } @@ -105,7 +132,9 @@ export class ZAITextAdapter extends BaseTextAdapter< * Convert universal TanStack `TextOptions` into OpenAI-compatible * Chat Completions request params for Z.AI. */ - private mapTextOptionsToZAI(options: TextOptions): ZAIChatCompletionParams { + private mapTextOptionsToZAI( + options: TextOptions>, + ): ZAIChatCompletionParams { const messages = this.convertMessagesToInput(options.messages, options) const rawProviderOptions = (options.modelOptions ?? {}) as any @@ -227,12 +256,15 @@ export class ZAITextAdapter extends BaseTextAdapter< responseId = chunk.id || responseId responseModel = chunk.model || responseModel - const choice = chunk.choices?.[0] + 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 + const deltaContent = delta.content + const deltaToolCalls = delta.tool_calls if (typeof deltaContent === 'string' && deltaContent.length) { accumulatedContent += deltaContent @@ -334,8 +366,8 @@ export class ZAITextAdapter extends BaseTextAdapter< if (Array.isArray(content)) { return content - .filter((p) => p && typeof p === 'object' && (p as any).type === 'text') - .map((p) => String((p as any).content ?? '')) + .filter((p) => p && typeof p === 'object' && p.type === 'text') + .map((p) => String(p.content ?? '')) .join('') } @@ -347,9 +379,9 @@ export class ZAITextAdapter extends BaseTextAdapter< ): Record | undefined { const request = options.request const userHeaders = - request && request instanceof Request + request instanceof Request ? Object.fromEntries(request.headers.entries()) - : (request as RequestInit | undefined)?.headers + : request?.headers if (!userHeaders) return undefined @@ -361,7 +393,7 @@ export class ZAITextAdapter extends BaseTextAdapter< return Object.fromEntries(userHeaders.entries()) } - return userHeaders as Record + return userHeaders } /** @@ -375,7 +407,6 @@ export class ZAITextAdapter extends BaseTextAdapter< const request = options.request if (request && request instanceof Request) return request.signal - const init = request as RequestInit | undefined - return init?.signal ?? undefined + return request?.signal ?? undefined } } diff --git a/packages/typescript/ai-zai/src/utils/conversion.ts b/packages/typescript/ai-zai/src/utils/conversion.ts index 66df2e0a..cfca7d38 100644 --- a/packages/typescript/ai-zai/src/utils/conversion.ts +++ b/packages/typescript/ai-zai/src/utils/conversion.ts @@ -5,11 +5,11 @@ export function convertToolsToZAIFormat( tools: Array, ): Array { return tools.map((tool) => { - const inputSchema = (tool.inputSchema ?? { + const inputSchema: JSONSchema = tool.inputSchema ?? { type: 'object', properties: {}, required: [], - }) as JSONSchema + } const parameters: JSONSchema = { ...inputSchema } if (parameters.type === 'object') { @@ -38,24 +38,19 @@ export function mapZAIErrorToStreamChunk(error: any): StreamChunk { if (error && typeof error === 'object') { const maybeMessage = - (error as any).error?.message ?? - (error as any).message ?? - (error as any).toString?.() + error.error?.message ?? error.message ?? error.toString?.() if (typeof maybeMessage === 'string' && maybeMessage.trim()) { message = maybeMessage } const maybeCode = - (error as any).code ?? - (error as any).error?.code ?? - (error as any).type ?? - (error as any).error?.type + error.code ?? error.error?.code ?? error.type ?? error.error?.type if (typeof maybeCode === 'string' && maybeCode.trim()) { code = maybeCode - } else if (typeof (error as any).status === 'number') { - code = String((error as any).status) + } else if (typeof error.status === 'number') { + code = String(error.status) } } else if (typeof error === 'string' && error.trim()) { message = error @@ -72,4 +67,3 @@ export function mapZAIErrorToStreamChunk(error: any): StreamChunk { }, } } - From 214d155c94bfcb1bdf8deead2757abd43528ff44 Mon Sep 17 00:00:00 2001 From: shakibdshy Date: Thu, 15 Jan 2026 19:10:38 +0600 Subject: [PATCH 3/5] feat(tools): add tool support for Z.AI including web search and function tools Implement tool support for Z.AI with web search and function tools, including tool conversion utilities and type definitions. Add subpath export for tools in package.json and update documentation with new features. Add summarization adapter with streaming support and update README with examples for web search, thinking mode, and tool streaming features. Move API key utility to shared client utils. --- packages/typescript/ai-zai/CHANGELOG.md | 22 +++ packages/typescript/ai-zai/README.md | 103 ++++++++++-- packages/typescript/ai-zai/package.json | 4 + .../typescript/ai-zai/src/adapters/index.ts | 27 +--- .../ai-zai/src/adapters/summarize.ts | 152 ++++++++++++++++++ packages/typescript/ai-zai/src/index.ts | 12 +- .../ai-zai/src/text/text-provider-options.ts | 20 ++- .../ai-zai/src/tools/function-tool.ts | 32 ++++ packages/typescript/ai-zai/src/tools/index.ts | 4 + .../ai-zai/src/tools/tool-choice.ts | 16 ++ .../ai-zai/src/tools/tool-converter.ts | 24 +++ .../ai-zai/src/tools/web-search-tool.ts | 36 +++++ .../typescript/ai-zai/src/utils/client.ts | 19 +++ .../typescript/ai-zai/src/utils/conversion.ts | 29 +--- 14 files changed, 440 insertions(+), 60 deletions(-) create mode 100644 packages/typescript/ai-zai/CHANGELOG.md create mode 100644 packages/typescript/ai-zai/src/adapters/summarize.ts create mode 100644 packages/typescript/ai-zai/src/tools/function-tool.ts create mode 100644 packages/typescript/ai-zai/src/tools/index.ts create mode 100644 packages/typescript/ai-zai/src/tools/tool-choice.ts create mode 100644 packages/typescript/ai-zai/src/tools/tool-converter.ts create mode 100644 packages/typescript/ai-zai/src/tools/web-search-tool.ts 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 index fb3f9789..c35da608 100644 --- a/packages/typescript/ai-zai/README.md +++ b/packages/typescript/ai-zai/README.md @@ -14,6 +14,7 @@ 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 @@ -57,37 +58,55 @@ for await (const chunk of result) { } ``` -### Streaming (direct) +### 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: [{ type: 'text', content: 'Stream a short poem about TypeScript.' }] }, - ], + 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) - if (chunk.type === 'error') { - console.error(chunk.error) - break - } - if (chunk.type === 'done') break } ``` -### With Explicit API Key +### Thinking Mode (GLM-4.7/4.6/4.5) + +Enable Deep Thinking for complex reasoning tasks. ```ts -import { createZAIChat } from '@tanstack/ai-zai' +import { zaiText } from '@tanstack/ai-zai' -const adapter = createZAIChat('glm-4.7', 'your-zai-api-key-here') +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 +### Tool / Function Calling & Streaming + +GLM-4.7 supports streaming tool calls via `tool_stream`. ```ts import { zaiText } from '@tanstack/ai-zai' @@ -111,6 +130,9 @@ 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 @@ -119,6 +141,54 @@ for await (const chunk of adapter.chatStream({ } ``` +### 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. @@ -171,14 +241,17 @@ Uses `ZAI_API_KEY` from your environment. ### Chat Models -- `glm-4.7` - Latest flagship model -- `glm-4.6` - Previous flagship model +- `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) diff --git a/packages/typescript/ai-zai/package.json b/packages/typescript/ai-zai/package.json index 22c59f58..5642871b 100644 --- a/packages/typescript/ai-zai/package.json +++ b/packages/typescript/ai-zai/package.json @@ -16,6 +16,10 @@ ".": { "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": [ diff --git a/packages/typescript/ai-zai/src/adapters/index.ts b/packages/typescript/ai-zai/src/adapters/index.ts index 5708c610..4ba7d2c8 100644 --- a/packages/typescript/ai-zai/src/adapters/index.ts +++ b/packages/typescript/ai-zai/src/adapters/index.ts @@ -1,7 +1,15 @@ +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' export type ZAIModel = (typeof ZAI_CHAT_MODELS)[number] @@ -9,25 +17,6 @@ export interface ZAIAdapterConfig { baseURL?: string } -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 createZAIChat with an explicit API key.', - ) - } - - return key -} - /** * Create a Z.AI text adapter instance with an explicit API key. */ 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..6786928a --- /dev/null +++ b/packages/typescript/ai-zai/src/adapters/summarize.ts @@ -0,0 +1,152 @@ +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, + }) + } + + 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/index.ts b/packages/typescript/ai-zai/src/index.ts index 9caeef18..1dbeb718 100644 --- a/packages/typescript/ai-zai/src/index.ts +++ b/packages/typescript/ai-zai/src/index.ts @@ -2,9 +2,17 @@ export { ZAITextAdapter, createZAIChat, zaiText, + ZAISummarizeAdapter, + createZAISummarize, + zaiSummarize, } from './adapters/index' -export type { ZAIAdapterConfig, ZAIModel } from './adapters/index' +export type { + ZAIAdapterConfig, + ZAIModel, + ZAISummarizeConfig, + ZAISummarizeProviderOptions, +} from './adapters/index' export type { ZAIModelMap, @@ -12,3 +20,5 @@ export type { } from './model-meta' export type { ZAIMessageMetadataByModality } from './message-types' + +export * from './tools/index' diff --git a/packages/typescript/ai-zai/src/text/text-provider-options.ts b/packages/typescript/ai-zai/src/text/text-provider-options.ts index 3cf593e4..a402208a 100644 --- a/packages/typescript/ai-zai/src/text/text-provider-options.ts +++ b/packages/typescript/ai-zai/src/text/text-provider-options.ts @@ -99,6 +99,18 @@ export interface ZAIReasoningOptions { */ 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 { @@ -130,6 +142,12 @@ export interface ZAIToolsOptions { * 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 { @@ -184,4 +202,4 @@ export interface ZAITextOptions export interface ZAITextOptionsMinimal extends ZAIBaseOptions, ZAIStreamingOptions, - ZAIMetadataOptions {} \ No newline at end of file + 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..759f4e46 --- /dev/null +++ b/packages/typescript/ai-zai/src/tools/function-tool.ts @@ -0,0 +1,32 @@ +import type { JSONSchema, Tool } from '@tanstack/ai' +import type OpenAI from 'openai' + +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..2be10316 --- /dev/null +++ b/packages/typescript/ai-zai/src/tools/tool-choice.ts @@ -0,0 +1,16 @@ +export interface FunctionToolChoice { + type: 'function' + function: { + name: string + } +} + +export interface WebSearchToolChoice { + type: 'web_search' +} + +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..86ff0d47 --- /dev/null +++ b/packages/typescript/ai-zai/src/tools/tool-converter.ts @@ -0,0 +1,24 @@ +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' + +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..c0ff3079 --- /dev/null +++ b/packages/typescript/ai-zai/src/tools/web-search-tool.ts @@ -0,0 +1,36 @@ +import type { Tool } from '@tanstack/ai' + +export interface ZaiWebSearchTool { + type: 'web_search' + web_search?: { + enable?: boolean + search_query?: string + search_result?: boolean + } +} + +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 index 2acc4af7..0dd233cc 100644 --- a/packages/typescript/ai-zai/src/utils/client.ts +++ b/packages/typescript/ai-zai/src/utils/client.ts @@ -10,6 +10,25 @@ export function getZAIHeaders(): Record { } } +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 +} + export function validateZAIApiKey(apiKey?: string): string { if (!apiKey || typeof apiKey !== 'string') { throw new Error('Z.AI API key is required') diff --git a/packages/typescript/ai-zai/src/utils/conversion.ts b/packages/typescript/ai-zai/src/utils/conversion.ts index cfca7d38..52e924bc 100644 --- a/packages/typescript/ai-zai/src/utils/conversion.ts +++ b/packages/typescript/ai-zai/src/utils/conversion.ts @@ -1,32 +1,13 @@ +import { convertToolsToProviderFormat } from '../tools/tool-converter' import type OpenAI from 'openai' -import type { JSONSchema, StreamChunk, Tool } from '@tanstack/ai' +import type { StreamChunk, Tool } from '@tanstack/ai' export function convertToolsToZAIFormat( tools: Array, ): Array { - return tools.map((tool) => { - const inputSchema: JSONSchema = tool.inputSchema ?? { - type: 'object', - properties: {}, - required: [], - } - - 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, - }, - } - }) + // 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 { From 54bf059ff4fdf2a018a899a88b684847255e9659 Mon Sep 17 00:00:00 2001 From: shakibdshy Date: Thu, 15 Jan 2026 19:49:53 +0600 Subject: [PATCH 4/5] feat(zai): add zai provider support across multiple examples - Add zai provider to model selection in vue and svelte examples - Include zai-text in chat server implementations - Update package.json dependencies for zai integration - Remove unused ZAITextOptionsMinimal interface - Add tools entry point in ai-zai vite config --- .../chat-server/claude-service.ts | 1 + examples/ts-group-chat/package.json | 1 + examples/ts-react-chat/package.json | 2 +- examples/ts-solid-chat/package.json | 1 + examples/ts-solid-chat/src/routes/api.chat.ts | 55 ++++++++++++------- examples/ts-svelte-chat/package.json | 1 + .../ts-svelte-chat/src/lib/model-selection.ts | 2 +- .../src/routes/api/chat/+server.ts | 12 ++-- examples/ts-vue-chat/package.json | 1 + .../ts-vue-chat/src/lib/model-selection.ts | 19 ++++++- examples/ts-vue-chat/vite.config.ts | 7 ++- packages/typescript/ai-zai/package.json | 1 - .../ai-zai/src/text/text-provider-options.ts | 9 --- packages/typescript/ai-zai/vite.config.ts | 2 +- pnpm-lock.yaml | 15 ++++- testing/panel/package.json | 2 +- 16 files changed, 87 insertions(+), 44 deletions(-) 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 2c0e85d3..1822f2ec 100644 --- a/examples/ts-react-chat/package.json +++ b/examples/ts-react-chat/package.json @@ -17,9 +17,9 @@ "@tanstack/ai-grok": "workspace:*", "@tanstack/ai-ollama": "workspace:*", "@tanstack/ai-openai": "workspace:*", - "@tanstack/ai-zai": "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-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/package.json b/packages/typescript/ai-zai/package.json index 5642871b..712d70ae 100644 --- a/packages/typescript/ai-zai/package.json +++ b/packages/typescript/ai-zai/package.json @@ -50,7 +50,6 @@ }, "devDependencies": { "@tanstack/ai": "workspace:*", - "@vitest/coverage-v8": "4.0.14", "vite": "^7.2.7" } } diff --git a/packages/typescript/ai-zai/src/text/text-provider-options.ts b/packages/typescript/ai-zai/src/text/text-provider-options.ts index a402208a..6fe88bce 100644 --- a/packages/typescript/ai-zai/src/text/text-provider-options.ts +++ b/packages/typescript/ai-zai/src/text/text-provider-options.ts @@ -194,12 +194,3 @@ export interface ZAITextOptions ZAIToolsOptions, ZAIStreamingOptions, ZAIMetadataOptions {} - -/** - * Minimal text provider options for Z.AI. - * Includes only the most commonly used options. - */ -export interface ZAITextOptionsMinimal - extends ZAIBaseOptions, - ZAIStreamingOptions, - ZAIMetadataOptions {} diff --git a/packages/typescript/ai-zai/vite.config.ts b/packages/typescript/ai-zai/vite.config.ts index 99cd64ee..7af3d169 100644 --- a/packages/typescript/ai-zai/vite.config.ts +++ b/packages/typescript/ai-zai/vite.config.ts @@ -25,7 +25,7 @@ const config = defineConfig({ export default mergeConfig( config, tanstackViteConfig({ - entry: ['./src/index.ts'], + entry: ['./src/index.ts', './src/tools/index.ts'], srcDir: './src', cjs: false, }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af470ca1..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) @@ -355,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)) @@ -464,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 @@ -540,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 @@ -1030,9 +1042,6 @@ importers: '@tanstack/ai': specifier: workspace:* version: link:../ai - '@vitest/coverage-v8': - specifier: 4.0.14 - version: 4.0.14(vitest@4.0.15(@types/node@25.0.1)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) 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) diff --git a/testing/panel/package.json b/testing/panel/package.json index 13f1e94c..56882a7b 100644 --- a/testing/panel/package.json +++ b/testing/panel/package.json @@ -16,9 +16,9 @@ "@tanstack/ai-grok": "workspace:*", "@tanstack/ai-ollama": "workspace:*", "@tanstack/ai-openai": "workspace:*", - "@tanstack/ai-zai": "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", From d6c7a647f1fb427b463198a0b001721e2964a795 Mon Sep 17 00:00:00 2001 From: shakibdshy Date: Thu, 15 Jan 2026 21:52:48 +0600 Subject: [PATCH 5/5] feat(adapters): add multimodal support for image/video content in ZAITextAdapter add support for image and video content in messages when model supports it update documentation for model capabilities and tool types add test case for image content preservation --- .../typescript/ai-zai/src/adapters/index.ts | 10 ++ .../ai-zai/src/adapters/summarize.ts | 4 + .../typescript/ai-zai/src/adapters/text.ts | 94 ++++++++++++++----- packages/typescript/ai-zai/src/model-meta.ts | 6 ++ .../ai-zai/src/text/text-provider-options.ts | 9 +- .../ai-zai/src/tools/function-tool.ts | 3 + .../ai-zai/src/tools/tool-choice.ts | 10 ++ .../ai-zai/src/tools/tool-converter.ts | 4 + .../ai-zai/src/tools/web-search-tool.ts | 6 ++ .../typescript/ai-zai/src/utils/client.ts | 8 ++ .../typescript/ai-zai/src/utils/conversion.ts | 4 + .../ai-zai/tests/zai-adapter.test.ts | 31 ++++++ testing/panel/.env.example | 2 +- 13 files changed, 167 insertions(+), 24 deletions(-) diff --git a/packages/typescript/ai-zai/src/adapters/index.ts b/packages/typescript/ai-zai/src/adapters/index.ts index 4ba7d2c8..f80191f9 100644 --- a/packages/typescript/ai-zai/src/adapters/index.ts +++ b/packages/typescript/ai-zai/src/adapters/index.ts @@ -11,9 +11,19 @@ export { 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 } diff --git a/packages/typescript/ai-zai/src/adapters/summarize.ts b/packages/typescript/ai-zai/src/adapters/summarize.ts index 6786928a..d2f0350f 100644 --- a/packages/typescript/ai-zai/src/adapters/summarize.ts +++ b/packages/typescript/ai-zai/src/adapters/summarize.ts @@ -89,6 +89,10 @@ export class ZAISummarizeAdapter< }) } + /** + * 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. ' diff --git a/packages/typescript/ai-zai/src/adapters/text.ts b/packages/typescript/ai-zai/src/adapters/text.ts index 300b61a2..5c643abc 100644 --- a/packages/typescript/ai-zai/src/adapters/text.ts +++ b/packages/typescript/ai-zai/src/adapters/text.ts @@ -1,6 +1,7 @@ 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, @@ -10,7 +11,6 @@ import type { ZAIMessageMetadataByModality } from '../message-types' import type { ZAIModelInputModalitiesByName, ZAIModelMap, - ZAI_CHAT_MODELS, } from '../model-meta' import type { ZAITextOptions } from '../text/text-provider-options' import type OpenAI from 'openai' @@ -177,6 +177,14 @@ export class ZAITextAdapter< ): 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', @@ -186,9 +194,12 @@ export class ZAITextAdapter< 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 || '', + tool_call_id: message.toolCallId, content: typeof message.content === 'string' ? message.content @@ -198,21 +209,26 @@ export class ZAITextAdapter< } 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), - }, - })) + 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.extractTextContent(message.content), + content: this.convertContent(message.content, { + image: false, + video: false, + }) as string, ...(toolCalls && toolCalls.length ? { tool_calls: toolCalls } : {}), }) continue @@ -220,7 +236,7 @@ export class ZAITextAdapter< result.push({ role: 'user', - content: this.extractTextContent(message.content), + content: this.convertContent(message.content, capabilities), }) } @@ -357,23 +373,57 @@ export class ZAITextAdapter< } /** - * Extract a plain string from TanStack message content. - * The core types allow either `string | null | ContentPart[]`. + * Convert TanStack message content to OpenAI format, preserving supported modalities. */ - private extractTextContent(content: unknown): string { + private convertContent( + content: unknown, + capabilities: { image: boolean; video: boolean }, + ): string | Array { if (typeof content === 'string') return content if (!content) return '' if (Array.isArray(content)) { - return content - .filter((p) => p && typeof p === 'object' && p.type === 'text') - .map((p) => String(p.content ?? '')) - .join('') + // 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 { diff --git a/packages/typescript/ai-zai/src/model-meta.ts b/packages/typescript/ai-zai/src/model-meta.ts index 721e400c..80448b8a 100644 --- a/packages/typescript/ai-zai/src/model-meta.ts +++ b/packages/typescript/ai-zai/src/model-meta.ts @@ -217,12 +217,18 @@ export type ZAIModelMap = { 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, diff --git a/packages/typescript/ai-zai/src/text/text-provider-options.ts b/packages/typescript/ai-zai/src/text/text-provider-options.ts index 6fe88bce..4af59130 100644 --- a/packages/typescript/ai-zai/src/text/text-provider-options.ts +++ b/packages/typescript/ai-zai/src/text/text-provider-options.ts @@ -77,7 +77,14 @@ export interface ZAIBaseOptions { // 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' /** @@ -91,7 +98,7 @@ export interface ZAIReasoningOptions { reasoning?: { /** * Controls the amount of reasoning effort. - * Supported values: none, low, medium, high + * Supported values: none, minimal, low, medium, high */ effort?: ReasoningEffort /** diff --git a/packages/typescript/ai-zai/src/tools/function-tool.ts b/packages/typescript/ai-zai/src/tools/function-tool.ts index 759f4e46..074d39e7 100644 --- a/packages/typescript/ai-zai/src/tools/function-tool.ts +++ b/packages/typescript/ai-zai/src/tools/function-tool.ts @@ -1,6 +1,9 @@ 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 /** diff --git a/packages/typescript/ai-zai/src/tools/tool-choice.ts b/packages/typescript/ai-zai/src/tools/tool-choice.ts index 2be10316..df559caa 100644 --- a/packages/typescript/ai-zai/src/tools/tool-choice.ts +++ b/packages/typescript/ai-zai/src/tools/tool-choice.ts @@ -1,3 +1,6 @@ +/** + * Configuration for forcing a specific function tool. + */ export interface FunctionToolChoice { type: 'function' function: { @@ -5,10 +8,17 @@ export interface FunctionToolChoice { } } +/** + * 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' diff --git a/packages/typescript/ai-zai/src/tools/tool-converter.ts b/packages/typescript/ai-zai/src/tools/tool-converter.ts index 86ff0d47..39c55018 100644 --- a/packages/typescript/ai-zai/src/tools/tool-converter.ts +++ b/packages/typescript/ai-zai/src/tools/tool-converter.ts @@ -4,6 +4,10 @@ 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 /** diff --git a/packages/typescript/ai-zai/src/tools/web-search-tool.ts b/packages/typescript/ai-zai/src/tools/web-search-tool.ts index c0ff3079..29af650e 100644 --- a/packages/typescript/ai-zai/src/tools/web-search-tool.ts +++ b/packages/typescript/ai-zai/src/tools/web-search-tool.ts @@ -1,5 +1,8 @@ import type { Tool } from '@tanstack/ai' +/** + * Definition of the Z.AI Web Search tool structure. + */ export interface ZaiWebSearchTool { type: 'web_search' web_search?: { @@ -9,6 +12,9 @@ export interface ZaiWebSearchTool { } } +/** + * Alias for the Z.AI Web Search tool. + */ export type WebSearchTool = ZaiWebSearchTool /** diff --git a/packages/typescript/ai-zai/src/utils/client.ts b/packages/typescript/ai-zai/src/utils/client.ts index 0dd233cc..6aa8639b 100644 --- a/packages/typescript/ai-zai/src/utils/client.ts +++ b/packages/typescript/ai-zai/src/utils/client.ts @@ -29,6 +29,14 @@ export function getZAIApiKeyFromEnv(): string { 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') diff --git a/packages/typescript/ai-zai/src/utils/conversion.ts b/packages/typescript/ai-zai/src/utils/conversion.ts index 52e924bc..6e1c476b 100644 --- a/packages/typescript/ai-zai/src/utils/conversion.ts +++ b/packages/typescript/ai-zai/src/utils/conversion.ts @@ -2,6 +2,10 @@ 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 { diff --git a/packages/typescript/ai-zai/tests/zai-adapter.test.ts b/packages/typescript/ai-zai/tests/zai-adapter.test.ts index 489e3560..e2e15bcb 100644 --- a/packages/typescript/ai-zai/tests/zai-adapter.test.ts +++ b/packages/typescript/ai-zai/tests/zai-adapter.test.ts @@ -250,6 +250,37 @@ describe('ZAITextAdapter', () => { 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', () => { diff --git a/testing/panel/.env.example b/testing/panel/.env.example index fa97c129..f9523283 100644 --- a/testing/panel/.env.example +++ b/testing/panel/.env.example @@ -3,7 +3,7 @@ # OPENAI_API_KEY=sk-... # Z.AI API Key -# Get yours at: https://docs.z.ai/ +# Get yours at: https://z.ai/manage-apikey/apikey-list # ZAI_API_KEY= # Anthropic API Key