f734a9f1a6
引入 `switch_conf.py` 脚本,用于交互式切换 Hermes 的模型和提供商。该脚本支持通过隐藏输入提示安全地输入敏感密钥,并将其作为环境变量仅保存在本地的 `.env` 文件中,避免敏感配置泄露到版本控制系统中。
512 lines
17 KiB
Python
Executable File
512 lines
17 KiB
Python
Executable File
#!/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())
|