feat: models command #1

Merged
xiaonuo merged 2 commits from feat/models-command into main 2026-04-21 02:42:02 +00:00
2 changed files with 510 additions and 0 deletions

View File

@ -1,9 +1,11 @@
#!/usr/bin/env bun
import { personality } from "./commands/personality";
import { models } from "./commands/models";
const COMMANDS: Record<string, (args: string[]) => Promise<void>> = {
personality,
models,
};
async function main() {
@ -16,6 +18,7 @@ Usage: hermes-har <command> [options]
Commands:
personality Manage personality presets (list/add/remove/switch)
models Manage providers & models (list/test/switch)
Run 'hermes-har <command> --help' for details.`);
process.exit(0);

507
src/commands/models.ts Normal file
View File

@ -0,0 +1,507 @@
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<string, AuxiliaryEntry>;
[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<string, string> = {
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<string, string> {
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<string, typeof models> = {};
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<unknown>;
};
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<string, AuxiliaryEntry>;
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<string, unknown>).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 <provider> [model] Switch main provider (config)
hermes-har models switch <provider> [model] --tg Switch via Telegram (session)
hermes-har models switch <provider> [model] --global Persistent (with --tg)
hermes-har models switch <provider> [model] --aux <task> 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 <provider> [model] [flags]");
process.exit(1);
}
// Parse args: switch <provider> [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);
}
}