SmolAgents budget control with SpendGuard
Your SmolAgents
CodeAgentrunsagent.run("...")across many tool-calling steps and you cannot tell which step blew the budget — until the bill arrives. SpendGuard subclasses thesmolagents.ModelABC and wraps an inner Model so everygenerate()call reserves against a budget BEFORE the upstream HTTP fires. One class covers every vendor backend that’s compatible with the SmolAgents Model interface — you only change the inner constructor.
Why you’d want this
Section titled “Why you’d want this”- One wrapper, every backend. SmolAgents’
ModelABC sits ABOVE the vendor SDK boundary.InferenceClientModel(HF Inference API),OpenAIServerModel(vLLM / Ollama / Together / Groq / OpenAI-compatible), andTransformersModel(in-process HuggingFace transformers) are gated identically by oneSpendGuardSmolModelinstance because gating sits at the ABC layer. - Pre-call refusal, not post-hoc accounting. DENY raises
DecisionDenieddirectly out ofgenerate().MultiStepAgent.stephas no framework-side catch on the model path (verified against smolagents 1.26), so the raise reaches theCodeAgent.runcaller cleanly — the upstream model HTTP is never issued. - Both
<1.5and>=1.5agents work. Legacy SmolAgents agents invokemodel(messages, ...); current agents callmodel.generate(...). The wrapper defines both with__call__delegating togenerateso install-time version drift cannot silently bypass the gate. - Audit + approval pipeline shared with every other framework.
The wrapper writes to the same SpendGuard ledger as the LangChain,
Pydantic-AI, OpenAI Agents, Google ADK, AWS Strands, DSPy, Agno,
BeeAI, and AutoGen integrations. The shared
spendguard_run_contextcontextvar (reused fromspendguard.integrations.openai_agents) means a parent OpenAI Agents run wrapping a SmolAgentsCodeAgentreuses the samerun_id.
Two paths — pick the right one
Section titled “Two paths — pick the right one”| If your inner Model is | Install | Integration |
|------------------------|---------|-------------|
| InferenceClientModel (HF Inference API) | pip install 'spendguard-sdk[smolagents]' | SpendGuardSmolModel(inner=InferenceClientModel(...)) — direct wrap |
| OpenAIServerModel (vLLM / Ollama / Together / Groq / OpenAI-compatible) | (same) | SpendGuardSmolModel(inner=OpenAIServerModel(...)) — direct wrap |
| TransformersModel (in-process HF transformers) | (same) | SpendGuardSmolModel(inner=TransformersModel(...)) — direct wrap; token-count only (no GPU-second accounting in this release) |
| LiteLLMModel | pip install spendguard-litellm-shim | Do NOT wrap — use the LiteLLM SDK shim instead. The wrapper REFUSES this combination at construction time to prevent double-gating. |
| step_callbacks=[...] telemetry mirror | (same as wrapper) | spendguard_step_callback(client, run_id=...) — informational only, does NOT gate |
Setup (60 seconds)
Section titled “Setup (60 seconds)”pip install 'spendguard-sdk[smolagents]'The extra resolves smolagents>=1.5,<2. Vendor backends
(huggingface_hub, openai, transformers) are NOT pinned — SmolAgents
itself declares those as appropriate sub-extras and you pick the inner
Model class.
from smolagents import CodeAgent, OpenAIServerModel
from spendguard import SpendGuardClientfrom spendguard.integrations.smolagents import ( SpendGuardSmolModel, spendguard_step_callback, RunContext, run_context,)from spendguard._proto.spendguard.common.v1 import common_pb2
client = SpendGuardClient(socket_path="/var/run/spendguard/sidecar.sock", tenant_id="...")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")
guarded = SpendGuardSmolModel( inner=OpenAIServerModel(model_id="gpt-4o-mini", api_base="https://api.openai.com/v1", api_key="..."), client=client, budget_id="...", window_instance_id="...", unit=unit, pricing=pricing, claim_estimator=lambda messages: [common_pb2.BudgetClaim(...)],)
agent = CodeAgent( model=guarded, tools=[], step_callbacks=[spendguard_step_callback(client, run_id="my-run-1")],)
# CodeAgent.run is SYNC (smolagents Model.generate is sync). The# run_context contextmanager is async — pre-bind the contextvar via an# outer async scope so the sync agent inherits the run_id:async with run_context(RunContext(run_id="my-run-1")): # Drive the agent inside a thread executor so the wrapper's # asyncio.run does not collide with this outer loop: import asyncio, contextvars ctx = contextvars.copy_context() result = await asyncio.get_running_loop().run_in_executor( None, lambda: ctx.run(agent.run, "Say hello in three words."), )That’s it. Every model.generate(...) call now:
- Reserves against the budget via
RequestDecision(LLM_CALL_PRE). - On
ALLOW, calls the inner model (OpenAIServerModel/InferenceClientModel/TransformersModel) — the upstream HTTP fires only after the reservation is confirmed. - On success, commits the reservation with the real
ChatMessage.token_usage.input_tokens + output_tokenscount. - On
DENY, raisesDecisionDenied— yourCodeAgent.runpropagates the error cleanly; the upstream HTTP is never issued.
step_callbacks — informational telemetry only
Section titled “step_callbacks — informational telemetry only”SmolAgents MultiStepAgent accepts step_callbacks: list[Callable].
They fire AFTER each ActionStep / PlanningStep completes —
they cannot deny a pending LLM call and they cannot reserve
before provider HTTP. Useful for telemetry mirroring, not gating.
The spendguard_step_callback(client, run_id=...) helper returns a
sync Callable[[ActionStep | PlanningStep], None] that emits an
informational agent_step audit event. The callable catches every
Exception so a sidecar outage during telemetry cannot abort the
host agent run. The wrapper (SpendGuardSmolModel) is the gating
surface; the helper is for trace symmetry with other frameworks’
post-step hooks.
Why no LiteLLMModel wrap?
Section titled “Why no LiteLLMModel wrap?”SmolAgents ships LiteLLMModel — a Model subclass that routes via
LiteLLM. SpendGuard already gates every litellm.acompletion /
completion / Router.acompletion call in the running interpreter
via the LiteLLM SDK shim (D12).
Wrapping a LiteLLMModel with SpendGuardSmolModel would
double-gate — D12 fires PRE first, then D25 fires PRE again on the
SmolAgents call boundary, producing two reservations per call.
The wrapper refuses this combination at construction time:
>>> SpendGuardSmolModel(inner=LiteLLMModel(...), ...)spendguard.integrations.smolagents.SpendGuardConfigError:SpendGuardSmolModel refuses to wrap a smolagents LiteLLMModel —the D12 LiteLLM SDK shim already gates every litellm.acompletioncall. Use the shim directly: `pip install spendguard-litellm-shim`and let the raw LiteLLMModel call through.Polyglot trace sharing
Section titled “Polyglot trace sharing”The shared spendguard_run_context contextvar is reused from
spendguard.integrations.openai_agents (review-standards §1.3). A
polyglot agent stack — say, an OpenAI Agents Runner.run invoking a
SmolAgents CodeAgent step — shares one run_id across all audit
rows because both adapters read the same module-level contextvar:
from agents import Runner, Agentfrom smolagents import CodeAgent, OpenAIServerModel
from spendguard.integrations.openai_agents import ( SpendGuardAgentsModel, RunContext, run_context,)from spendguard.integrations.smolagents import SpendGuardSmolModel
async with run_context(RunContext(run_id="poly-1")): # ... drive both an openai_agents Agent and a smolagents CodeAgent; # every audit row tagged decision_context.integration=openai_agents # OR decision_context.integration=smolagents, all sharing run_id=poly-1. ...Demo it locally
Section titled “Demo it locally”git clone https://github.com/m24927605/agentic-spendguardcd agentic-spendguardmake demo DEMO_MODE=agent_real_smolagentsThe demo spins up the SpendGuard sidecar + a mock OpenAI-compatible
provider (counting-stub), then drives SpendGuardSmolModel(inner= OpenAIServerModel(...)).generate(...) against the stub. The verify
SQL asserts:
- 1
LLM_CALL_PREdecision row withdecision='ALLOW',route='llm.call', anddecision_context.integration='smolagents'. - 1 paired
LLM_CALL_POSToutcome row withoutcome='SUCCESS'andestimated_amount_atomicmatching the stub’s reported token usage.
Production checklist
Section titled “Production checklist”- Budget pre-allocation. Reserve a budget per tenant / per
CodeAgent.runinvocation;claim_estimatorprojects the expected cost from the message payload. - Run context. Bind one
run_context(RunContext(run_id=...))perCodeAgent.runcall. The samerun_idties together all audit rows the agent produces — across the wrapper, thestep_callbackstelemetry, and any other framework adapters running in the same stack. - Drive from a sync entry point OR pre-bind the contextvar in an
outer async scope and dispatch the sync agent via a thread
executor. SmolAgents
Model.generateis synchronous; the wrapper bridges async sidecar RPCs viaasyncio.run, which raisesSyncInAsyncContextif invoked inside a running event loop. - Per-vendor pricing. SmolAgents inner Models expose
model_iddifferently (InferenceClientModel/OpenAIServerModelset it;TransformersModeldoes not). The wrapper has no default claim_estimator — you pass one explicitly so the projector can resolve unit pricing without depending on a non-standardized attribute.
Source spec
Section titled “Source spec”docs/specs/coverage/D25_smolagents/design.mddocs/specs/coverage/D25_smolagents/implementation.mddocs/specs/coverage/D25_smolagents/review-standards.md