@stigg/agent-sdk-js 0.0.0-stage → 1.0.1

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 (82) hide show
  1. package/README.md +1147 -2
  2. package/dist/bindings/client/core/registry.d.ts +12 -0
  3. package/dist/bindings/client/core/registry.js +22 -0
  4. package/dist/bindings/client/core/types.d.ts +117 -0
  5. package/dist/bindings/client/core/types.js +13 -0
  6. package/dist/bindings/client/core/wrap-client.d.ts +6 -0
  7. package/dist/bindings/client/core/wrap-client.js +258 -0
  8. package/dist/bindings/client/gateways/litellm/litellm-recipe.d.ts +17 -0
  9. package/dist/bindings/client/gateways/litellm/litellm-recipe.js +171 -0
  10. package/dist/bindings/client/gateways/litellm/types.d.ts +30 -0
  11. package/dist/bindings/client/gateways/litellm/types.js +2 -0
  12. package/dist/bindings/client/gateways/openrouter/openrouter-recipe.d.ts +15 -0
  13. package/dist/bindings/client/gateways/openrouter/openrouter-recipe.js +141 -0
  14. package/dist/bindings/client/gateways/openrouter/types.d.ts +29 -0
  15. package/dist/bindings/client/gateways/openrouter/types.js +2 -0
  16. package/dist/bindings/client/providers/anthropic/anthropic-recipe.d.ts +30 -0
  17. package/dist/bindings/client/providers/anthropic/anthropic-recipe.js +79 -0
  18. package/dist/bindings/client/providers/gemini/gemini-recipe.d.ts +54 -0
  19. package/dist/bindings/client/providers/gemini/gemini-recipe.js +243 -0
  20. package/dist/bindings/client/providers/openai/openai-recipe.d.ts +43 -0
  21. package/dist/bindings/client/providers/openai/openai-recipe.js +80 -0
  22. package/dist/bindings/client/providers/typesafe/typesafe-recipe.d.ts +33 -0
  23. package/dist/bindings/client/providers/typesafe/typesafe-recipe.js +73 -0
  24. package/dist/bindings/proxy/headers.d.ts +6 -0
  25. package/dist/bindings/proxy/headers.js +12 -0
  26. package/dist/bindings/proxy/index.d.ts +21 -0
  27. package/dist/bindings/proxy/index.js +18 -0
  28. package/dist/bindings/proxy/types.d.ts +8 -0
  29. package/dist/bindings/proxy/types.js +2 -0
  30. package/dist/bindings/shared/estimate.d.ts +27 -0
  31. package/dist/bindings/shared/estimate.js +38 -0
  32. package/dist/bindings/shared/meter-stream.d.ts +18 -0
  33. package/dist/bindings/shared/meter-stream.js +151 -0
  34. package/dist/bindings/shared/token-usage.d.ts +148 -0
  35. package/dist/bindings/shared/token-usage.js +414 -0
  36. package/dist/bindings/shared/types.d.ts +79 -0
  37. package/dist/bindings/shared/types.js +2 -0
  38. package/dist/bindings/shared/weighted-metrics.d.ts +37 -0
  39. package/dist/bindings/shared/weighted-metrics.js +44 -0
  40. package/dist/config.d.ts +136 -0
  41. package/dist/config.js +97 -0
  42. package/dist/core/context.d.ts +151 -0
  43. package/dist/core/context.js +154 -0
  44. package/dist/core/dimensions.d.ts +22 -0
  45. package/dist/core/dimensions.js +47 -0
  46. package/dist/core/hooks.d.ts +21 -0
  47. package/dist/core/hooks.js +47 -0
  48. package/dist/core/session.d.ts +161 -0
  49. package/dist/core/session.js +403 -0
  50. package/dist/decision-text.d.ts +15 -0
  51. package/dist/decision-text.js +54 -0
  52. package/dist/engine/composite-engine.d.ts +72 -0
  53. package/dist/engine/composite-engine.js +135 -0
  54. package/dist/engine/create-engine.d.ts +16 -0
  55. package/dist/engine/create-engine.js +93 -0
  56. package/dist/engine/decision.d.ts +37 -0
  57. package/dist/engine/decision.js +128 -0
  58. package/dist/engine/engine.d.ts +166 -0
  59. package/dist/engine/engine.js +11 -0
  60. package/dist/engine/node-sdk-engine.d.ts +125 -0
  61. package/dist/engine/node-sdk-engine.js +262 -0
  62. package/dist/engine/report.d.ts +106 -0
  63. package/dist/engine/report.js +185 -0
  64. package/dist/engine/rest-governance-engine.d.ts +47 -0
  65. package/dist/engine/rest-governance-engine.js +220 -0
  66. package/dist/errors.d.ts +78 -0
  67. package/dist/errors.js +151 -0
  68. package/dist/headers.d.ts +27 -0
  69. package/dist/headers.js +63 -0
  70. package/dist/index.d.ts +48 -0
  71. package/dist/index.js +85 -0
  72. package/dist/internal.d.ts +33 -0
  73. package/dist/internal.js +81 -0
  74. package/dist/logger.d.ts +15 -0
  75. package/dist/logger.js +20 -0
  76. package/dist/sdk-identity.d.ts +36 -0
  77. package/dist/sdk-identity.js +43 -0
  78. package/dist/stigg-agent-sdk.d.ts +242 -0
  79. package/dist/stigg-agent-sdk.js +594 -0
  80. package/dist/types.d.ts +531 -0
  81. package/dist/types.js +97 -0
  82. package/package.json +80 -3
