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
| Constant | Value | Meaning |
|---|---|---|
NEURONS_PER_CREDIT | 50 | 1 credit = 50 Cloudflare Neurons (Workers AI's real billing unit) |
TRANSCRIPTION_CREDITS_PER_AUDIO_MINUTE | 1 | 1 credit is roughly 1 minute of Whisper transcription (about 46.6 neurons/minute per the price table) |
GUEST_RESEARCH_CREDITS | 5 | Flat 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_secondsis 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_quotarecordsusage_eventsrows only on admit). allowed: falsemeans do not run the transcription. The work is the cost: the job is markedskippedwith 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:
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.NULLremaining means unlimited and is allowed;0covers both an exhausted allowance and a missing(account, key)row (C8 fail-closed).- Run the generation. The workflow persists the result durably in its own step.
recordGenerativeSpend(): soft-decrement the ACTUAL usage (creditsFromNeurons(neurons)=ceil(neurons / 50)) withmodeOverride: 'soft'(migration20260612210000). Soft always records: it fills the monthly allowance first, draws the AI-pack pool FEFO, and books any excess onquota_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_usedvsquota_limitSELECT: 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
failedjobs withcredits_reserved > 0refund. Askippedjob never does: the reserve itself was refused, so there is nothing to return. Fail-closed on every other state. - Idempotent via the
dedupe_keyUNIQUE column ('ai_refund:<job_id>'); workflow-step retries cannot double-grant. Stripe-provisioned grants keep their idempotency onstripe_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_fromat the reservation instant (the hard consume'susage_eventsrow, falling back tostarted_at, thencreated_at, then now) andvalid_untilat 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
| Surface | Gate | On exhaustion |
|---|---|---|
POST /api/ai/transcriptions/:episodeId | Preflight read | 429 quota_exceeded with { needed, remaining } |
TranscriptionWorkflow reserve-credits | Hard consume (authoritative) | Job skipped with host-visible message; notify_ai_job_result fires; never retried |
POST /api/ai/research/:episodeId | Preflight read | 429 quota_exceeded (needs 5) |
| Booking confirm research side-effect | None (by design) | Worker reserve refuses; job lands skipped plus an ai.research_skipped notification (the auto-run contract) |
GuestResearchWorkflow reserve-credits | Hard consume (authoritative) | Job skipped + notify |
POST /api/ai/content/:episodeId | Preflight read (remaining <= 0) | 429 quota_exceeded |
ContentGenerationWorkflow check-headroom | Read (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), apostgres-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.
Related
- Entitlement Model (the
consume_quotaRPC, packs, and the quotas matrix) - Transcription Pipeline
- Guest Research