mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-07-10 23:12:05 +08:00
fix(hermes): stabilize gateway provider routing and startup
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 注册表
This commit is contained in:
@@ -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('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('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('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() {
|
function hermesHome() {
|
||||||
@@ -217,6 +217,7 @@ function diagnoseHermesInstallError(text = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _hermesGwProcess = null
|
let _hermesGwProcess = null
|
||||||
|
let _hermesGwStarting = null
|
||||||
|
|
||||||
const __dev_dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dev_dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const DEFAULT_OPENCLAW_DIR = path.join(homedir(), '.openclaw')
|
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']) {
|
for (const d of ['cron','sessions','logs','memories','skills','pairing','hooks','image_cache','audio_cache']) {
|
||||||
fs.mkdirSync(path.join(home, d), { recursive: true })
|
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 pcfg = HERMES_PROVIDER_REGISTRY.find(p => p.id === providerId)
|
||||||
const modelStr = (model || pcfg?.models?.[0] || '').trim()
|
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`)
|
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 urlEnv = pcfg?.baseUrlEnvVar || ''
|
||||||
const managedKeys = handlers._hermesManagedEnvKeys()
|
const managedKeys = handlers._hermesManagedEnvKeys()
|
||||||
const newPairs = [['GATEWAY_ALLOW_ALL_USERS', 'true'], ['API_SERVER_KEY', 'clawpanel-local']]
|
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])
|
if (urlEnv && baseUrlValue) newPairs.push([urlEnv, baseUrlValue])
|
||||||
const envPath = path.join(home, '.env')
|
const envPath = path.join(home, '.env')
|
||||||
let envContent
|
let envContent
|
||||||
@@ -6999,39 +7003,48 @@ const handlers = {
|
|||||||
try { this._hermesEnsureApiServerEnabled() } catch (e) {
|
try { this._hermesEnsureApiServerEnabled() } catch (e) {
|
||||||
console.warn('[hermes guardian] patch failed:', e.message || 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)
|
const alive = await _tcpProbe('127.0.0.1', port, 300)
|
||||||
if (alive) return 'Gateway 已在运行'
|
if (alive) return 'Gateway 已在运行'
|
||||||
// 启动
|
if (_hermesGwStarting) return await _hermesGwStarting
|
||||||
const home = hermesHome()
|
_hermesGwStarting = (async () => {
|
||||||
const envVars = { ...process.env, PATH: enhanced }
|
const aliveAfterWait = await _tcpProbe('127.0.0.1', port, 500)
|
||||||
const envPath = path.join(home, '.env')
|
if (aliveAfterWait) return 'Gateway 已在运行'
|
||||||
if (fs.existsSync(envPath)) {
|
// 启动
|
||||||
for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
|
const home = hermesHome()
|
||||||
const t = line.trim()
|
const envVars = { ...process.env, PATH: enhanced }
|
||||||
if (!t || t.startsWith('#')) continue
|
const envPath = path.join(home, '.env')
|
||||||
const eq = t.indexOf('=')
|
if (fs.existsSync(envPath)) {
|
||||||
if (eq > 0) envVars[t.slice(0, eq).trim()] = t.slice(eq + 1).trim()
|
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 logPath = path.join(home, 'gateway-run.log')
|
const logFd = fs.openSync(logPath, 'a')
|
||||||
const logFd = fs.openSync(logPath, 'a')
|
const child = spawn('hermes', ['gateway', 'run'], {
|
||||||
const child = spawn('hermes', ['gateway', 'run'], {
|
cwd: home, env: envVars, stdio: ['ignore', logFd, logFd],
|
||||||
cwd: home, env: envVars, stdio: ['ignore', logFd, logFd],
|
detached: true, windowsHide: true,
|
||||||
detached: true, windowsHide: true,
|
})
|
||||||
})
|
child.unref()
|
||||||
child.unref()
|
_hermesGwProcess = child
|
||||||
_hermesGwProcess = child
|
// 等端口可达
|
||||||
// 等端口可达
|
for (let i = 0; i < 40; i++) {
|
||||||
for (let i = 0; i < 40; i++) {
|
await new Promise(r => setTimeout(r, 500))
|
||||||
await new Promise(r => setTimeout(r, 500))
|
if (await _tcpProbe('127.0.0.1', port, 500)) {
|
||||||
if (await _tcpProbe('127.0.0.1', port, 500)) {
|
fs.closeSync(logFd)
|
||||||
fs.closeSync(logFd)
|
return 'Gateway 已启动'
|
||||||
return 'Gateway 已启动'
|
}
|
||||||
}
|
}
|
||||||
}
|
fs.closeSync(logFd)
|
||||||
fs.closeSync(logFd)
|
throw new Error('Gateway 启动后端口未就绪')
|
||||||
throw new Error('Gateway 启动后端口未就绪')
|
})().finally(() => { _hermesGwStarting = null })
|
||||||
|
return await _hermesGwStarting
|
||||||
}
|
}
|
||||||
if (action === 'stop') {
|
if (action === 'stop') {
|
||||||
if (_hermesGwProcess) { try { _hermesGwProcess.kill() } catch {} _hermesGwProcess = null }
|
if (_hermesGwProcess) { try { _hermesGwProcess.kill() } catch {} _hermesGwProcess = null }
|
||||||
@@ -7051,6 +7064,7 @@ const handlers = {
|
|||||||
async _hermesEnsureGatewayReady() {
|
async _hermesEnsureGatewayReady() {
|
||||||
const customUrl = hermesGatewayCustomUrl()
|
const customUrl = hermesGatewayCustomUrl()
|
||||||
if (customUrl && !isLoopbackGatewayUrl(customUrl)) return
|
if (customUrl && !isLoopbackGatewayUrl(customUrl)) return
|
||||||
|
try { _sanitizeHermesOpenrouterCustomMismatch() } catch {}
|
||||||
const port = hermesGatewayPort()
|
const port = hermesGatewayPort()
|
||||||
if (await _tcpProbe('127.0.0.1', port, 300)) return
|
if (await _tcpProbe('127.0.0.1', port, 300)) return
|
||||||
await this.hermes_gateway_action({ action: 'start' })
|
await this.hermes_gateway_action({ action: 'start' })
|
||||||
@@ -7124,6 +7138,7 @@ const handlers = {
|
|||||||
const home = hermesHome()
|
const home = hermesHome()
|
||||||
const configPath = path.join(home, 'config.yaml')
|
const configPath = path.join(home, 'config.yaml')
|
||||||
const envPath = path.join(home, '.env')
|
const envPath = path.join(home, '.env')
|
||||||
|
try { _sanitizeHermesOpenrouterCustomMismatch() } catch {}
|
||||||
let modelName = '', baseUrl = '', provider = '', apiKey = ''
|
let modelName = '', baseUrl = '', provider = '', apiKey = ''
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(configPath, 'utf8')
|
const content = fs.readFileSync(configPath, 'utf8')
|
||||||
@@ -8691,6 +8706,112 @@ function _mergeEnvFile(existing, managedKeys, newPairs) {
|
|||||||
return content
|
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) {
|
function _tcpProbe(host, port, timeoutMs) {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
const sock = new net.Socket()
|
const sock = new net.Socket()
|
||||||
|
|||||||
@@ -28,9 +28,25 @@ static GW_PID: AtomicU32 = AtomicU32::new(0);
|
|||||||
static GW_GUARDIAN_ACTIVE: AtomicBool = AtomicBool::new(false);
|
static GW_GUARDIAN_ACTIVE: AtomicBool = AtomicBool::new(false);
|
||||||
/// 通知 guardian 停止的 flag
|
/// 通知 guardian 停止的 flag
|
||||||
static GW_GUARDIAN_STOP: AtomicBool = AtomicBool::new(false);
|
static GW_GUARDIAN_STOP: AtomicBool = AtomicBool::new(false);
|
||||||
|
static GW_STARTING: AtomicBool = AtomicBool::new(false);
|
||||||
/// 缓存 AppHandle 供 guardian 发送事件
|
/// 缓存 AppHandle 供 guardian 发送事件
|
||||||
static GW_APP_HANDLE: OnceLock<tauri::AppHandle> = OnceLock::new();
|
static GW_APP_HANDLE: OnceLock<tauri::AppHandle> = OnceLock::new();
|
||||||
|
|
||||||
|
struct GatewayStartGuard;
|
||||||
|
|
||||||
|
impl Drop for GatewayStartGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
GW_STARTING.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_gateway_start_guard() -> Option<GatewayStartGuard> {
|
||||||
|
GW_STARTING
|
||||||
|
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||||
|
.ok()
|
||||||
|
.map(|_| GatewayStartGuard)
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取 Gateway 的完整 URL(当前本地,未来可扩展为远程)
|
/// 获取 Gateway 的完整 URL(当前本地,未来可扩展为远程)
|
||||||
fn hermes_gateway_custom_url() -> Option<String> {
|
fn hermes_gateway_custom_url() -> Option<String> {
|
||||||
super::read_panel_config_value()
|
super::read_panel_config_value()
|
||||||
@@ -84,6 +100,7 @@ async fn ensure_managed_gateway_ready(app: &tauri::AppHandle, gw_url: &str) -> R
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let _ = sanitize_hermes_openrouter_custom_mismatch()?;
|
||||||
if gateway_quick_health_check().await {
|
if gateway_quick_health_check().await {
|
||||||
start_guardian(app);
|
start_guardian(app);
|
||||||
emit_gateway_status(true);
|
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<String> {
|
||||||
|
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<bool, String> {
|
||||||
|
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<bool, String> {
|
||||||
|
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 旧进程 → 启动新进程)
|
/// 重启 Gateway(kill 旧进程 → 启动新进程)
|
||||||
async fn do_restart_gateway() -> Result<(), String> {
|
async fn do_restart_gateway() -> Result<(), String> {
|
||||||
// 1. 杀掉旧进程
|
// 1. 杀掉旧进程
|
||||||
@@ -1698,6 +1882,7 @@ pub async fn configure_hermes(
|
|||||||
// config.yaml 的 model.provider 字段。
|
// config.yaml 的 model.provider 字段。
|
||||||
use super::hermes_providers;
|
use super::hermes_providers;
|
||||||
|
|
||||||
|
let provider = normalize_hermes_provider_for_base_url(&provider, base_url.as_deref());
|
||||||
let pcfg = hermes_providers::get_provider(&provider);
|
let pcfg = hermes_providers::get_provider(&provider);
|
||||||
|
|
||||||
// 模型标识:优先使用调用方传入,否则用 provider 的首个已知模型;
|
// 模型标识:优先使用调用方传入,否则用 provider 的首个已知模型;
|
||||||
@@ -1769,6 +1954,9 @@ platforms:
|
|||||||
if let Some(env) = key_env {
|
if let Some(env) = key_env {
|
||||||
if !api_key.trim().is_empty() {
|
if !api_key.trim().is_empty() {
|
||||||
new_pairs.push((env.into(), api_key.trim().into()));
|
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() {
|
} else if !api_key.trim().is_empty() {
|
||||||
// OAuth provider 传了 api_key —— 记日志,不落盘
|
// OAuth provider 传了 api_key —— 记日志,不落盘
|
||||||
@@ -1952,6 +2140,7 @@ pub async fn hermes_read_config() -> Result<Value, String> {
|
|||||||
let home = hermes_home();
|
let home = hermes_home();
|
||||||
let config_path = home.join("config.yaml");
|
let config_path = home.join("config.yaml");
|
||||||
let env_path = home.join(".env");
|
let env_path = home.join(".env");
|
||||||
|
let _ = sanitize_hermes_openrouter_custom_mismatch();
|
||||||
|
|
||||||
// 读取 config.yaml
|
// 读取 config.yaml
|
||||||
let config_raw = std::fs::read_to_string(&config_path).unwrap_or_default();
|
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}"))?;
|
std::fs::write(&config_path, new_content).map_err(|e| format!("写入 config.yaml 失败: {e}"))?;
|
||||||
|
let _ = sanitize_hermes_openrouter_custom_mismatch()?;
|
||||||
Ok(format!("模型已切换为 {model_str}"))
|
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).
|
// before every start. Auto-heal if missing (with a .bak backup).
|
||||||
// See `ensure_api_server_enabled` for rationale.
|
// See `ensure_api_server_enabled` for rationale.
|
||||||
ensure_api_server_enabled(&app)?;
|
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")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -695,7 +695,7 @@ const P_CUSTOM: HermesProvider = HermesProvider {
|
|||||||
auth_type: AUTH_API_KEY,
|
auth_type: AUTH_API_KEY,
|
||||||
base_url: "",
|
base_url: "",
|
||||||
base_url_env_var: "OPENAI_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,
|
transport: TRANSPORT_OPENAI_CHAT,
|
||||||
models_probe: PROBE_OPENAI,
|
models_probe: PROBE_OPENAI,
|
||||||
models: &[],
|
models: &[],
|
||||||
|
|||||||
Reference in New Issue
Block a user