Build Capability Contracts for Multi-Model AI Gateways
Most multi-model gateways start with a simple promise: keep the OpenAI-compatible client shape and switch the model field when a workload needs a different provider. That is a useful interface. It is not enough for production.
A production gateway needs to know more than the name of a model. It needs to know whether the route supports tool calls, structured output, streaming, cache accounting, long context, batch execution, reasoning controls, and the output length your application expects. It also needs to know which pricing page was checked, when it was checked, and which token classes the estimate used.
That is the job of a capability contract.
A capability contract is a small, source-dated record that says what a route is allowed to do and how the application will verify it before traffic moves. It is not a marketing model card. It is closer to an API compatibility test plan that can be executed in CI, read by finance, and used by a router during incidents.
The live source checks for this article were refreshed on August 24, 2026. AIWave's public pricing page lists all-day DeepSeek V4 gateway rows checked on 2026-08-19: V4 Flash at $0.638 input, $1.914 output, and $0.0203 cache-hit input per 1M tokens; V4 Pro at $1.914 input, $5.742 output, and $0.0638 cache-hit input per 1M tokens. DeepSeek's official API docs list direct V4 Flash and V4 Pro rows with OpenAI-compatible and Anthropic-compatible base URLs, 1M context, 384K maximum output, tool calls, JSON output, and concurrency limits of 2500 for Flash and 500 for Pro. Kimi's K3 page lists a 2.8T-parameter model, native vision, 1M-token context, API availability, and $0.30/MTok cache-hit input, $3.00/MTok cache-miss input, and $15.00/MTok output. Z.AI lists GLM-5.1 at $1.40 input, $0.26 cached input, and $4.40 output per 1M tokens. QwenCloud documents context-tiered pricing, Batch API rates at 50% of real-time pricing, context caching, thinking-token output billing, and built-in tool fees.
The point is not that one route always wins. The point is that each route has a different contract surface. A gateway that treats every model as "chat in, text out" will eventually break a workload in a way that looks like a quality regression, a spend surprise, or a support incident.
What Belongs in a Capability Contract
A useful contract should be explicit enough that a router can reject an unsafe switch before it reaches users.
| Contract field | Why it matters |
|---|---|
model |
The exact production model name, not a loose alias |
provider_family |
Helps isolate provider incidents and pricing changes |
source_url |
Shows where the capability or price came from |
source_checked_at |
Prevents silent use of stale rate cards |
context_window_tokens |
Blocks prompts that exceed tested context limits |
max_output_tokens |
Protects latency and output spend |
supports_tools |
Stops tool workflows from moving to text-only routes |
supports_json_mode |
Protects structured extraction and validation jobs |
supports_streaming |
Protects user-facing latency contracts |
cache_accounting |
Separates fresh input from cache-hit input |
batch_allowed |
Keeps async jobs away from interactive lanes unless declared |
pricing_units |
Makes estimates reproducible |
fallbacks |
Lists routes approved by tests, not by guesswork |
This record should live with your gateway configuration, not inside a blog post or a spreadsheet alone. The application should load it, validate it, and attach its version to every usage event.
A Minimal Contract Format
Start with a JSON document. Keep it boring.
{
"contract_version": "model-capability-2026-08-24",
"model": "deepseek-v4-flash",
"provider_family": "deepseek",
"gateway": "aiwave",
"source_url": "https://aiwave.live/pricing",
"source_checked_at": "2026-08-24",
"rate_card_date": "2026-08-19",
"context_window_tokens": 1000000,
"max_output_tokens": 8000,
"supports_tools": true,
"supports_json_mode": true,
"supports_streaming": true,
"cache_accounting": "separate_cache_hit_input",
"pricing_per_million_tokens": {
"input": 0.638,
"cache_hit_input": 0.0203,
"output": 1.914
},
"approved_task_classes": [
"support_summary",
"classification",
"short_extraction"
],
"fallbacks": [
"glm-5.1",
"qwen3.8-turbo"
]
}
This is intentionally narrow. A single contract should describe one route. If the same model is used through two gateways, create two contracts. If the provider changes a price row, create a new contract version instead of editing the old row in place. Historical usage should always point to the contract that was true when the request ran.
Why Contracts Beat Runtime Guessing
Runtime guessing feels convenient until a workload depends on a capability that is not universal.
A JSON extraction workflow may pass with one model because JSON mode is supported and fail with another because the router only changed the model name. A tool-calling workflow may work on a route that supports function calls and fail on a route that treats tool schemas as plain prompt text. A long-context coding job may fit in one route and quietly truncate or degrade on another. A batch evaluation may qualify for an async lane while a customer chat reply cannot wait.
Contracts force these differences into a reviewable shape.
| Risk | Contract check |
|---|---|
| Hidden alias drift | Pin exact model names and source dates |
| Output bill expansion | Enforce tested max_output_tokens per task class |
| Tool-call mismatch | Require supports_tools=true for tool workflows |
| JSON parse failures | Require structured-output acceptance tests |
| Cache forecast error | Store cache accounting mode and actual cache tokens |
| Latency regression | Separate streaming support from batch support |
| Incident blast radius | Limit fallbacks to routes already tested |
The router can still be dynamic. The contract makes sure it is dynamic inside known boundaries.
Contract Tests in Python
The following example validates a route before it enters an approved route set. It checks required fields, scans source dates, estimates a small request, and runs an OpenAI-compatible smoke test without hardcoding a real key.
import os
from dataclasses import dataclass
from datetime import date
from typing import Any
from openai import OpenAI
TODAY = date(2026, 8, 24)
@dataclass(frozen=True)
class CapabilityContract:
contract_version: str
model: str
provider_family: str
gateway: str
source_url: str
source_checked_at: str
rate_card_date: str
context_window_tokens: int
max_output_tokens: int
supports_tools: bool
supports_json_mode: bool
supports_streaming: bool
cache_accounting: str
pricing_per_million_tokens: dict[str, float]
approved_task_classes: list[str]
fallbacks: list[str]
def parse_contract(raw: dict[str, Any]) -> CapabilityContract:
required = [
"contract_version",
"model",
"provider_family",
"gateway",
"source_url",
"source_checked_at",
"rate_card_date",
"context_window_tokens",
"max_output_tokens",
"supports_tools",
"supports_json_mode",
"supports_streaming",
"cache_accounting",
"pricing_per_million_tokens",
"approved_task_classes",
"fallbacks",
]
missing = [field for field in required if field not in raw]
if missing:
raise ValueError(f"contract missing fields: {missing}")
return CapabilityContract(**raw)
def assert_fresh(contract: CapabilityContract, max_age_days: int = 14) -> None:
checked = date.fromisoformat(contract.source_checked_at)
age = (TODAY - checked).days
if age > max_age_days:
raise ValueError(
f"{contract.model} contract source is {age} days old; recheck {contract.source_url}"
)
def estimate_usd(contract: CapabilityContract, input_tokens: int, cache_hit_tokens: int, output_tokens: int) -> float:
rates = contract.pricing_per_million_tokens
fresh_tokens = max(input_tokens - cache_hit_tokens, 0)
cache_rate = rates.get("cache_hit_input", rates["input"])
total = (
fresh_tokens / 1_000_000 * rates["input"]
+ cache_hit_tokens / 1_000_000 * cache_rate
+ output_tokens / 1_000_000 * rates["output"]
)
return round(total, 6)
def assert_task_allowed(contract: CapabilityContract, task_class: str, needs_tools: bool, needs_json: bool) -> None:
if task_class not in contract.approved_task_classes:
raise ValueError(f"{contract.model} is not approved for {task_class}")
if needs_tools and not contract.supports_tools:
raise ValueError(f"{contract.model} is not approved for tool workflows")
if needs_json and not contract.supports_json_mode:
raise ValueError(f"{contract.model} is not approved for JSON output")
def smoke_test_chat(contract: CapabilityContract) -> None:
client = OpenAI(
base_url="https://aiwave.live/v1",
api_key=os.environ.get("AIWAVE_API_KEY", "YOUR_API_KEY_HERE"),
)
response = client.chat.completions.create(
model=contract.model,
messages=[{"role": "user", "content": "Reply with exactly: contract-ok"}],
max_tokens=20,
temperature=0,
)
text = response.choices[0].message.content or ""
if "contract-ok" not in text:
raise RuntimeError(f"unexpected smoke-test response for {contract.model}: {text!r}")
raw_contract = {
"contract_version": "model-capability-2026-08-24",
"model": "deepseek-v4-flash",
"provider_family": "deepseek",
"gateway": "aiwave",
"source_url": "https://aiwave.live/pricing",
"source_checked_at": "2026-08-24",
"rate_card_date": "2026-08-19",
"context_window_tokens": 1000000,
"max_output_tokens": 8000,
"supports_tools": True,
"supports_json_mode": True,
"supports_streaming": True,
"cache_accounting": "separate_cache_hit_input",
"pricing_per_million_tokens": {"input": 0.638, "cache_hit_input": 0.0203, "output": 1.914},
"approved_task_classes": ["support_summary", "classification", "short_extraction"],
"fallbacks": ["glm-5.1", "qwen3.8-turbo"],
}
contract = parse_contract(raw_contract)
assert_fresh(contract)
assert_task_allowed(contract, "support_summary", needs_tools=False, needs_json=True)
print({"estimated_usd": estimate_usd(contract, 12000, 8000, 700)})
print("Run smoke_test_chat(contract) in CI only when a test key is present.")
The key point is not the code itself. The key point is that model approval becomes executable. A pull request that changes a route must update a contract, pass the contract tests, and explain why the fallback set is still valid.
Separate Capability From Preference
Do not mix "can do" with "we prefer it." A route can support 1M context and still be the wrong choice for a short classification task. A route can have a lower direct-provider output rate and still be the wrong choice if your team needs one endpoint, one billing ledger, and one tested fallback policy. A route can support tools and still fail your tool-use acceptance set.
Use two layers:
| Layer | Example |
|---|---|
| Capability contract |
supports_tools=true, context_window_tokens=1000000, cache_accounting=separate_cache_hit_input
|
| Routing policy | Use this route for support summaries under 900 output tokens |
This separation prevents preference from hiding missing capability. It also helps procurement. Finance can inspect the rate-card date, while engineering can inspect the acceptance criteria.
Pricing Dates Are Part of the Contract
Pricing is not stable enough to keep in prose alone. The contract should store source_checked_at, rate_card_date, source_url, and separate rates for input, cache-hit input, and output. If a provider uses additional dimensions such as context tiers, batch lanes, thinking tokens, or tool fees, add those fields explicitly.
QwenCloud is a good example of why this matters. Its pricing docs describe request-size tiers where a request belongs to one tier based on total input tokens, Batch API rates at 50% of real-time pricing, context caching discounts, thinking tokens billed as output, and built-in tool fees. That is more than a single input and output row.
DeepSeek is another example. The official page exposes direct model rows and concurrency limits. AIWave exposes gateway rows with a dated all-day rate card. Both can be true because they describe different buying paths. A contract must say which path it represents.
Contract-Aware Routing
Once contracts exist, the router can make decisions without guessing.
def choose_route(task: dict, contracts: list[CapabilityContract]) -> CapabilityContract:
candidates = []
for contract in contracts:
try:
assert_fresh(contract)
assert_task_allowed(
contract,
task_class=task["task_class"],
needs_tools=task.get("needs_tools", False),
needs_json=task.get("needs_json", False),
)
except ValueError:
continue
if task["estimated_input_tokens"] > contract.context_window_tokens:
continue
if task["max_output_tokens"] > contract.max_output_tokens:
continue
estimated = estimate_usd(
contract,
input_tokens=task["estimated_input_tokens"],
cache_hit_tokens=task.get("estimated_cache_hit_tokens", 0),
output_tokens=task["max_output_tokens"],
)
if estimated <= task["budget_ceiling_usd"]:
candidates.append((estimated, contract))
if not candidates:
raise RuntimeError("no approved route fits this task contract")
candidates.sort(key=lambda item: item[0])
return candidates[0][1]
This router is intentionally conservative. It does not infer that a model is acceptable because the name looks similar. It does not assume a fallback can replace a route because both are chat models. It only chooses from contracts that are fresh, approved for the task class, and within the caller's declared budget.
Observability Fields
Every request should record which contract was used. That lets you answer "what changed?" when a bill, latency number, or quality metric moves.
| Field | Example |
|---|---|
ai.contract.version |
model-capability-2026-08-24 |
ai.contract.source_url |
https://aiwave.live/pricing |
ai.contract.rate_card_date |
2026-08-19 |
ai.route.model |
deepseek-v4-flash |
ai.route.gateway |
aiwave |
ai.task.class |
support_summary |
ai.estimate.usd |
0.003205 |
ai.usage.input_tokens |
12000 |
ai.usage.cache_hit_tokens |
8000 |
ai.usage.output_tokens |
642 |
ai.fallback.used |
false |
These fields are more useful than a generic "model switched" log. They show whether the route was approved, whether the pricing source was current, and whether the actual usage matched the estimate.
Rollout Plan
Start with your top three task classes. For example: support summaries, short extraction, and coding review. Create one contract per approved route and run smoke tests in CI.
Next, attach contract versions to usage logs without changing routing. This shadow period will show which tasks exceed output caps, which routes have stale source dates, and which prompts depend on capabilities that were never documented.
Then enforce source freshness. If a contract is older than your allowed window, block new route approvals and require a source recheck. This is a low-risk control because it does not change model quality; it only prevents stale configuration from spreading.
After that, enforce task-class approval. A route can serve a task only after it has passed the acceptance set for that task. This is the point where contracts start reducing production incidents.
Finally, enforce fallback limits. During an outage, the gateway may reroute only to contracts listed in the original route's fallback set. If no approved fallback exists, fail clearly instead of silently moving a workload to an untested model.
Source Links for Your Contracts
For AIWave routes, keep dated links to the AIWave pricing page, AIWave predictable-pricing page, model catalog, and Chat Completions documentation.
For provider calibration, keep current links to DeepSeek pricing, Kimi K3, Z.AI pricing, and QwenCloud pricing. Recheck them before changing a contract or a monthly forecast.
Final Checklist
Before a model route is approved, make sure the contract answers these questions:
| Question | Pass condition |
|---|---|
| Is the source current? |
source_checked_at is within the allowed freshness window |
| Is the route exact? | Model name and gateway are pinned |
| Is pricing reproducible? | Input, cache-hit input, output, and extra fees are separated |
| Is the task approved? | The route passed an acceptance set for that task class |
| Are limits explicit? | Context and output caps are stored in the contract |
| Are tools safe? | Tool workflows require a route that passed tool-call tests |
| Is fallback bounded? | Only tested fallback routes are listed |
| Is usage auditable? | Every request logs the contract version and rate-card date |
Capability contracts make multi-model routing less mysterious. Instead of asking whether a model is generally good, the gateway asks a narrower question: is this exact route, with this dated source, approved for this exact task under this budget and capability set? That is the question production systems can answer reliably.
Top comments (0)