# Usage normalization contract

This contract is only for usage and context accounting. Text, reasoning, and
tool rendering are separate.

## Canonical shape

```ts
type NormalizedUsage = {
  operation: 'chat_step' | 'chat_turn' | 'compaction' | 'title_generation' | 'tool_or_subagent_child';
  cadence?: 'snapshot' | 'step_final' | 'turn_final';

  modelId: string;          // canonical billing model, resolved server-side
  displayModelId?: string;  // user-facing selection, optional
  runtimeModelId?: string;  // CLI/provider-facing name, optional

  tokenList: {
    input: number;           // uncached input only
    output: number;          // includes reasoning/thought output
    cacheRead: number;
    cacheWrite: number;
    thought: number;         // breakdown of output, not added to total
    total: number;           // input + output + cacheRead + cacheWrite
  };

  inputTokens: number;      // uncached input only
  outputTokens: number;
  cacheReadTokens: number;
  cacheWriteTokens: number;
  thoughtTokens: number;

  totalInputTokens: number; // input + cacheRead + cacheWrite
  totalTokens: number;      // tokenList.total
  inputContextSize: number; // current context footprint for the circle

  contextUsedTokens: number | null; // legacy/wire alias for inputContextSize
  contextWindow: number | null;
  totalProcessedTokens?: number | null;
  billable: boolean;

  costUsd: {
    input: number | null;
    output: number | null;
    cacheRead: number | null;
    cacheWrite: number | null;
    total: number | null;
    source: 'proxy' | 'provider' | 'local_estimate' | 'unknown';
  };

  source: string;           // provider/native source
  exact: boolean;           // safe for billing/logging
  providerSessionId?: string | null;
  parentTurnId?: string | null;
  raw?: unknown;            // audit only, not persisted by default
};
```

## Rules

1. `inputTokens` means uncached input only.
2. Cache reads and cache writes are always separate fields.
3. `totalInputTokens = inputTokens + cacheReadTokens + cacheWriteTokens`.
4. `tokenList.total = input + output + cacheRead + cacheWrite`.
5. `totalTokens = tokenList.total`.
6. `inputContextSize` is the current context footprint used by the circle. For
   step-shaped events it usually equals `tokenList.total`. For aggregate
   turn-final billing records it may differ because `tokenList` is billable
   processed work while `inputContextSize` is the latest context footprint.
7. `thoughtTokens` is a breakdown of output. It is not added separately to
   `inputContextSize`; normalize provider shapes so `outputTokens` already
   includes reasoning/thought tokens.
8. Unknown cost is `null`, never zero.
9. Context window comes from the server-resolved gateway model table, not the CLI
   display name and not the client.
10. Billing model identity is resolved server-side and must not be trusted from
   the browser or from a user-editable CLI model string.
11. BYOK/provider-auth usage is logged from the machine when exact usage is
   available. Amalgm-key usage is logged by the proxy.
12. Live context UI should update whenever a provider emits usage: per turn,
   per step, or per progress event. Cadence may differ; shape should not.
13. Billing uses exact operation deltas, not cumulative thread totals.
14. If a provider emits a step-final usage event, treat that as the billable
    operation unit. If only turn-final usage exists, the turn is the operation
    unit.
15. Context UI may consume snapshots or final operation usage, but it should
    only draw a percentage when `contextWindow` is known.
16. Compaction is a billable operation when provider usage is available. Do not
    derive billing tokens from `preTokens` / `postTokens`; those are context
    state metadata, not price input.

## Operation semantics

Usage has two consumers:

1. Billing / persistence wants exact operation deltas.
2. The context circle wants live snapshots of current context pressure.

The normalized event shape should support both without making the UI infer
provider-specific meaning.

```ts
type UsageSnapshotEvent = {
  type: 'usage.snapshot';
  usage: NormalizedUsage; // cadence: snapshot
};

type UsageOperationEvent = {
  type: 'usage.operation';
  usage: NormalizedUsage; // cadence: step_final | turn_final
};
```

