aiml
SearchMy learning
Menu

Lesson 9 of 21Module 3

Build a portable model adapter with contract tests

Use one chat contract for Ollama’s native and OpenAI-compatible APIs. Test recorded traffic for request, response and error behavior.

Prerequisites

Build a local model request tracer, or the same understanding of /api/chat fields, finish reasons and failure paths. Python dataclasses, exceptions and unittest from Programming for the builds.

By the end, you can

  • Define a chat contract with a request, a normalized result and named error kinds that application code can depend on.
  • Implement two adapters for different wire formats and run one shared contract test suite against each.
  • Find silent differences between APIs, such as default thinking and an option one API cannot carry, from recorded traffic.
Runtime
Python 3.11+ and Ollama
Dependencies
Python standard library; local Gemma 4 E2B and Qwen3.5 0.8B models via Ollama for recording

Lab code, tests and fixtures (ZIP, 14 KB)Quiz check your understanding

Progress is saved in this browser.

Problem

When you need this. Use this when your application talks to more than one model API format and you need a single typed adapter backed by shared contract tests.

The request tracer and the first local client both speak Ollama’s native API, /api/chat, directly. Their code reads done_reason, prompt_eval_count and eval_count. Move to another server and those names are gone. Many servers, including llama.cpp’s llama-server, vLLM and Ollama itself, also offer an OpenAI-compatible API at /v1/chat/completions, with different field names: finish_reason, usage.prompt_tokens, max_tokens.

Renaming fields is the easy part. The hard part is that two APIs can accept the same intent and behave differently without an error: a setting one of them cannot carry, a default that changes the reply, an error in another shape. Application code that is ported field by field inherits each of these silently.

This build puts one contract in front of both wire formats and tests each adapter against it. Success check: the same contract test methods pass for both adapters against traffic recorded from both endpoints with two models, the two adapters return equal results for every recorded case, and a deliberate omission in either adapter makes a named test fail.

Prerequisites: the request tracer, or the same knowledge of /api/chat, finish reasons and failure paths; Python dataclasses, exceptions and unittest from Programming for the builds.

Concepts

An adapter translates; a contract says what it must preserve

An adapter is code that translates between the shape your application uses and the shape one external system uses. A contract is the set of rules every adapter must follow, written so a test can check them. Ours has three parts, all in adapters.py:

Request
ChatRequest: messages as (role, text) pairs, an output limit, temperature, seed and an optional context size. validate checks it before any adapter builds a body: known roles, a system message only first, the last message from the user, limits in range.
Result
ChatResult: nonblank text, a finish of stop or length, and input and output token counts. One shared function, result, builds it for both adapters, so neither can accept a reply the other would refuse.
Errors
AdapterError with one of six kinds: connection, timeout, model_not_found, http, protocol and unsupported. The application handles kinds, never a server’s error text.

Two further rules are about behavior, not shapes. Thinking is off in every request, because a model that spends its output limit on hidden reasoning returns no text. And an option an API cannot carry is refused, never dropped: dropping it would let two adapters answer the same request under different settings.

A contract test runs once per adapter

A contract test checks an adapter against the contract, not against its own implementation. The suite is written once, as a class of test methods, and each adapter gets a subclass that only names the adapter. A new adapter is supported when it passes every method unchanged. This is different from a unit test of one function and from an outcome check of answer quality: a contract test says the adapter preserves the contract’s meaning, and nothing about whether the model’s answer is right.

The two wire formats, side by side

These are the bodies each adapter sent for the same ChatRequest, taken from the recording:

Contract field Native /api/chat OpenAI-compatible /v1/chat/completions
Output limit options.num_predict: 48 max_tokens: 48
Thinking off think: false reasoning_effort: "none"
Context size options.num_ctx No field; the adapter refuses it
Finish done_reason choices[0].finish_reason
Token counts prompt_eval_count, eval_count usage.prompt_tokens, usage.completion_tokens
Missing model HTTP 404, {"error":"model 'aiml-missing:never' not found"} HTTP 404, an error object with message and type "not_found_error"

Ollama documents that its OpenAI-compatible API “does not have a way of setting the context size for a model”; changing it there means creating a new model with a different num_ctx parameter. That is why the contract’s context_tokens is optional, and why one adapter must refuse it.

Architecture

Two wire formats share one application contract and one local model; unsupported options are refused
Application ChatRequestone contract for either adapterValidate the requestroles, text and numeric limitsValueErrorinvalid request; nothing sentNative request bodythink: false; num_predictnum_ctx when requestedCompatible request bodyreasoning_effort: nonemax_tokensAdapterError: unsupportedcompatible API: context size setOllamaSame pinned model weightsnative and OpenAI-compatible endpoints/api/chat/v1/chat/completionsNative response parsermessage, finish and eval countsCompatible response parserchoice, finish and usage countsShared result validationnonblank text; stop or lengthChatResult: text, finish, tokensApplication ChatRequestsame contract for either adapterValidate the requestroles, text and numeric limitsValueErrorinvalid; nothing sentNativebodythink: falsenum_predictCompatiblebodyeffort: nonemax_tokensunsupportedcontext size setOllamaSame pinned weightstwo API endpointsNativeparsermessage, finisheval countsCompatibleparserchoice, finishusage countsShared result validationnonblank text; stop or lengthinvalid replies raise AdapterErrorChatResulttext, finish and token counts
Both adapters validate the same ChatRequest before writing their own body. Each makes one HTTP attempt to the same local Ollama model, reads its own response format and uses shared status, JSON and result checks. Transport, HTTP and malformed-response errors raise AdapterError; no adapter retries. The compatible path refuses a requested context size before sending.

Application code builds a ChatRequest and calls complete(model, request) on whichever adapter it was given. The request is checked first, so a malformed request fails the same way on both. Each adapter then writes its own body and posts it with the same standard-library function, http_post, which returns the HTTP status and body bytes and turns transport failures into connection or timeout. Both paths reach the same Ollama server and the same model. Each adapter reads its own response shape, and both pass what they read to the same result function. The process is one Python program and one local server; nothing is cached or retried.

Working code

Choose a model

Both models were checked on their publishers’ model cards on 2026-10-20 and recorded through both adapters. The adapters do not depend on the model; using two shows the contract holds for more than one.

Choice Model License Runtime Task compatibility Verification
Tested default Gemma 4 E2B IT, Ollama gemma4:e2b, digest b37049369adf Apache 2.0 Ollama 0.32.14, both endpoints Text chat with a system role Four cases recorded through each adapter
Smaller local option Qwen3.5 0.8B, Ollama qwen3.5:0.8b, digest de63045f2975 Apache 2.0 Ollama 0.32.14, both endpoints Text chat with a system role Four cases recorded through each adapter

Servers other than Ollama that offer /v1/chat/completions are candidates for the same adapter. None was tested here, so none is claimed as supported: each needs its own recorded traffic and a passing contract suite.

Read the adapters

Download the files listed in the README into one directory.

adapters.pySource · pythonDownload
"""One chat contract, two wire formats: Ollama's native /api/chat and the OpenAI-compatible
/v1/chat/completions. Application code depends on ChatRequest, ChatResult and AdapterError only.
Python 3.11+, standard library only.

python3 adapters.py --api openai-compatible "What does JSON stand for?"
"""
import argparse
import json
import math
import socket
from dataclasses import dataclass
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit
from urllib.request import Request, urlopen

