import * as CodingAgent from "@mariozechner/pi-coding-agent"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { ModelsConfig } from "./types.ts"; type YamlModule = { parse(text: string): any; stringify(value: any, options?: { lineWidth?: number }): string; }; // `yaml` is only needed to read/write OMP's models.yml. A static // `import ... from "yaml"` would crash the whole extension at load time when // the package is absent, so load it lazily. The previous build used // `createRequire(import.meta.url)("yaml")` and let Node walk `node_modules`, // but the OMP compiled-binary loader serves this file from an `onLoad` hook // whose module graph never reaches the plugin's sibling `node_modules`, so // that path throws `Cannot find module 'yaml'` even when the package is // installed. Walk `node_modules` ourselves from the extension's own // location, then require the resolved entry through a require anchored at // the package itself. function resolveSiblingPackage(name: string): string | null { let dir = dirname(fileURLToPath(import.meta.url)); while (true) { const candidate = join(dir, "node_modules", name); if (existsSync(join(candidate, "package.json"))) return candidate; const parent = dirname(dir); if (parent === dir) return null; dir = parent; } } let yamlModule: YamlModule | undefined; const yamlPkgRoot = resolveSiblingPackage("yaml"); if (yamlPkgRoot) { try { // Read the package's `main` (or the `node` condition under `exports["."]`) // and require the resolved file directly. `createRequire(pkgDir)(pkgDir)` // would let the host do `require()` resolution, which the OMP // compiled-binary loader rejects with "Cannot find module"; pinning the // absolute file path keeps the require anchored at a real source on disk. const manifest = JSON.parse(readFileSync(join(yamlPkgRoot, "package.json"), "utf8")) as { main?: string; exports?: { "."?: { node?: string; default?: string } }; }; const mainRel = manifest.exports?.["."]?.node ?? manifest.main ?? "./index.js"; const mainAbs = join(yamlPkgRoot, mainRel); yamlModule = createRequire(mainAbs)(mainAbs) as YamlModule; } catch { yamlModule = undefined; } } const AGENT_DIR = CodingAgent.getAgentDir(); const IS_OMP = "logger" in CodingAgent || /(^|[\\/])\.?omp([\\/]|$)/i.test(AGENT_DIR); // OMP prefers YAML and still accepts legacy JSON; normal Pi uses JSON only. export const MODELS_JSON_PATH = (IS_OMP ? ["models.yml", "models.yaml", "models.json"] : ["models.json"]) .map((name) => join(AGENT_DIR, name)) .find(existsSync) ?? join(AGENT_DIR, IS_OMP ? "models.yml" : "models.json"); const IS_YAML_CONFIG = /\.ya?ml$/i.test(MODELS_JSON_PATH); // Built-in provider ids, copied from pi-ai's getBuiltinProviders() (the // authoritative source — regenerate against the installed pi when updating). // A custom provider reusing one of these ids COLLIDES with the built-in: pi // merges the built-in catalog into the provider (built-in models can never be // removed via models.json), and auth.json is keyed by id, so /login & /logout // for the built-in also read/wipe the custom provider's key. export const BUILTIN_PROVIDER_IDS = new Set([ "amazon-bedrock", "ant-ling", "anthropic", "azure-openai-responses", "baseten", "cerebras", "cloudflare-ai-gateway", "cloudflare-workers-ai", "deepseek", "fireworks", "github-copilot", "google", "google-vertex", "groq", "huggingface", "kimi-coding", "minimax", "minimax-cn", "mistral", "moonshotai", "moonshotai-cn", "nvidia", "openai", "openai-codex", "opencode", "opencode-go", "openrouter", "qwen-token-plan", "qwen-token-plan-cn", "qwen-token-plan-individual", "together", "vercel-ai-gateway", "xai", "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp", "zai", "zai-coding-cn", ]); // Warning text shown when a custom provider (re)uses a built-in id. export const BUILTIN_COLLISION_WARNING = "This id matches a built-in provider. pi merges the built-in model catalog into it — built-in models stay visible even if you delete them here — and /login & /logout for this id share the same auth.json entry, so logging out wipes your key too. Consider a distinct id (e.g. suffix -relay) unless you deliberately want to extend the built-in."; function ensureConfigDir() { mkdirSync(dirname(MODELS_JSON_PATH), { recursive: true }); } // pi's official split: credentials live in auth.json (the file /login writes), // model declarations in models.json. pi resolves an auth.json entry by provider // id for ANY provider (built-in or custom), and its key field supports the same // "$ENV" / "!cmd" syntax as an inline apiKey — so a custom provider declared in // models.json without an apiKey picks its key up from auth.json automatically. // OMP's key handling is kept inline (models.yml) since its auth.json semantics // are not verified. export const AUTH_JSON_PATH = join(AGENT_DIR, "auth.json"); export const SPLIT_AUTH = !IS_OMP; type AuthFile = Record; export function loadAuthFile(): AuthFile { if (!existsSync(AUTH_JSON_PATH)) return {}; const raw = readFileSync(AUTH_JSON_PATH, "utf8").trim(); if (!raw) return {}; const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error(`${AUTH_JSON_PATH} must be an object`); } return parsed; } function saveAuthFile(auth: AuthFile) { mkdirSync(dirname(AUTH_JSON_PATH), { recursive: true }); writeFileSync(AUTH_JSON_PATH, `${JSON.stringify(auth, null, 2)}\n`, "utf8"); } // The stored key for a provider: auth.json wins (pi gives stored credentials // priority over everything else), with the legacy inline apiKey as fallback. export function storedApiKey(providerId: string, provider: any): string | undefined { if (SPLIT_AUTH) { try { const entry = loadAuthFile()[providerId]; if (entry && typeof entry === "object" && typeof entry.key === "string" && entry.key) { return entry.key; } } catch { // Unreadable auth.json — fall back to the inline key below. } } const inline = typeof provider?.apiKey === "string" ? provider.apiKey : ""; return inline || undefined; } // Write a provider's api_key entry into auth.json, preserving all other // entries (other providers, oauth logins). No-op where keys stay inline. export function saveProviderApiKey(providerId: string, serializedKey: string) { if (!SPLIT_AUTH) return; const auth = loadAuthFile(); auth[providerId] = { type: "api_key", key: serializedKey }; saveAuthFile(auth); } // Drop a provider's auth.json entry along with the provider. Only api_key // entries are touched — an oauth login under the same id is left alone. export function removeProviderApiKey(providerId: string) { if (!SPLIT_AUTH) return; let auth: AuthFile; try { auth = loadAuthFile(); } catch { return; } if (auth[providerId]?.type !== "api_key") return; delete auth[providerId]; saveAuthFile(auth); } // Carry a provider's auth.json entry over to its new id on rename. export function renameProviderApiKey(oldId: string, newId: string) { if (!SPLIT_AUTH) return; let auth: AuthFile; try { auth = loadAuthFile(); } catch { return; } if (!(oldId in auth)) return; if (!(newId in auth)) auth[newId] = auth[oldId]; delete auth[oldId]; saveAuthFile(auth); } // Move inline apiKeys into auth.json and strip them from the models config, so // the files end up split the way pi itself writes them. An existing auth.json // entry always wins over the inline value (pi resolves stored credentials // first anyway). auth.json is written BEFORE models.json so a failed write // never strands a key. function migrateInlineApiKeys(config: ModelsConfig) { if (!SPLIT_AUTH) return; const providers = config.providers ?? {}; const inline: Array<[string, string]> = []; for (const [id, provider] of Object.entries(providers)) { const key = (provider as any)?.apiKey; if (typeof key === "string" && key) inline.push([id, key]); } if (inline.length === 0) return; const auth = loadAuthFile(); for (const [id, key] of inline) { if (!(id in auth)) auth[id] = { type: "api_key", key }; } saveAuthFile(auth); for (const [id] of inline) { delete (providers[id] as any).apiKey; } } export function loadModelsConfig(): ModelsConfig { ensureConfigDir(); if (!existsSync(MODELS_JSON_PATH)) { return { providers: {} }; } const raw = readFileSync(MODELS_JSON_PATH, "utf8").trim(); if (!raw) return { providers: {} }; const parsed = (IS_YAML_CONFIG ? parseYamlConfig(raw) : JSON.parse(raw)) as ModelsConfig; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("models config must be an object"); } if (!parsed.providers || typeof parsed.providers !== "object") { parsed.providers = {}; } return parsed; } // Parse a models.yml. Prefers the yaml package; when it is missing (folder // copied into ~/.pi/agent/extensions/ without npm install) JSON is accepted, // because JSON is a valid YAML subset. Genuine YAML gets a clear error instead // of corrupting the file. function parseYamlConfig(raw: string): any { if (yamlModule) return yamlModule.parse(raw); try { return JSON.parse(raw); } catch { throw new Error( `${MODELS_JSON_PATH} is YAML, but the "yaml" package is not installed. ` + 'Run "npm install" in the better-custom folder, or reinstall the extension with "pi install", to edit YAML configs.', ); } } export function saveModelsConfig(config: ModelsConfig) { ensureConfigDir(); migrateInlineApiKeys(config); let content: string; if (IS_YAML_CONFIG) { content = yamlModule ? yamlModule.stringify(config, { lineWidth: 0 }) : // JSON is valid YAML — writing it keeps OMP/pi able to load the config // even when the yaml package is unavailable. JSON.stringify(config, null, 2); } else { content = JSON.stringify(config, null, 2); } writeFileSync(MODELS_JSON_PATH, `${content.trimEnd()}\n`, "utf8"); }