@stigg/agent-sdk-js 1.0.1 → 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 +6 -3
@@ -1,9 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.normalizedMetrics = exports.addMetrics = exports.METRIC_PATHS = exports.metricNamesMatchPaths = exports.COMPUTED_TOTALS = exports.metricName = exports.GATEWAY_METRICS = exports.OPENAI_METRICS = exports.TYPESAFE_METRICS = exports.TYPESAFE_COMPONENT_METRICS = exports.OPENAI_RESPONSES_METRICS = exports.OPENAI_CHAT_METRICS = exports.GEMINI_COMPONENT_METRICS = exports.GEMINI_METRICS = exports.ANTHROPIC_METRICS = exports.ANTHROPIC_COMPONENT_METRICS = exports.TOTAL_TOKENS = exports.addTokenUsage = void 0;
3
+ exports.normalizedMetrics = exports.addMetrics = exports.METRIC_PATHS = exports.metricNamesMatchPaths = exports.COMPUTED_TOTALS = exports.metricName = exports.BEDROCK_METRICS = exports.BEDROCK_PATHS = exports.GATEWAY_METRICS = exports.OPENAI_METRICS = exports.TYPESAFE_METRICS = exports.TYPESAFE_COMPONENT_METRICS = exports.OPENAI_RESPONSES_METRICS = exports.OPENAI_CHAT_METRICS = exports.GEMINI_COMPONENT_METRICS = exports.GEMINI_METRICS = exports.ANTHROPIC_METRICS = exports.ANTHROPIC_COMPONENT_METRICS = exports.TOTAL_TOKENS = exports.addTokenUsage = void 0;
4
4
  exports.fromFlatTokenUsage = fromFlatTokenUsage;
5
5
  exports.fromOpenAIUsage = fromOpenAIUsage;
6
6
  exports.fromResponsesUsage = fromResponsesUsage;
7
+ exports.fromBedrockUsage = fromBedrockUsage;
7
8
  exports.fromLangChainUsage = fromLangChainUsage;
8
9
  exports.fromGeminiUsage = fromGeminiUsage;
9
10
  exports.normalizeUsage = normalizeUsage;
@@ -64,8 +65,41 @@ function fromResponsesUsage(usage) {
64
65
  input: Math.max(0, n(usage.input_tokens) - cacheRead),
65
66
  output: n(usage.output_tokens),
66
67
  cacheRead,
67
- // The surface reports no cache writes of its own.
68
- cacheCreate: 0,
68
+ // OpenAI's own Responses API reports no cache writes, so this is 0 for it either way. A
69
+ // gateway mirroring the surface may report them - OpenRouter does - and reading the field
70
+ // costs nothing where it is absent, where the alternative was dropping a counter the customer
71
+ // was charged for.
72
+ cacheCreate: n(usage.input_tokens_details?.cache_write_tokens),
73
+ };
74
+ }
75
+ /**
76
+ * Bedrock's Converse usage, whose four counters are disjoint.
77
+ *
78
+ * Nothing is subtracted, unlike the OpenAI and Gemini readers: AWS states that `inputTokens`
79
+ * already excludes whatever was read from or written to the cache, so taking the cache back out
80
+ * would charge the call for less than it used.
81
+ *
82
+ * The total is read, not recomputed, like every other vendor that publishes one - but it is worth
83
+ * knowing what it covers. AWS documents `totalTokens` as the input and output of the call, and
84
+ * documents `inputTokens` as cache-exclusive, so on a cached call the stated total can be smaller
85
+ * than the four counters add up to. Both are reported: price the breakdown if the cache matters to
86
+ * you, the total if it does not, and see the README's overlap table before pricing both.
87
+ */
88
+ function fromBedrockUsage(usage) {
89
+ if (!usage)
90
+ return (0, types_1.emptyTokenUsage)();
91
+ const counted = {
92
+ input: n(usage.inputTokens),
93
+ output: n(usage.outputTokens),
94
+ cacheRead: n(usage.cacheReadInputTokens),
95
+ cacheCreate: n(usage.cacheWriteInputTokens),
96
+ };
97
+ const stated = n(usage.totalTokens);
98
+ return {
99
+ ...counted,
100
+ // The vendor's figure when it gave one; otherwise the sum, which is sound precisely because
101
+ // these four do not overlap.
102
+ ...withTotal(stated || counted.input + counted.output + counted.cacheRead + counted.cacheCreate),
69
103
  };
70
104
  }
