mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 14:10:19 +08:00
fix(models): correct fast pricing and online sync
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
+278
-49
@@ -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此操作不可撤销。`,
|
||||
|
||||
Reference in New Issue
Block a user