feat(M3-001): refactor ProviderService into IProviderAdapter pattern
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed

Introduces the IProviderAdapter interface as the architecture foundation for
multi-provider support. Extracts Ollama logic into OllamaAdapter and makes
ProviderService adapter-aware while preserving the full existing API.

## What changed

### @mosaic/types — new provider adapter types
- IProviderAdapter interface: register(), listModels(), healthCheck(), createCompletion()
- ProviderHealth / ProviderHealthStatus — health check result
- CompletionParams / CompletionMessage / CompletionTool — completion request
- CompletionEvent / CompletionUsage — streamed completion response

### apps/gateway — adapter implementation
- apps/gateway/src/agent/adapters/ollama.adapter.ts — OllamaAdapter (extracted from ProviderService)
- apps/gateway/src/agent/adapters/index.ts — barrel export
- ProviderService.onModuleInit() is now async (awaits registerAll())
- New ProviderService methods: registerAll(), getAdapter(), healthCheckAll()
- PROVIDER_ADAPTERS symbol exported as future DI injection token
- Anthropic/OpenAI/Z.ai remain as direct registry calls (M3-002 to M3-005 scope)
- Updated provider.service.test.ts: all tests now await onModuleInit()

## Pi SDK compatibility findings
Pi SDK uses ModelRegistry as central registry. The adapter pattern is a Mosaic
abstraction layered on top — adapters call registry.registerProvider() during
register(). Pi SDK has no native adapter concept; it does not conflict.

createCompletion() is defined in the interface but not called by the Pi layer.
Pi SDK's AgentSession.prompt() and ModelRegistry.getAvailable() handle all
actual completions. createCompletion() is reserved for future direct-completion
use cases (post-M3 scope). OllamaAdapter throws NotImplementedError for now.

No Pi SDK friction was found for the adapter pattern. The registry integration
point (registerProvider()) is exactly what the existing code already used.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 16:15:35 -05:00
parent 0d7f3c6d14
commit df71850d4f
6 changed files with 389 additions and 47 deletions

View File

