BUILT INTO EVERY GOODSPEED APP
Cost Guardrails (Per-Scope Budgets)
A per-(scope, key, period) budget system with three enforcement modes (throttle, block, or alert_only) prevents any single user or feature from running up uncapped LLM or API spend in production.
- Tier: Core
- Status: Config-toggled
- Config: costBudgets.defaults
WHY IT MATTERS
Every app that makes LLM calls or charges per-API-call has the same production failure mode: one unusually active user, a misconfigured retry loop, or an unexpected traffic spike drives spend through the ceiling before anyone notices. Traditional approaches are coarse: a global monthly cap on your OpenAI account does not tell you which user triggered it, cannot apply different rules to different features, and only blocks after damage is done. The template takes a different approach: every call that carries variable cost goes through a single Supabase RPC named consume_cost before it executes. The RPC takes a scope (a feature identifier like 'llm' or 'image-gen'), a key (typically the authenticated user's UUID), a cost estimate, and a period ('hour', 'day', or 'month'). It acquires an advisory lock on that (scope, key, period) triple, reads the budget row from cost_budgets, sums the current-period usage from cost_usage, and makes an atomic allow/deny decision in a single database transaction. No concurrent call for the same user can race ahead of the lock. The response is a typed JSON object with five fields: allowed, remaining, reset_at, enforcement, and throttled, so the calling code always knows exactly what happened and why.
The enforcement mode is the key design lever. block is a hard wall: once a user's per-day budget is exhausted, every subsequent call in that period returns allowed: false and the app surface can show a clear message about the limit resetting at midnight. throttle is a softer ceiling: the budget's token-bucket parameters (throttle_capacity and throttle_refill_per_second in the cost_budgets row) kick in once the raw limit is hit, letting through calls at a metered rate rather than cutting off entirely. alert_only lets everything through while recording an over_cap flag in cost_usage and writing a cost.cap_exceeded_alert_only event to audit_log, giving operators visibility into overage patterns before committing to a harder mode. In services/llm.ts the chat() function demonstrates the full reserve-and-reconcile pattern: it calls consume_cost with a worst-case estimate before the LLM call begins, then after the API responds it calculates the actual usage delta and issues a second consume_cost call with a negative value to refund the unused portion of the reservation. The net result is that cost_usage always reflects actual spend, not pessimistic estimates. Budget seeds are declared in gasConfig.costBudgets.defaults, seeded by the migration, and enforced on every call without any per-feature configuration from the app developer.
HOW IT IS WIRED
Real code from the GAS template
The code below is drawn from services/llm.ts in the gas-template repository. This is the code your generated app gets, not pseudocode, not a description of intent.
// services/llm.ts -- consume_cost: reserve-and-reconcile before/after each LLM call
async function consumeCost(userId: string, cost: number, period: string): Promise<boolean> {
const { data } = await retryWithBackoff(
async () => {
const res = await supabase.rpc('consume_cost', {
p_scope: gasConfig.llm.costScope ?? 'llm',
p_key: userId,
p_cost: cost,
p_period: period,
});
if (res.error) throw res.error;
return res;
},
{ shouldRetry: isTransientNon4xxError },
);
return (data as { allowed: boolean }).allowed;
}
// chat() -- reserve worst-case estimate, then refund the unused delta
export async function chat(messages: ChatMessage[], opts?: ChatOptions): Promise<ChatCompletion> {
const adapter = resolveAdapter();
const model = opts?.model ?? gasConfig.llm.defaultChatModel ?? DEFAULT_CHAT_MODEL;
const period = gasConfig.llm.budgetPeriod ?? 'day';
const userId = await getUserId();
const charCount = messages.reduce((n, m) => n + (m.content?.length ?? 0), 0);
const estimate = reserveEstimate(adapter, model, charCount, opts?.maxTokens ?? DEFAULT_MAX_TOKENS);
const allowed = await consumeCost(userId, estimate, period);
if (!allowed) throw new ServiceError('llm_budget_exceeded', 429, 'Daily LLM budget reached');
const completion = await adapter.chat(messages, { ...opts, model });
const actual = actualCost(adapter, completion);
await recordDelta(userId, actual - estimate, period); // negative = refund
return completion;
}Source: goodspeed-apps/gas-template → services/llm.ts
HONEST LIMITS
When Cost Guardrails (Per-Scope Budgets) is the wrong choice
Cost guardrails exist to prevent unbounded spend that scales with usage. If your app has no per-call variable costs (no LLM API calls, no SMS sending, no third-party API billed per request), every consume_cost call is a round-trip that returns allowed: true and records nothing meaningful. The RPC adds a real database write on every interaction. That overhead is worth paying when a single runaway user could cost you thousands of dollars, but it is genuinely wasteful when your cost structure is flat: for example, an app that only reads and writes its own Supabase database under a fixed plan, with no metered external APIs involved at any point in any user flow. In those cases, remove the cost_budgets seeding step from your migration and omit costBudgets.defaults from your gasConfig. The cost_budgets and cost_usage tables will still exist in the schema (they are part of migration 008), but nothing will write to them. A second scenario where you may want to replace this system rather than use it as-is: multi-tenant SaaS apps where cost should be attributed at the tenant level and potentially passed through to customer billing. The template's model keys on the individual user UUID, so usage by ten users within one organization appears as ten separate budget counters with no shared ceiling. Implementing a shared per-organization cap requires a different key strategy: passing the organization or team ID as p_key instead of the user ID, along with a custom billing reconciliation step that reads from cost_usage grouped by that key. That pattern is achievable with the same RPC but requires deliberate configuration; it does not come out of the box.
Tier: Core · Config-toggled
Evaluate your use case
Check whether cost guardrails (per-scope budgets) aligns with your target audience, platform constraints, and regulatory environment before enabling it.
Audit the config
The `costBudgets.defaults` flag controls this feature. Set it to false in gas.config.ts to disable the feature entirely with no residual code paths.
Seek alternatives
If the built-in implementation does not fit, the generated codebase is standard React Native + Expo code. Any library in the Expo ecosystem can replace the default.
APPS USING THIS FEATURE
Every generated Goodspeed app includes cost guardrails (per-scope budgets). Browse the ideas catalog to see apps across all categories that ship with this feature wired in.
CAPABILITIES
Cost Guardrails (Per-Scope Budgets) capability breakdown
Concrete dimensions of what the built-in cost guardrails (per-scope budgets) implementation covers. These reflect the actual template code, not a marketing summary.
| Item | Description | Strength |
|---|---|---|
| Storage backend | Where budget rows and usage events are stored | Supabase Postgres (cost_budgets + cost_usage) |
| Enforcement modes | How the system responds when a budget is exhausted | throttle | block | alert_only |
| Atomicity | Concurrency safety mechanism for concurrent calls from the same user | pg_advisory_xact_lock per (scope, key, period) |
| Cost accounting | How LLM call costs are tracked relative to the API response | Reserve-and-reconcile (pre-call estimate + post-call delta refund) |
| Budget scope | Granularity of budget enforcement | Per user per feature per period (hour | day | month) |
COMMON QUESTIONS
Does the budget check add meaningful latency to every LLM call?
The consume_cost RPC is a single Supabase function call that runs inside a transaction on the same Postgres instance as the rest of your app data. On a standard Supabase project in the same region as your Edge Function, the round-trip is typically under 10ms. The advisory lock on (scope, key, period) only blocks concurrent calls from the same user hitting the same feature at exactly the same millisecond, which is rare in most consumer apps. The reserve-and-reconcile pattern in services/llm.ts does add two RPC calls per LLM invocation: one before to reserve the estimate and one after to refund the unused delta. Both are issued over the existing Supabase client connection, so there is no additional authentication overhead. If you are building a high-throughput batch processing pipeline where hundreds of LLM calls are issued per second per user, the locking overhead becomes meaningful and you should consider a Redis-backed token-bucket instead. For user-facing interactive features, the latency contribution is well below what a user perceives.
What happens when a user hits the block limit mid-session?
When consume_cost returns allowed: false, the chat() function in services/llm.ts throws a ServiceError with status 429 and the message 'Daily LLM budget reached'. The calling component should catch errors with status 429 and surface an appropriate message to the user, such as 'You have reached your daily limit. Your allowance resets at midnight.' The reset_at field in the consume_cost response tells you exactly when the window resets, so you can display a countdown or absolute time. The template does not include a pre-built UI component for this state because the right presentation depends heavily on your app's design and whether you want to upsell a higher-tier plan at that moment. The enforcement mode can be changed from block to throttle in the cost_budgets row if you prefer to slow users down rather than cut them off. No code change required, just an UPDATE to the database row.
How do I configure different limits for free vs paid users?
The cost_budgets table uses (scope, key, period) as its primary key. The key column is typically the user UUID. You can insert separate rows for specific users to override the default. The recommended pattern for tiered limits is: seed a default row with scope='llm' and key='__default__' representing your free-tier ceiling, then at subscription activation time (in your webhook or RevenueCat listener) upsert a row with scope='llm', key=userId, and a higher cost_limit for that specific user. The consume_cost RPC looks up by the exact (scope, key, period) triple. To support a fallback-to-default pattern, add a check in your services layer that queries the user's tier first and passes the appropriate limit directly, bypassing the budgets table for the simplest two-tier case.
Can I use this system to guard non-LLM costs like SMS or image generation?
Yes, and this is the intended design. The scope parameter is a free-form string: 'llm', 'sms', 'image-gen', or any key that maps to a metered cost center in your app. Each scope can have its own row in cost_budgets with its own limit, period, and enforcement mode. For SMS via Twilio, for example, you would call consume_cost with p_scope='sms', p_key=userId, p_cost=0.0075 (approximate cost per message in USD) before each send. The services/api.ts file in the template includes a comment noting that cost budgets are enforced server-side via the consume_cost RPC inside Edge Functions, confirming the pattern is not limited to the LLM service. For image generation, you can use the same reserve-and-reconcile approach as the LLM service: reserve a worst-case cost before sending the request to the image API, then reconcile to actual cost after the response comes back with the actual dimensions or model tier billed.
GET IT BUILT INTO YOUR APP