mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-10 05:00:19 +08:00
fix(frontend): preserve fetched model preset pricing
This commit is contained in:
@@ -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)],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+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