Skip to content

symfonic.services.models.resolution

resolution

One precedence chain for "which model serves this call?".

Three independent answers shipped before T3.1.1:

  • SymfonicAgent.resolve_model_config(role) read the static role_models table and nothing else.
  • _AgentModelResolver.resolve(ctx) layered per-dispatch policies and the adopter hook over an AgentConfig.model snapshot, and could not see roles.
  • agent.backend.model.resolve_model_config(provider) asked the provider for its declared default, and could not see either of the above — which is why the simple facade could not honour a role at all.

Each was correct about its own layer and blind to the others, so "why did this call use that model?" had no single answer. :class:ModelResolutionService composes all five sources in one ordered chain and records which one won.

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]

ResolvedModel dataclass

ResolvedModel(config: ModelConfig, role: str, source: ResolutionSource, family: ProviderFamily, capabilities: ProviderCapabilities)

One resolution, with the reason it came out that way.

source is the load-bearing field. A resolution that cannot say why it chose a model is indistinguishable from a resolution that ignored the caller's configuration, which is the class of bug this service exists to make impossible to ship silently.

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()