Skip to content

PyPIPyPI DownloadsCIRecovery conformanceLicenseCoverage

Cycles Python Client — AI agent budget and action authority SDK

Python SDK for AI agent budget governance — enforce cost limits, tool permissions, and multi-tenant policies before LLM calls or agent actions execute. Works with OpenAI, Anthropic, LangChain, OpenAI Agents SDK, CrewAI, and any Python agent framework.

Decorator-based API for the Cycles Protocol: reserve budget up front, execute your agent code, commit or release — with concurrency-safe enforcement, automatic heartbeats, and typed reservation contexts. Install via pip install runcycles.

Installation

pip install runcycles

Quick Start

Decorator-based (recommended)

fromruncyclesimportCyclesClient, CyclesConfig, cycles, get_cycles_context, CyclesMetricsconfig=CyclesConfig(
base_url="http://localhost:7878",
api_key="your-api-key",
tenant="acme",
)
client=CyclesClient(config)
@cycles(estimate=lambdaprompt, tokens: tokens*10,actual=lambdaresult: len(result) *5,action_kind="llm.completion",action_name="gpt-4",client=client,)defcall_llm(prompt: str, tokens: int) ->str:
# Access the reservation context inside the guarded functionctx=get_cycles_context()
ifctxandctx.has_caps():
tokens=min(tokens, ctx.caps.max_tokensortokens)
result=f"Response to: {prompt}"# Report metrics (included in the commit)ifctx:
ctx.metrics=CyclesMetrics(tokens_input=tokens, tokens_output=len(result))
returnresultresult=call_llm("Hello", tokens=100)

Need an API key? API keys are created via the Cycles Admin Server (port 7979). See the deployment guide to create one, or run:

curl -s -X POST http://localhost:7979/v1/admin/api-keys \
-H "Content-Type: application/json" \
-H "X-Admin-API-Key: admin-bootstrap-key" \
-d '{"tenant_id":"acme-corp","name":"dev-key","permissions":["reservations:create","reservations:commit","reservations:release","reservations:extend","reservations:list","balances:read","decide","events:create"]}'| jq -r '.key_secret'

The key (e.g. cyc_live_abc123...) is shown only once — save it immediately. For key rotation and lifecycle details, see API Key Management.

Dynamic subject and action fields

Subject fields (tenant, workspace, app, workflow, agent, toolset), action fields (action_kind, action_name, action_tags), and dimensions all accept either a constant or a callable. When given a callable, it is invoked with the decorated function's *args, **kwargs at reservation time — useful for routing per-call to different budget scopes or labeling actions dynamically:

