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.5.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.5-py3-none-any.whl (31.2 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: ducto-0.1.5.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.5.tar.gz
Algorithm Hash digest
SHA256 d51a8b9086839198f184db90207dc368f5521e9bfbeafa0a95771348adecd7cf
MD5 f139770c6cdd8b5bd2204ab14ce56a7f
BLAKE2b-256 c403b1cd30444c2898ad3426589c23e51431ce761586cae2af5856d6292f39bd

See more details on using hashes here.

File details

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

File metadata

  • Download URL: ducto-0.1.5-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.5-py3-none-any.whl
Algorithm Hash digest
SHA256 01f6b2add7a3b232547b2291f97949f4849a409bdff01249517386c619e42292
MD5 d6f0cf73a079605338da3b10611887d1
BLAKE2b-256 e443d436d3c7428f9729f6cbcba0ff0dfbe7b521bc38e1a65d35aab312ab4101

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