Repository navigation
[G1] Usage normalization in core: per-request usage records, session summary, and moving attribute_session_usage out of the cloud plugin (supersedes #99 scope) #172
Copy link
Copy link
Closed
Description
Part of #160 (group 1 — independent). Extends #99 ("provider-neutral session usage summary API"); #99's acceptance criteria are included here and #99 should be closed as a duplicate when this lands, or this issue narrowed if #99 ships first.
Problem
Usage exists in the store but is not normalized where consumers can reach it:
- Claude:
message.usageis copied verbatim intotoken_jsonon every content block of the message (crates/ai-hist/src/lib.rs:3070-3072). Counting rows multiplies one request by its block count. - Codex:
token_countcumulative snapshots are differenced into per-request deltas and attached to the nearest assistant event (lib.rs:2251-2289,CodexTokenTotals:1979, invariant at:2070-2073: summingtoken_jsonover a session equals the session total). - The only normalizer/attributor lives in the commercial plugin:
plugins/relayhistory/rust/src/outbox.rs:397attribute_session_usage(groups bymessage_id, dedups identical per-block copies at:415-424, walksparent_idto the owning prompt at:511, refuses to attribute on ambiguity) and:528parse_token_usage(Codexinput - cached_input,cached_input,cache_write; Claudeinput_tokens,cache_read_input_tokens,cache_creation_input_tokens;_ => Nonefor every other source). Its output typeTokenUsage { input, output, reasoning, cacheRead, cacheCreate }(convergence.rs:61) collapses Claude'sephemeral_5m/ephemeral_1hsplit that burn prices differently (Usage.cache_create_5m/1h, burnreader/types.rs:111-120). - No
providerfield anywhere (burn infers it inanalyze/provider.rs+provider_reattribution.rs, incl.hf:*,accounts/fireworks/models/*,synthetic/*prefixes).
burn's Inference (reader/inference.rs:92-127) is "one API request": {request_id, request_id_source, turn_id, model, usage, kind, tool_uses, start/end} grouped by Claude requestId (falls back to message_id). relayhistory should own that grouping since it owns the raw fields (see the raw-facts issue for request_id).
Scope
- Core normalized usage type in
ai-hist-core:pluspub struct NormalizedUsage { pub input_tokens: u64, // excludes cache reads pub output_tokens: u64, pub reasoning_tokens: Option<u64>, pub cache_read_tokens: u64, pub cache_write_tokens: u64, // total pub cache_write_5m_tokens: Option<u64>, pub cache_write_1h_tokens: Option<u64>, pub provider_total_tokens: Option<u64>, // as reported; never recomputed pub reported_cost_usd: Option<f64>, // only if the provider wrote one pub accounting: UsageAccounting, // PerRequest | PerMessage | CumulativeDelta | ContextProxy }
fn normalize_usage(source, token_json) -> Result<Option<NormalizedUsage>, UsageError>with deterministic handling of negative/non-finite/missing values (error, never clamp silently) — portparse_token_usagehere and make the plugin call core. - Per-request records:
session_requestsview or table — one row per(source, session_id, request_key)whererequest_key = request_idwhen present elsemessage_id, withmodel,provider(from the OpenCode column, else null),ts_msfirst/last,usage_json(normalized),tool_use_ids_json,has_thinking. Populated at hydration time so it is incremental. Read APIsession_requests_page(...)keyset(ts_ms, id). - Session summary (
#99):session_usage_summary(source, session_id) -> Option<SessionUsageSummary>aggregatingsession_requestsexactly once per request,Nonewhen no usage evidence, bounded memory (SQL aggregate, not a full event load). - Prompt attribution: move
attribute_session_usage+parent_promptfromoutbox.rsinto core asattribute_usage_to_prompts(...)with the same refusal semantics; the plugin becomes a thin caller. Keep its tests, move them. - Expose on napi/TS/MCP:
getSessionUsage,getSessionRequestsPage; contract bumps. - Docs:
docs/usage-accounting.mdstating per-source accounting mode, the dedup rule, and "cost is never estimated here;reported_cost_usdonly when present in source data".
Acceptance
multi-block-turn(Claude): onesession_requestsrow perrequestId; summary input/output equal burn'sTurnRecord.usagefor the same fixture.- Codex
compaction: per-request deltas sum to the final cumulative total; a regressed counter yields aUSAGE_COUNTER_REGRESSEDdiagnostic, not a negative delta. missing-output-tokens:output_tokensabsent → summary reportsoutput_tokens: 0with acoveragenote, orNoneif no usage at all (decide and document).- Claude
cache_creation.ephemeral_*values are preserved on the normalized record. plugins/relayhistorycompiles against the core function with its existingdestination.rs/token.rstests unchanged.
Out of scope
Pricing/cost computation; provider inference from model strings (burn keeps analyze/provider.rs).
Reactions are currently unavailable
Activity