@bounded-sh/observe 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/README.md +167 -0
- package/dist/context.d.ts +18 -0
- package/dist/context.js +105 -0
- package/dist/counters.d.ts +34 -0
- package/dist/counters.js +141 -0
- package/dist/denylist.d.ts +7 -0
- package/dist/denylist.js +81 -0
- package/dist/envelope.d.ts +71 -0
- package/dist/envelope.js +20 -0
- package/dist/escort.d.ts +92 -0
- package/dist/escort.js +269 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +274 -0
- package/dist/intercept/fetch.d.ts +2 -0
- package/dist/intercept/fetch.js +534 -0
- package/dist/intercept/http.d.ts +2 -0
- package/dist/intercept/http.js +262 -0
- package/dist/llm-prices.d.ts +136 -0
- package/dist/llm-prices.js +240 -0
- package/dist/manifest.d.ts +168 -0
- package/dist/manifest.js +941 -0
- package/dist/pipeline.d.ts +18 -0
- package/dist/pipeline.js +212 -0
- package/dist/recognize.d.ts +38 -0
- package/dist/recognize.js +185 -0
- package/dist/register.d.ts +9 -0
- package/dist/register.js +138 -0
- package/dist/reporter.d.ts +27 -0
- package/dist/reporter.js +210 -0
- package/dist/shapes.d.ts +15 -0
- package/dist/shapes.js +56 -0
- package/dist/state.d.ts +32 -0
- package/dist/state.js +42 -0
- package/dist/template.d.ts +7 -0
- package/dist/template.js +63 -0
- package/dist/types.d.ts +125 -0
- package/dist/types.js +10 -0
- package/package.json +43 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// intercept/http.ts -- http/https.request + .get interception (SPEC §3.1a).
|
|
4
|
+
//
|
|
5
|
+
// Same invariants as the fetch interceptor: the original call ALWAYS goes
|
|
6
|
+
// through with its original semantics; observation failures are swallowed;
|
|
7
|
+
// X-Bounded-* headers are stripped from egress (options.headers in place —
|
|
8
|
+
// they must not leave the process — plus a setHeader guard on the returned
|
|
9
|
+
// ClientRequest); ingest self-traffic is skipped.
|
|
10
|
+
//
|
|
11
|
+
// Notes (documented): status/duration are recorded at response-HEADERS time;
|
|
12
|
+
// bytes.o comes from Content-Length (0 when chunked). Response bodies are
|
|
13
|
+
// never read on this path (no token counts via http/https — fetch-based SDKs
|
|
14
|
+
// get those), so recognized-field extraction here covers request bodies only.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.patchHttp = patchHttp;
|
|
18
|
+
exports.unpatchHttp = unpatchHttp;
|
|
19
|
+
const context_1 = require("../context");
|
|
20
|
+
const state_1 = require("../state");
|
|
21
|
+
const BOUNDED_HEADER_RE = /^x-bounded-/i;
|
|
22
|
+
const BODY_CAP = 32 * 1024;
|
|
23
|
+
const patchedModules = [];
|
|
24
|
+
function aliasHost(host) {
|
|
25
|
+
return state_1.state.config?.aliasHosts?.[host] ?? host;
|
|
26
|
+
}
|
|
27
|
+
function noteBoundedHeader(acc, name, value) {
|
|
28
|
+
const n = name.toLowerCase();
|
|
29
|
+
const v = Array.isArray(value) ? String(value[0]) : String(value);
|
|
30
|
+
if (n === "x-bounded-actor")
|
|
31
|
+
acc.id = v;
|
|
32
|
+
else if (n === "x-bounded-actor-kind")
|
|
33
|
+
acc.kind = v;
|
|
34
|
+
else if (n === "x-bounded-on-behalf-of")
|
|
35
|
+
acc.onBehalfOf = v;
|
|
36
|
+
}
|
|
37
|
+
function headerActorToContext(acc) {
|
|
38
|
+
if (!acc.id)
|
|
39
|
+
return undefined;
|
|
40
|
+
const kind = acc.kind === "human" || acc.kind === "agent" || acc.kind === "service" ? acc.kind : "unknown";
|
|
41
|
+
const ctx = { id: acc.id.slice(0, 256), kind };
|
|
42
|
+
if (acc.onBehalfOf)
|
|
43
|
+
ctx.onBehalfOf = acc.onBehalfOf.slice(0, 256);
|
|
44
|
+
return ctx;
|
|
45
|
+
}
|
|
46
|
+
/** Strip X-Bounded-* from an options.headers object IN PLACE (must not egress). */
|
|
47
|
+
function stripOptionHeaders(headers, acc) {
|
|
48
|
+
if (!headers || typeof headers !== "object")
|
|
49
|
+
return;
|
|
50
|
+
for (const k of Object.keys(headers)) {
|
|
51
|
+
if (BOUNDED_HEADER_RE.test(k)) {
|
|
52
|
+
noteBoundedHeader(acc, k, headers[k]);
|
|
53
|
+
delete headers[k];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function headerLookup(headers, name) {
|
|
58
|
+
if (!headers)
|
|
59
|
+
return undefined;
|
|
60
|
+
for (const k of Object.keys(headers)) {
|
|
61
|
+
if (k.toLowerCase() === name) {
|
|
62
|
+
const v = headers[k];
|
|
63
|
+
return Array.isArray(v) ? String(v[0]) : String(v);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
/** Normalize http.request's polymorphic args into capture metadata. */
|
|
69
|
+
function preprocess(args, defaultProtocol) {
|
|
70
|
+
let url;
|
|
71
|
+
let options;
|
|
72
|
+
const a0 = args[0];
|
|
73
|
+
if (typeof a0 === "string")
|
|
74
|
+
url = new URL(a0);
|
|
75
|
+
else if (a0 instanceof URL)
|
|
76
|
+
url = a0;
|
|
77
|
+
else if (a0 && typeof a0 === "object")
|
|
78
|
+
options = a0;
|
|
79
|
+
if (url && args[1] && typeof args[1] === "object" && typeof args[1] !== "function") {
|
|
80
|
+
options = args[1];
|
|
81
|
+
}
|
|
82
|
+
// Host resolution: options override url parts (Node merge semantics).
|
|
83
|
+
const hostname = options?.hostname ??
|
|
84
|
+
options?.host?.split(":")[0] ??
|
|
85
|
+
url?.hostname ??
|
|
86
|
+
"localhost";
|
|
87
|
+
const protocol = (options?.protocol ?? url?.protocol ?? defaultProtocol).toLowerCase();
|
|
88
|
+
const defaultPort = protocol === "https:" ? "443" : "80";
|
|
89
|
+
const port = String(options?.port ?? url?.port ?? "") || defaultPort;
|
|
90
|
+
const rawHost = port === defaultPort ? hostname : `${hostname}:${port}`;
|
|
91
|
+
const host = aliasHost(rawHost);
|
|
92
|
+
if (rawHost === state_1.state.ingestHost || host === state_1.state.ingestHost)
|
|
93
|
+
return undefined;
|
|
94
|
+
const fullPath = options?.path ?? (url ? url.pathname + url.search : "/");
|
|
95
|
+
const qIdx = fullPath.indexOf("?");
|
|
96
|
+
const path = qIdx === -1 ? fullPath : fullPath.slice(0, qIdx);
|
|
97
|
+
let queryNames;
|
|
98
|
+
if (qIdx !== -1) {
|
|
99
|
+
try {
|
|
100
|
+
queryNames = [...new URLSearchParams(fullPath.slice(qIdx + 1)).keys()].slice(0, 64);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
/* ignore */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const method = (options?.method ?? "GET").toUpperCase();
|
|
107
|
+
const acc = {};
|
|
108
|
+
const headers = options?.headers;
|
|
109
|
+
stripOptionHeaders(headers, acc);
|
|
110
|
+
const contentLength = Number(headerLookup(headers, "content-length"));
|
|
111
|
+
return {
|
|
112
|
+
host,
|
|
113
|
+
path,
|
|
114
|
+
queryNames,
|
|
115
|
+
method,
|
|
116
|
+
actor: (0, context_1.currentActor)() ?? headerActorToContext(acc),
|
|
117
|
+
contentType: headerLookup(headers, "content-type"),
|
|
118
|
+
bytesInHeader: Number.isFinite(contentLength) && contentLength > 0 ? contentLength : 0,
|
|
119
|
+
captureBody: method !== "GET" && method !== "HEAD" && state_1.state.manifest.captureHosts.has(host),
|
|
120
|
+
ts: Date.now(),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function instrument(req, meta) {
|
|
124
|
+
const start = performance.now();
|
|
125
|
+
let bodyText = "";
|
|
126
|
+
let bodyBytes = 0;
|
|
127
|
+
let finalized = false;
|
|
128
|
+
const chunkText = (chunk) => {
|
|
129
|
+
try {
|
|
130
|
+
if (typeof chunk === "string") {
|
|
131
|
+
bodyBytes += Buffer.byteLength(chunk);
|
|
132
|
+
if (meta.captureBody && bodyText.length < BODY_CAP)
|
|
133
|
+
bodyText += chunk.slice(0, BODY_CAP - bodyText.length);
|
|
134
|
+
}
|
|
135
|
+
else if (chunk instanceof Uint8Array) {
|
|
136
|
+
bodyBytes += chunk.byteLength;
|
|
137
|
+
if (meta.captureBody && bodyText.length < BODY_CAP) {
|
|
138
|
+
bodyText += Buffer.from(chunk.buffer, chunk.byteOffset, Math.min(chunk.byteLength, BODY_CAP - bodyText.length)).toString("utf8");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
/* body capture is best-effort */
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
// Wrap write/end to observe body bytes; pass EVERYTHING through untouched.
|
|
147
|
+
const origWrite = req.write.bind(req);
|
|
148
|
+
const origEnd = req.end.bind(req);
|
|
149
|
+
req.write = function boundedWrite(chunk, ...rest) {
|
|
150
|
+
chunkText(chunk);
|
|
151
|
+
return origWrite(chunk, ...rest);
|
|
152
|
+
};
|
|
153
|
+
req.end = function boundedEnd(chunk, ...rest) {
|
|
154
|
+
if (chunk !== undefined && typeof chunk !== "function")
|
|
155
|
+
chunkText(chunk);
|
|
156
|
+
return origEnd(chunk, ...rest);
|
|
157
|
+
};
|
|
158
|
+
// Late header injection guard: X-Bounded-* set after creation never egress
|
|
159
|
+
// (still used for attribution fallback).
|
|
160
|
+
const origSetHeader = req.setHeader.bind(req);
|
|
161
|
+
const lateActor = {};
|
|
162
|
+
req.setHeader = function boundedSetHeader(name, value) {
|
|
163
|
+
if (typeof name === "string" && BOUNDED_HEADER_RE.test(name)) {
|
|
164
|
+
noteBoundedHeader(lateActor, name, value);
|
|
165
|
+
if (!meta.actor)
|
|
166
|
+
meta.actor = headerActorToContext(lateActor);
|
|
167
|
+
return req;
|
|
168
|
+
}
|
|
169
|
+
return origSetHeader(name, value);
|
|
170
|
+
};
|
|
171
|
+
const finalize = (status, bytesOut) => {
|
|
172
|
+
if (finalized)
|
|
173
|
+
return;
|
|
174
|
+
finalized = true;
|
|
175
|
+
try {
|
|
176
|
+
const raw = {
|
|
177
|
+
ts: meta.ts,
|
|
178
|
+
host: meta.host,
|
|
179
|
+
path: meta.path,
|
|
180
|
+
queryNames: meta.queryNames,
|
|
181
|
+
method: meta.method,
|
|
182
|
+
status,
|
|
183
|
+
durMs: performance.now() - start,
|
|
184
|
+
bytesIn: meta.bytesInHeader > 0 ? meta.bytesInHeader : bodyBytes,
|
|
185
|
+
bytesOut,
|
|
186
|
+
actor: meta.actor,
|
|
187
|
+
reqBodyText: meta.captureBody && bodyText.length > 0 ? bodyText : undefined,
|
|
188
|
+
reqContentType: meta.contentType,
|
|
189
|
+
};
|
|
190
|
+
state_1.state.pipeline?.enqueueRaw(raw);
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
(0, state_1.noteInternalError)("http.finalize", err);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
req.on("response", (res) => {
|
|
197
|
+
try {
|
|
198
|
+
const cl = Number(res.headers["content-length"]);
|
|
199
|
+
finalize(res.statusCode ?? 0, Number.isFinite(cl) && cl > 0 ? cl : 0);
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
(0, state_1.noteInternalError)("http.response", err);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
req.on("error", () => finalize(0, 0));
|
|
206
|
+
}
|
|
207
|
+
function patchModule(mod, defaultProtocol) {
|
|
208
|
+
const asAny = mod;
|
|
209
|
+
if (asAny.__boundedPatched)
|
|
210
|
+
return;
|
|
211
|
+
const origRequest = mod.request;
|
|
212
|
+
const origGet = mod.get;
|
|
213
|
+
patchedModules.push({ mod, origRequest, origGet });
|
|
214
|
+
const patchedRequest = function boundedRequest(...args) {
|
|
215
|
+
if (!state_1.state.enabled)
|
|
216
|
+
return origRequest.apply(this, args);
|
|
217
|
+
let meta;
|
|
218
|
+
try {
|
|
219
|
+
meta = preprocess(args, defaultProtocol);
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
(0, state_1.noteInternalError)("http.preprocess", err);
|
|
223
|
+
meta = undefined;
|
|
224
|
+
}
|
|
225
|
+
const req = origRequest.apply(this, args);
|
|
226
|
+
if (meta) {
|
|
227
|
+
try {
|
|
228
|
+
instrument(req, meta);
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
(0, state_1.noteInternalError)("http.instrument", err);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return req;
|
|
235
|
+
};
|
|
236
|
+
// Node's http.get calls the module-local request, so it must be patched too.
|
|
237
|
+
const patchedGet = function boundedGet(...args) {
|
|
238
|
+
const req = patchedRequest.apply(this, args);
|
|
239
|
+
req.end();
|
|
240
|
+
return req;
|
|
241
|
+
};
|
|
242
|
+
asAny.request = patchedRequest;
|
|
243
|
+
asAny.get = patchedGet;
|
|
244
|
+
asAny.__boundedPatched = true;
|
|
245
|
+
}
|
|
246
|
+
function patchHttp() {
|
|
247
|
+
// Lazy require so the module graph stays clean for fetch-only runtimes.
|
|
248
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
249
|
+
const http = require("node:http");
|
|
250
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
251
|
+
const https = require("node:https");
|
|
252
|
+
patchModule(http, "http:");
|
|
253
|
+
patchModule(https, "https:");
|
|
254
|
+
}
|
|
255
|
+
function unpatchHttp() {
|
|
256
|
+
for (const p of patchedModules.splice(0)) {
|
|
257
|
+
const asAny = p.mod;
|
|
258
|
+
asAny.request = p.origRequest;
|
|
259
|
+
asAny.get = p.origGet;
|
|
260
|
+
delete asAny.__boundedPatched;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/** Bump on ANY edit to MODEL_PRICES OR the estimate formula (content version,
|
|
2
|
+
* mirrored byte-identically in org-api rails/llm-gateway.ts). */
|
|
3
|
+
export declare const PRICE_TABLE_VERSION = "llm-prices-2026-07-05b";
|
|
4
|
+
export interface ModelPrice {
|
|
5
|
+
/** USD per 1M input tokens. */
|
|
6
|
+
inputPerMTok: number;
|
|
7
|
+
/** USD per 1M output tokens. */
|
|
8
|
+
outputPerMTok: number;
|
|
9
|
+
/** Model max output tokens — the fallback ceiling when a request omits a cap. */
|
|
10
|
+
maxOutputTokens: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Per-model prices in USD per 1M tokens. Anthropic numbers are verified against
|
|
14
|
+
* the claude-api reference (cached 2026-05-26). OpenAI numbers are VERSIONED
|
|
15
|
+
* DATA present so a known OpenAI model resolves — OpenAI prices change often; do
|
|
16
|
+
* not trust them from memory, verify before prod. Any model NOT in this table
|
|
17
|
+
* resolves to the conservative ceiling (see resolveModelPrice) so estCents is
|
|
18
|
+
* always a real upper bound, never $0.
|
|
19
|
+
*/
|
|
20
|
+
export declare const MODEL_PRICES: Record<string, ModelPrice>;
|
|
21
|
+
/** The conservative ceiling used for an unrecognized model (fail-closed, T9). */
|
|
22
|
+
export declare const CEILING_PRICE: ModelPrice;
|
|
23
|
+
/** Coarse chars-per-token used to estimate INPUT tokens from the request body
|
|
24
|
+
* BYTE length only (never its content). Deliberately low so the estimate leans
|
|
25
|
+
* high (conservative). Output cost — the dominant, 5×-priced component — is
|
|
26
|
+
* hard-bounded by the request's own max-tokens cap, so the input coarseness is
|
|
27
|
+
* a minor fraction of the reservation. */
|
|
28
|
+
export declare const CHARS_PER_TOKEN = 3.5;
|
|
29
|
+
/** Floor for the coarse INPUT-token estimate: even a tiny request body reserves
|
|
30
|
+
* at least this many input tokens (a real call always carries a system prompt,
|
|
31
|
+
* tool schemas, and JSON framing the byte heuristic undercounts). Conservative —
|
|
32
|
+
* it only ever RAISES the estimate, never lowers it. */
|
|
33
|
+
export declare const MIN_INPUT_TOKENS = 512;
|
|
34
|
+
/** Prompt-cache WRITE multiplier applied to the ESTIMATE's input tokens: the most
|
|
35
|
+
* expensive way the actual input can be billed (cache creation ~1.25× base). So
|
|
36
|
+
* estCents stays an upper bound even when the call opts into cache writes. */
|
|
37
|
+
export declare const INPUT_CACHE_WRITE_MULT = 1.25;
|
|
38
|
+
export interface ResolvedPrice {
|
|
39
|
+
/** The table key matched (or the raw model when it fell through to ceiling). */
|
|
40
|
+
key: string;
|
|
41
|
+
price: ModelPrice;
|
|
42
|
+
/** false ⇒ unrecognized model, priced at the ceiling. */
|
|
43
|
+
known: boolean;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Resolve a model id to a price. Exact match wins; otherwise the LONGEST table
|
|
47
|
+
* key the model starts with (so "gpt-4o-2024-08-06" → "gpt-4o", a dated
|
|
48
|
+
* Anthropic snapshot → its base). Unrecognized ⇒ the conservative ceiling.
|
|
49
|
+
*/
|
|
50
|
+
export declare function resolveModelPrice(model: string | undefined): ResolvedPrice;
|
|
51
|
+
/** Cost in cents for `tokens` at `usdPerMTok` (USD/1M → cents): t·price/10000. */
|
|
52
|
+
export declare function centsForTokens(tokens: number, usdPerMTok: number): number;
|
|
53
|
+
export interface UpperBoundInput {
|
|
54
|
+
model: string | undefined;
|
|
55
|
+
/** The request's output-token cap; when absent/0 the model ceiling is used. */
|
|
56
|
+
maxTokensReq?: number;
|
|
57
|
+
/** Coarse input-token estimate (from body byte length). */
|
|
58
|
+
approxInputTokens?: number;
|
|
59
|
+
/** openai `n` / `best_of` — the provider bills output for EACH sampled
|
|
60
|
+
* completion, so the estimate multiplies the OUTPUT component by
|
|
61
|
+
* max(n, best_of, 1). Absent ⇒ 1 (anthropic messages has no such knob). */
|
|
62
|
+
n?: number;
|
|
63
|
+
}
|
|
64
|
+
export interface UpperBoundResult {
|
|
65
|
+
estCents: number;
|
|
66
|
+
/** Resolved table key (or the raw model when priced at the ceiling). */
|
|
67
|
+
model: string;
|
|
68
|
+
modelKnown: boolean;
|
|
69
|
+
/** The output-token count the estimate was priced against (per completion). */
|
|
70
|
+
outputTokensBound: number;
|
|
71
|
+
/** Completions the output cost was multiplied by (max(n, best_of, 1)). */
|
|
72
|
+
completions: number;
|
|
73
|
+
/** "known" (model priced from the table) or "ceiling" (unknown → conservative). */
|
|
74
|
+
basis: "known" | "ceiling";
|
|
75
|
+
/** Set when the model was unrecognized and priced at the ceiling (fail-closed). */
|
|
76
|
+
estFlag?: "unknown_model";
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* estCents = UPPER BOUND on the call's cost at verdict time. Composition:
|
|
80
|
+
* output = cap (or model ceiling) × max(n, best_of, 1) × output rate [HARD bound]
|
|
81
|
+
* input = max(bodyBytes÷3.5, MIN_INPUT_TOKENS) × 1.25 × input rate [heuristic]
|
|
82
|
+
* Math.ceil so it never rounds below a plausible actual; never < 1 cent for a
|
|
83
|
+
* real call (unknown model ⇒ ceiling, fail-closed). See the file header for
|
|
84
|
+
* exactly which inputs bound the estimate and which do not.
|
|
85
|
+
*/
|
|
86
|
+
export declare function estimateUpperBoundCents(input: UpperBoundInput): UpperBoundResult;
|
|
87
|
+
export interface UsageInput {
|
|
88
|
+
model: string | undefined;
|
|
89
|
+
inputTokens?: number;
|
|
90
|
+
outputTokens?: number;
|
|
91
|
+
/** Anthropic prompt-cache write tokens (billed ~1.25× input). */
|
|
92
|
+
cacheCreationInputTokens?: number;
|
|
93
|
+
/** Anthropic/OpenAI prompt-cache read tokens (billed ~0.1× input). */
|
|
94
|
+
cacheReadInputTokens?: number;
|
|
95
|
+
}
|
|
96
|
+
export interface ActualResult {
|
|
97
|
+
actualCents: number;
|
|
98
|
+
model: string;
|
|
99
|
+
modelKnown: boolean;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Read a provider RESPONSE body (OpenAI chat/completions or Anthropic messages
|
|
103
|
+
* shape) into a UsageInput for settle-time actual-cost. Model comes from the
|
|
104
|
+
* response's own `model` echo (both providers include it); when absent the
|
|
105
|
+
* actual is priced at the ceiling (conservative — org-api then keeps the lower
|
|
106
|
+
* reservation). Byte-for-byte mirror of org-api rails/llm-gateway.ts readUsage's
|
|
107
|
+
* token mapping. Returns undefined when there is no usage to price. */
|
|
108
|
+
export declare function usageFromResponse(body: unknown): UsageInput | undefined;
|
|
109
|
+
/**
|
|
110
|
+
* ACTUAL (settle-time) cost from a response's usage. Accounts for prompt-cache
|
|
111
|
+
* write (~1.25×) and read (~0.1×) tokens when present. Uses Math.round; for the
|
|
112
|
+
* common no-cache case actualCents ≤ estimateUpperBoundCents when the usage's
|
|
113
|
+
* tokens are within the verdict-time bounds (see the monotonicity test).
|
|
114
|
+
*/
|
|
115
|
+
export declare function actualCentsFromUsage(u: UsageInput): ActualResult;
|
|
116
|
+
/** Read the output-token cap for a route action from a parsed request body.
|
|
117
|
+
* OpenAI's cap is optional and inconsistently named; Anthropic requires it. */
|
|
118
|
+
export declare function extractMaxTokensReq(body: Record<string, unknown>, action: string): number | undefined;
|
|
119
|
+
/** Coarse INPUT-token estimate from the request body BYTE length (not content). */
|
|
120
|
+
export declare function approxInputTokensFromBody(bodyText: string | undefined): number;
|
|
121
|
+
/** Completion count the provider will bill output for: openai `n` (choices) and
|
|
122
|
+
* legacy `best_of` (samples). max(n, best_of, 1). Anthropic messages has no such
|
|
123
|
+
* knob → 1. Read from the request the app ALREADY built (metadata only). */
|
|
124
|
+
export declare function extractCompletions(body: Record<string, unknown>, action: string): number;
|
|
125
|
+
export interface LlmIntentFields {
|
|
126
|
+
model: string;
|
|
127
|
+
maxTokensReq: number;
|
|
128
|
+
estCents: number;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Build the llm-gateway intent fields {model, maxTokensReq, estCents} from the
|
|
132
|
+
* request the app ALREADY built (metadata only). Called by the shim escort at
|
|
133
|
+
* verdict time (escort.ts buildIntentFields) for rail === "llm-gateway".
|
|
134
|
+
* Returns undefined when there is no usable model (no spend to reserve).
|
|
135
|
+
*/
|
|
136
|
+
export declare function buildLlmIntentFields(body: Record<string, unknown> | undefined, action: string, bodyText: string | undefined): LlmIntentFields | undefined;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// llm-prices.ts -- the shim-side static price table + spend estimator for the
|
|
4
|
+
// llm-gateway rail (SPEC §4.3 V1 rail; Track C).
|
|
5
|
+
//
|
|
6
|
+
// This is a VERSIONED, content-addressed artifact (like a policyVersion): every
|
|
7
|
+
// edit MUST bump PRICE_TABLE_VERSION. A mirror copy lives in the enforcement
|
|
8
|
+
// worker at packages/cdk/cloudflare/bounded-org-api/src/rails/llm-gateway.ts —
|
|
9
|
+
// the two tables MUST stay byte-identical (same models, same numbers, same
|
|
10
|
+
// version string). Drift is a bug: the shim reserves estCents at verdict time
|
|
11
|
+
// and the worker reports actual usage-cents at settle; if the tables disagree,
|
|
12
|
+
// the settle "decrease" could become an increase.
|
|
13
|
+
//
|
|
14
|
+
// Determinism: pure integer/float math, no wall clock, no LLM (T3). The shim
|
|
15
|
+
// NEVER reads message content. What the estimate reads from the request the app
|
|
16
|
+
// ALREADY built: `model`, the output-token cap (max_tokens /
|
|
17
|
+
// max_completion_tokens / max_output_tokens), the completion count (openai
|
|
18
|
+
// `n` / `best_of`), and a COARSE input-token estimate from the request body
|
|
19
|
+
// BYTE LENGTH (never its content).
|
|
20
|
+
//
|
|
21
|
+
// WHAT estCents BOUNDS (be honest — SPEC T9):
|
|
22
|
+
// • OUTPUT cost is HARD-BOUNDED: cap (or the model's max-output ceiling when the
|
|
23
|
+
// request omits one) × max(n, best_of, 1) × the model's output rate. The
|
|
24
|
+
// provider cannot bill more output than the cap it was handed, per completion.
|
|
25
|
+
// This is the dominant, up-to-5×-priced component.
|
|
26
|
+
// • INPUT cost is a HEURISTIC upper estimate, NOT a hard bound: body-bytes ÷ 3.5
|
|
27
|
+
// (chars/token leans high), floored at MIN_INPUT_TOKENS so a tiny body still
|
|
28
|
+
// reserves realistic input, then priced at 1.25× the input rate (the prompt-
|
|
29
|
+
// cache WRITE worst case). This bounds the tokens IMPLIED BY THE REQUEST BODY;
|
|
30
|
+
// it does NOT bound input the provider adds server-side that is not in the body
|
|
31
|
+
// (e.g. a gateway-injected system prompt or retrieved tool results appended
|
|
32
|
+
// downstream). In the common case the reservation is dominated by the hard
|
|
33
|
+
// output bound.
|
|
34
|
+
// • Unknown model → the table CEILING (max rate across the table) + estFlag
|
|
35
|
+
// "unknown_model"; never $0 (fail-closed).
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
38
|
+
exports.INPUT_CACHE_WRITE_MULT = exports.MIN_INPUT_TOKENS = exports.CHARS_PER_TOKEN = exports.CEILING_PRICE = exports.MODEL_PRICES = exports.PRICE_TABLE_VERSION = void 0;
|
|
39
|
+
exports.resolveModelPrice = resolveModelPrice;
|
|
40
|
+
exports.centsForTokens = centsForTokens;
|
|
41
|
+
exports.estimateUpperBoundCents = estimateUpperBoundCents;
|
|
42
|
+
exports.usageFromResponse = usageFromResponse;
|
|
43
|
+
exports.actualCentsFromUsage = actualCentsFromUsage;
|
|
44
|
+
exports.extractMaxTokensReq = extractMaxTokensReq;
|
|
45
|
+
exports.approxInputTokensFromBody = approxInputTokensFromBody;
|
|
46
|
+
exports.extractCompletions = extractCompletions;
|
|
47
|
+
exports.buildLlmIntentFields = buildLlmIntentFields;
|
|
48
|
+
/** Bump on ANY edit to MODEL_PRICES OR the estimate formula (content version,
|
|
49
|
+
* mirrored byte-identically in org-api rails/llm-gateway.ts). */
|
|
50
|
+
exports.PRICE_TABLE_VERSION = "llm-prices-2026-07-05b";
|
|
51
|
+
/**
|
|
52
|
+
* Per-model prices in USD per 1M tokens. Anthropic numbers are verified against
|
|
53
|
+
* the claude-api reference (cached 2026-05-26). OpenAI numbers are VERSIONED
|
|
54
|
+
* DATA present so a known OpenAI model resolves — OpenAI prices change often; do
|
|
55
|
+
* not trust them from memory, verify before prod. Any model NOT in this table
|
|
56
|
+
* resolves to the conservative ceiling (see resolveModelPrice) so estCents is
|
|
57
|
+
* always a real upper bound, never $0.
|
|
58
|
+
*/
|
|
59
|
+
exports.MODEL_PRICES = {
|
|
60
|
+
// ── Anthropic (api.anthropic.com/v1/messages) ─────────────────────────────
|
|
61
|
+
"claude-fable-5": { inputPerMTok: 10, outputPerMTok: 50, maxOutputTokens: 128_000 },
|
|
62
|
+
"claude-opus-4-8": { inputPerMTok: 5, outputPerMTok: 25, maxOutputTokens: 128_000 },
|
|
63
|
+
"claude-opus-4-7": { inputPerMTok: 5, outputPerMTok: 25, maxOutputTokens: 128_000 },
|
|
64
|
+
"claude-opus-4-6": { inputPerMTok: 5, outputPerMTok: 25, maxOutputTokens: 128_000 },
|
|
65
|
+
"claude-opus-4-5": { inputPerMTok: 5, outputPerMTok: 25, maxOutputTokens: 64_000 },
|
|
66
|
+
"claude-sonnet-4-6": { inputPerMTok: 3, outputPerMTok: 15, maxOutputTokens: 64_000 },
|
|
67
|
+
"claude-haiku-4-5": { inputPerMTok: 1, outputPerMTok: 5, maxOutputTokens: 64_000 },
|
|
68
|
+
// ── OpenAI (api.openai.com) — VERSIONED DATA, verify before prod ───────────
|
|
69
|
+
"gpt-4o": { inputPerMTok: 2.5, outputPerMTok: 10, maxOutputTokens: 16_384 },
|
|
70
|
+
"gpt-4o-mini": { inputPerMTok: 0.15, outputPerMTok: 0.6, maxOutputTokens: 16_384 },
|
|
71
|
+
"gpt-4.1": { inputPerMTok: 2, outputPerMTok: 8, maxOutputTokens: 32_768 },
|
|
72
|
+
"gpt-4.1-mini": { inputPerMTok: 0.4, outputPerMTok: 1.6, maxOutputTokens: 32_768 },
|
|
73
|
+
};
|
|
74
|
+
/** The conservative ceiling used for an unrecognized model (fail-closed, T9). */
|
|
75
|
+
exports.CEILING_PRICE = {
|
|
76
|
+
inputPerMTok: Math.max(...Object.values(exports.MODEL_PRICES).map((p) => p.inputPerMTok)),
|
|
77
|
+
outputPerMTok: Math.max(...Object.values(exports.MODEL_PRICES).map((p) => p.outputPerMTok)),
|
|
78
|
+
maxOutputTokens: Math.max(...Object.values(exports.MODEL_PRICES).map((p) => p.maxOutputTokens)),
|
|
79
|
+
};
|
|
80
|
+
/** Coarse chars-per-token used to estimate INPUT tokens from the request body
|
|
81
|
+
* BYTE length only (never its content). Deliberately low so the estimate leans
|
|
82
|
+
* high (conservative). Output cost — the dominant, 5×-priced component — is
|
|
83
|
+
* hard-bounded by the request's own max-tokens cap, so the input coarseness is
|
|
84
|
+
* a minor fraction of the reservation. */
|
|
85
|
+
exports.CHARS_PER_TOKEN = 3.5;
|
|
86
|
+
/** Floor for the coarse INPUT-token estimate: even a tiny request body reserves
|
|
87
|
+
* at least this many input tokens (a real call always carries a system prompt,
|
|
88
|
+
* tool schemas, and JSON framing the byte heuristic undercounts). Conservative —
|
|
89
|
+
* it only ever RAISES the estimate, never lowers it. */
|
|
90
|
+
exports.MIN_INPUT_TOKENS = 512;
|
|
91
|
+
/** Prompt-cache WRITE multiplier applied to the ESTIMATE's input tokens: the most
|
|
92
|
+
* expensive way the actual input can be billed (cache creation ~1.25× base). So
|
|
93
|
+
* estCents stays an upper bound even when the call opts into cache writes. */
|
|
94
|
+
exports.INPUT_CACHE_WRITE_MULT = 1.25;
|
|
95
|
+
/**
|
|
96
|
+
* Resolve a model id to a price. Exact match wins; otherwise the LONGEST table
|
|
97
|
+
* key the model starts with (so "gpt-4o-2024-08-06" → "gpt-4o", a dated
|
|
98
|
+
* Anthropic snapshot → its base). Unrecognized ⇒ the conservative ceiling.
|
|
99
|
+
*/
|
|
100
|
+
function resolveModelPrice(model) {
|
|
101
|
+
const m = (model || "").trim();
|
|
102
|
+
if (m && Object.prototype.hasOwnProperty.call(exports.MODEL_PRICES, m)) {
|
|
103
|
+
return { key: m, price: exports.MODEL_PRICES[m], known: true };
|
|
104
|
+
}
|
|
105
|
+
let best = null;
|
|
106
|
+
for (const key of Object.keys(exports.MODEL_PRICES)) {
|
|
107
|
+
if (m.startsWith(key) && (best === null || key.length > best.length))
|
|
108
|
+
best = key;
|
|
109
|
+
}
|
|
110
|
+
if (best)
|
|
111
|
+
return { key: best, price: exports.MODEL_PRICES[best], known: true };
|
|
112
|
+
return { key: m || "unknown", price: exports.CEILING_PRICE, known: false };
|
|
113
|
+
}
|
|
114
|
+
/** Cost in cents for `tokens` at `usdPerMTok` (USD/1M → cents): t·price/10000. */
|
|
115
|
+
function centsForTokens(tokens, usdPerMTok) {
|
|
116
|
+
if (!Number.isFinite(tokens) || tokens <= 0)
|
|
117
|
+
return 0;
|
|
118
|
+
return (tokens * usdPerMTok) / 10_000;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* estCents = UPPER BOUND on the call's cost at verdict time. Composition:
|
|
122
|
+
* output = cap (or model ceiling) × max(n, best_of, 1) × output rate [HARD bound]
|
|
123
|
+
* input = max(bodyBytes÷3.5, MIN_INPUT_TOKENS) × 1.25 × input rate [heuristic]
|
|
124
|
+
* Math.ceil so it never rounds below a plausible actual; never < 1 cent for a
|
|
125
|
+
* real call (unknown model ⇒ ceiling, fail-closed). See the file header for
|
|
126
|
+
* exactly which inputs bound the estimate and which do not.
|
|
127
|
+
*/
|
|
128
|
+
function estimateUpperBoundCents(input) {
|
|
129
|
+
const { key, price, known } = resolveModelPrice(input.model);
|
|
130
|
+
const cap = input.maxTokensReq && input.maxTokensReq > 0 ? Math.round(input.maxTokensReq) : price.maxOutputTokens;
|
|
131
|
+
// Coarse input estimate, floored so a tiny body still reserves realistic input.
|
|
132
|
+
const approx = input.approxInputTokens && input.approxInputTokens > 0 ? Math.round(input.approxInputTokens) : 0;
|
|
133
|
+
const inTokens = Math.max(approx, exports.MIN_INPUT_TOKENS);
|
|
134
|
+
// Output bills per sampled completion (openai n / best_of); >= 1.
|
|
135
|
+
const completions = Math.max(1, Math.round(input.n && input.n > 0 ? input.n : 1));
|
|
136
|
+
const cents = centsForTokens(inTokens, price.inputPerMTok * exports.INPUT_CACHE_WRITE_MULT) +
|
|
137
|
+
centsForTokens(cap, price.outputPerMTok) * completions;
|
|
138
|
+
const estCents = Math.max(1, Math.ceil(cents));
|
|
139
|
+
return {
|
|
140
|
+
estCents,
|
|
141
|
+
model: key,
|
|
142
|
+
modelKnown: known,
|
|
143
|
+
outputTokensBound: cap,
|
|
144
|
+
completions,
|
|
145
|
+
basis: known ? "known" : "ceiling",
|
|
146
|
+
...(known ? {} : { estFlag: "unknown_model" }),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Read a provider RESPONSE body (OpenAI chat/completions or Anthropic messages
|
|
151
|
+
* shape) into a UsageInput for settle-time actual-cost. Model comes from the
|
|
152
|
+
* response's own `model` echo (both providers include it); when absent the
|
|
153
|
+
* actual is priced at the ceiling (conservative — org-api then keeps the lower
|
|
154
|
+
* reservation). Byte-for-byte mirror of org-api rails/llm-gateway.ts readUsage's
|
|
155
|
+
* token mapping. Returns undefined when there is no usage to price. */
|
|
156
|
+
function usageFromResponse(body) {
|
|
157
|
+
if (!body || typeof body !== "object")
|
|
158
|
+
return undefined;
|
|
159
|
+
const b = body;
|
|
160
|
+
const u = b.usage && typeof b.usage === "object" ? b.usage : undefined;
|
|
161
|
+
if (!u)
|
|
162
|
+
return undefined;
|
|
163
|
+
const n = (v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
|
|
164
|
+
const details = (v) => v && typeof v === "object" ? n(v.cached_tokens) : undefined;
|
|
165
|
+
return {
|
|
166
|
+
model: typeof b.model === "string" ? b.model : undefined,
|
|
167
|
+
// Anthropic: input_tokens/output_tokens; OpenAI: prompt_tokens/completion_tokens.
|
|
168
|
+
inputTokens: n(u.input_tokens) ?? n(u.prompt_tokens),
|
|
169
|
+
outputTokens: n(u.output_tokens) ?? n(u.completion_tokens),
|
|
170
|
+
cacheCreationInputTokens: n(u.cache_creation_input_tokens),
|
|
171
|
+
cacheReadInputTokens: n(u.cache_read_input_tokens) ?? details(u.prompt_tokens_details) ?? details(u.input_tokens_details),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* ACTUAL (settle-time) cost from a response's usage. Accounts for prompt-cache
|
|
176
|
+
* write (~1.25×) and read (~0.1×) tokens when present. Uses Math.round; for the
|
|
177
|
+
* common no-cache case actualCents ≤ estimateUpperBoundCents when the usage's
|
|
178
|
+
* tokens are within the verdict-time bounds (see the monotonicity test).
|
|
179
|
+
*/
|
|
180
|
+
function actualCentsFromUsage(u) {
|
|
181
|
+
const { key, price, known } = resolveModelPrice(u.model);
|
|
182
|
+
const inCents = centsForTokens(u.inputTokens ?? 0, price.inputPerMTok) +
|
|
183
|
+
centsForTokens(u.cacheCreationInputTokens ?? 0, price.inputPerMTok * 1.25) +
|
|
184
|
+
centsForTokens(u.cacheReadInputTokens ?? 0, price.inputPerMTok * 0.1);
|
|
185
|
+
const outCents = centsForTokens(u.outputTokens ?? 0, price.outputPerMTok);
|
|
186
|
+
return { actualCents: Math.max(0, Math.round(inCents + outCents)), model: key, modelKnown: known };
|
|
187
|
+
}
|
|
188
|
+
/** Read the output-token cap for a route action from a parsed request body.
|
|
189
|
+
* OpenAI's cap is optional and inconsistently named; Anthropic requires it. */
|
|
190
|
+
function extractMaxTokensReq(body, action) {
|
|
191
|
+
const a = (action || "").toLowerCase();
|
|
192
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.round(v) : undefined;
|
|
193
|
+
if (a.startsWith("anthropic."))
|
|
194
|
+
return num(body["max_tokens"]);
|
|
195
|
+
if (a === "openai.responses.create")
|
|
196
|
+
return num(body["max_output_tokens"]);
|
|
197
|
+
if (a === "openai.chat.completions.create")
|
|
198
|
+
return num(body["max_completion_tokens"]) ?? num(body["max_tokens"]);
|
|
199
|
+
// openai.completions.create (legacy), and any other openai.* fallback.
|
|
200
|
+
return num(body["max_tokens"]) ?? num(body["max_completion_tokens"]) ?? num(body["max_output_tokens"]);
|
|
201
|
+
}
|
|
202
|
+
/** Coarse INPUT-token estimate from the request body BYTE length (not content). */
|
|
203
|
+
function approxInputTokensFromBody(bodyText) {
|
|
204
|
+
if (!bodyText)
|
|
205
|
+
return 0;
|
|
206
|
+
return Math.ceil(bodyText.length / exports.CHARS_PER_TOKEN);
|
|
207
|
+
}
|
|
208
|
+
/** Completion count the provider will bill output for: openai `n` (choices) and
|
|
209
|
+
* legacy `best_of` (samples). max(n, best_of, 1). Anthropic messages has no such
|
|
210
|
+
* knob → 1. Read from the request the app ALREADY built (metadata only). */
|
|
211
|
+
function extractCompletions(body, action) {
|
|
212
|
+
const a = (action || "").toLowerCase();
|
|
213
|
+
if (a.startsWith("anthropic."))
|
|
214
|
+
return 1;
|
|
215
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.round(v) : 1;
|
|
216
|
+
return Math.max(num(body["n"]), num(body["best_of"]), 1);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Build the llm-gateway intent fields {model, maxTokensReq, estCents} from the
|
|
220
|
+
* request the app ALREADY built (metadata only). Called by the shim escort at
|
|
221
|
+
* verdict time (escort.ts buildIntentFields) for rail === "llm-gateway".
|
|
222
|
+
* Returns undefined when there is no usable model (no spend to reserve).
|
|
223
|
+
*/
|
|
224
|
+
function buildLlmIntentFields(body, action, bodyText) {
|
|
225
|
+
if (!body)
|
|
226
|
+
return undefined;
|
|
227
|
+
const model = typeof body["model"] === "string" ? body["model"].slice(0, 128) : "";
|
|
228
|
+
if (!model)
|
|
229
|
+
return undefined;
|
|
230
|
+
const maxTokensReq = extractMaxTokensReq(body, action);
|
|
231
|
+
const approxInputTokens = approxInputTokensFromBody(bodyText);
|
|
232
|
+
const n = extractCompletions(body, action);
|
|
233
|
+
const est = estimateUpperBoundCents({ model, maxTokensReq, approxInputTokens, n });
|
|
234
|
+
return {
|
|
235
|
+
model,
|
|
236
|
+
// 0 signals "capped at the model ceiling" (request omitted a limit).
|
|
237
|
+
maxTokensReq: maxTokensReq ?? 0,
|
|
238
|
+
estCents: est.estCents,
|
|
239
|
+
};
|
|
240
|
+
}
|