1use serde::Deserialize;
16use serde_json::Value;
17
18use crate::ai::cost::PricingTable;
19use crate::ai::types::Usage;
20
21#[derive(Debug, Deserialize)]
29pub struct BatchResultLine {
30 pub custom_id: String,
32 pub response: Option<BatchResponse>,
34 pub error: Option<Value>,
36}
37
38#[derive(Debug, Deserialize)]
40pub struct BatchResponse {
41 pub model: Option<String>,
43 pub usage: Option<BatchUsage>,
45}
46
47#[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 pub prompt_tokens_details: Option<BatchPromptTokenDetails>,
55 pub cache_read_input_tokens: Option<u64>,
57}
58
59#[derive(Debug, Deserialize)]
61pub struct BatchPromptTokenDetails {
62 pub cached_tokens: Option<u64>,
63}
64
65impl BatchUsage {
66 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#[derive(Debug, Clone, Default)]
84pub struct BatchMeteringSummary {
85 pub lines: u64,
87 pub successful: u64,
89 pub errors: u64,
91 pub total_cost_micros: u64,
93 pub total_prompt_tokens: u64,
95 pub total_completion_tokens: u64,
97}
98
99pub 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 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 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}