Skip to content

Adding a provider

A "provider" is a ChatModel: the thing the agent loop talks to. Adding one is implementing a single protocol and passing the conformance suite. Nothing above the adapter boundary — not the agent, not the retriever, not the gate — can tell which provider is answering, so a new adapter cannot weaken the isolation guarantee even if it wants to. The wall is PolicyGate, and no provider can reach it.

The contract

ChatModel (in chancel/providers/base.py) is one attribute and one method:

class ChatModel(Protocol):
    name: str

    def complete(
        self, system: str, turns: Sequence[ChatTurn], tools: Sequence[ToolSpec]
    ) -> ModelReply: ...

You speak only neutral typesChatTurn, ToolSpec, ToolCall, ModelReply. No field is named after a vendor's wire format, and a CI grep enforces that no provider name or isinstance check leaks above this boundary. The one rule with teeth: ToolCall.arguments is always a parsed dict. If your API hands back a JSON string, decode it with the shared normalize_tool_arguments() helper — and if it hands back something unparseable, that helper returns {"__malformed__": "<raw>"} rather than raising, so a broken model becomes an error result instead of a crash.

A minimal adapter, end to end

This adapter wraps a hypothetical HTTP chat API. It is complete — roughly forty lines.

# src/chancel/providers/example_http.py
from __future__ import annotations

from collections.abc import Sequence

import httpx

from chancel.providers.base import (
    ChatModel,
    ChatTurn,
    ModelReply,
    ToolCall,
    ToolSpec,
    normalize_tool_arguments,
)


class ExampleHTTPModel:
    """A ChatModel over a generic /chat endpoint."""

    name = "example_http"

    def __init__(self, *, base_url: str, model: str) -> None:
        self._client = httpx.Client(base_url=base_url, timeout=30.0)
        self._model = model

    def complete(
        self, system: str, turns: Sequence[ChatTurn], tools: Sequence[ToolSpec]
    ) -> ModelReply:
        payload = {
            "model": self._model,
            "system": system,
            "messages": [{"role": t.role, "content": t.text} for t in turns],
            "tools": [
                {"name": s.name, "description": s.description, "parameters": s.parameters}
                for s in tools
            ],
        }
        data = self._client.post("/chat", json=payload).raise_for_status().json()

        calls = tuple(
            ToolCall(
                id=c["id"],
                name=c["name"],
                # arguments may arrive as a JSON string — normalize to a dict,
                # never raise on malformed input.
                arguments=normalize_tool_arguments(c["arguments"]),
            )
            for c in data.get("tool_calls", [])
        )
        return ModelReply(text=data.get("text", ""), tool_calls=calls)


_model: ChatModel = ExampleHTTPModel(base_url="http://localhost:8080", model="demo")

Register it (optional)

To make it reachable by name from the CLI and the demo, add a branch to build_provider() in chancel/registry.py — deferred-imported so an optional dependency never loads unless the provider is actually requested:

    if resolved_name == "example_http":
        from chancel.providers.example_http import ExampleHTTPModel

        return ExampleHTTPModel(
            base_url=resolved_base_url or "http://localhost:8080",
            model=resolved_model or "demo",
        )

Registration is only needed for name-based lookup; the conformance suite tests the class, not the registry.

Prove it drops in

uv run pytest tests/conformance

That is the whole acceptance criterion: implement one protocol, pass tests/conformance/. You do not edit the suite. tests/conformance/test_new_adapter_is_drop_in.py already makes this concrete — it defines a brand-new ConstantChatModel inside the test file, never registers it, and runs it through the exact same assertion helpers the registered adapters use. It passes with zero edits to the suite. An adapter that requires editing the suite is a design failure, not a test failure — and that test is there to keep it that way.