SOLWYN
Guides

Budget Enforcement

Set spending caps on your AI agents — alert-only mode vs hard-deny mode

import os
from openai import OpenAI
from solwyn import BudgetExceededError, Solwyn

client = Solwyn(
    OpenAI(),
    api_key=os.environ["SOLWYN_API_KEY"],
    budget_mode="hard_deny",
)

try:
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": "Hello!"}],
    )
    print(response.choices[0].message.content)
except BudgetExceededError as e:
    print(f"Blocked: ${e.current_usage:.4f} of ${e.budget_limit:.2f} used")

client.close()

Budget limits are configured in the Solwyn dashboard per project. The SDK enforces those limits before every LLM call.

Budget modes

Budget mode decides what happens when a call would exceed the budget. It is a property of the project, set in the Solwyn dashboard and returned on every budget check — the SDK enforces the answer it is given:

ModeValueBehavior
Alert only"alert_only" (default)The call proceeds. Spend is metered, the overshoot shows on the dashboard, and your alert rules fire.
Hard deny"hard_deny"The SDK raises BudgetExceededError and the provider is never reached.

The client-side budget_mode= setting does not override this. It is the mode the SDK reports on results it builds itself while Solwyn Cloud is unreachable — see budget_mode.

Alert-only mode (default)

In alert-only mode the SDK never blocks calls. Its Budget limit reached (alert_only mode) warning is built from a server refusal, so which refusals reach the SDK is Solwyn Cloud's decision: since 2026-09-01 a project whose own budget mode is alert-only is admitted past its project-level cap with a reservation and no refusal — its spend is metered and the dashboard shows the overshoot — so no warning is logged for that case. Hard-deny scoped rules and hard-deny per-run caps still refuse, and those refusals still warn or raise as described here.

import os
from openai import OpenAI
from solwyn import Solwyn

client = Solwyn(
    OpenAI(),
    api_key=os.environ["SOLWYN_API_KEY"],
    budget_mode="alert_only",  # this is the default
)

# On an alert-only project this call succeeds even past the cap: Solwyn
# Cloud admits it with a reservation, so the spend is metered and the
# overshoot shows on the dashboard. No warning is logged for a
# project-level overshoot — "Budget limit reached (alert_only mode)"
# only follows a server refusal, which hard-deny scoped rules and
# per-run caps still produce.
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "Hello!"}],
)

client.close()

Hard-deny mode

In hard-deny mode, the SDK raises BudgetExceededError before the LLM call is made:

import os
from openai import OpenAI
from solwyn import BudgetExceededError, Solwyn

client = Solwyn(
    OpenAI(),
    api_key=os.environ["SOLWYN_API_KEY"],
    budget_mode="hard_deny",
)

try:
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": "Hello!"}],
    )
    print(response.choices[0].message.content)
except BudgetExceededError as e:
    print(f"Budget exhausted: ${e.current_usage:.4f} used of ${e.budget_limit:.2f} cap")
    print(f"Blocked request estimated input tokens: {e.estimated_input_tokens}")
    print(f"Period: {e.budget_period}")
    print(f"Mode: {e.mode}")

client.close()

Pre-call flow

Every call is admitted before it reaches the provider. Since 0.4.0 there are two admission paths, and which one a call takes depends on whether it is inside an agent run and whether it carries spend tags:

0. If the active run has been stopped --> raise RunStoppedError
   (operator stop or local velocity stop; never fails open)

1. Estimate the call's billable basis:
   - chat and embeddings: input tokens, from local message-text length
   - Responses API: input tokens, from the text parts of `input` and `instructions`
   - media surfaces: request-derived quantities — image count x size/quality,
     requested video duration and resolution, TTS input character count

2. Admit the call:
   - lease path   --> reserve tokens from a run's in-memory grant (no network)
   - legacy path  --> send a budget check to the Solwyn Cloud API, carrying the
                      run id and the captured tag snapshot

