返回 CodeWhale
route_billing.rs
根目录 / crates / tui / src / route_billing.rs
1 //! Route-aware billing presentation.
2 //!
3 //! Model pricing and the way a user pays for a route are different facts.
4 //! The same model can be metered through an API key or covered by an OAuth /
5 //! token-plan subscription. Keep that decision in one small module so TUI
6 //! surfaces do not infer dollars from a model id alone.
7 //!
8 //! Display rule (TUI-DOG-010):
9 //! - dollars only for metered routes with a real priced usage basis and
10 //! positive accrued spend;
11 //! - OAuth/token-plan routes show a quota label, or a real used % when one
12 //! was supplied by the provider;
13 //! - unknown stays unknown — never `$0.00` and never an estimate-as-spend.
14
15 use crate::config::{ApiProvider, Config, ProviderConfig};
16 use crate::pricing::{CostCurrency, UnpricedReason, format_cost_amount};
17 use codewhale_localization::{Locale, MessageId, tr};
18
19 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
20 pub enum BillingPresentation {
21 /// Per-token API usage may be rendered as a currency estimate.
22 Metered,
23 /// Account/subscription quota is the truthful owner; dollar estimates are
24 /// intentionally hidden unless the provider later exposes real spend.
25 Subscription(&'static str),
26 /// The route is local or otherwise has no provider bill.
27 Local,
28 /// Billing basis is not known; never invent dollars or a fake zero.
29 Unknown,
30 }
31
32 /// Truthful chip for session/footer/sidebar usage surfaces.
33 #[derive(Debug, Clone, PartialEq)]
34 pub enum UsageChip {
35 /// Positive accrued spend on a metered route with real pricing.
36 Money(String),
37 /// Authoritatively priced portion of a mixed/legacy session whose complete
38 /// spend is unknown. The amount remains visible without being called a
39 /// total.
40 PricedSubtotal {
41 amount: String,
42 legacy: bool,
43 reasons: Vec<UnpricedReason>,
44 },
45 /// Subscription / OAuth allowance. `used_pct` is only set when the
46 /// provider supplied a real percentage.
47 Allowance {
48 label: &'static str,
49 used_pct: Option<f32>,
50 },
51 Local,
52 Unknown(Vec<UnpricedReason>),
53 /// Metered route with pricing, but nothing spent yet — omit the chip
54 /// rather than rendering `$0.00` / `<$0.0001`.
55 Hidden,
56 }
57
58 impl BillingPresentation {
59 #[must_use]
60 pub const fn shows_money(self) -> bool {
61 matches!(self, Self::Metered)
62 }
63
64 #[must_use]
65 #[allow(dead_code)] // label helpers for non-metered chip copy (TUI-DOG-010)
66 pub const fn label(self) -> Option<&'static str> {
67 match self {
68 Self::Metered => None,
69 Self::Subscription(label) => Some(label),
70 Self::Local => Some("local"),
71 Self::Unknown => Some("unknown"),
72 }
73 }
74 }
75
76 /// Serializable mirror of [`BillingPresentation`] for crossing the child →
77 /// parent mailbox boundary. `BillingPresentation` borrows a `&'static str`
78 /// label, which serde cannot deserialize, so the token-usage envelope carries
79 /// this owned form instead. Conversion back recognizes only the labels
80 /// [`for_route`] itself produces; an unrecognized free-text label fails
81 /// closed to `Unknown` rather than inventing a quota claim.
82 ///
83 /// **Not on the production child path.** The wired child receipt is
84 /// [`crate::cost_status::EffectiveRouteEnvelope`], which carries the same
85 /// classification as a `RouteBillingMode` plus the billing surface, endpoint
86 /// fingerprint and dispatch instant, and is emitted by all three real
87 /// producers (`review`, `verify`, `rlm`) and by the sub-agent mailbox. This
88 /// owned-label mirror is retained only as the executable record of the
89 /// serialization contract; gate it with the tests so it cannot rot into a
90 /// second, drifting provenance channel.
91 #[cfg(test)]
92 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
93 #[serde(tag = "kind", rename_all = "snake_case")]
94 pub enum ChildBillingProvenance {
95 Metered,
96 Subscription { label: String },
97 Local,
98 Unknown,
99 }
100
101 #[cfg(test)]
102 impl From<BillingPresentation> for ChildBillingProvenance {
103 fn from(billing: BillingPresentation) -> Self {
104 match billing {
105 BillingPresentation::Metered => Self::Metered,
106 BillingPresentation::Subscription(label) => Self::Subscription {
107 label: label.to_string(),
108 },
109 BillingPresentation::Local => Self::Local,
110 BillingPresentation::Unknown => Self::Unknown,
111 }
112 }
113 }
114
115 #[cfg(test)]
116 impl ChildBillingProvenance {
117 /// Convert back to the presentation form consumed by pricing.
118 #[must_use]
119 pub fn as_billing_presentation(&self) -> BillingPresentation {
120 match self {
121 Self::Metered => BillingPresentation::Metered,
122 Self::Local => BillingPresentation::Local,
123 Self::Unknown => BillingPresentation::Unknown,
124 Self::Subscription { label } => static_subscription_label(label).map_or(
125 BillingPresentation::Unknown,
126 BillingPresentation::Subscription,
127 ),
128 }
129 }
130 }
131
132 /// The subscription labels [`for_route`] can emit, mapped back to their
133 /// static form. Anything else is not a label this process vouches for.
134 #[cfg(test)]
135 fn static_subscription_label(label: &str) -> Option<&'static str> {
136 Some(match label {
137 "Codex OAuth quota" => "Codex OAuth quota",
138 "OpenCode Go quota" => "OpenCode Go quota",
139 "Z.ai Coding Plan quota" => "Z.ai Coding Plan quota",
140 "MiMo token plan" => "MiMo token plan",
141 "Kimi Code quota" => "Kimi Code quota",
142 "MiniMax Token Plan quota" => "MiniMax Token Plan quota",
143 "Grok OAuth quota" => "Grok OAuth quota",
144 "Claude OAuth quota" => "Claude OAuth quota",
145 "StepFun Step Plan quota" => "StepFun Step Plan quota",
146 "Alibaba Token Plan" => "Alibaba Token Plan",
147 "Alibaba Coding Plan" => "Alibaba Coding Plan",
148 "Volcengine Coding Plan" => "Volcengine Coding Plan",
149 _ => return None,
150 })
151 }
152
153 /// Immutable, non-secret receipt of the route a request was dispatched on.
154 ///
155 /// This is what a child/non-active route must be billed from. Re-reading an
156 /// ambient `Config` for a non-active provider is unsound: `apply_env_overrides`
157 /// merges provider endpoint variables (`MOONSHOT_BASE_URL`, `KIMI_BASE_URL`,
158 /// …) into the **active** provider's table only, so a cross-provider child's
159 /// config entry does not describe the endpoint its client was built with.
160 ///
161 /// Test-only: the production dispatch path captures a full
162 /// [`DispatchedReceipt`] at the client-freeze boundary and classifies with
163 /// [`for_dispatched_receipt`]. This pair exists so route-resolution tests can
164 /// assert that the pre-dispatch and receipt answers cannot disagree.
165 #[cfg(test)]
166 #[derive(Debug, Clone, Copy)]
167 pub struct DispatchedRoute<'a> {
168 /// Provider the dispatched client is bound to.
169 pub provider: ApiProvider,
170 /// Base URL the dispatched client will call, verbatim.
171 pub base_url: &'a str,
172 }
173
174 /// A fully captured, `Config`-free billing receipt.
175 ///
176 /// This is what [`for_dispatched_receipt`] consumes. Every field is captured
177 /// at dispatch; nothing here can be re-derived later.
178 #[derive(Debug, Clone, Copy)]
179 pub struct DispatchedReceipt<'a> {
180 /// Provider the dispatched client was bound to.
181 pub provider: ApiProvider,
182 /// Non-secret identity key that selected this route's table — the
183 /// `[providers.<name>]` key for a named custom route, the provider's own
184 /// key otherwise.
185 ///
186 /// `None` means the identity was not captured. For a named custom route
187 /// that is fatal to any product claim: without it there is no way to say
188 /// *which* custom vendor ran, and the classifier fails closed rather than
189 /// reading whichever custom table happens to be selected now.
190 pub identity: Option<&'a str>,
191 /// Base URL the dispatched client called, verbatim.
192 pub base_url: &'a str,
193 /// Product truth captured when this client was built.
194 pub product: RouteProduct,
195 }
196
197 /// Immutable, non-secret product truth for one route, captured at the moment
198 /// its client was built.
199 ///
200 /// Several providers are *credential-shaped* rather than endpoint-shaped: the
201 /// same host sells both a metered and a subscription product, and only the
202 /// credential (or an operator-declared pay mode) separates them. That fact
203 /// cannot be recovered later from an ambient `Config` — the session may have
204 /// switched provider, custom table, or key since — so it has to travel with
205 /// the receipt.
206 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
207 pub enum RouteProduct {
208 /// No product fact was captured. Credential-shaped providers must fail
209 /// closed on this: an uncaptured product is not a licence to guess.
210 #[default]
211 Unproven,
212 /// The route's credential/pay mode is subscription-backed, with this
213 /// user-facing quota label.
214 Subscription(&'static str),
215 /// The route bills per token.
216 Metered,
217 }
218
219 /// Resolve how a provider route should present usage, from the endpoint that
220 /// route resolves to right now.
221 ///
222 /// The endpoint is resolved exactly once, through the same identity-aware
223 /// [`Config::base_url_for_route`] the client is built from, and is then judged
224 /// by the same exact-product rules a dispatch receipt gets. There is no
225 /// separate "ambient" reading of a provider's table: a config entry with no
226 /// `base_url` still resolves to a real endpoint (an imported Kimi token
227 /// resolves to the Kimi Code membership host), and classifying from the raw
228 /// table field would call that route metered and invent dollars against a
229 /// membership quota.
230 ///
231 /// This is the pre-dispatch answer — for a turn that already ran, bill from
232 /// its receipt with [`crate::route_billing::for_dispatched_receipt`] instead.
233 #[must_use]
234 pub fn for_route(config: &Config, provider: ApiProvider) -> BillingPresentation {
235 let base_url = config.base_url_for_route(provider);
236 let identity = config.provider_identity_for(provider);
237 classify(
238 provider,
239 Some(identity.as_str()),
240 &base_url,
241 capture_product(config, provider),
242 )
243 }
244
245 /// Capture the immutable product facts for `provider` from the config its
246 /// client is being built from, **at dispatch time**.
247 ///
248 /// Call this while the config still describes the route being dispatched. The
249 /// result is what travels on [`crate::route_billing::DispatchedReceipt::product`];
250 /// nothing downstream
251 /// may re-derive it.
252 #[must_use]
253 pub fn capture_product(config: &Config, provider: ApiProvider) -> RouteProduct {
254 let provider_config = config.provider_config_for(provider);
255 match provider {
256 ApiProvider::Minimax | ApiProvider::MinimaxAnthropic => {
257 match minimax_credential_product(config, provider, provider_config) {
258 CredentialProduct::Plan => RouteProduct::Subscription("MiniMax Token Plan quota"),
259 CredentialProduct::PayAsYouGo => RouteProduct::Metered,
260 CredentialProduct::Unprovable => RouteProduct::Unproven,
261 }
262 }
263 ApiProvider::Csdn => match csdn_credential_product(provider_config) {
264 CredentialProduct::Plan => RouteProduct::Subscription("CSDN Coding Plan quota"),
265 CredentialProduct::PayAsYouGo => RouteProduct::Metered,
266 CredentialProduct::Unprovable => RouteProduct::Unproven,
267 },
268 ApiProvider::XiaomiMimo => {
269 if xiaomi_is_explicit_pay_as_you_go(provider_config) {
270 RouteProduct::Metered
271 } else {
272 RouteProduct::Subscription("MiMo token plan")
273 }
274 }
275 ApiProvider::Xai => {
276 if provider_config.is_some_and(uses_xai_oauth)
277 && crate::oauth::credentials_valid(crate::oauth::OAuthProvider::Xai, config)
278 {
279 RouteProduct::Subscription("Grok OAuth quota")
280 } else {
281 RouteProduct::Metered
282 }
283 }
284 ApiProvider::Anthropic => {
285 if provider_config.is_some_and(uses_anthropic_oauth) {
286 RouteProduct::Subscription("Claude OAuth quota")
287 } else {
288 RouteProduct::Metered
289 }
290 }
291 ApiProvider::Custom => match provider_config {
292 Some(entry) if !custom_billing_unknown(entry) => RouteProduct::Metered,
293 // No table, or a table with no declared pay mode: a custom vendor
294 // that has not told us how it bills.
295 _ => RouteProduct::Unproven,
296 },
297 // Endpoint-shaped and flat-rate providers need no credential fact.
298 _ => RouteProduct::Unproven,
299 }
300 }
301
302 /// Resolve billing for a route from its dispatch-time receipt.
303 ///
304 /// Deliberately takes no `Config`: after dispatch there is no sound ambient
305 /// state to consult. The session can have switched provider, custom table, or
306 /// credential since the request went out, so every fact this needs must
307 /// already be on the receipt. A receipt that does not name a product fails
308 /// closed to [`BillingPresentation::Unknown`] rather than inventing one.
309 /// Classify a receipt with no `Config` in reach at all.
310 ///
311 /// This is the entry point every post-dispatch caller must use. Because it
312 /// takes no config, a provider switch, a `/provider` change, or a different
313 /// custom table being selected after dispatch cannot retro-bill the turn onto
314 /// another route.
315 #[must_use]
316 pub fn for_dispatched_receipt(receipt: DispatchedReceipt<'_>) -> BillingPresentation {
317 classify(
318 receipt.provider,
319 receipt.identity,
320 receipt.base_url,
321 receipt.product,
322 )
323 }
324
325 /// Convenience wrapper for callers that still hold the route's own
326 /// **dispatch-time** config and have not captured a receipt yet.
327 ///
328 /// Sound only while `config` still describes the dispatched route. Anything
329 /// that runs after the turn has already completed must capture a
330 /// [`DispatchedReceipt`] at dispatch and use [`for_dispatched_receipt`].
331 #[cfg(test)]
332 #[must_use]
333 pub fn for_dispatched_route(config: &Config, route: DispatchedRoute<'_>) -> BillingPresentation {
334 let identity = config.provider_identity_for(route.provider);
335 for_dispatched_receipt(DispatchedReceipt {
336 provider: route.provider,
337 identity: Some(identity.as_str()),
338 base_url: route.base_url,
339 product: capture_product(config, route.provider),
340 })
341 }
342
343 /// The one classifier, pure in its inputs.
344 ///
345 /// `base_url` is the single resolved endpoint for this route and `product` is
346 /// the captured credential truth. There is no `Config` parameter on purpose:
347 /// this cannot read a provider table, a custom entry, or an active selection,
348 /// so a pre-dispatch answer and a receipt answer cannot drift apart and a
349 /// post-dispatch provider switch cannot retro-bill a turn onto another route.
350 fn classify(
351 provider: ApiProvider,
352 identity: Option<&str>,
353 base_url: &str,
354 product: RouteProduct,
355 ) -> BillingPresentation {
356 match provider {
357 ApiProvider::Ollama | ApiProvider::Sglang | ApiProvider::Vllm => BillingPresentation::Local,
358 ApiProvider::OpenaiCodex => BillingPresentation::Subscription("Codex OAuth quota"),
359 ApiProvider::OpencodeGo => BillingPresentation::Subscription("OpenCode Go quota"),
360 // StepFun already reduces an endpoint to a non-secret billing surface
361 // and fails closed on anything it does not recognize.
362 ApiProvider::Stepfun => stepfun_billing_for_endpoint(Some(base_url)),
363 // Z.ai's dedicated Coding endpoint is the GLM Coding Plan route. Its
364 // quota is subscription-backed, so a public API price estimate is not
365 // truthful spend and must not appear as dollars in the UI. A
366 // credentials-only `[providers.zai]` entry still resolves to that
367 // endpoint, because it is also CodeWhale's Z.ai default.
368 ApiProvider::Zai if base_url.trim().is_empty() => BillingPresentation::Unknown,
369 ApiProvider::Zai if is_zai_coding_plan_endpoint(base_url) => {
370 BillingPresentation::Subscription("Z.ai Coding Plan quota")
371 }
372 ApiProvider::Zai => endpoint_shaped_payg_billing(provider, base_url),
373 ApiProvider::XiaomiMimo => product_billing(product),
374
375 // Moonshot's direct platform is pay-as-you-go metered. Only the exact
376 // Kimi Code membership endpoint bills against subscription quota.
377 //
378 // The endpoint must name one of the two known products outright. A
379 // neighboring Kimi-hosted path, a gateway host, or a shipped default
380 // reached for a route we cannot otherwise explain must not inherit
381 // Moonshot's metered price list.
382 //
383 // Reading the resolved endpoint (not the provider table's `base_url`)
384 // is what makes the imported-token membership route truthful: a Kimi
385 // Code token with no `base_url` in its table still resolves to
386 // api.kimi.com/coding/v1, and calling that metered would put invented
387 // dollars against a membership quota.
388 ApiProvider::Moonshot if crate::config::moonshot_base_url_is_exact_kimi_code(base_url) => {
389 BillingPresentation::Subscription("Kimi Code quota")
390 }
391 ApiProvider::Moonshot
392 if crate::config::moonshot_base_url_is_exact_direct_platform(base_url) =>
393 {
394 BillingPresentation::Metered
395 }
396 ApiProvider::Moonshot => BillingPresentation::Unknown,
397 // Both MiniMax dialects (`[providers.minimax]` chat-completions and
398 // `[providers.minimax_anthropic]` Messages) are reachable with the
399 // same MINIMAX_API_KEY and sell the same PAYG/Token Plan duality over
400 // the same endpoints, so the wire protocol must not change the billing
401 // story and the endpoint cannot settle it either. Only the credential
402 // product can, and when that is unprovable the route is Unknown.
403 // A MiniMax gateway sells its own product on its own terms, and the
404 // PAYG/Token Plan duality only describes MiniMax's own hosts. Settle
405 // the endpoint first: anything off the supported direct routes is
406 // Unknown no matter what credential was captured.
407 ApiProvider::Minimax | ApiProvider::MinimaxAnthropic
408 if !minimax_base_url_is_supported_direct(base_url) =>
409 {
410 BillingPresentation::Unknown
411 }
412 ApiProvider::Minimax | ApiProvider::MinimaxAnthropic => product_billing(product),
413 // CSDN 星图 sells the Coding Plan (`glm_for_coding`) and metered
414 // marketplace models over the same endpoint; the URL proves only that
415 // the route is first-party, so the captured product decides. Anything
416 // off the supported direct endpoint is Unknown no matter what was
417 // captured.
418 ApiProvider::Csdn
419 if !codewhale_config::provider::is_exact_csdn_platform_route(
420 codewhale_config::ProviderKind::Csdn,
421 base_url,
422 ) =>
423 {
424 BillingPresentation::Unknown
425 }
426 ApiProvider::Csdn => product_billing(product),
427 ApiProvider::Xai | ApiProvider::Anthropic => product_billing(product),
428 // A named custom route is billed from the identity and endpoint it
429 // dispatched on. Without an identity there is no vendor to name, and
430 // without an endpoint there is no route at all — either way the honest
431 // answer is Unknown rather than whatever the active custom table says.
432 ApiProvider::Custom
433 if identity.is_none_or(|key| key.trim().is_empty()) || base_url.trim().is_empty() =>
434 {
435 BillingPresentation::Unknown
436 }
437 ApiProvider::Custom => product_billing(product),
438 // These providers are endpoint-shaped — but
439 // only on an endpoint we actually recognize. A first-party or
440 // aggregator provider pointed at an unrecognized host is not evidence
441 // that the host sells that provider's price list, so it must not fall
442 // through to metered per-token dollars on the strength of a provider
443 // name (#4318).
444 // Keep this match exhaustive: onboarding a provider requires an
445 // explicit billing decision and the default-route audit below.
446 ApiProvider::Deepseek
447 | ApiProvider::DeepseekCN
448 | ApiProvider::DeepseekAnthropic
449 | ApiProvider::NvidiaNim
450 | ApiProvider::Openai
451 | ApiProvider::Atlascloud
452 | ApiProvider::WanjieArk
453 | ApiProvider::Volcengine
454 | ApiProvider::Openrouter
455 | ApiProvider::Orcarouter
456 | ApiProvider::Novita
457 | ApiProvider::Fireworks
458 | ApiProvider::Siliconflow
459 | ApiProvider::SiliconflowCn
460 | ApiProvider::Arcee
461 | ApiProvider::OllamaCloud
462 | ApiProvider::Huggingface
463 | ApiProvider::Modelscope
464 | ApiProvider::Together
465 | ApiProvider::Qianfan
466 | ApiProvider::Openmodel
467 | ApiProvider::Deepinfra
468 | ApiProvider::Sakana
469 | ApiProvider::LongCat
470 | ApiProvider::OpencodeZen
471 | ApiProvider::Meta
472 | ApiProvider::Mistral
473 | ApiProvider::Google
474 | ApiProvider::Antigravity
475 | ApiProvider::Telecomjs
476 | ApiProvider::Edenai
477 | ApiProvider::Zenmux
478 | ApiProvider::Concentrate
479 | ApiProvider::Codewhale
480 | ApiProvider::ModelstudioTokenPlan
481 | ApiProvider::ModelstudioTokenPlanAnthropic
482 | ApiProvider::ModelstudioCodingPlan
483 | ApiProvider::ModelstudioCodingPlanAnthropic => {
484 endpoint_shaped_payg_billing(provider, base_url)
485 }
486 }
487 }
488
489 /// Metered only when the resolved endpoint reduces to a known money surface.
490 /// An unclassified endpoint is Unknown, never metered-by-provider-name.
491 fn endpoint_shaped_payg_billing(provider: ApiProvider, base_url: &str) -> BillingPresentation {
492 use crate::pricing::EndpointMetering;
493
494 let surface = crate::pricing::billing_surface_for_route(provider, Some(base_url));
495 match crate::pricing::endpoint_metering_for_billing_surface(surface) {
496 EndpointMetering::Money => BillingPresentation::Metered,
497 EndpointMetering::LocalNoBill => BillingPresentation::Local,
498 EndpointMetering::ExactSubscription => BillingPresentation::Subscription(match surface {
499 Some(crate::pricing::MODELSTUDIO_TOKEN_PLAN_BILLING_SURFACE) => "Alibaba Token Plan",
500 Some(crate::pricing::MODELSTUDIO_CODING_PLAN_BILLING_SURFACE) => "Alibaba Coding Plan",
501 Some(crate::pricing::VOLCENGINE_CODING_PLAN_BILLING_SURFACE) => {
502 "Volcengine Coding Plan"
503 }
504 _ => "provider plan",
505 }),
506 EndpointMetering::Unknown => BillingPresentation::Unknown,
507 }
508 }
509
510 /// Billing presentation for callers that hold a provider and the concrete base
511 /// URL but **not** the app [`Config`] — background helpers (compaction,
512 /// purge) that run off a bare client.
513 ///
514 /// Everything decidable from provider identity plus a classified endpoint is
515 /// decided; everything that depends on credentials or an auth mode CodeWhale
516 /// cannot see from here stays [`BillingPresentation::Unknown`]. In particular a
517 /// local, custom, or plan endpoint is never allowed to fall through to metered
518 /// per-token dollars on the strength of a provider name (#4318).
519 ///
520 /// This is exactly a receipt with no identity and no captured product, so it
521 /// runs through the one [`classify`] path rather than keeping a second,
522 /// drift-prone copy of the endpoint rules: an uncaptured product makes every
523 /// credential-shaped provider Unknown, and a missing identity makes every
524 /// named custom route Unknown.
525 #[must_use]
526 pub fn for_endpoint_without_config(
527 provider: ApiProvider,
528 base_url: Option<&str>,
529 ) -> BillingPresentation {
530 classify(
531 provider,
532 None,
533 base_url.unwrap_or_default(),
534 RouteProduct::Unproven,
535 )
536 }
537
538 /// Immutable billing surface captured when a foreground/child request is
539 /// dispatched. Endpoint classification owns ordinary providers; MiniMax and
540 /// OAuth-on-the-same-host providers require the saved route mode as additional
541 /// evidence and otherwise fail closed.
542 #[must_use]
543 pub fn billing_surface_for_dispatch(
544 config: Option<&Config>,
545 provider: ApiProvider,
546 base_url: Option<&str>,
547 ) -> Option<&'static str> {
548 if let Some(config) = config {
549 match for_route(config, provider) {
550 BillingPresentation::Subscription(_) => {
551 return Some(match provider {
552 ApiProvider::Minimax | ApiProvider::MinimaxAnthropic => {
553 crate::pricing::MINIMAX_TOKEN_PLAN_BILLING_SURFACE
554 }
555 ApiProvider::Csdn => crate::pricing::CSDN_CODING_PLAN_BILLING_SURFACE,
556 ApiProvider::OpenaiCodex
557 | ApiProvider::OpencodeGo
558 | ApiProvider::Anthropic
559 | ApiProvider::Xai => crate::pricing::OAUTH_SUBSCRIPTION_BILLING_SURFACE,
560 _ => crate::pricing::billing_surface_for_route(provider, base_url)
561 .unwrap_or(crate::pricing::UNCLASSIFIED_BILLING_SURFACE),
562 });
563 }
564 BillingPresentation::Metered
565 if matches!(
566 provider,
567 ApiProvider::Minimax | ApiProvider::MinimaxAnthropic
568 ) =>
569 {
570 return Some(crate::pricing::MINIMAX_PAYG_BILLING_SURFACE);
571 }
572 BillingPresentation::Metered if provider == ApiProvider::Csdn => {
573 return Some(crate::pricing::CSDN_PAYG_BILLING_SURFACE);
574 }
575 BillingPresentation::Local => return Some(crate::pricing::LOCAL_BILLING_SURFACE),
576 BillingPresentation::Unknown | BillingPresentation::Metered => {}
577 }
578 }
579 crate::pricing::billing_surface_for_route(provider, base_url)
580 }
581
582 /// Credential-shaped providers answer from the captured product and nothing
583 /// else. An uncaptured product is Unknown: no invented dollars, no invented
584 /// quota label.
585 fn product_billing(product: RouteProduct) -> BillingPresentation {
586 match product {
587 RouteProduct::Subscription(label) => BillingPresentation::Subscription(label),
588 RouteProduct::Metered => BillingPresentation::Metered,
589 RouteProduct::Unproven => BillingPresentation::Unknown,
590 }
591 }
592
593 // MiniMax's own hosted routes, for both wire dialects. Single-sourced in
594 // `config` so billing classification and request shaping cannot disagree about
595 // which hosts are first-party.
596 use crate::config::minimax_base_url_is_supported_direct;
597
598 /// StepFun already reduces an endpoint to a non-secret billing surface and
599 /// fails closed on anything it does not recognize, so the resolved endpoint
600 /// and a dispatch receipt use the same reduction unchanged.
601 fn stepfun_billing_for_endpoint(base_url: Option<&str>) -> BillingPresentation {
602 match crate::pricing::billing_surface_for_route(ApiProvider::Stepfun, base_url) {
603 Some(crate::pricing::STEPFUN_PAYG_BILLING_SURFACE) => BillingPresentation::Metered,
604 Some(crate::pricing::STEPFUN_PLAN_BILLING_SURFACE) => {
605 BillingPresentation::Subscription("StepFun Step Plan quota")
606 }
607 _ => BillingPresentation::Unknown,
608 }
609 }
610
611 fn is_zai_coding_plan_endpoint(base_url: &str) -> bool {
612 base_url
613 .trim()
614 .trim_end_matches('/')
615 .ends_with("/api/coding/paas/v4")
616 }
617
618 /// Billing for a child route. Billing is never guessed from provider
619 /// identity:
620 ///
621 /// - `child_provenance` — the child's own route truth, classified by
622 /// [`for_dispatched_route`] from the immutable endpoint receipt captured
623 /// when its client was built, and carried on the usage envelope — always
624 /// wins.
625 /// - Without provenance, a child on the parent's provider runs the parent's
626 /// exact route (review/verify/rlm children reuse the session client), so
627 /// it inherits `parent_billing`.
628 /// - Without provenance, a cross-provider child fails closed: local routes
629 /// stay `Local`; everything else is `Unknown` — no invented dollars and no
630 /// invented subscription labels.
631 ///
632 /// **Superseded by [`for_child_route_receipt`].** Retained for the
633 /// subagent-routing path and its existing coverage, which compare first-party
634 /// providers whose identity key is the provider string itself. It must not be
635 /// used where a named custom route can appear: every custom route maps to
636 /// `ApiProvider::Custom`, so the enum comparison below cannot tell custom
637 /// vendor A from custom vendor B.
638 ///
639 /// Unknown is deliberately not a subscription label (#4318). A provider that
640 /// *can* be subscription-billed is not evidence that this child turn *was*,
641 /// and because non-metered routes are excused from money coverage, that guess
642 /// would quietly remove real spend from `/cost`'s denominator instead of
643 /// reporting it as missing.
644 #[must_use]
645 #[cfg(test)]
646 pub fn for_child_route(
647 parent_provider: ApiProvider,
648 parent_billing: BillingPresentation,
649 child_provider: ApiProvider,
650 child_provenance: Option<BillingPresentation>,
651 ) -> BillingPresentation {
652 if let Some(provenance) = child_provenance {
653 return provenance;
654 }
655 if child_provider == parent_provider {
656 return parent_billing;
657 }
658 match child_provider {
659 // No provider bill exists for a local runtime under any configuration.
660 ApiProvider::Ollama | ApiProvider::Sglang | ApiProvider::Vllm => BillingPresentation::Local,
661 _ => BillingPresentation::Unknown,
662 }
663 }
664
665 /// Identity-aware child billing, from the parent's frozen receipt.
666 ///
667 /// **Not on the production child path**, for the same reason as
668 /// [`ChildBillingProvenance`]: `tui::tool_routing` bills a child from the
669 /// child's own [`crate::cost_status::EffectiveRouteEnvelope`], rehydrated from
670 /// the complete `child_*` metadata its producer emits, and an incomplete
671 /// payload fails closed to Unknown rather than inheriting anything (see
672 /// `legacy_child_usage_metadata_fails_closed_without_parent_route_fallback`).
673 /// The identity-comparison rule below is therefore structurally unreachable —
674 /// nothing inherits — and is kept with the tests as the record of it.
675 #[cfg(test)]
676 #[must_use]
677 pub fn for_child_route_receipt(
678 parent: ChildParentRoute<'_>,
679 child: ChildRouteClaim<'_>,
680 child_provenance: Option<BillingPresentation>,
681 ) -> BillingPresentation {
682 if let Some(provenance) = child_provenance {
683 return provenance;
684 }
685 // A child that claims no route at all ran in-process on the parent's own
686 // client (review/verify/rlm critics reuse the session client), so the
687 // parent's frozen receipt *is* its receipt. This is inheritance from an
688 // immutable capture, not from live session state.
689 if !child.named {
690 return parent.billing;
691 }
692 // Same-route inheritance requires the *whole* route to match, not just the
693 // provider enum. Every named custom route maps to `ApiProvider::Custom`,
694 // so an enum comparison would let a child on custom vendor A inherit the
695 // parent's product label from custom vendor B.
696 if child.provider == Some(parent.provider)
697 && child.identity.is_some_and(|key| key == parent.identity)
698 {
699 return parent.billing;
700 }
701 // A child that named a provider string this build cannot parse names no
702 // route we can vouch for. That is not a licence to inherit: Unknown.
703 match child.provider {
704 Some(ApiProvider::Ollama | ApiProvider::Sglang | ApiProvider::Vllm) => {
705 BillingPresentation::Local
706 }
707 _ => BillingPresentation::Unknown,
708 }
709 }
710
711 /// Non-secret route facts a child tool must publish alongside its token usage.
712 ///
713 /// Emitted from the child's own dispatched client, so the parent consumer never
714 /// has to infer which route ran. Keys are pinned by
715 /// `child_route_metadata_round_trips_through_the_consumer` so a producer and
716 /// the reader in `tui::tool_routing` cannot drift apart.
717 ///
718 /// `product` is left [`RouteProduct::Unproven`] when the child has no
719 /// route-scoped `Config` in reach: that classifies credential-shaped providers
720 /// as Unknown, which is the honest answer rather than a guess. A child running
721 /// the parent's exact route is recognized by identity and inherits the
722 /// parent's frozen receipt instead.
723 /// Currently exercised only by
724 /// `child_route_metadata_round_trips_through_the_consumer`: no tool producer
725 /// emits the keys yet, and the reader in `tui::tool_routing` treats them as
726 /// optional. The pairing lives here so a producer and that reader cannot drift
727 /// apart when one is wired up.
728 #[cfg(test)]
729 #[must_use]
730 pub fn child_route_metadata(
731 provider: ApiProvider,
732 identity: &str,
733 base_url: &str,
734 product: RouteProduct,
735 ) -> serde_json::Value {
736 let billing = for_dispatched_receipt(DispatchedReceipt {
737 provider,
738 identity: Some(identity),
739 base_url,
740 product,
741 });
742 serde_json::json!({
743 "child_provider": provider.as_str(),
744 "child_provider_identity": identity,
745 "child_billing": ChildBillingProvenance::from(billing),
746 })
747 }
748
749 /// The parent turn's frozen receipt, as the only inheritance basis a child may
750 /// use.
751 ///
752 /// Deliberately not `app.billing_presentation`: that chip is live session
753 /// state, rewritten on every `/provider` switch, so reading it when a child's
754 /// usage envelope arrives bills the child against whatever route the session
755 /// points at *now*.
756 #[cfg(test)]
757 #[derive(Debug, Clone, Copy)]
758 pub struct ChildParentRoute<'a> {
759 pub provider: ApiProvider,
760 /// The parent turn's captured identity key.
761 pub identity: &'a str,
762 /// Billing classified from the parent turn's dispatch receipt.
763 pub billing: BillingPresentation,
764 }
765
766 /// What a child claims about its own route.
767 ///
768 /// `named` distinguishes the two very different silences:
769 ///
770 /// - `named: false` — the child published no route at all, which means it ran
771 /// on the parent's own client. Inheriting the parent's frozen receipt is
772 /// correct.
773 /// - `named: true` with `provider: None` — the child published a provider
774 /// string this build cannot parse. It named *some* route, just not one we
775 /// recognize, so inheritance would be a guess: Unknown.
776 #[cfg(test)]
777 #[derive(Debug, Clone, Copy, Default)]
778 pub struct ChildRouteClaim<'a> {
779 /// Whether the child published any route string at all.
780 pub named: bool,
781 pub provider: Option<ApiProvider>,
782 pub identity: Option<&'a str>,
783 }
784
785 /// Whether this route may show a dollar amount for the given model.
786 ///
787 /// Requires both a metered billing presentation and an authoritative priced
788 /// basis for the model. OAuth/token-plan routes always return false even when
789 /// the same model id is priced on a public API route.
790 #[cfg(test)]
791 #[must_use]
792 pub fn has_priced_metered_basis(
793 billing: BillingPresentation,
794 provider: ApiProvider,
795 model: &str,
796 ) -> bool {
797 billing.shows_money()
798 && if provider == ApiProvider::Stepfun {
799 crate::pricing::has_pricing_for_billing_surface(
800 provider,
801 model,
802 Some(crate::pricing::STEPFUN_PAYG_BILLING_SURFACE),
803 )
804 } else {
805 crate::pricing::has_pricing_for_provider(provider, model)
806 }
807 }
808
809 /// Build the truthful usage chip for session surfaces.
810 ///
811 /// `used_pct` is only honored for subscription/OAuth routes and must come from
812 /// a provider-supplied allowance reading — never from a local estimate.
813 #[must_use]
814 pub fn usage_chip(
815 billing: BillingPresentation,
816 provider: ApiProvider,
817 model: &str,
818 displayed_cost: f64,
819 currency: CostCurrency,
820 used_pct: Option<f32>,
821 ) -> UsageChip {
822 match billing {
823 BillingPresentation::Local => UsageChip::Local,
824 BillingPresentation::Unknown => {
825 UsageChip::Unknown(vec![UnpricedReason::UnknownBillingBasis])
826 }
827 BillingPresentation::Subscription(label) => UsageChip::Allowance {
828 label,
829 used_pct: used_pct.filter(|pct| pct.is_finite() && *pct >= 0.0),
830 },
831 BillingPresentation::Metered => {
832 let surface = (provider == ApiProvider::Stepfun)
833 .then_some(crate::pricing::STEPFUN_PAYG_BILLING_SURFACE);
834 let audit = if surface.is_some() {
835 crate::pricing::audit_turn_cost_for_route_at(
836 provider,
837 model,
838 surface,
839 &codewhale_models::Usage::default(),
840 chrono::Utc::now(),
841 )
842 } else {
843 crate::pricing::audit_turn_cost_for_provider_at(
844 provider,
845 model,
846 &codewhale_models::Usage::default(),
847 chrono::Utc::now(),
848 )
849 };
850 if !audit.is_priced_in(currency) {
851 UsageChip::Unknown(vec![
852 audit
853 .unpriced_reason
854 .unwrap_or(UnpricedReason::UnsupportedCurrency),
855 ])
856 } else if displayed_cost.is_finite() && displayed_cost > 0.0 {
857 UsageChip::Money(format_cost_amount(displayed_cost, currency))
858 } else {
859 UsageChip::Hidden
860 }
861 }
862 }
863 }
864
865 /// Compact footer/header chip text. `None` means omit the chip.
866 #[must_use]
867 #[allow(dead_code)] // shared chip formatter for footer/sidebar siblings (TUI-DOG-010)
868 pub fn format_usage_chip(chip: &UsageChip, locale: Locale) -> Option<String> {
869 match chip {
870 UsageChip::Money(amount) => Some(amount.clone()),
871 UsageChip::PricedSubtotal {
872 amount,
873 legacy,
874 reasons,
875 } => Some(
876 tr(
877 locale,
878 if *legacy {
879 MessageId::CostChipSavedSubtotal
880 } else {
881 MessageId::CostChipSubtotal
882 },
883 )
884 .replace("{amount}", amount)
885 .replace("{reasons}", &format_unpriced_reasons(reasons, locale)),
886 ),
887 UsageChip::Allowance { label, used_pct } => Some(match used_pct {
888 Some(pct) => tr(locale, MessageId::CostChipAllowancePercent)
889 .replace("{plan}", label)
890 .replace("{percent}", &format!("{pct:.0}")),
891 None => tr(locale, MessageId::CostChipAllowance).replace("{plan}", label),
892 }),
893 UsageChip::Local => Some(tr(locale, MessageId::CostChipLocal).into_owned()),
894 UsageChip::Unknown(reasons) => Some(
895 tr(locale, MessageId::CostChipUnknown)
896 .replace("{reasons}", &format_unpriced_reasons(reasons, locale)),
897 ),
898 UsageChip::Hidden => None,
899 }
900 }
901
902 /// The same saved receipt explains missing coverage in every cost surface.
903 #[must_use]
904 pub fn format_unpriced_reasons(reasons: &[UnpricedReason], locale: Locale) -> String {
905 if reasons.is_empty() {
906 return tr(locale, UnpricedReason::UnrecordedCoverage.message_id()).into_owned();
907 }
908 let mut descriptions = Vec::new();
909 for reason in reasons {
910 let text = tr(locale, reason.message_id());
911 if !descriptions.contains(&text) {
912 descriptions.push(text);
913 }
914 }
915 descriptions.join(", ")
916 }
917
918 fn custom_billing_unknown(config: &ProviderConfig) -> bool {
919 // A custom OpenAI-compatible endpoint with no explicit pay mode and no
920 // priced catalog is treated as unknown rather than inventing metered
921 // dollars from a borrowed model id.
922 let mode = auth_mode(config);
923 !mode.as_deref().is_some_and(|mode| {
924 matches!(
925 mode,
926 "api_key"
927 | "api"
928 | "key"
929 | "keyring"
930 | "payg"
931 | "paygo"
932 | "pay_as_you_go"
933 | "metered"
934 | "standard"
935 )
936 })
937 }
938
939 fn normalized(value: &str) -> String {
940 value.trim().to_ascii_lowercase().replace(['-', ' '], "_")
941 }
942
943 fn auth_mode(config: &ProviderConfig) -> Option<String> {
944 config
945 .auth_mode
946 .as_deref()
947 .or(config.mode.as_deref())
948 .map(normalized)
949 }
950
951 fn uses_xai_oauth(config: &ProviderConfig) -> bool {
952 auth_mode(config).is_some_and(|mode| crate::oauth::auth_mode_uses_xai_oauth(&mode))
953 }
954
955 fn uses_anthropic_oauth(config: &ProviderConfig) -> bool {
956 auth_mode(config).is_some_and(|mode| {
957 matches!(
958 mode.as_str(),
959 "oauth"
960 | "anthropic_oauth"
961 | "claude_oauth"
962 | "claude_cli"
963 | "claude_code"
964 | "max"
965 | "subscription"
966 )
967 })
968 }
969
970 /// What immutable, non-secret provenance can prove about the credential
971 /// product behind a dual-product route.
972 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
973 enum CredentialProduct {
974 /// A subscription / token-plan product is proven.
975 Plan,
976 /// An ordinary metered (pay-as-you-go) product is proven.
977 PayAsYouGo,
978 /// Neither can be proven from route/auth provenance. Classification must
979 /// fail closed rather than default to metered dollars.
980 Unprovable,
981 }
982
983 /// MiniMax sells both a pay-as-you-go API and a Token Plan subscription over
984 /// the *same* endpoints and the same `MINIMAX_API_KEY`, so the product can
985 /// only come from an explicit pay mode or the credential's own product prefix.
986 ///
987 /// A key held in the Codewhale secret store / OS keyring is deliberately not
988 /// probed: classification must never be a reason to open secret storage. When
989 /// no product marker is visible the route is `Unprovable`, and [`for_route`]
990 /// reports Unknown instead of inventing pay-as-you-go dollars.
991 fn minimax_credential_product(
992 config: &Config,
993 provider: ApiProvider,
994 provider_config: Option<&ProviderConfig>,
995 ) -> CredentialProduct {
996 // An explicit operator-set pay mode is the strongest non-secret
997 // provenance available: the operator has told us how the account bills,
998 // and it wins over key shape in both directions. An unrecognized mode is
999 // not a product claim.
1000 if let Some(mode) = provider_config
1001 .and_then(|config| config.mode.as_deref())
1002 .filter(|mode| !mode.trim().is_empty())
1003 .map(normalized)
1004 {
1005 return match mode.as_str() {
1006 // `subscription_plan` is the spelling the cost lane's operator
1007 // docs and tests used; keep it recognized so an explicit operator
1008 // declaration is never silently discarded as "unprovable".
1009 "token_plan" | "tokenplan" | "plan" | "subscription" | "subscription_plan" => {
1010 CredentialProduct::Plan
1011 }
1012 "pay_as_you_go" | "payg" | "paygo" | "pay_as_go" | "metered" | "standard" | "api"
1013 | "api_key" | "default" => CredentialProduct::PayAsYouGo,
1014 _ => CredentialProduct::Unprovable,
1015 };
1016 }
1017 match visible_minimax_credential_is_plan_shaped(config, provider, provider_config) {
1018 Some(true) => CredentialProduct::Plan,
1019 Some(false) => CredentialProduct::PayAsYouGo,
1020 None => CredentialProduct::Unprovable,
1021 }
1022 }
1023
1024 /// CSDN 星图 sells the Coding Plan and metered marketplace models over the
1025 /// same `ai.csdn.net/api/model/v1` endpoint and `CSDN_API_KEY` slot, so the
1026 /// product comes from an explicit saved pay mode or the routed model:
1027 /// `glm_for_coding` is the Coding Plan's dedicated model id — the route the
1028 /// plan sells — while every other model on the platform endpoint is ordinary
1029 /// metered marketplace access. An explicit operator-set mode wins over the
1030 /// model in both directions; an unrecognized mode is not a product claim.
1031 fn csdn_credential_product(provider_config: Option<&ProviderConfig>) -> CredentialProduct {
1032 if let Some(mode) = provider_config
1033 .and_then(|config| config.mode.as_deref())
1034 .filter(|mode| !mode.trim().is_empty())
1035 .map(normalized)
1036 {
1037 return match mode.as_str() {
1038 "coding_plan" | "codingplan" | "plan" | "subscription" | "subscription_plan" => {
1039 CredentialProduct::Plan
1040 }
1041 "pay_as_you_go" | "payg" | "paygo" | "pay_as_go" | "metered" | "standard" | "api"
1042 | "api_key" | "default" => CredentialProduct::PayAsYouGo,
1043 _ => CredentialProduct::Unprovable,
1044 };
1045 }
1046 // No table at all still resolves to the plan model: `glm_for_coding` is
1047 // the shipped default for the `csdn` route.
1048 let model = provider_config
1049 .and_then(|entry| entry.model.as_deref())
1050 .map(str::trim)
1051 .filter(|model| !model.is_empty())
1052 .unwrap_or(crate::config::DEFAULT_CSDN_MODEL);
1053 if model.eq_ignore_ascii_case(crate::config::DEFAULT_CSDN_MODEL) {
1054 CredentialProduct::Plan
1055 } else {
1056 CredentialProduct::PayAsYouGo
1057 }
1058 }
1059
1060 /// Whether a MiniMax credential is visible in non-secret-store provenance,
1061 /// and if so whether it carries the Token Plan (`sk-cp…`) product prefix.
1062 ///
1063 /// Only the product marker is returned — the credential value never leaves
1064 /// this function, nothing is logged, and the secret store is never opened.
1065 /// `None` means "no visible credential", which is the honest answer for a
1066 /// key resolved from the keyring, from an OAuth/command source, or from
1067 /// nowhere at all.
1068 fn visible_minimax_credential_is_plan_shaped(
1069 config: &Config,
1070 provider: ApiProvider,
1071 provider_config: Option<&ProviderConfig>,
1072 ) -> Option<bool> {
1073 let is_plan_shaped = |key: &str| key.trim_start().starts_with("sk-cp");
1074 // 1. An explicit `[providers.minimax*] api_key` is file-owned route truth.
1075 if let Some(key) = provider_config
1076 .and_then(|config| config.api_key.as_deref())
1077 .filter(|key| {
1078 crate::config::classify_config_api_key_value(key)
1079 == crate::config::ConfigApiKeyValueKind::Literal
1080 })
1081 .map(str::trim)
1082 {
1083 return Some(is_plan_shaped(key));
1084 }
1085 // 2. `api_key_env = "…"` binds one variable to this route by name, so the
1086 // binding itself is config-owned provenance even though the value is
1087 // ambient.
1088 if let Some(value) = provider_config
1089 .and_then(|config| config.api_key_env.as_deref())
1090 .map(str::trim)
1091 .filter(|name| !name.is_empty())
1092 .and_then(|name| std::env::var(name).ok())
1093 .filter(|value| !value.trim().is_empty())
1094 {
1095 return Some(is_plan_shaped(&value));
1096 }
1097 // 3. Ambient `MINIMAX_API_KEY` only describes the route when the route is
1098 // still an official MiniMax endpoint. Credential resolution refuses to
1099 // send ambient provider keys to a custom host, so on a custom endpoint
1100 // the exported key proves nothing about what this route bills.
1101 if config.provider_uses_custom_endpoint(provider) {
1102 return None;
1103 }
1104 std::env::var("MINIMAX_API_KEY")
1105 .ok()
1106 .filter(|key| !key.trim().is_empty())
1107 .map(|key| is_plan_shaped(&key))
1108 }
1109
1110 fn xiaomi_is_explicit_pay_as_you_go(config: Option<&ProviderConfig>) -> bool {
1111 if let Some(mode) = std::env::var("XIAOMI_MIMO_MODE")
1112 .ok()
1113 .filter(|mode| !mode.trim().is_empty())
1114 .map(|mode| normalized(&mode))
1115 {
1116 return matches!(
1117 mode.as_str(),
1118 "standard" | "default" | "payg" | "paygo" | "pay_as_you_go" | "pay_as_go"
1119 );
1120 }
1121 if let Some(base_url) = std::env::var("XIAOMI_MIMO_BASE_URL")
1122 .ok()
1123 .filter(|base_url| !base_url.trim().is_empty())
1124 {
1125 return !base_url.to_ascii_lowercase().contains("token-plan-");
1126 }
1127 let token_plan_env = ["XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "MIMO_TOKEN_PLAN_API_KEY"]
1128 .iter()
1129 .any(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty()));
1130 let standard_env = ["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"]
1131 .iter()
1132 .any(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty()));
1133 if standard_env && !token_plan_env {
1134 return true;
1135 }
1136 let Some(config) = config else {
1137 // The shipped MiMo default is a token-plan endpoint.
1138 return false;
1139 };
1140 if let Some(mode) = config
1141 .mode
1142 .as_deref()
1143 .filter(|mode| !mode.trim().is_empty())
1144 .map(normalized)
1145 {
1146 return matches!(
1147 mode.as_str(),
1148 "pay_as_you_go" | "payg" | "paygo" | "api" | "standard" | "default"
1149 );
1150 }
1151 if let Some(api_key) = config.api_key.as_deref().filter(|key| {
1152 crate::config::classify_config_api_key_value(key)
1153 == crate::config::ConfigApiKeyValueKind::Literal
1154 }) {
1155 return !api_key.trim_start().starts_with("tp-");
1156 }
1157 config.base_url.as_deref().is_some_and(|base_url| {
1158 let lower = base_url.to_ascii_lowercase();
1159 !lower.contains("token-plan-") && !lower.contains("token_plan_")
1160 })
1161 }
1162
1163 #[cfg(test)]
1164 mod tests {
1165 use super::*;
1166 use crate::pricing::CostCurrency;
1167 use codewhale_models::Usage;
1168
1169 fn config_with(provider: ApiProvider, provider_config: ProviderConfig) -> Config {
1170 let mut config = Config::default();
1171 *config.provider_config_for_mut(provider) = provider_config;
1172 config
1173 }
1174
1175 /// Clear every variable that could otherwise supply a Moonshot endpoint,
1176 /// so the resolver has to answer from the config alone.
1177 fn moonshot_endpoint_env_lock() -> [crate::test_support::EnvVarGuard; 4] {
1178 [
1179 crate::test_support::EnvVarGuard::remove("CODEWHALE_BASE_URL"),
1180 crate::test_support::EnvVarGuard::remove("DEEPSEEK_BASE_URL"),
1181 crate::test_support::EnvVarGuard::remove("MOONSHOT_BASE_URL"),
1182 crate::test_support::EnvVarGuard::remove("KIMI_BASE_URL"),
1183 ]
1184 }
1185
1186 #[test]
1187 fn imported_token_moonshot_without_table_base_url_bills_membership_quota() {
1188 let _lock = crate::test_support::lock_test_env();
1189 let _env = moonshot_endpoint_env_lock();
1190 // An imported Kimi Code token with no `base_url` in its table. The
1191 // table field is empty, but the route still resolves to the exact
1192 // membership endpoint, so classifying from the raw field would call a
1193 // membership quota metered and invent dollars against it.
1194 let config = config_with(
1195 ApiProvider::Moonshot,
1196 ProviderConfig {
1197 auth_mode: Some("kimi_oauth".to_string()),
1198 ..ProviderConfig::default()
1199 },
1200 );
1201 assert_eq!(
1202 config.base_url_for_route(ApiProvider::Moonshot),
1203 crate::config::DEFAULT_KIMI_CODE_BASE_URL
1204 );
1205
1206 let billing = for_route(&config, ApiProvider::Moonshot);
1207 assert_eq!(
1208 billing,
1209 BillingPresentation::Subscription("Kimi Code quota")
1210 );
1211 assert!(!billing.shows_money());
1212
1213 let chip = usage_chip(
1214 billing,
1215 ApiProvider::Moonshot,
1216 crate::config::DEFAULT_KIMI_CODE_MODEL,
1217 12.34,
1218 CostCurrency::Usd,
1219 None,
1220 );
1221 assert!(!matches!(chip, UsageChip::Money(_)));
1222 assert_eq!(
1223 format_usage_chip(&chip, codewhale_localization::Locale::En).as_deref(),
1224 Some("usage: Kimi Code quota")
1225 );
1226 // The label names the membership product, never the credential import
1227 // mechanism, and never a dollar figure.
1228 assert!(
1229 !format_usage_chip(&chip, codewhale_localization::Locale::En)
1230 .unwrap_or_default()
1231 .contains("OAuth")
1232 );
1233 assert!(
1234 !format_usage_chip(&chip, codewhale_localization::Locale::En)
1235 .unwrap_or_default()
1236 .contains("imported token")
1237 );
1238 assert!(
1239 !format_usage_chip(&chip, codewhale_localization::Locale::En)
1240 .unwrap_or_default()
1241 .contains('$')
1242 );
1243 }
1244
1245 #[test]
1246 fn turn_complete_kimi_code_receipt_accrues_no_dollars() {
1247 let _lock = crate::test_support::lock_test_env();
1248 let _env = moonshot_endpoint_env_lock();
1249 // Pins the exact decision the `EngineEvent::TurnComplete` arm makes:
1250 // classify from the event's immutable `base_url` receipt, then accrue
1251 // only when the result shows money. The ambient config deliberately
1252 // points at a *different* provider to prove the arm cannot re-resolve
1253 // its way onto another route's price list.
1254 let mut config = config_with(
1255 ApiProvider::Deepseek,
1256 ProviderConfig {
1257 api_key: Some("sk-session-deepseek".to_string()),
1258 ..ProviderConfig::default()
1259 },
1260 );
1261 config.provider = Some("deepseek".to_string());
1262
1263 let billing = for_dispatched_route(
1264 &config,
1265 DispatchedRoute {
1266 provider: ApiProvider::Moonshot,
1267 base_url: "https://api.kimi.com/coding/v1",
1268 },
1269 );
1270 assert_eq!(
1271 billing,
1272 BillingPresentation::Subscription("Kimi Code quota")
1273 );
1274 // `shows_money()` is the gate guarding `accrue_session_cost_estimate`.
1275 assert!(!billing.shows_money());
1276
1277 // A missing receipt must not fall back to the session's metered route.
1278 let no_receipt = for_dispatched_route(
1279 &config,
1280 DispatchedRoute {
1281 provider: ApiProvider::Moonshot,
1282 base_url: "",
1283 },
1284 );
1285 assert_eq!(no_receipt, BillingPresentation::Unknown);
1286 assert!(!no_receipt.shows_money());
1287 }
1288
1289 #[test]
1290 fn moonshot_ambient_and_dispatch_billing_agree_on_the_resolved_endpoint() {
1291 let _lock = crate::test_support::lock_test_env();
1292 let _env = moonshot_endpoint_env_lock();
1293 let cases = [
1294 // (table base_url, auth_mode, expected)
1295 (
1296 None,
1297 Some("kimi_oauth"),
1298 BillingPresentation::Subscription("Kimi Code quota"),
1299 ),
1300 (None, None, BillingPresentation::Metered),
1301 (
1302 Some("https://api.kimi.com/coding/v1"),
1303 None,
1304 BillingPresentation::Subscription("Kimi Code quota"),
1305 ),
1306 (
1307 Some("https://api.moonshot.ai/v1"),
1308 None,
1309 BillingPresentation::Metered,
1310 ),
1311 (
1312 Some("https://proxy.example.test/v1"),
1313 None,
1314 BillingPresentation::Unknown,
1315 ),
1316 ];
1317 for (base_url, auth_mode, expected) in cases {
1318 let config = config_with(
1319 ApiProvider::Moonshot,
1320 ProviderConfig {
1321 base_url: base_url.map(str::to_string),
1322 auth_mode: auth_mode.map(str::to_string),
1323 ..ProviderConfig::default()
1324 },
1325 );
1326 let resolved = config.base_url_for_route(ApiProvider::Moonshot);
1327 let ambient = for_route(&config, ApiProvider::Moonshot);
1328 let dispatched = for_dispatched_route(
1329 &config,
1330 DispatchedRoute {
1331 provider: ApiProvider::Moonshot,
1332 base_url: &resolved,
1333 },
1334 );
1335 assert_eq!(ambient, expected, "{base_url:?}/{auth_mode:?}");
1336 assert_eq!(
1337 ambient, dispatched,
1338 "{base_url:?}/{auth_mode:?} resolved to {resolved}: the pre-dispatch and \
1339 receipt classifications must not be able to disagree"
1340 );
1341 }
1342 }
1343
1344 #[test]
1345 fn moonshot_custom_gateway_is_unknown_not_metered() {
1346 let _lock = crate::test_support::lock_test_env();
1347 let _env = moonshot_endpoint_env_lock();
1348 // A Moonshot-compatible gateway sells its own product on its own
1349 // terms. Inheriting Moonshot's metered price list would invent
1350 // dollars; inheriting a membership label would invent a quota.
1351 for base_url in [
1352 "https://proxy.example.test/v1",
1353 "https://gateway.internal.test/moonshot/v1",
1354 ] {
1355 let config = config_with(
1356 ApiProvider::Moonshot,
1357 ProviderConfig {
1358 base_url: Some(base_url.to_string()),
1359 ..ProviderConfig::default()
1360 },
1361 );
1362 let billing = for_route(&config, ApiProvider::Moonshot);
1363 assert_eq!(
1364 billing,
1365 BillingPresentation::Unknown,
1366 "{base_url} must not inherit a Moonshot product"
1367 );
1368 assert!(!billing.shows_money());
1369 let chip = usage_chip(
1370 billing,
1371 ApiProvider::Moonshot,
1372 "kimi-k2.7-code",
1373 12.34,
1374 CostCurrency::Usd,
1375 None,
1376 );
1377 assert!(!matches!(chip, UsageChip::Money(_)));
1378 assert!(
1379 !format_usage_chip(&chip, codewhale_localization::Locale::En)
1380 .unwrap_or_default()
1381 .contains('$')
1382 );
1383 }
1384 }
1385
1386 #[test]
1387 fn moonshot_direct_platform_stays_metered_with_priced_model() {
1388 let config = config_with(
1389 ApiProvider::Moonshot,
1390 ProviderConfig {
1391 base_url: Some("https://api.moonshot.ai/v1".to_string()),
1392 ..ProviderConfig::default()
1393 },
1394 );
1395 let billing = for_route(&config, ApiProvider::Moonshot);
1396 assert_eq!(billing, BillingPresentation::Metered);
1397 assert!(billing.shows_money());
1398 let chip = usage_chip(
1399 billing,
1400 ApiProvider::Moonshot,
1401 "kimi-k2.7-code",
1402 0.42,
1403 CostCurrency::Usd,
1404 None,
1405 );
1406 assert!(matches!(chip, UsageChip::Money(_)));
1407 assert!(
1408 format_usage_chip(&chip, codewhale_localization::Locale::En)
1409 .unwrap_or_default()
1410 .contains('$')
1411 );
1412 }
1413
1414 #[test]
1415 fn moonshot_exact_kimi_code_endpoint_is_subscription_quota() {
1416 let config = config_with(
1417 ApiProvider::Moonshot,
1418 ProviderConfig {
1419 base_url: Some("https://api.kimi.com/coding/v1".to_string()),
1420 ..ProviderConfig::default()
1421 },
1422 );
1423 let billing = for_route(&config, ApiProvider::Moonshot);
1424 assert_eq!(
1425 billing,
1426 BillingPresentation::Subscription("Kimi Code quota")
1427 );
1428 assert!(!billing.shows_money());
1429 // `kimi-k2.7-code` is priced on the metered route; the subscription
1430 // classification must still win over the priced row.
1431 let chip = usage_chip(
1432 billing,
1433 ApiProvider::Moonshot,
1434 "kimi-k2.7-code",
1435 12.34,
1436 CostCurrency::Usd,
1437 None,
1438 );
1439 assert!(!matches!(chip, UsageChip::Money(_)));
1440 assert_eq!(
1441 chip,
1442 UsageChip::Allowance {
1443 label: "Kimi Code quota",
1444 used_pct: None,
1445 }
1446 );
1447 assert!(
1448 !format_usage_chip(&chip, codewhale_localization::Locale::En)
1449 .unwrap_or_default()
1450 .contains('$')
1451 );
1452 }
1453
1454 #[test]
1455 fn moonshot_neighboring_kimi_paths_are_unknown_not_metered() {
1456 let _lock = crate::test_support::lock_test_env();
1457 let _env = moonshot_endpoint_env_lock();
1458 // A Kimi-hosted path that is not the exact membership endpoint names
1459 // no product we can stand behind. It must claim neither the Kimi Code
1460 // quota nor Moonshot's metered price list — the pre-dispatch and
1461 // receipt answers are the same fail-closed Unknown.
1462 for base_url in [
1463 "https://api.kimi.com/coding/v2",
1464 "https://api.kimi.com/v1",
1465 "https://api.kimi.com/coding/v1/preview",
1466 ] {
1467 let config = config_with(
1468 ApiProvider::Moonshot,
1469 ProviderConfig {
1470 base_url: Some(base_url.to_string()),
1471 ..ProviderConfig::default()
1472 },
1473 );
1474 let billing = for_route(&config, ApiProvider::Moonshot);
1475 assert_eq!(
1476 billing,
1477 BillingPresentation::Unknown,
1478 "{base_url} must claim neither Kimi Code quota nor metered dollars"
1479 );
1480 assert!(!billing.shows_money());
1481 assert_eq!(
1482 billing,
1483 for_dispatched_route(
1484 &config,
1485 DispatchedRoute {
1486 provider: ApiProvider::Moonshot,
1487 base_url,
1488 },
1489 )
1490 );
1491 }
1492 }
1493
1494 /// The second release blocker. `apply_env_overrides` merges
1495 /// `MOONSHOT_BASE_URL`/`KIMI_BASE_URL` into the ACTIVE provider's table
1496 /// only, so a Moonshot child spawned from (say) a DeepSeek session has an
1497 /// empty `[providers.moonshot]` entry no matter what the operator
1498 /// exported. Re-reading that config calls a membership route metered;
1499 /// the dispatch receipt — the endpoint the child's client was actually
1500 /// built with — tells the truth.
1501 #[test]
1502 fn dispatched_moonshot_receipt_owns_billing_over_any_later_config_state() {
1503 let _lock = crate::test_support::lock_test_env();
1504 // Env-only endpoint selection: nothing is in the provider table.
1505 let _generic = crate::test_support::EnvVarGuard::remove("CODEWHALE_BASE_URL");
1506 let _legacy = crate::test_support::EnvVarGuard::remove("DEEPSEEK_BASE_URL");
1507 let _moonshot = crate::test_support::EnvVarGuard::remove("MOONSHOT_BASE_URL");
1508 let _kimi = crate::test_support::EnvVarGuard::set(
1509 "KIMI_BASE_URL",
1510 "https://api.kimi.com/coding/v1",
1511 );
1512 let config = config_with(ApiProvider::Moonshot, ProviderConfig::default());
1513
1514 // The pre-dispatch answer resolves the same env-selected endpoint
1515 // instead of reading the empty provider table — that blind spot is
1516 // what let an imported-token membership route look metered.
1517 assert_eq!(
1518 for_route(&config, ApiProvider::Moonshot),
1519 BillingPresentation::Subscription("Kimi Code quota")
1520 );
1521
1522 // A receipt still wins outright. A turn dispatched on the direct
1523 // platform bills metered even though the config resolves to the
1524 // membership host now.
1525 assert_eq!(
1526 for_dispatched_route(
1527 &config,
1528 DispatchedRoute {
1529 provider: ApiProvider::Moonshot,
1530 base_url: "https://api.moonshot.ai/v1",
1531 },
1532 ),
1533 BillingPresentation::Metered,
1534 "the endpoint the turn actually dispatched to owns its billing"
1535 );
1536 assert_eq!(
1537 for_dispatched_route(
1538 &config,
1539 DispatchedRoute {
1540 provider: ApiProvider::Moonshot,
1541 base_url: "https://api.kimi.com/coding/v1",
1542 },
1543 ),
1544 BillingPresentation::Subscription("Kimi Code quota")
1545 );
1546 }
1547
1548 /// A dispatched endpoint must NAME a known product. The exact direct
1549 /// platform is metered; a gateway host, a neighboring Kimi path, and a
1550 /// blank receipt are all ambiguous and fail closed.
1551 #[test]
1552 fn dispatched_moonshot_endpoint_must_name_a_known_product() {
1553 assert_eq!(
1554 for_dispatched_route(
1555 &Config::default(),
1556 DispatchedRoute {
1557 provider: ApiProvider::Moonshot,
1558 base_url: "https://api.moonshot.ai/v1",
1559 },
1560 ),
1561 BillingPresentation::Metered
1562 );
1563 for ambiguous in [
1564 "",
1565 " ",
1566 "https://api.kimi.com/v1",
1567 "https://api.kimi.com/coding/v1/preview",
1568 "https://gateway.internal.example/v1",
1569 ] {
1570 let billing = for_dispatched_route(
1571 &Config::default(),
1572 DispatchedRoute {
1573 provider: ApiProvider::Moonshot,
1574 base_url: ambiguous,
1575 },
1576 );
1577 assert_eq!(
1578 billing,
1579 BillingPresentation::Unknown,
1580 "{ambiguous:?} names no Moonshot product"
1581 );
1582 assert!(!billing.shows_money());
1583 }
1584 }
1585
1586 #[test]
1587 fn codex_oauth_never_claims_api_dollars() {
1588 assert_eq!(
1589 for_route(&Config::default(), ApiProvider::OpenaiCodex),
1590 BillingPresentation::Subscription("Codex OAuth quota")
1591 );
1592 let chip = usage_chip(
1593 BillingPresentation::Subscription("Codex OAuth quota"),
1594 ApiProvider::OpenaiCodex,
1595 "gpt-5.5",
1596 12.34,
1597 CostCurrency::Usd,
1598 None,
1599 );
1600 assert_eq!(
1601 format_usage_chip(&chip, codewhale_localization::Locale::En).as_deref(),
1602 Some("usage: Codex OAuth quota")
1603 );
1604 assert!(
1605 !format_usage_chip(&chip, codewhale_localization::Locale::En)
1606 .unwrap_or_default()
1607 .contains('$')
1608 );
1609 }
1610
1611 #[test]
1612 fn xai_api_key_fallback_is_metered_when_external_oauth_is_unavailable() {
1613 let _lock = crate::test_support::lock_test_env();
1614 let temp = tempfile::tempdir().expect("xAI billing fixture");
1615 let grok_path = temp.path().join("external-grok-auth.json");
1616 std::fs::write(&grok_path, "must-never-be-read").expect("external trap");
1617 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
1618 let _grok = crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &grok_path);
1619
1620 let config = config_with(
1621 ApiProvider::Xai,
1622 ProviderConfig {
1623 auth_mode: Some("oauth".to_string()),
1624 api_key: Some("xai-api-key".to_string()),
1625 ..ProviderConfig::default()
1626 },
1627 );
1628 crate::external_credentials::reset_side_effect_trap();
1629 assert_eq!(
1630 for_route(&config, ApiProvider::Xai),
1631 BillingPresentation::Metered
1632 );
1633 assert_eq!(
1634 crate::external_credentials::side_effect_trap_counts(),
1635 (0, 0)
1636 );
1637 assert_eq!(
1638 std::fs::read_to_string(grok_path).expect("external trap unchanged"),
1639 "must-never-be-read"
1640 );
1641 }
1642
1643 #[test]
1644 fn opencode_go_quota_never_claims_token_dollars() {
1645 let billing = for_route(&Config::default(), ApiProvider::OpencodeGo);
1646 assert_eq!(
1647 billing,
1648 BillingPresentation::Subscription("OpenCode Go quota")
1649 );
1650 let chip = usage_chip(
1651 billing,
1652 ApiProvider::OpencodeGo,
1653 "deepseek-v4-pro",
1654 12.34,
1655 CostCurrency::Usd,
1656 None,
1657 );
1658 assert!(
1659 !format_usage_chip(&chip, codewhale_localization::Locale::En)
1660 .unwrap_or_default()
1661 .contains('$')
1662 );
1663 assert_eq!(
1664 for_child_route(
1665 ApiProvider::Deepseek,
1666 BillingPresentation::Metered,
1667 ApiProvider::OpencodeGo,
1668 None,
1669 ),
1670 BillingPresentation::Unknown,
1671 "provider identity alone must not claim OpenCode Go quota"
1672 );
1673 assert_eq!(
1674 for_child_route(
1675 ApiProvider::Deepseek,
1676 BillingPresentation::Metered,
1677 ApiProvider::OpencodeGo,
1678 Some(BillingPresentation::Subscription("OpenCode Go quota")),
1679 ),
1680 BillingPresentation::Subscription("OpenCode Go quota"),
1681 "the child's own route truth is what may claim the quota"
1682 );
1683 }
1684
1685 #[test]
1686 fn zai_coding_plan_endpoint_never_claims_api_dollars() {
1687 let config = config_with(
1688 ApiProvider::Zai,
1689 ProviderConfig {
1690 base_url: Some("https://api.z.ai/api/coding/paas/v4".to_string()),
1691 ..ProviderConfig::default()
1692 },
1693 );
1694 let billing = for_route(&config, ApiProvider::Zai);
1695 assert_eq!(
1696 billing,
1697 BillingPresentation::Subscription("Z.ai Coding Plan quota")
1698 );
1699 let chip = usage_chip(
1700 billing,
1701 ApiProvider::Zai,
1702 "glm-5.2",
1703 0.05,
1704 CostCurrency::Usd,
1705 None,
1706 );
1707 assert!(
1708 !format_usage_chip(&chip, codewhale_localization::Locale::En)
1709 .unwrap_or_default()
1710 .contains('$')
1711 );
1712 }
1713
1714 #[test]
1715 fn zai_default_coding_endpoint_never_claims_api_dollars() {
1716 // The route resolves its shipped default, so the ambient generic
1717 // endpoint override has to be locked out for the assertion to be
1718 // about the default at all.
1719 let _lock = crate::test_support::lock_test_env();
1720 let _generic = crate::test_support::EnvVarGuard::remove("CODEWHALE_BASE_URL");
1721 let _legacy = crate::test_support::EnvVarGuard::remove("DEEPSEEK_BASE_URL");
1722 let config = config_with(ApiProvider::Zai, ProviderConfig::default());
1723 assert_eq!(
1724 for_route(&config, ApiProvider::Zai),
1725 BillingPresentation::Subscription("Z.ai Coding Plan quota")
1726 );
1727 }
1728
1729 #[test]
1730 fn stepfun_payg_shows_money_but_step_plan_stays_subscription_billed() {
1731 // Same reason as the Z.ai default test: the PAYG half asserts against
1732 // StepFun's shipped default endpoint.
1733 let _lock = crate::test_support::lock_test_env();
1734 let _generic = crate::test_support::EnvVarGuard::remove("CODEWHALE_BASE_URL");
1735 let _legacy = crate::test_support::EnvVarGuard::remove("DEEPSEEK_BASE_URL");
1736 let payg_billing = for_route(&Config::default(), ApiProvider::Stepfun);
1737 assert_eq!(payg_billing, BillingPresentation::Metered);
1738 let payg_chip = usage_chip(
1739 payg_billing,
1740 ApiProvider::Stepfun,
1741 crate::config::DEFAULT_STEPFUN_MODEL,
1742 0.42,
1743 CostCurrency::Usd,
1744 None,
1745 );
1746 assert_eq!(
1747 format_usage_chip(&payg_chip, codewhale_localization::Locale::En).as_deref(),
1748 Some("$0.42")
1749 );
1750
1751 let plan_config = config_with(
1752 ApiProvider::Stepfun,
1753 ProviderConfig {
1754 base_url: Some("https://api.stepfun.ai/step_plan/v1".to_string()),
1755 ..ProviderConfig::default()
1756 },
1757 );
1758 let plan_billing = for_route(&plan_config, ApiProvider::Stepfun);
1759 assert_eq!(
1760 plan_billing,
1761 BillingPresentation::Subscription("StepFun Step Plan quota")
1762 );
1763 let plan_chip = usage_chip(
1764 plan_billing,
1765 ApiProvider::Stepfun,
1766 crate::config::DEFAULT_STEPFUN_MODEL,
1767 0.42,
1768 CostCurrency::Usd,
1769 None,
1770 );
1771 assert!(
1772 !format_usage_chip(&plan_chip, codewhale_localization::Locale::En)
1773 .unwrap_or_default()
1774 .contains('$')
1775 );
1776
1777 assert_eq!(
1778 for_child_route(
1779 ApiProvider::Deepseek,
1780 BillingPresentation::Metered,
1781 ApiProvider::Stepfun,
1782 None,
1783 ),
1784 BillingPresentation::Unknown
1785 );
1786 }
1787
1788 /// A dual-mode child provider with no dispatch config is *unknown*, not a
1789 /// subscription. It still never shows dollars, but the distinction is what
1790 /// keeps its spend inside `/cost`'s coverage denominator instead of being
1791 /// excused as quota-billed (#4318).
1792 #[test]
1793 fn routed_zai_child_never_claims_api_dollars_without_full_route_config() {
1794 let billing = for_child_route(
1795 ApiProvider::Deepseek,
1796 BillingPresentation::Metered,
1797 ApiProvider::Zai,
1798 None,
1799 );
1800 assert_eq!(
1801 billing,
1802 BillingPresentation::Unknown,
1803 "without the child's route truth, fail closed instead of guessing a quota"
1804 );
1805 assert!(!billing.shows_money());
1806 assert_eq!(billing.label(), Some("unknown"));
1807 }
1808
1809 /// Child-route billing for each shape a child can take. Without the
1810 /// child's own provenance, only a local runtime is exactly non-metered;
1811 /// every other cross-provider child fails closed to Unknown, and Unknown
1812 /// (unlike a subscription label) keeps the turn inside `/cost`'s money
1813 /// coverage denominator instead of excusing it as quota-billed (#4318).
1814 #[test]
1815 fn child_route_billing_fails_closed_for_every_ambiguous_provider() {
1816 use crate::pricing::UnpricedReason;
1817
1818 let usage = codewhale_models::Usage {
1819 input_tokens: 10_000,
1820 output_tokens: 1_000,
1821 ..Default::default()
1822 };
1823 let now = chrono::Utc::now();
1824
1825 // Nothing about a provider name — not an aggregator, not a first-party
1826 // PAYG API, not an OAuth-only broker — is evidence of what this child
1827 // turn billed. Every one of them is Unknown without provenance, and
1828 // the cost audit counts them toward money coverage rather than
1829 // excusing them.
1830 for provider in [
1831 ApiProvider::Openrouter,
1832 ApiProvider::Openai,
1833 ApiProvider::Zai,
1834 ApiProvider::Moonshot,
1835 ApiProvider::Anthropic,
1836 ApiProvider::XiaomiMimo,
1837 ApiProvider::Xai,
1838 ApiProvider::Minimax,
1839 ApiProvider::MinimaxAnthropic,
1840 ApiProvider::Stepfun,
1841 ApiProvider::Custom,
1842 ApiProvider::OpenaiCodex,
1843 ApiProvider::OpencodeGo,
1844 ] {
1845 let billing = for_child_route(
1846 ApiProvider::Deepseek,
1847 BillingPresentation::Metered,
1848 provider,
1849 None,
1850 );
1851 assert_eq!(billing, BillingPresentation::Unknown, "{provider:?}");
1852 assert!(!billing.shows_money(), "{provider:?}");
1853 let audit = crate::pricing::audit_turn_cost_for_route(
1854 provider,
1855 "some-model",
1856 None,
1857 &usage,
1858 now,
1859 billing,
1860 );
1861 assert_eq!(
1862 audit.unpriced_reason,
1863 Some(UnpricedReason::UnknownBillingBasis),
1864 "{provider:?}"
1865 );
1866 assert!(
1867 audit.counts_toward_money_coverage(),
1868 "{provider:?} must stay in the coverage denominator"
1869 );
1870 }
1871
1872 // A local runtime has no provider bill under any configuration, so it
1873 // is exactly non-metered and is excluded from money coverage.
1874 for provider in [ApiProvider::Ollama, ApiProvider::Sglang, ApiProvider::Vllm] {
1875 let billing = for_child_route(
1876 ApiProvider::Deepseek,
1877 BillingPresentation::Metered,
1878 provider,
1879 None,
1880 );
1881 assert_eq!(billing, BillingPresentation::Local, "{provider:?}");
1882 let audit = crate::pricing::audit_turn_cost_for_route(
1883 provider,
1884 "some-model",
1885 None,
1886 &usage,
1887 now,
1888 billing,
1889 );
1890 assert_eq!(
1891 audit.unpriced_reason,
1892 Some(UnpricedReason::NotMoneyMetered),
1893 "{provider:?}"
1894 );
1895 assert!(!audit.counts_toward_money_coverage(), "{provider:?}");
1896 }
1897
1898 // A child on the parent's own provider ran the parent's exact route,
1899 // so it inherits the parent's frozen billing — the one inheritance
1900 // that is a fact rather than a guess.
1901 assert_eq!(
1902 for_child_route(
1903 ApiProvider::Deepseek,
1904 BillingPresentation::Metered,
1905 ApiProvider::Deepseek,
1906 None,
1907 ),
1908 BillingPresentation::Metered
1909 );
1910
1911 // The child's own captured provenance is the only thing that prices
1912 // (or excuses) the route.
1913 assert_eq!(
1914 for_child_route(
1915 ApiProvider::Deepseek,
1916 BillingPresentation::Metered,
1917 ApiProvider::Openrouter,
1918 Some(BillingPresentation::Metered),
1919 ),
1920 BillingPresentation::Metered
1921 );
1922 assert_eq!(
1923 for_child_route(
1924 ApiProvider::Deepseek,
1925 BillingPresentation::Metered,
1926 ApiProvider::Anthropic,
1927 Some(BillingPresentation::Subscription("Claude OAuth quota")),
1928 ),
1929 BillingPresentation::Subscription("Claude OAuth quota")
1930 );
1931 }
1932
1933 #[test]
1934 fn oauth_allowance_percent_is_shown_when_provider_supplies_it() {
1935 let chip = usage_chip(
1936 BillingPresentation::Subscription("Grok OAuth quota"),
1937 ApiProvider::Xai,
1938 "grok-4",
1939 0.0,
1940 CostCurrency::Usd,
1941 Some(37.0),
1942 );
1943 assert_eq!(
1944 format_usage_chip(&chip, codewhale_localization::Locale::En).as_deref(),
1945 Some("usage: Grok OAuth quota · 37%")
1946 );
1947 }
1948
1949 #[test]
1950 fn api_key_metered_shows_dollars_only_with_priced_positive_spend() {
1951 let billing = BillingPresentation::Metered;
1952 assert!(has_priced_metered_basis(
1953 billing,
1954 ApiProvider::Deepseek,
1955 "deepseek-v4-flash"
1956 ));
1957 let spent = usage_chip(
1958 billing,
1959 ApiProvider::Deepseek,
1960 "deepseek-v4-flash",
1961 0.42,
1962 CostCurrency::Usd,
1963 None,
1964 );
1965 assert_eq!(
1966 format_usage_chip(&spent, codewhale_localization::Locale::En).as_deref(),
1967 Some("$0.42")
1968 );
1969
1970 let zero = usage_chip(
1971 billing,
1972 ApiProvider::Deepseek,
1973 "deepseek-v4-flash",
1974 0.0,
1975 CostCurrency::Usd,
1976 None,
1977 );
1978 assert_eq!(zero, UsageChip::Hidden);
1979 assert!(format_usage_chip(&zero, codewhale_localization::Locale::En).is_none());
1980 assert!(
1981 !format_usage_chip(&zero, codewhale_localization::Locale::En)
1982 .unwrap_or_default()
1983 .contains('$')
1984 );
1985 }
1986
1987 #[test]
1988 fn local_free_routes_never_show_dollars() {
1989 assert_eq!(
1990 for_route(&Config::default(), ApiProvider::Ollama),
1991 BillingPresentation::Local
1992 );
1993 let chip = usage_chip(
1994 BillingPresentation::Local,
1995 ApiProvider::Ollama,
1996 "llama3.2",
1997 9.99,
1998 CostCurrency::Usd,
1999 None,
2000 );
2001 assert_eq!(
2002 format_usage_chip(&chip, codewhale_localization::Locale::En).as_deref(),
2003 Some("cost: local")
2004 );
2005 assert!(
2006 !format_usage_chip(&chip, codewhale_localization::Locale::En)
2007 .unwrap_or_default()
2008 .contains('$')
2009 );
2010 }
2011
2012 #[test]
2013 fn ollama_cloud_is_unknown_and_counts_as_possible_spend() {
2014 let config = Config {
2015 provider: Some("ollama-cloud".to_string()),
2016 providers: Some(crate::config::ProvidersConfig {
2017 ollama_cloud: crate::config::ProviderConfig {
2018 api_key: Some("cloud-key".to_string()),
2019 ..Default::default()
2020 },
2021 ..Default::default()
2022 }),
2023 ..Default::default()
2024 };
2025 let billing = for_route(&config, ApiProvider::OllamaCloud);
2026 assert_eq!(billing, BillingPresentation::Unknown);
2027 assert!(!billing.shows_money());
2028
2029 let audit = crate::pricing::audit_turn_cost_for_route(
2030 ApiProvider::OllamaCloud,
2031 crate::config::DEFAULT_OLLAMA_CLOUD_MODEL,
2032 Some(crate::pricing::UNCLASSIFIED_BILLING_SURFACE),
2033 &Usage {
2034 input_tokens: 1_000,
2035 output_tokens: 100,
2036 ..Usage::default()
2037 },
2038 chrono::Utc::now(),
2039 billing,
2040 );
2041 assert_eq!(
2042 audit.unpriced_reason,
2043 Some(crate::pricing::UnpricedReason::UnknownBillingBasis)
2044 );
2045 assert!(audit.counts_toward_money_coverage());
2046 }
2047
2048 #[test]
2049 fn unknown_is_unknown_not_zero_dollars() {
2050 let chip = usage_chip(
2051 BillingPresentation::Metered,
2052 ApiProvider::NvidiaNim,
2053 "deepseek-ai/deepseek-v4-pro",
2054 0.0,
2055 CostCurrency::Usd,
2056 None,
2057 );
2058 assert_eq!(chip, UsageChip::Unknown(vec![UnpricedReason::NoPricingRow]));
2059 assert_eq!(
2060 format_usage_chip(&chip, codewhale_localization::Locale::En).as_deref(),
2061 Some("cost: unknown (rate unavailable)")
2062 );
2063 assert!(
2064 !format_usage_chip(&chip, codewhale_localization::Locale::En)
2065 .unwrap_or_default()
2066 .contains('$')
2067 );
2068
2069 let unknown_billing = usage_chip(
2070 BillingPresentation::Unknown,
2071 ApiProvider::Custom,
2072 "anything",
2073 1.23,
2074 CostCurrency::Usd,
2075 None,
2076 );
2077 assert_eq!(
2078 unknown_billing,
2079 UsageChip::Unknown(vec![UnpricedReason::UnknownBillingBasis])
2080 );
2081 assert!(
2082 !format_usage_chip(&unknown_billing, codewhale_localization::Locale::En)
2083 .unwrap_or_default()
2084 .contains('$')
2085 );
2086 }
2087
2088 #[test]
2089 fn xai_oauth_and_api_key_routes_stay_distinct() {
2090 let _lock = crate::test_support::lock_test_env();
2091 let temp = tempfile::tempdir().expect("xAI owned credential fixture");
2092 let owned_home = temp
2093 .path()
2094 .canonicalize()
2095 .expect("canonical xAI owned credential fixture");
2096 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &owned_home);
2097 let owned_path = owned_home.join("credentials/xai-auth.json");
2098 std::fs::create_dir_all(owned_path.parent().expect("owned credential parent"))
2099 .expect("create owned credential directory");
2100 #[cfg(windows)]
2101 crate::external_credentials::secure_codewhale_owned_windows_path(
2102 owned_path.parent().expect("owned credential parent"),
2103 true,
2104 )
2105 .expect("secure owned credential directory");
2106 let scope = format!(
2107 "{}::{}",
2108 crate::oauth::XAI_OIDC_ISSUER,
2109 crate::oauth::GROK_OIDC_CLIENT_ID
2110 );
2111 std::fs::write(
2112 &owned_path,
2113 serde_json::json!({
2114 scope: {
2115 "key": crate::test_support::future_test_jwt("billing"),
2116 "auth_mode": "oidc"
2117 }
2118 })
2119 .to_string(),
2120 )
2121 .expect("write Codewhale-owned xAI credential");
2122 #[cfg(unix)]
2123 {
2124 use std::os::unix::fs::PermissionsExt as _;
2125 std::fs::set_permissions(&owned_path, std::fs::Permissions::from_mode(0o600))
2126 .expect("secure owned credential file");
2127 }
2128 #[cfg(windows)]
2129 crate::external_credentials::secure_codewhale_owned_windows_path(&owned_path, false)
2130 .expect("secure owned credential file");
2131 let oauth = config_with(
2132 ApiProvider::Xai,
2133 ProviderConfig {
2134 auth_mode: Some("grok-oauth".to_string()),
2135 ..ProviderConfig::default()
2136 },
2137 );
2138 let api = config_with(
2139 ApiProvider::Xai,
2140 ProviderConfig {
2141 auth_mode: Some("api-key".to_string()),
2142 ..ProviderConfig::default()
2143 },
2144 );
2145 assert!(!for_route(&oauth, ApiProvider::Xai).shows_money());
2146 assert!(for_route(&api, ApiProvider::Xai).shows_money());
2147 }
2148
2149 #[test]
2150 fn future_claude_oauth_does_not_inherit_anthropic_api_prices() {
2151 let oauth = config_with(
2152 ApiProvider::Anthropic,
2153 ProviderConfig {
2154 auth_mode: Some("claude-code".to_string()),
2155 ..ProviderConfig::default()
2156 },
2157 );
2158 assert_eq!(
2159 for_route(&oauth, ApiProvider::Anthropic).label(),
2160 Some("Claude OAuth quota")
2161 );
2162 }
2163
2164 #[test]
2165 fn xiaomi_defaults_to_token_plan_but_explicit_payg_is_metered() {
2166 let _lock = crate::test_support::lock_test_env();
2167 let _mode = crate::test_support::EnvVarGuard::remove("XIAOMI_MIMO_MODE");
2168 let _base = crate::test_support::EnvVarGuard::remove("XIAOMI_MIMO_BASE_URL");
2169 let _token = crate::test_support::EnvVarGuard::remove("XIAOMI_MIMO_TOKEN_PLAN_API_KEY");
2170 let _token_alias = crate::test_support::EnvVarGuard::remove("MIMO_TOKEN_PLAN_API_KEY");
2171 let _standard_a = crate::test_support::EnvVarGuard::remove("XIAOMI_MIMO_API_KEY");
2172 let _standard_b = crate::test_support::EnvVarGuard::remove("XIAOMI_API_KEY");
2173 let _standard_c = crate::test_support::EnvVarGuard::remove("MIMO_API_KEY");
2174 assert!(!for_route(&Config::default(), ApiProvider::XiaomiMimo).shows_money());
2175 let payg = config_with(
2176 ApiProvider::XiaomiMimo,
2177 ProviderConfig {
2178 mode: Some("pay-as-you-go".to_string()),
2179 ..ProviderConfig::default()
2180 },
2181 );
2182 assert!(for_route(&payg, ApiProvider::XiaomiMimo).shows_money());
2183 let standard_key = config_with(
2184 ApiProvider::XiaomiMimo,
2185 ProviderConfig {
2186 api_key: Some("sk-standard".to_string()),
2187 ..ProviderConfig::default()
2188 },
2189 );
2190 assert!(for_route(&standard_key, ApiProvider::XiaomiMimo).shows_money());
2191 }
2192
2193 #[test]
2194 fn minimax_requires_an_explicit_saved_billing_mode() {
2195 let _lock = crate::test_support::lock_test_env();
2196 let _env = minimax_env_guard();
2197 for provider in [ApiProvider::Minimax, ApiProvider::MinimaxAnthropic] {
2198 assert_eq!(
2199 for_route(&Config::default(), provider),
2200 BillingPresentation::Unknown
2201 );
2202 assert_eq!(
2203 for_endpoint_without_config(provider, Some(provider.default_base_url())),
2204 BillingPresentation::Unknown
2205 );
2206
2207 let payg = config_with(
2208 provider,
2209 ProviderConfig {
2210 mode: Some("pay-as-you-go".to_string()),
2211 ..ProviderConfig::default()
2212 },
2213 );
2214 assert_eq!(for_route(&payg, provider), BillingPresentation::Metered);
2215 assert_eq!(
2216 billing_surface_for_dispatch(
2217 Some(&payg),
2218 provider,
2219 Some(provider.default_base_url())
2220 ),
2221 Some(crate::pricing::MINIMAX_PAYG_BILLING_SURFACE)
2222 );
2223
2224 let plan = config_with(
2225 provider,
2226 ProviderConfig {
2227 mode: Some("subscription-plan".to_string()),
2228 ..ProviderConfig::default()
2229 },
2230 );
2231 assert_eq!(
2232 for_route(&plan, provider),
2233 // The product's own name, not a generic "subscription plan":
2234 // MiniMax sells PAYG and Token Plan over the same endpoint.
2235 BillingPresentation::Subscription("MiniMax Token Plan quota")
2236 );
2237 assert_eq!(
2238 billing_surface_for_dispatch(
2239 Some(&plan),
2240 provider,
2241 Some(provider.default_base_url())
2242 ),
2243 Some(crate::pricing::MINIMAX_TOKEN_PLAN_BILLING_SURFACE)
2244 );
2245 }
2246 }
2247
2248 #[test]
2249 fn unknown_cross_provider_oauth_capable_child_never_invents_dollars() {
2250 assert!(
2251 !for_child_route(
2252 ApiProvider::Deepseek,
2253 BillingPresentation::Metered,
2254 ApiProvider::Xai,
2255 None,
2256 )
2257 .shows_money()
2258 );
2259 // Identity alone no longer claims metered dollars either: without the
2260 // child's own route truth a cross-provider child fails closed.
2261 assert!(
2262 !for_child_route(
2263 ApiProvider::Deepseek,
2264 BillingPresentation::Metered,
2265 ApiProvider::Openrouter,
2266 None,
2267 )
2268 .shows_money()
2269 );
2270 // Unknown, not an invented "provider quota" subscription.
2271 assert_eq!(
2272 for_child_route(
2273 ApiProvider::Deepseek,
2274 BillingPresentation::Metered,
2275 ApiProvider::Xai,
2276 None,
2277 ),
2278 BillingPresentation::Unknown
2279 );
2280 // The child's own metered provenance is what prices the route.
2281 assert!(
2282 for_child_route(
2283 ApiProvider::Deepseek,
2284 BillingPresentation::Metered,
2285 ApiProvider::Openrouter,
2286 Some(BillingPresentation::Metered),
2287 )
2288 .shows_money()
2289 );
2290 }
2291
2292 #[test]
2293 fn standard_mimo_env_key_uses_metered_presentation() {
2294 let _lock = crate::test_support::lock_test_env();
2295 let _mode = crate::test_support::EnvVarGuard::remove("XIAOMI_MIMO_MODE");
2296 let _base = crate::test_support::EnvVarGuard::remove("XIAOMI_MIMO_BASE_URL");
2297 let _token = crate::test_support::EnvVarGuard::remove("XIAOMI_MIMO_TOKEN_PLAN_API_KEY");
2298 let _token_alias = crate::test_support::EnvVarGuard::remove("MIMO_TOKEN_PLAN_API_KEY");
2299 let _standard_a = crate::test_support::EnvVarGuard::remove("XIAOMI_MIMO_API_KEY");
2300 let _standard_b = crate::test_support::EnvVarGuard::remove("XIAOMI_API_KEY");
2301 let _standard = crate::test_support::EnvVarGuard::set("MIMO_API_KEY", "sk-metered");
2302
2303 assert!(for_route(&Config::default(), ApiProvider::XiaomiMimo).shows_money());
2304 }
2305
2306 #[test]
2307 fn custom_without_pay_mode_stays_unknown() {
2308 assert_eq!(
2309 for_route(&Config::default(), ApiProvider::Custom),
2310 BillingPresentation::Unknown
2311 );
2312 let mut metered_custom = Config {
2313 provider: Some("acme".to_string()),
2314 ..Config::default()
2315 };
2316 *metered_custom.provider_config_for_mut(ApiProvider::Custom) = ProviderConfig {
2317 auth_mode: Some("api-key".to_string()),
2318 ..ProviderConfig::default()
2319 };
2320 assert_eq!(
2321 for_route(&metered_custom, ApiProvider::Custom),
2322 BillingPresentation::Metered
2323 );
2324 }
2325
2326 /// Cross-provider dispatch receipts for the other endpoint-shaped routes.
2327 #[test]
2328 fn dispatched_endpoint_shaped_routes_classify_from_the_receipt() {
2329 let config = Config::default();
2330 // StepFun: plan endpoint, PAYG endpoint, unrecognized host.
2331 assert_eq!(
2332 for_dispatched_route(
2333 &config,
2334 DispatchedRoute {
2335 provider: ApiProvider::Stepfun,
2336 base_url: "https://api.stepfun.ai/step_plan/v1",
2337 },
2338 ),
2339 BillingPresentation::Subscription("StepFun Step Plan quota")
2340 );
2341 assert_eq!(
2342 for_dispatched_route(
2343 &config,
2344 DispatchedRoute {
2345 provider: ApiProvider::Stepfun,
2346 base_url: crate::config::DEFAULT_STEPFUN_BASE_URL,
2347 },
2348 ),
2349 BillingPresentation::Metered
2350 );
2351 assert_eq!(
2352 for_dispatched_route(
2353 &config,
2354 DispatchedRoute {
2355 provider: ApiProvider::Stepfun,
2356 base_url: "https://gateway.internal.example/v1",
2357 },
2358 ),
2359 BillingPresentation::Unknown
2360 );
2361 // Z.ai: the Coding Plan path is quota-billed; a blank receipt is not
2362 // an excuse to fall back to the plan default.
2363 assert_eq!(
2364 for_dispatched_route(
2365 &config,
2366 DispatchedRoute {
2367 provider: ApiProvider::Zai,
2368 base_url: "https://api.z.ai/api/coding/paas/v4",
2369 },
2370 ),
2371 BillingPresentation::Subscription("Z.ai Coding Plan quota")
2372 );
2373 assert_eq!(
2374 for_dispatched_route(
2375 &config,
2376 DispatchedRoute {
2377 provider: ApiProvider::Zai,
2378 base_url: "",
2379 },
2380 ),
2381 BillingPresentation::Unknown
2382 );
2383 // Identity-owned routes are unchanged by the receipt.
2384 assert_eq!(
2385 for_dispatched_route(
2386 &config,
2387 DispatchedRoute {
2388 provider: ApiProvider::Ollama,
2389 base_url: "http://localhost:11434/v1",
2390 },
2391 ),
2392 BillingPresentation::Local
2393 );
2394 assert_eq!(
2395 for_dispatched_route(
2396 &config,
2397 DispatchedRoute {
2398 provider: ApiProvider::OpenaiCodex,
2399 base_url: "https://chatgpt.com/backend-api/codex",
2400 },
2401 ),
2402 BillingPresentation::Subscription("Codex OAuth quota")
2403 );
2404 }
2405
2406 #[test]
2407 fn minimax_defaults_to_pay_as_you_go_metered() {
2408 let _lock = crate::test_support::lock_test_env();
2409 let _key = crate::test_support::EnvVarGuard::remove("MINIMAX_API_KEY");
2410 let config = config_with(
2411 ApiProvider::Minimax,
2412 ProviderConfig {
2413 base_url: Some("https://api.minimax.io/v1".to_string()),
2414 api_key: Some("sk-test-payg-key".to_string()),
2415 ..ProviderConfig::default()
2416 },
2417 );
2418 let billing = for_route(&config, ApiProvider::Minimax);
2419 assert_eq!(billing, BillingPresentation::Metered);
2420 assert!(billing.shows_money());
2421 let chip = usage_chip(
2422 billing,
2423 ApiProvider::Minimax,
2424 "MiniMax-M3",
2425 0.42,
2426 CostCurrency::Usd,
2427 None,
2428 );
2429 assert!(matches!(chip, UsageChip::Money(_)));
2430 assert!(
2431 format_usage_chip(&chip, codewhale_localization::Locale::En)
2432 .unwrap_or_default()
2433 .contains('$')
2434 );
2435 }
2436
2437 #[test]
2438 fn minimax_explicit_token_plan_mode_is_subscription_quota() {
2439 let _lock = crate::test_support::lock_test_env();
2440 let _key = crate::test_support::EnvVarGuard::remove("MINIMAX_API_KEY");
2441 let config = config_with(
2442 ApiProvider::Minimax,
2443 ProviderConfig {
2444 mode: Some("token-plan".to_string()),
2445 api_key: Some("sk-test-payg-key".to_string()),
2446 ..ProviderConfig::default()
2447 },
2448 );
2449 let billing = for_route(&config, ApiProvider::Minimax);
2450 assert_eq!(
2451 billing,
2452 BillingPresentation::Subscription("MiniMax Token Plan quota")
2453 );
2454 assert!(!billing.shows_money());
2455 // `MiniMax-M3` is priced on the metered route; the subscription
2456 // classification must still win over the priced row.
2457 let chip = usage_chip(
2458 billing,
2459 ApiProvider::Minimax,
2460 "MiniMax-M3",
2461 12.34,
2462 CostCurrency::Usd,
2463 None,
2464 );
2465 assert!(!matches!(chip, UsageChip::Money(_)));
2466 assert!(
2467 !format_usage_chip(&chip, codewhale_localization::Locale::En)
2468 .unwrap_or_default()
2469 .contains('$')
2470 );
2471 }
2472
2473 #[test]
2474 fn minimax_sk_cp_config_key_is_subscription_quota() {
2475 let _lock = crate::test_support::lock_test_env();
2476 let _key = crate::test_support::EnvVarGuard::remove("MINIMAX_API_KEY");
2477 let config = config_with(
2478 ApiProvider::Minimax,
2479 ProviderConfig {
2480 api_key: Some("sk-cp-test-token-plan-key".to_string()),
2481 ..ProviderConfig::default()
2482 },
2483 );
2484 let billing = for_route(&config, ApiProvider::Minimax);
2485 assert_eq!(
2486 billing,
2487 BillingPresentation::Subscription("MiniMax Token Plan quota")
2488 );
2489 assert!(!billing.shows_money());
2490 }
2491
2492 /// The Anthropic-dialect MiniMax route is the same product behind a
2493 /// different wire protocol: same MINIMAX_API_KEY, same PAYG/Token Plan
2494 /// duality. Classifying only the chat-completions dialect would show
2495 /// invented dollars for a Token Plan key on `[providers.minimax_anthropic]`.
2496 #[test]
2497 fn minimax_anthropic_dialect_shares_the_token_plan_classification() {
2498 let _lock = crate::test_support::lock_test_env();
2499 let _key = crate::test_support::EnvVarGuard::remove("MINIMAX_API_KEY");
2500
2501 let plan = config_with(
2502 ApiProvider::MinimaxAnthropic,
2503 ProviderConfig {
2504 api_key: Some("sk-cp-test-token-plan-key".to_string()),
2505 ..ProviderConfig::default()
2506 },
2507 );
2508 let plan_billing = for_route(&plan, ApiProvider::MinimaxAnthropic);
2509 assert_eq!(
2510 plan_billing,
2511 BillingPresentation::Subscription("MiniMax Token Plan quota")
2512 );
2513 assert!(!plan_billing.shows_money());
2514
2515 let explicit_plan = config_with(
2516 ApiProvider::MinimaxAnthropic,
2517 ProviderConfig {
2518 mode: Some("token-plan".to_string()),
2519 api_key: Some("sk-test-payg-key".to_string()),
2520 ..ProviderConfig::default()
2521 },
2522 );
2523 assert_eq!(
2524 for_route(&explicit_plan, ApiProvider::MinimaxAnthropic),
2525 BillingPresentation::Subscription("MiniMax Token Plan quota")
2526 );
2527
2528 // Pay-as-you-go on the same dialect stays metered.
2529 let payg = config_with(
2530 ApiProvider::MinimaxAnthropic,
2531 ProviderConfig {
2532 api_key: Some("sk-test-payg-key".to_string()),
2533 ..ProviderConfig::default()
2534 },
2535 );
2536 let payg_billing = for_route(&payg, ApiProvider::MinimaxAnthropic);
2537 assert_eq!(payg_billing, BillingPresentation::Metered);
2538 assert!(payg_billing.shows_money());
2539 }
2540
2541 #[test]
2542 fn minimax_sk_cp_env_key_is_subscription_quota() {
2543 let _lock = crate::test_support::lock_test_env();
2544 let _key =
2545 crate::test_support::EnvVarGuard::set("MINIMAX_API_KEY", "sk-cp-test-token-plan-key");
2546 let config = config_with(ApiProvider::Minimax, ProviderConfig::default());
2547 assert_eq!(
2548 for_route(&config, ApiProvider::Minimax),
2549 BillingPresentation::Subscription("MiniMax Token Plan quota")
2550 );
2551 }
2552
2553 #[test]
2554 fn minimax_explicit_pay_as_you_go_wins_over_sk_cp_key() {
2555 let _lock = crate::test_support::lock_test_env();
2556 let _key = crate::test_support::EnvVarGuard::remove("MINIMAX_API_KEY");
2557 for mode in ["pay-as-you-go", "payg", "metered"] {
2558 let config = config_with(
2559 ApiProvider::Minimax,
2560 ProviderConfig {
2561 mode: Some(mode.to_string()),
2562 api_key: Some("sk-cp-test-token-plan-key".to_string()),
2563 ..ProviderConfig::default()
2564 },
2565 );
2566 let billing = for_route(&config, ApiProvider::Minimax);
2567 assert_eq!(
2568 billing,
2569 BillingPresentation::Metered,
2570 "explicit mode {mode} must win over the sk-cp key shape"
2571 );
2572 assert!(billing.shows_money());
2573 }
2574 }
2575
2576 /// Clear the only ambient variable `minimax_credential_product` reads, so
2577 /// a developer's real shell cannot decide a billing regression's outcome.
2578 fn minimax_env_guard() -> crate::test_support::EnvVarGuard {
2579 crate::test_support::EnvVarGuard::remove("MINIMAX_API_KEY")
2580 }
2581
2582 /// The release blocker: a MiniMax key saved through `codewhale auth set`
2583 /// lives in the secret store, so neither the config table nor
2584 /// `MINIMAX_API_KEY` carries a product marker. Classification must not
2585 /// open the secret store to find out, and must not silently call the
2586 /// route pay-as-you-go — a Token Plan account would then accrue invented
2587 /// dollars on every benchmark receipt.
2588 #[test]
2589 fn minimax_keyring_or_opaque_credential_is_unclassified_not_metered() {
2590 let _lock = crate::test_support::lock_test_env();
2591 let _env = minimax_env_guard();
2592 for provider in [ApiProvider::Minimax, ApiProvider::MinimaxAnthropic] {
2593 // No credential visible at all (keyring/OAuth/command-sourced).
2594 let opaque = config_with(provider, ProviderConfig::default());
2595 assert_eq!(
2596 for_route(&opaque, provider),
2597 BillingPresentation::Unknown,
2598 "{provider:?} must not claim pay-as-you-go it cannot prove"
2599 );
2600 // The legacy keyring placeholder is not a credential and carries
2601 // no product prefix.
2602 for sentinel in [crate::config::API_KEYRING_SENTINEL, " __KEYRING__ "] {
2603 let sentinel = config_with(
2604 provider,
2605 ProviderConfig {
2606 api_key: Some(sentinel.to_string()),
2607 ..ProviderConfig::default()
2608 },
2609 );
2610 assert_eq!(
2611 for_route(&sentinel, provider),
2612 BillingPresentation::Unknown,
2613 "{provider:?} keyring sentinel is not a pay-as-you-go proof"
2614 );
2615 }
2616 let chip = usage_chip(
2617 for_route(&opaque, provider),
2618 provider,
2619 "MiniMax-M3",
2620 12.34,
2621 CostCurrency::Usd,
2622 None,
2623 );
2624 assert_eq!(
2625 chip,
2626 UsageChip::Unknown(vec![UnpricedReason::UnknownBillingBasis])
2627 );
2628 assert!(
2629 !format_usage_chip(&chip, codewhale_localization::Locale::En)
2630 .unwrap_or_default()
2631 .contains('$')
2632 );
2633 }
2634 }
2635
2636 /// Provenance-by-source, both dialects: config value, route-bound
2637 /// `api_key_env`, and ambient `MINIMAX_API_KEY` are each sufficient to
2638 /// prove a product, and each proves it the same way.
2639 #[test]
2640 fn minimax_credential_provenance_classifies_both_dialects_identically() {
2641 let _lock = crate::test_support::lock_test_env();
2642 let _env = minimax_env_guard();
2643 for provider in [ApiProvider::Minimax, ApiProvider::MinimaxAnthropic] {
2644 // 1. Config-owned key.
2645 for (key, expected) in [
2646 (
2647 "sk-cp-plan-key",
2648 BillingPresentation::Subscription("MiniMax Token Plan quota"),
2649 ),
2650 ("sk-payg-key", BillingPresentation::Metered),
2651 ] {
2652 let config = config_with(
2653 provider,
2654 ProviderConfig {
2655 api_key: Some(key.to_string()),
2656 ..ProviderConfig::default()
2657 },
2658 );
2659 assert_eq!(for_route(&config, provider), expected, "{provider:?} {key}");
2660 }
2661
2662 // 2. Route-bound `api_key_env`: the binding is config-owned even
2663 // though the value is ambient.
2664 for (key, expected) in [
2665 (
2666 "sk-cp-plan-key",
2667 BillingPresentation::Subscription("MiniMax Token Plan quota"),
2668 ),
2669 ("sk-payg-key", BillingPresentation::Metered),
2670 ] {
2671 let _bound =
2672 crate::test_support::EnvVarGuard::set("CW_TEST_MINIMAX_BOUND_KEY", key);
2673 let config = config_with(
2674 provider,
2675 ProviderConfig {
2676 api_key_env: Some("CW_TEST_MINIMAX_BOUND_KEY".to_string()),
2677 ..ProviderConfig::default()
2678 },
2679 );
2680 assert_eq!(
2681 for_route(&config, provider),
2682 expected,
2683 "{provider:?} api_key_env {key}"
2684 );
2685 }
2686
2687 // 3. Ambient provider environment on an official endpoint.
2688 for (key, expected) in [
2689 (
2690 "sk-cp-plan-key",
2691 BillingPresentation::Subscription("MiniMax Token Plan quota"),
2692 ),
2693 ("sk-payg-key", BillingPresentation::Metered),
2694 ] {
2695 let _ambient = crate::test_support::EnvVarGuard::set("MINIMAX_API_KEY", key);
2696 let config = config_with(provider, ProviderConfig::default());
2697 assert_eq!(
2698 for_route(&config, provider),
2699 expected,
2700 "{provider:?} MINIMAX_API_KEY {key}"
2701 );
2702 }
2703 }
2704 }
2705
2706 /// Ambient provider credentials are never sent to a custom host, so an
2707 /// exported `MINIMAX_API_KEY` proves nothing about what a gateway route
2708 /// bills. That route is Unknown, not metered-by-default.
2709 #[test]
2710 fn minimax_ambient_key_does_not_classify_a_custom_endpoint() {
2711 let _lock = crate::test_support::lock_test_env();
2712 let _env = minimax_env_guard();
2713 let _ambient = crate::test_support::EnvVarGuard::set("MINIMAX_API_KEY", "sk-payg-key");
2714 let config = config_with(
2715 ApiProvider::Minimax,
2716 ProviderConfig {
2717 base_url: Some("https://gateway.internal.example/v1".to_string()),
2718 ..ProviderConfig::default()
2719 },
2720 );
2721 assert_eq!(
2722 for_route(&config, ApiProvider::Minimax),
2723 BillingPresentation::Unknown
2724 );
2725 }
2726
2727 /// An operator pay mode we do not recognize is not a product claim.
2728 #[test]
2729 fn minimax_unrecognized_pay_mode_is_unclassified() {
2730 let _lock = crate::test_support::lock_test_env();
2731 let _env = minimax_env_guard();
2732 let config = config_with(
2733 ApiProvider::Minimax,
2734 ProviderConfig {
2735 mode: Some("enterprise-committed-spend".to_string()),
2736 api_key: Some("sk-cp-plan-key".to_string()),
2737 ..ProviderConfig::default()
2738 },
2739 );
2740 assert_eq!(
2741 for_route(&config, ApiProvider::Minimax),
2742 BillingPresentation::Unknown
2743 );
2744 }
2745
2746 /// MiniMax billing is credential-shaped, not endpoint-shaped: a dispatch
2747 /// receipt pointing at the shipped default URL still cannot invent a
2748 /// product.
2749 #[test]
2750 fn dispatched_minimax_default_endpoint_does_not_invent_a_product() {
2751 let _lock = crate::test_support::lock_test_env();
2752 let _env = minimax_env_guard();
2753 let config = config_with(ApiProvider::Minimax, ProviderConfig::default());
2754 assert_eq!(
2755 for_dispatched_route(
2756 &config,
2757 DispatchedRoute {
2758 provider: ApiProvider::Minimax,
2759 base_url: "https://api.minimax.io/v1",
2760 },
2761 ),
2762 BillingPresentation::Unknown
2763 );
2764 }
2765
2766 #[test]
2767 fn same_provider_child_without_provenance_inherits_parent_billing() {
2768 assert_eq!(
2769 for_child_route(
2770 ApiProvider::Moonshot,
2771 BillingPresentation::Subscription("Kimi Code quota"),
2772 ApiProvider::Moonshot,
2773 None,
2774 ),
2775 BillingPresentation::Subscription("Kimi Code quota")
2776 );
2777 assert_eq!(
2778 for_child_route(
2779 ApiProvider::Minimax,
2780 BillingPresentation::Metered,
2781 ApiProvider::Minimax,
2782 None,
2783 ),
2784 BillingPresentation::Metered
2785 );
2786 }
2787
2788 #[test]
2789 fn cross_provider_child_without_provenance_fails_closed_unknown() {
2790 // Moonshot and MiniMax both run metered AND subscription routes, so
2791 // identity alone must never guess either direction.
2792 for child in [ApiProvider::Moonshot, ApiProvider::Minimax] {
2793 assert_eq!(
2794 for_child_route(
2795 ApiProvider::Deepseek,
2796 BillingPresentation::Metered,
2797 child,
2798 None,
2799 ),
2800 BillingPresentation::Unknown,
2801 "{child:?} identity must not guess subscription or metered billing"
2802 );
2803 }
2804 // Local routes are the one identity-derived fact that stays truthful.
2805 for child in [ApiProvider::Ollama, ApiProvider::Sglang, ApiProvider::Vllm] {
2806 assert_eq!(
2807 for_child_route(
2808 ApiProvider::Deepseek,
2809 BillingPresentation::Metered,
2810 child,
2811 None,
2812 ),
2813 BillingPresentation::Local
2814 );
2815 }
2816 }
2817
2818 #[test]
2819 fn child_provenance_wins_over_parent_route_and_provider_identity() {
2820 // Direct-platform Moonshot child under a Kimi Code membership
2821 // parent: the child's own metered truth must price the route.
2822 assert_eq!(
2823 for_child_route(
2824 ApiProvider::Moonshot,
2825 BillingPresentation::Subscription("Kimi Code quota"),
2826 ApiProvider::Moonshot,
2827 Some(BillingPresentation::Metered),
2828 ),
2829 BillingPresentation::Metered
2830 );
2831 // Membership Moonshot child under a metered parent: quota wins.
2832 assert_eq!(
2833 for_child_route(
2834 ApiProvider::Deepseek,
2835 BillingPresentation::Metered,
2836 ApiProvider::Moonshot,
2837 Some(BillingPresentation::Subscription("Kimi Code quota")),
2838 ),
2839 BillingPresentation::Subscription("Kimi Code quota")
2840 );
2841 // MiniMax Token Plan provenance never invents dollars; metered
2842 // provenance is allowed to accrue.
2843 assert!(
2844 !for_child_route(
2845 ApiProvider::Deepseek,
2846 BillingPresentation::Metered,
2847 ApiProvider::Minimax,
2848 Some(BillingPresentation::Subscription(
2849 "MiniMax Token Plan quota"
2850 )),
2851 )
2852 .shows_money()
2853 );
2854 assert!(
2855 for_child_route(
2856 ApiProvider::Deepseek,
2857 BillingPresentation::Metered,
2858 ApiProvider::Minimax,
2859 Some(BillingPresentation::Metered),
2860 )
2861 .shows_money()
2862 );
2863 }
2864
2865 #[test]
2866 fn child_billing_provenance_round_trips_through_serde() {
2867 for billing in [
2868 BillingPresentation::Metered,
2869 BillingPresentation::Subscription("Kimi Code quota"),
2870 BillingPresentation::Subscription("MiniMax Token Plan quota"),
2871 BillingPresentation::Local,
2872 BillingPresentation::Unknown,
2873 ] {
2874 let provenance = ChildBillingProvenance::from(billing);
2875 let json = serde_json::to_string(&provenance).expect("serialize provenance");
2876 let back: ChildBillingProvenance =
2877 serde_json::from_str(&json).expect("deserialize provenance");
2878 assert_eq!(back.as_billing_presentation(), billing);
2879 }
2880 // An unrecognized free-text label fails closed rather than
2881 // inventing a quota claim.
2882 assert_eq!(
2883 ChildBillingProvenance::Subscription {
2884 label: "free lunch".to_string(),
2885 }
2886 .as_billing_presentation(),
2887 BillingPresentation::Unknown
2888 );
2889 }
2890
2891 /// Two named custom routes are the same `ApiProvider::Custom`. Identity,
2892 /// not the enum, decides whether a child may inherit the parent's product.
2893 #[test]
2894 fn custom_siblings_do_not_inherit_each_others_product() {
2895 let parent = ChildParentRoute {
2896 provider: ApiProvider::Custom,
2897 identity: "gateway-a",
2898 billing: BillingPresentation::Metered,
2899 };
2900
2901 // Same vendor: inheritance is sound.
2902 assert_eq!(
2903 for_child_route_receipt(
2904 parent,
2905 ChildRouteClaim {
2906 named: true,
2907 provider: Some(ApiProvider::Custom),
2908 identity: Some("gateway-a"),
2909 },
2910 None,
2911 ),
2912 BillingPresentation::Metered
2913 );
2914
2915 // Sibling vendor on the same enum: must not borrow gateway-a's product.
2916 assert_eq!(
2917 for_child_route_receipt(
2918 parent,
2919 ChildRouteClaim {
2920 named: true,
2921 provider: Some(ApiProvider::Custom),
2922 identity: Some("gateway-b"),
2923 },
2924 None,
2925 ),
2926 BillingPresentation::Unknown
2927 );
2928 }
2929
2930 /// A child that names an unparseable provider named *some* route, just not
2931 /// one this build knows. That is never a licence to inherit.
2932 #[test]
2933 fn unparseable_child_provider_is_unknown_not_inherited() {
2934 let parent = ChildParentRoute {
2935 provider: ApiProvider::Anthropic,
2936 identity: "anthropic",
2937 billing: BillingPresentation::Subscription("Claude OAuth quota"),
2938 };
2939 assert_eq!(
2940 for_child_route_receipt(
2941 parent,
2942 ChildRouteClaim {
2943 named: true,
2944 provider: None,
2945 identity: Some("some-future-vendor"),
2946 },
2947 None,
2948 ),
2949 BillingPresentation::Unknown
2950 );
2951 // But a child that claims nothing ran the parent's own client.
2952 assert_eq!(
2953 for_child_route_receipt(parent, ChildRouteClaim::default(), None),
2954 BillingPresentation::Subscription("Claude OAuth quota")
2955 );
2956 }
2957
2958 /// The producer's metadata keys are exactly the ones the consumer reads.
2959 /// Pins the wire contract that previously had a reader and no producer.
2960 #[test]
2961 fn child_route_metadata_round_trips_through_the_consumer() {
2962 let metadata = child_route_metadata(
2963 ApiProvider::Ollama,
2964 "ollama",
2965 "http://localhost:11434/v1",
2966 RouteProduct::Unproven,
2967 );
2968
2969 assert_eq!(metadata["child_provider"], "ollama");
2970 assert_eq!(metadata["child_provider_identity"], "ollama");
2971 let provenance: ChildBillingProvenance =
2972 serde_json::from_value(metadata["child_billing"].clone())
2973 .expect("child_billing must deserialize with the consumer's type");
2974 assert_eq!(
2975 provenance.as_billing_presentation(),
2976 BillingPresentation::Local
2977 );
2978 }
2979
2980 /// A dispatched-route classification survives the child → parent mailbox
2981 /// boundary and still beats provider identity at the consumer.
2982 #[test]
2983 fn dispatched_receipt_survives_the_child_provenance_boundary() {
2984 let _lock = crate::test_support::lock_test_env();
2985 let _kimi = crate::test_support::EnvVarGuard::set(
2986 "KIMI_BASE_URL",
2987 "https://api.kimi.com/coding/v1",
2988 );
2989 let config = config_with(ApiProvider::Moonshot, ProviderConfig::default());
2990 let dispatched = for_dispatched_route(
2991 &config,
2992 DispatchedRoute {
2993 provider: ApiProvider::Moonshot,
2994 base_url: "https://api.kimi.com/coding/v1",
2995 },
2996 );
2997 let wire = serde_json::to_string(&ChildBillingProvenance::from(dispatched))
2998 .expect("serialize dispatch receipt");
2999 let back: ChildBillingProvenance =
3000 serde_json::from_str(&wire).expect("deserialize dispatch receipt");
3001 let billing = for_child_route(
3002 ApiProvider::Deepseek,
3003 BillingPresentation::Metered,
3004 ApiProvider::Moonshot,
3005 Some(back.as_billing_presentation()),
3006 );
3007 assert_eq!(
3008 billing,
3009 BillingPresentation::Subscription("Kimi Code quota")
3010 );
3011 assert!(!billing.shows_money());
3012 }
3013
3014 /// Every provider env contract that can move a default route's endpoint.
3015 /// The audit below pins shipped defaults, so these must not leak in.
3016 const BASE_URL_ENV_VARS: &[&str] = &[
3017 "CODEWHALE_BASE_URL",
3018 "DEEPSEEK_BASE_URL",
3019 "NIM_BASE_URL",
3020 "NVIDIA_BASE_URL",
3021 "NVIDIA_NIM_BASE_URL",
3022 "OPENAI_BASE_URL",
3023 "ATLASCLOUD_BASE_URL",
3024 "OPENROUTER_BASE_URL",
3025 "ORCAROUTER_BASE_URL",
3026 "MIMO_BASE_URL",
3027 "XIAOMI_MIMO_BASE_URL",
3028 "WANJIE_ARK_BASE_URL",
3029 "WANJIE_BASE_URL",
3030 "WANJIE_MAAS_BASE_URL",
3031 "VOLCENGINE_BASE_URL",
3032 "VOLCENGINE_ARK_BASE_URL",
3033 "ARK_BASE_URL",
3034 "NOVITA_BASE_URL",
3035 "FIREWORKS_BASE_URL",
3036 "SILICONFLOW_BASE_URL",
3037 "ARCEE_BASE_URL",
3038 "MOONSHOT_BASE_URL",
3039 "KIMI_BASE_URL",
3040 "SGLANG_BASE_URL",
3041 "VLLM_BASE_URL",
3042 "OLLAMA_BASE_URL",
3043 "OLLAMA_CLOUD_BASE_URL",
3044 "HF_BASE_URL",
3045 "HUGGINGFACE_BASE_URL",
3046 "META_MODEL_API_BASE_URL",
3047 "MODEL_API_BASE_URL",
3048 "MISTRAL_BASE_URL",
3049 "XAI_BASE_URL",
3050 "GEMINI_BASE_URL",
3051 "GOOGLE_BASE_URL",
3052 "TELECOMJS_BASE_URL",
3053 "EDENAI_BASE_URL",
3054 "CONCENTRATE_BASE_URL",
3055 "MODELSTUDIO_TOKEN_PLAN_BASE_URL",
3056 "MODELSTUDIO_CODING_PLAN_BASE_URL",
3057 "OPENCODE_GO_BASE_URL",
3058 "OPENCODE_ZEN_BASE_URL",
3059 ];
3060
3061 /// The shipped default-route billing decision for every runnable provider.
3062 /// Onboarding or re-defaulting a provider must update this table and the
3063 /// audit artifact (`docs/PROVIDERS.md` billing column) deliberately.
3064 const DEFAULT_ROUTE_BILLING_AUDIT: &[(ApiProvider, BillingPresentation)] = &[
3065 (ApiProvider::Deepseek, BillingPresentation::Metered),
3066 (ApiProvider::DeepseekAnthropic, BillingPresentation::Metered),
3067 (ApiProvider::NvidiaNim, BillingPresentation::Metered),
3068 (ApiProvider::Openai, BillingPresentation::Metered),
3069 (ApiProvider::Atlascloud, BillingPresentation::Metered),
3070 (ApiProvider::WanjieArk, BillingPresentation::Metered),
3071 (
3072 ApiProvider::Volcengine,
3073 BillingPresentation::Subscription("Volcengine Coding Plan"),
3074 ),
3075 (ApiProvider::Openrouter, BillingPresentation::Metered),
3076 (ApiProvider::Orcarouter, BillingPresentation::Metered),
3077 (
3078 ApiProvider::XiaomiMimo,
3079 BillingPresentation::Subscription("MiMo token plan"),
3080 ),
3081 (ApiProvider::Novita, BillingPresentation::Metered),
3082 (ApiProvider::Fireworks, BillingPresentation::Metered),
3083 (ApiProvider::Siliconflow, BillingPresentation::Metered),
3084 (ApiProvider::Arcee, BillingPresentation::Metered),
3085 (ApiProvider::SiliconflowCn, BillingPresentation::Metered),
3086 (ApiProvider::Moonshot, BillingPresentation::Metered),
3087 (ApiProvider::Sglang, BillingPresentation::Local),
3088 (ApiProvider::Vllm, BillingPresentation::Local),
3089 (ApiProvider::Ollama, BillingPresentation::Local),
3090 (ApiProvider::OllamaCloud, BillingPresentation::Unknown),
3091 (ApiProvider::Huggingface, BillingPresentation::Metered),
3092 (ApiProvider::Modelscope, BillingPresentation::Metered),
3093 (ApiProvider::Together, BillingPresentation::Metered),
3094 (ApiProvider::Qianfan, BillingPresentation::Metered),
3095 (
3096 ApiProvider::OpenaiCodex,
3097 BillingPresentation::Subscription("Codex OAuth quota"),
3098 ),
3099 (ApiProvider::Anthropic, BillingPresentation::Metered),
3100 (ApiProvider::Openmodel, BillingPresentation::Metered),
3101 (
3102 ApiProvider::Zai,
3103 BillingPresentation::Subscription("Z.ai Coding Plan quota"),
3104 ),
3105 (ApiProvider::Stepfun, BillingPresentation::Metered),
3106 (ApiProvider::Minimax, BillingPresentation::Unknown),
3107 (ApiProvider::MinimaxAnthropic, BillingPresentation::Unknown),
3108 (ApiProvider::Deepinfra, BillingPresentation::Metered),
3109 (ApiProvider::Sakana, BillingPresentation::Metered),
3110 (ApiProvider::LongCat, BillingPresentation::Metered),
3111 (
3112 ApiProvider::OpencodeGo,
3113 BillingPresentation::Subscription("OpenCode Go quota"),
3114 ),
3115 (ApiProvider::OpencodeZen, BillingPresentation::Metered),
3116 (ApiProvider::Meta, BillingPresentation::Metered),
3117 (ApiProvider::Xai, BillingPresentation::Metered),
3118 (ApiProvider::Mistral, BillingPresentation::Metered),
3119 (ApiProvider::Telecomjs, BillingPresentation::Metered),
3120 (
3121 ApiProvider::ModelstudioTokenPlan,
3122 BillingPresentation::Subscription("Alibaba Token Plan"),
3123 ),
3124 (
3125 ApiProvider::ModelstudioTokenPlanAnthropic,
3126 BillingPresentation::Subscription("Alibaba Token Plan"),
3127 ),
3128 (
3129 ApiProvider::ModelstudioCodingPlan,
3130 BillingPresentation::Subscription("Alibaba Coding Plan"),
3131 ),
3132 (
3133 ApiProvider::ModelstudioCodingPlanAnthropic,
3134 BillingPresentation::Subscription("Alibaba Coding Plan"),
3135 ),
3136 // Retired identity: never selectable or runnable. Its classification
3137 // is pinned only so the endpoint-shaped arm stays exhaustive.
3138 (ApiProvider::Antigravity, BillingPresentation::Metered),
3139 (ApiProvider::Google, BillingPresentation::Metered),
3140 (ApiProvider::Edenai, BillingPresentation::Metered),
3141 (ApiProvider::Zenmux, BillingPresentation::Metered),
3142 (
3143 ApiProvider::Csdn,
3144 BillingPresentation::Subscription("CSDN Coding Plan quota"),
3145 ),
3146 (ApiProvider::Concentrate, BillingPresentation::Metered),
3147 (ApiProvider::Codewhale, BillingPresentation::Metered),
3148 (ApiProvider::Custom, BillingPresentation::Unknown),
3149 ];
3150
3151 /// Default-route billing is a deliberate, audited decision for every
3152 /// provider `ApiProvider::all()` exposes — 51 rows covering the primary
3153 /// route and every dialect/plan-variant alternate identity.
3154 #[test]
3155 fn default_route_billing_audit_covers_every_provider() {
3156 let _lock = crate::test_support::lock_test_env();
3157 let _env: Vec<_> = BASE_URL_ENV_VARS
3158 .iter()
3159 .copied()
3160 .map(crate::test_support::EnvVarGuard::remove)
3161 .collect();
3162 // Credential shape also steers MiniMax's default product; the audit
3163 // pins the no-credential answer.
3164 let _minimax = crate::test_support::EnvVarGuard::remove("MINIMAX_API_KEY");
3165
3166 let audited: Vec<_> = DEFAULT_ROUTE_BILLING_AUDIT
3167 .iter()
3168 .map(|(provider, _)| provider)
3169 .collect();
3170 for (index, provider) in audited.iter().enumerate() {
3171 assert!(
3172 !audited[..index].contains(provider),
3173 "duplicate audit row for {provider:?}"
3174 );
3175 }
3176 for provider in ApiProvider::all() {
3177 assert!(
3178 audited.contains(&provider),
3179 "{provider:?} is missing from DEFAULT_ROUTE_BILLING_AUDIT"
3180 );
3181 }
3182 assert_eq!(
3183 DEFAULT_ROUTE_BILLING_AUDIT.len(),
3184 52,
3185 "the audit covers every provider identity, primary and alternate"
3186 );
3187
3188 let config = Config::default();
3189 for (provider, expected) in DEFAULT_ROUTE_BILLING_AUDIT {
3190 let actual = for_route(&config, *provider);
3191 assert_eq!(
3192 &actual, expected,
3193 "{provider:?} default route billing changed; update the audit deliberately"
3194 );
3195 }
3196 }
3197 }
3198
3198 lines RUST