@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.
Files changed (82) hide show
  1. package/README.md +1147 -2
  2. package/dist/bindings/client/core/registry.d.ts +12 -0
  3. package/dist/bindings/client/core/registry.js +22 -0
  4. package/dist/bindings/client/core/types.d.ts +117 -0
  5. package/dist/bindings/client/core/types.js +13 -0
  6. package/dist/bindings/client/core/wrap-client.d.ts +6 -0
  7. package/dist/bindings/client/core/wrap-client.js +258 -0
  8. package/dist/bindings/client/gateways/litellm/litellm-recipe.d.ts +17 -0
  9. package/dist/bindings/client/gateways/litellm/litellm-recipe.js +171 -0
  10. package/dist/bindings/client/gateways/litellm/types.d.ts +30 -0
  11. package/dist/bindings/client/gateways/litellm/types.js +2 -0
  12. package/dist/bindings/client/gateways/openrouter/openrouter-recipe.d.ts +15 -0
  13. package/dist/bindings/client/gateways/openrouter/openrouter-recipe.js +141 -0
  14. package/dist/bindings/client/gateways/openrouter/types.d.ts +29 -0
  15. package/dist/bindings/client/gateways/openrouter/types.js +2 -0
  16. package/dist/bindings/client/providers/anthropic/anthropic-recipe.d.ts +34 -0
  17. package/dist/bindings/client/providers/anthropic/anthropic-recipe.js +91 -0
  18. package/dist/bindings/client/providers/gemini/gemini-recipe.d.ts +54 -0
  19. package/dist/bindings/client/providers/gemini/gemini-recipe.js +243 -0
  20. package/dist/bindings/client/providers/openai/openai-recipe.d.ts +43 -0
  21. package/dist/bindings/client/providers/openai/openai-recipe.js +80 -0
  22. package/dist/bindings/client/providers/typesafe/typesafe-recipe.d.ts +33 -0
  23. package/dist/bindings/client/providers/typesafe/typesafe-recipe.js +73 -0
  24. package/dist/bindings/proxy/headers.d.ts +6 -0
  25. package/dist/bindings/proxy/headers.js +12 -0
  26. package/dist/bindings/proxy/index.d.ts +21 -0
  27. package/dist/bindings/proxy/index.js +18 -0
  28. package/dist/bindings/proxy/types.d.ts +8 -0
  29. package/dist/bindings/proxy/types.js +2 -0
  30. package/dist/bindings/shared/estimate.d.ts +27 -0
  31. package/dist/bindings/shared/estimate.js +38 -0
  32. package/dist/bindings/shared/meter-stream.d.ts +18 -0
  33. package/dist/bindings/shared/meter-stream.js +151 -0
  34. package/dist/bindings/shared/token-usage.d.ts +148 -0
  35. package/dist/bindings/shared/token-usage.js +414 -0
  36. package/dist/bindings/shared/types.d.ts +79 -0
  37. package/dist/bindings/shared/types.js +2 -0
  38. package/dist/bindings/shared/weighted-metrics.d.ts +37 -0
  39. package/dist/bindings/shared/weighted-metrics.js +44 -0
  40. package/dist/config.d.ts +136 -0
  41. package/dist/config.js +97 -0
  42. package/dist/core/context.d.ts +151 -0
  43. package/dist/core/context.js +154 -0
  44. package/dist/core/dimensions.d.ts +22 -0
  45. package/dist/core/dimensions.js +47 -0
  46. package/dist/core/hooks.d.ts +21 -0
  47. package/dist/core/hooks.js +47 -0
  48. package/dist/core/session.d.ts +161 -0
  49. package/dist/core/session.js +403 -0
  50. package/dist/decision-text.d.ts +15 -0
  51. package/dist/decision-text.js +54 -0
  52. package/dist/engine/composite-engine.d.ts +72 -0
  53. package/dist/engine/composite-engine.js +135 -0
  54. package/dist/engine/create-engine.d.ts +16 -0
  55. package/dist/engine/create-engine.js +93 -0
  56. package/dist/engine/decision.d.ts +37 -0
  57. package/dist/engine/decision.js +128 -0
  58. package/dist/engine/engine.d.ts +166 -0
  59. package/dist/engine/engine.js +11 -0
  60. package/dist/engine/node-sdk-engine.d.ts +125 -0
  61. package/dist/engine/node-sdk-engine.js +262 -0
  62. package/dist/engine/report.d.ts +106 -0
  63. package/dist/engine/report.js +185 -0
  64. package/dist/engine/rest-governance-engine.d.ts +47 -0
  65. package/dist/engine/rest-governance-engine.js +220 -0
  66. package/dist/errors.d.ts +78 -0
  67. package/dist/errors.js +151 -0
  68. package/dist/headers.d.ts +27 -0
  69. package/dist/headers.js +63 -0
  70. package/dist/index.d.ts +48 -0
  71. package/dist/index.js +85 -0
  72. package/dist/internal.d.ts +33 -0
  73. package/dist/internal.js +81 -0
  74. package/dist/logger.d.ts +15 -0
  75. package/dist/logger.js +20 -0
  76. package/dist/sdk-identity.d.ts +36 -0
  77. package/dist/sdk-identity.js +43 -0
  78. package/dist/stigg-agent-sdk.d.ts +242 -0
  79. package/dist/stigg-agent-sdk.js +594 -0
  80. package/dist/types.d.ts +531 -0
  81. package/dist/types.js +97 -0
  82. 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,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -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;