3. If admitted --> proceed with the LLM call
4. If denied — the mode ON THE DENIAL decides, not your client setting:
   - alert_only --> log warning, proceed anyway — the call was not refused,
                    so it reports an ordinary success event. (Solwyn Cloud
                    no longer refuses an alert-only project on its own
                    project-level cap, so only an older deployment or the
                    testing double reaches this arm.)
   - hard_deny  --> raise BudgetExceededError, block the call, and report
                    a denial receipt. A hard-deny scoped rule or per-run
                    cap reports this mode even on an alert_only project.

5. After the call returns, settlement is handed to the background reporter
   — the response is NOT held for a Solwyn round-trip

Step 0 is new in 0.6.0: a stopped run stays stopped, whatever the project's mode and whatever your fail_open posture. See Run control.

Step 5 also changed in 0.4.0. Non-streaming chat and every media surface used to settle with a blocking confirm request on your thread, after the provider had already answered — the response was withheld until Solwyn replied. Settlement is now enqueued and delivered in the background, so the provider's response reaches you without waiting on Solwyn. See Spend delivery.

The media basis travels as estimated_media — quantities and selectors only, never content; see Privacy. Video's pre-flight is exact by construction (the requested duration and resolution are in the request), so an over-budget generation is denied before the provider is called even though its settlement is an estimated, settles-at-initiation over-count.

# Media calls pre-flight identically — deny happens before the provider is reached.
try:
    client.images.generate(model="gpt-image-1", prompt="...", n=4, size="1024x1024")
except BudgetExceededError as e:
    print(f"Over budget: ${e.current_usage:.2f} / ${e.budget_limit:.2f}")

On the legacy path, "allowed" budget checks are cached locally for a configurable TTL (default: 5 seconds) to reduce API round-trips. "Denied" responses are never cached, so a limit takes effect promptly. SDK v0.6.0+: the cache is a bounded 16-entry LRU keyed by the server-priced chain shape — provider, model, the fallback provider and model tuples, and modality. A hit replays that entry's own price hints and never its reservation id. Calls inside a run and calls carrying spend tags never read or write the cache.

Run-scoped leases

New in 0.4.0, and on by default. Token-billed calls inside a solwyn.run(...) scope no longer pay a blocking round-trip to Solwyn before every provider request. Instead the run takes one server-granted lease and draws it down locally.

The shape is DHCP's, for the same reason: authority is delegated for a bounded window so the client can keep serving without asking permission every time.

first eligible call  -->  request a grant from Solwyn        (one round-trip)
later calls          -->  reserve tokens from the grant      (no network)
75% depleted, or
refresh deadline     -->  renew in the background            (off your thread)
scope exits, or the
authority is revoked -->  hand the lease back                 (off your thread)

A lease is denominated in tokens, never dollars. The server folds price into the granted amount; the SDK still performs no pricing math.

What each call reserves

Each admitted call reserves its estimated input tokens plus a conservative output allowance, then trues that reservation up to actual usage once the call settles. The output half is the largest effective cap across every configured provider hop, honoring per-call over per-entry over global precedence and each provider's own cap spelling (including Google's and Bedrock's nested fields). Any hop that is genuinely unbounded contributes lease_output_bound_default (4096) to that maximum. So a call with a small cap on the primary but an uncapped fallback hop reserves 4096, not the primary's cap — the fallback could legitimately produce that much.

Reserving the largest cap in the chain is deliberate: a failover hop could legitimately produce that much, and under-reserving would let a run exceed its cap.

When a call does not finish cleanly

Changed in 0.8.0. A reservation whose outcome the SDK cannot settle now keeps its authorized bound instead of being left as a live draw for the 900-second reservation sweep to refund. The rule: unknown provider usage is charged at the bound, and only a provably unsent request returns its authority.

