1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use sha2::{Digest, Sha256};
4use time::OffsetDateTime;
5
6use deepstrike_core::context::measurement::{MeasurementConfidence, MeasurementSource};
7
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase", deny_unknown_fields)]
12pub struct ProviderRequestEndpoint {
13 pub id: String,
14 pub protocol: String,
15 #[serde(rename = "baseURL")]
16 pub base_url: String,
17}
18
19impl ProviderRequestEndpoint {
20 pub fn new(
21 id: impl Into<String>,
22 protocol: impl Into<String>,
23 base_url: impl Into<String>,
24 ) -> Self {
25 Self {
26 id: id.into(),
27 protocol: protocol.into(),
28 base_url: base_url.into(),
29 }
30 }
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36#[serde(rename_all = "camelCase", deny_unknown_fields)]
37pub struct ProviderRequestPlan {
38 pub provider_id: String,
39 pub model_id: String,
40 pub endpoint: ProviderRequestEndpoint,
41 pub context: Value,
42 pub tools: Vec<Value>,
43 pub options: Value,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub execution: Option<Value>,
46 pub fingerprint: String,
47}
48
49impl ProviderRequestPlan {
50 pub fn new(
51 provider_id: impl Into<String>,
52 model_id: impl Into<String>,
53 endpoint: ProviderRequestEndpoint,
54 context: Value,
55 tools: Vec<Value>,
56 options: Value,
57 ) -> Result<Self, RequestPlanError> {
58 let provider_id = provider_id.into();
59 let model_id = model_id.into();
60 let options = material_options(options)?;
61 let hashed = serde_json::json!({
62 "providerId": provider_id,
63 "modelId": model_id,
64 "endpoint": {
65 "id": endpoint.id,
66 "protocol": endpoint.protocol,
67 "baseURL": endpoint.base_url,
68 },
69 "context": context,
70 "tools": tools,
71 "options": options,
72 });
73 let fingerprint = format!(
74 "sha256:{:x}",
75 Sha256::digest(canonical_json(&hashed).as_bytes())
76 );
77 Ok(Self {
78 provider_id,
79 model_id,
80 endpoint,
81 context: hashed["context"].clone(),
82 tools: hashed["tools"].as_array().cloned().unwrap_or_default(),
83 options: hashed["options"].clone(),
84 execution: None,
85 fingerprint,
86 })
87 }
88
89 pub fn with_execution(mut self, execution: Value) -> Self {
91 let material = serde_json::json!({
92 "providerId": self.provider_id, "modelId": self.model_id,
93 "endpoint": self.endpoint, "context": self.context, "tools": self.tools,
94 "options": self.options, "execution": execution,
95 });
96 self.fingerprint = format!(
97 "sha256:{:x}",
98 Sha256::digest(canonical_json(&material).as_bytes())
99 );
100 self.execution = Some(execution);
101 self
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
106#[serde(rename_all = "camelCase", deny_unknown_fields)]
107pub struct RecordedPromptMeasurement {
108 pub request_fingerprint: String,
109 pub input_tokens: u64,
110 pub source: MeasurementSource,
111 pub confidence: MeasurementConfidence,
112}
113
114pub type PromptMeasurementSource = MeasurementSource;
115
116pub fn record_prompt_measurement(
117 plan: &ProviderRequestPlan,
118 input_tokens: u64,
119 source: MeasurementSource,
120 confidence: MeasurementConfidence,
121) -> RecordedPromptMeasurement {
122 RecordedPromptMeasurement {
123 request_fingerprint: plan.fingerprint.clone(),
124 input_tokens,
125 source,
126 confidence,
127 }
128}
129
130pub fn measurement_for_plan(
131 plan: &ProviderRequestPlan,
132 measurement: &RecordedPromptMeasurement,
133) -> Option<RecordedPromptMeasurement> {
134 (measurement.request_fingerprint == plan.fingerprint).then(|| measurement.clone())
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Default)]
138pub struct ProviderUsage {
139 pub input_tokens: u64,
140 pub output_tokens: u64,
141 pub cache_read_input_tokens: u64,
142 pub cache_creation_input_tokens: u64,
143 pub reasoning_tokens: Option<u64>,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct NormalizedProviderUsage {
148 pub input_tokens: u64,
149 pub uncached_input_tokens: u64,
150 pub output_tokens: u64,
151 pub cache_read_input_tokens: u64,
152 pub cache_creation_input_tokens: u64,
153 pub reasoning_tokens: Option<u64>,
154}
155
156pub fn normalize_provider_usage(
157 usage: &ProviderUsage,
158) -> Result<NormalizedProviderUsage, RequestPlanError> {
159 let cached = usage
160 .cache_read_input_tokens
161 .checked_add(usage.cache_creation_input_tokens)
162 .ok_or(RequestPlanError::InvalidUsage)?;
163 if cached > usage.input_tokens
164 || usage
165 .reasoning_tokens
166 .is_some_and(|tokens| tokens > usage.output_tokens)
167 {
168 return Err(RequestPlanError::InvalidUsage);
169 }
170 Ok(NormalizedProviderUsage {
171 input_tokens: usage.input_tokens,
172 uncached_input_tokens: usage.input_tokens - cached,
173 output_tokens: usage.output_tokens,
174 cache_read_input_tokens: usage.cache_read_input_tokens,
175 cache_creation_input_tokens: usage.cache_creation_input_tokens,
176 reasoning_tokens: usage.reasoning_tokens,
177 })
178}
179
180#[derive(Debug, Clone, PartialEq)]
181pub struct PricingRates {
182 pub input: f64,
183 pub output: f64,
184 pub cache_read: Option<f64>,
185 pub cache_creation: Option<f64>,
186 pub reasoning: Option<f64>,
187}
188
189#[derive(Debug, Clone, PartialEq)]
190pub struct PricingSnapshot {
191 pub version: String,
192 pub currency: String,
193 pub region: String,
194 pub effective_from: String,
195 pub expires_at: Option<String>,
196 pub rates_per_million: PricingRates,
197}
198
199impl PricingSnapshot {
200 pub fn new(
201 version: impl Into<String>,
202 currency: impl Into<String>,
203 region: impl Into<String>,
204 effective_from: impl Into<String>,
205 expires_at: Option<String>,
206 rates_per_million: PricingRates,
207 ) -> Self {
208 Self {
209 version: version.into(),
210 currency: currency.into(),
211 region: region.into(),
212 effective_from: effective_from.into(),
213 expires_at,
214 rates_per_million,
215 }
216 }
217}
218
219#[derive(Debug, Clone, PartialEq)]
220pub enum CostObservation {
221 Snapshot {
222 currency: String,
223 amount: f64,
224 pricing_version: String,
225 },
226 Unpriced {
227 reason: UnpricedReason,
228 },
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum UnpricedReason {
233 SnapshotNotEffective,
234 SnapshotExpired,
235 InvalidPricingSnapshot,
236}
237
238pub fn price_provider_usage(
239 usage: &NormalizedProviderUsage,
240 snapshot: &PricingSnapshot,
241 observed_at: &str,
242) -> CostObservation {
243 let valid_rates = [
244 Some(snapshot.rates_per_million.input),
245 Some(snapshot.rates_per_million.output),
246 snapshot.rates_per_million.cache_read,
247 snapshot.rates_per_million.cache_creation,
248 snapshot.rates_per_million.reasoning,
249 ]
250 .into_iter()
251 .flatten()
252 .all(|rate| rate.is_finite() && rate >= 0.0);
253 let observed_at = parse_rfc3339(observed_at);
254 let effective_from = parse_rfc3339(&snapshot.effective_from);
255 let expires_at = snapshot
256 .expires_at
257 .as_deref()
258 .map(parse_rfc3339)
259 .transpose();
260 if snapshot.version.is_empty()
261 || snapshot.currency.is_empty()
262 || !valid_rates
263 || observed_at.is_err()
264 || effective_from.is_err()
265 || expires_at.is_err()
266 {
267 return CostObservation::Unpriced {
268 reason: UnpricedReason::InvalidPricingSnapshot,
269 };
270 }
271 let observed_at = observed_at.expect("checked above");
272 let effective_from = effective_from.expect("checked above");
273 if observed_at < effective_from {
274 return CostObservation::Unpriced {
275 reason: UnpricedReason::SnapshotNotEffective,
276 };
277 }
278 if expires_at
279 .expect("checked above")
280 .is_some_and(|expires| observed_at >= expires)
281 {
282 return CostObservation::Unpriced {
283 reason: UnpricedReason::SnapshotExpired,
284 };
285 }
286 let rates = &snapshot.rates_per_million;
287 let amount = (usage.uncached_input_tokens as f64 * rates.input
288 + usage.output_tokens as f64 * rates.output
289 + usage.cache_read_input_tokens as f64 * rates.cache_read.unwrap_or(rates.input)
290 + usage.cache_creation_input_tokens as f64 * rates.cache_creation.unwrap_or(rates.input)
291 + usage.reasoning_tokens.unwrap_or_default() as f64 * rates.reasoning.unwrap_or(0.0))
292 / 1_000_000.0;
293 CostObservation::Snapshot {
294 currency: snapshot.currency.clone(),
295 amount,
296 pricing_version: snapshot.version.clone(),
297 }
298}
299
300fn parse_rfc3339(value: &str) -> Result<OffsetDateTime, time::error::Parse> {
301 OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339)
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
305pub enum RequestPlanError {
306 #[error("request plan options must be a JSON object")]
307 OptionsMustBeObject,
308 #[error("provider usage has inconsistent token subsets")]
309 InvalidUsage,
310}
311
312fn material_options(options: Value) -> Result<Value, RequestPlanError> {
313 let Value::Object(object) = options else {
314 return Err(RequestPlanError::OptionsMustBeObject);
315 };
316 Ok(sanitize_value(Value::Object(object)).unwrap_or(Value::Object(Default::default())))
317}
318
319fn sanitize_value(value: Value) -> Option<Value> {
320 match value {
321 Value::Array(values) => Some(Value::Array(
322 values.into_iter().filter_map(sanitize_value).collect(),
323 )),
324 Value::Object(values) => Some(Value::Object(
325 values
326 .into_iter()
327 .filter_map(|(key, value)| {
328 (!transport_only_key(&key))
329 .then(|| sanitize_value(value).map(|value| (key, value)))
330 .flatten()
331 })
332 .collect(),
333 )),
334 value => Some(value),
335 }
336}
337
338fn transport_only_key(key: &str) -> bool {
339 let normalized: String = key
340 .chars()
341 .filter(|character| character.is_ascii_alphanumeric())
342 .flat_map(|character| character.to_lowercase())
343 .collect();
344 matches!(
345 normalized.as_str(),
346 "retry" | "maxretries" | "basedelay" | "timeout" | "signal"
347 ) || normalized.contains("authorization")
348 || normalized.contains("credential")
349 || normalized.contains("accesstoken")
350 || normalized.contains("refreshtoken")
351 || normalized.contains("apikey")
352 || matches!(
353 normalized.as_str(),
354 "bearer" | "token" | "secret" | "xapikey"
355 )
356}
357
358fn canonical_json(value: &Value) -> String {
359 match value {
360 Value::Null => "null".into(),
361 Value::Bool(value) => value.to_string(),
362 Value::Number(value) => value.to_string(),
363 Value::String(value) => serde_json::to_string(value).expect("strings serialize"),
364 Value::Array(values) => format!(
365 "[{}]",
366 values
367 .iter()
368 .map(canonical_json)
369 .collect::<Vec<_>>()
370 .join(",")
371 ),
372 Value::Object(values) => format!(
373 "{{{}}}",
374 values
375 .iter()
376 .map(|(key, value)| format!(
377 "{}:{}",
378 serde_json::to_string(key).expect("keys serialize"),
379 canonical_json(value)
380 ))
381 .collect::<Vec<_>>()
382 .join(","),
383 ),
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 #[test]
392 fn uses_the_shared_cross_sdk_sha256_fixture() {
393 let fixture: serde_json::Value = serde_json::from_str(include_str!(
394 "../../../tests/fixtures/provider-request-plan/canonical.json"
395 ))
396 .expect("fixture is valid JSON");
397 let input = fixture.get("input").expect("fixture input");
398 let endpoint = input.get("endpoint").expect("fixture endpoint");
399 let plan = ProviderRequestPlan::new(
400 input["providerId"].as_str().unwrap(),
401 input["modelId"].as_str().unwrap(),
402 ProviderRequestEndpoint::new(
403 endpoint["id"].as_str().unwrap(),
404 endpoint["protocol"].as_str().unwrap(),
405 endpoint["baseURL"].as_str().unwrap(),
406 ),
407 input["context"].clone(),
408 input["tools"].as_array().unwrap().clone(),
409 input["options"].clone(),
410 )
411 .expect("valid request plan");
412
413 assert_eq!(plan.fingerprint, fixture["fingerprint"].as_str().unwrap());
414 assert_eq!(
415 plan.options,
416 serde_json::json!({"auth": {"mode": "request"}, "temperature": 0.2, "transport": {}})
417 );
418 assert!(
419 !serde_json::to_string(&plan)
420 .unwrap()
421 .contains("must-not-hash")
422 );
423 }
424
425 #[test]
426 fn records_measurements_and_prices_only_valid_snapshots() {
427 let plan = ProviderRequestPlan::new(
428 "openai",
429 "gpt-4o",
430 ProviderRequestEndpoint::new("openai.chat", "openai-chat", "https://api.openai.com/v1"),
431 serde_json::json!({"systemText": "s", "turns": []}),
432 vec![],
433 serde_json::json!({}),
434 )
435 .unwrap();
436 let measurement = record_prompt_measurement(
437 &plan,
438 42,
439 PromptMeasurementSource::Native {
440 provider: "openai".into(),
441 },
442 MeasurementConfidence::Exact,
443 );
444 assert_eq!(measurement_for_plan(&plan, &measurement), Some(measurement));
445
446 let usage = normalize_provider_usage(&ProviderUsage {
447 input_tokens: 120,
448 output_tokens: 30,
449 cache_read_input_tokens: 20,
450 cache_creation_input_tokens: 10,
451 reasoning_tokens: Some(6),
452 })
453 .unwrap();
454 assert_eq!(usage.uncached_input_tokens, 90);
455 assert_eq!(
456 price_provider_usage(
457 &usage,
458 &PricingSnapshot::new(
459 "pricing-2026-08",
460 "USD",
461 "global",
462 "2026-08-01T00:00:00Z",
463 Some("2026-09-01T00:00:00Z".into()),
464 PricingRates {
465 input: 2.0,
466 output: 8.0,
467 cache_read: Some(0.2),
468 cache_creation: Some(2.5),
469 reasoning: None,
470 },
471 ),
472 "2026-08-13T00:00:00Z",
473 ),
474 CostObservation::Snapshot {
475 currency: "USD".into(),
476 amount: 0.000449,
477 pricing_version: "pricing-2026-08".into()
478 },
479 );
480 }
481
482 #[test]
483 fn redacts_nested_case_and_separator_variant_credentials() {
484 let plan = ProviderRequestPlan::new(
485 "openai",
486 "gpt-4o",
487 ProviderRequestEndpoint::new("openai.chat", "openai-chat", "https://api.openai.com/v1"),
488 serde_json::json!({"turns": []}),
489 vec![],
490 serde_json::json!({"headers": {"Authorization": "secret", "x-api-key": "secret"}, "access_token": "secret", "temperature": 0.2}),
491 )
492 .unwrap();
493 assert_eq!(
494 plan.options,
495 serde_json::json!({"temperature": 0.2, "headers": {}})
496 );
497 assert!(!serde_json::to_string(&plan).unwrap().contains("secret"));
498 }
499}