fix(models): correct fast pricing and online sync

This commit is contained in:
elky
2026-07-24 01:45:38 +08:00
parent a0767d957c
commit 387134ca87
11 changed files with 1269 additions and 420 deletions
+155 -10
View File
@@ -360,13 +360,14 @@ impl BillingModelPricingSnapshot {
} else {
BillingPricingSource::Mixed
};
let pricing = multiply_pricing_catalog(standard, multiplier).ok_or_else(|| {
invalid_processing_tier_error(
multiplier_source,
processing_tier,
"price_multiplier produced an invalid pricing catalog",
)
})?;
let pricing =
multiply_pricing_catalog(standard, multiplier, processing_tier).ok_or_else(|| {
invalid_processing_tier_error(
multiplier_source,
processing_tier,
"price_multiplier produced an invalid pricing catalog",
)
})?;
Ok((pricing, source))
}
@@ -587,7 +588,11 @@ fn invalid_processing_tier_error(
))
}
fn multiply_pricing_catalog(pricing: &Value, multiplier: f64) -> Option<Value> {
fn multiply_pricing_catalog(
pricing: &Value,
multiplier: f64,
processing_tier: &str,
) -> Option<Value> {
if !multiplier.is_finite() || multiplier < 0.0 {
return None;
}
@@ -597,6 +602,12 @@ fn multiply_pricing_catalog(pricing: &Value, multiplier: f64) -> Option<Value> {
// settlement snapshot recursively carry unrelated processing-tier configuration.
object.remove("processing_tiers");
// Fast pricing is flat: its multiplier applies to Standard's first band at every context size.
// Fast prices are fixed from the base Standard band; long-context premiums do not stack.
if matches!(processing_tier, "priority" | "fast") {
collapse_token_tiers_to_first(object)?;
}
if let Some(tiers) = object.get_mut("tiers").and_then(Value::as_array_mut) {
for tier in tiers.iter_mut().filter_map(Value::as_object_mut) {
multiply_price_fields(
@@ -646,6 +657,24 @@ fn multiply_pricing_catalog(pricing: &Value, multiplier: f64) -> Option<Value> {
Some(multiplied)
}
fn collapse_token_tiers_to_first(object: &mut serde_json::Map<String, Value>) -> Option<()> {
let Some(tiers_value) = object.get_mut("tiers") else {
return Some(());
};
if tiers_value.is_null() {
return Some(());
}
let tiers = tiers_value.as_array_mut()?;
let Some(mut first_tier) = tiers.first().cloned() else {
return Some(());
};
first_tier
.as_object_mut()?
.insert("up_to".to_string(), Value::Null);
*tiers = vec![first_tier];
Some(())
}
fn multiply_price_fields(
object: &mut serde_json::Map<String, Value>,
fields: &[&str],
@@ -989,7 +1018,7 @@ mod tests {
}
#[test]
fn processing_multiplier_materializes_known_prices_without_touching_bounds_or_extensions() {
fn fast_processing_multiplier_materializes_known_prices_and_preserves_extensions() {
let mut pricing = snapshot(
Some(json!({
"tiers": [{
@@ -1038,7 +1067,10 @@ mod tests {
Some(BillingPricingSource::ProviderOverride)
);
assert_eq!(resolution.processing_tier_price_multiplier, Some(2.5));
assert_eq!(catalog.pointer("/tiers/0/up_to"), Some(&json!(272000)));
assert_eq!(
catalog.pointer("/tiers/0/up_to"),
Some(&serde_json::Value::Null)
);
assert_eq!(
catalog.pointer("/tiers/0/input_price_per_1m"),
Some(&json!(5.0))
@@ -1109,6 +1141,119 @@ mod tests {
}
}
#[test]
fn fast_multipliers_flatten_multiband_standard_while_flex_preserves_all_bands() {
let pricing = snapshot(
Some(json!({
"tiers": [
{
"up_to": 272000,
"input_price_per_1m": 2.0,
"output_price_per_1m": 4.0,
"cache_creation_price_per_1m": 6.0,
"cache_read_price_per_1m": 8.0,
"future_tier_option": "first"
},
{
"up_to": null,
"input_price_per_1m": 5.0,
"output_price_per_1m": 7.0,
"cache_creation_price_per_1m": 9.0,
"cache_read_price_per_1m": 11.0,
"future_tier_option": "second"
}
],
"image_output_price_default": 10.0,
"future_catalog_option": 31,
"processing_tiers": {
"priority": {"price_multiplier": 2.0},
"fast": {"price_multiplier": 3.0},
"flex": {"price_multiplier": 4.0}
}
})),
None,
);
for (tier, expected_tiers, expected_image_price, expected_multiplier) in [
(
"priority",
json!([{
"up_to": null,
"input_price_per_1m": 4.0,
"output_price_per_1m": 8.0,
"cache_creation_price_per_1m": 12.0,
"cache_read_price_per_1m": 16.0,
"future_tier_option": "first"
}]),
json!(20.0),
2.0,
),
(
"fast",
json!([{
"up_to": null,
"input_price_per_1m": 6.0,
"output_price_per_1m": 12.0,
"cache_creation_price_per_1m": 18.0,
"cache_read_price_per_1m": 24.0,
"future_tier_option": "first"
}]),
json!(30.0),
3.0,
),
] {
let resolution = pricing.resolve_pricing(Some(tier), None);
let catalog = resolution
.tiered_pricing
.as_ref()
.expect("fast multiplier should materialize a pricing catalog");
assert_eq!(catalog["tiers"], expected_tiers, "{tier}");
assert_eq!(
catalog["image_output_price_default"], expected_image_price,
"{tier}"
);
assert_eq!(catalog["future_catalog_option"], json!(31), "{tier}");
assert!(catalog.get("processing_tiers").is_none(), "{tier}");
assert_eq!(
resolution.processing_tier_price_multiplier,
Some(expected_multiplier),
"{tier}"
);
}
let flex = pricing.resolve_pricing(Some("flex"), None);
let flex_catalog = flex
.tiered_pricing
.as_ref()
.expect("flex multiplier should materialize a pricing catalog");
assert_eq!(
flex_catalog["tiers"],
json!([
{
"up_to": 272000,
"input_price_per_1m": 8.0,
"output_price_per_1m": 16.0,
"cache_creation_price_per_1m": 24.0,
"cache_read_price_per_1m": 32.0,
"future_tier_option": "first"
},
{
"up_to": null,
"input_price_per_1m": 20.0,
"output_price_per_1m": 28.0,
"cache_creation_price_per_1m": 36.0,
"cache_read_price_per_1m": 44.0,
"future_tier_option": "second"
}
])
);
assert_eq!(flex_catalog["image_output_price_default"], json!(40.0));
assert_eq!(flex_catalog["future_catalog_option"], json!(31));
assert!(flex_catalog.get("processing_tiers").is_none());
assert_eq!(flex.processing_tier_price_multiplier, Some(4.0));
}
#[test]
fn processing_overlay_precedence_is_provider_explicit_multiplier_then_global() {
let provider_explicit = snapshot(
+49
View File
@@ -1456,6 +1456,55 @@ mod tests {
);
}
#[test]
fn fast_multiplier_uses_one_fixed_first_band_above_context_threshold() {
let pricing = BillingModelPricingSnapshot {
default_tiered_pricing: Some(json!({
"tiers": [
{
"up_to": 271999,
"input_price_per_1m": 5.0,
"output_price_per_1m": 30.0
},
{
"up_to": null,
"input_price_per_1m": 10.0,
"output_price_per_1m": 45.0
}
],
"processing_tiers": {
"priority": {"price_multiplier": 2.0}
}
})),
model_tiered_pricing: None,
..pricing()
};
for input_tokens in [271_999, 272_000, 300_000] {
let result = BillingService::new()
.calculate(
&pricing,
&processing_usage(Some("priority"), Some("priority"), input_tokens),
)
.expect("Fast pricing should settle at every context size");
assert_eq!(
result.cost_result.status,
BillingSnapshotStatus::Complete,
"context: {input_tokens}"
);
assert_eq!(
result.cost_result.snapshot.resolved_variables["input_price_per_1m"],
json!(10.0),
"Fast must use the first Standard band at context {input_tokens}"
);
assert_eq!(
result.cost_result.snapshot.resolved_variables["output_price_per_1m"],
json!(60.0),
"Fast output must remain fixed at context {input_tokens}"
);
}
}
#[test]
fn processing_catalog_boundaries_match_context_and_priority_contracts() {
let cases = [
@@ -63,6 +63,14 @@ describe('buildModelsDevTieredPricing', () => {
})
})
it('fails closed when a legacy long-context copy has no authoritative tier boundary', () => {
expect(buildModelsDevTieredPricing({
input: 5,
output: 30,
context_over_200k: { input: 10, output: 45 },
})).toBeNull()
})
it('allows special token dimensions only when they use the base token price', () => {
expect(buildModelsDevTieredPricing({
input: 1,
@@ -214,7 +222,7 @@ describe('resolveModelsDevTieredPricing', () => {
expect(resolveModelsDevTieredPricing('openai', 'gpt-5.6-sol', undefined)).toBeNull()
})
it('keeps a models.dev fast cost as an explicit Priority catalog when bands differ', () => {
it('collapses a flat models.dev Fast cost to the Standard first band multiplier', () => {
expect(resolveModelsDevTieredPricing('openai', 'gpt-5.6-sol', {
input: 5,
output: 30,
@@ -235,12 +243,81 @@ describe('resolveModelsDevTieredPricing', () => {
],
processing_tiers: {
priority: {
tiers: [{ up_to: null, input_price_per_1m: 10, output_price_per_1m: 60 }],
price_multiplier: 2,
},
},
})
})
it('maps the GPT-5.6 Fast cost and ignores the legacy context copy', () => {
expect(resolveModelsDevTieredPricing('openai', 'gpt-5.6-sol', {
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 },
}],
context_over_200k: {
input: 10,
output: 45,
cache_read: 1,
cache_write: 12.5,
},
}, {
fast: {
cost: {
input: 10,
output: 60,
cache_read: 1,
cache_write: 12.5,
},
provider: { body: { service_tier: 'priority' } },
},
})).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,
},
],
processing_tiers: {
priority: { price_multiplier: 2 },
},
})
})
it('fails closed when an experimental mode tries to supply context tiers', () => {
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,
tiers: [{ input: 20, output: 90, tier: { type: 'context', size: 272_000 } }],
},
provider: { body: { service_tier: 'priority' } },
},
})?.processing_tiers).toBeUndefined()
})
it('uses a multiplier only when every fast price has the same ratio', () => {
expect(resolveModelsDevTieredPricing('anthropic', 'claude-opus-4.8', {
input: 5,
@@ -269,6 +346,27 @@ describe('resolveModelsDevTieredPricing', () => {
})
})
it('keeps a non-uniform Fast cost as one explicit unbounded band', () => {
expect(resolveModelsDevTieredPricing('openai', 'future-model', {
input: 5,
output: 30,
tiers: [{
input: 10,
output: 45,
tier: { type: 'context', size: 272_000 },
}],
}, {
fast: {
cost: { input: 10, output: 75 },
provider: { body: { service_tier: 'priority' } },
},
})?.processing_tiers).toEqual({
priority: {
tiers: [{ up_to: null, input_price_per_1m: 10, output_price_per_1m: 75 }],
},
})
})
it('uses the standard catalog when an imported tier has a zero default ratio', () => {
expect(resolveModelsDevTieredPricing('openai', 'gpt-5.6-sol', {
input: 5,
+39 -3
View File
@@ -2,18 +2,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const apiMocks = vi.hoisted(() => ({
get: vi.fn(),
delete: vi.fn(),
}))
vi.mock('@/api/client', () => ({
default: { get: apiMocks.get },
default: { get: apiMocks.get, delete: apiMocks.delete },
}))
import { clearModelsDevCache, getModelsDevList } from '@/api/models-dev'
import { clearModelsDevCache, getModelsDevList, refreshModelsDevList } from '@/api/models-dev'
beforeEach(() => {
clearModelsDevCache()
localStorage.clear()
apiMocks.get.mockReset()
apiMocks.delete.mockReset()
apiMocks.delete.mockResolvedValue({ data: { cleared: true } })
})
describe('getModelsDevList', () => {
@@ -34,7 +37,15 @@ describe('getModelsDevList', () => {
input: ['text', 'image'],
output: ['text', 'image'],
},
cost: { input: 2, output: 4 },
cost: {
input: 2,
output: 4,
tiers: [{
input: 4,
output: 8,
tier: { type: 'context', size: 100_000 },
}],
},
experimental: {
modes: {
fast: {
@@ -86,4 +97,29 @@ describe('getModelsDevList', () => {
})
expect(audioPriced?.tieredPricing).toBeUndefined()
})
it('clears the gateway cache before rebuilding the online model list', async () => {
apiMocks.get.mockResolvedValue({
data: {
openai: {
name: 'OpenAI',
official: true,
models: {
'gpt-test': {
id: 'gpt-test',
name: 'GPT Test',
cost: { input: 2, output: 4 },
},
},
},
},
})
await getModelsDevList(false)
await refreshModelsDevList(false)
expect(apiMocks.delete).toHaveBeenCalledOnce()
expect(apiMocks.delete).toHaveBeenCalledWith('/api/admin/models/external/cache')
expect(apiMocks.get).toHaveBeenCalledTimes(2)
})
})
+1 -1
View File
@@ -35,7 +35,7 @@ export interface ImageOutputPriceRange {
/** 按处理层级覆盖的费率配置。允许图像或未来计费字段独立扩展。 */
export interface ProcessingTierPricingConfig {
/** 相对 Standard 目录的统一价格倍率。新写入应与显式目录二选一;读取混合配置时显式目录优先。 */
/** 相对 Standard 的统一倍率;Fast/priority 以首档作为固定基准,其他层级缩放完整目录。 */
price_multiplier?: number
tiers?: PricingTier[]
image_output_prices?: Record<string, ImageOutputQualityPricing> | null
+57 -15
View File
@@ -18,6 +18,8 @@ export interface ModelsDevCostTier extends ModelsDevTokenCost {
}
export interface ModelsDevCost extends ModelsDevTokenCost {
/** Legacy copy; `tiers` remains the authoritative source for exact boundaries. */
context_over_200k?: ModelsDevTokenCost
tiers?: ModelsDevCostTier[]
}
@@ -30,8 +32,19 @@ const TOKEN_PRICE_FIELDS = [
'cache_read_price_per_1m',
] as const
const PROCESSING_MODE_FALLBACK_KEYS = new Set(['fast', 'priority', 'flex', 'batch'])
// Models.dev experimental Fast/priority prices are flat even when Standard has
// context bands. Their multiplier is relative to the first Standard band only.
const FLAT_MULTIPLIER_PROCESSING_TIERS = new Set(['fast', 'priority'])
const DEFAULT_PROCESSING_TIER_MULTIPLIER = 1
type ParsedTokenPrices = Pick<
PricingTier,
| 'input_price_per_1m'
| 'output_price_per_1m'
| 'cache_creation_price_per_1m'
| 'cache_read_price_per_1m'
>
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -74,24 +87,27 @@ export function getModelsDevUnsupportedPricingFields(
return [...unsupportedFields]
}
function parseTokenPrices(value: unknown): Omit<PricingTier, 'up_to'> | null {
function parseTokenPrices(value: unknown): ParsedTokenPrices | 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
const cacheWrite = value.cache_write
const cacheRead = value.cache_read
return {
const prices: ParsedTokenPrices = {
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 }),
}
if (cacheWrite !== undefined) {
if (!isPrice(cacheWrite)) return null
prices.cache_creation_price_per_1m = cacheWrite
}
if (cacheRead !== undefined) {
if (!isPrice(cacheRead)) return null
prices.cache_read_price_per_1m = cacheRead
}
return prices
}
function parseContextTier(value: unknown): { size: number; prices: Omit<PricingTier, 'up_to'> } | null {
function parseContextTier(value: unknown): { size: number; prices: ParsedTokenPrices } | null {
if (!isRecord(value) || !isRecord(value.tier)) return null
if (
value.tier.type !== 'context'
@@ -112,7 +128,9 @@ export function buildModelsDevTieredPricing(cost: unknown): TieredPricingConfig
const rawTiers = cost.tiers
if (rawTiers !== undefined && !Array.isArray(rawTiers)) return null
const contextTiers = (rawTiers ?? []).map(parseContextTier)
// The compatibility copy omits the exact starting boundary; importing it alone could underbill.
if (rawTiers === undefined && cost.context_over_200k !== undefined) return null
const contextTiers = (Array.isArray(rawTiers) ? rawTiers : []).map(parseContextTier)
if (contextTiers.some(tier => tier === null)) return null
const sortedTiers = contextTiers
@@ -171,6 +189,26 @@ function uniformPriceMultiplier(
return candidate === 0 ? DEFAULT_PROCESSING_TIER_MULTIPLIER : candidate
}
function collapseToFirstPricingTier(pricing: TieredPricingConfig): TieredPricingConfig {
const firstTier = pricing.tiers[0]
if (!firstTier) return pricing
return {
...pricing,
tiers: [{ ...firstTier, up_to: null }],
}
}
/**
* Experimental mode costs use models.dev's flat `Cost` shape. Rejecting a
* context-tier extension here keeps a future/invalid upstream shape from being
* silently interpreted as a different billing contract.
*/
function buildExperimentalModePricing(cost: unknown): TieredPricingConfig | null {
if (!isRecord(cost)) return null
if (cost.tiers !== undefined || cost.context_over_200k !== undefined) return null
return buildModelsDevTieredPricing(cost)
}
export function resolveModelsDevTieredPricing(
_providerId: string,
_modelId: string,
@@ -185,8 +223,6 @@ export function resolveModelsDevTieredPricing(
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
@@ -212,7 +248,13 @@ export function resolveModelsDevTieredPricing(
continue
}
const multiplier = uniformPriceMultiplier(standard, modePricing)
const modePricing = buildExperimentalModePricing(rawMode.cost)
if (!modePricing) continue
const multiplierBase = FLAT_MULTIPLIER_PROCESSING_TIERS.has(processingTier)
? collapseToFirstPricingTier(standard)
: standard
const multiplier = uniformPriceMultiplier(multiplierBase, modePricing)
seenProcessingTiers.add(processingTier)
processingTierEntries.push([processingTier, multiplier === null
? { tiers: modePricing.tiers }
+16 -1
View File
@@ -8,6 +8,7 @@ import {
getModelsDevUnsupportedPricingFields,
resolveModelsDevTieredPricing,
type ModelsDevCost,
type ModelsDevTokenCost,
type ModelsDevUnsupportedPricingField,
} from './models-dev-pricing'
import type { TieredPricingConfig } from './endpoints/types'
@@ -46,7 +47,9 @@ export interface ModelsDevModel {
cost?: ModelsDevCost
experimental?: {
modes?: Record<string, {
cost?: ModelsDevCost
// models.dev experimental modes use the flat Cost shape; context tiers
// belong to the parent model cost only.
cost?: ModelsDevTokenCost
provider?: {
body?: Record<string, unknown>
headers?: Record<string, string>
@@ -256,6 +259,18 @@ export async function getModelsDevList(officialOnly: boolean = true): Promise<Mo
return modelsListCache
}
/**
* 清理前后端 models.dev 缓存后重新获取模型目录。
* 编辑模型的价格同步使用此入口,避免只命中浏览器或网关的旧缓存。
*/
export async function refreshModelsDevList(
officialOnly: boolean = true,
): Promise<ModelsDevModelItem[]> {
await api.delete('/api/admin/models/external/cache')
clearModelsDevCache()
return getModelsDevList(officialOnly)
}
/**
* 搜索模型
* 搜索时包含所有提供商(包括第三方)
File diff suppressed because it is too large Load Diff
@@ -98,7 +98,7 @@
<div class="space-y-1">
<Label class="text-xs font-medium">层级倍率相对 Standard</Label>
<p class="text-xs text-muted-foreground">
该层级按 Standard 的完整价格目录统一缩放
该层级按 Standard 基准价格统一缩放
</p>
</div>
<Button
@@ -14,6 +14,7 @@ import GlobalModelFormDialog from '../GlobalModelFormDialog.vue'
const modelsDevMocks = vi.hoisted(() => ({
getModelsDevList: vi.fn(),
refreshModelsDevList: vi.fn(),
}))
const globalModelMocks = vi.hoisted(() => ({
@@ -24,6 +25,7 @@ const globalModelMocks = vi.hoisted(() => ({
vi.mock('@/api/models-dev', () => ({
getModelsDevList: modelsDevMocks.getModelsDevList,
refreshModelsDevList: modelsDevMocks.refreshModelsDevList,
getProviderLogoUrl: (providerId: string) => `/logos/${providerId}.svg`,
}))
@@ -62,6 +64,27 @@ const stalePreset: ModelsDevModelItem = {
},
}
const alternateStalePreset: ModelsDevModelItem = {
...stalePreset,
providerId: 'azure-openai',
providerName: 'Azure OpenAI',
inputPrice: 7,
outputPrice: 8,
tieredPricing: {
tiers: [{
up_to: null,
input_price_per_1m: 7,
output_price_per_1m: 8,
}],
},
}
const unavailableStalePreset: ModelsDevModelItem = {
...stalePreset,
pricingUnsupportedFields: ['reasoning'],
tieredPricing: undefined,
}
const freshPreset: ModelsDevModelItem = {
providerId: 'openai',
providerName: 'OpenAI',
@@ -125,18 +148,26 @@ function mountDialog() {
const root = document.createElement('div')
document.body.appendChild(root)
const open = ref(false)
const editingModel = ref<GlobalModelResponse | null>(null)
const editModel = vi.fn((model: GlobalModelResponse) => {
editingModel.value = model
})
const pricingSynced = vi.fn()
const app = createApp(defineComponent({
setup() {
return () => h(GlobalModelFormDialog, {
open: open.value,
model: null,
model: editingModel.value,
onEditModel: editModel,
onPricingSynced: pricingSynced,
'onUpdate:open': (value: boolean) => { open.value = value },
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
open.value = true
return { root, open }
return { root, open, editingModel, editModel, pricingSynced }
}
async function settle() {
@@ -164,6 +195,24 @@ function findExactButton(text: string): HTMLButtonElement {
return button
}
function findExistingEditButton(modelId: string): HTMLButtonElement {
const button = document.body.querySelector(
`[data-testid="edit-existing-model-${modelId}"]`,
)
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`Missing existing-model edit button: ${modelId}`)
}
return button
}
function findBillingTab(value: string): HTMLButtonElement {
const button = document.body.querySelector(`button[data-value="${value}"]`)
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`Missing billing tab: ${value}`)
}
return button
}
async function setInput(input: HTMLInputElement | null, value: string) {
if (!input) throw new Error('Missing input')
input.value = value
@@ -175,6 +224,8 @@ beforeEach(() => {
localStorage.clear()
modelsDevMocks.getModelsDevList.mockReset()
modelsDevMocks.getModelsDevList.mockResolvedValue([stalePreset, freshPreset, unsupportedPreset])
modelsDevMocks.refreshModelsDevList.mockReset()
modelsDevMocks.refreshModelsDevList.mockResolvedValue([stalePreset, freshPreset, unsupportedPreset])
globalModelMocks.createGlobalModel.mockReset()
globalModelMocks.createGlobalModel.mockResolvedValue({ id: 'created-model' })
globalModelMocks.listGlobalModels.mockReset()
@@ -200,6 +251,7 @@ describe('GlobalModelFormDialog preset replacement', () => {
mountDialog()
await settle()
expect(document.body.textContent).not.toContain('fresh-family')
findButton('Stale Model').click()
await settle()
@@ -325,62 +377,45 @@ describe('GlobalModelFormDialog preset replacement', () => {
expect(payload.default_tiered_pricing.processing_tiers).not.toHaveProperty('standard')
})
it('marks an existing model and updates only its online pricing after confirmation', async () => {
it('routes an already-added online model from its card action without syncing prices', async () => {
const existingStaleModel = buildExistingStaleModel()
localStorage.setItem('aether:models-dev-pricing-sources:v1', JSON.stringify({
version: 1,
models: {
[existingStaleModel.id]: {
provider_id: 'openai',
provider_name: 'OpenAI',
},
},
}))
globalModelMocks.listGlobalModels.mockResolvedValue({
models: [existingStaleModel],
total: 1,
})
mountDialog()
const { editModel } = mountDialog()
await settle()
expect(document.body.textContent).toContain('已添加')
expect(document.body.textContent).toContain('价格可更新')
expect(document.body.textContent).toContain('上次来源')
const editButton = findExistingEditButton(stalePreset.modelId)
const modelCard = findButton('Stale Model')
expect(modelCard.disabled).toBe(true)
expect(editButton.parentElement?.textContent).toContain('已添加')
expect(editButton.title).toBe('去编辑')
expect(editButton.getAttribute('aria-label')).toBe('编辑 Stale Model')
expect(document.body.textContent).not.toContain('价格可更新')
expect(document.body.textContent).not.toContain('上次来源')
expect(document.body.textContent).not.toContain('同步在线价格')
expect(document.body.textContent).not.toContain('保留当前价格')
expect(document.body.textContent).not.toContain('使用在线价格')
expect(document.body.querySelector('[data-testid="tier-input-price"]')).toBeNull()
findButton('Stale Model').click()
modelCard.click()
await settle()
expect(editModel).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('创建统一模型')
editButton.click()
await settle()
expect(document.body.textContent).toContain('仅更新该模型的价格配置')
expect(document.body.querySelector<HTMLInputElement>('[data-testid="tier-input-price"]')?.value).toBe('9')
expect(document.body.querySelector('[aria-label="选择已有模型时自动应用在线价格"]')).toBeNull()
expect(findExactButton('请选择在线价格').disabled).toBe(true)
findButton('使用在线价格').click()
await settle()
expect(document.body.querySelector<HTMLInputElement>('[data-testid="tier-input-price"]')?.value).toBe('1')
findExactButton('同步价格').click()
await settle()
expect(globalModelMocks.updateGlobalModel).toHaveBeenCalledOnce()
expect(globalModelMocks.updateGlobalModel).toHaveBeenCalledWith(
existingStaleModel.id,
{ default_tiered_pricing: stalePreset.tieredPricing },
)
expect(JSON.parse(
localStorage.getItem('aether:models-dev-pricing-sources:v1') || 'null',
)).toMatchObject({
models: {
[existingStaleModel.id]: {
provider_id: 'openai',
provider_name: 'OpenAI',
},
},
})
expect(editModel).toHaveBeenCalledOnce()
expect(editModel).toHaveBeenCalledWith(existingStaleModel)
expect(document.body.textContent).toContain('编辑模型')
expect(findExactButton('保存').disabled).toBe(false)
expect(globalModelMocks.createGlobalModel).not.toHaveBeenCalled()
expect(globalModelMocks.updateGlobalModel).not.toHaveBeenCalled()
})
it('blocks manual updates when the online source has unsupported pricing dimensions', async () => {
it('routes an existing model to edit even when its online pricing is unsupported', async () => {
const existingModel = {
...buildExistingStaleModel(),
id: 'reasoning-priced-global-model',
@@ -391,15 +426,209 @@ describe('GlobalModelFormDialog preset replacement', () => {
models: [existingModel],
total: 1,
})
const { editModel } = mountDialog()
await settle()
findExistingEditButton(unsupportedPreset.modelId).click()
await settle()
expect(document.body.textContent).not.toContain('计价不兼容')
expect(document.body.textContent).not.toContain('同步在线价格')
expect(editModel).toHaveBeenCalledWith(existingModel)
expect(document.body.textContent).toContain('编辑模型')
expect(globalModelMocks.createGlobalModel).not.toHaveBeenCalled()
expect(globalModelMocks.updateGlobalModel).not.toHaveBeenCalled()
})
it('opens model editing on Token while preserving alternate billing data', async () => {
const existingModel = {
...buildExistingStaleModel(),
default_price_per_request: 0.25,
supported_capabilities: ['image_generation'],
config: {
streaming: true,
billing: {
video: {
price_per_second_by_resolution: { '720p': 0.1 },
},
},
},
}
globalModelMocks.listGlobalModels.mockResolvedValue({
models: [existingModel],
total: 1,
})
mountDialog()
await settle()
expect(document.body.textContent).toContain('计价不兼容')
findButton(unsupportedPreset.modelName).click()
findExistingEditButton(stalePreset.modelId).click()
await settle()
expect(document.body.textContent).toContain('无法独立结算推理 Token')
expect(findExactButton('暂无在线价格').disabled).toBe(true)
expect(findBillingTab('token').dataset.state).toBe('active')
expect(findBillingTab('request').dataset.state).toBe('inactive')
expect(findBillingTab('image').dataset.state).toBe('inactive')
expect(findBillingTab('video').dataset.state).toBe('inactive')
findBillingTab('request').click()
await nextTick()
expect(document.body.querySelector<HTMLInputElement>('input[placeholder="如 0.01"]')?.value)
.toBe('0.25')
})
it('refreshes and applies the latest online price from the edit dialog', async () => {
const existingStaleModel = buildExistingStaleModel()
const syncedModel = {
...existingStaleModel,
default_tiered_pricing: stalePreset.tieredPricing!,
}
globalModelMocks.updateGlobalModel.mockResolvedValue(syncedModel)
globalModelMocks.listGlobalModels.mockResolvedValue({
models: [existingStaleModel],
total: 1,
})
const { editModel, pricingSynced } = mountDialog()
await settle()
findExistingEditButton(stalePreset.modelId).click()
await settle()
const syncButton = document.body.querySelector<HTMLButtonElement>(
'[data-testid="sync-online-pricing"]',
)
if (!syncButton) throw new Error('Missing online pricing sync button')
expect(syncButton.title).toBe('同步最新在线价格')
expect(syncButton.getAttribute('aria-label')).toBe('同步最新在线价格')
syncButton.click()
await settle()
expect(modelsDevMocks.refreshModelsDevList).toHaveBeenCalledOnce()
expect(modelsDevMocks.refreshModelsDevList).toHaveBeenCalledWith(false)
expect(globalModelMocks.updateGlobalModel).toHaveBeenCalledWith(
existingStaleModel.id,
{ default_tiered_pricing: stalePreset.tieredPricing },
)
expect(pricingSynced).toHaveBeenCalledWith(syncedModel)
expect(document.body.querySelector<HTMLInputElement>('[data-testid="tier-input-price"]')?.value)
.toBe('1')
expect(document.body.textContent).toContain('编辑模型')
expect(editModel).toHaveBeenCalledWith(existingStaleModel)
expect(JSON.parse(
localStorage.getItem('aether:models-dev-pricing-sources:v1') || 'null',
)).toMatchObject({
models: {
[existingStaleModel.id]: {
provider_id: stalePreset.providerId,
provider_name: stalePreset.providerName,
},
},
})
})
it('offers a provider choice when the remembered source is unavailable', async () => {
const existingStaleModel = buildExistingStaleModel()
const syncedModel = {
...existingStaleModel,
default_tiered_pricing: alternateStalePreset.tieredPricing!,
}
modelsDevMocks.refreshModelsDevList.mockResolvedValue([
unavailableStalePreset,
alternateStalePreset,
])
localStorage.setItem('aether:models-dev-pricing-sources:v1', JSON.stringify({
version: 1,
models: {
[existingStaleModel.id]: {
provider_id: unavailableStalePreset.providerId,
provider_name: unavailableStalePreset.providerName,
},
},
}))
globalModelMocks.updateGlobalModel.mockResolvedValue(syncedModel)
const { editingModel, pricingSynced } = mountDialog()
await settle()
// Open the editor directly so there is no transient provider preference.
editingModel.value = existingStaleModel
await settle()
const syncButton = document.body.querySelector<HTMLButtonElement>(
'[data-testid="sync-online-pricing"]',
)
if (!syncButton) throw new Error('Missing online pricing sync button')
syncButton.click()
await settle()
expect(globalModelMocks.updateGlobalModel).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('选择在线价格来源')
expect(document.body.textContent).toContain('OpenAI')
expect(document.body.textContent).toContain('Azure OpenAI')
const alternateSource = document.body.querySelector<HTMLButtonElement>(
'[data-testid="online-pricing-source-azure-openai"]',
)
if (!alternateSource) throw new Error('Missing alternate pricing source')
expect(document.body.querySelector<HTMLButtonElement>(
'[data-testid="online-pricing-source-openai"]',
)?.disabled).toBe(true)
expect(document.activeElement).toBe(alternateSource)
alternateSource.click()
await nextTick()
findExactButton('同步此来源').click()
await settle()
expect(globalModelMocks.updateGlobalModel).toHaveBeenCalledWith(
existingStaleModel.id,
{ default_tiered_pricing: alternateStalePreset.tieredPricing },
)
expect(pricingSynced).toHaveBeenCalledWith(syncedModel)
expect(document.body.textContent).not.toContain('选择在线价格来源')
expect(JSON.parse(
localStorage.getItem('aether:models-dev-pricing-sources:v1') || 'null',
)).toMatchObject({
models: {
[existingStaleModel.id]: {
provider_id: alternateStalePreset.providerId,
provider_name: alternateStalePreset.providerName,
},
},
})
})
it('opens the compact source popover for multiple usable providers without a remembered source', async () => {
const existingStaleModel = buildExistingStaleModel()
modelsDevMocks.refreshModelsDevList.mockResolvedValue([
stalePreset,
alternateStalePreset,
])
globalModelMocks.listGlobalModels.mockResolvedValue({
models: [existingStaleModel],
total: 1,
})
const { editingModel } = mountDialog()
await settle()
editingModel.value = existingStaleModel
await settle()
const syncButton = document.body.querySelector<HTMLButtonElement>(
'[data-testid="sync-online-pricing"]',
)
if (!syncButton) throw new Error('Missing online pricing sync button')
syncButton.click()
await settle()
expect(document.body.querySelector('[data-testid="online-pricing-source-openai"]')).not.toBeNull()
expect(document.body.querySelector('[data-testid="online-pricing-source-azure-openai"]')).not.toBeNull()
expect(document.body.querySelectorAll('.fixed.inset-0.overflow-hidden.pointer-events-none')).toHaveLength(1)
expect(globalModelMocks.updateGlobalModel).not.toHaveBeenCalled()
const cancelButton = document.body.querySelector<HTMLButtonElement>(
'[data-testid="online-pricing-source-cancel"]',
)
if (!cancelButton) throw new Error('Missing source popover cancel button')
cancelButton.click()
await settle()
expect(document.body.querySelector('[data-testid="online-pricing-source-openai"]')).toBeNull()
expect(globalModelMocks.updateGlobalModel).not.toHaveBeenCalled()
})
})
@@ -311,6 +311,8 @@
:model="editingModel"
@update:open="handleModelDialogUpdate"
@success="handleModelFormSuccess"
@edit-model="editModel"
@pricing-synced="handleModelPricingSynced"
/>
<!-- 模型详情抽屉 -->
@@ -1823,6 +1825,23 @@ async function editModel(model: GlobalModelResponse) {
createModelDialogOpen.value = true
}
function handleModelPricingSynced(model: GlobalModelResponse) {
const updatePricing = (models: GlobalModelResponse[]) => {
const current = models.find(entry => entry.id === model.id)
if (current) {
current.default_tiered_pricing = cloneTieredPricingConfig(model.default_tiered_pricing)
}
}
updatePricing(globalModels.value)
updatePricing(batchManageModels.value)
if (editingModel.value?.id === model.id) {
editingModel.value.default_tiered_pricing = cloneTieredPricingConfig(model.default_tiered_pricing)
}
if (selectedModel.value?.id === model.id) {
selectedModel.value.default_tiered_pricing = cloneTieredPricingConfig(model.default_tiered_pricing)
}
}
async function deleteModel(model: GlobalModelResponse) {
const confirmed = await confirmDanger(
`确定删除模型 "${model.name}" 吗?\n\n此操作不可撤销。`,