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