---
title: "ADR-189: A model capability is vocabulary, and only sometimes data"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-189@0.35"
source: "Adr/Adr189CapabilityVocabularyVersusData.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-189: A model capability is vocabulary, and only sometimes data

-   *Status:* Accepted
-   *Date:* 2026-09-04
-   *Extends:*

    [ADR-018](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-018@0.35) (multi-provider model discovery) and
    [ADR-138](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-138@0.35) (criteria-mode selection matches the operation,
    not only the criteria)

-   *Authors:* Netresearch DTT GmbH

## Context

`ModelCapability` offers eleven cases. Two of them are written by no
model discoverer at all, and until this record nothing said so anywhere a
reader would look: the enum carried one line of prose that named three
examples, and the distinction that matters was buried in the class docblock of
`OperationCapabilityMap`, a class nobody consults when authoring criteria.

The three surfaces disagree by construction and each is right on its own terms:

-   The **enum** is the vocabulary a record may use. It is deliberately wider
    than any one provider, because it also has to describe what an administrator
    ticks by hand and what the specialized services do.
-   **Discovery** writes only what a provider's own response substantiates, which
    [ADR-018](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-018@0.35) requires and which #671/#676 made true per
    discoverer. The result is correct data and UNEVEN data: Groq's listing has no
    capability field, so its models are seeded `chat` alone, and a Groq record
    without `tools` is a gap rather than a statement.
-   **Criteria** may require any of the eleven.

The failure follows from the three together.
`EligibilityEvaluator::matchesCapabilities()` requires every capability a
criteria set lists, so one entry nothing writes makes the whole set
unsatisfiable — and it reports "no candidates", which reads as "this
installation has no suitable model" rather than "this requirement can never be
met anywhere".

That is not hypothetical. `nr_repurpose_text` required `json_mode`
alongside `chat`. The use-case pack carrying that preset could not be
installed on any installation, and the demo instance reported it on every
deploy until the criterion was dropped. The message named both capabilities, so
a reader looked first at `chat`, which every model there declares (#913,
#918).

## Decision

**A capability may be required in selection criteria only if some producer
actually writes it.** The enum stays the wider vocabulary; the constraint is on
what criteria may demand, not on what the enum may contain.

Three consequences, in the order they bind:

1.  **The enum documents, per case, who writes it and who reads it.** Not a
    description of the feature — every reader can guess what `vision` means.
    What they cannot guess is that `completion` means the same thing as
    `chat` in this codebase, or that `json_mode` is forwarded to providers
    without any capability ever being consulted.
1.  **The claim is measured, not read off the sources.**
    `CapabilitySeedTest` runs every discoverer — each one's offline corpus
    plus the recorded responses — and collects what they write into
    `DiscoveredModel::$capabilities`. A case written by none of them must
    appear in that test's exception list with a reason, or the build fails. Its
    predecessor scanned the discovery sources for the quoted token and could not
    tell an assignment from an array subscript: it read OpenRouter's
    `$pricing['completion']` as evidence that `completion` was seeded, and
    therefore stayed silent about a dead capability.
1.  **When criteria cannot be satisfied, the message names the capability, not
    the set.** The preset preflight narrows one capability at a time and reports
    the ones no active model declares (#918).

Enforcement of an operation's own required capability is a separate axis and
stays where `OperationCapabilityMap` documents it: enforced only for
`chat`, `vision` and `tools`, because those are the tokens whose
producers have caught up, and `observe` remains the safer default for an
upgrade.

## Consequences

**A dead capability now fails a build rather than an operator's install.** The
exception list is the place where "nothing writes this" is stated on purpose,
and a reviewer sees it in the diff when it grows.

**The list is not a promise about every possible provider response.** The test
proves what the recorded responses and the offline corpora produce. A mapper
branch added without a recording that reaches it stays invisible to the check,
which is why each entry carries a reason a reader can verify rather than a bare
token.

**The two remaining unseeded cases stay in the enum, for different reasons.**
`audio` was the third and left the list the same day this record was drafted:
`OpenAiModelDiscoverer` now seeds it from the model id, the instrument it
already used for `tts-`, `whisper-` and `gpt-image`, because
`GET /v1/models` describes no capabilities. Nothing on the call path branches
on it, so a wrong assignment widens selection rather than breaking a call.

-   `json_mode` is a request option, not something a model can be asked to do.
    `ChatOptions` carries `responseFormat` to the provider
    unconditionally and no call path consults the capability, so as a criterion
    it can only subtract models. Populating it would assert per model that the
    provider honours `response_format`, which fails at call time when it does
    not. Removing the case belongs to the 1.0 API freeze (#895), because it and
    `Model::supportsJsonMode()` both sit on the frozen surface.
-   `completion` is a synonym of `chat` here.
    `CompletionService::complete()` delegates to
    `LlmServiceManager::chat()` and every adapter posts to
    `chat/completions`; no legacy completions endpoint is called anywhere.
    Seeding it would give chat models a second token with the same meaning, so
    two criteria sets differing only in which one they name would select
    differently for no reason. It was found by the replacement check, because
    the one it replaced counted OpenRouter's pricing keys as seeds.

**Nothing changes at runtime.** No capability is added, removed or newly
enforced by this record; it names a rule the code already followed unevenly and
makes the next violation visible.
