Merge pull request #692 from zhefox/main

Sync global model prices and track online pricing sources
This commit is contained in:
ZheFox
2026-07-23 16:02:17 +08:00
committed by GitHub
11 changed files with 1255 additions and 61 deletions
@@ -63,6 +63,36 @@ describe('buildModelsDevTieredPricing', () => {
})
})
it('allows special token dimensions only when they use the base token price', () => {
expect(buildModelsDevTieredPricing({
input: 1,
output: 2,
input_audio: 1,
output_audio: 2,
reasoning: 2,
})).toEqual({
tiers: [{ up_to: null, input_price_per_1m: 1, output_price_per_1m: 2 }],
})
})
it.each([
{ input: 1, output: 2, reasoning: 4 },
{ input: 1, output: 2, input_audio: 3 },
{ input: 1, output: 2, output_audio: 5 },
{
input: 1,
output: 2,
tiers: [{
input: 3,
output: 4,
input_audio: 9,
tier: { type: 'context', size: 100_000 },
}],
},
])('rejects pricing dimensions the billing engine cannot settle independently', (cost) => {
expect(buildModelsDevTieredPricing(cost)).toBeNull()
})
it('omits an empty base band when context pricing starts at zero', () => {
expect(buildModelsDevTieredPricing({
input: 1,
@@ -51,6 +51,11 @@ describe('getModelsDevList', () => {
output: ['text'],
cost: { input: 1, output: 2 },
},
'audio-priced': {
id: 'audio-priced',
name: 'Audio Priced',
cost: { input: 1, output: 2, input_audio: 4 },
},
},
},
},
@@ -59,6 +64,7 @@ describe('getModelsDevList', () => {
const models = await getModelsDevList()
const current = models.find(model => model.modelId === 'gpt-test')
const legacy = models.find(model => model.modelId === 'legacy')
const audioPriced = models.find(model => model.modelId === 'audio-priced')
expect(current).toMatchObject({
supportsVision: true,
@@ -73,5 +79,11 @@ describe('getModelsDevList', () => {
inputModalities: ['text', 'image'],
outputModalities: ['text'],
})
expect(audioPriced).toMatchObject({
inputPrice: 1,
outputPrice: 2,
pricingUnsupportedFields: ['input_audio'],
})
expect(audioPriced?.tieredPricing).toBeUndefined()
})
})
+37
View File
@@ -21,6 +21,8 @@ export interface ModelsDevCost extends ModelsDevTokenCost {
tiers?: ModelsDevCostTier[]
}
export type ModelsDevUnsupportedPricingField = 'reasoning' | 'input_audio' | 'output_audio'
const TOKEN_PRICE_FIELDS = [
'input_price_per_1m',
'output_price_per_1m',
@@ -38,6 +40,40 @@ function isPrice(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0
}
const SPECIAL_PRICE_BASE_FIELDS: Array<{
field: ModelsDevUnsupportedPricingField
baseField: 'input' | 'output'
}> = [
{ field: 'reasoning', baseField: 'output' },
{ field: 'input_audio', baseField: 'input' },
{ field: 'output_audio', baseField: 'output' },
]
export function getModelsDevUnsupportedPricingFields(
cost: unknown,
): ModelsDevUnsupportedPricingField[] {
if (!isRecord(cost)) return []
const unsupportedFields = new Set<ModelsDevUnsupportedPricingField>()
const inspectPrices = (prices: Record<string, unknown>) => {
for (const { field, baseField } of SPECIAL_PRICE_BASE_FIELDS) {
const specialPrice = prices[field]
if (specialPrice === undefined) continue
const basePrice = prices[baseField]
if (!isPrice(specialPrice) || !isPrice(basePrice) || specialPrice !== basePrice) {
unsupportedFields.add(field)
}
}
}
inspectPrices(cost)
if (Array.isArray(cost.tiers)) {
for (const tier of cost.tiers) {
if (isRecord(tier)) inspectPrices(tier)
}
}
return [...unsupportedFields]
}
function parseTokenPrices(value: unknown): Omit<PricingTier, 'up_to'> | null {
if (!isRecord(value) || !isPrice(value.input) || !isPrice(value.output)) return null
if (value.cache_write !== undefined && !isPrice(value.cache_write)) return null
@@ -70,6 +106,7 @@ function parseContextTier(value: unknown): { size: number; prices: Omit<PricingT
}
export function buildModelsDevTieredPricing(cost: unknown): TieredPricingConfig | null {
if (getModelsDevUnsupportedPricingFields(cost).length > 0) return null
const basePrices = parseTokenPrices(cost)
if (!basePrices || !isRecord(cost)) return null
+11
View File
@@ -5,8 +5,10 @@
import api from './client'
import {
getModelsDevUnsupportedPricingFields,
resolveModelsDevTieredPricing,
type ModelsDevCost,
type ModelsDevUnsupportedPricingField,
} from './models-dev-pricing'
import type { TieredPricingConfig } from './endpoints/types'
@@ -78,6 +80,7 @@ export interface ModelsDevModelItem {
inputPrice?: number
outputPrice?: number
tieredPricing?: TieredPricingConfig
pricingUnsupportedFields?: ModelsDevUnsupportedPricingField[]
contextLimit?: number
outputLimit?: number
supportsVision?: boolean
@@ -187,6 +190,11 @@ export async function getModelsDevList(officialOnly: boolean = true): Promise<Mo
model.cost,
model.experimental?.modes,
)
const pricingUnsupportedFields = [...new Set([
...getModelsDevUnsupportedPricingFields(model.cost),
...Object.values(model.experimental?.modes ?? {})
.flatMap(mode => getModelsDevUnsupportedPricingFields(mode.cost)),
])]
const basePricingTier = tieredPricing?.tiers[0]
items.push({
providerId,
@@ -197,6 +205,9 @@ export async function getModelsDevList(officialOnly: boolean = true): Promise<Mo
inputPrice: basePricingTier?.input_price_per_1m ?? model.cost?.input,
outputPrice: basePricingTier?.output_price_per_1m ?? model.cost?.output,
tieredPricing: tieredPricing ?? undefined,
pricingUnsupportedFields: pricingUnsupportedFields.length > 0
? pricingUnsupportedFields
: undefined,
contextLimit: model.limit?.context,
outputLimit: model.limit?.output,
supportsVision: inputModalities?.includes('image'),
@@ -99,7 +99,7 @@
v-for="item in expandedProviderGroup.models"
:key="item.modelId"
type="button"
class="group relative flex min-h-[152px] min-w-0 flex-col rounded-xl border bg-card p-4 text-left shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-md"
class="group relative flex min-h-[152px] min-w-0 flex-col rounded-lg border bg-card p-4 text-left shadow-sm transition-[border-color,box-shadow,transform,background-color] duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:shadow-md active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
:class="selectedModel?.modelId === item.modelId && selectedModel?.providerId === item.providerId
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border/70'"
@@ -122,10 +122,30 @@
<span class="block truncate text-sm font-semibold leading-5">{{ item.modelName }}</span>
<span class="block truncate font-mono text-[10px] text-muted-foreground">{{ item.modelId }}</span>
</span>
<span
v-if="item.family"
class="max-w-[88px] shrink-0 truncate rounded-md bg-muted px-1.5 py-0.5 text-[9px] font-medium text-muted-foreground"
>{{ item.family }}</span>
<span class="flex shrink-0 flex-col items-end gap-1">
<span
v-if="getExistingModel(item)"
class="inline-flex h-5 items-center gap-1.5 text-[10px] font-medium text-muted-foreground"
:title="`已添加 · ${getPricingSyncLabel(item)}`"
>
<CircleCheck class="h-3 w-3 text-foreground/50" />
<span>已添加</span>
<span
class="h-1.5 w-1.5 rounded-full"
:class="getPricingSyncIndicatorClass(item)"
/>
<span>{{ getPricingSyncLabel(item) }}</span>
</span>
<span
v-if="isRememberedPricingSource(item)"
class="rounded-md bg-primary/8 px-1.5 py-0.5 text-[9px] font-medium text-primary"
title="上次手动导入或同步使用此价格来源"
>上次来源</span>
<span
v-if="item.family"
class="max-w-[88px] truncate rounded-md bg-muted px-1.5 py-0.5 text-[9px] font-medium text-muted-foreground"
>{{ item.family }}</span>
</span>
</span>
<span class="mt-2 flex min-h-5 flex-wrap gap-1">
@@ -175,7 +195,7 @@
</span>
<span
v-if="item.inputPrice !== undefined || item.outputPrice !== undefined"
class="shrink-0 text-right font-medium text-foreground/70"
class="shrink-0 text-right font-medium tabular-nums text-foreground/70"
>
<span class="block">输入 ${{ formatModelPrice(item.inputPrice) }}/M</span>
<span class="block">输出 ${{ formatModelPrice(item.outputPrice) }}/M</span>
@@ -255,12 +275,72 @@
返回选择模型
</Button>
</div>
<section
v-if="selectedExistingModel"
class="mb-4 space-y-3 rounded-lg border border-sky-500/20 bg-sky-500/5 p-4"
>
<div>
<h4 class="text-sm font-medium">
同步在线价格
</h4>
<p class="mt-1 text-xs text-muted-foreground">
选择在线价格并点击同步后仅更新该模型的价格配置
</p>
</div>
<div
v-if="selectedModel?.pricingUnsupportedFields?.length"
class="rounded-md border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-800 dark:text-rose-200"
>
当前计费引擎无法独立结算{{ formatUnsupportedPricingFields(selectedModel.pricingUnsupportedFields) }}无法同步以避免错误计价
</div>
<div
v-else-if="!selectedModel?.tieredPricing"
class="rounded-md border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200"
>
在线目录未提供该模型的价格暂时无法同步
</div>
<div
v-else-if="onlinePricingMatchesExisting"
class="rounded-md border border-emerald-500/20 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-800 dark:text-emerald-200"
>
当前价格与在线目录一致无需更新
</div>
<div
v-else
class="grid grid-cols-1 gap-2 sm:grid-cols-2"
>
<button
type="button"
class="rounded-md border px-3 py-2.5 text-left transition-colors"
:class="pricingSource === 'current'
? 'border-primary bg-background ring-1 ring-primary'
: 'border-border/70 bg-background/60 hover:border-border'"
@click="restoreExistingPricing"
>
<span class="block text-xs font-medium">保留当前价格</span>
<span class="mt-1 block text-[11px] text-muted-foreground">{{ formatPricingSummary(selectedExistingModel.default_tiered_pricing) }}</span>
</button>
<button
type="button"
class="rounded-md border px-3 py-2.5 text-left transition-colors"
:class="pricingSource === 'online'
? 'border-primary bg-background ring-1 ring-primary'
: 'border-border/70 bg-background/60 hover:border-border'"
@click="applyOnlinePricing"
>
<span class="block text-xs font-medium">使用在线价格</span>
<span class="mt-1 block text-[11px] text-muted-foreground">{{ formatPricingSummary(selectedModel.tieredPricing) }}</span>
</button>
</div>
</section>
<form
class="space-y-5"
@submit.prevent="handleSubmit"
>
<!-- 基本信息 -->
<section
v-if="!selectedExistingModel"
ref="basicInfoSection"
class="space-y-3 rounded-lg border bg-card p-4"
>
@@ -366,7 +446,35 @@
</section>
<!-- 价格配置 -->
<section class="space-y-3 rounded-lg border bg-card p-4">
<section
v-if="selectedExistingModel"
class="space-y-3 rounded-lg border bg-card p-4"
>
<div>
<h4 class="text-sm font-medium">
{{ pricingSource === 'online' ? '在线价格预览' : '当前价格' }}
</h4>
<p class="mt-1 text-xs text-muted-foreground">
可在同步前检查阶梯价格;在线价格应用后仍可微调。
</p>
</div>
<TieredPricingEditor
ref="tieredPricingEditorRef"
v-model="tieredPricing"
:auto-fill-missing-cache-prices="false"
:show-token-pricing="true"
:show-image-pricing="tieredPricingHasImageOutputPricing(tieredPricing)"
:show-image-editor="tieredPricingHasImageOutputPricing(tieredPricing)"
:show-processing-tier-controls="false"
:show-processing-tier-multiplier-controls="true"
/>
</section>
<!-- 价格配置 -->
<section
v-else
class="space-y-3 rounded-lg border bg-card p-4"
>
<h4 class="font-medium text-sm">
选择计费模式
</h4>
@@ -551,14 +659,14 @@
</Button>
<Button
v-if="isEditMode || presetPanelCollapsed"
:disabled="submitting || !form.name || !form.display_name"
:disabled="submitting || !form.name || !form.display_name || (!!selectedExistingModel && !canSubmitPriceSync)"
@click="handleSubmit"
>
<Loader2
v-if="submitting"
class="w-4 h-4 mr-2 animate-spin"
/>
{{ isEditMode ? '保存' : '添加' }}
{{ isEditMode ? '保存' : selectedExistingModel ? priceSyncSubmitLabel : '添加' }}
</Button>
<Button
v-if="selectedModel && !isEditMode && presetPanelCollapsed"
@@ -577,7 +685,7 @@ import { ref, computed, nextTick, watch } from 'vue'
import {
Loader2, Layers, SquarePen,
Search, ChevronLeft, ChevronRight, Plus, Trash2, Check,
BrainCircuit, Eye, Wrench, Braces, Database, PackageOpen
BrainCircuit, Eye, Wrench, Braces, Database, PackageOpen, CircleCheck
} from 'lucide-vue-next'
import {
Dialog, Button, Input, Label, Checkbox,
@@ -596,6 +704,7 @@ import {
} from '@/api/models-dev'
import {
createGlobalModel,
listGlobalModels,
updateGlobalModel,
type GlobalModelResponse,
} from '@/api/global-models'
@@ -605,8 +714,11 @@ import {
buildGlobalModelCreatePayload,
buildGlobalModelUpdatePayload,
cloneTieredPricingConfig,
findGlobalModelByName,
tieredPricingConfigsEqual,
} from './global-model-form-helpers'
import { tieredPricingHasImageOutputPricing } from '../utils/tiered-pricing'
import { useModelsDevPricingSources } from '../composables/useModelsDevPricingSources'
const props = defineProps<{
open: boolean
@@ -619,6 +731,7 @@ const emit = defineEmits<{
}>()
const { success, error: showError } = useToast()
const { getSource, setSource } = useModelsDevPricingSources()
const submitting = ref(false)
const tieredPricingEditorRef = ref<InstanceType<typeof TieredPricingEditor> | null>(null)
const basicInfoSection = ref<HTMLElement | null>(null)
@@ -627,11 +740,95 @@ const basicInfoSection = ref<HTMLElement | null>(null)
const loading = ref(false)
const searchQuery = ref('')
const allModelsCache = ref<ModelsDevModelItem[]>([]) // 全部模型(缓存)
const existingModelsCache = ref<GlobalModelResponse[]>([])
const selectedModel = ref<ModelsDevModelItem | null>(null)
const expandedProvider = ref<string | null>(null)
const providerLogoScroller = ref<HTMLElement | null>(null)
const presetPanelCollapsed = ref(false)
const billingMode = ref('token')
const pricingSource = ref<'current' | 'online'>('current')
function getExistingModel(model: ModelsDevModelItem): GlobalModelResponse | undefined {
return findGlobalModelByName(existingModelsCache.value, model.modelId)
}
function isRememberedPricingSource(model: ModelsDevModelItem): boolean {
const existingModel = getExistingModel(model)
return !!existingModel && getSource(existingModel.id)?.provider_id === model.providerId
}
type PricingSyncState = 'same' | 'different' | 'unsupported' | 'unavailable'
function getPricingSyncState(model: ModelsDevModelItem): PricingSyncState {
const existingModel = getExistingModel(model)
if (model.pricingUnsupportedFields?.length) return 'unsupported'
if (!existingModel || !model.tieredPricing) return 'unavailable'
return tieredPricingConfigsEqual(existingModel.default_tiered_pricing, model.tieredPricing)
? 'same'
: 'different'
}
function getPricingSyncLabel(model: ModelsDevModelItem): string {
const state = getPricingSyncState(model)
if (state === 'same') return '价格一致'
if (state === 'different') return '价格可更新'
if (state === 'unsupported') return '计价不兼容'
return '无在线价格'
}
function getPricingSyncIndicatorClass(model: ModelsDevModelItem): string {
const state = getPricingSyncState(model)
if (state === 'same') return 'bg-emerald-500'
if (state === 'different') return 'bg-amber-500'
if (state === 'unsupported') return 'bg-rose-500'
return 'bg-muted-foreground/45'
}
function formatUnsupportedPricingFields(fields: ModelsDevModelItem['pricingUnsupportedFields']): string {
const labels = {
reasoning: '推理 Token',
input_audio: '输入音频 Token',
output_audio: '输出音频 Token',
}
return (fields ?? []).map(field => labels[field]).join('、')
}
const selectedExistingModel = computed(() => (
selectedModel.value ? getExistingModel(selectedModel.value) : undefined
))
const onlinePricingMatchesExisting = computed(() => (
!!selectedExistingModel.value
&& !!selectedModel.value?.tieredPricing
&& tieredPricingConfigsEqual(
selectedExistingModel.value.default_tiered_pricing,
selectedModel.value.tieredPricing,
)
))
const selectedPricingSourceMatches = computed(() => (
!!selectedExistingModel.value
&& !!selectedModel.value
&& getSource(selectedExistingModel.value.id)?.provider_id === selectedModel.value.providerId
))
const canSubmitPriceSync = computed(() => (
!!selectedExistingModel.value
&& !!selectedModel.value?.tieredPricing
&& (
(onlinePricingMatchesExisting.value && !selectedPricingSourceMatches.value)
|| (!onlinePricingMatchesExisting.value && pricingSource.value === 'online')
)
))
const priceSyncSubmitLabel = computed(() => {
if (!selectedModel.value?.tieredPricing) return '暂无在线价格'
if (onlinePricingMatchesExisting.value) {
return selectedPricingSourceMatches.value ? '价格已是最新' : '保存价格来源'
}
if (pricingSource.value !== 'online') return '请选择在线价格'
return '同步价格'
})
function formatTokenLimit(value: number): string {
if (value >= 1_000_000) {
@@ -650,6 +847,12 @@ function formatModelPrice(value?: number): string {
return value.toFixed(precision).replace(/\.?0+$/, '')
}
function formatPricingSummary(pricing?: TieredPricingConfig | null): string {
const firstTier = pricing?.tiers?.[0]
if (!firstTier) return '未配置 Token 价格'
return `输入 $${formatModelPrice(firstTier.input_price_per_1m)}/M · 输出 $${formatModelPrice(firstTier.output_price_per_1m)}/M`
}
// 当前显示的模型列表:有搜索词时用全部,否则只用官方
const allModels = computed(() => {
if (searchQuery.value) {
@@ -1043,18 +1246,31 @@ function fillVideoResolutionPricePreset(preset: 'common' | 'sora' | 'veo') {
}
// 加载模型列表
async function loadExistingModels() {
const models: GlobalModelResponse[] = []
let total = 0
do {
const response = await listGlobalModels({ skip: models.length, limit: 1000 })
models.push(...response.models)
total = response.total
if (response.models.length === 0) break
} while (models.length < total)
existingModelsCache.value = models
}
// 加载在线目录和已有模型列表
async function loadModels() {
if (allModelsCache.value.length > 0) return
loading.value = true
try {
// 只加载一次全部模型,过滤在 computed 中完成
allModelsCache.value = await getModelsDevList(false)
} catch (err) {
log.error('Failed to load models:', err)
} finally {
loading.value = false
}
await Promise.all([
allModelsCache.value.length > 0
? Promise.resolve()
: getModelsDevList(false)
.then(models => { allModelsCache.value = models })
.catch(err => log.error('Failed to load online models:', err)),
loadExistingModels()
.catch(err => log.error('Failed to load existing models:', err)),
])
loading.value = false
}
// 打开对话框时加载数据
@@ -1073,6 +1289,15 @@ function selectModel(model: ModelsDevModelItem) {
selectedModel.value = model
expandedProvider.value = model.providerId
const existingModel = getExistingModel(model)
if (existingModel) {
populateFormFromGlobalModel(existingModel)
pricingSource.value = 'current'
presetPanelCollapsed.value = true
scrollToBasicInformation()
return
}
// 构建 config
const config: Record<string, unknown> = {
streaming: model.supportsEmbedding ? false : true,
@@ -1121,6 +1346,18 @@ function selectModel(model: ModelsDevModelItem) {
scrollToBasicInformation()
}
function applyOnlinePricing() {
if (!selectedModel.value?.tieredPricing) return
tieredPricing.value = cloneTieredPricingConfig(selectedModel.value.tieredPricing)
pricingSource.value = 'online'
}
function restoreExistingPricing() {
if (!selectedExistingModel.value) return
tieredPricing.value = cloneTieredPricingConfig(selectedExistingModel.value.default_tiered_pricing)
pricingSource.value = 'current'
}
// 清除选择(手动填写)
function clearSelection() {
imageGenerationExplicitOverride.value = null
@@ -1129,6 +1366,7 @@ function clearSelection() {
tieredPricing.value = null
videoResolutionPrices.value = []
billingMode.value = 'token'
pricingSource.value = 'current'
}
// Logo 加载失败处理
@@ -1148,35 +1386,27 @@ function resetForm() {
expandedProvider.value = null
presetPanelCollapsed.value = false
billingMode.value = 'token'
pricingSource.value = 'current'
}
// 加载模型数据(编辑模式)
function loadModelData() {
if (!props.model) return
function populateFormFromGlobalModel(model: GlobalModelResponse) {
imageGenerationExplicitOverride.value = null
// 先重置创建模式的残留状态
selectedModel.value = null
searchQuery.value = ''
expandedProvider.value = null
presetPanelCollapsed.value = false
const modelTieredPricing = props.model.default_tiered_pricing
? JSON.parse(JSON.stringify(props.model.default_tiered_pricing))
const modelTieredPricing = model.default_tiered_pricing
? cloneTieredPricingConfig(model.default_tiered_pricing)
: null
const supportedCapabilities = new Set(props.model.supported_capabilities || [])
const supportedCapabilities = new Set(model.supported_capabilities || [])
if (tieredPricingHasImageOutputPricing(modelTieredPricing)) {
supportedCapabilities.add('image_generation')
}
form.value = {
name: props.model.name,
display_name: props.model.display_name,
default_price_per_request: props.model.default_price_per_request,
name: model.name,
display_name: model.display_name,
default_price_per_request: model.default_price_per_request,
supported_capabilities: [...supportedCapabilities],
config: props.model.config ? { ...props.model.config } : { streaming: true },
is_active: props.model.is_active,
config: model.config ? { ...model.config } : { streaming: true },
is_active: model.is_active,
}
// 确保 tieredPricing 也被正确设置或重置
tieredPricing.value = modelTieredPricing
loadVideoPricingFromConfig()
if (videoResolutionPrices.value.length > 0) {
@@ -1190,6 +1420,18 @@ function loadModelData() {
}
}
// 加载模型数据(编辑模式)
function loadModelData() {
if (!props.model) return
// 先重置创建模式的残留状态
selectedModel.value = null
searchQuery.value = ''
expandedProvider.value = null
presetPanelCollapsed.value = false
pricingSource.value = 'current'
populateFormFromGlobalModel(props.model)
}
// 使用 useFormDialog 统一处理对话框逻辑
const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
isOpen: () => props.open,
@@ -1235,13 +1477,46 @@ async function handleSubmit() {
submitting.value = true
try {
if (isEditMode.value && props.model) {
const existingModel = selectedExistingModel.value
if (existingModel) {
if (!canSubmitPriceSync.value) {
showError('请先选择使用在线价格')
return
}
const pricingWillChange = !tieredPricingConfigsEqual(
existingModel.default_tiered_pricing,
finalTieredPricing,
)
if (pricingWillChange) {
await updateGlobalModel(existingModel.id, {
default_tiered_pricing: finalTieredPricing,
})
existingModel.default_tiered_pricing = cloneTieredPricingConfig(finalTieredPricing)
}
if (selectedModel.value) {
setSource(existingModel.id, {
provider_id: selectedModel.value.providerId,
provider_name: selectedModel.value.providerName,
})
}
success(pricingWillChange ? '模型价格同步成功' : '模型价格来源已保存')
reopenPresetPanel()
emit('success')
return
} else if (isEditMode.value && props.model) {
const updateData = buildGlobalModelUpdatePayload(form.value, finalTieredPricing)
await updateGlobalModel(props.model.id, updateData)
success('模型更新成功')
} else {
const createData = buildGlobalModelCreatePayload(form.value, finalTieredPricing)
await createGlobalModel(createData)
const createdModel = await createGlobalModel(createData)
existingModelsCache.value.unshift(createdModel)
if (selectedModel.value) {
setSource(createdModel.id, {
provider_id: selectedModel.value.providerId,
provider_name: selectedModel.value.providerName,
})
}
success('模型创建成功')
clearSelection()
emit('success')
@@ -9,6 +9,7 @@ import {
} from 'vue'
import type { ModelsDevModelItem } from '@/api/models-dev'
import type { GlobalModelResponse } from '@/api/global-models'
import GlobalModelFormDialog from '../GlobalModelFormDialog.vue'
const modelsDevMocks = vi.hoisted(() => ({
@@ -17,6 +18,7 @@ const modelsDevMocks = vi.hoisted(() => ({
const globalModelMocks = vi.hoisted(() => ({
createGlobalModel: vi.fn(),
listGlobalModels: vi.fn(),
updateGlobalModel: vi.fn(),
}))
@@ -27,6 +29,7 @@ vi.mock('@/api/models-dev', () => ({
vi.mock('@/api/global-models', () => ({
createGlobalModel: globalModelMocks.createGlobalModel,
listGlobalModels: globalModelMocks.listGlobalModels,
updateGlobalModel: globalModelMocks.updateGlobalModel,
}))
@@ -89,6 +92,35 @@ const freshPreset: ModelsDevModelItem = {
},
}
const unsupportedPreset: ModelsDevModelItem = {
providerId: 'openai',
providerName: 'OpenAI',
modelId: 'reasoning-priced-model',
modelName: 'Reasoning Priced Model',
official: true,
inputPrice: 1,
outputPrice: 2,
pricingUnsupportedFields: ['reasoning'],
}
function buildExistingStaleModel(): GlobalModelResponse {
return {
id: 'global-stale-model',
name: 'stale-model',
display_name: 'Configured Stale Model',
is_active: true,
default_tiered_pricing: {
tiers: [{
up_to: null,
input_price_per_1m: 9,
output_price_per_1m: 18,
}],
},
config: { streaming: true },
created_at: '2026-07-23T00:00:00Z',
}
}
function mountDialog() {
const root = document.createElement('div')
document.body.appendChild(root)
@@ -140,11 +172,15 @@ async function setInput(input: HTMLInputElement | null, value: string) {
}
beforeEach(() => {
localStorage.clear()
modelsDevMocks.getModelsDevList.mockReset()
modelsDevMocks.getModelsDevList.mockResolvedValue([stalePreset, freshPreset])
modelsDevMocks.getModelsDevList.mockResolvedValue([stalePreset, freshPreset, unsupportedPreset])
globalModelMocks.createGlobalModel.mockReset()
globalModelMocks.createGlobalModel.mockResolvedValue({})
globalModelMocks.createGlobalModel.mockResolvedValue({ id: 'created-model' })
globalModelMocks.listGlobalModels.mockReset()
globalModelMocks.listGlobalModels.mockResolvedValue({ models: [], total: 0 })
globalModelMocks.updateGlobalModel.mockReset()
globalModelMocks.updateGlobalModel.mockResolvedValue({})
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
value: vi.fn(),
configurable: true,
@@ -234,6 +270,16 @@ describe('GlobalModelFormDialog preset replacement', () => {
],
},
})
expect(JSON.parse(
localStorage.getItem('aether:models-dev-pricing-sources:v1') || 'null',
)).toMatchObject({
models: {
'created-model': {
provider_id: 'openai',
provider_name: 'OpenAI',
},
},
})
expect(payload.config).not.toHaveProperty('description')
expect(payload.config).not.toHaveProperty('billing')
expect(payload.default_tiered_pricing).not.toHaveProperty('processing_tiers')
@@ -278,4 +324,82 @@ describe('GlobalModelFormDialog preset replacement', () => {
})
expect(payload.default_tiered_pricing.processing_tiers).not.toHaveProperty('standard')
})
it('marks an existing model and updates only its online pricing after confirmation', async () => {
const existingStaleModel = buildExistingStaleModel()
localStorage.setItem('aether:models-dev-pricing-sources:v1', JSON.stringify({
version: 1,
models: {
[existingStaleModel.id]: {
provider_id: 'openai',
provider_name: 'OpenAI',
},
},
}))
globalModelMocks.listGlobalModels.mockResolvedValue({
models: [existingStaleModel],
total: 1,
})
mountDialog()
await settle()
expect(document.body.textContent).toContain('已添加')
expect(document.body.textContent).toContain('价格可更新')
expect(document.body.textContent).toContain('上次来源')
findButton('Stale Model').click()
await settle()
expect(document.body.textContent).toContain('仅更新该模型的价格配置')
expect(document.body.querySelector<HTMLInputElement>('[data-testid="tier-input-price"]')?.value).toBe('9')
expect(document.body.querySelector('[aria-label="选择已有模型时自动应用在线价格"]')).toBeNull()
expect(findExactButton('请选择在线价格').disabled).toBe(true)
findButton('使用在线价格').click()
await settle()
expect(document.body.querySelector<HTMLInputElement>('[data-testid="tier-input-price"]')?.value).toBe('1')
findExactButton('同步价格').click()
await settle()
expect(globalModelMocks.updateGlobalModel).toHaveBeenCalledOnce()
expect(globalModelMocks.updateGlobalModel).toHaveBeenCalledWith(
existingStaleModel.id,
{ default_tiered_pricing: stalePreset.tieredPricing },
)
expect(JSON.parse(
localStorage.getItem('aether:models-dev-pricing-sources:v1') || 'null',
)).toMatchObject({
models: {
[existingStaleModel.id]: {
provider_id: 'openai',
provider_name: 'OpenAI',
},
},
})
expect(globalModelMocks.createGlobalModel).not.toHaveBeenCalled()
})
it('blocks manual updates when the online source has unsupported pricing dimensions', async () => {
const existingModel = {
...buildExistingStaleModel(),
id: 'reasoning-priced-global-model',
name: unsupportedPreset.modelId,
display_name: unsupportedPreset.modelName,
}
globalModelMocks.listGlobalModels.mockResolvedValue({
models: [existingModel],
total: 1,
})
mountDialog()
await settle()
expect(document.body.textContent).toContain('计价不兼容')
findButton(unsupportedPreset.modelName).click()
await settle()
expect(document.body.textContent).toContain('无法独立结算推理 Token')
expect(findExactButton('暂无在线价格').disabled).toBe(true)
expect(globalModelMocks.updateGlobalModel).not.toHaveBeenCalled()
})
})
@@ -3,9 +3,12 @@ import { reactive } from 'vue'
import {
EMBEDDING_API_FORMATS,
buildGlobalModelPriceSyncPlan,
buildGlobalModelCreatePayload,
buildGlobalModelUpdatePayload,
cloneTieredPricingConfig,
findGlobalModelByName,
tieredPricingConfigsEqual,
} from '../global-model-form-helpers'
import type { TieredPricingConfig } from '@/api/endpoints/types'
@@ -89,4 +92,131 @@ describe('global model form pricing presets', () => {
cloned.tiers[0].input_price_per_1m = 9
expect(pricing.tiers[0].input_price_per_1m).toBe(3)
})
it('matches existing models by normalized model ID', () => {
const existingModel = { id: 'model-1', name: ' Claude-Sonnet-5 ' }
expect(findGlobalModelByName([existingModel], 'claude-sonnet-5')).toBe(existingModel)
expect(findGlobalModelByName([existingModel], 'claude-opus-5')).toBeUndefined()
})
it('compares pricing independently of object key order', () => {
const currentPricing = {
processing_tiers: {
priority: { price_multiplier: 2 },
},
tiers: [{
output_price_per_1m: 15,
input_price_per_1m: 3,
up_to: null,
}],
} as TieredPricingConfig
const onlinePricing = {
tiers: [{
up_to: null,
input_price_per_1m: 3,
output_price_per_1m: 15,
}],
processing_tiers: {
priority: { price_multiplier: 2 },
},
} as TieredPricingConfig
expect(tieredPricingConfigsEqual(currentPricing, onlinePricing)).toBe(true)
onlinePricing.tiers[0].output_price_per_1m = 16
expect(tieredPricingConfigsEqual(currentPricing, onlinePricing)).toBe(false)
})
it('groups models by their selected provider pricing sync state', () => {
const makeGlobalModel = (id: string, name: string, inputPrice: number) => ({
id,
name,
display_name: name,
is_active: true,
default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: inputPrice, output_price_per_1m: 10 }],
},
created_at: '2026-07-23T00:00:00Z',
})
const makeOnlineModel = (modelId: string, inputPrice?: number) => ({
providerId: 'anthropic',
providerName: 'Anthropic',
modelId,
modelName: modelId,
tieredPricing: inputPrice === undefined
? undefined
: { tiers: [{ up_to: null, input_price_per_1m: inputPrice, output_price_per_1m: 10 }] },
})
const currentModel = makeGlobalModel('current', 'current-model', 2)
const staleModel = makeGlobalModel('stale', 'stale-model', 3)
const unavailableModel = makeGlobalModel('missing', 'missing-model', 4)
const unsupportedModel = makeGlobalModel('unsupported', 'unsupported-model', 5)
const plan = buildGlobalModelPriceSyncPlan(
[currentModel, staleModel, unavailableModel, unsupportedModel],
[
makeOnlineModel('current-model', 2),
makeOnlineModel('stale-model', 5),
{
...makeOnlineModel('unsupported-model'),
pricingUnsupportedFields: ['reasoning'],
},
],
)
expect(plan.unchanged.map(entry => entry.model.id)).toEqual(['current'])
expect(plan.syncable.map(entry => entry.model.id)).toEqual(['stale'])
expect(plan.unsupported.map(entry => entry.model.id)).toEqual(['unsupported'])
expect(plan.unavailable.map(model => model.id)).toEqual(['missing'])
})
it('resolves each model from its remembered pricing provider', () => {
const makeGlobalModel = (id: string, name: string) => ({
id,
name,
display_name: name,
is_active: true,
default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 1, output_price_per_1m: 10 }],
},
created_at: '2026-07-23T00:00:00Z',
})
const makeOnlineModel = (providerId: string, modelId: string, inputPrice: number) => ({
providerId,
providerName: providerId,
modelId,
modelName: modelId,
tieredPricing: {
tiers: [{ up_to: null, input_price_per_1m: inputPrice, output_price_per_1m: 10 }],
},
})
const openAiModel = makeGlobalModel('openai-model', 'shared-model')
const anthropicModel = makeGlobalModel('anthropic-model', 'other-model')
const missingSourceModel = makeGlobalModel('missing-source', 'shared-model')
const plan = buildGlobalModelPriceSyncPlan(
[openAiModel, anthropicModel, missingSourceModel],
[
makeOnlineModel('anthropic', 'shared-model', 2),
makeOnlineModel('openai', 'shared-model', 3),
makeOnlineModel('anthropic', 'other-model', 4),
makeOnlineModel('openai', 'other-model', 5),
],
new Map([
[openAiModel.id, 'openai'],
[anthropicModel.id, 'anthropic'],
]),
)
expect(plan.syncable.map(entry => [
entry.model.id,
entry.onlineModel.providerId,
entry.onlineModel.tieredPricing?.tiers[0].input_price_per_1m,
])).toEqual([
['openai-model', 'openai', 3],
['anthropic-model', 'anthropic', 4],
])
expect(plan.unavailable.map(model => model.id)).toEqual(['missing-source'])
})
})
@@ -1,5 +1,6 @@
import type { GlobalModelCreate, GlobalModelUpdate } from '@/api/global-models'
import type { GlobalModelCreate, GlobalModelResponse, GlobalModelUpdate } from '@/api/global-models'
import type { TieredPricingConfig } from '@/api/endpoints/types'
import type { ModelsDevModelItem } from '@/api/models-dev'
export const EMBEDDING_API_FORMATS = [
'openai:embedding',
@@ -31,6 +32,94 @@ export function cloneTieredPricingConfig(
return JSON.parse(JSON.stringify(pricing)) as TieredPricingConfig
}
export function findGlobalModelByName<T extends { name: string }>(
models: T[],
modelName: string,
): T | undefined {
const normalizedName = modelName.trim().toLowerCase()
return models.find(model => model.name.trim().toLowerCase() === normalizedName)
}
function normalizeJsonValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(normalizeJsonValue)
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.filter(([, entryValue]) => entryValue !== undefined)
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
.map(([key, entryValue]) => [key, normalizeJsonValue(entryValue)]),
)
}
return value
}
export function tieredPricingConfigsEqual(
currentPricing: TieredPricingConfig | null | undefined,
onlinePricing: TieredPricingConfig | null | undefined,
): boolean {
return JSON.stringify(normalizeJsonValue(currentPricing ?? null))
=== JSON.stringify(normalizeJsonValue(onlinePricing ?? null))
}
export interface GlobalModelPriceSyncEntry {
model: GlobalModelResponse
onlineModel: ModelsDevModelItem
}
export interface GlobalModelPriceSyncPlan {
syncable: GlobalModelPriceSyncEntry[]
unchanged: GlobalModelPriceSyncEntry[]
unsupported: GlobalModelPriceSyncEntry[]
unavailable: GlobalModelResponse[]
}
export function buildGlobalModelPriceSyncPlan(
models: GlobalModelResponse[],
onlineModels: ModelsDevModelItem[],
pricingProviderIds?: ReadonlyMap<string, string>,
): GlobalModelPriceSyncPlan {
const onlineModelsByName = new Map<string, ModelsDevModelItem>()
const onlineModelsBySource = new Map<string, ModelsDevModelItem>()
for (const onlineModel of onlineModels) {
const normalizedName = onlineModel.modelId.trim().toLowerCase()
if (!onlineModelsByName.has(normalizedName)) {
onlineModelsByName.set(normalizedName, onlineModel)
}
onlineModelsBySource.set(
`${onlineModel.providerId.trim().toLowerCase()}\u0000${normalizedName}`,
onlineModel,
)
}
const plan: GlobalModelPriceSyncPlan = {
syncable: [],
unchanged: [],
unsupported: [],
unavailable: [],
}
for (const model of models) {
const normalizedName = model.name.trim().toLowerCase()
const sourceProviderId = pricingProviderIds?.get(model.id)?.trim().toLowerCase()
const onlineModel = pricingProviderIds
? sourceProviderId
? onlineModelsBySource.get(`${sourceProviderId}\u0000${normalizedName}`)
: undefined
: onlineModelsByName.get(normalizedName)
if (onlineModel?.pricingUnsupportedFields?.length) {
plan.unsupported.push({ model, onlineModel })
} else if (!onlineModel?.tieredPricing) {
plan.unavailable.push(model)
} else if (tieredPricingConfigsEqual(model.default_tiered_pricing, onlineModel.tieredPricing)) {
plan.unchanged.push({ model, onlineModel })
} else {
plan.syncable.push({ model, onlineModel })
}
}
return plan
}
function cleanGlobalModelConfig(form: GlobalModelFormPayloadState): Record<string, unknown> | undefined {
return form.config && Object.keys(form.config).length > 0 ? form.config : undefined
}
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { useModelsDevPricingSources } from '../useModelsDevPricingSources'
const STORAGE_KEY = 'aether:models-dev-pricing-sources:v1'
const LEGACY_STORAGE_KEY = 'aether:models-dev-pricing-preferences:v1'
describe('useModelsDevPricingSources', () => {
beforeEach(() => {
localStorage.clear()
})
it('stores the provider used by a manual pricing action', () => {
const { getSource, setSource } = useModelsDevPricingSources()
setSource('model-1', {
provider_id: 'openai',
provider_name: 'OpenAI',
})
expect(getSource('model-1')).toEqual({
provider_id: 'openai',
provider_name: 'OpenAI',
})
expect(JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null')).toEqual({
version: 1,
models: {
'model-1': {
provider_id: 'openai',
provider_name: 'OpenAI',
},
},
})
})
it('migrates the previous provider record without retaining its automatic preference key', () => {
localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify({
version: 1,
models: {
'model-1': {
provider_id: 'anthropic',
provider_name: 'Anthropic',
},
},
}))
const { getSource } = useModelsDevPricingSources()
expect(getSource('model-1')).toEqual({
provider_id: 'anthropic',
provider_name: 'Anthropic',
})
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBeNull()
expect(localStorage.getItem(STORAGE_KEY)).not.toBeNull()
})
it.each([
'{broken',
JSON.stringify({ version: 2, models: {} }),
JSON.stringify({ version: 1, models: [] }),
])('ignores incompatible or malformed source documents', (stored) => {
localStorage.setItem(STORAGE_KEY, stored)
const { getSource } = useModelsDevPricingSources()
expect(getSource('model-1')).toBeNull()
})
})
@@ -0,0 +1,95 @@
import { ref } from 'vue'
export interface ModelsDevPricingSource {
provider_id: string
provider_name: string
}
interface StoredModelsDevPricingSources {
version: 1
models: Record<string, ModelsDevPricingSource>
}
const STORAGE_KEY = 'aether:models-dev-pricing-sources:v1'
const LEGACY_STORAGE_KEY = 'aether:models-dev-pricing-preferences:v1'
const sources = ref<Record<string, ModelsDevPricingSource>>({})
function parseStoredSources(key: string): Record<string, ModelsDevPricingSource> | null {
try {
const stored = JSON.parse(localStorage.getItem(key) || 'null') as unknown
if (!stored || typeof stored !== 'object') return null
const document = stored as Partial<StoredModelsDevPricingSources>
if (document.version !== 1 || !document.models || typeof document.models !== 'object') return null
const validSources: Record<string, ModelsDevPricingSource> = {}
for (const [modelId, value] of Object.entries(document.models)) {
if (!value || typeof value !== 'object') continue
const source = value as Partial<ModelsDevPricingSource>
if (
typeof source.provider_id === 'string'
&& source.provider_id.length > 0
&& typeof source.provider_name === 'string'
&& source.provider_name.length > 0
) {
validSources[modelId] = {
provider_id: source.provider_id,
provider_name: source.provider_name,
}
}
}
return validSources
} catch {
return null
}
}
function writeStoredSources(value: Record<string, ModelsDevPricingSource>): boolean {
try {
const document: StoredModelsDevPricingSources = {
version: 1,
models: value,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(document))
return true
} catch {
return false
}
}
function readStoredSources(): Record<string, ModelsDevPricingSource> {
if (typeof localStorage === 'undefined') return {}
const currentSources = parseStoredSources(STORAGE_KEY)
if (currentSources) {
localStorage.removeItem(LEGACY_STORAGE_KEY)
return currentSources
}
const legacySources = parseStoredSources(LEGACY_STORAGE_KEY)
if (legacySources && writeStoredSources(legacySources)) {
localStorage.removeItem(LEGACY_STORAGE_KEY)
}
return legacySources ?? {}
}
export function useModelsDevPricingSources() {
sources.value = readStoredSources()
function getSource(modelId: string): ModelsDevPricingSource | null {
return sources.value[modelId] ?? null
}
function setSource(modelId: string, source: ModelsDevPricingSource) {
const nextSources = {
...sources.value,
[modelId]: source,
}
sources.value = nextSources
writeStoredSources(nextSources)
}
return {
getSource,
setSource,
}
}
+341 -18
View File
@@ -31,7 +31,7 @@
variant="ghost"
size="icon"
class="h-8 w-8"
title="批量管理"
title="快速筛选与批量操作"
@click="openBatchManageDialog"
>
<ListChecks class="w-3.5 h-3.5" />
@@ -469,17 +469,16 @@
<!-- 批量管理全局模型对话框 -->
<Dialog
:model-value="batchManageDialogOpen"
title="批量管理模型"
description="选择要删除的全局模型"
:icon="Trash2"
icon-class="bg-destructive/10"
title="快速筛选与批量操作"
description="默认按每个模型上次选择的在线来源同步,也可手动指定统一来源"
:icon="ListChecks"
size="2xl"
@update:model-value="batchManageDialogOpen = $event"
>
<template #default>
<div class="space-y-4">
<!-- 搜索 -->
<div class="flex items-center gap-2">
<!-- 搜索与在线价格来源 -->
<div class="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_220px]">
<div class="flex-1 relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
@@ -488,6 +487,27 @@
class="pl-8 h-9"
/>
</div>
<Select
v-model="batchPricingProviderId"
:disabled="batchManageOnlineLoading"
>
<SelectTrigger class="h-9 text-xs">
<SelectValue placeholder="选择在线价格来源" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="REMEMBERED_PRICING_PROVIDER_ID">
上次选择按模型
</SelectItem>
<SelectItem
v-for="provider in batchPricingProviderOptions"
:key="provider.providerId"
:value="provider.providerId"
>
{{ provider.providerName }}
<span class="ml-1 text-muted-foreground">({{ provider.matchCount }})</span>
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 快捷选中 -->
@@ -497,7 +517,8 @@
v-for="shortcut in batchManageShortcuts"
:key="shortcut.label"
type="button"
class="text-xs px-2 py-1 rounded-md border border-border/60 hover:bg-muted transition-colors"
class="rounded-md border border-border/60 px-2 py-1 text-xs transition-colors hover:bg-muted active:scale-[0.96]"
:class="shortcut.emphasis ? 'border-primary/30 bg-primary/5 text-primary' : ''"
:title="shortcut.description"
@click="applyBatchManageShortcut(shortcut.filter)"
>
@@ -509,7 +530,7 @@
<div class="border rounded-lg overflow-hidden">
<div class="max-h-96 overflow-y-auto">
<div
v-if="batchManageLoading"
v-if="batchManageLoading || batchManageOnlineLoading"
class="flex items-center justify-center py-12"
>
<Loader2 class="w-6 h-6 animate-spin text-primary" />
@@ -556,6 +577,21 @@
</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<span
class="max-w-28 truncate text-[10px] text-muted-foreground"
:title="getBatchPricingSourceLabel(model)"
>{{ getBatchPricingSourceLabel(model) }}</span>
<span
class="inline-flex items-center gap-1.5 text-[11px] font-medium"
:class="getBatchPricingStateClass(model)"
:title="getBatchPricingStateDescription(model)"
>
<span
class="h-1.5 w-1.5 rounded-full"
:class="getBatchPricingStateDotClass(model)"
/>
{{ getBatchPricingStateLabel(model) }}
</span>
<Badge
variant="secondary"
class="text-xs"
@@ -588,23 +624,40 @@
</div>
</template>
<template #footer>
<div class="flex items-center justify-between w-full">
<p class="text-xs text-muted-foreground">
{{ selectedBatchManageModelIds.size > 0 ? `已选择 ${selectedBatchManageModelIds.size} 个模型` : '' }}
<div class="flex w-full min-w-0 flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<p class="min-w-0 break-keep text-pretty text-xs leading-5 text-muted-foreground lg:flex-1">
{{ batchManageSelectionSummary }}
</p>
<div class="flex items-center gap-2">
<div class="flex w-full min-w-0 flex-col gap-2 sm:flex-row lg:w-auto lg:shrink-0">
<Button
class="w-full whitespace-nowrap sm:flex-1 lg:w-auto lg:flex-none"
:disabled="selectedBatchPriceSyncPlan.syncable.length === 0 || submittingBatchManage"
@click="confirmBatchSyncPrices"
>
<Loader2
v-if="batchManageAction === 'sync-prices'"
class="w-4 h-4 mr-1 animate-spin"
/>
<RefreshCw
v-else
class="w-4 h-4 mr-1"
/>
{{ batchManageAction === 'sync-prices' ? '同步中...' : `同步在线价格 (${selectedBatchPriceSyncPlan.syncable.length})` }}
</Button>
<Button
class="w-full whitespace-nowrap sm:flex-1 lg:w-auto lg:flex-none"
variant="destructive"
:disabled="selectedBatchManageModelIds.size === 0 || submittingBatchManage"
@click="confirmBatchDeleteModels"
>
<Loader2
v-if="submittingBatchManage"
v-if="batchManageAction === 'delete'"
class="w-4 h-4 mr-1 animate-spin"
/>
{{ submittingBatchManage ? '删除中...' : '删除选中' }}
{{ batchManageAction === 'delete' ? '删除中...' : '删除选中' }}
</Button>
<Button
class="w-full whitespace-nowrap sm:flex-1 lg:w-auto lg:flex-none"
variant="outline"
@click="batchManageDialogOpen = false"
>
@@ -632,6 +685,7 @@ import {
Server,
Check,
ListChecks,
RefreshCw,
} from 'lucide-vue-next'
import ModelDetailDrawer from '@/features/models/components/ModelDetailDrawer.vue'
import GlobalModelFormDialog from '@/features/models/components/GlobalModelFormDialog.vue'
@@ -657,6 +711,11 @@ import {
Dialog,
Pagination,
RefreshButton,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui'
import {
listGlobalModels,
@@ -671,6 +730,12 @@ import {
import { log } from '@/utils/logger'
import { formatUsageCount } from '@/utils/format'
import { getProvidersSummary, type ProviderWithEndpointsSummary } from '@/api/endpoints/providers'
import { getModelsDevList, type ModelsDevModelItem } from '@/api/models-dev'
import {
buildGlobalModelPriceSyncPlan,
cloneTieredPricingConfig,
} from '@/features/models/components/global-model-form-helpers'
import { useModelsDevPricingSources } from '@/features/models/composables/useModelsDevPricingSources'
interface ModelProviderDisplay {
@@ -697,6 +762,8 @@ interface ModelProviderDisplay {
const { success, error: showError } = useToast()
const { copyToClipboard } = useClipboard()
const { getSource: getModelsDevPricingSource, setSource: setModelsDevPricingSource } = useModelsDevPricingSources()
const REMEMBERED_PRICING_PROVIDER_ID = '__remembered__'
// 状态
const loading = ref(false)
@@ -712,6 +779,8 @@ const globalModels = ref<GlobalModelResponse[]>([])
const totalGlobalModels = ref(0)
const batchManageModels = ref<GlobalModelResponse[]>([])
const batchManageLoading = ref(false)
const batchManageOnlineModels = ref<ModelsDevModelItem[]>([])
const batchManageOnlineLoading = ref(false)
const GLOBAL_MODELS_BATCH_FETCH_PAGE_SIZE = 1000
let globalModelsRequestId = 0
let modelSelectionRequestId = 0
@@ -746,8 +815,10 @@ const editingProvider = ref<ModelProviderDisplay | null>(null)
// 批量管理全局模型
const batchManageDialogOpen = ref(false)
const batchManageSearchQuery = ref('')
const batchPricingProviderId = ref(REMEMBERED_PRICING_PROVIDER_ID)
const selectedBatchManageModelIds = ref<Set<string>>(new Set())
const submittingBatchManage = ref(false)
const batchManageAction = ref<'sync-prices' | 'delete' | null>(null)
// 将 provider 数据转换为 Model 类型供 ProviderModelFormDialog 使用
const editingProviderModel = computed<Model | null>(() => {
@@ -774,7 +845,7 @@ const editingProviderModel = computed<Model | null>(() => {
})
// 使用全局确认对话框
const { confirmDanger } = useConfirm()
const { confirm, confirmDanger } = useConfirm()
// 从 GlobalModel 的 default_tiered_pricing 获取第一阶梯价格
function getFirstTierPrice(model: GlobalModelResponse, type: 'input' | 'output'): number | null {
@@ -1294,6 +1365,147 @@ const filteredBatchManageModels = computed(() => {
})
})
const batchPricingProviderOptions = computed(() => {
const existingModelNames = new Set(batchManageModels.value.map(model => model.name.trim().toLowerCase()))
const providers = new Map<string, {
providerId: string
providerName: string
matchCount: number
official: boolean
}>()
for (const onlineModel of batchManageOnlineModels.value) {
const provider = providers.get(onlineModel.providerId) ?? {
providerId: onlineModel.providerId,
providerName: onlineModel.providerName,
matchCount: 0,
official: onlineModel.official === true,
}
if (
(onlineModel.tieredPricing || onlineModel.pricingUnsupportedFields?.length)
&& existingModelNames.has(onlineModel.modelId.trim().toLowerCase())
) {
provider.matchCount += 1
}
providers.set(onlineModel.providerId, provider)
}
return [...providers.values()]
.filter(provider => provider.matchCount > 0)
.sort((left, right) => (
Number(right.official) - Number(left.official)
|| right.matchCount - left.matchCount
|| left.providerName.localeCompare(right.providerName)
))
})
const selectedBatchPricingProvider = computed(() => (
batchPricingProviderOptions.value.find(provider => provider.providerId === batchPricingProviderId.value)
))
const batchPricingProviderModels = computed(() => (
batchPricingProviderId.value === REMEMBERED_PRICING_PROVIDER_ID
? batchManageOnlineModels.value
: batchManageOnlineModels.value.filter(model => model.providerId === batchPricingProviderId.value)
))
const rememberedBatchPricingProviderIds = computed(() => {
if (batchPricingProviderId.value !== REMEMBERED_PRICING_PROVIDER_ID) return undefined
const providerIds = new Map<string, string>()
for (const model of batchManageModels.value) {
const source = getModelsDevPricingSource(model.id)
if (source) providerIds.set(model.id, source.provider_id)
}
return providerIds
})
const batchPriceSyncPlan = computed(() => (
buildGlobalModelPriceSyncPlan(
batchManageModels.value,
batchPricingProviderModels.value,
rememberedBatchPricingProviderIds.value,
)
))
const batchPricingStateByModelId = computed(() => {
const states = new Map<string, 'syncable' | 'unchanged' | 'unsupported' | 'unavailable'>()
for (const entry of batchPriceSyncPlan.value.syncable) states.set(entry.model.id, 'syncable')
for (const entry of batchPriceSyncPlan.value.unchanged) states.set(entry.model.id, 'unchanged')
for (const entry of batchPriceSyncPlan.value.unsupported) states.set(entry.model.id, 'unsupported')
for (const model of batchPriceSyncPlan.value.unavailable) states.set(model.id, 'unavailable')
return states
})
const selectedBatchManageModels = computed(() => (
batchManageModels.value.filter(model => selectedBatchManageModelIds.value.has(model.id))
))
const selectedBatchPriceSyncPlan = computed(() => (
buildGlobalModelPriceSyncPlan(
selectedBatchManageModels.value,
batchPricingProviderModels.value,
rememberedBatchPricingProviderIds.value,
)
))
const batchManageSelectionSummary = computed(() => {
const selectedCount = selectedBatchManageModelIds.value.size
if (selectedCount === 0) return '选择模型后执行批量操作'
const plan = selectedBatchPriceSyncPlan.value
return `已选择 ${selectedCount} 个 · 可更新 ${plan.syncable.length} · 已一致 ${plan.unchanged.length} · 不兼容 ${plan.unsupported.length} · 无在线价格 ${plan.unavailable.length}`
})
function getBatchPricingState(model: GlobalModelResponse) {
return batchPricingStateByModelId.value.get(model.id) ?? 'unavailable'
}
function getBatchPricingStateLabel(model: GlobalModelResponse): string {
const state = getBatchPricingState(model)
if (state === 'syncable') return '价格可更新'
if (state === 'unchanged') return '价格一致'
if (state === 'unsupported') return '计价不兼容'
return '无在线价格'
}
function getBatchPricingStateDescription(model: GlobalModelResponse): string {
const providerName = getBatchPricingSourceLabel(model)
const unsupportedEntry = batchPriceSyncPlan.value.unsupported.find(entry => entry.model.id === model.id)
if (unsupportedEntry) {
return `${providerName || unsupportedEntry.onlineModel.providerName} · 不支持独立结算 ${formatBatchUnsupportedPricingFields(unsupportedEntry.onlineModel)}`
}
return `${providerName} · ${getBatchPricingStateLabel(model)}`
}
function getBatchPricingSourceLabel(model: GlobalModelResponse): string {
if (batchPricingProviderId.value !== REMEMBERED_PRICING_PROVIDER_ID) {
return selectedBatchPricingProvider.value?.providerName ?? '未选择来源'
}
return getModelsDevPricingSource(model.id)?.provider_name ?? '未记录来源'
}
function getBatchPricingStateClass(model: GlobalModelResponse): string {
const state = getBatchPricingState(model)
if (state === 'syncable') return 'text-amber-700 dark:text-amber-300'
if (state === 'unchanged') return 'text-emerald-700 dark:text-emerald-300'
if (state === 'unsupported') return 'text-rose-700 dark:text-rose-300'
return 'text-muted-foreground'
}
function getBatchPricingStateDotClass(model: GlobalModelResponse): string {
const state = getBatchPricingState(model)
if (state === 'syncable') return 'bg-amber-500'
if (state === 'unchanged') return 'bg-emerald-500'
if (state === 'unsupported') return 'bg-rose-500'
return 'bg-muted-foreground/45'
}
function formatBatchUnsupportedPricingFields(model: ModelsDevModelItem): string {
const labels = {
reasoning: '推理 Token',
input_audio: '输入音频 Token',
output_audio: '输出音频 Token',
}
return (model.pricingUnsupportedFields ?? []).map(field => labels[field]).join('、')
}
// 批量管理 - 快捷筛选定义
function hasNoPrice(m: GlobalModelResponse): boolean {
return !getFirstTierPrice(m, 'input') && !getFirstTierPrice(m, 'output')
@@ -1302,7 +1514,16 @@ function hasNoPrice(m: GlobalModelResponse): boolean {
const batchManageShortcuts = computed(() => {
const models = batchManageModels.value
const defs: { label: string; description: string; filter: (m: GlobalModelResponse) => boolean }[] = [
const defs: {
label: string
description: string
filter: (m: GlobalModelResponse) => boolean
emphasis?: boolean
}[] = [
{ label: '价格可更新', description: '当前价格与所选供应商在线价格不同', filter: m => getBatchPricingState(m) === 'syncable', emphasis: true },
{ label: '价格一致', description: '当前价格与所选供应商在线价格一致', filter: m => getBatchPricingState(m) === 'unchanged' },
{ label: '计价不兼容', description: '在线来源包含当前计费引擎无法独立结算的价格维度', filter: m => getBatchPricingState(m) === 'unsupported' },
{ label: '无在线价格', description: '所选供应商没有该模型的在线价格', filter: m => getBatchPricingState(m) === 'unavailable' },
{ label: '无提供商', description: '没有关联任何提供商的模型', filter: m => (m.provider_count || 0) === 0 },
{ label: '无活跃提供商', description: '有提供商但没有活跃提供商的模型', filter: m => (m.active_provider_count || 0) === 0 && (m.provider_count || 0) > 0 },
{ label: '禁用', description: '被禁用的模型', filter: m => !m.is_active },
@@ -1352,9 +1573,93 @@ function toggleAllBatchManageModels() {
// 打开批量管理对话框
function openBatchManageDialog() {
batchManageSearchQuery.value = ''
batchPricingProviderId.value = REMEMBERED_PRICING_PROVIDER_ID
selectedBatchManageModelIds.value = new Set()
batchManageDialogOpen.value = true
loadBatchManageModels()
void Promise.all([loadBatchManageModels(), loadBatchManageOnlineModels()])
}
async function loadBatchManageOnlineModels() {
batchManageOnlineLoading.value = true
try {
batchManageOnlineModels.value = await getModelsDevList(false)
} catch (err: unknown) {
log.error('加载在线模型价格失败:', err)
showError(parseApiError(err, '加载在线模型价格失败'), '加载失败')
} finally {
batchManageOnlineLoading.value = false
}
}
async function runBatchTasksWithConcurrency(
tasks: Array<() => Promise<void>>,
concurrency: number = 6,
) {
let cursor = 0
const runNext = async (): Promise<void> => {
while (cursor < tasks.length) {
const taskIndex = cursor++
await tasks[taskIndex]()
}
}
await Promise.all(Array.from(
{ length: Math.min(concurrency, tasks.length) },
() => runNext(),
))
}
async function confirmBatchSyncPrices() {
const plan = selectedBatchPriceSyncPlan.value
if (plan.syncable.length === 0) return
const providerName = batchPricingProviderId.value === REMEMBERED_PRICING_PROVIDER_ID
? '各模型上次选择的提供商'
: selectedBatchPricingProvider.value?.providerName || '所选供应商'
const skippedCount = plan.unchanged.length + plan.unsupported.length + plan.unavailable.length
const confirmed = await confirm({
title: '批量同步模型价格',
message: `将根据 ${providerName} 的在线定价更新 ${plan.syncable.length} 个模型。${skippedCount > 0 ? `另有 ${skippedCount} 个模型因价格一致、计价不兼容或无在线价格而跳过。` : ''}\n\n仅更新模型价格,不修改名称、能力或其他配置。`,
confirmText: '同步价格',
variant: 'info',
})
if (!confirmed) return
submittingBatchManage.value = true
batchManageAction.value = 'sync-prices'
const failedIds = new Set<string>()
const failureMessages: string[] = []
let successCount = 0
try {
const tasks = plan.syncable.map(entry => async () => {
try {
const onlinePricing = entry.onlineModel.tieredPricing
if (!onlinePricing) {
throw new Error('在线目录未提供价格配置')
}
const pricing = cloneTieredPricingConfig(onlinePricing)
await updateGlobalModel(entry.model.id, { default_tiered_pricing: pricing })
entry.model.default_tiered_pricing = pricing
setModelsDevPricingSource(entry.model.id, {
provider_id: entry.onlineModel.providerId,
provider_name: entry.onlineModel.providerName,
})
successCount += 1
} catch (err: unknown) {
failedIds.add(entry.model.id)
failureMessages.push(`${entry.model.display_name}: ${parseApiError(err, '更新失败')}`)
}
})
await runBatchTasksWithConcurrency(tasks)
if (successCount > 0) success(`成功同步 ${successCount} 个模型价格`)
if (failureMessages.length > 0) {
showError(`${failureMessages.length} 个模型同步失败:${failureMessages.slice(0, 2).join('')}`, '部分失败')
}
await Promise.all([loadGlobalModels(), loadBatchManageModels()])
selectedBatchManageModelIds.value = failedIds
} finally {
batchManageAction.value = null
submittingBatchManage.value = false
}
}
// 确认批量删除模型
@@ -1369,6 +1674,7 @@ async function confirmBatchDeleteModels() {
if (!confirmed) return
submittingBatchManage.value = true
batchManageAction.value = 'delete'
try {
const ids = Array.from(selectedBatchManageModelIds.value)
const result = await batchDeleteGlobalModels(ids)
@@ -1390,10 +1696,27 @@ async function confirmBatchDeleteModels() {
} catch (err: unknown) {
showError(parseApiError(err, '批量删除失败'), '错误')
} finally {
batchManageAction.value = null
submittingBatchManage.value = false
}
}
watch(batchPricingProviderId, (value, previousValue) => {
if (previousValue && value !== previousValue) {
selectedBatchManageModelIds.value = new Set()
}
})
watch([batchPricingProviderOptions, batchManageDialogOpen], ([options, dialogOpen]) => {
if (!dialogOpen) return
if (
batchPricingProviderId.value !== REMEMBERED_PRICING_PROVIDER_ID
&& !options.some(provider => provider.providerId === batchPricingProviderId.value)
) {
batchPricingProviderId.value = REMEMBERED_PRICING_PROVIDER_ID
}
}, { immediate: true })
// 抽屉控制函数
function handleDrawerOpenChange(value: boolean) {
if (!value && !hasBlockingDialogOpen.value) {