Skip to content

symfonic.services.models.ports

ports

The declared port of the model-resolution service (LAY-ADR §2, port).

The dependency matrix gives facade-compiler → runtime-service the cell port: the compiler may reach this layer, but only through a declared port module — "narrow interfaces only, never internals". Before TA2.1 the facade imported :mod:symfonic.services.models (the package's whole public surface, 20 names) and, in one case, the private-by-position submodules :mod:~symfonic.services.models.family and :mod:~symfonic.services.models.values directly. That is the internals reach the cell exists to forbid.

This module is that narrow interface and nothing else: the names the compiler actually binds while turning a configuration into an :class:~symfonic.kernel.contracts.InvocationPlan. It defines no behaviour — every name is re-exported from the module that owns it, so there is exactly one implementation of each and no second place to change.

TA2.2 added four descriptor names (PROVIDER_DESCRIPTORS, cache_dialect_for, descriptor_for_family, PROVIDER_FAMILIES). They are not new reach: symfonic.core — now ruled facade-compiler — was already importing :mod:~symfonic.services.models.descriptors and :mod:~symfonic.services.models.values directly from four modules, which the port cell forbids. Declaring the four names here and repointing those imports is the fix; it does not license the internals those two modules also hold.

Widening this list is a reviewed architectural change, not a convenience: a name added here becomes importable by the whole facade layer.

ModelResolutionService

ModelResolutionService(provider: Any, *, default_config: ModelConfig | None = None, role_models: Mapping[str, ModelConfig] | None = None, dispatch_resolver: Any = None)

Resolve a role (and optionally one dispatch) to a model and its capabilities.

Precedence, first match wins:

  1. dispatch_resolver(ctx) — consulted only when a dispatch context is supplied, so a service constructed with a resolver still answers static questions statically.
  2. role_models[role] — the static per-role table.
  3. default_config — the caller's explicit snapshot default.
  4. the provider's own declaration (default_model_config / _symfonic_default_model), followed through routing wrappers.
  5. ModelConfig() — the framework default.
Source code in src/symfonic/services/models/resolution.py
def __init__(
    self,
    provider: Any,
    *,
    default_config: ModelConfig | None = None,
    role_models: Mapping[str, ModelConfig] | None = None,
    dispatch_resolver: Any = None,
) -> None:
    self._provider = provider
    self._default_config = default_config
    # Snapshot then freeze: an adopter's live ``FrameworkConfig`` dict must
    # not be able to retune routing under a run that already started.
    self._role_models: Mapping[str, ModelConfig] = MappingProxyType(
        dict(role_models or {})
    )
    self._dispatch_resolver = dispatch_resolver

role_models property

role_models: Mapping[str, ModelConfig]

Read-only view of the role table this service was built with.

resolve

resolve(role: str = DEFAULT_ROLE, *, dispatch_context: Any = None) -> ResolvedModel

Resolve role — and, when a context is given, this dispatch.

Source code in src/symfonic/services/models/resolution.py
def resolve(
    self, role: str = DEFAULT_ROLE, *, dispatch_context: Any = None
) -> ResolvedModel:
    """Resolve ``role`` — and, when a context is given, this dispatch."""
    config, source = self._choose(role, dispatch_context)
    capabilities = describe_provider(self._provider, config)
    return ResolvedModel(
        config=config,
        role=role,
        source=source,
        family=capabilities.family,
        capabilities=capabilities,
    )

resolve_config

resolve_config(role: str = DEFAULT_ROLE, *, dispatch_context: Any = None) -> ModelConfig

The config alone, for call sites that need nothing else.

Source code in src/symfonic/services/models/resolution.py
def resolve_config(
    self, role: str = DEFAULT_ROLE, *, dispatch_context: Any = None
) -> ModelConfig:
    """The config alone, for call sites that need nothing else."""
    return self._choose(role, dispatch_context)[0]

cache_dialect_for

cache_dialect_for(family: ProviderFamily) -> CacheDialect

The prompt-cache annotation dialect for a wire family.

Total over the closed family set, and derived rather than assigned: an adapter cannot claim cache_control support without claiming the Anthropic wire family that actually accepts it.

Source code in src/symfonic/services/models/descriptors.py
def cache_dialect_for(family: ProviderFamily) -> CacheDialect:
    """The prompt-cache annotation dialect for a wire family.

    Total over the closed family set, and derived rather than assigned: an
    adapter cannot claim ``cache_control`` support without claiming the
    Anthropic wire family that actually accepts it.
    """
    return _CACHE_DIALECTS.get(family, "none")

describe_provider

describe_provider(provider: object, resolved_config: Any = None) -> ProviderCapabilities

Describe how provider will serve resolved_config.

Pure: no I/O, no provider-state mutation, safe to call every turn. The family and descriptor come from the leaf that will actually serve the config (routing wrappers are followed); the supports_* probes are put to the original provider, because a router knows how to delegate them and a leaf does not know it is being routed to.

Source code in src/symfonic/services/models/capabilities.py
def describe_provider(
    provider: object, resolved_config: Any = None
) -> ProviderCapabilities:
    """Describe how ``provider`` will serve ``resolved_config``.

    Pure: no I/O, no provider-state mutation, safe to call every turn. The
    family and descriptor come from the leaf that will actually serve the
    config (routing wrappers are followed); the ``supports_*`` probes are put
    to the *original* provider, because a router knows how to delegate them
    and a leaf does not know it is being routed to.
    """
    row = descriptor_for_provider(provider, resolved_config)
    family = detect_provider_family(provider, resolved_config)
    config = resolved_config if resolved_config is not None else _default_config()

    # A gateway adapter reassigns its own family from the model it is actually
    # serving -- ``AWSBedrockProvider`` rewrites ``_symfonic_provider_family``
    # on every ``get_chat_model`` call -- so the row reached by class name can
    # describe a different wire than this call will use. Family-derived facts
    # (thinking, and whether thinking blocks a forced tool choice) follow the
    # DETECTED family; adapter-specific facts (label, timeouts, sampling) stay
    # on the row, because those are properties of the client being built and
    # not of the model being served. Without this split one
    # ``ProviderCapabilities`` reports two providers: Bedrock serving a Llama
    # model would carry ``family="openai"`` next to Anthropic's
    # extended-thinking constraint.
    served = row if row.family == family else descriptor_for_family(family)
    blocks_forced = served.thinking_blocks_forced_tool_choice

    forced = _probe_forced_tool_choice(provider, config)
    return ProviderCapabilities(
        label=row.label,
        family=family,
        cache_dialect=cache_dialect_for(family),
        thinking=ThinkingSupport(
            enabled=_probe_bool(provider, "supports_thinking", served.thinking),
            blocks_forced_tool_choice=blocks_forced,
        ),
        streaming=_probe_bool(provider, "supports_streaming", row.streaming),
        forced_tool_choice=forced,
        forced_tool_choice_refusal=(
            None
            if forced
            else _probe_refusal(provider, config, row.label, blocks_forced)
        ),
        timeouts=row.timeouts,
        sampling=row.sampling,
    )

descriptor_for_family

descriptor_for_family(family: ProviderFamily) -> ProviderDescriptor

The fallback row for a family. Total over the closed family set.

Source code in src/symfonic/services/models/descriptors.py
def descriptor_for_family(family: ProviderFamily) -> ProviderDescriptor:
    """The fallback row for a family. Total over the closed family set."""
    return PROVIDER_DESCRIPTORS[_FAMILY_FALLBACK.get(family, "unknown")]

detect_provider_family

detect_provider_family(provider: object, resolved_config: Any = None) -> ProviderFamily

Classify provider into the content-block dialect it expects.

resolved_config is optional and, when given, decides routing wrappers: the family returned is the family of the leaf that will serve that config, not the wrapper's default leaf.

Source code in src/symfonic/services/models/family.py
def detect_provider_family(
    provider: object,
    resolved_config: Any = None,
) -> ProviderFamily:
    """Classify ``provider`` into the content-block dialect it expects.

    ``resolved_config`` is optional and, when given, decides routing wrappers:
    the family returned is the family of the leaf that will serve *that*
    config, not the wrapper's default leaf.
    """
    seen: set[int] = set()
    current: Any = provider
    for _ in range(_MAX_DEPTH):
        if id(current) in seen:
            break
        seen.add(id(current))

        routed = _route_leaf(current, resolved_config)
        if routed is not current:
            # A route decision is a leaf decision: re-enter classification on
            # the picked provider with no config, so a router wrapping a router
            # cannot loop on the same pick forever.
            return detect_provider_family(routed, None)

        declared = _declared_family(current)
        if declared is not None:
            return declared

        inner = getattr(current, "_default", None) or getattr(current, "default", None)
        if inner is None or inner is current:
            break
        current = inner
    return "unknown"

provider_default_config

provider_default_config(provider: Any) -> ModelConfig

The ModelConfig a provider declares for callers that name none.

Resolution order, first match wins:

  1. provider.default_model_config — a ModelConfig or a zero-argument callable returning one. The full seam: model, temperature and token budget pinned in one place.
  2. provider._symfonic_default_model — a model-name string. The cheap seam, and the one every shipped adapter declares.
  3. ModelConfig() — the framework default.

Routing wrappers are followed through _default / default, so a router wrapping one provider inherits that provider's declaration.

This is the function symfonic.agent.backend.model.resolve_model_config now is: the simple Agent facade has no model= parameter, so without it Agent(OpenAIProvider()) would ask OpenAI for claude-sonnet-4-5 and fail at request time with model_not_found.

Source code in src/symfonic/services/models/resolution.py
def provider_default_config(provider: Any) -> ModelConfig:
    """The ``ModelConfig`` a provider declares for callers that name none.

    Resolution order, first match wins:

    1. ``provider.default_model_config`` — a ``ModelConfig`` or a zero-argument
       callable returning one. The full seam: model, temperature and token
       budget pinned in one place.
    2. ``provider._symfonic_default_model`` — a model-name string. The cheap
       seam, and the one every shipped adapter declares.
    3. ``ModelConfig()`` — the framework default.

    Routing wrappers are followed through ``_default`` / ``default``, so a
    router wrapping one provider inherits that provider's declaration.

    This is the function ``symfonic.agent.backend.model.resolve_model_config``
    now *is*: the simple ``Agent`` facade has no ``model=`` parameter, so
    without it ``Agent(OpenAIProvider())`` would ask OpenAI for
    ``claude-sonnet-4-5`` and fail at request time with ``model_not_found``.
    """
    return _declared_default(provider) or ModelConfig()