Fix bridge history, profile models, and Windows gateway handling (#845)

* feat: support profile-aware group chat bridge flows

* feat: route cron jobs through hermes cli

* Fix group chat routing and isolate bridge tests

* Add Grok image-to-video media skill

* Default Grok videos to media directory

* Fix bridge profile fallback and cron repeat clearing

* Refine bridge chat and gateway platform handling

* Filter bridge tool-call text deltas

* Preserve structured bridge chat history

* Prepare beta release build artifacts

* Fix Windows run profile resolution

* Fix Windows path compatibility checks

* Fix profile-scoped model page display

* Hide Windows subprocess windows for jobs and updates

* Hide Windows file backend subprocess windows

* Avoid Windows gateway restart lock conflicts

* Treat Windows gateway lock as running on startup

* Force release Windows gateway lock on restart

* Tighten Windows gateway lock cleanup

* Update chat e2e source expectation

* Bump package version to 0.5.30

---------

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
ekko
2026-05-19 16:09:59 +08:00
committed by GitHub
co-authored by Codex
parent 3d74d78698
commit 9a9416c99c
129 changed files with 7016 additions and 1837 deletions
@@ -1,7 +1,8 @@
import { readFile } from 'fs/promises'
import { existsSync, readFileSync } from 'fs'
import { getActiveEnvPath, getActiveAuthPath } from '../../services/hermes/hermes-profile'
import { readConfigYaml, updateConfigYaml, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers'
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'
@@ -118,6 +119,73 @@ function resolveVisibleDefault(defaultModel: string, defaultProvider: string, gr
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 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 行为一致,避免出现"模型能拉到却被判未授权"。
@@ -125,8 +193,244 @@ 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))
}
return { profile, default: currentDefault, default_provider: currentDefaultProvider, groups }
}
export async function getAvailable(ctx: any) {
try {
const requestedProfile = typeof ctx.query.profile === 'string' && ctx.query.profile.trim()
? ctx.query.profile.trim()
: ''
if (!requestedProfile) {
const appConfig = await readAppConfig()
const modelAliases = normalizeAliases(appConfig.modelAliases)
const modelVisibility = normalizeModelVisibility(appConfig.modelVisibility)
const fetchCache: ProviderFetchCache = new Map()
const profileResults = await Promise.all(
listProfileNamesFromDisk().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,
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 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,
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 = ''
@@ -239,16 +543,16 @@ export async function getAvailable(ctx: any) {
const live = await getCopilotLive()
if (live.length > 0) {
modelsList = live.map((m) => m.id)
modelMeta = {}
const nextModelMeta: Record<string, ModelMeta> = {}
for (const m of live) {
if (m.preview || m.disabled) {
modelMeta[m.id] = {
nextModelMeta[m.id] = {
...(m.preview ? { preview: true } : {}),
...(m.disabled ? { disabled: true } : {}),
}
}
}
if (Object.keys(modelMeta).length === 0) modelMeta = undefined
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.
@@ -286,8 +590,9 @@ export async function getAvailable(ctx: any) {
)
for (const result of customFetches) {
if (result.status === 'fulfilled' && result.value) {
const { providerKey, label, base_url, models, api_key: cpApiKey, builtin: cpBuiltin } = result.value as any
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)
}
}