返回 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, VecDeque};
36 use std::sync::{Arc, Mutex, OnceLock};
37
38 use chrono::{DateTime, Utc};
39
40 use crate::config::ApiProvider;
41 use crate::models::Usage;
42 use crate::pricing::{CostEstimate, TurnCostAudit};
43 use crate::route_billing::BillingPresentation;
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 }
84
85 /// Immutable, non-secret route evidence captured before a provider request.
86 /// It contains enough information to audit the eventual usage without reading
87 /// mutable parent/app config at completion time.
88 #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
89 pub struct EffectiveRouteEnvelope {
90 pub provider: ApiProvider,
91 pub provider_identity: String,
92 pub model: String,
93 pub billing_surface: Option<String>,
94 pub endpoint_fingerprint: Option<String>,
95 #[serde(default)]
96 pub billing_mode: RouteBillingMode,
97 pub dispatched_at: DateTime<Utc>,
98 }
99
100 impl serde::Serialize for EffectiveRouteEnvelope {
101 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
102 where
103 S: serde::Serializer,
104 {
105 use serde::ser::SerializeStruct as _;
106
107 let route = self.sanitized_for_persistence();
108 let mut state = serializer.serialize_struct("EffectiveRouteEnvelope", 7)?;
109 state.serialize_field("provider", &route.provider)?;
110 state.serialize_field("provider_identity", &route.provider_identity)?;
111 state.serialize_field("model", &route.model)?;
112 state.serialize_field("billing_surface", &route.billing_surface)?;
113 state.serialize_field("endpoint_fingerprint", &route.endpoint_fingerprint)?;
114 state.serialize_field("billing_mode", &route.billing_mode)?;
115 state.serialize_field("dispatched_at", &route.dispatched_at)?;
116 state.end()
117 }
118 }
119
120 /// One provider usage payload paired with the immutable route that served it.
121 ///
122 /// Runtime hosts persist these for model calls made below the parent turn
123 /// (sub-agents, review/verify/RLM tools, and compaction). Keeping route and
124 /// usage together makes the record independently auditable and prevents a
125 /// later provider/model selection from changing its price.
126 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
127 pub struct EffectiveRouteUsage {
128 pub route: EffectiveRouteEnvelope,
129 pub usage: Usage,
130 }
131
132 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
133 #[serde(rename_all = "snake_case")]
134 pub enum RouteBillingMode {
135 Metered,
136 Subscription,
137 Local,
138 #[default]
139 Unknown,
140 }
141
142 impl From<BillingPresentation> for RouteBillingMode {
143 fn from(value: BillingPresentation) -> Self {
144 match value {
145 BillingPresentation::Metered => Self::Metered,
146 BillingPresentation::Subscription(_) => Self::Subscription,
147 BillingPresentation::Local => Self::Local,
148 BillingPresentation::Unknown => Self::Unknown,
149 }
150 }
151 }
152
153 impl EffectiveRouteEnvelope {
154 #[must_use]
155 pub fn capture(
156 config: Option<&crate::config::Config>,
157 provider: ApiProvider,
158 provider_identity: impl Into<String>,
159 model: impl Into<String>,
160 base_url: Option<&str>,
161 dispatched_at: DateTime<Utc>,
162 ) -> Self {
163 let provider_identity = provider_identity.into();
164 let model = model.into();
165 let billing = config.map_or_else(
166 || crate::route_billing::for_endpoint_without_config(provider, base_url),
167 |config| crate::route_billing::for_route(config, provider),
168 );
169 Self {
170 provider,
171 provider_identity: sanitize_persisted_route_label(&provider_identity),
172 model: sanitize_persisted_route_label(&model),
173 billing_surface: crate::route_billing::billing_surface_for_dispatch(
174 config, provider, base_url,
175 )
176 .map(str::to_string),
177 endpoint_fingerprint: base_url.and_then(endpoint_fingerprint),
178 billing_mode: billing.into(),
179 dispatched_at,
180 }
181 }
182
183 #[must_use]
184 pub fn audit(&self, usage: &Usage) -> TurnCostAudit {
185 match self.billing_mode {
186 RouteBillingMode::Subscription | RouteBillingMode::Local => {
187 return TurnCostAudit::unpriced(crate::pricing::UnpricedReason::NotMoneyMetered);
188 }
189 RouteBillingMode::Unknown => {
190 return TurnCostAudit::unpriced(
191 crate::pricing::UnpricedReason::UnknownBillingBasis,
192 );
193 }
194 RouteBillingMode::Metered => {}
195 }
196 crate::pricing::audit_turn_cost_for_route_on_endpoint_at(
197 self.provider,
198 &self.model,
199 self.billing_surface.as_deref(),
200 self.endpoint_fingerprint.as_deref(),
201 usage,
202 self.dispatched_at,
203 )
204 }
205
206 #[must_use]
207 pub fn receipt(&self, audit: &TurnCostAudit) -> String {
208 let route = self.sanitized_for_persistence();
209 route_receipt(
210 route.provider,
211 Some(&route.provider_identity),
212 &route.model,
213 route.billing_surface.as_deref(),
214 route.endpoint_fingerprint.as_deref(),
215 route.billing_mode,
216 currency_tag(audit),
217 )
218 }
219
220 /// Redact filesystem-like labels before a route crosses a persistence or
221 /// metadata boundary. Ordinary provider model namespaces such as
222 /// `anthropic/claude-*` remain intact; absolute/local path forms do not.
223 #[must_use]
224 pub fn sanitized_for_persistence(&self) -> Self {
225 let mut route = self.clone();
226 route.provider_identity = sanitize_persisted_route_label(&route.provider_identity);
227 route.model = sanitize_persisted_route_label(&route.model);
228 route.billing_surface = route
229 .billing_surface
230 .as_deref()
231 .map(sanitize_persisted_route_label);
232 route.endpoint_fingerprint =
233 route
234 .endpoint_fingerprint
235 .as_deref()
236 .and_then(|fingerprint| {
237 let fingerprint = fingerprint.trim();
238 (fingerprint.len() == 64
239 && fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()))
240 .then(|| fingerprint.to_ascii_lowercase())
241 });
242 route
243 }
244 }
245
246 fn receipt_with_usage_classes(mut receipt: String, usage: &Usage) -> String {
247 let classes = crate::pricing::token_usage_for_pricing(usage);
248 if classes.cache_write > 0 {
249 receipt.push_str(" cache_write=yes");
250 }
251 if usage.reasoning_tokens.unwrap_or(0) > 0 {
252 receipt.push_str(" reasoning=yes");
253 }
254 receipt
255 }
256
257 /// Canonical redacted route receipt for one exact usage payload.
258 #[must_use]
259 pub fn effective_route_usage_receipt(
260 route: &EffectiveRouteEnvelope,
261 audit: &TurnCostAudit,
262 usage: &Usage,
263 ) -> String {
264 receipt_with_usage_classes(route.receipt(audit), usage)
265 }
266
267 /// Canonical `child_*` token and route metadata for tools that make their own
268 /// LLM calls (`review`, `verify`, and `rlm`). Keeping this next to the immutable
269 /// route envelope prevents the pure model types from depending on app config.
270 #[must_use]
271 pub fn child_usage_metadata_fields(
272 route: &EffectiveRouteEnvelope,
273 usage: &Usage,
274 ) -> serde_json::Map<String, serde_json::Value> {
275 let route = route.sanitized_for_persistence();
276 let mut fields = serde_json::Map::new();
277 fields.insert("child_provider".into(), serde_json::json!(route.provider));
278 fields.insert(
279 "child_provider_identity".into(),
280 serde_json::json!(route.provider_identity),
281 );
282 fields.insert("child_model".into(), serde_json::json!(route.model));
283 fields.insert(
284 "child_billing_surface".into(),
285 serde_json::json!(route.billing_surface),
286 );
287 fields.insert(
288 "child_endpoint_fingerprint".into(),
289 serde_json::json!(route.endpoint_fingerprint),
290 );
291 fields.insert(
292 "child_billing_mode".into(),
293 serde_json::json!(route.billing_mode),
294 );
295 fields.insert(
296 "child_dispatched_at".into(),
297 serde_json::json!(route.dispatched_at),
298 );
299 fields.insert(
300 "child_input_tokens".into(),
301 serde_json::json!(usage.input_tokens),
302 );
303 fields.insert(
304 "child_output_tokens".into(),
305 serde_json::json!(usage.output_tokens),
306 );
307 fields.insert(
308 "child_prompt_cache_hit_tokens".into(),
309 serde_json::json!(usage.prompt_cache_hit_tokens),
310 );
311 fields.insert(
312 "child_prompt_cache_miss_tokens".into(),
313 serde_json::json!(usage.prompt_cache_miss_tokens),
314 );
315 fields.insert(
316 "child_prompt_cache_write_tokens".into(),
317 serde_json::json!(usage.prompt_cache_write_tokens),
318 );
319 // Informational: reasoning tokens are already included in output tokens.
320 fields.insert(
321 "child_reasoning_tokens".into(),
322 serde_json::json!(usage.reasoning_tokens),
323 );
324 fields.insert(
325 "child_reasoning_replay_tokens".into(),
326 serde_json::json!(usage.reasoning_replay_tokens),
327 );
328 fields.insert(
329 "child_server_tool_use".into(),
330 serde_json::json!(usage.server_tool_use),
331 );
332 fields
333 }
334
335 /// Merge canonical child usage into a tool metadata object.
336 pub fn attach_child_usage_metadata(
337 metadata: &mut serde_json::Value,
338 route: &EffectiveRouteEnvelope,
339 usage: &Usage,
340 ) {
341 if let Some(object) = metadata.as_object_mut() {
342 object.extend(child_usage_metadata_fields(route, usage));
343 }
344 }
345
346 /// Rehydrate the immutable route envelope emitted with child usage. Legacy or
347 /// incomplete metadata becomes an explicitly unknown route and never borrows
348 /// mutable parent-session facts.
349 #[must_use]
350 pub fn child_route_envelope_from_metadata(
351 metadata: &serde_json::Value,
352 ) -> Option<EffectiveRouteEnvelope> {
353 let model = metadata.get("child_model")?.as_str()?.to_string();
354 let provider = metadata
355 .get("child_provider")
356 .cloned()
357 .and_then(|value| serde_json::from_value(value).ok());
358 let provider_identity = metadata
359 .get("child_provider_identity")
360 .and_then(serde_json::Value::as_str)
361 .map(str::to_string);
362 let billing_mode = metadata
363 .get("child_billing_mode")
364 .cloned()
365 .and_then(|value| serde_json::from_value(value).ok());
366 let dispatched_at = metadata
367 .get("child_dispatched_at")
368 .cloned()
369 .and_then(|value| serde_json::from_value(value).ok());
370
371 let complete = provider.is_some()
372 && provider_identity.is_some()
373 && billing_mode.is_some()
374 && dispatched_at.is_some();
375 Some(
376 EffectiveRouteEnvelope {
377 provider: provider.unwrap_or(ApiProvider::Custom),
378 provider_identity: provider_identity.unwrap_or_else(|| "legacy-unreported".to_string()),
379 model,
380 billing_surface: metadata
381 .get("child_billing_surface")
382 .and_then(serde_json::Value::as_str)
383 .map(str::to_string),
384 endpoint_fingerprint: metadata
385 .get("child_endpoint_fingerprint")
386 .and_then(serde_json::Value::as_str)
387 .map(str::to_string),
388 billing_mode: billing_mode
389 .filter(|_| complete)
390 .unwrap_or(RouteBillingMode::Unknown),
391 dispatched_at: dispatched_at.unwrap_or_else(|| {
392 DateTime::<Utc>::from_timestamp(0, 0).expect("Unix epoch is representable")
393 }),
394 }
395 .sanitized_for_persistence(),
396 )
397 }
398
399 /// Rehydrate the complete child usage payload emitted by
400 /// [`attach_child_usage_metadata`]. The presence of a canonical child token
401 /// field is significant even when every value is zero: a zero-usage provider
402 /// call still needs a route receipt and coverage classification.
403 #[must_use]
404 pub fn child_usage_from_metadata(metadata: &serde_json::Value) -> Option<Usage> {
405 const TOKEN_FIELDS: &[&str] = &[
406 "child_input_tokens",
407 "child_output_tokens",
408 "child_prompt_cache_hit_tokens",
409 "child_prompt_cache_miss_tokens",
410 "child_prompt_cache_write_tokens",
411 "child_reasoning_tokens",
412 "child_reasoning_replay_tokens",
413 ];
414 if !TOKEN_FIELDS
415 .iter()
416 .any(|field| metadata.get(field).is_some())
417 {
418 return None;
419 }
420
421 fn u32_field(metadata: &serde_json::Value, field: &str) -> Option<u32> {
422 metadata
423 .get(field)
424 .and_then(serde_json::Value::as_u64)
425 .map(|value| u32::try_from(value).unwrap_or(u32::MAX))
426 }
427
428 Some(Usage {
429 input_tokens: u32_field(metadata, "child_input_tokens").unwrap_or(0),
430 output_tokens: u32_field(metadata, "child_output_tokens").unwrap_or(0),
431 prompt_cache_hit_tokens: u32_field(metadata, "child_prompt_cache_hit_tokens"),
432 prompt_cache_miss_tokens: u32_field(metadata, "child_prompt_cache_miss_tokens"),
433 prompt_cache_write_tokens: u32_field(metadata, "child_prompt_cache_write_tokens"),
434 reasoning_tokens: u32_field(metadata, "child_reasoning_tokens"),
435 reasoning_replay_tokens: u32_field(metadata, "child_reasoning_replay_tokens"),
436 server_tool_use: metadata
437 .get("child_server_tool_use")
438 .cloned()
439 .and_then(|value| serde_json::from_value(value).ok()),
440 })
441 }
442
443 impl PendingBackgroundCost {
444 /// Whether anything at all was accrued.
445 ///
446 /// Compared against `Default` rather than checking a subset of fields, so a
447 /// field added later cannot be silently left out of the emptiness test.
448 #[must_use]
449 pub fn is_empty(&self) -> bool {
450 *self == Self::default()
451 }
452 }
453
454 #[derive(Default)]
455 struct ScopedPendingBackgroundCost {
456 generation: u64,
457 pending: PendingBackgroundCost,
458 }
459
460 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
461 pub struct CostScopeToken(u64);
462
463 #[cfg(not(test))]
464 static PENDING: OnceLock<Mutex<ScopedPendingBackgroundCost>> = OnceLock::new();
465
466 #[cfg(test)]
467 static TEST_PENDING: OnceLock<
468 Mutex<std::collections::HashMap<std::thread::ThreadId, ScopedPendingBackgroundCost>>,
469 > = OnceLock::new();
470
471 fn with_pending_state_mut<R>(f: impl FnOnce(&mut ScopedPendingBackgroundCost) -> R) -> R {
472 #[cfg(not(test))]
473 {
474 let mut pending = PENDING
475 .get_or_init(|| Mutex::new(ScopedPendingBackgroundCost::default()))
476 .lock()
477 .unwrap_or_else(|e| e.into_inner());
478 f(&mut pending)
479 }
480 #[cfg(test)]
481 {
482 // Rust tests run concurrently. A test-local collector prevents a UI
483 // drain or successful purge in one test from stealing another test's
484 // accounting. Tokio's default test runtime is current-thread, so async
485 // helpers retain this scope across awaits.
486 let mut by_thread = TEST_PENDING
487 .get_or_init(|| Mutex::new(std::collections::HashMap::new()))
488 .lock()
489 .unwrap_or_else(|e| e.into_inner());
490 f(by_thread.entry(std::thread::current().id()).or_default())
491 }
492 }
493
494 /// Runtime accounting gets a cloned, owner-scoped copy of compaction usage.
495 /// This journal is deliberately separate from the TUI pending-money pool:
496 /// taking one runtime owner's records cannot steal or reset the foreground
497 /// session's `/cost` state.
498 const MAX_RUNTIME_USAGE_RECORDS_PER_OWNER: usize = 64;
499
500 #[derive(Default)]
501 struct OwnerRuntimeUsageJournal {
502 records: VecDeque<RuntimeUsageRecord>,
503 dropped_records: u64,
504 }
505
506 type RuntimeUsageJournal = HashMap<String, OwnerRuntimeUsageJournal>;
507
508 /// Bounded fallback batch returned when no synchronous runtime sink was
509 /// available. `dropped_records` is persisted into the turn so aggregates fail
510 /// closed instead of silently presenting a partial cost as complete.
511 #[derive(Debug, Clone, Default, PartialEq, Eq)]
512 pub struct RuntimeUsageBatch {
513 pub records: Vec<RuntimeUsageRecord>,
514 pub dropped_records: u64,
515 }
516
517 /// One owner-scoped usage report with the stable provider-call identity used
518 /// to make durable replay idempotent.
519 #[derive(Debug, Clone, PartialEq, Eq)]
520 pub struct RuntimeUsageRecord {
521 pub source_id: String,
522 pub usage: EffectiveRouteUsage,
523 }
524
525 pub(crate) type RuntimeUsageSink = Arc<dyn Fn(RuntimeUsageRecord) -> bool + Send + Sync>;
526
527 struct RuntimeUsageSinkEntry {
528 sink: RuntimeUsageSink,
529 leases: usize,
530 terminal: bool,
531 }
532
533 /// Keeps an owner sink alive while a detached child can still report usage.
534 /// The runtime turn may already be terminal; the last child release retires
535 /// the sink only after its final provider response has been durably appended.
536 #[derive(Debug)]
537 pub(crate) struct RuntimeUsageLease {
538 owner: String,
539 active: bool,
540 }
541
542 #[cfg(not(test))]
543 static RUNTIME_USAGE_JOURNAL: OnceLock<Mutex<RuntimeUsageJournal>> = OnceLock::new();
544
545 #[cfg(test)]
546 static TEST_RUNTIME_USAGE_JOURNAL: OnceLock<
547 Mutex<std::collections::HashMap<std::thread::ThreadId, RuntimeUsageJournal>>,
548 > = OnceLock::new();
549
550 #[cfg(not(test))]
551 static RUNTIME_USAGE_SINKS: OnceLock<Mutex<HashMap<String, RuntimeUsageSinkEntry>>> =
552 OnceLock::new();
553
554 /// Sinks are keyed by owner id, and owner ids in tests are short fixture
555 /// strings that repeat across tests. Under the default parallel test harness a
556 /// process-global map let one test's `register_runtime_usage_sink` replace
557 /// another's live sink, and let one test's `finish_runtime_usage_owner` retire
558 /// it — turning exactly-once child accounting into an order-dependent race.
559 /// Scoping by thread matches the pending-cost pool and the runtime journal,
560 /// which are already thread-scoped for the same reason.
561 #[cfg(test)]
562 #[allow(clippy::type_complexity)]
563 static TEST_RUNTIME_USAGE_SINKS: OnceLock<
564 Mutex<HashMap<std::thread::ThreadId, HashMap<String, RuntimeUsageSinkEntry>>>,
565 > = OnceLock::new();
566
567 /// Run `f` against this scope's sink registry.
568 fn with_runtime_usage_sinks<R>(
569 f: impl FnOnce(&mut HashMap<String, RuntimeUsageSinkEntry>) -> R,
570 ) -> R {
571 #[cfg(not(test))]
572 {
573 let mut sinks = RUNTIME_USAGE_SINKS
574 .get_or_init(|| Mutex::new(HashMap::new()))
575 .lock()
576 .unwrap_or_else(|error| error.into_inner());
577 f(&mut sinks)
578 }
579 #[cfg(test)]
580 {
581 let mut by_thread = TEST_RUNTIME_USAGE_SINKS
582 .get_or_init(|| Mutex::new(HashMap::new()))
583 .lock()
584 .unwrap_or_else(|error| error.into_inner());
585 f(by_thread.entry(std::thread::current().id()).or_default())
586 }
587 }
588
589 /// Like [`with_runtime_usage_sinks`], but does not create the registry when it
590 /// has never been initialized. Used on drop paths, where allocating a registry
591 /// to then find it empty would be pointless.
592 fn with_existing_runtime_usage_sinks<R>(
593 f: impl FnOnce(&mut HashMap<String, RuntimeUsageSinkEntry>) -> R,
594 ) -> Option<R> {
595 #[cfg(not(test))]
596 {
597 let sinks = RUNTIME_USAGE_SINKS.get()?;
598 let mut sinks = sinks.lock().unwrap_or_else(|error| error.into_inner());
599 Some(f(&mut sinks))
600 }
601 #[cfg(test)]
602 {
603 let by_thread = TEST_RUNTIME_USAGE_SINKS.get()?;
604 let mut by_thread = by_thread.lock().unwrap_or_else(|error| error.into_inner());
605 let sinks = by_thread.get_mut(&std::thread::current().id())?;
606 Some(f(sinks))
607 }
608 }
609
610 fn with_runtime_usage_journal_mut<R>(f: impl FnOnce(&mut RuntimeUsageJournal) -> R) -> R {
611 #[cfg(not(test))]
612 {
613 let mut journal = RUNTIME_USAGE_JOURNAL
614 .get_or_init(|| Mutex::new(HashMap::new()))
615 .lock()
616 .unwrap_or_else(|error| error.into_inner());
617 f(&mut journal)
618 }
619 #[cfg(test)]
620 {
621 let mut by_thread = TEST_RUNTIME_USAGE_JOURNAL
622 .get_or_init(|| Mutex::new(std::collections::HashMap::new()))
623 .lock()
624 .unwrap_or_else(|error| error.into_inner());
625 f(by_thread.entry(std::thread::current().id()).or_default())
626 }
627 }
628
629 fn record_runtime_usage(
630 owner: &str,
631 source_id: &str,
632 route: &EffectiveRouteEnvelope,
633 usage: &Usage,
634 ) {
635 let owner = owner.trim();
636 if owner.is_empty() {
637 return;
638 }
639 let record = RuntimeUsageRecord {
640 source_id: source_id.to_string(),
641 usage: EffectiveRouteUsage {
642 route: route.sanitized_for_persistence(),
643 usage: usage.clone(),
644 },
645 };
646 let sink =
647 with_runtime_usage_sinks(|sinks| sinks.get(owner).map(|entry| Arc::clone(&entry.sink)));
648 if sink.is_some_and(|sink| sink(record.clone())) {
649 return;
650 }
651 with_runtime_usage_journal_mut(|journal| {
652 let owner_journal = journal.entry(owner.to_string()).or_default();
653 if owner_journal.records.len() == MAX_RUNTIME_USAGE_RECORDS_PER_OWNER {
654 owner_journal.records.pop_front();
655 owner_journal.dropped_records = owner_journal.dropped_records.saturating_add(1);
656 }
657 owner_journal.records.push_back(record);
658 });
659 }
660
661 /// Install a synchronous durability sink for one active runtime turn.
662 /// Compaction calls invoke this before they return to the engine, so a process
663 /// crash cannot erase already-reported usage from an in-memory journal.
664 pub(crate) fn register_runtime_usage_sink(owner: &str, sink: RuntimeUsageSink) {
665 let owner = owner.trim();
666 if owner.is_empty() {
667 return;
668 }
669 with_runtime_usage_sinks(|sinks| {
670 sinks.insert(
671 owner.to_string(),
672 RuntimeUsageSinkEntry {
673 sink,
674 leases: 0,
675 terminal: false,
676 },
677 );
678 });
679 }
680
681 /// Acquire an owner lease for a root sub-agent runtime. Runtime clones inherit
682 /// the lease, so top-level detached children can outlive the parent mailbox
683 /// without losing their accounting path.
684 pub(crate) fn acquire_runtime_usage_lease(owner: &str) -> Option<RuntimeUsageLease> {
685 let owner = owner.trim();
686 if owner.is_empty() {
687 return None;
688 }
689 with_runtime_usage_sinks(|sinks| {
690 let entry = sinks.get_mut(owner)?;
691 entry.leases = entry.leases.saturating_add(1);
692 Some(RuntimeUsageLease {
693 owner: owner.to_string(),
694 active: true,
695 })
696 })
697 }
698
699 impl RuntimeUsageLease {
700 #[must_use]
701 pub(crate) fn owner(&self) -> &str {
702 &self.owner
703 }
704 }
705
706 impl Clone for RuntimeUsageLease {
707 fn clone(&self) -> Self {
708 if self.active {
709 let cloned = with_runtime_usage_sinks(|sinks| {
710 sinks.get_mut(&self.owner).map(|entry| {
711 entry.leases = entry.leases.saturating_add(1);
712 })
713 });
714 if cloned.is_some() {
715 return Self {
716 owner: self.owner.clone(),
717 active: true,
718 };
719 }
720 }
721 Self {
722 owner: self.owner.clone(),
723 active: false,
724 }
725 }
726 }
727
728 impl Drop for RuntimeUsageLease {
729 fn drop(&mut self) {
730 if !self.active {
731 return;
732 }
733 with_existing_runtime_usage_sinks(|sinks| {
734 let should_remove = sinks.get_mut(&self.owner).is_some_and(|entry| {
735 entry.leases = entry.leases.saturating_sub(1);
736 entry.terminal && entry.leases == 0
737 });
738 if should_remove {
739 sinks.remove(&self.owner);
740 }
741 });
742 }
743 }
744
745 /// Mark the parent turn terminal. An owner with detached children stays live
746 /// until their cloned leases drop; owners without children retire now.
747 pub(crate) fn finish_runtime_usage_owner(owner: &str) {
748 with_existing_runtime_usage_sinks(|sinks| {
749 let should_remove = sinks.get_mut(owner).is_some_and(|entry| {
750 entry.terminal = true;
751 entry.leases == 0
752 });
753 if should_remove {
754 sinks.remove(owner);
755 }
756 });
757 }
758
759 /// Take only the background usage assigned to one runtime turn.
760 /// Other runtime turns and the TUI pending pool remain untouched.
761 #[must_use]
762 pub fn take_runtime_usage(owner: &str) -> RuntimeUsageBatch {
763 with_runtime_usage_journal_mut(|journal| {
764 journal
765 .remove(owner)
766 .map_or_else(RuntimeUsageBatch::default, |entry| RuntimeUsageBatch {
767 records: entry.records.into_iter().collect(),
768 dropped_records: entry.dropped_records,
769 })
770 })
771 }
772
773 /// Capture the current session/run generation before starting a background
774 /// provider request. The same token must be supplied when its usage returns.
775 #[must_use]
776 pub fn scope_token() -> CostScopeToken {
777 with_pending_state_mut(|state| CostScopeToken(state.generation))
778 }
779
780 /// Atomically close the current cost scope and start a fresh generation.
781 /// Reports from old in-flight requests are rejected after this returns, so
782 /// `/new` and session load cannot inherit another session's spend.
783 #[must_use]
784 pub fn close_current_scope() -> PendingBackgroundCost {
785 with_pending_state_mut(|state| {
786 let pending = std::mem::take(&mut state.pending);
787 state.generation = state.generation.wrapping_add(1);
788 pending
789 })
790 }
791
792 /// The non-secret identity of a background LLM call's route.
793 ///
794 /// Background helpers run off a bare client with no app `Config`, so they cannot
795 /// resolve credential-derived billing. They *can* report what they actually know
796 /// — which provider, which configured route, which wire model, which endpoint —
797 /// and this type carries exactly that, so the pricing decision is made from
798 /// evidence instead of from a provider name.
799 #[derive(Debug, Clone, Copy)]
800 #[cfg(test)]
801 pub struct BackgroundRoute<'a> {
802 /// Provider kind serving the call.
803 pub provider: ApiProvider,
804 /// Configured route identity (the `[providers.<name>]` key), when the
805 /// caller has one. This is a user-chosen label, not a credential.
806 pub provider_identity: Option<&'a str>,
807 /// Wire model id as sent on the request.
808 pub wire_model: &'a str,
809 /// Concrete base URL the request went to, when the client exposes one.
810 ///
811 /// Only ever used to derive a billing-surface classification and a
812 /// SHA-256 fingerprint; the URL itself never leaves this struct.
813 pub base_url: Option<&'a str>,
814 }
815
816 #[cfg(test)]
817 impl<'a> BackgroundRoute<'a> {
818 /// A route with no endpoint information.
819 #[must_use]
820 pub fn new(provider: ApiProvider, wire_model: &'a str) -> Self {
821 Self {
822 provider,
823 provider_identity: None,
824 wire_model,
825 base_url: None,
826 }
827 }
828
829 #[must_use]
830 pub fn with_base_url(mut self, base_url: Option<&'a str>) -> Self {
831 self.base_url = base_url;
832 self
833 }
834
835 /// Attach the configured route label (`[providers.<name>]` key).
836 ///
837 /// Background helpers do not currently resolve one; the foreground turn path
838 /// supplies it through [`route_receipt`] directly.
839 #[must_use]
840 #[allow(dead_code)]
841 pub fn with_identity(mut self, provider_identity: Option<&'a str>) -> Self {
842 self.provider_identity = provider_identity;
843 self
844 }
845
846 /// Non-secret billing-surface classification for this endpoint.
847 #[must_use]
848 pub fn billing_surface(&self) -> Option<&'static str> {
849 crate::pricing::billing_surface_for_route(self.provider, self.base_url)
850 }
851
852 /// SHA-256 fingerprint of the normalized base URL, or `None` when unknown.
853 ///
854 /// This is the same digest the catalog scopes live rows on, so a live
855 /// pricing row can be proven to price *this* endpoint.
856 #[must_use]
857 pub fn endpoint_fingerprint(&self) -> Option<String> {
858 self.base_url.and_then(endpoint_fingerprint)
859 }
860
861 /// Billing presentation derivable without app config.
862 #[must_use]
863 pub fn billing(&self) -> BillingPresentation {
864 crate::route_billing::for_endpoint_without_config(self.provider, self.base_url)
865 }
866
867 /// A redacted, stable receipt describing this route.
868 #[must_use]
869 pub fn receipt(&self, currency: &str) -> String {
870 route_receipt(
871 self.provider,
872 self.provider_identity,
873 self.wire_model,
874 self.billing_surface(),
875 self.endpoint_fingerprint().as_deref(),
876 self.billing().into(),
877 currency,
878 )
879 }
880 }
881
882 /// Format one redacted route receipt.
883 ///
884 /// Contains only: provider kind, configured route label, wire model,
885 /// billing-surface classification, endpoint fingerprint, billing mode, and the currency the
886 /// estimate is denominated in. It deliberately contains no URL, no credential,
887 /// and no filesystem path, so it is safe to persist into a saved session and to
888 /// log. This is the single formatter, so the foreground turn path and the
889 /// background pool cannot describe the same route two different ways.
890 #[must_use]
891 pub fn route_receipt(
892 provider: ApiProvider,
893 provider_identity: Option<&str>,
894 wire_model: &str,
895 billing_surface: Option<&str>,
896 endpoint_fingerprint: Option<&str>,
897 billing_mode: RouteBillingMode,
898 currency: &str,
899 ) -> String {
900 format!(
901 "provider={} identity={} model={} surface={} endpoint_fp={} billing_mode={} currency={currency}",
902 provider.as_str(),
903 safe_receipt_field(provider_identity.unwrap_or("-")),
904 safe_receipt_field(wire_model),
905 safe_receipt_field(billing_surface.unwrap_or("unreported")),
906 safe_receipt_field(endpoint_fingerprint.unwrap_or("unreported")),
907 match billing_mode {
908 RouteBillingMode::Metered => "metered",
909 RouteBillingMode::Subscription => "subscription",
910 RouteBillingMode::Local => "local",
911 RouteBillingMode::Unknown => "unknown",
912 },
913 )
914 }
915
916 const MAX_RECEIPT_FIELD_CHARS: usize = 96;
917
918 fn safe_receipt_field(raw: &str) -> String {
919 let sanitized = sanitize_persisted_route_label(raw);
920 let mut out = String::with_capacity(raw.len().min(MAX_RECEIPT_FIELD_CHARS));
921 let mut previous_separator = false;
922 for ch in sanitized.chars() {
923 if out.chars().count() >= MAX_RECEIPT_FIELD_CHARS {
924 break;
925 }
926 let safe = if ch.is_alphanumeric() || matches!(ch, '.' | '_' | '-' | '/' | ':' | '+') {
927 ch
928 } else {
929 '_'
930 };
931 let separator = safe == '_';
932 if separator && previous_separator {
933 continue;
934 }
935 out.push(safe);
936 previous_separator = separator;
937 }
938 if out.is_empty() { "-".to_string() } else { out }
939 }
940
941 pub(crate) fn sanitize_persisted_route_label(raw: &str) -> String {
942 const MAX_PERSISTED_ROUTE_LABEL_CHARS: usize = 256;
943 let value = raw.trim();
944 let lower = value.to_ascii_lowercase();
945
946 if value.is_empty() {
947 return "-".to_string();
948 }
949
950 // URLs are not route labels. Endpoints have a dedicated, validated hash
951 // field; persisting a URL here risks leaking userinfo, query credentials,
952 // or fragments through a custom provider/model name.
953 if value.contains("://") {
954 return "redacted-url".to_string();
955 }
956
957 let authorization_value = ["bearer ", "basic ", "digest ", "token ", "apikey "]
958 .iter()
959 .any(|scheme| lower.starts_with(scheme))
960 || lower.contains("authorization:")
961 || lower.contains("proxy-authorization:");
962 if authorization_value {
963 return "redacted-credential".to_string();
964 }
965
966 // Reject credential assignments regardless of common casing or separator:
967 // FOO_API_KEY=..., access-token:..., password = ....
968 for (index, ch) in value.char_indices() {
969 if !matches!(ch, '=' | ':') {
970 continue;
971 }
972 let name = lower[..index]
973 .trim()
974 .trim_matches(|ch: char| matches!(ch, '"' | '\'' | '{' | '[' | ','));
975 let name = name.rsplit([' ', ',', ';']).next().unwrap_or(name);
976 let normalized = name.replace('-', "_");
977 if normalized.ends_with("api_key")
978 || normalized.ends_with("token")
979 || normalized.ends_with("secret")
980 || normalized.ends_with("password")
981 || normalized.ends_with("passwd")
982 {
983 return "redacted-credential".to_string();
984 }
985 }
986
987 // Common credential token prefixes. These are intentionally checked at
988 // word boundaries so model ids containing an incidental "sk" survive.
989 let credential_prefix = lower
990 .split(|ch: char| ch.is_whitespace() || matches!(ch, '=' | ':' | ',' | ';' | '"' | '\''))
991 .filter(|part| !part.is_empty())
992 .any(|part| {
993 [
994 "sk-",
995 "sk_",
996 "rk-",
997 "pk-",
998 "ghp_",
999 "gho_",
1000 "ghu_",
1001 "ghs_",
1002 "github_pat_",
1003 "xoxb-",
1004 "xoxp-",
1005 "xoxa-",
1006 "akia",
1007 "aiza",
1008 "eyj",
1009 ]
1010 .iter()
1011 .any(|prefix| part.starts_with(prefix))
1012 });
1013 if credential_prefix {
1014 return "redacted-credential".to_string();
1015 }
1016
1017 let windows_absolute = value.as_bytes().get(1) == Some(&b':')
1018 && value
1019 .as_bytes()
1020 .get(2)
1021 .is_some_and(|separator| matches!(separator, b'/' | b'\\'));
1022 let contains_local_root = [
1023 "/users/",
1024 "/volumes/",
1025 "/home/",
1026 "/private/",
1027 "\\users\\",
1028 "file://",
1029 "/.ssh/",
1030 "\\.ssh\\",
1031 ]
1032 .iter()
1033 .any(|needle| lower.contains(needle));
1034 let looks_like_relative_path = value.contains('\\')
1035 || lower.starts_with(".ssh/")
1036 || lower.starts_with(".ssh\\")
1037 || lower.split('/').any(|segment| {
1038 matches!(
1039 segment,
1040 "." | ".."
1041 | ".ssh"
1042 | ".config"
1043 | "secrets"
1044 | "secret"
1045 | "credentials"
1046 | "credential"
1047 | "relative"
1048 | "workspace"
1049 | "tmp"
1050 )
1051 });
1052 if std::path::Path::new(value).is_absolute()
1053 || windows_absolute
1054 || value.starts_with("~/")
1055 || value.starts_with("./")
1056 || value.starts_with("../")
1057 || contains_local_root
1058 || looks_like_relative_path
1059 {
1060 return "redacted-local-path".to_string();
1061 }
1062 let bounded: String = value
1063 .chars()
1064 .filter(|ch| !ch.is_control())
1065 .take(MAX_PERSISTED_ROUTE_LABEL_CHARS)
1066 .collect();
1067 if bounded.is_empty() {
1068 "-".to_string()
1069 } else {
1070 bounded
1071 }
1072 }
1073
1074 /// Validate and canonicalize an endpoint before producing the cryptographic
1075 /// fingerprint persisted in a receipt. Secret-bearing/malformed URLs receive
1076 /// no fingerprint at all; userinfo, query strings, and fragments are never fed
1077 /// to the hash function.
1078 #[must_use]
1079 pub fn endpoint_fingerprint(base_url: &str) -> Option<String> {
1080 let mut parsed = reqwest::Url::parse(base_url.trim()).ok()?;
1081 if !matches!(parsed.scheme(), "http" | "https")
1082 || !parsed.username().is_empty()
1083 || parsed.password().is_some()
1084 || parsed.query().is_some()
1085 || parsed.fragment().is_some()
1086 || parsed.host_str().is_none()
1087 {
1088 return None;
1089 }
1090 parsed.set_query(None);
1091 parsed.set_fragment(None);
1092 let canonical = parsed.as_str().trim_end_matches('/');
1093 Some(codewhale_config::catalog::base_url_fingerprint(canonical))
1094 }
1095
1096 /// Currency tag for a receipt, derived from authoritative currency coverage —
1097 /// not from a positive amount, because a zero-usage priced turn is still a
1098 /// valid zero in its published currency.
1099 #[must_use]
1100 pub fn currency_tag(audit: &TurnCostAudit) -> &'static str {
1101 match (audit.usd_priced, audit.cny_priced) {
1102 (true, true) => "usd+cny",
1103 (true, false) => "usd",
1104 (false, true) => "cny",
1105 (false, false) => "unpriced",
1106 }
1107 }
1108
1109 /// Background callers report their LLM usage here.
1110 ///
1111 /// The route is priced through the same [`crate::pricing::audit_turn_cost_for_route_on_endpoint`]
1112 /// the foreground turn path uses, so a background turn cannot be counted under
1113 /// different rules than a parent turn. Adds no money when the route is exactly
1114 /// non-metered (a local runtime, an OAuth broker, a named plan endpoint), and
1115 /// counts the turn as *missing spend* whenever it is money-metered or of unknown
1116 /// basis but could not be priced — an unknown basis is never waved through as a
1117 /// subscription (#4318).
1118 #[cfg(test)]
1119 pub fn report(scope: CostScopeToken, route: &BackgroundRoute<'_>, usage: &Usage) {
1120 let billing_surface = route.billing_surface();
1121 let fingerprint = route.endpoint_fingerprint();
1122 let audit = crate::pricing::audit_turn_cost_for_route_on_endpoint(
1123 route.provider,
1124 route.wire_model,
1125 billing_surface,
1126 fingerprint.as_deref(),
1127 usage,
1128 chrono::Utc::now(),
1129 route.billing(),
1130 );
1131 record(scope, route.receipt(currency_tag(&audit)), &audit, usage);
1132 }
1133
1134 /// Report usage against an immutable route envelope captured before the call.
1135 /// This is the background equivalent of foreground/subagent accrual and avoids
1136 /// response aliases or completion-time clocks changing billing identity.
1137 pub fn report_effective_route(
1138 scope: CostScopeToken,
1139 route: &EffectiveRouteEnvelope,
1140 usage: &Usage,
1141 ) {
1142 let audit = route.audit(usage);
1143 record(scope, route.receipt(&audit), &audit, usage);
1144 }
1145
1146 /// Report background usage to exactly one accounting owner.
1147 ///
1148 /// Runtime-owned calls go only to the durable runtime sink. Calls without a
1149 /// runtime owner belong to the interactive TUI pool. Mixing both paths would
1150 /// count one provider response twice in hosts that expose both projections.
1151 pub fn report_effective_route_for_runtime(
1152 scope: CostScopeToken,
1153 runtime_owner: Option<&str>,
1154 source_id: &str,
1155 route: &EffectiveRouteEnvelope,
1156 usage: &Usage,
1157 ) {
1158 if let Some(owner) = runtime_owner {
1159 record_runtime_usage(owner, source_id, route, usage);
1160 } else {
1161 report_effective_route(scope, route, usage);
1162 }
1163 }
1164
1165 /// Fold one already-computed audit into the pending pool.
1166 fn record(scope: CostScopeToken, route_receipt: String, audit: &TurnCostAudit, usage: &Usage) {
1167 with_pending_state_mut(|state| {
1168 if state.generation != scope.0 {
1169 return;
1170 }
1171 let pending = &mut state.pending;
1172 if let Some(provenance) = audit.provenance.as_ref() {
1173 pending.pricing_provenances.insert(provenance.label());
1174 }
1175 if let Some(defect) = audit.live_pricing_defect.as_ref() {
1176 if audit.estimate.is_some() {
1177 pending.live_pricing_defects.insert(defect.label());
1178 } else {
1179 pending.live_pricing_unusable_defects.insert(defect.label());
1180 }
1181 }
1182 if let Some(cost) = audit.estimate {
1183 pending.estimate = pending.estimate.saturating_add(cost);
1184 }
1185
1186 // Only money-metered/unknown-basis turns belong in missing-money coverage
1187 // or its reason list. A subscription/local receipt is still audited below,
1188 // but `not_money_metered` must never be presented as a gap in a subtotal.
1189 if audit.counts_toward_money_coverage() {
1190 if audit.usd_priced {
1191 pending.priced_turns = pending.priced_turns.saturating_add(1);
1192 } else {
1193 pending.unpriced_turns = pending.unpriced_turns.saturating_add(1);
1194 }
1195 if audit.cny_priced {
1196 pending.cny_priced_turns = pending.cny_priced_turns.saturating_add(1);
1197 } else {
1198 pending.cny_unpriced_turns = pending.cny_unpriced_turns.saturating_add(1);
1199 }
1200 for class in &audit.unpriced_classes {
1201 pending.unpriced_classes.insert(class.label());
1202 }
1203 if !audit.usd_priced
1204 && let Some(reason) = audit.unpriced_reason
1205 {
1206 pending.unpriced_reasons.insert(reason.label());
1207 }
1208 if !audit.cny_priced {
1209 pending.cny_unpriced_reasons.insert(
1210 audit
1211 .unpriced_reason
1212 .map_or("currency_not_published", |reason| reason.label()),
1213 );
1214 }
1215 }
1216
1217 // Record which token classes this route actually billed on, so a receipt
1218 // shows whether cache-write/reasoning telemetry was even present.
1219 pending
1220 .route_receipts
1221 .insert(receipt_with_usage_classes(route_receipt, usage));
1222 });
1223 }
1224
1225 /// Drain the pending pool, returning it and resetting to zero.
1226 ///
1227 /// Money and its completeness leave together, so a caller can never fold a
1228 /// subtotal into a session total without the counters that qualify it.
1229 #[must_use]
1230 pub fn drain() -> PendingBackgroundCost {
1231 with_pending_state_mut(|state| std::mem::take(&mut state.pending))
1232 }
1233
1234 /// Reset the pool to zero without consuming. Test-only helper for
1235 /// suites that share the static and need to start from a known
1236 /// state. Production code should always use [`drain`].
1237 #[cfg(test)]
1238 pub fn reset_for_tests() {
1239 with_pending_state_mut(|state| state.pending = PendingBackgroundCost::default());
1240 with_runtime_usage_journal_mut(HashMap::clear);
1241 }
1242
1243 #[cfg(test)]
1244 pub(crate) struct TestCostScope;
1245
1246 #[cfg(test)]
1247 impl Drop for TestCostScope {
1248 fn drop(&mut self) {
1249 reset_for_tests();
1250 }
1251 }
1252
1253 #[cfg(test)]
1254 pub(crate) fn test_scope() -> TestCostScope {
1255 reset_for_tests();
1256 TestCostScope
1257 }
1258
1259 #[cfg(test)]
1260 mod tests {
1261 use super::*;
1262
1263 fn small_usage() -> Usage {
1264 Usage {
1265 input_tokens: 1_000,
1266 output_tokens: 500,
1267 ..Default::default()
1268 }
1269 }
1270
1271 fn deepseek() -> BackgroundRoute<'static> {
1272 BackgroundRoute::new(ApiProvider::Deepseek, "deepseek-v4-flash")
1273 .with_base_url(Some(crate::config::DEFAULT_DEEPSEEK_BASE_URL))
1274 }
1275
1276 fn deepseek_envelope() -> EffectiveRouteEnvelope {
1277 EffectiveRouteEnvelope::capture(
1278 None,
1279 ApiProvider::Deepseek,
1280 "deepseek-primary",
1281 "deepseek-v4-flash",
1282 Some(crate::config::DEFAULT_DEEPSEEK_BASE_URL),
1283 Utc::now(),
1284 )
1285 }
1286
1287 #[test]
1288 fn child_metadata_round_trip_preserves_zero_and_reasoning_usage() {
1289 let route = deepseek_envelope();
1290 let usage = Usage {
1291 input_tokens: 0,
1292 output_tokens: 9,
1293 reasoning_tokens: Some(7),
1294 reasoning_replay_tokens: Some(3),
1295 ..Usage::default()
1296 };
1297 let mut metadata = serde_json::json!({"tool": "rlm_eval"});
1298 attach_child_usage_metadata(&mut metadata, &route, &usage);
1299
1300 assert_eq!(child_route_envelope_from_metadata(&metadata), Some(route));
1301 assert_eq!(child_usage_from_metadata(&metadata), Some(usage));
1302
1303 let mut zero_metadata = serde_json::json!({});
1304 let zero = Usage::default();
1305 attach_child_usage_metadata(&mut zero_metadata, &deepseek_envelope(), &zero);
1306 assert_eq!(child_usage_from_metadata(&zero_metadata), Some(zero));
1307 }
1308
1309 #[test]
1310 fn runtime_owned_usage_is_isolated_from_tui_pool() {
1311 let _g = test_scope();
1312 let route = deepseek_envelope();
1313 let usage = small_usage();
1314 report_effective_route_for_runtime(
1315 scope_token(),
1316 Some("turn-a"),
1317 "response-a",
1318 &route,
1319 &usage,
1320 );
1321 report_effective_route_for_runtime(
1322 scope_token(),
1323 Some("turn-b"),
1324 "response-b",
1325 &route,
1326 &usage,
1327 );
1328
1329 assert_eq!(take_runtime_usage("turn-a").records.len(), 1);
1330 assert!(take_runtime_usage("turn-a").records.is_empty());
1331 assert_eq!(take_runtime_usage("turn-b").records.len(), 1);
1332 assert!(
1333 drain().is_empty(),
1334 "runtime-owned usage must not enter TUI cost"
1335 );
1336
1337 report_effective_route_for_runtime(scope_token(), None, "response-tui", &route, &usage);
1338 assert_eq!(drain().priced_turns, 1, "ownerless usage belongs to TUI");
1339 }
1340
1341 /// Every piece of shared cost accounting is scoped to the test that owns
1342 /// it, including the durability sink registry.
1343 ///
1344 /// Sinks are keyed by owner id, and owner ids in tests are short fixture
1345 /// strings that repeat. A process-global registry let one test's
1346 /// `register_runtime_usage_sink` overwrite another's live sink, and let one
1347 /// test's `finish_runtime_usage_owner` retire it mid-flight — so a passing
1348 /// exactly-once assertion depended on which tests happened to run
1349 /// concurrently. This pins the isolation directly: a sink registered on
1350 /// another thread must be invisible here, and usage reported here must not
1351 /// reach it.
1352 #[test]
1353 fn runtime_usage_sinks_do_not_leak_across_test_threads() {
1354 let _g = test_scope();
1355 let owner = "shared-owner";
1356 let other_thread_deliveries = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1357
1358 // A concurrent test, standing in for any other test in the binary that
1359 // happens to use the same owner id.
1360 let deliveries = Arc::clone(&other_thread_deliveries);
1361 let (ready_tx, ready_rx) = std::sync::mpsc::channel();
1362 let (done_tx, done_rx) = std::sync::mpsc::channel();
1363 let other = std::thread::spawn(move || {
1364 register_runtime_usage_sink(
1365 owner,
1366 Arc::new(move |_record| {
1367 deliveries.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1368 true
1369 }),
1370 );
1371 ready_tx.send(()).expect("signal registration");
1372 // Hold the registration open across this thread's assertions.
1373 done_rx.recv().expect("wait for the other test to finish");
1374 // The other thread's own reports still reach its own sink.
1375 report_effective_route_for_runtime(
1376 scope_token(),
1377 Some(owner),
1378 "response-other",
1379 &deepseek_envelope(),
1380 &small_usage(),
1381 );
1382 });
1383 ready_rx.recv().expect("other test registered its sink");
1384
1385 // This thread never registered a sink, so its usage must fall through
1386 // to this thread's journal — not into the other test's sink.
1387 report_effective_route_for_runtime(
1388 scope_token(),
1389 Some(owner),
1390 "response-mine",
1391 &deepseek_envelope(),
1392 &small_usage(),
1393 );
1394 assert_eq!(
1395 other_thread_deliveries.load(std::sync::atomic::Ordering::SeqCst),
1396 0,
1397 "another test's sink received this test's usage"
1398 );
1399 let mine = take_runtime_usage(owner);
1400 assert_eq!(mine.records.len(), 1);
1401 assert_eq!(mine.records[0].source_id, "response-mine");
1402 assert_eq!(mine.dropped_records, 0);
1403
1404 // Retiring the owner here must not retire the other test's sink.
1405 finish_runtime_usage_owner(owner);
1406 done_tx.send(()).expect("release the other test");
1407 other.join().expect("other test thread");
1408 assert_eq!(
1409 other_thread_deliveries.load(std::sync::atomic::Ordering::SeqCst),
1410 1,
1411 "the other test's sink was retired by an unrelated test"
1412 );
1413 }
1414
1415 #[test]
1416 fn runtime_usage_fallback_is_bounded_and_reports_truncation() {
1417 let _g = test_scope();
1418 let route = deepseek_envelope();
1419 for index in 0..(MAX_RUNTIME_USAGE_RECORDS_PER_OWNER + 3) {
1420 report_effective_route_for_runtime(
1421 scope_token(),
1422 Some("turn-bounded"),
1423 &format!("response-{index}"),
1424 &route,
1425 &small_usage(),
1426 );
1427 }
1428
1429 let batch = take_runtime_usage("turn-bounded");
1430 assert_eq!(batch.records.len(), MAX_RUNTIME_USAGE_RECORDS_PER_OWNER);
1431 assert_eq!(batch.dropped_records, 3);
1432 assert!(drain().is_empty(), "runtime fallback must stay out of TUI");
1433 }
1434
1435 #[test]
1436 fn route_labels_redact_local_paths_but_preserve_model_namespaces() {
1437 let route = EffectiveRouteEnvelope {
1438 provider: ApiProvider::Openrouter,
1439 provider_identity: "/Users/alice/.config/provider-secret".to_string(),
1440 model: "/Volumes/private/checkpoints/model.gguf".to_string(),
1441 billing_surface: None,
1442 endpoint_fingerprint: None,
1443 billing_mode: RouteBillingMode::Metered,
1444 dispatched_at: Utc::now(),
1445 };
1446 let sanitized = route.sanitized_for_persistence();
1447 assert_eq!(sanitized.provider_identity, "redacted-local-path");
1448 assert_eq!(sanitized.model, "redacted-local-path");
1449 let receipt = route.receipt(&TurnCostAudit::unpriced(
1450 crate::pricing::UnpricedReason::NoPricingRow,
1451 ));
1452 assert!(!receipt.contains("alice"));
1453 assert!(!receipt.contains("Volumes"));
1454
1455 assert_eq!(
1456 sanitize_persisted_route_label("anthropic/claude-sonnet-5"),
1457 "anthropic/claude-sonnet-5"
1458 );
1459 }
1460
1461 #[test]
1462 fn route_label_sanitizer_rejects_credentials_urls_and_relative_paths() {
1463 for credential in [
1464 "Bearer secret-token",
1465 "Authorization: Basic abc123",
1466 "OPENAI_API_KEY=sk-secret",
1467 "service_token: ghp_secret",
1468 "db-password=hunter2",
1469 "sk-live-secret",
1470 "https://alice:password@example.test/v1?api_key=secret#fragment",
1471 ] {
1472 let sanitized = sanitize_persisted_route_label(credential);
1473 assert!(
1474 sanitized.starts_with("redacted-"),
1475 "credential was not redacted: {credential:?} -> {sanitized:?}"
1476 );
1477 }
1478 for path in [
1479 ".ssh/id_ed25519",
1480 "../secrets/provider.key",
1481 "workspace/.ssh/config",
1482 "relative/path/to/credential",
1483 r"relative\path\credential",
1484 ] {
1485 assert_eq!(
1486 sanitize_persisted_route_label(path),
1487 "redacted-local-path",
1488 "path was not redacted: {path:?}"
1489 );
1490 }
1491 assert_eq!(
1492 sanitize_persisted_route_label("moonshot/kimi-k3"),
1493 "moonshot/kimi-k3"
1494 );
1495 }
1496
1497 #[test]
1498 fn serialized_route_envelopes_records_and_child_receipts_are_secret_free() {
1499 let route = EffectiveRouteEnvelope {
1500 provider: ApiProvider::Custom,
1501 provider_identity: "Authorization: Bearer provider-secret".to_string(),
1502 model: "MODEL_API_KEY=sk-model-secret".to_string(),
1503 billing_surface: Some(
1504 "https://alice:password@example.test/v1?token=secret#fragment".to_string(),
1505 ),
1506 endpoint_fingerprint: Some("../.ssh/provider_key".to_string()),
1507 billing_mode: RouteBillingMode::Metered,
1508 dispatched_at: Utc::now(),
1509 };
1510 let usage = Usage {
1511 input_tokens: 7,
1512 output_tokens: 3,
1513 ..Usage::default()
1514 };
1515
1516 let envelope_json = serde_json::to_string(&route).expect("serialize envelope");
1517 let record_json = serde_json::to_string(&EffectiveRouteUsage {
1518 route: route.clone(),
1519 usage: usage.clone(),
1520 })
1521 .expect("serialize route usage");
1522 let child_json = serde_json::to_string(&child_usage_metadata_fields(&route, &usage))
1523 .expect("serialize child receipt");
1524 for serialized in [&envelope_json, &record_json, &child_json] {
1525 for secret in [
1526 "provider-secret",
1527 "sk-model-secret",
1528 "alice",
1529 "password",
1530 "token=secret",
1531 ".ssh",
1532 ] {
1533 assert!(
1534 !serialized.contains(secret),
1535 "serialized route leaked {secret:?}: {serialized}"
1536 );
1537 }
1538 }
1539 }
1540
1541 #[test]
1542 fn report_adds_to_pool_and_drain_returns_then_resets() {
1543 let _g = test_scope();
1544 report(scope_token(), &deepseek(), &small_usage());
1545 let first = drain();
1546 assert!(
1547 first.estimate.usd > 0.0,
1548 "expected positive USD cost, got {first:?}"
1549 );
1550 assert!(
1551 first.estimate.cny > 0.0,
1552 "expected positive CNY cost, got {first:?}"
1553 );
1554 assert_eq!(first.priced_turns, 1);
1555 assert_eq!(first.unpriced_turns, 0);
1556 assert_eq!(first.cny_priced_turns, 1);
1557 assert_eq!(first.cny_unpriced_turns, 0);
1558 // The receipt names the route without leaking the endpoint URL.
1559 assert_eq!(first.route_receipts.len(), 1);
1560 let receipt = first.route_receipts.iter().next().expect("receipt");
1561 assert!(receipt.contains("provider=deepseek"), "{receipt}");
1562 assert!(receipt.contains("model=deepseek-v4-flash"), "{receipt}");
1563 assert!(receipt.contains("currency=usd+cny"), "{receipt}");
1564 assert!(!receipt.contains("http"), "{receipt}");
1565
1566 let second = drain();
1567 assert!(second.is_empty(), "drain must zero the pool: {second:?}");
1568 }
1569
1570 #[test]
1571 fn reports_from_a_closed_session_scope_are_discarded() {
1572 let _g = test_scope();
1573 let old_scope = scope_token();
1574 let settled = close_current_scope();
1575 assert!(settled.is_empty());
1576
1577 report(old_scope, &deepseek(), &small_usage());
1578 assert!(drain().is_empty(), "old session usage crossed the boundary");
1579
1580 report(scope_token(), &deepseek(), &small_usage());
1581 assert_eq!(drain().priced_turns, 1);
1582 }
1583
1584 #[test]
1585 fn report_counts_unknown_models_as_missing_spend_not_as_free() {
1586 let _g = test_scope();
1587 // NIM-hosted models intentionally have no DeepSeek pricing, but the
1588 // route *is* money-metered — so the turn is missing spend, not absent.
1589 report(
1590 scope_token(),
1591 &BackgroundRoute::new(ApiProvider::NvidiaNim, "deepseek-ai/deepseek-v4-pro"),
1592 &small_usage(),
1593 );
1594 let drained = drain();
1595 assert_eq!(drained.estimate, CostEstimate::default());
1596 assert_eq!(drained.priced_turns, 0);
1597 assert_eq!(drained.unpriced_turns, 1);
1598 assert!(!drained.unpriced_reasons.is_empty());
1599 }
1600
1601 #[test]
1602 fn report_skips_codex_oauth_pricing_without_calling_it_incomplete() {
1603 let _g = test_scope();
1604 report(
1605 scope_token(),
1606 &BackgroundRoute::new(ApiProvider::OpenaiCodex, "gpt-5.5"),
1607 &small_usage(),
1608 );
1609 let drained = drain();
1610 assert_eq!(drained.estimate, CostEstimate::default());
1611 // Exactly non-metered: not counted in either coverage bucket.
1612 assert_eq!(drained.priced_turns, 0);
1613 assert_eq!(drained.unpriced_turns, 0);
1614 assert!(drained.unpriced_reasons.is_empty());
1615 assert!(drained.cny_unpriced_reasons.is_empty());
1616 }
1617
1618 #[test]
1619 fn report_skips_stepfun_without_billing_surface() {
1620 let _g = test_scope();
1621 report(
1622 scope_token(),
1623 &BackgroundRoute::new(ApiProvider::Stepfun, "step-3.7-flash"),
1624 &small_usage(),
1625 );
1626 report(
1627 scope_token(),
1628 &BackgroundRoute::new(ApiProvider::Openrouter, "step-3.7-flash"),
1629 &small_usage(),
1630 );
1631 let drained = drain();
1632 assert_eq!(drained.estimate, CostEstimate::default());
1633 // Both are metered-or-unknown routes that could not be priced, so both
1634 // are reported as missing rather than dropped.
1635 assert_eq!(drained.unpriced_turns, 2);
1636 }
1637
1638 /// A local runtime and a plan endpoint must never be guessed into public
1639 /// per-token dollars just because the provider also sells a paid API.
1640 #[test]
1641 fn local_and_plan_endpoints_are_never_treated_as_public_payg() {
1642 let _g = test_scope();
1643 report(
1644 scope_token(),
1645 &BackgroundRoute::new(ApiProvider::Ollama, "llama3.2"),
1646 &small_usage(),
1647 );
1648 report(
1649 scope_token(),
1650 &BackgroundRoute::new(ApiProvider::Zai, "glm-5.2")
1651 .with_base_url(Some("https://api.z.ai/api/coding/paas/v4")),
1652 &small_usage(),
1653 );
1654 report(
1655 scope_token(),
1656 &BackgroundRoute::new(ApiProvider::Moonshot, "kimi-for-coding")
1657 .with_base_url(Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL)),
1658 &small_usage(),
1659 );
1660 let drained = drain();
1661 assert_eq!(drained.estimate, CostEstimate::default());
1662 assert_eq!(drained.priced_turns, 0);
1663 assert_eq!(
1664 drained.unpriced_turns, 0,
1665 "exactly non-metered routes are not missing dollars: {drained:?}"
1666 );
1667 assert!(drained.unpriced_reasons.is_empty());
1668 assert!(drained.cny_unpriced_reasons.is_empty());
1669 assert!(
1670 drained
1671 .route_receipts
1672 .iter()
1673 .any(|receipt| receipt.contains("surface=zai-coding-plan")),
1674 "{drained:?}"
1675 );
1676 assert!(
1677 drained
1678 .route_receipts
1679 .iter()
1680 .any(|receipt| receipt.contains("surface=local-no-bill")),
1681 "{drained:?}"
1682 );
1683 assert!(
1684 drained
1685 .route_receipts
1686 .iter()
1687 .any(|receipt| receipt.contains("surface=moonshot-kimi-code")),
1688 "{drained:?}"
1689 );
1690 }
1691
1692 /// The receipt carries an endpoint *fingerprint*, never the URL.
1693 #[test]
1694 fn route_receipts_fingerprint_the_endpoint_and_keep_secrets_out() {
1695 let _g = test_scope();
1696 let base_url = "https://api.deepseek.com/v1";
1697 report(
1698 scope_token(),
1699 &deepseek().with_base_url(Some(base_url)),
1700 &small_usage(),
1701 );
1702 let drained = drain();
1703 let receipt = drained.route_receipts.iter().next().expect("receipt");
1704 let expected_fp = endpoint_fingerprint(base_url).expect("valid endpoint fingerprint");
1705 assert!(
1706 receipt.contains(&format!("endpoint_fp={expected_fp}")),
1707 "{receipt}"
1708 );
1709 for needle in ["http", "api.deepseek.com", "sk-", "/Users/", "/home/"] {
1710 assert!(!receipt.contains(needle), "{needle} leaked into {receipt}");
1711 }
1712 }
1713
1714 #[test]
1715 fn receipt_fields_are_bounded_and_secret_bearing_urls_are_not_hashed() {
1716 let hostile = format!("model\nAuthorization: bearer {}", "x".repeat(400));
1717 let receipt = route_receipt(
1718 ApiProvider::Deepseek,
1719 Some("identity\r\nforged=yes"),
1720 &hostile,
1721 Some(crate::pricing::FIRST_PARTY_PAYG_BILLING_SURFACE),
1722 None,
1723 RouteBillingMode::Metered,
1724 "usd+cny",
1725 );
1726 assert!(!receipt.contains('\n'), "{receipt}");
1727 assert!(!receipt.contains('\r'), "{receipt}");
1728 assert!(
1729 receipt.len() < 420,
1730 "receipt was not bounded: {}",
1731 receipt.len()
1732 );
1733
1734 for secret_url in [
1735 "https://user:secret@api.example.com/v1",
1736 "https://api.example.com/v1?api_key=secret",
1737 "https://api.example.com/v1#secret",
1738 ] {
1739 assert_eq!(endpoint_fingerprint(secret_url), None, "{secret_url}");
1740 }
1741 assert_eq!(
1742 endpoint_fingerprint("https://API.Example.com/v1/")
1743 .expect("valid endpoint")
1744 .len(),
1745 64
1746 );
1747 }
1748
1749 #[test]
1750 fn report_accumulates_across_multiple_calls() {
1751 let _g = test_scope();
1752 report(scope_token(), &deepseek(), &small_usage());
1753 report(scope_token(), &deepseek(), &small_usage());
1754 let total = drain();
1755 // Two equal reports — total must be 2× a single report.
1756 let single = crate::pricing::calculate_turn_cost_estimate_from_usage(
1757 "deepseek-v4-flash",
1758 &small_usage(),
1759 )
1760 .unwrap();
1761 assert!((total.estimate.usd - 2.0 * single.usd).abs() < 1e-12);
1762 assert!((total.estimate.cny - 2.0 * single.cny).abs() < 1e-12);
1763 assert_eq!(total.priced_turns, 2);
1764 // Identical routes collapse to one receipt rather than growing without
1765 // bound across a long session.
1766 assert_eq!(total.route_receipts.len(), 1);
1767 }
1768
1769 /// A cache-write turn on a route with no published write rate must show up
1770 /// as missing spend naming the class, not as a discounted total.
1771 #[test]
1772 fn unpriced_cache_write_class_is_reported_not_absorbed() {
1773 let _g = test_scope();
1774 let write_heavy = Usage {
1775 input_tokens: 1_000_000,
1776 output_tokens: 100_000,
1777 prompt_cache_hit_tokens: Some(200_000),
1778 prompt_cache_write_tokens: Some(100_000),
1779 ..Default::default()
1780 };
1781 report(
1782 scope_token(),
1783 &BackgroundRoute::new(ApiProvider::Moonshot, "kimi-k2.7-code")
1784 .with_base_url(Some("https://api.moonshot.ai/v1")),
1785 &write_heavy,
1786 );
1787 let drained = drain();
1788 assert_eq!(drained.estimate, CostEstimate::default());
1789 assert_eq!(drained.unpriced_turns, 1);
1790 assert!(drained.unpriced_reasons.contains("missing_class_price"));
1791 assert!(drained.unpriced_classes.contains("cache_write"));
1792 assert!(
1793 drained
1794 .route_receipts
1795 .iter()
1796 .any(|receipt| receipt.contains("cache_write=yes")),
1797 "{drained:?}"
1798 );
1799 }
1800 }
1801
1801 lines RUST