bursar 0.0.1__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.
- bursar-0.0.1/PKG-INFO +432 -0
- bursar-0.0.1/README.md +396 -0
- bursar-0.0.1/pyproject.toml +128 -0
- bursar-0.0.1/setup.cfg +4 -0
- bursar-0.0.1/src/bursar/__init__.py +141 -0
- bursar-0.0.1/src/bursar/__main__.py +419 -0
- bursar-0.0.1/src/bursar/allowance.py +177 -0
- bursar-0.0.1/src/bursar/breakdown.py +35 -0
- bursar-0.0.1/src/bursar/config.py +161 -0
- bursar-0.0.1/src/bursar/engine.py +257 -0
- bursar-0.0.1/src/bursar/events.py +134 -0
- bursar-0.0.1/src/bursar/expr.py +494 -0
- bursar-0.0.1/src/bursar/interface/__init__.py +1 -0
- bursar-0.0.1/src/bursar/interface/base.py +772 -0
- bursar-0.0.1/src/bursar/interface/memory.py +1945 -0
- bursar-0.0.1/src/bursar/interface/models.py +596 -0
- bursar-0.0.1/src/bursar/interface/postgres.py +1120 -0
- bursar-0.0.1/src/bursar/interface/supabase.py +1014 -0
- bursar-0.0.1/src/bursar/manager.py +1566 -0
- bursar-0.0.1/src/bursar/metrics.py +50 -0
- bursar-0.0.1/src/bursar/py.typed +0 -0
- bursar-0.0.1/src/bursar/sql/001_core_schema.sql +163 -0
- bursar-0.0.1/src/bursar/sql/002_credit_rpcs.sql +217 -0
- bursar-0.0.1/src/bursar/sql/003_pricing_config.sql +231 -0
- bursar-0.0.1/src/bursar/sql/004_plans.sql +377 -0
- bursar-0.0.1/src/bursar/sql/005_spend_caps.sql +105 -0
- bursar-0.0.1/src/bursar/sql/006_refunds_and_expiry.sql +360 -0
- bursar-0.0.1/src/bursar/sql/007_analytics.sql +327 -0
- bursar-0.0.1/src/bursar/sql/008_teams.sql +324 -0
- bursar-0.0.1/src/bursar/sql/009_deduct_and_leases.sql +993 -0
- bursar-0.0.1/src/bursar/sql/010_credit_tiers.sql +212 -0
- bursar-0.0.1/src/bursar/sql/011_lazy_expiry.sql +383 -0
- bursar-0.0.1/src/bursar/sql/012_feature_limits.sql +74 -0
- bursar-0.0.1/src/bursar/sql/__init__.py +13 -0
- bursar-0.0.1/src/bursar.egg-info/PKG-INFO +432 -0
- bursar-0.0.1/src/bursar.egg-info/SOURCES.txt +52 -0
- bursar-0.0.1/src/bursar.egg-info/dependency_links.txt +1 -0
- bursar-0.0.1/src/bursar.egg-info/entry_points.txt +2 -0
- bursar-0.0.1/src/bursar.egg-info/requires.txt +19 -0
- bursar-0.0.1/src/bursar.egg-info/top_level.txt +1 -0
- bursar-0.0.1/tests/test_allowance.py +338 -0
- bursar-0.0.1/tests/test_cli.py +567 -0
- bursar-0.0.1/tests/test_config.py +721 -0
- bursar-0.0.1/tests/test_engine.py +612 -0
- bursar-0.0.1/tests/test_expr.py +677 -0
- bursar-0.0.1/tests/test_lazy_expiry.py +186 -0
- bursar-0.0.1/tests/test_lease.py +567 -0
- bursar-0.0.1/tests/test_lease_adversarial.py +531 -0
- bursar-0.0.1/tests/test_manager.py +2074 -0
- bursar-0.0.1/tests/test_store.py +1810 -0
- bursar-0.0.1/tests/test_store_integration.py +3161 -0
- bursar-0.0.1/tests/test_subscription_cycle.py +193 -0
- bursar-0.0.1/tests/test_tiers.py +715 -0
- bursar-0.0.1/tests/test_tiers_adversarial.py +375 -0
bursar-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bursar
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Declarative credit calculation engine for AI SaaS platforms
|
|
5
|
+
Project-URL: Homepage, https://zonastery.github.io/bursar/
|
|
6
|
+
Project-URL: Source, https://github.com/Zonastery/bursar
|
|
7
|
+
Project-URL: Documentation, https://zonastery.github.io/bursar/
|
|
8
|
+
Project-URL: Issues, https://github.com/Zonastery/bursar/issues
|
|
9
|
+
Keywords: credits,billing,llm,usage-metering,pricing
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: <3.14,>=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: pydantic>=2.0.0
|
|
21
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
22
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
23
|
+
Provides-Extra: supabase
|
|
24
|
+
Requires-Dist: httpx>=0.28.1; extra == "supabase"
|
|
25
|
+
Provides-Extra: postgres
|
|
26
|
+
Requires-Dist: psycopg2-binary>=2.9.12; extra == "postgres"
|
|
27
|
+
Provides-Extra: test
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
29
|
+
Requires-Dist: ruff>=0.15.0; extra == "test"
|
|
30
|
+
Requires-Dist: pyright>=1.1.390; extra == "test"
|
|
31
|
+
Requires-Dist: pytest-testmon>=2.2.0; extra == "test"
|
|
32
|
+
Requires-Dist: pytest-postgresql>=7.0; extra == "test"
|
|
33
|
+
Requires-Dist: httpx>=0.28.1; extra == "test"
|
|
34
|
+
Requires-Dist: psycopg2-binary>=2.9.12; extra == "test"
|
|
35
|
+
Requires-Dist: pyyaml>=6.0.3; extra == "test"
|
|
36
|
+
|
|
37
|
+
# bursar
|
|
38
|
+
|
|
39
|
+
[](https://github.com/Zonastery/bursar/actions/workflows/ci.yml)
|
|
40
|
+
[](https://www.python.org/)
|
|
41
|
+
[](LICENSE)
|
|
42
|
+
|
|
43
|
+
Add usage-based credits to your AI SaaS in minutes — not weeks.
|
|
44
|
+
|
|
45
|
+
bursar is a drop-in credit calculation engine. Define pricing as math expressions
|
|
46
|
+
(per-model, per-tool, search/RAG, cache, fixed jobs), connect a database, and
|
|
47
|
+
start deducting credits. No billing infrastructure to build. Pricing lives in
|
|
48
|
+
your DB — update it live without redeploys.
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from bursar import CreditManager, UsageMetrics
|
|
52
|
+
from bursar.interface.supabase import HttpxSupabaseStore
|
|
53
|
+
|
|
54
|
+
store = HttpxSupabaseStore(url=supabase_url, key=service_role_key)
|
|
55
|
+
manager = CreditManager(store=store)
|
|
56
|
+
manager.load_pricing_from_store()
|
|
57
|
+
|
|
58
|
+
manager.add_credits("user_abc", 1000)
|
|
59
|
+
|
|
60
|
+
result = manager.deduct(
|
|
61
|
+
user_id="user_abc",
|
|
62
|
+
metrics=UsageMetrics(model="gpt-4", input_tokens=500, output_tokens=200),
|
|
63
|
+
idempotency_key="chat_42",
|
|
64
|
+
)
|
|
65
|
+
print(f"Deducted {abs(result.amount)} credits. Balance: {result.balance_after}")
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Features
|
|
69
|
+
|
|
70
|
+
- **Safe expression engine** — Python `ast` module with strict allowlist. `min`, `max`, `if`, `tier`, `clamp`, `ceil`, `floor`, `round`, `percentile`. No eval/exec, no attribute access, no imports.
|
|
71
|
+
- **Plan-based pricing** — Subscription plans with free monthly allowances, rate overrides, and feature flags. Allowance consumed before balance.
|
|
72
|
+
- **Refunds** — Full and partial credit reversals with duplicate detection and idempotency.
|
|
73
|
+
- **Credit expiry / TTL** — Time-bound credits with `expires_at` on `add_credits`. Sweep with dry-run mode.
|
|
74
|
+
- **Team / shared balances** — Separate team credit pools with per-member spend caps and attribution.
|
|
75
|
+
- **Spend caps** — Per-user daily/monthly limits with `deny`, `warn`, `notify` actions. Per-model caps supported.
|
|
76
|
+
- **Usage analytics** — `spend_by_user`, `spend_by_model`, `top_users`, `daily_spend`, `aggregate_stats` across time windows.
|
|
77
|
+
- **Event hooks** — Typed pub/sub for `credits.deducted`, `credits.added`, `credits.refunded`, `credits.expired`, `credits.cap_reached`, `credits.cap_warning`, `credits.low_balance`.
|
|
78
|
+
- **Database-backed pricing** — Live updates without redeploys. Dict loading for testing.
|
|
79
|
+
- **Multi-dimensional** — Per-model (with `_default` fallback), per-tool overrides, search/RAG, cache discounts, fixed-cost jobs.
|
|
80
|
+
- **Pluggable storage** — `CreditStore` adapters: Supabase, PostgreSQL, in-memory.
|
|
81
|
+
- **Safe defaults** — `min_balance` floor, atomic idempotent deductions, fractional `Decimal` credits (no truncation).
|
|
82
|
+
- **Auditable** — Structured `CostBreakdown` with per-dimension costs.
|
|
83
|
+
|
|
84
|
+
## Installation
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pip install bursar
|
|
88
|
+
|
|
89
|
+
# With Supabase store
|
|
90
|
+
pip install "bursar[supabase]"
|
|
91
|
+
|
|
92
|
+
# With PostgreSQL store
|
|
93
|
+
pip install "bursar[postgres]"
|
|
94
|
+
|
|
95
|
+
# Development & testing
|
|
96
|
+
pip install "bursar[test]"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Requires Python 3.11+.
|
|
100
|
+
|
|
101
|
+
## Full docs
|
|
102
|
+
|
|
103
|
+
**[zonastery.github.io/bursar](https://zonastery.github.io/bursar/)** — Python API reference, expressions, configuration, examples.
|
|
104
|
+
|
|
105
|
+
## Quick Start
|
|
106
|
+
|
|
107
|
+
### 0. Stateless calculation (no database)
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from bursar import PricingEngine, UsageMetrics
|
|
111
|
+
|
|
112
|
+
engine = PricingEngine.from_dict({
|
|
113
|
+
"version": 1,
|
|
114
|
+
"models": {"_default": "input_tokens * 0.001 + output_tokens * 0.003"},
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
result = engine.calculate(UsageMetrics(model="gpt-4", input_tokens=500, output_tokens=200))
|
|
118
|
+
print(f"Total credits: {result.total}")
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### 1. Install and migrate
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
pip install "bursar[postgres]"
|
|
125
|
+
|
|
126
|
+
# The connection string is read from DATABASE_URL (recommended) — keeping the
|
|
127
|
+
# password out of your shell history, `ps` output and CI logs.
|
|
128
|
+
export DATABASE_URL="postgresql://user:pass@host:5432/db"
|
|
129
|
+
bursar migrate
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
> A positional URL (`bursar migrate "postgresql://…"`) still works for convenience
|
|
133
|
+
> but is discouraged and prints a warning, since it leaks the password via the
|
|
134
|
+
> process list, shell history and CI logs.
|
|
135
|
+
|
|
136
|
+
Creates all tables (`user_credits`, `credit_transactions`, `credit_reservations`,
|
|
137
|
+
`credit_plans`, `credit_usage_window`, `credit_teams`, `credit_team_members`,
|
|
138
|
+
`credit_spend_caps`, `credit_pricing_config`) and 20+ RPCs — all idempotent.
|
|
139
|
+
|
|
140
|
+
### 2. Pricing version management
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
# Apply new pricing (creates v1)
|
|
144
|
+
bursar pricing set - <<'JSON'
|
|
145
|
+
{
|
|
146
|
+
"version": 1,
|
|
147
|
+
"models": { "_default": "input_tokens * 0.01 + output_tokens * 0.03" },
|
|
148
|
+
"plans": {
|
|
149
|
+
"free": { "id": "free", "name": "Free Tier", "free_allowance": 50000 },
|
|
150
|
+
"pro": { "id": "pro", "name": "Pro", "free_allowance": 500000 }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
JSON
|
|
154
|
+
|
|
155
|
+
# Apply with a label
|
|
156
|
+
bursar pricing set pricing.yaml --label "deploy-42"
|
|
157
|
+
|
|
158
|
+
# List all versions (* = active)
|
|
159
|
+
bursar pricing list
|
|
160
|
+
|
|
161
|
+
# Switch active pricing
|
|
162
|
+
bursar pricing activate 1
|
|
163
|
+
|
|
164
|
+
# Diff two versions
|
|
165
|
+
bursar pricing diff 1 2
|
|
166
|
+
|
|
167
|
+
# Export a version as JSON
|
|
168
|
+
bursar pricing export 2
|
|
169
|
+
|
|
170
|
+
# Validate without applying
|
|
171
|
+
bursar pricing validate pricing.yaml
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Each `pricing set` creates a new immutable version. Roll back with `pricing activate <version>`.
|
|
175
|
+
|
|
176
|
+
| Command | Description |
|
|
177
|
+
|---------|-------------|
|
|
178
|
+
| `pricing set <file> [--label <msg>]` | Apply config (always creates new version) |
|
|
179
|
+
| `pricing get` | Show active config |
|
|
180
|
+
| `pricing list` | List all versions |
|
|
181
|
+
| `pricing activate <version>` | Switch to any version |
|
|
182
|
+
| `pricing validate <file>` | Dry-run validate |
|
|
183
|
+
| `pricing diff <v1> <v2>` | Unified diff between versions |
|
|
184
|
+
| `pricing export <version>` | Dump version as JSON |
|
|
185
|
+
|
|
186
|
+
### 3. Deduct credits
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from bursar import CreditManager, UsageMetrics
|
|
190
|
+
from bursar.interface.postgres import PostgresStore
|
|
191
|
+
|
|
192
|
+
store = PostgresStore("postgresql://user:pass@host:5432/db")
|
|
193
|
+
manager = CreditManager(store=store)
|
|
194
|
+
manager.load_pricing_from_store()
|
|
195
|
+
|
|
196
|
+
manager.add_credits("user_abc", 1000)
|
|
197
|
+
result = manager.deduct(
|
|
198
|
+
user_id="user_abc",
|
|
199
|
+
metrics=UsageMetrics(model="gpt-4", input_tokens=500, output_tokens=200),
|
|
200
|
+
idempotency_key="tx_001",
|
|
201
|
+
)
|
|
202
|
+
print(f"Deducted {abs(result.amount)} credits. Balance: {result.balance_after}")
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Pricing Configuration
|
|
206
|
+
|
|
207
|
+
### Basic config
|
|
208
|
+
|
|
209
|
+
```json
|
|
210
|
+
{
|
|
211
|
+
"version": 1,
|
|
212
|
+
"models": {
|
|
213
|
+
"gpt-4": "input_tokens * 0.01 + output_tokens * 0.03",
|
|
214
|
+
"_default": "input_tokens * 0.001 + output_tokens * 0.003"
|
|
215
|
+
},
|
|
216
|
+
"tools": { "_default": "tool_calls * 0" },
|
|
217
|
+
"search": { "costs": "search_queries * 0.5 + search_results * 0.05" },
|
|
218
|
+
"cache": { "discount": "-cache_read_tokens * 0.0045" },
|
|
219
|
+
"fixed": { "batch_job": 20 },
|
|
220
|
+
"min_balance": 5
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### With plans
|
|
225
|
+
|
|
226
|
+
```json
|
|
227
|
+
{
|
|
228
|
+
"version": 1,
|
|
229
|
+
"models": { "_default": "input_tokens * 0.01 + output_tokens * 0.03" },
|
|
230
|
+
"plans": {
|
|
231
|
+
"free": {
|
|
232
|
+
"id": "free",
|
|
233
|
+
"name": "Free Tier",
|
|
234
|
+
"free_allowance": 50000,
|
|
235
|
+
"rate_overrides": { "_default": "input_tokens * 0.02 + output_tokens * 0.06" },
|
|
236
|
+
"features": { "max_concurrency": 1, "background_removal": true },
|
|
237
|
+
"feature_limits": {
|
|
238
|
+
"background_removal": { "max_calls": 5, "period": "monthly", "action": "deny" }
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
"pro": {
|
|
242
|
+
"id": "pro",
|
|
243
|
+
"name": "Pro Plan",
|
|
244
|
+
"free_allowance": 500000
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Feature Examples
|
|
251
|
+
|
|
252
|
+
### Refunds
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
tx = manager.deduct("user_abc", UsageMetrics(model="gpt-4", input_tokens=500))
|
|
256
|
+
refund = manager.refund_credits(tx.transaction_id) # full refund
|
|
257
|
+
partial = manager.refund_credits(tx.transaction_id, amount=5) # partial
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Credit expiry
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
manager.add_credits("user_abc", 100, "purchase", expires_at=datetime(2025, 1, 1))
|
|
264
|
+
result = manager.sweep_expired_credits() # sweep
|
|
265
|
+
report = manager.sweep_expired_credits(dry_run=True) # preview only
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### Team / shared balances
|
|
269
|
+
|
|
270
|
+
```python
|
|
271
|
+
team = store.create_team("Engineering", initial_balance=5000)
|
|
272
|
+
store.add_team_member(team.team_id, "user_abc", role="admin", spend_cap=1000)
|
|
273
|
+
result = manager.deduct_team(team.team_id, "user_abc", UsageMetrics(model="gpt-4", input_tokens=500))
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### Spend caps
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
from bursar.interface.models import SpendCap
|
|
280
|
+
store.set_spend_cap(SpendCap(user_id="user_abc", cap_type="daily", limit=100, action="deny"))
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Financial safety (leases)
|
|
284
|
+
|
|
285
|
+
Because bursar charges *after* the AI call, the safe pattern is an atomic **lease** taken *before* the work: `reserve` a worst-case hold against `available = balance − Σ(active holds)`, do the work, then `settle` the **actual** cost (de-clamped) or `release` to cancel. `reserve` is the only admission gate. Two presets: `strict_prepaid` (default; floor ≥ 0, structurally zero debt) and `overdraft` (negative floor; bills full actual; for paid users with auto-reload).
|
|
286
|
+
|
|
287
|
+
```python
|
|
288
|
+
from decimal import Decimal
|
|
289
|
+
|
|
290
|
+
manager = CreditManager(store=store, policy="strict_prepaid") # or policy="overdraft", overdraft_floor=Decimal("-50")
|
|
291
|
+
lease = manager.reserve("user_abc", Decimal("40")) # worst-case hold
|
|
292
|
+
deduction = manager.settle("user_abc", lease.lease_id, Decimal("11")) # actual cost; de-clamped
|
|
293
|
+
# on failure: manager.release("user_abc", lease.lease_id) # idempotent
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
### Usage analytics
|
|
297
|
+
|
|
298
|
+
```python
|
|
299
|
+
from datetime import datetime, timedelta
|
|
300
|
+
now = datetime.now()
|
|
301
|
+
rows = manager.spend_by_user(now - timedelta(days=30), now) # per-user totals
|
|
302
|
+
rows = manager.spend_by_model(now - timedelta(days=30), now) # per-model spend
|
|
303
|
+
rows = manager.top_users(10, now - timedelta(days=30), now) # top 10 users
|
|
304
|
+
rows = manager.daily_spend(now - timedelta(days=30), now) # daily buckets
|
|
305
|
+
stats = manager.aggregate_stats(now - timedelta(days=30), now) # aggregate summary
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### Events
|
|
309
|
+
|
|
310
|
+
```python
|
|
311
|
+
from bursar.events import CreditEventEmitter
|
|
312
|
+
emitter = CreditEventEmitter()
|
|
313
|
+
manager = CreditManager(store=store, emitter=emitter)
|
|
314
|
+
emitter.on("credits.deducted", lambda e: print(f"User {e.user_id} spent credits"))
|
|
315
|
+
emitter.on("credits.low_balance", lambda e: send_alert(e.user_id, e.data["balance"]))
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
### Expression syntax
|
|
319
|
+
|
|
320
|
+
| Feature | Example |
|
|
321
|
+
|---------|---------|
|
|
322
|
+
| Arithmetic | `+`, `-`, `*`, `/`, `//`, `%` (exponentiation `**` is rejected at validate time) |
|
|
323
|
+
| Comparisons | `==`, `!=`, `<`, `<=`, `>`, `>=`, `in`, `not in` |
|
|
324
|
+
| Boolean | `and`, `or`, `not` |
|
|
325
|
+
| Ternary | `X if cond else Y` |
|
|
326
|
+
| Functions | `ceil`, `floor`, `round`, `min`, `max`, `if(cond,t,f)`, `tier(v,t1,r1,t2,r2,...)`, `clamp(x,lo,hi)`, `percentile(p,v1,v2,...)` |
|
|
327
|
+
|
|
328
|
+
### Available metrics
|
|
329
|
+
|
|
330
|
+
| Variable | Source |
|
|
331
|
+
|----------|--------|
|
|
332
|
+
| `input_tokens` | `UsageMetrics.input_tokens` |
|
|
333
|
+
| `output_tokens` | `UsageMetrics.output_tokens` |
|
|
334
|
+
| `cache_read_tokens` | `UsageMetrics.cache_read_tokens` |
|
|
335
|
+
| `cache_write_tokens` | `UsageMetrics.cache_write_tokens` |
|
|
336
|
+
| `tool_calls` | `len(UsageMetrics.tool_calls)` |
|
|
337
|
+
| `search_queries` | `UsageMetrics.search_queries` |
|
|
338
|
+
| `search_results` | `UsageMetrics.search_results` |
|
|
339
|
+
| `web_search_calls` | `UsageMetrics.web_search_calls` |
|
|
340
|
+
| `code_exec_calls` | `UsageMetrics.code_exec_calls` |
|
|
341
|
+
|
|
342
|
+
## Storage Backends
|
|
343
|
+
|
|
344
|
+
| Store | Import | Deps | Use case |
|
|
345
|
+
|-------|--------|------|----------|
|
|
346
|
+
| `MemoryStore` | `bursar.interface.memory.MemoryStore` | None | Testing, dev |
|
|
347
|
+
| `HttpxSupabaseStore` | `bursar.interface.supabase.HttpxSupabaseStore` | `httpx` | Supabase production |
|
|
348
|
+
| `PostgresStore` | `bursar.interface.postgres.PostgresStore` | `psycopg2` | Direct PostgreSQL |
|
|
349
|
+
|
|
350
|
+
### Custom stores
|
|
351
|
+
|
|
352
|
+
Implement `bursar.interface.base.CreditStore` (ABC with 29 abstract methods).
|
|
353
|
+
|
|
354
|
+
## Credit Lifecycle
|
|
355
|
+
|
|
356
|
+
`CreditManager.deduct()`:
|
|
357
|
+
|
|
358
|
+
1. **Calculate** — `PricingEngine.calculate(metrics)` → `cost` (exact `Decimal`, no truncation)
|
|
359
|
+
2. **Short-circuit** — if `cost <= 0`, return a zero-amount result without touching the store
|
|
360
|
+
3. **Atomic charge** — one `store.deduct_with_allowance(...)` call applies plan allowance,
|
|
361
|
+
spend-cap enforcement, the `min_balance` floor and the balance debit inside a **single
|
|
362
|
+
transaction**, keyed by `idempotency_key` (a replay returns the original result)
|
|
363
|
+
|
|
364
|
+
The legacy two-phase `reserve_credits` + `deduct_credits` API is still available on the store
|
|
365
|
+
for callers that need a reservation step.
|
|
366
|
+
|
|
367
|
+
### Additional operations
|
|
368
|
+
|
|
369
|
+
- **Refund:** `manager.refund_credits(tx_id, amount?)` — full or partial
|
|
370
|
+
- **Expire:** `manager.sweep_expired_credits(dry_run=True)` — preview or execute
|
|
371
|
+
- **Team deduct:** `manager.deduct_team(team_id, user_id, metrics)` — team pool
|
|
372
|
+
- **Analytics:** `spend_by_user`, `spend_by_model`, `top_users`, `daily_spend`, `aggregate_stats`
|
|
373
|
+
- **Events:** Subscribe via `CreditEventEmitter` for lifecycle hooks
|
|
374
|
+
|
|
375
|
+
## SQL Migrations
|
|
376
|
+
|
|
377
|
+
10 bundled migrations (`DATABASE_URL=… bursar migrate`):
|
|
378
|
+
|
|
379
|
+
| File | Contents |
|
|
380
|
+
|------|----------|
|
|
381
|
+
| `001_core_schema.sql` | Core tables (`user_credits`, `credit_transactions`, `credit_reservations`) + RLS + signup bonus trigger |
|
|
382
|
+
| `002_credit_rpcs.sql` | `credits_add`, `get_credits_balance` |
|
|
383
|
+
| `003_pricing_config.sql` | Pricing config table + get/set/list/activate RPCs |
|
|
384
|
+
| `004_plans.sql` | Subscription plans, usage windows, allowance RPCs |
|
|
385
|
+
| `005_spend_caps.sql` | Spend cap table + `check_spend_cap` RPC |
|
|
386
|
+
| `006_refunds_and_expiry.sql` | `refund_credits`, `expire_credits` |
|
|
387
|
+
| `007_analytics.sql` | Analytics + transaction-listing RPCs |
|
|
388
|
+
| `008_teams.sql` | Team balance pools + RPCs |
|
|
389
|
+
| `009_deduct_and_leases.sql` | Atomic `deduct_with_allowance` + full lease lifecycle |
|
|
390
|
+
| `010_credit_tiers.sql` | Configurable credit tiers (priority-ordered balance buckets) |
|
|
391
|
+
|
|
392
|
+
## Architecture
|
|
393
|
+
|
|
394
|
+
```
|
|
395
|
+
bursar/
|
|
396
|
+
expr.py # Safe AST expression evaluator
|
|
397
|
+
config.py # PricingConfig loading + validation
|
|
398
|
+
engine.py # PricingEngine — calculate, calculateBatch
|
|
399
|
+
metrics.py # UsageMetrics, ToolCall
|
|
400
|
+
breakdown.py # CostBreakdown
|
|
401
|
+
events.py # CreditEventEmitter pub/sub
|
|
402
|
+
manager.py # CreditManager orchestration
|
|
403
|
+
interface/
|
|
404
|
+
base.py # CreditStore ABC (29 abstract methods)
|
|
405
|
+
models.py # Pydantic schemas
|
|
406
|
+
memory.py # MemoryStore
|
|
407
|
+
supabase.py # HttpxSupabaseStore + run_migrations()
|
|
408
|
+
postgres.py # PostgresStore
|
|
409
|
+
sql/ # 001_*.sql … 015_*.sql (15 migrations)
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
## Expression Safety
|
|
413
|
+
|
|
414
|
+
1. Parse `ast.parse(expr, mode="eval")`
|
|
415
|
+
2. Walk AST — each node type in an allowlist
|
|
416
|
+
3. Allowed functions: `ceil`, `floor`, `round`, `min`, `max`, `if`, `tier`, `clamp`, `percentile`
|
|
417
|
+
4. Rejects: attributes, subscripts, lambdas, comprehensions, imports, exponentiation (`**`)
|
|
418
|
+
5. Division / modulo by zero and non-finite results raise `ExpressionError` (never `inf`/`NaN`)
|
|
419
|
+
6. `__builtins__` emptied at evaluation time
|
|
420
|
+
7. All expressions — and their variable names — validated at config load time
|
|
421
|
+
|
|
422
|
+
## Development
|
|
423
|
+
|
|
424
|
+
```bash
|
|
425
|
+
pip install "bursar[test]"
|
|
426
|
+
pytest
|
|
427
|
+
ruff check .
|
|
428
|
+
ruff format .
|
|
429
|
+
pyright
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md).
|