ROLES = ('system', 'user', 'assistant')
FINISH = ('stop', 'length')
KINDS = ('connection', 'timeout', 'model_not_found', 'http', 'protocol', 'unsupported')


class AdapterError(RuntimeError):
    """A failure in one of the six kinds the contract names. No adapter retries."""

    def __init__(self, kind, message):
        assert kind in KINDS
        super().__init__(message)
        self.kind = kind


@dataclass(frozen=True)
class ChatRequest:
    messages: tuple
    max_tokens: int = 64
    temperature: float = 0.0
    seed: int | None = 42
    context_tokens: int | None = None


@dataclass(frozen=True)
class ChatResult:
    text: str
    finish: str
    input_tokens: int
    output_tokens: int


def validate(request):
    """The request half of the contract. Runs before any adapter builds a body."""
    messages = request.messages
    if not isinstance(messages, tuple) or not messages:
        raise ValueError('messages must be a nonempty tuple of (role, content) pairs.')
    for index, item in enumerate(messages):
        if not (isinstance(item, tuple) and len(item) == 2 and item[0] in ROLES
                and isinstance(item[1], str) and item[1].strip()):
            raise ValueError(f'Message {index} must be (role, text) with a known role and nonblank text.')
        if item[0] == 'system' and index != 0:
            raise ValueError('Only the first message may be a system message.')
    if messages[-1][0] != 'user':
        raise ValueError('The last message must come from the user.')
    if type(request.max_tokens) is not int or not 1 <= request.max_tokens <= 4096:
        raise ValueError('max_tokens must be an integer from 1 to 4096.')
    if (isinstance(request.temperature, bool) or not isinstance(request.temperature, (int, float))
            or not 0 <= request.temperature <= 2):
        raise ValueError('temperature must be a number from 0 to 2.')
    if request.seed is not None and type(request.seed) is not int:
        raise ValueError('seed must be an integer or None.')
    if request.context_tokens is not None and (type(request.context_tokens) is not int
                                               or request.context_tokens < 256):
        raise ValueError('context_tokens must be an integer of at least 256, or None.')


def http_post(url, payload, headers, timeout):
    """Send one JSON POST. Returns (status, body bytes); raises only for transport failures."""
    request = Request(url, data=json.dumps(payload).encode(), headers=headers)
    try:
        with urlopen(request, timeout=timeout) as response:
            return response.status, response.read()
    except HTTPError as error:
        return error.code, error.read()
    except (TimeoutError, socket.timeout) as error:
        raise AdapterError('timeout', 'The model server did not answer in time. No retry was sent.') from error
    except URLError as error:
        if isinstance(error.reason, (TimeoutError, socket.timeout)):
            raise AdapterError('timeout', 'The model server did not answer in time. No retry was sent.') from error
        raise AdapterError('connection', f'Cannot reach the model server: {error.reason}') from error
    except ConnectionError as error:
        raise AdapterError('connection', f'Cannot reach the model server: {error}') from error


def local_origin(base_url):
    url = urlsplit(base_url)
    if url.scheme != 'http' or url.hostname not in ('localhost', '127.0.0.1', '::1'):
        raise ValueError('These adapters accept only a local HTTP model server.')
    if url.username or url.password or url.query or url.fragment or url.path not in ('', '/'):
        raise ValueError('Use a server origin without credentials or a path.')
    return base_url.rstrip('/')


def decode(status, body):
    """Shared status and JSON handling. Each adapter then reads its own response shape."""
    try:
        data = json.loads(body)
    except (ValueError, UnicodeError):
        data = None
    if status == 404:
        raise AdapterError('model_not_found', 'The server does not have this model. Pull it explicitly.')
    if status != 200:
        raise AdapterError('http', f'The model server returned HTTP {status}.')
    if not isinstance(data, dict):
        raise AdapterError('protocol', 'The response body is not a JSON object.')
    return data


def count(value, name):
    if type(value) is not int or value < 0:
        raise AdapterError('protocol', f'Missing or invalid token count: {name}.')
    return value


def result(text, finish, input_tokens, output_tokens):
    """The result half of the contract, shared so both adapters accept exactly the same results."""
    if not isinstance(text, str) or not text.strip():
        raise AdapterError('protocol', 'The reply has no text. Check that thinking is turned off.')
    if finish not in FINISH:
        raise AdapterError('protocol', f'Unsupported finish reason: {finish!r}.')
    return ChatResult(text.strip(), finish, input_tokens, output_tokens)


class OllamaChatAdapter:
    """Ollama's native API: POST /api/chat."""
    name = 'ollama-native'
    path = '/api/chat'

    def __init__(self, base_url='http://127.0.0.1:11434', post=http_post, timeout=120):
        if not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout <= 0:
            raise ValueError('Timeout must be positive and finite.')
        self.base_url, self.post, self.timeout = local_origin(base_url), post, timeout

    def body(self, model, request):
        options = {'num_predict': request.max_tokens, 'temperature': request.temperature}
        if request.seed is not None:
            options['seed'] = request.seed
        if request.context_tokens is not None:
            options['num_ctx'] = request.context_tokens
        return {'model': model, 'messages': [{'role': r, 'content': c} for r, c in request.messages],
                'stream': False, 'think': False, 'options': options}

    def parse(self, status, body):
        data = decode(status, body)
        message = data.get('message')
        if data.get('done') is not True or not isinstance(message, dict) or message.get('role') != 'assistant':
            raise AdapterError('protocol', 'Expected a completed assistant message.')
        if message.get('tool_calls'):
            raise AdapterError('protocol', 'The reply asks for a tool call; this contract has none.')
        return result(message.get('content'), data.get('done_reason'),
                      count(data.get('prompt_eval_count'), 'prompt_eval_count'),
                      count(data.get('eval_count'), 'eval_count'))

    def complete(self, model, request):
        validate(request)
        status, body = self.post(self.base_url + self.path, self.body(model, request),
                                 {'Content-Type': 'application/json'}, self.timeout)
        return self.parse(status, body)


class OpenAICompatibleAdapter(OllamaChatAdapter):
    """The OpenAI-compatible API that Ollama also serves: POST /v1/chat/completions."""
    name = 'openai-compatible'
    path = '/v1/chat/completions'

    def body(self, model, request):
        if request.context_tokens is not None:
            raise AdapterError('unsupported', 'This API cannot set the context size; leave context_tokens unset.')
        body = {'model': model, 'messages': [{'role': r, 'content': c} for r, c in request.messages],
                'max_tokens': request.max_tokens, 'temperature': request.temperature,
                'stream': False, 'reasoning_effort': 'none'}
        if request.seed is not None:
            body['seed'] = request.seed
        return body

    def parse(self, status, body):
        data = decode(status, body)
        choices = data.get('choices')
        if not isinstance(choices, list) or len(choices) != 1 or not isinstance(choices[0], dict):
            raise AdapterError('protocol', 'Expected exactly one choice.')
        message, usage = choices[0].get('message'), data.get('usage')
        if not isinstance(message, dict) or message.get('role') != 'assistant' or not isinstance(usage, dict):
            raise AdapterError('protocol', 'Expected an assistant message and a usage object.')
        if message.get('tool_calls'):
            raise AdapterError('protocol', 'The reply asks for a tool call; this contract has none.')
        return result(message.get('content'), choices[0].get('finish_reason'),
                      count(usage.get('prompt_tokens'), 'prompt_tokens'),
                      count(usage.get('completion_tokens'), 'completion_tokens'))


