From 9e069a20a1a58c5bda26abab08c68f2fe162d727 Mon Sep 17 00:00:00 2001 From: ekko Date: Mon, 13 Apr 2026 12:15:16 +0800 Subject: [PATCH] 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 --- server/src/routes/filesystem.ts | 170 ++++++++++++-- server/src/shared/providers.ts | 215 +++++++++++++++++ src/api/system.ts | 1 + src/components/layout/AppSidebar.vue | 15 ++ src/components/models/ProviderCard.vue | 143 ++++++++++++ src/components/models/ProviderFormModal.vue | 245 ++++++++++++++++++++ src/components/models/ProvidersPanel.vue | 52 +++++ src/router/index.ts | 5 + src/shared/providers.ts | 215 +++++++++++++++++ src/stores/models.ts | 78 +++++++ src/views/ModelsView.vue | 84 +++++++ 11 files changed, 1198 insertions(+), 25 deletions(-) create mode 100644 server/src/shared/providers.ts create mode 100644 src/components/models/ProviderCard.vue create mode 100644 src/components/models/ProviderFormModal.vue create mode 100644 src/components/models/ProvidersPanel.vue create mode 100644 src/shared/providers.ts create mode 100644 src/stores/models.ts create mode 100644 src/views/ModelsView.vue diff --git a/server/src/routes/filesystem.ts b/server/src/routes/filesystem.ts index 980bca7..7ad6242 100644 --- a/server/src/routes/filesystem.ts +++ b/server/src/routes/filesystem.ts @@ -51,6 +51,10 @@ async function fetchProviderModels(baseUrl: string, apiKey: string): Promise { }) } - // 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 diff --git a/server/src/shared/providers.ts b/server/src/shared/providers.ts new file mode 100644 index 0000000..26868fa --- /dev/null +++ b/server/src/shared/providers.ts @@ -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 for backend lookup */ +export function buildProviderModelMap(): Record { + const map: Record = {} + for (const p of PROVIDER_PRESETS) { + if (p.models.length > 0) { + map[p.value] = p.models + } + } + return map +} diff --git a/src/api/system.ts b/src/api/system.ts index e6b881f..7ef424c 100644 --- a/src/api/system.ts +++ b/src/api/system.ts @@ -49,6 +49,7 @@ export interface CustomProvider { base_url: string api_key: string model: string + providerKey?: string | null } export async function checkHealth(): Promise { diff --git a/src/components/layout/AppSidebar.vue b/src/components/layout/AppSidebar.vue index 63cfa07..f9b6813 100644 --- a/src/components/layout/AppSidebar.vue +++ b/src/components/layout/AppSidebar.vue @@ -48,6 +48,21 @@ function handleNav(key: string) { Jobs + +