README
¶
ai-credits
SQLite-backed credits/billing + BYOK for AI applications.
Scope note: since v0.4.1 the library also ships optional provider- neutral payments and a Stripe adapter in the same module (
payments,stripepackages). The core ledger only depends onturso.tech/database/tursogoandgolang.org/x/crypto;stripe-gois used by the payments/stripe packages, not the corecreditspackage. The driver is caller-supplied (OpenSQLitetakes the driver name), so apps on ncruces/go-sqlite3 keep working — the lib itself no longer depends onmodernc.org/sqlite.
Billing modes
- managed — the app bills the user for its own LLM calls: a JSON pricing
engine prices each call (
Cost/Credits),Reserveholds a conservative bound for unknown-cost calls,Settle/Releasefinalize with the real usage. - byok — each user brings their own provider key; the relay meters the
upstream call into
llm_usagefor analytics/throttling but charges nothing (credits_charged=0).
Features
- Immutable ledger + materialized balance (
balance == SUM(ledger)invariant) - JSON-configurable pricing engine (
Cost,EstimateMax,Credits) - Reserve / Settle / Release for unknown-output LLM calls (concurrency-safe)
- Lazy idempotent monthly grants (no scheduler)
- Subscription lifecycle: plan status (active/paused/cancelled) gates the
monthly grant (entitlement),
SetSubscription/CancelSubscription - Durable settlement outbox:
EnqueueSettlement/SettleViaOutbox/ProcessSettlementOutboxkeep a settle alive across an app crash - BYOK: XChaCha20-Poly1305-encrypted credential store + in-process relay
that auto-meters every upstream call into
llm_usage(billing_mode=byok) - Reconcile (orphan reservation expiry + balance/ledger drift report)
- Payment integrations (optional):
paymentspackage +stripeadapter for checkout, event worker with backoff/dead-letter, andinvoice.paidauto-grant (docs/payments.md)
Install
go get github.com/calionauta/ai-credits@latest
Example
package main
import (
"context"
"fmt"
"log"
"github.com/calionauta/ai-credits/credits"
_ "turso.tech/database/tursogo" // app supplies the driver
)
func main() {
ctx := context.Background()
// OpenSQLite applies the WAL + busy_timeout pragmas the concurrency-safe
// ledger needs; a bare sql.Open can deadlock on concurrent writes.
db, err := credits.OpenSQLite("turso", "app.db")
if err != nil {
log.Fatal(err)
}
svc, err := credits.New(db, credits.Config{MonthlyCredits: 1000})
if err != nil {
log.Fatal(err)
}
const user = "user-123"
// top-up / signup grant (idempotent by key)
if err := svc.Grant(ctx, user, 1000, "signup", "welcome", "signup:"+user); err != nil {
log.Fatal(err)
}
// unknown-output call: reserve a conservative max, settle actual
max, _ := svc.EstimateMax(ctx, "gpt-4o-mini", 1500, 800)
rsv, _ := svc.Reserve(ctx, user, "req-1", max)
// ... run the LLM call, then:
usage := credits.Usage{RequestID: "req-1", Model: "gpt-4o-mini",
BillingMode: "managed", UserID: user, Provider: "openai",
InputTokens: 1500, OutputTokens: 412}
// Settle takes CREDITS (not micro-units). Convert the cost to credits
// first — passing micro-units would look like a huge over-charge and
// Settle returns ErrReservationExceeded.
creditsUsed, _ := svc.Credits(ctx, usage)
if err := svc.Settle(ctx, rsv, creditsUsed); err != nil {
log.Fatal(err)
}
// RecordUsageRetry requires RequestID + UserID + Provider + Model; omitting
// any makes the audit row silently fail (validateUsage). Here RequestID
// matches the reserve so the audit links back to the charge.
if err := svc.RecordUsageRetry(ctx, usage); err != nil {
log.Printf("usage audit pending for req-1: %v", err)
}
fmt.Println("balance:", must(svc.Balance(ctx, user)))
}
func must[T any](v T, err error) T {
if err != nil {
panic(err)
}
return v
}
Managed (usage-based billing)
The Example above is the managed flow: your app owns the provider call and
bills the user for it.
- Price —
Cost(ctx, Usage)converts a model + token counts to micro-units via the JSON pricing engine;Credits(ctx, Usage)converts that to credits (ceil(cost / microunits_per_credit)). - Reserve — before an unknown-cost LLM call,
EstimateMax(model, in, maxOut)returns a conservative reserve (cost × 1.2) andReserveholds it against the user's balance (idempotent by request id). A call must be authorized before it runs (fail-closed:ErrInsufficientBalanceblocks it). - Settle — after the call,
Settle(r, actualCredits)charges the real usage and refunds the rest; overage is auto-drawn from the remaining balance (a slightly low estimate doesn't fail a successful call).
Only calls that actually ran get charged; Release(r) returns an unused reserve.
The balance == SUM(ledger) invariant holds across all of it.
Durable settlement (recommended): if the provider call returns before you
persist the settle (e.g. in a long-lived process), a crash between provider
responded and ledger settled loses money. Use the outbox instead of the
plain Settle+RecordUsageRetry pairing:
EnqueueSettlement(ctx, requestID, userID, reservationID, provider, model)right afterReserve(idempotent row keyed byrequest_id),SettleViaOutbox(ctx, requestID, usage)after the provider returns,- run
ProcessSettlementOutbox(ctx)on a timer (e.g. every minute) to retry/expire anything left pending after a crash.
The settle is persisted (via request_id) so a restart resumes it instead of
dropping the charge. RecordUsageRetry still logs the audit row; keep
usage.BillingMode consistent with the charge (managed when it debits
credits — byok usage must carry credits_charged=0).
BYOK (bring your own key)
For apps where each user brings their own LLM provider key, the library ships a credential store plus an in-process relay that meters every upstream call.
- Configure the encryption key the relay uses to store keys at rest:
CREDITS_ENC_KEY(hex of 32 bytes, e.g.openssl rand -hex 32), passed toNewCredentialStore. No key → the store is disabled (clear error). - Define provider bases — a
map[string]stringof provider → OpenAI-compatible base URL (e.g.openai: https://api.openai.com/v1). - Mount the relay behind your auth middleware (the relay does not
authenticate; it trusts
X-Auth-User, which the app must set only after session validation and strip from external requests):
store := svc.NewCredentialStore(key32) // key32 [32]byte
relay := svc.NewByokRelay(store, providerBases, logger)
mux.Handle("/api/byok/", authMiddleware(relay)) // POST /api/byok/{provider}/{path...}
Each call is proxied OpenAI-compat to the user's provider with their key injected
as the bearer token, and the upstream usage (JSON or final SSE chunk) is
captured into llm_usage with billing_mode=byok and credits_charged=0 — so
BYOK calls are visible to analytics/throttling without charging the user.
Architecture
Design reference (schema, pricing, domain rules, BYOK relay, security,
deliberate simplifications): docs/architecture.md.
Optional provider-neutral payments and Stripe integration: docs/payments.md.
Testing
go test -race ./... # unit + synthetic webhook tests (no secrets)
gofumpt -l . # formatting
golangci-lint run ./... # lint gate
govulncheck ./... # vuln scan
Synthetic Stripe webhooks and the opt-in E2E suite against real Stripe
(test-mode, requires keys) are documented in docs/testing.md.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package payments turns verified provider payment facts into idempotent credit grants.
|
Package payments turns verified provider payment facts into idempotent credit grants. |
|
Package stripe verifies Stripe webhooks and maps them to payments events.
|
Package stripe verifies Stripe webhooks and maps them to payments events. |