---
title: "ADR-078: Budget pre-flight for the specialized image/speech services"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-078@0.35"
source: "Adr/Adr078SpecializedServiceBudgetEnforcement.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-078: Budget pre-flight for the specialized image/speech services

-   *Status:* Accepted
-   *Date:* 2026-07-17
-   *Authors:* Netresearch DTT GmbH

## Context

Chat-shaped calls run through the provider middleware pipeline, so
`BudgetMiddleware` ([ADR-025](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-025@0.35)) enforces per-user and
per-configuration spend ceilings before any provider request. The
specialized image/speech services (DALL-E, FAL, Whisper, TTS) dispatch
HTTP directly from `AbstractSpecializedService` and **bypass** that
pipeline.

[ADR-057](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-057@0.35) gave the specialized option DTOs the
`beUserUid`/`plannedCost` fields (via `BudgetFieldsTrait` /
`BudgetAwareOptionsInterface`) and wired them into *usage attribution*
— but deliberately deferred *enforcement*, recording that "routing these
services through a budget pre-flight is a separate decision with its own
trade-offs (no token cost model for FAL, multipart flows)" and leaving
`plannedCost` carried-but-unused on the send path. This ADR is that
deferred follow-up.

## Decision

**Add a pre-flight budget gate to** `AbstractSpecializedService`. A new
protected \``enforceBudget(?int $beUserUid, ?float $plannedCost, ?string
$configurationIdentifier)`` mirrors ``BudgetMiddleware::handle()`\`: it
resolves the named configuration (reusing the existing
`findActiveConfiguration()`), calls `BudgetServiceInterface::check()`
and throws `BudgetExceededException` before any HTTP dispatch when a
limit is exceeded. Each concrete service calls it after option resolution
and input validation, before building its request:

-   `DallEImageService` — `generate()`, `generateMultiple()` (from the
    options), and `createVariations()`/`edit()` (from the scalar
    `beUserUid`, no cost/configuration).
-   `TextToSpeechService` — `synthesize()` (`synthesizeToFile()` /
    `synthesizeLong()` delegate to it, so they inherit the gate without
    double-gating).
-   `WhisperTranscriptionService` — all three entry points
    (`transcribe()`, `transcribeFromContent()`, `translateToEnglish()`);
    gating only some would leave an enforcement bypass.
-   `FalImageService` — `generate()`/`generateMultiple()`, reading
    `beUserUid`/`plannedCost` from its allow-listed options array (a new
    `extractPlannedCost()` mirrors the existing `extractBeUserUid()`; the
    payload builder drops both, so neither reaches the FAL API).

`DeepLTranslator` was initially left out because its options carried no
budget fields. That exception did not survive review: a paid external call
no cap can stop is not defensible, whatever the shape of its options.
`TranslationService` now threads `plannedCost` and `configuration`
through alongside the already-threaded `beUserUid`, and both `translate()`
and `translateBatch()` run the same pre-flight as the image and speech
services. (Amended 2026-07-20; the original decision is kept above so the
change of mind is visible rather than rewritten away.)

**Fail-open by construction.** The gate hangs on a new *optional* trailing
constructor parameter `?BudgetServiceInterface $budgetService = null`. It
autowires from the existing `BudgetServiceInterface` alias in production;
when absent (unconfigured deployments, unit tests) `enforceBudget()` is a
no-op. Even when wired, `BudgetService::check()` short-circuits to
"allowed" for calls without a backend user or without configured limits, so
nothing changes until an operator actually sets a cap.

## Consequences

-   **Behavioural change, gated on configuration.** Deployments that already
    set per-user or per-configuration limits will start receiving
    `BudgetExceededException` on image/speech calls once a cap is hit —
    previously those calls were only *attributed* (ADR-057), never blocked.
    Deployments with no limits are unaffected.
-   **FAL and DALL-E variations/edit have no cost model.** FAL publishes no
    static price list and the variations/edit endpoints carry no
    `plannedCost`, so *cost* caps cannot trip for those paths — only
    request-count/token caps enforce. Operators must not assume a cost ceiling
    protects FAL image spend.
-   **Catch surface.** `BudgetExceededException` lives in `Exception\` (the
    shared budget exception, ADR-025), not `Specialized\Exception\`.
    Consumers that catch only `SpecializedServiceException` will not catch a
    budget denial — this is the intended shared-exception behaviour.
-   Consumers that hand-rolled their own specialized pre-gate (e.g. an
    extension guarding image/TTS spend itself) can drop it; `check()` has no
    side effects, so a transitional double-gate is idempotent and harmless.
-   The optional constructor parameter is a semver-minor change in the 0.x
    line, consistent with ADR-052/ADR-057; implementers that construct the
    specialized services by hand gain a nullable trailing argument.
