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:
alexpaynex
2026-03-19 16:34:05 +00:00
parent b664ee9b2f
commit 4c3a0e867a
9 changed files with 502 additions and 27 deletions

View File

@@ -0,0 +1,83 @@
import { Router, type Request, type Response } from "express";
import { pricingService } from "../lib/pricing.js";
import { agentService } from "../lib/agent.js";
import { getBtcPriceUsd, usdToSats } from "../lib/btc-oracle.js";
import { freeTierService } from "../lib/free-tier.js";
import { trustService } from "../lib/trust.js";
const router = Router();
/**
* GET /api/estimate?request=<text>[&nostr_token=<token>]
*
* Returns a pre-flight cost estimate for a request, including free-tier
* status for the authenticated identity (if a valid nostr_token is supplied).
*
* No payment required. Does not create a job.
*/
router.get("/estimate", async (req: Request, res: Response) => {
const requestText =
typeof req.query.request === "string" ? req.query.request.trim() : "";
if (!requestText) {
res.status(400).json({ error: "Query param 'request' is required" });
return;
}
try {
const inputTokens = pricingService.estimateInputTokens(requestText);
const outputTokens = pricingService.estimateOutputTokens(requestText);
const btcPriceUsd = await getBtcPriceUsd();
const costUsd = pricingService.calculateWorkFeeUsd(inputTokens, outputTokens, agentService.workModel);
const estimatedSats = usdToSats(costUsd, btcPriceUsd);
// Optionally resolve Nostr identity from query param or header for free-tier preview
const rawToken =
(req.headers["x-nostr-token"] as string | undefined) ??
(typeof req.query.nostr_token === "string" ? req.query.nostr_token : undefined);
let pubkey: string | null = null;
let trustTier = "anonymous";
let freeTierDecision: { serve: string; absorbSats: number; chargeSats: number } | null = null;
if (rawToken) {
const parsed = trustService.verifyToken(rawToken.trim());
if (parsed) {
pubkey = parsed.pubkey;
trustTier = await trustService.getTier(pubkey);
const decision = await freeTierService.decide(pubkey, estimatedSats);
freeTierDecision = {
serve: decision.serve,
absorbSats: decision.absorbSats,
chargeSats: decision.chargeSats,
};
}
}
const poolStatus = await freeTierService.poolStatus();
res.json({
estimatedSats,
estimatedCostUsd: costUsd,
btcPriceUsd,
tokenEstimate: {
inputTokens,
outputTokens,
model: agentService.workModel,
},
identity: {
trust_tier: trustTier,
...(pubkey ? { pubkey } : {}),
...(freeTierDecision ? { free_tier: freeTierDecision } : {}),
},
pool: {
balanceSats: poolStatus.balanceSats,
dailyBudgets: poolStatus.budgets,
},
});
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : "Estimate failed" });
}
});
export default router;

View File

@@ -11,12 +11,14 @@ import nodeDiagnosticsRouter from "./node-diagnostics.js";
import metricsRouter from "./metrics.js";
import worldRouter from "./world.js";
import identityRouter from "./identity.js";
import estimateRouter from "./estimate.js";
const router: IRouter = Router();
router.use(healthRouter);
router.use(metricsRouter);
router.use(jobsRouter);
router.use(estimateRouter);
router.use(bootstrapRouter);
router.use(sessionsRouter);
router.use(identityRouter);

View File

