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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user