Split ingress keys into text/image protocols with Replicate model listing.
CI / docker (push) Successful in 3m38s
CI / docker (push) Successful in 3m38s
Add model input defaults (including prompt), workflow model editing, ingress API docs, and fix img2img image upload filenames for signed URLs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Luminary AI Gateway</title>
|
||||
<script type="module" crossorigin src="/assets/index-CF-14R2-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CDyLXzvy.css">
|
||||
<script type="module" crossorigin src="/assets/index-BmYw8w9L.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-pxQ9-t6p.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface ModelEntry {
|
||||
workflow_type?: string
|
||||
workflow_json?: string
|
||||
input_binding_json?: string
|
||||
input_defaults_json?: string
|
||||
version_hash?: string
|
||||
enabled: boolean
|
||||
created_at?: string
|
||||
@@ -62,6 +63,7 @@ export interface IngressKey {
|
||||
id: number
|
||||
name: string
|
||||
prefix: string
|
||||
protocol: 'text' | 'image'
|
||||
enabled: boolean
|
||||
budget_limit: number
|
||||
budget_used: number
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" :title="t('providers.workflowDialogTitle')" width="720px" destroy-on-close @closed="reset">
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="isEdit ? t('providers.editWorkflowDialogTitle') : t('providers.workflowDialogTitle')"
|
||||
width="960px"
|
||||
destroy-on-close
|
||||
@closed="onClosed"
|
||||
>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item :label="t('providers.ingressModelId')" required>
|
||||
<el-input v-model="form.model_id" placeholder="luminary/flux-sdxl" />
|
||||
@@ -23,13 +29,13 @@
|
||||
</el-form>
|
||||
|
||||
<el-table v-if="bindings.length" :data="bindings" size="small" class="u-mt-2">
|
||||
<el-table-column prop="param" :label="t('providers.paramName')" width="140" />
|
||||
<el-table-column :label="t('providers.bindEnabled')" width="90">
|
||||
<el-table-column prop="param" :label="t('providers.paramName')" width="130" />
|
||||
<el-table-column :label="t('providers.bindEnabled')" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.enabled" :disabled="row.required" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('providers.targetNode')">
|
||||
<el-table-column :label="t('providers.targetNode')" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.node" clearable filterable :disabled="!row.enabled" @change="onNodeChange(row)">
|
||||
<el-option
|
||||
@@ -41,14 +47,32 @@
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('providers.targetField')">
|
||||
<el-table-column :label="t('providers.targetField')" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.field" clearable :disabled="!row.enabled || !row.node">
|
||||
<el-option v-for="f in fieldsFor(row.node)" :key="f" :label="f" :value="f" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('providers.paramDefault')" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-if="row.paramType !== 'string'"
|
||||
v-model="row.defaultValue"
|
||||
:disabled="!row.enabled"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
/>
|
||||
<el-input
|
||||
v-else
|
||||
v-model="row.defaultValue"
|
||||
:disabled="!row.enabled"
|
||||
clearable
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<p v-if="bindings.length" class="form-hint u-mt-2">{{ t('providers.paramDefaultHint') }}</p>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">{{ t('common.cancel') }}</el-button>
|
||||
@@ -61,7 +85,7 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api from '@/api/client'
|
||||
import api, { type ModelEntry } from '@/api/client'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -71,15 +95,32 @@ interface WorkflowNode {
|
||||
injectable_fields: string[]
|
||||
}
|
||||
|
||||
interface ParamMeta {
|
||||
name: string
|
||||
type: 'string' | 'int' | 'float'
|
||||
default?: string | number
|
||||
}
|
||||
|
||||
interface BindingRow {
|
||||
param: string
|
||||
paramType: 'string' | 'int' | 'float'
|
||||
required: boolean
|
||||
enabled: boolean
|
||||
node: string
|
||||
field: string
|
||||
defaultValue: string | number | null
|
||||
}
|
||||
|
||||
const props = defineProps<{ providerId: number }>()
|
||||
interface NodeFieldBinding {
|
||||
node: string
|
||||
field: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
providerId: number
|
||||
model?: ModelEntry | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
|
||||
const visible = defineModel<boolean>({ required: true })
|
||||
@@ -87,14 +128,17 @@ const analyzing = ref(false)
|
||||
const saving = ref(false)
|
||||
const nodes = ref<WorkflowNode[]>([])
|
||||
const bindings = ref<BindingRow[]>([])
|
||||
const paramMetaByType = ref<Record<string, ParamMeta[]>>({})
|
||||
|
||||
const form = ref({
|
||||
model_id: '',
|
||||
display_name: '',
|
||||
workflow_type: 'txt2img',
|
||||
workflow_type: 'txt2img' as 'txt2img' | 'img2img',
|
||||
workflow_json: '',
|
||||
})
|
||||
|
||||
const isEdit = computed(() => !!props.model?.id)
|
||||
|
||||
const requiredParams = computed(() =>
|
||||
form.value.workflow_type === 'img2img' ? ['prompt', 'image'] : ['prompt'],
|
||||
)
|
||||
@@ -105,6 +149,75 @@ function reset() {
|
||||
bindings.value = []
|
||||
}
|
||||
|
||||
function onClosed() {
|
||||
reset()
|
||||
}
|
||||
|
||||
async function ensureParamMeta() {
|
||||
if (Object.keys(paramMetaByType.value).length > 0) return
|
||||
const res = await api.get('/workflows/param-meta')
|
||||
paramMetaByType.value = {
|
||||
txt2img: res.data.txt2img || [],
|
||||
img2img: res.data.img2img || [],
|
||||
}
|
||||
}
|
||||
|
||||
function systemDefault(param: string): string | number | undefined {
|
||||
const defs = paramMetaByType.value[form.value.workflow_type] || []
|
||||
return defs.find(d => d.name === param)?.default
|
||||
}
|
||||
|
||||
function parseBindingJSON(raw?: string): Record<string, NodeFieldBinding> {
|
||||
if (!raw) return {}
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function parseDefaultsJSON(raw?: string): Record<string, string | number> {
|
||||
if (!raw) return {}
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!parsed || typeof parsed !== 'object') return {}
|
||||
return parsed as Record<string, string | number>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function initialDefaultValue(
|
||||
param: string,
|
||||
paramType: 'string' | 'int' | 'float',
|
||||
saved: Record<string, string | number>,
|
||||
workflowDefault: unknown,
|
||||
): string | number | null {
|
||||
if (saved[param] !== undefined && saved[param] !== null) {
|
||||
return saved[param]
|
||||
}
|
||||
if (workflowDefault !== undefined && workflowDefault !== null && workflowDefault !== '') {
|
||||
if (paramType === 'string') return String(workflowDefault)
|
||||
if (paramType === 'int') return Number(workflowDefault)
|
||||
return Number(workflowDefault)
|
||||
}
|
||||
const builtin = systemDefault(param)
|
||||
if (builtin !== undefined) return builtin
|
||||
return paramType === 'string' ? '' : null
|
||||
}
|
||||
|
||||
function applySavedBindings(savedBindings: Record<string, NodeFieldBinding>) {
|
||||
for (const row of bindings.value) {
|
||||
const hit = savedBindings[row.param]
|
||||
if (hit?.node && hit?.field) {
|
||||
row.enabled = true
|
||||
row.node = hit.node
|
||||
row.field = hit.field
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fieldsFor(nodeId: string) {
|
||||
return nodes.value.find(n => n.id === nodeId)?.injectable_fields || []
|
||||
}
|
||||
@@ -118,25 +231,45 @@ async function onTypeChange() {
|
||||
if (form.value.workflow_json) await analyze()
|
||||
}
|
||||
|
||||
function snapshotState() {
|
||||
return {
|
||||
bindings: buildBindingMap(),
|
||||
defaults: buildDefaultsMap(),
|
||||
}
|
||||
}
|
||||
|
||||
async function analyze() {
|
||||
if (!form.value.workflow_json.trim()) {
|
||||
ElMessage.warning(t('providers.workflowJsonRequired'))
|
||||
return
|
||||
}
|
||||
const saved = snapshotState()
|
||||
analyzing.value = true
|
||||
try {
|
||||
await ensureParamMeta()
|
||||
const res = await api.post('/workflows/analyze', {
|
||||
workflow_json: form.value.workflow_json,
|
||||
workflow_type: form.value.workflow_type,
|
||||
})
|
||||
nodes.value = res.data.nodes || []
|
||||
bindings.value = (res.data.suggestions || []).map((s: any) => ({
|
||||
param: s.param,
|
||||
required: requiredParams.value.includes(s.param),
|
||||
enabled: requiredParams.value.includes(s.param) || !!(s.node && s.field),
|
||||
node: s.node || '',
|
||||
field: s.field || '',
|
||||
}))
|
||||
bindings.value = (res.data.suggestions || []).map((s: any) => {
|
||||
const paramType = (s.type || 'string') as 'string' | 'int' | 'float'
|
||||
return {
|
||||
param: s.param,
|
||||
paramType,
|
||||
required: requiredParams.value.includes(s.param),
|
||||
enabled: requiredParams.value.includes(s.param) || !!(s.node && s.field),
|
||||
node: s.node || '',
|
||||
field: s.field || '',
|
||||
defaultValue: initialDefaultValue(s.param, paramType, saved.defaults, s.default_from_workflow),
|
||||
}
|
||||
})
|
||||
applySavedBindings(saved.bindings)
|
||||
for (const row of bindings.value) {
|
||||
if (saved.defaults[row.param] !== undefined) {
|
||||
row.defaultValue = saved.defaults[row.param]
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.error || t('providers.analyzeFailed'))
|
||||
} finally {
|
||||
@@ -144,8 +277,8 @@ async function analyze() {
|
||||
}
|
||||
}
|
||||
|
||||
function buildBinding() {
|
||||
const out: Record<string, { node: string; field: string }> = {}
|
||||
function buildBindingMap() {
|
||||
const out: Record<string, NodeFieldBinding> = {}
|
||||
for (const row of bindings.value) {
|
||||
if (!row.enabled || !row.node || !row.field) continue
|
||||
out[row.param] = { node: row.node, field: row.field }
|
||||
@@ -153,20 +286,69 @@ function buildBinding() {
|
||||
return out
|
||||
}
|
||||
|
||||
function buildDefaultsMap() {
|
||||
const out: Record<string, string | number> = {}
|
||||
for (const row of bindings.value) {
|
||||
if (!row.enabled) continue
|
||||
if (row.defaultValue === null || row.defaultValue === '') continue
|
||||
if (row.paramType === 'int') {
|
||||
out[row.param] = Number(row.defaultValue)
|
||||
} else if (row.paramType === 'float') {
|
||||
out[row.param] = Number(row.defaultValue)
|
||||
} else {
|
||||
out[row.param] = String(row.defaultValue)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function loadModel(m: ModelEntry) {
|
||||
await ensureParamMeta()
|
||||
form.value = {
|
||||
model_id: m.model_id,
|
||||
display_name: m.display_name || m.model_id,
|
||||
workflow_type: (m.workflow_type as 'txt2img' | 'img2img') || 'txt2img',
|
||||
workflow_json: m.workflow_json || '',
|
||||
}
|
||||
nodes.value = []
|
||||
bindings.value = []
|
||||
const savedBindings = parseBindingJSON(m.input_binding_json)
|
||||
const savedDefaults = parseDefaultsJSON(m.input_defaults_json)
|
||||
if (form.value.workflow_json.trim()) {
|
||||
await analyze()
|
||||
applySavedBindings(savedBindings)
|
||||
for (const row of bindings.value) {
|
||||
if (savedDefaults[row.param] !== undefined) {
|
||||
row.defaultValue = savedDefaults[row.param]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.value.model_id.trim()) {
|
||||
ElMessage.warning(t('providers.ingressModelIdRequired'))
|
||||
return
|
||||
}
|
||||
if (!form.value.workflow_json.trim()) {
|
||||
ElMessage.warning(t('providers.workflowJsonRequired'))
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await api.post(`/providers/${props.providerId}/models`, {
|
||||
const payload = {
|
||||
model_id: form.value.model_id.trim(),
|
||||
display_name: form.value.display_name || form.value.model_id,
|
||||
workflow_type: form.value.workflow_type,
|
||||
workflow_json: form.value.workflow_json,
|
||||
input_binding: buildBinding(),
|
||||
})
|
||||
input_binding: buildBindingMap(),
|
||||
input_defaults: buildDefaultsMap(),
|
||||
}
|
||||
if (isEdit.value && props.model?.id) {
|
||||
await api.put(`/providers/${props.providerId}/models/${props.model.id}`, payload)
|
||||
} else {
|
||||
await api.post(`/providers/${props.providerId}/models`, payload)
|
||||
}
|
||||
ElMessage.success(t('providers.workflowSaved'))
|
||||
visible.value = false
|
||||
emit('saved')
|
||||
@@ -177,7 +359,13 @@ async function save() {
|
||||
}
|
||||
}
|
||||
|
||||
watch(visible, (v) => {
|
||||
if (v) reset()
|
||||
watch(visible, async (open) => {
|
||||
if (!open) return
|
||||
if (props.model?.id) {
|
||||
await loadModel(props.model)
|
||||
} else {
|
||||
reset()
|
||||
await ensureParamMeta()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -130,6 +130,23 @@ export default {
|
||||
modelAliasHint: 'Leave empty to use upstream Model ID at ingress',
|
||||
upstreamModelId: 'Upstream Model ID',
|
||||
editModelDialogTitle: 'Edit model',
|
||||
addWorkflowModel: 'Add Workflow Model',
|
||||
workflowDialogTitle: 'Register Workflow Model',
|
||||
editWorkflowDialogTitle: 'Edit Workflow Model',
|
||||
workflowType: 'Type',
|
||||
txt2img: 'Text to Image',
|
||||
img2img: 'Image to Image',
|
||||
workflowJson: 'Workflow JSON',
|
||||
analyzeWorkflow: 'Analyze Workflow',
|
||||
workflowJsonRequired: 'Paste Workflow JSON first',
|
||||
analyzeFailed: 'Analysis failed',
|
||||
workflowSaved: 'Workflow model saved',
|
||||
paramName: 'Parameter',
|
||||
paramDefault: 'Default',
|
||||
paramDefaultHint: 'Fields omitted from requests use configured defaults (including prompt); required fields without a default must still be sent by clients.',
|
||||
bindEnabled: 'Bind',
|
||||
targetNode: 'Target Node',
|
||||
targetField: 'Target Field',
|
||||
providerNameExists: 'Provider name already exists',
|
||||
modelAliasExists: 'Model alias already exists',
|
||||
modelIdRequired: 'Model ID is required',
|
||||
@@ -186,6 +203,13 @@ export default {
|
||||
confirmDeleteKey: 'Delete this key?',
|
||||
copied: 'Copied',
|
||||
ruleAdded: 'Rule added',
|
||||
protocol: 'Protocol',
|
||||
protocolText: 'Text (OpenAI)',
|
||||
protocolImage: 'Image (Replicate)',
|
||||
protocolHint: 'Text keys use /v1/chat/completions etc.; image keys use /v1/predictions and Replicate model listing',
|
||||
editKeyTitle: 'Edit Key',
|
||||
protocolUpdated: 'Key updated',
|
||||
apiDoc: 'API Docs',
|
||||
},
|
||||
logs: {
|
||||
title: 'Request Logs',
|
||||
@@ -244,4 +268,65 @@ export default {
|
||||
settingsSaved: 'Settings saved',
|
||||
passwordUpdated: 'Password updated',
|
||||
},
|
||||
apiDocs: {
|
||||
backToKeys: 'Back to Ingress Keys',
|
||||
authTitle: 'Authentication',
|
||||
authDesc: 'Send your ingress key (sk-lum-*) using either header:',
|
||||
baseUrl: 'Base URL',
|
||||
endpointsTitle: 'Endpoints',
|
||||
examplesTitle: 'Examples',
|
||||
method: 'Method',
|
||||
path: 'Path',
|
||||
summary: 'Summary',
|
||||
text: {
|
||||
title: 'Text API (OpenAI compatible)',
|
||||
description: 'For ingress keys with the Text protocol. Compatible with OpenAI SDKs.',
|
||||
note: 'Available endpoints depend on enabled egress endpoints. Empty whitelist means no provider/model restriction.',
|
||||
endpointRows: [
|
||||
{ method: 'GET', path: '/v1/models', summary: 'List models (OpenAI format)', status: 'Supported' },
|
||||
{ method: 'POST', path: '/v1/chat/completions', summary: 'Chat completions; SSE when stream: true', status: 'Supported' },
|
||||
{ method: 'POST', path: '/v1/responses', summary: 'OpenAI Responses API', status: 'Supported' },
|
||||
{ method: 'POST', path: '/v1/completions', summary: 'Legacy text completions', status: 'Supported' },
|
||||
{ method: 'POST', path: '/v1/embeddings', summary: 'Embeddings', status: 'Supported' },
|
||||
{ method: 'POST', path: '/v1/rerank', summary: 'Cohere-compatible rerank', status: 'Supported' },
|
||||
],
|
||||
exampleRows: [
|
||||
{
|
||||
title: 'List models',
|
||||
code: 'curl {baseURL}/models \\\n -H "Authorization: Bearer sk-lum-..."',
|
||||
},
|
||||
{
|
||||
title: 'Chat completion',
|
||||
code: 'curl {baseURL}/chat/completions \\\n -H "Authorization: Bearer sk-lum-..." \\\n -H "Content-Type: application/json" \\\n -d \'{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Hello"}]}\'',
|
||||
},
|
||||
{
|
||||
title: 'Embeddings',
|
||||
code: 'curl {baseURL}/embeddings \\\n -H "Authorization: Bearer sk-lum-..." \\\n -H "Content-Type: application/json" \\\n -d \'{"model":"text-embedding-3-small","input":"hello"}\'',
|
||||
},
|
||||
],
|
||||
},
|
||||
image: {
|
||||
title: 'Image API (Replicate compatible)',
|
||||
description: 'For ingress keys with the Image protocol. Async ComfyUI txt2img / img2img workflows.',
|
||||
note: 'Request field version accepts owner/name or short name (aligned with latest_version.id, owner, and name from the model list). See openapi_schema.components.schemas.Input for input fields. Outputs are signed URLs (/files/:id?exp=&sig=).',
|
||||
endpointRows: [
|
||||
{ method: 'GET', path: '/v1/models', summary: 'List workflow models (Replicate format)', status: 'Supported' },
|
||||
{ method: 'POST', path: '/v1/predictions', summary: 'Create async job; Prefer: wait for sync', status: 'Supported' },
|
||||
{ method: 'GET', path: '/v1/predictions', summary: 'List jobs (cursor pagination)', status: 'Supported' },
|
||||
{ method: 'GET', path: '/v1/predictions/:id', summary: 'Get job status and output', status: 'Supported' },
|
||||
{ method: 'POST', path: '/v1/predictions/:id/cancel', summary: 'Cancel a running job', status: 'Supported' },
|
||||
{ method: 'GET', path: '/v1/predictions/:id/stream', summary: 'SSE progress stream', status: 'Supported' },
|
||||
],
|
||||
exampleRows: [
|
||||
{
|
||||
title: 'List workflow models',
|
||||
code: 'curl {baseURL}/models \\\n -H "Authorization: Bearer sk-lum-..."',
|
||||
},
|
||||
{
|
||||
title: 'Create prediction',
|
||||
code: 'curl -X POST {baseURL}/predictions \\\n -H "Authorization: Bearer sk-lum-..." \\\n -H "Content-Type: application/json" \\\n -d \'{"version":"luminary/flux-txt2img","input":{"prompt":"a cat"}}\'',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@ export default {
|
||||
modelDeleted: '模型已删除',
|
||||
confirmDeleteModel: '确认删除此模型?',
|
||||
workflowDialogTitle: '注册 Workflow 模型',
|
||||
editWorkflowDialogTitle: '编辑 Workflow 模型',
|
||||
modelId: 'Model ID',
|
||||
ingressModelId: '入口 Model ID',
|
||||
ingressModelIdHint: '客户端在 /v1/predictions 的 version 字段使用此名称',
|
||||
@@ -164,6 +165,8 @@ export default {
|
||||
modelIdRequired: '请填写 Model ID',
|
||||
workflowSaved: 'Workflow 模型已保存',
|
||||
paramName: '参数',
|
||||
paramDefault: '默认值',
|
||||
paramDefaultHint: '未在请求中传入的字段将使用此处配置的默认值(含 prompt);未配置默认值的必填字段仍须由客户端提供。',
|
||||
bindEnabled: '绑定',
|
||||
targetNode: '目标节点',
|
||||
targetField: '目标字段',
|
||||
@@ -202,6 +205,13 @@ export default {
|
||||
confirmDeleteKey: '确认删除此密钥?',
|
||||
copied: '已复制',
|
||||
ruleAdded: '规则已添加',
|
||||
protocol: '协议',
|
||||
protocolText: '文本(OpenAI)',
|
||||
protocolImage: '图片(Replicate)',
|
||||
protocolHint: '文本密钥调用 /v1/chat/completions 等;图片密钥调用 /v1/predictions 与 Replicate 模型列表',
|
||||
editKeyTitle: '编辑密钥',
|
||||
protocolUpdated: '密钥已更新',
|
||||
apiDoc: '接口文档',
|
||||
},
|
||||
logs: {
|
||||
title: '请求日志',
|
||||
@@ -265,4 +275,65 @@ export default {
|
||||
settingsSaved: '系统设置已保存',
|
||||
passwordUpdated: '密码已更新',
|
||||
},
|
||||
apiDocs: {
|
||||
backToKeys: '返回入口管理',
|
||||
authTitle: '鉴权',
|
||||
authDesc: '请求头二选一携带入口密钥(sk-lum-*):',
|
||||
baseUrl: 'Base URL',
|
||||
endpointsTitle: '接口列表',
|
||||
examplesTitle: '请求示例',
|
||||
method: '方法',
|
||||
path: '路径',
|
||||
summary: '说明',
|
||||
text: {
|
||||
title: '文本 API 文档(OpenAI 兼容)',
|
||||
description: '适用于协议为「文本」的入口密钥,与 OpenAI SDK 兼容。',
|
||||
note: '实际可访问的接口取决于上游 Provider 在「出口管理」中启用的 endpoint。白名单为空表示不限制 Provider / 模型。',
|
||||
endpointRows: [
|
||||
{ method: 'GET', path: '/v1/models', summary: '列出可用模型(OpenAI 格式)', status: '已支持' },
|
||||
{ method: 'POST', path: '/v1/chat/completions', summary: '对话补全;stream: true 时 SSE 流式', status: '已支持' },
|
||||
{ method: 'POST', path: '/v1/responses', summary: 'OpenAI Responses API', status: '已支持' },
|
||||
{ method: 'POST', path: '/v1/completions', summary: '文本补全(Legacy)', status: '已支持' },
|
||||
{ method: 'POST', path: '/v1/embeddings', summary: '向量嵌入', status: '已支持' },
|
||||
{ method: 'POST', path: '/v1/rerank', summary: 'Cohere 兼容重排序', status: '已支持' },
|
||||
],
|
||||
exampleRows: [
|
||||
{
|
||||
title: '列出模型',
|
||||
code: 'curl {baseURL}/models \\\n -H "Authorization: Bearer sk-lum-..."',
|
||||
},
|
||||
{
|
||||
title: '对话补全',
|
||||
code: 'curl {baseURL}/chat/completions \\\n -H "Authorization: Bearer sk-lum-..." \\\n -H "Content-Type: application/json" \\\n -d \'{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Hello"}]}\'',
|
||||
},
|
||||
{
|
||||
title: 'Embeddings',
|
||||
code: 'curl {baseURL}/embeddings \\\n -H "Authorization: Bearer sk-lum-..." \\\n -H "Content-Type: application/json" \\\n -d \'{"model":"text-embedding-3-small","input":"hello"}\'',
|
||||
},
|
||||
],
|
||||
},
|
||||
image: {
|
||||
title: '图片 API 文档(Replicate 兼容)',
|
||||
description: '适用于协议为「图片」的入口密钥,用于 ComfyUI 文生图 / 图生图异步任务。',
|
||||
note: '请求字段 version 支持 owner/name 或短 name(与模型列表 latest_version.id、owner、name 一致)。input 字段见 openapi_schema.components.schemas.Input。任务完成后输出图像通过签名 URL 返回(/files/:id?exp=&sig=)。',
|
||||
endpointRows: [
|
||||
{ method: 'GET', path: '/v1/models', summary: '列出可用工作流模型(Replicate 格式)', status: '已支持' },
|
||||
{ method: 'POST', path: '/v1/predictions', summary: '创建异步任务;Prefer: wait 可同步等待', status: '已支持' },
|
||||
{ method: 'GET', path: '/v1/predictions', summary: '列出任务(cursor 分页)', status: '已支持' },
|
||||
{ method: 'GET', path: '/v1/predictions/:id', summary: '查询任务状态与输出', status: '已支持' },
|
||||
{ method: 'POST', path: '/v1/predictions/:id/cancel', summary: '取消进行中的任务', status: '已支持' },
|
||||
{ method: 'GET', path: '/v1/predictions/:id/stream', summary: 'SSE 流式推送任务进度', status: '已支持' },
|
||||
],
|
||||
exampleRows: [
|
||||
{
|
||||
title: '列出工作流模型',
|
||||
code: 'curl {baseURL}/models \\\n -H "Authorization: Bearer sk-lum-..."',
|
||||
},
|
||||
{
|
||||
title: '创建 Predictions 任务',
|
||||
code: 'curl -X POST {baseURL}/predictions \\\n -H "Authorization: Bearer sk-lum-..." \\\n -H "Content-Type: application/json" \\\n -d \'{"version":"luminary/flux-txt2img","input":{"prompt":"a cat"}}\'',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ const titleMap: Record<string, string> = {
|
||||
'/dashboard': 'nav.dashboard',
|
||||
'/egress/providers': 'nav.providers',
|
||||
'/ingress/keys': 'nav.ingress',
|
||||
'/ingress/docs/text': 'apiDocs.text.title',
|
||||
'/ingress/docs/image': 'apiDocs.image.title',
|
||||
'/logs': 'nav.logs',
|
||||
'/security/ip-rules': 'nav.ipRules',
|
||||
'/security/settings': 'nav.settings',
|
||||
@@ -87,6 +89,7 @@ const currentTitle = computed(() => {
|
||||
})
|
||||
|
||||
function isActive(path: string) {
|
||||
if (path === '/ingress/keys' && route.path.startsWith('/ingress/')) return true
|
||||
return route.path === path || route.path.startsWith(path + '/')
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,18 @@ const router = createRouter({
|
||||
{ path: 'dashboard', name: 'dashboard', component: () => import('@/views/DashboardView.vue') },
|
||||
{ path: 'egress/providers', name: 'providers', component: () => import('@/views/ProvidersView.vue') },
|
||||
{ path: 'ingress/keys', name: 'ingress-keys', component: () => import('@/views/IngressKeysView.vue') },
|
||||
{
|
||||
path: 'ingress/docs/text',
|
||||
name: 'ingress-api-doc-text',
|
||||
component: () => import('@/views/IngressApiDocView.vue'),
|
||||
props: { protocol: 'text' },
|
||||
},
|
||||
{
|
||||
path: 'ingress/docs/image',
|
||||
name: 'ingress-api-doc-image',
|
||||
component: () => import('@/views/IngressApiDocView.vue'),
|
||||
props: { protocol: 'image' },
|
||||
},
|
||||
{ path: 'logs', name: 'logs', component: () => import('@/views/RequestLogsView.vue') },
|
||||
{ path: 'security/ip-rules', name: 'ip-rules', component: () => import('@/views/IPRulesView.vue') },
|
||||
{ path: 'security/settings', name: 'settings', component: () => import('@/views/SettingsView.vue') },
|
||||
|
||||
@@ -588,7 +588,40 @@ code.inline-code {
|
||||
margin-left: var(--space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.app-card__body--padded .card-inset-block {
|
||||
margin: 0 0 var(--space-4);
|
||||
}
|
||||
|
||||
.app-card__body--padded pre.card-inset-block {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
border: 1px solid var(--border);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.app-card__body--padded .card-inset-block:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.page-form--docs {
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.method-tag {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.doc-lead {
|
||||
margin: 0 0 var(--space-4);
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.stat-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.content-grid--2-1,
|
||||
.content-grid--2,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<PageHeader :title="doc.title" :description="doc.description">
|
||||
<template #actions>
|
||||
<el-button @click="router.push('/ingress/keys')">{{ t('apiDocs.backToKeys') }}</el-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="page-form page-form--docs">
|
||||
<AppCard :title="t('apiDocs.authTitle')" padded>
|
||||
<p class="doc-lead">{{ t('apiDocs.authDesc') }}</p>
|
||||
<pre class="card-inset-block">{{ authExample }}</pre>
|
||||
<div class="kv-block u-mt-4">
|
||||
<p>
|
||||
<span class="label">{{ t('apiDocs.baseUrl') }}</span>
|
||||
<code>{{ baseURL }}</code>
|
||||
</p>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard :title="t('apiDocs.endpointsTitle')">
|
||||
<el-table :data="doc.endpoints" size="small">
|
||||
<el-table-column :label="t('apiDocs.method')" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="methodTagType(row.method)" size="small" class="method-tag">{{ row.method }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="path" :label="t('apiDocs.path')" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<code class="inline-code">{{ row.path }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="summary" :label="t('apiDocs.summary')" min-width="240" />
|
||||
<el-table-column :label="t('common.status')" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="success" size="small">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<p v-if="doc.note" class="card-footer-hint form-hint">{{ doc.note }}</p>
|
||||
</AppCard>
|
||||
|
||||
<AppCard :title="t('apiDocs.examplesTitle')" padded>
|
||||
<template v-for="(example, idx) in doc.examples" :key="idx">
|
||||
<div class="section-title">{{ example.title }}</div>
|
||||
<pre class="card-inset-block" :class="{ 'u-mb-4': idx < doc.examples.length - 1 }">{{ example.code }}</pre>
|
||||
</template>
|
||||
</AppCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AppCard from '@/components/AppCard.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
protocol: 'text' | 'image'
|
||||
}>()
|
||||
|
||||
const { t, tm } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const host = window.location.origin.replace(/\/$/, '')
|
||||
const baseURL = `${host}/v1`
|
||||
const authExample = `Authorization: Bearer sk-lum-<your-key>\n# or\nX-Api-Key: sk-lum-<your-key>`
|
||||
|
||||
interface EndpointRow {
|
||||
method: string
|
||||
path: string
|
||||
summary: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface ExampleRow {
|
||||
title: string
|
||||
code: string
|
||||
}
|
||||
|
||||
const doc = computed(() => {
|
||||
const key = props.protocol
|
||||
const section = tm(`apiDocs.${key}`) as {
|
||||
title: string
|
||||
description: string
|
||||
note: string
|
||||
endpointRows: EndpointRow[]
|
||||
exampleRows: ExampleRow[]
|
||||
}
|
||||
return {
|
||||
title: t(`apiDocs.${key}.title`),
|
||||
description: t(`apiDocs.${key}.description`),
|
||||
note: t(`apiDocs.${key}.note`),
|
||||
endpoints: section.endpointRows ?? [],
|
||||
examples: (section.exampleRows ?? []).map(row => ({
|
||||
title: row.title,
|
||||
code: row.code.replaceAll('{baseURL}', baseURL).replaceAll('{host}', host),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
function methodTagType(method: string) {
|
||||
switch (method.toUpperCase()) {
|
||||
case 'GET':
|
||||
return 'success'
|
||||
case 'POST':
|
||||
return 'primary'
|
||||
case 'PUT':
|
||||
return 'warning'
|
||||
case 'DELETE':
|
||||
return 'danger'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -13,6 +13,13 @@
|
||||
<el-table :data="keys" v-loading="loading" size="small">
|
||||
<el-table-column prop="name" :label="t('common.name')" min-width="120" />
|
||||
<el-table-column prop="prefix" :label="t('ingress.keyPrefix')" width="140" />
|
||||
<el-table-column :label="t('ingress.protocol')" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.protocol === 'image' ? 'warning' : 'primary'" size="small">
|
||||
{{ row.protocol === 'image' ? t('ingress.protocolImage') : t('ingress.protocolText') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="enabled" :label="t('common.status')" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? t('common.enabled') : t('common.disabled') }}</el-tag>
|
||||
@@ -31,10 +38,11 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="request_count" :label="t('dashboard.requestCount')" width="90" />
|
||||
<el-table-column prop="token_count" :label="t('common.tokens')" width="100" />
|
||||
<el-table-column :label="t('common.actions')" width="240" fixed="right">
|
||||
<el-table-column :label="t('common.actions')" width="320" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link @click="openWhitelist(row)">{{ t('ingress.whitelistAction') }}</el-button>
|
||||
<el-button size="small" link @click="openRouting(row)">{{ t('ingress.routingAction') }}</el-button>
|
||||
<el-button size="small" link @click="openApiDoc(row)">{{ t('ingress.apiDoc') }}</el-button>
|
||||
<el-button size="small" link @click="openEdit(row)">{{ t('common.edit') }}</el-button>
|
||||
<el-button v-if="isTextKey(row)" size="small" link @click="openRouting(row)">{{ t('ingress.routingAction') }}</el-button>
|
||||
<el-button size="small" link @click="toggle(row)">{{ row.enabled ? t('common.disabled') : t('common.enabled') }}</el-button>
|
||||
<el-button size="small" type="danger" link @click="remove(row)">{{ t('common.delete') }}</el-button>
|
||||
</template>
|
||||
@@ -45,11 +53,19 @@
|
||||
<el-dialog v-model="showCreate" :title="t('ingress.createDialogTitle')" width="560px" destroy-on-close @open="loadCatalog">
|
||||
<el-form label-width="120px">
|
||||
<el-form-item :label="t('common.name')"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item :label="t('ingress.protocol')">
|
||||
<el-radio-group v-model="form.protocol" @change="onProtocolChange">
|
||||
<el-radio value="text">{{ t('ingress.protocolText') }}</el-radio>
|
||||
<el-radio value="image">{{ t('ingress.protocolImage') }}</el-radio>
|
||||
</el-radio-group>
|
||||
<div class="form-hint">{{ t('ingress.protocolHint') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('ingress.budgetLimit')"><el-input-number v-model="form.budget_limit" :min="0" :precision="4" class="w-full" /></el-form-item>
|
||||
<el-form-item :label="t('ingress.rpmLimit')"><el-input-number v-model="form.rate_limit_rpm" :min="0" class="w-full" /></el-form-item>
|
||||
<el-form-item :label="t('ingress.tpmLimit')"><el-input-number v-model="form.rate_limit_tpm" :min="0" class="w-full" /></el-form-item>
|
||||
<el-form-item :label="t('ingress.providerWhitelist')">
|
||||
<el-select
|
||||
:key="`create-providers-${form.protocol}`"
|
||||
v-model="form.allowed_providers"
|
||||
multiple
|
||||
filterable
|
||||
@@ -58,12 +74,13 @@
|
||||
collapse-tags-tooltip
|
||||
:placeholder="t('ingress.providerWhitelistPlaceholder')"
|
||||
>
|
||||
<el-option v-for="p in catalogProviders" :key="p.name" :label="p.name" :value="p.name" />
|
||||
<el-option v-for="p in createProviderOptions" :key="p.name" :label="p.name" :value="p.name" />
|
||||
</el-select>
|
||||
<div class="form-hint">{{ t('ingress.whitelistHint') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('ingress.modelWhitelist')">
|
||||
<el-select
|
||||
:key="`create-models-${form.protocol}-${form.allowed_providers.join(',')}`"
|
||||
v-model="form.allowed_models"
|
||||
multiple
|
||||
filterable
|
||||
@@ -73,7 +90,7 @@
|
||||
:placeholder="t('ingress.modelWhitelistPlaceholder')"
|
||||
>
|
||||
<el-option
|
||||
v-for="m in filteredModelOptions(form.allowed_providers)"
|
||||
v-for="m in createModelOptions"
|
||||
:key="m.model_id + '@' + m.provider_name"
|
||||
:label="m.label"
|
||||
:value="m.model_id"
|
||||
@@ -88,11 +105,19 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="showWhitelist" :title="t('ingress.editWhitelistTitle')" width="560px" destroy-on-close>
|
||||
<el-dialog v-model="showEdit" :title="t('ingress.editKeyTitle')" width="560px" destroy-on-close @open="loadCatalogForEdit">
|
||||
<el-form label-width="120px">
|
||||
<el-form-item :label="t('ingress.protocol')">
|
||||
<el-radio-group v-model="editForm.protocol" @change="onEditProtocolChange">
|
||||
<el-radio value="text">{{ t('ingress.protocolText') }}</el-radio>
|
||||
<el-radio value="image">{{ t('ingress.protocolImage') }}</el-radio>
|
||||
</el-radio-group>
|
||||
<div class="form-hint">{{ t('ingress.protocolHint') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('ingress.providerWhitelist')">
|
||||
<el-select
|
||||
v-model="whitelistForm.allowed_providers"
|
||||
:key="`edit-providers-${editForm.protocol}`"
|
||||
v-model="editForm.allowed_providers"
|
||||
multiple
|
||||
filterable
|
||||
clearable
|
||||
@@ -100,12 +125,13 @@
|
||||
collapse-tags-tooltip
|
||||
:placeholder="t('ingress.providerWhitelistPlaceholder')"
|
||||
>
|
||||
<el-option v-for="p in catalogProviders" :key="p.name" :label="p.name" :value="p.name" />
|
||||
<el-option v-for="p in editProviderOptions" :key="p.name" :label="p.name" :value="p.name" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('ingress.modelWhitelist')">
|
||||
<el-select
|
||||
v-model="whitelistForm.allowed_models"
|
||||
:key="`edit-models-${editForm.protocol}-${editForm.allowed_providers.join(',')}`"
|
||||
v-model="editForm.allowed_models"
|
||||
multiple
|
||||
filterable
|
||||
clearable
|
||||
@@ -114,7 +140,7 @@
|
||||
:placeholder="t('ingress.modelWhitelistPlaceholder')"
|
||||
>
|
||||
<el-option
|
||||
v-for="m in filteredModelOptions(whitelistForm.allowed_providers)"
|
||||
v-for="m in editModelOptions"
|
||||
:key="m.model_id + '@' + m.provider_name"
|
||||
:label="m.label"
|
||||
:value="m.model_id"
|
||||
@@ -123,8 +149,8 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showWhitelist = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" @click="saveWhitelist">{{ t('common.save') }}</el-button>
|
||||
<el-button @click="showEdit = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" @click="saveEdit">{{ t('common.save') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -204,23 +230,25 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import api, { type IngressKey, type Provider, type ProviderKey } from '@/api/client'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AppCard from '@/components/AppCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
interface CatalogProvider { id: number; name: string }
|
||||
interface CatalogProvider { id: number; name: string; category: string }
|
||||
interface RoutingProvider { id: number; name: string; keys: { id: number; name: string }[] }
|
||||
interface ModelOption { model_id: string; display_name: string; provider_name: string; label: string }
|
||||
interface ModelOption { model_id: string; display_name: string; provider_name: string; provider_category: string; label: string }
|
||||
|
||||
const keys = ref<IngressKey[]>([])
|
||||
const loading = ref(false)
|
||||
const showCreate = ref(false)
|
||||
const showWhitelist = ref(false)
|
||||
const showEdit = ref(false)
|
||||
const showPlainKey = ref(false)
|
||||
const plainKey = ref('')
|
||||
const routingVisible = ref(false)
|
||||
@@ -238,17 +266,51 @@ const catalogProviders = ref<CatalogProvider[]>([])
|
||||
const catalogModels = ref<ModelOption[]>([])
|
||||
const form = ref({
|
||||
name: '',
|
||||
protocol: 'text' as 'text' | 'image',
|
||||
budget_limit: 0,
|
||||
rate_limit_rpm: 0,
|
||||
rate_limit_tpm: 0,
|
||||
allowed_providers: [] as string[],
|
||||
allowed_models: [] as string[],
|
||||
})
|
||||
const whitelistForm = ref({
|
||||
const editForm = ref({
|
||||
protocol: 'text' as 'text' | 'image',
|
||||
allowed_providers: [] as string[],
|
||||
allowed_models: [] as string[],
|
||||
})
|
||||
|
||||
const createProviderOptions = computed(() => catalogProvidersFor(form.value.protocol))
|
||||
const editProviderOptions = computed(() => catalogProvidersFor(editForm.value.protocol))
|
||||
|
||||
const createModelOptions = computed(() =>
|
||||
filteredModelOptions(form.value.allowed_providers, form.value.protocol),
|
||||
)
|
||||
const editModelOptions = computed(() =>
|
||||
filteredModelOptions(editForm.value.allowed_providers, editForm.value.protocol),
|
||||
)
|
||||
|
||||
watch(() => form.value.protocol, (protocol) => {
|
||||
const pruned = pruneWhitelistSelections(protocol, form.value.allowed_providers, form.value.allowed_models)
|
||||
form.value.allowed_providers = pruned.allowed_providers
|
||||
form.value.allowed_models = pruned.allowed_models
|
||||
})
|
||||
|
||||
watch(() => editForm.value.protocol, (protocol) => {
|
||||
const pruned = pruneWhitelistSelections(protocol, editForm.value.allowed_providers, editForm.value.allowed_models)
|
||||
editForm.value.allowed_providers = pruned.allowed_providers
|
||||
editForm.value.allowed_models = pruned.allowed_models
|
||||
})
|
||||
|
||||
watch(() => form.value.allowed_providers, (providers) => {
|
||||
const valid = new Set(filteredModelOptions(providers, form.value.protocol).map(m => m.model_id))
|
||||
form.value.allowed_models = form.value.allowed_models.filter(id => valid.has(id))
|
||||
})
|
||||
|
||||
watch(() => editForm.value.allowed_providers, (providers) => {
|
||||
const valid = new Set(filteredModelOptions(providers, editForm.value.protocol).map(m => m.model_id))
|
||||
editForm.value.allowed_models = editForm.value.allowed_models.filter(id => valid.has(id))
|
||||
})
|
||||
|
||||
const keysForRuleProvider = computed(() => {
|
||||
if (!ruleForm.value.provider_id) return []
|
||||
return routingProviders.value.find(p => p.id === ruleForm.value.provider_id)?.keys ?? []
|
||||
@@ -297,15 +359,54 @@ function selectionToPayload(list: string[]) {
|
||||
return list.length === 0 ? [] : list
|
||||
}
|
||||
|
||||
function filteredModelOptions(selectedProviders: string[]) {
|
||||
if (isAllowAll(selectedProviders)) return catalogModels.value
|
||||
return catalogModels.value.filter(m => selectedProviders.includes(m.provider_name))
|
||||
function isTextKey(row: IngressKey) {
|
||||
return (row.protocol || 'text') === 'text'
|
||||
}
|
||||
|
||||
function providerMatchesProtocol(category: string, protocol: 'text' | 'image') {
|
||||
return protocol === 'image' ? category === 'image' : category !== 'image'
|
||||
}
|
||||
|
||||
function catalogProvidersFor(protocol: 'text' | 'image') {
|
||||
return catalogProviders.value.filter(p => providerMatchesProtocol(p.category, protocol))
|
||||
}
|
||||
|
||||
function pruneWhitelistSelections(
|
||||
protocol: 'text' | 'image',
|
||||
providers: string[],
|
||||
models: string[],
|
||||
) {
|
||||
const validProviders = new Set(catalogProvidersFor(protocol).map(p => p.name))
|
||||
const nextProviders = providers.filter(name => validProviders.has(name))
|
||||
const validModels = new Set(filteredModelOptions(nextProviders, protocol).map(m => m.model_id))
|
||||
return {
|
||||
allowed_providers: nextProviders,
|
||||
allowed_models: models.filter(id => validModels.has(id)),
|
||||
}
|
||||
}
|
||||
|
||||
function onProtocolChange() {
|
||||
const pruned = pruneWhitelistSelections(form.value.protocol, form.value.allowed_providers, form.value.allowed_models)
|
||||
form.value.allowed_providers = pruned.allowed_providers
|
||||
form.value.allowed_models = pruned.allowed_models
|
||||
}
|
||||
|
||||
function onEditProtocolChange() {
|
||||
const pruned = pruneWhitelistSelections(editForm.value.protocol, editForm.value.allowed_providers, editForm.value.allowed_models)
|
||||
editForm.value.allowed_providers = pruned.allowed_providers
|
||||
editForm.value.allowed_models = pruned.allowed_models
|
||||
}
|
||||
|
||||
function filteredModelOptions(selectedProviders: string[], protocol: 'text' | 'image' = 'text') {
|
||||
const models = catalogModels.value.filter(m => providerMatchesProtocol(m.provider_category, protocol))
|
||||
if (isAllowAll(selectedProviders)) return models
|
||||
return models.filter(m => selectedProviders.includes(m.provider_name))
|
||||
}
|
||||
|
||||
async function loadCatalog() {
|
||||
const res = await api.get('/providers')
|
||||
const providers: Provider[] = res.data.data
|
||||
catalogProviders.value = providers.map(p => ({ id: p.id, name: p.name }))
|
||||
catalogProviders.value = providers.map(p => ({ id: p.id, name: p.name, category: p.category }))
|
||||
const models: ModelOption[] = []
|
||||
for (const p of providers) {
|
||||
for (const m of p.models || []) {
|
||||
@@ -315,6 +416,7 @@ async function loadCatalog() {
|
||||
model_id: ingressId,
|
||||
display_name: m.display_name || ingressId,
|
||||
provider_name: p.name,
|
||||
provider_category: p.category,
|
||||
label: `${ingressId} (${p.name})`,
|
||||
})
|
||||
}
|
||||
@@ -322,10 +424,13 @@ async function loadCatalog() {
|
||||
catalogModels.value = models
|
||||
}
|
||||
|
||||
async function loadCatalogForEdit() {
|
||||
await loadCatalog()
|
||||
}
|
||||
|
||||
async function loadRoutingCatalog() {
|
||||
const res = await api.get('/providers')
|
||||
const providers: Provider[] = res.data.data ?? []
|
||||
catalogProviders.value = providers.map(p => ({ id: p.id, name: p.name }))
|
||||
const providers: Provider[] = (res.data.data ?? []).filter(p => p.category !== 'image')
|
||||
routingProviders.value = await Promise.all(providers.map(async (p) => {
|
||||
const keysRes = await api.get(`/providers/${p.id}/keys`)
|
||||
const keys = (keysRes.data.data as ProviderKey[] || [])
|
||||
@@ -348,6 +453,7 @@ async function load() {
|
||||
function openCreate() {
|
||||
form.value = {
|
||||
name: '',
|
||||
protocol: 'text',
|
||||
budget_limit: 0,
|
||||
rate_limit_rpm: 0,
|
||||
rate_limit_tpm: 0,
|
||||
@@ -360,6 +466,7 @@ function openCreate() {
|
||||
async function create() {
|
||||
const res = await api.post('/ingress-keys', {
|
||||
name: form.value.name,
|
||||
protocol: form.value.protocol,
|
||||
budget_limit: form.value.budget_limit,
|
||||
rate_limit_rpm: form.value.rate_limit_rpm,
|
||||
rate_limit_tpm: form.value.rate_limit_tpm,
|
||||
@@ -372,23 +479,30 @@ async function create() {
|
||||
load()
|
||||
}
|
||||
|
||||
function openWhitelist(row: IngressKey) {
|
||||
function openApiDoc(row: IngressKey) {
|
||||
const protocol = row.protocol === 'image' ? 'image' : 'text'
|
||||
router.push(protocol === 'image' ? '/ingress/docs/image' : '/ingress/docs/text')
|
||||
}
|
||||
|
||||
function openEdit(row: IngressKey) {
|
||||
current.value = row
|
||||
whitelistForm.value = {
|
||||
editForm.value = {
|
||||
protocol: row.protocol || 'text',
|
||||
allowed_providers: jsonListToSelection(row.allowed_providers_json),
|
||||
allowed_models: jsonListToSelection(row.allowed_models_json),
|
||||
}
|
||||
showWhitelist.value = true
|
||||
showEdit.value = true
|
||||
}
|
||||
|
||||
async function saveWhitelist() {
|
||||
async function saveEdit() {
|
||||
if (!current.value) return
|
||||
await api.put(`/ingress-keys/${current.value.id}`, {
|
||||
allowed_providers: selectionToPayload(whitelistForm.value.allowed_providers),
|
||||
allowed_models: selectionToPayload(whitelistForm.value.allowed_models),
|
||||
protocol: editForm.value.protocol,
|
||||
allowed_providers: selectionToPayload(editForm.value.allowed_providers),
|
||||
allowed_models: selectionToPayload(editForm.value.allowed_models),
|
||||
})
|
||||
showWhitelist.value = false
|
||||
ElMessage.success(t('ingress.whitelistUpdated'))
|
||||
showEdit.value = false
|
||||
ElMessage.success(t('ingress.protocolUpdated'))
|
||||
load()
|
||||
}
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
|
||||
<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">
|
||||
<el-button v-if="current?.category === 'image'" type="primary" size="small" @click="openAddWorkflowModel">
|
||||
{{ t('providers.addWorkflowModel') }}
|
||||
</el-button>
|
||||
<template v-else-if="current">
|
||||
@@ -255,21 +255,13 @@
|
||||
|
||||
<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.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>
|
||||
<el-form-item :label="t('providers.displayName')">
|
||||
<el-input v-model="editModelForm.display_name" />
|
||||
</el-form-item>
|
||||
@@ -284,6 +276,7 @@
|
||||
v-if="current"
|
||||
v-model="showWorkflowDialog"
|
||||
:provider-id="current.id"
|
||||
:model="editingWorkflowModel"
|
||||
@saved="current && openModels(current)"
|
||||
/>
|
||||
</div>
|
||||
@@ -337,6 +330,7 @@ const enabledProviderModelCount = computed(() =>
|
||||
providerModels.value.filter(m => m.enabled).length,
|
||||
)
|
||||
const showWorkflowDialog = ref(false)
|
||||
const editingWorkflowModel = ref<ModelEntry | null>(null)
|
||||
const showAddModel = ref(false)
|
||||
const showEditModel = ref(false)
|
||||
const addModelForm = ref({ model_id: '', alias: '', display_name: '' })
|
||||
@@ -665,12 +659,21 @@ function modelIngressId(m: ModelEntry) {
|
||||
return m.alias || m.model_id
|
||||
}
|
||||
|
||||
function openAddWorkflowModel() {
|
||||
editingWorkflowModel.value = null
|
||||
showWorkflowDialog.value = true
|
||||
}
|
||||
|
||||
function openEditModel(m: ModelEntry) {
|
||||
const isImage = current.value?.category === 'image'
|
||||
if (current.value?.category === 'image') {
|
||||
editingWorkflowModel.value = m
|
||||
showWorkflowDialog.value = true
|
||||
return
|
||||
}
|
||||
editModelForm.value = {
|
||||
id: m.id,
|
||||
model_id: isImage ? modelIngressId(m) : m.model_id,
|
||||
alias: isImage ? '' : (m.alias || ''),
|
||||
model_id: m.model_id,
|
||||
alias: m.alias || '',
|
||||
display_name: m.display_name || '',
|
||||
}
|
||||
showEditModel.value = true
|
||||
@@ -678,11 +681,10 @@ function openEditModel(m: ModelEntry) {
|
||||
|
||||
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() }),
|
||||
alias: editModelForm.value.alias.trim(),
|
||||
display_name: editModelForm.value.display_name.trim(),
|
||||
})
|
||||
} catch (e: any) {
|
||||
|
||||
Reference in New Issue
Block a user