@stigg/agent-sdk-js 0.0.0-stage → 1.0.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 +1147 -2
- package/dist/bindings/client/core/registry.d.ts +12 -0
- package/dist/bindings/client/core/registry.js +22 -0
- package/dist/bindings/client/core/types.d.ts +117 -0
- package/dist/bindings/client/core/types.js +13 -0
- package/dist/bindings/client/core/wrap-client.d.ts +6 -0
- package/dist/bindings/client/core/wrap-client.js +258 -0
- package/dist/bindings/client/gateways/litellm/litellm-recipe.d.ts +17 -0
- package/dist/bindings/client/gateways/litellm/litellm-recipe.js +171 -0
- package/dist/bindings/client/gateways/litellm/types.d.ts +30 -0
- package/dist/bindings/client/gateways/litellm/types.js +2 -0
- package/dist/bindings/client/gateways/openrouter/openrouter-recipe.d.ts +15 -0
- package/dist/bindings/client/gateways/openrouter/openrouter-recipe.js +141 -0
- package/dist/bindings/client/gateways/openrouter/types.d.ts +29 -0
- package/dist/bindings/client/gateways/openrouter/types.js +2 -0
- package/dist/bindings/client/providers/anthropic/anthropic-recipe.d.ts +34 -0
- package/dist/bindings/client/providers/anthropic/anthropic-recipe.js +91 -0
- package/dist/bindings/client/providers/gemini/gemini-recipe.d.ts +54 -0
- package/dist/bindings/client/providers/gemini/gemini-recipe.js +243 -0
- package/dist/bindings/client/providers/openai/openai-recipe.d.ts +43 -0
- package/dist/bindings/client/providers/openai/openai-recipe.js +80 -0
- package/dist/bindings/client/providers/typesafe/typesafe-recipe.d.ts +33 -0
- package/dist/bindings/client/providers/typesafe/typesafe-recipe.js +73 -0
- package/dist/bindings/proxy/headers.d.ts +6 -0
- package/dist/bindings/proxy/headers.js +12 -0
- package/dist/bindings/proxy/index.d.ts +21 -0
- package/dist/bindings/proxy/index.js +18 -0
- package/dist/bindings/proxy/types.d.ts +8 -0
- package/dist/bindings/proxy/types.js +2 -0
- package/dist/bindings/shared/estimate.d.ts +27 -0
- package/dist/bindings/shared/estimate.js +38 -0
- package/dist/bindings/shared/meter-stream.d.ts +18 -0
- package/dist/bindings/shared/meter-stream.js +151 -0
- package/dist/bindings/shared/token-usage.d.ts +148 -0
- package/dist/bindings/shared/token-usage.js +414 -0
- package/dist/bindings/shared/types.d.ts +79 -0
- package/dist/bindings/shared/types.js +2 -0
- package/dist/bindings/shared/weighted-metrics.d.ts +37 -0
- package/dist/bindings/shared/weighted-metrics.js +44 -0
- package/dist/config.d.ts +136 -0
- package/dist/config.js +97 -0
- package/dist/core/context.d.ts +151 -0
- package/dist/core/context.js +154 -0
- package/dist/core/dimensions.d.ts +22 -0
- package/dist/core/dimensions.js +47 -0
- package/dist/core/hooks.d.ts +21 -0
- package/dist/core/hooks.js +47 -0
- package/dist/core/session.d.ts +161 -0
- package/dist/core/session.js +403 -0
- package/dist/decision-text.d.ts +15 -0
- package/dist/decision-text.js +54 -0
- package/dist/engine/composite-engine.d.ts +72 -0
- package/dist/engine/composite-engine.js +135 -0
- package/dist/engine/create-engine.d.ts +16 -0
- package/dist/engine/create-engine.js +93 -0
- package/dist/engine/decision.d.ts +37 -0
- package/dist/engine/decision.js +128 -0
- package/dist/engine/engine.d.ts +166 -0
- package/dist/engine/engine.js +11 -0
- package/dist/engine/node-sdk-engine.d.ts +125 -0
- package/dist/engine/node-sdk-engine.js +262 -0
- package/dist/engine/report.d.ts +106 -0
- package/dist/engine/report.js +185 -0
- package/dist/engine/rest-governance-engine.d.ts +47 -0
- package/dist/engine/rest-governance-engine.js +220 -0
- package/dist/errors.d.ts +78 -0
- package/dist/errors.js +151 -0
- package/dist/headers.d.ts +27 -0
- package/dist/headers.js +63 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +85 -0
- package/dist/internal.d.ts +33 -0
- package/dist/internal.js +81 -0
- package/dist/logger.d.ts +15 -0
- package/dist/logger.js +20 -0
- package/dist/sdk-identity.d.ts +36 -0
- package/dist/sdk-identity.js +43 -0
- package/dist/stigg-agent-sdk.d.ts +242 -0
- package/dist/stigg-agent-sdk.js +594 -0
- package/dist/types.d.ts +531 -0
- package/dist/types.js +97 -0
- package/package.json +80 -3
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { GATEWAY_METRICS } from '../../../shared/token-usage';
|
|
2
|
+
import type { OpenRouterRecipeOptions } from './types';
|
|
3
|
+
import type { ClientRecipe, MetricsFor, MetricsOf } from '../../core/types';
|
|
4
|
+
/** Both OpenAI surfaces plus the price the gateway itself charged. */
|
|
5
|
+
type GatewayMetric = (typeof GATEWAY_METRICS)[number];
|
|
6
|
+
/**
|
|
7
|
+
* One recipe covers every upstream. OpenRouter normalizes Anthropic, OpenAI and the rest to the
|
|
8
|
+
* same wire shape, so the response never changes form - `model` is just a field saying who served.
|
|
9
|
+
*/
|
|
10
|
+
export declare function openrouterRecipe(options?: OpenRouterRecipeOptions): ClientRecipe<GatewayMetric>;
|
|
11
|
+
/** One of the metric names OpenRouter reports. */
|
|
12
|
+
export type OpenRouterMetric = MetricsOf<ReturnType<typeof openrouterRecipe>>;
|
|
13
|
+
/** What a mapper reads off an OpenRouter call: those names, each possibly absent. */
|
|
14
|
+
export type OpenRouterMetrics = MetricsFor<ReturnType<typeof openrouterRecipe>>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.openrouterRecipe = openrouterRecipe;
|
|
4
|
+
/**
|
|
5
|
+
* The OpenRouter recipe - a gateway you call rather than one you operate.
|
|
6
|
+
*
|
|
7
|
+
* OpenRouter speaks the OpenAI wire format, so `openAIRecipe` already fits it by shape. What that
|
|
8
|
+
* recipe cannot express is the two things a gateway knows and a client-side wrapper cannot: the
|
|
9
|
+
* credits it actually charged (`usage.cost`) and which model actually served after fallback
|
|
10
|
+
* routing (`response.model`). Both are on the response, and both are dropped by a projection down
|
|
11
|
+
* to four token counters - so this recipe bills the real number and attributes it to the model
|
|
12
|
+
* that earned it.
|
|
13
|
+
*
|
|
14
|
+
* It is registered by default, because reading a response is not a pricing decision. It reports
|
|
15
|
+
* what the gateway stated - the same token counters `openAIRecipe` would, plus `cost` and the
|
|
16
|
+
* model that served - and what any of that is worth is the run's mapper to decide:
|
|
17
|
+
* `weightedMetrics(openrouterRecipe(), 'credits', { cost: 1 })` bills the money and ignores the
|
|
18
|
+
* tokens. The SDK must not infer that from a base URL, which is why it is not inferred at all.
|
|
19
|
+
*
|
|
20
|
+
* The other topology - a proxy you run yourself, fronting services that may not even be Node -
|
|
21
|
+
* has no client object to wrap and stays with the ingest-only gateway binding.
|
|
22
|
+
*/
|
|
23
|
+
const meter_stream_1 = require("../../../shared/meter-stream");
|
|
24
|
+
const openai_recipe_1 = require("../../providers/openai/openai-recipe");
|
|
25
|
+
const token_usage_1 = require("../../../shared/token-usage");
|
|
26
|
+
/** Float noise: 0.00108 * 1e6 is 1080.0000000000002, and ceil would bill the extra unit. */
|
|
27
|
+
const toUnits = (cost, unitsPerCredit) => Math.max(1, Math.ceil(Number((cost * unitsPerCredit).toPrecision(12))));
|
|
28
|
+
/**
|
|
29
|
+
* Tokens and cost, from one response.
|
|
30
|
+
*
|
|
31
|
+
* A gateway states both, they are different features, and reporting both is what lets one meter
|
|
32
|
+
* the tokens and the other the money. A streamed call simply carries no `cost` - the header went
|
|
33
|
+
* out before the first token existed - so the key is absent rather than substituted.
|
|
34
|
+
*/
|
|
35
|
+
function measure(usage, parse, keys, model, id, options) {
|
|
36
|
+
if (!usage)
|
|
37
|
+
return undefined;
|
|
38
|
+
const cost = usage.cost;
|
|
39
|
+
const metrics = (0, token_usage_1.pickMetrics)(usage, keys);
|
|
40
|
+
// `cost` is the gateway's own field name, so it obeys the same rule as every other metric.
|
|
41
|
+
if (typeof cost === 'number' && cost > 0)
|
|
42
|
+
metrics.cost = toUnits(cost, options.unitsPerCredit);
|
|
43
|
+
return {
|
|
44
|
+
metrics,
|
|
45
|
+
usage: parse(usage),
|
|
46
|
+
// What the gateway stated, before `cost` was scaled into whole units - an event can carry the
|
|
47
|
+
// fraction as it was, which is the honest number and the one that reconciles with the bill.
|
|
48
|
+
raw: usage,
|
|
49
|
+
// The gateway's own request id: stable across its retries, which a local step counter is not.
|
|
50
|
+
dimensions: model ? { [options.modelDimension]: model } : undefined,
|
|
51
|
+
idempotencyKey: id,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* One recipe covers every upstream. OpenRouter normalizes Anthropic, OpenAI and the rest to the
|
|
56
|
+
* same wire shape, so the response never changes form - `model` is just a field saying who served.
|
|
57
|
+
*/
|
|
58
|
+
function openrouterRecipe(options = {}) {
|
|
59
|
+
const resolved = {
|
|
60
|
+
estimate: options.estimate ?? (() => 1),
|
|
61
|
+
unitsPerCredit: positiveDenomination(options.unitsPerCredit, 'unitsPerCredit'),
|
|
62
|
+
modelDimension: options.modelDimension ?? 'model',
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
metrics: token_usage_1.GATEWAY_METRICS,
|
|
66
|
+
// The OpenAI shape it speaks, so the OpenAI name for the total.
|
|
67
|
+
totalTokensMetric: token_usage_1.TOTAL_TOKENS,
|
|
68
|
+
vendor: 'openrouter',
|
|
69
|
+
// Shape alone cannot separate this from any other OpenAI-compatible client, so the base URL
|
|
70
|
+
// is the discriminator - it is the one thing that says which gateway is on the other end.
|
|
71
|
+
matches: (client) => openai_recipe_1.openAIRecipe.matches(client) && isOpenRouter(client),
|
|
72
|
+
// Built from the OpenAI recipe's own surfaces. A gateway speaks that API, so the methods worth
|
|
73
|
+
// governing are the same ones - and because this recipe is matched ahead of it, any surface it
|
|
74
|
+
// failed to declare would be left ungated rather than falling through.
|
|
75
|
+
methods: openai_recipe_1.openAIRecipe.methods.map((base) => {
|
|
76
|
+
const { parse, keys, streamed } = (0, openai_recipe_1.openAISurface)(base.path);
|
|
77
|
+
return {
|
|
78
|
+
path: base.path,
|
|
79
|
+
estimate: resolved.estimate,
|
|
80
|
+
extractUsage: (result) => measure(result?.usage, parse, keys, result?.model, result?.id, resolved),
|
|
81
|
+
// Usage arrives once, at the end; `model` and `id` ride every chunk.
|
|
82
|
+
attachStream: (stream, ingest) => {
|
|
83
|
+
let usage;
|
|
84
|
+
let model;
|
|
85
|
+
let id;
|
|
86
|
+
(0, meter_stream_1.meterStream)(stream, (chunk) => {
|
|
87
|
+
const seen = streamed(chunk);
|
|
88
|
+
if (seen)
|
|
89
|
+
usage = seen;
|
|
90
|
+
if (chunk?.model)
|
|
91
|
+
model = chunk.model;
|
|
92
|
+
if (chunk?.id)
|
|
93
|
+
id = chunk.id;
|
|
94
|
+
}, () => {
|
|
95
|
+
const measurement = measure(usage, parse, keys, model, id, resolved);
|
|
96
|
+
if (measurement)
|
|
97
|
+
ingest(measurement);
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* `baseURL` is a public field on the OpenAI client. Shape cannot answer this and a wrong guess
|
|
106
|
+
* silently changes the unit of account, so the check is narrow on purpose: an OpenRouter-compatible
|
|
107
|
+
* endpoint on another host takes the recipe explicitly, via `wrap(client, run, { recipe })`.
|
|
108
|
+
*/
|
|
109
|
+
function isOpenRouter(client) {
|
|
110
|
+
const baseUrl = client?.baseURL;
|
|
111
|
+
if (typeof baseUrl !== 'string')
|
|
112
|
+
return false;
|
|
113
|
+
// The host, not a substring of the URL: `openrouter.ai.example.com` is somebody else's domain,
|
|
114
|
+
// and claiming it would meter an unrelated client as a gateway.
|
|
115
|
+
const host = hostOf(baseUrl);
|
|
116
|
+
return host === 'openrouter.ai' || host.endsWith('.openrouter.ai');
|
|
117
|
+
}
|
|
118
|
+
/** The hostname, lowercased, or '' when the URL is not one this can reason about. */
|
|
119
|
+
function hostOf(url) {
|
|
120
|
+
try {
|
|
121
|
+
return new URL(url).hostname.toLowerCase();
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return '';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The denomination, refused when it cannot divide a price.
|
|
129
|
+
*
|
|
130
|
+
* `NaN` and `Infinity` make `toUnits` produce a value the ingest rejects a call later, and zero or
|
|
131
|
+
* a negative clamps every cost to the floor of 1 - a bill that looks plausible and is not. Both
|
|
132
|
+
* are configuration mistakes worth naming where they were made.
|
|
133
|
+
*/
|
|
134
|
+
function positiveDenomination(value, name) {
|
|
135
|
+
if (value === undefined)
|
|
136
|
+
return 1_000_000;
|
|
137
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
138
|
+
throw new Error(`${name} must be a finite positive number - got ${value}, which prices every call wrongly.`);
|
|
139
|
+
}
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { OpenAILikeUsage } from '../../../shared/types';
|
|
2
|
+
/** OpenRouter extends the OpenAI usage object with the credits it actually charged. */
|
|
3
|
+
export type OpenRouterUsage = OpenAILikeUsage & {
|
|
4
|
+
cost?: number | null;
|
|
5
|
+
};
|
|
6
|
+
export type OpenRouterRecipeOptions = {
|
|
7
|
+
/**
|
|
8
|
+
* How much to ask permission for before the call.
|
|
9
|
+
*
|
|
10
|
+
* The default asks for 1 - "is there any budget left at all?" - and lets the ingest afterwards
|
|
11
|
+
* settle the balance. Billing tokens, the gap that leaves is about one call's worth of units.
|
|
12
|
+
* Billing `cost` it is not: a call priced in micro-credits is thousands of units, so a run with
|
|
13
|
+
* one unit left passes the gate and then spends far past it.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here can price a call the gateway has not priced yet, so closing that gap needs a
|
|
16
|
+
* ceiling only the caller knows - `max_tokens` against a rate they are willing to assume. Give
|
|
17
|
+
* one and the gate asks for it; leave it and the overshoot is bounded by a single call, which
|
|
18
|
+
* is the trade the default makes everywhere else.
|
|
19
|
+
*/
|
|
20
|
+
estimate?: (params: any) => number;
|
|
21
|
+
/**
|
|
22
|
+
* Units per credit. OpenRouter charges fractional credits and Stigg ingests whole units, so the
|
|
23
|
+
* meter has to be denominated in something small enough not to round every call up to 1. The
|
|
24
|
+
* default is micro-credits; set it to match however the Stigg currency is defined.
|
|
25
|
+
*/
|
|
26
|
+
unitsPerCredit?: number;
|
|
27
|
+
/** Dimension the served model is attributed to, so routing drift is visible in Stigg. */
|
|
28
|
+
modelDimension?: string;
|
|
29
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type Measurement, type Metrics } from '../../../../types';
|
|
2
|
+
import type { MetricsFor, MetricsOf } from '../../core/types';
|
|
3
|
+
/**
|
|
4
|
+
* Accumulates usage across raw stream events: Anthropic sends the input counts on
|
|
5
|
+
* `message_start` and the output count on `message_delta`, so one completed stream produces one
|
|
6
|
+
* measurement rather than one per event.
|
|
7
|
+
*/
|
|
8
|
+
declare function observeStream(stream: any, ingest: (measurement: Measurement) => void): void;
|
|
9
|
+
export declare const anthropicRecipe: {
|
|
10
|
+
vendor: string;
|
|
11
|
+
metrics: readonly ["input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens", "total_tokens"];
|
|
12
|
+
totalTokensMetric: "total_tokens";
|
|
13
|
+
matches: (client: unknown) => boolean;
|
|
14
|
+
methods: {
|
|
15
|
+
path: string[];
|
|
16
|
+
extractUsage: (result: any) => {
|
|
17
|
+
metrics: Metrics;
|
|
18
|
+
usage: import("../../../../types").TokenUsage;
|
|
19
|
+
raw: any;
|
|
20
|
+
} | undefined;
|
|
21
|
+
attachStream: typeof observeStream;
|
|
22
|
+
}[];
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* A pre-call estimate based on the requested output ceiling. Opt-in: the default asks for a
|
|
26
|
+
* single unit, because an estimate that overshoots denies calls the budget could have afforded,
|
|
27
|
+
* and the ingest that follows corrects the balance anyway.
|
|
28
|
+
*/
|
|
29
|
+
export declare const estimateFromMaxTokens: (tokensPerUnit?: number) => (params: any) => number;
|
|
30
|
+
/** One of the metric names Anthropic reports. */
|
|
31
|
+
export type AnthropicMetric = MetricsOf<typeof anthropicRecipe>;
|
|
32
|
+
/** What a mapper reads off an Anthropic call: those names, each possibly absent. */
|
|
33
|
+
export type AnthropicMetrics = MetricsFor<typeof anthropicRecipe>;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.estimateFromMaxTokens = exports.anthropicRecipe = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The Anthropic recipe.
|
|
6
|
+
*
|
|
7
|
+
* Only `messages.create` is gated, because `messages.stream()` routes through it - so one patch
|
|
8
|
+
* covers both call styles, and `stream()` keeps its synchronous signature (callers attach
|
|
9
|
+
* listeners to the returned object before awaiting anything). A denial raised inside the create
|
|
10
|
+
* leg surfaces as a rejection from `finalMessage()`/`done()`, which is where a stream caller
|
|
11
|
+
* already handles failure.
|
|
12
|
+
*/
|
|
13
|
+
const token_usage_1 = require("../../../shared/token-usage");
|
|
14
|
+
const token_usage_2 = require("../../../shared/token-usage");
|
|
15
|
+
const types_1 = require("../../../../types");
|
|
16
|
+
const estimate_1 = require("../../../shared/estimate");
|
|
17
|
+
const registry_1 = require("../../core/registry");
|
|
18
|
+
/**
|
|
19
|
+
* Accumulates usage across raw stream events: Anthropic sends the input counts on
|
|
20
|
+
* `message_start` and the output count on `message_delta`, so one completed stream produces one
|
|
21
|
+
* measurement rather than one per event.
|
|
22
|
+
*/
|
|
23
|
+
function observeStream(stream, ingest) {
|
|
24
|
+
if (!stream || typeof stream[Symbol.asyncIterator] !== 'function')
|
|
25
|
+
return;
|
|
26
|
+
const iterate = stream[Symbol.asyncIterator].bind(stream);
|
|
27
|
+
stream[Symbol.asyncIterator] = async function* gatedIterator() {
|
|
28
|
+
let total = (0, types_1.emptyTokenUsage)();
|
|
29
|
+
// Accumulated beside the normalized counters: a stream states its own names across two
|
|
30
|
+
// events, and what the run reports by default is those names.
|
|
31
|
+
let metrics = {};
|
|
32
|
+
try {
|
|
33
|
+
for await (const event of iterate()) {
|
|
34
|
+
if (event?.type === 'message_start') {
|
|
35
|
+
total = (0, token_usage_2.addTokenUsage)(total, (0, token_usage_1.fromFlatTokenUsage)(event.message?.usage));
|
|
36
|
+
metrics = (0, token_usage_1.addMetrics)(metrics, (0, token_usage_1.pickMetrics)(event.message?.usage, token_usage_1.ANTHROPIC_COMPONENT_METRICS));
|
|
37
|
+
}
|
|
38
|
+
if (event?.type === 'message_delta') {
|
|
39
|
+
total = (0, token_usage_2.addTokenUsage)(total, (0, token_usage_1.fromFlatTokenUsage)(event.usage));
|
|
40
|
+
metrics = (0, token_usage_1.addMetrics)(metrics, (0, token_usage_1.pickMetrics)(event.usage, token_usage_1.ANTHROPIC_COMPONENT_METRICS));
|
|
41
|
+
}
|
|
42
|
+
yield event;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
// Meter what was actually produced, even if the consumer broke out of the loop early.
|
|
47
|
+
//
|
|
48
|
+
// No `raw` here, deliberately, and this is the one vendor that cannot have one: the counters
|
|
49
|
+
// arrive split across `message_start` and `message_delta`, so there is no single usage block
|
|
50
|
+
// on the wire to carry. The Gemini and OpenAI streams each report one whole block and do
|
|
51
|
+
// pass it on; anything passed here would be an object this stream never actually sent.
|
|
52
|
+
// The total is added here rather than per event: these counters arrive across two of
|
|
53
|
+
// them, so summing a half-read call would report a total that was never true of it.
|
|
54
|
+
ingest({ metrics: (0, token_usage_1.withComputedTotal)(metrics, token_usage_1.COMPUTED_TOTALS.anthropic), usage: total });
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
exports.anthropicRecipe = {
|
|
59
|
+
vendor: 'anthropic',
|
|
60
|
+
metrics: token_usage_1.ANTHROPIC_METRICS,
|
|
61
|
+
totalTokensMetric: token_usage_1.TOTAL_TOKENS,
|
|
62
|
+
matches: (client) => (0, registry_1.hasMethod)(client, ['messages', 'create']) && (0, registry_1.hasMethod)(client, ['messages', 'stream']),
|
|
63
|
+
methods: [
|
|
64
|
+
{
|
|
65
|
+
// `messages.stream()` reaches the wire through this same method, so gating it here covers
|
|
66
|
+
// both call styles - and leaves `stream()` synchronous, which its callers rely on.
|
|
67
|
+
path: ['messages', 'create'],
|
|
68
|
+
// Both views of the call: what Anthropic called it, which is what gets reported, and the
|
|
69
|
+
// normalized counters a mapper of your own would write against.
|
|
70
|
+
extractUsage: (result) => result?.usage
|
|
71
|
+
? {
|
|
72
|
+
// Anthropic publishes no total of its own, so one is added up from the four it
|
|
73
|
+
// does publish - which are disjoint, so the sum is the whole call.
|
|
74
|
+
metrics: (0, token_usage_1.withComputedTotal)((0, token_usage_1.pickMetrics)(result.usage, token_usage_1.ANTHROPIC_COMPONENT_METRICS), token_usage_1.COMPUTED_TOTALS.anthropic),
|
|
75
|
+
usage: (0, token_usage_1.fromFlatTokenUsage)(result.usage),
|
|
76
|
+
// The block as it arrived, for an event to state in full - including the counters
|
|
77
|
+
// this recipe does not name, which a meter can then read without an SDK release.
|
|
78
|
+
raw: result.usage,
|
|
79
|
+
}
|
|
80
|
+
: undefined,
|
|
81
|
+
attachStream: observeStream,
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* A pre-call estimate based on the requested output ceiling. Opt-in: the default asks for a
|
|
87
|
+
* single unit, because an estimate that overshoots denies calls the budget could have afforded,
|
|
88
|
+
* and the ingest that follows corrects the balance anyway.
|
|
89
|
+
*/
|
|
90
|
+
const estimateFromMaxTokens = (tokensPerUnit = 1) => (0, estimate_1.estimateUnits)((params) => params?.max_tokens ?? 0, tokensPerUnit);
|
|
91
|
+
exports.estimateFromMaxTokens = estimateFromMaxTokens;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type Measurement } from '../../../../types';
|
|
2
|
+
import type { MetricsFor, MetricsOf } from '../../core/types';
|
|
3
|
+
/** The one-shot surface: `ai.models.generateContent` and its streaming twin. */
|
|
4
|
+
export declare const geminiRecipe: {
|
|
5
|
+
vendor: string;
|
|
6
|
+
metrics: readonly ["promptTokenCount", "candidatesTokenCount", "cachedContentTokenCount", "thoughtsTokenCount", "toolUsePromptTokenCount", "totalTokenCount"];
|
|
7
|
+
totalTokensMetric: "totalTokenCount";
|
|
8
|
+
matches: (client: unknown) => boolean;
|
|
9
|
+
methods: ({
|
|
10
|
+
path: readonly ["models", "generateContent"];
|
|
11
|
+
extractUsage: (result: any) => Measurement | undefined;
|
|
12
|
+
attachStream: (stream: any, ingest: (measurement: Measurement) => void) => void;
|
|
13
|
+
} | {
|
|
14
|
+
path: readonly ["models", "generateContentStream"];
|
|
15
|
+
extractUsage: (result: any) => Measurement | undefined;
|
|
16
|
+
attachStream: (stream: any, ingest: (measurement: Measurement) => void) => void;
|
|
17
|
+
})[];
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The conversational surface: one `Chat`, as `ai.chats.create(...)` hands it back.
|
|
21
|
+
*
|
|
22
|
+
* Matched on all three of its own methods rather than on `sendMessage` alone - that name is far
|
|
23
|
+
* too common for a duck-typed registry to claim on sight, and a wrong match here would wrap
|
|
24
|
+
* somebody else's object and meter its calls as Gemini traffic.
|
|
25
|
+
*/
|
|
26
|
+
export declare const geminiChatRecipe: {
|
|
27
|
+
vendor: string;
|
|
28
|
+
metrics: readonly ["promptTokenCount", "candidatesTokenCount", "cachedContentTokenCount", "thoughtsTokenCount", "toolUsePromptTokenCount", "totalTokenCount"];
|
|
29
|
+
totalTokensMetric: "totalTokenCount";
|
|
30
|
+
matches: (client: unknown) => boolean;
|
|
31
|
+
methods: ({
|
|
32
|
+
path: readonly ["sendMessage"];
|
|
33
|
+
event: string;
|
|
34
|
+
extractUsage: (result: any) => Measurement | undefined;
|
|
35
|
+
attachStream: (stream: any, ingest: (measurement: Measurement) => void) => void;
|
|
36
|
+
} | {
|
|
37
|
+
path: readonly ["sendMessageStream"];
|
|
38
|
+
event: string;
|
|
39
|
+
extractUsage: (result: any) => Measurement | undefined;
|
|
40
|
+
attachStream: (stream: any, ingest: (measurement: Measurement) => void) => void;
|
|
41
|
+
})[];
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* A pre-call estimate based on the requested output ceiling, which Gemini nests under `config`.
|
|
45
|
+
*
|
|
46
|
+
* Opt-in, on the same terms as the Anthropic one: the default asks for a single unit, because an
|
|
47
|
+
* estimate that overshoots denies calls the budget could have afforded, and the ingest that
|
|
48
|
+
* follows corrects the balance anyway.
|
|
49
|
+
*/
|
|
50
|
+
export declare const estimateFromMaxOutputTokens: (tokensPerUnit?: number) => (params: any) => number;
|
|
51
|
+
/** One of the metric names Gemini reports. */
|
|
52
|
+
export type GeminiMetric = MetricsOf<typeof geminiRecipe>;
|
|
53
|
+
/** What a mapper reads off a Gemini call: those names, each possibly absent. */
|
|
54
|
+
export type GeminiMetrics = MetricsFor<typeof geminiRecipe>;
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.estimateFromMaxOutputTokens = exports.geminiChatRecipe = exports.geminiRecipe = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The Gemini recipe - `@google/genai`, the GA SDK that replaced `@google/generative-ai`.
|
|
6
|
+
*
|
|
7
|
+
* Two recipes, because the two call styles share no code path. `new GoogleGenAI()` builds its
|
|
8
|
+
* chat module as `new Chats(this.models, ...)` and each `Chat` keeps that reference privately, so
|
|
9
|
+
* a chat created from a wrapped client still calls the original, unpatched `models.generateContent`.
|
|
10
|
+
* Gating `models.*` alone would leave every conversation ungoverned while the client looked
|
|
11
|
+
* governed, so a chat is wrapped in its own right:
|
|
12
|
+
*
|
|
13
|
+
* const chat = stigg.wrap(ai.chats.create({ model }));
|
|
14
|
+
*
|
|
15
|
+
* Nothing meters twice: the chat reaches the unwrapped module by construction.
|
|
16
|
+
*
|
|
17
|
+
* Generation only. `models.embedContent`, `generateImages`, `generateVideos`, `countTokens`,
|
|
18
|
+
* `batches.*`, `caches.*` and `live.*` are billable and **not** gated here - each counts in a unit
|
|
19
|
+
* these metrics do not describe. Meter one by naming your own recipe at `wrap(client, { recipe })`.
|
|
20
|
+
*/
|
|
21
|
+
const token_usage_1 = require("../../../shared/token-usage");
|
|
22
|
+
const meter_stream_1 = require("../../../shared/meter-stream");
|
|
23
|
+
const types_1 = require("../../../../types");
|
|
24
|
+
const estimate_1 = require("../../../shared/estimate");
|
|
25
|
+
const registry_1 = require("../../core/registry");
|
|
26
|
+
/**
|
|
27
|
+
* Both views of a completed response: what Gemini called the counters, which is what gets
|
|
28
|
+
* reported, and the normalized ones a mapper of your own would write against.
|
|
29
|
+
*
|
|
30
|
+
* Returns undefined for a streamed call - `generateContentStream` resolves to an async generator
|
|
31
|
+
* with no `usageMetadata` on it - which is what hands the result to `attachStream` below.
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* How many model calls one `generateContent` made.
|
|
35
|
+
*
|
|
36
|
+
* With a callable tool the SDK drives the tool loop itself - up to `maximumRemoteCalls`, 10 by
|
|
37
|
+
* default - and returns only the last response. Each round that called a tool leaves its reply in
|
|
38
|
+
* `automaticFunctionCallingHistory`, which is `Content` and carries no token counts: the rounds
|
|
39
|
+
* can be counted, not priced.
|
|
40
|
+
*/
|
|
41
|
+
function afcRounds(result) {
|
|
42
|
+
const history = result?.automaticFunctionCallingHistory;
|
|
43
|
+
if (!Array.isArray(history))
|
|
44
|
+
return 1;
|
|
45
|
+
const toolReplies = history.filter((content) => Array.isArray(content?.parts) && content.parts.some((part) => part?.functionResponse)).length;
|
|
46
|
+
return toolReplies + 1;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* What a call whose earlier rounds were never reported can honestly say about itself.
|
|
50
|
+
*
|
|
51
|
+
* `generateContent` reassigns `response` each iteration and returns the last, so the earlier
|
|
52
|
+
* rounds are gone before it returns and no arithmetic here recovers them. Measured: two rounds
|
|
53
|
+
* billed at 126 and 171, of which the response carries 171. The streamed surface has no such gap.
|
|
54
|
+
*/
|
|
55
|
+
const partialAfcReason = (rounds, measured) => `Gemini ran ${rounds} model calls for one generateContent (automatic function calling), and the ` +
|
|
56
|
+
`response carries usage for the last one only - ${measured} tokens, out of a total this SDK ` +
|
|
57
|
+
`cannot see. The earlier rounds were billed by the vendor and are not in the response, so they ` +
|
|
58
|
+
`are not in this measurement. To meter every round: stream the call, or set ` +
|
|
59
|
+
`\`automaticFunctionCalling: { disable: true }\` and drive the tool loop yourself, which makes ` +
|
|
60
|
+
`each round a call this SDK gates and meters on its own.`;
|
|
61
|
+
const extractUsage = (result) => result?.usageMetadata
|
|
62
|
+
? {
|
|
63
|
+
metrics: (0, token_usage_1.pickMetrics)(result.usageMetadata, token_usage_1.GEMINI_METRICS),
|
|
64
|
+
usage: (0, token_usage_1.fromGeminiUsage)(result.usageMetadata),
|
|
65
|
+
// The vendor's own id, so one turn reported twice is one turn. The fallback is a step
|
|
66
|
+
// counter off the run, which is stable only within a process.
|
|
67
|
+
...(typeof result.responseId === 'string' ? { idempotencyKey: result.responseId } : {}),
|
|
68
|
+
...afcShortfall(result),
|
|
69
|
+
// The block as it arrived, for an event to state in full - including `totalTokenCount`
|
|
70
|
+
// and the per-modality breakdowns this recipe does not name, which a meter can then read
|
|
71
|
+
// without waiting for an SDK release.
|
|
72
|
+
raw: result.usageMetadata,
|
|
73
|
+
}
|
|
74
|
+
: undefined;
|
|
75
|
+
/** The rounds this call made and what went unmeasured, for the calls where any did. */
|
|
76
|
+
function afcShortfall(result) {
|
|
77
|
+
const rounds = afcRounds(result);
|
|
78
|
+
if (rounds < 2)
|
|
79
|
+
return {};
|
|
80
|
+
return {
|
|
81
|
+
dimensions: { gemini_afc_rounds: String(rounds) },
|
|
82
|
+
incomplete: partialAfcReason(rounds, n(result?.usageMetadata?.totalTokenCount)),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const n = (value) => (typeof value === 'number' ? value : 0);
|
|
86
|
+
/**
|
|
87
|
+
* Meters a streamed call: the last block of each round, summed across rounds.
|
|
88
|
+
*
|
|
89
|
+
* **Within a round** `usageMetadata` repeats on every chunk as a running total, so the last block
|
|
90
|
+
* is the whole round and summing the chunks bills it many times over. **Across rounds** automatic
|
|
91
|
+
* function calling yields several rounds through one generator, each counting from zero, so
|
|
92
|
+
* keeping only the last block bills one round of several. Measured: 305 charged, 175 from the last
|
|
93
|
+
* block alone, 556 from summing every chunk.
|
|
94
|
+
*
|
|
95
|
+
* The boundary is `responseId`, shared by a round's chunks and changed by the next, and it is
|
|
96
|
+
* trusted on its own. Measured rather than assumed: `promptTokenCount` changing is **not** a
|
|
97
|
+
* boundary - it moves within a round too (92 -> 131 on the wire) - and neither is a counter
|
|
98
|
+
* *failing* to drop, since a round that ended on a function call is easily smaller than the first
|
|
99
|
+
* chunk of the one after it. Where no id is reported, a counter running backwards stands in,
|
|
100
|
+
* being the only evidence left that a round restarted.
|
|
101
|
+
*/
|
|
102
|
+
const attachStream = (stream, ingest) => {
|
|
103
|
+
/** Rounds already finished, which never change again. */
|
|
104
|
+
let banked = (0, types_1.emptyTokenUsage)();
|
|
105
|
+
let bankedMetrics = {};
|
|
106
|
+
/** The round being read, replaced by each block it reports because they are running totals. */
|
|
107
|
+
let round;
|
|
108
|
+
let roundMetrics = {};
|
|
109
|
+
/** What the round being read calls itself, which is what makes the boundary exact. */
|
|
110
|
+
let roundId;
|
|
111
|
+
/** Undefined until a chunk states one. A counter nobody has reported cannot have gone down. */
|
|
112
|
+
let candidates;
|
|
113
|
+
let thoughts;
|
|
114
|
+
/**
|
|
115
|
+
* The block as it arrived, for an event to state in full - kept only while the stream turns
|
|
116
|
+
* out to be a single round. Copied rather than referenced, because it is held until the stream
|
|
117
|
+
* ends and the chunk it came from is the consumer's: a reference would let the dimensions
|
|
118
|
+
* reported disagree with the metrics computed beside them. (`extractUsage` needs no copy -
|
|
119
|
+
* there the block is flattened in the same tick it is read.)
|
|
120
|
+
*/
|
|
121
|
+
let raw;
|
|
122
|
+
let lastId;
|
|
123
|
+
let rounds = 0;
|
|
124
|
+
(0, meter_stream_1.meterStream)(stream, (chunk) => {
|
|
125
|
+
const reported = chunk?.usageMetadata;
|
|
126
|
+
if (!reported)
|
|
127
|
+
return;
|
|
128
|
+
const id = typeof chunk.responseId === 'string' ? chunk.responseId : undefined;
|
|
129
|
+
const nextCandidates = typeof reported.candidatesTokenCount === 'number' ? reported.candidatesTokenCount : undefined;
|
|
130
|
+
const nextThoughts = typeof reported.thoughtsTokenCount === 'number' ? reported.thoughtsTokenCount : undefined;
|
|
131
|
+
// Both values must be present for a decrease to mean anything: an absent counter read as
|
|
132
|
+
// zero looks like a restart, and banks a round that never ended.
|
|
133
|
+
const dropped = (next, previous) => next !== undefined && previous !== undefined && next < previous;
|
|
134
|
+
// The id decides, on its own, wherever one is reported: a `responseId` identifies one
|
|
135
|
+
// response, and one response is one round. Requiring a counter to drop as well looked
|
|
136
|
+
// safer and was not - it misses a boundary whenever the new round's first usage chunk
|
|
137
|
+
// already carries more output than the whole of the previous round, which is the normal
|
|
138
|
+
// shape when a round ends on a function call. Measured: 223 billed as 147.
|
|
139
|
+
//
|
|
140
|
+
// The counters are the fallback for a stream that names nothing, where a counter running
|
|
141
|
+
// backwards is the only evidence a round restarted.
|
|
142
|
+
const restarted = dropped(nextCandidates, candidates) || dropped(nextThoughts, thoughts);
|
|
143
|
+
const endsRound = id !== undefined && roundId !== undefined ? id !== roundId : restarted;
|
|
144
|
+
// A round ended: bank what it finally reported before the new one starts from zero.
|
|
145
|
+
let ended = false;
|
|
146
|
+
if (round !== undefined && endsRound) {
|
|
147
|
+
ended = true;
|
|
148
|
+
banked = (0, token_usage_1.addTokenUsage)(banked, round);
|
|
149
|
+
bankedMetrics = (0, token_usage_1.addMetrics)(bankedMetrics, roundMetrics);
|
|
150
|
+
rounds += 1;
|
|
151
|
+
}
|
|
152
|
+
round = (0, token_usage_1.fromGeminiUsage)(reported);
|
|
153
|
+
roundMetrics = (0, token_usage_1.pickMetrics)(reported, token_usage_1.GEMINI_METRICS);
|
|
154
|
+
raw = { ...reported };
|
|
155
|
+
roundId = id;
|
|
156
|
+
// The last round's id names the call: one measurement goes out, and this is the id that is
|
|
157
|
+
// still the same if the stream is read again.
|
|
158
|
+
if (id !== undefined)
|
|
159
|
+
lastId = id;
|
|
160
|
+
// Within a round an omitted counter keeps its last value; across one the baselines are
|
|
161
|
+
// replaced, absence included. Carrying the old round's figure made the next chunk stating
|
|
162
|
+
// that counter look like a further restart, banking a round mid-round.
|
|
163
|
+
if (ended) {
|
|
164
|
+
candidates = nextCandidates;
|
|
165
|
+
thoughts = nextThoughts;
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
if (nextCandidates !== undefined)
|
|
169
|
+
candidates = nextCandidates;
|
|
170
|
+
if (nextThoughts !== undefined)
|
|
171
|
+
thoughts = nextThoughts;
|
|
172
|
+
}
|
|
173
|
+
}, () => {
|
|
174
|
+
const usage = round ? (0, token_usage_1.addTokenUsage)(banked, round) : banked;
|
|
175
|
+
const metrics = round ? (0, token_usage_1.addMetrics)(bankedMetrics, roundMetrics) : bankedMetrics;
|
|
176
|
+
// One round, one block, and `raw` describes the whole measurement. Several rounds and no
|
|
177
|
+
// block does: each states only its own round, so passing the last would hand a meter
|
|
178
|
+
// dimensions that disagree with the metrics beside them. Reported instead is how many
|
|
179
|
+
// rounds there were, which is the thing a reader would otherwise have no way to see.
|
|
180
|
+
const single = rounds === 0 && raw;
|
|
181
|
+
ingest({
|
|
182
|
+
metrics,
|
|
183
|
+
usage,
|
|
184
|
+
...(single ? { raw } : {}),
|
|
185
|
+
...(lastId ? { idempotencyKey: lastId } : {}),
|
|
186
|
+
...(rounds ? { dimensions: { gemini_afc_rounds: String(rounds + 1) } } : {}),
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
/** The one-shot surface: `ai.models.generateContent` and its streaming twin. */
|
|
191
|
+
exports.geminiRecipe = {
|
|
192
|
+
vendor: 'gemini',
|
|
193
|
+
metrics: token_usage_1.GEMINI_METRICS,
|
|
194
|
+
// Gemini's own name for it, which is the one its invoice shows.
|
|
195
|
+
totalTokensMetric: 'totalTokenCount',
|
|
196
|
+
// Either is enough on its own, the way the OpenAI recipe treats its two surfaces. Both methods
|
|
197
|
+
// sit on the same module today, so requiring both would pass anyway - and would start refusing
|
|
198
|
+
// clients the day the vendor splits them, which is a guard failing for a reason nobody intended.
|
|
199
|
+
matches: (client) => (0, registry_1.hasMethod)(client, ['models', 'generateContent']) || (0, registry_1.hasMethod)(client, ['models', 'generateContentStream']),
|
|
200
|
+
// `as const` on each path, which is what `PathsOf` reads: a recipe named explicitly at
|
|
201
|
+
// `wrap(client, { recipe })` then overlays the per-call context on exactly the methods it
|
|
202
|
+
// gates, rather than falling back to the shipped vendors' list. It matters most for the chat
|
|
203
|
+
// recipe below - see there - and both are written the same way so neither drifts.
|
|
204
|
+
methods: [
|
|
205
|
+
{ path: ['models', 'generateContent'], extractUsage, attachStream },
|
|
206
|
+
{ path: ['models', 'generateContentStream'], extractUsage, attachStream },
|
|
207
|
+
],
|
|
208
|
+
};
|
|
209
|
+
/**
|
|
210
|
+
* The conversational surface: one `Chat`, as `ai.chats.create(...)` hands it back.
|
|
211
|
+
*
|
|
212
|
+
* Matched on all three of its own methods rather than on `sendMessage` alone - that name is far
|
|
213
|
+
* too common for a duck-typed registry to claim on sight, and a wrong match here would wrap
|
|
214
|
+
* somebody else's object and meter its calls as Gemini traffic.
|
|
215
|
+
*/
|
|
216
|
+
exports.geminiChatRecipe = {
|
|
217
|
+
vendor: 'gemini',
|
|
218
|
+
metrics: token_usage_1.GEMINI_METRICS,
|
|
219
|
+
// Gemini's own name for it, which is the one its invoice shows.
|
|
220
|
+
totalTokensMetric: 'totalTokenCount',
|
|
221
|
+
matches: (client) => (0, registry_1.hasMethod)(client, ['sendMessage']) && (0, registry_1.hasMethod)(client, ['sendMessageStream']) && (0, registry_1.hasMethod)(client, ['getHistory']),
|
|
222
|
+
// Literal paths, so naming this recipe explicitly types the context onto these two methods
|
|
223
|
+
// whatever the client looks like. Without it the overlay fell back to `ChatOf<T>`, which keys
|
|
224
|
+
// off `getHistory` - right for detection, wrong here: an explicit recipe never consults
|
|
225
|
+
// `matches`, so `wrapClient` patches and strips the context on a two-method client the types
|
|
226
|
+
// were refusing it on.
|
|
227
|
+
methods: [
|
|
228
|
+
// Named for where the call comes from rather than for the object it was reached through: a
|
|
229
|
+
// bare `gemini.sendMessage` says nothing about which API it is, and the event name is what
|
|
230
|
+
// somebody has to recognise when configuring a meter to filter on it.
|
|
231
|
+
{ path: ['sendMessage'], event: 'gemini.chats.sendMessage', extractUsage, attachStream },
|
|
232
|
+
{ path: ['sendMessageStream'], event: 'gemini.chats.sendMessageStream', extractUsage, attachStream },
|
|
233
|
+
],
|
|
234
|
+
};
|
|
235
|
+
/**
|
|
236
|
+
* A pre-call estimate based on the requested output ceiling, which Gemini nests under `config`.
|
|
237
|
+
*
|
|
238
|
+
* Opt-in, on the same terms as the Anthropic one: the default asks for a single unit, because an
|
|
239
|
+
* estimate that overshoots denies calls the budget could have afforded, and the ingest that
|
|
240
|
+
* follows corrects the balance anyway.
|
|
241
|
+
*/
|
|
242
|
+
const estimateFromMaxOutputTokens = (tokensPerUnit = 1) => (0, estimate_1.estimateUnits)((params) => params?.config?.maxOutputTokens ?? 0, tokensPerUnit);
|
|
243
|
+
exports.estimateFromMaxOutputTokens = estimateFromMaxOutputTokens;
|