优化页面交互并增强 Provider 配置能力
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s

- 表单下拉框不再用 label 包裹,改为 div + aria-label,避免点击后
  下拉面板瞬间关闭
- Provider 卡片新增「从上游获取模型」和「修改」入口,支持编辑
  base URL、API Key 与 api_mode,凭据写入对应的环境变量
- 服务端抽出 provider-model-discovery 统一不同 api_mode 的模型发现,
  旧的 custom_providers 与新的 providers 字典结构都能正确更新
- Agent Bridge 改为后台启动,HTTP 接口在 Python 运行时就绪前即可用;
  worker 连接被拒时按类型重试,不重放可能已产生副作用的请求
- 断线重连后恢复 profiles、models 与会话列表,健康检查改为自调度
  的 setTimeout,离线时 2 秒后重试
- 批量删除确认文案改用 Popconfirm content,避免变成侧栏触发器文本
This commit is contained in:
2026-10-22 14:29:48 +08:00
committed by trae-solo-agent
parent 90c759c760
commit 3ca960f388
35 changed files with 876 additions and 78 deletions
+4
View File
@@ -100,6 +100,7 @@ export interface AvailableModelGroup {
builtin?: boolean
/** Env var used by Hermes to override this provider's base URL. If present, the preset URL is editable. */
base_url_env?: string
credentials_editable?: boolean
/** Config source for custom providers. Dict-backed providers can be deleted from providers:<key>. */
provider_source?: 'custom_providers' | 'providers'
provider_key?: string
@@ -224,6 +225,7 @@ export async function fetchProviderModels(data: {
provider?: string
label?: string
update_cache?: boolean
api_mode?: ProviderApiMode
}): Promise<{ models: string[] }> {
return request<{ models: string[] }>('/api/hermes/provider-models', {
method: 'POST',
@@ -277,6 +279,8 @@ export async function removeCustomProvider(name: string, options: { source?: 'cu
}
export async function updateProvider(poolKey: string, data: {
source?: 'custom_providers' | 'providers'
providerKey?: string
name?: string
base_url?: string
api_key?: string
@@ -1125,13 +1125,16 @@ async function handleSessionModelCustomSubmit() {
<a-popconfirm
v-if="isBatchMode && selectedCount > 0"
:visible="showBatchDeleteConfirm"
:content="t('chat.confirmBatchDelete', { count: selectedCount })"
:ok-text="t('common.confirm')"
:cancel-text="t('common.cancel')"
:ok-button-props="{ loading: isBatchDeleting, disabled: isBatchDeleting }"
:cancel-button-props="{ disabled: isBatchDeleting }"
@ok="handleBatchDeleteConfirm"
@cancel="showBatchDeleteConfirm = false"
@update:visible="showBatchDeleteConfirm = $event"
>
<a-button type="secondary" size="mini" status="danger" :loading="isBatchDeleting" :disabled="isBatchDeleting">
<a-button :aria-label="t('common.delete')" type="secondary" size="mini" status="danger" :loading="isBatchDeleting" :disabled="isBatchDeleting">
<template #icon>
<svg
width="14"
@@ -1146,7 +1149,6 @@ async function handleSessionModelCustomSubmit() {
</svg>
</template>
</a-button>
{{ t('chat.confirmBatchDelete', { count: selectedCount }) }}
</a-popconfirm>
<a-button
v-if="isBatchMode"
@@ -1435,34 +1437,37 @@ async function handleSessionModelCustomSubmit() {
>
<template #title>{{ t('chat.newChat') }}</template>
<div class="new-chat-form">
<label class="new-chat-field">
<div class="new-chat-field">
<span class="new-chat-label">{{ t("sidebar.profiles") }}</span>
<a-select
:aria-label="t('sidebar.profiles')"
:model-value="newChatProfile"
:options="newChatProfileOptions"
:loading="newChatLoading || profilesStore.loading"
@change="handleNewChatProfileChange"
/>
</label>
<label class="new-chat-field">
</div>
<div class="new-chat-field">
<span class="new-chat-label">{{ t("models.provider") }}</span>
<a-select
:aria-label="t('models.provider')"
:model-value="newChatProvider"
:options="newChatProviderOptions"
:disabled="newChatLoading"
@change="handleNewChatProviderChange"
/>
</label>
<label class="new-chat-field">
</div>
<div class="new-chat-field">
<span class="new-chat-label">{{ t("models.models") }}</span>
<a-select
:aria-label="t('models.models')"
v-model="newChatModel"
:options="newChatModelOptions"
:disabled="newChatLoading || !newChatProvider"
allow-search
allow-clear
/>
</label>
</div>
<div class="new-chat-field">
<span class="new-chat-label">
{{ t("chat.workspace") }}
@@ -282,25 +282,27 @@ watch(() => form.value.provider, (provider) => {
:mask-closable="!saving"
>
<div class="auxiliary-form">
<label>
<div class="auxiliary-select-field">
<span>{{ t('models.provider') }}</span>
<a-select
:aria-label="t('models.provider')"
v-model="form.provider"
:options="providerOptions"
:placeholder="t('models.chooseProvider')"
allow-search
/>
</label>
<label>
</div>
<div class="auxiliary-select-field">
<span>{{ t('models.defaultModel') }}</span>
<a-select
:aria-label="t('models.defaultModel')"
v-model="form.model"
:options="modelOptions"
:placeholder="t('models.selectModel')"
:disabled="!form.provider || form.provider === 'auto' || form.provider === 'main'"
allow-search
/>
</label>
</div>
<label>
<span>{{ t('models.auxiliaryTimeout') }}</span>
<a-input-number
@@ -395,7 +397,8 @@ watch(() => form.value.provider, (provider) => {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
label {
label,
.auxiliary-select-field {
display: flex;
flex-direction: column;
gap: 6px;
@@ -6,6 +6,8 @@ import type { AvailableModelGroup } from '@/api/hermes/system'
import { useModelsStore } from '@/stores/hermes/models'
import { useAppStore } from '@/stores/hermes/app'
import { useChatStore } from '@/stores/hermes/chat'
import ProviderEditModal from './ProviderEditModal.vue'
import { fetchProviderModels } from '@/api/hermes/system'
import { checkCopilotToken, disableCopilot } from '@/api/hermes/copilot-auth'
import { useI18n } from 'vue-i18n'
@@ -24,6 +26,32 @@ const isConfigBackedProvider = computed(() => isCustomProviderKey.value || (!pro
const isCopilot = computed(() => props.provider.provider === 'copilot')
const displayName = computed(() => props.provider.label)
const deleting = ref(false)
const showEditModal = ref(false)
const fetchingUpstream = ref(false)
const canEditProvider = computed(() => isConfigBackedProvider.value || props.provider.credentials_editable || !!props.provider.base_url_env)
async function handleFetchUpstream() {
if (fetchingUpstream.value) return
fetchingUpstream.value = true
try {
const result = await fetchProviderModels({
provider: props.provider.provider,
label: props.provider.label,
base_url: props.provider.base_url,
api_key: props.provider.api_key,
update_cache: true,
api_mode: props.provider.api_mode,
freeOnly: props.provider.provider === 'openrouter',
})
await modelsStore.fetchProviders()
await appStore.reloadModels()
message.success(t('models.upstreamModelsFetched', { count: result.models.length }))
} catch (e: any) {
message.error(e?.message || t('models.upstreamModelsFailed'))
} finally {
fetchingUpstream.value = false
}
}
const destructiveActionLabel = computed(() => {
if (isConfigBackedProvider.value) return t('common.delete')
if (isCopilot.value) return t('models.disableProvider')
@@ -302,11 +330,15 @@ async function handleDelete() {
</div>
<div class="cy-panel-b card-actions">
<a-button size="mini" type="text" :loading="fetchingUpstream" :disabled="!provider.base_url || showEditModal" @click="handleFetchUpstream">{{ t('models.fetchUpstreamModels') }}</a-button>
<a-button v-if="canEditProvider" size="mini" type="text" :disabled="fetchingUpstream || deleting" @click="showEditModal = true">{{ t('common.edit') }}</a-button>
<a-button size="mini" type="text" @click="showAliasListModal = true">{{ t('models.aliasManage') }}</a-button>
<a-button size="mini" type="text" @click="openVisibilityModal">{{ t('models.manageVisibleModels') }}</a-button>
<a-button size="mini" type="text" status="danger" :loading="deleting" @click="handleDelete">{{ destructiveActionLabel }}</a-button>
</div>
<ProviderEditModal v-if="showEditModal" :provider="provider" @close="showEditModal = false" />
<a-modal
v-model:visible="showAliasListModal"
:footer="false"
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { updateProvider, type AvailableModelGroup } from '@/api/hermes/system'
import { useModelsStore } from '@/stores/hermes/models'
import { useAppStore } from '@/stores/hermes/app'
import { useMessage } from '@/composables/useMessage'
const props = defineProps<{ provider: AvailableModelGroup }>()
const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
const modelsStore = useModelsStore()
const appStore = useAppStore()
const message = useMessage()
const visible = ref(true)
const saving = ref(false)
const baseUrl = ref(props.provider.base_url)
const apiKey = ref('')
const canEditBaseUrl = computed(() => props.provider.provider.startsWith('custom:') || !!props.provider.provider_source || !!props.provider.base_url_env)
async function save() {
if (saving.value) return
const url = baseUrl.value.trim()
if (canEditBaseUrl.value) {
try {
if (!['http:', 'https:'].includes(new URL(url).protocol)) throw new Error('Invalid protocol')
} catch {
message.error(t('models.invalidBaseUrl'))
return
}
}
saving.value = true
try {
await updateProvider(props.provider.provider, {
...(canEditBaseUrl.value ? { base_url: url } : {}),
...(apiKey.value.trim() ? { api_key: apiKey.value.trim() } : {}),
source: props.provider.provider_source,
providerKey: props.provider.provider_key,
})
await modelsStore.fetchProviders()
await appStore.reloadModels()
message.success(t('models.providerUpdated'))
emit('close')
} catch (e: any) {
message.error(e?.message || t('models.providerUpdateFailed'))
} finally {
saving.value = false
}
}
</script>
<template>
<a-modal
v-model:visible="visible"
:title="t('models.editProviderFor', { name: provider.label })"
width="min(560px, calc(100vw - 32px))"
:mask-closable="!saving"
:esc-to-close="!saving"
:closable="!saving"
@cancel="emit('close')"
>
<a-form :model="{ baseUrl, apiKey }" layout="vertical" class="provider-edit-form">
<a-form-item :label="t('models.provider')">
<a-input :model-value="provider.provider" disabled />
</a-form-item>
<a-form-item :label="t('models.baseUrl')">
<a-input v-model="baseUrl" :aria-label="t('models.baseUrl')" :disabled="saving || !canEditBaseUrl" />
</a-form-item>
<a-form-item :label="t('models.apiKey')" :extra="t('models.keepExistingKey')">
<a-input-password v-model="apiKey" :aria-label="t('models.apiKey')" :placeholder="t('models.keepExistingKey')" :disabled="saving" autocomplete="new-password" />
</a-form-item>
</a-form>
<template #footer>
<a-button :disabled="saving" @click="emit('close')">{{ t('common.cancel') }}</a-button>
<a-button type="primary" :loading="saving" @click="save">{{ t('common.save') }}</a-button>
</template>
</a-modal>
</template>
@@ -221,6 +221,7 @@ async function fetchModels() {
: formData.value.name.trim() || provider
const data = await fetchProviderModels({
base_url: base_url.trim(),
api_mode: formData.value.api_mode,
api_key: formData.value.api_key.trim(),
provider,
label,
+8
View File
@@ -1211,6 +1211,14 @@ export default {
// Models
models: {
fetchUpstreamModels: 'Fetch models from upstream',
upstreamModelsFetched: 'Fetched {count} models from upstream',
upstreamModelsFailed: 'Failed to fetch models from upstream',
editProviderFor: 'Edit {name}',
keepExistingKey: 'Leave blank to keep the existing key',
invalidBaseUrl: 'Enter a valid HTTP or HTTPS Base URL',
providerUpdated: 'Provider configuration updated',
providerUpdateFailed: 'Failed to update provider configuration',
title: 'Models',
searchPlaceholder: 'Search models...',
noResults: 'No results',
+8
View File
@@ -1211,6 +1211,14 @@ export default {
// 模型
models: {
fetchUpstreamModels: '从上游获取模型',
upstreamModelsFetched: '已从上游获取 {count} 个模型',
upstreamModelsFailed: '从上游获取模型失败',
editProviderFor: '修改 {name}',
keepExistingKey: '留空保留现有 Key',
invalidBaseUrl: '请输入有效的 HTTP 或 HTTPS Base URL',
providerUpdated: 'Provider 配置已更新',
providerUpdateFailed: '修改 Provider 配置失败',
title: '模型',
searchPlaceholder: '搜索模型...',
noResults: '无结果',
+11 -4
View File
@@ -41,7 +41,8 @@ export const useAppStore = defineStore('app', () => {
const customModels = ref<Record<string, string[]>>({})
const modelAliases = ref<Record<string, Record<string, string>>>({})
const modelVisibility = ref<ModelVisibility>({})
const healthPollTimer = ref<ReturnType<typeof setInterval>>()
const healthPollTimer = ref<ReturnType<typeof setTimeout>>()
let healthPollGeneration = 0
const nodeVersion = ref('')
const isDocker = ref(false)
@@ -301,13 +302,19 @@ export const useAppStore = defineStore('app', () => {
function startHealthPolling(interval = 30000) {
stopHealthPolling()
checkConnection()
healthPollTimer.value = setInterval(checkConnection, interval)
const generation = healthPollGeneration
const poll = async () => {
await checkConnection()
if (generation !== healthPollGeneration) return
healthPollTimer.value = setTimeout(poll, connected.value ? interval : Math.min(interval, 2000))
}
void poll()
}
function stopHealthPolling() {
healthPollGeneration++
if (healthPollTimer.value) {
clearInterval(healthPollTimer.value)
clearTimeout(healthPollTimer.value)
healthPollTimer.value = undefined
}
}
+3 -1
View File
@@ -995,11 +995,13 @@ export const useChatStore = defineStore('chat', () => {
} else {
clearActiveSession()
}
sessionsLoaded.value = true
return true
} catch (err) {
console.error('Failed to load sessions:', err)
return false
} finally {
isLoadingSessions.value = false
sessionsLoaded.value = true
}
}
+15 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ChatPanel from '@/components/hermes/chat/ChatPanel.vue'
import { useAppStore } from '@/stores/hermes/app'
@@ -24,6 +24,8 @@ const routeProfile = computed(() => {
return typeof value === 'string' && value.trim() ? value : null
})
const initialLoadFinished = ref(false)
const productTitle = 'hermes-ui'
const tabTitle = computed(() => {
if (route.name !== 'hermes.session') return productTitle
@@ -39,8 +41,8 @@ onUnmounted(() => {
})
async function loadRouteSession() {
await chatStore.loadSessions(chatStore.sessionProfileFilter, routeSessionId.value)
if (routeSessionId.value && chatStore.activeSessionId !== routeSessionId.value) {
const loaded = await chatStore.loadSessions(chatStore.sessionProfileFilter, routeSessionId.value)
if (loaded && routeSessionId.value && chatStore.activeSessionId !== routeSessionId.value) {
await router.replace({ name: 'hermes.chat' })
}
}
@@ -55,6 +57,16 @@ onMounted(async () => {
settingsStore.fetchSettings(),
])
await loadRouteSession()
initialLoadFinished.value = true
})
watch([() => appStore.connected, initialLoadFinished], async ([connected, initialized], [, wasInitialized]) => {
if (!connected || !initialized) return
if (!wasInitialized && chatStore.sessionsLoaded) return
await Promise.all([profilesStore.fetchProfiles(), settingsStore.fetchSettings(), appStore.loadModels()])
if (chatStore.isLoadingSessions) return
if (!chatStore.sessionsLoaded) await loadRouteSession()
else await chatStore.refreshSessionListOnly(chatStore.sessionProfileFilter)
})
watch([routeSessionId, routeProfile], async ([sessionId]) => {
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ChatPanel from '@/components/hermes/chat/ChatPanel.vue'
import { useAppStore } from '@/stores/hermes/app'
@@ -24,9 +24,11 @@ const routeProfile = computed(() => {
return typeof value === 'string' && value.trim() ? value : null
})
const initialLoadFinished = ref(false)
async function loadRouteSession() {
await chatStore.loadSessions(chatStore.sessionProfileFilter, routeSessionId.value)
if (routeSessionId.value && chatStore.activeSessionId !== routeSessionId.value) {
const loaded = await chatStore.loadSessions(chatStore.sessionProfileFilter, routeSessionId.value)
if (loaded && routeSessionId.value && chatStore.activeSessionId !== routeSessionId.value) {
await router.replace({ name: 'hermes.globalAgent' })
}
}
@@ -39,6 +41,16 @@ onMounted(async () => {
settingsStore.fetchSettings(),
])
await loadRouteSession()
initialLoadFinished.value = true
})
watch([() => appStore.connected, initialLoadFinished], async ([connected, initialized], [, wasInitialized]) => {
if (!connected || !initialized || chatStore.runtimeMode !== 'global_agent') return
if (!wasInitialized && chatStore.sessionsLoaded) return
await Promise.all([profilesStore.fetchProfiles(), settingsStore.fetchSettings(), appStore.loadModels()])
if (chatStore.isLoadingSessions) return
if (!chatStore.sessionsLoaded) await loadRouteSession()
else await chatStore.refreshSessionListOnly(chatStore.sessionProfileFilter)
})
onUnmounted(() => {
@@ -332,6 +332,7 @@ async function openDefaultHistorySession(replace = false) {
}
async function syncRouteSession() {
if (!hermesSessionsLoaded.value) return
const sessionId = routeSessionId.value
if (!sessionId) return
@@ -379,6 +380,13 @@ onUnmounted(() => {
window.removeEventListener('hermes:open-page-sidebar', openPageSidebar)
})
watch(() => appStore.connected, async (connected) => {
if (!connected || hermesSessionsLoading.value) return
await profilesStore.fetchProfiles()
await loadHermesSessions()
await syncRouteSession()
})
watch([routeSessionId, routeProfile], async ([sessionId]) => {
if (!sessionId) {
historySessionId.value = null
@@ -2147,14 +2147,15 @@ function nodeColor(node: { data: WorkflowAgentNodeData }) {
@press-enter="submitCreateWorkflow"
/>
</label>
<label class="workflow-field">
<div class="workflow-field">
<span class="workflow-field-label">{{ t('workflow.profile') }}</span>
<a-select
:aria-label="t('workflow.profile')"
v-model="createWorkflowProfile"
:options="workflowProfileOptions"
:loading="profilesStore.loading"
/>
</label>
</div>
<div class="workflow-field">
<span class="workflow-field-label">{{ t('workflow.workspace.select') }}</span>
<FolderPicker v-model="createWorkflowWorkspace" />
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import { getActiveEnvPath, getActiveAuthPath, getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from '../../services/hermes/hermes-profile'
import { readConfigYaml, readConfigYamlForProfile, updateConfigYaml, updateConfigYamlForProfile, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers'
import { discoverProviderModels } from '../../services/hermes/provider-model-discovery'
import { getCompatibleCustomProviders } from '../../services/hermes/custom-providers-compat'
import { buildProviderModelMap, PROVIDER_PRESETS } from '../../shared/providers'
import { getCopilotModelsDetailed, resolveCopilotOAuthToken, type CopilotModelMeta } from '../../services/hermes/copilot-models'
@@ -22,7 +23,7 @@ const PROVIDER_MODEL_CATALOG = buildProviderModelMap()
type ModelMeta = { preview?: boolean; disabled?: boolean; alias?: string }
type ProviderApiMode = 'chat_completions' | 'codex_responses' | 'anthropic_messages' | 'bedrock_converse' | 'codex_app_server'
type AvailableGroup = { provider: string; label: string; base_url: string; models: string[]; api_key: string; api_mode?: ProviderApiMode; builtin?: boolean; model_meta?: Record<string, ModelMeta>; available_models?: string[]; base_url_env?: string; provider_source?: 'custom_providers' | 'providers'; provider_key?: string }
type AvailableGroup = { provider: string; label: string; base_url: string; models: string[]; api_key: string; api_mode?: ProviderApiMode; builtin?: boolean; credentials_editable?: boolean; model_meta?: Record<string, ModelMeta>; available_models?: string[]; base_url_env?: string; provider_source?: 'custom_providers' | 'providers'; provider_key?: string }
type ModelVisibility = Record<string, ModelVisibilityRule>
type CustomModels = Record<string, string[]>
@@ -366,7 +367,9 @@ async function buildAvailableForProfile(
seenProviders.add(provider)
const availableModels = [...new Set(models)]
const apiMode = providerApiMode(provider, extra?.api_mode)
groups.push({ provider, label, base_url, models: availableModels, available_models: availableModels, api_key, ...(apiMode ? { api_mode: apiMode } : {}), ...(builtin ? { builtin: true } : {}), ...(model_meta ? { model_meta } : {}), ...(extra?.provider_source ? { provider_source: extra.provider_source } : {}), ...(extra?.provider_key ? { provider_key: extra.provider_key } : {}) })
groups.push({ provider, label, base_url, models: availableModels, available_models: availableModels, api_key,
...(PROVIDER_ENV_MAP[provider]?.base_url_env ? { base_url_env: PROVIDER_ENV_MAP[provider].base_url_env } : {}),
...(PROVIDER_ENV_MAP[provider]?.api_key_env ? { credentials_editable: true } : {}), ...(apiMode ? { api_mode: apiMode } : {}), ...(builtin ? { builtin: true } : {}), ...(model_meta ? { model_meta } : {}), ...(extra?.provider_source ? { provider_source: extra.provider_source } : {}), ...(extra?.provider_key ? { provider_key: extra.provider_key } : {}) })
}
const copilotEnabled = appConfig.copilotEnabled === true
@@ -421,7 +424,7 @@ async function buildAvailableForProfile(
let models = [...new Set([cp.model, ...configuredModels, ...builtinCatalogModels].filter(Boolean) as string[])]
const cachedModels = getCachedProviderModels(modelCatalogCache, providerKey, baseUrl)
if (cachedModels) models = [...new Set([...models, ...cachedModels])]
return { providerKey, label: cp.name, base_url: baseUrl, models, api_key: cp.api_key || '', api_mode: cp.api_mode, builtin: isBuiltinProviderKey(providerKey), provider_source: cp.source, provider_key: cp.provider_key }
return { providerKey, label: cp.name, base_url: baseUrl, models, api_key: cp.api_key || (cp.key_env ? envGetValue(cp.key_env) : ''), api_mode: cp.api_mode, builtin: isBuiltinProviderKey(providerKey), provider_source: cp.source, provider_key: cp.provider_key }
}),
)
for (const result of customFetches) {
@@ -805,7 +808,7 @@ export async function removeCustomModel(ctx: any) {
export async function fetchProviderModelList(ctx: any) {
try {
const body = ctx.request.body as { base_url?: string; api_key?: string; freeOnly?: boolean; provider?: string; label?: string; update_cache?: boolean }
const body = ctx.request.body as { base_url?: string; api_key?: string; freeOnly?: boolean; provider?: string; label?: string; update_cache?: boolean; api_mode?: ProviderApiMode }
const baseUrl = String(body?.base_url || '').trim()
const apiKey = String(body?.api_key || '').trim()
const freeOnly = body?.freeOnly === true
@@ -832,33 +835,15 @@ export async function fetchProviderModelList(ctx: any) {
return
}
const base = baseUrl.replace(/\/+$/, '')
const modelsUrl = /\/v\d+\/?$/.test(base) ? `${base}/models` : `${base}/v1/models`
const headers: Record<string, string> = {}
if (apiKey) headers.Authorization = `Bearer ${apiKey}`
const res = await fetch(modelsUrl, {
headers,
signal: AbortSignal.timeout(8000),
})
if (!res.ok) {
const apiMode = providerApiMode(provider, body?.api_mode)
let models = await discoverProviderModels(baseUrl, apiKey, apiMode === 'anthropic_messages')
if (freeOnly) models = models.filter(model => model.endsWith(':free'))
const uniqueModels = models
if (body?.update_cache === true && provider && uniqueModels.length === 0) {
ctx.status = 502
ctx.body = { error: `Provider returned HTTP ${res.status}` }
ctx.body = { error: 'Provider returned no models; existing catalog was kept' }
return
}
const data = await res.json() as { data?: Array<{ id?: unknown }> }
if (!Array.isArray(data.data)) {
ctx.status = 502
ctx.body = { error: 'Provider returned unexpected format' }
return
}
let models = data.data
.map(m => String(m?.id || '').trim())
.filter(Boolean)
if (freeOnly) models = models.filter(m => m.endsWith(':free'))
const uniqueModels = Array.from(new Set(models)).sort()
if (body?.update_cache === true && provider) {
await writeProviderModelCatalogEntry({
provider,
@@ -194,23 +194,35 @@ export async function create(ctx: any) {
export async function update(ctx: any) {
const poolKey = decodeURIComponent(ctx.params.poolKey)
const { name, base_url, api_key, model, api_mode } = ctx.request.body as {
name?: string; base_url?: string; api_key?: string; model?: string; api_mode?: ProviderApiMode
const { name, base_url, api_key, model, api_mode, source, providerKey } = ctx.request.body as {
name?: string; base_url?: string; api_key?: string; model?: string; api_mode?: ProviderApiMode; source?: string; providerKey?: string
}
const customApiMode = normalizeApiMode(api_mode)
if (base_url !== undefined) {
try {
const parsed = new URL(base_url)
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('Invalid protocol')
} catch {
ctx.status = 400; ctx.body = { error: 'base_url must be a valid HTTP or HTTPS URL' }; return
}
}
try {
const profile = requestedProfile(ctx)
const isCustom = poolKey.startsWith('custom:')
if (isCustom) {
const found = await updateConfigYamlForProfile(profile, (config) => {
if (!Array.isArray(config.custom_providers)) return { data: config, result: false, write: false }
const entry = (config.custom_providers as any[]).find((e: any) => {
return `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` === poolKey
})
if (!entry) return { data: config, result: false, write: false }
const found = await updateConfigYamlForProfile(profile, async (config) => {
const legacyIndex = source === 'providers' ? -1 : findLegacyCustomProviderIndex(config, poolKey)
const dictKey = source === 'custom_providers' || legacyIndex !== -1
? '' : findProviderDictKey(config, poolKey, providerKey)
const entry = legacyIndex !== -1 ? config.custom_providers[legacyIndex] : dictKey ? config.providers[dictKey] : null
const normalized = normalizeCustomProviderEntry(entry, dictKey, dictKey ? 'providers' : 'custom_providers')
if (!entry || !normalized || providerKeyForCustomName(normalized.name) !== poolKey) return { data: config, result: false, write: false }
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 (base_url !== undefined) entry.base_url = base_url.trim()
if (api_key !== undefined) {
if (normalized.key_env) await saveEnvValueForProfile(profile, normalized.key_env, api_key)
entry.api_key = api_key
}
if (model !== undefined) entry.model = model
if (customApiMode !== undefined) entry.api_mode = customApiMode
return { data: config, result: true }
@@ -220,10 +232,17 @@ export async function update(ctx: any) {
}
} else {
const envMapping = PROVIDER_ENV_MAP[poolKey]
if (!envMapping?.api_key_env) {
if (!envMapping || (!envMapping.api_key_env && !envMapping.base_url_env)) {
ctx.status = 400; ctx.body = { error: `Cannot update credentials for "${poolKey}"` }; return
}
if (api_key !== undefined) { await saveEnvValueForProfile(profile, envMapping.api_key_env, api_key) }
if (base_url !== undefined && !envMapping.base_url_env) {
ctx.status = 400; ctx.body = { error: `Cannot update base URL for "${poolKey}"` }; return
}
if (api_key !== undefined && !envMapping.api_key_env) {
ctx.status = 400; ctx.body = { error: `Cannot update API key for "${poolKey}"` }; return
}
if (api_key !== undefined) await saveEnvValueForProfile(profile, envMapping.api_key_env, api_key)
if (base_url !== undefined) await saveEnvValueForProfile(profile, envMapping.base_url_env, base_url.trim())
}
// TODO: Test if provider works without gateway restart
// try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') }
+6 -5
View File
@@ -18,7 +18,7 @@ import { setGroupChatServer } from './routes/hermes/group-chat'
import { setChatRunServer } from './routes/hermes/chat-run'
import { GroupChatServer } from './services/hermes/group-chat'
import { ChatRunSocket } from './services/hermes/run-chat'
import { startAgentBridgeManager } from './services/hermes/agent-bridge'
import { getAgentBridgeManager } from './services/hermes/agent-bridge'
import { HermesSkillInjector } from './services/hermes/skill-injector'
import { injectBundledMcpServer } from './services/hermes/studio-mcp-autoinject'
import { ensureProfileGatewaysRunning } from './services/hermes/gateway-autostart'
@@ -109,7 +109,7 @@ function skillInjectionDisabled(): boolean {
return envFlagEnabled('HERMES_WEB_UI_DISABLE_SKILL_INJECTION')
}
async function startRuntimeServicesBeforeListen(): Promise<void> {
async function startRuntimeServices(): Promise<void> {
if (gatewayAutostartDisabled()) {
console.log('[bootstrap] profile gateway check disabled by HERMES_WEB_UI_DISABLE_GATEWAY_AUTOSTART')
} else {
@@ -122,7 +122,7 @@ async function startRuntimeServicesBeforeListen(): Promise<void> {
}
try {
agentBridgeManager = await startAgentBridgeManager()
await agentBridgeManager.start()
console.log('[bootstrap] agent bridge started')
} catch (err) {
logger.warn(err, '[bootstrap] agent bridge failed to start')
@@ -187,8 +187,6 @@ export async function bootstrap() {
console.warn('[bootstrap] failed to inject bundled MCP server:', err instanceof Error ? err.message : err)
}
await startRuntimeServicesBeforeListen()
const app = new Koa()
// Initialize all web-ui SQLite tables
const { initAllStores } = await import('./db/hermes/init')
@@ -291,7 +289,10 @@ export async function bootstrap() {
})
})
agentBridgeManager = getAgentBridgeManager()
bindShutdown(servers, groupChatServer, chatRunServer, agentBridgeManager)
// Session/history APIs must remain available while the Python runtime starts.
void startRuntimeServices()
startVersionCheck()
}
@@ -373,10 +373,23 @@ export class AgentBridgeClient {
}, '[agent-bridge-client] request')
}
try {
const socket = await this.connectSocket()
socket.write(`${JSON.stringify(payload)}\n`)
const raw = await this.readResponse(socket, timeoutMs)
const response = JSON.parse(raw) as { ok?: boolean; error?: string }
const workerConnectDeadline = Date.now() + Math.max(0, this.connectRetryMs)
let response: { ok?: boolean; error?: string; error_type?: string }
for (;;) {
const socket = await this.connectSocket()
socket.write(`${JSON.stringify(payload)}\n`)
const raw = await this.readResponse(socket, timeoutMs)
response = JSON.parse(raw) as typeof response
// The broker reports worker connect failures before any request bytes
// reach the worker. Only these typed failures are safe to replay;
// timeouts, lost responses and agent errors may have side effects.
const workerNotListening = response.ok === false && (
(response.error_type === 'ConnectionRefusedError' && /^\[Errno (111|61|10061)\]/.test(response.error || '')) ||
(response.error_type === 'FileNotFoundError' && /^\[Errno 2\]/.test(response.error || ''))
)
if (!workerNotListening || Date.now() >= workerConnectDeadline) break
await delay(100)
}
if (!response.ok) {
const error = new AgentBridgeError(response.error || 'Agent bridge request failed')
error.response = response
@@ -11,8 +11,8 @@
* Studio historically only read the legacy `custom_providers:` list, which makes
* v12+ dict-shaped providers invisible in the model picker, catalog, and context
* lookups (Issue #1570). This helper mirrors Agent's behavior so Studio can read
* both shapes; write paths intentionally still target the legacy list (that's
* the canonical write surface Studio's UI manipulates today).
* both shapes. Creation targets the legacy list; updates and deletion preserve
* the original source and dictionary key returned by this helper.
*/
const KNOWN_KEYS = new Set([
@@ -0,0 +1,30 @@
/** Strict live discovery: never replace a cached catalog with a partial/error response. */
export async function discoverProviderModels(baseUrl: string, apiKey: string, anthropic = false): Promise<string[]> {
const base = baseUrl.replace(/\/+$/, '')
const modelsUrl = new URL(/\/v\d+\/?$/.test(base) ? `${base}/models` : `${base}/v1/models`)
const headers: Record<string, string> = {}
if (anthropic) {
if (apiKey) headers['x-api-key'] = apiKey
headers['anthropic-version'] = '2023-06-01'
} else if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`
}
const models: string[] = []
const cursors = new Set<string>()
const signal = AbortSignal.timeout(8000)
// Anthropic-compatible /models uses has_more + last_id cursor pagination.
// https://platform.claude.com/docs/en/api/models/list
for (let page = 0; page < 100; page++) {
const res = await fetch(modelsUrl.toString(), { headers, signal })
if (!res.ok) throw new Error(`Provider returned HTTP ${res.status}`)
const data = await res.json() as { data?: Array<{ id?: unknown }>; has_more?: boolean; last_id?: string }
if (!Array.isArray(data.data)) throw new Error('Provider returned unexpected format')
models.push(...data.data.map(model => String(model?.id || '').trim()).filter(Boolean))
if (!data.has_more) return [...new Set(models)].sort()
const cursor = data.last_id
if (typeof cursor !== 'string' || !cursor || cursors.has(cursor)) throw new Error('Provider returned invalid model pagination')
cursors.add(cursor)
modelsUrl.searchParams.set('after_id', cursor)
}
throw new Error('Provider model pagination limit exceeded')
}