margin-meter 0.1.0 → 0.2.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 +61 -6
- package/dist/index.d.ts +124 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +313 -0
- package/dist/index.js.map +1 -1
- package/dist/instrument.d.ts +73 -0
- package/dist/instrument.d.ts.map +1 -0
- package/dist/instrument.js +152 -0
- package/dist/instrument.js.map +1 -0
- package/dist/providers.d.ts +158 -0
- package/dist/providers.d.ts.map +1 -0
- package/dist/providers.js +397 -0
- package/dist/providers.js.map +1 -0
- package/dist/substrate.d.ts +51 -0
- package/dist/substrate.d.ts.map +1 -0
- package/dist/substrate.js +132 -0
- package/dist/substrate.js.map +1 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -15,17 +15,24 @@ authenticated with a per-project ingest key.
|
|
|
15
15
|
|
|
16
16
|
## Install
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
account needed pre-launch):
|
|
18
|
+
From the npm registry:
|
|
20
19
|
|
|
21
20
|
```bash
|
|
22
21
|
npm install margin-meter
|
|
23
|
-
# or, pinning the subdirectory with your package manager of choice, e.g. pnpm:
|
|
24
|
-
# pnpm add "https://gitpkg.now.sh/subhsubh24/Margin.ai/sdk/ts?main"
|
|
25
22
|
```
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
> **Check the version you get.** This package ships from the registry, and the
|
|
25
|
+
> registry can lag the repo. Confirm with `npm ls margin-meter`, and compare it
|
|
26
|
+
> against `sdk/ts/package.json` on `main`. An older release silently omits newer
|
|
27
|
+
> capture (e.g. `substrate`, reasoning tokens), which under-reports your spend
|
|
28
|
+
> rather than failing loudly — the one failure mode this SDK exists to prevent.
|
|
29
|
+
|
|
30
|
+
Unlike the [Python SDK](../python), this package is **not** git-installable:
|
|
31
|
+
npm has no equivalent of pip's `#subdirectory=`, `dist/` is a build artifact
|
|
32
|
+
that is not committed, and the entry points resolve to `./dist`. Installing
|
|
33
|
+
from a clone or a git URL therefore yields a package with no entry point. Use
|
|
34
|
+
the registry, or publish a new version from `sdk/ts` (`npm publish` runs the
|
|
35
|
+
build via `prepublishOnly`).
|
|
29
36
|
|
|
30
37
|
## Configure
|
|
31
38
|
|
|
@@ -79,6 +86,51 @@ const res = await meter.recordCall({
|
|
|
79
86
|
if (!res.ok) console.warn(`margin ingest failed: ${res.error} (${res.statusCode})`);
|
|
80
87
|
```
|
|
81
88
|
|
|
89
|
+
## Auto-instrument (a few lines for a whole repo)
|
|
90
|
+
|
|
91
|
+
To instrument an existing agent you didn't write, don't edit every call site.
|
|
92
|
+
Point the meter at the client the agent already constructed and every model call
|
|
93
|
+
is timed and metered automatically — tokens are read straight off each
|
|
94
|
+
provider's response, so there's no `setTokens` bookkeeping:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import OpenAI from "openai";
|
|
98
|
+
import { MarginMeter, instrumentOpenAI } from "margin-meter";
|
|
99
|
+
|
|
100
|
+
const client = new OpenAI();
|
|
101
|
+
const meter = new MarginMeter();
|
|
102
|
+
const undo = instrumentOpenAI(client, meter, { workflowId: "aider-fix" });
|
|
103
|
+
|
|
104
|
+
// ... the agent's normal client.chat.completions.create(...) calls are metered ...
|
|
105
|
+
|
|
106
|
+
await meter.recordOutcome({ workflowId: "aider-fix", passed: testsGreen });
|
|
107
|
+
undo(); // restore the original method
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`instrumentAnthropic(client, …)` patches `messages.create`;
|
|
111
|
+
`instrumentGemini(model, …)` patches a `GenerativeModel`'s `generateContent`
|
|
112
|
+
(recovering the model id off the instance). Each returns an `undo()` and throws
|
|
113
|
+
`MarginInstrumentError` if the client shape has moved.
|
|
114
|
+
|
|
115
|
+
Two lower-level primitives when you want tighter control:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
// Wrap ONE call (no monkeypatch) — returns a drop-in replacement.
|
|
119
|
+
const create = meter.wrap(client.chat.completions.create.bind(client.chat.completions), {
|
|
120
|
+
workflowId: "cart-parse",
|
|
121
|
+
provider: "openai",
|
|
122
|
+
});
|
|
123
|
+
const resp = await create({ model: "gpt-4o-mini", messages: [/* … */] }); // metered; resp untouched
|
|
124
|
+
|
|
125
|
+
// Or meter a response you already have.
|
|
126
|
+
await meter.recordResponse({ workflowId: "cart-parse", response: resp, provider: "openai" });
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Honesty: tokens are read verbatim from the real response (`extractUsage`) —
|
|
130
|
+
cache reads are billed separately, never double-counted as fresh input — and a
|
|
131
|
+
metering failure is fail-safe (your call still returns / still throws). A failed
|
|
132
|
+
provider call is still recorded with `status:"error"` so it appears in the chain.
|
|
133
|
+
|
|
82
134
|
## API
|
|
83
135
|
|
|
84
136
|
| Method | Emits to | Notes |
|
|
@@ -86,6 +138,9 @@ if (!res.ok) console.warn(`margin ingest failed: ${res.error} (${res.statusCode}
|
|
|
86
138
|
| `recordCall(input)` | `POST /api/ingest/calls` | cost computed from the pricing table when `costUsd` omitted |
|
|
87
139
|
| `recordOutcome(input)` | `POST /api/ingest/outcomes` | `qualityMethod` records HOW the score was graded |
|
|
88
140
|
| `measure(input, fn)` | `recordCall` on completion | times latency; `status:"error"` + rethrow if `fn` throws; returns `fn`'s value, emit result on `m.result` |
|
|
141
|
+
| `recordResponse(input)` | `recordCall` | meters straight off a provider response — no manual tokens |
|
|
142
|
+
| `wrap(fn, opts)` | `recordCall` per call | returns an auto-metered drop-in for any LLM call |
|
|
143
|
+
| `instrumentOpenAI` / `instrumentAnthropic` / `instrumentGemini` | — | monkeypatch a client instance's create method |
|
|
89
144
|
|
|
90
145
|
Every method resolves to an `IngestResult { ok, statusCode, body?, error? }`.
|
|
91
146
|
`ok` is true only on HTTP 200; `body` holds `call_id`/`outcome_id` + `source`.
|
package/dist/index.d.ts
CHANGED
|
@@ -21,6 +21,12 @@
|
|
|
21
21
|
* written `source` is forced server-side to the key's project slug — the
|
|
22
22
|
* client cannot spoof another project's economics.
|
|
23
23
|
*/
|
|
24
|
+
import { type ProviderName } from "./providers.ts";
|
|
25
|
+
import { type Substrate } from "./substrate.ts";
|
|
26
|
+
export * from "./providers.ts";
|
|
27
|
+
export * from "./instrument.ts";
|
|
28
|
+
export * as substrate from "./substrate.ts";
|
|
29
|
+
export type { Substrate } from "./substrate.ts";
|
|
24
30
|
export declare const CALLS_PATH = "/api/ingest/calls";
|
|
25
31
|
export declare const OUTCOMES_PATH = "/api/ingest/outcomes";
|
|
26
32
|
export declare const KEY_HEADER = "X-Margin-Key";
|
|
@@ -84,13 +90,62 @@ export interface RecordCallInput {
|
|
|
84
90
|
inputTokens?: number;
|
|
85
91
|
outputTokens?: number;
|
|
86
92
|
cacheReadTokens?: number;
|
|
93
|
+
cacheCreationTokens?: number;
|
|
94
|
+
/** The 1-hour-TTL portion of cache creation (Anthropic bills it ~2x input, not
|
|
95
|
+
* the 5-minute ~1.25x). Disjoint from cacheCreationTokens; omit/0 when a call
|
|
96
|
+
* uses only the default cache. */
|
|
97
|
+
cacheCreation1hTokens?: number;
|
|
98
|
+
/** The hidden-thinking share of outputTokens (Gemini thoughts, OpenAI
|
|
99
|
+
* reasoning_tokens). A SUBSET of output, already priced at the output rate —
|
|
100
|
+
* never priced separately. Carried only so the reasoning-effort lever can be
|
|
101
|
+
* isolated; omit/0 on a non-reasoning call. */
|
|
102
|
+
reasoningTokens?: number;
|
|
87
103
|
latencyMs?: number;
|
|
88
104
|
status?: string;
|
|
89
105
|
isRetry?: boolean;
|
|
90
106
|
sessionId?: string;
|
|
91
107
|
promptId?: string;
|
|
108
|
+
/** The STEP within a multi-operation workflow this call belongs to (a
|
|
109
|
+
* supply-chain node label the Money Map decomposes by, e.g. "extract"). Omit
|
|
110
|
+
* for a single-call workflow. */
|
|
111
|
+
operation?: string;
|
|
92
112
|
costUsd?: number;
|
|
93
113
|
isSimulated?: boolean;
|
|
114
|
+
/** Idempotency key baked into the emitted payload. Defaults to a fresh uuid
|
|
115
|
+
* per call, so a redelivery of THIS exact emit (a transport retry) collapses
|
|
116
|
+
* to one row server-side instead of double-counting spend. Distinct calls get
|
|
117
|
+
* distinct ids, so real volume is never deduped away. */
|
|
118
|
+
eventId?: string;
|
|
119
|
+
/** The provider billing tier. "standard" (the default) is a normal synchronous
|
|
120
|
+
* call; "batch" is a call routed through the async Batch API, which the server
|
|
121
|
+
* prices at the ~50%-off tier — pricing a real batch call standard would
|
|
122
|
+
* over-state its cost. Omit for standard; the field is sent only when "batch". */
|
|
123
|
+
billingTier?: "standard" | "batch";
|
|
124
|
+
/** This call's own node in an agent tree. A branching run is otherwise a flat
|
|
125
|
+
* list of calls sharing a sessionId — enough to total the run, never enough to
|
|
126
|
+
* ask what one BRANCH cost. Omit for a single-agent call. */
|
|
127
|
+
spanId?: string;
|
|
128
|
+
/** The node that spawned this one; omit at the root. Requires `spanId` (a parent
|
|
129
|
+
* link with no node of its own is an edge to nowhere, and the server rejects
|
|
130
|
+
* it). */
|
|
131
|
+
parentSpanId?: string;
|
|
132
|
+
/** The orchestration shape THIS call performed, on the node that did the
|
|
133
|
+
* orchestrating — not on its branches. The set is closed server-side so shapes
|
|
134
|
+
* stay comparable across runs. */
|
|
135
|
+
orchestrationPattern?: "pipeline" | "barrier" | "panel" | "loop_until_dry";
|
|
136
|
+
/** How many branches the orchestration opened. */
|
|
137
|
+
branchWidth?: number;
|
|
138
|
+
/** The rule that ended the orchestration, free text (e.g. "majority of 3"). */
|
|
139
|
+
stoppingRule?: string;
|
|
140
|
+
/** Which PATH this call took to reach the model — the vendor's own API
|
|
141
|
+
* ("direct"), a router ("openrouter", "litellm"), a cloud reseller ("azure",
|
|
142
|
+
* "bedrock", "vertex"), or an unrecognised proxy ("gateway"). Independent of
|
|
143
|
+
* `provider`, which says who owns the model and bills for it: the same
|
|
144
|
+
* openai/gpt-4o call can arrive over four different paths, and comparing them
|
|
145
|
+
* is something a single-substrate router structurally cannot do. Omit it when
|
|
146
|
+
* the path is not actually known — an unmeasured dimension belongs empty, not
|
|
147
|
+
* defaulted to "direct". The set is closed server-side. */
|
|
148
|
+
substrate?: "direct" | "openrouter" | "litellm" | "azure" | "bedrock" | "vertex" | "gateway";
|
|
94
149
|
}
|
|
95
150
|
export interface RecordOutcomeInput {
|
|
96
151
|
workflowId: string;
|
|
@@ -99,6 +154,9 @@ export interface RecordOutcomeInput {
|
|
|
99
154
|
qualityMethod?: string;
|
|
100
155
|
link?: string;
|
|
101
156
|
isSimulated?: boolean;
|
|
157
|
+
/** Idempotency key (see RecordCallInput.eventId): a redelivered outcome does
|
|
158
|
+
* not double-count the cost-per-outcome denominator. Defaults to a fresh uuid. */
|
|
159
|
+
eventId?: string;
|
|
102
160
|
}
|
|
103
161
|
/** A per-project client that emits measured economics to a Margin ingest API. */
|
|
104
162
|
export declare class MarginMeter {
|
|
@@ -122,17 +180,83 @@ export declare class MarginMeter {
|
|
|
122
180
|
* value `fn` produced; the emit's `IngestResult` is available as `m.result`.
|
|
123
181
|
*/
|
|
124
182
|
measure<T>(input: Omit<RecordCallInput, "latencyMs" | "status">, fn: (m: Measurement) => Promise<T> | T): Promise<T>;
|
|
183
|
+
/**
|
|
184
|
+
* Meter one call by reading tokens straight off its provider `response`
|
|
185
|
+
* (OpenAI / Anthropic / Gemini result). The no-bookkeeping counterpart to
|
|
186
|
+
* `recordCall`: the `(provider, model, tokens)` are recovered by
|
|
187
|
+
* `extractUsage`. `provider` is optional when the shape is unambiguous but the
|
|
188
|
+
* instrumenter always passes it; `model` overrides a response that omits it
|
|
189
|
+
* (Gemini). Numbers are read verbatim. Never throws unless `raiseOnError`.
|
|
190
|
+
*/
|
|
191
|
+
recordResponse(input: RecordResponseInput): Promise<IngestResult>;
|
|
192
|
+
/**
|
|
193
|
+
* Return `fn` wrapped so every call is timed and auto-metered. The core
|
|
194
|
+
* auto-instrumentation primitive: give it any async LLM call
|
|
195
|
+
* (`client.chat.completions.create`, `client.messages.create`,
|
|
196
|
+
* `model.generateContent` …) and it returns a drop-in replacement that times
|
|
197
|
+
* the call, emits its economics from the returned response, and hands the
|
|
198
|
+
* ORIGINAL response back untouched. On a throw the call is metered with
|
|
199
|
+
* `status="error"` and the error re-thrown (never swallowed); a metering
|
|
200
|
+
* failure is fail-safe and never breaks the wrapped call. `provider` is
|
|
201
|
+
* required for a shape the extractor can't disambiguate; `model` supplies a
|
|
202
|
+
* model the response omits (Gemini); `resolveModel(args)` derives it per call
|
|
203
|
+
* (e.g. off a bound Gemini model). Signature-agnostic (`...args`).
|
|
204
|
+
*/
|
|
205
|
+
wrap<F extends (...args: never[]) => Promise<unknown>>(fn: F, opts: WrapOptions): (...args: Parameters<F>) => Promise<Awaited<ReturnType<F>>>;
|
|
206
|
+
}
|
|
207
|
+
/** Input to `recordResponse` — meter off a provider response object. */
|
|
208
|
+
export interface RecordResponseInput {
|
|
209
|
+
workflowId: string;
|
|
210
|
+
response: unknown;
|
|
211
|
+
provider?: ProviderName;
|
|
212
|
+
model?: string;
|
|
213
|
+
latencyMs?: number;
|
|
214
|
+
status?: string;
|
|
215
|
+
isRetry?: boolean;
|
|
216
|
+
sessionId?: string;
|
|
217
|
+
promptId?: string;
|
|
218
|
+
operation?: string;
|
|
219
|
+
isSimulated?: boolean;
|
|
220
|
+
/** The delivery path the call took (see `substrate.ts`); omit when unknown. */
|
|
221
|
+
substrate?: Substrate;
|
|
222
|
+
}
|
|
223
|
+
/** Options for `wrap` — how to meter every call to the wrapped fn. */
|
|
224
|
+
export interface WrapOptions {
|
|
225
|
+
workflowId: string;
|
|
226
|
+
provider?: ProviderName;
|
|
227
|
+
model?: string;
|
|
228
|
+
operation?: string;
|
|
229
|
+
sessionId?: string;
|
|
230
|
+
isRetry?: boolean;
|
|
231
|
+
isSimulated?: boolean;
|
|
232
|
+
/** Derive the model per call (e.g. off a bound Gemini model); a truthy return
|
|
233
|
+
* overrides the static `model`. Receives the wrapped call's args. */
|
|
234
|
+
resolveModel?: (args: unknown[]) => string | undefined;
|
|
235
|
+
/** OpenAI omits usage from a stream unless asked; when set (only by the OpenAI
|
|
236
|
+
* chat instrumenter), a `stream: true` call gets `stream_options.include_usage`
|
|
237
|
+
* injected so its final chunk carries the count the accumulator meters. */
|
|
238
|
+
streamIncludeUsage?: boolean;
|
|
239
|
+
/** Which DELIVERY PATH this wrapper's calls take (see `substrate.ts`). The JS
|
|
240
|
+
* instrumenters patch a specific client instance, so the endpoint is known at
|
|
241
|
+
* patch time and every call through this wrapper travelled it. Leave unset when
|
|
242
|
+
* the endpoint could not be read — an unmeasured dimension belongs empty, never
|
|
243
|
+
* defaulted to "direct". Never touches the cost math. */
|
|
244
|
+
substrate?: Substrate;
|
|
125
245
|
}
|
|
126
246
|
/** Token accumulator passed into `measure`'s callback. */
|
|
127
247
|
export declare class Measurement {
|
|
128
248
|
inputTokens: number;
|
|
129
249
|
outputTokens: number;
|
|
130
250
|
cacheReadTokens: number;
|
|
251
|
+
cacheCreationTokens: number;
|
|
252
|
+
cacheCreation1hTokens: number;
|
|
131
253
|
result?: IngestResult;
|
|
132
254
|
setTokens(tokens: {
|
|
133
255
|
inputTokens?: number;
|
|
134
256
|
outputTokens?: number;
|
|
135
257
|
cacheReadTokens?: number;
|
|
258
|
+
cacheCreationTokens?: number;
|
|
259
|
+
cacheCreation1hTokens?: number;
|
|
136
260
|
}): void;
|
|
137
261
|
}
|
|
138
262
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,eAAO,MAAM,UAAU,sBAAsB,CAAC;AAC9C,eAAO,MAAM,aAAa,yBAAyB,CAAC;AACpD,eAAO,MAAM,UAAU,iBAAiB,CAAC;AACzC,eAAO,MAAM,kBAAkB,0BAA0B,CAAC;AAC1D,eAAO,MAAM,kBAAkB,QAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -21,11 +21,30 @@
|
|
|
21
21
|
* written `source` is forced server-side to the key's project slug — the
|
|
22
22
|
* client cannot spoof another project's economics.
|
|
23
23
|
*/
|
|
24
|
+
import { extractUsage, StreamUsageAccumulator, } from "./providers.js";
|
|
25
|
+
export * from "./providers.js";
|
|
26
|
+
export * from "./instrument.js";
|
|
27
|
+
// Namespaced, not flattened: `BEDROCK` means two different things here — the
|
|
28
|
+
// provider that bills for the model and the path the request took — and merging
|
|
29
|
+
// them into one barrel would collide the names and blur the distinction the
|
|
30
|
+
// substrate dimension exists to draw. `substrate.BEDROCK` reads as the path.
|
|
31
|
+
export * as substrate from "./substrate.js";
|
|
24
32
|
export const CALLS_PATH = "/api/ingest/calls";
|
|
25
33
|
export const OUTCOMES_PATH = "/api/ingest/outcomes";
|
|
26
34
|
export const KEY_HEADER = "X-Margin-Key";
|
|
27
35
|
export const DEFAULT_INGEST_URL = "http://127.0.0.1:8000";
|
|
28
36
|
export const DEFAULT_TIMEOUT_MS = 10_000;
|
|
37
|
+
/** A fresh idempotency key per emit. Uses the platform crypto UUID (Node 16.7+,
|
|
38
|
+
* modern browsers); falls back to a random-hex id where crypto is absent, which
|
|
39
|
+
* is still unique enough to key a single call's dedup. */
|
|
40
|
+
function newEventId() {
|
|
41
|
+
const c = globalThis.crypto;
|
|
42
|
+
if (c?.randomUUID)
|
|
43
|
+
return c.randomUUID();
|
|
44
|
+
return (Date.now().toString(16) +
|
|
45
|
+
Math.random().toString(16).slice(2) +
|
|
46
|
+
Math.random().toString(16).slice(2));
|
|
47
|
+
}
|
|
29
48
|
/** Raised (only in strict mode) when a row is rejected by the ingest API. */
|
|
30
49
|
export class MarginIngestError extends Error {
|
|
31
50
|
statusCode;
|
|
@@ -115,17 +134,57 @@ export class MarginMeter {
|
|
|
115
134
|
input_tokens: input.inputTokens ?? 0,
|
|
116
135
|
output_tokens: input.outputTokens ?? 0,
|
|
117
136
|
cache_read_tokens: input.cacheReadTokens ?? 0,
|
|
137
|
+
cache_creation_tokens: input.cacheCreationTokens ?? 0,
|
|
118
138
|
latency_ms: input.latencyMs ?? 0,
|
|
119
139
|
status: input.status ?? "ok",
|
|
120
140
|
is_retry: input.isRetry ?? false,
|
|
121
141
|
is_simulated: input.isSimulated ?? false,
|
|
142
|
+
event_id: input.eventId ?? newEventId(),
|
|
122
143
|
};
|
|
123
144
|
if (input.sessionId !== undefined)
|
|
124
145
|
payload.session_id = input.sessionId;
|
|
125
146
|
if (input.promptId !== undefined)
|
|
126
147
|
payload.prompt_id = input.promptId;
|
|
148
|
+
if (input.operation !== undefined)
|
|
149
|
+
payload.operation = input.operation;
|
|
127
150
|
if (input.costUsd !== undefined)
|
|
128
151
|
payload.cost_usd = input.costUsd;
|
|
152
|
+
// Only send a non-default tier: a synchronous call omits it (the server
|
|
153
|
+
// defaults "standard"), keeping the wire minimal and an older server that
|
|
154
|
+
// predates the field unaffected.
|
|
155
|
+
if (input.billingTier !== undefined && input.billingTier !== "standard") {
|
|
156
|
+
payload.billing_tier = input.billingTier;
|
|
157
|
+
}
|
|
158
|
+
// Only send the 1-hour cache-write bucket when a call used one: the common
|
|
159
|
+
// single-TTL call omits it (the server reads 0 and prices its whole cache
|
|
160
|
+
// creation at the 5-minute rate, unchanged), keeping the wire minimal and an
|
|
161
|
+
// older server that predates the field unaffected.
|
|
162
|
+
if (input.cacheCreation1hTokens) {
|
|
163
|
+
payload.cache_creation_1h_tokens = input.cacheCreation1hTokens;
|
|
164
|
+
}
|
|
165
|
+
// Only send the reasoning subset when a call spent thinking tokens: a
|
|
166
|
+
// non-reasoning call omits it (server reads 0). It never changes cost — it is a
|
|
167
|
+
// share of output already priced at the output rate.
|
|
168
|
+
if (input.reasoningTokens) {
|
|
169
|
+
payload.reasoning_tokens = input.reasoningTokens;
|
|
170
|
+
}
|
|
171
|
+
// The agent-tree fields ride only when a call belongs to one, so a
|
|
172
|
+
// single-agent call's wire is unchanged and an older server that predates them
|
|
173
|
+
// is unaffected. They carry structure, never money — no cost branch reads them.
|
|
174
|
+
if (input.spanId !== undefined)
|
|
175
|
+
payload.span_id = input.spanId;
|
|
176
|
+
if (input.parentSpanId !== undefined)
|
|
177
|
+
payload.parent_span_id = input.parentSpanId;
|
|
178
|
+
if (input.orchestrationPattern !== undefined) {
|
|
179
|
+
payload.orchestration_pattern = input.orchestrationPattern;
|
|
180
|
+
}
|
|
181
|
+
if (input.branchWidth !== undefined)
|
|
182
|
+
payload.branch_width = input.branchWidth;
|
|
183
|
+
if (input.stoppingRule !== undefined)
|
|
184
|
+
payload.stopping_rule = input.stoppingRule;
|
|
185
|
+
// The delivery path, sent only when it was actually determined.
|
|
186
|
+
if (input.substrate !== undefined)
|
|
187
|
+
payload.substrate = input.substrate;
|
|
129
188
|
return await this.emit(CALLS_PATH, payload);
|
|
130
189
|
}
|
|
131
190
|
/** Emit one outcome (a unit of productivity). `qualityMethod` records HOW the
|
|
@@ -135,6 +194,7 @@ export class MarginMeter {
|
|
|
135
194
|
workflow_id: input.workflowId,
|
|
136
195
|
passed: input.passed,
|
|
137
196
|
is_simulated: input.isSimulated ?? false,
|
|
197
|
+
event_id: input.eventId ?? newEventId(),
|
|
138
198
|
};
|
|
139
199
|
if (input.qualityScore !== undefined)
|
|
140
200
|
payload.quality_score = input.qualityScore;
|
|
@@ -169,22 +229,275 @@ export class MarginMeter {
|
|
|
169
229
|
inputTokens: m.inputTokens,
|
|
170
230
|
outputTokens: m.outputTokens,
|
|
171
231
|
cacheReadTokens: m.cacheReadTokens,
|
|
232
|
+
cacheCreationTokens: m.cacheCreationTokens,
|
|
233
|
+
cacheCreation1hTokens: m.cacheCreation1hTokens,
|
|
172
234
|
latencyMs,
|
|
173
235
|
status: threw ? "error" : "ok",
|
|
174
236
|
});
|
|
175
237
|
}
|
|
176
238
|
}
|
|
239
|
+
// --------------------------------------------------------------------- //
|
|
240
|
+
// Auto-instrumentation — meter straight off a provider response, no manual
|
|
241
|
+
// setTokens(). The "few lines to instrument a repo" path.
|
|
242
|
+
// --------------------------------------------------------------------- //
|
|
243
|
+
/**
|
|
244
|
+
* Meter one call by reading tokens straight off its provider `response`
|
|
245
|
+
* (OpenAI / Anthropic / Gemini result). The no-bookkeeping counterpart to
|
|
246
|
+
* `recordCall`: the `(provider, model, tokens)` are recovered by
|
|
247
|
+
* `extractUsage`. `provider` is optional when the shape is unambiguous but the
|
|
248
|
+
* instrumenter always passes it; `model` overrides a response that omits it
|
|
249
|
+
* (Gemini). Numbers are read verbatim. Never throws unless `raiseOnError`.
|
|
250
|
+
*/
|
|
251
|
+
async recordResponse(input) {
|
|
252
|
+
const usage = extractUsage(input.response, {
|
|
253
|
+
provider: input.provider,
|
|
254
|
+
model: input.model,
|
|
255
|
+
});
|
|
256
|
+
return await this.recordCall({
|
|
257
|
+
workflowId: input.workflowId,
|
|
258
|
+
provider: usage.provider,
|
|
259
|
+
model: usage.model ?? "",
|
|
260
|
+
inputTokens: usage.inputTokens,
|
|
261
|
+
outputTokens: usage.outputTokens,
|
|
262
|
+
cacheReadTokens: usage.cacheReadTokens,
|
|
263
|
+
cacheCreationTokens: usage.cacheCreationTokens,
|
|
264
|
+
cacheCreation1hTokens: usage.cacheCreation1hTokens,
|
|
265
|
+
reasoningTokens: usage.reasoningTokens,
|
|
266
|
+
latencyMs: input.latencyMs,
|
|
267
|
+
status: input.status ?? "ok",
|
|
268
|
+
isRetry: input.isRetry,
|
|
269
|
+
sessionId: input.sessionId,
|
|
270
|
+
promptId: input.promptId,
|
|
271
|
+
operation: input.operation,
|
|
272
|
+
isSimulated: input.isSimulated,
|
|
273
|
+
substrate: input.substrate,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Return `fn` wrapped so every call is timed and auto-metered. The core
|
|
278
|
+
* auto-instrumentation primitive: give it any async LLM call
|
|
279
|
+
* (`client.chat.completions.create`, `client.messages.create`,
|
|
280
|
+
* `model.generateContent` …) and it returns a drop-in replacement that times
|
|
281
|
+
* the call, emits its economics from the returned response, and hands the
|
|
282
|
+
* ORIGINAL response back untouched. On a throw the call is metered with
|
|
283
|
+
* `status="error"` and the error re-thrown (never swallowed); a metering
|
|
284
|
+
* failure is fail-safe and never breaks the wrapped call. `provider` is
|
|
285
|
+
* required for a shape the extractor can't disambiguate; `model` supplies a
|
|
286
|
+
* model the response omits (Gemini); `resolveModel(args)` derives it per call
|
|
287
|
+
* (e.g. off a bound Gemini model). Signature-agnostic (`...args`).
|
|
288
|
+
*/
|
|
289
|
+
wrap(fn, opts) {
|
|
290
|
+
const meter = this;
|
|
291
|
+
return async function (...args) {
|
|
292
|
+
let callModel = opts.model;
|
|
293
|
+
if (opts.resolveModel) {
|
|
294
|
+
try {
|
|
295
|
+
const resolved = opts.resolveModel(args);
|
|
296
|
+
if (resolved)
|
|
297
|
+
callModel = resolved;
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
/* a resolver hiccup never breaks the wrapped call */
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// Ask OpenAI to include usage on a streamed call so the final chunk carries
|
|
304
|
+
// the count the accumulator needs (only when this is an OpenAI chat patch).
|
|
305
|
+
maybeInjectIncludeUsage(args, opts);
|
|
306
|
+
const start = now();
|
|
307
|
+
let response;
|
|
308
|
+
try {
|
|
309
|
+
response = (await fn(...args));
|
|
310
|
+
}
|
|
311
|
+
catch (e) {
|
|
312
|
+
const latencyMs = Math.round(now() - start);
|
|
313
|
+
try {
|
|
314
|
+
await meter.recordCall({
|
|
315
|
+
workflowId: opts.workflowId,
|
|
316
|
+
provider: opts.provider ?? "unknown",
|
|
317
|
+
model: callModel ?? "",
|
|
318
|
+
latencyMs,
|
|
319
|
+
status: "error",
|
|
320
|
+
isRetry: opts.isRetry,
|
|
321
|
+
sessionId: opts.sessionId,
|
|
322
|
+
operation: opts.operation,
|
|
323
|
+
isSimulated: opts.isSimulated,
|
|
324
|
+
substrate: opts.substrate,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
/* metering must never mask the host's real exception */
|
|
329
|
+
}
|
|
330
|
+
throw e;
|
|
331
|
+
}
|
|
332
|
+
// A streamed call resolves to an async-iterable of chunks, not a `.usage`
|
|
333
|
+
// response. Reading it here would throw and drop the call; instead hand back
|
|
334
|
+
// a transparent proxy that meters ONCE as the host consumes the stream.
|
|
335
|
+
if (isAsyncIterable(response)) {
|
|
336
|
+
return meteredStream(response, meter, opts, callModel, start);
|
|
337
|
+
}
|
|
338
|
+
const latencyMs = Math.round(now() - start);
|
|
339
|
+
try {
|
|
340
|
+
await meter.recordResponse({
|
|
341
|
+
workflowId: opts.workflowId,
|
|
342
|
+
response,
|
|
343
|
+
provider: opts.provider,
|
|
344
|
+
model: callModel,
|
|
345
|
+
latencyMs,
|
|
346
|
+
isRetry: opts.isRetry,
|
|
347
|
+
sessionId: opts.sessionId,
|
|
348
|
+
operation: opts.operation,
|
|
349
|
+
isSimulated: opts.isSimulated,
|
|
350
|
+
substrate: opts.substrate,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
/* fail-safe: an unreadable response must not break the host call */
|
|
355
|
+
}
|
|
356
|
+
return response;
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/** True when `x` is an async-iterable (a provider stream), never a plain response
|
|
361
|
+
* object, string, or array. The clean discriminator between a streamed call and
|
|
362
|
+
* a normal one after the wrapped fn resolves. */
|
|
363
|
+
function isAsyncIterable(x) {
|
|
364
|
+
return (x != null &&
|
|
365
|
+
(typeof x === "object" || typeof x === "function") &&
|
|
366
|
+
typeof x[Symbol.asyncIterator] === "function");
|
|
367
|
+
}
|
|
368
|
+
/** When this wrapper is an OpenAI chat patch (`streamIncludeUsage`) AND the call
|
|
369
|
+
* requests `stream: true`, add `stream_options.include_usage` so OpenAI emits its
|
|
370
|
+
* usage on the final chunk. Clones the params object (never mutates the caller's),
|
|
371
|
+
* and fills only when absent so an explicit caller choice is respected. */
|
|
372
|
+
function maybeInjectIncludeUsage(args, opts) {
|
|
373
|
+
if (!opts.streamIncludeUsage)
|
|
374
|
+
return;
|
|
375
|
+
const params = args[0];
|
|
376
|
+
if (params == null || typeof params !== "object")
|
|
377
|
+
return;
|
|
378
|
+
const p = params;
|
|
379
|
+
if (p.stream !== true)
|
|
380
|
+
return;
|
|
381
|
+
const existing = p.stream_options;
|
|
382
|
+
if (existing == null) {
|
|
383
|
+
args[0] = { ...p, stream_options: { include_usage: true } };
|
|
384
|
+
}
|
|
385
|
+
else if (typeof existing === "object" &&
|
|
386
|
+
!("include_usage" in existing)) {
|
|
387
|
+
args[0] = {
|
|
388
|
+
...p,
|
|
389
|
+
stream_options: { ...existing, include_usage: true },
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* A transparent proxy over a provider stream that meters on completion.
|
|
395
|
+
*
|
|
396
|
+
* The TS twin of `margin_meter`'s `_MeteredStream`. It hands every chunk to the
|
|
397
|
+
* host UNCHANGED while feeding it to a `StreamUsageAccumulator`; when the stream
|
|
398
|
+
* is exhausted, the host breaks early (its iterator's `return()`), or a chunk
|
|
399
|
+
* throws, it meters exactly once with the accumulated usage — or nothing when the
|
|
400
|
+
* stream carried no usage at all (an OpenAI stream without `include_usage`), since
|
|
401
|
+
* a zero-token row would misreport a real call as free. Every other property/method
|
|
402
|
+
* delegates to the wrapped stream, so `for await (const c of stream)`, `.tee()`,
|
|
403
|
+
* and the rest work unchanged. Fail-safe: a metering hiccup never breaks iteration.
|
|
404
|
+
*/
|
|
405
|
+
function meteredStream(stream, meter, opts, callModel, start) {
|
|
406
|
+
const acc = new StreamUsageAccumulator(opts.provider, callModel);
|
|
407
|
+
let finished = false;
|
|
408
|
+
const finish = async () => {
|
|
409
|
+
if (finished)
|
|
410
|
+
return;
|
|
411
|
+
finished = true;
|
|
412
|
+
const usage = acc.finalize();
|
|
413
|
+
// Honest: a stream that carried no usage is NOT recorded — there is no real
|
|
414
|
+
// token count and a zero-token row would report the call as free.
|
|
415
|
+
if (usage === undefined)
|
|
416
|
+
return;
|
|
417
|
+
const latencyMs = Math.round(now() - start);
|
|
418
|
+
try {
|
|
419
|
+
await meter.recordCall({
|
|
420
|
+
workflowId: opts.workflowId,
|
|
421
|
+
provider: usage.provider ?? opts.provider ?? "unknown",
|
|
422
|
+
model: usage.model ?? callModel ?? "",
|
|
423
|
+
inputTokens: usage.inputTokens,
|
|
424
|
+
outputTokens: usage.outputTokens,
|
|
425
|
+
cacheReadTokens: usage.cacheReadTokens,
|
|
426
|
+
cacheCreationTokens: usage.cacheCreationTokens,
|
|
427
|
+
cacheCreation1hTokens: usage.cacheCreation1hTokens,
|
|
428
|
+
reasoningTokens: usage.reasoningTokens,
|
|
429
|
+
latencyMs,
|
|
430
|
+
status: "ok",
|
|
431
|
+
isRetry: opts.isRetry,
|
|
432
|
+
sessionId: opts.sessionId,
|
|
433
|
+
operation: opts.operation,
|
|
434
|
+
isSimulated: opts.isSimulated,
|
|
435
|
+
substrate: opts.substrate,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
/* metering must never mask the host's real result */
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
return new Proxy(stream, {
|
|
443
|
+
get(target, prop, receiver) {
|
|
444
|
+
if (prop === Symbol.asyncIterator) {
|
|
445
|
+
return function meteredAsyncIterator() {
|
|
446
|
+
const inner = target[Symbol.asyncIterator]();
|
|
447
|
+
return {
|
|
448
|
+
async next() {
|
|
449
|
+
let r;
|
|
450
|
+
try {
|
|
451
|
+
r = await inner.next();
|
|
452
|
+
}
|
|
453
|
+
catch (e) {
|
|
454
|
+
await finish(); // a mid-stream error still banks the partial usage seen
|
|
455
|
+
throw e;
|
|
456
|
+
}
|
|
457
|
+
if (r.done) {
|
|
458
|
+
await finish(); // metered once, cleanly, at the end of the stream
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
try {
|
|
462
|
+
acc.observe(r.value);
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
/* a malformed chunk never breaks the host's read */
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return r;
|
|
469
|
+
},
|
|
470
|
+
async return(value) {
|
|
471
|
+
await finish(); // the host broke early — meter what was seen
|
|
472
|
+
if (typeof inner.return === "function") {
|
|
473
|
+
return inner.return(value);
|
|
474
|
+
}
|
|
475
|
+
return { done: true, value };
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
const val = Reflect.get(target, prop, receiver);
|
|
481
|
+
return typeof val === "function"
|
|
482
|
+
? val.bind(target)
|
|
483
|
+
: val;
|
|
484
|
+
},
|
|
485
|
+
});
|
|
177
486
|
}
|
|
178
487
|
/** Token accumulator passed into `measure`'s callback. */
|
|
179
488
|
export class Measurement {
|
|
180
489
|
inputTokens = 0;
|
|
181
490
|
outputTokens = 0;
|
|
182
491
|
cacheReadTokens = 0;
|
|
492
|
+
cacheCreationTokens = 0;
|
|
493
|
+
cacheCreation1hTokens = 0;
|
|
183
494
|
result;
|
|
184
495
|
setTokens(tokens) {
|
|
185
496
|
this.inputTokens = tokens.inputTokens ?? 0;
|
|
186
497
|
this.outputTokens = tokens.outputTokens ?? 0;
|
|
187
498
|
this.cacheReadTokens = tokens.cacheReadTokens ?? 0;
|
|
499
|
+
this.cacheCreationTokens = tokens.cacheCreationTokens ?? 0;
|
|
500
|
+
this.cacheCreation1hTokens = tokens.cacheCreation1hTokens ?? 0;
|
|
188
501
|
}
|
|
189
502
|
}
|
|
190
503
|
async function extractDetail(resp) {
|