@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,448 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GovernanceSession = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The governance session: one per wrapped client, and the two operations every binding needs.
|
|
6
|
+
*
|
|
7
|
+
* check(context, requestedUsage) - before the call. Returns a decision, never throws.
|
|
8
|
+
* ingest(context, measurement) - after it. Never throws, never fails the caller's call.
|
|
9
|
+
*
|
|
10
|
+
* Everything that used to belong to a run belongs here instead, with one difference that matters:
|
|
11
|
+
* nothing is scoped to a single customer or budget. A context arrives with every operation, so one
|
|
12
|
+
* session serves every tenant a client is called for.
|
|
13
|
+
*
|
|
14
|
+
* ## What is deliberately not here
|
|
15
|
+
*
|
|
16
|
+
* No cache. This package holds no decision cache of its own, and that is the whole design: the
|
|
17
|
+
* Node server SDK underneath already caches entitlement state, and does it far better than a TTL
|
|
18
|
+
* over decisions ever could - write-through from the server's own measurement responses, realtime
|
|
19
|
+
* invalidation, usage-period resets applied at read time, fallbacks, and an optional Redis store
|
|
20
|
+
* that two processes can share.
|
|
21
|
+
*
|
|
22
|
+
* An earlier design cached decisions here for `cacheTtlMs`. It duplicated that machinery and
|
|
23
|
+
* degraded it: a cached allow outlived the report that spent the budget, and it could not hear an
|
|
24
|
+
* invalidation. What is left is a straight call to the engine, which is the thing that knows.
|
|
25
|
+
* {@link GovernanceSession.warmUp} primes the engine's cache rather than one of its own.
|
|
26
|
+
*/
|
|
27
|
+
const decision_1 = require("../engine/decision");
|
|
28
|
+
const errors_1 = require("../errors");
|
|
29
|
+
const headers_1 = require("../headers");
|
|
30
|
+
const dimensions_1 = require("./dimensions");
|
|
31
|
+
const hooks_1 = require("./hooks");
|
|
32
|
+
const context_1 = require("./context");
|
|
33
|
+
const types_1 = require("../types");
|
|
34
|
+
/** What a context looks like to a hook: the attribution, and nothing a hook could change. */
|
|
35
|
+
const hookContext = (context) => ({
|
|
36
|
+
customerId: context.customerId,
|
|
37
|
+
dimensions: context.dimensions,
|
|
38
|
+
correlationId: context.correlationId,
|
|
39
|
+
entitlementId: context.checkedId,
|
|
40
|
+
});
|
|
41
|
+
/** A metric nobody can bill: refused where the mapper that invented it can still be found. */
|
|
42
|
+
const emptyMetricNameError = () => new errors_1.EngineError('measure', 'a metric needs a name - an empty one reports to no feature at all.');
|
|
43
|
+
/**
|
|
44
|
+
* What a report is called when nothing names it.
|
|
45
|
+
*
|
|
46
|
+
* A gated call is named after the vendor method it wrapped - `anthropic.messages.create` - which
|
|
47
|
+
* is what a Stigg meter filters on. This is the floor underneath that, for the paths with no
|
|
48
|
+
* wrapped method behind them: `stigg.report(...)`, and a gateway's own ingest. Without it an
|
|
49
|
+
* `'async'` report would reach the event API with no name at all.
|
|
50
|
+
*/
|
|
51
|
+
const DEFAULT_EVENT_NAME = 'agent.usage';
|
|
52
|
+
class GovernanceSession {
|
|
53
|
+
deps;
|
|
54
|
+
pending = new Set();
|
|
55
|
+
reported = {};
|
|
56
|
+
checks = 0;
|
|
57
|
+
ingested = 0;
|
|
58
|
+
ingestFailures = 0;
|
|
59
|
+
step = 0;
|
|
60
|
+
decision;
|
|
61
|
+
/** The unmapped-feature warning is said once per client, not once per call. */
|
|
62
|
+
warnedUnmapped = false;
|
|
63
|
+
/** Said once per client: the reason is the same every call, and so is the fix. */
|
|
64
|
+
warnedRounded = false;
|
|
65
|
+
constructor(deps) {
|
|
66
|
+
this.deps = deps;
|
|
67
|
+
}
|
|
68
|
+
get lastDecision() {
|
|
69
|
+
return this.decision;
|
|
70
|
+
}
|
|
71
|
+
/** The attribution to send downstream when part of the work happens in another process. */
|
|
72
|
+
headers(context) {
|
|
73
|
+
return {
|
|
74
|
+
[headers_1.GOVERNANCE_HEADERS.customer]: context.customerId,
|
|
75
|
+
[headers_1.GOVERNANCE_HEADERS.dimensions]: JSON.stringify(context.dimensions),
|
|
76
|
+
[headers_1.GOVERNANCE_HEADERS.run]: context.correlationId,
|
|
77
|
+
// Which of those dimensions is the agent. Only this side knows: the receiver sees a flat
|
|
78
|
+
// map and cannot tell attribution from metadata. Empty when the run names no agent, which
|
|
79
|
+
// is a receiver that should round nothing.
|
|
80
|
+
[headers_1.GOVERNANCE_HEADERS.attributionKey]: context.attributionKey ?? '',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Primes the cache that actually serves these checks, so the calls that follow read local state.
|
|
85
|
+
*
|
|
86
|
+
* It primes the *engine's* cache, not one here - there isn't one. The engine loads the
|
|
87
|
+
* customer's whole entitlement state, so every entitlement is warm rather than one decision,
|
|
88
|
+
* and each check is still evaluated against the amount it actually asks for. An engine with no
|
|
89
|
+
* cache (the governance REST client) implements nothing and this is a no-op, which is the
|
|
90
|
+
* honest answer: there would be nothing for the round trip to fill.
|
|
91
|
+
*
|
|
92
|
+
* Failures are logged rather than thrown: the client works either way, and the first real check
|
|
93
|
+
* applies the error policy.
|
|
94
|
+
*/
|
|
95
|
+
async warmUp(context) {
|
|
96
|
+
if (!(0, context_1.isEnforced)(context))
|
|
97
|
+
return;
|
|
98
|
+
try {
|
|
99
|
+
await (await this.deps.engine()).preload?.(context.customerId, context.dimensions);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
this.deps.logger.warn(`[stigg] warm-up failed for ${context.customerId}: ${String(err)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The enforcement path. Returns a decision, never throws - a refusal is a commercial event the
|
|
107
|
+
* application routes on, not an exception it recovers from.
|
|
108
|
+
*/
|
|
109
|
+
async check(context, requestedUsage = 1) {
|
|
110
|
+
this.checks += 1;
|
|
111
|
+
const { entitlement } = context;
|
|
112
|
+
// Asked and answered, with nobody to ask: this context named no entitlement, so there is no
|
|
113
|
+
// budget to consult and no refusal that could come back. Still counted and recorded, because
|
|
114
|
+
// the caller did ask and `lastDecision` is where they read what they got.
|
|
115
|
+
if (!entitlement)
|
|
116
|
+
return this.record((0, decision_1.unenforcedDecision)(), context);
|
|
117
|
+
try {
|
|
118
|
+
const engine = await this.deps.engine();
|
|
119
|
+
const result = await engine.check(context.customerId, {
|
|
120
|
+
entitlement,
|
|
121
|
+
requestedUsage,
|
|
122
|
+
dimensions: context.dimensions,
|
|
123
|
+
});
|
|
124
|
+
return this.record((0, decision_1.toDecision)(result, { customerId: context.customerId, entitlement: context.entitlement }), context);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
const error = asEngineError('check', err);
|
|
128
|
+
this.reportError(error, 'check', context);
|
|
129
|
+
// Governance is fail-closed server-side; `'deny'` mirrors that, and `'allow'` - the default -
|
|
130
|
+
// is the soft limit that keeps the application up while Stigg is unreachable.
|
|
131
|
+
return this.record((0, decision_1.policyDecision)(context.checkErrorAction === 'allow'), context);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** Applies the context's mapper to a token measurement. The path every binding uses. */
|
|
135
|
+
ingestTokens(context, usage, options = {}) {
|
|
136
|
+
return this.ingestMeasurement(context, { usage }, options);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Ingests whatever a binding measured, through the context's mapper.
|
|
140
|
+
*
|
|
141
|
+
* The mapper runs inside the boundary that never throws, because it is the application's own
|
|
142
|
+
* function over a vendor's object and either half is free to change shape - a throw there would
|
|
143
|
+
* put an exception where the contract promises a result.
|
|
144
|
+
*/
|
|
145
|
+
ingestMeasurement(context, measurement, options = {}) {
|
|
146
|
+
const measured = (0, types_1.toMeasurement)(measurement);
|
|
147
|
+
// Merged, not replaced: a measurement names what only it can know - the model that served -
|
|
148
|
+
// and the caller's own attribution is not an alternative to that. The measurement wins a key
|
|
149
|
+
// they both set, because it saw the call.
|
|
150
|
+
const dimensions = measured.dimensions ? { ...context.dimensions, ...measured.dimensions } : context.dimensions;
|
|
151
|
+
const idempotencyKey = measured.idempotencyKey ?? options.idempotencyKey ?? context.idempotencyKey ?? this.nextKey(context);
|
|
152
|
+
try {
|
|
153
|
+
return this.send(context, context.usageMapper(measured), {
|
|
154
|
+
dimensions,
|
|
155
|
+
idempotencyKey,
|
|
156
|
+
raw: measured.raw ?? options.raw,
|
|
157
|
+
event: options.event ?? context.eventName,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
return Promise.resolve(this.failedIngest(context, idempotencyKey, asEngineError('measure', err)));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Reports one value against the entitlement this context checks.
|
|
166
|
+
*
|
|
167
|
+
* The narrow case, and the only one where the SDK knows what a report costs the budget it gates
|
|
168
|
+
* on - which is why it is also the only one that can name the feature for you.
|
|
169
|
+
*/
|
|
170
|
+
ingest(context, value, options = {}) {
|
|
171
|
+
if (!context.checkedId) {
|
|
172
|
+
// There is no feature id to put this under: the whole point of this overload is that the
|
|
173
|
+
// value names what the context checks, and this one checks nothing. Failing the way a broken
|
|
174
|
+
// metric fails - counted, reported, handed back as a result - is what keeps the one promise
|
|
175
|
+
// ingest makes, that metering never breaks the caller's call.
|
|
176
|
+
const key = options.idempotencyKey ?? context.idempotencyKey ?? this.nextKey(context);
|
|
177
|
+
return Promise.resolve(this.failedIngest(context, key, new errors_1.EngineError('ingest', 'ingest(value) reports against the entitlement the call checks, and this context named ' +
|
|
178
|
+
'none - so there is no feature id to report it under. Report by name with ' +
|
|
179
|
+
'ingestMetrics({ [featureId]: value }), or name an entitlement.')));
|
|
180
|
+
}
|
|
181
|
+
return this.ingestMetrics(context, { [context.checkedId]: value }, options);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Reports named metrics directly, skipping the mapper. The path a gateway and a hand-rolled
|
|
185
|
+
* binding use, where the numbers are already in Stigg's own vocabulary.
|
|
186
|
+
*/
|
|
187
|
+
ingestMetrics(context, metrics, options = {}) {
|
|
188
|
+
return this.send(context, metrics, {
|
|
189
|
+
dimensions: context.dimensions,
|
|
190
|
+
idempotencyKey: options.idempotencyKey ?? context.idempotencyKey ?? this.nextKey(context),
|
|
191
|
+
raw: options.raw,
|
|
192
|
+
event: options.event ?? context.eventName,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The instrumentation path: one report per metric, all in one call where the transport has one.
|
|
197
|
+
*
|
|
198
|
+
* Never throws and never applies the error policy - the spend already happened, so failing here
|
|
199
|
+
* must not fail the caller's call. Errors go to `onError`.
|
|
200
|
+
*/
|
|
201
|
+
send(context, metrics, sendOptions) {
|
|
202
|
+
const { dimensions, idempotencyKey, raw, event } = sendOptions;
|
|
203
|
+
const mode = context.ingestMode;
|
|
204
|
+
// A broken value is one broken metric, not a broken call: the others measured fine and the
|
|
205
|
+
// spend they describe already happened. Dropping only the bad one reports what is knowable.
|
|
206
|
+
//
|
|
207
|
+
// What counts as broken follows the mode, because the two DTOs take different numbers. A zero
|
|
208
|
+
// survives into an event and is dropped from a usage report: a meter may filter on a field
|
|
209
|
+
// being present, and an event that omits it on a cache miss changes shape call to call, while
|
|
210
|
+
// a usage report of zero is a request that moves nothing.
|
|
211
|
+
// Which metric draws the wallet, which is what decides whether a fraction is legal: the one
|
|
212
|
+
// named after the currency this context checks goes to `consumeCredits`, and everything else
|
|
213
|
+
// to the usage report or an event. The same split `reportMetrics` makes further down.
|
|
214
|
+
const creditCurrencyId = context.entitlement && (0, types_1.entitlementKind)(context.entitlement) === 'credit' ? context.checkedId : undefined;
|
|
215
|
+
// Whether this run is attributed to a governance entity, which is what decides whether a
|
|
216
|
+
// fraction can be counted. See `wholeUnitsForGovernance`.
|
|
217
|
+
const attributed = !!context.attributionKey && !!context.dimensions[context.attributionKey];
|
|
218
|
+
const sendable = {};
|
|
219
|
+
let rejected;
|
|
220
|
+
for (const [featureId, rawValue] of Object.entries(metrics)) {
|
|
221
|
+
const drawsWallet = featureId === creditCurrencyId;
|
|
222
|
+
// The mapper's own number is what gets validated, before any rounding of ours. Rounding
|
|
223
|
+
// first laundered a negative into a success: `Math.ceil(-0.5)` is `-0`, which is not `< 0`,
|
|
224
|
+
// so it passed the guard that exists to catch it, was then dropped for not being `> 0`, and
|
|
225
|
+
// reported as the no-op ingest that an empty turn is. A mapper returning a negative would
|
|
226
|
+
// have been told it succeeded.
|
|
227
|
+
//
|
|
228
|
+
// The key is the feature id this spend lands on, and the mapper that produced it is the
|
|
229
|
+
// application's own function. An empty one is validated by nothing downstream - the value is
|
|
230
|
+
// checked, the key is not - so it would report to a feature that cannot exist, answer `ok`,
|
|
231
|
+
// and move no budget: the failure that looks like success, from a typo.
|
|
232
|
+
const invalid = featureId.trim() ? (0, types_1.usageValueError)(rawValue, mode, drawsWallet) : emptyMetricNameError();
|
|
233
|
+
if (invalid) {
|
|
234
|
+
rejected ??= invalid;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const value = attributed && drawsWallet ? (0, types_1.wholeUnitsForGovernance)(rawValue) : rawValue;
|
|
238
|
+
if (value !== rawValue)
|
|
239
|
+
this.warnRounded(featureId, rawValue, value);
|
|
240
|
+
if (value > 0 || mode === 'async')
|
|
241
|
+
sendable[featureId] = value;
|
|
242
|
+
}
|
|
243
|
+
// Zero-token calls are normal (a cached refusal, an empty stream); they are not an error. They
|
|
244
|
+
// are still an ingest that happened, though - counted, so a client that meters nothing is
|
|
245
|
+
// distinguishable from one that never tried, and reported to `onIngest` like every result.
|
|
246
|
+
if (!Object.keys(sendable).length) {
|
|
247
|
+
if (rejected)
|
|
248
|
+
return Promise.resolve(this.failedIngest(context, idempotencyKey, rejected));
|
|
249
|
+
this.ingested += 1;
|
|
250
|
+
const noop = { ok: true, reported: {}, idempotencyKey, mode, replayed: false };
|
|
251
|
+
this.hook('onIngest', context, () => this.deps.hooks.onIngest?.(noop, hookContext(context)));
|
|
252
|
+
return Promise.resolve(noop);
|
|
253
|
+
}
|
|
254
|
+
const promise = this.deps
|
|
255
|
+
.engine()
|
|
256
|
+
.then((engine) => engine.report(context.customerId, {
|
|
257
|
+
metrics: sendable,
|
|
258
|
+
entitlement: context.entitlement,
|
|
259
|
+
mode,
|
|
260
|
+
// Flattened here rather than in the engine, so both transports send the same event and a
|
|
261
|
+
// sync report pays nothing for a field it will not use.
|
|
262
|
+
raw: mode === 'async' ? (0, dimensions_1.flattenRaw)(raw) : undefined,
|
|
263
|
+
dimensions,
|
|
264
|
+
idempotencyKey,
|
|
265
|
+
event: event ?? { name: DEFAULT_EVENT_NAME },
|
|
266
|
+
}))
|
|
267
|
+
.then((result) => {
|
|
268
|
+
this.ingested += 1;
|
|
269
|
+
// A replay is a delivery that consumed nothing - the server had already counted these
|
|
270
|
+
// keys - so it is one more ingest but not one more unit of spend.
|
|
271
|
+
if (!result.replayed)
|
|
272
|
+
(0, context_1.addMetrics)(this.reported, result.reported);
|
|
273
|
+
this.warnIfNothingDrains(context, result, sendable);
|
|
274
|
+
const landed = {
|
|
275
|
+
// A metric the caller's own mapper got wrong does not stop the rest from being
|
|
276
|
+
// delivered - the spend is real and dropping it would understate a bill. But the turn
|
|
277
|
+
// did not report everything it measured, and saying `ok` would hide that: the metrics
|
|
278
|
+
// that were refused are missing from `reported`, and nothing else would ever mention
|
|
279
|
+
// them. So what landed is reported as landed, and the refusal travels with it.
|
|
280
|
+
ok: !rejected,
|
|
281
|
+
reported: result.reported,
|
|
282
|
+
idempotencyKey,
|
|
283
|
+
mode: result.mode,
|
|
284
|
+
replayed: result.replayed,
|
|
285
|
+
creditsConsumed: result.creditsConsumed,
|
|
286
|
+
...(rejected ? { error: rejected } : {}),
|
|
287
|
+
};
|
|
288
|
+
// Counted and routed to the error sink exactly as a wholly failed ingest is - a mapper
|
|
289
|
+
// that produced an unusable value is the application's own bug either way. `failedIngest`
|
|
290
|
+
// does this for the turn that delivered nothing; this is the turn that delivered some.
|
|
291
|
+
if (rejected) {
|
|
292
|
+
this.ingestFailures += 1;
|
|
293
|
+
this.reportError(rejected, rejected.operation, context, idempotencyKey);
|
|
294
|
+
}
|
|
295
|
+
this.hook('onIngest', context, () => this.deps.hooks.onIngest?.(landed, hookContext(context)));
|
|
296
|
+
return landed;
|
|
297
|
+
})
|
|
298
|
+
.catch((err) => this.failedIngest(context, idempotencyKey, asEngineError('ingest', err)))
|
|
299
|
+
.finally(() => {
|
|
300
|
+
this.pending.delete(promise);
|
|
301
|
+
});
|
|
302
|
+
this.pending.add(promise);
|
|
303
|
+
return promise;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* A binding's own failure at measuring a call that already happened.
|
|
307
|
+
*
|
|
308
|
+
* A recipe reads usage off a vendor's response, and a vendor is free to change that response -
|
|
309
|
+
* so reading it can throw. The call succeeded and the money is spent either way, which makes
|
|
310
|
+
* this the same kind of event as a failed ingest: counted, reported, and never raised at the
|
|
311
|
+
* caller, who asked the vendor for something and got it.
|
|
312
|
+
*/
|
|
313
|
+
failedToMeasure(context, err) {
|
|
314
|
+
this.ingestFailures += 1;
|
|
315
|
+
this.reportError(asEngineError('measure', err), 'measure', context);
|
|
316
|
+
}
|
|
317
|
+
/** Waits for in-flight ingests, so a caller knows what was actually delivered. */
|
|
318
|
+
async drain() {
|
|
319
|
+
while (this.pending.size)
|
|
320
|
+
await Promise.allSettled([...this.pending]);
|
|
321
|
+
return this.summary();
|
|
322
|
+
}
|
|
323
|
+
summary() {
|
|
324
|
+
return {
|
|
325
|
+
checks: this.checks,
|
|
326
|
+
ingested: this.ingested,
|
|
327
|
+
reported: { ...this.reported },
|
|
328
|
+
ingestFailures: this.ingestFailures,
|
|
329
|
+
lastDecision: this.decision,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
/** `correlationId:n` - distinct per call, for a vendor that offered no id of its own. */
|
|
333
|
+
nextKey(context) {
|
|
334
|
+
return `${context.correlationId}:${++this.step}`;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* The failure that looks like success, said once.
|
|
338
|
+
*
|
|
339
|
+
* A call gates on one capability and reports to another. Stigg only moves the one to the other
|
|
340
|
+
* when the feature is credit-backed in the catalog - and when it is not, every call still
|
|
341
|
+
* succeeds, every ingest returns `ok`, and the budget never moves. Nothing else in the system
|
|
342
|
+
* can notice: the check keeps passing because nothing was ever spent.
|
|
343
|
+
*
|
|
344
|
+
* The two modes fail the same way and can be told about it to different degrees. A usage report
|
|
345
|
+
* answers with a credit balance, so its silence is proof. An event answers with nothing at all,
|
|
346
|
+
* so the most that can be said is that nothing reported names what this call checks, and whether
|
|
347
|
+
* a meter closes that gap is configuration this process cannot see.
|
|
348
|
+
*/
|
|
349
|
+
/**
|
|
350
|
+
* Says that an attributed run rounded, because an overcharge the caller did not ask for must
|
|
351
|
+
* not be silent - that silence is the thing this SDK was told off for in the first place.
|
|
352
|
+
*/
|
|
353
|
+
warnRounded(featureId, from, to) {
|
|
354
|
+
if (this.warnedRounded)
|
|
355
|
+
return;
|
|
356
|
+
this.warnedRounded = true;
|
|
357
|
+
this.deps.logger.warn(`[stigg] rounded ${featureId} up from ${from} to ${to}: this run is attributed to a governance ` +
|
|
358
|
+
'entity, and per-entity usage is counted in whole units. An unattributed run reports the ' +
|
|
359
|
+
'fraction as measured. Denominate the currency finely enough that one unit is small, or ' +
|
|
360
|
+
'drop the agent attribution to meter exactly.');
|
|
361
|
+
}
|
|
362
|
+
warnIfNothingDrains(context, result, sent) {
|
|
363
|
+
if (this.warnedUnmapped)
|
|
364
|
+
return;
|
|
365
|
+
// The warning exists to say that the budget this call checks will never drain. A call that
|
|
366
|
+
// named no entitlement checks no budget, so there is nothing here that can fail to drain -
|
|
367
|
+
// saying it anyway would invent a gate the caller deliberately did not ask for.
|
|
368
|
+
const checked = context.checkedId;
|
|
369
|
+
if (!checked)
|
|
370
|
+
return;
|
|
371
|
+
const names = Object.keys(sent).join(', ');
|
|
372
|
+
if (result.mode === 'async') {
|
|
373
|
+
if (Object.keys(sent).includes(checked))
|
|
374
|
+
return;
|
|
375
|
+
this.warnedUnmapped = true;
|
|
376
|
+
this.deps.logger.warn(`[stigg] reported ${names} as events under ${context.eventName?.name ?? 'the call event'} - none of ` +
|
|
377
|
+
`them names ${checked}, which is what this call checks. Whether the budget moves is now up to the ` +
|
|
378
|
+
'meters configured for that event, and the event API answers with nothing that can confirm it.');
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (result.creditsConsumed !== undefined)
|
|
382
|
+
return;
|
|
383
|
+
this.warnedUnmapped = true;
|
|
384
|
+
this.deps.logger.warn(`[stigg] reported ${names} but the response carried no credit balance - ${names.includes(',') ? 'these features are' : 'this feature is'} not mapped to ${checked}, so the budget this call checks ` +
|
|
385
|
+
'against will never drain. Map it in Stigg, or report to the currency directly.');
|
|
386
|
+
}
|
|
387
|
+
record(decision, context) {
|
|
388
|
+
this.decision = decision;
|
|
389
|
+
this.hook('onDecision', context, () => this.deps.hooks.onDecision?.(decision, hookContext(context)));
|
|
390
|
+
return decision;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Hooks are instrumentation, so a throwing one must not reach the caller: it cannot turn an
|
|
394
|
+
* allow into a denial, flip a delivered ingest to `ok: false`, or reject a promise the contract
|
|
395
|
+
* says never rejects. The failure is reported like any other.
|
|
396
|
+
*/
|
|
397
|
+
hook(name, context, invoke) {
|
|
398
|
+
(0, hooks_1.safely)(invoke, (err) => this.reportError(asEngineError(name, err), name, context));
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* The last stop for a failure. `onError` is the application's own code and can throw too, and
|
|
402
|
+
* there is nowhere left to report that to, so it falls back to the logger.
|
|
403
|
+
*/
|
|
404
|
+
reportError(error, operation, context, idempotencyKey) {
|
|
405
|
+
(0, hooks_1.safely)(() => this.deps.onError(error, {
|
|
406
|
+
operation,
|
|
407
|
+
runId: context?.correlationId,
|
|
408
|
+
idempotencyKey,
|
|
409
|
+
customerId: context?.customerId,
|
|
410
|
+
}), (err) => this.deps.logger.warn(`[stigg] onError threw while reporting ${error.message}: ${String(err)}`));
|
|
411
|
+
}
|
|
412
|
+
failedIngest(context, idempotencyKey, error) {
|
|
413
|
+
this.ingestFailures += 1;
|
|
414
|
+
// The error knows what failed - reading a vendor's usage is not the same event as the report
|
|
415
|
+
// that would have followed it, and a context that says otherwise makes the two
|
|
416
|
+
// indistinguishable.
|
|
417
|
+
this.reportError(error, error.operation, context, idempotencyKey);
|
|
418
|
+
// `reported` is what landed, which is not always nothing: an ingest is up to two calls, and a
|
|
419
|
+
// rejection on the second leaves the first one's spend at Stigg. It is what was attempted
|
|
420
|
+
// minus whatever the failure took with it, so it comes off the error rather than off the
|
|
421
|
+
// metrics this call set out to send.
|
|
422
|
+
const landed = error.reported ?? {};
|
|
423
|
+
// Guarded exactly as a successful ingest is: what landed before the failure can itself have
|
|
424
|
+
// been a replay, and the server consumed nothing for it. Counting it here would make a
|
|
425
|
+
// half-failed retry the one way to inflate the summary past what was actually spent.
|
|
426
|
+
if (!error.replayed)
|
|
427
|
+
(0, context_1.addMetrics)(this.reported, landed);
|
|
428
|
+
const failure = {
|
|
429
|
+
ok: false,
|
|
430
|
+
reported: landed,
|
|
431
|
+
idempotencyKey,
|
|
432
|
+
mode: context.ingestMode,
|
|
433
|
+
// What the error carried, not an assumption: `undefined` stays undefined, because the event
|
|
434
|
+
// API cannot tell a first delivery from a replay and saying `false` would claim it can.
|
|
435
|
+
replayed: error.replayed ?? false,
|
|
436
|
+
error,
|
|
437
|
+
};
|
|
438
|
+
this.hook('onIngest', context, () => this.deps.hooks.onIngest?.(failure, hookContext(context)));
|
|
439
|
+
return failure;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
exports.GovernanceSession = GovernanceSession;
|
|
443
|
+
/** Whatever went wrong, as the seam's own error. */
|
|
444
|
+
function asEngineError(operation, err) {
|
|
445
|
+
if (err instanceof errors_1.EngineError)
|
|
446
|
+
return err;
|
|
447
|
+
return new errors_1.EngineError(operation, err instanceof Error ? err.message : String(err), { cause: err });
|
|
448
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a decision reads to a human. Kept next to the decision itself rather than in a binding,
|
|
3
|
+
* because every binding needs the same words and none of them should invent their own.
|
|
4
|
+
*/
|
|
5
|
+
import type { AccessDecision } from './types';
|
|
6
|
+
/**
|
|
7
|
+
* Denial -> what to tell the user.
|
|
8
|
+
*
|
|
9
|
+
* An entitlement refusal is a commercial event, not an incident: an empty wallet is an upsell, an
|
|
10
|
+
* admin-set cap is an escalation to that admin, and the two must not be collapsed into one
|
|
11
|
+
* "you have been blocked" string.
|
|
12
|
+
*/
|
|
13
|
+
export declare function remedyMessage(decision: AccessDecision | undefined): string;
|
|
14
|
+
/** One-line decision summary for logs. */
|
|
15
|
+
export declare function describeDecision(decision: Omit<AccessDecision, 'summary' | 'message'>): string;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.remedyMessage = remedyMessage;
|
|
4
|
+
exports.describeDecision = describeDecision;
|
|
5
|
+
/**
|
|
6
|
+
* Denial -> what to tell the user.
|
|
7
|
+
*
|
|
8
|
+
* An entitlement refusal is a commercial event, not an incident: an empty wallet is an upsell, an
|
|
9
|
+
* admin-set cap is an escalation to that admin, and the two must not be collapsed into one
|
|
10
|
+
* "you have been blocked" string.
|
|
11
|
+
*/
|
|
12
|
+
function remedyMessage(decision) {
|
|
13
|
+
if (!decision || decision.hasAccess)
|
|
14
|
+
return 'Allowed.';
|
|
15
|
+
const who = decision.deniedBy ? ` for ${decision.deniedBy}` : '';
|
|
16
|
+
switch (decision.remedy) {
|
|
17
|
+
case 'top-up':
|
|
18
|
+
return `Out of credits${who}. Top up the balance to continue.`;
|
|
19
|
+
case 'raise-budget':
|
|
20
|
+
return `Budget exhausted${who}. Ask whoever set this budget to raise it, or wait for the next reset.`;
|
|
21
|
+
case 'subscribe':
|
|
22
|
+
return `No entitlement${who}. An active subscription including this capability is required.`;
|
|
23
|
+
case 'retry':
|
|
24
|
+
return 'Governance is temporarily unavailable and the run was held back. Retry shortly.';
|
|
25
|
+
default:
|
|
26
|
+
return `Denied${who}${decision.reason ? ` (${decision.reason})` : ''}.`;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** One-line decision summary for logs. */
|
|
30
|
+
function describeDecision(decision) {
|
|
31
|
+
// Every field the line below is built from - remaining, who denied, which client answered - is
|
|
32
|
+
// absent on an unenforced decision, and a line assembled from what is left reads as an allow with
|
|
33
|
+
// a budget behind it. There is no budget behind it, so the line says that instead.
|
|
34
|
+
if (decision.source === 'unenforced') {
|
|
35
|
+
return 'hasAccess=true source=unenforced - this run named no entitlement, so nothing was checked';
|
|
36
|
+
}
|
|
37
|
+
const parts = [`hasAccess=${decision.hasAccess}`];
|
|
38
|
+
if (decision.remaining !== undefined)
|
|
39
|
+
parts.push(`remaining=${decision.remaining}`);
|
|
40
|
+
if (decision.deniedBy)
|
|
41
|
+
parts.push(`deniedBy=${decision.deniedBy}`);
|
|
42
|
+
if (decision.reason)
|
|
43
|
+
parts.push(`reason=${decision.reason}`);
|
|
44
|
+
if (decision.remedy)
|
|
45
|
+
parts.push(`remedy=${decision.remedy}`);
|
|
46
|
+
parts.push(`source=${decision.source}`);
|
|
47
|
+
if (decision.isFallback)
|
|
48
|
+
parts.push('fallback=true');
|
|
49
|
+
// Printed for the same reason `fallback` is: both say how much the line can be trusted, and an
|
|
50
|
+
// operator reading it back has no object to inspect - only this.
|
|
51
|
+
if (decision.locallyDerived)
|
|
52
|
+
parts.push('local=true');
|
|
53
|
+
return parts.join(' ');
|
|
54
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Logger } from '../logger';
|
|
2
|
+
import type { EngineCapabilities, EngineCheckParams, EngineCheckResult, EngineReportParams, EngineReportResult, EngineResolution, EntityInput, EntityPlacement, GovernanceEngine } from './engine';
|
|
3
|
+
export type CompositeDelegates = {
|
|
4
|
+
upsertEntity?: GovernanceEngine;
|
|
5
|
+
check: GovernanceEngine;
|
|
6
|
+
/**
|
|
7
|
+
* Serves a check that carries no attribution. With no dimensions there is no entity to resolve
|
|
8
|
+
* and no chain to lose, so the question is the customer's pool - which the cached client can
|
|
9
|
+
* answer without a round trip. Unset, every check goes to `check`.
|
|
10
|
+
*/
|
|
11
|
+
unattributedCheck?: GovernanceEngine;
|
|
12
|
+
report: GovernanceEngine;
|
|
13
|
+
archiveEntity?: GovernanceEngine;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Whether this check is about an entity at all.
|
|
17
|
+
*
|
|
18
|
+
* `dimensions` is the only attribution a check carries - the REST call sends nothing else that
|
|
19
|
+
* could resolve one - so an empty map means the governance tree is never consulted and the answer
|
|
20
|
+
* is the customer's pool either way. Registration is deliberately NOT the signal: an agent
|
|
21
|
+
* provisioned last week is governed by a run that registers nothing today.
|
|
22
|
+
*/
|
|
23
|
+
export declare const isAttributed: (params: {
|
|
24
|
+
dimensions: Record<string, string>;
|
|
25
|
+
}) => boolean;
|
|
26
|
+
export type CompositeDeps = {
|
|
27
|
+
delegates: CompositeDelegates;
|
|
28
|
+
resolution: EngineResolution;
|
|
29
|
+
/** The complete implementation. Takes over from any delegate that proves unreachable. */
|
|
30
|
+
fallback?: GovernanceEngine;
|
|
31
|
+
logger?: Logger;
|
|
32
|
+
};
|
|
33
|
+
export declare class CompositeEngine implements GovernanceEngine {
|
|
34
|
+
readonly capabilities: EngineCapabilities;
|
|
35
|
+
private readonly delegates;
|
|
36
|
+
private readonly resolution;
|
|
37
|
+
private readonly fallback?;
|
|
38
|
+
private readonly logger?;
|
|
39
|
+
/**
|
|
40
|
+
* Which `operation::client` pairs have failed in a way another transport could survive. Keyed
|
|
41
|
+
* by the pair rather than by the engine: one client can serve two operations over two different
|
|
42
|
+
* services, and a GraphQL outage must not disable an entitlement read that never went there.
|
|
43
|
+
*/
|
|
44
|
+
private readonly demoted;
|
|
45
|
+
/** What each delegate is, so a result names the client that actually answered it. */
|
|
46
|
+
private readonly sourceOf;
|
|
47
|
+
constructor(deps: CompositeDeps);
|
|
48
|
+
upsertEntity(customerId: string, entity: EntityInput, placement?: EntityPlacement): Promise<void>;
|
|
49
|
+
archiveEntity(customerId: string, entityId: string): Promise<void>;
|
|
50
|
+
check(customerId: string, params: EngineCheckParams): Promise<EngineCheckResult>;
|
|
51
|
+
report(customerId: string, params: EngineReportParams): Promise<EngineReportResult>;
|
|
52
|
+
/**
|
|
53
|
+
* Warms the delegate that would serve this check - not every delegate that holds a cache.
|
|
54
|
+
*
|
|
55
|
+
* Routed by the same rule `check` uses, because warming anything else is a round trip spent on
|
|
56
|
+
* a cache this context will never read: an attributed check under governance goes to REST,
|
|
57
|
+
* which caches nothing, so the honest answer there is to do nothing. Deliberately not run
|
|
58
|
+
* through `run()`: a warm-up that cannot reach its transport is not worth demoting an operation
|
|
59
|
+
* over, and the first real check will discover the same outage and apply the error policy.
|
|
60
|
+
*/
|
|
61
|
+
preload(customerId: string, dimensions: Record<string, string>): Promise<void>;
|
|
62
|
+
close(): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* Runs an operation on its delegate, and once - and only once per delegate - retries on the
|
|
65
|
+
* complete implementation if the failure says the transport is unreachable rather than the
|
|
66
|
+
* request wrong.
|
|
67
|
+
*/
|
|
68
|
+
private run;
|
|
69
|
+
/** Results carry the client that actually answered, which is not always the one planned. */
|
|
70
|
+
private stamp;
|
|
71
|
+
private provisioner;
|
|
72
|
+
}
|