Skip to content

symfonic.core.providers

providers

ModelProvider protocol + concrete implementations.

Per ADR-PFX-003 and TDD SS2.4: - ModelProvider: protocol for LLM instantiation - AnthropicProvider: default, installed via symfonic-core[anthropic] - OpenAIProvider: installed via symfonic-core[openai] - DeepSeekProvider: installed via symfonic-core[deepseek] - GoogleProvider: installed via symfonic-core[google] - OllamaProvider: installed via symfonic-core[ollama] - AWSBedrockProvider: any model on AWS Bedrock (Claude, Llama, Nova, …), installed via symfonic-core[aws] - OpenRouterProvider: any model via the OpenRouter gateway, installed via symfonic-core[openrouter]

AWSBedrockProvider

AWSBedrockProvider(*, region_name: str | None = None, credentials_profile_name: str | None = None, credentials_resolver: Callable[[], Mapping[str, str]] | None = None, provider_family: str | None = None)

Any model on AWS Bedrock via the Converse API (Claude, Llama, Nova, …).

Requires symfonic-core[aws] (langchain-aws + boto3). Region and credentials resolve through the standard boto3 chain — env vars (AWS_REGION / AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY), a shared profile, or an EC2/ECS/EKS instance role — so nothing secret is stored on the instance.

ModelConfig.model_name MUST be a Bedrock model id or inference-profile id (NOT a bare Anthropic SKU), e.g. "us.anthropic.claude-sonnet-4-5-20250929-v1:0" or "us.meta.llama3-3-70b-instruct-v1:0".

Wire family is auto-detected per model from the id (see :func:bedrock_family_for_model): Anthropic ids get the anthropic family (extended thinking), every other vendor is treated wire-neutral (unknown) so no Anthropic-specific blocks are stamped onto it. Pass provider_family= to override the detection.

NOTE: Bedrock's Converse prompt-cache wire (cachePoint blocks) is NOT identical to Anthropic-native cache_control; leave the messages-region cache disabled on Bedrock until a Bedrock cachePoint adapter lands.

Construct the provider.

Parameters:

Name Type Description Default
region_name str | None

AWS region (e.g. "us-east-1"). None falls back to AWS_REGION / AWS_DEFAULT_REGION then boto3.

None
credentials_profile_name str | None

Optional named profile from ~/.aws/credentials.

None
credentials_resolver Callable[[], Mapping[str, str]] | None

Optional per-call multi-tenant seam (mirrors :class:AnthropicProvider). A zero-arg callable returning a mapping that may carry region_name and/or aws_access_key_id / aws_secret_access_key / aws_session_token. Invoked inside :meth:get_chat_model; the result is threaded into the freshly-built client and never stored on the instance.

None
provider_family str | None

Force the wire family ("anthropic" / "openai" / "google" / "unknown") instead of auto-detecting it from the model id. Use for a Bedrock vendor whose dialect you want to pin explicitly.

None
Source code in src/symfonic/core/providers.py
def __init__(
    self,
    *,
    region_name: str | None = None,
    credentials_profile_name: str | None = None,
    credentials_resolver: Callable[[], Mapping[str, str]] | None = None,
    provider_family: str | None = None,
) -> None:
    """Construct the provider.

    Args:
        region_name: AWS region (e.g. ``"us-east-1"``). ``None`` falls
            back to ``AWS_REGION`` / ``AWS_DEFAULT_REGION`` then boto3.
        credentials_profile_name: Optional named profile from
            ``~/.aws/credentials``.
        credentials_resolver: Optional per-call multi-tenant seam
            (mirrors :class:`AnthropicProvider`). A zero-arg callable
            returning a mapping that may carry ``region_name`` and/or
            ``aws_access_key_id`` / ``aws_secret_access_key`` /
            ``aws_session_token``. Invoked inside
            :meth:`get_chat_model`; the result is threaded into the
            freshly-built client and never stored on the instance.
        provider_family: Force the wire family
            (``"anthropic"`` / ``"openai"`` / ``"google"`` / ``"unknown"``)
            instead of auto-detecting it from the model id. Use for a
            Bedrock vendor whose dialect you want to pin explicitly.
    """
    self._region_name = region_name
    self._credentials_profile_name = credentials_profile_name
    self._credentials_resolver = credentials_resolver
    # Explicit override wins and freezes detection; otherwise the family
    # is auto-detected from each model id in get_chat_model.
    self._family_override = provider_family
    self._symfonic_provider_family = (
        provider_family or type(self)._symfonic_provider_family
    )

