Skip to main content

Declarative credit calculation engine for AI SaaS platforms

Project description

ducto

CI Python 3.11+ [License: MIT(LICENSE)

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. Pricing lives in your DB — update it live without redeploys.

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

store = HttpxSupabaseStore(url=supabase_url, key=service_role_key)
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_42",
)
print(f"Deducted {abs(result.amount)} credits. Balance: {result.balance_after}")

Features

  • 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.
  • Plan-based pricing — Subscription plans with free monthly allowances, rate overrides, and feature flags. Allowance consumed before balance.
  • Refunds — Full and partial credit reversals with duplicate detection and idempotency.
  • Credit expiry / TTL — Time-bound credits with expires_at on add_credits. Sweep with dry-run mode.
  • Team / shared balances — Separate team credit pools with per-member spend caps and attribution.
  • Spend caps — Per-user daily/monthly limits with deny, warn, notify actions. Per-model caps supported.
  • Usage analyticsspend_by_user, spend_by_model, top_users, daily_spend, aggregate_stats across time windows.
  • Event hooks — Typed pub/sub for credits.deducted, credits.added, credits.refunded, credits.expired, credits.cap_reached, credits.cap_warning, credits.low_balance.
  • Database-backed pricing — Live updates without redeploys. Dict loading for testing.
  • Multi-dimensional — Per-model (with _default fallback), per-tool overrides, search/RAG, cache discounts, fixed-cost jobs.
  • Pluggable storage — Reserve-then-deduct via CreditStore adapters: Supabase, PostgreSQL, in-memory.
  • Safe defaultsmin_balance floor, reservation expiry (10 min), idempotent deductions, concurrent protection.
  • Auditable — Structured CostBreakdown with per-dimension costs.

Installation

pip install ducto

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

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

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

Requires Python 3.11+.

Full docs

apoorwv.github.io/ducto — Python API reference, expressions, configuration, examples.

Quick Start

0. Stateless calculation (no database)

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}")

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, credit_plans, credit_usage_window, credit_teams, credit_team_members, credit_spend_caps, credit_pricing_config) and 20+ RPCs — all idempotent.

2. Pricing version management

# Apply new pricing (creates v1)
ducto pricing set - <<'JSON'
{
  "version": 1,
  "models": { "_default": "input_tokens * 0.01 + output_tokens * 0.03" },
  "plans": {
    "free": { "id": "free", "name": "Free Tier", "free_allowance": 50000 },
    "pro": { "id": "pro", "name": "Pro", "free_allowance": 500000 }
  }
}
JSON

# Apply with a label
ducto pricing set pricing.yaml --label "deploy-42"

# List all versions  (* = active)
ducto pricing list

# Switch active pricing
ducto pricing activate 1

# Diff two versions
ducto pricing diff 1 2

# Export a version as JSON
ducto pricing export 2

# Validate without applying
ducto pricing validate pricing.yaml

Each pricing set creates a new immutable version. Roll back with pricing activate <version>.

Command Description
pricing set <file> [--label <msg>] Apply config (always creates new version)
pricing get Show active config
pricing list List all versions
pricing activate <version> Switch to any version
pricing validate <file> Dry-run validate
pricing diff <v1> <v2> Unified diff between versions
pricing export <version> Dump version as JSON

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="tx_001",
)
print(f"Deducted {abs(result.amount)} credits. Balance: {result.balance_after}")

Pricing Configuration

Basic config

{
  "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" },
  "search": { "costs": "search_queries * 0.5 + search_results * 0.05" },
  "cache": { "discount": "-cache_read_tokens * 0.0045" },
  "fixed": { "batch_job": 20 },
  "min_balance": 5
}

With plans

{
  "version": 1,
  "models": { "_default": "input_tokens * 0.01 + output_tokens * 0.03" },
  "plans": {
    "free": {
      "id": "free",
      "name": "Free Tier",
      "free_allowance": 50000,
      "rate_overrides": { "_default": "input_tokens * 0.02 + output_tokens * 0.06" },
      "features": { "max_concurrency": 1 }
    },
    "pro": {
      "id": "pro",
      "name": "Pro Plan",
      "free_allowance": 500000
    }
  }
}

Feature Examples

Refunds

tx = manager.deduct("user_abc", UsageMetrics(model="gpt-4", input_tokens=500))
refund = manager.refund_credits(tx.transaction_id)                     # full refund
partial = manager.refund_credits(tx.transaction_id, amount=5)          # partial

Credit expiry

manager.add_credits("user_abc", 100, "purchase", expires_at=datetime(2025, 1, 1))
result = manager.sweep_expired_credits()                                 # sweep
report = manager.sweep_expired_credits(dry_run=True)                     # preview only

Team / shared balances

team = store.create_team("Engineering", initial_balance=5000)
store.add_team_member(team.team_id, "user_abc", role="admin", spend_cap=1000)
result = manager.deduct_team(team.team_id, "user_abc", UsageMetrics(model="gpt-4", input_tokens=500))

Spend caps

from ducto.interface.models import SpendCap
store.set_spend_cap(SpendCap(user_id="user_abc", cap_type="daily", limit=100, action="deny"))

Usage analytics

