Skip to main content

zag_agent/providers/claude/
models.rs

1/// Claude-specific JSON output models.
2///
3/// These structures directly map to the JSON output format produced by the
4/// Claude CLI when running with `--output json` (verbose mode). They can be
5/// deserialized from JSON and then converted to the unified `AgentOutput` format.
6///
7/// See README.md in this directory for detailed documentation on the output format.
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11use crate::output::{
12    AgentOutput, ContentBlock as UnifiedContentBlock, Event as UnifiedEvent, ToolResult,
13    Usage as UnifiedUsage,
14};
15
16/// The root structure: an array of events.
17pub type ClaudeOutput = Vec<ClaudeEvent>;
18
19/// A single event in Claude's output stream.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(tag = "type", rename_all = "snake_case")]
22pub enum ClaudeEvent {
23    /// System initialization event
24    System {
25        subtype: String,
26        session_id: String,
27        cwd: Option<String>,
28        model: String,
29        tools: Vec<String>,
30        #[serde(default)]
31        mcp_servers: Vec<serde_json::Value>,
32        #[serde(rename = "permissionMode")]
33        permission_mode: Option<String>,
34        #[serde(default)]
35        slash_commands: Vec<String>,
36        #[serde(default)]
37        agents: Vec<String>,
38        #[serde(default)]
39        skills: Vec<serde_json::Value>,
40        #[serde(default)]
41        plugins: Vec<Plugin>,
42        uuid: String,
43        #[serde(flatten)]
44        extra: HashMap<String, serde_json::Value>,
45    },
46
47    /// Assistant message event
48    Assistant {
49        message: Message,
50        parent_tool_use_id: Option<String>,
51        session_id: String,
52        uuid: String,
53    },
54
55    /// User message event (tool results)
56    User {
57        message: UserMessage,
58        parent_tool_use_id: Option<String>,
59        session_id: String,
60        uuid: String,
61        tool_use_result: Option<serde_json::Value>,
62    },
63
64    /// Final result event
65    Result {
66        subtype: String,
67        is_error: bool,
68        duration_ms: u64,
69        duration_api_ms: u64,
70        num_turns: u32,
71        result: String,
72        session_id: String,
73        total_cost_usd: f64,
74        usage: Usage,
75        #[serde(default, rename = "modelUsage")]
76        model_usage: HashMap<String, ModelUsage>,
77        #[serde(default)]
78        permission_denials: Vec<PermissionDenial>,
79        /// Structured JSON output when `--json-schema` is used.
80        /// Claude CLI may place the actual data here while leaving
81        /// `result` empty (or containing a markdown-wrapped copy).
82        #[serde(default)]
83        structured_output: Option<serde_json::Value>,
84        uuid: String,
85    },
86
87    /// Unknown/unhandled event type (e.g., rate_limit_event) — silently ignored
88    #[serde(other)]
89    Other,
90}
91
92/// An assistant message from Claude.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct Message {
95    pub model: String,
96    pub id: String,
97    #[serde(rename = "type")]
98    pub message_type: String,
99    pub role: String,
100    pub content: Vec<ContentBlock>,
101    pub stop_reason: Option<String>,
102    pub stop_sequence: Option<String>,
103    pub usage: Usage,
104    pub context_management: Option<serde_json::Value>,
105}
106
107/// A user message containing tool results and other content.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct UserMessage {
110    pub role: String,
111    pub content: Vec<UserContentBlock>,
112}
113
114/// A content block in an assistant message.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116#[serde(tag = "type", rename_all = "snake_case")]
117pub enum ContentBlock {
118    /// Text content
119    Text { text: String },
120
121    /// Tool invocation
122    ToolUse {
123        id: String,
124        name: String,
125        input: serde_json::Value,
126    },
127
128    /// Thinking content (extended thinking)
129    Thinking {
130        #[serde(default)]
131        thinking: String,
132        #[serde(flatten)]
133        extra: HashMap<String, serde_json::Value>,
134    },
135
136    /// Unknown/unhandled content block type — silently ignored
137    #[serde(other)]
138    Other,
139}
140
141/// A content block in a user message (tool results, text, or other types).
142#[derive(Debug, Clone, Serialize, Deserialize)]
143#[serde(tag = "type", rename_all = "snake_case")]
144pub enum UserContentBlock {
145    /// Tool result
146    ToolResult {
147        tool_use_id: String,
148        content: String,
149        #[serde(default)]
150        is_error: bool,
151    },
152
153    /// Text content
154    Text { text: String },
155
156    /// Any other content type
157    #[serde(other)]
158    Other,
159}
160
161/// Usage statistics for a message or session.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct Usage {
164    pub input_tokens: u64,
165    #[serde(default)]
166    pub cache_creation_input_tokens: u64,
167    #[serde(default)]
168    pub cache_read_input_tokens: u64,
169    pub output_tokens: u64,
170    #[serde(default)]
171    pub cache_creation: Option<CacheCreation>,
172    #[serde(default)]
173    pub server_tool_use: Option<ServerToolUse>,
174    #[serde(default)]
175    pub service_tier: Option<String>,
176}
177
178/// Cache creation details.
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct CacheCreation {
181    #[serde(default)]
182    pub ephemeral_5m_input_tokens: u64,
183    #[serde(default)]
184    pub ephemeral_1h_input_tokens: u64,
185}
186
187/// Server-side tool usage.
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct ServerToolUse {
190    #[serde(default)]
191    pub web_search_requests: u32,
192    #[serde(default)]
193    pub web_fetch_requests: u32,
194}
195
196/// Per-model usage statistics.
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct ModelUsage {
199    #[serde(rename = "inputTokens")]
200    pub input_tokens: u64,
201    #[serde(rename = "outputTokens")]
202    pub output_tokens: u64,
203    #[serde(default, rename = "cacheReadInputTokens")]
204    pub cache_read_input_tokens: u64,
205    #[serde(default, rename = "cacheCreationInputTokens")]
206    pub cache_creation_input_tokens: u64,
207    #[serde(default, rename = "webSearchRequests")]
208    pub web_search_requests: u32,
209    #[serde(rename = "costUSD")]
210    pub cost_usd: f64,
211    #[serde(default, rename = "contextWindow")]
212    pub context_window: u64,
213    #[serde(default, rename = "maxOutputTokens")]
214    pub max_output_tokens: u64,
215}
216
217/// Information about a denied permission request.
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct PermissionDenial {
220    pub tool_name: String,
221    pub tool_use_id: String,
222    pub tool_input: serde_json::Value,
223}
224
225/// Plugin information.
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct Plugin {
228    pub name: String,
229    pub path: String,
230}
231
232/// Convert Claude output to unified agent output.
233pub fn claude_output_to_agent_output(claude_output: ClaudeOutput) -> AgentOutput {
234    let mut session_id = String::from("unknown");
235    let mut result = None;
236    let mut is_error = false;
237    let mut total_cost_usd = None;
238    let mut usage = None;
239    let mut events = Vec::new();
240    let mut model_name: Option<String> = None;
241
242    // Turn-boundary state for synthesizing Event::TurnComplete before each
243    // Event::Result. Mirrors `ClaudeEventTranslator` in the streaming path
244    // but is inlined here because the full-parse path also does its own
245    // metadata extraction (session_id, total_cost_usd, ...) that doesn't
246    // fit the translator's per-event shape.
247    let mut pending_stop_reason: Option<String> = None;
248    let mut pending_turn_usage: Option<UnifiedUsage> = None;
249    let mut next_turn_index: u32 = 0;
250
251    // Track text from the last assistant message for fallback when
252    // Result.result is empty (e.g. when --json-schema is used, Claude Code
253    // may put the content in the assistant message but leave the result
254    // field blank).
255    let mut last_assistant_text: Option<String> = None;
256
257    for event in claude_output {
258        match event {
259            ClaudeEvent::System {
260                session_id: sid,
261                model,
262                tools,
263                cwd,
264                mut extra,
265                ..
266            } => {
267                session_id = sid;
268                model_name = Some(model.clone());
269
270                // Include all extra fields as metadata
271                if let Some(cwd) = cwd {
272                    extra.insert("cwd".to_string(), serde_json::json!(cwd));
273                }
274
275                events.push(UnifiedEvent::Init {
276                    model,
277                    tools,
278                    working_directory: extra
279                        .get("cwd")
280                        .and_then(|v| v.as_str().map(|s| s.to_string())),
281                    metadata: extra,
282                });
283            }
284
285            ClaudeEvent::Assistant {
286                message,
287                session_id: sid,
288                parent_tool_use_id,
289                ..
290            } => {
291                session_id = sid;
292
293                // Track the latest stop_reason for the current turn; the
294                // final assistant message before a Result is the one whose
295                // stop_reason explains why the turn ended.
296                if let Some(reason) = &message.stop_reason {
297                    pending_stop_reason = Some(reason.clone());
298                }
299
300                // Convert content blocks (skip thinking blocks)
301                let content: Vec<UnifiedContentBlock> = message
302                    .content
303                    .into_iter()
304                    .filter_map(|block| match block {
305                        ContentBlock::Text { text } => Some(UnifiedContentBlock::Text { text }),
306                        ContentBlock::ToolUse { id, name, input } => {
307                            Some(UnifiedContentBlock::ToolUse { id, name, input })
308                        }
309                        ContentBlock::Thinking { .. } | ContentBlock::Other => None,
310                    })
311                    .collect();
312
313                // Collect text blocks for fallback result extraction.
314                let text_parts: Vec<&str> = content
315                    .iter()
316                    .filter_map(|b| match b {
317                        UnifiedContentBlock::Text { text } => Some(text.as_str()),
318                        _ => None,
319                    })
320                    .collect();
321                if !text_parts.is_empty() {
322                    last_assistant_text = Some(text_parts.join("\n"));
323                }
324
325                // Convert usage
326                let msg_usage = Some(UnifiedUsage {
327                    input_tokens: message.usage.input_tokens,
328                    output_tokens: message.usage.output_tokens,
329                    cache_read_tokens: Some(message.usage.cache_read_input_tokens),
330                    cache_creation_tokens: Some(message.usage.cache_creation_input_tokens),
331                    web_search_requests: message
332                        .usage
333                        .server_tool_use
334                        .as_ref()
335                        .map(|s| s.web_search_requests),
336                    web_fetch_requests: message
337                        .usage
338                        .server_tool_use
339                        .as_ref()
340                        .map(|s| s.web_fetch_requests),
341                });
342                pending_turn_usage = msg_usage.clone();
343
344                events.push(UnifiedEvent::AssistantMessage {
345                    content,
346                    usage: msg_usage,
347                    parent_tool_use_id,
348                });
349            }
350
351            ClaudeEvent::User {
352                message,
353                tool_use_result,
354                session_id: sid,
355                parent_tool_use_id,
356                ..
357            } => {
358                session_id = sid;
359
360                // Convert tool results to tool execution events (skip non-tool-result blocks)
361                for block in message.content {
362                    if let UserContentBlock::ToolResult {
363                        tool_use_id,
364                        content,
365                        is_error,
366                    } = block
367                    {
368                        let tool_name = find_tool_name(&events, &tool_use_id)
369                            .unwrap_or_else(|| "unknown".to_string());
370
371                        let tool_result = ToolResult {
372                            success: !is_error,
373                            output: if !is_error {
374                                Some(content.clone())
375                            } else {
376                                None
377                            },
378                            error: if is_error {
379                                Some(content.clone())
380                            } else {
381                                None
382                            },
383                            data: tool_use_result.clone(),
384                        };
385
386                        events.push(UnifiedEvent::ToolExecution {
387                            tool_name,
388                            tool_id: tool_use_id,
389                            input: serde_json::Value::Null,
390                            result: tool_result,
391                            parent_tool_use_id: parent_tool_use_id.clone(),
392                        });
393                    }
394                }
395            }
396
397            ClaudeEvent::Other => {
398                log::debug!("Skipping unknown Claude event type during output conversion");
399            }
400
401            ClaudeEvent::Result {
402                is_error: err,
403                result: res,
404                total_cost_usd: cost,
405                usage: u,
406                duration_ms,
407                num_turns,
408                permission_denials,
409                session_id: sid,
410                structured_output,
411                subtype: _,
412                ..
413            } => {
414                session_id = sid;
415                is_error = err;
416
417                // When Result.result is empty, fall back to structured_output
418                // (set by Claude CLI when --json-schema is used) or the last
419                // assistant message text.
420                let effective_result = if res.is_empty() {
421                    if let Some(ref so) = structured_output {
422                        let json = serde_json::to_string(so).unwrap_or_default();
423                        log::debug!(
424                            "Result.result is empty; using structured_output ({} bytes)",
425                            json.len()
426                        );
427                        json
428                    } else if let Some(ref fallback) = last_assistant_text {
429                        log::debug!(
430                            "Result.result is empty; using last assistant text ({} bytes)",
431                            fallback.len()
432                        );
433                        fallback.clone()
434                    } else {
435                        res.clone()
436                    }
437                } else {
438                    res.clone()
439                };
440
441                result = Some(effective_result.clone());
442                total_cost_usd = Some(cost);
443
444                // Convert usage
445                usage = Some(UnifiedUsage {
446                    input_tokens: u.input_tokens,
447                    output_tokens: u.output_tokens,
448                    cache_read_tokens: Some(u.cache_read_input_tokens),
449                    cache_creation_tokens: Some(u.cache_creation_input_tokens),
450                    web_search_requests: u.server_tool_use.as_ref().map(|s| s.web_search_requests),
451                    web_fetch_requests: u.server_tool_use.as_ref().map(|s| s.web_fetch_requests),
452                });
453
454                // Add permission denial events
455                for denial in permission_denials {
456                    events.push(UnifiedEvent::PermissionRequest {
457                        tool_name: denial.tool_name,
458                        description: format!(
459                            "Permission denied for tool input: {}",
460                            serde_json::to_string(&denial.tool_input).unwrap_or_default()
461                        ),
462                        granted: false,
463                    });
464                }
465
466                // Emit TurnComplete immediately before the per-turn Result.
467                events.push(UnifiedEvent::TurnComplete {
468                    stop_reason: pending_stop_reason.take(),
469                    turn_index: next_turn_index,
470                    usage: pending_turn_usage.take(),
471                });
472                next_turn_index = next_turn_index.saturating_add(1);
473
474                // Add final result event
475                events.push(UnifiedEvent::Result {
476                    success: !err,
477                    message: Some(effective_result),
478                    duration_ms: Some(duration_ms),
479                    num_turns: Some(num_turns),
480                });
481            }
482        }
483    }
484
485    AgentOutput {
486        agent: "claude".to_string(),
487        session_id,
488        events,
489        result,
490        is_error,
491        exit_code: None,
492        error_message: None,
493        total_cost_usd,
494        usage,
495        model: model_name,
496        provider: Some("claude".to_string()),
497    }
498}
499
500/// Find the tool name for a given tool_use_id by searching previous events.
501fn find_tool_name(events: &[UnifiedEvent], tool_use_id: &str) -> Option<String> {
502    for event in events.iter().rev() {
503        if let UnifiedEvent::AssistantMessage { content, .. } = event {
504            for block in content {
505                if let UnifiedContentBlock::ToolUse { id, name, .. } = block
506                    && id == tool_use_id
507                {
508                    return Some(name.clone());
509                }
510            }
511        }
512    }
513    None
514}
515
516#[cfg(test)]
517#[path = "models_tests.rs"]
518mod tests;