From e95c70d3295d75ae271e46f0060335b246014ca1 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 21 Mar 2026 21:16:45 +0000 Subject: [PATCH] feat(M3-001): refactor ProviderService into IProviderAdapter pattern (#306) Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- .../agent/__tests__/provider.service.test.ts | 28 ++-- apps/gateway/src/agent/adapters/index.ts | 1 + .../src/agent/adapters/ollama.adapter.ts | 125 +++++++++++++++++ apps/gateway/src/agent/provider.service.ts | 130 +++++++++++++----- docs/scratchpads/m3-001-provider-adapter.md | 55 ++++++++ packages/types/src/provider/index.ts | 97 +++++++++++++ 6 files changed, 389 insertions(+), 47 deletions(-) create mode 100644 apps/gateway/src/agent/adapters/index.ts create mode 100644 apps/gateway/src/agent/adapters/ollama.adapter.ts create mode 100644 docs/scratchpads/m3-001-provider-adapter.md diff --git a/apps/gateway/src/agent/__tests__/provider.service.test.ts b/apps/gateway/src/agent/__tests__/provider.service.test.ts index d69ea88..142dfef 100644 --- a/apps/gateway/src/agent/__tests__/provider.service.test.ts +++ b/apps/gateway/src/agent/__tests__/provider.service.test.ts @@ -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(); diff --git a/apps/gateway/src/agent/adapters/index.ts b/apps/gateway/src/agent/adapters/index.ts new file mode 100644 index 0000000..0ab07b6 --- /dev/null +++ b/apps/gateway/src/agent/adapters/index.ts @@ -0,0 +1 @@ +export { OllamaAdapter } from './ollama.adapter.js'; diff --git a/apps/gateway/src/agent/adapters/ollama.adapter.ts b/apps/gateway/src/agent/adapters/ollama.adapter.ts new file mode 100644 index 0000000..b71d520 --- /dev/null +++ b/apps/gateway/src/agent/adapters/ollama.adapter.ts @@ -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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/gateway/src/agent/provider.service.ts b/apps/gateway/src/agent/provider.service.ts index 36cdf0c..43cc6d1 100644 --- a/apps/gateway/src/agent/provider.service.ts +++ b/apps/gateway/src/agent/provider.service.ts @@ -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 { 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 { + 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> { + const results: Record = {}; + 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 { + // 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; diff --git a/docs/scratchpads/m3-001-provider-adapter.md b/docs/scratchpads/m3-001-provider-adapter.md new file mode 100644 index 0000000..b81bdb3 --- /dev/null +++ b/docs/scratchpads/m3-001-provider-adapter.md @@ -0,0 +1,55 @@ +# M3-001 Provider Adapter Pattern — Scratchpad + +## Objective + +Refactor ProviderService into an IProviderAdapter pattern without breaking existing Ollama flow. + +## Plan + +1. Add `IProviderAdapter` interface and supporting types to `@mosaic/types` provider package +2. Create `apps/gateway/src/agent/adapters/` directory with: + - `provider-adapter.interface.ts` — IProviderAdapter + ProviderHealth + CompletionParams + CompletionEvent + - `ollama.adapter.ts` — extract existing Ollama logic +3. Refactor ProviderService: + - Accept `IProviderAdapter[]` (injected via DI token) + - `registerAll()` / `listModels()` aggregates from all adapters + - `getAdapter(name)` — lookup by name + - `healthCheckAll()` — check all adapters + - Keep Pi ModelRegistry wiring (required by AgentService) +4. Wire up in AgentModule + +## Key Findings + +### Pi SDK Compatibility + +- Pi SDK uses `ModelRegistry` as central registry; ProviderService wraps it +- `ModelRegistry.registerProvider()` is the integration point — adapters call this +- Pi doesn't have a native "IProviderAdapter" concept — adapters are a Mosaic abstraction on top +- The `createAgentSession()` call in AgentService uses `modelRegistry: this.providerService.getRegistry()` +- OllamaAdapter should call `registry.registerProvider('ollama', {...})` same as today +- CompletionParams/CompletionEvent: Pi SDK streams via `AgentSession.prompt()`, not raw completion + — IProviderAdapter.createCompletion() is for future direct use; for now stub or leave as interface-only + — ASSUMPTION: createCompletion is reserved for future M3+ work; Pi SDK owns the actual streaming + +## Implementation Notes + +- ESM: use `.js` extensions in all imports +- NestJS: use `@Inject()` explicitly +- Keep RoutingService working — it only uses `providerService.listAvailableModels()` +- Keep AgentService working — it uses `providerService.getRegistry()`, `findModel()`, `getDefaultModel()`, `listAvailableModels()` + +## Progress + +- [ ] Add types to @mosaic/types +- [ ] Create adapters/ directory +- [ ] Create IProviderAdapter interface file +- [ ] Create OllamaAdapter +- [ ] Refactor ProviderService +- [ ] Update AgentModule +- [ ] Run tests +- [ ] Run quality gates + +## Risks + +- Pi SDK doesn't natively support IProviderAdapter — adapters are a layer on top +- createCompletion() is architecturally sound but requires Pi session bypass (future work) diff --git a/packages/types/src/provider/index.ts b/packages/types/src/provider/index.ts index 1305391..1ee5bc7 100644 --- a/packages/types/src/provider/index.ts +++ b/packages/types/src/provider/index.ts @@ -52,3 +52,100 @@ export interface CustomProviderConfig { maxTokens?: number; }>; } + +// --------------------------------------------------------------------------- +// IProviderAdapter pattern — M3-001 +// --------------------------------------------------------------------------- + +/** Health status of a provider */ +export type ProviderHealthStatus = 'healthy' | 'degraded' | 'down'; + +/** Result of a provider health check */ +export interface ProviderHealth { + status: ProviderHealthStatus; + /** Round-trip latency in milliseconds (undefined when provider is down) */ + latencyMs?: number; + /** ISO-8601 timestamp of the check */ + lastChecked: string; + /** Human-readable error message (defined when status is not healthy) */ + error?: string; +} + +/** A single message in a completion request */ +export interface CompletionMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +/** Tool definition for completion requests */ +export interface CompletionTool { + name: string; + description: string; + parameters: Record; +} + +/** Parameters for a completion request */ +export interface CompletionParams { + model: string; + messages: CompletionMessage[]; + tools?: CompletionTool[]; + temperature?: number; + maxTokens?: number; + stream?: boolean; +} + +/** Usage statistics for a completion event */ +export interface CompletionUsage { + inputTokens: number; + outputTokens: number; +} + +/** A streamed completion event */ +export type CompletionEvent = + | { type: 'text_delta'; content: string } + | { type: 'tool_call'; name: string; arguments: string } + | { type: 'done'; usage?: CompletionUsage }; + +/** + * Pluggable provider adapter interface. + * + * Each LLM provider (Anthropic, OpenAI, Ollama, etc.) implements this interface + * to integrate with Mosaic's provider layer. The ProviderService aggregates all + * registered adapters and routes requests accordingly. + * + * Note on createCompletion: this method is part of the interface for future + * direct-completion use cases. The current Pi SDK integration routes completions + * through the Pi session/ModelRegistry layer rather than calling adapters directly. + * Adapters MUST still implement register() and healthCheck() correctly — those are + * used by ProviderService today. + */ +export interface IProviderAdapter { + /** Unique provider identifier (e.g. 'anthropic', 'openai', 'ollama') */ + readonly name: string; + + /** + * Initialize the provider — connect, discover models, register with the + * Pi ModelRegistry. Called once at module startup by ProviderService.registerAll(). + */ + register(): Promise; + + /** + * Return the list of models this adapter makes available. + * Returns an empty array when the provider is not configured. + */ + listModels(): ModelInfo[]; + + /** + * Check whether the provider endpoint is reachable and responsive. + */ + healthCheck(): Promise; + + /** + * Stream a completion from the provider. + * + * Note: Currently reserved for future use. The Pi SDK integration routes + * completions through ModelRegistry / AgentSession rather than this method. + * Implementations may throw NotImplementedError until M3+ tasks wire this up. + */ + createCompletion(params: CompletionParams): AsyncIterable; +}