@stigg/agent-sdk-js 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +135 -43
  2. package/dist/bindings/client/gateways/bedrock/bedrock-recipe.d.ts +17 -0
  3. package/dist/bindings/client/gateways/bedrock/bedrock-recipe.js +134 -0
  4. package/dist/bindings/client/gateways/bedrock/types.d.ts +13 -0
  5. package/dist/bindings/client/gateways/bedrock/types.js +2 -0
  6. package/dist/bindings/client/gateways/openrouter/openrouter-recipe.d.ts +5 -0
  7. package/dist/bindings/client/gateways/openrouter/openrouter-recipe.js +137 -34
  8. package/dist/bindings/client/gateways/openrouter/sdk-usage.d.ts +37 -0
  9. package/dist/bindings/client/gateways/openrouter/sdk-usage.js +63 -0
  10. package/dist/bindings/client/gateways/openrouter/types.d.ts +49 -0
  11. package/dist/bindings/client/providers/anthropic/anthropic-recipe.d.ts +5 -1
  12. package/dist/bindings/client/providers/anthropic/anthropic-recipe.js +103 -13
  13. package/dist/bindings/shared/token-usage.d.ts +30 -1
  14. package/dist/bindings/shared/token-usage.js +58 -3
  15. package/dist/bindings/shared/types.d.ts +16 -0
  16. package/dist/bindings/shared/weighted-metrics.js +5 -2
  17. package/dist/config.d.ts +26 -17
  18. package/dist/config.js +29 -27
  19. package/dist/core/context.d.ts +21 -12
  20. package/dist/core/context.js +76 -9
  21. package/dist/core/session.d.ts +38 -9
  22. package/dist/core/session.js +57 -12
  23. package/dist/decision-text.d.ts +3 -3
  24. package/dist/engine/decision.d.ts +11 -6
  25. package/dist/engine/decision.js +9 -2
  26. package/dist/engine/report.js +4 -1
  27. package/dist/errors.d.ts +14 -6
  28. package/dist/errors.js +13 -3
  29. package/dist/headers.d.ts +13 -0
  30. package/dist/headers.js +20 -1
  31. package/dist/index.d.ts +8 -5
  32. package/dist/index.js +8 -2
  33. package/dist/stigg-agent-sdk.d.ts +29 -13
  34. package/dist/stigg-agent-sdk.js +103 -56
  35. package/dist/types.d.ts +124 -35
  36. package/dist/types.js +66 -16
  37. package/package.json +5 -2
@@ -31,6 +31,13 @@ const dimensions_1 = require("./dimensions");
31
31
  const hooks_1 = require("./hooks");
32
32
  const context_1 = require("./context");
33
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
+ });
34
41
  /** A metric nobody can bill: refused where the mapper that invented it can still be found. */
35
42
  const emptyMetricNameError = () => new errors_1.EngineError('measure', 'a metric needs a name - an empty one reports to no feature at all.');
36
43
  /**
@@ -53,6 +60,8 @@ class GovernanceSession {
53
60
  decision;
54
61
  /** The unmapped-feature warning is said once per client, not once per call. */
55
62
  warnedUnmapped = false;
63
+ /** Said once per client: the reason is the same every call, and so is the fix. */
64
+ warnedRounded = false;
56
65
  constructor(deps) {
57
66
  this.deps = deps;
58
67
  }
@@ -65,6 +74,10 @@ class GovernanceSession {
65
74
  [headers_1.GOVERNANCE_HEADERS.customer]: context.customerId,
66
75
  [headers_1.GOVERNANCE_HEADERS.dimensions]: JSON.stringify(context.dimensions),
67
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 ?? '',
68
81
  };
69
82
  }
70
83
  /**
@@ -100,7 +113,7 @@ class GovernanceSession {
100
113
  // budget to consult and no refusal that could come back. Still counted and recorded, because
101
114
  // the caller did ask and `lastDecision` is where they read what they got.
102
115
  if (!entitlement)
103
- return this.record((0, decision_1.unenforcedDecision)());
116
+ return this.record((0, decision_1.unenforcedDecision)(), context);
104
117
  try {
105
118
  const engine = await this.deps.engine();
106
119
  const result = await engine.check(context.customerId, {
@@ -108,14 +121,14 @@ class GovernanceSession {
108
121
  requestedUsage,
109
122
  dimensions: context.dimensions,
110
123
  });
111
- return this.record((0, decision_1.toDecision)(result, { customerId: context.customerId }));
124
+ return this.record((0, decision_1.toDecision)(result, { customerId: context.customerId, entitlement: context.entitlement }), context);
112
125
  }
113
126
  catch (err) {
114
127
  const error = asEngineError('check', err);
115
128
  this.reportError(error, 'check', context);
116
129
  // Governance is fail-closed server-side; `'deny'` mirrors that, and `'allow'` - the default -
117
130
  // is the soft limit that keeps the application up while Stigg is unreachable.
118
- return this.record((0, decision_1.policyDecision)(context.checkErrorAction === 'allow'));
131
+ return this.record((0, decision_1.policyDecision)(context.checkErrorAction === 'allow'), context);
119
132
  }
120
133
  }
121
134
  /** Applies the context's mapper to a token measurement. The path every binding uses. */
