BeeAI Framework budget control with SpendGuard
Your BeeAI
ReActAgentrunsawait agent.run(prompt)against an open-ended tool loop, fanning out across multiple LLM calls per turn. Without a gate, you find out the cost on next month’s billing dashboard. SpendGuard plugs a single subscriber into BeeAI viasubscribe_spendguard(agent, client, ...)so every LLM step reserves against a budget before the upstream call goes out — and the same subscriber works forOpenAIChatModel,WatsonxChatModel,OllamaChatModel,GroqChatModel, or any otherChatModelyou wire into the agent.
Why you’d want this
Section titled “Why you’d want this”- One subscriber, every backend. BeeAI’s
ChatModelabstraction is plural (OpenAI / Watsonx / Ollama / Groq / custom). Gating at the agent-runtimeEmitterboundary means a singlesubscribe_spendguardcall covers all of them; you don’t write per-vendor wrappers. - Pre-call refusal, not post-hoc accounting. DENY surfaces as
DecisionDenied; BeeAI’sEmitter._invokewraps it asEmitterErrorpreserving__cause__, so the model HTTP is never issued and yourtry/exceptcan catch by either type. - Provider-agnostic by shape, not by string. Reads
usage.total_tokens(withprompt_tokens + completion_tokensfallback) from the*.successpayload — no model-string parsing required. - Audit + approval pipeline shared with every other framework.
The subscriber writes to the same SpendGuard ledger as the
LangChain, Pydantic-AI, OpenAI Agents, Google ADK, AWS Strands,
DSPy, and Agno integrations. The shared
spendguard_run_contextcontextvar means a parent LangChain run wrapping a BeeAI agent reuses the samerun_id.
Setup (60 seconds)
Section titled “Setup (60 seconds)”pip install 'spendguard-sdk[beeai]'Bring up a sidecar via the demo stack:
git clone https://github.com/m24927605/agentic-spendguard.gitcd agentic-spendguard && make demo-upWire it up
Section titled “Wire it up”import asyncio
from beeai_framework.agents.react import ReActAgentfrom beeai_framework.backend.chat import ChatModel
from spendguard import SpendGuardClient, new_uuid7from spendguard.integrations.beeai import ( RunContext, run_context, subscribe_spendguard,)from spendguard._proto.spendguard.common.v1 import common_pb2
async def main() -> None: client = SpendGuardClient( socket_path="/var/run/spendguard/adapter.sock", tenant_id="00000000-0000-4000-8000-000000000001", ) await client.connect() await client.handshake()
unit = common_pb2.UnitRef( unit_id="usd_micros", token_kind="output_token", model_family="gpt-4", ) pricing = common_pb2.PricingFreeze(pricing_version="2026-q2")
llm = ChatModel.from_name("openai:gpt-4o-mini") agent = ReActAgent(llm=llm, tools=[])
unsubscribe = subscribe_spendguard( agent, client, budget_id="my-budget", window_instance_id="my-window", unit=unit, pricing=pricing, ) try: async with run_context(RunContext(run_id=str(new_uuid7()))): result = await agent.run("Say hello in three words.") print(result.result.text) finally: unsubscribe()
asyncio.run(main())Hold the unsubscribe. subscribe_spendguard returns a no-arg
callable that unhooks the listener from the agent’s Emitter. The
adapter is NOT idempotent in that calling subscribe_spendguard a
second time on the same agent installs a second listener — always
call the returned unsubscribe() in a finally: block.
How it works
Section titled “How it works”BeeAI’s Emitter publishes events on hierarchical paths like
agent.react.llm.<uuid>.start, .success, and .error. The
subscriber registers ONE predicate on agent.emitter.match that
fires for any event whose name is start / success / error
AND whose path contains an llm segment — covering ReActAgent,
Workflow, and any other agent that publishes under llm.*.
For each start event:
- The handler derives a stable per-call key
(
EventMeta.pathwith the trailing.startsegment stripped), a deterministicllm_call_id+decision_id(distinct scopes underderive_uuid_from_signature), and a SpendGuardRequestDecision(LLM_CALL_PRE)envelope. client.request_decision(...)runs BEFORE any provider HTTP. OnCONTINUE, the reservation is stashed in a bounded FIFO inflight map (10 000 entry cap, one-shot warning on eviction). On DENY,DecisionDeniedpropagates — BeeAI’sEmitter._invokewraps it asEmitterErrorpreserving__cause__, and the model HTTP is never reached.- The matching
successevent pops the inflight slot and callsclient.emit_llm_call_post(SUCCESS, total_tokens)with the realusage.total_tokensfrom the provider response. - An
errorevent pops the slot and callsemit_llm_call_post(PROVIDER_ERROR, 0)so the reservation is released rather than abandoned to TTL-sweep.
Claim estimator
Section titled “Claim estimator”If you omit claim_estimator=, the subscriber wires in the shared
langchain_default_claim_estimator keyed on the live model_id
extracted from each event payload. That covers OpenAI / Anthropic /
Gemini / Cohere / Llama models out of the box via the bundled
tokenizers.
For custom behaviour, pass claim_estimator= with a
(BeeAiStartEvent) → list[BudgetClaim] callable:
from spendguard.integrations.beeai import BeeAiStartEvent
def my_estimator(ev: BeeAiStartEvent) -> list[common_pb2.BudgetClaim]: # Project a flat 500-token reservation per call. return [ common_pb2.BudgetClaim( budget_id="my-budget", unit=common_pb2.UnitRef(unit_id="usd_micros"), amount_atomic="500", direction=common_pb2.BudgetClaim.DEBIT, window_instance_id="my-window", ) ]
unsubscribe = subscribe_spendguard( agent, client, budget_id="my-budget", window_instance_id="my-window", unit=unit, pricing=pricing, claim_estimator=my_estimator,)run_context rule
Section titled “run_context rule”Every BeeAI *.start event MUST fire inside an active
run_context(...) block. The handler raises RuntimeError with an
actionable message otherwise — wrap your agent.run(...) call:
async with run_context(RunContext(run_id=str(new_uuid7()))): result = await agent.run("...")The spendguard_run_context ContextVar is SHARED across every
SpendGuard adapter (LangChain, Pydantic-AI, OpenAI Agents, Agno,
DSPy, ADK, Strands, BeeAI). A parent LangChain run wrapping a BeeAI
agent reuses the same run_id without explicit threading.
Troubleshooting
Section titled “Troubleshooting”ImportError on spendguard.integrations.beeai. You missed the
[beeai] extra. Reinstall:
pip install 'spendguard-sdk[beeai]'The extra pins beeai-framework>=0.1.81,<0.2 — the actual PyPI
release line at the time of writing (the spec’s >=0.3,<1.0 cap
predates the project’s first PyPI publish under that name; see
DEVIATION-A in the module docstring).
RuntimeError: subscriber fired outside an active run_context().
You’re calling agent.run(...) without the
async with run_context(...) wrap. Bind a fresh RunContext (with
a freshly minted run_id) per top-level run.
DEGRADE behaviour. Mutation patches are NOT applied (parity with
LangChain / Pydantic-AI / Agno). The handler stashes the inflight
slot and lets the call proceed; the eventual *.success commits at
the projected envelope.
Out of scope
Section titled “Out of scope”- Tool-call gating.
subscribe_spendguardfilters by the presence of anllmsegment in the event path;tool.*paths fall through to theintegrations.agtterritory. - Mid-stream gating.
newToken/partialUpdateevents are silently ignored; commit only fires onsuccess. - TypeScript adapter. Tier 3 is Python-only per the build plan §2.3; the TS port is deferred.