Repository navigation
feat(usage): parse real Claude JSON receipts #299
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| //! Pure parser for one completed `claude -p --output-format json` capture. | ||
| //! Runtime and ledger wiring belong to a later serial package. | ||
| use super::usage_receipt::UsageReceipt; | ||
| use serde_json::Value; | ||
| use sha2::{Digest, Sha256}; | ||
|
|
||
| pub(in crate::store) const CLAUDE_COLLECTOR: &str = "claude-print-json-v1"; | ||
| pub(in crate::store) const CLAUDE_TRANSPORT: &str = "native_claude_print_json"; | ||
|
|
||
| /// Classify one complete process-owned capture without touching the store. | ||
| pub(in crate::store) fn complete_usage(stdout: &[u8], exit_code: Option<i32>) -> UsageReceipt { | ||
| let source_sha256 = format!("{:x}", Sha256::digest(stdout)); | ||
| let rejected = |reason| UsageReceipt::Rejected { | ||
| reason, | ||
| source_sha256: source_sha256.clone(), | ||
| collector: CLAUDE_COLLECTOR, | ||
| }; | ||
| if exit_code != Some(0) { | ||
| return rejected("process exit code is not zero"); | ||
| } | ||
| if stdout.is_empty() { | ||
| return rejected("capture is empty"); | ||
| } | ||
| if stdout.len() > 1_048_576 { | ||
| return rejected("capture exceeds 1 MiB"); | ||
| } | ||
| let result: Value = match serde_json::from_slice(stdout) { | ||
| Ok(value) => value, | ||
| Err(_) => return rejected("capture is not one JSON value"), | ||
| }; | ||
| if result["type"] != "result" || result["subtype"] != "success" || result["is_error"] != false { | ||
| return rejected("provider reported an error result"); | ||
| } | ||
| let Some(usage) = result.get("usage").filter(|value| value.is_object()) else { | ||
| return UsageReceipt::NotReported { | ||
|
Comment on lines
+34
to
+35
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a successful Claude response contains AGENTS.md reference: AGENTS.md:L87-L89 Useful? React with 👍 / 👎. |
||
| provider: "claude".into(), | ||
| transport: CLAUDE_TRANSPORT.into(), | ||
| }; | ||
| }; | ||
| // Claude reports these as separate categories. `modelUsage` and the cost | ||
| // fields have no counterpart in UsageReceipt, so this parser does not | ||
| // guess a model or turn a list-cost field into subscription billing. | ||
| let mut tokens = 0_i64; | ||
| for field in [ | ||
| "input_tokens", | ||
| "cache_creation_input_tokens", | ||
| "cache_read_input_tokens", | ||
| "output_tokens", | ||
|
Comment on lines
+44
to
+48
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a Claude run invokes the Advisor tool, the top-level usage counters cover executor iterations only, while the advisor's tokens are reported separately in Useful? React with 👍 / 👎. |
||
| ] { | ||
| let Some(count) = usage.get(field).and_then(Value::as_i64).filter(|n| *n >= 0) else { | ||
| return rejected("usage count is missing, negative or not an integer"); | ||
| }; | ||
| let Some(total) = tokens | ||
| .checked_add(count) | ||
| .filter(|total| *total <= 1_000_000_000) | ||
| else { | ||
| return rejected("usage total exceeds the ledger limit"); | ||
| }; | ||
| tokens = total; | ||
| } | ||
| UsageReceipt::Measured { | ||
| tokens, | ||
| source_sha256, | ||
| collector: CLAUDE_COLLECTOR, | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| const REAL_CAPTURE: &[u8] = include_bytes!("fixtures/claude-print-json-2.1.287.json"); | ||
|
|
||
| #[test] | ||
| fn real_claude_2_1_287_json_maps_observed_token_counters() { | ||
| let receipt = complete_usage(REAL_CAPTURE, Some(0)); | ||
| assert_eq!(receipt.state(), "measured"); | ||
| // 2 uncached + 1,857 cache creation + 535 cache read + 4 output. | ||
| assert_eq!(receipt.tokens(), Some(2_398)); | ||
| assert!(receipt | ||
| .ledger_source() | ||
| .unwrap() | ||
| .starts_with("claude-print-json-v1:sha256:")); | ||
| assert_eq!( | ||
| receipt.to_json(Some(1))["provenance"]["collector"], | ||
| CLAUDE_COLLECTOR | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn claude_json_without_usage_is_not_reported_instead_of_zero() { | ||
| let receipt = complete_usage( | ||
| br#"{"type":"result","subtype":"success","is_error":false}"#, | ||
| Some(0), | ||
| ); | ||
| assert_eq!(receipt.state(), "not_reported"); | ||
| assert_eq!(receipt.tokens(), None); | ||
| assert!(receipt.to_json(Some(1))["reason"] | ||
| .as_str() | ||
| .unwrap() | ||
| .contains("JSON result did not include a usage object")); | ||
| } | ||
|
|
||
| #[test] | ||
| fn negative_or_missing_claude_counters_are_rejected() { | ||
| for usage in [ | ||
| serde_json::json!({"input_tokens":-1,"cache_creation_input_tokens":2, | ||
| "cache_read_input_tokens":3,"output_tokens":4}), | ||
| serde_json::json!({"input_tokens":1,"cache_creation_input_tokens":2, | ||
| "output_tokens":4}), | ||
| serde_json::json!({"input_tokens":1,"cache_creation_input_tokens":2, | ||
| "cache_read_input_tokens":3,"output_tokens":null}), | ||
| ] { | ||
| let capture = serde_json::json!({"type":"result","subtype":"success", | ||
| "is_error":false,"usage":usage}); | ||
| let receipt = complete_usage(capture.to_string().as_bytes(), Some(0)); | ||
| assert_eq!(receipt.state(), "rejected", "{capture}"); | ||
| assert_eq!(receipt.tokens(), None); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn claude_error_run_is_rejected_instead_of_zero() { | ||
| let capture = serde_json::json!({"type":"result","subtype":"error", | ||
| "is_error":true,"usage":{"input_tokens":1,"cache_creation_input_tokens":2, | ||
| "cache_read_input_tokens":3,"output_tokens":4}}); | ||
| for exit_code in [Some(0), Some(1), None] { | ||
| let receipt = complete_usage(capture.to_string().as_bytes(), exit_code); | ||
| assert_eq!(receipt.state(), "rejected", "{exit_code:?}"); | ||
| assert_eq!(receipt.tokens(), None); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| { | ||
| "duration_api_ms": 1123, | ||
| "stop_reason": "end_turn", | ||
| "session_id": "<session-id>", | ||
| "total_cost_usd": "<redacted>", | ||
| "usage": { | ||
| "input_tokens": 2, | ||
| "cache_creation_input_tokens": 1857, | ||
| "cache_read_input_tokens": 535, | ||
| "output_tokens": 4, | ||
| "output_tokens_details": { "thinking_tokens": 0 }, | ||
| "server_tool_use": { "web_search_requests": 0, "web_fetch_requests": 0 }, | ||
| "service_tier": "standard", | ||
| "cache_creation": { | ||
| "ephemeral_1h_input_tokens": 1857, | ||
| "ephemeral_5m_input_tokens": 0 | ||
| }, | ||
| "inference_geo": "not_available", | ||
| "iterations": [ | ||
| { | ||
| "input_tokens": 2, | ||
| "output_tokens": 4, | ||
| "cache_read_input_tokens": 535, | ||
| "cache_creation_input_tokens": 1857, | ||
| "cache_creation": { | ||
| "ephemeral_5m_input_tokens": 0, | ||
| "ephemeral_1h_input_tokens": 1857 | ||
| }, | ||
| "type": "message" | ||
| } | ||
| ], | ||
| "speed": "standard", | ||
| "fallback_credit": null | ||
| }, | ||
| "modelUsage": { | ||
| "claude-sonnet-5-5": { | ||
| "inputTokens": 2, | ||
| "outputTokens": 4, | ||
| "cacheReadInputTokens": 535, | ||
| "cacheCreationInputTokens": 1857, | ||
| "webSearchRequests": 0, | ||
| "costUSD": "<redacted>", | ||
| "contextWindow": 1000000, | ||
| "maxOutputTokens": 128000, | ||
| "thinkingTokens": 0, | ||
| "canonicalModel": "claude-sonnet-5-5", | ||
| "provider": "firstParty", | ||
| "costBasis": "list" | ||
| } | ||
| }, | ||
| "permission_denials": [], | ||
| "terminal_reason": "completed", | ||
| "fast_mode_state": "off", | ||
| "fast_mode_disabled_reason": "sdk_opt_in_required", | ||
| "is_error": false, | ||
| "num_turns": 1, | ||
| "subtype": "success", | ||
| "api_error_status": null, | ||
| "result": "OK", | ||
| "type": "result", | ||
| "duration_ms": 1311, | ||
| "uuid": "<result-id>", | ||
| "result_index": 0 | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This package changes files under
src-tauri/src/store/, which the repository rules explicitly include in a serial seam, but this journal entry schedules only a Tier B review. That classification obtains one reviewer instead of the two cross-vendor reviewers required for seam changes, so reclassify the package as Tier A and record both review dispositions before accepting it.AGENTS.md reference: AGENTS.md:L26-L32
Useful? React with 👍 / 👎.