Task #6: Cost-based work fee pricing with BTC oracle

- btc-oracle.ts: CoinGecko BTC/USD fetch (60s cache), usdToSats() helper,
  fallback to BTC_PRICE_USD_FALLBACK env var (default $100k), 5s abort timeout
- pricing.ts: Full rewrite — per-model token rates (Haiku/Sonnet, env-var
  overridable), DO infra amortisation, originator margin %, estimateInputTokens(),
  estimateOutputTokens() by request tier, calculateActualCostUsd() for post-work ledger,
  async calculateWorkFeeSats() → WorkFeeBreakdown
- agent.ts: WorkResult now includes inputTokens + outputTokens from Anthropic usage;
  workModel/evalModel exposed as readonly public; EVAL_MODEL/WORK_MODEL env var support
- jobs.ts: Work invoice creation calls pricingService.calculateWorkFeeSats() async;
  stores estimatedCostUsd/marginPct/btcPriceUsd on job; after executeWork stores
  actualInputTokens/actualOutputTokens/actualCostUsd; GET response includes
  pricingBreakdown (awaiting_work_payment) and costLedger (complete)
- lib/db/src/schema/jobs.ts: 6 new real/integer columns for cost tracking; schema pushed
- openapi.yaml: PricingBreakdown + CostLedger schemas added to JobStatusResponse
- replit.md: 17 new env vars documented in Cost-based work fee pricing section
This commit is contained in:
alexpaynex
2026-03-18 19:20:34 +00:00
parent bc78bfa452
commit 69eba6190d
7 changed files with 347 additions and 49 deletions

View File

@@ -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<EvalResult> {
@@ -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,
};
}
}

View File

@@ -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<number> {
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));
}

View File

@@ -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<WorkFeeBreakdown> {
const estimatedCostUsd = this.calculateWorkFeeUsd(inputTokens, outputTokens, modelId);
const btcPriceUsd = await getBtcPriceUsd();
const amountSats = usdToSats(estimatedCostUsd, btcPriceUsd);
return { amountSats, estimatedCostUsd, marginPct: this.marginPct, btcPriceUsd };
}
}

View File

@@ -50,9 +50,17 @@ async function advanceJob(job: Job): Promise<Job | null> {
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<Job | null> {
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<Job | null> {
.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<Job | null> {
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);
}

View File

@@ -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:

View File

@@ -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(),
});

View File

@@ -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 |