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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +1411 -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 +126 -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 +261 -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/cloudflare/cloudflare-recipe.d.ts +10 -0
  13. package/dist/bindings/client/gateways/cloudflare/cloudflare-recipe.js +254 -0
  14. package/dist/bindings/client/gateways/cloudflare/types.d.ts +35 -0
  15. package/dist/bindings/client/gateways/cloudflare/types.js +2 -0
  16. package/dist/bindings/client/gateways/databricks/databricks-recipe.d.ts +32 -0
  17. package/dist/bindings/client/gateways/databricks/databricks-recipe.js +324 -0
  18. package/dist/bindings/client/gateways/databricks/types.d.ts +30 -0
  19. package/dist/bindings/client/gateways/databricks/types.js +2 -0
  20. package/dist/bindings/client/gateways/litellm/litellm-recipe.d.ts +17 -0
  21. package/dist/bindings/client/gateways/litellm/litellm-recipe.js +158 -0
  22. package/dist/bindings/client/gateways/litellm/types.d.ts +30 -0
  23. package/dist/bindings/client/gateways/litellm/types.js +2 -0
  24. package/dist/bindings/client/gateways/openrouter/openrouter-recipe.d.ts +20 -0
  25. package/dist/bindings/client/gateways/openrouter/openrouter-recipe.js +233 -0
  26. package/dist/bindings/client/gateways/openrouter/sdk-usage.d.ts +37 -0
  27. package/dist/bindings/client/gateways/openrouter/sdk-usage.js +63 -0
  28. package/dist/bindings/client/gateways/openrouter/types.d.ts +78 -0
  29. package/dist/bindings/client/gateways/openrouter/types.js +2 -0
  30. package/dist/bindings/client/providers/anthropic/anthropic-recipe.d.ts +34 -0
  31. package/dist/bindings/client/providers/anthropic/anthropic-recipe.js +169 -0
  32. package/dist/bindings/client/providers/gemini/gemini-recipe.d.ts +54 -0
  33. package/dist/bindings/client/providers/gemini/gemini-recipe.js +243 -0
  34. package/dist/bindings/client/providers/groq/groq-recipe.d.ts +20 -0
  35. package/dist/bindings/client/providers/groq/groq-recipe.js +84 -0
  36. package/dist/bindings/client/providers/mistral/mistral-recipe.d.ts +22 -0
  37. package/dist/bindings/client/providers/mistral/mistral-recipe.js +114 -0
  38. package/dist/bindings/client/providers/mistral/types.d.ts +38 -0
  39. package/dist/bindings/client/providers/mistral/types.js +2 -0
  40. package/dist/bindings/client/providers/ollama/ollama-recipe.d.ts +31 -0
  41. package/dist/bindings/client/providers/ollama/ollama-recipe.js +107 -0
  42. package/dist/bindings/client/providers/ollama/types.d.ts +24 -0
  43. package/dist/bindings/client/providers/ollama/types.js +2 -0
  44. package/dist/bindings/client/providers/openai/openai-recipe.d.ts +71 -0
  45. package/dist/bindings/client/providers/openai/openai-recipe.js +122 -0
  46. package/dist/bindings/client/providers/typesafe/typesafe-recipe.d.ts +44 -0
  47. package/dist/bindings/client/providers/typesafe/typesafe-recipe.js +83 -0
  48. package/dist/bindings/proxy/headers.d.ts +6 -0
  49. package/dist/bindings/proxy/headers.js +12 -0
  50. package/dist/bindings/proxy/index.d.ts +21 -0
  51. package/dist/bindings/proxy/index.js +18 -0
  52. package/dist/bindings/proxy/types.d.ts +8 -0
  53. package/dist/bindings/proxy/types.js +2 -0
  54. package/dist/bindings/shared/base-url.d.ts +44 -0
  55. package/dist/bindings/shared/base-url.js +90 -0
  56. package/dist/bindings/shared/estimate.d.ts +27 -0
  57. package/dist/bindings/shared/estimate.js +38 -0
  58. package/dist/bindings/shared/meter-stream.d.ts +18 -0
  59. package/dist/bindings/shared/meter-stream.js +151 -0
  60. package/dist/bindings/shared/token-usage.d.ts +345 -0
  61. package/dist/bindings/shared/token-usage.js +735 -0
  62. package/dist/bindings/shared/types.d.ts +170 -0
  63. package/dist/bindings/shared/types.js +2 -0
  64. package/dist/bindings/shared/weighted-metrics.d.ts +37 -0
  65. package/dist/bindings/shared/weighted-metrics.js +47 -0
  66. package/dist/config.d.ts +145 -0
  67. package/dist/config.js +99 -0
  68. package/dist/core/context.d.ts +160 -0
  69. package/dist/core/context.js +221 -0
  70. package/dist/core/dimensions.d.ts +22 -0
  71. package/dist/core/dimensions.js +47 -0
  72. package/dist/core/hooks.d.ts +21 -0
  73. package/dist/core/hooks.js +47 -0
  74. package/dist/core/session.d.ts +190 -0
  75. package/dist/core/session.js +448 -0
  76. package/dist/decision-text.d.ts +15 -0
  77. package/dist/decision-text.js +54 -0
  78. package/dist/engine/composite-engine.d.ts +72 -0
  79. package/dist/engine/composite-engine.js +135 -0
  80. package/dist/engine/create-engine.d.ts +16 -0
  81. package/dist/engine/create-engine.js +93 -0
  82. package/dist/engine/decision.d.ts +42 -0
  83. package/dist/engine/decision.js +135 -0
  84. package/dist/engine/engine.d.ts +166 -0
  85. package/dist/engine/engine.js +11 -0
  86. package/dist/engine/node-sdk-engine.d.ts +125 -0
  87. package/dist/engine/node-sdk-engine.js +262 -0
  88. package/dist/engine/report.d.ts +106 -0
  89. package/dist/engine/report.js +188 -0
  90. package/dist/engine/rest-governance-engine.d.ts +47 -0
  91. package/dist/engine/rest-governance-engine.js +220 -0
  92. package/dist/errors.d.ts +86 -0
  93. package/dist/errors.js +161 -0
  94. package/dist/headers.d.ts +40 -0
  95. package/dist/headers.js +82 -0
  96. package/dist/index.d.ts +63 -0
  97. package/dist/index.js +124 -0
  98. package/dist/internal.d.ts +33 -0
  99. package/dist/internal.js +91 -0
  100. package/dist/logger.d.ts +15 -0
  101. package/dist/logger.js +20 -0
  102. package/dist/sdk-identity.d.ts +36 -0
  103. package/dist/sdk-identity.js +43 -0
  104. package/dist/stigg-agent-sdk.d.ts +300 -0
  105. package/dist/stigg-agent-sdk.js +669 -0
  106. package/dist/types.d.ts +676 -0
  107. package/dist/types.js +147 -0
  108. package/package.json +94 -3
