fix(frontend): preserve fetched model preset pricing

This commit is contained in:
elky
2026-07-16 16:38:30 +08:00
parent 8fe4d24408
commit c32d043afb
7 changed files with 565 additions and 167 deletions
@@ -104,56 +104,75 @@ describe('buildModelsDevTieredPricing', () => {
})
describe('resolveModelsDevTieredPricing', () => {
it.each([
{
modelId: 'gpt-5.6-sol',
standard: [5, 30, 6.25, 0.5],
longContext: [10, 45, 12.5, 1],
},
{
modelId: 'gpt-5.6-terra',
standard: [2.5, 15, 3.125, 0.25],
longContext: [5, 22.5, 6.25, 0.5],
},
{
modelId: 'gpt-5.6-luna',
standard: [1, 6, 1.25, 0.1],
longContext: [2, 9, 2.5, 0.2],
},
])('uses the complete OpenAI catalog for $modelId', ({ modelId, standard, longContext }) => {
const tier = (
upTo: number | null,
prices: number[],
multiplier: number,
) => ({
up_to: upTo,
input_price_per_1m: prices[0] * multiplier,
output_price_per_1m: prices[1] * multiplier,
cache_creation_price_per_1m: prices[2] * multiplier,
cache_read_price_per_1m: prices[3] * multiplier,
})
expect(resolveModelsDevTieredPricing('openai', modelId, { input: 999, output: 999 }))
.toEqual({
tiers: [
tier(272_000, standard, 1),
tier(null, longContext, 1),
],
processing_tiers: {
flex: {
tiers: [
tier(272_000, standard, 0.5),
tier(null, longContext, 0.5),
],
},
priority: {
tiers: [tier(272_000, standard, 2)],
},
it('uses the GPT-5.5 Pro context tier declared by models.dev without inventing cache prices', () => {
expect(resolveModelsDevTieredPricing('openai', 'gpt-5.5-pro', {
input: 30,
output: 180,
tiers: [{
input: 60,
output: 270,
tier: { type: 'context', size: 272_000 },
}],
context_over_200k: {
input: 60,
output: 270,
},
})).toEqual({
tiers: [
{
up_to: 271_999,
input_price_per_1m: 30,
output_price_per_1m: 180,
},
})
{
up_to: null,
input_price_per_1m: 60,
output_price_per_1m: 270,
},
],
})
})
it('keeps the models.dev lower-bound conversion for models outside the catalog', () => {
it.each([
'gpt-5.6-sol',
'gpt-5.6-terra',
'gpt-5.6-luna',
])('uses the fetched models.dev cost for OpenAI model %s', (modelId) => {
const fetchedCost = {
input: 7,
output: 11,
cache_read: 0.7,
cache_write: 8.75,
tiers: [{
input: 13,
output: 17,
cache_read: 1.3,
cache_write: 16.25,
tier: { type: 'context' as const, size: 123_000 },
}],
}
expect(resolveModelsDevTieredPricing('openai', modelId, fetchedCost)).toEqual({
tiers: [
{
up_to: 122_999,
input_price_per_1m: 7,
output_price_per_1m: 11,
cache_creation_price_per_1m: 8.75,
cache_read_price_per_1m: 0.7,
},
{
up_to: null,
input_price_per_1m: 13,
output_price_per_1m: 17,
cache_creation_price_per_1m: 16.25,
cache_read_price_per_1m: 1.3,
},
],
})
})
it('uses the same fetched-cost conversion for every provider and model identity', () => {
expect(resolveModelsDevTieredPricing('openai', 'other-model', {
input: 1,
output: 2,
@@ -161,16 +180,7 @@ describe('resolveModelsDevTieredPricing', () => {
})?.tiers.map(tier => tier.up_to)).toEqual([271_999, null])
})
it.each([
['other-provider', 'gpt-5.6-sol'],
['openai', 'GPT-5.6-SOL'],
['openai', 'gpt-5.6-sol-latest'],
['openai', '__proto__'],
['openai', 'constructor'],
])('matches provider and model identities exactly for %s/%s', (providerId, modelId) => {
expect(resolveModelsDevTieredPricing(providerId, modelId, { input: 1, output: 2 }))
.toEqual({
tiers: [{ up_to: null, input_price_per_1m: 1, output_price_per_1m: 2 }],
})
it('does not synthesize pricing when the fetched cost is absent', () => {
expect(resolveModelsDevTieredPricing('openai', 'gpt-5.6-sol', undefined)).toBeNull()
})
})
@@ -1,71 +0,0 @@
import type { PricingTier, TieredPricingConfig } from './endpoints/types'
interface TokenPrices {
input: number
output: number
cacheCreation: number
cacheRead: number
}
interface ContextPricing {
standard: TokenPrices
longContext: TokenPrices
}
const OPENAI_GPT_56_PRICING = new Map<string, ContextPricing>([
['gpt-5.6-sol', {
standard: { input: 5, output: 30, cacheCreation: 6.25, cacheRead: 0.5 },
longContext: { input: 10, output: 45, cacheCreation: 12.5, cacheRead: 1 },
}],
['gpt-5.6-terra', {
standard: { input: 2.5, output: 15, cacheCreation: 3.125, cacheRead: 0.25 },
longContext: { input: 5, output: 22.5, cacheCreation: 6.25, cacheRead: 0.5 },
}],
['gpt-5.6-luna', {
standard: { input: 1, output: 6, cacheCreation: 1.25, cacheRead: 0.1 },
longContext: { input: 2, output: 9, cacheCreation: 2.5, cacheRead: 0.2 },
}],
])
const STANDARD_CONTEXT_LIMIT = 272_000
function pricingTier(
upTo: number | null,
prices: TokenPrices,
multiplier = 1,
): PricingTier {
return {
up_to: upTo,
input_price_per_1m: prices.input * multiplier,
output_price_per_1m: prices.output * multiplier,
cache_creation_price_per_1m: prices.cacheCreation * multiplier,
cache_read_price_per_1m: prices.cacheRead * multiplier,
}
}
function contextPricingTiers(pricing: ContextPricing, multiplier: number): PricingTier[] {
return [
pricingTier(STANDARD_CONTEXT_LIMIT, pricing.standard, multiplier),
pricingTier(null, pricing.longContext, multiplier),
]
}
export function getAuthoritativeModelPricing(
providerId: string,
modelId: string,
): TieredPricingConfig | null {
if (providerId !== 'openai') return null
const pricing = OPENAI_GPT_56_PRICING.get(modelId)
if (!pricing) return null
return {
tiers: contextPricingTiers(pricing, 1),
processing_tiers: {
flex: { tiers: contextPricingTiers(pricing, 0.5) },
priority: {
tiers: [pricingTier(STANDARD_CONTEXT_LIMIT, pricing.standard, 2)],
},
},
}
}
+4 -5
View File
@@ -1,5 +1,4 @@
import type { PricingTier, TieredPricingConfig } from './endpoints/types'
import { getAuthoritativeModelPricing } from './authoritative-model-pricing'
export interface ModelsDevTokenCost {
input: number
@@ -93,10 +92,10 @@ export function buildModelsDevTieredPricing(cost: unknown): TieredPricingConfig
}
export function resolveModelsDevTieredPricing(
providerId: string,
modelId: string,
_providerId: string,
_modelId: string,
cost: unknown,
): TieredPricingConfig | null {
return getAuthoritativeModelPricing(providerId, modelId)
?? buildModelsDevTieredPricing(cost)
// Provider/model identities must never inject local prices over the fetched catalog.
return buildModelsDevTieredPricing(cost)
}
@@ -394,6 +394,7 @@
ref="tieredPricingEditorRef"
v-model="tieredPricing"
class="mt-3"
:auto-fill-missing-cache-prices="autoFillMissingCachePrices"
:show-token-pricing="billingMode === 'token'"
:show-image-pricing="isImageGenerationEnabled"
:show-image-editor="billingMode === 'image'"
@@ -780,6 +781,7 @@ function enterManualEntryMode() {
}
function reopenPresetPanel() {
clearSelection()
presetPanelCollapsed.value = false
}
@@ -1068,8 +1070,6 @@ function selectModel(model: ModelsDevModelItem) {
imageGenerationExplicitOverride.value = null
selectedModel.value = model
expandedProvider.value = model.providerId
form.value.name = model.modelId
form.value.display_name = model.modelName
// 构建 config
const config: Record<string, unknown> = {
@@ -1089,11 +1089,16 @@ function selectModel(model: ModelsDevModelItem) {
if (model.releaseDate) config.release_date = model.releaseDate
if (model.inputModalities?.length) config.input_modalities = model.inputModalities
if (model.outputModalities?.length) config.output_modalities = model.outputModalities
form.value.config = config
const supportedCapabilities = new Set<string>()
if (model.supportsEmbedding) supportedCapabilities.add('embedding')
if (model.outputModalities?.includes('image')) supportedCapabilities.add('image_generation')
form.value.supported_capabilities = [...supportedCapabilities]
form.value = {
...defaultForm(),
name: model.modelId,
display_name: model.modelName,
config,
supported_capabilities: [...supportedCapabilities],
}
if (model.supportsEmbedding) {
setEmbeddingEnabled(true)
}
@@ -1120,6 +1125,7 @@ function clearSelection() {
selectedModel.value = null
form.value = defaultForm()
tieredPricing.value = null
videoResolutionPrices.value = []
billingMode.value = 'token'
}
@@ -1192,6 +1198,10 @@ const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
resetForm,
})
const autoFillMissingCachePrices = computed(() => (
!isEditMode.value && selectedModel.value === null
))
async function handleSubmit() {
if (!form.value.name || !form.value.display_name) {
showError('请填写模型ID和名称')
@@ -481,9 +481,11 @@ const props = withDefaults(defineProps<{
showCache1h?: boolean
showImagePricing?: boolean
showImageEditor?: boolean
autoFillMissingCachePrices?: boolean
}>(), {
showTokenPricing: true,
showImageEditor: true,
autoFillMissingCachePrices: true,
})
const emit = defineEmits<{
'update:modelValue': [value: TieredPricingConfig | null]
@@ -508,6 +510,7 @@ const activePricingScope = ref(STANDARD_PRICING_SCOPE)
const processingTierKeysEdited = ref(false)
const originalEmptyProcessingTiers = ref<'absent' | 'null' | 'object'>('absent')
const lastEmittedPricingJson = ref<string>('')
let initializedAutoFillMissingCachePrices: boolean | undefined
let imageOutputPriceRowId = 0
let imageOutputPriceRangeRowId = 0
@@ -608,11 +611,17 @@ const customInputValue = reactive<Record<number, string>>({})
// 初始化
watch(
() => props.modelValue,
(newValue) => {
if (lastEmittedPricingJson.value && JSON.stringify(newValue ?? null) === lastEmittedPricingJson.value) {
[() => props.modelValue, () => props.autoFillMissingCachePrices],
([newValue, autoFillMissingCachePrices]) => {
if (
initializedAutoFillMissingCachePrices === autoFillMissingCachePrices
&& lastEmittedPricingJson.value
&& JSON.stringify(newValue ?? null) === lastEmittedPricingJson.value
) {
return
}
lastEmittedPricingJson.value = ''
initializedAutoFillMissingCachePrices = autoFillMissingCachePrices
if (newValue?.tiers) {
const clonedValue = cloneJson(newValue)
basePricingConfig.value = clonedValue
@@ -726,19 +735,28 @@ function initializeScopeCacheState(scope: string, tiers: PricingTier[]) {
function createCacheMultiplierDraft(tier: PricingTier): CacheMultiplierDraft {
return {
creation: String(cacheMultiplierFromPrice(
creation: createCacheMultiplierDraftValue(
tier.input_price_per_1m,
tier.cache_creation_price_per_1m,
1.25,
)),
read: String(cacheMultiplierFromPrice(
),
read: createCacheMultiplierDraftValue(
tier.input_price_per_1m,
tier.cache_read_price_per_1m,
0.1,
)),
),
}
}
function createCacheMultiplierDraftValue(
inputPrice: number,
cachePrice: number | undefined,
fallback: number,
): string {
if (cachePrice == null && !props.autoFillMissingCachePrices) return ''
return String(cacheMultiplierFromPrice(inputPrice, cachePrice, fallback))
}
function getCachePriceMode(index: number): CachePriceMode {
return cachePriceModes.value?.[index] ?? 'multiplier'
}
@@ -756,10 +774,19 @@ function toggleCachePriceMode(index: number) {
const tier = localTiers.value[index]
const modes = cachePriceModes.value
const drafts = cacheMultiplierDrafts.value
const manualState = requireActiveCacheManualState()
if (!tier || !modes || !drafts) return
if (getCachePriceMode(index) === 'multiplier') {
tier.cache_creation_price_per_1m = getResolvedCacheCreationPrice(index)
tier.cache_read_price_per_1m = getResolvedCacheReadPrice(index)
if (props.autoFillMissingCachePrices || manualState[index]?.creation) {
tier.cache_creation_price_per_1m = getResolvedCacheCreationPrice(index)
} else {
delete tier.cache_creation_price_per_1m
}
if (props.autoFillMissingCachePrices || manualState[index]?.read) {
tier.cache_read_price_per_1m = getResolvedCacheReadPrice(index)
} else {
delete tier.cache_read_price_per_1m
}
modes[index] = 'price'
} else {
drafts[index] = createCacheMultiplierDraft(tier)
@@ -1176,18 +1203,26 @@ function buildTiersForScope(scope: string, includeAutomaticCache: boolean): Pric
const tier = cloneJson(sourceTier)
const state = manualState[index]
tier.cache_creation_price_per_1m = resolveCachePriceForScope(
scope,
index,
sourceTier,
'creation',
)
tier.cache_read_price_per_1m = resolveCachePriceForScope(
scope,
index,
sourceTier,
'read',
)
if (props.autoFillMissingCachePrices || state?.creation) {
tier.cache_creation_price_per_1m = resolveCachePriceForScope(
scope,
index,
sourceTier,
'creation',
)
} else {
delete tier.cache_creation_price_per_1m
}
if (props.autoFillMissingCachePrices || state?.read) {
tier.cache_read_price_per_1m = resolveCachePriceForScope(
scope,
index,
sourceTier,
'read',
)
} else {
delete tier.cache_read_price_per_1m
}
if (props.showCache1h) {
if (state?.cache1h && sourceTier.cache_ttl_pricing?.length) {
@@ -1479,23 +1514,33 @@ function confirmCustomInput(index: number) {
}
function updateCacheCreation(index: number, value: string | number) {
const manualState = requireActiveCacheManualState()
const hasValue = value !== '' && value !== null && value !== undefined
manualState[index] = { ...manualState[index], creation: hasValue }
if (getCachePriceMode(index) === 'multiplier') {
getCacheMultiplierDraft(index).creation = String(value ?? '')
} else {
localTiers.value[index].cache_creation_price_per_1m = value === ''
? undefined
: parseFloatInput(value)
if (hasValue) {
localTiers.value[index].cache_creation_price_per_1m = parseFloatInput(value)
} else {
delete localTiers.value[index].cache_creation_price_per_1m
}
}
syncToParent()
}
function updateCacheRead(index: number, value: string | number) {
const manualState = requireActiveCacheManualState()
const hasValue = value !== '' && value !== null && value !== undefined
manualState[index] = { ...manualState[index], read: hasValue }
if (getCachePriceMode(index) === 'multiplier') {
getCacheMultiplierDraft(index).read = String(value ?? '')
} else {
localTiers.value[index].cache_read_price_per_1m = value === ''
? undefined
: parseFloatInput(value)
if (hasValue) {
localTiers.value[index].cache_read_price_per_1m = parseFloatInput(value)
} else {
delete localTiers.value[index].cache_read_price_per_1m
}
}
syncToParent()
}
@@ -0,0 +1,249 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createApp,
defineComponent,
h,
nextTick,
ref,
type App,
} from 'vue'
import type { ModelsDevModelItem } from '@/api/models-dev'
import GlobalModelFormDialog from '../GlobalModelFormDialog.vue'
const modelsDevMocks = vi.hoisted(() => ({
getModelsDevList: vi.fn(),
}))
const globalModelMocks = vi.hoisted(() => ({
createGlobalModel: vi.fn(),
updateGlobalModel: vi.fn(),
}))
vi.mock('@/api/models-dev', () => ({
getModelsDevList: modelsDevMocks.getModelsDevList,
getProviderLogoUrl: (providerId: string) => `/logos/${providerId}.svg`,
}))
vi.mock('@/api/global-models', () => ({
createGlobalModel: globalModelMocks.createGlobalModel,
updateGlobalModel: globalModelMocks.updateGlobalModel,
}))
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
const stalePreset: ModelsDevModelItem = {
providerId: 'openai',
providerName: 'OpenAI',
modelId: 'stale-model',
modelName: 'Stale Model',
official: true,
supportsReasoning: true,
inputPrice: 1,
outputPrice: 2,
tieredPricing: {
tiers: [{
up_to: null,
input_price_per_1m: 1,
output_price_per_1m: 2,
}],
processing_tiers: {
priority: {
tiers: [{
up_to: null,
input_price_per_1m: 2,
output_price_per_1m: 4,
}],
},
},
},
}
const freshPreset: ModelsDevModelItem = {
providerId: 'openai',
providerName: 'OpenAI',
modelId: 'fresh-model',
modelName: 'Fresh Model',
family: 'fresh-family',
official: true,
supportsTemperature: false,
contextLimit: 128_000,
outputLimit: 4_096,
inputModalities: ['text'],
outputModalities: ['text'],
inputPrice: 3,
outputPrice: 4,
tieredPricing: {
tiers: [
{
up_to: 99_999,
input_price_per_1m: 3,
output_price_per_1m: 4,
},
{
up_to: null,
input_price_per_1m: 5,
output_price_per_1m: 6,
},
],
},
}
function mountDialog() {
const root = document.createElement('div')
document.body.appendChild(root)
const open = ref(false)
const app = createApp(defineComponent({
setup() {
return () => h(GlobalModelFormDialog, {
open: open.value,
model: null,
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
open.value = true
return { root, open }
}
async function settle() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve()
await nextTick()
}
}
function findButton(text: string): HTMLButtonElement {
const button = [...document.body.querySelectorAll('button')]
.find(candidate => candidate.textContent?.trim().includes(text))
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`Missing button containing: ${text}`)
}
return button
}
function findExactButton(text: string): HTMLButtonElement {
const button = [...document.body.querySelectorAll('button')]
.find(candidate => candidate.textContent?.trim() === text)
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`Missing button: ${text}`)
}
return button
}
async function setInput(input: HTMLInputElement | null, value: string) {
if (!input) throw new Error('Missing input')
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
}
beforeEach(() => {
modelsDevMocks.getModelsDevList.mockReset()
modelsDevMocks.getModelsDevList.mockResolvedValue([stalePreset, freshPreset])
globalModelMocks.createGlobalModel.mockReset()
globalModelMocks.createGlobalModel.mockResolvedValue({})
globalModelMocks.updateGlobalModel.mockReset()
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
value: vi.fn(),
configurable: true,
})
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
document.body.innerHTML = ''
})
describe('GlobalModelFormDialog preset replacement', () => {
it('drops the previous draft and submits only the newly selected model preset', async () => {
mountDialog()
await settle()
findButton('Stale Model').click()
await settle()
await setInput(
document.body.querySelector<HTMLInputElement>('input[placeholder="如 0.01"]'),
'0.25',
)
await setInput(
document.body.querySelector<HTMLInputElement>('#model-description'),
'must not leak into the next preset',
)
await setInput(
document.body.querySelector<HTMLInputElement>('[data-testid="tier-input-price"]'),
'99',
)
findExactButton('视频').click()
await nextTick()
findExactButton('Sora').click()
await nextTick()
findButton('返回选择模型').click()
await settle()
findButton('Fresh Model').click()
await settle()
expect(document.body.querySelector<HTMLInputElement>('#model-name')?.value).toBe('fresh-model')
expect(document.body.querySelector<HTMLInputElement>('#model-display-name')?.value).toBe('Fresh Model')
expect(document.body.querySelector<HTMLInputElement>('#model-description')?.value).toBe('')
expect(document.body.querySelector<HTMLInputElement>('input[placeholder="如 0.01"]')?.value).toBe('')
expect(
[...document.body.querySelectorAll<HTMLInputElement>('[data-testid="tier-input-price"]')]
.map(input => input.value),
).toEqual(['3', '5'])
findExactButton('添加').click()
await settle()
expect(globalModelMocks.createGlobalModel).toHaveBeenCalledOnce()
const payload = globalModelMocks.createGlobalModel.mock.calls[0][0]
expect(payload).toMatchObject({
name: 'fresh-model',
display_name: 'Fresh Model',
default_price_per_request: undefined,
config: {
streaming: true,
context_limit: 128_000,
output_limit: 4_096,
family: 'fresh-family',
input_modalities: ['text'],
output_modalities: ['text'],
},
default_tiered_pricing: {
tiers: [
{
up_to: 99_999,
input_price_per_1m: 3,
output_price_per_1m: 4,
},
{
up_to: null,
input_price_per_1m: 5,
output_price_per_1m: 6,
},
],
},
})
expect(payload.config).not.toHaveProperty('description')
expect(payload.config).not.toHaveProperty('billing')
expect(payload.default_tiered_pricing).not.toHaveProperty('processing_tiers')
expect(payload.default_tiered_pricing.tiers).toEqual([
{
up_to: 99_999,
input_price_per_1m: 3,
output_price_per_1m: 4,
},
{
up_to: null,
input_price_per_1m: 5,
output_price_per_1m: 6,
},
])
})
})
@@ -1,5 +1,13 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, type App, type ComponentPublicInstance } from 'vue'
import {
createApp,
defineComponent,
h,
nextTick,
shallowRef,
type App,
type ComponentPublicInstance,
} from 'vue'
import type { TieredPricingConfig } from '@/api/endpoints/types'
import TieredPricingEditor from '../TieredPricingEditor.vue'
@@ -14,6 +22,7 @@ const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function mountEditor(
modelValue: TieredPricingConfig,
options: {
autoFillMissingCachePrices?: boolean
showCache1h?: boolean
showImagePricing?: boolean
showTokenPricing?: boolean
@@ -23,6 +32,7 @@ function mountEditor(
const root = document.createElement('div')
document.body.appendChild(root)
const onUpdate = vi.fn()
const currentModelValue = shallowRef(modelValue)
let editor: TieredPricingEditorExposed | null = null
const app = createApp(defineComponent({
@@ -31,7 +41,8 @@ function mountEditor(
ref: (instance: unknown) => {
editor = instance as TieredPricingEditorExposed | null
},
modelValue,
modelValue: currentModelValue.value,
autoFillMissingCachePrices: options.autoFillMissingCachePrices,
showCache1h: options.showCache1h,
showImagePricing: options.showImagePricing,
showTokenPricing: options.showTokenPricing,
@@ -47,6 +58,9 @@ function mountEditor(
return {
root,
onUpdate,
setModelValue: (value: TieredPricingConfig) => {
currentModelValue.value = value
},
getFinalPricing: () => {
if (!editor) throw new Error('TieredPricingEditor ref was not mounted')
return editor.getFinalPricing()
@@ -280,6 +294,148 @@ describe('TieredPricingEditor processing tiers', () => {
expect(result.processing_tiers?.priority.tiers?.[0].cache_creation_price_per_1m).toBe(20)
})
it('keeps absent cache prices empty and absent when automatic cache filling is disabled', () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
} as TieredPricingConfig
const { root, getFinalPricing } = mountEditor(pricing, {
autoFillMissingCachePrices: false,
})
const creation = root.querySelector(
'input[aria-label="Standard 阶梯 1 缓存创建倍率"]',
) as HTMLInputElement
const read = root.querySelector(
'input[aria-label="Standard 阶梯 1 缓存读取倍率"]',
) as HTMLInputElement
expect(creation.value).toBe('')
expect(read.value).toBe('')
expect(getFinalPricing().tiers).toEqual([
{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 },
])
})
it('preserves only the explicitly supplied side of cache pricing when automatic filling is disabled', () => {
const pricing = {
tiers: [
{
up_to: 128_000,
input_price_per_1m: 5,
output_price_per_1m: 30,
cache_creation_price_per_1m: 6.25,
},
{
up_to: null,
input_price_per_1m: 7,
output_price_per_1m: 42,
cache_read_price_per_1m: 0.7,
},
],
} as TieredPricingConfig
const { root, getFinalPricing } = mountEditor(pricing, {
autoFillMissingCachePrices: false,
})
const creationValues = [...root.querySelectorAll<HTMLInputElement>(
'input[aria-label*="缓存创建倍率"]',
)].map(input => input.value)
const readValues = [...root.querySelectorAll<HTMLInputElement>(
'input[aria-label*="缓存读取倍率"]',
)].map(input => input.value)
expect(creationValues).toEqual(['1.25', ''])
expect(readValues).toEqual(['', '0.1'])
expect(getFinalPricing().tiers).toEqual(pricing.tiers)
})
it('adds and removes only the cache price edited by the user when automatic filling is disabled', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
} as TieredPricingConfig
const { root, getFinalPricing } = mountEditor(pricing, {
autoFillMissingCachePrices: false,
})
const read = root.querySelector(
'input[aria-label="Standard 阶梯 1 缓存读取倍率"]',
) as HTMLInputElement
read.value = '0.2'
read.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(getFinalPricing().tiers).toEqual([{
up_to: null,
input_price_per_1m: 5,
output_price_per_1m: 30,
cache_read_price_per_1m: 1,
}])
read.value = ''
read.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(getFinalPricing().tiers).toEqual([{
up_to: null,
input_price_per_1m: 5,
output_price_per_1m: 30,
}])
})
it('does not turn absent cache prices into zero when switching editor modes', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
} as TieredPricingConfig
const { root, getFinalPricing } = mountEditor(pricing, {
autoFillMissingCachePrices: false,
})
click(root.querySelector(
'button[aria-label="Standard 阶梯 1 切换缓存价格输入方式"]',
))
await nextTick()
expect((root.querySelector(
'input[aria-label="Standard 阶梯 1 缓存创建价格"]',
) as HTMLInputElement).value).toBe('')
expect((root.querySelector(
'input[aria-label="Standard 阶梯 1 缓存读取价格"]',
) as HTMLInputElement).value).toBe('')
expect(getFinalPricing().tiers).toEqual(pricing.tiers)
})
it('rebuilds when an external model later matches an older emitted value', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
} as TieredPricingConfig
const { root, onUpdate, setModelValue } = mountEditor(pricing, {
autoFillMissingCachePrices: false,
})
const read = root.querySelector(
'input[aria-label="Standard 阶梯 1 缓存读取倍率"]',
) as HTMLInputElement
read.value = '0.2'
read.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
const olderEmittedValue = onUpdate.mock.lastCall?.[0] as TieredPricingConfig
setModelValue({
tiers: [{ up_to: null, input_price_per_1m: 7, output_price_per_1m: 42 }],
})
await nextTick()
expect((root.querySelector('[data-testid="tier-input-price"]') as HTMLInputElement).value)
.toBe('7')
setModelValue(olderEmittedValue)
await nextTick()
expect((root.querySelector('[data-testid="tier-input-price"]') as HTMLInputElement).value)
.toBe('5')
expect((root.querySelector(
'input[aria-label="Standard 阶梯 1 缓存读取倍率"]',
) as HTMLInputElement).value).toBe('0.2')
})
it('keeps processing image catalogs editable when token controls are hidden', async () => {
const pricing = {
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],