@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,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,126 @@
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
+ * Which argument carries `stiggContext`, when it is not the first.
37
+ *
38
+ * Most vendor methods take one options object, so the context rides in argument 0 and this is
39
+ * left alone. A method whose first argument is not that object needs to say so - Cloudflare's
40
+ * `ai.run(model, inputs)` names the model first - or a caller's per-call context is forwarded
41
+ * to the vendor as an unknown field and the gate silently uses the wrap-time one instead.
42
+ */
43
+ contextArg?: number;
44
+ /**
45
+ * True when this call is an internal leg of another wrapped method and must not be gated
46
+ * twice. Vendors mark these differently, so only the recipe can tell.
47
+ */
48
+ passthrough?: (params: any, options: any) => boolean;
49
+ /** Units to ask for before the call. Defaults to 1 - "is there any budget left at all?". */
50
+ estimate?: (params: any) => number;
51
+ /**
52
+ * Read straight off a completed response. Undefined when the result is a stream.
53
+ *
54
+ * `response` is the transport response the body came in on, for the vendors that report spend
55
+ * in a header rather than in the body. It is absent when the client's promise exposes no
56
+ * `withResponse()`, so a recipe that needs it must say what it does without it.
57
+ */
58
+ extractUsage?: (result: any, response?: MeteredResponse) => TokenUsage | Measurement | undefined;
59
+ /**
60
+ * Hooks a result that reports usage later. Wrap the iterator in place and call `ingest` once
61
+ * the stream completes, so one completed response emits exactly one measurement.
62
+ */
63
+ attachStream?: (result: any, ingest: (measurement: TokenUsage | Measurement) => void, response?: MeteredResponse) => void;
64
+ /**
65
+ * The event name for this call, when async ingest should report it as something other than the
66
+ * derived `vendor.path` - a proxy standing in for another vendor's API, say.
67
+ */
68
+ event?: string;
69
+ };
70
+ export type ClientRecipe<M extends string = string> = {
71
+ vendor: string;
72
+ /**
73
+ * The *name* of whichever metric is this vendor's total - not the number, which rides the
74
+ * measurement as `TokenUsage.total`.
75
+ *
76
+ * Declared rather than inferred, because the name differs per vendor - `totalTokenCount` for
77
+ * Gemini, `total_tokens` for everyone else - and a caller pricing metrics otherwise has no way
78
+ * to tell the total apart from the counters underneath it except by knowing each vendor by
79
+ * heart. Which matters: billing this *and* a component double-charges those tokens.
80
+ *
81
+ * Absent for a vendor that publishes no total and whose counters overlap, where there is
82
+ * nothing honest to put here.
83
+ */
84
+ totalTokensMetric?: M;
85
+ /**
86
+ * Every metric name this recipe can report, across all of its surfaces.
87
+ *
88
+ * What a mapper of your own can price, and what `weightedMetrics` types its table against. The
89
+ * union rather than one list per surface: a client speaks whichever API it likes, and a table
90
+ * covering both prices either - a name the call did not report is simply absent.
91
+ */
92
+ metrics: readonly M[];
93
+ methods: MethodRecipe[];
94
+ /**
95
+ * Duck-typed guard: does this recipe fit that client? Lets `wrap(client)` take one argument
96
+ * instead of asking the caller to name a vendor it can see for itself.
97
+ */
98
+ matches?: (client: unknown) => boolean;
99
+ };
100
+ /** The metric names a recipe reports, for naming the union without repeating it. */
101
+ export type MetricsOf<R> = R extends ClientRecipe<infer M> ? M : never;
102
+ /**
103
+ * What a mapper reads for that recipe: its metric names, each possibly absent.
104
+ *
105
+ * Partial because a counter the response did not carry is missing rather than zero - a vendor
106
+ * reports cache reads only when there were some. Each client binding exports this ready-made for
107
+ * its own vendor, so a hand-written mapper names the shape instead of building it.
108
+ */
109
+ export type MetricsFor<R> = Partial<Record<MetricsOf<R>, number>>;
110
+ /** The set of recipes a client can be matched against. */
111
+ export interface ClientRegistry {
112
+ register(recipe: ClientRecipe): void;
113
+ find(client: unknown): ClientRecipe | undefined;
114
+ vendors(): string[];
115
+ }
116
+ /**
117
+ * What one call is called, for the event an async ingest reports.
118
+ *
119
+ * The vendor's own words for its own call - `anthropic.messages.create` - because the name is not
120
+ * an internal label: a Stigg raw-events meter filters on it, so it has to be something whoever
121
+ * configures that meter would recognise and could have guessed. Derived from what the recipe
122
+ * already declares, so a new binding names its events by existing.
123
+ */
124
+ export declare const eventNameFor: (recipe: {
125
+ vendor: string;
126
+ }, 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,261 @@
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
+ // Argument 0 unless the method says otherwise: a vendor whose first argument is not its
109
+ // options object would otherwise have a caller's context forwarded to it verbatim.
110
+ const contextArg = method.contextArg ?? 0;
111
+ let params = args[contextArg];
112
+ let context = defaults;
113
+ // The context rides inside the vendor's own parameter object, so it has to come back out
114
+ // before anything - the recipe's own hooks included - ever sees those parameters. Both the
115
+ // copy and the merge happen only for a call that actually carried one.
116
+ if (params?.stiggContext !== undefined) {
117
+ const { stiggContext, ...rest } = params;
118
+ params = rest;
119
+ args = args.map((arg, i) => (i === contextArg ? rest : arg));
120
+ context = (0, context_1.overrideContext)(defaults, stiggContext);
121
+ }
122
+ const callOptions = args[1];
123
+ // The vendor's own object is the receiver, never the copy: an SDK method reading a private
124
+ // field off `this` would throw on an object that only delegates to the real one.
125
+ const target = realOwner;
126
+ if (method.passthrough?.(params, callOptions)) {
127
+ return original.apply(target, args);
128
+ }
129
+ const settled = (async () => {
130
+ // The one field a call cannot proceed without, refused here rather than three frames down.
131
+ // `wrap` deliberately allows a client with no customer - its calls each name their own, and
132
+ // the types say which still owe one - but a type is not a guarantee at runtime, and a
133
+ // framework that owns the call site cannot satisfy one. Left to reach the engine, the empty
134
+ // id comes back as a verdict about a customer that is not there, worded as confidently as
135
+ // any other and attributed to nobody.
136
+ //
137
+ // Inside the async body on purpose: this method stands in for the vendor's, which returns a
138
+ // promise, so a caller's `.catch()` has to see this the way it sees a denial.
139
+ if (!context.customerId)
140
+ throw (0, context_1.noCustomer)();
141
+ // The context owns the check policy; this only decides how much to ask for. A context that
142
+ // names no entitlement has nothing to ask against, so the gate is skipped outright rather
143
+ // than asked and ignored: `estimate` is the recipe reading the caller's own parameters, and
144
+ // paying for one per call to throw the answer away is not what metering-only promises - nor
145
+ // would a client whose every call checked still report the `checks: 0` an operator reads.
146
+ if ((0, context_1.isEnforced)(context)) {
147
+ const decision = await session.check(context, requestedUnits(estimate, params));
148
+ // No return path here for a structured refusal - the vendor's signature owns the return
149
+ // type - so this is the one place the SDK raises instead of returning a decision.
150
+ if (!decision.hasAccess)
151
+ throw new errors_1.AccessDeniedError(decision);
152
+ }
153
+ const result = await collect(original.apply(target, args), params);
154
+ // Whatever the recipe reports - tokens for most vendors, real spend plus the model that
155
+ // served it for one that knows better. The wrapper stays out of that judgement.
156
+ const ingest = (measured) => {
157
+ const measurement = (0, types_1.toMeasurement)(measured);
158
+ void session.ingestMeasurement(context, measurement);
159
+ // Reported what it could, and said what it could not. A measurement that does not cover
160
+ // everything the vendor charged for is a metering failure even though the ingest itself
161
+ // succeeded - so it goes out *and* is raised, rather than one or the other.
162
+ if (measurement.incomplete)
163
+ session.failedToMeasure(context, new Error(measurement.incomplete));
164
+ };
165
+ try {
166
+ const measurement = method.extractUsage?.(result.data, result.response);
167
+ if (measurement)
168
+ ingest(measurement);
169
+ else
170
+ method.attachStream?.(result.data, ingest, result.response);
171
+ }
172
+ catch (err) {
173
+ // The vendor already answered. A recipe that cannot read its response is a metering
174
+ // failure, not the caller's - turning it into a rejection would fail a call that worked.
175
+ session.failedToMeasure(context, err);
176
+ }
177
+ return result;
178
+ })();
179
+ // Marks the inner promise handled; the stand-in below still surfaces the rejection to callers.
180
+ settled.catch(() => undefined);
181
+ return gatedPromise(settled);
182
+ };
183
+ return true;
184
+ }
185
+ /**
186
+ * How much to ask permission for, with the one answer a gate must never send.
187
+ *
188
+ * `estimate` is the application's own callback - a gateway recipe takes it as an option - and a
189
+ * negative one is worse than a wrong one: `remaining >= requestedUsage` is true at any balance,
190
+ * so an unaffordable call is granted by a budget that had nothing left. `NaN` and `Infinity` fail
191
+ * the same comparison in the other direction. Zero and fractions are left alone: a credit
192
+ * currency is spent in fractions, and asking for nothing is a legitimate "may I at all".
193
+ */
194
+ function requestedUnits(estimate, params) {
195
+ const units = estimate(params);
196
+ return Number.isFinite(units) && units >= 0 ? units : 1;
197
+ }
198
+ /**
199
+ * Awaits the vendor's own promise, keeping the caller's response readable where it can.
200
+ *
201
+ * A body can be read once, and the recipes need it parsed to find usage in - so a gated call used
202
+ * to hand `asResponse()` a response nobody could read afterwards. `asResponse()` gives the
203
+ * response over before anything has touched it, which is early enough to take a copy: the SDK
204
+ * parses the original exactly as it always did, and the untouched clone is what the caller gets.
205
+ *
206
+ * Not for a stream, whose body *is* the stream - copying it would tee an open connection into
207
+ * memory. Nothing is lost there, because a stream is metered off its own events rather than off
208
+ * the response.
209
+ */
210
+ async function collect(pending, params) {
211
+ if (typeof pending?.withResponse !== 'function')
212
+ return { data: await pending };
213
+ // Only a promise that hands over the unparsed response can be copied, and only a body that is
214
+ // not itself a stream is worth copying.
215
+ const spare = params?.stream ? undefined : await spareResponse(pending);
216
+ // `withResponse()` after the copy, so the vendor still supplies its own request id alongside
217
+ // the parse rather than this having to guess which header it came from.
218
+ return { ...(await pending.withResponse()), spare };
219
+ }
220
+ async function spareResponse(pending) {
221
+ if (typeof pending.asResponse !== 'function')
222
+ return undefined;
223
+ const response = await pending.asResponse();
224
+ // The request parameter says what was asked for; the response says what was sent, and only the
225
+ // second is worth trusting with an open connection. A stream is an event stream, and teeing one
226
+ // would buffer a live body to serve a copy nobody asked for.
227
+ if (String(response?.headers?.get?.('content-type') ?? '').includes('event-stream'))
228
+ return undefined;
229
+ return typeof response?.clone === 'function' && response.bodyUsed === false ? response.clone() : undefined;
230
+ }
231
+ /**
232
+ * A real promise, because the gate has to return one before the vendor's exists.
233
+ *
234
+ * The check is asynchronous and the vendor is not called until it passes, so at the moment the
235
+ * caller receives a value there is nothing yet to hand over. What stands in is a `Promise`
236
+ * subclass carrying the helpers the vendor's own promise declares - a plain object with a `then`
237
+ * is not a promise: `instanceof Promise` is false on it, and everything past `then`/`catch`
238
+ * silently does not exist despite the type saying otherwise.
239
+ */
240
+ class GatedPromise extends Promise {
241
+ // `then` must hand back a plain promise. The default builds another of these through the
242
+ // constructor, which has no settled result to hang the helpers off.
243
+ static get [Symbol.species]() {
244
+ return Promise;
245
+ }
246
+ }
247
+ function gatedPromise(settled) {
248
+ const promise = new GatedPromise((resolve, reject) => {
249
+ settled.then((result) => resolve(result.data), reject);
250
+ });
251
+ // A caller that only wants the response asks `withResponse()` and never touches this one -
252
+ // Anthropic's stream helper does exactly that. Left alone, a refusal would reject a promise
253
+ // nobody is holding, which Node reports as unhandled and, by default, exits on. Marking it
254
+ // handled costs nothing: the rejection is still delivered to whoever does await it.
255
+ promise.catch(() => undefined);
256
+ // The copy is ours; the caller is handed it in place of the response the parse consumed.
257
+ promise.asResponse = () => settled.then((result) => result.spare ?? result.response);
258
+ promise.withResponse = () => settled.then(({ spare: _spare, ...rest }) => rest);
259
+ promise._thenUnwrap = (transform) => gatedPromise(settled.then((result) => ({ ...result, data: transform(result.data, result) })));
260
+ return promise;
261
+ }
@@ -0,0 +1,17 @@
1
+ import { BEDROCK_METRICS } from '../../../shared/token-usage';
2
+ import type { BedrockRecipeOptions } from './types';
3
+ import type { ClientRecipe, MetricsFor, MetricsOf } from '../../core/types';
4
+ type BedrockMetric = (typeof BEDROCK_METRICS)[number];
5
+ /**
6
+ * One recipe for every model Bedrock serves.
7
+ *
8
+ * Converse normalizes Anthropic, Nova, Llama and the rest to one request and one response, so the
9
+ * usage block never changes shape - which is the whole reason this can be a single recipe rather
10
+ * than one per upstream vendor.
11
+ */
12
+ export declare function bedrockRecipe(options?: BedrockRecipeOptions): ClientRecipe<BedrockMetric>;
13
+ /** One of the metric names Bedrock reports. */
14
+ export type BedrockMetricName = MetricsOf<ReturnType<typeof bedrockRecipe>>;
15
+ /** What a mapper reads off a Bedrock call: those names, each possibly absent. */
16
+ export type BedrockMetrics = MetricsFor<ReturnType<typeof bedrockRecipe>>;
17
+ export {};
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.bedrockRecipe = bedrockRecipe;
4
+ /**
5
+ * The Bedrock recipe - a gateway whose client is a command bus, and which never states a price.
6
+ *
7
+ * Bedrock is the third shape of "gateway" these bindings cover, and it differs from the other two
8
+ * in both halves:
9
+ *
10
+ * - **The client is not a method tree.** `openai-node` and `@openrouter/sdk` hang a method off a
11
+ * namespace - `chat.completions.create`, `chat.send` - and a recipe names that path. The AWS
12
+ * SDK has exactly one method, `send`, and the operation is the *argument*: `send(new
13
+ * ConverseCommand(...))`. So the gated path is `['send']` and the recipe has to look at what
14
+ * it was handed, because the same method also lists async invocations and fetches guardrails,
15
+ * and gating those would charge a budget for calls that spend nothing.
16
+ *
17
+ * - **There is no cost on the response.** OpenRouter writes the credits it charged into the
18
+ * body; LiteLLM prices every call and puts dollars in a header. Bedrock reports tokens and
19
+ * stops - the price lives in the AWS bill, not in the response - so a run that wants to bill
20
+ * real spend cannot do it here, and one that wants to meter tokens is served exactly as well
21
+ * as anywhere else. `metrics` says so by having no `cost` in it, rather than by reporting a
22
+ * zero that a mapper would happily multiply.
23
+ *
24
+ * There is no served-model dimension either, and that absence is also a fact about Bedrock rather
25
+ * than an omission: it does not route. You name a model id and that model answers, so there is no
26
+ * drift between what was asked for and what served, and nothing for a dimension to tell you.
27
+ */
28
+ const meter_stream_1 = require("../../../shared/meter-stream");
29
+ const token_usage_1 = require("../../../shared/token-usage");
30
+ /** The service a command belongs to, as the AWS SDK's own schema spells it. */
31
+ const BEDROCK_SERVICE = 'com.amazonaws.bedrockruntime';
32
+ /**
33
+ * The two operations that run inference through the Converse API, and so the two worth gating.
34
+ *
35
+ * `InvokeModel` is inference too, and is deliberately not here: its request and response bodies
36
+ * are the *upstream vendor's* - Anthropic's shape for a Claude model, Amazon's for a Nova one - so
37
+ * one reader cannot meter them, and a recipe that gated the call while measuring nothing would
38
+ * report every one of them as a metering failure. An application on `InvokeModel` wants a recipe
39
+ * per upstream; an application on Converse gets one that works for every model Bedrock serves.
40
+ */
41
+ const METERED_OPERATIONS = new Set(['Converse', 'ConverseStream']);
42
+ /**
43
+ * Which AWS operation a command object is for.
44
+ *
45
+ * Read off the SDK's own serialization schema - `[version, service, operation, http]` - rather
46
+ * than from the class name, because a class name is the first thing a bundler rewrites and this
47
+ * decides whether a call is gated at all. The constructor name is the fallback for an SDK version
48
+ * that shapes the schema differently, where being approximately right beats refusing to gate.
49
+ */
50
+ function operationOf(command) {
51
+ const schema = command?.schema;
52
+ if (Array.isArray(schema) && typeof schema[1] === 'string' && typeof schema[2] === 'string') {
53
+ return { service: schema[1], operation: schema[2] };
54
+ }
55
+ const name = command?.constructor?.name;
56
+ return typeof name === 'string' && name.endsWith('Command') ? { operation: name.slice(0, -'Command'.length) } : {};
57
+ }
58
+ /** True for a call this recipe must leave alone - anything that is not Converse inference. */
59
+ function isNotMetered(command) {
60
+ const { service, operation } = operationOf(command);
61
+ if (service !== undefined && service !== BEDROCK_SERVICE)
62
+ return true;
63
+ return !operation || !METERED_OPERATIONS.has(operation);
64
+ }
65
+ /** Tokens from one Converse response, under Bedrock's own names. */
66
+ function measure(usage, requestId) {
67
+ if (!usage)
68
+ return undefined;
69
+ return {
70
+ metrics: (0, token_usage_1.pickMetrics)(usage, token_usage_1.BEDROCK_PATHS),
71
+ usage: (0, token_usage_1.fromBedrockUsage)(usage),
72
+ raw: usage,
73
+ // AWS's own request id, which is stable across the SDK's retries - and the SDK retries by
74
+ // default, so a local counter would bill the same answer more than once.
75
+ idempotencyKey: requestId,
76
+ };
77
+ }
78
+ /**
79
+ * One recipe for every model Bedrock serves.
80
+ *
81
+ * Converse normalizes Anthropic, Nova, Llama and the rest to one request and one response, so the
82
+ * usage block never changes shape - which is the whole reason this can be a single recipe rather
83
+ * than one per upstream vendor.
84
+ */
85
+ function bedrockRecipe(options = {}) {
86
+ const estimate = options.estimate ?? (() => 1);
87
+ return {
88
+ vendor: 'bedrock',
89
+ metrics: token_usage_1.BEDROCK_METRICS,
90
+ totalTokensMetric: 'totalTokens',
91
+ // The client's own configuration names the service, which is what separates this from every
92
+ // other AWS v3 client - they all have `send`, and none of the rest are inference. Data rather
93
+ // than a class name, for the same reason `operationOf` prefers the schema.
94
+ matches: (client) => {
95
+ const config = client?.config;
96
+ if (!config || typeof client?.send !== 'function')
97
+ return false;
98
+ return config.defaultSigningName === 'bedrock' && typeof config.serviceId === 'string';
99
+ },
100
+ methods: [
101
+ {
102
+ path: ['send'],
103
+ estimate,
104
+ // The operation rides in the argument, so this is where a non-inference call is let
105
+ // through ungated rather than charged for.
106
+ passthrough: (command) => isNotMetered(command),
107
+ // A streamed Converse resolves to `{ stream, $metadata }` - the usage is not on the
108
+ // response at all, so this returns nothing and `attachStream` takes it from there.
109
+ extractUsage: (result) => result?.stream ? undefined : measure(result?.usage, result?.$metadata?.requestId),
110
+ /**
111
+ * ConverseStream puts its usage in a `metadata` event at the end, so the stream has to be
112
+ * run to the end to know what the call cost - the same bargain every streaming binding
113
+ * makes.
114
+ *
115
+ * The iterable is `result.stream`, not `result`: AWS hands back an envelope with the
116
+ * stream hanging off it, where the other SDKs hand back the stream itself. Metering the
117
+ * envelope would patch an object nobody iterates and report nothing, with no error.
118
+ */
119
+ attachStream: (result, ingest) => {
120
+ const requestId = result?.$metadata?.requestId;
121
+ let usage;
122
+ (0, meter_stream_1.meterStream)(result?.stream, (event) => {
123
+ if (event?.metadata?.usage)
124
+ usage = event.metadata.usage;
125
+ }, () => {
126
+ const measurement = measure(usage, requestId);
127
+ if (measurement)
128
+ ingest(measurement);
129
+ });
130
+ },
131
+ },
132
+ ],
133
+ };
134
+ }
@@ -0,0 +1,13 @@
1
+ export type BedrockRecipeOptions = {
2
+ /**
3
+ * How much to ask permission for before the call.
4
+ *
5
+ * The default asks for 1 - "is there any budget left at all?" - and lets the ingest afterwards
6
+ * settle the balance, which leaves a gap of about one call's worth of tokens. A run that wants
7
+ * that gap closed knows something this recipe cannot: `inferenceConfig.maxTokens` is the only
8
+ * ceiling Bedrock is given, and whether it is worth asking for depends on how a budget is
9
+ * denominated. Reading it is one line - `(command) => command?.input?.inferenceConfig?.maxTokens
10
+ * ?? 1` - and it is the caller's line to write.
11
+ */
12
+ estimate?: (command: any) => number;
13
+ };
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,10 @@
1
+ import { CLOUDFLARE_METRICS } from '../../../shared/token-usage';
2
+ import type { CloudflareRecipeOptions } from './types';
3
+ import type { ClientRecipe, MetricsFor, MetricsOf } from '../../core/types';
4
+ type CloudflareMetric = (typeof CLOUDFLARE_METRICS)[number];
5
+ export declare function cloudflareRecipe(options?: CloudflareRecipeOptions): ClientRecipe<CloudflareMetric>;
6
+ /** One of the metric names Cloudflare can report, across every wire it serves. */
7
+ export type CloudflareMetricName = MetricsOf<ReturnType<typeof cloudflareRecipe>>;
8
+ /** What a mapper reads off a Cloudflare call: those names, each possibly absent. */
9
+ export type CloudflareMetrics = MetricsFor<ReturnType<typeof cloudflareRecipe>>;
10
+ export {};