@@ -0,0 +1,84 @@
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
+ const base_url_1 = require("../../../shared/base-url");
55
+ /** The one surface Groq serves, as the OpenAI recipe addresses it. */
56
+ const CHAT_PATH = ['chat', 'completions', 'create'];
57
+ /**
58
+ * `baseURL` is a public field on both clients that reach Groq, and the only thing that says which
59
+ * vendor is on the other end.
60
+ *
61
+ * Narrow on purpose, exactly as `isOpenRouter` is: the host, not a substring, because
62
+ * `api.groq.com.example.com` is somebody else's domain and claiming it would meter an unrelated
63
+ * client as Groq. A Groq deployment on another base URL - `GROQ_BASE_URL` set to a regional
64
+ * endpoint, or a proxy in front of it - is not claimed here and is governed by `openAIRecipe`
65
+ * instead, which still reads its streamed counters. Name this recipe at
66
+ * `wrap(client, { recipe: groqRecipe })` to attribute such a client to Groq explicitly.
67
+ */
68
+ function isGroq(client) {
69
+ const baseUrl = (0, base_url_1.baseUrlOf)(client);
70
+ return baseUrl !== undefined && (0, base_url_1.matchesHost)(baseUrl, 'api.groq.com');
71
+ }
72
+ exports.groqRecipe = {
73
+ vendor: 'groq',
74
+ // The OpenAI chat counters, under the OpenAI names - Groq reports that block verbatim. The
75
+ // timings beside it (`queue_time`, `prompt_time`, ...) are floats and are not metrics.
76
+ metrics: token_usage_1.OPENAI_CHAT_METRICS,
77
+ totalTokensMetric: token_usage_1.TOTAL_TOKENS,
78
+ matches: (client) => openai_recipe_1.openAIRecipe.matches(client) && isGroq(client),
79
+ // Built from the OpenAI recipe's own chat surface, so the extractor and the streamed reader -
80
+ // including the `x_groq` fallback - have one implementation shared with it rather than a copy.
81
+ methods: openai_recipe_1.openAIRecipe.methods
82
+ .filter((method) => method.path.join('.') === CHAT_PATH.join('.'))
83
+ .map((method) => ({ ...method, path: [...CHAT_PATH] })),
84
+ };
@@ -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>;
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.mistralRecipe = void 0;
4
+ /**
5
+ * The Mistral recipe - `@mistralai/mistralai`, the Speakeasy-generated client.
6
+ *
7
+ * ## What is covered
8
+ *
9
+ * Every surface that burns tokens and reports them the same way: `chat.complete`, `chat.stream`,
10
+ * `chat.parse`, `chat.parseStream`, `fim.complete`, `fim.stream`, `agents.complete` and
11
+ * `agents.stream`. Eight methods, one extractor - `FIMCompletionResponse` and
12
+ * `ChatCompletionResponse` both carry `usage: UsageInfo`, `ParsedChatCompletionResponse` is a
13
+ * `ChatCompletionResponse` with its choices retyped, and all four streamed methods resolve to the
14
+ * same `EventStream<CompletionEvent>`. That was read off the SDK's own types, not assumed.
15
+ *
16
+ * Each is its own event name, because a meter filtering on one sees nothing from the others.
17
+ * `chat.parse` in particular is **not** a wrapper over `chat.complete`: it reaches the wire itself,
18
+ * so leaving it out would ship a billable path that looks governed.
19
+ *
20
+ * ## What is deliberately not covered
21
+ *
22
+ * `beta.conversations.*` is declined by name. It reports `ConversationUsageInfo` - the three
23
+ * counters plus `connectorTokens` and a per-connector map - which is a second vocabulary, and an
24
+ * event name is permanent once a customer's meter filters on it. Gating it with this extractor
25
+ * would read the three it shares and silently drop the connector spend, which is the shape of
26
+ * error this file exists to avoid. It wants its own recipe and its own metric list.
27
+ *
28
+ * Also not covered, each counting in units these token metrics do not describe: `embeddings.create`
29
+ * (tokens, but of a different kind and against a different price list), `ocr.process` (whose
30
+ * `usageInfo` is `pagesProcessed`, a page count), `audio.transcriptions.*` (`promptAudioSeconds`),
31
+ * `classifiers.*`, `files.*`, `batch.*` and v2's `workflows.*`. Meter one by naming your own
32
+ * recipe at `wrap(client, { recipe })`.
33
+ *
34
+ * ## Two gaps a caller should know about
35
+ *
36
+ * **The standalone functions bypass this entirely.** Every method also exists as a tree-shakable
37
+ * function taking the client as its first argument - `chatComplete(client, request)` from
38
+ * `@mistralai/mistralai/funcs/chatComplete.js`. Those do not go through the patched method, so a
39
+ * call made that way is neither gated nor metered while `wrap()` reports success. Mistral ships an
40
+ * `SDKHooks` seam that would catch both forms, and it is deliberately not used: holding its
41
+ * `HookContext` means importing the vendor package into `src/`, which `module-boundaries.test.ts`
42
+ * forbids outright. A recipe here is data - method paths and duck-typed extractors - and that is
43
+ * what lets `src/index.ts` export every binding with no vendor SDK at runtime. So this is a real
44
+ * limit rather than an oversight: call the methods, not the funcs.
45
+ *
46
+ * **A stream read through `getReader()` is not metered.** `EventStream` extends `ReadableStream`
47
+ * *and* overrides `[Symbol.asyncIterator]`, so `meterStream` patches the iterator in place and the
48
+ * documented `for await` is covered. A consumer that takes a reader instead bypasses it, and the
49
+ * stream is the caller's own object - it cannot be swapped for a metered copy without breaking
50
+ * `instanceof`. Stated here rather than left to be discovered.
51
+ */
52
+ const token_usage_1 = require("../../../shared/token-usage");
53
+ const meter_stream_1 = require("../../../shared/meter-stream");
54
+ const types_1 = require("../../../../types");
55
+ const registry_1 = require("../../core/registry");
56
+ /**
57
+ * Both views of one usage block: Mistral's counters under the wire's names, and the normalized
58
+ * four a mapper of your own would read.
59
+ *
60
+ * `raw` is the renamed projection rather than the object the SDK handed over, so an event's
61
+ * `raw.*` dimensions agree with its metrics - and so the same account reports the same strings
62
+ * whether the application holds a `Mistral` client or an `OpenAI` one pointed at its base URL.
63
+ */
64
+ function measure(usage) {
65
+ if (!usage)
66
+ return undefined;
67
+ const metrics = (0, token_usage_1.pickRenamedMetrics)(usage, token_usage_1.MISTRAL_METRIC_NAME_MAP);
68
+ // Nothing readable in it - an absent counter is absent, not zero, so there is no measurement to
69
+ // report rather than one that says the call cost nothing.
70
+ if (Object.keys(metrics).length === 0)
71
+ return undefined;
72
+ return { metrics, usage: (0, token_usage_1.fromMistralUsage)(usage), raw: { ...metrics } };
73
+ }
74
+ /** Reads the buffered response every gated `complete`/`parse` surface returns. */
75
+ const extractUsage = (result) => measure(result?.usage);
76
+ /** The streamed counterpart, whose counters arrive once and a level down, under `data`. */
77
+ const attachStream = (stream, ingest) => {
78
+ let metrics = {};
79
+ let usage = (0, types_1.emptyTokenUsage)();
80
+ let raw;
81
+ (0, meter_stream_1.meterStream)(stream, (chunk) => {
82
+ // Stated whole on the terminating chunk rather than as deltas, so the last one wins.
83
+ const measured = measure(chunk?.data?.usage);
84
+ if (measured) {
85
+ metrics = measured.metrics ?? {};
86
+ usage = measured.usage ?? (0, types_1.emptyTokenUsage)();
87
+ raw = measured.raw;
88
+ }
89
+ },
90
+ // Absent rather than an empty object when no chunk carried usage, as the other recipes do.
91
+ () => ingest({ metrics, usage, ...(raw ? { raw } : {}) }));
92
+ };
93
+ /** The surfaces, and whether each one streams. */
94
+ const SURFACES = [
95
+ { path: ['chat', 'complete'], streams: false },
96
+ { path: ['chat', 'stream'], streams: true },
97
+ { path: ['chat', 'parse'], streams: false },
98
+ { path: ['chat', 'parseStream'], streams: true },
99
+ { path: ['fim', 'complete'], streams: false },
100
+ { path: ['fim', 'stream'], streams: true },
101
+ { path: ['agents', 'complete'], streams: false },
102
+ { path: ['agents', 'stream'], streams: true },
103
+ ];
104
+ exports.mistralRecipe = {
105
+ vendor: 'mistral',
106
+ metrics: token_usage_1.MISTRAL_METRICS,
107
+ // Mistral states its own total, so it is read rather than summed from the parts.
108
+ totalTokensMetric: token_usage_1.TOTAL_TOKENS,
109
+ // Two signals, because `complete` on its own is far too common a verb to claim. An `OpenAI`
110
+ // client pointed at `api.mistral.ai/v1` has neither of these - it speaks `chat.completions.
111
+ // create` and is governed by `openAIRecipe` as `openai.*`, which the README spells out.
112
+ matches: (client) => (0, registry_1.hasMethod)(client, ['chat', 'complete']) && (0, registry_1.hasMethod)(client, ['chat', 'stream']),
113
+ methods: SURFACES.map(({ path, streams }) => streams ? { path: [...path], attachStream } : { path: [...path], extractUsage }),
114
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * What a Mistral response carries, typed structurally.
3
+ *
4
+ * No import from `@mistralai/mistralai` - `module-boundaries.test.ts` would fail on one, and the
5
+ * package's whole claim is that importing a recipe pulls in no vendor SDK. These are the fields
6
+ * the recipe reads and nothing more.
7
+ */
8
+ import type { MistralLikeUsage } from '../../../shared/types';
9
+ /**
10
+ * The buffered response, shared by every surface this recipe gates.
11
+ *
12
+ * `chat.complete`, `fim.complete` and `agents.complete` return three differently-named types in
13
+ * the SDK - `ChatCompletionResponse`, `FIMCompletionResponse`, `ChatCompletionResponse` again -
14
+ * and all three carry the same `usage: UsageInfo`. `chat.parse` is `Omit<ChatCompletionResponse,
15
+ * 'choices'> & { choices? }`, so it keeps `usage` too. That was checked against the SDK's own
16
+ * types rather than assumed, because one extractor across four surfaces is only safe if the
17
+ * shapes really are one shape.
18
+ */
19
+ export type MistralCompletionResponse = {
20
+ id?: string;
21
+ model?: string;
22
+ usage?: MistralLikeUsage | null;
23
+ };
24
+ /**
25
+ * One streamed event.
26
+ *
27
+ * The counters sit a level down, under `data` - all four streamed surfaces resolve to the same
28
+ * `EventStream<CompletionEvent>`, and `CompletionEvent` is `{ data: CompletionChunk }`. Reading
29
+ * `chunk.usage` instead of `chunk.data.usage` finds nothing and meters the call at zero, which is
30
+ * why this is typed rather than reached for inline.
31
+ */
32
+ export type MistralCompletionEvent = {
33
+ data?: {
34
+ id?: string;
35
+ model?: string;
36
+ usage?: MistralLikeUsage | null;
37
+ } | null;
38
+ };
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,31 @@
1
+ import { type Measurement } from '../../../../types';
2
+ import type { OllamaResponse } from './types';
3
+ import type { MetricsFor, MetricsOf } from '../../core/types';
4
+ export declare const ollamaRecipe: {
5
+ vendor: string;
6
+ metrics: readonly ["prompt_eval_count", "eval_count", "total_tokens"];
7
+ totalTokensMetric: "total_tokens";
8
+ /**
9
+ * Three markers, not one.
10
+ *
11
+ * `chat` and `generate` are extremely common verbs, and this client has no namespace to make
12
+ * them distinctive - so a two-signal guard would still claim unrelated objects. `ps` is the
13
+ * third because it is specific to a model runner. Not matched on the host: Ollama is
14
+ * self-hosted so there is no fixed hostname, and `proxy: true` leaves `config.host` an empty
15
+ * string, which a truthiness check would miss.
16
+ */
17
+ matches: (client: unknown) => boolean;
18
+ methods: ({
19
+ path: readonly ["chat"];
20
+ extractUsage: (result: OllamaResponse | undefined) => Measurement | undefined;
21
+ attachStream: (stream: unknown, ingest: (measurement: Measurement) => void) => void;
22
+ } | {
23
+ path: readonly ["generate"];
24
+ extractUsage: (result: OllamaResponse | undefined) => Measurement | undefined;
25
+ attachStream: (stream: unknown, ingest: (measurement: Measurement) => void) => void;
26
+ })[];
27
+ };
28
+ /** One of the metric names Ollama reports. */
29
+ export type OllamaMetric = MetricsOf<typeof ollamaRecipe>;
30
+ /** What a mapper reads off an Ollama call: those names, each possibly absent. */
31
+ export type OllamaMetrics = MetricsFor<typeof ollamaRecipe>;
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ollamaRecipe = void 0;
4
+ /**
5
+ * The Ollama recipe - `ollama`, the self-hosted engine's own client.
6
+ *
7
+ * ## What these numbers mean, which is not what every other recipe's mean
8
+ *
9
+ * Ollama invoices nobody. It is not a provider in the sense the other recipes here are - it bills
10
+ * nothing, and it serves open-weight models it did not make - so there is no vendor dashboard to
11
+ * reconcile against and no `cost` to recover. What is being governed is the customer's **own**
12
+ * capacity: a per-agent or per-team budget over hardware they already pay for. The counters are
13
+ * reported under Ollama's own names anyway, for the same reason as everywhere else - so a meter
14
+ * reads one vocabulary - but nobody should expect them to match an invoice, because there is none.
15
+ *
16
+ * ## Shape
17
+ *
18
+ * The client is **flat**: `chat` and `generate` are methods on the client object itself, not
19
+ * namespaces. Both take `{ stream: true }` to switch mode, so each declares `extractUsage` *and*
20
+ * `attachStream` - a streamed call resolves to an `AbortableAsyncIterator` with no counters on it,
21
+ * `extractUsage` returns undefined, and the wrapper falls through to the stream path. That is the
22
+ * same dual-mode arrangement `openAIRecipe` uses for `chat.completions.create`.
23
+ *
24
+ * The counters sit flat on the response root rather than inside a `usage` object, which is why
25
+ * `raw` here is an explicit projection of the two counters rather than the response. Passing the
26
+ * response would flatten `message.content` - the model's own reply text - into event dimensions.
27
+ *
28
+ * ## What is deliberately not covered
29
+ *
30
+ * `embed` reports `prompt_eval_count` but no `eval_count`: it is a different unit against a
31
+ * different price list, and metering it with a chat recipe reports a number that is wrong rather
32
+ * than missing. `embeddings` (the legacy endpoint) reports no counters at all. `pull`, `push` and
33
+ * `create` move or quantize bytes and report progress, not tokens.
34
+ *
35
+ * `systemone` is declined by name even though it genuinely carries tokens. Its block is
36
+ * `{ input_tokens, output_tokens }` - the identical names `typesafe.systemOne` reports - so gating
37
+ * it here would emit `ollama.systemone` carrying metrics indistinguishable from TypeSafe's for
38
+ * anyone metering on metric names rather than event names. One vocabulary per vendor is worth more
39
+ * than one extra surface; name your own recipe at `wrap(client, { recipe })` if you need it.
40
+ *
41
+ * `webSearch` and `webFetch` are the ones worth knowing about: they are **billable**, they are
42
+ * charged against `OLLAMA_API_KEY` at `ollama.com`, and they ignore `config.host` entirely - so
43
+ * they are invisible to any host-based reasoning about where this client points. They report no
44
+ * token counters, so there is nothing honest for this recipe to meter them with.
45
+ */
46
+ const token_usage_1 = require("../../../shared/token-usage");
47
+ const meter_stream_1 = require("../../../shared/meter-stream");
48
+ const types_1 = require("../../../../types");
49
+ const registry_1 = require("../../core/registry");
50
+ /**
51
+ * Both views of one response's counters.
52
+ *
53
+ * `raw` is built from the picked metrics rather than from the response, deliberately: the counters
54
+ * are flat on a root that also carries the generated text, and `raw` becomes event dimensions.
55
+ */
56
+ function measure(result) {
57
+ // Absent, not zero. The declared type says these are always present and the wire disagrees -
58
+ // every intermediate stream chunk omits them - so a response with neither has nothing to report.
59
+ const metrics = (0, token_usage_1.pickMetrics)(result, token_usage_1.METRIC_PATHS.ollama);
60
+ if (Object.keys(metrics).length === 0)
61
+ return undefined;
62
+ return {
63
+ // Ollama publishes no total, so one is summed from two disjoint counters - the only case where
64
+ // adding them up is the vendor's own arithmetic rather than ours.
65
+ metrics: (0, token_usage_1.withComputedTotal)(metrics, token_usage_1.COMPUTED_TOTALS.ollama),
66
+ usage: (0, token_usage_1.fromOllamaUsage)(result),
67
+ raw: { ...metrics },
68
+ };
69
+ }
70
+ /** A buffered `chat`/`generate`. A streamed call resolves to an iterator and reports nothing here. */
71
+ const extractUsage = (result) => measure(result);
72
+ /** The streamed counterpart: the counters ride the final `done: true` chunk and nothing before it. */
73
+ const attachStream = (stream, ingest) => {
74
+ let metrics = {};
75
+ let usage = (0, types_1.emptyTokenUsage)();
76
+ let raw;
77
+ (0, meter_stream_1.meterStream)(stream, (chunk) => {
78
+ const measured = measure(chunk);
79
+ if (measured) {
80
+ metrics = measured.metrics ?? {};
81
+ usage = measured.usage ?? (0, types_1.emptyTokenUsage)();
82
+ raw = measured.raw;
83
+ }
84
+ },
85
+ // A stream that ended before its final chunk reports nothing rather than zeroes: it produced
86
+ // tokens the customer paid for in compute, and a zero would say it produced none.
87
+ () => ingest({ metrics, usage, ...(raw ? { raw } : {}) }));
88
+ };
89
+ exports.ollamaRecipe = {
90
+ vendor: 'ollama',
91
+ metrics: token_usage_1.OLLAMA_METRICS,
92
+ totalTokensMetric: token_usage_1.TOTAL_TOKENS,
93
+ /**
94
+ * Three markers, not one.
95
+ *
96
+ * `chat` and `generate` are extremely common verbs, and this client has no namespace to make
97
+ * them distinctive - so a two-signal guard would still claim unrelated objects. `ps` is the
98
+ * third because it is specific to a model runner. Not matched on the host: Ollama is
99
+ * self-hosted so there is no fixed hostname, and `proxy: true` leaves `config.host` an empty
100
+ * string, which a truthiness check would miss.
101
+ */
102
+ matches: (client) => (0, registry_1.hasMethod)(client, ['chat']) && (0, registry_1.hasMethod)(client, ['generate']) && (0, registry_1.hasMethod)(client, ['ps']),
103
+ methods: [
104
+ { path: ['chat'], extractUsage, attachStream },
105
+ { path: ['generate'], extractUsage, attachStream },
106
+ ],
107
+ };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * What an Ollama response carries, typed structurally.
3
+ *
4
+ * No import from `ollama` - `module-boundaries.test.ts` would fail on one.
5
+ */
6
+ import type { OllamaLikeUsage } from '../../../shared/types';
7
+ /**
8
+ * A completed `chat` or `generate` response, or the final chunk of a streamed one.
9
+ *
10
+ * The counters are flat on the root - there is no `usage` object to reach into - and every one of
11
+ * them is optional here, against the vendor's own `.d.ts`, which declares them required. The
12
+ * server marks the whole `Metrics` struct `omitempty`, so an intermediate chunk carries none of
13
+ * them and the declared type is wrong about it. See `OllamaLikeUsage`.
14
+ *
15
+ * `done` is what distinguishes a final chunk from an intermediate one, and is likewise optional
16
+ * on the wire. The response also carries `message`, `response`, `context` and a set of
17
+ * nanosecond durations; none is read here, and `message` in particular must never reach `raw` -
18
+ * it holds the model's reply text, and `raw` becomes event dimensions.
19
+ */
20
+ export type OllamaResponse = OllamaLikeUsage & {
21
+ model?: string;
22
+ done?: boolean;
23
+ done_reason?: string;
24
+ };
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The OpenAI recipe - the second vendor, and the proof that the generic wrapper carries its
3
+ * weight: everything below is response-shape knowledge, with no gate logic repeated.
4
+ *
5
+ * Works against any OpenAI-compatible endpoint, a gateway included. Note that a gateway usually
6
+ * wants the ingest-only gateway binding instead: it sees requests, not runs.
7
+ *
8
+ * ## Why Groq's own field name is read here, when Groq has a recipe of its own
9
+ *
10
+ * `groqRecipe` exists and is matched ahead of this one, so most Groq traffic never reaches here.
11
+ * The `x_groq.usage` read below stays anyway, and deliberately, for two reasons.
12
+ *
13
+ * **It is written once and used twice.** `groqRecipe` builds its methods from these surfaces, the
14
+ * way the OpenRouter and LiteLLM recipes do, so it reads its own counters through this reader
15
+ * rather than carrying a copy. Moving the read into that recipe would duplicate it, not relocate
16
+ * it.
17
+ *
18
+ * **A Groq client the host matcher declines still lands here.** That matcher is narrow on purpose -
19
+ * it compares the hostname, because a wrong guess silently changes who gets billed - so a Groq
20
+ * deployment reached on any other base URL is not claimed by it: `GROQ_BASE_URL` pointed at a
21
+ * regional or staging endpoint, or a corporate proxy in front of `api.groq.com`. Those clients are
22
+ * governed by this recipe, their streamed chunks still carry **no top-level `usage` at all**, and
23
+ * without this fallback every one of them would be wrapped, stamped governed, and metered at
24
+ * nothing. That is the bug this read was added to fix, and it is reachable by configuration alone.
25
+ *
26
+ * Nothing else is affected: a response from any other OpenAI-compatible vendor has no `x_groq`, so
27
+ * the fallback yields `undefined` and the behaviour is exactly what it was.
28
+ *
29
+ * Mind the trap in that field. `x_groq.usage` means token totals on a **streamed** chunk and
30
+ * hardware cache statistics - `dram_cached_tokens`, `sram_cached_tokens` - on a buffered response.
31
+ * Two things keep them apart: only `streamed` consults it at all, and {@link asTokenUsage} refuses
32
+ * a block that names none of the token counters. Without that guard a buffered response would read
33
+ * as all-zero counters with cache statistics riding along as `raw` - a measurement of nothing that
34
+ * looks like data, which is the one failure shape nothing downstream can notice.
35
+ */
36
+ import { fromOpenAIUsage, fromResponsesUsage } from '../../../shared/token-usage';
37
+ import { type Measurement, type Metrics } from '../../../../types';
38
+ import type { MetricsFor, MetricsOf } from '../../core/types';
39
+ /** The reader for a surface, so a recipe built on top of this one stays in step with it. */
40
+ export declare const openAISurface: (path: readonly string[]) => {
41
+ readonly path: readonly ["chat", "completions", "create"];
42
+ readonly parse: typeof fromOpenAIUsage;
43
+ readonly keys: readonly ["prompt_tokens", "completion_tokens", "prompt_tokens_details.cached_tokens", "total_tokens"];
44
+ readonly metrics: readonly ["prompt_tokens", "completion_tokens", "cached_tokens", "total_tokens"];
45
+ readonly streamed: (chunk: any) => any;
46
+ } | {
47
+ readonly path: readonly ["responses", "create"];
48
+ readonly parse: typeof fromResponsesUsage;
49
+ readonly keys: readonly ["input_tokens", "output_tokens", "input_tokens_details.cached_tokens", "total_tokens"];
50
+ readonly metrics: readonly ["input_tokens", "output_tokens", "cached_tokens", "total_tokens"];
51
+ readonly streamed: (chunk: any) => any;
52
+ };
53
+ export declare const openAIRecipe: {
54
+ vendor: string;
55
+ metrics: readonly ["prompt_tokens", "completion_tokens", "input_tokens", "output_tokens", "cached_tokens", "total_tokens"];
56
+ totalTokensMetric: "total_tokens";
57
+ matches: (client: unknown) => boolean;
58
+ methods: {
59
+ path: ("create" | "chat" | "completions" | "responses")[];
60
+ extractUsage: (result: any) => {
61
+ metrics: Metrics;
62
+ usage: import("../../../../types").TokenUsage;
63
+ raw: any;
64
+ } | undefined;
65
+ attachStream: (stream: any, ingest: (measurement: Measurement) => void) => void;
66
+ }[];
67
+ };
68
+ /** One of the metric names OpenAI reports. */
69
+ export type OpenAIMetric = MetricsOf<typeof openAIRecipe>;
70
+ /** What a mapper reads off an OpenAI call: those names, each possibly absent. */
71
+ export type OpenAIMetrics = MetricsFor<typeof openAIRecipe>;
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.openAIRecipe = exports.openAISurface = void 0;
4
+ /**
5
+ * The OpenAI recipe - the second vendor, and the proof that the generic wrapper carries its
6
+ * weight: everything below is response-shape knowledge, with no gate logic repeated.
7
+ *
8
+ * Works against any OpenAI-compatible endpoint, a gateway included. Note that a gateway usually
9
+ * wants the ingest-only gateway binding instead: it sees requests, not runs.
10
+ *
11
+ * ## Why Groq's own field name is read here, when Groq has a recipe of its own
12
+ *
13
+ * `groqRecipe` exists and is matched ahead of this one, so most Groq traffic never reaches here.
14
+ * The `x_groq.usage` read below stays anyway, and deliberately, for two reasons.
15
+ *
16
+ * **It is written once and used twice.** `groqRecipe` builds its methods from these surfaces, the
17
+ * way the OpenRouter and LiteLLM recipes do, so it reads its own counters through this reader
18
+ * rather than carrying a copy. Moving the read into that recipe would duplicate it, not relocate
19
+ * it.
20
+ *
21
+ * **A Groq client the host matcher declines still lands here.** That matcher is narrow on purpose -
22
+ * it compares the hostname, because a wrong guess silently changes who gets billed - so a Groq
23
+ * deployment reached on any other base URL is not claimed by it: `GROQ_BASE_URL` pointed at a
24
+ * regional or staging endpoint, or a corporate proxy in front of `api.groq.com`. Those clients are
25
+ * governed by this recipe, their streamed chunks still carry **no top-level `usage` at all**, and
26
+ * without this fallback every one of them would be wrapped, stamped governed, and metered at
27
+ * nothing. That is the bug this read was added to fix, and it is reachable by configuration alone.
28
+ *
29
+ * Nothing else is affected: a response from any other OpenAI-compatible vendor has no `x_groq`, so
30
+ * the fallback yields `undefined` and the behaviour is exactly what it was.
31
+ *
32
+ * Mind the trap in that field. `x_groq.usage` means token totals on a **streamed** chunk and
33
+ * hardware cache statistics - `dram_cached_tokens`, `sram_cached_tokens` - on a buffered response.
34
+ * Two things keep them apart: only `streamed` consults it at all, and {@link asTokenUsage} refuses
35
+ * a block that names none of the token counters. Without that guard a buffered response would read
36
+ * as all-zero counters with cache statistics riding along as `raw` - a measurement of nothing that
37
+ * looks like data, which is the one failure shape nothing downstream can notice.
38
+ */
39
+ const token_usage_1 = require("../../../shared/token-usage");
40
+ const meter_stream_1 = require("../../../shared/meter-stream");
41
+ const types_1 = require("../../../../types");
42
+ const registry_1 = require("../../core/registry");
43
+ /**
44
+ * A usage block, or nothing - for the one field that holds something else half the time.
45
+ *
46
+ * Groq's `x_groq.usage` carries the token counters while streaming and hardware cache statistics
47
+ * (`dram_cached_tokens`, `sram_cached_tokens`) when not. Recognised by the counters themselves
48
+ * rather than by which call produced it, so a block naming none of them is declined outright
49
+ * instead of being read as three zeroes.
50
+ */
51
+ const asTokenUsage = (usage) => typeof usage?.prompt_tokens === 'number' ||
52
+ typeof usage?.completion_tokens === 'number' ||
53
+ typeof usage?.total_tokens === 'number'
54
+ ? usage
55
+ : undefined;
56
+ /**
57
+ * The two surfaces, and where each keeps its usage.
58
+ *
59
+ * Chat Completions and Responses are the same API family with different field names, so the only
60
+ * thing that varies per surface is which extractor reads the counts and where a stream hides them.
61
+ */
62
+ const SURFACES = [
63
+ {
64
+ path: ['chat', 'completions', 'create'],
65
+ parse: token_usage_1.fromOpenAIUsage,
66
+ keys: token_usage_1.METRIC_PATHS.openaiChat,
67
+ metrics: token_usage_1.OPENAI_CHAT_METRICS,
68
+ // Reported once, on the final chunk - at the root for OpenAI itself, and under `x_groq` for
69
+ // Groq, whose chunks carry no top-level `usage` at all. See the header on that second read.
70
+ streamed: (chunk) => chunk?.usage ?? asTokenUsage(chunk?.x_groq?.usage),
71
+ },
72
+ {
73
+ path: ['responses', 'create'],
74
+ parse: token_usage_1.fromResponsesUsage,
75
+ keys: token_usage_1.METRIC_PATHS.openaiResponses,
76
+ metrics: token_usage_1.OPENAI_RESPONSES_METRICS,
77
+ // Reported on the completion event, which carries the whole response rather than a delta.
78
+ streamed: (chunk) => chunk?.response?.usage ?? chunk?.usage,
79
+ },
80
+ ];
81
+ /** The reader for a surface, so a recipe built on top of this one stays in step with it. */
82
+ const openAISurface = (path) => SURFACES.find((surface) => surface.path.join('.') === path.join('.')) ?? SURFACES[0];
83
+ exports.openAISurface = openAISurface;
84
+ exports.openAIRecipe = {
85
+ vendor: 'openai',
86
+ metrics: token_usage_1.OPENAI_METRICS,
87
+ totalTokensMetric: token_usage_1.TOTAL_TOKENS,
88
+ // Either surface is enough: the recipe governs both, and a client built only for the Responses
89
+ // API has no `chat.completions` to find.
90
+ matches: (client) => (0, registry_1.hasMethod)(client, ['chat', 'completions', 'create']) || (0, registry_1.hasMethod)(client, ['responses', 'create']),
91
+ methods: SURFACES.map(({ path, parse, keys, streamed }) => ({
92
+ path: [...path],
93
+ // What OpenAI called it, which is what gets reported, beside the normalized counters. Note
94
+ // `prompt_tokens` includes the cached tokens reported next to it - the overlap is the
95
+ // vendor's, kept so the numbers reconcile against its own dashboard.
96
+ extractUsage: (result) => result?.usage
97
+ ? { metrics: (0, token_usage_1.pickMetrics)(result.usage, keys), usage: parse(result.usage), raw: result.usage }
98
+ : undefined,
99
+ attachStream: (stream, ingest) => {
100
+ let total = (0, types_1.emptyTokenUsage)();
101
+ let metrics = {};
102
+ // As on the non-streamed path, so a streamed turn states its usage just as fully - the
103
+ // counters this recipe does not name (`total_tokens`, the reasoning-token detail) reach a
104
+ // meter either way rather than only when the caller happened not to stream.
105
+ let raw;
106
+ (0, meter_stream_1.meterStream)(stream, (chunk) => {
107
+ const usage = streamed(chunk);
108
+ // Reported once and whole rather than as deltas, so the last one wins outright - which
109
+ // is also what makes the block safe to keep as it arrived.
110
+ if (usage) {
111
+ total = parse(usage);
112
+ metrics = (0, token_usage_1.pickMetrics)(usage, keys);
113
+ // Copied, not referenced - it is held until the stream ends, and the chunk it came
114
+ // from belongs to the consumer. See the Gemini recipe for the full reasoning.
115
+ raw = { ...usage };
116
+ }
117
+ },
118
+ // Absent rather than `undefined` when no chunk carried usage - see the Gemini recipe.
119
+ () => ingest({ metrics, usage: total, ...(raw ? { raw } : {}) }));
120
+ },
121
+ })),
122
+ };