Merge remote-tracking branch 'origin/main' into codex/usage-pending-reasoning-reset-expiry-20260712

# Conflicts:
#	frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts
This commit is contained in:
MMEXA
2026-07-17 00:16:58 +08:00
40 changed files with 5012 additions and 565 deletions
@@ -183,4 +183,98 @@ describe('resolveModelsDevTieredPricing', () => {
it('does not synthesize pricing when the fetched cost is absent', () => {
expect(resolveModelsDevTieredPricing('openai', 'gpt-5.6-sol', undefined)).toBeNull()
})
it('keeps a models.dev fast cost as an explicit Priority catalog when bands differ', () => {
expect(resolveModelsDevTieredPricing('openai', 'gpt-5.6-sol', {
input: 5,
output: 30,
tiers: [{
input: 10,
output: 45,
tier: { type: 'context', size: 272_000 },
}],
}, {
fast: {
cost: { input: 10, output: 60 },
provider: { body: { service_tier: 'priority' } },
},
})).toEqual({
tiers: [
{ up_to: 271_999, input_price_per_1m: 5, output_price_per_1m: 30 },
{ up_to: null, input_price_per_1m: 10, output_price_per_1m: 45 },
],
processing_tiers: {
priority: {
tiers: [{ up_to: null, input_price_per_1m: 10, output_price_per_1m: 60 }],
},
},
})
})
it('uses a multiplier only when every fast price has the same ratio', () => {
expect(resolveModelsDevTieredPricing('anthropic', 'claude-opus-4.8', {
input: 5,
output: 25,
cache_read: 0.5,
cache_write: 6.25,
}, {
fast: {
cost: { input: 10, output: 50, cache_read: 1, cache_write: 12.5 },
provider: { body: { speed: 'fast' } },
},
})?.processing_tiers).toEqual({
fast: { price_multiplier: 2 },
})
expect(resolveModelsDevTieredPricing('anthropic', 'claude-opus-4.7', {
input: 5,
output: 25,
}, {
fast: {
cost: { input: 30, output: 150 },
provider: { body: { speed: 'fast' } },
},
})?.processing_tiers).toEqual({
fast: { price_multiplier: 6 },
})
})
it('prefers Anthropic speed=fast when the mode body also carries a standard service tier', () => {
expect(resolveModelsDevTieredPricing('anthropic', 'claude-opus-fast', {
input: 5,
output: 25,
}, {
fast: {
cost: { input: 10, output: 50 },
provider: { body: { speed: ' FAST ', service_tier: 'default' } },
},
})?.processing_tiers).toEqual({
fast: { price_multiplier: 2 },
})
})
it('falls back to the mode key and keeps non-uniform prices explicit', () => {
expect(resolveModelsDevTieredPricing('vendor', 'model', {
input: 2,
output: 4,
}, {
flex: { cost: { input: 1, output: 3 } },
})?.processing_tiers).toEqual({
flex: {
tiers: [{ up_to: null, input_price_per_1m: 1, output_price_per_1m: 3 }],
},
})
})
it('does not reinterpret unrelated or special experimental modes as processing tiers', () => {
const modes = JSON.parse(
'{"pro":{"cost":{"input":2,"output":4}},"__proto__":{"cost":{"input":2,"output":4}}}',
)
const pricing = resolveModelsDevTieredPricing('vendor', 'model', {
input: 1,
output: 2,
}, modes)
expect(pricing?.processing_tiers).toBeUndefined()
})
})
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const apiMocks = vi.hoisted(() => ({
get: vi.fn(),
}))
vi.mock('@/api/client', () => ({
default: { get: apiMocks.get },
}))
import { clearModelsDevCache, getModelsDevList } from '@/api/models-dev'
beforeEach(() => {
clearModelsDevCache()
localStorage.clear()
apiMocks.get.mockReset()
})
describe('getModelsDevList', () => {
it('uses current modalities and experimental mode pricing while keeping legacy fallbacks', async () => {
apiMocks.get.mockResolvedValue({
data: {
openai: {
id: 'openai',
name: 'OpenAI',
official: true,
models: {
'gpt-test': {
id: 'gpt-test',
name: 'GPT Test',
input: ['text'],
output: ['text'],
modalities: {
input: ['text', 'image'],
output: ['text', 'image'],
},
cost: { input: 2, output: 4 },
experimental: {
modes: {
fast: {
cost: { input: 4, output: 8 },
provider: { body: { service_tier: 'priority' } },
},
},
},
},
legacy: {
id: 'legacy',
name: 'Legacy',
input: ['text', 'image'],
output: ['text'],
cost: { input: 1, output: 2 },
},
},
},
},
})
const models = await getModelsDevList()
const current = models.find(model => model.modelId === 'gpt-test')
const legacy = models.find(model => model.modelId === 'legacy')
expect(current).toMatchObject({
supportsVision: true,
inputModalities: ['text', 'image'],
outputModalities: ['text', 'image'],
tieredPricing: {
processing_tiers: { priority: { price_multiplier: 2 } },
},
})
expect(legacy).toMatchObject({
supportsVision: true,
inputModalities: ['text', 'image'],
outputModalities: ['text'],
})
})
})
+26 -24
View File
@@ -154,10 +154,34 @@ export interface RequestSchedulingFailure {
no_upstream_attempt?: boolean | null
}
export interface RequestPricingTier {
up_to?: number | null
input_price_per_1m?: number | null
output_price_per_1m?: number | null
cache_creation_price_per_1m?: number | null
cache_read_price_per_1m?: number | null
cache_ttl_pricing?: Array<{
ttl_minutes?: number | null
cache_creation_price_per_1m?: number | null
cache_read_price_per_1m?: number | null
}> | null
[key: string]: unknown
}
export interface RequestSettlementTieredPricing {
tiers?: RequestPricingTier[] | null
[key: string]: unknown
}
export interface RequestSettlementPricingSnapshot {
requested_processing_tier?: string | null
actual_processing_tier?: string | null
billing_processing_tier?: string | null
processing_tier_price_multiplier?: number | null
pricing_source?: string | null
tiered_pricing_source?: string | null
price_per_request_source?: string | null
tiered_pricing?: RequestSettlementTieredPricing | null
[key: string]: unknown
}
@@ -283,30 +307,8 @@ export interface RequestDetail {
tier_index: number // 命中的阶梯索引 (0-based)
tier_count: number // 阶梯总数
source?: 'provider' | 'global' // 定价来源: 提供商或全局
current_tier: { // 当前命中的阶梯配置
up_to?: number | null
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
cache_ttl_pricing?: Array<{
ttl_minutes: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
}>
}
tiers: Array<{ // 完整阶梯配置列表
up_to?: number | null
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
cache_ttl_pricing?: Array<{
ttl_minutes: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
}>
}>
current_tier: RequestPricingTier // 当前命中的阶梯配置
tiers: RequestPricingTier[] // 完整阶梯配置列表
} | null
// 视频/图像/音频计费信息
video_billing?: VideoBilling | null
+19 -4
View File
@@ -35,6 +35,8 @@ export interface ImageOutputPriceRange {
/** 按处理层级覆盖的费率配置。允许图像或未来计费字段独立扩展。 */
export interface ProcessingTierPricingConfig {
/** 相对 Standard 目录的统一价格倍率。新写入应与显式目录二选一;读取混合配置时显式目录优先。 */
price_multiplier?: number
tiers?: PricingTier[]
image_output_prices?: Record<string, ImageOutputQualityPricing> | null
image_output_price_default?: number | null
@@ -52,6 +54,19 @@ export interface TieredPricingConfig {
[key: string]: unknown
}
/**
* Provider 价格覆盖可以只声明 processing_tiers,并继续从 GlobalModel
* 继承 Standard 目录,因此 tiers 在原始 Provider 配置中是可选的。
*/
export interface ProviderTieredPricingConfig {
tiers?: PricingTier[]
image_output_prices?: Record<string, ImageOutputQualityPricing> | null
image_output_price_default?: number | null
image_output_price_ranges?: ImageOutputPriceRange[] | null
processing_tiers?: Record<string, ProcessingTierPricingConfig> | null
[key: string]: unknown
}
export interface Model {
id: string
provider_id: string
@@ -61,7 +76,7 @@ export interface Model {
config?: Record<string, unknown> | null // 额外配置(如 billing/video 等)
// 原始配置值(可能为空,为空时使用 GlobalModel 默认值)
price_per_request?: number | null // 按次计费价格
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
tiered_pricing?: ProviderTieredPricingConfig | null // Provider 原始覆盖,可仅包含 processing_tiers
supports_vision?: boolean | null
supports_function_calling?: boolean | null
supports_streaming?: boolean | null
@@ -69,7 +84,7 @@ export interface Model {
supports_image_generation?: boolean | null
supports_embedding?: boolean | null
// 有效值(合并 Model 和 GlobalModel 默认值后的结果)
effective_tiered_pricing?: TieredPricingConfig | null // 有效阶梯计费配置
effective_tiered_pricing?: ProviderTieredPricingConfig | null // 当前响应可能是 Provider partial 覆盖
effective_input_price?: number | null
effective_output_price?: number | null
effective_price_per_request?: number | null // 有效按次计费价格
@@ -97,7 +112,7 @@ export interface ModelCreate {
global_model_id: string // 关联的 GlobalModel ID(必填)
// 计费配置(可选,为空时使用 GlobalModel 默认值)
price_per_request?: number // 按次计费价格
tiered_pricing?: TieredPricingConfig // 阶梯计费配置
tiered_pricing?: ProviderTieredPricingConfig // Provider 阶梯计费覆盖
// 能力配置(可选,为空时使用 GlobalModel 默认值)
supports_vision?: boolean
supports_function_calling?: boolean
@@ -113,7 +128,7 @@ export interface ModelUpdate {
provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级)
global_model_id?: string
price_per_request?: number | null // 按次计费价格(null 表示清空/使用默认值)
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
tiered_pricing?: ProviderTieredPricingConfig | null // Provider 阶梯计费覆盖
supports_vision?: boolean
supports_function_calling?: boolean
supports_streaming?: boolean
+86 -1
View File
@@ -21,6 +21,14 @@ export interface ModelsDevCost extends ModelsDevTokenCost {
tiers?: ModelsDevCostTier[]
}
const TOKEN_PRICE_FIELDS = [
'input_price_per_1m',
'output_price_per_1m',
'cache_creation_price_per_1m',
'cache_read_price_per_1m',
] as const
const PROCESSING_MODE_FALLBACK_KEYS = new Set(['fast', 'priority', 'flex', 'batch'])
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -91,11 +99,88 @@ export function buildModelsDevTieredPricing(cost: unknown): TieredPricingConfig
return { tiers }
}
function uniformPriceMultiplier(
standard: TieredPricingConfig,
processing: TieredPricingConfig,
): number | null {
if (standard.tiers.length !== processing.tiers.length) return null
let candidate: number | null = null
for (const [index, standardTier] of standard.tiers.entries()) {
const processingTier = processing.tiers[index]
if (standardTier.up_to !== processingTier?.up_to) return null
for (const field of TOKEN_PRICE_FIELDS) {
const standardPrice = standardTier[field]
const processingPrice = processingTier[field]
if (standardPrice === undefined || processingPrice === undefined) {
if (standardPrice !== processingPrice) return null
continue
}
if (standardPrice === 0) {
if (processingPrice !== 0) return null
continue
}
const ratio = processingPrice / standardPrice
if (!Number.isFinite(ratio) || ratio < 0) return null
if (candidate === null) candidate = ratio
if (Math.abs(processingPrice - standardPrice * candidate) > 1e-9) return null
}
}
return candidate
}
export function resolveModelsDevTieredPricing(
_providerId: string,
_modelId: string,
cost: unknown,
experimentalModes?: unknown,
): TieredPricingConfig | null {
// Provider/model identities must never inject local prices over the fetched catalog.
return buildModelsDevTieredPricing(cost)
const standard = buildModelsDevTieredPricing(cost)
if (!standard || !isRecord(experimentalModes)) return standard
const processingTierEntries: Array<[string, NonNullable<TieredPricingConfig['processing_tiers']>[string]]> = []
const seenProcessingTiers = new Set<string>()
for (const [modeKey, rawMode] of Object.entries(experimentalModes)) {
if (!isRecord(rawMode)) continue
const modePricing = buildModelsDevTieredPricing(rawMode.cost)
if (!modePricing) continue
const provider = isRecord(rawMode.provider) ? rawMode.provider : null
const body = provider && isRecord(provider.body) ? provider.body : null
// Anthropic Fast is expressed with `speed=fast`. A provider body may also carry a
// `service_tier` fact (commonly `default`/`standard`), but runtime settlement deliberately
// gives Fast speed precedence, so catalog import must resolve the same processing-tier key.
const mappedProcessingTier = typeof body?.speed === 'string'
&& body.speed.trim().toLowerCase() === 'fast'
? body.speed
: typeof body?.service_tier === 'string'
? body.service_tier
: null
const normalizedModeKey = modeKey.trim().toLowerCase()
const rawProcessingTier = mappedProcessingTier
?? (PROCESSING_MODE_FALLBACK_KEYS.has(normalizedModeKey) ? normalizedModeKey : '')
const processingTier = rawProcessingTier.trim().toLowerCase()
if (
!processingTier
|| processingTier.length > 64
|| ['auto', 'default', 'standard'].includes(processingTier)
|| seenProcessingTiers.has(processingTier)
) {
continue
}
const multiplier = uniformPriceMultiplier(standard, modePricing)
seenProcessingTiers.add(processingTier)
processingTierEntries.push([processingTier, multiplier === null
? { tiers: modePricing.tiers }
: { price_multiplier: multiplier }])
}
if (processingTierEntries.length === 0) return standard
return {
...standard,
processing_tiers: Object.fromEntries(processingTierEntries),
}
}
+24 -4
View File
@@ -36,8 +36,21 @@ export interface ModelsDevModel {
last_updated?: string
input?: string[] // 输入模态: text, image, audio, video, pdf
output?: string[] // 输出模态: text, image, audio
modalities?: {
input?: string[]
output?: string[]
}
open_weights?: boolean
cost?: ModelsDevCost
experimental?: {
modes?: Record<string, {
cost?: ModelsDevCost
provider?: {
body?: Record<string, unknown>
headers?: Record<string, string>
}
}>
}
limit?: ModelsDevLimit
deprecated?: boolean
}
@@ -166,7 +179,14 @@ export async function getModelsDevList(officialOnly: boolean = true): Promise<Mo
if (!provider.models) continue
for (const [modelId, model] of Object.entries(provider.models)) {
const tieredPricing = resolveModelsDevTieredPricing(providerId, modelId, model.cost)
const inputModalities = model.modalities?.input ?? model.input
const outputModalities = model.modalities?.output ?? model.output
const tieredPricing = resolveModelsDevTieredPricing(
providerId,
modelId,
model.cost,
model.experimental?.modes,
)
const basePricingTier = tieredPricing?.tiers[0]
items.push({
providerId,
@@ -179,7 +199,7 @@ export async function getModelsDevList(officialOnly: boolean = true): Promise<Mo
tieredPricing: tieredPricing ?? undefined,
contextLimit: model.limit?.context,
outputLimit: model.limit?.output,
supportsVision: model.input?.includes('image'),
supportsVision: inputModalities?.includes('image'),
supportsToolCall: model.tool_call,
supportsReasoning: model.reasoning,
supportsStructuredOutput: model.structured_output,
@@ -194,8 +214,8 @@ export async function getModelsDevList(officialOnly: boolean = true): Promise<Mo
// display_metadata 相关字段
knowledgeCutoff: model.knowledge,
releaseDate: model.release_date,
inputModalities: model.input,
outputModalities: model.output,
inputModalities,
outputModalities,
})
}
}