mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-07-11 19:20:16 +00:00
b67607729d
* include auth providers in model catalog cache * fetch auth provider model catalogs
322 lines
11 KiB
TypeScript
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'],
|
|
})
|
|
})
|
|
})
|