From ff1f47174577cb0bfd27ad643ee672103e466b44 Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Thu, 21 May 2026 20:55:19 +0800 Subject: [PATCH] Persist custom Hermes models (#913) --- packages/client/src/api/hermes/system.ts | 24 ++++++ packages/client/src/stores/hermes/app.ts | 45 ++++++++-- .../server/src/controllers/hermes/models.ts | 83 ++++++++++++++++++- packages/server/src/routes/hermes/models.ts | 2 + packages/server/src/services/app-config.ts | 5 ++ .../hermes/run-chat/handle-bridge-run.ts | 40 ++++++++- tests/client/app-store.test.ts | 83 +++++++++++++++++++ .../model-visibility-controller.test.ts | 78 +++++++++++++++++ .../run-chat-bridge-terminal-error.test.ts | 71 ++++++++++++++++ 9 files changed, 418 insertions(+), 13 deletions(-) create mode 100644 tests/server/run-chat-bridge-terminal-error.test.ts diff --git a/packages/client/src/api/hermes/system.ts b/packages/client/src/api/hermes/system.ts index f05660f..fae002b 100644 --- a/packages/client/src/api/hermes/system.ts +++ b/packages/client/src/api/hermes/system.ts @@ -31,6 +31,7 @@ export interface ModelVisibilityRule { } export type ModelVisibility = Record +export type CustomModels = Record export interface AvailableModelGroup { provider: string // credential pool key (e.g. "zai", "custom:subrouter.ai") @@ -61,6 +62,7 @@ export interface AvailableModelsResponse { /** Web UI-only display aliases keyed by provider -> canonical model ID. */ model_aliases?: Record> model_visibility?: ModelVisibility + custom_models?: CustomModels } export interface CustomProvider { @@ -163,3 +165,25 @@ export async function updateModelVisibility(data: { body: JSON.stringify(data), }) } + +export async function addCustomModel(data: { + provider: string + model: string +}): Promise<{ success: boolean; custom_models: CustomModels }> { + return request<{ success: boolean; custom_models: CustomModels }>('/api/hermes/custom-model', { + method: 'PUT', + body: JSON.stringify(data), + }) +} + +export async function removeCustomModel(data: { + provider: string + model: string +}): Promise<{ success: boolean; custom_models: CustomModels }> { + const params = new URLSearchParams() + params.set('provider', data.provider) + params.set('model', data.model) + return request<{ success: boolean; custom_models: CustomModels }>(`/api/hermes/custom-model?${params.toString()}`, { + method: 'DELETE', + }) +} diff --git a/packages/client/src/stores/hermes/app.ts b/packages/client/src/stores/hermes/app.ts index a1094a4..85ab228 100644 --- a/packages/client/src/stores/hermes/app.ts +++ b/packages/client/src/stores/hermes/app.ts @@ -3,6 +3,8 @@ import { ref } from 'vue' import { checkHealth, fetchAvailableModels, + addCustomModel as persistCustomModel, + removeCustomModel as deletePersistedCustomModel, updateDefaultModel, updateModelVisibility, triggerUpdate, @@ -85,6 +87,7 @@ export const useAppStore = defineStore('app', () => { profileModelGroups.value = res.profiles || [] modelAliases.value = res.model_aliases || {} modelVisibility.value = res.model_visibility || {} + customModels.value = res.custom_models || {} const defaultModel = res.default || '' const defaultProvider = res.default_provider || '' @@ -118,19 +121,19 @@ export const useAppStore = defineStore('app', () => { const selectedGroup = explicitGroup || inferredGroup! selectedModel.value = defaultModel selectedProvider.value = selectedGroup.provider - customModels.value = {} } else if (unlistedDefault) { selectedModel.value = defaultModel selectedProvider.value = defaultProvider - customModels.value = { [defaultProvider]: [defaultModel] } + customModels.value = { + ...customModels.value, + [defaultProvider]: Array.from(new Set([...(customModels.value[defaultProvider] || []), defaultModel])), + } } else if (fallbackGroup) { selectedModel.value = fallbackGroup.models[0] selectedProvider.value = fallbackGroup.provider - customModels.value = {} } else { selectedModel.value = '' selectedProvider.value = '' - customModels.value = {} } } @@ -178,6 +181,25 @@ export const useAppStore = defineStore('app', () => { return getModelAlias(modelId, provider) || modelId } + function removeModelFromGroupList(groups: AvailableModelGroup[], provider: string, modelId: string): AvailableModelGroup[] { + return groups.map(group => { + if (group.provider !== provider) return group + return { + ...group, + models: group.models.filter(model => model !== modelId), + available_models: group.available_models?.filter(model => model !== modelId), + } + }) + } + + function removeModelFromLoadedGroups(provider: string, modelId: string) { + modelGroups.value = removeModelFromGroupList(modelGroups.value, provider, modelId) + profileModelGroups.value = profileModelGroups.value.map(profileEntry => ({ + ...profileEntry, + groups: removeModelFromGroupList(profileEntry.groups, provider, modelId), + })) + } + async function setModelAlias(modelId: string, provider: string, alias: string) { const cleanAlias = alias.trim() await updateModelAlias({ provider, model: modelId, alias: cleanAlias }) @@ -204,10 +226,8 @@ export const useAppStore = defineStore('app', () => { selectedProvider.value = provider || '' // Track as custom if not already in the server-fetched list if (provider && !modelGroups.value.find(g => g.provider === provider)?.models.includes(modelId)) { - if (!customModels.value[provider]) customModels.value[provider] = [] - if (!customModels.value[provider].includes(modelId)) { - customModels.value[provider] = [...customModels.value[provider], modelId] - } + const res = await persistCustomModel({ provider, model: modelId }) + customModels.value = res.custom_models || {} } } catch (err: any) { console.error('Failed to switch model:', err) @@ -222,7 +242,14 @@ export const useAppStore = defineStore('app', () => { const remaining = providerModels.filter(m => m !== modelId) if (remaining.length > 0) nextCustomModels[provider] = remaining else delete nextCustomModels[provider] - customModels.value = nextCustomModels + try { + const res = await deletePersistedCustomModel({ provider, model: modelId }) + customModels.value = res.custom_models || nextCustomModels + } catch (err) { + console.error('Failed to remove custom model:', err) + customModels.value = nextCustomModels + } + removeModelFromLoadedGroups(provider, modelId) if (selectedModel.value === modelId && selectedProvider.value === provider) { const providerGroup = modelGroups.value.find(g => g.provider === provider && g.models.length > 0) diff --git a/packages/server/src/controllers/hermes/models.ts b/packages/server/src/controllers/hermes/models.ts index 55459b7..65b49f5 100644 --- a/packages/server/src/controllers/hermes/models.ts +++ b/packages/server/src/controllers/hermes/models.ts @@ -14,6 +14,7 @@ const PROVIDER_MODEL_CATALOG = buildProviderModelMap() type ModelMeta = { preview?: boolean; disabled?: boolean; alias?: string } type AvailableGroup = { provider: string; label: string; base_url: string; models: string[]; api_key: string; builtin?: boolean; model_meta?: Record; available_models?: string[] } type ModelVisibility = Record +type CustomModels = Record const RESERVED_ALIAS_KEYS = new Set(['__proto__', 'prototype', 'constructor']) @@ -67,6 +68,28 @@ function uniqueStrings(values: unknown): string[] { return Array.from(new Set(values.map(v => String(v || '').trim()).filter(Boolean))) } +function normalizeCustomModels(input: unknown): CustomModels { + if (!input || typeof input !== 'object' || Array.isArray(input)) return {} + const out: CustomModels = {} + for (const [provider, rawModels] of Object.entries(input as Record)) { + const providerKey = String(provider || '').trim() + if (!providerKey) continue + const models = uniqueStrings(rawModels) + if (models.length > 0) out[providerKey] = models + } + return out +} + +function applyCustomModels(groups: AvailableGroup[], customModels: CustomModels): AvailableGroup[] { + return groups.map(group => { + const extra = customModels[group.provider] || [] + if (!extra.length) return group + const models = [...new Set([...group.models, ...extra])] + const availableModels = [...new Set([...(group.available_models || group.models), ...extra])] + return { ...group, models, available_models: availableModels } + }) +} + function normalizeModelVisibility(input: unknown): ModelVisibility { if (!input || typeof input !== 'object' || Array.isArray(input)) return {} const out: ModelVisibility = {} @@ -349,8 +372,9 @@ async function buildAvailableForProfile( g.models = Array.from(new Set(g.models)) g.available_models = Array.from(new Set(g.available_models || g.models)) } + const groupsWithCustomModels = applyCustomModels(groups, normalizeCustomModels(appConfig.customModels)) - return { profile, default: currentDefault, default_provider: currentDefaultProvider, groups } + return { profile, default: currentDefault, default_provider: currentDefaultProvider, groups: groupsWithCustomModels } } export async function getAvailable(ctx: any) { @@ -362,6 +386,7 @@ export async function getAvailable(ctx: any) { const appConfig = await readAppConfig() const modelAliases = normalizeAliases(appConfig.modelAliases) const modelVisibility = normalizeModelVisibility(appConfig.modelVisibility) + const customModels = normalizeCustomModels(appConfig.customModels) const fetchCache: ProviderFetchCache = new Map() const profileResults = await Promise.all( listProfileNamesFromDisk().map(profile => buildAvailableForProfile(profile, fetchCache, appConfig)), @@ -392,6 +417,7 @@ export async function getAvailable(ctx: any) { allProviders: applyModelAliases(allProvidersBase, modelAliases), model_aliases: modelAliases, model_visibility: modelVisibility, + custom_models: customModels, profiles: profileResults.map(result => ({ profile: result.profile, default: result.default, @@ -405,6 +431,7 @@ export async function getAvailable(ctx: any) { const appConfigForProfile = await readAppConfig() const modelAliasesForProfile = normalizeAliases(appConfigForProfile.modelAliases) const modelVisibilityForProfile = normalizeModelVisibility(appConfigForProfile.modelVisibility) + const customModelsForProfile = normalizeCustomModels(appConfigForProfile.customModels) const profileResult = await buildAvailableForProfile(requestedProfile, new Map(), appConfigForProfile) const profileGroupsWithAliases = applyModelAliases(profileResult.groups, modelAliasesForProfile) const visibleProfileGroups = applyModelVisibility(profileGroupsWithAliases, modelVisibilityForProfile) @@ -422,6 +449,7 @@ export async function getAvailable(ctx: any) { })), modelAliasesForProfile), model_aliases: modelAliasesForProfile, model_visibility: modelVisibilityForProfile, + custom_models: customModelsForProfile, profiles: [{ profile: profileResult.profile, default: profileResult.default, @@ -511,6 +539,7 @@ export async function getAvailable(ctx: any) { const copilotEnabled = appConfig.copilotEnabled === true const modelAliases = normalizeAliases(appConfig.modelAliases) const modelVisibility = normalizeModelVisibility(appConfig.modelVisibility) + const customModels = normalizeCustomModels(appConfig.customModels) // 兼容老用户:上一版本会"自动 fallback discovery"出 Copilot;升级后这些用户的 // config.yaml 可能仍把 model.default 指向某个 copilot 模型。若此时 copilot 已不 @@ -598,7 +627,7 @@ export async function getAvailable(ctx: any) { } for (const g of groups) { g.models = Array.from(new Set(g.models)) } - const groupsWithAliases = applyModelAliases(groups, modelAliases) + const groupsWithAliases = applyModelAliases(applyCustomModels(groups, customModels), modelAliases) const visibleGroups = applyModelVisibility(groupsWithAliases, modelVisibility) const visibleDefault = resolveVisibleDefault(currentDefault, currentDefaultProvider, visibleGroups) @@ -638,6 +667,7 @@ export async function getAvailable(ctx: any) { allProviders, model_aliases: modelAliases, model_visibility: modelVisibility, + custom_models: customModels, } return } @@ -649,6 +679,7 @@ export async function getAvailable(ctx: any) { allProviders, model_aliases: modelAliases, model_visibility: modelVisibility, + custom_models: customModels, } } catch (err: any) { ctx.status = 500 @@ -656,6 +687,54 @@ export async function getAvailable(ctx: any) { } } +export async function addCustomModel(ctx: any) { + const { provider, model } = (ctx.request.body || {}) as { provider?: string; model?: string } + const providerKey = String(provider || '').trim() + const modelId = String(model || '').trim() + if (!providerKey || !modelId) { + ctx.status = 400 + ctx.body = { error: 'Missing provider or model' } + return + } + + try { + const appConfig = await readAppConfig() + const customModels = normalizeCustomModels(appConfig.customModels) + customModels[providerKey] = Array.from(new Set([...(customModels[providerKey] || []), modelId])) + const saved = await writeAppConfig({ customModels }) + ctx.body = { success: true, custom_models: normalizeCustomModels(saved.customModels) } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function removeCustomModel(ctx: any) { + const body = (ctx.request.body || {}) as { provider?: string; model?: string } + const provider = body.provider ?? ctx.query?.provider + const model = body.model ?? ctx.query?.model + const providerKey = String(provider || '').trim() + const modelId = String(model || '').trim() + if (!providerKey || !modelId) { + ctx.status = 400 + ctx.body = { error: 'Missing provider or model' } + return + } + + try { + const appConfig = await readAppConfig() + const customModels = normalizeCustomModels(appConfig.customModels) + const remaining = (customModels[providerKey] || []).filter(item => item !== modelId) + if (remaining.length > 0) customModels[providerKey] = remaining + else delete customModels[providerKey] + const saved = await writeAppConfig({ customModels }) + ctx.body = { success: true, custom_models: normalizeCustomModels(saved.customModels) } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + export async function fetchProviderModelList(ctx: any) { try { const body = ctx.request.body as { base_url?: string; api_key?: string; freeOnly?: boolean } diff --git a/packages/server/src/routes/hermes/models.ts b/packages/server/src/routes/hermes/models.ts index 9c922eb..584f0c3 100644 --- a/packages/server/src/routes/hermes/models.ts +++ b/packages/server/src/routes/hermes/models.ts @@ -9,6 +9,8 @@ modelRoutes.get('/api/hermes/config/models', ctrl.getConfigModels) modelRoutes.put('/api/hermes/config/model', ctrl.setConfigModel) modelRoutes.put('/api/hermes/model-alias', ctrl.setModelAlias) modelRoutes.put('/api/hermes/model-visibility', ctrl.setModelVisibility) +modelRoutes.put('/api/hermes/custom-model', ctrl.addCustomModel) +modelRoutes.delete('/api/hermes/custom-model', ctrl.removeCustomModel) // Model context routes modelRoutes.get('/api/hermes/model-context', ctrl.getModelContext) diff --git a/packages/server/src/services/app-config.ts b/packages/server/src/services/app-config.ts index bfb1d6a..4d3fdb3 100644 --- a/packages/server/src/services/app-config.ts +++ b/packages/server/src/services/app-config.ts @@ -22,6 +22,11 @@ export interface AppConfig { // These aliases never replace the canonical model ID sent back to Hermes. modelAliases?: Record> + // Web UI-only manually entered model IDs. Keys are provider -> model IDs. + // This lets users persist provider-supported models that are absent from a + // provider catalog response without changing Hermes Agent config.yaml. + customModels?: Record + // Web UI-only model picker visibility. This filters what the WUI exposes in // its sidebar/model pages and never renames or rewrites Hermes canonical // provider/model IDs. Hermes CLI config remains the upstream source of truth. diff --git a/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts b/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts index a2cbfd9..4582f31 100644 --- a/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts +++ b/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts @@ -30,6 +30,41 @@ import { filterBridgeToolCallMarkupDelta } from './bridge-delta' const BRIDGE_USAGE_FLUSH_DELAY_MS = 200 +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function looksLikeAgentFailure(value: string): boolean { + return /\bAPI call failed after\b/i.test(value) + || /\bHTTP\s+(?:4\d\d|5\d\d)\b/i.test(value) + || /\b(?:401|403|429|500|502|503|504)\b/.test(value) && /\b(?:unauthorized|forbidden|rate limit|unavailable|failed|error)\b/i.test(value) +} + +export function bridgeTerminalError(chunk: Pick): string | null { + const result = chunk.result && typeof chunk.result === 'object' && !Array.isArray(chunk.result) + ? chunk.result as Record + : null + const resultError = result + ? stringValue(result.error) + || stringValue(result.exception) + || stringValue(result.message) + : '' + const finalResponse = result ? stringValue(result.final_response) : '' + + if (chunk.status === 'error') { + return stringValue(chunk.error) || resultError || finalResponse || 'Agent run failed' + } + + if (result?.failed === true || result?.completed === false) { + return resultError || finalResponse || 'Agent reported failure' + } + + if (resultError) return resultError + if (finalResponse && looksLikeAgentFailure(finalResponse)) return finalResponse + + return null +} + export async function handleBridgeRun( nsp: ReturnType, socket: Socket, @@ -491,13 +526,14 @@ async function applyBridgeChunkAsync( state.runId = undefined state.activeRunMarker = undefined state.events = [] - const eventName = chunk.status === 'error' ? 'run.failed' : 'run.completed' + const terminalError = bridgeTerminalError(chunk) + const eventName = terminalError ? 'run.failed' : 'run.completed' const payload = { event: eventName, run_id: chunk.run_id, output: chunk.output || state.bridgeOutput || '', result: chunk.result, - error: chunk.error, + error: terminalError || chunk.error, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, queue_remaining: state.queue.length, diff --git a/tests/client/app-store.test.ts b/tests/client/app-store.test.ts index bac461d..321ec2f 100644 --- a/tests/client/app-store.test.ts +++ b/tests/client/app-store.test.ts @@ -5,6 +5,8 @@ import { createPinia, setActivePinia } from 'pinia' const mockSystemApi = vi.hoisted(() => ({ checkHealth: vi.fn(), fetchAvailableModels: vi.fn(), + addCustomModel: vi.fn(), + removeCustomModel: vi.fn(), updateDefaultModel: vi.fn(), updateModelAlias: vi.fn(), updateModelVisibility: vi.fn(), @@ -20,6 +22,8 @@ describe('App Store', () => { beforeEach(() => { setActivePinia(createPinia()) vi.clearAllMocks() + mockSystemApi.addCustomModel.mockResolvedValue({ success: true, custom_models: {} }) + mockSystemApi.removeCustomModel.mockResolvedValue({ success: true, custom_models: {} }) window.localStorage.clear() }) @@ -318,6 +322,32 @@ describe('App Store', () => { expect(store.customModels).toEqual({ deepseek: ['manually-supported-id'] }) }) + it('loads persisted custom models from the server response', async () => { + mockSystemApi.fetchAvailableModels.mockResolvedValue({ + default: 'gemma-4-26b-a4b-it', + default_provider: 'google-ai-studio', + groups: [{ + provider: 'google-ai-studio', + label: 'Google AI Studio', + base_url: 'https://generativelanguage.googleapis.com/v1beta', + models: ['gemma-4-26b-a4b-it'], + api_key: '', + }], + allProviders: [], + custom_models: { + 'google-ai-studio': ['gemma-4-26b-a4b-it'], + }, + }) + const store = useAppStore() + + await store.loadModels() + + expect(store.selectedModel).toBe('gemma-4-26b-a4b-it') + expect(store.customModels).toEqual({ + 'google-ai-studio': ['gemma-4-26b-a4b-it'], + }) + }) + it('saves and clears model aliases via the Web UI-only alias API', async () => { mockSystemApi.updateModelAlias.mockResolvedValue(undefined) const store = useAppStore() @@ -345,17 +375,70 @@ describe('App Store', () => { models: ['deepseek-v4-flash'], api_key: '', }] + mockSystemApi.addCustomModel.mockResolvedValue({ + success: true, + custom_models: { deepseek: ['test'] }, + }) + mockSystemApi.removeCustomModel.mockResolvedValue({ + success: true, + custom_models: {}, + }) await store.switchModel('test', 'deepseek') expect(store.selectedModel).toBe('test') expect(store.customModels).toEqual({ deepseek: ['test'] }) + expect(mockSystemApi.addCustomModel).toHaveBeenCalledWith({ + provider: 'deepseek', + model: 'test', + }) await store.removeCustomModel('test', 'deepseek') expect(store.customModels).toEqual({}) + expect(mockSystemApi.removeCustomModel).toHaveBeenCalledWith({ + provider: 'deepseek', + model: 'test', + }) expect(store.selectedModel).toBe('deepseek-v4-flash') expect(mockSystemApi.updateDefaultModel).toHaveBeenLastCalledWith({ default: 'deepseek-v4-flash', provider: 'deepseek', }) }) + + it('removes deleted custom models from loaded model groups immediately', async () => { + mockSystemApi.removeCustomModel.mockResolvedValue({ + success: true, + custom_models: {}, + }) + const store = useAppStore() + store.customModels = { deepseek: ['manual-model'] } + store.modelGroups = [{ + provider: 'deepseek', + label: 'DeepSeek', + base_url: 'https://api.deepseek.com/v1', + models: ['deepseek-v4-flash', 'manual-model'], + available_models: ['deepseek-v4-flash', 'manual-model'], + api_key: '', + }] + store.profileModelGroups = [{ + profile: 'default', + default: 'deepseek-v4-flash', + default_provider: 'deepseek', + groups: [{ + provider: 'deepseek', + label: 'DeepSeek', + base_url: 'https://api.deepseek.com/v1', + models: ['deepseek-v4-flash', 'manual-model'], + available_models: ['deepseek-v4-flash', 'manual-model'], + api_key: '', + }], + }] + + await store.removeCustomModel('manual-model', 'deepseek') + + expect(store.modelGroups[0].models).toEqual(['deepseek-v4-flash']) + expect(store.modelGroups[0].available_models).toEqual(['deepseek-v4-flash']) + expect(store.profileModelGroups[0].groups[0].models).toEqual(['deepseek-v4-flash']) + expect(store.profileModelGroups[0].groups[0].available_models).toEqual(['deepseek-v4-flash']) + }) }) diff --git a/tests/server/model-visibility-controller.test.ts b/tests/server/model-visibility-controller.test.ts index e9c30ba..c67f5b0 100644 --- a/tests/server/model-visibility-controller.test.ts +++ b/tests/server/model-visibility-controller.test.ts @@ -130,6 +130,27 @@ describe('models controller — model visibility', () => { deepseek: { mode: 'include', models: ['deepseek-reasoner'] }, }) }) + + it('merges Web UI custom models into available provider groups', async () => { + mockReadAppConfig.mockResolvedValue({ + customModels: { + deepseek: ['gemma-4-26b-a4b-it', 'deepseek-chat'], + }, + }) + + const ctx = makeCtx() + await ctrl.getAvailable(ctx) + + expect(ctx.status).toBe(200) + expect(ctx.body.groups[0]).toMatchObject({ + provider: 'deepseek', + models: ['deepseek-chat', 'deepseek-reasoner', 'gemma-4-26b-a4b-it'], + available_models: ['deepseek-chat', 'deepseek-reasoner', 'gemma-4-26b-a4b-it'], + }) + expect(ctx.body.custom_models).toEqual({ + deepseek: ['gemma-4-26b-a4b-it', 'deepseek-chat'], + }) + }) it('accepts OAuth providers stored in credential_pool entries', async () => { mockExistsSync.mockReturnValue(true) mockReadFileSync.mockReturnValue(JSON.stringify({ @@ -281,6 +302,63 @@ describe('models controller — model visibility', () => { }) }) + it('adds and removes custom models in web-ui app config only', async () => { + mockReadAppConfig.mockResolvedValueOnce({ + customModels: { deepseek: ['existing'] }, + }) + mockWriteAppConfig.mockResolvedValueOnce({ + customModels: { deepseek: ['existing', 'manual-model'] }, + }) + + const addCtx = makeCtx({ provider: 'deepseek', model: 'manual-model' }) + await ctrl.addCustomModel(addCtx) + + expect(mockWriteAppConfig).toHaveBeenCalledWith({ + customModels: { deepseek: ['existing', 'manual-model'] }, + }) + expect(addCtx.body).toEqual({ + success: true, + custom_models: { deepseek: ['existing', 'manual-model'] }, + }) + + mockReadAppConfig.mockResolvedValueOnce({ + customModels: { deepseek: ['existing', 'manual-model'] }, + }) + mockWriteAppConfig.mockResolvedValueOnce({ + customModels: { deepseek: ['existing'] }, + }) + + const removeCtx = makeCtx({ provider: 'deepseek', model: 'manual-model' }) + await ctrl.removeCustomModel(removeCtx) + + expect(mockWriteAppConfig).toHaveBeenLastCalledWith({ + customModels: { deepseek: ['existing'] }, + }) + expect(removeCtx.body).toEqual({ + success: true, + custom_models: { deepseek: ['existing'] }, + }) + }) + + it('removes custom models from query params when DELETE body is missing', async () => { + mockReadAppConfig.mockResolvedValueOnce({ + customModels: { deepseek: ['manual-model'] }, + }) + mockWriteAppConfig.mockResolvedValueOnce({ + customModels: {}, + }) + + const ctx = makeCtx() + ctx.request.body = undefined + ctx.query = { provider: 'deepseek', model: 'manual-model' } + + await ctrl.removeCustomModel(ctx) + + expect(ctx.status).toBe(200) + expect(mockWriteAppConfig).toHaveBeenCalledWith({ customModels: {} }) + expect(ctx.body).toEqual({ success: true, custom_models: {} }) + }) + it('rejects empty include lists', async () => { const ctx = makeCtx({ provider: 'deepseek', mode: 'include', models: [] }) await ctrl.setModelVisibility(ctx) diff --git a/tests/server/run-chat-bridge-terminal-error.test.ts b/tests/server/run-chat-bridge-terminal-error.test.ts new file mode 100644 index 0000000..465c74c --- /dev/null +++ b/tests/server/run-chat-bridge-terminal-error.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' + +import { bridgeTerminalError } from '../../packages/server/src/services/hermes/run-chat/handle-bridge-run' + +describe('bridge terminal error detection', () => { + it('uses bridge status errors directly', () => { + expect(bridgeTerminalError({ + status: 'error', + error: 'bridge crashed', + result: null, + } as any)).toBe('bridge crashed') + }) + + it('surfaces agent result failure flags as run failures', () => { + expect(bridgeTerminalError({ + status: 'complete', + error: undefined, + result: { + failed: true, + completed: false, + error: 'API call failed after 3 retries. HTTP 503: no available channel', + final_response: 'API call failed after 3 retries. HTTP 503: no available channel', + }, + } as any)).toBe('API call failed after 3 retries. HTTP 503: no available channel') + }) + + it('falls back to final_response for failed results without an error field', () => { + expect(bridgeTerminalError({ + status: 'complete', + result: { + completed: false, + final_response: 'API call failed after 3 retries: timeout', + }, + } as any)).toBe('API call failed after 3 retries: timeout') + }) + + it('surfaces HTTP auth/provider errors even when failure flags are missing', () => { + expect(bridgeTerminalError({ + status: 'complete', + result: { + final_response: 'API call failed after 3 retries. HTTP 403: forbidden', + }, + } as any)).toBe('API call failed after 3 retries. HTTP 403: forbidden') + + expect(bridgeTerminalError({ + status: 'complete', + result: { + error: 'HTTP 401: unauthorized', + }, + } as any)).toBe('HTTP 401: unauthorized') + }) + + it('surfaces generic provider result errors even without failed flags', () => { + expect(bridgeTerminalError({ + status: 'complete', + result: { + error: '分组 subrouter 下模型 test 无可用渠道(distributor)', + }, + } as any)).toBe('分组 subrouter 下模型 test 无可用渠道(distributor)') + }) + + it('does not flag successful complete results', () => { + expect(bridgeTerminalError({ + status: 'complete', + result: { + completed: true, + final_response: 'done', + }, + } as any)).toBeNull() + }) +})