- 表单下拉框不再用 label 包裹,改为 div + aria-label,避免点击后 下拉面板瞬间关闭 - Provider 卡片新增「从上游获取模型」和「修改」入口,支持编辑 base URL、API Key 与 api_mode,凭据写入对应的环境变量 - 服务端抽出 provider-model-discovery 统一不同 api_mode 的模型发现, 旧的 custom_providers 与新的 providers 字典结构都能正确更新 - Agent Bridge 改为后台启动,HTTP 接口在 Python 运行时就绪前即可用; worker 连接被拒时按类型重试,不重放可能已产生副作用的请求 - 断线重连后恢复 profiles、models 与会话列表,健康检查改为自调度 的 setTimeout,离线时 2 秒后重试 - 批量删除确认文案改用 Popconfirm content,避免变成侧栏触发器文本
327 lines
15 KiB
TypeScript
327 lines
15 KiB
TypeScript
import { existsSync, readFileSync } from 'fs'
|
|
import { writeFile } from 'fs/promises'
|
|
import { join } from 'path'
|
|
import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile'
|
|
import { updateConfigYamlForProfile, saveEnvValueForProfile, PROVIDER_ENV_MAP } from '../../services/config-helpers'
|
|
import { getCompatibleCustomProviders, normalizeCustomProviderEntry } from '../../services/hermes/custom-providers-compat'
|
|
import { PROVIDER_PRESETS } from '../../shared/providers'
|
|
import { logger } from '../../services/logger'
|
|
|
|
const OPTIONAL_API_KEY_PROVIDERS = new Set(['cliproxyapi', 'xai-oauth', 'openai-codex', 'google-gemini-cli', 'claude-oauth'])
|
|
const DIRECT_CONFIG_PROVIDERS = new Set(['xai-oauth', 'openai-codex', 'google-gemini-cli', 'claude-oauth'])
|
|
type ProviderApiMode = 'chat_completions' | 'codex_responses' | 'anthropic_messages' | 'bedrock_converse' | 'codex_app_server'
|
|
|
|
function requestedProfile(ctx: any): string {
|
|
return ctx.state?.profile?.name || getActiveProfileName() || 'default'
|
|
}
|
|
|
|
function authPathForProfile(profile: string): string {
|
|
return join(getProfileDir(profile), 'auth.json')
|
|
}
|
|
|
|
async function clearStoredAuthProvider(profile: string, poolKey: string) {
|
|
try {
|
|
const authPath = authPathForProfile(profile)
|
|
if (!existsSync(authPath)) return
|
|
|
|
const auth = JSON.parse(readFileSync(authPath, 'utf-8'))
|
|
let changed = false
|
|
if (auth.providers && Object.prototype.hasOwnProperty.call(auth.providers, poolKey)) {
|
|
delete auth.providers[poolKey]
|
|
changed = true
|
|
}
|
|
if (auth.credential_pool && Object.prototype.hasOwnProperty.call(auth.credential_pool, poolKey)) {
|
|
delete auth.credential_pool[poolKey]
|
|
changed = true
|
|
}
|
|
if (changed) {
|
|
await writeFile(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8')
|
|
}
|
|
} catch (err: any) { logger.error(err, 'Failed to clear auth credentials for %s', poolKey) }
|
|
}
|
|
|
|
function normalizeApiMode(value: unknown): ProviderApiMode | undefined {
|
|
const apiMode = String(value || '').trim()
|
|
return apiMode === 'chat_completions' ||
|
|
apiMode === 'codex_responses' ||
|
|
apiMode === 'anthropic_messages' ||
|
|
apiMode === 'bedrock_converse' ||
|
|
apiMode === 'codex_app_server'
|
|
? apiMode
|
|
: undefined
|
|
}
|
|
|
|
function buildProviderEntry(name: string, base_url: string, api_key: string, model: string, context_length?: number, api_mode?: ProviderApiMode) {
|
|
const entry: any = { name, base_url, api_key, model }
|
|
if (api_mode) {
|
|
entry.api_mode = api_mode
|
|
}
|
|
if (context_length && context_length > 0) {
|
|
entry.models = { [model]: { context_length } }
|
|
}
|
|
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)
|
|
}
|
|
|
|
function providerKeyForCustomName(name: string): string {
|
|
return `custom:${String(name || '').trim().toLowerCase().replace(/ /g, '-')}`
|
|
}
|
|
|
|
function findLegacyCustomProviderIndex(config: any, poolKey: string): number {
|
|
return Array.isArray(config.custom_providers)
|
|
? (config.custom_providers as any[]).findIndex((e: any) => providerKeyForCustomName(e?.name) === poolKey)
|
|
: -1
|
|
}
|
|
|
|
function findProviderDictKey(config: any, poolKey: string, requestedProviderKey = ''): string {
|
|
const dict = config.providers
|
|
if (!dict || typeof dict !== 'object' || Array.isArray(dict)) return ''
|
|
if (requestedProviderKey && Object.prototype.hasOwnProperty.call(dict, requestedProviderKey)) {
|
|
return requestedProviderKey
|
|
}
|
|
for (const [key, entry] of Object.entries(dict)) {
|
|
const normalized = normalizeCustomProviderEntry(entry, key, 'providers')
|
|
if (normalized && providerKeyForCustomName(normalized.name) === poolKey) return key
|
|
}
|
|
return ''
|
|
}
|
|
|
|
export async function create(ctx: any) {
|
|
const { name, base_url, api_key, model, context_length, providerKey, api_mode } = ctx.request.body as {
|
|
name: string; base_url: string; api_key: string; model: string; context_length?: number; providerKey?: string | null; api_mode?: ProviderApiMode
|
|
}
|
|
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
|
|
const customApiMode = normalizeApiMode(api_mode)
|
|
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 || ''))) {
|
|
ctx.status = 400; ctx.body = { error: 'Missing API key' }; return
|
|
}
|
|
try {
|
|
const profile = requestedProfile(ctx)
|
|
await updateConfigYamlForProfile(profile, async (config) => {
|
|
if (typeof config.model !== 'object' || config.model === null) { config.model = {} }
|
|
if (!isBuiltin) {
|
|
if (!Array.isArray(config.custom_providers)) { config.custom_providers = [] }
|
|
const existing = (config.custom_providers as any[]).find(
|
|
(e: any) => `custom:${e.name}` === poolKey
|
|
)
|
|
if (existing) {
|
|
existing.base_url = effectiveBaseUrl
|
|
existing.api_key = api_key
|
|
existing.model = model
|
|
const preset = PROVIDER_PRESETS.find(p => p.value === poolKey.replace('custom:', ''))
|
|
if (preset?.api_mode) existing.api_mode = preset.api_mode
|
|
else if (customApiMode) existing.api_mode = customApiMode
|
|
if (context_length && context_length > 0) {
|
|
if (!existing.models) existing.models = {}
|
|
existing.models[model] = existing.models[model] || {}
|
|
existing.models[model].context_length = context_length
|
|
}
|
|
} else {
|
|
const entry = buildProviderEntry(normalizedName.toLowerCase().replace(/ /g, '-'), effectiveBaseUrl, api_key, model, context_length, customApiMode)
|
|
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)
|
|
}
|
|
config.model.default = model
|
|
config.model.provider = poolKey
|
|
} 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 && 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 && 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 (!Array.isArray(config.custom_providers)) { config.custom_providers = [] }
|
|
const existing = (config.custom_providers as any[]).find(
|
|
(e: any) => `custom:${e.name}` === `custom:${poolKey}`
|
|
)
|
|
if (existing) {
|
|
existing.base_url = effectiveBaseUrl
|
|
existing.api_key = api_key
|
|
existing.model = model
|
|
const preset = PROVIDER_PRESETS.find(p => p.value === poolKey)
|
|
if (preset?.api_mode) existing.api_mode = preset.api_mode
|
|
else if (customApiMode) existing.api_mode = customApiMode
|
|
if (context_length && context_length > 0) {
|
|
if (!existing.models) existing.models = {}
|
|
existing.models[model] = existing.models[model] || {}
|
|
existing.models[model].context_length = context_length
|
|
}
|
|
} else {
|
|
const entry = buildProviderEntry(poolKey, effectiveBaseUrl, api_key, model, context_length, customApiMode)
|
|
const preset = PROVIDER_PRESETS.find(p => p.value === poolKey)
|
|
if (preset?.api_mode) entry.api_mode = preset.api_mode
|
|
config.custom_providers.push(entry)
|
|
}
|
|
config.model.default = model
|
|
config.model.provider = `custom:${poolKey}`
|
|
}
|
|
}
|
|
delete config.model.base_url
|
|
delete config.model.api_key
|
|
return config
|
|
})
|
|
// TODO: Test if provider works without gateway restart
|
|
// try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') }
|
|
ctx.body = { success: true }
|
|
} catch (err: any) {
|
|
ctx.status = 500; ctx.body = { error: err.message }
|
|
}
|
|
}
|
|
|
|
export async function update(ctx: any) {
|
|
const poolKey = decodeURIComponent(ctx.params.poolKey)
|
|
const { name, base_url, api_key, model, api_mode, source, providerKey } = ctx.request.body as {
|
|
name?: string; base_url?: string; api_key?: string; model?: string; api_mode?: ProviderApiMode; source?: string; providerKey?: string
|
|
}
|
|
const customApiMode = normalizeApiMode(api_mode)
|
|
if (base_url !== undefined) {
|
|
try {
|
|
const parsed = new URL(base_url)
|
|
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('Invalid protocol')
|
|
} catch {
|
|
ctx.status = 400; ctx.body = { error: 'base_url must be a valid HTTP or HTTPS URL' }; return
|
|
}
|
|
}
|
|
try {
|
|
const profile = requestedProfile(ctx)
|
|
const isCustom = poolKey.startsWith('custom:')
|
|
if (isCustom) {
|
|
const found = await updateConfigYamlForProfile(profile, async (config) => {
|
|
const legacyIndex = source === 'providers' ? -1 : findLegacyCustomProviderIndex(config, poolKey)
|
|
const dictKey = source === 'custom_providers' || legacyIndex !== -1
|
|
? '' : findProviderDictKey(config, poolKey, providerKey)
|
|
const entry = legacyIndex !== -1 ? config.custom_providers[legacyIndex] : dictKey ? config.providers[dictKey] : null
|
|
const normalized = normalizeCustomProviderEntry(entry, dictKey, dictKey ? 'providers' : 'custom_providers')
|
|
if (!entry || !normalized || providerKeyForCustomName(normalized.name) !== poolKey) return { data: config, result: false, write: false }
|
|
if (name !== undefined) entry.name = name
|
|
if (base_url !== undefined) entry.base_url = base_url.trim()
|
|
if (api_key !== undefined) {
|
|
if (normalized.key_env) await saveEnvValueForProfile(profile, normalized.key_env, api_key)
|
|
entry.api_key = api_key
|
|
}
|
|
if (model !== undefined) entry.model = model
|
|
if (customApiMode !== undefined) entry.api_mode = customApiMode
|
|
return { data: config, result: true }
|
|
})
|
|
if (!found) {
|
|
ctx.status = 404; ctx.body = { error: `Custom provider "${poolKey}" not found` }; return
|
|
}
|
|
} else {
|
|
const envMapping = PROVIDER_ENV_MAP[poolKey]
|
|
if (!envMapping || (!envMapping.api_key_env && !envMapping.base_url_env)) {
|
|
ctx.status = 400; ctx.body = { error: `Cannot update credentials for "${poolKey}"` }; return
|
|
}
|
|
if (base_url !== undefined && !envMapping.base_url_env) {
|
|
ctx.status = 400; ctx.body = { error: `Cannot update base URL for "${poolKey}"` }; return
|
|
}
|
|
if (api_key !== undefined && !envMapping.api_key_env) {
|
|
ctx.status = 400; ctx.body = { error: `Cannot update API key for "${poolKey}"` }; return
|
|
}
|
|
if (api_key !== undefined) await saveEnvValueForProfile(profile, envMapping.api_key_env, api_key)
|
|
if (base_url !== undefined) await saveEnvValueForProfile(profile, envMapping.base_url_env, base_url.trim())
|
|
}
|
|
// TODO: Test if provider works without gateway restart
|
|
// try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') }
|
|
ctx.body = { success: true }
|
|
} catch (err: any) {
|
|
ctx.status = 500; ctx.body = { error: err.message }
|
|
}
|
|
}
|
|
|
|
export async function remove(ctx: any) {
|
|
const poolKey = decodeURIComponent(ctx.params.poolKey)
|
|
const query = ctx.query as { source?: string; providerKey?: string }
|
|
const requestedSource = query?.source === 'providers' || query?.source === 'custom_providers'
|
|
? query.source
|
|
: ''
|
|
const requestedProviderKey = typeof query?.providerKey === 'string' ? query.providerKey.trim() : ''
|
|
try {
|
|
const profile = requestedProfile(ctx)
|
|
const isCustom = poolKey.startsWith('custom:')
|
|
const removed = await updateConfigYamlForProfile(profile, async (config) => {
|
|
if (isCustom) {
|
|
const removeLegacy = requestedSource !== 'providers'
|
|
const removeDict = requestedSource !== 'custom_providers'
|
|
let didRemove = false
|
|
if (removeLegacy) {
|
|
const idx = findLegacyCustomProviderIndex(config, poolKey)
|
|
if (idx !== -1) {
|
|
;(config.custom_providers as any[]).splice(idx, 1)
|
|
didRemove = true
|
|
}
|
|
}
|
|
if (!didRemove && removeDict) {
|
|
const dictKey = findProviderDictKey(config, poolKey, requestedProviderKey)
|
|
if (dictKey) {
|
|
delete config.providers[dictKey]
|
|
didRemove = true
|
|
}
|
|
}
|
|
if (!didRemove) return { data: config, result: false, write: false }
|
|
} else {
|
|
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 (config.model?.provider === poolKey) {
|
|
const remaining = getCompatibleCustomProviders(config)
|
|
if (remaining.length > 0) {
|
|
const fallbackCp = remaining[0]
|
|
const fallbackKey = providerKeyForCustomName(fallbackCp.name)
|
|
if (typeof config.model !== 'object' || config.model === null) { config.model = {} }
|
|
config.model.default = fallbackCp.model || Object.keys(fallbackCp.models || {})[0] || ''
|
|
config.model.provider = fallbackKey
|
|
delete config.model.base_url
|
|
delete config.model.api_key
|
|
} else {
|
|
config.model = {}
|
|
}
|
|
}
|
|
return { data: config, result: true }
|
|
})
|
|
if (!removed) {
|
|
ctx.status = 404; ctx.body = { error: `Custom provider "${poolKey}" not found` }; return
|
|
}
|
|
if (!isCustom) {
|
|
const envMapping = PROVIDER_ENV_MAP[poolKey]
|
|
if (!envMapping) {
|
|
ctx.status = 404; ctx.body = { error: `Provider "${poolKey}" not found` }; return
|
|
}
|
|
}
|
|
await clearStoredAuthProvider(profile, poolKey)
|
|
// TODO: Test if provider works without gateway restart
|
|
// try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') }
|
|
ctx.body = { success: true }
|
|
} catch (err: any) {
|
|
ctx.status = 500; ctx.body = { error: err.message }
|
|
}
|
|
}
|