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
    )

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. The framework-level usage extractors in callbacks/emit.py and nodes/react.py consult this helper so they stay provider-agnostic — they pass through the input_token_details dict without coupling themselves to Anthropic-specific key names.

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.  The framework-level usage extractors in
    ``callbacks/emit.py`` and ``nodes/react.py`` consult this helper
    so they stay provider-agnostic — they pass through the
    ``input_token_details`` dict without coupling themselves to
    Anthropic-specific key names.

    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).
    """
    if not isinstance(input_token_details, dict):
        return None
    # ``or 0`` guards against the field being present but ``None``
    # (older langchain-anthropic surfaced a None when 1h cache was
    # absent rather than omitting the key).
    ephemeral_1h = input_token_details.get("ephemeral_1h_input_tokens") or 0
    if ephemeral_1h > 0:
        return "1h"
    return None

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

Google Gemini models. Requires langchain-google-genai.

Install via: pip install symfonic-core[google]

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

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
)

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):
    self._base_url = base_url
    self._api_key = api_key

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"