Files
script/hermes/switch_conf.py
T
2026-06-01 11:27:02 +08:00

516 lines
17 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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/.envconfig.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)