Files
timmy-tower/artifacts/api-server/src/routes/estimate.ts

84 lines
2.8 KiB
TypeScript
Raw Normal View History

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)
2026-03-19 16:34:05 +00:00
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;