feat(credits): port computeCreditsDeductedCents from open-agents (step 2/6)

Closed
SW
5mo ago
+509-0

Summary

Step 2 of 6 in closing the chat-workflow credits gap tracked in #605 (silent revenue loss — every chat turn on `/api/chat/workflow` is currently free).

Ports the per-turn cost math from open-agents so the same billing math runs on both sides of the chat cutover. No behavior change yet — this PR only adds the foundation that step 3 (the `deductCreditsWithAudit` wrapper) will use.

What landed

Three new files in `lib/credits/` (one exported function each, matching api's SRP convention):

FilePurpose
`AvailableModelCost.ts`Cost-catalog type. Mirrors open-agents' `AvailableModelCost` so the same estimator handles either catalog. api's current catalog (via `getAvailableModels` → `models.dev`) only emits `{ input, output }` — the richer `cache_read` / `context_over_200k` fields are typed-but-undefined for now and future-proof for catalog expansion.
`estimateModelUsageCost.ts`Token-based USD estimator. Ports open-agents `apps/web/lib/models.ts:estimateModelUsageCost` line-for-line. Handles the 200k+ context tier swap with partial-override fallback, and bills cached input tokens at `cache_read` price when present (falls back to base input price otherwise).
`computeCreditsDeductedCents.ts`Top-level orchestrator. Ports open-agents' `compute-credits-deducted-cents.ts` with one improvement: calls api's `getAvailableModels()` directly instead of HTTP-fetching `/api/ai/models` from itself.

Resolution order (unchanged from open-agents)

  1. Gateway-reported cost on `responseMessage.metadata.totalMessageCost` — the same number the chat UI shows next to the assistant response. Used directly so the wallet debit converges with the cost label.
  2. Token-based estimate against the catalog's `cost` entry. Catalog comes from `getAvailableModels()` (gateway + models.dev pipeline).
  3. 1c floor — every successful turn moves the wallet by at least 1c so a catalog outage can't make a turn free.

Tests

27 new unit tests:

  • `estimateModelUsageCost.test.ts` — 13 tests:

    • guard rails (missing cost / missing input price / missing output price → undefined)
    • base tier math, cached + uncached portions
    • `cache_read` priced correctly when present; falls back to input price when absent
    • clamps negative cached / negative output to 0
    • clamps `cachedInputTokens > inputTokens` so uncached portion can't go negative
    • `context_over_200k` tier swap triggers at strictly > 200k input
    • tier swap ignored when only `cache_read` is overridden (not a real tier)
    • falls back to base tier values when override is partial
  • `computeCreditsDeductedCents.test.ts` — 14 tests:

    • gateway cost path: positive number → cents, rounds correctly, 1c floor
    • does NOT call catalog when gateway cost is usable (perf check)
    • falls back to estimate for undefined / 0 / negative / NaN / Infinity gateway cost
    • returns 1c when model not in catalog / cost missing / fetch rejects / estimate ≤ 0
    • matches modelId exactly (no fuzzy matching)

Full suite: 3191 → 3205 passing.

What's next

After this merges:

  • Step 3: `lib/credits/deductCreditsWithAudit.ts` — thin `supabase.rpc('deduct_credits_with_audit', ...)` wrapper using the RPC merged in recoupable/database#26
  • Step 4: `"use step"` `recordChatUsage.ts` reading `responseMessage.metadata.{totalMessageCost, totalMessageUsage}` and calling the wrapper
  • Step 5: Wire `recordChatUsage` into `runAgentWorkflow` after `persistAssistantMessage`
  • Step 6: Preview verification — trigger a chat, then `GET /api/accounts/{id}/credits` (balance drops) and `/api/admins/credits/events` (new audit row visible)

Test plan

  • 27 new unit tests pass
  • Full suite passes (3205/3205)
  • Lint / format clean
  • After merge to `test`: confirm gateway-cost path with a live chat turn (`responseMessage.metadata.totalMessageCost` is currently emitted by `buildMessageMetadataCallback` and would now also be the input to billing)

🤖 Generated with Claude Code


Summary by cubic

Ports the per-turn billing math from open-agents to compute chat turn charges consistently across the cutover. Step 2/6 for #605; no behavior change yet.

  • New Features
    • Added lib/credits/computeCreditsDeductedCents.ts to compute cents per turn: gateway-reported cost → token estimate → 1c floor.
    • Added lib/credits/estimateModelUsageCost.ts for token-based USD estimates, including 200k+ context tier and cache_read pricing.
    • Added lib/credits/AvailableModelCost.ts types mirroring open-agents; uses the getAvailableModels() catalog.