# @vevee/sdk

Drop-in usage metering and limits for AI-powered apps. Track LLM tokens, image generations,
video seconds, agent steps - anything you sell. Provider-agnostic, strict-mode enforcement,
zero-runtime-deps SDK.

> This file mirrors the human docs at https://www.vevee.org/docs in flat markdown.
> For the full SDK reference (every method, parameter, error), see /llms.txt.

---

## 30-second snippet

```bash
pnpm add @vevee/sdk
```

```ts
import { createClient } from '@vevee/sdk';

const vevee = createClient({ apiKey: process.env.VEVEE_KEY! });

// After your AI call succeeds:
await vevee.track('user_abc123', 'image.render', 1, {
  model: 'flux-pro',
  resolution: '1024x1024',
});
```

---

## What is Vevee?

Vevee is the metering and quota layer for products that resell AI capacity. You define
plans and limits in the dashboard, install the SDK in your backend, and we handle per-user
counters, period rollovers, atomic reservations, and analytics. You never write another
`if (user.imagesUsed >= plan.imagesLimit)` branch.

### The problem we solve

- Every AI app needs per-user quotas, but rolling your own means counters, period resets,
  race conditions, and a usage table that grows forever.
- Stripe meters dollars, not tokens or images. PostHog tracks events but doesn't enforce limits.
- Naive `if (used < limit)` checks break under concurrency - two parallel requests both pass
  the check and both consume.

### What you get

- **Atomic reservations** - concurrent requests cannot bypass a quota.
- **Limit groups** - one event can count against multiple quotas (e.g. `premium-images` AND
  `total-images`).
- **Period rollovers** - daily / weekly / monthly / lifetime, calendar- or
  subscription-anchored.
- **Provider-agnostic** - works with OpenAI, Anthropic, Replicate, Fal, your own models. The
  SDK only sees event names and quantities.
- **Zero runtime deps** - the SDK uses native `fetch`. Tiny bundle, dual ESM/CJS, full `.d.ts`.

---

## Mental model - three primitives

| Method | What it does | Atomic? | When to use |
|---|---|---|---|
| `track()` | Records consumption. Increments every matching limit group. | No | After-the-fact metering when you don't need pre-flight enforcement. |
| `canUse()` | Read-only check. Does NOT increment. | No | UI gating - disable a button, show "upgrade" banners. |
| `reserve()` / `commit()` / `release()` | Atomically holds quota for 60s, then confirms or refunds. | **Yes** | Every paid AI call. The only safe pattern under concurrency. |

> ⚠ Naive `canUse → call → track` is broken. Two parallel requests can both pass `canUse`,
> both call your AI provider, and both call `track` - pushing the user past the limit.
> Use `reserve / commit` for anything that costs money.

---

## Fail-closed defaults

`canUse()` and `reserve()` are **fail-closed**. They return
`{ allowed: false, matched: false }` in two situations that previously failed
silently:

| Reason | Cause | Fix |
|---|---|---|
| `unmatched_event` | The `event` string doesn't match any limit group on the user's plan. | Check for typos; add a matching limit group in the dashboard. |
| `no_subscription` | The user has no active subscription on this app. | Call `vevee.upsertSubscription({ userId, planId })` first. |

In development the SDK `console.warn`s once per call when `matched === false`,
naming the event and the cause. Production stays silent.

`track()` always **records** the event - even unmatched / no-subscription -
and stamps it with a `matchStatus` so it shows up in the dashboard's
**Events** tab with a status badge (Counted / Not matched / Limit reached /
No plan). Unmatched rows include a "did you mean?" suggestion against the
user's current-plan patterns, computed via Levenshtein distance.

---

## Plan-change semantics

`upsertSubscription({ userId, planId })` with the **same** `planId` is a no-op:
counters keep ticking, `startedAt` is preserved, periods don't reset. Safe to
call from `onLogin`, retried webhooks, etc.

When `planId` actually changes mid-period, each limit group on the new plan
picks one of three behaviors (configured per-plan in the dashboard's
**Advanced** section):

