---
title: "ADR-097: Specialized services dispatch through the shared pipeline"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-097@0.35"
source: "Adr/Adr097SpecializedServicesOnPipeline.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# 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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-096@0.35) 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.
