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

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #306.
This commit is contained in:
2026-03-21 21:16:45 +00:00
committed by jason.woltje
parent d8ac088f3a
commit e95c70d329
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;

View File

@@ -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)

View File

@@ -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<string, unknown>;
}
/** 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<void>;
/**
* 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<ProviderHealth>;
/**
* 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<CompletionEvent>;
}