refusal_reason

refusal_reason(config: ModelConfig) -> str | None

T3.1.1: name the cause of the refusal above.

Found by the provider contract check: this adapter mirrored AnthropicProvider's refusal but not its explanation, so the engine's per-turn WARN fell through to generic text and an operator saw a tool silently not being forced with no way to tell why.

Source code in src/symfonic/core/providers.py
def refusal_reason(self, config: ModelConfig) -> str | None:
    """T3.1.1: name the cause of the refusal above.

    Found by the provider contract check: this adapter mirrored
    ``AnthropicProvider``'s refusal but not its explanation, so the
    engine's per-turn WARN fell through to generic text and an operator
    saw a tool silently not being forced with no way to tell why.
    """
    if not config.thinking:
        return None
    if self._symfonic_provider_family == "anthropic":
        return (
            "Bedrock rejects tool_choice={type:tool,name:X} for Anthropic "
            "models when extended thinking is enabled (the published "
            "extended-thinking constraint applies through Converse)."
        )
    # ``supports_forced_tool_choice`` above refuses for ANY served model
    # once ``thinking`` is configured, so the reason must not name a
    # constraint that only governs Anthropic models: an operator running a
    # Llama or Mistral model on Bedrock would go looking for an Anthropic
    # API rule that does not apply to their request.
    return (
        "this provider abstains from forcing a tool choice while extended "
        "thinking is configured; the served Bedrock model family is "
        f"{self._symfonic_provider_family!r}, for which the thinking + "
        "forced-tool-choice combination is not declared supported."
    )

AnthropicProvider

AnthropicProvider(*, token: str | None = None, credentials_resolver: Callable[[], Mapping[str, str]] | None = None)

Anthropic-specific model construction. Requires symfonic-core[anthropic].

Reads ANTHROPIC_API_KEY from the environment on first get_chat_model() call; logs an INFO line with the last 8 chars of the key (never the full value) so operators can tell whether the running process picked up the expected key.

Construct the provider.

