Solwyn Python SDK
Budget enforcement, circuit breaking, and usage tracking for OpenAI, Anthropic, Google, and Amazon Bedrock LLM clients — plus any provider that speaks the OpenAI Chat Completions dialect (xAI, DeepSeek, Mistral, Qwen, Z.ai, Groq, Together, Fireworks, Perplexity, Azure OpenAI, OpenRouter, Ollama, vLLM, LM Studio, …).
Solwyn wraps your existing LLM client. Calls go directly to the provider — the SDK only reports metadata (token counts, media quantities, latency, model name) to the Solwyn API. Prompts and responses never leave your application.
Installation
pip install solwyn
Optional extras pin tested provider-SDK floors — solwyn[openai], solwyn[anthropic], solwyn[google], solwyn[bedrock] (convenience only — the SDK never imports boto3), solwyn[together] (Together SDK 2.0+), or solwyn[all]:
pip install solwyn[openai]
Other OpenAI-compatible endpoints (Groq, OpenRouter, vLLM, …) ride the openai extra. Together can use that path too; solwyn[together] supplies its native SDK instead.
Supported provider SDK versions
| Provider SDK | Declared floor | Tested breakpoints (including floor) | Latest tested |
|---|---|---|---|
openai |
openai>=2.0 |
openai==2.0.0, openai==2.2.0, openai==2.21.0, openai==2.34.0, openai==2.52.0 |
openai==3.37.0 |
anthropic |
anthropic>=0.50 |
anthropic==0.50.0, anthropic==0.80.0, anthropic==1.0.0, anthropic==1.14.0 |
anthropic==2.3.0 |
together |
together>=2.0 |
together==2.0.0, together==2.41.0 |
together==3.0.0 |
google-genai |
google-genai>=1.0 |
google-genai==1.0.0, google-genai==1.60.0, google-genai==2.0.0 |
google-genai==2.33.0 |
google-generativeai |
google-generativeai>=0.8.5 |
google-generativeai==0.8.5 |
google-generativeai==0.8.6 |
bedrock |
boto3>=1.34 |
aioboto3==13.0.0, aioboto3==13.4.0, aioboto3==15.0.0, aioboto3==15.5.0 |
boto3==1.43.118, aioboto3==15.5.0 |
Floors come from the install extras in pyproject.toml; the legacy
google-generativeai floor is declared in the dev extra, and that SDK must be
installed separately. Breakpoints come from the surface canary's interval
catalog. Latest tested versions come from the development lock and surface
inventory; Bedrock's async lanes install aioboto3 with its compatible boto3.
Anthropic versions below 1.14 remain fully supported with no behavior change.
opentelemetry-api is optional and never installed directly by Solwyn
(Anthropic 1.14+ brings it as its own dependency).
Quick Start
from openai import OpenAI
from solwyn import Solwyn
client = Solwyn(
OpenAI(),
api_key="sk_proj_...",
)
response = client.chat.completions.create(
model="gpt-5.5",
messages=[{"role": "user", "content": "Hello!"}],
)
client.close()
Or use as a context manager:
with Solwyn(OpenAI(), api_key="sk_proj_...") as client:
response = client.chat.completions.create(
model="gpt-5.5",
messages=[{"role": "user", "content": "Hello!"}],
)
Drop-in type compatibility
Solwyn wrappers pass framework admission checks that require the concrete provider SDK type:
raw = OpenAI()
client = Solwyn(raw, api_key="sk_proj_...")
assert isinstance(client, OpenAI)
assert isinstance(client, type(raw))
type(client) remains the truthful wrapper class (Solwyn or
AsyncSolwyn), while client.__class__ reports the wrapped provider class for
isinstance-based framework compatibility. The same contract holds for
AsyncSolwyn(AsyncOpenAI(...)). Every non-_solwyn_* attribute assignment and
deletion forwards to the provider client, including names also defined by the
wrapper; only _solwyn_* state remains local.
These clients own live reporter, budget, and provider-transport state.
copy.copy(client) and copy.deepcopy(client) therefore return the same
shared wrapper, rather than cloning those resources. Pickling is rejected with
guidance to construct a fresh Solwyn(...) or AsyncSolwyn(...) in the target
process.
Framework integrations
See the framework support matrix for the exact enforcement boundary, admitted call surfaces, dependency posture, and current limitations.
- OpenAI Agents SDK — a docs-and-smoke
recipe injects an
AsyncSolwyndefault client for enforced Chat Completions and stable workflow/agent attribution; no Agents integration module ships. - LangChain and LangGraph — the shipped
content-free handler plus the exact recipe shim covers basic non-streaming
invoke/ainvokeChat Completions and explicit graph hierarchy. - CrewAI — the shipped content-free listener
adds crew/task hierarchy; native LiteLLM has no Solwyn enforcement, while the
narrow custom-
BaseLLMrecipe routes one sync plain-text call through a wrapped client.
Compatibility is limited to the paths named in the matrix and recipes; do not infer budget enforcement for other framework call surfaces.
Providers
OpenAI
from openai import OpenAI
from solwyn import Solwyn
client = Solwyn(OpenAI(), api_key="sk_proj_...")
response = client.chat.completions.create(model="gpt-5.5", messages=[...])
Anthropic
from anthropic import Anthropic
from solwyn import Solwyn
client = Solwyn(Anthropic(), api_key="sk_proj_...")
response = client.messages.create(model="claude-sonnet-4-20250514", max_tokens=1024, messages=[...])
client.beta.messages.create(...) is metered the same way, streaming or not, so
beta features such as server-side compaction
are budget-checked and settled. Compaction tokens, which Anthropic reports only
in usage.iterations, are added to the settled input, output, and cache counts
and broken out in token_details (see Data Transparency).
Beta request fields (betas, context_management, compaction, ...) pass
through untouched and are never recorded. A beta call fails over only to other
Anthropic entries in the fallback= chain, never to a different provider:
beta-only fields have no equivalent elsewhere. The other beta Messages helpers
(beta.messages.stream, .parse, .tool_runner, .count_tokens, and batches)
remain unmetered and follow on_unmetered, like their non-beta counterparts.
Google Gemini
from google import genai
from solwyn import Solwyn
client = Solwyn(genai.Client(api_key="..."), api_key="sk_proj_...")
response = client.models.generate_content(model="gemini-3.5-flash", contents="Hello!")
Amazon Bedrock
Wrap a bedrock-runtime boto3 client. Solwyn intercepts the Converse API (converse / converse_stream), which works uniformly across every chat model Bedrock hosts — Anthropic Claude, Meta Llama, Mistral, Amazon Nova, Cohere, AI21, DeepSeek, and more. Auth stays entirely on your boto3 client (IAM credentials, profiles, roles, SigV4) — Solwyn never sees it.
import boto3
from botocore.config import Config
from solwyn import Solwyn
bedrock = boto3.client(
"bedrock-runtime",
region_name="us-east-1",
# Recommended: let Solwyn own retries/failover instead of stacking
# botocore's default retry layer (legacy mode retries up to 5 times).
config=Config(retries={"total_max_attempts": 1}, read_timeout=60),
)
client = Solwyn(bedrock, api_key="sk_proj_...")
response = client.converse(
modelId="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=[{"role": "user", "content": [{"text": "Hello!"}]}],
inferenceConfig={"maxTokens": 1024},
)
Streaming preserves the boto3 contract (response["stream"]); usage settles from the stream's terminal metadata event:
response = client.converse_stream(
modelId="amazon.nova-pro-v1:0",
messages=[{"role": "user", "content": [{"text": "Hello!"}]}],
)
for event in response["stream"]:
...
If you stop consuming the stream early, call response["stream"].close() (or wrap iteration in with response["stream"]:) to settle the budget reservation — the same close obligation raw boto3's EventStream has. close() settles exactly once with whatever usage was observed and is safe to call repeatedly.
Notes:
- Model identity is reported exactly as you pass it — foundation-model ids, cross-region inference profiles (
us./eu./jp./global.…), or full ARNs — together with the client's region, because Bedrock pricing is keyed per model and region. Prompt-cache reads/writes (including the 1h-TTL tier viausage.cacheDetails) and the latency/service pricing tier are captured for exact repricing. invoke_model/invoke_model_with_response_stream/start_async_invokeraiseConfigurationErrorinstead of bypassing budget tracking: their usage is buried in a consume-once body (or lands out-of-band in S3, forstart_async_invoke) alongside response content. Use Converse, or call the unwrapped boto3 client for deliberately untracked calls.- boto3 has no per-call timeout override, so neither the failover window nor
failover_hop_read_timeoutcan shorten an in-flight Bedrock hop — setread_timeoutin your botocoreConfig(building a client withread_timeout=Nonelogs a warning; see Failover timeouts). - Async works with aioboto3:
AsyncSolwyn(client)insideasync with session.client("bedrock-runtime") as client. - Bedrock participates in cross-provider failover in both directions (e.g. Bedrock-Claude ⇄ direct Anthropic) via the same canonical translation subset as the other providers.
Together AI
Solwyn supports the native Together SDK at together>=2.0. Install the convenience extra, then wrap the client directly:
pip install "solwyn[together]"
from solwyn import Solwyn
from together import Together
client = Solwyn(Together(api_key="..."), api_key="sk_proj_...")
response = client.chat.completions.create(
model="meta-llama/Llama-3.3-70B-Instruct-Turbo",
messages=[{"role": "user", "content": "Hello!"}],
)
Pair sync and async client types: use Solwyn with Together, and AsyncSolwyn with AsyncTogether:
from solwyn import AsyncSolwyn
from together import AsyncTogether
async with AsyncSolwyn(AsyncTogether(api_key="..."), api_key="sk_proj_...") as client:
response = await client.chat.completions.create(
model="meta-llama/Llama-3.3-70B-Instruct-Turbo",
messages=[{"role": "user", "content": "Hello!"}],
)
The optional extra is bring-your-own convenience only: Solwyn core never imports Together. An openai.OpenAI client pointed at Together's compatible endpoint remains supported as described below.
OpenAI-compatible providers
Point an openai.OpenAI client at any OpenAI-compatible endpoint via base_url and wrap it as usual. Solwyn detects the provider from the URL, so budgets, per-agent attribution, failover, and the cost dashboard all see the real provider (e.g. groq), not "openai":
from openai import OpenAI
from solwyn import Solwyn
client = Solwyn(
OpenAI(base_url="https://api.groq.com/openai/v1", api_key="gsk_..."),
api_key="sk_proj_...",
)
response = client.chat.completions.create(
model="llama-3.3-70b-versatile",
messages=[{"role": "user", "content": "Hello!"}],
)
Auto-detected providers:
| Provider | Detected from | Streaming usage |
|---|---|---|
| xAI (Grok) | api.x.ai |
automatic (final chunk); stream_options is never sent — xAI rejects it |
| DeepSeek | api.deepseek.com |
include_usage injected |
| Mistral | api.mistral.ai |
stream_options never sent (strict validation); final-chunk usage or estimate |
| Qwen (DashScope compat) | dashscope*.aliyuncs.com |
include_usage injected |
Z.ai (zai) |
api.z.ai |
include_usage injected |
| Groq | api.groq.com |
include_usage injected; legacy x_groq.usage also handled |
| Together AI | native Together / AsyncTogether, or api.together.xyz / api.together.ai |
automatic (final chunk) |
| Fireworks | api.fireworks.ai |
automatic (final chunk) |
| Perplexity (Sonar) | api.perplexity.ai |
usage on streamed chunks; stream_options never sent |
| Azure OpenAI | *.openai.azure.com or AzureOpenAI client class |
include_usage injected (skipped for "on your data" data_sources requests, which reject it) |
| OpenRouter | openrouter.ai |
automatic (final chunk); stream_options is deprecated there |
| Ollama | localhost:11434 |
include_usage injected (older versions ignore it → estimate) |
| vLLM | localhost:8000 |
include_usage injected |
| LM Studio | localhost:1234 |
include_usage injected (pre-0.3.18 omits usage → estimate) |
| Anything else | any non-OpenAI base_url |
generic openai_compatible; stream_options never sent |
For endpoints auto-detection can't name, pin the provider explicitly — on the constructor for the primary, or as the 4th element of a fallback spec:
client = Solwyn(
OpenAI(base_url="http://gpu-box:8080/v1", api_key="-"),
api_key="sk_proj_...",
provider="vllm",
fallback=[(OpenAI(base_url="https://openrouter.ai/api/v1", api_key="sk-or-..."), "openrouter/auto"),
(other_client, "my-model", {}, "ollama")],
)
provider= is an identity assertion, not a label applied after detection. It
bypasses base_url detection entirely and selects the named adapter. The pin
does not translate dialects, rewrite the endpoint, or synthesize a different
SDK client; construction still validates the actual client family and
sync/async mode, and raises ConfigurationError(field="client") for a mismatch.
Unknown provider names raise ConfigurationError(field="provider"). Fallback
provider pins follow the same rules.
This is useful for native OpenAI behind a corporate gateway or local proxy,
where an arbitrary base_url would otherwise look like a generic compatible
provider. Pinning openai preserves the native Responses surface and OpenAI
budget attribution:
gateway = OpenAI(base_url="http://localhost:9999/v1", api_key="...")
client = Solwyn(gateway, api_key="sk_proj_...", provider="openai")
response = client.responses.create(
model="gpt-5.5",
input="Summarize the release notes.",
)
If that gateway omits or zeroes foreground Responses usage, create and
parse settle the request-length input estimate with
token_details.is_estimated = true instead of reporting exact 0/0. The
unknown output remains zero in the estimate, and a lease-backed call keeps its
full reserved bound so that unseen output spend is not re-lent. Streaming and
the stream helper apply the same conservative policy when terminal usage is
missing.
Token accounting. Budgets and attribution depend on accurate per-call usage, and "OpenAI-compatible" endpoints differ most in exactly that. Solwyn requests streaming usage only from providers where that's documented-safe, reads it from the final chunk where it arrives automatically, and — when a provider reports no usage at all (or reports an unparseable/zeroed block alongside real content) — falls back to a length-based estimate that is explicitly marked (token_details.is_estimated = true on the wire; compatible-provider degradation also emits the existing one-time SDK warning where applicable). Degraded accounting is flagged, never silently zero.
The "never sent" entries above describe Solwyn's own injection policy. A stream_options you pass explicitly always reaches your configured provider untouched (drop-in contract); it is only stripped when a failover hop lands on a provider known to reject it.
Pricing. The SDK never computes cost. It reports the served (provider, model) verbatim — for OpenRouter that's the full model slug (e.g. anthropic/claude-sonnet-4.5) — and Solwyn Cloud's PricingService prices it. Models unknown to the catalog are surfaced as unpriced on the dashboard rather than silently costed at $0.
Failover. Compat providers participate fully in failover. Between two OpenAI-dialect providers (e.g. Groq → OpenRouter) requests pass through natively — tools, JSON mode, and streaming included (max_completion_tokens is rewritten to max_tokens for targets that need the legacy key). Per-call extra_headers/extra_query/extra_body are stripped on cross-provider hops — they're endpoint-scoped, authored for the original endpoint — though the fallback entry's own default_params versions still apply. Across dialects (e.g. Groq → Anthropic) the standard translation subset applies.
Known limitation. Circuit-breaker health, latency signals, and failover labeling key off the provider name. Two chain entries that resolve to the same name (two Azure resources, two unnamed gateways both detected as openai_compatible) share one health domain and are reported as model fallbacks of each other. For the same reason, a hop between same-name entries skips cross-provider request sanitization — stream_options stripping, the max_completion_tokens → max_tokens rewrite, and endpoint-scoped param stripping (extra_headers/extra_query/extra_body). A stream_options or gateway header you authored for the first endpoint reaches the second untouched and can 4xx there. Give distinct endpoints distinct provider identities where possible — explicit provider= on the constructor, or the 4th element of a fallback spec.
Known limitation. solwyn_tags is removed only on intercepted provider calls. On non-intercepted surfaces such as client.files.create(...), it is passed through to the provider SDK, which can raise TypeError for the unexpected keyword. Call those surfaces without solwyn_tags.
Media surfaces
Beyond chat, Solwyn tracks these billed API surfaces. Each rides the same budget-check → provider call → confirm lifecycle as a chat call, tagged with its modality (text, embedding, image, audio, video) for pricing. Decisions and media calls use the primary provider without model or provider failover.
| Surface | OpenAI dialect (native + compatible) | Google (Gemini) |
|---|---|---|
| Embeddings | client.embeddings.create |
client.models.embed_content |
| Images | client.images.generate / client.images.edit |
client.models.generate_images (Imagen) |
| Audio — transcription | client.audio.transcriptions.create (incl. Groq whisper) |
— |
| Audio — speech (TTS) | client.audio.speech.create |
— |
| Video | client.videos.create (Sora) |
client.models.generate_videos (Veo) |
| Responses | Native OpenAI + Azure OpenAI: client.responses.create / .parse / .stream |
— |
| Decisions | Native OpenAI only: client.decisions.create (openai>=3.26) |
— |
Billable quantities are read from the response's usage block where it exists (gpt-image token buckets, whisper duration) and derived from the request where a provider reports none — image counts from n=, TTS character counts from input=, video seconds from the request. Whatever the SDK can't observe stays None, and the call is tracked unpriced rather than settled at a silent $0. Only lengths, counts, durations, and variant selectors are ever measured — never the media itself.
Posture notes.
- OpenAI Decisions uses text metering.
decisions.create(...)is budget-checked on sync and async native OpenAI clients. Provider usage supplies input, output, cache-read, cache-write, and reasoning token counts; missing or zeroed usage falls back to a length estimate markedtoken_details.is_estimated=True. Preflight counts text ininputand question instructions, choices, and score levels; image content contributes no estimated text tokens. Unmeasured output retains its reserved lease bound, as do ambiguous failures or interruptions after dispatch. Passmodel,input, andquestionsas top-level arguments; metering-field overrides andstreaminextra_bodyare refused before dispatch. The OpenAIDecisionsToolRunnerand async twin meter every API call when theirrun(...)method receives the Solwyn-wrapped client. - Whisper needs a JSON
response_formatto be priced.whisper-1reports its billable duration only under a JSON response format. A non-JSONresponse_format(text/srt/vtt) carries no usage, so the call is tracked unpriced with a one-time hint to passresponse_format="json"(or"verbose_json") for priced tracking. gpt-4o-mini-ttsis untracked. Token-billed TTS models publish no usage metadata, so their audio-output tokens are unobservable. Rather than settle a silent $0, the call follows the configured untracked posture (under the defaultwarn, it passes through after a one-time warning with no budget check or cost event).audio.translationsis untracked. The translations sub-surface isn't intercepted yet, so it follows the same configured posture.
Strict coverage controls
- Anthropic beta Messages:
beta.messages.createno longer needs an acknowledgment. Remove it fromacknowledge_untracked: a stale token now raisesConfigurationError. Every otherbeta.messagesleaf and its raw/streaming response mirrors keep their exact acknowledgment tokens. - OpenAI Decisions: Native
decisions.createno longer needs an acknowledgment. Removedecisions.createfromacknowledge_untrackedon native OpenAI clients: a stale token now raisesConfigurationError, including when the acknowledgment list is shared with Azure or compatible clients. Azure OpenAI and compatible providers retain their unmetered posture and exactdecisions.createtoken. Raw/streaming response mirrors retain their separate exact-token guards. The read-onlyadmin.organization.usage.decisionsendpoint reports historical organization usage; it remains unmetered, requires its own exact acknowledgment in strict mode, and never produces a settlement. - OpenAI Responses: Native OpenAI and Azure OpenAI
responses.create(...),responses.parse(...), and theresponses.stream(...)context-manager helper are budget-metered for sync and async clients.create(stream=True)is supported; streamingparseis not metered, so any effective streaming parse request is refused. The stream helper's new-response overload preserves the SDK's context-manager andget_final_response()behavior while settling terminal usage or a conservative estimate on early exit; a helper closed before it is ever entered sent no provider request, so it releases its reservation instead of settling. Foreground non-streaming calls likewise settle a conservative marked estimate when Responses usage is missing or zeroed; lease-backed calls hold the reserved bound because output usage is unobservable. Its existing-response retrieval overload (response_id/starting_after) creates no new spend, so it is a reviewed raw pass-through: no defaults, budget check, or duplicate settlement are applied. Every other Responses leaf, including beta and raw response helpers, remains guarded byon_unmetered.background=Truecreate calls are refused because queued responses expose no create-time usage. Because the OpenAI SDK serializesextra_bodyafter named arguments, metering-critical overrides formodel,input,instructions,max_output_tokens, orstreamare refused withConfigurationError(field="extra_body"); pass those values as top-level Responses arguments instead. Other vendor-specificextra_bodyextensions pass through unchanged. Other OpenAI-compatible providers retain their raw Responses managers and follow the guarded unmetered posture.
Solwyn classifies the public pre-call capability graph of every supported
wrapped client. Tracked leaves are intercepted as usual. Resource namespaces
stay guarded so access to a parent never grants present or future descendants.
Known untracked leaves and newly observed leaves follow on_unmetered:
on_unmetered="warn"logs once and permits the call (the compatibility default).on_unmetered="raise"refuses the call before provider I/O withUntrackedSpendSurfaceError. This is strict mode.on_unmetered="allow"permits the call without warning.
By default, unacknowledged warn and allow observations schedule structural
advisory POSTs to the project-implicit /api/v1/untracked-surfaces route. A
background reporter thread/task sends them immediately on first observation
and then at a 15-minute cadence; shutdown may make a deadline-bounded
best-effort final attempt. Send failures are silent. Payloads contain only the
dotted surface path, bounded provider/client-shape, sync/async, rule, scope,
and posture fields, approximate occurrence counts and first/last timestamps,
and random SDK-instance/report identifiers. Model names, request arguments,
prompts, and responses are never included. This is an approximate signal, not
billing truth, and it does not add a budget check or cost event.
Set report_untracked_surfaces=False or
SOLWYN_REPORT_UNTRACKED_SURFACES=false to opt out of advisory egress. This
does not change the local on_unmetered posture; warnings, allowed calls, and
strict refusals continue unchanged.
Set the posture in the constructor or with SOLWYN_ON_UNMETERED=raise:
from openai import OpenAI
from solwyn import Solwyn
client = Solwyn(
OpenAI(),
api_key="sk_proj_...",
on_unmetered="raise",
acknowledge_untracked={"responses.retrieve"},
)
Acknowledgments are narrow, deliberate exceptions to the posture. Each token
must name an applicable, observed terminal capability; it grants only that
leaf. Namespace tokens such as responses are invalid, as are wildcards,
typos, tracked leaves, blocked leaves, and unsupported leaves. Namespace
objects remain guarded after an acknowledgment, so responses.retrieve does not
authorize a future sibling. The equivalent comma-delimited environment
encoding is
SOLWYN_ACKNOWLEDGE_UNTRACKED="responses.retrieve,audio.speech.create:gpt-4o-mini-tts".
The conditional token for token-billed TTS is exactly
audio.speech.create:gpt-4o-mini-tts; acknowledging ordinary
audio.speech.create does not cover that model-specific exception.
Provider applicability is explicit. Native OpenAI video is tracked through
videos.create; video on an OpenAI-compatible provider is unsupported and
raises UnsupportedSurfaceError before dispatch. An acknowledgment cannot
turn an unsupported adapter surface into a supported one.
Tested SDK version intervals
The surface contract is verified against pinned structural breakpoints per
provider (see tests/provider_surface_intervals.json): floor, named
breakpoints, and latest, as listed in the supported provider SDK versions
table. Versions between tested breakpoints may expose
surfaces we have not classified; those resolve as unknown and follow
on_unmetered.
Use solwyn.coverage(client) to review the exact effective graph without
calling a provider operation. Coverage is computed locally and transmits
nothing. It reads structural client metadata only—never prompts, responses,
credentials, or request content. The report separates policy decisions from
dispatch behavior and includes provider-chain usage guarantees.
For CI, pin an independently reviewed literal fingerprint. This example is the
exhaustive strict, unacknowledged fingerprint exercised against
openai==3.37.0 by this repository's real-client test:
from openai import OpenAI
from solwyn import CoverageFingerprint, Solwyn, coverage
audit_client = Solwyn(
OpenAI(),
api_key="sk_proj_...",
on_unmetered="raise",
)
OPENAI_STRICT_FINGERPRINT = CoverageFingerprint(
guarded_namespaces="sha256:9ea13020464d009b7857e0eb04e31c392d28fa8491fec545b309e37e83dce13b",
tracked="sha256:a36c56e2099daf5e86aea8e925500ffc6792cb33d5dce377af742cbdd1e8c776",
untracked="sha256:59bce56e917e7bd253adb77b8d83406fa5f5d1d5cb8b040d42ff9b42d053286c",
unknown="sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
scoped_escapes="sha256:4d53dc944ec1aa82d6a9fd5876b3007da89dc453bf4f71b3b81c195882c704d9",
blocked="sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
unsupported="sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
conditional="sha256:ce837f71d1fc97849872c5d0f86b0b1f26e1bc4e46a29c3b1b8004bf4b9bcb77",
safe="sha256:9029368e5fa0a7bf4260cc782560c8ec9a53c948fc280102b1c3633eee5234c5",
)
report = coverage(audit_client)
report.expect(OPENAI_STRICT_FINGERPRINT)
Azure OpenAI exposes the same metered Responses trio, but its surrounding capability graph is distinct. Audit and pin an Azure client independently; do not reuse the native OpenAI fingerprint for Azure.
When a provider SDK changes, inspect report.entries, decide whether each
change is acceptable, and then paste a newly reviewed literal. Never approve a
report with a fingerprint derived from that same report in the assertion; that
would make the check tautological.
Strict mode is not a sandbox. It is a cooperative guard around the wrapper's public pre-call surface. The following can bypass pre-call strict enforcement: retaining the raw provider client, accessing private wrapper state, acknowledging a scoped raw escape, or invoking native behavior on a returned response, page, stream, job, or operation object. Keep those capabilities out of code that relies on strict enforcement, or review their use explicitly.
Async
from openai import AsyncOpenAI
from solwyn import AsyncSolwyn
async with AsyncSolwyn(
AsyncOpenAI(),
api_key="sk_proj_...",
) as client:
response = await client.chat.completions.create(
model="gpt-5.5",
messages=[{"role": "user", "content": "Hello!"}],
)
Streaming
Pass stream=True as you normally would. Solwyn wraps the stream transparently and reports usage when it completes:
stream = client.chat.completions.create(
model="gpt-5.5",
messages=[{"role": "user", "content": "Hello!"}],
stream=True,
)
for chunk in stream:
print(chunk.choices[0].delta.content or "", end="")
Tagging Calls with Agent Runs
Wrap a unit of work with solwyn.run(name, tags=...) to attribute every LLM call inside it to a single agent run. The dashboard groups cost and latency by run, so you can see "this nightly batch cost $4.20." Tags are optional explicit customer metadata for grouping and export.
import solwyn
from openai import OpenAI
client = solwyn.Solwyn(
OpenAI(),
api_key="sk_proj_...",
tags={"environment": "prod", "service": "research"},
)
with solwyn.run("nightly-batch", tags={"team": "research", "env": "prod"}) as run_id:
client.chat.completions.create(model="gpt-5.5", messages=[...])
client.chat.completions.create(
model="gpt-5.5",
messages=[...],
solwyn_tags={"env": "staging", "job": "backfill"},
)
The constructor's tags= mapping supplies client defaults to every intercepted call. You can set the same defaults from the environment with comma-separated key=value entries:
export SOLWYN_TAGS="environment=prod,service=research"
The environment format splits each entry at its first =, so values may contain = but cannot contain commas. Use constructor tags={"segment": "east,canary"} when a value contains a comma.
Nested runs inherit outer tags additively. Inner tags overwrite only keys they reuse, so a sub-agent keeps its orchestrator's attribution without repeating it:
with solwyn.run("orchestrator", tags={"team": "research", "workflow": "eval"}):
with solwyn.run("critic", tags={"agent": "critic", "team": "safety"}):
client.chat.completions.create(model="gpt-5.5", messages=[...])
# Tags: team=safety, workflow=eval, agent=critic
with solwyn.run("isolated", tags={"agent": "one-off"}, inherit_tags=False):
client.chat.completions.create(model="gpt-5.5", messages=[...])
# Tags: agent=one-off
Use the reserved solwyn_tags= keyword as a call argument, not in default_params; it is removed before provider dispatch. Precedence is client defaults, then the active run scope, then per-call tags, with the higher-precedence value kept on conflicts. Each supplied mapping allows at most 10 string keys, keys must contain 1–64 characters, and string values may contain 0–256 characters. Keys and values cannot contain NUL characters. The SDK validates and copies mappings at client or run creation and at call start, so invalid input fails eagerly and later caller mutation cannot change attribution.
The combined map can exceed 10 keys even when each supplied mapping is valid. In that case the SDK keeps 10 deterministically: per-call keys first, then active-scope keys, then client-default keys, preserving insertion order within each layer. It emits one SolwynTagsClampedWarning for the overflowing call and still dispatches the provider request; lower-priority excess tags are absent from that call's event.
Tagged calls always perform a fresh budget check because tag selectors can carry independent spending caps. They cannot use the project allow cache or the agent-run budget lease path. Setting constructor tags= therefore puts every intercepted call on the control-plane request path; leave client defaults unset when that attribution is not needed.
Use solwyn.current_run_context() to read the active RunContext(id, name, tags). Its tag mapping is a fresh copy, so caller mutation cannot change the active scope. solwyn.current_run() returns the (id, name) pair.
Works the same with async with and is safe across concurrent asyncio tasks — each task sees only its own active run. Calls made outside a solwyn.run(...) scope are still tracked; the API groups them into _auto-{sdk_instance_id}-{YYYY-MM-DD} using the event's UTC timestamp.
Runs opened in synchronous generators keep their attribution across yields,
including when Starlette/FastAPI StreamingResponse steps each chunk in a fresh
context on a worker thread. This also works through @contextmanager helpers,
ExitStack, and delegating __enter__ / __exit__ methods. Every call uses the
generator's run and recorded parent. Normal scope exit releases its budget lease
and ends its run span synchronously, including shared-context for loops.
Generator finalization, exits during garbage collection, and exits from a
foreign context defer cleanup to a background worker; the span keeps the
timestamp recorded at exit. Client close and interpreter exit drain pending
cleanup, with a bounded wait for span export. Driver calls between steps keep
the driver's context. Threads or executors started inside a step do not inherit a run
recovered from the generator's stack; use a detached run handle and explicitly
activate it in that work. A plain for loop sharing one context continues to
see the generator's run in its loop body. Recovery follows the executing owner
frame, up to 256 stack frames. A plain function that opens and closes a run
within one call keeps ordinary context scoping.
start_run(...).finish() normally requires the context that opened the run.
A run opened directly in a sync generator can also finish from that generator
in a later fresh-context step. Detached activations, including those wrapped in
context managers, can span yields and then finish normally. For separate
callbacks, tasks, or threads, use create_run() and handle.activate();
ordinary activations must exit in the context where they entered.
Do not open solwyn.run(...) inside an async generator. Python runs the consumer's async for body in the same context after a generator yield, so an inner generator scope would leak into customer code. The SDK rejects that pattern at scope entry. Open the scope in the consumer, or await the generator entirely inside an outer run scope.
Tasks created with asyncio.create_task(...) inside an ordinary run scope,
including helpers built with @contextmanager, capture that task's context.
If the task keeps making LLM calls after the with block exits, those calls are
still attributed to the captured run id. The same applies to copied contexts
when a generator exits in its entry context. A generator closed in a different
context retires its run in all copied step contexts. Use asyncio.TaskGroup or
await spawned tasks before leaving the scope when attribution must end with
the block.
Anthropic conversation IDs
Set anthropic_conversation_id=True (or
SOLWYN_ANTHROPIC_CONVERSATION_ID=true) to attach the active Solwyn run ID
to Anthropic's native OpenTelemetry span as gen_ai.conversation.id.
The flag defaults to False. Native spans require anthropic>=1.14 and an
application-configured tracer provider, set globally or on the Anthropic client.
For example, using your existing tracer_provider:
from anthropic import Anthropic
from solwyn import Solwyn, run
client = Solwyn(
Anthropic(open_telemetry={"tracer_provider": tracer_provider}),
api_key="sk_proj_...",
anthropic_conversation_id=True,
)
with run("nightly-batch") as run_id:
client.messages.create(
model="claude-sonnet-5",
max_tokens=256,
messages=[{"role": "user", "content": "Hello!"}],
)
Solwyn extends the resolved mapping returned by client.open_telemetry,
preserving every other setting. Anthropic exposes this mapping even for
Anthropic() and Anthropic(open_telemetry=None), so those clients receive
the run ID too. With Anthropic(open_telemetry=False), the copied options
receive the ID while tracing stays disabled and emits no span. Solwyn never
constructs the option from scratch or changes nested tracing settings.
A customer-configured conversation_id string is kept, including an empty
string. Anthropic drops conversation_id=None when resolving its options,
so Solwyn adds the active run ID in that case. Calls without an active run
and other dialects are left alone. Older SDKs and variants with a missing,
unreadable, or non-mapping public option are skipped without interrupting
the provider call. Sync, async, and streaming calls use the same behavior;
each Anthropic failover hop extends that client's own mapping.
The added ID is an SDK-minted run_<uuid4>; run names and tags are not added.
conversation_id stays local to the span and is not sent to Anthropic
in HTTP headers or the request body.
With otel_spans=True as well, the native anthropic.messages.create span is
a child of solwyn.call and carries gen_ai.conversation.id. The Solwyn call
span keeps its closed solwyn.* attribute allowlist and carries no gen_ai.*
attributes.
ThreadPoolExecutor
solwyn.run(...) uses Python contextvars. Context propagates across asyncio tasks, but not into ThreadPoolExecutor workers. Use solwyn.run_in_executor(...) when submitting threaded work that should keep the active run tag:
from concurrent.futures import ThreadPoolExecutor
with solwyn.run("nightly-batch"), ThreadPoolExecutor() as executor:
future = solwyn.run_in_executor(executor, call_openai, prompt)
result = future.result()
run_in_executor(...) returns the executor's concurrent.futures.Future, not an awaitable. In asyncio code, wrap it with asyncio.wrap_future(future). If you submit directly to an executor, wrap the callable with contextvars.copy_context().run(...) yourself.
Budget Enforcement
Set budget_mode to control spending:
client = Solwyn(
OpenAI(),
api_key="sk_proj_...",
budget_mode="hard_deny",
)
| Mode | Behavior |
|---|---|
alert_only |
Log a warning when budget is exceeded (default) |
hard_deny |
Raise BudgetExceededError and block the call |
Run-scoped leases
Token-billed calls inside solwyn.run(...) use budget leases by default. The first
eligible call requests a server grant; later calls reserve tokens from that grant
in memory, while renewal runs in the background at the refresh deadline or 75%
depletion. Run-scope exit and close() both surrender held leases. Non-run
traffic, non-token/media traffic, lease-ineligible runs or models, and clients
with lease_enabled=False keep using the per-call /budgets/check path.
A lease is handed back as soon as the SDK knows it has stopped using it: when the
with solwyn.run(...) block exits (or RunHandle.finish() is called), when a
renewal comes back ineligible or denied, when a lease response cannot be read,
and when the server stops the run. Release enqueue never waits for network or a
worker slot. Each budget enforcer has at most four release workers/requests
and 64 queued releases, separate from reporter and renewal workers. Releases
reuse one dedicated HTTP pool; async origin TLS initialization runs in one
daemon thread, off the application's event loop. If loop shutdown cancels the
initializer, a later loop can finish initialization using that same TLS work.
While its owning loop is alive, a native async HTTP pool must be closed there;
close on another loop is rejected before draining leases. Pending initialization
or cleanup also remains owned by its live loop. Once that loop is closed, the
SDK discards its release pool/task references and creates a fresh pool as needed,
so that pool no longer blocks a later asyncio.run(client.close()). Provider
clients retain their own loop requirements.
The same off-loop TLS result remains reusable. Close clients before ending their
loop for orderly socket teardown. HTTPX can still initialize separate TLS
contexts when connecting through an HTTPS proxy.
Identical release payloads coalesce; different holder/generation/spend identities
remain distinct. A full queue abandons the new, unsent courtesy release and
increments BudgetEnforcer.release_counts["queue_full"] (also available on
AsyncBudgetEnforcer). Queued, unsent work expires six seconds after enqueue, counted
as expired, including work parked without a running event loop. It is not
guaranteed to remain available for a later close() or process-exit flush.
Failed worker startup is counted as dispatch_failed and leaves the bounded
queue available for retry or shutdown within that deadline. Client/TLS setup
failures count as setup_failed, other local request errors as local_error,
and cancelled release workers as cancelled. These local outcomes do not
change shared control-plane breaker health; cancellation does not prove whether
the server received a request. The server reclaims leases that it did not hear
about at its own expiry. These limits add no configuration options and do not
change the wire protocol.
Work that outlives a scope with the same run id waits for its old release before
regranting. If the bounded wait expires while that request is still active, the
call uses the per-call budget check instead; the old request keeps its worker
slot and fence until it actually ends. close() waits up to its three-second
courtesy budget, expires unsent backlog, and lets outstanding work retain its
pool until completion. Late renewal authority uses the same dispatcher. The
existing process-exit fallback is a separate single synchronous sender with a
shared two-second lease budget; it may overlap already-running release workers.
Injected transports remain caller-owned throughout. Manually closing a loop
with unresolved injected-transport work does not release its request slots or
fences. An activate() scope on a
create_run() handle does not release, because a detached identity is meant to
be re-entered.
Settlement delivery remains serial and off the caller. Each reporter cycle
handles at most min(reporter_batch_size, reporter_max_queue_size) standalone
confirms and the same number of settlement confirms (50 each by default), then
services ready metadata. Its metadata stage has a bounded snapshot of batches,
so new arrivals cannot indefinitely postpone heartbeat/advisory work. Productive
cycles continue immediately; idle and retry-held queues retain their cadence.
reporter_batch_size must be at least one; configuration, environment settings,
and direct reporter constructors reject zero or negative values.
Each settlement's own confirm disposition still precedes its metadata transfer.
reporter_max_in_flight remains an event-send guard, not a parallel-confirm or
throughput setting. Queue capacities, counted overflow, retry limits and the
single reporter shutdown deadline are unchanged; bursts before the first
scheduled flush can still overflow.
Each reservation includes the input estimate plus the largest effective output
cap across the configured provider chain, including global defaults, provider
defaults, and Google/Bedrock nested cap fields. When a hop has no explicit cap,
lease_output_bound_default supplies that hop’s conservative output allowance.
Cancelling an async chat or Responses call, or interrupting a sync one with a
non-Exception BaseException (KeyboardInterrupt, SystemExit), retires its
local reservation immediately and re-raises the original exception.
If dispatch may have sent the request, the lease keeps the reserved token bound as spent;
unknown output usage is never invented or refunded into spendable authority.
The reporter sends a structural possibly_succeeded error receipt, and renewal
or surrender carries the conservative token tally. Proven pre-dispatch
cancellation and cancellation during a rejected request's Retry-After wait
return the reservation. Cancellation is neutral to provider health and frees
only that attempt's recovery probe. A streaming call whose already-open provider
stream cannot be wrapped is reconciled the same way in both clients: the bound is
kept behind a possibly_succeeded receipt, no provider-health failure is
recorded, the provider stream is closed best-effort, and the original error is
re-raised. The Responses stream() manager applies the same rule to a wrap
failure at context entry. Use stream context managers or explicit
close when abandoning an established stream so its provider connection closes.
Sync and async dispatch errors with unknown post-send usage (read timeouts,
5xx responses, or protocol drops) also retain the bound when ambiguous failover
is disabled. With failover_idempotency="always" the call fails over instead,
still on the same reservation: that reservation is pinned at its bound, so a
later served hop settles at no less than the bound and a later refusal, chain
exhaustion, or cancellation retires it rather than returning it. This
conservative debit can exceed actual usage and consume lease authority sooner
under aggressive timeouts; request-shaped refusals and provably unsent failures
(a 429, a connect error) still return unused authority.
During a control-plane outage, a live lease spends its remaining grant and then
its holder-specific headroom share. Exhausting both follows the customer’s
budget_mode: hard_deny blocks; alert_only proceeds with a warning. After a
lease expires, fail_open=True permits explicitly uncounted calls and tallies
them for the next successful renewal; fail_open=False enforces the last known
local bound. Uncounted episodes log lease.uncounted_entry immediately and
lease.uncounted_continuing at most every 30 seconds. Installing a fresh grant
ends the episode, so a later outage emits a new entry warning.
Calls that do not ride a lease (non-run traffic, media, lease_enabled=False,
or a run the server keeps on the per-call path) take the legacy per-call check.
The SDK never estimates cost, so when that check cannot reach Solwyn it has
nothing to meter against locally:
fail_open=Trueadmits the call with a warning and tallies it — one call plus its token count — in memory. The count starts as the SDK's length-based input estimate and is replaced by the provider-reported total when the call settles (a media call billed per unit, or a call that settles with zero tokens such as an abandoned stream, keeps its estimate). The tally rides the next/budgets/checkthat reaches Solwyn asuncounted_calls/uncounted_tokens. A 408, 409 or 429, a 5xx, a timeout or a lost connection keeps it for the following check, so delivery is at-least-once. A 2xx or any other 4xx clears it, because Solwyn records the tally before evaluating the check; a 4xx drop logsbudget.uncounted_report_dropped. The tally only rides/budgets/check, so if every later call is lease-funded or served from the allow cache it is never sent, and anything still unsent when the process exits is lost.fail_open=Falsefails closed: the call raisesBudgetExceededError(deny_source="local_enforcement",deny_reason="control_plane_unreachable") until Solwyn is reachable again, and the SDK logsbudget.fail_closed_unreachableat most every 30 seconds. To keep run-scoped calls metered through an outage instead, use budget leases (lease_enabled=True, the default) insidesolwyn.run(...).
A prior hard deny, or a stop retained for the run, still wins over both postures during an outage.
The global allow cache applies only to eligible legacy/non-run checks; it never authorizes one run from another run’s state. Cloud usage reporting remains asynchronous, so legacy cached work and the reporter flush interval can still delay dashboard visibility.
from solwyn import BudgetExceededError
try:
response = client.chat.completions.create(model="gpt-5.5", messages=[...])
except BudgetExceededError as e:
print(f"Budget limit: ${e.budget_limit}, usage: ${e.current_usage}")
Testing your budget enforcement
FakeControlPlane exercises the production control-plane transport seam with
zero network traffic and the same request, response, and Pydantic wire models as
Solwyn Cloud. You create and own the plane, wrap your provider client with it,
and inspect its request recordings after the call. The provider remains your
responsibility: mock it normally whenever the test can reach provider dispatch.
Closing the wrapper shuts down Solwyn's control-plane resources and then
forwards to the wrapped provider client's own close seam — the recipes below
nest both context managers and verify that forwarding.
The double never prices anything — the API owns pricing. Scripted denials test your handling, not your budget math.
1. Test a deny handler
The magic denial happens during preflight, so no provider request can occur. The wrapper context manager closes the client even if the assertion fails.
import pytest
from openai import OpenAI
from solwyn import BudgetExceededError
from solwyn.testing import FakeControlPlane
def test_deny_handler():
plane = FakeControlPlane()
with OpenAI(api_key="test") as provider:
with plane.wrap(provider) as client:
with pytest.raises(BudgetExceededError):
client.chat.completions.create(model="solwyn-test/deny", messages=[])
# Closing the wrapper forwards to the provider client's close seam.
assert provider.is_closed()
Magic models are reserved, deterministic verdict scripts:
| Model | Scripted control-plane behavior |
|---|---|
solwyn-test/deny |
Monthly denial using the plane's configured mode (hard_deny by default) |
solwyn-test/deny-alert |
Monthly denial that forces alert_only on per-call checks, regardless of configured mode; lease-path denials are always hard_deny |
solwyn-test/deny-tag |
tag-period denial using the plane's configured mode (hard_deny by default) on the check path; the lease path treats tag-scoped rules as lease ineligibility instead of a denial |
solwyn-test/deny-stopped |
Always a hard_deny run_stopped denial with zero remaining budget — dashboard stops override alert-only projects and raise RunStoppedError through the wrapper. Requires an active solwyn.run(...) scope |
solwyn-test/runaway |
First check per run is allowed; later agent_run denials use the plane's configured mode (hard_deny by default). Requires an active solwyn.run(...) scope |
solwyn-test/kill |
First check per run is allowed; every later check, lease grant, and renewal for that run is a hard_deny run_stopped denial carrying a version 1 run_control terminate directive, exactly as plane.stop_run(run_id) does. Requires an active solwyn.run(...) scope |
solwyn-test/lease-ineligible |
Allow the call but make its run ineligible for a token lease |
For overlapping scripts, precedence is transport failure → endpoint refusal → verdict → allow. An outage therefore tests unreachable posture without a scripted denial leaking through, while a reachable endpoint refusal wins over the normal verdict.
2. Test fail-open posture
Mock the provider separately with the HTTP dialect used by the installed OpenAI SDK, and assert both that dispatch proceeded and that the control-plane warning surfaced.
import logging
from openai import OpenAI
from solwyn.testing import FakeControlPlane
try:
import httpx2 as provider_httpx
except ImportError:
import httpx as provider_httpx
def test_fail_open_provider_proceeds(caplog):
plane = FakeControlPlane()
provider_requests = []
def handle_provider(request):
provider_requests.append(request)
return provider_httpx.Response(
200,
json={
"id": "chatcmpl-test", "object": "chat.completion", "created": 0,
"model": "gpt-5.5", "choices": [{"index": 0,
"message": {"role": "assistant", "content": "served"},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 2, "completion_tokens": 1, "total_tokens": 3},
},
)
transport = provider_httpx.MockTransport(handle_provider)
with provider_httpx.Client(transport=transport) as provider_http_client:
with OpenAI(
base_url="https://provider.test/v1",
api_key="test",
http_client=provider_http_client,
) as provider:
with (
plane.wrap(provider, fail_open=True, lease_enabled=False) as client,
caplog.at_level(logging.WARNING),
plane.outage(),
):
response = client.chat.completions.create(model="gpt-5.5", messages=[])
# Closing the wrapper forwards to the provider client's close seam.
assert provider.is_closed()
assert len(provider_requests) == 1
assert response.choices[0].message.content == "served"
assert "budget check failed" in caplog.text.lower()
3. Run a deny → outage → recovery game day
Compose scenarios on one caller-owned plane to prove that a known hard denial is preserved during an outage and cleared only by a recovered allow verdict.
import pytest
from openai import OpenAI
from solwyn import BudgetExceededError
from solwyn.testing import FakeControlPlane
try:
import httpx2 as provider_httpx
except ImportError:
import httpx as provider_httpx
def test_deny_outage_recovery():
plane = FakeControlPlane()
provider_requests = []
def handle_provider(request):
provider_requests.append(request)
return provider_httpx.Response(
200,
json={
"id": "chatcmpl-test", "object": "chat.completion", "created": 0,
"model": "gpt-5.5", "choices": [{"index": 0,
"message": {"role": "assistant", "content": "served"},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 2, "completion_tokens": 1, "total_tokens": 3},
},
)
transport = provider_httpx.MockTransport(handle_provider)
with provider_httpx.Client(transport=transport) as provider_http_client:
with OpenAI(
base_url="https://provider.test/v1",
api_key="test",
http_client=provider_http_client,
) as provider:
with plane.wrap(provider, fail_open=True, lease_enabled=False) as client:
with pytest.raises(BudgetExceededError):
client.chat.completions.create(model="solwyn-test/deny", messages=[])
with plane.outage(), pytest.raises(BudgetExceededError):
client.chat.completions.create(model="gpt-5.5", messages=[])
recovered = client.chat.completions.create(model="gpt-5.5", messages=[])
# Closing the wrapper forwards to the provider client's close seam.
assert provider.is_closed()
assert len(provider_requests) == 1
assert recovered.choices[0].message.content == "served"
See the in-repo
test_gameday_recipes.py
for the full refusal, breaker, reporter, lease-drawdown, and recovery ladder.
4. Simulating an operator kill
plane.stop_run(run_id) is the dashboard kill switch: from the next request on,
every check, lease grant, and lease renewal naming that run is denied and the
wrapper raises RunStoppedError — which is not a BudgetExceededError, so a
budget-denial handler cannot swallow it. The stop stays terminal through a
control-plane outage, and plane.clear_stop(run_id) lifts it.
import pytest
import solwyn
from openai import OpenAI
from solwyn import RunStoppedError
from solwyn.testing import FakeControlPlane
def test_operator_kill_stops_the_run():
plane = FakeControlPlane()
with OpenAI(api_key="test") as provider:
with plane.wrap(provider) as client, solwyn.run("nightly-report") as run_id:
plane.stop_run(run_id, reason="operator_stop")
with pytest.raises(RunStoppedError) as stopped:
client.chat.completions.create(model="gpt-5.5", messages=[])
assert stopped.value.agent_run_id == run_id
assert stopped.value.reason == "operator_stop"
assert [receipt.deny_reason for receipt in plane.denial_receipts] == ["operator_stop"]
plane.denial_receipts holds the content-free evidence the SDK reported for
every call it refused — who denied it (deny_source), why (deny_reason), and
under which period — with plane.aggregate_replays holding the folded
aggregates the SDK replays after an ingest rejection.
Opt-in pytest fixtures
Fixtures never auto-register. Enable them in the test module (or your own
conftest.py) and request both fixtures when you want to script and inspect the
same plane. solwyn_test_client is the normal Solwyn wrapper around a private
denial-only dispatch sentinel; it does not simulate provider responses.
import pytest
from solwyn import BudgetExceededError
pytest_plugins = ["solwyn.testing.pytest_plugin"]
def test_denial_fixture(solwyn_control_plane, solwyn_test_client):
with pytest.raises(BudgetExceededError):
solwyn_test_client.chat.completions.create(
model="solwyn-test/deny", messages=[]
)
assert len(solwyn_control_plane.checks) == 1
Configuration
| Parameter | Env Var | Default | Description |
|---|---|---|---|
api_key |
SOLWYN_API_KEY |
required | Solwyn project API key |
api_url |
SOLWYN_API_URL |
https://api.solwyn.ai |
Solwyn API endpoint |
tags |
SOLWYN_TAGS |
None |
Default spend tags for intercepted calls; env format is comma-separated key=value entries |
fail_open |
SOLWYN_FAIL_OPEN |
True |
When Solwyn API is unreachable: True allows calls and tallies their tokens for the next successful check; False denies calls that no live budget lease covers |
budget_mode |
SOLWYN_BUDGET_MODE |
alert_only |
Budget enforcement mode |
budget_check_cache_ttl |
SOLWYN_BUDGET_CHECK_CACHE_TTL |
5 |
Allow-cache lifetime for eligible legacy/non-run checks |
budget_check_timeout |
SOLWYN_BUDGET_CHECK_TIMEOUT |
1.0 |
Hot-path control-plane check/grant timeout in seconds |
lease_enabled |
SOLWYN_LEASE_ENABLED |
True |
Use in-memory token leases for eligible run-scoped calls |
lease_output_bound_default |
SOLWYN_LEASE_OUTPUT_BOUND_DEFAULT |
4096 |
Output-token allowance when no configured provider hop has an explicit cap |
velocity_mode |
SOLWYN_VELOCITY_MODE |
warn |
Content-free run velocity posture: off, advisory warn, or local deny |
velocity_repeat_count |
SOLWYN_VELOCITY_REPEAT_COUNT |
5 |
Near-identical same-model calls required inside the repeat window |
velocity_repeat_window_s |
SOLWYN_VELOCITY_REPEAT_WINDOW_S |
60.0 |
Seconds retained for repeat-size matching |
velocity_growth_streak |
SOLWYN_VELOCITY_GROWTH_STREAK |
8 |
Strictly increasing calls required for monotonic-growth detection |
velocity_growth_factor |
SOLWYN_VELOCITY_GROWTH_FACTOR |
3.0 |
Required last/first input-size ratio for monotonic growth |
velocity_accel_floor_per_min |
SOLWYN_VELOCITY_ACCEL_FLOOR_PER_MIN |
30 |
Minimum current-window call count for rate acceleration (maximum 64) |
velocity_accel_factor |
SOLWYN_VELOCITY_ACCEL_FACTOR |
3.0 |
Required current/prior one-minute call-count ratio |
on_unmetered |
SOLWYN_ON_UNMETERED |
warn |
Handle untracked or unknown pre-call capabilities with warn, raise, or allow |
report_untracked_surfaces |
SOLWYN_REPORT_UNTRACKED_SURFACES |
True |
Send optional structural advisory reports for unacknowledged warn/allow observations; set false to keep them local |
trace_correlation |
SOLWYN_TRACE_CORRELATION |
True |
Include active OpenTelemetry trace identifiers in metadata events when available; set false to disable capture |
otel_spans |
SOLWYN_OTEL_SPANS |
False |
Emit content-free solwyn.call and solwyn.run spans through your application's OpenTelemetry tracing |
anthropic_conversation_id |
SOLWYN_ANTHROPIC_CONVERSATION_ID |
False |
Extend existing Anthropic telemetry options with the active run ID as a local span attribute |
acknowledge_untracked |
SOLWYN_ACKNOWLEDGE_UNTRACKED |
empty | Exact terminal capability tokens; env format is comma-delimited |
control_plane_failure_threshold |
SOLWYN_CONTROL_PLANE_FAILURE_THRESHOLD |
3 |
Consecutive Solwyn API failures before local outage posture applies |
control_plane_recovery_timeout |
SOLWYN_CONTROL_PLANE_RECOVERY_TIMEOUT |
30.0 |
Seconds before probing the Solwyn API after its breaker opens |
Velocity detection retains only scalar token counts, monotonic timestamps, and
structural run/model identifiers—never prompts or responses. repeat_size and
monotonic_growth are eligible to stop a run in deny mode;
rate_acceleration is advisory only. Scalar history is fixed at 128 runs × 64
observations, with fixed-memory conservative suppression under extreme identity
churn so losing an exact identifier can suppress a signal but can never invent one.
Failover and routing (model=, fallback=, provider=, default_params=, selection_policy=, and the failover tuning knobs) are configured in code only — they take client objects and policies, not strings. See Provider Failover and Configuration.
CostPolicy orders eligible providers by health and then by server-provided
relative price hints for that request; the SDK never computes prices locally.
Every budget check opts into server hints with price_hints_version: "1".
A populated hint mapping can place a cheaper healthy provider ahead of a
healthy primary; when that provider serves, failover metadata reports
cost_routed. An empty mapping ({}) means the server explicitly supplied no
hints, while null means the check carried no hints and triggers the one-time
no-hints warning. The allow-cache is a bounded 16-entry LRU keyed by provider,
model, fallback chain, and modality, with the existing budget_check_cache_ttl
window (default 5 s); a cache hit replays only its own entry's hints.
Lease-backed solwyn.run() calls carry no hints, so CostPolicy keeps
configured order there until lease grants carry hints.
Use env vars to avoid passing credentials in code:
export SOLWYN_API_KEY="sk_proj_..."
client = Solwyn(OpenAI()) # picks up from environment
Failover timeouts
Failover is bounded by two independent timeouts. Both are constructor-only (no SOLWYN_* env var) and server-governed: on a plan without the failover-tuning entitlement a custom value is replaced by the SDK default, warned once per client.
| Knob | Default | What it bounds |
|---|---|---|
failover_total_timeout |
30.0 |
The failover window — the budget pre-flight, each hop's connect/pool slice, Retry-After sleeps, and advancement between hops |
failover_hop_read_timeout |
600.0 |
The per-hop read/write bound — how long one dispatched hop may spend reading a response |
The failover window deliberately does not cap a dispatched hop's read. A pre-send hang (connect, pool wait) is provably failover-safe, so it must fail inside the window; a read timeout is post-send ambiguous — the request may already have been served and billed — and under the default failover_idempotency="safe" it re-raises instead of failing over. Cutting a slow read at the failover window therefore buys no failover, only ambiguous spend.
600.0 matches the openai/anthropic SDK's read/write default, so a wrapped call's read/write bound never fires earlier than the unwrapped SDK's would — connect/pool instead track the shrinking failover window. Because window expiry gates advancement between hops, at most one hop per call can consume the full read bound: worst-case wall clock is roughly one failover window plus one failover_hop_read_timeout.
Lower failover_hop_read_timeout if you would rather fail fast than wait out a slow generation (reasoning models, large max_tokens) — remembering that the fast failure is an ambiguous re-raise, not a failover:
client = Solwyn(OpenAI(), api_key="sk_proj_...", failover_hop_read_timeout=120.0)
google-genai limitation. google-genai supports only a single whole-request timeout — it cannot split connect from read (a client-level httpx.Timeout via client_args is overridden per-request by the SDK itself). Solwyn therefore gives a google hop the read bound (failover_hop_read_timeout, default 600s) as its whole-request timeout. Consequence: a google pre-send hang (TCP/TLS connect, pool wait) is not bounded by failover_total_timeout — a single hung google hop can block up to the read bound and exhaust the failover window without ever failing over. (OS TCP timeouts typically cap a dead-host connect at ~1–2 minutes.) If you run google as primary with fallbacks and want a tighter failover guarantee, lower failover_hop_read_timeout — for google it bounds the entire request.
Bedrock limitation. boto3 has no per-call timeout override, so Solwyn cannot bound a Bedrock hop at all — the caller's botocore Config(read_timeout=...) governs. Building a Bedrock client whose botocore Config sets read_timeout=None logs a warning at build time: that is the one shape neither Solwyn nor botocore will bound.
Error Handling
All SDK errors inherit from SolwynError:
| Exception | Raised when |
|---|---|
BudgetExceededError |
Cloud denies a budget check in hard_deny mode, or Cloud is unreachable and fail_open=False (deny_reason="control_plane_unreachable"). Carries estimated_input_tokens; estimated_cost is None because the SDK never estimates cost |
RunStoppedError |
A server/operator stop or a deny-eligible local velocity rule prevents provider dispatch for an agent run |
ProviderUnavailableError |
Circuit breaker is open, or the failover chain is exhausted |
ConfigurationError |
Invalid API key format, invalid provider= pin/client pairing, or an untracked call surface (e.g. Bedrock invoke_model) |
UntrackedSpendSurfaceError |
Strict coverage posture refuses an unacknowledged untracked or unknown capability before provider I/O |
UnsupportedSurfaceError |
The selected provider adapter does not support an explicit Solwyn wrapper surface |
UntranslatableRequestError |
A cross-provider failover hop cannot represent the request (structural labels only — never content) |
UntranslatableModelError |
No model mapping exists for a cross-provider failover hop |
RunStoppedError inherits directly from SolwynError, not BudgetExceededError.
This deliberate separation prevents an agent loop's except BudgetExceededError
handler from swallowing and retrying an explicit stop. The exception carries the
structural agent_run_id, reason, and source; server/operator stops use the
server source and eligible local velocity_mode="deny" decisions use
local_velocity. Exact first-writer reasons and structural run IDs live only in
a 256-entry LRU. The registry never guesses from fingerprints, so churn cannot
false-stop an unrelated or new run. Active stream handles retain their immutable
first stop independently of registry eviction until the stream settles, closes,
or is abandoned. A stopped run with no active stream handle may be forgotten
after LRU eviction; a later call can proceed if the control plane is unavailable
or does not reaffirm the stop. This is the unavoidable tradeoff between exact
answers and fixed memory under unbounded identity churn.
Cooperative run code can call current_run_terminated() for the ambient run,
inspect run_termination(run_id), or explicitly clear stored stop state with
clear_run_termination(run_id). run_termination returns an immutable
RunTermination (reason, source, and at_monotonic) or None. A non-streaming
request already in flight is not preempted. A metered stream already returned is
stopped cooperatively at its next raw provider-chunk boundary: that chunk is
pulled and discarded, previously observed usage is settled exactly once as a
partial success, the provider stream is closed, and the original
RunStoppedError remains terminal for later iterator calls.
Provider errors (e.g., openai.RateLimitError) pass through unmodified.
Data Transparency
The SDK sends a MetadataEvent after each LLM call. This is everything it transmits:
| Field | Type | Description |
|---|---|---|
model |
str |
Model name (e.g., gpt-5.5) |
provider |
str |
Provider identifier (openai, anthropic, google, bedrock, groq, openrouter, …) |
modality |
str |
Call modality (text, image, audio, video, embedding); text for chat, embedding for embeddings calls |
input_tokens |
int |
Input token count |
output_tokens |
int |
Output token count |
token_details |
object |
Breakdown: cached, reasoning, audio, and image token buckets; is_estimated flags length-based estimates when a provider reports no usage. Anthropic server-side compaction adds compaction_input_tokens, compaction_cached_input_tokens, compaction_cache_creation_5m_tokens, compaction_cache_creation_1h_tokens, and compaction_output_tokens: token counts already included in the matching totals, each omitted when zero |
media_usage |
object | None |
Non-token billable quantities for media calls — image counts, media durations in seconds, TTS character counts — plus resolution/quality variant selectors. Each quantity is None when the SDK can't observe it (never a zero-as-default), and the whole object is omitted for text/chat calls |
latency_ms |
float |
Call duration in milliseconds |
status |
str |
success, error, or budget_denied |
is_model_fallback |
bool |
Whether the call was served by a same-provider entry in the fallback= chain after the primary model failed |
sdk_instance_id |
str |
Per-process UUID for deduplication |
timestamp |
datetime |
When the call completed (UTC) |
agent_run_id |
str | None |
Run id from the active solwyn.run(...) scope, if any. When omitted, the API creates _auto-{sdk_instance_id}-{YYYY-MM-DD} |
agent_run_name |
str | None |
Run name passed to solwyn.run(...), if any |
lease_id |
str | None |
Id of the budget lease that funded the call: an opaque server-minted token, the same id the call's confirmation carries. Present only on lease-funded calls; omitted for reservation-funded, cached, fail-open, uncounted, and denied calls |
provider_region |
str | None |
Cloud region of the serving endpoint (Bedrock — pricing is per model and region); omitted for other providers |
trace_id |
str | None |
Random OpenTelemetry trace identifier active when the call starts (32 lowercase hex characters) |
span_id |
str | None |
Random OpenTelemetry span identifier at call start (16 lowercase hex characters): the caller's span by default, or the recording, sampled solwyn.call span when enabled |
trace_sampled |
bool | None |
Whether that trace was sampled; an unsampled trace may be absent from your tracing backend |
tags |
object | None |
Optional explicit customer-supplied tags from solwyn.run(..., tags=...) and solwyn_tags=. Never inferred from prompts or responses; omitted when empty or unset |
deny_source |
str | None |
Structural denial source (server, sticky/local enforcement sources, or aggregate_replay) |
deny_reason |
str | None |
Bounded structural reason for a denied call; never derived from content |
denied_by_period |
str | None |
Budget period that denied the call, when supplied by the control plane |
estimated_output_bound |
int | None |
Output-token bound used for the denied pre-flight |
velocity_flags |
list[str] | None |
Content-free v1 rule names: repeat_size, monotonic_growth, and rate_acceleration |
receipt_aggregate_count |
int | None |
Number of denied receipts represented by a content-free aggregate replay |
receipt_pricing_input_tokens |
int | None |
Original per-call input-token count used to select the pricing card for a homogeneous aggregate replay; omitted for ordinary and legacy events |
Optional fields are omitted entirely when unset (never null), so payloads for
providers that do not use them are byte-identical to earlier releases.
Trace correlation captures only these two random identifiers and the sampled
flag, never content. With Solwyn spans disabled (the default), it requires your
application's configured tracer provider and an active, valid OpenTelemetry
span; solwyn[otel] is only a convenience for installing the optional API.
Solwyn's own code never injects trace context, baggage, or tracestate. Your application's httpx
auto-instrumentation may still add traceparent or baggage to Solwyn's
control-plane requests, including budget checks in the caller's context.
Disable capture with
Solwyn(client, trace_correlation=False) (or AsyncSolwyn) or
SOLWYN_TRACE_CORRELATION=false. Unset correlation fields are omitted entirely
from payloads. Aggregated denial replays carry no trace because they span calls.
Optional call and run spans are enabled with Solwyn(client, otel_spans=True) (or
AsyncSolwyn) or SOLWYN_OTEL_SPANS=true. Configure your application's
OpenTelemetry tracer provider and exporter first; installing solwyn[otel]
alone does not export spans. Cold import solwyn never imports OpenTelemetry.
solwyn.run(...) opens an INTERNAL span named literally solwyn.run at scope
entry and keeps it current in that context. Application spans and
solwyn.call spans nest beneath it; nested runs carry the parent run ID and
follow the current tracing parent. In fresh-context generator steps, calls and
nested runs use the recovered run span unless a valid current application span
differs from the tracing parent captured at run entry; that application span
takes precedence, including spans opened inside the step. The span ends once,
synchronously on normal sync or async exit and on explicit handle finish,
including finish callbacks inside generators. Generator finalization,
garbage collection, and foreign-context exits hand cleanup to a background
worker with the end timestamp recorded at exit; client close and interpreter
exit drain pending cleanup, with a bounded wait for span export. The status is
ERROR when an exception or interruption leaves the scope and OK otherwise.
Run spans contain only
solwyn.agent_run_id and, for child runs, solwyn.parent_agent_run_id.
Reusing a solwyn.run(...) scope object creates an independent run for each
entry, including concurrent generators stepped in fresh contexts. Use detached
run handles to share a run identity across tasks or threads.
Run scopes are independent of clients. Run-span opt-in is process-wide and
sticky: the first wrapper constructed with otel_spans=True, or a truthy
SOLWYN_OTEL_SPANS observed at run scope entry or handle creation, enables run
spans for the rest of the process. Closing or collecting wrappers, clearing the
environment flag, or constructing an opted-out wrapper does not reset it. Opt in
before entering the first scope you want traced. Forked children inherit the
opt-in. Each client's call-span flag remains independent; a client with
otel_spans=False can still make calls inside a traced run.
start_run(...) opens the same current run span until handle.finish().
create_run(...) creates one detached span, with its tracing parent captured
at creation, and leaves the current span unchanged. Every handle.activate()
attaches that same span with a fresh context token, restoring the previous
span on exit without ending the run. This works across tasks and threads,
including overlapping activations. After all activations exit, finish() ends
the span once; an exception leaving any activation marks the final status
ERROR. An unfinished handle collected by GC also ends its span with ERROR;
this tracing cleanup does not finish attribution or release leases. LangChain
and CrewAI integrations inherit these spans through their existing detached
run handles. Their error callbacks finish runs outside an activation, so those
runs export status OK unless an exception also left an activation. A
start_run handle dropped without finish() gets its span ended by GC, but its
run and ended span remain current in the creating context. Finish handles
explicitly for deterministic completion and context cleanup.
Each intercepted chat or media call creates one solwyn.call span of kind
INTERNAL, parented under the current run or application span, or as a root when
there is no parent. Instrumented budget preflight requests and native provider
SDK spans are children of solwyn.call, and all failover attempts share that
call span. It is made current only during the budget check and provider
dispatch, including with_options(...) and deferred Responses manager entry,
and is never current while application code iterates a returned stream.
Background confirmations and reporting run in an isolated context. Streams
keep the span open until completion, explicit close, failure, or garbage
collection.
The closed call-span attribute allowlist is:
solwyn.call_id,solwyn.agent_run_id,solwyn.parent_agent_run_idsolwyn.budget.allowed,solwyn.budget.mode,solwyn.deny_source,solwyn.denied_by_periodsolwyn.provider.requested,solwyn.provider.served,solwyn.model.requested,solwyn.model.servedsolwyn.failover.reason,solwyn.attempt_index,solwyn.lease_funded
Attributes appear when their values are known. Served provider/model and attempt
index describe the final attempted hop, including a hop that fails. Completed
calls and explicitly closed streams have status OK; denials, run stops,
provider errors, interruptions, and garbage-collected abandonment have status
ERROR. A Responses manager closed before entry also ends with ERROR.
Solwyn call and run spans never carry cost, token usage, gen_ai.* attributes,
run names, tags, prompt/response content, exception messages, or events. Native provider
gen_ai spans remain the generation/usage signal, avoiding duplicate usage or
cost accounting in tools such as Langfuse.
With correlation enabled, metadata captures the current trace once at call start:
the solwyn.call span when enabled, or the run/application span otherwise.
Solwyn adopts a new call or run span only when it has a valid context and is
both recording and sampled. Otherwise it keeps the caller snapshot and provider
parentage used with spans off. Solwyn never makes an unexported child ID current.
The trace ID is unchanged
when there is an existing caller trace. trace_correlation=False independently
disables metadata identifiers while leaving opted-in span export enabled.
Token, output-bound, aggregate-count, pricing-basis, and non-token media quantities are each capped at 100,000,000 per event. Larger folded receipt totals replay as multiple pricing-compatible events without changing their exact totals or turning an unknown media quantity into zero.
The SDK never captures, logs, or transmits prompts or responses. Explicit customer-supplied tags are outside this zero-content guarantee and are transmitted as provided. Prompt and response privacy is enforced by structural tests and the privacy module.
Requirements
Python 3.11+
Contributing
make install # install in dev mode
make install-hooks # install pre-commit hook
make check # lint + format + typecheck
make test # run unit tests
Links
- Documentation
- Solwyn Cloud — Dashboard, alerts, and analytics
- MPI.sh — LLM API pricing comparison
License
Apache 2.0 — see LICENSE for details.
Metadata
Release files for solwyn 0.9.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| solwyn-0.9.0.tar.gz | 1.8 MB | With provenance | Details for solwyn-0.9.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| solwyn-0.9.0-py3-none-any.whl | Python 3 | none | any | Details for solwyn-0.9.0-py3-none-any.whl |
Total release size: 2.5 MB
Release files / solwyn-0.9.0.tar.gz
| Download URL | solwyn-0.9.0.tar.gz |
|---|---|
| Size | 1.8 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
0135da8ba6fd727a653edf66b5186b3dc34b909dbc04ae2bddea0b9c85494f96
|
|
BLAKE2b-256 checksum How to use checksums |
f4b28f208ec05cc73c0dc07b89ae462ec1ab5df597cc97b4f04e3fcba139657c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.13
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 28, 2026.
Transparency logRelease files / solwyn-0.9.0-py3-none-any.whl
| Download URL | solwyn-0.9.0-py3-none-any.whl |
|---|---|
| Size | 703.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d6e48ebe1f4743836795d4841c8ac5ddf85b90021c1ffcfa0245922fda4273d5
|
|
BLAKE2b-256 checksum How to use checksums |
f6b097a30eacac44935585f70b7a4cd238106c8d32a99ec083668fda6dcc59cd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.13
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 28, 2026.
Transparency logSecurity for solwyn 0.9.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for solwyn-0.9.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 28, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.