agentbill-sdk 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentbill-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Usage-based billing for AI agent developers. 3-line integration.
|
|
5
|
+
Project-URL: Homepage, https://github.com/marketinglior-pixel/agentbill
|
|
6
|
+
Project-URL: Repository, https://github.com/marketinglior-pixel/agentbill
|
|
7
|
+
Project-URL: Documentation, https://github.com/marketinglior-pixel/agentbill#readme
|
|
8
|
+
Author-email: AgentBill <marketinglior@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: agents,ai,billing,llm,metering,usage-based
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
25
|
+
Requires-Dist: respx; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# AgentBill
|
|
29
|
+
|
|
30
|
+
**Usage-based billing for AI agents. 3-line integration.**
|
|
31
|
+
|
|
32
|
+
Stop charging flat monthly fees for agents whose costs swing between $2 and $40 per run.
|
|
33
|
+
Stop losing money when a rogue agent loops for 45 minutes at your expense.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## The problem
|
|
38
|
+
|
|
39
|
+
You built an AI agent. It does something valuable. You charge $99/month flat.
|
|
40
|
+
|
|
41
|
+
- A 3-second run costs you $0.80. You made $98.20.
|
|
42
|
+
- A 45-minute recursive loop costs you $140. You lost $41.
|
|
43
|
+
|
|
44
|
+
And you don't find out until your OpenAI invoice arrives.
|
|
45
|
+
|
|
46
|
+
## The fix: 3 lines
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from agentbill import meter
|
|
50
|
+
|
|
51
|
+
@meter(event="research_run", customer_id_from="customer_id", preflight=True)
|
|
52
|
+
async def run_agent(customer_id: str, topic: str) -> str:
|
|
53
|
+
result = await call_your_llm(topic)
|
|
54
|
+
return result
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
That's it. AgentBill now:
|
|
58
|
+
- Checks the customer's credit balance **before** the LLM call (`preflight=True`)
|
|
59
|
+
- Records the credit usage **after** the function succeeds
|
|
60
|
+
- Blocks the call with `BudgetExhaustedError` the moment the customer runs out — no surprise overages
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install agentbill
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npm install agentbill
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Quick start (5 minutes)
|
|
77
|
+
|
|
78
|
+
### 1. Get an API key
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
AGENTBILL_API_KEY=your_key_here
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 2. Decorate your agent
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from agentbill import meter, BudgetExhaustedError
|
|
88
|
+
|
|
89
|
+
# Charge 1 credit per run
|
|
90
|
+
@meter(event="research_run", customer_id_from="customer_id")
|
|
91
|
+
async def run_agent(customer_id: str, topic: str) -> str:
|
|
92
|
+
...
|
|
93
|
+
|
|
94
|
+
# Pre-flight: block BEFORE the LLM call if the customer is out of credits
|
|
95
|
+
@meter(event="research_run", customer_id_from="customer_id", preflight=True)
|
|
96
|
+
async def run_agent_safe(customer_id: str, topic: str) -> str:
|
|
97
|
+
...
|
|
98
|
+
|
|
99
|
+
# Outcome-based: charge credits only if the task succeeded
|
|
100
|
+
@meter(
|
|
101
|
+
event="ticket_resolved",
|
|
102
|
+
customer_id_from="customer_id",
|
|
103
|
+
units=lambda result: 5 if result["resolved"] else 0,
|
|
104
|
+
)
|
|
105
|
+
async def resolve_ticket(customer_id: str, ticket_id: str) -> dict:
|
|
106
|
+
...
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### 3. Handle credit exhaustion
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
try:
|
|
113
|
+
result = await run_agent(customer_id="cust_123", topic="quarterly report")
|
|
114
|
+
except BudgetExhaustedError as e:
|
|
115
|
+
# Show paywall, send upgrade email, pause the agent — your call
|
|
116
|
+
show_paywall(e.customer_id)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 4. Watch your dashboard
|
|
120
|
+
|
|
121
|
+
Open `https://your-instance/dashboard` to see every customer's credit usage in real time:
|
|
122
|
+
|
|
123
|
+
- Credit usage bar (turns red at 80%)
|
|
124
|
+
- Remaining credits
|
|
125
|
+
- BLOCKED badge when limit is hit
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Node.js
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
import { meter, BudgetExhaustedError } from 'agentbill'
|
|
133
|
+
|
|
134
|
+
const runAgent = meter(
|
|
135
|
+
async ({ customerId, topic }: { customerId: string; topic: string }) => {
|
|
136
|
+
const result = await callLLM(topic)
|
|
137
|
+
return result
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
event: 'research_run',
|
|
141
|
+
customerIdFrom: 'customerId',
|
|
142
|
+
preflight: true,
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
await runAgent({ customerId: 'cust_123', topic: 'quarterly report' })
|
|
148
|
+
} catch (e) {
|
|
149
|
+
if (e instanceof BudgetExhaustedError) {
|
|
150
|
+
showPaywall(e.customerId)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Pricing for outcomes, not tokens
|
|
158
|
+
|
|
159
|
+
Most billing tools count *events*. They have no concept of "did the task actually succeed?"
|
|
160
|
+
|
|
161
|
+
AgentBill does. The credit count is a function of the result — you decide what success means:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
# Support agent — charge credits only when the ticket is resolved
|
|
165
|
+
@meter(
|
|
166
|
+
event="ticket_resolved",
|
|
167
|
+
customer_id_from="customer_id",
|
|
168
|
+
units=lambda result: 5 if result["resolved"] else 0,
|
|
169
|
+
)
|
|
170
|
+
async def resolve_ticket(customer_id: str, ticket_id: str) -> dict:
|
|
171
|
+
resolution = await run_support_agent(ticket_id)
|
|
172
|
+
return resolution # {"resolved": True, "summary": "..."}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
# Coding agent — charge credits only when tests pass
|
|
177
|
+
@meter(
|
|
178
|
+
event="code_generated",
|
|
179
|
+
customer_id_from="customer_id",
|
|
180
|
+
units=lambda result: 10 if result["tests_passed"] else 0,
|
|
181
|
+
)
|
|
182
|
+
async def generate_code(customer_id: str, spec: str) -> dict:
|
|
183
|
+
code = await run_coding_agent(spec)
|
|
184
|
+
passed = run_tests(code)
|
|
185
|
+
return {"code": code, "tests_passed": passed}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
# Research agent — charge by volume processed
|
|
190
|
+
@meter(
|
|
191
|
+
event="research_completed",
|
|
192
|
+
customer_id_from="customer_id",
|
|
193
|
+
units=lambda result: result["pages_processed"],
|
|
194
|
+
)
|
|
195
|
+
async def research(customer_id: str, topic: str) -> dict:
|
|
196
|
+
return await run_research_agent(topic)
|
|
197
|
+
# returns {"summary": "...", "pages_processed": 14}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
If credits resolve to `0` — no event is recorded. The customer is not charged. Your margins stay intact.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Why AgentBill? (vs. Metronome / Orb / Stripe)
|
|
205
|
+
|
|
206
|
+
**Metronome and Orb** are excellent for SaaS products. They're built around usage records, pricing tiers, and invoicing. If you're building a database or an API with predictable units — use them.
|
|
207
|
+
|
|
208
|
+
AgentBill is different in two ways:
|
|
209
|
+
|
|
210
|
+
### 1. Pre-flight enforcement
|
|
211
|
+
|
|
212
|
+
Metronome and Orb record usage *after the fact*. They have no way to stop an expensive operation before it starts.
|
|
213
|
+
|
|
214
|
+
AgentBill checks the customer's credit balance **before** the LLM call runs. If they're out — the function never executes. No API call is made. No money is spent.
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
Metronome/Orb: run → bill → (oops, over budget)
|
|
218
|
+
AgentBill: check → [blocked if over budget] → run → bill
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
This matters when a single agent run costs $0.80 on a good day and $43 on a bad one.
|
|
222
|
+
|
|
223
|
+
### 2. Lives inside your function
|
|
224
|
+
|
|
225
|
+
Metronome requires you to emit events from your infrastructure. AgentBill is a decorator — it wraps your function directly and handles everything: pre-flight check, credit deduction, idempotency, error handling.
|
|
226
|
+
|
|
227
|
+
No event pipelines. No webhooks to configure. One line.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Current scope — what AgentBill solves today
|
|
232
|
+
|
|
233
|
+
AgentBill is designed for **atomic, short-running agent tasks** — functions that complete in a single execution and return a deterministic result.
|
|
234
|
+
|
|
235
|
+
**Works well for:**
|
|
236
|
+
- Research runs, report generation, document processing
|
|
237
|
+
- Support ticket resolution (single attempt)
|
|
238
|
+
- Code generation with test validation
|
|
239
|
+
- Any agent function that runs once and returns a clear result
|
|
240
|
+
|
|
241
|
+
**Not yet supported:**
|
|
242
|
+
- **Multi-signal outcomes** — tasks where success is determined by multiple events over time (e.g., a ticket that gets reopened 3 days later)
|
|
243
|
+
- **Long-running workflows** — agents that run for hours or days across multiple steps
|
|
244
|
+
- **Outcome invalidation** — billing reversal when a previously "successful" result is later undone
|
|
245
|
+
|
|
246
|
+
These are real problems. They require a different architecture — event sourcing, state machines, reversal logic. If you're building at that level of complexity, AgentBill's current version isn't the right tool yet.
|
|
247
|
+
|
|
248
|
+
For atomic tasks — it's 3 lines.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## How it works
|
|
253
|
+
|
|
254
|
+
```
|
|
255
|
+
Your agent code
|
|
256
|
+
│
|
|
257
|
+
▼
|
|
258
|
+
@meter decorator
|
|
259
|
+
│
|
|
260
|
+
├─ [preflight=true] GET /budget → is_blocked? → raise BudgetExhaustedError
|
|
261
|
+
│
|
|
262
|
+
├─ Run your function (LLM call happens here)
|
|
263
|
+
│
|
|
264
|
+
├─ [function succeeded] POST /events → record credits used
|
|
265
|
+
│
|
|
266
|
+
└─ Return result
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Credits are recorded **after success only**. If your agent throws, the customer is not charged.
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## API reference
|
|
274
|
+
|
|
275
|
+
### `@meter(event, options)`
|
|
276
|
+
|
|
277
|
+
| Option | Type | Default | Description |
|
|
278
|
+
|---|---|---|---|
|
|
279
|
+
| `event` | `str` | required | Event label, shown in dashboard |
|
|
280
|
+
| `customer_id` | `str` | — | Fixed customer identifier |
|
|
281
|
+
| `customer_id_from` | `str` | — | Name of a function parameter to read customer_id from |
|
|
282
|
+
| `units` | `int \| callable` | `1` | Credits per call, or a function `(result) -> int` returning 0 to skip billing |
|
|
283
|
+
| `preflight` | `bool` | `False` | Check credit balance before running. Blocks immediately if exhausted. |
|
|
284
|
+
| `metadata` | `dict` | — | Static key-value pairs attached to every event |
|
|
285
|
+
|
|
286
|
+
### Exceptions
|
|
287
|
+
|
|
288
|
+
| Exception | When |
|
|
289
|
+
|---|---|
|
|
290
|
+
| `BudgetExhaustedError` | Customer has 0 remaining credits (HTTP 402) |
|
|
291
|
+
| `AgentBillError` | Network error or unexpected server response |
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## Self-hosting
|
|
296
|
+
|
|
297
|
+
```bash
|
|
298
|
+
git clone https://github.com/marketinglior-pixel/agentbill
|
|
299
|
+
cd agentbill
|
|
300
|
+
cp .env.example .env # add your DATABASE_URL and AGENTBILL_API_KEY
|
|
301
|
+
npm install
|
|
302
|
+
npm run dev
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Requires: Node 20+, PostgreSQL 14+
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## Roadmap
|
|
310
|
+
|
|
311
|
+
- [x] Core metering (`POST /events`)
|
|
312
|
+
- [x] Credit balance enforcement (HTTP 402)
|
|
313
|
+
- [x] Pre-flight guardrails (`preflight=True`)
|
|
314
|
+
- [x] Outcome-based billing (`units=lambda`)
|
|
315
|
+
- [x] Live dashboard
|
|
316
|
+
- [ ] Stripe Connect — bill your customers directly
|
|
317
|
+
- [ ] Webhooks — alerts at 80% and 100% credit usage
|
|
318
|
+
- [ ] Multi-signal outcome support
|
|
319
|
+
- [ ] Team accounts
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
## Why not Stripe directly?
|
|
324
|
+
|
|
325
|
+
Stripe's metered billing requires: a product, a price, a customer, a subscription, a subscription item, and then a usage record per event. That's 6 API calls and 47 pages of documentation to charge someone $2.
|
|
326
|
+
|
|
327
|
+
Stripe also has no concept of "did the task succeed?" or "stop before it starts."
|
|
328
|
+
|
|
329
|
+
AgentBill handles all of that behind a single decorator.
|
|
330
|
+
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
Built for developers who ship agents and want to get paid fairly for what they actually deliver.
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# AgentBill
|
|
2
|
+
|
|
3
|
+
**Usage-based billing for AI agents. 3-line integration.**
|
|
4
|
+
|
|
5
|
+
Stop charging flat monthly fees for agents whose costs swing between $2 and $40 per run.
|
|
6
|
+
Stop losing money when a rogue agent loops for 45 minutes at your expense.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## The problem
|
|
11
|
+
|
|
12
|
+
You built an AI agent. It does something valuable. You charge $99/month flat.
|
|
13
|
+
|
|
14
|
+
- A 3-second run costs you $0.80. You made $98.20.
|
|
15
|
+
- A 45-minute recursive loop costs you $140. You lost $41.
|
|
16
|
+
|
|
17
|
+
And you don't find out until your OpenAI invoice arrives.
|
|
18
|
+
|
|
19
|
+
## The fix: 3 lines
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from agentbill import meter
|
|
23
|
+
|
|
24
|
+
@meter(event="research_run", customer_id_from="customer_id", preflight=True)
|
|
25
|
+
async def run_agent(customer_id: str, topic: str) -> str:
|
|
26
|
+
result = await call_your_llm(topic)
|
|
27
|
+
return result
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
That's it. AgentBill now:
|
|
31
|
+
- Checks the customer's credit balance **before** the LLM call (`preflight=True`)
|
|
32
|
+
- Records the credit usage **after** the function succeeds
|
|
33
|
+
- Blocks the call with `BudgetExhaustedError` the moment the customer runs out — no surprise overages
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install agentbill
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install agentbill
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Quick start (5 minutes)
|
|
50
|
+
|
|
51
|
+
### 1. Get an API key
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
AGENTBILL_API_KEY=your_key_here
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 2. Decorate your agent
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from agentbill import meter, BudgetExhaustedError
|
|
61
|
+
|
|
62
|
+
# Charge 1 credit per run
|
|
63
|
+
@meter(event="research_run", customer_id_from="customer_id")
|
|
64
|
+
async def run_agent(customer_id: str, topic: str) -> str:
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
# Pre-flight: block BEFORE the LLM call if the customer is out of credits
|
|
68
|
+
@meter(event="research_run", customer_id_from="customer_id", preflight=True)
|
|
69
|
+
async def run_agent_safe(customer_id: str, topic: str) -> str:
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
# Outcome-based: charge credits only if the task succeeded
|
|
73
|
+
@meter(
|
|
74
|
+
event="ticket_resolved",
|
|
75
|
+
customer_id_from="customer_id",
|
|
76
|
+
units=lambda result: 5 if result["resolved"] else 0,
|
|
77
|
+
)
|
|
78
|
+
async def resolve_ticket(customer_id: str, ticket_id: str) -> dict:
|
|
79
|
+
...
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 3. Handle credit exhaustion
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
try:
|
|
86
|
+
result = await run_agent(customer_id="cust_123", topic="quarterly report")
|
|
87
|
+
except BudgetExhaustedError as e:
|
|
88
|
+
# Show paywall, send upgrade email, pause the agent — your call
|
|
89
|
+
show_paywall(e.customer_id)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### 4. Watch your dashboard
|
|
93
|
+
|
|
94
|
+
Open `https://your-instance/dashboard` to see every customer's credit usage in real time:
|
|
95
|
+
|
|
96
|
+
- Credit usage bar (turns red at 80%)
|
|
97
|
+
- Remaining credits
|
|
98
|
+
- BLOCKED badge when limit is hit
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Node.js
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
import { meter, BudgetExhaustedError } from 'agentbill'
|
|
106
|
+
|
|
107
|
+
const runAgent = meter(
|
|
108
|
+
async ({ customerId, topic }: { customerId: string; topic: string }) => {
|
|
109
|
+
const result = await callLLM(topic)
|
|
110
|
+
return result
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
event: 'research_run',
|
|
114
|
+
customerIdFrom: 'customerId',
|
|
115
|
+
preflight: true,
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await runAgent({ customerId: 'cust_123', topic: 'quarterly report' })
|
|
121
|
+
} catch (e) {
|
|
122
|
+
if (e instanceof BudgetExhaustedError) {
|
|
123
|
+
showPaywall(e.customerId)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Pricing for outcomes, not tokens
|
|
131
|
+
|
|
132
|
+
Most billing tools count *events*. They have no concept of "did the task actually succeed?"
|
|
133
|
+
|
|
134
|
+
AgentBill does. The credit count is a function of the result — you decide what success means:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
# Support agent — charge credits only when the ticket is resolved
|
|
138
|
+
@meter(
|
|
139
|
+
event="ticket_resolved",
|
|
140
|
+
customer_id_from="customer_id",
|
|
141
|
+
units=lambda result: 5 if result["resolved"] else 0,
|
|
142
|
+
)
|
|
143
|
+
async def resolve_ticket(customer_id: str, ticket_id: str) -> dict:
|
|
144
|
+
resolution = await run_support_agent(ticket_id)
|
|
145
|
+
return resolution # {"resolved": True, "summary": "..."}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
# Coding agent — charge credits only when tests pass
|
|
150
|
+
@meter(
|
|
151
|
+
event="code_generated",
|
|
152
|
+
customer_id_from="customer_id",
|
|
153
|
+
units=lambda result: 10 if result["tests_passed"] else 0,
|
|
154
|
+
)
|
|
155
|
+
async def generate_code(customer_id: str, spec: str) -> dict:
|
|
156
|
+
code = await run_coding_agent(spec)
|
|
157
|
+
passed = run_tests(code)
|
|
158
|
+
return {"code": code, "tests_passed": passed}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
# Research agent — charge by volume processed
|
|
163
|
+
@meter(
|
|
164
|
+
event="research_completed",
|
|
165
|
+
customer_id_from="customer_id",
|
|
166
|
+
units=lambda result: result["pages_processed"],
|
|
167
|
+
)
|
|
168
|
+
async def research(customer_id: str, topic: str) -> dict:
|
|
169
|
+
return await run_research_agent(topic)
|
|
170
|
+
# returns {"summary": "...", "pages_processed": 14}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
If credits resolve to `0` — no event is recorded. The customer is not charged. Your margins stay intact.
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Why AgentBill? (vs. Metronome / Orb / Stripe)
|
|
178
|
+
|
|
179
|
+
**Metronome and Orb** are excellent for SaaS products. They're built around usage records, pricing tiers, and invoicing. If you're building a database or an API with predictable units — use them.
|
|
180
|
+
|
|
181
|
+
AgentBill is different in two ways:
|
|
182
|
+
|
|
183
|
+
### 1. Pre-flight enforcement
|
|
184
|
+
|
|
185
|
+
Metronome and Orb record usage *after the fact*. They have no way to stop an expensive operation before it starts.
|
|
186
|
+
|
|
187
|
+
AgentBill checks the customer's credit balance **before** the LLM call runs. If they're out — the function never executes. No API call is made. No money is spent.
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
Metronome/Orb: run → bill → (oops, over budget)
|
|
191
|
+
AgentBill: check → [blocked if over budget] → run → bill
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
This matters when a single agent run costs $0.80 on a good day and $43 on a bad one.
|
|
195
|
+
|
|
196
|
+
### 2. Lives inside your function
|
|
197
|
+
|
|
198
|
+
Metronome requires you to emit events from your infrastructure. AgentBill is a decorator — it wraps your function directly and handles everything: pre-flight check, credit deduction, idempotency, error handling.
|
|
199
|
+
|
|
200
|
+
No event pipelines. No webhooks to configure. One line.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Current scope — what AgentBill solves today
|
|
205
|
+
|
|
206
|
+
AgentBill is designed for **atomic, short-running agent tasks** — functions that complete in a single execution and return a deterministic result.
|
|
207
|
+
|
|
208
|
+
**Works well for:**
|
|
209
|
+
- Research runs, report generation, document processing
|
|
210
|
+
- Support ticket resolution (single attempt)
|
|
211
|
+
- Code generation with test validation
|
|
212
|
+
- Any agent function that runs once and returns a clear result
|
|
213
|
+
|
|
214
|
+
**Not yet supported:**
|
|
215
|
+
- **Multi-signal outcomes** — tasks where success is determined by multiple events over time (e.g., a ticket that gets reopened 3 days later)
|
|
216
|
+
- **Long-running workflows** — agents that run for hours or days across multiple steps
|
|
217
|
+
- **Outcome invalidation** — billing reversal when a previously "successful" result is later undone
|
|
218
|
+
|
|
219
|
+
These are real problems. They require a different architecture — event sourcing, state machines, reversal logic. If you're building at that level of complexity, AgentBill's current version isn't the right tool yet.
|
|
220
|
+
|
|
221
|
+
For atomic tasks — it's 3 lines.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## How it works
|
|
226
|
+
|
|
227
|
+
```
|
|
228
|
+
Your agent code
|
|
229
|
+
│
|
|
230
|
+
▼
|
|
231
|
+
@meter decorator
|
|
232
|
+
│
|
|
233
|
+
├─ [preflight=true] GET /budget → is_blocked? → raise BudgetExhaustedError
|
|
234
|
+
│
|
|
235
|
+
├─ Run your function (LLM call happens here)
|
|
236
|
+
│
|
|
237
|
+
├─ [function succeeded] POST /events → record credits used
|
|
238
|
+
│
|
|
239
|
+
└─ Return result
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Credits are recorded **after success only**. If your agent throws, the customer is not charged.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## API reference
|
|
247
|
+
|
|
248
|
+
### `@meter(event, options)`
|
|
249
|
+
|
|
250
|
+
| Option | Type | Default | Description |
|
|
251
|
+
|---|---|---|---|
|
|
252
|
+
| `event` | `str` | required | Event label, shown in dashboard |
|
|
253
|
+
| `customer_id` | `str` | — | Fixed customer identifier |
|
|
254
|
+
| `customer_id_from` | `str` | — | Name of a function parameter to read customer_id from |
|
|
255
|
+
| `units` | `int \| callable` | `1` | Credits per call, or a function `(result) -> int` returning 0 to skip billing |
|
|
256
|
+
| `preflight` | `bool` | `False` | Check credit balance before running. Blocks immediately if exhausted. |
|
|
257
|
+
| `metadata` | `dict` | — | Static key-value pairs attached to every event |
|
|
258
|
+
|
|
259
|
+
### Exceptions
|
|
260
|
+
|
|
261
|
+
| Exception | When |
|
|
262
|
+
|---|---|
|
|
263
|
+
| `BudgetExhaustedError` | Customer has 0 remaining credits (HTTP 402) |
|
|
264
|
+
| `AgentBillError` | Network error or unexpected server response |
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## Self-hosting
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
git clone https://github.com/marketinglior-pixel/agentbill
|
|
272
|
+
cd agentbill
|
|
273
|
+
cp .env.example .env # add your DATABASE_URL and AGENTBILL_API_KEY
|
|
274
|
+
npm install
|
|
275
|
+
npm run dev
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Requires: Node 20+, PostgreSQL 14+
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## Roadmap
|
|
283
|
+
|
|
284
|
+
- [x] Core metering (`POST /events`)
|
|
285
|
+
- [x] Credit balance enforcement (HTTP 402)
|
|
286
|
+
- [x] Pre-flight guardrails (`preflight=True`)
|
|
287
|
+
- [x] Outcome-based billing (`units=lambda`)
|
|
288
|
+
- [x] Live dashboard
|
|
289
|
+
- [ ] Stripe Connect — bill your customers directly
|
|
290
|
+
- [ ] Webhooks — alerts at 80% and 100% credit usage
|
|
291
|
+
- [ ] Multi-signal outcome support
|
|
292
|
+
- [ ] Team accounts
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
## Why not Stripe directly?
|
|
297
|
+
|
|
298
|
+
Stripe's metered billing requires: a product, a price, a customer, a subscription, a subscription item, and then a usage record per event. That's 6 API calls and 47 pages of documentation to charge someone $2.
|
|
299
|
+
|
|
300
|
+
Stripe also has no concept of "did the task succeed?" or "stop before it starts."
|
|
301
|
+
|
|
302
|
+
AgentBill handles all of that behind a single decorator.
|
|
303
|
+
|
|
304
|
+
---
|
|
305
|
+
|
|
306
|
+
Built for developers who ship agents and want to get paid fairly for what they actually deliver.
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""agentbill.meter
|
|
2
|
+
~~~~~~~~~~~~~~~
|
|
3
|
+
|
|
4
|
+
A decorator that records billable agent events.
|
|
5
|
+
|
|
6
|
+
from agentbill import meter
|
|
7
|
+
|
|
8
|
+
@meter(event="research_run", customer_id_from="customer_id")
|
|
9
|
+
async def run_agent(customer_id: str, topic: str) -> str:
|
|
10
|
+
...
|
|
11
|
+
|
|
12
|
+
Environment variables
|
|
13
|
+
---------------------
|
|
14
|
+
AGENTBILL_API_KEY Required. Your API key from agentbill.dev/dashboard.
|
|
15
|
+
AGENTBILL_BASE_URL Optional. Defaults to https://api.agentbill.dev/v1
|
|
16
|
+
AGENTBILL_CUSTOMER_ID Optional. Fallback customer_id when not passed per-call.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import functools
|
|
23
|
+
import inspect
|
|
24
|
+
import os
|
|
25
|
+
import uuid
|
|
26
|
+
from typing import Any, Callable, Optional, TypeVar, Union
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
31
|
+
UnitsResolver = Union[int, Callable[[Any], int]]
|
|
32
|
+
|
|
33
|
+
_BASE_URL = os.environ.get("AGENTBILL_BASE_URL", "https://api.agentbill.dev/v1")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Public exceptions — import and handle these in your agent code
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
class BudgetExhaustedError(Exception):
|
|
41
|
+
"""The customer has 0 remaining units (HTTP 402).
|
|
42
|
+
|
|
43
|
+
Catch this to show the user a paywall or pause the agent gracefully.
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
result = await run_agent(customer_id="cust_123", topic="...")
|
|
47
|
+
except BudgetExhaustedError as e:
|
|
48
|
+
print(f"Customer {e.customer_id} is out of budget.")
|
|
49
|
+
"""
|
|
50
|
+
def __init__(self, customer_id: str, message: str = "") -> None:
|
|
51
|
+
self.customer_id = customer_id
|
|
52
|
+
super().__init__(message or f"Customer {customer_id!r} has no remaining budget.")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AgentBillError(Exception):
|
|
56
|
+
"""Unexpected AgentBill error (network failure, 5xx response).
|
|
57
|
+
|
|
58
|
+
By default the decorator raises this so your agent doesn't bill
|
|
59
|
+
silently on server errors. Override this by wrapping the call.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Internal helpers
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def _api_key() -> str:
|
|
68
|
+
key = os.environ.get("AGENTBILL_API_KEY", "")
|
|
69
|
+
if not key:
|
|
70
|
+
raise AgentBillError(
|
|
71
|
+
"AGENTBILL_API_KEY is not set. "
|
|
72
|
+
"Get your key at agentbill.dev/dashboard."
|
|
73
|
+
)
|
|
74
|
+
return key
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _resolve_customer_id(
|
|
78
|
+
customer_id: str | None,
|
|
79
|
+
customer_id_from: str | None,
|
|
80
|
+
func: Callable,
|
|
81
|
+
args: tuple,
|
|
82
|
+
kwargs: dict,
|
|
83
|
+
) -> str:
|
|
84
|
+
# Priority: explicit kwarg > function param > env var
|
|
85
|
+
if customer_id is not None:
|
|
86
|
+
return customer_id
|
|
87
|
+
|
|
88
|
+
if customer_id_from is not None:
|
|
89
|
+
sig = inspect.signature(func)
|
|
90
|
+
bound = sig.bind(*args, **kwargs)
|
|
91
|
+
bound.apply_defaults()
|
|
92
|
+
value = bound.arguments.get(customer_id_from)
|
|
93
|
+
if value is None:
|
|
94
|
+
raise AgentBillError(
|
|
95
|
+
f"customer_id_from={customer_id_from!r} was not found in "
|
|
96
|
+
f"the arguments of {func.__name__}(). "
|
|
97
|
+
f"Available: {list(bound.arguments)}"
|
|
98
|
+
)
|
|
99
|
+
return str(value)
|
|
100
|
+
|
|
101
|
+
env_id = os.environ.get("AGENTBILL_CUSTOMER_ID", "")
|
|
102
|
+
if env_id:
|
|
103
|
+
return env_id
|
|
104
|
+
|
|
105
|
+
raise AgentBillError(
|
|
106
|
+
"No customer_id resolved. Use one of:\n"
|
|
107
|
+
" @meter(event=..., customer_id='fixed_id')\n"
|
|
108
|
+
" @meter(event=..., customer_id_from='param_name')\n"
|
|
109
|
+
" export AGENTBILL_CUSTOMER_ID=..."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _build_payload(customer_id: str, event: str, units: int, metadata: dict | None) -> dict:
|
|
114
|
+
payload: dict = {
|
|
115
|
+
"customer_id": customer_id,
|
|
116
|
+
"event_type": event,
|
|
117
|
+
"units": units,
|
|
118
|
+
# Auto-generated — unique per invocation, safe for retries
|
|
119
|
+
"idempotency_key": f"{event}_{uuid.uuid4().hex}",
|
|
120
|
+
}
|
|
121
|
+
if metadata:
|
|
122
|
+
payload["metadata"] = metadata
|
|
123
|
+
return payload
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _handle_response(resp: httpx.Response, customer_id: str) -> None:
|
|
127
|
+
if resp.status_code == 200:
|
|
128
|
+
return
|
|
129
|
+
if resp.status_code == 402:
|
|
130
|
+
data = resp.json()
|
|
131
|
+
raise BudgetExhaustedError(customer_id, data.get("message", ""))
|
|
132
|
+
raise AgentBillError(
|
|
133
|
+
f"AgentBill returned unexpected status {resp.status_code}: {resp.text[:200]}"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _resolve_units(units: UnitsResolver, result: Any) -> int:
|
|
138
|
+
if callable(units):
|
|
139
|
+
resolved = units(result)
|
|
140
|
+
if not isinstance(resolved, int) or resolved < 0:
|
|
141
|
+
raise AgentBillError(
|
|
142
|
+
f"units callable must return a non-negative int, got {resolved!r}"
|
|
143
|
+
)
|
|
144
|
+
return resolved
|
|
145
|
+
return units
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _preflight_sync(customer_id: str) -> None:
|
|
149
|
+
"""Raise BudgetExhaustedError before the agent runs if the customer is blocked."""
|
|
150
|
+
with httpx.Client() as client:
|
|
151
|
+
resp = client.get(
|
|
152
|
+
f"{_BASE_URL}/budget",
|
|
153
|
+
params={"customer_id": customer_id},
|
|
154
|
+
headers={"Authorization": f"Bearer {_api_key()}"},
|
|
155
|
+
timeout=5.0,
|
|
156
|
+
)
|
|
157
|
+
if resp.status_code != 200:
|
|
158
|
+
raise AgentBillError(f"AgentBill /budget returned {resp.status_code}: {resp.text[:200]}")
|
|
159
|
+
data = resp.json()
|
|
160
|
+
if data.get("is_blocked"):
|
|
161
|
+
raise BudgetExhaustedError(customer_id)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def _preflight_async(customer_id: str) -> None:
|
|
165
|
+
"""Async version of _preflight_sync."""
|
|
166
|
+
async with httpx.AsyncClient() as client:
|
|
167
|
+
resp = await client.get(
|
|
168
|
+
f"{_BASE_URL}/budget",
|
|
169
|
+
params={"customer_id": customer_id},
|
|
170
|
+
headers={"Authorization": f"Bearer {_api_key()}"},
|
|
171
|
+
timeout=5.0,
|
|
172
|
+
)
|
|
173
|
+
if resp.status_code != 200:
|
|
174
|
+
raise AgentBillError(f"AgentBill /budget returned {resp.status_code}: {resp.text[:200]}")
|
|
175
|
+
data = resp.json()
|
|
176
|
+
if data.get("is_blocked"):
|
|
177
|
+
raise BudgetExhaustedError(customer_id)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _submit_sync(customer_id: str, event: str, units: int, metadata: dict | None) -> None:
|
|
181
|
+
with httpx.Client() as client:
|
|
182
|
+
resp = client.post(
|
|
183
|
+
f"{_BASE_URL}/events",
|
|
184
|
+
json=_build_payload(customer_id, event, units, metadata),
|
|
185
|
+
headers={"Authorization": f"Bearer {_api_key()}"},
|
|
186
|
+
timeout=5.0,
|
|
187
|
+
)
|
|
188
|
+
_handle_response(resp, customer_id)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def _submit_async(customer_id: str, event: str, units: int, metadata: dict | None) -> None:
|
|
192
|
+
async with httpx.AsyncClient() as client:
|
|
193
|
+
resp = await client.post(
|
|
194
|
+
f"{_BASE_URL}/events",
|
|
195
|
+
json=_build_payload(customer_id, event, units, metadata),
|
|
196
|
+
headers={"Authorization": f"Bearer {_api_key()}"},
|
|
197
|
+
timeout=5.0,
|
|
198
|
+
)
|
|
199
|
+
_handle_response(resp, customer_id)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# Public decorator
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def meter(
|
|
207
|
+
event: str,
|
|
208
|
+
*,
|
|
209
|
+
units: UnitsResolver = 1,
|
|
210
|
+
customer_id: str | None = None,
|
|
211
|
+
customer_id_from: str | None = None,
|
|
212
|
+
metadata: dict | None = None,
|
|
213
|
+
preflight: bool = False,
|
|
214
|
+
) -> Callable[[F], F]:
|
|
215
|
+
"""Decorator that records a billable event after the wrapped function returns.
|
|
216
|
+
|
|
217
|
+
The event is submitted AFTER the function succeeds. If the function raises,
|
|
218
|
+
no event is recorded and your customer is not billed.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
event: Event type label (snake_case). Shown in dashboard and Stripe.
|
|
222
|
+
units: Billable units per call. Default 1.
|
|
223
|
+
customer_id: Fixed customer identifier.
|
|
224
|
+
customer_id_from: Name of a function parameter to read customer_id from.
|
|
225
|
+
metadata: Static key-value pairs attached to every event (not billed).
|
|
226
|
+
preflight: If True, check budget BEFORE running the function. Raises
|
|
227
|
+
BudgetExhaustedError immediately if the customer is blocked,
|
|
228
|
+
preventing any expensive LLM calls from being made.
|
|
229
|
+
|
|
230
|
+
Raises:
|
|
231
|
+
BudgetExhaustedError: Customer has 0 remaining units (HTTP 402).
|
|
232
|
+
AgentBillError: Network error or unexpected server response.
|
|
233
|
+
|
|
234
|
+
Examples::
|
|
235
|
+
|
|
236
|
+
# Async agent — reads customer_id from function param
|
|
237
|
+
@meter(event="research_run", customer_id_from="customer_id")
|
|
238
|
+
async def run_agent(customer_id: str, topic: str) -> str:
|
|
239
|
+
...
|
|
240
|
+
|
|
241
|
+
# Sync function — fixed customer
|
|
242
|
+
@meter(event="report_generated", customer_id="internal_ops", units=1)
|
|
243
|
+
def generate_report(date: str) -> bytes:
|
|
244
|
+
...
|
|
245
|
+
|
|
246
|
+
# Batch: bill by volume (e.g. pages processed)
|
|
247
|
+
@meter(event="pages_processed", customer_id_from="customer_id", units=10)
|
|
248
|
+
async def process_document(customer_id: str, path: str) -> list:
|
|
249
|
+
...
|
|
250
|
+
"""
|
|
251
|
+
def decorator(func: F) -> F:
|
|
252
|
+
if asyncio.iscoroutinefunction(func):
|
|
253
|
+
@functools.wraps(func)
|
|
254
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
255
|
+
cid = _resolve_customer_id(customer_id, customer_id_from, func, args, kwargs)
|
|
256
|
+
if preflight:
|
|
257
|
+
await _preflight_async(cid)
|
|
258
|
+
result = await func(*args, **kwargs)
|
|
259
|
+
actual_units = _resolve_units(units, result)
|
|
260
|
+
if actual_units > 0:
|
|
261
|
+
await _submit_async(cid, event, actual_units, metadata)
|
|
262
|
+
return result
|
|
263
|
+
return async_wrapper # type: ignore[return-value]
|
|
264
|
+
else:
|
|
265
|
+
@functools.wraps(func)
|
|
266
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
267
|
+
cid = _resolve_customer_id(customer_id, customer_id_from, func, args, kwargs)
|
|
268
|
+
if preflight:
|
|
269
|
+
_preflight_sync(cid)
|
|
270
|
+
result = func(*args, **kwargs)
|
|
271
|
+
actual_units = _resolve_units(units, result)
|
|
272
|
+
if actual_units > 0:
|
|
273
|
+
_submit_sync(cid, event, actual_units, metadata)
|
|
274
|
+
return result
|
|
275
|
+
return sync_wrapper # type: ignore[return-value]
|
|
276
|
+
|
|
277
|
+
return decorator # type: ignore[return-value]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agentbill-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Usage-based billing for AI agent developers. 3-line integration."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = ["httpx>=0.27"]
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "AgentBill", email = "marketinglior@gmail.com" }
|
|
15
|
+
]
|
|
16
|
+
keywords = ["ai", "agents", "billing", "metering", "llm", "usage-based"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/marketinglior-pixel/agentbill"
|
|
31
|
+
Repository = "https://github.com/marketinglior-pixel/agentbill"
|
|
32
|
+
Documentation = "https://github.com/marketinglior-pixel/agentbill#readme"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["agentbill"]
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
dev = ["pytest", "pytest-asyncio", "respx"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pre-flight budget check test.
|
|
3
|
+
Run: python3 test_preflight.py
|
|
4
|
+
Server must be running: npm run dev (in agentbill folder)
|
|
5
|
+
"""
|
|
6
|
+
import sys, os
|
|
7
|
+
sys.path.insert(0, '.')
|
|
8
|
+
|
|
9
|
+
os.environ['AGENTBILL_API_KEY'] = 'test_key'
|
|
10
|
+
os.environ['AGENTBILL_BASE_URL'] = 'http://localhost:3000'
|
|
11
|
+
|
|
12
|
+
from agentbill.meter import meter, BudgetExhaustedError
|
|
13
|
+
|
|
14
|
+
call_count = 0
|
|
15
|
+
|
|
16
|
+
# limit_test is already BLOCKED (used=3, limit=3)
|
|
17
|
+
@meter(event="preflight_test", customer_id="limit_test", units=1, preflight=True)
|
|
18
|
+
def expensive_agent(task: str) -> str:
|
|
19
|
+
global call_count
|
|
20
|
+
call_count += 1
|
|
21
|
+
print(f" → Agent body ran (#{call_count}) — this costs money!")
|
|
22
|
+
return f"result: {task}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
print("=" * 50)
|
|
26
|
+
print("Pre-flight budget check test")
|
|
27
|
+
print("Customer: limit_test (blocked, used=3/3)")
|
|
28
|
+
print("=" * 50)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
expensive_agent("analyze this document")
|
|
32
|
+
print("\nFAIL: should have been blocked before running!")
|
|
33
|
+
except BudgetExhaustedError as e:
|
|
34
|
+
print(f"\n✅ BudgetExhaustedError caught BEFORE agent ran")
|
|
35
|
+
print(f" Message: {e}")
|
|
36
|
+
print(f" Agent body executions: {call_count} (expected: 0)")
|
|
37
|
+
print(f"\n→ The LLM call was never made. No money wasted.")
|