Files
Luminary/web/src/views/ProvidersView.vue
T
renjue e761074fb9
CI / docker (push) Successful in 3m46s
Unify ComfyUI ingress model ID and fix seed validation errors.
Merge model_id/alias in the image provider UI, and ensure ComfyUI never receives seed -1 after cloneMap type coercion.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:17:49 +08:00

745 lines
30 KiB
Vue

<template>
<div class="page">
<PageHeader
:title="t('providers.title')"
:description="t('providers.description')"
>
<template #actions>
<el-button type="primary" :disabled="!currentCategorySupported" @click="openCreate">
{{ t('providers.addProvider') }}
</el-button>
</template>
</PageHeader>
<AppCard>
<div class="tabs-bar">
<el-tabs v-model="activeCategory" @tab-change="load">
<el-tab-pane v-for="cat in categories" :key="cat.key" :name="cat.key">
<template #label>
{{ cat.label }}
<el-tag v-if="!cat.supported" size="small" type="info" class="soon-tag">{{ t('common.soon') }}</el-tag>
</template>
</el-tab-pane>
</el-tabs>
</div>
<el-table :data="providers" v-loading="loading" row-key="id" size="small">
<el-table-column type="expand">
<template #default="{ row }">
<div class="models-expand">
<div v-if="!row.models?.length" class="models-empty">{{ t('providers.modelsEmpty') }}</div>
<el-table v-else :data="row.models" size="small" class="models-table">
<template v-if="row.category === 'image'">
<el-table-column :label="t('providers.ingressModelId')" min-width="180" show-overflow-tooltip>
<template #default="{ row: m }">{{ modelIngressId(m) }}</template>
</el-table-column>
</template>
<template v-else>
<el-table-column prop="model_id" :label="t('providers.upstreamModelId')" min-width="180" show-overflow-tooltip />
<el-table-column :label="t('providers.modelAlias')" min-width="140" show-overflow-tooltip>
<template #default="{ row: m }">{{ m.alias || '—' }}</template>
</el-table-column>
</template>
<el-table-column v-if="row.category === 'image'" prop="workflow_type" :label="t('providers.workflowType')" width="100" />
<el-table-column prop="display_name" :label="t('providers.displayName')" min-width="160" show-overflow-tooltip />
<el-table-column :label="t('common.enabled')" width="80">
<template #default="{ row: m }">
<el-switch
:model-value="m.enabled"
size="small"
@change="(v: boolean) => toggleModel(m, v, row)"
/>
</template>
</el-table-column>
<el-table-column prop="created_at" :label="t('providers.syncedAt')" width="180">
<template #default="{ row: m }">{{ formatDateTime(m.created_at) }}</template>
</el-table-column>
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="name" :label="t('common.name')" min-width="120" />
<el-table-column prop="type" :label="t('providers.protocolType')" width="130">
<template #default="{ row }">{{ protocolTypeLabel(row.type) }}</template>
</el-table-column>
<el-table-column :label="t('common.category')" width="100">
<template #default="{ row }">{{ categoryLabel(row.category) }}</template>
</el-table-column>
<el-table-column :label="t('providers.models')" width="120">
<template #default="{ row }">
<el-tag size="small" :type="enabledModelCount(row) > 0 ? 'success' : 'info'">
{{ enabledModelCount(row) }}/{{ row.models?.length || 0 }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="base_url" label="Base URL" show-overflow-tooltip min-width="160" />
<el-table-column :label="t('providers.enabledEndpoints')" min-width="180">
<template #default="{ row }">
<el-tag v-for="ep in parseList(row.allowed_endpoints_json)" :key="ep" size="small" class="ep-tag">{{ ep }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" :label="t('common.status')" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'healthy' ? 'success' : 'info'" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('common.enabled')" width="80">
<template #default="{ row }">
<el-switch :model-value="row.enabled !== false" size="small" @change="(v: boolean) => toggleProvider(row, v)" />
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="320" fixed="right">
<template #default="{ row }">
<el-button size="small" link @click="openModels(row)">{{ t('providers.modelsAction') }}</el-button>
<el-button size="small" link @click="openEndpoints(row)">{{ t('providers.endpointsAction') }}</el-button>
<el-button size="small" link @click="openKeys(row)">{{ t('providers.keysAction') }}</el-button>
<el-button v-if="row.category !== 'image' && providerHasListModels(row)" size="small" link @click="syncModels(row)">{{ t('providers.syncModels') }}</el-button>
<el-button size="small" type="danger" link @click="remove(row)">{{ t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</AppCard>
<el-dialog v-model="showCreate" :title="t('providers.addDialogTitle')" width="640px" destroy-on-close>
<el-form label-width="110px">
<el-form-item :label="t('common.name')"><el-input v-model="form.name" /></el-form-item>
<el-form-item :label="t('common.category')">
<el-select v-model="form.category" @change="onCategoryChange">
<el-option v-for="cat in categories" :key="cat.key" :label="cat.label" :value="cat.key" :disabled="!cat.supported" />
</el-select>
</el-form-item>
<el-form-item :label="t('providers.protocolType')">
<el-select v-model="form.type" @change="onProtocolTypeChange">
<el-option
v-for="pt in createProviderTypes"
:key="pt.key"
:label="pt.label"
:value="pt.key"
/>
</el-select>
</el-form-item>
<el-form-item :label="t('providers.baseUrl')"><el-input v-model="form.base_url" :placeholder="t('providers.baseUrlPlaceholder')" /></el-form-item>
<template v-if="form.category !== 'image'">
<el-divider>{{ t('providers.endpointDivider') }}</el-divider>
<div v-for="ep in createEndpointMeta" :key="ep.key" class="endpoint-row">
<el-checkbox v-model="form.allowedMap[ep.key]">{{ ep.label }}</el-checkbox>
<el-input v-model="form.pathMap[ep.key]" :placeholder="ep.default_path" size="small" />
</div>
</template>
</el-form>
<template #footer>
<el-button @click="showCreate = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="create">{{ t('common.create') }}</el-button>
</template>
</el-dialog>
<el-drawer v-model="endpointVisible" :title="t('providers.endpointConfigTitle', { name: current?.name || '' })" size="50%">
<el-form label-width="110px">
<el-form-item :label="t('providers.baseUrl')"><el-input v-model="editForm.base_url" /></el-form-item>
<template v-if="current?.category !== 'image'">
<el-divider>{{ t('providers.endpointEditDivider') }}</el-divider>
<p class="form-hint">{{ t('providers.pathHint') }}</p>
<div v-for="ep in editEndpointMeta" :key="ep.key" class="endpoint-row">
<el-checkbox v-model="editForm.allowedMap[ep.key]">{{ ep.label }}</el-checkbox>
<el-input v-model="editForm.pathMap[ep.key]" :placeholder="ep.default_path" size="small" />
</div>
</template>
</el-form>
<template #footer>
<el-button type="primary" @click="saveEndpoints">{{ t('common.save') }}</el-button>
</template>
</el-drawer>
<el-drawer v-model="keysVisible" :title="t('providers.keysTitle', { name: current?.name || '' })" size="50%">
<div class="drawer-toolbar">
<el-button type="primary" size="small" @click="showKeyForm = true">{{ t('providers.addKey') }}</el-button>
</div>
<el-table :data="keys" size="small">
<el-table-column prop="name" :label="t('common.name')" />
<el-table-column prop="weight" :label="t('common.weight')" width="80" />
<el-table-column prop="enabled" :label="t('common.enabled')" width="80">
<template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? t('common.yes') : t('common.no') }}</el-tag></template>
</el-table-column>
<el-table-column prop="requests_today" :label="t('providers.requestsToday')" />
<el-table-column :label="t('common.actions')" width="100">
<template #default="{ row }">
<el-button size="small" type="danger" link @click="deleteKey(row)">{{ t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</el-drawer>
<el-dialog v-model="showKeyForm" :title="t('providers.addKeyDialogTitle')" width="480px" destroy-on-close>
<el-form label-width="120px">
<el-form-item :label="t('common.name')"><el-input v-model="keyForm.name" /></el-form-item>
<el-form-item :label="t('providers.apiKey')"><el-input v-model="keyForm.api_key" type="password" show-password /></el-form-item>
<el-form-item :label="t('common.weight')"><el-input-number v-model="keyForm.weight" :min="0.1" :step="0.1" /></el-form-item>
<el-form-item :label="t('providers.modelWhitelist')"><el-input v-model="keyForm.models" :placeholder="t('providers.modelWhitelistPlaceholder')" /></el-form-item>
<el-form-item :label="t('providers.maxRequestsPerDay')"><el-input-number v-model="keyForm.max_requests_per_day" :min="0" /></el-form-item>
<el-form-item :label="t('providers.maxTokensPerDay')"><el-input-number v-model="keyForm.max_tokens_per_day" :min="0" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="showKeyForm = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="createKey">{{ t('common.add') }}</el-button>
</template>
</el-dialog>
<el-drawer v-model="modelsVisible" :title="t('providers.modelsTitle', { name: current?.name || '' })" size="50%">
<div class="drawer-toolbar">
<el-button v-if="current?.category === 'image'" type="primary" size="small" @click="showWorkflowDialog = true">
{{ t('providers.addWorkflowModel') }}
</el-button>
<template v-else-if="current">
<el-button v-if="providerHasListModels(current)" type="primary" size="small" @click="syncModels(current)">
{{ t('providers.syncModels') }}
</el-button>
<el-button type="primary" size="small" plain @click="openAddModel">{{ t('providers.addModel') }}</el-button>
</template>
<span class="form-hint u-mt-0">
{{ t('providers.modelsSummary', { total: providerModels.length, enabled: enabledProviderModelCount }) }}
</span>
</div>
<el-table :data="providerModels" size="small" v-loading="modelsLoading">
<template v-if="current?.category === 'image'">
<el-table-column :label="t('providers.ingressModelId')" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ modelIngressId(row) }}</template>
</el-table-column>
</template>
<template v-else>
<el-table-column prop="model_id" :label="t('providers.upstreamModelId')" min-width="180" show-overflow-tooltip />
<el-table-column :label="t('providers.modelAlias')" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.alias || '—' }}</template>
</el-table-column>
</template>
<el-table-column v-if="current?.category === 'image'" prop="workflow_type" :label="t('providers.workflowType')" width="100" />
<el-table-column prop="display_name" :label="t('providers.displayName')" min-width="160" show-overflow-tooltip />
<el-table-column :label="t('common.enabled')" width="80">
<template #default="{ row }">
<el-switch
:model-value="row.enabled"
size="small"
@change="(v: boolean) => toggleModel(row, v)"
/>
</template>
</el-table-column>
<el-table-column prop="created_at" :label="t('providers.syncedAt')" width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="120">
<template #default="{ row }">
<el-button size="small" link @click="openEditModel(row)">{{ t('common.edit') }}</el-button>
<el-button size="small" type="danger" link @click="deleteModel(row)">{{ t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</el-drawer>
<el-dialog v-model="showAddModel" :title="t('providers.addModelDialogTitle')" width="480px" destroy-on-close>
<el-form label-width="100px">
<el-form-item :label="t('providers.modelId')" required>
<el-input v-model="addModelForm.model_id" placeholder="text-embedding-3-small" />
</el-form-item>
<el-form-item :label="t('providers.modelAlias')">
<el-input v-model="addModelForm.alias" :placeholder="t('providers.modelAliasPlaceholder')" />
<p class="form-hint">{{ t('providers.modelAliasHint') }}</p>
</el-form-item>
<el-form-item :label="t('providers.displayName')">
<el-input v-model="addModelForm.display_name" :placeholder="t('providers.modelId')" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddModel = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="createModel">{{ t('common.create') }}</el-button>
</template>
</el-dialog>
<el-dialog v-model="showEditModel" :title="t('providers.editModelDialogTitle')" width="480px" destroy-on-close>
<el-form label-width="110px">
<template v-if="current?.category === 'image'">
<el-form-item :label="t('providers.ingressModelId')" required>
<el-input v-model="editModelForm.model_id" placeholder="luminary/flux-sdxl" />
<p class="form-hint">{{ t('providers.ingressModelIdHint') }}</p>
</el-form-item>
</template>
<template v-else>
<el-form-item :label="t('providers.upstreamModelId')">
<el-input v-model="editModelForm.model_id" />
</el-form-item>
<el-form-item :label="t('providers.modelAlias')">
<el-input v-model="editModelForm.alias" :placeholder="t('providers.modelAliasPlaceholder')" />
<p class="form-hint">{{ t('providers.modelAliasHint') }}</p>
</el-form-item>
</template>
<el-form-item :label="t('providers.displayName')">
<el-input v-model="editModelForm.display_name" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEditModel = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="saveEditModel">{{ t('common.save') }}</el-button>
</template>
</el-dialog>
<WorkflowModelDialog
v-if="current"
v-model="showWorkflowDialog"
:provider-id="current.id"
@saved="current && openModels(current)"
/>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import api, { type Provider, type ProviderKey, type ModelEntry } from '@/api/client'
import PageHeader from '@/components/PageHeader.vue'
import AppCard from '@/components/AppCard.vue'
import WorkflowModelDialog from '@/components/WorkflowModelDialog.vue'
import { formatDateTime } from '@/utils/format'
const { t } = useI18n()
interface CategoryMeta { key: string; label: string; supported: boolean }
interface ProviderTypeMeta { key: string; label: string }
interface EndpointMetaItem { key: string; label: string; method: string; ingress_path: string; default_path: string }
const categories = ref<CategoryMeta[]>([])
const createProviderTypes = ref<ProviderTypeMeta[]>([])
const createEndpointMeta = ref<EndpointMetaItem[]>([])
const editEndpointMeta = ref<EndpointMetaItem[]>([])
const providerTypeLabels = ref<Record<string, string>>({})
const activeCategory = ref('llm')
const providers = ref<Provider[]>([])
const loading = ref(false)
const showCreate = ref(false)
const form = ref({
name: '', type: 'openai', category: 'llm', base_url: '',
allowedMap: {} as Record<string, boolean>,
pathMap: {} as Record<string, string>,
})
const editForm = ref({
base_url: '',
allowedMap: {} as Record<string, boolean>,
pathMap: {} as Record<string, string>,
})
const endpointVisible = ref(false)
const current = ref<Provider | null>(null)
const keysVisible = ref(false)
const keys = ref<ProviderKey[]>([])
const showKeyForm = ref(false)
const modelsVisible = ref(false)
const modelsLoading = ref(false)
const providerModels = ref<ModelEntry[]>([])
const enabledProviderModelCount = computed(() =>
providerModels.value.filter(m => m.enabled).length,
)
const showWorkflowDialog = ref(false)
const showAddModel = ref(false)
const showEditModel = ref(false)
const addModelForm = ref({ model_id: '', alias: '', display_name: '' })
const editModelForm = ref({ id: 0, model_id: '', alias: '', display_name: '' })
const keyForm = ref({ name: '', api_key: '', weight: 1, models: '["*"]', max_requests_per_day: 0, max_tokens_per_day: 0 })
let createMetaSeq = 0
let editMetaSeq = 0
const currentCategorySupported = computed(() =>
categories.value.find(c => c.key === activeCategory.value)?.supported ?? true,
)
function categoryLabel(key: string) {
return categories.value.find(c => c.key === key)?.label || key
}
function protocolTypeLabel(key: string) {
return providerTypeLabels.value[key] || key
}
function defaultProviderType(category: string, types: ProviderTypeMeta[]) {
if (types.length > 0) return types[0].key
return category === 'image' ? 'comfyui' : 'openai'
}
function apiErrorMessage(error: string | undefined, fallback: string) {
if (error === 'provider name already exists') return t('providers.providerNameExists')
if (error === 'model alias already exists') return t('providers.modelAliasExists')
return error || fallback
}
function providerHasListModels(row: Provider) {
return parseList(row.allowed_endpoints_json).includes('list_models')
}
async function fetchEndpointMeta(category: string, type?: string) {
const res = await api.get('/endpoint-meta', { params: { category, type: type || undefined } })
return {
categories: (res.data.categories || []) as CategoryMeta[],
providerTypes: (res.data.provider_types || []) as ProviderTypeMeta[],
allProviderTypes: (res.data.all_provider_types || []) as ProviderTypeMeta[],
endpoints: (res.data.endpoints || []) as EndpointMetaItem[],
}
}
function applyDefaultEndpoints(
category: string,
endpoints: EndpointMetaItem[],
allowedMap: Record<string, boolean>,
pathMap: Record<string, string>,
reset: boolean,
) {
const defaultKeys = defaultEndpointsForCategory(category)
for (const ep of endpoints) {
if (reset || allowedMap[ep.key] === undefined) {
allowedMap[ep.key] = defaultKeys.includes(ep.key)
}
if (reset || pathMap[ep.key] === undefined) {
pathMap[ep.key] = pathMap[ep.key] || ''
}
}
}
async function loadCreateEndpointMeta(category: string, type: string, reset = false) {
const seq = ++createMetaSeq
const data = await fetchEndpointMeta(category, type)
if (seq !== createMetaSeq) return data
if (!categories.value.length) categories.value = data.categories
createProviderTypes.value = data.providerTypes
for (const pt of data.providerTypes) providerTypeLabels.value[pt.key] = pt.label
createEndpointMeta.value = data.endpoints
applyDefaultEndpoints(category, data.endpoints, form.value.allowedMap, form.value.pathMap, reset)
return data
}
async function loadEditEndpointMeta(category: string, type: string) {
const seq = ++editMetaSeq
const data = await fetchEndpointMeta(category, type)
if (seq !== editMetaSeq) return data
editEndpointMeta.value = data.endpoints
return data
}
function parseList(json: string) {
try { return JSON.parse(json || '[]') } catch { return [] }
}
function parsePaths(json: string): Record<string, string> {
try { return JSON.parse(json || '{}') } catch { return {} }
}
function enabledModelCount(row: Provider) {
return (row.models ?? []).filter(m => m.enabled).length
}
function syncModelState(m: ModelEntry, enabled: boolean, providerRow?: Provider) {
m.enabled = enabled
if (providerRow?.models) {
const item = providerRow.models.find(x => x.id === m.id)
if (item) item.enabled = enabled
}
if (current.value?.id === m.provider_id) {
const item = providerModels.value.find(x => x.id === m.id)
if (item) item.enabled = enabled
}
const provider = providers.value.find(p => p.id === m.provider_id)
if (provider?.models) {
const item = provider.models.find(x => x.id === m.id)
if (item) item.enabled = enabled
}
}
async function toggleModel(m: ModelEntry, enabled: boolean, providerRow?: Provider) {
if (!m.id || !m.provider_id) {
ElMessage.warning(t('providers.modelNotLoaded'))
return
}
const prev = m.enabled
syncModelState(m, enabled, providerRow)
try {
await api.put(`/providers/${m.provider_id}/models/${m.id}`, { enabled, model_id: m.model_id })
ElMessage.success(enabled ? t('providers.modelEnabled') : t('providers.modelDisabled'))
} catch (e: any) {
syncModelState(m, prev, providerRow)
const msg = e.response?.data?.error
ElMessage.error(msg === 'not found' || msg === 'model not found' ? t('providers.modelNotFound') : (msg || t('providers.updateFailed')))
}
}
async function onProtocolTypeChange() {
await nextTick()
const allowedMap = { ...form.value.allowedMap }
const pathMap = { ...form.value.pathMap }
await loadCreateEndpointMeta(form.value.category, form.value.type)
for (const ep of createEndpointMeta.value) {
if (allowedMap[ep.key] !== undefined) form.value.allowedMap[ep.key] = allowedMap[ep.key]
if (pathMap[ep.key] !== undefined) form.value.pathMap[ep.key] = pathMap[ep.key]
}
}
function defaultEndpointsForCategory(category: string) {
switch (category) {
case 'embedding':
return ['list_models', 'embeddings']
case 'rerank':
return ['list_models', 'rerank']
case 'image':
return []
default:
return ['list_models', 'chat_completion']
}
}
function onCategoryChange() {
void (async () => {
form.value.allowedMap = {}
form.value.pathMap = {}
const meta = await fetchEndpointMeta(form.value.category)
form.value.type = defaultProviderType(form.value.category, meta.providerTypes)
await loadCreateEndpointMeta(form.value.category, form.value.type, true)
})()
}
async function toggleProvider(row: Provider, enabled: boolean) {
await api.put(`/providers/${row.id}`, { enabled })
row.enabled = enabled
ElMessage.success(enabled ? t('providers.providerEnabled') : t('providers.providerDisabled'))
}
function buildPayload(baseURL: string, allowedMap: Record<string, boolean>, pathMap: Record<string, string>) {
const allowed_endpoints = Object.keys(allowedMap).filter(k => allowedMap[k])
const endpoint_paths: Record<string, string> = {}
for (const [k, v] of Object.entries(pathMap)) {
if (v.trim()) endpoint_paths[k] = v.trim()
}
return { base_url: baseURL, allowed_endpoints, endpoint_paths }
}
async function load() {
loading.value = true
try {
const res = await api.get('/providers', { params: { category: activeCategory.value } })
providers.value = (res.data.data ?? []).map((p: Provider) => ({
...p,
models: p.models ?? [],
}))
} finally {
loading.value = false
}
}
async function openCreate() {
form.value = {
name: '',
type: 'openai',
category: activeCategory.value,
base_url: '',
allowedMap: {},
pathMap: {},
}
const meta = await fetchEndpointMeta(activeCategory.value)
if (!categories.value.length) categories.value = meta.categories
for (const pt of meta.providerTypes) providerTypeLabels.value[pt.key] = pt.label
form.value.type = defaultProviderType(activeCategory.value, meta.providerTypes)
await loadCreateEndpointMeta(activeCategory.value, form.value.type, true)
showCreate.value = true
}
async function create() {
const payload = {
name: form.value.name,
type: form.value.type,
category: form.value.category,
...buildPayload(form.value.base_url, form.value.allowedMap, form.value.pathMap),
}
try {
await api.post('/providers', payload)
} catch (e: any) {
ElMessage.error(apiErrorMessage(e.response?.data?.error, t('providers.updateFailed')))
return
}
showCreate.value = false
ElMessage.success(t('providers.createSuccess'))
load()
}
async function openEndpoints(row: Provider) {
current.value = row
await loadEditEndpointMeta(row.category || 'llm', row.type)
const allowed = parseList(row.allowed_endpoints_json)
const paths = parsePaths(row.endpoint_paths_json)
const allowedMap: Record<string, boolean> = {}
const pathMap: Record<string, string> = {}
for (const ep of editEndpointMeta.value) {
allowedMap[ep.key] = allowed.includes(ep.key)
pathMap[ep.key] = paths[ep.key] || ''
}
editForm.value = { base_url: row.base_url, allowedMap, pathMap }
endpointVisible.value = true
}
async function saveEndpoints() {
if (!current.value) return
await api.put(`/providers/${current.value.id}`, buildPayload(
editForm.value.base_url,
editForm.value.allowedMap,
editForm.value.pathMap,
))
endpointVisible.value = false
ElMessage.success(t('providers.saved'))
load()
}
async function remove(row: Provider) {
await ElMessageBox.confirm(t('common.confirmDelete'))
await api.delete(`/providers/${row.id}`)
ElMessage.success(t('providers.deleted'))
load()
}
async function openKeys(row: Provider) {
current.value = row
const res = await api.get(`/providers/${row.id}/keys`)
keys.value = res.data.data
keysVisible.value = true
}
async function openModels(row: Provider) {
current.value = row
modelsVisible.value = true
modelsLoading.value = true
providerModels.value = []
try {
const res = await api.get(`/providers/${row.id}/models`)
const models = res.data.data ?? []
providerModels.value = models
row.models = models
} finally {
modelsLoading.value = false
}
}
async function syncModels(row: Provider) {
try {
const res = await api.post(`/providers/${row.id}/sync-models`)
ElMessage.success(t('providers.syncedCount', { count: res.data.synced }))
await load()
const updated = providers.value.find(p => p.id === row.id)
if (updated) {
Object.assign(row, updated)
if (current.value?.id === row.id) {
current.value = updated
}
}
if (modelsVisible.value && current.value?.id === row.id) {
await openModels(row)
}
} catch (e: any) {
ElMessage.error(e.response?.data?.error || t('providers.syncFailed'))
}
}
async function createKey() {
if (!current.value) return
let models: string[] = ['*']
try { models = JSON.parse(keyForm.value.models) } catch { /* keep */ }
await api.post(`/providers/${current.value.id}/keys`, { ...keyForm.value, models })
showKeyForm.value = false
keyForm.value = { name: '', api_key: '', weight: 1, models: '["*"]', max_requests_per_day: 0, max_tokens_per_day: 0 }
openKeys(current.value)
ElMessage.success(t('providers.keyAdded'))
}
async function deleteKey(row: ProviderKey) {
await api.delete(`/provider-keys/${row.id}`)
if (current.value) openKeys(current.value)
}
function openAddModel() {
addModelForm.value = { model_id: '', alias: '', display_name: '' }
showAddModel.value = true
}
function modelIngressId(m: ModelEntry) {
return m.alias || m.model_id
}
function openEditModel(m: ModelEntry) {
const isImage = current.value?.category === 'image'
editModelForm.value = {
id: m.id,
model_id: isImage ? modelIngressId(m) : m.model_id,
alias: isImage ? '' : (m.alias || ''),
display_name: m.display_name || '',
}
showEditModel.value = true
}
async function saveEditModel() {
if (!current.value || !editModelForm.value.id) return
const isImage = current.value.category === 'image'
try {
await api.put(`/providers/${current.value.id}/models/${editModelForm.value.id}`, {
model_id: editModelForm.value.model_id.trim(),
...(isImage ? { alias: '' } : { alias: editModelForm.value.alias.trim() }),
display_name: editModelForm.value.display_name.trim(),
})
} catch (e: any) {
ElMessage.error(apiErrorMessage(e.response?.data?.error, t('providers.updateFailed')))
return
}
showEditModel.value = false
ElMessage.success(t('providers.saved'))
await openModels(current.value)
await load()
}
async function createModel() {
if (!current.value) return
const modelId = addModelForm.value.model_id.trim()
if (!modelId) {
ElMessage.warning(t('providers.modelIdRequired'))
return
}
const displayName = addModelForm.value.display_name.trim() || modelId
try {
await api.post(`/providers/${current.value.id}/models`, {
model_id: modelId,
alias: addModelForm.value.alias.trim(),
display_name: displayName,
})
} catch (e: any) {
ElMessage.error(apiErrorMessage(e.response?.data?.error, t('providers.updateFailed')))
return
}
showAddModel.value = false
ElMessage.success(t('providers.modelAdded'))
await openModels(current.value)
await load()
}
async function deleteModel(m: ModelEntry) {
if (!current.value || !m.id) return
await ElMessageBox.confirm(t('providers.confirmDeleteModel'))
try {
await api.delete(`/providers/${current.value.id}/models/${m.id}`)
} catch (e: any) {
ElMessage.error(e.response?.data?.error || t('providers.updateFailed'))
return
}
ElMessage.success(t('providers.modelDeleted'))
await openModels(current.value)
await load()
}
onMounted(async () => {
const meta = await fetchEndpointMeta('llm')
categories.value = meta.categories
for (const pt of (meta.allProviderTypes?.length ? meta.allProviderTypes : meta.providerTypes) ?? []) {
providerTypeLabels.value[pt.key] = pt.label
}
load()
})
</script>