Skip to content

[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

Closed

Description

@willwashburn

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.usage is copied verbatim into token_json on 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_count cumulative snapshots are differenced into per-request deltas and attached to the nearest assistant event (lib.rs:2251-2289, CodexTokenTotals :1979, invariant at :2070-2073: summing token_json over a session equals the session total).
  • The only normalizer/attributor lives in the commercial plugin: plugins/relayhistory/rust/src/outbox.rs:397 attribute_session_usage (groups by message_id, dedups identical per-block copies at :415-424, walks parent_id to the owning prompt at :511, refuses to attribute on ambiguity) and :528 parse_token_usage (Codex input - cached_input, cached_input, cache_write; Claude input_tokens, cache_read_input_tokens, cache_creation_input_tokens; _ => None for every other source). Its output type TokenUsage { input, output, reasoning, cacheRead, cacheCreate } (convergence.rs:61) collapses Claude's ephemeral_5m/ephemeral_1h split that burn prices differently (Usage.cache_create_5m/1h, burn reader/types.rs:111-120).
  • No provider field anywhere (burn infers it in analyze/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

  1. Core normalized usage type in ai-hist-core:
    pub 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
    }
    plus fn normalize_usage(source, token_json) -> Result<Option<NormalizedUsage>, UsageError> with deterministic handling of negative/non-finite/missing values (error, never clamp silently) — port parse_token_usage here and make the plugin call core.
  2. Per-request records: session_requests view or table — one row per (source, session_id, request_key) where request_key = request_id when present else message_id, with model, provider (from the OpenCode column, else null), ts_ms first/last, usage_json (normalized), tool_use_ids_json, has_thinking. Populated at hydration time so it is incremental. Read API session_requests_page(...) keyset (ts_ms, id).
  3. Session summary (#99): session_usage_summary(source, session_id) -> Option<SessionUsageSummary> aggregating session_requests exactly once per request, None when no usage evidence, bounded memory (SQL aggregate, not a full event load).
  4. Prompt attribution: move attribute_session_usage + parent_prompt from outbox.rs into core as attribute_usage_to_prompts(...) with the same refusal semantics; the plugin becomes a thin caller. Keep its tests, move them.
  5. Expose on napi/TS/MCP: getSessionUsage, getSessionRequestsPage; contract bumps.
  6. Docs: docs/usage-accounting.md stating per-source accounting mode, the dedup rule, and "cost is never estimated here; reported_cost_usd only when present in source data".

Acceptance

  • multi-block-turn (Claude): one session_requests row per requestId; summary input/output equal burn's TurnRecord.usage for the same fixture.
  • Codex compaction: per-request deltas sum to the final cumulative total; a regressed counter yields a USAGE_COUNTER_REGRESSED diagnostic, not a negative delta.
  • missing-output-tokens: output_tokens absent → summary reports output_tokens: 0 with a coverage note, or None if no usage at all (decide and document).
  • Claude cache_creation.ephemeral_* values are preserved on the normalized record.
  • plugins/relayhistory compiles against the core function with its existing destination.rs/token.rs tests unchanged.

Out of scope

Pricing/cost computation; provider inference from model strings (burn keeps analyze/provider.rs).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions