#!/usr/bin/env python3 """Interactive Hermes model/provider switcher. Secrets are entered with hidden prompts and stored only in Hermes' local .env as environment variables referenced from config.yaml. Do not commit .env. """ from __future__ import annotations import getpass import os import re import shutil import subprocess import sys from pathlib import Path from typing import Any try: import yaml except ImportError: # pragma: no cover print("缺少 PyYAML:请先安装 `python3 -m pip install pyyaml`", file=sys.stderr) sys.exit(1) PROVIDERS: list[dict[str, Any]] = [ { "id": "custom", "name": "Custom / OpenAI-compatible endpoint", "models": ["gpt-5.5", "gpt-5.1", "claude-sonnet-4.5", "gemini-2.5-pro", "qwen3-coder-plus"], "base_url": "", "api_mode": "auto", "secret_env": "HERMES_MODEL_API_KEY", "notes": "任意 OpenAI 兼容中转/代理;最通用。", }, { "id": "openrouter", "name": "OpenRouter", "models": ["openai/gpt-5.1", "openai/gpt-5", "anthropic/claude-sonnet-4.5", "google/gemini-2.5-pro", "deepseek/deepseek-chat-v3.1"], "base_url": "https://openrouter.ai/api/v1", "api_mode": "chat_completions", "secret_env": "OPENROUTER_API_KEY", }, { "id": "openai-api", "name": "OpenAI API", "models": ["gpt-5.1", "gpt-5", "gpt-5-mini", "gpt-4.1", "o4-mini"], "base_url": "https://api.openai.com/v1", "api_mode": "codex_responses", "secret_env": "OPENAI_API_KEY", }, { "id": "anthropic", "name": "Anthropic Claude", "models": ["claude-sonnet-4-5", "claude-opus-4-1", "claude-haiku-4-5", "claude-3-7-sonnet-latest"], "base_url": "https://api.anthropic.com", "api_mode": "anthropic_messages", "secret_env": "ANTHROPIC_API_KEY", }, { "id": "google", "name": "Google Gemini API / OpenAI-compatible", "models": ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash"], "base_url": "https://generativelanguage.googleapis.com/v1beta/openai/", "api_mode": "chat_completions", "secret_env": "GEMINI_API_KEY", }, { "id": "deepseek", "name": "DeepSeek", "models": ["deepseek-chat", "deepseek-reasoner"], "base_url": "https://api.deepseek.com/v1", "api_mode": "chat_completions", "secret_env": "DEEPSEEK_API_KEY", }, { "id": "alibaba", "name": "Alibaba DashScope / Qwen", "models": ["qwen3-coder-plus", "qwen-plus", "qwen-max", "qwen-turbo"], "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "api_mode": "chat_completions", "secret_env": "DASHSCOPE_API_KEY", }, { "id": "zai", "name": "Z.AI / GLM", "models": ["glm-4.6", "glm-4.5", "glm-4-plus"], "base_url": "https://open.bigmodel.cn/api/paas/v4", "api_mode": "chat_completions", "secret_env": "GLM_API_KEY", }, { "id": "xai", "name": "xAI Grok", "models": ["grok-4", "grok-code-fast-1", "grok-3"], "base_url": "https://api.x.ai/v1", "api_mode": "codex_responses", "secret_env": "XAI_API_KEY", }, { "id": "kimi-for-coding", "name": "Kimi / Moonshot Coding", "models": ["kimi-k2", "kimi-latest", "kimi-thinking-preview"], "base_url": "https://api.kimi.com/coding", "api_mode": "anthropic_messages", "secret_env": "KIMI_API_KEY", }, { "id": "minimax", "name": "MiniMax Global", "models": ["MiniMax-M2", "MiniMax-Text-01"], "base_url": "https://api.minimax.io/anthropic", "api_mode": "anthropic_messages", "secret_env": "MINIMAX_API_KEY", }, { "id": "minimax-cn", "name": "MiniMax CN", "models": ["MiniMax-M2", "MiniMax-Text-01"], "base_url": "https://api.minimax.chat/anthropic", "api_mode": "anthropic_messages", "secret_env": "MINIMAX_CN_API_KEY", }, { "id": "vercel", "name": "Vercel AI Gateway", "models": ["openai/gpt-5.1", "anthropic/claude-sonnet-4.5", "google/gemini-2.5-pro"], "base_url": "https://ai-gateway.vercel.sh/v1", "api_mode": "chat_completions", "secret_env": "AI_GATEWAY_API_KEY", }, { "id": "opencode", "name": "OpenCode Zen", "models": ["anthropic/claude-sonnet-4.5", "openai/gpt-5.1"], "base_url": "https://opencode.ai/zen/v1", "api_mode": "auto", "secret_env": "OPENCODE_ZEN_API_KEY", }, { "id": "opencode-go", "name": "OpenCode Go", "models": ["anthropic/claude-sonnet-4.5", "openai/gpt-5.1"], "base_url": "https://opencode.ai/go/v1", "api_mode": "auto", "secret_env": "OPENCODE_GO_API_KEY", }, { "id": "kilo", "name": "Kilo Code Gateway", "models": ["anthropic/claude-sonnet-4.5", "openai/gpt-5.1", "google/gemini-2.5-pro"], "base_url": "https://kilocode.ai/api/openrouter/v1", "api_mode": "chat_completions", "secret_env": "KILOCODE_API_KEY", }, { "id": "huggingface", "name": "Hugging Face Inference Providers", "models": ["openai/gpt-oss-120b", "meta-llama/Llama-3.3-70B-Instruct"], "base_url": "https://router.huggingface.co/v1", "api_mode": "chat_completions", "secret_env": "HF_TOKEN", }, { "id": "novita", "name": "Novita AI", "models": ["deepseek/deepseek-v3.1", "qwen/qwen3-coder-480b-a35b-instruct"], "base_url": "https://api.novita.ai/v3/openai", "api_mode": "chat_completions", "secret_env": "NOVITA_API_KEY", }, { "id": "nvidia", "name": "NVIDIA NIM", "models": ["openai/gpt-oss-120b", "nvidia/llama-3.1-nemotron-ultra-253b-v1"], "base_url": "https://integrate.api.nvidia.com/v1", "api_mode": "chat_completions", "secret_env": "NVIDIA_API_KEY", }, { "id": "gmi", "name": "GMI Cloud", "models": ["deepseek-ai/DeepSeek-V3.1", "Qwen/Qwen3-Coder-480B-A35B-Instruct"], "base_url": "https://api.gmi-serving.com/v1", "api_mode": "chat_completions", "secret_env": "GMI_API_KEY", }, { "id": "arcee", "name": "Arcee AI", "models": ["arcee-ai/coder-large", "arcee-ai/spotlight"], "base_url": "https://api.arcee.ai/api/v1", "api_mode": "chat_completions", "secret_env": "ARCEE_API_KEY", }, { "id": "xiaomi", "name": "Xiaomi MiMo", "models": ["mimo-vl-7b", "mimo-7b"], "base_url": "https://api.mimo.mi.com/openai/v1", "api_mode": "chat_completions", "secret_env": "XIAOMI_API_KEY", }, { "id": "lmstudio", "name": "LM Studio Local", "models": ["local-model"], "base_url": "http://127.0.0.1:1234/v1", "api_mode": "chat_completions", "secret_env": "LM_API_KEY", "allow_blank_key": True, }, { "id": "ollama-cloud", "name": "Ollama Cloud / Local OpenAI-compatible", "models": ["gpt-oss:120b", "llama3.3:70b", "qwen3-coder"], "base_url": "http://127.0.0.1:11434/v1", "api_mode": "chat_completions", "secret_env": "OLLAMA_API_KEY", "allow_blank_key": True, }, ] API_MODES = [ ("auto", "自动/不写入 api_mode"), ("chat_completions", "OpenAI Chat Completions /chat/completions"), ("codex_responses", "OpenAI Responses /responses,常用于 GPT-5.x、xAI"), ("anthropic_messages", "Anthropic Messages /v1/messages"), ("bedrock_converse", "AWS Bedrock Converse"), ] ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") DOTENV_SAFE_VALUE_RE = re.compile(r"^[A-Za-z0-9_./:@%+=,\-]+$") def hermes_home() -> Path: return Path(os.environ.get("HERMES_HOME") or Path.home() / ".hermes").expanduser() def config_path() -> Path: env_path = os.environ.get("HERMES_CONFIG") return Path(env_path).expanduser() if env_path else hermes_home() / "config.yaml" def env_path() -> Path: env_path_override = os.environ.get("HERMES_ENV_PATH") return Path(env_path_override).expanduser() if env_path_override else hermes_home() / ".env" def prompt(text: str, default: str = "") -> str: suffix = f" [{default}]" if default else "" try: value = input(f"{text}{suffix}: ").strip() except EOFError: print() return default return value if value else default def choose(title: str, options: list[str], default_index: int = 0) -> int: print(f"\n{title}") for index, option in enumerate(options, 1): marker = "*" if index - 1 == default_index else " " print(f" {marker} {index:2d}) {option}") while True: try: raw = input(f"请选择 1-{len(options)} [{default_index + 1}]: ").strip() except EOFError: print() return default_index if not raw: return default_index if raw.isdigit() and 1 <= int(raw) <= len(options): return int(raw) - 1 print("输入无效,请重新选择。") def confirm(text: str, default: bool = True) -> bool: suffix = "Y/n" if default else "y/N" while True: try: raw = input(f"{text} [{suffix}]: ").strip().lower() except EOFError: print() return default if not raw: return default if raw in {"y", "yes", "1", "true", "是"}: return True if raw in {"n", "no", "0", "false", "否"}: return False print("请输入 y 或 n。") def safe_env_name(value: str, fallback: str) -> str: name = re.sub(r"[^A-Za-z0-9_]+", "_", value).upper().strip("_") if not name or not name[0].isalpha(): name = fallback return name def validate_env_name(value: str) -> str: if not ENV_NAME_RE.fullmatch(value): raise SystemExit(f"环境变量名无效:{value!r}。请使用类似 OPENAI_API_KEY 的格式。") return value def ensure_mapping(parent: dict[str, Any], key: str) -> dict[str, Any]: value = parent.get(key) if isinstance(value, dict): return value if value is not None: print(f"配置项 {key!r} 不是 YAML 字典,已重置。", file=sys.stderr) value = {} parent[key] = value return value def load_yaml(path: Path) -> dict[str, Any]: if not path.exists(): return {} with path.open("r", encoding="utf-8-sig") as handle: data = yaml.safe_load(handle) or {} if not isinstance(data, dict): raise SystemExit(f"配置文件不是 YAML 字典:{path}") return data def save_yaml(path: Path, data: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(path.suffix + ".tmp") with tmp.open("w", encoding="utf-8") as handle: yaml.safe_dump(data, handle, allow_unicode=True, sort_keys=False) os.replace(tmp, path) try: path.chmod(0o600) except OSError: pass def upsert_env(path: Path, key: str, value: str) -> None: validate_env_name(key) path.parent.mkdir(parents=True, exist_ok=True) lines: list[str] = [] if path.exists(): lines = path.read_text(encoding="utf-8-sig", errors="replace").splitlines() rendered = f"{key}={render_dotenv_value(value)}" pattern = re.compile(rf"^\s*(?:export\s+)?{re.escape(key)}\s*=") replaced = False out: list[str] = [] for line in lines: if pattern.match(line): out.append(rendered) replaced = True else: out.append(line) if not replaced: out.append(rendered) path.write_text("\n".join(out).rstrip() + "\n", encoding="utf-8") try: path.chmod(0o600) except OSError: pass def render_dotenv_value(value: str) -> str: if DOTENV_SAFE_VALUE_RE.fullmatch(value): return value escaped = ( value.replace("\\", "\\\\") .replace('"', '\\"') .replace("$", "\\$") .replace("\n", "\\n") .replace("\r", "\\r") ) return f'"{escaped}"' def masked_env_ref(env_name: str) -> str: return "${" + env_name + "}" def maybe_restart_gateway() -> None: if not shutil.which("hermes"): print("未找到 hermes 命令,跳过 gateway restart。") return if not confirm("是否重启 Hermes gateway 让 Telegram/微信等立即生效?", True): return result = subprocess.run(["hermes", "gateway", "restart"], text=True) if result.returncode != 0: print("gateway restart 失败;你可以稍后手动运行 `hermes gateway restart`。", file=sys.stderr) def main() -> int: print("Hermes 模型/Provider 一键交互切换") print("说明:API Key 隐藏输入,写入本机 ~/.hermes/.env;config.yaml 只保存 ${ENV_VAR} 引用。") cfg_file = Path(prompt("config.yaml 路径", str(config_path()))).expanduser() dotenv_file = Path(prompt(".env 路径", str(env_path()))).expanduser() provider_labels = [f"{item['id']} — {item['name']}" for item in PROVIDERS] provider = PROVIDERS[choose("选择提供商", provider_labels, 0)] model_options = list(provider["models"]) + ["手动输入其它模型名"] model_index = choose("选择模型", model_options, 0) model = prompt("模型名") if model_index == len(model_options) - 1 else model_options[model_index] if not model: raise SystemExit("模型名不能为空。") default_base = str(provider.get("base_url") or "") base_url = prompt("Base URL(回车使用默认;输入 - 清空)", default_base) if base_url.lower() in {"-", "none", "null"}: base_url = "" default_mode = str(provider.get("api_mode") or "auto") mode_index = next((i for i, (mode, _) in enumerate(API_MODES) if mode == default_mode), 0) api_mode = API_MODES[choose("选择 API 协议模式", [f"{m} — {label}" for m, label in API_MODES], mode_index)][0] default_secret_env = str(provider.get("secret_env") or safe_env_name(provider["id"], "HERMES_MODEL") + "_API_KEY") secret_env = validate_env_name(prompt("API Key 保存到哪个环境变量名", default_secret_env)) allow_blank_key = bool(provider.get("allow_blank_key")) api_key = getpass.getpass("API Key(隐藏输入;留空=不修改现有值): ").strip() if api_key: api_key_ref = masked_env_ref(secret_env) elif allow_blank_key: api_key_ref = "" else: api_key_ref = masked_env_ref(secret_env) print(f"未输入新 key,将保留/引用现有 {secret_env}。") context_length_raw = prompt("context_length(可选,留空则清除现有值)") service_tier = prompt("service_tier(可选,例如 flex/default,留空则清除现有值)") cfg = load_yaml(cfg_file) model_cfg = ensure_mapping(cfg, "model") model_cfg["default"] = model model_cfg["provider"] = provider["id"] if base_url: model_cfg["base_url"] = base_url.rstrip("/") else: model_cfg.pop("base_url", None) if api_key_ref: model_cfg["api_key"] = api_key_ref elif allow_blank_key: model_cfg.pop("api_key", None) if api_mode and api_mode != "auto": model_cfg["api_mode"] = api_mode else: model_cfg.pop("api_mode", None) if context_length_raw: try: model_cfg["context_length"] = int(context_length_raw.replace(",", "")) except ValueError: print("context_length 不是数字,已保留原值。") else: model_cfg.pop("context_length", None) if service_tier: ensure_mapping(cfg, "agent")["service_tier"] = service_tier else: agent_cfg = cfg.get("agent") if isinstance(agent_cfg, dict): agent_cfg.pop("service_tier", None) if confirm("是否同时把 delegation 子代理模型配置成一样?", True): delegation = ensure_mapping(cfg, "delegation") delegation["model"] = model delegation["provider"] = provider["id"] if base_url: delegation["base_url"] = base_url.rstrip("/") else: delegation.pop("base_url", None) if api_key_ref: delegation["api_key"] = api_key_ref elif allow_blank_key: delegation.pop("api_key", None) if api_mode and api_mode != "auto": delegation["api_mode"] = api_mode else: delegation.pop("api_mode", None) print("\n即将写入:") print(f" provider: {provider['id']}") print(f" model: {model}") print(f" base_url: {base_url or '(empty)'}") print(f" api_key: {api_key_ref or '(empty/no key)'}") print(f" api_mode: {api_mode}") print(f" config: {cfg_file}") print(f" env: {dotenv_file}") if not confirm("确认写入?", True): print("已取消。") return 1 try: if api_key: upsert_env(dotenv_file, secret_env, api_key) print(f"API Key 已写入 {dotenv_file}。") save_yaml(cfg_file, cfg) except OSError as exc: print(f"写入失败:{exc}", file=sys.stderr) return 1 print("配置已写入。") maybe_restart_gateway() print("完成。") return 0 if __name__ == "__main__": try: raise SystemExit(main()) except KeyboardInterrupt: print("\n已取消。", file=sys.stderr) raise SystemExit(130)