ADAPTERS = {adapter.name: adapter for adapter in (OllamaChatAdapter, OpenAICompatibleAdapter)}


def main():
    parser = argparse.ArgumentParser(description='Ask one question through either adapter.')
    parser.add_argument('question')
    parser.add_argument('--api', choices=sorted(ADAPTERS), default='ollama-native')
    parser.add_argument('--model', default='gemma4:e2b')
    parser.add_argument('--base-url', default='http://127.0.0.1:11434')
    parser.add_argument('--max-tokens', type=int, default=64)
    args = parser.parse_args()
    request = ChatRequest((('system', 'Answer in one short sentence.'), ('user', args.question)),
                          max_tokens=args.max_tokens)
    try:
        reply = ADAPTERS[args.api](args.base_url).complete(args.model, request)
    except (ValueError, AdapterError) as error:
        kind = getattr(error, 'kind', 'request')
        parser.exit(1, f'{kind}: {error}\n')
    print(json.dumps(reply.__dict__, ensure_ascii=False))


if __name__ == '__main__':
    main()

Start with validate and result: they are the contract, and neither belongs to an adapter. decode is shared too. It maps HTTP 404 to model_not_found before reading the body, because the two APIs put the reason in different places, and any other non-200 status to http.

OllamaChatAdapter.body puts the limits in options and sends think: false. OpenAICompatibleAdapter inherits complete and the constructor, and replaces only body, parse and path. Its body raises unsupported for a context size before anything is sent, and sends reasoning_effort: "none" as its way of turning thinking off. Each parse reads its own shape, rejects tool calls and a second choice, and hands text, finish reason and counts to result.

http_post returns an HTTP error as a status and body instead of raising, so decode decides what it means. It raises only when there is no response at all, and never retries: a timed-out request may still be running on the server.

Record both wire formats

record_wire.py record sends four cases per model through each adapter’s own body and saves the exact request, status and response body. The fourth case, thinking-left-on, removes the adapter’s thinking switch to show what the server does by default. Without record, it replays the saved responses through each adapter’s parse and writes the comparison:

Inspect record_wire.py
record_wire.pySource · pythonDownload
"""Record what each wire format sends and receives, then compare the adapters offline.

python3 record_wire.py record   # needs Ollama with gemma4:e2b and qwen3.5:0.8b; writes wire-record.json
python3 record_wire.py          # offline: replays wire-record.json through the adapters, writes contract-report.json
Python 3.11+, standard library only.
"""
import argparse
import json
import platform
import time
from pathlib import Path

from urllib.request import urlopen

from adapters import ADAPTERS, AdapterError, ChatRequest, http_post, local_origin, validate

HERE = Path(__file__).resolve().parent
MODELS = ('gemma4:e2b', 'qwen3.5:0.8b')
MESSAGES = (('system', 'Answer in one short sentence.'), ('user', 'What does HTTP stand for?'))
CASES = {
    'complete': ('{model}', ChatRequest(MESSAGES, max_tokens=48)),
    'output-limit': ('{model}', ChatRequest(MESSAGES, max_tokens=3)),
    'missing-model': ('aiml-missing:never', ChatRequest(MESSAGES, max_tokens=48)),
    'thinking-left-on': ('{model}', ChatRequest(MESSAGES, max_tokens=48)),
}
THINKING_KEYS = {'ollama-native': 'think', 'openai-compatible': 'reasoning_effort'}


def wire_body(adapter, model, case, request):
    """The adapter's own body, except the thinking-left-on case, which drops the thinking switch."""
    body = adapter.body(model, request)
    if case == 'thinking-left-on':
        body.pop(THINKING_KEYS[adapter.name])
    return body


def record(base_url='http://127.0.0.1:11434', post=http_post, clock=time.perf_counter):
    rows = []
    for model in MODELS:
        for name, Adapter in ADAPTERS.items():
            adapter = Adapter(base_url)
            for case, (target, request) in CASES.items():
                validate(request)
                body = wire_body(adapter, target.format(model=model), case, request)
                started = clock()
                status, raw = post(adapter.base_url + adapter.path, body, {'Content-Type': 'application/json'}, 120)
                rows.append({'model': model, 'adapter': name, 'case': case, 'request': body,
                             'status': status, 'body': raw.decode('utf-8'),
                             'client_ms': round((clock() - started) * 1000, 1)})
    origin = local_origin(base_url)
    with urlopen(origin + '/api/version', timeout=10) as response:
        version = json.load(response)['version']
    with urlopen(origin + '/api/tags', timeout=10) as response:
        digests = {m['name']: m['digest'] for m in json.load(response)['models'] if m['name'] in MODELS}
    return {'recorded_on': time.strftime('%Y-%m-%d'),
            'environment': {'python': platform.python_version(), 'platform': platform.platform(),
                            'ollama': version, 'digests': digests},
            'rows': rows}


def outcome(row):
    """Replay one recorded response through the adapter's own parser."""
    adapter = ADAPTERS[row['adapter']]()
    try:
        r = adapter.parse(row['status'], row['body'].encode('utf-8'))
    except AdapterError as error:
        return {'error': error.kind, 'message': str(error)}
    return {'text': r.text, 'finish': r.finish, 'input_tokens': r.input_tokens, 'output_tokens': r.output_tokens}


def report(record_data):
    """Offline and deterministic: one outcome per model, case and adapter, and whether the two agree."""
    table = {}
    for row in record_data['rows']:
        table.setdefault(row['model'], {}).setdefault(row['case'], {})[row['adapter']] = outcome(row)
    for cases in table.values():
        for outcomes in cases.values():
            native, compatible = outcomes['ollama-native'], outcomes['openai-compatible']
            outcomes['agree'] = native == compatible
    return table


def dumps(data):
    return json.dumps(data, indent=2, ensure_ascii=False) + '\n'


def main():
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument('mode', nargs='?', choices=['record', 'report'], default='report')
    parser.add_argument('--base-url', default='http://127.0.0.1:11434')
    args = parser.parse_args()
    if args.mode == 'record':
        (HERE / 'wire-record.json').write_text(dumps(record(args.base_url)))
    data = report(json.loads((HERE / 'wire-record.json').read_text()))
    (HERE / 'contract-report.json').write_text(dumps(data))
    for model, cases in data.items():
        for case, outcomes in cases.items():
            print(f"{model:14} {case:17} agree={outcomes['agree']!s:5} "
                  f"{outcomes['ollama-native'].get('text', outcomes['ollama-native'].get('error'))!r:50.50} "
                  f"{outcomes['openai-compatible'].get('text', outcomes['openai-compatible'].get('error'))!r:.50}")


if __name__ == '__main__':
    main()
