Solwyn
Reference for the Solwyn synchronous client wrapper
import os
from openai import OpenAI
from solwyn import Solwyn
with Solwyn(
OpenAI(),
api_key=os.environ["SOLWYN_API_KEY"],
) as client:
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello!"}],
)
print(response.choices[0].message.content)Synchronous client wrapper. Wraps an OpenAI, Anthropic, Google Gemini, or Amazon Bedrock (bedrock-runtime) client — or an openai.OpenAI client pointed at any OpenAI-compatible endpoint via base_url — with budget enforcement, circuit breaking, and metadata reporting.
Constructor
Solwyn(
client,
*,
api_key: str | None = None,
model: str | None = None,
provider: str | None = None,
fallback: list[tuple] | None = None,
default_params: dict | None = None,
tags: Mapping[str, str] | None = None,
on_unmetered: str | None = None, # "warn" | "raise" | "allow"
acknowledge_untracked: Collection[str] | None = None,
selection_policy: SelectionPolicy | None = None,
control_plane_transport: httpx.BaseTransport | None = None,
**config_kwargs,
)Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
client | openai.OpenAI | anthropic.Anthropic | genai.Client | boto3 bedrock-runtime client | Yes | The LLM provider client to wrap. Provider is auto-detected — including OpenAI-compatible endpoints via the client's base_url. Becomes the primary entry in the failover chain. Passing an already-wrapped client raises ConfigurationError(field="client"). |
api_key | str | None | Yes (kwarg or env) | Solwyn API key. Must match sk_proj_<64 lowercase hex chars>. Falls back to SOLWYN_API_KEY if omitted. |
model | str | None | No | Model for the primary entry. Optional for single-provider use (the per-call model wins); set it when configuring a fallback chain. |
provider | str | None | No | An explicit provider identity for the primary client. SDK v0.6.0+: a pin bypasses type and base_url auto-detection entirely and selects the named adapter — the way to keep native OpenAI metering, including the Responses API, behind a corporate gateway, or to name a local server on a non-default port. It does not translate dialects, rewrite base_url, or synthesize a different client. An unknown name raises ConfigurationError(field="provider"); a pin whose client family or sync/async mode does not match raises ConfigurationError(field="client"). |
fallback | list[tuple] | None | No | Failover chain entries: (client, model), (client, model, default_params), or (client, model, default_params, provider) tuples, in attempt order. See Provider Failover. |
default_params | dict | None | No | Global fill-absent request params applied to every entry (per-entry default_params wins). |
tags | Mapping[str, str] | None | No | SDK v0.5.0+. Default spend tags applied to every intercepted call from this client — the lowest-precedence layer under run-scope and per-call tags. Falls back to SOLWYN_TAGS. Invalid mappings raise ConfigurationError(field="tags"). A tagged call always takes a live budget check. See Tags. |
on_unmetered | "warn" | "raise" | "allow" | No | SDK v0.6.0+. What happens when your code reaches a provider capability Solwyn does not meter. Default "warn"; falls back to SOLWYN_ON_UNMETERED. See Coverage controls. |
acknowledge_untracked | Collection[str] | None | No | SDK v0.6.0+. Exact capability tokens to exempt from on_unmetered. Validated against the wrapped client at construction; an invalid token raises ConfigurationError(field="acknowledge_untracked"). Falls back to the comma-delimited SOLWYN_ACKNOWLEDGE_UNTRACKED. |
selection_policy | SelectionPolicy | None | No | Candidate ordering policy. Defaults to HealthBasedPolicy; LatencyPolicy and CostPolicy are also available. Since 0.6.0 CostPolicy is driven by per-call server price hints. See Selection policies. |
control_plane_transport | httpx.BaseTransport | None | No | SDK v0.6.0+. A transport for all Solwyn Cloud traffic, shared by budget checks, reporting, exit delivery, and fork recovery. You own it; the SDK never closes it. The seam solwyn.testing.FakeControlPlane uses — see Testing. |
**config_kwargs | No | Additional configuration options. See SolwynConfig. |
Everything after client is keyword-only. If SOLWYN_API_KEY is present in the environment, you can omit api_key:
# With SOLWYN_API_KEY set in the environment:
client = Solwyn(OpenAI())ConfigurationError is raised if neither the kwarg nor the corresponding environment variable is set, or if the format is invalid.
Raises
ConfigurationError-- at construction: invalid API key format, malformedfallbackspec (field="fallback_specs"), unknown config field, an unknownprovider=name (field="provider"), a pin or wrapped client whose family or sync/async mode does not match (field="client"), invalidtagsorSOLWYN_TAGS(field="tags"), an invalid acknowledgment token (field="acknowledge_untracked"), or a non-finite failover timeout (fieldnames the timeout). At call time: the Bedrockinvoke_model/invoke_model_with_response_stream/start_async_invokeguards (fieldis the method name), and a Responses call withbackground=True, a streamingparse, or a metering-criticalextra_bodyoverride (field="background"/"stream"/"extra_body").UntranslatableRequestError-- (at call time) a cross-provider failover hop cannot translate the request shape.UntranslatableModelError-- (at call time) a fallback entry has no model configured for its provider.UntrackedSpendSurfaceError-- (at attribute access or call time,on_unmetered="raise"only) an untracked capability was reached.UnsupportedSurfaceError-- (at call time) the selected adapter serves no seam for a wrapper surface.RunStoppedError-- (at call time, inside asolwyn.run(...)scope) the run was stopped by an operator or by local velocity detection.
Example
import os
from anthropic import Anthropic
from openai import OpenAI
from solwyn import Solwyn
client = Solwyn(
OpenAI(),
model="gpt-4o",
api_key=os.environ["SOLWYN_API_KEY"],
budget_mode="hard_deny",
fallback=[(Anthropic(), "claude-sonnet-4-5", {"max_tokens": 1024})],
tags={"environment": "production"},
)Methods
close()
client.close() -> NoneShuts down the metadata reporter and closes HTTP connections. Always call close() when you are done with the client, or use a context manager.
Calling close() flushes any pending metadata events to Solwyn Cloud before shutting down.
Changed in 0.4.0. close() is bounded by a single wall-clock deadline shared across the worker join, the final flush, and the last breaker-report cycle. Against an unreachable Solwyn it no longer pays a serial timeout per queued item. Work still undelivered at the deadline is counted and dropped rather than requeued — see Undeliverable spend. Denial receipts are the exception: they fold into an aggregate and are replayed rather than dropped.
The deadline is configured on the client, not passed to close():
client = Solwyn(OpenAI(), api_key="sk_proj_...", reporter_shutdown_deadline=30.0)
...
client.close()close() also surrenders any held budget leases on a short best-effort deadline, so Solwyn can re-lend the float immediately, and forwards to the wrapped provider client's own close.
A process that exits without calling close() gets a bounded flush and lease surrender from an exit hook. That is a safety net, not a substitute.
close() is the only method Solwyn adds. The run APIs — solwyn.run(...), solwyn.start_run(...), solwyn.create_run(...) and its RunHandle, solwyn.current_run_context() — are module-level functions on the solwyn package, not client methods; their signatures are in the API summary on Agent runs.
Context manager
Solwyn supports use as a context manager via with:
import os
from openai import OpenAI
from solwyn import Solwyn
with Solwyn(
OpenAI(),
api_key=os.environ["SOLWYN_API_KEY"],
) as client:
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello!"}],
)
# close() is called automatically on exitType transparency
SDK v0.6.0+. The wrapper passes the type checks frameworks apply to a provider client:
from openai import OpenAI
from solwyn import Solwyn
client = Solwyn(OpenAI(), api_key="sk_proj_...")
assert isinstance(client, OpenAI) # admitted wherever an OpenAI client is expected
assert isinstance(client, Solwyn)
assert client.__class__ is OpenAI # reports the wrapped class
assert type(client) is Solwyn # type() stays truthful
repr(client) # "Solwyn(<OpenAI ...>)"__class__ is a read-only property returning the wrapped client's class, so isinstance succeeds against the provider class and its bases, and a Pydantic field typed as the provider class (with arbitrary_types_allowed) accepts the wrapper. type(client) remains Solwyn, and issubclass(type(client), OpenAI) is False — only isinstance consults __class__. Wrapping a wrapper raises ConfigurationError (field="client", message mentions "already wrapped").
Attribute writes and deletes forward to the wrapped client: client.timeout = 5.0 sets the provider's timeout, and client.chat = replacement replaces the provider's chat resource while reads still return Solwyn's metering proxy. Solwyn's own state lives under _solwyn_* names, which never forward in either direction and are never read from the provider client. dir(client) is the union of both.
copy.copy(client) and copy.deepcopy(client) return the same wrapper, because it holds live reporter, budget, and lease state. pickle raises TypeError ("Solwyn clients hold live reporter/budget state and cannot be pickled; construct a fresh Solwyn(...) in the target process"). Forked children keep working — see Fork safety.
Breaking for consumers of private attributes only: the former _client, _budget, and _reporter wrapper attributes no longer exist; Solwyn-owned state is _solwyn_client, _solwyn_budget, _solwyn_reporter, and so on, and every non-prefixed private name now belongs to the provider client. No public API changed. There is no public accessor for the reporter; the supported way to observe dropped spend is the reporter.spend_events_dropped log line.
Call surfaces
The call surface depends on the wrapped provider client:
OpenAI
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello!"}],
)
# SDK v0.6.0+: the Responses API is metered too
response = client.responses.create(model="gpt-4o", input="Hello!")
parsed = client.responses.parse(model="gpt-4o", input="Hello!", text_format=MySchema)
with client.responses.stream(model="gpt-4o", input="Hello!") as stream:
for event in stream:
...client.responses is a metering proxy on native OpenAI and Azure OpenAI clients: create, parse, and stream are budget-checked and settled from provider usage; every other Responses leaf (retrieve, delete, cancel, input_items.list, ...) is untracked and follows on_unmetered. Three shapes are refused with ConfigurationError because they cannot be metered — background=True, a streaming parse, and an extra_body that overrides model, input, instructions, max_output_tokens, or stream. Responses calls never fail over: the primary serves them or the call fails. See The Responses API.
Anthropic
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello!"}],
)Google Gemini
response = client.models.generate_content(
model="gemini-2.0-flash",
contents="Hello!",
)Amazon Bedrock
response = client.converse(
modelId="us.anthropic.claude-3-5-sonnet-20241022-v2:0",
messages=[{"role": "user", "content": [{"text": "Hello!"}]}],
inferenceConfig={"maxTokens": 1024},
)converse() requires the modelId keyword argument (TypeError otherwise) and returns the raw boto3 Converse dict untouched. converse_stream(...) returns the boto3 contract dict whose result["stream"] is Solwyn's wrapped stream — see Streaming for the close obligation on early abandonment.
invoke_model, invoke_model_with_response_stream, and start_async_invoke raise ConfigurationError on a Bedrock client instead of silently bypassing budget tracking. Use the Converse API (it covers every chat model Bedrock hosts), or call the unwrapped boto3 client directly for deliberately untracked calls. See Amazon Bedrock.
OpenAI-compatible providers
OpenAI-compatible providers (xAI, Groq, Azure OpenAI, Ollama, ...) use the OpenAI call surface. chat.completions.create, embeddings.create, images.generate / images.edit, audio.transcriptions.create, and audio.speech.create are intercepted on the OpenAI dialect: embedding calls are budget-checked and priced server-side from the response's usage.prompt_tokens (or a flagged length estimate when the endpoint omits usage), recorded as a cost event with modality embedding; image generation and edits are budget-checked and recorded with modality image, priced from the request's image count (these endpoints return no usage) at the model's per-image rate; audio is budget-checked and recorded with modality audio, transcriptions priced from usage token buckets or the whole-second duration a JSON response_format reports, text-to-speech priced from the input character count measured inside the firewall. Token-billed TTS models with no usage (gpt-4o-mini-tts) and audio.translations are untracked and follow on_unmetered; video generation (videos.create, Sora) is OpenAI-native only, so on a compat endpoint it fails loud with UnsupportedSurfaceError. The Responses API is metered on Azure OpenAI only; on every other compatible endpoint the Responses leaves — including create / parse / stream — are untracked and follow your on_unmetered posture (warn once and forward by default); they are not budget-checked and produce no cost event.
All keyword arguments are passed through to the underlying provider client. A non-streaming response object is the same type returned by the provider SDK; streams are returned wrapped, preserving iteration and the context-manager protocol but not the provider's exact stream class.
Attribute passthrough
Any attribute not intercepted by Solwyn is resolved against the underlying provider client — through the coverage guard, not blindly:
# Untracked: warns once per process by default, then forwards
models = client.models.list()
# Structural: always silent
client.base_urlA leaf Solwyn does not meter follows on_unmetered (warn once, raise, or allow); a namespace such as client.audio comes back as a guarded resource whose members are classified individually; metadata and infrastructure attributes are always silent. Writes and deletes forward unconditionally (see Type transparency). Private provider attributes bypass the guard.
Interception behavior
For each intercepted call, Solwyn:
- Checks whether the active run has been stopped, and raises
RunStoppedErrorif so - Estimates input tokens from message text (or from a Responses call's
inputandinstructions) - Admits the call — from a run's in-memory token lease where eligible, otherwise a budget check with Solwyn Cloud (or local fallback), carrying the run id and the merged tag snapshot
- Orders the failover chain via the selection policy, dropping providers whose circuit is open and not yet eligible for a recovery probe — with
CostPolicy, using the price hints the check returned - Walks the chain: for each candidate, translates the request if the dialects differ (same-dialect cross-provider hops pass through natively, with request sanitization), then calls the provider client
- Extracts token details from the response
- Hands settlement and metadata to the background reporter as one ordered item, and returns — since 0.4.0 the response is not held for a Solwyn round-trip. See Spend delivery.
If the budget check fails in hard_deny mode, BudgetExceededError is raised before step 5 and a denial receipt is reported. If no candidate could be dispatched — every circuit open, or the failover window expired — ProviderUnavailableError is raised; if every candidate was attempted and failed, the last provider's exception propagates instead. A cross-provider hop that cannot translate the request raises UntranslatableRequestError (or UntranslatableModelError) and aborts the chain before any network call.
Provider exceptions from the underlying SDK are not caught -- when the chain cannot recover, the originating provider exception propagates directly to your code.