feat(gateway): add Anthropic, OpenAI, Z.ai LLM providers (P8-002)

This commit is contained in:
2026-03-18 21:34:38 -05:00
parent cbfd6fb996
commit 714fee52b9
3 changed files with 227 additions and 2 deletions

View File

@@ -62,9 +62,15 @@ OTEL_SERVICE_NAME=mosaic-gateway
# Comma-separated list of Ollama model IDs to register (default: llama3.2,codellama,mistral)
# OLLAMA_MODELS=llama3.2,codellama,mistral
# OpenAI — required for embedding and log-summarization features
# Anthropic (claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5)
# ANTHROPIC_API_KEY=sk-ant-...
# OpenAI (gpt-4o, gpt-4o-mini, o3-mini)
# OPENAI_API_KEY=sk-...
# Z.ai / GLM (glm-4.5, glm-4.5-air, glm-4.5-flash)
# ZAI_API_KEY=...
# Custom providers — JSON array of provider configs
# Format: [{"id":"<id>","baseUrl":"<url>","apiKey":"<key>","models":[{"id":"<model-id>","name":"<label>"}]}]
# MOSAIC_CUSTOM_PROVIDERS=

View File

@@ -0,0 +1,143 @@
import { beforeEach, afterEach, describe, expect, it } from 'vitest';
import { ProviderService } from '../provider.service.js';
const ENV_KEYS = [
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
'ZAI_API_KEY',
'OLLAMA_BASE_URL',
'OLLAMA_HOST',
'OLLAMA_MODELS',
'MOSAIC_CUSTOM_PROVIDERS',
] as const;
type EnvKey = (typeof ENV_KEYS)[number];
describe('ProviderService', () => {
const savedEnv = new Map<EnvKey, string | undefined>();
beforeEach(() => {
for (const key of ENV_KEYS) {
savedEnv.set(key, process.env[key]);
delete process.env[key];
}
});
afterEach(() => {
for (const key of ENV_KEYS) {
const value = savedEnv.get(key);
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it('skips API-key providers when env vars are missing (no models become available)', () => {
const service = new ProviderService();
service.onModuleInit();
// Pi's built-in registry may include model definitions for all providers, but
// without API keys none of them should be available (usable).
const availableModels = service.listAvailableModels();
const availableProviderIds = new Set(availableModels.map((m) => m.provider));
expect(availableProviderIds).not.toContain('anthropic');
expect(availableProviderIds).not.toContain('openai');
expect(availableProviderIds).not.toContain('zai');
// Providers list may show built-in providers, but they should not be marked available
const providers = service.listProviders();
for (const p of providers.filter((p) => ['anthropic', 'openai', 'zai'].includes(p.id))) {
expect(p.available).toBe(false);
}
});
it('registers Anthropic provider with correct models when ANTHROPIC_API_KEY is set', () => {
process.env['ANTHROPIC_API_KEY'] = 'test-anthropic';
const service = new ProviderService();
service.onModuleInit();
const providers = service.listProviders();
const anthropic = providers.find((p) => p.id === 'anthropic');
expect(anthropic).toBeDefined();
expect(anthropic!.available).toBe(true);
expect(anthropic!.models.map((m) => m.id)).toEqual([
'claude-sonnet-4-6',
'claude-opus-4-6',
'claude-haiku-4-5',
]);
// contextWindow override from Pi built-in (200000)
for (const m of anthropic!.models) {
expect(m.contextWindow).toBe(200000);
// maxTokens capped at 8192 per task spec
expect(m.maxTokens).toBe(8192);
}
});
it('registers OpenAI provider with correct models when OPENAI_API_KEY is set', () => {
process.env['OPENAI_API_KEY'] = 'test-openai';
const service = new ProviderService();
service.onModuleInit();
const providers = service.listProviders();
const openai = providers.find((p) => p.id === 'openai');
expect(openai).toBeDefined();
expect(openai!.available).toBe(true);
expect(openai!.models.map((m) => m.id)).toEqual(['gpt-4o', 'gpt-4o-mini', 'o3-mini']);
});
it('registers Z.ai provider with correct models when ZAI_API_KEY is set', () => {
process.env['ZAI_API_KEY'] = 'test-zai';
const service = new ProviderService();
service.onModuleInit();
const providers = service.listProviders();
const zai = providers.find((p) => p.id === 'zai');
expect(zai).toBeDefined();
expect(zai!.available).toBe(true);
expect(zai!.models.map((m) => m.id)).toEqual(['glm-4.5', 'glm-4.5-air', 'glm-4.5-flash']);
});
it('registers all three providers when all keys are set', () => {
process.env['ANTHROPIC_API_KEY'] = 'test-anthropic';
process.env['OPENAI_API_KEY'] = 'test-openai';
process.env['ZAI_API_KEY'] = 'test-zai';
const service = new ProviderService();
service.onModuleInit();
const providerIds = service.listProviders().map((p) => p.id);
expect(providerIds).toContain('anthropic');
expect(providerIds).toContain('openai');
expect(providerIds).toContain('zai');
});
it('can find registered Anthropic models by provider+id', () => {
process.env['ANTHROPIC_API_KEY'] = 'test-anthropic';
const service = new ProviderService();
service.onModuleInit();
const sonnet = service.findModel('anthropic', 'claude-sonnet-4-6');
expect(sonnet).toBeDefined();
expect(sonnet!.provider).toBe('anthropic');
expect(sonnet!.id).toBe('claude-sonnet-4-6');
});
it('can find registered Z.ai models by provider+id', () => {
process.env['ZAI_API_KEY'] = 'test-zai';
const service = new ProviderService();
service.onModuleInit();
const glm = service.findModel('zai', 'glm-4.5');
expect(glm).toBeDefined();
expect(glm!.provider).toBe('zai');
expect(glm!.id).toBe('glm-4.5');
});
});

View File

@@ -1,6 +1,6 @@
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { ModelRegistry, AuthStorage } from '@mariozechner/pi-coding-agent';
import type { Model, Api } from '@mariozechner/pi-ai';
import { getModel, type Model, type Api } from '@mariozechner/pi-ai';
import type { ModelInfo, ProviderInfo, CustomProviderConfig } from '@mosaic/types';
import type { TestConnectionResultDto } from './provider.dto.js';
@@ -14,6 +14,9 @@ export class ProviderService implements OnModuleInit {
this.registry = new ModelRegistry(authStorage);
this.registerOllamaProvider();
this.registerAnthropicProvider();
this.registerOpenAIProvider();
this.registerZaiProvider();
this.registerCustomProviders();
const available = this.registry.getAvailable();
@@ -140,6 +143,66 @@ export class ProviderService implements OnModuleInit {
this.logger.log(`Registered custom provider: ${config.id} (${config.models.length} models)`);
}
private registerAnthropicProvider(): void {
const apiKey = process.env['ANTHROPIC_API_KEY'];
if (!apiKey) {
this.logger.debug('Skipping Anthropic provider registration: ANTHROPIC_API_KEY not set');
return;
}
const models = ['claude-sonnet-4-6', 'claude-opus-4-6', 'claude-haiku-4-5'].map((id) =>
this.cloneBuiltInModel('anthropic', id, { maxTokens: 8192 }),
);
this.registry.registerProvider('anthropic', {
apiKey,
baseUrl: 'https://api.anthropic.com',
models,
});
this.logger.log('Anthropic provider registered with 3 models');
}
private registerOpenAIProvider(): void {
const apiKey = process.env['OPENAI_API_KEY'];
if (!apiKey) {
this.logger.debug('Skipping OpenAI provider registration: OPENAI_API_KEY not set');
return;
}
const models = ['gpt-4o', 'gpt-4o-mini', 'o3-mini'].map((id) =>
this.cloneBuiltInModel('openai', id),
);
this.registry.registerProvider('openai', {
apiKey,
baseUrl: 'https://api.openai.com/v1',
models,
});
this.logger.log('OpenAI provider registered with 3 models');
}
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 registerOllamaProvider(): void {
const ollamaUrl = process.env['OLLAMA_BASE_URL'] ?? process.env['OLLAMA_HOST'];
if (!ollamaUrl) return;
@@ -184,6 +247,19 @@ export class ProviderService implements OnModuleInit {
}
}
private cloneBuiltInModel(
provider: string,
modelId: string,
overrides: Partial<Model<Api>> = {},
): Model<Api> {
const model = getModel(provider as never, modelId as never) as Model<Api> | undefined;
if (!model) {
throw new Error(`Built-in model not found: ${provider}:${modelId}`);
}
return { ...model, ...overrides };
}
private toModelInfo(model: Model<Api>): ModelInfo {
return {
id: model.id,