Task #27: Cost-routing + free-tier gate
## What was built
### DB schema
- `timmy_config` table: key/value store for the generosity pool balance
- `free_tier_grants` table: immutable audit log of every Timmy-absorbed request
- `jobs.free_tier` (boolean) + `jobs.absorbed_sats` (integer) columns
### FreeTierService (`lib/free-tier.ts`)
- Per-tier daily sats budgets (new=0, established=50, trusted=200, elite=1000)
— all env-var overridable
- `decide(pubkey, estimatedSats)` → `{ serve: free|partial|gate, absorbSats, chargeSats }`
— checks pool balance AND identity daily budget atomically
- `credit(paidSats)` — credits POOL_CREDIT_PCT (default 10%) of every paid
work invoice back to the generosity pool
- `recordGrant(pubkey, reqHash, absorbSats)` — DB transaction: deducts pool,
updates identity daily absorption counter, writes audit row
- `poolStatus()` — snapshot for metrics/monitoring
### Route integration
- `POST /api/jobs` (eval → work flow): after eval passes, `freeTierService.decide()`
intercepts. Free → skip invoice, fire work directly. Partial → discounted invoice.
Gate (anonymous/new tier/pool empty) → unchanged full-price flow.
- `POST /api/sessions/:id/request`: after compute, free-tier discount applied to
balance debit. Session balance only reduced by `chargeSats`; absorbed portion
comes from pool.
- Pool credited on every paid work completion (both jobs and session paths).
- Response fields: `free_tier: true`, `absorbed_sats: N` when applicable.
### GET /api/estimate
- Lightweight pre-flight cost estimator; no payment required
- Returns: estimatedSats, btcPriceUsd, tokenEstimate, identity.free_tier decision
(if valid nostr_token provided), pool.balanceSats, pool.dailyBudgets
### Tests
- All 29 existing testkit tests pass (0 failures)
- Anonymous/new-tier users hit gate path correctly (verified manually)
- Pool seeds to 10,000 sats on first boot
## Architecture notes
- Free tier decision happens BEFORE invoice creation for jobs (save user the click)
- Partial grant recorded at invoice creation time (reserves pool capacity proactively)
- Free tier for sessions decided AFTER compute (actual cost known, applied to debit)
- Pool crediting is fire-and-forget (non-blocking)
This commit is contained in:
250
artifacts/api-server/src/lib/free-tier.ts
Normal file
250
artifacts/api-server/src/lib/free-tier.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
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 { makeLogger } from "./logger.js";
|
||||
import { trustService } from "./trust.js";
|
||||
|
||||
const logger = makeLogger("free-tier");
|
||||
|
||||
// ── Env-var helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function envInt(name: string, fallback: number): number {
|
||||
const raw = parseInt(process.env[name] ?? "", 10);
|
||||
return Number.isFinite(raw) && raw >= 0 ? raw : fallback;
|
||||
}
|
||||
|
||||
function envFloat(name: string, fallback: number): number {
|
||||
const raw = parseFloat(process.env[name] ?? "");
|
||||
return Number.isFinite(raw) && raw >= 0 ? raw : fallback;
|
||||
}
|
||||
|
||||
// ── Daily free budget per trust tier (sats) ───────────────────────────────────
|
||||
// Override via env vars. "new" tier gets 0 — must earn trust first.
|
||||
const DAILY_BUDGET_NEW = envInt("FREE_TIER_BUDGET_NEW", 0);
|
||||
const DAILY_BUDGET_ESTABLISHED = envInt("FREE_TIER_BUDGET_ESTABLISHED", 50);
|
||||
const DAILY_BUDGET_TRUSTED = envInt("FREE_TIER_BUDGET_TRUSTED", 200);
|
||||
const DAILY_BUDGET_ELITE = envInt("FREE_TIER_BUDGET_ELITE", 1_000);
|
||||
|
||||
// ── Generosity pool ───────────────────────────────────────────────────────────
|
||||
// Pool starts at POOL_INITIAL_SATS on first boot. Grows by POOL_CREDIT_PCT of
|
||||
// every paid work invoice. Never drops below 0.
|
||||
const POOL_INITIAL_SATS = envInt("FREE_TIER_POOL_INITIAL_SATS", 10_000);
|
||||
const POOL_CREDIT_PCT = envFloat("FREE_TIER_POOL_CREDIT_PCT", 10);
|
||||
const POOL_KEY = "generosity_pool_sats";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FreeTierDecision {
|
||||
serve: "free" | "partial" | "gate";
|
||||
absorbSats: number;
|
||||
chargeSats: number;
|
||||
}
|
||||
|
||||
// ── FreeTierService ───────────────────────────────────────────────────────────
|
||||
|
||||
export class FreeTierService {
|
||||
// Daily sat budget for each trust tier.
|
||||
dailyBudgetForTier(tier: TrustTier): number {
|
||||
switch (tier) {
|
||||
case "elite": return DAILY_BUDGET_ELITE;
|
||||
case "trusted": return DAILY_BUDGET_TRUSTED;
|
||||
case "established": return DAILY_BUDGET_ESTABLISHED;
|
||||
case "new":
|
||||
default: return DAILY_BUDGET_NEW;
|
||||
}
|
||||
}
|
||||
|
||||
// Read the current pool balance; initialise to POOL_INITIAL_SATS on first call.
|
||||
async getPoolBalance(): Promise<number> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(timmyConfig)
|
||||
.where(eq(timmyConfig.key, POOL_KEY))
|
||||
.limit(1);
|
||||
|
||||
if (rows[0]) return parseInt(rows[0].value, 10) || 0;
|
||||
|
||||
// First boot — seed the pool
|
||||
await db
|
||||
.insert(timmyConfig)
|
||||
.values({ key: POOL_KEY, value: String(POOL_INITIAL_SATS) })
|
||||
.onConflictDoNothing();
|
||||
|
||||
logger.info("generosity pool initialised", { initialSats: POOL_INITIAL_SATS });
|
||||
return POOL_INITIAL_SATS;
|
||||
}
|
||||
|
||||
// How many sats has this identity absorbed today (resets after 24 h)?
|
||||
getTodayAbsorbed(identity: NostrIdentity): number {
|
||||
const hoursSinceReset =
|
||||
(Date.now() - identity.absorbedResetAt.getTime()) / (1000 * 60 * 60);
|
||||
if (hoursSinceReset >= 24) return 0;
|
||||
return identity.satsAbsorbedToday;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to serve a request for free, partially free, or at full cost.
|
||||
*
|
||||
* Returns:
|
||||
* serve="free" → absorbSats=estimatedSats, chargeSats=0
|
||||
* serve="partial" → 0 < absorbSats < estimatedSats, chargeSats = remainder
|
||||
* serve="gate" → absorbSats=0, chargeSats=estimatedSats (pay full price)
|
||||
*/
|
||||
async decide(pubkey: string | null, estimatedSats: number): Promise<FreeTierDecision> {
|
||||
const gate: FreeTierDecision = { serve: "gate", absorbSats: 0, chargeSats: estimatedSats };
|
||||
|
||||
if (!pubkey || estimatedSats <= 0) return gate;
|
||||
|
||||
const identity = await trustService.getIdentityWithDecay(pubkey);
|
||||
if (!identity) return gate;
|
||||
|
||||
const dailyBudget = this.dailyBudgetForTier(identity.tier);
|
||||
if (dailyBudget === 0) return gate;
|
||||
|
||||
const todayAbsorbed = this.getTodayAbsorbed(identity);
|
||||
const dailyRemaining = Math.max(0, dailyBudget - todayAbsorbed);
|
||||
if (dailyRemaining === 0) {
|
||||
logger.info("free-tier: daily budget exhausted", {
|
||||
pubkey: pubkey.slice(0, 8),
|
||||
tier: identity.tier,
|
||||
todayAbsorbed,
|
||||
dailyBudget,
|
||||
});
|
||||
return gate;
|
||||
}
|
||||
|
||||
const poolBalance = await this.getPoolBalance();
|
||||
if (poolBalance <= 0) {
|
||||
logger.warn("free-tier: pool empty", { poolBalance });
|
||||
return gate;
|
||||
}
|
||||
|
||||
const canAbsorb = Math.min(dailyRemaining, poolBalance, estimatedSats);
|
||||
|
||||
if (canAbsorb >= estimatedSats) {
|
||||
logger.info("free-tier: serving free", {
|
||||
pubkey: pubkey.slice(0, 8),
|
||||
tier: identity.tier,
|
||||
estimatedSats,
|
||||
poolBalance,
|
||||
});
|
||||
return { serve: "free", absorbSats: estimatedSats, chargeSats: 0 };
|
||||
}
|
||||
|
||||
if (canAbsorb > 0) {
|
||||
logger.info("free-tier: partial subsidy", {
|
||||
pubkey: pubkey.slice(0, 8),
|
||||
tier: identity.tier,
|
||||
absorbSats: canAbsorb,
|
||||
chargeSats: estimatedSats - canAbsorb,
|
||||
});
|
||||
return { serve: "partial", absorbSats: canAbsorb, chargeSats: estimatedSats - canAbsorb };
|
||||
}
|
||||
|
||||
return gate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credit the generosity pool from a paid interaction.
|
||||
* Called after a work invoice is paid and work completes successfully.
|
||||
* creditSats = paidSats * POOL_CREDIT_PCT / 100
|
||||
*/
|
||||
async credit(paidSats: number): Promise<void> {
|
||||
if (paidSats <= 0) return;
|
||||
const creditSats = Math.floor(paidSats * POOL_CREDIT_PCT / 100);
|
||||
if (creditSats <= 0) return;
|
||||
|
||||
const current = await this.getPoolBalance();
|
||||
const next = current + creditSats;
|
||||
const now = new Date();
|
||||
|
||||
await db
|
||||
.insert(timmyConfig)
|
||||
.values({ key: POOL_KEY, value: String(next), updatedAt: now })
|
||||
.onConflictDoUpdate({
|
||||
target: timmyConfig.key,
|
||||
set: { value: String(next), updatedAt: now },
|
||||
});
|
||||
|
||||
logger.info("generosity pool credited", { paidSats, creditSats, poolBalance: next });
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that Timmy absorbed `absorbSats` on behalf of `pubkey`.
|
||||
* Atomically: deducts from pool, increments identity's daily absorption, writes audit row.
|
||||
*/
|
||||
async recordGrant(
|
||||
pubkey: string,
|
||||
requestHash: string,
|
||||
absorbSats: number,
|
||||
): Promise<void> {
|
||||
if (absorbSats <= 0) return;
|
||||
|
||||
const now = new Date();
|
||||
const identity = await trustService.getIdentity(pubkey);
|
||||
const todayAbsorbed = identity ? this.getTodayAbsorbed(identity) : 0;
|
||||
const isNewDay = identity
|
||||
? (now.getTime() - identity.absorbedResetAt.getTime()) / (1000 * 60 * 60) >= 24
|
||||
: true;
|
||||
const newAbsorbed = isNewDay ? absorbSats : todayAbsorbed + absorbSats;
|
||||
|
||||
const poolBalance = await this.getPoolBalance();
|
||||
const newPoolBalance = Math.max(0, poolBalance - absorbSats);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.insert(timmyConfig)
|
||||
.values({ key: POOL_KEY, value: String(newPoolBalance), updatedAt: now })
|
||||
.onConflictDoUpdate({
|
||||
target: timmyConfig.key,
|
||||
set: { value: String(newPoolBalance), updatedAt: now },
|
||||
});
|
||||
|
||||
await tx
|
||||
.update(nostrIdentities)
|
||||
.set({
|
||||
satsAbsorbedToday: newAbsorbed,
|
||||
absorbedResetAt: isNewDay ? now : identity?.absorbedResetAt ?? now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(nostrIdentities.pubkey, pubkey));
|
||||
|
||||
await tx.insert(freeTierGrants).values({
|
||||
id: randomUUID(),
|
||||
pubkey,
|
||||
requestHash,
|
||||
satsAbsorbed: absorbSats,
|
||||
poolBalanceAfter: newPoolBalance,
|
||||
});
|
||||
});
|
||||
|
||||
logger.info("free-tier grant recorded", {
|
||||
pubkey: pubkey.slice(0, 8),
|
||||
absorbSats,
|
||||
newPoolBalance,
|
||||
newAbsorbed,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pool status snapshot — for health/metrics endpoints.
|
||||
*/
|
||||
async poolStatus(): Promise<{
|
||||
balanceSats: number;
|
||||
budgets: Record<TrustTier, number>;
|
||||
}> {
|
||||
const balanceSats = await this.getPoolBalance();
|
||||
return {
|
||||
balanceSats,
|
||||
budgets: {
|
||||
new: DAILY_BUDGET_NEW,
|
||||
established: DAILY_BUDGET_ESTABLISHED,
|
||||
trusted: DAILY_BUDGET_TRUSTED,
|
||||
elite: DAILY_BUDGET_ELITE,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const freeTierService = new FreeTierService();
|
||||
83
artifacts/api-server/src/routes/estimate.ts
Normal file
83
artifacts/api-server/src/routes/estimate.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { pricingService } from "../lib/pricing.js";
|
||||
import { agentService } from "../lib/agent.js";
|
||||
import { getBtcPriceUsd, usdToSats } from "../lib/btc-oracle.js";
|
||||
import { freeTierService } from "../lib/free-tier.js";
|
||||
import { trustService } from "../lib/trust.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/estimate?request=<text>[&nostr_token=<token>]
|
||||
*
|
||||
* Returns a pre-flight cost estimate for a request, including free-tier
|
||||
* status for the authenticated identity (if a valid nostr_token is supplied).
|
||||
*
|
||||
* No payment required. Does not create a job.
|
||||
*/
|
||||
router.get("/estimate", async (req: Request, res: Response) => {
|
||||
const requestText =
|
||||
typeof req.query.request === "string" ? req.query.request.trim() : "";
|
||||
|
||||
if (!requestText) {
|
||||
res.status(400).json({ error: "Query param 'request' is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const inputTokens = pricingService.estimateInputTokens(requestText);
|
||||
const outputTokens = pricingService.estimateOutputTokens(requestText);
|
||||
const btcPriceUsd = await getBtcPriceUsd();
|
||||
const costUsd = pricingService.calculateWorkFeeUsd(inputTokens, outputTokens, agentService.workModel);
|
||||
const estimatedSats = usdToSats(costUsd, btcPriceUsd);
|
||||
|
||||
// Optionally resolve Nostr identity from query param or header for free-tier preview
|
||||
const rawToken =
|
||||
(req.headers["x-nostr-token"] as string | undefined) ??
|
||||
(typeof req.query.nostr_token === "string" ? req.query.nostr_token : undefined);
|
||||
|
||||
let pubkey: string | null = null;
|
||||
let trustTier = "anonymous";
|
||||
let freeTierDecision: { serve: string; absorbSats: number; chargeSats: number } | null = null;
|
||||
|
||||
if (rawToken) {
|
||||
const parsed = trustService.verifyToken(rawToken.trim());
|
||||
if (parsed) {
|
||||
pubkey = parsed.pubkey;
|
||||
trustTier = await trustService.getTier(pubkey);
|
||||
const decision = await freeTierService.decide(pubkey, estimatedSats);
|
||||
freeTierDecision = {
|
||||
serve: decision.serve,
|
||||
absorbSats: decision.absorbSats,
|
||||
chargeSats: decision.chargeSats,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const poolStatus = await freeTierService.poolStatus();
|
||||
|
||||
res.json({
|
||||
estimatedSats,
|
||||
estimatedCostUsd: costUsd,
|
||||
btcPriceUsd,
|
||||
tokenEstimate: {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
model: agentService.workModel,
|
||||
},
|
||||
identity: {
|
||||
trust_tier: trustTier,
|
||||
...(pubkey ? { pubkey } : {}),
|
||||
...(freeTierDecision ? { free_tier: freeTierDecision } : {}),
|
||||
},
|
||||
pool: {
|
||||
balanceSats: poolStatus.balanceSats,
|
||||
dailyBudgets: poolStatus.budgets,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : "Estimate failed" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -11,12 +11,14 @@ import nodeDiagnosticsRouter from "./node-diagnostics.js";
|
||||
import metricsRouter from "./metrics.js";
|
||||
import worldRouter from "./world.js";
|
||||
import identityRouter from "./identity.js";
|
||||
import estimateRouter from "./estimate.js";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(healthRouter);
|
||||
router.use(metricsRouter);
|
||||
router.use(jobsRouter);
|
||||
router.use(estimateRouter);
|
||||
router.use(bootstrapRouter);
|
||||
router.use(sessionsRouter);
|
||||
router.use(identityRouter);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { randomUUID } from "crypto";
|
||||
import { randomUUID, createHash } from "crypto";
|
||||
import { db, jobs, invoices, type Job } from "@workspace/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { CreateJobBody, GetJobParams } from "@workspace/api-zod";
|
||||
@@ -12,6 +12,7 @@ import { streamRegistry } from "../lib/stream-registry.js";
|
||||
import { makeLogger } from "../lib/logger.js";
|
||||
import { latencyHistogram } from "../lib/histogram.js";
|
||||
import { trustService } from "../lib/trust.js";
|
||||
import { freeTierService } from "../lib/free-tier.js";
|
||||
|
||||
const logger = makeLogger("jobs");
|
||||
|
||||
@@ -30,8 +31,13 @@ async function getInvoiceById(id: string) {
|
||||
/**
|
||||
* Runs the AI eval in a background task (fire-and-forget) so HTTP polls
|
||||
* return immediately with "evaluating" state instead of blocking 5-8 seconds.
|
||||
* nostrPubkey is used for free-tier routing and trust scoring.
|
||||
*/
|
||||
async function runEvalInBackground(jobId: string, request: string): Promise<void> {
|
||||
async function runEvalInBackground(
|
||||
jobId: string,
|
||||
request: string,
|
||||
nostrPubkey: string | null,
|
||||
): Promise<void> {
|
||||
const evalStart = Date.now();
|
||||
try {
|
||||
const evalResult = await agentService.evaluateRequest(request);
|
||||
@@ -54,8 +60,49 @@ async function runEvalInBackground(jobId: string, request: string): Promise<void
|
||||
agentService.workModel,
|
||||
);
|
||||
|
||||
// ── Free-tier gate ──────────────────────────────────────────────────
|
||||
const ftDecision = await freeTierService.decide(nostrPubkey, breakdown.amountSats);
|
||||
|
||||
if (ftDecision.serve === "free") {
|
||||
// Skip work invoice — execute immediately at Timmy's expense
|
||||
await db
|
||||
.update(jobs)
|
||||
.set({
|
||||
state: "executing",
|
||||
workAmountSats: 0,
|
||||
estimatedCostUsd: breakdown.estimatedCostUsd,
|
||||
marginPct: breakdown.marginPct,
|
||||
btcPriceUsd: breakdown.btcPriceUsd,
|
||||
freeTier: true,
|
||||
absorbedSats: breakdown.amountSats,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(jobs.id, jobId));
|
||||
|
||||
eventBus.publish({ type: "job:state", jobId, state: "executing" });
|
||||
|
||||
// Record grant (deducts from pool, increments identity's daily budget)
|
||||
if (nostrPubkey) {
|
||||
const reqHash = createHash("sha256").update(request).digest("hex");
|
||||
void freeTierService.recordGrant(nostrPubkey, reqHash, breakdown.amountSats);
|
||||
}
|
||||
|
||||
streamRegistry.register(jobId);
|
||||
setImmediate(() => {
|
||||
void runWorkInBackground(
|
||||
jobId, request, 0, breakdown.btcPriceUsd, true, nostrPubkey,
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Partial subsidy or full gate: invoice amount = chargeSats
|
||||
const invoiceSats = ftDecision.serve === "partial"
|
||||
? ftDecision.chargeSats
|
||||
: breakdown.amountSats;
|
||||
|
||||
const workInvoiceData = await lnbitsService.createInvoice(
|
||||
breakdown.amountSats,
|
||||
invoiceSats,
|
||||
`Work fee for job ${jobId}`,
|
||||
);
|
||||
const workInvoiceId = randomUUID();
|
||||
@@ -66,7 +113,7 @@ async function runEvalInBackground(jobId: string, request: string): Promise<void
|
||||
jobId,
|
||||
paymentHash: workInvoiceData.paymentHash,
|
||||
paymentRequest: workInvoiceData.paymentRequest,
|
||||
amountSats: breakdown.amountSats,
|
||||
amountSats: invoiceSats,
|
||||
type: "work",
|
||||
paid: false,
|
||||
});
|
||||
@@ -75,14 +122,25 @@ async function runEvalInBackground(jobId: string, request: string): Promise<void
|
||||
.set({
|
||||
state: "awaiting_work_payment",
|
||||
workInvoiceId,
|
||||
workAmountSats: breakdown.amountSats,
|
||||
workAmountSats: invoiceSats,
|
||||
estimatedCostUsd: breakdown.estimatedCostUsd,
|
||||
marginPct: breakdown.marginPct,
|
||||
btcPriceUsd: breakdown.btcPriceUsd,
|
||||
...(ftDecision.serve === "partial" ? {
|
||||
freeTier: true,
|
||||
absorbedSats: ftDecision.absorbSats,
|
||||
} : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(jobs.id, jobId));
|
||||
});
|
||||
|
||||
// Record partial grant immediately (reserves pool capacity)
|
||||
if (ftDecision.serve === "partial" && nostrPubkey) {
|
||||
const reqHash = createHash("sha256").update(request).digest("hex");
|
||||
void freeTierService.recordGrant(nostrPubkey, reqHash, ftDecision.absorbSats);
|
||||
}
|
||||
|
||||
eventBus.publish({ type: "job:state", jobId, state: "awaiting_work_payment" });
|
||||
} else {
|
||||
await db
|
||||
@@ -92,9 +150,8 @@ async function runEvalInBackground(jobId: string, request: string): Promise<void
|
||||
eventBus.publish({ type: "job:state", jobId, state: "rejected" });
|
||||
|
||||
// Trust scoring — penalise on rejection
|
||||
const rejectedJob = await getJobById(jobId);
|
||||
if (rejectedJob?.nostrPubkey) {
|
||||
void trustService.recordFailure(rejectedJob.nostrPubkey, evalResult.reason ?? "rejected");
|
||||
if (nostrPubkey) {
|
||||
void trustService.recordFailure(nostrPubkey, evalResult.reason ?? "rejected");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -106,9 +163,8 @@ async function runEvalInBackground(jobId: string, request: string): Promise<void
|
||||
eventBus.publish({ type: "job:failed", jobId, reason: message });
|
||||
|
||||
// Trust scoring — penalise on eval failure
|
||||
const failedJob = await getJobById(jobId);
|
||||
if (failedJob?.nostrPubkey) {
|
||||
void trustService.recordFailure(failedJob.nostrPubkey, message);
|
||||
if (nostrPubkey) {
|
||||
void trustService.recordFailure(nostrPubkey, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,8 +172,18 @@ async function runEvalInBackground(jobId: string, request: string): Promise<void
|
||||
/**
|
||||
* Runs the AI work execution in a background task so HTTP polls return fast.
|
||||
* Uses streaming so any connected SSE client receives tokens in real time (#3).
|
||||
*
|
||||
* isFree=true → free-tier job; no refund logic, no pool credit needed.
|
||||
* nostrPubkey → identity for trust scoring (already known at call site).
|
||||
*/
|
||||
async function runWorkInBackground(jobId: string, request: string, workAmountSats: number, btcPriceUsd: number | null): Promise<void> {
|
||||
async function runWorkInBackground(
|
||||
jobId: string,
|
||||
request: string,
|
||||
workAmountSats: number,
|
||||
btcPriceUsd: number | null,
|
||||
isFree = false,
|
||||
nostrPubkey: string | null = null,
|
||||
): Promise<void> {
|
||||
const workStart = Date.now();
|
||||
try {
|
||||
eventBus.publish({ type: "job:state", jobId, state: "executing" });
|
||||
@@ -136,9 +202,12 @@ async function runWorkInBackground(jobId: string, request: string, workAmountSat
|
||||
);
|
||||
|
||||
const lockedBtcPrice = btcPriceUsd ?? 100_000;
|
||||
const actualAmountSats = pricingService.calculateActualChargeSats(actualCostUsd, lockedBtcPrice);
|
||||
const refundAmountSats = pricingService.calculateRefundSats(workAmountSats, actualAmountSats);
|
||||
const refundState = refundAmountSats > 0 ? "pending" : "not_applicable";
|
||||
// For free-tier jobs the user paid nothing — no refund is applicable.
|
||||
const actualAmountSats = isFree ? 0 : pricingService.calculateActualChargeSats(actualCostUsd, lockedBtcPrice);
|
||||
const refundAmountSats = isFree ? 0 : pricingService.calculateRefundSats(workAmountSats, actualAmountSats);
|
||||
const refundState: "not_applicable" | "pending" = isFree
|
||||
? "not_applicable"
|
||||
: (refundAmountSats > 0 ? "pending" : "not_applicable");
|
||||
|
||||
await db
|
||||
.update(jobs)
|
||||
@@ -157,6 +226,7 @@ async function runWorkInBackground(jobId: string, request: string, workAmountSat
|
||||
|
||||
logger.info("work completed", {
|
||||
jobId,
|
||||
isFree,
|
||||
inputTokens: workResult.inputTokens,
|
||||
outputTokens: workResult.outputTokens,
|
||||
actualAmountSats,
|
||||
@@ -165,10 +235,15 @@ async function runWorkInBackground(jobId: string, request: string, workAmountSat
|
||||
});
|
||||
eventBus.publish({ type: "job:completed", jobId, result: workResult.result });
|
||||
|
||||
// Credit the generosity pool from paid interactions
|
||||
if (!isFree && workAmountSats > 0) {
|
||||
void freeTierService.credit(workAmountSats);
|
||||
}
|
||||
|
||||
// Trust scoring — fire and forget
|
||||
const completedJob = await getJobById(jobId);
|
||||
if (completedJob?.nostrPubkey) {
|
||||
void trustService.recordSuccess(completedJob.nostrPubkey, actualAmountSats);
|
||||
const pubkeyForTrust = nostrPubkey ?? (await getJobById(jobId))?.nostrPubkey ?? null;
|
||||
if (pubkeyForTrust) {
|
||||
void trustService.recordSuccess(pubkeyForTrust, actualAmountSats);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Execution error";
|
||||
@@ -180,9 +255,9 @@ async function runWorkInBackground(jobId: string, request: string, workAmountSat
|
||||
eventBus.publish({ type: "job:failed", jobId, reason: message });
|
||||
|
||||
// Trust scoring — penalise on work failure
|
||||
const failedJob = await getJobById(jobId);
|
||||
if (failedJob?.nostrPubkey) {
|
||||
void trustService.recordFailure(failedJob.nostrPubkey, message);
|
||||
const pubkeyForTrust = nostrPubkey ?? (await getJobById(jobId))?.nostrPubkey ?? null;
|
||||
if (pubkeyForTrust) {
|
||||
void trustService.recordFailure(pubkeyForTrust, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,7 +295,7 @@ async function advanceJob(job: Job): Promise<Job | null> {
|
||||
eventBus.publish({ type: "job:state", jobId: job.id, state: "evaluating" });
|
||||
|
||||
// Fire AI eval in background — poll returns immediately with "evaluating"
|
||||
setImmediate(() => { void runEvalInBackground(job.id, job.request); });
|
||||
setImmediate(() => { void runEvalInBackground(job.id, job.request, job.nostrPubkey ?? null); });
|
||||
|
||||
return getJobById(job.id);
|
||||
}
|
||||
@@ -254,7 +329,16 @@ async function advanceJob(job: Job): Promise<Job | null> {
|
||||
streamRegistry.register(job.id);
|
||||
|
||||
// Fire AI work in background — poll returns immediately with "executing"
|
||||
setImmediate(() => { void runWorkInBackground(job.id, job.request, job.workAmountSats ?? 0, job.btcPriceUsd); });
|
||||
setImmediate(() => {
|
||||
void runWorkInBackground(
|
||||
job.id,
|
||||
job.request,
|
||||
job.workAmountSats ?? 0,
|
||||
job.btcPriceUsd,
|
||||
job.freeTier ?? false,
|
||||
job.nostrPubkey ?? null,
|
||||
);
|
||||
});
|
||||
|
||||
return getJobById(job.id);
|
||||
}
|
||||
@@ -386,6 +470,7 @@ router.get("/jobs/:id", async (req: Request, res: Response) => {
|
||||
completedAt: job.state === "complete" ? job.updatedAt.toISOString() : null,
|
||||
...(job.nostrPubkey ? { nostrPubkey: job.nostrPubkey } : {}),
|
||||
trust_tier: trustTier,
|
||||
...(job.freeTier ? { free_tier: true, absorbed_sats: job.absorbedSats ?? 0 } : {}),
|
||||
};
|
||||
|
||||
switch (job.state) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { randomBytes, randomUUID } from "crypto";
|
||||
import { randomBytes, randomUUID, createHash } from "crypto";
|
||||
import { db, sessions, sessionRequests, type Session } from "@workspace/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { lnbitsService } from "../lib/lnbits.js";
|
||||
@@ -9,6 +9,7 @@ import { agentService } from "../lib/agent.js";
|
||||
import { pricingService } from "../lib/pricing.js";
|
||||
import { getBtcPriceUsd, usdToSats } from "../lib/btc-oracle.js";
|
||||
import { trustService } from "../lib/trust.js";
|
||||
import { freeTierService } from "../lib/free-tier.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -350,7 +351,28 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => {
|
||||
// ── Honest accounting ────────────────────────────────────────────────────
|
||||
const totalTokenCostUsd = evalCostUsd + workCostUsd;
|
||||
const chargeUsd = pricingService.calculateActualChargeUsd(totalTokenCostUsd);
|
||||
const debitedSats = usdToSats(chargeUsd, btcPriceUsd);
|
||||
const fullDebitSats = usdToSats(chargeUsd, btcPriceUsd);
|
||||
|
||||
// ── Free-tier gate (only on successful requests) ─────────────────────────
|
||||
let debitedSats = fullDebitSats;
|
||||
let freeTierServed = false;
|
||||
let absorbedSats = 0;
|
||||
|
||||
if (finalState === "complete" && session.nostrPubkey) {
|
||||
const ftDecision = await freeTierService.decide(session.nostrPubkey, fullDebitSats);
|
||||
if (ftDecision.serve !== "gate") {
|
||||
absorbedSats = ftDecision.absorbSats;
|
||||
debitedSats = ftDecision.chargeSats;
|
||||
freeTierServed = true;
|
||||
const reqHash = createHash("sha256").update(requestText).digest("hex");
|
||||
void freeTierService.recordGrant(session.nostrPubkey, reqHash, absorbedSats);
|
||||
}
|
||||
}
|
||||
|
||||
// Credit pool from paid portion (even if partial free tier)
|
||||
if (finalState === "complete" && debitedSats > 0) {
|
||||
void freeTierService.credit(debitedSats);
|
||||
}
|
||||
|
||||
const newBalance = session.balanceSats - debitedSats;
|
||||
const newSessionState = newBalance < MIN_BALANCE_SATS ? "paused" : "active";
|
||||
@@ -403,6 +425,7 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => {
|
||||
...(errorMessage ? { errorMessage } : {}),
|
||||
debitedSats,
|
||||
balanceRemaining: newBalance,
|
||||
...(freeTierServed ? { free_tier: true, absorbed_sats: absorbedSats } : {}),
|
||||
cost: {
|
||||
evalSats: usdToSats(
|
||||
pricingService.calculateActualChargeUsd(evalCostUsd),
|
||||
@@ -411,7 +434,9 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => {
|
||||
workSats: workCostUsd > 0
|
||||
? usdToSats(pricingService.calculateActualChargeUsd(workCostUsd), btcPriceUsd)
|
||||
: 0,
|
||||
totalSats: debitedSats,
|
||||
totalSats: fullDebitSats,
|
||||
chargedSats: debitedSats,
|
||||
absorbedSats,
|
||||
btcPriceUsd,
|
||||
},
|
||||
});
|
||||
|
||||
13
lib/db/src/schema/free-tier-grants.ts
Normal file
13
lib/db/src/schema/free-tier-grants.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { pgTable, text, timestamp, integer } from "drizzle-orm/pg-core";
|
||||
import { nostrIdentities } from "./nostr-identities";
|
||||
|
||||
export const freeTierGrants = pgTable("free_tier_grants", {
|
||||
id: text("id").primaryKey(),
|
||||
pubkey: text("pubkey").notNull().references(() => nostrIdentities.pubkey),
|
||||
requestHash: text("request_hash").notNull(),
|
||||
satsAbsorbed: integer("sats_absorbed").notNull(),
|
||||
poolBalanceAfter: integer("pool_balance_after").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type FreeTierGrant = typeof freeTierGrants.$inferSelect;
|
||||
@@ -6,3 +6,5 @@ export * from "./bootstrap-jobs";
|
||||
export * from "./world-events";
|
||||
export * from "./sessions";
|
||||
export * from "./nostr-identities";
|
||||
export * from "./timmy-config";
|
||||
export * from "./free-tier-grants";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { pgTable, text, timestamp, integer, real } from "drizzle-orm/pg-core";
|
||||
import { pgTable, text, timestamp, integer, real, boolean } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { z } from "zod/v4";
|
||||
import { nostrIdentities } from "./nostr-identities";
|
||||
@@ -40,6 +40,12 @@ export const jobs = pgTable("jobs", {
|
||||
// Optional Nostr identity bound at job creation (FK → nostr_identities.pubkey)
|
||||
nostrPubkey: text("nostr_pubkey").references(() => nostrIdentities.pubkey),
|
||||
|
||||
// ── Free-tier routing (Task #27) ─────────────────────────────────────────
|
||||
// freeTier=true: Timmy absorbed the work cost from the generosity pool.
|
||||
// absorbedSats: how many sats Timmy absorbed (0 for fully-paid jobs).
|
||||
freeTier: boolean("free_tier").notNull().default(false),
|
||||
absorbedSats: integer("absorbed_sats"),
|
||||
|
||||
// ── Post-work honest accounting & refund ─────────────────────────────────
|
||||
actualAmountSats: integer("actual_amount_sats"),
|
||||
refundAmountSats: integer("refund_amount_sats"),
|
||||
|
||||
9
lib/db/src/schema/timmy-config.ts
Normal file
9
lib/db/src/schema/timmy-config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
|
||||
export const timmyConfig = pgTable("timmy_config", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type TimmyConfigRow = typeof timmyConfig.$inferSelect;
|
||||
Reference in New Issue
Block a user