Runtime budget, action, and audit authority for the OpenAI Agents SDK — enforce LLM cost limits, tool call caps, and audit trails before execution.
Project description
OpenAI Agents SDK Budget Control — Cycles integration for Python
Runtime budget, action, and audit authority for the OpenAI Agents SDK — enforce LLM cost limits, tool call caps, action permissions, and audit trails on Python AI agents before execution. Wraps OpenAI Agents SDK hooks and guardrails with the Cycles Protocol reservation lifecycle: per-tenant budgets, tool risk scoring, pre-run checks, and structured audit trails. Install via pip install runcycles-openai-agents.
Prerequisites
Before you begin, make sure you have:
- Python 3.10+
- An OpenAI API key — required by the OpenAI Agents SDK to call LLMs
- A running Cycles server — see the deployment guide to set one up
- A Cycles API key — see API key management
- A tenant and budget — see tenant management and budget allocation
New to Cycles? The end-to-end tutorial walks through the full setup — from deploying the server to making your first budget-guarded API call — in about 10 minutes.
Why
The OpenAI Agents SDK gives you hooks and guardrails for content safety, but nothing for governance or action authority. Without Cycles governance:
- A retry loop burns through $47 of API calls before anyone notices.
- An agent with a
send_emailtool sends 200 emails in a single run because nothing limits it. - You can't give Tenant A a $10/day budget and Tenant B a $100/day budget — every tenant gets unlimited access.
- There's no audit trail showing which agent called which tool, how many tokens it used, or what was consumed.
This plugin fixes all of that with one line:
result = await CyclesRunHooks(tenant="acme").run(agent, input="...")
Every LLM call and every tool call in the entire agent run — including handoffs to sub-agents — automatically reserves budget before execution and commits actual usage after. If the budget is exhausted, the agent stops. No per-function decoration. No code changes to your tools.
What It Does
| Problem | How This Solves It |
|---|---|
| Runaway LLM spending | Every LLM call reserves budget before running. DENY = agent stops. |
| Uncontrolled tool actions | Tool estimate map assigns per-call estimates (send_email: 50, search: 0). Higher-estimate tools consume budget faster. |
| No per-tenant limits | Pass tenant="acme" — Cycles enforces per-tenant budgets server-side. |
| No pre-run check | cycles_budget_guardrail calls /v1/decide before the agent starts. Zero tokens consumed on DENY. |
| No audit trail | Every reservation, commit, and handoff is recorded in the Cycles ledger. |
| Long-running or abandoned calls | TTL heartbeats extend active reservations for at most 10 minutes by default. Cleanup releases known reservations; a missed cleanup path still degrades to TTL expiry. |
Installation
pip install runcycles-openai-agents
Setup
Set the following environment variables before running your agent:
# Required — OpenAI Agents SDK needs this to call LLMs
export OPENAI_API_KEY=sk-...
# Required — tells the plugin where your Cycles server is
export CYCLES_BASE_URL=http://localhost:7878
export CYCLES_API_KEY=cyc_live_...
Quick Start
from agents import Agent
from runcycles_openai_agents import CyclesRunHooks, cycles_budget_guardrail
# Pre-run budget check — agent never starts if budget exhausted
guardrail = cycles_budget_guardrail(tenant="acme-corp", estimate=5_000_000)
# Runtime governance — every tool/LLM call goes through Cycles
hooks = CyclesRunHooks(
tenant="acme-corp",
app="support-platform",
tool_estimates={
"send_email": 50, # 50 RISK_POINTS per call
"update_crm": 10, # 10 RISK_POINTS per call
"search_knowledge": 0, # zero estimate — no reservation
},
)
agent = Agent(
name="case-resolver",
instructions="You resolve support cases.",
input_guardrails=[guardrail],
)
result = await hooks.run(agent, input="...")
Hook lifecycle
The hooks plug into the SDK's native RunHooks interface and govern the entire agent run. Use hooks.run(...) for non-streaming runs or hooks.run_streamed(...) for streaming runs so cleanup is attached at the SDK run-finalization boundary:
| Hook | Cycles API Call | Blocking | Detail |
|---|---|---|---|
on_tool_start |
create_reservation (tool estimate) |
Raises on DENY | Budget reserved based on tool estimate map |
on_tool_end |
commit_reservation |
No | Actual amount committed |
on_llm_start |
create_reservation (LLM estimate) |
Raises on DENY | Budget reserved before each LLM call |
on_llm_end |
commit_reservation (actual tokens) |
No | Real token count from response.usage committed |
on_handoff |
create_event (audit trail) |
No | Handoff recorded in Cycles ledger |
All raised exceptions from budget denial trigger BudgetExceededError. See Error Handling Patterns in Python for details.
Error handling
CyclesRunHooks.run() releases reservations scoped to that run before re-raising an exception or asyncio.CancelledError:
hooks = CyclesRunHooks(tenant="acme-corp", app="support-platform")
result = await hooks.run(agent, input="...")
Streaming runs receive the same protection. Consume the returned proxy's events, or call its synchronous cancel() method; either path waits for or schedules run-scoped cleanup:
result = hooks.run_streamed(agent, input="...")
async for event in result.stream_events():
handle(event)
The OpenAI Agents SDK does not expose a general RunHooks.on_error callback. If you call bare Runner.run(..., hooks=hooks) or Runner.run_streamed(..., hooks=hooks), automatic exception/cancellation cleanup cannot run. In a single-run error path, call release_pending(); when multiple runs are pending it raises instead of guessing and releasing another run. Prefer the wrappers for concurrent runs because they carry a stable run ID into scoped cleanup. release_all_pending() is the explicit application-shutdown escape hatch. Heartbeats are capped at 10 minutes by default, so even a missed cleanup path eventually falls back to reservation TTL expiry instead of extending forever.
Commit failures are isolated from later operations. A reservation leaves active start/end correlation before its commit is attempted: ordinary client rejections are released immediately, already-finalized and idempotency-mismatch responses are retired without an unsafe release, and exhausted ambiguous failures remain available only for run cleanup. A later LLM or tool completion therefore cannot accidentally reuse the failed reservation or attach new usage metrics to it.
When budget is denied, the hooks raise BudgetExceededError:
from runcycles import BudgetExceededError
try:
result = await hooks.run(agent, input="...")
except BudgetExceededError as e:
print(f"Budget denied: {e}")
# Agent stopped — no further tokens consumed
Guardrail (pre-run check)
cycles_budget_guardrail returns an InputGuardrail that calls /v1/decide before the agent starts. If the tenant is suspended or budget is exhausted, the guardrail trips and the agent never runs — zero tokens consumed:
from runcycles_openai_agents import cycles_budget_guardrail
guardrail = cycles_budget_guardrail(
tenant="acme-corp",
estimate=5_000_000, # expected total run estimate
unit=Unit.USD_MICROCENTS,
fail_open=True, # allow if Cycles server is down
)
agent = Agent(name="bot", input_guardrails=[guardrail])
Tool estimate mapping
Define an estimate policy once. New tools added to the agent get a default estimate automatically:
from runcycles_openai_agents import ToolEstimateMap, ToolEstimateConfig
hooks = CyclesRunHooks(
tenant="acme-corp",
tool_estimates=ToolEstimateMap(
mapping={
"send_email": 50, # 50 RISK_POINTS (default unit)
"update_crm": ToolEstimateConfig(
estimate=10,
action_kind="tool.crm.update",
unit=Unit.RISK_POINTS, # explicit unit
),
"search_knowledge": 0, # zero estimate — no reservation
},
default_estimate=1, # unmapped tools: 1 RISK_POINT
default_unit=Unit.RISK_POINTS, # unit for int shorthand values
),
)
Configuration
Explicit client
from runcycles import CyclesConfig, AsyncCyclesClient
from runcycles_openai_agents import CyclesRunHooks
config = CyclesConfig(base_url="http://localhost:7878", api_key="cyc_live_...")
client = AsyncCyclesClient(config)
hooks = CyclesRunHooks(client=client, tenant="acme-corp")
Fail-open / fail-closed
Hooks are fail-closed by default (fail_open=False): if the Cycles server is unreachable, the governed operation is blocked. Availability-first behavior remains an explicit opt-in:
hooks = CyclesRunHooks(tenant="acme", fail_open=True)
All options
CyclesRunHooks(
client=None, # AsyncCyclesClient (or auto-created from config/env)
config=None, # CyclesConfig (creates client if no client given)
tenant="acme-corp", # Subject.tenant
workspace="prod", # Subject.workspace
app="support-platform", # Subject.app
workflow="case-resolution", # Subject.workflow
agent="case-resolver", # Subject.agent (overridden by actual agent name)
toolset=None, # Subject.toolset (overridden by tool name)
tool_estimates={"email": 50}, # dict or ToolEstimateMap (default unit: RISK_POINTS)
default_tool_estimate=1, # estimate for unmapped tools (in default unit)
llm_estimate=500_000, # per-LLM-call estimate (~$0.005 in USD_MICROCENTS)
llm_unit=Unit.USD_MICROCENTS,
fail_open=False, # block execution if Cycles is down (default)
ttl_ms=60_000, # reservation TTL (heartbeat extends at half-interval)
heartbeat_max_age_ms=600_000, # stop extending after 10 minutes
heartbeat_max_extensions=None, # optional additional extension-count cap
commit_max_attempts=2, # inline retries for transport, 429, and 5xx commit failures
overage_policy=CommitOveragePolicy.ALLOW_IF_AVAILABLE,
dry_run=False, # shadow mode — no budget consumed
retry_engine=None, # optional AsyncCommitRetryEngine override (built from client config by default)
)
Features
- Framework-native: Plugs into the SDK's
RunHooksinterface — not function-level decoration - Policy-driven: Define tool estimates once in a map, not per-function
- LLM governance: Every LLM call reserves and commits with real token metrics
- Pre-run guardrail:
/v1/decidecheck before agent starts — zero tokens on DENY - Handoff-aware: Agent handoffs recorded as audit events in the Cycles ledger
- Bounded heartbeat: TTL extension keeps active reservations alive but stops after a configurable maximum age
- Run-finalization cleanup:
hooks.run()andhooks.run_streamed()release run-scoped reservations on exceptions and cancellation; TTL expiry is the fallback - Replay-safe, isolated commits: Retries reuse one deterministic request, and failed settlements cannot poison later operation correlation
- Durable settlement: Commits that exhaust inline retries are journaled by the runcycles 0.5.0 retry engine (exponential backoff, replay across restarts,
POST /v1/eventsfallback for expired reservations) — spent budget is never released - Fail-closed by default: Cycles transport and HTTP errors block governed operations;
fail_open=Trueis explicit opt-in - Environment config:
CYCLES_BASE_URL+CYCLES_API_KEYfor zero-config setup - Typed exceptions:
BudgetExceededErrorfor precise error handling
Examples
The examples/ directory contains runnable integration examples:
| Example | Description |
|---|---|
| basic_budget.py | LLM token budget enforcement |
| tool_governance.py | Tool estimate mapping — higher-estimate tools consume more, read-only tools use zero estimate |
| multi_agent.py | Multi-agent handoff with shared budget and pre-run guardrail |
See examples/README.md for setup instructions.
Development
pip install -e ".[dev]"
# Lint
ruff check .
# Type check (strict mode)
mypy src/runcycles_openai_agents
# Run tests with coverage (95% threshold enforced in CI)
pytest --cov
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 Client — the underlying
runcyclesclient - Cycles Protocol — how reserve-commit works
- Error Handling Patterns — handling budget errors
License
Apache 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_openai_agents-0.3.0.tar.gz.
File metadata
- Download URL: runcycles_openai_agents-0.3.0.tar.gz
- Upload date:
- Size: 49.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
452c8e42eced7a2905083faa24e48f6921b49f53290375540434bdd5e6b94dad
|
|
| MD5 |
584fd2e0a8c27446b009fcf6f59c9afa
|
|
| BLAKE2b-256 |
892e694898a1c7515588b67149bb4b6714aab873738e8d78bbcb219dcc837e0e
|
Provenance
The following attestation bundles were made for runcycles_openai_agents-0.3.0.tar.gz:
Publisher:
python-publish.yml on runcycles/cycles-openai-agents
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
runcycles_openai_agents-0.3.0.tar.gz -
Subject digest:
452c8e42eced7a2905083faa24e48f6921b49f53290375540434bdd5e6b94dad - Sigstore transparency entry: 2259678171
- Sigstore integration time:
-
Permalink:
runcycles/cycles-openai-agents@70666511f7c48e246ff170ae54d78e9c97a0c626 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/runcycles
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@70666511f7c48e246ff170ae54d78e9c97a0c626 -
Trigger Event:
push
-
Statement type:
File details
Details for the file runcycles_openai_agents-0.3.0-py3-none-any.whl.
File metadata
- Download URL: runcycles_openai_agents-0.3.0-py3-none-any.whl
- Upload date:
- Size: 24.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
670f7e1b67fc5154038233e2af40fe664f7fe9662791ea09db8edf1ae9f91349
|
|
| MD5 |
c40312722bfc5241a32102c5cbfb0e91
|
|
| BLAKE2b-256 |
250137257fb90d79595121981dfd84936acb0bd861af5d19056e390e49bb1ea7
|
Provenance
The following attestation bundles were made for runcycles_openai_agents-0.3.0-py3-none-any.whl:
Publisher:
python-publish.yml on runcycles/cycles-openai-agents
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
runcycles_openai_agents-0.3.0-py3-none-any.whl -
Subject digest:
670f7e1b67fc5154038233e2af40fe664f7fe9662791ea09db8edf1ae9f91349 - Sigstore transparency entry: 2259678273
- Sigstore integration time:
-
Permalink:
runcycles/cycles-openai-agents@70666511f7c48e246ff170ae54d78e9c97a0c626 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/runcycles
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@70666511f7c48e246ff170ae54d78e9c97a0c626 -
Trigger Event:
push
-
Statement type: