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
This commit is contained in:
alexpaynex
2026-03-19 16:59:11 +00:00
parent 1754ab1dbc
commit 373477ba7f
2 changed files with 54 additions and 34 deletions

View File

@@ -80,16 +80,13 @@ async function runEvalInBackground(
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");
await freeTierService.recordGrant(nostrPubkey, reqHash, breakdown.amountSats);
}
// Grant is recorded AFTER work completes (in runWorkInBackground) so we use
// actual cost rather than estimated sats for the audit log.
streamRegistry.register(jobId);
setImmediate(() => {
void runWorkInBackground(
jobId, request, 0, breakdown.btcPriceUsd, true, nostrPubkey,
breakdown.amountSats, // pass estimated as cap; actual cost may be lower
);
});
return;
@@ -237,11 +234,17 @@ async function runWorkInBackground(
void freeTierService.credit(workAmountSats);
}
// Record partial free-tier grant now that work is confirmed complete.
// Deferred from invoice creation to prevent economic DoS (pool reservation without payment).
if (!isFree && partialAbsorbSats > 0 && nostrPubkey) {
// Record free-tier grant now that work is confirmed complete.
// For fully-free jobs: cap at actual cost (actualAmountSats was 0 for isFree, use actualCostUsd→sats).
// For partial jobs: partialAbsorbSats is the estimated absorbed portion (cap enforced in recordGrant).
// Both are deferred until post-execution so the audit log reflects real cost, not estimates.
if (partialAbsorbSats > 0 && nostrPubkey) {
const lockedBtcPrice = btcPriceUsd ?? 100_000;
const absorbCap = isFree
? pricingService.calculateActualChargeSats(actualCostUsd, lockedBtcPrice)
: partialAbsorbSats;
const reqHash = createHash("sha256").update(request).digest("hex");
await freeTierService.recordGrant(nostrPubkey, reqHash, partialAbsorbSats);
await freeTierService.recordGrant(nostrPubkey, reqHash, absorbCap);
}
// Trust scoring — fire and forget