/** * Ollama Cloud model list updater * * Registers a dedicated `ollama-cloud` provider whose model list is fetched * live from https://ollama.com. Cloud models are reached through the local * Ollama daemon (http://127.0.0.1:11434/v1) using the `:cloud` / `-cloud` * name suffix, so this extension is non-destructive: it does not touch the * existing `ollama` provider defined in ~/.pi/agent/models.json. * * - On startup: uses a cached list (1h TTL); falls back to a fresh fetch. * - `/ollama-cloud-refresh`: force-refresh the list from ollama.com and * re-register the provider immediately (no /reload needed). * * Endpoints (both public, no auth required): * GET https://ollama.com/v1/models -> { data: [{ id }] } * POST https://ollama.com/api/show -> { capabilities, model_info } * * Cloud naming convention (per Ollama docs): * untagged model -> append ":cloud" (e.g. glm-5.2 -> glm-5.2:cloud) * tagged model -> append "-cloud" (e.g. gpt-oss:120b -> gpt-oss:120b-cloud) */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { readFile, writeFile, mkdir } from "node:fs/promises"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; const CLOUD_API = "https://ollama.com"; const LOCAL_BASE_URL = "http://127.0.0.1:11434/v1"; const PROVIDER = "ollama-cloud"; const PROVIDER_NAME = "Ollama Cloud"; const CACHE_FILE = join( homedir(), ".pi", "agent", "extensions", ".ollama-cloud-cache.json", ); const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour const SHOW_CONCURRENCY = 6; const STARTUP_TIMEOUT_MS = 15_000; interface CloudModelDef { id: string; // cloud-suffixed id passed to the local Ollama daemon baseId: string; // raw id from /v1/models reasoning: boolean; input: string[]; contextWindow: number; capabilities: string[]; } interface CacheShape { fetchedAt: number; models: CloudModelDef[]; } /** Build the cloud-routed model id the local Ollama daemon expects. */ function cloudId(baseId: string): string { return baseId.includes(":") ? `${baseId}-cloud` : `${baseId}:cloud`; } async function fetchJson(url: string, init?: RequestInit): Promise { const res = await fetch(url, init); if (!res.ok) throw new Error(`${url} -> HTTP ${res.status}`); return res.json(); } /** Fetch capabilities + context length for one cloud model. Returns null if retired/unavailable. */ async function showModel( baseId: string, signal?: AbortSignal, ): Promise { const name = cloudId(baseId); try { const data = await fetchJson(`${CLOUD_API}/api/show`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), signal, }); const capabilities: string[] = Array.isArray(data?.capabilities) ? data.capabilities : []; const modelInfo: Record = data?.model_info ?? {}; let contextLength = 0; for (const [k, v] of Object.entries(modelInfo)) { if (k.endsWith(".context_length") && typeof v === "number") { contextLength = Math.max(contextLength, v); } } return { id: name, baseId, reasoning: capabilities.includes("thinking"), input: capabilities.includes("vision") ? ["text", "image"] : ["text"], contextWindow: contextLength || 131072, capabilities, }; } catch { // Retired models and transient errors: skip silently. return null; } } /** Fetch the full cloud model list with per-model capability enrichment. */ async function fetchCloudModels(signal?: AbortSignal): Promise { const payload = await fetchJson(`${CLOUD_API}/v1/models`, { signal }); const baseIds: string[] = (payload?.data ?? []) .map((m: any) => m?.id) .filter((id: unknown): id is string => typeof id === "string" && id.length > 0); const results: (CloudModelDef | null)[] = []; const queue = [...baseIds]; const workers = Array.from({ length: SHOW_CONCURRENCY }, async () => { while (queue.length) { const baseId = queue.shift()!; results.push(await showModel(baseId, signal)); } }); await Promise.all(workers); return results.filter((m): m is CloudModelDef => m !== null); } async function readCache(): Promise { try { const raw = await readFile(CACHE_FILE, "utf8"); const parsed = JSON.parse(raw) as CacheShape; if ( parsed && typeof parsed.fetchedAt === "number" && Array.isArray(parsed.models) ) { return parsed; } return null; } catch { return null; } } async function writeCache(cache: CacheShape): Promise { try { await mkdir(dirname(CACHE_FILE), { recursive: true }); await writeFile(CACHE_FILE, JSON.stringify(cache, null, 2)); } catch { // Cache is best-effort; ignore write failures. } } /** Register (or replace) the ollama-cloud provider with the given model list. */ function register(pi: ExtensionAPI, models: CloudModelDef[]): void { pi.registerProvider(PROVIDER, { name: PROVIDER_NAME, baseUrl: LOCAL_BASE_URL, api: "openai-completions", apiKey: "ollama", // placeholder; local Ollama daemon ignores it compat: { supportsDeveloperRole: false, supportsReasoningEffort: false, }, models: models.map((m) => ({ id: m.id, name: m.baseId, reasoning: m.reasoning, input: m.input, contextWindow: m.contextWindow, maxTokens: 32768, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, })), }); } export default async function (pi: ExtensionAPI): Promise { let models: CloudModelDef[] = []; const cache = await readCache(); const cacheFresh = cache && cache.models.length > 0 && Date.now() - cache.fetchedAt < CACHE_TTL_MS; if (cacheFresh) { models = cache!.models; } else { try { models = await fetchCloudModels( AbortSignal.timeout(STARTUP_TIMEOUT_MS) as unknown as AbortSignal, ); if (models.length > 0) { await writeCache({ fetchedAt: Date.now(), models }); } } catch (err) { // Network failed: fall back to stale cache if we have one. if (cache && cache.models.length > 0) { models = cache.models; } else { console.error("[ollama-cloud-models] startup fetch failed:", err); } } } if (models.length > 0) register(pi, models); pi.registerCommand("ollama-cloud-refresh", { description: "Refresh the Ollama Cloud model list from ollama.com", handler: async (_args, ctx) => { ctx.ui.notify("Refreshing Ollama Cloud models…", "info"); try { const fresh = await fetchCloudModels(); if (fresh.length === 0) { ctx.ui.notify("Ollama Cloud returned no models", "error"); return; } register(pi, fresh); await writeCache({ fetchedAt: Date.now(), models: fresh }); ctx.ui.notify(`Ollama Cloud: ${fresh.length} models available`, "info"); } catch (err) { ctx.ui.notify(`Failed to refresh Ollama Cloud: ${String(err)}`, "error"); } }, }); }