1use serde::{Deserialize, Serialize};
4use serde_json::{json, Map, Value};
5
6#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
8#[serde(rename_all = "camelCase")]
9pub struct PricingInfo {
10 pub total_cost_usd: f64,
11 pub source: String,
12}
13
14#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct ResultMetadata {
18 pub tool: String,
19 pub exit_code: i32,
20 pub success: bool,
21 pub session_id: Option<String>,
22 pub limit_reached: bool,
23 pub limit_reset_time: Option<String>,
24 pub limit_timezone: Option<String>,
25 pub anthropic_total_cost_usd: Option<f64>,
26 pub public_pricing_estimate: Option<f64>,
27 pub pricing_info: Option<PricingInfo>,
28 pub result_summary: Option<String>,
29 pub result_model_usage: Option<Value>,
30 pub stream_token_usage: Option<Value>,
31 pub sub_agent_calls: Option<Vec<Value>>,
32 pub error_during_execution: bool,
33 pub error_type: Option<String>,
34 pub error_message: Option<String>,
35}
36
37#[derive(Debug, Clone)]
39pub struct BuildMetadataOptions<'a> {
40 pub tool: &'a str,
41 pub exit_code: i32,
42 pub plain_output: &'a str,
43 pub parsed_output: Option<&'a [Value]>,
44 pub session_id: Option<String>,
45 pub usage: Option<Value>,
46}
47
48#[derive(Debug, Clone, Default)]
49struct UsageLimit {
50 reached: bool,
51 reset_time: Option<String>,
52 timezone: Option<String>,
53}
54
55#[derive(Debug, Clone, Default)]
56struct ExecutionError {
57 has_error: bool,
58 error_type: Option<String>,
59 message: Option<String>,
60}
61
62fn parse_json_messages(output: &str) -> Vec<Value> {
63 if output.trim().is_empty() {
64 return Vec::new();
65 }
66
67 if let Ok(parsed) = serde_json::from_str::<Value>(output) {
68 return match parsed {
69 Value::Array(messages) => messages,
70 message => vec![message],
71 };
72 }
73
74 output
75 .lines()
76 .filter_map(|line| {
77 let trimmed = line.trim();
78 if trimmed.is_empty() || (!trimmed.starts_with('{') && !trimmed.starts_with('[')) {
79 return None;
80 }
81
82 serde_json::from_str::<Value>(trimmed).ok()
83 })
84 .flat_map(|value| match value {
85 Value::Array(values) => values,
86 message => vec![message],
87 })
88 .collect()
89}
90
91fn limit_reached(output: &str) -> bool {
92 let lower = output.to_ascii_lowercase();
93 [
94 "usage limit reached",
95 "usage limit exceeded",
96 "rate limit",
97 "rate_limit",
98 "limit reached",
99 "billing hard limit",
100 "please try again at",
101 "available again at",
102 "session limit reached",
103 "weekly limit reached",
104 "daily limit reached",
105 "monthly limit reached",
106 "freeusagelimiterror",
107 "resets ",
108 ]
109 .iter()
110 .any(|pattern| lower.contains(pattern))
111}
112
113fn clean_reset_time(value: &str) -> Option<String> {
114 let cleaned = value
115 .trim()
116 .trim_matches(|c: char| c == ')' || c == ',' || c == ';')
117 .trim();
118 if cleaned.is_empty() || cleaned.len() > 120 {
119 return None;
120 }
121
122 Some(cleaned.to_string())
123}
124
125fn extract_reset_time(output: &str) -> Option<String> {
126 let lower = output.to_ascii_lowercase();
127 for marker in [
128 "limit resets at",
129 "reset time:",
130 "resets at",
131 "reset at",
132 "try again at",
133 "available again at",
134 "available at",
135 "resets",
136 "reset",
137 ] {
138 if let Some(index) = lower.find(marker) {
139 let tail = &output[index + marker.len()..];
140 let candidate = tail.split(['\n', '.']).next().unwrap_or_default().trim();
141 if let Some(cleaned) = clean_reset_time(candidate) {
142 return Some(cleaned);
143 }
144 }
145 }
146
147 None
148}
149
150fn looks_like_timezone(value: &str) -> bool {
151 let value = value.trim();
152 if value.starts_with("UTC") {
153 return true;
154 }
155 if value.contains('/') {
156 return value
157 .chars()
158 .all(|c| c.is_ascii_alphabetic() || c == '/' || c == '_' || c == '-');
159 }
160 value.len() >= 2 && value.len() <= 5 && value.chars().all(|c| c.is_ascii_uppercase())
161}
162
163fn extract_timezone(output: &str, reset_time: Option<&str>) -> Option<String> {
164 for part in output.split('(').skip(1) {
165 if let Some(candidate) = part.split(')').next() {
166 if looks_like_timezone(candidate) {
167 return Some(candidate.trim().to_string());
168 }
169 }
170 }
171
172 if let Some(reset_time) = reset_time {
173 for token in reset_time.split_whitespace() {
174 let candidate = token.trim_matches(|c: char| c == '(' || c == ')' || c == ',');
175 if looks_like_timezone(candidate) {
176 return Some(candidate.to_string());
177 }
178 }
179 }
180
181 None
182}
183
184fn detect_usage_limit(output: &str) -> UsageLimit {
185 if !limit_reached(output) {
186 return UsageLimit::default();
187 }
188
189 let reset_time = extract_reset_time(output);
190 let timezone = extract_timezone(output, reset_time.as_deref());
191
192 UsageLimit {
193 reached: true,
194 reset_time,
195 timezone,
196 }
197}
198
199fn text_from_value(value: Option<&Value>) -> Option<String> {
200 match value {
201 Some(Value::String(text)) => {
202 let trimmed = text.trim();
203 if trimmed.is_empty() {
204 None
205 } else {
206 Some(trimmed.to_string())
207 }
208 }
209 Some(Value::Array(values)) => {
210 let text = values
211 .iter()
212 .filter_map(|item| text_from_value(Some(item)))
213 .collect::<Vec<_>>()
214 .join("\n");
215 if text.trim().is_empty() {
216 None
217 } else {
218 Some(text)
219 }
220 }
221 Some(Value::Object(map)) => {
222 for key in ["text", "content", "result", "summary", "message"] {
223 if let Some(text) = text_from_value(map.get(key)) {
224 return Some(text);
225 }
226 }
227 None
228 }
229 _ => None,
230 }
231}
232
233fn tail_chars(value: &str, max_chars: usize) -> String {
234 let chars = value.chars().collect::<Vec<_>>();
235 let start = chars.len().saturating_sub(max_chars);
236 chars[start..].iter().collect()
237}
238
239fn extract_result_summary(messages: &[Value], plain_output: &str) -> Option<String> {
240 let keys = [
241 "result",
242 "summary",
243 "result_summary",
244 "resultSummary",
245 "final_answer",
246 "finalAnswer",
247 "text",
248 "content",
249 "message",
250 ];
251
252 for message in messages.iter().rev() {
253 if !message.is_object() {
254 continue;
255 }
256
257 for key in keys {
258 if let Some(text) = text_from_value(message.get(key)) {
259 return Some(text);
260 }
261 }
262
263 let nested_text = text_from_value(message.pointer("/item/content"))
264 .or_else(|| text_from_value(message.pointer("/item/text")))
265 .or_else(|| text_from_value(message.pointer("/delta/text")));
266 if nested_text.is_some() {
267 return nested_text;
268 }
269 }
270
271 let trimmed = plain_output.trim();
272 if trimmed.is_empty() {
273 None
274 } else {
275 Some(tail_chars(trimmed, 4000))
276 }
277}
278
279fn first_number(messages: &[Value], keys: &[&str]) -> Option<f64> {
280 for message in messages.iter().rev() {
281 for key in keys {
282 if let Some(value) = message.get(*key).and_then(Value::as_f64) {
283 return Some(value);
284 }
285 }
286 }
287
288 None
289}
290
291fn extract_result_model_usage(messages: &[Value]) -> Option<Value> {
292 for message in messages.iter().rev() {
293 for key in [
294 "resultModelUsage",
295 "result_model_usage",
296 "modelUsage",
297 "model_usage",
298 "usage_by_model",
299 ] {
300 if let Some(value) = message.get(key) {
301 if value.is_object() {
302 return Some(value.clone());
303 }
304 }
305 }
306 }
307
308 let mut usage_by_model = Map::new();
309 for message in messages {
310 let model = message
311 .get("model")
312 .or_else(|| message.pointer("/message/model"))
313 .or_else(|| message.pointer("/part/model"))
314 .and_then(Value::as_str);
315 let usage = message
316 .get("usage")
317 .or_else(|| message.pointer("/message/usage"))
318 .or_else(|| message.pointer("/part/tokens"));
319
320 if let (Some(model), Some(usage)) = (model, usage) {
321 let entry = usage_by_model
322 .entry(model.to_string())
323 .or_insert_with(|| Value::Array(Vec::new()));
324 if let Value::Array(values) = entry {
325 values.push(usage.clone());
326 }
327 }
328 }
329
330 if usage_by_model.is_empty() {
331 None
332 } else {
333 Some(Value::Object(usage_by_model))
334 }
335}
336
337fn extract_sub_agent_calls(messages: &[Value]) -> Option<Vec<Value>> {
338 let mut calls = Vec::new();
339
340 for message in messages {
341 for key in [
342 "subAgentCalls",
343 "sub_agent_calls",
344 "subAgents",
345 "sub_agents",
346 ] {
347 if let Some(Value::Array(values)) = message.get(key) {
348 calls.extend(values.iter().cloned());
349 }
350 }
351
352 let message_type = message
353 .get("type")
354 .or_else(|| message.pointer("/item/type"))
355 .or_else(|| message.get("item_type"))
356 .and_then(Value::as_str);
357 if let Some(message_type) = message_type {
358 let lower = message_type.to_ascii_lowercase();
359 if lower.contains("sub_agent")
360 || lower.contains("sub-agent")
361 || lower.contains("subagent")
362 || lower.contains("collab")
363 {
364 calls.push(json!({
365 "type": message_type,
366 "id": message
367 .get("id")
368 .or_else(|| message.get("call_id"))
369 .or_else(|| message.pointer("/item/id"))
370 .cloned()
371 .unwrap_or(Value::Null),
372 "name": message
373 .get("name")
374 .or_else(|| message.get("tool"))
375 .or_else(|| message.pointer("/item/name"))
376 .cloned()
377 .unwrap_or(Value::Null),
378 "status": message
379 .get("status")
380 .or_else(|| message.get("state"))
381 .cloned()
382 .unwrap_or(Value::Null),
383 "summary": extract_result_summary(std::slice::from_ref(message), ""),
384 }));
385 }
386 }
387 }
388
389 if calls.is_empty() {
390 None
391 } else {
392 Some(calls)
393 }
394}
395
396fn extract_error_from_messages(messages: &[Value]) -> ExecutionError {
397 for message in messages.iter().rev() {
398 let message_type = message
399 .get("type")
400 .or_else(|| message.get("subtype"))
401 .or_else(|| message.pointer("/item/type"))
402 .and_then(Value::as_str);
403 let is_error = message.get("is_error").and_then(Value::as_bool) == Some(true)
404 || message.get("error").is_some()
405 || matches!(message_type, Some("error" | "step_error"));
406
407 if !is_error {
408 continue;
409 }
410
411 let error = message.get("error");
412 let error_type = error
413 .and_then(|value| value.get("type").or_else(|| value.get("code")))
414 .and_then(Value::as_str)
415 .or_else(|| message.get("errorType").and_then(Value::as_str))
416 .or_else(|| message.get("error_type").and_then(Value::as_str))
417 .or(message_type)
418 .unwrap_or("execution_error")
419 .to_string();
420 let error_message = error
421 .and_then(Value::as_str)
422 .map(ToString::to_string)
423 .or_else(|| {
424 error
425 .and_then(|value| value.get("message").or_else(|| value.get("details")))
426 .and_then(Value::as_str)
427 .map(ToString::to_string)
428 })
429 .or_else(|| text_from_value(message.get("message")))
430 .or_else(|| text_from_value(message.get("result")))
431 .unwrap_or_else(|| "Execution failed".to_string());
432
433 return ExecutionError {
434 has_error: true,
435 error_type: Some(error_type),
436 message: Some(error_message),
437 };
438 }
439
440 ExecutionError::default()
441}
442
443fn detect_execution_error(
444 tool: &str,
445 exit_code: i32,
446 plain_output: &str,
447 messages: &[Value],
448) -> ExecutionError {
449 if tool == "agent" {
450 let detected = crate::tools::agent::detect_errors(plain_output);
451 if detected.has_error {
452 return ExecutionError {
453 has_error: true,
454 error_type: detected
455 .error_type
456 .or_else(|| Some("execution_error".to_string())),
457 message: detected
458 .message
459 .or_else(|| Some("Execution failed".to_string())),
460 };
461 }
462 }
463
464 let message_error = extract_error_from_messages(messages);
465 if message_error.has_error {
466 return message_error;
467 }
468
469 if exit_code != 0 {
470 let last_line = plain_output
471 .trim()
472 .lines()
473 .rev()
474 .find(|line| !line.trim().is_empty())
475 .map(str::trim)
476 .filter(|line| !line.is_empty())
477 .map(ToString::to_string);
478 return ExecutionError {
479 has_error: true,
480 error_type: Some("exit_code".to_string()),
481 message: last_line.or_else(|| Some(format!("Process exited with code {}", exit_code))),
482 };
483 }
484
485 ExecutionError::default()
486}
487
488fn extract_session_id(explicit_session_id: Option<String>, messages: &[Value]) -> Option<String> {
489 if explicit_session_id.is_some() {
490 return explicit_session_id;
491 }
492
493 for message in messages {
494 for key in [
495 "session_id",
496 "sessionId",
497 "thread_id",
498 "threadId",
499 "conversation_id",
500 "conversationId",
501 ] {
502 if let Some(session_id) = message.get(key).and_then(Value::as_str) {
503 return Some(session_id.to_string());
504 }
505 }
506 }
507
508 None
509}
510
511fn public_pricing_estimate(tool: &str, usage: Option<&Value>) -> Option<f64> {
512 if tool != "agent" && tool != "opencode" {
513 return None;
514 }
515
516 usage
517 .and_then(|value| value.get("totalCost").or_else(|| value.get("totalCostUSD")))
518 .and_then(Value::as_f64)
519}
520
521pub fn build_normalized_result_metadata(options: BuildMetadataOptions<'_>) -> ResultMetadata {
523 let messages = options.parsed_output.map_or_else(
524 || parse_json_messages(options.plain_output),
525 <[Value]>::to_vec,
526 );
527 let usage_limit = detect_usage_limit(options.plain_output);
528 let execution_error = detect_execution_error(
529 options.tool,
530 options.exit_code,
531 options.plain_output,
532 &messages,
533 );
534 let session_id = extract_session_id(options.session_id, &messages);
535 let public_pricing_estimate = public_pricing_estimate(options.tool, options.usage.as_ref());
536 let pricing_info = public_pricing_estimate.map(|total_cost_usd| PricingInfo {
537 total_cost_usd,
538 source: format!("{}-stream-usage", options.tool),
539 });
540
541 ResultMetadata {
542 tool: options.tool.to_string(),
543 exit_code: options.exit_code,
544 success: options.exit_code == 0 && !usage_limit.reached && !execution_error.has_error,
545 session_id,
546 limit_reached: usage_limit.reached,
547 limit_reset_time: usage_limit.reset_time,
548 limit_timezone: usage_limit.timezone,
549 anthropic_total_cost_usd: first_number(
550 &messages,
551 &["total_cost_usd", "totalCostUsd", "anthropicTotalCostUSD"],
552 ),
553 public_pricing_estimate,
554 pricing_info,
555 result_summary: extract_result_summary(&messages, options.plain_output),
556 result_model_usage: extract_result_model_usage(&messages),
557 stream_token_usage: options.usage,
558 sub_agent_calls: extract_sub_agent_calls(&messages),
559 error_during_execution: execution_error.has_error,
560 error_type: execution_error.error_type,
561 error_message: execution_error.message,
562 }
563}
564
565#[cfg(test)]
566mod tests {
567 use super::{build_normalized_result_metadata, BuildMetadataOptions};
568 use serde_json::json;
569
570 #[test]
571 fn normalizes_codex_thread_id_and_usage() {
572 let usage = json!({
573 "inputTokens": 12,
574 "outputTokens": 4,
575 });
576 let metadata = build_normalized_result_metadata(BuildMetadataOptions {
577 tool: "codex",
578 exit_code: 0,
579 plain_output:
580 "{\"type\":\"session\",\"thread_id\":\"thread-123\"}\n{\"type\":\"message\",\"content\":\"Done.\"}",
581 parsed_output: None,
582 session_id: None,
583 usage: Some(usage.clone()),
584 });
585
586 assert_eq!(metadata.tool, "codex");
587 assert!(metadata.success);
588 assert_eq!(metadata.session_id, Some("thread-123".to_string()));
589 assert_eq!(metadata.result_summary, Some("Done.".to_string()));
590 assert_eq!(metadata.stream_token_usage, Some(usage));
591 }
592
593 #[test]
594 fn exposes_agent_pricing_and_sub_agent_calls() {
595 let messages = vec![
596 json!({
597 "type": "collab_tool_call",
598 "id": "call-1",
599 "name": "worker",
600 "status": "completed",
601 "summary": "Worker finished."
602 }),
603 json!({
604 "type": "message",
605 "text": "Final summary."
606 }),
607 ];
608 let usage = json!({
609 "inputTokens": 100,
610 "outputTokens": 40,
611 "totalCost": 0.004,
612 "stepCount": 1
613 });
614 let metadata = build_normalized_result_metadata(BuildMetadataOptions {
615 tool: "agent",
616 exit_code: 0,
617 plain_output: "",
618 parsed_output: Some(&messages),
619 session_id: None,
620 usage: Some(usage.clone()),
621 });
622
623 assert_eq!(metadata.tool, "agent");
624 assert!(metadata.success);
625 assert_eq!(metadata.public_pricing_estimate, Some(0.004));
626 assert_eq!(
627 metadata
628 .pricing_info
629 .as_ref()
630 .map(|pricing| pricing.source.as_str()),
631 Some("agent-stream-usage")
632 );
633 assert_eq!(metadata.result_summary, Some("Final summary.".to_string()));
634 let sub_agent_calls = metadata.sub_agent_calls.unwrap();
635 assert_eq!(sub_agent_calls.len(), 1);
636 assert_eq!(sub_agent_calls[0].get("id"), Some(&json!("call-1")));
637 }
638}