ducto 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.
- ducto-0.1.0/LICENSE +21 -0
- ducto-0.1.0/PKG-INFO +32 -0
- ducto-0.1.0/README.md +308 -0
- ducto-0.1.0/pyproject.toml +94 -0
- ducto-0.1.0/setup.cfg +4 -0
- ducto-0.1.0/src/ducto/__init__.py +45 -0
- ducto-0.1.0/src/ducto/__main__.py +157 -0
- ducto-0.1.0/src/ducto/breakdown.py +30 -0
- ducto-0.1.0/src/ducto/config.py +81 -0
- ducto-0.1.0/src/ducto/engine.py +231 -0
- ducto-0.1.0/src/ducto/expr.py +149 -0
- ducto-0.1.0/src/ducto/interface/__init__.py +1 -0
- ducto-0.1.0/src/ducto/interface/base.py +114 -0
- ducto-0.1.0/src/ducto/interface/memory.py +231 -0
- ducto-0.1.0/src/ducto/interface/models.py +110 -0
- ducto-0.1.0/src/ducto/interface/postgres.py +244 -0
- ducto-0.1.0/src/ducto/interface/supabase.py +230 -0
- ducto-0.1.0/src/ducto/manager.py +245 -0
- ducto-0.1.0/src/ducto/metrics.py +34 -0
- ducto-0.1.0/src/ducto/py.typed +0 -0
- ducto-0.1.0/src/ducto/sql/001_credit_tables.sql +162 -0
- ducto-0.1.0/src/ducto/sql/002_credit_rpcs.sql +256 -0
- ducto-0.1.0/src/ducto/sql/003_pricing_config.sql +108 -0
- ducto-0.1.0/src/ducto/sql/__init__.py +13 -0
- ducto-0.1.0/src/ducto.egg-info/PKG-INFO +32 -0
- ducto-0.1.0/src/ducto.egg-info/SOURCES.txt +34 -0
- ducto-0.1.0/src/ducto.egg-info/dependency_links.txt +1 -0
- ducto-0.1.0/src/ducto.egg-info/entry_points.txt +2 -0
- ducto-0.1.0/src/ducto.egg-info/requires.txt +15 -0
- ducto-0.1.0/src/ducto.egg-info/top_level.txt +1 -0
- ducto-0.1.0/tests/test_cli.py +59 -0
- ducto-0.1.0/tests/test_config.py +109 -0
- ducto-0.1.0/tests/test_engine.py +209 -0
- ducto-0.1.0/tests/test_expr.py +88 -0
- ducto-0.1.0/tests/test_manager.py +195 -0
- ducto-0.1.0/tests/test_store.py +70 -0
ducto-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 apoorwv
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ducto-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ducto
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Declarative credit calculation engine AI SaaS platforms
|
|
5
|
+
Project-URL: Homepage, https://github.com/apoorwv/ducto
|
|
6
|
+
Project-URL: Source, https://github.com/apoorwv/ducto
|
|
7
|
+
Project-URL: Documentation, https://github.com/apoorwv/ducto#readme
|
|
8
|
+
Project-URL: Issues, https://github.com/apoorwv/ducto/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.11
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: pydantic>=2.0.0
|
|
21
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
22
|
+
Provides-Extra: supabase
|
|
23
|
+
Requires-Dist: httpx>=0.27; extra == "supabase"
|
|
24
|
+
Requires-Dist: psycopg2-binary>=2.9; extra == "supabase"
|
|
25
|
+
Provides-Extra: postgres
|
|
26
|
+
Requires-Dist: psycopg2-binary>=2.9; 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; extra == "test"
|
|
31
|
+
Requires-Dist: pytest-testmon>=2.0.0; extra == "test"
|
|
32
|
+
Dynamic: license-file
|
ducto-0.1.0/README.md
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# ducto
|
|
2
|
+
|
|
3
|
+
[](https://github.com/apoorwv/ducto/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.python.org/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Declarative credit calculation engine for AI SaaS platforms.
|
|
8
|
+
|
|
9
|
+
Pricing expressions stored in a `credit_pricing_config` table enable live
|
|
10
|
+
updates without redeploys. A safe AST-walking expression engine calculates
|
|
11
|
+
credit costs from usage metrics. Supports per-model formulas, tool costs, search/RAG pricing,
|
|
12
|
+
cache discounts, fixed-cost batch jobs, and a full reserve-then-deduct
|
|
13
|
+
lifecycle.
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- **Safe expression engine** — Uses Python's `ast` module with a strict
|
|
18
|
+
allowlist (no `eval()` of raw strings, no `exec()`, no attribute access,
|
|
19
|
+
no imports). Validated at config load time.
|
|
20
|
+
- **Database-backed pricing** — Pricing expressions stored in a
|
|
21
|
+
`credit_pricing_config` table. Enables live pricing updates without
|
|
22
|
+
redeploys. Dict loading available for testing and stateless calculation.
|
|
23
|
+
- **Multi-dimensional** — Per-model formulas (with `_default` fallback),
|
|
24
|
+
per-tool overrides, search/RAG, cache read discounts, fixed-cost jobs.
|
|
25
|
+
- **Stateless core** — Pure calculation layer has zero database dependency.
|
|
26
|
+
- **Auditable** — Returns a structured `CostBreakdown` with per-dimension
|
|
27
|
+
costs and metadata.
|
|
28
|
+
- **Pluggable storage** — Reserve-then-deduct pattern via `CreditStore`
|
|
29
|
+
adapters: Supabase, raw PostgreSQL, or in-memory for testing.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install ducto
|
|
35
|
+
|
|
36
|
+
# With Supabase store support
|
|
37
|
+
pip install "ducto[supabase]"
|
|
38
|
+
|
|
39
|
+
# With PostgreSQL store support
|
|
40
|
+
pip install "ducto[postgres]"
|
|
41
|
+
|
|
42
|
+
# Development & testing
|
|
43
|
+
pip install "ducto[test]"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Requires Python 3.11+.
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
### Full lifecycle with store
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from ducto import CreditManager, UsageMetrics
|
|
54
|
+
from ducto.interface.supabase import HttpxSupabaseStore
|
|
55
|
+
|
|
56
|
+
store = HttpxSupabaseStore(url=supabase_url, key=service_role_key)
|
|
57
|
+
manager = CreditManager(store=store)
|
|
58
|
+
|
|
59
|
+
# Load pricing from the credit_pricing_config table
|
|
60
|
+
manager.load_pricing_from_store()
|
|
61
|
+
|
|
62
|
+
# Deduct credits for a usage event
|
|
63
|
+
result = manager.deduct(
|
|
64
|
+
user_id="user_abc",
|
|
65
|
+
metrics=UsageMetrics(model="claude-opus-4", input_tokens=500, output_tokens=200),
|
|
66
|
+
idempotency_key="chat_42_turn_7",
|
|
67
|
+
)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Requires existing schema (run `ducto migrate`) and seeded pricing config
|
|
71
|
+
(run `ducto pricing set defaults.yaml`).
|
|
72
|
+
|
|
73
|
+
### Calculation only (no database)
|
|
74
|
+
|
|
75
|
+
For testing or stateless calculation without a store:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from ducto import PricingEngine, UsageMetrics
|
|
79
|
+
|
|
80
|
+
engine = PricingEngine.from_dict({
|
|
81
|
+
"version": 1,
|
|
82
|
+
"models": {"_default": "input_tokens * 0.001 + output_tokens * 0.003"},
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
result = engine.calculate(
|
|
86
|
+
UsageMetrics(model="gpt-4", input_tokens=500, output_tokens=200),
|
|
87
|
+
)
|
|
88
|
+
print(f"Total credits: {result.total}")
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Pricing Configuration
|
|
92
|
+
|
|
93
|
+
Pricing is stored in the `credit_pricing_config` table via the
|
|
94
|
+
`set_active_pricing_config` RPC. The
|
|
95
|
+
`CreditManager.load_pricing_from_store()` method fetches the active
|
|
96
|
+
config at runtime. See [`scripts/seed_pricing.py`](scripts/seed_pricing.py)
|
|
97
|
+
for reference.
|
|
98
|
+
|
|
99
|
+
### Expression format
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"version": 1,
|
|
104
|
+
"models": {
|
|
105
|
+
"gpt-4": "input_tokens * 0.01 + output_tokens * 0.03",
|
|
106
|
+
"_default": "input_tokens * 0.001 + output_tokens * 0.003"
|
|
107
|
+
},
|
|
108
|
+
"tools": {
|
|
109
|
+
"_default": "tool_calls * 0",
|
|
110
|
+
"web_search": "web_search_calls * 0.5"
|
|
111
|
+
},
|
|
112
|
+
"search": {
|
|
113
|
+
"costs": "search_queries * 0.5 + search_results * 0.05"
|
|
114
|
+
},
|
|
115
|
+
"cache": {
|
|
116
|
+
"discount": "-cache_read_tokens * 0.0045"
|
|
117
|
+
},
|
|
118
|
+
"fixed": {
|
|
119
|
+
"batch_job": 20
|
|
120
|
+
},
|
|
121
|
+
"min_balance": 5
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Available expression variables
|
|
126
|
+
|
|
127
|
+
| Variable | Source field in `UsageMetrics` |
|
|
128
|
+
|----------|--------------------------------|
|
|
129
|
+
| `input_tokens` | `metrics.input_tokens` |
|
|
130
|
+
| `output_tokens` | `metrics.output_tokens` |
|
|
131
|
+
| `cache_read_tokens` | `metrics.cache_read_tokens` |
|
|
132
|
+
| `cache_write_tokens` | `metrics.cache_write_tokens` |
|
|
133
|
+
| `tool_calls` | `len(metrics.tool_calls)` |
|
|
134
|
+
| `search_queries` | `metrics.search_queries` |
|
|
135
|
+
| `search_results` | `metrics.search_results` |
|
|
136
|
+
| `web_search_calls` | `metrics.web_search_calls` |
|
|
137
|
+
| `code_exec_calls` | `metrics.code_exec_calls` |
|
|
138
|
+
|
|
139
|
+
### Supported functions
|
|
140
|
+
|
|
141
|
+
`ceil`, `floor`, `min`, `max`, `round`
|
|
142
|
+
|
|
143
|
+
### Version 1 rules
|
|
144
|
+
|
|
145
|
+
- `models` section is **required** and must be a non-empty dict
|
|
146
|
+
- `_default` model is used when no specific model matches
|
|
147
|
+
- Tool costs don't double-count: tools with individual entries are
|
|
148
|
+
evaluated separately; remaining calls use `_default`
|
|
149
|
+
- `cache.discount` is typically a negative value (savings/rebate)
|
|
150
|
+
- `fixed` costs are non-negative integers, applied when
|
|
151
|
+
`UsageMetrics.fixed_job` matches
|
|
152
|
+
|
|
153
|
+
## Storage Backends
|
|
154
|
+
|
|
155
|
+
### MemoryStore (testing/dev)
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
from ducto import CreditManager
|
|
159
|
+
from ducto.interface.memory import MemoryStore
|
|
160
|
+
|
|
161
|
+
store = MemoryStore()
|
|
162
|
+
manager = CreditManager(store=store)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### SupabaseStore
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
from ducto.interface.supabase import HttpxSupabaseStore
|
|
169
|
+
|
|
170
|
+
store = HttpxSupabaseStore(url=supabase_url, key=service_role_key)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### PostgresStore
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
from ducto.interface.postgres import PostgresStore
|
|
177
|
+
|
|
178
|
+
store = PostgresStore("postgresql://user:pass@host:5432/db")
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Custom adapters
|
|
182
|
+
|
|
183
|
+
Implement `ducto.interface.base.CreditStore` (an ABC with 8 methods) to
|
|
184
|
+
integrate with any backend.
|
|
185
|
+
|
|
186
|
+
## Credit Lifecycle
|
|
187
|
+
|
|
188
|
+
`CreditManager` orchestrates a three-step reserve-then-deduct pattern:
|
|
189
|
+
|
|
190
|
+
1. **Calculate** — `PricingEngine.calculate(UsageMetrics)` -> `CostBreakdown`
|
|
191
|
+
2. **Reserve** — `store.reserve_credits(user_id, amount)` -> `ReserveResult`
|
|
192
|
+
(locks the user row; reservations auto-expire after 10 minutes)
|
|
193
|
+
3. **Deduct** — `store.deduct_credits(user_id, reservation_id, amount)`
|
|
194
|
+
-> `DeductionResult` (idempotent, atomic)
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
manager = CreditManager(store=store)
|
|
198
|
+
manager.load_pricing_from_store()
|
|
199
|
+
result = manager.deduct(
|
|
200
|
+
user_id="user_abc",
|
|
201
|
+
metrics=UsageMetrics(model="gpt-4", input_tokens=100, output_tokens=50),
|
|
202
|
+
idempotency_key="tx_42",
|
|
203
|
+
)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Pricing can also be loaded from a dict (no database):
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
manager.publish_pricing_from_dict({
|
|
210
|
+
"version": 1,
|
|
211
|
+
"models": {"_default": "input_tokens * 0.001 + output_tokens * 0.003"},
|
|
212
|
+
})
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## SQL Migrations
|
|
216
|
+
|
|
217
|
+
Three bundled SQL files create the required schema:
|
|
218
|
+
|
|
219
|
+
| File | Creates |
|
|
220
|
+
|------|---------|
|
|
221
|
+
| `001_credit_tables.sql` | `user_credits`, `credit_transactions`, `credit_reservations` tables, RLS policies, signup bonus trigger |
|
|
222
|
+
| `002_credit_rpcs.sql` | `credits_add`, `reserve_credits`, `deduct_credits`, `get_credits_balance` RPCs (SECURITY DEFINER, service_role only) |
|
|
223
|
+
| `003_pricing_config.sql` | `credit_pricing_config` table, `get_active_pricing_config`, `set_active_pricing_config` RPCs |
|
|
224
|
+
|
|
225
|
+
All DDL is idempotent (uses `IF NOT EXISTS` / `CREATE OR REPLACE`).
|
|
226
|
+
|
|
227
|
+
### CLI reference
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
# Create tables, indexes, and RPC functions
|
|
231
|
+
ducto migrate "postgresql://user:pass@host:5432/db"
|
|
232
|
+
|
|
233
|
+
# Show current active pricing config
|
|
234
|
+
ducto pricing get
|
|
235
|
+
|
|
236
|
+
# Update active pricing from a JSON or YAML file
|
|
237
|
+
ducto pricing set config.yaml
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
The `pricing` commands require `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY`
|
|
241
|
+
environment variables.
|
|
242
|
+
|
|
243
|
+
Or from Python:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
from ducto.interface.supabase import run_migrations
|
|
247
|
+
|
|
248
|
+
result = run_migrations("postgresql://user:pass@host:5432/db")
|
|
249
|
+
assert result.success, result.errors
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Expression Safety
|
|
253
|
+
|
|
254
|
+
The expression engine uses a strict AST-walking validator:
|
|
255
|
+
|
|
256
|
+
1. Parse `ast.parse(expr, mode="eval")`
|
|
257
|
+
2. Walk the AST -- every node type must be in an allowlist (~25 node
|
|
258
|
+
types: binary ops, comparisons, conditionals, booleans, constants,
|
|
259
|
+
names, calls)
|
|
260
|
+
3. Function calls must be in a whitelist (`ceil`, `floor`, `min`, `max`,
|
|
261
|
+
`round`)
|
|
262
|
+
4. Rejects: attributes (`x.__class__`), subscripts (`x[0]`), lambdas,
|
|
263
|
+
comprehensions, imports, starred expressions
|
|
264
|
+
5. Evaluation namespace has `__builtins__` emptied -- only the 5
|
|
265
|
+
whitelisted math/python builtins and user-provided variable names are
|
|
266
|
+
available
|
|
267
|
+
6. All expression strings are validated at config load time -- invalid
|
|
268
|
+
configs never reach the engine
|
|
269
|
+
|
|
270
|
+
## Architecture
|
|
271
|
+
|
|
272
|
+
```
|
|
273
|
+
ducto/
|
|
274
|
+
expr.py # Safe AST expression evaluator
|
|
275
|
+
config.py # Pydantic model + dict loading for PricingConfig
|
|
276
|
+
engine.py # PricingEngine -- core calculation logic
|
|
277
|
+
metrics.py # UsageMetrics, ToolCall dataclasses
|
|
278
|
+
breakdown.py # CostBreakdown dataclass
|
|
279
|
+
manager.py # CreditManager -- calculate -> reserve -> deduct
|
|
280
|
+
interface/
|
|
281
|
+
base.py # CreditStore ABC
|
|
282
|
+
models.py # Pydantic schemas for store operations
|
|
283
|
+
memory.py # MemoryStore (in-memory for testing)
|
|
284
|
+
supabase.py # HttpxSupabaseStore adapter + run_migrations()
|
|
285
|
+
postgres.py # PostgresStore adapter
|
|
286
|
+
sql/
|
|
287
|
+
001_credit_tables.sql
|
|
288
|
+
002_credit_rpcs.sql
|
|
289
|
+
003_pricing_config.sql
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
## Development
|
|
293
|
+
|
|
294
|
+
```bash
|
|
295
|
+
# Install with dev dependencies
|
|
296
|
+
pip install "ducto[test]"
|
|
297
|
+
|
|
298
|
+
# Run tests
|
|
299
|
+
pytest
|
|
300
|
+
|
|
301
|
+
# Lint & format
|
|
302
|
+
ruff check .
|
|
303
|
+
ruff format .
|
|
304
|
+
|
|
305
|
+
# Type check
|
|
306
|
+
pyright
|
|
307
|
+
```
|
|
308
|
+
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ducto"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Declarative credit calculation engine AI SaaS platforms"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"pydantic>=2.0.0",
|
|
8
|
+
"pyyaml>=6.0.3",
|
|
9
|
+
]
|
|
10
|
+
keywords = ["credits", "billing", "llm", "usage-metering", "pricing"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/apoorwv/ducto"
|
|
24
|
+
Source = "https://github.com/apoorwv/ducto"
|
|
25
|
+
Documentation = "https://github.com/apoorwv/ducto#readme"
|
|
26
|
+
Issues = "https://github.com/apoorwv/ducto/issues"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["setuptools>=64"]
|
|
30
|
+
build-backend = "setuptools.build_meta"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
ducto = "ducto.__main__:main"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
supabase = [
|
|
40
|
+
"httpx>=0.27",
|
|
41
|
+
"psycopg2-binary>=2.9",
|
|
42
|
+
]
|
|
43
|
+
postgres = [
|
|
44
|
+
"psycopg2-binary>=2.9",
|
|
45
|
+
]
|
|
46
|
+
test = [
|
|
47
|
+
"pytest>=8.0",
|
|
48
|
+
"ruff>=0.15.0",
|
|
49
|
+
"pyright",
|
|
50
|
+
"pytest-testmon>=2.0.0",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[tool.setuptools.package-data]
|
|
54
|
+
ducto = ["sql/*.sql"]
|
|
55
|
+
|
|
56
|
+
[tool.ruff]
|
|
57
|
+
target-version = "py311"
|
|
58
|
+
line-length = 120
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint]
|
|
61
|
+
select = ["E", "F", "I", "N", "W", "UP", "B", "ASYNC", "RUF100", "SIM", "RET", "C901"]
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint.mccabe]
|
|
64
|
+
max-complexity = 15
|
|
65
|
+
|
|
66
|
+
[tool.ruff.lint.per-file-ignores]
|
|
67
|
+
"**/__init__.py" = ["UP006"]
|
|
68
|
+
"**/test_*.py" = ["SIM", "RET", "N"]
|
|
69
|
+
|
|
70
|
+
[tool.ruff.format]
|
|
71
|
+
quote-style = "double"
|
|
72
|
+
indent-style = "space"
|
|
73
|
+
|
|
74
|
+
[tool.pyright]
|
|
75
|
+
typeCheckingMode = "standard"
|
|
76
|
+
useLibraryCodeForTypes = false
|
|
77
|
+
venvPath = "."
|
|
78
|
+
venv = ".venv"
|
|
79
|
+
exclude = ["**/__pycache__", "**/.venv/**", "**/.pytest_cache/**"]
|
|
80
|
+
reportMissingImports = "none"
|
|
81
|
+
reportMissingModuleSource = "none"
|
|
82
|
+
reportOptionalMemberAccess = "none"
|
|
83
|
+
|
|
84
|
+
[tool.coverage.run]
|
|
85
|
+
source = ["ducto"]
|
|
86
|
+
branch = true
|
|
87
|
+
|
|
88
|
+
[tool.coverage.report]
|
|
89
|
+
exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:", "def __repr__", "raise NotImplementedError"]
|
|
90
|
+
|
|
91
|
+
[dependency-groups]
|
|
92
|
+
dev = [
|
|
93
|
+
"pytest-testmon>=2.2.0",
|
|
94
|
+
]
|
ducto-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""ducto — declarative credit calculation engine for AI SaaS platforms."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from ducto.breakdown import CostBreakdown
|
|
6
|
+
from ducto.config import ConfigError, PricingConfig
|
|
7
|
+
from ducto.engine import PricingEngine
|
|
8
|
+
from ducto.expr import ExpressionError, evaluate_expression, validate_expression
|
|
9
|
+
from ducto.interface.memory import MemoryStore
|
|
10
|
+
from ducto.interface.models import (
|
|
11
|
+
AddCreditsResult,
|
|
12
|
+
BalanceResult,
|
|
13
|
+
CreditMetadata,
|
|
14
|
+
DeductionResult,
|
|
15
|
+
PricingConfigData,
|
|
16
|
+
PricingConfigResult,
|
|
17
|
+
ReserveResult,
|
|
18
|
+
SetupResult,
|
|
19
|
+
)
|
|
20
|
+
from ducto.manager import CreditManager, InsufficientCreditsError, PricingNotLoadedError
|
|
21
|
+
from ducto.metrics import ToolCall, UsageMetrics
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"PricingEngine",
|
|
25
|
+
"CostBreakdown",
|
|
26
|
+
"UsageMetrics",
|
|
27
|
+
"ToolCall",
|
|
28
|
+
"PricingConfig",
|
|
29
|
+
"ConfigError",
|
|
30
|
+
"ExpressionError",
|
|
31
|
+
"evaluate_expression",
|
|
32
|
+
"validate_expression",
|
|
33
|
+
"CreditManager",
|
|
34
|
+
"InsufficientCreditsError",
|
|
35
|
+
"PricingNotLoadedError",
|
|
36
|
+
"CreditMetadata",
|
|
37
|
+
"PricingConfigData",
|
|
38
|
+
"BalanceResult",
|
|
39
|
+
"AddCreditsResult",
|
|
40
|
+
"ReserveResult",
|
|
41
|
+
"DeductionResult",
|
|
42
|
+
"PricingConfigResult",
|
|
43
|
+
"SetupResult",
|
|
44
|
+
"MemoryStore",
|
|
45
|
+
]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""ducto CLI — migrate, pricing get/set."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ducto.interface.supabase import HttpxSupabaseStore
|
|
13
|
+
|
|
14
|
+
_RETRY_DELAY = 2
|
|
15
|
+
_RETRIES = 15
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _store_from_env() -> HttpxSupabaseStore:
|
|
19
|
+
"""Create HttpxSupabaseStore from SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY."""
|
|
20
|
+
url = os.environ.get("SUPABASE_URL")
|
|
21
|
+
key = os.environ.get("SUPABASE_SERVICE_ROLE_KEY")
|
|
22
|
+
if not url:
|
|
23
|
+
print("SUPABASE_URL required", file=sys.stderr)
|
|
24
|
+
sys.exit(1)
|
|
25
|
+
if not key:
|
|
26
|
+
print("SUPABASE_SERVICE_ROLE_KEY required", file=sys.stderr)
|
|
27
|
+
sys.exit(1)
|
|
28
|
+
|
|
29
|
+
from ducto.interface.supabase import HttpxSupabaseStore
|
|
30
|
+
|
|
31
|
+
return HttpxSupabaseStore(url=url, key=key)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _migrate(args: list[str]) -> None:
|
|
35
|
+
if not args:
|
|
36
|
+
print("Usage: ducto migrate <database_url>", file=sys.stderr)
|
|
37
|
+
sys.exit(1)
|
|
38
|
+
|
|
39
|
+
from ducto.interface.supabase import run_migrations
|
|
40
|
+
|
|
41
|
+
result = run_migrations(args[0])
|
|
42
|
+
for t in result.tables_created:
|
|
43
|
+
print(f" ✓ {t}")
|
|
44
|
+
for e in result.errors:
|
|
45
|
+
print(f" ✗ {e}", file=sys.stderr)
|
|
46
|
+
|
|
47
|
+
if result.success:
|
|
48
|
+
print("Migration complete.")
|
|
49
|
+
else:
|
|
50
|
+
print("Migration completed with errors.", file=sys.stderr)
|
|
51
|
+
sys.exit(1)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _load_pricing_file(filepath: str) -> dict:
|
|
55
|
+
"""Read a JSON or YAML pricing config file."""
|
|
56
|
+
if filepath.endswith((".yaml", ".yml")):
|
|
57
|
+
try:
|
|
58
|
+
import yaml
|
|
59
|
+
except ImportError:
|
|
60
|
+
print("PyYAML required for .yaml files: pip install ducto[supabase]", file=sys.stderr)
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
try:
|
|
63
|
+
with open(filepath) as f:
|
|
64
|
+
return yaml.safe_load(f)
|
|
65
|
+
except FileNotFoundError:
|
|
66
|
+
print(f"File not found: {filepath}", file=sys.stderr)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
else:
|
|
69
|
+
try:
|
|
70
|
+
with open(filepath) as f:
|
|
71
|
+
return json.load(f)
|
|
72
|
+
except FileNotFoundError:
|
|
73
|
+
print(f"File not found: {filepath}", file=sys.stderr)
|
|
74
|
+
sys.exit(1)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _pricing_set(args: list[str]) -> None:
|
|
78
|
+
if not args:
|
|
79
|
+
print("Usage: ducto pricing set <file.json|file.yaml>", file=sys.stderr)
|
|
80
|
+
sys.exit(1)
|
|
81
|
+
|
|
82
|
+
from ducto.interface.models import PricingConfigData
|
|
83
|
+
|
|
84
|
+
data = _load_pricing_file(args[0])
|
|
85
|
+
|
|
86
|
+
config = PricingConfigData.model_validate(data)
|
|
87
|
+
|
|
88
|
+
store = _store_from_env()
|
|
89
|
+
|
|
90
|
+
# Retry: PostgREST schema cache may not be refreshed yet
|
|
91
|
+
for attempt in range(_RETRIES):
|
|
92
|
+
try:
|
|
93
|
+
existing = store.get_active_pricing()
|
|
94
|
+
if existing is not None:
|
|
95
|
+
print(f"Active pricing already exists (id={existing.id}) — skipping.")
|
|
96
|
+
return
|
|
97
|
+
store.set_active_pricing(config)
|
|
98
|
+
print("Pricing config set successfully.")
|
|
99
|
+
return
|
|
100
|
+
except Exception as exc:
|
|
101
|
+
if attempt == _RETRIES - 1:
|
|
102
|
+
print(f"Failed to set pricing: {exc}", file=sys.stderr)
|
|
103
|
+
print("Tip: Ensure 'ducto migrate' has been run and the schema cache has refreshed.", file=sys.stderr)
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
time.sleep(_RETRY_DELAY)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _pricing_get() -> None:
|
|
109
|
+
store = _store_from_env()
|
|
110
|
+
for attempt in range(_RETRIES):
|
|
111
|
+
try:
|
|
112
|
+
result = store.get_active_pricing()
|
|
113
|
+
if result is None:
|
|
114
|
+
print("No active pricing config.", file=sys.stderr)
|
|
115
|
+
sys.exit(1)
|
|
116
|
+
print(json.dumps(result.model_dump(mode="json"), indent=2))
|
|
117
|
+
return
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
if attempt == _RETRIES - 1:
|
|
120
|
+
print(f"Failed to get pricing: {exc}", file=sys.stderr)
|
|
121
|
+
print("Tip: Ensure 'ducto migrate' has been run and the schema cache has refreshed.", file=sys.stderr)
|
|
122
|
+
sys.exit(1)
|
|
123
|
+
time.sleep(_RETRY_DELAY)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _pricing(args: list[str]) -> None:
|
|
127
|
+
if not args:
|
|
128
|
+
print("Usage: ducto pricing <get|set> ...", file=sys.stderr)
|
|
129
|
+
sys.exit(1)
|
|
130
|
+
|
|
131
|
+
sub = args[0]
|
|
132
|
+
if sub == "set":
|
|
133
|
+
_pricing_set(args[1:])
|
|
134
|
+
elif sub == "get":
|
|
135
|
+
_pricing_get()
|
|
136
|
+
else:
|
|
137
|
+
print(f"Unknown pricing subcommand: {sub}", file=sys.stderr)
|
|
138
|
+
sys.exit(1)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main() -> None:
|
|
142
|
+
if len(sys.argv) < 2:
|
|
143
|
+
print("Usage: ducto <migrate|pricing> ...", file=sys.stderr)
|
|
144
|
+
sys.exit(1)
|
|
145
|
+
|
|
146
|
+
cmd = sys.argv[1]
|
|
147
|
+
if cmd == "migrate":
|
|
148
|
+
_migrate(sys.argv[2:])
|
|
149
|
+
elif cmd == "pricing":
|
|
150
|
+
_pricing(sys.argv[2:])
|
|
151
|
+
else:
|
|
152
|
+
print(f"Unknown command: {cmd}", file=sys.stderr)
|
|
153
|
+
sys.exit(1)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
main()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Aggregated cost breakdown produced by ``PricingEngine.calculate()``.
|
|
2
|
+
|
|
3
|
+
The ``CostBreakdown`` dataclass holds per-category credit costs and
|
|
4
|
+
computes a ``total`` in ``__post_init__``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class CostBreakdown:
|
|
12
|
+
"""Granular credit cost report for a usage event or batch.
|
|
13
|
+
|
|
14
|
+
``total`` is automatically computed from the component fields
|
|
15
|
+
during initialisation and is capped at ``0.0`` from below.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
model_credits: float = 0.0
|
|
19
|
+
tool_credits: float = 0.0
|
|
20
|
+
search_credits: float = 0.0
|
|
21
|
+
cache_savings: float = 0.0
|
|
22
|
+
fixed_credits: float = 0.0
|
|
23
|
+
total: float = 0.0
|
|
24
|
+
breakdown: dict = field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
def __post_init__(self) -> None:
|
|
27
|
+
self.total = max(
|
|
28
|
+
0.0,
|
|
29
|
+
self.model_credits + self.tool_credits + self.search_credits + self.fixed_credits + self.cache_savings,
|
|
30
|
+
)
|