Skip to main content

Declarative credit calculation engine for AI SaaS platforms

Project description

ducto

CI Python 3.11+ License: MIT

Add usage-based credits to your AI SaaS in minutes — not weeks.

ducto is a drop-in credit calculation engine. Define pricing as math expressions (per-model, per-tool, search/RAG, cache, fixed jobs), connect a database, and start deducting credits. No billing infrastructure to build. No YAML configs. Pricing lives in your DB — update it live without redeploys.

from ducto import CreditManager, UsageMetrics
from ducto.interface.supabase import HttpxSupabaseStore

# 3 lines. Your users now have credits.
store = HttpxSupabaseStore(url=supabase_url, key=service_role_key)
manager = CreditManager(store=store)
manager.load_pricing_from_store()

# Deduct credits from a single LLM call
manager.deduct(
    user_id="user_abc",
    metrics=UsageMetrics(model="gpt-4", input_tokens=500, output_tokens=200),
    idempotency_key="chat_42_turn_7",
)

Run ducto migrate "postgresql://..." && ducto pricing set config.json and you're live. Balance tracking, idempotent deductions, overuse protection — all handled.

Features

  • Safe expression engine — Uses Python's ast module with a strict allowlist (no eval() of raw strings, no exec(), no attribute access, no imports). Validated at config load time.
  • Database-backed pricing — Pricing expressions stored in a credit_pricing_config table. Enables live pricing updates without redeploys. Dict loading available for testing and stateless calculation.
  • Multi-dimensional — Per-model formulas (with _default fallback), per-tool overrides, search/RAG, cache read discounts, fixed-cost jobs.
  • Stateless core — Pure calculation layer has zero database dependency.
  • Auditable — Returns a structured CostBreakdown with per-dimension costs and metadata.
  • Pluggable storage — Reserve-then-deduct pattern via CreditStore adapters: Supabase, raw PostgreSQL, or in-memory for testing.
  • Safe defaults — Configurable min_balance floor, reservation expiry (10 min), non-negative balance enforcement at the database level.

Installation

pip install ducto

# With Supabase store support
pip install "ducto[supabase]"

# With PostgreSQL store support
pip install "ducto[postgres]"

# Development & testing
pip install "ducto[test]"

Requires Python 3.11+.

Quick Start

1. Install and migrate

pip install "ducto[postgres]"
ducto migrate "postgresql://user:pass@host:5432/db"

Creates all tables (user_credits, credit_transactions, credit_reservations) and RPCs — idempotent, safe to run on every deploy.

2. Seed pricing

ducto pricing set - <<'JSON'
{
  "version": 1,
  "models": {
    "gpt-4": "input_tokens * 0.01 + output_tokens * 0.03",
    "_default": "input_tokens * 0.001 + output_tokens * 0.003"
  }
}
JSON

Pricing is stored in credit_pricing_config. Update it at any time — no redeploy needed.

3. Deduct credits

from ducto import CreditManager, UsageMetrics
from ducto.interface.postgres import PostgresStore

store = PostgresStore("postgresql://user:pass@host:5432/db")
manager = CreditManager(store=store)
manager.load_pricing_from_store()

manager.add_credits("user_abc", 1000)

result = manager.deduct(
    user_id="user_abc",
    metrics=UsageMetrics(model="gpt-4", input_tokens=500, output_tokens=200),
    idempotency_key="chat_session_42",
)
print(f"Deducted {abs(result.amount)} credits. Balance: {result.balance_after}")

Calculation only (no database)

For testing or stateless calculation without a store:

from ducto import PricingEngine, UsageMetrics

engine = PricingEngine.from_dict({
    "version": 1,
    "models": {"_default": "input_tokens * 0.001 + output_tokens * 0.003"},
})

result = engine.calculate(
    UsageMetrics(model="gpt-4", input_tokens=500, output_tokens=200),
)
print(f"Total credits: {result.total}")

Pricing Configuration

Pricing is stored in the credit_pricing_config table via the set_active_pricing_config RPC. The CreditManager.load_pricing_from_store() method fetches the active config at runtime. See scripts/seed_pricing.py for reference.

Expression format

{
  "version": 1,
  "models": {
    "gpt-4": "input_tokens * 0.01 + output_tokens * 0.03",
    "_default": "input_tokens * 0.001 + output_tokens * 0.003"
  },
  "tools": {
    "_default": "tool_calls * 0",
    "web_search": "web_search_calls * 0.5"
  },
  "search": {
    "costs": "search_queries * 0.5 + search_results * 0.05"
  },
  "cache": {
    "discount": "-cache_read_tokens * 0.0045"
  },
  "fixed": {
    "batch_job": 20
  },
  "min_balance": 5
}

Available expression variables

Variable Source field in UsageMetrics
input_tokens metrics.input_tokens
output_tokens metrics.output_tokens
cache_read_tokens metrics.cache_read_tokens
cache_write_tokens metrics.cache_write_tokens
tool_calls len(metrics.tool_calls)
search_queries metrics.search_queries
search_results metrics.search_results
web_search_calls metrics.web_search_calls
code_exec_calls metrics.code_exec_calls

Supported functions

ceil, floor, min, max, round

Version 1 rules

  • models section is required and must be a non-empty dict
  • _default model is used when no specific model matches
  • Tool costs don't double-count: tools with individual entries are evaluated separately; remaining calls use _default
  • cache.discount is typically a negative value (savings/rebate)
  • fixed costs are non-negative integers, applied when UsageMetrics.fixed_job matches