v8.5.0 (issue #4): two optional credential-injection seams for adopters who cannot rely on the ANTHROPIC_API_KEY env var (e.g. in-process multi-tenant K8s pods). When BOTH are omitted the provider is byte-identical to v8.4.1 — it constructs no api_key kwarg and ChatAnthropic falls through to its own ANTHROPIC_API_KEY env lookup.

Parameters:

Name Type Description Default
credentials_resolver Callable[[], Mapping[str, str]] | None

PRIMARY path for multi-tenant. A zero-arg callable invoked inside :meth:get_chat_model on every call (the per-react-iteration hot path). It returns a mapping carrying the key, e.g. {"api_key": "sk-ant-..."}. The resolved secret is threaded into the freshly-built ChatAnthropic and is never stored on the instance — this is the only shape that is safe under a provider cached/shared across tenants, because it sidesteps the cross-tenant leak by construction. Wire your request-scoped credential service (contextvars / in-flight request) behind it.

None
token str | None

SECONDARY single-tenant convenience. A static API key bound at construction. Documented footgun: if the provider instance is cached and shared across tenants this re-introduces the very cross-tenant leak the resolver eliminates (request B reuses request A's instance-held token). Use credentials_resolver for multi-tenant deployments.

None

Resolution precedence (inside :meth:get_chat_model): credentials_resolver() > static token > env lookup.

Source code in src/symfonic/core/providers.py
def __init__(
    self,
    *,
    token: str | None = None,
    credentials_resolver: Callable[[], Mapping[str, str]] | None = None,
) -> None:
    """Construct the provider.

    v8.5.0 (issue #4): two optional credential-injection seams for
    adopters who cannot rely on the ``ANTHROPIC_API_KEY`` env var
    (e.g. in-process multi-tenant K8s pods).  When BOTH are omitted
    the provider is byte-identical to v8.4.1 — it constructs no
    ``api_key`` kwarg and ``ChatAnthropic`` falls through to its own
    ``ANTHROPIC_API_KEY`` env lookup.

    Args:
        credentials_resolver: PRIMARY path for multi-tenant.  A
            zero-arg callable invoked *inside* :meth:`get_chat_model`
            on every call (the per-react-iteration hot path).  It
            returns a mapping carrying the key, e.g.
            ``{"api_key": "sk-ant-..."}``.  The resolved secret is
            threaded into the freshly-built ``ChatAnthropic`` and is
            **never stored on the instance** — this is the only shape
            that is safe under a provider cached/shared across
            tenants, because it sidesteps the cross-tenant leak by
            construction.  Wire your request-scoped credential
            service (contextvars / in-flight request) behind it.
        token: SECONDARY single-tenant convenience.  A static API key
            bound at construction.  **Documented footgun:** if the
            provider instance is cached and shared across tenants
            this re-introduces the very cross-tenant leak the
            resolver eliminates (request B reuses request A's
            instance-held token).  Use ``credentials_resolver`` for
            multi-tenant deployments.

    Resolution precedence (inside :meth:`get_chat_model`):
    ``credentials_resolver()`` > static ``token`` > env lookup.
    """
    self._token = token
    self._credentials_resolver = credentials_resolver

extract_cache_ttl staticmethod

extract_cache_ttl(input_token_details: Any) -> str | None

v7.20.0 T-7.20.0.6: mine the Anthropic 1h-cache TTL from a LangChain usage_metadata.input_token_details dict.

Anthropic's API surfaces the prompt-cache write rate via the cache_creation: {ephemeral_5m_input_tokens, ephemeral_1h_input_tokens} sub-object on the response usage block. langchain-anthropic (>= 0.3.20) flattens that into input_token_details with the same key names — see langchain_anthropic.chat_models:2600-2610.

When ephemeral_1h_input_tokens > 0 (the 1h cache was used on this turn), this returns "1h" so the cost layer picks the cache_write_1h rate from the pricing row (T-7.20.0.5). Otherwise — pure cache_read, only 5m cache, or no cache_creation at all — returns None so the cost layer falls through to cache_write (the 5-min rate, which IS the documented default in the registry).

Provider boundary placement (architect verdict 2026-06-05 §5.c): wire-shape translation lives on the provider that owns the wire contract. This method is still that declared entry point — same name, same signature, same behaviour — but TA2.3 moved the implementation to :func:symfonic.core.usage.extract_cache_ttl and this delegates to it, so there is exactly one implementation.

Why it moved: symfonic.memory is ruled capability under LAY-ADR and its three LLM call sites need :func:symfonic.core.usage.extract_usage, so that module had to become kernel-contracts — which forbids reaching back into this facade-compiler module. See the moved function's docstring for what the move costs.

Parameters:

Name Type Description Default
input_token_details Any

The input_token_details sub-dict of a LangChain usage_metadata payload. May be None (no cache info), an empty dict, or any non-dict value (defensive — never raises).

required

Returns:

Type Description
str | None

"1h" when the 1h cache was used this turn. None

str | None

otherwise (5m cache only, no cache, or non-dict input).

Source code in src/symfonic/core/providers.py
@staticmethod
def extract_cache_ttl(input_token_details: Any) -> str | None:
    """v7.20.0 T-7.20.0.6: mine the Anthropic 1h-cache TTL from a
    LangChain ``usage_metadata.input_token_details`` dict.

    Anthropic's API surfaces the prompt-cache write rate via the
    ``cache_creation: {ephemeral_5m_input_tokens, ephemeral_1h_input_tokens}``
    sub-object on the response usage block.  langchain-anthropic
    (>= 0.3.20) flattens that into ``input_token_details`` with the
    same key names — see ``langchain_anthropic.chat_models:2600-2610``.

    When ``ephemeral_1h_input_tokens > 0`` (the 1h cache was used on
    this turn), this returns ``"1h"`` so the cost layer picks the
    ``cache_write_1h`` rate from the pricing row (T-7.20.0.5).
    Otherwise — pure cache_read, only 5m cache, or no cache_creation
    at all — returns ``None`` so the cost layer falls through to
    ``cache_write`` (the 5-min rate, which IS the documented default
    in the registry).

    Provider boundary placement (architect verdict 2026-06-05 §5.c):
    wire-shape translation lives on the provider that owns the wire
    contract.  This method is still that declared entry point — same
    name, same signature, same behaviour — but TA2.3 moved the
    implementation to :func:`symfonic.core.usage.extract_cache_ttl`
    and this delegates to it, so there is exactly one implementation.

    Why it moved: ``symfonic.memory`` is ruled ``capability`` under
    LAY-ADR and its three LLM call sites need
    :func:`symfonic.core.usage.extract_usage`, so that module had to
    become ``kernel-contracts`` — which forbids reaching back into
    this facade-compiler module.  See the moved function's docstring
    for what the move costs.

    Args:
        input_token_details: The ``input_token_details`` sub-dict of
            a LangChain ``usage_metadata`` payload.  May be ``None``
            (no cache info), an empty dict, or any non-dict value
            (defensive — never raises).

    Returns:
        ``"1h"`` when the 1h cache was used this turn.  ``None``
        otherwise (5m cache only, no cache, or non-dict input).
    """
    from .usage import extract_cache_ttl

    return extract_cache_ttl(input_token_details)

refusal_reason

refusal_reason(config: ModelConfig) -> str | None

v7.19.2: human-readable reason for the extended-thinking refusal, surfaced in the engine's per-turn refusal WARN.

Source code in src/symfonic/core/providers.py
def refusal_reason(self, config: ModelConfig) -> str | None:
    """v7.19.2: human-readable reason for the extended-thinking
    refusal, surfaced in the engine's per-turn refusal WARN."""
    if config.thinking:
        return (
            "Anthropic API rejects tool_choice={type:tool,name:X} "
            "when extended thinking is enabled (returns HTTP 400 "
            "per the published constraint)."
        )
    return None

supports_forced_tool_choice

supports_forced_tool_choice(config: ModelConfig) -> bool

v7.8.5: Anthropic API rejects tool_choice={type:tool, name:X} and {type:any} when thinking is enabled.

Per the published extended-thinking constraint at platform.claude.com/docs/en/docs/build-with-claude/ extended-thinking: when thinking is enabled, only {type:auto} and {type:none} are accepted; forced tool_choice returns 400.

Returns False when config.thinking is set, so the engine's force lever cleanly abstains instead of crashing the API call at runtime. Returns True otherwise (the unconstrained Anthropic path).

Source code in src/symfonic/core/providers.py
def supports_forced_tool_choice(self, config: ModelConfig) -> bool:
    """v7.8.5: Anthropic API rejects ``tool_choice={type:tool,
    name:X}`` and ``{type:any}`` when ``thinking`` is enabled.

    Per the published extended-thinking constraint at
    ``platform.claude.com/docs/en/docs/build-with-claude/
    extended-thinking``: when thinking is enabled, only
    ``{type:auto}`` and ``{type:none}`` are accepted; forced
    tool_choice returns ``400``.

    Returns ``False`` when ``config.thinking`` is set, so the
    engine's force lever cleanly abstains instead of crashing the
    API call at runtime.  Returns ``True`` otherwise (the
    unconstrained Anthropic path).
    """
    return not bool(config.thinking)

DeepSeekProvider

DeepSeekProvider(api_key: str | None = None)

DeepSeek via OpenAI-compatible API. Requires langchain-openai.

Install via: pip install symfonic-core[deepseek]

Source code in src/symfonic/core/providers.py
def __init__(self, api_key: str | None = None):
    self._api_key = api_key

GoogleProvider

GoogleProvider(api_key: str | None = None)

Google Gemini models. Requires langchain-google-genai.

Install via: pip install symfonic-core[google]

Bind an optional single-tenant Google API key explicitly.

pydantic-settings reads values from .env without copying them into os.environ. Accepting the key here lets generated projects pass their typed setting instead of depending on an ambient process variable that may not exist.

Source code in src/symfonic/core/providers.py
def __init__(self, api_key: str | None = None) -> None:
    """Bind an optional single-tenant Google API key explicitly.

    ``pydantic-settings`` reads values from ``.env`` without copying them
    into ``os.environ``.  Accepting the key here lets generated projects
    pass their typed setting instead of depending on an ambient process
    variable that may not exist.
    """
    self._api_key = api_key

HttpClientUnsupportedWarning

Bases: UserWarning

Emitted when ModelConfig.http_client is set on a provider whose LangChain class does not accept an externally-supplied async HTTP client.

v7.25.0 (adopter stream-stall closure). ModelConfig.http_client is the fine-grained per-chunk idle-bound seam adopters thread an httpx.AsyncClient through. At the time of v7.25.0 the LangChain Anthropic, Google, and Ollama integrations build their own httpx clients internally and do not accept one from the caller; the framework still threads timeout_seconds so adopters get coarse-grained protection, but http_client is dropped with this warning.

Mirrors the v7.22 MultiScopeIgnoredWarning pattern -- fired exactly once per process per provider family, then no-op silently so a long-running agent does not spam its log. Adopters filter via warnings.filterwarnings("ignore", category=HttpClientUnsupportedWarning).

KimiProvider

KimiProvider(api_key: str | None = None, base_url: str = 'https://api.moonshot.ai/v1')

Kimi (Moonshot AI) via OpenAI-compatible API. Requires langchain-openai.

v7.24.0 (2026-06-10): added for the adopter brain team adoption of Kimi K2. Mirrors :class:DeepSeekProvider's shape -- OpenAI-compatible client with a custom base_url and an env-var-or-explicit api_key.

Install via: pip install symfonic-core[kimi]

Default base_url is the international Moonshot endpoint (https://api.moonshot.ai/v1). Adopters on the CN region override via KimiProvider(base_url="https://api.moonshot.cn/v1").

Default model is the current Kimi K2 latest snapshot (kimi-k2-0905-preview). Override via ModelConfig.model_name.

Reads MOONSHOT_API_KEY from the environment when api_key is not passed explicitly (the underlying ChatOpenAI client honours this).

Source code in src/symfonic/core/providers.py
def __init__(
    self,
    api_key: str | None = None,
    base_url: str = "https://api.moonshot.ai/v1",
) -> None:
    self._api_key = api_key
    self._base_url = base_url

ModelProvider

Bases: Protocol

Protocol for LLM model construction.

supports_forced_tool_choice

supports_forced_tool_choice(config: ModelConfig) -> bool

v7.8.5: provider-level introspection for the v7.8.3 procedural_force_first_action_tool lever.

Returns True when the provider can honour llm.bind_tools(tools, tool_choice=<name>) under the given ModelConfig. Returns False when the provider would reject forced tool_choice (Anthropic's API returns 400 when thinking is enabled alongside tool_choice={type:tool,...}; see Anthropic's published extended-thinking constraint).

Default implementation returns True -- providers that have a no-force constraint override. The engine's _maybe_resolve_forced_tool_choice consults this BEFORE stamping forced_tool_choice into the LangGraph state, so adopters who configure thinking see a clean WARN log and a forced no-op rather than a runtime 400 from the API boundary.

Implementations MUST be pure functions of the passed ModelConfig -- no I/O, no provider state mutation -- so the engine can call this on every turn without cost.

v7.8.3 architectural-line preservation: the framework constrains the choice set when it can; abstains cleanly when it cannot. Auto-disabling config.thinking would cross the line (reasoning is model-owned).

Source code in src/symfonic/core/providers.py
def supports_forced_tool_choice(self, config: ModelConfig) -> bool:
    """v7.8.5: provider-level introspection for the v7.8.3
    ``procedural_force_first_action_tool`` lever.

    Returns ``True`` when the provider can honour
    ``llm.bind_tools(tools, tool_choice=<name>)`` under the given
    ``ModelConfig``.  Returns ``False`` when the provider would
    reject forced tool_choice (Anthropic's API returns ``400`` when
    ``thinking`` is enabled alongside ``tool_choice={type:tool,...}``;
    see Anthropic's published extended-thinking constraint).

    Default implementation returns ``True`` -- providers that have
    a no-force constraint override.  The engine's
    ``_maybe_resolve_forced_tool_choice`` consults this BEFORE
    stamping ``forced_tool_choice`` into the LangGraph state, so
    adopters who configure ``thinking`` see a clean WARN log and
    a forced no-op rather than a runtime 400 from the API
    boundary.

    Implementations MUST be pure functions of the passed
    ``ModelConfig`` -- no I/O, no provider state mutation -- so
    the engine can call this on every turn without cost.

    v7.8.3 architectural-line preservation: the framework
    constrains the choice set when it can; abstains cleanly when
    it cannot.  Auto-disabling ``config.thinking`` would cross the
    line (reasoning is model-owned).
    """
    return True

MultiProviderRouter

MultiProviderRouter(*, default: ModelProvider, routes: dict[str, ModelProvider] | None = None)

v7.9.1: prefix-based ModelProvider dispatcher.

Closes the v7.9.0 adoption gap: FrameworkConfig.role_models lets adopters route different roles to different ModelConfig instances, but SymfonicAgent accepts a SINGLE model_provider -- so a ModelConfig(model_name= "gpt-4.1-nano") route handed to AnthropicProvider builds ChatAnthropic(model="gpt-4.1-nano") and crashes at the API boundary with 404 model_not_found.

This router satisfies the ModelProvider protocol and dispatches each call to a backing provider based on the ModelConfig.model_name prefix. Adopters compose it once at agent construction; SymfonicAgent never sees the multi-provider topology.

Example::

from symfonic.core.providers import (
    AnthropicProvider, MultiProviderRouter, OpenAIProvider,
)

provider = MultiProviderRouter(
    default=AnthropicProvider(),
    routes={
        "gpt-": OpenAIProvider(),
        "o1-": OpenAIProvider(),
        "o3-": OpenAIProvider(),
    },
)
agent = SymfonicAgent(
    model_provider=provider,
    config=FrameworkConfig(
        agent=AgentConfig(
            model=ModelConfig(model_name="claude-opus-4-6"),
        ),
        role_models={
            "action": ModelConfig(model_name="gpt-4.1-nano"),
        },
    ),
)

Routes are matched in insertion order via startswith (the first matching prefix wins). Unmatched model_name strings fall back to default. Empty / None model_name falls back to default (the construction-time provider's default model).

Architectural-line preserved (v7.8.3): the router is wiring, not policy. It exposes the ModelProvider surface and delegates per call; it does not modify ModelConfig instances, does not log model selection (adopter telemetry is the right place), and does not maintain per-call state.

Construct the router.

Parameters:

Name Type Description Default
default ModelProvider

The provider used for any ModelConfig whose model_name does not match a registered prefix (or has an empty / None model_name).

required
routes dict[str, ModelProvider] | None

Mapping from prefix string to ModelProvider. Matched in insertion order via startswith. An empty / None mapping makes the router a pass-through to default.

None
Source code in src/symfonic/core/providers.py
def __init__(
    self,
    *,
    default: ModelProvider,
    routes: dict[str, ModelProvider] | None = None,
) -> None:
    """Construct the router.

    Args:
        default: The provider used for any ``ModelConfig`` whose
            ``model_name`` does not match a registered prefix
            (or has an empty / ``None`` ``model_name``).
        routes: Mapping from prefix string to ``ModelProvider``.
            Matched in insertion order via ``startswith``.  An
            empty / ``None`` mapping makes the router a
            pass-through to ``default``.
    """
    self._default: ModelProvider = default
    self._routes: dict[str, ModelProvider] = dict(routes or {})
    # v7.11.0 (closed-enumeration audit, Part C): warn adopters
    # when an earlier prefix would shadow a longer one that can
    # never fire.  Example: ``{"gpt-": OpenAIProvider(),
    # "gpt-4.1-": SpecificProvider()}`` -- ``"gpt-"`` matches
    # every gpt-4.1-* model first, so SpecificProvider() is dead
    # code.  Insertion-order semantics are documented but the
    # footgun is silent -- this WARN surfaces it once at
    # construction so adopters can re-order.  Architectural
    # principle: closed-set enumerations are framework
    # boundaries; cross-boundary extension (custom route maps)
    # needs an adopter seam AND honest feedback when the seam is
    # misconfigured.
    self._warn_shadowed_routes()

get_structured_chat_model

get_structured_chat_model(config: ModelConfig) -> BaseChatModel | None

Return a route-matched specialization, never rebuild its chat model.

Source code in src/symfonic/core/providers.py
def get_structured_chat_model(self, config: ModelConfig) -> BaseChatModel | None:
    """Return a route-matched specialization, never rebuild its chat model."""
    picked = self._pick(config)
    getter = getattr(picked, "get_structured_chat_model", None)
    return getter(config) if callable(getter) else None

refusal_reason

refusal_reason(config: ModelConfig) -> str | None

v7.19.2: delegate refusal-reason diagnostics to the route- matched provider so the per-turn WARN names the right cause.

Source code in src/symfonic/core/providers.py
def refusal_reason(self, config: ModelConfig) -> str | None:
    """v7.19.2: delegate refusal-reason diagnostics to the route-
    matched provider so the per-turn WARN names the right cause."""
    picked = self._pick(config)
    getter = getattr(picked, "refusal_reason", None)
    if callable(getter):
        try:
            return getter(config)
        except Exception:
            return None
    return None

supports_forced_tool_choice

supports_forced_tool_choice(config: ModelConfig) -> bool

Delegate to the route-matched provider so per-role forcing eligibility tracks the right model. v7.8.5 refuse-to-force contract: the engine queries this BEFORE binding tools, so the answer must reflect the model that will actually receive the call -- not the default.

Source code in src/symfonic/core/providers.py
def supports_forced_tool_choice(self, config: ModelConfig) -> bool:
    """Delegate to the route-matched provider so per-role forcing
    eligibility tracks the right model.  v7.8.5 refuse-to-force
    contract: the engine queries this BEFORE binding tools, so
    the answer must reflect the model that will actually receive
    the call -- not the default."""
    return self._pick(config).supports_forced_tool_choice(config)

supports_streaming

supports_streaming() -> bool

Delegate to default for the same reason as supports_thinking.

Source code in src/symfonic/core/providers.py
def supports_streaming(self) -> bool:
    """Delegate to ``default`` for the same reason as
    ``supports_thinking``."""
    return self._default.supports_streaming()

supports_thinking

supports_thinking() -> bool

Delegate to default -- this method is called without a config per the v7.x protocol, so the safest answer is the default provider's capability.

Source code in src/symfonic/core/providers.py
def supports_thinking(self) -> bool:
    """Delegate to ``default`` -- this method is called without a
    ``config`` per the v7.x protocol, so the safest answer is
    the default provider's capability."""
    return self._default.supports_thinking()

OllamaProvider

OllamaProvider(base_url: str = 'http://localhost:11434')

Local Ollama models. Requires langchain-ollama.

Install via: pip install symfonic-core[ollama]

Source code in src/symfonic/core/providers.py
def __init__(self, base_url: str = "http://localhost:11434"):
    self._base_url = base_url

OpenAIProvider

OpenAIProvider(base_url: str | None = None, api_key: str | None = None, *, extra_body: Mapping[str, Any] | None = None)

OpenAI model construction. Requires langchain-openai.

Install via: pip install symfonic-core[openai]

Source code in src/symfonic/core/providers.py
def __init__(
    self,
    base_url: str | None = None,
    api_key: str | None = None,
    *,
    extra_body: Mapping[str, Any] | None = None,
):
    self._base_url = base_url
    self._api_key = api_key
    self._extra_body = dict(extra_body) if extra_body is not None else None

get_structured_chat_model

get_structured_chat_model(config: ModelConfig) -> BaseChatModel | None

Build a Qwen terminal schema model or decline specialization.

Qwen's OpenAI-compatible chat template emits reasoning before its answer by default. vLLM then accepts response_format but does not constrain that reasoning-shaped response to the declared schema. The conversational model keeps its configured thinking mode; only the terminal structured-output pass disables it. Other OpenAI-family models return None so the plan reuses the model it already built.

Source code in src/symfonic/core/providers.py
def get_structured_chat_model(self, config: ModelConfig) -> BaseChatModel | None:
    """Build a Qwen terminal schema model or decline specialization.

    Qwen's OpenAI-compatible chat template emits reasoning before its
    answer by default.  vLLM then accepts ``response_format`` but does not
    constrain that reasoning-shaped response to the declared schema.  The
    conversational model keeps its configured thinking mode; only the
    terminal structured-output pass disables it.  Other OpenAI-family
    models return ``None`` so the plan reuses the model it already built.
    """
    if not config.model_name.casefold().startswith("qwen"):
        return None
    extra = dict(self._extra_body or {})
    template = dict(extra.get("chat_template_kwargs") or {})
    template["enable_thinking"] = False
    extra["chat_template_kwargs"] = template
    return OpenAIProvider(
        base_url=self._base_url,
        api_key=self._api_key,
        extra_body=extra,
    ).get_chat_model(config)

OpenRouterProvider

OpenRouterProvider(api_key: str | None = None, base_url: str = _DEFAULT_BASE_URL, *, site_url: str | None = None, app_name: str | None = None)

OpenRouter (https://openrouter.ai) via its OpenAI-compatible API.

v8.10.0. OpenRouter is a gateway that routes to models from many vendors (Anthropic, OpenAI, Google, Meta, …) behind one OpenAI-shaped endpoint, so this mirrors :class:KimiProvider / :class:DeepSeekProvider — a ChatOpenAI client with a custom base_url and an env-var-or-explicit api_key.

Install via: pip install symfonic-core[openrouter]

ModelConfig.model_name MUST be a fully-qualified OpenRouter model id, e.g. "anthropic/claude-sonnet-4.5", "openai/gpt-4o", "meta-llama/llama-3.3-70b-instruct".

Reads OPENROUTER_API_KEY from the environment when api_key is not passed explicitly. Optionally set site_url / app_name — OpenRouter uses the HTTP-Referer / X-Title headers for its app-ranking board.

Declares the unknown (wire-neutral) family: because the underlying model varies per request, symfonic makes no Anthropic-/OpenAI-specific cache-control or thinking assumptions about the wire.

Source code in src/symfonic/core/providers.py
def __init__(
    self,
    api_key: str | None = None,
    base_url: str = _DEFAULT_BASE_URL,
    *,
    site_url: str | None = None,
    app_name: str | None = None,
) -> None:
    self._api_key = api_key
    self._base_url = base_url
    self._site_url = site_url
    self._app_name = app_name

bedrock_family_for_model

bedrock_family_for_model(model_id: str) -> str

Classify a Bedrock model / inference-profile id into a symfonic wire family.

Bedrock serves many vendors through one Converse endpoint. The id is [<region>.]<vendor>.<model> (e.g. us.anthropic.claude-..., us.meta.llama3-..., amazon.nova-pro-..., mistral.mistral-large-...). We map the anthropic vendor to the anthropic wire family (so Claude-on-Bedrock keeps extended thinking) and every other vendor to unknown — wire-neutral, so symfonic never stamps Anthropic-native cache_control / thinking blocks onto a Llama / Nova / Mistral / Titan / Cohere / AI21 request (Converse would reject them).

Source code in src/symfonic/core/providers.py
def bedrock_family_for_model(model_id: str) -> str:
    """Classify a Bedrock model / inference-profile id into a symfonic wire family.

    Bedrock serves many vendors through one Converse endpoint. The id is
    ``[<region>.]<vendor>.<model>`` (e.g. ``us.anthropic.claude-...``,
    ``us.meta.llama3-...``, ``amazon.nova-pro-...``, ``mistral.mistral-large-...``).
    We map the ``anthropic`` vendor to the ``anthropic`` wire family (so
    Claude-on-Bedrock keeps extended thinking) and every other vendor to
    ``unknown`` — wire-neutral, so symfonic never stamps Anthropic-native
    ``cache_control`` / ``thinking`` blocks onto a Llama / Nova / Mistral /
    Titan / Cohere / AI21 request (Converse would reject them).
    """
    vendor = model_id.lower()
    # Strip an optional cross-region inference-profile prefix
    # (``us.`` / ``eu.`` / ``apac.`` / ``us-gov.`` / ``global.``).
    for prefix in ("us-gov.", "global.", "us.", "eu.", "apac.", "ca.", "sa."):
        if vendor.startswith(prefix):
            vendor = vendor[len(prefix) :]
            break
    return "anthropic" if vendor.startswith("anthropic.") else "unknown"