diff --git a/packages/client/src/api/hermes/system.ts b/packages/client/src/api/hermes/system.ts index fae002b..470b471 100644 --- a/packages/client/src/api/hermes/system.ts +++ b/packages/client/src/api/hermes/system.ts @@ -42,6 +42,8 @@ export interface AvailableModelGroup { available_models?: string[] api_key: string builtin?: boolean + /** Env var used by Hermes to override this provider's base URL. If present, the preset URL is editable. */ + base_url_env?: string /** 可选:模型 ID -> 元数据(preview/disabled/alias)。alias 仅用于 Web UI 展示。 */ model_meta?: Record } diff --git a/packages/client/src/components/hermes/models/ProviderFormModal.vue b/packages/client/src/components/hermes/models/ProviderFormModal.vue index daae499..3a871d1 100644 --- a/packages/client/src/components/hermes/models/ProviderFormModal.vue +++ b/packages/client/src/components/hermes/models/ProviderFormModal.vue @@ -64,6 +64,10 @@ const alibabaCodingRegion = ref<'intl' | 'cn'>('intl') const presetOptions = computed(() => modelsStore.allProviders.map(g => ({ label: g.label, value: g.provider })), ) +const selectedPresetProvider = computed(() => + selectedPreset.value ? modelsStore.allProviders.find(g => g.provider === selectedPreset.value) : null, +) +const canEditPresetBaseUrl = computed(() => !!selectedPresetProvider.value?.base_url_env) const FUN_LINK_MAP: Record = { 'fun-codex': 'https://apikey.fun/register?aff=LIBAPI', @@ -85,7 +89,7 @@ watch(selectedPreset, (val) => { formData.value.model = '' alibabaCodingRegion.value = 'intl' if (val) { - const group = modelsStore.allProviders.find(g => g.provider === val) + const group = selectedPresetProvider.value if (group) { formData.value.name = group.label formData.value.base_url = group.base_url @@ -371,7 +375,7 @@ function handleClose() { diff --git a/packages/server/src/controllers/hermes/models.ts b/packages/server/src/controllers/hermes/models.ts index 0fd1286..57d7de1 100644 --- a/packages/server/src/controllers/hermes/models.ts +++ b/packages/server/src/controllers/hermes/models.ts @@ -13,7 +13,7 @@ import { listUserProfiles } from '../../db/hermes/users-store' const PROVIDER_MODEL_CATALOG = buildProviderModelMap() type ModelMeta = { preview?: boolean; disabled?: boolean; alias?: string } -type AvailableGroup = { provider: string; label: string; base_url: string; models: string[]; api_key: string; builtin?: boolean; model_meta?: Record; available_models?: string[] } +type AvailableGroup = { provider: string; label: string; base_url: string; models: string[]; api_key: string; builtin?: boolean; model_meta?: Record; available_models?: string[]; base_url_env?: string } type ModelVisibility = Record type CustomModels = Record @@ -91,6 +91,18 @@ function applyCustomModels(groups: AvailableGroup[], customModels: CustomModels) }) } +function providerPresetToGroup(p: any, models?: string[]): AvailableGroup { + const envMapping = PROVIDER_ENV_MAP[p.value] + return { + provider: p.value, + label: p.label, + base_url: p.base_url, + models: models || p.models, + api_key: '', + ...(envMapping?.base_url_env ? { base_url_env: envMapping.base_url_env } : {}), + } +} + function normalizeModelVisibility(input: unknown): ModelVisibility { if (!input || typeof input !== 'object' || Array.isArray(input)) return {} const out: ModelVisibility = {} @@ -169,6 +181,18 @@ function providerKeyForCustom(name: string): string { return `custom:${name.trim().toLowerCase().replace(/ /g, '-')}` } +function providerShouldFetchLiveModels(providerKey: string): boolean { + return providerKey === 'openrouter' || + providerKey === 'cliproxyapi' || + providerKey === 'ollama-cloud' || + providerKey === 'lmstudio' +} + +function includeConfiguredDefaultModel(providerKey: string, modelsList: string[], currentDefault: string, currentDefaultProvider: string): string[] { + if (!currentDefault || providerKey !== currentDefaultProvider) return modelsList + return [...new Set([...modelsList, currentDefault])] +} + function mergeAvailableGroups(groups: AvailableGroup[]): AvailableGroup[] { const byProvider = new Map() for (const group of groups) { @@ -347,15 +371,18 @@ async function buildAvailableForProfile( } if (Object.keys(modelMeta).length === 0) modelMeta = undefined } - } else if (providerKey === 'openrouter' || providerKey === 'cliproxyapi' || providerKey === 'ollama-cloud') { + } else if (providerShouldFetchLiveModels(providerKey)) { if (envMapping.api_key_env) { const apiKey = envGetValue(envMapping.api_key_env) if (apiKey) { - const fetched = await cachedProviderModels(fetchCache, baseUrl, apiKey, providerKey === 'openrouter') - if (fetched.length > 0) modelsList = fetched + try { + const fetched = await cachedProviderModels(fetchCache, baseUrl, apiKey, providerKey === 'openrouter') + if (fetched.length > 0) modelsList = fetched + } catch { /* ignore live catalog failures */ } } } } + modelsList = includeConfiguredDefaultModel(providerKey, modelsList, currentDefault, currentDefaultProvider) if (modelsList.length > 0) { const apiKey = envMapping.api_key_env ? envGetValue(envMapping.api_key_env) : '' addGroup(providerKey, label, baseUrl, modelsList, apiKey, true, modelMeta) @@ -428,13 +455,7 @@ export async function getAvailable(ctx: any) { defaultProfile?.default_provider || '', visibleGroups, ) - const allProvidersBase = PROVIDER_PRESETS.map((p: any) => ({ - provider: p.value, - label: p.label, - base_url: p.base_url, - models: p.models, - api_key: '', - })) + const allProvidersBase = PROVIDER_PRESETS.map((p: any) => providerPresetToGroup(p)) ctx.body = { default: visibleDefault.defaultModel, default_provider: visibleDefault.defaultProvider, @@ -465,13 +486,7 @@ export async function getAvailable(ctx: any) { default: visibleProfileDefault.defaultModel, default_provider: visibleProfileDefault.defaultProvider, groups: visibleProfileGroups, - allProviders: applyModelAliases(PROVIDER_PRESETS.map((p: any) => ({ - provider: p.value, - label: p.label, - base_url: p.base_url, - models: p.models, - api_key: '', - })), modelAliasesForProfile), + allProviders: applyModelAliases(PROVIDER_PRESETS.map((p: any) => providerPresetToGroup(p)), modelAliasesForProfile), model_aliases: modelAliasesForProfile, model_visibility: modelVisibilityForProfile, custom_models: customModelsForProfile, @@ -608,8 +623,8 @@ export async function getAvailable(ctx: any) { } modelMeta = Object.keys(nextModelMeta).length > 0 ? nextModelMeta : undefined } - } else if (providerKey === 'openrouter' || providerKey === 'cliproxyapi' || providerKey === 'ollama-cloud') { - // OpenRouter and local CLIProxyAPI expose dynamic OpenAI-compatible /models catalogs. + } else if (providerShouldFetchLiveModels(providerKey)) { + // These providers expose dynamic OpenAI-compatible /models catalogs. if (envMapping.api_key_env) { const apiKey = envGetValue(envMapping.api_key_env) if (apiKey) { @@ -620,6 +635,7 @@ export async function getAvailable(ctx: any) { } } } + modelsList = includeConfiguredDefaultModel(providerKey, modelsList, currentDefault, currentDefaultProvider) if (modelsList.length > 0) { const apiKey = envMapping.api_key_env ? envGetValue(envMapping.api_key_env) : '' addGroup(providerKey, label, baseUrl, modelsList, apiKey, true, modelMeta) @@ -661,12 +677,10 @@ export async function getAvailable(ctx: any) { const liveCopilotModels = copilotEnabled ? await getCopilotLive() : [] const liveCopilotIds = liveCopilotModels.map((m) => m.id) - const allProvidersBase = PROVIDER_PRESETS.map((p: any) => ({ - provider: p.value, - label: p.label, - base_url: p.base_url, - models: p.value === 'copilot' && liveCopilotIds.length > 0 ? liveCopilotIds : p.models, - })) + const allProvidersBase = PROVIDER_PRESETS.map((p: any) => providerPresetToGroup( + p, + p.value === 'copilot' && liveCopilotIds.length > 0 ? liveCopilotIds : p.models, + )) const allProviders = applyModelAliases(allProvidersBase, modelAliases) if (groups.length === 0) { diff --git a/packages/server/src/controllers/hermes/providers.ts b/packages/server/src/controllers/hermes/providers.ts index 735bc1b..03eae77 100644 --- a/packages/server/src/controllers/hermes/providers.ts +++ b/packages/server/src/controllers/hermes/providers.ts @@ -46,11 +46,29 @@ function buildProviderEntry(name: string, base_url: string, api_key: string, mod return entry } +function normalizeBaseUrl(url: string): string { + return String(url || '').trim().replace(/\/+$/, '') +} + +function builtinBaseUrl(poolKey: string, requestedBaseUrl: string): string { + return requestedBaseUrl || PROVIDER_PRESETS.find(p => p.value === poolKey)?.base_url || '' +} + +function shouldPersistBuiltinBaseUrl(poolKey: string, requestedBaseUrl: string): boolean { + const presetBaseUrl = PROVIDER_PRESETS.find(p => p.value === poolKey)?.base_url || '' + if (!requestedBaseUrl || !presetBaseUrl) return !!requestedBaseUrl + return normalizeBaseUrl(requestedBaseUrl) !== normalizeBaseUrl(presetBaseUrl) +} + export async function create(ctx: any) { const { name, base_url, api_key, model, context_length, providerKey } = ctx.request.body as { name: string; base_url: string; api_key: string; model: string; context_length?: number; providerKey?: string | null } - if (!name || !base_url || !model) { + const normalizedName = String(name || '').trim() + const poolKey = providerKey || `custom:${normalizedName.toLowerCase().replace(/ /g, '-')}` + const isBuiltin = poolKey in PROVIDER_ENV_MAP + const effectiveBaseUrl = isBuiltin ? builtinBaseUrl(poolKey, base_url) : base_url + if (!normalizedName || !effectiveBaseUrl || !model) { ctx.status = 400; ctx.body = { error: 'Missing name, base_url, or model' }; return } if (!api_key && !OPTIONAL_API_KEY_PROVIDERS.has(String(providerKey || ''))) { @@ -58,8 +76,6 @@ export async function create(ctx: any) { } try { const profile = requestedProfile(ctx) - const poolKey = providerKey || `custom:${name.trim().toLowerCase().replace(/ /g, '-')}` - const isBuiltin = poolKey in PROVIDER_ENV_MAP await updateConfigYamlForProfile(profile, async (config) => { if (typeof config.model !== 'object' || config.model === null) { config.model = {} } if (!isBuiltin) { @@ -68,7 +84,7 @@ export async function create(ctx: any) { (e: any) => `custom:${e.name}` === poolKey ) if (existing) { - existing.base_url = base_url + existing.base_url = effectiveBaseUrl existing.api_key = api_key existing.model = model const preset = PROVIDER_PRESETS.find(p => p.value === poolKey.replace('custom:', '')) @@ -79,7 +95,7 @@ export async function create(ctx: any) { existing.models[model].context_length = context_length } } else { - const entry = buildProviderEntry(name.trim().toLowerCase().replace(/ /g, '-'), base_url, api_key, model, context_length) + const entry = buildProviderEntry(normalizedName.toLowerCase().replace(/ /g, '-'), effectiveBaseUrl, api_key, model, context_length) const preset = PROVIDER_PRESETS.find(p => p.value === poolKey.replace('custom:', '')) if (preset?.api_mode) entry.api_mode = preset.api_mode config.custom_providers.push(entry) @@ -89,11 +105,11 @@ export async function create(ctx: any) { } else { if (PROVIDER_ENV_MAP[poolKey].api_key_env) { await saveEnvValueForProfile(profile, PROVIDER_ENV_MAP[poolKey].api_key_env, api_key) - if (PROVIDER_ENV_MAP[poolKey].base_url_env) { await saveEnvValueForProfile(profile, PROVIDER_ENV_MAP[poolKey].base_url_env, base_url) } + if (PROVIDER_ENV_MAP[poolKey].base_url_env && shouldPersistBuiltinBaseUrl(poolKey, base_url)) { await saveEnvValueForProfile(profile, PROVIDER_ENV_MAP[poolKey].base_url_env, effectiveBaseUrl) } config.model.default = model config.model.provider = poolKey } else if (DIRECT_CONFIG_PROVIDERS.has(poolKey)) { - if (PROVIDER_ENV_MAP[poolKey].base_url_env) { await saveEnvValueForProfile(profile, PROVIDER_ENV_MAP[poolKey].base_url_env, base_url) } + if (PROVIDER_ENV_MAP[poolKey].base_url_env && shouldPersistBuiltinBaseUrl(poolKey, base_url)) { await saveEnvValueForProfile(profile, PROVIDER_ENV_MAP[poolKey].base_url_env, effectiveBaseUrl) } config.model.default = model config.model.provider = poolKey } else { @@ -102,7 +118,7 @@ export async function create(ctx: any) { (e: any) => `custom:${e.name}` === `custom:${poolKey}` ) if (existing) { - existing.base_url = base_url + existing.base_url = effectiveBaseUrl existing.api_key = api_key existing.model = model const preset = PROVIDER_PRESETS.find(p => p.value === poolKey) @@ -113,7 +129,7 @@ export async function create(ctx: any) { existing.models[model].context_length = context_length } } else { - const entry = buildProviderEntry(poolKey, base_url, api_key, model, context_length) + const entry = buildProviderEntry(poolKey, effectiveBaseUrl, api_key, model, context_length) const preset = PROVIDER_PRESETS.find(p => p.value === poolKey) if (preset?.api_mode) entry.api_mode = preset.api_mode config.custom_providers.push(entry) @@ -191,7 +207,9 @@ export async function remove(ctx: any) { const envMapping = PROVIDER_ENV_MAP[poolKey] if (envMapping?.api_key_env) { await saveEnvValueForProfile(profile, envMapping.api_key_env, '') - if (envMapping.base_url_env) { await saveEnvValueForProfile(profile, envMapping.base_url_env, '') } + } + if (envMapping?.base_url_env) { + await saveEnvValueForProfile(profile, envMapping.base_url_env, '') } } if (config.model?.provider === poolKey) { diff --git a/packages/server/src/services/config-helpers.ts b/packages/server/src/services/config-helpers.ts index b6ae6e9..7e2ac2f 100644 --- a/packages/server/src/services/config-helpers.ts +++ b/packages/server/src/services/config-helpers.ts @@ -10,31 +10,32 @@ import { safeFileStore } from './safe-file-store' export const PROVIDER_ENV_MAP: Record = { 'fun-codex': { api_key_env: '', base_url_env: '' }, 'fun-claude': { api_key_env: '', base_url_env: '' }, - openrouter: { api_key_env: 'OPENROUTER_API_KEY', base_url_env: '' }, + lmstudio: { api_key_env: 'LM_API_KEY', base_url_env: 'LM_BASE_URL' }, + openrouter: { api_key_env: 'OPENROUTER_API_KEY', base_url_env: 'OPENROUTER_BASE_URL' }, 'glm-coding-plan': { api_key_env: '', base_url_env: '' }, - zai: { api_key_env: 'GLM_API_KEY', base_url_env: '' }, + zai: { api_key_env: 'GLM_API_KEY', base_url_env: 'GLM_BASE_URL' }, 'kimi-coding-cn': { api_key_env: 'KIMI_CN_API_KEY', base_url_env: '' }, - moonshot: { api_key_env: 'MOONSHOT_API_KEY', base_url_env: '' }, - minimax: { api_key_env: 'MINIMAX_API_KEY', base_url_env: '' }, - 'minimax-cn': { api_key_env: 'MINIMAX_CN_API_KEY', base_url_env: '' }, - deepseek: { api_key_env: 'DEEPSEEK_API_KEY', base_url_env: '' }, - alibaba: { api_key_env: 'DASHSCOPE_API_KEY', base_url_env: '' }, + moonshot: { api_key_env: 'MOONSHOT_API_KEY', base_url_env: 'KIMI_BASE_URL' }, + minimax: { api_key_env: 'MINIMAX_API_KEY', base_url_env: 'MINIMAX_BASE_URL' }, + 'minimax-cn': { api_key_env: 'MINIMAX_CN_API_KEY', base_url_env: 'MINIMAX_CN_BASE_URL' }, + deepseek: { api_key_env: 'DEEPSEEK_API_KEY', base_url_env: 'DEEPSEEK_BASE_URL' }, + alibaba: { api_key_env: 'DASHSCOPE_API_KEY', base_url_env: 'DASHSCOPE_BASE_URL' }, 'alibaba-coding-plan': { api_key_env: 'ALIBABA_CODING_PLAN_API_KEY', base_url_env: 'ALIBABA_CODING_PLAN_BASE_URL' }, - anthropic: { api_key_env: 'ANTHROPIC_API_KEY', base_url_env: '' }, - xai: { api_key_env: 'XAI_API_KEY', base_url_env: '' }, + anthropic: { api_key_env: 'ANTHROPIC_API_KEY', base_url_env: 'ANTHROPIC_BASE_URL' }, + xai: { api_key_env: 'XAI_API_KEY', base_url_env: 'XAI_BASE_URL' }, 'xai-oauth': { api_key_env: '', base_url_env: '' }, - xiaomi: { api_key_env: 'XIAOMI_API_KEY', base_url_env: '' }, + xiaomi: { api_key_env: 'XIAOMI_API_KEY', base_url_env: 'XIAOMI_BASE_URL' }, 'xiaomi-token-plan': { api_key_env: '', base_url_env: '' }, - gemini: { api_key_env: 'GEMINI_API_KEY', base_url_env: '' }, - kilocode: { api_key_env: 'KILO_API_KEY', base_url_env: '' }, - 'ai-gateway': { api_key_env: 'AI_GATEWAY_API_KEY', base_url_env: '' }, + gemini: { api_key_env: 'GEMINI_API_KEY', base_url_env: 'GEMINI_BASE_URL' }, + kilocode: { api_key_env: 'KILO_API_KEY', base_url_env: 'KILOCODE_BASE_URL' }, + 'ai-gateway': { api_key_env: 'AI_GATEWAY_API_KEY', base_url_env: 'AI_GATEWAY_BASE_URL' }, cliproxyapi: { api_key_env: '', base_url_env: '' }, - 'opencode-zen': { api_key_env: 'OPENCODE_ZEN_API_KEY', base_url_env: '' }, - 'opencode-go': { api_key_env: 'OPENCODE_GO_API_KEY', base_url_env: '' }, - huggingface: { api_key_env: 'HF_TOKEN', base_url_env: '' }, - arcee: { api_key_env: 'ARCEE_API_KEY', base_url_env: '' }, - stepfun: { api_key_env: 'STEPFUN_API_KEY', base_url_env: '' }, - 'ollama-cloud': { api_key_env: 'OLLAMA_API_KEY', base_url_env: '' }, + 'opencode-zen': { api_key_env: 'OPENCODE_ZEN_API_KEY', base_url_env: 'OPENCODE_ZEN_BASE_URL' }, + 'opencode-go': { api_key_env: 'OPENCODE_GO_API_KEY', base_url_env: 'OPENCODE_GO_BASE_URL' }, + huggingface: { api_key_env: 'HF_TOKEN', base_url_env: 'HF_BASE_URL' }, + arcee: { api_key_env: 'ARCEE_API_KEY', base_url_env: 'ARCEE_BASE_URL' }, + stepfun: { api_key_env: 'STEPFUN_API_KEY', base_url_env: 'STEPFUN_BASE_URL' }, + 'ollama-cloud': { api_key_env: 'OLLAMA_API_KEY', base_url_env: 'OLLAMA_BASE_URL' }, nous: { api_key_env: '', base_url_env: '' }, 'openai-codex': { api_key_env: '', base_url_env: '' }, copilot: { api_key_env: '', base_url_env: '' }, diff --git a/packages/server/src/shared/providers.ts b/packages/server/src/shared/providers.ts index 79750e7..f1dbe79 100644 --- a/packages/server/src/shared/providers.ts +++ b/packages/server/src/shared/providers.ts @@ -40,6 +40,14 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ 'claude-haiku-4-5' ], }, + { + label: 'LM Studio', + value: 'lmstudio', + builtin: true, + base_url: 'http://127.0.0.1:1234/v1', + api_mode: 'chat_completions', + models: [], + }, { label: 'Anthropic', value: 'anthropic', diff --git a/tests/server/model-visibility-controller.test.ts b/tests/server/model-visibility-controller.test.ts index 8f27a7f..3f693c5 100644 --- a/tests/server/model-visibility-controller.test.ts +++ b/tests/server/model-visibility-controller.test.ts @@ -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 = {}): 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 () => { diff --git a/tests/server/provider-create-controller.test.ts b/tests/server/provider-create-controller.test.ts new file mode 100644 index 0000000..f820b27 --- /dev/null +++ b/tests/server/provider-create-controller.test.ts @@ -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, 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') + }) +}) diff --git a/tests/server/provider-delete-controller.test.ts b/tests/server/provider-delete-controller.test.ts index 50de11e..fd24696 100644 --- a/tests/server/provider-delete-controller.test.ts +++ b/tests/server/provider-delete-controller.test.ts @@ -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:',