import { readFileSync, writeFileSync, existsSync } from "fs"; import { homedir } from "os"; import { join } from "path"; import { parse, stringify } from "yaml"; const CONFIG_PATH = join(homedir(), ".hermes", "config.yaml"); const ENV_PATH = join(homedir(), ".hermes", ".env"); // ── Types ────────────────────────────────────────────────────────────── interface CustomProvider { name: string; base_url: string; api_key?: string; api_key_env?: string; api_mode?: string; } interface AuxiliaryEntry { provider: string; model: string; base_url?: string; api_key?: string; timeout?: number; [key: string]: unknown; } interface Config { model?: { default?: string; provider?: string; [key: string]: unknown; }; custom_providers?: CustomProvider[]; auxiliary?: Record; [key: string]: unknown; } const AUXILIARY_TASKS = [ "vision", "web_extract", "compression", "session_search", "skills_hub", "summary", "title_generation", "approval", ]; // ── Helpers ──────────────────────────────────────────────────────────── function loadConfig(): Config { if (!existsSync(CONFIG_PATH)) { throw new Error(`Config not found: ${CONFIG_PATH}`); } return parse(readFileSync(CONFIG_PATH, "utf-8")) || {}; } function saveConfig(config: Config) { writeFileSync(CONFIG_PATH, stringify(config, { lineWidth: 120 }), "utf-8"); } function getCustomProviders(config: Config): CustomProvider[] { return config.custom_providers || []; } function findProvider( config: Config, name: string ): CustomProvider | undefined { return getCustomProviders(config).find((p) => p.name === name); } function getProviderBaseUrl(config: Config, name: string): string | null { const provider = findProvider(config, name); if (provider) return provider.base_url; // Check env for standard providers const envMap: Record = { openrouter: "https://openrouter.ai/api/v1", anthropic: "https://api.anthropic.com/v1", openai: "https://api.openai.com/v1", deepseek: "https://api.deepseek.com/v1", }; return envMap[name] || null; } function getProviderApiKey(provider: CustomProvider): string { if (provider.api_key) return provider.api_key; if (provider.api_key_env) { // Try process.env first, then read from .env file const envVal = process.env[provider.api_key_env]; if (envVal) return envVal; if (existsSync(ENV_PATH)) { for (const line of readFileSync(ENV_PATH, "utf-8").split("\n")) { if (line.startsWith(`${provider.api_key_env}=`)) { return line.split("=").slice(1).join("=").trim(); } } } } return ""; } function getAuthHeaders(provider: CustomProvider): Record { const key = getProviderApiKey(provider); return key ? { Authorization: `Bearer ${key}` } : {}; } // ── providers ────────────────────────────────────────────────────────── function providers() { const config = loadConfig(); const customProviders = getCustomProviders(config); const mainProvider = config.model?.provider || "auto"; const mainModel = config.model?.default || "(not set)"; console.log(`Main: provider=${mainProvider} model=${mainModel}\n`); if (customProviders.length === 0) { console.log("No custom providers configured."); return; } console.log("Custom providers:\n"); for (const p of customProviders) { const active = p.name === mainProvider || `custom:${p.name}` === mainProvider ? " ◀ active" : ""; console.log(` ${p.name}${active}`); console.log(` url: ${p.base_url}`); if (p.api_mode) console.log(` mode: ${p.api_mode}`); } console.log(`\nTotal: ${customProviders.length}`); } // ── list ──────────────────────────────────────────────────────────────── async function list(providerName?: string) { const config = loadConfig(); const customProviders = getCustomProviders(config); const targets: CustomProvider[] = providerName ? customProviders.filter((p) => p.name === providerName) : customProviders; if (targets.length === 0) { if (providerName) { console.error(`Provider not found: ${providerName}`); console.error( `Available: ${customProviders.map((p) => p.name).join(", ")}` ); } else { console.log("No custom providers configured."); } process.exit(1); } for (const provider of targets) { console.log(`\n[${provider.name}] ${provider.base_url}\n`); try { const modelsUrl = provider.base_url.replace(/\/+$/, "") + "/models"; const resp = await fetch(modelsUrl, { headers: getAuthHeaders(provider), signal: AbortSignal.timeout(10000), }); if (!resp.ok) { console.error(` HTTP ${resp.status}: ${resp.statusText}`); continue; } const data = (await resp.json()) as { data?: Array<{ id: string; display_name?: string; owned_by?: string; }>; }; const models = data.data || []; if (models.length === 0) { console.log(" (no models)"); continue; } // Group by owned_by const grouped: Record = {}; for (const m of models) { const owner = m.owned_by || "unknown"; if (!grouped[owner]) grouped[owner] = []; grouped[owner].push(m); } for (const [owner, ownerModels] of Object.entries(grouped)) { console.log(` ${owner}:`); for (const m of ownerModels) { const display = m.display_name ? ` (${m.display_name})` : ""; console.log(` ${m.id}${display}`); } } console.log(`\n Total: ${models.length} models`); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); console.error(` Error fetching models: ${msg}`); } } } // ── test ──────────────────────────────────────────────────────────────── async function test(providerName?: string) { const config = loadConfig(); const customProviders = getCustomProviders(config); const targets: CustomProvider[] = providerName ? customProviders.filter((p) => p.name === providerName) : customProviders; if (targets.length === 0) { if (providerName) { console.error(`Provider not found: ${providerName}`); } else { console.log("No custom providers configured."); } process.exit(1); } for (const provider of targets) { const modelsUrl = provider.base_url.replace(/\/+$/, "") + "/models"; const start = performance.now(); try { const resp = await fetch(modelsUrl, { headers: getAuthHeaders(provider), signal: AbortSignal.timeout(10000), }); const elapsed = Math.round(performance.now() - start); if (resp.ok) { const data = (await resp.json()) as { data?: Array; }; const count = data.data?.length || 0; console.log(` ✅ ${provider.name} — ${elapsed}ms, ${count} models`); } else { console.log( ` ❌ ${provider.name} — HTTP ${resp.status} (${elapsed}ms)` ); } } catch (err: unknown) { const elapsed = Math.round(performance.now() - start); const msg = err instanceof Error ? err.message : String(err); console.log(` ❌ ${provider.name} — ${msg} (${elapsed}ms)`); } } } // ── status ────────────────────────────────────────────────────────────── function status() { const config = loadConfig(); const mainProvider = config.model?.provider || "auto"; const mainModel = config.model?.default || "(not set)"; console.log("=== Model Configuration ===\n"); console.log(`Main: provider=${mainProvider} model=${mainModel}\n`); const aux = config.auxiliary || {}; const tasks = Object.keys(aux).filter((k) => typeof aux[k] === "object" && aux[k]?.provider !== undefined); if (tasks.length === 0) { console.log("Auxiliary: (not configured)"); return; } console.log("Auxiliary:"); for (const task of tasks) { const entry = aux[task]; const provider = entry.provider || "auto"; const model = entry.model || "(default)"; console.log(` ${task.padEnd(20)} provider=${provider} model=${model}`); } } // ── switch ────────────────────────────────────────────────────────────── interface SwitchOptions { provider: string; model?: string; tg?: boolean; global?: boolean; auxTasks?: string[]; } function switchConfig(opts: SwitchOptions) { const config = loadConfig(); if (opts.auxTasks && opts.auxTasks.length > 0) { // Switch auxiliary tasks if (!config.auxiliary) config.auxiliary = {} as Record; for (const task of opts.auxTasks) { if (!config.auxiliary[task]) { config.auxiliary[task] = { provider: "", model: "", timeout: 30 }; } config.auxiliary[task].provider = opts.provider; if (opts.model) { config.auxiliary[task].model = opts.model; } console.log(` auxiliary.${task} → provider=${opts.provider}${opts.model ? ` model=${opts.model}` : ""}`); } } else { // Switch main provider const providerValue = findProvider(config, opts.provider) ? `custom:${opts.provider}` : opts.provider; if (!config.model) config.model = {}; config.model.provider = providerValue; if (opts.model) { config.model.default = opts.model; } console.log(` main → provider=${providerValue}${opts.model ? ` model=${opts.model}` : ""}`); } saveConfig(config); console.log("\nConfig saved. New sessions will use the updated settings."); } async function switchTelegram(opts: SwitchOptions) { const config = loadConfig(); // Build the /model command let command: string; if (opts.auxTasks && opts.auxTasks.length > 0) { // Hermes doesn't support /model for aux via TG, fall back to config edit console.log("Note: Telegram /model doesn't support auxiliary switching. Updating config directly."); switchConfig(opts); return; } const modelPart = opts.model || config.model?.default || ""; command = `/model ${modelPart} --provider ${opts.provider}`; if (opts.global) { command += " --global"; } // Find bot token let token = process.env.TELEGRAM_BOT_TOKEN || ""; if (!token && existsSync(ENV_PATH)) { for (const line of readFileSync(ENV_PATH, "utf-8").split("\n")) { if (line.startsWith("TELEGRAM_BOT_TOKEN=")) { token = line.split("=").slice(1).join("=").trim(); } } } if (!token) { console.error("TELEGRAM_BOT_TOKEN not found"); process.exit(1); } const chatId = process.env.TELEGRAM_HOME_CHANNEL || (config as Record).TELEGRAM_HOME_CHANNEL || ""; if (!chatId) { console.error("TELEGRAM_HOME_CHANNEL not configured"); process.exit(1); } const url = `https://api.telegram.org/bot${token}/sendMessage`; const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ chat_id: chatId, text: command }), }); const result = (await resp.json()) as { ok: boolean; description?: string; result?: { message_id: number }; }; if (result.ok) { console.log(`Sent: ${command}`); console.log(opts.global ? "Persistent change (--global)." : "Current session only."); // Clean up the command message if (result.result?.message_id) { await fetch(`https://api.telegram.org/bot${token}/deleteMessage`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ chat_id: chatId, message_id: result.result.message_id, }), }).catch(() => {}); } } else { console.error(`Telegram API error: ${result.description}`); process.exit(1); } } // ── CLI ───────────────────────────────────────────────────────────────── const HELP = `hermes-har models — Manage providers & models Usage: hermes-har models providers List custom providers hermes-har models list [provider] List available models hermes-har models test [provider] Test provider connectivity hermes-har models status Show current model config hermes-har models switch [model] Switch main provider (config) hermes-har models switch [model] --tg Switch via Telegram (session) hermes-har models switch [model] --global Persistent (with --tg) hermes-har models switch [model] --aux Switch auxiliary task Multiple --aux flags supported: --aux vision --aux compression`; export async function models(args: string[]) { const action = args[0]; if (!action || action === "--help" || action === "help") { console.log(HELP); return; } switch (action) { case "providers": case "provider": providers(); break; case "list": case "ls": await list(args[1]); break; case "test": await test(args[1]); break; case "status": case "st": status(); break; case "switch": case "use": { if (!args[1]) { console.error("Usage: hermes-har models switch [model] [flags]"); process.exit(1); } // Parse args: switch [model] [--tg] [--global] [--aux task]* const provider = args[1]; let model: string | undefined; let tg = false; let global = false; const auxTasks: string[] = []; let i = 2; // model is the next positional arg if it doesn't start with -- if (i < args.length && !args[i].startsWith("--")) { model = args[i]; i++; } while (i < args.length) { if (args[i] === "--tg" || args[i] === "--telegram") { tg = true; i++; } else if (args[i] === "--global") { global = true; i++; } else if (args[i] === "--aux" && i + 1 < args.length) { const task = args[i + 1]; if (!AUXILIARY_TASKS.includes(task)) { console.error(`Unknown auxiliary task: ${task}`); console.error(`Available: ${AUXILIARY_TASKS.join(", ")}`); process.exit(1); } auxTasks.push(task); i += 2; } else { console.error(`Unknown flag: ${args[i]}`); process.exit(1); } } const opts: SwitchOptions = { provider, model, tg, global, auxTasks }; if (tg) { await switchTelegram(opts); } else { switchConfig(opts); } break; } default: console.error(`Unknown action: ${action}`); console.log(HELP); process.exit(1); } }