Terminal commandsTerminal · bash
python3 record_wire.py record
python3 record_wire.py
contract-report.jsonRecorded output · jsonDownload
{
  "gemma4:e2b": {
    "complete": {
      "ollama-native": {
        "text": "HTTP stands for Hypertext Transfer Protocol.",
        "finish": "stop",
        "input_tokens": 26,
        "output_tokens": 9
      },
      "openai-compatible": {
        "text": "HTTP stands for Hypertext Transfer Protocol.",
        "finish": "stop",
        "input_tokens": 26,
        "output_tokens": 9
      },
      "agree": true
    },
    "output-limit": {
      "ollama-native": {
        "text": "HTTP stands for",
        "finish": "length",
        "input_tokens": 26,
        "output_tokens": 3
      },
      "openai-compatible": {
        "text": "HTTP stands for",
        "finish": "length",
        "input_tokens": 26,
        "output_tokens": 3
      },
      "agree": true
    },
    "missing-model": {
      "ollama-native": {
        "error": "model_not_found",
        "message": "The server does not have this model. Pull it explicitly."
      },
      "openai-compatible": {
        "error": "model_not_found",
        "message": "The server does not have this model. Pull it explicitly."
      },
      "agree": true
    },
    "thinking-left-on": {
      "ollama-native": {
        "text": "HTTP stands for Hypertext Transfer Protocol.",
        "finish": "stop",
        "input_tokens": 28,
        "output_tokens": 9
      },
      "openai-compatible": {
        "text": "HTTP stands for Hypertext Transfer Protocol.",
        "finish": "stop",
        "input_tokens": 28,
        "output_tokens": 9
      },
      "agree": true
    }
  },
  "qwen3.5:0.8b": {
    "complete": {
      "ollama-native": {
        "text": "HTTP stands for Hypertext Transfer Protocol.",
        "finish": "stop",
        "input_tokens": 29,
        "output_tokens": 9
      },
      "openai-compatible": {
        "text": "HTTP stands for Hypertext Transfer Protocol.",
        "finish": "stop",
        "input_tokens": 29,
        "output_tokens": 9
      },
      "agree": true
    },
    "output-limit": {
      "ollama-native": {
        "text": "HTTP stands for",
        "finish": "length",
        "input_tokens": 29,
        "output_tokens": 3
      },
      "openai-compatible": {
        "text": "HTTP stands for",
        "finish": "length",
        "input_tokens": 29,
        "output_tokens": 3
      },
      "agree": true
    },
    "missing-model": {
      "ollama-native": {
        "error": "model_not_found",
        "message": "The server does not have this model. Pull it explicitly."
      },
      "openai-compatible": {
        "error": "model_not_found",
        "message": "The server does not have this model. Pull it explicitly."
      },
      "agree": true
    },
    "thinking-left-on": {
      "ollama-native": {
        "error": "protocol",
        "message": "The reply has no text. Check that thinking is turned off."
      },
      "openai-compatible": {
        "error": "protocol",
        "message": "The reply has no text. Check that thinking is turned off."
      },
      "agree": true
    }
  }
}
Inspect the recorded traffic · wire-record.json
wire-record.jsonRecorded output · jsonDownload
{
  "recorded_on": "2026-10-20",
  "environment": {
    "python": "3.15.0",
    "platform": "macOS-27.0.1-arm64-arm-64bit-Mach-O",
    "ollama": "0.32.14",
    "digests": {
      "gemma4:e2b": "b37049369adfe3d2b653af0ab301a062ca5cbe96ace6aa0d3f2559ee9b563fc2",
      "qwen3.5:0.8b": "de63045f29755d4721c09d54c9b45c2dadb644f4fd7d604d4cac90d96049971c"
    }
  },
  "rows": [
    {
      "model": "gemma4:e2b",
      "adapter": "ollama-native",
      "case": "complete",
      "request": {
        "model": "gemma4:e2b",
        "messages": [
          {
            "role": "system",
            "content": "Answer in one short sentence."
          },
          {
            "role": "user",
            "content": "What does HTTP stand for?"
          }
        ],
        "stream": false,
        "think": false,
        "options": {
          "num_predict": 48,
          "temperature": 0.0,
          "seed": 42
        }
      },
      "status": 200,
      "body": "{\"model\":\"gemma4:e2b\",\"created_at\":\"2026-10-20T17:07:06.83998Z\",\"message\":{\"role\":\"assistant\",\"content\":\"HTTP stands for Hypertext Transfer Protocol.\"},\"done\":true,\"done_reason\":\"stop\",\"total_duration\":3253627750,\"load_duration\":3024329250,\"prompt_eval_count\":26,\"prompt_eval_duration\":85003000,\"eval_count\":9,\"eval_duration\":143246000}",
      "client_ms": 3264.1
    },
    {
      "model": "gemma4:e2b",
      "adapter": "ollama-native",
      "case": "output-limit",
      "request": {
        "model": "gemma4:e2b",
        "messages": [
          {
            "role": "system",
            "content": "Answer in one short sentence."
          },
          {
            "role": "user",
            "content": "What does HTTP stand for?"
          }
        ],
        "stream": false,
        "think": false,
        "options": {
          "num_predict": 3,
          "temperature": 0.0,
          "seed": 42
        }
      },
      "status": 200,
      "body": "{\"model\":\"gemma4:e2b\",\"created_at\":\"2026-10-20T17:07:12.478553Z\",\"message\":{\"role\":\"assistant\",\"content\":\"HTTP stands for\"},\"done\":true,\"done_reason\":\"length\",\"total_duration\":5637498417,\"load_duration\":5518662834,\"prompt_eval_count\":26,\"prompt_eval_duration\":84000000,\"eval_count\":3,\"eval_duration\":33523000}",
      "client_ms": 5638.1
    },
    {
      "model": "gemma4:e2b",
      "adapter": "ollama-native",
      "case": "missing-model",
      "request": {
        "model": "aiml-missing:never",
        "messages": [
          {
            "role": "system",
            "content": "Answer in one short sentence."
          },
          {
            "role": "user",
            "content": "What does HTTP stand for?"
          }
        ],
        "stream": false,
        "think": false,
        "options": {
          "num_predict": 48,
          "temperature": 0.0,
          "seed": 42
        }
      },
      "status": 404,
      "body": "{\"error\":\"model 'aiml-missing:never' not found\"}",
      "client_ms": 0.9
    },
    {
      "model": "gemma4:e2b",
      "adapter": "ollama-native",
      "case": "thinking-left-on",
      "request": {
        "model": "gemma4:e2b",
        "messages": [
          {
            "role": "system",
            "content": "Answer in one short sentence."
          },
          {
            "role": "user",
            "content": "What does HTTP stand for?"
          }
        ],
        "stream": false,
        "options": {
          "num_predict": 48,
          "temperature": 0.0,
          "seed": 42
        }
      },
      "status": 200,
      "body": "{\"model\":\"gemma4:e2b\",\"created_at\":\"2026-10-20T17:07:17.95317Z\",\"message\":{\"role\":\"assistant\",\"content\":\"HTTP stands for Hypertext Transfer Protocol.\"},\"done\":true,\"done_reason\":\"stop\",\"total_duration\":5473064625,\"load_duration\":5252614250,\"prompt_eval_count\":28,\"prompt_eval_duration\":80521000,\"eval_count\":9,\"eval_duration\":137231000}",
      "client_ms": 5473.6
    },
    {
      "model": "gemma4:e2b",
      "adapter": "openai-compatible",
      "case": "complete",
      "request": {
        "model": "gemma4:e2b",
        "messages": [
          {
            "role": "system",
            "content": "Answer in one short sentence."
          },
          {
            "role": "user",
            "content": "What does HTTP stand for?"
          }
        ],
        "max_tokens": 48,
        "temperature": 0.0,
        "stream": false,
        "reasoning_effort": "none",
        "seed": 42
      },
      "status": 200,
      "body": "{\"id\":\"chatcmpl-943\",\"object\":\"chat.completion\",\"created\":1792516038,\"model\":\"gemma4:e2b\",\"system_fingerprint\":\"fp_ollama\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"HTTP stands for Hypertext Transfer Protocol.\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":26,\"completion_tokens\":9,\"total_tokens\":35}}\n",
      "client_ms": 945.9
    },
    {
      "model": "gemma4:e2b",
      "adapter": "openai-compatible",
      "case": "output-limit",
      "request": {
        "model": "gemma4:e2b",
        "messages": [
          {
            "role": "system",
            "content": "Answer in one short sentence."
          },
          {
            "role": "user",
            "content": "What does HTTP stand for?"
          }
        ],
        "max_tokens": 3,
        "temperature": 0.0,
        "stream": false,
        "reasoning_effort": "none",
        "seed": 42
      },
      "status": 200,
      "body": "{\"id\":\"chatcmpl-840\",\"object\":\"chat.completion\",\"created\":1792516044,\"model\":\"gemma4:e2b\",\"system_fingerprint\":\"fp_ollama\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"HTTP stands for\"},\"finish_reason\":\"length\"}],\"usage\":{\"prompt_tokens\":26,\"completion_tokens\":3,\"total_tokens\":29}}\n",
      "client_ms": 5362.8
    },
    {
      "model": "gemma4:e2b",
      "adapter": "openai-compatible",
      "case": "missing-model",
      "request": {
        "model": "aiml-missing:never",
        "messages": [
          {
            "role": "system",
            "content": "Answer in one short sentence."
          },
          {
            "role": "user",
            "content": "What does HTTP stand for?"
          }
        ],
        "max_tokens": 48,
        "temperature": 0.0,
        "stream": false,
        "reasoning_effort": "none",
        "seed": 42
      },
      "status": 404,
      "body": "{\"error\":{\"message\":\"model 'aiml-missing:never' not found\",\"type\":\"not_found_error\",\"param\":null,\"code\":null}}\n",
      "client_ms": 0.8
    },
    {
      "model": "gemma4:e2b",
      "adapter": "openai-compatible",
      "case": "thinking-left-on",
      "request": {
        "model": "gemma4:e2b",
        "messages": [
          {
            "role": "system",
            "content": "Answer in one short sentence."
          },
          {
            "role": "user",
            "content": "What does HTTP stand for?"
          }
        ],
        "max_tokens": 48,
        "temperature": 0.0,
        "stream": false,
        "seed": 42
      },
      "status": 200,
      "body": "{\"id\":\"chatcmpl-561\",\"object\":\"chat.completion\",\"created\":1792516045,\"model\":\"gemma4:e2b\",\"system_fingerprint\":\"fp_ollama\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"HTTP stands for Hypertext Transfer Protocol.\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":28,\"completion_tokens\":9,\"total_tokens\":37}}\n",
      "client_ms": 915.0
    },
    {
      "model": "qwen3.5:0.8b",
      "adapter": "ollama-native",
      "case": "complete",
      "request": {
        "model": "qwen3.5:0.8b",
        "messages": [
          {
            "role": "system",
            "content": "Answer in one short sentence."
          },
          {
            "role": "user",
            "content": "What does HTTP stand for?"
          }
        ],
        "stream": false,
        "think": false,
        "options": {
          "num_predict": 48,
          "temperature": 0.0,
          "seed": 42
        }
      },
      "status": 200,
      "body": "{\"model\":\"qwen3.5:0.8b\",\"created_at\":\"2026-10-20T17:07:25.484113Z\",\"message\":{\"role\":\"assistant\",\"content\":\"HTTP stands for Hypertext Transfer Protocol.\"},\"done\":true,\"done_reason\":\"stop\",\"total_duration\":305279250,\"load_duration\":153923417,\"prompt_eval_count\":29,\"prompt_eval_duration\":25572000,\"eval_count\":9,\"eval_duration\":124392000}",
      "client_ms": 305.8
    },
    {
      "model": "qwen3.5:0.8b",
      "adapter": "ollama-native",
      "case": "output-limit",
      "request": {
        "model": "qwen3.5:0.8b",
        "messages": [
          {
            "role": "system",
            "content": "Answer in one short sentence."
          },
          {
            "role": "user",
            "content": "What does HTTP stand for?"
          }
        ],
        "stream": false,
        "think": false,
        "options": {
          "num_predict": 3,
          "temperature": 0.0,
          "seed": 42
        }
      },
      "status": 200,
      "body": "{\"model\":\"qwen3.5:0.8b\",\"created_at\":\"2026-10-20T17:07:25.703836Z\",\"message\":{\"role\":\"assistant\",\"content\":\"HTTP stands for\"},\"done\":true,\"done_reason\":\"length\",\"total_duration\":219233666,\"load_duration\":155552208,\"prompt_eval_count\":29,\"prompt_eval_duration\":32194000,\"eval_count\":3,\"eval_duration\":30362000}",
      "client_ms": 219.7
    },
    {
      "model": "qwen3.5:0.8b",
      "adapter": "ollama-native",
      "case": "missing-model",
      "request": {
        "model": "aiml-missing:never",
        "messages": [
          {
            "role": "system",
            "content": "Answer in one short sentence."
          },
          {
            "role": "user",
            "content": "What does HTTP stand for?"
          }
        ],
        "stream": false,
        "think": false,
        "options": {
          "num_predict": 48,
          "temperature": 0.0,
          "seed": 42
        }
      },
      "status": 404,
      "body": "{\"error\":\"model 'aiml-missing:never' not found\"}",
      "client_ms": 0.8
    },
    {
      "model": "qwen3.5:0.8b",
      "adapter": "ollama-native",
      "case": "thinking-left-on",
      "request": {
        "model": "qwen3.5:0.8b",
        "messages": [
          {
            "role": "system",
            "content": "Answer in one short sentence."
          },
          {
            "role": "user",
            "content": "What does HTTP stand for?"
          }
        ],
        "stream": false,
        "options": {
          "num_predict": 48,
          "temperature": 0.0,
          "seed": 42
        }
      },
      "status": 200,
      "body": "{\"model\":\"qwen3.5:0.8b\",\"created_at\":\"2026-10-20T17:07:26.390877Z\",\"message\":{\"role\":\"assistant\",\"content\":\"\",\"thinking\":\"Thinking Process:\\n\\n1.  **Analyze the Request:** The user is asking for the acronym \\\"HTTP\\\" and wants the answer in one short sentence.\\n\\n2.  **Identify the Acronym:** HTTP stands for Hypertext\"},\"done\":true,\"done_reason\":\"length\",\"total_duration\":685886500,\"load_duration\":153232916,\"prompt_eval_count\":27,\"prompt_eval_duration\":11564000,\"eval_count\":48,\"eval_duration\":519955000}",
      "client_ms": 686.3
    },
    {
      "model": "qwen3.5:0.8b",
      "adapter": "openai-compatible",
      "case": "complete",
      "request": {
        "model": "qwen3.5:0.8b",
        "messages": [
          {
            "role": "system",
            "content": "Answer in one short sentence."
          },
          {
            "role": "user",
            "content": "What does HTTP stand for?"
          }
        ],
        "max_tokens": 48,
        "temperature": 0.0,
        "stream": false,
        "reasoning_effort": "none",
        "seed": 42
      },
      "status": 200,
      "body": "{\"id\":\"chatcmpl-351\",\"object\":\"chat.completion\",\"created\":1792516046,\"model\":\"qwen3.5:0.8b\",\"system_fingerprint\":\"fp_ollama\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"HTTP stands for Hypertext Transfer Protocol.\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":29,\"completion_tokens\":9,\"total_tokens\":38}}\n",
      "client_ms": 299.5
    },
    {
      "model": "qwen3.5:0.8b",
      "adapter": "openai-compatible",
      "case": "output-limit",
      "request": {
        "model": "qwen3.5:0.8b",
        "messages": [
          {
            "role": "system",
            "content": "Answer in one short sentence."
          },
          {
            "role": "user",
            "content": "What does HTTP stand for?"
          }
        ],
        "max_tokens": 3,
        "temperature": 0.0,
        "stream": false,
        "reasoning_effort": "none",
        "seed": 42
      },
      "status": 200,
      "body": "{\"id\":\"chatcmpl-52\",\"object\":\"chat.completion\",\"created\":1792516046,\"model\":\"qwen3.5:0.8b\",\"system_fingerprint\":\"fp_ollama\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"HTTP stands for\"},\"finish_reason\":\"length\"}],\"usage\":{\"prompt_tokens\":29,\"completion_tokens\":3,\"total_tokens\":32}}\n",
      "client_ms": 248.6
    },
    {
      "model": "qwen3.5:0.8b",
      "adapter": "openai-compatible",
      "case": "missing-model",
      "request": {
        "model": "aiml-missing:never",
        "messages": [
          {
            "role": "system",
            "content": "Answer in one short sentence."
          },
          {
            "role": "user",
            "content": "What does HTTP stand for?"
          }
        ],
        "max_tokens": 48,
        "temperature": 0.0,
        "stream": false,
        "reasoning_effort": "none",
        "seed": 42
      },
      "status": 404,
      "body": "{\"error\":{\"message\":\"model 'aiml-missing:never' not found\",\"type\":\"not_found_error\",\"param\":null,\"code\":null}}\n",
      "client_ms": 5.0
    },
    {
      "model": "qwen3.5:0.8b",
      "adapter": "openai-compatible",
      "case": "thinking-left-on",
      "request": {
        "model": "qwen3.5:0.8b",
        "messages": [
          {
            "role": "system",
            "content": "Answer in one short sentence."
          },
          {
            "role": "user",
            "content": "What does HTTP stand for?"
          }
        ],
        "max_tokens": 48,
        "temperature": 0.0,
        "stream": false,
        "seed": 42
      },
      "status": 200,
      "body": "{\"id\":\"chatcmpl-631\",\"object\":\"chat.completion\",\"created\":1792516047,\"model\":\"qwen3.5:0.8b\",\"system_fingerprint\":\"fp_ollama\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning\":\"Thinking Process:\\n\\n1.  **Analyze the Request:** The user is asking for the acronym \\\"HTTP\\\" and wants the answer in one short sentence.\\n\\n2.  **Identify the Acronym:** HTTP stands for Hypertext\"},\"finish_reason\":\"length\"}],\"usage\":{\"prompt_tokens\":27,\"completion_tokens\":48,\"total_tokens\":75}}\n",
      "client_ms": 633.4
    }
  ]
}

