Add session mode for pre-funded request processing

Implement session-based API endpoints for creating, managing, and interacting with pre-funded sessions, including deposit and top-up invoice generation, macaroon authentication, and per-request debiting of compute costs.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 418bf6f8-212b-4bb0-a7a5-8231a061da4e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 2dc3847e-7186-4a22-9c7e-16cd31bca8d9
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/9f85e954-647c-46a5-90a7-396e495a805a/418bf6f8-212b-4bb0-a7a5-8231a061da4e/sPDHkg8
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
alexpaynex
2026-03-18 20:00:24 +00:00
parent dfc9ecdc7b
commit ab2cc06a79
29 changed files with 1075 additions and 978 deletions

View File

@@ -3,6 +3,8 @@ import { anthropic } from "@workspace/integrations-anthropic-ai";
export interface EvalResult {
accepted: boolean;
reason: string;
inputTokens: number;
outputTokens: number;
}
export interface WorkResult {
@@ -49,7 +51,12 @@ Respond ONLY with valid JSON: {"accepted": true, "reason": "..."} or {"accepted"
throw new Error(`Failed to parse eval JSON: ${block.text}`);
}
return { accepted: Boolean(parsed.accepted), reason: parsed.reason ?? "" };
return {
accepted: Boolean(parsed.accepted),
reason: parsed.reason ?? "",
inputTokens: message.usage.input_tokens,
outputTokens: message.usage.output_tokens,
};
}
async executeWork(requestText: string): Promise<WorkResult> {

View File

@@ -2,6 +2,7 @@ import { Router, type IRouter } from "express";
import healthRouter from "./health.js";
import jobsRouter from "./jobs.js";
import bootstrapRouter from "./bootstrap.js";
import sessionsRouter from "./sessions.js";
import demoRouter from "./demo.js";
import devRouter from "./dev.js";
import testkitRouter from "./testkit.js";
@@ -12,6 +13,7 @@ const router: IRouter = Router();
router.use(healthRouter);
router.use(jobsRouter);
router.use(bootstrapRouter);
router.use(sessionsRouter);
router.use(demoRouter);
router.use(testkitRouter);
router.use(uiRouter);

View File

@@ -0,0 +1,438 @@
import { Router, type Request, type Response } from "express";
import { randomBytes, randomUUID } from "crypto";
import { db, sessions, sessionRequests, type Session } from "@workspace/db";
import { eq, and } from "drizzle-orm";
import { lnbitsService } from "../lib/lnbits.js";
import { agentService } from "../lib/agent.js";
import { pricingService } from "../lib/pricing.js";
import { getBtcPriceUsd, usdToSats } from "../lib/btc-oracle.js";
const router = Router();
// ── Env-var config ─────────────────────────────────────────────────────────────
function envInt(name: string, fallback: number): number {
const raw = parseInt(process.env[name] ?? "", 10);
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
}
const MIN_DEPOSIT_SATS = envInt("SESSION_MIN_DEPOSIT_SATS", 100);
const MAX_DEPOSIT_SATS = envInt("SESSION_MAX_DEPOSIT_SATS", 10_000);
const MIN_BALANCE_SATS = envInt("SESSION_MIN_BALANCE_SATS", 50);
const EXPIRY_HOURS = envInt("SESSION_EXPIRY_HOURS", 24);
const EXPIRY_MS = EXPIRY_HOURS * 60 * 60 * 1000;
// ── Helpers ────────────────────────────────────────────────────────────────────
async function getSessionById(id: string): Promise<Session | null> {
const rows = await db.select().from(sessions).where(eq(sessions.id, id)).limit(1);
return rows[0] ?? null;
}
function checkExpired(session: Session): boolean {
return session.expiresAt !== null && new Date() > session.expiresAt;
}
function extractMacaroon(req: Request): string | null {
const auth = req.headers.authorization ?? "";
if (auth.startsWith("Bearer ")) return auth.slice(7).trim();
return null;
}
function sessionView(session: Session, includeInvoice = false) {
const base = {
sessionId: session.id,
state: session.state,
balanceSats: session.balanceSats,
expiresAt: session.expiresAt?.toISOString() ?? null,
minimumBalanceSats: MIN_BALANCE_SATS,
...(session.macaroon && (session.state === "active" || session.state === "paused")
? { macaroon: session.macaroon }
: {}),
};
if (includeInvoice && session.state === "awaiting_payment") {
return {
...base,
invoice: {
paymentRequest: session.depositPaymentRequest,
amountSats: session.depositAmountSats,
...(lnbitsService.stubMode ? { paymentHash: session.depositPaymentHash } : {}),
},
};
}
if (session.topupPaymentHash && !session.topupPaid) {
return {
...base,
pendingTopup: {
paymentRequest: session.topupPaymentRequest,
amountSats: session.topupAmountSats,
...(lnbitsService.stubMode ? { paymentHash: session.topupPaymentHash } : {}),
},
};
}
return base;
}
// ── Auto-advance: awaiting_payment → active ────────────────────────────────────
async function advanceSessionPayment(session: Session): Promise<Session> {
if (session.state !== "awaiting_payment" || session.depositPaid) return session;
const paid = await lnbitsService.checkInvoicePaid(session.depositPaymentHash);
if (!paid) return session;
const macaroon = randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + EXPIRY_MS);
const updated = await db
.update(sessions)
.set({
state: "active",
depositPaid: true,
balanceSats: session.depositAmountSats,
macaroon,
expiresAt,
updatedAt: new Date(),
})
.where(and(eq(sessions.id, session.id), eq(sessions.state, "awaiting_payment")))
.returning();
return updated[0] ?? session;
}
// ── Auto-advance: pending topup paid → credit balance ─────────────────────────
async function advanceTopup(session: Session): Promise<Session> {
if (!session.topupPaymentHash || session.topupPaid) return session;
const paid = await lnbitsService.checkInvoicePaid(session.topupPaymentHash);
if (!paid) return session;
const newBalance = session.balanceSats + (session.topupAmountSats ?? 0);
const newState =
session.state === "paused" && newBalance >= MIN_BALANCE_SATS ? "active" : session.state;
const expiresAt = new Date(Date.now() + EXPIRY_MS);
const updated = await db
.update(sessions)
.set({
state: newState,
topupPaid: true,
balanceSats: newBalance,
expiresAt,
updatedAt: new Date(),
})
.where(eq(sessions.id, session.id))
.returning();
return updated[0] ?? session;
}
// ── POST /sessions ─────────────────────────────────────────────────────────────
router.post("/sessions", async (req: Request, res: Response) => {
const rawAmount = req.body?.amount_sats;
const amountSats = parseInt(String(rawAmount ?? ""), 10);
if (!Number.isFinite(amountSats) || amountSats < MIN_DEPOSIT_SATS || amountSats > MAX_DEPOSIT_SATS) {
res.status(400).json({
error: `amount_sats must be an integer between ${MIN_DEPOSIT_SATS} and ${MAX_DEPOSIT_SATS}`,
});
return;
}
try {
const sessionId = randomUUID();
const invoice = await lnbitsService.createInvoice(amountSats, `Session deposit ${sessionId}`);
await db.insert(sessions).values({
id: sessionId,
state: "awaiting_payment",
balanceSats: 0,
depositAmountSats: amountSats,
depositPaymentHash: invoice.paymentHash,
depositPaymentRequest: invoice.paymentRequest,
depositPaid: false,
expiresAt: new Date(Date.now() + EXPIRY_MS),
});
res.status(201).json({
sessionId,
state: "awaiting_payment",
invoice: {
paymentRequest: invoice.paymentRequest,
amountSats,
...(lnbitsService.stubMode ? { paymentHash: invoice.paymentHash } : {}),
},
});
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : "Failed to create session" });
}
});
// ── GET /sessions/:id ─────────────────────────────────────────────────────────
router.get("/sessions/:id", async (req: Request, res: Response) => {
const id = req.params.id as string;
try {
let session = await getSessionById(id);
if (!session) { res.status(404).json({ error: "Session not found" }); return; }
// Mark expired sessions
if (checkExpired(session) && session.state !== "expired") {
await db
.update(sessions)
.set({ state: "expired", updatedAt: new Date() })
.where(eq(sessions.id, id));
session = (await getSessionById(id))!;
}
// Auto-advance deposit payment
if (session.state === "awaiting_payment") {
session = await advanceSessionPayment(session);
}
// Auto-advance topup payment
if (session.topupPaymentHash && !session.topupPaid) {
session = await advanceTopup(session);
}
res.json(sessionView(session, true));
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : "Failed to fetch session" });
}
});
// ── POST /sessions/:id/request ────────────────────────────────────────────────
router.post("/sessions/:id/request", async (req: Request, res: Response) => {
const id = req.params.id as string;
const macaroon = extractMacaroon(req);
const requestText = typeof req.body?.request === "string" ? req.body.request.trim() : "";
if (!requestText) {
res.status(400).json({ error: "Body must include 'request' string" });
return;
}
try {
let session = await getSessionById(id);
if (!session) { res.status(404).json({ error: "Session not found" }); return; }
// Auth
if (!macaroon || macaroon !== session.macaroon) {
res.status(401).json({ error: "Invalid or missing macaroon. Include 'Authorization: Bearer <macaroon>' header." });
return;
}
// State checks
if (checkExpired(session) || session.state === "expired") {
res.status(410).json({ error: "Session has expired" });
return;
}
if (session.state === "paused") {
res.status(402).json({
error: "Insufficient balance",
balance: session.balanceSats,
minimumRequired: MIN_BALANCE_SATS,
});
return;
}
if (session.state !== "active") {
res.status(409).json({ error: `Session is in state '${session.state}'` });
return;
}
if (session.balanceSats < MIN_BALANCE_SATS) {
// Mark as paused before returning
await db
.update(sessions)
.set({ state: "paused", updatedAt: new Date() })
.where(eq(sessions.id, id));
res.status(402).json({
error: "Insufficient balance",
balance: session.balanceSats,
minimumRequired: MIN_BALANCE_SATS,
});
return;
}
// ── Run the request ───────────────────────────────────────────────────────
const requestId = randomUUID();
const btcPriceUsd = await getBtcPriceUsd();
// Eval phase
const evalResult = await agentService.evaluateRequest(requestText);
const evalCostUsd = pricingService.calculateActualCostUsd(
evalResult.inputTokens,
evalResult.outputTokens,
agentService.evalModel,
);
let workInputTokens = 0;
let workOutputTokens = 0;
let workCostUsd = 0;
let result: string | null = null;
let finalState: "complete" | "rejected" | "failed" = "rejected";
let reason: string | null = null;
let errorMessage: string | null = null;
if (evalResult.accepted) {
try {
const workResult = await agentService.executeWork(requestText);
workInputTokens = workResult.inputTokens;
workOutputTokens = workResult.outputTokens;
workCostUsd = pricingService.calculateActualCostUsd(
workResult.inputTokens,
workResult.outputTokens,
agentService.workModel,
);
result = workResult.result;
finalState = "complete";
} catch (err) {
errorMessage = err instanceof Error ? err.message : "Execution error";
finalState = "failed";
}
} else {
reason = evalResult.reason;
}
// ── Honest accounting ────────────────────────────────────────────────────
const totalTokenCostUsd = evalCostUsd + workCostUsd;
const chargeUsd = pricingService.calculateActualChargeUsd(totalTokenCostUsd);
const debitedSats = usdToSats(chargeUsd, btcPriceUsd);
const newBalance = session.balanceSats - debitedSats;
const newSessionState = newBalance < MIN_BALANCE_SATS ? "paused" : "active";
const expiresAt = new Date(Date.now() + EXPIRY_MS);
// Persist session request + update session balance atomically
await db.transaction(async (tx) => {
await tx.insert(sessionRequests).values({
id: requestId,
sessionId: id,
request: requestText,
state: finalState,
result,
reason,
errorMessage,
evalInputTokens: evalResult.inputTokens,
evalOutputTokens: evalResult.outputTokens,
workInputTokens: workInputTokens || null,
workOutputTokens: workOutputTokens || null,
debitedSats,
balanceAfterSats: newBalance,
btcPriceUsd,
});
await tx
.update(sessions)
.set({
balanceSats: newBalance,
state: newSessionState,
expiresAt,
updatedAt: new Date(),
})
.where(eq(sessions.id, id));
});
res.json({
requestId,
state: finalState,
...(result ? { result } : {}),
...(reason ? { reason } : {}),
...(errorMessage ? { errorMessage } : {}),
debitedSats,
balanceRemaining: newBalance,
cost: {
evalSats: usdToSats(
pricingService.calculateActualChargeUsd(evalCostUsd),
btcPriceUsd,
),
workSats: workCostUsd > 0
? usdToSats(pricingService.calculateActualChargeUsd(workCostUsd), btcPriceUsd)
: 0,
totalSats: debitedSats,
btcPriceUsd,
},
});
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : "Request failed" });
}
});
// ── POST /sessions/:id/topup ──────────────────────────────────────────────────
router.post("/sessions/:id/topup", async (req: Request, res: Response) => {
const id = req.params.id as string;
const macaroon = extractMacaroon(req);
const rawAmount = req.body?.amount_sats;
const amountSats = parseInt(String(rawAmount ?? ""), 10);
if (!Number.isFinite(amountSats) || amountSats < MIN_DEPOSIT_SATS || amountSats > MAX_DEPOSIT_SATS) {
res.status(400).json({
error: `amount_sats must be an integer between ${MIN_DEPOSIT_SATS} and ${MAX_DEPOSIT_SATS}`,
});
return;
}
try {
const session = await getSessionById(id);
if (!session) { res.status(404).json({ error: "Session not found" }); return; }
if (!macaroon || macaroon !== session.macaroon) {
res.status(401).json({ error: "Invalid or missing macaroon" });
return;
}
if (session.state !== "active" && session.state !== "paused") {
res.status(409).json({ error: `Cannot top up a session in state '${session.state}'` });
return;
}
if (session.topupPaymentHash && !session.topupPaid) {
res.status(409).json({
error: "A topup invoice is already pending. Pay it first or poll GET /sessions/:id.",
pendingTopup: {
paymentRequest: session.topupPaymentRequest,
amountSats: session.topupAmountSats,
...(lnbitsService.stubMode ? { paymentHash: session.topupPaymentHash } : {}),
},
});
return;
}
const invoice = await lnbitsService.createInvoice(amountSats, `Session topup ${id}`);
await db
.update(sessions)
.set({
topupAmountSats: amountSats,
topupPaymentHash: invoice.paymentHash,
topupPaymentRequest: invoice.paymentRequest,
topupPaid: false,
updatedAt: new Date(),
})
.where(eq(sessions.id, id));
res.json({
sessionId: id,
topup: {
paymentRequest: invoice.paymentRequest,
amountSats,
...(lnbitsService.stubMode ? { paymentHash: invoice.paymentHash } : {}),
},
});
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : "Topup failed" });
}
});
export default router;

