AWS Strands budget control with SpendGuard
Your AWS Strands
Agentjust hit a runaway tool loop on a Claude Sonnet model via Bedrock. Each turn dispatches anotherInvokeModelHTTP request. Without a gate, you find out the cost on next month’s billing dashboard. SpendGuard plugs a singleSpendGuardStrandsHookProviderinto Strands’ typed event bus viahooks=[provider]so every model invocation reserves against a budget before the upstream call goes out — and the same provider works for OpenAI, Gemini, Ollama, or LiteLLM backends with zero changes.
Why you’d want this
Section titled “Why you’d want this”- One provider, every backend. Strands’
Modelabstraction is plural (Bedrock / OpenAI / Anthropic / Gemini / Ollama / LiteLLM). Gating at the agent-runtime boundary means the same provider instance covers all of them; you don’t write per-vendor wrappers. - Pre-call refusal, not post-hoc accounting. DENY raises
DecisionDeniedBEFORE Strands dispatches the model HTTP. The upstream call is never issued — verified by theagent_real_strands_denydemo which asserts counting-stub hits remain unchanged on the DENY turn. - Bedrock-first, but model-agnostic. Reads
result.usagefield shape (Anthropicinput_tokens/output_tokens, OpenAIprompt_tokens/completion_tokens, LiteLLM normalised) — no model-string parsing required. - Audit + approval pipeline shared with every other framework. The provider writes to the same SpendGuard ledger as the LangChain, Pydantic-AI, OpenAI Agents, and Google ADK integrations.
Setup (60 seconds)
Section titled “Setup (60 seconds)”pip install 'spendguard-sdk[strands]'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 strands import Agentfrom strands.models.bedrock import BedrockModel
from spendguard import SpendGuardClientfrom spendguard.integrations.strands import ( SpendGuardStrandsHookProvider,)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="anthropic.claude-3-5-sonnet", ) pricing = common_pb2.PricingFreeze(pricing_version="2026-q2")
def estimate(invocation): # Conservative: reserve 500 atomic per call (above # Sonnet's typical ~30-token short response). return [common_pb2.BudgetClaim( budget_id="my-budget", unit=unit, amount_atomic="500", direction=common_pb2.BudgetClaim.DEBIT, window_instance_id="my-window", )]
def reconcile(invocation, result): usage = result.usage total = ( getattr(usage, "total_tokens", None) or ((getattr(usage, "input_tokens", 0) or 0) + (getattr(usage, "output_tokens", 0) or 0)) ) return [common_pb2.BudgetClaim( budget_id="my-budget", unit=unit, amount_atomic=str(total), direction=common_pb2.BudgetClaim.DEBIT, window_instance_id="my-window", )]
guard = SpendGuardStrandsHookProvider( client=client, budget_id="my-budget", window_instance_id="my-window", unit=unit, pricing=pricing, claim_estimator=estimate, claim_reconciler=reconcile, )
agent = Agent( model=BedrockModel( model_id="anthropic.claude-3-5-sonnet-20241022-v2:0", ), hooks=[guard], ) result = await agent.invoke_async(prompt="Say hello in three words.") print(result.message)
asyncio.run(main())What you get
Section titled “What you get”- Pre-call budget reservation on every
Agent.invoke_async()turn, including tool-loop iterations. - Multi-vendor coverage by shape. Bedrock / OpenAI / Anthropic /
Gemini / Ollama / LiteLLM all extract usage from
result.usagefield shape — no model-string match. - Concurrent-safe. Strands assigns a fresh
invocation_idper attempt; the provider’s stash is keyed by that id soasyncio.gatherover multipleagent.invoke_async()calls never collides. - DEGRADE fails closed by default. Set
SPENDGUARD_STRANDS_FAIL_OPEN=1(env) orfail_closed=False(constructor) for dev-only fail-open behaviour.
Model-backend coverage matrix
Section titled “Model-backend coverage matrix”| Backend | Coverage | Tested in CI | Notes |
|---------|----------|--------------|-------|
| BedrockModel | v1 verified | yes | Anthropic-shape usage (input_tokens / output_tokens). Load-bearing in AWS shops. |
| OpenAIModel | v1 verified | yes | OpenAI-shape usage (prompt_tokens / completion_tokens / total_tokens). |
| AnthropicModel | v1 verified | yes | Same shape as Bedrock-via-Anthropic. |
| LiteLLMModel | v1 verified | yes | LiteLLM normalised shape; covers Gemini / Cohere / Llama-via-LiteLLM. |
| GeminiModel | v1 covered | indirect | Goes through LiteLLM in the recorded fixture matrix. |
| OllamaModel | v1 covered | no | Same shape as OpenAI through Ollama’s compatibility layer; not in the CI matrix. |
Add a new backend = add a fourth row to
tests/integrations/strands/test_hook_provider.py::test_I02_multi_backend_allow_path.
Common patterns
Section titled “Common patterns”Cross-framework run_id correlation
Section titled “Cross-framework run_id correlation”from spendguard.integrations.strands import ( StrandsRunContext, run_context,)
async with run_context(StrandsRunContext(run_id="my-parent-run-1")): result = await agent.invoke_async(prompt="hello")Strands’ event bus already carries invocation_id end-to-end, so a
StrandsRunContext is optional (unlike LangChain / MAF where you
must bind one). Use it only when bridging a Strands run to a parent
trace from another framework.
DENY behavior
Section titled “DENY behavior”When request_decision returns DENY, the provider:
- Raises
DecisionDenieddirectly. - Strands wraps it as
HookExecutionError; callers catch via the__cause__chain:try:await agent.invoke_async(prompt="...")except Exception as exc:if isinstance(exc.__cause__, DecisionDenied):# handle the budget refusal... - The model HTTP is never issued (verified by the
agent_real_strands_denydemo). - Nothing is stashed — no commit or release fires.
DEGRADE behavior
Section titled “DEGRADE behavior”DEGRADE means the sidecar returned a non-CONTINUE outcome the adapter
should not block on (e.g. a temporary downgrade). By default the
provider fails closed and raises SpendGuardDegradeBlocked. Set
fail_closed=False or SPENDGUARD_STRANDS_FAIL_OPEN=1 to allow the
invocation; no commit row will be produced in that case (the
reservation TTL-sweeps).
Tool callbacks
Section titled “Tool callbacks”Per-tool budgets via before_tool / after_tool are out of scope for
v1. Tool cost is bundled into the parent invocation’s
estimator/reconciler. Per-tool gating is tracked as D20.1.
Streaming on_message
Section titled “Streaming on_message”Intra-invocation streaming token gating is not supported in v1; the
commit fires at after_invocation only. The estimator-snapshot
fallback covers the case where result.usage is None (rare with
the standard Strands runtime).
Related
Section titled “Related”- Quickstart — full stack up in 5 minutes
- Contract DSL reference — author allow/stop rules
- Other integrations: Google ADK · Pydantic-AI · LangChain & LangGraph · OpenAI Agents SDK · Microsoft Agent Framework