mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user