View File

@@ -300,6 +300,146 @@ else
fi
fi
# ---------------------------------------------------------------------------
# Test 11 — Session: create session
# ---------------------------------------------------------------------------
sep "Test 11 — Session: create session (awaiting_payment)"
T11_RES=$(curl -s -w "\\n%{http_code}" -X POST "$BASE/api/sessions" \\
-H "Content-Type: application/json" \\
-d '{"amount_sats": 200}')
T11_BODY=$(echo "$T11_RES" | head -n-1)
T11_CODE=$(echo "$T11_RES" | tail -n1)
SESSION_ID=$(echo "$T11_BODY" | jq -r '.sessionId' 2>/dev/null || echo "")
T11_STATE=$(echo "$T11_BODY" | jq -r '.state' 2>/dev/null || echo "")
T11_AMT=$(echo "$T11_BODY" | jq -r '.invoice.amountSats' 2>/dev/null || echo "")
DEPOSIT_HASH=$(echo "$T11_BODY" | jq -r '.invoice.paymentHash' 2>/dev/null || echo "")
if [[ "$T11_CODE" == "201" && -n "$SESSION_ID" && "$T11_STATE" == "awaiting_payment" && "$T11_AMT" == "200" ]]; then
note PASS "HTTP 201, sessionId=$SESSION_ID, state=awaiting_payment, amount=200"
PASS=$((PASS+1))
else
note FAIL "code=$T11_CODE body=$T11_BODY"
FAIL=$((FAIL+1))
fi
# ---------------------------------------------------------------------------
# Test 12 — Session: poll before payment (stub hash present)
# ---------------------------------------------------------------------------
sep "Test 12 — Session: poll before payment"
T12_RES=$(curl -s -w "\\n%{http_code}" "$BASE/api/sessions/$SESSION_ID")
T12_BODY=$(echo "$T12_RES" | head -n-1)
T12_CODE=$(echo "$T12_RES" | tail -n1)
T12_STATE=$(echo "$T12_BODY" | jq -r '.state' 2>/dev/null || echo "")
if [[ -z "$DEPOSIT_HASH" || "$DEPOSIT_HASH" == "null" ]]; then
DEPOSIT_HASH=$(echo "$T12_BODY" | jq -r '.invoice.paymentHash' 2>/dev/null || echo "")
fi
if [[ "$T12_CODE" == "200" && "$T12_STATE" == "awaiting_payment" ]]; then
note PASS "state=awaiting_payment before payment"
PASS=$((PASS+1))
else
note FAIL "code=$T12_CODE body=$T12_BODY"
FAIL=$((FAIL+1))
fi
# ---------------------------------------------------------------------------
# Test 13 — Session: pay deposit + activate
# ---------------------------------------------------------------------------
sep "Test 13 — Session: pay deposit (stub) + auto-advance to active"
if [[ -n "$DEPOSIT_HASH" && "$DEPOSIT_HASH" != "null" ]]; then
curl -s -X POST "$BASE/api/dev/stub/pay/$DEPOSIT_HASH" >/dev/null
sleep 1
T13_RES=$(curl -s -w "\\n%{http_code}" "$BASE/api/sessions/$SESSION_ID")
T13_BODY=$(echo "$T13_RES" | head -n-1)
T13_CODE=$(echo "$T13_RES" | tail -n1)
T13_STATE=$(echo "$T13_BODY" | jq -r '.state' 2>/dev/null || echo "")
T13_BAL=$(echo "$T13_BODY" | jq -r '.balanceSats' 2>/dev/null || echo "")
SESSION_MACAROON=$(echo "$T13_BODY" | jq -r '.macaroon' 2>/dev/null || echo "")
if [[ "$T13_CODE" == "200" && "$T13_STATE" == "active" && "$T13_BAL" == "200" && -n "$SESSION_MACAROON" && "$SESSION_MACAROON" != "null" ]]; then
note PASS "state=active, balanceSats=200, macaroon present"
PASS=$((PASS+1))
else
note FAIL "code=$T13_CODE state=$T13_STATE bal=$T13_BAL body=$T13_BODY"
FAIL=$((FAIL+1))
fi
else
note SKIP "No deposit hash (stub mode not active)"
SKIP=$((SKIP+1))
fi
# ---------------------------------------------------------------------------
# Test 14 — Session: submit request (accepted path)
# ---------------------------------------------------------------------------
sep "Test 14 — Session: submit request (accepted)"
if [[ -n "$SESSION_MACAROON" && "$SESSION_MACAROON" != "null" ]]; then
START_T14=$(date +%s)
T14_RES=$(curl -s -w "\\n%{http_code}" -X POST "$BASE/api/sessions/$SESSION_ID/request" \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer $SESSION_MACAROON" \\
-d '{"request":"What is Bitcoin in one sentence?"}')
T14_BODY=$(echo "$T14_RES" | head -n-1)
T14_CODE=$(echo "$T14_RES" | tail -n1)
T14_STATE=$(echo "$T14_BODY" | jq -r '.state' 2>/dev/null || echo "")
T14_DEBITED=$(echo "$T14_BODY" | jq -r '.debitedSats' 2>/dev/null || echo "")
T14_BAL=$(echo "$T14_BODY" | jq -r '.balanceRemaining' 2>/dev/null || echo "")
END_T14=$(date +%s)
ELAPSED_T14=$((END_T14 - START_T14))
if [[ "$T14_CODE" == "200" && ("$T14_STATE" == "complete" || "$T14_STATE" == "rejected") && -n "$T14_DEBITED" && "$T14_DEBITED" != "null" && -n "$T14_BAL" ]]; then
note PASS "state=$T14_STATE in ${ELAPSED_T14}s, debitedSats=$T14_DEBITED, balanceRemaining=$T14_BAL"
PASS=$((PASS+1))
else
note FAIL "code=$T14_CODE body=$T14_BODY"
FAIL=$((FAIL+1))
fi
else
note SKIP "No macaroon — skipping"
SKIP=$((SKIP+1))
fi
# ---------------------------------------------------------------------------
# Test 15 — Session: missing/invalid macaroon → 401
# ---------------------------------------------------------------------------
sep "Test 15 — Session: reject request without valid macaroon"
if [[ -n "$SESSION_ID" ]]; then
T15_RES=$(curl -s -w "\\n%{http_code}" -X POST "$BASE/api/sessions/$SESSION_ID/request" \\
-H "Content-Type: application/json" \\
-d '{"request":"What is Bitcoin?"}')
T15_CODE=$(echo "$T15_RES" | tail -n1)
if [[ "$T15_CODE" == "401" ]]; then
note PASS "HTTP 401 without macaroon"
PASS=$((PASS+1))
else
note FAIL "Expected 401, got code=$T15_CODE"
FAIL=$((FAIL+1))
fi
else
note SKIP "No session ID — skipping"
SKIP=$((SKIP+1))
fi
# ---------------------------------------------------------------------------
# Test 16 — Session: topup invoice creation
# ---------------------------------------------------------------------------
sep "Test 16 — Session: topup invoice creation"
if [[ -n "$SESSION_MACAROON" && "$SESSION_MACAROON" != "null" ]]; then
T16_RES=$(curl -s -w "\\n%{http_code}" -X POST "$BASE/api/sessions/$SESSION_ID/topup" \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer $SESSION_MACAROON" \\
-d '{"amount_sats": 500}')
T16_BODY=$(echo "$T16_RES" | head -n-1)
T16_CODE=$(echo "$T16_RES" | tail -n1)
T16_PR=$(echo "$T16_BODY" | jq -r '.topup.paymentRequest' 2>/dev/null || echo "")
T16_AMT=$(echo "$T16_BODY" | jq -r '.topup.amountSats' 2>/dev/null || echo "")
if [[ "$T16_CODE" == "200" && -n "$T16_PR" && "$T16_PR" != "null" && "$T16_AMT" == "500" ]]; then
note PASS "Topup invoice created, amountSats=500"
PASS=$((PASS+1))
else
note FAIL "code=$T16_CODE body=$T16_BODY"
FAIL=$((FAIL+1))
fi
else
note SKIP "No macaroon — skipping"
SKIP=$((SKIP+1))
fi
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

