Files
hermes-web-ui/tests/server/model-catalog-cache.test.ts
ekko b67607729d [codex] include auth providers in model catalog cache (#1583)
* include auth providers in model catalog cache

* fetch auth provider model catalogs
2026-06-16 10:36:12 +08:00

322 lines
11 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockReadFile,
mockFetchProviderModels,
mockReadConfigYamlForProfile,
mockReadText,
mockUpdateText,
mockListProfileNamesFromDisk,
mockGetProfileDir,
mockReadAppConfig,
mockResolveCopilotOAuthToken,
mockGlobalFetch,
} = vi.hoisted(() => ({
mockReadFile: vi.fn(),
mockFetchProviderModels: vi.fn(),
mockReadConfigYamlForProfile: vi.fn(),
mockReadText: vi.fn(),
mockUpdateText: vi.fn(),
mockListProfileNamesFromDisk: vi.fn(),
mockGetProfileDir: vi.fn(),
mockReadAppConfig: vi.fn(),
mockResolveCopilotOAuthToken: vi.fn(),
mockGlobalFetch: vi.fn(),
}))
vi.mock('fs/promises', () => ({
readFile: mockReadFile,
}))
vi.mock('../../packages/server/src/config', () => ({
config: { appHome: '/app-home' },
}))
vi.mock('../../packages/server/src/shared/providers', () => ({
PROVIDER_PRESETS: [
{
value: 'openrouter',
label: 'OpenRouter',
base_url: 'https://openrouter.ai/api/v1',
models: ['openrouter/fallback'],
},
{
value: 'deepseek',
label: 'DeepSeek',
base_url: 'https://api.deepseek.com/v1',
models: ['deepseek-chat'],
},
{
value: 'openai-codex',
label: 'OpenAI Codex',
base_url: 'https://chatgpt.com/backend-api/codex',
models: ['gpt-5.5', 'gpt-5.4-mini'],
},
{
value: 'xai-oauth',
label: 'xAI Grok OAuth',
base_url: 'https://api.x.ai/v1',
models: ['grok-4.3'],
},
{
value: 'copilot',
label: 'GitHub Copilot',
base_url: 'https://api.githubcopilot.com',
models: ['gpt-5.5', 'claude-sonnet-4.6'],
},
{
value: 'nous',
label: 'Nous Portal',
base_url: 'https://inference-api.nousresearch.com/v1',
models: ['anthropic/claude-opus-4.8'],
},
{
value: 'google-gemini-cli',
label: 'Google Gemini OAuth',
base_url: 'cloudcode-pa://google',
models: ['gemini-3.1-pro-preview', 'gemini-3-pro-preview'],
},
{
value: 'claude-oauth',
label: 'Claude OAuth',
base_url: 'https://api.anthropic.com',
models: ['claude-sonnet-4-6', 'claude-opus-4-7'],
},
],
}))
vi.mock('../../packages/server/src/services/config-helpers', () => ({
PROVIDER_ENV_MAP: {
openrouter: { api_key_env: 'OPENROUTER_API_KEY', base_url_env: 'OPENROUTER_BASE_URL' },
deepseek: { api_key_env: 'DEEPSEEK_API_KEY', base_url_env: 'DEEPSEEK_BASE_URL' },
'openai-codex': { api_key_env: '', base_url_env: '' },
'xai-oauth': { api_key_env: '', base_url_env: '' },
copilot: { api_key_env: 'GITHUB_TOKEN', base_url_env: '' },
nous: { api_key_env: '', base_url_env: '' },
'google-gemini-cli': { api_key_env: '', base_url_env: '' },
'claude-oauth': { api_key_env: '', base_url_env: '' },
},
fetchProviderModels: mockFetchProviderModels,
readConfigYamlForProfile: mockReadConfigYamlForProfile,
}))
vi.mock('../../packages/server/src/services/app-config', () => ({
readAppConfig: mockReadAppConfig,
}))
vi.mock('../../packages/server/src/services/hermes/copilot-models', () => ({
resolveCopilotOAuthToken: mockResolveCopilotOAuthToken,
}))
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getProfileDir: mockGetProfileDir,
listProfileNamesFromDisk: mockListProfileNamesFromDisk,
}))
vi.mock('../../packages/server/src/services/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
},
}))
vi.mock('../../packages/server/src/services/safe-file-store', () => ({
safeFileStore: {
readText: mockReadText,
updateText: mockUpdateText,
},
}))
describe('model catalog cache', () => {
let cacheText = ''
beforeEach(() => {
vi.clearAllMocks()
cacheText = ''
mockListProfileNamesFromDisk.mockReturnValue(['default', 'team'])
mockGetProfileDir.mockImplementation((profile: string) => `/hermes/${profile}`)
mockReadText.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
mockUpdateText.mockImplementation(async (_path: string, updater: (current: string) => string) => {
cacheText = updater(cacheText)
})
mockReadAppConfig.mockResolvedValue({})
mockResolveCopilotOAuthToken.mockResolvedValue('')
mockFetchProviderModels.mockResolvedValue([])
mockGlobalFetch.mockResolvedValue({ ok: false, status: 404, json: async () => ({}) })
vi.stubGlobal('fetch', mockGlobalFetch)
mockReadFile.mockImplementation(async (path: string) => {
if (path === '/hermes/default/.env') return 'OPENROUTER_API_KEY=default-openrouter\n'
if (path === '/hermes/team/.env') {
return [
'OPENROUTER_API_KEY=team-openrouter',
'DEEPSEEK_API_KEY=team-deepseek',
].join('\n')
}
throw Object.assign(new Error('missing'), { code: 'ENOENT' })
})
mockReadConfigYamlForProfile.mockImplementation(async (profile: string) => {
if (profile === 'default') {
return {
custom_providers: [
{ name: 'Shared Local', base_url: 'https://custom.local/v1', api_key: 'custom-a', model: 'local-a' },
],
}
}
return {
custom_providers: [
{ name: 'Shared Local', base_url: 'https://custom.local/v1', api_key: 'custom-b', model: 'local-b' },
],
}
})
})
it('refreshes providers from all profiles and deduplicates identical catalogs', async () => {
const { refreshConfiguredProviderModelCatalogs, providerModelCatalogKey } = await import(
'../../packages/server/src/services/hermes/model-catalog-cache'
)
await refreshConfiguredProviderModelCatalogs({ force: true })
expect(mockFetchProviderModels).toHaveBeenCalledTimes(3)
expect(mockFetchProviderModels).toHaveBeenCalledWith('https://openrouter.ai/api/v1', 'default-openrouter', true)
expect(mockFetchProviderModels).toHaveBeenCalledWith('https://api.deepseek.com/v1', 'team-deepseek', false)
expect(mockFetchProviderModels).toHaveBeenCalledWith('https://custom.local/v1', 'custom-a', false)
const cache = JSON.parse(cacheText)
expect(cache.providers[providerModelCatalogKey('openrouter', 'https://openrouter.ai/api/v1', true)]).toMatchObject({
provider: 'openrouter',
models: ['openrouter/fallback'],
profiles: ['default', 'team'],
})
expect(cache.providers[providerModelCatalogKey('deepseek', 'https://api.deepseek.com/v1')]).toMatchObject({
provider: 'deepseek',
models: ['deepseek-chat'],
profiles: ['team'],
})
expect(cache.providers[providerModelCatalogKey('custom:shared-local', 'https://custom.local/v1')]).toMatchObject({
provider: 'custom:shared-local',
models: ['local-a', 'local-b'],
profiles: ['default', 'team'],
})
})
it('adds authorized providers to the catalog cache and fetches live models for compatible auth providers', async () => {
mockListProfileNamesFromDisk.mockReturnValue(['default'])
mockReadAppConfig.mockResolvedValue({ copilotEnabled: true })
mockResolveCopilotOAuthToken.mockResolvedValue('gho-copilot')
mockGlobalFetch.mockImplementation(async (url: string) => {
if (url === 'https://chatgpt.com/backend-api/codex/models?client_version=1.0.0') {
return {
ok: true,
json: async () => ({
models: [
{ slug: 'hidden-codex', visibility: 'hidden', priority: 0 },
{ slug: 'gpt-5.4-mini', priority: 20 },
{ slug: 'gpt-5.5', priority: 10 },
],
}),
}
}
if (url === 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota') {
return {
ok: true,
json: async () => ({
buckets: [
{ modelId: 'gemini-3.1-pro-preview' },
{ modelId: 'gemini-3-flash-preview' },
],
}),
}
}
return { ok: false, status: 404, json: async () => ({}) }
})
mockFetchProviderModels.mockImplementation(async (baseUrl: string, apiKey: string) => {
if (baseUrl === 'https://inference-api.nousresearch.com/v1' && apiKey === 'nous-agent-key') {
return ['nous/live-a', 'nous/live-b']
}
if (baseUrl === 'https://api.x.ai/v1' && apiKey === 'xai-access-token') {
return ['grok-live-a', 'grok-live-b']
}
return []
})
mockReadFile.mockImplementation(async (path: string) => {
if (path === '/hermes/default/.env') return ''
if (path === '/hermes/default/auth.json') {
return JSON.stringify({
providers: {
'openai-codex': { tokens: { access_token: 'codex-token' } },
'google-gemini-cli': {
access_token: 'gemini-access-token',
refresh_token: 'gemini-refresh-token',
base_url: 'cloudcode-pa://google',
},
'claude-oauth': {
tokens: {
access_token: 'claude-access-token',
refresh_token: 'claude-refresh-token',
},
base_url: 'https://api.anthropic.com',
},
},
credential_pool: {
'xai-oauth': [{ access_token: 'xai-access-token' }],
nous: [{
agent_key: 'nous-agent-key',
inference_base_url: 'https://inference-api.nousresearch.com/v1',
}],
},
})
}
throw Object.assign(new Error('missing'), { code: 'ENOENT' })
})
mockReadConfigYamlForProfile.mockResolvedValue({})
const { refreshConfiguredProviderModelCatalogs, providerModelCatalogKey } = await import(
'../../packages/server/src/services/hermes/model-catalog-cache'
)
await refreshConfiguredProviderModelCatalogs({ force: true })
expect(mockFetchProviderModels).toHaveBeenCalledTimes(2)
expect(mockFetchProviderModels).toHaveBeenCalledWith('https://inference-api.nousresearch.com/v1', 'nous-agent-key', false)
expect(mockFetchProviderModels).toHaveBeenCalledWith('https://api.x.ai/v1', 'xai-access-token', false)
const cache = JSON.parse(cacheText)
expect(cache.providers[providerModelCatalogKey('openai-codex', 'https://chatgpt.com/backend-api/codex')]).toMatchObject({
provider: 'openai-codex',
models: ['gpt-5.5', 'gpt-5.4-mini'],
source: 'live',
profiles: ['default'],
})
expect(cache.providers[providerModelCatalogKey('xai-oauth', 'https://api.x.ai/v1')]).toMatchObject({
provider: 'xai-oauth',
models: ['grok-live-a', 'grok-live-b'],
source: 'live',
profiles: ['default'],
})
expect(cache.providers[providerModelCatalogKey('copilot', 'https://api.githubcopilot.com')]).toMatchObject({
provider: 'copilot',
models: ['gpt-5.5', 'claude-sonnet-4.6'],
source: 'fallback',
profiles: ['default'],
})
expect(cache.providers[providerModelCatalogKey('nous', 'https://inference-api.nousresearch.com/v1')]).toMatchObject({
provider: 'nous',
models: ['nous/live-a', 'nous/live-b'],
source: 'live',
profiles: ['default'],
})
expect(cache.providers[providerModelCatalogKey('google-gemini-cli', 'cloudcode-pa://google')]).toMatchObject({
provider: 'google-gemini-cli',
models: ['gemini-3.1-pro-preview', 'gemini-3-flash-preview'],
source: 'live',
profiles: ['default'],
})
expect(cache.providers[providerModelCatalogKey('claude-oauth', 'https://api.anthropic.com')]).toMatchObject({
provider: 'claude-oauth',
models: ['claude-sonnet-4-6', 'claude-opus-4-7'],
source: 'fallback',
profiles: ['default'],
})
})
})