diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 26f345c..ca6b0fc 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -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 { + 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 { // 读取 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 { .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 { } } - // 读取 .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 = 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 { 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, + provider: Option, ) -> Result, 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 = 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 { +pub async fn hermes_update_model( + model: String, + provider: Option, +) -> Result { + 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::>() - .join("\n"); + // Provider 决定策略: + // 1. 调用方显式提供 → 直接使用 + // 2. 从静态 catalog 反查唯一匹配 → 使用反查结果 + // 3. 找不到 / 模糊 → 保持现有 provider(不改) + let resolved_provider: Option = + 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 = 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}")) } diff --git a/src-tauri/src/commands/hermes_providers.rs b/src-tauri/src/commands/hermes_providers.rs new file mode 100644 index 0000000..008d98a --- /dev/null +++ b/src-tauri/src/commands/hermes_providers.rs @@ -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 (`_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 { + 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); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f244a24..3143bcf 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 700f9cf..ede1d5b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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,