Files
HiveGrid-Wireframes/hivegrid-cloud/docs/features/ai-cost-tracking.md
T

4.9 KiB

AI Cost Tracking — Phase 1

Status: Phase-1 (foundation) shipped. Phases 2 (quota enforcement) and 3 (Stripe overage billing) build on the pipeline below.

Architecture

Three-layer pipeline that attributes every LLM call to a tenant + user and persists the cost asynchronously via the transactional outbox:

LLM call-site
    │
    ▼
executeLLMCall          ← packages/ai/src/cost/executeLLMCall.ts
  ├─ countTokens()      ← packages/ai/src/cost/tokens.ts
  ├─ countCallCost()    ← packages/ai/src/cost/cost.ts
  └─ publishOutboxEvent ← @toolctrl/db/outbox
                        │
                        ▼
                 OutboxEvent (PENDING)
                        │  dispatcher polls (1s)
                        ▼
                 BullMQ outbox.ai-cost queue
                        │
                        ▼
                 ai-cost.persist handler  ← apps/worker/src/jobs/ai-cost-persist.ts
                                                bootstrapped by worker-general
                        │
                        ▼
                 AICostEvent (append-only row)

apps/worker/src/index.ts registers the outbox.ai-cost subscriber on worker-general when Redis is configured. Set AI_COST_PERSIST_ENABLED=false to pause persistence during incident response; leave it enabled in staging/production so the outbox does not report cost events as dispatched while no consumer writes AICostEvent rows.

The apps/worker/src/jobs/ai-cost-aggregate.ts rollup helper upserts TenantAICostSummary rows so dashboard top-line lookups stay O(1). apps/worker/src/index.ts registers the dedicated toolctrl.ai-cost-aggregate scheduler/worker on the general worker group when Redis is configured. Set AI_COST_AGGREGATE_ENABLED=false or 0 to disable it during incident response.

Why a wrapper instead of provider-native usage?

  • Multi-provider consistency. OpenAI, Anthropic, Mistral and Scaleway expose usage telemetry in slightly different shapes. The wrapper normalises to one schema.
  • Hot-path latency. Synchronous cost-write would block the LLM stream. The outbox lets us commit the cost-event in the surrounding transaction and persist async.
  • PII hygiene. The wrapper closes over the prompt/response text; only counts + metadata leak into the outbox payload (see "Security" below).

API

import { executeLLMCall } from "@toolctrl/ai/cost";

const result = await executeLLMCall({
  provider: "openai",
  model: "gpt-4o-mini",
  capability: "chat",
  ctx: { tenantId, userId, requestId: crypto.randomUUID() },
  run: async () => {
    const response = await openai.chat.completions.create({...});
    return {
      output: response.choices[0].message.content ?? "",
      usage: {
        inputTokens: response.usage?.prompt_tokens,
        outputTokens: response.usage?.completion_tokens,
      },
    };
  },
});

When the provider does not return usage numbers, populate inputForCount / outputForCount so the wrapper falls back to countTokens.

Pricing tables

Pricing is checked-in (packages/ai/src/cost/pricing/<version>.ts) and immutable once shipped. Adding a price change:

  1. Copy the latest v<YYYY-MM-DD>.ts to a new file with today's date.
  2. Edit the rows that changed.
  3. Update LATEST_PRICING_VERSION in pricing/index.ts.
  4. Append the new entry to PRICING_VERSIONS.

Historical events keep referring to their original pricing version via AICostEvent.pricingVersion — so a 6-month-old event still renders with the cents it was originally billed at.

Phase-1 ships covers OpenAI (10 models), Anthropic (6), Mistral (7), Scaleway (5) and Aleph-Alpha (5).

Operational toggles

Env Default Effect
AI_COST_TRACKING_ENABLED=false enabled Skip outbox publish entirely; LLM hot-path keeps working

Idempotency

Every event carries a requestId which the publisher writes as the outbox dispatchKey AND which is uniquely indexed on AICostEvent. Re-deliveries collapse to a no-op via the existing outbox dedup-on-conflict path.

Tenant isolation

  • AICostEvent.tenantId is nullable to support admin-only chats; rows cascade-delete with the Tenant.
  • Dashboard loaders strictly filter by tenantId.
  • Admin analytics endpoints are gated behind the admin role.

Security

  • The outbox payload contains counts + metadata only. Prompt and response text never leave the wrapper closure.
  • CSV-exports cap at 100k rows; larger exports go through the background-job queue (planned).
  • Pricing tables are git-versioned — no DB override path.
  • Cost wrapper adds <5ms in the LLM hot-path (tokenizer is sync; outbox insert is one round-trip co-located with existing tx).

Spec

Full spec, acceptance criteria and migration plan: docs/improvement-plan/05-business-features/ai-cost-phase-1.md.