@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.
- package/README.md +1296 -2
- package/dist/bindings/client/core/registry.d.ts +12 -0
- package/dist/bindings/client/core/registry.js +22 -0
- package/dist/bindings/client/core/types.d.ts +117 -0
- package/dist/bindings/client/core/types.js +13 -0
- package/dist/bindings/client/core/wrap-client.d.ts +6 -0
- package/dist/bindings/client/core/wrap-client.js +258 -0
- package/dist/bindings/client/gateways/bedrock/bedrock-recipe.d.ts +17 -0
- package/dist/bindings/client/gateways/bedrock/bedrock-recipe.js +134 -0
- package/dist/bindings/client/gateways/bedrock/types.d.ts +13 -0
- package/dist/bindings/client/gateways/bedrock/types.js +2 -0
- package/dist/bindings/client/gateways/litellm/litellm-recipe.d.ts +17 -0
- package/dist/bindings/client/gateways/litellm/litellm-recipe.js +171 -0
- package/dist/bindings/client/gateways/litellm/types.d.ts +30 -0
- package/dist/bindings/client/gateways/litellm/types.js +2 -0
- package/dist/bindings/client/gateways/openrouter/openrouter-recipe.d.ts +20 -0
- package/dist/bindings/client/gateways/openrouter/openrouter-recipe.js +244 -0
- package/dist/bindings/client/gateways/openrouter/sdk-usage.d.ts +37 -0
- package/dist/bindings/client/gateways/openrouter/sdk-usage.js +63 -0
- package/dist/bindings/client/gateways/openrouter/types.d.ts +78 -0
- package/dist/bindings/client/gateways/openrouter/types.js +2 -0
- package/dist/bindings/client/providers/anthropic/anthropic-recipe.d.ts +34 -0
- package/dist/bindings/client/providers/anthropic/anthropic-recipe.js +169 -0
- package/dist/bindings/client/providers/gemini/gemini-recipe.d.ts +54 -0
- package/dist/bindings/client/providers/gemini/gemini-recipe.js +243 -0
- package/dist/bindings/client/providers/groq/groq-recipe.d.ts +20 -0
- package/dist/bindings/client/providers/groq/groq-recipe.js +101 -0
- package/dist/bindings/client/providers/mistral/mistral-recipe.d.ts +22 -0
- package/dist/bindings/client/providers/mistral/mistral-recipe.js +114 -0
- package/dist/bindings/client/providers/mistral/types.d.ts +38 -0
- package/dist/bindings/client/providers/mistral/types.js +2 -0
- package/dist/bindings/client/providers/ollama/ollama-recipe.d.ts +31 -0
- package/dist/bindings/client/providers/ollama/ollama-recipe.js +107 -0
- package/dist/bindings/client/providers/ollama/types.d.ts +24 -0
- package/dist/bindings/client/providers/ollama/types.js +2 -0
- package/dist/bindings/client/providers/openai/openai-recipe.d.ts +71 -0
- package/dist/bindings/client/providers/openai/openai-recipe.js +122 -0
- package/dist/bindings/client/providers/typesafe/typesafe-recipe.d.ts +44 -0
- package/dist/bindings/client/providers/typesafe/typesafe-recipe.js +83 -0
- package/dist/bindings/proxy/headers.d.ts +6 -0
- package/dist/bindings/proxy/headers.js +12 -0
- package/dist/bindings/proxy/index.d.ts +21 -0
- package/dist/bindings/proxy/index.js +18 -0
- package/dist/bindings/proxy/types.d.ts +8 -0
- package/dist/bindings/proxy/types.js +2 -0
- package/dist/bindings/shared/estimate.d.ts +27 -0
- package/dist/bindings/shared/estimate.js +38 -0
- package/dist/bindings/shared/meter-stream.d.ts +18 -0
- package/dist/bindings/shared/meter-stream.js +151 -0
- package/dist/bindings/shared/token-usage.d.ts +240 -0
- package/dist/bindings/shared/token-usage.js +574 -0
- package/dist/bindings/shared/types.d.ts +129 -0
- package/dist/bindings/shared/types.js +2 -0
- package/dist/bindings/shared/weighted-metrics.d.ts +37 -0
- package/dist/bindings/shared/weighted-metrics.js +47 -0
- package/dist/config.d.ts +145 -0
- package/dist/config.js +99 -0
- package/dist/core/context.d.ts +160 -0
- package/dist/core/context.js +221 -0
- package/dist/core/dimensions.d.ts +22 -0
- package/dist/core/dimensions.js +47 -0
- package/dist/core/hooks.d.ts +21 -0
- package/dist/core/hooks.js +47 -0
- package/dist/core/session.d.ts +190 -0
- package/dist/core/session.js +448 -0
- package/dist/decision-text.d.ts +15 -0
- package/dist/decision-text.js +54 -0
- package/dist/engine/composite-engine.d.ts +72 -0
- package/dist/engine/composite-engine.js +135 -0
- package/dist/engine/create-engine.d.ts +16 -0
- package/dist/engine/create-engine.js +93 -0
- package/dist/engine/decision.d.ts +42 -0
- package/dist/engine/decision.js +135 -0
- package/dist/engine/engine.d.ts +166 -0
- package/dist/engine/engine.js +11 -0
- package/dist/engine/node-sdk-engine.d.ts +125 -0
- package/dist/engine/node-sdk-engine.js +262 -0
- package/dist/engine/report.d.ts +106 -0
- package/dist/engine/report.js +188 -0
- package/dist/engine/rest-governance-engine.d.ts +47 -0
- package/dist/engine/rest-governance-engine.js +220 -0
- package/dist/errors.d.ts +86 -0
- package/dist/errors.js +161 -0
- package/dist/headers.d.ts +40 -0
- package/dist/headers.js +82 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.js +106 -0
- package/dist/internal.d.ts +33 -0
- package/dist/internal.js +87 -0
- package/dist/logger.d.ts +15 -0
- package/dist/logger.js +20 -0
- package/dist/sdk-identity.d.ts +36 -0
- package/dist/sdk-identity.js +43 -0
- package/dist/stigg-agent-sdk.d.ts +300 -0
- package/dist/stigg-agent-sdk.js +656 -0
- package/dist/types.d.ts +642 -0
- package/dist/types.js +147 -0
- package/package.json +89 -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 { 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,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 {};
|