1050 lines
41 KiB
TypeScript
1050 lines
41 KiB
TypeScript
import { readFile } from 'fs/promises'
|
|
import { existsSync, readFileSync } from 'fs'
|
|
import { join } from 'path'
|
|
import { getActiveEnvPath, getActiveAuthPath, getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from '../../services/hermes/hermes-profile'
|
|
import { readConfigYaml, readConfigYamlForProfile, updateConfigYaml, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers'
|
|
import { buildProviderModelMap, PROVIDER_PRESETS } from '../../shared/providers'
|
|
import { getCopilotModelsDetailed, resolveCopilotOAuthToken, type CopilotModelMeta } from '../../services/hermes/copilot-models'
|
|
import { readAppConfig, writeAppConfig, type ModelVisibilityRule } from '../../services/app-config'
|
|
import { getDb } from '../../db'
|
|
import { MODEL_CONTEXT_TABLE } from '../../db/hermes/schemas'
|
|
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 ModelVisibility = Record<string, ModelVisibilityRule>
|
|
type CustomModels = Record<string, string[]>
|
|
|
|
const RESERVED_ALIAS_KEYS = new Set(['__proto__', 'prototype', 'constructor'])
|
|
|
|
function isSafeAliasKey(value: string): boolean {
|
|
const trimmed = value.trim()
|
|
return !!trimmed && trimmed.length <= 512 && !RESERVED_ALIAS_KEYS.has(trimmed)
|
|
}
|
|
|
|
function createAliasMap(): Record<string, string> {
|
|
return Object.create(null) as Record<string, string>
|
|
}
|
|
|
|
function createProviderAliasMap(): Record<string, Record<string, string>> {
|
|
return Object.create(null) as Record<string, Record<string, string>>
|
|
}
|
|
|
|
function normalizeAliases(value: unknown): Record<string, Record<string, string>> {
|
|
const normalized = createProviderAliasMap()
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return normalized
|
|
for (const [provider, models] of Object.entries(value as Record<string, unknown>)) {
|
|
if (!isSafeAliasKey(provider) || !models || typeof models !== 'object' || Array.isArray(models)) continue
|
|
for (const [model, alias] of Object.entries(models as Record<string, unknown>)) {
|
|
if (!isSafeAliasKey(model) || typeof alias !== 'string') continue
|
|
const trimmed = alias.trim()
|
|
if (!trimmed || trimmed.length > 512) continue
|
|
if (!Object.hasOwn(normalized, provider)) normalized[provider] = createAliasMap()
|
|
normalized[provider][model] = trimmed
|
|
}
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
function applyModelAliases<T extends { provider: string; models: string[]; model_meta?: Record<string, ModelMeta> }>(groups: T[], aliases: Record<string, Record<string, string>>): T[] {
|
|
return groups.map((group) => {
|
|
const providerAliases = aliases[group.provider]
|
|
if (!providerAliases) return group
|
|
const modelMeta: Record<string, ModelMeta> = { ...(group.model_meta || {}) }
|
|
let changed = false
|
|
for (const model of group.models) {
|
|
const alias = providerAliases[model]
|
|
if (!alias) continue
|
|
modelMeta[model] = { ...(modelMeta[model] || {}), alias }
|
|
changed = true
|
|
}
|
|
return changed ? { ...group, model_meta: modelMeta } : group
|
|
})
|
|
}
|
|
|
|
function uniqueStrings(values: unknown): string[] {
|
|
if (!Array.isArray(values)) return []
|
|
return Array.from(new Set(values.map(v => String(v || '').trim()).filter(Boolean)))
|
|
}
|
|
|
|
function normalizeCustomModels(input: unknown): CustomModels {
|
|
if (!input || typeof input !== 'object' || Array.isArray(input)) return {}
|
|
const out: CustomModels = {}
|
|
for (const [provider, rawModels] of Object.entries(input as Record<string, unknown>)) {
|
|
const providerKey = String(provider || '').trim()
|
|
if (!providerKey) continue
|
|
const models = uniqueStrings(rawModels)
|
|
if (models.length > 0) out[providerKey] = models
|
|
}
|
|
return out
|
|
}
|
|
|
|
function applyCustomModels(groups: AvailableGroup[], customModels: CustomModels): AvailableGroup[] {
|
|
return groups.map(group => {
|
|
const extra = customModels[group.provider] || []
|
|
if (!extra.length) return group
|
|
const models = [...new Set([...group.models, ...extra])]
|
|
const availableModels = [...new Set([...(group.available_models || group.models), ...extra])]
|
|
return { ...group, models, available_models: availableModels }
|
|
})
|
|
}
|
|
|
|
function normalizeModelVisibility(input: unknown): ModelVisibility {
|
|
if (!input || typeof input !== 'object' || Array.isArray(input)) return {}
|
|
const out: ModelVisibility = {}
|
|
for (const [provider, rawRule] of Object.entries(input as Record<string, unknown>)) {
|
|
const providerKey = String(provider || '').trim()
|
|
if (!providerKey || !rawRule || typeof rawRule !== 'object' || Array.isArray(rawRule)) continue
|
|
const rule = rawRule as { mode?: unknown; models?: unknown }
|
|
const mode = rule.mode === 'include' ? 'include' : 'all'
|
|
const models = uniqueStrings(rule.models)
|
|
if (mode === 'include') {
|
|
if (models.length > 0) out[providerKey] = { mode, models }
|
|
} else {
|
|
out[providerKey] = { mode: 'all', models: [] }
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
function filterModelsForProvider(provider: string, models: string[], visibility: ModelVisibility): string[] {
|
|
const rule = visibility[provider]
|
|
if (!rule || rule.mode !== 'include') return models
|
|
const allowed = new Set(rule.models)
|
|
const visible = models.filter(model => allowed.has(model))
|
|
// If a stale hand-edited rule references models that are no longer present,
|
|
// fail open so the provider remains recoverable from the Web UI.
|
|
return visible.length > 0 ? visible : models
|
|
}
|
|
|
|
function applyModelVisibility(groups: AvailableGroup[], visibility: ModelVisibility): AvailableGroup[] {
|
|
return groups
|
|
.map(group => {
|
|
const availableModels = group.available_models || group.models
|
|
return {
|
|
...group,
|
|
available_models: availableModels,
|
|
models: filterModelsForProvider(group.provider, availableModels, visibility),
|
|
}
|
|
})
|
|
.filter(group => group.models.length > 0)
|
|
}
|
|
|
|
function resolveVisibleDefault(defaultModel: string, defaultProvider: string, groups: AvailableGroup[]) {
|
|
if (defaultModel) {
|
|
const explicit = groups.find(group => group.provider === defaultProvider && group.models.includes(defaultModel))
|
|
if (explicit) return { defaultModel, defaultProvider }
|
|
const inferred = groups.find(group => group.models.includes(defaultModel))
|
|
if (inferred) return { defaultModel, defaultProvider: inferred.provider }
|
|
}
|
|
const fallback = groups.find(group => group.models.length > 0)
|
|
return { defaultModel: fallback?.models[0] || '', defaultProvider: fallback?.provider || '' }
|
|
}
|
|
|
|
function profileEnvPath(profile: string): string {
|
|
return join(getProfileDir(profile), '.env')
|
|
}
|
|
|
|
function profileAuthPath(profile: string): string {
|
|
return join(getProfileDir(profile), 'auth.json')
|
|
}
|
|
|
|
function envReader(envContent: string) {
|
|
const envHasValue = (key: string): boolean => {
|
|
if (!key) return false
|
|
const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm'))
|
|
return !!match && match[1].trim() !== '' && !match[1].trim().startsWith('#')
|
|
}
|
|
const envGetValue = (key: string): string => {
|
|
if (!key) return ''
|
|
const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm'))
|
|
return match?.[1]?.trim() || ''
|
|
}
|
|
return { envHasValue, envGetValue }
|
|
}
|
|
|
|
function providerKeyForCustom(name: string): string {
|
|
return `custom:${name.trim().toLowerCase().replace(/ /g, '-')}`
|
|
}
|
|
|
|
function mergeAvailableGroups(groups: AvailableGroup[]): AvailableGroup[] {
|
|
const byProvider = new Map<string, AvailableGroup>()
|
|
for (const group of groups) {
|
|
const existing = byProvider.get(group.provider)
|
|
if (!existing) {
|
|
byProvider.set(group.provider, {
|
|
...group,
|
|
models: [...new Set(group.models)],
|
|
available_models: [...new Set(group.available_models || group.models)],
|
|
model_meta: group.model_meta ? { ...group.model_meta } : undefined,
|
|
})
|
|
continue
|
|
}
|
|
existing.models = [...new Set([...existing.models, ...group.models])]
|
|
existing.available_models = [...new Set([...(existing.available_models || existing.models), ...(group.available_models || group.models)])]
|
|
existing.api_key = existing.api_key || group.api_key
|
|
existing.base_url = existing.base_url || group.base_url
|
|
existing.builtin = existing.builtin || group.builtin
|
|
existing.model_meta = { ...(existing.model_meta || {}), ...(group.model_meta || {}) }
|
|
if (existing.model_meta && Object.keys(existing.model_meta).length === 0) delete existing.model_meta
|
|
}
|
|
return [...byProvider.values()]
|
|
}
|
|
|
|
type ProviderFetchCache = Map<string, Promise<string[]>>
|
|
|
|
function requestedProfileName(ctx: any): string {
|
|
const queryProfile = ctx.query?.profile
|
|
return typeof queryProfile === 'string' && queryProfile.trim() ? queryProfile.trim() : ''
|
|
}
|
|
|
|
function visibleProfileNamesForUser(ctx: any): string[] {
|
|
const diskProfiles = listProfileNamesFromDisk()
|
|
const user = ctx.state?.user
|
|
if (!user || user.role === 'super_admin') return diskProfiles
|
|
const allowed = new Set(listUserProfiles(user.id).map(profile => profile.profile_name))
|
|
return diskProfiles.filter(profile => allowed.has(profile))
|
|
}
|
|
|
|
function cachedProviderModels(
|
|
cache: ProviderFetchCache,
|
|
baseUrl: string,
|
|
apiKey: string,
|
|
freeOnly = false,
|
|
): Promise<string[]> {
|
|
const key = `${baseUrl.replace(/\/+$/, '')}\n${apiKey}\n${freeOnly ? 'free' : 'all'}`
|
|
let pending = cache.get(key)
|
|
if (!pending) {
|
|
pending = fetchProviderModels(baseUrl, apiKey, freeOnly)
|
|
cache.set(key, pending)
|
|
}
|
|
return pending
|
|
}
|
|
|
|
|
|
// Copilot 授权检测:复用同一套 token 解析逻辑(含 ~/.config/github-copilot/apps.json
|
|
// 与 ghp_ PAT 跳过),与 getCopilotModels 行为一致,避免出现"模型能拉到却被判未授权"。
|
|
async function isCopilotAuthorized(envContent: string): Promise<boolean> {
|
|
return !!(await resolveCopilotOAuthToken(envContent))
|
|
}
|
|
|
|
async function buildAvailableForProfile(
|
|
profile: string,
|
|
fetchCache: ProviderFetchCache,
|
|
appConfig: Awaited<ReturnType<typeof readAppConfig>>,
|
|
): Promise<{
|
|
profile: string
|
|
default: string
|
|
default_provider: string
|
|
groups: AvailableGroup[]
|
|
}> {
|
|
const config = await readConfigYamlForProfile(profile)
|
|
const modelSection = config.model
|
|
let currentDefault = ''
|
|
let currentDefaultProvider = ''
|
|
if (typeof modelSection === 'object' && modelSection !== null) {
|
|
currentDefault = String(modelSection.default || '').trim()
|
|
currentDefaultProvider = String(modelSection.provider || '').trim()
|
|
if (currentDefaultProvider === 'custom' && currentDefault) {
|
|
const cps = Array.isArray(config.custom_providers) ? config.custom_providers as any[] : []
|
|
const match = cps.find(
|
|
(cp: any) => cp.base_url?.replace(/\/+$/, '') === String(modelSection.base_url || '').replace(/\/+$/, '')
|
|
&& cp.model === currentDefault,
|
|
)
|
|
if (match) currentDefaultProvider = providerKeyForCustom(String(match.name || ''))
|
|
}
|
|
} else if (typeof modelSection === 'string') {
|
|
currentDefault = modelSection.trim()
|
|
}
|
|
|
|
let envContent = ''
|
|
try { envContent = await readFile(profileEnvPath(profile), 'utf-8') } catch {}
|
|
const { envHasValue, envGetValue } = envReader(envContent)
|
|
|
|
const isOAuthAuthorized = (providerKey: string): boolean => {
|
|
try {
|
|
const authPath = profileAuthPath(profile)
|
|
if (!existsSync(authPath)) return false
|
|
const auth = JSON.parse(readFileSync(authPath, 'utf-8'))
|
|
const provider = auth.providers?.[providerKey]
|
|
const pool = auth.credential_pool?.[providerKey]
|
|
return !!(
|
|
provider?.tokens?.access_token ||
|
|
provider?.access_token ||
|
|
(Array.isArray(pool) && pool.some((entry: any) => entry?.access_token))
|
|
)
|
|
} catch { return false }
|
|
}
|
|
|
|
let copilotLiveModels: CopilotModelMeta[] | null = null
|
|
const getCopilotLive = async (): Promise<CopilotModelMeta[]> => {
|
|
if (copilotLiveModels !== null) return copilotLiveModels
|
|
try { copilotLiveModels = await getCopilotModelsDetailed(envContent) }
|
|
catch { copilotLiveModels = [] }
|
|
return copilotLiveModels
|
|
}
|
|
|
|
const groups: AvailableGroup[] = []
|
|
const seenProviders = new Set<string>()
|
|
const addGroup = (provider: string, label: string, base_url: string, models: string[], api_key: string, builtin?: boolean, model_meta?: Record<string, ModelMeta>) => {
|
|
if (seenProviders.has(provider)) return
|
|
seenProviders.add(provider)
|
|
const availableModels = [...new Set(models)]
|
|
groups.push({ provider, label, base_url, models: availableModels, available_models: availableModels, api_key, ...(builtin ? { builtin: true } : {}), ...(model_meta ? { model_meta } : {}) })
|
|
}
|
|
|
|
const copilotEnabled = appConfig.copilotEnabled === true
|
|
if (!copilotEnabled && currentDefaultProvider.toLowerCase() === 'copilot') {
|
|
currentDefault = ''
|
|
currentDefaultProvider = ''
|
|
}
|
|
|
|
for (const [providerKey, envMapping] of Object.entries(PROVIDER_ENV_MAP)) {
|
|
if (envMapping.api_key_env && !envHasValue(envMapping.api_key_env)) continue
|
|
if (!envMapping.api_key_env) {
|
|
if (providerKey === 'copilot') {
|
|
if (!copilotEnabled) continue
|
|
if (!(await isCopilotAuthorized(envContent))) continue
|
|
} else if (!isOAuthAuthorized(providerKey)) {
|
|
continue
|
|
}
|
|
}
|
|
const preset = PROVIDER_PRESETS.find((p: any) => p.value === providerKey)
|
|
const label = preset?.label || providerKey.replace(/^custom:/, '')
|
|
let baseUrl = preset?.base_url || ''
|
|
if (envMapping.base_url_env && envHasValue(envMapping.base_url_env)) {
|
|
baseUrl = envGetValue(envMapping.base_url_env) || baseUrl
|
|
}
|
|
const catalogModels = PROVIDER_MODEL_CATALOG[providerKey]
|
|
let modelsList: string[] = catalogModels && catalogModels.length > 0 ? [...catalogModels] : []
|
|
let modelMeta: Record<string, ModelMeta> | undefined
|
|
if (providerKey === 'copilot') {
|
|
const live = await getCopilotLive()
|
|
if (live.length > 0) {
|
|
modelsList = live.map((m) => m.id)
|
|
modelMeta = {}
|
|
for (const m of live) {
|
|
if (m.preview || m.disabled) {
|
|
modelMeta[m.id] = {
|
|
...(m.preview ? { preview: true } : {}),
|
|
...(m.disabled ? { disabled: true } : {}),
|
|
}
|
|
}
|
|
}
|
|
if (Object.keys(modelMeta).length === 0) modelMeta = undefined
|
|
}
|
|
} else if (providerKey === 'openrouter' || providerKey === 'cliproxyapi' || providerKey === 'ollama-cloud') {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
if (modelsList.length > 0) {
|
|
const apiKey = envMapping.api_key_env ? envGetValue(envMapping.api_key_env) : ''
|
|
addGroup(providerKey, label, baseUrl, modelsList, apiKey, true, modelMeta)
|
|
}
|
|
}
|
|
|
|
const customProviders = Array.isArray(config.custom_providers)
|
|
? config.custom_providers as Array<{ name: string; base_url: string; model: string; api_key?: string }>
|
|
: []
|
|
const customFetches = await Promise.allSettled(
|
|
customProviders.map(async cp => {
|
|
if (!cp.base_url) return null
|
|
const providerKey = providerKeyForCustom(cp.name)
|
|
const baseUrl = cp.base_url.replace(/\/+$/, '')
|
|
let models = [cp.model].filter(Boolean)
|
|
if (cp.api_key) {
|
|
const fetched = await cachedProviderModels(fetchCache, baseUrl, cp.api_key)
|
|
if (fetched.length > 0) models = [...new Set([...models, ...fetched])]
|
|
}
|
|
return { providerKey, label: cp.name, base_url: baseUrl, models, api_key: cp.api_key || '' }
|
|
}),
|
|
)
|
|
for (const result of customFetches) {
|
|
if (result.status === 'fulfilled' && result.value?.models.length) {
|
|
const { providerKey, label, base_url, models, api_key } = result.value
|
|
addGroup(providerKey, label, base_url, models, api_key)
|
|
}
|
|
}
|
|
|
|
if (groups.length === 0) {
|
|
const fallback = buildModelGroups(config)
|
|
for (const group of fallback.groups) {
|
|
const models = group.models.map(model => model.id)
|
|
if (models.length) addGroup(group.provider, group.provider, '', models, '')
|
|
}
|
|
currentDefault = currentDefault || fallback.default
|
|
}
|
|
|
|
for (const g of groups) {
|
|
g.models = Array.from(new Set(g.models))
|
|
g.available_models = Array.from(new Set(g.available_models || g.models))
|
|
}
|
|
const groupsWithCustomModels = applyCustomModels(groups, normalizeCustomModels(appConfig.customModels))
|
|
|
|
return { profile, default: currentDefault, default_provider: currentDefaultProvider, groups: groupsWithCustomModels }
|
|
}
|
|
|
|
export async function getAvailable(ctx: any) {
|
|
try {
|
|
const requestedProfile = requestedProfileName(ctx)
|
|
if (!requestedProfile) {
|
|
const appConfig = await readAppConfig()
|
|
const modelAliases = normalizeAliases(appConfig.modelAliases)
|
|
const modelVisibility = normalizeModelVisibility(appConfig.modelVisibility)
|
|
const customModels = normalizeCustomModels(appConfig.customModels)
|
|
const fetchCache: ProviderFetchCache = new Map()
|
|
const visibleProfiles = visibleProfileNamesForUser(ctx)
|
|
const profileResults = await Promise.all(
|
|
visibleProfiles.map(profile => buildAvailableForProfile(profile, fetchCache, appConfig)),
|
|
)
|
|
const mergedGroups = mergeAvailableGroups(profileResults.flatMap(result => result.groups))
|
|
const groupsWithAliases = applyModelAliases(mergedGroups, modelAliases)
|
|
const visibleGroups = applyModelVisibility(groupsWithAliases, modelVisibility)
|
|
const activeProfile = getActiveProfileName()
|
|
const defaultProfile = profileResults.find(result => result.profile === activeProfile && (result.default || result.default_provider))
|
|
|| profileResults.find(result => result.default && result.default_provider)
|
|
|| profileResults.find(result => result.default)
|
|
const visibleDefault = resolveVisibleDefault(
|
|
defaultProfile?.default || '',
|
|
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: '',
|
|
}))
|
|
ctx.body = {
|
|
default: visibleDefault.defaultModel,
|
|
default_provider: visibleDefault.defaultProvider,
|
|
groups: visibleGroups,
|
|
allProviders: applyModelAliases(allProvidersBase, modelAliases),
|
|
model_aliases: modelAliases,
|
|
model_visibility: modelVisibility,
|
|
custom_models: customModels,
|
|
profiles: profileResults.map(result => ({
|
|
profile: result.profile,
|
|
default: result.default,
|
|
default_provider: result.default_provider,
|
|
groups: applyModelVisibility(applyModelAliases(result.groups, modelAliases), modelVisibility),
|
|
})),
|
|
}
|
|
return
|
|
}
|
|
|
|
const appConfigForProfile = await readAppConfig()
|
|
const modelAliasesForProfile = normalizeAliases(appConfigForProfile.modelAliases)
|
|
const modelVisibilityForProfile = normalizeModelVisibility(appConfigForProfile.modelVisibility)
|
|
const customModelsForProfile = normalizeCustomModels(appConfigForProfile.customModels)
|
|
const profileResult = await buildAvailableForProfile(requestedProfile, new Map(), appConfigForProfile)
|
|
const profileGroupsWithAliases = applyModelAliases(profileResult.groups, modelAliasesForProfile)
|
|
const visibleProfileGroups = applyModelVisibility(profileGroupsWithAliases, modelVisibilityForProfile)
|
|
const visibleProfileDefault = resolveVisibleDefault(profileResult.default, profileResult.default_provider, visibleProfileGroups)
|
|
ctx.body = {
|
|
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),
|
|
model_aliases: modelAliasesForProfile,
|
|
model_visibility: modelVisibilityForProfile,
|
|
custom_models: customModelsForProfile,
|
|
profiles: [{
|
|
profile: profileResult.profile,
|
|
default: profileResult.default,
|
|
default_provider: profileResult.default_provider,
|
|
groups: visibleProfileGroups,
|
|
}],
|
|
}
|
|
return
|
|
|
|
const config = await readConfigYaml()
|
|
const modelSection = config.model
|
|
let currentDefault = ''
|
|
let currentDefaultProvider = ''
|
|
if (typeof modelSection === 'object' && modelSection !== null) {
|
|
currentDefault = String(modelSection.default || '').trim()
|
|
currentDefaultProvider = String(modelSection.provider || '').trim()
|
|
// When hermes CLI sets provider: custom, resolve to custom:name
|
|
// by matching base_url + model against custom_providers
|
|
if (currentDefaultProvider === 'custom' && currentDefault) {
|
|
const cps = Array.isArray(config.custom_providers) ? config.custom_providers as any[] : []
|
|
const match = cps.find(
|
|
(cp: any) => cp.base_url?.replace(/\/+$/, '') === String(modelSection.base_url || '').replace(/\/+$/, '')
|
|
&& cp.model === currentDefault,
|
|
)
|
|
if (match) {
|
|
currentDefaultProvider = `custom:${match.name.trim().toLowerCase().replace(/ /g, '-')}`
|
|
}
|
|
}
|
|
} else if (typeof modelSection === 'string') {
|
|
currentDefault = modelSection.trim()
|
|
}
|
|
|
|
const groups: AvailableGroup[] = []
|
|
const seenProviders = new Set<string>()
|
|
|
|
let envContent = ''
|
|
try { envContent = await readFile(getActiveEnvPath(), 'utf-8') } catch { }
|
|
|
|
const envHasValue = (key: string): boolean => {
|
|
if (!key) return false
|
|
const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm'))
|
|
return !!match && match[1].trim() !== '' && !match[1].trim().startsWith('#')
|
|
}
|
|
const envGetValue = (key: string): string => {
|
|
if (!key) return ''
|
|
const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm'))
|
|
return match?.[1]?.trim() || ''
|
|
}
|
|
const addGroup = (provider: string, label: string, base_url: string, models: string[], api_key: string, builtin?: boolean, model_meta?: Record<string, ModelMeta>) => {
|
|
if (seenProviders.has(provider)) return
|
|
seenProviders.add(provider)
|
|
const availableModels = [...models]
|
|
groups.push({ provider, label, base_url, models: availableModels, available_models: availableModels, api_key, ...(builtin ? { builtin: true } : {}), ...(model_meta ? { model_meta } : {}) })
|
|
}
|
|
|
|
const isOAuthAuthorized = (providerKey: string): boolean => {
|
|
try {
|
|
const authPath = getActiveAuthPath()
|
|
if (!existsSync(authPath)) return false
|
|
const auth = JSON.parse(readFileSync(authPath, 'utf-8'))
|
|
const provider = auth.providers?.[providerKey]
|
|
const pool = auth.credential_pool?.[providerKey]
|
|
// Legacy OAuth providers are stored under providers.*; newer Hermes
|
|
// credential pools store Codex-style OAuth entries under
|
|
// credential_pool.*. Treat either shape as an authorized provider.
|
|
return !!(
|
|
provider?.tokens?.access_token ||
|
|
provider?.access_token ||
|
|
(Array.isArray(pool) && pool.some((entry: any) => entry?.access_token))
|
|
)
|
|
} catch { return false }
|
|
}
|
|
|
|
// 同一请求内复用 copilot 动态模型(getCopilotModelsDetailed 内部有 inflight + 缓存,
|
|
// 这里再缓存到局部变量进一步减少分支)
|
|
let copilotLiveModels: CopilotModelMeta[] | null = null
|
|
const getCopilotLive = async (): Promise<CopilotModelMeta[]> => {
|
|
if (copilotLiveModels !== null) return copilotLiveModels
|
|
try { copilotLiveModels = await getCopilotModelsDetailed(envContent) }
|
|
catch { copilotLiveModels = [] }
|
|
return copilotLiveModels
|
|
}
|
|
|
|
// Copilot 显式 opt-in:即便能解析到 token,未通过 web-ui Add Provider 显式启用
|
|
// 时也不返回。避免误把 VS Code/gh CLI 用户的全局凭证当作 hermes provider。
|
|
const appConfig = await readAppConfig()
|
|
const copilotEnabled = appConfig.copilotEnabled === true
|
|
const modelAliases = normalizeAliases(appConfig.modelAliases)
|
|
const modelVisibility = normalizeModelVisibility(appConfig.modelVisibility)
|
|
const customModels = normalizeCustomModels(appConfig.customModels)
|
|
|
|
// 兼容老用户:上一版本会"自动 fallback discovery"出 Copilot;升级后这些用户的
|
|
// config.yaml 可能仍把 model.default 指向某个 copilot 模型。若此时 copilot 已不
|
|
// 启用,把返回的 default 清掉,让前端兜底自动选剩余 provider 的第一个 model。
|
|
if (!copilotEnabled && currentDefaultProvider.toLowerCase() === 'copilot') {
|
|
currentDefault = ''
|
|
currentDefaultProvider = ''
|
|
}
|
|
|
|
for (const [providerKey, envMapping] of Object.entries(PROVIDER_ENV_MAP)) {
|
|
if (envMapping.api_key_env && !envHasValue(envMapping.api_key_env)) continue
|
|
if (!envMapping.api_key_env) {
|
|
if (providerKey === 'copilot') {
|
|
if (!copilotEnabled) continue
|
|
if (!(await isCopilotAuthorized(envContent))) continue
|
|
} else if (!isOAuthAuthorized(providerKey)) {
|
|
continue
|
|
}
|
|
}
|
|
const preset = PROVIDER_PRESETS.find((p: any) => p.value === providerKey)
|
|
const label = preset?.label || providerKey.replace(/^custom:/, '')
|
|
let baseUrl = preset?.base_url || ''
|
|
if (envMapping.base_url_env && envHasValue(envMapping.base_url_env)) {
|
|
baseUrl = envGetValue(envMapping.base_url_env) || baseUrl
|
|
}
|
|
const catalogModels = PROVIDER_MODEL_CATALOG[providerKey]
|
|
let modelsList: string[] = catalogModels && catalogModels.length > 0 ? [...catalogModels] : []
|
|
let modelMeta: Record<string, ModelMeta> | undefined
|
|
if (providerKey === 'copilot') {
|
|
const live = await getCopilotLive()
|
|
if (live.length > 0) {
|
|
modelsList = live.map((m) => m.id)
|
|
const nextModelMeta: Record<string, ModelMeta> = {}
|
|
for (const m of live) {
|
|
if (m.preview || m.disabled) {
|
|
nextModelMeta[m.id] = {
|
|
...(m.preview ? { preview: true } : {}),
|
|
...(m.disabled ? { disabled: true } : {}),
|
|
}
|
|
}
|
|
}
|
|
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.
|
|
if (envMapping.api_key_env) {
|
|
const apiKey = envGetValue(envMapping.api_key_env)
|
|
if (apiKey) {
|
|
try {
|
|
const fetched = await fetchProviderModels(baseUrl, apiKey, providerKey === 'openrouter')
|
|
if (fetched.length > 0) modelsList = fetched
|
|
} catch { /* ignore — leave empty, won't show */ }
|
|
}
|
|
}
|
|
}
|
|
if (modelsList.length > 0) {
|
|
const apiKey = envMapping.api_key_env ? envGetValue(envMapping.api_key_env) : ''
|
|
addGroup(providerKey, label, baseUrl, modelsList, apiKey, true, modelMeta)
|
|
}
|
|
}
|
|
|
|
const customProviders = Array.isArray(config.custom_providers)
|
|
? config.custom_providers as Array<{ name: string; base_url: string; model: string; api_key?: string }>
|
|
: []
|
|
|
|
const customFetches = await Promise.allSettled(
|
|
customProviders.map(async cp => {
|
|
if (!cp.base_url) return null
|
|
const providerKey = `custom:${cp.name.trim().toLowerCase().replace(/ /g, '-')}`
|
|
const baseUrl = cp.base_url.replace(/\/+$/, '')
|
|
let models = [cp.model]
|
|
if (cp.api_key) {
|
|
try { const fetched = await fetchProviderModels(baseUrl, cp.api_key); if (fetched.length > 0) models = [...new Set([cp.model, ...fetched])] } catch { }
|
|
}
|
|
return { providerKey, label: cp.name, base_url: baseUrl, models, api_key: cp.api_key || '' }
|
|
}),
|
|
)
|
|
|
|
for (const result of customFetches) {
|
|
const value = (result as { value?: any }).value
|
|
if (value) {
|
|
const { providerKey, label, base_url, models, api_key: cpApiKey, builtin: cpBuiltin } = value
|
|
addGroup(providerKey, label, base_url, models, cpApiKey, cpBuiltin)
|
|
}
|
|
}
|
|
|
|
for (const g of groups) { g.models = Array.from(new Set(g.models)) }
|
|
const groupsWithAliases = applyModelAliases(applyCustomModels(groups, customModels), modelAliases)
|
|
const visibleGroups = applyModelVisibility(groupsWithAliases, modelVisibility)
|
|
const visibleDefault = resolveVisibleDefault(currentDefault, currentDefaultProvider, visibleGroups)
|
|
|
|
// 动态拉一次 copilot 模型用于 allProviders 展示(同一请求复用缓存)
|
|
// 未启用 Copilot 时跳过拉取,避免空跑网络请求。
|
|
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 allProviders = applyModelAliases(allProvidersBase, modelAliases)
|
|
|
|
if (groups.length === 0) {
|
|
const fallback = buildModelGroups(config)
|
|
const fallbackGroups: AvailableGroup[] = fallback.groups.map(group => {
|
|
const models = group.models.map(model => model.id)
|
|
return {
|
|
provider: group.provider,
|
|
label: group.provider,
|
|
base_url: '',
|
|
models,
|
|
available_models: models,
|
|
api_key: '',
|
|
}
|
|
})
|
|
const fallbackGroupsWithAliases = applyModelAliases(fallbackGroups, modelAliases)
|
|
const visibleFallbackGroups = applyModelVisibility(fallbackGroupsWithAliases, modelVisibility)
|
|
const fallbackDefault = resolveVisibleDefault(fallback.default, currentDefaultProvider, visibleFallbackGroups)
|
|
ctx.body = {
|
|
default: fallbackDefault.defaultModel,
|
|
default_provider: fallbackDefault.defaultProvider,
|
|
groups: visibleFallbackGroups,
|
|
allProviders,
|
|
model_aliases: modelAliases,
|
|
model_visibility: modelVisibility,
|
|
custom_models: customModels,
|
|
}
|
|
return
|
|
}
|
|
|
|
ctx.body = {
|
|
default: visibleDefault.defaultModel,
|
|
default_provider: visibleDefault.defaultProvider,
|
|
groups: visibleGroups,
|
|
allProviders,
|
|
model_aliases: modelAliases,
|
|
model_visibility: modelVisibility,
|
|
custom_models: customModels,
|
|
}
|
|
} catch (err: any) {
|
|
ctx.status = 500
|
|
ctx.body = { error: err.message }
|
|
}
|
|
}
|
|
|
|
export async function addCustomModel(ctx: any) {
|
|
const { provider, model } = (ctx.request.body || {}) as { provider?: string; model?: string }
|
|
const providerKey = String(provider || '').trim()
|
|
const modelId = String(model || '').trim()
|
|
if (!providerKey || !modelId) {
|
|
ctx.status = 400
|
|
ctx.body = { error: 'Missing provider or model' }
|
|
return
|
|
}
|
|
|
|
try {
|
|
const appConfig = await readAppConfig()
|
|
const customModels = normalizeCustomModels(appConfig.customModels)
|
|
customModels[providerKey] = Array.from(new Set([...(customModels[providerKey] || []), modelId]))
|
|
const saved = await writeAppConfig({ customModels })
|
|
ctx.body = { success: true, custom_models: normalizeCustomModels(saved.customModels) }
|
|
} catch (err: any) {
|
|
ctx.status = 500
|
|
ctx.body = { error: err.message }
|
|
}
|
|
}
|
|
|
|
export async function removeCustomModel(ctx: any) {
|
|
const body = (ctx.request.body || {}) as { provider?: string; model?: string }
|
|
const provider = body.provider ?? ctx.query?.provider
|
|
const model = body.model ?? ctx.query?.model
|
|
const providerKey = String(provider || '').trim()
|
|
const modelId = String(model || '').trim()
|
|
if (!providerKey || !modelId) {
|
|
ctx.status = 400
|
|
ctx.body = { error: 'Missing provider or model' }
|
|
return
|
|
}
|
|
|
|
try {
|
|
const appConfig = await readAppConfig()
|
|
const customModels = normalizeCustomModels(appConfig.customModels)
|
|
const remaining = (customModels[providerKey] || []).filter(item => item !== modelId)
|
|
if (remaining.length > 0) customModels[providerKey] = remaining
|
|
else delete customModels[providerKey]
|
|
const saved = await writeAppConfig({ customModels })
|
|
ctx.body = { success: true, custom_models: normalizeCustomModels(saved.customModels) }
|
|
} catch (err: any) {
|
|
ctx.status = 500
|
|
ctx.body = { error: err.message }
|
|
}
|
|
}
|
|
|
|
export async function fetchProviderModelList(ctx: any) {
|
|
try {
|
|
const body = ctx.request.body as { base_url?: string; api_key?: string; freeOnly?: boolean }
|
|
const baseUrl = String(body?.base_url || '').trim()
|
|
const apiKey = String(body?.api_key || '').trim()
|
|
const freeOnly = body?.freeOnly === true
|
|
|
|
if (!baseUrl) {
|
|
ctx.status = 400
|
|
ctx.body = { error: 'Missing base_url' }
|
|
return
|
|
}
|
|
|
|
let parsed: URL
|
|
try {
|
|
parsed = new URL(baseUrl)
|
|
} catch {
|
|
ctx.status = 400
|
|
ctx.body = { error: 'Invalid base_url' }
|
|
return
|
|
}
|
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
ctx.status = 400
|
|
ctx.body = { error: 'base_url must use http or https' }
|
|
return
|
|
}
|
|
|
|
const base = baseUrl.replace(/\/+$/, '')
|
|
const modelsUrl = /\/v\d+\/?$/.test(base) ? `${base}/models` : `${base}/v1/models`
|
|
const headers: Record<string, string> = {}
|
|
if (apiKey) headers.Authorization = `Bearer ${apiKey}`
|
|
|
|
const res = await fetch(modelsUrl, {
|
|
headers,
|
|
signal: AbortSignal.timeout(8000),
|
|
})
|
|
if (!res.ok) {
|
|
ctx.status = 502
|
|
ctx.body = { error: `Provider returned HTTP ${res.status}` }
|
|
return
|
|
}
|
|
|
|
const data = await res.json() as { data?: Array<{ id?: unknown }> }
|
|
if (!Array.isArray(data.data)) {
|
|
ctx.status = 502
|
|
ctx.body = { error: 'Provider returned unexpected format' }
|
|
return
|
|
}
|
|
|
|
let models = data.data
|
|
.map(m => String(m?.id || '').trim())
|
|
.filter(Boolean)
|
|
if (freeOnly) models = models.filter(m => m.endsWith(':free'))
|
|
ctx.body = { models: Array.from(new Set(models)).sort() }
|
|
} catch (err: any) {
|
|
ctx.status = err?.name === 'TimeoutError' ? 504 : 502
|
|
ctx.body = { error: err?.message || 'Failed to fetch provider models' }
|
|
}
|
|
}
|
|
|
|
|
|
export async function setModelAlias(ctx: any) {
|
|
const body = ctx.request.body
|
|
const provider = body && typeof body === 'object' && !Array.isArray(body) ? body.provider : undefined
|
|
const model = body && typeof body === 'object' && !Array.isArray(body) ? body.model : undefined
|
|
const alias = body && typeof body === 'object' && !Array.isArray(body) ? body.alias : undefined
|
|
|
|
if (typeof provider !== 'string' || typeof model !== 'string' || (alias !== undefined && typeof alias !== 'string')) {
|
|
ctx.status = 400
|
|
ctx.body = { error: 'Invalid provider, model, or alias' }
|
|
return
|
|
}
|
|
|
|
const cleanProvider = provider.trim()
|
|
const cleanModel = model.trim()
|
|
const cleanAlias = (alias || '').trim()
|
|
|
|
if (!isSafeAliasKey(cleanProvider) || !isSafeAliasKey(cleanModel)) {
|
|
ctx.status = 400
|
|
ctx.body = { error: 'Invalid provider or model' }
|
|
return
|
|
}
|
|
|
|
if (cleanAlias.length > 512) {
|
|
ctx.status = 400
|
|
ctx.body = { error: 'Alias is too long' }
|
|
return
|
|
}
|
|
|
|
try {
|
|
const appConfig = await readAppConfig()
|
|
const modelAliases = normalizeAliases(appConfig.modelAliases)
|
|
if (cleanAlias) {
|
|
if (!Object.hasOwn(modelAliases, cleanProvider)) modelAliases[cleanProvider] = createAliasMap()
|
|
modelAliases[cleanProvider][cleanModel] = cleanAlias
|
|
} else {
|
|
if (Object.hasOwn(modelAliases, cleanProvider)) delete modelAliases[cleanProvider][cleanModel]
|
|
if (Object.hasOwn(modelAliases, cleanProvider) && Object.keys(modelAliases[cleanProvider]).length === 0) {
|
|
delete modelAliases[cleanProvider]
|
|
}
|
|
}
|
|
await writeAppConfig({ modelAliases })
|
|
ctx.body = { success: true, model_aliases: modelAliases }
|
|
} catch (err: any) {
|
|
ctx.status = 500
|
|
ctx.body = { error: err.message }
|
|
}
|
|
}
|
|
|
|
export async function getConfigModels(ctx: any) {
|
|
try {
|
|
const config = await readConfigYaml()
|
|
ctx.body = buildModelGroups(config)
|
|
} catch (err: any) {
|
|
ctx.status = 500
|
|
ctx.body = { error: err.message }
|
|
}
|
|
}
|
|
|
|
export async function setConfigModel(ctx: any) {
|
|
const { default: defaultModel, provider: reqProvider } = ctx.request.body as { default: string; provider?: string }
|
|
if (!defaultModel) {
|
|
ctx.status = 400
|
|
ctx.body = { error: 'Missing default model' }
|
|
return
|
|
}
|
|
try {
|
|
await updateConfigYaml((config) => {
|
|
config.model = {}
|
|
config.model.default = defaultModel
|
|
if (reqProvider) { config.model.provider = reqProvider }
|
|
return config
|
|
})
|
|
ctx.body = { success: true }
|
|
} catch (err: any) {
|
|
ctx.status = 500
|
|
ctx.body = { error: err.message }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 设置模型上下文配置(UPSERT:存在则更新,不存在则插入)
|
|
* 支持路径参数和查询参数两种方式
|
|
*/
|
|
export async function updateModelContext(ctx: any) {
|
|
// 支持两种方式:
|
|
// 1. 路径参数: /api/hermes/model-context/:provider/:model
|
|
// 2. 查询参数: /api/hermes/model-context?provider=xxx&model=xxx
|
|
let provider: string | undefined
|
|
let model: string | undefined
|
|
|
|
// 优先从路径参数获取
|
|
if (ctx.params.provider && ctx.params.model) {
|
|
provider = ctx.params.provider
|
|
model = ctx.params.model
|
|
} else {
|
|
// 从查询参数获取
|
|
const query = ctx.query as { provider?: string; model?: string }
|
|
provider = query.provider
|
|
model = query.model
|
|
}
|
|
|
|
// 如果没有参数,从请求体获取
|
|
if (!provider || !model) {
|
|
const body = ctx.request.body as { provider?: string; model?: string; context_limit?: number }
|
|
provider = body.provider
|
|
model = body.model
|
|
}
|
|
|
|
const { context_limit } = ctx.request.body as { context_limit: number }
|
|
|
|
if (!provider || !model || !context_limit) {
|
|
ctx.status = 400
|
|
ctx.body = { error: 'Missing required fields: provider, model, context_limit' }
|
|
return
|
|
}
|
|
|
|
if (typeof context_limit !== 'number' || context_limit <= 0) {
|
|
ctx.status = 400
|
|
ctx.body = { error: 'Context limit must be a positive number' }
|
|
return
|
|
}
|
|
|
|
try {
|
|
const db = getDb()
|
|
if (!db) {
|
|
ctx.status = 500
|
|
ctx.body = { error: 'Database not available' }
|
|
return
|
|
}
|
|
|
|
// 使用 REPLACE 实现 UPSERT:存在则替换,不存在则插入
|
|
db.prepare(
|
|
`REPLACE INTO ${MODEL_CONTEXT_TABLE} (provider, model, context_limit) VALUES (?, ?, ?)`
|
|
).run(provider, model, context_limit)
|
|
|
|
// 查询并返回更新后的数据
|
|
const row = db.prepare(
|
|
`SELECT id, provider, model, context_limit FROM ${MODEL_CONTEXT_TABLE} WHERE provider = ? AND model = ?`
|
|
).get(provider, model) as { id: number; provider: string; model: string; context_limit: number }
|
|
|
|
ctx.body = {
|
|
success: true,
|
|
data: row
|
|
}
|
|
} catch (err: any) {
|
|
ctx.status = 500
|
|
ctx.body = { error: err.message }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 查询模型上下文配置
|
|
*/
|
|
export async function getModelContext(ctx: any) {
|
|
// 支持两种方式:
|
|
// 1. 路径参数: /api/hermes/model-context/:provider/:model
|
|
// 2. 查询参数: /api/hermes/model-context?provider=xxx&model=xxx
|
|
let provider: string | undefined
|
|
let model: string | undefined
|
|
|
|
// 优先从路径参数获取
|
|
if (ctx.params.provider && ctx.params.model) {
|
|
provider = ctx.params.provider
|
|
model = ctx.params.model
|
|
} else {
|
|
// 从查询参数获取
|
|
const query = ctx.query as { provider?: string; model?: string }
|
|
provider = query.provider
|
|
model = query.model
|
|
}
|
|
|
|
if (!provider || !model) {
|
|
ctx.status = 400
|
|
ctx.body = { error: 'Missing provider or model parameter' }
|
|
return
|
|
}
|
|
|
|
try {
|
|
const db = getDb()
|
|
if (!db) {
|
|
ctx.status = 500
|
|
ctx.body = { error: 'Database not available' }
|
|
return
|
|
}
|
|
|
|
const row = db.prepare(
|
|
`SELECT id, provider, model, context_limit FROM ${MODEL_CONTEXT_TABLE} WHERE provider = ? AND model = ?`
|
|
).get(provider, model) as { id: number; provider: string; model: string; context_limit: number } | undefined
|
|
|
|
if (!row) {
|
|
ctx.status = 404
|
|
ctx.body = { error: 'Model context not found' }
|
|
return
|
|
}
|
|
|
|
ctx.body = { data: { ...row, limit: row.context_limit } }
|
|
} catch (err: any) {
|
|
ctx.status = 500
|
|
ctx.body = { error: err.message }
|
|
}
|
|
}
|
|
|
|
|
|
export async function setModelVisibility(ctx: any) {
|
|
const { provider, mode, models } = ctx.request.body as { provider?: string; mode?: string; models?: string[] }
|
|
const providerKey = String(provider || '').trim()
|
|
if (!providerKey) {
|
|
ctx.status = 400
|
|
ctx.body = { error: 'Missing provider' }
|
|
return
|
|
}
|
|
if (mode !== 'all' && mode !== 'include') {
|
|
ctx.status = 400
|
|
ctx.body = { error: 'Invalid visibility mode' }
|
|
return
|
|
}
|
|
const selectedModels = uniqueStrings(models)
|
|
if (mode === 'include' && selectedModels.length === 0) {
|
|
ctx.status = 400
|
|
ctx.body = { error: 'Select at least one model' }
|
|
return
|
|
}
|
|
|
|
try {
|
|
const appConfig = await readAppConfig()
|
|
const modelVisibility = normalizeModelVisibility(appConfig.modelVisibility)
|
|
if (mode === 'all') {
|
|
delete modelVisibility[providerKey]
|
|
} else {
|
|
modelVisibility[providerKey] = { mode: 'include', models: selectedModels }
|
|
}
|
|
const saved = await writeAppConfig({ modelVisibility })
|
|
ctx.body = { success: true, model_visibility: normalizeModelVisibility(saved.modelVisibility) }
|
|
} catch (err: any) {
|
|
ctx.status = 500
|
|
ctx.body = { error: err.message }
|
|
}
|
|
}
|