2026-04-12 23:23:50 +08:00
|
|
|
import Router from '@koa/router'
|
|
|
|
|
import { readdir, readFile, stat, writeFile, mkdir, copyFile } from 'fs/promises'
|
|
|
|
|
import { join, resolve } from 'path'
|
2026-04-14 10:22:29 +08:00
|
|
|
import YAML from 'js-yaml'
|
2026-04-16 13:51:42 +08:00
|
|
|
import { getActiveProfileDir, getActiveConfigPath, getActiveAuthPath, getActiveEnvPath } from '../../services/hermes-profile'
|
|
|
|
|
import * as hermesCli from '../../services/hermes-cli'
|
|
|
|
|
|
|
|
|
|
// --- Provider env var mapping (from hermes providers.py HERMES_OVERLAYS + config.py) ---
|
|
|
|
|
// Maps provider key → { api_key_envs: all env var aliases for API key, base_url_env: env var for base URL }
|
|
|
|
|
const PROVIDER_ENV_MAP: Record<string, { api_key_env: string; base_url_env: string }> = {
|
|
|
|
|
openrouter: { api_key_env: 'OPENROUTER_API_KEY', base_url_env: 'OPENROUTER_BASE_URL' },
|
|
|
|
|
zai: { api_key_env: 'ZAI_API_KEY', base_url_env: '' },
|
|
|
|
|
'kimi-for-coding': { api_key_env: 'KIMI_API_KEY', base_url_env: '' },
|
|
|
|
|
minimax: { api_key_env: 'MINIMAX_API_KEY', base_url_env: 'MINIMAX_BASE_URL' },
|
|
|
|
|
'minimax-cn': { api_key_env: 'MINIMAX_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' },
|
|
|
|
|
anthropic: { api_key_env: 'ANTHROPIC_API_KEY', base_url_env: '' },
|
|
|
|
|
xai: { api_key_env: 'XAI_API_KEY', base_url_env: 'XAI_BASE_URL' },
|
|
|
|
|
xiaomi: { api_key_env: 'XIAOMI_API_KEY', base_url_env: 'XIAOMI_BASE_URL' },
|
|
|
|
|
gemini: { api_key_env: 'GEMINI_API_KEY', base_url_env: '' },
|
|
|
|
|
kilo: { api_key_env: 'KILO_API_KEY', base_url_env: 'KILOCODE_BASE_URL' },
|
|
|
|
|
vercel: { api_key_env: 'AI_GATEWAY_API_KEY', base_url_env: '' },
|
|
|
|
|
opencode: { api_key_env: 'OPENCODE_API_KEY', base_url_env: 'OPENCODE_ZEN_BASE_URL' },
|
|
|
|
|
'opencode-go': { api_key_env: 'OPENCODE_API_KEY', base_url_env: 'OPENCODE_GO_BASE_URL' },
|
|
|
|
|
huggingface: { api_key_env: 'HF_TOKEN', base_url_env: 'HF_BASE_URL' },
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveEnvValue(key: string, value: string): Promise<void> {
|
|
|
|
|
const envPath = getActiveEnvPath()
|
|
|
|
|
let raw: string
|
|
|
|
|
try {
|
|
|
|
|
raw = await readFile(envPath, 'utf-8')
|
|
|
|
|
} catch {
|
|
|
|
|
raw = ''
|
|
|
|
|
}
|
|
|
|
|
const remove = !value
|
|
|
|
|
const lines = raw.split('\n')
|
|
|
|
|
let found = false
|
|
|
|
|
const result: string[] = []
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
const trimmed = line.trim()
|
|
|
|
|
if (trimmed.startsWith('#') && trimmed.startsWith(`# ${key}=`)) {
|
|
|
|
|
if (!remove) result.push(`${key}=${value}`)
|
|
|
|
|
found = true
|
|
|
|
|
} else {
|
|
|
|
|
const eqIdx = trimmed.indexOf('=')
|
|
|
|
|
if (eqIdx !== -1 && trimmed.slice(0, eqIdx).trim() === key) {
|
|
|
|
|
if (!remove) result.push(`${key}=${value}`)
|
|
|
|
|
found = true
|
|
|
|
|
} else {
|
|
|
|
|
result.push(line)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!found && !remove) {
|
|
|
|
|
result.push(`${key}=${value}`)
|
|
|
|
|
}
|
|
|
|
|
let output = result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n'
|
|
|
|
|
await writeFile(envPath, output, 'utf-8')
|
|
|
|
|
}
|
2026-04-12 23:23:50 +08:00
|
|
|
|
|
|
|
|
// --- Auth / Credential Pool ---
|
|
|
|
|
|
|
|
|
|
interface CredentialPoolEntry {
|
|
|
|
|
id: string
|
|
|
|
|
label: string
|
|
|
|
|
base_url: string
|
|
|
|
|
access_token: string
|
|
|
|
|
last_status?: string | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface AuthJson {
|
|
|
|
|
credential_pool?: Record<string, CredentialPoolEntry[]>
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 13:51:42 +08:00
|
|
|
const authPath = () => getActiveAuthPath()
|
2026-04-12 23:23:50 +08:00
|
|
|
|
|
|
|
|
async function loadAuthJson(): Promise<AuthJson | null> {
|
|
|
|
|
try {
|
2026-04-16 13:51:42 +08:00
|
|
|
const raw = await readFile(authPath(), 'utf-8')
|
2026-04-12 23:23:50 +08:00
|
|
|
return JSON.parse(raw) as AuthJson
|
|
|
|
|
} catch {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 10:22:29 +08:00
|
|
|
async function saveAuthJson(auth: AuthJson): Promise<void> {
|
2026-04-16 13:51:42 +08:00
|
|
|
await writeFile(authPath(), JSON.stringify(auth, null, 2) + '\n', 'utf-8')
|
2026-04-14 10:22:29 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-12 23:23:50 +08:00
|
|
|
async function fetchProviderModels(baseUrl: string, apiKey: string): Promise<string[]> {
|
|
|
|
|
try {
|
|
|
|
|
const url = baseUrl.replace(/\/+$/, '') + '/models'
|
|
|
|
|
const res = await fetch(url, {
|
|
|
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
|
|
|
signal: AbortSignal.timeout(8000),
|
|
|
|
|
})
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
console.error(`[available-models] ${baseUrl} returned ${res.status}`)
|
|
|
|
|
return []
|
|
|
|
|
}
|
|
|
|
|
const data = await res.json() as { data?: Array<{ id: string }> }
|
|
|
|
|
if (!Array.isArray(data.data)) {
|
|
|
|
|
console.error(`[available-models] ${baseUrl} returned unexpected format`)
|
|
|
|
|
return []
|
|
|
|
|
}
|
|
|
|
|
return data.data.map(m => m.id).sort()
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
console.error(`[available-models] ${baseUrl} failed: ${err.message}`)
|
|
|
|
|
return []
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 12:15:16 +08:00
|
|
|
// --- Hardcoded model catalogs (single source: src/shared/providers.ts) ---
|
2026-04-16 08:38:18 +08:00
|
|
|
import { buildProviderModelMap } from '../../shared/providers'
|
2026-04-13 12:15:16 +08:00
|
|
|
const PROVIDER_MODEL_CATALOG = buildProviderModelMap()
|
|
|
|
|
|
2026-04-12 23:23:50 +08:00
|
|
|
export const fsRoutes = new Router()
|
|
|
|
|
|
2026-04-16 13:51:42 +08:00
|
|
|
const hermesDir = () => getActiveProfileDir()
|
2026-04-12 23:23:50 +08:00
|
|
|
|
|
|
|
|
// --- Types ---
|
|
|
|
|
|
|
|
|
|
interface SkillInfo {
|
|
|
|
|
name: string
|
|
|
|
|
description: string
|
2026-04-14 21:48:53 +08:00
|
|
|
enabled: boolean
|
2026-04-12 23:23:50 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SkillCategory {
|
|
|
|
|
name: string
|
|
|
|
|
description: string
|
|
|
|
|
skills: SkillInfo[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Helpers ---
|
|
|
|
|
|
|
|
|
|
function extractDescription(content: string): string {
|
|
|
|
|
const lines = content.split('\n')
|
|
|
|
|
let inFrontmatter = false
|
|
|
|
|
let bodyStarted = false
|
|
|
|
|
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
if (!bodyStarted && line.trim() === '---') {
|
|
|
|
|
if (!inFrontmatter) {
|
|
|
|
|
inFrontmatter = true
|
|
|
|
|
continue
|
|
|
|
|
} else {
|
|
|
|
|
inFrontmatter = false
|
|
|
|
|
bodyStarted = true
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (inFrontmatter) continue
|
|
|
|
|
if (line.trim() === '') continue
|
|
|
|
|
if (line.startsWith('#')) continue
|
|
|
|
|
return line.trim().slice(0, 80)
|
|
|
|
|
}
|
|
|
|
|
return ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function safeReadFile(filePath: string): Promise<string | null> {
|
|
|
|
|
try {
|
|
|
|
|
return await readFile(filePath, 'utf-8')
|
|
|
|
|
} catch {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function safeStat(filePath: string): Promise<{ mtime: number } | null> {
|
|
|
|
|
try {
|
|
|
|
|
const s = await stat(filePath)
|
|
|
|
|
return { mtime: Math.round(s.mtimeMs) }
|
|
|
|
|
} catch {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 10:22:29 +08:00
|
|
|
// --- Config YAML helpers ---
|
|
|
|
|
|
2026-04-16 13:51:42 +08:00
|
|
|
const configPath = () => getActiveConfigPath()
|
2026-04-14 10:22:29 +08:00
|
|
|
|
|
|
|
|
async function readConfigYaml(): Promise<Record<string, any>> {
|
2026-04-16 13:51:42 +08:00
|
|
|
const raw = await safeReadFile(configPath())
|
2026-04-14 10:22:29 +08:00
|
|
|
if (!raw) return {}
|
|
|
|
|
return (YAML.load(raw) as Record<string, any>) || {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function writeConfigYaml(config: Record<string, any>): Promise<void> {
|
2026-04-16 13:51:42 +08:00
|
|
|
const cp = configPath()
|
|
|
|
|
await copyFile(cp, cp + '.bak')
|
2026-04-14 10:22:29 +08:00
|
|
|
const yamlStr = YAML.dump(config, {
|
|
|
|
|
lineWidth: -1,
|
|
|
|
|
noRefs: true,
|
|
|
|
|
quotingType: '"',
|
|
|
|
|
})
|
2026-04-16 13:51:42 +08:00
|
|
|
await writeFile(cp, yamlStr, 'utf-8')
|
2026-04-14 10:22:29 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-12 23:23:50 +08:00
|
|
|
// --- Skills Routes ---
|
|
|
|
|
|
|
|
|
|
// List all skills grouped by category
|
2026-04-16 08:38:18 +08:00
|
|
|
fsRoutes.get('/api/hermes/skills', async (ctx) => {
|
2026-04-16 13:51:42 +08:00
|
|
|
const skillsDir = join(hermesDir(), 'skills')
|
2026-04-12 23:23:50 +08:00
|
|
|
|
|
|
|
|
try {
|
2026-04-14 21:48:53 +08:00
|
|
|
// Read disabled skills list from config.yaml
|
|
|
|
|
const config = await readConfigYaml()
|
|
|
|
|
const disabledList: string[] = config.skills?.disabled || []
|
|
|
|
|
|
2026-04-12 23:23:50 +08:00
|
|
|
const entries = await readdir(skillsDir, { withFileTypes: true })
|
|
|
|
|
const categories: SkillCategory[] = []
|
|
|
|
|
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
|
|
|
|
|
|
|
|
|
const catDir = join(skillsDir, entry.name)
|
|
|
|
|
const catDesc = await safeReadFile(join(catDir, 'DESCRIPTION.md'))
|
|
|
|
|
const catDescription = catDesc ? catDesc.trim().split('\n')[0].replace(/^#+\s*/, '').slice(0, 100) : ''
|
|
|
|
|
|
|
|
|
|
const skillEntries = await readdir(catDir, { withFileTypes: true })
|
|
|
|
|
const skills: SkillInfo[] = []
|
|
|
|
|
|
|
|
|
|
for (const se of skillEntries) {
|
|
|
|
|
if (!se.isDirectory()) continue
|
|
|
|
|
const skillMd = await safeReadFile(join(catDir, se.name, 'SKILL.md'))
|
|
|
|
|
if (skillMd) {
|
|
|
|
|
skills.push({
|
|
|
|
|
name: se.name,
|
|
|
|
|
description: extractDescription(skillMd),
|
2026-04-14 21:48:53 +08:00
|
|
|
enabled: !disabledList.includes(se.name),
|
2026-04-12 23:23:50 +08:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (skills.length > 0) {
|
|
|
|
|
categories.push({ name: entry.name, description: catDescription, skills })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
categories.sort((a, b) => a.name.localeCompare(b.name))
|
|
|
|
|
for (const cat of categories) {
|
|
|
|
|
cat.skills.sort((a, b) => a.name.localeCompare(b.name))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctx.body = { categories }
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
ctx.status = 500
|
|
|
|
|
ctx.body = { error: `Failed to read skills directory: ${err.message}` }
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-14 21:48:53 +08:00
|
|
|
// Toggle skill enabled/disabled via config.yaml skills.disabled
|
2026-04-16 08:38:18 +08:00
|
|
|
fsRoutes.put('/api/hermes/skills/toggle', async (ctx) => {
|
2026-04-14 21:48:53 +08:00
|
|
|
const { name, enabled } = ctx.request.body as { name?: string; enabled?: boolean }
|
|
|
|
|
|
|
|
|
|
if (!name || typeof enabled !== 'boolean') {
|
|
|
|
|
ctx.status = 400
|
|
|
|
|
ctx.body = { error: 'Missing name or enabled flag' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const config = await readConfigYaml()
|
|
|
|
|
if (!config.skills) config.skills = {}
|
|
|
|
|
if (!Array.isArray(config.skills.disabled)) config.skills.disabled = []
|
|
|
|
|
|
|
|
|
|
const disabled = config.skills.disabled as string[]
|
|
|
|
|
const idx = disabled.indexOf(name)
|
|
|
|
|
|
|
|
|
|
if (enabled) {
|
|
|
|
|
// Enable: remove from disabled list
|
|
|
|
|
if (idx !== -1) disabled.splice(idx, 1)
|
|
|
|
|
} else {
|
|
|
|
|
// Disable: add to disabled list
|
|
|
|
|
if (idx === -1) disabled.push(name)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await writeConfigYaml(config)
|
|
|
|
|
ctx.body = { success: true }
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
ctx.status = 500
|
|
|
|
|
ctx.body = { error: err.message }
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-14 10:22:29 +08:00
|
|
|
// List files in a skill directory
|
2026-04-12 23:23:50 +08:00
|
|
|
async function listFilesRecursive(dir: string, prefix: string): Promise<{ path: string; name: string }[]> {
|
|
|
|
|
const result: { path: string; name: string }[] = []
|
|
|
|
|
let entries
|
|
|
|
|
try {
|
|
|
|
|
entries = await readdir(dir, { withFileTypes: true })
|
|
|
|
|
} catch {
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name
|
|
|
|
|
if (entry.isDirectory()) {
|
|
|
|
|
result.push(...await listFilesRecursive(join(dir, entry.name), relPath))
|
|
|
|
|
} else {
|
|
|
|
|
result.push({ path: relPath, name: entry.name })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 08:38:18 +08:00
|
|
|
fsRoutes.get('/api/hermes/skills/:category/:skill/files', async (ctx) => {
|
2026-04-12 23:23:50 +08:00
|
|
|
const { category, skill } = ctx.params
|
2026-04-16 13:51:42 +08:00
|
|
|
const skillDir = join(hermesDir(), 'skills', category, skill)
|
2026-04-12 23:23:50 +08:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const allFiles = await listFilesRecursive(skillDir, '')
|
|
|
|
|
const files = allFiles.filter(f => f.path !== 'SKILL.md')
|
|
|
|
|
ctx.body = { files }
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
ctx.status = 500
|
|
|
|
|
ctx.body = { error: err.message }
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-16 08:38:18 +08:00
|
|
|
// Read a specific file under skills/ (must be registered after the /files route)
|
|
|
|
|
fsRoutes.get('/api/hermes/skills/{*path}', async (ctx) => {
|
|
|
|
|
const filePath = (ctx.params as any).path
|
2026-04-16 13:51:42 +08:00
|
|
|
const hd = hermesDir()
|
|
|
|
|
const fullPath = resolve(join(hd, 'skills', filePath))
|
2026-04-12 23:23:50 +08:00
|
|
|
|
2026-04-16 13:51:42 +08:00
|
|
|
if (!fullPath.startsWith(join(hd, 'skills'))) {
|
2026-04-12 23:23:50 +08:00
|
|
|
ctx.status = 403
|
|
|
|
|
ctx.body = { error: 'Access denied' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const content = await safeReadFile(fullPath)
|
|
|
|
|
if (content === null) {
|
|
|
|
|
ctx.status = 404
|
|
|
|
|
ctx.body = { error: 'File not found' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctx.body = { content }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// --- Memory Routes ---
|
|
|
|
|
|
2026-04-16 08:38:18 +08:00
|
|
|
fsRoutes.get('/api/hermes/memory', async (ctx) => {
|
2026-04-16 13:51:42 +08:00
|
|
|
const hd = hermesDir()
|
|
|
|
|
const memoryPath = join(hd, 'memories', 'MEMORY.md')
|
|
|
|
|
const userPath = join(hd, 'memories', 'USER.md')
|
|
|
|
|
const soulPath = join(hd, 'SOUL.md')
|
2026-04-12 23:23:50 +08:00
|
|
|
|
2026-04-16 13:51:42 +08:00
|
|
|
const [memory, user, soul, memoryStat, userStat, soulStat] = await Promise.all([
|
2026-04-12 23:23:50 +08:00
|
|
|
safeReadFile(memoryPath),
|
|
|
|
|
safeReadFile(userPath),
|
2026-04-16 13:51:42 +08:00
|
|
|
safeReadFile(soulPath),
|
2026-04-12 23:23:50 +08:00
|
|
|
safeStat(memoryPath),
|
|
|
|
|
safeStat(userPath),
|
2026-04-16 13:51:42 +08:00
|
|
|
safeStat(soulPath),
|
2026-04-12 23:23:50 +08:00
|
|
|
])
|
|
|
|
|
|
|
|
|
|
ctx.body = {
|
|
|
|
|
memory: memory || '',
|
|
|
|
|
user: user || '',
|
2026-04-16 13:51:42 +08:00
|
|
|
soul: soul || '',
|
2026-04-12 23:23:50 +08:00
|
|
|
memory_mtime: memoryStat?.mtime || null,
|
|
|
|
|
user_mtime: userStat?.mtime || null,
|
2026-04-16 13:51:42 +08:00
|
|
|
soul_mtime: soulStat?.mtime || null,
|
2026-04-12 23:23:50 +08:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-16 08:38:18 +08:00
|
|
|
fsRoutes.post('/api/hermes/memory', async (ctx) => {
|
2026-04-12 23:23:50 +08:00
|
|
|
const { section, content } = ctx.request.body as { section: string; content: string }
|
|
|
|
|
|
|
|
|
|
if (!section || !content) {
|
|
|
|
|
ctx.status = 400
|
|
|
|
|
ctx.body = { error: 'Missing section or content' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 13:51:42 +08:00
|
|
|
if (section !== 'memory' && section !== 'user' && section !== 'soul') {
|
2026-04-12 23:23:50 +08:00
|
|
|
ctx.status = 400
|
2026-04-16 13:51:42 +08:00
|
|
|
ctx.body = { error: 'Section must be "memory", "user", or "soul"' }
|
2026-04-12 23:23:50 +08:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 13:51:42 +08:00
|
|
|
let filePath: string
|
|
|
|
|
if (section === 'soul') {
|
|
|
|
|
filePath = join(hermesDir(), 'SOUL.md')
|
|
|
|
|
} else {
|
|
|
|
|
const fileName = section === 'memory' ? 'MEMORY.md' : 'USER.md'
|
|
|
|
|
filePath = join(hermesDir(), 'memories', fileName)
|
|
|
|
|
}
|
2026-04-12 23:23:50 +08:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await writeFile(filePath, content, 'utf-8')
|
|
|
|
|
ctx.body = { success: true }
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
ctx.status = 500
|
|
|
|
|
ctx.body = { error: err.message }
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// --- Config Model Routes ---
|
|
|
|
|
|
|
|
|
|
interface ModelInfo {
|
|
|
|
|
id: string
|
|
|
|
|
label: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ModelGroup {
|
|
|
|
|
provider: string
|
|
|
|
|
models: ModelInfo[]
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 10:22:29 +08:00
|
|
|
// Build model list from user's actual config.yaml using js-yaml
|
|
|
|
|
function buildModelGroups(config: Record<string, any>): { default: string; groups: ModelGroup[] } {
|
2026-04-12 23:23:50 +08:00
|
|
|
let defaultModel = ''
|
|
|
|
|
let defaultProvider = ''
|
|
|
|
|
const groups: ModelGroup[] = []
|
|
|
|
|
const allModelIds = new Set<string>()
|
|
|
|
|
|
2026-04-14 10:22:29 +08:00
|
|
|
// 1. Extract current model
|
|
|
|
|
const modelSection = config.model
|
|
|
|
|
if (typeof modelSection === 'object' && modelSection !== null) {
|
|
|
|
|
defaultModel = String(modelSection.default || '').trim()
|
|
|
|
|
defaultProvider = String(modelSection.provider || '').trim()
|
|
|
|
|
} else if (typeof modelSection === 'string') {
|
|
|
|
|
defaultModel = modelSection.trim()
|
2026-04-12 23:23:50 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-14 10:22:29 +08:00
|
|
|
// 2. Extract custom_providers section
|
|
|
|
|
const customProviders = config.custom_providers
|
|
|
|
|
if (Array.isArray(customProviders)) {
|
|
|
|
|
const customModels: ModelInfo[] = []
|
|
|
|
|
for (const entry of customProviders) {
|
|
|
|
|
if (entry && typeof entry === 'object') {
|
|
|
|
|
const cName = String(entry.name || '').trim()
|
|
|
|
|
const cModel = String(entry.model || '').trim()
|
2026-04-12 23:23:50 +08:00
|
|
|
if (cName && cModel) {
|
|
|
|
|
customModels.push({ id: cModel, label: `${cName}: ${cModel}` })
|
|
|
|
|
allModelIds.add(cModel)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-14 10:22:29 +08:00
|
|
|
}
|
|
|
|
|
if (customModels.length > 0) {
|
|
|
|
|
groups.push({ provider: 'Custom', models: customModels })
|
2026-04-12 23:23:50 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 10:22:29 +08:00
|
|
|
// 3. Add current default model (if not already in custom_providers)
|
2026-04-12 23:23:50 +08:00
|
|
|
if (defaultModel && !allModelIds.has(defaultModel)) {
|
|
|
|
|
groups.unshift({ provider: 'Current', models: [{ id: defaultModel, label: defaultModel }] })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { default: defaultModel, groups }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GET /api/available-models — fetch models from all credential pool endpoints
|
2026-04-16 08:38:18 +08:00
|
|
|
fsRoutes.get('/api/hermes/available-models', async (ctx) => {
|
2026-04-12 23:23:50 +08:00
|
|
|
try {
|
|
|
|
|
const auth = await loadAuthJson()
|
|
|
|
|
const pool = auth?.credential_pool || {}
|
|
|
|
|
|
2026-04-14 10:22:29 +08:00
|
|
|
const config = await readConfigYaml()
|
|
|
|
|
const modelSection = config.model
|
|
|
|
|
let currentDefault = ''
|
|
|
|
|
if (typeof modelSection === 'object' && modelSection !== null) {
|
|
|
|
|
currentDefault = String(modelSection.default || '').trim()
|
|
|
|
|
} else if (typeof modelSection === 'string') {
|
|
|
|
|
currentDefault = modelSection.trim()
|
|
|
|
|
}
|
2026-04-12 23:23:50 +08:00
|
|
|
|
|
|
|
|
// Collect unique endpoints from credential pool
|
|
|
|
|
const endpoints: Array<{ key: string; label: string; base_url: string; token: string }> = []
|
|
|
|
|
const seenUrls = new Set<string>()
|
|
|
|
|
|
|
|
|
|
for (const [providerKey, entries] of Object.entries(pool)) {
|
|
|
|
|
if (!Array.isArray(entries) || entries.length === 0) continue
|
|
|
|
|
const entry = entries.find(e => e.last_status !== 'exhausted') || entries[0]
|
|
|
|
|
if (!entry?.base_url || !entry?.access_token) continue
|
|
|
|
|
const baseUrl = entry.base_url.replace(/\/+$/, '')
|
|
|
|
|
if (seenUrls.has(baseUrl)) continue
|
|
|
|
|
seenUrls.add(baseUrl)
|
|
|
|
|
endpoints.push({
|
|
|
|
|
key: providerKey,
|
|
|
|
|
label: providerKey.replace(/^custom:/, '') || entry.label || baseUrl,
|
|
|
|
|
base_url: baseUrl,
|
|
|
|
|
token: entry.access_token,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 12:15:16 +08:00
|
|
|
// Resolve models: hardcoded catalog first, live probe as fallback
|
2026-04-12 23:23:50 +08:00
|
|
|
const groups: Array<{ provider: string; label: string; base_url: string; models: string[] }> = []
|
2026-04-13 12:15:16 +08:00
|
|
|
const liveEndpoints: typeof endpoints = []
|
|
|
|
|
|
|
|
|
|
for (const ep of endpoints) {
|
|
|
|
|
const catalogModels = PROVIDER_MODEL_CATALOG[ep.key]
|
|
|
|
|
if (catalogModels && catalogModels.length > 0) {
|
|
|
|
|
groups.push({ provider: ep.key, label: ep.label, base_url: ep.base_url, models: catalogModels })
|
|
|
|
|
} else {
|
|
|
|
|
liveEndpoints.push(ep)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (liveEndpoints.length > 0) {
|
|
|
|
|
const results = await Promise.allSettled(
|
|
|
|
|
liveEndpoints.map(async ep => {
|
|
|
|
|
const models = await fetchProviderModels(ep.base_url, ep.token)
|
|
|
|
|
return { ...ep, models }
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
for (const result of results) {
|
|
|
|
|
if (result.status === 'fulfilled' && result.value.models.length > 0) {
|
|
|
|
|
const { key, label, base_url, models } = result.value
|
|
|
|
|
groups.push({ provider: key, label, base_url, models })
|
|
|
|
|
} else if (result.status === 'rejected') {
|
|
|
|
|
console.error(`[available-models] Failed: ${result.reason?.message || result.reason}`)
|
|
|
|
|
}
|
2026-04-12 23:23:50 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback: if no providers returned models, fall back to config.yaml parsing
|
|
|
|
|
if (groups.length === 0) {
|
2026-04-14 10:22:29 +08:00
|
|
|
const fallback = buildModelGroups(config)
|
2026-04-12 23:23:50 +08:00
|
|
|
ctx.body = fallback
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctx.body = { default: currentDefault, groups }
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
ctx.status = 500
|
|
|
|
|
ctx.body = { error: err.message }
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// GET /api/config/models
|
2026-04-16 08:38:18 +08:00
|
|
|
fsRoutes.get('/api/hermes/config/models', async (ctx) => {
|
2026-04-12 23:23:50 +08:00
|
|
|
try {
|
2026-04-14 10:22:29 +08:00
|
|
|
const config = await readConfigYaml()
|
|
|
|
|
ctx.body = buildModelGroups(config)
|
2026-04-12 23:23:50 +08:00
|
|
|
} catch (err: any) {
|
|
|
|
|
ctx.status = 500
|
|
|
|
|
ctx.body = { error: err.message }
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// PUT /api/config/model
|
2026-04-16 08:38:18 +08:00
|
|
|
fsRoutes.put('/api/hermes/config/model', async (ctx) => {
|
2026-04-12 23:23:50 +08:00
|
|
|
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 {
|
2026-04-14 10:22:29 +08:00
|
|
|
const config = await readConfigYaml()
|
|
|
|
|
|
|
|
|
|
if (typeof config.model !== 'object' || config.model === null) {
|
|
|
|
|
config.model = {}
|
|
|
|
|
}
|
2026-04-12 23:23:50 +08:00
|
|
|
|
2026-04-14 10:22:29 +08:00
|
|
|
config.model.default = defaultModel
|
|
|
|
|
if (reqProvider) {
|
|
|
|
|
config.model.provider = reqProvider
|
2026-04-12 23:23:50 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-14 10:22:29 +08:00
|
|
|
await writeConfigYaml(config)
|
2026-04-12 23:23:50 +08:00
|
|
|
ctx.body = { success: true }
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
ctx.status = 500
|
|
|
|
|
ctx.body = { error: err.message }
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// POST /api/config/providers
|
2026-04-16 08:38:18 +08:00
|
|
|
fsRoutes.post('/api/hermes/config/providers', async (ctx) => {
|
2026-04-13 12:15:16 +08:00
|
|
|
const { name, base_url, api_key, model, providerKey } = ctx.request.body as {
|
2026-04-12 23:23:50 +08:00
|
|
|
name: string
|
|
|
|
|
base_url: string
|
|
|
|
|
api_key: string
|
|
|
|
|
model: string
|
2026-04-13 12:15:16 +08:00
|
|
|
providerKey?: string | null
|
2026-04-12 23:23:50 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!name || !base_url || !model) {
|
|
|
|
|
ctx.status = 400
|
|
|
|
|
ctx.body = { error: 'Missing name, base_url, or model' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 12:15:16 +08:00
|
|
|
if (!api_key) {
|
|
|
|
|
ctx.status = 400
|
|
|
|
|
ctx.body = { error: 'Missing API key' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 23:23:50 +08:00
|
|
|
try {
|
2026-04-16 13:51:42 +08:00
|
|
|
// Determine if this is a built-in provider or a custom one
|
|
|
|
|
const poolKey = providerKey
|
|
|
|
|
|| `custom:${name.trim().toLowerCase().replace(/ /g, '-')}`
|
|
|
|
|
const isBuiltin = poolKey in PROVIDER_ENV_MAP
|
2026-04-12 23:23:50 +08:00
|
|
|
|
2026-04-16 13:51:42 +08:00
|
|
|
if (!isBuiltin) {
|
|
|
|
|
// Custom provider: write to config.yaml custom_providers
|
|
|
|
|
const config = await readConfigYaml()
|
|
|
|
|
if (!Array.isArray(config.custom_providers)) {
|
|
|
|
|
config.custom_providers = []
|
|
|
|
|
}
|
|
|
|
|
config.custom_providers.push({ name, base_url, api_key, model })
|
|
|
|
|
await writeConfigYaml(config)
|
2026-04-12 23:23:50 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-16 13:51:42 +08:00
|
|
|
// Write to auth.json credential_pool (all providers)
|
2026-04-13 12:15:16 +08:00
|
|
|
const auth = await loadAuthJson() || { credential_pool: {} }
|
|
|
|
|
if (!auth.credential_pool) auth.credential_pool = {}
|
|
|
|
|
if (!auth.credential_pool[poolKey]) {
|
|
|
|
|
auth.credential_pool[poolKey] = []
|
|
|
|
|
}
|
|
|
|
|
auth.credential_pool[poolKey].push({
|
|
|
|
|
id: `${poolKey}-${Date.now()}`,
|
|
|
|
|
label: name,
|
|
|
|
|
base_url,
|
|
|
|
|
access_token: api_key,
|
|
|
|
|
last_status: null,
|
|
|
|
|
})
|
2026-04-14 10:22:29 +08:00
|
|
|
await saveAuthJson(auth)
|
2026-04-13 12:15:16 +08:00
|
|
|
|
2026-04-16 13:51:42 +08:00
|
|
|
// Write API key to .env (built-in providers only)
|
|
|
|
|
const envMapping = PROVIDER_ENV_MAP[poolKey] || PROVIDER_ENV_MAP[providerKey || '']
|
|
|
|
|
if (envMapping) {
|
|
|
|
|
await saveEnvValue(envMapping.api_key_env, api_key)
|
|
|
|
|
if (envMapping.base_url_env) {
|
|
|
|
|
await saveEnvValue(envMapping.base_url_env, base_url)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Auto-switch model to the newly added provider
|
2026-04-14 10:22:29 +08:00
|
|
|
const config2 = await readConfigYaml()
|
|
|
|
|
if (typeof config2.model !== 'object' || config2.model === null) {
|
|
|
|
|
config2.model = {}
|
2026-04-13 12:15:16 +08:00
|
|
|
}
|
2026-04-14 10:22:29 +08:00
|
|
|
config2.model.default = model
|
|
|
|
|
config2.model.provider = poolKey
|
|
|
|
|
await writeConfigYaml(config2)
|
2026-04-13 12:15:16 +08:00
|
|
|
|
2026-04-16 13:51:42 +08:00
|
|
|
// Restart gateway to pick up .env and config.yaml changes
|
|
|
|
|
try {
|
|
|
|
|
await hermesCli.restartGateway()
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
console.error('[Provider] Gateway restart failed:', e.message)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 23:23:50 +08:00
|
|
|
ctx.body = { success: true }
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
ctx.status = 500
|
|
|
|
|
ctx.body = { error: err.message }
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-13 12:15:16 +08:00
|
|
|
// DELETE /api/config/providers/:poolKey
|
2026-04-16 08:38:18 +08:00
|
|
|
fsRoutes.delete('/api/hermes/config/providers/:poolKey', async (ctx) => {
|
2026-04-13 12:15:16 +08:00
|
|
|
const poolKey = decodeURIComponent(ctx.params.poolKey)
|
2026-04-12 23:23:50 +08:00
|
|
|
|
|
|
|
|
try {
|
2026-04-13 12:15:16 +08:00
|
|
|
const auth = await loadAuthJson()
|
|
|
|
|
if (!auth?.credential_pool) {
|
|
|
|
|
ctx.status = 404
|
|
|
|
|
ctx.body = { error: 'No credential pool found' }
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-12 23:23:50 +08:00
|
|
|
|
2026-04-13 12:15:16 +08:00
|
|
|
const keys = Object.keys(auth.credential_pool)
|
|
|
|
|
|
|
|
|
|
if (keys.length <= 1) {
|
|
|
|
|
ctx.status = 400
|
|
|
|
|
ctx.body = { error: 'Cannot delete the last provider' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!(poolKey in auth.credential_pool)) {
|
|
|
|
|
ctx.status = 404
|
|
|
|
|
ctx.body = { error: `Provider "${poolKey}" not found` }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if this is the current active provider
|
2026-04-14 10:22:29 +08:00
|
|
|
const config = await readConfigYaml()
|
|
|
|
|
const currentProvider = config.model?.provider
|
|
|
|
|
const isCurrent = currentProvider === poolKey
|
2026-04-13 12:15:16 +08:00
|
|
|
|
2026-04-14 10:22:29 +08:00
|
|
|
// Save base_url before deleting
|
2026-04-13 12:15:16 +08:00
|
|
|
const deletedBaseUrl = auth.credential_pool[poolKey]?.[0]?.base_url
|
|
|
|
|
|
|
|
|
|
// 1. Delete from auth.json
|
|
|
|
|
delete auth.credential_pool[poolKey]
|
2026-04-14 10:22:29 +08:00
|
|
|
await saveAuthJson(auth)
|
2026-04-13 12:15:16 +08:00
|
|
|
|
|
|
|
|
// 2. Remove matching entry from config.yaml custom_providers
|
2026-04-14 10:22:29 +08:00
|
|
|
if (deletedBaseUrl && Array.isArray(config.custom_providers)) {
|
|
|
|
|
config.custom_providers = (config.custom_providers as any[]).filter(
|
|
|
|
|
(entry: any) => entry.base_url !== deletedBaseUrl,
|
2026-04-13 12:15:16 +08:00
|
|
|
)
|
2026-04-14 10:22:29 +08:00
|
|
|
await writeConfigYaml(config)
|
2026-04-13 12:15:16 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. If was the current provider, switch to first remaining
|
|
|
|
|
if (isCurrent) {
|
|
|
|
|
const remainingKeys = Object.keys(auth.credential_pool)
|
|
|
|
|
if (remainingKeys.length > 0) {
|
|
|
|
|
const fallback = remainingKeys[0]
|
|
|
|
|
const fallbackEntry = auth.credential_pool[fallback]?.[0]
|
|
|
|
|
const catalogModels = PROVIDER_MODEL_CATALOG[fallback] || []
|
|
|
|
|
const fallbackModel = catalogModels[0] || fallbackEntry?.label || fallback
|
|
|
|
|
|
2026-04-14 10:22:29 +08:00
|
|
|
const config2 = await readConfigYaml()
|
|
|
|
|
if (typeof config2.model !== 'object' || config2.model === null) {
|
|
|
|
|
config2.model = {}
|
2026-04-13 12:15:16 +08:00
|
|
|
}
|
2026-04-14 10:22:29 +08:00
|
|
|
config2.model.default = fallbackModel
|
|
|
|
|
config2.model.provider = fallback
|
|
|
|
|
await writeConfigYaml(config2)
|
2026-04-13 12:15:16 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-12 23:23:50 +08:00
|
|
|
|
|
|
|
|
ctx.body = { success: true }
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
ctx.status = 500
|
|
|
|
|
ctx.body = { error: err.message }
|
|
|
|
|
}
|
2026-04-14 10:22:29 +08:00
|
|
|
})
|