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