import { createHash, randomUUID } from "node:crypto"; import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent"; import { BUNDLED_METADATA_DATE, BUNDLED_MODELS, applyDocumentedCostRules, canonicalModelId, modelTemplate } from "./models.js"; import { type DocMetadata, fetchDocMetadata, nameKey, validateDocMetadata } from "./catalog-docs.js"; import { getCatalogText, modelsUrl } from "./catalog-http.js"; const DOC_TTL_MS = 24 * 60 * 60 * 1000; export const DISCOVERY_TIMEOUT_MS = 4000; interface CatalogEntry { id: string; owned_by?: string; display_name?: string } interface Snapshot { version: 1; fetchedAt: number; entries: CatalogEntry[] } interface DocSnapshot { version: 1; fetchedAt: number; metadata: DocMetadata } export interface CatalogResult { models: ProviderModelConfig[]; source: "live" | "cache" | "bundled"; fetchedAt?: number; docsSource: "live" | "cache" | "bundled" | "disabled"; docsFetchedAt?: number; estimatedIds: string[]; skippedIds: string[]; warnings: string[]; } export function catalogScope(baseUrl: string, apiKey: string | undefined): string { // Scope snapshots to an endpoint AND credential, without writing either to disk. return createHash("sha256").update(modelsUrl(baseUrl).href).update("\0").update(apiKey ?? "").digest("hex"); } export function parseCatalog(value: unknown): CatalogEntry[] { if (!value || typeof value !== "object" || !Array.isArray((value as any).data) || (value as any).data.length > 10000) { throw new Error("Invalid model catalog"); } return (value as { data: unknown[] }).data.map(item => { if (!item || typeof item !== "object") throw new Error("Invalid model entry"); const m = item as Record; if (typeof m.id !== "string" || !/^[a-z0-9][a-z0-9./_-]{0,159}$/.test(m.id)) throw new Error("Invalid model ID"); if (m.owned_by !== undefined && (typeof m.owned_by !== "string" || !/^[a-z0-9_-]{1,40}$/.test(m.owned_by))) throw new Error("Invalid model owner"); const name = m.display_name ?? m.name; return { id: m.id, ...(typeof m.owned_by === "string" ? { owned_by: m.owned_by } : {}), ...(typeof name === "string" && name.length <= 160 && !/[\x00-\x1f\x7f]/.test(name) ? { display_name: name } : {}), }; }); } export function buildModels(entries: CatalogEntry[], docs?: DocMetadata, now = Date.now()) { const models: ProviderModelConfig[] = [], estimatedIds: string[] = [], skippedIds: string[] = []; const seen = new Set(); for (const entry of entries) { const id = canonicalModelId(entry.id, entry.owned_by); if (!id) { skippedIds.push(entry.id); continue; } if (seen.has(id)) continue; seen.add(id); const model = modelTemplate(id); if (!model) { skippedIds.push(entry.id); continue; } const reviewed = BUNDLED_MODELS.find(m => m.id === id); model.name = entry.display_name ?? reviewed?.name ?? id.split("/")[1]; const price = docs?.prices[nameKey(model.name)]; const limits = docs?.limits[id]; if (price) model.cost = { ...price }; if (limits) Object.assign(model, limits); // New families/versions use inferred wire behavior even if docs provide rates. const estimated = !reviewed || (id === "openai/gpt-5.3-codex" && !price); if (estimated) { model.name += " (estimated metadata)"; estimatedIds.push(id); } applyDocumentedCostRules(model, now); models.push(model); } return { models, estimatedIds, skippedIds }; } async function readJson(file: string): Promise { try { const data = await readFile(file, "utf8"); return data.length <= 2 * 1024 * 1024 ? JSON.parse(data) : undefined; } catch { return undefined; } } async function saveJson(dir: string, file: string, data: unknown): Promise { const temp = `${file}.${randomUUID()}.tmp`; try { await mkdir(dir, { recursive: true, mode: 0o700 }); await writeFile(temp, JSON.stringify(data), { mode: 0o600, flag: "wx" }); await rename(temp, file); } catch { // Read-only sandboxes and cache write failures must not prevent model selection. } finally { await unlink(temp).catch(() => {}); } } export interface DiscoveryOptions { baseUrl: string; apiKey?: string; cacheDir: string; forceDocs?: boolean; docsEnabled?: boolean; signal?: AbortSignal; timeoutMs?: number; now?: number; /** Dependency injection for tests; production always uses the fixed docs origin. */ loadDocs?: (signal: AbortSignal) => Promise; } export async function discoverModels(options: DiscoveryOptions): Promise { const now = options.now ?? Date.now(); const warnings: string[] = []; const bundledEntries = BUNDLED_MODELS.map(m => ({ id: m.id, display_name: m.name })); const fallback = (docsSource: CatalogResult["docsSource"] = "bundled"): CatalogResult => ({ ...buildModels(bundledEntries, undefined, now), source: "bundled", docsSource, warnings, }); if (!options.apiKey) return fallback(); // No credential: no network requests at all. let url: URL, scope: string; try { url = modelsUrl(options.baseUrl); scope = catalogScope(options.baseUrl, options.apiKey); } catch { warnings.push("Invalid Parley base URL; using bundled models."); return fallback(); } const file = join(options.cacheDir, `${scope}.json`); const docsFile = join(options.cacheDir, "docs-v1.json"); // Public data, independent of account. const [rawCache, rawDocs] = await Promise.all([readJson(file), readJson(docsFile)]); const validTime = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n) && n > 0 && n <= now; let cache: Snapshot | undefined; try { if (rawCache?.version === 1 && validTime(rawCache.fetchedAt)) { cache = { version: 1, fetchedAt: rawCache.fetchedAt, entries: parseCatalog({ data: rawCache.entries }) }; } } catch { /* Ignore a corrupt snapshot. */ } let docCache: DocSnapshot | undefined; const metadata = validateDocMetadata(rawDocs?.metadata); if (rawDocs?.version === 1 && validTime(rawDocs.fetchedAt) && metadata) { docCache = { version: 1, fetchedAt: rawDocs.fetchedAt, metadata }; } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? DISCOVERY_TIMEOUT_MS); const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal; try { const docs: { source: CatalogResult["docsSource"]; snapshot?: DocSnapshot } = { source: options.docsEnabled === false ? "disabled" : docCache ? "cache" : "bundled", snapshot: options.docsEnabled === false ? undefined : docCache, }; const catalogTask = (async () => { try { const text = await getCatalogText(url, signal, { Authorization: `Bearer ${options.apiKey}` }, 2 * 1024 * 1024); return { version: 1 as const, fetchedAt: now, entries: parseCatalog(JSON.parse(text)) }; } catch { warnings.push(`Parley model discovery failed; using ${cache ? "cached" : "bundled"} models. Check connectivity, the key, and GET /v1/models proxy access.`); return undefined; } })(); const docsTask = (async () => { if (options.docsEnabled === false || (!options.forceDocs && docCache && now - docCache.fetchedAt < DOC_TTL_MS)) return; try { const loaded = validateDocMetadata(await (options.loadDocs ?? fetchDocMetadata)(signal)); if (!loaded) throw new Error("Invalid documentation metadata"); docs.snapshot = { version: 1, fetchedAt: now, metadata: loaded }; docs.source = "live"; } catch { warnings.push(`Parley documentation refresh failed; using ${docCache ? "cached" : "bundled"} prices and limits.`); } })(); const [fresh] = await Promise.all([catalogTask, docsTask]); if (options.signal?.aborted) throw new Error("Parley model refresh cancelled"); const selected = fresh ?? cache; const result: CatalogResult = { ...buildModels(selected?.entries ?? bundledEntries, docs.snapshot?.metadata, now), source: fresh ? "live" : cache ? "cache" : "bundled", fetchedAt: selected?.fetchedAt, docsSource: docs.source, docsFetchedAt: docs.snapshot?.fetchedAt, warnings, }; await Promise.all([ fresh ? saveJson(options.cacheDir, file, fresh) : undefined, docs.source === "live" ? saveJson(options.cacheDir, docsFile, docs.snapshot) : undefined, ]); return result; } finally { clearTimeout(timer); } } export function catalogSummary(result: CatalogResult): string { const date = (n?: number) => n ? ` (${new Date(n).toISOString().slice(0, 10)})` : ""; return [ `Parley: ${result.models.length} chat models; catalog: ${result.source}${date(result.fetchedAt)}; docs: ${result.docsSource}${date(result.docsFetchedAt)}.`, `Bundled fallback metadata: ${BUNDLED_METADATA_DATE}. Costs are estimates, not invoices.`, result.estimatedIds.length ? `Estimated metadata: ${result.estimatedIds.join(", ")}.` : "", result.skippedIds.length ? `Skipped unsupported/non-chat models: ${result.skippedIds.join(", ")}.` : "", ...result.warnings, ].filter(Boolean).join("\n"); }