/** * Shared Ollama utilities for Pi Coding Agent extensions. * Eliminates getOllamaBaseUrl() duplication across simplebench, ollama-sync, status. * * @module shared/ollama * @writtenby VTSTech — https://www.vts-tech.org */ import * as fs from "node:fs"; import { join } from "node:path"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { debugLog } from "./debug"; /** Minimal shape of Pi's extension context that provider detection reads. Keeps this module * standalone instead of importing a Pi type the extension host resolves at load time. */ interface PiExtensionContext { model?: { provider?: string; id?: string }; } // ============================================================================ // Constants // ============================================================================ /** * Extension package version. Derived from the VERSION file in the repo root. * * IMPORTANT: Do NOT update this constant manually. * Use ./scripts/bump-version.sh to update ALL locations: * - VERSION file (source of truth) * - shared/ollama.ts (EXTENSION_VERSION) * - scripts/build-packages.sh (derived from VERSION at runtime) * - scripts/publish-packages.sh (derived from VERSION at runtime) * - package.json (version field) */ export const EXTENSION_VERSION = "1.3.4"; /** * Path to Pi's models.json configuration file. * * This file contains the provider configurations and model definitions * used by Pi to connect to various LLM backends (Ollama, OpenAI, etc.). * * @example * ```typescript * Pi reads it from its own agent directory, so `modelsJsonPath()` resolves it per call * and a custom `PI_AGENT_DIR` is honoured. * * @example * ```typescript * console.log(modelsJsonPath()); * // Output: "/home/user/.pi/agent/models.json" * ``` */ export function modelsJsonPath(): string { return join(getAgentDir(), "models.json"); } /** Pi's credential store, resolved the same way as {@link modelsJsonPath}. */ export function authJsonPath(): string { return join(getAgentDir(), "auth.json"); } // ============================================================================ // Types // ============================================================================ /** * Model information returned by Ollama's /api/tags endpoint. * * Contains metadata about a pulled model including its name, size, * quantization level, and model family. * * @property name - The model's tag name (e.g., "qwen3:0.6b", "llama3.2:1b") * @property model - Alternative model identifier (often same as name) * @property modified_at - ISO 8601 timestamp of when the model was last modified * @property size - Model file size in bytes * @property digest - SHA256 digest of the model * @property details - Additional model metadata */ export interface OllamaModel { name: string; model: string; modified_at: string; size: number; digest: string; details: { parent_model: string; format: string; family: string; families: string[]; parameter_size: string; quantization_level: string; }; } /** * Pi's models.json configuration structure. * * Contains provider configurations keyed by provider name (e.g., "ollama", "openai"). * Each provider has a base URL, API mode, and list of available models. * * @example * ```json * { * "providers": { * "ollama": { * "baseUrl": "http://localhost:11434/v1", * "api": "openai-completions", * "models": [{ "id": "qwen3:0.6b" }] * } * } * } * ``` */ export interface PiModelsJson { providers: Record; } /** * Configuration for a single provider in models.json. * * @property baseUrl - The base URL for the provider's API (e.g., "http://localhost:11434/v1") * @property api - The API mode to use (e.g., "openai-completions", "anthropic-messages") * @property apiKey - Optional API key for authentication * @property compat - Compatibility settings for non-standard APIs * @property models - List of models available from this provider */ export interface PiProviderConfig { baseUrl?: string; api?: string; apiKey?: string; compat?: Record; models: PiModelEntry[]; } /** * Entry for a single model in Pi's configuration. * * @property id - The model identifier (e.g., "qwen3:0.6b") * @property reasoning - Whether the model supports extended thinking/reasoning * @property toolSupport - Level of tool support ("native", "react", "none") * @property modelFamily - The model family (e.g., "qwen3", "llama", "granite") * @property parameterSize - Human-readable parameter count (e.g., "352M", "1.5B") * @property quantizationLevel - Quantization type (e.g., "Q4_K_M", "BF16") */ export interface PiModelEntry { id: string; reasoning?: boolean; toolSupport?: string; modelFamily?: string; parameterSize?: string; quantizationLevel?: string; contextLength?: number; estimatedSize?: { gpu: number; cpu: number }; [key: string]: unknown; } // ============================================================================ // In-Memory Cache (TTL-based) // ============================================================================ let _modelsJsonCache: { data: PiModelsJson; ts: number } | null = null; let _ollamaBaseUrlCache: { data: string; ts: number } | null = null; const CACHE_TTL_MS = 2000; // 2-second TTL // ============================================================================ // Ollama Base URL Resolution // ============================================================================ /** * Resolve the Ollama base URL using the three-tier priority chain. * * This function determines the correct Ollama API endpoint by checking * multiple sources in order of precedence: * * 1. **models.json** - Checks `providers.ollama.baseUrl` in Pi's config * 2. **OLLAMA_HOST** - Falls back to the environment variable * 3. **localhost** - Uses the default `http://localhost:11434` * * The function automatically strips the `/v1` suffix from URLs since * different endpoints need different path handling. * * @returns The resolved Ollama base URL without trailing slash * * @example * ```typescript * // With models.json configured * getOllamaBaseUrl(); * // Returns: "https://abc123.trycloudflare.com" * * // With OLLAMA_HOST env var * process.env.OLLAMA_HOST = "192.168.1.100:11434"; * getOllamaBaseUrl(); * // Returns: "http://192.168.1.100:11434" * * // Default fallback * getOllamaBaseUrl(); * // Returns: "http://localhost:11434" * ``` */ export function getOllamaBaseUrl(): string { const now = Date.now(); if (_ollamaBaseUrlCache && now - _ollamaBaseUrlCache.ts < CACHE_TTL_MS) return _ollamaBaseUrlCache.data; try { const file = modelsJsonPath(); if (fs.existsSync(file)) { const raw = fs.readFileSync(file, "utf-8"); const config = JSON.parse(raw) as PiModelsJson; const baseUrl = config?.providers?.["ollama"]?.baseUrl; if (baseUrl) { // baseUrl is like "https://host/v1" or "http://localhost:11434/v1" — strip /v1 const result = baseUrl.replace(/\/v1\/?$/, ""); _ollamaBaseUrlCache = { data: result, ts: now }; return result; } } } catch (err) { debugLog("ollama", "failed to parse models.json for base URL", err); } if (process.env.OLLAMA_HOST) { const result = `http://${process.env.OLLAMA_HOST.replace(/^https?:\/\//, "")}`; _ollamaBaseUrlCache = { data: result, ts: now }; return result; } const fallback = "http://localhost:11434"; _ollamaBaseUrlCache = { data: fallback, ts: now }; return fallback; } // ============================================================================ // Models.json I/O // ============================================================================ /** * Read and parse Pi's models.json configuration file. * * Safely reads the configuration file, returning an empty structure * if the file doesn't exist or contains invalid JSON. * * @returns The parsed models.json configuration, or an empty structure * * @example * ```typescript * const config = readModelsJson(); * const ollamaModels = config.providers["ollama"]?.models || []; * console.log(`Found ${ollamaModels.length} Ollama models`); * ``` */ export function readModelsJson(): PiModelsJson { const now = Date.now(); if (_modelsJsonCache && now - _modelsJsonCache.ts < CACHE_TTL_MS) return _modelsJsonCache.data; try { const file = modelsJsonPath(); if (fs.existsSync(file)) { const raw = fs.readFileSync(file, "utf-8"); const data = JSON.parse(raw) as PiModelsJson; _modelsJsonCache = { data, ts: now }; return data; } } catch (err) { debugLog("ollama", "failed to read/parse models.json", err); } const empty = { providers: {} }; _modelsJsonCache = { data: empty, ts: now }; return empty; } // ============================================================================ // Ollama API Helpers // ============================================================================ /** * Retry configuration for Ollama API calls. */ export interface RetryOptions { /** Maximum number of retry attempts (default: 2) */ maxRetries?: number; /** Base delay in ms for exponential backoff (default: 1000) */ baseDelayMs?: number; /** Maximum delay in ms (default: 10000) */ maxDelayMs?: number; /** Whether to retry on timeout (default: true) */ retryOnTimeout?: boolean; /** Whether to retry on connection errors (default: true) */ retryOnConnectionError?: boolean; } const DEFAULT_RETRY_OPTIONS: Required = { maxRetries: 2, baseDelayMs: 1000, maxDelayMs: 10000, retryOnTimeout: true, retryOnConnectionError: true, }; /** * Calculate exponential backoff delay with jitter. */ function backoffDelay(attempt: number, baseDelayMs: number, maxDelayMs: number): number { const delay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs); // Add jitter (±25%) to avoid thundering herd const jitter = delay * 0.25 * (Math.random() * 2 - 1); return Math.max(0, Math.round(delay + jitter)); } /** * Error message patterns that indicate transient (retryable) failures. */ const RETRYABLE_ERROR_PATTERNS = [ "ECONNREFUSED", "ECONNRESET", "ENOTFOUND", "ETIMEDOUT", "fetch failed", "network error", "socket hang up", "Empty response", ]; /** * Determine if an error is retryable based on its message. */ function isRetryableError(error: unknown, opts: Required): boolean { if (error instanceof Error) { if (error.name === "AbortError" && opts.retryOnTimeout) return true; const msg = error.message; if (opts.retryOnConnectionError && RETRYABLE_ERROR_PATTERNS.some(p => msg.includes(p))) { return true; } } return false; } /** * Execute a fetch request with exponential backoff retry logic. * * Wraps any async function with automatic retry on transient failures. * Uses exponential backoff with jitter to avoid thundering herd. * * @param fn - The async function to execute (typically a fetch call) * @param options - Retry configuration * @returns The result of the function * @throws The last error if all retries are exhausted * * @example * ```typescript * const models = await withRetry( * () => fetchOllamaModels(baseUrl), * { maxRetries: 2, baseDelayMs: 1000 } * ); * ``` */ export async function withRetry( fn: () => Promise, options?: RetryOptions, ): Promise { const opts = { ...DEFAULT_RETRY_OPTIONS, ...options }; let lastError: unknown; for (let attempt = 0; attempt <= opts.maxRetries; attempt++) { try { return await fn(); } catch (error) { lastError = error; if (attempt < opts.maxRetries && isRetryableError(error, opts)) { const delay = backoffDelay(attempt, opts.baseDelayMs, opts.maxDelayMs); debugLog("ollama", `Retry ${attempt + 1}/${opts.maxRetries} after ${delay}ms: ${error instanceof Error ? error.message : String(error)}`); await new Promise(r => setTimeout(r, delay)); continue; } throw error; } } throw lastError; } /** * Fetch the list of available models from an Ollama instance. * * Queries the `/api/tags` endpoint to get all pulled models with their * metadata (size, quantization, family, etc.). * * @param baseUrl - The Ollama base URL (without /v1 suffix) * @returns Array of model objects from Ollama * @throws Error if the request fails or Ollama returns an error status * * @example * ```typescript * const models = await fetchOllamaModels("http://localhost:11434"); * for (const model of models) { * console.log(`${model.name}: ${model.details.parameter_size}`); * } * ``` */ export async function fetchOllamaModels(baseUrl: string): Promise { return withRetry(async () => { const res = await fetch(`${baseUrl}/api/tags`, { signal: AbortSignal.timeout(5000), }); if (!res.ok) throw new Error(`Ollama returned ${res.status}`); const data = (await res.json()) as { models?: OllamaModel[] }; return data.models ?? []; }); } /** * Check if an Ollama model name suggests reasoning capability. * * Examines the model name for patterns that indicate support for * extended thinking/reasoning tokens (e.g., deepseek-r1, qwq, o1, o3). * * @param name - The model name to check * @returns `true` if the model name suggests reasoning capability * * @example * ```typescript * isReasoningModel("deepseek-r1:1.5b"); // true * isReasoningModel("qwq:32b"); // true * isReasoningModel("qwen3:0.6b"); // false (qwen3 has thinking but not in name) * isReasoningModel("llama3.2:1b"); // false * ``` */ export function isReasoningModel(name: string): boolean { const lower = name.toLowerCase(); return ( lower.includes("deepseek-r1") || lower.includes("qwq") || /\bo1\b/.test(lower) || /\bo3\b/.test(lower) || lower.includes("qwen3") || lower.includes("reasoning") || lower.includes("thinker") || lower.includes("thinking") ); } // ============================================================================ // Built-in Provider Registry // ============================================================================ /** * Known built-in providers that Pi supports out of the box. * * Pi has two provider layers: * 1. Built-in providers (openrouter, anthropic, google, etc.) configured by Pi internally * 2. User-defined providers in models.json (e.g. ollama) * * The ExtensionAPI does not expose a getProviderConfig() method, so we maintain a * lookup table for known built-in providers. Used by diag.ts, simplebench.ts, and * api.ts for provider detection and display. * * Each entry maps provider name to its API mode, base URL, and the env var * that Pi reads for the API key. */ export const BUILTIN_PROVIDERS: Record = { openrouter: { api: "openai-completions", baseUrl: "https://openrouter.ai/api/v1", envKey: "OPENROUTER_API_KEY" }, anthropic: { api: "anthropic-messages", baseUrl: "https://api.anthropic.com/v1", envKey: "ANTHROPIC_API_KEY" }, google: { api: "gemini", baseUrl: "https://generativelanguage.googleapis.com", envKey: "GOOGLE_API_KEY" }, openai: { api: "openai-completions", baseUrl: "https://api.openai.com/v1", envKey: "OPENAI_API_KEY" }, groq: { api: "openai-completions", baseUrl: "https://api.groq.com/v1", envKey: "GROQ_API_KEY" }, deepseek: { api: "openai-completions", baseUrl: "https://api.deepseek.com/v1", envKey: "DEEPSEEK_API_KEY" }, mistral: { api: "openai-completions", baseUrl: "https://api.mistral.ai/v1", envKey: "MISTRAL_API_KEY" }, xai: { api: "openai-completions", baseUrl: "https://api.x.ai/v1", envKey: "XAI_API_KEY" }, together: { api: "openai-completions", baseUrl: "https://api.together.xyz/v1", envKey: "TOGETHER_API_KEY" }, fireworks: { api: "openai-completions", baseUrl: "https://api.fireworks.ai/inference/v1", envKey: "FIREWORKS_API_KEY" }, cohere: { api: "cohere-chat", baseUrl: "https://api.cohere.com/v1", envKey: "COHERE_API_KEY" }, zai: { api: "openai-completions", baseUrl: "https://open.bigmodel.cn/api/paas/v4", envKey: "ZAI_API_KEY" }, }; // ============================================================================ // Model Family Detection // ============================================================================ /** * Detect the model family from a model name. * * Ported from AgentNova's `core/model_family_config.py` `detect_family()`. * Uses substring matching against known model name patterns to determine * the family for template/prompt selection purposes. * * The order of checks matters: more specific patterns (e.g., "qwen3.5") * are checked before general ones (e.g., "qwen"). * * @param modelName - The model name to analyze * @returns The detected family identifier, or "unknown" if no match * * @example * ```typescript * detectModelFamily("qwen3.5:0.8b"); // "qwen35" * detectModelFamily("qwen3:0.6b"); // "qwen3" * detectModelFamily("llama3.2:1b"); // "llama" * detectModelFamily("granite4:350m"); // "granite" * detectModelFamily("glm-4:flash"); // "glm" * detectModelFamily("unknown-model"); // "unknown" * ``` */ export function detectModelFamily(modelName: string): string { const name = modelName.toLowerCase(); // Order matters: longer/more-specific prefixes first const families: [string, string][] = [ ["qwen3.5", "qwen35"], ["qwen3", "qwen3"], ["qwen2.5", "qwen2"], ["qwen2", "qwen2"], ["qwen", "qwen2"], ["llama3.3", "llama"], ["llama3.2", "llama"], ["llama3.1", "llama"], ["llama3", "llama"], ["llama", "llama"], ["gemma3", "gemma3"], ["gemma2", "gemma2"], ["gemma", "gemma2"], ["granite", "granite"], ["dolphin", "dolphin"], ["glm-4", "glm"], ["glm", "glm"], ["deepseek-r1", "deepseek-r1"], ["deepseek", "deepseek"], ["mistral", "qwen2"], ["phi", "llama"], ["tinyllama", "llama"], ["codestral", "qwen2"], ]; for (const [prefix, family] of families) { if (name.includes(prefix)) return family; } return "unknown"; } // ============================================================================ // Provider Detection // ============================================================================ /** * Kind of provider: local Ollama, built-in cloud, or unknown. */ export type ProviderKind = "ollama" | "builtin" | "bedrock" | "unknown"; /** * Information about the active provider, detected from Pi's context and models.json. * * @property kind - The provider kind (ollama, builtin, or unknown) * @property name - Provider name (e.g. "ollama", "openrouter", "anthropic") * @property apiMode - API mode (e.g. "openai-completions", "ollama") * @property baseUrl - API base URL * @property envKey - Environment variable for API key * @property apiKey - Actual API key value (if available) */ export interface ProviderInfo { kind: ProviderKind; name: string; apiMode?: string; baseUrl?: string; region?: string; envKey?: string; apiKey?: string; } /** * Expand environment variable references in a string. * Supports $VAR_NAME and ${VAR_NAME} syntax. * Returns the original string if the env var is not set. */ function expandEnvVars(value: string): string { if (!value || !value.includes("$")) return value; // Match $VAR_NAME or ${VAR_NAME} return value.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (_, varName) => { const envValue = process.env[varName]; return envValue !== undefined ? envValue : `$${varName}`; }); } /** * Look up a provider's stored credential in ~/.pi/agent/auth.json. * Shape: { "openrouter": { "type": "api_key", "key": "sk-or-..." } } * Returns "" when absent. */ function readAuthJsonKey(providerName: string): string { try { const p = authJsonPath(); if (!fs.existsSync(p)) return ""; const entry = JSON.parse(fs.readFileSync(p, "utf-8"))[providerName]; return typeof entry?.key === "string" ? entry.key : ""; } catch { return ""; } } /** * Detect whether the current model is on an Ollama provider, a built-in * provider, or an unknown provider. Uses 3-tier logic: * 1. User-defined provider in models.json * 2. Known built-in provider * 3. Unknown provider * * @param ctx - Pi's extension context (from session_start event) * @param modelsJson - Optional pre-loaded models.json data (avoids redundant disk read) * @returns Provider information including kind, name, API details, and key */ export function detectProvider(ctx: PiExtensionContext, modelsJson?: PiModelsJson): ProviderInfo { const model = ctx.model; if (!model) return { kind: "unknown", name: "none" }; const providerName = model.provider || ""; if (!providerName) return { kind: "unknown", name: "none" }; // Tier 1: Check if provider is defined in models.json const effectiveModelsJson = modelsJson ?? readModelsJson(); // Bedrock: AWS SigV4 auth, region taken from the model's baseUrl if (providerName === "amazon-bedrock") { const baseUrl = (model as any).baseUrl || ""; const region = baseUrl.match(/bedrock-runtime\.([a-z0-9-]+)\.amazonaws/)?.[1] || process.env.AWS_REGION || "us-east-1"; return { kind: "bedrock", name: providerName, baseUrl, region, apiKey: "" }; } // Only treat as a user-defined provider if it carries connection config; // entries like openrouter exist in models.json purely for modelOverrides. const cfg = ((effectiveModelsJson.providers || {}) as Record)[providerName]; const hasConnectionConfig = !!(cfg?.baseUrl || cfg?.api || cfg?.apiKey); const userProviderCfg = hasConnectionConfig ? cfg : undefined; if (userProviderCfg) { const baseUrl = userProviderCfg.baseUrl || ""; const apiMode = userProviderCfg.api || ""; const apiKey = expandEnvVars(userProviderCfg.apiKey || ""); // Check if it's an Ollama provider (user-defined Ollama in models.json) const isOllama = /ollama/i.test(providerName) || /localhost:\d+/.test(baseUrl) || /127\.0\.0\.1:\d+/.test(baseUrl) || /0\.0\.0\.0:\d+/.test(baseUrl) || /\/api\/chat/.test(baseUrl) || apiMode === "ollama"; if (isOllama) { return { kind: "ollama", name: providerName, apiMode: "ollama", baseUrl, apiKey }; } // User-defined non-Ollama provider (treat as Ollama-style if it has /api/chat) if (/\/api\/chat/.test(baseUrl)) { return { kind: "ollama", name: providerName, apiMode: "ollama", baseUrl, apiKey }; } // User-defined provider with custom config — treat as built-in style return { kind: "builtin", name: providerName, apiMode: apiMode || "openai-completions", baseUrl, apiKey, }; } // Tier 2: Check built-in providers const builtin = BUILTIN_PROVIDERS[providerName]; if (builtin) { // Key priority: models.json entry → env var → auth.json (where Pi stores login credentials) const userProviderCfg = (effectiveModelsJson.providers || {})[providerName]; const apiKeyFromConfig = expandEnvVars(userProviderCfg?.apiKey || ""); const apiKeyFromEnv = process.env[builtin.envKey] || ""; const apiKey = apiKeyFromConfig || apiKeyFromEnv || readAuthJsonKey(providerName); return { kind: "builtin", name: providerName, apiMode: builtin.api, baseUrl: builtin.baseUrl, envKey: builtin.envKey, apiKey, }; } // Tier 3: Unknown provider return { kind: "unknown", name: providerName }; }