Split ingress keys into text/image protocols with Replicate model listing.
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:
renjue
2026-06-29 21:41:06 +08:00
parent 2665ac1dee
commit 321683f863
35 changed files with 1646 additions and 167 deletions
+2 -2
View File
@@ -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>
+2
View File
@@ -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
+210 -22
View File
@@ -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>
+85
View File
@@ -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"}}\'',
},
],
},
},
}
+71
View File
@@ -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"}}\'',
},
],
},
},
}
+3
View File
@@ -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 + '/')
}
+12
View File
@@ -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') },
+34 -1
View File
@@ -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,
+118
View File
@@ -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>
+145 -31
View File
@@ -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()
}
+23 -21
View File
@@ -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) {