Skip to content

symfonic.services.models.capabilities

capabilities

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