feat(hermes): add 22-provider registry + rewrite core commands (Step 1)

Introduce an authoritative Hermes provider registry in Rust, mirroring
upstream `hermes-agent` data (PROVIDER_REGISTRY + _PROVIDER_MODELS), and
refactor the four critical Hermes commands to be provider-aware.

Closes the G1/G2/G3 blockers identified in the v3 integration design.

New module `src-tauri/src/commands/hermes_providers.rs`:
- Static catalog of 22 providers: 17 api_key (incl. DeepSeek, Gemini,
  xAI, GLM/Z.AI, Kimi, MiniMax int'l + CN, Alibaba DashScope, Xiaomi,
  Copilot PAT, HuggingFace, OpenRouter, Vercel AI Gateway, OpenCode
  Zen/Go, Kilocode), 3 OAuth (Nous, OpenAI Codex, Qwen OAuth), 1
  external_process (Copilot ACP), and `custom` placeholder.
- Each entry carries id, display name, auth_type, base_url, env var
  priority list, transport, probe strategy, known models, aggregator
  flag, and CLI auth hint.
- Helpers: `get_provider`, `primary_api_key_env`, `primary_base_url_env`,
  `all_managed_env_keys`, `infer_provider_from_env_keys`,
  `find_provider_by_model`.
- New Tauri command `hermes_list_providers` (cached client-side).
- 5 unit tests covering registry integrity and lookup semantics.

Refactored `src-tauri/src/commands/hermes.rs`:
- `configure_hermes`: writes `model.provider` in config.yaml and routes
  API keys through the registry's `api_key_env_vars`. Skips key write
  for OAuth providers (Hermes CLI manages auth.json itself). Clears
  all managed keys on provider switch to avoid stale credentials.
- `hermes_read_config`: reads `model.provider`, then reverse-looks up
  the matching API key from the registry's env var priority list.
  Falls back to inferring provider from .env when config.yaml omits
  the provider field.
- `hermes_update_model`: accepts optional `provider` param; writes
  `model.provider` line into config.yaml (adds it when missing, updates
  in place when present, removes it for `custom`).
- `hermes_fetch_models`: accepts optional `provider` param; uses
  registry's probe strategy (`PROBE_NONE` returns static catalog for
  OAuth providers instead of hitting the API).

Registration:
- `src-tauri/src/commands/mod.rs`: declare `hermes_providers` module.
- `src-tauri/src/lib.rs`: import + register `hermes_list_providers`.

Verified: cargo fmt / cargo clippy -D warnings / cargo test all green
(5 new unit tests pass).
This commit is contained in:
晴天
2026-04-24 20:40:40 +08:00
parent 11cd6218dc
commit 17759fc1e6
4 changed files with 971 additions and 109 deletions

View File

