---
title: "Metered actions"
description: "Sell metered AI features — setupAi wraps any Convex action so it is rate-limited and prepaid-credit-metered with reserve → run → settle, and a failed run consumes nothing."
canonical_url: "https://nuxt-backend.dev/docs/ai/metered-actions"
---
# Metered actions

> Sell metered AI features — setupAi wraps any Convex action so it is rate-limited and prepaid-credit-metered with reserve → run → settle, and a failed run consumes nothing.

`nuxt-backend/ai` is the rails for selling metered AI features. `setupAi(components, { billing, rateLimiter })` composes the billing spend reservation, the rate limiter, and the upstream persistent-text-streaming component, and hands back two builders: `meteredAction` (this page) and `stream` ([Streaming](https://nuxt-backend.dev/docs/ai/streaming)). Usage recording **is** the provider event, so per-customer usage shows up in the provider's own portal and dashboard with no extra tables.

<note>

`nuxt-backend/ai` is in the experimental tier (see STABILITY.md): the reserve → settle metering model over provider meter events may change shape in a minor release.

</note>

## Setup

The scaffolded `backend/ai.ts`:

```ts [backend/ai.ts]
import { setupAi } from 'nuxt-backend/ai'
import { v } from 'convex/values'
import { components } from './_generated/api'
import { billing } from './billing'
import { rateLimiter } from './rateLimiter'

export const ai = setupAi(components, { billing, rateLimiter })

// A metered action — swap the echo for your model call. Costs 1 credit
// from the 'credits' meter per call (see billing.catalog.ts).
export const transform = ai.meteredAction({
  meter: 'credits',
  args: { prompt: v.string() },
  handler: async (ctx, { prompt }) => {
    // const completion = await yourModel(prompt)
    return { echo: prompt.toUpperCase(), chargedTo: ctx.usage.entityId }
  },
})
```

`setupAi` config:

<field-group>
<field name="billing" type="Billing" :required="true">

Your `setupBilling(...)` instance — supplies entity resolution and the credit spend.

</field>

<field name="rateLimiter" type="AiRateLimiter">

Your `setupRateLimiter(...)` instance. Metered actions/streams check their named limit (default `'ai'`, seeded in `DEFAULT_LIMITS`) per billing entity before reserving. Omit to leave them unthrottled.

</field>

<field name="cors.origin" type="string">

CORS origin for the stream endpoint. Defaults to the `SITE_URL` env var (`*` if unset).

</field>

<field name="budget" type="AiBudget">

A per-entity credit cap on top of the call-rate limit — `{ units, period, limit? }`: `units` credits per `period` ms (`HOUR` / `DAY` from `nuxt-backend/rate-limit`), so `{ units: 500, period: DAY }` is "500 credits a day per workspace", not 500 calls. Enforced through the rate limiter as a fixed window over spend value (requires `rateLimiter`; the window is recorded under `limit`, default `'aiBudget'` — name your own to keep separate budgets per feature). A cap, not a ledger: like every rate limit, consumption is never given back, so a run that fails or finalizes under its estimate still spent the estimate against the window. A call whose cost exceeds `units` is refused outright — it could never fit.

</field>

<field name="credits" type="AiCreditsConfig">

Low-balance reactions fired after a spend settles: `lowBalanceThreshold`, plus `onCreditsLow(ctx, event)` (the balance just crossed the threshold downwards) and `onCreditsExhausted(ctx, event)` (it just went from positive to zero or below). Both fire on the **crossing** only — a second spend that stays below the line is silent — so they are safe to wire straight to a notification. The event carries `entityId`, `userId`, `meter` / `meterId`, `balance`, `previousBalance`, `spent` and `threshold`. A hook that throws is logged; the spend stays settled. Keep them thin — record or hand off; sending belongs to the lifecycle emails.

</field>

<field name="streamTimeout" type="number | false">

How long a started stream may hold its reservation before it is released automatically, in ms. Default 5 minutes; `false` disables the timer. See [Streaming](https://nuxt-backend.dev/docs/ai/streaming).

</field>
</field-group>

To cap spend per window and react to a low balance, see [Set budgets and track costs](https://nuxt-backend.dev/docs/ai/costs).

The stream endpoint needs `persistentTextStreaming` mounted in `convex.config.ts` (the scaffold does) and `registerBackendRoutes({ ai })` in `http.ts`.

## `ai.meteredAction(config)`

A drop-in replacement for `action`: it rate-limits, reserves prepaid credits, runs your handler, then settles the spend. Failed runs release the reservation — nothing charged.

```ts
export const generate = ai.meteredAction({
  meter: 'credits',
  cost: args => args.long ? 5 : 1,
  limit: 'ai',
  charge: 'success',
  args: { prompt: v.string(), long: v.optional(v.boolean()) },
  handler: async (ctx, { prompt, long }) => {
    // ctx is the full action ctx plus ctx.usage
    return runModel(prompt, { maxTokens: long ? 4000 : 800 })
  },
})
```

<field-group>
<field name="meter" type="string | false" :required="true">

The catalog credit meter to charge — or `false` for a rate-limit-only action (no reservation, no event).

</field>

<field name="cost" type="MeteredCost">

Credits per call: a number, `(args) => number`, or `{ estimate, finalize }` to reserve an estimate and settle what the run actually used — see [Token pricing](#token-pricing). Default `1`. On a `count` meter the value must be `1`. A metered call's cost must be a finite number above zero; anything else throws before the rate limit, the budget or the reservation counts it.

</field>

<field name="limit" type="string | false">

Named rate limit checked per billing entity through the configured `rateLimiter`. Default `'ai'`; `false` disables the check.

</field>

<field name="allowOverage" type="boolean">

Let this spend take the balance negative instead of refusing it — for a pay-as-you-go meter with no credit benefit behind it, where every unit is overage the provider invoices at the end of the cycle. Off by default: credits stay strictly prepaid.

</field>

<field name="charge" type="'success' | 'start'">

When to charge. `'success'` (default) settles after the handler returns — a failed run consumes nothing. `'start'` settles before running, for work that is irrevocably consumed upstream the moment it starts (no refund on failure). A finalized cost needs the result, so it only applies to `'success'`.

</field>

<field name="args / handler">

Convex validators and `(ctx & { usage: MeteredUsage }, args) => Promise<unknown>`.

</field>
</field-group>

`ctx.usage` carries `entityId` (the workspace or user charged), `userId` (the signed-in caller), `meter`, and `cost` (the estimate, for a finalized cost).

### What happens on each call

1. **Cost** — a metered call whose cost is not a finite number above zero throws before anything counts it.
2. **Identity** — an unauthenticated call throws `unauthenticated`; the billing entity resolves from claims (`no_workspace` if none — a personal workspace normally guarantees one).
3. **Rate limit** — the named limit is checked, keyed by the entity; over the limit throws `rate_limited` with the limit's name and `retryAfter`.
4. **Budget** — with `budget` configured (it needs `rateLimiter`) and the action metered, the call's cost is counted against the entity's window; over it throws `rate_limited` with `limit: 'aiBudget'` (or the budget's own limit name).
5. **Reserve** — `billing.reserveCredits` decrements the cached balance atomically by the cost (the estimate, for a finalized cost); too little balance throws `insufficient_credits` with `balance` and `need` unless `allowOverage` is set.

Each refusal is a [catalog code](https://nuxt-backend.dev/docs/reference/backend/errors) that survives production; `getBackendError(error)` reads it on the client ([Handle errors](https://nuxt-backend.dev/docs/data/errors)).
6. **Run** — your handler.
7. **Settle or release** — success ingests the usage event at the final cost, hands any unspent remainder of the estimate back to the balance, and fires the `credits` hooks if the balance crossed a threshold; a throw releases the reservation and rethrows. With `charge: 'start'` the settle happens before the run instead.

## Token pricing

A model call's cost is only known once it returns. `cost: { estimate, finalize }` reserves the estimate, runs the handler, then settles what `finalize` computes from the result and hands the remainder back to the balance in the same transaction. `priceTokens` is the arithmetic — a price table the app owns (no provider knows what a model costs you; rates change weekly and only your margin decides the credit price), applied to the token usage the SDK reports:

```ts [backend/ai.ts]
import { priceTokens, type TokenPriceTable, type TokenUsage } from 'nuxt-backend/ai'

// Credits per million tokens, per model id.
const prices: TokenPriceTable = {
  'fast-1': { input: 3, output: 15, per: 1_000_000 },
  'deep-1': { input: 15, output: 75, cachedInput: 1.5, per: 1_000_000 },
}

export const generate = ai.meteredAction({
  meter: 'credits',
  cost: {
    estimate: 200,   // the ceiling: reserved up front, refunded down to the final cost
    finalize: (result: { usage: TokenUsage }) => priceTokens(result.usage, prices, { round: 'up' }),
  },
  args: { prompt: v.string() },
  handler: async (ctx, { prompt }) => {
    const completion = await yourModel(prompt)
    // Return the usage — `finalize` prices what the handler returns.
    return { text: completion.text, usage: { model: 'fast-1', input: completion.inputTokens, output: completion.outputTokens } }
  },
})
```

- **The estimate is the ceiling.** A final amount above it is clamped down and logged (`Raise the estimate.`): the reservation is what made the spend safe against a concurrent one, so settling beyond it would be a second, unguarded debit. Estimate high — an over-estimate costs nothing once it settles, though it must fit the balance to reserve and counts in full against a `budget` window.
- **A cost that is not an amount settles at the ceiling too.** When `finalize` returns `NaN`, an infinity or a negative number, the reservation is charged and the bug logged (`ai.cost_over_reservation`): the run is done and its result still reaches the caller, but the value never reaches the balance. A `finalize` that returns `0` ingests nothing and hands the whole reservation back.
- **Sum meters only.** The finalized amount rides in the ingested event's metadata; a `count` meter can only ever count 1 per event.
- **charge: 'success' only.** A finalized cost needs the result.
- `estimate` may also be `(args) => number`; `finalize(result, usage)` receives `ctx.usage` as its second argument. `result` is typed from what `handler` returns — annotate it, as above, or list `handler` before `cost` so inference has seen it.

`priceTokens(usage, table, options?)` takes a `TokenUsage` (`model`, `input`, `output`, `cachedInput?`) and a `TokenPriceTable` (model id → `{ input, output, cachedInput?, per? }`, rates in credits per `per` tokens — default 1; `options.per` sets a table-wide default). `cachedInput` tokens bill at the entry's own rate when it has one, as ordinary input otherwise. `options.round` (`'up'` | `'nearest'` | `'none'`, the default) and `options.minimum` shape the result. A model missing from the table **throws** — a silent 0 would be a revenue leak, and an unpriced model is a deployment mistake worth surfacing.

```ts
priceTokens({ model: 'fast-1', input: 12_000, output: 800 }, prices) // → 0.048
```

## Calling it from the app

```vue
<script setup lang="ts">
import { api } from '#backend/api'

const credits = useCredits('credits')
const transform = useAction(api.ai.transform)
const result = ref<string>()

async function run(prompt: string) {
  try {
    result.value = (await transform({ prompt })).echo
  }
  catch (error) {
    // insufficient_credits, rate_limited, or your model's error — nothing was charged
    const refusal = getBackendError(error)
    if (refusal?.code === 'insufficient_credits') navigateTo('/pricing')
  }
}
</script>

<template>
  <p>{{ credits.balance.value }} credits</p>
</template>
```

The balance updates live: the cache is debited on reserve and the provider webhook confirms the consumed units.

## Limits and budgets

The `ai` limit, a per-entity `budget` and the low-balance hooks: see [Set budgets and track costs](https://nuxt-backend.dev/docs/ai/costs).

<page-keys>

- Try it live in the playground: [/playground/platform/ai](https://nuxt-backend.dev/playground/platform/ai)
- API reference: [/docs/reference/api/ai](https://nuxt-backend.dev/docs/reference/api/ai)
- Source: [https://github.com/qruto/nuxt-backend/blob/main/src/convex/integrations/ai.ts](https://github.com/qruto/nuxt-backend/blob/main/src/convex/integrations/ai.ts)

</page-keys>


## Sitemap

See the full [sitemap](https://nuxt-backend.dev/sitemap.md) for all pages.
