fix: 修正时间戳精度、TTL 定价匹配逻辑及账单快照展示

- 将 created_at_unix_ms 从 current_unix_secs 改为 current_unix_ms,修正候选尝试和跳过记录的时间戳精度
- formula_engine: TTL 定价从「<=上限」模糊匹配改为精确匹配,null 值改为回退到基础价格而非透传
- 新增 ttl_pricing_requires_exact_match 和 ttl_pricing_null_value_falls_back_to_base_tier_value 测试用例
- service: 新增 5min/1h cache TTL 的端到端计费验证测试
- RequestDetailDrawer: 优先从 billing_snapshot 读取已解析的价格和费用,正确展示当前 TTL 对应的缓存创建/读取价格,修正输入/输出/缓存费用列的数据来源
This commit is contained in:
fawney19
2026-04-10 18:50:59 +08:00
parent 010ab127e2
commit d5b8583d6b
11 changed files with 542 additions and 76 deletions

View File

@@ -380,28 +380,21 @@ fn resolve_tiered(
if let (Some(cache_ttl_minutes), Some(ttl_value_key)) = (cache_ttl_minutes, ttl_value_key) {
if let Some(ttl_pricing) = tier.get("cache_ttl_pricing").and_then(|v| v.as_array()) {
for ttl_entry in ttl_pricing {
let ttl_limit = ttl_entry
if let Some(ttl_entry) = ttl_pricing.iter().find(|entry| {
entry
.get("ttl_minutes")
.and_then(as_f64)
.unwrap_or_default();
if cache_ttl_minutes <= ttl_limit {
if let Some(value) = ttl_entry.get(ttl_value_key) {
return Ok((
value.clone(),
false,
Some((matched_index.unwrap_or(0), tier)),
));
}
.map(|value| value == cache_ttl_minutes)
.unwrap_or(false)
}) {
if let Some(value) = ttl_entry.get(ttl_value_key).filter(|value| !value.is_null()) {
return Ok((
value.clone(),
false,
Some((matched_index.unwrap_or(0), tier)),
));
}
}
if let Some(value) = ttl_pricing.last().and_then(|v| v.get(ttl_value_key)) {
return Ok((
value.clone(),
false,
Some((matched_index.unwrap_or(0), tier)),
));
}
}
}
@@ -857,4 +850,82 @@ mod tests {
assert_eq!(result.status, FormulaEvaluationStatus::Incomplete);
assert_eq!(result.missing_required, vec!["input_tokens".to_string()]);
}
#[test]
fn ttl_pricing_requires_exact_match() {
let engine = FormulaEngine::new();
let dimensions = BTreeMap::from([
("total_input_context".to_string(), serde_json::json!(22_562)),
("cache_ttl_minutes".to_string(), serde_json::json!(5)),
]);
let mappings = BTreeMap::from([(
"cache_creation_price_per_1m".to_string(),
serde_json::json!({
"source": "tiered",
"tier_key": "total_input_context",
"ttl_key": "cache_ttl_minutes",
"ttl_value_key": "cache_creation_price_per_1m",
"tiers": [{
"up_to": null,
"value": 3.125,
"cache_ttl_pricing": [{
"ttl_minutes": 60,
"cache_creation_price_per_1m": 5.0
}]
}],
"default": 0.0
}),
)]);
let result = engine
.evaluate(
"cache_creation_price_per_1m",
None,
Some(&dimensions),
Some(&mappings),
false,
)
.expect("tiered mapping should evaluate");
assert_eq!(result.cost, 3.125);
}
#[test]
fn ttl_pricing_null_value_falls_back_to_base_tier_value() {
let engine = FormulaEngine::new();
let dimensions = BTreeMap::from([
("total_input_context".to_string(), serde_json::json!(22_562)),
("cache_ttl_minutes".to_string(), serde_json::json!(60)),
]);
let mappings = BTreeMap::from([(
"cache_read_price_per_1m".to_string(),
serde_json::json!({
"source": "tiered",
"tier_key": "total_input_context",
"ttl_key": "cache_ttl_minutes",
"ttl_value_key": "cache_read_price_per_1m",
"tiers": [{
"up_to": null,
"value": 0.25,
"cache_ttl_pricing": [{
"ttl_minutes": 60,
"cache_read_price_per_1m": null
}]
}],
"default": 0.0
}),
)]);
let result = engine
.evaluate(
"cache_read_price_per_1m",
None,
Some(&dimensions),
Some(&mappings),
false,
)
.expect("tiered mapping should evaluate");
assert_eq!(result.cost, 0.25);
}
}

View File

@@ -264,4 +264,142 @@ mod tests {
assert!(result.actual_total_cost > 0.0);
assert_eq!(result.rate_multiplier, 0.5);
}
#[test]
fn five_minute_cache_ttl_uses_base_cache_prices() {
let pricing = BillingModelPricingSnapshot {
provider_id: "provider-1".to_string(),
provider_billing_type: Some("pay_as_you_go".to_string()),
provider_api_key_id: Some("key-1".to_string()),
provider_api_key_rate_multipliers: None,
provider_api_key_cache_ttl_minutes: Some(5),
global_model_id: "global-model-1".to_string(),
global_model_name: "gpt-5.4".to_string(),
global_model_config: None,
default_price_per_request: None,
default_tiered_pricing: Some(json!({
"tiers": [{
"up_to": null,
"input_price_per_1m": 2.5,
"output_price_per_1m": 15.0,
"cache_creation_price_per_1m": 3.125,
"cache_read_price_per_1m": 0.25,
"cache_ttl_pricing": [{
"ttl_minutes": 60,
"cache_creation_price_per_1m": 5.0,
"cache_read_price_per_1m": null
}]
}]
})),
model_id: None,
model_provider_model_name: None,
model_config: None,
model_price_per_request: None,
model_tiered_pricing: None,
};
let result = BillingService::new()
.calculate(
&pricing,
&BillingUsageInput {
task_type: "chat".to_string(),
api_format: None,
request_count: 1,
input_tokens: 1_000,
output_tokens: 10,
cache_creation_tokens: 0,
cache_creation_ephemeral_5m_tokens: 0,
cache_creation_ephemeral_1h_tokens: 0,
cache_read_tokens: 100,
cache_ttl_minutes: Some(5),
},
)
.expect("billing should calculate");
assert_eq!(
result
.cost_result
.snapshot
.resolved_variables
.get("cache_creation_price_per_1m"),
Some(&json!(3.125))
);
assert_eq!(
result
.cost_result
.snapshot
.resolved_variables
.get("cache_read_price_per_1m"),
Some(&json!(0.25))
);
}
#[test]
fn one_hour_cache_ttl_keeps_base_cache_read_when_ttl_entry_omits_it() {
let pricing = BillingModelPricingSnapshot {
provider_id: "provider-1".to_string(),
provider_billing_type: Some("pay_as_you_go".to_string()),
provider_api_key_id: Some("key-1".to_string()),
provider_api_key_rate_multipliers: None,
provider_api_key_cache_ttl_minutes: Some(60),
global_model_id: "global-model-1".to_string(),
global_model_name: "gpt-5.4".to_string(),
global_model_config: None,
default_price_per_request: None,
default_tiered_pricing: Some(json!({
"tiers": [{
"up_to": null,
"input_price_per_1m": 2.5,
"output_price_per_1m": 15.0,
"cache_creation_price_per_1m": 3.125,
"cache_read_price_per_1m": 0.25,
"cache_ttl_pricing": [{
"ttl_minutes": 60,
"cache_creation_price_per_1m": 5.0,
"cache_read_price_per_1m": null
}]
}]
})),
model_id: None,
model_provider_model_name: None,
model_config: None,
model_price_per_request: None,
model_tiered_pricing: None,
};
let result = BillingService::new()
.calculate(
&pricing,
&BillingUsageInput {
task_type: "chat".to_string(),
api_format: None,
request_count: 1,
input_tokens: 1_000,
output_tokens: 10,
cache_creation_tokens: 0,
cache_creation_ephemeral_5m_tokens: 0,
cache_creation_ephemeral_1h_tokens: 0,
cache_read_tokens: 100,
cache_ttl_minutes: Some(60),
},
)
.expect("billing should calculate");
assert_eq!(
result
.cost_result
.snapshot
.resolved_variables
.get("cache_creation_price_per_1m"),
Some(&json!(5.0))
);
assert_eq!(
result
.cost_result
.snapshot
.resolved_variables
.get("cache_read_price_per_1m"),
Some(&json!(0.25))
);
}
}