| Mode | What happens to counters at switch time |
|---|---|
| `carry` *(default)* | Existing counters with the same limit-group ID continue. Brand-new groups start at 0. |
| `reset` | Counters for the new plan's groups are wiped - the user starts the period from 0. |
| `block` | Counters are pre-filled to quota; `canUse` / `reserve` return `limit_reached` until next rollover. Closes the "free → upgrade → cancel → fresh free quota" cycling exploit. |

---

## Use cases

### 1. Freemium image generator (Flux, DALL·E, SDXL)

Free users get 10 images/month. Pro users get 500. Limit on monthly count, also tracking spend
in cents.

```ts
async function generateImage(userId: string, prompt: string) {
  const r = await vevee.reserve(userId, 'image.render', 1, { model: 'flux-pro' });
  if (!r.allowed) {
    throw new Error(`Out of images: ${r.reasons?.join(', ')}`);
  }
  try {
    const image = await fal.run('fal-ai/flux-pro', { prompt });
    await vevee.commit(r.reservationId!);
    return image;
  } catch (err) {
    // Optional errorCode + reason are persisted on the released reservation
    // so you can audit *why* quota was refunded.
    await vevee.release(r.reservationId!, {
      errorCode: 'provider_error',
      reason: err instanceof Error ? err.message : String(err),
    });
    throw err;
  }
}
```

### 2. LLM token metering (OpenAI, Anthropic streaming)

Reserve an upper bound before the call (e.g. `max_tokens`), commit on success. Optionally
track a refund event for the unused tokens.

```ts
async function chat(userId: string, messages: Message[]) {
  const maxTokens = 4096;
  const r = await vevee.reserve(userId, 'llm.tokens', maxTokens, {
    model: 'gpt-4o',
    direction: 'output',
  });
  if (!r.allowed) throw new Error('Token budget exceeded');

  try {
    const res = await openai.chat.completions.create({
      model: 'gpt-4o',
      messages,
      max_tokens: maxTokens,
    });
    const used = res.usage?.completion_tokens ?? maxTokens;
    await vevee.commit(r.reservationId!);

    // Refund unused tokens (optional but accurate):
    if (used < maxTokens) {
      await vevee.track(userId, 'llm.tokens.refund', maxTokens - used, {
        model: 'gpt-4o',
      });
    }
    return res;
  } catch (err) {
    await vevee.release(r.reservationId!, {
      errorCode: 'provider_error',
      reason: err instanceof Error ? err.message : String(err),
    });
    throw err;
  }
}
```

### 3. Video generation (seconds-based metering)

Limit by total seconds rendered per month - different from per-call billing.

```ts
async function renderVideo(userId: string, durationSec: number) {
  const r = await vevee.reserve(userId, 'video.render', durationSec, {
    resolution: '1080p',
  });
  if (!r.allowed) {
    return { error: 'monthly_video_quota_reached', reasons: r.reasons };
  }
  try {
    const video = await runway.generate({ duration: durationSec });
    await vevee.commit(r.reservationId!);
    return { video };
  } catch (err) {
    await vevee.release(r.reservationId!, {
      errorCode: 'provider_error',
      reason: err instanceof Error ? err.message : String(err),
    });
    throw err;
  }
}
```

### 4. Agent step counting (LangGraph, multi-step workflows)

Cap how many tool-calls / agent steps a user can run. Use `track()` per step because steps
are cheap and after-the-fact metering is fine.

```ts
async function runAgent(userId: string, task: string) {
  const result = await graph.invoke({ task }, {
    callbacks: [{
      onStep: async (step) => {
        await vevee.track(userId, 'agent.step', 1, { tool: step.tool });
      },
    }],
  });
  return result;
}
```

---

## Common patterns

### Express / Hono middleware

```ts
import { vevee } from './vevee';

export const requireQuota = (event: string) => async (req, res, next) => {
  const ok = await vevee.can(req.user.id, event);
  if (!ok) return res.status(429).json({ error: 'limit_reached' });
  next();
};

app.post('/api/render', requireQuota('image.render'), handler);
```

### Next.js route handler with reserve/commit

