feat(M3-007,M3-009): provider health check scheduler and Ollama embedding default (#308)
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 #308.
This commit is contained in:
2026-03-21 21:30:15 +00:00
committed by jason.woltje
parent cfdd2b679c
commit 6c6bcbdb7f
3 changed files with 263 additions and 67 deletions

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { Injectable, Logger, type OnModuleDestroy, 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 {
@@ -8,14 +8,17 @@ import type {
ProviderHealth,
ProviderInfo,
} from '@mosaic/types';
import { OllamaAdapter } from './adapters/index.js';
import { AnthropicAdapter, OllamaAdapter, OpenAIAdapter } from './adapters/index.js';
import type { TestConnectionResultDto } from './provider.dto.js';
/** Default health check interval in seconds */
const DEFAULT_HEALTH_INTERVAL_SECS = 60;
/** DI injection token for the provider adapter array. */
export const PROVIDER_ADAPTERS = Symbol('PROVIDER_ADAPTERS');
@Injectable()
export class ProviderService implements OnModuleInit {
export class ProviderService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(ProviderService.name);
private registry!: ModelRegistry;
@@ -26,25 +29,123 @@ export class ProviderService implements OnModuleInit {
*/
private adapters: IProviderAdapter[] = [];
/**
* Cached health status per provider, updated by the health check scheduler.
*/
private healthCache: Map<string, ProviderHealth & { modelCount: number }> = new Map();
/** Timer handle for the periodic health check scheduler */
private healthCheckTimer: ReturnType<typeof setInterval> | null = null;
async onModuleInit(): Promise<void> {
const authStorage = AuthStorage.inMemory();
this.registry = new ModelRegistry(authStorage);
// Build the default set of adapters that rely on the registry
this.adapters = [new OllamaAdapter(this.registry)];
this.adapters = [
new OllamaAdapter(this.registry),
new AnthropicAdapter(this.registry),
new OpenAIAdapter(this.registry),
];
// Run all adapter registrations first (Ollama, and any future adapters)
// Run all adapter registrations first (Ollama, Anthropic, 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();
// Register API-key providers directly (Z.ai, custom)
// OpenAI now has a dedicated adapter (M3-003).
this.registerZaiProvider();
this.registerCustomProviders();
const available = this.registry.getAvailable();
this.logger.log(`Providers initialized: ${available.length} models available`);
// Kick off the health check scheduler
this.startHealthCheckScheduler();
}
onModuleDestroy(): void {
if (this.healthCheckTimer !== null) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = null;
}
}
// ---------------------------------------------------------------------------
// Health check scheduler
// ---------------------------------------------------------------------------
/**
* Start periodic health checks on all adapters.
* Interval is configurable via PROVIDER_HEALTH_INTERVAL env (seconds, default 60).
*/
private startHealthCheckScheduler(): void {
const intervalSecs =
parseInt(process.env['PROVIDER_HEALTH_INTERVAL'] ?? '', 10) || DEFAULT_HEALTH_INTERVAL_SECS;
const intervalMs = intervalSecs * 1000;
// Run an initial check immediately (non-blocking)
void this.runScheduledHealthChecks();
this.healthCheckTimer = setInterval(() => {
void this.runScheduledHealthChecks();
}, intervalMs);
this.logger.log(`Provider health check scheduler started (interval: ${intervalSecs}s)`);
}
private async runScheduledHealthChecks(): Promise<void> {
for (const adapter of this.adapters) {
try {
const health = await adapter.healthCheck();
const modelCount = adapter.listModels().length;
this.healthCache.set(adapter.name, { ...health, modelCount });
this.logger.debug(
`Health check [${adapter.name}]: ${health.status} (${health.latencyMs ?? 'n/a'}ms)`,
);
} catch (err) {
const modelCount = adapter.listModels().length;
this.healthCache.set(adapter.name, {
status: 'down',
lastChecked: new Date().toISOString(),
error: err instanceof Error ? err.message : String(err),
modelCount,
});
}
}
}
/**
* Return the cached health status for all adapters.
* Format: array of { name, status, latencyMs, lastChecked, modelCount }
*/
getProvidersHealth(): Array<{
name: string;
status: string;
latencyMs?: number;
lastChecked: string;
modelCount: number;
error?: string;
}> {
return this.adapters.map((adapter) => {
const cached = this.healthCache.get(adapter.name);
if (cached) {
return {
name: adapter.name,
status: cached.status,
latencyMs: cached.latencyMs,
lastChecked: cached.lastChecked,
modelCount: cached.modelCount,
error: cached.error,
};
}
// Not yet checked — return a pending placeholder
return {
name: adapter.name,
status: 'unknown',
lastChecked: new Date().toISOString(),
modelCount: adapter.listModels().length,
};
});
}
// ---------------------------------------------------------------------------
@@ -234,49 +335,9 @@ export class ProviderService implements OnModuleInit {
// ---------------------------------------------------------------------------
// Private helpers — direct registry registration for providers without adapters yet
// (Anthropic, OpenAI, Z.ai will move to adapters in M3-002 through M3-005)
// (Z.ai will move to an adapter in M3-005)
// ---------------------------------------------------------------------------
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) {