@@ -1198,35 +1198,38 @@ pub async fn configure_hermes(
let _ = std::fs::create_dir_all(home.join(dir));
}
// Hermes 不使用 provider 字段——它通过 .env 中的 API key/base_url 自动检测。
// 只需要区分 env key 名称OPENAI_API_KEY vs ANTHROPIC_API_KEY 等)。
let env_provider = match provider.as_str() {
"anthropic" | "minimax" => "anthropic",
"openrouter" => "openrouter",
_ => "openai", // 所有 OpenAI 兼容的
};
// ---- Provider-aware key routing ----
// ClawPanel 使用 HERMES_PROVIDER_REGISTRY (22 providers) 决定 .env key 名和
// config.yaml 的 model.provider 字段。详见 hermes_providers.rs 的文档。
use super::hermes_providers;
// 模型标识Hermes 直接用模型名,不加 provider/ 前缀
let model_str = model.unwrap_or_else(|| match env_provider {
"anthropic" => "claude-sonnet-4-20250514".into(),
"openrouter" => "anthropic/claude-sonnet-4-20250514".into(),
_ => "gpt-4o".into(),
let pcfg = hermes_providers::get_provider(&provider);
// 模型标识:优先使用调用方传入,否则用 provider 的首个已知模型;
// aggregator 没有默认模型,要求调用方显式提供。
let model_str = model.unwrap_or_else(|| {
pcfg.and_then(|p| p.models.first().map(|s| s.to_string()))
.unwrap_or_default()
});
if model_str.is_empty() {
return Err(format!(
"Provider '{provider}' has no default model; please pass an explicit model name"
));
}
// ---- 写入 config.yaml合并模式保留用户自定义的 hooks/skills/cron 等) ----
let config_path = home.join("config.yaml");
let base_url_line = if let Some(ref url) = base_url {
let u = url.trim();
if !u.is_empty() {
format!(" base_url: {u}\n")
} else {
String::new()
}
} else {
String::new()
let base_url_line = match base_url.as_ref() {
Some(url) if !url.trim().is_empty() => format!(" base_url: {}\n", url.trim()),
_ => String::new(),
};
// Provider 字段Hermes v0.14+ 的 model_switch 依赖该字段决定 env_var。
// `custom` 不写 provider 行,让 Hermes 从 base_url 自动推断。
let provider_line = if provider == "custom" || provider.is_empty() {
String::new()
} else {
format!(" provider: {provider}\n")
};
// Hermes 不使用 provider 字段,留空即可
let provider_line = String::new();
let config_content = if config_path.exists() {
// 读取现有配置,只更新 model 区块,保留其余内容
@@ -1238,7 +1241,7 @@ pub async fn configure_hermes(
r#"# Hermes Agent configuration (managed by ClawPanel)
model:
default: {model_str}
{base_url_line}platform_toolsets:
{provider_line}{base_url_line}platform_toolsets:
api_server:
- hermes-api-server
terminal:
@@ -1253,35 +1256,34 @@ platforms:
.map_err(|e| format!("写入 config.yaml 失败: {e}"))?;
// ---- 写入 .env合并模式保留用户自定义的环境变量如 TAVILY_API_KEY 等) ----
let env_key = match env_provider {
"anthropic" => "ANTHROPIC_API_KEY",
"openrouter" => "OPENROUTER_API_KEY",
_ => "OPENAI_API_KEY",
};
// ClawPanel 管理的 key 列表(更新时覆盖,其他 key 保留)
let managed_keys: Vec<&str> = vec![
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"OPENROUTER_API_KEY",
"OPENAI_BASE_URL",
"ANTHROPIC_BASE_URL",
"GATEWAY_ALLOW_ALL_USERS",
"API_SERVER_KEY",
];
// 根据 provider 选择正确的 env varOAuth/external_process 类没有 api_key_env_vars
// 此时跳过写 keyCLI 登录后 Hermes 会自行管理 auth.json
let key_env = hermes_providers::primary_api_key_env(&provider);
let url_env = hermes_providers::primary_base_url_env(&provider);
// ClawPanel 管理的 key 列表:包含所有 provider 的 api_key_env_vars + base_url_env_vars
// + ClawPanel 特定的两个 key。换 provider 时这些会被重写或清除。
let managed_keys_owned = hermes_providers::all_managed_env_keys();
let managed_keys: Vec<&str> = managed_keys_owned.to_vec();
let mut new_pairs: Vec<(String, String)> = vec![
(env_key.into(), api_key.clone()),
("GATEWAY_ALLOW_ALL_USERS".into(), "true".into()),
("API_SERVER_KEY".into(), "clawpanel-local".into()),
];
// 清除旧 provider 的 key换 provider 时不残留旧凭证)
// 新的 base_url
if let Some(ref url) = base_url {
if !url.trim().is_empty() {
let url_key = match env_provider {
"anthropic" => "ANTHROPIC_BASE_URL",
_ => "OPENAI_BASE_URL",
};
new_pairs.push((url_key.into(), url.trim().into()));
if let Some(env) = key_env {
if !api_key.trim().is_empty() {
new_pairs.push((env.into(), api_key.trim().into()));
}
} else if !api_key.trim().is_empty() {
// OAuth provider 传了 api_key —— 记日志,不落盘
eprintln!("[configure_hermes] Provider '{provider}' uses OAuth; ignoring provided api_key");
}
if let (Some(env), Some(url)) = (url_env, base_url.as_ref()) {
let u = url.trim();
if !u.is_empty() {
new_pairs.push((env.into(), u.into()));
}
}
@@ -1450,6 +1452,8 @@ fn merge_env_file(existing: &str, managed_keys: &[&str], new_pairs: &[(String, S
#[tauri::command]
pub async fn hermes_read_config() -> Result<Value, String> {
use super::hermes_providers;
let home = hermes_home();
let config_path = home.join("config.yaml");
let env_path = home.join(".env");
@@ -1457,15 +1461,14 @@ pub async fn hermes_read_config() -> Result<Value, String> {
// 读取 config.yaml
let config_raw = std::fs::read_to_string(&config_path).unwrap_or_default();
let mut model_name = String::new();
let mut base_url = String::new();
let mut provider = String::new();
// 简易 YAML 解析(避免引入 yaml 依赖)
let mut base_url_from_yaml = String::new();
let mut provider_from_yaml = String::new();
let mut in_model = false;
for line in config_raw.lines() {
let trimmed = line.trim();
if trimmed.starts_with("model:") {
in_model = true;
// model: "xxx" 单行格式
// `model: "xxx"` 单行格式
if let Some(v) = trimmed
.strip_prefix("model:")
.map(|s| s.trim().trim_matches('"'))
@@ -1485,14 +1488,14 @@ pub async fn hermes_read_config() -> Result<Value, String> {
.trim_matches('"')
.to_string();
} else if trimmed.starts_with("base_url:") {
base_url = trimmed
base_url_from_yaml = trimmed
.strip_prefix("base_url:")
.unwrap()
.trim()
.trim_matches('"')
.to_string();
} else if trimmed.starts_with("provider:") {
provider = trimmed
provider_from_yaml = trimmed
.strip_prefix("provider:")
.unwrap()
.trim()
@@ -1505,37 +1508,53 @@ pub async fn hermes_read_config() -> Result<Value, String> {
}
}
// 读取 .env 中的 API key
// 读取 .env 到 key→value map
let env_raw = std::fs::read_to_string(&env_path).unwrap_or_default();
let mut api_key = String::new();
for line in env_raw.lines() {
let trimmed = line.trim();
if trimmed.starts_with("OPENAI_API_KEY=") {
api_key = trimmed.strip_prefix("OPENAI_API_KEY=").unwrap().to_string();
} else if trimmed.starts_with("ANTHROPIC_API_KEY=") && api_key.is_empty() {
api_key = trimmed
.strip_prefix("ANTHROPIC_API_KEY=")
.unwrap()
.to_string();
} else if trimmed.starts_with("OPENROUTER_API_KEY=") && api_key.is_empty() {
api_key = trimmed
.strip_prefix("OPENROUTER_API_KEY=")
.unwrap()
.to_string();
}
// base_url from .env if not in config
if trimmed.starts_with("OPENAI_BASE_URL=") && base_url.is_empty() {
base_url = trimmed
.strip_prefix("OPENAI_BASE_URL=")
.unwrap()
.to_string();
} else if trimmed.starts_with("ANTHROPIC_BASE_URL=") && base_url.is_empty() {
base_url = trimmed
.strip_prefix("ANTHROPIC_BASE_URL=")
.unwrap()
.to_string();
}
}
let env_map: std::collections::HashMap<String, String> = env_raw
.lines()
.filter_map(|line| {
let t = line.trim();
if t.is_empty() || t.starts_with('#') {
return None;
}
t.split_once('=')
.map(|(k, v)| (k.trim().to_string(), v.to_string()))
})
.collect();
// 推断 provider优先 config.yaml.model.provider其次从 .env 反查
let provider_id: String = if !provider_from_yaml.is_empty() {
provider_from_yaml.clone()
} else {
let keys_refs: Vec<&str> = env_map.keys().map(|s| s.as_str()).collect();
hermes_providers::infer_provider_from_env_keys(&keys_refs)
.map(String::from)
.unwrap_or_default()
};
// 按 provider 的 api_key_env_vars 顺序拿 api_key
let api_key: String = hermes_providers::get_provider(&provider_id)
.and_then(|p| {
p.api_key_env_vars
.iter()
.find_map(|ev| env_map.get(*ev).cloned())
})
.unwrap_or_default();
// 有效 base_url优先 config.yaml.model.base_url其次 provider 的 base_url_env_var
let effective_base_url: String = if !base_url_from_yaml.is_empty() {
base_url_from_yaml.clone()
} else {
hermes_providers::get_provider(&provider_id)
.and_then(|p| {
if p.base_url_env_var.is_empty() {
None
} else {
env_map.get(p.base_url_env_var).cloned()
}
})
.unwrap_or_default()
};
// UI 显示用短名(去掉 provider/ 前缀),如 openai/QC-S05 → QC-S05
let display_model = if let Some(pos) = model_name.find('/') {
@@ -1547,8 +1566,8 @@ pub async fn hermes_read_config() -> Result<Value, String> {
Ok(serde_json::json!({
"model": display_model,
"model_raw": model_name,
"base_url": base_url,
"provider": provider,
"base_url": effective_base_url,
"provider": provider_id,
"api_key": api_key,
"config_exists": config_path.exists(),
}))
@@ -1563,13 +1582,41 @@ pub async fn hermes_fetch_models(
base_url: String,
api_key: String,
api_type: Option<String>,
provider: Option<String>,
) -> Result<Vec<String>, String> {
use super::hermes_providers;
// 如果显式指定了 provider优先走注册表决定 probe 方式 + fallback
if let Some(pid) = provider.as_ref() {
if let Some(pcfg) = hermes_providers::get_provider(pid) {
// OAuth / external_process / copilot → 不能用 api_key 探测,
// 直接返回静态 catalog
if pcfg.models_probe == hermes_providers::PROBE_NONE {
let mut models: Vec<String> = pcfg.models.iter().map(|s| s.to_string()).collect();
models.sort();
return Ok(models);
}
}
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("HTTP client error: {e}"))?;
let api = api_type.unwrap_or_else(|| "openai".into());
// api_type 优先级:调用方 api_type > provider.transport 推断 > 默认 openai
let api = api_type.unwrap_or_else(|| {
provider
.as_ref()
.and_then(|pid| hermes_providers::get_provider(pid))
.map(|p| match p.transport {
hermes_providers::TRANSPORT_ANTHROPIC => "anthropic-messages".to_string(),
hermes_providers::TRANSPORT_GOOGLE => "google-generative-ai".to_string(),
_ => "openai".to_string(),
})
.unwrap_or_else(|| "openai".into())
});
let mut base = base_url.trim_end_matches('/').to_string();
// 移除尾部的 chat/completions 等路径
for suffix in &[
@@ -1662,36 +1709,102 @@ pub async fn hermes_fetch_models(
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn hermes_update_model(model: String) -> Result<String, String> {
pub async fn hermes_update_model(
model: String,
provider: Option<String>,
) -> Result<String, String> {
use super::hermes_providers;
let home = hermes_home();
let config_path = home.join("config.yaml");
let config_raw =
std::fs::read_to_string(&config_path).map_err(|e| format!("读取 config.yaml 失败: {e}"))?;
// Hermes 直接用模型名,不加 provider/ 前缀
let model_str = model.clone();
// 替换 model.default 行
let mut found = false;
let new_content: String = config_raw
.lines()
.map(|line| {
let trimmed = line.trim();
if trimmed.starts_with("default:") && !found {
found = true;
let indent = line.len() - line.trim_start().len();
format!("{}default: {}", " ".repeat(indent), model_str)
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
// Provider 决定策略:
// 1. 调用方显式提供 → 直接使用
// 2. 从静态 catalog 反查唯一匹配 → 使用反查结果
// 3. 找不到 / 模糊 → 保持现有 provider不改
let resolved_provider: Option<String> =
provider.or_else(|| hermes_providers::find_provider_by_model(&model).map(String::from));
if !found {
// 一次性扫描并替换 model 区块中的 default / provider 字段。
let lines: Vec<&str> = config_raw.lines().collect();
let mut out: Vec<String> = Vec::with_capacity(lines.len() + 1);
let mut in_model = false;
let mut default_written = false;
let mut provider_written = false;
let mut default_indent: String = " ".into();
for line in lines.iter() {
let trimmed = line.trim();
if trimmed.starts_with("model:") {
in_model = true;
out.push(line.to_string());
continue;
}
if in_model {
let is_indented = line.starts_with(" ") || line.starts_with('\t');
if !is_indented && !trimmed.is_empty() && !trimmed.starts_with('#') {
// 离开 model 区块 —— 先补齐未写入的 provider 行
if let Some(pid) = resolved_provider.as_ref() {
if !provider_written && !pid.is_empty() && pid != "custom" {
out.push(format!("{default_indent}provider: {pid}"));
provider_written = true;
}
}
in_model = false;
out.push(line.to_string());
continue;
}
if trimmed.starts_with("default:") {
let indent_len = line.len() - line.trim_start().len();
default_indent = " ".repeat(indent_len);
out.push(format!("{default_indent}default: {model_str}"));
default_written = true;
continue;
}
if trimmed.starts_with("provider:") {
if let Some(pid) = resolved_provider.as_ref() {
if !pid.is_empty() && pid != "custom" {
let indent_len = line.len() - line.trim_start().len();
let indent = " ".repeat(indent_len);
out.push(format!("{indent}provider: {pid}"));
provider_written = true;
continue;
}
// custom → 删除 provider 行
continue;
}
// 未提供新 provider保留旧值
out.push(line.to_string());
provider_written = true;
continue;
}
}
out.push(line.to_string());
}
// 文件末尾还在 model 块里:补 provider 行
if in_model {
if let Some(pid) = resolved_provider.as_ref() {
if !provider_written && !pid.is_empty() && pid != "custom" {
out.push(format!("{default_indent}provider: {pid}"));
}
}
}
if !default_written {
return Err("config.yaml 中未找到 model.default 字段".into());
}
let mut new_content = out.join("\n");
if !new_content.ends_with('\n') {
new_content.push('\n');
}
std::fs::write(&config_path, new_content).map_err(|e| format!("写入 config.yaml 失败: {e}"))?;
Ok(format!("模型已切换为 {model_str}"))
}

View File

@@ -0,0 +1,747 @@
//! Hermes Provider Registry — authoritative catalog of 22 providers supported
//! by Hermes Agent, with their auth schemes, env vars, base URLs, and known
//! model catalogs.
//!
//! Source of truth: upstream `hermes-agent` repository
//! - Auth / env vars: `hermes_cli/auth.py::PROVIDER_REGISTRY`
//! - Model catalogs: `hermes_cli/models.py::_PROVIDER_MODELS`
//!
//! Synced from upstream at:
//! - hermes_cli/auth.py (v0.14.x series)
//! - hermes_cli/models.py (v0.14.x series)
//!
//! When syncing a new Hermes release, verify:
//! 1. Each provider's `api_key_env_vars` matches upstream tuple ordering
//! 2. `models` list reflects the latest _PROVIDER_MODELS entries
//! 3. `base_url` mirrors the default inference URL (users can override via
//! `base_url_env_var`)
//!
//! This module is intentionally self-contained: it must NOT depend on any
//! runtime state. The static data is queried by commands in `hermes.rs`
//! and surfaced to the frontend via `hermes_list_providers`.
use serde::Serialize;
// =============================================================================
// Data model
// =============================================================================
/// Auth scheme matching upstream `auth.py::ProviderConfig.auth_type`.
///
/// - `api_key`: traditional env-var based key (`<PROVIDER>_API_KEY`, etc.)
/// - `oauth_device_code`: interactive device-code OAuth flow (Nous)
/// - `oauth_external`: OAuth handled by external process (Codex, Qwen)
/// - `external_process`: backing process handles auth (Copilot ACP)
pub const AUTH_API_KEY: &str = "api_key";
pub const AUTH_OAUTH_DEVICE: &str = "oauth_device_code";
pub const AUTH_OAUTH_EXTERNAL: &str = "oauth_external";
pub const AUTH_EXTERNAL_PROCESS: &str = "external_process";
/// Transport negotiated with the provider.
pub const TRANSPORT_OPENAI_CHAT: &str = "openai_chat";
pub const TRANSPORT_ANTHROPIC: &str = "anthropic_messages";
pub const TRANSPORT_GOOGLE: &str = "google_gemini";
pub const TRANSPORT_CODEX: &str = "codex_responses";
/// `/models` probe strategy used by `hermes_fetch_models`.
///
/// Note: all OpenAI-compatible providers (including Gemini via its OpenAI
/// adapter) use `PROBE_OPENAI`. A separate `PROBE_GOOGLE` was considered for
/// native Google Gemini API probing, but in practice every provider we
/// support uses one of these three strategies.
pub const PROBE_OPENAI: &str = "openai";
pub const PROBE_ANTHROPIC: &str = "anthropic";
pub const PROBE_NONE: &str = "none";
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HermesProvider {
/// Stable identifier (matches upstream PROVIDER_REGISTRY keys).
pub id: &'static str,
/// Human-readable display name.
pub name: &'static str,
/// See AUTH_* constants above.
pub auth_type: &'static str,
/// Default inference base URL.
pub base_url: &'static str,
/// Env var name for overriding `base_url` (empty string = none).
pub base_url_env_var: &'static str,
/// Env vars checked in priority order for API key (empty for OAuth/external).
pub api_key_env_vars: &'static [&'static str],
/// See TRANSPORT_* constants above.
pub transport: &'static str,
/// See PROBE_* constants above.
pub models_probe: &'static str,
/// Known static model list (subset of upstream _PROVIDER_MODELS).
pub models: &'static [&'static str],
/// True for aggregators/routers (OpenRouter, AI Gateway, etc.) — users
/// must explicitly specify a model since there is no sensible default.
pub is_aggregator: bool,
/// Hint for the UI when the CLI must be used for login (OAuth providers).
pub cli_auth_hint: &'static str,
}
// =============================================================================
// Static registry — 22 providers
// =============================================================================
const P_ANTHROPIC: HermesProvider = HermesProvider {
id: "anthropic",
name: "Anthropic",
auth_type: AUTH_API_KEY,
base_url: "https://api.anthropic.com",
base_url_env_var: "",
api_key_env_vars: &[
"ANTHROPIC_API_KEY",
"ANTHROPIC_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN",
],
transport: TRANSPORT_ANTHROPIC,
models_probe: PROBE_ANTHROPIC,
models: &[
"claude-opus-4-7",
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-opus-4-5-20251101",
"claude-sonnet-4-5-20250929",
"claude-opus-4-20250514",
"claude-sonnet-4-20250514",
"claude-haiku-4-5-20251001",
],
is_aggregator: false,
cli_auth_hint: "",
};
const P_GEMINI: HermesProvider = HermesProvider {
id: "gemini",
name: "Google AI Studio",
auth_type: AUTH_API_KEY,
base_url: "https://generativelanguage.googleapis.com/v1beta/openai",
base_url_env_var: "GEMINI_BASE_URL",
api_key_env_vars: &["GOOGLE_API_KEY", "GEMINI_API_KEY"],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &[
"gemini-3.1-pro-preview",
"gemini-3-flash-preview",
"gemini-3.1-flash-lite-preview",
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemma-4-31b-it",
"gemma-4-26b-it",
],
is_aggregator: false,
cli_auth_hint: "",
};
const P_DEEPSEEK: HermesProvider = HermesProvider {
id: "deepseek",
name: "DeepSeek",
auth_type: AUTH_API_KEY,
base_url: "https://api.deepseek.com",
base_url_env_var: "DEEPSEEK_BASE_URL",
api_key_env_vars: &["DEEPSEEK_API_KEY"],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &["deepseek-chat", "deepseek-reasoner"],
is_aggregator: false,
cli_auth_hint: "",
};
const P_ZAI: HermesProvider = HermesProvider {
id: "zai",
name: "Z.AI / GLM",
auth_type: AUTH_API_KEY,
base_url: "https://api.z.ai/api/paas/v4",
base_url_env_var: "GLM_BASE_URL",
api_key_env_vars: &["GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &[
"glm-5.1",
"glm-5",
"glm-5v-turbo",
"glm-5-turbo",
"glm-4.7",
"glm-4.5",
"glm-4.5-flash",
],
is_aggregator: false,
cli_auth_hint: "",
};
const P_KIMI_CODING: HermesProvider = HermesProvider {
id: "kimi-coding",
name: "Kimi / Moonshot",
auth_type: AUTH_API_KEY,
base_url: "https://api.moonshot.ai/v1",
base_url_env_var: "KIMI_BASE_URL",
api_key_env_vars: &["KIMI_API_KEY"],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &[
"kimi-for-coding",
"kimi-k2.6",
"kimi-k2.5",
"kimi-k2-thinking",
"kimi-k2-turbo-preview",
"kimi-k2-0905-preview",
],
is_aggregator: false,
cli_auth_hint: "",
};
const P_XAI: HermesProvider = HermesProvider {
id: "xai",
name: "xAI",
auth_type: AUTH_API_KEY,
base_url: "https://api.x.ai/v1",
base_url_env_var: "XAI_BASE_URL",
api_key_env_vars: &["XAI_API_KEY"],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &["grok-4.20-reasoning", "grok-4-1-fast-reasoning"],
is_aggregator: false,
cli_auth_hint: "",
};
const P_MINIMAX: HermesProvider = HermesProvider {
id: "minimax",
name: "MiniMax (International)",
auth_type: AUTH_API_KEY,
base_url: "https://api.minimax.io/anthropic/v1",
base_url_env_var: "MINIMAX_BASE_URL",
api_key_env_vars: &["MINIMAX_API_KEY"],
transport: TRANSPORT_ANTHROPIC,
models_probe: PROBE_ANTHROPIC,
models: &[
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
"MiniMax-M2.1",
"MiniMax-M2.1-highspeed",
"MiniMax-M2",
"MiniMax-M2-highspeed",
],
is_aggregator: false,
cli_auth_hint: "",
};
const P_MINIMAX_CN: HermesProvider = HermesProvider {
id: "minimax-cn",
name: "MiniMax (China)",
auth_type: AUTH_API_KEY,
base_url: "https://api.minimaxi.com/v1",
base_url_env_var: "MINIMAX_CN_BASE_URL",
api_key_env_vars: &["MINIMAX_CN_API_KEY"],
transport: TRANSPORT_ANTHROPIC,
models_probe: PROBE_ANTHROPIC,
models: &[
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
"MiniMax-M2.1",
"MiniMax-M2.1-highspeed",
"MiniMax-M2",
"MiniMax-M2-highspeed",
],
is_aggregator: false,
cli_auth_hint: "",
};
const P_ALIBABA: HermesProvider = HermesProvider {
id: "alibaba",
name: "Alibaba Cloud (DashScope)",
auth_type: AUTH_API_KEY,
base_url: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
base_url_env_var: "DASHSCOPE_BASE_URL",
api_key_env_vars: &["DASHSCOPE_API_KEY"],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &[
"qwen3.5-plus",
"qwen3-coder-plus",
"qwen3-coder-next",
"glm-5",
"glm-4.7",
"kimi-k2.5",
"MiniMax-M2.5",
],
is_aggregator: false,
cli_auth_hint: "",
};
const P_HUGGINGFACE: HermesProvider = HermesProvider {
id: "huggingface",
name: "Hugging Face",
auth_type: AUTH_API_KEY,
base_url: "https://router.huggingface.co/v1",
base_url_env_var: "HF_BASE_URL",
api_key_env_vars: &["HF_TOKEN"],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &[
"Qwen/Qwen3.5-397B-A17B",
"Qwen/Qwen3.5-35B-A3B",
"deepseek-ai/DeepSeek-V3.2",
"moonshotai/Kimi-K2.5",
"MiniMaxAI/MiniMax-M2.5",
"zai-org/GLM-5",
"XiaomiMiMo/MiMo-V2-Flash",
"moonshotai/Kimi-K2-Thinking",
],
is_aggregator: true,
cli_auth_hint: "",
};
const P_XIAOMI: HermesProvider = HermesProvider {
id: "xiaomi",
name: "Xiaomi MiMo",
auth_type: AUTH_API_KEY,
base_url: "https://api.xiaomimimo.com/v1",
base_url_env_var: "XIAOMI_BASE_URL",
api_key_env_vars: &["XIAOMI_API_KEY"],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &["mimo-v2-pro", "mimo-v2-omni", "mimo-v2-flash"],
is_aggregator: false,
cli_auth_hint: "",
};
const P_AI_GATEWAY: HermesProvider = HermesProvider {
id: "ai-gateway",
name: "Vercel AI Gateway",
auth_type: AUTH_API_KEY,
base_url: "https://ai-gateway.vercel.sh/v1",
base_url_env_var: "AI_GATEWAY_BASE_URL",
api_key_env_vars: &["AI_GATEWAY_API_KEY"],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &[
"anthropic/claude-opus-4.6",
"anthropic/claude-sonnet-4.6",
"anthropic/claude-sonnet-4.5",
"anthropic/claude-haiku-4.5",
"openai/gpt-5",
"openai/gpt-4.1",
"openai/gpt-4.1-mini",
"google/gemini-3-pro-preview",
"google/gemini-3-flash",
"google/gemini-2.5-pro",
"google/gemini-2.5-flash",
"deepseek/deepseek-v3.2",
],
is_aggregator: true,
cli_auth_hint: "",
};
const P_OPENCODE_ZEN: HermesProvider = HermesProvider {
id: "opencode-zen",
name: "OpenCode Zen",
auth_type: AUTH_API_KEY,
base_url: "https://opencode.ai/zen/v1",
base_url_env_var: "OPENCODE_ZEN_BASE_URL",
api_key_env_vars: &["OPENCODE_ZEN_API_KEY"],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &[
"gpt-5.4-pro",
"gpt-5.4",
"gpt-5.3-codex",
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-haiku-4-5",
"gemini-3.1-pro",
"gemini-3-pro",
"minimax-m2.7",
"glm-5",
"kimi-k2.5",
"qwen3-coder",
],
is_aggregator: true,
cli_auth_hint: "",
};
const P_OPENCODE_GO: HermesProvider = HermesProvider {
id: "opencode-go",
name: "OpenCode Go",
auth_type: AUTH_API_KEY,
base_url: "https://opencode.ai/zen/go/v1",
base_url_env_var: "OPENCODE_GO_BASE_URL",
api_key_env_vars: &["OPENCODE_GO_API_KEY"],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &[
"glm-5.1",
"glm-5",
"kimi-k2.5",
"mimo-v2-pro",
"mimo-v2-omni",
"minimax-m2.7",
"minimax-m2.5",
],
is_aggregator: true,
cli_auth_hint: "",
};
const P_KILOCODE: HermesProvider = HermesProvider {
id: "kilocode",
name: "Kilo Code",
auth_type: AUTH_API_KEY,
base_url: "https://api.kilo.ai/api/gateway",
base_url_env_var: "KILOCODE_BASE_URL",
api_key_env_vars: &["KILOCODE_API_KEY"],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &[
"anthropic/claude-opus-4.6",
"anthropic/claude-sonnet-4.6",
"openai/gpt-5.4",
"google/gemini-3-pro-preview",
"google/gemini-3-flash-preview",
],
is_aggregator: true,
cli_auth_hint: "",
};
const P_COPILOT: HermesProvider = HermesProvider {
id: "copilot",
name: "GitHub Copilot (PAT)",
auth_type: AUTH_API_KEY,
base_url: "https://api.githubcopilot.com",
base_url_env_var: "",
api_key_env_vars: &["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_NONE,
models: &[
"gpt-4o",
"gpt-4.1",
"claude-3.5-sonnet",
"claude-3.7-sonnet",
"claude-sonnet-4-5",
"o1",
"o1-mini",
"gemini-2.5-pro",
],
is_aggregator: false,
cli_auth_hint: "",
};
const P_OPENROUTER: HermesProvider = HermesProvider {
id: "openrouter",
name: "OpenRouter",
auth_type: AUTH_API_KEY,
base_url: "https://openrouter.ai/api/v1",
base_url_env_var: "OPENAI_BASE_URL",
api_key_env_vars: &["OPENROUTER_API_KEY"],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &[],
is_aggregator: true,
cli_auth_hint: "",
};
// OAuth providers — NO api_key_env_vars; user must run CLI to log in.
const P_NOUS: HermesProvider = HermesProvider {
id: "nous",
name: "Nous Portal",
auth_type: AUTH_OAUTH_DEVICE,
base_url: "https://inference-api.nousresearch.com/v1",
base_url_env_var: "",
api_key_env_vars: &[],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_NONE,
models: &[
"moonshotai/kimi-k2.6",
"anthropic/claude-opus-4.7",
"anthropic/claude-sonnet-4.6",
"openai/gpt-5.4",
"google/gemini-3-pro-preview",
"qwen/qwen3.5-plus-02-15",
"minimax/minimax-m2.7",
"z-ai/glm-5.1",
"x-ai/grok-4.20-beta",
],
is_aggregator: true,
cli_auth_hint: "hermes auth login nous",
};
const P_OPENAI_CODEX: HermesProvider = HermesProvider {
id: "openai-codex",
name: "OpenAI Codex",
auth_type: AUTH_OAUTH_EXTERNAL,
base_url: "https://chatgpt.com/backend-api/codex",
base_url_env_var: "",
api_key_env_vars: &[],
transport: TRANSPORT_CODEX,
models_probe: PROBE_NONE,
models: &[
"gpt-5.5",
"gpt-5.4-mini",
"gpt-5.4",
"gpt-5.3-codex",
"gpt-5.2-codex",
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
],
is_aggregator: false,
cli_auth_hint: "hermes auth login openai-codex",
};
const P_QWEN_OAUTH: HermesProvider = HermesProvider {
id: "qwen-oauth",
name: "Qwen OAuth",
auth_type: AUTH_OAUTH_EXTERNAL,
base_url: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
base_url_env_var: "",
api_key_env_vars: &[],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_NONE,
models: &["qwen3.5-plus", "qwen3-coder-plus", "qwen3-coder-next"],
is_aggregator: false,
cli_auth_hint: "hermes auth login qwen-oauth",
};
const P_COPILOT_ACP: HermesProvider = HermesProvider {
id: "copilot-acp",
name: "GitHub Copilot ACP",
auth_type: AUTH_EXTERNAL_PROCESS,
base_url: "http://127.0.0.1:0",
base_url_env_var: "COPILOT_ACP_BASE_URL",
api_key_env_vars: &[],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_NONE,
models: &[
"gpt-4o",
"gpt-4.1",
"claude-3.5-sonnet",
"claude-3.7-sonnet",
],
is_aggregator: false,
cli_auth_hint: "hermes auth login copilot-acp",
};
// Custom placeholder — frontend-only. Backend treats `custom` as opaque:
// uses whatever api_key + base_url the user provides.
const P_CUSTOM: HermesProvider = HermesProvider {
id: "custom",
name: "Custom OpenAI-Compatible",
auth_type: AUTH_API_KEY,
base_url: "",
base_url_env_var: "OPENAI_BASE_URL",
api_key_env_vars: &["CUSTOM_API_KEY", "OPENAI_API_KEY"],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &[],
is_aggregator: true,
cli_auth_hint: "",
};
/// Full provider registry. Order matters for UI rendering (first = top).
pub const ALL_PROVIDERS: &[HermesProvider] = &[
// API-key providers — international
P_ANTHROPIC,
P_GEMINI,
P_DEEPSEEK,
P_XAI,
P_MINIMAX,
P_HUGGINGFACE,
P_COPILOT,
// API-key providers — China
P_ZAI,
P_KIMI_CODING,
P_ALIBABA,
P_MINIMAX_CN,
P_XIAOMI,
// Aggregators / routers
P_OPENROUTER,
P_AI_GATEWAY,
P_OPENCODE_ZEN,
P_OPENCODE_GO,
P_KILOCODE,
// OAuth / external-process
P_NOUS,
P_OPENAI_CODEX,
P_QWEN_OAUTH,
P_COPILOT_ACP,
// Custom (frontend placeholder)
P_CUSTOM,
];
// =============================================================================
// Query helpers
// =============================================================================
/// Look up a provider by stable id.
pub fn get_provider(id: &str) -> Option<&'static HermesProvider> {
ALL_PROVIDERS.iter().find(|p| p.id == id)
}
/// Primary env var for writing the API key for a given provider.
/// Returns `None` for OAuth / external_process providers.
pub fn primary_api_key_env(provider_id: &str) -> Option<&'static str> {
get_provider(provider_id).and_then(|p| p.api_key_env_vars.first().copied())
}
/// Env var for overriding the base URL (empty string if provider has no such var).
pub fn primary_base_url_env(provider_id: &str) -> Option<&'static str> {
get_provider(provider_id).and_then(|p| {
if p.base_url_env_var.is_empty() {
None
} else {
Some(p.base_url_env_var)
}
})
}
/// All env var keys that ClawPanel manages across every provider.
/// Used by `configure_hermes::merge_env_file` to know which keys to clear
/// when the user switches providers. This is the union of:
/// - all `api_key_env_vars` across providers
/// - all non-empty `base_url_env_var` values
/// - the two ClawPanel-specific env vars (`GATEWAY_ALLOW_ALL_USERS`,
/// `API_SERVER_KEY`)
pub fn all_managed_env_keys() -> Vec<&'static str> {
let mut out: Vec<&'static str> = Vec::new();
for p in ALL_PROVIDERS {
for ev in p.api_key_env_vars {
if !out.contains(ev) {
out.push(ev);
}
}
if !p.base_url_env_var.is_empty() && !out.contains(&p.base_url_env_var) {
out.push(p.base_url_env_var);
}
}
// ClawPanel-specific keys
for extra in &["GATEWAY_ALLOW_ALL_USERS", "API_SERVER_KEY"] {
if !out.contains(extra) {
out.push(extra);
}
}
out
}
/// Given the set of env var keys present in a `.env` file, infer the most
/// likely provider. Priority follows `ALL_PROVIDERS` order, so users who have
/// multiple provider keys set will be identified with the first matching
/// canonical provider.
pub fn infer_provider_from_env_keys(keys: &[&str]) -> Option<&'static str> {
for p in ALL_PROVIDERS {
if p.api_key_env_vars.is_empty() {
continue; // Skip OAuth/external
}
for ev in p.api_key_env_vars {
if keys.contains(ev) {
return Some(p.id);
}
}
}
None
}
/// Find the first provider whose static model catalog contains the given model
/// name (exact match). Returns `None` on ambiguity (multiple matches) or miss.
pub fn find_provider_by_model(model: &str) -> Option<&'static str> {
let hits: Vec<&'static str> = ALL_PROVIDERS
.iter()
.filter(|p| p.models.contains(&model))
.map(|p| p.id)
.collect();
if hits.len() == 1 {
Some(hits[0])
} else {
None
}
}
// =============================================================================
// Tauri command
// =============================================================================
/// Return the full provider registry for the frontend. The list is static —
/// clients can cache it for the lifetime of the session.
#[tauri::command]
pub fn hermes_list_providers() -> Vec<HermesProvider> {
ALL_PROVIDERS.to_vec()
}
// =============================================================================
// Tests
// =============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_has_expected_providers() {
assert_eq!(ALL_PROVIDERS.len(), 22);
assert!(get_provider("anthropic").is_some());
assert!(get_provider("gemini").is_some());
assert!(get_provider("nous").is_some());
assert!(get_provider("custom").is_some());
assert!(get_provider("nonexistent").is_none());
}
#[test]
fn primary_api_key_env_picks_first() {
assert_eq!(primary_api_key_env("anthropic"), Some("ANTHROPIC_API_KEY"));
assert_eq!(primary_api_key_env("gemini"), Some("GOOGLE_API_KEY"));
assert_eq!(primary_api_key_env("zai"), Some("GLM_API_KEY"));
assert_eq!(primary_api_key_env("nous"), None);
}
#[test]
fn all_managed_env_keys_covers_everything() {
let keys = all_managed_env_keys();
assert!(keys.contains(&"ANTHROPIC_API_KEY"));
assert!(keys.contains(&"DEEPSEEK_API_KEY"));
assert!(keys.contains(&"GOOGLE_API_KEY"));
assert!(keys.contains(&"GEMINI_API_KEY"));
assert!(keys.contains(&"GEMINI_BASE_URL"));
assert!(keys.contains(&"GATEWAY_ALLOW_ALL_USERS"));
assert!(keys.contains(&"API_SERVER_KEY"));
// No duplicates
for i in 0..keys.len() {
for j in (i + 1)..keys.len() {
assert_ne!(keys[i], keys[j], "duplicate: {}", keys[i]);
}
}
}
#[test]
fn infer_provider_from_env_keys_follows_registry_order() {
// ANTHROPIC appears before DEEPSEEK in ALL_PROVIDERS, so if both are present
// the anthropic entry wins.
let keys = vec!["DEEPSEEK_API_KEY", "ANTHROPIC_API_KEY"];
assert_eq!(infer_provider_from_env_keys(&keys), Some("anthropic"));
// Only DeepSeek set → matches deepseek.
let keys = vec!["DEEPSEEK_API_KEY"];
assert_eq!(infer_provider_from_env_keys(&keys), Some("deepseek"));
// Secondary anthropic env var still matches.
let keys = vec!["ANTHROPIC_TOKEN"];
assert_eq!(infer_provider_from_env_keys(&keys), Some("anthropic"));
// Unknown key → no match.
let keys = vec!["UNRELATED_KEY"];
assert_eq!(infer_provider_from_env_keys(&keys), None);
}
#[test]
fn find_provider_by_model_is_unambiguous() {
assert_eq!(find_provider_by_model("deepseek-chat"), Some("deepseek"));
assert_eq!(
find_provider_by_model("kimi-for-coding"),
Some("kimi-coding")
);
// Unknown model
assert_eq!(find_provider_by_model("nonexistent"), None);
}
}

View File

@@ -20,6 +20,7 @@ pub mod device;
pub mod diagnose;
pub mod extensions;
pub mod hermes;
pub mod hermes_providers;
pub mod logs;
pub mod memory;
pub mod messaging;

View File

@@ -4,8 +4,8 @@ mod tray;
mod utils;
use commands::{
agent, assistant, config, device, diagnose, extensions, hermes, logs, memory, messaging,
pairing, service, skills, update,
agent, assistant, config, device, diagnose, extensions, hermes, hermes_providers, logs, memory,
messaging, pairing, service, skills, update,
};
pub fn run() {
@@ -230,6 +230,7 @@ pub fn run() {
hermes::hermes_fetch_models,
hermes::hermes_update_model,
hermes::hermes_detect_environments,
hermes_providers::hermes_list_providers,
hermes::hermes_set_gateway_url,
hermes::update_hermes,
hermes::uninstall_hermes,