chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting point for the rebuild, then trim the surfaces this project does not ship: Electron desktop packaging, the marketing website, and the ESP32 firmware tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows. Rename the package and CLI entry points to hermes-ui, drop the prepare hook so npm ci no longer triggers a build, and drop the removed tsconfig and nodemon references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs so the documented package map and hard rules match this repository. reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
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 } = ctx.request.body as {
|
||||
name?: string; base_url?: string; api_key?: string; model?: string; api_mode?: ProviderApiMode
|
||||
}
|
||||
const customApiMode = normalizeApiMode(api_mode)
|
||||
try {
|
||||
const profile = requestedProfile(ctx)
|
||||
const isCustom = poolKey.startsWith('custom:')
|
||||
if (isCustom) {
|
||||
const found = await updateConfigYamlForProfile(profile, (config) => {
|
||||
if (!Array.isArray(config.custom_providers)) return { data: config, result: false, write: false }
|
||||
const entry = (config.custom_providers as any[]).find((e: any) => {
|
||||
return `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` === poolKey
|
||||
})
|
||||
if (!entry) return { data: config, result: false, write: false }
|
||||
if (name !== undefined) entry.name = name
|
||||
if (base_url !== undefined) entry.base_url = base_url
|
||||
if (api_key !== undefined) 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?.api_key_env) {
|
||||
ctx.status = 400; ctx.body = { error: `Cannot update credentials for "${poolKey}"` }; return
|
||||
}
|
||||
if (api_key !== undefined) { await saveEnvValueForProfile(profile, envMapping.api_key_env, api_key) }
|
||||
}
|
||||
// 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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user