OutcomeReservation
A read timeout, 5xx, or protocol drop after the request was sent, with ambiguous failover disabled (the default)Retired at the bound. The error receipt carries possibly_succeeded=true. Before 0.8.0 the reservation was refunded while the receipt still said the call might have run.
The same under failover_idempotency="always"The call fails over on the same reservation, which is pinned at its bound for the rest of the call: a later served hop settles at no less than the bound (its confirmation still carries the measured usage), and a later refusal, chain exhaustion, cancellation, or sweep retires the bound instead of refunding it.
An async call cancelled, or a sync call interrupted by KeyboardInterrupt, SystemExit, or another non-Exception exit, after the provider SDK was invoked — including while it was still waiting for a connection-pool slot, since cancellation alone cannot prove that no request was sentRetired at the bound, with an error receipt (possibly_succeeded=true, failover_error_class naming the interrupt). No provider-breaker verdict is recorded, and the original interrupt is re-raised.
An interrupt that provably landed before the request — during the hop's preparation, or during a rejected request's Retry-After waitReturned. An error receipt is still reported, without possibly_succeeded.
A failure while wrapping a provider stream that is already open, including at a Responses stream() helper's entryRetired at the bound behind a possibly_succeeded receipt whose failover_error_class names the wrapping error; the provider stream is closed best-effort, no breaker failure is recorded, and the original error is re-raised.
A stream whose streamed usage cannot be finalizedRetired at the bound, same receipt shape, no breaker verdict.
A proven pre-send failure (connect error or timeout, pool timeout, 429) or a request-shaped 4xx refusalReturned, as before.

The conservative tally can exceed actual usage, and aggressive caller timeouts exhaust a lease sooner than the run's real spend would — the next call then takes a per-call check. It never invents output usage or computes a cost: an abandoned reservation sends no confirmation, and its error receipt reports zero tokens. What each terminal charged leaves your process on the run's next lease renewal or surrender as spent_tokens, off the calling thread.

Which calls use a lease

Lease-eligible calls take the lease path automatically. These take the legacy per-call check instead, with no configuration on your part:

  • Calls outside any solwyn.run(...) scope.
  • Calls carrying spend tags — from a client-wide tags=, the scope, or solwyn_tags — because a tag-scoped budget needs the tag snapshot on a live check. See Tags and budget admission.
  • Non-text modalities, and any call carrying media quantities.
  • Calls whose model or fallback chain is outside the lease's declared set.
  • Runs the server marks lease-ineligible (for example a unit-priced or zero-rate model). Since 0.7.0 a lease already held is handed back when the renewal answers ineligible.
  • Runs carrying a sticky hard deny. Once a run (or the project period) has an authoritative deny on record, it returns to the per-call path so a live check re-decides it. Local lease authority never outranks a server denial.
  • Every call, when you set lease_enabled=False — the kill switch, which builds no lease state at all.

Renewal

Renewal is driven by demand, not by a timer, so an idle run costs nothing. It fires when the grant is at least 75% depleted or the refresh deadline has passed, and it always runs off your calling thread — a background thread for Solwyn, a task for AsyncSolwyn. Renewals are jittered, one in flight at a time, and back off from 1s to 30s on failure.

If Solwyn signals that a lease is winding down, the SDK logs a warning and keeps serving from what remains.

Leases and per-run caps

Per-run budget caps still work exactly as they did in 0.3.0 — the cap is enforced server-side, and an authoritative deny at grant or renewal feeds the same sticky-deny machinery. A hard deny still raises the same BudgetExceededError, so nothing in your except block changes. See Per-run budget caps.

The sticky-deny behavior from 0.3.0 is unchanged: a hard deny is remembered per run id (an LRU over the 128 most recent runs), and if Cloud then goes unreachable the denial is preserved rather than failing open. Each preserved deny logs a WARNING naming the usage and limit, on every call it applies to — see Logging.

The allow cache never authorizes a lease-funded call, and run-scoped checks have bypassed it since 0.3.0. Cloud usage visibility remains asynchronous either way: settlement rides the background reporter, so the dashboard trails live spend by roughly the flush interval.

When a lease is handed back

Changed in 0.7.0. A lease is surrendered the moment the SDK knows it has stopped using it, so Solwyn can re-lend the float immediately rather than waiting out the lease deadline. Before 0.7.0 a lease was held until close() or the exit hook, whatever the run had done in the meantime. The release fires when:

  • The with solwyn.run(...) block exits — normally or on an exception — or RunHandle.finish() is called on a start_run or create_run handle. An inner run releases only its own lease; the enclosing run keeps drawing on its own.
  • A renewal comes back ineligible or denied, or the lease block in a response cannot be read.
  • Solwyn Cloud stops the run. The grant or renewal carrying the stop directive is refused rather than installed, and the lease the run already holds is handed back at that same moment. A later grant or renewal landing on a run already known to be stopped is itself surrendered, as it was before 0.7.0.
  • close() runs, or the interpreter-exit hook if the process never closes. Both release whatever is still held, including a release that could not be dispatched earlier.

