fix provider base URL env handling (#1054)

This commit is contained in:
ekko
2026-05-27 10:41:29 +08:00
committed by GitHub
parent a10e171082
commit 07c4c1ddd5
9 changed files with 252 additions and 59 deletions
@@ -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<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 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 {
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<string, AvailableGroup>()
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) {
@@ -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) {
+20 -19
View File
@@ -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 }> = {
'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: '' },
+8
View File
@@ -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',