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:
@@ -62,9 +62,15 @@ OTEL_SERVICE_NAME=mosaic-gateway
|
|||||||
# Comma-separated list of Ollama model IDs to register (default: llama3.2,codellama,mistral)
|
# Comma-separated list of Ollama model IDs to register (default: llama3.2,codellama,mistral)
|
||||||
# OLLAMA_MODELS=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-...
|
# 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
|
# Custom providers — JSON array of provider configs
|
||||||
# Format: [{"id":"<id>","baseUrl":"<url>","apiKey":"<key>","models":[{"id":"<model-id>","name":"<label>"}]}]
|
# Format: [{"id":"<id>","baseUrl":"<url>","apiKey":"<key>","models":[{"id":"<model-id>","name":"<label>"}]}]
|
||||||
# MOSAIC_CUSTOM_PROVIDERS=
|
# MOSAIC_CUSTOM_PROVIDERS=
|
||||||
|
|||||||
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 { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||||
import { ModelRegistry, AuthStorage } from '@mariozechner/pi-coding-agent';
|
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 { ModelInfo, ProviderInfo, CustomProviderConfig } from '@mosaic/types';
|
||||||
import type { TestConnectionResultDto } from './provider.dto.js';
|
import type { TestConnectionResultDto } from './provider.dto.js';
|
||||||
|
|
||||||
@@ -14,6 +14,9 @@ export class ProviderService implements OnModuleInit {
|
|||||||
this.registry = new ModelRegistry(authStorage);
|
this.registry = new ModelRegistry(authStorage);
|
||||||
|
|
||||||
this.registerOllamaProvider();
|
this.registerOllamaProvider();
|
||||||
|
this.registerAnthropicProvider();
|
||||||
|
this.registerOpenAIProvider();
|
||||||
|
this.registerZaiProvider();
|
||||||
this.registerCustomProviders();
|
this.registerCustomProviders();
|
||||||
|
|
||||||
const available = this.registry.getAvailable();
|
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)`);
|
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 {
|
private registerOllamaProvider(): void {
|
||||||
const ollamaUrl = process.env['OLLAMA_BASE_URL'] ?? process.env['OLLAMA_HOST'];
|
const ollamaUrl = process.env['OLLAMA_BASE_URL'] ?? process.env['OLLAMA_HOST'];
|
||||||
if (!ollamaUrl) return;
|
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 {
|
private toModelInfo(model: Model<Api>): ModelInfo {
|
||||||
return {
|
return {
|
||||||
id: model.id,
|
id: model.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user