ADR-160: One adapter contract, and honest capability provenance
- Status
-
Accepted
- Date
-
2026-08-11
- Authors
-
Netresearch DTT GmbH
Context
Two separate honesty gaps, both about a claim nobody could check.
Seven adapters, seven private definitions of "works"
Classes/ holds seven adapters — OpenAI, Claude, Gemini, Groq,
Mistral, Ollama, OpenRouter. Each had its own unit test and its own opinion of
what needed testing. Nothing compared them.
The result was not that adapters were untested. It was that the differences between them were invisible. Reading the suite could not tell you whether "OpenRouter has no test for retry behaviour" meant the adapter does not retry or that nobody wrote the test. Three examples the contract found on its first run:
OllamaimplementsProvider Tooland itsCapable Interface supportsreturns true, butTools () toolswas missing from its$supported. The service layer'sFeatures instanceofgate let a tool call through whileLlmdenied the same capability to whoever asked.Service Manager:: supports Feature ('tools', 'ollama') Opensends through a private request path (it needs theRouter Provider HTTP-/Referer X-attribution headers and a 402 = out-of-credits mapping). That path had noTitle try/catcharoundsend, so a connection refusal or a cURL timeout escaped the adapter as a raw PSR-18 exception and reached the caller as an unhandled 500 — the same class of bug the unparseable-body branch right below it already fixed.Request () - The same private path never called
validate. An OpenRouter with no vault key fell throughConfiguration () get's api-key-less branch and sent a keyless request, so a local misconfiguration reached the operator as the provider's 401 — the provider's name on our mistake, after an outbound call that never had a chance.Http Client () streamvalidated;Chat Completion () chatdid not.Completion ()
None is exotic. All three survived because no artefact stated what an adapter must answer to.
A capability with no provenance
tx_ is a comma-separated token list. An operator's
manual tick in the record editor and an answer from the provider's own model
endpoint produced byte-identical rows. Afterwards nothing could tell them
apart — not the model module, not routing, not the operator.
That matters most exactly where it is least visible. When a provider's model
endpoint is unreachable, Model substitutes the static catalog
bundled with the extension (Discovery). A model created
from that catalog looked, in the database, exactly like a model the provider
had confirmed.
Decision
1. One abstract contract case every adapter extends
Tests/ fixes seven
things for all seven adapters: the identifier, the capability declaration,
error normalisation per exception type, refusing to send without a credential,
timeout behaviour, usage reporting, and — where declared — the shape of a tool
call and of a structured-output request. Every adapter has a concrete
subclass; the four OpenAI-dialect adapters share their wire fixtures through
one intermediate case.
Three rules keep it a contract rather than a lowest common denominator:
A capability an adapter does not have is skipped by name. The document,
unsupported-schema, credential and retry contracts call mark
with the reason. A reader of the run sees nine named skips and can tell
"cannot" from "not tested" — which is the entire point. The tool contracts
carry the same guard and never fire it: all seven bundled adapters implement
Tool. The guard stays for the eighth.
A deliberate deviation is declared, not tolerated. Three hooks —
expected, retries and
requires — carry the differences that are real: the first two
because Open does not send through
Abstract, the third because a local Ollama
authenticates nothing. Overriding one is a statement in the subclass, with the
reason in its docblock. That is where the deviation is now written down;
before, it was written down nowhere.
No live calls. Every adapter is driven through an injected PSR-18 double.
The suite lives under Tests/ deliberately, not under
Tests/. The integration testsuite is in
Build/ but no CI job runs it: ci. runs
ci:, …: and …:, and integration
appears only in the local composer ci aggregate. A conformance suite that
no gate executes is a decoration.
Streaming is covered by the declaration contract only. An SSE fixture is dialect-specific enough that a shared one would assert the fixture rather than the adapter, and each adapter's own test already carries one.
The repair round-trip stays where it is. ADR-126's single
repair attempt, the nested-keyword limits and the rejection of a schema
outside the subset are Completion and Json
behaviour, not adapter behaviour, and both already have tests for them. What
the adapter owns is the provider-native request shape, the degradation when
the provider cannot enforce the schema, and passing a malformed answer back
untouched so the layer that can repair it sees the real body. Those three are
in the contract.
2. Capability provenance, with the catalog kept separate
Three columns on tx_: capabilities_ (what the last
discovery reported), capabilities_ (when, 0 = never) and
capabilities_ (a Capability value).
Per-capability provenance is derived, not stored per capability:
Model:: compares the declared set against the
discovered set. A capability the provider named carries that run's source and
date; one only the operator ticked carries Capability and
no date, because there is no confirmation to date. This gives the right answer
for free on every record written before provenance existed — nothing confirmed
it, and it now says so.
Capability is deliberately distinct from Discovery.
Folding the substituted static catalog into "confirmed by the provider" would
manufacture exactly the confidence this record exists to remove.
The source follows the capability tokens, not the model list. These come
apart, and reading only Model
would have been the same conflation one level down. OpenAI's /v1/
returns id/object/created/owned_, Anthropic's returns no
capabilities either, and Groq's listing has no capability field: their
discoverers read the tokens out of the bundled catalog on the live path
exactly as on the fallback path. Gemini's curated table wins over the listing
for every model it names. So Discovered carries
capabilities, set by the discoverer and true only where the tokens
were derived from the response payload — Mistral's capabilities object,
OpenRouter's supported_ and input_, Ollama's
/api/ array, and Gemini's supported for a model
the table does not know. Capability records Discovery only
when a live list and payload-derived tokens both hold; everything else is
Catalog. The practical consequence is that confirming an OpenAI or
Anthropic model against a reachable API yields Catalog, which is the true
answer — nothing about those tokens was confirmed.
3. The consumer is the model backend module
The capability column of Backend/ renders each capability
with its provenance: a plain badge when a live provider answer confirmed it, a
warning badge with a question mark and a tooltip naming the source otherwise,
plus a "last confirmed" line per row. A "Confirm capabilities" row action
(Model) runs discovery for the model's
provider and records the answer, so "last confirmed" ages honestly instead of
freezing at creation time. The same three fields appear read-only on the
Capabilities tab of the record editor.
Why the routing readout on the Governance tab waited. It is not the
readout the brief assumed. Backend/ renders governance
profile deviations — Governance over policy
rows. There is no per-model capability readout there to annotate, so wiring
provenance into it means first building that readout. That is a larger change
than this one and belongs with whoever builds it.
Routing does not read provenance, on purpose. Making eligibility depend on it would silently drop every model whose capabilities an operator declared by hand — a behaviour change dressed as a data change. Provenance is informational until someone decides, explicitly, that it should gate.
4. The setup wizard is not a provenance writer
Capability is the only writer. The wizard deliberately is not one:
by the time Setup persists the selected
models, the discovery it displayed happened in an earlier request and it can no
longer tell a live answer from the substituted catalog. Stamping "confirmed by
discovery" there would be the same conflation in a different place. A
wizard-created model therefore starts as unconfirmed — which is true, and
which is what makes the Confirm action worth clicking.
Consequences
✓ Adding a provider means writing a contract subclass, and the abstract case enumerates what the adapter has to answer to. A capability it lacks is a named skip, not an omission.
✓ Three real defects are closed: Ollama's contradictory tool declaration, OpenRouter's raw transport exception, and OpenRouter's keyless request on an unconfigured adapter. All three were found by the contract on its first run rather than in production.
✓ An operator can see which capabilities the provider actually confirmed, and when.
✕ supports now returns true where it returned
false. That is the correct answer — the adapter has always been able to make
tool calls — but it is a behaviour change for anything that branched on the
wrong one.
✕ Two contract holes exist by declaration, both from OpenRouter's private
request path. It maps a 5xx other than 503 to Provider
rather than Provider — 503 has its own arm in
handle and keeps the shared class — and it does not retry
transport failures at all. Neither is fixed here, because unifying that path
means changing its 401/402/429/500 messages, which existing tests pin.
The 5xx mapping does not cost a fallback hop. Failure
(ADR-095) reads the carried HTTP status, not the exception
class: a Provider with a 5xx code classifies as
Failure, which answers is and
trips exactly as CONNECTION does, so Fallback
hops either way. What it costs is the class a caller catches and the wording:
a handler with a catch arm ahead of a generic
one — Provider is the in-tree case —
takes that arm for an OpenRouter 5xx and the generic Provider arm
for every other adapter's, and the message reads
Open where the shared path says
Server returned status 502.
✕ Provenance is per confirmation run, not per capability write. An operator who edits the capability list right after a verification gets the new capability attributed to themselves — correct — but the confirmation date of the untouched ones does not move, which is also correct and may read as stale.
Explicitly out of scope
No new provider adapter. ADR-147 keeps AWS Bedrock and Google
Vertex AI a deliberate gap with two named triggers — symfony/
reaching 1.0, or a named customer requirement. Neither has fired, and a
conformance suite is not one of them.