@@ -195,17 +208,36 @@ class GovernanceSession {
195
208
  // survives into an event and is dropped from a usage report: a meter may filter on a field
196
209
  // being present, and an event that omits it on a cache miss changes shape call to call, while
197
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];
198
218
  const sendable = {};
199
219
  let rejected;
200
- for (const [featureId, value] of Object.entries(metrics)) {
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
+ //
201
228
  // The key is the feature id this spend lands on, and the mapper that produced it is the
202
229
  // application's own function. An empty one is validated by nothing downstream - the value is
203
230
  // checked, the key is not - so it would report to a feature that cannot exist, answer `ok`,
204
231
  // and move no budget: the failure that looks like success, from a typo.
205
- const invalid = featureId.trim() ? (0, types_1.usageValueError)(value, mode) : emptyMetricNameError();
206
- if (invalid)
232
+ const invalid = featureId.trim() ? (0, types_1.usageValueError)(rawValue, mode, drawsWallet) : emptyMetricNameError();
233
+ if (invalid) {
207
234
  rejected ??= invalid;
208
- else if (value > 0 || mode === 'async')
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')
209
241
  sendable[featureId] = value;
210
242
  }
211
243
  // Zero-token calls are normal (a cached refusal, an empty stream); they are not an error. They
@@ -216,7 +248,7 @@ class GovernanceSession {
216
248
  return Promise.resolve(this.failedIngest(context, idempotencyKey, rejected));
217
249
  this.ingested += 1;
218
250
  const noop = { ok: true, reported: {}, idempotencyKey, mode, replayed: false };
219
- this.hook('onIngest', context, () => this.deps.hooks.onIngest?.(noop));
251
+ this.hook('onIngest', context, () => this.deps.hooks.onIngest?.(noop, hookContext(context)));
220
252
  return Promise.resolve(noop);
221
253
  }
222
254
  const promise = this.deps
@@ -260,7 +292,7 @@ class GovernanceSession {
260
292
  this.ingestFailures += 1;
261
293
  this.reportError(rejected, rejected.operation, context, idempotencyKey);
262
294
  }
263
- this.hook('onIngest', context, () => this.deps.hooks.onIngest?.(landed));
295
+ this.hook('onIngest', context, () => this.deps.hooks.onIngest?.(landed, hookContext(context)));
264
296
  return landed;
265
297
  })
266
298
  .catch((err) => this.failedIngest(context, idempotencyKey, asEngineError('ingest', err)))
