Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions packages/server/src/controllers/hermes/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { readFile } from 'fs/promises'
import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import { getActiveEnvPath, getActiveAuthPath, getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from '../../services/hermes/hermes-profile'
import { readConfigYaml, readConfigYamlForProfile, updateConfigYaml, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers'
import { readConfigYaml, readConfigYamlForProfile, updateConfigYaml, fetchProviderModels, buildModelGroups, listConfiguredProviderModels, PROVIDER_ENV_MAP } from '../../services/config-helpers'
import { buildProviderModelMap, PROVIDER_PRESETS } from '../../shared/providers'
import { getCopilotModelsDetailed, resolveCopilotOAuthToken, type CopilotModelMeta } from '../../services/hermes/copilot-models'
import { readAppConfig, writeAppConfig, type ModelVisibilityRule } from '../../services/app-config'
Expand Down Expand Up @@ -208,7 +208,7 @@ async function buildAvailableForProfile(
let currentDefault = ''
let currentDefaultProvider = ''
if (typeof modelSection === 'object' && modelSection !== null) {
currentDefault = String(modelSection.default || '').trim()
currentDefault = String(modelSection.default || modelSection.model || '').trim()
currentDefaultProvider = String(modelSection.provider || '').trim()
if (currentDefaultProvider === 'custom' && currentDefault) {
const cps = Array.isArray(config.custom_providers) ? config.custom_providers as any[] : []
Expand Down Expand Up @@ -252,9 +252,20 @@ async function buildAvailableForProfile(
const groups: AvailableGroup[] = []
const seenProviders = new Set<string>()
const addGroup = (provider: string, label: string, base_url: string, models: string[], api_key: string, builtin?: boolean, model_meta?: Record<string, ModelMeta>) => {
if (seenProviders.has(provider)) return
seenProviders.add(provider)
const availableModels = [...new Set(models)]
const existing = groups.find(group => group.provider === provider)
if (existing) {
existing.models = [...new Set([...existing.models, ...availableModels])]
existing.available_models = [...new Set([...(existing.available_models || existing.models), ...availableModels])]
existing.label = existing.label || label
existing.base_url = existing.base_url || base_url
existing.api_key = existing.api_key || api_key
existing.builtin = existing.builtin || builtin
existing.model_meta = { ...(existing.model_meta || {}), ...(model_meta || {}) }
if (existing.model_meta && Object.keys(existing.model_meta).length === 0) delete existing.model_meta
return
}
seenProviders.add(provider)
groups.push({ provider, label, base_url, models: availableModels, available_models: availableModels, api_key, ...(builtin ? { builtin: true } : {}), ...(model_meta ? { model_meta } : {}) })
}

Expand Down Expand Up @@ -313,6 +324,12 @@ async function buildAvailableForProfile(
}
}

const configuredProviders = listConfiguredProviderModels(config)
for (const provider of configuredProviders) {
const apiKey = provider.key_env ? envGetValue(provider.key_env) : ''
addGroup(provider.provider, provider.label, provider.base_url, provider.models, apiKey)
}

const customProviders = Array.isArray(config.custom_providers)
? config.custom_providers as Array<{ name: string; base_url: string; model: string; api_key?: string }>
: []
Expand Down Expand Up @@ -436,7 +453,7 @@ export async function getAvailable(ctx: any) {
let currentDefault = ''
let currentDefaultProvider = ''
if (typeof modelSection === 'object' && modelSection !== null) {
currentDefault = String(modelSection.default || '').trim()
currentDefault = String(modelSection.default || modelSection.model || '').trim()
currentDefaultProvider = String(modelSection.provider || '').trim()
// When hermes CLI sets provider: custom, resolve to custom:name
// by matching base_url + model against custom_providers
Expand Down
73 changes: 71 additions & 2 deletions packages/server/src/services/config-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,19 +235,88 @@ export async function fetchProviderModels(baseUrl: string, apiKey: string, freeO
}
}

function normalizeEnvKey(key: unknown): string {
const trimmed = String(key || '').trim()
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed) ? trimmed : ''
}

export function extractConfiguredProviderModels(
config: Record<string, any>,
providerKey: string,
): { label: string; base_url: string; key_env: string; models: string[] } | null {
if (!providerKey || !config.providers || typeof config.providers !== 'object' || Array.isArray(config.providers)) {
return null
}
const entry = config.providers[providerKey]
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null

const models: string[] = []
if (entry.models && typeof entry.models === 'object' && !Array.isArray(entry.models)) {
for (const model of Object.keys(entry.models)) {
const trimmed = String(model || '').trim()
if (trimmed) models.push(trimmed)
}
} else if (Array.isArray(entry.models)) {
for (const model of entry.models) {
const trimmed = String(model || '').trim()
if (trimmed) models.push(trimmed)
}
}

const defaultModel = String(entry.default_model || '').trim()
if (defaultModel) models.push(defaultModel)

const modelSection = config.model
if (typeof modelSection === 'object' && modelSection !== null) {
const currentProvider = String(modelSection.provider || '').trim()
const currentDefault = String(modelSection.default || modelSection.model || '').trim()
if (currentProvider === providerKey && currentDefault) models.push(currentDefault)
}

const uniqueModels = Array.from(new Set(models))
if (uniqueModels.length === 0) return null

return {
label: String(entry.name || providerKey).trim() || providerKey,
base_url: String(entry.base_url || entry.api || '').trim(),
key_env: normalizeEnvKey(entry.key_env),
models: uniqueModels,
}
}

export function listConfiguredProviderModels(config: Record<string, any>): Array<{ provider: string; label: string; base_url: string; key_env: string; models: string[] }> {
if (!config.providers || typeof config.providers !== 'object' || Array.isArray(config.providers)) return []
const result: Array<{ provider: string; label: string; base_url: string; key_env: string; models: string[] }> = []
for (const providerKey of Object.keys(config.providers)) {
const provider = String(providerKey || '').trim()
if (!provider) continue
const extracted = extractConfiguredProviderModels(config, provider)
if (extracted) result.push({ provider, ...extracted })
}
return result
}

export function buildModelGroups(config: Record<string, any>): { default: string; groups: ModelGroup[] } {
let defaultModel = ''
const groups: ModelGroup[] = []

// 1. Extract current model
const modelSection = config.model
if (typeof modelSection === 'object' && modelSection !== null) {
defaultModel = String(modelSection.default || '').trim()
defaultModel = String(modelSection.default || modelSection.model || '').trim()
} else if (typeof modelSection === 'string') {
defaultModel = modelSection.trim()
}

// 2. Extract custom_providers section
// 2. Extract providers section written by newer Hermes Agent configs
for (const provider of listConfiguredProviderModels(config)) {
groups.push({
provider: provider.provider,
models: provider.models.map(model => ({ id: model, label: `${provider.label}: ${model}` })),
})
}

// 3. Extract custom_providers section
const customProviders = config.custom_providers
if (Array.isArray(customProviders)) {
const customModels: ModelInfo[] = []
Expand Down
61 changes: 61 additions & 0 deletions tests/server/config-helpers-file-lock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,65 @@ describe('config-helpers locked file updates', () => {
expect(result.changed).toBe(true)
expect(result.config).toEqual({})
})

it('extracts model groups from newer Hermes providers config entries', async () => {
const { listConfiguredProviderModels, buildModelGroups } = await loadHelpers()
const config = {
model: { default: 'custom-model-pro', provider: 'customrelay' },
providers: {
customrelay: {
name: 'Custom Relay',
base_url: 'https://relay.example/v1',
key_env: 'CUSTOM_RELAY_API_KEY',
default_model: 'custom-model-pro',
models: {
'custom-model-pro': { reasoning_effort: 'xhigh' },
'custom-model-lite': {},
},
},
},
}

expect(listConfiguredProviderModels(config)).toEqual([
{
provider: 'customrelay',
label: 'Custom Relay',
base_url: 'https://relay.example/v1',
key_env: 'CUSTOM_RELAY_API_KEY',
models: ['custom-model-pro', 'custom-model-lite'],
},
])
expect(buildModelGroups(config)).toEqual({
default: 'custom-model-pro',
groups: [
{
provider: 'customrelay',
models: [
{ id: 'custom-model-pro', label: 'Custom Relay: custom-model-pro' },
{ id: 'custom-model-lite', label: 'Custom Relay: custom-model-lite' },
],
},
],
})
})

it('normalizes configured providers without trusting invalid key_env names', async () => {
const { listConfiguredProviderModels } = await loadHelpers()
expect(listConfiguredProviderModels({
model: { model: 'fallback-model', provider: 'relay' },
providers: {
relay: {
key_env: 'CUSTOM_RELAY_API_KEY|MALICIOUS',
},
},
})).toEqual([
{
provider: 'relay',
label: 'relay',
base_url: '',
key_env: '',
models: ['fallback-model'],
},
])
})
})
Loading