The release is sent off your thread — a daemon thread named solwyn-lease-release for Solwyn, a task for AsyncSolwyn — so a scope exit costs thread dispatch and nothing else. An async client driven from synchronous code, with no event loop running at scope exit, parks the release; since 0.9.0 it hands the release to the event loop that client's enforcer last ran on, if that loop is still open — as when a sync generator step runs on a framework worker thread — and otherwise sends it at close(). The release is best-effort: each attempt is bounded at 3 seconds, a timeout is retried once, a refusal or transport failure is not, an open control-plane breaker skips it, and an unreleased lease is still reclaimed at its server deadline. An unreachable Solwyn never holds up a scope exit or process exit.

Changed in 0.8.0. Releases are bounded and share one HTTP pool. A burst of finished leased runs used to start one release worker and one native HTTP/TLS client per release, which could stall an event loop for over a second at 128 releases. Each budget enforcer now runs at most 4 release workers with at most 64 releases parked unsent; identical lease, holder, generation, and spend payloads coalesce. When the queue is full the new release is abandoned unsent — nothing already parked is evicted — and counted under queue_full; parked work still unsent after 6 seconds expires (expired, cut to 3 seconds by close()), and a worker that cannot be started counts dispatch_failed. Releases reuse a dedicated pool, separate from the provider clients and from the reporter, and load their trust roots once off the loop. A pool or TLS failure before a request counts setup_failed and any other local error local_error — neither touches the shared control-plane breaker, which only a transport or HTTP failure on a sent request feeds — and a cancelled async worker counts cancelled. The counters are readable as release_counts on the BudgetEnforcer / AsyncBudgetEnforcer, a plain dict[str, int] in which an absent key means zero; nothing is counted for a release that was sent. Server lease expiry remains the backstop in every case, and no configuration field or wire shape changed.

Three cases deliberately do not release. Exiting a handle.activate() scope on a create_run(...) handle keeps the lease, because a detached identity is meant to be re-entered — finish() releases it. A renewal answered 404 or 409 has nothing to release, or a live successor that would refuse it. And a lease that expires locally simply re-grants on the next admission.

Work that outlives its scope with the run id still bound keeps working: its next call finds no lease and pays one blocking grant. That grant is held behind the release of the lease it replaces — for at most 6 seconds, typically the release's real round-trip — so the server sees the replacement after the release except in the rare case where the release outlives that bound. A call still at the provider when its scope exits settles normally, and its confirmation still names the lease it was funded by, so the spend is recorded either way.

BudgetExceededError attributes

AttributeTypeDescription
budget_limitfloatConfigured spending cap in dollars.
current_usagefloatAmount already consumed in the current period.
estimated_input_tokensintSDK v0.8.0+. The SDK's length-based input-token estimate for the blocked request — the same integer the budget check carried.
estimated_costfloat | NoneChanged in 0.8.0. A server-supplied cost for the blocked request, when one is given. Solwyn Cloud supplies none today, so it is None on every error the SDK raises; before 0.8.0 the SDK filled it from a flat local per-token estimate.
budget_periodstrWhat denied the call: a project period ("daily", "weekly", "monthly"), a per-run cap ("agent_run"), a scoped rule ("model", "provider", "agent_run", "tag"), or "unknown" if not provided by the API. A stopped run raises RunStoppedError instead.
modestrActive budget mode when the error was raised. A scoped rule in hard-deny mode reports "hard_deny" even on an alert_only project.

When Solwyn Cloud is unreachable

