1use af_context::{MeteringCorrectionId, RunId, SubjectId};
3use serde::{Deserialize, Serialize};
4
5use crate::{EventError, MeteringDetails, MeteringSource, SessionProjection};
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(deny_unknown_fields)]
10pub struct OperationUsage {
11 pub prompt_tokens: u64,
13 pub completion_tokens: u64,
15 pub cost_units: u64,
17 pub metering: Option<MeteringDetails>,
19 pub revision: u64,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct RunOperationUsage {
26 pub operation_id: String,
28 pub usage: OperationUsage,
30 pub pending: bool,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct MeteringCorrection {
38 pub id: MeteringCorrectionId,
40 pub run_id: RunId,
42 pub operation: String,
44 pub expected_revision: u64,
46 pub prompt_tokens: u64,
48 pub completion_tokens: u64,
50 pub cost_units: u64,
52 pub metering: MeteringDetails,
54 pub reason: String,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct AppliedMeteringCorrection {
61 pub correction: MeteringCorrection,
63 pub actor_id: SubjectId,
65 pub seq: u64,
67}
68
69impl MeteringCorrection {
70 pub fn validate(&self) -> Result<(), EventError> {
72 self.metering.validate()?;
73 let valid_text = |value: &str| {
74 !value.trim().is_empty() && value.len() <= 512 && !value.chars().any(char::is_control)
75 };
76 if !valid_text(self.id.as_str())
77 || !valid_text(&self.operation)
78 || !valid_text(&self.reason)
79 || self.expected_revision == u64::MAX
80 || self
81 .prompt_tokens
82 .checked_add(self.completion_tokens)
83 .and_then(|v| v.checked_add(self.cost_units))
84 .is_none_or(|v| v > i64::MAX as u64)
85 || !matches!(
86 self.metering,
87 MeteringDetails::Model {
88 source: MeteringSource::Reported,
89 ..
90 } | MeteringDetails::Tool {
91 source: MeteringSource::Reported,
92 ..
93 }
94 )
95 {
96 return Err(EventError::InvalidMetering);
97 }
98 Ok(())
99 }
100}
101
102impl SessionProjection {
103 pub fn run_usage_operations(&self, run_id: &RunId) -> Vec<RunOperationUsage> {
108 let mut result: std::collections::BTreeMap<String, RunOperationUsage> = self
109 .pending_usage_operations
110 .iter()
111 .filter(|((id, _), _)| id == run_id)
112 .map(|((_, operation), (prompt, completion, cost, metering))| {
113 (
114 operation.clone(),
115 RunOperationUsage {
116 operation_id: operation.clone(),
117 usage: OperationUsage {
118 prompt_tokens: *prompt,
119 completion_tokens: *completion,
120 cost_units: *cost,
121 metering: metering.clone(),
122 revision: 0,
123 },
124 pending: true,
125 },
126 )
127 })
128 .collect();
129 for ((id, operation), (prompt, completion, cost, metering)) in &self.usage_operations {
130 if id == run_id {
131 result.insert(
132 operation.clone(),
133 RunOperationUsage {
134 operation_id: operation.clone(),
135 usage: OperationUsage {
136 prompt_tokens: *prompt,
137 completion_tokens: *completion,
138 cost_units: *cost,
139 metering: metering.clone(),
140 revision: 0,
141 },
142 pending: false,
143 },
144 );
145 }
146 }
147 result.into_values().collect()
148 }
149
150 pub fn operation_usage(&self, run_id: &RunId, operation: &str) -> Option<OperationUsage> {
152 let key = (run_id.clone(), operation.to_owned());
153 if let Some(value) = self.corrected_usage.get(&key) {
154 return Some(value.clone());
155 }
156 if let Some((prompt_tokens, completion_tokens, cost_units, metering)) =
157 self.usage_operations.get(&key)
158 {
159 return Some(OperationUsage {
160 prompt_tokens: *prompt_tokens,
161 completion_tokens: *completion_tokens,
162 cost_units: *cost_units,
163 metering: metering.clone(),
164 revision: 0,
165 });
166 }
167 self.pending_usage_operations.get(&key).map(
168 |(prompt_tokens, completion_tokens, cost_units, metering)| OperationUsage {
169 prompt_tokens: *prompt_tokens,
170 completion_tokens: *completion_tokens,
171 cost_units: *cost_units,
172 metering: metering.clone(),
173 revision: 0,
174 },
175 )
176 }
177
178 pub fn metering_correction(
180 &self,
181 id: &MeteringCorrectionId,
182 ) -> Option<&AppliedMeteringCorrection> {
183 self.metering_corrections.get(id)
184 }
185
186 pub(super) fn apply_metering_correction(
187 &mut self,
188 correction: &MeteringCorrection,
189 actor_id: &SubjectId,
190 seq: u64,
191 ) -> Result<(), EventError> {
192 correction.validate()?;
193 if let Some(applied) = self.metering_corrections.get(&correction.id) {
194 return if applied.correction == *correction && applied.actor_id == *actor_id {
195 Ok(())
196 } else {
197 Err(EventError::UsageConflict(correction.operation.clone()))
198 };
199 }
200 let previous = self
201 .operation_usage(&correction.run_id, &correction.operation)
202 .ok_or_else(|| EventError::UsageConflict(correction.operation.clone()))?;
203 if !self
204 .run_status
205 .get(&correction.run_id)
206 .is_some_and(|status| status.is_terminal())
207 || previous.revision != correction.expected_revision
208 || !previous
209 .metering
210 .as_ref()
211 .is_some_and(|details| correction.metering.completes(details))
212 {
213 return Err(EventError::UsageConflict(correction.operation.clone()));
214 }
215 self.corrected_usage.insert(
216 (correction.run_id.clone(), correction.operation.clone()),
217 OperationUsage {
218 prompt_tokens: correction.prompt_tokens,
219 completion_tokens: correction.completion_tokens,
220 cost_units: correction.cost_units,
221 metering: Some(correction.metering.clone()),
222 revision: previous.revision + 1,
223 },
224 );
225 self.metering_corrections.insert(
226 correction.id.clone(),
227 AppliedMeteringCorrection {
228 correction: correction.clone(),
229 actor_id: actor_id.clone(),
230 seq,
231 },
232 );
233 Ok(())
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use crate::{MeteringOutcome, RunState, RunStatus};
241
242 #[test]
243 fn original_evidence_keeps_mixed_models_pending_tools_and_excludes_corrections() {
244 let mut projection = SessionProjection::default();
245 let run: RunId = "run".parse().unwrap();
246 for (operation, model) in [("model-a", "model-one"), ("model-b", "model-two")] {
247 projection.usage_operations.insert(
248 (run.clone(), operation.into()),
249 (
250 3,
251 2,
252 0,
253 Some(MeteringDetails::Model {
254 model: model.into(),
255 provider: Some("provider".into()),
256 provider_attempt_id: operation.parse().unwrap(),
257 source: MeteringSource::Reported,
258 outcome: MeteringOutcome::Completed,
259 }),
260 ),
261 );
262 }
263 projection.pending_usage_operations.insert(
264 (run.clone(), "tool".into()),
265 (
266 0,
267 0,
268 7,
269 Some(MeteringDetails::Tool {
270 call_id: "call".parse().unwrap(),
271 name: "lookup".into(),
272 source: MeteringSource::Estimated,
273 outcome: MeteringOutcome::Unknown,
274 }),
275 ),
276 );
277 projection.usage_operations.insert(
278 ("another-run".parse().unwrap(), "model-a".into()),
279 (99, 0, 0, None),
280 );
281 let original = projection.run_usage_operations(&run);
282 assert_eq!(original.len(), 3);
283 assert!(!original[0].pending);
284 assert!(original[2].pending);
285 assert_ne!(original[0].usage.metering, original[1].usage.metering);
286 assert_eq!(
287 original
288 .iter()
289 .map(|fact| fact.usage.prompt_tokens
290 + fact.usage.completion_tokens
291 + fact.usage.cost_units)
292 .sum::<u64>(),
293 projection.billable_units_for("run")
294 );
295 projection.corrected_usage.insert(
296 (run.clone(), "model-a".into()),
297 OperationUsage {
298 prompt_tokens: 1,
299 completion_tokens: 1,
300 cost_units: 0,
301 metering: None,
302 revision: 1,
303 },
304 );
305 assert_eq!(projection.run_usage_operations(&run), original);
306 assert_eq!(projection.clone().run_usage_operations(&run), original);
307 assert!(projection
308 .run_usage_operations(&"missing".parse().unwrap())
309 .is_empty());
310 }
311
312 fn report() -> MeteringCorrection {
313 MeteringCorrection {
314 id: "correction".parse().unwrap(),
315 run_id: "run".parse().unwrap(),
316 operation: "operation".into(),
317 expected_revision: 0,
318 prompt_tokens: 1,
319 completion_tokens: 2,
320 cost_units: 0,
321 metering: MeteringDetails::Model {
322 model: "m".into(),
323 provider: Some("p".into()),
324 provider_attempt_id: "attempt".parse().unwrap(),
325 source: MeteringSource::Reported,
326 outcome: MeteringOutcome::Completed,
327 },
328 reason: "provider report".into(),
329 }
330 }
331
332 #[test]
333 fn invalid_reports_and_mismatched_identities_fail_closed() {
334 let good = report();
335 good.validate().unwrap();
336 for mutation in 0..7 {
337 let mut invalid = good.clone();
338 match mutation {
339 0 => invalid.reason.clear(),
340 1 => invalid.operation = "a".repeat(513),
341 2 => invalid.prompt_tokens = u64::MAX,
342 3 => invalid.expected_revision = u64::MAX,
343 4 => invalid.id = "bad\nid".parse().unwrap(),
344 5 => {
345 if let MeteringDetails::Model { source, .. } = &mut invalid.metering {
346 *source = MeteringSource::Estimated;
347 }
348 }
349 _ => {
350 if let MeteringDetails::Model { model, .. } = &mut invalid.metering {
351 model.clear();
352 }
353 }
354 }
355 assert!(invalid.validate().is_err());
356 }
357 let mut projection = SessionProjection::default();
358 let actor = "admin".parse().unwrap();
359 assert!(projection
360 .apply_metering_correction(&good, &actor, 10)
361 .is_err());
362 projection
363 .run_status
364 .insert(good.run_id.clone(), RunState::Terminal(RunStatus::Failed));
365 let mut pending = good.metering.clone();
366 if let MeteringDetails::Model {
367 source, outcome, ..
368 } = &mut pending
369 {
370 *source = MeteringSource::Estimated;
371 *outcome = MeteringOutcome::Unknown;
372 }
373 projection.pending_usage_operations.insert(
374 (good.run_id.clone(), good.operation.clone()),
375 (100, 20, 0, Some(pending)),
376 );
377 assert_eq!(
378 projection
379 .operation_usage(&good.run_id, &good.operation)
380 .unwrap()
381 .prompt_tokens,
382 100
383 );
384 let mut wrong = good.clone();
385 if let MeteringDetails::Model { provider, .. } = &mut wrong.metering {
386 *provider = Some("wrong".into());
387 }
388 assert!(projection
389 .apply_metering_correction(&wrong, &actor, 10)
390 .is_err());
391 projection
392 .apply_metering_correction(&good, &actor, 10)
393 .unwrap();
394 projection
395 .apply_metering_correction(&good, &actor, 11)
396 .unwrap();
397 assert_eq!(projection.metering_correction(&good.id).unwrap().seq, 10);
398 assert!(projection
399 .apply_metering_correction(&good, &"other".parse().unwrap(), 12)
400 .is_err());
401 assert_eq!(
402 projection
403 .operation_usage(&good.run_id, &good.operation)
404 .unwrap()
405 .prompt_tokens,
406 1
407 );
408 assert_eq!(projection.billable_units_for("run"), 120);
409 assert!(projection
410 .operation_usage(&good.run_id, "missing")
411 .is_none());
412 }
413}