Task #27: Apply all required fixes for cost-routing + free-tier gate

Fix 1 — Add `estimateRequestCost(request, model)` to PricingService (pricing.ts)
  - Unified method: 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 for partial path — 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 with infra+margin already
    applied (calculateWorkFeeUsd), so convert directly to sats — no second
    calculateActualChargeUsd wrapping
  - absorbedSats capped at actual cost post-execution to prevent over-absorption

Fix 4 — Correct isFree flag for partial jobs in advanceJob() (jobs.ts)
  - job.freeTier=true for BOTH fully-free and partial jobs
  - isFreeExecution now derived from workAmountSats===0 (user paid nothing)
  - Partial jobs (freeTier=true, workAmountSats>0) run the paid accounting path:
    actualAmountSats, refundState, pool credit, and deferred grant recording

Fix 5 — Atomic pool deduction in recordGrant (free-tier.ts)
  - Replaced non-atomic read-then-write with SQL GREATEST expression inside tx
  - UPDATE timmyConfig SET value = GREATEST(value::int - N, 0)::text RETURNING value
  - Audit log receives actual DB-returned value; no oversubscription under concurrency
  - Removed unused createHash import
This commit is contained in:
alexpaynex
2026-03-19 16:50:48 +00:00
parent 3a617669f0
commit d899503f5d

View File

@@ -333,18 +333,20 @@ async function advanceJob(job: Job): Promise<Job | null> {
streamRegistry.register(job.id);
// Fire AI work in background — poll returns immediately with "executing"
// isFree is true ONLY for fully-free jobs (user paid nothing, workAmountSats=0).
// Partial jobs (freeTier=true but workAmountSats>0) must use the paid accounting path.
const isFreeExecution = (job.workAmountSats ?? 0) === 0;
setImmediate(() => {
void runWorkInBackground(
job.id,
job.request,
job.workAmountSats ?? 0,
job.btcPriceUsd,
job.freeTier ?? false,
isFreeExecution,
job.nostrPubkey ?? null,
// For partial free-tier jobs (freeTier=true but user paid chargeSats),
// pass absorbedSats so the grant is recorded post-payment in runWorkInBackground.
// For fully-free jobs (isFree=true, workAmountSats=0), grant was already recorded at eval time.
(job.freeTier && (job.workAmountSats ?? 0) > 0) ? (job.absorbedSats ?? 0) : 0,
// For partial jobs: pass absorbedSats so grant is recorded post-payment.
// For fully-free jobs: grant was already recorded at eval time, pass 0.
(!isFreeExecution && job.freeTier) ? (job.absorbedSats ?? 0) : 0,
);
});