Skip to content

Commit 67d84e2

Browse files
committed
fix(pricing): preserve route billing edge cases
Classify StepFun PAYG and Step Plan routes before presenting usage, retain unknown billing for child routes without endpoint provenance, and allow the documented GPT-5.5 cache-write fallback without filling unrelated catalog gaps. Signed-off-by: Nightt <87569709+nightt5879@users.noreply.github.com>
1 parent f798f3f commit 67d84e2

2 files changed

Lines changed: 144 additions & 3 deletions

File tree

crates/tui/src/pricing.rs

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,17 @@ pub fn has_pricing_for_provider(provider: ApiProvider, model: &str) -> bool {
203203
calculate_turn_cost_estimate_for_provider(provider, model, &Usage::default()).is_some()
204204
}
205205

206+
/// Return whether a provider/model route has authoritative pricing for an
207+
/// already-classified billing surface.
208+
#[must_use]
209+
pub(crate) fn has_pricing_for_billing_surface(
210+
provider: ApiProvider,
211+
model: &str,
212+
billing_surface: Option<&str>,
213+
) -> bool {
214+
pricing_for_billing_surface(provider, model, billing_surface).is_some()
215+
}
216+
206217
fn pricing_for_model_at(model: &str, now: DateTime<Utc>) -> Option<ModelPricing> {
207218
let lower = model.to_lowercase();
208219
if lower.starts_with("deepseek-ai/") {
@@ -561,7 +572,16 @@ pub(crate) fn calculate_turn_cost_estimate_for_provider_at(
561572
crate::provider_lake::catalog_offering_for_model(provider, &catalog_model)
562573
&& OfferingPricing::from_catalog_offering(&offering).is_some()
563574
{
564-
return catalog_cost_estimate_for_route(provider, &catalog_model, &offering, usage);
575+
if let Some(estimate) =
576+
catalog_cost_estimate_for_route(provider, &catalog_model, &offering, usage)
577+
{
578+
return Some(estimate);
579+
}
580+
if catalog_gap_uses_documented_hand_price(provider, &catalog_model, &offering, usage) {
581+
let pricing = provider_owned_hand_pricing_at(provider, &catalog_model, recorded_at)?;
582+
return Some(cost_estimate_with_pricing(pricing, usage));
583+
}
584+
return None;
565585
}
566586

567587
// A few first-party rows predate or intentionally omit a Models.dev entry
@@ -644,6 +664,30 @@ fn provider_owned_hand_pricing_at(
644664
.flatten()
645665
}
646666

667+
/// Whether a failed catalog estimate is missing only a class whose billing is
668+
/// explicitly documented by the provider-owned row. Keep this narrow: a hand
669+
/// row must not fill unrelated catalog gaps (for example an unpublished cache
670+
/// read rate) merely because the model name is known locally.
671+
fn catalog_gap_uses_documented_hand_price(
672+
provider: ApiProvider,
673+
model: &str,
674+
offering: &codewhale_config::catalog::CatalogOffering,
675+
usage: &Usage,
676+
) -> bool {
677+
if provider != ApiProvider::Openai || !model.eq_ignore_ascii_case("gpt-5.5") {
678+
return false;
679+
}
680+
let Some(pricing) = OfferingPricing::from_catalog_offering(offering) else {
681+
return false;
682+
};
683+
let usage = token_usage_for_pricing(usage);
684+
usage.cache_write > 0
685+
&& pricing.cache_write_per_million.is_none()
686+
&& (usage.input == 0 || pricing.input_per_million.is_some())
687+
&& (usage.output == 0 || pricing.output_per_million.is_some())
688+
&& (usage.cache_read == 0 || pricing.cache_read_per_million.is_some())
689+
}
690+
647691
/// Estimate usage only from the exact provider offering. Missing prices for a
648692
/// used token class fail closed, except on the two documented first-party
649693
/// routes where cache tokens are explicitly billed at the input rate.
@@ -1267,6 +1311,34 @@ mod tests {
12671311
assert!(has_pricing_for_provider(ApiProvider::Openai, "gpt-5-codex"));
12681312
}
12691313

1314+
#[test]
1315+
fn provider_hand_price_fills_catalog_missing_used_class() {
1316+
let offering =
1317+
crate::provider_lake::catalog_offering_for_model(ApiProvider::Openai, "gpt-5.5")
1318+
.expect("bundled OpenAI route");
1319+
let catalog_pricing =
1320+
OfferingPricing::from_catalog_offering(&offering).expect("catalog pricing");
1321+
assert!(catalog_pricing.cache_write_per_million.is_none());
1322+
let usage = Usage {
1323+
input_tokens: 1_000_000,
1324+
output_tokens: 0,
1325+
prompt_cache_miss_tokens: Some(0),
1326+
prompt_cache_write_tokens: Some(1_000_000),
1327+
..Default::default()
1328+
};
1329+
1330+
let estimate = calculate_turn_cost_estimate_for_provider_at(
1331+
ApiProvider::Openai,
1332+
"gpt-5.5",
1333+
&usage,
1334+
Utc::now(),
1335+
)
1336+
.expect("provider hand price supplies the missing cache-write class");
1337+
1338+
assert!((estimate.usd - 5.0).abs() < f64::EPSILON);
1339+
assert_eq!(estimate.cny, 0.0);
1340+
}
1341+
12701342
#[test]
12711343
fn provider_cost_does_not_fabricate_price_for_costless_catalog_route() {
12721344
let offering = crate::provider_lake::catalog_offering_for_model(

crates/tui/src/route_billing.rs

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ pub fn for_route(config: &Config, provider: ApiProvider) -> BillingPresentation
7979

8080
let provider_config = config.provider_config_for(provider);
8181
match provider {
82+
ApiProvider::Stepfun => stepfun_billing(provider_config),
8283
// Z.ai's dedicated Coding endpoint is the GLM Coding Plan route. Its
8384
// quota is subscription-backed, so a public API price estimate is not
8485
// truthful spend and must not appear as dollars in the UI.
@@ -104,6 +105,19 @@ pub fn for_route(config: &Config, provider: ApiProvider) -> BillingPresentation
104105
}
105106
}
106107

108+
fn stepfun_billing(config: Option<&ProviderConfig>) -> BillingPresentation {
109+
let base_url = config
110+
.and_then(|config| config.base_url.as_deref())
111+
.unwrap_or(crate::config::DEFAULT_STEPFUN_BASE_URL);
112+
match crate::pricing::billing_surface_for_route(ApiProvider::Stepfun, Some(base_url)) {
113+
Some(crate::pricing::STEPFUN_PAYG_BILLING_SURFACE) => BillingPresentation::Metered,
114+
Some(crate::pricing::STEPFUN_PLAN_BILLING_SURFACE) => {
115+
BillingPresentation::Subscription("StepFun Step Plan quota")
116+
}
117+
_ => BillingPresentation::Unknown,
118+
}
119+
}
120+
107121
fn uses_zai_coding_plan(config: Option<&ProviderConfig>) -> bool {
108122
// The configured URL is optional because the Coding Plan endpoint is also
109123
// CodeWhale's Z.ai default. A credentials-only `[providers.zai]` entry
@@ -138,7 +152,7 @@ pub fn for_child_route(
138152
| ApiProvider::Anthropic
139153
| ApiProvider::XiaomiMimo
140154
| ApiProvider::Zai => BillingPresentation::Subscription("provider quota"),
141-
ApiProvider::Custom => BillingPresentation::Unknown,
155+
ApiProvider::Stepfun | ApiProvider::Custom => BillingPresentation::Unknown,
142156
_ => BillingPresentation::Metered,
143157
}
144158
}
@@ -154,7 +168,16 @@ pub fn has_priced_metered_basis(
154168
provider: ApiProvider,
155169
model: &str,
156170
) -> bool {
157-
billing.shows_money() && crate::pricing::has_pricing_for_provider(provider, model)
171+
billing.shows_money()
172+
&& if provider == ApiProvider::Stepfun {
173+
crate::pricing::has_pricing_for_billing_surface(
174+
provider,
175+
model,
176+
Some(crate::pricing::STEPFUN_PAYG_BILLING_SURFACE),
177+
)
178+
} else {
179+
crate::pricing::has_pricing_for_provider(provider, model)
180+
}
158181
}
159182

160183
/// Build the truthful usage chip for session surfaces.
@@ -401,6 +424,52 @@ mod tests {
401424
);
402425
}
403426

427+
#[test]
428+
fn stepfun_payg_shows_money_but_step_plan_stays_subscription_billed() {
429+
let payg_billing = for_route(&Config::default(), ApiProvider::Stepfun);
430+
assert_eq!(payg_billing, BillingPresentation::Metered);
431+
let payg_chip = usage_chip(
432+
payg_billing,
433+
ApiProvider::Stepfun,
434+
crate::config::DEFAULT_STEPFUN_MODEL,
435+
0.42,
436+
CostCurrency::Usd,
437+
None,
438+
);
439+
assert_eq!(format_usage_chip(&payg_chip).as_deref(), Some("$0.42"));
440+
441+
let plan_config = config_with(
442+
ApiProvider::Stepfun,
443+
ProviderConfig {
444+
base_url: Some("https://api.stepfun.ai/step_plan/v1".to_string()),
445+
..ProviderConfig::default()
446+
},
447+
);
448+
let plan_billing = for_route(&plan_config, ApiProvider::Stepfun);
449+
assert_eq!(
450+
plan_billing,
451+
BillingPresentation::Subscription("StepFun Step Plan quota")
452+
);
453+
let plan_chip = usage_chip(
454+
plan_billing,
455+
ApiProvider::Stepfun,
456+
crate::config::DEFAULT_STEPFUN_MODEL,
457+
0.42,
458+
CostCurrency::Usd,
459+
None,
460+
);
461+
assert!(!format_usage_line(&plan_chip).contains('$'));
462+
463+
assert_eq!(
464+
for_child_route(
465+
ApiProvider::Deepseek,
466+
BillingPresentation::Metered,
467+
ApiProvider::Stepfun,
468+
),
469+
BillingPresentation::Unknown
470+
);
471+
}
472+
404473
#[test]
405474
fn routed_zai_child_never_claims_api_dollars_without_full_route_config() {
406475
assert_eq!(

0 commit comments

Comments
 (0)