@suluk/cost 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +32 -0
- package/src/contract.ts +91 -0
- package/src/index.ts +17 -0
- package/src/ledger.ts +37 -0
- package/src/meter.ts +65 -0
- package/src/types.ts +59 -0
- package/test/cost.test.ts +105 -0
- package/tsconfig.json +1 -0
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@suluk/cost",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cost as a contract facet: declare per-operation cost (incl. third-party usage), bubble it into the v4 doc/Scalar/tests, and meter the ACTUAL per-user cost at runtime (frontend action -> operation -> third-party). Display as-is. CANDIDATE tooling.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@suluk/core": "0.1.0"
|
|
12
|
+
},
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"hono": "^4.0.0"
|
|
15
|
+
},
|
|
16
|
+
"peerDependenciesMeta": {
|
|
17
|
+
"hono": {
|
|
18
|
+
"optional": true
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/bun": "latest",
|
|
23
|
+
"hono": "^4.0.0",
|
|
24
|
+
"@suluk/hono": "0.1.0",
|
|
25
|
+
"@suluk/openapi-compat": "0.1.0",
|
|
26
|
+
"zod": "^4.4.3"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "bun test",
|
|
30
|
+
"typecheck": "tsc --noEmit -p ."
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/contract.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cost as a CONTRACT FACET. A cost model is attached to each operation as the `x-suluk-cost` vendor
|
|
3
|
+
* extension on its Request — so it bubbles like everything else: it survives the 3.1 downgrade (3.1 keeps
|
|
4
|
+
* `x-*`), Scalar/Swagger render it, and a coverage audit can flag operations that never declared a cost.
|
|
5
|
+
* The contract tells you what an operation *should* cost; the runtime meter tells you what it *did*.
|
|
6
|
+
*/
|
|
7
|
+
import type { OpenAPIv4Document, PathItem, Request } from "@suluk/core";
|
|
8
|
+
import type { CostModel, UsageReport } from "./types";
|
|
9
|
+
|
|
10
|
+
export const COST_EXT = "x-suluk-cost";
|
|
11
|
+
|
|
12
|
+
/** Annotate a v4 document in place-safe (returns a new doc): set x-suluk-cost on each named operation. */
|
|
13
|
+
export function annotateCosts(doc: OpenAPIv4Document, costs: Record<string, CostModel>): OpenAPIv4Document {
|
|
14
|
+
const out: OpenAPIv4Document = structuredClone(doc);
|
|
15
|
+
for (const pi of Object.values(out.paths ?? {})) {
|
|
16
|
+
for (const [name, req] of Object.entries((pi as PathItem).requests ?? {})) {
|
|
17
|
+
const model = costs[name];
|
|
18
|
+
if (model) (req as Request & Record<string, unknown>)[COST_EXT] = model;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Read the cost model declared on an operation (if any). */
|
|
25
|
+
export function costOf(req: Request): CostModel | undefined {
|
|
26
|
+
return (req as Request & Record<string, unknown>)[COST_EXT] as CostModel | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CostFinding {
|
|
30
|
+
code: "no-cost-model" | "zero-cost";
|
|
31
|
+
severity: "warn" | "info";
|
|
32
|
+
path: string;
|
|
33
|
+
operation: string;
|
|
34
|
+
message: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Cost-coverage audit: which operations have NOT declared what they cost. This is the same ceiling-side
|
|
39
|
+
* discipline as the documentation audit — an undeclared cost is a blind spot, surfaced, never assumed zero.
|
|
40
|
+
*/
|
|
41
|
+
export function costAudit(doc: OpenAPIv4Document): CostFinding[] {
|
|
42
|
+
const findings: CostFinding[] = [];
|
|
43
|
+
for (const [path, piRaw] of Object.entries(doc.paths ?? {})) {
|
|
44
|
+
for (const [name, reqRaw] of Object.entries((piRaw as PathItem).requests ?? {})) {
|
|
45
|
+
const model = costOf(reqRaw as Request);
|
|
46
|
+
if (!model) {
|
|
47
|
+
findings.push({ code: "no-cost-model", severity: "warn", path, operation: name, message: "operation declares no cost — its cost to you is unknown (not assumed zero)" });
|
|
48
|
+
} else if (!model.components.length) {
|
|
49
|
+
findings.push({ code: "zero-cost", severity: "info", path, operation: name, message: "operation declares an empty cost model (explicitly free)" });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return findings;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The declared costs across the document, for display (the cockpit/admin show this raw). */
|
|
57
|
+
export function costTable(doc: OpenAPIv4Document): { operation: string; path: string; estimateMicroUsd: number; sources: string[] }[] {
|
|
58
|
+
const rows: { operation: string; path: string; estimateMicroUsd: number; sources: string[] }[] = [];
|
|
59
|
+
for (const [path, piRaw] of Object.entries(doc.paths ?? {})) {
|
|
60
|
+
for (const [name, reqRaw] of Object.entries((piRaw as PathItem).requests ?? {})) {
|
|
61
|
+
const model = costOf(reqRaw as Request);
|
|
62
|
+
if (!model) continue;
|
|
63
|
+
const fixed = model.components.filter((c) => c.basis === "per-call").reduce((s, c) => s + c.microUsd, 0);
|
|
64
|
+
rows.push({ operation: name, path, estimateMicroUsd: model.estimateMicroUsd ?? fixed, sources: [...new Set(model.components.map((c) => c.source))] });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return rows;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Compute the actual µ$ a request cost, from its declared model + the usage the handler reported. Fixed
|
|
72
|
+
* (per-call) components always count; variable components count their reported units × unit cost. Returns
|
|
73
|
+
* the per-source breakdown + total — raw, for the meter to record.
|
|
74
|
+
*/
|
|
75
|
+
export function computeCost(model: CostModel | undefined, usage: UsageReport[] = []): { breakdown: { source: string; microUsd: number }[]; totalMicroUsd: number } {
|
|
76
|
+
const bySource = new Map<string, number>();
|
|
77
|
+
const add = (source: string, micro: number) => bySource.set(source, (bySource.get(source) ?? 0) + micro);
|
|
78
|
+
const usageBySource = new Map<string, number>();
|
|
79
|
+
for (const u of usage) usageBySource.set(u.source, (usageBySource.get(u.source) ?? 0) + u.units);
|
|
80
|
+
|
|
81
|
+
for (const c of model?.components ?? []) {
|
|
82
|
+
if (c.basis === "per-call") add(c.source, c.microUsd);
|
|
83
|
+
else {
|
|
84
|
+
const units = usageBySource.get(c.source) ?? 0;
|
|
85
|
+
const per = c.basis === "per-1k-tokens" ? c.microUsd / 1000 : c.microUsd;
|
|
86
|
+
add(c.source, Math.round(per * units));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const breakdown = [...bySource.entries()].map(([source, microUsd]) => ({ source, microUsd }));
|
|
90
|
+
return { breakdown, totalMicroUsd: breakdown.reduce((s, b) => s + b.microUsd, 0) };
|
|
91
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @suluk/cost — cost as a contract facet + runtime metering. You can't price a user without knowing what
|
|
3
|
+
* they cost you. So: declare per-operation cost (incl. third-party usage) on the contract — it bubbles into
|
|
4
|
+
* the v4 doc, Scalar, and the audit; meter the ACTUAL cost per request at runtime, traced from the frontend
|
|
5
|
+
* action down to each third party; and read the raw per-user picture from the ledger. We display the data as
|
|
6
|
+
* it is and let you build pricing on top (Stripe via @suluk/stripe). CANDIDATE tooling — NOT official OAS.
|
|
7
|
+
*/
|
|
8
|
+
export {
|
|
9
|
+
type CostBasis, type CostComponent, type CostModel, type UsageReport, type CostEvent, formatMicroUsd,
|
|
10
|
+
} from "./types";
|
|
11
|
+
export {
|
|
12
|
+
COST_EXT, annotateCosts, costOf, costAudit, costTable, computeCost, type CostFinding,
|
|
13
|
+
} from "./contract";
|
|
14
|
+
export {
|
|
15
|
+
costMeter, recordUsage, MemoryCostSink, type CostSink, type CostMeterOptions,
|
|
16
|
+
} from "./meter";
|
|
17
|
+
export { summarize, principalCost, type CostSummary } from "./ledger";
|
package/src/ledger.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ledger — aggregate cost events into the raw picture: total, and breakdowns by principal (what each
|
|
3
|
+
* USER cost you), by operation, by frontend action, and by source (which third party). We display these AS
|
|
4
|
+
* THEY ARE; pricing, margins, and limits are something the consumer builds on top — not something we impose.
|
|
5
|
+
*/
|
|
6
|
+
import type { CostEvent } from "./types";
|
|
7
|
+
|
|
8
|
+
export interface CostSummary {
|
|
9
|
+
total: number;
|
|
10
|
+
count: number;
|
|
11
|
+
byPrincipal: Record<string, number>;
|
|
12
|
+
byOperation: Record<string, number>;
|
|
13
|
+
byAction: Record<string, number>;
|
|
14
|
+
bySource: Record<string, number>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function bump(rec: Record<string, number>, key: string | undefined, micro: number): void {
|
|
18
|
+
if (!key) return;
|
|
19
|
+
rec[key] = (rec[key] ?? 0) + micro;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function summarize(events: CostEvent[]): CostSummary {
|
|
23
|
+
const s: CostSummary = { total: 0, count: events.length, byPrincipal: {}, byOperation: {}, byAction: {}, bySource: {} };
|
|
24
|
+
for (const e of events) {
|
|
25
|
+
s.total += e.totalMicroUsd;
|
|
26
|
+
bump(s.byPrincipal, e.principal, e.totalMicroUsd);
|
|
27
|
+
bump(s.byOperation, e.operation, e.totalMicroUsd);
|
|
28
|
+
bump(s.byAction, e.action, e.totalMicroUsd);
|
|
29
|
+
for (const b of e.breakdown) bump(s.bySource, b.source, b.microUsd);
|
|
30
|
+
}
|
|
31
|
+
return s;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What ONE principal cost you (the question that lets you price them) — and the trace by operation + action. */
|
|
35
|
+
export function principalCost(events: CostEvent[], principal: string): CostSummary {
|
|
36
|
+
return summarize(events.filter((e) => e.principal === principal));
|
|
37
|
+
}
|
package/src/meter.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime meter — records what each request ACTUALLY cost, attributed all the way down: the frontend
|
|
3
|
+
* action (a header the client sets) → the operation → the per-source breakdown (declared model + the
|
|
4
|
+
* third-party usage the handler reported). Records go to a pluggable sink; we display them raw.
|
|
5
|
+
*/
|
|
6
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
7
|
+
import type { CostModel, CostEvent, UsageReport } from "./types";
|
|
8
|
+
import { computeCost } from "./contract";
|
|
9
|
+
|
|
10
|
+
export interface CostSink {
|
|
11
|
+
record(event: CostEvent): void | Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A simple in-memory sink (for the demo / tests). Production swaps in D1, a queue, etc. */
|
|
15
|
+
export class MemoryCostSink implements CostSink {
|
|
16
|
+
private _events: CostEvent[] = [];
|
|
17
|
+
record(e: CostEvent): void { this._events.push(e); }
|
|
18
|
+
events(): CostEvent[] { return this._events.slice(); }
|
|
19
|
+
clear(): void { this._events = []; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const USAGE_KEY = "suluk-cost-usage";
|
|
23
|
+
|
|
24
|
+
/** A handler calls this to report MEASURED third-party usage for the current request (e.g. tokens used). */
|
|
25
|
+
export function recordUsage(c: Context, source: string, units: number): void {
|
|
26
|
+
const arr = (c.get(USAGE_KEY) as UsageReport[] | undefined) ?? [];
|
|
27
|
+
arr.push({ source, units });
|
|
28
|
+
c.set(USAGE_KEY, arr);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CostMeterOptions {
|
|
32
|
+
sink: CostSink;
|
|
33
|
+
/** operation name → its declared cost model. */
|
|
34
|
+
costs: Record<string, CostModel>;
|
|
35
|
+
/** Resolve the operation name for a request (e.g. c.get("operation"), or a matcher). */
|
|
36
|
+
operationOf: (c: Context) => string | undefined;
|
|
37
|
+
/** Resolve the principal/user id (default: none). */
|
|
38
|
+
principalOf?: (c: Context) => string | undefined;
|
|
39
|
+
/** Header carrying the frontend action id (default "x-suluk-action"). */
|
|
40
|
+
actionHeader?: string;
|
|
41
|
+
/** Wall-clock now (ms). Pass `() => Date.now()` in production; a fixed fn in tests for reproducibility. */
|
|
42
|
+
now?: () => number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Hono middleware: after the handler runs, record what the request cost (declared model + reported usage). */
|
|
46
|
+
export function costMeter(opts: CostMeterOptions): MiddlewareHandler {
|
|
47
|
+
const actionHeader = opts.actionHeader ?? "x-suluk-action";
|
|
48
|
+
const now = opts.now ?? (() => Date.now());
|
|
49
|
+
return async (c, next) => {
|
|
50
|
+
await next();
|
|
51
|
+
const operation = opts.operationOf(c);
|
|
52
|
+
if (!operation) return;
|
|
53
|
+
const usage = (c.get(USAGE_KEY) as UsageReport[] | undefined) ?? [];
|
|
54
|
+
const { breakdown, totalMicroUsd } = computeCost(opts.costs[operation], usage);
|
|
55
|
+
const event: CostEvent = {
|
|
56
|
+
at: now(),
|
|
57
|
+
operation,
|
|
58
|
+
principal: opts.principalOf?.(c),
|
|
59
|
+
action: c.req.header(actionHeader) || undefined,
|
|
60
|
+
breakdown,
|
|
61
|
+
totalMicroUsd,
|
|
62
|
+
};
|
|
63
|
+
await opts.sink.record(event);
|
|
64
|
+
};
|
|
65
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cost model — what an operation declares it costs you, and what a single request actually cost.
|
|
3
|
+
*
|
|
4
|
+
* All money is integer **micro-USD** (1 USD = 1_000_000 µ$). Integers avoid float drift and are the rawest
|
|
5
|
+
* possible representation — we display the data AS IT IS and let consumers build pricing on top. A cost has
|
|
6
|
+
* COMPONENTS, each tied to a source (a third party, compute, egress, …) and a basis (per-call vs per-unit),
|
|
7
|
+
* so the actual cost of a request is the fixed components plus the metered usage of the variable ones.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type CostBasis =
|
|
11
|
+
| "per-call" // a flat cost each time the operation runs
|
|
12
|
+
| "per-unit" // generic metered unit (you report how many)
|
|
13
|
+
| "per-token"
|
|
14
|
+
| "per-1k-tokens"
|
|
15
|
+
| "per-second"
|
|
16
|
+
| "per-request" // to a third party (e.g. one downstream API call)
|
|
17
|
+
| "per-mb";
|
|
18
|
+
|
|
19
|
+
export interface CostComponent {
|
|
20
|
+
/** Where the money goes: "openai", "compute", "egress", "twilio", … (free-form, your taxonomy). */
|
|
21
|
+
source: string;
|
|
22
|
+
basis: CostBasis;
|
|
23
|
+
/** Cost per one unit of `basis`, in micro-USD. */
|
|
24
|
+
microUsd: number;
|
|
25
|
+
description?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CostModel {
|
|
29
|
+
components: CostComponent[];
|
|
30
|
+
/** Optional typical total for one call (µ$), for display + tests when usage isn't yet known. */
|
|
31
|
+
estimateMicroUsd?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A measured usage report for one variable component during a request (e.g. {source:"openai", units: 1350}). */
|
|
35
|
+
export interface UsageReport {
|
|
36
|
+
source: string;
|
|
37
|
+
units: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What a single request actually cost — the rawest record, attributed all the way down. */
|
|
41
|
+
export interface CostEvent {
|
|
42
|
+
/** Wall-clock ms (an input, never read ambiently — pass it in, so events are reproducible/testable). */
|
|
43
|
+
at: number;
|
|
44
|
+
/** Who incurred it (the principal/user id), if known. */
|
|
45
|
+
principal?: string;
|
|
46
|
+
/** Which operation (the v4 by-name handle). */
|
|
47
|
+
operation: string;
|
|
48
|
+
/** The frontend action that triggered it (a button-click id), if the client tagged the request. */
|
|
49
|
+
action?: string;
|
|
50
|
+
/** Per-source breakdown (µ$). */
|
|
51
|
+
breakdown: { source: string; microUsd: number }[];
|
|
52
|
+
/** Total µ$ for the request. */
|
|
53
|
+
totalMicroUsd: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Format micro-USD as a display string (we store raw integers; this is only for humans). */
|
|
57
|
+
export function formatMicroUsd(microUsd: number): string {
|
|
58
|
+
return `$${(microUsd / 1_000_000).toFixed(6).replace(/0+$/, "").replace(/\.$/, "")}`;
|
|
59
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
import { emitV4 } from "@suluk/hono";
|
|
5
|
+
import { validateDocument } from "@suluk/core";
|
|
6
|
+
import { downgrade, validate31 } from "@suluk/openapi-compat";
|
|
7
|
+
import {
|
|
8
|
+
annotateCosts, costOf, costAudit, costTable, computeCost, COST_EXT,
|
|
9
|
+
costMeter, recordUsage, MemoryCostSink, summarize, principalCost, formatMicroUsd,
|
|
10
|
+
type CostModel,
|
|
11
|
+
} from "../src/index";
|
|
12
|
+
|
|
13
|
+
const ASK: CostModel = {
|
|
14
|
+
components: [
|
|
15
|
+
{ source: "compute", basis: "per-call", microUsd: 50 },
|
|
16
|
+
{ source: "openai", basis: "per-1k-tokens", microUsd: 2000, description: "$0.002 / 1k tokens" },
|
|
17
|
+
],
|
|
18
|
+
estimateMicroUsd: 1050,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe("computeCost — fixed + metered, raw µ$", () => {
|
|
22
|
+
test("per-call component always counts; per-1k-tokens scales with reported usage", () => {
|
|
23
|
+
const r = computeCost(ASK, [{ source: "openai", units: 1500 }]);
|
|
24
|
+
// compute: 50 ; openai: 2000/1000 * 1500 = 3000
|
|
25
|
+
expect(r.totalMicroUsd).toBe(3050);
|
|
26
|
+
expect(r.breakdown).toEqual([{ source: "compute", microUsd: 50 }, { source: "openai", microUsd: 3000 }]);
|
|
27
|
+
});
|
|
28
|
+
test("no usage → only the fixed components", () => {
|
|
29
|
+
expect(computeCost(ASK).totalMicroUsd).toBe(50);
|
|
30
|
+
});
|
|
31
|
+
test("formatMicroUsd shows it as it is", () => {
|
|
32
|
+
expect(formatMicroUsd(3050)).toBe("$0.00305");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("cost as a contract facet — bubbles into v4, Scalar, and the audit", () => {
|
|
37
|
+
const { document } = emitV4([
|
|
38
|
+
{ method: "post", path: "/ask", name: "ask", summary: "Ask the model", request: { json: z.object({ q: z.string() }) }, responses: [{ status: 200, description: "ok", schema: z.object({ answer: z.string() }) }] },
|
|
39
|
+
{ method: "get", path: "/ping", name: "ping", summary: "Ping", responses: [{ status: 200, description: "ok" }] },
|
|
40
|
+
], { info: { title: "AI", version: "1" } });
|
|
41
|
+
const annotated = annotateCosts(document, { ask: ASK });
|
|
42
|
+
|
|
43
|
+
test("x-suluk-cost lands on the operation and the doc still validates", () => {
|
|
44
|
+
const req = (annotated.paths["ask"].requests.ask as unknown as Record<string, unknown>)[COST_EXT];
|
|
45
|
+
expect(req).toEqual(ASK as unknown as Record<string, unknown>);
|
|
46
|
+
expect(validateDocument(annotated).valid).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
test("it survives the 3.1 downgrade — so Scalar/Swagger show it", () => {
|
|
49
|
+
const down = downgrade(annotated).document as any;
|
|
50
|
+
expect(validate31(down).valid).toBe(true);
|
|
51
|
+
expect(down.paths["/ask"].post[COST_EXT]).toEqual(ASK);
|
|
52
|
+
});
|
|
53
|
+
test("costAudit flags operations with NO declared cost (not assumed zero)", () => {
|
|
54
|
+
const findings = costAudit(annotated);
|
|
55
|
+
expect(findings.map((f) => f.operation)).toContain("ping"); // ping declared no cost
|
|
56
|
+
expect(findings.map((f) => f.operation)).not.toContain("ask");
|
|
57
|
+
});
|
|
58
|
+
test("costTable surfaces the declared costs for display", () => {
|
|
59
|
+
const rows = costTable(annotated);
|
|
60
|
+
expect(rows.find((r) => r.operation === "ask")!.sources.sort()).toEqual(["compute", "openai"]);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("runtime meter — actual cost, traced from the frontend action down", () => {
|
|
65
|
+
test("records what a request cost, attributed to principal + action + source", async () => {
|
|
66
|
+
const sink = new MemoryCostSink();
|
|
67
|
+
const app = new Hono<{ Variables: { operation: string; principal: string } }>();
|
|
68
|
+
app.use("*", async (c, next) => { c.set("operation", "ask"); c.set("principal", "user_42"); await next(); });
|
|
69
|
+
app.use("*", costMeter({
|
|
70
|
+
sink, costs: { ask: ASK },
|
|
71
|
+
operationOf: (c) => (c as { get(k: string): string }).get("operation"),
|
|
72
|
+
principalOf: (c) => (c as { get(k: string): string }).get("principal"),
|
|
73
|
+
now: () => 1000,
|
|
74
|
+
}));
|
|
75
|
+
app.post("/ask", (c) => { recordUsage(c, "openai", 2000); return c.json({ answer: "42" }); });
|
|
76
|
+
|
|
77
|
+
await app.request("/ask", { method: "POST", headers: { "content-type": "application/json", "x-suluk-action": "ask-button" }, body: "{}" });
|
|
78
|
+
const events = sink.events();
|
|
79
|
+
expect(events.length).toBe(1);
|
|
80
|
+
const e = events[0];
|
|
81
|
+
expect(e.operation).toBe("ask");
|
|
82
|
+
expect(e.principal).toBe("user_42");
|
|
83
|
+
expect(e.action).toBe("ask-button"); // traced from the frontend button
|
|
84
|
+
expect(e.totalMicroUsd).toBe(50 + 4000); // compute 50 + openai 2000/1000*2000
|
|
85
|
+
expect(e.at).toBe(1000);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("ledger — the raw per-user picture you price on", () => {
|
|
90
|
+
const events = [
|
|
91
|
+
{ at: 1, principal: "a", operation: "ask", action: "btn1", breakdown: [{ source: "openai", microUsd: 3000 }], totalMicroUsd: 3000 },
|
|
92
|
+
{ at: 2, principal: "a", operation: "ask", action: "btn2", breakdown: [{ source: "openai", microUsd: 1000 }], totalMicroUsd: 1000 },
|
|
93
|
+
{ at: 3, principal: "b", operation: "list", action: "btn1", breakdown: [{ source: "compute", microUsd: 50 }], totalMicroUsd: 50 },
|
|
94
|
+
];
|
|
95
|
+
test("summarize aggregates by principal / operation / action / source", () => {
|
|
96
|
+
const s = summarize(events);
|
|
97
|
+
expect(s.total).toBe(4050);
|
|
98
|
+
expect(s.byPrincipal).toEqual({ a: 4000, b: 50 });
|
|
99
|
+
expect(s.bySource).toEqual({ openai: 4000, compute: 50 });
|
|
100
|
+
expect(s.byAction.btn1).toBe(3050);
|
|
101
|
+
});
|
|
102
|
+
test("principalCost answers 'what did user a cost me?'", () => {
|
|
103
|
+
expect(principalCost(events, "a").total).toBe(4000);
|
|
104
|
+
});
|
|
105
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["bun"] }, "include": ["src", "test"] }
|