Current wire compatibility may still use `usage.final` / `usage_update`, but the
semantic target is:

- `usage.snapshot`: frequent enough for context UI; not necessarily billable.
- `usage.operation`: exact provider/proxy delta; safe for usage logs.
- While wire compatibility uses `usage.final`, distinguish the two with
  `billable` and `exact`: snapshots use `billable: false, exact: false`;
  operation rows use `billable: true, exact: true`.

If a provider sends only one exact event at turn end, emit one
`usage.operation` with `operation: "chat_turn"` and `cadence: "turn_final"`.

If a provider sends step-final events, emit each as a `usage.operation` with
`cadence: "step_final"`. The chat turn can still complete separately, but billing
must not overwrite/squash earlier step operations with only the last step.

For cumulative providers, compute or select the per-operation delta:

- Prefer native `last` / step delta.
- Store cumulative totals separately only for audit and context.
- After compaction/reset boundaries, never compute a negative delta. Mark the
  baseline reset.

## Context circle

The context circle should consume:

```ts
{
  inputContextSize,
  contextWindow,
  totalProcessedTokens?,
  operation,
  cadence,
  updatedAt
}
```

Rules:

- Use `inputContextSize / contextWindow` for the percentage.
- `contextUsedTokens` may stay on the wire for compatibility, but it should be
  derived from the same computed `inputContextSize`.
- If `contextWindow` is null or zero, show unknown/disabled state rather than
  `used == size`.
- Compute `inputContextSize` the same way for every provider. Do not use native
  cumulative usage for one provider and local bucket math for another.
- For Codex, `last` is the current operation delta, while `total` is useful for
  cumulative processed tokens and audit comparisons. `modelContextWindow` is
  the trusted window when provided.
- For Claude and OpenCode, server-resolved model metadata provides the window.
- For Claude aggregate `chat_turn` records, use `result.modelUsage` for the
  billable `tokenList` when present, and use the latest assistant snapshot or
  `result.usage.iterations[]` total for `inputContextSize`.
- After compaction, context used should drop to the post-compaction working set
  when the provider exposes it. If only operation usage exists, use the next
  context-bearing usage event.

## Compaction billing

Compaction should be represented as both:

- a process/message part, so the user sees the boundary after reload
- a usage operation, when provider usage exists

Recommended metadata:

```ts
type CompactionUsageMetadata = {
  operation: 'compaction';
  preCompactionContextTokens?: number | null;
  postCompactionContextTokens?: number | null;
  trigger?: 'auto' | 'manual' | 'model_triggered';
  durationMs?: number | null;
  providerSessionId?: string | null;
  parentTurnId?: string | null;
};
```

Billing rule:

```text
bill from provider/proxy usage tokens for the compaction operation
do not bill from preTokens/postTokens alone
```

If provider usage is missing, persist the compaction boundary but do not create a
billable usage row unless an exact delta can be proven.

## 2026-05-08 compaction stress audit

Run id:

```text
usage-compaction-20260508-143857
```

Files:

```text
~/.amalgm/chat-core-recordings/usage-compaction-20260508-143857.ndjson
~/.amalgm/agent-normalization-audits/usage-compaction-20260508-143857/ui-events.ndjson
```

Reports:

```text
~/.amalgm/agent-normalization-audits/usage-compaction-20260508-143857/reports/b1e015c7-994a-4ed1-be65-a2dd1d18d07d.json
~/.amalgm/agent-normalization-audits/usage-compaction-20260508-143857/reports/a8e18261-bf9d-4716-b84a-e2b1a194fff9.json
~/.amalgm/agent-normalization-audits/usage-compaction-20260508-143857/reports/d61a55bd-035f-4e76-a5d4-0ae985b3cb68.json
```

Observed:

- Claude Haiku/provider-auth emitted three automatic compactions. Native
  `compact_boundary` exposed `pre_tokens`, `post_tokens`, and `duration_ms`, but
  exact usage/cost still arrived on the surrounding `result.usage` turn-final
  events.
