dwara_core/ai/
batch.rs

1//! AI-04 (#194): Batch API support and half-price metering.
2//!
3//! OpenAI and Anthropic batch APIs are 50% cheaper than real-time
4//! endpoints. The gateway proxies batch creation/status/cancel as a
5//! passthrough (no translation), and when batch results are retrieved
6//! the JSONL output file is parsed line-by-line. Each line's `usage`
7//! object is metered at the batch rate (`batch_input_per_1k_micros` /
8//! `batch_output_per_1k_micros`, falling back to standard rates when
9//! batch-specific rates are not configured).
10//!
11//! The half-price cost computation already exists in
12//! `ai::cost::PricingTable::cost_micros(batch=true)`; this module
13//! provides the JSONL parsing and batch result metering.
14
15use serde::Deserialize;
16use serde_json::Value;
17
18use crate::ai::cost::PricingTable;
19use crate::ai::types::Usage;
20
21/// One line of a batch result JSONL file. The OpenAI Batch API result
22/// format has this shape per line:
23/// ```json
24/// {"custom_id":"request-1","response":{"id":"...","model":"gpt-4o-mini",
25///  "choices":[...],"usage":{"prompt_tokens":10,"completion_tokens":5,
26///  "total_tokens":15}},"error":null}
27/// ```
28#[derive(Debug, Deserialize)]
29pub struct BatchResultLine {
30    /// The client-provided custom_id for this request.
31    pub custom_id: String,
32    /// The response object (when successful).
33    pub response: Option<BatchResponse>,
34    /// The error object (when failed).
35    pub error: Option<Value>,
36}
37
38/// The `response` field of a batch result line.
39#[derive(Debug, Deserialize)]
40pub struct BatchResponse {
41    /// The model that served the request (provider model name).
42    pub model: Option<String>,
43    /// Token usage for this request.
44    pub usage: Option<BatchUsage>,
45}
46
47/// The `usage` field of a batch response.
48#[derive(Debug, Deserialize)]
49pub struct BatchUsage {
50    pub prompt_tokens: Option<u64>,
51    pub completion_tokens: Option<u64>,
52    pub total_tokens: Option<u64>,
53    /// OpenAI reports cached tokens here.
54    pub prompt_tokens_details: Option<BatchPromptTokenDetails>,
55    /// Anthropic reports cached tokens here.
56    pub cache_read_input_tokens: Option<u64>,
57}
58
59/// The `prompt_tokens_details` sub-object.
60#[derive(Debug, Deserialize)]
61pub struct BatchPromptTokenDetails {
62    pub cached_tokens: Option<u64>,
63}
64
65impl BatchUsage {
66    /// Convert to the canonical `Usage` type.
67    pub fn to_usage(&self) -> Usage {
68        let cached = self
69            .prompt_tokens_details
70            .as_ref()
71            .and_then(|d| d.cached_tokens)
72            .or(self.cache_read_input_tokens);
73        Usage {
74            prompt_tokens: self.prompt_tokens,
75            completion_tokens: self.completion_tokens,
76            total_tokens: self.total_tokens,
77            cached_tokens: cached,
78        }
79    }
80}
81
82/// Summary of a batch result file's metering.
83#[derive(Debug, Clone, Default)]
84pub struct BatchMeteringSummary {
85    /// Number of lines parsed.
86    pub lines: u64,
87    /// Number of lines with a successful response (has usage).
88    pub successful: u64,
89    /// Number of lines with an error.
90    pub errors: u64,
91    /// Total cost in micro-USD across all lines (batch rate).
92    pub total_cost_micros: u64,
93    /// Total prompt tokens across all lines.
94    pub total_prompt_tokens: u64,
95    /// Total completion tokens across all lines.
96    pub total_completion_tokens: u64,
97}
98
99/// Parse a batch result JSONL file and compute the total cost at the
100/// batch (50% discount) rate. Each line is parsed independently; a
101/// malformed line is counted as an error and skipped.
102///
103/// `pricing` is the gateway's compiled pricing table. `default_model`
104/// is used when a response line does not include a model field.
105pub fn meter_batch_results(
106    jsonl: &str,
107    pricing: &PricingTable,
108    default_model: &str,
109) -> BatchMeteringSummary {
110    let mut summary = BatchMeteringSummary::default();
111    for line in jsonl.lines() {
112        let line = line.trim();
113        if line.is_empty() {
114            continue;
115        }
116        summary.lines += 1;
117        let parsed: Result<BatchResultLine, _> = serde_json::from_str(line);
118        match parsed {
119            Ok(result) => {
120                if result.error.is_some() {
121                    summary.errors += 1;
122                    continue;
123                }
124                if let Some(resp) = &result.response {
125                    if let Some(usage) = &resp.usage {
126                        let model = resp.model.as_deref().unwrap_or(default_model);
127                        let canonical = usage.to_usage();
128                        let cost = pricing.cost_micros(model, canonical, true).unwrap_or(0);
129                        summary.total_cost_micros += cost;
130                        summary.total_prompt_tokens += canonical.prompt_tokens.unwrap_or(0);
131                        summary.total_completion_tokens += canonical.completion_tokens.unwrap_or(0);
132                        summary.successful += 1;
133                    } else {
134                        // Response present but no usage — count as success
135                        // but no metering.
136                        summary.successful += 1;
137                    }
138                } else {
139                    summary.errors += 1;
140                }
141            }
142            Err(_) => {
143                summary.errors += 1;
144            }
145        }
146    }
147    summary
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn parse_batch_result_line() {
156        let json = r#"{"custom_id":"req-1","response":{"id":"chatcmpl-x","model":"gpt-4o-mini","usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}},"error":null}"#;
157        let line: BatchResultLine = serde_json::from_str(json).unwrap();
158        assert_eq!(line.custom_id, "req-1");
159        assert!(line.error.is_none());
160        let resp = line.response.unwrap();
161        assert_eq!(resp.model.as_deref(), Some("gpt-4o-mini"));
162        let usage = resp.usage.unwrap();
163        assert_eq!(usage.prompt_tokens, Some(10));
164        assert_eq!(usage.completion_tokens, Some(5));
165    }
166
167    #[test]
168    fn parse_batch_result_with_cached_tokens() {
169        let json = r#"{"custom_id":"req-2","response":{"model":"gpt-4o","usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150,"prompt_tokens_details":{"cached_tokens":80}}},"error":null}"#;
170        let line: BatchResultLine = serde_json::from_str(json).unwrap();
171        let usage = line.response.unwrap().usage.unwrap();
172        let canonical = usage.to_usage();
173        assert_eq!(canonical.prompt_tokens, Some(100));
174        assert_eq!(canonical.cached_tokens, Some(80));
175    }
176
177    #[test]
178    fn parse_batch_result_error_line() {
179        let json = r#"{"custom_id":"req-3","response":null,"error":{"message":"rate limited"}}"#;
180        let line: BatchResultLine = serde_json::from_str(json).unwrap();
181        assert!(line.response.is_none());
182        assert!(line.error.is_some());
183    }
184
185    #[test]
186    fn meter_batch_results_counts_lines() {
187        let jsonl = "\
188{\"custom_id\":\"req-1\",\"response\":{\"model\":\"gpt-4o-mini\",\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}},\"error\":null}\n\
189{\"custom_id\":\"req-2\",\"response\":null,\"error\":{\"message\":\"failed\"}}\n\
190{\"custom_id\":\"req-3\",\"response\":{\"model\":\"gpt-4o-mini\",\"usage\":{\"prompt_tokens\":20,\"completion_tokens\":10,\"total_tokens\":30}},\"error\":null}";
191        // Use an empty pricing table — cost will be 0 (unknown model),
192        // but line counting should still work.
193        let pricing = PricingTable::compile(None);
194        let summary = meter_batch_results(jsonl, &pricing, "gpt-4o-mini");
195        assert_eq!(summary.lines, 3);
196        assert_eq!(summary.successful, 2);
197        assert_eq!(summary.errors, 1);
198        assert_eq!(summary.total_prompt_tokens, 30);
199        assert_eq!(summary.total_completion_tokens, 15);
200    }
201}