fix provider base URL env handling (#1054)
This commit is contained in:
@@ -42,8 +42,9 @@ vi.mock('../../packages/server/src/services/config-helpers', () => ({
|
||||
fetchProviderModels: mockFetchProviderModels,
|
||||
buildModelGroups: mockBuildModelGroups,
|
||||
PROVIDER_ENV_MAP: {
|
||||
deepseek: { api_key_env: 'DEEPSEEK_API_KEY' },
|
||||
'xai-oauth': { api_key_env: '', base_url_env: 'XAI_BASE_URL' },
|
||||
deepseek: { api_key_env: 'DEEPSEEK_API_KEY', base_url_env: 'DEEPSEEK_BASE_URL' },
|
||||
lmstudio: { api_key_env: 'LM_API_KEY', base_url_env: 'LM_BASE_URL' },
|
||||
'xai-oauth': { api_key_env: '', base_url_env: '' },
|
||||
openrouter: {},
|
||||
},
|
||||
}))
|
||||
@@ -67,6 +68,12 @@ vi.mock('../../packages/server/src/shared/providers', () => ({
|
||||
base_url: 'https://openrouter.ai/api/v1',
|
||||
models: ['openrouter/auto'],
|
||||
},
|
||||
{
|
||||
value: 'lmstudio',
|
||||
label: 'LM Studio',
|
||||
base_url: 'http://127.0.0.1:1234/v1',
|
||||
models: [],
|
||||
},
|
||||
{
|
||||
value: 'xai-oauth',
|
||||
label: 'xAI Grok OAuth (SuperGrok Subscription)',
|
||||
@@ -103,6 +110,7 @@ function makeCtx(body: Record<string, unknown> = {}): any {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockReadFile.mockResolvedValue('DEEPSEEK_API_KEY=sk-test\n')
|
||||
mockFetchProviderModels.mockResolvedValue([])
|
||||
mockReadConfigYaml.mockResolvedValue({ model: { default: 'deepseek-chat', provider: 'deepseek' } })
|
||||
mockReadConfigYamlForProfile.mockResolvedValue({ model: { default: 'deepseek-chat', provider: 'deepseek' } })
|
||||
mockBuildModelGroups.mockReturnValue({ default: '', groups: [] })
|
||||
@@ -262,6 +270,44 @@ describe('models controller — model visibility', () => {
|
||||
]))
|
||||
})
|
||||
|
||||
it('marks allProviders with base URL env support for editable preset URLs', async () => {
|
||||
const ctx = makeCtx()
|
||||
await ctrl.getAvailable(ctx)
|
||||
|
||||
expect(ctx.body.allProviders).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
provider: 'deepseek',
|
||||
base_url_env: 'DEEPSEEK_BASE_URL',
|
||||
}),
|
||||
expect.not.objectContaining({
|
||||
provider: 'xai-oauth',
|
||||
base_url_env: expect.any(String),
|
||||
}),
|
||||
]))
|
||||
})
|
||||
|
||||
it('returns LM Studio configured default model when env credentials exist and catalog is empty', async () => {
|
||||
mockReadFile.mockResolvedValue('LM_API_KEY=local\nLM_BASE_URL=http://127.0.0.1:1234/v1\n')
|
||||
mockReadConfigYaml.mockResolvedValue({ model: { default: 'eee', provider: 'lmstudio' } })
|
||||
mockReadConfigYamlForProfile.mockResolvedValue({ model: { default: 'eee', provider: 'lmstudio' } })
|
||||
|
||||
const ctx = makeCtx()
|
||||
await ctrl.getAvailable(ctx)
|
||||
|
||||
expect(ctx.status).toBe(200)
|
||||
expect(ctx.body.groups).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
provider: 'lmstudio',
|
||||
label: 'LM Studio',
|
||||
base_url: 'http://127.0.0.1:1234/v1',
|
||||
models: ['eee'],
|
||||
available_models: ['eee'],
|
||||
}),
|
||||
]))
|
||||
expect(ctx.body.default).toBe('eee')
|
||||
expect(ctx.body.default_provider).toBe('lmstudio')
|
||||
})
|
||||
|
||||
|
||||
|
||||
it('fails open for stale include rules so a provider can be recovered in the UI', async () => {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
||||
restartGateway: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
let hermesHome = ''
|
||||
|
||||
async function loadProvidersController() {
|
||||
vi.resetModules()
|
||||
process.env.HERMES_HOME = hermesHome
|
||||
return import('../../packages/server/src/controllers/hermes/providers')
|
||||
}
|
||||
|
||||
function makeCtx(body: Record<string, any>, profile = 'default') {
|
||||
return {
|
||||
request: { body },
|
||||
state: { profile: { name: profile } },
|
||||
status: 200,
|
||||
body: undefined as unknown,
|
||||
}
|
||||
}
|
||||
|
||||
describe('providers controller create', () => {
|
||||
beforeEach(() => {
|
||||
hermesHome = mkdtempSync(join(tmpdir(), 'hwui-provider-create-'))
|
||||
mkdirSync(hermesHome, { recursive: true })
|
||||
writeFileSync(join(hermesHome, 'config.yaml'), 'model: {}\n')
|
||||
writeFileSync(join(hermesHome, '.env'), '')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.HERMES_HOME
|
||||
vi.doUnmock('../../packages/server/src/controllers/hermes/providers')
|
||||
vi.clearAllMocks()
|
||||
if (hermesHome) rmSync(hermesHome, { recursive: true, force: true })
|
||||
hermesHome = ''
|
||||
})
|
||||
|
||||
it('does not persist a built-in provider base URL when it matches the preset default', async () => {
|
||||
const { create } = await loadProvidersController()
|
||||
const ctx = makeCtx({
|
||||
name: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com',
|
||||
api_key: 'deepseek-key',
|
||||
model: 'deepseek-chat',
|
||||
providerKey: 'deepseek',
|
||||
})
|
||||
|
||||
await create(ctx)
|
||||
|
||||
expect(ctx.body).toEqual({ success: true })
|
||||
const envAfter = readFileSync(join(hermesHome, '.env'), 'utf-8')
|
||||
expect(envAfter).toContain('DEEPSEEK_API_KEY=deepseek-key')
|
||||
expect(envAfter).not.toContain('DEEPSEEK_BASE_URL')
|
||||
})
|
||||
|
||||
it('persists a built-in provider base URL when it differs from the preset default', async () => {
|
||||
const { create } = await loadProvidersController()
|
||||
const ctx = makeCtx({
|
||||
name: 'DeepSeek',
|
||||
base_url: 'https://deepseek-proxy.invalid/v1',
|
||||
api_key: 'deepseek-key',
|
||||
model: 'deepseek-chat',
|
||||
providerKey: 'deepseek',
|
||||
})
|
||||
|
||||
await create(ctx)
|
||||
|
||||
expect(ctx.body).toEqual({ success: true })
|
||||
const envAfter = readFileSync(join(hermesHome, '.env'), 'utf-8')
|
||||
expect(envAfter).toContain('DEEPSEEK_API_KEY=deepseek-key')
|
||||
expect(envAfter).toContain('DEEPSEEK_BASE_URL=https://deepseek-proxy.invalid/v1')
|
||||
})
|
||||
})
|
||||
@@ -47,7 +47,9 @@ describe('providers controller delete', () => {
|
||||
it('removes built-in API-key provider credentials from env and auth pool', async () => {
|
||||
writeFileSync(join(hermesHome, '.env'), [
|
||||
['DEEPSEEK_API_KEY', 'deepseek-placeholder'].join('='),
|
||||
['DEEPSEEK_BASE_URL', 'https://deepseek-proxy.invalid/v1'].join('='),
|
||||
['OPENROUTER_API_KEY', 'openrouter-placeholder'].join('='),
|
||||
['OPENROUTER_BASE_URL', 'https://openrouter-proxy.invalid/v1'].join('='),
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(hermesHome, 'auth.json'), JSON.stringify({
|
||||
@@ -69,7 +71,9 @@ describe('providers controller delete', () => {
|
||||
expect(ctx.body).toEqual({ success: true })
|
||||
const envAfter = readFileSync(join(hermesHome, '.env'), 'utf-8')
|
||||
expect(envAfter).not.toContain('DEEPSEEK_API_KEY')
|
||||
expect(envAfter).not.toContain('DEEPSEEK_BASE_URL')
|
||||
expect(envAfter).toContain(['OPENROUTER_API_KEY', 'openrouter-placeholder'].join('='))
|
||||
expect(envAfter).toContain(['OPENROUTER_BASE_URL', 'https://openrouter-proxy.invalid/v1'].join('='))
|
||||
|
||||
const authAfter = readAuth()
|
||||
expect(authAfter.providers).not.toHaveProperty('deepseek')
|
||||
@@ -80,6 +84,24 @@ describe('providers controller delete', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('does not remove unrelated base URL env for a provider without a base URL env mapping', async () => {
|
||||
writeFileSync(join(hermesHome, '.env'), [
|
||||
['XAI_BASE_URL', 'https://xai-proxy.invalid/v1'].join('='),
|
||||
['DEEPSEEK_BASE_URL', 'https://deepseek-proxy.invalid/v1'].join('='),
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
const { remove } = await loadProvidersController()
|
||||
const ctx = makeCtx('xai-oauth')
|
||||
|
||||
await remove(ctx)
|
||||
|
||||
expect(ctx.body).toEqual({ success: true })
|
||||
const envAfter = readFileSync(join(hermesHome, '.env'), 'utf-8')
|
||||
expect(envAfter).toContain(['XAI_BASE_URL', 'https://xai-proxy.invalid/v1'].join('='))
|
||||
expect(envAfter).toContain(['DEEPSEEK_BASE_URL', 'https://deepseek-proxy.invalid/v1'].join('='))
|
||||
})
|
||||
|
||||
it('removes custom provider config and any matching stored auth entry', async () => {
|
||||
writeFileSync(join(hermesHome, 'config.yaml'), [
|
||||
'model:',
|
||||
|
||||
Reference in New Issue
Block a user