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,18 @@
1
+ # Environment — never commit real credentials
2
+ .env
3
+ .env.local
4
+
5
+ # Dependencies
6
+ node_modules/
7
+ sdk/node/node_modules/
8
+
9
+ # Build output
10
+ dist/
11
+ sdk/node/dist/
12
+
13
+ # macOS
14
+ .DS_Store
15
+
16
+ # Logs
17
+ *.log
18
+ npm-debug.log*
@@ -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,3 @@
1
+ from .meter import meter, BudgetExhaustedError, AgentBillError
2
+
3
+ __all__ = ["meter", "BudgetExhaustedError", "AgentBillError"]
@@ -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.")