The run used Ollama 0.32.14 on 2026-10-20.

Read the result

The two adapters agree on every recorded case: 8 of 8. For the complete case with gemma4:e2b, both returned {"text":"HTTP stands for Hypertext Transfer Protocol.","finish":"stop","input_tokens":26,"output_tokens":9}. The input token counts match too, 26 through each endpoint, which is consistent with the server formatting the same messages the same way. With an output limit of 3, both returned the cut-off text “HTTP stands for” with finish length, which the contract treats as a result for the caller to inspect, not an error.

Leaving thinking at the server default changes what happens, through both endpoints. In the thinking-left-on case, qwen3.5:0.8b spent all 48 output tokens on reasoning, returned empty content, and finished with length on both APIs. Both adapters turned that into a protocol error instead of an empty answer. The Qwen3.5 0.8B model card describes non-thinking as the model’s default; this Ollama packaging behaved otherwise. Recorded traffic shows this kind of difference; the model card does not. gemma4:e2b still answered within the limit, but its input grew from 26 to 28 tokens: the server changed the prompt it built even though the reply looked the same.

The thinking switch has two names. The native API’s think: false and the OpenAI-compatible reasoning_effort: "none" produced the same input token counts and the same text. An adapter that forgets its switch is not visibly broken with gemma4:e2b; it breaks with qwen3.5:0.8b. The contract tests catch it either way, because they compare the body each adapter sends with the recorded one.

