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.
Files changed (36) hide show
  1. ducto-0.1.0/LICENSE +21 -0
  2. ducto-0.1.0/PKG-INFO +32 -0
  3. ducto-0.1.0/README.md +308 -0
  4. ducto-0.1.0/pyproject.toml +94 -0
  5. ducto-0.1.0/setup.cfg +4 -0
  6. ducto-0.1.0/src/ducto/__init__.py +45 -0
  7. ducto-0.1.0/src/ducto/__main__.py +157 -0
  8. ducto-0.1.0/src/ducto/breakdown.py +30 -0
  9. ducto-0.1.0/src/ducto/config.py +81 -0
  10. ducto-0.1.0/src/ducto/engine.py +231 -0
  11. ducto-0.1.0/src/ducto/expr.py +149 -0
  12. ducto-0.1.0/src/ducto/interface/__init__.py +1 -0
  13. ducto-0.1.0/src/ducto/interface/base.py +114 -0
  14. ducto-0.1.0/src/ducto/interface/memory.py +231 -0
  15. ducto-0.1.0/src/ducto/interface/models.py +110 -0
  16. ducto-0.1.0/src/ducto/interface/postgres.py +244 -0
  17. ducto-0.1.0/src/ducto/interface/supabase.py +230 -0
  18. ducto-0.1.0/src/ducto/manager.py +245 -0
  19. ducto-0.1.0/src/ducto/metrics.py +34 -0
  20. ducto-0.1.0/src/ducto/py.typed +0 -0
  21. ducto-0.1.0/src/ducto/sql/001_credit_tables.sql +162 -0
  22. ducto-0.1.0/src/ducto/sql/002_credit_rpcs.sql +256 -0
  23. ducto-0.1.0/src/ducto/sql/003_pricing_config.sql +108 -0
  24. ducto-0.1.0/src/ducto/sql/__init__.py +13 -0
  25. ducto-0.1.0/src/ducto.egg-info/PKG-INFO +32 -0
  26. ducto-0.1.0/src/ducto.egg-info/SOURCES.txt +34 -0
  27. ducto-0.1.0/src/ducto.egg-info/dependency_links.txt +1 -0
  28. ducto-0.1.0/src/ducto.egg-info/entry_points.txt +2 -0
  29. ducto-0.1.0/src/ducto.egg-info/requires.txt +15 -0
  30. ducto-0.1.0/src/ducto.egg-info/top_level.txt +1 -0
  31. ducto-0.1.0/tests/test_cli.py +59 -0
  32. ducto-0.1.0/tests/test_config.py +109 -0
  33. ducto-0.1.0/tests/test_engine.py +209 -0
  34. ducto-0.1.0/tests/test_expr.py +88 -0
  35. ducto-0.1.0/tests/test_manager.py +195 -0
  36. 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
+ [![CI](https://github.com/apoorwv/ducto/actions/workflows/ci.yml/badge.svg)](https://github.com/apoorwv/ducto/actions/workflows/ci.yml)
4
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue)](https://www.python.org/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ )