From f195e6a8aa3fce01e4520791ff29023f6663e966 Mon Sep 17 00:00:00 2001 From: Xiaonuo Date: Tue, 21 Apr 2026 10:09:47 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20models=20command=20=E2=80=94=20prov?= =?UTF-8?q?iders/list/test/status/switch=20with=20--aux=20and=20--tg=20sup?= =?UTF-8?q?port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - models providers: list configured custom providers - models list [provider]: fetch and display available models from /v1/models - models test [provider]: connectivity check with latency - models status: show current main + auxiliary model config - models switch: change provider/model for main or auxiliary tasks - --aux : target specific auxiliary tasks (repeatable) - --tg: switch via Telegram /model command (current session) - --global: persistent switch (with --tg) Signed-off-by: Xiaonuo --- src/cli.ts | 3 + src/commands/models.ts | 488 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 491 insertions(+) create mode 100644 src/commands/models.ts diff --git a/src/cli.ts b/src/cli.ts index 97bee0f..09bdc26 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,9 +1,11 @@ #!/usr/bin/env bun import { personality } from "./commands/personality"; +import { models } from "./commands/models"; const COMMANDS: Record Promise> = { personality, + models, }; async function main() { @@ -16,6 +18,7 @@ Usage: hermes-har [options] Commands: personality Manage personality presets (list/add/remove/switch) + models Manage providers & models (list/test/switch) Run 'hermes-har --help' for details.`); process.exit(0); diff --git a/src/commands/models.ts b/src/commands/models.ts new file mode 100644 index 0000000..edba842 --- /dev/null +++ b/src/commands/models.ts @@ -0,0 +1,488 @@ +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_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; +} + +// ── 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: provider.api_key + ? { Authorization: `Bearer ${provider.api_key}` } + : {}, + 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: provider.api_key + ? { Authorization: `Bearer ${provider.api_key}` } + : {}, + 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); + } +} -- 2.43.0 From d78116ed26815f4af5c84d824d0833c04066c0c0 Mon Sep 17 00:00:00 2001 From: Xiaonuo Date: Tue, 21 Apr 2026 10:22:55 +0800 Subject: [PATCH 2/2] fix: read api_key_env from .env file for provider auth Supports api_key_env field in custom_providers config, reads from process.env first then falls back to ~/.hermes/.env. Signed-off-by: Xiaonuo --- src/commands/models.ts | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/commands/models.ts b/src/commands/models.ts index edba842..c24b99f 100644 --- a/src/commands/models.ts +++ b/src/commands/models.ts @@ -12,6 +12,7 @@ interface CustomProvider { name: string; base_url: string; api_key?: string; + api_key_env?: string; api_mode?: string; } @@ -84,6 +85,28 @@ function getProviderBaseUrl(config: Config, name: string): string | null { 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() { @@ -136,9 +159,7 @@ async function list(providerName?: string) { try { const modelsUrl = provider.base_url.replace(/\/+$/, "") + "/models"; const resp = await fetch(modelsUrl, { - headers: provider.api_key - ? { Authorization: `Bearer ${provider.api_key}` } - : {}, + headers: getAuthHeaders(provider), signal: AbortSignal.timeout(10000), }); @@ -209,9 +230,7 @@ async function test(providerName?: string) { try { const resp = await fetch(modelsUrl, { - headers: provider.api_key - ? { Authorization: `Bearer ${provider.api_key}` } - : {}, + headers: getAuthHeaders(provider), signal: AbortSignal.timeout(10000), }); -- 2.43.0