Storage Backends

MemoryStore (testing/dev)

from ducto import CreditManager
from ducto.interface.memory import MemoryStore

store = MemoryStore()
manager = CreditManager(store=store)

SupabaseStore

from ducto.interface.supabase import HttpxSupabaseStore

store = HttpxSupabaseStore(url=supabase_url, key=service_role_key)

PostgresStore

from ducto.interface.postgres import PostgresStore

store = PostgresStore("postgresql://user:pass@host:5432/db")

Custom adapters

Implement ducto.interface.base.CreditStore (an ABC with 8 methods) to integrate with any backend.

Credit Lifecycle

CreditManager orchestrates a three-step reserve-then-deduct pattern:

  1. CalculatePricingEngine.calculate(UsageMetrics) -> CostBreakdown
  2. Reservestore.reserve_credits(user_id, amount) -> ReserveResult (locks the user row; reservations auto-expire after 10 minutes)
  3. Deductstore.deduct_credits(user_id, reservation_id, amount) -> DeductionResult (idempotent, atomic)
manager = CreditManager(store=store)
manager.load_pricing_from_store()
result = manager.deduct(
    user_id="user_abc",
    metrics=UsageMetrics(model="gpt-4", input_tokens=100, output_tokens=50),
    idempotency_key="tx_42",
)

Pricing can also be loaded from a dict (no database):

manager.publish_pricing_from_dict({
    "version": 1,
    "models": {"_default": "input_tokens * 0.001 + output_tokens * 0.003"},
})

SQL Migrations

Three bundled SQL files create the required schema:

File Creates
001_credit_tables.sql user_credits, credit_transactions, credit_reservations tables, RLS policies, signup bonus trigger
002_credit_rpcs.sql credits_add, reserve_credits, deduct_credits, get_credits_balance RPCs (SECURITY DEFINER, service_role only)
003_pricing_config.sql credit_pricing_config table, get_active_pricing_config, set_active_pricing_config RPCs

All DDL is idempotent (uses IF NOT EXISTS / CREATE OR REPLACE).

CLI reference

# Create tables, indexes, and RPC functions
ducto migrate "postgresql://user:pass@host:5432/db"

# Show current active pricing config
ducto pricing get

# Update active pricing from a JSON or YAML file
ducto pricing set config.yaml

The pricing commands require SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY environment variables.

Or from Python:

from ducto.interface.supabase import run_migrations

result = run_migrations("postgresql://user:pass@host:5432/db")
assert result.success, result.errors

Expression Safety

The expression engine uses a strict AST-walking validator:

  1. Parse ast.parse(expr, mode="eval")
  2. Walk the AST -- every node type must be in an allowlist (~25 node types: binary ops, comparisons, conditionals, booleans, constants, names, calls)
  3. Function calls must be in a whitelist (ceil, floor, min, max, round)
  4. Rejects: attributes (x.__class__), subscripts (x[0]), lambdas, comprehensions, imports, starred expressions
  5. Evaluation namespace has __builtins__ emptied -- only the 5 whitelisted math/python builtins and user-provided variable names are available
  6. All expression strings are validated at config load time -- invalid configs never reach the engine

Architecture

ducto/
  expr.py          # Safe AST expression evaluator
  config.py        # Pydantic model + dict loading for PricingConfig
  engine.py        # PricingEngine -- core calculation logic
  metrics.py       # UsageMetrics, ToolCall dataclasses
  breakdown.py     # CostBreakdown dataclass
  manager.py       # CreditManager -- calculate -> reserve -> deduct
  interface/
    base.py        # CreditStore ABC
    models.py      # Pydantic schemas for store operations
    memory.py      # MemoryStore (in-memory for testing)
    supabase.py    # HttpxSupabaseStore adapter + run_migrations()
    postgres.py    # PostgresStore adapter
  sql/
    001_credit_tables.sql
    002_credit_rpcs.sql
    003_pricing_config.sql

Development

# Install with dev dependencies
pip install "ducto[test]"

# Run tests
pytest

# Lint & format
ruff check .
ruff format .

# Type check
pyright

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ducto-0.1.6.tar.gz (34.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ducto-0.1.6-py3-none-any.whl (31.2 kB view details)

Uploaded Python 3

File details

Details for the file ducto-0.1.6.tar.gz.

File metadata

  • Download URL: ducto-0.1.6.tar.gz
  • Upload date:
  • Size: 34.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.23 {"installer":{"name":"uv","version":"0.11.23","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for ducto-0.1.6.tar.gz
Algorithm Hash digest
SHA256 944506e69583789d5f7c3666335ef30b905077a47c12893a5ce648d27c20d050
MD5 4bb6a3a4ca730c73a1d13a794d1729f6
BLAKE2b-256 d3eba12349e5c7df22c83820d5685adad7eb09498736ced5771177f3b9b53f9f

See more details on using hashes here.

File details

Details for the file ducto-0.1.6-py3-none-any.whl.

File metadata

  • Download URL: ducto-0.1.6-py3-none-any.whl
  • Upload date:
  • Size: 31.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.23 {"installer":{"name":"uv","version":"0.11.23","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for ducto-0.1.6-py3-none-any.whl
Algorithm Hash digest
SHA256 0019392f5a8a09f2fb5bc76e1c1efa180a29419dbf3348593b239416e5eee13e
MD5 2bba8fff55db41a486e433edf06fd1d0
BLAKE2b-256 7ef714fb97e25f8bce1d826970292024acfd37e84d51e995ca6780c298b50dfb

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page