/** * pi-loom: Shared Model Utilities * * resolveModel, resolveMcpAuth, callLLMForJSON, and resolveModelForLLM. * Used by Dream Engine (dream.ts) and Extraction Engine (extract.ts). */ import type { AssistantMessage, Model } from "@earendil-works/pi-ai"; import { complete } from "@earendil-works/pi-ai"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; /** Resolve a model string ("provider/id" or just "id") via ctx.modelRegistry. */ export function resolveModel( ctx: ExtensionContext | null, modelStr: string, defaultProvider?: string, ): Model | undefined { if (!ctx) return undefined; const [provider, ...idParts] = modelStr.split("/"); if (idParts.length > 0) { return ctx.modelRegistry.find(provider, idParts.join("/")); } const pid = defaultProvider ?? provider; return ctx.modelRegistry.find(pid, provider) ?? undefined; } /** Auth for MCP standalone: read API keys from environment variables. */ export function resolveMcpAuth(model: Model): { ok: boolean; apiKey?: string; headers?: Record; } { const envKey = `${model.provider.toUpperCase()}_API_KEY`; const key = process.env[envKey] || process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY; if (!key && typeof process.env.DEEPSEEK_API_KEY === "string") { return { ok: true, apiKey: process.env.DEEPSEEK_API_KEY, headers: {} }; } return { ok: !!key, apiKey: key ?? undefined, headers: {} }; } export interface LLMCallConfig { apiKey: string; headers?: Record; maxTokens?: number; } /** * Call an LLM, parse a JSON array from its response. * Shared by dream.ts (insight generation) and extract.ts (fact extraction). * * Returns parsed JSON array, or null if parsing fails. * Handles the common pattern: * complete(model, ctx, opts) → filter text → match /\[[\s\S]*\]/ → JSON.parse */ export async function callLLMForJSON(model: Model, prompt: string, auth: LLMCallConfig): Promise { try { const response: AssistantMessage = await complete( model, { messages: [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }] }, { apiKey: auth.apiKey, headers: auth.headers, maxTokens: auth.maxTokens ?? 2048 }, ); const text = response.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map((c) => c.text) .join("\n"); const jsonMatch = text.match(/\[[\s\S]*\]/); if (!jsonMatch) return null; return JSON.parse(jsonMatch[0]) as T[]; } catch { return null; } } /** * Resolve which model to use for LLM calls, with a priority chain: * 1. Explicit model string from config/params (e.g. "deepseek/deepseek-v3.1") * 2. PI_DREAM_MODEL / PI_FACT_MODEL env var * 3. ctx.model (the currently active model in the session) * 4. Hard-coded default (provider + modelId) */ export function resolveModelForLLM( ctx: ExtensionContext | null, config: { modelProvider?: string; modelId?: string }, envVar: string, defaultModel: string, ): Model | undefined { // 1. Explicit config if (config.modelId) { return resolveModel(ctx, config.modelId, config.modelProvider); } // 2. Env var override if (typeof process !== "undefined" && process.env?.[envVar]) { return resolveModel(ctx, process.env[envVar]); } // 3. Active session model if (ctx?.model) { return ctx.modelRegistry.find(ctx.model.provider, ctx.model.id); } // 4. Default return resolveModel(ctx, defaultModel); } /** * Check whether any LLM is available (API key + model or session context). * Used to decide whether to run LLM-dependent operations or use local fallbacks. */ export function hasLLM(ctx: ExtensionContext | null): boolean { // Check env vars for API keys const keys = [ process.env.DEEPSEEK_API_KEY, process.env.OPENAI_API_KEY, process.env.ANTHROPIC_API_KEY, process.env.GOOGLE_API_KEY, ]; if (keys.some((k) => typeof k === "string" && k.length > 10)) return true; // Check if we have a session context with a model if (ctx?.model) return true; return false; }