Skip to content

Commit 6faa2b3

Browse files
committed
fix(pricing): keep route provenance through cost aggregation
Price runtime aggregates at each turn's recorded timestamp and route all scorecard estimates through the same provider-aware path. Fail closed for costless catalog offerings, retain only explicit provider-owned hand-price fallbacks such as OpenAI API gpt-5-codex, and derive cache savings from the exact provider route. Add regressions for historical Sonnet pricing, absent catalog hand rows, foreign costless routes, and scorecard parity.
1 parent 9264067 commit 6faa2b3

5 files changed

Lines changed: 307 additions & 219 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
effective route and a non-secret billing surface, runtime exports and
1414
supported aliases ingest cleanly, legacy/unknown routes remain explicitly
1515
unpriced, and route-scoped cache and recorded-time pricing replace model-only
16-
guesses. StepFun PAYG and Step Plan usage now stay distinct without persisting
17-
raw endpoint URLs, so subscription quota is never reported as token spend
18-
(#4335). Completion-only shell, manual-compaction, and purge events remain
19-
visible to `turn_end` observers as explicitly non-model lifecycle records.
20-
This builds on the scorecard introduced by @findshan in #3388.
16+
guesses. Historical runtime aggregates use each turn's recorded time;
17+
costless catalog routes fail closed while exact provider-owned hand-price
18+
rows remain available. StepFun PAYG and Step Plan usage now stay distinct
19+
without persisting raw endpoint URLs, so subscription quota is never reported
20+
as token spend (#4335). Completion-only shell, manual-compaction, and purge
21+
events remain visible to `turn_end` observers as explicitly non-model
22+
lifecycle records. This builds on the scorecard introduced by @findshan in
23+
#3388.
2124

2225
## [0.8.68] - 2026-07-12
2326

crates/tui/src/pricing.rs

Lines changed: 209 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66
//! reliable balance endpoint exists.
77
88
use chrono::{DateTime, TimeZone, Utc};
9-
use codewhale_config::pricing::{OfferingPricing, TokenUsage};
9+
use codewhale_config::pricing::{Currency, OfferingPricing, TokenUsage};
1010

1111
use crate::config::{
1212
ApiProvider, DEEPSEEK_ALIAS_REPLACEMENT, DEEPSEEK_ALIAS_RETIREMENT_UTC,
13-
DEFAULT_STEPFUN_BASE_URL, DEFAULT_STEPFUN_MODEL,
13+
DEFAULT_STEPFUN_BASE_URL, DEFAULT_STEPFUN_MODEL, canonical_model_id_for_provider,
1414
};
1515
use crate::models::Usage;
1616

@@ -200,9 +200,7 @@ pub fn has_pricing_for_model(model: &str) -> bool {
200200
/// subscription/account scoped, while StepFun needs PAYG-vs-Plan provenance.
201201
#[must_use]
202202
pub fn has_pricing_for_provider(provider: ApiProvider, model: &str) -> bool {
203-
provider != ApiProvider::OpenaiCodex
204-
&& !route_requires_billing_surface(provider, model)
205-
&& has_pricing_for_model(model)
203+
calculate_turn_cost_estimate_for_provider(provider, model, &Usage::default()).is_some()
206204
}
207205

208206
fn pricing_for_model_at(model: &str, now: DateTime<Utc>) -> Option<ModelPricing> {
@@ -421,6 +419,7 @@ pub fn calculate_turn_cost_from_usage(model: &str, usage: &Usage) -> Option<f64>
421419

422420
/// Calculate cost from provider usage in both official currencies.
423421
#[must_use]
422+
#[cfg(test)]
424423
pub fn calculate_turn_cost_estimate_from_usage(model: &str, usage: &Usage) -> Option<CostEstimate> {
425424
let pricing = pricing_for_model(model)?;
426425
Some(cost_estimate_with_pricing(pricing, usage))
@@ -446,19 +445,14 @@ pub fn calculate_turn_cost_estimate_for_provider(
446445
model: &str,
447446
usage: &Usage,
448447
) -> Option<CostEstimate> {
449-
if provider == ApiProvider::OpenaiCodex || route_requires_billing_surface(provider, model) {
450-
return None;
451-
}
452-
if let Some(estimate) = provider_catalog_cache_write_estimate(provider, model, usage) {
453-
return Some(estimate);
454-
}
455-
calculate_turn_cost_estimate_from_usage(model, usage)
448+
calculate_turn_cost_estimate_for_provider_at(provider, model, usage, Utc::now())
456449
}
457450

458451
/// Estimate a turn when endpoint-derived billing provenance is available.
459452
/// StepFun's standard API and Step Plan share provider/model text but not a
460453
/// billing system, so that route fails closed unless the PAYG surface is known.
461454
#[must_use]
455+
#[cfg(test)]
462456
pub(crate) fn calculate_turn_cost_estimate_for_route(
463457
provider: ApiProvider,
464458
model: &str,
@@ -492,6 +486,7 @@ pub(crate) fn calculate_turn_cost_estimate_for_provider_at(
492486
provider,
493487
ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic
494488
);
489+
let canonical_model = canonical_model_id_for_provider(provider, normalized_model)?;
495490
let catalog_model = if direct_deepseek
496491
&& matches!(model_lower.as_str(), "deepseek-chat" | "deepseek-reasoner")
497492
{
@@ -501,23 +496,35 @@ pub(crate) fn calculate_turn_cost_estimate_for_provider_at(
501496
if recorded_at >= retirement {
502497
return None;
503498
}
504-
DEEPSEEK_ALIAS_REPLACEMENT
499+
DEEPSEEK_ALIAS_REPLACEMENT.to_string()
505500
} else {
506-
normalized_model
501+
canonical_model
507502
};
508-
let offering = crate::provider_lake::catalog_offering_for_model(provider, catalog_model)?;
509-
// Sonnet 5 has an explicit recorded-time window, including its cache-write
510-
// tier. Other models should retain the provider catalog override added in
511-
// #4318 when their recorded usage contains cache creation tokens.
512-
let time_scoped_sonnet =
513-
provider == ApiProvider::Anthropic && catalog_model.eq_ignore_ascii_case("claude-sonnet-5");
514-
if !time_scoped_sonnet
515-
&& usage.prompt_cache_write_tokens.unwrap_or(0) > 0
516-
&& let Some(estimate) = catalog_cost_estimate_from_offering(&offering, usage)
503+
504+
// Direct DeepSeek pricing carries an authoritative CNY row, and Sonnet 5
505+
// has a recorded-time introductory window that a static catalog row cannot
506+
// represent. These exact first-party routes intentionally override the
507+
// catalog; no other provider/model text match is allowed to do so.
508+
if direct_deepseek
509+
|| (provider == ApiProvider::Anthropic
510+
&& catalog_model.eq_ignore_ascii_case("claude-sonnet-5"))
517511
{
518-
return Some(estimate);
512+
let pricing = provider_owned_hand_pricing_at(provider, &catalog_model, recorded_at)?;
513+
return Some(cost_estimate_with_pricing(pricing, usage));
514+
}
515+
516+
if let Some(offering) =
517+
crate::provider_lake::catalog_offering_for_model(provider, &catalog_model)
518+
&& OfferingPricing::from_catalog_offering(&offering).is_some()
519+
{
520+
return catalog_cost_estimate_for_route(provider, &catalog_model, &offering, usage);
519521
}
520-
let pricing = pricing_for_model_at(catalog_model, recorded_at)?;
522+
523+
// A few first-party rows predate or intentionally omit a Models.dev entry
524+
// (for example OpenAI API `gpt-5-codex`, Arcee `trinity-mini`, and MiniMax
525+
// `minimax-m2.7`). Preserve only an explicit provider-owned allowlist here;
526+
// a costless foreign/catalog route must remain unpriced.
527+
let pricing = provider_owned_hand_pricing_at(provider, &catalog_model, recorded_at)?;
521528
Some(cost_estimate_with_pricing(pricing, usage))
522529
}
523530

@@ -540,33 +547,93 @@ pub(crate) fn calculate_turn_cost_estimate_for_route_at(
540547
calculate_turn_cost_estimate_for_provider_at(provider, model, usage, recorded_at)
541548
}
542549

543-
fn provider_catalog_cache_write_estimate(
550+
fn provider_owned_hand_pricing_at(
544551
provider: ApiProvider,
545552
model: &str,
546-
usage: &Usage,
547-
) -> Option<CostEstimate> {
548-
if usage.prompt_cache_write_tokens.unwrap_or(0) == 0 {
549-
return None;
550-
}
551-
crate::provider_lake::catalog_offering_for_model(provider, model)
552-
.as_ref()
553-
.and_then(|offering| catalog_cost_estimate_from_offering(offering, usage))
553+
recorded_at: DateTime<Utc>,
554+
) -> Option<ModelPricing> {
555+
let model_lower = model.trim().to_ascii_lowercase();
556+
let provider_owns_row = match provider {
557+
ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic => {
558+
matches!(
559+
model_lower.as_str(),
560+
"deepseek-v4-pro" | "deepseek-v4-flash"
561+
)
562+
}
563+
ApiProvider::Openai => matches!(
564+
model_lower.as_str(),
565+
"gpt-5-codex"
566+
| "gpt-5.3-codex"
567+
| "gpt-5.5"
568+
| "gpt-5.5-pro"
569+
| "gpt-5.6"
570+
| "gpt-5.6-sol"
571+
| "gpt-5.6-terra"
572+
| "gpt-5.6-luna"
573+
),
574+
ApiProvider::Anthropic => matches!(
575+
model_lower.as_str(),
576+
"claude-opus-4-8"
577+
| "claude-sonnet-4-6"
578+
| "claude-haiku-4-5"
579+
| "claude-fable-5"
580+
| "claude-sonnet-5"
581+
),
582+
ApiProvider::Zai => matches!(model_lower.as_str(), "glm-5.1" | "glm-5.2" | "glm-5-turbo"),
583+
ApiProvider::Moonshot => {
584+
matches!(model_lower.as_str(), "kimi-k2.6" | "kimi-k2.7-code")
585+
}
586+
ApiProvider::Minimax => {
587+
matches!(model_lower.as_str(), "minimax-m3" | "minimax-m2.7")
588+
}
589+
ApiProvider::Arcee => {
590+
matches!(
591+
model_lower.as_str(),
592+
"trinity-mini" | "trinity-large-thinking"
593+
)
594+
}
595+
ApiProvider::Meta => model_lower == "muse-spark-1.1",
596+
_ => false,
597+
};
598+
provider_owns_row
599+
.then(|| pricing_for_model_at(&model_lower, recorded_at))
600+
.flatten()
554601
}
555602

556-
/// Estimate cache-write usage from a sourced catalog row when it publishes the
557-
/// separate write tier. Other usage continues through the legacy table, which
558-
/// retains CNY estimates and compatibility fallbacks.
559-
fn catalog_cost_estimate_from_offering(
603+
/// Estimate usage only from the exact provider offering. Missing prices for a
604+
/// used token class fail closed, except on the two documented first-party
605+
/// routes where cache tokens are explicitly billed at the input rate.
606+
fn catalog_cost_estimate_for_route(
607+
provider: ApiProvider,
608+
model: &str,
560609
offering: &codewhale_config::catalog::CatalogOffering,
561610
usage: &Usage,
562611
) -> Option<CostEstimate> {
563612
let usage = token_usage_for_pricing(usage);
564-
let pricing = OfferingPricing::from_catalog_offering(offering)?;
565-
if usage.cache_write == 0 || pricing.cache_write_per_million.is_none() {
566-
return None;
613+
let mut pricing = OfferingPricing::from_catalog_offering(offering)?;
614+
let model_lower = model.trim().to_ascii_lowercase();
615+
let cache_uses_input_rate = matches!(
616+
(provider, model_lower.as_str()),
617+
(ApiProvider::Openai, "gpt-5.5-pro") | (ApiProvider::Arcee, "trinity-large-thinking")
618+
);
619+
if cache_uses_input_rate {
620+
if usage.cache_read > 0 && pricing.cache_read_per_million.is_none() {
621+
pricing.cache_read_per_million = pricing.input_per_million;
622+
}
623+
if usage.cache_write > 0 && pricing.cache_write_per_million.is_none() {
624+
pricing.cache_write_per_million = pricing.input_per_million;
625+
}
567626
}
568627

569-
pricing.estimate_cost(&usage).map(CostEstimate::usd_only)
628+
let amount = pricing.estimate_cost(&usage)?;
629+
match pricing.currency {
630+
Currency::Usd => Some(CostEstimate::usd_only(amount)),
631+
Currency::Cny => Some(CostEstimate {
632+
usd: 0.0,
633+
cny: amount,
634+
}),
635+
Currency::Other(_) => None,
636+
}
570637
}
571638

572639
/// Project provider-normalized turn usage into canonical billable token
@@ -615,40 +682,36 @@ fn calculate_turn_cost_from_usage_with_pricing(pricing: CurrencyPricing, usage:
615682
hit_cost + miss_cost + write_cost + output_cost
616683
}
617684

618-
/// Estimate how much money was saved by serving `cache_hit_tokens` from the
619-
/// prefix cache instead of billing them at the cache-miss rate. Returns `None`
620-
/// when the model's pricing is unknown or the number of cache-hit tokens is
621-
/// zero (nothing to save).
622-
#[must_use]
623-
pub fn calculate_cache_savings(model: &str, cache_hit_tokens: u32) -> Option<CostEstimate> {
624-
if cache_hit_tokens == 0 {
625-
return None;
626-
}
627-
let pricing = pricing_for_model(model)?;
628-
let tokens = cache_hit_tokens as f64 / 1_000_000.0;
629-
Some(CostEstimate {
630-
usd: tokens
631-
* (pricing.usd.input_cache_miss_per_million - pricing.usd.input_cache_hit_per_million),
632-
cny: pricing
633-
.cny
634-
.map(|pricing| {
635-
tokens
636-
* (pricing.input_cache_miss_per_million - pricing.input_cache_hit_per_million)
637-
})
638-
.unwrap_or(0.0),
639-
})
640-
}
641-
685+
/// Estimate cache savings from the exact provider route by comparing the same
686+
/// tokens as cache hits and ordinary input. Unknown or costless routes remain
687+
/// unavailable instead of inheriting a model-only rate.
642688
#[must_use]
643689
pub fn calculate_cache_savings_for_provider(
644690
provider: ApiProvider,
645691
model: &str,
646692
cache_hit_tokens: u32,
647693
) -> Option<CostEstimate> {
648-
if provider == ApiProvider::OpenaiCodex || route_requires_billing_surface(provider, model) {
694+
if cache_hit_tokens == 0 {
649695
return None;
650696
}
651-
calculate_cache_savings(model, cache_hit_tokens)
697+
let cached = Usage {
698+
input_tokens: cache_hit_tokens,
699+
prompt_cache_hit_tokens: Some(cache_hit_tokens),
700+
prompt_cache_miss_tokens: Some(0),
701+
..Usage::default()
702+
};
703+
let uncached = Usage {
704+
input_tokens: cache_hit_tokens,
705+
prompt_cache_hit_tokens: Some(0),
706+
prompt_cache_miss_tokens: Some(cache_hit_tokens),
707+
..Usage::default()
708+
};
709+
let cached = calculate_turn_cost_estimate_for_provider(provider, model, &cached)?;
710+
let uncached = calculate_turn_cost_estimate_for_provider(provider, model, &uncached)?;
711+
Some(CostEstimate {
712+
usd: uncached.usd - cached.usd,
713+
cny: uncached.cny - cached.cny,
714+
})
652715
}
653716

654717
/// Format a cost amount for compact display in the chosen currency.
@@ -1003,8 +1066,13 @@ mod tests {
10031066
..Default::default()
10041067
};
10051068

1006-
let estimate =
1007-
catalog_cost_estimate_from_offering(&offering, &usage).expect("catalog cost estimate");
1069+
let estimate = catalog_cost_estimate_for_route(
1070+
ApiProvider::Anthropic,
1071+
"catalog-priced-model",
1072+
&offering,
1073+
&usage,
1074+
)
1075+
.expect("catalog cost estimate");
10081076
assert!((estimate.usd - 0.000_382).abs() < 1e-15);
10091077
assert_eq!(estimate.cny, 0.0);
10101078
}
@@ -1051,6 +1119,77 @@ mod tests {
10511119
);
10521120
}
10531121

1122+
#[test]
1123+
fn provider_cost_keeps_owned_hand_price_without_catalog_offering() {
1124+
let usage = Usage {
1125+
input_tokens: 1_000_000,
1126+
output_tokens: 0,
1127+
..Default::default()
1128+
};
1129+
assert!(
1130+
crate::provider_lake::catalog_offering_for_model(ApiProvider::Openai, "gpt-5-codex")
1131+
.is_none(),
1132+
"regression fixture must exercise the hand-price fallback"
1133+
);
1134+
1135+
let estimate = calculate_turn_cost_estimate_for_provider_at(
1136+
ApiProvider::Openai,
1137+
"gpt-5-codex",
1138+
&usage,
1139+
Utc::now(),
1140+
)
1141+
.expect("OpenAI API owns the hand-priced model");
1142+
1143+
assert!((estimate.usd - 1.25).abs() < f64::EPSILON);
1144+
assert_eq!(estimate.cny, 0.0);
1145+
assert!(has_pricing_for_provider(ApiProvider::Openai, "gpt-5-codex"));
1146+
}
1147+
1148+
#[test]
1149+
fn provider_cost_does_not_fabricate_price_for_costless_catalog_route() {
1150+
let offering = crate::provider_lake::catalog_offering_for_model(
1151+
ApiProvider::Openai,
1152+
"deepseek-v4-pro",
1153+
)
1154+
.expect("bundled OpenAI-compatible route");
1155+
assert!(OfferingPricing::from_catalog_offering(&offering).is_none());
1156+
let usage = Usage {
1157+
input_tokens: 1_000_000,
1158+
output_tokens: 0,
1159+
..Default::default()
1160+
};
1161+
1162+
assert!(
1163+
calculate_turn_cost_estimate_for_provider_at(
1164+
ApiProvider::Openai,
1165+
"deepseek-v4-pro",
1166+
&usage,
1167+
Utc::now(),
1168+
)
1169+
.is_none()
1170+
);
1171+
assert!(
1172+
calculate_turn_cost_estimate_for_provider(
1173+
ApiProvider::Openai,
1174+
"deepseek-v4-pro",
1175+
&usage,
1176+
)
1177+
.is_none()
1178+
);
1179+
assert!(!has_pricing_for_provider(
1180+
ApiProvider::Openai,
1181+
"deepseek-v4-pro"
1182+
));
1183+
assert!(
1184+
calculate_cache_savings_for_provider(
1185+
ApiProvider::Openai,
1186+
"deepseek-v4-pro",
1187+
1_000_000,
1188+
)
1189+
.is_none()
1190+
);
1191+
}
1192+
10541193
#[test]
10551194
fn recorded_time_provider_cost_bounds_deepseek_compatibility_aliases() {
10561195
let usage = Usage {

0 commit comments

Comments
 (0)