fix(frontend): import structured models.dev pricing

This commit is contained in:
MMEXA
2026-07-11 18:12:55 +08:00
parent 3014fd50c6
commit 59d37ae1dd
4 changed files with 206 additions and 18 deletions
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import { buildModelsDevTieredPricing } from '@/api/models-dev-pricing'
describe('buildModelsDevTieredPricing', () => {
it('maps context bands and cache prices without flattening them', () => {
expect(buildModelsDevTieredPricing({
input: 5,
output: 30,
cache_read: 0.5,
cache_write: 6.25,
tiers: [{
input: 10,
output: 45,
cache_read: 1,
cache_write: 12.5,
tier: { type: 'context', size: 272_000 },
}],
})).toEqual({
tiers: [
{
up_to: 271_999,
input_price_per_1m: 5,
output_price_per_1m: 30,
cache_creation_price_per_1m: 6.25,
cache_read_price_per_1m: 0.5,
},
{
up_to: null,
input_price_per_1m: 10,
output_price_per_1m: 45,
cache_creation_price_per_1m: 12.5,
cache_read_price_per_1m: 1,
},
],
})
})
it('sorts multiple context boundaries into contiguous Aether bands', () => {
const cost = {
input: 1,
output: 2,
tiers: [
{ input: 5, output: 6, tier: { type: 'context' as const, size: 200_000 } },
{ input: 3, output: 4, tier: { type: 'context' as const, size: 100_000 } },
],
}
expect(buildModelsDevTieredPricing(cost)?.tiers).toEqual([
{ up_to: 99_999, input_price_per_1m: 1, output_price_per_1m: 2 },
{ up_to: 199_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(cost.tiers.map(tier => tier.tier.size)).toEqual([200_000, 100_000])
})
it('keeps flat token pricing as one unbounded band', () => {
expect(buildModelsDevTieredPricing({ input: 0, output: 0.1 })).toEqual({
tiers: [{ up_to: null, input_price_per_1m: 0, output_price_per_1m: 0.1 }],
})
})
it('omits an empty base band when context pricing starts at zero', () => {
expect(buildModelsDevTieredPricing({
input: 1,
output: 2,
tiers: [
{ input: 3, output: 4, tier: { type: 'context', size: 0 } },
{ input: 5, output: 6, tier: { type: 'context', size: 100_000 } },
],
})?.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 },
])
})
it.each([
{ input: -1, output: 2 },
{ input: 1, output: Number.POSITIVE_INFINITY },
{
input: 1,
output: 2,
tiers: [{ input: 3, output: 4, tier: { type: 'context', size: Number.MAX_SAFE_INTEGER + 1 } }],
},
{
input: 1,
output: 2,
tiers: [{ input: 3, output: 4, tier: { type: 'context', size: -1 } }],
},
{
input: 1,
output: 2,
tiers: [
{ input: 3, output: 4, tier: { type: 'context', size: 100 } },
{ input: 5, output: 6, tier: { type: 'context', size: 100 } },
],
},
])('fails closed for malformed structured pricing', (cost) => {
expect(buildModelsDevTieredPricing(cost)).toBeNull()
})
})
+92
View File
@@ -0,0 +1,92 @@
import type { PricingTier, TieredPricingConfig } from './endpoints/types'
export interface ModelsDevTokenCost {
input: number
output: number
reasoning?: number
cache_read?: number
cache_write?: number
input_audio?: number
output_audio?: number
}
export interface ModelsDevCostTier extends ModelsDevTokenCost {
tier: {
type: 'context'
size: number
}
}
export interface ModelsDevCost extends ModelsDevTokenCost {
tiers?: ModelsDevCostTier[]
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isPrice(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0
}
function parseTokenPrices(value: unknown): Omit<PricingTier, 'up_to'> | null {
if (!isRecord(value) || !isPrice(value.input) || !isPrice(value.output)) return null
if (value.cache_write !== undefined && !isPrice(value.cache_write)) return null
if (value.cache_read !== undefined && !isPrice(value.cache_read)) return null
return {
input_price_per_1m: value.input,
output_price_per_1m: value.output,
...(value.cache_write === undefined
? {}
: { cache_creation_price_per_1m: value.cache_write }),
...(value.cache_read === undefined
? {}
: { cache_read_price_per_1m: value.cache_read }),
}
}
function parseContextTier(value: unknown): { size: number; prices: Omit<PricingTier, 'up_to'> } | null {
if (!isRecord(value) || !isRecord(value.tier)) return null
if (
value.tier.type !== 'context'
|| typeof value.tier.size !== 'number'
|| !Number.isSafeInteger(value.tier.size)
|| value.tier.size < 0
) {
return null
}
const prices = parseTokenPrices(value)
return prices ? { size: value.tier.size, prices } : null
}
export function buildModelsDevTieredPricing(cost: unknown): TieredPricingConfig | null {
const basePrices = parseTokenPrices(cost)
if (!basePrices || !isRecord(cost)) return null
const rawTiers = cost.tiers
if (rawTiers !== undefined && !Array.isArray(rawTiers)) return null
const contextTiers = (rawTiers ?? []).map(parseContextTier)
if (contextTiers.some(tier => tier === null)) return null
const sortedTiers = contextTiers
.filter((tier): tier is NonNullable<typeof tier> => tier !== null)
.sort((a, b) => a.size - b.size)
if (sortedTiers.some((tier, index) => index > 0 && tier.size === sortedTiers[index - 1].size)) {
return null
}
const tiers: PricingTier[] = []
if (sortedTiers[0]?.size !== 0) {
tiers.push({
...basePrices,
up_to: sortedTiers[0] ? sortedTiers[0].size - 1 : null,
})
}
tiers.push(...sortedTiers.map((tier, index) => ({
...tier.prices,
up_to: sortedTiers[index + 1] ? sortedTiers[index + 1].size - 1 : null,
})))
return { tiers }
}
+10 -7
View File
@@ -4,19 +4,19 @@
*/
import api from './client'
import {
buildModelsDevTieredPricing,
type ModelsDevCost,
} from './models-dev-pricing'
import type { TieredPricingConfig } from './endpoints/types'
export type { ModelsDevCost, ModelsDevCostTier, ModelsDevTokenCost } from './models-dev-pricing'
// 缓存配置
const CACHE_KEY = 'models_dev_cache'
const CACHE_DURATION = 15 * 60 * 1000 // 15 分钟
// Models.dev API 数据结构
export interface ModelsDevCost {
input?: number
output?: number
reasoning?: number
cache_read?: number
}
export interface ModelsDevLimit {
context?: number
output?: number
@@ -64,6 +64,7 @@ export interface ModelsDevModelItem {
family?: string
inputPrice?: number
outputPrice?: number
tieredPricing?: TieredPricingConfig
contextLimit?: number
outputLimit?: number
supportsVision?: boolean
@@ -165,6 +166,7 @@ export async function getModelsDevList(officialOnly: boolean = true): Promise<Mo
if (!provider.models) continue
for (const [modelId, model] of Object.entries(provider.models)) {
const tieredPricing = buildModelsDevTieredPricing(model.cost)
items.push({
providerId,
providerName: provider.name,
@@ -173,6 +175,7 @@ export async function getModelsDevList(officialOnly: boolean = true): Promise<Mo
family: model.family,
inputPrice: model.cost?.input,
outputPrice: model.cost?.output,
tieredPricing: tieredPricing ?? undefined,
contextLimit: model.limit?.context,
outputLimit: model.limit?.output,
supportsVision: model.input?.includes('image'),
@@ -1105,17 +1105,9 @@ function selectModel(model: ModelsDevModelItem) {
}
loadVideoPricingFromConfig()
if (model.inputPrice !== undefined || model.outputPrice !== undefined) {
tieredPricing.value = {
tiers: [{
up_to: null,
input_price_per_1m: model.inputPrice || 0,
output_price_per_1m: model.outputPrice || 0,
}]
}
} else {
tieredPricing.value = null
}
tieredPricing.value = model.tieredPricing
? structuredClone(model.tieredPricing)
: null
presetPanelCollapsed.value = true
scrollToBasicInformation()