@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
@@ -25,6 +25,7 @@ const dimensions_1 = require("./core/dimensions");
25
25
  const anthropic_recipe_1 = require("./bindings/client/providers/anthropic/anthropic-recipe");
26
26
  const gemini_recipe_1 = require("./bindings/client/providers/gemini/gemini-recipe");
27
27
  const litellm_recipe_1 = require("./bindings/client/gateways/litellm/litellm-recipe");
28
+ const bedrock_recipe_1 = require("./bindings/client/gateways/bedrock/bedrock-recipe");
28
29
  const openai_recipe_1 = require("./bindings/client/providers/openai/openai-recipe");
29
30
  const openrouter_recipe_1 = require("./bindings/client/gateways/openrouter/openrouter-recipe");
30
31
  const typesafe_recipe_1 = require("./bindings/client/providers/typesafe/typesafe-recipe");
@@ -54,6 +55,9 @@ exports.DEFAULT_RECIPES = [
54
55
  anthropic_recipe_1.anthropicRecipe,
55
56
  (0, openrouter_recipe_1.openrouterRecipe)(),
56
57
  (0, litellm_recipe_1.litellmRecipe)(),
58
+ // Matched on its own client's configuration, so it sits with the gateways rather than ahead of
59
+ // or behind them: an AWS command bus is nothing any other guard here can mistake for its own.
60
+ (0, bedrock_recipe_1.bedrockRecipe)(),
57
61
  openai_recipe_1.openAIRecipe,
58
62
  typesafe_recipe_1.typesafeRecipe,
59
63
  // Gemini's two surfaces are matched separately - a client and one of its chats are different
@@ -81,11 +85,46 @@ const DEFAULT_EVENT_NAME = 'agent.usage';
81
85
  */
82
86
  const DEFAULT_WALLET = { currencyId: 'credits' };
83
87
  /**
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.
88
+ * How long a failed engine probe is remembered before the next call builds a new one.
89
+ *
90
+ * Long enough that an outage is probed on a timer rather than once per request, short enough that
91
+ * a transient failure costs seconds instead of a restart. See {@link StiggAgentSDK.ensureStarted}
92
+ * for why remembering it at all is the point.
87
93
  */
88
- const hasPlacement = (input) => input.parentId !== undefined || input.usageLimit !== undefined || input.usageLimitOnCreate !== undefined;
94
+ const RETRY_START_AFTER_MS = 2_000;
95
+ /** The key shorthand and the options object, folded into the one object the config takes. */
96
+ function toOptions(first, second) {
97
+ return typeof first === 'string' ? { ...second, apiKey: first } : { ...first, ...second };
98
+ }
99
+ /** The assignment `register` is being asked to write, if it is being asked to write one. */
100
+ function toPlacement(input) {
101
+ if (!input.budget)
102
+ return undefined;
103
+ const { entitlement, cadence, parentId } = input.budget;
104
+ // A placement writes an assignment against the entitlement, so an empty one persists a budget
105
+ // attached to nothing rather than merely routing badly.
106
+ const invalid = (0, types_1.entitlementError)(entitlement);
107
+ if (invalid)
108
+ throw invalid;
109
+ // `'on-create'` seeds and leaves an admin's change alone; `'always'`, the default, writes every
110
+ // time. One field, so the two cannot both be given.
111
+ const onCreate = input.budget.apply === 'on-create';
112
+ // The type says a seed carries a number, and a JS caller is not bound by it. Seeding `undefined`
113
+ // sends a cadence and no cap, so the agent is created with the budget it asked for missing -
114
+ // enforcement then falls through to whatever ancestor has one, silently. Refused instead.
115
+ if (onCreate && typeof input.budget.limit !== 'number') {
116
+ throw new Error(`Agent ${input.agentId} asked to seed a budget with no limit to seed. ` +
117
+ "`budget.apply: 'on-create'` writes a cap the first time and never again, so there is " +
118
+ 'nothing to write without `budget.limit`.');
119
+ }
120
+ return {
121
+ parentId,
122
+ entitlement,
123
+ cadence,
124
+ usageLimit: onCreate ? undefined : input.budget.limit,
125
+ usageLimitOnCreate: onCreate ? (input.budget.limit ?? undefined) : undefined,
126
+ };
127
+ }
89
128
  class StiggAgentSDK {
90
129
  config;
91
130
  clients;
@@ -105,14 +144,14 @@ class StiggAgentSDK {
105
144
  */
106
145
  registering = new Map();
107
146
  started;
147
+ /**
148
+ * When the attempt currently in `started` rejected, or undefined while it is pending or has
149
+ * resolved. What makes a failed probe expire rather than persist - see `ensureStarted`.
150
+ */
151
+ startFailedAt;
108
152
  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);
153
+ constructor(first, second) {
154
+ this.config = (0, config_1.resolveConfig)(toOptions(first, second));
116
155
  this.clients = (0, registry_1.createClientRegistry)(exports.DEFAULT_RECIPES);
117
156
  }
118
157
  /** Releases what this SDK's engine holds open. */
@@ -164,35 +203,7 @@ class StiggAgentSDK {
164
203
  if (typeof agentId !== 'string' || !agentId.trim()) {
165
204
  throw new Error('register needs an agentId - an empty one provisions an entity nobody can name.');
166
205
  }
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
- }
206
+ const placement = toPlacement(input);
196
207
  const key = `${customerId}:${agentId}`;
197
208
  // Memoized on the description, not just the pair: a second call describing the agent
198
209
  // differently is asking for that description to be written, and skipping it leaves the
@@ -461,11 +472,23 @@ class StiggAgentSDK {
461
472
  if (!attribution) {
462
473
  throw new errors_1.EngineError('ingest', 'request carries no governance headers - nothing to attribute usage to');
463
474
  }
475
+ // The same split `reportMetrics` makes: a metric named after the currency this ingest
476
+ // draws on goes to the wallet, which takes fractions, and everything else to the usage
477
+ // report, which does not.
478
+ const entitlement = input.entitlement ?? DEFAULT_WALLET;
479
+ const creditCurrencyId = (0, types_1.entitlementKind)(entitlement) === 'credit' ? (0, types_1.entitlementId)(entitlement) : undefined;
480
+ // Whether this proxied call names an agent, read from the header the sending side sets
481
+ // rather than guessed from the dimensions. The map arrives flat, so `{ region: 'eu' }`
482
+ // looks exactly like an attribution key from here - and rounding on that would overcharge a
483
+ // wallet draw with no entity behind it. See `wholeUnitsForGovernance`.
484
+ const attributed = !!attribution.attributionKey;
464
485
  const sendable = {};
465
- for (const [featureId, value] of Object.entries(metrics)) {
486
+ for (const [featureId, rawValue] of Object.entries(metrics)) {
487
+ const drawsWallet = featureId === creditCurrencyId;
488
+ const value = attributed && drawsWallet ? (0, types_1.wholeUnitsForGovernance)(rawValue) : rawValue;
466
489
  // A gateway maps someone else's usage object with someone else's mapper, so it is the
467
490
  // path most likely to produce a value that is not a measurement at all.
468
- const invalid = (0, types_1.usageValueError)(value, mode);
491
+ const invalid = (0, types_1.usageValueError)(value, mode, drawsWallet);
469
492
  if (invalid)
470
493
  throw invalid;
471
494
  if (value > 0 || mode === 'async')
@@ -480,7 +503,7 @@ class StiggAgentSDK {
480
503
  }
481
504
  const reported = await this.engine.report(attribution.customerId, {
482
505
  metrics: sendable,
483
- entitlement: input.entitlement ?? DEFAULT_WALLET,
506
+ entitlement,
484
507
  mode,
485
508
  // What the proxy observed, as the proxy received it. There is no recipe out here, so the
486
509
  // block is whatever `observedMeasurement` could make of the usage object it was handed.
@@ -544,20 +567,44 @@ class StiggAgentSDK {
544
567
  await this.close();
545
568
  return summaries;
546
569
  }
547
- /** Builds the engine once, on first need. Concurrent callers share the same attempt. */
570
+ /**
571
+ * Builds the engine once, on first need. Concurrent callers share the same attempt.
572
+ *
573
+ * A *failed* attempt is shared too, but only for {@link RETRY_START_AFTER_MS}. A rejected
574
+ * promise is still a promise, so memoizing one unconditionally outlives the failure that caused
575
+ * it: under the default `checkErrorAction: 'allow'` the process stops enforcing any budget at
576
+ * all, and under `'deny'` it refuses every call - neither saying anything a log would show.
577
+ *
578
+ * Holding the failure briefly rather than discarding it on sight is the other half: callers
579
+ * inside the window get the same rejection immediately and their own `checkErrorAction` decides
580
+ * the outcome, so an outage is re-probed on a timer instead of once per request.
581
+ */
548
582
  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
- });
583
+ if (this.startFailedAt !== undefined && Date.now() - this.startFailedAt >= RETRY_START_AFTER_MS) {
584
+ this.started = undefined;
585
+ this.startFailedAt = undefined;
586
+ }
587
+ if (!this.started) {
588
+ const attempt = (0, create_engine_1.createGovernanceEngine)({
589
+ apiKey: this.config.apiKey,
590
+ baseUrl: this.config.baseUrl,
591
+ timeoutMs: this.config.timeoutMs,
592
+ cache: this.config.cache,
593
+ client: this.config.client,
594
+ logger: this.config.logger,
595
+ }).then((state) => {
596
+ this.engineState = state;
597
+ this.config.logger.info(`[stigg] ${(0, engine_1.describeResolution)(state.resolution)}`);
598
+ return state;
599
+ });
600
+ this.started = attempt;
601
+ // Checked against the attempt that failed, not just set: a late rejection from an attempt
602
+ // already replaced would otherwise expire the newer one that succeeded.
603
+ attempt.catch(() => {
604
+ if (this.started === attempt)
605
+ this.startFailedAt = Date.now();
606
+ });
607
+ }
561
608
  // `wrap` starts this and does not await it, so between a failing probe and the first call
562
609
  // there is a rejected promise nobody is holding - which Node treats as an unhandled rejection
563
610
  // and ends the process over. Marked handled here; it changes nothing for an awaiter, because
package/dist/types.d.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { EngineError } from './errors';
12
12
  import type { StiggContext } from './core/context';
13
- import type { GovernanceSummary, SessionHooks } from './core/session';
13
+ import type { SessionSummary, SessionHooks } from './core/session';
14
14
  import type { GovernanceHeaders } from './headers';
15
15
  import type { ClientRecipe } from './bindings/client/core/types';
16
16
  import type { ObservedVendor } from './bindings/shared/token-usage';
@@ -111,7 +111,7 @@ export type Metrics = Record<string, number>;
111
111
  * Turns what a binding observed into the metrics to report.
112
112
  *
113
113
  * Defaults to the vendor's own: whatever names and values came back on the response. Supply your
114
- * own to report something else - see `creditsPerTokens` for the one-number case, and each client
114
+ * own to report something else - see `unitsPerTokens` for the one-number case, and each client
115
115
  * recipe for what that vendor meters by default.
116
116
  */
117
117
  export type MetricsMapper = (measurement: Measurement) => Metrics;
@@ -141,6 +141,19 @@ export type Measurement = {
141
141
  raw?: Record<string, unknown>;
142
142
  /** Per-call attribution, merged over the run's own dimensions. e.g. the model that served. */
143
143
  dimensions?: Record<string, string>;
144
+ /**
145
+ * Set when a counter here is this SDK's estimate rather than something the vendor stated.
146
+ *
147
+ * Only one thing sets it today: a stream the consumer abandoned. Anthropic states the output
148
+ * count on the event that ends the stream, so a reply stopped halfway never states one - and
149
+ * reporting nothing for text the customer read, and the vendor billed, is a hole rather than a
150
+ * conservative answer.
151
+ *
152
+ * Carried on the measurement rather than folded into a dimension so it reaches the run's
153
+ * `usageMapper`, which is what makes it a decision rather than a surprise: a mapper that would
154
+ * rather bill nothing than bill an estimate can branch on this and return `{}`.
155
+ */
156
+ estimated?: true;
144
157
  /** The vendor's own request id, when it has one that is stable across its retries. */
145
158
  idempotencyKey?: string;
146
159
  /**
@@ -159,27 +172,76 @@ export type Measurement = {
159
172
  export declare const vendorMetrics: MetricsMapper;
160
173
  /** A binding may report either shape; `TokenUsage` is the common case and stays terse. */
161
174
  export declare const toMeasurement: (value: TokenUsage | Measurement) => Measurement;
175
+ /**
176
+ * The amount to report when this run is attributed to a governance entity.
177
+ *
178
+ * Governance counts in whole units and cannot be made to count otherwise from here: its ingest
179
+ * schema is `z.number().int()`, and the counter behind it is a Redis `HINCRBY`, which takes an
180
+ * integer delta. A fractional amount is rejected at that boundary - the wallet still moves, the
181
+ * per-entity counter does not, and nothing in the response says so. An agent's budget silently
182
+ * stops advancing while its spend carries on.
183
+ *
184
+ * So an attributed run rounds up, and only an attributed run: a run that names no entity reports
185
+ * what its mapper produced, because the wallet takes fractions and nothing downstream of it
186
+ * counts in whole units.
187
+ *
188
+ * Rounding *up* rather than to nearest, because a call that happened must cost something: at any
189
+ * sane denomination most calls are worth less than one unit, and rounding to nearest would bill
190
+ * nothing for nearly all of them.
191
+ *
192
+ * TODO: delete this, and the call sites that use it, once governance counts fractional usage.
193
+ * The platform fix is to scale internally - store micro-units and accept fractions at the API
194
+ * boundary - which keeps `HINCRBY` working and is the only way an agent budget can be
195
+ * denominated finely enough to price a call worth a tenth of a cent. Until then this is a
196
+ * deliberate overcharge on small calls, which is why it is announced rather than silent.
197
+ */
198
+ export declare const wholeUnitsForGovernance: (value: number) => number;
162
199
  /**
163
200
  * Why a measured value cannot be reported, if it cannot.
164
201
  *
165
202
  * Zero is not an error - a cached refusal or an empty stream is a normal turn - but a mapper
166
203
  * dividing by an unset config returns NaN or Infinity, and reading those as "nothing was used"
167
- * meters the run at zero while every call still reports success. Those are refused in both modes;
168
- * a fraction is refused only under `'sync'`, whose DTO takes whole units.
204
+ * meters the run at zero while every call still reports success. Those are refused everywhere.
205
+ *
206
+ * A fraction is refused on one path only, and the rule is the endpoint's rather than this SDK's:
207
+ *
208
+ * - **The wallet** takes them. `credits.consumption.consume` and its async twin accept a
209
+ * fractional `amount`, and balances are fractional too - which they have to be, or a currency
210
+ * could never be denominated finely enough to price a call worth a tenth of a cent.
211
+ * - **An event** takes them. The number rides as a dimension and a meter sums what it finds,
212
+ * which is the only way a gateway's `cost` reports honestly.
213
+ * - **The usage report** does not: `POST /api/v1/usage` answers
214
+ * `400 Expected integer, received float`. That is the one case refused here, where the caller
215
+ * can still see which metric it was - metering failures are swallowed into `onError`, so a 400
216
+ * three frames down is a silent gap rather than an error anyone reads.
169
217
  *
170
218
  * Shared, because every path that reports usage has to answer this the same way.
171
219
  */
172
- export declare function usageValueError(value: number, mode?: IngestMode): EngineError | undefined;
220
+ export declare function usageValueError(value: number, mode?: IngestMode, drawsWallet?: boolean): EngineError | undefined;
173
221
  /**
174
222
  * One metric, summed from every token class and divided by the unit size.
175
223
  *
176
- * The mapper for an application that prices tokens itself rather than letting Stigg convert a
177
- * credit-backed feature. Weighting cache reads cheaper is a pricing decision and belongs in
178
- * Stigg's consumption mapping, not here; Stigg rejects fractions, so the result rounds up.
224
+ * The mapper for an application that prices tokens itself. Named for what it produces - units
225
+ * under an id - rather than for credits, because it does not deduct any: it reports a number, and
226
+ * that number draws the wallet only when the id happens to name a credit currency. Which it is
227
+ * decides the whole ingest path, so a mapper that claimed to know was the wrong place to look.
228
+ *
229
+ * **Prefer reporting the raw counters and converting in Stigg.** A consumption mapping on the plan
230
+ * does the same arithmetic where a pricing change does not need a deploy, and it is the only place
231
+ * that can weight cache reads cheaper. Reach for this when the application deliberately prices
232
+ * itself.
233
+ *
234
+ * The division is reported as it comes out. A credit currency takes fractions, so a call worth
235
+ * four fifths of a unit costs four fifths of one - rounding it up here would be this SDK's
236
+ * arithmetic on someone else's price. A **feature** is the exception: `POST /api/v1/usage` takes
237
+ * integers, so pick a `tokensPerUnit` that divides, or round in your own mapper.
238
+ *
239
+ * @param id The Stigg feature id or credit currency id to report under.
240
+ * @param tokensPerUnit How many tokens make one unit.
179
241
  */
180
- export declare const creditsPerTokens: (featureId: string, tokensPerUnit?: number) => MetricsMapper;
242
+ export declare const unitsPerTokens: (id: string, tokensPerUnit?: number) => MetricsMapper;
181
243
  /** What the caller should do about a denial. A refusal is a commercial event, not an error. */
182
- export type GovernanceRemedy = 'top-up' | 'raise-budget' | 'subscribe' | 'retry';
244
+ export type Remedy = 'top-up' | 'raise-budget' | 'subscribe' | 'retry';
183
245
  /**
184
246
  * Which client answered, or - for 'cache', 'policy' and 'unenforced' - that nothing was asked.
185
247
  *
@@ -191,7 +253,7 @@ export type DecisionSource = 'rest' | 'node-sdk' | 'cache' | 'policy' | 'unenfor
191
253
  * The structured refusal. Returned, never thrown, everywhere the caller has a return path:
192
254
  * the denial path is a revenue surface (top-up, upgrade), so it must be inspectable.
193
255
  */
194
- export type GovernanceDecision = {
256
+ export type AccessDecision = {
195
257
  hasAccess: boolean;
196
258
  /** Which entity blocked - a governance node, or the customer when governance is not in play. */
197
259
  deniedBy?: string;
@@ -200,7 +262,7 @@ export type GovernanceDecision = {
200
262
  * the SDK reached the verdict itself. `InsufficientCredits` and `BudgetExceeded` differ.
201
263
  */
202
264
  reason?: string;
203
- remedy?: GovernanceRemedy;
265
+ remedy?: Remedy;
204
266
  /** Tightest remaining budget across the pool and the chain. Undefined when nothing is bounded. */
205
267
  remaining?: number;
206
268
  usageLimit?: number | null;
@@ -296,30 +358,57 @@ export type AgentOptions = {
296
358
  entityTypeId: string;
297
359
  displayName?: string;
298
360
  metadata?: Record<string, string>;
299
- /** Places the agent under a parent entity. Tri-state: omit to leave it, null to make it a root. */
300
- parentId?: string | null;
301
- /** Budget for this agent, written on every call. Omit to leave whatever is already assigned. */
302
- usageLimit?: number | null;
303
361
  /**
304
- * Budget applied only when this agent has no assignment yet. Use this rather than `usageLimit`
305
- * when an admin may change the budget out of band: `usageLimit` would overwrite their change on
306
- * every process start. Mutually exclusive with `usageLimit`.
362
+ * What this agent may spend, and where it sits in the tree. Omit it and `register` writes an
363
+ * identity and nothing else.
364
+ *
365
+ * Grouped rather than flat because these five are one write underneath - an assignment over
366
+ * `(entity x entitlement)` - while the fields above are the entity. See {@link AgentBudget}.
307
367
  */
308
- usageLimitOnCreate?: number;
368
+ budget?: AgentBudget;
369
+ };
370
+ /**
371
+ * What an agent may spend, as the one write it actually is.
372
+ *
373
+ * Grouped because these are one write underneath - an assignment over `(entity x entitlement)` -
374
+ * while the fields beside them describe the entity. The grouping is what makes two illegal
375
+ * combinations unrepresentable: `apply` is one field, so there is no way to ask for both budget
376
+ * modes, and `entitlement` is required, so there is no way to write a limit against nothing.
377
+ *
378
+ * `parentId` lives here rather than on the entity because the hierarchy is **per-entitlement**:
379
+ * an agent can sit under one parent for one budget and elsewhere for another.
380
+ */
381
+ export type AgentBudget = {
382
+ /**
383
+ * Which entitlement the budget is written against - a credit currency or a metered feature.
384
+ *
385
+ * Required, because an assignment is written over `(entity x entitlement)`: a limit with
386
+ * nothing to write it against has nowhere to land.
387
+ */
388
+ entitlement: Entitlement;
309
389
  /** ISO-8601 single-unit reset window, e.g. 'P1M'. Governance requires it when creating a budget. */
310
390
  cadence?: string;
311
- };
391
+ /** Places the agent under a parent entity. Tri-state: omit to leave it, null to make it a root. */
392
+ parentId?: string | null;
393
+ } & ({
394
+ /** The limit, written on every call. `null` clears whatever is assigned. */
395
+ limit?: number | null;
396
+ /** Writes the limit every time. The default. */
397
+ apply?: 'always';
398
+ } | {
399
+ /** The limit to seed with. Not nullable: there is nothing to clear on an agent being created. */
400
+ limit: number;
401
+ /**
402
+ * Writes the limit only when this agent has no assignment yet.
403
+ *
404
+ * Reach for it when an admin may change the budget out of band - `'always'` would overwrite
405
+ * their change on every process start.
406
+ */
407
+ apply: 'on-create';
408
+ });
312
409
  export type AgentScope = {
313
410
  /** The tenant. Every governance call is scoped to one customer. */
314
411
  customerId: string;
315
- /**
316
- * Which entitlement the budget is written against. Optional only because a bare registration is
317
- * an identity and nothing else, and a metering-only run has none to hand over. A call carrying
318
- * `usageLimit`, `usageLimitOnCreate` or `parentId` still needs one and `register` refuses it
319
- * without: an assignment is written over `(entity x entitlement)`, so a placement with nothing
320
- * to write it against has nowhere to land.
321
- */
322
- entitlement?: Entitlement;
323
412
  };
324
413
  /** `register` - the standalone call, which has no run to take the scope from. */
325
414
  export type RegisterInput = AgentOptions & AgentScope;
@@ -440,7 +529,7 @@ type GatedMethod<F, Ctx> = F extends {
440
529
  * The client's own surface, with every gated method taught to accept a `stiggContext`.
441
530
  *
442
531
  * Rebuilt structurally, which on its own would lose the vendor's class identity - so
443
- * {@link GovernedClient} intersects it back with `T` rather than replacing it. The overlay comes
532
+ * {@link WrappedClient} intersects it back with `T` rather than replacing it. The overlay comes
444
533
  * first in that intersection on purpose: an intersection of two call signatures is an overload
445
534
  * set resolved in order, and the vendor's own signature would reject `stiggContext` as an excess
446
535
  * property if it were tried first.
@@ -481,24 +570,24 @@ export type CallContext<Wrapped> = Wrapped extends {
481
570
  * A vendor client bound to this environment. Every call its recipe covers is checked before it
482
571
  * leaves and metered after, against the context `wrap` was given and whatever the call overrides.
483
572
  */
484
- export type GovernedClient<T, Wrapped = StiggContext, Paths extends readonly string[] = GatedPath> = GatedSurface<T, CallContext<Wrapped>, Paths> & T & {
573
+ export type WrappedClient<T, Wrapped = StiggContext, Paths extends readonly string[] = GatedPath> = GatedSurface<T, CallContext<Wrapped>, Paths> & T & {
485
574
  /** What this client did, and what is still in flight. */
486
575
  readonly stigg: ClientGovernance;
487
576
  };
488
577
  /** The governance handle hung off a wrapped client. */
489
578
  export type ClientGovernance = {
490
579
  /** Waits for in-flight ingests, then reports what this client did. */
491
- flush(): Promise<GovernanceSummary>;
580
+ flush(): Promise<SessionSummary>;
492
581
  /** Drains, then releases what this client holds. */
493
582
  close(): Promise<void>;
494
583
  /** What this client has done so far, without waiting. */
495
- summary(): GovernanceSummary;
584
+ summary(): SessionSummary;
496
585
  /** The last decision this client made, or undefined if it has made none. */
497
- readonly lastDecision?: GovernanceDecision;
586
+ readonly lastDecision?: AccessDecision;
498
587
  /** The `x-stigg-*` attribution for work continuing in another process. */
499
588
  headers(override?: StiggContext): GovernanceHeaders;
500
589
  /** The gate, for a call this client does not wrap. */
501
- check(override?: StiggContext, requestedUsage?: number): Promise<GovernanceDecision>;
590
+ check(override?: StiggContext, requestedUsage?: number): Promise<AccessDecision>;
502
591
  /** The meter, for usage this client did not observe. */
503
592
  report(metrics: Metrics, override?: StiggContext): Promise<IngestResult>;
504
593
  };
package/dist/types.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.creditsPerTokens = exports.toMeasurement = exports.vendorMetrics = exports.emptyTokenUsage = exports.entitlementRef = exports.entitlementKind = exports.entitlementId = void 0;
3
+ exports.unitsPerTokens = exports.wholeUnitsForGovernance = exports.toMeasurement = exports.vendorMetrics = exports.emptyTokenUsage = exports.entitlementRef = exports.entitlementKind = exports.entitlementId = void 0;
4
4
  exports.entitlementError = entitlementError;
5
5
  exports.usageValueError = usageValueError;
6
6
  /**
@@ -63,35 +63,85 @@ exports.vendorMetrics = vendorMetrics;
63
63
  /** A binding may report either shape; `TokenUsage` is the common case and stays terse. */
64
64
  const toMeasurement = (value) => 'input' in value ? { usage: value } : value;
65
65
  exports.toMeasurement = toMeasurement;
66
+ /**
67
+ * The amount to report when this run is attributed to a governance entity.
68
+ *
69
+ * Governance counts in whole units and cannot be made to count otherwise from here: its ingest
70
+ * schema is `z.number().int()`, and the counter behind it is a Redis `HINCRBY`, which takes an
71
+ * integer delta. A fractional amount is rejected at that boundary - the wallet still moves, the
72
+ * per-entity counter does not, and nothing in the response says so. An agent's budget silently
73
+ * stops advancing while its spend carries on.
74
+ *
75
+ * So an attributed run rounds up, and only an attributed run: a run that names no entity reports
76
+ * what its mapper produced, because the wallet takes fractions and nothing downstream of it
77
+ * counts in whole units.
78
+ *
79
+ * Rounding *up* rather than to nearest, because a call that happened must cost something: at any
80
+ * sane denomination most calls are worth less than one unit, and rounding to nearest would bill
81
+ * nothing for nearly all of them.
82
+ *
83
+ * TODO: delete this, and the call sites that use it, once governance counts fractional usage.
84
+ * The platform fix is to scale internally - store micro-units and accept fractions at the API
85
+ * boundary - which keeps `HINCRBY` working and is the only way an agent budget can be
86
+ * denominated finely enough to price a call worth a tenth of a cent. Until then this is a
87
+ * deliberate overcharge on small calls, which is why it is announced rather than silent.
88
+ */
89
+ const wholeUnitsForGovernance = (value) => Math.ceil(value);
90
+ exports.wholeUnitsForGovernance = wholeUnitsForGovernance;
66
91
  /**
67
92
  * Why a measured value cannot be reported, if it cannot.
68
93
  *
69
94
  * Zero is not an error - a cached refusal or an empty stream is a normal turn - but a mapper
70
95
  * dividing by an unset config returns NaN or Infinity, and reading those as "nothing was used"
71
- * meters the run at zero while every call still reports success. Those are refused in both modes;
72
- * a fraction is refused only under `'sync'`, whose DTO takes whole units.
96
+ * meters the run at zero while every call still reports success. Those are refused everywhere.
97
+ *
98
+ * A fraction is refused on one path only, and the rule is the endpoint's rather than this SDK's:
99
+ *
100
+ * - **The wallet** takes them. `credits.consumption.consume` and its async twin accept a
101
+ * fractional `amount`, and balances are fractional too - which they have to be, or a currency
102
+ * could never be denominated finely enough to price a call worth a tenth of a cent.
103
+ * - **An event** takes them. The number rides as a dimension and a meter sums what it finds,
104
+ * which is the only way a gateway's `cost` reports honestly.
105
+ * - **The usage report** does not: `POST /api/v1/usage` answers
106
+ * `400 Expected integer, received float`. That is the one case refused here, where the caller
107
+ * can still see which metric it was - metering failures are swallowed into `onError`, so a 400
108
+ * three frames down is a silent gap rather than an error anyone reads.
73
109
  *
74
110
  * Shared, because every path that reports usage has to answer this the same way.
75
111
  */
76
- function usageValueError(value, mode = 'sync') {
112
+ function usageValueError(value, mode = 'sync',
113
+ // Whether this metric draws the credit wallet rather than reporting a feature. The wallet takes
114
+ // fractions in both modes, so what the ingest mode decides does not apply to it.
115
+ drawsWallet = false) {
77
116
  if (!Number.isFinite(value) || value < 0) {
78
117
  return new errors_1.EngineError('ingest', `usage value must be a finite non-negative number, got ${value}`);
79
118
  }
80
- // An event carries the number as a dimension for a meter to aggregate, and a meter is free to
81
- // sum fractions - which is the only way a gateway's `cost` reports honestly. The usage report
82
- // takes a whole number or nothing.
83
- if (mode === 'async' || Number.isInteger(value))
119
+ if (mode === 'async' || drawsWallet || Number.isInteger(value))
84
120
  return undefined;
85
- return new errors_1.EngineError('ingest', `usage value must be a positive whole number, got ${value}`);
121
+ return new errors_1.EngineError('ingest', `usage value must be a whole number to report against a feature, got ${value}. The usage ` +
122
+ 'report takes integers; a credit currency takes fractions, and so does an event under ' +
123
+ "`ingest: 'async'`.");
86
124
  }
87
125
  /**
88
126
  * One metric, summed from every token class and divided by the unit size.
89
127
  *
90
- * The mapper for an application that prices tokens itself rather than letting Stigg convert a
91
- * credit-backed feature. Weighting cache reads cheaper is a pricing decision and belongs in
92
- * Stigg's consumption mapping, not here; Stigg rejects fractions, so the result rounds up.
128
+ * The mapper for an application that prices tokens itself. Named for what it produces - units
129
+ * under an id - rather than for credits, because it does not deduct any: it reports a number, and
130
+ * that number draws the wallet only when the id happens to name a credit currency. Which it is
131
+ * decides the whole ingest path, so a mapper that claimed to know was the wrong place to look.
132
+ *
133
+ * **Prefer reporting the raw counters and converting in Stigg.** A consumption mapping on the plan
134
+ * does the same arithmetic where a pricing change does not need a deploy, and it is the only place
135
+ * that can weight cache reads cheaper. Reach for this when the application deliberately prices
136
+ * itself.
137
+ *
138
+ * The division is reported as it comes out. A credit currency takes fractions, so a call worth
139
+ * four fifths of a unit costs four fifths of one - rounding it up here would be this SDK's
140
+ * arithmetic on someone else's price. A **feature** is the exception: `POST /api/v1/usage` takes
141
+ * integers, so pick a `tokensPerUnit` that divides, or round in your own mapper.
142
+ *
143
+ * @param id The Stigg feature id or credit currency id to report under.
144
+ * @param tokensPerUnit How many tokens make one unit.
93
145
  */
94
- const creditsPerTokens = (featureId, tokensPerUnit = 1) => ({ usage }) => usage
95
- ? { [featureId]: Math.ceil((usage.input + usage.output + usage.cacheRead + usage.cacheCreate) / tokensPerUnit) }
96
- : {};
97
- exports.creditsPerTokens = creditsPerTokens;
146
+ const unitsPerTokens = (id, tokensPerUnit = 1) => ({ usage }) => usage ? { [id]: (usage.input + usage.output + usage.cacheRead + usage.cacheCreate) / tokensPerUnit } : {};
147
+ exports.unitsPerTokens = unitsPerTokens;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stigg/agent-sdk-js",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "Stigg agent SDK (JS) - run-lifecycle governance for agentic stacks, with examples per layer",
5
5
  "main": "dist/index.js",
6
6
  "typings": "dist/index.d.ts",
@@ -41,7 +41,8 @@
41
41
  "example-two-agents": "tsx examples/client/two-agents.ts",
42
42
  "example-gateway": "tsx examples/gateway/openrouter.ts",
43
43
  "example-gateway-openrouter": "tsx examples/gateway/openrouter.ts",
44
- "example-gateway-litellm": "tsx examples/gateway/litellm.ts"
44
+ "example-gateway-litellm": "tsx examples/gateway/litellm.ts",
45
+ "example-gateway-bedrock": "tsx examples/gateway/bedrock.ts"
45
46
  },
46
47
  "nx": {
47
48
  "includedScripts": [
@@ -63,7 +64,9 @@
63
64
  },
64
65
  "devDependencies": {
65
66
  "@anthropic-ai/sdk": "^0.124.0",
67
+ "@aws-sdk/client-bedrock-runtime": "^3.1150.0",
66
68
  "@google/genai": "^2.28.0",
69
+ "@openrouter/sdk": "^1.4.25",
67
70
  "@typesafe-ai/sdk": "^0.6.0",
68
71
  "dotenv": "^17.4.2",
69
72
  "openai": "^7.13.0",