Skip to main content

cc_token_usage/data/
models.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4// ─── Re-exports from cc-session-jsonl ───────────────────────────────────────
5
6/// Re-export the session file metadata type from cc-session-jsonl.
7///
8/// Note: field name is `path` (not `file_path`). All call-sites have been
9/// updated accordingly.
10pub use cc_session_jsonl::scanner::SessionFile;
11
12// ─── Local Token/Usage Types (kept to avoid changing analysis/pricing/output) ─
13
14/// Token usage statistics for a single API call.
15#[derive(Debug, Clone, Default, Deserialize)]
16pub struct TokenUsage {
17    pub input_tokens: Option<u64>,
18    pub output_tokens: Option<u64>,
19    pub cache_creation_input_tokens: Option<u64>,
20    pub cache_read_input_tokens: Option<u64>,
21    pub cache_creation: Option<CacheCreationDetail>,
22    pub server_tool_use: Option<ServerToolUse>,
23    pub service_tier: Option<String>,
24    pub speed: Option<String>,
25    pub inference_geo: Option<String>,
26}
27
28/// Breakdown of cache creation tokens by TTL bucket.
29#[derive(Debug, Clone, PartialEq, Deserialize)]
30pub struct CacheCreationDetail {
31    pub ephemeral_5m_input_tokens: Option<u64>,
32    pub ephemeral_1h_input_tokens: Option<u64>,
33}
34
35/// Server-side tool usage counters.
36#[derive(Debug, Clone, Deserialize)]
37pub struct ServerToolUse {
38    pub web_search_requests: Option<u64>,
39    pub web_fetch_requests: Option<u64>,
40}
41
42// ─── Conversions from cc-session-jsonl types ─────────────────────────────────
43
44impl From<cc_session_jsonl::types::Usage> for TokenUsage {
45    fn from(u: cc_session_jsonl::types::Usage) -> Self {
46        Self {
47            input_tokens: u.input_tokens,
48            output_tokens: u.output_tokens,
49            cache_creation_input_tokens: u.cache_creation_input_tokens,
50            cache_read_input_tokens: u.cache_read_input_tokens,
51            cache_creation: u.cache_creation.map(|c| CacheCreationDetail {
52                ephemeral_5m_input_tokens: c.ephemeral_5m_input_tokens,
53                ephemeral_1h_input_tokens: c.ephemeral_1h_input_tokens,
54            }),
55            server_tool_use: u.server_tool_use.map(|s| ServerToolUse {
56                web_search_requests: s.web_search_requests,
57                web_fetch_requests: s.web_fetch_requests,
58            }),
59            service_tier: u.service_tier,
60            inference_geo: u.inference_geo,
61            speed: u.speed,
62        }
63    }
64}
65
66// ─── Validated Data Layer ────────────────────────────────────────────────────
67
68/// A single validated assistant turn, ready for analysis.
69#[derive(Debug, Clone)]
70pub struct ValidatedTurn {
71    pub uuid: String,
72    pub request_id: Option<String>,
73    pub timestamp: DateTime<Utc>,
74    pub model: String,
75    pub usage: TokenUsage,
76    pub stop_reason: Option<String>,
77    pub content_types: Vec<String>,
78    pub is_agent: bool,
79    pub agent_id: Option<String>,
80    pub user_text: Option<String>,      // 对应的用户消息文本(截断)
81    pub assistant_text: Option<String>, // assistant 回复文本(截断)
82    pub tool_names: Vec<String>,        // 使用的工具名列表
83    pub service_tier: Option<String>,
84    pub speed: Option<String>,
85    pub inference_geo: Option<String>,
86    pub tool_error_count: usize, // ToolResult blocks with is_error=true
87    pub git_branch: Option<String>, // from the assistant entry's gitBranch field
88    /// 触发本 turn 的 plugin 名(如 "superpowers",Claude Code 2.1.138+)
89    pub attribution_plugin: Option<String>,
90    /// 触发本 turn 的 skill 名(如 "superpowers:brainstorming",Claude Code 2.1.138+)
91    pub attribution_skill: Option<String>,
92}
93
94/// A subagent invocation within a session, grouped from one agent JSONL file.
95///
96/// Each `Subagent` corresponds to one `agent-<id>.jsonl` file under a parent
97/// session. The struct itself is *not* `Serialize` because it embeds the
98/// internal `ValidatedTurn` analysis type; the JSON output layer builds its
99/// own purpose-shaped `SubagentJson` (see `output/json.rs`).
100#[derive(Debug, Clone)]
101pub struct Subagent {
102    /// Agent ID extracted from the agent JSONL file name (e.g. `agent-abc123`).
103    pub agent_id: String,
104    /// Agent type from `.meta.json`, e.g. "general-purpose".
105    pub agent_type: Option<String>,
106    /// Human-readable task description from `.meta.json`.
107    pub description: Option<String>,
108    /// All turns from this subagent, sorted by timestamp.
109    pub turns: Vec<ValidatedTurn>,
110    pub first_timestamp: Option<DateTime<Utc>>,
111    pub last_timestamp: Option<DateTime<Utc>>,
112    /// The workflow run id (`wf_<runId>`) this subagent belongs to, if it was
113    /// discovered under `<uuid>/subagents/workflows/wf_<runId>/`. `None` for
114    /// ordinary (non-workflow) subagents. Used to distinguish workflow-spawned
115    /// agents from regular Task-tool subagents in analysis and output.
116    pub workflow_run_id: Option<String>,
117}
118
119/// Per-plugin aggregation for one session.
120///
121/// Plugin name comes from `attributionPlugin` on assistant entries
122/// (Claude Code 2.1.138+).
123#[derive(Debug, Clone, Serialize, Deserialize)]
124#[serde(rename_all = "camelCase")]
125pub struct PluginUsage {
126    pub plugin: String,
127    pub turns: u64,
128    pub cost: f64,
129    pub input_tokens: u64,
130    pub output_tokens: u64,
131}
132
133/// Per-skill aggregation for one session.
134///
135/// Skill name comes from `attributionSkill` on assistant entries
136/// (Claude Code 2.1.138+).
137#[derive(Debug, Clone, Serialize, Deserialize)]
138#[serde(rename_all = "camelCase")]
139pub struct SkillUsage {
140    pub skill: String,
141    pub turns: u64,
142    pub cost: f64,
143    pub input_tokens: u64,
144    pub output_tokens: u64,
145}
146
147/// Per-hook aggregation for one session.
148///
149/// Hooks come from `system` entries with `subtype == "stop_hook_summary"`
150/// (Claude Code 2.1.104+). Grouped by `hookInfos[].command`.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152#[serde(rename_all = "camelCase")]
153pub struct HookUsage {
154    pub command: String,
155    pub invocations: u64,
156    pub total_duration_ms: u64,
157    pub error_count: u64,
158    pub prevented_continuation_count: u64,
159}
160
161/// Aggregated data from a single session.
162#[derive(Debug, Clone)]
163pub struct SessionData {
164    pub session_id: String,
165    pub project: Option<String>,
166    pub turns: Vec<ValidatedTurn>,
167    /// Subagent groups for this session. Each entry corresponds to one
168    /// `agent-<id>.jsonl` file. Empty for sessions without subagents.
169    pub subagents: Vec<Subagent>,
170    /// Plugins used in this session (aggregated from main session turns'
171    /// `attributionPlugin`). Empty for pre-2.1.138 sessions.
172    pub plugins: Vec<PluginUsage>,
173    /// Skills used in this session (aggregated from main session turns'
174    /// `attributionSkill`). Empty for pre-2.1.138 sessions.
175    pub skills: Vec<SkillUsage>,
176    /// Hooks triggered in this session (from `stop_hook_summary` system
177    /// entries). Empty for sessions without hooks.
178    pub hooks: Vec<HookUsage>,
179    pub first_timestamp: Option<DateTime<Utc>>,
180    pub last_timestamp: Option<DateTime<Utc>>,
181    pub version: Option<String>,
182    pub quality: DataQuality,
183    pub metadata: SessionMetadata,
184    /// Orphan session: the parent main session `.jsonl` was deleted, but
185    /// subagent files under `<uuid>/subagents/` still exist. Scanner picked
186    /// them up and the loader created this session as a placeholder so the
187    /// subagent data isn't lost. Turn / token / cost totals still include
188    /// these sessions; this flag only marks them for separate display.
189    pub is_orphan: bool,
190}
191
192/// Per-`agent_type` rollup of all subagent invocations within one session.
193///
194/// One session may invoke the same agent type (e.g. `builder`) many times;
195/// each invocation produces its own `agent-<id>.jsonl` file and one
196/// `Subagent` instance. This struct groups those instances by `agent_type`
197/// so the UI can render a single chip per type with a call count.
198///
199/// Subagents whose `agent_type` is `None` (no `.meta.json` sidecar) are
200/// grouped under the literal type `"unknown"` rather than dropped.
201#[derive(Debug, Clone, Serialize, Deserialize)]
202#[serde(rename_all = "camelCase")]
203pub struct SubagentTypeAggregate {
204    pub agent_type: String,
205    pub count: u64,
206    pub total_turns: u64,
207    pub total_cost: f64,
208    pub total_input_tokens: u64,
209    pub total_output_tokens: u64,
210    /// Descriptions from each invocation of this agent type, in deterministic
211    /// order (sorted by agent_id). Empty strings are omitted.
212    pub descriptions: Vec<String>,
213}
214
215// ─── Session Metadata ───────────────────────────────────────────────────────
216
217/// PR link info extracted from pr-link entries.
218#[derive(Debug, Clone, serde::Serialize)]
219pub struct PrLinkInfo {
220    pub number: u64,
221    pub url: String,
222    pub repository: String,
223}
224
225/// A committed context collapse event.
226#[derive(Debug, Clone)]
227pub struct CollapseCommit {
228    pub collapse_id: String,
229    pub summary: String,
230}
231
232/// Snapshot of context collapse risk state.
233#[derive(Debug, Clone)]
234pub struct CollapseSnapshot {
235    pub staged_count: usize,
236    pub avg_risk: f64,
237    pub max_risk: f64,
238    pub armed: bool,
239    pub last_spawn_tokens: u64,
240}
241
242/// Attribution data extracted from attribution-snapshot entries.
243#[derive(Debug, Clone, serde::Serialize)]
244pub struct AttributionData {
245    pub surface: String,
246    pub file_count: usize,
247    pub total_claude_contribution: u64,
248    pub prompt_count: Option<u64>,
249    pub escape_count: Option<u64>,
250    pub permission_prompt_count: Option<u64>,
251}
252
253/// Metadata collected from non-assistant/user entries during parsing.
254#[derive(Debug, Default, Clone)]
255pub struct SessionMetadata {
256    pub title: Option<String>, // custom-title > ai-title
257    pub tags: Vec<String>,
258    pub mode: Option<String>, // last-wins
259    pub pr_links: Vec<PrLinkInfo>,
260    pub speculation_accepts: usize,
261    pub speculation_time_saved_ms: f64,
262    pub queue_enqueues: usize,
263    pub queue_dequeues: usize,
264    pub api_error_count: usize,   // assistant entries with api_error/error
265    pub user_prompt_count: usize, // count of user entries
266    pub collapse_commits: Vec<CollapseCommit>,
267    pub collapse_snapshot: Option<CollapseSnapshot>,
268    pub attribution: Option<AttributionData>,
269}
270
271impl SessionData {
272    /// All API responses (main + every subagent), sorted by timestamp.
273    pub fn all_responses(&self) -> Vec<&ValidatedTurn> {
274        let mut all: Vec<&ValidatedTurn> = self
275            .turns
276            .iter()
277            .chain(self.subagents.iter().flat_map(|s| s.turns.iter()))
278            .collect();
279        all.sort_by_key(|r| r.timestamp);
280        all
281    }
282
283    /// Total number of API responses (main + all subagent turns).
284    pub fn total_turn_count(&self) -> usize {
285        self.turns.len() + self.subagents.iter().map(|s| s.turns.len()).sum::<usize>()
286    }
287
288    /// Total number of agent API responses (sum across all subagents).
289    pub fn agent_turn_count(&self) -> usize {
290        self.subagents.iter().map(|s| s.turns.len()).sum::<usize>()
291    }
292
293    /// Group this session's subagents by `agent_type` for chip rendering.
294    ///
295    /// Each output entry corresponds to one `agent_type` string. Subagents
296    /// with `agent_type = None` are grouped under the literal type
297    /// `"unknown"` (data is preserved, never dropped). Output is sorted by
298    /// `agent_type` ascending for deterministic JSON serialization.
299    ///
300    /// Token / cost totals are summed across each subagent's turns; the
301    /// `count` field counts the number of `Subagent` instances per type
302    /// (i.e. how many times that type was invoked in this session).
303    pub fn subagent_type_aggregates(
304        &self,
305        calc: &crate::pricing::calculator::PricingCalculator,
306    ) -> Vec<SubagentTypeAggregate> {
307        use std::collections::BTreeMap;
308
309        // Sort subagents by agent_id so descriptions land in deterministic order.
310        let mut sorted: Vec<&Subagent> = self.subagents.iter().collect();
311        sorted.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
312
313        let mut acc: BTreeMap<String, SubagentTypeAggregate> = BTreeMap::new();
314        for sa in sorted {
315            let key = sa
316                .agent_type
317                .clone()
318                .filter(|s| !s.is_empty())
319                .unwrap_or_else(|| "unknown".to_string());
320            let mut sa_input: u64 = 0;
321            let mut sa_output: u64 = 0;
322            let mut sa_cost: f64 = 0.0;
323            for t in &sa.turns {
324                sa_input += t.usage.input_tokens.unwrap_or(0);
325                sa_output += t.usage.output_tokens.unwrap_or(0);
326                sa_cost += calc.calculate_turn_cost(&t.model, &t.usage).total;
327            }
328            let entry = acc
329                .entry(key.clone())
330                .or_insert_with(|| SubagentTypeAggregate {
331                    agent_type: key,
332                    count: 0,
333                    total_turns: 0,
334                    total_cost: 0.0,
335                    total_input_tokens: 0,
336                    total_output_tokens: 0,
337                    descriptions: Vec::new(),
338                });
339            entry.count += 1;
340            entry.total_turns += sa.turns.len() as u64;
341            entry.total_cost += sa_cost;
342            entry.total_input_tokens += sa_input;
343            entry.total_output_tokens += sa_output;
344            if let Some(desc) = sa.description.as_ref().filter(|d| !d.is_empty()) {
345                entry.descriptions.push(desc.clone());
346            }
347        }
348        acc.into_values().collect()
349    }
350}
351
352/// Quality metrics for a single session file.
353#[derive(Debug, Default, Clone)]
354pub struct DataQuality {
355    pub total_lines: usize,
356    pub valid_turns: usize,
357    pub skipped_synthetic: usize,
358    pub skipped_sidechain: usize,
359    pub skipped_invalid: usize,
360    pub skipped_parse_error: usize,
361    pub duplicate_turns: usize,
362}
363
364/// Quality metrics aggregated across all session files.
365#[derive(Debug, Default, Clone, Serialize)]
366pub struct GlobalDataQuality {
367    pub total_session_files: usize,
368    pub total_agent_files: usize,
369    pub orphan_agents: usize,
370    pub total_valid_turns: usize,
371    pub total_skipped: usize,
372    pub time_range: Option<(DateTime<Utc>, DateTime<Utc>)>,
373}
374
375// ─── Tests ───────────────────────────────────────────────────────────────────
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn test_parse_assistant_message() {
383        let json = r#"{"parentUuid":"abc","isSidechain":false,"type":"assistant","uuid":"def","timestamp":"2026-03-16T13:51:35.912Z","message":{"model":"claude-opus-4-6","role":"assistant","stop_reason":"end_turn","usage":{"input_tokens":3,"cache_creation_input_tokens":1281,"cache_read_input_tokens":15204,"cache_creation":{"ephemeral_5m_input_tokens":1281,"ephemeral_1h_input_tokens":0},"output_tokens":108,"service_tier":"standard"},"content":[{"type":"text","text":"Hello"}]},"sessionId":"abc-123","version":"2.0.77","cwd":"/tmp","gitBranch":"main","userType":"external","requestId":"req_1"}"#;
384
385        let entry: cc_session_jsonl::types::Entry = serde_json::from_str(json).unwrap();
386
387        match entry {
388            cc_session_jsonl::types::Entry::Assistant(msg) => {
389                assert_eq!(msg.uuid.as_deref(), Some("def"));
390                assert_eq!(msg.session_id.as_deref(), Some("abc-123"));
391                assert_eq!(msg.request_id.as_deref(), Some("req_1"));
392                assert_eq!(msg.parent_uuid.as_deref(), Some("abc"));
393                assert_eq!(msg.is_sidechain, Some(false));
394
395                let api = msg.message.unwrap();
396                assert_eq!(api.model.as_deref(), Some("claude-opus-4-6"));
397                assert_eq!(api.stop_reason.as_deref(), Some("end_turn"));
398
399                let usage: TokenUsage = api.usage.unwrap().into();
400                assert_eq!(usage.input_tokens, Some(3));
401                assert_eq!(usage.output_tokens, Some(108));
402                assert_eq!(usage.cache_creation_input_tokens, Some(1281));
403                assert_eq!(usage.cache_read_input_tokens, Some(15204));
404                assert_eq!(usage.service_tier.as_deref(), Some("standard"));
405
406                let cache = usage.cache_creation.unwrap();
407                assert_eq!(cache.ephemeral_5m_input_tokens, Some(1281));
408                assert_eq!(cache.ephemeral_1h_input_tokens, Some(0));
409
410                let content = api.content.unwrap();
411                assert_eq!(content.len(), 1);
412                match &content[0] {
413                    cc_session_jsonl::types::ContentBlock::Text { text } => {
414                        assert_eq!(text.as_deref(), Some("Hello"));
415                    }
416                    _ => panic!("expected Text content block"),
417                }
418            }
419            _ => panic!("expected Assistant variant"),
420        }
421    }
422
423    #[test]
424    fn test_parse_user_message() {
425        let json = r#"{"parentUuid":null,"isSidechain":false,"type":"user","message":{"role":"user","content":[{"type":"text","text":"hello"}]},"uuid":"u1","timestamp":"2026-03-16T13:51:19.053Z","sessionId":"s1","version":"2.1.80","cwd":"/tmp","gitBranch":"main","userType":"external"}"#;
426
427        let entry: cc_session_jsonl::types::Entry = serde_json::from_str(json).unwrap();
428
429        match entry {
430            cc_session_jsonl::types::Entry::User(msg) => {
431                assert_eq!(msg.uuid.as_deref(), Some("u1"));
432                assert_eq!(msg.session_id.as_deref(), Some("s1"));
433                assert_eq!(msg.version.as_deref(), Some("2.1.80"));
434                assert_eq!(msg.cwd.as_deref(), Some("/tmp"));
435                assert_eq!(msg.git_branch.as_deref(), Some("main"));
436                assert!(msg.parent_uuid.is_none());
437            }
438            _ => panic!("expected User variant"),
439        }
440    }
441
442    #[test]
443    fn test_parse_queue_operation() {
444        let json = r#"{"type":"queue-operation","operation":"dequeue","timestamp":"2026-03-16T13:51:19.041Z","sessionId":"abc"}"#;
445
446        let entry: cc_session_jsonl::types::Entry = serde_json::from_str(json).unwrap();
447
448        match entry {
449            cc_session_jsonl::types::Entry::QueueOperation(val) => {
450                assert_eq!(val.operation.as_deref(), Some("dequeue"));
451                assert_eq!(val.session_id.as_deref(), Some("abc"));
452            }
453            _ => panic!("expected QueueOperation variant"),
454        }
455    }
456
457    #[test]
458    fn test_parse_progress_entry() {
459        let json = r#"{"type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"toolUseID":"toolu_01","parentToolUseID":"toolu_01","uuid":"u1","timestamp":"2026-03-16T13:51:19.053Z","sessionId":"s1"}"#;
460        let entry: cc_session_jsonl::types::Entry = serde_json::from_str(json).unwrap();
461        match entry {
462            cc_session_jsonl::types::Entry::Progress(p) => {
463                assert_eq!(p.tool_use_id.as_deref(), Some("toolu_01"));
464                match p.data.unwrap() {
465                    cc_session_jsonl::types::ProgressData::HookProgress {
466                        hook_event,
467                        hook_name,
468                        command,
469                    } => {
470                        assert_eq!(hook_event.as_deref(), Some("PostToolUse"));
471                        assert_eq!(hook_name.as_deref(), Some("PostToolUse:Read"));
472                        assert_eq!(command.as_deref(), Some("callback"));
473                    }
474                    other => panic!("expected HookProgress, got {other:?}"),
475                }
476            }
477            other => panic!("expected Progress, got {other:?}"),
478        }
479    }
480
481    #[test]
482    fn test_parse_system_entry() {
483        let json = r#"{"type":"system","subtype":"turn_duration","durationMs":1234,"uuid":"u1","timestamp":"2026-03-16T13:51:19.053Z","sessionId":"s1"}"#;
484        let entry: cc_session_jsonl::types::Entry = serde_json::from_str(json).unwrap();
485        assert!(matches!(entry, cc_session_jsonl::types::Entry::System(_)));
486    }
487
488    #[test]
489    fn test_parse_unknown_entry_type() {
490        let json = r#"{"type":"some-future-type","data":"whatever","uuid":"u1","timestamp":"2026-03-16T13:51:19.053Z"}"#;
491        let entry: cc_session_jsonl::types::Entry = serde_json::from_str(json).unwrap();
492        assert!(matches!(entry, cc_session_jsonl::types::Entry::Unknown));
493    }
494
495    #[test]
496    fn test_parse_thinking_content_block() {
497        let json = r#"{"type":"assistant","uuid":"u1","timestamp":"2026-03-16T10:00:00Z","message":{"model":"claude-opus-4-6","role":"assistant","stop_reason":"end_turn","usage":{"input_tokens":3,"output_tokens":100,"cache_creation_input_tokens":500,"cache_read_input_tokens":10000},"content":[{"type":"thinking","thinking":"Let me analyze this...","signature":"abc123"},{"type":"text","text":"Here is my answer."}]},"sessionId":"s1","cwd":"/tmp","gitBranch":"","userType":"external","isSidechain":false,"parentUuid":null,"requestId":"r1"}"#;
498        let entry: cc_session_jsonl::types::Entry = serde_json::from_str(json).unwrap();
499        match entry {
500            cc_session_jsonl::types::Entry::Assistant(msg) => {
501                let content = msg.message.unwrap().content.unwrap();
502                assert_eq!(content.len(), 2);
503                assert!(
504                    matches!(&content[0], cc_session_jsonl::types::ContentBlock::Thinking { thinking: Some(t), .. } if t.contains("analyze"))
505                );
506                assert!(matches!(
507                    &content[1],
508                    cc_session_jsonl::types::ContentBlock::Text { .. }
509                ));
510            }
511            _ => panic!("expected Assistant variant"),
512        }
513    }
514
515    #[test]
516    fn test_parse_synthetic_message() {
517        let json = r#"{"type":"assistant","uuid":"x","timestamp":"2026-03-16T00:00:00Z","message":{"model":"<synthetic>","role":"assistant","stop_reason":"stop_sequence","usage":{"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0},"content":[{"type":"text","text":"error"}]},"sessionId":"s1","cwd":"/tmp","gitBranch":"","userType":"external","isSidechain":false,"parentUuid":null}"#;
518
519        let entry: cc_session_jsonl::types::Entry = serde_json::from_str(json).unwrap();
520
521        match entry {
522            cc_session_jsonl::types::Entry::Assistant(msg) => {
523                let api = msg.message.unwrap();
524                assert_eq!(api.model.as_deref(), Some("<synthetic>"));
525                assert_eq!(api.stop_reason.as_deref(), Some("stop_sequence"));
526
527                let usage: TokenUsage = api.usage.unwrap().into();
528                assert_eq!(usage.input_tokens, Some(0));
529                assert_eq!(usage.output_tokens, Some(0));
530
531                // synthetic messages typically lack cache_creation detail
532                assert!(usage.cache_creation.is_none());
533            }
534            _ => panic!("expected Assistant variant"),
535        }
536    }
537
538    #[test]
539    fn test_token_usage_from_conversion() {
540        let lib_usage = cc_session_jsonl::types::Usage {
541            input_tokens: Some(100),
542            output_tokens: Some(200),
543            cache_creation_input_tokens: Some(50),
544            cache_read_input_tokens: Some(300),
545            cache_creation: Some(cc_session_jsonl::types::CacheCreation {
546                ephemeral_5m_input_tokens: Some(30),
547                ephemeral_1h_input_tokens: Some(20),
548            }),
549            server_tool_use: Some(cc_session_jsonl::types::ServerToolUse {
550                web_search_requests: Some(2),
551                web_fetch_requests: Some(1),
552            }),
553            service_tier: Some("standard".into()),
554            inference_geo: Some("us".into()), // dropped in conversion
555            iterations: None,                 // dropped in conversion
556            speed: Some("fast".into()),
557        };
558
559        let local: TokenUsage = lib_usage.into();
560        assert_eq!(local.input_tokens, Some(100));
561        assert_eq!(local.output_tokens, Some(200));
562        assert_eq!(local.cache_creation_input_tokens, Some(50));
563        assert_eq!(local.cache_read_input_tokens, Some(300));
564        assert_eq!(local.service_tier.as_deref(), Some("standard"));
565        assert_eq!(local.speed.as_deref(), Some("fast"));
566
567        let cache = local.cache_creation.unwrap();
568        assert_eq!(cache.ephemeral_5m_input_tokens, Some(30));
569        assert_eq!(cache.ephemeral_1h_input_tokens, Some(20));
570
571        let stu = local.server_tool_use.unwrap();
572        assert_eq!(stu.web_search_requests, Some(2));
573        assert_eq!(stu.web_fetch_requests, Some(1));
574    }
575}