feat(models): track online pricing sources and unsupported fields

This commit is contained in:
ZheFox
2026-07-23 15:18:08 +08:00
parent 323273ff30
commit 1d2655432d
11 changed files with 549 additions and 242 deletions
@@ -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')
@@ -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',
},
},
},
)
})
})
@@ -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 })