From 59d37ae1dd73b38155973c5b3980b5e103d87344 Mon Sep 17 00:00:00 2001 From: MMEXA Date: Sat, 11 Jul 2026 18:09:31 +0800 Subject: [PATCH] fix(frontend): import structured models.dev pricing --- .../api/__tests__/models-dev-pricing.spec.ts | 101 ++++++++++++++++++ frontend/src/api/models-dev-pricing.ts | 92 ++++++++++++++++ frontend/src/api/models-dev.ts | 17 +-- .../components/GlobalModelFormDialog.vue | 14 +-- 4 files changed, 206 insertions(+), 18 deletions(-) create mode 100644 frontend/src/api/__tests__/models-dev-pricing.spec.ts create mode 100644 frontend/src/api/models-dev-pricing.ts diff --git a/frontend/src/api/__tests__/models-dev-pricing.spec.ts b/frontend/src/api/__tests__/models-dev-pricing.spec.ts new file mode 100644 index 000000000..b58f72c11 --- /dev/null +++ b/frontend/src/api/__tests__/models-dev-pricing.spec.ts @@ -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() + }) +}) diff --git a/frontend/src/api/models-dev-pricing.ts b/frontend/src/api/models-dev-pricing.ts new file mode 100644 index 000000000..09cccb76f --- /dev/null +++ b/frontend/src/api/models-dev-pricing.ts @@ -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 { + 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 | 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 } | 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 => 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 } +} diff --git a/frontend/src/api/models-dev.ts b/frontend/src/api/models-dev.ts index f5ef5c949..a4fd640b1 100644 --- a/frontend/src/api/models-dev.ts +++ b/frontend/src/api/models-dev.ts @@ -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