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 `runLifecycle(ProviderCallContext $context, callable $call)`, 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: AbstractSpecializedService gained a required MiddlewarePipeline constructor parameter (after budgetService). A subclass or manual construction must pass it; an empty MiddlewarePipeline([]) 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 own trackImageUsage() (unifying that into a pipeline extractor is a later step), and the budget is still enforced by enforceBudget() before dispatch — the budget middleware stays inert for a service context, so there is no double check.
  • Classes/Specialized now depends on Classes/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.