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:
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
438
artifacts/api-server/src/routes/sessions.ts
Normal file
438
artifacts/api-server/src/routes/sessions.ts
Normal 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;
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BIN
attached_assets/IMG_0025_1773862974250.jpeg
Normal file
BIN
attached_assets/IMG_0025_1773862974250.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 336 KiB |
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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: 100–10,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 (100–10,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>`.
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
3
lib/api-zod/src/generated/types.ts
Normal file
3
lib/api-zod/src/generated/types.ts
Normal 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 {};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
39
lib/db/migrations/0004_sessions.sql
Normal file
39
lib/db/migrations/0004_sessions.sql
Normal 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()
|
||||
);
|
||||
@@ -3,3 +3,4 @@ export * from "./invoices";
|
||||
export * from "./conversations";
|
||||
export * from "./messages";
|
||||
export * from "./bootstrap-jobs";
|
||||
export * from "./sessions";
|
||||
|
||||
101
lib/db/src/schema/sessions.ts
Normal file
101
lib/db/src/schema/sessions.ts
Normal 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>;
|
||||
49
replit.md
49
replit.md
@@ -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 11–16 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** — 100–10,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:
|
||||
|
||||
Reference in New Issue
Block a user