@@ -34,9 +34,9 @@ describe('ProviderService', () => {
}
});
it('skips API-key providers when env vars are missing (no models become available)', () => {
it('skips API-key providers when env vars are missing (no models become available)', async () => {
const service = new ProviderService();
service.onModuleInit();
await 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).
@@ -54,11 +54,11 @@ describe('ProviderService', () => {
}
});
it('registers Anthropic provider with correct models when ANTHROPIC_API_KEY is set', () => {
it('registers Anthropic provider with correct models when ANTHROPIC_API_KEY is set', async () => {
process.env['ANTHROPIC_API_KEY'] = 'test-anthropic';
const service = new ProviderService();
service.onModuleInit();
await service.onModuleInit();
const providers = service.listProviders();
const anthropic = providers.find((p) => p.id === 'anthropic');
@@ -77,11 +77,11 @@ describe('ProviderService', () => {
}
});
it('registers OpenAI provider with correct models when OPENAI_API_KEY is set', () => {
it('registers OpenAI provider with correct models when OPENAI_API_KEY is set', async () => {
process.env['OPENAI_API_KEY'] = 'test-openai';
const service = new ProviderService();
service.onModuleInit();
await service.onModuleInit();
const providers = service.listProviders();
const openai = providers.find((p) => p.id === 'openai');
@@ -90,11 +90,11 @@ describe('ProviderService', () => {
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', () => {
it('registers Z.ai provider with correct models when ZAI_API_KEY is set', async () => {
process.env['ZAI_API_KEY'] = 'test-zai';
const service = new ProviderService();
service.onModuleInit();
await service.onModuleInit();
const providers = service.listProviders();
const zai = providers.find((p) => p.id === 'zai');
@@ -103,13 +103,13 @@ describe('ProviderService', () => {
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', () => {
it('registers all three providers when all keys are set', async () => {
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();
await service.onModuleInit();
const providerIds = service.listProviders().map((p) => p.id);
expect(providerIds).toContain('anthropic');
@@ -117,11 +117,11 @@ describe('ProviderService', () => {
expect(providerIds).toContain('zai');
});
it('can find registered Anthropic models by provider+id', () => {
it('can find registered Anthropic models by provider+id', async () => {
process.env['ANTHROPIC_API_KEY'] = 'test-anthropic';
const service = new ProviderService();
service.onModuleInit();
await service.onModuleInit();
const sonnet = service.findModel('anthropic', 'claude-sonnet-4-6');
expect(sonnet).toBeDefined();
@@ -129,11 +129,11 @@ describe('ProviderService', () => {
expect(sonnet!.id).toBe('claude-sonnet-4-6');
});
it('can find registered Z.ai models by provider+id', () => {
it('can find registered Z.ai models by provider+id', async () => {
process.env['ZAI_API_KEY'] = 'test-zai';
const service = new ProviderService();
service.onModuleInit();
await service.onModuleInit();
const glm = service.findModel('zai', 'glm-4.5');
expect(glm).toBeDefined();

View File

@@ -0,0 +1 @@
export { OllamaAdapter } from './ollama.adapter.js';

View File

@@ -0,0 +1,125 @@
import { Logger } from '@nestjs/common';
import type { ModelRegistry } from '@mariozechner/pi-coding-agent';
import type {
CompletionEvent,
CompletionParams,
IProviderAdapter,
ModelInfo,
ProviderHealth,
} from '@mosaic/types';
/**
* Ollama provider adapter.
*
* Registers local Ollama models with the Pi ModelRegistry via the OpenAI-compatible
* completions API. Configuration is driven by environment variables:
* OLLAMA_BASE_URL or OLLAMA_HOST — base URL of the Ollama instance
* OLLAMA_MODELS — comma-separated list of model IDs (default: llama3.2,codellama,mistral)
*/
export class OllamaAdapter implements IProviderAdapter {
readonly name = 'ollama';
private readonly logger = new Logger(OllamaAdapter.name);
private registeredModels: ModelInfo[] = [];
constructor(private readonly registry: ModelRegistry) {}
async register(): Promise<void> {
const ollamaUrl = process.env['OLLAMA_BASE_URL'] ?? process.env['OLLAMA_HOST'];
if (!ollamaUrl) {
this.logger.debug('Skipping Ollama provider registration: OLLAMA_BASE_URL not set');
return;
}
const modelsEnv = process.env['OLLAMA_MODELS'] ?? 'llama3.2,codellama,mistral';
const modelIds = modelsEnv
.split(',')
.map((id: string) => id.trim())
.filter(Boolean);
this.registry.registerProvider('ollama', {
baseUrl: `${ollamaUrl}/v1`,
apiKey: 'ollama',
api: 'openai-completions' as never,
models: modelIds.map((id) => ({
id,
name: id,
reasoning: false,
input: ['text'] as ('text' | 'image')[],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 4096,
})),
});
this.registeredModels = modelIds.map((id) => ({
id,
provider: 'ollama',
name: id,
reasoning: false,
contextWindow: 8192,
maxTokens: 4096,
inputTypes: ['text'] as ('text' | 'image')[],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
}));
this.logger.log(
`Ollama provider registered at ${ollamaUrl} with models: ${modelIds.join(', ')}`,
);
}
listModels(): ModelInfo[] {
return this.registeredModels;
}
async healthCheck(): Promise<ProviderHealth> {
const ollamaUrl = process.env['OLLAMA_BASE_URL'] ?? process.env['OLLAMA_HOST'];
if (!ollamaUrl) {
return {
status: 'down',
lastChecked: new Date().toISOString(),
error: 'OLLAMA_BASE_URL not configured',
};
}
const checkUrl = `${ollamaUrl}/v1/models`;
const start = Date.now();
try {
const res = await fetch(checkUrl, {
method: 'GET',
headers: { 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 };
}
}
/**
* createCompletion is reserved for future direct-completion use.
* The current integration routes completions through Pi SDK's ModelRegistry/AgentSession.
*/
async *createCompletion(_params: CompletionParams): AsyncIterable<CompletionEvent> {
throw new Error(
'OllamaAdapter.createCompletion is not yet implemented. ' +
'Use Pi SDK AgentSession for completions.',
);
// Satisfy the AsyncGenerator return type — unreachable but required for TypeScript.
yield undefined as never;
}
}

View File

@@ -1,19 +1,43 @@
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { ModelRegistry, AuthStorage } from '@mariozechner/pi-coding-agent';
import { getModel, type Model, type Api } from '@mariozechner/pi-ai';
import type { ModelInfo, ProviderInfo, CustomProviderConfig } from '@mosaic/types';
import type {
CustomProviderConfig,
IProviderAdapter,
ModelInfo,
ProviderHealth,
ProviderInfo,
} from '@mosaic/types';
import { OllamaAdapter } from './adapters/index.js';
import type { TestConnectionResultDto } from './provider.dto.js';
/** DI injection token for the provider adapter array. */
export const PROVIDER_ADAPTERS = Symbol('PROVIDER_ADAPTERS');
@Injectable()
export class ProviderService implements OnModuleInit {
private readonly logger = new Logger(ProviderService.name);
private registry!: ModelRegistry;
onModuleInit(): void {
/**
* Adapters registered with this service.
* Built-in adapters (Ollama) are always present; additional adapters can be
* supplied via the PROVIDER_ADAPTERS injection token in the future.
*/
private adapters: IProviderAdapter[] = [];
async onModuleInit(): Promise<void> {
const authStorage = AuthStorage.inMemory();
this.registry = new ModelRegistry(authStorage);
this.registerOllamaProvider();
// Build the default set of adapters that rely on the registry
this.adapters = [new OllamaAdapter(this.registry)];
// Run all adapter registrations first (Ollama, and any future adapters)
await this.registerAll();
// Register API-key providers directly (Anthropic, OpenAI, Z.ai, custom)
// These do not yet have dedicated adapter classes (M3-002 through M3-005).
this.registerAnthropicProvider();
this.registerOpenAIProvider();
this.registerZaiProvider();
@@ -23,6 +47,59 @@ export class ProviderService implements OnModuleInit {
this.logger.log(`Providers initialized: ${available.length} models available`);
}
// ---------------------------------------------------------------------------
// Adapter-pattern API
// ---------------------------------------------------------------------------
/**
* Call register() on each adapter in order.
* Errors from individual adapters are logged and do not abort the others.
*/
async registerAll(): Promise<void> {
for (const adapter of this.adapters) {
try {
await adapter.register();
} catch (err) {
this.logger.error(
`Adapter "${adapter.name}" registration failed`,
err instanceof Error ? err.stack : String(err),
);
}
}
}
/**
* Return the adapter registered under the given provider name, or undefined.
*/
getAdapter(providerName: string): IProviderAdapter | undefined {
return this.adapters.find((a) => a.name === providerName);
}
/**
* Run healthCheck() on all adapters and return results keyed by provider name.
*/
async healthCheckAll(): Promise<Record<string, ProviderHealth>> {
const results: Record<string, ProviderHealth> = {};
await Promise.all(
this.adapters.map(async (adapter) => {
try {
results[adapter.name] = await adapter.healthCheck();
} catch (err) {
results[adapter.name] = {
status: 'down',
lastChecked: new Date().toISOString(),
error: err instanceof Error ? err.message : String(err),
};
}
}),
);
return results;
}
// ---------------------------------------------------------------------------
// Legacy / Pi-SDK-facing API (preserved for AgentService and RoutingService)
// ---------------------------------------------------------------------------
getRegistry(): ModelRegistry {
return this.registry;
}
@@ -69,6 +146,18 @@ export class ProviderService implements OnModuleInit {
}
async testConnection(providerId: string, baseUrl?: string): Promise<TestConnectionResultDto> {
// Delegate to the adapter when one exists and no URL override is given
const adapter = this.getAdapter(providerId);
if (adapter && !baseUrl) {
const health = await adapter.healthCheck();
return {
providerId,
reachable: health.status !== 'down',
latencyMs: health.latencyMs,
error: health.error,
};
}
// Resolve baseUrl: explicit override > registered provider > ollama env
let resolvedUrl = baseUrl;
@@ -143,6 +232,11 @@ export class ProviderService implements OnModuleInit {
this.logger.log(`Registered custom provider: ${config.id} (${config.models.length} models)`);
}
// ---------------------------------------------------------------------------
// Private helpers — direct registry registration for providers without adapters yet
// (Anthropic, OpenAI, Z.ai will move to adapters in M3-002 through M3-005)
// ---------------------------------------------------------------------------
private registerAnthropicProvider(): void {
const apiKey = process.env['ANTHROPIC_API_KEY'];
if (!apiKey) {
@@ -203,36 +297,6 @@ export class ProviderService implements OnModuleInit {
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;
const modelsEnv = process.env['OLLAMA_MODELS'] ?? 'llama3.2,codellama,mistral';
const modelIds = modelsEnv
.split(',')
.map((modelId: string) => modelId.trim())
.filter(Boolean);
this.registry.registerProvider('ollama', {
baseUrl: `${ollamaUrl}/v1`,
apiKey: 'ollama',
api: 'openai-completions' as never,
models: modelIds.map((id) => ({
id,
name: id,
reasoning: false,
input: ['text'] as ('text' | 'image')[],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 4096,
})),
});
this.logger.log(
`Ollama provider registered at ${ollamaUrl} with models: ${modelIds.join(', ')}`,
);
}
private registerCustomProviders(): void {
const customJson = process.env['MOSAIC_CUSTOM_PROVIDERS'];
if (!customJson) return;