/** * config-core.ts — pure backend for models.json visual config. * * No TUI, no IO side effects except load/save (which are explicit). * Safe to import from both the HTTP server and tests. */ import { existsSync, copyFileSync, readFileSync, writeFileSync, renameSync, readdirSync, rmSync } from "node:fs"; import { join, dirname, basename } from "node:path"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; // ============================================================ // Constants — field metadata & enums // ============================================================ const API_TYPES = [ "openai-completions", "openai-responses", "anthropic-messages", "google-generative-ai", ] as const; type ApiType = (typeof API_TYPES)[number]; const THINKING_FORMATS = [ "reasoning_effort", "openrouter", "deepseek", "together", "zai", "qwen", "qwen-chat-template", ] as const; const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const; type ThinkingLevel = (typeof THINKING_LEVELS)[number]; // Provider-level fields that are always shown (required core) const PROVIDER_REQUIRED = ["name", "api", "baseUrl", "apiKey", "models"] as const; // Provider-level optional fields (add-able, not always present) const PROVIDER_OPTIONAL = ["headers", "compat", "modelOverrides", "authHeader"] as const; // Model-level required const MODEL_REQUIRED = ["id"] as const; // Model-level optional (add-able) const MODEL_OPTIONAL = [ "name", "api", "reasoning", "input", "contextWindow", "maxTokens", "cost", "thinkingLevelMap", "compat", ] as const; // Compat fields — split into "used in current config" vs "available but unused" const COMPAT_USED_BOOL = ["supportsDeveloperRole", "supportsReasoningEffort"] as const; const COMPAT_OPTIONAL_BOOL = [ "supportsUsageInStreaming", "supportsEagerToolInputStreaming", "supportsLongCacheRetention", "forceAdaptiveThinking", "allowEmptySignature", ] as const; const COMPAT_ENUM: Record = { thinkingFormat: THINKING_FORMATS, maxTokensField: ["max_completion_tokens", "max_tokens"], cacheControlFormat: ["anthropic"], }; // ============================================================ // Types (loose — models.json is user-controlled) // ============================================================ type Json = string | number | boolean | null | undefined | Json[] | { [k: string]: Json }; interface ModelConfig { id: string; name?: string; api?: ApiType; reasoning?: boolean; input?: string[]; contextWindow?: number; maxTokens?: number; cost?: { input: number; output: number; cacheRead: number; cacheWrite: number }; thinkingLevelMap?: Record; compat?: Record; [k: string]: Json; } interface ProviderConfig { baseUrl?: string; api?: ApiType; apiKey?: string; headers?: Record; compat?: Record; models?: ModelConfig[]; modelOverrides?: Record>; authHeader?: boolean; [k: string]: Json; } interface ModelsConfig { providers: Record; } // Field kind, for frontend dynamic rendering type FieldKind = "text" | "number" | "boolean" | "enum" | "object" | "array"; interface FieldMeta { key: string; label: string; kind: FieldKind; required: boolean; enumValues?: readonly string[]; // for object fields: sub-field schema children?: FieldMeta[]; } // ============================================================ // File IO // ============================================================ function getConfigPath(): string { return join(getAgentDir(), "models.json"); } // ============================================================ // 原子写 + bak 回退 (多文件共用: models.json / roundrobin config.json / presets) // ============================================================ /** 原子写: 先写 tmp 再 rename — 写一半崩溃/断电只丢 tmp, 主文件不会截断。 * (rename 在 POSIX 同文件系统上是原子的; 与 pruneHealthFile 同模式) */ export function atomicWriteFileSync(path: string, content: string): void { const tmp = `${path}.tmp.${process.pid}.${Date.now()}`; writeFileSync(tmp, content, "utf-8"); renameSync(tmp, path); } /** 列出 path 的 .bak.* 备份, 按时间戳降序(最新在前)。 */ function listBaks(path: string): string[] { const dir = dirname(path); const base = basename(path); let entries: string[]; try { entries = readdirSync(dir); } catch { return []; } return entries .filter((e) => e.startsWith(`${base}.bak.`)) .map((e) => join(dir, e)) .sort() .reverse(); } /** 保留最近 keep 份 .bak.*, 删更旧的。保存路径上调用, 防无限累积。 */ export function pruneBaks(path: string, keep = 10): void { const baks = listBaks(path); for (const b of baks.slice(keep)) { try { rmSync(b); } catch { /* ignore */ } } } /** 读 JSON: 主文件 parse/校验失败时自动回退最新 .bak.* — 否则写一半崩溃后 * pi 启动即抛错需手动找 bak。返回实际使用的文件路径供告警。 */ export function loadJsonWithBakFallback( path: string, validate: (raw: string) => T, ): { value: T; source: string } { const candidates = [path, ...listBaks(path)]; let lastErr: unknown; for (const p of candidates) { if (!existsSync(p)) continue; let raw: string; try { raw = readFileSync(p, "utf-8"); } catch (e) { lastErr = e; continue; } try { return { value: validate(raw), source: p }; } catch (e) { lastErr = e; } } throw lastErr instanceof Error ? lastErr : new Error(`load failed: ${path}`); } function loadConfig(): ModelsConfig { const path = getConfigPath(); if (!existsSync(path)) return { providers: {} }; const { value: parsed, source } = loadJsonWithBakFallback(path, (raw) => { const v = JSON.parse(raw) as ModelsConfig; if (!v || typeof v !== "object" || !v.providers) { throw new Error("models.json: missing top-level `providers` object"); } return v; }); if (source !== path) { // 主文件损坏, 已从最新备份恢复 — 告警但不中断 console.error(`[pi-provider-manager] models.json 损坏, 已回退备份 ${basename(source)}`); } return parsed; } /** Serialize config to pretty JSON (2-space indent, trailing newline). */ function serializeConfig(config: ModelsConfig): string { return JSON.stringify(config, null, 2) + "\n"; } /** Write config to disk. Backs up first(原子写: tmp+rename). Returns backup path. */ function saveConfig(config: ModelsConfig): string { const path = getConfigPath(); // validate 先于备份: 校验失败不产生孤儿 bak (M2) validateConfig(config); const ts = new Date().toISOString().replace(/[:.]/g, "-"); const backup = `${path}.bak.${ts}`; if (existsSync(path)) copyFileSync(path, backup); atomicWriteFileSync(path, serializeConfig(config)); pruneBaks(path, 10); // bak 保留最近 10 份, 防无限累积 (M2) return backup; } /** Structural validation; throws on hard errors. */ function validateConfig(config: ModelsConfig): void { if (!config || typeof config !== "object") throw new Error("config is not an object"); if (!config.providers || typeof config.providers !== "object") { throw new Error("missing `providers` object"); } for (const [pname, p] of Object.entries(config.providers)) { if (!p || typeof p !== "object") throw new Error(`provider ${pname}: not an object`); if (p.api && !API_TYPES.includes(p.api as ApiType)) { throw new Error(`provider ${pname}: unknown api "${p.api}"`); } if (p.models) { if (!Array.isArray(p.models)) throw new Error(`provider ${pname}: models is not an array`); p.models.forEach((m, i) => { if (!m || typeof m !== "object") throw new Error(`provider ${pname}: model[${i}] not an object`); if (!m.id || typeof m.id !== "string") { throw new Error(`provider ${pname}: model[${i}] missing string id`); } if (m.api && !API_TYPES.includes(m.api as ApiType)) { throw new Error(`provider ${pname}: model ${m.id} unknown api "${m.api}"`); } }); } } } // ============================================================ // Pure helpers (exported) // ============================================================ /** Build the `/models` endpoint URL from a baseUrl. Appends /v1 if missing. */ export function buildModelsUrl(baseUrl: string): string { const b = baseUrl.replace(/\/+$/, ""); if (/\/v\d+$/i.test(b)) return `${b}/models`; if (/\/models$/i.test(b)) return b; return `${b}/v1/models`; } /** Compare configured model ids vs endpoint ids. Three categories. */ export function diffModels(configured: string[], available: string[]) { const cset = new Set(configured); const aset = new Set(available); return { configured: configured.filter((id) => aset.has(id)), missing: available.filter((id) => !cset.has(id)), stale: configured.filter((id) => !aset.has(id)), }; } /** Default model template for newly-added models. * 默认值取自实际 models.json 统计最高频: maxTokens=64000(95/210), * contextWindow=1000000(85/210)。reasoning=true(210/210), input=["text"](118/210)。 */ export function defaultModel(id: string): ModelConfig { return { id, reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1000000, maxTokens: 64000, thinkingLevelMap: { minimal: null, low: "low", medium: "medium", high: "high", xhigh: "max", }, }; } /** Default provider template for newly-added providers. */ export function defaultProvider(): ProviderConfig { return { api: "openai-completions", models: [] }; } /** Detect which top-level keys a provider actually has. */ export function detectProviderFields(p: ProviderConfig): string[] { return Object.keys(p); } /** Mask an apiKey for display. */ export function maskKey(key: string | undefined): string { if (!key) return "(未设置)"; if (key.startsWith("$") || key.startsWith("!")) return key; if (key.length <= 8) return "***"; return `${key.slice(0, 4)}***${key.slice(-4)}`; } // ============================================================ // Field metadata for frontend dynamic rendering // ============================================================ export interface MetaResponse { apiTypes: readonly string[]; thinkingFormats: readonly string[]; thinkingLevels: readonly string[]; providerRequired: readonly string[]; providerOptional: readonly string[]; modelRequired: readonly string[]; modelOptional: readonly string[]; compatUsedBool: readonly string[]; compatOptionalBool: readonly string[]; compatEnum: Record; inputOptions: readonly string[][]; defaultModelTemplate: ModelConfig; defaultProviderTemplate: ProviderConfig; } /** Pack all field metadata for the frontend. */ export function getMeta(): MetaResponse { return { apiTypes: API_TYPES, thinkingFormats: THINKING_FORMATS, thinkingLevels: THINKING_LEVELS, providerRequired: PROVIDER_REQUIRED, providerOptional: PROVIDER_OPTIONAL, modelRequired: MODEL_REQUIRED, modelOptional: MODEL_OPTIONAL, compatUsedBool: COMPAT_USED_BOOL, compatOptionalBool: COMPAT_OPTIONAL_BOOL, compatEnum: COMPAT_ENUM, inputOptions: [["text"], ["text", "image"]], defaultModelTemplate: defaultModel("__template__"), defaultProviderTemplate: defaultProvider(), }; } // ============================================================ // Fetch available models (metadata only — NO liveness test) // ============================================================ export interface FetchParams { baseUrl: string; apiKey?: string; headers?: Record; configuredIds?: string[]; } export interface FetchResult { available: string[]; diff: { configured: string[]; missing: string[]; stale: string[] }; } /** Fetch `/models` from upstream. Metadata only, never sends chat requests. */ export async function fetchAvailableModels(params: FetchParams): Promise { const base = params.baseUrl; if (!base) throw new Error("provider 未配置 baseUrl"); const url = buildModelsUrl(base); const headers: Record = { ...(params.headers ?? {}) }; const key = params.apiKey; if (key && !key.startsWith("!")) { // env interpolation: $VAR / ${VAR}; literal keys used directly const resolved = key.startsWith("$") ? process.env[key.replace(/^\${?|}?$/g, "")] ?? key : key; headers["Authorization"] = `Bearer ${resolved}`; } const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 10000); try { const res = await fetch(url, { headers, signal: ctrl.signal }); if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText} @ ${url}`); const body = (await res.json()) as { data?: { id: string }[]; models?: { id: string }[] }; const list = body.data ?? body.models ?? []; const available = list.map((m) => m.id).filter((id): id is string => typeof id === "string"); const diff = diffModels(params.configuredIds ?? [], available); return { available, diff }; } finally { clearTimeout(timer); } } // ============================================================ // Re-exports of IO for the server (server wraps these in routes) // ============================================================ export { loadConfig, saveConfig, serializeConfig, validateConfig, getConfigPath, }; export type { ModelsConfig, ProviderConfig, ModelConfig, ApiType, ThinkingLevel };