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