Use either adapter from application code

The command-line example asks one question through the adapter named by --api. The application code is the same for both:

cli-run.txtRecorded output · textDownload
$ python3 adapters.py --api ollama-native 'What does JSON stand for?'
{"text": "JSON stands for JavaScript Object Notation.", "finish": "stop", "input_tokens": 26, "output_tokens": 8}
$ python3 adapters.py --api openai-compatible 'What does JSON stand for?'
{"text": "JSON stands for JavaScript Object Notation.", "finish": "stop", "input_tokens": 26, "output_tokens": 8}
$ python3 adapters.py --api openai-compatible --model aiml-missing:never 'What does JSON stand for?'
model_not_found: The server does not have this model. Pull it explicitly.
exit status 1
$ python3 adapters.py --base-url http://127.0.0.1:1 'What does JSON stand for?'
connection: Cannot reach the model server: [Errno 61] Connection refused
exit status 1

The last two commands show the error kinds reaching the caller: model_not_found from the OpenAI-compatible error object, and connection from a port where nothing listens.

Failure cases

Run the contract suite. It replays recorded responses and edited copies of them; no server is needed:

Terminal commandsTerminal · bash
python3 -m unittest -v test_contract.py
test-output.txtRecorded output · textDownload
test_a_completed_reply_is_normalized (test_contract.CompatibleContract.test_a_completed_reply_is_normalized) ... ok
test_a_missing_model_is_model_not_found (test_contract.CompatibleContract.test_a_missing_model_is_model_not_found) ... ok
test_an_option_the_api_cannot_carry_is_refused_not_dropped (test_contract.CompatibleContract.test_an_option_the_api_cannot_carry_is_refused_not_dropped) ... ok
test_requests_are_checked_before_anything_is_sent (test_contract.CompatibleContract.test_requests_are_checked_before_anything_is_sent) ... ok
test_server_errors_and_malformed_bodies_get_a_named_kind (test_contract.CompatibleContract.test_server_errors_and_malformed_bodies_get_a_named_kind) ... ok
test_the_output_limit_is_a_result_not_an_error (test_contract.CompatibleContract.test_the_output_limit_is_a_result_not_an_error) ... ok
test_thinking_is_turned_off_in_every_request (test_contract.CompatibleContract.test_thinking_is_turned_off_in_every_request) ... ok
test_transport_failures_are_reported_once_without_retry (test_contract.CompatibleContract.test_transport_failures_are_reported_once_without_retry) ... ok
test_a_completed_reply_is_normalized (test_contract.NativeContract.test_a_completed_reply_is_normalized) ... ok
test_a_missing_model_is_model_not_found (test_contract.NativeContract.test_a_missing_model_is_model_not_found) ... ok
test_an_option_the_api_cannot_carry_is_refused_not_dropped (test_contract.NativeContract.test_an_option_the_api_cannot_carry_is_refused_not_dropped) ... ok
test_requests_are_checked_before_anything_is_sent (test_contract.NativeContract.test_requests_are_checked_before_anything_is_sent) ... ok
test_server_errors_and_malformed_bodies_get_a_named_kind (test_contract.NativeContract.test_server_errors_and_malformed_bodies_get_a_named_kind) ... ok
test_the_output_limit_is_a_result_not_an_error (test_contract.NativeContract.test_the_output_limit_is_a_result_not_an_error) ... ok
test_thinking_is_turned_off_in_every_request (test_contract.NativeContract.test_thinking_is_turned_off_in_every_request) ... ok
test_transport_failures_are_reported_once_without_retry (test_contract.NativeContract.test_transport_failures_are_reported_once_without_retry) ... ok
test_both_wire_formats_give_the_same_result_for_every_recorded_case (test_contract.RecordedComparison.test_both_wire_formats_give_the_same_result_for_every_recorded_case) ... ok
test_the_report_is_reproduced_from_the_recording (test_contract.RecordedComparison.test_the_report_is_reproduced_from_the_recording) ... ok