View File

@@ -1,124 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
export interface HealthStatus {
status: string;
}
export interface ErrorResponse {
error: string;
}
export interface InvoiceInfo {
paymentRequest: string;
amountSats: number;
}
export interface CreateJobRequest {
/** @minLength 1 */
request: string;
}
export interface CreateJobResponse {
jobId: string;
evalInvoice: InvoiceInfo;
}
export type JobState = (typeof JobState)[keyof typeof JobState];
export const JobState = {
awaiting_eval_payment: "awaiting_eval_payment",
evaluating: "evaluating",
rejected: "rejected",
awaiting_work_payment: "awaiting_work_payment",
executing: "executing",
complete: "complete",
failed: "failed",
} as const;
/**
* Cost breakdown shown with the work invoice (estimations at invoice-creation time)
*/
export interface PricingBreakdown {
/** Total estimated cost in USD (token cost + DO infra + margin) */
estimatedCostUsd?: number;
/** Originator margin percentage applied */
marginPct?: number;
/** BTC/USD spot price used to convert the invoice to sats */
btcPriceUsd?: number;
}
/**
* Lifecycle of the refund for this job
*/
export type CostLedgerRefundState =
(typeof CostLedgerRefundState)[keyof typeof CostLedgerRefundState];
export const CostLedgerRefundState = {
not_applicable: "not_applicable",
pending: "pending",
paid: "paid",
} as const;
/**
* Honest post-work accounting stored after the job completes
*/
export interface CostLedger {
actualInputTokens?: number;
actualOutputTokens?: number;
/** Sum of actualInputTokens + actualOutputTokens */
totalTokens?: number;
/** Raw Anthropic token cost (no infra, no margin) */
actualCostUsd?: number;
/** What we honestly charged in USD (actual token cost + DO infra + margin) */
actualChargeUsd?: number;
/** Original estimate used to create the work invoice */
estimatedCostUsd?: number;
/** Honest sats charge (actual cost converted at the locked BTC price) */
actualAmountSats?: number;
/** Amount the user originally paid in sats */
workAmountSats?: number;
/** Sats owed back to the user (workAmountSats - actualAmountSats, >= 0) */
refundAmountSats?: number;
/** Lifecycle of the refund for this job */
refundState?: CostLedgerRefundState;
marginPct?: number;
/** BTC/USD price locked at invoice creation time */
btcPriceUsd?: number;
}
export interface JobStatusResponse {
jobId: string;
state: JobState;
evalInvoice?: InvoiceInfo;
workInvoice?: InvoiceInfo;
pricingBreakdown?: PricingBreakdown;
reason?: string;
result?: string;
costLedger?: CostLedger;
errorMessage?: string;
}
export interface ClaimRefundRequest {
/** BOLT11 invoice for exactly refundAmountSats */
invoice: string;
}
export interface ClaimRefundResponse {
ok: boolean;
refundAmountSats: number;
paymentHash: string;
message: string;
}
export interface DemoResponse {
result: string;
}
export type RunDemoParams = {
request: string;
};

