feat(M3-005): implement ZaiAdapter for Z.ai GLM-5 provider
Z.ai exposes an OpenAI-compatible API at https://open.bigmodel.cn/api/paas/v4. The adapter uses the openai SDK with a custom baseURL and ZAI_API_KEY env var. - Add ZaiAdapter implementing IProviderAdapter with register(), listModels(), healthCheck(), and createCompletion() (streaming via OpenAI-compat API) - Register GLM-5 (128K context, standard tier, tools support) from model-capabilities - Support ZAI_BASE_URL override for custom deployments - Graceful degradation when ZAI_API_KEY is absent - Remove legacy registerZaiProvider() direct-registry method from ProviderService - Export ZaiAdapter from adapters/index.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,3 +2,4 @@ export { OllamaAdapter } from './ollama.adapter.js';
|
|||||||
export { AnthropicAdapter } from './anthropic.adapter.js';
|
export { AnthropicAdapter } from './anthropic.adapter.js';
|
||||||
export { OpenAIAdapter } from './openai.adapter.js';
|
export { OpenAIAdapter } from './openai.adapter.js';
|
||||||
export { OpenRouterAdapter } from './openrouter.adapter.js';
|
export { OpenRouterAdapter } from './openrouter.adapter.js';
|
||||||
|
export { ZaiAdapter } from './zai.adapter.js';
|
||||||
|
|||||||
187
apps/gateway/src/agent/adapters/zai.adapter.ts
Normal file
187
apps/gateway/src/agent/adapters/zai.adapter.ts
Normal file
@@ -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<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 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<ProviderHealth> {
|
||||||
|
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<CompletionEvent> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
OllamaAdapter,
|
OllamaAdapter,
|
||||||
OpenAIAdapter,
|
OpenAIAdapter,
|
||||||
OpenRouterAdapter,
|
OpenRouterAdapter,
|
||||||
|
ZaiAdapter,
|
||||||
} from './adapters/index.js';
|
} from './adapters/index.js';
|
||||||
import type { TestConnectionResultDto } from './provider.dto.js';
|
import type { TestConnectionResultDto } from './provider.dto.js';
|
||||||
|
|
||||||
@@ -52,14 +53,13 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy {
|
|||||||
new AnthropicAdapter(this.registry),
|
new AnthropicAdapter(this.registry),
|
||||||
new OpenAIAdapter(this.registry),
|
new OpenAIAdapter(this.registry),
|
||||||
new OpenRouterAdapter(),
|
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();
|
await this.registerAll();
|
||||||
|
|
||||||
// Register API-key providers directly (Z.ai, custom)
|
// Register API-key providers directly (custom)
|
||||||
// OpenAI now has a dedicated adapter (M3-003).
|
|
||||||
this.registerZaiProvider();
|
|
||||||
this.registerCustomProviders();
|
this.registerCustomProviders();
|
||||||
|
|
||||||
const available = this.registry.getAvailable();
|
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
|
// Private helpers
|
||||||
// (Z.ai will move to an adapter in M3-005)
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
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 {
|
private registerCustomProviders(): void {
|
||||||
const customJson = process.env['MOSAIC_CUSTOM_PROVIDERS'];
|
const customJson = process.env['MOSAIC_CUSTOM_PROVIDERS'];
|
||||||
if (!customJson) return;
|
if (!customJson) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user