import type { RefreshModelsContext } from "@earendil-works/pi-ai"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { getDiscoveryApiKey } from "./auth.ts"; import { ProviderCatalog, type CatalogRefreshResult, type CatalogSnapshot, type RefreshTarget } from "./catalog.ts"; import { buildUnavailableProviderModels } from "./provider.ts"; import { buildProviderRegistration, normalizeProviderModels } from "./registration.ts"; import type { CpaProviderConfig, ProviderModelConfigLike } from "./types.ts"; export interface ProviderRuntimeOptions { pi: ExtensionAPI; config: CpaProviderConfig; catalog: ProviderCatalog; } export class ProviderRuntime { private registeredFingerprint?: string; private readonly options: ProviderRuntimeOptions; constructor(options: ProviderRuntimeOptions) { this.options = options; } async start(): Promise { const snapshot = await this.options.catalog.load(); this.register(snapshot, true); return snapshot; } async refresh( target: RefreshTarget = "all", mode: "background" | "manual" = "manual", getDiscoveryApiKey?: () => Promise, ): Promise { const result = await this.options.catalog.refresh(target, mode, getDiscoveryApiKey); if (result.models.updated || result.metadata.updated) this.register(result.snapshot, false); return result; } async refreshModels(context: RefreshModelsContext): Promise { if (!context.allowNetwork) { // Pi issues several offline refreshes during startup (one per provider // registration, one after services are created). The snapshot built at // start() is already current; re-reading a ~7 MB models.dev cache from // disk for each one is pure startup cost. const snapshot = this.options.catalog.current() ?? await this.options.catalog.load(); return normalizeProviderModels( snapshot.built.models.length > 0 ? snapshot.built.models : buildUnavailableProviderModels(), this.options.config.baseUrl, ); } const mode = context.force ? "manual" : "background"; const credential = context.credential; const keyFn: () => Promise = credential?.type === "api_key" ? async () => credential.key : () => getDiscoveryApiKey(this.options.config.providerName); // Pi calls this at startup and whenever the model selector opens. CPA's // model list is always re-discovered; models.dev metadata is re-fetched // only once it has gone stale, so a newly listed model stops rendering // with fallback metadata without anyone running a manual refresh. const result = await this.options.catalog.refresh("models-if-stale", mode, keyFn, context.signal); // Pi publishes refreshModels' return value synchronously. Registering here as // well would create a second, competing catalog publication. return normalizeProviderModels( result.snapshot.built.models.length > 0 ? result.snapshot.built.models : buildUnavailableProviderModels(), this.options.config.baseUrl, ); } private register(snapshot: CatalogSnapshot, force: boolean): void { const models = snapshot.built.models.length > 0 ? snapshot.built.models : buildUnavailableProviderModels(); const fingerprint = JSON.stringify(models); if (!force && fingerprint === this.registeredFingerprint) return; const registration = buildProviderRegistration(this.options.config, models, (ctx) => this.refreshModels(ctx)); this.options.pi.registerProvider(registration.providerName, registration.config); this.registeredFingerprint = fingerprint; } }