@stigg/agent-sdk-js 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +1296 -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/bedrock/bedrock-recipe.d.ts +17 -0
  9. package/dist/bindings/client/gateways/bedrock/bedrock-recipe.js +134 -0
  10. package/dist/bindings/client/gateways/bedrock/types.d.ts +13 -0
  11. package/dist/bindings/client/gateways/bedrock/types.js +2 -0
  12. package/dist/bindings/client/gateways/litellm/litellm-recipe.d.ts +17 -0
  13. package/dist/bindings/client/gateways/litellm/litellm-recipe.js +171 -0
  14. package/dist/bindings/client/gateways/litellm/types.d.ts +30 -0
  15. package/dist/bindings/client/gateways/litellm/types.js +2 -0
  16. package/dist/bindings/client/gateways/openrouter/openrouter-recipe.d.ts +20 -0
  17. package/dist/bindings/client/gateways/openrouter/openrouter-recipe.js +244 -0
  18. package/dist/bindings/client/gateways/openrouter/sdk-usage.d.ts +37 -0
  19. package/dist/bindings/client/gateways/openrouter/sdk-usage.js +63 -0
  20. package/dist/bindings/client/gateways/openrouter/types.d.ts +78 -0
  21. package/dist/bindings/client/gateways/openrouter/types.js +2 -0
  22. package/dist/bindings/client/providers/anthropic/anthropic-recipe.d.ts +34 -0
  23. package/dist/bindings/client/providers/anthropic/anthropic-recipe.js +169 -0
  24. package/dist/bindings/client/providers/gemini/gemini-recipe.d.ts +54 -0
  25. package/dist/bindings/client/providers/gemini/gemini-recipe.js +243 -0
  26. package/dist/bindings/client/providers/groq/groq-recipe.d.ts +20 -0
  27. package/dist/bindings/client/providers/groq/groq-recipe.js +101 -0
  28. package/dist/bindings/client/providers/mistral/mistral-recipe.d.ts +22 -0
  29. package/dist/bindings/client/providers/mistral/mistral-recipe.js +114 -0
  30. package/dist/bindings/client/providers/mistral/types.d.ts +38 -0
  31. package/dist/bindings/client/providers/mistral/types.js +2 -0
  32. package/dist/bindings/client/providers/ollama/ollama-recipe.d.ts +31 -0
  33. package/dist/bindings/client/providers/ollama/ollama-recipe.js +107 -0
  34. package/dist/bindings/client/providers/ollama/types.d.ts +24 -0
  35. package/dist/bindings/client/providers/ollama/types.js +2 -0
  36. package/dist/bindings/client/providers/openai/openai-recipe.d.ts +71 -0
  37. package/dist/bindings/client/providers/openai/openai-recipe.js +122 -0
  38. package/dist/bindings/client/providers/typesafe/typesafe-recipe.d.ts +44 -0
  39. package/dist/bindings/client/providers/typesafe/typesafe-recipe.js +83 -0
  40. package/dist/bindings/proxy/headers.d.ts +6 -0
  41. package/dist/bindings/proxy/headers.js +12 -0
  42. package/dist/bindings/proxy/index.d.ts +21 -0
  43. package/dist/bindings/proxy/index.js +18 -0
  44. package/dist/bindings/proxy/types.d.ts +8 -0
  45. package/dist/bindings/proxy/types.js +2 -0
  46. package/dist/bindings/shared/estimate.d.ts +27 -0
  47. package/dist/bindings/shared/estimate.js +38 -0
  48. package/dist/bindings/shared/meter-stream.d.ts +18 -0
  49. package/dist/bindings/shared/meter-stream.js +151 -0
  50. package/dist/bindings/shared/token-usage.d.ts +240 -0
  51. package/dist/bindings/shared/token-usage.js +574 -0
  52. package/dist/bindings/shared/types.d.ts +129 -0
  53. package/dist/bindings/shared/types.js +2 -0
  54. package/dist/bindings/shared/weighted-metrics.d.ts +37 -0
  55. package/dist/bindings/shared/weighted-metrics.js +47 -0
  56. package/dist/config.d.ts +145 -0
  57. package/dist/config.js +99 -0
  58. package/dist/core/context.d.ts +160 -0
  59. package/dist/core/context.js +221 -0
  60. package/dist/core/dimensions.d.ts +22 -0
  61. package/dist/core/dimensions.js +47 -0
  62. package/dist/core/hooks.d.ts +21 -0
  63. package/dist/core/hooks.js +47 -0
  64. package/dist/core/session.d.ts +190 -0
  65. package/dist/core/session.js +448 -0
  66. package/dist/decision-text.d.ts +15 -0
  67. package/dist/decision-text.js +54 -0
  68. package/dist/engine/composite-engine.d.ts +72 -0
  69. package/dist/engine/composite-engine.js +135 -0
  70. package/dist/engine/create-engine.d.ts +16 -0
  71. package/dist/engine/create-engine.js +93 -0
  72. package/dist/engine/decision.d.ts +42 -0
  73. package/dist/engine/decision.js +135 -0
  74. package/dist/engine/engine.d.ts +166 -0
  75. package/dist/engine/engine.js +11 -0
  76. package/dist/engine/node-sdk-engine.d.ts +125 -0
  77. package/dist/engine/node-sdk-engine.js +262 -0
  78. package/dist/engine/report.d.ts +106 -0
  79. package/dist/engine/report.js +188 -0
  80. package/dist/engine/rest-governance-engine.d.ts +47 -0
  81. package/dist/engine/rest-governance-engine.js +220 -0
  82. package/dist/errors.d.ts +86 -0
  83. package/dist/errors.js +161 -0
  84. package/dist/headers.d.ts +40 -0
  85. package/dist/headers.js +82 -0
  86. package/dist/index.d.ts +57 -0
  87. package/dist/index.js +106 -0
  88. package/dist/internal.d.ts +33 -0
  89. package/dist/internal.js +87 -0
  90. package/dist/logger.d.ts +15 -0
  91. package/dist/logger.js +20 -0
  92. package/dist/sdk-identity.d.ts +36 -0
  93. package/dist/sdk-identity.js +43 -0
  94. package/dist/stigg-agent-sdk.d.ts +300 -0
  95. package/dist/stigg-agent-sdk.js +656 -0
  96. package/dist/types.d.ts +642 -0
  97. package/dist/types.js +147 -0
  98. package/package.json +89 -3
