mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +08:00
fix(frontend): preserve fetched model preset pricing
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
+249
@@ -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,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
+158
-2
@@ -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 }],
|
||||
|
||||
Reference in New Issue
Block a user