On the legacy check path, fail_open decides what happens. Changed in 0.8.0: the SDK no longer estimates cost during an outage. Before 0.8.0 both postures kept a local dollar ledger at a flat per-token rate — a figure that meant nothing once calls became multimodal, and one that started from zero on every outage without the spend the server had already counted, so it could never enforce the real limit. Nothing in the SDK multiplies tokens by a price any more.

  • fail_open=True (default): Calls proceed, and every call admitted this way is tallied — one call plus its input-token estimate, replaced by the provider-reported total once the call settles. A media call with no token usage, and a call that settles with zero tokens (a stream abandoned before its usage chunk), keeps its estimate. The tally rides the next /budgets/check that reaches Solwyn as uncounted_calls / uncounted_tokens, where Solwyn Cloud records it as an advisory enforcement gap — never priced, never added to current_usage. Both fields are omitted when zero, so a check with nothing to report is byte-identical to a 0.7.0 check. Delivery is at-least-once: the tally is kept for the next check when that check gets a 408, 409 or 429, any 5xx, a transport error or timeout, is held by the control-plane breaker, or is cancelled. Any other answer clears it — a 2xx (even one the SDK cannot parse), or any other 4xx, because Solwyn records the tally before it evaluates the check; a 4xx that drops a report logs budget.uncounted_report_dropped with the status, at most once every 30 seconds. The tally only rides a budget check: a process whose traffic is all lease-funded or served from the allow cache never sends one, and a tally still unsent at process exit is lost — there is no exit flush, so close() never waits on a down control plane. A run's own outage tally is separate and still rides lease renewals; see the ladder below.
  • fail_open=False: Fails closed. Every call that no live budget lease covers raises BudgetExceededError until Solwyn is reachable again — including the first call of a run that holds no lease yet. The error carries budget_limit and current_usage of 0.0, budget_period="unknown", the request's estimated_input_tokens, and your configured budget_mode as mode — an alert_only client still raises, because there is no server verdict to soften. Its denial receipt reports deny_source="local_enforcement" and deny_reason="control_plane_unreachable". The SDK logs budget.fail_closed_unreachable at most once every 30 seconds. Before 0.8.0 this posture admitted calls against the last known dollar limit using the local estimate. To keep calls flowing through an outage, run them inside solwyn.run(...) with lease_enabled=True (the default) so a live lease admits them locally, or set fail_open=True to admit and tally.

A cached "allowed" verdict still serves on either posture: the allow cache is consulted before the network and is cleared only by a check response, so an untagged call outside a run can be admitted from it until its TTL expires.

The uncounted_* fields are API-first — Solwyn Cloud has accepted them on /budgets/check since 2026-09-17, before this SDK shipped. Against a control plane that predates them, the first check after an outage is rejected with a 4xx and the SDK drops that outage's tally rather than re-sending it forever.

Since 0.4.0, the SDK also keeps a circuit breaker around Solwyn Cloud itself, so an outage is discovered once per client rather than re-paid as a timeout on every call. Combined with the shorter budget_check_timeout (now 1.0s), a Solwyn outage costs your hot path far less than it used to. That breaker is a separate health domain from your provider breakers, and it never appears in breaker state reports.

An overloaded Solwyn is treated as unreachable. Any non-2xx answer to /budgets/check other than a read-only-key 403 — including a 503 busy response — is an outage: it feeds the control-plane breaker, and the call takes the fail_open posture above. After control_plane_failure_threshold failures (3 by default) the breaker opens, and later checks short-circuit without a request, even under hard_deny. A 503 on /budgets/lease is different: Solwyn answered, so it is a refusal. It counts as a response for the breaker, and the same call falls back to /budgets/check.

Verdicts that outrank the outage posture

Two decisions are settled before fail_open is consulted, and an outage never reopens them:

  • A run stop. Once the SDK has received an operator stop or issued a local velocity stop for a run, every later call in that run raises RunStoppedError — with fail_open=True, on an alert_only project, through any outage. A stop is not a budget decision and no posture softens it. See Run control.
  • A sticky hard deny. An authoritative hard deny on record for the run or the project period is preserved through an outage rather than forgotten; the SDK logs a WARNING each time it applies.

Everything below concerns calls that have no such verdict yet.

The lease outage ladder

A run holding a lease degrades in stages. The guiding rule: every deny traces to a verdict you chose, never to Solwyn being unreachable.