----------------------------------------------------------------------
Ran 18 tests in 0.002s

OK

test_contract.py defines 10 test methods and the runner ran 18: the contract methods in Contract run once for each adapter subclass, and two more check the recorded comparison. The subclasses add only the adapter class, whether it can set a context size, and how to break a recorded response of its own shape.

Case Expected behavior
The model is not installed model_not_found from either error shape.
Thinking is left at the server default Each body carries its thinking switch; an empty reply caused by reasoning is a protocol error, never an empty answer.
An HTTP error, invalid JSON, or a response with the wrong shape http for a non-200 status; protocol for a body that is not a JSON object, an unfinished reply, a wrong role, empty text, a tool call, an unknown finish reason, a missing or negative count, or the wrong number of choices.
A refused connection or a timeout connection or timeout, after exactly one attempt.
A request the contract does not allow ValueError before any request: no messages, blank text, an unknown role, a late system message, a reply as the last message, or a limit out of range.
An option the API cannot carry The native adapter sends num_ctx; the OpenAI-compatible adapter raises unsupported and sends nothing.
The reply stops at the output limit A ChatResult with finish length, the same for both adapters.

To check that each test catches a real defect, we removed each rule from a copy of adapters.py and ran the suite: each thinking switch, each output-limit field, the context mapping and the refusal, the seed, the 404 mapping, the status check, every check in result, decode and both parse methods, the timeout mapping, the call to validate, every rule inside validate, and the local-origin check. Every removal made at least one test fail.

Inspect the tests · test_contract.py
test_contract.pySource · pythonDownload
"""The adapter contract, run once per adapter, against recorded wire traffic. No model or network is used."""
import json
import socket
import unittest
from pathlib import Path
from unittest.mock import patch
from urllib.error import URLError

import adapters as lab
from record_wire import CASES, MESSAGES, report

HERE = Path(__file__).resolve().parent
RECORD = json.loads((HERE / 'wire-record.json').read_text())
MODELS = sorted({row['model'] for row in RECORD['rows']})


def recorded(model, adapter, case):
    return next(r for r in RECORD['rows'] if (r['model'], r['adapter'], r['case']) == (model, adapter, case))


class Replay:
    """A post() that answers with one recorded or edited response and keeps what was sent."""

    def __init__(self, status, body):
        self.status, self.body, self.sent = status, body if isinstance(body, bytes) else body.encode(), []

    def __call__(self, url, payload, headers, timeout):
        self.sent.append((url, payload))
        return self.status, self.body


def never(*args):
    raise AssertionError('The adapter sent a request it should have refused.')


class Contract:
    """Every adapter must pass every test here. Subclasses only name the adapter and its wire shape."""
    adapter_class = None
    sets_context = None

    def make(self, post=never):
        return self.adapter_class(post=post)

    def replay(self, model, case):
        row = recorded(model, self.adapter_class.name, case)
        post = Replay(row['status'], row['body'])
        return post, row

    def test_a_completed_reply_is_normalized(self):
        for model in MODELS:
            with self.subTest(model=model):
                post, row = self.replay(model, 'complete')
                got = self.make(post).complete(model, CASES['complete'][1])
                self.assertEqual(post.sent[0][1], row['request'])
                self.assertEqual(got.finish, 'stop')
                self.assertTrue(got.text.startswith('HTTP stands for'))
                self.assertGreater(got.input_tokens, 0)
                self.assertGreater(got.output_tokens, 0)

    def test_the_output_limit_is_a_result_not_an_error(self):
        for model in MODELS:
            with self.subTest(model=model):
                post, _ = self.replay(model, 'output-limit')
                got = self.make(post).complete(model, CASES['output-limit'][1])
                self.assertEqual((got.finish, got.output_tokens), ('length', 3))

    def test_a_missing_model_is_model_not_found(self):
        post, _ = self.replay(MODELS[0], 'missing-model')
        with self.assertRaises(lab.AdapterError) as caught:
            self.make(post).complete('aiml-missing:never', CASES['missing-model'][1])
        self.assertEqual(caught.exception.kind, 'model_not_found')

    def test_thinking_is_turned_off_in_every_request(self):
        body = self.make().body('qwen3.5:0.8b', CASES['complete'][1])
        self.assertIn(body.get('think', body.get('reasoning_effort')), (False, 'none'))
        post, _ = self.replay('qwen3.5:0.8b', 'thinking-left-on')
        with self.assertRaises(lab.AdapterError) as caught:
            self.make(post).complete('qwen3.5:0.8b', CASES['complete'][1])
        self.assertEqual(caught.exception.kind, 'protocol')

    def test_server_errors_and_malformed_bodies_get_a_named_kind(self):
        good = json.loads(recorded(MODELS[0], self.adapter_class.name, 'complete')['body'])
        cases = [(500, good, 'http'), (400, {'error': 'bad request'}, 'http'), (200, b'{not json', 'protocol'),
                 (200, [], 'protocol')] + [(200, body, 'protocol') for body in self.broken(good)]
        for status, body, kind in cases:
            with self.subTest(status=status, body=str(body)[:70]):
                raw = body if isinstance(body, bytes) else json.dumps(body).encode()
                with self.assertRaises(lab.AdapterError) as caught:
                    self.make(Replay(status, raw)).complete(MODELS[0], CASES['complete'][1])
                self.assertEqual(caught.exception.kind, kind)

    def test_transport_failures_are_reported_once_without_retry(self):
        adapter = self.adapter_class()
        for error, kind in ((URLError(ConnectionRefusedError(61, 'refused')), 'connection'),
                            (URLError(socket.timeout('timed out')), 'timeout'), (TimeoutError(), 'timeout')):
            with self.subTest(kind=kind), patch.object(lab, 'urlopen', side_effect=error) as opened:
                with self.assertRaises(lab.AdapterError) as caught:
                    lab.http_post(adapter.base_url + adapter.path, {}, {}, 5)
                self.assertEqual((caught.exception.kind, opened.call_count), (kind, 1))

    def test_requests_are_checked_before_anything_is_sent(self):
        adapter = self.make()
        bad = [(), (('user', ' '),), (('assistant', 'Hi'),), (('user', 'a'), ('system', 'late'), ('user', 'b')),
               (('tool', 'x'), ('user', 'a')), [('user', 'not a tuple')]]
        for messages in bad:
            with self.subTest(messages=messages), self.assertRaises(ValueError):
                adapter.complete('gemma4:e2b', lab.ChatRequest(messages))
        for change in ({'max_tokens': 0}, {'max_tokens': True}, {'temperature': 3}, {'temperature': '0'},
                       {'seed': 1.5}, {'context_tokens': 10}):
            with self.subTest(change=change), self.assertRaises(ValueError):
                adapter.complete('gemma4:e2b', lab.ChatRequest(MESSAGES, **change))
        for origin in ('https://example.com', 'http://example.com:11434', 'http://localhost/v1'):
            with self.subTest(origin=origin), self.assertRaises(ValueError):
                self.adapter_class(base_url=origin)

    def test_an_option_the_api_cannot_carry_is_refused_not_dropped(self):
        request = lab.ChatRequest(MESSAGES, context_tokens=4096)
        if self.sets_context:
            post, _ = self.replay(MODELS[0], 'complete')
            self.make(post).complete(MODELS[0], request)
            self.assertEqual(post.sent[0][1]['options']['num_ctx'], 4096)
        else:
            with self.assertRaises(lab.AdapterError) as caught:
                self.make().complete(MODELS[0], request)
            self.assertEqual(caught.exception.kind, 'unsupported')


