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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +1147 -2
  2. package/dist/bindings/client/core/registry.d.ts +12 -0
  3. package/dist/bindings/client/core/registry.js +22 -0
  4. package/dist/bindings/client/core/types.d.ts +117 -0
  5. package/dist/bindings/client/core/types.js +13 -0
  6. package/dist/bindings/client/core/wrap-client.d.ts +6 -0
  7. package/dist/bindings/client/core/wrap-client.js +258 -0
  8. package/dist/bindings/client/gateways/litellm/litellm-recipe.d.ts +17 -0
  9. package/dist/bindings/client/gateways/litellm/litellm-recipe.js +171 -0
  10. package/dist/bindings/client/gateways/litellm/types.d.ts +30 -0
  11. package/dist/bindings/client/gateways/litellm/types.js +2 -0
  12. package/dist/bindings/client/gateways/openrouter/openrouter-recipe.d.ts +15 -0
  13. package/dist/bindings/client/gateways/openrouter/openrouter-recipe.js +141 -0
  14. package/dist/bindings/client/gateways/openrouter/types.d.ts +29 -0
  15. package/dist/bindings/client/gateways/openrouter/types.js +2 -0
  16. package/dist/bindings/client/providers/anthropic/anthropic-recipe.d.ts +34 -0
  17. package/dist/bindings/client/providers/anthropic/anthropic-recipe.js +91 -0
  18. package/dist/bindings/client/providers/gemini/gemini-recipe.d.ts +54 -0
  19. package/dist/bindings/client/providers/gemini/gemini-recipe.js +243 -0
  20. package/dist/bindings/client/providers/openai/openai-recipe.d.ts +43 -0
  21. package/dist/bindings/client/providers/openai/openai-recipe.js +80 -0
  22. package/dist/bindings/client/providers/typesafe/typesafe-recipe.d.ts +33 -0
  23. package/dist/bindings/client/providers/typesafe/typesafe-recipe.js +73 -0
  24. package/dist/bindings/proxy/headers.d.ts +6 -0
  25. package/dist/bindings/proxy/headers.js +12 -0
  26. package/dist/bindings/proxy/index.d.ts +21 -0
  27. package/dist/bindings/proxy/index.js +18 -0
  28. package/dist/bindings/proxy/types.d.ts +8 -0
  29. package/dist/bindings/proxy/types.js +2 -0
  30. package/dist/bindings/shared/estimate.d.ts +27 -0
  31. package/dist/bindings/shared/estimate.js +38 -0
  32. package/dist/bindings/shared/meter-stream.d.ts +18 -0
  33. package/dist/bindings/shared/meter-stream.js +151 -0
  34. package/dist/bindings/shared/token-usage.d.ts +148 -0
  35. package/dist/bindings/shared/token-usage.js +414 -0
  36. package/dist/bindings/shared/types.d.ts +79 -0
  37. package/dist/bindings/shared/types.js +2 -0
  38. package/dist/bindings/shared/weighted-metrics.d.ts +37 -0
  39. package/dist/bindings/shared/weighted-metrics.js +44 -0
  40. package/dist/config.d.ts +136 -0
  41. package/dist/config.js +97 -0
  42. package/dist/core/context.d.ts +151 -0
  43. package/dist/core/context.js +154 -0
  44. package/dist/core/dimensions.d.ts +22 -0
  45. package/dist/core/dimensions.js +47 -0
  46. package/dist/core/hooks.d.ts +21 -0
  47. package/dist/core/hooks.js +47 -0
  48. package/dist/core/session.d.ts +161 -0
  49. package/dist/core/session.js +403 -0
  50. package/dist/decision-text.d.ts +15 -0
  51. package/dist/decision-text.js +54 -0
  52. package/dist/engine/composite-engine.d.ts +72 -0
  53. package/dist/engine/composite-engine.js +135 -0
  54. package/dist/engine/create-engine.d.ts +16 -0
  55. package/dist/engine/create-engine.js +93 -0
  56. package/dist/engine/decision.d.ts +37 -0
  57. package/dist/engine/decision.js +128 -0
  58. package/dist/engine/engine.d.ts +166 -0
  59. package/dist/engine/engine.js +11 -0
  60. package/dist/engine/node-sdk-engine.d.ts +125 -0
  61. package/dist/engine/node-sdk-engine.js +262 -0
  62. package/dist/engine/report.d.ts +106 -0
  63. package/dist/engine/report.js +185 -0
  64. package/dist/engine/rest-governance-engine.d.ts +47 -0
  65. package/dist/engine/rest-governance-engine.js +220 -0
  66. package/dist/errors.d.ts +78 -0
  67. package/dist/errors.js +151 -0
  68. package/dist/headers.d.ts +27 -0
  69. package/dist/headers.js +63 -0
  70. package/dist/index.d.ts +48 -0
  71. package/dist/index.js +85 -0
  72. package/dist/internal.d.ts +33 -0
  73. package/dist/internal.js +81 -0
  74. package/dist/logger.d.ts +15 -0
  75. package/dist/logger.js +20 -0
  76. package/dist/sdk-identity.d.ts +36 -0
  77. package/dist/sdk-identity.js +43 -0
  78. package/dist/stigg-agent-sdk.d.ts +242 -0
  79. package/dist/stigg-agent-sdk.js +594 -0
  80. package/dist/types.d.ts +531 -0
  81. package/dist/types.js +97 -0
  82. package/package.json +80 -3
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Which recipe fits a given client.
3
+ *
4
+ * Recipes are matched by the shape of the client rather than by a name the caller has to supply,
5
+ * so `wrap()` takes one argument and adding a provider is a registry entry - core never learns
6
+ * about a vendor. An unmatched client fails loudly and names what is supported, because a silent
7
+ * no-op here would mean a client that quietly meters nothing.
8
+ */
9
+ import type { ClientRecipe, ClientRegistry } from './types';
10
+ export declare function createClientRegistry(recipes?: ClientRecipe[]): ClientRegistry;
11
+ /** True when every segment of the path exists on the client and the last one is callable. */
12
+ export declare function hasMethod(client: unknown, path: readonly string[]): boolean;
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createClientRegistry = createClientRegistry;
4
+ exports.hasMethod = hasMethod;
5
+ function createClientRegistry(recipes = []) {
6
+ const entries = [...recipes];
7
+ return {
8
+ register: (recipe) => {
9
+ entries.push(recipe);
10
+ },
11
+ find: (client) => entries.find((recipe) => recipe.matches?.(client) ?? false),
12
+ vendors: () => entries.map((recipe) => recipe.vendor),
13
+ };
14
+ }
15
+ /** True when every segment of the path exists on the client and the last one is callable. */
16
+ // `readonly`, because the paths a caller has to hand are a recipe's own - and `MethodRecipe.path`
17
+ // is readonly so a literal tuple survives assignment. Taking a mutable array made the exported
18
+ // guard unusable with the exported type.
19
+ function hasMethod(client, path) {
20
+ const target = path.reduce((node, key) => (node == null ? node : node[key]), client);
21
+ return typeof target === 'function';
22
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * What a client binding needs to know about one vendor - and nothing more.
3
+ *
4
+ * Everything vendor-specific lives in a recipe: which methods to gate, how to read usage off a
5
+ * response, how a streamed response reports it later. The wrapper itself stays generic, so a new
6
+ * provider is a recipe file rather than a second copy of the gate.
7
+ */
8
+ import type { Measurement, TokenUsage } from '../../../types';
9
+ /**
10
+ * Just enough of a fetch `Response` for a recipe that has to read a header off it.
11
+ *
12
+ * Structural on purpose: core must not depend on a vendor's response class, nor on the DOM lib.
13
+ * Most vendors put everything a meter needs in the body, but not all - LiteLLM computes the cost
14
+ * and reports it beside the body, in `x-litellm-response-cost`, so a body-only extractor cannot
15
+ * see the one number that matters.
16
+ */
17
+ export type MeteredResponse = {
18
+ headers?: {
19
+ get: (name: string) => string | null;
20
+ };
21
+ };
22
+ /** A single method to gate, addressed by its property path from the client root. */
23
+ export type MethodRecipe = {
24
+ /**
25
+ * e.g. `['messages', 'create']` - the owner object is everything but the last segment.
26
+ *
27
+ * The method must return the vendor's request promise - an `APIPromise`, in both SDKs' terms:
28
+ * a promise that also offers `asResponse()` and `withResponse()`. The gate cannot hand over the
29
+ * vendor's own object, because the check it runs first is asynchronous and the object does not
30
+ * exist until it has passed, so it returns a stand-in shaped like one. A method that returns
31
+ * anything else - a paginated list, a stream helper, a bare value - would lose whatever that
32
+ * shape offers beyond a promise.
33
+ */
34
+ path: readonly string[];
35
+ /**
36
+ * True when this call is an internal leg of another wrapped method and must not be gated
37
+ * twice. Vendors mark these differently, so only the recipe can tell.
38
+ */
39
+ passthrough?: (params: any, options: any) => boolean;
40
+ /** Units to ask for before the call. Defaults to 1 - "is there any budget left at all?". */
41
+ estimate?: (params: any) => number;
42
+ /**
43
+ * Read straight off a completed response. Undefined when the result is a stream.
44
+ *
45
+ * `response` is the transport response the body came in on, for the vendors that report spend
46
+ * in a header rather than in the body. It is absent when the client's promise exposes no
47
+ * `withResponse()`, so a recipe that needs it must say what it does without it.
48
+ */
49
+ extractUsage?: (result: any, response?: MeteredResponse) => TokenUsage | Measurement | undefined;
50
+ /**
51
+ * Hooks a result that reports usage later. Wrap the iterator in place and call `ingest` once
52
+ * the stream completes, so one completed response emits exactly one measurement.
53
+ */
54
+ attachStream?: (result: any, ingest: (measurement: TokenUsage | Measurement) => void, response?: MeteredResponse) => void;
55
+ /**
56
+ * The event name for this call, when async ingest should report it as something other than the
57
+ * derived `vendor.path` - a proxy standing in for another vendor's API, say.
58
+ */
59
+ event?: string;
60
+ };
61
+ export type ClientRecipe<M extends string = string> = {
62
+ vendor: string;
63
+ /**
64
+ * The *name* of whichever metric is this vendor's total - not the number, which rides the
65
+ * measurement as `TokenUsage.total`.
66
+ *
67
+ * Declared rather than inferred, because the name differs per vendor - `totalTokenCount` for
68
+ * Gemini, `total_tokens` for everyone else - and a caller pricing metrics otherwise has no way
69
+ * to tell the total apart from the counters underneath it except by knowing each vendor by
70
+ * heart. Which matters: billing this *and* a component double-charges those tokens.
71
+ *
72
+ * Absent for a vendor that publishes no total and whose counters overlap, where there is
73
+ * nothing honest to put here.
74
+ */
75
+ totalTokensMetric?: M;
76
+ /**
77
+ * Every metric name this recipe can report, across all of its surfaces.
78
+ *
79
+ * What a mapper of your own can price, and what `weightedMetrics` types its table against. The
80
+ * union rather than one list per surface: a client speaks whichever API it likes, and a table
81
+ * covering both prices either - a name the call did not report is simply absent.
82
+ */
83
+ metrics: readonly M[];
84
+ methods: MethodRecipe[];
85
+ /**
86
+ * Duck-typed guard: does this recipe fit that client? Lets `wrap(client)` take one argument
87
+ * instead of asking the caller to name a vendor it can see for itself.
88
+ */
89
+ matches?: (client: unknown) => boolean;
90
+ };
91
+ /** The metric names a recipe reports, for naming the union without repeating it. */
92
+ export type MetricsOf<R> = R extends ClientRecipe<infer M> ? M : never;
93
+ /**
94
+ * What a mapper reads for that recipe: its metric names, each possibly absent.
95
+ *
96
+ * Partial because a counter the response did not carry is missing rather than zero - a vendor
97
+ * reports cache reads only when there were some. Each client binding exports this ready-made for
98
+ * its own vendor, so a hand-written mapper names the shape instead of building it.
99
+ */
100
+ export type MetricsFor<R> = Partial<Record<MetricsOf<R>, number>>;
101
+ /** The set of recipes a client can be matched against. */
102
+ export interface ClientRegistry {
103
+ register(recipe: ClientRecipe): void;
104
+ find(client: unknown): ClientRecipe | undefined;
105
+ vendors(): string[];
106
+ }
107
+ /**
108
+ * What one call is called, for the event an async ingest reports.
109
+ *
110
+ * The vendor's own words for its own call - `anthropic.messages.create` - because the name is not
111
+ * an internal label: a Stigg raw-events meter filters on it, so it has to be something whoever
112
+ * configures that meter would recognise and could have guessed. Derived from what the recipe
113
+ * already declares, so a new binding names its events by existing.
114
+ */
115
+ export declare const eventNameFor: (recipe: {
116
+ vendor: string;
117
+ }, method: MethodRecipe) => string;
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.eventNameFor = void 0;
4
+ /**
5
+ * What one call is called, for the event an async ingest reports.
6
+ *
7
+ * The vendor's own words for its own call - `anthropic.messages.create` - because the name is not
8
+ * an internal label: a Stigg raw-events meter filters on it, so it has to be something whoever
9
+ * configures that meter would recognise and could have guessed. Derived from what the recipe
10
+ * already declares, so a new binding names its events by existing.
11
+ */
12
+ const eventNameFor = (recipe, method) => method.event ?? `${recipe.vendor}.${method.path.join('.')}`;
13
+ exports.eventNameFor = eventNameFor;
@@ -0,0 +1,6 @@
1
+ import { type ResolvedContext } from '../../../core/context';
2
+ import type { GovernanceSession } from '../../../core/session';
3
+ import type { ClientRecipe } from './types';
4
+ export declare function wrapClient<T extends object>(client: T, recipe: ClientRecipe, session: GovernanceSession, context: ResolvedContext): T;
5
+ /** Whether this client is one `wrap(...)` handed back. */
6
+ export declare function isGoverned(client: object): boolean;
@@ -0,0 +1,258 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.wrapClient = wrapClient;
4
+ exports.isGoverned = isGoverned;
5
+ /**
6
+ * The client binding: a drop-in wrapper that gates before the call and meters after it.
7
+ *
8
+ * Two properties matter and pull in opposite directions. Metering must never break the caller's
9
+ * call, so ingest failures are swallowed into `onError`. Enforcement must never be swallowed, so
10
+ * a denial is raised. They are deliberately separate code paths here: a failed check becomes a
11
+ * policy decision, never an accidental "allowed".
12
+ *
13
+ * Methods are patched onto a copy rather than proxied or subclassed: a Proxy needs a nested proxy
14
+ * per namespace and has to keep `this` intact for helpers that re-enter the client, and
15
+ * subclassing breaks on the next SDK release. The copy is what leaves `wrap()`; the object the
16
+ * caller built is never mutated, so it keeps working ungoverned.
17
+ *
18
+ * ## The per-call context, and why it is read the way it is
19
+ *
20
+ * A gated call sits directly in front of a network request the application pays for, so the SDK's
21
+ * own work has to disappear next to it. The hot path below does exactly this much when a call
22
+ * carries no `stiggContext`: one property read and one truthiness test. Nothing is destructured,
23
+ * merged, validated or allocated - the context `wrap` resolved is used as it stands.
24
+ */
25
+ const errors_1 = require("../../../errors");
26
+ const context_1 = require("../../../core/context");
27
+ const types_1 = require("../../../types");
28
+ const types_2 = require("./types");
29
+ const WRAPPED = Symbol.for('stigg.agent-sdk.wrapped');
30
+ function wrapClient(client, recipe, session, context) {
31
+ const already = client[WRAPPED];
32
+ if (already) {
33
+ throw new Error(`This client is already governed (${already}). Wrap the client you built, not one that ` +
34
+ '`wrap(...)` handed back - the context travels with each call, so one wrapped client ' +
35
+ 'already serves every customer and agent.');
36
+ }
37
+ const copy = Object.create(client);
38
+ let patched = 0;
39
+ for (const method of recipe.methods) {
40
+ if (patch(copy, client, session, context, method, (0, types_2.eventNameFor)(recipe, method)))
41
+ patched += 1;
42
+ }
43
+ // A recipe whose paths resolve to nothing produces a client that is stamped, looks governed,
44
+ // and gates nothing - the same outcome `wrap` already refuses when no recipe matches at all,
45
+ // reached by a different road. Partial is fine and expected: `openAIRecipe` declares both
46
+ // surfaces because one client can speak either, and a client built for one has no other.
47
+ if (!patched) {
48
+ throw new Error(`The ${recipe.vendor} recipe gates ${recipe.methods.map((m) => m.path.join('.')).join(', ')}, and this ` +
49
+ 'client has none of them. Nothing would be checked or metered, so this is refused rather ' +
50
+ 'than handed back looking governed. Check the recipe matches the client you passed.');
51
+ }
52
+ Object.defineProperty(copy, WRAPPED, { value: recipe.vendor, enumerable: false });
53
+ return copy;
54
+ }
55
+ /** Whether this client is one `wrap(...)` handed back. */
56
+ function isGoverned(client) {
57
+ return client[WRAPPED] !== undefined;
58
+ }
59
+ /**
60
+ * Gives the copy its own objects along one gated path.
61
+ *
62
+ * `Object.create(client)` alone is not enough: the copy would *inherit* `messages`, so patching
63
+ * `copy.messages.create` would write straight into the namespace the original shares. Only the
64
+ * objects on a gated path are copied; everything else is inherited, so a namespace the vendor
65
+ * builds lazily still resolves.
66
+ */
67
+ function ownPath(copy, source, path) {
68
+ let owner = copy;
69
+ let realOwner = source;
70
+ for (const key of path) {
71
+ const next = realOwner?.[key];
72
+ if (next === null || typeof next !== 'object')
73
+ return undefined;
74
+ if (!Object.prototype.hasOwnProperty.call(owner, key)) {
75
+ Object.defineProperty(owner, key, {
76
+ value: Object.create(next),
77
+ enumerable: true,
78
+ configurable: true,
79
+ writable: true,
80
+ });
81
+ }
82
+ owner = owner[key];
83
+ realOwner = next;
84
+ }
85
+ return { owner, realOwner };
86
+ }
87
+ function patch(copy, source, session, base, method, eventName) {
88
+ const resolved = ownPath(copy, source, method.path.slice(0, -1));
89
+ if (!resolved)
90
+ return false;
91
+ const { owner, realOwner } = resolved;
92
+ const key = method.path[method.path.length - 1];
93
+ const original = realOwner?.[key];
94
+ if (typeof original !== 'function')
95
+ return false;
96
+ // How much to ask permission for before the call - Stigg's `requestedUsage`. The recipe owns
97
+ // it: only the adapter can read a cost off that vendor's own parameters. Defaults to 1,
98
+ // "is there any budget left at all?" - the ingest afterwards is what makes the balance right.
99
+ const estimate = method.estimate ?? (() => 1);
100
+ // Folded in once per method rather than per call. The event name is the only part of a context
101
+ // that depends on which method was gated - it is what a Stigg meter filters on, and one client
102
+ // can reach two APIs a meter should be able to tell apart - so resolving it here is what lets
103
+ // the common call read a context with nothing left to decide.
104
+ const defaults = base.eventName ? base : { ...base, eventName: { name: eventName } };
105
+ owner[key] = function gated(...args) {
106
+ // Whatever the vendor's own signature takes. Naming the first two and forwarding only those
107
+ // would quietly drop the rest of a call the recipe never claimed to reshape.
108
+ let [params] = args;
109
+ let context = defaults;
110
+ // The context rides inside the vendor's own parameter object, so it has to come back out
111
+ // before anything - the recipe's own hooks included - ever sees those parameters. Both the
112
+ // copy and the merge happen only for a call that actually carried one.
113
+ if (params?.stiggContext !== undefined) {
114
+ const { stiggContext, ...rest } = params;
115
+ params = rest;
116
+ args = [rest, ...args.slice(1)];
117
+ context = (0, context_1.overrideContext)(defaults, stiggContext);
118
+ }
119
+ const callOptions = args[1];
120
+ // The vendor's own object is the receiver, never the copy: an SDK method reading a private
121
+ // field off `this` would throw on an object that only delegates to the real one.
122
+ const target = realOwner;
123
+ if (method.passthrough?.(params, callOptions)) {
124
+ return original.apply(target, args);
125
+ }
126
+ const settled = (async () => {
127
+ // The one field a call cannot proceed without, refused here rather than three frames down.
128
+ // `wrap` deliberately allows a client with no customer - its calls each name their own, and
129
+ // the types say which still owe one - but a type is not a guarantee at runtime, and a
130
+ // framework that owns the call site cannot satisfy one. Left to reach the engine, the empty
131
+ // id comes back as a verdict about a customer that is not there, worded as confidently as
132
+ // any other and attributed to nobody.
133
+ //
134
+ // Inside the async body on purpose: this method stands in for the vendor's, which returns a
135
+ // promise, so a caller's `.catch()` has to see this the way it sees a denial.
136
+ if (!context.customerId)
137
+ throw (0, context_1.noCustomer)();
138
+ // The context owns the check policy; this only decides how much to ask for. A context that
139
+ // names no entitlement has nothing to ask against, so the gate is skipped outright rather
140
+ // than asked and ignored: `estimate` is the recipe reading the caller's own parameters, and
141
+ // paying for one per call to throw the answer away is not what metering-only promises - nor
142
+ // would a client whose every call checked still report the `checks: 0` an operator reads.
143
+ if ((0, context_1.isEnforced)(context)) {
144
+ const decision = await session.check(context, requestedUnits(estimate, params));
145
+ // No return path here for a structured refusal - the vendor's signature owns the return
146
+ // type - so this is the one place the SDK raises instead of returning a decision.
147
+ if (!decision.hasAccess)
148
+ throw new errors_1.AccessDeniedError(decision);
149
+ }
150
+ const result = await collect(original.apply(target, args), params);
151
+ // Whatever the recipe reports - tokens for most vendors, real spend plus the model that
152
+ // served it for one that knows better. The wrapper stays out of that judgement.
153
+ const ingest = (measured) => {
154
+ const measurement = (0, types_1.toMeasurement)(measured);
155
+ void session.ingestMeasurement(context, measurement);
156
+ // Reported what it could, and said what it could not. A measurement that does not cover
157
+ // everything the vendor charged for is a metering failure even though the ingest itself
158
+ // succeeded - so it goes out *and* is raised, rather than one or the other.
159
+ if (measurement.incomplete)
160
+ session.failedToMeasure(context, new Error(measurement.incomplete));
161
+ };
162
+ try {
163
+ const measurement = method.extractUsage?.(result.data, result.response);
164
+ if (measurement)
165
+ ingest(measurement);
166
+ else
167
+ method.attachStream?.(result.data, ingest, result.response);
168
+ }
169
+ catch (err) {
170
+ // The vendor already answered. A recipe that cannot read its response is a metering
171
+ // failure, not the caller's - turning it into a rejection would fail a call that worked.
172
+ session.failedToMeasure(context, err);
173
+ }
174
+ return result;
175
+ })();
176
+ // Marks the inner promise handled; the stand-in below still surfaces the rejection to callers.
177
+ settled.catch(() => undefined);
178
+ return gatedPromise(settled);
179
+ };
180
+ return true;
181
+ }
182
+ /**
183
+ * How much to ask permission for, with the one answer a gate must never send.
184
+ *
185
+ * `estimate` is the application's own callback - a gateway recipe takes it as an option - and a
186
+ * negative one is worse than a wrong one: `remaining >= requestedUsage` is true at any balance,
187
+ * so an unaffordable call is granted by a budget that had nothing left. `NaN` and `Infinity` fail
188
+ * the same comparison in the other direction. Zero and fractions are left alone: a credit
189
+ * currency is spent in fractions, and asking for nothing is a legitimate "may I at all".
190
+ */
191
+ function requestedUnits(estimate, params) {
192
+ const units = estimate(params);
193
+ return Number.isFinite(units) && units >= 0 ? units : 1;
194
+ }
195
+ /**
196
+ * Awaits the vendor's own promise, keeping the caller's response readable where it can.
197
+ *
198
+ * A body can be read once, and the recipes need it parsed to find usage in - so a gated call used
199
+ * to hand `asResponse()` a response nobody could read afterwards. `asResponse()` gives the
200
+ * response over before anything has touched it, which is early enough to take a copy: the SDK
201
+ * parses the original exactly as it always did, and the untouched clone is what the caller gets.
202
+ *
203
+ * Not for a stream, whose body *is* the stream - copying it would tee an open connection into
204
+ * memory. Nothing is lost there, because a stream is metered off its own events rather than off
205
+ * the response.
206
+ */
207
+ async function collect(pending, params) {
208
+ if (typeof pending?.withResponse !== 'function')
209
+ return { data: await pending };
210
+ // Only a promise that hands over the unparsed response can be copied, and only a body that is
211
+ // not itself a stream is worth copying.
212
+ const spare = params?.stream ? undefined : await spareResponse(pending);
213
+ // `withResponse()` after the copy, so the vendor still supplies its own request id alongside
214
+ // the parse rather than this having to guess which header it came from.
215
+ return { ...(await pending.withResponse()), spare };
216
+ }
217
+ async function spareResponse(pending) {
218
+ if (typeof pending.asResponse !== 'function')
219
+ return undefined;
220
+ const response = await pending.asResponse();
221
+ // The request parameter says what was asked for; the response says what was sent, and only the
222
+ // second is worth trusting with an open connection. A stream is an event stream, and teeing one
223
+ // would buffer a live body to serve a copy nobody asked for.
224
+ if (String(response?.headers?.get?.('content-type') ?? '').includes('event-stream'))
225
+ return undefined;
226
+ return typeof response?.clone === 'function' && response.bodyUsed === false ? response.clone() : undefined;
227
+ }
228
+ /**
229
+ * A real promise, because the gate has to return one before the vendor's exists.
230
+ *
231
+ * The check is asynchronous and the vendor is not called until it passes, so at the moment the
232
+ * caller receives a value there is nothing yet to hand over. What stands in is a `Promise`
233
+ * subclass carrying the helpers the vendor's own promise declares - a plain object with a `then`
234
+ * is not a promise: `instanceof Promise` is false on it, and everything past `then`/`catch`
235
+ * silently does not exist despite the type saying otherwise.
236
+ */
237
+ class GatedPromise extends Promise {
238
+ // `then` must hand back a plain promise. The default builds another of these through the
239
+ // constructor, which has no settled result to hang the helpers off.
240
+ static get [Symbol.species]() {
241
+ return Promise;
242
+ }
243
+ }
244
+ function gatedPromise(settled) {
245
+ const promise = new GatedPromise((resolve, reject) => {
246
+ settled.then((result) => resolve(result.data), reject);
247
+ });
248
+ // A caller that only wants the response asks `withResponse()` and never touches this one -
249
+ // Anthropic's stream helper does exactly that. Left alone, a refusal would reject a promise
250
+ // nobody is holding, which Node reports as unhandled and, by default, exits on. Marking it
251
+ // handled costs nothing: the rejection is still delivered to whoever does await it.
252
+ promise.catch(() => undefined);
253
+ // The copy is ours; the caller is handed it in place of the response the parse consumed.
254
+ promise.asResponse = () => settled.then((result) => result.spare ?? result.response);
255
+ promise.withResponse = () => settled.then(({ spare: _spare, ...rest }) => rest);
256
+ promise._thenUnwrap = (transform) => gatedPromise(settled.then((result) => ({ ...result, data: transform(result.data, result) })));
257
+ return promise;
258
+ }
@@ -0,0 +1,17 @@
1
+ import { GATEWAY_METRICS } from '../../../shared/token-usage';
2
+ import type { LiteLLMRecipeOptions } 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. LiteLLM normalizes Anthropic, OpenAI, Bedrock and the rest to
8
+ * the same wire shape, so the response never changes form - `model` is just a field saying which
9
+ * deployment served, which is what makes it worth putting on the ingest as a dimension: the client
10
+ * asked for a model group, and the group is free to resolve to something else.
11
+ */
12
+ export declare function litellmRecipe(options?: LiteLLMRecipeOptions): ClientRecipe<GatewayMetric>;
13
+ /** One of the metric names LiteLLM reports. */
14
+ export type LiteLLMMetric = MetricsOf<ReturnType<typeof litellmRecipe>>;
15
+ /** What a mapper reads off a LiteLLM call: those names, each possibly absent. */
16
+ export type LiteLLMMetrics = MetricsFor<ReturnType<typeof litellmRecipe>>;
17
+ export {};
@@ -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 });