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: UsageMiddleware records both the token- shaped responses and the specialized operations. The services drop their direct trackUsage() calls (and DallEImageService::trackImageUsage() / WhisperTranscriptionService::trackTranscriptionUsage() are gone).
  • UsageMiddleware gained an autowired iterator of extractors; with none tagged its behaviour is unchanged. ProviderOperation::Metadata records 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_extractor and setting an intent before dispatch; no service touches the usage table.