ADR 189 · Accepted

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

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 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.

  2. 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.

  3. 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.

← All ADRs