feat(hermes): dynamically load provider registry in setup & dashboard (Step 2)

Wire the new Rust `hermes_list_providers` command into the frontend and
replace the hardcoded OpenClaw PROVIDER_PRESETS usage with the
authoritative Hermes registry. Closes the G4 gap from the v3 design.

New module `src/engines/hermes/lib/providers.js`:
- Async `loadHermesProviders()` with per-session cache.
- `groupProviders()` buckets by authType + region: apiKeyIntl,
  apiKeyCn, aggregators, oauth, externalProc, custom.
- Lookup helpers: `findProviderById`, `inferProviderByBaseUrl`,
  `defaultModelFor`, plus cache reset for hot-reload scenarios.
- Exported auth_type / transport string constants mirroring Rust.

Refactored `src/engines/hermes/pages/setup.js`:
- Drops `PROVIDER_PRESETS` import; loads registry before first paint.
- Provider buttons are grouped under labeled sections (International,
  China, Aggregators), with an OAuth hint block listing the CLI
  commands users must run (e.g. `hermes auth login nous`).
- Selected provider detail panel now shows target env var
  (`ANTHROPIC_API_KEY`, `DEEPSEEK_API_KEY`, etc.) and model catalog
  size.
- `doSaveConfig` sends the provider id (not preset key) through
  `api.configureHermes`; falls back to `custom` when the base URL
  doesn't match any registered provider.
- `doFetchModels` maps provider.transport → apiType.
- Graceful fallback: when the registry is empty (Web mode), UI
  degrades to manual Base URL + API Key entry.

Refactored `src/engines/hermes/pages/dashboard.js`:
- Loads provider registry in parallel with gateway refresh.
- Preset buttons filter out the `custom` placeholder and source
  data from the async-loaded list.
- Uses `inferProviderByBaseUrl` consistently for highlight / fetch /
  save flows.

Frontend API wiring:
- `src/lib/tauri-api.js`:
  - Added `hermesListProviders` (10-minute cache).
  - Extended `hermesFetchModels` and `hermesUpdateModel` with
    optional `provider` param.
- `scripts/dev-api.js`:
  - `hermes_list_providers`: Web-mode stub returning [] (triggers
    frontend fallback UI).
  - `hermes_fetch_models` / `hermes_update_model` accept provider
    param (no-op in fetch; full YAML rewrite in update_model matching
    Rust behavior).

Verified: `npm run build` green (1.04s). Setup chunk 24.34 kB,
dashboard chunk 24.30 kB. No new warnings.
This commit is contained in:
晴天
2026-04-24 20:41:06 +08:00
parent 17759fc1e6
commit 42d6758eb4
5 changed files with 363 additions and 46 deletions

View File

