Persist custom Hermes models (#913)
This commit is contained in:
@@ -31,6 +31,7 @@ export interface ModelVisibilityRule {
|
||||
}
|
||||
|
||||
export type ModelVisibility = Record<string, ModelVisibilityRule>
|
||||
export type CustomModels = Record<string, string[]>
|
||||
|
||||
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<string, Record<string, string>>
|
||||
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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, ModelMeta>; available_models?: string[] }
|
||||
type ModelVisibility = Record<string, ModelVisibilityRule>
|
||||
type CustomModels = Record<string, string[]>
|
||||
|
||||
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<string, unknown>)) {
|
||||
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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -22,6 +22,11 @@ export interface AppConfig {
|
||||
// These aliases never replace the canonical model ID sent back to Hermes.
|
||||
modelAliases?: Record<string, Record<string, string>>
|
||||
|
||||
// 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<string, string[]>
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -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<AgentBridgeOutput, 'status' | 'error' | 'result'>): string | null {
|
||||
const result = chunk.result && typeof chunk.result === 'object' && !Array.isArray(chunk.result)
|
||||
? chunk.result as Record<string, unknown>
|
||||
: 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<Server['of']>,
|
||||
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,
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user