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