DEV Community

Mattias chaw
Mattias chaw

Posted on

Build Capability Contracts for Multi-Model AI Gateways

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"
  ]
}
Enter fullscreen mode Exit fullscreen mode

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.")
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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)