feat: 自定义 OpenAI 兼容上游聚合 + 权重/并发调度
- custom provider:把任意 v1 端点当账号(base_url+key),按 model id 路由, 直连不走代理;支持 images/generations、images/edits、Sora 式异步视频 - 调度器全局权重优先 + 每账号并发感知(上游账号可配并发,其余系统固定) - 后台:添加/编辑上游、自定义模型表单(每档普通/代理价,留空=不支持)、 账号权重/并发列与导入权重 - grok-video / nano-banana-2(runway) 写入硬编码目录(开源自带); 去掉 540p,Adobe 视频最低 720p - UI:SelectMenu 与输入框等高、测试耗时改用秒 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import Icon from './Icon.vue'
|
||||
import SelectMenu from './SelectMenu.vue'
|
||||
|
||||
const emit = defineEmits(['close', 'saved'])
|
||||
|
||||
const RATIO_OPTS = ['1:1', '16:9', '9:16', '4:3', '3:4', '21:9', '3:2', '5:4', '4:5', '2:3', '2:1']
|
||||
const IMG_RES = ['1K', '2K', '4K']
|
||||
const VID_RES = ['720p', '1080p', '2K', '4K']
|
||||
const ALL_RES = ['1K', '2K', '4K', '720p', '1080p']
|
||||
const DUR_OPTS = ['5s', '6s', '8s', '10s']
|
||||
|
||||
const id = ref('')
|
||||
const type = ref('image')
|
||||
const ratios = ref(['1:1', '16:9', '9:16'])
|
||||
const maxRefs = ref(0)
|
||||
const refMode = ref('none')
|
||||
const weight = ref(0)
|
||||
// tier -> { price, agent } — blank price means the tier is NOT supported.
|
||||
const res = ref(Object.fromEntries(ALL_RES.map((r) => [r, { price: '', agent: '' }])))
|
||||
const dur = ref(Object.fromEntries(DUR_OPTS.map((d) => [d, { price: '', agent: '' }])))
|
||||
const error = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
const isVideo = computed(() => type.value === 'video')
|
||||
// Resolution tiers depend on type: image = 1K/2K/4K, video = 540p/720p/1080p/2K/4K.
|
||||
const resOpts = computed(() => (isVideo.value ? VID_RES : IMG_RES))
|
||||
|
||||
// 首尾帧(frame) only has first+last slots → cap reference images at 2.
|
||||
const refsCap = computed(() => (refMode.value === 'frame' ? 2 : 99))
|
||||
watch([refMode, maxRefs], () => {
|
||||
if (refMode.value === 'frame' && Number(maxRefs.value) > 2) maxRefs.value = 2
|
||||
})
|
||||
|
||||
function toggleRatio(r) {
|
||||
const i = ratios.value.indexOf(r)
|
||||
if (i >= 0) ratios.value.splice(i, 1)
|
||||
else ratios.value.push(r)
|
||||
}
|
||||
|
||||
// collect checked tiers into { tier: price } and { tier: agentPrice }
|
||||
// blank price = tier not supported (skipped), matching the edit form.
|
||||
function collect(tiers, allowed) {
|
||||
const prices = {}, agent = {}, keys = []
|
||||
for (const [k, v] of Object.entries(tiers)) {
|
||||
if (allowed && !allowed.includes(k)) continue
|
||||
const raw = String(v.price ?? '').trim()
|
||||
if (raw === '') continue
|
||||
const n = Number(raw)
|
||||
if (Number.isNaN(n) || n < 0) continue
|
||||
prices[k] = n; keys.push(k)
|
||||
const ar = String(v.agent ?? '').trim()
|
||||
if (ar !== '') { const a = Number(ar); if (!Number.isNaN(a) && a >= 0) agent[k] = a }
|
||||
}
|
||||
return { prices, agent, keys }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const mid = id.value.trim()
|
||||
if (!mid) { error.value = '请填写模型 id'; return }
|
||||
if (refMode.value === 'frame' && Number(maxRefs.value) > 2) maxRefs.value = 2
|
||||
const r = collect(res.value, resOpts.value)
|
||||
if (!r.keys.length) { error.value = '请至少勾选一个分辨率并填价格'; return }
|
||||
const body = {
|
||||
id: mid,
|
||||
name: mid,
|
||||
type: type.value,
|
||||
provider: 'custom',
|
||||
prices: r.prices,
|
||||
prices_agent: r.agent,
|
||||
ratios: ratios.value.slice(),
|
||||
max_reference_images: Number(maxRefs.value) || 0,
|
||||
reference_mode: refMode.value,
|
||||
weight: Number(weight.value) || 0,
|
||||
image_to_image: (Number(maxRefs.value) || 0) > 0,
|
||||
}
|
||||
if (isVideo.value) {
|
||||
body.resolutions = r.keys
|
||||
const d = collect(dur.value)
|
||||
if (!d.keys.length) { error.value = '视频请至少勾选一个时长并填价格'; return }
|
||||
body.duration_prices = d.prices
|
||||
body.duration_prices_agent = d.agent
|
||||
body.durations = d.keys
|
||||
}
|
||||
saving.value = true; error.value = ''
|
||||
try {
|
||||
const resp = await api('/managed-models', jsonBody('POST', body))
|
||||
if (resp.ok || resp.data?.ok || resp.status === 200) emit('saved')
|
||||
else error.value = resp.data?.detail || '创建失败'
|
||||
} catch (e) { error.value = String(e) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
|
||||
@click.self="emit('close')">
|
||||
<div class="card !shadow-xl mt-10 mb-10 w-full max-w-xl">
|
||||
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">添加自定义模型(上游 / provider=custom)</h2>
|
||||
<button @click="emit('close')" class="text-slate-400 hover:text-slate-700"><Icon name="close" class="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<p class="text-xs text-slate-500 leading-relaxed">
|
||||
id 要与上游模型名<strong class="text-slate-700">一致</strong> —— 生成时按 id 自动路由到「支持该 id 的上游账号」。价格按本地价计费。
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-slate-500 block mb-1">模型 id <span class="text-rose-500">*</span></label>
|
||||
<input v-model="id" class="field font-mono text-xs h-10" placeholder="gpt-image-2" />
|
||||
</div>
|
||||
<div class="w-28">
|
||||
<label class="text-xs text-slate-500 block mb-1">类型</label>
|
||||
<SelectMenu v-model="type" :options="[{value:'image',label:'图像'},{value:'video',label:'视频'}]" />
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<label class="text-xs text-slate-500 block mb-1">权重</label>
|
||||
<input v-model.number="weight" type="number" class="field h-10" placeholder="0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 block mb-1.5">比例(多选)</label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button v-for="r in RATIO_OPTS" :key="r" type="button" @click="toggleRatio(r)"
|
||||
class="px-2 py-1 rounded text-xs ring-1 transition-colors"
|
||||
:class="ratios.includes(r) ? 'bg-indigo-500/15 text-indigo-600 ring-indigo-300' : 'bg-slate-50 text-slate-500 ring-slate-200 hover:ring-slate-300'">{{ r }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 block mb-1.5">分辨率 · 价格(填普通价 = 支持该档,<strong class="text-slate-600">留空 = 不支持</strong>)</label>
|
||||
<div class="space-y-1.5">
|
||||
<div v-for="r in resOpts" :key="r" class="flex items-center gap-2">
|
||||
<span class="w-16 text-xs font-mono text-slate-500">{{ r }}</span>
|
||||
<input v-model="res[r].price" type="number" class="field !py-1 flex-1" placeholder="普通价(留空=不支持)" />
|
||||
<input v-model="res[r].agent" type="number" class="field !py-1 flex-1" placeholder="代理价(留空跟随)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isVideo">
|
||||
<label class="text-xs text-slate-500 block mb-1.5">时长 · 价格(总价 = 分辨率价 + 时长价;<strong class="text-slate-600">留空 = 不支持</strong>)</label>
|
||||
<div class="space-y-1.5">
|
||||
<div v-for="d in DUR_OPTS" :key="d" class="flex items-center gap-2">
|
||||
<span class="w-16 text-xs font-mono text-slate-500">{{ d }}</span>
|
||||
<input v-model="dur[d].price" type="number" class="field !py-1 flex-1" placeholder="普通价(留空=不支持)" />
|
||||
<input v-model="dur[d].agent" type="number" class="field !py-1 flex-1" placeholder="代理价(留空跟随)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-slate-500 block mb-1">参考图张数<span v-if="refMode==='frame'" class="text-white/40">(首尾帧最多 2)</span></label>
|
||||
<input v-model.number="maxRefs" type="number" min="0" :max="refsCap" class="field h-10" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-slate-500 block mb-1">参考模式</label>
|
||||
<SelectMenu v-model="refMode" :options="[{value:'none',label:'无'},{value:'asset',label:'参考图'},{value:'frame',label:'首尾帧(视频)'}]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="save" :disabled="saving" class="btn-primary w-full">{{ saving ? '创建中…' : '创建模型' }}</button>
|
||||
<p v-if="error" class="text-xs text-rose-600">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,10 +7,14 @@ import Icon from './Icon.vue'
|
||||
const emit = defineEmits(['close', 'imported'])
|
||||
|
||||
const input = ref('')
|
||||
const weight = ref(0)
|
||||
const status = ref('')
|
||||
const isError = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
// type → token pool (for the post-import weight PATCH).
|
||||
const TYPE_POOL = { openai: 'chatgpt', adobe: 'adobe', runway: 'runway', leonardo: 'leonardo', krea: 'krea', imagine: 'imagine', grok: 'grok' }
|
||||
|
||||
// Live preview of what the parser would extract — updates as the user types
|
||||
// so they can see whether their paste was understood before clicking import.
|
||||
const detected = computed(() => {
|
||||
@@ -56,8 +60,15 @@ async function doSmartImport() {
|
||||
: it.type === 'imagine'
|
||||
? await api('/tokens/import-imagine-token', jsonBody('POST', { value: it.value }))
|
||||
: await api('/tokens/import-adobe-cookie', jsonBody('POST', { cookie: it.value }))
|
||||
if (r.ok) ok++
|
||||
else { fail++; errs.push(`${it.type}: ${r.data?.detail || r.status}`) }
|
||||
if (r.ok) {
|
||||
ok++
|
||||
// Apply the chosen weight to the freshly-imported account (best-effort).
|
||||
const w = Number(weight.value) || 0
|
||||
const pool = TYPE_POOL[it.type]
|
||||
if (w !== 0 && pool && r.data?.id) {
|
||||
try { await api(`/tokens/${pool}/${r.data.id}`, jsonBody('PATCH', { weight: w })) } catch (_) {}
|
||||
}
|
||||
} else { fail++; errs.push(`${it.type}: ${r.data?.detail || r.status}`) }
|
||||
} catch (e) {
|
||||
fail++; errs.push(`${it.type}: ${e}`)
|
||||
}
|
||||
@@ -131,6 +142,10 @@ async function doSmartImport() {
|
||||
</template>
|
||||
<span v-else class="text-rose-600">未识别到任何 Cookie 或 JWT</span>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<label class="text-xs text-slate-500 whitespace-nowrap">权重(本批账号,高的优先)</label>
|
||||
<input v-model.number="weight" type="number" class="field !w-24" placeholder="0" />
|
||||
</div>
|
||||
<button @click="doSmartImport" :disabled="submitting || !detected.total" class="btn-primary w-full mt-3">
|
||||
{{ submitting ? '导入中…' : (detected.total ? `识别并导入 (${detected.total})` : '识别并导入') }}
|
||||
</button>
|
||||
|
||||
@@ -73,7 +73,7 @@ onUnmounted(() => document.removeEventListener('mousedown', onDocClick))
|
||||
<button type="button" @click="toggle" @keydown="onKeydown"
|
||||
:aria-expanded="open"
|
||||
:disabled="disabled"
|
||||
class="field flex items-center justify-between gap-2 text-left disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="field flex items-center justify-between gap-2 text-left h-10 !py-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="[mono ? 'font-mono' : '', selected ? '' : 'text-[color:var(--fg-faint)]']">
|
||||
<span class="truncate">{{ label }}</span>
|
||||
<Icon name="chevron"
|
||||
|
||||
@@ -142,7 +142,7 @@ async function run() {
|
||||
busy.value = false
|
||||
resultUrl.value = r.data.url
|
||||
resultKind.value = r.data.kind || (isVideo ? 'video' : 'image')
|
||||
status.value = `完成 · ${r.data.provider} · ${r.data.elapsed_ms}ms`
|
||||
status.value = `完成 · ${r.data.provider} · ${(r.data.elapsed_ms / 1000).toFixed(1)}s`
|
||||
} else if (GATEWAY_TIMEOUT.has(r.status)) {
|
||||
// CDN/代理回源超时(如 EdgeOne 524)—— 后端仍在生成。保持锁住,轮询恢复结果。
|
||||
status.value = isVideo ? '生成视频中 (约 1–3 分钟)…' : '生成中…'
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
const props = defineProps({ account: { type: Object, default: null } }) // edit mode when set
|
||||
const emit = defineEmits(['close', 'imported'])
|
||||
|
||||
const isEdit = !!props.account
|
||||
const name = ref(props.account?.email || '')
|
||||
const baseUrl = ref(props.account?.base_url || '')
|
||||
const key = ref('') // edit: blank = keep existing key
|
||||
const allModels = ref([]) // existing models to pick from
|
||||
const selected = ref(props.account?.models ? String(props.account.models).split(',').map((x) => x.trim()).filter(Boolean) : [])
|
||||
const weight = ref(Number(props.account?.weight) || 0)
|
||||
const concurrency = ref(Number(props.account?.concurrency) || 1)
|
||||
const status = ref('')
|
||||
const isError = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const r = await api('/managed-models')
|
||||
allModels.value = (r.data?.data || []).map((m) => ({ id: m.id, type: m.type }))
|
||||
} catch (_) {}
|
||||
})
|
||||
|
||||
function toggle(id) {
|
||||
const i = selected.value.indexOf(id)
|
||||
if (i >= 0) selected.value.splice(i, 1)
|
||||
else selected.value.push(id)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!baseUrl.value.trim() || (!isEdit && !key.value.trim())) {
|
||||
status.value = isEdit ? '请填写 URL' : '请填写 URL 和 Key'; isError.value = true; return
|
||||
}
|
||||
submitting.value = true; status.value = ''; isError.value = false
|
||||
try {
|
||||
const r = await api('/tokens/import-custom-account', jsonBody('POST', {
|
||||
id: isEdit ? props.account.id : undefined,
|
||||
name: name.value.trim(),
|
||||
base_url: baseUrl.value.trim(),
|
||||
key: key.value.trim(), // blank in edit = keep existing
|
||||
models: selected.value.join(','),
|
||||
weight: Number(weight.value) || 0,
|
||||
concurrency: Number(concurrency.value) || 1,
|
||||
}))
|
||||
if (r.ok) {
|
||||
status.value = isEdit ? '✓ 已保存' : '✓ 已添加上游'; emit('imported')
|
||||
setTimeout(() => emit('close'), 700)
|
||||
} else {
|
||||
status.value = r.data?.detail || '保存失败'; isError.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
status.value = String(e); isError.value = true
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
|
||||
@click.self="emit('close')">
|
||||
<div class="card !shadow-xl mt-14 mb-14 w-full max-w-lg">
|
||||
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">{{ isEdit ? '编辑上游' : '添加上游(自定义 OpenAI 兼容)' }}</h2>
|
||||
<button @click="emit('close')" class="text-slate-400 hover:text-slate-700 transition-colors">
|
||||
<Icon name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-5 space-y-3">
|
||||
<p class="text-xs text-slate-500 leading-relaxed">
|
||||
上游就是一个账号:填 v1 URL + Key。模型按 <strong class="text-slate-700">id 相同</strong>自动路由 ——
|
||||
在「模型管理」加一个 provider=custom、id 与上游一致的模型即可从这个上游调用。调用<strong class="text-slate-700">直连不走代理</strong>。
|
||||
</p>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">备注名</label>
|
||||
<input v-model="name" class="field" placeholder="例如:我的中转 / xx-api" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">v1 URL <span class="text-rose-500">*</span></label>
|
||||
<input v-model="baseUrl" class="field font-mono text-xs" placeholder="https://api.example.com(无需 /v1 结尾)" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">Key <span v-if="!isEdit" class="text-rose-500">*</span><span v-else class="text-white/40">(留空=不改)</span></label>
|
||||
<input v-model="key" class="field font-mono text-xs" :placeholder="isEdit ? '留空保持原 key' : 'sk-...'" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<label class="text-xs text-slate-500">支持的模型(多选,不选 = 全部)</label>
|
||||
<span class="text-[11px] text-slate-400">{{ selected.length ? `已选 ${selected.length}` : '全部' }}</span>
|
||||
</div>
|
||||
<div v-if="!allModels.length" class="text-xs text-slate-400 rounded-lg ring-1 ring-slate-200 bg-slate-50/60 p-3">
|
||||
暂无模型 —— 可先去模型管理加自定义模型
|
||||
</div>
|
||||
<div v-else class="flex flex-wrap gap-1.5 max-h-44 overflow-y-auto rounded-lg ring-1 ring-slate-200 bg-slate-50/60 p-2">
|
||||
<button v-for="m in allModels" :key="m.id" type="button" @click="toggle(m.id)"
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs ring-1 transition-colors"
|
||||
:class="selected.includes(m.id) ? 'bg-indigo-500/15 text-indigo-700 ring-indigo-300 font-medium' : 'bg-white text-slate-600 ring-slate-200 hover:ring-slate-300'">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="m.type === 'video' ? 'bg-violet-400' : 'bg-emerald-400'"></span>{{ m.id }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-slate-500">权重(高的优先)</label>
|
||||
<input v-model.number="weight" type="number" class="field" placeholder="0" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-slate-500">并发数(单账号)</label>
|
||||
<input v-model.number="concurrency" type="number" min="1" class="field" placeholder="1" />
|
||||
</div>
|
||||
</div>
|
||||
<button @click="submit" :disabled="submitting" class="btn-primary w-full mt-1">
|
||||
{{ submitting ? '保存中…' : (isEdit ? '保存' : '添加上游') }}
|
||||
</button>
|
||||
<p v-if="status" class="text-xs" :class="isError ? 'text-rose-600' : 'text-emerald-600'">{{ status }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user