OpenAI Agents SDK spending limit, one ceiling per job
Pythonopenai-agents 0.22.3A RunHooks class that asks AgentBill before each model request Runner.run makes, with the job's task_ref. When the request would pass the job's ceiling, the SDK raises inside the hook, the request does not go out, and the error comes out of Runner.run to your code.
Keep OpenAI's spend limits and max_turns
OpenAI's hard spend limits are monthly amounts set for the organization or for a project, and a request past one is answered with a 429. Their documentation is plain about the timing:
“Enforcement is not instantaneous, so recorded spend can slightly exceed the configured amount.”
Turn them on. Runner.run also takes max_turns, which caps how many turns one run may take. Both are bound to something real: an organization or project over a month, or one run's turn count. The ceiling below is bound to a task_ref, the name of the job, across every run, agent and process that passes it, in units you assign.
Install
pip install agentbill-sdk "openai-agents==0.22.3"
Give the job its ceiling before it runs, in the console or with PUT /tasks/:task_ref/ceiling. The samples on this page were run end to end against openai-agents 0.22.3 and agentbill-sdk 0.6.5, with a stub model in place of a provider.
The hooks
on_llm_start is awaited before each model request, so a refusal raised there is raised before the request is made. on_llm_end settles the units once the response is back.
import asyncio import os from agents import RunHooks from agentbill import AgentBillClient client = AgentBillClient(api_key=os.environ["AGENTBILL_API_KEY"]) UNITS = 12 # what one model call is worth to you, in your own units class AgentBillCeiling(RunHooks): def __init__(self, task_ref: str): self.task_ref = task_ref # the job's name, e.g. "job-142" async def on_llm_start(self, context, agent, system_prompt, input_items): # Raises TaskCeilingExceededError when this request would pass the # job's ceiling, and Runner.run raises it to your code. await asyncio.to_thread(lambda: client.preflight( agent_id=agent.name, task_ref=self.task_ref, estimated_units=UNITS)) async def on_llm_end(self, context, agent, response): await asyncio.to_thread(lambda: client.record( agent_id=agent.name, task_ref=self.task_ref, units=UNITS))
The client is synchronous, so each call runs in a thread and the event loop keeps going while AgentBill answers.
Run a job, and decide what a refusal means
from agents import Agent, Runner
from agentbill import TaskCeilingExceededError
agent = Agent(name="researcher", instructions="Research the topic you are given.", tools=tools)
async def main():
try:
result = await Runner.run(agent, "Research the topic", hooks=AgentBillCeiling("job-142"))
print(result.final_output)
except TaskCeilingExceededError as e:
# Your code decides: keep what you have, retry smaller, or tell a person.
print(f"{e.task_ref} was refused at {e.task_used_units}/{e.task_ceiling} units")
asyncio.run(main())tools is your tool list. The exception carries task_ref, task_ceiling, task_used_units and task_remaining_units, so the handler can say what happened without a second call.
Handoffs and several agents
Hooks passed to Runner.run apply to the whole run, so an agent the run hands off to asks the same ceiling. agent.name goes to AgentBill as the agent_id, which the console groups spend and refusals by; it carries no budget of its own. The ceiling is the job's.
Parallel runs and several workers
Pass the same task_ref from every run and every process on the job. Each reservation is one conditional UPDATE on the job's row, so when the last units remain and two requests ask at once, one is approved and the other is refused.
If a model request fails
on_llm_end runs when a response comes back. When the request raises instead, the reservation is not settled and is held until it expires, 60 minutes by default, and then its units come back. In that window the job's ceiling is tighter, never looser.
Without the Agents SDK
Calling the Responses API or Chat Completions directly, the same pair goes around the call: preflight before it with the job's task_ref, record after it. How to cap what one agent run can spend has that version, in Python and Node.
What it does not do
- It does not count tokens. UNITS is the number you assign to one model request.
- It does not read your OpenAI bill.
- It does not end the run. Preflight answers, the SDK raises, and your code decides.
- A refusal from AgentBill's own monthly quota is returned, not raised, so these hooks let that request run.