@cycles(estimate=lambdareq, workspace_id: req.tokens*10,workspace=lambdareq, workspace_id: workspace_id, # per-call budget routingaction_kind=lambdareq, *_: f"llm.{req.provider}", # dynamic action labelaction_name=lambdareq, *_: req.model,dimensions=lambdareq, *_: {"region": req.region},client=client,)defrun_request(req: ResponseRequest, workspace_id: str) ->Response:
...

Fallback semantics mirror the constant case:

  • Subject callables returning None fall through to the client-config default (CyclesConfig(workspace=...)).
  • action_kind / action_name returning None fall through to "unknown".
  • action_tags / dimensions returning None are omitted from the request.
  • A callable that raises propagates the exception — fail-fast — without creating a reservation.

Budget lifecycle

The @cycles decorator wraps your function in a reserve → execute → commit/release lifecycle:

ScenarioOutcomeDetail
Reservation deniedNeitherBudgetExceededError, OverdraftLimitExceededError, or DebtOutstandingError raised; function never executes
dry_run=True, any decisionNeitherReturns DryRunResult or raises; no real reservation created
Function returns successfullyCommitActual amount charged; unused remainder auto-released
Guarded function raisesReleaseFull reserved amount returned to budget; exception re-raised
actual callback raises or returns a non-int64 amountCommit estimateCommit carries metadata.actual_source="estimate"
Post-action settlement setup raisesNeitherError surfaces, but known spend is never released
Commit fails (5xx / network)RetryExponential backoff with configurable attempts
Commit fails (recognized non-retryable 4xx)NeitherRetry stops and the journal entry is discarded, but known spend is never released
Commit gets RESERVATION_EXPIREDRecoverServer reclaimed the hold; known spend is recorded through POST /v1/events
Commit gets RESERVATION_FINALIZEDNeitherAlready committed or released (idempotent replay)
Commit gets IDEMPOTENCY_MISMATCHNeitherPrevious commit already processed; no release attempted

All raised exceptions from the guarded function trigger release. Failures after it returns never release known spend. If estimate fallback is disabled without an actual, configuration is rejected before reservation or execution. See How Reserve-Commit Works for the full protocol-level explanation.

Programmatic client

fromruncyclesimport (
CyclesClient, CyclesConfig, ReservationCreateRequest,
CommitRequest, Subject, Action, Amount, Unit, CyclesMetrics,
)
config=CyclesConfig(base_url="http://localhost:7878", api_key="your-api-key")
withCyclesClient(config) asclient:
# 1. Reserve budgetresponse=client.create_reservation(ReservationCreateRequest(
idempotency_key="req-001",
subject=Subject(tenant="acme", agent="support-bot"),
action=Action(kind="llm.completion", name="gpt-4"),
estimate=Amount(unit=Unit.USD_MICROCENTS, amount=500_000),
ttl_ms=30_000,
))
ifresponse.is_success:
reservation_id=response.get_body_attribute("reservation_id")
# 2. Do work ...# 3. Commit actual usageclient.commit_reservation(reservation_id, CommitRequest(
idempotency_key="commit-001",
actual=Amount(unit=Unit.USD_MICROCENTS, amount=420_000),
metrics=CyclesMetrics(tokens_input=1200, tokens_output=800),
))

Async support

fromruncyclesimportAsyncCyclesClient, CyclesConfig, cyclesconfig=CyclesConfig(base_url="http://localhost:7878", api_key="your-api-key")
client=AsyncCyclesClient(config)
@cycles(estimate=1000, client=client)asyncdefcall_llm(prompt: str) ->str:
returnf"Response to: {prompt}"# In an async context:result=awaitcall_llm("Hello")

Streaming

For streaming LLM responses, use the stream_reservation() context manager. It reserves budget on enter, heartbeats while work runs, journals known spend before committing on successful exit, and releases on handler exception:

fromopenaiimportOpenAIfromruncyclesimportCyclesClient, CyclesConfig, Action, Amount, Unitconfig=CyclesConfig(base_url="http://localhost:7878", api_key="your-api-key", tenant="acme")
cycles_client=CyclesClient(config)
openai_client=OpenAI()
max_tokens=1024withcycles_client.stream_reservation(
action=Action(kind="llm.completion", name="gpt-4o"),
estimate=Amount(unit=Unit.USD_MICROCENTS, amount=max_tokens*1000),
cost_fn=lambdau: u.tokens_input*250+u.tokens_output*1000,
idempotency_key="chat-run-123", # optional stable upstream identityraise_on_commit_failure=True, # recovery is queued before this surfaces
) asreservation:
# Caps available immediately after entering the contextifreservation.capsandreservation.caps.max_tokens:
max_tokens=min(max_tokens, reservation.caps.max_tokens)
stream=openai_client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello"}],
max_tokens=max_tokens,
stream=True,
stream_options={"include_usage": True},
)
forchunkinstream:
ifchunk.choicesandchunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="", flush=True)
ifchunk.usage:
reservation.usage.tokens_input=chunk.usage.prompt_tokensreservation.usage.tokens_output=chunk.usage.completion_tokens# Committed automatically with actual cost computed by cost_fn

Also available as async with client.stream_reservation(...) for async clients. With the default raise_on_commit_failure=False, synchronous settlement failures are logged and exposed as reservation.settlement_error while durable recovery continues. Known spend is never released because its commit failed. See streaming_usage.py for a complete example.

Configuration

From environment variables

