From 59bd9c7b0e7308c8310b831acd78de9f87a0190b Mon Sep 17 00:00:00 2001 From: Serge Ivo Date: Sat, 1 Aug 2026 09:15:11 +1000 Subject: [PATCH] feat(admin): meter platform-paid AI into the ledger (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the platform's own AI spend visible + attributable (closes the loop on "the platform silently pays for AI"). Platform env.AI calls now write ai_usage rows tagged provider="platform", so /v1/admin/spending's split is real. - lib/ai-pricing.ts: estimatePlatformCostMicros() + approxTokens() — a rough, clearly-labeled Workers-AI cost estimate (authoritative neuron spend = CF billing actuals, #45). - lib/usage.ts: recordPlatformUsage() (provider="platform"); UsageKind gains "embedding" | "summary". - agent-storage.ts: optional EngineMeter (6th ctor arg, default null → zero churn to the 30 existing call sites). embed() + generateSummary() ledger when a meter with a userId is present. - agent-do.ts: getStorageEngine(agentId, userId?) builds a meter from env.DB + the acting user; wired at the chat handler (recurring RAG-embed + summary spend). Other call sites unmetered (unchanged). - instances-translation.ts: platform translate path ledgers kind="translate". - /v1/admin/spending: platformPaid.metered=true, estimated=true. Coverage: chat-time embeds/summaries + translation. Background ingest/repo embeds not yet metered (documented). Tests: pricing + recordPlatformUsage (6) + updated spending assertions; full API suite green (655). Co-Authored-By: Claude Opus 4.8 (1M context) --- workers/api/src/agent-do.ts | 10 +++- workers/api/src/agent-storage.ts | 34 ++++++++++++ workers/api/src/lib/ai-pricing.ts | 25 +++++++++ workers/api/src/lib/platform-usage.test.ts | 52 +++++++++++++++++++ workers/api/src/lib/usage.ts | 44 +++++++++++++++- workers/api/src/routes/admin.test.ts | 4 +- workers/api/src/routes/admin.ts | 20 +++---- .../api/src/routes/instances-translation.ts | 11 ++++ 8 files changed, 186 insertions(+), 14 deletions(-) create mode 100644 workers/api/src/lib/platform-usage.test.ts diff --git a/workers/api/src/agent-do.ts b/workers/api/src/agent-do.ts index 13b12b01..817dcc44 100644 --- a/workers/api/src/agent-do.ts +++ b/workers/api/src/agent-do.ts @@ -65,17 +65,23 @@ export type { const MAX_CONTEXT_MESSAGES = 10; export class AgentDO extends DurableObject { - private getStorageEngine(agentId: string): AgentStorageEngine { + private getStorageEngine(agentId: string, userId?: string): AgentStorageEngine { // Platform-paid internal AI (embeddings + summary) is gated behind one master // switch. Off (default) → pass null AI, so embed/summary no-op and the platform // never spends tokens (BYOK-only). LLM chat is BYOK regardless of this flag. const platformAi = this.env.PLATFORM_AI_ENABLED === "true" ? this.env.AI || null : null; + // When the acting user is known, meter platform-paid embeds/summaries into the + // ai_usage ledger (provider="platform") so operator spend is visible (issue #44). + const meter = platformAi && userId + ? { db: this.env.DB, userId, agentId } + : null; return new AgentStorageEngine( this.ctx.storage, this.env.STORAGE || null, this.env.VECTORIZE || null, platformAi, agentId, + meter, ); } @@ -316,7 +322,7 @@ export class AgentDO extends DurableObject { await this.appendMessage(userMsg); this.broadcast({ type: "message", message: userMsg }); - const engine = this.getStorageEngine(state.agentId); + const engine = this.getStorageEngine(state.agentId, userId); await engine.logEvent("chat.message", userId, { messageId: userMsg.id }); // Run agent loop diff --git a/workers/api/src/agent-storage.ts b/workers/api/src/agent-storage.ts index 15d923cb..7a8168c3 100644 --- a/workers/api/src/agent-storage.ts +++ b/workers/api/src/agent-storage.ts @@ -22,6 +22,21 @@ import type { } from "./agent-storage-types.js"; import type { AgentMessage, KnowledgeDoc, MemoryEntry } from "./agent-types.js"; import { chunkText, deleteKeysBatched, encodeIndexValue, extractFileText, shortId, validateRecord } from "./agent-storage-utils.js"; +import { approxTokens } from "./lib/ai-pricing.js"; +import { recordPlatformUsage } from "./lib/usage.js"; + +/** + * Optional metering hook (issue #44): when present, platform-paid Workers-AI calls + * (embeddings + conversation summaries) run inside this engine are ledgered as + * provider="platform" so operator spend is visible + attributable. Absent (most + * call sites) → no ledger, same as before. + */ +export interface EngineMeter { + db: D1Database; + userId?: string; + agentId?: string | null; + instanceId?: string | null; +} const MAX_EVENTS = 500; const SUMMARY_THRESHOLD = 20; @@ -36,6 +51,7 @@ export class AgentStorageEngine { private vectorize: VectorizeIndex | null, private ai: Ai | null, private agentId: string, + private meter: EngineMeter | null = null, ) {} // ── Vector Storage ──────────────────────────────────────────────────────── @@ -345,6 +361,15 @@ export class AgentStorageEngine { const result = await this.ai.run("@cf/baai/bge-base-en-v1.5", { text: [text], }); + // Platform-paid: ledger the embedding (issue #44). Embeddings have no output + // tokens; input is estimated from text length (Workers AI returns no usage). + if (this.meter?.userId) { + await recordPlatformUsage( + { DB: this.meter.db }, + { userId: this.meter.userId, agentId: this.meter.agentId, instanceId: this.meter.instanceId, model: "@cf/baai/bge-base-en-v1.5", kind: "embedding" }, + { input: approxTokens(text.length), output: 0 }, + ); + } return (result as { data: number[][] }).data?.[0] || null; } catch { return null; @@ -1001,6 +1026,15 @@ Extract key facts about the user, their preferences, decisions made, and informa })) as { response?: string }; const text = result.response || ""; + // Platform-paid: ledger the summary LLM call (issue #44). Tokens estimated + // from transcript in / response out (Workers AI returns no usage here). + if (this.meter?.userId) { + await recordPlatformUsage( + { DB: this.meter.db }, + { userId: this.meter.userId, agentId: this.meter.agentId, instanceId: this.meter.instanceId, model, kind: "summary" }, + { input: approxTokens(transcript.length), output: approxTokens(text.length) }, + ); + } const jsonMatch = text.match(/\{[\s\S]*\}/); if (!jsonMatch) return null; diff --git a/workers/api/src/lib/ai-pricing.ts b/workers/api/src/lib/ai-pricing.ts index 81dc4f54..56e3ec2e 100644 --- a/workers/api/src/lib/ai-pricing.ts +++ b/workers/api/src/lib/ai-pricing.ts @@ -110,3 +110,28 @@ export function formatUsd(micros: number): string { if (usd < 0.01) return `<$0.01`; return `$${usd.toFixed(2)}`; } + +// ── Platform-paid Workers AI (issue #44) ────────────────────────────────── +// Internal AI the PLATFORM pays for (embeddings/summaries/translation on env.AI) +// when PLATFORM_AI_ENABLED. Cloudflare bills Workers AI per *neuron*, not per token, +// and per-model neuron rates vary widely — so this is a deliberately ROUGH, nominal +// placeholder so platform spend reads as a non-zero, order-of-magnitude number that +// is attributable per user/kind. The AUTHORITATIVE figure comes from Cloudflare +// billing actuals (issue #45); UI/API label these as estimates. +export const PLATFORM_CF_PRICE: ModelPrice = { inputPerM: 0.1, outputPerM: 0.3 }; + +/** Estimated platform Workers-AI cost (micros USD) for a call. See PLATFORM_CF_PRICE. */ +export function estimatePlatformCostMicros( + inputTokens: number | null | undefined, + outputTokens: number | null | undefined, +): number { + const inTok = Math.max(0, Math.floor(Number(inputTokens) || 0)); + const outTok = Math.max(0, Math.floor(Number(outputTokens) || 0)); + return Math.round(inTok * PLATFORM_CF_PRICE.inputPerM + outTok * PLATFORM_CF_PRICE.outputPerM); +} + +/** Rough token count from character length (~4 chars/token) — for call sites that + * don't get usage back from Workers AI (embeddings, CF llama translate). */ +export function approxTokens(chars: number | null | undefined): number { + return Math.ceil(Math.max(0, Number(chars) || 0) / 4); +} diff --git a/workers/api/src/lib/platform-usage.test.ts b/workers/api/src/lib/platform-usage.test.ts new file mode 100644 index 00000000..7fa9258f --- /dev/null +++ b/workers/api/src/lib/platform-usage.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; +import { approxTokens, estimatePlatformCostMicros, PLATFORM_CF_PRICE } from "./ai-pricing.js"; +import { recordPlatformUsage } from "./usage.js"; + +describe("platform cost estimation (issue #44)", () => { + it("approxTokens is ~4 chars/token, floors at 0", () => { + expect(approxTokens(400)).toBe(100); + expect(approxTokens(0)).toBe(0); + expect(approxTokens(-5)).toBe(0); + expect(approxTokens(3)).toBe(1); // rounds up + }); + + it("estimatePlatformCostMicros uses the platform rate, never negative", () => { + expect(estimatePlatformCostMicros(1_000_000, 0)).toBe(PLATFORM_CF_PRICE.inputPerM * 1_000_000); + expect(estimatePlatformCostMicros(0, 1_000_000)).toBe(PLATFORM_CF_PRICE.outputPerM * 1_000_000); + expect(estimatePlatformCostMicros(-5, -5)).toBe(0); + }); +}); + +describe("recordPlatformUsage", () => { + function mockDb() { + const run = vi.fn(async () => ({})); + const bind = vi.fn(() => ({ run })); + const prepare = vi.fn(() => ({ bind })); + return { db: { DB: { prepare } as any }, prepare, bind, run }; + } + + it("inserts a provider=platform row with an estimated cost", async () => { + const m = mockDb(); + await recordPlatformUsage( + m.db, + { userId: "u1", instanceId: "i1", model: "@cf/baai/bge-base-en-v1.5", kind: "embedding" }, + { input: 100, output: 0 }, + ); + expect(m.prepare).toHaveBeenCalledOnce(); + expect(m.prepare.mock.calls[0][0]).toContain("'platform'"); + // binds: id, userId, agentId, instanceId, model, kind, input, output, cost + const args = m.bind.mock.calls[0]; + expect(args[1]).toBe("u1"); + expect(args[3]).toBe("i1"); + expect(args[5]).toBe("embedding"); + expect(args[6]).toBe(100); + expect(args[8]).toBe(estimatePlatformCostMicros(100, 0)); + }); + + it("no-ops with no user or no tokens", async () => { + const m = mockDb(); + await recordPlatformUsage(m.db, { userId: undefined, model: "x", kind: "summary" }, { input: 5, output: 5 }); + await recordPlatformUsage(m.db, { userId: "u1", model: "x", kind: "summary" }, { input: 0, output: 0 }); + expect(m.prepare).not.toHaveBeenCalled(); + }); +}); diff --git a/workers/api/src/lib/usage.ts b/workers/api/src/lib/usage.ts index c400fb7e..111498b0 100644 --- a/workers/api/src/lib/usage.ts +++ b/workers/api/src/lib/usage.ts @@ -2,7 +2,7 @@ // it for the Usage page. Recording is best-effort — a ledger write must never // break or slow an actual chat/apply/coding call. -import { estimateCostMicros } from "./ai-pricing.js"; +import { estimateCostMicros, estimatePlatformCostMicros } from "./ai-pricing.js"; export type UsageKind = | "chat" @@ -13,7 +13,10 @@ export type UsageKind = | "run" | "resume" | "translate" - | "voice"; + | "voice" + // Platform-paid internal AI (issue #44), billed to the platform, not BYOK. + | "embedding" + | "summary"; /** What a call site knows about the call. provider+model+userId are filled in by * the AI layer (it knows the real model actually used), so callers pass only the @@ -97,6 +100,43 @@ export async function recordVoiceUsage( } } +/** + * Ledger a PLATFORM-PAID Workers-AI call (issue #44) — provider "platform" so the + * admin split can separate it from BYOK. Cost is the rough platform estimate + * (authoritative = CF billing actuals, issue #45). Best-effort like recordUsage. + */ +export async function recordPlatformUsage( + env: { DB: D1Database }, + args: { userId: string | undefined; instanceId?: string | null; agentId?: string | null; model: string; kind: UsageKind }, + usage: UsageTokens | null | undefined, +): Promise { + try { + if (!args.userId || !usage) return; + const input = Math.max(0, Math.floor(Number(usage.input) || 0)); + const output = Math.max(0, Math.floor(Number(usage.output) || 0)); + if (input === 0 && output === 0) return; + const cost = estimatePlatformCostMicros(input, output); + await env.DB.prepare( + `INSERT INTO ai_usage (id, user_id, agent_id, instance_id, provider, model, kind, input_tokens, output_tokens, cost_micros, created_at) + VALUES (?1, ?2, ?3, ?4, 'platform', ?5, ?6, ?7, ?8, ?9, datetime('now'))`, + ) + .bind( + crypto.randomUUID(), + args.userId, + args.agentId ?? null, + args.instanceId ?? null, + args.model, + args.kind, + input, + output, + cost, + ) + .run(); + } catch { + /* observability, never load-bearing */ + } +} + // --------------------------------------------------------------------------- // Aggregation (pure — unit-tested against fixture rows) // --------------------------------------------------------------------------- diff --git a/workers/api/src/routes/admin.test.ts b/workers/api/src/routes/admin.test.ts index 3977c5b5..45e09a24 100644 --- a/workers/api/src/routes/admin.test.ts +++ b/workers/api/src/routes/admin.test.ts @@ -142,7 +142,9 @@ describe("GET /v1/admin/spending", () => { const body = (await res.json()) as any; expect(body.byok.costMicros).toBe(10500); expect(body.platformAiEnabled).toBe(true); - expect(body.platformPaid.metered).toBe(false); + expect(body.platformPaid.metered).toBe(true); + expect(body.platformPaid.estimated).toBe(true); + expect(body.platformPaid.calls).toBe(1); // the platform embedding row expect(body.topSpenders[0].label).toBe("alice"); }); }); diff --git a/workers/api/src/routes/admin.ts b/workers/api/src/routes/admin.ts index 2639efd0..0a5fb575 100644 --- a/workers/api/src/routes/admin.ts +++ b/workers/api/src/routes/admin.ts @@ -100,13 +100,14 @@ adminRoutes.get("/usage", async (c) => { * GET /v1/admin/spending?range=30d — the money view: BYOK spend (real, estimated * from tokens) + top spenders/models + trend, plus the platform-paid picture. * - * IMPORTANT: platform-paid AI (embeddings / summaries / translation run on the - * platform's Workers AI when PLATFORM_AI_ENABLED) is NOT fully metered into the - * ledger yet — only rows tagged provider="platform" are counted. Until the - * write-path metering + Cloudflare billing-actuals integration land (see the - * follow-up issues), `platformPaid.metered` is false and the authoritative number - * for platform Workers-AI spend is the Cloudflare dashboard. `platformAiEnabled` - * reports whether the platform is currently allowed to pay for internal AI. + * Platform-paid AI (embeddings / summaries / translation on the platform's Workers + * AI when PLATFORM_AI_ENABLED) IS now metered into the ledger as provider="platform" + * (issue #44) — but cost is a ROUGH estimate: Cloudflare bills Workers AI per neuron + * and we estimate from token counts (issue #45 wires the authoritative CF billing + * actuals). So `platformPaid.metered` is true but `platformPaid.estimated` is also + * true. Coverage is chat-time embeds/summaries + translation; background ingest + * embeds aren't metered yet. `platformAiEnabled` reports whether the platform is + * currently allowed to pay for internal AI. */ adminRoutes.get("/spending", async (c) => { await requireAdmin(c); @@ -123,8 +124,9 @@ adminRoutes.get("/spending", async (c) => { platformAiEnabled: c.env.PLATFORM_AI_ENABLED === "true", platformPaid: { ...s.split.platformPaid, - metered: false, - note: "Platform-paid Workers AI (embeddings/summaries/translation) is not yet fully metered into the ledger; see the CF dashboard for authoritative neuron spend.", + metered: true, + estimated: true, + note: "Platform-paid Workers AI (chat embeddings/summaries + translation) is metered as provider=platform; cost is a rough token-based estimate (authoritative neuron spend = CF billing actuals, issue #45). Background ingest embeds not yet metered.", }, }); }); diff --git a/workers/api/src/routes/instances-translation.ts b/workers/api/src/routes/instances-translation.ts index d742b9d1..e817c349 100644 --- a/workers/api/src/routes/instances-translation.ts +++ b/workers/api/src/routes/instances-translation.ts @@ -8,6 +8,8 @@ import type { Hono } from "hono"; import { HttpError, requireUser } from "../lib/auth.js"; import { runUserWorkersAi } from "../lib/user-ai.js"; +import { recordPlatformUsage } from "../lib/usage.js"; +import { approxTokens } from "../lib/ai-pricing.js"; import type { Env } from "../types.js"; import { readInstanceConfig } from "./instances-apply.js"; import { requireOwnedInstance } from "./instances-runtime.js"; @@ -245,6 +247,15 @@ export function registerTranslationRoutes(router: Hono<{ Bindings: Env }>): void )) as { response?: string }; raw = (r.response || "").trim(); viaPlatform = !!raw; + // Platform-paid: ledger the translation call (issue #44). Workers AI + // returns no usage, so tokens are estimated from prompt/response length. + if (viaPlatform) { + await recordPlatformUsage( + c.env, + { userId: session.uid, instanceId, model: "@cf/meta/llama-3.3-70b-instruct-fp8-fast", kind: "translate" }, + { input: approxTokens(JSON.stringify(messages).length), output: approxTokens(raw.length) }, + ); + } } catch { /* fall through to BYOK */ } } if (!raw) {