Supports api_key_env field in custom_providers config, reads from process.env first then falls back to ~/.hermes/.env. Signed-off-by: Xiaonuo <xiaonuo@shazhou.work>
508 lines
16 KiB
TypeScript
508 lines
16 KiB
TypeScript
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);
|
|
}
|
|
}
|