mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-06 20:02:49 +08:00
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:
@@ -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 var;OAuth/external_process 类没有 api_key_env_vars,
|
||||
// 此时跳过写 key(CLI 登录后 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}"))
|
||||
}
|
||||
|
||||
747
src-tauri/src/commands/hermes_providers.rs
Normal file
747
src-tauri/src/commands/hermes_providers.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user