feat(credits): port computeCreditsDeductedCents from open-agents (step 2/6)
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):
| File | Purpose |
|---|---|
| `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)
- 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.
- Token-based estimate against the catalog's `cost` entry. Catalog comes from `getAvailableModels()` (gateway + models.dev pipeline).
- 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.tsto compute cents per turn: gateway-reported cost → token estimate → 1c floor. - Added
lib/credits/estimateModelUsageCost.tsfor token-based USD estimates, including 200k+ context tier andcache_readpricing. - Added
lib/credits/AvailableModelCost.tstypes mirroring open-agents; uses thegetAvailableModels()catalog.
- Added