WorkWingman AI-Usage Metrics — METRICS.md¶
Status: WW-66 (metering + ledger + isolation enforcement), first slice of the TROI epic. Satisfies the §7 hard gate of the Token-ROI Metrics Standard v1.0 — this repo's compliance doc, referenced from docs/technical/byok-efficiency.md §1.
Scope: this document describes what WW-66 actually measures today. It does not define cost
model math, savings claims, or routing policy — those are later tickets (WW-67 rate cards/governor,
WW-69 router, WW-71 durable-outcome linking). Read docs/technical/byok-efficiency.md for the
full nine-part design; this file is the narrower "what's shipped and how it's honest" record.
1. What is metered¶
Every call through any ILlmHarness (src/WorkWingman.Core/Interfaces/ILlmHarness.cs) — Anthropic,
OpenAI, Ollama, and every CLI harness (Claude Code, Codex, agy/Gemini, Grok, Meta) — is metered. There is
exactly one door: MeteringLlmHarness (src/WorkWingman.Infrastructure/Llm/MeteringLlmHarness.cs) wraps
every registered harness in DI (LlmHarnessRegistration.cs), so a caller cannot get an unmetered handle
to a provider by construction — metering isn't each caller's responsibility, it's the registration's.
This meters the user's own BYOK keys / installed CLIs, only for WorkWingman workloads, only while WW runs. It is not a general LLM-telemetry product.
2. The ledger is content-free by construction¶
AiUsageEvent (src/WorkWingman.Core/Models/AiUsageEvent.cs) is a sealed record with no free-text
field of any kind — no prompt, no response, no message body. A reflection test
(tests/WorkWingman.Tests/AiUsageEventContentFreeTests.cs) asserts no property name contains "prompt",
"response", "content", "text", "message", or "body", and that the only string-typed properties are
Provider and Model (both non-secret labels, never the key and never generated text). A resume, a job
description, or a model's answer is structurally impossible to record here — there's nowhere to put it.
Schema (v1):
| Field | Meaning |
|---|---|
Id |
Stable id stamped at append |
Ts |
UTC completion time |
Provider |
Harness id, e.g. anthropic, openai, ollama:qwen3:8b — identifies the key/CLI slot, not a secret |
Model |
Model name when the harness/provider surfaces one (empty for CLI harnesses that don't) |
TaskClass |
Declared workload intent (LlmTaskClass) — required on every call, rejects Unknown at the harness boundary |
Status |
Ok | Empty | Failed — mirrors the harness's own null-on-failure contract |
LatencyMs |
Wall-clock latency of the harness call |
InputTokens / OutputTokens |
See §3 |
SchemaVersion |
Lets later shards add fields (cache tokens, etcUsd, routeDecision, outcomeRef per the TROI design) without breaking readers of older rows |
This is a deliberate subset of the full ledger shape sketched in byok-efficiency.md §1
(keyIdHash, feature, cache-token breakdown, et/etcUsd, rate-limit snapshots, routing decisions,
outcomeRef) — those fields arrive with WW-67/69/71 once there's a metered baseline to build on.
3. Token capture — real numbers where the provider gives them, honest nulls where it doesn't¶
Token counts come from an ambient side-channel, AiCallContext
(src/WorkWingman.Core/Interfaces/AiCallContext.cs): MeteringLlmHarness opens a scope
(AiCallContext.Begin()) around each inner GenerateAsync call; a harness that parses a provider usage
block calls AiCallContext.Report(input, output, model) while inside that call; the decorator reads
scope.Usage once the call returns and stamps it onto the ledger row. This flows the numbers back
without changing the frozen ILlmHarness.GenerateAsync(LlmRequest, CancellationToken) -> Task<string?>
signature.
- Anthropic (
AnthropicLlmHarness) — readsusage.input_tokens/usage.output_tokensfrom the Messages API response. - OpenAI (
OpenAiLlmHarness) — readsusage.prompt_tokens/usage.completion_tokensfrom the Chat Completions response. - Ollama (
OllamaLlmHarness) — readsprompt_eval_count/eval_countfrom/api/generate. - CLI harnesses (Claude Code, Codex, agy, Grok, Meta —
CliLlmHarness) — report nothing. These drive a subscription via a subprocess; there is no per-call token count to read. Their ledger rows carryInputTokens = null, OutputTokens = null.
Null means "not measured," never "zero." A CLI call that consumed real subscription capacity is
recorded with null tokens, not 0 — collapsing "no data" into "zero cost" would understate real usage
and is exactly the kind of quiet-zero the standard's honesty requirement rules out. Tokens are reported
even when the call's text comes back empty or the provider call otherwise "fails" post-response — the
report happens right after the response JSON is parsed, inside the same try block, because tokens were
consumed regardless of what the text turned out to contain.
4. Storage: a separate, append-only shard family¶
AiUsageLedger (src/WorkWingman.Infrastructure/Services/AiUsageLedger.cs) is a LocalJsonStore-backed
recorder, one collection per month: ai-usage-{yyyy-MM}. This is a separate shard family from
metrics-events-* (the Value Metrics event log) by design — different PII postures, must never share a
file. Appends are gated by a semaphore around a load-add-save sequence so concurrent callers (e.g.
JobFit's parallel narrative calls) don't clobber each other's rows.
Never throws. RecordAsync swallows any storage failure — a broken ledger write must never take
down the feature that made the AI call. This mirrors MetricsEventLog's existing best-effort contract
exactly (IAiUsageRecorder, src/WorkWingman.Core/Interfaces/IAiUsageRecorder.cs).
5. Structural coverage guarantee — the hostname gate¶
A test (tests/WorkWingman.Tests/Architecture/ProviderHostnameGateTests.cs) scans every .cs file under
src/ and fails the build if a provider hostname literal (api.anthropic.com, api.openai.com,
generativelanguage, api.x.ai) appears outside src/WorkWingman.Infrastructure/Llm/ — the sole
door. This is a structural guarantee, not a procedural one: if a future call site hits a provider
endpoint directly instead of going through an ILlmHarness, that call bypasses MeteringLlmHarness and
would silently escape the ledger. The test catches that the moment it happens rather than relying on a
reviewer noticing during code review.
6. What this does NOT claim¶
Per the standard's "meter before optimize" principle and the WW-66 build-order note in
byok-efficiency.md §9: this ticket makes no cost, dollar, or savings claims. There is no
etcUsd field, no plan/tier pricing table, and no MEASURED-vs-MODELED savings computation yet — those
require a metered baseline window first (WW-67 rate cards + governor, WW-69 router savings, WW-70
compression savings). Everything in this document is a measurement, not a valuation. When cost
figures do arrive in later tickets, they will be labeled MEASURED (calls actually served cheap/free with
a priced same-taskClass baseline) or MODELED ("a stronger model would have cost X") and the two will
never be summed, per the parent standard §5.
7. Test coverage¶
tests/WorkWingman.Tests/Llm/MeteringLlmHarnessTests.cs— decorator behavior: one event per call, status mapping, provider/taskClass/latency pass-through, token capture viaAiCallContext, cancellation re-throw with nothing recorded, boundary-guard rejection ofUnknowntask class.tests/WorkWingman.Tests/AiUsageLedgerTests.cs— append/read round-trip, monthly shard naming, merge across shards oldest-first, concurrent-append durability, isolation frommetrics-events-*, never-throws on a broken store.tests/WorkWingman.Tests/AiUsageEventContentFreeTests.cs— reflection proof the ledger row type cannot hold content/PII.tests/WorkWingman.Tests/AiCallContextTests.cs— the AsyncLocal report/scope mechanics, including the no-op-when-no-scope case.tests/WorkWingman.Tests/Architecture/ProviderHostnameGateTests.cs— the structural hostname gate described in §5.tests/WorkWingman.Tests/Llm/{Ollama,Anthropic,OpenAi}LlmHarnessTests.cs— existing harness suites, extended coverage for the new usage-parsing DTO fields is implicit (their HttpTest mocks don't need to include ausageblock;AiCallContext.Report(null, null, ...)is harmless when the field is absent).