diff --git a/artifacts/api-server/src/lib/agent.ts b/artifacts/api-server/src/lib/agent.ts index 950d5c9..d02b94c 100644 --- a/artifacts/api-server/src/lib/agent.ts +++ b/artifacts/api-server/src/lib/agent.ts @@ -7,6 +7,8 @@ export interface EvalResult { export interface WorkResult { result: string; + inputTokens: number; + outputTokens: number; } export interface AgentConfig { @@ -15,12 +17,12 @@ export interface AgentConfig { } export class AgentService { - private readonly evalModel: string; - private readonly workModel: string; + readonly evalModel: string; + readonly workModel: string; constructor(config?: AgentConfig) { - this.evalModel = config?.evalModel ?? "claude-haiku-4-5"; - this.workModel = config?.workModel ?? "claude-sonnet-4-6"; + this.evalModel = config?.evalModel ?? process.env.EVAL_MODEL ?? "claude-haiku-4-5"; + this.workModel = config?.workModel ?? process.env.WORK_MODEL ?? "claude-sonnet-4-6"; } async evaluateRequest(requestText: string): Promise { @@ -64,7 +66,11 @@ Fulfill it thoroughly and helpfully. Be concise yet complete.`, throw new Error("Unexpected non-text response from work model"); } - return { result: block.text }; + return { + result: block.text, + inputTokens: message.usage.input_tokens, + outputTokens: message.usage.output_tokens, + }; } } diff --git a/artifacts/api-server/src/lib/btc-oracle.ts b/artifacts/api-server/src/lib/btc-oracle.ts new file mode 100644 index 0000000..f4acf72 --- /dev/null +++ b/artifacts/api-server/src/lib/btc-oracle.ts @@ -0,0 +1,57 @@ +const COINGECKO_URL = + "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd"; + +interface CachedPrice { + price: number; + at: number; +} + +let cache: CachedPrice | null = null; +const CACHE_MS = 60_000; + +function fallbackPrice(): number { + const raw = parseFloat(process.env.BTC_PRICE_USD_FALLBACK ?? ""); + return Number.isFinite(raw) && raw > 0 ? raw : 100_000; +} + +/** + * Returns the current BTC/USD price. + * Caches for 60 s; falls back to BTC_PRICE_USD_FALLBACK env var (default $100,000) + * on network failure so the service keeps running without internet access. + */ +export async function getBtcPriceUsd(): Promise { + if (cache && Date.now() - cache.at < CACHE_MS) { + return cache.price; + } + + try { + const res = await fetch(COINGECKO_URL, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(5_000), + }); + + if (!res.ok) throw new Error(`CoinGecko HTTP ${res.status}`); + + const data = (await res.json()) as { bitcoin?: { usd?: number } }; + const price = data?.bitcoin?.usd; + if (!price || !Number.isFinite(price) || price <= 0) { + throw new Error(`Unexpected CoinGecko response: ${JSON.stringify(data)}`); + } + + cache = { price, at: Date.now() }; + return price; + } catch (err) { + const fb = fallbackPrice(); + console.warn(`[btc-oracle] Price fetch failed (using $${fb} fallback):`, err); + return fb; + } +} + +/** + * Convert a USD amount to satoshis at the given BTC/USD price. + * Always rounds up (ceil) so costs are fully covered; minimum 1 sat. + */ +export function usdToSats(usd: number, btcPriceUsd: number): number { + if (usd <= 0) return 1; + return Math.max(1, Math.ceil((usd / btcPriceUsd) * 1e8)); +} diff --git a/artifacts/api-server/src/lib/pricing.ts b/artifacts/api-server/src/lib/pricing.ts index 2e9718f..653452d 100644 --- a/artifacts/api-server/src/lib/pricing.ts +++ b/artifacts/api-server/src/lib/pricing.ts @@ -1,48 +1,162 @@ -export interface PricingConfig { - evalFeeSats?: number; - workFeeShortSats?: number; - workFeeMediumSats?: number; - workFeeLongSats?: number; - shortMaxChars?: number; - mediumMaxChars?: number; - bootstrapFeeSats?: number; +import { getBtcPriceUsd, usdToSats } from "./btc-oracle.js"; + +// ── Env-var helpers ──────────────────────────────────────────────────────────── + +function envFloat(name: string, fallback: number): number { + const raw = parseFloat(process.env[name] ?? ""); + return Number.isFinite(raw) && raw > 0 ? raw : fallback; } -export class PricingService { - private readonly evalFee: number; - private readonly workFeeShort: number; - private readonly workFeeMedium: number; - private readonly workFeeLong: number; - private readonly shortMax: number; - private readonly mediumMax: number; - private readonly bootstrapFee: number; +function envInt(name: string, fallback: number): number { + const raw = parseInt(process.env[name] ?? "", 10); + return Number.isFinite(raw) && raw > 0 ? raw : fallback; +} - constructor(config?: PricingConfig) { - this.evalFee = config?.evalFeeSats ?? 10; - this.workFeeShort = config?.workFeeShortSats ?? 50; - this.workFeeMedium = config?.workFeeMediumSats ?? 100; - this.workFeeLong = config?.workFeeLongSats ?? 250; - this.shortMax = config?.shortMaxChars ?? 100; - this.mediumMax = config?.mediumMaxChars ?? 300; - const rawFee = parseInt(process.env.BOOTSTRAP_FEE_SATS ?? "", 10); - this.bootstrapFee = - config?.bootstrapFeeSats ?? - (Number.isFinite(rawFee) && rawFee > 0 ? rawFee : 10_000); - } +// ── Model rate tables ────────────────────────────────────────────────────────── + +export interface ModelRates { + inputPer1kUsd: number; + outputPer1kUsd: number; +} + +/** + * Anthropic model rates (USD per 1 000 tokens). + * Defaults approximate claude-haiku-4-x / claude-sonnet-4-x pricing. + * Override via env vars. + */ +const HAIKU_RATES: ModelRates = { + inputPer1kUsd: envFloat("HAIKU_INPUT_COST_PER_1K_TOKENS", 0.0008), + outputPer1kUsd: envFloat("HAIKU_OUTPUT_COST_PER_1K_TOKENS", 0.004), +}; + +const SONNET_RATES: ModelRates = { + inputPer1kUsd: envFloat("SONNET_INPUT_COST_PER_1K_TOKENS", 0.003), + outputPer1kUsd: envFloat("SONNET_OUTPUT_COST_PER_1K_TOKENS", 0.015), +}; + +function ratesForModel(modelId: string): ModelRates { + const id = modelId.toLowerCase(); + if (id.includes("haiku")) return HAIKU_RATES; + if (id.includes("sonnet")) return SONNET_RATES; + return SONNET_RATES; // conservative fallback +} + +// ── Output token estimates by request length tier ───────────────────────────── + +const OUTPUT_EST_SHORT = envInt("OUTPUT_TOKENS_SHORT_EST", 200); +const OUTPUT_EST_MEDIUM = envInt("OUTPUT_TOKENS_MEDIUM_EST", 400); +const OUTPUT_EST_LONG = envInt("OUTPUT_TOKENS_LONG_EST", 800); +const SHORT_MAX_CHARS = envInt("SHORT_MAX_CHARS", 100); +const MEDIUM_MAX_CHARS = envInt("MEDIUM_MAX_CHARS", 300); + +// Approximate tokens in the work system prompt (for input token estimation). +const WORK_SYSTEM_PROMPT_TOKENS = envInt("WORK_SYSTEM_PROMPT_TOKENS_EST", 50); + +// ── DO infra amortisation ───────────────────────────────────────────────────── + +const DO_MONTHLY_COST_USD = envFloat("DO_MONTHLY_COST_USD", 100); +const DO_MONTHLY_REQUESTS = envInt("DO_MONTHLY_REQUEST_VOLUME", 1000); +const DO_INFRA_PER_REQUEST_USD = DO_MONTHLY_COST_USD / DO_MONTHLY_REQUESTS; + +// ── Originator margin ────────────────────────────────────────────────────────── + +const ORIGINATOR_MARGIN_PCT = envFloat("ORIGINATOR_MARGIN_PCT", 25); + +// ── Fixed fees ──────────────────────────────────────────────────────────────── + +const EVAL_FEE_SATS = envInt("EVAL_FEE_SATS", 10); + +const BOOTSTRAP_FEE_SATS = (() => { + const raw = parseInt(process.env.BOOTSTRAP_FEE_SATS ?? "", 10); + return Number.isFinite(raw) && raw > 0 ? raw : 10_000; +})(); + +// ── Public types ────────────────────────────────────────────────────────────── + +export interface WorkFeeBreakdown { + amountSats: number; + estimatedCostUsd: number; + marginPct: number; + btcPriceUsd: number; +} + +// ── PricingService ──────────────────────────────────────────────────────────── + +export class PricingService { + readonly marginPct = ORIGINATOR_MARGIN_PCT; + + // ── Fixed fees (unchanged from v1) ─────────────────────────────────────── calculateEvalFeeSats(): number { - return this.evalFee; - } - - calculateWorkFeeSats(requestText: string): number { - const len = requestText.trim().length; - if (len <= this.shortMax) return this.workFeeShort; - if (len <= this.mediumMax) return this.workFeeMedium; - return this.workFeeLong; + return EVAL_FEE_SATS; } calculateBootstrapFeeSats(): number { - return this.bootstrapFee; + return BOOTSTRAP_FEE_SATS; + } + + // ── Token estimation ───────────────────────────────────────────────────── + + /** + * Estimate input tokens for a work request. + * Uses chars/4 rule for the user message plus a fixed system-prompt overhead. + */ + estimateInputTokens(requestText: string): number { + return Math.ceil(requestText.length / 4) + WORK_SYSTEM_PROMPT_TOKENS; + } + + /** + * Estimate output tokens based on request length tier. + */ + estimateOutputTokens(requestText: string): number { + const len = requestText.trim().length; + if (len <= SHORT_MAX_CHARS) return OUTPUT_EST_SHORT; + if (len <= MEDIUM_MAX_CHARS) return OUTPUT_EST_MEDIUM; + return OUTPUT_EST_LONG; + } + + // ── Cost calculation (pure, no oracle) ────────────────────────────────── + + /** + * Calculate the total USD cost for a set of token counts + model. + * Includes DO infra amortisation and originator margin. + */ + calculateWorkFeeUsd(inputTokens: number, outputTokens: number, modelId: string): number { + const rates = ratesForModel(modelId); + const tokenCostUsd = + (inputTokens / 1000) * rates.inputPer1kUsd + + (outputTokens / 1000) * rates.outputPer1kUsd; + const rawCostUsd = tokenCostUsd + DO_INFRA_PER_REQUEST_USD; + return rawCostUsd * (1 + this.marginPct / 100); + } + + /** + * Calculate actual token cost (no infra, no margin — raw Anthropic spend). + * Used for the post-work cost ledger. + */ + calculateActualCostUsd(inputTokens: number, outputTokens: number, modelId: string): number { + const rates = ratesForModel(modelId); + return ( + (inputTokens / 1000) * rates.inputPer1kUsd + + (outputTokens / 1000) * rates.outputPer1kUsd + ); + } + + // ── Invoice amount (calls oracle) ──────────────────────────────────────── + + /** + * Fetch BTC price, convert USD cost to sats, and return the full breakdown. + * This is the main entry point for generating a work invoice amount. + */ + async calculateWorkFeeSats( + inputTokens: number, + outputTokens: number, + modelId: string, + ): Promise { + const estimatedCostUsd = this.calculateWorkFeeUsd(inputTokens, outputTokens, modelId); + const btcPriceUsd = await getBtcPriceUsd(); + const amountSats = usdToSats(estimatedCostUsd, btcPriceUsd); + return { amountSats, estimatedCostUsd, marginPct: this.marginPct, btcPriceUsd }; } } diff --git a/artifacts/api-server/src/routes/jobs.ts b/artifacts/api-server/src/routes/jobs.ts index 2572ae2..f3b0464 100644 --- a/artifacts/api-server/src/routes/jobs.ts +++ b/artifacts/api-server/src/routes/jobs.ts @@ -50,9 +50,17 @@ async function advanceJob(job: Job): Promise { const evalResult = await agentService.evaluateRequest(job.request); if (evalResult.accepted) { - const workFee = pricingService.calculateWorkFeeSats(job.request); + // Cost-based work fee: estimate tokens → fetch BTC price → invoice sats + const inputEst = pricingService.estimateInputTokens(job.request); + const outputEst = pricingService.estimateOutputTokens(job.request); + const breakdown = await pricingService.calculateWorkFeeSats( + inputEst, + outputEst, + agentService.workModel, + ); + const workInvoiceData = await lnbitsService.createInvoice( - workFee, + breakdown.amountSats, `Work fee for job ${job.id}`, ); const workInvoiceId = randomUUID(); @@ -63,7 +71,7 @@ async function advanceJob(job: Job): Promise { jobId: job.id, paymentHash: workInvoiceData.paymentHash, paymentRequest: workInvoiceData.paymentRequest, - amountSats: workFee, + amountSats: breakdown.amountSats, type: "work", paid: false, }); @@ -72,7 +80,10 @@ async function advanceJob(job: Job): Promise { .set({ state: "awaiting_work_payment", workInvoiceId, - workAmountSats: workFee, + workAmountSats: breakdown.amountSats, + estimatedCostUsd: breakdown.estimatedCostUsd, + marginPct: breakdown.marginPct, + btcPriceUsd: breakdown.btcPriceUsd, updatedAt: new Date(), }) .where(eq(jobs.id, job.id)); @@ -118,9 +129,22 @@ async function advanceJob(job: Job): Promise { try { const workResult = await agentService.executeWork(job.request); + const actualCostUsd = pricingService.calculateActualCostUsd( + workResult.inputTokens, + workResult.outputTokens, + agentService.workModel, + ); + await db .update(jobs) - .set({ state: "complete", result: workResult.result, updatedAt: new Date() }) + .set({ + state: "complete", + result: workResult.result, + actualInputTokens: workResult.inputTokens, + actualOutputTokens: workResult.outputTokens, + actualCostUsd, + updatedAt: new Date(), + }) .where(eq(jobs.id, job.id)); } catch (err) { const message = err instanceof Error ? err.message : "Execution error"; @@ -228,6 +252,7 @@ router.get("/jobs/:id", async (req: Request, res: Response) => { }); break; } + case "awaiting_work_payment": { const inv = job.workInvoiceId ? await getInvoiceById(job.workInvoiceId) : null; res.json({ @@ -239,18 +264,44 @@ router.get("/jobs/:id", async (req: Request, res: Response) => { ...(lnbitsService.stubMode ? { paymentHash: inv.paymentHash } : {}), }, } : {}), + // Cost breakdown so callers understand the invoice amount + ...(job.estimatedCostUsd != null ? { + pricingBreakdown: { + estimatedCostUsd: job.estimatedCostUsd, + marginPct: job.marginPct, + btcPriceUsd: job.btcPriceUsd, + }, + } : {}), }); break; } + case "rejected": res.json({ ...base, reason: job.rejectionReason ?? undefined }); break; + case "complete": - res.json({ ...base, result: job.result ?? undefined }); + res.json({ + ...base, + result: job.result ?? undefined, + // Actual token usage + cost ledger + ...(job.actualCostUsd != null ? { + costLedger: { + actualInputTokens: job.actualInputTokens, + actualOutputTokens: job.actualOutputTokens, + actualCostUsd: job.actualCostUsd, + estimatedCostUsd: job.estimatedCostUsd, + marginPct: job.marginPct, + btcPriceUsd: job.btcPriceUsd, + }, + } : {}), + }); break; + case "failed": res.json({ ...base, errorMessage: job.errorMessage ?? undefined }); break; + default: res.json(base); } diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 21fefdd..e8e89e5 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -181,6 +181,36 @@ components: - executing - complete - failed + PricingBreakdown: + type: object + description: Cost breakdown shown with the work invoice (estimations at invoice-creation time) + properties: + estimatedCostUsd: + type: number + description: Total estimated cost in USD (token cost + DO infra + margin) + marginPct: + type: number + description: Originator margin percentage applied + btcPriceUsd: + type: number + description: BTC/USD spot price used to convert the invoice to sats + CostLedger: + type: object + description: Actual cost record stored after the job completes + properties: + actualInputTokens: + type: integer + actualOutputTokens: + type: integer + actualCostUsd: + type: number + description: Raw Anthropic token cost (no infra, no margin) + estimatedCostUsd: + type: number + marginPct: + type: number + btcPriceUsd: + type: number JobStatusResponse: type: object required: @@ -195,10 +225,14 @@ components: $ref: "#/components/schemas/InvoiceInfo" workInvoice: $ref: "#/components/schemas/InvoiceInfo" + pricingBreakdown: + $ref: "#/components/schemas/PricingBreakdown" reason: type: string result: type: string + costLedger: + $ref: "#/components/schemas/CostLedger" errorMessage: type: string DemoResponse: diff --git a/lib/db/src/schema/jobs.ts b/lib/db/src/schema/jobs.ts index 1819c45..161bbb9 100644 --- a/lib/db/src/schema/jobs.ts +++ b/lib/db/src/schema/jobs.ts @@ -1,4 +1,4 @@ -import { pgTable, text, timestamp, integer } from "drizzle-orm/pg-core"; +import { pgTable, text, timestamp, integer, real } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { z } from "zod/v4"; @@ -25,6 +25,17 @@ export const jobs = pgTable("jobs", { rejectionReason: text("rejection_reason"), result: text("result"), errorMessage: text("error_message"), + + // ── Cost-based pricing (set when work invoice is created) ─────────────── + estimatedCostUsd: real("estimated_cost_usd"), + marginPct: real("margin_pct"), + btcPriceUsd: real("btc_price_usd"), + + // ── Actual token usage (set after work executes) ──────────────────────── + actualInputTokens: integer("actual_input_tokens"), + actualOutputTokens: integer("actual_output_tokens"), + actualCostUsd: real("actual_cost_usd"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), }); diff --git a/replit.md b/replit.md index 021d392..dca698a 100644 --- a/replit.md +++ b/replit.md @@ -68,6 +68,31 @@ Every package extends `tsconfig.base.json` which sets `composite: true`. The roo > **Note:** If `LNBITS_URL` and `LNBITS_API_KEY` are absent, `LNbitsService` automatically runs in **stub mode** — invoices are simulated in-memory and can be marked paid via `svc.stubMarkPaid(hash)`. This is intentional for development without a Lightning node. +### Cost-based work fee pricing + +| Secret | Description | Default | +|---|---|---| +| `HAIKU_INPUT_COST_PER_1K_TOKENS` | Haiku input cost per 1K tokens (USD) | `0.0008` | +| `HAIKU_OUTPUT_COST_PER_1K_TOKENS` | Haiku output cost per 1K tokens (USD) | `0.004` | +| `SONNET_INPUT_COST_PER_1K_TOKENS` | Sonnet input cost per 1K tokens (USD) | `0.003` | +| `SONNET_OUTPUT_COST_PER_1K_TOKENS` | Sonnet output cost per 1K tokens (USD) | `0.015` | +| `DO_MONTHLY_COST_USD` | Monthly DO infra cost amortised per request | `100` | +| `DO_MONTHLY_REQUEST_VOLUME` | Expected monthly request volume (divisor) | `1000` | +| `ORIGINATOR_MARGIN_PCT` | Margin percentage on top of cost | `25` | +| `OUTPUT_TOKENS_SHORT_EST` | Estimated output tokens for short requests | `200` | +| `OUTPUT_TOKENS_MEDIUM_EST` | Estimated output tokens for medium requests | `400` | +| `OUTPUT_TOKENS_LONG_EST` | Estimated output tokens for long requests | `800` | +| `WORK_SYSTEM_PROMPT_TOKENS_EST` | Work model system-prompt size in tokens | `50` | +| `SHORT_MAX_CHARS` | Max chars for "short" request tier | `100` | +| `MEDIUM_MAX_CHARS` | Max chars for "medium" request tier | `300` | +| `EVAL_FEE_SATS` | Fixed eval invoice amount | `10` | +| `BTC_PRICE_USD_FALLBACK` | BTC/USD price fallback if CoinGecko is unreachable | `100000` | +| `EVAL_MODEL` | Anthropic model used for evaluation | `claude-haiku-4-5` | +| `WORK_MODEL` | Anthropic model used for work execution | `claude-sonnet-4-6` | + +Work fee flow: estimate tokens → fetch BTC price from CoinGecko (60s cache) → `(token_cost + DO_infra) × (1 + margin%)` → convert USD → sats. +After work runs, actual token counts and raw Anthropic spend are stored in `jobs` as `actual_input_tokens`, `actual_output_tokens`, `actual_cost_usd`. + ### Node bootstrap secrets (for `POST /api/bootstrap`) | Secret | Description | Default |