2025-12-10 20:52:44 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<Dialog
|
|
|
|
|
|
:model-value="open"
|
|
|
|
|
|
:title="isEditing ? '编辑模型配置' : '添加模型'"
|
2026-02-03 18:48:39 +08:00
|
|
|
|
:description="isEditing ? '修改模型价格配置' : '为此 Provider 添加模型实现'"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
:icon="isEditing ? SquarePen : Layers"
|
|
|
|
|
|
size="xl"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
@update:model-value="handleClose"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<form
|
|
|
|
|
|
class="space-y-4"
|
|
|
|
|
|
@submit.prevent="handleSubmit"
|
|
|
|
|
|
>
|
2026-05-19 16:31:58 +08:00
|
|
|
|
<!-- 添加模式:选择或手动创建本地全局模型 -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="!isEditing"
|
2026-05-15 00:51:30 +08:00
|
|
|
|
class="space-y-3"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
>
|
2026-05-19 16:31:58 +08:00
|
|
|
|
<div class="flex items-center justify-between gap-3">
|
|
|
|
|
|
<Label for="global-model">选择已有模型或手动添加 *</Label>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-7 px-2 text-xs"
|
|
|
|
|
|
@click="manualGlobalModelMode = !manualGlobalModelMode"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ manualGlobalModelMode ? '选择已有模型' : '手动添加' }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="!manualGlobalModelMode"
|
|
|
|
|
|
class="space-y-2"
|
|
|
|
|
|
>
|
2026-05-15 00:51:30 +08:00
|
|
|
|
<Select
|
|
|
|
|
|
:model-value="form.global_model_id"
|
|
|
|
|
|
:disabled="loadingGlobalModels"
|
|
|
|
|
|
@update:model-value="handleGlobalModelSelect"
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger class="w-full">
|
|
|
|
|
|
<SelectValue :placeholder="loadingGlobalModels ? '加载中...' : '请选择模型'" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem
|
|
|
|
|
|
v-for="model in availableGlobalModels"
|
|
|
|
|
|
:key="model.id"
|
|
|
|
|
|
:value="model.id"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ model.display_name }} ({{ model.name }})
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
2026-05-19 16:31:58 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="rounded-lg border border-border/60 bg-muted/20 p-3 space-y-3"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="grid grid-cols-2 gap-3">
|
|
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<Label
|
|
|
|
|
|
for="manual-global-model-name"
|
|
|
|
|
|
class="text-xs"
|
|
|
|
|
|
>模型ID *</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="manual-global-model-name"
|
|
|
|
|
|
v-model="form.manual_global_model_name"
|
|
|
|
|
|
placeholder="如 gpt-4o-mini"
|
|
|
|
|
|
@update:model-value="syncManualProviderName"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<Label
|
|
|
|
|
|
for="manual-global-model-display-name"
|
|
|
|
|
|
class="text-xs"
|
|
|
|
|
|
>显示名称</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="manual-global-model-display-name"
|
|
|
|
|
|
v-model="form.manual_global_model_display_name"
|
|
|
|
|
|
placeholder="默认使用模型ID"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
|
|
|
|
|
无法联网获取模型目录时,可直接填写模型ID。保存时会先创建本地全局模型,再添加到当前 Provider。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<p
|
2026-05-19 16:31:58 +08:00
|
|
|
|
v-if="availableGlobalModels.length === 0 && !loadingGlobalModels && !manualGlobalModelMode"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
class="text-xs text-muted-foreground"
|
|
|
|
|
|
>
|
2026-05-19 16:31:58 +08:00
|
|
|
|
没有可选择的本地全局模型。可以切换到“手动添加”继续保存。
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</p>
|
2026-05-15 00:51:30 +08:00
|
|
|
|
<div class="space-y-1.5">
|
2026-05-18 12:41:57 +08:00
|
|
|
|
<Label
|
|
|
|
|
|
for="provider-model-name"
|
|
|
|
|
|
class="text-xs"
|
|
|
|
|
|
>Provider 模型名 *</Label>
|
2026-05-15 00:51:30 +08:00
|
|
|
|
<Input
|
|
|
|
|
|
id="provider-model-name"
|
|
|
|
|
|
v-model="form.provider_model_name"
|
|
|
|
|
|
placeholder="Provider 实际接收的模型名,如 gpt-4o-mini"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
|
|
|
|
|
默认跟随所选模型ID;如内网模型、兼容网关或别名不同,可手动覆盖。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-05-03 17:32:41 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="selectedGlobalModelSupportsEmbedding"
|
|
|
|
|
|
class="rounded-lg border border-border/60 bg-muted/20 px-3 py-2"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="text-sm font-medium">
|
|
|
|
|
|
Embedding
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
|
|
|
|
|
此模型将继承全局模型的 Embeddings 元数据,不按 Chat 能力处理。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 编辑模式:显示模型信息 -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="rounded-lg border bg-muted/30 p-4"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<div class="flex items-start justify-between">
|
|
|
|
|
|
<div>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<p class="font-semibold text-lg">
|
|
|
|
|
|
{{ editingModel?.global_model_display_name || editingModel?.provider_model_name }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p class="text-sm text-muted-foreground font-mono">
|
|
|
|
|
|
{{ editingModel?.provider_model_name }}
|
|
|
|
|
|
</p>
|
2026-05-03 17:32:41 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
v-if="editingModelSupportsEmbedding"
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
class="mt-2 text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
Embedding
|
|
|
|
|
|
</Badge>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-18 02:49:56 +08:00
|
|
|
|
<div class="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
|
|
|
|
|
|
<div class="flex items-start gap-2">
|
|
|
|
|
|
<Checkbox
|
2026-05-18 03:27:31 +08:00
|
|
|
|
:checked="isImageGenerationEnabled"
|
2026-05-18 02:49:56 +08:00
|
|
|
|
class="mt-0.5"
|
2026-05-18 03:27:31 +08:00
|
|
|
|
@update:checked="setImageGenerationEnabled"
|
2026-05-18 02:49:56 +08:00
|
|
|
|
/>
|
|
|
|
|
|
<div class="space-y-1">
|
|
|
|
|
|
<div class="text-sm font-medium">
|
|
|
|
|
|
图片模型
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
|
|
|
|
|
启用图片输出计费,并展开尺寸 × 质量矩阵价格。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<!-- 价格配置 -->
|
|
|
|
|
|
<div class="space-y-4">
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<h4 class="font-semibold text-sm border-b pb-2">
|
|
|
|
|
|
价格配置
|
|
|
|
|
|
</h4>
|
|
|
|
|
|
<TieredPricingEditor
|
|
|
|
|
|
ref="tieredPricingEditorRef"
|
|
|
|
|
|
v-model="tieredPricing"
|
|
|
|
|
|
:show-cache1h="showCache1h"
|
2026-05-18 02:49:56 +08:00
|
|
|
|
:show-image-pricing="isImageGenerationEnabled"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
/>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 按次计费 -->
|
|
|
|
|
|
<div class="flex items-center gap-3 pt-2 border-t">
|
|
|
|
|
|
<Label class="text-xs whitespace-nowrap">按次计费 ($/次)</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
:model-value="form.price_per_request ?? ''"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
step="0.001"
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
class="w-32"
|
|
|
|
|
|
placeholder="留空使用默认值"
|
|
|
|
|
|
@update:model-value="(v) => form.price_per_request = parseNumberInput(v, { allowFloat: true })"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span class="text-xs text-muted-foreground">每次请求固定费用,留空使用全局模型默认值</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<!-- 视频计费(可选覆盖) -->
|
|
|
|
|
|
<div class="pt-3 border-t space-y-2">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<div class="text-sm font-medium">
|
|
|
|
|
|
视频计费(可选覆盖)
|
|
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<div class="flex items-center gap-1.5 flex-wrap">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-7 text-xs"
|
|
|
|
|
|
@click="() => { fillVideoResolutionPricePreset('common'); configTouched = true }"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
通用
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-7 text-xs"
|
|
|
|
|
|
@click="() => { fillVideoResolutionPricePreset('sora'); configTouched = true }"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
Sora
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-7 text-xs"
|
|
|
|
|
|
@click="() => { fillVideoResolutionPricePreset('veo'); configTouched = true }"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
Veo
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-7 text-xs"
|
|
|
|
|
|
@click="() => { addVideoResolutionPriceRow(); configTouched = true }"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<Plus class="w-3.5 h-3.5 mr-0.5" />
|
|
|
|
|
|
自定义
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="videoResolutionPrices.length > 0"
|
|
|
|
|
|
class="rounded-lg border border-border overflow-hidden"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="grid grid-cols-[1fr_1fr_32px] gap-0 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 border-b border-border">
|
|
|
|
|
|
<span>分辨率</span>
|
|
|
|
|
|
<span>单价($/秒)</span>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<span />
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="divide-y divide-border">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="(row, idx) in videoResolutionPrices"
|
|
|
|
|
|
:key="idx"
|
|
|
|
|
|
class="grid grid-cols-[1fr_1fr_32px] gap-2 items-center px-3 py-1.5"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
v-model="row.resolution"
|
|
|
|
|
|
class="h-7 text-sm"
|
|
|
|
|
|
placeholder="如 720p"
|
|
|
|
|
|
@update:model-value="() => { configTouched = true }"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
:model-value="row.price_per_second ?? ''"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
step="0.0001"
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
class="h-7 text-sm"
|
|
|
|
|
|
placeholder="0"
|
|
|
|
|
|
@update:model-value="(v) => { row.price_per_second = parseNumberInput(v, { allowFloat: true }); configTouched = true }"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-7 w-7"
|
|
|
|
|
|
title="删除"
|
|
|
|
|
|
@click="() => { removeVideoResolutionPriceRow(idx); configTouched = true }"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
|
|
<template #footer>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
@click="handleClose(false)"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
取消
|
|
|
|
|
|
</Button>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<Button
|
2026-05-15 00:51:30 +08:00
|
|
|
|
:disabled="submitting || (!isEditing && !canSubmitCreate)"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
@click="handleSubmit"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Loader2
|
|
|
|
|
|
v-if="submitting"
|
|
|
|
|
|
class="w-4 h-4 mr-2 animate-spin"
|
|
|
|
|
|
/>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
{{ isEditing ? '保存' : '添加' }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { ref, computed, watch } from 'vue'
|
2026-02-22 00:43:41 +08:00
|
|
|
|
import { parseApiError } from '@/utils/errorParser'
|
2026-02-03 18:48:39 +08:00
|
|
|
|
import { Loader2, Layers, SquarePen, Plus, Trash2 } from 'lucide-vue-next'
|
2025-12-12 16:15:36 +08:00
|
|
|
|
import {
|
|
|
|
|
|
Dialog,
|
|
|
|
|
|
Button,
|
|
|
|
|
|
Input,
|
|
|
|
|
|
Label,
|
|
|
|
|
|
Select,
|
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
|
SelectValue,
|
|
|
|
|
|
SelectContent,
|
|
|
|
|
|
SelectItem,
|
2026-05-03 17:32:41 +08:00
|
|
|
|
Badge,
|
2026-05-18 02:49:56 +08:00
|
|
|
|
Checkbox,
|
2025-12-12 16:15:36 +08:00
|
|
|
|
} from '@/components/ui'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import { useToast } from '@/composables/useToast'
|
2026-02-03 18:48:39 +08:00
|
|
|
|
import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import { createModel, updateModel, getProviderModels } from '@/api/endpoints/models'
|
2026-05-19 16:31:58 +08:00
|
|
|
|
import { createGlobalModel, listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import TieredPricingEditor from '@/features/models/components/TieredPricingEditor.vue'
|
|
|
|
|
|
import type { Model, TieredPricingConfig } from '@/api/endpoints'
|
2026-05-03 17:32:41 +08:00
|
|
|
|
import {
|
|
|
|
|
|
buildProviderModelCreatePayload,
|
|
|
|
|
|
buildProviderModelUpdatePayload,
|
|
|
|
|
|
modelSupportsEmbedding,
|
|
|
|
|
|
} from './provider-model-form-helpers'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
|
open: boolean
|
|
|
|
|
|
providerId: string
|
|
|
|
|
|
providerName?: string
|
|
|
|
|
|
editingModel?: Model | null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
|
|
|
|
providerName: '',
|
|
|
|
|
|
editingModel: null
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
'update:open': [value: boolean]
|
|
|
|
|
|
'saved': []
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const { error: showError, success: showSuccess } = useToast()
|
|
|
|
|
|
|
2025-12-12 15:43:00 +08:00
|
|
|
|
const tieredPricingEditorRef = ref<InstanceType<typeof TieredPricingEditor> | null>(null)
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const isEditing = computed(() => !!props.editingModel)
|
|
|
|
|
|
|
2026-05-03 17:32:41 +08:00
|
|
|
|
const selectedGlobalModel = computed(() => {
|
|
|
|
|
|
return availableGlobalModels.value.find(model => model.id === form.value.global_model_id) || null
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const selectedGlobalModelSupportsEmbedding = computed(() => modelSupportsEmbedding(selectedGlobalModel.value))
|
2026-05-18 02:49:56 +08:00
|
|
|
|
const selectedGlobalModelSupportsImageGeneration = computed(() => modelSupportsImageGeneration(selectedGlobalModel.value))
|
2026-05-03 17:32:41 +08:00
|
|
|
|
const editingModelSupportsEmbedding = computed(() => {
|
|
|
|
|
|
return props.editingModel?.effective_supports_embedding === true
|
|
|
|
|
|
|| modelSupportsEmbedding(props.editingModel)
|
|
|
|
|
|
})
|
2026-05-18 02:49:56 +08:00
|
|
|
|
const editingModelSupportsImageGeneration = computed(() => {
|
|
|
|
|
|
return props.editingModel?.effective_supports_image_generation === true
|
|
|
|
|
|
|| modelSupportsImageGeneration(props.editingModel)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const isImageGenerationEnabled = computed(() => {
|
2026-05-18 03:39:55 +08:00
|
|
|
|
if (imageGenerationExplicitOverride.value !== null) {
|
|
|
|
|
|
return imageGenerationExplicitOverride.value
|
|
|
|
|
|
}
|
2026-05-18 02:49:56 +08:00
|
|
|
|
if (form.value.supports_image_generation !== undefined) {
|
|
|
|
|
|
return form.value.supports_image_generation === true
|
|
|
|
|
|
}
|
2026-05-18 03:39:55 +08:00
|
|
|
|
const supportsImageGeneration = isEditing.value
|
2026-05-18 02:49:56 +08:00
|
|
|
|
? editingModelSupportsImageGeneration.value
|
|
|
|
|
|
: selectedGlobalModelSupportsImageGeneration.value
|
2026-05-18 03:39:55 +08:00
|
|
|
|
return supportsImageGeneration || tieredPricingHasImageOutputPricing(tieredPricing.value)
|
2026-05-18 02:49:56 +08:00
|
|
|
|
})
|
2026-05-03 17:32:41 +08:00
|
|
|
|
|
2026-02-28 13:52:32 +08:00
|
|
|
|
// 1h 缓存定价始终显示
|
|
|
|
|
|
const showCache1h = true
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
// 表单状态
|
|
|
|
|
|
const submitting = ref(false)
|
|
|
|
|
|
const loadingGlobalModels = ref(false)
|
|
|
|
|
|
const availableGlobalModels = ref<GlobalModelResponse[]>([])
|
2026-05-19 16:31:58 +08:00
|
|
|
|
const manualGlobalModelMode = ref(false)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
// 阶梯计费配置
|
|
|
|
|
|
const tieredPricing = ref<TieredPricingConfig | null>(null)
|
|
|
|
|
|
// 跟踪用户是否修改了阶梯配置(用于判断是否提交)
|
|
|
|
|
|
const tieredPricingModified = ref(false)
|
|
|
|
|
|
// 保存原始配置用于比较
|
|
|
|
|
|
const originalTieredPricing = ref<string>('')
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
type VideoResolutionPriceRow = { resolution: string; price_per_second: number | undefined }
|
|
|
|
|
|
|
|
|
|
|
|
const configTouched = ref(false)
|
|
|
|
|
|
const videoResolutionPrices = ref<VideoResolutionPriceRow[]>([])
|
|
|
|
|
|
|
|
|
|
|
|
const VIDEO_RESOLUTION_PRICE_PRESETS: Record<
|
|
|
|
|
|
'common' | 'sora' | 'veo',
|
|
|
|
|
|
VideoResolutionPriceRow[]
|
|
|
|
|
|
> = {
|
|
|
|
|
|
common: [
|
|
|
|
|
|
{ resolution: '480p', price_per_second: 0 },
|
|
|
|
|
|
{ resolution: '720p', price_per_second: 0 },
|
|
|
|
|
|
{ resolution: '1080p', price_per_second: 0 },
|
|
|
|
|
|
{ resolution: '4k', price_per_second: 0 },
|
|
|
|
|
|
],
|
|
|
|
|
|
sora: [
|
|
|
|
|
|
{ resolution: '720x1080', price_per_second: 0 },
|
|
|
|
|
|
{ resolution: '1024x1792', price_per_second: 0 },
|
|
|
|
|
|
],
|
|
|
|
|
|
veo: [
|
|
|
|
|
|
{ resolution: '720p', price_per_second: 0 },
|
|
|
|
|
|
{ resolution: '1080p', price_per_second: 0 },
|
|
|
|
|
|
{ resolution: '4k', price_per_second: 0 },
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 16:31:58 +08:00
|
|
|
|
const DEFAULT_MANUAL_GLOBAL_MODEL_PRICING: TieredPricingConfig = {
|
|
|
|
|
|
tiers: [{ up_to: null, input_price_per_1m: 0, output_price_per_1m: 0 }],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const form = ref({
|
|
|
|
|
|
global_model_id: '',
|
2026-05-15 00:51:30 +08:00
|
|
|
|
provider_model_name: '',
|
2026-05-19 16:31:58 +08:00
|
|
|
|
manual_global_model_name: '',
|
|
|
|
|
|
manual_global_model_display_name: '',
|
2025-12-10 20:52:44 +08:00
|
|
|
|
price_per_request: undefined as number | undefined,
|
2026-02-22 00:43:41 +08:00
|
|
|
|
config: {} as Record<string, unknown>,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 能力配置
|
|
|
|
|
|
supports_vision: undefined as boolean | undefined,
|
|
|
|
|
|
supports_function_calling: undefined as boolean | undefined,
|
|
|
|
|
|
supports_streaming: undefined as boolean | undefined,
|
|
|
|
|
|
supports_extended_thinking: undefined as boolean | undefined,
|
|
|
|
|
|
supports_image_generation: undefined as boolean | undefined,
|
|
|
|
|
|
is_active: true
|
|
|
|
|
|
})
|
2026-05-18 03:39:55 +08:00
|
|
|
|
const imageGenerationExplicitOverride = ref<boolean | null>(null)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-05-15 00:51:30 +08:00
|
|
|
|
const canSubmitCreate = computed(() => {
|
|
|
|
|
|
if (isEditing.value) return true
|
|
|
|
|
|
if (!form.value.provider_model_name.trim()) return false
|
2026-05-19 16:31:58 +08:00
|
|
|
|
if (manualGlobalModelMode.value) return !!form.value.manual_global_model_name.trim()
|
2026-05-15 00:51:30 +08:00
|
|
|
|
return !!form.value.global_model_id
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 监听 open 变化
|
|
|
|
|
|
watch(() => props.open, async (newOpen) => {
|
|
|
|
|
|
if (newOpen) {
|
|
|
|
|
|
resetForm()
|
|
|
|
|
|
if (props.editingModel) {
|
|
|
|
|
|
// 编辑模式:填充表单
|
2026-02-03 18:48:39 +08:00
|
|
|
|
// 使用有效配置(合并全局模型的默认值)供用户查看和编辑
|
|
|
|
|
|
const effectiveConfig = props.editingModel.effective_config || props.editingModel.config || {}
|
2026-05-18 03:19:58 +08:00
|
|
|
|
const supportsImageGeneration = modelSupportsImageGeneration(props.editingModel)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
form.value = {
|
|
|
|
|
|
global_model_id: props.editingModel.global_model_id || '',
|
2026-05-15 00:51:30 +08:00
|
|
|
|
provider_model_name: props.editingModel.provider_model_name || '',
|
2026-05-19 16:31:58 +08:00
|
|
|
|
manual_global_model_name: '',
|
|
|
|
|
|
manual_global_model_display_name: '',
|
2026-02-03 18:48:39 +08:00
|
|
|
|
// 显示有效的按次计费价格(继承自全局模型)
|
|
|
|
|
|
price_per_request: props.editingModel.effective_price_per_request ?? props.editingModel.price_per_request ?? undefined,
|
|
|
|
|
|
config: effectiveConfig ? JSON.parse(JSON.stringify(effectiveConfig)) : {},
|
2025-12-10 20:52:44 +08:00
|
|
|
|
supports_vision: props.editingModel.supports_vision ?? undefined,
|
|
|
|
|
|
supports_function_calling: props.editingModel.supports_function_calling ?? undefined,
|
|
|
|
|
|
supports_streaming: props.editingModel.supports_streaming ?? undefined,
|
|
|
|
|
|
supports_extended_thinking: props.editingModel.supports_extended_thinking ?? undefined,
|
2026-05-18 03:19:58 +08:00
|
|
|
|
supports_image_generation: supportsImageGeneration ? true : props.editingModel.supports_image_generation ?? undefined,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
is_active: props.editingModel.is_active
|
|
|
|
|
|
}
|
2026-02-03 18:48:39 +08:00
|
|
|
|
// 从有效配置中加载视频费用
|
|
|
|
|
|
loadVideoPricingFromConfig(effectiveConfig)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 加载阶梯计费配置:优先使用 Provider 自定义配置,否则使用有效配置(继承自全局模型)
|
|
|
|
|
|
const pricing = props.editingModel.tiered_pricing || props.editingModel.effective_tiered_pricing
|
|
|
|
|
|
if (pricing) {
|
|
|
|
|
|
tieredPricing.value = JSON.parse(JSON.stringify(pricing))
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 添加模式:加载可用全局模型
|
|
|
|
|
|
await loadAvailableGlobalModels()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 添加模式:选择全局模型时显示其阶梯计费配置(仅供预览)
|
|
|
|
|
|
// 注意:为保持继承关系,添加时只有用户修改了配置才提交 tiered_pricing
|
|
|
|
|
|
watch(() => form.value.global_model_id, (newId) => {
|
|
|
|
|
|
if (!isEditing.value && newId) {
|
|
|
|
|
|
const selectedModel = availableGlobalModels.value.find(m => m.id === newId)
|
2026-05-15 00:51:30 +08:00
|
|
|
|
if (selectedModel && !form.value.provider_model_name.trim()) {
|
|
|
|
|
|
form.value.provider_model_name = selectedModel.name
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if (selectedModel?.default_tiered_pricing) {
|
|
|
|
|
|
// 深拷贝阶梯计费配置用于预览
|
|
|
|
|
|
const pricingCopy = JSON.parse(JSON.stringify(selectedModel.default_tiered_pricing))
|
|
|
|
|
|
tieredPricing.value = pricingCopy
|
|
|
|
|
|
// 保存原始配置用于比较
|
|
|
|
|
|
originalTieredPricing.value = JSON.stringify(pricingCopy)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
tieredPricing.value = null
|
|
|
|
|
|
originalTieredPricing.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
tieredPricingModified.value = false
|
|
|
|
|
|
// 同时继承按次计费(仅供预览)
|
|
|
|
|
|
form.value.price_per_request = selectedModel?.default_price_per_request ?? undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 监听阶梯配置变化,标记为已修改
|
|
|
|
|
|
watch(tieredPricing, (newValue) => {
|
|
|
|
|
|
if (!isEditing.value && originalTieredPricing.value) {
|
|
|
|
|
|
const newJson = JSON.stringify(newValue)
|
|
|
|
|
|
tieredPricingModified.value = newJson !== originalTieredPricing.value
|
|
|
|
|
|
}
|
|
|
|
|
|
}, { deep: true })
|
|
|
|
|
|
|
|
|
|
|
|
// 重置表单
|
|
|
|
|
|
function resetForm() {
|
2026-05-18 03:39:55 +08:00
|
|
|
|
imageGenerationExplicitOverride.value = null
|
2025-12-10 20:52:44 +08:00
|
|
|
|
form.value = {
|
|
|
|
|
|
global_model_id: '',
|
2026-05-15 00:51:30 +08:00
|
|
|
|
provider_model_name: '',
|
2026-05-19 16:31:58 +08:00
|
|
|
|
manual_global_model_name: '',
|
|
|
|
|
|
manual_global_model_display_name: '',
|
2025-12-10 20:52:44 +08:00
|
|
|
|
price_per_request: undefined,
|
2026-02-03 18:48:39 +08:00
|
|
|
|
config: {},
|
2025-12-10 20:52:44 +08:00
|
|
|
|
supports_vision: undefined,
|
|
|
|
|
|
supports_function_calling: undefined,
|
|
|
|
|
|
supports_streaming: undefined,
|
|
|
|
|
|
supports_extended_thinking: undefined,
|
|
|
|
|
|
supports_image_generation: undefined,
|
|
|
|
|
|
is_active: true
|
|
|
|
|
|
}
|
2026-02-03 18:48:39 +08:00
|
|
|
|
configTouched.value = false
|
|
|
|
|
|
videoResolutionPrices.value = []
|
2025-12-10 20:52:44 +08:00
|
|
|
|
tieredPricing.value = null
|
|
|
|
|
|
tieredPricingModified.value = false
|
|
|
|
|
|
originalTieredPricing.value = ''
|
|
|
|
|
|
availableGlobalModels.value = []
|
2026-05-19 16:31:58 +08:00
|
|
|
|
manualGlobalModelMode.value = false
|
2026-05-15 00:51:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleGlobalModelSelect(value: string) {
|
2026-05-18 03:39:55 +08:00
|
|
|
|
imageGenerationExplicitOverride.value = null
|
|
|
|
|
|
form.value.supports_image_generation = undefined
|
2026-05-15 00:51:30 +08:00
|
|
|
|
form.value.global_model_id = value
|
|
|
|
|
|
const selectedModel = availableGlobalModels.value.find(model => model.id === value)
|
|
|
|
|
|
form.value.provider_model_name = selectedModel?.name || form.value.provider_model_name
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 02:49:56 +08:00
|
|
|
|
function modelSupportsImageGeneration(model: {
|
|
|
|
|
|
supported_capabilities?: string[] | null
|
|
|
|
|
|
supports_image_generation?: boolean | null
|
|
|
|
|
|
effective_supports_image_generation?: boolean | null
|
2026-05-18 03:19:58 +08:00
|
|
|
|
default_tiered_pricing?: TieredPricingConfig | null
|
|
|
|
|
|
tiered_pricing?: TieredPricingConfig | null
|
|
|
|
|
|
effective_tiered_pricing?: TieredPricingConfig | null
|
2026-05-18 02:49:56 +08:00
|
|
|
|
config?: Record<string, unknown> | null
|
|
|
|
|
|
} | null | undefined): boolean {
|
|
|
|
|
|
if (!model) return false
|
|
|
|
|
|
if (model.effective_supports_image_generation === true) return true
|
|
|
|
|
|
if (model.supports_image_generation === true) return true
|
|
|
|
|
|
const config = model.config || {}
|
|
|
|
|
|
return model.supported_capabilities?.includes('image_generation') === true
|
|
|
|
|
|
|| config.image_generation === true
|
|
|
|
|
|
|| config.model_type === 'image'
|
|
|
|
|
|
|| (Array.isArray(config.api_formats) && config.api_formats.some((format) => String(format).endsWith(':image')))
|
2026-05-18 03:19:58 +08:00
|
|
|
|
|| tieredPricingHasImageOutputPricing(model.default_tiered_pricing)
|
|
|
|
|
|
|| tieredPricingHasImageOutputPricing(model.tiered_pricing)
|
|
|
|
|
|
|| tieredPricingHasImageOutputPricing(model.effective_tiered_pricing)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function tieredPricingHasImageOutputPricing(pricing: TieredPricingConfig | null | undefined): boolean {
|
|
|
|
|
|
if (!pricing) return false
|
2026-05-18 03:39:55 +08:00
|
|
|
|
if (toFinitePrice(pricing.image_output_price_default) !== null) return true
|
2026-05-18 11:52:01 +08:00
|
|
|
|
if (Object.values(pricing.image_output_prices || {}).some((prices) => {
|
2026-05-18 03:19:58 +08:00
|
|
|
|
if (!prices || typeof prices !== 'object') return false
|
2026-05-18 03:39:55 +08:00
|
|
|
|
return Object.values(prices).some((price) => toFinitePrice(price) !== null)
|
2026-05-18 11:52:01 +08:00
|
|
|
|
})) return true
|
|
|
|
|
|
return (pricing.image_output_price_ranges || []).some((range) => {
|
|
|
|
|
|
if (!range || typeof range !== 'object') return false
|
|
|
|
|
|
const prices = range.prices && typeof range.prices === 'object'
|
|
|
|
|
|
? range.prices
|
|
|
|
|
|
: range as Record<string, unknown>
|
|
|
|
|
|
return Object.values(prices).some((price) => toFinitePrice(price) !== null)
|
2026-05-18 03:19:58 +08:00
|
|
|
|
})
|
2026-05-18 02:49:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 03:39:55 +08:00
|
|
|
|
function toFinitePrice(value: unknown): number | null {
|
|
|
|
|
|
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
|
|
|
|
if (typeof value === 'string' && value.trim()) {
|
|
|
|
|
|
const parsed = Number(value)
|
|
|
|
|
|
return Number.isFinite(parsed) ? parsed : null
|
|
|
|
|
|
}
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 02:49:56 +08:00
|
|
|
|
function setImageGenerationEnabled(value: boolean | 'indeterminate') {
|
2026-05-18 03:39:55 +08:00
|
|
|
|
const enabled = value === true
|
|
|
|
|
|
imageGenerationExplicitOverride.value = enabled
|
|
|
|
|
|
form.value.supports_image_generation = enabled
|
2026-05-18 02:49:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 16:31:58 +08:00
|
|
|
|
function syncManualProviderName(value: string | number) {
|
|
|
|
|
|
const modelName = String(value || '').trim()
|
|
|
|
|
|
if (!form.value.provider_model_name.trim()) {
|
|
|
|
|
|
form.value.provider_model_name = modelName
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!form.value.manual_global_model_display_name.trim()) {
|
|
|
|
|
|
form.value.manual_global_model_display_name = modelName
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
function getNested(obj: Record<string, unknown>, path: string): unknown {
|
2026-02-03 18:48:39 +08:00
|
|
|
|
if (!obj || typeof obj !== 'object') return undefined
|
|
|
|
|
|
const parts = path.split('.').filter(Boolean)
|
2026-02-22 00:43:41 +08:00
|
|
|
|
let cur: unknown = obj
|
2026-02-03 18:48:39 +08:00
|
|
|
|
for (const p of parts) {
|
|
|
|
|
|
if (!cur || typeof cur !== 'object') return undefined
|
2026-02-22 00:43:41 +08:00
|
|
|
|
cur = (cur as Record<string, unknown>)[p]
|
2026-02-03 18:48:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
return cur
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
function setNested(obj: Record<string, unknown>, path: string, value: unknown) {
|
2026-02-03 18:48:39 +08:00
|
|
|
|
if (!obj || typeof obj !== 'object') return
|
|
|
|
|
|
const parts = path.split('.').filter(Boolean)
|
|
|
|
|
|
if (parts.length === 0) return
|
2026-02-22 00:43:41 +08:00
|
|
|
|
let cur: Record<string, unknown> = obj
|
2026-02-03 18:48:39 +08:00
|
|
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
|
|
|
|
const p = parts[i]
|
|
|
|
|
|
if (!cur[p] || typeof cur[p] !== 'object') {
|
|
|
|
|
|
cur[p] = {}
|
|
|
|
|
|
}
|
2026-02-22 00:43:41 +08:00
|
|
|
|
cur = cur[p] as Record<string, unknown>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
cur[parts[parts.length - 1]] = value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
function deleteNested(obj: Record<string, unknown>, path: string) {
|
2026-02-03 18:48:39 +08:00
|
|
|
|
if (!obj || typeof obj !== 'object') return
|
|
|
|
|
|
const parts = path.split('.').filter(Boolean)
|
|
|
|
|
|
if (parts.length === 0) return
|
2026-02-22 00:43:41 +08:00
|
|
|
|
let cur: Record<string, unknown> = obj
|
2026-02-03 18:48:39 +08:00
|
|
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
|
|
|
|
const p = parts[i]
|
|
|
|
|
|
if (!cur[p] || typeof cur[p] !== 'object') return
|
2026-02-22 00:43:41 +08:00
|
|
|
|
cur = cur[p] as Record<string, unknown>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
delete cur[parts[parts.length - 1]]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
function pruneEmptyBillingConfig(cfg: Record<string, unknown>) {
|
2026-02-03 18:48:39 +08:00
|
|
|
|
const billing = cfg.billing
|
|
|
|
|
|
if (!billing || typeof billing !== 'object') return
|
2026-02-22 00:43:41 +08:00
|
|
|
|
const billingObj = billing as Record<string, unknown>
|
|
|
|
|
|
const video = billingObj.video
|
2026-02-03 18:48:39 +08:00
|
|
|
|
if (video && typeof video === 'object' && Object.keys(video).length === 0) {
|
2026-02-22 00:43:41 +08:00
|
|
|
|
delete billingObj.video
|
2026-02-03 18:48:39 +08:00
|
|
|
|
}
|
2026-02-22 00:43:41 +08:00
|
|
|
|
if (Object.keys(billingObj).length === 0) {
|
2026-02-03 18:48:39 +08:00
|
|
|
|
delete cfg.billing
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Normalize resolution key:
|
|
|
|
|
|
* - lowercase, remove spaces, × → x
|
|
|
|
|
|
* - For WxH format, sort dimensions so smaller comes first (720x1080 = 1080x720)
|
|
|
|
|
|
*/
|
|
|
|
|
|
function normalizeResolutionKey(raw: string): string {
|
|
|
|
|
|
let k = (raw || '').trim().toLowerCase().replace(/\s+/g, '').replace(/×/g, 'x')
|
|
|
|
|
|
// Check if it's WxH format (e.g., 1080x720)
|
|
|
|
|
|
const match = k.match(/^(\d+)x(\d+)$/)
|
|
|
|
|
|
if (match) {
|
|
|
|
|
|
const a = parseInt(match[1], 10)
|
|
|
|
|
|
const b = parseInt(match[2], 10)
|
|
|
|
|
|
// Sort: smaller dimension first
|
|
|
|
|
|
k = a <= b ? `${a}x${b}` : `${b}x${a}`
|
|
|
|
|
|
}
|
|
|
|
|
|
return k
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
function loadVideoPricingFromConfig(cfg: Record<string, unknown>) {
|
2026-02-03 18:48:39 +08:00
|
|
|
|
const raw = getNested(cfg, 'billing.video.price_per_second_by_resolution')
|
|
|
|
|
|
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
|
|
|
|
// 按分辨率从低到高排序
|
|
|
|
|
|
const sortedEntries = sortResolutionEntries(Object.entries(raw))
|
|
|
|
|
|
videoResolutionPrices.value = sortedEntries.map(([k, v]) => ({
|
|
|
|
|
|
resolution: String(k),
|
|
|
|
|
|
price_per_second: typeof v === 'number' ? v : undefined,
|
|
|
|
|
|
}))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
videoResolutionPrices.value = []
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
function applyVideoPricingToConfig(cfg: Record<string, unknown>) {
|
2026-02-03 18:48:39 +08:00
|
|
|
|
// Clean legacy keys
|
|
|
|
|
|
deleteNested(cfg, 'billing.video.price_per_second')
|
|
|
|
|
|
deleteNested(cfg, 'billing.video.resolution_multipliers')
|
|
|
|
|
|
|
|
|
|
|
|
// resolution/size prices (normalized: 1080x720 → 720x1080)
|
|
|
|
|
|
const map: Record<string, number> = {}
|
|
|
|
|
|
for (const row of videoResolutionPrices.value) {
|
|
|
|
|
|
const k = normalizeResolutionKey(row.resolution || '')
|
|
|
|
|
|
const v = row.price_per_second
|
|
|
|
|
|
if (!k) continue
|
|
|
|
|
|
if (typeof v !== 'number' || Number.isNaN(v)) continue
|
|
|
|
|
|
map[k] = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if (Object.keys(map).length > 0) {
|
|
|
|
|
|
setNested(cfg, 'billing.video.price_per_second_by_resolution', map)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
deleteNested(cfg, 'billing.video.price_per_second_by_resolution')
|
|
|
|
|
|
}
|
|
|
|
|
|
pruneEmptyBillingConfig(cfg)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function addVideoResolutionPriceRow() {
|
|
|
|
|
|
videoResolutionPrices.value.push({ resolution: '', price_per_second: undefined })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function removeVideoResolutionPriceRow(idx: number) {
|
|
|
|
|
|
videoResolutionPrices.value.splice(idx, 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function fillVideoResolutionPricePreset(preset: 'common' | 'sora' | 'veo') {
|
|
|
|
|
|
videoResolutionPrices.value = VIDEO_RESOLUTION_PRICE_PRESETS[preset].map(r => ({
|
|
|
|
|
|
resolution: r.resolution,
|
|
|
|
|
|
price_per_second: r.price_per_second,
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 01:05:48 +08:00
|
|
|
|
function _copyVideoPricingFromSelectedGlobal() {
|
2026-02-03 18:48:39 +08:00
|
|
|
|
const gm = availableGlobalModels.value.find(m => m.id === form.value.global_model_id)
|
|
|
|
|
|
const cfg = gm?.config || {}
|
|
|
|
|
|
if (cfg && typeof cfg === 'object') {
|
|
|
|
|
|
const raw = getNested(cfg, 'billing.video.price_per_second_by_resolution')
|
|
|
|
|
|
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
|
|
|
|
videoResolutionPrices.value = Object.entries(raw).map(([k, v]) => ({
|
|
|
|
|
|
resolution: String(k),
|
|
|
|
|
|
price_per_second: typeof v === 'number' ? v : undefined,
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
configTouched.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 16:31:58 +08:00
|
|
|
|
async function createManualGlobalModel(finalTieredPricing: TieredPricingConfig | null, cleanConfig: Record<string, unknown> | undefined): Promise<GlobalModelResponse> {
|
|
|
|
|
|
const modelName = form.value.manual_global_model_name.trim()
|
|
|
|
|
|
const displayName = form.value.manual_global_model_display_name.trim() || modelName
|
|
|
|
|
|
const supportedCapabilities = [
|
|
|
|
|
|
form.value.supports_vision === true ? 'vision' : null,
|
|
|
|
|
|
form.value.supports_function_calling === true ? 'function_calling' : null,
|
|
|
|
|
|
form.value.supports_streaming === true ? 'streaming' : null,
|
|
|
|
|
|
form.value.supports_extended_thinking === true ? 'extended_thinking' : null,
|
|
|
|
|
|
form.value.supports_image_generation === true ? 'image_generation' : null,
|
|
|
|
|
|
].filter((capability): capability is string => capability !== null)
|
|
|
|
|
|
|
|
|
|
|
|
return createGlobalModel({
|
|
|
|
|
|
name: modelName,
|
|
|
|
|
|
display_name: displayName,
|
|
|
|
|
|
default_price_per_request: form.value.price_per_request,
|
|
|
|
|
|
default_tiered_pricing: finalTieredPricing || DEFAULT_MANUAL_GLOBAL_MODEL_PRICING,
|
|
|
|
|
|
supported_capabilities: supportedCapabilities.length ? supportedCapabilities : undefined,
|
|
|
|
|
|
config: cleanConfig,
|
|
|
|
|
|
is_active: true,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 加载可用的全局模型(排除已添加的)
|
|
|
|
|
|
async function loadAvailableGlobalModels() {
|
|
|
|
|
|
loadingGlobalModels.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [globalModelsResponse, existingModels] = await Promise.all([
|
|
|
|
|
|
listGlobalModels({ limit: 1000, is_active: true }),
|
|
|
|
|
|
getProviderModels(props.providerId)
|
|
|
|
|
|
])
|
|
|
|
|
|
const allGlobalModels = globalModelsResponse.models || []
|
|
|
|
|
|
|
|
|
|
|
|
// 获取当前 provider 已添加的模型的 global_model_id 列表
|
|
|
|
|
|
const existingGlobalModelIds = new Set(
|
2026-02-28 11:44:08 +08:00
|
|
|
|
existingModels.map((m: Model) => m.global_model_id)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// 过滤掉已添加的模型
|
|
|
|
|
|
availableGlobalModels.value = allGlobalModels.filter(
|
|
|
|
|
|
(gm: GlobalModelResponse) => !existingGlobalModelIds.has(gm.id)
|
|
|
|
|
|
)
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(parseApiError(err, '加载模型列表失败'), '错误')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
loadingGlobalModels.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 关闭对话框
|
|
|
|
|
|
function handleClose(value: boolean) {
|
|
|
|
|
|
if (!submitting.value) {
|
|
|
|
|
|
emit('update:open', value)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 提交表单
|
|
|
|
|
|
async function handleSubmit() {
|
|
|
|
|
|
if (submitting.value) return
|
2026-05-15 00:51:30 +08:00
|
|
|
|
if (!isEditing.value && !canSubmitCreate.value) {
|
2026-05-19 16:31:58 +08:00
|
|
|
|
showError(manualGlobalModelMode.value ? '请填写模型ID和 Provider 模型名' : '请选择模型并填写 Provider 模型名', '错误')
|
2026-05-15 00:51:30 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
submitting.value = true
|
|
|
|
|
|
try {
|
2025-12-12 15:43:00 +08:00
|
|
|
|
// 获取包含自动计算缓存价格的最终数据
|
2026-05-18 00:11:30 +08:00
|
|
|
|
const finalTieredPricing = tieredPricingEditorRef.value?.getFinalPricing() ?? tieredPricing.value
|
2026-05-18 03:19:58 +08:00
|
|
|
|
const supportsImageGeneration = isImageGenerationEnabled.value
|
|
|
|
|
|
|| tieredPricingHasImageOutputPricing(finalTieredPricing)
|
2025-12-12 15:43:00 +08:00
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
// Apply billing (video) pricing into config.
|
|
|
|
|
|
applyVideoPricingToConfig(form.value.config)
|
|
|
|
|
|
const cleanConfig = form.value.config && Object.keys(form.value.config).length > 0
|
|
|
|
|
|
? form.value.config
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if (isEditing.value && props.editingModel) {
|
|
|
|
|
|
// 编辑模式
|
|
|
|
|
|
// 注意:使用 null 而不是 undefined 来显式清空字段(undefined 会被 JSON 序列化忽略)
|
2026-05-03 17:32:41 +08:00
|
|
|
|
await updateModel(props.providerId, props.editingModel.id, buildProviderModelUpdatePayload({
|
|
|
|
|
|
finalTieredPricing,
|
|
|
|
|
|
pricePerRequest: form.value.price_per_request,
|
|
|
|
|
|
cleanConfig,
|
|
|
|
|
|
supportsVision: form.value.supports_vision,
|
|
|
|
|
|
supportsFunctionCalling: form.value.supports_function_calling,
|
|
|
|
|
|
supportsStreaming: form.value.supports_streaming,
|
|
|
|
|
|
supportsExtendedThinking: form.value.supports_extended_thinking,
|
2026-05-18 03:19:58 +08:00
|
|
|
|
supportsImageGeneration,
|
2026-05-03 17:32:41 +08:00
|
|
|
|
isActive: form.value.is_active
|
|
|
|
|
|
}))
|
2025-12-10 20:52:44 +08:00
|
|
|
|
showSuccess('模型配置已更新')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 添加模式:只有用户修改了配置才提交 tiered_pricing,否则保持继承关系
|
2026-05-19 16:31:58 +08:00
|
|
|
|
const selectedModel = manualGlobalModelMode.value
|
|
|
|
|
|
? await createManualGlobalModel(finalTieredPricing, cleanConfig)
|
|
|
|
|
|
: availableGlobalModels.value.find(m => m.id === form.value.global_model_id)
|
2026-05-15 00:51:30 +08:00
|
|
|
|
if (!selectedModel) {
|
2026-05-19 16:31:58 +08:00
|
|
|
|
showError('请选择模型,或切换到手动添加后填写模型ID', '错误')
|
2026-05-15 00:51:30 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-05-03 17:32:41 +08:00
|
|
|
|
await createModel(props.providerId, buildProviderModelCreatePayload({
|
2026-05-15 00:51:30 +08:00
|
|
|
|
globalModelId: selectedModel.id,
|
|
|
|
|
|
providerModelName: form.value.provider_model_name.trim(),
|
2026-05-03 17:32:41 +08:00
|
|
|
|
finalTieredPricing,
|
2026-05-19 16:31:58 +08:00
|
|
|
|
tieredPricingModified: manualGlobalModelMode.value ? false : tieredPricingModified.value,
|
|
|
|
|
|
pricePerRequest: manualGlobalModelMode.value ? undefined : form.value.price_per_request,
|
2026-05-03 17:32:41 +08:00
|
|
|
|
cleanConfig,
|
2026-05-19 16:31:58 +08:00
|
|
|
|
configTouched: manualGlobalModelMode.value ? false : configTouched.value,
|
2026-05-03 17:32:41 +08:00
|
|
|
|
supportsVision: form.value.supports_vision,
|
|
|
|
|
|
supportsFunctionCalling: form.value.supports_function_calling,
|
|
|
|
|
|
supportsStreaming: form.value.supports_streaming,
|
|
|
|
|
|
supportsExtendedThinking: form.value.supports_extended_thinking,
|
2026-05-18 03:19:58 +08:00
|
|
|
|
supportsImageGeneration,
|
2026-05-03 17:32:41 +08:00
|
|
|
|
isActive: form.value.is_active
|
|
|
|
|
|
}))
|
2025-12-10 20:52:44 +08:00
|
|
|
|
showSuccess('模型已添加')
|
|
|
|
|
|
}
|
|
|
|
|
|
emit('update:open', false)
|
|
|
|
|
|
emit('saved')
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(parseApiError(err, isEditing.value ? '更新失败' : '添加失败'), '错误')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
submitting.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|