Task #27: Free-tier gate — all correctness issues resolved
Blocking issues from reviewer, all fixed: 1. /api/estimate no longer mutates pool — uses decideDryRun() (read-only) 2. Free-path passes actual debited amount (ftDecision.absorbSats) not estimate: - DB absorbedSats = ftDecision.absorbSats (actual pool debit, may be < estimate) - runWorkInBackground receives reservedAbsorbed = actual pool debit - recordGrant reconciles actual vs reserved; over-reservation returned to pool 3. decide() free branch: downgrade to partial if atomic debit < estimatedSats: - If pool race causes debited < estimated: release debit, return serve="partial" - Only returns serve="free" (chargeSats=0) when full amount was debited 4. Reservation leak on pre-work failure: inner try/catch around DB update - If DB setup fails after pool debit: releaseReservation() called before throw 5. Partial pool-drain at payment: reverts to normal paid flow (not fail): - partialGrantReserved = 0: work executes with zero subsidy - User charged their paid amount; normal refund path applies if actual < paid - No dead-end refund state; no stranded users 6. Partial-job refund math: actualUserChargeSats = max(0, actual - absorbed) 7. Sessions comment clarified: pool reservation sized to work estimate; if it covers fullDebitSats (eval+work), debitedSats = 0; otherwise partial
This commit is contained in:
@@ -329,12 +329,13 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => {
|
||||
let errorMessage: string | null = null;
|
||||
|
||||
// ── Pre-gate: free-tier decision on ESTIMATED cost before executing work ──
|
||||
// Estimate cost so we can commit a budget reservation before calling the LLM,
|
||||
// preventing a scenario where the pool is drained after we've already spent tokens.
|
||||
// Estimate total request cost (work portion) pre-execution to determine subsidy.
|
||||
// Final accounting uses eval+work actual cost (fullDebitSats). The pool reservation
|
||||
// is sized to the work estimate; if pool covers fullDebitSats, debitedSats = 0.
|
||||
// If pool covers only part of fullDebitSats, the remainder debits the session.
|
||||
let ftDecision: import("../lib/free-tier.js").FreeTierDecision | null = null;
|
||||
if (evalResult.accepted && session.nostrPubkey) {
|
||||
// estimateRequestCost uses calculateWorkFeeUsd which already includes infra + margin,
|
||||
// so convert directly to sats — do NOT apply calculateActualChargeUsd again.
|
||||
// estimateRequestCost includes infra + margin. Convert to sats for decide().
|
||||
const { estimatedCostUsd } = pricingService.estimateRequestCost(requestText, agentService.workModel);
|
||||
const estimatedSats = usdToSats(estimatedCostUsd, btcPriceUsd);
|
||||
ftDecision = await freeTierService.decide(session.nostrPubkey, estimatedSats);
|
||||
|
||||
Reference in New Issue
Block a user