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. |
None
|
credentials_profile_name
|
str | None
|
Optional named profile from
|
None
|
credentials_resolver
|
Callable[[], Mapping[str, str]] | None
|
Optional per-call multi-tenant seam
(mirrors :class: |
None
|
provider_family
|
str | None
|
Force the wire family
( |
None
|
Source code in src/symfonic/core/providers.py
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: |
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 |
None
|
Resolution precedence (inside :meth:get_chat_model):
credentials_resolver() > static token > env lookup.
Source code in src/symfonic/core/providers.py
extract_cache_ttl
staticmethod
¶
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 |
required |
Returns:
| Type | Description |
|---|---|
str | None
|
|
str | None
|
otherwise (5m cache only, no cache, or non-dict input). |
Source code in src/symfonic/core/providers.py
refusal_reason ¶
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
supports_forced_tool_choice ¶
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
DeepSeekProvider ¶
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 ¶
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
ModelProvider ¶
Bases: Protocol
Protocol for LLM model construction.
supports_forced_tool_choice ¶
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
MultiProviderRouter ¶
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 |
required |
routes
|
dict[str, ModelProvider] | None
|
Mapping from prefix string to |
None
|
Source code in src/symfonic/core/providers.py
refusal_reason ¶
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
supports_forced_tool_choice ¶
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
supports_streaming ¶
supports_thinking ¶
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
OllamaProvider ¶
OpenAIProvider ¶
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
bedrock_family_for_model ¶
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).