Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions workers/api/src/agent-do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,23 @@ export type {
const MAX_CONTEXT_MESSAGES = 10;

export class AgentDO extends DurableObject<Env> {
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,
);
}

Expand Down Expand Up @@ -316,7 +322,7 @@ export class AgentDO extends DurableObject<Env> {
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
Expand Down
34 changes: 34 additions & 0 deletions workers/api/src/agent-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 ────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down
25 changes: 25 additions & 0 deletions workers/api/src/lib/ai-pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
52 changes: 52 additions & 0 deletions workers/api/src/lib/platform-usage.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
44 changes: 42 additions & 2 deletions workers/api/src/lib/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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<void> {
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)
// ---------------------------------------------------------------------------
Expand Down
4 changes: 3 additions & 1 deletion workers/api/src/routes/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
20 changes: 11 additions & 9 deletions workers/api/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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.",
},
});
});
11 changes: 11 additions & 0 deletions workers/api/src/routes/instances-translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down