diff --git a/artifacts/api-server/src/lib/agent.ts b/artifacts/api-server/src/lib/agent.ts index d02b94c..4a8d7f3 100644 --- a/artifacts/api-server/src/lib/agent.ts +++ b/artifacts/api-server/src/lib/agent.ts @@ -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 { diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index a8b5852..5378851 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -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); diff --git a/artifacts/api-server/src/routes/sessions.ts b/artifacts/api-server/src/routes/sessions.ts new file mode 100644 index 0000000..e23a597 --- /dev/null +++ b/artifacts/api-server/src/routes/sessions.ts @@ -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 { + 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 { + 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 { + 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 ' 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; diff --git a/artifacts/api-server/src/routes/testkit.ts b/artifacts/api-server/src/routes/testkit.ts index 7dfc0e8..78b5836 100644 --- a/artifacts/api-server/src/routes/testkit.ts +++ b/artifacts/api-server/src/routes/testkit.ts @@ -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 # --------------------------------------------------------------------------- diff --git a/attached_assets/IMG_0025_1773862974250.jpeg b/attached_assets/IMG_0025_1773862974250.jpeg new file mode 100644 index 0000000..96b348b Binary files /dev/null and b/attached_assets/IMG_0025_1773862974250.jpeg differ diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts deleted file mode 100644 index 3a2f8e7..0000000 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ /dev/null @@ -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; -}; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts deleted file mode 100644 index 64f851d..0000000 --- a/lib/api-client-react/src/generated/api.ts +++ /dev/null @@ -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 = PromiseLike | T; - -type Awaited = O extends AwaitedInput ? T : never; - -type SecondParameter unknown> = Parameters[1]; - -/** - * Returns server health status - * @summary Health check - */ -export const getHealthCheckUrl = () => { - return `/api/healthz`; -}; - -export const healthCheck = async ( - options?: RequestInit, -): Promise => { - return customFetch(getHealthCheckUrl(), { - ...options, - method: "GET", - }); -}; - -export const getHealthCheckQueryKey = () => { - return [`/api/healthz`] as const; -}; - -export const getHealthCheckQueryOptions = < - TData = Awaited>, - TError = ErrorType, ->(options?: { - query?: UseQueryOptions< - Awaited>, - TError, - TData - >; - request?: SecondParameter; -}) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getHealthCheckQueryKey(); - - const queryFn: QueryFunction>> = ({ - signal, - }) => healthCheck({ signal, ...requestOptions }); - - return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< - Awaited>, - TError, - TData - > & { queryKey: QueryKey }; -}; - -export type HealthCheckQueryResult = NonNullable< - Awaited> ->; -export type HealthCheckQueryError = ErrorType; - -/** - * @summary Health check - */ - -export function useHealthCheck< - TData = Awaited>, - TError = ErrorType, ->(options?: { - query?: UseQueryOptions< - Awaited>, - TError, - TData - >; - request?: SecondParameter; -}): UseQueryResult & { queryKey: QueryKey } { - const queryOptions = getHealthCheckQueryOptions(options); - - const query = useQuery(queryOptions) as UseQueryResult & { - 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 => { - return customFetch(getCreateJobUrl(), { - ...options, - method: "POST", - headers: { "Content-Type": "application/json", ...options?.headers }, - body: JSON.stringify(createJobRequest), - }); -}; - -export const getCreateJobMutationOptions = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { data: BodyType }, - TContext - >; - request?: SecondParameter; -}): UseMutationOptions< - Awaited>, - TError, - { data: BodyType }, - 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>, - { data: BodyType } - > = (props) => { - const { data } = props ?? {}; - - return createJob(data, requestOptions); - }; - - return { mutationFn, ...mutationOptions }; -}; - -export type CreateJobMutationResult = NonNullable< - Awaited> ->; -export type CreateJobMutationBody = BodyType; -export type CreateJobMutationError = ErrorType; - -/** - * @summary Create a new agent job - */ -export const useCreateJob = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { data: BodyType }, - TContext - >; - request?: SecondParameter; -}): UseMutationResult< - Awaited>, - TError, - { data: BodyType }, - 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 => { - return customFetch(getGetJobUrl(id), { - ...options, - method: "GET", - }); -}; - -export const getGetJobQueryKey = (id: string) => { - return [`/api/jobs/${id}`] as const; -}; - -export const getGetJobQueryOptions = < - TData = Awaited>, - TError = ErrorType, ->( - id: string, - options?: { - query?: UseQueryOptions>, TError, TData>; - request?: SecondParameter; - }, -) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getGetJobQueryKey(id); - - const queryFn: QueryFunction>> = ({ - signal, - }) => getJob(id, { signal, ...requestOptions }); - - return { - queryKey, - queryFn, - enabled: !!id, - ...queryOptions, - } as UseQueryOptions>, TError, TData> & { - queryKey: QueryKey; - }; -}; - -export type GetJobQueryResult = NonNullable>>; -export type GetJobQueryError = ErrorType; - -/** - * @summary Get job status - */ - -export function useGetJob< - TData = Awaited>, - TError = ErrorType, ->( - id: string, - options?: { - query?: UseQueryOptions>, TError, TData>; - request?: SecondParameter; - }, -): UseQueryResult & { queryKey: QueryKey } { - const queryOptions = getGetJobQueryOptions(id, options); - - const query = useQuery(queryOptions) as UseQueryResult & { - 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 => { - return customFetch(getClaimRefundUrl(id), { - ...options, - method: "POST", - headers: { "Content-Type": "application/json", ...options?.headers }, - body: JSON.stringify(claimRefundRequest), - }); -}; - -export const getClaimRefundMutationOptions = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { id: string; data: BodyType }, - TContext - >; - request?: SecondParameter; -}): UseMutationOptions< - Awaited>, - TError, - { id: string; data: BodyType }, - 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>, - { id: string; data: BodyType } - > = (props) => { - const { id, data } = props ?? {}; - - return claimRefund(id, data, requestOptions); - }; - - return { mutationFn, ...mutationOptions }; -}; - -export type ClaimRefundMutationResult = NonNullable< - Awaited> ->; -export type ClaimRefundMutationBody = BodyType; -export type ClaimRefundMutationError = ErrorType; - -/** - * @summary Claim a refund for overpayment - */ -export const useClaimRefund = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { id: string; data: BodyType }, - TContext - >; - request?: SecondParameter; -}): UseMutationResult< - Awaited>, - TError, - { id: string; data: BodyType }, - 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 => { - return customFetch(getRunDemoUrl(params), { - ...options, - method: "GET", - }); -}; - -export const getRunDemoQueryKey = (params?: RunDemoParams) => { - return [`/api/demo`, ...(params ? [params] : [])] as const; -}; - -export const getRunDemoQueryOptions = < - TData = Awaited>, - TError = ErrorType, ->( - params: RunDemoParams, - options?: { - query?: UseQueryOptions>, TError, TData>; - request?: SecondParameter; - }, -) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getRunDemoQueryKey(params); - - const queryFn: QueryFunction>> = ({ - signal, - }) => runDemo(params, { signal, ...requestOptions }); - - return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< - Awaited>, - TError, - TData - > & { queryKey: QueryKey }; -}; - -export type RunDemoQueryResult = NonNullable< - Awaited> ->; -export type RunDemoQueryError = ErrorType; - -/** - * @summary Free demo (rate-limited) - */ - -export function useRunDemo< - TData = Awaited>, - TError = ErrorType, ->( - params: RunDemoParams, - options?: { - query?: UseQueryOptions>, TError, TData>; - request?: SecondParameter; - }, -): UseQueryResult & { queryKey: QueryKey } { - const queryOptions = getRunDemoQueryOptions(params, options); - - const query = useQuery(queryOptions) as UseQueryResult & { - queryKey: QueryKey; - }; - - return { ...query, queryKey: queryOptions.queryKey }; -} diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 45e2d48..a3b456e 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -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 `. + 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 `. diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 63f5c74..4ce044a 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -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), }); diff --git a/lib/api-zod/src/generated/types.ts b/lib/api-zod/src/generated/types.ts new file mode 100644 index 0000000..3776f91 --- /dev/null +++ b/lib/api-zod/src/generated/types.ts @@ -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 {}; diff --git a/lib/api-zod/src/generated/types/claimRefundRequest.ts b/lib/api-zod/src/generated/types/claimRefundRequest.ts deleted file mode 100644 index cdd7eae..0000000 --- a/lib/api-zod/src/generated/types/claimRefundRequest.ts +++ /dev/null @@ -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; -} diff --git a/lib/api-zod/src/generated/types/claimRefundResponse.ts b/lib/api-zod/src/generated/types/claimRefundResponse.ts deleted file mode 100644 index 8643ece..0000000 --- a/lib/api-zod/src/generated/types/claimRefundResponse.ts +++ /dev/null @@ -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; -} diff --git a/lib/api-zod/src/generated/types/costLedger.ts b/lib/api-zod/src/generated/types/costLedger.ts deleted file mode 100644 index 6b2e39e..0000000 --- a/lib/api-zod/src/generated/types/costLedger.ts +++ /dev/null @@ -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; -} diff --git a/lib/api-zod/src/generated/types/costLedgerRefundState.ts b/lib/api-zod/src/generated/types/costLedgerRefundState.ts deleted file mode 100644 index 7f44b92..0000000 --- a/lib/api-zod/src/generated/types/costLedgerRefundState.ts +++ /dev/null @@ -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; diff --git a/lib/api-zod/src/generated/types/createJobRequest.ts b/lib/api-zod/src/generated/types/createJobRequest.ts deleted file mode 100644 index 15b1d8f..0000000 --- a/lib/api-zod/src/generated/types/createJobRequest.ts +++ /dev/null @@ -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; -} diff --git a/lib/api-zod/src/generated/types/createJobResponse.ts b/lib/api-zod/src/generated/types/createJobResponse.ts deleted file mode 100644 index f1c62b0..0000000 --- a/lib/api-zod/src/generated/types/createJobResponse.ts +++ /dev/null @@ -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; -} diff --git a/lib/api-zod/src/generated/types/demoResponse.ts b/lib/api-zod/src/generated/types/demoResponse.ts deleted file mode 100644 index acceb16..0000000 --- a/lib/api-zod/src/generated/types/demoResponse.ts +++ /dev/null @@ -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; -} diff --git a/lib/api-zod/src/generated/types/errorResponse.ts b/lib/api-zod/src/generated/types/errorResponse.ts deleted file mode 100644 index 8c46a3a..0000000 --- a/lib/api-zod/src/generated/types/errorResponse.ts +++ /dev/null @@ -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; -} diff --git a/lib/api-zod/src/generated/types/healthStatus.ts b/lib/api-zod/src/generated/types/healthStatus.ts deleted file mode 100644 index f1ad88c..0000000 --- a/lib/api-zod/src/generated/types/healthStatus.ts +++ /dev/null @@ -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; -} diff --git a/lib/api-zod/src/generated/types/index.ts b/lib/api-zod/src/generated/types/index.ts deleted file mode 100644 index 9547d3e..0000000 --- a/lib/api-zod/src/generated/types/index.ts +++ /dev/null @@ -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"; diff --git a/lib/api-zod/src/generated/types/invoiceInfo.ts b/lib/api-zod/src/generated/types/invoiceInfo.ts deleted file mode 100644 index 1370753..0000000 --- a/lib/api-zod/src/generated/types/invoiceInfo.ts +++ /dev/null @@ -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; -} diff --git a/lib/api-zod/src/generated/types/jobState.ts b/lib/api-zod/src/generated/types/jobState.ts deleted file mode 100644 index b4c113c..0000000 --- a/lib/api-zod/src/generated/types/jobState.ts +++ /dev/null @@ -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; diff --git a/lib/api-zod/src/generated/types/jobStatusResponse.ts b/lib/api-zod/src/generated/types/jobStatusResponse.ts deleted file mode 100644 index 4a86bb8..0000000 --- a/lib/api-zod/src/generated/types/jobStatusResponse.ts +++ /dev/null @@ -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; -} diff --git a/lib/api-zod/src/generated/types/pricingBreakdown.ts b/lib/api-zod/src/generated/types/pricingBreakdown.ts deleted file mode 100644 index 4c1ed4c..0000000 --- a/lib/api-zod/src/generated/types/pricingBreakdown.ts +++ /dev/null @@ -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; -} diff --git a/lib/api-zod/src/generated/types/runDemoParams.ts b/lib/api-zod/src/generated/types/runDemoParams.ts deleted file mode 100644 index e7bbdac..0000000 --- a/lib/api-zod/src/generated/types/runDemoParams.ts +++ /dev/null @@ -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; -}; diff --git a/lib/db/migrations/0004_sessions.sql b/lib/db/migrations/0004_sessions.sql new file mode 100644 index 0000000..61ce720 --- /dev/null +++ b/lib/db/migrations/0004_sessions.sql @@ -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() +); diff --git a/lib/db/src/schema/index.ts b/lib/db/src/schema/index.ts index e03fd04..3043169 100644 --- a/lib/db/src/schema/index.ts +++ b/lib/db/src/schema/index.ts @@ -3,3 +3,4 @@ export * from "./invoices"; export * from "./conversations"; export * from "./messages"; export * from "./bootstrap-jobs"; +export * from "./sessions"; diff --git a/lib/db/src/schema/sessions.ts b/lib/db/src/schema/sessions.ts new file mode 100644 index 0000000..54c67d4 --- /dev/null +++ b/lib/db/src/schema/sessions.ts @@ -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().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; + +// ── 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() + .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; diff --git a/replit.md b/replit.md index c6e6de0..2a2a6ff 100644 --- a/replit.md +++ b/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