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:
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user