feat(gateway): add Anthropic, OpenAI, Z.ai LLM providers (P8-002) #212
@@ -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=
|
||||
|
||||
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,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# BUG-CLI Scratchpad
|
||||
|
||||
## Objective
|
||||
|
||||
Fix 4 CLI/TUI polish bugs in a single PR (issues #192, #193, #194, #199).
|
||||
|
||||
## Issues
|
||||
|
||||
- #192: Ctrl+T leaks 't' into input
|
||||
- #193: Duplicate React keys in CommandAutocomplete
|
||||
- #194: /provider login false clipboard claim
|
||||
@@ -12,28 +14,33 @@ Fix 4 CLI/TUI polish bugs in a single PR (issues #192, #193, #194, #199).
|
||||
## Plan and Fixes
|
||||
|
||||
### Bug #192 — Ctrl+T character leak
|
||||
|
||||
- Location: `packages/cli/src/tui/app.tsx`
|
||||
- Fix: Added `ctrlJustFired` ref. Set synchronously in Ctrl+T/L/N/K handlers, cleared via microtask.
|
||||
In the `onChange` wrapper passed to `InputBar`, if `ctrlJustFired.current` is true, suppress the
|
||||
leaked character and return early.
|
||||
|
||||
### Bug #193 — Duplicate React keys
|
||||
|
||||
- Location: `packages/cli/src/tui/components/command-autocomplete.tsx`
|
||||
- Fix: Changed `key={cmd.name}` to `key={`${cmd.execution}-${cmd.name}`}` for uniqueness.
|
||||
- Also: `packages/cli/src/tui/commands/registry.ts` — `getAll()` now deduplicates gateway commands
|
||||
that share a name with local commands. Local commands take precedence.
|
||||
|
||||
### Bug #194 — False clipboard claim
|
||||
|
||||
- Location: `apps/gateway/src/commands/command-executor.service.ts`
|
||||
- Fix: Removed the `\n\n(URL copied to clipboard)` suffix from the provider login message.
|
||||
|
||||
### Bug #199 — Hardcoded version "0.0.0"
|
||||
|
||||
- Location: `packages/cli/src/cli.ts` + `packages/cli/src/tui/app.tsx`
|
||||
- Fix: `cli.ts` reads version from `../package.json` via `createRequire`. Passes `version: CLI_VERSION`
|
||||
to TuiApp in both render calls. TuiApp has new optional `version` prop (defaults to '0.0.0'),
|
||||
passes it to TopBar instead of hardcoded `"0.0.0"`.
|
||||
|
||||
## Quality Gates
|
||||
|
||||
- CLI typecheck: PASSED
|
||||
- CLI lint: PASSED
|
||||
- Prettier format:check: PASSED
|
||||
|
||||
Reference in New Issue
Block a user