class NativeContract(Contract, unittest.TestCase):
    adapter_class = lab.OllamaChatAdapter
    sets_context = True

    @staticmethod
    def broken(good):
        message = good['message']
        return [{**good, 'done': False}, {**good, 'message': {**message, 'role': 'user'}},
                {**good, 'message': {**message, 'content': ''}}, {**good, 'message': {**message, 'tool_calls': [{}]}},
                {**good, 'done_reason': 'unload'}, {**good, 'eval_count': -1}, {**good, 'prompt_eval_count': '26'}]


class CompatibleContract(Contract, unittest.TestCase):
    adapter_class = lab.OpenAICompatibleAdapter
    sets_context = False

    @staticmethod
    def broken(good):
        choice, message = good['choices'][0], good['choices'][0]['message']
        return [{**good, 'choices': []}, {**good, 'choices': [choice, choice]},
                {**good, 'choices': [{**choice, 'message': {**message, 'content': ''}}]},
                {**good, 'choices': [{**choice, 'message': {**message, 'tool_calls': [{}]}}]},
                {**good, 'choices': [{**choice, 'finish_reason': 'content_filter'}]},
                {**good, 'usage': None}, {**good, 'usage': {**good['usage'], 'completion_tokens': -1}}]


class RecordedComparison(unittest.TestCase):
    def test_the_report_is_reproduced_from_the_recording(self):
        expected = (HERE / 'contract-report.json').read_text()
        self.assertEqual(json.dumps(report(RECORD), indent=2, ensure_ascii=False) + '\n', expected)

    def test_both_wire_formats_give_the_same_result_for_every_recorded_case(self):
        for model, cases in report(RECORD).items():
            for case, outcomes in cases.items():
                with self.subTest(model=model, case=case):
                    self.assertTrue(outcomes['agree'])


if __name__ == '__main__':
    unittest.main()

Deployment

The deployment unit is a library: adapters.py, imported by the application, plus the recorded traffic and the contract suite that justify each supported adapter. Choose the adapter in configuration, not in application code, and log the adapter name, model and digest with each request so a change of wire format is visible in the logs.

Before switching a deployment to another server or version, record its traffic with record_wire.py, run the contract suite, and keep the previous recording. Rolling back means pointing the configuration at the previous adapter and server, which the application can do without code changes because it depends only on the contract. Both adapters accept loopback servers only; a remote or hosted API also needs credentials, TLS and a decision about what may be sent, and none of that is part of this build.

Scaling

No load was measured. An adapter adds one dictionary translation in each direction to a request whose cost is model inference, so its own overhead is not the bottleneck worth measuring first. Scaling here means more adapters. Each new wire format adds a subclass with body, parse and broken, a recording, and nothing else; the suite grows by the number of contract methods per adapter.

When several adapters run in production, the contract also needs the pieces this build leaves out: a streaming variant, timeouts per adapter, and a recorded comparison for each model you support. Keep comparing normalized results across adapters, as contract-report.json does, so a server change that alters the reply without an error shows up as a disagreement.

Try it: remove one adapter’s thinking switch

In a copy of adapters.py, delete 'reasoning_effort': 'none' from OpenAICompatibleAdapter.body. Before you run the suite, predict which tests fail and for which adapter, and why the native adapter’s tests are unaffected.

Terminal commandsTerminal · bash
python3 -m unittest test_contract.py
Compare your result

Only CompatibleContract tests fail: test_thinking_is_turned_off_in_every_request, because the body no longer turns thinking off, and test_a_completed_reply_is_normalized for both models, because the body the adapter sends no longer matches the recorded request. NativeContract runs the same methods with its own adapter, which still sends think: false. The suite failed even for gemma4:e2b, whose recorded reply did not change without the switch: the contract checks what is sent, not only what comes back.

Check your understanding

Your ChatRequest sets context_tokens to 4096, and the application uses the OpenAI-compatible adapter. What does the adapter do?

Choose one answer, then check your reasoning.

Read the explanation

A portable contract either carries an option to the server or refuses it. Dropping it quietly would make the two adapters behave differently for the same request.

Key takeaways

  • An adapter translates field names; a contract says what the translation must preserve: the request, a normalized result and named error kinds.
  • Write the contract tests once and run them against every adapter, using recorded traffic from the real server.
  • The dangerous differences between APIs raise no error: a default such as thinking, or an option one API cannot carry. Make the adapter send the setting explicitly or refuse the request.
  • A new server or version is supported only after its own recorded traffic passes the same suite.

Best practices

  • Keep validation and result checks outside the adapters, so no adapter can be more permissive than another.
  • Compare normalized results across adapters for the same recorded cases, and treat a disagreement as a failure to explain.
  • Hand errors to the application as kinds, not as server text, and never retry inside an adapter.

References

Official sources checked on 2026-10-20, for Ollama 0.32.14.

The recorded traffic, report and tests belong to this build. The documentation describes the APIs; it does not validate these results.

Written by Arjun Prabhulal.

Module 3

Adapt model interfaces and select a candidate