Skip to main content
You can’t fix what you can’t see. The manager writes one row to ai_call per attempt — success, error, or cancelled — including tokens (if the provider reports them), latency, and any error message. That single table powers the admin dashboard at /admin/usage and is all you need to spot a runaway model or a broken provider.

Prerequisites

  • Migrated DB so the ai_call table exists (pnpm db:push).
  • A few real AI calls behind you — the table is empty until then.
  • Read src/ai/manager.ts recordCall() — that’s where every row is born.

The columns that matter

// src/db/ai.schema.ts (excerpt)
ai_call {
  id              text PRIMARY KEY
  userId          text NULL                            // FK to user
  provider        text NOT NULL                        // 'openai' | 'mock' | ...
  model           text NOT NULL                        // 'gpt-4o-mini'
  operation       text NOT NULL                        // 'chat' | 'chat_stream' | 'image'
  status          text NOT NULL DEFAULT 'pending'      // 'success'|'error'|'cancelled'|...
  streamed        boolean NOT NULL DEFAULT false
  cached          boolean NOT NULL DEFAULT false
  inputTokens     integer NULL                         // present for text APIs only
  outputTokens    integer NULL                         // present for text APIs only
  ttftMs          integer NULL                         // streaming only
  totalMs         integer NULL
  errorMessage    text NULL
  metadata        jsonb DEFAULT '{}'
  createdAt       timestamp NOT NULL DEFAULT now()
}
Indexes on userId, (provider, model), status, createdAt cover the queries the dashboard runs.

Why no cost column

We deliberately don’t compute or store dollar cost. Provider pricing is moving target — caching tiers, fine-tune surcharges, agreement discounts — and any local copy drifts within weeks. Vibestrap’s job is to record what happened (calls, tokens, latency, errors); your provider’s billing dashboard is the source of truth for what it cost. If your product does need a cost view (e.g., you bill end-users by provider call), add a cost_micro_cents column back with your own estimation logic. The schema is intentionally trivial to extend.

Why tokens can be NULL

Token-returning APIs (OpenAI / Anthropic / OpenRouter chat) populate inputTokens and outputTokens. Image / video / audio APIs (Replicate / fal.ai) typically do not — those columns stay NULL for those rows. Don’t sum them as “0” silently; treat NULL as “not applicable” in your queries.

Step-by-step: add a usage report

Three useful queries straight against ai_call.

Daily call volume, last 30 days

SELECT
  date_trunc('day', created_at) AS day,
  COUNT(*)                       AS calls,
  COUNT(*) FILTER (WHERE status = 'success') AS successes
FROM ai_call
WHERE created_at >= now() - interval '30 days'
GROUP BY 1
ORDER BY 1 DESC;

Error rate per model

SELECT
  provider || '/' || model AS model,
  COUNT(*) AS calls,
  ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'error') / COUNT(*), 2)
    AS error_pct
FROM ai_call
WHERE created_at >= now() - interval '7 days'
GROUP BY model
HAVING COUNT(*) >= 10
ORDER BY error_pct DESC;

Latency p95 per model

Postgres has percentile_cont:
SELECT
  provider || '/' || model AS model,
  percentile_cont(0.5) WITHIN GROUP (ORDER BY total_ms) AS p50_ms,
  percentile_cont(0.95) WITHIN GROUP (ORDER BY total_ms) AS p95_ms
FROM ai_call
WHERE created_at >= now() - interval '7 days'
  AND total_ms IS NOT NULL
GROUP BY model
ORDER BY p95_ms DESC;

Tying calls to credits

Every successful chat call passes through the manager’s token-based credit reservation. The same ai_call row that records inputTokens / outputTokens is the basis for tokensToCredits() (in src/credits/index.ts). If you change siteConfig.credits.perKToken, historical rows don’t get re-priced — only future calls. For images, the manager charges siteConfig.credits.perImage per generated image, regardless of provider. Override in your own code if you want compute-time-based pricing.