Python client for the Cycles budget-management protocol
Project description
Cycles Python Client
Python client for the Cycles budget-management protocol.
Installation
pip install runcycles
Quick Start
Decorator-based (recommended)
from runcycles import CyclesClient, CyclesConfig, cycles, get_cycles_context, CyclesMetrics
config = CyclesConfig(
base_url="http://localhost:7878",
api_key="your-api-key",
tenant="acme",
)
client = CyclesClient(config)
@cycles(
estimate=lambda prompt, tokens: tokens * 10,
actual=lambda result: len(result) * 5,
action_kind="llm.completion",
action_name="gpt-4",
client=client,
)
def call_llm(prompt: str, tokens: int) -> str:
# Access the reservation context inside the guarded function
ctx = get_cycles_context()
if ctx and ctx.has_caps():
tokens = min(tokens, ctx.caps.max_tokens or tokens)
result = f"Response to: {prompt}"
# Report metrics (included in the commit)
if ctx:
ctx.metrics = CyclesMetrics(tokens_input=tokens, tokens_output=len(result))
return result
result = 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.
Budget lifecycle
The @cycles decorator wraps your function in a reserve → execute → commit/release lifecycle:
| Scenario | Outcome | Detail |
|---|---|---|
| Reservation denied | Neither | BudgetExceededError, OverdraftLimitExceededError, or DebtOutstandingError raised; function never executes |
dry_run=True, any decision |
Neither | Returns DryRunResult or raises; no real reservation created |
| Function returns successfully | Commit | Actual amount charged; unused remainder auto-released |
| Function raises any exception | Release | Full reserved amount returned to budget; exception re-raised |
| Commit fails (5xx / network) | Retry | Exponential backoff with configurable attempts |
| Commit fails (non-retryable 4xx) | Release | Reservation released after non-retryable client error |
| Commit gets RESERVATION_EXPIRED | Neither | Server already reclaimed budget on TTL expiry |
| Commit gets RESERVATION_FINALIZED | Neither | Already committed or released (idempotent replay) |
| Commit gets IDEMPOTENCY_MISMATCH | Neither | Previous commit already processed; no release attempted |
All raised exceptions from the guarded function trigger release. See How Reserve-Commit Works for the full protocol-level explanation.
Programmatic client
from runcycles import (
CyclesClient, CyclesConfig, ReservationCreateRequest,
CommitRequest, Subject, Action, Amount, Unit, CyclesMetrics,
)
config = CyclesConfig(base_url="http://localhost:7878", api_key="your-api-key")
with CyclesClient(config) as client:
# 1. Reserve budget
response = 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,
))
if response.is_success:
reservation_id = response.get_body_attribute("reservation_id")
# 2. Do work ...
# 3. Commit actual usage
client.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
from runcycles import AsyncCyclesClient, CyclesConfig, cycles
config = CyclesConfig(base_url="http://localhost:7878", api_key="your-api-key")
client = AsyncCyclesClient(config)
@cycles(estimate=1000, client=client)
async def call_llm(prompt: str) -> str:
return f"Response to: {prompt}"
# In an async context:
result = await call_llm("Hello")
Configuration
From environment variables
from runcycles import CyclesConfig
config = 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,
)
Default client / config
Instead of passing client= to every @cycles decorator, set a module-level default:
from runcycles import CyclesConfig, 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 client
set_default_client(CyclesClient(CyclesConfig(base_url="http://localhost:7878", api_key="your-key")))
# Now @cycles works without client=
@cycles(estimate=1000)
def my_func() -> str:
return "hello"
Error handling
from runcycles import (
CyclesClient, CyclesConfig, ReservationCreateRequest,
Subject, Action, Amount, Unit,
)
config = CyclesConfig(base_url="http://localhost:7878", api_key="your-key")
with CyclesClient(config) as client:
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),
))
if response.is_transport_error:
print(f"Transport error: {response.error_message}")
elif not response.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:
from runcycles import cycles, BudgetExceededError, CyclesProtocolError
@cycles(estimate=1000, client=client)
def guarded_func() -> str:
return "result"
try:
guarded_func()
except BudgetExceededError:
print("Budget exhausted — degrade or queue")
except CyclesProtocolError as e:
if e.is_retryable() and e.retry_after_ms:
print(f"Retry after {e.retry_after_ms}ms")
print(f"Protocol error: {e}, code: {e.error_code}")
Exception hierarchy:
| Exception | When |
|---|---|
CyclesError |
Base for all Cycles errors |
CyclesProtocolError |
Server returned a protocol-level error |
BudgetExceededError |
Budget insufficient for the reservation |
OverdraftLimitExceededError |
Debt exceeds the overdraft limit |
DebtOutstandingError |
Outstanding debt blocks new reservations |
ReservationExpiredError |
Operating on an expired reservation |
ReservationFinalizedError |
Operating on an already-committed/released reservation |
CyclesTransportError |
Network-level failure (connection, DNS, timeout) |
Preflight checks (decide)
Check whether a reservation would be allowed without creating one:
from runcycles import DecisionRequest, Subject, Action, Amount, Unit
response = 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),
))
if response.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:
from runcycles import EventCreateRequest, Subject, Action, Amount, Unit
response = 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")
if response.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-Id
print(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)
def shadow_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:
from runcycles import CommitOveragePolicy
# 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)
def overdraft_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")
def inner_call():
return "done"
@cycles(estimate=500, action_name="outer")
def outer_call():
return inner_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:
def inner_call(): # no @cycles — called within a guarded operation
return "done"
@cycles(estimate=500, action_name="outer")
def outer_call():
return inner_call() # single reservation — 500 total
Features
- Decorator-based:
@cycleswraps 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, andrate_limit_reseton every response - Environment config:
CyclesConfig.from_env()for 12-factor apps
Examples
The examples/ directory contains runnable integration examples:
| Example | Description |
|---|---|
| basic_usage.py | Programmatic reserve → commit lifecycle |
| decorator_usage.py | @cycles decorator with estimates, caps, and metrics |
| async_usage.py | Async client and async decorator |
| openai_integration.py | Guard OpenAI chat completions with budget checks |
| anthropic_integration.py | Guard Anthropic messages with per-tool budget tracking |
| streaming_usage.py | Budget-managed streaming with token accumulation |
| fastapi_integration.py | FastAPI middleware, dependency injection, per-tenant budgets |
| langchain_integration.py | LangChain callback handler for budget-aware agents |
See examples/README.md for setup instructions.
Development
pip install -e ".[dev]"
# Lint
ruff check .
# Type check (strict mode)
mypy runcycles
# Run tests with coverage (85% 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
- Cycles Documentation — full docs site
- Python Quickstart — getting started guide
- Python Client Configuration Reference — all configuration options
- Error Handling Patterns in Python — handling budget errors
Requirements
- Python 3.10+
- httpx
- pydantic >= 2.0
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file runcycles-0.2.0.tar.gz.
File metadata
- Download URL: runcycles-0.2.0.tar.gz
- Upload date:
- Size: 49.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d2b7a5ca1d815b786f4bbae63e86f300086a2cc0ec4fce42808c6ba76d0120d7
|
|
| MD5 |
4c9b6126637bf5a9839ba44b00237e17
|
|
| BLAKE2b-256 |
80c992db9ad278ba0f8ad93a3c8473b66c1bd19ad62315cad8f2dfcd874ce0d4
|
Provenance
The following attestation bundles were made for runcycles-0.2.0.tar.gz:
Publisher:
python-publish.yml on runcycles/cycles-client-python
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
runcycles-0.2.0.tar.gz -
Subject digest:
d2b7a5ca1d815b786f4bbae63e86f300086a2cc0ec4fce42808c6ba76d0120d7 - Sigstore transparency entry: 1175533095
- Sigstore integration time:
-
Permalink:
runcycles/cycles-client-python@b16772e6aefce01bbdb45c0cffe4f6edba448e55 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/runcycles
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@b16772e6aefce01bbdb45c0cffe4f6edba448e55 -
Trigger Event:
push
-
Statement type:
File details
Details for the file runcycles-0.2.0-py3-none-any.whl.
File metadata
- Download URL: runcycles-0.2.0-py3-none-any.whl
- Upload date:
- Size: 28.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
98361dc8b6818eeeebdc30c0adb79f202132f0fadcb737d3417d5aa3a440f6d1
|
|
| MD5 |
ae1e1858508b1b2940235bc27d69d3d3
|
|
| BLAKE2b-256 |
0ac941f2791169d8374c09b76412c8f122163b4f8ef576a5592188484b85edcc
|
Provenance
The following attestation bundles were made for runcycles-0.2.0-py3-none-any.whl:
Publisher:
python-publish.yml on runcycles/cycles-client-python
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
runcycles-0.2.0-py3-none-any.whl -
Subject digest:
98361dc8b6818eeeebdc30c0adb79f202132f0fadcb737d3417d5aa3a440f6d1 - Sigstore transparency entry: 1175533166
- Sigstore integration time:
-
Permalink:
runcycles/cycles-client-python@b16772e6aefce01bbdb45c0cffe4f6edba448e55 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/runcycles
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@b16772e6aefce01bbdb45c0cffe4f6edba448e55 -
Trigger Event:
push
-
Statement type: