/** * 配置读写:key 池、活跃 key、冷却、配额封禁。 * 零 pi 依赖的纯业务模块,可被 pi / dsh 复用。 */ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; export const DEFAULT_COOLDOWN_MINUTES = 60; export const DEFAULT_WATCHDOG_IDLE_MS = 90_000; export interface KeyEntry { name: string; key: string; } export interface Config { keys: KeyEntry[]; activeKeyIndex: number; cooldownMinutes: number; watchdogEnabled: boolean; watchdogIdleMs: number; /** key 下标 → 冷却开始时刻(epoch ms) */ cooldowns: Record; /** key 下标 → 配额封禁解除时刻(epoch ms) */ quotaBlockedUntil: Record; /** sessionId → 已绑定的 key 下标 */ sessionAffinity: Record; } export const EMPTY_CONFIG: Config = { keys: [], activeKeyIndex: 0, cooldownMinutes: DEFAULT_COOLDOWN_MINUTES, watchdogEnabled: true, watchdogIdleMs: DEFAULT_WATCHDOG_IDLE_MS, cooldowns: {}, quotaBlockedUntil: {}, sessionAffinity: {}, }; /** 返回一个全新的(深拷贝嵌套对象)空配置,避免浅拷贝共享 mutate 全局常量。 */ export function createEmptyConfig(): Config { return { keys: [], activeKeyIndex: 0, cooldownMinutes: DEFAULT_COOLDOWN_MINUTES, watchdogEnabled: true, watchdogIdleMs: DEFAULT_WATCHDOG_IDLE_MS, cooldowns: {}, quotaBlockedUntil: {}, sessionAffinity: {}, }; } const ENV_CONFIG_PATH = "PI_OPENCODE_ROTATION_CONFIG"; export function defaultConfigPath(): string { return join(homedir(), ".pi", "agent", "opencode-keys.json"); } export function getConfigPath(env = process.env): string { return env[ENV_CONFIG_PATH] ?? defaultConfigPath(); } export interface ConfigIO { read(path: string): string | undefined; write(path: string, data: string): void; exists(path: string): boolean; mkdir(dir: string): void; chmod(path: string, mode: number): void; dirname(path: string): string; } const nodeIO: ConfigIO = { read: (p) => (existsSync(p) ? readFileSync(p, "utf-8") : undefined), write: (p, d) => writeFileSync(p, d, { mode: 0o600 }), exists: existsSync, mkdir: (d) => mkdirSync(d, { recursive: true }), chmod: chmodSync, dirname, }; /** 纯净版:注入 IO 便于测试 */ export function loadConfigPath(path: string, io: ConfigIO = nodeIO): Config { const raw = io.read(path); if (raw === undefined) return createEmptyConfig(); try { const parsed = JSON.parse(raw) as Partial; return { ...createEmptyConfig(), ...parsed, cooldowns: parsed.cooldowns ?? {}, quotaBlockedUntil: parsed.quotaBlockedUntil ?? {}, sessionAffinity: parsed.sessionAffinity ?? {}, }; } catch { return createEmptyConfig(); } } export function loadConfig(io: ConfigIO = nodeIO): Config { return loadConfigPath(getConfigPath(), io); } export function saveConfig(config: Config, io: ConfigIO = nodeIO): void { const path = getConfigPath(); io.mkdir(io.dirname(path)); io.write(path, JSON.stringify(config, null, 2)); try { io.chmod(path, 0o600); } catch { /* best-effort */ } } export function getCooldownMs(config: Config): number { return (config.cooldownMinutes || DEFAULT_COOLDOWN_MINUTES) * 60_000; } export function getWatchdogIdleMs(config: Config): number { return config.watchdogIdleMs > 0 ? config.watchdogIdleMs : DEFAULT_WATCHDOG_IDLE_MS; }