diff --git a/hermes/switch_conf.py b/hermes/switch_conf.py new file mode 100755 index 0000000..fe36ffd --- /dev/null +++ b/hermes/switch_conf.py @@ -0,0 +1,511 @@ +#!/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__": + raise SystemExit(main())