@@ -314,6 +346,19 @@ class GovernanceSession {
314
346
  * so the most that can be said is that nothing reported names what this call checks, and whether
315
347
  * a meter closes that gap is configuration this process cannot see.
316
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
+ }
317
362
  warnIfNothingDrains(context, result, sent) {
318
363
  if (this.warnedUnmapped)
319
364
  return;
@@ -339,9 +384,9 @@ class GovernanceSession {
339
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 ` +
340
385
  'against will never drain. Map it in Stigg, or report to the currency directly.');
341
386
  }
342
- record(decision) {
387
+ record(decision, context) {
343
388
  this.decision = decision;
344
- this.hook('onDecision', undefined, () => this.deps.hooks.onDecision?.(decision));
389
+ this.hook('onDecision', context, () => this.deps.hooks.onDecision?.(decision, hookContext(context)));
345
390
  return decision;
346
391
  }
347
392
  /**
@@ -390,7 +435,7 @@ class GovernanceSession {
390
435
  replayed: error.replayed ?? false,
391
436
  error,
392
437
  };
393
- this.hook('onIngest', context, () => this.deps.hooks.onIngest?.(failure));
438
+ this.hook('onIngest', context, () => this.deps.hooks.onIngest?.(failure, hookContext(context)));
394
439
  return failure;
395
440
  }
396
441
  }
@@ -2,7 +2,7 @@
2
2
  * How a decision reads to a human. Kept next to the decision itself rather than in a binding,
3
3
  * because every binding needs the same words and none of them should invent their own.
4
4
  */
5
- import type { GovernanceDecision } from './types';
5
+ import type { AccessDecision } from './types';
6
6
  /**
7
7
  * Denial -> what to tell the user.
8
8
  *
@@ -10,6 +10,6 @@ import type { GovernanceDecision } from './types';
10
10
  * admin-set cap is an escalation to that admin, and the two must not be collapsed into one
11
11
  * "you have been blocked" string.
12
12
  */
13
- export declare function remedyMessage(decision: GovernanceDecision | undefined): string;
13
+ export declare function remedyMessage(decision: AccessDecision | undefined): string;
14
14
  /** One-line decision summary for logs. */
15
- export declare function describeDecision(decision: Omit<GovernanceDecision, 'summary' | 'message'>): string;
15
+ export declare function describeDecision(decision: Omit<AccessDecision, 'summary' | 'message'>): string;
@@ -1,31 +1,36 @@
1
- import type { GovernanceDecision, GovernanceRemedy } from '../types';
1
+ import type { AccessDecision, Entitlement, Remedy } from '../types';
2
2
  import type { EngineCheckResult } from './engine';
3
3
  /**
4
4
  * Denial reasons map to different products, so they must not be collapsed: an empty wallet is an
5
5
  * upsell, an admin-set cap is an escalation, and a missing subscription is neither.
6
6
  */
7
- export declare function remedyFor(reason: string | null | undefined): GovernanceRemedy | undefined;
7
+ export declare function remedyFor(reason: string | null | undefined): Remedy | undefined;
8
8
  export type DecisionContext = {
9
9
  customerId: string;
10
+ /**
11
+ * What the run gates on, when it gates on anything. Only its *kind* is read - see
12
+ * {@link toDecision} for why a denial needs it.
13
+ */
14
+ entitlement?: Entitlement;
10
15
  };
11
16
  /**
12
17
  * `remaining` is the tightest bound across the pool and every chain node, because the binding
13
18
  * constraint is whichever runs out first. It stays undefined when nothing is bounded - the
14
19
  * observe-only rollout, where usage is counted but never blocks.
15
20
  */
16
- export declare function toDecision(result: EngineCheckResult, { customerId }: DecisionContext): GovernanceDecision;
21
+ export declare function toDecision(result: EngineCheckResult, { customerId, entitlement }: DecisionContext): AccessDecision;
17
22
  /**
18
23
  * Attaches the human-readable halves, so every caller prints the same words. Exported because
19
24
  * anything that adjusts a decision after the fact has to refresh them too - a stale summary is
20
25
  * worse than none.
21
26
  */
22
- export declare function withDecisionText(decision: Omit<GovernanceDecision, 'summary' | 'message'>): GovernanceDecision;
27
+ export declare function withDecisionText(decision: Omit<AccessDecision, 'summary' | 'message'>): AccessDecision;
23
28
  /**
24
29
  * The decision to return when the engine itself failed and policy resolved the outcome. No
25
30
  * `deniedBy`: nobody's budget refused this, so naming an entity would be a lie - the failure
26
31
  * detail goes to the `onError` hook instead.
27
32
  */
28
- export declare function policyDecision(hasAccess: boolean): GovernanceDecision;
33
+ export declare function policyDecision(hasAccess: boolean): AccessDecision;
29
34
  /**
30
35
  * The decision a run that named no entitlement hands back.
31
36
  *
@@ -34,4 +39,4 @@ export declare function policyDecision(hasAccess: boolean): GovernanceDecision;
34
39
  * is no budget to ask about - so the only fields true of it are the allow and the source that says
35
40
  * why it is one. No `remaining`, because nothing is bounded; no `deniedBy`, because nothing refused.
36
41
  */
37
- export declare function unenforcedDecision(): GovernanceDecision;
42
+ export declare function unenforcedDecision(): AccessDecision;
@@ -12,6 +12,7 @@ exports.unenforcedDecision = unenforcedDecision;
12
12
  * much, so no new primitive is needed on top of it.
13
13
  */
14
14
  const decision_text_1 = require("../decision-text");
15
+ const types_1 = require("../types");
15
16
  /**
16
17
  * Denial reasons map to different products, so they must not be collapsed: an empty wallet is an
17
18
  * upsell, an admin-set cap is an escalation, and a missing subscription is neither.
@@ -49,7 +50,7 @@ const tightest = (values) => {
49
50
  * constraint is whichever runs out first. It stays undefined when nothing is bounded - the
50
51
  * observe-only rollout, where usage is counted but never blocks.
51
52
  */
52
- function toDecision(result, { customerId }) {
53
+ function toDecision(result, { customerId, entitlement }) {
53
54
  const nodes = (result.chains ?? []).flat();
54
55
  const denied = nodes.find((node) => !node.isGranted);
55
56
  const reason = result.accessDeniedReason ?? undefined;
@@ -70,7 +71,13 @@ function toDecision(result, { customerId }) {
70
71
  decision.locallyDerived = true;
71
72
  if (reason) {
72
73
  decision.reason = reason;
73
- decision.remedy = remedyFor(reason);
74
+ // An exhausted credit pool comes back as a budget denial, so the reason alone cannot tell an
75
+ // empty wallet from an admin-set cap - and the two are different products. Nothing in the
76
+ // chain denied means nothing anyone administers refused this: the pool did. On a credit
77
+ // entitlement that is a top-up, and saying "ask whoever set this budget to raise it" instead
78
+ // sends a customer who would have paid to go and find an administrator.
79
+ const poolLevel = !denied && entitlement !== undefined && (0, types_1.entitlementKind)(entitlement) === 'credit';
80
+ decision.remedy = poolLevel && remedyFor(reason) === 'raise-budget' ? 'top-up' : remedyFor(reason);
74
81
  }
75
82
  if (!result.isGranted) {
76
83
  // With no chain (governance off, or a pool-level denial) the customer is what blocked.
@@ -75,7 +75,10 @@ function creditResult(currencyId, amount, currentUsage, mode, source) {
75
75
  return {
76
76
  reported: { [currencyId]: amount },
77
77
  currentUsage: currentUsage ?? undefined,
78
- creditsConsumed: amount,
78
+ // What Stigg confirmed drawn, not what was sent - `reported` already says that. Under
79
+ // `'async'` the draw is reconciled later and the response carries no balance, so there is
80
+ // nothing confirmed to state and the field stays absent, exactly as its own type promises.
81
+ creditsConsumed: mode === 'async' ? undefined : amount,
79
82
  replayed: mode === 'async' ? undefined : false,
80
83
  mode,
81
84
  source,
package/dist/errors.d.ts CHANGED
@@ -2,15 +2,15 @@
2
2
  * A flat error hierarchy with `isX()` guards, so callers can branch without `instanceof`
3
3
  * surviving across bundles or duplicated copies of this package.
4
4
  */
5
- import type { GovernanceDecision, Metrics } from './types';
5
+ import type { AccessDecision, Metrics } from './types';
6
6
  /**
7
7
  * Thrown only where there is no return path for a decision - inside a wrapped client method,
8
8
  * which must hand back the vendor's own response type or nothing at all. Everywhere else a
9
- * denial is a returned `GovernanceDecision`.
9
+ * denial is a returned `AccessDecision`.
10
10
  */
11
11
  export declare class AccessDeniedError extends Error {
12
- readonly decision: GovernanceDecision;
13
- constructor(decision: GovernanceDecision);
12
+ readonly decision: AccessDecision;
13
+ constructor(decision: AccessDecision);
14
14
  }
15
15
  /** Anything the engine could not complete: transport, timeout, 4xx/5xx, bad input. */
16
16
  export declare class EngineError extends Error {
@@ -38,12 +38,20 @@ export declare class EngineError extends Error {
38
38
  replayed?: boolean;
39
39
  });
40
40
  }
41
- /** Governance is not enabled for this Stigg account. An account state, not a transient failure. */
41
+ /**
42
+ * Per-agent budgets are not enabled for this Stigg environment. An account state, not a transient
43
+ * failure, and the class keeps the platform's own name because that is what has to be turned on.
44
+ *
45
+ * The message says what to do rather than what is missing. "Governance is not enabled" names an
46
+ * internal capability to someone who was trying to charge for agent usage and has no reason to
47
+ * know the two are the same thing - so the first line is the fix, and the feature name follows it
48
+ * for whoever has to go and find the setting.
49
+ */
42
50
  export declare class GovernanceNotEnabledError extends Error {
43
51
  constructor(detail?: string);
44
52
  }
45
53
  /** Returns the decision when `err` is a governance denial - the one-liner for a binding catch. */
46
- export declare function isAccessDenied(err: unknown): GovernanceDecision | undefined;
54
+ export declare function isAccessDenied(err: unknown): AccessDecision | undefined;
47
55
  export declare const isEngineError: (err: unknown) => err is EngineError;
48
56
  export declare const isGovernanceNotEnabled: (err: unknown) => err is GovernanceNotEnabledError;
49
57
  /**
package/dist/errors.js CHANGED
@@ -16,7 +16,7 @@ const isKind = (err, kind) => typeof err === 'object' && err !== null && err[MAR
16
16
  /**
17
17
  * Thrown only where there is no return path for a decision - inside a wrapped client method,
18
18
  * which must hand back the vendor's own response type or nothing at all. Everywhere else a
19
- * denial is a returned `GovernanceDecision`.
19
+ * denial is a returned `AccessDecision`.
20
20
  */
21
21
  class AccessDeniedError extends Error {
22
22
  decision;
@@ -59,9 +59,19 @@ class EngineError extends Error {
59
59
  }
60
60
  }
61
61
  exports.EngineError = EngineError;
62
- /** Governance is not enabled for this Stigg account. An account state, not a transient failure. */
62
+ /**
63
+ * Per-agent budgets are not enabled for this Stigg environment. An account state, not a transient
64
+ * failure, and the class keeps the platform's own name because that is what has to be turned on.
65
+ *
66
+ * The message says what to do rather than what is missing. "Governance is not enabled" names an
67
+ * internal capability to someone who was trying to charge for agent usage and has no reason to
68
+ * know the two are the same thing - so the first line is the fix, and the feature name follows it
69
+ * for whoever has to go and find the setting.
70
+ */
63
71
  class GovernanceNotEnabledError extends Error {
64
- constructor(detail = 'Governance is not enabled for this account. Contact Stigg to enable it.') {
72
+ constructor(detail = 'Per-agent budgets are not enabled for this Stigg environment, so an agent cannot be ' +
73
+ 'given an identity or a budget. Enable Governance for the account to use them - or drop ' +
74
+ '`agentId` and the budget fields, and checks and usage apply to the customer instead.') {
65
75
  super(detail);
66
76
  this.name = 'GovernanceNotEnabledError';
67
77
  mark(this, 'not-enabled');
package/dist/headers.d.ts CHANGED
@@ -9,6 +9,17 @@ export declare const GOVERNANCE_HEADERS: {
9
9
  readonly customer: "x-stigg-customer-id";
10
10
  readonly dimensions: "x-stigg-dimensions";
11
11
  readonly run: "x-stigg-run-id";
12
+ /**
13
+ * Which key in `dimensions` names the agent, when one does.
14
+ *
15
+ * Sent because the far end cannot work it out. Governance resolves an entity per attribution
16
+ * key, and which keys those are is declared on the entity types in the account - so a receiver
17
+ * holding only `{ agentId: 'a-1', region: 'eu' }` cannot tell the attribution from the
18
+ * metadata. Guessing either way is wrong in a direction that costs money: treat every
19
+ * dimension as attribution and an ordinary `region` rounds the draw up; treat none as
20
+ * attribution and a real agent's budget stops counting.
21
+ */
22
+ readonly attributionKey: "x-stigg-attribution-key";
12
23
  };
13
24
  /**
14
25
  * What `run.headers()` produces - all three, always.
@@ -22,6 +33,8 @@ export type ParsedGovernanceHeaders = {
22
33
  customerId: string;
23
34
  dimensions: Record<string, string>;
24
35
  runId?: string;
36
+ /** The key under which `dimensions` names the agent, when the sender named one. */
37
+ attributionKey?: string;
25
38
  };
26
39
  /** The receiving side. Returns undefined when the request carries no attribution to act on. */
27
40
  export declare function parseGovernanceHeaders(headers: Record<string, string | string[] | undefined>): ParsedGovernanceHeaders | undefined;
package/dist/headers.js CHANGED
@@ -13,6 +13,17 @@ exports.GOVERNANCE_HEADERS = {
13
13
  customer: 'x-stigg-customer-id',
14
14
  dimensions: 'x-stigg-dimensions',
15
15
  run: 'x-stigg-run-id',
16
+ /**
17
+ * Which key in `dimensions` names the agent, when one does.
18
+ *
19
+ * Sent because the far end cannot work it out. Governance resolves an entity per attribution
20
+ * key, and which keys those are is declared on the entity types in the account - so a receiver
21
+ * holding only `{ agentId: 'a-1', region: 'eu' }` cannot tell the attribution from the
22
+ * metadata. Guessing either way is wrong in a direction that costs money: treat every
23
+ * dimension as attribution and an ordinary `region` rounds the draw up; treat none as
24
+ * attribution and a real agent's budget stops counting.
25
+ */
26
+ attributionKey: 'x-stigg-attribution-key',
16
27
  };
17
28
  /** The receiving side. Returns undefined when the request carries no attribution to act on. */
18
29
  function parseGovernanceHeaders(headers) {
@@ -55,7 +66,15 @@ function parseGovernanceHeaders(headers) {
55
66
  dimensions = {};
56
67
  }
57
68
  }
58
- return { customerId, dimensions, runId: read(exports.GOVERNANCE_HEADERS.run) };
69
+ // Only honoured when it names a dimension that is actually there: a key with nothing under it
70
+ // attributes to no entity, and treating it as though it did would round a draw up for nothing.
71
+ const attributionKey = read(exports.GOVERNANCE_HEADERS.attributionKey);
72
+ return {
73
+ customerId,
74
+ dimensions,
75
+ runId: read(exports.GOVERNANCE_HEADERS.run),
76
+ ...(attributionKey && dimensions[attributionKey] ? { attributionKey } : {}),
77
+ };
59
78
  }
60
79
  const isDimensions = (value) => typeof value === 'object' &&
61
80
  value !== null &&
package/dist/index.d.ts CHANGED
@@ -11,10 +11,10 @@
11
11
  * `@stigg/agent-sdk-js/internal`, under a weaker compatibility promise.
12
12
  */
13
13
  export { StiggAgentSDK } from './stigg-agent-sdk';
14
- export type { AgentType, StiggContext } from './core/context';
15
- export type { GovernanceSummary, IngestOptions, SessionHooks } from './core/session';
14
+ export type { StiggContext } from './core/context';
15
+ export type { SessionSummary, HookContext, IngestOptions, SessionHooks } from './core/session';
16
16
  export { RAW_PREFIX, flattenRaw } from './core/dimensions';
17
- export type { CacheOptions, ErrorContext, NodeSdkClient, StiggAgentSDKOptions, StiggTarget } from './config';
17
+ export type { CacheOptions, ErrorContext, NodeSdkClient, StiggAgentSDKOptions } from './config';
18
18
  export { describeDecision, remedyMessage } from './decision-text';
19
19
  export { GOVERNANCE_HEADERS, parseGovernanceHeaders } from './headers';
20
20
  export type { GovernanceHeaders, ParsedGovernanceHeaders } from './headers';
@@ -22,8 +22,8 @@ export { consoleLogger, noopLogger } from './logger';
22
22
  export type { Logger } from './logger';
23
23
  export { EngineError, AccessDeniedError, GovernanceNotEnabledError, isEngineError, isAccessDenied, isGovernanceNotEnabled, looksLikeGovernanceNotEnabled, } from './errors';
24
24
  export type { EngineCapabilities, EngineChainNode, EngineCheckParams, EngineCheckResult, EngineReportParams, EngineReportResult, EngineResolution, EntityInput, EventIngest, EntityPlacement, GovernanceEngine, } from './engine/engine';
25
- export { creditsPerTokens, vendorMetrics, emptyTokenUsage, entitlementId, entitlementKind, entitlementRef, toMeasurement, } from './types';
26
- export type { AgentOptions, AgentScope, ClientGovernance, CheckErrorAction, ClientOptions, DecisionSource, DeregisterInput, Entitlement, GovernanceDecision, GovernanceRemedy, GovernedClient, IngestContext, IngestMode, IngestResult, Measurement, RegisterInput, GatedSurface, CallContext, TokenUsage, Metrics, MetricsMapper, } from './types';
25
+ export { unitsPerTokens, vendorMetrics, emptyTokenUsage, entitlementId, entitlementKind, entitlementRef, toMeasurement, } from './types';
26
+ export type { AgentBudget, AgentOptions, AgentScope, ClientGovernance, CheckErrorAction, ClientOptions, DecisionSource, DeregisterInput, Entitlement, AccessDecision, Remedy, WrappedClient, IngestContext, IngestMode, IngestResult, Measurement, RegisterInput, GatedSurface, CallContext, TokenUsage, Metrics, MetricsMapper, } from './types';
27
27
  export { wrapClient } from './bindings/client/core/wrap-client';
28
28
  export { createClientRegistry, hasMethod } from './bindings/client/core/registry';
29
29
  export type { ClientRecipe, ClientRegistry, MethodRecipe, MetricsFor, MetricsOf, MeteredResponse, } from './bindings/client/core/types';
@@ -40,6 +40,9 @@ export type { OpenRouterRecipeOptions } from './bindings/client/gateways/openrou
40
40
  export { litellmRecipe } from './bindings/client/gateways/litellm/litellm-recipe';
41
41
  export type { LiteLLMMetric, LiteLLMMetrics } from './bindings/client/gateways/litellm/litellm-recipe';
42
42
  export type { LiteLLMRecipeOptions } from './bindings/client/gateways/litellm/types';
43
+ export { bedrockRecipe } from './bindings/client/gateways/bedrock/bedrock-recipe';
44
+ export type { BedrockMetricName, BedrockMetrics } from './bindings/client/gateways/bedrock/bedrock-recipe';
45
+ export type { BedrockRecipeOptions } from './bindings/client/gateways/bedrock/types';
43
46
  export { typesafeRecipe, estimateFromState } from './bindings/client/providers/typesafe/typesafe-recipe';
44
47
  export type { TypeSafeMetric, TypeSafeMetrics } from './bindings/client/providers/typesafe/typesafe-recipe';
45
48
  export { weightedMetrics } from './bindings/shared/weighted-metrics';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.normalizeUsage = exports.normalizedMetrics = exports.weightedMetrics = exports.estimateFromState = exports.typesafeRecipe = exports.litellmRecipe = exports.openrouterRecipe = exports.estimateFromMaxOutputTokens = exports.geminiChatRecipe = exports.geminiRecipe = exports.openAIRecipe = exports.estimateFromMaxTokens = exports.anthropicRecipe = exports.hasMethod = exports.createClientRegistry = exports.wrapClient = exports.toMeasurement = exports.entitlementRef = exports.entitlementKind = exports.entitlementId = exports.emptyTokenUsage = exports.vendorMetrics = exports.creditsPerTokens = exports.looksLikeGovernanceNotEnabled = exports.isGovernanceNotEnabled = exports.isAccessDenied = exports.isEngineError = exports.GovernanceNotEnabledError = exports.AccessDeniedError = exports.EngineError = exports.noopLogger = exports.consoleLogger = exports.parseGovernanceHeaders = exports.GOVERNANCE_HEADERS = exports.remedyMessage = exports.describeDecision = exports.flattenRaw = exports.RAW_PREFIX = exports.StiggAgentSDK = void 0;
3
+ exports.normalizeUsage = exports.normalizedMetrics = exports.weightedMetrics = exports.estimateFromState = exports.typesafeRecipe = exports.bedrockRecipe = exports.litellmRecipe = exports.openrouterRecipe = exports.estimateFromMaxOutputTokens = exports.geminiChatRecipe = exports.geminiRecipe = exports.openAIRecipe = exports.estimateFromMaxTokens = exports.anthropicRecipe = exports.hasMethod = exports.createClientRegistry = exports.wrapClient = exports.toMeasurement = exports.entitlementRef = exports.entitlementKind = exports.entitlementId = exports.emptyTokenUsage = exports.vendorMetrics = exports.unitsPerTokens = exports.looksLikeGovernanceNotEnabled = exports.isGovernanceNotEnabled = exports.isAccessDenied = exports.isEngineError = exports.GovernanceNotEnabledError = exports.AccessDeniedError = exports.EngineError = exports.noopLogger = exports.consoleLogger = exports.parseGovernanceHeaders = exports.GOVERNANCE_HEADERS = exports.remedyMessage = exports.describeDecision = exports.flattenRaw = exports.RAW_PREFIX = exports.StiggAgentSDK = void 0;
4
4
  /**
5
5
  * Public surface.
6
6
  *
@@ -37,7 +37,7 @@ Object.defineProperty(exports, "isAccessDenied", { enumerable: true, get: functi
37
37
  Object.defineProperty(exports, "isGovernanceNotEnabled", { enumerable: true, get: function () { return errors_1.isGovernanceNotEnabled; } });
38
38
  Object.defineProperty(exports, "looksLikeGovernanceNotEnabled", { enumerable: true, get: function () { return errors_1.looksLikeGovernanceNotEnabled; } });
39
39
  var types_1 = require("./types");
40
- Object.defineProperty(exports, "creditsPerTokens", { enumerable: true, get: function () { return types_1.creditsPerTokens; } });
40
+ Object.defineProperty(exports, "unitsPerTokens", { enumerable: true, get: function () { return types_1.unitsPerTokens; } });
41
41
  Object.defineProperty(exports, "vendorMetrics", { enumerable: true, get: function () { return types_1.vendorMetrics; } });
42
42
  Object.defineProperty(exports, "emptyTokenUsage", { enumerable: true, get: function () { return types_1.emptyTokenUsage; } });
43
43
  Object.defineProperty(exports, "entitlementId", { enumerable: true, get: function () { return types_1.entitlementId; } });
@@ -73,6 +73,12 @@ Object.defineProperty(exports, "openrouterRecipe", { enumerable: true, get: func
73
73
  // nothing from a stream. Name it at `wrap(client, { recipe })` to give it your proxy's base URL.
74
74
  var litellm_recipe_1 = require("./bindings/client/gateways/litellm/litellm-recipe");
75
75
  Object.defineProperty(exports, "litellmRecipe", { enumerable: true, get: function () { return litellm_recipe_1.litellmRecipe; } });
76
+ // A default too, and the gateway that states no price. Bedrock's client is a command bus rather
77
+ // than a method tree, so the recipe gates `send` and reads the command to tell inference from the
78
+ // rest of the API; it reports Bedrock's own token counters and no `cost`, because there is none on
79
+ // the response to report. See the recipe for what that means for a run that wanted to bill spend.
80
+ var bedrock_recipe_1 = require("./bindings/client/gateways/bedrock/bedrock-recipe");
81
+ Object.defineProperty(exports, "bedrockRecipe", { enumerable: true, get: function () { return bedrock_recipe_1.bedrockRecipe; } });
76
82
  // Built in as well. One method, no stream, and two counters - see the recipe for why a
77
83
  // question-answering API has neither a cache counter nor a cost to report.
78
84
  var typesafe_recipe_1 = require("./bindings/client/providers/typesafe/typesafe-recipe");
@@ -1,8 +1,8 @@
1
- import { type ResolvedConfig, type StiggAgentSDKOptions, type StiggTarget } from './config';
1
+ import { type ResolvedConfig, type StiggAgentSDKOptions } from './config';
2
2
  import { type EngineResolution, type GovernanceEngine } from './engine/engine';
3
3
  import type { StiggContext } from './core/context';
4
- import type { GovernanceSummary } from './core/session';
5
- import type { Metrics, ClientOptions, DeregisterInput, GovernanceDecision, GovernedClient, PathsOf, IngestContext, IngestResult, RegisterInput } from './types';
4
+ import type { SessionSummary } from './core/session';
5
+ import type { Metrics, ClientOptions, DeregisterInput, AccessDecision, WrappedClient, PathsOf, IngestContext, IngestResult, RegisterInput } from './types';
6
6
  import type { GovernanceHeaders } from './headers';
7
7
  /**
8
8
  * Recipes are data - method paths and usage extractors - so wiring them here costs nothing at
@@ -77,7 +77,7 @@ export declare const DEFAULT_RECIPES: ({
77
77
  } | undefined;
78
78
  attachStream: (stream: any, ingest: (measurement: import("./types").Measurement) => void) => void;
79
79
  }[];
80
- } | import("./bindings/client/core/types").ClientRecipe<"cached_tokens" | "prompt_tokens" | "completion_tokens" | "input_tokens" | "output_tokens" | "total_tokens" | "cost"> | {
80
+ } | import("./bindings/client/core/types").ClientRecipe<"cached_tokens" | "prompt_tokens" | "completion_tokens" | "input_tokens" | "output_tokens" | "total_tokens" | "cost"> | import("./bindings/client/core/types").ClientRecipe<"inputTokens" | "outputTokens" | "cacheReadInputTokens" | "cacheWriteInputTokens" | "totalTokens"> | {
81
81
  vendor: string;
82
82
  metrics: readonly ["input_tokens", "output_tokens", "total_tokens"];
83
83
  totalTokensMetric: "total_tokens";
@@ -110,6 +110,11 @@ export declare class StiggAgentSDK {
110
110
  */
111
111
  private readonly registering;
112
112
  private started?;
113
+ /**
114
+ * When the attempt currently in `started` rejected, or undefined while it is pending or has
115
+ * resolved. What makes a failed probe expire rather than persist - see `ensureStarted`.
116
+ */
117
+ private startFailedAt?;
113
118
  private engineState?;
114
119
  /**
115
120
  * Synchronous - nothing here touches the network. The engine is built on the first call that
@@ -117,16 +122,16 @@ export declare class StiggAgentSDK {
117
122
  *
118
123
  * Where this SDK sends its traffic comes first, because it is the only thing that cannot be
119
124
  * inferred: a server key, a `{ baseUrl }` target that needs none, or nothing at all to read
120
- * `STIGG_SERVER_API_KEY`. See {@link StiggTarget}. A key that belongs to somewhere other than
125
+ * `STIGG_SERVER_API_KEY`. A key that belongs to somewhere other than
121
126
  * the hosted API names that URL second - the two together are one idea, so neither is buried
122
127
  * among the tuning knobs that follow.
123
128
  *
124
129
  * Construct one per environment and share it - the engine's cache and its connection pool are
125
130
  * per instance, so a second one for the same environment is wasted rather than wrong.
126
131
  */
127
- constructor(target?: StiggTarget, baseUrl?: string, options?: StiggAgentSDKOptions);
128
- /** The same, for a target that already carries its URL - or one on the hosted API. */
129
- constructor(target?: StiggTarget, options?: StiggAgentSDKOptions);
132
+ constructor(options?: StiggAgentSDKOptions);
133
+ /** The shorthand, for a server key with nothing else to say about where it points. */
134
+ constructor(apiKey: string, options?: Omit<StiggAgentSDKOptions, 'apiKey'>);
130
135
  /** Releases what this SDK's engine holds open. */
131
136
  close(): Promise<void>;
132
137
  /**
@@ -174,7 +179,7 @@ export declare class StiggAgentSDK {
174
179
  * entitlement, so the calls that follow read local state. Nothing is cached in this package -
175
180
  * see {@link GovernanceSession.warmUp} for which cache that is and when there is one at all.
176
181
  */
177
- wrap<T extends object, O extends ClientOptions>(client: T, options?: O): GovernedClient<T, O['stiggContext'], PathsOf<O['recipe'], T>>;
182
+ wrap<T extends object, O extends ClientOptions>(client: T, options?: O): WrappedClient<T, O['stiggContext'], PathsOf<O['recipe'], T>>;
178
183
  /**
179
184
  * Hangs the governance handle off the wrapped client. Refuses to overwrite a `stigg` the vendor
180
185
  * already has: silently replacing one of its own properties would be a worse surprise.
@@ -184,7 +189,7 @@ export declare class StiggAgentSDK {
184
189
  * The gate, with no client to wrap - what a framework binding reaches for, and what the gateway
185
190
  * example checks with before it proxies.
186
191
  */
187
- check(context: StiggContext, requestedUsage?: number): Promise<GovernanceDecision>;
192
+ check(context: StiggContext, requestedUsage?: number): Promise<AccessDecision>;
188
193
  /** The meter, with no client to wrap. Metrics already in Stigg's own vocabulary. */
189
194
  report(context: StiggContext, metrics: Metrics): Promise<IngestResult>;
190
195
  /** The `x-stigg-*` attribution for work continuing in another process, with no client involved. */
@@ -205,7 +210,7 @@ export declare class StiggAgentSDK {
205
210
  * Returns the decision when `err` is a governance refusal, so a catch block is one line and the
206
211
  * caller needs no import to tell a denial from a real failure.
207
212
  */
208
- denial(err: unknown): GovernanceDecision | undefined;
213
+ denial(err: unknown): AccessDecision | undefined;
209
214
  /**
210
215
  * The gateway side of the split lifecycle: ingest usage a proxy observed, attributed from the
211
216
  * headers the application sent with the request. Ingest only - a gateway sees requests, not
@@ -236,7 +241,18 @@ export declare class StiggAgentSDK {
236
241
  */
237
242
  flush(options?: {
238
243
  close?: boolean;
239
- }): Promise<GovernanceSummary[]>;
240
- /** Builds the engine once, on first need. Concurrent callers share the same attempt. */
244
+ }): Promise<SessionSummary[]>;
245
+ /**
246
+ * Builds the engine once, on first need. Concurrent callers share the same attempt.
247
+ *
248
+ * A *failed* attempt is shared too, but only for {@link RETRY_START_AFTER_MS}. A rejected
249
+ * promise is still a promise, so memoizing one unconditionally outlives the failure that caused
250
+ * it: under the default `checkErrorAction: 'allow'` the process stops enforcing any budget at
251
+ * all, and under `'deny'` it refuses every call - neither saying anything a log would show.
252
+ *
253
+ * Holding the failure briefly rather than discarding it on sight is the other half: callers
254
+ * inside the window get the same rejection immediately and their own `checkErrorAction` decides
255
+ * the outcome, so an outage is re-probed on a timer instead of once per request.
256
+ */
241
257
  private ensureStarted;
242
258
  }