Skip to content

AI Credits & Metering

There is exactly ONE AI quota meter: ai_credits_per_month. Every AI surface (transcription, guest research, generation) spends it with a variable quantity through the helpers in src/lib/entitlements/ai-credits.ts. "Transcription hours" on the pricing page are a presentation of this meter (credits / 60), never a separate key.

Superseded keys must never reappear

ai_minutes_per_month and ai_generation_uses_per_month are superseded catalog keys. They must not appear anywhere: not in the catalog, not in copy, not in code. Any design that wants a second AI meter is wrong by decision (Epic 13 key-decisions section 6).

Credit definition

ConstantValueMeaning
NEURONS_PER_CREDIT501 credit = 50 Cloudflare Neurons (Workers AI's real billing unit)
TRANSCRIPTION_CREDITS_PER_AUDIO_MINUTE11 credit is roughly 1 minute of Whisper transcription (about 46.6 neurons/minute per the price table)
GUEST_RESEARCH_CREDITS5Flat price per research job. The 2026-07-04 spike measured 1.4 to 2.1 credits actual (up to about 3 with the bounded link-follow); 5 is an honest margin. Actual usage is recorded in ai_jobs.cost; repricing = change this constant

Plan defaults (catalog, see Entitlement Model): Free 0, Creator 120, Studio 300, Production House 1,200, Enterprise unlimited. Hard mode. The ai_credits_300 add-on pack tops the pool up by 300 for 6 months and is drawn FEFO after the monthly base.

Call pattern 1: transcription (exact pre-pay)

The audio duration is known up front, so reserveTranscriptionCredits() places a HARD consumeQuota of transcriptionCreditsNeeded(seconds) = ceil(minutes) BEFORE the Workers AI call.

  • The metered duration is frame-MEASURED, never client-supplied. The workflow's plan-chunks step scans the MP3 frames in R2 and its total duration is the only metering input; episodes.audio_duration_seconds is client-derived display data. Pricing the reserve from the client value would let an understated duration transcribe a long file for a few credits.
  • Idempotency key = the AI job id. Stable across queue redelivery and workflow-step retries; an admitted reserve replays and increments nothing (consume_quota records usage_events rows only on admit).
  • allowed: false means do not run the transcription. The work is the cost: the job is marked skipped with a host-visible message and the message is acked. Never retried, because a monthly quota does not replenish inside a retry window.
  • No overshoot. The reservation is exact.

The route-level preflight (429 with { needed, remaining }) is UX only; the worker's reserve stays authoritative.

Call pattern 2: guest research (flat pre-pay)

reserveGuestResearchCredits() is the same hard pre-pay shape with a flat quantity of 5, keyed by the job id. Same refusal contract: mark skipped, notify, terminal.

Call pattern 3: generation (headroom check, then soft record)

Token counts are unknown until after the call, so generation is post-pay:

  1. checkGenerativeHeadroom(): a READ of effective remaining (the full union: base, active overrides, in-window AI-pack grants, period-aware) via the shared resolver. Block BEFORE generating when remaining is 0 or less. NULL remaining means unlimited and is allowed; 0 covers both an exhausted allowance and a missing (account, key) row (C8 fail-closed).
  2. Run the generation. The workflow persists the result durably in its own step.
  3. recordGenerativeSpend(): soft-decrement the ACTUAL usage (creditsFromNeurons(neurons) = ceil(neurons / 50)) with modeOverride: 'soft' (migration 20260612210000). Soft always records: it fills the monthly allowance first, draws the AI-pack pool FEFO, and books any excess on quota_used. A zero-credit call records nothing (the RPC rejects non-positive quantities).

Two designs are deliberately forbidden here:

  • Never a reserve-then-true-up sharing one idempotency key: the true-up would be swallowed by the ledger's ON CONFLICT DO NOTHING.
  • Never a hand-rolled quota_used vs quota_limit SELECT: it ignores the pool and falsely blocks AI-pack buyers.
  • Never a HARD post-call decrement: over cap it would reject and record NOTHING, drifting the local ledger under the real Neuron bill.

Accepted overshoot is at most one call's tokens per concurrent call; the monthly Cloudflare Neuron reconciliation (not the runtime gate) catches the residue. The generate/record-spend step split in the workflow means a metering retry never re-runs the LLM call.

Refund on failure

refund_ai_job_credits(job_id) (20260703161339_refund_ai_job_credits.sql, revised by 20260710210000_refund_into_reservation_window.sql) returns reserved credits on terminal failure. Because consume_quota is append-only, a refund is modelled as an in-window pool grant on add_on_credit_grants (add_on_type = 'ai_job_refund'): entitlement_pool_remaining immediately extends headroom by the refunded amount.

  • Only failed jobs with credits_reserved > 0 refund. A skipped job never does: the reserve itself was refused, so there is nothing to return. Fail-closed on every other state.
  • Idempotent via the dedupe_key UNIQUE column ('ai_refund:<job_id>'); workflow-step retries cannot double-grant. Stripe-provisioned grants keep their idempotency on stripe_checkout_session_id.
  • The grant is windowed to the RESERVATION month, not the refund month. The original migration windowed at refund time, which let a job that reserved on July 31 and failed on August 1 mint extra August credits (the July consume had already rolled away). The revision anchors valid_from at the reservation instant (the hard consume's usage_events row, falling back to started_at, then created_at, then now) and valid_until at the end of that month. A cross-month refund therefore lands in a closed window: the grant row exists for audit and dedupe, but expired grants contribute nothing to the pool.

Generation never refunds because it never reserves.

Exhaustion behavior per job type

SurfaceGateOn exhaustion
POST /api/ai/transcriptions/:episodeIdPreflight read429 quota_exceeded with { needed, remaining }
TranscriptionWorkflow reserve-creditsHard consume (authoritative)Job skipped with host-visible message; notify_ai_job_result fires; never retried
POST /api/ai/research/:episodeIdPreflight read429 quota_exceeded (needs 5)
Booking confirm research side-effectNone (by design)Worker reserve refuses; job lands skipped plus an ai.research_skipped notification (the auto-run contract)
GuestResearchWorkflow reserve-creditsHard consume (authoritative)Job skipped + notify
POST /api/ai/content/:episodeIdPreflight read (remaining <= 0)429 quota_exceeded
ContentGenerationWorkflow check-headroomRead (authoritative block)Job skipped; no notification (content-generation jobs do not notify; the UI watches the job live)

Quota decisions are never retried in the worker. Transient metering ERRORS (as opposed to over-cap decisions) throw, so the step retries; consumes are idempotency-keyed, so replays increment nothing.

The metering seam

The helpers are written against the minimal AiCreditsMeter port (consumeQuota returning the raw RPC decision without throwing, plus effectiveRemaining). Two adapters satisfy it:

  • createWorkerEntitlements() (src/lib/entitlements/adapters/worker.ts), a postgres-over-Hyperdrive executor: what all three workflows use.
  • aiCreditsMeterFromCtx() for app surfaces (Hono / SvelteKit service-role executors), should an AI call ever land in a route instead of a worker.

Internal documentation - Not for public distribution