#[path = "claude_model.rs"]
mod model;
use model::{extract_result_model, normalize_model};
use chrono::Local;
use serde_json::{Value, json};
use super::truncate::{capped_detail, capped_detail_with};
use crate::types::*;
pub(crate) fn parse_event_line(task_id: &TaskId, line: &str) -> Option<TaskEvent> {
parse_event_line_for_route(task_id, line, None)
}
pub(crate) fn parse_event_line_for_route(
task_id: &TaskId, line: &str, route: Option<&str>,
) -> Option<TaskEvent> {
let cleaned = crate::runtime::watcher::strip_terminal_escapes(line);
let trimmed = cleaned.trim();
if trimmed.is_empty() {
return None;
}
let v: Value = serde_json::from_str(trimmed).ok()?;
let now = Local::now();
if trimmed.contains("NO_CHANGES_NEEDED") {
return Some(TaskEvent {
task_id: task_id.clone(),
timestamp: now,
event_kind: EventKind::NoOp,
detail: extract_noop_reason(trimmed),
metadata: None,
});
}
match v.get("type").and_then(|value| value.as_str())? {
"assistant" => parse_assistant_event(task_id, &v, now),
"tool_use" => parse_tool_use_event(task_id, &v, now),
"result" => parse_result_event(task_id, &v, now),
"system" => parse_system_event(task_id, &v, now).map(|mut event| {
if route.is_some() && v["subtype"] == "init" && v["apiKeySource"] == "apiKeyHelper" {
let metadata = event.metadata.get_or_insert_with(|| json!({}));
metadata["api_route_init_verified"] = json!(true);
}
event
}),
"user" => parse_user_event(task_id, &v, now),
"error" => parse_error_event(task_id, &v, now, route),
_ => None,
}
}
fn parse_assistant_event(
task_id: &TaskId,
v: &Value,
now: chrono::DateTime<Local>,
) -> Option<TaskEvent> {
let message = v.get("message")?;
let content = message.get("content")?.as_array()?;
if let Some(item) = content
.iter()
.find(|item| item.get("type").and_then(|value| value.as_str()) == Some("tool_use"))
{
return build_tool_event(task_id, item, now);
}
let text = content.iter().find_map(|item| {
if item.get("type").and_then(|value| value.as_str()) == Some("text") {
item.get("text").and_then(|value| value.as_str())
} else {
None
}
})?;
let metadata = base_metadata(
message.get("model").and_then(|value| value.as_str()),
v.get("session_id").and_then(|value| value.as_str()),
);
let (detail, metadata) = capped_detail_with(text, metadata);
Some(TaskEvent {
task_id: task_id.clone(),
timestamp: now,
event_kind: EventKind::Reasoning,
detail,
metadata,
})
}
fn parse_tool_use_event(
task_id: &TaskId,
v: &Value,
now: chrono::DateTime<Local>,
) -> Option<TaskEvent> {
let tool = v.get("tool").unwrap_or(v);
build_tool_event(task_id, tool, now)
}
fn build_tool_event(
task_id: &TaskId,
tool: &Value,
now: chrono::DateTime<Local>,
) -> Option<TaskEvent> {
let name = tool.get("name").and_then(|value| value.as_str()).unwrap_or("tool");
let command = tool
.pointer("/input/command")
.and_then(|value| value.as_str())
.or_else(|| tool.pointer("/input/description").and_then(|value| value.as_str()));
let text = command
.map(|value| format!("{name}: {value}"))
.unwrap_or_else(|| name.to_string());
let (detail, metadata) = capped_detail(&text);
Some(TaskEvent {
task_id: task_id.clone(),
timestamp: now,
event_kind: EventKind::ToolCall,
detail,
metadata,
})
}
fn parse_result_event(
task_id: &TaskId,
v: &Value,
now: chrono::DateTime<Local>,
) -> Option<TaskEvent> {
let payload = v.get("result").filter(|value| value.is_object()).unwrap_or(v);
let usage = payload.get("usage").unwrap_or(payload);
let input_tokens = usage_token_count(usage, "input_tokens", "tokens_in");
let output_tokens = usage_token_count(usage, "output_tokens", "tokens_out");
let cache_creation_tokens = usage_token_count(
usage, "cache_creation_input_tokens", "cache_creation_input_tokens",
);
let cache_read_tokens = usage_token_count(usage, "cache_read_input_tokens", "cache_read_input_tokens");
let total_tokens = input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens;
let detail = format!(
"tokens: {} in + {} out = {} ({} cache create, {} cache read)",
input_tokens,
output_tokens,
total_tokens,
cache_creation_tokens,
cache_read_tokens
);
let metadata = result_metadata(payload, v, json!({
"tokens": total_tokens,
"input_tokens": input_tokens,
"uncached_input_tokens": input_tokens,
"output_tokens": output_tokens,
"prompt_tokens": input_tokens,
"cache_creation_input_tokens": cache_creation_tokens,
"cache_read_input_tokens": cache_read_tokens,
}));
let (detail, metadata) = capped_detail_with(&detail, Some(metadata));
Some(TaskEvent {
task_id: task_id.clone(),
timestamp: now,
event_kind: EventKind::Completion,
detail,
metadata,
})
}
fn usage_token_count(usage: &Value, key: &str, alias: &str) -> i64 {
usage.get(key).or_else(|| usage.get(alias)).and_then(Value::as_i64).unwrap_or(0)
}
fn result_metadata(payload: &Value, envelope: &Value, mut metadata: Value) -> Value {
let cost_usd = payload
.get("total_cost_usd")
.or_else(|| payload.get("cost_usd"))
.and_then(|value| value.as_f64());
let model = extract_result_model(payload);
let session_id = payload
.get("session_id")
.or_else(|| envelope.get("session_id"))
.and_then(|value| value.as_str());
if let Some(cost) = cost_usd {
metadata["cost_usd"] = json!(cost);
}
if let Some(model) = model {
metadata["model"] = json!(model);
}
if let Some(session_id) = session_id {
metadata["agent_session_id"] = json!(session_id);
}
metadata
}
fn parse_system_event(
task_id: &TaskId,
v: &Value,
now: chrono::DateTime<Local>,
) -> Option<TaskEvent> {
let subtype = v.get("subtype").and_then(|value| value.as_str())?;
if subtype == "init" {
let detail = v
.get("model")
.and_then(|value| value.as_str())
.map(|model| format!("init: {model}"))
.unwrap_or_else(|| "init".to_string());
let metadata = base_metadata(
v.get("model").and_then(|value| value.as_str()),
v.get("session_id").and_then(|value| value.as_str()),
);
let (detail, metadata) = capped_detail_with(&detail, metadata);
return Some(TaskEvent {
task_id: task_id.clone(),
timestamp: now,
event_kind: EventKind::Reasoning,
detail,
metadata,
});
}
if subtype == "hook_response" && v.get("outcome").and_then(|value| value.as_str()) == Some("error")
{
let text = v
.get("stderr")
.or_else(|| v.get("output"))
.and_then(|value| value.as_str())?;
let (detail, metadata) = capped_detail(text);
return Some(TaskEvent {
task_id: task_id.clone(),
timestamp: now,
event_kind: EventKind::Error,
detail,
metadata,
});
}
None
}
fn parse_user_event(task_id: &TaskId, v: &Value, now: chrono::DateTime<Local>) -> Option<TaskEvent> {
let detail = v
.get("tool_use_result")
.and_then(|value| value.as_str())
.or_else(|| {
v.pointer("/message/content/0/content")
.and_then(|value| value.as_str())
})?;
let is_error = v
.pointer("/message/content/0/is_error")
.and_then(|value| value.as_bool())
.unwrap_or(false);
if !is_error {
return None;
}
let (detail, metadata) = capped_detail(detail);
Some(TaskEvent { task_id: task_id.clone(), timestamp: now, event_kind: EventKind::Error, detail, metadata })
}
fn parse_error_event(
task_id: &TaskId, v: &Value, now: chrono::DateTime<Local>, route: Option<&str>,
) -> Option<TaskEvent> {
let detail = v.get("message").or_else(|| v.pointer("/error/message")).and_then(|value| value.as_str()).filter(|msg| !msg.is_empty())?;
if route.is_none() {
crate::routing::quota::channel::mark_stream_refusal(AgentKind::Claude, None, &v.to_string());
}
let (detail, metadata) = capped_detail(detail);
Some(TaskEvent {
task_id: task_id.clone(),
timestamp: now,
event_kind: EventKind::Error,
detail,
metadata,
})
}
fn base_metadata(model: Option<&str>, session_id: Option<&str>) -> Option<Value> {
match (model, session_id) {
(None, None) => None,
_ => {
let mut metadata = json!({});
if let Some(model) = model {
metadata["model"] = json!(normalize_model(model));
}
if let Some(session_id) = session_id {
metadata["agent_session_id"] = json!(session_id);
}
Some(metadata)
}
}
}
fn extract_noop_reason(line: &str) -> String {
if let Some(pos) = line.find("NO_CHANGES_NEEDED:") {
format!("NO_CHANGES_NEEDED: {}", line[pos + 18..].trim().trim_matches('"'))
} else {
"NO_CHANGES_NEEDED".to_string()
}
}
#[cfg(test)]
#[path = "claude_events_tests.rs"]
mod tests;