Skip to main content

thndrs_agent/
accounting.rs

1//! Provider-neutral request size and usage accounting.
2//!
3//! Accounting values deliberately carry their origin. Bytes are measured at
4//! the serialized request boundary, estimates identify the heuristic version,
5//! and provider values distinguish reported components from derived totals.
6//! This module never stores provider payloads or request content.
7
8use serde::{Deserialize, Serialize};
9
10use crate::context::{ContextItem, ContextVisibility};
11
12/// Version of the conservative serialized-byte token estimator.
13pub const TOKEN_ESTIMATOR_VERSION: &str = "utf8-bytes-divisor-3-overhead-16-v1";
14
15/// Version of the provider usage normalization rules.
16pub const USAGE_NORMALIZATION_VERSION: &str = "provider-inclusive-input-v1";
17
18/// Maximum model-projection bytes retained on an in-memory request event.
19///
20/// The projection is deliberately skipped by accounting serialization. It is
21/// supplied to the application for inspection/export and is not session truth.
22pub const MODEL_PROJECTION_MAX_BYTES: usize = 128 * 1024;
23
24/// Why a measured value is known.
25#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
26#[serde(tag = "kind", rename_all = "snake_case")]
27pub enum MeasurementProvenance {
28    /// Exact bytes from a named serialization boundary.
29    ExactSerialized {
30        /// Serialization boundary name.
31        boundary: String,
32    },
33    /// Conservative local estimate.
34    Estimated {
35        /// Estimator name.
36        estimator: String,
37        /// Estimator version.
38        version: String,
39    },
40    /// A component returned by a provider.
41    ProviderReported {
42        /// Provider label.
43        provider: String,
44        /// Provider component name.
45        component: String,
46    },
47    /// A value derived from provider-reported components.
48    Derived {
49        /// Normalization rule name.
50        rule: String,
51        /// Normalization rule version.
52        version: String,
53    },
54    /// The provider did not report this component.
55    Unknown,
56}
57
58/// A byte measurement with its serialization boundary.
59#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
60pub struct ByteMeasurement {
61    /// Measured UTF-8 request bytes.
62    pub value: u64,
63    /// Boundary and serialization provenance.
64    pub provenance: MeasurementProvenance,
65}
66
67/// A token value which can remain unknown without conflating unknown and zero.
68#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
69pub struct TokenMeasurement {
70    /// Token count, or `None` when it was not available.
71    pub value: Option<u64>,
72    /// Estimator, provider component, or derivation provenance.
73    pub provenance: MeasurementProvenance,
74}
75
76impl TokenMeasurement {
77    /// Return an unknown token value.
78    pub const fn unknown() -> Self {
79        Self { value: None, provenance: MeasurementProvenance::Unknown }
80    }
81
82    /// Return a provider-reported component. Zero is intentionally retained.
83    pub fn provider(provider: &str, component: &str, value: Option<u64>) -> Self {
84        Self {
85            value,
86            provenance: if value.is_some() {
87                MeasurementProvenance::ProviderReported {
88                    provider: provider.to_string(),
89                    component: component.to_string(),
90                }
91            } else {
92                MeasurementProvenance::Unknown
93            },
94        }
95    }
96}
97
98/// Provider-specific usage components before normalization.
99#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
100pub struct ProviderUsageComponents {
101    /// Provider input/prompt tokens.
102    pub input_tokens: Option<u64>,
103    /// Provider output/completion tokens.
104    pub output_tokens: Option<u64>,
105    /// Input tokens read from a provider cache.
106    pub cache_read_input_tokens: Option<u64>,
107    /// Input tokens written to a provider cache.
108    pub cache_creation_input_tokens: Option<u64>,
109    /// Output reasoning tokens, when the provider exposes the breakdown.
110    pub reasoning_tokens: Option<u64>,
111}
112
113impl ProviderUsageComponents {
114    /// Build the common input/output portion of a usage snapshot.
115    pub const fn new(input_tokens: u64, output_tokens: u64) -> Self {
116        Self {
117            input_tokens: Some(input_tokens),
118            output_tokens: Some(output_tokens),
119            cache_read_input_tokens: None,
120            cache_creation_input_tokens: None,
121            reasoning_tokens: None,
122        }
123    }
124
125    /// Merge a provider snapshot without double-counting repeated stream updates.
126    pub fn merge_snapshot(&mut self, next: &Self) {
127        merge_max(&mut self.input_tokens, next.input_tokens);
128        merge_max(&mut self.output_tokens, next.output_tokens);
129        merge_max(&mut self.cache_read_input_tokens, next.cache_read_input_tokens);
130        merge_max(&mut self.cache_creation_input_tokens, next.cache_creation_input_tokens);
131        merge_max(&mut self.reasoning_tokens, next.reasoning_tokens);
132    }
133
134    /// Normalize components using the provider's documented accounting rule.
135    pub fn normalize(&self, provider: &str, rule: ProviderUsageRule) -> ProviderUsage {
136        let inclusive_input_tokens = match rule {
137            ProviderUsageRule::AnthropicMessages => self.input_tokens.map(|input| {
138                input
139                    .saturating_add(self.cache_read_input_tokens.unwrap_or(0))
140                    .saturating_add(self.cache_creation_input_tokens.unwrap_or(0))
141            }),
142            ProviderUsageRule::OpenAiChat | ProviderUsageRule::OpenAiResponses => self.input_tokens,
143        };
144        ProviderUsage {
145            provider: provider.to_string(),
146            rule,
147            components: self.clone(),
148            inclusive_input_tokens: TokenMeasurement {
149                value: inclusive_input_tokens,
150                provenance: if inclusive_input_tokens.is_some() {
151                    MeasurementProvenance::Derived {
152                        rule: rule.label().to_string(),
153                        version: USAGE_NORMALIZATION_VERSION.to_string(),
154                    }
155                } else {
156                    MeasurementProvenance::Unknown
157                },
158            },
159        }
160    }
161}
162
163fn merge_max(current: &mut Option<u64>, next: Option<u64>) {
164    if let Some(next) = next {
165        *current = Some(current.map_or(next, |current| current.max(next)));
166    }
167}
168
169/// Provider rule used to derive inclusive input.
170#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
171#[serde(rename_all = "snake_case")]
172pub enum ProviderUsageRule {
173    /// Anthropic input excludes cache-read and cache-creation components.
174    AnthropicMessages,
175    /// OpenAI-compatible prompt tokens already include cached input.
176    OpenAiChat,
177    /// Responses input tokens already include cached input.
178    OpenAiResponses,
179}
180
181impl ProviderUsageRule {
182    /// Stable rule label.
183    pub const fn label(self) -> &'static str {
184        match self {
185            Self::AnthropicMessages => "anthropic_input_plus_cache_components",
186            Self::OpenAiChat => "openai_prompt_tokens_inclusive",
187            Self::OpenAiResponses => "openai_responses_input_tokens_inclusive",
188        }
189    }
190}
191
192/// Normalized provider usage for one completed request.
193#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
194pub struct ProviderUsage {
195    /// Provider adapter label.
196    pub provider: String,
197    /// Rule used to derive the inclusive input total.
198    pub rule: ProviderUsageRule,
199    /// Raw components retained exactly when reported.
200    pub components: ProviderUsageComponents,
201    /// Derived inclusive input total.
202    pub inclusive_input_tokens: TokenMeasurement,
203}
204
205/// One bounded provider-neutral message in the model-facing request projection.
206#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
207pub struct ModelProjectionMessage {
208    /// Message role at the provider-neutral boundary.
209    pub role: String,
210    /// Bounded rendered content. Structured content is represented as JSON.
211    pub content: String,
212}
213
214/// One deterministic reduction receipt proposed for a request.
215#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
216pub struct ContextReductionReceipt {
217    /// Stable context item id.
218    pub item_id: String,
219    /// Reducer or selection method name.
220    pub method: String,
221    /// Reducer method version.
222    pub version: String,
223    /// Bytes before the proposed decision.
224    pub before_bytes: u64,
225    /// Bytes after the proposed decision.
226    pub after_bytes: u64,
227    /// Whether the proposed decision may remove information.
228    pub lossy: bool,
229}
230
231/// One context candidate captured at the final request boundary.
232#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
233pub struct ContextItemSnapshot {
234    /// Stable context item id.
235    pub id: String,
236    /// Stable handle for bounded redacted recovery, when available.
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub artifact_handle: Option<String>,
239    /// State at the request boundary.
240    pub state: ContextVisibility,
241    /// Stable policy reason code.
242    pub reason_code: String,
243    /// Redacted human-readable reason.
244    pub reason: String,
245}
246
247impl From<&ContextItem> for ContextItemSnapshot {
248    fn from(item: &ContextItem) -> Self {
249        Self {
250            id: item.id.clone(),
251            artifact_handle: item.artifact_handle.clone(),
252            state: item.visibility.clone(),
253            reason_code: item.reason_code.clone(),
254            reason: item.reason.clone(),
255        }
256    }
257}
258
259/// Build a one-pass snapshot of every candidate in a context ledger.
260pub fn snapshot_context(items: &[ContextItem]) -> Vec<ContextItemSnapshot> {
261    items.iter().map(ContextItemSnapshot::from).collect()
262}
263
264/// Accounting for one successful serialized provider request.
265#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
266pub struct ProviderRequestAccounting {
267    /// Session turn that owns this request.
268    pub turn_id: String,
269    /// Stable request identity across event delivery and session persistence.
270    pub request_id: String,
271    /// One-based retry attempt for this request identity.
272    pub attempt: u32,
273    /// Provider adapter label.
274    pub provider: String,
275    /// Selected model id.
276    pub model: String,
277    /// Exact serialized request body size.
278    pub serialized_bytes: ByteMeasurement,
279    /// Conservative input estimate from the same serialized bytes.
280    pub estimated_input_tokens: TokenMeasurement,
281    /// Provider usage, when the successful response reported it.
282    pub provider_usage: Option<ProviderUsage>,
283    /// All context candidates considered for this request, once each.
284    pub context: Vec<ContextItemSnapshot>,
285    /// Shadow reducer receipts for this request.
286    #[serde(default)]
287    pub shadow_receipts: Vec<ContextReductionReceipt>,
288    /// In-memory model projection for the selected request.
289    ///
290    /// This is not durable session data and is skipped during serialization;
291    /// the application may use it to build an explicit bounded export.
292    #[serde(skip)]
293    pub model_projection: Vec<ModelProjectionMessage>,
294}
295
296impl ProviderRequestAccounting {
297    /// Create accounting from the exact serialized request body.
298    pub fn from_serialized_request(
299        turn_id: impl Into<String>, request_id: impl Into<String>, attempt: u32, provider: &str, model: &str,
300        bytes: &[u8], context: Vec<ContextItemSnapshot>,
301    ) -> Self {
302        let byte_count = bytes.len() as u64;
303        Self {
304            turn_id: turn_id.into(),
305            request_id: request_id.into(),
306            attempt,
307            provider: provider.to_string(),
308            model: model.to_string(),
309            serialized_bytes: ByteMeasurement {
310                value: byte_count,
311                provenance: MeasurementProvenance::ExactSerialized { boundary: "provider_request_body".to_string() },
312            },
313            estimated_input_tokens: TokenMeasurement {
314                value: Some(estimate_serialized_tokens(byte_count)),
315                provenance: MeasurementProvenance::Estimated {
316                    estimator: "utf8_bytes_divisor_3_plus_item_overhead".to_string(),
317                    version: TOKEN_ESTIMATOR_VERSION.to_string(),
318                },
319            },
320            provider_usage: None,
321            context,
322            shadow_receipts: Vec::new(),
323            model_projection: Vec::new(),
324        }
325    }
326
327    /// Attach a bounded in-memory model projection without changing durable
328    /// accounting or provider request bytes.
329    pub fn with_model_projection(mut self, projection: Vec<ModelProjectionMessage>) -> Self {
330        let mut bytes = 0usize;
331        self.model_projection = projection
332            .into_iter()
333            .filter_map(|mut message| {
334                let remaining = MODEL_PROJECTION_MAX_BYTES.saturating_sub(bytes);
335                if remaining == 0 {
336                    return None;
337                }
338                message.content = truncate_utf8(&message.content, remaining);
339                bytes = bytes
340                    .saturating_add(message.role.len())
341                    .saturating_add(message.content.len());
342                Some(message)
343            })
344            .collect();
345        self
346    }
347}
348
349fn truncate_utf8(value: &str, max_bytes: usize) -> String {
350    if value.len() <= max_bytes {
351        return value.to_string();
352    }
353    let mut end = max_bytes;
354    while end > 0 && !value.is_char_boundary(end) {
355        end -= 1;
356    }
357    value[..end].to_string()
358}
359
360/// Estimate input tokens from serialized request bytes.
361pub const fn estimate_serialized_tokens(bytes: u64) -> u64 {
362    bytes.div_ceil(3).saturating_add(16)
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn provider_components_preserve_unknown_and_measured_zero() {
371        let components = ProviderUsageComponents {
372            input_tokens: Some(0),
373            output_tokens: None,
374            cache_read_input_tokens: None,
375            cache_creation_input_tokens: Some(0),
376            reasoning_tokens: None,
377        };
378        assert_eq!(components.input_tokens, Some(0));
379        assert_eq!(components.output_tokens, None);
380        assert_eq!(components.cache_creation_input_tokens, Some(0));
381    }
382
383    #[test]
384    fn repeated_stream_snapshots_are_not_added_twice() {
385        let mut total = ProviderUsageComponents::new(12, 3);
386        total.merge_snapshot(&ProviderUsageComponents::new(12, 3));
387        total.merge_snapshot(&ProviderUsageComponents::new(15, 4));
388        assert_eq!(total.input_tokens, Some(15));
389        assert_eq!(total.output_tokens, Some(4));
390    }
391
392    #[test]
393    fn provider_rules_normalize_anthropic_and_openai_inputs_differently() {
394        let components = ProviderUsageComponents {
395            input_tokens: Some(100),
396            output_tokens: Some(10),
397            cache_read_input_tokens: Some(20),
398            cache_creation_input_tokens: Some(5),
399            reasoning_tokens: Some(2),
400        };
401        assert_eq!(
402            components
403                .normalize("anthropic", ProviderUsageRule::AnthropicMessages)
404                .inclusive_input_tokens
405                .value,
406            Some(125)
407        );
408        assert_eq!(
409            components
410                .normalize("openai", ProviderUsageRule::OpenAiChat)
411                .inclusive_input_tokens
412                .value,
413            Some(100)
414        );
415    }
416
417    #[test]
418    fn request_accounting_records_exact_bytes_and_context_once() {
419        let item = ContextItem {
420            id: "item-1".to_string(),
421            kind: crate::context::ContextItemKind::Transcript,
422            label: "turn".to_string(),
423            source_path: None,
424            scope: ".".to_string(),
425            content_hash: None,
426            artifact_handle: None,
427            byte_count: 4,
428            content: None,
429            token_estimate: 18,
430            visibility: ContextVisibility::Visible,
431            reason_code: "recent_transcript".to_string(),
432            reason: "recent transcript entry".to_string(),
433        };
434        let context = snapshot_context(std::slice::from_ref(&item));
435        let accounting = ProviderRequestAccounting::from_serialized_request(
436            "turn_1",
437            "turn_1:request:0",
438            1,
439            "provider",
440            "model",
441            b"{}",
442            context,
443        );
444        assert_eq!(accounting.serialized_bytes.value, 2);
445        assert_eq!(accounting.estimated_input_tokens.value, Some(17));
446        assert_eq!(accounting.context.len(), 1);
447        assert_eq!(accounting.context[0].reason_code, "recent_transcript");
448    }
449}