fromruncyclesimportCyclesConfigconfig=CyclesConfig.from_env()
# Reads: CYCLES_BASE_URL, CYCLES_API_KEY, CYCLES_TENANT, etc.

Need an API key? See the deployment guide or API Key Management.

All options

CyclesConfig(
base_url="http://localhost:7878",
api_key="your-api-key",
tenant="acme",
workspace="prod",
app="chat",
workflow="refund-flow",
agent="planner",
toolset="search-tools",
connect_timeout=2.0,
read_timeout=5.0,
retry_enabled=True,
retry_max_attempts=5,
retry_initial_delay=0.5,
retry_multiplier=2.0,
retry_max_delay=30.0,
retry_flush_timeout=10.0,
journal_enabled=True,
journal_dir=None, # None → ~/.runcycles/commit-journal
)

Commit durability

A commit records spend that has already happened, so the SDK never lets one exist only in memory. Every commit scheduled for background retry is first journaled to disk and removed only on a terminal outcome. Records live under journal_dir (default ~/.runcycles/commit-journal) in a per-identity subdirectory (directories 0700, files 0600 where supported) keyed by a non-secret fingerprint of the server plus principal — the configured tenant when set (stable across API-key rotation; any same-tenant credential can settle the records), otherwise the API key. Clients using different servers or principals on the same machine never replay each other's records. Without a tenant configured, rotating the API key orphans pending records under the old fingerprint directory; records are plain JSON, so moving them into the new identity directory is safe — replay is idempotent:

  • Process exit: an atexit hook waits up to retry_flush_timeout seconds (one process-wide budget shared across all engines) for in-flight retries; anything unfinished stays journaled and is replayed automatically the next time the process creates a client lifecycle.
  • Rate limiting: HTTP 429 / LIMIT_EXCEEDED responses are transient everywhere — a rate-limited first commit attempt is scheduled for retry (never released, which would return budget for spend that already happened), the journal entry is kept, and the next attempt waits at least the server's Retry-After. The floor is persisted as an absolute timestamp, so a restart mid-wait still honors it.
  • Authentication failures: 401/403 on any commit attempt — first or retried — journals the spend (never releases it) and stops the current run's attempts, so spend recorded during a key misconfiguration or rotation window replays once credentials are fixed.
  • Reservation expired before the commit landed: the server has already returned the reserved budget to the pool, so the SDK re-records the spend via POST /v1/events (the protocol's post-hoc direct-debit endpoint), tagging the event metadata with recovered_reservation_id for reconciliation. Commit and event requests both carry idempotency keys, so replays across restarts (or from multiple processes sharing a journal directory) are exactly-once.
  • Set journal_enabled=False (or CYCLES_JOURNAL_ENABLED=false) to opt out and restore fire-and-forget behavior.

Default client / config

Instead of passing client= to every @cycles decorator, set a module-level default:

fromruncyclesimportCyclesConfig, set_default_config, set_default_client, CyclesClient, cycles# Option 1: Set a config (client created lazily)set_default_config(CyclesConfig(base_url="http://localhost:7878", api_key="your-key", tenant="acme"))
# Option 2: Set an explicit clientset_default_client(CyclesClient(CyclesConfig(base_url="http://localhost:7878", api_key="your-key")))
# Now @cycles works without client=@cycles(estimate=1000)defmy_func() ->str:
return"hello"

Error handling

fromruncyclesimport (
CyclesClient, CyclesConfig, ReservationCreateRequest,
Subject, Action, Amount, Unit,
)
config=CyclesConfig(base_url="http://localhost:7878", api_key="your-key")
withCyclesClient(config) asclient:
response=client.create_reservation(ReservationCreateRequest(
idempotency_key="req-002",
subject=Subject(tenant="acme"),
action=Action(kind="llm.completion", name="gpt-4"),
estimate=Amount(unit=Unit.USD_MICROCENTS, amount=500_000),
))
ifresponse.is_transport_error:
print(f"Transport error: {response.error_message}")
elifnotresponse.is_success:
print(f"Error {response.status}: {response.error_message}")
print(f"Request ID: {response.request_id}")

With the @cycles decorator, protocol errors are raised as typed exceptions:

fromruncyclesimportcycles, BudgetExceededError, CyclesProtocolError@cycles(estimate=1000, client=client)defguarded_func() ->str:
return"result"try:
guarded_func()
exceptBudgetExceededError:
print("Budget exhausted — degrade or queue")
exceptCyclesProtocolErrorase:
ife.is_retryable() ande.retry_after_ms:
print(f"Retry after {e.retry_after_ms}ms")
print(f"Protocol error: {e}, code: {e.error_code}")

Exception hierarchy:

ExceptionWhen
CyclesErrorBase for all Cycles errors
CyclesProtocolErrorServer returned a protocol-level error; also raised with status == -1 when the SDK wraps a reserve-time transport failure
BudgetExceededErrorBudget insufficient for the reservation
OverdraftLimitExceededErrorDebt exceeds the overdraft limit
DebtOutstandingErrorOutstanding debt blocks new reservations
ReservationExpiredErrorOperating on an expired reservation
ReservationFinalizedErrorOperating on an already-committed/released reservation
TenantClosedErrorThe owning tenant is CLOSED (HTTP 409 TENANT_CLOSED, runtime spec v0.1.25.13); raised at reservation-creation time — commit/release failures are handled internally by the commit-retry/release policy
CyclesTransportErrorExported for use in your own code; never raised by the SDK — transport failures surface as status == -1 (see below)

Transport errors

When the HTTP request itself fails (DNS resolution, connection refused, timeout), the SDK never raises CyclesTransportError — the class is exported for use in your own code (e.g. wrapping transport-level failures in higher-level integrations). Instead:

  • Lifecycle-managed surfaces (@cycles and stream_reservation()): a transport failure at reserve time raises CyclesProtocolError with status == -1 and error_code=None. Commit transport failures are durably queued; stream_reservation(..., raise_on_commit_failure=True) also surfaces one as CyclesProtocolError after queuing it.
  • Programmatic client: calls never raise for transport failures — they return a CyclesResponse with is_transport_error == True and status == -1 (shown above).
fromruncyclesimportCyclesProtocolErrortry:
guarded_func()
exceptCyclesProtocolErrorase:
ife.status==-1:
print(f"Network error reaching Cycles: {e}") # retry or degradeelse:
raise

Preflight checks (decide)

Check whether a reservation would be allowed without creating one:

fromruncyclesimportDecisionRequest, Subject, Action, Amount, Unitresponse=client.decide(DecisionRequest(
idempotency_key="decide-001",
subject=Subject(tenant="acme"),
action=Action(kind="llm.completion", name="gpt-4"),
estimate=Amount(unit=Unit.USD_MICROCENTS, amount=500_000),
))
ifresponse.is_success:
decision=response.get_body_attribute("decision") # "ALLOW" or "DENY"print(f"Decision: {decision}")

Events (direct debit)

Record usage without a reservation — useful for post-hoc accounting:

fromruncyclesimportEventCreateRequest, Subject, Action, Amount, Unitresponse=client.create_event(EventCreateRequest(
idempotency_key="evt-001",
subject=Subject(tenant="acme"),
action=Action(kind="api.call", name="geocode"),
actual=Amount(unit=Unit.USD_MICROCENTS, amount=1_500),
))

Querying balances

At least one subject filter (tenant, workspace, app, workflow, agent, or toolset) is required:

response=client.get_balances(tenant="acme")
ifresponse.is_success:
print(response.body)

Response metadata

Every response exposes protocol headers for debugging and rate-limit awareness:

response=client.create_reservation(request)
print(response.request_id) # X-Request-Idprint(response.rate_limit_remaining) # X-RateLimit-Remaining (int or None)print(response.rate_limit_reset) # X-RateLimit-Reset (int or None)print(response.cycles_tenant) # X-Cycles-Tenant

Dry run (shadow mode)

Evaluate a reservation without persisting it. The @cycles decorator supports dry_run=True:

@cycles(estimate=1000, dry_run=True, client=client)defshadow_func() ->str:
return"result"

In dry-run mode, the server evaluates the reservation and returns a decision, but no budget is held or consumed. The decorated function does not execute — a DryRunResult is returned instead.

Overage policies

Control what happens when actual usage exceeds the estimate at commit time:

fromruncyclesimportCommitOveragePolicy# REJECT — commit fails if budget is insufficient for the overage# ALLOW_IF_AVAILABLE (default) — commit succeeds if remaining budget covers the overage# ALLOW_WITH_OVERDRAFT — commit always succeeds, may create debt@cycles(estimate=1000, overage_policy="ALLOW_WITH_OVERDRAFT", client=client)defoverdraft_func() ->str:
return"result"

Nested @cycles Calls

Calling a @cycles-decorated function from inside another @cycles-decorated function is allowed — it will not raise an error. However, each decorator creates an independent reservation that deducts budget separately:

@cycles(estimate=100, action_name="inner")definner_call():
return"done"@cycles(estimate=500, action_name="outer")defouter_call():
returninner_call() # creates a SECOND reservation — 600 total deducted, not 500

This means nested decorators double-count budget. The outer reservation already covers the full estimated cost of the operation, so an inner reservation deducts additional budget from the same pool.

Recommended pattern: Place @cycles at the outermost entry point only. Inner functions should be plain functions without their own guard:

definner_call(): # no @cycles — called within a guarded operationreturn"done"@cycles(estimate=500, action_name="outer")defouter_call():
returninner_call() # single reservation — 500 total

Features

  • Decorator-based: @cycles wraps functions with automatic reserve/execute/commit lifecycle
  • Programmatic client: Full control via CyclesClient / AsyncCyclesClient
  • Sync + async: Both synchronous and asyncio-based APIs
  • Automatic heartbeat: TTL extension at half-interval keeps reservations alive
  • Commit retry: Failed commits are retried with exponential backoff
  • Context access: get_cycles_context() provides reservation details inside guarded functions
  • Typed exceptions: BudgetExceededError, OverdraftLimitExceededError, etc. for precise error handling
  • Pydantic models: Typed request/response models with spec-enforced validation constraints
  • Response metadata: Access request_id, rate_limit_remaining, and rate_limit_reset on every response
  • Environment config: CyclesConfig.from_env() for 12-factor apps

Examples

The examples/ directory contains runnable integration examples:

ExampleDescription
basic_usage.pyProgrammatic reserve → commit lifecycle
decorator_usage.py@cycles decorator with estimates, caps, and metrics
async_usage.pyAsync client and async decorator
openai_integration.pyGuard OpenAI chat completions with budget checks
anthropic_integration.pyGuard Anthropic messages with per-tool budget tracking
streaming_usage.pystream_reservation() context manager with auto-commit
fastapi_integration.pyFastAPI middleware, dependency injection, per-tenant budgets
langchain_integration.pyLangChain callback handler for non-agent runnables (ChatOpenAI etc.) — for agents using create_agent, see langchain-runcycles below

See examples/README.md for setup instructions.

Integrations

Sibling packages and integrations published separately:

PackagePurpose
langchain-runcycles (PyPI: langchain-runcycles)LangChain agent middleware — pre-tool-call authorization (CyclesToolGate) and fan-out caps (CyclesFanOutGate) for create_agent workflows. Use this for agent-style LangChain code; the callback handler example in this repo remains the right fit for bare-runnable (non-agent) LangChain usage.

Development

pip install -e ".[dev]"# Lint
ruff check .# Type check (strict mode)
mypy runcycles
# Run tests with coverage (95% threshold enforced in CI)
pytest --cov runcycles --cov-fail-under=85

CI runs all three checks on Python 3.10 and 3.12 for every push and pull request.

Documentation

Requirements

  • Python 3.10+
  • httpx
  • pydantic >= 2.0

Releases

Packages

Used by

Contributors

Languages