From 583f5401ac9216560bae5115662c854b2b1ce76a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Fri, 15 May 2026 17:32:38 +0800 Subject: [PATCH] fix(hermes): stabilize gateway provider routing and startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes Gateway 之前容易出现两类问题:配置里的 provider 与 base_url 不一致, 以及多个启动入口并发触发 Gateway start,导致日志里反复出现启动流程,运行状态也不稳定。 ## Provider / base_url 自愈 - 归一化 provider URL(去掉尾斜杠和常见 API path 后缀) - 当 openrouter provider 搭配自定义 base_url 时,自动切换为 custom provider - 在读取配置、写入模型配置、启动 Gateway 前都执行一次自愈 - Web 模式同步实现相同逻辑,避免桌面端和浏览器端行为不一致 ## API Key 别名兼容 - custom provider 优先读取 OPENAI_API_KEY - 当 .env 只有 CUSTOM_API_KEY 时,自动补齐 OPENAI_API_KEY - 避免辅助客户端读取不到凭证或落到错误 provider ## Gateway 启动互斥 - 增加 Gateway start guard,串行化启动流程 - 如果已有启动流程在进行中,直接复用健康检查结果 - 避免重复 Gateway 进程、重复日志和竞态状态覆盖 ## 范围 - 桌面 Tauri 命令 - Web dev API 运行时 - Hermes provider 注册表 --- scripts/dev-api.js | 183 +++++++++++++++--- src-tauri/src/commands/hermes.rs | 214 +++++++++++++++++++++ src-tauri/src/commands/hermes_providers.rs | 2 +- 3 files changed, 367 insertions(+), 32 deletions(-) diff --git a/scripts/dev-api.js b/scripts/dev-api.js index afa9f7e..f05c801 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -58,7 +58,7 @@ const HERMES_PROVIDER_REGISTRY = [ hermesProvider('google-gemini-cli', 'Google Gemini (OAuth)', 'oauth_external', 'https://generativelanguage.googleapis.com/v1beta/openai', '', [], 'openai_chat', 'none', ['gemini-2.5-pro', 'gemini-2.5-flash'], false, 'hermes auth login google-gemini-cli'), hermesProvider('minimax-oauth', 'MiniMax (OAuth)', 'oauth_minimax', 'https://api.minimax.io/anthropic', '', [], 'anthropic_messages', 'none', ['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'], false, 'hermes auth login minimax-oauth'), hermesProvider('copilot-acp', 'GitHub Copilot ACP', 'external_process', 'http://127.0.0.1:0', 'COPILOT_ACP_BASE_URL', [], 'openai_chat', 'none', ['gpt-4o', 'gpt-4.1', 'claude-3.5-sonnet', 'claude-3.7-sonnet'], false, 'hermes auth login copilot-acp'), - hermesProvider('custom', 'Custom OpenAI-Compatible', 'api_key', '', 'OPENAI_BASE_URL', ['CUSTOM_API_KEY', 'OPENAI_API_KEY'], 'openai_chat', 'openai', [], true), + hermesProvider('custom', 'Custom OpenAI-Compatible', 'api_key', '', 'OPENAI_BASE_URL', ['OPENAI_API_KEY', 'CUSTOM_API_KEY'], 'openai_chat', 'openai', [], true), ] function hermesHome() { @@ -217,6 +217,7 @@ function diagnoseHermesInstallError(text = '') { } let _hermesGwProcess = null +let _hermesGwStarting = null const __dev_dirname = path.dirname(fileURLToPath(import.meta.url)) const DEFAULT_OPENCLAW_DIR = path.join(homedir(), '.openclaw') @@ -6956,7 +6957,7 @@ const handlers = { for (const d of ['cron','sessions','logs','memories','skills','pairing','hooks','image_cache','audio_cache']) { fs.mkdirSync(path.join(home, d), { recursive: true }) } - const providerId = (provider || '').trim() + const providerId = _normalizeHermesProviderForBaseUrl(provider, baseUrl) const pcfg = HERMES_PROVIDER_REGISTRY.find(p => p.id === providerId) const modelStr = (model || pcfg?.models?.[0] || '').trim() if (!modelStr) throw new Error(`Provider '${providerId || 'custom'}' has no default model; please pass an explicit model name`) @@ -6976,7 +6977,10 @@ const handlers = { const urlEnv = pcfg?.baseUrlEnvVar || '' const managedKeys = handlers._hermesManagedEnvKeys() const newPairs = [['GATEWAY_ALLOW_ALL_USERS', 'true'], ['API_SERVER_KEY', 'clawpanel-local']] - if (envKey && apiKey && apiKey.trim()) newPairs.push([envKey, apiKey.trim()]) + if (envKey && apiKey && apiKey.trim()) { + newPairs.push([envKey, apiKey.trim()]) + if (providerId === 'custom' && envKey !== 'CUSTOM_API_KEY') newPairs.push(['CUSTOM_API_KEY', apiKey.trim()]) + } if (urlEnv && baseUrlValue) newPairs.push([urlEnv, baseUrlValue]) const envPath = path.join(home, '.env') let envContent @@ -6999,39 +7003,48 @@ const handlers = { try { this._hermesEnsureApiServerEnabled() } catch (e) { console.warn('[hermes guardian] patch failed:', e.message || e) } + try { _sanitizeHermesOpenrouterCustomMismatch() } catch (e) { + console.warn('[hermes guardian] provider/base_url sanitize failed:', e.message || e) + } // 检测是否已运行 const alive = await _tcpProbe('127.0.0.1', port, 300) if (alive) return 'Gateway 已在运行' - // 启动 - const home = hermesHome() - const envVars = { ...process.env, PATH: enhanced } - const envPath = path.join(home, '.env') - if (fs.existsSync(envPath)) { - for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) { - const t = line.trim() - if (!t || t.startsWith('#')) continue - const eq = t.indexOf('=') - if (eq > 0) envVars[t.slice(0, eq).trim()] = t.slice(eq + 1).trim() + if (_hermesGwStarting) return await _hermesGwStarting + _hermesGwStarting = (async () => { + const aliveAfterWait = await _tcpProbe('127.0.0.1', port, 500) + if (aliveAfterWait) return 'Gateway 已在运行' + // 启动 + const home = hermesHome() + const envVars = { ...process.env, PATH: enhanced } + const envPath = path.join(home, '.env') + if (fs.existsSync(envPath)) { + for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) { + const t = line.trim() + if (!t || t.startsWith('#')) continue + const eq = t.indexOf('=') + if (eq > 0) envVars[t.slice(0, eq).trim()] = t.slice(eq + 1).trim() + } } - } - const logPath = path.join(home, 'gateway-run.log') - const logFd = fs.openSync(logPath, 'a') - const child = spawn('hermes', ['gateway', 'run'], { - cwd: home, env: envVars, stdio: ['ignore', logFd, logFd], - detached: true, windowsHide: true, - }) - child.unref() - _hermesGwProcess = child - // 等端口可达 - for (let i = 0; i < 40; i++) { - await new Promise(r => setTimeout(r, 500)) - if (await _tcpProbe('127.0.0.1', port, 500)) { - fs.closeSync(logFd) - return 'Gateway 已启动' + const logPath = path.join(home, 'gateway-run.log') + const logFd = fs.openSync(logPath, 'a') + const child = spawn('hermes', ['gateway', 'run'], { + cwd: home, env: envVars, stdio: ['ignore', logFd, logFd], + detached: true, windowsHide: true, + }) + child.unref() + _hermesGwProcess = child + // 等端口可达 + for (let i = 0; i < 40; i++) { + await new Promise(r => setTimeout(r, 500)) + if (await _tcpProbe('127.0.0.1', port, 500)) { + fs.closeSync(logFd) + return 'Gateway 已启动' + } } - } - fs.closeSync(logFd) - throw new Error('Gateway 启动后端口未就绪') + fs.closeSync(logFd) + throw new Error('Gateway 启动后端口未就绪') + })().finally(() => { _hermesGwStarting = null }) + return await _hermesGwStarting } if (action === 'stop') { if (_hermesGwProcess) { try { _hermesGwProcess.kill() } catch {} _hermesGwProcess = null } @@ -7051,6 +7064,7 @@ const handlers = { async _hermesEnsureGatewayReady() { const customUrl = hermesGatewayCustomUrl() if (customUrl && !isLoopbackGatewayUrl(customUrl)) return + try { _sanitizeHermesOpenrouterCustomMismatch() } catch {} const port = hermesGatewayPort() if (await _tcpProbe('127.0.0.1', port, 300)) return await this.hermes_gateway_action({ action: 'start' }) @@ -7124,6 +7138,7 @@ const handlers = { const home = hermesHome() const configPath = path.join(home, 'config.yaml') const envPath = path.join(home, '.env') + try { _sanitizeHermesOpenrouterCustomMismatch() } catch {} let modelName = '', baseUrl = '', provider = '', apiKey = '' try { const content = fs.readFileSync(configPath, 'utf8') @@ -8691,6 +8706,112 @@ function _mergeEnvFile(existing, managedKeys, newPairs) { return content } +function _normalizeProviderUrl(raw) { + let out = String(raw || '').trim().replace(/\/+$/, '').toLowerCase() + for (const suffix of ['/chat/completions', '/completions', '/responses', '/messages', '/models']) { + if (out.endsWith(suffix)) { + out = out.slice(0, -suffix.length) + break + } + } + return out +} + +function _normalizeHermesProviderForBaseUrl(provider, baseUrl) { + const pid = String(provider || '').trim() + if (pid === 'openrouter') { + const base = _normalizeProviderUrl(baseUrl) + const expected = _normalizeProviderUrl('https://openrouter.ai/api/v1') + if (base && base !== expected) return 'custom' + } + return pid +} + +function _envHasValue(raw, key) { + return String(raw || '').split('\n').some(line => { + const t = line.trim() + if (!t || t.startsWith('#')) return false + const eq = t.indexOf('=') + return eq > 0 && t.slice(0, eq).trim() === key && t.slice(eq + 1).trim() + }) +} + +function _envValue(raw, key) { + for (const line of String(raw || '').split('\n')) { + const t = line.trim() + if (!t || t.startsWith('#')) continue + const eq = t.indexOf('=') + if (eq > 0 && t.slice(0, eq).trim() === key) { + const value = t.slice(eq + 1).trim() + if (value) return value + } + } + return '' +} + +function _ensureCustomOpenAIKeyAlias() { + const envPath = path.join(hermesHome(), '.env') + if (!fs.existsSync(envPath)) return false + let raw = fs.readFileSync(envPath, 'utf8') + if (_envHasValue(raw, 'OPENAI_API_KEY')) return false + const customKey = _envValue(raw, 'CUSTOM_API_KEY') + if (!customKey) return false + if (!raw.endsWith('\n')) raw += '\n' + fs.writeFileSync(envPath, `${raw}OPENAI_API_KEY=${customKey}\n`) + return true +} + +function _sanitizeHermesOpenrouterCustomMismatch() { + const home = hermesHome() + const configPath = path.join(home, 'config.yaml') + if (!fs.existsSync(configPath)) return false + const raw = fs.readFileSync(configPath, 'utf8') + let provider = '' + let baseUrl = '' + let inModel = false + for (const line of raw.split('\n')) { + const t = line.trim() + if (t.startsWith('model:')) { inModel = true; continue } + if (inModel) { + const indented = line.startsWith(' ') || line.startsWith('\t') + if (!indented && t && !t.startsWith('#')) break + if (t.startsWith('provider:')) provider = t.slice(9).trim().replace(/^['"]|['"]$/g, '') + else if (t.startsWith('base_url:')) baseUrl = t.slice(9).trim().replace(/^['"]|['"]$/g, '') + } + } + const base = _normalizeProviderUrl(baseUrl) + const expected = _normalizeProviderUrl('https://openrouter.ai/api/v1') + const usesCustomEndpoint = base && base !== expected + const aliasChanged = (!provider || provider === 'custom' || usesCustomEndpoint) ? _ensureCustomOpenAIKeyAlias() : false + if (provider !== 'openrouter') return aliasChanged + if (!base || base === expected) return aliasChanged + let envRaw = '' + try { envRaw = fs.readFileSync(path.join(home, '.env'), 'utf8') } catch {} + const hasOpenrouterKey = _envHasValue(envRaw, 'OPENROUTER_API_KEY') + const hasCustomKey = _envHasValue(envRaw, 'CUSTOM_API_KEY') || _envHasValue(envRaw, 'OPENAI_API_KEY') + if (hasOpenrouterKey && !hasCustomKey) return aliasChanged + const out = [] + inModel = false + for (const line of raw.split('\n')) { + const t = line.trim() + if (t.startsWith('model:')) { + inModel = true + out.push(line) + continue + } + if (inModel) { + const indented = line.startsWith(' ') || line.startsWith('\t') + if (!indented && t && !t.startsWith('#')) inModel = false + else if (t.startsWith('provider:')) continue + } + out.push(line) + } + let fixed = out.join('\n') + if (!fixed.endsWith('\n')) fixed += '\n' + fs.writeFileSync(configPath, fixed) + return true +} + function _tcpProbe(host, port, timeoutMs) { return new Promise(resolve => { const sock = new net.Socket() diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index e1523bc..df91a95 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -28,9 +28,25 @@ static GW_PID: AtomicU32 = AtomicU32::new(0); static GW_GUARDIAN_ACTIVE: AtomicBool = AtomicBool::new(false); /// 通知 guardian 停止的 flag static GW_GUARDIAN_STOP: AtomicBool = AtomicBool::new(false); +static GW_STARTING: AtomicBool = AtomicBool::new(false); /// 缓存 AppHandle 供 guardian 发送事件 static GW_APP_HANDLE: OnceLock = OnceLock::new(); +struct GatewayStartGuard; + +impl Drop for GatewayStartGuard { + fn drop(&mut self) { + GW_STARTING.store(false, Ordering::SeqCst); + } +} + +fn try_gateway_start_guard() -> Option { + GW_STARTING + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .ok() + .map(|_| GatewayStartGuard) +} + /// 获取 Gateway 的完整 URL(当前本地,未来可扩展为远程) fn hermes_gateway_custom_url() -> Option { super::read_panel_config_value() @@ -84,6 +100,7 @@ async fn ensure_managed_gateway_ready(app: &tauri::AppHandle, gw_url: &str) -> R return Ok(()); } } + let _ = sanitize_hermes_openrouter_custom_mismatch()?; if gateway_quick_health_check().await { start_guardian(app); emit_gateway_status(true); @@ -299,6 +316,173 @@ async fn gateway_quick_health_check() -> bool { } } +fn normalize_provider_url(raw: &str) -> String { + let mut out = raw.trim().trim_end_matches('/').to_ascii_lowercase(); + for suffix in [ + "/chat/completions", + "/completions", + "/responses", + "/messages", + "/models", + ] { + if out.ends_with(suffix) { + out.truncate(out.len() - suffix.len()); + break; + } + } + out +} + +fn normalize_hermes_provider_for_base_url(provider: &str, base_url: Option<&str>) -> String { + let pid = provider.trim(); + if pid == "openrouter" { + if let Some(url) = base_url { + let base = normalize_provider_url(url); + let expected = normalize_provider_url("https://openrouter.ai/api/v1"); + if !base.is_empty() && base != expected { + return "custom".into(); + } + } + } + pid.to_string() +} + +fn env_file_has_value(raw: &str, key: &str) -> bool { + raw.lines().any(|line| { + let t = line.trim(); + if t.is_empty() || t.starts_with('#') { + return false; + } + t.split_once('=') + .map(|(k, v)| k.trim() == key && !v.trim().is_empty()) + .unwrap_or(false) + }) +} + +fn env_file_value(raw: &str, key: &str) -> Option { + raw.lines().find_map(|line| { + let t = line.trim(); + if t.is_empty() || t.starts_with('#') { + return None; + } + t.split_once('=').and_then(|(k, v)| { + if k.trim() == key { + let value = v.trim(); + if value.is_empty() { + None + } else { + Some(value.to_string()) + } + } else { + None + } + }) + }) +} + +fn ensure_custom_openai_key_alias() -> Result { + let env_path = hermes_home().join(".env"); + if !env_path.exists() { + return Ok(false); + } + let raw = std::fs::read_to_string(&env_path).map_err(|e| format!("读取 .env 失败: {e}"))?; + if env_file_has_value(&raw, "OPENAI_API_KEY") { + return Ok(false); + } + let Some(custom_key) = env_file_value(&raw, "CUSTOM_API_KEY") else { + return Ok(false); + }; + let mut fixed = raw; + if !fixed.ends_with('\n') { + fixed.push('\n'); + } + fixed.push_str(&format!("OPENAI_API_KEY={custom_key}\n")); + std::fs::write(&env_path, fixed).map_err(|e| format!("写入 .env 失败: {e}"))?; + Ok(true) +} + +fn sanitize_hermes_openrouter_custom_mismatch() -> Result { + let home = hermes_home(); + let config_path = home.join("config.yaml"); + if !config_path.exists() { + return Ok(false); + } + + let raw = + std::fs::read_to_string(&config_path).map_err(|e| format!("读取 config.yaml 失败: {e}"))?; + let mut provider = String::new(); + let mut base_url = String::new(); + let mut in_model = false; + + for line in raw.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("model:") { + in_model = true; + continue; + } + if in_model { + let indented = line.starts_with(' ') || line.starts_with('\t'); + if !indented && !trimmed.is_empty() && !trimmed.starts_with('#') { + break; + } + if let Some(v) = trimmed.strip_prefix("provider:") { + provider = v.trim().trim_matches('"').trim_matches('\'').to_string(); + } else if let Some(v) = trimmed.strip_prefix("base_url:") { + base_url = v.trim().trim_matches('"').trim_matches('\'').to_string(); + } + } + } + + let base = normalize_provider_url(&base_url); + let expected = normalize_provider_url("https://openrouter.ai/api/v1"); + let uses_custom_endpoint = !base.is_empty() && base != expected; + let alias_changed = if provider.is_empty() || provider == "custom" || uses_custom_endpoint { + ensure_custom_openai_key_alias()? + } else { + false + }; + if provider != "openrouter" { + return Ok(alias_changed); + } + if base.is_empty() || base == expected { + return Ok(alias_changed); + } + + let env_raw = std::fs::read_to_string(home.join(".env")).unwrap_or_default(); + let has_openrouter_key = env_file_has_value(&env_raw, "OPENROUTER_API_KEY"); + let has_custom_key = env_file_has_value(&env_raw, "CUSTOM_API_KEY") + || env_file_has_value(&env_raw, "OPENAI_API_KEY"); + if has_openrouter_key && !has_custom_key { + return Ok(alias_changed); + } + + let mut out = Vec::new(); + let mut in_model = false; + for line in raw.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("model:") { + in_model = true; + out.push(line.to_string()); + continue; + } + if in_model { + let indented = line.starts_with(' ') || line.starts_with('\t'); + if !indented && !trimmed.is_empty() && !trimmed.starts_with('#') { + in_model = false; + } else if trimmed.starts_with("provider:") { + continue; + } + } + out.push(line.to_string()); + } + let mut fixed = out.join("\n"); + if !fixed.ends_with('\n') { + fixed.push('\n'); + } + std::fs::write(&config_path, fixed).map_err(|e| format!("写入 config.yaml 失败: {e}"))?; + Ok(true) +} + /// 重启 Gateway(kill 旧进程 → 启动新进程) async fn do_restart_gateway() -> Result<(), String> { // 1. 杀掉旧进程 @@ -1698,6 +1882,7 @@ pub async fn configure_hermes( // config.yaml 的 model.provider 字段。 use super::hermes_providers; + let provider = normalize_hermes_provider_for_base_url(&provider, base_url.as_deref()); let pcfg = hermes_providers::get_provider(&provider); // 模型标识:优先使用调用方传入,否则用 provider 的首个已知模型; @@ -1769,6 +1954,9 @@ platforms: if let Some(env) = key_env { if !api_key.trim().is_empty() { new_pairs.push((env.into(), api_key.trim().into())); + if provider == "custom" && env != "CUSTOM_API_KEY" { + new_pairs.push(("CUSTOM_API_KEY".into(), api_key.trim().into())); + } } } else if !api_key.trim().is_empty() { // OAuth provider 传了 api_key —— 记日志,不落盘 @@ -1952,6 +2140,7 @@ pub async fn hermes_read_config() -> Result { let home = hermes_home(); let config_path = home.join("config.yaml"); let env_path = home.join(".env"); + let _ = sanitize_hermes_openrouter_custom_mismatch(); // 读取 config.yaml let config_raw = std::fs::read_to_string(&config_path).unwrap_or_default(); @@ -2532,6 +2721,7 @@ pub async fn hermes_update_model( } std::fs::write(&config_path, new_content).map_err(|e| format!("写入 config.yaml 失败: {e}"))?; + let _ = sanitize_hermes_openrouter_custom_mismatch()?; Ok(format!("模型已切换为 {model_str}")) } @@ -2551,6 +2741,30 @@ pub async fn hermes_gateway_action( // before every start. Auto-heal if missing (with a .bak backup). // See `ensure_api_server_enabled` for rationale. ensure_api_server_enabled(&app)?; + let _ = sanitize_hermes_openrouter_custom_mismatch()?; + if gateway_quick_health_check().await { + start_guardian(&app); + emit_gateway_status(true); + return Ok("Gateway 已在运行".into()); + } + let _start_guard = if let Some(guard) = try_gateway_start_guard() { + guard + } else { + for _ in 0..40 { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + if gateway_quick_health_check().await { + start_guardian(&app); + emit_gateway_status(true); + return Ok("Gateway 已在运行".into()); + } + } + return Err("Gateway 正在启动中,请稍后重试".into()); + }; + if gateway_quick_health_check().await { + start_guardian(&app); + emit_gateway_status(true); + return Ok("Gateway 已在运行".into()); + } #[cfg(target_os = "windows")] { diff --git a/src-tauri/src/commands/hermes_providers.rs b/src-tauri/src/commands/hermes_providers.rs index 3e28c93..2e2516e 100644 --- a/src-tauri/src/commands/hermes_providers.rs +++ b/src-tauri/src/commands/hermes_providers.rs @@ -695,7 +695,7 @@ const P_CUSTOM: HermesProvider = HermesProvider { auth_type: AUTH_API_KEY, base_url: "", base_url_env_var: "OPENAI_BASE_URL", - api_key_env_vars: &["CUSTOM_API_KEY", "OPENAI_API_KEY"], + api_key_env_vars: &["OPENAI_API_KEY", "CUSTOM_API_KEY"], transport: TRANSPORT_OPENAI_CHAT, models_probe: PROBE_OPENAI, models: &[],