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 { randomUUID } from "crypto";
|
|
|
|
|
import { db, timmyConfig, freeTierGrants, nostrIdentities, type NostrIdentity, type TrustTier } from "@workspace/db";
|
2026-03-19 16:47:51 +00:00
|
|
|
import { eq, sql } from "drizzle-orm";
|
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 { makeLogger } from "./logger.js";
|
|
|
|
|
import { trustService } from "./trust.js";
|
|
|
|
|
|
|
|
|
|
const logger = makeLogger("free-tier");
|
|
|
|
|
|
|
|
|
|
// ── Env-var helpers ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function envInt(name: string, fallback: number): number {
|
|
|
|
|
const raw = parseInt(process.env[name] ?? "", 10);
|
|
|
|
|
return Number.isFinite(raw) && raw >= 0 ? raw : fallback;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function envFloat(name: string, fallback: number): number {
|
|
|
|
|
const raw = parseFloat(process.env[name] ?? "");
|
|
|
|
|
return Number.isFinite(raw) && raw >= 0 ? raw : fallback;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Daily free budget per trust tier (sats) ───────────────────────────────────
|
|
|
|
|
// Override via env vars. "new" tier gets 0 — must earn trust first.
|
|
|
|
|
const DAILY_BUDGET_NEW = envInt("FREE_TIER_BUDGET_NEW", 0);
|
|
|
|
|
const DAILY_BUDGET_ESTABLISHED = envInt("FREE_TIER_BUDGET_ESTABLISHED", 50);
|
|
|
|
|
const DAILY_BUDGET_TRUSTED = envInt("FREE_TIER_BUDGET_TRUSTED", 200);
|
|
|
|
|
const DAILY_BUDGET_ELITE = envInt("FREE_TIER_BUDGET_ELITE", 1_000);
|
|
|
|
|
|
|
|
|
|
// ── Generosity pool ───────────────────────────────────────────────────────────
|
|
|
|
|
// Pool starts at POOL_INITIAL_SATS on first boot. Grows by POOL_CREDIT_PCT of
|
|
|
|
|
// every paid work invoice. Never drops below 0.
|
|
|
|
|
const POOL_INITIAL_SATS = envInt("FREE_TIER_POOL_INITIAL_SATS", 10_000);
|
|
|
|
|
const POOL_CREDIT_PCT = envFloat("FREE_TIER_POOL_CREDIT_PCT", 10);
|
|
|
|
|
const POOL_KEY = "generosity_pool_sats";
|
|
|
|
|
|
|
|
|
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export interface FreeTierDecision {
|
|
|
|
|
serve: "free" | "partial" | "gate";
|
|
|
|
|
absorbSats: number;
|
|
|
|
|
chargeSats: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── FreeTierService ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export class FreeTierService {
|
|
|
|
|
// Daily sat budget for each trust tier.
|
|
|
|
|
dailyBudgetForTier(tier: TrustTier): number {
|
|
|
|
|
switch (tier) {
|
|
|
|
|
case "elite": return DAILY_BUDGET_ELITE;
|
|
|
|
|
case "trusted": return DAILY_BUDGET_TRUSTED;
|
|
|
|
|
case "established": return DAILY_BUDGET_ESTABLISHED;
|
|
|
|
|
case "new":
|
|
|
|
|
default: return DAILY_BUDGET_NEW;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Read the current pool balance; initialise to POOL_INITIAL_SATS on first call.
|
|
|
|
|
async getPoolBalance(): Promise<number> {
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select()
|
|
|
|
|
.from(timmyConfig)
|
|
|
|
|
.where(eq(timmyConfig.key, POOL_KEY))
|
|
|
|
|
.limit(1);
|
|
|
|
|
|
|
|
|
|
if (rows[0]) return parseInt(rows[0].value, 10) || 0;
|
|
|
|
|
|
|
|
|
|
// First boot — seed the pool
|
|
|
|
|
await db
|
|
|
|
|
.insert(timmyConfig)
|
|
|
|
|
.values({ key: POOL_KEY, value: String(POOL_INITIAL_SATS) })
|
|
|
|
|
.onConflictDoNothing();
|
|
|
|
|
|
|
|
|
|
logger.info("generosity pool initialised", { initialSats: POOL_INITIAL_SATS });
|
|
|
|
|
return POOL_INITIAL_SATS;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// How many sats has this identity absorbed today (resets after 24 h)?
|
|
|
|
|
getTodayAbsorbed(identity: NostrIdentity): number {
|
|
|
|
|
const hoursSinceReset =
|
|
|
|
|
(Date.now() - identity.absorbedResetAt.getTime()) / (1000 * 60 * 60);
|
|
|
|
|
if (hoursSinceReset >= 24) return 0;
|
|
|
|
|
return identity.satsAbsorbedToday;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Decide whether to serve a request for free, partially free, or at full cost.
|
|
|
|
|
*
|
|
|
|
|
* Returns:
|
|
|
|
|
* serve="free" → absorbSats=estimatedSats, chargeSats=0
|
|
|
|
|
* serve="partial" → 0 < absorbSats < estimatedSats, chargeSats = remainder
|
|
|
|
|
* serve="gate" → absorbSats=0, chargeSats=estimatedSats (pay full price)
|
|
|
|
|
*/
|
|
|
|
|
async decide(pubkey: string | null, estimatedSats: number): Promise<FreeTierDecision> {
|
|
|
|
|
const gate: FreeTierDecision = { serve: "gate", absorbSats: 0, chargeSats: estimatedSats };
|
|
|
|
|
|
|
|
|
|
if (!pubkey || estimatedSats <= 0) return gate;
|
|
|
|
|
|
|
|
|
|
const identity = await trustService.getIdentityWithDecay(pubkey);
|
|
|
|
|
if (!identity) return gate;
|
|
|
|
|
|
|
|
|
|
const dailyBudget = this.dailyBudgetForTier(identity.tier);
|
|
|
|
|
if (dailyBudget === 0) return gate;
|
|
|
|
|
|
|
|
|
|
const todayAbsorbed = this.getTodayAbsorbed(identity);
|
|
|
|
|
const dailyRemaining = Math.max(0, dailyBudget - todayAbsorbed);
|
|
|
|
|
if (dailyRemaining === 0) {
|
|
|
|
|
logger.info("free-tier: daily budget exhausted", {
|
|
|
|
|
pubkey: pubkey.slice(0, 8),
|
|
|
|
|
tier: identity.tier,
|
|
|
|
|
todayAbsorbed,
|
|
|
|
|
dailyBudget,
|
|
|
|
|
});
|
|
|
|
|
return gate;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const poolBalance = await this.getPoolBalance();
|
|
|
|
|
if (poolBalance <= 0) {
|
|
|
|
|
logger.warn("free-tier: pool empty", { poolBalance });
|
|
|
|
|
return gate;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const canAbsorb = Math.min(dailyRemaining, poolBalance, estimatedSats);
|
|
|
|
|
|
|
|
|
|
if (canAbsorb >= estimatedSats) {
|
|
|
|
|
logger.info("free-tier: serving free", {
|
|
|
|
|
pubkey: pubkey.slice(0, 8),
|
|
|
|
|
tier: identity.tier,
|
|
|
|
|
estimatedSats,
|
|
|
|
|
poolBalance,
|
|
|
|
|
});
|
|
|
|
|
return { serve: "free", absorbSats: estimatedSats, chargeSats: 0 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (canAbsorb > 0) {
|
|
|
|
|
logger.info("free-tier: partial subsidy", {
|
|
|
|
|
pubkey: pubkey.slice(0, 8),
|
|
|
|
|
tier: identity.tier,
|
|
|
|
|
absorbSats: canAbsorb,
|
|
|
|
|
chargeSats: estimatedSats - canAbsorb,
|
|
|
|
|
});
|
|
|
|
|
return { serve: "partial", absorbSats: canAbsorb, chargeSats: estimatedSats - canAbsorb };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return gate;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Credit the generosity pool from a paid interaction.
|
|
|
|
|
* Called after a work invoice is paid and work completes successfully.
|
|
|
|
|
* creditSats = paidSats * POOL_CREDIT_PCT / 100
|
|
|
|
|
*/
|
|
|
|
|
async credit(paidSats: number): Promise<void> {
|
|
|
|
|
if (paidSats <= 0) return;
|
|
|
|
|
const creditSats = Math.floor(paidSats * POOL_CREDIT_PCT / 100);
|
|
|
|
|
if (creditSats <= 0) return;
|
|
|
|
|
|
|
|
|
|
const current = await this.getPoolBalance();
|
|
|
|
|
const next = current + creditSats;
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
|
|
|
|
await db
|
|
|
|
|
.insert(timmyConfig)
|
|
|
|
|
.values({ key: POOL_KEY, value: String(next), updatedAt: now })
|
|
|
|
|
.onConflictDoUpdate({
|
|
|
|
|
target: timmyConfig.key,
|
|
|
|
|
set: { value: String(next), updatedAt: now },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
logger.info("generosity pool credited", { paidSats, creditSats, poolBalance: next });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Record that Timmy absorbed `absorbSats` on behalf of `pubkey`.
|
|
|
|
|
* Atomically: deducts from pool, increments identity's daily absorption, writes audit row.
|
|
|
|
|
*/
|
|
|
|
|
async recordGrant(
|
|
|
|
|
pubkey: string,
|
|
|
|
|
requestHash: string,
|
|
|
|
|
absorbSats: number,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
if (absorbSats <= 0) return;
|
|
|
|
|
|
|
|
|
|
const now = new Date();
|
Task #27: Complete cost-routing + free-tier gate — all critical fixes applied
Fix 1 — Add `estimateRequestCost(request, model)` to PricingService (pricing.ts)
- Unified: estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd
- Replaces duplicated estimation logic in jobs.ts, sessions.ts, estimate.ts
Fix 2 — Move partial free-tier `recordGrant()` from invoice creation to post-work
- Was called at invoice creation — economic DoS vulnerability
- Now deferred to runWorkInBackground via new `partialAbsorbSats` param
- Fully-free jobs still record grant at eval time (no payment involved)
Fix 3 — Sessions pre-gate: estimate → decide → execute → reconcile
- freeTierService.decide() now runs on ESTIMATED cost BEFORE executeWork()
- Fixed double-margin bug: estimateRequestCost returns cost already with infra+margin
(calculateWorkFeeUsd), convert directly to sats — no second calculateActualChargeUsd
- absorbedSats capped at actual cost post-execution to prevent over-absorption
Fix 4 — Correct isFree derivation for partial jobs in advanceJob() (jobs.ts)
- isFreeExecution = workAmountSats === 0 (not job.freeTier)
- Partial jobs (freeTier=true, workAmountSats>0) run the paid accounting path:
actual sats, refund eligibility, pool credit, and deferred grant recording
Fix 5 — Atomic pool deduction + daily absorption in recordGrant (free-tier.ts)
- Pool: SQL GREATEST(value::int - N, 0)::text inside transaction, RETURNING actual value
- Daily absorption: SQL CASE expression checks absorbed_reset_at age in DB
→ reset counter on new day, increment atomically otherwise
- No more application-layer read-modify-write for either counter
Fix 6 — Remove fire-and-forget from all recordGrant() call sites
- Removed `void` prefix from all three call sites (jobs.ts x2, sessions.ts x1)
- Grant persistence failures now propagate correctly instead of silently diverging
- Removed unused createHash import from free-tier.ts
2026-03-19 16:55:03 +00:00
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
|
|
Task #27: Complete cost-routing + free-tier gate — all critical fixes applied
Fix 1 — Add `estimateRequestCost(request, model)` to PricingService (pricing.ts)
- Unified: estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd
- Replaces duplicated estimation in jobs.ts, sessions.ts, estimate.ts
Fix 2 — Sessions pre-gate: estimate → decide → execute → reconcile
- freeTierService.decide() runs on ESTIMATED cost BEFORE executeWork()
- Fixed double-margin: estimateRequestCost already includes infra+margin; convert directly
- absorbedSats capped at actual cost post-execution (Math.min)
Fix 3 — Correct isFree derivation for partial jobs in advanceJob() (jobs.ts)
- isFreeExecution = workAmountSats === 0 (not job.freeTier)
- Partial jobs run paid accounting: actual sats, refund, pool credit, deferred grant
Fix 4 — Defer ALL grant recording to post-work execution (jobs.ts)
- Fully-free path: removed recordGrant from eval time; now called in runWorkInBackground
- For isFree jobs: absorbCap = actual post-execution cost (calculateActualChargeSats)
- For partial jobs: grant deferred from invoice creation to after work completes
Fix 5 — Atomic, pool-bounded grant recording with row locking (free-tier.ts)
- SELECT ... FOR UPDATE locks pool row inside transaction
- actualAbsorbed = Math.min(absorbSats, poolBalance) — pool can never go negative
- Pool balance update is plain write (lock already held)
- Daily absorption: SQL CASE expression atomically handles new-day reset
- Audit log and identity counter both reflect actualAbsorbed, not requested amount
- If pool is empty at grant time, transaction returns without writing
Fix 6 — Remove fire-and-forget (void) from all recordGrant() call sites
- All three call sites now use await; grant failures propagate correctly
- Removed unused createHash import from free-tier.ts
2026-03-19 16:59:11 +00:00
|
|
|
// All three mutations happen atomically inside a single transaction with row-locking:
|
|
|
|
|
// 1. Lock + read pool row (FOR UPDATE), compute actual deductible amount
|
|
|
|
|
// 2. Pool deduction capped to available balance
|
|
|
|
|
// 3. Daily absorption increment via SQL CASE (reset on new day), capped to actualAbsorbed
|
|
|
|
|
// 4. Audit log insert with accurate absorbed amount
|
|
|
|
|
// Using FOR UPDATE ensures concurrent grants cannot over-debit the pool.
|
|
|
|
|
let actualAbsorbed = 0;
|
2026-03-19 16:47:51 +00:00
|
|
|
let actualNewPoolBalance = 0;
|
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
|
|
|
|
|
|
|
|
await db.transaction(async (tx) => {
|
2026-03-19 16:47:51 +00:00
|
|
|
// Ensure the pool row exists first (idempotent seed)
|
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
|
|
|
await tx
|
|
|
|
|
.insert(timmyConfig)
|
2026-03-19 16:47:51 +00:00
|
|
|
.values({ key: POOL_KEY, value: String(POOL_INITIAL_SATS) })
|
|
|
|
|
.onConflictDoNothing();
|
|
|
|
|
|
Task #27: Complete cost-routing + free-tier gate — all critical fixes applied
Fix 1 — Add `estimateRequestCost(request, model)` to PricingService (pricing.ts)
- Unified: estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd
- Replaces duplicated estimation in jobs.ts, sessions.ts, estimate.ts
Fix 2 — Sessions pre-gate: estimate → decide → execute → reconcile
- freeTierService.decide() runs on ESTIMATED cost BEFORE executeWork()
- Fixed double-margin: estimateRequestCost already includes infra+margin; convert directly
- absorbedSats capped at actual cost post-execution (Math.min)
Fix 3 — Correct isFree derivation for partial jobs in advanceJob() (jobs.ts)
- isFreeExecution = workAmountSats === 0 (not job.freeTier)
- Partial jobs run paid accounting: actual sats, refund, pool credit, deferred grant
Fix 4 — Defer ALL grant recording to post-work execution (jobs.ts)
- Fully-free path: removed recordGrant from eval time; now called in runWorkInBackground
- For isFree jobs: absorbCap = actual post-execution cost (calculateActualChargeSats)
- For partial jobs: grant deferred from invoice creation to after work completes
Fix 5 — Atomic, pool-bounded grant recording with row locking (free-tier.ts)
- SELECT ... FOR UPDATE locks pool row inside transaction
- actualAbsorbed = Math.min(absorbSats, poolBalance) — pool can never go negative
- Pool balance update is plain write (lock already held)
- Daily absorption: SQL CASE expression atomically handles new-day reset
- Audit log and identity counter both reflect actualAbsorbed, not requested amount
- If pool is empty at grant time, transaction returns without writing
Fix 6 — Remove fire-and-forget (void) from all recordGrant() call sites
- All three call sites now use await; grant failures propagate correctly
- Removed unused createHash import from free-tier.ts
2026-03-19 16:59:11 +00:00
|
|
|
// Lock the pool row for this transaction; read current balance.
|
|
|
|
|
const locked = await tx.execute(
|
|
|
|
|
sql`SELECT value::int AS balance FROM timmy_config WHERE key = ${POOL_KEY} FOR UPDATE`,
|
|
|
|
|
);
|
|
|
|
|
const poolBalance = (locked.rows[0] as { balance: number } | undefined)?.balance ?? 0;
|
2026-03-19 16:47:51 +00:00
|
|
|
|
Task #27: Complete cost-routing + free-tier gate — all critical fixes applied
Fix 1 — Add `estimateRequestCost(request, model)` to PricingService (pricing.ts)
- Unified: estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd
- Replaces duplicated estimation in jobs.ts, sessions.ts, estimate.ts
Fix 2 — Sessions pre-gate: estimate → decide → execute → reconcile
- freeTierService.decide() runs on ESTIMATED cost BEFORE executeWork()
- Fixed double-margin: estimateRequestCost already includes infra+margin; convert directly
- absorbedSats capped at actual cost post-execution (Math.min)
Fix 3 — Correct isFree derivation for partial jobs in advanceJob() (jobs.ts)
- isFreeExecution = workAmountSats === 0 (not job.freeTier)
- Partial jobs run paid accounting: actual sats, refund, pool credit, deferred grant
Fix 4 — Defer ALL grant recording to post-work execution (jobs.ts)
- Fully-free path: removed recordGrant from eval time; now called in runWorkInBackground
- For isFree jobs: absorbCap = actual post-execution cost (calculateActualChargeSats)
- For partial jobs: grant deferred from invoice creation to after work completes
Fix 5 — Atomic, pool-bounded grant recording with row locking (free-tier.ts)
- SELECT ... FOR UPDATE locks pool row inside transaction
- actualAbsorbed = Math.min(absorbSats, poolBalance) — pool can never go negative
- Pool balance update is plain write (lock already held)
- Daily absorption: SQL CASE expression atomically handles new-day reset
- Audit log and identity counter both reflect actualAbsorbed, not requested amount
- If pool is empty at grant time, transaction returns without writing
Fix 6 — Remove fire-and-forget (void) from all recordGrant() call sites
- All three call sites now use await; grant failures propagate correctly
- Removed unused createHash import from free-tier.ts
2026-03-19 16:59:11 +00:00
|
|
|
// Cap actual absorption to what the pool can cover.
|
|
|
|
|
actualAbsorbed = Math.min(absorbSats, poolBalance);
|
|
|
|
|
if (actualAbsorbed <= 0) {
|
|
|
|
|
// Pool is empty; nothing to absorb — roll back silently.
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
actualNewPoolBalance = poolBalance - actualAbsorbed;
|
|
|
|
|
|
|
|
|
|
await tx
|
|
|
|
|
.update(timmyConfig)
|
|
|
|
|
.set({ value: String(actualNewPoolBalance), updatedAt: now })
|
|
|
|
|
.where(eq(timmyConfig.key, POOL_KEY));
|
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
|
|
|
|
Task #27: Complete cost-routing + free-tier gate — all critical fixes applied
Fix 1 — Add `estimateRequestCost(request, model)` to PricingService (pricing.ts)
- Unified: estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd
- Replaces duplicated estimation in jobs.ts, sessions.ts, estimate.ts
Fix 2 — Sessions pre-gate: estimate → decide → execute → reconcile
- freeTierService.decide() runs on ESTIMATED cost BEFORE executeWork()
- Fixed double-margin: estimateRequestCost already includes infra+margin; convert directly
- absorbedSats capped at actual cost post-execution (Math.min)
Fix 3 — Correct isFree derivation for partial jobs in advanceJob() (jobs.ts)
- isFreeExecution = workAmountSats === 0 (not job.freeTier)
- Partial jobs run paid accounting: actual sats, refund, pool credit, deferred grant
Fix 4 — Defer ALL grant recording to post-work execution (jobs.ts)
- Fully-free path: removed recordGrant from eval time; now called in runWorkInBackground
- For isFree jobs: absorbCap = actual post-execution cost (calculateActualChargeSats)
- For partial jobs: grant deferred from invoice creation to after work completes
Fix 5 — Atomic, pool-bounded grant recording with row locking (free-tier.ts)
- SELECT ... FOR UPDATE locks pool row inside transaction
- actualAbsorbed = Math.min(absorbSats, poolBalance) — pool can never go negative
- Pool balance update is plain write (lock already held)
- Daily absorption: SQL CASE expression atomically handles new-day reset
- Audit log and identity counter both reflect actualAbsorbed, not requested amount
- If pool is empty at grant time, transaction returns without writing
Fix 6 — Remove fire-and-forget (void) from all recordGrant() call sites
- All three call sites now use await; grant failures propagate correctly
- Removed unused createHash import from free-tier.ts
2026-03-19 16:59:11 +00:00
|
|
|
// Atomically increment daily absorption by the actual absorbed amount.
|
Task #27: Complete cost-routing + free-tier gate — all critical fixes applied
Fix 1 — Add `estimateRequestCost(request, model)` to PricingService (pricing.ts)
- Unified: estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd
- Replaces duplicated estimation logic in jobs.ts, sessions.ts, estimate.ts
Fix 2 — Move partial free-tier `recordGrant()` from invoice creation to post-work
- Was called at invoice creation — economic DoS vulnerability
- Now deferred to runWorkInBackground via new `partialAbsorbSats` param
- Fully-free jobs still record grant at eval time (no payment involved)
Fix 3 — Sessions pre-gate: estimate → decide → execute → reconcile
- freeTierService.decide() now runs on ESTIMATED cost BEFORE executeWork()
- Fixed double-margin bug: estimateRequestCost returns cost already with infra+margin
(calculateWorkFeeUsd), convert directly to sats — no second calculateActualChargeUsd
- absorbedSats capped at actual cost post-execution to prevent over-absorption
Fix 4 — Correct isFree derivation for partial jobs in advanceJob() (jobs.ts)
- isFreeExecution = workAmountSats === 0 (not job.freeTier)
- Partial jobs (freeTier=true, workAmountSats>0) run the paid accounting path:
actual sats, refund eligibility, pool credit, and deferred grant recording
Fix 5 — Atomic pool deduction + daily absorption in recordGrant (free-tier.ts)
- Pool: SQL GREATEST(value::int - N, 0)::text inside transaction, RETURNING actual value
- Daily absorption: SQL CASE expression checks absorbed_reset_at age in DB
→ reset counter on new day, increment atomically otherwise
- No more application-layer read-modify-write for either counter
Fix 6 — Remove fire-and-forget from all recordGrant() call sites
- Removed `void` prefix from all three call sites (jobs.ts x2, sessions.ts x1)
- Grant persistence failures now propagate correctly instead of silently diverging
- Removed unused createHash import from free-tier.ts
2026-03-19 16:55:03 +00:00
|
|
|
// If absorbed_reset_at is older than 24 h, reset the counter (new day).
|
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
|
|
|
await tx
|
|
|
|
|
.update(nostrIdentities)
|
|
|
|
|
.set({
|
Task #27: Complete cost-routing + free-tier gate — all critical fixes applied
Fix 1 — Add `estimateRequestCost(request, model)` to PricingService (pricing.ts)
- Unified: estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd
- Replaces duplicated estimation logic in jobs.ts, sessions.ts, estimate.ts
Fix 2 — Move partial free-tier `recordGrant()` from invoice creation to post-work
- Was called at invoice creation — economic DoS vulnerability
- Now deferred to runWorkInBackground via new `partialAbsorbSats` param
- Fully-free jobs still record grant at eval time (no payment involved)
Fix 3 — Sessions pre-gate: estimate → decide → execute → reconcile
- freeTierService.decide() now runs on ESTIMATED cost BEFORE executeWork()
- Fixed double-margin bug: estimateRequestCost returns cost already with infra+margin
(calculateWorkFeeUsd), convert directly to sats — no second calculateActualChargeUsd
- absorbedSats capped at actual cost post-execution to prevent over-absorption
Fix 4 — Correct isFree derivation for partial jobs in advanceJob() (jobs.ts)
- isFreeExecution = workAmountSats === 0 (not job.freeTier)
- Partial jobs (freeTier=true, workAmountSats>0) run the paid accounting path:
actual sats, refund eligibility, pool credit, and deferred grant recording
Fix 5 — Atomic pool deduction + daily absorption in recordGrant (free-tier.ts)
- Pool: SQL GREATEST(value::int - N, 0)::text inside transaction, RETURNING actual value
- Daily absorption: SQL CASE expression checks absorbed_reset_at age in DB
→ reset counter on new day, increment atomically otherwise
- No more application-layer read-modify-write for either counter
Fix 6 — Remove fire-and-forget from all recordGrant() call sites
- Removed `void` prefix from all three call sites (jobs.ts x2, sessions.ts x1)
- Grant persistence failures now propagate correctly instead of silently diverging
- Removed unused createHash import from free-tier.ts
2026-03-19 16:55:03 +00:00
|
|
|
satsAbsorbedToday: sql`
|
|
|
|
|
CASE
|
|
|
|
|
WHEN EXTRACT(EPOCH FROM (${now}::timestamptz - absorbed_reset_at)) * 1000 >= ${DAY_MS}
|
Task #27: Complete cost-routing + free-tier gate — all critical fixes applied
Fix 1 — Add `estimateRequestCost(request, model)` to PricingService (pricing.ts)
- Unified: estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd
- Replaces duplicated estimation in jobs.ts, sessions.ts, estimate.ts
Fix 2 — Sessions pre-gate: estimate → decide → execute → reconcile
- freeTierService.decide() runs on ESTIMATED cost BEFORE executeWork()
- Fixed double-margin: estimateRequestCost already includes infra+margin; convert directly
- absorbedSats capped at actual cost post-execution (Math.min)
Fix 3 — Correct isFree derivation for partial jobs in advanceJob() (jobs.ts)
- isFreeExecution = workAmountSats === 0 (not job.freeTier)
- Partial jobs run paid accounting: actual sats, refund, pool credit, deferred grant
Fix 4 — Defer ALL grant recording to post-work execution (jobs.ts)
- Fully-free path: removed recordGrant from eval time; now called in runWorkInBackground
- For isFree jobs: absorbCap = actual post-execution cost (calculateActualChargeSats)
- For partial jobs: grant deferred from invoice creation to after work completes
Fix 5 — Atomic, pool-bounded grant recording with row locking (free-tier.ts)
- SELECT ... FOR UPDATE locks pool row inside transaction
- actualAbsorbed = Math.min(absorbSats, poolBalance) — pool can never go negative
- Pool balance update is plain write (lock already held)
- Daily absorption: SQL CASE expression atomically handles new-day reset
- Audit log and identity counter both reflect actualAbsorbed, not requested amount
- If pool is empty at grant time, transaction returns without writing
Fix 6 — Remove fire-and-forget (void) from all recordGrant() call sites
- All three call sites now use await; grant failures propagate correctly
- Removed unused createHash import from free-tier.ts
2026-03-19 16:59:11 +00:00
|
|
|
THEN ${actualAbsorbed}
|
|
|
|
|
ELSE sats_absorbed_today + ${actualAbsorbed}
|
Task #27: Complete cost-routing + free-tier gate — all critical fixes applied
Fix 1 — Add `estimateRequestCost(request, model)` to PricingService (pricing.ts)
- Unified: estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd
- Replaces duplicated estimation logic in jobs.ts, sessions.ts, estimate.ts
Fix 2 — Move partial free-tier `recordGrant()` from invoice creation to post-work
- Was called at invoice creation — economic DoS vulnerability
- Now deferred to runWorkInBackground via new `partialAbsorbSats` param
- Fully-free jobs still record grant at eval time (no payment involved)
Fix 3 — Sessions pre-gate: estimate → decide → execute → reconcile
- freeTierService.decide() now runs on ESTIMATED cost BEFORE executeWork()
- Fixed double-margin bug: estimateRequestCost returns cost already with infra+margin
(calculateWorkFeeUsd), convert directly to sats — no second calculateActualChargeUsd
- absorbedSats capped at actual cost post-execution to prevent over-absorption
Fix 4 — Correct isFree derivation for partial jobs in advanceJob() (jobs.ts)
- isFreeExecution = workAmountSats === 0 (not job.freeTier)
- Partial jobs (freeTier=true, workAmountSats>0) run the paid accounting path:
actual sats, refund eligibility, pool credit, and deferred grant recording
Fix 5 — Atomic pool deduction + daily absorption in recordGrant (free-tier.ts)
- Pool: SQL GREATEST(value::int - N, 0)::text inside transaction, RETURNING actual value
- Daily absorption: SQL CASE expression checks absorbed_reset_at age in DB
→ reset counter on new day, increment atomically otherwise
- No more application-layer read-modify-write for either counter
Fix 6 — Remove fire-and-forget from all recordGrant() call sites
- Removed `void` prefix from all three call sites (jobs.ts x2, sessions.ts x1)
- Grant persistence failures now propagate correctly instead of silently diverging
- Removed unused createHash import from free-tier.ts
2026-03-19 16:55:03 +00:00
|
|
|
END
|
|
|
|
|
`,
|
|
|
|
|
absorbedResetAt: sql`
|
|
|
|
|
CASE
|
|
|
|
|
WHEN EXTRACT(EPOCH FROM (${now}::timestamptz - absorbed_reset_at)) * 1000 >= ${DAY_MS}
|
|
|
|
|
THEN ${now}::timestamptz
|
|
|
|
|
ELSE absorbed_reset_at
|
|
|
|
|
END
|
|
|
|
|
`,
|
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
|
|
|
updatedAt: now,
|
|
|
|
|
})
|
|
|
|
|
.where(eq(nostrIdentities.pubkey, pubkey));
|
|
|
|
|
|
|
|
|
|
await tx.insert(freeTierGrants).values({
|
|
|
|
|
id: randomUUID(),
|
|
|
|
|
pubkey,
|
|
|
|
|
requestHash,
|
Task #27: Complete cost-routing + free-tier gate — all critical fixes applied
Fix 1 — Add `estimateRequestCost(request, model)` to PricingService (pricing.ts)
- Unified: estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd
- Replaces duplicated estimation in jobs.ts, sessions.ts, estimate.ts
Fix 2 — Sessions pre-gate: estimate → decide → execute → reconcile
- freeTierService.decide() runs on ESTIMATED cost BEFORE executeWork()
- Fixed double-margin: estimateRequestCost already includes infra+margin; convert directly
- absorbedSats capped at actual cost post-execution (Math.min)
Fix 3 — Correct isFree derivation for partial jobs in advanceJob() (jobs.ts)
- isFreeExecution = workAmountSats === 0 (not job.freeTier)
- Partial jobs run paid accounting: actual sats, refund, pool credit, deferred grant
Fix 4 — Defer ALL grant recording to post-work execution (jobs.ts)
- Fully-free path: removed recordGrant from eval time; now called in runWorkInBackground
- For isFree jobs: absorbCap = actual post-execution cost (calculateActualChargeSats)
- For partial jobs: grant deferred from invoice creation to after work completes
Fix 5 — Atomic, pool-bounded grant recording with row locking (free-tier.ts)
- SELECT ... FOR UPDATE locks pool row inside transaction
- actualAbsorbed = Math.min(absorbSats, poolBalance) — pool can never go negative
- Pool balance update is plain write (lock already held)
- Daily absorption: SQL CASE expression atomically handles new-day reset
- Audit log and identity counter both reflect actualAbsorbed, not requested amount
- If pool is empty at grant time, transaction returns without writing
Fix 6 — Remove fire-and-forget (void) from all recordGrant() call sites
- All three call sites now use await; grant failures propagate correctly
- Removed unused createHash import from free-tier.ts
2026-03-19 16:59:11 +00:00
|
|
|
satsAbsorbed: actualAbsorbed,
|
2026-03-19 16:47:51 +00:00
|
|
|
poolBalanceAfter: actualNewPoolBalance,
|
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
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
Task #27: Complete cost-routing + free-tier gate — all critical fixes applied
Fix 1 — Add `estimateRequestCost(request, model)` to PricingService (pricing.ts)
- Unified: estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd
- Replaces duplicated estimation in jobs.ts, sessions.ts, estimate.ts
Fix 2 — Sessions pre-gate: estimate → decide → execute → reconcile
- freeTierService.decide() runs on ESTIMATED cost BEFORE executeWork()
- Fixed double-margin: estimateRequestCost already includes infra+margin; convert directly
- absorbedSats capped at actual cost post-execution (Math.min)
Fix 3 — Correct isFree derivation for partial jobs in advanceJob() (jobs.ts)
- isFreeExecution = workAmountSats === 0 (not job.freeTier)
- Partial jobs run paid accounting: actual sats, refund, pool credit, deferred grant
Fix 4 — Defer ALL grant recording to post-work execution (jobs.ts)
- Fully-free path: removed recordGrant from eval time; now called in runWorkInBackground
- For isFree jobs: absorbCap = actual post-execution cost (calculateActualChargeSats)
- For partial jobs: grant deferred from invoice creation to after work completes
Fix 5 — Atomic, pool-bounded grant recording with row locking (free-tier.ts)
- SELECT ... FOR UPDATE locks pool row inside transaction
- actualAbsorbed = Math.min(absorbSats, poolBalance) — pool can never go negative
- Pool balance update is plain write (lock already held)
- Daily absorption: SQL CASE expression atomically handles new-day reset
- Audit log and identity counter both reflect actualAbsorbed, not requested amount
- If pool is empty at grant time, transaction returns without writing
Fix 6 — Remove fire-and-forget (void) from all recordGrant() call sites
- All three call sites now use await; grant failures propagate correctly
- Removed unused createHash import from free-tier.ts
2026-03-19 16:59:11 +00:00
|
|
|
if (actualAbsorbed > 0) {
|
|
|
|
|
logger.info("free-tier grant recorded", {
|
|
|
|
|
pubkey: pubkey.slice(0, 8),
|
|
|
|
|
requestedSats: absorbSats,
|
|
|
|
|
actualAbsorbed,
|
|
|
|
|
newPoolBalance: actualNewPoolBalance,
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
logger.warn("free-tier grant skipped: pool empty at grant time", {
|
|
|
|
|
pubkey: pubkey.slice(0, 8),
|
|
|
|
|
requestedSats: absorbSats,
|
|
|
|
|
});
|
|
|
|
|
}
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Pool status snapshot — for health/metrics endpoints.
|
|
|
|
|
*/
|
|
|
|
|
async poolStatus(): Promise<{
|
|
|
|
|
balanceSats: number;
|
|
|
|
|
budgets: Record<TrustTier, number>;
|
|
|
|
|
}> {
|
|
|
|
|
const balanceSats = await this.getPoolBalance();
|
|
|
|
|
return {
|
|
|
|
|
balanceSats,
|
|
|
|
|
budgets: {
|
|
|
|
|
new: DAILY_BUDGET_NEW,
|
|
|
|
|
established: DAILY_BUDGET_ESTABLISHED,
|
|
|
|
|
trusted: DAILY_BUDGET_TRUSTED,
|
|
|
|
|
elite: DAILY_BUDGET_ELITE,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const freeTierService = new FreeTierService();
|