feat: add model management module with provider CRUD
- New /models page with provider list (built-in + custom) - Add provider via preset selection or custom URL with auto-fetch models - Delete provider removes from auth.json credential_pool + config.yaml custom_providers - Auto-switch model on add, fallback switch on delete - Sync sidebar ModelSelector on all provider changes - Unified provider presets in shared/providers.ts (frontend + backend) - Backend uses hardcoded catalog first, live probe as fallback Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+145
-25
@@ -51,6 +51,10 @@ async function fetchProviderModels(baseUrl: string, apiKey: string): Promise<str
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hardcoded model catalogs (single source: src/shared/providers.ts) ---
|
||||
import { buildProviderModelMap } from '../shared/providers'
|
||||
const PROVIDER_MODEL_CATALOG = buildProviderModelMap()
|
||||
|
||||
export const fsRoutes = new Router()
|
||||
|
||||
const hermesDir = resolve(homedir(), '.hermes')
|
||||
@@ -70,6 +74,10 @@ interface SkillCategory {
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function extractDescription(content: string): string {
|
||||
// SKILL.md format: YAML frontmatter between --- delimiters, then markdown body
|
||||
// Extract first non-empty, non-frontmatter, non-heading line as description
|
||||
@@ -373,21 +381,35 @@ fsRoutes.get('/api/available-models', async (ctx) => {
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch all provider models in parallel
|
||||
const results = await Promise.allSettled(
|
||||
endpoints.map(async ep => {
|
||||
const models = await fetchProviderModels(ep.base_url, ep.token)
|
||||
return { ...ep, models }
|
||||
}),
|
||||
)
|
||||
|
||||
// Resolve models: hardcoded catalog first, live probe as fallback
|
||||
const groups: Array<{ provider: string; label: string; base_url: string; models: string[] }> = []
|
||||
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}`)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Only probe endpoints not in the catalog
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,11 +479,12 @@ fsRoutes.put('/api/config/model', async (ctx) => {
|
||||
|
||||
// POST /api/config/providers
|
||||
fsRoutes.post('/api/config/providers', async (ctx) => {
|
||||
const { name, base_url, api_key, model } = ctx.request.body as {
|
||||
const { name, base_url, api_key, model, providerKey } = ctx.request.body as {
|
||||
name: string
|
||||
base_url: string
|
||||
api_key: string
|
||||
model: string
|
||||
providerKey?: string | null
|
||||
}
|
||||
|
||||
if (!name || !base_url || !model) {
|
||||
@@ -470,11 +493,18 @@ fsRoutes.post('/api/config/providers', async (ctx) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!api_key) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing API key' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Write to config.yaml custom_providers
|
||||
await copyFile(configPath, configPath + '.bak')
|
||||
let yaml = await safeReadFile(configPath) || ''
|
||||
|
||||
const newEntry = `- name: ${name}\n base_url: ${base_url}\n api_key: ${api_key || ''}\n model: ${model}\n`
|
||||
const newEntry = `- name: ${name}\n base_url: ${base_url}\n api_key: ${api_key}\n model: ${model}\n`
|
||||
|
||||
if (/^custom_providers:/m.test(yaml)) {
|
||||
yaml = yaml.replace(/^(custom_providers:)/m, `$1\n${newEntry}`)
|
||||
@@ -483,6 +513,37 @@ fsRoutes.post('/api/config/providers', async (ctx) => {
|
||||
}
|
||||
|
||||
await writeFile(configPath, yaml, 'utf-8')
|
||||
|
||||
// 2. Write to auth.json credential_pool so GET /api/available-models sees it immediately
|
||||
const poolKey = providerKey
|
||||
|| `custom:${name.trim().toLowerCase().replace(/ /g, '-')}`
|
||||
const auth = await loadAuthJson() || { credential_pool: {} }
|
||||
if (!auth.credential_pool) auth.credential_pool = {}
|
||||
|
||||
// Don't overwrite existing entries for built-in providers
|
||||
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,
|
||||
})
|
||||
|
||||
await writeFile(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8')
|
||||
|
||||
// 3. Auto-switch model to the newly added provider
|
||||
let yaml2 = await safeReadFile(configPath) || ''
|
||||
const modelBlockMatch = yaml2.match(/^(model:\s*\n(?: .+\n)*)/m)
|
||||
if (modelBlockMatch) {
|
||||
const lines = [`model:`, ` default: ${model}`, ` provider: ${poolKey}`]
|
||||
yaml2 = yaml2.replace(modelBlockMatch[1], lines.join('\n') + '\n')
|
||||
await writeFile(configPath, yaml2, 'utf-8')
|
||||
}
|
||||
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
@@ -490,19 +551,78 @@ fsRoutes.post('/api/config/providers', async (ctx) => {
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/config/providers/:name
|
||||
fsRoutes.delete('/api/config/providers/:name', async (ctx) => {
|
||||
const name = ctx.params.name
|
||||
// DELETE /api/config/providers/:poolKey
|
||||
fsRoutes.delete('/api/config/providers/:poolKey', async (ctx) => {
|
||||
const poolKey = decodeURIComponent(ctx.params.poolKey)
|
||||
|
||||
try {
|
||||
await copyFile(configPath, configPath + '.bak')
|
||||
let yaml = await safeReadFile(configPath) || ''
|
||||
const auth = await loadAuthJson()
|
||||
if (!auth?.credential_pool) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'No credential pool found' }
|
||||
return
|
||||
}
|
||||
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const blockRegex = new RegExp(` - name:\\s*${escaped}\\s*\\n(?: .+\\n)*`, 'g')
|
||||
yaml = yaml.replace(blockRegex, '')
|
||||
const keys = Object.keys(auth.credential_pool)
|
||||
|
||||
// Guard: cannot delete the last provider
|
||||
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
|
||||
const yaml = await safeReadFile(configPath) || ''
|
||||
const providerMatch = yaml.match(/^ provider:\s*(.+)$/m)
|
||||
const isCurrent = providerMatch && providerMatch[1].trim() === poolKey
|
||||
|
||||
// Save base_url before deleting (needed for config.yaml cleanup)
|
||||
const deletedBaseUrl = auth.credential_pool[poolKey]?.[0]?.base_url
|
||||
|
||||
// 1. Delete from auth.json
|
||||
delete auth.credential_pool[poolKey]
|
||||
await writeFile(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8')
|
||||
|
||||
// 2. Remove matching entry from config.yaml custom_providers
|
||||
// Use base_url to match — more reliable than name (preset key ≠ display name)
|
||||
if (deletedBaseUrl) {
|
||||
await copyFile(configPath, configPath + '.bak')
|
||||
let newYaml = await safeReadFile(configPath) || ''
|
||||
const entryRegex = new RegExp(
|
||||
`^- name:.*\\n(?:[ \\t]+.*\\n)*? base_url:\\s*${escapeRegExp(deletedBaseUrl)}\\s*\\n(?:[ \\t]+.*\\n)*`,
|
||||
'gm',
|
||||
)
|
||||
newYaml = newYaml.replace(entryRegex, '').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'
|
||||
await writeFile(configPath, newYaml, 'utf-8')
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
await copyFile(configPath, configPath + '.bak')
|
||||
let newYaml = await safeReadFile(configPath) || ''
|
||||
const modelBlockMatch = newYaml.match(/^(model:\s*\n(?: .+\n)*)/m)
|
||||
if (modelBlockMatch) {
|
||||
const lines = [`model:`, ` default: ${fallbackModel}`, ` provider: ${fallback}`]
|
||||
newYaml = newYaml.replace(modelBlockMatch[1], lines.join('\n') + '\n')
|
||||
await writeFile(configPath, newYaml, 'utf-8')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await writeFile(configPath, yaml, 'utf-8')
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Provider registry — single source of truth for both frontend and backend.
|
||||
* Synced from hermes-agent hermes_cli/models.py _PROVIDER_MODELS.
|
||||
*/
|
||||
|
||||
export interface ProviderPreset {
|
||||
label: string
|
||||
value: string
|
||||
base_url: string
|
||||
models: string[]
|
||||
}
|
||||
|
||||
export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
{
|
||||
label: 'Anthropic',
|
||||
value: 'anthropic',
|
||||
base_url: 'https://api.anthropic.com',
|
||||
models: [
|
||||
'claude-opus-4-6',
|
||||
'claude-sonnet-4-6',
|
||||
'claude-opus-4-5-20251101',
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'claude-opus-4-20250514',
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-haiku-4-5-20251001',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Google AI Studio',
|
||||
value: 'gemini',
|
||||
base_url: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
||||
models: [
|
||||
'gemini-3.1-pro-preview',
|
||||
'gemini-3-flash-preview',
|
||||
'gemini-3.1-flash-lite-preview',
|
||||
'gemini-2.5-pro',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-flash-lite',
|
||||
'gemma-4-31b-it',
|
||||
'gemma-4-26b-it',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'DeepSeek',
|
||||
value: 'deepseek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
models: ['deepseek-chat', 'deepseek-reasoner'],
|
||||
},
|
||||
{
|
||||
label: 'Z.AI / GLM',
|
||||
value: 'zai',
|
||||
base_url: 'https://api.z.ai/api/paas/v4',
|
||||
models: ['glm-5', 'glm-5-turbo', 'glm-4.7', 'glm-4.5', 'glm-4.5-flash'],
|
||||
},
|
||||
{
|
||||
label: 'Kimi Coding Plan',
|
||||
value: 'kimi-coding',
|
||||
base_url: 'https://api.kimi.com/coding/v1',
|
||||
models: [
|
||||
'kimi-for-coding',
|
||||
'kimi-k2.5',
|
||||
'kimi-k2-thinking',
|
||||
'kimi-k2-thinking-turbo',
|
||||
'kimi-k2-turbo-preview',
|
||||
'kimi-k2-0905-preview',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Moonshot (Pay-as-you-go)',
|
||||
value: 'moonshot',
|
||||
base_url: 'https://api.moonshot.ai/v1',
|
||||
models: ['kimi-k2.5', 'kimi-k2-thinking', 'kimi-k2-turbo-preview', 'kimi-k2-0905-preview'],
|
||||
},
|
||||
{
|
||||
label: 'xAI',
|
||||
value: 'xai',
|
||||
base_url: 'https://api.x.ai/v1',
|
||||
models: [
|
||||
'grok-4.20-0309-reasoning',
|
||||
'grok-4.20-0309-non-reasoning',
|
||||
'grok-4-1-fast-reasoning',
|
||||
'grok-4-1-fast-non-reasoning',
|
||||
'grok-4-fast-reasoning',
|
||||
'grok-4-fast-non-reasoning',
|
||||
'grok-4-0709',
|
||||
'grok-code-fast-1',
|
||||
'grok-3',
|
||||
'grok-3-mini',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'MiniMax',
|
||||
value: 'minimax',
|
||||
base_url: 'https://api.minimax.io/anthropic',
|
||||
models: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.1', 'MiniMax-M2'],
|
||||
},
|
||||
{
|
||||
label: 'MiniMax (China)',
|
||||
value: 'minimax-cn',
|
||||
base_url: 'https://api.minimaxi.com/anthropic',
|
||||
models: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.1', 'MiniMax-M2'],
|
||||
},
|
||||
{
|
||||
label: 'Alibaba Cloud',
|
||||
value: 'alibaba',
|
||||
base_url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
||||
models: [
|
||||
'qwen3.5-plus',
|
||||
'qwen3-coder-plus',
|
||||
'qwen3-coder-next',
|
||||
'glm-5',
|
||||
'glm-4.7',
|
||||
'kimi-k2.5',
|
||||
'MiniMax-M2.5',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Hugging Face',
|
||||
value: 'huggingface',
|
||||
base_url: 'https://router.huggingface.co/v1',
|
||||
models: [
|
||||
'Qwen/Qwen3.5-397B-A17B',
|
||||
'Qwen/Qwen3.5-35B-A3B',
|
||||
'deepseek-ai/DeepSeek-V3.2',
|
||||
'moonshotai/Kimi-K2.5',
|
||||
'MiniMaxAI/MiniMax-M2.5',
|
||||
'zai-org/GLM-5',
|
||||
'XiaomiMiMo/MiMo-V2-Flash',
|
||||
'moonshotai/Kimi-K2-Thinking',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Xiaomi MiMo',
|
||||
value: 'xiaomi',
|
||||
base_url: 'https://api.xiaomimimo.com/v1',
|
||||
models: ['mimo-v2-pro', 'mimo-v2-omni', 'mimo-v2-flash'],
|
||||
},
|
||||
{
|
||||
label: 'Kilo Code',
|
||||
value: 'kilocode',
|
||||
base_url: 'https://api.kilo.ai/api/gateway',
|
||||
models: [
|
||||
'anthropic/claude-opus-4.6',
|
||||
'anthropic/claude-sonnet-4.6',
|
||||
'openai/gpt-5.4',
|
||||
'google/gemini-3-pro-preview',
|
||||
'google/gemini-3-flash-preview',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'AI Gateway',
|
||||
value: 'ai-gateway',
|
||||
base_url: 'https://ai-gateway.vercel.sh/v1',
|
||||
models: [
|
||||
'anthropic/claude-opus-4.6',
|
||||
'anthropic/claude-sonnet-4.6',
|
||||
'anthropic/claude-sonnet-4.5',
|
||||
'anthropic/claude-haiku-4.5',
|
||||
'openai/gpt-5',
|
||||
'openai/gpt-4.1',
|
||||
'openai/gpt-4.1-mini',
|
||||
'google/gemini-3-pro-preview',
|
||||
'google/gemini-3-flash',
|
||||
'google/gemini-2.5-pro',
|
||||
'google/gemini-2.5-flash',
|
||||
'deepseek/deepseek-v3.2',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'OpenCode Zen',
|
||||
value: 'opencode-zen',
|
||||
base_url: 'https://opencode.ai/zen/v1',
|
||||
models: [
|
||||
'gpt-5.4-pro',
|
||||
'gpt-5.4',
|
||||
'gpt-5.3-codex',
|
||||
'gpt-5.2',
|
||||
'gpt-5.1',
|
||||
'claude-opus-4-6',
|
||||
'claude-sonnet-4-6',
|
||||
'claude-haiku-4-5',
|
||||
'gemini-3.1-pro',
|
||||
'gemini-3-pro',
|
||||
'gemini-3-flash',
|
||||
'minimax-m2.7',
|
||||
'minimax-m2.5',
|
||||
'glm-5',
|
||||
'glm-4.7',
|
||||
'kimi-k2.5',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'OpenCode Go',
|
||||
value: 'opencode-go',
|
||||
base_url: 'https://opencode.ai/zen/go/v1',
|
||||
models: ['glm-5', 'kimi-k2.5', 'mimo-v2-pro', 'mimo-v2-omni', 'minimax-m2.7', 'minimax-m2.5'],
|
||||
},
|
||||
{
|
||||
label: 'OpenRouter',
|
||||
value: 'openrouter',
|
||||
base_url: 'https://openrouter.ai/api/v1',
|
||||
models: [],
|
||||
},
|
||||
]
|
||||
|
||||
/** Build a Record<providerKey, models[]> for backend lookup */
|
||||
export function buildProviderModelMap(): Record<string, string[]> {
|
||||
const map: Record<string, string[]> = {}
|
||||
for (const p of PROVIDER_PRESETS) {
|
||||
if (p.models.length > 0) {
|
||||
map[p.value] = p.models
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
Reference in New Issue
Block a user