Skip to content

symfonic.services.models.family

family

Provider-family classification — the one implementation.

Moved here from symfonic.agent._provider_family (T3.1.1). The classifier was a facade-layer private, which is why the plan compiler could not reach it and stamped the provider class name into ModelResolution.provider_family instead. symfonic.agent._provider_family and symfonic.agent.engine now re-export these objects, so is identity holds across every historical import path and a second copy cannot quietly appear.

Detection precedence (unchanged from v7.13.4):

  1. Duck-typed declaration_symfonic_provider_family read off the instance so adapters that classify per model at runtime (Bedrock) are honoured, falling back to the ClassVar for declare-once adapters, and then to the nearest base class that declares a canonical family. That last step is the adopter-fork defence: a fork that subclasses KimiProvider and overrides the ClassVar to something non-canonical ("kimi") still classifies openai, because KimiProvider itself declares it.
  2. Routing-wrapper descent through _default / default, bounded to depth 4 with an identity-cycle guard.

That base-class step used to be a separate isinstance() pass against six adapter classes imported lazily from symfonic.core.providers. TA2.2 replaced it with the MRO walk in :func:_declared_family, which decides the same cases — the six shipped classes are exactly the ones declaring a canonical family — without a runtime-service -> facade-compiler import, the one edge in this package the dependency matrix forbids outright with no port option. See RET-PREP/core-mapping.md.

"unknown" is the wire-neutral verdict, not a failure: the cache layer refuses to place a cache_control annotation on a payload whose dialect it cannot name, which is what preserves the wire-neutrality contract for custom Llama / vLLM / gateway deployments.

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"

leaf_provider

leaf_provider(provider: Any, resolved_config: Any = None) -> Any

The provider that will actually serve resolved_config.

Follows route maps first (when a config is supplied), then plain wrapper _default / default chains, under the same depth bound and cycle guard as :func:detect_provider_family.

Source code in src/symfonic/services/models/family.py
def leaf_provider(provider: Any, resolved_config: Any = None) -> Any:
    """The provider that will actually serve ``resolved_config``.

    Follows route maps first (when a config is supplied), then plain wrapper
    ``_default`` / ``default`` chains, under the same depth bound and cycle
    guard as :func:`detect_provider_family`.
    """
    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:
            current = routed
            continue
        if _declared_family(current) is not None:
            return current

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