Skip to content

proposal: canonical model/usage capture at the adapter seam (typed fields + OTel-aligned analytics columns) #98

Description

@tenequm

Summary

Proposal: graduate per-response model + token usage from options.<provider>.* into typed, optional canonical fields on the assistant message, extracted at the adapter parse face (seam-enforced), materialized as narrow analytics columns on messages, and backfilled in place on existing stores. Follow-on to the parts.tool_name/call_id/is_failure materialization (#89) and the additive-schema-migration mechanism that lands with it.

This is a PROPOSAL issue: the design below was researched in depth but deliberately NOT implemented yet. Two semantic questions (OTel normalization, provider column) need a decision before code.

Why

  • pond's own usage evidence (all 601 historical pond_sql_query calls analyzed): variant_data tool analytics is the top pattern (covered by Remote-store SQL analytics over parts.variant_data time out: compress JSON columns + materialize tool columns #89), and messages.options model/usage drilling is second - anthropic.usage.* ~44 path hits, anthropic.model 17. Token-spend and model-mix analytics are real, recurring, and currently require provider-specific JSON drilling that cannot be uniform across agents.
  • The goal is agentsview-class analytics (usage_events, cost, model mix) served uniformly from pond for ANY agentic client, including the ~100+ adapters planned. That requires agent-agnostic columns, not per-provider JSON paths.

Ground truth (verified against real sources and the live store, 2026-07-07)

Every current source records model + usage; pond's lossless guarantee held everywhere - nothing needs re-ingest:

Adapter Where it lives in the store today Granularity
claude-code (+ subagents) options.anthropic.model / .usage (same row) per assistant row; modern CLI repeats one API response's envelope on EVERY row of that response (verified 59/59 rows, 18 responses); older CLI wrote it on one row only, continuation rows are literally {content, role}
claude-desktop-app options.anthropic.* (same API shape) per row
opencode options.source.raw_record (modelID, providerID, tokens{input,output,reasoning,cache.read,cache.write}, cost) per assistant message
pi-coding-agent options.pi namespace, already extracted (model, provider, api, usage{input,output,cacheRead,cacheWrite}, cost, stopReason) per assistant message
codex-cli rule-3 system rows: 30,022 event_msg records (incl. token_count with last_token_usage{input,cached_input,output,reasoning_output}) + 3,595 turn_context records (model, effort) per turn / per API call, on SIBLING rows
claude-ai-export to verify against fixtures; the export format likely carries no per-message usage (genuine absence) -

Future adapters with fixtures already in-repo: nanoclaw (claude-code-shaped, anthropic envelope), openclaw (model_change events + messages), claude_managed_agents (span.model_request_start/end events). Fixture README "Provider / model recording" row documents the granularity variance: per-message vs per-span vs per-turn.

Key design findings

  1. Extraction must live in the adapter parse face, not core. Only the sequential parse has the context to attribute turn-level (codex) or span-level (managed agents) usage to the right message. Core-side derivation would need adapter-specific record knowledge, which the seam-boundaries rule forbids.
  2. Seam enforcement: model: Option<Extracted<String>> and usage: Option<Usage> as REQUIRED struct fields on the assistant wire variant - every adapter construction site must explicitly decide them (compile error otherwise). Usage counters individually sealed (Option<Extracted<i64>>, new extract_int seam primitive) so unwrap_or(0)-class fabricated counters do not compile. Absence stays honest NULL (model-no-synthesis).
  3. The attribution invariant (proposed new named spec rule): usage is attributed to exactly ONE assistant message per provider-reported accounting event, as close to the generating turn as the source allows. Modern claude-code repeats one response's envelope on every row - naive per-row copying inflates SUM(tokens) ~4x. Rule: first row seen per API message.id carries it; later rows of the same response derive NULL. Codex: each token_count event attributes to the most recent unattributed assistant message. This is what makes SUM() over messages equal provider-reported totals.
  4. Conformance extension (spec 6.8 already mandates per-adapter round-trip tests): every adapter's fixtures must demonstrate extraction of Some(model) + Some(usage) on at least one row, or carry an explicit documented opt-out in code. "Forgot the mapping" becomes red CI, never a silent NULL.
  5. Storage: 5-6 nullable columns on messages (see open questions), derived-storage class like the embedding columns (spec 5.5 precedent). Backfill rides the additive-migration mechanism from Remote-store SQL analytics over parts.variant_data time out: compress JSON columns + materialize tool columns #89/PR1: same-row mapper for anthropic/opencode/pi; codex needs a session-scoped second pass replaying the stored system rows in order (turn_context model carry-forward + token_count attribution), filled via the same partial-update machinery the embed pass uses.

Spec amendments required

  • Spec 4.6 currently says the OPPOSITE, deliberately: "Turn-level metadata - model, token usage, finish reason, error - is not a canonical field; clients record it on their assistant turns and adapters route it to options.<provider>.*." Graduating model/usage amends this. Justification: they are stable conversational-layer facts (every one of the 9 fixture platforms records them; OTel standardizes them), not volatile harness behavior - and spec 9.6.8 already defers "OTel-compatible projection of the canonical model".
  • Spec 5.1 "no projections, no promotions" needs the derived-columns exception widened (embedding-columns precedent).
  • New named rule for the attribution invariant + the conformance extension.

Open questions to decide BEFORE implementation

  1. OTel normalization vs provider-raw. OTel gen_ai.usage.input_tokens = total input INCLUDING cache; cache_read.input_tokens / cache_creation.input_tokens are sub-counters contained in it; reasoning.output_tokens is contained in output_tokens. Providers disagree: Anthropic raw input_tokens EXCLUDES cache tokens; OpenAI/codex raw input_tokens INCLUDES cached. Storing provider-raw makes cross-agent SUM(input_tokens) incomparable; normalizing to OTel semantics mutates Anthropic's reported number (raw stays verbatim in options). Naming would mirror OTel: input_tokens, output_tokens, reasoning_output_tokens, cache_read_input_tokens, cache_creation_input_tokens. Reference: docs/references/otel-genai-semconv.md section 1.4.
  2. model_provider column (OTel gen_ai.provider.name): pi (ollama), opencode (zai-coding-plan), codex (session_meta.model_provider) record it; disambiguates same model name via different providers. Include alongside model or drop?
  3. finish_reason (OTel gen_ai.response.finish_reasons; pi stopReason, anthropic stop_reason): no historical query evidence - defer or bundle?
  4. cost: opencode/pi record provider-computed cost. Derivable from tokens + pricing tables; only 2 of 9 sources report it. Proposed: leave in options.
  5. Old-format claude-code continuation rows ({content, role}, no message.id): model attribution via parentUuid chain is possible but inferential. Proposed: leave NULL (their usage is captured on the envelope row; only per-message model-mix counts undercount slightly for old sessions).

Recoverability guarantee

All data for all current adapters is recoverable from the store alone (verified above) - the backfill requires no re-ingest and no source access, consistent with session-durable-copy ("re-ingest is not a recovery path").

Dependencies

Prior partial work

A first draft of the wire types existed briefly on feat/parts-zstd-tool-columns (Usage struct + fields on the assistant variant + extract_int) and was reverted when this was split out; see that branch history around 2026-07-07 for reference.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions