/** * pi-airpx — catalog and credential POLICY. * * Like src/map.ts this module is I/O-free: every side effect (network, disk) is * injected, so the cache contract and credential precedence are unit-testable * offline. extensions/index.ts supplies the real implementations. */ import type { PiModel } from "./map.ts"; /** Thrown when the proxy rejects the credential (HTTP 401/403). */ export class KeyRejectedError extends Error {} /** Injected side effects. */ export interface CatalogDeps { /** Fetch + map the upstream catalog. May throw (incl. KeyRejectedError). */ fetchModels: () => Promise; /** Last-good snapshot, or null/[] when absent. May throw. */ readCache: () => PiModel[] | null; /** Persist the snapshot. Best-effort: may throw and is then ignored. */ writeCache: (models: PiModel[]) => void; /** User-visible diagnostic sink. */ log: (message: string) => void; } export interface CatalogResult { models: PiModel[]; /** Where the list came from — useful for diagnostics and tests. */ source: "fresh" | "cache" | "empty"; } /** * A live API that returns ZERO models is a broken/half-deployed upstream, not a * catalog that legitimately became empty: airpx fails /readyz on an empty model * registry, and its deploy gate requires /v1/models to contain at least one id. * Treating it as authoritative would wipe a good cache and leave pi with no * models, so it is handled like a fetch failure. */ class EmptyCatalogError extends Error {} /** Run a side effect that must never break catalog resolution. */ function attempt(effect: () => T, fallback: T): T { try { return effect(); } catch { return fallback; } } /** Explain a degraded outcome. Pure: message text lives in exactly one place. */ function describeFailure(err: unknown, hasCache: boolean): string { const suffix = hasCache ? "using cached catalog (stale)" : "no cache available; registering empty"; if (err instanceof KeyRejectedError) return `key rejected; ${suffix}`; if (err instanceof EmptyCatalogError) { return `upstream returned no models (likely mid-deploy); ${suffix}`; } return `catalog fetch failed (${(err as Error).message}); ${suffix}`; } /** * Resolve the catalog to register. * * Contract: * SUCCESS ⇒ the fresh response REPLACES the cache (last-good * snapshot, never a union), so upstream removals and * re-pricing take effect immediately. * FAILURE + cache ⇒ register the last-good cache and keep it intact. * FAILURE + no cache ⇒ register an empty list so pi still boots. * * NEVER throws: the extension factory must not fail, and login must not fail on * a transient catalog error after the key was already validated. */ export async function resolveCatalog(deps: CatalogDeps): Promise { try { const fresh = await deps.fetchModels(); if (fresh.length === 0) throw new EmptyCatalogError(); // Best-effort: losing the cache is recoverable, losing the fresh list is not. attempt(() => deps.writeCache(fresh), undefined); return { models: fresh, source: "fresh" }; } catch (err) { const cached = attempt(() => deps.readCache() ?? [], []); deps.log(describeFailure(err, cached.length > 0)); return cached.length > 0 ? { models: cached, source: "cache" } : { models: [], source: "empty" }; } } /** Parse a cache file body into models. Never throws; junk degrades to []. */ export function parseCachedModels(raw: string): PiModel[] { try { const parsed = JSON.parse(raw) as { models?: PiModel[] }; return Array.isArray(parsed?.models) ? parsed.models : []; } catch { return []; } } /** * The pi provider id, which is ALSO the key under which credentials are stored * in ~/.pi/agent/auth.json. Single source of truth: `registerProvider(PROVIDER)` * in extensions/index.ts and the auth-file lookup below must agree, or a * successful `/login` would write a credential that resolveKey() cannot find. */ export const PROVIDER = "airpx"; /** * Resolve the credential: stored auth entry first (api_key `key` or oauth * `access`), then AIRPX_API_KEY. Never throws on a corrupt auth file. */ export function resolveKey( authFileBody: string | undefined, env: Record, ): string | undefined { const stored = storedKey(authFileBody); return stored ?? nonBlank(env.AIRPX_API_KEY); } function storedKey(authFileBody: string | undefined): string | undefined { if (!authFileBody) return undefined; try { const auth = JSON.parse(authFileBody) as Record< string, { type?: string; key?: string; access?: string } | undefined >; const entry = auth?.[PROVIDER]; if (!entry) return undefined; return nonBlank(entry.key) ?? nonBlank(entry.access); } catch { return undefined; } } function nonBlank(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; }