```ts
// app/api/render/route.ts
import { NextResponse } from 'next/server';
import { vevee } from '@/lib/vevee';
import { auth } from '@/lib/auth';

export async function POST(req: Request) {
  const session = await auth();
  if (!session) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });

  const { prompt } = await req.json();
  const r = await vevee.reserve(session.user.id, 'image.render', 1);
  if (!r.allowed) {
    return NextResponse.json({ error: 'limit_reached', reasons: r.reasons }, { status: 429 });
  }

  try {
    const image = await runFluxPro(prompt);
    await vevee.commit(r.reservationId!);
    return NextResponse.json({ image });
  } catch (err) {
    await vevee.release(r.reservationId!, {
      errorCode: 'provider_error',
      reason: err instanceof Error ? err.message : String(err),
    });
    throw err;
  }
}
```

### Stripe webhook → upsert subscription

```ts
// app/api/stripe/webhook/route.ts
import { vevee } from '@/lib/vevee';

const STRIPE_TO_PLAN: Record<string, string> = {
  prod_pro_monthly: 'plan_01HXY...PRO',
  prod_team:        'plan_01HXY...TEAM',
};

export async function POST(req: Request) {
  const event = parseStripeEvent(await req.text());

  if (event.type === 'checkout.session.completed') {
    const userId = event.data.object.client_reference_id;
    const productId = event.data.object.line_items[0].price.product;
    await vevee.upsertSubscription({
      userId,
      planId: STRIPE_TO_PLAN[productId],
    });
  }

  if (event.type === 'customer.subscription.deleted') {
    const userId = event.data.object.metadata.userId;
    await vevee.upsertSubscription({ userId, planId: 'plan_free' });
  }

  return new Response('ok');
}
```

### Client-side usage display (with pk_live_ key)

```ts
'use client';
import { createClient } from '@vevee/sdk';

const vevee = createClient({ apiKey: process.env.NEXT_PUBLIC_VEVEE_KEY! });

export async function getMyUsage(userId: string) {
  const usage = await vevee.usage(userId);
  // usage.counters -> [{ groupId, label, unit, quota, count, remaining, costCents, filters }, ...]
  // usage.period   -> { start, end }
  // Includes every group on the user's plan (zero-filled). 'remaining' is
  // max(0, quota - count) so UI code never has to subtract or clamp. 'filters'
  // carries the metadata gate that defines per-source / per-variant splits,
  // e.g. { source: ['text'] } - empty {} for "overall" buckets.
  return usage;
}
```

---

## Behavioral analytics

A separate surface from metering: capture what users *do* (paywall views,
onboarding, checkout) - not what they consume. Browser-safe with a public
`pk_*` key. Powers the dashboard funnel builder.

```ts
import { createClient, getAnonymousId } from '@vevee/sdk';

const vevee = createClient({ apiKey: process.env.NEXT_PUBLIC_VEVEE_KEY! });

// Capture an event - a pk_* key is safe in the browser.
vevee.analytics.capture({
  distinctId: getAnonymousId(),
  event: 'paywall_shown',
  properties: { placement: 'after_3rd_image', plan: 'pro' },
});

// Identify on signup - pass the anonymous id once to merge the session.
vevee.analytics.identify(
  user.id,
  { email: user.email },        // profile props (overwrite)
  { signup_source: 'twitter' }, // profile props (write-once)
  getAnonymousId(),             // merge the anonymous session
);

// Send up to 100 events at once.
vevee.analytics.captureBatch([
  { distinctId: 'u_1', event: 'onboarding_step', properties: { step: '1' } },
]);
```

- `getAnonymousId()` returns a stable per-browser id for pre-signup events.
- `vevee.analytics.alias(distinctId, alias)` bridges two ids for one person.
- Reserved event names (`paywall_shown`, `onboarding_step`,
  `checkout_completed`, `signed_up`, `trial_started`, ...) get badges and
  preset funnels. Import `RESERVED_EVENTS` / `isReservedEvent` for typed names.
- `$set` / `$set_once` keys inside `properties` write to the person profile.
- Funnels are built in the dashboard (Funnels -> New funnel) from these events.
- Analytics events have a separate monthly quota; over-quota returns
  `analytics_quota_exceeded` (429).

---

## Attributes - Customer-declared semantic facts

Attributes are typed, named values on a person - declared once in the dashboard
(`persona`: single_choice of {teacher, student}, `goal`: free_text, …) and then
populated automatically from `capture()` events or set explicitly via the SDK.

