Help us shape the future of Go! Take the 2026 Go Developer Survey

ai-credits

module
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 12, 2026 License: MIT

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, stripe packages). The core ledger only depends on turso.tech/database/tursogo and golang.org/x/crypto; stripe-go is used by the payments/stripe packages, not the core credits package. The driver is caller-supplied (OpenSQLite takes the driver name), so apps on ncruces/go-sqlite3 keep working — the lib itself no longer depends on modernc.org/sqlite.

Billing modes

  • managed — the app bills the user for its own LLM calls: a JSON pricing engine prices each call (Cost/Credits), Reserve holds a conservative bound for unknown-cost calls, Settle/Release finalize with the real usage.
  • byok — each user brings their own provider key; the relay meters the upstream call into llm_usage for 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 / ProcessSettlementOutbox keep 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): payments package + stripe adapter for checkout, event worker with backoff/dead-letter, and invoice.paid auto-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.

  1. 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)).
  2. Reserve — before an unknown-cost LLM call, EstimateMax(model, in, maxOut) returns a conservative reserve (cost × 1.2) and Reserve holds it against the user's balance (idempotent by request id). A call must be authorized before it runs (fail-closed: ErrInsufficientBalance blocks it).
  3. 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:

  1. EnqueueSettlement(ctx, requestID, userID, reservationID, provider, model) right after Reserve (idempotent row keyed by request_id),
  2. SettleViaOutbox(ctx, requestID, usage) after the provider returns,
  3. 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.

  1. 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 to NewCredentialStore. No key → the store is disabled (clear error).
  2. Define provider bases — a map[string]string of provider → OpenAI-compatible base URL (e.g. openai: https://api.openai.com/v1).
  3. 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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL