fix provider base URL env handling (#1054)
This commit is contained in:
@@ -42,6 +42,8 @@ export interface AvailableModelGroup {
|
|||||||
available_models?: string[]
|
available_models?: string[]
|
||||||
api_key: string
|
api_key: string
|
||||||
builtin?: boolean
|
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 展示。 */
|
/** 可选:模型 ID -> 元数据(preview/disabled/alias)。alias 仅用于 Web UI 展示。 */
|
||||||
model_meta?: Record<string, { preview?: boolean; disabled?: boolean; alias?: string }>
|
model_meta?: Record<string, { preview?: boolean; disabled?: boolean; alias?: string }>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ const alibabaCodingRegion = ref<'intl' | 'cn'>('intl')
|
|||||||
const presetOptions = computed(() =>
|
const presetOptions = computed(() =>
|
||||||
modelsStore.allProviders.map(g => ({ label: g.label, value: g.provider })),
|
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<string, string> = {
|
const FUN_LINK_MAP: Record<string, string> = {
|
||||||
'fun-codex': 'https://apikey.fun/register?aff=LIBAPI',
|
'fun-codex': 'https://apikey.fun/register?aff=LIBAPI',
|
||||||
@@ -85,7 +89,7 @@ watch(selectedPreset, (val) => {
|
|||||||
formData.value.model = ''
|
formData.value.model = ''
|
||||||
alibabaCodingRegion.value = 'intl'
|
alibabaCodingRegion.value = 'intl'
|
||||||
if (val) {
|
if (val) {
|
||||||
const group = modelsStore.allProviders.find(g => g.provider === val)
|
const group = selectedPresetProvider.value
|
||||||
if (group) {
|
if (group) {
|
||||||
formData.value.name = group.label
|
formData.value.name = group.label
|
||||||
formData.value.base_url = group.base_url
|
formData.value.base_url = group.base_url
|
||||||
@@ -371,7 +375,7 @@ function handleClose() {
|
|||||||
<NInput
|
<NInput
|
||||||
v-model:value="formData.base_url"
|
v-model:value="formData.base_url"
|
||||||
:placeholder="t('models.baseUrlPlaceholder')"
|
:placeholder="t('models.baseUrlPlaceholder')"
|
||||||
:disabled="providerType === 'preset'"
|
:disabled="providerType === 'preset' && !canEditPresetBaseUrl"
|
||||||
/>
|
/>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { listUserProfiles } from '../../db/hermes/users-store'
|
|||||||
const PROVIDER_MODEL_CATALOG = buildProviderModelMap()
|
const PROVIDER_MODEL_CATALOG = buildProviderModelMap()
|
||||||
|
|
||||||
type ModelMeta = { preview?: boolean; disabled?: boolean; alias?: string }
|
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<string, ModelMeta>; available_models?: string[] }
|
type AvailableGroup = { provider: string; label: string; base_url: string; models: string[]; api_key: string; builtin?: boolean; model_meta?: Record<string, ModelMeta>; available_models?: string[]; base_url_env?: string }
|
||||||
type ModelVisibility = Record<string, ModelVisibilityRule>
|
type ModelVisibility = Record<string, ModelVisibilityRule>
|
||||||
type CustomModels = Record<string, string[]>
|
type CustomModels = Record<string, string[]>
|
||||||
|
|
||||||
@@ -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 {
|
function normalizeModelVisibility(input: unknown): ModelVisibility {
|
||||||
if (!input || typeof input !== 'object' || Array.isArray(input)) return {}
|
if (!input || typeof input !== 'object' || Array.isArray(input)) return {}
|
||||||
const out: ModelVisibility = {}
|
const out: ModelVisibility = {}
|
||||||
@@ -169,6 +181,18 @@ function providerKeyForCustom(name: string): string {
|
|||||||
return `custom:${name.trim().toLowerCase().replace(/ /g, '-')}`
|
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[] {
|
function mergeAvailableGroups(groups: AvailableGroup[]): AvailableGroup[] {
|
||||||
const byProvider = new Map<string, AvailableGroup>()
|
const byProvider = new Map<string, AvailableGroup>()
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
@@ -347,15 +371,18 @@ async function buildAvailableForProfile(
|
|||||||
}
|
}
|
||||||
if (Object.keys(modelMeta).length === 0) modelMeta = undefined
|
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) {
|
if (envMapping.api_key_env) {
|
||||||
const apiKey = envGetValue(envMapping.api_key_env)
|
const apiKey = envGetValue(envMapping.api_key_env)
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
const fetched = await cachedProviderModels(fetchCache, baseUrl, apiKey, providerKey === 'openrouter')
|
try {
|
||||||
if (fetched.length > 0) modelsList = fetched
|
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) {
|
if (modelsList.length > 0) {
|
||||||
const apiKey = envMapping.api_key_env ? envGetValue(envMapping.api_key_env) : ''
|
const apiKey = envMapping.api_key_env ? envGetValue(envMapping.api_key_env) : ''
|
||||||
addGroup(providerKey, label, baseUrl, modelsList, apiKey, true, modelMeta)
|
addGroup(providerKey, label, baseUrl, modelsList, apiKey, true, modelMeta)
|
||||||
@@ -428,13 +455,7 @@ export async function getAvailable(ctx: any) {
|
|||||||
defaultProfile?.default_provider || '',
|
defaultProfile?.default_provider || '',
|
||||||
visibleGroups,
|
visibleGroups,
|
||||||
)
|
)
|
||||||
const allProvidersBase = PROVIDER_PRESETS.map((p: any) => ({
|
const allProvidersBase = PROVIDER_PRESETS.map((p: any) => providerPresetToGroup(p))
|
||||||
provider: p.value,
|
|
||||||
label: p.label,
|
|
||||||
base_url: p.base_url,
|
|
||||||
models: p.models,
|
|
||||||
api_key: '',
|
|
||||||
}))
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
default: visibleDefault.defaultModel,
|
default: visibleDefault.defaultModel,
|
||||||
default_provider: visibleDefault.defaultProvider,
|
default_provider: visibleDefault.defaultProvider,
|
||||||
@@ -465,13 +486,7 @@ export async function getAvailable(ctx: any) {
|
|||||||
default: visibleProfileDefault.defaultModel,
|
default: visibleProfileDefault.defaultModel,
|
||||||
default_provider: visibleProfileDefault.defaultProvider,
|
default_provider: visibleProfileDefault.defaultProvider,
|
||||||
groups: visibleProfileGroups,
|
groups: visibleProfileGroups,
|
||||||
allProviders: applyModelAliases(PROVIDER_PRESETS.map((p: any) => ({
|
allProviders: applyModelAliases(PROVIDER_PRESETS.map((p: any) => providerPresetToGroup(p)), modelAliasesForProfile),
|
||||||
provider: p.value,
|
|
||||||
label: p.label,
|
|
||||||
base_url: p.base_url,
|
|
||||||
models: p.models,
|
|
||||||
api_key: '',
|
|
||||||
})), modelAliasesForProfile),
|
|
||||||
model_aliases: modelAliasesForProfile,
|
model_aliases: modelAliasesForProfile,
|
||||||
model_visibility: modelVisibilityForProfile,
|
model_visibility: modelVisibilityForProfile,
|
||||||
custom_models: customModelsForProfile,
|
custom_models: customModelsForProfile,
|
||||||
@@ -608,8 +623,8 @@ export async function getAvailable(ctx: any) {
|
|||||||
}
|
}
|
||||||
modelMeta = Object.keys(nextModelMeta).length > 0 ? nextModelMeta : undefined
|
modelMeta = Object.keys(nextModelMeta).length > 0 ? nextModelMeta : undefined
|
||||||
}
|
}
|
||||||
} else if (providerKey === 'openrouter' || providerKey === 'cliproxyapi' || providerKey === 'ollama-cloud') {
|
} else if (providerShouldFetchLiveModels(providerKey)) {
|
||||||
// OpenRouter and local CLIProxyAPI expose dynamic OpenAI-compatible /models catalogs.
|
// These providers expose dynamic OpenAI-compatible /models catalogs.
|
||||||
if (envMapping.api_key_env) {
|
if (envMapping.api_key_env) {
|
||||||
const apiKey = envGetValue(envMapping.api_key_env)
|
const apiKey = envGetValue(envMapping.api_key_env)
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
@@ -620,6 +635,7 @@ export async function getAvailable(ctx: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
modelsList = includeConfiguredDefaultModel(providerKey, modelsList, currentDefault, currentDefaultProvider)
|
||||||
if (modelsList.length > 0) {
|
if (modelsList.length > 0) {
|
||||||
const apiKey = envMapping.api_key_env ? envGetValue(envMapping.api_key_env) : ''
|
const apiKey = envMapping.api_key_env ? envGetValue(envMapping.api_key_env) : ''
|
||||||
addGroup(providerKey, label, baseUrl, modelsList, apiKey, true, modelMeta)
|
addGroup(providerKey, label, baseUrl, modelsList, apiKey, true, modelMeta)
|
||||||
@@ -661,12 +677,10 @@ export async function getAvailable(ctx: any) {
|
|||||||
const liveCopilotModels = copilotEnabled ? await getCopilotLive() : []
|
const liveCopilotModels = copilotEnabled ? await getCopilotLive() : []
|
||||||
const liveCopilotIds = liveCopilotModels.map((m) => m.id)
|
const liveCopilotIds = liveCopilotModels.map((m) => m.id)
|
||||||
|
|
||||||
const allProvidersBase = PROVIDER_PRESETS.map((p: any) => ({
|
const allProvidersBase = PROVIDER_PRESETS.map((p: any) => providerPresetToGroup(
|
||||||
provider: p.value,
|
p,
|
||||||
label: p.label,
|
p.value === 'copilot' && liveCopilotIds.length > 0 ? liveCopilotIds : p.models,
|
||||||
base_url: p.base_url,
|
))
|
||||||
models: p.value === 'copilot' && liveCopilotIds.length > 0 ? liveCopilotIds : p.models,
|
|
||||||
}))
|
|
||||||
const allProviders = applyModelAliases(allProvidersBase, modelAliases)
|
const allProviders = applyModelAliases(allProvidersBase, modelAliases)
|
||||||
|
|
||||||
if (groups.length === 0) {
|
if (groups.length === 0) {
|
||||||
|
|||||||
@@ -46,11 +46,29 @@ function buildProviderEntry(name: string, base_url: string, api_key: string, mod
|
|||||||
return entry
|
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) {
|
export async function create(ctx: any) {
|
||||||
const { name, base_url, api_key, model, context_length, providerKey } = ctx.request.body as {
|
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
|
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
|
ctx.status = 400; ctx.body = { error: 'Missing name, base_url, or model' }; return
|
||||||
}
|
}
|
||||||
if (!api_key && !OPTIONAL_API_KEY_PROVIDERS.has(String(providerKey || ''))) {
|
if (!api_key && !OPTIONAL_API_KEY_PROVIDERS.has(String(providerKey || ''))) {
|
||||||
@@ -58,8 +76,6 @@ export async function create(ctx: any) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const profile = requestedProfile(ctx)
|
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) => {
|
await updateConfigYamlForProfile(profile, async (config) => {
|
||||||
if (typeof config.model !== 'object' || config.model === null) { config.model = {} }
|
if (typeof config.model !== 'object' || config.model === null) { config.model = {} }
|
||||||
if (!isBuiltin) {
|
if (!isBuiltin) {
|
||||||
@@ -68,7 +84,7 @@ export async function create(ctx: any) {
|
|||||||
(e: any) => `custom:${e.name}` === poolKey
|
(e: any) => `custom:${e.name}` === poolKey
|
||||||
)
|
)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.base_url = base_url
|
existing.base_url = effectiveBaseUrl
|
||||||
existing.api_key = api_key
|
existing.api_key = api_key
|
||||||
existing.model = model
|
existing.model = model
|
||||||
const preset = PROVIDER_PRESETS.find(p => p.value === poolKey.replace('custom:', ''))
|
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
|
existing.models[model].context_length = context_length
|
||||||
}
|
}
|
||||||
} else {
|
} 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:', ''))
|
const preset = PROVIDER_PRESETS.find(p => p.value === poolKey.replace('custom:', ''))
|
||||||
if (preset?.api_mode) entry.api_mode = preset.api_mode
|
if (preset?.api_mode) entry.api_mode = preset.api_mode
|
||||||
config.custom_providers.push(entry)
|
config.custom_providers.push(entry)
|
||||||
@@ -89,11 +105,11 @@ export async function create(ctx: any) {
|
|||||||
} else {
|
} else {
|
||||||
if (PROVIDER_ENV_MAP[poolKey].api_key_env) {
|
if (PROVIDER_ENV_MAP[poolKey].api_key_env) {
|
||||||
await saveEnvValueForProfile(profile, PROVIDER_ENV_MAP[poolKey].api_key_env, api_key)
|
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.default = model
|
||||||
config.model.provider = poolKey
|
config.model.provider = poolKey
|
||||||
} else if (DIRECT_CONFIG_PROVIDERS.has(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.default = model
|
||||||
config.model.provider = poolKey
|
config.model.provider = poolKey
|
||||||
} else {
|
} else {
|
||||||
@@ -102,7 +118,7 @@ export async function create(ctx: any) {
|
|||||||
(e: any) => `custom:${e.name}` === `custom:${poolKey}`
|
(e: any) => `custom:${e.name}` === `custom:${poolKey}`
|
||||||
)
|
)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.base_url = base_url
|
existing.base_url = effectiveBaseUrl
|
||||||
existing.api_key = api_key
|
existing.api_key = api_key
|
||||||
existing.model = model
|
existing.model = model
|
||||||
const preset = PROVIDER_PRESETS.find(p => p.value === poolKey)
|
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
|
existing.models[model].context_length = context_length
|
||||||
}
|
}
|
||||||
} else {
|
} 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)
|
const preset = PROVIDER_PRESETS.find(p => p.value === poolKey)
|
||||||
if (preset?.api_mode) entry.api_mode = preset.api_mode
|
if (preset?.api_mode) entry.api_mode = preset.api_mode
|
||||||
config.custom_providers.push(entry)
|
config.custom_providers.push(entry)
|
||||||
@@ -191,7 +207,9 @@ export async function remove(ctx: any) {
|
|||||||
const envMapping = PROVIDER_ENV_MAP[poolKey]
|
const envMapping = PROVIDER_ENV_MAP[poolKey]
|
||||||
if (envMapping?.api_key_env) {
|
if (envMapping?.api_key_env) {
|
||||||
await saveEnvValueForProfile(profile, 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) {
|
if (config.model?.provider === poolKey) {
|
||||||
|
|||||||
@@ -10,31 +10,32 @@ import { safeFileStore } from './safe-file-store'
|
|||||||
export const PROVIDER_ENV_MAP: Record<string, { api_key_env: string; base_url_env: string }> = {
|
export const PROVIDER_ENV_MAP: Record<string, { api_key_env: string; base_url_env: string }> = {
|
||||||
'fun-codex': { api_key_env: '', base_url_env: '' },
|
'fun-codex': { api_key_env: '', base_url_env: '' },
|
||||||
'fun-claude': { 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: '' },
|
'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: '' },
|
'kimi-coding-cn': { api_key_env: 'KIMI_CN_API_KEY', base_url_env: '' },
|
||||||
moonshot: { api_key_env: 'MOONSHOT_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: { 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': { 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: { api_key_env: 'DEEPSEEK_API_KEY', base_url_env: 'DEEPSEEK_BASE_URL' },
|
||||||
alibaba: { api_key_env: 'DASHSCOPE_API_KEY', base_url_env: '' },
|
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' },
|
'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: '' },
|
anthropic: { api_key_env: 'ANTHROPIC_API_KEY', base_url_env: 'ANTHROPIC_BASE_URL' },
|
||||||
xai: { api_key_env: 'XAI_API_KEY', base_url_env: '' },
|
xai: { api_key_env: 'XAI_API_KEY', base_url_env: 'XAI_BASE_URL' },
|
||||||
'xai-oauth': { api_key_env: '', base_url_env: '' },
|
'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: '' },
|
'xiaomi-token-plan': { api_key_env: '', base_url_env: '' },
|
||||||
gemini: { api_key_env: 'GEMINI_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: { 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': { api_key_env: 'AI_GATEWAY_API_KEY', base_url_env: 'AI_GATEWAY_BASE_URL' },
|
||||||
cliproxyapi: { api_key_env: '', base_url_env: '' },
|
cliproxyapi: { api_key_env: '', base_url_env: '' },
|
||||||
'opencode-zen': { api_key_env: 'OPENCODE_ZEN_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': { api_key_env: 'OPENCODE_GO_API_KEY', base_url_env: 'OPENCODE_GO_BASE_URL' },
|
||||||
huggingface: { api_key_env: 'HF_TOKEN', base_url_env: '' },
|
huggingface: { api_key_env: 'HF_TOKEN', base_url_env: 'HF_BASE_URL' },
|
||||||
arcee: { api_key_env: 'ARCEE_API_KEY', base_url_env: '' },
|
arcee: { api_key_env: 'ARCEE_API_KEY', base_url_env: 'ARCEE_BASE_URL' },
|
||||||
stepfun: { api_key_env: 'STEPFUN_API_KEY', base_url_env: '' },
|
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-cloud': { api_key_env: 'OLLAMA_API_KEY', base_url_env: 'OLLAMA_BASE_URL' },
|
||||||
nous: { api_key_env: '', base_url_env: '' },
|
nous: { api_key_env: '', base_url_env: '' },
|
||||||
'openai-codex': { api_key_env: '', base_url_env: '' },
|
'openai-codex': { api_key_env: '', base_url_env: '' },
|
||||||
copilot: { api_key_env: '', base_url_env: '' },
|
copilot: { api_key_env: '', base_url_env: '' },
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
|||||||
'claude-haiku-4-5'
|
'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',
|
label: 'Anthropic',
|
||||||
value: 'anthropic',
|
value: 'anthropic',
|
||||||
|
|||||||
@@ -42,8 +42,9 @@ vi.mock('../../packages/server/src/services/config-helpers', () => ({
|
|||||||
fetchProviderModels: mockFetchProviderModels,
|
fetchProviderModels: mockFetchProviderModels,
|
||||||
buildModelGroups: mockBuildModelGroups,
|
buildModelGroups: mockBuildModelGroups,
|
||||||
PROVIDER_ENV_MAP: {
|
PROVIDER_ENV_MAP: {
|
||||||
deepseek: { api_key_env: 'DEEPSEEK_API_KEY' },
|
deepseek: { api_key_env: 'DEEPSEEK_API_KEY', base_url_env: 'DEEPSEEK_BASE_URL' },
|
||||||
'xai-oauth': { api_key_env: '', base_url_env: 'XAI_BASE_URL' },
|
lmstudio: { api_key_env: 'LM_API_KEY', base_url_env: 'LM_BASE_URL' },
|
||||||
|
'xai-oauth': { api_key_env: '', base_url_env: '' },
|
||||||
openrouter: {},
|
openrouter: {},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
@@ -67,6 +68,12 @@ vi.mock('../../packages/server/src/shared/providers', () => ({
|
|||||||
base_url: 'https://openrouter.ai/api/v1',
|
base_url: 'https://openrouter.ai/api/v1',
|
||||||
models: ['openrouter/auto'],
|
models: ['openrouter/auto'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'lmstudio',
|
||||||
|
label: 'LM Studio',
|
||||||
|
base_url: 'http://127.0.0.1:1234/v1',
|
||||||
|
models: [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: 'xai-oauth',
|
value: 'xai-oauth',
|
||||||
label: 'xAI Grok OAuth (SuperGrok Subscription)',
|
label: 'xAI Grok OAuth (SuperGrok Subscription)',
|
||||||
@@ -103,6 +110,7 @@ function makeCtx(body: Record<string, unknown> = {}): any {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockReadFile.mockResolvedValue('DEEPSEEK_API_KEY=sk-test\n')
|
mockReadFile.mockResolvedValue('DEEPSEEK_API_KEY=sk-test\n')
|
||||||
|
mockFetchProviderModels.mockResolvedValue([])
|
||||||
mockReadConfigYaml.mockResolvedValue({ model: { default: 'deepseek-chat', provider: 'deepseek' } })
|
mockReadConfigYaml.mockResolvedValue({ model: { default: 'deepseek-chat', provider: 'deepseek' } })
|
||||||
mockReadConfigYamlForProfile.mockResolvedValue({ model: { default: 'deepseek-chat', provider: 'deepseek' } })
|
mockReadConfigYamlForProfile.mockResolvedValue({ model: { default: 'deepseek-chat', provider: 'deepseek' } })
|
||||||
mockBuildModelGroups.mockReturnValue({ default: '', groups: [] })
|
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 () => {
|
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 () => {
|
it('removes built-in API-key provider credentials from env and auth pool', async () => {
|
||||||
writeFileSync(join(hermesHome, '.env'), [
|
writeFileSync(join(hermesHome, '.env'), [
|
||||||
['DEEPSEEK_API_KEY', 'deepseek-placeholder'].join('='),
|
['DEEPSEEK_API_KEY', 'deepseek-placeholder'].join('='),
|
||||||
|
['DEEPSEEK_BASE_URL', 'https://deepseek-proxy.invalid/v1'].join('='),
|
||||||
['OPENROUTER_API_KEY', 'openrouter-placeholder'].join('='),
|
['OPENROUTER_API_KEY', 'openrouter-placeholder'].join('='),
|
||||||
|
['OPENROUTER_BASE_URL', 'https://openrouter-proxy.invalid/v1'].join('='),
|
||||||
'',
|
'',
|
||||||
].join('\n'))
|
].join('\n'))
|
||||||
writeFileSync(join(hermesHome, 'auth.json'), JSON.stringify({
|
writeFileSync(join(hermesHome, 'auth.json'), JSON.stringify({
|
||||||
@@ -69,7 +71,9 @@ describe('providers controller delete', () => {
|
|||||||
expect(ctx.body).toEqual({ success: true })
|
expect(ctx.body).toEqual({ success: true })
|
||||||
const envAfter = readFileSync(join(hermesHome, '.env'), 'utf-8')
|
const envAfter = readFileSync(join(hermesHome, '.env'), 'utf-8')
|
||||||
expect(envAfter).not.toContain('DEEPSEEK_API_KEY')
|
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_API_KEY', 'openrouter-placeholder'].join('='))
|
||||||
|
expect(envAfter).toContain(['OPENROUTER_BASE_URL', 'https://openrouter-proxy.invalid/v1'].join('='))
|
||||||
|
|
||||||
const authAfter = readAuth()
|
const authAfter = readAuth()
|
||||||
expect(authAfter.providers).not.toHaveProperty('deepseek')
|
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 () => {
|
it('removes custom provider config and any matching stored auth entry', async () => {
|
||||||
writeFileSync(join(hermesHome, 'config.yaml'), [
|
writeFileSync(join(hermesHome, 'config.yaml'), [
|
||||||
'model:',
|
'model:',
|
||||||
|
|||||||
Reference in New Issue
Block a user