import { readFile, writeFile, mkdir } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; const API_BASE_URL = "https://api.cline.bot/api/v1"; const CACHE_TTL_MS = 60 * 60 * 1000; const CACHE_DIR = join(homedir(), ".cache", "pi", "cline-pi"); const CACHE_FILE = join(CACHE_DIR, "models.json"); type CatalogModel = { id: string; name: string; context_length: number; top_provider: { max_completion_tokens?: number }; supported_parameters?: string[]; pricing: { prompt?: string; completion?: string; input_cache_read?: string }; architecture?: { modality?: string }; }; type CatalogResponse = { data: CatalogModel[] }; type RecommendedModelsData = { recommended: { id: string; name: string; description: string; tags: string[] }[]; free: { id: string; name: string; description: string; tags: string[] }[]; clinePass: { id: string; name: string; description: string; tags: string[] }[]; }; type CachedData = { models: CatalogModel[]; fetchedAt: number }; export type PiModelConfig = { id: string; name: string; reasoning: boolean; input: ("text" | "image")[]; cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; contextWindow: number; maxTokens: number; compat?: { supportsDeveloperRole?: boolean }; }; function parseCost(val: string | undefined): number { const n = parseFloat(val ?? "0"); return Number.isFinite(n) ? n : 0; } function buildModel(raw: CatalogModel, free?: boolean): PiModelConfig { const hasReasoning = raw.supported_parameters?.includes("reasoning") || raw.supported_parameters?.includes("include_reasoning") || false; const modality = raw.architecture?.modality ?? ""; const hasImage = modality.includes("image"); const name = free ? `Free: ${raw.name}` : raw.name; return { id: raw.id, name, reasoning: hasReasoning, input: hasImage ? ["text", "image"] : ["text"], cost: { input: parseCost(raw.pricing?.prompt), output: parseCost(raw.pricing?.completion), cacheRead: parseCost(raw.pricing?.input_cache_read), cacheWrite: 0, }, contextWindow: raw.context_length ?? 200_000, maxTokens: raw.top_provider?.max_completion_tokens ?? 128_000, }; } function deduplicate(models: PiModelConfig[]): PiModelConfig[] { const seen = new Set(); return models.filter((m) => { if (seen.has(m.id)) return false; seen.add(m.id); return true; }); } async function readCache(): Promise { try { const raw = await readFile(CACHE_FILE, "utf8"); const cached = JSON.parse(raw) as CachedData; if (typeof cached.fetchedAt !== "number") return undefined; if (!Array.isArray(cached.models)) return undefined; if (Date.now() - cached.fetchedAt > CACHE_TTL_MS) return undefined; return cached; } catch { return undefined; } } async function writeCache(models: CatalogModel[]): Promise { try { await mkdir(CACHE_DIR, { recursive: true }); await writeFile(CACHE_FILE, JSON.stringify({ models, fetchedAt: Date.now() })); } catch { } } async function fetchCatalog(): Promise { const cached = await readCache(); if (cached) return cached.models; const response = await fetch(`${API_BASE_URL}/ai/cline/models`, { headers: { Accept: "application/json" }, }); if (!response.ok) { throw new Error( `Failed to fetch models: ${response.status} ${response.statusText}`, ); } const body = (await response.json()) as CatalogResponse; const models: CatalogModel[] = []; if (body?.data) { for (const m of body.data) { if (m.id && m.name) models.push(m); } } if (models.length > 0) { await writeCache(models); } return models; } async function fetchRecommendedModels(): Promise { try { const response = await fetch(`${API_BASE_URL}/ai/cline/recommended-models`, { headers: { Accept: "application/json" }, }); if (response.ok) { return (await response.json()) as RecommendedModelsData; } } catch { } return undefined; } function buildClinePassModel( entry: { id: string; name: string }, catalogModels: CatalogModel[], ): PiModelConfig { const suffix = entry.id.replace(/^cline-pass\//, ""); const match = catalogModels.find( (m) => m.name === suffix || m.id === suffix || m.id.endsWith(`/${suffix}`), ); if (match) { const config = buildModel(match); config.id = entry.id; config.name = entry.name ?? entry.id; return config; } return { id: entry.id, name: entry.name ?? entry.id, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200_000, maxTokens: 128_000, }; } export async function fetchModels(): Promise { const [models, recommended] = await Promise.all([ fetchCatalog(), fetchRecommendedModels(), ]); const freeIds = new Set((recommended?.free ?? []).map((e) => e.id)); const result: PiModelConfig[] = []; for (const m of models) { result.push(buildModel(m, freeIds.has(m.id))); } if (recommended?.clinePass) { for (const entry of recommended.clinePass) { if (!result.some((m) => m.id === entry.id)) { result.push(buildClinePassModel(entry, models)); } } } return deduplicate(result); }