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:
+133
-13
@@ -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()
|
export const fsRoutes = new Router()
|
||||||
|
|
||||||
const hermesDir = resolve(homedir(), '.hermes')
|
const hermesDir = resolve(homedir(), '.hermes')
|
||||||
@@ -70,6 +74,10 @@ interface SkillCategory {
|
|||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function escapeRegExp(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
}
|
||||||
|
|
||||||
function extractDescription(content: string): string {
|
function extractDescription(content: string): string {
|
||||||
// SKILL.md format: YAML frontmatter between --- delimiters, then markdown body
|
// SKILL.md format: YAML frontmatter between --- delimiters, then markdown body
|
||||||
// Extract first non-empty, non-frontmatter, non-heading line as description
|
// Extract first non-empty, non-frontmatter, non-heading line as description
|
||||||
@@ -373,15 +381,28 @@ fsRoutes.get('/api/available-models', async (ctx) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all provider models in parallel
|
// Resolve models: hardcoded catalog first, live probe as fallback
|
||||||
|
const groups: Array<{ provider: string; label: string; base_url: string; models: string[] }> = []
|
||||||
|
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(
|
const results = await Promise.allSettled(
|
||||||
endpoints.map(async ep => {
|
liveEndpoints.map(async ep => {
|
||||||
const models = await fetchProviderModels(ep.base_url, ep.token)
|
const models = await fetchProviderModels(ep.base_url, ep.token)
|
||||||
return { ...ep, models }
|
return { ...ep, models }
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const groups: Array<{ provider: string; label: string; base_url: string; models: string[] }> = []
|
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
if (result.status === 'fulfilled' && result.value.models.length > 0) {
|
if (result.status === 'fulfilled' && result.value.models.length > 0) {
|
||||||
const { key, label, base_url, models } = result.value
|
const { key, label, base_url, models } = result.value
|
||||||
@@ -390,6 +411,7 @@ fsRoutes.get('/api/available-models', async (ctx) => {
|
|||||||
console.error(`[available-models] Failed: ${result.reason?.message || result.reason}`)
|
console.error(`[available-models] Failed: ${result.reason?.message || result.reason}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback: if no providers returned models, fall back to config.yaml parsing
|
// Fallback: if no providers returned models, fall back to config.yaml parsing
|
||||||
if (groups.length === 0) {
|
if (groups.length === 0) {
|
||||||
@@ -457,11 +479,12 @@ fsRoutes.put('/api/config/model', async (ctx) => {
|
|||||||
|
|
||||||
// POST /api/config/providers
|
// POST /api/config/providers
|
||||||
fsRoutes.post('/api/config/providers', async (ctx) => {
|
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
|
name: string
|
||||||
base_url: string
|
base_url: string
|
||||||
api_key: string
|
api_key: string
|
||||||
model: string
|
model: string
|
||||||
|
providerKey?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!name || !base_url || !model) {
|
if (!name || !base_url || !model) {
|
||||||
@@ -470,11 +493,18 @@ fsRoutes.post('/api/config/providers', async (ctx) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!api_key) {
|
||||||
|
ctx.status = 400
|
||||||
|
ctx.body = { error: 'Missing API key' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 1. Write to config.yaml custom_providers
|
||||||
await copyFile(configPath, configPath + '.bak')
|
await copyFile(configPath, configPath + '.bak')
|
||||||
let yaml = await safeReadFile(configPath) || ''
|
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)) {
|
if (/^custom_providers:/m.test(yaml)) {
|
||||||
yaml = yaml.replace(/^(custom_providers:)/m, `$1\n${newEntry}`)
|
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')
|
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 }
|
ctx.body = { success: true }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ctx.status = 500
|
ctx.status = 500
|
||||||
@@ -490,19 +551,78 @@ fsRoutes.post('/api/config/providers', async (ctx) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// DELETE /api/config/providers/:name
|
// DELETE /api/config/providers/:poolKey
|
||||||
fsRoutes.delete('/api/config/providers/:name', async (ctx) => {
|
fsRoutes.delete('/api/config/providers/:poolKey', async (ctx) => {
|
||||||
const name = ctx.params.name
|
const poolKey = decodeURIComponent(ctx.params.poolKey)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const auth = await loadAuthJson()
|
||||||
|
if (!auth?.credential_pool) {
|
||||||
|
ctx.status = 404
|
||||||
|
ctx.body = { error: 'No credential pool found' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
await copyFile(configPath, configPath + '.bak')
|
||||||
let yaml = await safeReadFile(configPath) || ''
|
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')
|
||||||
|
}
|
||||||
|
|
||||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
// 3. If was the current provider, switch to first remaining
|
||||||
const blockRegex = new RegExp(` - name:\\s*${escaped}\\s*\\n(?: .+\\n)*`, 'g')
|
if (isCurrent) {
|
||||||
yaml = yaml.replace(blockRegex, '')
|
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 }
|
ctx.body = { success: true }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ctx.status = 500
|
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
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ export interface CustomProvider {
|
|||||||
base_url: string
|
base_url: string
|
||||||
api_key: string
|
api_key: string
|
||||||
model: string
|
model: string
|
||||||
|
providerKey?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkHealth(): Promise<HealthResponse> {
|
export async function checkHealth(): Promise<HealthResponse> {
|
||||||
|
|||||||
@@ -48,6 +48,21 @@ function handleNav(key: string) {
|
|||||||
<span>Jobs</span>
|
<span>Jobs</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: selectedKey === 'models' }"
|
||||||
|
@click="handleNav('models')"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M12 1v4" /><path d="M12 19v4" />
|
||||||
|
<path d="M1 12h4" /><path d="M19 12h4" />
|
||||||
|
<path d="M4.22 4.22l2.83 2.83" /><path d="M16.95 16.95l2.83 2.83" />
|
||||||
|
<path d="M4.22 19.78l2.83-2.83" /><path d="M16.95 7.05l2.83-2.83" />
|
||||||
|
</svg>
|
||||||
|
<span>Models</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: selectedKey === 'skills' }"
|
:class="{ active: selectedKey === 'skills' }"
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { NButton, useMessage, useDialog } from 'naive-ui'
|
||||||
|
import type { AvailableModelGroup } from '@/api/system'
|
||||||
|
import { useModelsStore } from '@/stores/models'
|
||||||
|
|
||||||
|
const props = defineProps<{ provider: AvailableModelGroup }>()
|
||||||
|
|
||||||
|
const modelsStore = useModelsStore()
|
||||||
|
const message = useMessage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
const isCustom = computed(() => props.provider.provider.startsWith('custom:'))
|
||||||
|
const displayName = computed(() => props.provider.label)
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
dialog.warning({
|
||||||
|
title: 'Delete Provider',
|
||||||
|
content: `Are you sure you want to delete "${displayName.value}"?`,
|
||||||
|
positiveText: 'Delete',
|
||||||
|
negativeText: 'Cancel',
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
try {
|
||||||
|
await modelsStore.removeProvider(props.provider.provider)
|
||||||
|
message.success('Provider deleted')
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="provider-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="provider-name">{{ displayName }}</h3>
|
||||||
|
<span class="type-badge" :class="isCustom ? 'custom' : 'builtin'">
|
||||||
|
{{ isCustom ? 'Custom' : 'Built-in' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Provider</span>
|
||||||
|
<code class="info-value mono">{{ provider.provider }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Base URL</span>
|
||||||
|
<code class="info-value mono">{{ provider.base_url }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions">
|
||||||
|
<NButton size="tiny" quaternary type="error" @click="handleDelete">Delete</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use '@/styles/variables' as *;
|
||||||
|
|
||||||
|
.provider-card {
|
||||||
|
background-color: $bg-card;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
padding: 16px;
|
||||||
|
transition: border-color $transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba($accent-primary, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-primary;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 70%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&.builtin {
|
||||||
|
background: rgba($accent-primary, 0.12);
|
||||||
|
color: $accent-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.custom {
|
||||||
|
background: rgba($success, 0.12);
|
||||||
|
color: $success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: $font-code;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
border-top: 1px solid $border-light;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { NModal, NForm, NFormItem, NInput, NButton, NSelect, useMessage } from 'naive-ui'
|
||||||
|
import { useModelsStore } from '@/stores/models'
|
||||||
|
import { PROVIDER_PRESETS } from '@/shared/providers'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
saved: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modelsStore = useModelsStore()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const showModal = ref(true)
|
||||||
|
const loading = ref(false)
|
||||||
|
const fetchingModels = ref(false)
|
||||||
|
|
||||||
|
const providerType = ref<'preset' | 'custom'>('preset')
|
||||||
|
const selectedPreset = ref<string | null>(null)
|
||||||
|
const formData = ref({
|
||||||
|
name: '',
|
||||||
|
base_url: '',
|
||||||
|
api_key: '',
|
||||||
|
model: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelOptions = ref<Array<{ label: string; value: string }>>([])
|
||||||
|
|
||||||
|
const PRESET_PROVIDERS = PROVIDER_PRESETS
|
||||||
|
|
||||||
|
function autoGenerateName(url: string): string {
|
||||||
|
const clean = url.replace(/^https?:\/\//, '').replace(/\/v1\/?$/, '')
|
||||||
|
const host = clean.split('/')[0]
|
||||||
|
if (host.includes('localhost') || host.includes('127.0.0.1')) {
|
||||||
|
return `Local (${host})`
|
||||||
|
}
|
||||||
|
return host.charAt(0).toUpperCase() + host.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(selectedPreset, (val) => {
|
||||||
|
formData.value.model = ''
|
||||||
|
if (val) {
|
||||||
|
const preset = PRESET_PROVIDERS.find(p => p.value === val)
|
||||||
|
if (preset) {
|
||||||
|
formData.value.name = preset.label
|
||||||
|
formData.value.base_url = preset.base_url
|
||||||
|
modelOptions.value = preset.models.map(m => ({ label: m, value: m }))
|
||||||
|
if (preset.models.length > 0) {
|
||||||
|
formData.value.model = preset.models[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => formData.value.base_url, (url) => {
|
||||||
|
if (providerType.value === 'custom' && url.trim()) {
|
||||||
|
formData.value.name = autoGenerateName(url.trim())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(providerType, () => {
|
||||||
|
modelOptions.value = []
|
||||||
|
formData.value = { name: '', base_url: '', api_key: '', model: '' }
|
||||||
|
selectedPreset.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchModels() {
|
||||||
|
const { base_url } = formData.value
|
||||||
|
if (!base_url.trim()) {
|
||||||
|
message.warning('Please enter Base URL first')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchingModels.value = true
|
||||||
|
try {
|
||||||
|
const url = base_url.replace(/\/+$/, '') + '/models'
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (formData.value.api_key.trim()) {
|
||||||
|
headers['Authorization'] = `Bearer ${formData.value.api_key.trim()}`
|
||||||
|
}
|
||||||
|
const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) })
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const data = await res.json() as { data?: Array<{ id: string }> }
|
||||||
|
if (!Array.isArray(data.data)) throw new Error('Unexpected response format')
|
||||||
|
|
||||||
|
modelOptions.value = data.data.map(m => ({ label: m.id, value: m.id }))
|
||||||
|
if (modelOptions.value.length > 0 && !formData.value.model) {
|
||||||
|
formData.value.model = modelOptions.value[0].value
|
||||||
|
}
|
||||||
|
message.success(`Found ${modelOptions.value.length} models`)
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error('Failed to fetch models: ' + e.message)
|
||||||
|
} finally {
|
||||||
|
fetchingModels.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (providerType.value === 'preset' && !selectedPreset.value) {
|
||||||
|
message.warning('Please select a provider')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!formData.value.base_url.trim()) {
|
||||||
|
message.warning('Base URL is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!formData.value.api_key.trim()) {
|
||||||
|
message.warning('API Key is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!formData.value.model) {
|
||||||
|
message.warning('Default Model is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const providerKey = providerType.value === 'preset'
|
||||||
|
? (PRESET_PROVIDERS.find(p => p.value === selectedPreset.value)?.value || null)
|
||||||
|
: null
|
||||||
|
|
||||||
|
await modelsStore.addProvider({
|
||||||
|
name: formData.value.name.trim(),
|
||||||
|
base_url: formData.value.base_url.trim(),
|
||||||
|
api_key: formData.value.api_key.trim(),
|
||||||
|
model: formData.value.model,
|
||||||
|
providerKey,
|
||||||
|
})
|
||||||
|
message.success('Provider added')
|
||||||
|
emit('saved')
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
showModal.value = false
|
||||||
|
setTimeout(() => emit('close'), 200)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NModal
|
||||||
|
v-model:show="showModal"
|
||||||
|
preset="card"
|
||||||
|
title="Add Provider"
|
||||||
|
:style="{ width: '520px' }"
|
||||||
|
:mask-closable="!loading"
|
||||||
|
@after-leave="emit('close')"
|
||||||
|
>
|
||||||
|
<NForm label-placement="top">
|
||||||
|
<NFormItem label="Provider Type">
|
||||||
|
<div style="display: flex; gap: 12px">
|
||||||
|
<NButton
|
||||||
|
:type="providerType === 'preset' ? 'primary' : 'default'"
|
||||||
|
size="small"
|
||||||
|
@click="providerType = 'preset'"
|
||||||
|
>
|
||||||
|
Preset
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
:type="providerType === 'custom' ? 'primary' : 'default'"
|
||||||
|
size="small"
|
||||||
|
@click="providerType = 'custom'"
|
||||||
|
>
|
||||||
|
Custom
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem v-if="providerType === 'preset'" label="Select Provider" required>
|
||||||
|
<NSelect
|
||||||
|
v-model:value="selectedPreset"
|
||||||
|
:options="PRESET_PROVIDERS"
|
||||||
|
placeholder="Choose a provider..."
|
||||||
|
filterable
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem v-if="providerType === 'custom'" label="Name">
|
||||||
|
<NInput
|
||||||
|
v-model:value="formData.name"
|
||||||
|
placeholder="Auto-generated from Base URL"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem label="Base URL" required>
|
||||||
|
<NInput
|
||||||
|
v-model:value="formData.base_url"
|
||||||
|
placeholder="e.g. https://api.example.com/v1"
|
||||||
|
:disabled="providerType === 'preset'"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem label="API Key" required>
|
||||||
|
<NInput
|
||||||
|
v-model:value="formData.api_key"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
placeholder="sk-..."
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem label="Default Model" required>
|
||||||
|
<div style="display: flex; gap: 8px; width: 100%">
|
||||||
|
<NSelect
|
||||||
|
v-model:value="formData.model"
|
||||||
|
:options="modelOptions"
|
||||||
|
filterable
|
||||||
|
placeholder="Select a model..."
|
||||||
|
style="flex: 1"
|
||||||
|
/>
|
||||||
|
<NButton
|
||||||
|
v-if="providerType === 'custom' || (providerType === 'preset' && modelOptions.length === 0)"
|
||||||
|
:loading="fetchingModels"
|
||||||
|
@click="fetchModels"
|
||||||
|
>
|
||||||
|
Fetch
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</NFormItem>
|
||||||
|
</NForm>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<NButton @click="handleClose">Cancel</NButton>
|
||||||
|
<NButton type="primary" :loading="loading" @click="handleSave">
|
||||||
|
Add
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ProviderCard from './ProviderCard.vue'
|
||||||
|
import { useModelsStore } from '@/stores/models'
|
||||||
|
|
||||||
|
const modelsStore = useModelsStore()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="modelsStore.providers.length === 0" class="empty-state">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" class="empty-icon">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||||
|
<path d="M2 17l10 5 10-5" />
|
||||||
|
<path d="M2 12l10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
<p>No providers found. Add a custom provider to get started.</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="providers-grid">
|
||||||
|
<ProviderCard
|
||||||
|
v-for="g in modelsStore.providers"
|
||||||
|
:key="g.provider"
|
||||||
|
:provider="g"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use '@/styles/variables' as *;
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: $text-muted;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.providers-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -13,6 +13,11 @@ const router = createRouter({
|
|||||||
name: 'jobs',
|
name: 'jobs',
|
||||||
component: () => import('@/views/JobsView.vue'),
|
component: () => import('@/views/JobsView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/models',
|
||||||
|
name: 'models',
|
||||||
|
component: () => import('@/views/ModelsView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/logs',
|
path: '/logs',
|
||||||
name: 'logs',
|
name: 'logs',
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import * as systemApi from '@/api/system'
|
||||||
|
import type { AvailableModelGroup, CustomProvider } from '@/api/system'
|
||||||
|
import { useAppStore } from './app'
|
||||||
|
|
||||||
|
export const useModelsStore = defineStore('models', () => {
|
||||||
|
const providers = ref<AvailableModelGroup[]>([])
|
||||||
|
const defaultModel = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const customProviders = computed(() =>
|
||||||
|
providers.value.filter(g => g.provider.startsWith('custom:')),
|
||||||
|
)
|
||||||
|
|
||||||
|
const builtinProviders = computed(() =>
|
||||||
|
providers.value.filter(g => !g.provider.startsWith('custom:')),
|
||||||
|
)
|
||||||
|
|
||||||
|
const allModels = computed(() =>
|
||||||
|
providers.value.flatMap(g =>
|
||||||
|
g.models.map(m => ({
|
||||||
|
id: m,
|
||||||
|
provider: g.provider,
|
||||||
|
label: g.label,
|
||||||
|
base_url: g.base_url,
|
||||||
|
isDefault: m === defaultModel.value,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async function fetchProviders() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await systemApi.fetchAvailableModels()
|
||||||
|
providers.value = res.groups
|
||||||
|
defaultModel.value = res.default
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch providers:', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setDefaultModel(modelId: string, provider: string) {
|
||||||
|
await systemApi.updateDefaultModel({ default: modelId, provider })
|
||||||
|
defaultModel.value = modelId
|
||||||
|
const appStore = useAppStore()
|
||||||
|
appStore.loadModels()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addProvider(data: CustomProvider) {
|
||||||
|
await systemApi.addCustomProvider(data)
|
||||||
|
await fetchProviders()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
appStore.loadModels()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeProvider(name: string) {
|
||||||
|
await systemApi.removeCustomProvider(name)
|
||||||
|
await fetchProviders()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
appStore.loadModels()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
providers,
|
||||||
|
defaultModel,
|
||||||
|
loading,
|
||||||
|
customProviders,
|
||||||
|
builtinProviders,
|
||||||
|
allModels,
|
||||||
|
fetchProviders,
|
||||||
|
setDefaultModel,
|
||||||
|
addProvider,
|
||||||
|
removeProvider,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { NButton, NSpin } from 'naive-ui'
|
||||||
|
import ProvidersPanel from '@/components/models/ProvidersPanel.vue'
|
||||||
|
import ProviderFormModal from '@/components/models/ProviderFormModal.vue'
|
||||||
|
import { useModelsStore } from '@/stores/models'
|
||||||
|
|
||||||
|
const modelsStore = useModelsStore()
|
||||||
|
const showModal = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
modelsStore.fetchProviders()
|
||||||
|
})
|
||||||
|
|
||||||
|
function openCreateModal() {
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleModalClose() {
|
||||||
|
showModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaved() {
|
||||||
|
await modelsStore.fetchProviders()
|
||||||
|
handleModalClose()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="models-view">
|
||||||
|
<header class="models-header">
|
||||||
|
<h2 class="header-title">Models</h2>
|
||||||
|
<NButton type="primary" @click="openCreateModal">
|
||||||
|
<template #icon>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
</template>
|
||||||
|
Add Provider
|
||||||
|
</NButton>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="models-content">
|
||||||
|
<NSpin :show="modelsStore.loading && modelsStore.providers.length === 0">
|
||||||
|
<ProvidersPanel />
|
||||||
|
</NSpin>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProviderFormModal
|
||||||
|
v-if="showModal"
|
||||||
|
@close="handleModalClose"
|
||||||
|
@saved="handleSaved"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use '@/styles/variables' as *;
|
||||||
|
|
||||||
|
.models-view {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.models-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.models-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user