from datetime import datetime, timedelta
now = datetime.now()
rows = manager.spend_by_user(now - timedelta(days=30), now)             # per-user totals
rows = manager.spend_by_model(now - timedelta(days=30), now)             # per-model spend
rows = manager.top_users(10, now - timedelta(days=30), now)              # top 10 users
rows = manager.daily_spend(now - timedelta(days=30), now)                # daily buckets
stats = manager.aggregate_stats(now - timedelta(days=30), now)           # aggregate summary

Events

from ducto.events import CreditEventEmitter
emitter = CreditEventEmitter()
manager = CreditManager(store=store, emitter=emitter)
emitter.on("credits.deducted", lambda e: print(f"User {e.user_id} spent credits"))
emitter.on("credits.low_balance", lambda e: send_alert(e.user_id, e.data["balance"]))

Expression syntax

Feature Example
Arithmetic +, -, *, /, //, %, **
Comparisons ==, !=, <, <=, >, >=, in, not in
Boolean and, or, not
Ternary X if cond else Y
Functions ceil, floor, round, min, max, if(cond,t,f), tier(v,t1,r1,t2,r2,...), clamp(x,lo,hi), percentile(p,v1,v2,...)

Available metrics

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

Storage Backends

Store Import Deps Use case
MemoryStore ducto.interface.memory.MemoryStore None Testing, dev
HttpxSupabaseStore ducto.interface.supabase.HttpxSupabaseStore httpx Supabase production
PostgresStore ducto.interface.postgres.PostgresStore psycopg2 Direct PostgreSQL

Custom stores

Implement ducto.interface.base.CreditStore (ABC with 18 abstract methods).

Credit Lifecycle

CreditManager.deduct() orchestrates:

  1. CalculatePricingEngine.calculate(metrics) → cost
  2. Plan allowance — consume free allowance if user has a plan
  3. Spend cap check — deny/warn/notify if configured limit exceeded
  4. Reservestore.reserve_credits() locks credits (auto-expires 10 min)
  5. Deductstore.deduct_credits() atomic deduction (idempotent)

Additional operations

  • Refund: manager.refund_credits(tx_id, amount?) — full or partial
  • Expire: manager.sweep_expired_credits(dry_run=True) — preview or execute
  • Team deduct: manager.deduct_team(team_id, user_id, metrics) — team pool
  • Analytics: spend_by_user, spend_by_model, top_users, daily_spend, aggregate_stats
  • Events: Subscribe via CreditEventEmitter for lifecycle hooks

SQL Migrations

10 bundled migrations (ducto migrate <url>):

File Contents
001_credit_tables.sql Core tables + RLS
002_credit_rpcs.sql Balance RPCs
003_pricing_config.sql Config table + RPCs
004_user_plans.sql Plans + usage windows
005_credit_refunds.sql Refund RPC
006_credit_expiry.sql Expiry sweep RPC
007_usage_analytics.sql Analytics RPCs
008_team_balances.sql Teams + members
009_spend_caps.sql Spend cap RPC
010_aggregate_stats.sql Aggregate stats RPC

Architecture

ducto/
  expr.py              # Safe AST expression evaluator
  config.py            # PricingConfig loading + validation
  engine.py            # PricingEngine — calculate, calculateBatch
  metrics.py           # UsageMetrics, ToolCall
  breakdown.py         # CostBreakdown
  events.py            # CreditEventEmitter pub/sub
  manager.py           # CreditManager orchestration
  interface/
    base.py            # CreditStore ABC (18 methods)
    models.py          # Pydantic schemas
    memory.py          # MemoryStore
    supabase.py        # HttpxSupabaseStore + run_migrations()
    postgres.py        # PostgresStore
  sql/                 # 010_*.sql

Expression Safety

  1. Parse ast.parse(expr, mode="eval")
  2. Walk AST — each node type in an allowlist
  3. Allowed functions: ceil, floor, round, min, max, if, tier, clamp, percentile
  4. Rejects: attributes, subscripts, lambdas, comprehensions, imports
  5. __builtins__ emptied at evaluation time
  6. All expressions validated at config load time

Development

pip install "ducto[test]"
pytest
ruff check .
ruff format .
pyright

See CONTRIBUTING.md.

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-1.0.2.tar.gz (63.0 kB view details)

Uploaded Source

Built Distribution

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

ducto-1.0.2-py3-none-any.whl (58.9 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: ducto-1.0.2.tar.gz
  • Upload date:
  • Size: 63.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.24 {"installer":{"name":"uv","version":"0.11.24","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-1.0.2.tar.gz
Algorithm Hash digest
SHA256 1cde22267ae471ab1132ca55bf2f30914140f06b78f7b05b549bce9566f4ba3d
MD5 1d6ae65d9df18424827901100c7d2898
BLAKE2b-256 8b7e8e89287e44eb016457ec3ee6192726b38b7b99645379c583aca6269d1b01

See more details on using hashes here.

File details

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

File metadata

  • Download URL: ducto-1.0.2-py3-none-any.whl
  • Upload date:
  • Size: 58.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.24 {"installer":{"name":"uv","version":"0.11.24","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-1.0.2-py3-none-any.whl
Algorithm Hash digest
SHA256 a8e4c85d6c74eb86cc81a86725303af229d43b3408780940db25f60c2f83d996
MD5 5ead864f2de8e1ce9cdcd6f358b91947
BLAKE2b-256 00d7ad4ddbf09f3f0f8e04599f09771d59e7e765e152c9ab3c56ec047b062386

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