@stigg/agent-sdk-js 0.3.2 → 0.5.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 +25 -0
- package/dist/bindings/client/providers/mistral/mistral-recipe.d.ts +2 -2
- package/dist/bindings/orchestrator/core/agent-middleware.d.ts +81 -0
- package/dist/bindings/orchestrator/core/agent-middleware.js +231 -0
- package/dist/bindings/orchestrator/langchain/callback-handler.d.ts +50 -0
- package/dist/bindings/orchestrator/langchain/callback-handler.js +151 -0
- package/dist/core/context.d.ts +27 -4
- package/dist/core/context.js +26 -5
- package/dist/core/session.d.ts +27 -0
- package/dist/core/session.js +74 -21
- package/dist/core/telemetry.d.ts +8 -0
- package/dist/core/telemetry.js +97 -0
- package/dist/engine/composite-engine.d.ts +7 -1
- package/dist/engine/composite-engine.js +9 -0
- package/dist/engine/create-engine.js +9 -2
- package/dist/engine/engine.d.ts +28 -0
- package/dist/engine/node-sdk-engine.d.ts +16 -1
- package/dist/engine/node-sdk-engine.js +38 -0
- package/dist/errors.d.ts +8 -1
- package/dist/errors.js +28 -6
- package/dist/index.d.ts +3 -1
- package/dist/index.js +5 -1
- package/dist/integrations/langchain.d.ts +14 -0
- package/dist/integrations/langchain.js +21 -0
- package/dist/stigg-agent-sdk.d.ts +43 -5
- package/dist/stigg-agent-sdk.js +106 -3
- package/dist/types.d.ts +97 -0
- package/package.json +37 -1
package/dist/core/context.js
CHANGED
|
@@ -58,15 +58,31 @@ function agentDimension(context) {
|
|
|
58
58
|
}
|
|
59
59
|
return { [context.attributionKey]: context.agentId };
|
|
60
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* A plan id as given, trimmed - or a refusal when it was given blank. Absent stays absent: no
|
|
63
|
+
* plan is a legal choice, while an empty one is a typo that would provision nobody onto nothing.
|
|
64
|
+
*/
|
|
65
|
+
function planId(value) {
|
|
66
|
+
if (value === undefined)
|
|
67
|
+
return undefined;
|
|
68
|
+
const named = typeof value === 'string' ? value.trim() : '';
|
|
69
|
+
if (!named) {
|
|
70
|
+
throw new Error('stiggContext.defaultPlanId is empty - leave the field out to provision no plan, or name the ' +
|
|
71
|
+
'plan new customers should start on.');
|
|
72
|
+
}
|
|
73
|
+
return named;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The one field a call cannot proceed without, refused the same way from every binding. Only where
|
|
77
|
+
* to put the customer differs, so the remedy is the caller's to word.
|
|
78
|
+
*/
|
|
79
|
+
const noCustomer = (remedy = 'Give `wrap(client, { stiggContext: { customerId } })` a default for every call through ' +
|
|
80
|
+
'this client, or name one per call in `stiggContext`.') => new Error(`No customerId - every governance call is scoped to a tenant. ${remedy}`);
|
|
81
|
+
exports.noCustomer = noCustomer;
|
|
61
82
|
/**
|
|
62
83
|
* Validates and derives, once. Everything expensive about a context happens here rather than on
|
|
63
84
|
* the call path - see the note at the top of this file for why that split exists.
|
|
64
85
|
*/
|
|
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
86
|
function resolveContext(context, correlationId,
|
|
71
87
|
// `wrap` passes false: a client whose calls each name their own customer is legal, and its
|
|
72
88
|
// types already say which calls still owe one. Every path that reaches the engine resolves it.
|
|
@@ -97,6 +113,7 @@ function resolveContext(context, correlationId,
|
|
|
97
113
|
const dimensions = agent ? { ...context.dimensions, ...agent } : (context.dimensions ?? {});
|
|
98
114
|
return {
|
|
99
115
|
customerId,
|
|
116
|
+
defaultPlanId: planId(context.defaultPlanId),
|
|
100
117
|
// Normalized, not passed through: `isEnforced` asks `!== undefined` while every other reader
|
|
101
118
|
// asks for truthiness, so a JS caller's `entitlement: null` would be enforced by one and
|
|
102
119
|
// absent to the rest - a call that runs the gate and then reports itself ungated.
|
|
@@ -120,6 +137,7 @@ function resolveContext(context, correlationId,
|
|
|
120
137
|
*/
|
|
121
138
|
const CONTEXT_KEYS = {
|
|
122
139
|
customerId: true,
|
|
140
|
+
defaultPlanId: true,
|
|
123
141
|
agentId: true,
|
|
124
142
|
attributionKey: true,
|
|
125
143
|
dimensions: true,
|
|
@@ -223,6 +241,9 @@ function overrideContext(base, override) {
|
|
|
223
241
|
}
|
|
224
242
|
return {
|
|
225
243
|
customerId,
|
|
244
|
+
// Independent of the customer: a call naming its own customer still provisions onto the
|
|
245
|
+
// client's plan unless it names a plan too.
|
|
246
|
+
defaultPlanId: override.defaultPlanId !== undefined ? planId(override.defaultPlanId) : base.defaultPlanId,
|
|
226
247
|
entitlement,
|
|
227
248
|
dimensions,
|
|
228
249
|
checkedId,
|
package/dist/core/session.d.ts
CHANGED
|
@@ -27,6 +27,13 @@ export type HookContext = {
|
|
|
27
27
|
correlationId: string;
|
|
28
28
|
/** What the call gated on, when it gated on anything. Undefined on a metering-only run. */
|
|
29
29
|
entitlementId?: string;
|
|
30
|
+
/**
|
|
31
|
+
* The agent that took the step, by the orchestrator's own name for it, when a middleware
|
|
32
|
+
* governs the run. Present at the customer level too, where nothing is attributed to an agent.
|
|
33
|
+
*/
|
|
34
|
+
agent?: string;
|
|
35
|
+
/** The tool a step ran, when it was a paid tool a middleware checked. */
|
|
36
|
+
tool?: string;
|
|
30
37
|
};
|
|
31
38
|
export type SessionHooks = {
|
|
32
39
|
/** Every check this client makes. The whole decision, already formatted as `.summary`. */
|
|
@@ -45,9 +52,20 @@ export type SessionDeps = {
|
|
|
45
52
|
* than disappearing.
|
|
46
53
|
*/
|
|
47
54
|
engine: () => Promise<GovernanceEngine>;
|
|
55
|
+
/**
|
|
56
|
+
* Creates the customer on their default plan if Stigg does not know them yet. Shared across
|
|
57
|
+
* every session of one SDK and remembered per customer, so only a customer's first call pays
|
|
58
|
+
* the round trip. Absent, nothing is ever provisioned.
|
|
59
|
+
*/
|
|
60
|
+
provisionCustomer?: (customerId: string, planId: string) => Promise<void>;
|
|
48
61
|
logger: Logger;
|
|
49
62
|
onError: (error: EngineError, context: ErrorContext) => void;
|
|
50
63
|
hooks: SessionHooks;
|
|
64
|
+
/**
|
|
65
|
+
* Whether each check and report is an OpenTelemetry span. On for an orchestrator's middleware,
|
|
66
|
+
* where an observability tool's trace is already open around the graph; off for `wrap`.
|
|
67
|
+
*/
|
|
68
|
+
tracing?: boolean;
|
|
51
69
|
};
|
|
52
70
|
/** What a client did, for whoever wants to print it. Spans every context it served. */
|
|
53
71
|
export type SessionSummary = {
|
|
@@ -175,12 +193,21 @@ export declare class GovernanceSession {
|
|
|
175
193
|
private warnRounded;
|
|
176
194
|
private warnIfNothingDrains;
|
|
177
195
|
private record;
|
|
196
|
+
/** Every ingest result, however it ended: to its span, then to the application's hook. */
|
|
197
|
+
private emitIngest;
|
|
178
198
|
/**
|
|
179
199
|
* Hooks are instrumentation, so a throwing one must not reach the caller: it cannot turn an
|
|
180
200
|
* allow into a denial, flip a delivered ingest to `ok: false`, or reject a promise the contract
|
|
181
201
|
* says never rejects. The failure is reported like any other.
|
|
182
202
|
*/
|
|
183
203
|
private hook;
|
|
204
|
+
/**
|
|
205
|
+
* Makes sure a customer with a default plan exists. Never throws, and answers whether Stigg was
|
|
206
|
+
* reached - false only for an outage, which is reported here and retried by the next call. A
|
|
207
|
+
* refusal is an answer: the SDK reports it once, and the call goes on to meet whatever the check
|
|
208
|
+
* says about a customer that does not exist.
|
|
209
|
+
*/
|
|
210
|
+
private provisionCustomer;
|
|
184
211
|
/**
|
|
185
212
|
* The last stop for a failure. `onError` is the application's own code and can throw too, and
|
|
186
213
|
* there is nowhere left to report that to, so it falls back to the logger.
|
package/dist/core/session.js
CHANGED
|
@@ -29,6 +29,7 @@ const errors_1 = require("../errors");
|
|
|
29
29
|
const headers_1 = require("../headers");
|
|
30
30
|
const dimensions_1 = require("./dimensions");
|
|
31
31
|
const hooks_1 = require("./hooks");
|
|
32
|
+
const telemetry_1 = require("./telemetry");
|
|
32
33
|
const context_1 = require("./context");
|
|
33
34
|
const types_1 = require("../types");
|
|
34
35
|
/** What a context looks like to a hook: the attribution, and nothing a hook could change. */
|
|
@@ -37,6 +38,8 @@ const hookContext = (context) => ({
|
|
|
37
38
|
dimensions: context.dimensions,
|
|
38
39
|
correlationId: context.correlationId,
|
|
39
40
|
entitlementId: context.checkedId,
|
|
41
|
+
...(context.agent !== undefined ? { agent: context.agent } : {}),
|
|
42
|
+
...(context.tool !== undefined ? { tool: context.tool } : {}),
|
|
40
43
|
});
|
|
41
44
|
/** A metric nobody can bill: refused where the mapper that invented it can still be found. */
|
|
42
45
|
const emptyMetricNameError = () => new errors_1.EngineError('measure', 'a metric needs a name - an empty one reports to no feature at all.');
|
|
@@ -49,6 +52,8 @@ const emptyMetricNameError = () => new errors_1.EngineError('measure', 'a metric
|
|
|
49
52
|
* `'async'` report would reach the event API with no name at all.
|
|
50
53
|
*/
|
|
51
54
|
const DEFAULT_EVENT_NAME = 'agent.usage';
|
|
55
|
+
/** How both engines say a check found no such customer - the reason, verbatim, from the API. */
|
|
56
|
+
const CUSTOMER_NOT_FOUND = 'CustomerNotFound';
|
|
52
57
|
class GovernanceSession {
|
|
53
58
|
deps;
|
|
54
59
|
pending = new Set();
|
|
@@ -93,6 +98,8 @@ class GovernanceSession {
|
|
|
93
98
|
* applies the error policy.
|
|
94
99
|
*/
|
|
95
100
|
async warmUp(context) {
|
|
101
|
+
// First, so the cache below loads a customer that exists rather than remembering one that did not.
|
|
102
|
+
await this.provisionCustomer(context);
|
|
96
103
|
if (!(0, context_1.isEnforced)(context))
|
|
97
104
|
return;
|
|
98
105
|
try {
|
|
@@ -107,28 +114,40 @@ class GovernanceSession {
|
|
|
107
114
|
* application routes on, not an exception it recovers from.
|
|
108
115
|
*/
|
|
109
116
|
async check(context, requestedUsage = 1) {
|
|
117
|
+
const startedAt = Date.now();
|
|
110
118
|
this.checks += 1;
|
|
111
119
|
const { entitlement } = context;
|
|
112
120
|
// Asked and answered, with nobody to ask: this context named no entitlement, so there is no
|
|
113
121
|
// budget to consult and no refusal that could come back. Still counted and recorded, because
|
|
114
122
|
// the caller did ask and `lastDecision` is where they read what they got.
|
|
115
123
|
if (!entitlement)
|
|
116
|
-
return this.record((0, decision_1.unenforcedDecision)(), context);
|
|
124
|
+
return this.record((0, decision_1.unenforcedDecision)(), context, startedAt);
|
|
117
125
|
try {
|
|
118
126
|
const engine = await this.deps.engine();
|
|
127
|
+
// Before the check, not after: a customer Stigg has never seen holds no entitlement, so their
|
|
128
|
+
// first call would be refused for not existing yet.
|
|
129
|
+
const reached = await this.provisionCustomer(context);
|
|
119
130
|
const result = await engine.check(context.customerId, {
|
|
120
131
|
entitlement,
|
|
121
132
|
requestedUsage,
|
|
122
133
|
dimensions: context.dimensions,
|
|
123
134
|
});
|
|
124
|
-
|
|
135
|
+
// The policy answers only when both halves say so: Stigg could not be reached to create the
|
|
136
|
+
// customer, and the check then found no customer. Either alone is not that. An existing
|
|
137
|
+
// customer gets the real answer whatever happened to the create, and a customer Stigg
|
|
138
|
+
// refused - an unknown plan, an archived customer - is refused here too, rather than
|
|
139
|
+
// waved through by an `allow` that exists for outages, not for configuration mistakes.
|
|
140
|
+
if (!reached && result.accessDeniedReason === CUSTOMER_NOT_FOUND) {
|
|
141
|
+
return this.record((0, decision_1.policyDecision)(context.checkErrorAction === 'allow'), context, startedAt);
|
|
142
|
+
}
|
|
143
|
+
return this.record((0, decision_1.toDecision)(result, { customerId: context.customerId, entitlement: context.entitlement }), context, startedAt);
|
|
125
144
|
}
|
|
126
145
|
catch (err) {
|
|
127
146
|
const error = asEngineError('check', err);
|
|
128
147
|
this.reportError(error, 'check', context);
|
|
129
148
|
// Governance is fail-closed server-side; `'deny'` mirrors that, and `'allow'` - the default -
|
|
130
149
|
// 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);
|
|
150
|
+
return this.record((0, decision_1.policyDecision)(context.checkErrorAction === 'allow'), context, startedAt);
|
|
132
151
|
}
|
|
133
152
|
}
|
|
134
153
|
/** Applies the context's mapper to a token measurement. The path every binding uses. */
|
|
@@ -201,6 +220,7 @@ class GovernanceSession {
|
|
|
201
220
|
send(context, metrics, sendOptions) {
|
|
202
221
|
const { dimensions, idempotencyKey, raw, event } = sendOptions;
|
|
203
222
|
const mode = context.ingestMode;
|
|
223
|
+
const startedAt = Date.now();
|
|
204
224
|
// A broken value is one broken metric, not a broken call: the others measured fine and the
|
|
205
225
|
// spend they describe already happened. Dropping only the bad one reports what is knowable.
|
|
206
226
|
//
|
|
@@ -245,25 +265,29 @@ class GovernanceSession {
|
|
|
245
265
|
// distinguishable from one that never tried, and reported to `onIngest` like every result.
|
|
246
266
|
if (!Object.keys(sendable).length) {
|
|
247
267
|
if (rejected)
|
|
248
|
-
return Promise.resolve(this.failedIngest(context, idempotencyKey, rejected));
|
|
268
|
+
return Promise.resolve(this.failedIngest(context, idempotencyKey, rejected, startedAt));
|
|
249
269
|
this.ingested += 1;
|
|
250
270
|
const noop = { ok: true, reported: {}, idempotencyKey, mode, replayed: false };
|
|
251
|
-
this.
|
|
271
|
+
this.emitIngest(context, noop, startedAt);
|
|
252
272
|
return Promise.resolve(noop);
|
|
253
273
|
}
|
|
254
274
|
const promise = this.deps
|
|
255
275
|
.engine()
|
|
256
|
-
.then((engine) =>
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
276
|
+
.then(async (engine) => {
|
|
277
|
+
// A metering-only client never checks, so this is the first place its customer is seen.
|
|
278
|
+
await this.provisionCustomer(context);
|
|
279
|
+
return engine.report(context.customerId, {
|
|
280
|
+
metrics: sendable,
|
|
281
|
+
entitlement: context.entitlement,
|
|
282
|
+
mode,
|
|
283
|
+
// Flattened here rather than in the engine, so both transports send the same event and a
|
|
284
|
+
// sync report pays nothing for a field it will not use.
|
|
285
|
+
raw: mode === 'async' ? (0, dimensions_1.flattenRaw)(raw) : undefined,
|
|
286
|
+
dimensions,
|
|
287
|
+
idempotencyKey,
|
|
288
|
+
event: event ?? { name: DEFAULT_EVENT_NAME },
|
|
289
|
+
});
|
|
290
|
+
})
|
|
267
291
|
.then((result) => {
|
|
268
292
|
this.ingested += 1;
|
|
269
293
|
// A replay is a delivery that consumed nothing - the server had already counted these
|
|
@@ -292,10 +316,10 @@ class GovernanceSession {
|
|
|
292
316
|
this.ingestFailures += 1;
|
|
293
317
|
this.reportError(rejected, rejected.operation, context, idempotencyKey);
|
|
294
318
|
}
|
|
295
|
-
this.
|
|
319
|
+
this.emitIngest(context, landed, startedAt);
|
|
296
320
|
return landed;
|
|
297
321
|
})
|
|
298
|
-
.catch((err) => this.failedIngest(context, idempotencyKey, asEngineError('ingest', err)))
|
|
322
|
+
.catch((err) => this.failedIngest(context, idempotencyKey, asEngineError('ingest', err), startedAt))
|
|
299
323
|
.finally(() => {
|
|
300
324
|
this.pending.delete(promise);
|
|
301
325
|
});
|
|
@@ -384,11 +408,22 @@ class GovernanceSession {
|
|
|
384
408
|
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
409
|
'against will never drain. Map it in Stigg, or report to the currency directly.');
|
|
386
410
|
}
|
|
387
|
-
record(decision, context) {
|
|
411
|
+
record(decision, context, startedAt) {
|
|
388
412
|
this.decision = decision;
|
|
413
|
+
// Telemetry is instrumentation exactly as a hook is: it never reaches the caller.
|
|
414
|
+
if (this.deps.tracing) {
|
|
415
|
+
(0, hooks_1.safely)(() => (0, telemetry_1.traceCheck)(context, decision, startedAt), (err) => this.deps.logger.debug(`[stigg] check span failed: ${String(err)}`));
|
|
416
|
+
}
|
|
389
417
|
this.hook('onDecision', context, () => this.deps.hooks.onDecision?.(decision, hookContext(context)));
|
|
390
418
|
return decision;
|
|
391
419
|
}
|
|
420
|
+
/** Every ingest result, however it ended: to its span, then to the application's hook. */
|
|
421
|
+
emitIngest(context, result, startedAt) {
|
|
422
|
+
if (this.deps.tracing) {
|
|
423
|
+
(0, hooks_1.safely)(() => (0, telemetry_1.traceIngest)(context, result, startedAt), (err) => this.deps.logger.debug(`[stigg] ingest span failed: ${String(err)}`));
|
|
424
|
+
}
|
|
425
|
+
this.hook('onIngest', context, () => this.deps.hooks.onIngest?.(result, hookContext(context)));
|
|
426
|
+
}
|
|
392
427
|
/**
|
|
393
428
|
* Hooks are instrumentation, so a throwing one must not reach the caller: it cannot turn an
|
|
394
429
|
* allow into a denial, flip a delivered ingest to `ok: false`, or reject a promise the contract
|
|
@@ -397,6 +432,24 @@ class GovernanceSession {
|
|
|
397
432
|
hook(name, context, invoke) {
|
|
398
433
|
(0, hooks_1.safely)(invoke, (err) => this.reportError(asEngineError(name, err), name, context));
|
|
399
434
|
}
|
|
435
|
+
/**
|
|
436
|
+
* Makes sure a customer with a default plan exists. Never throws, and answers whether Stigg was
|
|
437
|
+
* reached - false only for an outage, which is reported here and retried by the next call. A
|
|
438
|
+
* refusal is an answer: the SDK reports it once, and the call goes on to meet whatever the check
|
|
439
|
+
* says about a customer that does not exist.
|
|
440
|
+
*/
|
|
441
|
+
async provisionCustomer(context) {
|
|
442
|
+
if (!context.defaultPlanId || !context.customerId || !this.deps.provisionCustomer)
|
|
443
|
+
return true;
|
|
444
|
+
try {
|
|
445
|
+
await this.deps.provisionCustomer(context.customerId, context.defaultPlanId);
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
catch (err) {
|
|
449
|
+
this.reportError(asEngineError('provisionCustomer', err), 'provisionCustomer', context);
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
400
453
|
/**
|
|
401
454
|
* The last stop for a failure. `onError` is the application's own code and can throw too, and
|
|
402
455
|
* there is nowhere left to report that to, so it falls back to the logger.
|
|
@@ -409,7 +462,7 @@ class GovernanceSession {
|
|
|
409
462
|
customerId: context?.customerId,
|
|
410
463
|
}), (err) => this.deps.logger.warn(`[stigg] onError threw while reporting ${error.message}: ${String(err)}`));
|
|
411
464
|
}
|
|
412
|
-
failedIngest(context, idempotencyKey, error) {
|
|
465
|
+
failedIngest(context, idempotencyKey, error, startedAt = Date.now()) {
|
|
413
466
|
this.ingestFailures += 1;
|
|
414
467
|
// The error knows what failed - reading a vendor's usage is not the same event as the report
|
|
415
468
|
// that would have followed it, and a context that says otherwise makes the two
|
|
@@ -435,7 +488,7 @@ class GovernanceSession {
|
|
|
435
488
|
replayed: error.replayed ?? false,
|
|
436
489
|
error,
|
|
437
490
|
};
|
|
438
|
-
this.
|
|
491
|
+
this.emitIngest(context, failure, startedAt);
|
|
439
492
|
return failure;
|
|
440
493
|
}
|
|
441
494
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AccessDecision, IngestResult } from '../types';
|
|
2
|
+
import type { ResolvedContext } from './context';
|
|
3
|
+
/** The instrumentation scope every Stigg span is reported under - what a span filter matches on. */
|
|
4
|
+
export declare const TRACER_NAME = "@stigg/agent-sdk-js";
|
|
5
|
+
/** One check, from when it was asked to when it was answered. */
|
|
6
|
+
export declare function traceCheck(context: ResolvedContext, decision: AccessDecision, startedAt: number): void;
|
|
7
|
+
/** One report, whether it landed, failed, or had nothing to send. */
|
|
8
|
+
export declare function traceIngest(context: ResolvedContext, result: IngestResult, startedAt: number): void;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TRACER_NAME = void 0;
|
|
4
|
+
exports.traceCheck = traceCheck;
|
|
5
|
+
exports.traceIngest = traceIngest;
|
|
6
|
+
const sdk_identity_1 = require("../sdk-identity");
|
|
7
|
+
/** The instrumentation scope every Stigg span is reported under - what a span filter matches on. */
|
|
8
|
+
exports.TRACER_NAME = '@stigg/agent-sdk-js';
|
|
9
|
+
let api;
|
|
10
|
+
/** The application's copy of the API, loaded once - or `null` when it has none installed. */
|
|
11
|
+
function openTelemetry() {
|
|
12
|
+
if (api === undefined) {
|
|
13
|
+
try {
|
|
14
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
15
|
+
api = require('@opentelemetry/api');
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
api = null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return api;
|
|
22
|
+
}
|
|
23
|
+
/** Asked for per span, so a provider registered after this package was loaded is still found. */
|
|
24
|
+
const tracer = (otel) => otel.trace.getTracer(exports.TRACER_NAME, sdk_identity_1.AGENT_SDK_VERSION);
|
|
25
|
+
/** Who the call was for: the attribution, as the check and the report both carry it. */
|
|
26
|
+
function attribution(context) {
|
|
27
|
+
const attributes = {
|
|
28
|
+
'stigg.customer.id': context.customerId,
|
|
29
|
+
'stigg.correlation_id': context.correlationId,
|
|
30
|
+
};
|
|
31
|
+
if (context.agent !== undefined)
|
|
32
|
+
attributes['gen_ai.agent.name'] = context.agent;
|
|
33
|
+
if (context.tool !== undefined)
|
|
34
|
+
attributes['gen_ai.tool.name'] = context.tool;
|
|
35
|
+
if (context.attributionKey && context.dimensions[context.attributionKey] !== undefined) {
|
|
36
|
+
attributes['stigg.agent.id'] = context.dimensions[context.attributionKey];
|
|
37
|
+
}
|
|
38
|
+
for (const [key, value] of Object.entries(context.dimensions))
|
|
39
|
+
attributes[`stigg.dimension.${key}`] = value;
|
|
40
|
+
if (context.eventName)
|
|
41
|
+
attributes['stigg.event.name'] = context.eventName.name;
|
|
42
|
+
return attributes;
|
|
43
|
+
}
|
|
44
|
+
/** One check, from when it was asked to when it was answered. */
|
|
45
|
+
function traceCheck(context, decision, startedAt) {
|
|
46
|
+
const otel = openTelemetry();
|
|
47
|
+
if (!otel)
|
|
48
|
+
return;
|
|
49
|
+
const attributes = {
|
|
50
|
+
...attribution(context),
|
|
51
|
+
'stigg.decision.has_access': decision.hasAccess,
|
|
52
|
+
'stigg.decision.source': decision.source,
|
|
53
|
+
};
|
|
54
|
+
if (context.checkedId)
|
|
55
|
+
attributes['stigg.entitlement.id'] = context.checkedId;
|
|
56
|
+
if (decision.reason)
|
|
57
|
+
attributes['stigg.decision.reason'] = decision.reason;
|
|
58
|
+
if (decision.deniedBy)
|
|
59
|
+
attributes['stigg.decision.denied_by'] = decision.deniedBy;
|
|
60
|
+
if (decision.remaining !== undefined)
|
|
61
|
+
attributes['stigg.decision.remaining'] = decision.remaining;
|
|
62
|
+
if (decision.remedy)
|
|
63
|
+
attributes['stigg.decision.remedy'] = decision.remedy;
|
|
64
|
+
if (decision.isFallback)
|
|
65
|
+
attributes['stigg.decision.is_fallback'] = true;
|
|
66
|
+
const span = tracer(otel).startSpan('stigg.check', { startTime: startedAt, attributes });
|
|
67
|
+
if (!decision.hasAccess) {
|
|
68
|
+
span.recordException({ name: 'AccessDeniedError', message: decision.message });
|
|
69
|
+
span.setStatus({ code: otel.SpanStatusCode.ERROR, message: decision.summary });
|
|
70
|
+
}
|
|
71
|
+
span.end();
|
|
72
|
+
}
|
|
73
|
+
/** One report, whether it landed, failed, or had nothing to send. */
|
|
74
|
+
function traceIngest(context, result, startedAt) {
|
|
75
|
+
const otel = openTelemetry();
|
|
76
|
+
if (!otel)
|
|
77
|
+
return;
|
|
78
|
+
const attributes = {
|
|
79
|
+
...attribution(context),
|
|
80
|
+
'stigg.ingest.ok': result.ok,
|
|
81
|
+
'stigg.ingest.mode': result.mode,
|
|
82
|
+
'stigg.ingest.idempotency_key': result.idempotencyKey,
|
|
83
|
+
};
|
|
84
|
+
for (const [metric, value] of Object.entries(result.reported))
|
|
85
|
+
attributes[`stigg.metric.${metric}`] = value;
|
|
86
|
+
if (result.creditsConsumed !== undefined)
|
|
87
|
+
attributes['stigg.ingest.credits_consumed'] = result.creditsConsumed;
|
|
88
|
+
if (result.replayed !== undefined)
|
|
89
|
+
attributes['stigg.ingest.replayed'] = result.replayed;
|
|
90
|
+
const span = tracer(otel).startSpan('stigg.ingest', { startTime: startedAt, attributes });
|
|
91
|
+
if (!result.ok) {
|
|
92
|
+
const message = result.error instanceof Error ? result.error.message : String(result.error ?? 'ingest failed');
|
|
93
|
+
span.recordException({ name: 'EngineError', message });
|
|
94
|
+
span.setStatus({ code: otel.SpanStatusCode.ERROR, message });
|
|
95
|
+
}
|
|
96
|
+
span.end();
|
|
97
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Logger } from '../logger';
|
|
2
|
-
import type { EngineCapabilities, EngineCheckParams, EngineCheckResult, EngineReportParams, EngineReportResult, EngineResolution, EntityInput, EntityPlacement, GovernanceEngine } from './engine';
|
|
2
|
+
import type { EngineCapabilities, EngineCheckParams, EngineCheckResult, EngineReportParams, EngineReportResult, EngineResolution, EntityInput, EntityPlacement, GovernanceEngine, ProvisionOutcome } from './engine';
|
|
3
3
|
export type CompositeDelegates = {
|
|
4
4
|
upsertEntity?: GovernanceEngine;
|
|
5
5
|
check: GovernanceEngine;
|
|
@@ -11,6 +11,11 @@ export type CompositeDelegates = {
|
|
|
11
11
|
unattributedCheck?: GovernanceEngine;
|
|
12
12
|
report: GovernanceEngine;
|
|
13
13
|
archiveEntity?: GovernanceEngine;
|
|
14
|
+
/**
|
|
15
|
+
* Creates customers on their default plan. The Node SDK, so the plan it answers with lands in
|
|
16
|
+
* the cache the next check reads. A customer API, so it needs no governance.
|
|
17
|
+
*/
|
|
18
|
+
provisionCustomer?: GovernanceEngine;
|
|
14
19
|
};
|
|
15
20
|
/**
|
|
16
21
|
* Whether this check is about an entity at all.
|
|
@@ -47,6 +52,7 @@ export declare class CompositeEngine implements GovernanceEngine {
|
|
|
47
52
|
constructor(deps: CompositeDeps);
|
|
48
53
|
upsertEntity(customerId: string, entity: EntityInput, placement?: EntityPlacement): Promise<void>;
|
|
49
54
|
archiveEntity(customerId: string, entityId: string): Promise<void>;
|
|
55
|
+
provisionCustomer(customerId: string, planId: string): Promise<ProvisionOutcome>;
|
|
50
56
|
check(customerId: string, params: EngineCheckParams): Promise<EngineCheckResult>;
|
|
51
57
|
report(customerId: string, params: EngineReportParams): Promise<EngineReportResult>;
|
|
52
58
|
/**
|
|
@@ -70,6 +70,15 @@ class CompositeEngine {
|
|
|
70
70
|
async archiveEntity(customerId, entityId) {
|
|
71
71
|
await this.run('archiveEntity', this.provisioner(), (engine) => engine.archiveEntity(customerId, entityId));
|
|
72
72
|
}
|
|
73
|
+
async provisionCustomer(customerId, planId) {
|
|
74
|
+
const delegate = this.delegates.provisionCustomer;
|
|
75
|
+
if (!delegate?.provisionCustomer) {
|
|
76
|
+
throw new errors_2.EngineError('provisionCustomer', 'no client here can create customers.');
|
|
77
|
+
}
|
|
78
|
+
// Not through `run()`: its fallback is REST, which does not provision customers, so there is
|
|
79
|
+
// nothing to re-route to - an unreachable Node SDK fails here and the call's own check decides.
|
|
80
|
+
return delegate.provisionCustomer(customerId, planId);
|
|
81
|
+
}
|
|
73
82
|
async check(customerId, params) {
|
|
74
83
|
const delegate = (0, exports.isAttributed)(params)
|
|
75
84
|
? this.delegates.check
|
|
@@ -61,7 +61,7 @@ async function createGovernanceEngine(config) {
|
|
|
61
61
|
const resolution = customerLevel('governance not enabled for this account');
|
|
62
62
|
return {
|
|
63
63
|
engine: new composite_engine_1.CompositeEngine({
|
|
64
|
-
delegates: { check: nodeSdk, report: nodeSdk },
|
|
64
|
+
delegates: { check: nodeSdk, report: nodeSdk, provisionCustomer: nodeSdk },
|
|
65
65
|
fallback: rest,
|
|
66
66
|
resolution,
|
|
67
67
|
logger: config.logger,
|
|
@@ -78,7 +78,14 @@ async function createGovernanceEngine(config) {
|
|
|
78
78
|
engine: new composite_engine_1.CompositeEngine({
|
|
79
79
|
// An attributed check goes to REST because only it returns chains; an unattributed one has
|
|
80
80
|
// no chain to lose, and ingest prefers the cached SDK either way.
|
|
81
|
-
delegates: {
|
|
81
|
+
delegates: {
|
|
82
|
+
upsertEntity: rest,
|
|
83
|
+
check: rest,
|
|
84
|
+
unattributedCheck: nodeSdk,
|
|
85
|
+
report: nodeSdk,
|
|
86
|
+
archiveEntity: rest,
|
|
87
|
+
provisionCustomer: nodeSdk,
|
|
88
|
+
},
|
|
82
89
|
fallback: rest,
|
|
83
90
|
resolution,
|
|
84
91
|
logger: config.logger,
|
package/dist/engine/engine.d.ts
CHANGED
|
@@ -133,12 +133,40 @@ export type EntityInput = {
|
|
|
133
133
|
displayName?: string;
|
|
134
134
|
metadata?: Record<string, string>;
|
|
135
135
|
};
|
|
136
|
+
/** What provisioning a customer came to. Every variant is an answer from Stigg. */
|
|
137
|
+
export type ProvisionOutcome = {
|
|
138
|
+
status: 'created';
|
|
139
|
+
/** How the server chose the first subscription - `REQUESTED_PLAN` when it took ours. */
|
|
140
|
+
decision: string;
|
|
141
|
+
/** The plan the customer actually landed on, if they landed on one. */
|
|
142
|
+
planId?: string;
|
|
143
|
+
} | {
|
|
144
|
+
status: 'existing';
|
|
145
|
+
}
|
|
146
|
+
/** Refused, for a reason a retry would not change: an unknown plan, a key that cannot write. */
|
|
147
|
+
| {
|
|
148
|
+
status: 'rejected';
|
|
149
|
+
reason: string;
|
|
150
|
+
code?: string;
|
|
151
|
+
};
|
|
136
152
|
export interface GovernanceEngine {
|
|
137
153
|
readonly capabilities: EngineCapabilities;
|
|
138
154
|
upsertEntity(customerId: string, entity: EntityInput, placement?: EntityPlacement): Promise<void>;
|
|
139
155
|
check(customerId: string, params: EngineCheckParams): Promise<EngineCheckResult>;
|
|
140
156
|
report(customerId: string, params: EngineReportParams): Promise<EngineReportResult>;
|
|
141
157
|
archiveEntity(customerId: string, entityId: string): Promise<void>;
|
|
158
|
+
/**
|
|
159
|
+
* Creates the customer on `planId` when Stigg does not know them yet, and does nothing when it
|
|
160
|
+
* does. `existing` is the common answer, and it leaves the customer's subscriptions alone: a
|
|
161
|
+
* default plan is where a new customer starts, never something an existing one is moved onto.
|
|
162
|
+
*
|
|
163
|
+
* Resolves for every answer Stigg gave - including `rejected`, a request it refused - and
|
|
164
|
+
* throws only when Stigg could not be reached, which is the one failure worth trying again.
|
|
165
|
+
*
|
|
166
|
+
* Optional: an engine with no customer API cannot provision, and the caller treats its absence
|
|
167
|
+
* as having nothing to do.
|
|
168
|
+
*/
|
|
169
|
+
provisionCustomer?(customerId: string, planId: string): Promise<ProvisionOutcome>;
|
|
142
170
|
/**
|
|
143
171
|
* Loads whatever state this engine caches for a customer, so the checks that follow are local.
|
|
144
172
|
*
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* configuration passed straight through rather than re-described here.
|
|
14
14
|
*/
|
|
15
15
|
import Stigg, { type ClientConfiguration } from '@stigg/node-server-sdk';
|
|
16
|
-
import type { EngineCapabilities, EngineCheckParams, EngineCheckResult, EngineReportParams, EngineReportResult, GovernanceEngine } from './engine';
|
|
16
|
+
import type { EngineCapabilities, EngineCheckParams, EngineCheckResult, EngineReportParams, EngineReportResult, GovernanceEngine, ProvisionOutcome } from './engine';
|
|
17
17
|
/**
|
|
18
18
|
* Caching - all of it, and none of it implemented here.
|
|
19
19
|
*
|
|
@@ -84,6 +84,21 @@ export declare class NodeSdkEngine implements GovernanceEngine {
|
|
|
84
84
|
* amount happened to warm it.
|
|
85
85
|
*/
|
|
86
86
|
preload(customerId: string): Promise<void>;
|
|
87
|
+
/**
|
|
88
|
+
* One mutation, no lookup first. Stigg refuses to provision a customer it already has - the
|
|
89
|
+
* whole mutation, subscription included - so an existing customer comes back as a duplicate and
|
|
90
|
+
* is left exactly as they were. Reading first would only add a round trip to every customer's
|
|
91
|
+
* first call in each process, to learn what the create already says.
|
|
92
|
+
*
|
|
93
|
+
* Here rather than on REST because the Node SDK creates the customer and the subscription in one
|
|
94
|
+
* mutation, and writes the entitlements it answers with straight into the cache - so the check
|
|
95
|
+
* that follows reads the new plan locally instead of fetching it.
|
|
96
|
+
*
|
|
97
|
+
* Throws only when Stigg did not answer. Any answer - created, duplicate, or refused - resolves,
|
|
98
|
+
* because the caller treats them differently: a refusal will be the same next time, and an
|
|
99
|
+
* unreachable server may not.
|
|
100
|
+
*/
|
|
101
|
+
provisionCustomer(customerId: string, planId: string): Promise<ProvisionOutcome>;
|
|
87
102
|
upsertEntity(): Promise<void>;
|
|
88
103
|
archiveEntity(): Promise<void>;
|
|
89
104
|
/**
|
|
@@ -52,6 +52,11 @@ const node_server_sdk_1 = __importStar(require("@stigg/node-server-sdk"));
|
|
|
52
52
|
const errors_1 = require("../errors");
|
|
53
53
|
const sdk_identity_1 = require("../sdk-identity");
|
|
54
54
|
const report_1 = require("./report");
|
|
55
|
+
/**
|
|
56
|
+
* The API's own code for an error it answered with. Absent when nothing answered - a refused
|
|
57
|
+
* connection, a dropped socket - which is what tells a refusal apart from an outage.
|
|
58
|
+
*/
|
|
59
|
+
const errorCode = (err) => err?.errorCode;
|
|
55
60
|
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
56
61
|
/**
|
|
57
62
|
* Drops keys that were never set, so a spread cannot un-set a default.
|
|
@@ -133,6 +138,39 @@ class NodeSdkEngine {
|
|
|
133
138
|
async preload(customerId) {
|
|
134
139
|
await this.client.reloadEntitlements(customerId);
|
|
135
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* One mutation, no lookup first. Stigg refuses to provision a customer it already has - the
|
|
143
|
+
* whole mutation, subscription included - so an existing customer comes back as a duplicate and
|
|
144
|
+
* is left exactly as they were. Reading first would only add a round trip to every customer's
|
|
145
|
+
* first call in each process, to learn what the create already says.
|
|
146
|
+
*
|
|
147
|
+
* Here rather than on REST because the Node SDK creates the customer and the subscription in one
|
|
148
|
+
* mutation, and writes the entitlements it answers with straight into the cache - so the check
|
|
149
|
+
* that follows reads the new plan locally instead of fetching it.
|
|
150
|
+
*
|
|
151
|
+
* Throws only when Stigg did not answer. Any answer - created, duplicate, or refused - resolves,
|
|
152
|
+
* because the caller treats them differently: a refusal will be the same next time, and an
|
|
153
|
+
* unreachable server may not.
|
|
154
|
+
*/
|
|
155
|
+
async provisionCustomer(customerId, planId) {
|
|
156
|
+
try {
|
|
157
|
+
const provisioned = await this.client.provisionCustomer({ customerId, subscriptionParams: { planId } });
|
|
158
|
+
return {
|
|
159
|
+
status: 'created',
|
|
160
|
+
decision: provisioned.subscriptionDecisionStrategy,
|
|
161
|
+
planId: provisioned.subscription?.planId,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
const code = errorCode(err);
|
|
166
|
+
if (code === 'DuplicatedEntityNotAllowed')
|
|
167
|
+
return { status: 'existing' };
|
|
168
|
+
if (code && code !== 'RequestTimeout') {
|
|
169
|
+
return { status: 'rejected', code, reason: err instanceof Error ? err.message : String(err) };
|
|
170
|
+
}
|
|
171
|
+
throw (0, errors_1.asEngineError)('provisionCustomer', err);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
136
174
|
async upsertEntity() {
|
|
137
175
|
throw new errors_1.GovernanceNotEnabledError('The Node server SDK has no governance surface; entities need the REST client.');
|
|
138
176
|
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -50,7 +50,14 @@ export declare class EngineError extends Error {
|
|
|
50
50
|
export declare class GovernanceNotEnabledError extends Error {
|
|
51
51
|
constructor(detail?: string);
|
|
52
52
|
}
|
|
53
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Returns the decision when `err` is a governance denial - the one-liner for a binding catch.
|
|
55
|
+
*
|
|
56
|
+
* Frameworks rarely let an error through untouched. Vendor SDKs wrap callback errors, LangChain
|
|
57
|
+
* wraps a model call's error once per middleware, and LangGraph reports two branches that failed
|
|
58
|
+
* in one step as an `AggregateError` - so the denial is looked for down the whole `cause` chain and
|
|
59
|
+
* inside every aggregated error, not only at the top.
|
|
60
|
+
*/
|
|
54
61
|
export declare function isAccessDenied(err: unknown): AccessDecision | undefined;
|
|
55
62
|
export declare const isEngineError: (err: unknown) => err is EngineError;
|
|
56
63
|
export declare const isGovernanceNotEnabled: (err: unknown) => err is GovernanceNotEnabledError;
|