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:
dispatch_resolver(ctx) — consulted only when a dispatch context is
supplied, so a service constructed with a resolver still answers static
questions statically.
role_models[role] — the static per-role table.
default_config — the caller's explicit snapshot default.
- the provider's own declaration (
default_model_config /
_symfonic_default_model), followed through routing wrappers.
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:
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.
provider._symfonic_default_model — a model-name string. The cheap
seam, and the one every shipped adapter declares.
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()
|