Unlike `identify()`'s open-ended `$set` properties, attributes carry a declared schema
(type, options, LLM hint, sensitivity). That schema enables three downstream features:

- **Cohort filters in funnels** - `personFilters: [{ attribute: 'persona', op: 'eq', value: 'teacher' }]` restricts a funnel to matching persons before the step walk.
- **Attribute filter chips in the People list** - slice persons by declared attribute values.
- **Auto-injection into future LLM compose calls** - attributes with `useInLlm: true` are sent as structured context (separate spec).

### Setting values - three paths

**Auto-promotion from events (recommended).** Declare a *source* in the dashboard
mapping an event_name + filter pattern to a property extractor. Once configured,
your existing `capture()` calls populate attributes server-side, no code change.

```ts
// Dashboard: "persona" attribute, source: event 'onboarding_step_answered', filter
// step='persona', extract from properties.value.
await vevee.analytics.capture({
  distinctId: user.id,
  event: 'onboarding_step_answered',
  properties: { step: 'persona', value: 'teacher' },
});
// persona='teacher' now lives on this person.
```

**Direct write.**
```ts
await vevee.analytics.setAttribute({
  distinctId: user.id, attribute: 'persona', value: 'teacher',
});
```

**Bulk write (secret key only).**
```ts
const { applied, rejected } = await vevee.analytics.setAttributes({
  distinctId: user.id,
  attributes: { persona: 'teacher', goal: 'lesson_planning' },
});
```

### Reading values

```ts
const { attributes } = await vevee.analytics.getAttributes({ distinctId: user.id });
// { persona: 'teacher', goal: 'lesson_planning' }
```

### Privacy

`exportPerson()` includes an `attributes` block. `deletePerson()` deletes values and
scrubs the audit log to `"[REDACTED]"`. Opted-out persons receive no further attribute
writes. See [/docs/guides/attributes](/docs/guides/attributes) for the full picture.

---

## Decision tree - which method should I call?

```
Are you about to spend money on an AI provider?
├── YES → reserve() → AI call → commit() / release()
└── NO
    ├── Showing a button / quota in the UI?
    │   └── canUse() or can()
    ├── Just recording usage after the fact?
    │   └── track()
    └── Reading a user's current counters?
        └── usage()
```

---

## API key types

| Prefix | Where | Can do |
|---|---|---|
| `sk_live_…` | Backend only. Never ship to a client bundle. | Every endpoint: track, canUse, reserve/commit/release, usage, upsertSubscription. |
| `pk_live_…` | Safe in browser, mobile, public repos. | Read-only - only the caller's own `usage()`. |

---

## Errors at a glance

Every failure throws an `VeveeError` with `{ code, status, message }`. The codes
you'll handle most often:

- `limit_reached` (429) - your end-user is at quota.
- `workspace_limit_reached` (429) - YOUR Vevee account is at quota.
- `invalid_key` (401) - bad or revoked API key.
- `requires_secret_key` (403) - used a `pk_live_` on a write endpoint.
- `reservation_expired` (400) - committed/released after the 60s TTL.

`unmatched_event` and `no_subscription` are **not thrown** - they appear in
the `reasons` array of `canUse` / `reserve` responses when `matched === false`.

Full list: https://www.vevee.org/docs/errors

---

## More

- Quickstart: https://www.vevee.org/docs/quickstart
- Core concepts: https://www.vevee.org/docs/concepts
- SDK reference (track): https://www.vevee.org/docs/methods/track
- SDK reference (canUse): https://www.vevee.org/docs/methods/can-use
- SDK reference (reserve): https://www.vevee.org/docs/methods/reserve
- SDK reference (usage): https://www.vevee.org/docs/methods/usage
- SDK reference (upsertSubscription): https://www.vevee.org/docs/methods/upsert-subscription
- Recipes: https://www.vevee.org/docs/recipes
- Guide - freemium image generator: https://www.vevee.org/docs/guides/freemium-image-generator
- Errors: https://www.vevee.org/docs/errors
- Full LLM-readable SDK reference: https://www.vevee.org/llms.txt
