mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 06:00:20 +08:00
Merge remote-tracking branch 'origin/main' into codex/pool-key-bulk-management-20260714
This commit is contained in:
@@ -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'],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user