@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,171 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.litellmRecipe = litellmRecipe;
4
+ /**
5
+ * The LiteLLM recipe - a gateway you call, whose cost arrives beside the body rather than in it.
6
+ *
7
+ * LiteLLM Proxy speaks the OpenAI wire format, so `openAIRecipe` already fits it by shape. What
8
+ * that recipe cannot express is the same pair `openrouterRecipe` exists for: the money actually
9
+ * spent, and which deployment actually served after routing. LiteLLM knows both - it prices every
10
+ * call itself - but it reports the cost in a response header, `x-litellm-response-cost`, and never
11
+ * in the body. A body-only extractor cannot see it, which is why `extractUsage` takes the response.
12
+ *
13
+ * That placement has a consequence worth stating plainly, because it is the protocol and not a bug:
14
+ * headers go on the wire before the model has generated anything, so a STREAMED call can never
15
+ * carry a cost. Tokens survive it fine - LiteLLM honours `stream_options: {include_usage: true}`
16
+ * and puts `usage` on the final chunk - so a stream falls back to the token path via
17
+ * `onMissingCost`. Non-streaming bills real spend; streaming bills tokens. The streaming branch
18
+ * below still checks for the header, so the day LiteLLM can send one, this recipe already reads it.
19
+ *
20
+ * All of that applies to a run that bills `cost` - `weightedMetrics(litellmRecipe({ baseUrl }),
21
+ * 'credits', { cost: 1 })`. Bill the token counters instead and the split above stops mattering:
22
+ * one denomination whether or not the call streamed. Either way the recipe is worth naming for a
23
+ * self-hosted proxy, to give it the base URL its matcher cannot guess.
24
+ */
25
+ const meter_stream_1 = require("../../../shared/meter-stream");
26
+ const openai_recipe_1 = require("../../providers/openai/openai-recipe");
27
+ const token_usage_1 = require("../../../shared/token-usage");
28
+ /** The cost LiteLLM charged for this call, in USD. Non-streaming responses only. */
29
+ const COST_HEADER = 'x-litellm-response-cost';
30
+ /** The proxy's own id for the call: stable across its retries, which a local step counter is not. */
31
+ const CALL_ID_HEADER = 'x-litellm-call-id';
32
+ /** Float noise: 0.00108 * 1e6 is 1080.0000000000002, and ceil would bill the extra unit. */
33
+ const toUnits = (cost, unitsPerDollar) => Math.max(1, Math.ceil(Number((cost * unitsPerDollar).toPrecision(12))));
34
+ /** The header is a string on the wire, and absent on every streamed response. */
35
+ function costOf(response) {
36
+ const raw = response?.headers?.get(COST_HEADER);
37
+ if (raw == null || raw === '')
38
+ return undefined;
39
+ const cost = Number(raw);
40
+ return Number.isFinite(cost) ? cost : undefined;
41
+ }
42
+ function measure(usage, parse, keys, model, id, response, options) {
43
+ if (!usage)
44
+ return undefined;
45
+ // LiteLLM normalizes every upstream (Anthropic, Bedrock, whatever) to the OpenAI wire format,
46
+ // so the token names are OpenAI's. The cost is the proxy's own, and it rides beside them rather
47
+ // than instead of them - a streamed call simply has no cost to report, because the header goes
48
+ // out before the first token exists.
49
+ const cost = costOf(response);
50
+ const metrics = (0, token_usage_1.pickMetrics)(usage, keys);
51
+ if (cost !== undefined && cost > 0)
52
+ metrics.cost = toUnits(cost, options.unitsPerDollar);
53
+ return {
54
+ metrics,
55
+ usage: parse(usage),
56
+ // What the proxy stated, before `cost` was scaled into whole units - an event can carry the
57
+ // fraction as it was, which is the honest number and the one that reconciles with the bill.
58
+ //
59
+ // The cost is folded in rather than assumed present: LiteLLM reports it in a header, not in
60
+ // the body, so unlike OpenRouter the response alone carries no trace of it. Taking the body
61
+ // as-is left `metrics.cost` rounded up to whole units as the only record of a price the
62
+ // proxy had stated exactly.
63
+ raw: cost === undefined ? usage : { ...usage, cost },
64
+ dimensions: model ? { [options.modelDimension]: model } : undefined,
65
+ // The proxy's own call id wins over the completion id: it is what its spend logs are keyed on,
66
+ // so a replay lines up on both sides of the boundary.
67
+ idempotencyKey: response?.headers?.get(CALL_ID_HEADER) ?? id,
68
+ };
69
+ }
70
+ /**
71
+ * One recipe covers every upstream. LiteLLM normalizes Anthropic, OpenAI, Bedrock and the rest to
72
+ * the same wire shape, so the response never changes form - `model` is just a field saying which
73
+ * deployment served, which is what makes it worth putting on the ingest as a dimension: the client
74
+ * asked for a model group, and the group is free to resolve to something else.
75
+ */
76
+ function litellmRecipe(options = {}) {
77
+ const resolved = {
78
+ estimate: options.estimate ?? (() => 1),
79
+ unitsPerDollar: positiveDenomination(options.unitsPerDollar, 'unitsPerDollar'),
80
+ modelDimension: options.modelDimension ?? 'model',
81
+ };
82
+ return {
83
+ metrics: token_usage_1.GATEWAY_METRICS,
84
+ // The OpenAI shape it speaks, so the OpenAI name for the total.
85
+ totalTokensMetric: token_usage_1.TOTAL_TOKENS,
86
+ vendor: 'litellm',
87
+ // Shape alone cannot separate this from any other OpenAI-compatible client, so the base URL
88
+ // is the discriminator - it is the one thing that says which gateway is on the other end.
89
+ matches: (client) => openai_recipe_1.openAIRecipe.matches(client) && isLiteLLM(client, options.baseUrl),
90
+ // Built from the OpenAI recipe's own surfaces, for the same reason as the OpenRouter one: this
91
+ // recipe is matched ahead of it, so a surface it failed to declare would go ungated.
92
+ methods: openai_recipe_1.openAIRecipe.methods.map((base) => {
93
+ const { parse, keys, streamed } = (0, openai_recipe_1.openAISurface)(base.path);
94
+ return {
95
+ path: base.path,
96
+ estimate: resolved.estimate,
97
+ extractUsage: (result, response) => measure(result?.usage, parse, keys, result?.model, result?.id, response, resolved),
98
+ // Usage arrives once, at the end; `model` and `id` ride every chunk. The response is
99
+ // threaded through even though a real stream's headers carry no cost today - the branch
100
+ // costs nothing and is the whole difference between this being wrong later and right.
101
+ attachStream: (stream, ingest, response) => {
102
+ let usage;
103
+ let model;
104
+ let id;
105
+ (0, meter_stream_1.meterStream)(stream, (chunk) => {
106
+ const seen = streamed(chunk);
107
+ if (seen)
108
+ usage = seen;
109
+ if (chunk?.model)
110
+ model = chunk.model;
111
+ if (chunk?.id)
112
+ id = chunk.id;
113
+ }, () => {
114
+ const measurement = measure(usage, parse, keys, model, id, response, resolved);
115
+ if (measurement)
116
+ ingest(measurement);
117
+ });
118
+ },
119
+ };
120
+ }),
121
+ };
122
+ }
123
+ /**
124
+ * `baseURL` is a public field on the OpenAI client. Shape cannot answer this and a wrong guess
125
+ * silently changes the unit of account, so the check is narrow on purpose.
126
+ *
127
+ * A LiteLLM proxy is self-hosted, so there is no `openrouter.ai` to look for: the configured
128
+ * `baseUrl` is the only reliable answer, and the hostname guess below is a convenience for the
129
+ * deployments that do say so. A proxy on a host that mentions neither takes the recipe explicitly,
130
+ * via `wrap(client, run, { recipe })`.
131
+ */
132
+ function isLiteLLM(client, baseUrl) {
133
+ const clientBase = client?.baseURL;
134
+ if (typeof clientBase !== 'string')
135
+ return false;
136
+ // A prefix match on the raw string lets `.../v1` claim `.../v10`, which is a different
137
+ // deployment with a different price list. Compare the endpoint, one path segment at a time.
138
+ if (baseUrl)
139
+ return within(baseUrl, clientBase);
140
+ return clientBase.toLowerCase().includes('litellm');
141
+ }
142
+ /** Whether `candidate` is the configured endpoint, or something under it. */
143
+ function within(configured, candidate) {
144
+ try {
145
+ const want = new URL(configured);
146
+ const got = new URL(candidate);
147
+ if (want.origin !== got.origin)
148
+ return false;
149
+ const base = want.pathname.replace(/\/+$/, '');
150
+ const path = got.pathname.replace(/\/+$/, '');
151
+ return path === base || path.startsWith(`${base}/`);
152
+ }
153
+ catch {
154
+ return false;
155
+ }
156
+ }
157
+ /**
158
+ * The denomination, refused when it cannot divide a price.
159
+ *
160
+ * `NaN` and `Infinity` make `toUnits` produce a value the ingest rejects a call later, and zero or
161
+ * a negative clamps every cost to the floor of 1 - a bill that looks plausible and is not. Both
162
+ * are configuration mistakes worth naming where they were made.
163
+ */
164
+ function positiveDenomination(value, name) {
165
+ if (value === undefined)
166
+ return 1_000_000;
167
+ if (!Number.isFinite(value) || value <= 0) {
168
+ throw new Error(`${name} must be a finite positive number - got ${value}, which prices every call wrongly.`);
169
+ }
170
+ return value;
171
+ }
@@ -0,0 +1,30 @@
1
+ export type LiteLLMRecipeOptions = {
2
+ /**
3
+ * How much to ask permission for before the call, in whatever unit `meter` bills.
4
+ *
5
+ * The default asks for 1 - "is there any budget left at all?" - and lets the ingest afterwards
6
+ * settle the balance. Billing tokens, the gap that leaves is about one call's worth of units.
7
+ * Billing `cost` it is not: a call priced in micro-dollars is thousands of units, so a run with
8
+ * one unit left passes the gate and then spends far past it.
9
+ *
10
+ * Nothing here can price a call the gateway has not priced yet, so closing that gap needs a
11
+ * ceiling only the caller knows - `max_tokens` against a rate they are willing to assume. Give
12
+ * one and the gate asks for it; leave it and the overshoot is bounded by a single call, which
13
+ * is the trade the default makes everywhere else.
14
+ */
15
+ estimate?: (params: any) => number;
16
+ /**
17
+ * Units per dollar. LiteLLM reports fractional USD and Stigg ingests whole units, so the meter
18
+ * has to be denominated in something small enough not to round every call up to 1. The default
19
+ * is micro-dollars; set it to match however the Stigg currency is defined.
20
+ */
21
+ unitsPerDollar?: number;
22
+ /** Dimension the served model is attributed to, so routing drift is visible in Stigg. */
23
+ modelDimension?: string;
24
+ /**
25
+ * The proxy this recipe is for. A LiteLLM deployment is self-hosted, so unlike a public gateway
26
+ * there is no fixed host to recognise - without this, `matches` can only fall back to a narrow
27
+ * guess at the hostname. Give it the same base URL the client was built with.
28
+ */
29
+ baseUrl?: string;
30
+ };
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,20 @@
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
+ * It also covers both ways of reaching OpenRouter from Node: the OpenAI SDK pointed at the
11
+ * gateway's base URL, and OpenRouter's own `@openrouter/sdk`. Those are different objects with
12
+ * different method names reporting the same call, and a recipe that governed only the first would
13
+ * meter nothing at all for an application that took OpenRouter's own documented advice.
14
+ */
15
+ export declare function openrouterRecipe(options?: OpenRouterRecipeOptions): ClientRecipe<GatewayMetric>;
16
+ /** One of the metric names OpenRouter reports. */
17
+ export type OpenRouterMetric = MetricsOf<ReturnType<typeof openrouterRecipe>>;
18
+ /** What a mapper reads off an OpenRouter call: those names, each possibly absent. */
19
+ export type OpenRouterMetrics = MetricsFor<ReturnType<typeof openrouterRecipe>>;
20
+ export {};
@@ -0,0 +1,244 @@
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
+ const registry_1 = require("../../core/registry");
27
+ const sdk_usage_1 = require("./sdk-usage");
28
+ /** Float noise: 0.00108 * 1e6 is 1080.0000000000002, and ceil would bill the extra unit. */
29
+ const toUnits = (cost, unitsPerCredit) => Math.max(1, Math.ceil(Number((cost * unitsPerCredit).toPrecision(12))));
30
+ /**
31
+ * Tokens and cost, from one response.
32
+ *
33
+ * A gateway states both, they are different features, and reporting both is what lets one meter
34
+ * the tokens and the other the money.
35
+ *
36
+ * OpenRouter writes the money in the body, which is why a streamed call can still be billed on it:
37
+ * the usage object arrives on the last chunk, cost included, provided the request asked for usage
38
+ * (`stream_options.include_usage`, or `streamOptions.includeUsage` through the first-party SDK).
39
+ * A caller who does not ask gets a stream that states nothing, and the key is then absent rather
40
+ * than substituted - billing a guess is the one thing a cost-truth adapter must not do. LiteLLM is
41
+ * the gateway that genuinely cannot state it, because its cost rides a header that went out before
42
+ * the first token existed.
43
+ */
44
+ function measure(usage, parse, keys, model, id, options,
45
+ /**
46
+ * What the vendor itself stated, when that is not the object being read.
47
+ *
48
+ * The first-party SDK hands back camelCased counters, which are translated to the wire's own
49
+ * spelling before anything meters them - but an event should carry what OpenRouter said, not
50
+ * what this file rewrote it to, so the original rides along unchanged.
51
+ */
52
+ stated) {
53
+ if (!usage)
54
+ return undefined;
55
+ const cost = usage.cost;
56
+ const metrics = (0, token_usage_1.pickMetrics)(usage, keys);
57
+ // `cost` is the gateway's own field name, so it obeys the same rule as every other metric.
58
+ if (typeof cost === 'number' && cost > 0)
59
+ metrics.cost = toUnits(cost, options.unitsPerCredit);
60
+ return {
61
+ metrics,
62
+ usage: parse(usage),
63
+ // What the gateway stated, before `cost` was scaled into whole units - an event can carry the
64
+ // fraction as it was, which is the honest number and the one that reconciles with the bill.
65
+ raw: stated ?? usage,
66
+ // The gateway's own request id: stable across its retries, which a local step counter is not.
67
+ dimensions: model ? { [options.modelDimension]: model } : undefined,
68
+ idempotencyKey: id,
69
+ };
70
+ }
71
+ /**
72
+ * The first-party SDK's two surfaces, built the same way the OpenAI-shaped ones are.
73
+ *
74
+ * `@openrouter/sdk` is generated from the OpenAPI spec, so it shares nothing with `openai-node`
75
+ * but the service behind it: the method is `chat.send` rather than `chat.completions.create`, the
76
+ * request nests under `chatRequest`, and the counters come back camelCased. Only the last of
77
+ * those reaches a meter, and `sdk-usage.ts` undoes it so both clients report the same metric
78
+ * names for the same call.
79
+ *
80
+ * `responses.send` is here for the reason the comment on `methods` gives: a surface this recipe
81
+ * declines to declare is not metered by something else, it is simply ungated - and a client this
82
+ * recipe claims to govern must not have a billable method left open on it.
83
+ */
84
+ function sdkSurfaces(resolved) {
85
+ const surfaces = [
86
+ { path: ['chat', 'send'], read: sdk_usage_1.fromSdkChatUsage, parse: token_usage_1.fromOpenAIUsage, keys: token_usage_1.METRIC_PATHS.openaiChat },
87
+ {
88
+ path: ['responses', 'send'],
89
+ read: sdk_usage_1.fromSdkResponsesUsage,
90
+ parse: token_usage_1.fromResponsesUsage,
91
+ keys: token_usage_1.METRIC_PATHS.openaiResponses,
92
+ },
93
+ ];
94
+ return surfaces.map(({ path, read, parse, keys }) => {
95
+ /** The vendor's own object translated, with the original kept for the event to carry. */
96
+ const measureSdk = (usage, model, id) => measure(read(usage), parse, keys, model, id, resolved, usage);
97
+ return {
98
+ path: [...path],
99
+ estimate: resolved.estimate,
100
+ extractUsage: (result) => measureSdk(result?.usage, result?.model, result?.id),
101
+ // A streamed call resolves to an `EventStream`, which is a `ReadableStream` subclass: async
102
+ // iterable, and with no `next`/`return` of its own for `meterStream` to patch. So the
103
+ // metered path is `for await`, which is how the SDK documents consuming one. A caller who
104
+ // takes `getReader()` instead bypasses the wrapper - the same hole every streaming binding
105
+ // has, and the reason the non-streamed surface is the one the example leans on.
106
+ attachStream: (stream, ingest) => {
107
+ let usage;
108
+ let model;
109
+ let id;
110
+ (0, meter_stream_1.meterStream)(stream, (chunk) => {
111
+ // Usage arrives once, on the last chunk; `model` and `id` ride every one of them.
112
+ if (chunk?.usage)
113
+ usage = chunk.usage;
114
+ if (chunk?.model)
115
+ model = chunk.model;
116
+ if (chunk?.id)
117
+ id = chunk.id;
118
+ }, () => {
119
+ const measurement = measureSdk(usage, model, id);
120
+ if (measurement)
121
+ ingest(measurement);
122
+ });
123
+ },
124
+ };
125
+ });
126
+ }
127
+ /**
128
+ * One recipe covers every upstream. OpenRouter normalizes Anthropic, OpenAI and the rest to the
129
+ * same wire shape, so the response never changes form - `model` is just a field saying who served.
130
+ *
131
+ * It also covers both ways of reaching OpenRouter from Node: the OpenAI SDK pointed at the
132
+ * gateway's base URL, and OpenRouter's own `@openrouter/sdk`. Those are different objects with
133
+ * different method names reporting the same call, and a recipe that governed only the first would
134
+ * meter nothing at all for an application that took OpenRouter's own documented advice.
135
+ */
136
+ function openrouterRecipe(options = {}) {
137
+ const resolved = {
138
+ estimate: options.estimate ?? (() => 1),
139
+ unitsPerCredit: positiveDenomination(options.unitsPerCredit, 'unitsPerCredit'),
140
+ modelDimension: options.modelDimension ?? 'model',
141
+ };
142
+ return {
143
+ metrics: token_usage_1.GATEWAY_METRICS,
144
+ // The OpenAI shape it speaks, so the OpenAI name for the total.
145
+ totalTokensMetric: token_usage_1.TOTAL_TOKENS,
146
+ vendor: 'openrouter',
147
+ // Two clients, recognised two different ways - see each guard for why they cannot share one.
148
+ // The OpenAI-shaped client is OpenAI-shaped, so only its base URL says which gateway it talks
149
+ // to; OpenRouter's own SDK is unmistakable by shape and may legitimately point anywhere.
150
+ matches: (client) => (openai_recipe_1.openAIRecipe.matches(client) && isOpenRouter(client)) || isOpenRouterSdk(client),
151
+ // Built from the OpenAI recipe's own surfaces. A gateway speaks that API, so the methods worth
152
+ // governing are the same ones - and because this recipe is matched ahead of it, any surface it
153
+ // failed to declare would be left ungated rather than falling through.
154
+ methods: [
155
+ ...openai_recipe_1.openAIRecipe.methods.map((base) => {
156
+ const { parse, keys, streamed } = (0, openai_recipe_1.openAISurface)(base.path);
157
+ return {
158
+ path: base.path,
159
+ estimate: resolved.estimate,
160
+ extractUsage: (result) => measure(result?.usage, parse, keys, result?.model, result?.id, resolved),
161
+ // Usage arrives once, at the end; `model` and `id` ride every chunk.
162
+ attachStream: (stream, ingest) => {
163
+ let usage;
164
+ let model;
165
+ let id;
166
+ (0, meter_stream_1.meterStream)(stream, (chunk) => {
167
+ const seen = streamed(chunk);
168
+ if (seen)
169
+ usage = seen;
170
+ if (chunk?.model)
171
+ model = chunk.model;
172
+ if (chunk?.id)
173
+ id = chunk.id;
174
+ }, () => {
175
+ const measurement = measure(usage, parse, keys, model, id, resolved);
176
+ if (measurement)
177
+ ingest(measurement);
178
+ });
179
+ },
180
+ };
181
+ }),
182
+ ...sdkSurfaces(resolved),
183
+ ],
184
+ };
185
+ }
186
+ /**
187
+ * `baseURL` is a public field on the OpenAI client. Shape cannot answer this and a wrong guess
188
+ * silently changes the unit of account, so the check is narrow on purpose: an OpenRouter-compatible
189
+ * endpoint on another host takes the recipe explicitly, via `wrap(client, run, { recipe })`.
190
+ */
191
+ function isOpenRouter(client) {
192
+ const baseUrl = client?.baseURL;
193
+ if (typeof baseUrl !== 'string')
194
+ return false;
195
+ // The host, not a substring of the URL: `openrouter.ai.example.com` is somebody else's domain,
196
+ // and claiming it would meter an unrelated client as a gateway.
197
+ const host = hostOf(baseUrl);
198
+ return host === 'openrouter.ai' || host.endsWith('.openrouter.ai');
199
+ }
200
+ /**
201
+ * Whether this is OpenRouter's own SDK client, which carries no base URL to recognise it by.
202
+ *
203
+ * The OpenAI-shaped client is identified by where it points, because its shape says only
204
+ * "OpenAI-compatible" and half the industry is. This one is the opposite: `chat.send` is not a
205
+ * shape anyone else has, and the namespaces beside it - `credits`, `generations`, the gateway's
206
+ * own billing and routing APIs - are OpenRouter's product rather than an API style someone could
207
+ * implement by accident. So shape is the whole discriminator here, and there is no URL to check:
208
+ * an application may point the SDK at a proxy, which is still OpenRouter's SDK and still speaks
209
+ * its format.
210
+ *
211
+ * `in` rather than a property read, because every namespace on that client is a lazy getter that
212
+ * constructs a sub-client - and merely asking whether a recipe fits must not build three of them.
213
+ */
214
+ function isOpenRouterSdk(client) {
215
+ if (!client || typeof client !== 'object')
216
+ return false;
217
+ if (!(0, registry_1.hasMethod)(client, ['chat', 'send']) && !(0, registry_1.hasMethod)(client, ['responses', 'send']))
218
+ return false;
219
+ return 'credits' in client && 'generations' in client;
220
+ }
221
+ /** The hostname, lowercased, or '' when the URL is not one this can reason about. */
222
+ function hostOf(url) {
223
+ try {
224
+ return new URL(url).hostname.toLowerCase();
225
+ }
226
+ catch {
227
+ return '';
228
+ }
229
+ }
230
+ /**
231
+ * The denomination, refused when it cannot divide a price.
232
+ *
233
+ * `NaN` and `Infinity` make `toUnits` produce a value the ingest rejects a call later, and zero or
234
+ * a negative clamps every cost to the floor of 1 - a bill that looks plausible and is not. Both
235
+ * are configuration mistakes worth naming where they were made.
236
+ */
237
+ function positiveDenomination(value, name) {
238
+ if (value === undefined)
239
+ return 1_000_000;
240
+ if (!Number.isFinite(value) || value <= 0) {
241
+ throw new Error(`${name} must be a finite positive number - got ${value}, which prices every call wrongly.`);
242
+ }
243
+ return value;
244
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * OpenRouter's first-party SDK counts the same tokens under different names.
3
+ *
4
+ * `@openrouter/sdk` is generated from the OpenAPI spec and camelCases every field on the way in,
5
+ * so a response that arrived as `prompt_tokens` reaches the caller as `promptTokens`. Nothing
6
+ * about the call changed - same gateway, same wire format, same bill - only the spelling the
7
+ * parse handed back.
8
+ *
9
+ * That matters more than it looks. A metric name is a **feature id**: it is what the mapper
10
+ * prices and what Stigg meters. If the same gateway reported `prompt_tokens` through one client
11
+ * and `promptTokens` through another, one customer's calls would land on two different features
12
+ * depending on which SDK the application happened to import - and the second of them exists in
13
+ * nobody's catalog, so those calls would report to a feature that cannot be billed.
14
+ *
15
+ * So the spelling is undone here rather than carried through: each reader below returns the
16
+ * OpenAI-shaped object the wire actually carried, and everything downstream - `pickMetrics`,
17
+ * `fromOpenAIUsage`, the metric names in `GATEWAY_METRICS` - is the code that already serves the
18
+ * OpenAI-shaped client, reading the names it already knows. The vendor's own object is still what
19
+ * rides along as `raw`, so an event carries what OpenRouter said, spelled the way OpenRouter
20
+ * spelled it.
21
+ */
22
+ import type { OpenRouterSdkChatUsage, OpenRouterSdkResponsesUsage, OpenRouterUsage } from './types';
23
+ /**
24
+ * `chat.send` - the Chat Completions shape, camelCased.
25
+ *
26
+ * `cost` keeps its name across both spellings, because it is OpenRouter's own field rather than
27
+ * one inherited from OpenAI, and the generator had no snake_case original to convert.
28
+ */
29
+ export declare function fromSdkChatUsage(usage: OpenRouterSdkChatUsage | null | undefined): OpenRouterUsage | undefined;
30
+ /**
31
+ * `responses.send` - the Responses API shape, camelCased.
32
+ *
33
+ * The same tokens again under the third set of names this SDK can hand back: `inputTokens` rather
34
+ * than `promptTokens`, with the cache detail one level along. Read with the chat reader it would
35
+ * find none of them and meter the call at zero, which is why the two surfaces get a reader each.
36
+ */
37
+ export declare function fromSdkResponsesUsage(usage: OpenRouterSdkResponsesUsage | null | undefined): OpenRouterUsage | undefined;
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.fromSdkChatUsage = fromSdkChatUsage;
4
+ exports.fromSdkResponsesUsage = fromSdkResponsesUsage;
5
+ /** Drops what the SDK models as nullable, so an absent counter stays absent rather than null. */
6
+ const num = (value) => (typeof value === 'number' ? value : undefined);
7
+ /**
8
+ * `{ key: value }` when there is a value, and nothing at all when there is not.
9
+ *
10
+ * Absent and zero are different: `pickMetrics` skips a counter that is not a positive number, and
11
+ * a nested detail object that exists with nothing in it reads as a vendor that reported a cache
12
+ * and found none - which is not the same as a vendor that reported no cache at all.
13
+ */
14
+ const maybe = (key, value) => value === undefined ? {} : { [key]: value };
15
+ /** Nothing, rather than an empty object, when a whole detail group was absent. */
16
+ const group = (fields) => Object.keys(fields).length > 0 ? fields : undefined;
17
+ /**
18
+ * `chat.send` - the Chat Completions shape, camelCased.
19
+ *
20
+ * `cost` keeps its name across both spellings, because it is OpenRouter's own field rather than
21
+ * one inherited from OpenAI, and the generator had no snake_case original to convert.
22
+ */
23
+ function fromSdkChatUsage(usage) {
24
+ if (!usage)
25
+ return undefined;
26
+ const promptDetails = group({
27
+ ...maybe('cached_tokens', num(usage.promptTokensDetails?.cachedTokens)),
28
+ ...maybe('cache_write_tokens', num(usage.promptTokensDetails?.cacheWriteTokens)),
29
+ });
30
+ const completionDetails = group({
31
+ ...maybe('reasoning_tokens', num(usage.completionTokensDetails?.reasoningTokens)),
32
+ });
33
+ return {
34
+ ...maybe('prompt_tokens', num(usage.promptTokens)),
35
+ ...maybe('completion_tokens', num(usage.completionTokens)),
36
+ ...maybe('total_tokens', num(usage.totalTokens)),
37
+ ...maybe('cost', num(usage.cost)),
38
+ ...maybe('prompt_tokens_details', promptDetails),
39
+ ...maybe('completion_tokens_details', completionDetails),
40
+ };
41
+ }
42
+ /**
43
+ * `responses.send` - the Responses API shape, camelCased.
44
+ *
45
+ * The same tokens again under the third set of names this SDK can hand back: `inputTokens` rather
46
+ * than `promptTokens`, with the cache detail one level along. Read with the chat reader it would
47
+ * find none of them and meter the call at zero, which is why the two surfaces get a reader each.
48
+ */
49
+ function fromSdkResponsesUsage(usage) {
50
+ if (!usage)
51
+ return undefined;
52
+ const inputDetails = group({
53
+ ...maybe('cached_tokens', num(usage.inputTokensDetails?.cachedTokens)),
54
+ ...maybe('cache_write_tokens', num(usage.inputTokensDetails?.cacheWriteTokens)),
55
+ });
56
+ return {
57
+ ...maybe('input_tokens', num(usage.inputTokens)),
58
+ ...maybe('output_tokens', num(usage.outputTokens)),
59
+ ...maybe('total_tokens', num(usage.totalTokens)),
60
+ ...maybe('cost', num(usage.cost)),
61
+ ...maybe('input_tokens_details', inputDetails),
62
+ };
63
+ }
@@ -0,0 +1,78 @@
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
+ * The Responses-API counters, for the one client that can report either shape.
7
+ *
8
+ * `@openrouter/sdk` reaches both of OpenRouter's surfaces, and they count the same tokens under
9
+ * different names - so the object a reader hands back may carry one set or the other. Optional
10
+ * on both sides rather than a union, because the reader that produced it already knows which
11
+ * surface it read, and the metric list it is picked with is the surface's own.
12
+ */
13
+ input_tokens?: number | null;
14
+ output_tokens?: number | null;
15
+ input_tokens_details?: {
16
+ cached_tokens?: number | null;
17
+ cache_write_tokens?: number | null;
18
+ } | null;
19
+ };
20
+ /**
21
+ * What `@openrouter/sdk` hands back for `chat.send`, in the SDK's own spelling.
22
+ *
23
+ * Declared structurally rather than imported from `@openrouter/sdk`: the package is a devDependency
24
+ * of the examples, and a recipe that imported its types would make it a dependency of the SDK
25
+ * itself - every consumer installing a client they may not use. Only the counters are named here,
26
+ * which is all a meter reads; see `sdk-usage.ts` for why the spelling is undone rather than kept.
27
+ */
28
+ export type OpenRouterSdkChatUsage = {
29
+ promptTokens?: number | null;
30
+ completionTokens?: number | null;
31
+ totalTokens?: number | null;
32
+ cost?: number | null;
33
+ promptTokensDetails?: {
34
+ cachedTokens?: number | null;
35
+ cacheWriteTokens?: number | null;
36
+ } | null;
37
+ completionTokensDetails?: {
38
+ reasoningTokens?: number | null;
39
+ } | null;
40
+ };
41
+ /** The same, for `responses.send` - the Responses API's names, camelCased the same way. */
42
+ export type OpenRouterSdkResponsesUsage = {
43
+ inputTokens?: number | null;
44
+ outputTokens?: number | null;
45
+ totalTokens?: number | null;
46
+ cost?: number | null;
47
+ inputTokensDetails?: {
48
+ cachedTokens?: number | null;
49
+ cacheWriteTokens?: number | null;
50
+ } | null;
51
+ outputTokensDetails?: {
52
+ reasoningTokens?: number | null;
53
+ } | null;
54
+ };
55
+ export type OpenRouterRecipeOptions = {
56
+ /**
57
+ * How much to ask permission for before the call.
58
+ *
59
+ * The default asks for 1 - "is there any budget left at all?" - and lets the ingest afterwards
60
+ * settle the balance. Billing tokens, the gap that leaves is about one call's worth of units.
61
+ * Billing `cost` it is not: a call priced in micro-credits is thousands of units, so a run with
62
+ * one unit left passes the gate and then spends far past it.
63
+ *
64
+ * Nothing here can price a call the gateway has not priced yet, so closing that gap needs a
65
+ * ceiling only the caller knows - `max_tokens` against a rate they are willing to assume. Give
66
+ * one and the gate asks for it; leave it and the overshoot is bounded by a single call, which
67
+ * is the trade the default makes everywhere else.
68
+ */
69
+ estimate?: (params: any) => number;
70
+ /**
71
+ * Units per credit. OpenRouter charges fractional credits and Stigg ingests whole units, so the
72
+ * meter has to be denominated in something small enough not to round every call up to 1. The
73
+ * default is micro-credits; set it to match however the Stigg currency is defined.
74
+ */
75
+ unitsPerCredit?: number;
76
+ /** Dimension the served model is attributed to, so routing drift is visible in Stigg. */
77
+ modelDimension?: string;
78
+ };
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });