Skip to content

Commit ae800c5

Browse files
ashwin-antclaude
andauthored
feat: add max_budget_usd option to Python SDK (#293)
Add support for limiting API costs using the max_budget_usd option, mirroring the TypeScript SDK functionality. When the budget is exceeded, query execution stops and returns a result with subtype 'error_max_budget_usd'. - Add max_budget_usd field to ClaudeAgentOptions - Pass --max-budget-usd flag to Claude Code CLI - Add test coverage for budget limit behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <[email protected]>
1 parent 7be296f commit ae800c5

File tree

4 files changed

+169
-0
lines changed

4 files changed

+169
-0
lines changed

examples/max_budget_usd.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
#!/usr/bin/env python3
2+
"""Example demonstrating max_budget_usd option for cost control."""
3+
4+
import anyio
5+
6+
from claude_agent_sdk import (
7+
AssistantMessage,
8+
ClaudeAgentOptions,
9+
ResultMessage,
10+
TextBlock,
11+
query,
12+
)
13+
14+
15+
async def without_budget():
16+
"""Example without budget limit."""
17+
print("=== Without Budget Limit ===")
18+
19+
async for message in query(prompt="What is 2 + 2?"):
20+
if isinstance(message, AssistantMessage):
21+
for block in message.content:
22+
if isinstance(block, TextBlock):
23+
print(f"Claude: {block.text}")
24+
elif isinstance(message, ResultMessage):
25+
if message.total_cost_usd:
26+
print(f"Total cost: ${message.total_cost_usd:.4f}")
27+
print(f"Status: {message.subtype}")
28+
print()
29+
30+
31+
async def with_reasonable_budget():
32+
"""Example with budget that won't be exceeded."""
33+
print("=== With Reasonable Budget ($0.10) ===")
34+
35+
options = ClaudeAgentOptions(
36+
max_budget_usd=0.10, # 10 cents - plenty for a simple query
37+
)
38+
39+
async for message in query(prompt="What is 2 + 2?", options=options):
40+
if isinstance(message, AssistantMessage):
41+
for block in message.content:
42+
if isinstance(block, TextBlock):
43+
print(f"Claude: {block.text}")
44+
elif isinstance(message, ResultMessage):
45+
if message.total_cost_usd:
46+
print(f"Total cost: ${message.total_cost_usd:.4f}")
47+
print(f"Status: {message.subtype}")
48+
print()
49+
50+
51+
async def with_tight_budget():
52+
"""Example with very tight budget that will likely be exceeded."""
53+
print("=== With Tight Budget ($0.0001) ===")
54+
55+
options = ClaudeAgentOptions(
56+
max_budget_usd=0.0001, # Very small budget - will be exceeded quickly
57+
)
58+
59+
async for message in query(
60+
prompt="Read the README.md file and summarize it", options=options
61+
):
62+
if isinstance(message, AssistantMessage):
63+
for block in message.content:
64+
if isinstance(block, TextBlock):
65+
print(f"Claude: {block.text}")
66+
elif isinstance(message, ResultMessage):
67+
if message.total_cost_usd:
68+
print(f"Total cost: ${message.total_cost_usd:.4f}")
69+
print(f"Status: {message.subtype}")
70+
71+
# Check if budget was exceeded
72+
if message.subtype == "error_max_budget_usd":
73+
print("⚠️ Budget limit exceeded!")
74+
print(
75+
"Note: The cost may exceed the budget by up to one API call's worth"
76+
)
77+
print()
78+
79+
80+
async def main():
81+
"""Run all examples."""
82+
print("This example demonstrates using max_budget_usd to control API costs.\n")
83+
84+
await without_budget()
85+
await with_reasonable_budget()
86+
await with_tight_budget()
87+
88+
print(
89+
"\nNote: Budget checking happens after each API call completes,\n"
90+
"so the final cost may slightly exceed the specified budget.\n"
91+
)
92+
93+
94+
if __name__ == "__main__":
95+
anyio.run(main)

src/claude_agent_sdk/_internal/transport/subprocess_cli.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ def _build_command(self) -> list[str]:
115115
if self._options.max_turns:
116116
cmd.extend(["--max-turns", str(self._options.max_turns)])
117117

118+
if self._options.max_budget_usd is not None:
119+
cmd.extend(["--max-budget-usd", str(self._options.max_budget_usd)])
120+
118121
if self._options.disallowed_tools:
119122
cmd.extend(["--disallowedTools", ",".join(self._options.disallowed_tools)])
120123

src/claude_agent_sdk/types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,7 @@ class ClaudeAgentOptions:
518518
continue_conversation: bool = False
519519
resume: str | None = None
520520
max_turns: int | None = None
521+
max_budget_usd: float | None = None
521522
disallowed_tools: list[str] = field(default_factory=list)
522523
model: str | None = None
523524
permission_prompt_tool_name: str | None = None

tests/test_integration.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,73 @@ async def mock_receive():
212212
assert call_kwargs["options"].continue_conversation is True
213213

214214
anyio.run(_test)
215+
216+
def test_max_budget_usd_option(self):
217+
"""Test query with max_budget_usd option."""
218+
219+
async def _test():
220+
with patch(
221+
"claude_agent_sdk._internal.client.SubprocessCLITransport"
222+
) as mock_transport_class:
223+
mock_transport = AsyncMock()
224+
mock_transport_class.return_value = mock_transport
225+
226+
# Mock the message stream that exceeds budget
227+
async def mock_receive():
228+
yield {
229+
"type": "assistant",
230+
"message": {
231+
"role": "assistant",
232+
"content": [
233+
{"type": "text", "text": "Starting to read..."}
234+
],
235+
"model": "claude-opus-4-1-20250805",
236+
},
237+
}
238+
yield {
239+
"type": "result",
240+
"subtype": "error_max_budget_usd",
241+
"duration_ms": 500,
242+
"duration_api_ms": 400,
243+
"is_error": False,
244+
"num_turns": 1,
245+
"session_id": "test-session-budget",
246+
"total_cost_usd": 0.0002,
247+
"usage": {
248+
"input_tokens": 100,
249+
"output_tokens": 50,
250+
},
251+
}
252+
253+
mock_transport.read_messages = mock_receive
254+
mock_transport.connect = AsyncMock()
255+
mock_transport.close = AsyncMock()
256+
mock_transport.end_input = AsyncMock()
257+
mock_transport.write = AsyncMock()
258+
mock_transport.is_ready = Mock(return_value=True)
259+
260+
# Run query with very small budget
261+
messages = []
262+
async for msg in query(
263+
prompt="Read the readme",
264+
options=ClaudeAgentOptions(max_budget_usd=0.0001),
265+
):
266+
messages.append(msg)
267+
268+
# Verify results
269+
assert len(messages) == 2
270+
271+
# Check result message
272+
assert isinstance(messages[1], ResultMessage)
273+
assert messages[1].subtype == "error_max_budget_usd"
274+
assert messages[1].is_error is False
275+
assert messages[1].total_cost_usd == 0.0002
276+
assert messages[1].total_cost_usd is not None
277+
assert messages[1].total_cost_usd > 0
278+
279+
# Verify transport was created with max_budget_usd option
280+
mock_transport_class.assert_called_once()
281+
call_kwargs = mock_transport_class.call_args.kwargs
282+
assert call_kwargs["options"].max_budget_usd == 0.0001
283+
284+
anyio.run(_test)

0 commit comments

Comments
 (0)