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/Provider/ 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:
OllamaProviderimplementsToolCapableInterfaceand itssupportsTools()returns true, buttoolswas missing from its$supportedFeatures. The service layer'sinstanceofgate let a tool call through whileLlmServiceManager::supportsFeature('tools', 'ollama')denied the same capability to whoever asked.OpenRouterProvidersends through a private request path (it needs theHTTP-Referer/X-Titleattribution headers and a 402 = out-of-credits mapping). That path had notry/catcharoundsendRequest(), 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.- The same private path never called
validateConfiguration(). An OpenRouter with no vault key fell throughgetHttpClient()'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.streamChatCompletion()validated;chatCompletion()did not.
None is exotic. All three survived because no artefact stated what an adapter must answer to.
A capability with no provenance
tx_nrllm_model.capabilities 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, ModelDiscovery substitutes the static catalog
bundled with the extension (DiscoveryResult::fallback()). 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/Unit/Provider/Contract/AbstractAdapterContractTestCase 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 markTestSkipped()
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
ToolCapableInterface. The guard stays for the eighth.
A deliberate deviation is declared, not tolerated. Three hooks —
expectedServerErrorException(), retriesTransportFailures() and
requiresApiKey() — carry the differences that are real: the first two
because OpenRouterProvider does not send through
AbstractProvider::sendRequest(), 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/Unit deliberately, not under
Tests/Integration. The integration testsuite is in
Build/phpunit.xml but no CI job runs it: ci.yml runs
ci:test:php:unit, …:functional and …:fuzzy, 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 CompletionService and JsonSchemaValidator
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_nrllm_model: capabilities_discovered (what the last
discovery reported), capabilities_confirmed_at (when, 0 = never) and
capabilities_source (a CapabilitySource value).
Per-capability provenance is derived, not stored per capability:
Model::getCapabilityProvenance() 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 CapabilitySource::Operator 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.
CapabilitySource::Catalog 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 ModelDiscovery::wasLastDiscoveryFromFallback()
would have been the same conflation one level down. OpenAI's /v1/models
returns id/object/created/owned_by, 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 DiscoveredModel carries
capabilitiesFromApi, set by the discoverer and true only where the tokens
were derived from the response payload — Mistral's capabilities object,
OpenRouter's supported_parameters and input_modalities, Ollama's
/api/show array, and Gemini's supportedGenerationMethods for a model
the table does not know. CapabilityVerifier 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/Model/List.html 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
(ModelController::verifyCapabilitiesAction) 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/Governance.html renders governance
profile deviations — GovernanceProfileEvaluator::deviations() 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
CapabilityVerifier is the only writer. The wizard deliberately is not one:
by the time SetupWizardController::createModels() 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.
✕ supportsFeature('tools', 'ollama') 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 ProviderResponseException
rather than ProviderConnectionException — 503 has its own arm in
handleOpenRouterError() 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. FailureClassifier
(ADR-095) reads the carried HTTP status, not the exception
class: a ProviderResponseException with a 5xx code classifies as
FailureClass::SERVER_ERROR, which answers isRetryable() and
tripsCircuit() exactly as CONNECTION does, so FallbackMiddleware
hops either way. What it costs is the class a caller catches and the wording:
a handler with a catch (ProviderResponseException) arm ahead of a generic
one — ProviderController::testConnectionAction() is the in-tree case —
takes that arm for an OpenRouter 5xx and the generic ProviderException arm
for every other adapter's, and the message reads
OpenRouter API error (502): … 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/ai-platform
reaching 1.0, or a named customer requirement. Neither has fired, and a
conformance suite is not one of them.