71
105
  function fromLangChainUsage(usage) {
@@ -287,6 +321,26 @@ exports.OPENAI_METRICS = [
287
321
  ];
288
322
  /** A gateway speaks the OpenAI shape and adds the price it charged, under its own field name. */
289
323
  exports.GATEWAY_METRICS = [...exports.OPENAI_METRICS, 'cost'];
324
+ /**
325
+ * Bedrock's five, under its own names and with no price among them.
326
+ *
327
+ * Reported verbatim rather than translated into the OpenAI spelling, which is the rule everywhere
328
+ * except where one service is reachable through two different clients - see `sdk-usage.ts` for
329
+ * that exception. Bedrock is reached one way, so its own names are the honest ones, and a reader
330
+ * comparing a Stigg meter against an AWS bill is comparing like with like.
331
+ *
332
+ * No `cost`: Bedrock states no price on the response, which is the thing that most separates it
333
+ * from the other two gateways here. See the recipe for what that costs a run.
334
+ */
335
+ exports.BEDROCK_PATHS = [
336
+ 'inputTokens',
337
+ 'outputTokens',
338
+ 'cacheReadInputTokens',
339
+ 'cacheWriteInputTokens',
340
+ 'totalTokens',
341
+ ];
342
+ /** Those five. Flat, so the paths are the names. */
343
+ exports.BEDROCK_METRICS = exports.BEDROCK_PATHS;
290
344
  /** The leaf a dotted path reports under - `prompt_tokens_details.cached_tokens` -> `cached_tokens`. */
291
345
  const metricName = (path) => path.split('.').pop();
292
346
  exports.metricName = metricName;
@@ -331,6 +385,7 @@ exports.METRIC_PATHS = {
331
385
  openaiResponses: OPENAI_RESPONSES_PATHS,
332
386
  typesafe: TYPESAFE_PATHS,
333
387
  gemini: GEMINI_PATHS,
388
+ bedrock: exports.BEDROCK_PATHS,
334
389
  };
335
390
  /**
336
391
  * Reads the named keys off a vendor's usage object, dotted paths included.
@@ -39,8 +39,24 @@ export type ResponsesLikeUsage = {
39
39
  total_tokens?: number | null;
40
40
  input_tokens_details?: {
41
41
  cached_tokens?: number | null;
42
+ cache_write_tokens?: number | null;
42
43
  } | null;
43
44
  };
45
+ /**
46
+ * Bedrock's Converse `usage`. Flat, camelCased, and **disjoint**.
47
+ *
48
+ * AWS is explicit that `inputTokens` counts only the tokens that were neither read from nor
49
+ * written to the cache - "total input tokens = inputTokens + cacheReadInputTokens +
50
+ * cacheWriteInputTokens" - so this shape behaves like Anthropic's and not like OpenAI's, where the
51
+ * cached count nests inside the prompt count. Subtracting here would undercount the call.
52
+ */
53
+ export type BedrockLikeUsage = {
54
+ inputTokens?: number | null;
55
+ outputTokens?: number | null;
56
+ totalTokens?: number | null;
57
+ cacheReadInputTokens?: number | null;
58
+ cacheWriteInputTokens?: number | null;
59
+ };
44
60
  /** LangChain's `usage_metadata`, as carried on an AIMessage. */
45
61
  export type LangChainLikeUsage = {
46
62
  input_tokens?: number | null;
@@ -38,7 +38,10 @@ function weightedMetrics(recipe, target, weights, options = {}) {
38
38
  // counts as the no-op ingest it is rather than as a report of zero.
39
39
  if (weighted <= 0)
40
40
  return {};
41
- // Stigg takes whole units, and rounding down would bill nothing for a turn that did happen.
42
- return { [target]: Math.ceil(weighted / per) };
41
+ // Reported as it came out of the weights. A credit currency takes fractions, so a turn worth
42
+ // a fifth of a unit costs a fifth - rounding here would be this SDK's arithmetic on someone
43
+ // else's price. A feature is the exception, since `POST /api/v1/usage` takes integers: choose
44
+ // `per` so the weights divide, or round in a mapper of your own.
45
+ return { [target]: weighted / per };
43
46
  };
44
47
  }
package/dist/config.d.ts CHANGED
@@ -15,19 +15,6 @@ import { type Logger } from './logger';
15
15
  import type { CacheOptions, NodeSdkClient } from './engine/node-sdk-engine';
16
16
  import type { CheckErrorAction, IngestMode } from './types';
17
17
  import type { EngineError } from './errors';
18
- /**
19
- * Where the SDK sends its traffic, and how it identifies itself there.
20
- *
21
- * Either a server key - which names an environment on the hosted API - or a base URL that needs
22
- * no key, which is a local stand-in, a self-hosted deployment, or the built-in mock. They are one
23
- * positional parameter rather than two options fields precisely so they cannot both be given:
24
- * "an API key or a URL" is the real rule, and a union says it without a runtime check to fail.
25
- *
26
- * A self-hosted deployment that *does* want a key passes the key here and the URL in options.
27
- */
28
- export type StiggTarget = string | {
29
- baseUrl: string;
30
- };
31
18
  declare const dataSchema: z.ZodObject<{
32
19
  apiKey: z.ZodString;
33
20
  baseUrl: z.ZodOptional<z.ZodString>;
@@ -50,11 +37,33 @@ export type ErrorContext = {
50
37
  customerId?: string;
51
38
  };
52
39
  /**
53
- * The second constructor argument: policy and transport, all of it optional and all of it with a
54
- * working default. Nothing required lives here - the one thing the SDK cannot infer is the
55
- * target, and that is the first argument.
40
+ * Everything the SDK can be told, in one object.
41
+ *
42
+ * All of it optional and all of it with a working default, including where to send traffic: the
43
+ * key falls back to `STIGG_SERVER_API_KEY` and the URL to the hosted API, so the smallest working
44
+ * program is `new StiggAgentSDK()`.
45
+ *
46
+ * One object rather than positional arguments so that each answer has exactly one place to go -
47
+ * a URL given in two places needs a runtime check to refuse both, and an optional argument in
48
+ * front of a required one forces `undefined` placeholders at the call site.
56
49
  */
57
50
  export type StiggAgentSDKOptions = {
51
+ /**
52
+ * The server key, which names the environment. Falls back to `STIGG_SERVER_API_KEY`, the way
53
+ * every vendor SDK does - **including when `baseUrl` is set**, since a URL says where traffic
54
+ * goes and nothing about who is sending it.
55
+ *
56
+ * So a shell with that variable exported sends it to whatever `baseUrl` names, a local mock
57
+ * included. To run keyless on such a machine, unset the variable for the process rather than
58
+ * relying on `baseUrl` to suppress it: a deployment that genuinely needs no key is the case
59
+ * where nothing is configured anywhere, and that is the one the fallback below serves.
60
+ */
61
+ apiKey?: string;
62
+ /**
63
+ * Where to send traffic, when it is not the hosted API. Pass it alongside `apiKey` for a
64
+ * self-hosted deployment that still wants a key, or on its own for one that needs none.
65
+ */
66
+ baseUrl?: string;
58
67
  /**
59
68
  * How checks are cached. See {@link CacheOptions} - it is the Node server SDK's own cache
60
69
  * configuration, passed through, because that is where caching actually happens.
@@ -124,7 +133,7 @@ export type ResolvedConfig = Omit<Required<z.infer<typeof dataSchema>>, 'baseUrl
124
133
  logger: Logger;
125
134
  onError: (error: EngineError, context: ErrorContext) => void;
126
135
  };
127
- export declare function resolveConfig(target?: StiggTarget, baseUrl?: string, options?: StiggAgentSDKOptions): ResolvedConfig;
136
+ export declare function resolveConfig(options?: StiggAgentSDKOptions): ResolvedConfig;
128
137
  /** The slice of config the engine factory needs. */
129
138
  export type EngineConfig = {
130
139
  apiKey: string;
package/dist/config.js CHANGED
@@ -35,37 +35,39 @@ const resolveLogger = (logger) => {
35
35
  return logger;
36
36
  };
37
37
  /**
38
- * The target, split into the key and the URL the transport actually needs.
38
+ * The key to send, which is the environment's identity.
39
39
  *
40
- * An absent target falls back to the environment variable, the way every vendor SDK does, so the
41
- * smallest working program is `new StiggAgentSDK()`. The failure for a missing key is raised here
42
- * rather than left to zod, because "set STIGG_SERVER_API_KEY" is the actionable half and the
43
- * schema only knows the field was too short.
40
+ * The environment variable is tried before the keyless fallback, and that order is the whole of
41
+ * it. A `baseUrl` says *where* to send traffic and nothing about who is sending it, so the two
42
+ * are independent: a self-hosted deployment names its URL in options and keeps its key in
43
+ * `STIGG_SERVER_API_KEY`, and substituting a sentinel there would 401 with nothing in the config
44
+ * to say why.
45
+ *
46
+ * {@link KEYLESS} is the last resort, not the rule: it stands in for a deployment that needs no
47
+ * key at all - a local stand-in, or the built-in mock - and exists only because the schema below
48
+ * demands a non-empty string. Nothing authenticates with it.
49
+ *
50
+ * The failure for a missing key is raised here rather than left to zod, because "set
51
+ * STIGG_SERVER_API_KEY" is the actionable half and the schema only knows the field was too short.
44
52
  */
45
- function resolveTarget(target) {
46
- if (typeof target === 'object')
47
- return { apiKey: KEYLESS, baseUrl: target.baseUrl };
48
- const apiKey = (target ?? process.env.STIGG_SERVER_API_KEY)?.trim();
49
- if (!apiKey) {
50
- throw new Error('No Stigg API key - pass a server key as the first argument, or set STIGG_SERVER_API_KEY. ' +
51
- 'To run without one, pass a target with a `baseUrl` instead: a local stand-in, a ' +
52
- 'self-hosted deployment, or the built-in mock.');
53
- }
54
- return { apiKey };
53
+ function resolveApiKey({ apiKey, baseUrl }) {
54
+ const named = apiKey?.trim();
55
+ if (named)
56
+ return named;
57
+ const fromEnv = process.env.STIGG_SERVER_API_KEY?.trim();
58
+ if (fromEnv)
59
+ return fromEnv;
60
+ // No key anywhere. A URL to send to is the one case where that is a configuration rather than
61
+ // an omission.
62
+ if (baseUrl)
63
+ return KEYLESS;
64
+ throw new Error('No Stigg API key - pass `apiKey`, or set STIGG_SERVER_API_KEY. To run without one, pass ' +
65
+ '`baseUrl` instead: a local stand-in, a self-hosted deployment, or the built-in mock.');
55
66
  }
56
- function resolveConfig(target, baseUrl, options = {}) {
57
- const resolved = resolveTarget(target);
58
- // A keyless target *is* a base URL, so naming a second one is two answers to one question.
59
- // Refused rather than silently preferred: whichever the SDK picked, the other would be a URL
60
- // the caller believes it is talking to and is not.
61
- if (resolved.baseUrl && baseUrl) {
62
- throw new Error(`This SDK was given a target with its own baseUrl (${resolved.baseUrl}) and a second one ` +
63
- `(${baseUrl}). Pass the URL once: as the target when it needs no key, or as the second ` +
64
- 'argument alongside a key.');
65
- }
67
+ function resolveConfig(options = {}) {
66
68
  const parsed = dataSchema.parse({
67
- apiKey: resolved.apiKey,
68
- baseUrl: resolved.baseUrl ?? baseUrl,
69
+ apiKey: resolveApiKey(options),
70
+ baseUrl: options.baseUrl,
69
71
  checkErrorAction: options.checkErrorAction,
70
72
  ingest: options.ingest,
71
73
  timeoutMs: options.timeoutMs,
@@ -1,12 +1,5 @@
1
1
  import type { CheckErrorAction, Entitlement, IngestMode, Metrics, MetricsMapper } from '../types';
2
2
  import type { EventIngest } from '../engine/engine';
3
- /** The entity type an agent is provisioned and attributed as. Both halves, because they must agree. */
4
- export type AgentType = {
5
- /** The governance entity type, as it exists in the account. */
6
- entityTypeId: string;
7
- /** The attribution key that type declares, under which `agentId` is sent on every call. */
8
- attributionKey: string;
9
- };
10
3
  /**
11
4
  * What a call is governed by. Every field is optional here because each one may have been given
12
5
  * at `wrap` instead - `customerId` is the only one that has to come from somewhere, and the
@@ -16,15 +9,22 @@ export type StiggContext = {
16
9
  /** The tenant. Every governance call is scoped to one customer. */
17
10
  customerId?: string;
18
11
  /**
19
- * The agent this call spends as. Travels as a dimension under {@link AgentType.attributionKey},
20
- * which is the entire mechanism by which an agent is attributed - so naming one without an
21
- * `agentType` is refused rather than guessed at.
12
+ * The agent this call spends as. Travels as a dimension under
13
+ * {@link StiggContext.attributionKey}, which is the entire mechanism by which an agent is
14
+ * attributed - so naming one without a key is refused rather than guessed at.
22
15
  *
23
16
  * Omitted, the call resolves no entity and is checked against the customer's own budget.
24
17
  */
25
18
  agentId?: string;
26
- /** The entity type `agentId` is identified as. Required whenever `agentId` is given. */
27
- agentType?: AgentType;
19
+ /**
20
+ * The key the agent's entity type declares, under which `agentId` is sent on every call.
21
+ * Required whenever `agentId` is given, and not defaulted: a guessed key resolves no entity, so
22
+ * the spend lands on the customer looking exactly like a call that was never governed.
23
+ *
24
+ * `register` takes an `entityTypeId` as well, because that call creates the entity. Attributing
25
+ * a call does not need to know what type it was created as - only what key to send it under.
26
+ */
27
+ attributionKey?: string;
28
28
  /**
29
29
  * Extra attribution, sent verbatim on the check and the report. Every key must be an attribution
30
30
  * key on one of the entity types involved, or it matches nothing and the usage rolls up to the
@@ -101,6 +101,15 @@ export type ResolvedContext = {
101
101
  correlationId: string;
102
102
  /** The entitlement's own id, resolved once - undefined when this context gates on nothing. */
103
103
  checkedId?: string;
104
+ /**
105
+ * Kept so a per-call `agentId` can inherit it.
106
+ *
107
+ * The key belongs to the entity type, which is a property of the client, while the agent varies
108
+ * per call - so naming it once at `wrap` and then only the agent per call is the shape a
109
+ * multi-agent server wants. The folded dimension alone would not do: an override needs the key
110
+ * itself to build its own.
111
+ */
112
+ attributionKey?: string;
104
113
  };
105
114
  /**
106
115
  * What nobody told the SDK, and it will not guess.
@@ -50,13 +50,13 @@ function agentDimension(context) {
50
50
  if (!context.agentId.trim()) {
51
51
  throw new Error('stiggContext.agentId is empty - an agent nobody can name is also one nothing can bill.');
52
52
  }
53
- if (!context.agentType) {
54
- throw new Error(`stiggContext.agentId is '${context.agentId}' but no agentType says which key to send it under. ` +
55
- 'Pass `agentType: { entityTypeId, attributionKey }` - the pair the entity type was created ' +
56
- 'with. Without it the agent resolves no entity and the spend lands on the customer, which ' +
57
- 'looks exactly like a call that was never governed.');
53
+ if (!context.attributionKey?.trim()) {
54
+ throw new Error(`stiggContext.agentId is '${context.agentId}' but no attributionKey says which key to send ` +
55
+ "it under. Pass the key the agent's entity type declares. Without it the agent resolves " +
56
+ 'no entity and the spend lands on the customer, which looks exactly like a call that was ' +
57
+ 'never governed.');
58
58
  }
59
- return { [context.agentType.attributionKey]: context.agentId };
59
+ return { [context.attributionKey]: context.agentId };
60
60
  }
61
61
  /**
62
62
  * Validates and derives, once. Everything expensive about a context happens here rather than on
@@ -71,6 +71,10 @@ function resolveContext(context, correlationId,
71
71
  // `wrap` passes false: a client whose calls each name their own customer is legal, and its
72
72
  // types already say which calls still owe one. Every path that reaches the engine resolves it.
73
73
  { requireCustomer = true, defaults } = {}) {
74
+ // Checked here as well as in `overrideContext`, and this is the half that matters more: `wrap`
75
+ // names an agent and its entitlement once for every call through the client, so a typo here
76
+ // governs *everything* by the fallback rather than one call.
77
+ assertKnownContext(context, requireCustomer ? 'stiggContext' : "wrap's stiggContext");
74
78
  const customerId = context.customerId?.trim() ?? '';
75
79
  if (!customerId && requireCustomer)
76
80
  throw (0, exports.noCustomer)();
@@ -95,8 +99,58 @@ function resolveContext(context, correlationId,
95
99
  idempotencyKey: context.idempotencyKey,
96
100
  correlationId: context.correlationId?.trim() || correlationId,
97
101
  checkedId: context.entitlement ? (0, types_1.entitlementId)(context.entitlement) : undefined,
102
+ attributionKey: context.attributionKey,
98
103
  };
99
104
  }
105
+ /**
106
+ * Every field a `stiggContext` may carry.
107
+ *
108
+ * Typed as a total record of `keyof StiggContext` rather than written as a list, so a field added
109
+ * to the context without being added here is a compile error instead of a key this rejects.
110
+ */
111
+ const CONTEXT_KEYS = {
112
+ customerId: true,
113
+ agentId: true,
114
+ attributionKey: true,
115
+ dimensions: true,
116
+ entitlement: true,
117
+ checkErrorAction: true,
118
+ usageMapper: true,
119
+ ingestMode: true,
120
+ eventName: true,
121
+ idempotencyKey: true,
122
+ correlationId: true,
123
+ };
124
+ /**
125
+ * Refuses a context carrying a field this SDK does not read.
126
+ *
127
+ * Almost always a typo, and a silent one without this: the context is read key by key, so an
128
+ * unknown key simply never arrives. A call meaning to spend as one agent spends as whatever
129
+ * `wrap` was given instead, and the only trace is a bill against the wrong entity.
130
+ *
131
+ * The types catch it everywhere but `messages.stream`, which Anthropic declares generic -
132
+ * `stream<Params extends MessageStreamParams>(body: Params, ...)` - and a generic parameter
133
+ * infers from its argument rather than checking it for excess properties. `WrappedClient`
134
+ * intersects the overlay with the vendor's own type to stay assignable to it, and an intersection
135
+ * of call signatures is an overload set resolved in order, so a context the overlay rejects falls
136
+ * through to that signature and is taken. Hence the runtime guarantee, which covers every surface
137
+ * as well as JS callers and hand-typed call sites.
138
+ *
139
+ * Thrown rather than warned, like the other guards in this file: the call it would have governed
140
+ * is in front of money, and a warning is read after the bill.
141
+ *
142
+ * `Object.hasOwn` rather than `in`, which walks the prototype chain and so answers true for
143
+ * `toString`, `constructor` and `hasOwnProperty` - the names likeliest to arrive from a JS caller
144
+ * building a context out of an object it did not create.
145
+ */
146
+ function assertKnownContext(context, where) {
147
+ for (const key of Object.keys(context)) {
148
+ if (Object.hasOwn(CONTEXT_KEYS, key))
149
+ continue;
150
+ throw new Error(`${where} has no field \`${key}\` - this SDK would ignore it, and the call would be ` +
151
+ `governed by whatever remained instead. Valid fields: ${Object.keys(CONTEXT_KEYS).join(', ')}.`);
152
+ }
153
+ }
100
154
  /**
101
155
  * One call's override, applied over what `wrap` already resolved.
102
156
  *
@@ -106,6 +160,7 @@ function resolveContext(context, correlationId,
106
160
  * fields last, because nothing before the response depends on them.
107
161
  */
108
162
  function overrideContext(base, override) {
163
+ assertKnownContext(override, 'stiggContext');
109
164
  // The cheap test first: these are the only fields that can change which budget is consulted,
110
165
  // so a call overriding only, say, `idempotencyKey` re-derives nothing.
111
166
  const rebased = override.customerId !== undefined ||
@@ -125,10 +180,21 @@ function overrideContext(base, override) {
125
180
  entitlement = override.entitlement;
126
181
  checkedId = (0, types_1.entitlementId)(override.entitlement);
127
182
  }
183
+ // The key falls back to the client's own, so a call naming a different agent of the same type
184
+ // need only name the agent - which is the multi-agent case, and the common one.
185
+ const attributionKey = override.attributionKey ?? base.attributionKey;
186
+ const agent = agentDimension({ ...override, attributionKey });
128
187
  // Merged per key, not replaced: a per-call tenant must not drop the client's own attribution.
129
- const agent = agentDimension({ ...override, agentType: override.agentType });
130
- dimensions =
131
- override.dimensions || agent ? { ...base.dimensions, ...override.dimensions, ...agent } : base.dimensions;
188
+ //
189
+ // With one exception. An override naming a *different* entity type writes its agent under a
190
+ // different key, and the client's own key would otherwise ride along beside it - two agent
191
+ // dimensions on one call, which governance resolves as two entities and bills accordingly.
192
+ // That one is dropped by name: it describes an agent this call is not acting as. Every other
193
+ // dimension the client set is kept, because none of them is claiming to be the agent.
194
+ const inherited = agent && base.attributionKey && attributionKey !== base.attributionKey
195
+ ? Object.fromEntries(Object.entries(base.dimensions).filter(([key]) => key !== base.attributionKey))
196
+ : base.dimensions;
197
+ dimensions = override.dimensions || agent ? { ...inherited, ...override.dimensions, ...agent } : base.dimensions;
132
198
  }
133
199
  return {
134
200
  customerId,
@@ -141,6 +207,7 @@ function overrideContext(base, override) {
141
207
  usageMapper: override.usageMapper ?? base.usageMapper,
142
208
  idempotencyKey: override.idempotencyKey ?? base.idempotencyKey,
143
209
  correlationId: override.correlationId?.trim() || base.correlationId,
210
+ attributionKey: override.attributionKey ?? base.attributionKey,
144
211
  };
145
212
  }
146
213
  /** Whether a context gates at all. The enforcement switch, read on every call. */
@@ -4,13 +4,35 @@ import { type ResolvedContext } from './context';
4
4
  import type { GovernanceEngine } from '../engine/engine';
5
5
  import type { ErrorContext } from '../config';
6
6
  import type { Logger } from '../logger';
7
- import { type GovernanceDecision, type IngestResult, type Measurement, type Metrics, type TokenUsage } from '../types';
7
+ import { type AccessDecision, type IngestResult, type Measurement, type Metrics, type TokenUsage } from '../types';
8
8
  /** Everything the session reports back while it works. Set once, at `wrap`. */
9
+ /**
10
+ * Who the observation was for, which the observation itself cannot say.
11
+ *
12
+ * Neither an {@link AccessDecision} nor an {@link IngestResult} names the caller: `deniedBy`
13
+ * appears only on a refusal and names the entity that blocked, not the agent that asked. So one
14
+ * client serving several agents produced a stream of events nothing could attribute, and the only
15
+ * way to tell them apart was a separate wrapped client per agent - each with its own session
16
+ * state, which is a real cost rather than an inconvenience.
17
+ *
18
+ * `correlationId` is the one to reach for when reconciling: it ties a decision to the ingest that
19
+ * followed it, which is what anyone chasing a billing discrepancy actually needs.
20
+ */
21
+ export type HookContext = {
22
+ /** The tenant this call was scoped to. */
23
+ customerId: string;
24
+ /** What it was attributed by - the agent under its entity type's own key, plus any extra. */
25
+ dimensions: Record<string, string>;
26
+ /** Shared by every observation of one call, and sent on the wire as `x-stigg-run-id`. */
27
+ correlationId: string;
28
+ /** What the call gated on, when it gated on anything. Undefined on a metering-only run. */
29
+ entitlementId?: string;
30
+ };
9
31
  export type SessionHooks = {
10
32
  /** Every check this client makes. The whole decision, already formatted as `.summary`. */
11
- onDecision?: (decision: GovernanceDecision) => void;
33
+ onDecision?: (decision: AccessDecision, context: HookContext) => void;
12
34
  /** Every ingest this client makes - including the ones that failed. */
13
- onIngest?: (result: IngestResult) => void;
35
+ onIngest?: (result: IngestResult, context: HookContext) => void;
14
36
  };
15
37
  export type SessionDeps = {
16
38
  /**
@@ -28,7 +50,7 @@ export type SessionDeps = {
28
50
  hooks: SessionHooks;
29
51
  };
30
52
  /** What a client did, for whoever wants to print it. Spans every context it served. */
31
- export type GovernanceSummary = {
53
+ export type SessionSummary = {
32
54
  checks: number;
33
55
  ingested: number;
34
56
  /**
@@ -40,7 +62,7 @@ export type GovernanceSummary = {
40
62
  */
41
63
  reported: Metrics;
42
64
  ingestFailures: number;
43
- lastDecision?: GovernanceDecision;
65
+ lastDecision?: AccessDecision;
44
66
  };
45
67
  export type IngestOptions = {
46
68
  /** Overrides the context's own name for this one report - the vendor method that was gated. */
@@ -66,8 +88,10 @@ export declare class GovernanceSession {
66
88
  private decision?;
67
89
  /** The unmapped-feature warning is said once per client, not once per call. */
68
90
  private warnedUnmapped;
91
+ /** Said once per client: the reason is the same every call, and so is the fix. */
92
+ private warnedRounded;
69
93
  constructor(deps: SessionDeps);
70
- get lastDecision(): GovernanceDecision | undefined;
94
+ get lastDecision(): AccessDecision | undefined;
71
95
  /** The attribution to send downstream when part of the work happens in another process. */
72
96
  headers(context: ResolvedContext): GovernanceHeaders;
73
97
  /**
@@ -87,7 +111,7 @@ export declare class GovernanceSession {
87
111
  * The enforcement path. Returns a decision, never throws - a refusal is a commercial event the
88
112
  * application routes on, not an exception it recovers from.
89
113
  */
90
- check(context: ResolvedContext, requestedUsage?: number): Promise<GovernanceDecision>;
114
+ check(context: ResolvedContext, requestedUsage?: number): Promise<AccessDecision>;
91
115
  /** Applies the context's mapper to a token measurement. The path every binding uses. */
92
116
  ingestTokens(context: ResolvedContext, usage: TokenUsage, options?: IngestOptions): Promise<IngestResult>;
93
117
  /**
@@ -127,8 +151,8 @@ export declare class GovernanceSession {
127
151
  */
128
152
  failedToMeasure(context: ResolvedContext, err: unknown): void;
129
153
  /** Waits for in-flight ingests, so a caller knows what was actually delivered. */
130
- drain(): Promise<GovernanceSummary>;
131
- summary(): GovernanceSummary;
154
+ drain(): Promise<SessionSummary>;
155
+ summary(): SessionSummary;
132
156
  /** `correlationId:n` - distinct per call, for a vendor that offered no id of its own. */
133
157
  private nextKey;
134
158
  /**
@@ -144,6 +168,11 @@ export declare class GovernanceSession {
144
168
  * so the most that can be said is that nothing reported names what this call checks, and whether
145
169
  * a meter closes that gap is configuration this process cannot see.
146
170
  */
171
+ /**
172
+ * Says that an attributed run rounded, because an overcharge the caller did not ask for must
173
+ * not be silent - that silence is the thing this SDK was told off for in the first place.
174
+ */
175
+ private warnRounded;
147
176
  private warnIfNothingDrains;
148
177
  private record;
149
178
  /**