Skip to content

symfonic.services.models.contract

contract

The provider adapter contract, as a check rather than a paragraph.

ModelProvider is a runtime_checkable Protocol, which proves only that method names exist. Every failure this module catches passed that check and still broke at an adopter's API boundary:

  • an adapter that never declares _symfonic_provider_family classifies as "unknown", so its Anthropic traffic silently loses cache_control and its images are encoded in the wrong block shape;
  • an adapter that declares no default model makes the simple facade send claude-sonnet-4-5 to a gateway that has never heard of it;
  • an adapter that refuses a forced tool choice without a refusal_reason leaves the engine's per-turn WARN unable to name the cause.

The checker is deliberately structural, not a smoke test: it never calls get_chat_model and never constructs a client, so it runs with no credentials and no optional extras installed.

ProviderContractViolation dataclass

ProviderContractViolation(provider: str, rule: str, message: str)

One way an adapter fails the contract.

check_provider_contract

check_provider_contract(target: Any) -> tuple[ProviderContractViolation, ...]

Check one provider class or instance against the adapter contract.

Accepts either, because adapters differ in whether they can be constructed without credentials. Given a class, the probes that need an instance run against object.__new__(cls) โ€” no __init__, so no credential read, no network, no side effects. The Protocol already requires supports_forced_tool_choice to be a pure function of its ModelConfig argument, so an adapter that cannot answer from an uninitialised instance is itself outside the contract; those probes are skipped rather than guessed at, and the structural checks still apply.

Source code in src/symfonic/services/models/contract.py
def check_provider_contract(target: Any) -> tuple[ProviderContractViolation, ...]:
    """Check one provider class or instance against the adapter contract.

    Accepts either, because adapters differ in whether they can be constructed
    without credentials. Given a class, the probes that need an instance run
    against ``object.__new__(cls)`` โ€” no ``__init__``, so no credential read,
    no network, no side effects. The Protocol already requires
    ``supports_forced_tool_choice`` to be a pure function of its ``ModelConfig``
    argument, so an adapter that cannot answer from an uninitialised instance
    is itself outside the contract; those probes are skipped rather than
    guessed at, and the structural checks still apply.
    """
    cls = target if isinstance(target, type) else type(target)
    name = cls.__name__
    violations: list[ProviderContractViolation] = []

    for method in _REQUIRED_METHODS:
        if not callable(getattr(cls, method, None)):
            violations.append(
                ProviderContractViolation(
                    name,
                    "missing-method",
                    f"does not implement {method}(); the ModelProvider Protocol "
                    "requires it",
                )
            )

    violations.extend(_check_declarations(cls, name))

    instance = target if not isinstance(target, type) else _uninitialised(cls)
    if instance is not None:
        violations.extend(_check_probes(instance, name))
    return tuple(violations)