/** * rr-config.ts — roundrobin config IO for the models-config panel. * * Reads/writes ~/.pi/agent/roundrobin/config.json and its presets (files under * ~/.pi/agent/roundrobin/presets/, file name is the preset name). * Also lists models from models.json so the panel can offer candidates to add. * Pure IO + helpers, no HTTP. */ import { existsSync, copyFileSync, readFileSync, writeFileSync, readdirSync, rmSync, mkdirSync, } from "node:fs"; import { join } from "node:path"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { loadConfig as loadModelsConfig, atomicWriteFileSync, loadJsonWithBakFallback, pruneBaks } from "./config-core.js"; const RR_DIR = join(getAgentDir(), "roundrobin"); const RR_CONFIG_PATH = join(RR_DIR, "config.json"); const PRESETS_DIR = join(RR_DIR, "presets"); /** config.json 对应的 group 名(保留名,不可用作预设文件名)。 */ export const DEFAULT_GROUP_NAME = "default"; /** Ensure a directory exists (recursive, idempotent). Fixes first-save ENOENT when * ~/.pi/agent/roundrobin (or its presets subdir) has never been created. */ function ensureDir(dir: string): void { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); } export interface RrCandidate { provider: string; model: string; } export interface RrVirtualModel { id?: string; name?: string; reasoning?: boolean; input?: string[]; contextWindow?: number; maxTokens?: number; thinkingLevelMap?: Record; compat?: Record; } /** 测速排序配置:可选开启的可用性+速度测试,按 TTFT/延迟排序轮询候选。 * 开启后:首次请求某组时同步测速排序(懒触发);tryCandidates 整轮全炸时 * 触发重测(受 minIntervalMs 节流)。测速失败的候选进冷却排末尾,成功的 * 清冷却按 sortKey 升序排前,currentIndex 重置为 0。 */ export interface SpeedTestConfig { enabled?: boolean; /** 测速用的 prompt,与面板测试按钮同一内核(maxTokens=16 真实对话,非探活)。 */ prompt?: string; /** 排序键。默认 ttft。 */ sortKey?: "ttft" | "latency" | "hybrid" | "smart"; /** 单候选测速超时(ms)。默认 60000。 */ timeoutMs?: number; /** 并测并发数。默认 5。 */ concurrency?: number; /** 同组两次测速最小间隔(ms),节流防爆刷。默认 60000(1 分钟)。 */ minIntervalMs?: number; /** 单候选测速失败重试次数(不含首试)。默认 2(共 3 试);0=失败即判, 快速但无抖动容错。 */ retries?: number; } export interface RrConfig { virtualModel?: RrVirtualModel; candidates?: RrCandidate[]; log?: boolean; timeoutMs?: number; cooldownMs?: number; strategy?: "sticky" | "round-robin" | "primary"; /** 单候选失败后在冷却前原地重试的次数(不含首次尝试)。0=不重试(旧行为)。 * 未设置(undefined)时引擎默认为 2。重试间按指数退避(baseDelayMs=2000)。 */ maxRetriesPerCandidate?: number; /** 测速排序:可选的可用性+速度测试自动排序候选。详见 SpeedTestConfig。 */ speedTest?: SpeedTestConfig; } export function getRoundrobinDir(): string { return RR_DIR; } export function defaultRrConfig(): RrConfig { return { virtualModel: { id: "roundrobin", name: "Model Round Robin", reasoning: true, input: ["text", "image"], contextWindow: 200000, }, candidates: [], log: true, timeoutMs: 30000, cooldownMs: 60000, strategy: "sticky", maxRetriesPerCandidate: 2, speedTest: { enabled: false, sortKey: "ttft", prompt: "欧拉函数的意义?", timeoutMs: 60000, concurrency: 5, minIntervalMs: 60000, }, }; } export function loadRrConfig(): RrConfig { if (!existsSync(RR_CONFIG_PATH)) return defaultRrConfig(); const { value, source } = loadJsonWithBakFallback(RR_CONFIG_PATH, (raw) => normalizeConfig(JSON.parse(raw) as RrConfig)); if (source !== RR_CONFIG_PATH) { console.error(`[pi-provider-manager] roundrobin config.json 损坏, 已回退备份 ${source}`); } return value; } /** Save with backup(原子写: tmp+rename; validate 先于备份防孤儿 bak). Returns backup path. */ export function saveRrConfig(config: RrConfig): string { validateRrConfig(config); const ts = new Date().toISOString().replace(/[:.]/g, "-"); const backup = `${RR_CONFIG_PATH}.bak.${ts}`; if (existsSync(RR_CONFIG_PATH)) copyFileSync(RR_CONFIG_PATH, backup); ensureDir(RR_DIR); atomicWriteFileSync(RR_CONFIG_PATH, JSON.stringify(config, null, 2) + "\n"); pruneBaks(RR_CONFIG_PATH, 10); return backup; } export function validateRrConfig(config: RrConfig): void { if (!config || typeof config !== "object") throw new Error("config is not an object"); if (!Array.isArray(config.candidates)) throw new Error("candidates must be an array"); if (config.candidates.length === 0) throw new Error("candidates must not be empty"); if (config.speedTest && typeof config.speedTest === "object") { const st = config.speedTest; if (st.sortKey != null && st.sortKey !== "ttft" && st.sortKey !== "latency" && st.sortKey !== "hybrid" && st.sortKey !== "smart") throw new Error(`speedTest.sortKey must be "ttft", "latency", "hybrid", or "smart", got "${st.sortKey}"`); if (st.timeoutMs != null && (typeof st.timeoutMs !== "number" || st.timeoutMs < 1000)) throw new Error("speedTest.timeoutMs must be a number >= 1000"); if (st.concurrency != null && (typeof st.concurrency !== "number" || !Number.isInteger(st.concurrency) || st.concurrency < 1)) throw new Error("speedTest.concurrency must be a positive integer"); if (st.minIntervalMs != null && (typeof st.minIntervalMs !== "number" || st.minIntervalMs < 0)) throw new Error("speedTest.minIntervalMs must be a number >= 0"); } for (const c of config.candidates) { if (!c || !c.provider || !c.model) throw new Error("every candidate needs provider and model"); } } function presetPath(name: string): string { if (name === DEFAULT_GROUP_NAME) throw new Error(`"${name}" 是保留名(config.json 草稿),不能用作预设名`); if (!/^[a-zA-Z0-9_.-]+$/.test(name)) throw new Error("preset name may only contain letters, numbers, ., _ and -"); return join(PRESETS_DIR, name); } export function listPresets(): string[] { if (!existsSync(PRESETS_DIR)) return []; return readdirSync(PRESETS_DIR) .filter((name) => Boolean(name) && !name.startsWith("bak.")) .sort(); } export function readPreset(name: string): RrConfig { const path = presetPath(name); if (!existsSync(path)) throw new Error(`preset "${name}" not found`); return normalizeConfig(JSON.parse(readFileSync(path, "utf-8")) as RrConfig); } /** 归一化 speedTest 字段: 任何非法/缺失值回退到默认。引擎侧 groupConfigFromParsed 与 * normalizeConfig 共用, 保证手写 config 不完整时两侧默认值一致。 */ export function normalizeSpeedTest(raw: unknown): SpeedTestConfig { if (!raw || typeof raw !== "object") return { enabled: false, sortKey: "ttft", timeoutMs: 60000, concurrency: 5, minIntervalMs: 60000, prompt: "欧拉函数的意义?", retries: 2 }; const st = raw as Partial; return { enabled: typeof st.enabled === "boolean" ? st.enabled : false, sortKey: st.sortKey === "ttft" || st.sortKey === "latency" || st.sortKey === "hybrid" || st.sortKey === "smart" ? st.sortKey : "ttft", timeoutMs: typeof st.timeoutMs === "number" && st.timeoutMs >= 1000 ? st.timeoutMs : 60000, concurrency: typeof st.concurrency === "number" && Number.isInteger(st.concurrency) && st.concurrency >= 1 ? st.concurrency : 5, minIntervalMs: typeof st.minIntervalMs === "number" && st.minIntervalMs >= 0 ? st.minIntervalMs : 60000, prompt: typeof st.prompt === "string" && st.prompt ? st.prompt : "欧拉函数的意义?", retries: typeof st.retries === "number" && Number.isInteger(st.retries) && st.retries >= 0 ? st.retries : 2, }; } /** 合并默认值, 让缺失的 timeoutMs/cooldownMs/strategy 等字段有兜底 (面板显示 + 引擎加载都靠它)。 */ function normalizeConfig(parsed: RrConfig): RrConfig { const cfg = defaultRrConfig(); cfg.virtualModel = { ...cfg.virtualModel, ...parsed.virtualModel }; cfg.candidates = Array.isArray(parsed.candidates) ? parsed.candidates : []; cfg.log = parsed.log !== false; cfg.timeoutMs = typeof parsed.timeoutMs === "number" ? parsed.timeoutMs : 30000; cfg.cooldownMs = typeof parsed.cooldownMs === "number" ? parsed.cooldownMs : 60000; cfg.strategy = parsed.strategy === "round-robin" || parsed.strategy === "primary" ? parsed.strategy : "sticky"; cfg.maxRetriesPerCandidate = typeof parsed.maxRetriesPerCandidate === "number" ? parsed.maxRetriesPerCandidate : 2; // speedTest: 归一化(非法/缺失字段回退默认); 未提供对象时用默认(enabled=false) cfg.speedTest = normalizeSpeedTest(parsed.speedTest); return cfg; } /** 返回所有预设的 {name, config}(解析失败的本会 null,不炸整个列表)。 */ export interface PresetEntry { name: string; config: RrConfig | null; } export function listPresetsWithConfig(): PresetEntry[] { return listPresets().map((name) => { let config: RrConfig | null = null; try { config = readPreset(name); } catch { /* 预设损坏不炸列表 */ } return { name, config }; }); } /** 返回所有要注册为虚拟模型的 group 配置:default(config.json) + 所有预设。 */ export interface GroupConfig { name: string; config: RrConfig; } export function loadAllGroupsConfig(): GroupConfig[] { const out: GroupConfig[] = [{ name: DEFAULT_GROUP_NAME, config: loadRrConfig() }]; for (const { name, config } of listPresetsWithConfig()) { if (config) out.push({ name, config }); } return out; } /** Save a preset (new or overwrite; 原子写). */ export function savePreset(name: string, config: RrConfig): void { validateRrConfig(config); ensureDir(PRESETS_DIR); atomicWriteFileSync(presetPath(name), JSON.stringify(config, null, 2) + "\n"); } /** Activate a preset: copy preset → config.json. */ export function activatePreset(name: string): void { const path = presetPath(name); if (!existsSync(path)) throw new Error(`preset "${name}" not found`); ensureDir(RR_DIR); copyFileSync(path, RR_CONFIG_PATH); } export function deletePreset(name: string): void { const path = presetPath(name); if (!existsSync(path)) throw new Error(`preset "${name}" not found`); rmSync(path); } /** List all provider/model pairs from models.json, for adding as candidates. */ export interface ModelOption { provider: string; model: string; label: string; } export function listModelOptions(): ModelOption[] { const cfg = loadModelsConfig(); const out: ModelOption[] = []; for (const [pname, p] of Object.entries(cfg.providers)) { for (const m of p.models ?? []) { out.push({ provider: pname, model: m.id, label: `${pname}/${m.id}` }); } } return out.sort((a, b) => a.label.localeCompare(b.label)); }