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
This commit is contained in:
alexpaynex
2026-03-19 16:47:51 +00:00
parent 512089ca08
commit 3a617669f0
2 changed files with 25 additions and 15 deletions

View File

@@ -1,7 +1,6 @@
import { createHash } from "crypto";
import { randomUUID } from "crypto";
import { db, timmyConfig, freeTierGrants, nostrIdentities, type NostrIdentity, type TrustTier } from "@workspace/db";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { makeLogger } from "./logger.js";
import { trustService } from "./trust.js";
@@ -189,17 +188,29 @@ export class FreeTierService {
: true;
const newAbsorbed = isNewDay ? absorbSats : todayAbsorbed + absorbSats;
const poolBalance = await this.getPoolBalance();
const newPoolBalance = Math.max(0, poolBalance - absorbSats);
// Atomically deduct from pool inside the transaction using a SQL expression.
// GREATEST(..., 0) prevents the pool from going negative even under concurrency.
// We read back the actual new value via RETURNING so the audit log is accurate.
let actualNewPoolBalance = 0;
await db.transaction(async (tx) => {
// Ensure the pool row exists first (idempotent seed)
await tx
.insert(timmyConfig)
.values({ key: POOL_KEY, value: String(newPoolBalance), updatedAt: now })
.onConflictDoUpdate({
target: timmyConfig.key,
set: { value: String(newPoolBalance), updatedAt: now },
});
.values({ key: POOL_KEY, value: String(POOL_INITIAL_SATS) })
.onConflictDoNothing();
// Atomically decrement using GREATEST to avoid going negative
const updated = await tx
.update(timmyConfig)
.set({
value: sql`GREATEST(value::int - ${absorbSats}, 0)::text`,
updatedAt: now,
})
.where(eq(timmyConfig.key, POOL_KEY))
.returning({ value: timmyConfig.value });
actualNewPoolBalance = updated[0] ? parseInt(updated[0].value, 10) : 0;
await tx
.update(nostrIdentities)
@@ -215,14 +226,14 @@ export class FreeTierService {
pubkey,
requestHash,
satsAbsorbed: absorbSats,
poolBalanceAfter: newPoolBalance,
poolBalanceAfter: actualNewPoolBalance,
});
});
logger.info("free-tier grant recorded", {
pubkey: pubkey.slice(0, 8),
absorbSats,
newPoolBalance,
newPoolBalance: actualNewPoolBalance,
newAbsorbed,
});
}

View File

@@ -333,11 +333,10 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => {
// preventing a scenario where the pool is drained after we've already spent tokens.
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.
const { estimatedCostUsd } = pricingService.estimateRequestCost(requestText, agentService.workModel);
const estimatedSats = usdToSats(
pricingService.calculateActualChargeUsd(estimatedCostUsd),
btcPriceUsd,
);
const estimatedSats = usdToSats(estimatedCostUsd, btcPriceUsd);
ftDecision = await freeTierService.decide(session.nostrPubkey, estimatedSats);
}