View File

@@ -1,456 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
import { useMutation, useQuery } from "@tanstack/react-query";
import type {
MutationFunction,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from "@tanstack/react-query";
import type {
ClaimRefundRequest,
ClaimRefundResponse,
CreateJobRequest,
CreateJobResponse,
DemoResponse,
ErrorResponse,
HealthStatus,
JobStatusResponse,
RunDemoParams,
} from "./api.schemas";
import { customFetch } from "../custom-fetch";
import type { ErrorType, BodyType } from "../custom-fetch";
type AwaitedInput<T> = PromiseLike<T> | T;
type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* Returns server health status
* @summary Health check
*/
export const getHealthCheckUrl = () => {
return `/api/healthz`;
};
export const healthCheck = async (
options?: RequestInit,
): Promise<HealthStatus> => {
return customFetch<HealthStatus>(getHealthCheckUrl(), {
...options,
method: "GET",
});
};
export const getHealthCheckQueryKey = () => {
return [`/api/healthz`] as const;
};
export const getHealthCheckQueryOptions = <
TData = Awaited<ReturnType<typeof healthCheck>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof healthCheck>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getHealthCheckQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof healthCheck>>> = ({
signal,
}) => healthCheck({ signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof healthCheck>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type HealthCheckQueryResult = NonNullable<
Awaited<ReturnType<typeof healthCheck>>
>;
export type HealthCheckQueryError = ErrorType<unknown>;
/**
* @summary Health check
*/
export function useHealthCheck<
TData = Awaited<ReturnType<typeof healthCheck>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof healthCheck>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getHealthCheckQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* Accepts a request, creates a job row, and issues an eval fee Lightning invoice.
* @summary Create a new agent job
*/
export const getCreateJobUrl = () => {
return `/api/jobs`;
};
export const createJob = async (
createJobRequest: CreateJobRequest,
options?: RequestInit,
): Promise<CreateJobResponse> => {
return customFetch<CreateJobResponse>(getCreateJobUrl(), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(createJobRequest),
});
};
export const getCreateJobMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createJob>>,
TError,
{ data: BodyType<CreateJobRequest> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createJob>>,
TError,
{ data: BodyType<CreateJobRequest> },
TContext
> => {
const mutationKey = ["createJob"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createJob>>,
{ data: BodyType<CreateJobRequest> }
> = (props) => {
const { data } = props ?? {};
return createJob(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type CreateJobMutationResult = NonNullable<
Awaited<ReturnType<typeof createJob>>
>;
export type CreateJobMutationBody = BodyType<CreateJobRequest>;
export type CreateJobMutationError = ErrorType<ErrorResponse>;
/**
* @summary Create a new agent job
*/
export const useCreateJob = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createJob>>,
TError,
{ data: BodyType<CreateJobRequest> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof createJob>>,
TError,
{ data: BodyType<CreateJobRequest> },
TContext
> => {
return useMutation(getCreateJobMutationOptions(options));
};
/**
* Returns current job state. Automatically advances the state machine when a pending invoice is found to be paid.
* @summary Get job status
*/
export const getGetJobUrl = (id: string) => {
return `/api/jobs/${id}`;
};
export const getJob = async (
id: string,
options?: RequestInit,
): Promise<JobStatusResponse> => {
return customFetch<JobStatusResponse>(getGetJobUrl(id), {
...options,
method: "GET",
});
};
export const getGetJobQueryKey = (id: string) => {
return [`/api/jobs/${id}`] as const;
};
export const getGetJobQueryOptions = <
TData = Awaited<ReturnType<typeof getJob>>,
TError = ErrorType<ErrorResponse>,
>(
id: string,
options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetJobQueryKey(id);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getJob>>> = ({
signal,
}) => getJob(id, { signal, ...requestOptions });
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData> & {
queryKey: QueryKey;
};
};
export type GetJobQueryResult = NonNullable<Awaited<ReturnType<typeof getJob>>>;
export type GetJobQueryError = ErrorType<ErrorResponse>;
/**
* @summary Get job status
*/
export function useGetJob<
TData = Awaited<ReturnType<typeof getJob>>,
TError = ErrorType<ErrorResponse>,
>(
id: string,
options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData>;
request?: SecondParameter<typeof customFetch>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetJobQueryOptions(id, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* After a job completes, if the actual cost (tokens used + infra + margin) was
less than the work invoice amount, the difference is owed back to the user.
Submit a BOLT11 invoice for exactly `refundAmountSats` to receive the payment.
Idempotent: returns 409 if already paid or if no refund is owed.
* @summary Claim a refund for overpayment
*/
export const getClaimRefundUrl = (id: string) => {
return `/api/jobs/${id}/refund`;
};
export const claimRefund = async (
id: string,
claimRefundRequest: ClaimRefundRequest,
options?: RequestInit,
): Promise<ClaimRefundResponse> => {
return customFetch<ClaimRefundResponse>(getClaimRefundUrl(id), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(claimRefundRequest),
});
};
export const getClaimRefundMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof claimRefund>>,
TError,
{ id: string; data: BodyType<ClaimRefundRequest> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof claimRefund>>,
TError,
{ id: string; data: BodyType<ClaimRefundRequest> },
TContext
> => {
const mutationKey = ["claimRefund"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof claimRefund>>,
{ id: string; data: BodyType<ClaimRefundRequest> }
> = (props) => {
const { id, data } = props ?? {};
return claimRefund(id, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type ClaimRefundMutationResult = NonNullable<
Awaited<ReturnType<typeof claimRefund>>
>;
export type ClaimRefundMutationBody = BodyType<ClaimRefundRequest>;
export type ClaimRefundMutationError = ErrorType<ErrorResponse>;
/**
* @summary Claim a refund for overpayment
*/
export const useClaimRefund = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof claimRefund>>,
TError,
{ id: string; data: BodyType<ClaimRefundRequest> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof claimRefund>>,
TError,
{ id: string; data: BodyType<ClaimRefundRequest> },
TContext
> => {
return useMutation(getClaimRefundMutationOptions(options));
};
/**
* Runs the agent without payment. Limited to 5 requests per IP per hour.
* @summary Free demo (rate-limited)
*/
export const getRunDemoUrl = (params: RunDemoParams) => {
const normalizedParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(key, value === null ? "null" : value.toString());
}
});
const stringifiedParams = normalizedParams.toString();
return stringifiedParams.length > 0
? `/api/demo?${stringifiedParams}`
: `/api/demo`;
};
export const runDemo = async (
params: RunDemoParams,
options?: RequestInit,
): Promise<DemoResponse> => {
return customFetch<DemoResponse>(getRunDemoUrl(params), {
...options,
method: "GET",
});
};
export const getRunDemoQueryKey = (params?: RunDemoParams) => {
return [`/api/demo`, ...(params ? [params] : [])] as const;
};
export const getRunDemoQueryOptions = <
TData = Awaited<ReturnType<typeof runDemo>>,
TError = ErrorType<ErrorResponse>,
>(
params: RunDemoParams,
options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof runDemo>>, TError, TData>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getRunDemoQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof runDemo>>> = ({
signal,
}) => runDemo(params, { signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof runDemo>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type RunDemoQueryResult = NonNullable<
Awaited<ReturnType<typeof runDemo>>
>;
export type RunDemoQueryError = ErrorType<ErrorResponse>;
/**
* @summary Free demo (rate-limited)
*/
export function useRunDemo<
TData = Awaited<ReturnType<typeof runDemo>>,
TError = ErrorType<ErrorResponse>,
>(
params: RunDemoParams,
options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof runDemo>>, TError, TData>;
request?: SecondParameter<typeof customFetch>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getRunDemoQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}

View File

@@ -11,7 +11,9 @@ tags:
- name: health
description: Health operations
- name: jobs
description: Payment-gated agent job operations
description: Payment-gated agent job operations (Mode 1 -- per-job)
- name: sessions
description: Pre-funded session balance mode (Mode 2 -- pay once, run many)
- name: demo
description: Free demo endpoint (rate-limited)
paths:
@@ -143,6 +145,165 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/sessions:
post:
operationId: createSession
tags: [sessions]
summary: Create a pre-funded session
description: |
Opens a new session. Pay the returned Lightning invoice to activate it.
Once active, use the `macaroon` from GET /sessions/:id to authenticate requests.
Deposits: 10010,000 sats. Sessions expire after 24 h of inactivity.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateSessionRequest"
responses:
"201":
description: Session created -- awaiting deposit payment
content:
application/json:
schema:
$ref: "#/components/schemas/CreateSessionResponse"
"400":
description: Invalid amount
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"500":
description: Server error
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/sessions/{id}:
get:
operationId: getSession
tags: [sessions]
summary: Get session status
description: Returns current state, balance, and pending invoice info. Auto-advances on payment.
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: Session status
content:
application/json:
schema:
$ref: "#/components/schemas/SessionStatusResponse"
"404":
description: Session not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/sessions/{id}/request:
post:
operationId: submitSessionRequest
tags: [sessions]
summary: Submit a request against a session balance
description: |
Runs eval + work and debits the actual compute cost from the session balance.
Rejected requests still incur a small eval fee. Requires `Authorization: Bearer <macaroon>`.
parameters:
- name: id
in: path
required: true
schema:
type: string
security:
- sessionMacaroon: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SessionRequestBody"
responses:
"200":
description: Request completed (or rejected)
content:
application/json:
schema:
$ref: "#/components/schemas/SessionRequestResponse"
"401":
description: Missing or invalid macaroon
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"402":
description: Insufficient balance
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
description: Session not active
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"410":
description: Session expired
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/sessions/{id}/topup:
post:
operationId: topupSession
tags: [sessions]
summary: Add sats to a session
description: |
Creates a new Lightning invoice to top up the session balance.
Only one pending topup at a time. Paying it resumes a paused session.
parameters:
- name: id
in: path
required: true
schema:
type: string
security:
- sessionMacaroon: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateSessionRequest"
responses:
"200":
description: Topup invoice created
content:
application/json:
schema:
$ref: "#/components/schemas/TopupSessionResponse"
"400":
description: Invalid amount
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"401":
description: Invalid macaroon
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
description: Session not active/paused, or topup already pending
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/demo:
get:
operationId: runDemo
@@ -309,6 +470,109 @@ components:
$ref: "#/components/schemas/CostLedger"
errorMessage:
type: string
SessionState:
type: string
enum: [awaiting_payment, active, paused, expired]
SessionInvoiceInfo:
type: object
properties:
paymentRequest:
type: string
amountSats:
type: integer
paymentHash:
type: string
description: Only present in stub/dev mode
CreateSessionRequest:
type: object
required: [amount_sats]
properties:
amount_sats:
type: integer
description: Deposit amount (10010,000 sats)
minimum: 100
maximum: 10000
CreateSessionResponse:
type: object
required: [sessionId, state, invoice]
properties:
sessionId:
type: string
state:
$ref: "#/components/schemas/SessionState"
invoice:
$ref: "#/components/schemas/SessionInvoiceInfo"
SessionStatusResponse:
type: object
required: [sessionId, state, balanceSats]
properties:
sessionId:
type: string
state:
$ref: "#/components/schemas/SessionState"
balanceSats:
type: integer
minimumBalanceSats:
type: integer
macaroon:
type: string
description: Bearer token for authenticating requests; present when active or paused
expiresAt:
type: string
format: date-time
invoice:
$ref: "#/components/schemas/SessionInvoiceInfo"
description: Present when state is awaiting_payment
pendingTopup:
$ref: "#/components/schemas/SessionInvoiceInfo"
description: Present when a topup invoice is outstanding
SessionRequestBody:
type: object
required: [request]
properties:
request:
type: string
minLength: 1
SessionCostBreakdown:
type: object
properties:
evalSats:
type: integer
workSats:
type: integer
totalSats:
type: integer
btcPriceUsd:
type: number
SessionRequestResponse:
type: object
required: [requestId, state, debitedSats, balanceRemaining]
properties:
requestId:
type: string
state:
type: string
enum: [complete, rejected, failed]
result:
type: string
reason:
type: string
errorMessage:
type: string
debitedSats:
type: integer
balanceRemaining:
type: integer
cost:
$ref: "#/components/schemas/SessionCostBreakdown"
TopupSessionResponse:
type: object
required: [sessionId, topup]
properties:
sessionId:
type: string
topup:
$ref: "#/components/schemas/SessionInvoiceInfo"
ClaimRefundRequest:
type: object
required:
@@ -340,3 +604,8 @@ components:
properties:
result:
type: string
securitySchemes:
sessionMacaroon:
type: http
scheme: bearer
description: Session macaroon issued when a session activates. Pass as `Authorization: Bearer <macaroon>`.

View File

@@ -1,167 +1,45 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
import * as zod from "zod";
import { z } from "zod";
/**
* Returns server health status
* @summary Health check
*/
export const HealthCheckResponse = zod.object({
status: zod.string(),
export const HealthCheckResponse = z.object({
status: z.string(),
});
/**
* Accepts a request, creates a job row, and issues an eval fee Lightning invoice.
* @summary Create a new agent job
*/
export const CreateJobBody = zod.object({
request: zod.string().min(1),
export const ErrorResponse = z.object({
error: z.string(),
});
/**
* Returns current job state. Automatically advances the state machine when a pending invoice is found to be paid.
* @summary Get job status
*/
export const GetJobParams = zod.object({
id: zod.coerce.string(),
export const CreateJobBody = z.object({
request: z.string().min(1).max(500),
});
export const GetJobResponse = zod.object({
jobId: zod.string(),
state: zod.enum([
"awaiting_eval_payment",
"evaluating",
"rejected",
"awaiting_work_payment",
"executing",
"complete",
"failed",
]),
evalInvoice: zod
.object({
paymentRequest: zod.string(),
amountSats: zod.number(),
})
.optional(),
workInvoice: zod
.object({
paymentRequest: zod.string(),
amountSats: zod.number(),
})
.optional(),
pricingBreakdown: zod
.object({
estimatedCostUsd: zod
.number()
.optional()
.describe(
"Total estimated cost in USD (token cost + DO infra + margin)",
),
marginPct: zod
.number()
.optional()
.describe("Originator margin percentage applied"),
btcPriceUsd: zod
.number()
.optional()
.describe("BTC\/USD spot price used to convert the invoice to sats"),
})
.optional()
.describe(
"Cost breakdown shown with the work invoice (estimations at invoice-creation time)",
),
reason: zod.string().optional(),
result: zod.string().optional(),
costLedger: zod
.object({
actualInputTokens: zod.number().optional(),
actualOutputTokens: zod.number().optional(),
totalTokens: zod
.number()
.optional()
.describe("Sum of actualInputTokens + actualOutputTokens"),
actualCostUsd: zod
.number()
.optional()
.describe("Raw Anthropic token cost (no infra, no margin)"),
actualChargeUsd: zod
.number()
.optional()
.describe(
"What we honestly charged in USD (actual token cost + DO infra + margin)",
),
estimatedCostUsd: zod
.number()
.optional()
.describe("Original estimate used to create the work invoice"),
actualAmountSats: zod
.number()
.optional()
.describe(
"Honest sats charge (actual cost converted at the locked BTC price)",
),
workAmountSats: zod
.number()
.optional()
.describe("Amount the user originally paid in sats"),
refundAmountSats: zod
.number()
.optional()
.describe(
"Sats owed back to the user (workAmountSats - actualAmountSats, >= 0)",
),
refundState: zod
.enum(["not_applicable", "pending", "paid"])
.optional()
.describe("Lifecycle of the refund for this job"),
marginPct: zod.number().optional(),
btcPriceUsd: zod
.number()
.optional()
.describe("BTC\/USD price locked at invoice creation time"),
})
.optional()
.describe("Honest post-work accounting stored after the job completes"),
errorMessage: zod.string().optional(),
export const GetJobParams = z.object({
id: z.string(),
});
/**
* After a job completes, if the actual cost (tokens used + infra + margin) was
less than the work invoice amount, the difference is owed back to the user.
Submit a BOLT11 invoice for exactly `refundAmountSats` to receive the payment.
Idempotent: returns 409 if already paid or if no refund is owed.
* @summary Claim a refund for overpayment
*/
export const ClaimRefundParams = zod.object({
id: zod.coerce.string(),
export const GetJobRefundParams = z.object({
id: z.string(),
});
export const ClaimRefundBody = zod.object({
invoice: zod.string().describe("BOLT11 invoice for exactly refundAmountSats"),
export const ClaimRefundBody = z.object({
paymentRequest: z.string(),
});
export const ClaimRefundResponse = zod.object({
ok: zod.boolean(),
refundAmountSats: zod.number(),
paymentHash: zod.string(),
message: zod.string(),
export const RunDemoQueryParams = z.object({
request: z.string().min(1),
});
/**
* Runs the agent without payment. Limited to 5 requests per IP per hour.
* @summary Free demo (rate-limited)
*/
export const RunDemoQueryParams = zod.object({
request: zod.coerce.string(),
export const CreateSessionBody = z.object({
amount_sats: z.number().int().min(100).max(10000),
});
export const RunDemoResponse = zod.object({
result: zod.string(),
export const GetSessionParams = z.object({
id: z.string(),
});
export const SubmitSessionRequestBody = z.object({
request: z.string().min(1),
});
export const TopupSessionBody = z.object({
amount_sats: z.number().int().min(100).max(10000),
});

View File

@@ -0,0 +1,3 @@
// TypeScript types derived from Zod schemas in ./api
// All schemas and their inferred types are exported from ./api via src/index.ts
export {};

View File

@@ -1,12 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
export interface ClaimRefundRequest {
/** BOLT11 invoice for exactly refundAmountSats */
invoice: string;
}

View File

@@ -1,14 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
export interface ClaimRefundResponse {
ok: boolean;
refundAmountSats: number;
paymentHash: string;
message: string;
}

View File

@@ -1,35 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
import type { CostLedgerRefundState } from "./costLedgerRefundState";
/**
* Honest post-work accounting stored after the job completes
*/
export interface CostLedger {
actualInputTokens?: number;
actualOutputTokens?: number;
/** Sum of actualInputTokens + actualOutputTokens */
totalTokens?: number;
/** Raw Anthropic token cost (no infra, no margin) */
actualCostUsd?: number;
/** What we honestly charged in USD (actual token cost + DO infra + margin) */
actualChargeUsd?: number;
/** Original estimate used to create the work invoice */
estimatedCostUsd?: number;
/** Honest sats charge (actual cost converted at the locked BTC price) */
actualAmountSats?: number;
/** Amount the user originally paid in sats */
workAmountSats?: number;
/** Sats owed back to the user (workAmountSats - actualAmountSats, >= 0) */
refundAmountSats?: number;
/** Lifecycle of the refund for this job */
refundState?: CostLedgerRefundState;
marginPct?: number;
/** BTC/USD price locked at invoice creation time */
btcPriceUsd?: number;
}

View File

@@ -1,19 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
/**
* Lifecycle of the refund for this job
*/
export type CostLedgerRefundState =
(typeof CostLedgerRefundState)[keyof typeof CostLedgerRefundState];
export const CostLedgerRefundState = {
not_applicable: "not_applicable",
pending: "pending",
paid: "paid",
} as const;

View File

@@ -1,12 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
export interface CreateJobRequest {
/** @minLength 1 */
request: string;
}

View File

@@ -1,13 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
import type { InvoiceInfo } from "./invoiceInfo";
export interface CreateJobResponse {
jobId: string;
evalInvoice: InvoiceInfo;
}

View File

@@ -1,11 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
export interface DemoResponse {
result: string;
}

View File

@@ -1,11 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
export interface ErrorResponse {
error: string;
}

View File

@@ -1,11 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
export interface HealthStatus {
status: string;
}

View File

@@ -1,22 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
export * from "./claimRefundRequest";
export * from "./claimRefundResponse";
export * from "./costLedger";
export * from "./costLedgerRefundState";
export * from "./createJobRequest";
export * from "./createJobResponse";
export * from "./demoResponse";
export * from "./errorResponse";
export * from "./healthStatus";
export * from "./invoiceInfo";
export * from "./jobState";
export * from "./jobStatusResponse";
export * from "./pricingBreakdown";
export * from "./runDemoParams";

View File

@@ -1,12 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
export interface InvoiceInfo {
paymentRequest: string;
amountSats: number;
}

View File

@@ -1,19 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
export type JobState = (typeof JobState)[keyof typeof JobState];
export const JobState = {
awaiting_eval_payment: "awaiting_eval_payment",
evaluating: "evaluating",
rejected: "rejected",
awaiting_work_payment: "awaiting_work_payment",
executing: "executing",
complete: "complete",
failed: "failed",
} as const;

View File

@@ -1,23 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
import type { CostLedger } from "./costLedger";
import type { InvoiceInfo } from "./invoiceInfo";
import type { JobState } from "./jobState";
import type { PricingBreakdown } from "./pricingBreakdown";
export interface JobStatusResponse {
jobId: string;
state: JobState;
evalInvoice?: InvoiceInfo;
workInvoice?: InvoiceInfo;
pricingBreakdown?: PricingBreakdown;
reason?: string;
result?: string;
costLedger?: CostLedger;
errorMessage?: string;
}

View File

@@ -1,19 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
/**
* Cost breakdown shown with the work invoice (estimations at invoice-creation time)
*/
export interface PricingBreakdown {
/** Total estimated cost in USD (token cost + DO infra + margin) */
estimatedCostUsd?: number;
/** Originator margin percentage applied */
marginPct?: number;
/** BTC/USD spot price used to convert the invoice to sats */
btcPriceUsd?: number;
}

View File

@@ -1,11 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
export type RunDemoParams = {
request: string;
};

View File

@@ -0,0 +1,39 @@
-- Migration: Session balance mode (Mode 2)
-- Users pre-fund a Lightning session; requests auto-debit actual costs.
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
state TEXT NOT NULL DEFAULT 'awaiting_payment',
balance_sats INTEGER NOT NULL DEFAULT 0,
deposit_amount_sats INTEGER NOT NULL,
deposit_payment_hash TEXT NOT NULL,
deposit_payment_request TEXT NOT NULL,
deposit_paid BOOLEAN NOT NULL DEFAULT false,
topup_amount_sats INTEGER,
topup_payment_hash TEXT,
topup_payment_request TEXT,
topup_paid BOOLEAN,
macaroon TEXT,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS session_requests (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
request TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'processing',
result TEXT,
reason TEXT,
error_message TEXT,
eval_input_tokens INTEGER,
eval_output_tokens INTEGER,
work_input_tokens INTEGER,
work_output_tokens INTEGER,
debited_sats INTEGER,
balance_after_sats INTEGER,
btc_price_usd REAL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -3,3 +3,4 @@ export * from "./invoices";
export * from "./conversations";
export * from "./messages";
export * from "./bootstrap-jobs";
export * from "./sessions";

View File

@@ -0,0 +1,101 @@
import { pgTable, text, timestamp, integer, boolean, real } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";
// ── Session state machine ─────────────────────────────────────────────────────
export const SESSION_STATES = [
"awaiting_payment",
"active",
"paused",
"expired",
] as const;
export type SessionState = (typeof SESSION_STATES)[number];
export const SESSION_REQUEST_STATES = [
"processing",
"complete",
"rejected",
"failed",
] as const;
export type SessionRequestState = (typeof SESSION_REQUEST_STATES)[number];
// ── sessions ──────────────────────────────────────────────────────────────────
export const sessions = pgTable("sessions", {
id: text("id").primaryKey(),
state: text("state").$type<SessionState>().notNull().default("awaiting_payment"),
balanceSats: integer("balance_sats").notNull().default(0),
depositAmountSats: integer("deposit_amount_sats").notNull(),
// Deposit invoice (stored inline — avoids FK into jobs table)
depositPaymentHash: text("deposit_payment_hash").notNull(),
depositPaymentRequest: text("deposit_payment_request").notNull(),
depositPaid: boolean("deposit_paid").notNull().default(false),
// Current topup invoice (one at a time; all nullable until created)
topupAmountSats: integer("topup_amount_sats"),
topupPaymentHash: text("topup_payment_hash"),
topupPaymentRequest: text("topup_payment_request"),
topupPaid: boolean("topup_paid"),
// Auth token — issued once when session activates; required for requests
macaroon: text("macaroon"),
// TTL — refreshed on each successful request
expiresAt: timestamp("expires_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
export const insertSessionSchema = createInsertSchema(sessions).omit({
createdAt: true,
updatedAt: true,
});
export type Session = typeof sessions.$inferSelect;
export type InsertSession = z.infer<typeof insertSessionSchema>;
// ── session_requests ──────────────────────────────────────────────────────────
export const sessionRequests = pgTable("session_requests", {
id: text("id").primaryKey(),
sessionId: text("session_id")
.notNull()
.references(() => sessions.id),
request: text("request").notNull(),
state: text("state")
.$type<SessionRequestState>()
.notNull()
.default("processing"),
result: text("result"),
reason: text("reason"),
errorMessage: text("error_message"),
// Eval token usage (Haiku judge)
evalInputTokens: integer("eval_input_tokens"),
evalOutputTokens: integer("eval_output_tokens"),
// Work token usage (Sonnet; null for rejected requests)
workInputTokens: integer("work_input_tokens"),
workOutputTokens: integer("work_output_tokens"),
// Balance debit for this request
debitedSats: integer("debited_sats"),
balanceAfterSats: integer("balance_after_sats"),
btcPriceUsd: real("btc_price_usd"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
export const insertSessionRequestSchema = createInsertSchema(sessionRequests).omit({
createdAt: true,
updatedAt: true,
});
export type SessionRequest = typeof sessionRequests.$inferSelect;
export type InsertSessionRequest = z.infer<typeof insertSessionRequestSchema>;

View File

@@ -246,10 +246,53 @@ Only available in development (`NODE_ENV !== 'production'`).
Utility scripts package. Each script is a `.ts` file in `src/` with a corresponding npm script in `package.json`. Run scripts via `pnpm --filter @workspace/scripts run <script>`. Scripts can import any workspace package (e.g., `@workspace/db`) by adding it as a dependency in `scripts/package.json`.
## Roadmap
### Pre-funded session mode (Mode 2)
### Mode 2 — Pre-funded session (v2)
Token-based cost debits against a pre-funded balance. Spec in `TIMMY_TEST_PLAN.md` (Tests 1116 as SKIP stubs).
Pay once, run many requests. Balance is debited at actual cost per request — no per-job invoices.
```bash
BASE="http://localhost:8080"
# 1. Create a session (returns deposit invoice)
curl -s -X POST "$BASE/api/sessions" \
-H "Content-Type: application/json" \
-d '{"amount_sats": 500}'
# → {"sessionId":"…","state":"awaiting_payment","invoice":{"paymentRequest":"…","amountSats":500,"paymentHash":"…"}}
# 2. (Stub mode only) Pay deposit
curl -s -X POST "$BASE/api/dev/stub/pay/<paymentHash>"
# 3. Poll session — auto-advances to active, issues macaroon
curl -s "$BASE/api/sessions/<sessionId>"
# → {"sessionId":"…","state":"active","balanceSats":500,"macaroon":"…","minimumBalanceSats":50}
# 4. Submit requests (use macaroon as Bearer token)
curl -s -X POST "$BASE/api/sessions/<sessionId>/request" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <macaroon>" \
-d '{"request":"What is a satoshi?"}'
# → {"requestId":"…","state":"complete","result":"…","debitedSats":178,"balanceRemaining":322}
# 5. Top up when balance is low (session auto-pauses below 50 sats)
curl -s -X POST "$BASE/api/sessions/<sessionId>/topup" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <macaroon>" \
-d '{"amount_sats": 500}'
```
Session states: `awaiting_payment``active``paused` (low balance) → `expired` (24h TTL, refreshed on each request)
Key properties:
- **No per-job invoices** — balance is debited at actual compute cost (eval + work tokens + DO infra + margin)
- **Rejected requests** still incur a small eval fee (Haiku model only)
- **Macaroon auth** — 32-byte hex token issued on activation; required as `Authorization: Bearer` header
- **Pause on low balance** — session auto-pauses when balance < 50 sats; pay topup invoice to resume
- **TTL** — sessions expire 24 hours after last successful request
- **Deposit limits** — 10010,000 sats; env vars: `SESSION_MIN_DEPOSIT_SATS`, `SESSION_MAX_DEPOSIT_SATS`, `SESSION_MIN_BALANCE_SATS`, `SESSION_EXPIRY_HOURS`
DB tables: `sessions` (state machine, balance, macaroon), `session_requests` (per-request token + cost accounting)
## Roadmap
### Nostr integration
Nostr (NIP-04/NIP-44 encrypted DMs) is planned as the delivery layer for the bootstrap flow and beyond: