Merge pull request 'feat(gateway): add Anthropic, OpenAI, Z.ai LLM providers (P8-002)' (#212) from feat/p8-002-llm-providers into main
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Reviewed-on: mosaic/mosaic-stack#212
This commit was merged in pull request #212.
This commit is contained in:
143
apps/gateway/src/agent/__tests__/provider.service.test.ts
Normal file
143
apps/gateway/src/agent/__tests__/provider.service.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user