Task #27: Cost-routing + free-tier gate
## What was built
### DB schema
- `timmy_config` table: key/value store for the generosity pool balance
- `free_tier_grants` table: immutable audit log of every Timmy-absorbed request
- `jobs.free_tier` (boolean) + `jobs.absorbed_sats` (integer) columns
### FreeTierService (`lib/free-tier.ts`)
- Per-tier daily sats budgets (new=0, established=50, trusted=200, elite=1000)
— all env-var overridable
- `decide(pubkey, estimatedSats)` → `{ serve: free|partial|gate, absorbSats, chargeSats }`
— checks pool balance AND identity daily budget atomically
- `credit(paidSats)` — credits POOL_CREDIT_PCT (default 10%) of every paid
work invoice back to the generosity pool
- `recordGrant(pubkey, reqHash, absorbSats)` — DB transaction: deducts pool,
updates identity daily absorption counter, writes audit row
- `poolStatus()` — snapshot for metrics/monitoring
### Route integration
- `POST /api/jobs` (eval → work flow): after eval passes, `freeTierService.decide()`
intercepts. Free → skip invoice, fire work directly. Partial → discounted invoice.
Gate (anonymous/new tier/pool empty) → unchanged full-price flow.
- `POST /api/sessions/:id/request`: after compute, free-tier discount applied to
balance debit. Session balance only reduced by `chargeSats`; absorbed portion
comes from pool.
- Pool credited on every paid work completion (both jobs and session paths).
- Response fields: `free_tier: true`, `absorbed_sats: N` when applicable.
### GET /api/estimate
- Lightweight pre-flight cost estimator; no payment required
- Returns: estimatedSats, btcPriceUsd, tokenEstimate, identity.free_tier decision
(if valid nostr_token provided), pool.balanceSats, pool.dailyBudgets
### Tests
- All 29 existing testkit tests pass (0 failures)
- Anonymous/new-tier users hit gate path correctly (verified manually)
- Pool seeds to 10,000 sats on first boot
## Architecture notes
- Free tier decision happens BEFORE invoice creation for jobs (save user the click)
- Partial grant recorded at invoice creation time (reserves pool capacity proactively)
- Free tier for sessions decided AFTER compute (actual cost known, applied to debit)
- Pool crediting is fire-and-forget (non-blocking)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { randomBytes, randomUUID } from "crypto";
|
||||
import { randomBytes, randomUUID, createHash } from "crypto";
|
||||
import { db, sessions, sessionRequests, type Session } from "@workspace/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { lnbitsService } from "../lib/lnbits.js";
|
||||
@@ -9,6 +9,7 @@ import { agentService } from "../lib/agent.js";
|
||||
import { pricingService } from "../lib/pricing.js";
|
||||
import { getBtcPriceUsd, usdToSats } from "../lib/btc-oracle.js";
|
||||
import { trustService } from "../lib/trust.js";
|
||||
import { freeTierService } from "../lib/free-tier.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -350,7 +351,28 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => {
|
||||
// ── Honest accounting ────────────────────────────────────────────────────
|
||||
const totalTokenCostUsd = evalCostUsd + workCostUsd;
|
||||
const chargeUsd = pricingService.calculateActualChargeUsd(totalTokenCostUsd);
|
||||
const debitedSats = usdToSats(chargeUsd, btcPriceUsd);
|
||||
const fullDebitSats = usdToSats(chargeUsd, btcPriceUsd);
|
||||
|
||||
// ── Free-tier gate (only on successful requests) ─────────────────────────
|
||||
let debitedSats = fullDebitSats;
|
||||
let freeTierServed = false;
|
||||
let absorbedSats = 0;
|
||||
|
||||
if (finalState === "complete" && session.nostrPubkey) {
|
||||
const ftDecision = await freeTierService.decide(session.nostrPubkey, fullDebitSats);
|
||||
if (ftDecision.serve !== "gate") {
|
||||
absorbedSats = ftDecision.absorbSats;
|
||||
debitedSats = ftDecision.chargeSats;
|
||||
freeTierServed = true;
|
||||
const reqHash = createHash("sha256").update(requestText).digest("hex");
|
||||
void freeTierService.recordGrant(session.nostrPubkey, reqHash, absorbedSats);
|
||||
}
|
||||
}
|
||||
|
||||
// Credit pool from paid portion (even if partial free tier)
|
||||
if (finalState === "complete" && debitedSats > 0) {
|
||||
void freeTierService.credit(debitedSats);
|
||||
}
|
||||
|
||||
const newBalance = session.balanceSats - debitedSats;
|
||||
const newSessionState = newBalance < MIN_BALANCE_SATS ? "paused" : "active";
|
||||
@@ -403,6 +425,7 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => {
|
||||
...(errorMessage ? { errorMessage } : {}),
|
||||
debitedSats,
|
||||
balanceRemaining: newBalance,
|
||||
...(freeTierServed ? { free_tier: true, absorbed_sats: absorbedSats } : {}),
|
||||
cost: {
|
||||
evalSats: usdToSats(
|
||||
pricingService.calculateActualChargeUsd(evalCostUsd),
|
||||
@@ -411,7 +434,9 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => {
|
||||
workSats: workCostUsd > 0
|
||||
? usdToSats(pricingService.calculateActualChargeUsd(workCostUsd), btcPriceUsd)
|
||||
: 0,
|
||||
totalSats: debitedSats,
|
||||
totalSats: fullDebitSats,
|
||||
chargedSats: debitedSats,
|
||||
absorbedSats,
|
||||
btcPriceUsd,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user