Skip to main content

deepstrike_sdk/providers/
request_plan.rs

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/// Provider-visible endpoint identity. Credentials and retry transport state are intentionally
9/// excluded from this host-side request contract.
10#[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/// One material provider request. The fingerprint binds a preflight measurement to exactly the
34/// model, endpoint, context, tools, and material request options that will be executed.
35#[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}