@@ -0,0 +1,594 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.StiggAgentSDK = exports.DEFAULT_RECIPES = void 0;
4
+ /**
5
+ * The SDK's entry point, and the run lifecycle it exposes:
6
+ *
7
+ * register -> check -> execute -> ingest -> (deregister)
8
+ *
9
+ * `register` is one call because it is one idea - "this agent is about to do something" - but it
10
+ * is two operations underneath, and they have very different costs. Giving the agent an identity
11
+ * is a write, so it happens once per agent and is remembered; opening the run is not a write at
12
+ * all, so it happens every time and is what warms the budget. Calling `register` in a loop is
13
+ * therefore safe: the write does not repeat.
14
+ *
15
+ * This file is the composition root - the one place that knows both the core and the bindings.
16
+ * Nothing under `core/` or `engine/` imports a binding, and no binding imports another.
17
+ */
18
+ const node_crypto_1 = require("node:crypto");
19
+ const config_1 = require("./config");
20
+ const create_engine_1 = require("./engine/create-engine");
21
+ const engine_1 = require("./engine/engine");
22
+ const errors_1 = require("./errors");
23
+ const headers_1 = require("./headers");
24
+ const dimensions_1 = require("./core/dimensions");
25
+ const anthropic_recipe_1 = require("./bindings/client/providers/anthropic/anthropic-recipe");
26
+ const gemini_recipe_1 = require("./bindings/client/providers/gemini/gemini-recipe");
27
+ const litellm_recipe_1 = require("./bindings/client/gateways/litellm/litellm-recipe");
28
+ const openai_recipe_1 = require("./bindings/client/providers/openai/openai-recipe");
29
+ const openrouter_recipe_1 = require("./bindings/client/gateways/openrouter/openrouter-recipe");
30
+ const typesafe_recipe_1 = require("./bindings/client/providers/typesafe/typesafe-recipe");
31
+ const registry_1 = require("./bindings/client/core/registry");
32
+ const wrap_client_1 = require("./bindings/client/core/wrap-client");
33
+ const token_usage_1 = require("./bindings/shared/token-usage");
34
+ const context_1 = require("./core/context");
35
+ const session_1 = require("./core/session");
36
+ const hooks_1 = require("./core/hooks");
37
+ const types_1 = require("./types");
38
+ /**
39
+ * Recipes are data - method paths and usage extractors - so wiring them here costs nothing at
40
+ * runtime and pulls in no vendor package. An orchestration adapter is different: it needs its
41
+ * framework loaded, so it stays an explicit subpath import.
42
+ */
43
+ /**
44
+ * Order matters: the gateways are matched before the plain OpenAI adapter, because every one of
45
+ * them *is* an OpenAI client by shape and only the base URL says which. The bare adapter would
46
+ * otherwise claim them and lose what only a gateway knows - the model that actually served after
47
+ * routing, and the gateway's own request id.
48
+ *
49
+ * Matching them costs nothing in units: they report what the gateway stated and leave what it is
50
+ * worth to the run's mapper, like every other vendor. Billing the `cost` they add rather than the
51
+ * tokens is that mapper's business, and stays the caller's to decide.
52
+ */
53
+ exports.DEFAULT_RECIPES = [
54
+ anthropic_recipe_1.anthropicRecipe,
55
+ (0, openrouter_recipe_1.openrouterRecipe)(),
56
+ (0, litellm_recipe_1.litellmRecipe)(),
57
+ openai_recipe_1.openAIRecipe,
58
+ typesafe_recipe_1.typesafeRecipe,
59
+ // Gemini's two surfaces are matched separately - a client and one of its chats are different
60
+ // objects with no method in common, so neither guard can see the other's traffic.
61
+ gemini_recipe_1.geminiRecipe,
62
+ gemini_recipe_1.geminiChatRecipe,
63
+ ];
64
+ /**
65
+ * What a run's events are called when nothing names them.
66
+ *
67
+ * A client binding overrides this with the vendor call it wrapped - `anthropic.messages.create` -
68
+ * which is the name a Stigg meter should filter on. This is only the floor, for a run opened with
69
+ * no recipe behind it at all.
70
+ */
71
+ const DEFAULT_EVENT_NAME = 'agent.usage';
72
+ /**
73
+ * Which metric a gateway's report draws the wallet with when the caller names no entitlement.
74
+ *
75
+ * A routing default, not a gate - which is why the run paths have none and this one does. A run
76
+ * that names no entitlement is asking not to be gated, and substituting one there would gate it;
77
+ * `ingestFromHeaders` never checks anything, so nothing here can be made to look enforced. All this
78
+ * decides is whether a metric literally called `credits` draws the credit wallet or is reported as
79
+ * a feature by that name - and dropping it would silently re-route exactly those reports at the
80
+ * next deploy, on the one path with no check to notice and an `'async'` mode that answers nothing.
81
+ */
82
+ const DEFAULT_WALLET = { currencyId: 'credits' };
83
+ /**
84
+ * Whether this call means to write the agent's budget or its place in the tree. One predicate,
85
+ * used by both the operation that acts on it and the memo that decides whether to run at all -
86
+ * two lists that disagreed about what counts is how a budget went missing before.
87
+ */
88
+ const hasPlacement = (input) => input.parentId !== undefined || input.usageLimit !== undefined || input.usageLimitOnCreate !== undefined;
89
+ class StiggAgentSDK {
90
+ config;
91
+ clients;
92
+ /** Every session this SDK handed out, so `flush()` can drain all of them at once. */
93
+ sessions = new Set();
94
+ /** The session the no-client paths share. Built on first use. */
95
+ loose;
96
+ /**
97
+ * `customer:agent` -> what was last said about that agent, so a repeat of the same description
98
+ * is not written again per run. The value is a fingerprint of the describable attributes, not
99
+ * an id: the id is in the key.
100
+ */
101
+ registered = new Map();
102
+ /**
103
+ * Bare registrations still on the wire, so the burst of runs that opens a process writes once
104
+ * rather than once each. Only the memoized path is shared - see `register`.
105
+ */
106
+ registering = new Map();
107
+ started;
108
+ engineState;
109
+ constructor(target,
110
+ // A URL is a string and the options are not, which is the whole of the discrimination - the
111
+ // same shape the target itself uses. It exists so a keyless target can still name a knob
112
+ // without an `undefined` standing in for the URL it already carries.
113
+ baseUrlOrOptions, options = {}) {
114
+ const baseUrl = typeof baseUrlOrOptions === 'string' ? baseUrlOrOptions : undefined;
115
+ this.config = (0, config_1.resolveConfig)(target, baseUrl, typeof baseUrlOrOptions === 'object' ? baseUrlOrOptions : options);
116
+ this.clients = (0, registry_1.createClientRegistry)(exports.DEFAULT_RECIPES);
117
+ }
118
+ /** Releases what this SDK's engine holds open. */
119
+ async close() {
120
+ // A startup still in flight hands back an engine after this returns, and with the client
121
+ // already out of the registry there is nothing left holding its cache and its sockets. So
122
+ // wait for it: a close that reports success while a poller is still running is not one.
123
+ const started = await this.started?.catch(() => undefined);
124
+ await (started ?? this.engineState)?.engine.close?.();
125
+ }
126
+ /**
127
+ * Forces the engine to resolve now and reports which client serves what. Optional - every
128
+ * async call below does it anyway - but useful when you want the answer before the first run.
129
+ */
130
+ async init() {
131
+ return (await this.ensureStarted()).resolution;
132
+ }
133
+ /** Undefined until the engine has resolved. `init()` returns it directly. */
134
+ get resolution() {
135
+ return this.engineState?.resolution;
136
+ }
137
+ /** Available after the engine has resolved; the bindings that need it only run after that. */
138
+ get engine() {
139
+ if (!this.engineState)
140
+ throw new Error('The Stigg engine is not ready yet - await init() or any async call first.');
141
+ return this.engineState.engine;
142
+ }
143
+ /**
144
+ * Gives the agent an identity, a budget, and a place in the tree, and returns the agent id to
145
+ * open a run against.
146
+ *
147
+ * The id is the one you passed: nothing about the run depends on this call having happened, so
148
+ * returning it is a convenience rather than a handle. Skip `register` entirely and the agent
149
+ * has no entity - the run still works, and checks and usage apply to the customer instead.
150
+ *
151
+ * Idempotent, and remembered per process: an upsert is cheap, but it is still a write, so
152
+ * calling this on a hot path does not repeat it. A call carrying a budget or a parent always
153
+ * writes, because that is a deliberate change rather than a formality.
154
+ *
155
+ * `run.start(runOptions, agentOptions)` calls this for you with the run's own scope, which is
156
+ * the usual way in; reach for it directly to provision an agent no run is about to use.
157
+ */
158
+ async register(input) {
159
+ const { customerId, agentId } = input;
160
+ // An id reaches the entity payload and the memo key alike, and an empty one is a public
161
+ // identifier for nothing - provisioned, unfindable, and billed against by name. The type is
162
+ // checked rather than assumed: `.trim()` on the number a JS caller passed throws a bare
163
+ // TypeError from inside the SDK, which reads as a bug in it rather than in the call.
164
+ if (typeof agentId !== 'string' || !agentId.trim()) {
165
+ throw new Error('register needs an agentId - an empty one provisions an entity nobody can name.');
166
+ }
167
+ if (input.usageLimit !== undefined && input.usageLimitOnCreate !== undefined) {
168
+ throw new Error(`Agent ${agentId} was given both usageLimit and usageLimitOnCreate. They are different ` +
169
+ 'budget modes - one overwrites on every call, the other seeds once - so pass exactly one.');
170
+ }
171
+ // The placement, built once: `hasPlacement` is the predicate, and the object it produces is what
172
+ // proves the entitlement is there - a second `hasPlacement(...)` further down would be a second
173
+ // list of what counts, which is how a budget goes missing.
174
+ let placement;
175
+ if (hasPlacement(input)) {
176
+ const { entitlement } = input;
177
+ if (!entitlement) {
178
+ throw new Error(`Agent ${agentId} was given a budget or a parent, but no entitlement to write it against. ` +
179
+ 'An assignment is written over (entity x entitlement), so there is nowhere to put this ' +
180
+ 'one. Name `entitlement` on the run that starts the agent, or drop usageLimit, ' +
181
+ 'usageLimitOnCreate and parentId - a run that names no entitlement meters only.');
182
+ }
183
+ // `run()` guards this too. A placement writes an assignment against the entitlement, so an
184
+ // empty one here persists a budget attached to nothing rather than merely routing badly.
185
+ const invalid = (0, types_1.entitlementError)(entitlement);
186
+ if (invalid)
187
+ throw invalid;
188
+ placement = {
189
+ parentId: input.parentId,
190
+ entitlement,
191
+ usageLimit: input.usageLimit,
192
+ usageLimitOnCreate: input.usageLimitOnCreate,
193
+ cadence: input.cadence,
194
+ };
195
+ }
196
+ const key = `${customerId}:${agentId}`;
197
+ // Memoized on the description, not just the pair: a second call describing the agent
198
+ // differently is asking for that description to be written, and skipping it leaves the
199
+ // account holding a name the application has already stopped using. The agent's identity is
200
+ // `key`; this is only what is being said about it.
201
+ //
202
+ // Metadata is compared by its entries, sorted: `JSON.stringify` follows insertion order, so
203
+ // the same pairs built by a spread or an `Object.fromEntries` would fingerprint differently
204
+ // and re-upsert every run - which is the one case this memo exists to prevent.
205
+ const metadata = describableMetadata(agentId, input.metadata);
206
+ const describedAs = JSON.stringify([
207
+ input.entityTypeId,
208
+ input.displayName ?? null,
209
+ metadata ? Object.entries(metadata).sort(([a], [b]) => a.localeCompare(b)) : null,
210
+ ]);
211
+ if (!placement && this.registered.get(key) === describedAs)
212
+ return agentId;
213
+ // Everything above is a property of the arguments, so it is answered before the engine is
214
+ // built: `run()` already validates in this order, and a malformed call that reports itself as
215
+ // a probe timeout sends the reader looking at the network for a typo they can see.
216
+ await this.ensureStarted();
217
+ if (!this.engine.capabilities.provisioning) {
218
+ // Only a call that asked for something governance-only is an error. A budget or a parent
219
+ // cannot be honoured, and carrying on would leave an agent that looks governed with a
220
+ // budget that was never written - so that fails. A bare registration asked for neither.
221
+ if (placement) {
222
+ throw new errors_1.GovernanceNotEnabledError(`Cannot give agent ${agentId} a budget or a parent: governance is not enabled for this ` +
223
+ 'account, so there is nowhere to write the assignment. Contact Stigg to enable it, or ' +
224
+ 'register without them - checks and usage reporting work at the customer level.');
225
+ }
226
+ this.config.logger.warn(`[stigg] governance is not enabled: agent ${agentId} has no entity, so checks and usage ` +
227
+ 'apply to the customer rather than to the agent.');
228
+ return agentId;
229
+ }
230
+ const write = async () => {
231
+ await this.engine.upsertEntity(customerId, {
232
+ id: agentId,
233
+ entityTypeId: input.entityTypeId,
234
+ displayName: input.displayName,
235
+ metadata,
236
+ }, placement);
237
+ this.registered.set(key, describedAs);
238
+ this.config.logger.info(`[stigg] agent ${agentId} registered under customer ${customerId}`);
239
+ };
240
+ // Only the bare registration is shared. A call carrying a budget or a parent is a deliberate
241
+ // change that always writes, and two of those are two different instructions - collapsing
242
+ // them into one would silently drop whichever lost the race.
243
+ if (placement) {
244
+ await write();
245
+ return agentId;
246
+ }
247
+ // The memo above cannot help until the first write lands, which is exactly when a process
248
+ // opening its runs in parallel asks. Cleared on settle either way, so a failure is retryable
249
+ // and the callers sharing it all hear about it.
250
+ //
251
+ // Keyed on the description as well as the agent, for the same reason the settled memo is: two
252
+ // concurrent calls describing one agent differently are two different instructions, and
253
+ // sharing a promise between them would return success to the second having written only the
254
+ // first's `displayName`, metadata and entity type.
255
+ const shared = `${key}:${describedAs}`;
256
+ const inFlight = this.registering.get(shared) ?? write().finally(() => this.registering.delete(shared));
257
+ this.registering.set(shared, inFlight);
258
+ await inFlight;
259
+ return agentId;
260
+ }
261
+ /**
262
+ * Removes the agent's entity. Optional, mirrors `register`, and never relied on: it may never
263
+ * fire - a crash, an abandoned stream, an interrupt that is never resumed - which is why warm
264
+ * state has a TTL rather than waiting to be released.
265
+ */
266
+ async deregister(input) {
267
+ await this.ensureStarted();
268
+ const { customerId, agentId } = input;
269
+ if (!this.engine.capabilities.provisioning) {
270
+ throw new errors_1.GovernanceNotEnabledError(`Cannot deregister agent ${agentId}: governance is not enabled for this account.`);
271
+ }
272
+ await this.engine.archiveEntity(customerId, agentId);
273
+ this.registered.delete(`${customerId}:${agentId}`);
274
+ }
275
+ /**
276
+ * Governs a vendor client and hands back a copy whose calls are checked and metered.
277
+ *
278
+ * The whole lifecycle, in one step. `stiggContext` here is the default for every call through
279
+ * this client; any single call overrides it by putting its own `stiggContext` in the vendor's
280
+ * parameters. The client you passed is never mutated, so it keeps working ungoverned.
281
+ *
282
+ * The vendor is recognised from the client's own shape and base URL rather than named; pass
283
+ * `recipe` only for a vendor the built-ins cannot identify.
284
+ *
285
+ * Warms the entitlement cache before returning when the context names both a customer and an
286
+ * entitlement, so the calls that follow read local state. Nothing is cached in this package -
287
+ * see {@link GovernanceSession.warmUp} for which cache that is and when there is one at all.
288
+ */
289
+ wrap(client, options = {}) {
290
+ // Started, not awaited. Resolving the engine is a probe over the network, and this is a setup
291
+ // call that reads as instant - so the work begins here and the first call that needs an
292
+ // engine waits on it. `init()` is there for anyone who wants that settled up front.
293
+ //
294
+ // The rejection has to be caught here, though, and that is not tidiness: nobody is holding
295
+ // this promise, so a failed probe became an unhandled rejection - which Node treats as fatal
296
+ // by default. A Stigg outage would have taken the application down at `wrap()`, before a
297
+ // single call was made, which is the exact opposite of what a soft limit promises. Reported
298
+ // through the application's own error channel instead; the first call that needs an engine
299
+ // awaits the same promise and surfaces it again somewhere the caller can see it.
300
+ void this.ensureStarted().catch((err) => this.reportStartupFailure(err));
301
+ const recipe = options.recipe ?? this.clients.find(client);
302
+ if (!recipe) {
303
+ // Silently returning an unwrapped client would mean one that quietly meters nothing.
304
+ throw new Error(`No client recipe matches this client. Known vendors: ${this.clients.vendors().join(', ') || 'none'}. ` +
305
+ 'Pass `recipe` to name an adapter for it.');
306
+ }
307
+ const session = this.newSession(options.hooks);
308
+ // Resolved and validated once, here, so a call that overrides nothing has nothing to do. A
309
+ // client wrapped with no customer is legal - its calls each name their own, and the types say
310
+ // so - so that one case resolves per call instead.
311
+ const context = (0, context_1.resolveContext)(options.stiggContext ?? {}, (0, node_crypto_1.randomUUID)(), {
312
+ requireCustomer: false,
313
+ defaults: this.contextDefaults,
314
+ });
315
+ // Said once, at the boundary that owns the decision. A client that gates nothing looks exactly
316
+ // like one that gates, right up to the call that should have been refused and was not - so
317
+ // this is the only place the difference exists before it costs money. Only for a client that
318
+ // is otherwise fully configured: one whose calls each name their own customer may well name
319
+ // an entitlement too, and warning there would cry wolf on every wrap.
320
+ if (context.customerId && !(0, context_1.isEnforced)(context)) {
321
+ this.config.logger.warn('[stigg] this client named no entitlement, so nothing is checked and no call can be ' +
322
+ "refused - it meters only. Pass `entitlement: { currencyId: 'credits' }` in " +
323
+ '`stiggContext`, at the wrap or on a call, to gate it.');
324
+ }
325
+ const governed = (0, wrap_client_1.wrapClient)(client, recipe, session, context);
326
+ this.attachGovernance(governed, session, context);
327
+ // Not awaited: warming is an optimization, and it logs rather than throws.
328
+ if (context.customerId)
329
+ void session.warmUp(context);
330
+ return governed;
331
+ }
332
+ /**
333
+ * Hangs the governance handle off the wrapped client. Refuses to overwrite a `stigg` the vendor
334
+ * already has: silently replacing one of its own properties would be a worse surprise.
335
+ */
336
+ attachGovernance(client, session, base) {
337
+ if (client.stigg !== undefined) {
338
+ throw new Error('This client already has a `stigg` property, so the governance handle has nowhere to go. ' +
339
+ 'Governance would be invisible on it, so this is refused rather than overwritten.');
340
+ }
341
+ const resolve = (override) => {
342
+ const merged = override ? (0, context_1.overrideContext)(base, override) : base;
343
+ if (!merged.customerId)
344
+ throw (0, context_1.noCustomer)();
345
+ return merged;
346
+ };
347
+ const governance = {
348
+ flush: () => session.drain(),
349
+ close: async () => {
350
+ await session.drain();
351
+ this.sessions.delete(session);
352
+ },
353
+ summary: () => session.summary(),
354
+ get lastDecision() {
355
+ return session.lastDecision;
356
+ },
357
+ headers: (override) => session.headers(resolve(override)),
358
+ check: (override, requestedUsage) => session.check(resolve(override), requestedUsage),
359
+ report: (metrics, override) => session.ingestMetrics(resolve(override), metrics),
360
+ };
361
+ Object.defineProperty(client, 'stigg', { value: governance, enumerable: false });
362
+ }
363
+ /**
364
+ * The gate, with no client to wrap - what a framework binding reaches for, and what the gateway
365
+ * example checks with before it proxies.
366
+ */
367
+ async check(context, requestedUsage = 1) {
368
+ await this.ensureStarted();
369
+ return this.standalone().check((0, context_1.resolveContext)(context, (0, node_crypto_1.randomUUID)(), { defaults: this.contextDefaults }), requestedUsage);
370
+ }
371
+ /** The meter, with no client to wrap. Metrics already in Stigg's own vocabulary. */
372
+ async report(context, metrics) {
373
+ await this.ensureStarted();
374
+ return this.standalone().ingestMetrics((0, context_1.resolveContext)(context, (0, node_crypto_1.randomUUID)(), { defaults: this.contextDefaults }), metrics);
375
+ }
376
+ /** The `x-stigg-*` attribution for work continuing in another process, with no client involved. */
377
+ governanceHeaders(context) {
378
+ return this.standalone().headers((0, context_1.resolveContext)(context, (0, node_crypto_1.randomUUID)(), { defaults: this.contextDefaults }));
379
+ }
380
+ /**
381
+ * The policy a context falls back to when neither the call nor `wrap` named one.
382
+ *
383
+ * Both fields are always set - the config schema defaults them - so this is the one place a
384
+ * constructor option becomes the behaviour of a call. Before it existed the options were parsed,
385
+ * stored, and never read: every context fell through to the context module's own defaults, and a
386
+ * client constructed with `checkErrorAction: 'deny'` failed open on every call it gated.
387
+ */
388
+ get contextDefaults() {
389
+ return { checkErrorAction: this.config.checkErrorAction, ingestMode: this.config.ingest };
390
+ }
391
+ newSession(hooks) {
392
+ const session = new session_1.GovernanceSession({
393
+ engine: () => this.ensureStarted().then((state) => state.engine),
394
+ logger: this.config.logger,
395
+ onError: this.config.onError,
396
+ hooks: hooks ?? {},
397
+ });
398
+ this.sessions.add(session);
399
+ return session;
400
+ }
401
+ /** The session the no-client paths share, built on first use so an unused SDK builds none. */
402
+ standalone() {
403
+ this.loose ??= this.newSession();
404
+ return this.loose;
405
+ }
406
+ /**
407
+ * Returns the decision when `err` is a governance refusal, so a catch block is one line and the
408
+ * caller needs no import to tell a denial from a real failure.
409
+ */
410
+ denial(err) {
411
+ return (0, errors_1.isAccessDenied)(err);
412
+ }
413
+ /**
414
+ * The gateway side of the split lifecycle: ingest usage a proxy observed, attributed from the
415
+ * headers the application sent with the request. Ingest only - a gateway sees requests, not
416
+ * runs, so there is nothing here to check against.
417
+ */
418
+ async ingestFromHeaders(headers, input) {
419
+ const { idempotencyKey } = input;
420
+ const mode = input.ingest ?? this.config.ingest;
421
+ // Filled in once the headers are read, and reported with every failure after that. Declared
422
+ // out here rather than parsed earlier because the checks below are ordered deliberately - an
423
+ // empty idempotency key is the first thing rejected - so a failure that happens before the
424
+ // headers are read truthfully carries no attribution, because at that point there is none.
425
+ let attribution;
426
+ // The hook is the application's own code and a gateway has no run to fall back on, so it is
427
+ // run the way `RunHandle` runs its own: a throw from it is reported, never returned.
428
+ const notify = (result) => (0, hooks_1.safely)(() => input.onIngest?.(result), (err) => this.reportIngestFailure(asIngestError(err), idempotencyKey, attribution));
429
+ const failed = (error) => {
430
+ this.reportIngestFailure(error, idempotencyKey, attribution);
431
+ const result = {
432
+ ok: false,
433
+ // What the error carried, not an assumption of nothing. An ingest is up to two calls -
434
+ // the features, then the wallet - so a rejection on the second leaves the first one's
435
+ // spend at Stigg. `asEngineError` preserves that; hardcoding `{}` here threw it away, and
436
+ // a gateway reading this result would retry work that was already billed.
437
+ reported: error.reported ?? {},
438
+ idempotencyKey,
439
+ mode,
440
+ replayed: error.replayed ?? false,
441
+ error,
442
+ };
443
+ notify(result);
444
+ return result;
445
+ };
446
+ // Everything is inside the failure path, the mapper and the engine's own resolution included.
447
+ // This call is documented to answer rather than throw, and "the usage mapper threw" is not a
448
+ // different kind of event from "the report did" to whoever is holding the request.
449
+ try {
450
+ if (!idempotencyKey) {
451
+ // The gateway's request id is what deduplicates its retries. An empty one is not a key,
452
+ // and read as absent it bills every retry again.
453
+ throw new errors_1.EngineError('ingest', 'idempotencyKey must not be empty - use the gateway request id');
454
+ }
455
+ await this.ensureStarted();
456
+ // The proxy observed someone else's response, so the vendor has to be named rather than
457
+ // guessed: raw names read off the wrong shape would report to the wrong features entirely.
458
+ const measurement = (0, token_usage_1.observedMeasurement)(input.usage, input.vendor);
459
+ const metrics = (input.metrics ?? types_1.vendorMetrics)(measurement);
460
+ attribution = (0, headers_1.parseGovernanceHeaders)(headers);
461
+ if (!attribution) {
462
+ throw new errors_1.EngineError('ingest', 'request carries no governance headers - nothing to attribute usage to');
463
+ }
464
+ const sendable = {};
465
+ for (const [featureId, value] of Object.entries(metrics)) {
466
+ // A gateway maps someone else's usage object with someone else's mapper, so it is the
467
+ // path most likely to produce a value that is not a measurement at all.
468
+ const invalid = (0, types_1.usageValueError)(value, mode);
469
+ if (invalid)
470
+ throw invalid;
471
+ if (value > 0 || mode === 'async')
472
+ sendable[featureId] = value;
473
+ }
474
+ // Zero-token turns are normal (a cached refusal, an empty stream); they are not an error,
475
+ // and they are still an ingest an observer asked to be told about.
476
+ if (!Object.keys(sendable).length) {
477
+ const noop = { ok: true, reported: {}, idempotencyKey, mode, replayed: false };
478
+ notify(noop);
479
+ return noop;
480
+ }
481
+ const reported = await this.engine.report(attribution.customerId, {
482
+ metrics: sendable,
483
+ entitlement: input.entitlement ?? DEFAULT_WALLET,
484
+ mode,
485
+ // What the proxy observed, as the proxy received it. There is no recipe out here, so the
486
+ // block is whatever `observedMeasurement` could make of the usage object it was handed.
487
+ raw: mode === 'async' ? (0, dimensions_1.flattenRaw)(measurement.raw) : undefined,
488
+ dimensions: attribution.dimensions,
489
+ idempotencyKey,
490
+ // No wrapped call to take a name from out of process, so the caller names it - or the
491
+ // vendor it declared does, which is the closest thing to the call that actually happened.
492
+ event: input.event ?? { name: input.vendor ? `${input.vendor}.proxied` : DEFAULT_EVENT_NAME },
493
+ });
494
+ // Delivered. The hook runs on the settled result, so instrumentation cannot turn a report
495
+ // that landed into one that reads as failed.
496
+ const result = {
497
+ ok: true,
498
+ reported: reported.reported,
499
+ idempotencyKey,
500
+ mode: reported.mode,
501
+ replayed: reported.replayed,
502
+ creditsConsumed: reported.creditsConsumed,
503
+ };
504
+ notify(result);
505
+ return result;
506
+ }
507
+ catch (err) {
508
+ return failed(asIngestError(err));
509
+ }
510
+ }
511
+ /**
512
+ * A probe that failed with nobody waiting on it.
513
+ *
514
+ * `wrap()` returns before the engine resolves, so this is the one path where a startup failure
515
+ * has no caller to reject towards. Swallowing it silently would leave a client that looks
516
+ * governed and cannot check; letting it reject unobserved ends the process.
517
+ */
518
+ reportStartupFailure(err) {
519
+ const error = (0, errors_1.asEngineError)('start', err);
520
+ (0, hooks_1.safely)(() => this.config.onError(error, { operation: 'start' }), (reportErr) => this.config.logger.warn(`[stigg] onError threw while reporting ${error.message}: ${String(reportErr)}`));
521
+ }
522
+ /** `onError` is the application's too, and there is nowhere left to report its failure to. */
523
+ reportIngestFailure(error, idempotencyKey, attribution) {
524
+ (0, hooks_1.safely)(() => this.config.onError(error, {
525
+ operation: 'ingest',
526
+ idempotencyKey,
527
+ // The request said which run and which customer it was for. An ingest that fails out
528
+ // here is the one case where nobody is holding a `RunHandle` to ask, so dropping them
529
+ // leaves the failure attached to a request id and nothing else.
530
+ runId: attribution?.runId,
531
+ customerId: attribution?.customerId,
532
+ }), (err) => this.config.logger.warn(`[stigg] onError threw while reporting ${error.message}: ${String(err)}`));
533
+ }
534
+ /**
535
+ * Drains every wrapped client's in-flight ingests. Explicit because an exit hook does not fire
536
+ * in a request-scoped or serverless runtime.
537
+ *
538
+ * A single client drains on its own with `client.stigg.flush()`; this is the process-wide one,
539
+ * for a shutdown that does not know which clients exist.
540
+ */
541
+ async flush(options = {}) {
542
+ const summaries = await Promise.all([...this.sessions].map((session) => session.drain()));
543
+ if (options.close)
544
+ await this.close();
545
+ return summaries;
546
+ }
547
+ /** Builds the engine once, on first need. Concurrent callers share the same attempt. */
548
+ ensureStarted() {
549
+ this.started ??= (0, create_engine_1.createGovernanceEngine)({
550
+ apiKey: this.config.apiKey,
551
+ baseUrl: this.config.baseUrl,
552
+ timeoutMs: this.config.timeoutMs,
553
+ cache: this.config.cache,
554
+ client: this.config.client,
555
+ logger: this.config.logger,
556
+ }).then((state) => {
557
+ this.engineState = state;
558
+ this.config.logger.info(`[stigg] ${(0, engine_1.describeResolution)(state.resolution)}`);
559
+ return state;
560
+ });
561
+ // `wrap` starts this and does not await it, so between a failing probe and the first call
562
+ // there is a rejected promise nobody is holding - which Node treats as an unhandled rejection
563
+ // and ends the process over. Marked handled here; it changes nothing for an awaiter, because
564
+ // attaching a handler does not consume the rejection. Same reason `wrap-client` does it to
565
+ // the promise behind a gated call.
566
+ this.started.catch(() => undefined);
567
+ return this.started;
568
+ }
569
+ }
570
+ exports.StiggAgentSDK = StiggAgentSDK;
571
+ /** Whatever went wrong on the way to a report, as the seam's own error. */
572
+ function asIngestError(err) {
573
+ return err instanceof errors_1.EngineError ? err : new errors_1.EngineError('ingest', String(err), { cause: err });
574
+ }
575
+ /**
576
+ * The metadata an entity is described by, refused rather than carried when it is not that.
577
+ *
578
+ * Two readers need it to be a flat record of strings and neither says so when it is not: the memo
579
+ * fingerprints it with `JSON.stringify`, which throws a bare `TypeError` on anything cyclic, and
580
+ * the upsert sends it on to Stigg, which rejects the write one network hop later. A value the
581
+ * caller can see in their own argument is worth more than either.
582
+ */
583
+ function describableMetadata(agentId, metadata) {
584
+ if (metadata === undefined || metadata === null)
585
+ return undefined;
586
+ const entries = typeof metadata === 'object' && !Array.isArray(metadata) ? Object.entries(metadata) : undefined;
587
+ const offender = entries?.find(([, value]) => typeof value !== 'string');
588
+ if (!entries || offender) {
589
+ throw new Error(`Agent ${agentId} was given metadata that is not a record of strings` +
590
+ `${offender ? ` - \`${offender[0]}\` is a ${typeof offender[1]}` : ''}. ` +
591
+ 'Stigg stores entity metadata as strings; convert the values before passing them.');
592
+ }
593
+ return Object.fromEntries(entries);
594
+ }