@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,221 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.addMetrics = exports.isEnforced = exports.noCustomer = exports.CONTEXT_DEFAULTS = void 0;
|
|
4
|
+
exports.resolveContext = resolveContext;
|
|
5
|
+
exports.overrideContext = overrideContext;
|
|
6
|
+
/**
|
|
7
|
+
* The governance context: who a call is for, what gates it, and what it reports.
|
|
8
|
+
*
|
|
9
|
+
* One shape, given in two places. `wrap(client, { stiggContext })` sets the defaults for every
|
|
10
|
+
* call through that client; `client.messages.create({ ..., stiggContext })` overrides them for one
|
|
11
|
+
* call. That is the whole of the configuration surface - there is no run to open and nothing to
|
|
12
|
+
* keep in sync between a setup step and a call site.
|
|
13
|
+
*
|
|
14
|
+
* ## Why resolution is split in two
|
|
15
|
+
*
|
|
16
|
+
* A gated call sits directly in front of a network request the application is paying for, so the
|
|
17
|
+
* SDK's own work has to disappear next to it. The split below is what makes that true:
|
|
18
|
+
*
|
|
19
|
+
* `resolveContext` - runs once, at `wrap`. Validates, folds the agent into the dimensions,
|
|
20
|
+
* and precomputes everything a call would otherwise derive: the cache key,
|
|
21
|
+
* the entitlement id, the mapper.
|
|
22
|
+
* `overrideContext` - runs only when a call actually carries a `stiggContext`, and only touches
|
|
23
|
+
* the keys that call named.
|
|
24
|
+
*
|
|
25
|
+
* So a call that overrides nothing does no merging, no validation and no allocation: it reads the
|
|
26
|
+
* object `wrap` already built. A client wrapped without a customer is the exception - its calls
|
|
27
|
+
* each name their own, so each one carries an override and pays for the merge.
|
|
28
|
+
*/
|
|
29
|
+
const types_1 = require("../types");
|
|
30
|
+
/**
|
|
31
|
+
* What nobody told the SDK, and it will not guess.
|
|
32
|
+
*
|
|
33
|
+
* Exported because the constructor's own schema defaults to these: two copies of "what happens
|
|
34
|
+
* when nothing says" is how a configured `deny` came to be parsed, stored and then ignored in
|
|
35
|
+
* favour of an `allow` written somewhere else.
|
|
36
|
+
*
|
|
37
|
+
* `allow` is the soft limit - an unreachable Stigg does not take the application down with it.
|
|
38
|
+
*/
|
|
39
|
+
exports.CONTEXT_DEFAULTS = { checkErrorAction: 'allow', ingestMode: 'async' };
|
|
40
|
+
/**
|
|
41
|
+
* The agent, as the dimension it travels as.
|
|
42
|
+
*
|
|
43
|
+
* Refused rather than defaulted when the type is missing: a guessed attribution key resolves no
|
|
44
|
+
* entity, so the spend lands on the customer and the output is indistinguishable from a call that
|
|
45
|
+
* was never governed. There is no error to read later, which is why there is one here.
|
|
46
|
+
*/
|
|
47
|
+
function agentDimension(context) {
|
|
48
|
+
if (context.agentId === undefined)
|
|
49
|
+
return undefined;
|
|
50
|
+
if (!context.agentId.trim()) {
|
|
51
|
+
throw new Error('stiggContext.agentId is empty - an agent nobody can name is also one nothing can bill.');
|
|
52
|
+
}
|
|
53
|
+
if (!context.attributionKey?.trim()) {
|
|
54
|
+
throw new Error(`stiggContext.agentId is '${context.agentId}' but no attributionKey says which key to send ` +
|
|
55
|
+
"it under. Pass the key the agent's entity type declares. Without it the agent resolves " +
|
|
56
|
+
'no entity and the spend lands on the customer, which looks exactly like a call that was ' +
|
|
57
|
+
'never governed.');
|
|
58
|
+
}
|
|
59
|
+
return { [context.attributionKey]: context.agentId };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Validates and derives, once. Everything expensive about a context happens here rather than on
|
|
63
|
+
* the call path - see the note at the top of this file for why that split exists.
|
|
64
|
+
*/
|
|
65
|
+
/** The one field a call cannot proceed without, said the same way wherever it is missing. */
|
|
66
|
+
const noCustomer = () => new Error('No customerId - every governance call is scoped to a tenant. Give `wrap(client, ' +
|
|
67
|
+
'{ stiggContext: { customerId } })` a default for every call through this client, or name ' +
|
|
68
|
+
'one per call in `stiggContext`.');
|
|
69
|
+
exports.noCustomer = noCustomer;
|
|
70
|
+
function resolveContext(context, correlationId,
|
|
71
|
+
// `wrap` passes false: a client whose calls each name their own customer is legal, and its
|
|
72
|
+
// types already say which calls still owe one. Every path that reaches the engine resolves it.
|
|
73
|
+
{ requireCustomer = true, defaults } = {}) {
|
|
74
|
+
// Checked here as well as in `overrideContext`, and this is the half that matters more: `wrap`
|
|
75
|
+
// names an agent and its entitlement once for every call through the client, so a typo here
|
|
76
|
+
// governs *everything* by the fallback rather than one call.
|
|
77
|
+
assertKnownContext(context, requireCustomer ? 'stiggContext' : "wrap's stiggContext");
|
|
78
|
+
const customerId = context.customerId?.trim() ?? '';
|
|
79
|
+
if (!customerId && requireCustomer)
|
|
80
|
+
throw (0, exports.noCustomer)();
|
|
81
|
+
// Naming none is legal and means ungated. Naming an empty one is a typo that would meter into
|
|
82
|
+
// the dark, so it is refused here rather than three calls later.
|
|
83
|
+
const invalid = (0, types_1.entitlementError)(context.entitlement);
|
|
84
|
+
if (invalid)
|
|
85
|
+
throw invalid;
|
|
86
|
+
const agent = agentDimension(context);
|
|
87
|
+
const dimensions = agent ? { ...context.dimensions, ...agent } : (context.dimensions ?? {});
|
|
88
|
+
return {
|
|
89
|
+
customerId,
|
|
90
|
+
// Normalized, not passed through: `isEnforced` asks `!== undefined` while every other reader
|
|
91
|
+
// asks for truthiness, so a JS caller's `entitlement: null` would be enforced by one and
|
|
92
|
+
// absent to the rest - a call that runs the gate and then reports itself ungated.
|
|
93
|
+
entitlement: context.entitlement ?? undefined,
|
|
94
|
+
dimensions,
|
|
95
|
+
checkErrorAction: context.checkErrorAction ?? defaults?.checkErrorAction ?? exports.CONTEXT_DEFAULTS.checkErrorAction,
|
|
96
|
+
ingestMode: context.ingestMode ?? defaults?.ingestMode ?? exports.CONTEXT_DEFAULTS.ingestMode,
|
|
97
|
+
eventName: context.eventName,
|
|
98
|
+
usageMapper: context.usageMapper ?? types_1.vendorMetrics,
|
|
99
|
+
idempotencyKey: context.idempotencyKey,
|
|
100
|
+
correlationId: context.correlationId?.trim() || correlationId,
|
|
101
|
+
checkedId: context.entitlement ? (0, types_1.entitlementId)(context.entitlement) : undefined,
|
|
102
|
+
attributionKey: context.attributionKey,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Every field a `stiggContext` may carry.
|
|
107
|
+
*
|
|
108
|
+
* Typed as a total record of `keyof StiggContext` rather than written as a list, so a field added
|
|
109
|
+
* to the context without being added here is a compile error instead of a key this rejects.
|
|
110
|
+
*/
|
|
111
|
+
const CONTEXT_KEYS = {
|
|
112
|
+
customerId: true,
|
|
113
|
+
agentId: true,
|
|
114
|
+
attributionKey: true,
|
|
115
|
+
dimensions: true,
|
|
116
|
+
entitlement: true,
|
|
117
|
+
checkErrorAction: true,
|
|
118
|
+
usageMapper: true,
|
|
119
|
+
ingestMode: true,
|
|
120
|
+
eventName: true,
|
|
121
|
+
idempotencyKey: true,
|
|
122
|
+
correlationId: true,
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Refuses a context carrying a field this SDK does not read.
|
|
126
|
+
*
|
|
127
|
+
* Almost always a typo, and a silent one without this: the context is read key by key, so an
|
|
128
|
+
* unknown key simply never arrives. A call meaning to spend as one agent spends as whatever
|
|
129
|
+
* `wrap` was given instead, and the only trace is a bill against the wrong entity.
|
|
130
|
+
*
|
|
131
|
+
* The types catch it everywhere but `messages.stream`, which Anthropic declares generic -
|
|
132
|
+
* `stream<Params extends MessageStreamParams>(body: Params, ...)` - and a generic parameter
|
|
133
|
+
* infers from its argument rather than checking it for excess properties. `WrappedClient`
|
|
134
|
+
* intersects the overlay with the vendor's own type to stay assignable to it, and an intersection
|
|
135
|
+
* of call signatures is an overload set resolved in order, so a context the overlay rejects falls
|
|
136
|
+
* through to that signature and is taken. Hence the runtime guarantee, which covers every surface
|
|
137
|
+
* as well as JS callers and hand-typed call sites.
|
|
138
|
+
*
|
|
139
|
+
* Thrown rather than warned, like the other guards in this file: the call it would have governed
|
|
140
|
+
* is in front of money, and a warning is read after the bill.
|
|
141
|
+
*
|
|
142
|
+
* `Object.hasOwn` rather than `in`, which walks the prototype chain and so answers true for
|
|
143
|
+
* `toString`, `constructor` and `hasOwnProperty` - the names likeliest to arrive from a JS caller
|
|
144
|
+
* building a context out of an object it did not create.
|
|
145
|
+
*/
|
|
146
|
+
function assertKnownContext(context, where) {
|
|
147
|
+
for (const key of Object.keys(context)) {
|
|
148
|
+
if (Object.hasOwn(CONTEXT_KEYS, key))
|
|
149
|
+
continue;
|
|
150
|
+
throw new Error(`${where} has no field \`${key}\` - this SDK would ignore it, and the call would be ` +
|
|
151
|
+
`governed by whatever remained instead. Valid fields: ${Object.keys(CONTEXT_KEYS).join(', ')}.`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* One call's override, applied over what `wrap` already resolved.
|
|
156
|
+
*
|
|
157
|
+
* Only reached when a call actually carried a `stiggContext`; a call that did not reuses the
|
|
158
|
+
* resolved object as-is. The ordering below is the one the caller asked for: the base identity
|
|
159
|
+
* first, because a changed customer or agent changes which budget is consulted, and the metering
|
|
160
|
+
* fields last, because nothing before the response depends on them.
|
|
161
|
+
*/
|
|
162
|
+
function overrideContext(base, override) {
|
|
163
|
+
assertKnownContext(override, 'stiggContext');
|
|
164
|
+
// The cheap test first: these are the only fields that can change which budget is consulted,
|
|
165
|
+
// so a call overriding only, say, `idempotencyKey` re-derives nothing.
|
|
166
|
+
const rebased = override.customerId !== undefined ||
|
|
167
|
+
override.agentId !== undefined ||
|
|
168
|
+
override.dimensions !== undefined ||
|
|
169
|
+
override.entitlement !== undefined;
|
|
170
|
+
let customerId = base.customerId;
|
|
171
|
+
let dimensions = base.dimensions;
|
|
172
|
+
let entitlement = base.entitlement;
|
|
173
|
+
let checkedId = base.checkedId;
|
|
174
|
+
if (rebased) {
|
|
175
|
+
customerId = override.customerId?.trim() || base.customerId;
|
|
176
|
+
if (override.entitlement !== undefined) {
|
|
177
|
+
const invalid = (0, types_1.entitlementError)(override.entitlement);
|
|
178
|
+
if (invalid)
|
|
179
|
+
throw invalid;
|
|
180
|
+
entitlement = override.entitlement;
|
|
181
|
+
checkedId = (0, types_1.entitlementId)(override.entitlement);
|
|
182
|
+
}
|
|
183
|
+
// The key falls back to the client's own, so a call naming a different agent of the same type
|
|
184
|
+
// need only name the agent - which is the multi-agent case, and the common one.
|
|
185
|
+
const attributionKey = override.attributionKey ?? base.attributionKey;
|
|
186
|
+
const agent = agentDimension({ ...override, attributionKey });
|
|
187
|
+
// Merged per key, not replaced: a per-call tenant must not drop the client's own attribution.
|
|
188
|
+
//
|
|
189
|
+
// With one exception. An override naming a *different* entity type writes its agent under a
|
|
190
|
+
// different key, and the client's own key would otherwise ride along beside it - two agent
|
|
191
|
+
// dimensions on one call, which governance resolves as two entities and bills accordingly.
|
|
192
|
+
// That one is dropped by name: it describes an agent this call is not acting as. Every other
|
|
193
|
+
// dimension the client set is kept, because none of them is claiming to be the agent.
|
|
194
|
+
const inherited = agent && base.attributionKey && attributionKey !== base.attributionKey
|
|
195
|
+
? Object.fromEntries(Object.entries(base.dimensions).filter(([key]) => key !== base.attributionKey))
|
|
196
|
+
: base.dimensions;
|
|
197
|
+
dimensions = override.dimensions || agent ? { ...inherited, ...override.dimensions, ...agent } : base.dimensions;
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
customerId,
|
|
201
|
+
entitlement,
|
|
202
|
+
dimensions,
|
|
203
|
+
checkedId,
|
|
204
|
+
checkErrorAction: override.checkErrorAction ?? base.checkErrorAction,
|
|
205
|
+
ingestMode: override.ingestMode ?? base.ingestMode,
|
|
206
|
+
eventName: override.eventName ?? base.eventName,
|
|
207
|
+
usageMapper: override.usageMapper ?? base.usageMapper,
|
|
208
|
+
idempotencyKey: override.idempotencyKey ?? base.idempotencyKey,
|
|
209
|
+
correlationId: override.correlationId?.trim() || base.correlationId,
|
|
210
|
+
attributionKey: override.attributionKey ?? base.attributionKey,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/** Whether a context gates at all. The enforcement switch, read on every call. */
|
|
214
|
+
const isEnforced = (context) => context.entitlement !== undefined;
|
|
215
|
+
exports.isEnforced = isEnforced;
|
|
216
|
+
/** Reported metrics, accumulated across a client's calls. */
|
|
217
|
+
const addMetrics = (into, add) => {
|
|
218
|
+
for (const [name, value] of Object.entries(add))
|
|
219
|
+
into[name] = (into[name] ?? 0) + value;
|
|
220
|
+
};
|
|
221
|
+
exports.addMetrics = addMetrics;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vendor's usage object, reduced to something an event can carry.
|
|
3
|
+
*
|
|
4
|
+
* Stigg dimensions are a flat map of scalars, so a nested object has to be flattened or dropped -
|
|
5
|
+
* there is no third option, and stringifying a subtree would put something no meter can aggregate
|
|
6
|
+
* into a field a meter is meant to read.
|
|
7
|
+
*
|
|
8
|
+
* Everything here is a limit rather than a translation, because the input is not ours: a vendor
|
|
9
|
+
* adds fields whenever it likes, and this runs on every metered call. Depth, count and type are
|
|
10
|
+
* all capped so a response that grows a large nested block cannot grow the event with it.
|
|
11
|
+
*/
|
|
12
|
+
/** Prefix for everything read off the vendor's object, so it can never shadow a metric. */
|
|
13
|
+
export declare const RAW_PREFIX = "raw.";
|
|
14
|
+
/**
|
|
15
|
+
* The vendor's own usage block as event dimensions, `raw.`-prefixed and flattened with dotted keys.
|
|
16
|
+
*
|
|
17
|
+
* Fractions are kept: an event states what the vendor said and a meter is free to sum decimals,
|
|
18
|
+
* which is the one place a gateway's `cost` can be reported honestly. `null`, `undefined`, arrays
|
|
19
|
+
* and anything past the depth cap are dropped rather than coerced - a zero that was never reported
|
|
20
|
+
* is worse than a counter that is absent.
|
|
21
|
+
*/
|
|
22
|
+
export declare function flattenRaw(raw: Record<string, unknown> | undefined): Record<string, string | number | boolean>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The vendor's usage object, reduced to something an event can carry.
|
|
4
|
+
*
|
|
5
|
+
* Stigg dimensions are a flat map of scalars, so a nested object has to be flattened or dropped -
|
|
6
|
+
* there is no third option, and stringifying a subtree would put something no meter can aggregate
|
|
7
|
+
* into a field a meter is meant to read.
|
|
8
|
+
*
|
|
9
|
+
* Everything here is a limit rather than a translation, because the input is not ours: a vendor
|
|
10
|
+
* adds fields whenever it likes, and this runs on every metered call. Depth, count and type are
|
|
11
|
+
* all capped so a response that grows a large nested block cannot grow the event with it.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.RAW_PREFIX = void 0;
|
|
15
|
+
exports.flattenRaw = flattenRaw;
|
|
16
|
+
/** Prefix for everything read off the vendor's object, so it can never shadow a metric. */
|
|
17
|
+
exports.RAW_PREFIX = 'raw.';
|
|
18
|
+
const MAX_DEPTH = 2;
|
|
19
|
+
const MAX_KEYS = 32;
|
|
20
|
+
const isScalar = (value) => typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value));
|
|
21
|
+
/**
|
|
22
|
+
* The vendor's own usage block as event dimensions, `raw.`-prefixed and flattened with dotted keys.
|
|
23
|
+
*
|
|
24
|
+
* Fractions are kept: an event states what the vendor said and a meter is free to sum decimals,
|
|
25
|
+
* which is the one place a gateway's `cost` can be reported honestly. `null`, `undefined`, arrays
|
|
26
|
+
* and anything past the depth cap are dropped rather than coerced - a zero that was never reported
|
|
27
|
+
* is worse than a counter that is absent.
|
|
28
|
+
*/
|
|
29
|
+
function flattenRaw(raw) {
|
|
30
|
+
if (!raw)
|
|
31
|
+
return {};
|
|
32
|
+
const flat = {};
|
|
33
|
+
const walk = (node, path, depth) => {
|
|
34
|
+
for (const [key, value] of Object.entries(node)) {
|
|
35
|
+
if (Object.keys(flat).length >= MAX_KEYS)
|
|
36
|
+
return;
|
|
37
|
+
const name = path ? `${path}.${key}` : key;
|
|
38
|
+
if (isScalar(value))
|
|
39
|
+
flat[`${exports.RAW_PREFIX}${name}`] = value;
|
|
40
|
+
else if (depth < MAX_DEPTH && value && typeof value === 'object' && !Array.isArray(value)) {
|
|
41
|
+
walk(value, name, depth + 1);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
walk(raw, '', 0);
|
|
46
|
+
return flat;
|
|
47
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running an application's own callbacks, where their failure must not become the caller's.
|
|
3
|
+
*
|
|
4
|
+
* Hooks are instrumentation. One that throws has told us something went wrong with the watching,
|
|
5
|
+
* not with the work - so it cannot be allowed to fail a decision, flip a delivered ingest, or
|
|
6
|
+
* escape a promise the contract says never rejects. It cannot be ignored either, which is what a
|
|
7
|
+
* bare `try {} catch {}` amounts to.
|
|
8
|
+
*
|
|
9
|
+
* Shared, because every path that calls one has to get this right the same way, and the ones that
|
|
10
|
+
* reimplemented it are exactly the ones that got it wrong.
|
|
11
|
+
*/
|
|
12
|
+
/** Whether a callback typed `=> void` actually handed back something to wait on. */
|
|
13
|
+
export declare function isThenable(value: unknown): value is PromiseLike<unknown>;
|
|
14
|
+
/**
|
|
15
|
+
* Invokes a callback and routes its failure to `onFailure`, synchronous or not.
|
|
16
|
+
*
|
|
17
|
+
* The types say `void`, but nothing stops an application writing `async onIngest` - and a
|
|
18
|
+
* rejection there would otherwise surface as an unhandled one, which on Node's default takes the
|
|
19
|
+
* process down over instrumentation.
|
|
20
|
+
*/
|
|
21
|
+
export declare function safely(invoke: () => unknown, onFailure: (err: unknown) => void): void;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Running an application's own callbacks, where their failure must not become the caller's.
|
|
4
|
+
*
|
|
5
|
+
* Hooks are instrumentation. One that throws has told us something went wrong with the watching,
|
|
6
|
+
* not with the work - so it cannot be allowed to fail a decision, flip a delivered ingest, or
|
|
7
|
+
* escape a promise the contract says never rejects. It cannot be ignored either, which is what a
|
|
8
|
+
* bare `try {} catch {}` amounts to.
|
|
9
|
+
*
|
|
10
|
+
* Shared, because every path that calls one has to get this right the same way, and the ones that
|
|
11
|
+
* reimplemented it are exactly the ones that got it wrong.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.isThenable = isThenable;
|
|
15
|
+
exports.safely = safely;
|
|
16
|
+
/** Whether a callback typed `=> void` actually handed back something to wait on. */
|
|
17
|
+
function isThenable(value) {
|
|
18
|
+
return typeof value?.then === 'function';
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Invokes a callback and routes its failure to `onFailure`, synchronous or not.
|
|
22
|
+
*
|
|
23
|
+
* The types say `void`, but nothing stops an application writing `async onIngest` - and a
|
|
24
|
+
* rejection there would otherwise surface as an unhandled one, which on Node's default takes the
|
|
25
|
+
* process down over instrumentation.
|
|
26
|
+
*/
|
|
27
|
+
function safely(invoke, onFailure) {
|
|
28
|
+
// The reporter is the application's code too - an `onError` that throws, a logger that throws
|
|
29
|
+
// while saying so. Losing the report is the last acceptable failure here; letting it escape
|
|
30
|
+
// would fail the work the callback was only watching.
|
|
31
|
+
const report = (err) => {
|
|
32
|
+
try {
|
|
33
|
+
onFailure(err);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Nowhere left to report to.
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
try {
|
|
40
|
+
const returned = invoke();
|
|
41
|
+
if (isThenable(returned))
|
|
42
|
+
void Promise.resolve(returned).catch(report);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
report(err);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { EngineError } from '../errors';
|
|
2
|
+
import { type GovernanceHeaders } from '../headers';
|
|
3
|
+
import { type ResolvedContext } from './context';
|
|
4
|
+
import type { GovernanceEngine } from '../engine/engine';
|
|
5
|
+
import type { ErrorContext } from '../config';
|
|
6
|
+
import type { Logger } from '../logger';
|
|
7
|
+
import { type AccessDecision, type IngestResult, type Measurement, type Metrics, type TokenUsage } from '../types';
|
|
8
|
+
/** Everything the session reports back while it works. Set once, at `wrap`. */
|
|
9
|
+
/**
|
|
10
|
+
* Who the observation was for, which the observation itself cannot say.
|
|
11
|
+
*
|
|
12
|
+
* Neither an {@link AccessDecision} nor an {@link IngestResult} names the caller: `deniedBy`
|
|
13
|
+
* appears only on a refusal and names the entity that blocked, not the agent that asked. So one
|
|
14
|
+
* client serving several agents produced a stream of events nothing could attribute, and the only
|
|
15
|
+
* way to tell them apart was a separate wrapped client per agent - each with its own session
|
|
16
|
+
* state, which is a real cost rather than an inconvenience.
|
|
17
|
+
*
|
|
18
|
+
* `correlationId` is the one to reach for when reconciling: it ties a decision to the ingest that
|
|
19
|
+
* followed it, which is what anyone chasing a billing discrepancy actually needs.
|
|
20
|
+
*/
|
|
21
|
+
export type HookContext = {
|
|
22
|
+
/** The tenant this call was scoped to. */
|
|
23
|
+
customerId: string;
|
|
24
|
+
/** What it was attributed by - the agent under its entity type's own key, plus any extra. */
|
|
25
|
+
dimensions: Record<string, string>;
|
|
26
|
+
/** Shared by every observation of one call, and sent on the wire as `x-stigg-run-id`. */
|
|
27
|
+
correlationId: string;
|
|
28
|
+
/** What the call gated on, when it gated on anything. Undefined on a metering-only run. */
|
|
29
|
+
entitlementId?: string;
|
|
30
|
+
};
|
|
31
|
+
export type SessionHooks = {
|
|
32
|
+
/** Every check this client makes. The whole decision, already formatted as `.summary`. */
|
|
33
|
+
onDecision?: (decision: AccessDecision, context: HookContext) => void;
|
|
34
|
+
/** Every ingest this client makes - including the ones that failed. */
|
|
35
|
+
onIngest?: (result: IngestResult, context: HookContext) => void;
|
|
36
|
+
};
|
|
37
|
+
export type SessionDeps = {
|
|
38
|
+
/**
|
|
39
|
+
* Resolved at the point of use, not at construction.
|
|
40
|
+
*
|
|
41
|
+
* Building the engine is a network round trip - a probe that decides which transport serves
|
|
42
|
+
* which operation - and `wrap` is a setup call that reads as instant. So it starts that work
|
|
43
|
+
* and hands the session the promise: the first call that needs an engine awaits it, and every
|
|
44
|
+
* call after that gets one already resolved. The latency moves to the first request rather
|
|
45
|
+
* than disappearing.
|
|
46
|
+
*/
|
|
47
|
+
engine: () => Promise<GovernanceEngine>;
|
|
48
|
+
logger: Logger;
|
|
49
|
+
onError: (error: EngineError, context: ErrorContext) => void;
|
|
50
|
+
hooks: SessionHooks;
|
|
51
|
+
};
|
|
52
|
+
/** What a client did, for whoever wants to print it. Spans every context it served. */
|
|
53
|
+
export type SessionSummary = {
|
|
54
|
+
checks: number;
|
|
55
|
+
ingested: number;
|
|
56
|
+
/**
|
|
57
|
+
* Everything this client reported, summed per feature id - what it sent, not what it was billed.
|
|
58
|
+
*
|
|
59
|
+
* Under `'sync'` Stigg says whether a delivery was a replay, and a replay is left out: it
|
|
60
|
+
* consumed nothing. Under `'async'` it cannot say, so a retry Stigg deduplicated still counts
|
|
61
|
+
* here. Stigg remains the record of what was actually charged; this is the client's own tally.
|
|
62
|
+
*/
|
|
63
|
+
reported: Metrics;
|
|
64
|
+
ingestFailures: number;
|
|
65
|
+
lastDecision?: AccessDecision;
|
|
66
|
+
};
|
|
67
|
+
export type IngestOptions = {
|
|
68
|
+
/** Overrides the context's own name for this one report - the vendor method that was gated. */
|
|
69
|
+
event?: {
|
|
70
|
+
name: string;
|
|
71
|
+
};
|
|
72
|
+
/** The vendor's usage object, for the event to carry. Ignored under `'sync'`. */
|
|
73
|
+
raw?: Record<string, unknown>;
|
|
74
|
+
/**
|
|
75
|
+
* What deduplicates this report. Overrides the context's own, and is what a caller reaches for
|
|
76
|
+
* when one logical turn is reported twice - a retry, or a resumed stream.
|
|
77
|
+
*/
|
|
78
|
+
idempotencyKey?: string;
|
|
79
|
+
};
|
|
80
|
+
export declare class GovernanceSession {
|
|
81
|
+
private readonly deps;
|
|
82
|
+
private readonly pending;
|
|
83
|
+
private readonly reported;
|
|
84
|
+
private checks;
|
|
85
|
+
private ingested;
|
|
86
|
+
private ingestFailures;
|
|
87
|
+
private step;
|
|
88
|
+
private decision?;
|
|
89
|
+
/** The unmapped-feature warning is said once per client, not once per call. */
|
|
90
|
+
private warnedUnmapped;
|
|
91
|
+
/** Said once per client: the reason is the same every call, and so is the fix. */
|
|
92
|
+
private warnedRounded;
|
|
93
|
+
constructor(deps: SessionDeps);
|
|
94
|
+
get lastDecision(): AccessDecision | undefined;
|
|
95
|
+
/** The attribution to send downstream when part of the work happens in another process. */
|
|
96
|
+
headers(context: ResolvedContext): GovernanceHeaders;
|
|
97
|
+
/**
|
|
98
|
+
* Primes the cache that actually serves these checks, so the calls that follow read local state.
|
|
99
|
+
*
|
|
100
|
+
* It primes the *engine's* cache, not one here - there isn't one. The engine loads the
|
|
101
|
+
* customer's whole entitlement state, so every entitlement is warm rather than one decision,
|
|
102
|
+
* and each check is still evaluated against the amount it actually asks for. An engine with no
|
|
103
|
+
* cache (the governance REST client) implements nothing and this is a no-op, which is the
|
|
104
|
+
* honest answer: there would be nothing for the round trip to fill.
|
|
105
|
+
*
|
|
106
|
+
* Failures are logged rather than thrown: the client works either way, and the first real check
|
|
107
|
+
* applies the error policy.
|
|
108
|
+
*/
|
|
109
|
+
warmUp(context: ResolvedContext): Promise<void>;
|
|
110
|
+
/**
|
|
111
|
+
* The enforcement path. Returns a decision, never throws - a refusal is a commercial event the
|
|
112
|
+
* application routes on, not an exception it recovers from.
|
|
113
|
+
*/
|
|
114
|
+
check(context: ResolvedContext, requestedUsage?: number): Promise<AccessDecision>;
|
|
115
|
+
/** Applies the context's mapper to a token measurement. The path every binding uses. */
|
|
116
|
+
ingestTokens(context: ResolvedContext, usage: TokenUsage, options?: IngestOptions): Promise<IngestResult>;
|
|
117
|
+
/**
|
|
118
|
+
* Ingests whatever a binding measured, through the context's mapper.
|
|
119
|
+
*
|
|
120
|
+
* The mapper runs inside the boundary that never throws, because it is the application's own
|
|
121
|
+
* function over a vendor's object and either half is free to change shape - a throw there would
|
|
122
|
+
* put an exception where the contract promises a result.
|
|
123
|
+
*/
|
|
124
|
+
ingestMeasurement(context: ResolvedContext, measurement: Measurement | TokenUsage, options?: IngestOptions): Promise<IngestResult>;
|
|
125
|
+
/**
|
|
126
|
+
* Reports one value against the entitlement this context checks.
|
|
127
|
+
*
|
|
128
|
+
* The narrow case, and the only one where the SDK knows what a report costs the budget it gates
|
|
129
|
+
* on - which is why it is also the only one that can name the feature for you.
|
|
130
|
+
*/
|
|
131
|
+
ingest(context: ResolvedContext, value: number, options?: IngestOptions): Promise<IngestResult>;
|
|
132
|
+
/**
|
|
133
|
+
* Reports named metrics directly, skipping the mapper. The path a gateway and a hand-rolled
|
|
134
|
+
* binding use, where the numbers are already in Stigg's own vocabulary.
|
|
135
|
+
*/
|
|
136
|
+
ingestMetrics(context: ResolvedContext, metrics: Metrics, options?: IngestOptions): Promise<IngestResult>;
|
|
137
|
+
/**
|
|
138
|
+
* The instrumentation path: one report per metric, all in one call where the transport has one.
|
|
139
|
+
*
|
|
140
|
+
* Never throws and never applies the error policy - the spend already happened, so failing here
|
|
141
|
+
* must not fail the caller's call. Errors go to `onError`.
|
|
142
|
+
*/
|
|
143
|
+
private send;
|
|
144
|
+
/**
|
|
145
|
+
* A binding's own failure at measuring a call that already happened.
|
|
146
|
+
*
|
|
147
|
+
* A recipe reads usage off a vendor's response, and a vendor is free to change that response -
|
|
148
|
+
* so reading it can throw. The call succeeded and the money is spent either way, which makes
|
|
149
|
+
* this the same kind of event as a failed ingest: counted, reported, and never raised at the
|
|
150
|
+
* caller, who asked the vendor for something and got it.
|
|
151
|
+
*/
|
|
152
|
+
failedToMeasure(context: ResolvedContext, err: unknown): void;
|
|
153
|
+
/** Waits for in-flight ingests, so a caller knows what was actually delivered. */
|
|
154
|
+
drain(): Promise<SessionSummary>;
|
|
155
|
+
summary(): SessionSummary;
|
|
156
|
+
/** `correlationId:n` - distinct per call, for a vendor that offered no id of its own. */
|
|
157
|
+
private nextKey;
|
|
158
|
+
/**
|
|
159
|
+
* The failure that looks like success, said once.
|
|
160
|
+
*
|
|
161
|
+
* A call gates on one capability and reports to another. Stigg only moves the one to the other
|
|
162
|
+
* when the feature is credit-backed in the catalog - and when it is not, every call still
|
|
163
|
+
* succeeds, every ingest returns `ok`, and the budget never moves. Nothing else in the system
|
|
164
|
+
* can notice: the check keeps passing because nothing was ever spent.
|
|
165
|
+
*
|
|
166
|
+
* The two modes fail the same way and can be told about it to different degrees. A usage report
|
|
167
|
+
* answers with a credit balance, so its silence is proof. An event answers with nothing at all,
|
|
168
|
+
* so the most that can be said is that nothing reported names what this call checks, and whether
|
|
169
|
+
* a meter closes that gap is configuration this process cannot see.
|
|
170
|
+
*/
|
|
171
|
+
/**
|
|
172
|
+
* Says that an attributed run rounded, because an overcharge the caller did not ask for must
|
|
173
|
+
* not be silent - that silence is the thing this SDK was told off for in the first place.
|
|
174
|
+
*/
|
|
175
|
+
private warnRounded;
|
|
176
|
+
private warnIfNothingDrains;
|
|
177
|
+
private record;
|
|
178
|
+
/**
|
|
179
|
+
* Hooks are instrumentation, so a throwing one must not reach the caller: it cannot turn an
|
|
180
|
+
* allow into a denial, flip a delivered ingest to `ok: false`, or reject a promise the contract
|
|
181
|
+
* says never rejects. The failure is reported like any other.
|
|
182
|
+
*/
|
|
183
|
+
private hook;
|
|
184
|
+
/**
|
|
185
|
+
* The last stop for a failure. `onError` is the application's own code and can throw too, and
|
|
186
|
+
* there is nowhere left to report that to, so it falls back to the logger.
|
|
187
|
+
*/
|
|
188
|
+
private reportError;
|
|
189
|
+
private failedIngest;
|
|
190
|
+
}
|