/** * Provider snapshot cache — cold ACP probe per provider + static manifest merge. */ import { homedir } from 'node:os'; import { exec as execCb } from 'node:child_process'; import { promisify } from 'node:util'; import type { FastifyBaseLogger } from 'fastify'; import type { Sql } from '../db.js'; import type { Config } from '../config.js'; import { PROVIDERS, type ProviderDef } from './provider-registry.js'; import { getManifestDefaultModeId, getManifestModes, PROVIDER_MANIFEST, } from './provider-manifest.js'; import { probeAcpProvider } from './acp-probe.js'; import { parseCursorAgentModelsOutput } from './cursor-models.js'; import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js'; import { getManifestCommands, mergeCommands } from './provider-commands.js'; import { readQwenSettingsModels } from './qwen-settings.js'; const exec = promisify(execCb); interface AgentRow { name: string; install_path: string | null; supports_acp: boolean; models: ProviderModel[] | null; label: string | null; transport: string | null; } async function fetchLlamaSwapModels(config: Config): Promise { try { const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/models`); if (!res.ok) return []; const parsed = (await res.json()) as { data?: Array<{ id: string }> }; return (parsed.data ?? []).map((m) => ({ id: m.id, label: m.id })); } catch { return []; } } async function fetchCursorModelsCli(installPath: string): Promise { try { const { stdout } = await exec(`"${installPath}" models`, { timeout: 15_000, maxBuffer: 1024 * 1024 }); return parseCursorAgentModelsOutput(stdout); } catch { return []; } } /** Prefix llama-swap model ids so they don't collide with provider-native models. */ export function prefixLlamaSwapModels(models: ProviderModel[]): ProviderModel[] { return models.map((m) => ({ ...m, id: m.id.startsWith('llama-swap/') ? m.id : `llama-swap/${m.id}`, })); } function attachClaudeThinking(models: ProviderModel[]): ProviderModel[] { const thinking = PROVIDER_MANIFEST.claude?.thinkingOptions; if (!thinking?.length) return models; return models.map((m) => ({ ...m, thinkingOptions: thinking, defaultThinkingOptionId: 'medium', })); } export function mergeModels(...lists: ProviderModel[][]): ProviderModel[] { const seen = new Set(); const out: ProviderModel[] = []; for (const list of lists) { for (const m of list) { if (seen.has(m.id)) continue; seen.add(m.id); out.push(m); } } return out; } async function buildProviderEntry( provider: ProviderDef, agentRow: AgentRow | undefined, llamaModels: ProviderModel[], cwd: string, ): Promise { const isNative = provider.name === 'boocode'; const installed = isNative || !!agentRow; if (!installed) return null; let transport = provider.transport; if (agentRow && provider.transport === 'acp' && !agentRow.supports_acp) { transport = 'pty'; } const fallbackModes = getManifestModes(provider.name); const defaultModeId = getManifestDefaultModeId(provider.name); if (isNative) { return { name: provider.name, label: provider.label, transport, status: 'ready', installed: true, models: llamaModels, modes: [], defaultModeId: null, commands: getManifestCommands(provider.name), }; } let models: ProviderModel[] = []; if (provider.modelSource === 'llama-swap' && provider.mergeLlamaSwap) { models = llamaModels; } else if (agentRow?.models?.length) { models = agentRow.models; } else if (provider.staticModels) { models = provider.staticModels.map((m) => ({ id: m.id, label: m.label })); } if (provider.name === 'claude') { models = attachClaudeThinking(models); return { name: provider.name, label: agentRow?.label ?? provider.label, transport, status: 'ready', installed: true, models, modes: fallbackModes, defaultModeId, commands: getManifestCommands(provider.name), }; } if (transport === 'acp' && agentRow?.install_path && agentRow.supports_acp) { const probe = await probeAcpProvider(provider.name, agentRow.install_path, cwd); if (probe.models.length > 0) { models = probe.models; } else if (provider.name === 'cursor' && agentRow.install_path) { models = await fetchCursorModelsCli(agentRow.install_path); } else if (provider.modelSource === 'llama-swap') { models = llamaModels; } if (provider.name === 'qwen') { const settingsModels = await readQwenSettingsModels(); models = mergeModels(models, settingsModels); } if (provider.mergeLlamaSwap && provider.modelSource !== 'llama-swap') { const nativeModels = probe.models.length > 0 ? probe.models : models; models = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels)); } return { name: provider.name, label: agentRow.label ?? provider.label, transport, status: probe.ok ? 'ready' : 'error', installed: true, models, modes: probe.modes.length > 0 ? probe.modes : fallbackModes, defaultModeId: probe.defaultModeId ?? defaultModeId, commands: mergeCommands(getManifestCommands(provider.name), probe.commands), error: probe.error, }; } // PTY-only providers (qwen fallback when ACP unavailable) if (provider.name === 'qwen') { if (models.length === 0) { models = await readQwenSettingsModels(); } } return { name: provider.name, label: agentRow?.label ?? provider.label, transport, status: 'ready', installed: true, models, modes: fallbackModes, defaultModeId, commands: getManifestCommands(provider.name), }; } const snapshotCache = new Map(); const snapshotInflight = new Map>(); const CACHE_TTL_MS = 5 * 60_000; export async function getProviderSnapshot( sql: Sql, config: Config, cwd?: string, force = false, ): Promise { const resolvedCwd = cwd?.trim() || homedir(); const cacheKey = resolvedCwd; const cached = snapshotCache.get(cacheKey); if (!force && cached && Date.now() - cached.at < CACHE_TTL_MS) { return cached.entries; } const inflight = snapshotInflight.get(cacheKey); if (!force && inflight) { return inflight; } const build = async (): Promise => { const llamaModels = await fetchLlamaSwapModels(config); const agents = await sql` SELECT name, install_path, supports_acp, models, label, transport FROM available_agents `; const agentMap = new Map(agents.map((a) => [a.name, a])); const built = await Promise.all( PROVIDERS.map((provider) => buildProviderEntry(provider, agentMap.get(provider.name), llamaModels, resolvedCwd), ), ); const entries = built.filter((entry): entry is ProviderSnapshotEntry => entry !== null); snapshotCache.set(cacheKey, { at: Date.now(), entries }); return entries; }; const promise = build().finally(() => { snapshotInflight.delete(cacheKey); }); snapshotInflight.set(cacheKey, promise); return promise; } export function clearProviderSnapshotCache(): void { snapshotCache.clear(); snapshotInflight.clear(); } /** Persist probed model lists back to available_agents for fast legacy reads. */ export async function persistProbedModels( sql: Sql, entries: ProviderSnapshotEntry[], log: FastifyBaseLogger, ): Promise { let count = 0; for (const entry of entries) { if (entry.name === 'boocode' || entry.models.length === 0) continue; const flatModels = entry.models.map(({ id, label }) => ({ id, label })); await sql` UPDATE available_agents SET models = ${sql.json(flatModels as never)}, last_probed_at = clock_timestamp() WHERE name = ${entry.name} `; count++; } if (count > 0) { log.info({ count }, 'provider-snapshot: persisted models to available_agents'); } }