ADR-096: The pipeline configuration lives on the call context 

Status

Accepted

Date

2026-07-20

Authors

Netresearch DTT GmbH

Context 

The middleware pipeline that wraps every chat/embedding/vision call — budget, telemetry, cache, idempotency, guardrail, fallback, usage, circuit breaker — took the LlmConfiguration as a separate positional parameter of MiddlewarePipeline::run() and of every ProviderMiddlewareInterface::handle(). Six of the eight middleware merely forwarded it; it existed as its own parameter only so FallbackMiddleware could substitute a sibling configuration on a retryable failure.

That shape hard-wires the pipeline to callers that have an LlmConfiguration entity. The specialized services — DALL·E, FAL, Whisper, TTS, DeepL — do not: they are identified by provider and model strings and dispatch HTTP directly, which is exactly why they bypass the pipeline and, with it, telemetry, correlation ids, the circuit breaker and input guardrails.

Decision 

The configuration moves onto the context. ProviderCallContext gains a nullable configuration plus provider / model / configurationIdentifier strings. MiddlewarePipeline::run(context, terminal) and ProviderMiddlewareInterface::handle(context, next) drop the separate configuration parameter; $next and the terminal now receive the context. FallbackMiddleware swaps the configuration through ProviderCallContext::withConfiguration().

When the configuration entity is present it is the source of truth; when it is null the string fields are — telemetryProvider() / telemetryModel() / telemetryConfigurationIdentifier() encode that fallback in one place, so telemetry, usage and the circuit key work whether the call came from a configuration entity or a bare service descriptor. Three factories name the intent: for() (generic), forConfiguration() (an entity), forService() (provider/model strings, no entity).

ProviderOperation gains the specialized cases — image generation/edit/ variation, transcription, speech synthesis, translation — so every AI call is labelled from one vocabulary.

The class names keep the Provider prefix for now. Renaming ProviderCallContextAiCallContext and the sibling types is a pure cosmetic follow-up ( 40 references) and is deliberately not bundled into this behaviour-preserving change.

Consequences 

  • No behaviour change. This is a structural refactor: the chat path builds a context via forConfiguration() and every existing test passes unchanged in intent. TelemetryMiddleware reads provider/model/identifier from the context helpers rather than the entity, which also means the "requested primary configuration" survives a fallback swap for free.
  • Breaking for downstream pipeline callers. MiddlewarePipeline::run() and ProviderMiddlewareInterface::handle() changed signature, and a custom middleware or a direct run() caller must move the configuration onto the context. In-tree this was a mechanical migration across the middleware tests.
  • UsageMiddleware and CircuitBreakerMiddleware now tolerate a null configuration (a specialized call): usage attributes by the context's model string and the circuit keys off the context's provider.
  • This is the enabling step. It delivers no user-visible change on its own — the specialized services do not yet route through the pipeline. Adding the fail-closed dispatch seam and migrating those five services onto it is the following step, at which point they gain telemetry, correlation ids, the circuit breaker and input guardrails.