diff --git a/apps/gateway/src/agent/adapters/index.ts b/apps/gateway/src/agent/adapters/index.ts index 6585363..7e02424 100644 --- a/apps/gateway/src/agent/adapters/index.ts +++ b/apps/gateway/src/agent/adapters/index.ts @@ -2,3 +2,4 @@ export { OllamaAdapter } from './ollama.adapter.js'; export { AnthropicAdapter } from './anthropic.adapter.js'; export { OpenAIAdapter } from './openai.adapter.js'; export { OpenRouterAdapter } from './openrouter.adapter.js'; +export { ZaiAdapter } from './zai.adapter.js'; diff --git a/apps/gateway/src/agent/adapters/zai.adapter.ts b/apps/gateway/src/agent/adapters/zai.adapter.ts new file mode 100644 index 0000000..8664356 --- /dev/null +++ b/apps/gateway/src/agent/adapters/zai.adapter.ts @@ -0,0 +1,187 @@ +import { Logger } from '@nestjs/common'; +import OpenAI from 'openai'; +import type { + CompletionEvent, + CompletionParams, + IProviderAdapter, + ModelInfo, + ProviderHealth, +} from '@mosaic/types'; +import { getModelCapability } from '../model-capabilities.js'; + +/** + * Default Z.ai API base URL. + * Z.ai (BigModel / Zhipu AI) exposes an OpenAI-compatible API at this endpoint. + * Can be overridden via the ZAI_BASE_URL environment variable. + */ +const DEFAULT_ZAI_BASE_URL = 'https://open.bigmodel.cn/api/paas/v4'; + +/** + * GLM-5 model identifier on the Z.ai platform. + */ +const GLM5_MODEL_ID = 'glm-5'; + +/** + * Z.ai (Zhipu AI / BigModel) provider adapter. + * + * Z.ai exposes an OpenAI-compatible REST API. This adapter uses the `openai` + * SDK with a custom base URL and the ZAI_API_KEY environment variable. + * + * Configuration: + * ZAI_API_KEY — required; Z.ai API key + * ZAI_BASE_URL — optional; override the default API base URL + */ +export class ZaiAdapter implements IProviderAdapter { + readonly name = 'zai'; + + private readonly logger = new Logger(ZaiAdapter.name); + private client: OpenAI | null = null; + private registeredModels: ModelInfo[] = []; + + async register(): Promise { + const apiKey = process.env['ZAI_API_KEY']; + if (!apiKey) { + this.logger.debug('Skipping Z.ai provider registration: ZAI_API_KEY not set'); + return; + } + + const baseURL = process.env['ZAI_BASE_URL'] ?? DEFAULT_ZAI_BASE_URL; + + this.client = new OpenAI({ apiKey, baseURL }); + + this.registeredModels = this.buildModelList(); + this.logger.log(`Z.ai provider registered with ${this.registeredModels.length} model(s)`); + } + + listModels(): ModelInfo[] { + return this.registeredModels; + } + + async healthCheck(): Promise { + const apiKey = process.env['ZAI_API_KEY']; + if (!apiKey) { + return { + status: 'down', + lastChecked: new Date().toISOString(), + error: 'ZAI_API_KEY not configured', + }; + } + + const baseURL = process.env['ZAI_BASE_URL'] ?? DEFAULT_ZAI_BASE_URL; + const start = Date.now(); + + try { + const res = await fetch(`${baseURL}/models`, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: 'application/json', + }, + signal: AbortSignal.timeout(5000), + }); + const latencyMs = Date.now() - start; + + if (!res.ok) { + return { + status: 'degraded', + latencyMs, + lastChecked: new Date().toISOString(), + error: `HTTP ${res.status}`, + }; + } + + return { status: 'healthy', latencyMs, lastChecked: new Date().toISOString() }; + } catch (err) { + const latencyMs = Date.now() - start; + const error = err instanceof Error ? err.message : String(err); + return { status: 'down', latencyMs, lastChecked: new Date().toISOString(), error }; + } + } + + /** + * Stream a completion through Z.ai's OpenAI-compatible API. + */ + async *createCompletion(params: CompletionParams): AsyncIterable { + if (!this.client) { + throw new Error('ZaiAdapter is not initialized. Ensure ZAI_API_KEY is set.'); + } + + const stream = await this.client.chat.completions.create({ + model: params.model, + messages: params.messages.map((m) => ({ role: m.role, content: m.content })), + temperature: params.temperature, + max_tokens: params.maxTokens, + stream: true, + }); + + let inputTokens = 0; + let outputTokens = 0; + + for await (const chunk of stream) { + const choice = chunk.choices[0]; + if (!choice) continue; + + const delta = choice.delta; + + if (delta.content) { + yield { type: 'text_delta', content: delta.content }; + } + + if (choice.finish_reason === 'stop') { + const usage = (chunk as { usage?: { prompt_tokens?: number; completion_tokens?: number } }) + .usage; + if (usage) { + inputTokens = usage.prompt_tokens ?? 0; + outputTokens = usage.completion_tokens ?? 0; + } + } + } + + yield { + type: 'done', + usage: { inputTokens, outputTokens }, + }; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private buildModelList(): ModelInfo[] { + const capability = getModelCapability(GLM5_MODEL_ID); + + if (!capability) { + this.logger.warn(`Model capability entry not found for '${GLM5_MODEL_ID}'; using defaults`); + return [ + { + id: GLM5_MODEL_ID, + provider: 'zai', + name: 'GLM-5', + reasoning: false, + contextWindow: 128000, + maxTokens: 8192, + inputTypes: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ]; + } + + return [ + { + id: capability.id, + provider: 'zai', + name: capability.displayName, + reasoning: capability.capabilities.reasoning, + contextWindow: capability.contextWindow, + maxTokens: capability.maxOutputTokens, + inputTypes: capability.capabilities.vision ? ['text', 'image'] : ['text'], + cost: { + input: capability.costPer1kInput ?? 0, + output: capability.costPer1kOutput ?? 0, + cacheRead: 0, + cacheWrite: 0, + }, + }, + ]; + } +} diff --git a/apps/gateway/src/agent/provider.service.ts b/apps/gateway/src/agent/provider.service.ts index 1862a67..c278a39 100644 --- a/apps/gateway/src/agent/provider.service.ts +++ b/apps/gateway/src/agent/provider.service.ts @@ -13,6 +13,7 @@ import { OllamaAdapter, OpenAIAdapter, OpenRouterAdapter, + ZaiAdapter, } from './adapters/index.js'; import type { TestConnectionResultDto } from './provider.dto.js'; @@ -52,14 +53,13 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy { new AnthropicAdapter(this.registry), new OpenAIAdapter(this.registry), new OpenRouterAdapter(), + new ZaiAdapter(), ]; - // Run all adapter registrations first (Ollama, Anthropic, and any future adapters) + // Run all adapter registrations first (Ollama, Anthropic, OpenAI, OpenRouter, Z.ai) await this.registerAll(); - // Register API-key providers directly (Z.ai, custom) - // OpenAI now has a dedicated adapter (M3-003). - this.registerZaiProvider(); + // Register API-key providers directly (custom) this.registerCustomProviders(); const available = this.registry.getAvailable(); @@ -340,30 +340,9 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy { } // --------------------------------------------------------------------------- - // Private helpers — direct registry registration for providers without adapters yet - // (Z.ai will move to an adapter in M3-005) + // Private helpers // --------------------------------------------------------------------------- - private registerZaiProvider(): void { - const apiKey = process.env['ZAI_API_KEY']; - if (!apiKey) { - this.logger.debug('Skipping Z.ai provider registration: ZAI_API_KEY not set'); - return; - } - - const models = ['glm-4.5', 'glm-4.5-air', 'glm-4.5-flash'].map((id) => - this.cloneBuiltInModel('zai', id), - ); - - this.registry.registerProvider('zai', { - apiKey, - baseUrl: 'https://open.bigmodel.cn/api/paas/v4', - models, - }); - - this.logger.log('Z.ai provider registered with 3 models'); - } - private registerCustomProviders(): void { const customJson = process.env['MOSAIC_CUSTOM_PROVIDERS']; if (!customJson) return;