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    #[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    /// Bind frozen provider material and continuation state to this request's identity.
90    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}