ADR-097: Specialized services dispatch through the shared pipeline
- Status
-
Accepted
- Date
-
2026-07-20
- Authors
-
Netresearch DTT GmbH
Context
The specialized services — DALL·E, FAL, Whisper, TTS, DeepL — dispatch HTTP
directly and bypass the middleware pipeline, so they get none of what a chat
call gets: no telemetry row, no correlation id, no circuit breaker, no uniform
error classification. ADR-096 made that reachable by moving the
configuration onto the call context, so a caller without an LlmConfiguration
entity can now drive the pipeline through a forService() context.
Decision
AbstractSpecializedService takes the MiddlewarePipeline as a required
dependency and exposes `run`, which runs the actual HTTP dispatch as the pipeline terminal. Each
service builds a ProviderCallContext::forService(operation, provider, model)
and wraps its dispatch in runLifecycle().
For a service context (no configuration entity, no budget metadata) most
middleware self-disable: fallback has no chain, cache and idempotency have no
key, the guardrail passes a non-completion result through, and the budget
middleware is inert because the per-call budget is still enforced by
enforceBudget() before dispatch. What the pipeline adds is a telemetry row
with a correlation id and the provider circuit breaker — a flapping image
or speech endpoint now trips and fails fast like a chat provider.
All five services are migrated: DALL·E (generate/generate-multiple/
variations/edit), FAL (generate/generate-multiple), Whisper
(transcribe/transcribe-from-content/translate-to-English), TTS (synthesize)
and DeepL (translate/translate-batch). Each wraps its dispatch in
runLifecycle() with a forService() context labelled by its operation
(ProviderOperation::ImageGeneration / Transcription / SpeechSynthesis
/ Translation).
Consequences
- Breaking:
AbstractSpecializedServicegained a requiredMiddlewarePipelineconstructor parameter (afterbudgetService). A subclass or manual construction must pass it; an emptyMiddlewarePipeline([])is a valid pass-through for tests that do not exercise the lifecycle. - DALL·E calls now write a telemetry row (operation
image, the provider and model, a correlation id) and are guarded by the circuit breaker. No other behaviour changes: usage is still recorded by the service's owntrackImageUsage()(unifying that into a pipeline extractor is a later step), and the budget is still enforced byenforceBudget()before dispatch — the budget middleware stays inert for a service context, so there is no double check. Classes/Specializednow depends onClasses/Provider/Middleware. That is the point — the specialized services join the shared lifecycle rather than reimplementing it.- Deferred, each its own step: applying input-guardrail screening to the
specialized prompts; the fail-closed dispatch
seam that makes a forgotten lifecycle wrapper throw rather than spend
unmetered (it can only be switched on once all five services route through
runLifecycle()); and folding the per-service usage recording into a tagged pipeline extractor.