Commit Graph

3 Commits

Author SHA1 Message Date
alexpaynex
1754ab1dbc 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
alexpaynex
3a617669f0 Task #27: Apply 3 required fixes for cost-routing + free-tier gate
1. Add `estimateRequestCost(request, model)` to PricingService in pricing.ts
   - Unified method combining estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd
   - Replaces duplicated token estimation logic at call sites in jobs.ts, sessions.ts, estimate.ts

2. Move partial free-tier `recordGrant()` from invoice creation to post-work in runWorkInBackground
   - Previously called at invoice creation for partial path — economic DoS vulnerability
   - Now deferred to after work completes via new `partialAbsorbSats` param in runWorkInBackground
   - Fully-free jobs still record grant at eval time (no payment involved)

3. Sessions pre-gate: estimate → decide → execute → reconcile (with double-margin bug fix)
   - Free-tier `decide()` now runs on ESTIMATED cost BEFORE `executeWork()` is called
   - Fixed: estimateRequestCost already includes infra+margin via calculateWorkFeeUsd,
     so convert estimatedCostUsd directly to sats — no second calculateActualChargeUsd call
   - absorbedSats capped at actual cost post-execution (Math.min) to prevent over-absorption

4. Atomic pool deduction in recordGrant (free-tier.ts)
   - Replaced non-atomic read-then-write pattern with SQL GREATEST expression inside transaction
   - UPDATE timmyConfig SET value = GREATEST(value::int - absorbSats, 0)::text RETURNING value
   - Audit log (freeTierGrants) receives actual post-deduct value from DB; no oversubscription
   - Removed unused createHash import from free-tier.ts
2026-03-19 16:47:51 +00:00
alexpaynex
4c3a0e867a 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