- Codex mini/provider-auth emitted `thread/tokenUsage/updated` with exact `last`
  deltas and cumulative `total`, but no native cost. It did not compact in this
  run; it hit the context limit and reported `last` as zero with `totalTokens`
  equal to the window.
- OpenCode DeepSeek/Amalgm emitted build step usage and compaction-agent usage.
  Native compaction messages had `mode: "compaction"` / `agent: "compaction"`
  with exact tokens and provider-reported cost.
- Follow-up input-context verification showed the OpenCode normalizer must emit
  hidden compaction and `compaction_continue` usage into the normalized stream,
  not just rely on proxy persistence. Otherwise billing rows can exist while the
  context circle does not drop until the next user turn.
- OpenCode's third compaction reached native, Amalgm, and UI, but the turn never
  produced a final UI completion, so the compaction process part was not
  persisted even though proxy usage rows were logged. This is a persistence edge
  case to fix: compaction boundaries should not depend solely on a later turn
  completion to survive reload.
- Claude and OpenCode provide provider-side cost. Codex does not.
- Codex runtime model ids with effort suffixes, such as
  `openai/gpt-5.4-mini-thinking-low`, must resolve to a canonical billing model
  before pricing. In this run, saved Codex market/cost rows were zero because
  that alias was not found by the local catalog.

## Model names

We intentionally keep separate identities:

| Name | Purpose | Example |
| --- | --- | --- |
| Display model | What the user sees | `Opus 1M` |
| Runtime model | What the CLI needs | `opus[1m]` |
| Billing model | Tamper-proof cost identity | `anthropic/claude-opus-4.7` |
| Context profile | Window / max-output variant | `1m-context` |

`Opus` and `Opus 1M` can share the same billing model while using different
context profiles.

Cursor follows the same split but is not a billing surface for us: display /
central ids may be provider-shaped (`anthropic/claude-sonnet-5-high`), while
the runtime cliModel is a Cursor CLI-style token (`claude-sonnet-5-high`,
`claude-4.6-sonnet-medium`, `composer-2.5-fast`). The ACP adapter may translate
that again to a Cursor-advertised ACP id (`claude-sonnet-4-6[...]`). Cursor's
`gatewayModelId` is for grouping/analytics only until Cursor emits real token
usage.

## Provider mapping targets

### Claude Code

- Final exact billing usage: `result.modelUsage` when present; fall back to
  `result.usage` only if `modelUsage` is missing.
- Native cost: `result.total_cost_usd`
- Assistant message usage snapshots: non-billable context circle updates when
  present.
- `result.usage.iterations[]` is useful for the latest context footprint, but it
  can omit hidden/system/compaction billing work.
- Intermediate subagent progress: `system task_progress usage.total_tokens`
- Current gap: progress usage is useful for live context, but does not include
  input/output/cache breakdown.
- Compaction boundary metadata exposes `pre_tokens`, `post_tokens`, and
  `duration_ms`. Do not bill from those. In observed Electron fixtures, Claude
  folds compaction billing into the surrounding exact `result.modelUsage`
  aggregate rather than emitting a separate compaction tokenList.

### Codex

- Live usage: `thread/tokenUsage/updated`
- Fields: `inputTokens`, `outputTokens`, `cachedInputTokens`,
  `reasoningOutputTokens`, `modelContextWindow`
- Current gap: cache-write semantics still need confirmation from fixtures.

### OpenCode

- Step usage: `step-finish` parts and final `message.tokens`
- Fields: `tokens.input`, `tokens.output`, `tokens.cache.read`,
  `tokens.cache.write`, `tokens.reasoning`, `cost`
- Normalize `tokens.output + tokens.reasoning` into `outputTokens`, while
  keeping `tokens.reasoning` as `thoughtTokens`.
- Emit usage for same-session hidden compaction operations (`mode:
  "compaction"` / `agent: "compaction"`) and post-compaction continuation
  messages so `inputContextSize` drops immediately after compaction.
- Current gap: child-session usage should be classified separately from parent
  turn usage.
