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

The budget_mode setting controls what happens when a call would exceed the budget:

ModeValueBehavior
Alert only"alert_only" (default)Logs a warning but allows the call to proceed.
Hard deny"hard_deny"Raises BudgetExceededError and blocks the call.

Alert-only mode (default)

In the default mode, the SDK logs a warning when budget is exhausted but never blocks calls:

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
)

# This call will succeed even if budget is exhausted.
# A warning is logged: "Budget limit reached (alert_only mode)"
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 cost: ${e.estimated_cost:.4f}")
    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:
   - alert_only mode --> log warning, proceed anyway — the call was not
                         refused, so it reports an ordinary success event
   - hard_deny mode  --> raise BudgetExceededError, block the call, and
                         report a denial receipt

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 and is the one decision that ignores budget_mode and fail_open: a stopped run stays stopped. 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)
run finishes /
client closes        -->  surrender the lease

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.

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).
  • 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.

BudgetExceededError attributes

AttributeTypeDescription
budget_limitfloatConfigured spending cap in dollars.
current_usagefloatAmount already consumed in the current period.
estimated_costfloatEstimated cost of the blocked request.
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:

  • fail_open=True (default): Calls proceed. Local tracking continues, and reports are sent when connectivity is restored.
  • fail_open=False: Calls are denied until the budget service is reachable again.

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.

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 tooYour budget_mode decides: hard_deny denies, alert_only admits and warns.
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.
  • fail_open=False meters against the freshest share remainder it knows about and denies at your configured budget_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.

Leases are handed back cleanly at close() and again via an exit hook if the process never closes, so Solwyn can re-lend the float immediately rather than waiting out the lease deadline. An unreachable Solwyn never holds up process exit — an unsurrendered lease simply expires server-side.

import os
from openai import OpenAI
from solwyn import Solwyn

# Strict mode: enforce budgets even when cloud is unreachable
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, 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