返回 CodeWhale
cost_status.rs
根目录 / crates / tui / src / cost_status.rs
1 //! Process-wide cost-accrual side-channel (#526).
2 //!
3 //! Background LLM calls outside the main turn-complete path
4 //! (compaction summaries) used
5 //! to drop their token usage on the floor — the dashboard's
6 //! session-cost only saw the parent turn's tokens, so a long
7 //! session that triggered compaction under-reported
8 //! cost by however many tokens those background calls consumed.
9 //!
10 //! Mirrors the [`crate::retry_status`] pattern: background callers
11 //! call [`crate::cost_status::report_effective_route`] after each
12 //! `client.create_message`, the TUI
13 //! render loop calls [`drain`] every frame, and any drained amount
14 //! gets folded into `App::accrue_subagent_cost_estimate`.
15 //!
16 //! Why a side-channel and not a plumbed callback: the leaky callers
17 //! (`compaction.rs`) are
18 //! engine-internal machinery without a direct handle to `App` or
19 //! the engine's event channel. A side-channel keeps the change
20 //! surface tiny — one new `report` line per call site — and any
21 //! future background caller (summarizers, retrieval helpers) gets
22 //! accrued for free without further plumbing.
23 //!
24 //! ## One pool, not a pile of counters (#4318)
25 //!
26 //! Money and the *completeness* of that money are one fact, so they live in one
27 //! mutex-guarded [`PendingBackgroundCost`] that [`drain`] takes atomically.
28 //! Splitting them across free-standing atomics made two things go wrong at once:
29 //! a drain could observe a total without the counters that explain it, and every
30 //! new global was another piece of state a parallel test had to remember to
31 //! reset. There is exactly one *drainable cost pool*. The runtime-owner journal
32 //! below is a separate route/usage copy (never another money counter), and the
33 //! shared test reset clears both stores.
34
35 use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
36 use std::sync::{Arc, Mutex, OnceLock};
37
38 use chrono::{DateTime, Utc};
39
40 use crate::config::ApiProvider;
41 use crate::pricing::{CostEstimate, TurnCostAudit};
42 use crate::route_billing::BillingPresentation;
43 use codewhale_models::Usage;
44
45 /// Everything a drained background accrual needs to be explained.
46 ///
47 /// The money and the coverage/provenance that qualify it are drained together,
48 /// so `/cost` can never show a background subtotal whose completeness came from
49 /// a different observation.
50 #[derive(Debug, Clone, Default, PartialEq)]
51 pub struct PendingBackgroundCost {
52 /// Summed cost of the background turns that were priced.
53 pub estimate: CostEstimate,
54 /// Background turns that produced an authoritative price.
55 pub priced_turns: u32,
56 /// Background turns that were money-metered (or of unknown basis) but
57 /// produced no authoritative price, so their spend is missing.
58 pub unpriced_turns: u32,
59 /// Money-metered turns authoritatively priced in CNY.
60 pub cny_priced_turns: u32,
61 /// Money-metered turns missing authoritative CNY pricing.
62 pub cny_unpriced_turns: u32,
63 /// Stable reason labels for the unpriced turns.
64 pub unpriced_reasons: BTreeSet<&'static str>,
65 pub cny_unpriced_reasons: BTreeSet<&'static str>,
66 /// Token classes used on a background route that carry no published price.
67 pub unpriced_classes: BTreeSet<&'static str>,
68 /// Provenance labels of the pricing rows that were applied or attempted.
69 pub pricing_provenances: BTreeSet<&'static str>,
70 /// Live-pricing downgrade receipts, when a live catalog row could not be
71 /// verified for the endpoint that served the turn.
72 pub live_pricing_defects: BTreeSet<&'static str>,
73 /// Live pricing failed and no bundled row could price the turn. Kept
74 /// separate so `/cost` never claims a bundled fallback was used when the
75 /// result is actually unavailable.
76 pub live_pricing_unusable_defects: BTreeSet<&'static str>,
77 /// One redacted receipt per distinct background route that reported.
78 ///
79 /// See [`EffectiveRouteEnvelope::receipt`] for the exact contents; these carry
80 /// provider identity, endpoint *fingerprint*, billing surface, wire model,
81 /// and currency — never a URL, key, token, or filesystem path.
82 pub route_receipts: BTreeSet<String>,
83 /// Durable, redacted identities of provider responses folded into this
84 /// batch. These travel with the money so a session snapshot can make a
85 /// replay idempotent after reload.
86 pub usage_source_fingerprints: BTreeSet<String>,
87 }
88
89 /// Immutable, non-secret route evidence captured before a provider request.
90 /// It contains enough information to audit the eventual usage without reading
91 /// mutable parent/app config at completion time.
92 #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
93 pub struct EffectiveRouteEnvelope {
94 pub provider: ApiProvider,
95 pub provider_identity: String,
96 pub model: String,
97 /// Requested OpenRouter upstream, frozen with the client that dispatched.
98 #[serde(default)]
99 pub openrouter_vendor: Option<String>,
100 pub billing_surface: Option<String>,
101 pub endpoint_fingerprint: Option<String>,
102 /// Frozen provider-live or signed cloud rates captured from the exact catalog scope
103 /// at CodeWhale's pre-permit application-dispatch boundary. Legacy
104 /// receipts omit this and therefore cannot meter a reviewed custom route
105 /// retroactively.
106 #[serde(
107 default,
108 deserialize_with = "crate::provider_catalog_live::deserialize_optional_provider_live_pricing"
109 )]
110 pub provider_live_pricing: Option<crate::provider_catalog_live::ProviderLivePricingQuote>,
111 #[serde(default)]
112 pub billing_mode: RouteBillingMode,
113 pub dispatched_at: DateTime<Utc>,
114 }
115
116 impl serde::Serialize for EffectiveRouteEnvelope {
117 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
118 where
119 S: serde::Serializer,
120 {
121 use serde::ser::SerializeStruct as _;
122
123 let route = self.sanitized_for_persistence();
124 let mut state = serializer.serialize_struct(
125 "EffectiveRouteEnvelope",
126 8 + usize::from(route.openrouter_vendor.is_some()),
127 )?;
128 state.serialize_field("provider", &route.provider)?;
129 state.serialize_field("provider_identity", &route.provider_identity)?;
130 state.serialize_field("model", &route.model)?;
131 if let Some(vendor) = &route.openrouter_vendor {
132 state.serialize_field("openrouter_vendor", vendor)?;
133 }
134 state.serialize_field("billing_surface", &route.billing_surface)?;
135 state.serialize_field("endpoint_fingerprint", &route.endpoint_fingerprint)?;
136 state.serialize_field("provider_live_pricing", &route.provider_live_pricing)?;
137 state.serialize_field("billing_mode", &route.billing_mode)?;
138 state.serialize_field("dispatched_at", &route.dispatched_at)?;
139 state.end()
140 }
141 }
142
143 /// One provider usage payload paired with the immutable route that served it.
144 ///
145 /// Runtime hosts persist these for model calls made below the parent turn
146 /// (sub-agents, review/verify/RLM tools, and compaction). Keeping route and
147 /// usage together makes the record independently auditable and prevents a
148 /// later provider/model selection from changing its price.
149 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
150 pub struct EffectiveRouteUsage {
151 pub route: EffectiveRouteEnvelope,
152 pub usage: Usage,
153 }
154
155 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
156 #[serde(rename_all = "snake_case")]
157 pub enum RouteBillingMode {
158 Metered,
159 Subscription,
160 Local,
161 #[default]
162 Unknown,
163 }
164
165 impl From<BillingPresentation> for RouteBillingMode {
166 fn from(value: BillingPresentation) -> Self {
167 match value {
168 BillingPresentation::Metered => Self::Metered,
169 BillingPresentation::Subscription(_) => Self::Subscription,
170 BillingPresentation::Local => Self::Local,
171 BillingPresentation::Unknown => Self::Unknown,
172 }
173 }
174 }
175
176 impl EffectiveRouteEnvelope {
177 #[must_use]
178 pub fn capture(
179 config: Option<&crate::config::Config>,
180 provider: ApiProvider,
181 provider_identity: impl Into<String>,
182 model: impl Into<String>,
183 base_url: Option<&str>,
184 dispatched_at: DateTime<Utc>,
185 ) -> Self {
186 let provider_identity = provider_identity.into();
187 let model = model.into();
188 let billing = config.map_or_else(
189 || crate::route_billing::for_endpoint_without_config(provider, base_url),
190 |config| crate::route_billing::for_route(config, provider),
191 );
192 let endpoint_fingerprint = base_url.and_then(endpoint_fingerprint);
193 let provider_live_pricing = base_url.and_then(|base_url| {
194 u64::try_from(dispatched_at.timestamp())
195 .ok()
196 .and_then(|at| {
197 config
198 .and_then(|config| {
199 crate::provider_catalog_live::configured_dispatch_pricing_quote_at(
200 config.custom_models.as_deref().unwrap_or_default(),
201 provider,
202 &provider_identity,
203 &model,
204 base_url,
205 at,
206 )
207 })
208 .or_else(|| {
209 crate::provider_catalog_live::fresh_dispatch_pricing_quote_at(
210 provider,
211 &provider_identity,
212 &model,
213 base_url,
214 at,
215 )
216 })
217 })
218 });
219 Self {
220 provider,
221 provider_identity: sanitize_persisted_route_label(&provider_identity),
222 model: sanitize_persisted_route_label(&model),
223 openrouter_vendor: config
224 .filter(|_| provider == ApiProvider::Openrouter)
225 .and_then(|config| config.provider_config_for(provider))
226 .and_then(|entry| entry.vendor.as_deref())
227 .map(str::trim)
228 .filter(|vendor| !vendor.is_empty())
229 .map(sanitize_persisted_route_label),
230 billing_surface: crate::route_billing::billing_surface_for_dispatch(
231 config, provider, base_url,
232 )
233 .map(str::to_string),
234 endpoint_fingerprint,
235 provider_live_pricing,
236 billing_mode: billing.into(),
237 dispatched_at,
238 }
239 }
240
241 #[must_use]
242 pub fn audit(&self, usage: &Usage) -> TurnCostAudit {
243 let reviewed_custom_metered = crate::pricing::reviewed_custom_route_is_metered(
244 self.provider,
245 self.endpoint_fingerprint.as_deref(),
246 );
247 let declared_estimate = self.provider_live_pricing.as_ref().is_some_and(|quote| {
248 quote.provenance == codewhale_config::pricing::PricingProvenance::UserOverride
249 && self
250 .endpoint_fingerprint
251 .as_deref()
252 .zip(u64::try_from(self.dispatched_at.timestamp()).ok())
253 .is_some_and(|(fingerprint, at)| {
254 quote
255 .pricing_for_route(
256 self.provider,
257 &self.provider_identity,
258 &self.model,
259 fingerprint,
260 at,
261 )
262 .is_some()
263 })
264 });
265 match self.billing_mode {
266 RouteBillingMode::Subscription | RouteBillingMode::Local => {
267 return TurnCostAudit::unpriced(crate::pricing::UnpricedReason::NotMoneyMetered);
268 }
269 RouteBillingMode::Unknown if !reviewed_custom_metered && !declared_estimate => {
270 return TurnCostAudit::unpriced(
271 crate::pricing::UnpricedReason::UnknownBillingBasis,
272 );
273 }
274 RouteBillingMode::Metered | RouteBillingMode::Unknown => {}
275 }
276 // The OpenRouter model catalog does not identify a pinned upstream's
277 // price. An endpoint match alone must not promote that aggregate rate.
278 if self.provider == ApiProvider::Openrouter && self.openrouter_vendor.is_some() {
279 return TurnCostAudit::unpriced(crate::pricing::UnpricedReason::RoutingDependentPrice);
280 }
281 crate::pricing::audit_turn_cost_for_route_on_endpoint_for_identity_at(
282 self.provider,
283 Some(&self.provider_identity),
284 &self.model,
285 self.billing_surface.as_deref(),
286 self.endpoint_fingerprint.as_deref(),
287 self.provider_live_pricing.as_ref(),
288 usage,
289 self.dispatched_at,
290 )
291 }
292
293 #[must_use]
294 pub fn receipt(&self, audit: &TurnCostAudit) -> String {
295 let route = self.sanitized_for_persistence();
296 let mut receipt = route_receipt(
297 route.provider,
298 Some(&route.provider_identity),
299 &route.model,
300 route.billing_surface.as_deref(),
301 route.endpoint_fingerprint.as_deref(),
302 route.billing_mode,
303 currency_tag(audit),
304 );
305 if let Some(vendor) = route.openrouter_vendor.as_deref() {
306 receipt.push_str(" openrouter_vendor=");
307 receipt.push_str(&safe_receipt_field(vendor));
308 }
309 receipt
310 }
311
312 /// Redact filesystem-like labels before a route crosses a persistence or
313 /// metadata boundary. Ordinary provider model namespaces such as
314 /// `anthropic/claude-*` remain intact; absolute/local path forms do not.
315 #[must_use]
316 pub fn sanitized_for_persistence(&self) -> Self {
317 let mut route = self.clone();
318 route.provider_identity = sanitize_persisted_route_label(&route.provider_identity);
319 route.model = sanitize_persisted_route_label(&route.model);
320 route.openrouter_vendor = route
321 .openrouter_vendor
322 .as_deref()
323 .map(sanitize_persisted_route_label);
324 route.billing_surface = route
325 .billing_surface
326 .as_deref()
327 .map(sanitize_persisted_route_label);
328 route.endpoint_fingerprint =
329 route
330 .endpoint_fingerprint
331 .as_deref()
332 .and_then(|fingerprint| {
333 let fingerprint = fingerprint.trim();
334 (fingerprint.len() == 64
335 && fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()))
336 .then(|| fingerprint.to_ascii_lowercase())
337 });
338 let quote_is_valid = route
339 .provider_live_pricing
340 .as_ref()
341 .zip(route.endpoint_fingerprint.as_deref())
342 .and_then(|(quote, fingerprint)| {
343 u64::try_from(route.dispatched_at.timestamp())
344 .ok()
345 .and_then(|dispatched_at_unix| {
346 quote.pricing_for_route(
347 route.provider,
348 &route.provider_identity,
349 &route.model,
350 fingerprint,
351 dispatched_at_unix,
352 )
353 })
354 })
355 .is_some();
356 if !quote_is_valid {
357 route.provider_live_pricing = None;
358 }
359 route
360 }
361 }
362
363 fn receipt_with_usage_classes(mut receipt: String, usage: &Usage) -> String {
364 let classes = crate::pricing::token_usage_for_pricing(usage);
365 if classes.cache_write > 0 {
366 receipt.push_str(" cache_write=yes");
367 }
368 if usage.reasoning_tokens.unwrap_or(0) > 0 {
369 receipt.push_str(" reasoning=yes");
370 }
371 receipt
372 }
373
374 /// Canonical redacted route receipt for one exact usage payload.
375 #[must_use]
376 pub fn effective_route_usage_receipt(
377 route: &EffectiveRouteEnvelope,
378 audit: &TurnCostAudit,
379 usage: &Usage,
380 ) -> String {
381 receipt_with_usage_classes(route.receipt(audit), usage)
382 }
383
384 /// Canonical `child_*` token and route metadata for tools that make their own
385 /// LLM calls (`review`, `verify`, and `rlm`). Keeping this next to the immutable
386 /// route envelope prevents the pure model types from depending on app config.
387 #[must_use]
388 pub fn child_usage_metadata_fields(
389 route: &EffectiveRouteEnvelope,
390 usage: &Usage,
391 ) -> serde_json::Map<String, serde_json::Value> {
392 let route = route.sanitized_for_persistence();
393 let mut fields = serde_json::Map::new();
394 fields.insert("child_provider".into(), serde_json::json!(route.provider));
395 fields.insert(
396 "child_provider_identity".into(),
397 serde_json::json!(route.provider_identity),
398 );
399 fields.insert("child_model".into(), serde_json::json!(route.model));
400 fields.insert(
401 "child_openrouter_vendor".into(),
402 serde_json::json!(route.openrouter_vendor),
403 );
404 fields.insert(
405 "child_billing_surface".into(),
406 serde_json::json!(route.billing_surface),
407 );
408 fields.insert(
409 "child_endpoint_fingerprint".into(),
410 serde_json::json!(route.endpoint_fingerprint),
411 );
412 fields.insert(
413 "child_provider_live_pricing".into(),
414 serde_json::json!(route.provider_live_pricing),
415 );
416 fields.insert(
417 "child_billing_mode".into(),
418 serde_json::json!(route.billing_mode),
419 );
420 fields.insert(
421 "child_dispatched_at".into(),
422 serde_json::json!(route.dispatched_at),
423 );
424 fields.insert(
425 "child_input_tokens".into(),
426 serde_json::json!(usage.input_tokens),
427 );
428 fields.insert(
429 "child_output_tokens".into(),
430 serde_json::json!(usage.output_tokens),
431 );
432 fields.insert(
433 "child_prompt_cache_hit_tokens".into(),
434 serde_json::json!(usage.prompt_cache_hit_tokens),
435 );
436 fields.insert(
437 "child_prompt_cache_miss_tokens".into(),
438 serde_json::json!(usage.prompt_cache_miss_tokens),
439 );
440 fields.insert(
441 "child_prompt_cache_write_tokens".into(),
442 serde_json::json!(usage.prompt_cache_write_tokens),
443 );
444 // Informational: reasoning tokens are already included in output tokens.
445 fields.insert(
446 "child_reasoning_tokens".into(),
447 serde_json::json!(usage.reasoning_tokens),
448 );
449 fields.insert(
450 "child_reasoning_replay_tokens".into(),
451 serde_json::json!(usage.reasoning_replay_tokens),
452 );
453 fields.insert(
454 "child_server_tool_use".into(),
455 serde_json::json!(usage.server_tool_use),
456 );
457 fields
458 }
459
460 /// Merge canonical child usage into a tool metadata object.
461 pub fn attach_child_usage_metadata(
462 metadata: &mut serde_json::Value,
463 route: &EffectiveRouteEnvelope,
464 usage: &Usage,
465 ) {
466 if let Some(object) = metadata.as_object_mut() {
467 object.extend(child_usage_metadata_fields(route, usage));
468 }
469 }
470
471 /// Maximum number of distinct routed-usage segments accepted from one tool
472 /// result. RLM reserves against the same bound before dispatch, so a valid
473 /// producer never has to discard a provider receipt after doing the work.
474 pub const MAX_CHILD_USAGE_RECORDS: usize = 64;
475
476 const CHILD_USAGE_RECORDS_KEY: &str = "child_usage_records";
477 const CHILD_USAGE_DROP_RECORDS_KEY: &str = "child_usage_drop_records";
478 const CHILD_USAGE_DROPPED_RECORDS_KEY: &str = "child_usage_dropped_records";
479
480 /// Attach a bounded batch of routed child usage to tool metadata.
481 ///
482 /// The source identity is reduced to a one-way fingerprint before metadata
483 /// can enter a transcript. Routes pass through their persistence sanitizer,
484 /// so neither a raw response id nor an endpoint/credential can hitch a ride.
485 /// New consumers prefer this batch over the legacy single `child_*` fields.
486 /// Attach a bounded batch containing both exact usage receipts and exact
487 /// provider-success/missing-usage route receipts.
488 pub fn attach_child_usage_batch_metadata(
489 metadata: &mut serde_json::Value,
490 batch: &RuntimeUsageBatch,
491 ) {
492 let Some(object) = metadata.as_object_mut() else {
493 return;
494 };
495 let retained_records = batch
496 .records
497 .iter()
498 .take(MAX_CHILD_USAGE_RECORDS)
499 .map(|record| {
500 serde_json::json!({
501 "source_id": format!(
502 "routed:{}",
503 usage_source_fingerprint(&record.source_id)
504 ),
505 "route": record.usage.route.sanitized_for_persistence(),
506 "usage": record.usage.usage,
507 })
508 })
509 .collect::<Vec<_>>();
510 let remaining = MAX_CHILD_USAGE_RECORDS.saturating_sub(retained_records.len());
511 let retained_drops = batch
512 .drop_records
513 .iter()
514 .take(remaining)
515 .map(|record| {
516 serde_json::json!({
517 "source_id": format!(
518 "routed:{}",
519 usage_source_fingerprint(&record.source_id)
520 ),
521 "route": record.route.sanitized_for_persistence(),
522 })
523 })
524 .collect::<Vec<_>>();
525 object.insert(
526 CHILD_USAGE_RECORDS_KEY.into(),
527 serde_json::json!(retained_records),
528 );
529 object.insert(
530 CHILD_USAGE_DROP_RECORDS_KEY.into(),
531 serde_json::json!(retained_drops),
532 );
533 let usage_overflow = batch.records.len().saturating_sub(MAX_CHILD_USAGE_RECORDS);
534 let dropped_records = batch
535 .dropped_records
536 .max(u64::try_from(batch.drop_records.len()).unwrap_or(u64::MAX))
537 .saturating_add(u64::try_from(usage_overflow).unwrap_or(u64::MAX));
538 if dropped_records > 0 {
539 object.insert(
540 CHILD_USAGE_DROPPED_RECORDS_KEY.into(),
541 serde_json::json!(dropped_records),
542 );
543 } else {
544 object.remove(CHILD_USAGE_DROPPED_RECORDS_KEY);
545 }
546 }
547
548 /// Parse the preferred routed child-usage batch.
549 ///
550 /// `None` means the batch key was absent and callers may use the legacy
551 /// single-record parser. Once the key is present, malformed/overflow entries
552 /// are represented by `dropped_records` instead of falling back and risking a
553 /// partial subtotal being presented as complete.
554 #[must_use]
555 pub fn child_usage_records_from_metadata(
556 metadata: &serde_json::Value,
557 ) -> Option<RuntimeUsageBatch> {
558 let value = metadata.get(CHILD_USAGE_RECORDS_KEY)?;
559 let drop_values = metadata
560 .get(CHILD_USAGE_DROP_RECORDS_KEY)
561 .and_then(serde_json::Value::as_array)
562 .map(Vec::as_slice)
563 .unwrap_or_default();
564 let declared_dropped = metadata
565 .get(CHILD_USAGE_DROPPED_RECORDS_KEY)
566 .and_then(serde_json::Value::as_u64)
567 .unwrap_or(0);
568 let Some(values) = value.as_array() else {
569 return Some(RuntimeUsageBatch {
570 records: Vec::new(),
571 drop_records: Vec::new(),
572 dropped_records: declared_dropped.saturating_add(1),
573 });
574 };
575
576 let overflow = values.len().saturating_sub(MAX_CHILD_USAGE_RECORDS);
577 let mut batch = RuntimeUsageBatch {
578 records: Vec::with_capacity(values.len().min(MAX_CHILD_USAGE_RECORDS)),
579 drop_records: Vec::with_capacity(drop_values.len().min(MAX_CHILD_USAGE_RECORDS)),
580 dropped_records: declared_dropped
581 .max(u64::try_from(drop_values.len()).unwrap_or(u64::MAX))
582 .saturating_add(u64::try_from(overflow).unwrap_or(u64::MAX)),
583 };
584 for value in values.iter().take(MAX_CHILD_USAGE_RECORDS) {
585 let parsed = (|| {
586 let source_id = value.get("source_id")?.as_str()?;
587 let route =
588 serde_json::from_value::<EffectiveRouteEnvelope>(value.get("route")?.clone())
589 .ok()?
590 .sanitized_for_persistence();
591 let usage = serde_json::from_value::<Usage>(value.get("usage")?.clone()).ok()?;
592 Some(RuntimeUsageRecord {
593 // Treat metadata as an untrusted persistence boundary. A
594 // stable hash preserves idempotence without retaining the
595 // producer's raw identifier.
596 source_id: usage_source_fingerprint(source_id),
597 usage: EffectiveRouteUsage { route, usage },
598 })
599 })();
600 if let Some(record) = parsed {
601 batch.records.push(record);
602 } else {
603 batch.dropped_records = batch.dropped_records.saturating_add(1);
604 }
605 }
606 let remaining = MAX_CHILD_USAGE_RECORDS.saturating_sub(batch.records.len());
607 for value in drop_values.iter().take(remaining) {
608 let parsed = (|| {
609 let source_id = value.get("source_id")?.as_str()?;
610 let route =
611 serde_json::from_value::<EffectiveRouteEnvelope>(value.get("route")?.clone())
612 .ok()?
613 .sanitized_for_persistence();
614 Some(RuntimeUsageDropRecord {
615 source_id: usage_source_fingerprint(source_id),
616 route,
617 })
618 })();
619 if let Some(record) = parsed {
620 batch.drop_records.push(record);
621 }
622 // Every declared drop slot already contributes to dropped_records,
623 // including malformed entries; do not count the same gap twice.
624 }
625 Some(batch)
626 }
627
628 /// Rehydrate the immutable route envelope emitted with child usage. Legacy or
629 /// incomplete metadata becomes an explicitly unknown route and never borrows
630 /// mutable parent-session facts.
631 #[must_use]
632 pub fn child_route_envelope_from_metadata(
633 metadata: &serde_json::Value,
634 ) -> Option<EffectiveRouteEnvelope> {
635 let model = metadata.get("child_model")?.as_str()?.to_string();
636 let provider = metadata
637 .get("child_provider")
638 .cloned()
639 .and_then(|value| serde_json::from_value(value).ok());
640 let provider_identity = metadata
641 .get("child_provider_identity")
642 .and_then(serde_json::Value::as_str)
643 .map(str::to_string);
644 let billing_mode = metadata
645 .get("child_billing_mode")
646 .cloned()
647 .and_then(|value| serde_json::from_value(value).ok());
648 let dispatched_at = metadata
649 .get("child_dispatched_at")
650 .cloned()
651 .and_then(|value| serde_json::from_value(value).ok());
652
653 let complete = provider.is_some()
654 && provider_identity.is_some()
655 && billing_mode.is_some()
656 && dispatched_at.is_some();
657 Some(
658 EffectiveRouteEnvelope {
659 provider: provider.unwrap_or(ApiProvider::Custom),
660 provider_identity: provider_identity.unwrap_or_else(|| "legacy-unreported".to_string()),
661 model,
662 openrouter_vendor: metadata
663 .get("child_openrouter_vendor")
664 .and_then(serde_json::Value::as_str)
665 .map(str::to_string),
666 billing_surface: metadata
667 .get("child_billing_surface")
668 .and_then(serde_json::Value::as_str)
669 .map(str::to_string),
670 endpoint_fingerprint: metadata
671 .get("child_endpoint_fingerprint")
672 .and_then(serde_json::Value::as_str)
673 .map(str::to_string),
674 provider_live_pricing: metadata
675 .get("child_provider_live_pricing")
676 .cloned()
677 .and_then(|value| serde_json::from_value(value).ok()),
678 billing_mode: billing_mode
679 .filter(|_| complete)
680 .unwrap_or(RouteBillingMode::Unknown),
681 dispatched_at: dispatched_at.unwrap_or_else(|| {
682 DateTime::<Utc>::from_timestamp(0, 0).expect("Unix epoch is representable")
683 }),
684 }
685 .sanitized_for_persistence(),
686 )
687 }
688
689 /// Rehydrate the complete child usage payload emitted by
690 /// [`attach_child_usage_metadata`]. The presence of a canonical child token
691 /// field is significant even when every value is zero: a zero-usage provider
692 /// call still needs a route receipt and coverage classification.
693 #[must_use]
694 pub fn child_usage_from_metadata(metadata: &serde_json::Value) -> Option<Usage> {
695 const TOKEN_FIELDS: &[&str] = &[
696 "child_input_tokens",
697 "child_output_tokens",
698 "child_prompt_cache_hit_tokens",
699 "child_prompt_cache_miss_tokens",
700 "child_prompt_cache_write_tokens",
701 "child_reasoning_tokens",
702 "child_reasoning_replay_tokens",
703 ];
704 if !TOKEN_FIELDS
705 .iter()
706 .any(|field| metadata.get(field).is_some())
707 {
708 return None;
709 }
710
711 fn u32_field(metadata: &serde_json::Value, field: &str) -> Option<u32> {
712 metadata
713 .get(field)
714 .and_then(serde_json::Value::as_u64)
715 .map(|value| u32::try_from(value).unwrap_or(u32::MAX))
716 }
717
718 Some(Usage {
719 input_tokens: u32_field(metadata, "child_input_tokens").unwrap_or(0),
720 output_tokens: u32_field(metadata, "child_output_tokens").unwrap_or(0),
721 prompt_cache_hit_tokens: u32_field(metadata, "child_prompt_cache_hit_tokens"),
722 prompt_cache_miss_tokens: u32_field(metadata, "child_prompt_cache_miss_tokens"),
723 prompt_cache_write_tokens: u32_field(metadata, "child_prompt_cache_write_tokens"),
724 reasoning_tokens: u32_field(metadata, "child_reasoning_tokens"),
725 reasoning_replay_tokens: u32_field(metadata, "child_reasoning_replay_tokens"),
726 server_tool_use: metadata
727 .get("child_server_tool_use")
728 .cloned()
729 .and_then(|value| serde_json::from_value(value).ok()),
730 })
731 }
732
733 impl PendingBackgroundCost {
734 /// Whether anything at all was accrued.
735 ///
736 /// Compared against `Default` rather than checking a subset of fields, so a
737 /// field added later cannot be silently left out of the emptiness test.
738 #[must_use]
739 pub fn is_empty(&self) -> bool {
740 *self == Self::default()
741 }
742 }
743
744 #[derive(Default)]
745 struct ScopedPendingBackgroundCost {
746 generation: u64,
747 pending: PendingBackgroundCost,
748 /// All provider responses accepted in this session generation, including
749 /// batches already drained into the live session projection.
750 seen_usage_source_fingerprints: HashSet<String>,
751 }
752
753 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
754 pub struct CostScopeToken(u64);
755
756 #[cfg(not(test))]
757 static PENDING: OnceLock<Mutex<ScopedPendingBackgroundCost>> = OnceLock::new();
758
759 #[cfg(test)]
760 static TEST_PENDING: OnceLock<
761 Mutex<std::collections::HashMap<std::thread::ThreadId, ScopedPendingBackgroundCost>>,
762 > = OnceLock::new();
763
764 fn with_pending_state_mut<R>(f: impl FnOnce(&mut ScopedPendingBackgroundCost) -> R) -> R {
765 #[cfg(not(test))]
766 {
767 let mut pending = PENDING
768 .get_or_init(|| Mutex::new(ScopedPendingBackgroundCost::default()))
769 .lock()
770 .unwrap_or_else(|e| e.into_inner());
771 f(&mut pending)
772 }
773 #[cfg(test)]
774 {
775 // Rust tests run concurrently. A test-local collector prevents a UI
776 // drain or successful purge in one test from stealing another test's
777 // accounting. Tokio's default test runtime is current-thread, so async
778 // helpers retain this scope across awaits.
779 let mut by_thread = TEST_PENDING
780 .get_or_init(|| Mutex::new(std::collections::HashMap::new()))
781 .lock()
782 .unwrap_or_else(|e| e.into_inner());
783 f(by_thread.entry(std::thread::current().id()).or_default())
784 }
785 }
786
787 /// Runtime accounting gets a cloned, owner-scoped copy of compaction usage.
788 /// This journal is deliberately separate from the TUI pending-money pool:
789 /// taking one runtime owner's records cannot steal or reset the foreground
790 /// session's `/cost` state.
791 const MAX_RUNTIME_USAGE_RECORDS_PER_OWNER: usize = 64;
792
793 #[derive(Default)]
794 struct OwnerRuntimeUsageJournal {
795 records: VecDeque<RuntimeUsageRecord>,
796 drop_records: VecDeque<RuntimeUsageDropRecord>,
797 dropped_records: u64,
798 dropped_source_fingerprints: HashSet<String>,
799 dropped_fingerprint_overflowed: bool,
800 }
801
802 type RuntimeUsageJournal = HashMap<String, OwnerRuntimeUsageJournal>;
803
804 /// Bounded fallback batch returned when no synchronous runtime sink was
805 /// available. `dropped_records` is persisted into the turn so aggregates fail
806 /// closed instead of silently presenting a partial cost as complete.
807 #[derive(Debug, Clone, Default, PartialEq, Eq)]
808 pub struct RuntimeUsageBatch {
809 pub records: Vec<RuntimeUsageRecord>,
810 /// Exact provider-success calls whose usage payload was absent. The
811 /// bounded records retain route billing truth; `dropped_records` remains
812 /// the authoritative total and may exceed this vector after overflow.
813 pub drop_records: Vec<RuntimeUsageDropRecord>,
814 pub dropped_records: u64,
815 }
816
817 /// One owner-scoped usage report with the stable provider-call identity used
818 /// to make durable replay idempotent.
819 #[derive(Debug, Clone, PartialEq, Eq)]
820 pub struct RuntimeUsageRecord {
821 pub source_id: String,
822 pub usage: EffectiveRouteUsage,
823 }
824
825 /// One provider-success response that omitted usage metadata.
826 ///
827 /// The frozen route is required to distinguish money-metered calls from
828 /// subscription/local calls without consulting mutable completion-time config.
829 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
830 pub struct RuntimeUsageDropRecord {
831 pub source_id: String,
832 pub route: EffectiveRouteEnvelope,
833 }
834
835 pub(crate) type RuntimeUsageSink = Arc<dyn Fn(RuntimeUsageRecord) -> bool + Send + Sync>;
836 pub(crate) type RuntimeUsageDropSink = Arc<dyn Fn(RuntimeUsageDropRecord) -> bool + Send + Sync>;
837
838 struct RuntimeUsageSinkEntry {
839 sink: RuntimeUsageSink,
840 dropped_sink: Option<RuntimeUsageDropSink>,
841 leases: usize,
842 terminal: bool,
843 }
844
845 /// Keeps an owner sink alive while a detached child can still report usage.
846 /// The runtime turn may already be terminal; the last child release retires
847 /// the sink only after its final provider response has been durably appended.
848 #[derive(Debug)]
849 pub(crate) struct RuntimeUsageLease {
850 owner: String,
851 active: bool,
852 }
853
854 #[cfg(not(test))]
855 static RUNTIME_USAGE_JOURNAL: OnceLock<Mutex<RuntimeUsageJournal>> = OnceLock::new();
856
857 #[cfg(test)]
858 static TEST_RUNTIME_USAGE_JOURNAL: OnceLock<
859 Mutex<std::collections::HashMap<std::thread::ThreadId, RuntimeUsageJournal>>,
860 > = OnceLock::new();
861
862 #[cfg(not(test))]
863 static RUNTIME_USAGE_SINKS: OnceLock<Mutex<HashMap<String, RuntimeUsageSinkEntry>>> =
864 OnceLock::new();
865
866 /// Sinks are keyed by owner id, and owner ids in tests are short fixture
867 /// strings that repeat across tests. Under the default parallel test harness a
868 /// process-global map let one test's `register_runtime_usage_sink` replace
869 /// another's live sink, and let one test's `finish_runtime_usage_owner` retire
870 /// it — turning exactly-once child accounting into an order-dependent race.
871 /// Scoping by thread matches the pending-cost pool and the runtime journal,
872 /// which are already thread-scoped for the same reason.
873 #[cfg(test)]
874 #[allow(clippy::type_complexity)]
875 static TEST_RUNTIME_USAGE_SINKS: OnceLock<
876 Mutex<HashMap<std::thread::ThreadId, HashMap<String, RuntimeUsageSinkEntry>>>,
877 > = OnceLock::new();
878
879 /// Run `f` against this scope's sink registry.
880 fn with_runtime_usage_sinks<R>(
881 f: impl FnOnce(&mut HashMap<String, RuntimeUsageSinkEntry>) -> R,
882 ) -> R {
883 #[cfg(not(test))]
884 {
885 let mut sinks = RUNTIME_USAGE_SINKS
886 .get_or_init(|| Mutex::new(HashMap::new()))
887 .lock()
888 .unwrap_or_else(|error| error.into_inner());
889 f(&mut sinks)
890 }
891 #[cfg(test)]
892 {
893 let mut by_thread = TEST_RUNTIME_USAGE_SINKS
894 .get_or_init(|| Mutex::new(HashMap::new()))
895 .lock()
896 .unwrap_or_else(|error| error.into_inner());
897 f(by_thread.entry(std::thread::current().id()).or_default())
898 }
899 }
900
901 /// Like [`with_runtime_usage_sinks`], but does not create the registry when it
902 /// has never been initialized. Used on drop paths, where allocating a registry
903 /// to then find it empty would be pointless.
904 fn with_existing_runtime_usage_sinks<R>(
905 f: impl FnOnce(&mut HashMap<String, RuntimeUsageSinkEntry>) -> R,
906 ) -> Option<R> {
907 #[cfg(not(test))]
908 {
909 let sinks = RUNTIME_USAGE_SINKS.get()?;
910 let mut sinks = sinks.lock().unwrap_or_else(|error| error.into_inner());
911 Some(f(&mut sinks))
912 }
913 #[cfg(test)]
914 {
915 let by_thread = TEST_RUNTIME_USAGE_SINKS.get()?;
916 let mut by_thread = by_thread.lock().unwrap_or_else(|error| error.into_inner());
917 let sinks = by_thread.get_mut(&std::thread::current().id())?;
918 Some(f(sinks))
919 }
920 }
921
922 fn with_runtime_usage_journal_mut<R>(f: impl FnOnce(&mut RuntimeUsageJournal) -> R) -> R {
923 #[cfg(not(test))]
924 {
925 let mut journal = RUNTIME_USAGE_JOURNAL
926 .get_or_init(|| Mutex::new(HashMap::new()))
927 .lock()
928 .unwrap_or_else(|error| error.into_inner());
929 f(&mut journal)
930 }
931 #[cfg(test)]
932 {
933 let mut by_thread = TEST_RUNTIME_USAGE_JOURNAL
934 .get_or_init(|| Mutex::new(std::collections::HashMap::new()))
935 .lock()
936 .unwrap_or_else(|error| error.into_inner());
937 f(by_thread.entry(std::thread::current().id()).or_default())
938 }
939 }
940
941 fn record_runtime_usage(
942 owner: &str,
943 source_id: &str,
944 route: &EffectiveRouteEnvelope,
945 usage: &Usage,
946 ) {
947 if usage == &Usage::default() {
948 record_runtime_usage_drop(owner, source_id, route);
949 return;
950 }
951 let owner = owner.trim();
952 if owner.is_empty() {
953 return;
954 }
955 let record = RuntimeUsageRecord {
956 source_id: source_id.to_string(),
957 usage: EffectiveRouteUsage {
958 route: route.sanitized_for_persistence(),
959 usage: usage.clone(),
960 },
961 };
962 let sink =
963 with_runtime_usage_sinks(|sinks| sinks.get(owner).map(|entry| Arc::clone(&entry.sink)));
964 if sink.is_some_and(|sink| sink(record.clone())) {
965 return;
966 }
967 with_runtime_usage_journal_mut(|journal| {
968 let owner_journal = journal.entry(owner.to_string()).or_default();
969 if owner_journal.records.len() == MAX_RUNTIME_USAGE_RECORDS_PER_OWNER {
970 owner_journal.records.pop_front();
971 owner_journal.dropped_records = owner_journal.dropped_records.saturating_add(1);
972 }
973 owner_journal.records.push_back(record);
974 });
975 }
976
977 fn record_runtime_usage_drop(owner: &str, source_id: &str, route: &EffectiveRouteEnvelope) {
978 let owner = owner.trim();
979 if owner.is_empty() {
980 return;
981 }
982 let fingerprint = usage_source_fingerprint(source_id);
983 let sink = with_runtime_usage_sinks(|sinks| {
984 sinks
985 .get(owner)
986 .and_then(|entry| entry.dropped_sink.as_ref().map(Arc::clone))
987 });
988 let record = RuntimeUsageDropRecord {
989 source_id: source_id.to_string(),
990 route: route.sanitized_for_persistence(),
991 };
992 if sink.is_some_and(|sink| sink(record.clone())) {
993 return;
994 }
995 with_runtime_usage_journal_mut(|journal| {
996 let owner_journal = journal.entry(owner.to_string()).or_default();
997 if owner_journal
998 .dropped_source_fingerprints
999 .contains(&fingerprint)
1000 {
1001 return;
1002 }
1003 if owner_journal.dropped_source_fingerprints.len() < MAX_RUNTIME_USAGE_RECORDS_PER_OWNER {
1004 owner_journal
1005 .dropped_source_fingerprints
1006 .insert(fingerprint);
1007 owner_journal.drop_records.push_back(record);
1008 owner_journal.dropped_records = owner_journal.dropped_records.saturating_add(1);
1009 } else if !owner_journal.dropped_fingerprint_overflowed {
1010 // Preserve a bounded fail-closed overflow marker. Once the exact
1011 // identity ledger is full, further unknown ids share this one
1012 // marker so replays cannot grow the count without bound.
1013 owner_journal.dropped_fingerprint_overflowed = true;
1014 owner_journal.dropped_records = owner_journal.dropped_records.saturating_add(1);
1015 }
1016 });
1017 }
1018
1019 fn record_runtime_usage_drop_count(owner: &str, source_id: &str, count: u64) {
1020 let owner = owner.trim();
1021 if owner.is_empty() || count == 0 {
1022 return;
1023 }
1024 let fingerprint = usage_source_fingerprint(source_id);
1025 with_runtime_usage_journal_mut(|journal| {
1026 let owner_journal = journal.entry(owner.to_string()).or_default();
1027 if owner_journal
1028 .dropped_source_fingerprints
1029 .contains(&fingerprint)
1030 {
1031 return;
1032 }
1033 if owner_journal.dropped_source_fingerprints.len() < MAX_RUNTIME_USAGE_RECORDS_PER_OWNER {
1034 owner_journal
1035 .dropped_source_fingerprints
1036 .insert(fingerprint);
1037 owner_journal.dropped_records = owner_journal.dropped_records.saturating_add(count);
1038 } else if !owner_journal.dropped_fingerprint_overflowed {
1039 owner_journal.dropped_fingerprint_overflowed = true;
1040 owner_journal.dropped_records = owner_journal.dropped_records.saturating_add(1);
1041 }
1042 });
1043 }
1044
1045 /// Install a synchronous durability sink for one active runtime turn.
1046 /// Compaction calls invoke this before they return to the engine, so a process
1047 /// crash cannot erase already-reported usage from an in-memory journal.
1048 #[cfg(test)]
1049 pub(crate) fn register_runtime_usage_sink(owner: &str, sink: RuntimeUsageSink) {
1050 register_runtime_usage_sink_with_drop(owner, sink, None);
1051 }
1052
1053 pub(crate) fn register_runtime_usage_sink_with_drop(
1054 owner: &str,
1055 sink: RuntimeUsageSink,
1056 dropped_sink: Option<RuntimeUsageDropSink>,
1057 ) {
1058 let owner = owner.trim();
1059 if owner.is_empty() {
1060 return;
1061 }
1062 with_runtime_usage_sinks(|sinks| {
1063 sinks.insert(
1064 owner.to_string(),
1065 RuntimeUsageSinkEntry {
1066 sink,
1067 dropped_sink,
1068 leases: 0,
1069 terminal: false,
1070 },
1071 );
1072 });
1073 }
1074
1075 /// Redacted durable identity shared by runtime-turn, worker, and interactive
1076 /// session accounting. Raw response ids never need to be persisted merely to
1077 /// make replay idempotent.
1078 #[must_use]
1079 pub(crate) fn usage_source_fingerprint(source_id: &str) -> String {
1080 let source_id = source_id.trim();
1081 let fingerprint = source_id.strip_prefix("routed:").unwrap_or(source_id);
1082 if fingerprint.len() == 64 && fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1083 return fingerprint.to_ascii_lowercase();
1084 }
1085 codewhale_config::catalog::base_url_fingerprint(source_id)
1086 }
1087
1088 /// Install the interactive session's synchronous runtime sink. A detached
1089 /// child may report after the parent mailbox has sealed; its owner lease keeps
1090 /// this sink alive, while the captured scope prevents a later session from
1091 /// inheriting the spend.
1092 #[cfg(test)]
1093 pub(crate) fn register_interactive_runtime_usage_sink(owner: &str, scope: CostScopeToken) {
1094 register_runtime_usage_sink_with_drop(
1095 owner,
1096 Arc::new(move |record| record_interactive_runtime_usage(scope, record)),
1097 Some(Arc::new(move |record| {
1098 record_interactive_runtime_usage_drop(scope, record)
1099 })),
1100 );
1101 }
1102
1103 /// Install an interactive sink whose stale-scope fallback is an origin-session
1104 /// sidecar. `/new` may close the foreground pool while a detached provider call
1105 /// is still running; the sidecar keeps that exact response with the old saved
1106 /// session instead of either dropping it or contaminating the new one.
1107 pub(crate) fn register_persistent_interactive_runtime_usage_sink(
1108 owner: &str,
1109 scope: CostScopeToken,
1110 session_id: &str,
1111 turn_id: &str,
1112 ) {
1113 let Ok(manager) = crate::session_manager::SessionManager::default_location() else {
1114 // With no durable origin gate, leave the owner on the bounded journal
1115 // fallback. An in-memory-only sink could keep accepting a deleted
1116 // session's responses after its directory becomes available again.
1117 return;
1118 };
1119 register_persistent_interactive_runtime_usage_sink_at(
1120 owner,
1121 scope,
1122 session_id,
1123 turn_id,
1124 manager.sessions_dir().to_path_buf(),
1125 );
1126 }
1127
1128 fn register_persistent_interactive_runtime_usage_sink_at(
1129 owner: &str,
1130 scope: CostScopeToken,
1131 session_id: &str,
1132 turn_id: &str,
1133 sessions_dir: std::path::PathBuf,
1134 ) {
1135 let usage_session_id = session_id.to_string();
1136 let usage_turn_id = turn_id.to_string();
1137 let drop_session_id = usage_session_id.clone();
1138 let drop_turn_id = usage_turn_id.clone();
1139 let usage_sessions_dir = sessions_dir.clone();
1140 register_runtime_usage_sink_with_drop(
1141 owner,
1142 Arc::new(move |record| {
1143 crate::session_manager::SessionManager::new(usage_sessions_dir.clone())
1144 .map(|manager| {
1145 report_effective_route_for_interactive_origin_with_manager(
1146 scope,
1147 &usage_session_id,
1148 &usage_turn_id,
1149 &record.source_id,
1150 &record.usage.route,
1151 &record.usage.usage,
1152 &manager,
1153 )
1154 })
1155 .unwrap_or(false)
1156 }),
1157 Some(Arc::new(move |record| {
1158 crate::session_manager::SessionManager::new(sessions_dir.clone())
1159 .map(|manager| {
1160 report_unreceipted_for_interactive_origin_with_manager(
1161 scope,
1162 &drop_session_id,
1163 &drop_turn_id,
1164 &record.source_id,
1165 &record.route,
1166 &manager,
1167 )
1168 })
1169 .unwrap_or(false)
1170 })),
1171 );
1172 }
1173
1174 #[cfg(test)]
1175 pub(crate) fn register_persistent_interactive_runtime_usage_sink_for_test(
1176 owner: &str,
1177 scope: CostScopeToken,
1178 session_id: &str,
1179 turn_id: &str,
1180 manager: &crate::session_manager::SessionManager,
1181 ) {
1182 register_persistent_interactive_runtime_usage_sink_at(
1183 owner,
1184 scope,
1185 session_id,
1186 turn_id,
1187 manager.sessions_dir().to_path_buf(),
1188 );
1189 }
1190
1191 /// Acquire an owner lease for a root sub-agent runtime. Runtime clones inherit
1192 /// the lease, so top-level detached children can outlive the parent mailbox
1193 /// without losing their accounting path.
1194 pub(crate) fn acquire_runtime_usage_lease(owner: &str) -> Option<RuntimeUsageLease> {
1195 let owner = owner.trim();
1196 if owner.is_empty() {
1197 return None;
1198 }
1199 with_runtime_usage_sinks(|sinks| {
1200 let entry = sinks.get_mut(owner)?;
1201 entry.leases = entry.leases.saturating_add(1);
1202 Some(RuntimeUsageLease {
1203 owner: owner.to_string(),
1204 active: true,
1205 })
1206 })
1207 }
1208
1209 impl RuntimeUsageLease {
1210 #[must_use]
1211 pub(crate) fn owner(&self) -> &str {
1212 &self.owner
1213 }
1214 }
1215
1216 impl Clone for RuntimeUsageLease {
1217 fn clone(&self) -> Self {
1218 if self.active {
1219 let cloned = with_runtime_usage_sinks(|sinks| {
1220 sinks.get_mut(&self.owner).map(|entry| {
1221 entry.leases = entry.leases.saturating_add(1);
1222 })
1223 });
1224 if cloned.is_some() {
1225 return Self {
1226 owner: self.owner.clone(),
1227 active: true,
1228 };
1229 }
1230 }
1231 Self {
1232 owner: self.owner.clone(),
1233 active: false,
1234 }
1235 }
1236 }
1237
1238 impl Drop for RuntimeUsageLease {
1239 fn drop(&mut self) {
1240 if !self.active {
1241 return;
1242 }
1243 with_existing_runtime_usage_sinks(|sinks| {
1244 let should_remove = sinks.get_mut(&self.owner).is_some_and(|entry| {
1245 entry.leases = entry.leases.saturating_sub(1);
1246 entry.terminal && entry.leases == 0
1247 });
1248 if should_remove {
1249 sinks.remove(&self.owner);
1250 }
1251 });
1252 }
1253 }
1254
1255 /// Mark the parent turn terminal. An owner with detached children stays live
1256 /// until their cloned leases drop; owners without children retire now.
1257 pub(crate) fn finish_runtime_usage_owner(owner: &str) {
1258 with_existing_runtime_usage_sinks(|sinks| {
1259 let should_remove = sinks.get_mut(owner).is_some_and(|entry| {
1260 entry.terminal = true;
1261 entry.leases == 0
1262 });
1263 if should_remove {
1264 sinks.remove(owner);
1265 }
1266 });
1267 }
1268
1269 /// Take only the background usage assigned to one runtime turn.
1270 /// Other runtime turns and the TUI pending pool remain untouched.
1271 #[must_use]
1272 pub fn take_runtime_usage(owner: &str) -> RuntimeUsageBatch {
1273 with_runtime_usage_journal_mut(|journal| {
1274 journal
1275 .remove(owner)
1276 .map_or_else(RuntimeUsageBatch::default, |entry| RuntimeUsageBatch {
1277 records: entry.records.into_iter().collect(),
1278 drop_records: entry.drop_records.into_iter().collect(),
1279 dropped_records: entry.dropped_records,
1280 })
1281 })
1282 }
1283
1284 /// Capture the current session/run generation before starting a background
1285 /// provider request. The same token must be supplied when its usage returns.
1286 #[must_use]
1287 pub fn scope_token() -> CostScopeToken {
1288 with_pending_state_mut(|state| CostScopeToken(state.generation))
1289 }
1290
1291 /// Atomically close the current cost scope and start a fresh generation.
1292 /// Reports from old in-flight requests are rejected after this returns, so
1293 /// `/new` and session load cannot inherit another session's spend.
1294 #[must_use]
1295 pub fn close_current_scope() -> PendingBackgroundCost {
1296 with_pending_state_mut(|state| {
1297 let pending = std::mem::take(&mut state.pending);
1298 state.generation = state.generation.wrapping_add(1);
1299 state.seen_usage_source_fingerprints.clear();
1300 pending
1301 })
1302 }
1303
1304 /// Restore the durable response identities belonging to the newly loaded
1305 /// session. Callers close the previous scope before loading, so replacing the
1306 /// set cannot make another session's usage visible here.
1307 pub(crate) fn restore_usage_source_fingerprints(fingerprints: impl IntoIterator<Item = String>) {
1308 with_pending_state_mut(|state| {
1309 state.seen_usage_source_fingerprints = fingerprints.into_iter().collect();
1310 })
1311 }
1312
1313 /// Mark a deleted origin's response handled in its original live generation.
1314 /// This suppresses legacy mailbox fallback without putting deleted usage or
1315 /// even its fingerprint into the pending pool or a durable session snapshot.
1316 fn acknowledge_retired_usage_source(scope: CostScopeToken, source_id: &str) {
1317 with_pending_state_mut(|state| {
1318 if state.generation == scope.0 {
1319 state
1320 .seen_usage_source_fingerprints
1321 .insert(usage_source_fingerprint(source_id));
1322 }
1323 });
1324 }
1325
1326 /// Whether this session generation already accepted or retired a response.
1327 /// Used by mailbox delivery to avoid pricing a response that the synchronous
1328 /// runtime sink already handled.
1329 #[must_use]
1330 pub(crate) fn usage_source_seen(source_id: &str) -> bool {
1331 let fingerprint = usage_source_fingerprint(source_id);
1332 with_pending_state_mut(|state| state.seen_usage_source_fingerprints.contains(&fingerprint))
1333 }
1334
1335 /// The non-secret identity of a background LLM call's route.
1336 ///
1337 /// Background helpers run off a bare client with no app `Config`, so they cannot
1338 /// resolve credential-derived billing. They *can* report what they actually know
1339 /// — which provider, which configured route, which wire model, which endpoint —
1340 /// and this type carries exactly that, so the pricing decision is made from
1341 /// evidence instead of from a provider name.
1342 #[derive(Debug, Clone, Copy)]
1343 #[cfg(test)]
1344 pub struct BackgroundRoute<'a> {
1345 /// Provider kind serving the call.
1346 pub provider: ApiProvider,
1347 /// Configured route identity (the `[providers.<name>]` key), when the
1348 /// caller has one. This is a user-chosen label, not a credential.
1349 pub provider_identity: Option<&'a str>,
1350 /// Wire model id as sent on the request.
1351 pub wire_model: &'a str,
1352 /// Concrete base URL the request went to, when the client exposes one.
1353 ///
1354 /// Only ever used to derive a billing-surface classification and a
1355 /// SHA-256 fingerprint; the URL itself never leaves this struct.
1356 pub base_url: Option<&'a str>,
1357 }
1358
1359 #[cfg(test)]
1360 impl<'a> BackgroundRoute<'a> {
1361 /// A route with no endpoint information.
1362 #[must_use]
1363 pub fn new(provider: ApiProvider, wire_model: &'a str) -> Self {
1364 Self {
1365 provider,
1366 provider_identity: None,
1367 wire_model,
1368 base_url: None,
1369 }
1370 }
1371
1372 #[must_use]
1373 pub fn with_base_url(mut self, base_url: Option<&'a str>) -> Self {
1374 self.base_url = base_url;
1375 self
1376 }
1377
1378 /// Non-secret billing-surface classification for this endpoint.
1379 #[must_use]
1380 pub fn billing_surface(&self) -> Option<&'static str> {
1381 crate::pricing::billing_surface_for_route(self.provider, self.base_url)
1382 }
1383
1384 /// SHA-256 fingerprint of the normalized base URL, or `None` when unknown.
1385 ///
1386 /// This is the same digest the catalog scopes live rows on, so a live
1387 /// pricing row can be proven to price *this* endpoint.
1388 #[must_use]
1389 pub fn endpoint_fingerprint(&self) -> Option<String> {
1390 self.base_url.and_then(endpoint_fingerprint)
1391 }
1392
1393 /// Billing presentation derivable without app config.
1394 #[must_use]
1395 pub fn billing(&self) -> BillingPresentation {
1396 crate::route_billing::for_endpoint_without_config(self.provider, self.base_url)
1397 }
1398
1399 /// A redacted, stable receipt describing this route.
1400 #[must_use]
1401 pub fn receipt(&self, currency: &str) -> String {
1402 route_receipt(
1403 self.provider,
1404 self.provider_identity,
1405 self.wire_model,
1406 self.billing_surface(),
1407 self.endpoint_fingerprint().as_deref(),
1408 self.billing().into(),
1409 currency,
1410 )
1411 }
1412 }
1413
1414 /// Format one redacted route receipt.
1415 ///
1416 /// Contains only: provider kind, configured route label, wire model,
1417 /// billing-surface classification, endpoint fingerprint, billing mode, and the currency the
1418 /// estimate is denominated in. It deliberately contains no URL, no credential,
1419 /// and no filesystem path, so it is safe to persist into a saved session and to
1420 /// log. This is the single formatter, so the foreground turn path and the
1421 /// background pool cannot describe the same route two different ways.
1422 #[must_use]
1423 pub fn route_receipt(
1424 provider: ApiProvider,
1425 provider_identity: Option<&str>,
1426 wire_model: &str,
1427 billing_surface: Option<&str>,
1428 endpoint_fingerprint: Option<&str>,
1429 billing_mode: RouteBillingMode,
1430 currency: &str,
1431 ) -> String {
1432 format!(
1433 "provider={} identity={} model={} surface={} endpoint_fp={} billing_mode={} currency={currency}",
1434 provider.as_str(),
1435 safe_receipt_field(provider_identity.unwrap_or("-")),
1436 safe_receipt_field(wire_model),
1437 safe_receipt_field(billing_surface.unwrap_or("unreported")),
1438 safe_receipt_field(endpoint_fingerprint.unwrap_or("unreported")),
1439 match billing_mode {
1440 RouteBillingMode::Metered => "metered",
1441 RouteBillingMode::Subscription => "subscription",
1442 RouteBillingMode::Local => "local",
1443 RouteBillingMode::Unknown => "unknown",
1444 },
1445 )
1446 }
1447
1448 const MAX_RECEIPT_FIELD_CHARS: usize = 96;
1449
1450 fn safe_receipt_field(raw: &str) -> String {
1451 let sanitized = sanitize_persisted_route_label(raw);
1452 let mut out = String::with_capacity(raw.len().min(MAX_RECEIPT_FIELD_CHARS));
1453 let mut previous_separator = false;
1454 for ch in sanitized.chars() {
1455 if out.chars().count() >= MAX_RECEIPT_FIELD_CHARS {
1456 break;
1457 }
1458 let safe = if ch.is_alphanumeric() || matches!(ch, '.' | '_' | '-' | '/' | ':' | '+') {
1459 ch
1460 } else {
1461 '_'
1462 };
1463 let separator = safe == '_';
1464 if separator && previous_separator {
1465 continue;
1466 }
1467 out.push(safe);
1468 previous_separator = separator;
1469 }
1470 if out.is_empty() { "-".to_string() } else { out }
1471 }
1472
1473 pub(crate) fn sanitize_persisted_route_label(raw: &str) -> String {
1474 const MAX_PERSISTED_ROUTE_LABEL_CHARS: usize = 256;
1475 let value = raw.trim();
1476 let lower = value.to_ascii_lowercase();
1477
1478 if value.is_empty() {
1479 return "-".to_string();
1480 }
1481
1482 // URLs are not route labels. Endpoints have a dedicated, validated hash
1483 // field; persisting a URL here risks leaking userinfo, query credentials,
1484 // or fragments through a custom provider/model name.
1485 if value.contains("://") {
1486 return "redacted-url".to_string();
1487 }
1488
1489 let authorization_value = ["bearer ", "basic ", "digest ", "token ", "apikey "]
1490 .iter()
1491 .any(|scheme| lower.starts_with(scheme))
1492 || lower.contains("authorization:")
1493 || lower.contains("proxy-authorization:");
1494 if authorization_value {
1495 return "redacted-credential".to_string();
1496 }
1497
1498 // Reject credential assignments regardless of common casing or separator:
1499 // FOO_API_KEY=..., access-token:..., password = ....
1500 for (index, ch) in value.char_indices() {
1501 if !matches!(ch, '=' | ':') {
1502 continue;
1503 }
1504 let name = lower[..index]
1505 .trim()
1506 .trim_matches(|ch: char| matches!(ch, '"' | '\'' | '{' | '[' | ','));
1507 let name = name.rsplit([' ', ',', ';']).next().unwrap_or(name);
1508 let normalized = name.replace('-', "_");
1509 if normalized.ends_with("api_key")
1510 || normalized.ends_with("token")
1511 || normalized.ends_with("secret")
1512 || normalized.ends_with("password")
1513 || normalized.ends_with("passwd")
1514 {
1515 return "redacted-credential".to_string();
1516 }
1517 }
1518
1519 // Common credential token prefixes. These are intentionally checked at
1520 // word boundaries so model ids containing an incidental "sk" survive.
1521 let credential_prefix = lower
1522 .split(|ch: char| ch.is_whitespace() || matches!(ch, '=' | ':' | ',' | ';' | '"' | '\''))
1523 .filter(|part| !part.is_empty())
1524 .any(|part| {
1525 [
1526 "sk-",
1527 "sk_",
1528 "rk-",
1529 "pk-",
1530 "ghp_",
1531 "gho_",
1532 "ghu_",
1533 "ghs_",
1534 "github_pat_",
1535 "hf_",
1536 "glpat-",
1537 "xoxb-",
1538 "xoxp-",
1539 "xoxa-",
1540 "akia",
1541 "aiza",
1542 "eyj",
1543 ]
1544 .iter()
1545 .any(|prefix| part.starts_with(prefix))
1546 });
1547 if credential_prefix {
1548 return "redacted-credential".to_string();
1549 }
1550
1551 let windows_absolute = value.as_bytes().get(1) == Some(&b':')
1552 && value
1553 .as_bytes()
1554 .get(2)
1555 .is_some_and(|separator| matches!(separator, b'/' | b'\\'));
1556 let contains_local_root = [
1557 "/users/",
1558 "/volumes/",
1559 "/home/",
1560 "/private/",
1561 "\\users\\",
1562 "file://",
1563 "/.ssh/",
1564 "\\.ssh\\",
1565 ]
1566 .iter()
1567 .any(|needle| lower.contains(needle));
1568 let looks_like_relative_path = value.contains('\\')
1569 || lower.starts_with(".ssh/")
1570 || lower.starts_with(".ssh\\")
1571 || lower.split('/').any(|segment| {
1572 matches!(
1573 segment,
1574 "." | ".."
1575 | ".ssh"
1576 | ".config"
1577 | "secrets"
1578 | "secret"
1579 | "credentials"
1580 | "credential"
1581 | "relative"
1582 | "workspace"
1583 | "tmp"
1584 )
1585 });
1586 if std::path::Path::new(value).is_absolute()
1587 || windows_absolute
1588 || value.starts_with("~/")
1589 || value.starts_with("./")
1590 || value.starts_with("../")
1591 || contains_local_root
1592 || looks_like_relative_path
1593 {
1594 return "redacted-local-path".to_string();
1595 }
1596 let bounded: String = value
1597 .chars()
1598 .filter(|ch| !ch.is_control())
1599 .take(MAX_PERSISTED_ROUTE_LABEL_CHARS)
1600 .collect();
1601 if bounded.is_empty() {
1602 "-".to_string()
1603 } else {
1604 bounded
1605 }
1606 }
1607
1608 /// Validate and canonicalize an endpoint before producing the cryptographic
1609 /// fingerprint persisted in a receipt. Secret-bearing/malformed URLs receive
1610 /// no fingerprint at all; userinfo, query strings, and fragments are never fed
1611 /// to the hash function.
1612 #[must_use]
1613 pub fn endpoint_fingerprint(base_url: &str) -> Option<String> {
1614 let mut parsed = reqwest::Url::parse(base_url.trim()).ok()?;
1615 if !matches!(parsed.scheme(), "http" | "https")
1616 || !parsed.username().is_empty()
1617 || parsed.password().is_some()
1618 || parsed.query().is_some()
1619 || parsed.fragment().is_some()
1620 || parsed.host_str().is_none()
1621 {
1622 return None;
1623 }
1624 parsed.set_query(None);
1625 parsed.set_fragment(None);
1626 let canonical = parsed.as_str().trim_end_matches('/');
1627 Some(codewhale_config::catalog::base_url_fingerprint(canonical))
1628 }
1629
1630 /// Currency tag for a receipt, derived from authoritative currency coverage —
1631 /// not from a positive amount, because a zero-usage priced turn is still a
1632 /// valid zero in its published currency.
1633 #[must_use]
1634 pub fn currency_tag(audit: &TurnCostAudit) -> &'static str {
1635 match (audit.usd_priced, audit.cny_priced) {
1636 (true, true) => "usd+cny",
1637 (true, false) => "usd",
1638 (false, true) => "cny",
1639 (false, false) => "unpriced",
1640 }
1641 }
1642
1643 /// Background callers report their LLM usage here.
1644 ///
1645 /// The route is priced through the same [`crate::pricing::audit_turn_cost_for_route_on_endpoint`]
1646 /// the foreground turn path uses, so a background turn cannot be counted under
1647 /// different rules than a parent turn. Adds no money when the route is exactly
1648 /// non-metered (a local runtime, an OAuth broker, a named plan endpoint), and
1649 /// counts the turn as *missing spend* whenever it is money-metered or of unknown
1650 /// basis but could not be priced — an unknown basis is never waved through as a
1651 /// subscription (#4318).
1652 #[cfg(test)]
1653 pub fn report(scope: CostScopeToken, route: &BackgroundRoute<'_>, usage: &Usage) {
1654 let billing_surface = route.billing_surface();
1655 let fingerprint = route.endpoint_fingerprint();
1656 let audit = crate::pricing::audit_turn_cost_for_route_on_endpoint(
1657 route.provider,
1658 route.wire_model,
1659 billing_surface,
1660 fingerprint.as_deref(),
1661 usage,
1662 chrono::Utc::now(),
1663 route.billing(),
1664 );
1665 record(scope, route.receipt(currency_tag(&audit)), &audit, usage);
1666 }
1667
1668 /// Report background usage to exactly one accounting owner.
1669 ///
1670 /// Runtime-owned calls go only to the durable runtime sink. Calls without a
1671 /// runtime owner belong to the interactive TUI pool. Mixing both paths would
1672 /// count one provider response twice in hosts that expose both projections.
1673 pub fn report_effective_route_for_runtime(
1674 scope: CostScopeToken,
1675 runtime_owner: Option<&str>,
1676 source_id: &str,
1677 route: &EffectiveRouteEnvelope,
1678 usage: &Usage,
1679 ) {
1680 if let Some(owner) = runtime_owner {
1681 record_runtime_usage(owner, source_id, route, usage);
1682 } else {
1683 record_interactive_runtime_usage(
1684 scope,
1685 RuntimeUsageRecord {
1686 source_id: source_id.to_string(),
1687 usage: EffectiveRouteUsage {
1688 route: route.sanitized_for_persistence(),
1689 usage: usage.clone(),
1690 },
1691 },
1692 );
1693 }
1694 }
1695
1696 /// Report an interactive auxiliary response against its immutable origin.
1697 /// A stale foreground scope is not an error: it means `/new` or session load
1698 /// already moved on, so the exact receipt is appended to the old session's
1699 /// durable sidecar instead of being redirected to the active session.
1700 pub(crate) fn report_effective_route_for_interactive_origin(
1701 scope: CostScopeToken,
1702 session_id: &str,
1703 turn_id: &str,
1704 source_id: &str,
1705 route: &EffectiveRouteEnvelope,
1706 usage: &Usage,
1707 ) {
1708 let persisted =
1709 crate::session_manager::SessionManager::default_location().is_ok_and(|manager| {
1710 report_effective_route_for_interactive_origin_with_manager(
1711 scope, session_id, turn_id, source_id, route, usage, &manager,
1712 )
1713 });
1714 if !persisted {
1715 tracing::warn!("late interactive usage could not be persisted for its origin session");
1716 }
1717 }
1718
1719 fn report_effective_route_for_interactive_origin_with_manager(
1720 scope: CostScopeToken,
1721 session_id: &str,
1722 turn_id: &str,
1723 source_id: &str,
1724 route: &EffectiveRouteEnvelope,
1725 usage: &Usage,
1726 manager: &crate::session_manager::SessionManager,
1727 ) -> bool {
1728 let record = RuntimeUsageRecord {
1729 source_id: source_id.to_string(),
1730 usage: EffectiveRouteUsage {
1731 route: route.sanitized_for_persistence(),
1732 usage: usage.clone(),
1733 },
1734 };
1735 match manager.with_live_session_origin(session_id, || {
1736 record_interactive_runtime_usage(scope, record.clone())
1737 }) {
1738 Ok(None) => {
1739 acknowledge_retired_usage_source(scope, source_id);
1740 return true;
1741 }
1742 Ok(Some(true)) => return true,
1743 Ok(Some(false)) => {}
1744 Err(_) => return false,
1745 }
1746 manager
1747 .persist_late_runtime_usage(session_id, turn_id, &record)
1748 .unwrap_or(false)
1749 }
1750
1751 pub(crate) fn report_unreceipted_for_interactive_origin(
1752 scope: CostScopeToken,
1753 session_id: &str,
1754 turn_id: &str,
1755 source_id: &str,
1756 route: &EffectiveRouteEnvelope,
1757 ) {
1758 let persisted =
1759 crate::session_manager::SessionManager::default_location().is_ok_and(|manager| {
1760 report_unreceipted_for_interactive_origin_with_manager(
1761 scope, session_id, turn_id, source_id, route, &manager,
1762 )
1763 });
1764 if !persisted {
1765 tracing::warn!(
1766 "late interactive missing-usage receipt could not be persisted for its origin session"
1767 );
1768 }
1769 }
1770
1771 fn report_unreceipted_for_interactive_origin_with_manager(
1772 scope: CostScopeToken,
1773 session_id: &str,
1774 turn_id: &str,
1775 source_id: &str,
1776 route: &EffectiveRouteEnvelope,
1777 manager: &crate::session_manager::SessionManager,
1778 ) -> bool {
1779 let record = RuntimeUsageDropRecord {
1780 source_id: source_id.to_string(),
1781 route: route.sanitized_for_persistence(),
1782 };
1783 match manager.with_live_session_origin(session_id, || {
1784 record_interactive_runtime_usage_drop(scope, record.clone())
1785 }) {
1786 Ok(None) => {
1787 acknowledge_retired_usage_source(scope, source_id);
1788 return true;
1789 }
1790 Ok(Some(true)) => return true,
1791 Ok(Some(false)) => {}
1792 Err(_) => return false,
1793 }
1794 manager
1795 .persist_late_runtime_drop(session_id, turn_id, &record)
1796 .unwrap_or(false)
1797 }
1798
1799 /// Record one provider-success response whose usage payload was absent.
1800 ///
1801 /// Callers must supply the same fixed-length, non-secret source identity they
1802 /// would use for a normal routed usage receipt. Runtime owners persist one
1803 /// bounded dropped-coverage marker; ownerless/interactive calls add one
1804 /// unpriced coverage turn to the captured session scope. Replays are
1805 /// idempotent, and a stale scope cannot contaminate a later session.
1806 pub(crate) fn report_unreceipted_provider_success(
1807 scope: CostScopeToken,
1808 runtime_owner: Option<&str>,
1809 source_id: &str,
1810 route: &EffectiveRouteEnvelope,
1811 ) {
1812 if let Some(owner) = runtime_owner {
1813 record_runtime_usage_drop(owner, source_id, route);
1814 } else {
1815 record_interactive_runtime_usage_drop(
1816 scope,
1817 RuntimeUsageDropRecord {
1818 source_id: source_id.to_string(),
1819 route: route.sanitized_for_persistence(),
1820 },
1821 );
1822 }
1823 }
1824
1825 /// Settle one bounded routed-usage batch without repricing or losing exact
1826 /// missing-usage route evidence. Replaying the same batch is idempotent by the
1827 /// stable per-response source ids. Any residual count whose exact record was
1828 /// truncated remains an explicit fail-closed coverage gap.
1829 pub(crate) fn report_runtime_usage_batch(
1830 scope: CostScopeToken,
1831 runtime_owner: Option<&str>,
1832 batch: &RuntimeUsageBatch,
1833 ) {
1834 for record in &batch.records {
1835 report_effective_route_for_runtime(
1836 scope,
1837 runtime_owner,
1838 &record.source_id,
1839 &record.usage.route,
1840 &record.usage.usage,
1841 );
1842 }
1843 for record in &batch.drop_records {
1844 report_unreceipted_provider_success(scope, runtime_owner, &record.source_id, &record.route);
1845 }
1846
1847 let residual = batch
1848 .dropped_records
1849 .saturating_sub(u64::try_from(batch.drop_records.len()).unwrap_or(u64::MAX));
1850 if residual == 0 {
1851 return;
1852 }
1853 let mut identities = batch
1854 .records
1855 .iter()
1856 .map(|record| usage_source_fingerprint(&record.source_id))
1857 .chain(
1858 batch
1859 .drop_records
1860 .iter()
1861 .map(|record| usage_source_fingerprint(&record.source_id)),
1862 )
1863 .take(MAX_RUNTIME_USAGE_RECORDS_PER_OWNER)
1864 .collect::<Vec<_>>();
1865 identities.sort_unstable();
1866 let residual_source = format!(
1867 "runtime-usage-batch-residual:{}",
1868 usage_source_fingerprint(&format!(
1869 "{}:{}:{}:{}",
1870 batch.records.len(),
1871 batch.drop_records.len(),
1872 batch.dropped_records,
1873 identities.join(":")
1874 ))
1875 );
1876 if let Some(owner) = runtime_owner {
1877 record_runtime_usage_drop_count(owner, &residual_source, residual);
1878 } else {
1879 record_interactive_runtime_usage_drop_count(scope, &residual_source, residual);
1880 }
1881 }
1882
1883 #[must_use]
1884 pub(crate) fn background_cost_for_runtime_usage(
1885 record: &RuntimeUsageRecord,
1886 ) -> PendingBackgroundCost {
1887 if record.usage.usage == Usage::default() {
1888 return background_cost_for_runtime_drop(&RuntimeUsageDropRecord {
1889 source_id: record.source_id.clone(),
1890 route: record.usage.route.clone(),
1891 });
1892 }
1893 let mut pending = PendingBackgroundCost::default();
1894 let fingerprint = usage_source_fingerprint(&record.source_id);
1895 let audit = record.usage.route.audit(&record.usage.usage);
1896 let receipt = record.usage.route.receipt(&audit);
1897 pending.usage_source_fingerprints.insert(fingerprint);
1898 fold_audit_into_pending(&mut pending, receipt, &audit, &record.usage.usage);
1899 pending
1900 }
1901
1902 #[must_use]
1903 pub(crate) fn background_cost_for_runtime_drop(
1904 record: &RuntimeUsageDropRecord,
1905 ) -> PendingBackgroundCost {
1906 let mut pending = PendingBackgroundCost::default();
1907 pending
1908 .usage_source_fingerprints
1909 .insert(usage_source_fingerprint(&record.source_id));
1910 if !matches!(
1911 record.route.billing_mode,
1912 RouteBillingMode::Subscription | RouteBillingMode::Local
1913 ) {
1914 pending.unpriced_turns = 1;
1915 pending.cny_unpriced_turns = 1;
1916 pending
1917 .unpriced_reasons
1918 .insert("provider_success_missing_usage");
1919 pending
1920 .cny_unpriced_reasons
1921 .insert("provider_success_missing_usage");
1922 }
1923 pending
1924 }
1925
1926 /// Fold one already-computed audit into the pending pool.
1927 #[cfg(test)]
1928 fn record(scope: CostScopeToken, route_receipt: String, audit: &TurnCostAudit, usage: &Usage) {
1929 with_pending_state_mut(|state| {
1930 if state.generation != scope.0 {
1931 return;
1932 }
1933 fold_audit_into_pending(&mut state.pending, route_receipt, audit, usage);
1934 });
1935 }
1936
1937 fn record_interactive_runtime_usage(scope: CostScopeToken, record: RuntimeUsageRecord) -> bool {
1938 if record.usage.usage == Usage::default() {
1939 return record_interactive_runtime_usage_drop(
1940 scope,
1941 RuntimeUsageDropRecord {
1942 source_id: record.source_id,
1943 route: record.usage.route,
1944 },
1945 );
1946 }
1947 with_pending_state_mut(|state| {
1948 if state.generation != scope.0 {
1949 return false;
1950 }
1951 let fingerprint = usage_source_fingerprint(&record.source_id);
1952 if !state
1953 .seen_usage_source_fingerprints
1954 .insert(fingerprint.clone())
1955 {
1956 return true;
1957 }
1958 let audit = record.usage.route.audit(&record.usage.usage);
1959 let receipt = record.usage.route.receipt(&audit);
1960 state.pending.usage_source_fingerprints.insert(fingerprint);
1961 fold_audit_into_pending(&mut state.pending, receipt, &audit, &record.usage.usage);
1962 true
1963 })
1964 }
1965
1966 fn record_interactive_runtime_usage_drop(
1967 scope: CostScopeToken,
1968 record: RuntimeUsageDropRecord,
1969 ) -> bool {
1970 with_pending_state_mut(|state| {
1971 if state.generation != scope.0 {
1972 return false;
1973 }
1974 let fingerprint = usage_source_fingerprint(&record.source_id);
1975 if !state
1976 .seen_usage_source_fingerprints
1977 .insert(fingerprint.clone())
1978 {
1979 return true;
1980 }
1981 state.pending.usage_source_fingerprints.insert(fingerprint);
1982 if matches!(
1983 record.route.billing_mode,
1984 RouteBillingMode::Subscription | RouteBillingMode::Local
1985 ) {
1986 return true;
1987 }
1988 state.pending.unpriced_turns = state.pending.unpriced_turns.saturating_add(1);
1989 state.pending.cny_unpriced_turns = state.pending.cny_unpriced_turns.saturating_add(1);
1990 state
1991 .pending
1992 .unpriced_reasons
1993 .insert("provider_success_missing_usage");
1994 state
1995 .pending
1996 .cny_unpriced_reasons
1997 .insert("provider_success_missing_usage");
1998 true
1999 })
2000 }
2001
2002 fn record_interactive_runtime_usage_drop_count(
2003 scope: CostScopeToken,
2004 source_id: &str,
2005 count: u64,
2006 ) -> bool {
2007 with_pending_state_mut(|state| {
2008 if state.generation != scope.0 {
2009 return false;
2010 }
2011 let fingerprint = usage_source_fingerprint(source_id);
2012 if !state
2013 .seen_usage_source_fingerprints
2014 .insert(fingerprint.clone())
2015 {
2016 return true;
2017 }
2018 state.pending.usage_source_fingerprints.insert(fingerprint);
2019 let count = u32::try_from(count).unwrap_or(u32::MAX);
2020 state.pending.unpriced_turns = state.pending.unpriced_turns.saturating_add(count);
2021 state.pending.cny_unpriced_turns = state.pending.cny_unpriced_turns.saturating_add(count);
2022 state
2023 .pending
2024 .unpriced_reasons
2025 .insert("routed_usage_receipt_missing");
2026 state
2027 .pending
2028 .cny_unpriced_reasons
2029 .insert("routed_usage_receipt_missing");
2030 true
2031 })
2032 }
2033
2034 fn fold_audit_into_pending(
2035 pending: &mut PendingBackgroundCost,
2036 route_receipt: String,
2037 audit: &TurnCostAudit,
2038 usage: &Usage,
2039 ) {
2040 if let Some(provenance) = audit.provenance.as_ref() {
2041 pending.pricing_provenances.insert(provenance.label());
2042 }
2043 if let Some(defect) = audit.live_pricing_defect.as_ref() {
2044 if audit.estimate.is_some() {
2045 pending.live_pricing_defects.insert(defect.label());
2046 } else {
2047 pending.live_pricing_unusable_defects.insert(defect.label());
2048 }
2049 }
2050 if let Some(cost) = audit.estimate {
2051 pending.estimate = pending.estimate.saturating_add(cost);
2052 }
2053
2054 // Only money-metered/unknown-basis turns belong in missing-money coverage
2055 // or its reason list. A subscription/local receipt is still audited below,
2056 // but `not_money_metered` must never be presented as a gap in a subtotal.
2057 if audit.counts_toward_money_coverage() {
2058 if audit.usd_priced {
2059 pending.priced_turns = pending.priced_turns.saturating_add(1);
2060 } else {
2061 pending.unpriced_turns = pending.unpriced_turns.saturating_add(1);
2062 }
2063 if audit.cny_priced {
2064 pending.cny_priced_turns = pending.cny_priced_turns.saturating_add(1);
2065 } else {
2066 pending.cny_unpriced_turns = pending.cny_unpriced_turns.saturating_add(1);
2067 }
2068 for class in &audit.unpriced_classes {
2069 pending.unpriced_classes.insert(class.label());
2070 }
2071 if !audit.usd_priced
2072 && let Some(reason) = audit.unpriced_reason
2073 {
2074 pending.unpriced_reasons.insert(reason.label());
2075 }
2076 if !audit.cny_priced {
2077 pending.cny_unpriced_reasons.insert(
2078 audit
2079 .unpriced_reason
2080 .map_or("currency_not_published", |reason| reason.label()),
2081 );
2082 }
2083 }
2084
2085 // Record which token classes this route actually billed on, so a receipt
2086 // shows whether cache-write/reasoning telemetry was even present.
2087 pending
2088 .route_receipts
2089 .insert(receipt_with_usage_classes(route_receipt, usage));
2090 }
2091
2092 /// Drain the pending pool, returning it and resetting to zero.
2093 ///
2094 /// Money and its completeness leave together, so a caller can never fold a
2095 /// subtotal into a session total without the counters that qualify it.
2096 #[must_use]
2097 pub fn drain() -> PendingBackgroundCost {
2098 with_pending_state_mut(|state| std::mem::take(&mut state.pending))
2099 }
2100
2101 /// Reset the pool to zero without consuming. Test-only helper for
2102 /// suites that share the static and need to start from a known
2103 /// state. Production code should always use [`drain`].
2104 #[cfg(test)]
2105 pub fn reset_for_tests() {
2106 with_pending_state_mut(|state| {
2107 state.pending = PendingBackgroundCost::default();
2108 state.seen_usage_source_fingerprints.clear();
2109 });
2110 with_runtime_usage_journal_mut(HashMap::clear);
2111 }
2112
2113 #[cfg(test)]
2114 pub(crate) struct TestCostScope;
2115
2116 #[cfg(test)]
2117 impl Drop for TestCostScope {
2118 fn drop(&mut self) {
2119 reset_for_tests();
2120 }
2121 }
2122
2123 #[cfg(test)]
2124 pub(crate) fn test_scope() -> TestCostScope {
2125 reset_for_tests();
2126 TestCostScope
2127 }
2128
2129 #[cfg(test)]
2130 mod tests {
2131 use super::*;
2132
2133 fn configured_fixture_receipt() -> (crate::config::Config, EffectiveRouteEnvelope, Usage) {
2134 let config = toml::from_str(include_str!(
2135 "../../config/tests/fixtures/custom_models.toml"
2136 ))
2137 .unwrap();
2138 let receipt = EffectiveRouteEnvelope::capture(
2139 Some(&config),
2140 ApiProvider::Deepseek,
2141 "deepseek",
2142 "deepseek-v4.1-flash-expires-on-0910",
2143 Some("https://models.example.test/v1"),
2144 Utc::now(),
2145 );
2146 let usage = Usage {
2147 input_tokens: 1_000_000,
2148 output_tokens: 1_000_000,
2149 ..Usage::default()
2150 };
2151 (config, receipt, usage)
2152 }
2153
2154 #[test]
2155 fn configured_model_estimate_is_frozen_and_exactly_bound() {
2156 let (mut config, receipt, usage) = configured_fixture_receipt();
2157 let audit = receipt.audit(&usage);
2158 assert_eq!(
2159 audit.provenance,
2160 Some(codewhale_config::pricing::PricingProvenance::UserOverride)
2161 );
2162 assert!((audit.estimate.unwrap().usd - 2.0).abs() < 1e-12);
2163 let frozen: EffectiveRouteEnvelope =
2164 serde_json::from_str(&serde_json::to_string(&receipt).unwrap()).unwrap();
2165 config.custom_models.as_mut().unwrap()[0]
2166 .cost
2167 .as_mut()
2168 .unwrap()
2169 .input = Some(9.0);
2170 assert!((frozen.audit(&usage).estimate.unwrap().usd - 2.0).abs() < 1e-12);
2171 for (field, value) in [
2172 ("model", "deepseek-v4.1-flash"),
2173 ("identity", "other-provider"),
2174 ("endpoint", "https://other.example.test/v1"),
2175 ] {
2176 let mut wrong = frozen.clone();
2177 match field {
2178 "model" => wrong.model = value.into(),
2179 "identity" => wrong.provider_identity = value.into(),
2180 _ => wrong.endpoint_fingerprint = endpoint_fingerprint(value),
2181 }
2182 assert!(wrong.audit(&usage).estimate.is_none(), "{field}");
2183 }
2184 for billing in [RouteBillingMode::Local, RouteBillingMode::Subscription] {
2185 let mut nonmoney = frozen.clone();
2186 nonmoney.billing_mode = billing;
2187 assert_eq!(
2188 nonmoney.audit(&usage).unpriced_reason,
2189 Some(crate::pricing::UnpricedReason::NotMoneyMetered)
2190 );
2191 }
2192 let mut cached = usage.clone();
2193 cached.prompt_cache_write_tokens = Some(500);
2194 assert!(frozen.audit(&cached).estimate.is_none());
2195 }
2196
2197 #[test]
2198 fn configured_model_missing_prices_stay_unknown_and_vendor_pin_still_wins() {
2199 let (mut config, receipt, usage) = configured_fixture_receipt();
2200 config.custom_models.as_mut().unwrap()[0].cost = None;
2201 let unknown = EffectiveRouteEnvelope::capture(
2202 Some(&config),
2203 receipt.provider,
2204 receipt.provider_identity.clone(),
2205 receipt.model.clone(),
2206 Some("https://models.example.test/v1"),
2207 receipt.dispatched_at,
2208 );
2209 assert!(unknown.provider_live_pricing.is_some());
2210 assert!(unknown.audit(&usage).estimate.is_none());
2211 let mut pinned = receipt;
2212 pinned.provider = ApiProvider::Openrouter;
2213 pinned.openrouter_vendor = Some("exact-upstream".into());
2214 pinned.billing_mode = RouteBillingMode::Metered;
2215 assert_eq!(
2216 pinned.audit(&usage).unpriced_reason,
2217 Some(crate::pricing::UnpricedReason::RoutingDependentPrice)
2218 );
2219 }
2220
2221 #[test]
2222 fn configured_model_client_keeps_its_metadata_snapshot_after_reload() {
2223 let (mut config, _, usage) = configured_fixture_receipt();
2224 config.api_key = Some("fixture-not-a-provider-credential".into());
2225 let id = "deepseek-v4.1-flash-expires-on-0910";
2226 let route =
2227 crate::route_runtime::resolve_runtime_route(&config, ApiProvider::Deepseek, Some(id))
2228 .unwrap();
2229 let client =
2230 crate::client::CodewhaleClient::from_candidate(&config, &route.candidate).unwrap();
2231 config.custom_models.as_mut().unwrap()[0]
2232 .cost
2233 .as_mut()
2234 .unwrap()
2235 .input = Some(9.0);
2236 let envelope = client.effective_route_envelope(id, Utc::now());
2237 assert!((envelope.audit(&usage).estimate.unwrap().usd - 2.0).abs() < 1e-12);
2238 assert_eq!(
2239 client
2240 .effective_route_envelope("other-model", Utc::now())
2241 .provider_live_pricing,
2242 None
2243 );
2244 }
2245
2246 struct ProviderCatalogTestReset;
2247
2248 impl Drop for ProviderCatalogTestReset {
2249 fn drop(&mut self) {
2250 crate::provider_catalog_live::reset_cache_for_test();
2251 crate::provider_lake::clear_live_snapshot();
2252 }
2253 }
2254
2255 fn priced_provider_delta(
2256 provider: &str,
2257 model: &str,
2258 fingerprint: &str,
2259 fetched_at: u64,
2260 ) -> codewhale_config::catalog::ProviderCatalogDelta {
2261 priced_provider_delta_with_rates(provider, model, fingerprint, fetched_at, 1.25, 5.0)
2262 }
2263
2264 fn priced_provider_delta_with_rates(
2265 provider: &str,
2266 model: &str,
2267 fingerprint: &str,
2268 fetched_at: u64,
2269 input: f64,
2270 output: f64,
2271 ) -> codewhale_config::catalog::ProviderCatalogDelta {
2272 codewhale_config::catalog::ProviderCatalogDelta {
2273 provider: provider.to_string(),
2274 base_url_fingerprint: fingerprint.to_string(),
2275 fetched_at,
2276 offerings: vec![codewhale_config::catalog::CatalogOffering {
2277 provider: provider.to_string(),
2278 wire_model_id: model.to_string(),
2279 endpoint_key: "chat".to_string(),
2280 cost: Some(codewhale_config::models_dev::ModelsDevCost {
2281 input: Some(input),
2282 output: Some(output),
2283 cache_read: Some(0.25),
2284 cache_write: None,
2285 }),
2286 ..Default::default()
2287 }],
2288 }
2289 }
2290
2291 fn custom_usage_envelope(
2292 identity: &str,
2293 model: &str,
2294 fingerprint: &str,
2295 billing_mode: RouteBillingMode,
2296 dispatched_at: DateTime<Utc>,
2297 ) -> EffectiveRouteEnvelope {
2298 provider_live_usage_envelope(
2299 ApiProvider::Custom,
2300 identity,
2301 model,
2302 fingerprint,
2303 Some(crate::pricing::UNCLASSIFIED_BILLING_SURFACE),
2304 billing_mode,
2305 dispatched_at,
2306 )
2307 }
2308
2309 fn provider_live_usage_envelope(
2310 provider: ApiProvider,
2311 identity: &str,
2312 model: &str,
2313 fingerprint: &str,
2314 billing_surface: Option<&str>,
2315 billing_mode: RouteBillingMode,
2316 dispatched_at: DateTime<Utc>,
2317 ) -> EffectiveRouteEnvelope {
2318 let provider_live_pricing =
2319 u64::try_from(dispatched_at.timestamp())
2320 .ok()
2321 .and_then(|dispatched_at_unix| {
2322 crate::provider_catalog_live::fresh_provider_live_pricing_quote_at(
2323 provider,
2324 identity,
2325 model,
2326 fingerprint,
2327 dispatched_at_unix,
2328 )
2329 });
2330 EffectiveRouteEnvelope {
2331 provider,
2332 provider_identity: identity.to_string(),
2333 model: model.to_string(),
2334 openrouter_vendor: None,
2335 billing_surface: billing_surface.map(str::to_string),
2336 endpoint_fingerprint: Some(fingerprint.to_string()),
2337 provider_live_pricing,
2338 billing_mode,
2339 dispatched_at,
2340 }
2341 }
2342
2343 fn small_usage() -> Usage {
2344 Usage {
2345 input_tokens: 1_000,
2346 output_tokens: 500,
2347 ..Default::default()
2348 }
2349 }
2350
2351 #[test]
2352 fn baseten_usage_prices_only_the_reviewed_identity_on_the_official_endpoint() {
2353 let _env = crate::test_support::lock_test_env();
2354 let _live = crate::provider_lake::lock_live_snapshot();
2355 let home = tempfile::tempdir().expect("test home");
2356 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
2357 let _reset = ProviderCatalogTestReset;
2358 crate::provider_catalog_live::reset_cache_for_test();
2359 crate::provider_lake::clear_live_snapshot();
2360
2361 let now = Utc::now();
2362 let fetched_at = u64::try_from(now.timestamp()).expect("nonnegative timestamp");
2363 let model = "synthetic-baseten-priced-model";
2364 let fingerprint = codewhale_config::catalog::base_url_fingerprint(
2365 codewhale_config::catalog::BASETEN_BASE_URL,
2366 );
2367 crate::provider_catalog_live::record_success(priced_provider_delta(
2368 codewhale_config::catalog::BASETEN_PROVIDER_ID,
2369 model,
2370 &fingerprint,
2371 fetched_at,
2372 ));
2373 let usage = Usage {
2374 input_tokens: 1_000_000,
2375 ..Usage::default()
2376 };
2377
2378 let exact = custom_usage_envelope(
2379 codewhale_config::catalog::BASETEN_PROVIDER_ID,
2380 model,
2381 &fingerprint,
2382 RouteBillingMode::Unknown,
2383 now,
2384 )
2385 .audit(&usage);
2386 assert!(exact.is_priced(), "{exact:?}");
2387 assert_eq!(
2388 exact.provenance,
2389 Some(codewhale_config::pricing::PricingProvenance::ProviderLive)
2390 );
2391 assert_eq!(exact.estimate.expect("priced").usd, 1.25);
2392
2393 // A reviewed schema alias remains a distinct custom ownership scope.
2394 // It becomes billable only after that exact identity refreshed its own
2395 // catalog; it cannot borrow the canonical `baseten` partition above.
2396 let alias = "base-ten";
2397 crate::provider_catalog_live::record_success(priced_provider_delta(
2398 alias,
2399 model,
2400 &fingerprint,
2401 fetched_at,
2402 ));
2403 let alias_audit =
2404 custom_usage_envelope(alias, model, &fingerprint, RouteBillingMode::Unknown, now)
2405 .audit(&usage);
2406 assert!(alias_audit.is_priced(), "{alias_audit:?}");
2407 assert_eq!(
2408 alias_audit.provenance,
2409 Some(codewhale_config::pricing::PricingProvenance::ProviderLive)
2410 );
2411 assert_eq!(alias_audit.estimate.expect("priced").usd, 1.25);
2412
2413 let generic = custom_usage_envelope(
2414 "custom-lab",
2415 model,
2416 &fingerprint,
2417 RouteBillingMode::Metered,
2418 now,
2419 )
2420 .audit(&usage);
2421 assert!(!generic.is_priced(), "{generic:?}");
2422 // The endpoint fingerprint establishes Baseten's billing contract no
2423 // matter the table name, so the failure is an unverified price for
2424 // this identity — not an unknown basis (#6289).
2425 assert_eq!(
2426 generic.unpriced_reason,
2427 Some(crate::pricing::UnpricedReason::UnverifiedLivePricing)
2428 );
2429
2430 let wrong_fingerprint =
2431 codewhale_config::catalog::base_url_fingerprint("https://proxy.example/v1");
2432 let wrong_endpoint = custom_usage_envelope(
2433 codewhale_config::catalog::BASETEN_PROVIDER_ID,
2434 model,
2435 &wrong_fingerprint,
2436 RouteBillingMode::Metered,
2437 now,
2438 )
2439 .audit(&usage);
2440 assert!(!wrong_endpoint.is_priced(), "{wrong_endpoint:?}");
2441 assert_eq!(
2442 wrong_endpoint.unpriced_reason,
2443 Some(crate::pricing::UnpricedReason::UnknownBillingBasis)
2444 );
2445 }
2446
2447 #[test]
2448 fn baseten_usage_rejects_unknown_stale_and_failed_live_catalogs() {
2449 let _env = crate::test_support::lock_test_env();
2450 let _live = crate::provider_lake::lock_live_snapshot();
2451 let home = tempfile::tempdir().expect("test home");
2452 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
2453 let _reset = ProviderCatalogTestReset;
2454 crate::provider_catalog_live::reset_cache_for_test();
2455 crate::provider_lake::clear_live_snapshot();
2456
2457 let now = Utc::now();
2458 let now_unix = u64::try_from(now.timestamp()).expect("nonnegative timestamp");
2459 let model = "synthetic-baseten-status-model";
2460 let fingerprint = codewhale_config::catalog::base_url_fingerprint(
2461 codewhale_config::catalog::BASETEN_BASE_URL,
2462 );
2463 let unknown_route = custom_usage_envelope(
2464 codewhale_config::catalog::BASETEN_PROVIDER_ID,
2465 model,
2466 &fingerprint,
2467 RouteBillingMode::Unknown,
2468 now,
2469 );
2470 assert!(unknown_route.provider_live_pricing.is_none());
2471 let usage = Usage {
2472 input_tokens: 1_000_000,
2473 ..Usage::default()
2474 };
2475
2476 // A same-model price owned by another custom partition cannot price a
2477 // Baseten receipt whose exact catalog was never refreshed.
2478 crate::provider_catalog_live::record_success(priced_provider_delta(
2479 "other-custom",
2480 model,
2481 &fingerprint,
2482 now_unix,
2483 ));
2484 let unknown = unknown_route.audit(&usage);
2485 assert!(!unknown.is_priced(), "{unknown:?}");
2486 assert_eq!(
2487 unknown.unpriced_reason,
2488 Some(crate::pricing::UnpricedReason::UnverifiedLivePricing)
2489 );
2490
2491 let stale_at = now_unix
2492 .saturating_sub(crate::provider_catalog_live::DEFAULT_PROVIDER_CATALOG_TTL_SECS)
2493 .saturating_sub(1);
2494 crate::provider_catalog_live::record_success(priced_provider_delta(
2495 codewhale_config::catalog::BASETEN_PROVIDER_ID,
2496 model,
2497 &fingerprint,
2498 stale_at,
2499 ));
2500 let stale_route = custom_usage_envelope(
2501 codewhale_config::catalog::BASETEN_PROVIDER_ID,
2502 model,
2503 &fingerprint,
2504 RouteBillingMode::Unknown,
2505 now,
2506 );
2507 assert!(stale_route.provider_live_pricing.is_none());
2508 let stale = stale_route.audit(&usage);
2509 assert!(!stale.is_priced(), "{stale:?}");
2510 assert_eq!(
2511 stale.unpriced_reason,
2512 Some(crate::pricing::UnpricedReason::UnverifiedLivePricing)
2513 );
2514
2515 crate::provider_catalog_live::record_success(priced_provider_delta(
2516 codewhale_config::catalog::BASETEN_PROVIDER_ID,
2517 model,
2518 &fingerprint,
2519 now_unix,
2520 ));
2521 crate::provider_catalog_live::record_failure(
2522 codewhale_config::catalog::BASETEN_PROVIDER_ID,
2523 &fingerprint,
2524 codewhale_config::catalog::CatalogRefreshError::Network,
2525 );
2526 let failed_route = custom_usage_envelope(
2527 codewhale_config::catalog::BASETEN_PROVIDER_ID,
2528 model,
2529 &fingerprint,
2530 RouteBillingMode::Unknown,
2531 now,
2532 );
2533 assert!(failed_route.provider_live_pricing.is_none());
2534 let failed = failed_route.audit(&usage);
2535 assert!(!failed.is_priced(), "{failed:?}");
2536 assert_eq!(
2537 failed.unpriced_reason,
2538 Some(crate::pricing::UnpricedReason::UnverifiedLivePricing)
2539 );
2540 }
2541
2542 #[test]
2543 fn reviewed_provider_live_quotes_survive_same_second_refresh_and_key_state_changes() {
2544 let _env = crate::test_support::lock_test_env();
2545 let _live = crate::provider_lake::lock_live_snapshot();
2546 let home = tempfile::tempdir().expect("test home");
2547 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
2548 let _reset = ProviderCatalogTestReset;
2549 crate::provider_catalog_live::reset_cache_for_test();
2550 crate::provider_lake::clear_live_snapshot();
2551
2552 let now = Utc::now();
2553 let fetched_at = u64::try_from(now.timestamp()).expect("nonnegative timestamp");
2554 let cases = [
2555 (
2556 ApiProvider::Openrouter,
2557 ApiProvider::Openrouter.as_str(),
2558 "synthetic-openrouter-frozen-price",
2559 codewhale_config::catalog::base_url_fingerprint(
2560 crate::config::DEFAULT_OPENROUTER_BASE_URL,
2561 ),
2562 crate::pricing::AGGREGATOR_BILLING_SURFACE,
2563 RouteBillingMode::Metered,
2564 ),
2565 (
2566 ApiProvider::Custom,
2567 codewhale_config::catalog::BASETEN_PROVIDER_ID,
2568 "synthetic-baseten-frozen-price",
2569 codewhale_config::catalog::base_url_fingerprint(
2570 codewhale_config::catalog::BASETEN_BASE_URL,
2571 ),
2572 crate::pricing::UNCLASSIFIED_BILLING_SURFACE,
2573 RouteBillingMode::Unknown,
2574 ),
2575 ];
2576 let usage = Usage {
2577 input_tokens: 1_000_000,
2578 ..Usage::default()
2579 };
2580
2581 for (provider, identity, model, fingerprint, surface, mode) in cases {
2582 crate::provider_catalog_live::record_success(priced_provider_delta_with_rates(
2583 identity,
2584 model,
2585 &fingerprint,
2586 fetched_at,
2587 1.25,
2588 5.0,
2589 ));
2590 let first = provider_live_usage_envelope(
2591 provider,
2592 identity,
2593 model,
2594 &fingerprint,
2595 Some(surface),
2596 mode,
2597 now,
2598 );
2599 let first_quote = first
2600 .provider_live_pricing
2601 .as_ref()
2602 .expect("fresh exact scope freezes a quote");
2603
2604 // A second refresh in the same Unix second must still be a distinct
2605 // catalog revision and must not retroactively change `first`.
2606 crate::provider_catalog_live::record_success(priced_provider_delta_with_rates(
2607 identity,
2608 model,
2609 &fingerprint,
2610 fetched_at,
2611 9.5,
2612 19.0,
2613 ));
2614 let second = provider_live_usage_envelope(
2615 provider,
2616 identity,
2617 model,
2618 &fingerprint,
2619 Some(surface),
2620 mode,
2621 now,
2622 );
2623 let second_quote = second
2624 .provider_live_pricing
2625 .as_ref()
2626 .expect("replacement fresh scope freezes a quote");
2627 assert_ne!(
2628 first_quote.catalog_revision, second_quote.catalog_revision,
2629 "same-second price changes need distinct revisions"
2630 );
2631
2632 crate::provider_catalog_live::record_failure(
2633 identity,
2634 &fingerprint,
2635 codewhale_config::catalog::CatalogRefreshError::Unauthorized,
2636 );
2637 if provider == ApiProvider::Custom {
2638 // Baseten's same URL can represent another account after a key
2639 // switch. Starting that refresh clears the mutable old scope.
2640 let _new_key_refresh = crate::provider_catalog_live::begin_refresh_for_identity(
2641 provider,
2642 identity,
2643 codewhale_config::catalog::BASETEN_BASE_URL,
2644 );
2645 }
2646
2647 let first_audit = first.audit(&usage);
2648 let second_audit = second.audit(&usage);
2649 assert_eq!(first_audit.estimate.expect("first quote priced").usd, 1.25);
2650 assert_eq!(second_audit.estimate.expect("second quote priced").usd, 9.5);
2651
2652 let after_mutation = provider_live_usage_envelope(
2653 provider,
2654 identity,
2655 model,
2656 &fingerprint,
2657 Some(surface),
2658 mode,
2659 now,
2660 );
2661 assert!(
2662 after_mutation.provider_live_pricing.is_none(),
2663 "failed or cleared mutable state cannot mint a new quote"
2664 );
2665 }
2666 }
2667
2668 #[test]
2669 fn legacy_no_quote_receipts_cannot_be_retro_priced_by_a_later_refresh() {
2670 let _env = crate::test_support::lock_test_env();
2671 let _live = crate::provider_lake::lock_live_snapshot();
2672 let home = tempfile::tempdir().expect("test home");
2673 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
2674 let _reset = ProviderCatalogTestReset;
2675 crate::provider_catalog_live::reset_cache_for_test();
2676 crate::provider_lake::clear_live_snapshot();
2677
2678 let now = Utc::now();
2679 let fetched_at = u64::try_from(now.timestamp()).expect("nonnegative timestamp");
2680 let routes = [
2681 provider_live_usage_envelope(
2682 ApiProvider::Openrouter,
2683 ApiProvider::Openrouter.as_str(),
2684 "synthetic-openrouter-legacy",
2685 &codewhale_config::catalog::base_url_fingerprint(
2686 crate::config::DEFAULT_OPENROUTER_BASE_URL,
2687 ),
2688 Some(crate::pricing::AGGREGATOR_BILLING_SURFACE),
2689 RouteBillingMode::Metered,
2690 now,
2691 ),
2692 custom_usage_envelope(
2693 codewhale_config::catalog::BASETEN_PROVIDER_ID,
2694 "synthetic-baseten-legacy",
2695 &codewhale_config::catalog::base_url_fingerprint(
2696 codewhale_config::catalog::BASETEN_BASE_URL,
2697 ),
2698 RouteBillingMode::Unknown,
2699 now,
2700 ),
2701 ];
2702 assert!(
2703 routes
2704 .iter()
2705 .all(|route| route.provider_live_pricing.is_none())
2706 );
2707
2708 for route in &routes {
2709 crate::provider_catalog_live::record_success(priced_provider_delta(
2710 &route.provider_identity,
2711 &route.model,
2712 route.endpoint_fingerprint.as_deref().expect("fingerprint"),
2713 fetched_at,
2714 ));
2715 let audit = route.audit(&Usage {
2716 input_tokens: 1_000_000,
2717 ..Usage::default()
2718 });
2719 assert_eq!(
2720 audit.unpriced_reason,
2721 Some(if route.provider == ApiProvider::Openrouter {
2722 crate::pricing::UnpricedReason::NoPricingRow
2723 } else {
2724 crate::pricing::UnpricedReason::UnverifiedLivePricing
2725 }),
2726 "a completion-time refresh must not price {route:?}"
2727 );
2728 }
2729 }
2730
2731 #[test]
2732 fn openrouter_offline_bundled_price_is_immutable_after_dispatch() {
2733 let _env = crate::test_support::lock_test_env();
2734 let _live = crate::provider_lake::lock_live_snapshot();
2735 let home = tempfile::tempdir().expect("test home");
2736 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
2737 let _reset = ProviderCatalogTestReset;
2738 crate::provider_catalog_live::reset_cache_for_test();
2739 crate::provider_lake::clear_live_snapshot();
2740
2741 let dispatched_at = Utc::now();
2742 let fetched_at = u64::try_from(dispatched_at.timestamp()).expect("timestamp");
2743 let model = "qwen/qwen3.8-flash";
2744 let fingerprint = codewhale_config::catalog::base_url_fingerprint(
2745 crate::config::DEFAULT_OPENROUTER_BASE_URL,
2746 );
2747 let route = provider_live_usage_envelope(
2748 ApiProvider::Openrouter,
2749 ApiProvider::Openrouter.as_str(),
2750 model,
2751 &fingerprint,
2752 Some(crate::pricing::AGGREGATOR_BILLING_SURFACE),
2753 RouteBillingMode::Metered,
2754 dispatched_at,
2755 );
2756 assert!(route.provider_live_pricing.is_none());
2757
2758 let usage = Usage {
2759 input_tokens: 1_000_000,
2760 ..Usage::default()
2761 };
2762 let offline = route.audit(&usage);
2763 assert_eq!(
2764 offline.estimate.expect("bundled OpenRouter price").usd,
2765 0.16
2766 );
2767 assert_eq!(
2768 offline.provenance,
2769 Some(codewhale_config::pricing::PricingProvenance::ModelsDevBundled)
2770 );
2771
2772 // A later mutable refresh cannot change a turn that had no quote at
2773 // the application-dispatch boundary.
2774 crate::provider_catalog_live::record_success(priced_provider_delta_with_rates(
2775 ApiProvider::Openrouter.as_str(),
2776 model,
2777 &fingerprint,
2778 fetched_at,
2779 19.0,
2780 29.0,
2781 ));
2782 let after_refresh = route.audit(&usage);
2783 assert_eq!(after_refresh, offline);
2784
2785 // Admission without provider usage does not create a charge.
2786 let no_usage = route.audit(&Usage::default());
2787 let no_usage_estimate = no_usage.estimate.expect("known zero usage is priced");
2788 assert_eq!(no_usage_estimate.usd, 0.0);
2789 assert_eq!(no_usage_estimate.cny, 0.0);
2790 }
2791
2792 #[test]
2793 fn provider_live_quotes_reject_future_prices_and_every_route_binding_mismatch() {
2794 let _env = crate::test_support::lock_test_env();
2795 let _live = crate::provider_lake::lock_live_snapshot();
2796 let home = tempfile::tempdir().expect("test home");
2797 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
2798 let _reset = ProviderCatalogTestReset;
2799 crate::provider_catalog_live::reset_cache_for_test();
2800 crate::provider_lake::clear_live_snapshot();
2801
2802 let dispatched_at = Utc::now();
2803 let dispatch_unix = u64::try_from(dispatched_at.timestamp()).expect("timestamp");
2804 let future_at = dispatched_at + chrono::Duration::seconds(1);
2805 let future_unix = dispatch_unix.saturating_add(1);
2806 let cases = [
2807 (
2808 ApiProvider::Openrouter,
2809 ApiProvider::Openrouter.as_str(),
2810 "synthetic-openrouter-future",
2811 codewhale_config::catalog::base_url_fingerprint(
2812 crate::config::DEFAULT_OPENROUTER_BASE_URL,
2813 ),
2814 crate::pricing::AGGREGATOR_BILLING_SURFACE,
2815 RouteBillingMode::Metered,
2816 ),
2817 (
2818 ApiProvider::Custom,
2819 codewhale_config::catalog::BASETEN_PROVIDER_ID,
2820 "synthetic-baseten-future",
2821 codewhale_config::catalog::base_url_fingerprint(
2822 codewhale_config::catalog::BASETEN_BASE_URL,
2823 ),
2824 crate::pricing::UNCLASSIFIED_BILLING_SURFACE,
2825 RouteBillingMode::Unknown,
2826 ),
2827 ];
2828 let usage = Usage {
2829 input_tokens: 1_000_000,
2830 ..Usage::default()
2831 };
2832
2833 for (provider, identity, model, fingerprint, surface, mode) in cases {
2834 crate::provider_catalog_live::record_success(priced_provider_delta(
2835 identity,
2836 model,
2837 &fingerprint,
2838 future_unix,
2839 ));
2840 let no_future_quote = provider_live_usage_envelope(
2841 provider,
2842 identity,
2843 model,
2844 &fingerprint,
2845 Some(surface),
2846 mode,
2847 dispatched_at,
2848 );
2849 assert!(no_future_quote.provider_live_pricing.is_none());
2850 assert_eq!(
2851 no_future_quote.audit(&usage).unpriced_reason,
2852 Some(if provider == ApiProvider::Openrouter {
2853 crate::pricing::UnpricedReason::NoPricingRow
2854 } else {
2855 crate::pricing::UnpricedReason::UnverifiedLivePricing
2856 })
2857 );
2858
2859 let captured = provider_live_usage_envelope(
2860 provider,
2861 identity,
2862 model,
2863 &fingerprint,
2864 Some(surface),
2865 mode,
2866 future_at,
2867 );
2868 assert!(captured.provider_live_pricing.is_some());
2869
2870 let mut future_relative_to_dispatch = captured.clone();
2871 future_relative_to_dispatch.dispatched_at = dispatched_at;
2872 assert_eq!(
2873 future_relative_to_dispatch.audit(&usage).unpriced_reason,
2874 Some(crate::pricing::UnpricedReason::UnverifiedLivePricing)
2875 );
2876 let persisted = serde_json::to_value(&future_relative_to_dispatch)
2877 .expect("invalid future quote serializes only as absent");
2878 assert!(persisted["provider_live_pricing"].is_null());
2879
2880 let mut wrong_model = captured.clone();
2881 wrong_model.model.push_str("-other");
2882 assert_eq!(
2883 wrong_model.audit(&usage).unpriced_reason,
2884 Some(crate::pricing::UnpricedReason::UnverifiedLivePricing)
2885 );
2886
2887 let mut wrong_identity = captured.clone();
2888 wrong_identity.provider_identity.push_str("-other");
2889 // The endpoint fingerprint still establishes the billing contract,
2890 // so a renamed identity fails quote verification (#6289).
2891 assert_eq!(
2892 wrong_identity.audit(&usage).unpriced_reason,
2893 Some(crate::pricing::UnpricedReason::UnverifiedLivePricing)
2894 );
2895
2896 let mut wrong_endpoint = captured;
2897 wrong_endpoint.endpoint_fingerprint = Some(
2898 codewhale_config::catalog::base_url_fingerprint("https://proxy.example/v1"),
2899 );
2900 assert_eq!(
2901 wrong_endpoint.audit(&usage).unpriced_reason,
2902 Some(if provider == ApiProvider::Custom {
2903 crate::pricing::UnpricedReason::UnknownBillingBasis
2904 } else {
2905 crate::pricing::UnpricedReason::UnverifiedLivePricing
2906 })
2907 );
2908 }
2909 }
2910
2911 #[test]
2912 fn provider_live_quote_serialization_is_secret_free_and_legacy_compatible() {
2913 let _env = crate::test_support::lock_test_env();
2914 let _live = crate::provider_lake::lock_live_snapshot();
2915 let home = tempfile::tempdir().expect("test home");
2916 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
2917 let _reset = ProviderCatalogTestReset;
2918 crate::provider_catalog_live::reset_cache_for_test();
2919 crate::provider_lake::clear_live_snapshot();
2920
2921 let now = Utc::now();
2922 let fetched_at = u64::try_from(now.timestamp()).expect("nonnegative timestamp");
2923 let model = "synthetic-baseten-serialized-quote";
2924 let fingerprint = codewhale_config::catalog::base_url_fingerprint(
2925 codewhale_config::catalog::BASETEN_BASE_URL,
2926 );
2927 crate::provider_catalog_live::record_success(priced_provider_delta(
2928 codewhale_config::catalog::BASETEN_PROVIDER_ID,
2929 model,
2930 &fingerprint,
2931 fetched_at,
2932 ));
2933 let route = custom_usage_envelope(
2934 codewhale_config::catalog::BASETEN_PROVIDER_ID,
2935 model,
2936 &fingerprint,
2937 RouteBillingMode::Unknown,
2938 now,
2939 );
2940 assert!(route.provider_live_pricing.is_some());
2941
2942 let serialized = serde_json::to_string(&route).expect("serialize frozen route");
2943 assert!(serialized.contains("provider_live_pricing"));
2944 assert!(serialized.contains("catalog_revision"));
2945 assert!(serialized.contains("input_per_million"));
2946 for secret in [
2947 codewhale_config::catalog::BASETEN_BASE_URL,
2948 "api_key",
2949 "Bearer ",
2950 ] {
2951 // The assertion message must not itself become a logging sink for
2952 // the credential fragment it checks for — name the check, not the
2953 // secret.
2954 assert!(
2955 !serialized.contains(secret),
2956 "frozen route serialization leaked a credential fragment"
2957 );
2958 }
2959
2960 let mut child = serde_json::json!({});
2961 attach_child_usage_metadata(&mut child, &route, &Usage::default());
2962 let child_route = child_route_envelope_from_metadata(&child).expect("child route");
2963 assert_eq!(child_route, route.sanitized_for_persistence());
2964
2965 let mut legacy: serde_json::Value =
2966 serde_json::from_str(&serialized).expect("route JSON value");
2967 legacy
2968 .as_object_mut()
2969 .expect("route object")
2970 .remove("provider_live_pricing");
2971 let legacy: EffectiveRouteEnvelope =
2972 serde_json::from_value(legacy).expect("legacy route remains readable");
2973 assert!(legacy.provider_live_pricing.is_none());
2974 let audit = legacy.audit(&Usage {
2975 input_tokens: 1_000_000,
2976 ..Usage::default()
2977 });
2978 assert_eq!(
2979 audit.unpriced_reason,
2980 Some(crate::pricing::UnpricedReason::UnverifiedLivePricing)
2981 );
2982
2983 let mut wrong_model = route.clone();
2984 wrong_model.model.push_str("-other");
2985 assert_eq!(
2986 wrong_model.audit(&Usage::default()).unpriced_reason,
2987 Some(crate::pricing::UnpricedReason::UnverifiedLivePricing)
2988 );
2989 }
2990
2991 #[test]
2992 fn routed_child_batch_is_preferred_bounded_and_sanitized() {
2993 let route = deepseek_envelope();
2994 let records = vec![
2995 RuntimeUsageRecord {
2996 source_id: "raw-provider-response-id-one".to_string(),
2997 usage: EffectiveRouteUsage {
2998 route: route.clone(),
2999 usage: Usage {
3000 input_tokens: 11,
3001 ..Usage::default()
3002 },
3003 },
3004 },
3005 RuntimeUsageRecord {
3006 source_id: "raw-provider-response-id-two".to_string(),
3007 usage: EffectiveRouteUsage {
3008 route: route.clone(),
3009 usage: Usage {
3010 output_tokens: 7,
3011 ..Usage::default()
3012 },
3013 },
3014 },
3015 ];
3016 let mut metadata = serde_json::json!({});
3017 attach_child_usage_metadata(&mut metadata, &route, &Usage::default());
3018 attach_child_usage_batch_metadata(
3019 &mut metadata,
3020 &RuntimeUsageBatch {
3021 records,
3022 drop_records: Vec::new(),
3023 dropped_records: 0,
3024 },
3025 );
3026
3027 let serialized = serde_json::to_string(&metadata).expect("batch metadata");
3028 assert!(!serialized.contains("raw-provider-response-id"));
3029 let batch = child_usage_records_from_metadata(&metadata).expect("preferred batch");
3030 assert_eq!(batch.records.len(), 2);
3031 assert_eq!(batch.records[0].usage.usage.input_tokens, 11);
3032 assert_eq!(batch.records[1].usage.usage.output_tokens, 7);
3033 assert_eq!(batch.dropped_records, 0);
3034
3035 metadata[CHILD_USAGE_RECORDS_KEY] = serde_json::json!([{"bad": true}]);
3036 let malformed = child_usage_records_from_metadata(&metadata).expect("batch key wins");
3037 assert!(malformed.records.is_empty());
3038 assert_eq!(malformed.dropped_records, 1);
3039 }
3040
3041 fn deepseek() -> BackgroundRoute<'static> {
3042 BackgroundRoute::new(ApiProvider::Deepseek, "deepseek-v4-flash")
3043 .with_base_url(Some(crate::config::DEFAULT_DEEPSEEK_BASE_URL))
3044 }
3045
3046 fn deepseek_envelope() -> EffectiveRouteEnvelope {
3047 EffectiveRouteEnvelope::capture(
3048 None,
3049 ApiProvider::Deepseek,
3050 "deepseek-primary",
3051 "deepseek-v4-flash",
3052 Some(crate::config::DEFAULT_DEEPSEEK_BASE_URL),
3053 Utc::now(),
3054 )
3055 }
3056
3057 #[test]
3058 fn default_usage_is_one_missing_receipt_across_canonical_replay_and_owners() {
3059 let _g = test_scope();
3060 let route = deepseek_envelope();
3061 let raw = "compaction:turn:response";
3062 let fingerprint = usage_source_fingerprint(raw);
3063 let encoded = format!("routed:{fingerprint}");
3064 for source in [raw, fingerprint.as_str(), encoded.as_str()] {
3065 report_effective_route_for_runtime(
3066 scope_token(),
3067 None,
3068 source,
3069 &route,
3070 &Usage::default(),
3071 );
3072 }
3073 let missing = drain();
3074 assert_eq!(missing.priced_turns, 0);
3075 assert_eq!(missing.unpriced_turns, 1);
3076 assert_eq!(missing.cny_unpriced_turns, 1);
3077 assert_eq!(missing.estimate, CostEstimate::default());
3078 assert_eq!(
3079 missing.usage_source_fingerprints,
3080 BTreeSet::from([fingerprint.clone()])
3081 );
3082 assert!(
3083 missing
3084 .unpriced_reasons
3085 .contains("provider_success_missing_usage")
3086 );
3087 report_effective_route_for_runtime(
3088 scope_token(),
3089 None,
3090 &encoded,
3091 &route,
3092 &Usage::default(),
3093 );
3094 assert!(
3095 drain().is_empty(),
3096 "replayed metadata must stay consumed after drain"
3097 );
3098
3099 let owner = "runtime-default-usage-owner";
3100 for source in [raw, fingerprint.as_str(), encoded.as_str()] {
3101 report_effective_route_for_runtime(
3102 scope_token(),
3103 Some(owner),
3104 source,
3105 &route,
3106 &Usage::default(),
3107 );
3108 }
3109 let batch = take_runtime_usage(owner);
3110 assert!(batch.records.is_empty());
3111 assert_eq!(batch.drop_records.len(), 1);
3112 assert_eq!(batch.dropped_records, 1);
3113 assert!(drain().is_empty());
3114 let replay = background_cost_for_runtime_usage(&RuntimeUsageRecord {
3115 source_id: encoded,
3116 usage: EffectiveRouteUsage {
3117 route: route.clone(),
3118 usage: Usage::default(),
3119 },
3120 });
3121 assert_eq!(replay.unpriced_turns, missing.unpriced_turns);
3122 assert_eq!(replay.cny_unpriced_turns, missing.cny_unpriced_turns);
3123 assert_eq!(
3124 replay.usage_source_fingerprints,
3125 missing.usage_source_fingerprints
3126 );
3127
3128 for billing_mode in [RouteBillingMode::Subscription, RouteBillingMode::Local] {
3129 let mut nonmetered = route.clone();
3130 nonmetered.billing_mode = billing_mode;
3131 let cost = background_cost_for_runtime_usage(&RuntimeUsageRecord {
3132 source_id: raw.into(),
3133 usage: EffectiveRouteUsage {
3134 route: nonmetered,
3135 usage: Usage::default(),
3136 },
3137 });
3138 assert_eq!(cost.unpriced_turns, 0);
3139 assert_eq!(cost.cny_unpriced_turns, 0);
3140 assert_eq!(cost.usage_source_fingerprints.len(), 1);
3141 }
3142
3143 let tmp = tempfile::tempdir().unwrap();
3144 let manager =
3145 crate::session_manager::SessionManager::new(tmp.path().join("sessions")).unwrap();
3146 let session = crate::session_manager::create_saved_session_with_id_and_mode(
3147 "missing-origin".into(),
3148 &[],
3149 "deepseek-v4-flash",
3150 tmp.path(),
3151 0,
3152 None,
3153 Some("agent"),
3154 );
3155 manager.save_session(&session).unwrap();
3156 let origin_scope = scope_token();
3157 assert!(close_current_scope().is_empty());
3158 assert!(report_effective_route_for_interactive_origin_with_manager(
3159 origin_scope,
3160 "missing-origin",
3161 "turn",
3162 raw,
3163 &route,
3164 &Usage::default(),
3165 &manager,
3166 ));
3167 for _ in 0..2 {
3168 let snapshot = manager.load_session_snapshot("missing-origin").unwrap();
3169 assert_eq!(snapshot.metadata.total_tokens, 0);
3170 assert_eq!(snapshot.metadata.cost.priced_turns, 0);
3171 assert_eq!(snapshot.metadata.cost.unpriced_turns, 1);
3172 assert_eq!(snapshot.metadata.cost.cny_unpriced_turns, 1);
3173 assert_eq!(snapshot.metadata.cost.usage_source_fingerprints.len(), 1);
3174 manager.save_session(&snapshot).unwrap();
3175 }
3176 assert!(drain().is_empty());
3177 }
3178
3179 #[test]
3180 fn openrouter_vendor_pin_does_not_inherit_aggregate_catalog_price() {
3181 let _env = crate::test_support::lock_test_env();
3182 let home = tempfile::tempdir().expect("isolated catalog home");
3183 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
3184 let _reset = ProviderCatalogTestReset;
3185 crate::provider_catalog_live::reset_cache_for_test();
3186 let _live = crate::provider_lake::lock_live_snapshot();
3187 crate::provider_lake::clear_live_snapshot();
3188 let mut route = EffectiveRouteEnvelope::capture(
3189 None,
3190 ApiProvider::Openrouter,
3191 "openrouter",
3192 "qwen/qwen3.7-plus",
3193 Some(ApiProvider::Openrouter.default_base_url()),
3194 Utc::now(),
3195 );
3196 let usage = small_usage();
3197 let aggregate = route.audit(&usage);
3198 assert!(
3199 aggregate.is_priced(),
3200 "aggregate fixture must be priced: {aggregate:?}"
3201 );
3202
3203 route.openrouter_vendor = Some("cerebras".to_string());
3204 let audit = route.audit(&usage);
3205 assert_eq!(
3206 audit.unpriced_reason,
3207 Some(crate::pricing::UnpricedReason::RoutingDependentPrice)
3208 );
3209 assert!(audit.estimate.is_none());
3210 assert!(audit.counts_toward_money_coverage());
3211 assert!(route.receipt(&audit).contains("openrouter_vendor=cerebras"));
3212
3213 // Even a valid, frozen aggregate quote has no upstream-vendor dimension.
3214 let fingerprint = route
3215 .endpoint_fingerprint
3216 .clone()
3217 .expect("official endpoint");
3218 let dispatched_at = u64::try_from(route.dispatched_at.timestamp()).expect("timestamp");
3219 crate::provider_catalog_live::record_success(priced_provider_delta(
3220 "openrouter",
3221 &route.model,
3222 &fingerprint,
3223 dispatched_at,
3224 ));
3225 route.provider_live_pricing =
3226 crate::provider_catalog_live::fresh_provider_live_pricing_quote_at(
3227 route.provider,
3228 &route.provider_identity,
3229 &route.model,
3230 &fingerprint,
3231 dispatched_at,
3232 );
3233 assert!(route.provider_live_pricing.is_some());
3234 let saved: EffectiveRouteEnvelope =
3235 serde_json::from_value(serde_json::to_value(&route).unwrap()).unwrap();
3236 let child = child_route_envelope_from_metadata(&serde_json::Value::Object(
3237 child_usage_metadata_fields(&saved, &usage),
3238 ))
3239 .expect("child envelope");
3240 for receipt in [&route, &saved, &child] {
3241 assert_eq!(
3242 receipt.audit(&usage).unpriced_reason,
3243 Some(crate::pricing::UnpricedReason::RoutingDependentPrice)
3244 );
3245 }
3246
3247 for (billing_mode, reason) in [
3248 (
3249 RouteBillingMode::Subscription,
3250 crate::pricing::UnpricedReason::NotMoneyMetered,
3251 ),
3252 (
3253 RouteBillingMode::Local,
3254 crate::pricing::UnpricedReason::NotMoneyMetered,
3255 ),
3256 (
3257 RouteBillingMode::Unknown,
3258 crate::pricing::UnpricedReason::UnknownBillingBasis,
3259 ),
3260 ] {
3261 route.billing_mode = billing_mode;
3262 assert_eq!(route.audit(&usage).unpriced_reason, Some(reason));
3263 }
3264 }
3265
3266 #[test]
3267 fn openrouter_vendor_pin_survives_envelope_and_child_metadata_persistence() {
3268 let mut config = crate::config::Config {
3269 provider: Some("openrouter".to_string()),
3270 ..Default::default()
3271 };
3272 config
3273 .provider_config_for_mut(ApiProvider::Openrouter)
3274 .vendor = Some("cerebras".to_string());
3275 let route = EffectiveRouteEnvelope::capture(
3276 Some(&config),
3277 ApiProvider::Openrouter,
3278 "openrouter",
3279 "qwen/qwen3.7-plus",
3280 Some(ApiProvider::Openrouter.default_base_url()),
3281 Utc::now(),
3282 );
3283 config
3284 .provider_config_for_mut(ApiProvider::Openrouter)
3285 .vendor = None;
3286 assert_eq!(route.openrouter_vendor.as_deref(), Some("cerebras"));
3287
3288 let mut json = serde_json::to_value(&route).expect("serialize route");
3289 let restored: EffectiveRouteEnvelope =
3290 serde_json::from_value(json.clone()).expect("restore route");
3291 assert_eq!(restored, route);
3292 let metadata =
3293 serde_json::Value::Object(child_usage_metadata_fields(&route, &small_usage()));
3294 assert_eq!(child_route_envelope_from_metadata(&metadata), Some(route));
3295
3296 json.as_object_mut()
3297 .expect("route object")
3298 .remove("openrouter_vendor");
3299 let legacy: EffectiveRouteEnvelope = serde_json::from_value(json).expect("legacy route");
3300 assert_eq!(legacy.openrouter_vendor, None);
3301 }
3302
3303 #[test]
3304 fn child_metadata_round_trip_preserves_zero_and_reasoning_usage() {
3305 let route = deepseek_envelope();
3306 let usage = Usage {
3307 input_tokens: 0,
3308 output_tokens: 9,
3309 reasoning_tokens: Some(7),
3310 reasoning_replay_tokens: Some(3),
3311 ..Usage::default()
3312 };
3313 let mut metadata = serde_json::json!({"tool": "rlm_eval"});
3314 attach_child_usage_metadata(&mut metadata, &route, &usage);
3315
3316 assert_eq!(child_route_envelope_from_metadata(&metadata), Some(route));
3317 assert_eq!(child_usage_from_metadata(&metadata), Some(usage));
3318
3319 let mut zero_metadata = serde_json::json!({});
3320 let zero = Usage::default();
3321 attach_child_usage_metadata(&mut zero_metadata, &deepseek_envelope(), &zero);
3322 assert_eq!(child_usage_from_metadata(&zero_metadata), Some(zero));
3323 }
3324
3325 #[test]
3326 fn runtime_owned_usage_is_isolated_from_tui_pool() {
3327 let _g = test_scope();
3328 let route = deepseek_envelope();
3329 let usage = small_usage();
3330 report_effective_route_for_runtime(
3331 scope_token(),
3332 Some("turn-a"),
3333 "response-a",
3334 &route,
3335 &usage,
3336 );
3337 report_effective_route_for_runtime(
3338 scope_token(),
3339 Some("turn-b"),
3340 "response-b",
3341 &route,
3342 &usage,
3343 );
3344
3345 assert_eq!(take_runtime_usage("turn-a").records.len(), 1);
3346 assert!(take_runtime_usage("turn-a").records.is_empty());
3347 assert_eq!(take_runtime_usage("turn-b").records.len(), 1);
3348 assert!(
3349 drain().is_empty(),
3350 "runtime-owned usage must not enter TUI cost"
3351 );
3352
3353 report_effective_route_for_runtime(scope_token(), None, "response-tui", &route, &usage);
3354 assert_eq!(drain().priced_turns, 1, "ownerless usage belongs to TUI");
3355 }
3356
3357 /// Every piece of shared cost accounting is scoped to the test that owns
3358 /// it, including the durability sink registry.
3359 ///
3360 /// Sinks are keyed by owner id, and owner ids in tests are short fixture
3361 /// strings that repeat. A process-global registry let one test's
3362 /// `register_runtime_usage_sink` overwrite another's live sink, and let one
3363 /// test's `finish_runtime_usage_owner` retire it mid-flight — so a passing
3364 /// exactly-once assertion depended on which tests happened to run
3365 /// concurrently. This pins the isolation directly: a sink registered on
3366 /// another thread must be invisible here, and usage reported here must not
3367 /// reach it.
3368 #[test]
3369 fn runtime_usage_sinks_do_not_leak_across_test_threads() {
3370 let _g = test_scope();
3371 let owner = "shared-owner";
3372 let other_thread_deliveries = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3373
3374 // A concurrent test, standing in for any other test in the binary that
3375 // happens to use the same owner id.
3376 let deliveries = Arc::clone(&other_thread_deliveries);
3377 let (ready_tx, ready_rx) = std::sync::mpsc::channel();
3378 let (done_tx, done_rx) = std::sync::mpsc::channel();
3379 let other = std::thread::spawn(move || {
3380 register_runtime_usage_sink(
3381 owner,
3382 Arc::new(move |_record| {
3383 deliveries.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3384 true
3385 }),
3386 );
3387 ready_tx.send(()).expect("signal registration");
3388 // Hold the registration open across this thread's assertions.
3389 done_rx.recv().expect("wait for the other test to finish");
3390 // The other thread's own reports still reach its own sink.
3391 report_effective_route_for_runtime(
3392 scope_token(),
3393 Some(owner),
3394 "response-other",
3395 &deepseek_envelope(),
3396 &small_usage(),
3397 );
3398 });
3399 ready_rx.recv().expect("other test registered its sink");
3400
3401 // This thread never registered a sink, so its usage must fall through
3402 // to this thread's journal — not into the other test's sink.
3403 report_effective_route_for_runtime(
3404 scope_token(),
3405 Some(owner),
3406 "response-mine",
3407 &deepseek_envelope(),
3408 &small_usage(),
3409 );
3410 assert_eq!(
3411 other_thread_deliveries.load(std::sync::atomic::Ordering::SeqCst),
3412 0,
3413 "another test's sink received this test's usage"
3414 );
3415 let mine = take_runtime_usage(owner);
3416 assert_eq!(mine.records.len(), 1);
3417 assert_eq!(mine.records[0].source_id, "response-mine");
3418 assert_eq!(mine.dropped_records, 0);
3419
3420 // Retiring the owner here must not retire the other test's sink.
3421 finish_runtime_usage_owner(owner);
3422 done_tx.send(()).expect("release the other test");
3423 other.join().expect("other test thread");
3424 assert_eq!(
3425 other_thread_deliveries.load(std::sync::atomic::Ordering::SeqCst),
3426 1,
3427 "the other test's sink was retired by an unrelated test"
3428 );
3429 }
3430
3431 #[test]
3432 fn runtime_usage_fallback_is_bounded_and_reports_truncation() {
3433 let _g = test_scope();
3434 let route = deepseek_envelope();
3435 for index in 0..(MAX_RUNTIME_USAGE_RECORDS_PER_OWNER + 3) {
3436 report_effective_route_for_runtime(
3437 scope_token(),
3438 Some("turn-bounded"),
3439 &format!("response-{index}"),
3440 &route,
3441 &small_usage(),
3442 );
3443 }
3444
3445 let batch = take_runtime_usage("turn-bounded");
3446 assert_eq!(batch.records.len(), MAX_RUNTIME_USAGE_RECORDS_PER_OWNER);
3447 assert_eq!(batch.dropped_records, 3);
3448 assert!(drain().is_empty(), "runtime fallback must stay out of TUI");
3449 }
3450
3451 #[test]
3452 fn route_labels_redact_local_paths_but_preserve_model_namespaces() {
3453 let route = EffectiveRouteEnvelope {
3454 openrouter_vendor: None,
3455 provider: ApiProvider::Openrouter,
3456 provider_identity: "/Users/alice/.config/provider-secret".to_string(),
3457 model: "/Volumes/private/checkpoints/model.gguf".to_string(),
3458 billing_surface: None,
3459 endpoint_fingerprint: None,
3460 provider_live_pricing: None,
3461 billing_mode: RouteBillingMode::Metered,
3462 dispatched_at: Utc::now(),
3463 };
3464 let sanitized = route.sanitized_for_persistence();
3465 assert_eq!(sanitized.provider_identity, "redacted-local-path");
3466 assert_eq!(sanitized.model, "redacted-local-path");
3467 let receipt = route.receipt(&TurnCostAudit::unpriced(
3468 crate::pricing::UnpricedReason::NoPricingRow,
3469 ));
3470 assert!(!receipt.contains("alice"));
3471 assert!(!receipt.contains("Volumes"));
3472
3473 assert_eq!(
3474 sanitize_persisted_route_label("anthropic/claude-sonnet-5"),
3475 "anthropic/claude-sonnet-5"
3476 );
3477 }
3478
3479 #[test]
3480 fn route_label_sanitizer_rejects_credentials_urls_and_relative_paths() {
3481 for credential in [
3482 "Bearer secret-token",
3483 "Authorization: Basic abc123",
3484 "OPENAI_API_KEY=sk-secret",
3485 "service_token: ghp_secret",
3486 "hf_secret-token",
3487 "glpat-secret-token",
3488 "db-password=hunter2",
3489 "sk-live-secret",
3490 "https://alice:password@example.test/v1?api_key=secret#fragment",
3491 ] {
3492 let sanitized = sanitize_persisted_route_label(credential);
3493 assert!(
3494 sanitized.starts_with("redacted-"),
3495 "credential was not redacted: {credential:?} -> {sanitized:?}"
3496 );
3497 }
3498 for path in [
3499 ".ssh/id_ed25519",
3500 "../secrets/provider.key",
3501 "workspace/.ssh/config",
3502 "relative/path/to/credential",
3503 r"relative\path\credential",
3504 ] {
3505 assert_eq!(
3506 sanitize_persisted_route_label(path),
3507 "redacted-local-path",
3508 "path was not redacted: {path:?}"
3509 );
3510 }
3511 assert_eq!(
3512 sanitize_persisted_route_label("moonshot/kimi-k3"),
3513 "moonshot/kimi-k3"
3514 );
3515 }
3516
3517 #[test]
3518 fn serialized_route_envelopes_records_and_child_receipts_are_secret_free() {
3519 let route = EffectiveRouteEnvelope {
3520 openrouter_vendor: Some("Authorization: Bearer vendor-secret".to_string()),
3521 provider: ApiProvider::Custom,
3522 provider_identity: "Authorization: Bearer provider-secret".to_string(),
3523 model: "MODEL_API_KEY=sk-model-secret".to_string(),
3524 billing_surface: Some(
3525 "https://alice:password@example.test/v1?token=secret#fragment".to_string(),
3526 ),
3527 endpoint_fingerprint: Some("../.ssh/provider_key".to_string()),
3528 provider_live_pricing: None,
3529 billing_mode: RouteBillingMode::Metered,
3530 dispatched_at: Utc::now(),
3531 };
3532 let usage = Usage {
3533 input_tokens: 7,
3534 output_tokens: 3,
3535 ..Usage::default()
3536 };
3537
3538 let envelope_json = serde_json::to_string(&route).expect("serialize envelope");
3539 let record_json = serde_json::to_string(&EffectiveRouteUsage {
3540 route: route.clone(),
3541 usage: usage.clone(),
3542 })
3543 .expect("serialize route usage");
3544 let child_json = serde_json::to_string(&child_usage_metadata_fields(&route, &usage))
3545 .expect("serialize child receipt");
3546 for serialized in [&envelope_json, &record_json, &child_json] {
3547 for secret in [
3548 "vendor-secret",
3549 "provider-secret",
3550 "sk-model-secret",
3551 "alice",
3552 "password",
3553 "token=secret",
3554 ".ssh",
3555 ] {
3556 assert!(
3557 !serialized.contains(secret),
3558 "serialized route leaked {secret:?}: {serialized}"
3559 );
3560 }
3561 }
3562 }
3563
3564 #[test]
3565 fn report_adds_to_pool_and_drain_returns_then_resets() {
3566 let _g = test_scope();
3567 report(scope_token(), &deepseek(), &small_usage());
3568 let first = drain();
3569 assert!(
3570 first.estimate.usd > 0.0,
3571 "expected positive USD cost, got {first:?}"
3572 );
3573 assert!(
3574 first.estimate.cny > 0.0,
3575 "expected positive CNY cost, got {first:?}"
3576 );
3577 assert_eq!(first.priced_turns, 1);
3578 assert_eq!(first.unpriced_turns, 0);
3579 assert_eq!(first.cny_priced_turns, 1);
3580 assert_eq!(first.cny_unpriced_turns, 0);
3581 // The receipt names the route without leaking the endpoint URL.
3582 assert_eq!(first.route_receipts.len(), 1);
3583 let receipt = first.route_receipts.iter().next().expect("receipt");
3584 assert!(receipt.contains("provider=deepseek"), "{receipt}");
3585 assert!(receipt.contains("model=deepseek-v4-flash"), "{receipt}");
3586 assert!(receipt.contains("currency=usd+cny"), "{receipt}");
3587 assert!(!receipt.contains("http"), "{receipt}");
3588
3589 let second = drain();
3590 assert!(second.is_empty(), "drain must zero the pool: {second:?}");
3591 }
3592
3593 #[test]
3594 fn reports_from_a_closed_session_scope_are_discarded() {
3595 let _g = test_scope();
3596 let old_scope = scope_token();
3597 let settled = close_current_scope();
3598 assert!(settled.is_empty());
3599
3600 report(old_scope, &deepseek(), &small_usage());
3601 assert!(drain().is_empty(), "old session usage crossed the boundary");
3602
3603 report(scope_token(), &deepseek(), &small_usage());
3604 assert_eq!(drain().priced_turns, 1);
3605 }
3606
3607 #[test]
3608 fn retired_origin_acknowledges_sources_without_accrual_or_scope_leak() {
3609 let _g = test_scope();
3610 let tmp = tempfile::tempdir().expect("tempdir");
3611 let manager = crate::session_manager::SessionManager::new(tmp.path().join("sessions"))
3612 .expect("manager");
3613 let session = crate::session_manager::create_saved_session_with_id_and_mode(
3614 "retired-origin".to_string(),
3615 &[],
3616 "deepseek-v4-flash",
3617 tmp.path(),
3618 0,
3619 None,
3620 Some("agent"),
3621 );
3622 manager.save_session(&session).expect("save origin");
3623 let origin_scope = scope_token();
3624 manager
3625 .delete_session("retired-origin")
3626 .expect("delete origin");
3627 let route = deepseek_envelope();
3628 for _ in 0..2 {
3629 assert!(report_effective_route_for_interactive_origin_with_manager(
3630 origin_scope,
3631 "retired-origin",
3632 "origin-turn",
3633 "retired-usage",
3634 &route,
3635 &small_usage(),
3636 &manager,
3637 ));
3638 assert!(report_unreceipted_for_interactive_origin_with_manager(
3639 origin_scope,
3640 "retired-origin",
3641 "origin-turn",
3642 "retired-drop",
3643 &route,
3644 &manager,
3645 ));
3646 }
3647 assert!(usage_source_seen("retired-usage"));
3648 assert!(usage_source_seen("retired-drop"));
3649 assert!(
3650 drain().is_empty(),
3651 "retirement must not create any pending projection"
3652 );
3653 assert!(
3654 !manager
3655 .sessions_dir()
3656 .join(".late-usage/retired-origin.json")
3657 .exists()
3658 );
3659
3660 assert!(close_current_scope().is_empty());
3661 assert!(!usage_source_seen("retired-usage"));
3662 assert!(report_effective_route_for_interactive_origin_with_manager(
3663 origin_scope,
3664 "retired-origin",
3665 "origin-turn",
3666 "after-scope-change",
3667 &route,
3668 &small_usage(),
3669 &manager,
3670 ));
3671 assert!(
3672 !usage_source_seen("after-scope-change"),
3673 "old retirement cannot poison a new scope"
3674 );
3675 assert!(drain().is_empty());
3676 report_effective_route_for_runtime(
3677 scope_token(),
3678 None,
3679 "after-scope-change",
3680 &route,
3681 &small_usage(),
3682 );
3683 assert_eq!(
3684 drain().priced_turns,
3685 1,
3686 "the replacement scope still admits its own response"
3687 );
3688 }
3689
3690 #[test]
3691 fn detached_advisor_and_translation_receipts_survive_new_exactly_once() {
3692 let _g = test_scope();
3693 let tmp = tempfile::tempdir().expect("tempdir");
3694 let manager = crate::session_manager::SessionManager::new(tmp.path().join("sessions"))
3695 .expect("session manager");
3696 let old_session_id = "origin-session";
3697 let new_session_id = "replacement-session";
3698 for session_id in [old_session_id, new_session_id] {
3699 let session = crate::session_manager::create_saved_session_with_id_and_mode(
3700 session_id.to_string(),
3701 &[],
3702 "deepseek-v4-flash",
3703 tmp.path(),
3704 0,
3705 None,
3706 Some("agent"),
3707 );
3708 manager.save_session(&session).expect("save session");
3709 }
3710
3711 let origin_scope = scope_token();
3712 let owner = "interactive:origin-session:origin-turn";
3713 register_persistent_interactive_runtime_usage_sink_at(
3714 owner,
3715 origin_scope,
3716 old_session_id,
3717 "origin-turn",
3718 manager.sessions_dir().to_path_buf(),
3719 );
3720 let advisor_lease = acquire_runtime_usage_lease(owner).expect("advisor owner lease");
3721 finish_runtime_usage_owner(owner);
3722
3723 // `/new` closes the old foreground generation while the detached
3724 // advisor and translation requests are still in flight.
3725 assert!(close_current_scope().is_empty());
3726 let route = deepseek_envelope();
3727 let usage = Usage {
3728 input_tokens: 17,
3729 output_tokens: 5,
3730 ..Usage::default()
3731 };
3732 for _ in 0..2 {
3733 report_effective_route_for_runtime(
3734 origin_scope,
3735 Some(owner),
3736 "advisor:origin-turn:response",
3737 &route,
3738 &usage,
3739 );
3740 report_unreceipted_provider_success(
3741 origin_scope,
3742 Some(owner),
3743 "advisor:origin-turn:missing-usage",
3744 &route,
3745 );
3746 assert!(report_effective_route_for_interactive_origin_with_manager(
3747 origin_scope,
3748 old_session_id,
3749 "origin-turn",
3750 "translation:origin-turn:assistant",
3751 &route,
3752 &usage,
3753 &manager,
3754 ));
3755 assert!(report_unreceipted_for_interactive_origin_with_manager(
3756 origin_scope,
3757 old_session_id,
3758 "origin-turn",
3759 "translation:origin-turn:thinking-missing-usage",
3760 &route,
3761 &manager,
3762 ));
3763 }
3764 drop(advisor_lease);
3765
3766 let fallback = take_runtime_usage(owner);
3767 assert!(fallback.records.is_empty());
3768 assert!(fallback.drop_records.is_empty());
3769 assert_eq!(fallback.dropped_records, 0);
3770 assert!(drain().is_empty(), "late receipts polluted the new scope");
3771
3772 let old = manager
3773 .load_session_snapshot(old_session_id)
3774 .expect("load origin session");
3775 assert_eq!(old.metadata.total_tokens, 44);
3776 assert_eq!(old.metadata.cost.priced_turns, 2);
3777 assert_eq!(old.metadata.cost.unpriced_turns, 2);
3778 assert_eq!(old.metadata.cost.cny_unpriced_turns, 2);
3779 assert_eq!(old.metadata.cost.usage_source_fingerprints.len(), 4);
3780
3781 let replay = manager
3782 .load_session_snapshot(old_session_id)
3783 .expect("replay origin session");
3784 assert_eq!(replay.metadata.total_tokens, 44);
3785 assert_eq!(replay.metadata.cost.usage_source_fingerprints.len(), 4);
3786
3787 let replacement = manager
3788 .load_session_snapshot(new_session_id)
3789 .expect("load replacement session");
3790 assert_eq!(replacement.metadata.total_tokens, 0);
3791 assert_eq!(replacement.metadata.cost.priced_turns, 0);
3792 assert_eq!(replacement.metadata.cost.unpriced_turns, 0);
3793 assert!(
3794 replacement
3795 .metadata
3796 .cost
3797 .usage_source_fingerprints
3798 .is_empty()
3799 );
3800 }
3801
3802 #[test]
3803 fn report_counts_unknown_models_as_missing_spend_not_as_free() {
3804 let _g = test_scope();
3805 // NIM-hosted models intentionally have no DeepSeek pricing, but the
3806 // route *is* money-metered — so the turn is missing spend, not absent.
3807 report(
3808 scope_token(),
3809 &BackgroundRoute::new(ApiProvider::NvidiaNim, "deepseek-ai/deepseek-v4-pro"),
3810 &small_usage(),
3811 );
3812 let drained = drain();
3813 assert_eq!(drained.estimate, CostEstimate::default());
3814 assert_eq!(drained.priced_turns, 0);
3815 assert_eq!(drained.unpriced_turns, 1);
3816 assert!(!drained.unpriced_reasons.is_empty());
3817 }
3818
3819 #[test]
3820 fn report_skips_codex_oauth_pricing_without_calling_it_incomplete() {
3821 let _g = test_scope();
3822 report(
3823 scope_token(),
3824 &BackgroundRoute::new(ApiProvider::OpenaiCodex, "gpt-5.5"),
3825 &small_usage(),
3826 );
3827 let drained = drain();
3828 assert_eq!(drained.estimate, CostEstimate::default());
3829 // Exactly non-metered: not counted in either coverage bucket.
3830 assert_eq!(drained.priced_turns, 0);
3831 assert_eq!(drained.unpriced_turns, 0);
3832 assert!(drained.unpriced_reasons.is_empty());
3833 assert!(drained.cny_unpriced_reasons.is_empty());
3834 }
3835
3836 #[test]
3837 fn report_skips_stepfun_without_billing_surface() {
3838 let _g = test_scope();
3839 report(
3840 scope_token(),
3841 &BackgroundRoute::new(ApiProvider::Stepfun, "step-3.7-flash"),
3842 &small_usage(),
3843 );
3844 report(
3845 scope_token(),
3846 &BackgroundRoute::new(ApiProvider::Openrouter, "step-3.7-flash"),
3847 &small_usage(),
3848 );
3849 let drained = drain();
3850 assert_eq!(drained.estimate, CostEstimate::default());
3851 // Both are metered-or-unknown routes that could not be priced, so both
3852 // are reported as missing rather than dropped.
3853 assert_eq!(drained.unpriced_turns, 2);
3854 }
3855
3856 /// A local runtime and a plan endpoint must never be guessed into public
3857 /// per-token dollars just because the provider also sells a paid API.
3858 #[test]
3859 fn local_and_plan_endpoints_are_never_treated_as_public_payg() {
3860 let _g = test_scope();
3861 report(
3862 scope_token(),
3863 &BackgroundRoute::new(ApiProvider::Ollama, "llama3.2"),
3864 &small_usage(),
3865 );
3866 report(
3867 scope_token(),
3868 &BackgroundRoute::new(ApiProvider::Zai, "glm-5.2")
3869 .with_base_url(Some("https://api.z.ai/api/coding/paas/v4")),
3870 &small_usage(),
3871 );
3872 report(
3873 scope_token(),
3874 &BackgroundRoute::new(ApiProvider::Moonshot, "kimi-for-coding")
3875 .with_base_url(Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL)),
3876 &small_usage(),
3877 );
3878 let drained = drain();
3879 assert_eq!(drained.estimate, CostEstimate::default());
3880 assert_eq!(drained.priced_turns, 0);
3881 assert_eq!(
3882 drained.unpriced_turns, 0,
3883 "exactly non-metered routes are not missing dollars: {drained:?}"
3884 );
3885 assert!(drained.unpriced_reasons.is_empty());
3886 assert!(drained.cny_unpriced_reasons.is_empty());
3887 assert!(
3888 drained
3889 .route_receipts
3890 .iter()
3891 .any(|receipt| receipt.contains("surface=zai-coding-plan")),
3892 "{drained:?}"
3893 );
3894 assert!(
3895 drained
3896 .route_receipts
3897 .iter()
3898 .any(|receipt| receipt.contains("surface=local-no-bill")),
3899 "{drained:?}"
3900 );
3901 assert!(
3902 drained
3903 .route_receipts
3904 .iter()
3905 .any(|receipt| receipt.contains("surface=moonshot-kimi-code")),
3906 "{drained:?}"
3907 );
3908 }
3909
3910 /// The receipt carries an endpoint *fingerprint*, never the URL.
3911 #[test]
3912 fn route_receipts_fingerprint_the_endpoint_and_keep_secrets_out() {
3913 let _g = test_scope();
3914 let base_url = "https://api.deepseek.com/v1";
3915 report(
3916 scope_token(),
3917 &deepseek().with_base_url(Some(base_url)),
3918 &small_usage(),
3919 );
3920 let drained = drain();
3921 let receipt = drained.route_receipts.iter().next().expect("receipt");
3922 let expected_fp = endpoint_fingerprint(base_url).expect("valid endpoint fingerprint");
3923 assert!(
3924 receipt.contains(&format!("endpoint_fp={expected_fp}")),
3925 "{receipt}"
3926 );
3927 for needle in ["http", "api.deepseek.com", "sk-", "/Users/", "/home/"] {
3928 assert!(!receipt.contains(needle), "{needle} leaked into {receipt}");
3929 }
3930 }
3931
3932 #[test]
3933 fn receipt_fields_are_bounded_and_secret_bearing_urls_are_not_hashed() {
3934 let hostile = format!("model\nAuthorization: bearer {}", "x".repeat(400));
3935 let receipt = route_receipt(
3936 ApiProvider::Deepseek,
3937 Some("identity\r\nforged=yes"),
3938 &hostile,
3939 Some(crate::pricing::FIRST_PARTY_PAYG_BILLING_SURFACE),
3940 None,
3941 RouteBillingMode::Metered,
3942 "usd+cny",
3943 );
3944 assert!(!receipt.contains('\n'), "{receipt}");
3945 assert!(!receipt.contains('\r'), "{receipt}");
3946 assert!(
3947 receipt.len() < 420,
3948 "receipt was not bounded: {}",
3949 receipt.len()
3950 );
3951
3952 for secret_url in [
3953 "https://user:secret@api.example.com/v1",
3954 "https://api.example.com/v1?api_key=secret",
3955 "https://api.example.com/v1#secret",
3956 ] {
3957 assert_eq!(endpoint_fingerprint(secret_url), None, "{secret_url}");
3958 }
3959 assert_eq!(
3960 endpoint_fingerprint("https://API.Example.com/v1/")
3961 .expect("valid endpoint")
3962 .len(),
3963 64
3964 );
3965 }
3966
3967 #[test]
3968 fn report_accumulates_across_multiple_calls() {
3969 let _g = test_scope();
3970 report(scope_token(), &deepseek(), &small_usage());
3971 report(scope_token(), &deepseek(), &small_usage());
3972 let total = drain();
3973 // Two equal reports — total must be 2× a single report.
3974 let single = crate::pricing::calculate_turn_cost_estimate_from_usage(
3975 "deepseek-v4-flash",
3976 &small_usage(),
3977 )
3978 .unwrap();
3979 assert!((total.estimate.usd - 2.0 * single.usd).abs() < 1e-12);
3980 assert!((total.estimate.cny - 2.0 * single.cny).abs() < 1e-12);
3981 assert_eq!(total.priced_turns, 2);
3982 // Identical routes collapse to one receipt rather than growing without
3983 // bound across a long session.
3984 assert_eq!(total.route_receipts.len(), 1);
3985 }
3986
3987 /// A cache-write turn on a route with no published write rate must show up
3988 /// as missing spend naming the class, not as a discounted total.
3989 #[test]
3990 fn unpriced_cache_write_class_is_reported_not_absorbed() {
3991 let _g = test_scope();
3992 let write_heavy = Usage {
3993 input_tokens: 1_000_000,
3994 output_tokens: 100_000,
3995 prompt_cache_hit_tokens: Some(200_000),
3996 prompt_cache_write_tokens: Some(100_000),
3997 ..Default::default()
3998 };
3999 report(
4000 scope_token(),
4001 &BackgroundRoute::new(ApiProvider::Moonshot, "kimi-k2.7-code")
4002 .with_base_url(Some("https://api.moonshot.ai/v1")),
4003 &write_heavy,
4004 );
4005 let drained = drain();
4006 assert_eq!(drained.estimate, CostEstimate::default());
4007 assert_eq!(drained.unpriced_turns, 1);
4008 assert!(drained.unpriced_reasons.contains("missing_class_price"));
4009 assert!(drained.unpriced_classes.contains("cache_write"));
4010 assert!(
4011 drained
4012 .route_receipts
4013 .iter()
4014 .any(|receipt| receipt.contains("cache_write=yes")),
4015 "{drained:?}"
4016 );
4017 }
4018 }
4019
4019 lines RUST