mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-10 05:00:19 +08:00
feat(models): track online pricing sources and unsupported fields
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -136,6 +136,11 @@
|
||||
/>
|
||||
<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"
|
||||
@@ -274,28 +279,23 @@
|
||||
v-if="selectedExistingModel"
|
||||
class="mb-4 space-y-3 rounded-lg border border-sky-500/20 bg-sky-500/5 p-4"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium">
|
||||
同步在线价格
|
||||
</h4>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
仅更新该模型的价格配置,不修改名称、能力或其他设置。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<Label class="text-xs font-normal">自动应用在线价格</Label>
|
||||
<Switch
|
||||
:model-value="autoApplyOnlinePricing"
|
||||
:disabled="!selectedModel?.tieredPricing && !autoApplyOnlinePricing"
|
||||
aria-label="选择已有模型时自动应用在线价格"
|
||||
@update:model-value="setAutoApplyOnlinePricing"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium">
|
||||
同步在线价格
|
||||
</h4>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
选择在线价格并点击同步后,仅更新该模型的价格配置。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!selectedModel?.tieredPricing"
|
||||
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"
|
||||
>
|
||||
在线目录未提供该模型的价格,暂时无法同步。
|
||||
@@ -333,9 +333,6 @@
|
||||
<span class="mt-1 block text-[11px] text-muted-foreground">{{ formatPricingSummary(selectedModel.tieredPricing) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
开启后会记住当前在线来源,下次选择该模型时自动载入价格;仍需点击保存后写入。
|
||||
</p>
|
||||
</section>
|
||||
<form
|
||||
class="space-y-5"
|
||||
@@ -691,7 +688,7 @@ import {
|
||||
BrainCircuit, Eye, Wrench, Braces, Database, PackageOpen, CircleCheck
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
Dialog, Button, Input, Label, Checkbox, Switch,
|
||||
Dialog, Button, Input, Label, Checkbox,
|
||||
Tabs, TabsContent, TabsList, TabsTrigger,
|
||||
} from '@/components/ui'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
@@ -718,11 +715,10 @@ import {
|
||||
buildGlobalModelUpdatePayload,
|
||||
cloneTieredPricingConfig,
|
||||
findGlobalModelByName,
|
||||
mergeModelsDevPricingPreference,
|
||||
readModelsDevPricingPreference,
|
||||
tieredPricingConfigsEqual,
|
||||
} from './global-model-form-helpers'
|
||||
import { tieredPricingHasImageOutputPricing } from '../utils/tiered-pricing'
|
||||
import { useModelsDevPricingSources } from '../composables/useModelsDevPricingSources'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
@@ -735,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)
|
||||
@@ -749,17 +746,22 @@ const expandedProvider = ref<string | null>(null)
|
||||
const providerLogoScroller = ref<HTMLElement | null>(null)
|
||||
const presetPanelCollapsed = ref(false)
|
||||
const billingMode = ref('token')
|
||||
const autoApplyOnlinePricing = ref(false)
|
||||
const pricingSource = ref<'current' | 'online'>('current')
|
||||
|
||||
function getExistingModel(model: ModelsDevModelItem): GlobalModelResponse | undefined {
|
||||
return findGlobalModelByName(existingModelsCache.value, model.modelId)
|
||||
}
|
||||
|
||||
type PricingSyncState = 'same' | 'different' | 'unavailable'
|
||||
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'
|
||||
@@ -770,6 +772,7 @@ function getPricingSyncLabel(model: ModelsDevModelItem): string {
|
||||
const state = getPricingSyncState(model)
|
||||
if (state === 'same') return '价格一致'
|
||||
if (state === 'different') return '价格可更新'
|
||||
if (state === 'unsupported') return '计价不兼容'
|
||||
return '无在线价格'
|
||||
}
|
||||
|
||||
@@ -777,9 +780,19 @@ 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
|
||||
))
|
||||
@@ -793,35 +806,26 @@ const onlinePricingMatchesExisting = computed(() => (
|
||||
)
|
||||
))
|
||||
|
||||
const selectedPricingPreference = computed(() => (
|
||||
readModelsDevPricingPreference(selectedExistingModel.value?.config)
|
||||
const selectedPricingSourceMatches = computed(() => (
|
||||
!!selectedExistingModel.value
|
||||
&& !!selectedModel.value
|
||||
&& getSource(selectedExistingModel.value.id)?.provider_id === selectedModel.value.providerId
|
||||
))
|
||||
|
||||
const pricingPreferenceChanged = computed(() => {
|
||||
if (!selectedExistingModel.value || !selectedModel.value) return false
|
||||
const currentPreference = selectedPricingPreference.value
|
||||
if (!autoApplyOnlinePricing.value) return currentPreference !== null
|
||||
return currentPreference?.provider_id !== selectedModel.value.providerId
|
||||
})
|
||||
|
||||
const canSubmitPriceSync = computed(() => (
|
||||
!!selectedExistingModel.value
|
||||
&& !!selectedModel.value?.tieredPricing
|
||||
&& (
|
||||
pricingPreferenceChanged.value
|
||||
|| (
|
||||
!!selectedModel.value?.tieredPricing
|
||||
&& !onlinePricingMatchesExisting.value
|
||||
&& pricingSource.value === 'online'
|
||||
)
|
||||
(onlinePricingMatchesExisting.value && !selectedPricingSourceMatches.value)
|
||||
|| (!onlinePricingMatchesExisting.value && pricingSource.value === 'online')
|
||||
)
|
||||
))
|
||||
|
||||
const priceSyncSubmitLabel = computed(() => {
|
||||
if (pricingPreferenceChanged.value) {
|
||||
return autoApplyOnlinePricing.value ? '保存并同步价格' : '关闭自动应用'
|
||||
}
|
||||
if (!selectedModel.value?.tieredPricing) return '暂无在线价格'
|
||||
if (onlinePricingMatchesExisting.value) return '价格已是最新'
|
||||
if (onlinePricingMatchesExisting.value) {
|
||||
return selectedPricingSourceMatches.value ? '价格已是最新' : '保存价格来源'
|
||||
}
|
||||
if (pricingSource.value !== 'online') return '请选择在线价格'
|
||||
return '同步价格'
|
||||
})
|
||||
@@ -1289,11 +1293,6 @@ function selectModel(model: ModelsDevModelItem) {
|
||||
if (existingModel) {
|
||||
populateFormFromGlobalModel(existingModel)
|
||||
pricingSource.value = 'current'
|
||||
const savedPreference = readModelsDevPricingPreference(existingModel.config)
|
||||
autoApplyOnlinePricing.value = savedPreference?.provider_id === model.providerId
|
||||
if (autoApplyOnlinePricing.value && model.tieredPricing && !onlinePricingMatchesExisting.value) {
|
||||
applyOnlinePricing()
|
||||
}
|
||||
presetPanelCollapsed.value = true
|
||||
scrollToBasicInformation()
|
||||
return
|
||||
@@ -1359,15 +1358,6 @@ function restoreExistingPricing() {
|
||||
pricingSource.value = 'current'
|
||||
}
|
||||
|
||||
function setAutoApplyOnlinePricing(enabled: boolean) {
|
||||
autoApplyOnlinePricing.value = enabled
|
||||
if (enabled && selectedExistingModel.value && !onlinePricingMatchesExisting.value) {
|
||||
applyOnlinePricing()
|
||||
} else if (!enabled) {
|
||||
restoreExistingPricing()
|
||||
}
|
||||
}
|
||||
|
||||
// 清除选择(手动填写)
|
||||
function clearSelection() {
|
||||
imageGenerationExplicitOverride.value = null
|
||||
@@ -1396,7 +1386,6 @@ function resetForm() {
|
||||
expandedProvider.value = null
|
||||
presetPanelCollapsed.value = false
|
||||
billingMode.value = 'token'
|
||||
autoApplyOnlinePricing.value = false
|
||||
pricingSource.value = 'current'
|
||||
}
|
||||
|
||||
@@ -1494,26 +1483,23 @@ async function handleSubmit() {
|
||||
showError('请先选择使用在线价格')
|
||||
return
|
||||
}
|
||||
const preferenceWillChange = pricingPreferenceChanged.value
|
||||
const updateData: Parameters<typeof updateGlobalModel>[1] = {
|
||||
default_tiered_pricing: finalTieredPricing,
|
||||
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 (preferenceWillChange && selectedModel.value) {
|
||||
updateData.config = mergeModelsDevPricingPreference(
|
||||
existingModel.config,
|
||||
autoApplyOnlinePricing.value
|
||||
? {
|
||||
enabled: true,
|
||||
provider_id: selectedModel.value.providerId,
|
||||
provider_name: selectedModel.value.providerName,
|
||||
}
|
||||
: null,
|
||||
)
|
||||
if (selectedModel.value) {
|
||||
setSource(existingModel.id, {
|
||||
provider_id: selectedModel.value.providerId,
|
||||
provider_name: selectedModel.value.providerName,
|
||||
})
|
||||
}
|
||||
await updateGlobalModel(existingModel.id, updateData)
|
||||
existingModel.default_tiered_pricing = cloneTieredPricingConfig(finalTieredPricing)
|
||||
if ('config' in updateData) existingModel.config = updateData.config
|
||||
success(preferenceWillChange ? '模型在线价格设置已保存' : '模型价格同步成功')
|
||||
success(pricingWillChange ? '模型价格同步成功' : '模型价格来源已保存')
|
||||
reopenPresetPanel()
|
||||
emit('success')
|
||||
return
|
||||
@@ -1525,6 +1511,12 @@ async function handleSubmit() {
|
||||
const createData = buildGlobalModelCreatePayload(form.value, finalTieredPricing)
|
||||
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')
|
||||
|
||||
+57
-50
@@ -92,6 +92,17 @@ 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',
|
||||
@@ -161,10 +172,11 @@ 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()
|
||||
@@ -258,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')
|
||||
@@ -305,6 +327,15 @@ describe('GlobalModelFormDialog preset replacement', () => {
|
||||
|
||||
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,
|
||||
@@ -314,12 +345,14 @@ describe('GlobalModelFormDialog preset replacement', () => {
|
||||
|
||||
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()
|
||||
@@ -334,65 +367,39 @@ describe('GlobalModelFormDialog preset replacement', () => {
|
||||
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('auto-applies online pricing without submitting it', async () => {
|
||||
const existingStaleModel = buildExistingStaleModel()
|
||||
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: [existingStaleModel],
|
||||
models: [existingModel],
|
||||
total: 1,
|
||||
})
|
||||
mountDialog()
|
||||
await settle()
|
||||
|
||||
findButton('Stale Model').click()
|
||||
await settle()
|
||||
const autoApplySwitch = document.body.querySelector<HTMLButtonElement>(
|
||||
'[role="switch"][aria-label="选择已有模型时自动应用在线价格"]',
|
||||
)
|
||||
if (!autoApplySwitch) throw new Error('Missing automatic online pricing switch')
|
||||
autoApplySwitch.click()
|
||||
expect(document.body.textContent).toContain('计价不兼容')
|
||||
findButton(unsupportedPreset.modelName).click()
|
||||
await settle()
|
||||
|
||||
expect(document.body.querySelector<HTMLInputElement>('[data-testid="tier-input-price"]')?.value).toBe('1')
|
||||
expect(findExactButton('保存并同步价格').disabled).toBe(false)
|
||||
expect(document.body.textContent).toContain('无法独立结算推理 Token')
|
||||
expect(findExactButton('暂无在线价格').disabled).toBe(true)
|
||||
expect(globalModelMocks.updateGlobalModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('persists the selected online pricing source when automatic apply is enabled', async () => {
|
||||
const existingStaleModel = buildExistingStaleModel()
|
||||
globalModelMocks.listGlobalModels.mockResolvedValue({
|
||||
models: [existingStaleModel],
|
||||
total: 1,
|
||||
})
|
||||
mountDialog()
|
||||
await settle()
|
||||
|
||||
findButton('Stale Model').click()
|
||||
await settle()
|
||||
const autoApplySwitch = document.body.querySelector<HTMLButtonElement>(
|
||||
'[role="switch"][aria-label="选择已有模型时自动应用在线价格"]',
|
||||
)
|
||||
if (!autoApplySwitch) throw new Error('Missing automatic online pricing switch')
|
||||
autoApplySwitch.click()
|
||||
await settle()
|
||||
findExactButton('保存并同步价格').click()
|
||||
await settle()
|
||||
|
||||
expect(globalModelMocks.updateGlobalModel).toHaveBeenCalledWith(
|
||||
existingStaleModel.id,
|
||||
{
|
||||
default_tiered_pricing: stalePreset.tieredPricing,
|
||||
config: {
|
||||
streaming: true,
|
||||
models_dev_pricing: {
|
||||
enabled: true,
|
||||
provider_id: 'openai',
|
||||
provider_name: 'OpenAI',
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+57
-20
@@ -8,8 +8,6 @@ import {
|
||||
buildGlobalModelUpdatePayload,
|
||||
cloneTieredPricingConfig,
|
||||
findGlobalModelByName,
|
||||
mergeModelsDevPricingPreference,
|
||||
readModelsDevPricingPreference,
|
||||
tieredPricingConfigsEqual,
|
||||
} from '../global-model-form-helpers'
|
||||
import type { TieredPricingConfig } from '@/api/endpoints/types'
|
||||
@@ -152,34 +150,73 @@ describe('global model form pricing presets', () => {
|
||||
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],
|
||||
[makeOnlineModel('current-model', 2), makeOnlineModel('stale-model', 5)],
|
||||
[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('persists and removes the online pricing preference without replacing other config', () => {
|
||||
const config = {
|
||||
streaming: true,
|
||||
billing: { video: { price_per_second_by_resolution: { '720p': 0.04 } } },
|
||||
}
|
||||
const enabledConfig = mergeModelsDevPricingPreference(config, {
|
||||
enabled: true,
|
||||
provider_id: 'anthropic',
|
||||
provider_name: 'Anthropic',
|
||||
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')
|
||||
|
||||
expect(readModelsDevPricingPreference(enabledConfig)).toEqual({
|
||||
enabled: true,
|
||||
provider_id: 'anthropic',
|
||||
provider_name: 'Anthropic',
|
||||
})
|
||||
expect(enabledConfig).toMatchObject(config)
|
||||
expect(mergeModelsDevPricingPreference(enabledConfig, null)).toEqual(config)
|
||||
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'])
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -71,67 +71,45 @@ export interface GlobalModelPriceSyncEntry {
|
||||
export interface GlobalModelPriceSyncPlan {
|
||||
syncable: GlobalModelPriceSyncEntry[]
|
||||
unchanged: GlobalModelPriceSyncEntry[]
|
||||
unsupported: GlobalModelPriceSyncEntry[]
|
||||
unavailable: GlobalModelResponse[]
|
||||
}
|
||||
|
||||
export interface ModelsDevPricingPreference {
|
||||
enabled: true
|
||||
provider_id: string
|
||||
provider_name: string
|
||||
}
|
||||
|
||||
const MODELS_DEV_PRICING_CONFIG_KEY = 'models_dev_pricing'
|
||||
|
||||
export function readModelsDevPricingPreference(
|
||||
config: Record<string, unknown> | null | undefined,
|
||||
): ModelsDevPricingPreference | null {
|
||||
const value = config?.[MODELS_DEV_PRICING_CONFIG_KEY]
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||
const preference = value as Record<string, unknown>
|
||||
if (preference.enabled !== true) return null
|
||||
if (typeof preference.provider_id !== 'string' || !preference.provider_id.trim()) return null
|
||||
return {
|
||||
enabled: true,
|
||||
provider_id: preference.provider_id.trim(),
|
||||
provider_name: typeof preference.provider_name === 'string' && preference.provider_name.trim()
|
||||
? preference.provider_name.trim()
|
||||
: preference.provider_id.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeModelsDevPricingPreference(
|
||||
config: Record<string, unknown> | null | undefined,
|
||||
preference: ModelsDevPricingPreference | null,
|
||||
): Record<string, unknown> | null {
|
||||
const mergedConfig = { ...(config || {}) }
|
||||
if (preference) {
|
||||
mergedConfig[MODELS_DEV_PRICING_CONFIG_KEY] = preference
|
||||
} else {
|
||||
delete mergedConfig[MODELS_DEV_PRICING_CONFIG_KEY]
|
||||
}
|
||||
return Object.keys(mergedConfig).length > 0 ? mergedConfig : null
|
||||
}
|
||||
|
||||
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 onlineModel = onlineModelsByName.get(model.name.trim().toLowerCase())
|
||||
if (!onlineModel?.tieredPricing) {
|
||||
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 })
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -103,16 +103,8 @@
|
||||
>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{{ model.display_name }}</span>
|
||||
<span
|
||||
v-if="getModelPricingPreference(model)"
|
||||
class="inline-flex h-5 shrink-0 items-center gap-1 rounded-md bg-primary/8 px-1.5 text-[10px] font-medium text-primary"
|
||||
:title="getModelPricingPreferenceTitle(model)"
|
||||
>
|
||||
<RefreshCw class="h-2.5 w-2.5" />
|
||||
自动价格
|
||||
</span>
|
||||
<div class="font-medium">
|
||||
{{ model.display_name }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<span>{{ model.name }}</span>
|
||||
@@ -254,14 +246,6 @@
|
||||
<Copy class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="getModelPricingPreference(model)"
|
||||
class="mt-1 inline-flex items-center gap-1 text-[10px] font-medium text-primary"
|
||||
:title="getModelPricingPreferenceTitle(model)"
|
||||
>
|
||||
<RefreshCw class="h-2.5 w-2.5" />
|
||||
自动价格 · {{ getModelPricingPreference(model)?.provider_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-0.5 shrink-0"
|
||||
@@ -486,7 +470,7 @@
|
||||
<Dialog
|
||||
:model-value="batchManageDialogOpen"
|
||||
title="快速筛选与批量操作"
|
||||
description="按在线定价来源快速筛选,并批量同步价格或删除模型"
|
||||
description="默认按每个模型上次选择的在线来源同步,也可手动指定统一来源"
|
||||
:icon="ListChecks"
|
||||
size="2xl"
|
||||
@update:model-value="batchManageDialogOpen = $event"
|
||||
@@ -505,12 +489,15 @@
|
||||
</div>
|
||||
<Select
|
||||
v-model="batchPricingProviderId"
|
||||
:disabled="batchManageOnlineLoading || batchPricingProviderOptions.length === 0"
|
||||
: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"
|
||||
@@ -590,6 +577,10 @@
|
||||
</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)"
|
||||
@@ -633,12 +624,13 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
<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"
|
||||
>
|
||||
@@ -653,6 +645,7 @@
|
||||
{{ 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"
|
||||
@@ -664,6 +657,7 @@
|
||||
{{ batchManageAction === 'delete' ? '删除中...' : '删除选中' }}
|
||||
</Button>
|
||||
<Button
|
||||
class="w-full whitespace-nowrap sm:flex-1 lg:w-auto lg:flex-none"
|
||||
variant="outline"
|
||||
@click="batchManageDialogOpen = false"
|
||||
>
|
||||
@@ -740,8 +734,8 @@ import { getModelsDevList, type ModelsDevModelItem } from '@/api/models-dev'
|
||||
import {
|
||||
buildGlobalModelPriceSyncPlan,
|
||||
cloneTieredPricingConfig,
|
||||
readModelsDevPricingPreference,
|
||||
} from '@/features/models/components/global-model-form-helpers'
|
||||
import { useModelsDevPricingSources } from '@/features/models/composables/useModelsDevPricingSources'
|
||||
|
||||
|
||||
interface ModelProviderDisplay {
|
||||
@@ -768,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)
|
||||
@@ -819,7 +815,7 @@ const editingProvider = ref<ModelProviderDisplay | null>(null)
|
||||
// 批量管理全局模型
|
||||
const batchManageDialogOpen = ref(false)
|
||||
const batchManageSearchQuery = ref('')
|
||||
const batchPricingProviderId = 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)
|
||||
@@ -851,17 +847,6 @@ const editingProviderModel = computed<Model | null>(() => {
|
||||
// 使用全局确认对话框
|
||||
const { confirm, confirmDanger } = useConfirm()
|
||||
|
||||
function getModelPricingPreference(model: GlobalModelResponse) {
|
||||
return readModelsDevPricingPreference(model.config)
|
||||
}
|
||||
|
||||
function getModelPricingPreferenceTitle(model: GlobalModelResponse): string {
|
||||
const preference = getModelPricingPreference(model)
|
||||
return preference
|
||||
? `自动应用在线价格 · ${preference.provider_name}`
|
||||
: ''
|
||||
}
|
||||
|
||||
// 从 GlobalModel 的 default_tiered_pricing 获取第一阶梯价格
|
||||
function getFirstTierPrice(model: GlobalModelResponse, type: 'input' | 'output'): number | null {
|
||||
const tiered = model.default_tiered_pricing
|
||||
@@ -1395,7 +1380,10 @@ const batchPricingProviderOptions = computed(() => {
|
||||
matchCount: 0,
|
||||
official: onlineModel.official === true,
|
||||
}
|
||||
if (onlineModel.tieredPricing && existingModelNames.has(onlineModel.modelId.trim().toLowerCase())) {
|
||||
if (
|
||||
(onlineModel.tieredPricing || onlineModel.pricingUnsupportedFields?.length)
|
||||
&& existingModelNames.has(onlineModel.modelId.trim().toLowerCase())
|
||||
) {
|
||||
provider.matchCount += 1
|
||||
}
|
||||
providers.set(onlineModel.providerId, provider)
|
||||
@@ -1414,17 +1402,34 @@ const selectedBatchPricingProvider = computed(() => (
|
||||
))
|
||||
|
||||
const batchPricingProviderModels = computed(() => (
|
||||
batchManageOnlineModels.value.filter(model => model.providerId === batchPricingProviderId.value)
|
||||
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)
|
||||
buildGlobalModelPriceSyncPlan(
|
||||
batchManageModels.value,
|
||||
batchPricingProviderModels.value,
|
||||
rememberedBatchPricingProviderIds.value,
|
||||
)
|
||||
))
|
||||
|
||||
const batchPricingStateByModelId = computed(() => {
|
||||
const states = new Map<string, 'syncable' | 'unchanged' | 'unavailable'>()
|
||||
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
|
||||
})
|
||||
@@ -1434,14 +1439,18 @@ const selectedBatchManageModels = computed(() => (
|
||||
))
|
||||
|
||||
const selectedBatchPriceSyncPlan = computed(() => (
|
||||
buildGlobalModelPriceSyncPlan(selectedBatchManageModels.value, batchPricingProviderModels.value)
|
||||
buildGlobalModelPriceSyncPlan(
|
||||
selectedBatchManageModels.value,
|
||||
batchPricingProviderModels.value,
|
||||
rememberedBatchPricingProviderIds.value,
|
||||
)
|
||||
))
|
||||
|
||||
const batchManageSelectionSummary = computed(() => {
|
||||
const selectedCount = selectedBatchManageModelIds.value.size
|
||||
if (selectedCount === 0) return batchPricingProviderId.value ? '选择模型后执行批量操作' : '请先选择在线价格来源'
|
||||
if (selectedCount === 0) return '选择模型后执行批量操作'
|
||||
const plan = selectedBatchPriceSyncPlan.value
|
||||
return `已选择 ${selectedCount} 个 · 可更新 ${plan.syncable.length} · 已一致 ${plan.unchanged.length} · 无在线价格 ${plan.unavailable.length}`
|
||||
return `已选择 ${selectedCount} 个 · 可更新 ${plan.syncable.length} · 已一致 ${plan.unchanged.length} · 不兼容 ${plan.unsupported.length} · 无在线价格 ${plan.unavailable.length}`
|
||||
})
|
||||
|
||||
function getBatchPricingState(model: GlobalModelResponse) {
|
||||
@@ -1452,20 +1461,31 @@ 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 = selectedBatchPricingProvider.value?.providerName
|
||||
return providerName
|
||||
? `${providerName} · ${getBatchPricingStateLabel(model)}`
|
||||
: '请选择在线价格来源'
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -1473,9 +1493,19 @@ 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')
|
||||
@@ -1492,6 +1522,7 @@ const batchManageShortcuts = computed(() => {
|
||||
}[] = [
|
||||
{ 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 },
|
||||
@@ -1542,7 +1573,7 @@ function toggleAllBatchManageModels() {
|
||||
// 打开批量管理对话框
|
||||
function openBatchManageDialog() {
|
||||
batchManageSearchQuery.value = ''
|
||||
batchPricingProviderId.value = ''
|
||||
batchPricingProviderId.value = REMEMBERED_PRICING_PROVIDER_ID
|
||||
selectedBatchManageModelIds.value = new Set()
|
||||
batchManageDialogOpen.value = true
|
||||
void Promise.all([loadBatchManageModels(), loadBatchManageOnlineModels()])
|
||||
@@ -1580,11 +1611,13 @@ async function runBatchTasksWithConcurrency(
|
||||
async function confirmBatchSyncPrices() {
|
||||
const plan = selectedBatchPriceSyncPlan.value
|
||||
if (plan.syncable.length === 0) return
|
||||
const providerName = selectedBatchPricingProvider.value?.providerName || '所选供应商'
|
||||
const skippedCount = plan.unchanged.length + plan.unavailable.length
|
||||
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仅更新模型价格,不修改名称、能力或其他配置。`,
|
||||
message: `将根据 ${providerName} 的在线定价更新 ${plan.syncable.length} 个模型。${skippedCount > 0 ? `另有 ${skippedCount} 个模型因价格一致、计价不兼容或无在线价格而跳过。` : ''}\n\n仅更新模型价格,不修改名称、能力或其他配置。`,
|
||||
confirmText: '同步价格',
|
||||
variant: 'info',
|
||||
})
|
||||
@@ -1605,6 +1638,10 @@ async function confirmBatchSyncPrices() {
|
||||
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)
|
||||
@@ -1672,8 +1709,11 @@ watch(batchPricingProviderId, (value, previousValue) => {
|
||||
|
||||
watch([batchPricingProviderOptions, batchManageDialogOpen], ([options, dialogOpen]) => {
|
||||
if (!dialogOpen) return
|
||||
if (!options.some(provider => provider.providerId === batchPricingProviderId.value)) {
|
||||
batchPricingProviderId.value = options[0]?.providerId ?? ''
|
||||
if (
|
||||
batchPricingProviderId.value !== REMEMBERED_PRICING_PROVIDER_ID
|
||||
&& !options.some(provider => provider.providerId === batchPricingProviderId.value)
|
||||
) {
|
||||
batchPricingProviderId.value = REMEMBERED_PRICING_PROVIDER_ID
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user