From 562261d13f59093709f7a7699b38c06ace586782 Mon Sep 17 00:00:00 2001 From: ekko Date: Sun, 19 Apr 2026 20:59:25 +0800 Subject: [PATCH] feat: multi-gateway profile support, provider management overhaul, and model settings tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Profile-aware proxy: inject API key from profile-specific .env, route requests via X-Hermes-Profile header - Remove auth.json dependency: built-in providers use .env, custom providers use config.yaml - Add allProviders field to available-models response with all hardcoded provider catalogs - Add Models tab in Settings for editing provider API keys (built-in → .env, custom → config.yaml) - Add PUT /api/config/providers/:poolKey for updating provider credentials - ProviderFormModal uses backend allProviders for preset dropdown - Gateway log format support: parse both agent and gateway log formats - Add webui server.log to log viewer with log rotation at 3MB - Fix provider delete loading state and OAuth provider cleanup - Setup script: require Node.js 23+, auto-upgrade if version too low Co-Authored-By: Claude Opus 4.6 --- bin/hermes-web-ui.mjs | 16 +- package.json | 2 +- packages/client/src/api/client.ts | 6 + packages/client/src/api/hermes/chat.ts | 7 +- packages/client/src/api/hermes/system.ts | 14 + .../components/hermes/models/ProviderCard.vue | 8 +- .../hermes/models/ProviderFormModal.vue | 33 +- .../hermes/settings/ModelSettings.vue | 192 ++++++++ packages/client/src/i18n/locales/en.ts | 9 + packages/client/src/i18n/locales/zh.ts | 9 + packages/client/src/stores/hermes/models.ts | 3 + .../client/src/views/hermes/SettingsView.vue | 4 + .../server/src/routes/hermes/filesystem.ts | 457 +++++++++--------- packages/server/src/routes/hermes/logs.ts | 64 ++- .../server/src/routes/hermes/proxy-handler.ts | 24 +- .../src/services/hermes/gateway-manager.ts | 14 + .../src/services/hermes/hermes-profile.ts | 11 + packages/server/src/shared/providers.ts | 14 +- scripts/setup.sh | 24 +- 19 files changed, 635 insertions(+), 276 deletions(-) create mode 100644 packages/client/src/components/hermes/settings/ModelSettings.vue diff --git a/bin/hermes-web-ui.mjs b/bin/hermes-web-ui.mjs index 2a9ba49..e27ceb8 100755 --- a/bin/hermes-web-ui.mjs +++ b/bin/hermes-web-ui.mjs @@ -2,7 +2,7 @@ import { spawn, execSync } from 'child_process' import { resolve, dirname, join } from 'path' import { fileURLToPath } from 'url' -import { readFileSync, writeFileSync, unlinkSync, mkdirSync, openSync, chmodSync } from 'fs' +import { readFileSync, writeFileSync, unlinkSync, mkdirSync, openSync, chmodSync, statSync } from 'fs' import { randomBytes } from 'crypto' import { homedir } from 'os' @@ -118,6 +118,20 @@ function startDaemon(port) { ensureNativeModules() const token = ensureToken() + // Rotate log if over 3MB — keep last 2000 lines + const MAX_LOG_SIZE = 3 * 1024 * 1024 + const MAX_LOG_LINES = 2000 + try { + const stat = statSync(LOG_FILE) + if (stat.size > MAX_LOG_SIZE) { + const content = readFileSync(LOG_FILE, 'utf-8') + const lines = content.split('\n') + const kept = lines.slice(-MAX_LOG_LINES) + writeFileSync(LOG_FILE, kept.join('\n'), 'utf-8') + console.log(` ↻ Log rotated (${(stat.size / 1024 / 1024).toFixed(1)}MB → ${kept.length} lines)`) + } + } catch { } + const logStream = openSync(LOG_FILE, 'a') const child = spawn(process.execPath, [serverEntry], { detached: true, diff --git a/package.json b/package.json index c9bce05..9be3eb2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hermes-web-ui", - "version": "0.3.6", + "version": "0.3.7", "description": "Web dashboard for Hermes Agent — multi-platform AI chat, session management, scheduled jobs, usage analytics & channel configuration (Telegram, Discord, Slack, WhatsApp)", "repository": { "type": "git", diff --git a/packages/client/src/api/client.ts b/packages/client/src/api/client.ts index a94bbcf..289676a 100644 --- a/packages/client/src/api/client.ts +++ b/packages/client/src/api/client.ts @@ -39,6 +39,12 @@ export async function request(path: string, options: RequestInit = {}): Promi headers['Authorization'] = `Bearer ${apiKey}` } + // Inject active profile header for proxied gateway requests + const profileName = localStorage.getItem('hermes_active_profile_name') + if (profileName && profileName !== 'default') { + headers['X-Hermes-Profile'] = profileName + } + const res = await fetch(url, { ...options, headers }) // Global 401 handler — only redirect to login for local BFF endpoints diff --git a/packages/client/src/api/hermes/chat.ts b/packages/client/src/api/hermes/chat.ts index 952a043..a6f51cc 100644 --- a/packages/client/src/api/hermes/chat.ts +++ b/packages/client/src/api/hermes/chat.ts @@ -45,7 +45,12 @@ export function streamRunEvents( ) { const baseUrl = getBaseUrlValue() const token = getApiKey() - const url = `${baseUrl}/api/hermes/v1/runs/${runId}/events${token ? `?token=${encodeURIComponent(token)}` : ''}` + const profile = localStorage.getItem('hermes_active_profile_name') + const params = new URLSearchParams() + if (token) params.set('token', token) + if (profile && profile !== 'default') params.set('profile', profile) + const qs = params.toString() + const url = `${baseUrl}/api/hermes/v1/runs/${runId}/events${qs ? `?${qs}` : ''}` let closed = false const source = new EventSource(url) diff --git a/packages/client/src/api/hermes/system.ts b/packages/client/src/api/hermes/system.ts index 4835202..16c3f06 100644 --- a/packages/client/src/api/hermes/system.ts +++ b/packages/client/src/api/hermes/system.ts @@ -29,12 +29,14 @@ export interface AvailableModelGroup { label: string // display name (e.g. "zai", "subrouter.ai") base_url: string models: string[] + api_key: string } export interface AvailableModelsResponse { default: string default_provider: string groups: AvailableModelGroup[] + allProviders: AvailableModelGroup[] } export interface CustomProvider { @@ -85,3 +87,15 @@ export async function removeCustomProvider(name: string): Promise { method: 'DELETE', }) } + +export async function updateProvider(poolKey: string, data: { + name?: string + base_url?: string + api_key?: string + model?: string +}): Promise { + await request(`/api/hermes/config/providers/${encodeURIComponent(poolKey)}`, { + method: 'PUT', + body: JSON.stringify(data), + }) +} diff --git a/packages/client/src/components/hermes/models/ProviderCard.vue b/packages/client/src/components/hermes/models/ProviderCard.vue index a31a80a..9aad944 100644 --- a/packages/client/src/components/hermes/models/ProviderCard.vue +++ b/packages/client/src/components/hermes/models/ProviderCard.vue @@ -1,5 +1,5 @@ + + + + diff --git a/packages/client/src/i18n/locales/en.ts b/packages/client/src/i18n/locales/en.ts index 1de19d7..50185c8 100644 --- a/packages/client/src/i18n/locales/en.ts +++ b/packages/client/src/i18n/locales/en.ts @@ -306,6 +306,15 @@ export default { session: 'Session', privacy: 'Privacy', apiServer: 'API Server', + models: 'Models', + }, + models: { + apiKey: 'API Key', + apiKeyPlaceholder: 'Enter API key', + save: 'Save', + saved: 'Saved', + saveFailed: 'Save failed', + noProviders: 'No providers configured', }, display: { streaming: 'Stream Responses', diff --git a/packages/client/src/i18n/locales/zh.ts b/packages/client/src/i18n/locales/zh.ts index 94e88c1..2297fda 100644 --- a/packages/client/src/i18n/locales/zh.ts +++ b/packages/client/src/i18n/locales/zh.ts @@ -298,6 +298,15 @@ export default { session: '会话', privacy: '隐私', apiServer: 'API 服务器', + models: '模型', + }, + models: { + apiKey: 'API Key', + apiKeyPlaceholder: '输入 API Key', + save: '保存', + saved: '已保存', + saveFailed: '保存失败', + noProviders: '暂无已配置的模型', }, display: { streaming: '流式响应', diff --git a/packages/client/src/stores/hermes/models.ts b/packages/client/src/stores/hermes/models.ts index ec26202..07a287c 100644 --- a/packages/client/src/stores/hermes/models.ts +++ b/packages/client/src/stores/hermes/models.ts @@ -6,6 +6,7 @@ import { useAppStore } from './app' export const useModelsStore = defineStore('models', () => { const providers = ref([]) + const allProviders = ref([]) const defaultModel = ref('') const loading = ref(false) @@ -34,6 +35,7 @@ export const useModelsStore = defineStore('models', () => { try { const res = await systemApi.fetchAvailableModels() providers.value = res.groups + allProviders.value = res.allProviders defaultModel.value = res.default } catch (err) { console.error('Failed to fetch providers:', err) @@ -65,6 +67,7 @@ export const useModelsStore = defineStore('models', () => { return { providers, + allProviders, defaultModel, loading, customProviders, diff --git a/packages/client/src/views/hermes/SettingsView.vue b/packages/client/src/views/hermes/SettingsView.vue index 2db7bda..e4662dc 100644 --- a/packages/client/src/views/hermes/SettingsView.vue +++ b/packages/client/src/views/hermes/SettingsView.vue @@ -12,6 +12,7 @@ import AgentSettings from "@/components/hermes/settings/AgentSettings.vue"; import MemorySettings from "@/components/hermes/settings/MemorySettings.vue"; import SessionSettings from "@/components/hermes/settings/SessionSettings.vue"; import PrivacySettings from "@/components/hermes/settings/PrivacySettings.vue"; +import ModelSettings from "@/components/hermes/settings/ModelSettings.vue"; const settingsStore = useSettingsStore(); const { t } = useI18n(); @@ -49,6 +50,9 @@ onMounted(() => { + + + diff --git a/packages/server/src/routes/hermes/filesystem.ts b/packages/server/src/routes/hermes/filesystem.ts index 1b1812c..86c6ee4 100644 --- a/packages/server/src/routes/hermes/filesystem.ts +++ b/packages/server/src/routes/hermes/filesystem.ts @@ -1,33 +1,33 @@ import Router from '@koa/router' import { readdir, readFile, stat, writeFile, mkdir, copyFile } from 'fs/promises' +import { existsSync, readFileSync } from 'fs' import { join, resolve } from 'path' import YAML from 'js-yaml' -import { getActiveProfileDir, getActiveConfigPath, getActiveAuthPath, getActiveEnvPath } from '../../services/hermes/hermes-profile' +import { getActiveProfileDir, getActiveConfigPath, getActiveEnvPath, getActiveAuthPath } from '../../services/hermes/hermes-profile' import * as hermesCli from '../../services/hermes/hermes-cli' // --- Provider env var mapping (from hermes providers.py HERMES_OVERLAYS + config.py) --- // Maps provider key → { api_key_envs: all env var aliases for API key, base_url_env: env var for base URL } const PROVIDER_ENV_MAP: Record = { - openrouter: { api_key_env: 'OPENROUTER_API_KEY', base_url_env: 'OPENROUTER_BASE_URL' }, - zai: { api_key_env: 'ZAI_API_KEY', base_url_env: '' }, - 'kimi-coding': { api_key_env: 'KIMI_API_KEY', base_url_env: '' }, - 'kimi-coding-cn': { api_key_env: 'KIMI_API_KEY', base_url_env: '' }, - moonshot: { api_key_env: 'MOONSHOT_API_KEY', base_url_env: 'MOONSHOT_BASE_URL' }, - minimax: { api_key_env: 'MINIMAX_API_KEY', base_url_env: 'MINIMAX_BASE_URL' }, - 'minimax-cn': { api_key_env: 'MINIMAX_API_KEY', base_url_env: 'MINIMAX_CN_BASE_URL' }, - deepseek: { api_key_env: 'DEEPSEEK_API_KEY', base_url_env: 'DEEPSEEK_BASE_URL' }, - alibaba: { api_key_env: 'DASHSCOPE_API_KEY', base_url_env: 'DASHSCOPE_BASE_URL' }, + openrouter: { api_key_env: 'OPENROUTER_API_KEY', base_url_env: '' }, + zai: { api_key_env: 'GLM_API_KEY', base_url_env: '' }, + 'kimi-coding-cn': { api_key_env: 'KIMI_CN_API_KEY', base_url_env: '' }, + moonshot: { api_key_env: 'MOONSHOT_API_KEY', base_url_env: '' }, + minimax: { api_key_env: 'MINIMAX_API_KEY', base_url_env: '' }, + 'minimax-cn': { api_key_env: 'MINIMAX_CN_API_KEY', base_url_env: '' }, + deepseek: { api_key_env: 'DEEPSEEK_API_KEY', base_url_env: '' }, + alibaba: { api_key_env: 'DASHSCOPE_API_KEY', base_url_env: '' }, anthropic: { api_key_env: 'ANTHROPIC_API_KEY', base_url_env: '' }, - xai: { api_key_env: 'XAI_API_KEY', base_url_env: 'XAI_BASE_URL' }, - xiaomi: { api_key_env: 'XIAOMI_API_KEY', base_url_env: 'XIAOMI_BASE_URL' }, + xai: { api_key_env: 'XAI_API_KEY', base_url_env: '' }, + xiaomi: { api_key_env: 'XIAOMI_API_KEY', base_url_env: '' }, gemini: { api_key_env: 'GEMINI_API_KEY', base_url_env: '' }, - kilocode: { api_key_env: 'KILO_API_KEY', base_url_env: 'KILOCODE_BASE_URL' }, + kilocode: { api_key_env: 'KILO_API_KEY', base_url_env: '' }, 'ai-gateway': { api_key_env: 'AI_GATEWAY_API_KEY', base_url_env: '' }, - 'opencode-zen': { api_key_env: 'OPENCODE_API_KEY', base_url_env: 'OPENCODE_ZEN_BASE_URL' }, - 'opencode-go': { api_key_env: 'OPENCODE_API_KEY', base_url_env: 'OPENCODE_GO_BASE_URL' }, - huggingface: { api_key_env: 'HF_TOKEN', base_url_env: 'HF_BASE_URL' }, + 'opencode-zen': { api_key_env: 'OPENCODE_API_KEY', base_url_env: '' }, + 'opencode-go': { api_key_env: 'OPENCODE_API_KEY', base_url_env: '' }, + huggingface: { api_key_env: 'HF_TOKEN', base_url_env: '' }, arcee: { api_key_env: 'ARCEE_API_KEY', base_url_env: '' }, - 'openai-codex': { api_key_env: '', base_url_env: 'HERMES_CODEX_BASE_URL' }, + 'openai-codex': { api_key_env: '', base_url_env: '' }, } async function saveEnvValue(key: string, value: string): Promise { @@ -66,33 +66,6 @@ async function saveEnvValue(key: string, value: string): Promise { // --- Auth / Credential Pool --- -interface CredentialPoolEntry { - id: string - label: string - base_url: string - access_token: string - last_status?: string | null -} - -interface AuthJson { - credential_pool?: Record -} - -const authPath = () => getActiveAuthPath() - -async function loadAuthJson(): Promise { - try { - const raw = await readFile(authPath(), 'utf-8') - return JSON.parse(raw) as AuthJson - } catch { - return null - } -} - -async function saveAuthJson(auth: AuthJson): Promise { - await writeFile(authPath(), JSON.stringify(auth, null, 2) + '\n', 'utf-8') -} - async function fetchProviderModels(baseUrl: string, apiKey: string): Promise { try { const url = baseUrl.replace(/\/+$/, '') + '/models' @@ -101,12 +74,12 @@ async function fetchProviderModels(baseUrl: string, apiKey: string): Promise } if (!Array.isArray(data.data)) { - console.error(`[available-models] ${baseUrl} returned unexpected format`) + console.warn(`[available-models] ${baseUrl} returned unexpected format`) return [] } return data.data.map(m => m.id).sort() @@ -449,20 +422,12 @@ function buildModelGroups(config: Record): { default: string; group } } - // 3. Add current default model (if not already in custom_providers) - if (defaultModel && !allModelIds.has(defaultModel)) { - groups.unshift({ provider: 'Current', models: [{ id: defaultModel, label: defaultModel }] }) - } - return { default: defaultModel, groups } } -// GET /api/available-models — fetch models from all credential pool endpoints +// GET /api/available-models — resolve models from .env authorized providers + credential pool + custom providers fsRoutes.get('/api/hermes/available-models', async (ctx) => { try { - const auth = await loadAuthJson() - const pool = auth?.credential_pool || {} - const config = await readConfigYaml() const modelSection = config.model let currentDefault = '' @@ -474,127 +439,125 @@ fsRoutes.get('/api/hermes/available-models', async (ctx) => { currentDefault = modelSection.trim() } - // Collect unique endpoints from credential pool - const endpoints: Array<{ key: string; label: string; base_url: string; token: string }> = [] - const seenUrls = new Set() + const groups: Array<{ provider: string; label: string; base_url: string; models: string[]; api_key: string }> = [] + const seenProviders = new Set() - for (const [providerKey, entries] of Object.entries(pool)) { - if (!Array.isArray(entries) || entries.length === 0) continue - const entry = entries.find(e => e.last_status !== 'exhausted') || entries[0] - if (!entry?.base_url || !entry?.access_token) continue - const baseUrl = entry.base_url.replace(/\/+$/, '') - if (seenUrls.has(baseUrl)) continue - seenUrls.add(baseUrl) - endpoints.push({ - key: providerKey, - label: providerKey.replace(/^custom:/, '') || entry.label || baseUrl, - base_url: baseUrl, - token: entry.access_token, - }) + // 1. Read .env to discover authorized providers via PROVIDER_ENV_MAP + let envContent = '' + try { + envContent = await readFile(getActiveEnvPath(), 'utf-8') + } catch { } + + const envHasValue = (key: string): boolean => { + if (!key) return false + const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm')) + return !!match && match[1].trim() !== '' && !match[1].trim().startsWith('#') } - // Resolve models: hardcoded catalog first, live probe as fallback - const groups: Array<{ provider: string; label: string; base_url: string; models: string[] }> = [] - const liveEndpoints: typeof endpoints = [] + const envGetValue = (key: string): string => { + if (!key) return '' + const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm')) + return match?.[1]?.trim() || '' + } - for (const ep of endpoints) { - const catalogModels = PROVIDER_MODEL_CATALOG[ep.key] - if (catalogModels && catalogModels.length > 0) { - groups.push({ provider: ep.key, label: ep.label, base_url: ep.base_url, models: catalogModels }) - } else { - liveEndpoints.push(ep) + const addGroup = (provider: string, label: string, base_url: string, models: string[], api_key: string) => { + if (seenProviders.has(provider)) return + seenProviders.add(provider) + groups.push({ provider, label, base_url, models: [...models], api_key }) + } + + // Import PROVIDER_PRESETS for label + base_url lookup + const { PROVIDER_PRESETS } = await import('../../shared/providers') + + // 1. Authorized providers from .env + OAuth-based providers (no api_key_env) + // Check OAuth auth (e.g. openai-codex) via auth.json + const isOAuthAuthorized = (providerKey: string): boolean => { + try { + const authPath = getActiveAuthPath() + if (!existsSync(authPath)) return false + const auth = JSON.parse(readFileSync(authPath, 'utf-8')) + return !!auth.providers?.[providerKey]?.tokens?.access_token + } catch { + return false } } - if (liveEndpoints.length > 0) { - const results = await Promise.allSettled( - liveEndpoints.map(async ep => { - const models = await fetchProviderModels(ep.base_url, ep.token) - return { ...ep, models } - }), - ) + for (const [providerKey, envMapping] of Object.entries(PROVIDER_ENV_MAP)) { + // Skip providers that require API key but don't have one configured + if (envMapping.api_key_env && !envHasValue(envMapping.api_key_env)) continue + // Skip OAuth providers that haven't been authenticated + if (!envMapping.api_key_env && !isOAuthAuthorized(providerKey)) continue + const preset = PROVIDER_PRESETS.find(p => p.value === providerKey) + const label = preset?.label || providerKey.replace(/^custom:/, '') + const baseUrl = preset?.base_url || '' + const catalogModels = PROVIDER_MODEL_CATALOG[providerKey] + if (catalogModels && catalogModels.length > 0) { + const apiKey = envMapping.api_key_env ? envGetValue(envMapping.api_key_env) : '' + addGroup(providerKey, label, baseUrl, catalogModels, apiKey) + } + } - for (const result of results) { - if (result.status === 'fulfilled' && result.value.models.length > 0) { - const { key, label, base_url, models } = result.value - groups.push({ provider: key, label, base_url, models: Array.from(new Set(models)) }) - } else if (result.status === 'rejected') { - console.error(`[available-models] Failed: ${result.reason?.message || result.reason}`) + // 2. Custom providers from config.yaml — dynamically fetch models + const customProviders = Array.isArray(config.custom_providers) + ? config.custom_providers as Array<{ name: string; base_url: string; model: string; api_key?: string }> + : [] + + const customFetches = await Promise.allSettled( + customProviders.map(async cp => { + if (!cp.base_url) return null + const providerKey = `custom:${cp.name.trim().toLowerCase().replace(/ /g, '-')}` + const baseUrl = cp.base_url.replace(/\/+$/, '') + let models = [cp.model] // always include the statically configured model + if (cp.api_key) { + try { + const fetched = await fetchProviderModels(baseUrl, cp.api_key) + if (fetched.length > 0) models = fetched + } catch { } + } + return { providerKey, label: cp.name, base_url: baseUrl, models, api_key: cp.api_key || '' } + }), + ) + + for (const result of customFetches) { + if (result.status === 'fulfilled' && result.value) { + const { providerKey, label, base_url, models, api_key: cpApiKey } = result.value + const existing = groups.find(g => g.base_url.replace(/\/+$/, '') === base_url) + if (existing) { + for (const m of models) { + if (!existing.models.includes(m)) existing.models.push(m) + } + } else { + addGroup(providerKey, label, base_url, models, cpApiKey) } } } - // Deduplicate models within each group and merge groups with the same provider key - const dedupedGroups: typeof groups = [] - const seenProviders = new Map() + // Deduplicate models within each group for (const g of groups) { g.models = Array.from(new Set(g.models)) - const existingIdx = seenProviders.get(g.provider) - if (existingIdx !== undefined) { - // Merge models into existing group - const existing = dedupedGroups[existingIdx] - const existingSet = new Set(existing.models) - for (const m of g.models) { - if (!existingSet.has(m)) existing.models.push(m) - } - } else { - seenProviders.set(g.provider, dedupedGroups.length) - dedupedGroups.push(g) - } - } - - // Merge custom_providers from config.yaml (ensures manually-input model names appear) - const customProviders = Array.isArray(config.custom_providers) - ? config.custom_providers as Array<{ name: string; base_url: string; model: string }> - : [] - for (const cp of customProviders) { - if (!cp.base_url || !cp.model) continue - const baseUrl = cp.base_url.replace(/\/+$/, '') - // Check if we already have a group for this base_url - const existing = dedupedGroups.find(g => g.base_url.replace(/\/+$/, '') === baseUrl) - if (existing) { - if (!existing.models.includes(cp.model)) { - existing.models.push(cp.model) - } - } else { - dedupedGroups.push({ - provider: `custom:${cp.name.trim().toLowerCase().replace(/ /g, '-')}`, - label: cp.name, - base_url: baseUrl, - models: [cp.model], - }) - } - } - - // Ensure config's current default model appears in the model list - if (currentDefault) { - const currentProvider = typeof config.model === 'object' ? String(config.model.provider || '').trim() : '' - if (currentProvider) { - const targetGroup = dedupedGroups.find(g => g.provider === currentProvider) - if (targetGroup && !targetGroup.models.includes(currentDefault)) { - targetGroup.models.unshift(currentDefault) - } - } else { - // No provider specified — add to the first group that matches via base_url - // or just prepend to all groups - let found = false - for (const g of dedupedGroups) { - if (!found && !g.models.includes(currentDefault)) { - g.models.unshift(currentDefault) - found = true - } - } - } } // Fallback: if still no providers, fall back to config.yaml parsing - if (dedupedGroups.length === 0) { + if (groups.length === 0) { const fallback = buildModelGroups(config) - ctx.body = fallback + const allProviders = PROVIDER_PRESETS.map(p => ({ + provider: p.value, + label: p.label, + base_url: p.base_url, + models: p.models, + })) + ctx.body = { ...fallback, allProviders } return } - ctx.body = { default: currentDefault, default_provider: currentDefaultProvider, groups: dedupedGroups } + const allProviders = PROVIDER_PRESETS.map(p => ({ + provider: p.value, + label: p.label, + base_url: p.base_url, + models: p.models, + })) + + ctx.body = { default: currentDefault, default_provider: currentDefaultProvider, groups, allProviders } } catch (err: any) { ctx.status = 500 ctx.body = { error: err.message } @@ -683,21 +646,6 @@ fsRoutes.post('/api/hermes/config/providers', async (ctx) => { await writeConfigYaml(config) } - // Write to auth.json credential_pool (all providers) - const auth = await loadAuthJson() || { credential_pool: {} } - if (!auth.credential_pool) auth.credential_pool = {} - if (!auth.credential_pool[poolKey]) { - auth.credential_pool[poolKey] = [] - } - auth.credential_pool[poolKey].push({ - id: `${poolKey}-${Date.now()}`, - label: name, - base_url, - access_token: api_key, - last_status: null, - }) - await saveAuthJson(auth) - // Write API key to .env (built-in providers only) const envMapping = PROVIDER_ENV_MAP[poolKey] || PROVIDER_ENV_MAP[providerKey || ''] if (envMapping) { @@ -730,75 +678,134 @@ fsRoutes.post('/api/hermes/config/providers', async (ctx) => { } }) +// PUT /api/config/providers/:poolKey — update existing provider +fsRoutes.put('/api/hermes/config/providers/:poolKey', async (ctx) => { + const poolKey = decodeURIComponent(ctx.params.poolKey) + const { name, base_url, api_key, model } = ctx.request.body as { + name?: string + base_url?: string + api_key?: string + model?: string + } + + try { + const isCustom = poolKey.startsWith('custom:') + + if (isCustom) { + // Update custom provider in config.yaml + const config = await readConfigYaml() + if (!Array.isArray(config.custom_providers)) { + ctx.status = 404 + ctx.body = { error: `Custom provider "${poolKey}" not found` } + return + } + const entry = (config.custom_providers as any[]).find((e: any) => { + const key = `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` + return key === poolKey + }) + if (!entry) { + ctx.status = 404 + ctx.body = { error: `Custom provider "${poolKey}" not found` } + return + } + if (name !== undefined) entry.name = name + if (base_url !== undefined) entry.base_url = base_url + if (api_key !== undefined) entry.api_key = api_key + if (model !== undefined) entry.model = model + await writeConfigYaml(config) + } else { + // Built-in provider: update API key in .env + const envMapping = PROVIDER_ENV_MAP[poolKey] + if (!envMapping?.api_key_env) { + // OAuth provider — cannot update key + ctx.status = 400 + ctx.body = { error: `Cannot update credentials for "${poolKey}"` } + return + } + if (api_key !== undefined) { + await saveEnvValue(envMapping.api_key_env, api_key) + } + } + + // Restart gateway to pick up changes + try { + await hermesCli.restartGateway() + } catch (e: any) { + console.error('[Provider] Gateway restart failed:', e.message) + } + + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +}) + // DELETE /api/config/providers/:poolKey fsRoutes.delete('/api/hermes/config/providers/:poolKey', async (ctx) => { const poolKey = decodeURIComponent(ctx.params.poolKey) try { - const auth = await loadAuthJson() - if (!auth?.credential_pool) { - ctx.status = 404 - ctx.body = { error: 'No credential pool found' } - return - } + const config = await readConfigYaml() + const isCustom = poolKey.startsWith('custom:') - const keys = Object.keys(auth.credential_pool) - - if (keys.length <= 1) { - ctx.status = 400 - ctx.body = { error: 'Cannot delete the last provider' } - return - } - - // Case-insensitive key lookup: normalize poolKey to match credential_pool - let resolvedKey = poolKey - if (!(poolKey in auth.credential_pool)) { - const normalized = poolKey.toLowerCase() - const match = Object.keys(auth.credential_pool).find(k => k.toLowerCase() === normalized) - if (!match) { + if (isCustom) { + // Delete from config.yaml custom_providers + const idx = Array.isArray(config.custom_providers) + ? (config.custom_providers as any[]).findIndex((e: any) => { + const key = `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` + return key === poolKey + }) + : -1 + if (idx === -1) { ctx.status = 404 - ctx.body = { error: `Provider "${poolKey}" not found` } + ctx.body = { error: `Custom provider "${poolKey}" not found` } return } - resolvedKey = match - } - - // Check if this is the current active provider - const config = await readConfigYaml() - const currentProvider = config.model?.provider - const isCurrent = currentProvider === poolKey || currentProvider === resolvedKey - - // Save base_url before deleting - const deletedBaseUrl = auth.credential_pool[resolvedKey]?.[0]?.base_url - - // 1. Delete from auth.json - delete auth.credential_pool[resolvedKey] - await saveAuthJson(auth) - - // 2. Remove matching entry from config.yaml custom_providers - if (deletedBaseUrl && Array.isArray(config.custom_providers)) { - config.custom_providers = (config.custom_providers as any[]).filter( - (entry: any) => entry.base_url !== deletedBaseUrl, - ) + (config.custom_providers as any[]).splice(idx, 1) await writeConfigYaml(config) + } else { + // Built-in provider: remove API key from .env + const envMapping = PROVIDER_ENV_MAP[poolKey] + if (envMapping?.api_key_env) { + await saveEnvValue(envMapping.api_key_env, '') + } else if (!envMapping?.api_key_env) { + // OAuth provider (e.g. openai-codex): clear tokens from auth.json + try { + const authPath = getActiveAuthPath() + if (existsSync(authPath)) { + const auth = JSON.parse(readFileSync(authPath, 'utf-8')) + if (auth.providers?.[poolKey]) { + delete auth.providers[poolKey] + } + if (auth.credential_pool?.[poolKey]) { + delete auth.credential_pool[poolKey] + } + const { writeFile: wfs } = await import('fs/promises') + await wfs(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8') + } + } catch (err: any) { + console.error(`[Provider] Failed to clear OAuth tokens for ${poolKey}:`, err.message) + } + } } - // 3. If was the current provider, switch to first remaining + // If was the current provider, switch to first remaining + const currentProvider = config.model?.provider + const isCurrent = currentProvider === poolKey if (isCurrent) { - const remainingKeys = Object.keys(auth.credential_pool) - if (remainingKeys.length > 0) { - const fallback = remainingKeys[0] - const fallbackEntry = auth.credential_pool[fallback]?.[0] - const catalogModels = PROVIDER_MODEL_CATALOG[fallback] || [] - const fallbackModel = catalogModels[0] || fallbackEntry?.label || fallback - - const config2 = await readConfigYaml() - if (typeof config2.model !== 'object' || config2.model === null) { - config2.model = {} + // Find fallback from .env authorized providers or remaining custom_providers + const freshConfig = await readConfigYaml() + const remaining = Array.isArray(freshConfig.custom_providers) ? freshConfig.custom_providers as any[] : [] + const fallbackCp = remaining[0] + if (fallbackCp) { + const fallbackKey = `custom:${fallbackCp.name.trim().toLowerCase().replace(/ /g, '-')}` + if (typeof freshConfig.model !== 'object' || freshConfig.model === null) { + freshConfig.model = {} } - config2.model.default = fallbackModel - config2.model.provider = fallback - await writeConfigYaml(config2) + freshConfig.model.default = fallbackCp.model + freshConfig.model.provider = fallbackKey + await writeConfigYaml(freshConfig) } } diff --git a/packages/server/src/routes/hermes/logs.ts b/packages/server/src/routes/hermes/logs.ts index fcffc4a..ca04401 100644 --- a/packages/server/src/routes/hermes/logs.ts +++ b/packages/server/src/routes/hermes/logs.ts @@ -1,11 +1,29 @@ import Router from '@koa/router' +import { existsSync, statSync } from 'fs' +import { readFile } from 'fs/promises' +import { join } from 'path' +import { homedir } from 'os' import * as hermesCli from '../../services/hermes/hermes-cli' export const logRoutes = new Router() +const WEBUI_LOG_FILE = join(homedir(), '.hermes-web-ui', 'server.log') + // List available log files logRoutes.get('/api/hermes/logs', async (ctx) => { const files = await hermesCli.listLogFiles() + + if (existsSync(WEBUI_LOG_FILE)) { + try { + const stat = statSync(WEBUI_LOG_FILE) + const size = stat.size > 1024 * 1024 + ? `${(stat.size / 1024 / 1024).toFixed(1)}MB` + : `${(stat.size / 1024).toFixed(1)}KB` + const modified = stat.mtime.toLocaleString() + files.push({ name: 'webui', size, modified }) + } catch { } + } + ctx.body = { files } }) @@ -18,20 +36,19 @@ interface LogEntry { } // Parse a single log line into structured entry -function parseLine(line: string): LogEntry | null { - // Match: 2026-04-11 20:16:16,289 INFO aiohttp.access: message - const match = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+?):\s(.*)$/) +function parseLine(line: string): LogEntry { + // Match: 2026-04-11 20:16:16,289 INFO aiohttp.access: message (agent log format) + let match = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+?):\s(.*)$/) if (match) { - return { - timestamp: match[1], - level: match[2], - logger: match[3], - message: match[4], - raw: line, - } + return { timestamp: match[1], level: match[2], logger: match[3], message: match[4], raw: line } } - // Unparseable line (e.g. traceback continuation) - return null + // Match: [Lark] [2026-04-19 18:46:54,864] [INFO] message (gateway log format) + match = line.match(/^\[(\S+?)\]\s+\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\]\s+\[(DEBUG|INFO|WARNING|ERROR|CRITICAL)\]\s(.*)$/) + if (match) { + return { timestamp: match[2], level: match[3], logger: match[1], message: match[4], raw: line } + } + // Unparseable line — keep as raw entry so nothing is lost + return { timestamp: '', level: '', logger: '', message: line, raw: line } } // Read log lines (parsed) @@ -42,6 +59,29 @@ logRoutes.get('/api/hermes/logs/:name', async (ctx) => { const session = (ctx.query.session as string) || undefined const since = (ctx.query.since as string) || undefined + // Handle hermes-web-ui's own server log + if (logName === 'webui') { + try { + if (!existsSync(WEBUI_LOG_FILE)) { + ctx.body = { entries: [] } + return + } + const content = await readFile(WEBUI_LOG_FILE, 'utf-8') + const rawLines = content.split('\n') + const sliced = rawLines.length > lines ? rawLines.slice(-lines) : rawLines + const entries: LogEntry[] = [] + for (const line of sliced) { + if (!line.trim()) continue + entries.push(parseLine(line)) + } + ctx.body = { entries } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } + return + } + try { const content = await hermesCli.readLogs(logName, lines, level, session, since) const rawLines = content.split('\n') diff --git a/packages/server/src/routes/hermes/proxy-handler.ts b/packages/server/src/routes/hermes/proxy-handler.ts index 8d48030..7418dc0 100644 --- a/packages/server/src/routes/hermes/proxy-handler.ts +++ b/packages/server/src/routes/hermes/proxy-handler.ts @@ -28,23 +28,26 @@ async function waitForGatewayReady(upstream: string, timeoutMs: number = 5000): return false } +/** Resolve profile name from request */ +function resolveProfile(ctx: Context): string { + return ctx.get('x-hermes-profile') || (ctx.query.profile as string) || 'default' +} + /** Resolve upstream URL for a request based on profile header/query */ function resolveUpstream(ctx: Context): string { const mgr = getGatewayManager() if (mgr) { - // Check X-Hermes-Profile header or ?profile= query param - const profile = ctx.get('x-hermes-profile') || (ctx.query.profile as string) - if (profile) { + const profile = resolveProfile(ctx) + if (profile && profile !== 'default') { return mgr.getUpstream(profile) } - // Default to active profile's upstream return mgr.getUpstream() } - // Fallback: static upstream from config return config.upstream.replace(/\/$/, '') } export async function proxy(ctx: Context) { + const profile = resolveProfile(ctx) const upstream = resolveUpstream(ctx) // Rewrite path for upstream gateway: // /api/hermes/v1/* -> /v1/* (upstream uses /v1/ prefix) @@ -59,7 +62,7 @@ export async function proxy(ctx: Context) { const lower = key.toLowerCase() if (lower === 'host') { headers['host'] = new URL(upstream).host - } else if (lower === 'authorization' || lower === 'origin' || lower === 'referer' || lower === 'connection') { + } else if (lower === 'origin' || lower === 'referer' || lower === 'connection') { continue } else { const v = Array.isArray(value) ? value[0] : value @@ -67,6 +70,15 @@ export async function proxy(ctx: Context) { } } + // Inject Hermes gateway API key from profile's .env + const mgr = getGatewayManager() + if (mgr) { + const apiKey = mgr.getApiKey(profile) + if (apiKey) { + headers['authorization'] = `Bearer ${apiKey}` + } + } + try { // Build request body from raw body let body: string | undefined diff --git a/packages/server/src/services/hermes/gateway-manager.ts b/packages/server/src/services/hermes/gateway-manager.ts index 17dccb6..a3e1365 100644 --- a/packages/server/src/services/hermes/gateway-manager.ts +++ b/packages/server/src/services/hermes/gateway-manager.ts @@ -314,6 +314,20 @@ export class GatewayManager { return `http://${host}:${port}` } + /** 读取 profile 的 API_SERVER_KEY(从 .env 文件) */ + getApiKey(profileName?: string): string | null { + const name = profileName || this.activeProfile + try { + const envPath = join(this.profileDir(name), '.env') + if (!existsSync(envPath)) return null + const content = readFileSync(envPath, 'utf-8') + const match = content.match(/^API_SERVER_KEY\s*=\s*"?([^"\n]+)"?/m) + return match?.[1]?.trim() || null + } catch { + return null + } + } + getActiveProfile(): string { return this.activeProfile } diff --git a/packages/server/src/services/hermes/hermes-profile.ts b/packages/server/src/services/hermes/hermes-profile.ts index 1034853..d93e622 100644 --- a/packages/server/src/services/hermes/hermes-profile.ts +++ b/packages/server/src/services/hermes/hermes-profile.ts @@ -54,3 +54,14 @@ export function getActiveProfileName(): string { return 'default' } } + +/** + * Get profile directory by name. + * default → ~/.hermes/ + * other → ~/.hermes/profiles/{name}/ + */ +export function getProfileDir(name: string): string { + if (!name || name === 'default') return HERMES_BASE + const dir = join(HERMES_BASE, 'profiles', name) + return existsSync(dir) ? dir : HERMES_BASE +} diff --git a/packages/server/src/shared/providers.ts b/packages/server/src/shared/providers.ts index 3b9ae29..66553a6 100644 --- a/packages/server/src/shared/providers.ts +++ b/packages/server/src/shared/providers.ts @@ -55,22 +55,10 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ }, { label: 'Kimi for Coding', - value: 'kimi-coding', - base_url: 'https://api.kimi.com/coding/v1', - models: [ - 'kimi-for-coding', - 'kimi-k2.5', - 'kimi-k2-thinking', - 'kimi-k2-thinking-turbo', - 'kimi-k2-turbo-preview', - 'kimi-k2-0905-preview', - ], - }, - { - label: 'Kimi for Coding (CN)', value: 'kimi-coding-cn', base_url: 'https://api.kimi.com/coding/v1', models: [ + 'kimi-for-coding', 'kimi-k2.5', 'kimi-k2-thinking', 'kimi-k2-turbo-preview', diff --git a/scripts/setup.sh b/scripts/setup.sh index f3b55e8..80b29f7 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -14,7 +14,7 @@ err() { echo -e "${RED} ✗${NC} $1"; } install_node_deb() { echo "" warn "Node.js is not installed, installing via NodeSource..." - curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - >/dev/null 2>&1 + curl -fsSL https://deb.nodesource.com/setup_23.x | sudo -E bash - >/dev/null 2>&1 sudo apt install -y nodejs >/dev/null 2>&1 info "Node.js $(node -v) installed" } @@ -30,9 +30,29 @@ install_node_mac() { info "Node.js $(node -v) installed" } +MIN_NODE_MAJOR=23 + check_node() { if command -v node &>/dev/null; then - info "Node.js $(node -v) found ($(which node))" + local major + major=$(node -v | sed 's/^v//' | cut -d. -f1) + if [ "$major" -lt "$MIN_NODE_MAJOR" ] 2>/dev/null; then + warn "Node.js $(node -v) found but v${MIN_NODE_MAJOR}+ is required, upgrading..." + # Auto-upgrade based on OS + if grep -qi microsoft /proc/version 2>/dev/null; then + install_node_deb + elif command -v apt &>/dev/null; then + install_node_deb + elif command -v brew &>/dev/null || [[ "$OSTYPE" == "darwin"* ]]; then + install_node_mac + else + err "Node.js upgrade not supported on this system" + echo " Install manually: https://nodejs.org/" + return 1 + fi + else + info "Node.js $(node -v) found ($(which node))" + fi return 0 fi