@@ -6836,7 +6836,16 @@ const handlers = {
return { model: displayModel, model_raw: modelName, base_url: baseUrl, provider, api_key: apiKey, config_exists: fs.existsSync(configPath) }
},
async hermes_fetch_models({ baseUrl, apiKey, apiType } = {}) {
// Web-mode stub: the authoritative 22-provider registry lives in Rust.
// Web mode is primarily used for remote admin on headless Linux where
// Hermes configuration is a minor flow; returning an empty array makes
// the frontend fall back to a "Please use the desktop app to configure
// Hermes providers" message in setup.js.
hermes_list_providers() {
return []
},
async hermes_fetch_models({ baseUrl, apiKey, apiType, provider: _provider } = {}) {
const api = apiType || 'openai'
let base = baseUrl.replace(/\/+$/, '')
for (const suffix of ['/chat/completions', '/completions', '/responses', '/messages', '/models']) {
@@ -6867,20 +6876,68 @@ const handlers = {
return models.sort()
},
hermes_update_model({ model } = {}) {
hermes_update_model({ model, provider } = {}) {
const configPath = path.join(hermesHome(), 'config.yaml')
const content = fs.readFileSync(configPath, 'utf8')
let found = false
const newContent = content.split('\n').map(line => {
const lines = content.split('\n')
const out = []
let inModel = false
let defaultWritten = false
let providerWritten = false
let defaultIndent = ' '
for (const line of lines) {
const t = line.trim()
if (t.startsWith('default:') && !found) {
found = true
const indent = line.length - line.trimStart().length
return ' '.repeat(indent) + `default: ${model}`
if (t.startsWith('model:')) {
inModel = true
out.push(line)
continue
}
return line
}).join('\n')
if (!found) throw new Error('config.yaml 中未找到 model.default 字段')
if (inModel) {
const isIndented = line.startsWith(' ') || line.startsWith('\t')
if (!isIndented && t && !t.startsWith('#')) {
// leaving model block — flush provider if needed
if (provider && provider !== 'custom' && !providerWritten) {
out.push(`${defaultIndent}provider: ${provider}`)
providerWritten = true
}
inModel = false
out.push(line)
continue
}
if (t.startsWith('default:')) {
const indentLen = line.length - line.trimStart().length
defaultIndent = ' '.repeat(indentLen)
out.push(`${defaultIndent}default: ${model}`)
defaultWritten = true
continue
}
if (t.startsWith('provider:')) {
if (provider && provider !== 'custom') {
const indentLen = line.length - line.trimStart().length
out.push(`${' '.repeat(indentLen)}provider: ${provider}`)
providerWritten = true
continue
}
if (provider === 'custom') continue // drop
// no new provider → keep old
out.push(line)
providerWritten = true
continue
}
}
out.push(line)
}
// still in model block at EOF
if (inModel && provider && provider !== 'custom' && !providerWritten) {
out.push(`${defaultIndent}provider: ${provider}`)
}
if (!defaultWritten) throw new Error('config.yaml 中未找到 model.default 字段')
let newContent = out.join('\n')
if (!newContent.endsWith('\n')) newContent += '\n'
fs.writeFileSync(configPath, newContent)
return `模型已切换为 ${model}`
},

View File

@@ -0,0 +1,172 @@
/**
* Hermes provider registry (frontend mirror).
*
* The authoritative data lives in Rust at
* `src-tauri/src/commands/hermes_providers.rs::ALL_PROVIDERS`
* and is exposed via the Tauri command `hermes_list_providers`.
*
* This module:
* 1. Loads the 22 providers once per session (cached)
* 2. Groups them by auth type and region for UI rendering
* 3. Provides small lookup helpers (by id, by model, etc.)
*
* Never hardcode provider data here — always call `loadHermesProviders()`
* so we stay in sync with the Rust side.
*/
import { api } from '../../../lib/tauri-api.js'
// Auth type constants (must match Rust side)
export const AUTH_API_KEY = 'api_key'
export const AUTH_OAUTH_DEVICE = 'oauth_device_code'
export const AUTH_OAUTH_EXTERNAL = 'oauth_external'
export const AUTH_EXTERNAL_PROCESS = 'external_process'
// Transport constants
export const TRANSPORT_OPENAI_CHAT = 'openai_chat'
export const TRANSPORT_ANTHROPIC = 'anthropic_messages'
export const TRANSPORT_GOOGLE = 'google_gemini'
export const TRANSPORT_CODEX = 'codex_responses'
// China-region provider ids (for UI sub-grouping). Everything else is
// considered "International" by default.
const CN_PROVIDER_IDS = new Set(['zai', 'kimi-coding', 'alibaba', 'minimax-cn', 'xiaomi'])
// Aggregator ids (also tagged via `isAggregator` on the data).
const AGGREGATOR_IDS = new Set([
'openrouter',
'ai-gateway',
'opencode-zen',
'opencode-go',
'kilocode',
'huggingface',
'nous',
])
let _cached = null
let _loadPromise = null
/**
* Fetch the full provider list from Rust (cached for the session).
* Returns [] if the backend is unreachable — callers should degrade gracefully.
*/
export async function loadHermesProviders() {
if (_cached) return _cached
if (_loadPromise) return _loadPromise
_loadPromise = (async () => {
try {
const list = await api.hermesListProviders()
_cached = Array.isArray(list) ? list : []
return _cached
} catch (err) {
console.warn('[hermes/providers] failed to load registry:', err)
_cached = []
return _cached
} finally {
_loadPromise = null
}
})()
return _loadPromise
}
/** Look up a provider by stable id; returns undefined if unknown. */
export function findProviderById(list, id) {
return list?.find(p => p.id === id)
}
/** Case-insensitive search by display name or id. */
export function searchProviders(list, query) {
const q = (query || '').trim().toLowerCase()
if (!q) return list
return list.filter(p =>
p.id.toLowerCase().includes(q) || p.name.toLowerCase().includes(q)
)
}
/**
* Group providers by auth type, with api_key further split into region
* buckets for UI rendering. Returns:
* {
* apiKeyIntl: HermesProvider[],
* apiKeyCn: HermesProvider[],
* aggregators: HermesProvider[],
* oauth: HermesProvider[],
* externalProc: HermesProvider[],
* custom: HermesProvider[],
* }
*/
export function groupProviders(list) {
const groups = {
apiKeyIntl: [],
apiKeyCn: [],
aggregators: [],
oauth: [],
externalProc: [],
custom: [],
}
for (const p of list || []) {
if (p.id === 'custom') {
groups.custom.push(p)
continue
}
if (p.authType === AUTH_EXTERNAL_PROCESS) {
groups.externalProc.push(p)
continue
}
if (p.authType === AUTH_OAUTH_DEVICE || p.authType === AUTH_OAUTH_EXTERNAL) {
groups.oauth.push(p)
continue
}
if (p.isAggregator || AGGREGATOR_IDS.has(p.id)) {
groups.aggregators.push(p)
continue
}
if (CN_PROVIDER_IDS.has(p.id)) {
groups.apiKeyCn.push(p)
continue
}
groups.apiKeyIntl.push(p)
}
return groups
}
/**
* Given a freshly entered base URL, guess which provider best matches.
* Used by setup/dashboard forms to auto-highlight the preset button.
*/
export function inferProviderByBaseUrl(list, rawBaseUrl) {
const normalize = (u) => (u || '')
.trim()
.replace(/\/+$/, '')
.replace(/\/(chat\/completions|completions|responses|messages|models)$/, '')
const target = normalize(rawBaseUrl)
if (!target) return null
for (const p of list || []) {
if (normalize(p.baseUrl) === target) return p
}
return null
}
/**
* Return a sensible default model for a provider.
* Aggregators may have empty `models` — callers must handle null.
*/
export function defaultModelFor(provider) {
if (!provider || !provider.models || !provider.models.length) return null
return provider.models[0]
}
/** Synchronous accessor for already-loaded registry. */
export function getCachedProviders() {
return _cached || []
}
/** Force a reload on next call (e.g. after dev hot-reload). */
export function clearProviderCache() {
_cached = null
_loadPromise = null
}

View File

@@ -3,7 +3,10 @@
*/
import { t } from '../../../lib/i18n.js'
import { api } from '../../../lib/tauri-api.js'
import { PROVIDER_PRESETS } from '../../../lib/model-presets.js'
import {
loadHermesProviders,
inferProviderByBaseUrl,
} from '../lib/providers.js'
const ICONS = {
running: `<svg viewBox="0 0 24 24" fill="none" stroke="var(--success, #22c55e)" stroke-width="2.5" width="20" height="20"><circle cx="12" cy="12" r="10"/><polyline points="16 12 12 8 8 12"/><line x1="12" y1="16" x2="12" y2="8"/></svg>`,
@@ -14,7 +17,8 @@ const ICONS = {
refresh: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>`,
}
const HERMES_PROVIDERS = PROVIDER_PRESETS.filter(p => !p.hidden)
// Provider registry—异步加载第一次 render 前填充
let hermesProviders = []
// Lazy Tauri event listen (avoid top-level await for vite build)
let _listenFn = null
@@ -131,7 +135,7 @@ export function render() {
const displayModel = modelName || t('engine.dashNoModel')
// 服务商高亮匹配
const activePreset = HERMES_PROVIDERS.find(p => formBaseUrl === p.baseUrl)
const activePreset = inferProviderByBaseUrl(hermesProviders, formBaseUrl)
// 模型下拉 HTML
const dropdownHtml = showDropdown && models.length
@@ -195,9 +199,13 @@ export function render() {
</div>
<div style="${modelConfigCollapsed ? 'display:none' : 'padding:0 20px 20px'}">
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px">
${HERMES_PROVIDERS.map(p =>
`<button class="btn btn-sm btn-secondary hm-preset-btn" data-key="${p.key}" data-url="${esc(p.baseUrl)}" data-api="${p.api || 'openai-completions'}" style="font-size:11px;padding:2px 8px;${activePreset?.key === p.key ? 'opacity:1;font-weight:600' : 'opacity:0.6'}">${p.label}</button>`
).join('')}
${hermesProviders.filter(p => p.id !== 'custom').map(p => {
const api = p.transport === 'anthropic_messages' ? 'anthropic-messages'
: p.transport === 'google_gemini' ? 'google-generative-ai'
: 'openai-completions'
const active = activePreset?.id === p.id
return `<button class="btn btn-sm btn-secondary hm-preset-btn" data-key="${p.id}" data-url="${esc(p.baseUrl)}" data-api="${api}" style="font-size:11px;padding:2px 8px;${active ? 'opacity:1;font-weight:600' : 'opacity:0.6'}">${p.name}</button>`
}).join('')}
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
<label style="display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--text-secondary)">
@@ -419,8 +427,12 @@ export function render() {
if (!formBaseUrl) { cfgMsg = `<span style="color:var(--warning)">${t('engine.configFetchNeedUrl')}</span>`; draw(); return }
if (!formApiKey) { cfgMsg = `<span style="color:var(--warning)">${t('engine.configFetchNeedKey')}</span>`; draw(); return }
const matched = HERMES_PROVIDERS.find(p => formBaseUrl === p.baseUrl)
const apiType = matched?.api || 'openai-completions'
const matched = inferProviderByBaseUrl(hermesProviders, formBaseUrl)
const apiType = matched
? (matched.transport === 'anthropic_messages' ? 'anthropic-messages'
: matched.transport === 'google_gemini' ? 'google-generative-ai'
: 'openai-completions')
: 'openai-completions'
fetchBusy = true; cfgMsg = ''; draw()
try {
@@ -441,8 +453,8 @@ export function render() {
if (!formApiKey) { cfgMsg = `<span style="color:var(--warning)">${t('engine.configFetchNeedKey')}</span>`; draw(); return }
if (!formModel) { cfgMsg = `<span style="color:var(--warning)">请输入模型名</span>`; draw(); return }
const matched = HERMES_PROVIDERS.find(p => formBaseUrl && p.baseUrl === formBaseUrl)
const provider = matched?.key || 'custom'
const matched = inferProviderByBaseUrl(hermesProviders, formBaseUrl)
const provider = matched?.id || 'custom'
modelBusy = true; cfgMsg = ''; draw()
try {
@@ -534,8 +546,15 @@ export function render() {
draw()
}
// 初始加载
refresh()
// 初始加载:先拉取 provider registry和 refresh 并行),再渲染
;(async () => {
try {
hermesProviders = await loadHermesProviders()
} catch (err) {
console.warn('[hermes/dashboard] failed to load providers:', err)
}
refresh()
})()
// --- Guardian 事件监听:实时响应 Gateway 状态变化 ---
let unlisteners = []

View File

@@ -5,8 +5,13 @@
*/
import { t } from '../../../lib/i18n.js'
import { api, invalidate } from '../../../lib/tauri-api.js'
import { PROVIDER_PRESETS } from '../../../lib/model-presets.js'
import { getActiveEngine } from '../../../lib/engine-manager.js'
import {
loadHermesProviders,
groupProviders,
inferProviderByBaseUrl,
findProviderById,
} from '../lib/providers.js'
// SVG 图标
const ICONS = {
@@ -20,8 +25,10 @@ const ICONS = {
// 核心安装不带 extras后续可在管理页面按需安装
// Hermes 使用 OpenAI 兼容接口,过滤出兼容的服务商
const HERMES_PROVIDERS = PROVIDER_PRESETS.filter(p => !p.hidden)
// Provider 数据 — 异步从 Rust hermes_providers.rs 加载(首次 render 前)
// Web 模式下 dev-api.js 返回空数组UI 会降级到手填模式
let hermesProviders = []
let hermesGroups = { apiKeyIntl: [], apiKeyCn: [], aggregators: [], oauth: [], externalProc: [], custom: [] }
export function render() {
const el = document.createElement('div')
@@ -228,9 +235,7 @@ export function render() {
// --- 配置阶段 ---
function renderConfigure() {
const presetBtns = HERMES_PROVIDERS.map(p =>
`<button class="btn btn-sm btn-secondary hermes-preset-btn" data-key="${p.key}" data-url="${p.baseUrl}" data-api="${p.api}" style="font-size:12px;padding:3px 10px;margin:0 6px 6px 0">${p.label}${p.badge ? ` <span style="font-size:9px;background:var(--accent);color:#fff;padding:1px 4px;border-radius:6px;margin-left:3px">${p.badge}</span>` : ''}</button>`
).join('')
const presetBtns = renderGroupedProviderButtons()
return `<div class="card" style="margin-bottom:16px">
<div class="card-body" style="padding:24px">
@@ -240,7 +245,7 @@ export function render() {
<div class="hermes-form">
<div class="hermes-field">
<span>${t('engine.configProvider')}</span>
<div style="display:flex;flex-wrap:wrap">${presetBtns}</div>
${presetBtns}
<div id="hm-preset-detail" style="display:none;margin-top:6px;padding:8px 12px;background:var(--bg-tertiary);border-radius:var(--radius-md,8px);font-size:12px"></div>
</div>
<label class="hermes-field">
@@ -340,14 +345,18 @@ export function render() {
// 高亮选中
el.querySelectorAll('.hermes-preset-btn').forEach(b => b.style.opacity = '0.5')
btn.style.opacity = '1'
// 显示服务商详情
const preset = HERMES_PROVIDERS.find(p => p.key === btn.dataset.key)
// 显示服务商详情(展示 authType + models 预览)
const preset = findProviderById(hermesProviders, btn.dataset.key)
const detailEl = el.querySelector('#hm-preset-detail')
if (detailEl && preset) {
let html = preset.desc ? `<div style="color:var(--text-secondary);line-height:1.5">${preset.desc}</div>` : ''
if (preset.site) html += `<a href="${preset.site}" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-size:11px;margin-top:3px;display:inline-block">→ ${preset.label} 官网</a>`
detailEl.innerHTML = html
detailEl.style.display = html ? 'block' : 'none'
const envLine = preset.apiKeyEnvVars && preset.apiKeyEnvVars.length
? `<div style="color:var(--text-tertiary);font-size:11px;margin-top:2px">写入 <code>${preset.apiKeyEnvVars[0]}</code></div>`
: ''
const modelsPreview = preset.models && preset.models.length
? `<div style="color:var(--text-tertiary);font-size:11px;margin-top:2px">${preset.models.length} 个已知模型</div>`
: '<div style="color:var(--text-tertiary);font-size:11px;margin-top:2px">聚合路由:请自行指定模型</div>'
detailEl.innerHTML = `<div style="color:var(--text-secondary);line-height:1.5">${preset.name}</div>${envLine}${modelsPreview}`
detailEl.style.display = 'block'
}
})
})
@@ -533,9 +542,13 @@ export function render() {
// 移除常见尾部路径
base = base.replace(/\/(chat\/completions|completions|responses|messages|models)\/?$/, '')
// 判断 API 类型(大部分是 OpenAI 兼容
const matched = HERMES_PROVIDERS.find(p => baseUrl === p.baseUrl)
const apiType = matched?.api || 'openai-completions'
// 判断 API 类型:按 provider transport 推断fallback 到 openai 兼容
const matched = inferProviderByBaseUrl(hermesProviders, baseUrl)
let apiType = 'openai-completions'
if (matched) {
if (matched.transport === 'anthropic_messages') apiType = 'anthropic-messages'
else if (matched.transport === 'google_gemini') apiType = 'google-generative-ai'
}
let models = []
@@ -598,9 +611,9 @@ export function render() {
const baseUrl = el.querySelector('#hm-baseurl')?.value?.trim()
const apiKey = el.querySelector('#hm-apikey')?.value?.trim()
const model = el.querySelector('#hm-model')?.value?.trim()
// 从 baseUrl 推断 provider key
const matched = HERMES_PROVIDERS.find(p => baseUrl && p.baseUrl === baseUrl)
const provider = matched?.key || 'openai'
// 从 baseUrl 推断 provider id推不出来时用 'custom',让后端按通用 OpenAI 兼容处理
const matched = inferProviderByBaseUrl(hermesProviders, baseUrl)
const provider = matched?.id || 'custom'
if (!apiKey) {
alert('请输入 API Key')
@@ -655,8 +668,63 @@ export function render() {
draw()
}
// 启动检测
detect()
// 启动检测前先加载 provider registry然后启动检测
;(async () => {
try {
hermesProviders = await loadHermesProviders()
hermesGroups = groupProviders(hermesProviders)
} catch (err) {
console.warn('[hermes/setup] failed to load providers:', err)
}
detect()
})()
return el
}
// ============================================================================
// Helper: render the grouped provider buttons shown in renderConfigure()
// ============================================================================
function renderGroupedProviderButtons() {
if (!hermesProviders.length) {
return `<div style="padding:10px 12px;background:var(--bg-tertiary);border-radius:var(--radius-sm,6px);color:var(--text-secondary);font-size:12px;line-height:1.6">
未能加载 provider 列表。Web 模式下可手动填写下方 Base URL 与 API Key 完成配置。
</div>`
}
const sectionStyle = 'margin-top:6px'
const titleStyle = 'font-size:11px;color:var(--text-tertiary);margin:4px 0 4px;font-weight:500;letter-spacing:0.3px'
const rowStyle = 'display:flex;flex-wrap:wrap'
const btn = (p) => {
const envHint = p.apiKeyEnvVars && p.apiKeyEnvVars.length
? ` title="${p.apiKeyEnvVars[0]}"`
: ''
return `<button class="btn btn-sm btn-secondary hermes-preset-btn"
data-key="${p.id}"
data-url="${p.baseUrl}"
data-api="${p.transport === 'anthropic_messages' ? 'anthropic-messages' : p.transport === 'google_gemini' ? 'google-generative-ai' : 'openai-completions'}"${envHint}
style="font-size:12px;padding:3px 10px;margin:0 6px 6px 0">${p.name}</button>`
}
const parts = []
if (hermesGroups.apiKeyIntl.length) {
parts.push(`<div style="${sectionStyle}"><div style="${titleStyle}">国际 · API Key</div><div style="${rowStyle}">${hermesGroups.apiKeyIntl.map(btn).join('')}</div></div>`)
}
if (hermesGroups.apiKeyCn.length) {
parts.push(`<div style="${sectionStyle}"><div style="${titleStyle}">国内 · API Key</div><div style="${rowStyle}">${hermesGroups.apiKeyCn.map(btn).join('')}</div></div>`)
}
if (hermesGroups.aggregators.length) {
parts.push(`<div style="${sectionStyle}"><div style="${titleStyle}">聚合 / 路由</div><div style="${rowStyle}">${hermesGroups.aggregators.map(btn).join('')}</div></div>`)
}
if (hermesGroups.oauth.length) {
const oauthItems = hermesGroups.oauth.map(p =>
`<div style="font-size:11px;color:var(--text-tertiary);margin-right:10px"><code>${p.name}</code>:需运行 <code>${p.cliAuthHint}</code></div>`
).join('')
parts.push(`<div style="${sectionStyle}"><div style="${titleStyle}">OAuth 登录(需终端)</div><div style="display:flex;flex-wrap:wrap;gap:4px 0">${oauthItems}</div></div>`)
}
return parts.join('')
}

View File

@@ -398,8 +398,9 @@ export const api = {
hermesApiProxy: (method, path, body, headers) => invoke('hermes_api_proxy', { method, path, body: body || null, headers: headers || null }),
hermesAgentRun: (input, sessionId, conversationHistory, instructions) => invoke('hermes_agent_run', { input, sessionId: sessionId || null, conversationHistory: conversationHistory || null, instructions: instructions || null }),
hermesReadConfig: () => invoke('hermes_read_config'),
hermesFetchModels: (baseUrl, apiKey, apiType) => invoke('hermes_fetch_models', { baseUrl, apiKey, apiType: apiType || null }),
hermesUpdateModel: (model) => invoke('hermes_update_model', { model }),
hermesFetchModels: (baseUrl, apiKey, apiType, provider) => invoke('hermes_fetch_models', { baseUrl, apiKey, apiType: apiType || null, provider: provider || null }),
hermesUpdateModel: (model, provider) => invoke('hermes_update_model', { model, provider: provider || null }),
hermesListProviders: () => cachedInvoke('hermes_list_providers', {}, 600000),
hermesDetectEnvironments: () => invoke('hermes_detect_environments'),
hermesSetGatewayUrl: (url) => invoke('hermes_set_gateway_url', { url: url || null }),
updateHermes: () => invoke('update_hermes'),