Metering video analysis
Your users paste a YouTube URL; your app analyzes it with an LLM and returns the result. This guide shows how to cap each subscriber at 5 hours of video per month and see what every analysis costs - input and output - in the dashboard.
The shape of the problem
There are two different things to meter here, and they pull in different directions:
- Hours of video - a hard limit. 5 hours is
18000seconds. Each analysis consumes the video's duration and must be gated atomically so parallel requests can't slip past. This is a seconds quota. - LLM cost - not a limit, just a number you want to see. Input and output tokens are priced separately, and models like
gemini-3-1-flash-litehave context-tier pricing (≤200K vs >200K tokens) the catalog can't resolve automatically. You pass the exact cost yourself so it appears in the dashboard exactly.
The recommended approach (Design B below) uses two separate reserve() / commit() cycles: one for the seconds cap on a custom event, and one for the LLM cost on the built-in Language Model row. Design A - a single reservation carrying both - is shown afterward as an alternative.
Set up the plan (Design B - recommended)
In the dashboard, create (or edit) a plan and add two rows:
- Custom "Other events" row - name it
video.analyze, set the unit to seconds, and set the cap to18000(5 h × 3600). The custom-event unit toggle supports seconds, so this is a native seconds group - durations are summed, not counted. This row is the one that blocks. - Built-in Language Model row - select
gemini-3-1-flash-lite. Set its cents cap to a number you will never hit (e.g.100000000) - this makes it tracking-only and never blocks. The Language Model row gives you catalog price display and native input / output cost analytics in the dashboard.
Assign the plan to your subscribers with upsertSubscription() from your backend.
The flow (Design B)
Because the seconds cap and the LLM cost are different event types, each needs its own reservation. Gate on the seconds cap first so you never start an LLM call the user isn't allowed to make:
import { vevee } from '@/lib/vevee';
async function analyzeVideo(userId: string, youtubeUrl: string) {
const { durationSeconds, transcript } = await fetchYouTube(youtubeUrl);
// 1. Gate on the 5h cap (custom seconds event)
const cap = await vevee.reserve(userId, 'video.analyze', durationSeconds, { videoId: youtubeUrl });
if (!cap.allowed) throw new Error('5 hours used this month');
// 2. Record LLM cost via the Language Model row (no limit; reserve/commit splits input vs output)
const inputTokens = countTokens(transcript);
const tier: 'short' | 'long' = inputTokens > 200_000 ? 'long' : 'short';
const inputCostCents = priceInput(inputTokens, tier); // your tier-aware pricing function
const llm = await vevee.reserve(
userId,
'llm.gemini-3-1-flash-lite',
0,
{ videoId: youtubeUrl },
{ inputCostCents },
);
try {
const result = await gemini.generateContent({
model: 'gemini-3-1-flash-lite',
contents: buildPrompt(transcript),
});
const outputTokens = result.usageMetadata?.candidatesTokenCount ?? 0;
const outputCostCents = priceOutput(outputTokens, tier);
// Commit the LLM reservation with the actual output cost
await vevee.commit(llm.reservationId!, {
extra: { outputCostCents },
response: result.text, // optional: stored in event_logs
});
// Commit the seconds reservation - duration was exact at reserve time, nothing to add
await vevee.commit(cap.reservationId!);
return result;
} catch (err) {
// Release both reservations so the user isn't charged for a failed analysis
await vevee.release(llm.reservationId!).catch(() => {});
await vevee.release(cap.reservationId!).catch(() => {});
throw err;
}
}The inputCostCents and outputCostCents you pass are written to the event as its actual cost. That means they appear in the dashboard in three places: the overall app cost chart, the per-user coston the subscriber's detail page, and the per-event cost with a clear input / output split. Catalog list price is shown alongside as display context, but your explicit values are what counts.
Tiered pricing
Models like gemini-3-1-flash-litecharge different rates for prompts ≤200K tokens vs >200K tokens. The catalog lists a single reference price and cannot resolve which tier applies to a given call. Because you pass inputCostCents and outputCostCents explicitly, the tier logic lives in your code and is always exact:
// Example tier-aware pricing helpers (illustrative - substitute your provider's rates)
function priceInput(tokens: number, tier: 'short' | 'long'): number {
const ratePer1M = tier === 'short' ? 7.5 : 30; // cents per 1M input tokens
return Math.ceil((tokens / 1_000_000) * ratePer1M);
}
function priceOutput(tokens: number, tier: 'short' | 'long'): number {
const ratePer1M = tier === 'short' ? 30 : 150; // cents per 1M output tokens
return Math.ceil((tokens / 1_000_000) * ratePer1M);
}The tier is resolved once at the start of the flow (based on input token count) and reused for both the input and output price calculations. Nothing about the tier decision goes to Vevee - only the computed cent values do.
Alternative: single reservation (Design A)
If you prefer fewer API calls, you can carry both the seconds cap and the LLM cost on a single video.analyze reservation. The seconds group still gates the call; the cost shows up as a custom-event cost rather than under the Language Model row.
// Design A - one reservation, one commit
const inputCostCents = priceInput(inputTokens, tier);
const r = await vevee.reserve(
userId,
'video.analyze',
durationSeconds,
{ videoId: youtubeUrl },
{ inputCostCents },
);
if (!r.allowed) throw new Error('5 hours used this month');
// ... run the LLM ...
const outputCostCents = priceOutput(outputTokens, tier);
await vevee.commit(r.reservationId!, { extra: { outputCostCents } });If you don't need atomic gating at all (e.g. the cap is advisory), you can skip reserve/commit entirely and use a plain track() with the combined cost:
await vevee.track(
userId,
'video.analyze',
durationSeconds,
{ videoId: youtubeUrl },
{ inputCostCents, outputCostCents }, // split; mutually exclusive with costCents
);The trade-off: one fewer reservation, but the LLM cost shows as a custom video.analyze event rather than under the built-in Language Model row. Design B is recommended when you want cost broken out by model in the dashboard.
track() cost options, see track(). LLM token metering with safety buffers is covered in LLM token metering. To read back counters and remaining quota, see Reading usage and remaining quota.