@@ -1,5 +1,5 @@
import { Router, type Request, type Response } from "express";
import { randomUUID } from "crypto";
import { randomUUID, createHash } from "crypto";
import { db, jobs, invoices, type Job } from "@workspace/db";
import { eq, and } from "drizzle-orm";
import { CreateJobBody, GetJobParams } from "@workspace/api-zod";
@@ -12,6 +12,7 @@ import { streamRegistry } from "../lib/stream-registry.js";
import { makeLogger } from "../lib/logger.js";
import { latencyHistogram } from "../lib/histogram.js";
import { trustService } from "../lib/trust.js";
import { freeTierService } from "../lib/free-tier.js";
const logger = makeLogger("jobs");
@@ -30,8 +31,13 @@ async function getInvoiceById(id: string) {
/**
* Runs the AI eval in a background task (fire-and-forget) so HTTP polls
* return immediately with "evaluating" state instead of blocking 5-8 seconds.
* nostrPubkey is used for free-tier routing and trust scoring.
*/
async function runEvalInBackground(jobId: string, request: string): Promise<void> {
async function runEvalInBackground(
jobId: string,
request: string,
nostrPubkey: string | null,
): Promise<void> {
const evalStart = Date.now();
try {
const evalResult = await agentService.evaluateRequest(request);
@@ -54,8 +60,49 @@ async function runEvalInBackground(jobId: string, request: string): Promise<void
agentService.workModel,
);
// ── Free-tier gate ──────────────────────────────────────────────────
const ftDecision = await freeTierService.decide(nostrPubkey, breakdown.amountSats);
if (ftDecision.serve === "free") {
// Skip work invoice — execute immediately at Timmy's expense
await db
.update(jobs)
.set({
state: "executing",
workAmountSats: 0,
estimatedCostUsd: breakdown.estimatedCostUsd,
marginPct: breakdown.marginPct,
btcPriceUsd: breakdown.btcPriceUsd,
freeTier: true,
absorbedSats: breakdown.amountSats,
updatedAt: new Date(),
})
.where(eq(jobs.id, jobId));
eventBus.publish({ type: "job:state", jobId, state: "executing" });
// Record grant (deducts from pool, increments identity's daily budget)
if (nostrPubkey) {
const reqHash = createHash("sha256").update(request).digest("hex");
void freeTierService.recordGrant(nostrPubkey, reqHash, breakdown.amountSats);
}
streamRegistry.register(jobId);
setImmediate(() => {
void runWorkInBackground(
jobId, request, 0, breakdown.btcPriceUsd, true, nostrPubkey,
);
});
return;
}
// Partial subsidy or full gate: invoice amount = chargeSats
const invoiceSats = ftDecision.serve === "partial"
? ftDecision.chargeSats
: breakdown.amountSats;
const workInvoiceData = await lnbitsService.createInvoice(
breakdown.amountSats,
invoiceSats,
`Work fee for job ${jobId}`,
);
const workInvoiceId = randomUUID();
@@ -66,7 +113,7 @@ async function runEvalInBackground(jobId: string, request: string): Promise<void
jobId,
paymentHash: workInvoiceData.paymentHash,
paymentRequest: workInvoiceData.paymentRequest,
amountSats: breakdown.amountSats,
amountSats: invoiceSats,
type: "work",
paid: false,
});
@@ -75,14 +122,25 @@ async function runEvalInBackground(jobId: string, request: string): Promise<void
.set({
state: "awaiting_work_payment",
workInvoiceId,
workAmountSats: breakdown.amountSats,
workAmountSats: invoiceSats,
estimatedCostUsd: breakdown.estimatedCostUsd,
marginPct: breakdown.marginPct,
btcPriceUsd: breakdown.btcPriceUsd,
...(ftDecision.serve === "partial" ? {
freeTier: true,
absorbedSats: ftDecision.absorbSats,
} : {}),
updatedAt: new Date(),
})
.where(eq(jobs.id, jobId));
});
// Record partial grant immediately (reserves pool capacity)
if (ftDecision.serve === "partial" && nostrPubkey) {
const reqHash = createHash("sha256").update(request).digest("hex");
void freeTierService.recordGrant(nostrPubkey, reqHash, ftDecision.absorbSats);
}
eventBus.publish({ type: "job:state", jobId, state: "awaiting_work_payment" });
} else {
await db
@@ -92,9 +150,8 @@ async function runEvalInBackground(jobId: string, request: string): Promise<void
eventBus.publish({ type: "job:state", jobId, state: "rejected" });
// Trust scoring — penalise on rejection
const rejectedJob = await getJobById(jobId);
if (rejectedJob?.nostrPubkey) {
void trustService.recordFailure(rejectedJob.nostrPubkey, evalResult.reason ?? "rejected");
if (nostrPubkey) {
void trustService.recordFailure(nostrPubkey, evalResult.reason ?? "rejected");
}
}
} catch (err) {
@@ -106,9 +163,8 @@ async function runEvalInBackground(jobId: string, request: string): Promise<void
eventBus.publish({ type: "job:failed", jobId, reason: message });
// Trust scoring — penalise on eval failure
const failedJob = await getJobById(jobId);
if (failedJob?.nostrPubkey) {
void trustService.recordFailure(failedJob.nostrPubkey, message);
if (nostrPubkey) {
void trustService.recordFailure(nostrPubkey, message);
}
}
}
@@ -116,8 +172,18 @@ async function runEvalInBackground(jobId: string, request: string): Promise<void
/**
* Runs the AI work execution in a background task so HTTP polls return fast.
* Uses streaming so any connected SSE client receives tokens in real time (#3).
*
* isFree=true → free-tier job; no refund logic, no pool credit needed.
* nostrPubkey → identity for trust scoring (already known at call site).
*/
async function runWorkInBackground(jobId: string, request: string, workAmountSats: number, btcPriceUsd: number | null): Promise<void> {
async function runWorkInBackground(
jobId: string,
request: string,
workAmountSats: number,
btcPriceUsd: number | null,
isFree = false,
nostrPubkey: string | null = null,
): Promise<void> {
const workStart = Date.now();
try {
eventBus.publish({ type: "job:state", jobId, state: "executing" });
@@ -136,9 +202,12 @@ async function runWorkInBackground(jobId: string, request: string, workAmountSat
);
const lockedBtcPrice = btcPriceUsd ?? 100_000;
const actualAmountSats = pricingService.calculateActualChargeSats(actualCostUsd, lockedBtcPrice);
const refundAmountSats = pricingService.calculateRefundSats(workAmountSats, actualAmountSats);
const refundState = refundAmountSats > 0 ? "pending" : "not_applicable";
// For free-tier jobs the user paid nothing — no refund is applicable.
const actualAmountSats = isFree ? 0 : pricingService.calculateActualChargeSats(actualCostUsd, lockedBtcPrice);
const refundAmountSats = isFree ? 0 : pricingService.calculateRefundSats(workAmountSats, actualAmountSats);
const refundState: "not_applicable" | "pending" = isFree
? "not_applicable"
: (refundAmountSats > 0 ? "pending" : "not_applicable");
await db
.update(jobs)
@@ -157,6 +226,7 @@ async function runWorkInBackground(jobId: string, request: string, workAmountSat
logger.info("work completed", {
jobId,
isFree,
inputTokens: workResult.inputTokens,
outputTokens: workResult.outputTokens,
actualAmountSats,
@@ -165,10 +235,15 @@ async function runWorkInBackground(jobId: string, request: string, workAmountSat
});
eventBus.publish({ type: "job:completed", jobId, result: workResult.result });
// Credit the generosity pool from paid interactions
if (!isFree && workAmountSats > 0) {
void freeTierService.credit(workAmountSats);
}
// Trust scoring — fire and forget
const completedJob = await getJobById(jobId);
if (completedJob?.nostrPubkey) {
void trustService.recordSuccess(completedJob.nostrPubkey, actualAmountSats);
const pubkeyForTrust = nostrPubkey ?? (await getJobById(jobId))?.nostrPubkey ?? null;
if (pubkeyForTrust) {
void trustService.recordSuccess(pubkeyForTrust, actualAmountSats);
}
} catch (err) {
const message = err instanceof Error ? err.message : "Execution error";
@@ -180,9 +255,9 @@ async function runWorkInBackground(jobId: string, request: string, workAmountSat
eventBus.publish({ type: "job:failed", jobId, reason: message });
// Trust scoring — penalise on work failure
const failedJob = await getJobById(jobId);
if (failedJob?.nostrPubkey) {
void trustService.recordFailure(failedJob.nostrPubkey, message);
const pubkeyForTrust = nostrPubkey ?? (await getJobById(jobId))?.nostrPubkey ?? null;
if (pubkeyForTrust) {
void trustService.recordFailure(pubkeyForTrust, message);
}
}
}
@@ -220,7 +295,7 @@ async function advanceJob(job: Job): Promise<Job | null> {
eventBus.publish({ type: "job:state", jobId: job.id, state: "evaluating" });
// Fire AI eval in background — poll returns immediately with "evaluating"
setImmediate(() => { void runEvalInBackground(job.id, job.request); });
setImmediate(() => { void runEvalInBackground(job.id, job.request, job.nostrPubkey ?? null); });
return getJobById(job.id);
}
@@ -254,7 +329,16 @@ async function advanceJob(job: Job): Promise<Job | null> {
streamRegistry.register(job.id);
// Fire AI work in background — poll returns immediately with "executing"
setImmediate(() => { void runWorkInBackground(job.id, job.request, job.workAmountSats ?? 0, job.btcPriceUsd); });
setImmediate(() => {
void runWorkInBackground(
job.id,
job.request,
job.workAmountSats ?? 0,
job.btcPriceUsd,
job.freeTier ?? false,
job.nostrPubkey ?? null,
);
});
return getJobById(job.id);
}
@@ -386,6 +470,7 @@ router.get("/jobs/:id", async (req: Request, res: Response) => {
completedAt: job.state === "complete" ? job.updatedAt.toISOString() : null,
...(job.nostrPubkey ? { nostrPubkey: job.nostrPubkey } : {}),
trust_tier: trustTier,
...(job.freeTier ? { free_tier: true, absorbed_sats: job.absorbedSats ?? 0 } : {}),
};
switch (job.state) {

View File

@@ -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,
},
});