@@ -0,0 +1,34 @@
1
+ import type { Measurement } 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 restates them, with the output count, on `message_delta` - so one completed
6
+ * stream produces one 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: import("../../../../types").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,169 @@
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 estimate_1 = require("../../../shared/estimate");
15
+ const registry_1 = require("../../core/registry");
16
+ /**
17
+ * Characters per output token, for the one case where the vendor states no count.
18
+ *
19
+ * Approximate on purpose and deliberately not tuned: it is only ever reached for a stream that
20
+ * was abandoned, where the alternative is reporting nothing for text the customer read and the
21
+ * vendor billed. Roughly four characters to a token holds well enough across English prose for
22
+ * the estimate to be the closer of the two answers. Anything reported from it is marked
23
+ * `estimated` so it is never mistaken for a counter Anthropic stated.
24
+ */
25
+ const CHARS_PER_OUTPUT_TOKEN = 4;
26
+ /** The characters a delta contributed, or 0 for the events that generate nothing. */
27
+ function generatedChars(delta) {
28
+ if (!delta)
29
+ return 0;
30
+ const produced = delta.type === 'text_delta'
31
+ ? delta.text
32
+ : delta.type === 'thinking_delta'
33
+ ? delta.thinking
34
+ : delta.type === 'input_json_delta'
35
+ ? delta.partial_json
36
+ : undefined;
37
+ return typeof produced === 'string' ? produced.length : 0;
38
+ }
39
+ /**
40
+ * Whether a wire value is a count at all.
41
+ *
42
+ * Whole and non-negative, because a counter that *replaces* another has to be checked before it
43
+ * is trusted: a negative or fractional one is not a token count, and letting it overwrite a real
44
+ * count reads back as zero.
45
+ */
46
+ const isCount = (value) => typeof value === 'number' && Number.isInteger(value) && value >= 0;
47
+ /**
48
+ * The counters a stream has most recently stated - each event replacing what it names, rather
49
+ * than adding to it.
50
+ *
51
+ * Anthropic restates cumulatively. `message_delta.usage.input_tokens` is "the *cumulative* number
52
+ * of input tokens which were used", in the vendor's own words, and the same holds for both cache
53
+ * counters and for `output_tokens`. None of them is a delta despite the event's name, so summing
54
+ * the two events bills every input counter twice.
55
+ *
56
+ * Per counter rather than by replacing the block wholesale, because the two events do not carry
57
+ * the same fields: on older API versions `message_start` is the only one that states the input
58
+ * counts, and a wholesale replace would make the opposite billing error.
59
+ *
60
+ * The key list is {@link ANTHROPIC_COMPONENT_METRICS} rather than one of its own: these are the
61
+ * same four counters under the same four names, and a second list would be free to disagree with
62
+ * the one that decides what gets reported.
63
+ */
64
+ function restate(into, stated) {
65
+ if (!stated)
66
+ return into;
67
+ const merged = { ...into };
68
+ for (const key of token_usage_1.ANTHROPIC_COMPONENT_METRICS) {
69
+ const value = stated[key];
70
+ // Only a whole, non-negative number counts as a statement. `null` is how this wire says
71
+ // "not this event", and a malformed or negative one is not a count - letting either through
72
+ // would erase a count an earlier event did state.
73
+ if (isCount(value))
74
+ merged[key] = value;
75
+ }
76
+ return merged;
77
+ }
78
+ /**
79
+ * Accumulates usage across raw stream events: Anthropic sends the input counts on
80
+ * `message_start` and restates them, with the output count, on `message_delta` - so one completed
81
+ * stream produces one measurement rather than one per event.
82
+ */
83
+ function observeStream(stream, ingest) {
84
+ if (!stream || typeof stream[Symbol.asyncIterator] !== 'function')
85
+ return;
86
+ const iterate = stream[Symbol.asyncIterator].bind(stream);
87
+ stream[Symbol.asyncIterator] = async function* gatedIterator() {
88
+ // What the stream has said so far, in the vendor's own shape. Kept flat rather than converted
89
+ // per event: both views of the call - the vendor's names and the normalized counters - are
90
+ // derived from it once at the end, so neither can be accumulated out of step with the other.
91
+ let stated = {};
92
+ // Whether the stream ever stated a final output count. `message_start` carries a placeholder
93
+ // `output_tokens: 1`, so the presence of the counter says nothing - only `message_delta` does.
94
+ let hasStatedOutputTokens = false;
95
+ // What the stream actually produced, for the case where it never gets to say.
96
+ let chars = 0;
97
+ try {
98
+ for await (const event of iterate()) {
99
+ if (event?.type === 'message_start')
100
+ stated = restate(stated, event.message?.usage);
101
+ if (event?.type === 'message_delta') {
102
+ stated = restate(stated, event.usage);
103
+ hasStatedOutputTokens ||= typeof event.usage?.output_tokens === 'number';
104
+ }
105
+ if (event?.type === 'content_block_delta')
106
+ chars += generatedChars(event.delta);
107
+ yield event;
108
+ }
109
+ }
110
+ finally {
111
+ // Meter what was actually produced, even if the consumer broke out of the loop early.
112
+ //
113
+ // No `raw` here, deliberately, and this is the one vendor that cannot have one: the counters
114
+ // arrive split across `message_start` and `message_delta`, so there is no single usage block
115
+ // on the wire to carry. The Gemini and OpenAI streams each report one whole block and do
116
+ // pass it on; `stated` is this SDK's reconstruction across two events, not an object the
117
+ // stream ever actually sent, so passing it would be a claim about the wire that is not true.
118
+ //
119
+ // A stream the consumer walked away from never reaches `message_delta`, the only event
120
+ // that states a final output count - so without this the text it did produce meters at the
121
+ // placeholder `output_tokens: 1` from `message_start`. The customer read that text and
122
+ // Anthropic billed for generating it, so one token is a hole rather than caution. What it
123
+ // produced is estimated instead, and the measurement says so.
124
+ const estimated = !hasStatedOutputTokens && chars > 0;
125
+ const final = estimated
126
+ ? restate(stated, { output_tokens: Math.max(1, Math.ceil(chars / CHARS_PER_OUTPUT_TOKEN)) })
127
+ : stated;
128
+ ingest({
129
+ metrics: (0, token_usage_1.withComputedTotal)((0, token_usage_1.pickMetrics)(final, token_usage_1.ANTHROPIC_COMPONENT_METRICS), token_usage_1.COMPUTED_TOTALS.anthropic),
130
+ usage: (0, token_usage_1.fromFlatTokenUsage)(final),
131
+ ...(estimated ? { estimated: true } : {}),
132
+ });
133
+ }
134
+ };
135
+ }
136
+ exports.anthropicRecipe = {
137
+ vendor: 'anthropic',
138
+ metrics: token_usage_1.ANTHROPIC_METRICS,
139
+ totalTokensMetric: token_usage_1.TOTAL_TOKENS,
140
+ matches: (client) => (0, registry_1.hasMethod)(client, ['messages', 'create']) && (0, registry_1.hasMethod)(client, ['messages', 'stream']),
141
+ methods: [
142
+ {
143
+ // `messages.stream()` reaches the wire through this same method, so gating it here covers
144
+ // both call styles - and leaves `stream()` synchronous, which its callers rely on.
145
+ path: ['messages', 'create'],
146
+ // Both views of the call: what Anthropic called it, which is what gets reported, and the
147
+ // normalized counters a mapper of your own would write against.
148
+ extractUsage: (result) => result?.usage
149
+ ? {
150
+ // Anthropic publishes no total of its own, so one is added up from the four it
151
+ // does publish - which are disjoint, so the sum is the whole call.
152
+ 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),
153
+ usage: (0, token_usage_1.fromFlatTokenUsage)(result.usage),
154
+ // The block as it arrived, for an event to state in full - including the counters
155
+ // this recipe does not name, which a meter can then read without an SDK release.
156
+ raw: result.usage,
157
+ }
158
+ : undefined,
159
+ attachStream: observeStream,
160
+ },
161
+ ],
162
+ };
163
+ /**
164
+ * A pre-call estimate based on the requested output ceiling. Opt-in: the default asks for a
165
+ * single unit, because an estimate that overshoots denies calls the budget could have afforded,
166
+ * and the ingest that follows corrects the balance anyway.
167
+ */
168
+ const estimateFromMaxTokens = (tokensPerUnit = 1) => (0, estimate_1.estimateUnits)((params) => params?.max_tokens ?? 0, tokensPerUnit);
169
+ 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;
@@ -0,0 +1,20 @@
1
+ import type { MetricsFor, MetricsOf } from '../../core/types';
2
+ export declare const groqRecipe: {
3
+ vendor: string;
4
+ metrics: readonly ["prompt_tokens", "completion_tokens", "cached_tokens", "total_tokens"];
5
+ totalTokensMetric: "total_tokens";
6
+ matches: (client: unknown) => boolean;
7
+ methods: {
8
+ path: ("create" | "chat" | "completions")[];
9
+ extractUsage: (result: any) => {
10
+ metrics: import("../../../..").Metrics;
11
+ usage: import("../../../..").TokenUsage;
12
+ raw: any;
13
+ } | undefined;
14
+ attachStream: (stream: any, ingest: (measurement: import("../../../..").Measurement) => void) => void;
15
+ }[];
16
+ };
17
+ /** One of the metric names Groq reports. */
18
+ export type GroqMetric = MetricsOf<typeof groqRecipe>;
19
+ /** What a mapper reads off a Groq call: those names, each possibly absent. */
20
+ export type GroqMetrics = MetricsFor<typeof groqRecipe>;
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.groqRecipe = void 0;
4
+ /**
5
+ * The Groq recipe - a provider that speaks the OpenAI wire format.
6
+ *
7
+ * Groq invoices the customer directly and runs the weights on its own hardware. Its model ids are
8
+ * sometimes namespaced (`meta-llama/llama-4-scout-17b-16e-instruct`, `openai/gpt-oss-120b`) but
9
+ * that prefix is the model's *author*, not a route to someone else's API - no upstream maker is in
10
+ * the billing path. So: a provider, not a gateway, and there is no `cost` on the response to
11
+ * recover, only tokens and timings.
12
+ *
13
+ * ## What this buys over `openAIRecipe`
14
+ *
15
+ * Shape alone cannot tell Groq from any other OpenAI-compatible endpoint - a `groq-sdk` client and
16
+ * an `OpenAI` client both have `chat.completions.create` - so the hostname is the discriminator,
17
+ * the same way `isOpenRouter` works. What the dedicated recipe adds is a vendor name of its own:
18
+ * `groq.chat.completions.create` rather than `openai.chat.completions.create`, so a raw-events
19
+ * meter and a per-provider budget can tell Groq traffic apart without the caller having to attach
20
+ * a dimension by hand. The counters themselves are the OpenAI ones, under the OpenAI names.
21
+ *
22
+ * Both routes land here, which is the point of matching on the host rather than the client: the
23
+ * native `groq-sdk` defaults to `https://api.groq.com`, and an `OpenAI` client is pointed at
24
+ * `https://api.groq.com/openai/v1`. Same hostname, so one guard claims both.
25
+ *
26
+ * ## One surface, deliberately
27
+ *
28
+ * Only `chat.completions.create` is gated, because it is the only generation surface Groq serves.
29
+ * `groq-sdk` exposes `chat`, `completions` (an empty namespace holding a type), `embeddings`,
30
+ * `audio`, `models`, `batches` and `files` - there is no `responses` module, and Groq's API has no
31
+ * Responses endpoint for an `OpenAI` client to reach either.
32
+ *
33
+ * That is a departure from the OpenRouter and LiteLLM recipes, which declare *both* OpenAI
34
+ * surfaces precisely because they are matched ahead of `openAIRecipe` and an undeclared surface
35
+ * would be left ungated. The reasoning does not carry over here: the surface those recipes are
36
+ * protecting is a billable one, and `responses.create` on a client pointed at Groq is unreachable -
37
+ * it answers 404. Declaring it would publish `groq.responses.create`, an event name that can never
38
+ * fire, and event names are permanent once a customer's meter filters on them.
39
+ *
40
+ * Not gated, and billable: `audio.transcriptions`/`translations` (charged by audio duration) and
41
+ * `embeddings` (a different price list). Meter one by naming your own recipe at
42
+ * `wrap(client, { recipe })`.
43
+ *
44
+ * ## Where the counters are
45
+ *
46
+ * Buffered, at `response.usage` - a clean OpenAI shape. Streamed, at `x_groq.usage` on the final
47
+ * chunk, because Groq's chunks carry no top-level `usage` of their own. Both reads come from the
48
+ * OpenAI surfaces this recipe reuses, so there is one implementation of each; see
49
+ * `openai-recipe.ts` for why that read lives there and `./types.ts` for the shapes, including the
50
+ * per-model `usage_breakdown` this SDK cannot express and does not meter.
51
+ */
52
+ const token_usage_1 = require("../../../shared/token-usage");
53
+ const openai_recipe_1 = require("../openai/openai-recipe");
54
+ /** The one surface Groq serves, as the OpenAI recipe addresses it. */
55
+ const CHAT_PATH = ['chat', 'completions', 'create'];
56
+ /**
57
+ * The hostname, lowercased, or '' when the URL is not one this can reason about.
58
+ *
59
+ * Duplicated from the gateway recipes rather than shared, as they duplicate it from each other -
60
+ * three lines, and hoisting it would put a URL helper in the shared vocabulary file, which is
61
+ * about counters.
62
+ */
63
+ function hostOf(url) {
64
+ try {
65
+ return new URL(url).hostname.toLowerCase();
66
+ }
67
+ catch {
68
+ return '';
69
+ }
70
+ }
71
+ /**
72
+ * `baseURL` is a public field on both clients that reach Groq, and the only thing that says which
73
+ * vendor is on the other end.
74
+ *
75
+ * Narrow on purpose, exactly as `isOpenRouter` is: the host, not a substring, because
76
+ * `api.groq.com.example.com` is somebody else's domain and claiming it would meter an unrelated
77
+ * client as Groq. A Groq deployment on another base URL - `GROQ_BASE_URL` set to a regional
78
+ * endpoint, or a proxy in front of it - is not claimed here and is governed by `openAIRecipe`
79
+ * instead, which still reads its streamed counters. Name this recipe at
80
+ * `wrap(client, { recipe: groqRecipe })` to attribute such a client to Groq explicitly.
81
+ */
82
+ function isGroq(client) {
83
+ const baseUrl = client?.baseURL;
84
+ if (typeof baseUrl !== 'string')
85
+ return false;
86
+ const host = hostOf(baseUrl);
87
+ return host === 'api.groq.com' || host.endsWith('.api.groq.com');
88
+ }
89
+ exports.groqRecipe = {
90
+ vendor: 'groq',
91
+ // The OpenAI chat counters, under the OpenAI names - Groq reports that block verbatim. The
92
+ // timings beside it (`queue_time`, `prompt_time`, ...) are floats and are not metrics.
93
+ metrics: token_usage_1.OPENAI_CHAT_METRICS,
94
+ totalTokensMetric: token_usage_1.TOTAL_TOKENS,
95
+ matches: (client) => openai_recipe_1.openAIRecipe.matches(client) && isGroq(client),
96
+ // Built from the OpenAI recipe's own chat surface, so the extractor and the streamed reader -
97
+ // including the `x_groq` fallback - have one implementation shared with it rather than a copy.
98
+ methods: openai_recipe_1.openAIRecipe.methods
99
+ .filter((method) => method.path.join('.') === CHAT_PATH.join('.'))
100
+ .map((method) => ({ ...method, path: [...CHAT_PATH] })),
101
+ };
@@ -0,0 +1,22 @@
1
+ import { type Measurement } from '../../../../types';
2
+ import type { MistralCompletionResponse } from './types';
3
+ import type { MetricsFor, MetricsOf } from '../../core/types';
4
+ export declare const mistralRecipe: {
5
+ vendor: string;
6
+ metrics: readonly ["prompt_tokens", "completion_tokens", "total_tokens"];
7
+ totalTokensMetric: "total_tokens";
8
+ matches: (client: unknown) => boolean;
9
+ methods: ({
10
+ path: ("stream" | "chat" | "complete" | "parse" | "parseStream" | "fim" | "agents")[];
11
+ attachStream: (stream: unknown, ingest: (measurement: Measurement) => void) => void;
12
+ extractUsage?: undefined;
13
+ } | {
14
+ path: ("stream" | "chat" | "complete" | "parse" | "parseStream" | "fim" | "agents")[];
15
+ extractUsage: (result: MistralCompletionResponse | undefined) => Measurement | undefined;
16
+ attachStream?: undefined;
17
+ })[];
18
+ };
19
+ /** One of the metric names Mistral reports. */
20
+ export type MistralMetric = MetricsOf<typeof mistralRecipe>;
21
+ /** What a mapper reads off a Mistral call: those names, each possibly absent. */
22
+ export type MistralMetrics = MetricsFor<typeof mistralRecipe>;