StageConditionWhat happens
1Grant has tokens leftAdmitted from the local remainder. No network.
2Grant exhausted, Solwyn reachableFalls back to a per-call check. An empty wallet is not a refusal, and the server stays authoritative.
3Solwyn unreachableDraws on headroom_share_tokens — this lease holder's apportioned slice of real remaining budget. Admitted with a warning, still metered. (Apportioned per holder, so several SDK instances in one run each get their own slice.)
4That share exhausted tooThe project's mode decides — carried on the grant itself, so the verdict is the one you chose in Cloud, not a guess made offline: hard_deny denies, alert_only admits and warns. A hard-deny scoped rule or per-run cap on the run escalates the grant's posture to hard_deny.
5Lease deadline passed, Solwyn still downYour fail_open posture picks the floor. See below.

At stage 5 the two postures differ sharply, and the difference is a billing-integrity question worth understanding:

  • fail_open=True admits calls uncounted. They are explicitly not metered against your budget, but every one is tallied and reported on the next successful renewal, so the spend is not lost — only delayed. The SDK logs lease.uncounted_entry on entering the episode and lease.uncounted_continuing at most once every 30 seconds while it lasts. Installing a fresh grant ends the episode. An uncounted tally rides the next successful renewal, so it needs the run to still be open: since 0.7.0 a run-scope exit hands the lease back and drops the run's record, so a tally that no renewal has carried by then is not reported.
  • fail_open=False meters against the freshest share remainder it knows about and denies at the project's mode once that is spent.

If uncounted calls during a Solwyn outage are unacceptable for your workload, set fail_open=False and accept that calls stop instead.

The ladder only applies to a run that already holds a lease. A run whose first call lands during the outage has no lease yet, so that call takes the legacy path above: admitted and tallied on the run's lease ledger under fail_open=True, denied under fail_open=False.

A lease is handed back as soon as the SDK stops using it — at run-scope exit, when its authority is revoked, and as a last resort at close() or the exit hook — so Solwyn can re-lend the float rather than waiting out the lease deadline. An unreachable Solwyn never holds up a scope exit or process exit; an unsurrendered lease simply expires server-side. See When a lease is handed back.

import os
from openai import OpenAI
from solwyn import Solwyn

# Strict mode: fail closed while Solwyn is unreachable.
# Lease-funded calls inside solwyn.run(...) keep flowing; everything else raises.
client = Solwyn(
    OpenAI(),
    api_key=os.environ["SOLWYN_API_KEY"],
    budget_mode="hard_deny",
    fail_open=False,
)

Budget cache TTL

Applies to the per-call check path only — it never authorizes a lease-funded call. Budget check results are cached locally to reduce API calls. Adjust the TTL with budget_check_cache_ttl:

import os
from openai import OpenAI
from solwyn import Solwyn

client = Solwyn(
    OpenAI(),
    api_key=os.environ["SOLWYN_API_KEY"],
    budget_check_cache_ttl=10,  # cache for 10 seconds instead of default 5
)

Lower values give more responsive enforcement at the cost of more API calls. Higher values reduce API traffic but may allow brief overspend before a limit takes effect. The cache holds at most 16 distinct chain shapes; a 17th evicts the least recently used.

Testing enforcement

SDK v0.6.0+: solwyn.testing.FakeControlPlane lets you exercise every branch on this page without a network: model="solwyn-test/deny" makes the hard-deny except fire, plane.outage() drives the fail-open path and the outage ladder, plane.expire_leases() and plane.refuse_leases(...) walk the lease stages, plane.released_leases (SDK v0.7.0+) shows which leases a scope exit or a stop actually handed back, and plane.stop_run(run_id) is the operator kill switch. See Testing budget enforcement.

  • SolwynConfig -- All budget-related config fields and env vars
  • Run control -- Operator stops, local velocity detection, and RunStoppedError
  • Testing budget enforcement -- Script denials, outages, and kills against a zero-network control plane
  • Spend delivery -- How settlement leaves your process, and what happens when it cannot
  • Agent Runs -- The solwyn.run(...) scope that leases are drawn for
  • Troubleshooting -- Diagnose unexpected BudgetExceededError raises
  • CLI: Scripting and CI -- Gate a deploy on budget utilization, and guard un-instrumented spend with budget check / confirm

On this page