Files
hermes-web-ui/tests/server/custom-providers-compat.test.ts
ekko d4667e3d44 [codex] support deleting v12 provider entries (#1581)
* fix(config): read v12+ providers dict alongside legacy custom_providers list

Hermes Agent v12 migrated custom_providers (list) to providers (dict) and
ships get_compatible_custom_providers() so every consumer in core sees a
unified view across both schemas. Studio was the lone holdout — it only
read the legacy list, so any provider declared in the v12+ dict shape
(volcengine-coding, xiaomi-token-plan, …) was silently invisible in the
model picker, catalog refresher, and context-length lookup, even though
hermes CLI and the agent loop resolved them fine.

Add a TypeScript port of the compatibility helper that mirrors Agent's
field aliasing (apiKey/api_key, key_env/api_key_env, model/default_model,
list-of-strings or dict-shaped models, transport/api_mode) and route the
read paths through it: model resolution, catalog refresh, model-context
lookup, coding-agent resolution, and fun-codex media provider lookup.
Write paths (POST/PUT/DELETE in providers.ts) intentionally still target
the legacy list — that's Studio's UI write surface today and changing it
is out of scope for this bug.

Closes #1570
Refs NousResearch/hermes-agent@c449cd1af

* support deleting v12 provider entries

---------

Co-authored-by: simfeng <simfeng@users.noreply.github.com>
2026-06-16 09:43:39 +08:00

251 lines
9.2 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
getCompatibleCustomProviders,
normalizeCustomProviderEntry,
} from '../../packages/server/src/services/hermes/custom-providers-compat'
describe('normalizeCustomProviderEntry', () => {
it('returns null for non-objects', () => {
expect(normalizeCustomProviderEntry(null)).toBeNull()
expect(normalizeCustomProviderEntry('foo')).toBeNull()
expect(normalizeCustomProviderEntry([])).toBeNull()
})
it('returns null when no usable URL is present', () => {
expect(normalizeCustomProviderEntry({ name: 'foo' })).toBeNull()
expect(normalizeCustomProviderEntry({ name: 'foo', base_url: 'not-a-url' })).toBeNull()
})
it('returns null when no name is present and no providerKey is supplied', () => {
expect(normalizeCustomProviderEntry({ base_url: 'https://api.example.com' })).toBeNull()
})
it('falls back to providerKey when entry.name is missing', () => {
const result = normalizeCustomProviderEntry(
{ base_url: 'https://api.example.com' },
'volcengine-coding',
)
expect(result).not.toBeNull()
expect(result!.name).toBe('volcengine-coding')
expect(result!.provider_key).toBe('volcengine-coding')
})
it('accepts base_url, url, and api as URL aliases (first valid wins)', () => {
const fromUrl = normalizeCustomProviderEntry({ name: 'a', url: 'https://example.com' })
expect(fromUrl?.base_url).toBe('https://example.com')
const fromApi = normalizeCustomProviderEntry({ name: 'a', api: 'https://api.example.com' })
expect(fromApi?.base_url).toBe('https://api.example.com')
const baseWins = normalizeCustomProviderEntry({
name: 'a',
base_url: 'https://primary.example.com',
api: 'https://secondary.example.com',
})
expect(baseWins?.base_url).toBe('https://primary.example.com')
})
it('maps camelCase aliases to snake_case canonical fields', () => {
const result = normalizeCustomProviderEntry({
name: 'p',
baseUrl: 'https://example.com',
apiKey: 'secret',
keyEnv: 'P_KEY',
defaultModel: 'm-1',
})
expect(result?.base_url).toBe('https://example.com')
expect(result?.api_key).toBe('secret')
expect(result?.key_env).toBe('P_KEY')
expect(result?.model).toBe('m-1')
})
it('treats api_key_env as a snake_case alias for key_env', () => {
const result = normalizeCustomProviderEntry({
name: 'p',
base_url: 'https://example.com',
api_key_env: 'P_KEY',
})
expect(result?.key_env).toBe('P_KEY')
})
it('preserves transport as api_mode', () => {
const result = normalizeCustomProviderEntry({
name: 'p',
base_url: 'https://example.com',
transport: 'chat_completions',
})
expect(result?.api_mode).toBe('chat_completions')
})
it('converts a list-of-strings models field into the dict shape', () => {
const result = normalizeCustomProviderEntry({
name: 'p',
base_url: 'https://example.com',
models: ['m-1', 'm-2', 'm-3'],
})
expect(result?.models).toEqual({ 'm-1': {}, 'm-2': {}, 'm-3': {} })
})
it('preserves a dict-shaped models field', () => {
const result = normalizeCustomProviderEntry({
name: 'p',
base_url: 'https://example.com',
models: { 'm-1': { context_length: 128_000 } },
})
expect(result?.models).toEqual({ 'm-1': { context_length: 128_000 } })
})
it('preserves discover_models, context_length, rate_limit_delay, extra_body', () => {
const result = normalizeCustomProviderEntry({
name: 'p',
base_url: 'https://example.com',
discover_models: false,
context_length: 256_000,
rate_limit_delay: 0.5,
extra_body: { foo: 'bar' },
})
expect(result?.discover_models).toBe(false)
expect(result?.context_length).toBe(256_000)
expect(result?.rate_limit_delay).toBe(0.5)
expect(result?.extra_body).toEqual({ foo: 'bar' })
})
it('drops invalid context_length / rate_limit_delay values silently', () => {
const result = normalizeCustomProviderEntry({
name: 'p',
base_url: 'https://example.com',
context_length: 0,
rate_limit_delay: -1,
})
expect(result?.context_length).toBeUndefined()
expect(result?.rate_limit_delay).toBeUndefined()
})
})
describe('getCompatibleCustomProviders', () => {
it('returns an empty array for non-config inputs', () => {
expect(getCompatibleCustomProviders(null)).toEqual([])
expect(getCompatibleCustomProviders(undefined)).toEqual([])
expect(getCompatibleCustomProviders('not config')).toEqual([])
})
it('reads entries from the legacy custom_providers list', () => {
const config = {
custom_providers: [
{ name: 'volc', base_url: 'https://volc.example.com', model: 'minimax-m3' },
],
}
const result = getCompatibleCustomProviders(config)
expect(result).toHaveLength(1)
expect(result[0].name).toBe('volc')
expect(result[0].source).toBe('custom_providers')
expect(result[0].model).toBe('minimax-m3')
})
it('reads entries from the v12+ providers dict', () => {
const config = {
providers: {
'volcengine-coding': {
api: 'https://ark.cn-beijing.volces.com/api/coding/v3',
key_env: 'ARK_CODING_API_KEY',
default_model: 'minimax-m3',
models: ['minimax-m3', 'kimi-k2.6', 'glm-5.1'],
discover_models: false,
},
},
}
const result = getCompatibleCustomProviders(config)
expect(result).toHaveLength(1)
expect(result[0].name).toBe('volcengine-coding')
expect(result[0].source).toBe('providers')
expect(result[0].provider_key).toBe('volcengine-coding')
expect(result[0].base_url).toBe('https://ark.cn-beijing.volces.com/api/coding/v3')
expect(result[0].key_env).toBe('ARK_CODING_API_KEY')
expect(result[0].model).toBe('minimax-m3')
expect(result[0].models).toEqual({ 'minimax-m3': {}, 'kimi-k2.6': {}, 'glm-5.1': {} })
expect(result[0].discover_models).toBe(false)
})
it('merges entries from both schemas, list first', () => {
const config = {
custom_providers: [
{ name: 'legacy', base_url: 'https://legacy.example.com', model: 'm-l' },
],
providers: {
modern: { api: 'https://modern.example.com', default_model: 'm-m' },
},
}
const result = getCompatibleCustomProviders(config)
expect(result.map(p => p.name)).toEqual(['legacy', 'modern'])
})
it('deduplicates entries by provider_key (prefers list first)', () => {
const config = {
custom_providers: [
{ name: 'shared', base_url: 'https://list.example.com', model: 'list-model' },
],
providers: {
// Same provider_key as the list entry's name — list entry wins per Hermes Agent semantics.
shared: { api: 'https://dict.example.com', default_model: 'dict-model' },
},
}
const result = getCompatibleCustomProviders(config)
// The dict entry has provider_key='shared'. The list entry has no provider_key
// (its name is just 'shared'). Identity dedup uses (name, base_url, model)
// triple — different base_urls, so both should appear.
expect(result).toHaveLength(2)
expect(result[0].base_url).toBe('https://list.example.com')
expect(result[1].base_url).toBe('https://dict.example.com')
})
it('deduplicates true duplicates by name+base_url+model triple', () => {
const config = {
custom_providers: [
{ name: 'dup', base_url: 'https://dup.example.com', model: 'm-1' },
{ name: 'dup', base_url: 'https://dup.example.com/', model: 'm-1' }, // trailing slash
],
}
const result = getCompatibleCustomProviders(config)
expect(result).toHaveLength(1)
})
it('returns empty array when custom_providers is malformed', () => {
// Matches Hermes Agent: a non-list custom_providers value bails out entirely.
expect(getCompatibleCustomProviders({ custom_providers: 'broken' })).toEqual([])
})
it('skips dict entries without a base_url silently', () => {
const config = {
providers: {
'no-url': { default_model: 'foo' },
'with-url': { api: 'https://valid.example.com', default_model: 'bar' },
},
}
const result = getCompatibleCustomProviders(config)
expect(result).toHaveLength(1)
expect(result[0].name).toBe('with-url')
})
it('handles the volcengine-coding example from issue #1570', () => {
// This is the exact config shape from the bug report — Studio should now see
// the provider with all 5 models.
const config = {
model: { default: 'minimax-m3', provider: 'volcengine-coding' },
providers: {
'volcengine-coding': {
api: 'https://ark.cn-beijing.volces.com/api/coding/v3',
key_env: 'ARK_CODING_API_KEY',
default_model: 'minimax-m3',
models: ['minimax-m3', 'kimi-k2.6', 'glm-5.1', 'deepseek-v4-pro', 'deepseek-v4-flash'],
discover_models: false,
},
},
custom_providers: [],
}
const result = getCompatibleCustomProviders(config)
expect(result).toHaveLength(1)
expect(result[0].name).toBe('volcengine-coding')
expect(Object.keys(result[0].models || {})).toEqual([
'minimax-m3', 'kimi-k2.6', 'glm-5.1', 'deepseek-v4-pro', 'deepseek-v4-flash',
])
})
})