ADR-100: Specialized usage recorded by tagged extractors in the pipeline
- Status
-
Accepted
- Date
-
2026-07-21
- Authors
-
Netresearch DTT GmbH
Context
ADR-097 routed the specialized dispatches through the pipeline,
but each service still recorded its own usage by calling
UsageTrackerServiceInterface::trackUsage() directly after the dispatch — a
second write path alongside UsageMiddleware, which records the
token-shaped chat / embedding / vision responses. The specialized responses are
not token-shaped (they measure images, characters, audio seconds), so
UsageMiddleware skipped them and the service filled the gap itself. Two
recorders, two places to keep the request-count and attribution rules
consistent.
Decision
A specialized service no longer writes usage. Before dispatch it attaches a
SpecializedUsageIntent — the stable, dispatch-independent inputs it knows
(model, resolved model / configuration uid, attribution uid, and the input-
derived counters: characters, size, quality, batch size) — to the call-context
metadata. A tagged UsageMetricsExtractorInterface (one per service, matched on
operation and provider so DALL·E and FAL do not collide) reads that intent
together with the raw response and returns a ProviderUsageRecord.
UsageMiddleware writes it, as the single recorder, after the token path finds
nothing.
The response supplies what the service could not know up front: DALL·E's
gpt-image token object and the number of images returned, Whisper's audio
duration (verbose_json only). Cost is computed in the extractor from the
SpecializedCostCalculator exactly as the service did.
A service records usage iff it set an intent. DeepL's language-detection
sub-call and the getUsage() / getGlossaries() metadata calls
(ADR-099) set none, so the extractor returns null and nothing is
recorded — the former double-count guard is now structural.
Consequences
- One write path for every AI call:
UsageMiddlewarerecords both the token- shaped responses and the specialized operations. The services drop their directtrackUsage()calls (andDallEImageService::trackImageUsage()/WhisperTranscriptionService::trackTranscriptionUsage()are gone). UsageMiddlewaregained an autowired iterator of extractors; with none tagged its behaviour is unchanged.ProviderOperation::Metadatarecords nothing (no extractor claims it).- The recorded rows are unchanged — same service type, provider, metrics, cost,
model / configuration uid and attribution — verified end-to-end: the service
tests now drive a real
UsageMiddleware+ the service's extractor and assert the same rows they asserted before. - Adding a specialized provider means adding one extractor tagged
nr_llm.usage_metrics_extractorand setting an intent before dispatch; no service touches the usage table.