Providers and capabilities¶
One protocol, ModelProvider, sits between the kit and every vendor. A provider
translates its own wire format into ModelRequest and ModelResponse and declares, as
data, what its model can do. Nothing above the provider layer names a vendor type, and
nothing in the kit finds out a model's limits by exceeding them.
Import the surface from tesserix_adk.models:
from tesserix_adk.models import (
Capability,
ModelCapabilities,
ModelProvider,
ModelRef,
ModelRequest,
ModelResponse,
ModelSpec,
)
The types themselves are defined in core — a protocol typed over types that live above
it could not be implemented from below — and re-exported here, which is where a provider
author looks for them.
What a provider implements¶
| Member | Contract |
|---|---|
name |
Stable identifier, recorded on the run and used in routing. |
capabilities |
A ModelCapabilities record. Read at wiring time and before each request. |
complete(request) |
One completion. Vendor errors are translated into the kit's hierarchy. |
stream(request) |
Incremental events. Refuses with CapabilityError unless streaming is declared. |
count_tokens(messages) |
How many tokens the messages occupy, by this provider's own count. |
count_tokens belongs to the provider because every vendor counts differently, and it is
what the declared context window is checked against. A provider that ships no tokeniser
can use tesserix_adk.testing.estimate_tokens, which counts characters.
The capability record¶
ModelCapabilities(
structured_output=True,
tool_calling=True,
parallel_tool_calls=False,
vision=False,
streaming=True,
context_window_tokens=200_000,
max_output_tokens=8_192,
)
Every field defaults to off or unknown. Silence is not a claim: a capability nobody declared is one the kit will not assume, because assuming it moves the failure to the first request that needed it — in production, on someone's budget, mid-run.
declared is the set of capabilities that are on. supports(requirement) answers a
question; require(requirement, provider=..., model=...) raises a CapabilityError
naming all three, because "unsupported" on its own leaves the caller to work out which of
the three to change.
New capabilities arrive as fields with defaults, never as required arguments. That is a 1.0 promise: a provider written against an earlier version keeps compiling.
What is checked, and when¶
| Check | When | Failure |
|---|---|---|
| Provider satisfies the protocol | AgentRunner(...) |
ProtocolConformanceError |
A tool registry against tool_calling |
AgentRunner(...) |
CapabilityError |
An agent naming tools against tool_calling |
Before the first request | CapabilityError |
An image part against vision |
Before each request | CapabilityError |
Prompt length against context_window_tokens |
Before each request | ContextWindowExceededError |
Schema enforcement against structured_output |
Before each request | Falls back to the prompt |
A response that is not a ModelResponse |
After each call | ModelResponseError |
Wiring failures are raised where the wiring is, which is the thing the caller can still change. A window nobody declared is not a limit to check — the run proceeds.
The context-window check exists because a vendor handed an over-long prompt truncates it
and answers anyway, so the first sign of the problem is an answer that ignores the
beginning of the case. ContextWindowExceededError carries counted and limit.
structured_output is the one capability whose absence degrades rather than refuses: the
schema goes into the prompt instead and the answer is validated the same way. See
docs/structured-output.md.
ModelResponseError is distinct from SchemaViolationError. A well-formed answer in the
wrong shape is repairable, and goes to the repair flow. A payload that is not an answer
at all is a provider implementation fault: it carries the raw payload and the provider's
request id, and nothing is invented from it.
Addressing a model¶
ModelRef.parse("anthropic:claude-sonnet-5") # provider:model
ModelSpec(provider="vllm", model="llama-3.3-70b").with_capabilities(vision=False)
The provider is part of a model's identity rather than a lookup, because a vendor API and
an OpenAI-compatible proxy serve the same model ids and are not the same model.
Defaulting the provider is how a proxy's traffic ends up recorded against the vendor, so
ModelRef.parse refuses a reference without one.
A self-hosted endpoint serves the weights it was given rather than the ones on the model
card. ModelCapabilities.declaring(**overrides) and ModelSpec.with_capabilities(...)
narrow a record from configuration, so a deployment never needs a subclass to say what it
actually runs.
Proving a provider¶
A provider is substitutable only if it behaves the way the runtime assumes, and structural typing cannot express "a declaration does not change between reads". Inherit the suite:
from tesserix_adk.testing import ModelProviderConformance
class TestVLLMProvider(ModelProviderConformance):
def make_provider(self):
return VLLMProvider(endpoint="http://localhost:8000")
Adding a member to the protocol means adding its case to the suite in the same change, so every implementation learns about it by failing rather than by drifting.
For tests that need no provider at all, ScriptedProvider declares its capabilities
explicitly — capabilities=CAPABLE.declaring(structured_output=True) — so capability
gating can be proven without a network.
Runnable: examples/providers.py.
The vendor adapters¶
Three ship with the kit, under tesserix_adk.models.providers:
from tesserix_adk.models.providers import AnthropicProvider, GeminiProvider, OpenAIProvider
provider = AnthropicProvider("claude-sonnet-4-5") # key from ANTHROPIC_API_KEY
Each takes a model id as the vendor spells it, reads its capabilities and prices from the model catalogue, and resolves its key on every call rather than at construction, so a rotated key is picked up without a restart. Nothing else is required:
| Provider | Endpoint | Key |
|---|---|---|
AnthropicProvider |
/v1/messages |
ANTHROPIC_API_KEY |
OpenAIProvider |
/v1/chat/completions |
OPENAI_API_KEY |
GeminiProvider |
/v1beta/models/{model}:generateContent |
GEMINI_API_KEY |
Options are shared: capabilities overrides what the catalogue says, secrets injects a
SecretProvider, api_key_variable renames the variable, base_url reaches a proxy or a
self-hosted endpoint, timeout bounds the call, and transport replaces the HTTP
transport, which is what the recorded tests use.
They speak HTTP directly rather than through vendor SDKs. httpx is already a dependency,
so each adapter is one request shape and one response shape instead of a second dependency
graph and a second translation — and the traffic can be recorded at the HTTP layer, which
is where the interesting half of an adapter's behaviour lives.
Where the three differ¶
The differences the adapters absorb, so nothing above them has to:
| Anthropic | OpenAI | Gemini | |
|---|---|---|---|
| System prompt | Top-level system |
A system turn |
systemInstruction |
| Structured output | Forced tool, unwrapped on the way back | response_format, strict only when the schema qualifies |
responseSchema, pruned of keywords the vendor rejects |
| Tool results | Merged into one user turn of tool_result blocks |
One tool turn each |
functionResponse, matched back by tool name |
| Tool call ids | Sent | Sent | Not sent — minted by the adapter |
| Stop reason | Reported | Reported | STOP either way, so it is read off the parts |
An adapter that believed Gemini's STOP would return a finished turn for a model that had
asked for a tool, and the caller would never run it.
Recording the traffic¶
HttpCassette records exchanges at the HTTP layer and HttpReplay serves them back
through an httpx transport, so the whole matrix runs in CI with no network and no keys:
from tesserix_adk.testing import FakeSecrets, HttpCassette, HttpExchange, HttpReplay
replay = HttpReplay(HttpCassette(provider="openai", exchanges=(HttpExchange(...),)))
provider = OpenAIProvider(
"gpt-4o", secrets=FakeSecrets({"OPENAI_API_KEY": "test"}), transport=replay.transport
)
replay.sent is the list of requests the adapter actually made. Asserting on it is how a
dropped system prompt or a mis-shaped tool result is caught — a provider-level recording
cannot see any of it, because by then the translation has already happened.
Runnable: examples/vendor_providers.py.
Hosted OpenAI-compatible APIs¶
Groq, xAI/Grok, and OpenRouter use the compatible adapter with reviewed endpoint and credential presets:
from tesserix_adk.core import ModelCapabilities
from tesserix_adk.models.providers import GROQ, OpenAICompatibleProvider
provider = OpenAICompatibleProvider(
"your-groq-model",
preset=GROQ,
capabilities=ModelCapabilities(
tool_calling=True,
streaming=True,
context_window_tokens=32_000,
),
)
| Preset | Completion URL | Credential |
|---|---|---|
GROQ |
https://api.groq.com/openai/v1/chat/completions |
GROQ_API_KEY |
XAI / GROK |
https://api.x.ai/v1/chat/completions |
XAI_API_KEY |
OPENROUTER |
https://openrouter.ai/api/v1/chat/completions |
OPENROUTER_API_KEY |
Capabilities remain per deployed model. A router serving hundreds of models does not have
one capability record. Static routing/attribution headers are supported, while
Authorization and Content-Type remain owned by the adapter.
Copyable recipes, including custom gateway paths, are in Provider recipes.
Endpoints you run yourself¶
vLLM, Ollama and TGI serve the same wire format, so they go through the same adapter:
from tesserix_adk.core import ModelCapabilities
from tesserix_adk.models.providers import VLLM, OpenAICompatibleProvider
provider = OpenAICompatibleProvider(
"qwen2.5-7b-instruct",
base_url="http://vllm.models.svc.cluster.local:8000",
capabilities=ModelCapabilities(
tool_calling=True, structured_output=True, streaming=True, context_window_tokens=32768
),
preset=VLLM,
)
Two arguments have no default. base_url, because there is no host to guess for a service
only you have named — in-cluster service DNS is the expected form, and no egress is needed
for it. And capabilities, because the deployment's own flags decide them: whether tool
calling was enabled, what --max-model-len was set to. Nothing the endpoint reports says.
The provider is named for the server rather than for OpenAI, so a run against a box in the
cluster is not recorded against the vendor's bill. name= distinguishes two deployments.
api_key_variable is optional. Leave it out and no Authorization header is sent at all,
which is the in-cluster case; name a variable and it is read per call like any other key.
What the presets absorb¶
| vLLM | Ollama | TGI | |
|---|---|---|---|
strict on response_format |
Not claimed | Not claimed | Not claimed |
stream_options.include_usage |
Sent | Not sent | Not sent |
| Tool-call ids | Sent by the server | Minted by the adapter | Sent by the server |
| Default timeout | 120s | 300s | 120s |
Three deviations are handled for every preset, because every one of them is a wrong answer rather than an error if it is passed on:
- An error under a 200. Several compatible servers answer
{"error": ...}with a 200 and mean it. It raisesProviderError; no response is assembled from a failure. - A missing stop reason.
unknownon a turn that asked for a tool ends the run with the call never made, so the reason is read off what actually came back. - Missing usage. Zero tokens reads as a free call, and a call on a GPU somebody is
paying for is not free. The counts are estimated and
Usage.estimatedisTrue, so a ledger can tell a count from a guess. The cost staysNone— the kit does not know what your GPU hour is worth, and zero would be a false statement.
Nothing is emulated unless you ask¶
Where a model cannot enforce a schema itself, the kit can ask for JSON in the prompt and
validate the reply. Against a small self-hosted model that is a schema enforced by nobody,
so OpenAICompatibleProvider refuses instead: an agent with an output_type against an
endpoint that has not declared structured_output raises CapabilityError naming it,
before the run starts. Pass emulates=True to have the old behaviour anyway.
When it is not there¶
A connection that never landed, and 502/503/504, raise ProviderUnavailableError — a
ProviderError, so existing handling still catches it, and always retryable. Any
Retry-After the endpoint sent is on the error and is believed in preference to a
computed backoff: retrying a model that is still loading its weights as fast as the policy
allows is how it never finishes loading.
What every other vendor failure arrives as, what it carries and what it deliberately does not, and the timeouts and rate limiting in front of the call, are in resilience.md.
Runnable: examples/self_hosted_provider.py.
Azure OpenAI, Amazon Bedrock, Vertex AI, and other APIs with different authentication,
paths, payloads, or streaming contracts need dedicated ModelProvider adapters.
Changing base_url alone does not make another wire protocol compatible.