describe_provider — the static table plus what the adapter says.
Two sources of truth are unavoidable here and that is the point: the
descriptor row is what the framework knows about an adapter in general, while
supports_forced_tool_choice(config) is a question only the adapter can
answer about a specific ModelConfig. Merging them in one place is what
lets every call site ask one question instead of three, and is what makes the
"refuse to force, and say why" contract checkable.
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,
)
|