@stigg/agent-sdk-js 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,9 @@ Check a customer's entitlements, credits or budget **before** a model call, mete
6
6
  actually used **after** it, and let [Stigg](https://stigg.io) handle the pricing, packaging and
7
7
  billing behind it.
8
8
 
9
+ [![npm](https://img.shields.io/npm/v/@stigg/agent-sdk-js?color=cb3837&logo=npm)](https://www.npmjs.com/package/@stigg/agent-sdk-js)
10
+ [![license](https://img.shields.io/badge/license-MIT-blue)](https://github.com/stiggio/stigg-agent-sdk-ts/blob/main/LICENSE)
11
+
9
12
  ```bash
10
13
  npm install @stigg/agent-sdk-js # or: yarn add @stigg/agent-sdk-js
11
14
  ```
@@ -13,6 +16,23 @@ npm install @stigg/agent-sdk-js # or: yarn add @stigg/agent-sdk-js
13
16
  Requires Node 22 or later. Ships CommonJS with type declarations, and a `./internal` subpath for
14
17
  the engine seam. Twelve providers and gateways, **and not one of them is a dependency**.
15
18
 
19
+ ## ⚡ Try it in thirty seconds
20
+
21
+ No API keys. No Stigg account. Nothing to sign up for.
22
+
23
+ ```bash
24
+ git clone https://github.com/stiggio/stigg-agent-sdk-ts && cd stigg-agent-sdk-ts
25
+ corepack enable && yarn install
26
+ yarn example-client-anthropic
27
+ ```
28
+
29
+ A built-in mock stands in for both the LLM vendor and the Stigg API — and it is not a stub. The
30
+ vendor mock speaks the real wire format, so the vendor's own SDK parses a genuine response and
31
+ every field is populated. The Stigg mock implements real entities, budgets and hierarchy, so the
32
+ example genuinely hits a denial and tells you which budget caused it.
33
+
34
+ Add a real key to `.env` later and the same example talks to the real thing.
35
+
16
36
  ## 🎯 Quick start
17
37
 
18
38
  Wrap the client you already have, and keep calling it the way you already do.
@@ -99,6 +119,7 @@ rather than handing back a client that quietly meters nothing.
99
119
  | Gate without a client to wrap | `stigg.check(context)` |
100
120
  | Meter something you measured yourself | `stigg.report(context, metrics)` |
101
121
  | Give an agent its own budget | `stigg.register({ customerId, agentId, budget })` |
122
+ | Start new customers on a plan | `stiggContext: { defaultPlanId }`, beside `customerId` |
102
123
  | Meter a gateway you operate | `client.stigg.headers()` out, `stigg.ingestFromHeaders(...)` back |
103
124
 
104
125
  One wrapped client serves every customer and every agent: the context is an argument, given once at
@@ -112,6 +133,8 @@ One wrapped client serves every customer and every agent: the context is an argu
112
133
  and no Stigg account** against a built-in mock of both
113
134
  - **[Adding a platform](.claude/skills/govern-new-platform/SKILL.md)** — provider or gateway, and
114
135
  what every binding owes
136
+ - **[Contributing](CONTRIBUTING.md)** — how to run it locally, and how a change lands
137
+ - **[Security](SECURITY.md)** — reporting a vulnerability privately
115
138
 
116
139
  ## 📄 License
117
140
 
@@ -8,6 +8,16 @@ import type { EventIngest } from '../engine/engine';
8
8
  export type StiggContext = {
9
9
  /** The tenant. Every governance call is scoped to one customer. */
10
10
  customerId?: string;
11
+ /**
12
+ * The plan a customer is provisioned onto when Stigg does not know them yet. Optional: without
13
+ * it, an unknown customer is not provisioned.
14
+ *
15
+ * Kept apart from {@link StiggContext.customerId} rather than folded into it, because the two
16
+ * are usually given in different places - the plan once at `wrap`, for every customer the
17
+ * client will serve, and the customer per call. A call naming a different customer inherits the
18
+ * client's plan.
19
+ */
20
+ defaultPlanId?: string;
11
21
  /**
12
22
  * The agent this call spends as. Travels as a dimension under
13
23
  * {@link StiggContext.attributionKey}, which is the entire mechanism by which an agent is
@@ -90,6 +100,8 @@ export type ResolvedContext = {
90
100
  * reaches the engine resolves it first, so nothing downstream has to re-check it.
91
101
  */
92
102
  customerId: string;
103
+ /** The plan an unknown customer is provisioned onto - undefined when none was given. */
104
+ defaultPlanId?: string;
93
105
  entitlement?: Entitlement;
94
106
  /** The agent folded in, so the check and the report send one object and neither re-derives it. */
95
107
  dimensions: Record<string, string>;
@@ -58,6 +58,20 @@ function agentDimension(context) {
58
58
  }
59
59
  return { [context.attributionKey]: context.agentId };
60
60
  }
61
+ /**
62
+ * A plan id as given, trimmed - or a refusal when it was given blank. Absent stays absent: no
63
+ * plan is a legal choice, while an empty one is a typo that would provision nobody onto nothing.
64
+ */
65
+ function planId(value) {
66
+ if (value === undefined)
67
+ return undefined;
68
+ const named = typeof value === 'string' ? value.trim() : '';
69
+ if (!named) {
70
+ throw new Error('stiggContext.defaultPlanId is empty - leave the field out to provision no plan, or name the ' +
71
+ 'plan new customers should start on.');
72
+ }
73
+ return named;
74
+ }
61
75
  /**
62
76
  * Validates and derives, once. Everything expensive about a context happens here rather than on
63
77
  * the call path - see the note at the top of this file for why that split exists.
@@ -97,6 +111,7 @@ function resolveContext(context, correlationId,
97
111
  const dimensions = agent ? { ...context.dimensions, ...agent } : (context.dimensions ?? {});
98
112
  return {
99
113
  customerId,
114
+ defaultPlanId: planId(context.defaultPlanId),
100
115
  // Normalized, not passed through: `isEnforced` asks `!== undefined` while every other reader
101
116
  // asks for truthiness, so a JS caller's `entitlement: null` would be enforced by one and
102
117
  // absent to the rest - a call that runs the gate and then reports itself ungated.
@@ -120,6 +135,7 @@ function resolveContext(context, correlationId,
120
135
  */
121
136
  const CONTEXT_KEYS = {
122
137
  customerId: true,
138
+ defaultPlanId: true,
123
139
  agentId: true,
124
140
  attributionKey: true,
125
141
  dimensions: true,
@@ -223,6 +239,9 @@ function overrideContext(base, override) {
223
239
  }
224
240
  return {
225
241
  customerId,
242
+ // Independent of the customer: a call naming its own customer still provisions onto the
243
+ // client's plan unless it names a plan too.
244
+ defaultPlanId: override.defaultPlanId !== undefined ? planId(override.defaultPlanId) : base.defaultPlanId,
226
245
  entitlement,
227
246
  dimensions,
228
247
  checkedId,
@@ -45,6 +45,12 @@ export type SessionDeps = {
45
45
  * than disappearing.
46
46
  */
47
47
  engine: () => Promise<GovernanceEngine>;
48
+ /**
49
+ * Creates the customer on their default plan if Stigg does not know them yet. Shared across
50
+ * every session of one SDK and remembered per customer, so only a customer's first call pays
51
+ * the round trip. Absent, nothing is ever provisioned.
52
+ */
53
+ provisionCustomer?: (customerId: string, planId: string) => Promise<void>;
48
54
  logger: Logger;
49
55
  onError: (error: EngineError, context: ErrorContext) => void;
50
56
  hooks: SessionHooks;
@@ -181,6 +187,13 @@ export declare class GovernanceSession {
181
187
  * says never rejects. The failure is reported like any other.
182
188
  */
183
189
  private hook;
190
+ /**
191
+ * Makes sure a customer with a default plan exists. Never throws, and answers whether Stigg was
192
+ * reached - false only for an outage, which is reported here and retried by the next call. A
193
+ * refusal is an answer: the SDK reports it once, and the call goes on to meet whatever the check
194
+ * says about a customer that does not exist.
195
+ */
196
+ private provisionCustomer;
184
197
  /**
185
198
  * The last stop for a failure. `onError` is the application's own code and can throw too, and
186
199
  * there is nowhere left to report that to, so it falls back to the logger.
@@ -49,6 +49,8 @@ const emptyMetricNameError = () => new errors_1.EngineError('measure', 'a metric
49
49
  * `'async'` report would reach the event API with no name at all.
50
50
  */
51
51
  const DEFAULT_EVENT_NAME = 'agent.usage';
52
+ /** How both engines say a check found no such customer - the reason, verbatim, from the API. */
53
+ const CUSTOMER_NOT_FOUND = 'CustomerNotFound';
52
54
  class GovernanceSession {
53
55
  deps;
54
56
  pending = new Set();
@@ -93,6 +95,8 @@ class GovernanceSession {
93
95
  * applies the error policy.
94
96
  */
95
97
  async warmUp(context) {
98
+ // First, so the cache below loads a customer that exists rather than remembering one that did not.
99
+ await this.provisionCustomer(context);
96
100
  if (!(0, context_1.isEnforced)(context))
97
101
  return;
98
102
  try {
@@ -116,11 +120,22 @@ class GovernanceSession {
116
120
  return this.record((0, decision_1.unenforcedDecision)(), context);
117
121
  try {
118
122
  const engine = await this.deps.engine();
123
+ // Before the check, not after: a customer Stigg has never seen holds no entitlement, so their
124
+ // first call would be refused for not existing yet.
125
+ const reached = await this.provisionCustomer(context);
119
126
  const result = await engine.check(context.customerId, {
120
127
  entitlement,
121
128
  requestedUsage,
122
129
  dimensions: context.dimensions,
123
130
  });
131
+ // The policy answers only when both halves say so: Stigg could not be reached to create the
132
+ // customer, and the check then found no customer. Either alone is not that. An existing
133
+ // customer gets the real answer whatever happened to the create, and a customer Stigg
134
+ // refused - an unknown plan, an archived customer - is refused here too, rather than
135
+ // waved through by an `allow` that exists for outages, not for configuration mistakes.
136
+ if (!reached && result.accessDeniedReason === CUSTOMER_NOT_FOUND) {
137
+ return this.record((0, decision_1.policyDecision)(context.checkErrorAction === 'allow'), context);
138
+ }
124
139
  return this.record((0, decision_1.toDecision)(result, { customerId: context.customerId, entitlement: context.entitlement }), context);
125
140
  }
126
141
  catch (err) {
@@ -253,17 +268,21 @@ class GovernanceSession {
253
268
  }
254
269
  const promise = this.deps
255
270
  .engine()
256
- .then((engine) => engine.report(context.customerId, {
257
- metrics: sendable,
258
- entitlement: context.entitlement,
259
- mode,
260
- // Flattened here rather than in the engine, so both transports send the same event and a
261
- // sync report pays nothing for a field it will not use.
262
- raw: mode === 'async' ? (0, dimensions_1.flattenRaw)(raw) : undefined,
263
- dimensions,
264
- idempotencyKey,
265
- event: event ?? { name: DEFAULT_EVENT_NAME },
266
- }))
271
+ .then(async (engine) => {
272
+ // A metering-only client never checks, so this is the first place its customer is seen.
273
+ await this.provisionCustomer(context);
274
+ return engine.report(context.customerId, {
275
+ metrics: sendable,
276
+ entitlement: context.entitlement,
277
+ mode,
278
+ // Flattened here rather than in the engine, so both transports send the same event and a
279
+ // sync report pays nothing for a field it will not use.
280
+ raw: mode === 'async' ? (0, dimensions_1.flattenRaw)(raw) : undefined,
281
+ dimensions,
282
+ idempotencyKey,
283
+ event: event ?? { name: DEFAULT_EVENT_NAME },
284
+ });
285
+ })
267
286
  .then((result) => {
268
287
  this.ingested += 1;
269
288
  // A replay is a delivery that consumed nothing - the server had already counted these
@@ -397,6 +416,24 @@ class GovernanceSession {
397
416
  hook(name, context, invoke) {
398
417
  (0, hooks_1.safely)(invoke, (err) => this.reportError(asEngineError(name, err), name, context));
399
418
  }
419
+ /**
420
+ * Makes sure a customer with a default plan exists. Never throws, and answers whether Stigg was
421
+ * reached - false only for an outage, which is reported here and retried by the next call. A
422
+ * refusal is an answer: the SDK reports it once, and the call goes on to meet whatever the check
423
+ * says about a customer that does not exist.
424
+ */
425
+ async provisionCustomer(context) {
426
+ if (!context.defaultPlanId || !context.customerId || !this.deps.provisionCustomer)
427
+ return true;
428
+ try {
429
+ await this.deps.provisionCustomer(context.customerId, context.defaultPlanId);
430
+ return true;
431
+ }
432
+ catch (err) {
433
+ this.reportError(asEngineError('provisionCustomer', err), 'provisionCustomer', context);
434
+ return false;
435
+ }
436
+ }
400
437
  /**
401
438
  * The last stop for a failure. `onError` is the application's own code and can throw too, and
402
439
  * there is nowhere left to report that to, so it falls back to the logger.
@@ -1,5 +1,5 @@
1
1
  import type { Logger } from '../logger';
2
- import type { EngineCapabilities, EngineCheckParams, EngineCheckResult, EngineReportParams, EngineReportResult, EngineResolution, EntityInput, EntityPlacement, GovernanceEngine } from './engine';
2
+ import type { EngineCapabilities, EngineCheckParams, EngineCheckResult, EngineReportParams, EngineReportResult, EngineResolution, EntityInput, EntityPlacement, GovernanceEngine, ProvisionOutcome } from './engine';
3
3
  export type CompositeDelegates = {
4
4
  upsertEntity?: GovernanceEngine;
5
5
  check: GovernanceEngine;
@@ -11,6 +11,11 @@ export type CompositeDelegates = {
11
11
  unattributedCheck?: GovernanceEngine;
12
12
  report: GovernanceEngine;
13
13
  archiveEntity?: GovernanceEngine;
14
+ /**
15
+ * Creates customers on their default plan. The Node SDK, so the plan it answers with lands in
16
+ * the cache the next check reads. A customer API, so it needs no governance.
17
+ */
18
+ provisionCustomer?: GovernanceEngine;
14
19
  };
15
20
  /**
16
21
  * Whether this check is about an entity at all.
@@ -47,6 +52,7 @@ export declare class CompositeEngine implements GovernanceEngine {
47
52
  constructor(deps: CompositeDeps);
48
53
  upsertEntity(customerId: string, entity: EntityInput, placement?: EntityPlacement): Promise<void>;
49
54
  archiveEntity(customerId: string, entityId: string): Promise<void>;
55
+ provisionCustomer(customerId: string, planId: string): Promise<ProvisionOutcome>;
50
56
  check(customerId: string, params: EngineCheckParams): Promise<EngineCheckResult>;
51
57
  report(customerId: string, params: EngineReportParams): Promise<EngineReportResult>;
52
58
  /**
@@ -70,6 +70,15 @@ class CompositeEngine {
70
70
  async archiveEntity(customerId, entityId) {
71
71
  await this.run('archiveEntity', this.provisioner(), (engine) => engine.archiveEntity(customerId, entityId));
72
72
  }
73
+ async provisionCustomer(customerId, planId) {
74
+ const delegate = this.delegates.provisionCustomer;
75
+ if (!delegate?.provisionCustomer) {
76
+ throw new errors_2.EngineError('provisionCustomer', 'no client here can create customers.');
77
+ }
78
+ // Not through `run()`: its fallback is REST, which does not provision customers, so there is
79
+ // nothing to re-route to - an unreachable Node SDK fails here and the call's own check decides.
80
+ return delegate.provisionCustomer(customerId, planId);
81
+ }
73
82
  async check(customerId, params) {
74
83
  const delegate = (0, exports.isAttributed)(params)
75
84
  ? this.delegates.check
@@ -61,7 +61,7 @@ async function createGovernanceEngine(config) {
61
61
  const resolution = customerLevel('governance not enabled for this account');
62
62
  return {
63
63
  engine: new composite_engine_1.CompositeEngine({
64
- delegates: { check: nodeSdk, report: nodeSdk },
64
+ delegates: { check: nodeSdk, report: nodeSdk, provisionCustomer: nodeSdk },
65
65
  fallback: rest,
66
66
  resolution,
67
67
  logger: config.logger,
@@ -78,7 +78,14 @@ async function createGovernanceEngine(config) {
78
78
  engine: new composite_engine_1.CompositeEngine({
79
79
  // An attributed check goes to REST because only it returns chains; an unattributed one has
80
80
  // no chain to lose, and ingest prefers the cached SDK either way.
81
- delegates: { upsertEntity: rest, check: rest, unattributedCheck: nodeSdk, report: nodeSdk, archiveEntity: rest },
81
+ delegates: {
82
+ upsertEntity: rest,
83
+ check: rest,
84
+ unattributedCheck: nodeSdk,
85
+ report: nodeSdk,
86
+ archiveEntity: rest,
87
+ provisionCustomer: nodeSdk,
88
+ },
82
89
  fallback: rest,
83
90
  resolution,
84
91
  logger: config.logger,
@@ -133,12 +133,40 @@ export type EntityInput = {
133
133
  displayName?: string;
134
134
  metadata?: Record<string, string>;
135
135
  };
136
+ /** What provisioning a customer came to. Every variant is an answer from Stigg. */
137
+ export type ProvisionOutcome = {
138
+ status: 'created';
139
+ /** How the server chose the first subscription - `REQUESTED_PLAN` when it took ours. */
140
+ decision: string;
141
+ /** The plan the customer actually landed on, if they landed on one. */
142
+ planId?: string;
143
+ } | {
144
+ status: 'existing';
145
+ }
146
+ /** Refused, for a reason a retry would not change: an unknown plan, a key that cannot write. */
147
+ | {
148
+ status: 'rejected';
149
+ reason: string;
150
+ code?: string;
151
+ };
136
152
  export interface GovernanceEngine {
137
153
  readonly capabilities: EngineCapabilities;
138
154
  upsertEntity(customerId: string, entity: EntityInput, placement?: EntityPlacement): Promise<void>;
139
155
  check(customerId: string, params: EngineCheckParams): Promise<EngineCheckResult>;
140
156
  report(customerId: string, params: EngineReportParams): Promise<EngineReportResult>;
141
157
  archiveEntity(customerId: string, entityId: string): Promise<void>;
158
+ /**
159
+ * Creates the customer on `planId` when Stigg does not know them yet, and does nothing when it
160
+ * does. `existing` is the common answer, and it leaves the customer's subscriptions alone: a
161
+ * default plan is where a new customer starts, never something an existing one is moved onto.
162
+ *
163
+ * Resolves for every answer Stigg gave - including `rejected`, a request it refused - and
164
+ * throws only when Stigg could not be reached, which is the one failure worth trying again.
165
+ *
166
+ * Optional: an engine with no customer API cannot provision, and the caller treats its absence
167
+ * as having nothing to do.
168
+ */
169
+ provisionCustomer?(customerId: string, planId: string): Promise<ProvisionOutcome>;
142
170
  /**
143
171
  * Loads whatever state this engine caches for a customer, so the checks that follow are local.
144
172
  *
@@ -13,7 +13,7 @@
13
13
  * configuration passed straight through rather than re-described here.
14
14
  */
15
15
  import Stigg, { type ClientConfiguration } from '@stigg/node-server-sdk';
16
- import type { EngineCapabilities, EngineCheckParams, EngineCheckResult, EngineReportParams, EngineReportResult, GovernanceEngine } from './engine';
16
+ import type { EngineCapabilities, EngineCheckParams, EngineCheckResult, EngineReportParams, EngineReportResult, GovernanceEngine, ProvisionOutcome } from './engine';
17
17
  /**
18
18
  * Caching - all of it, and none of it implemented here.
19
19
  *
@@ -84,6 +84,21 @@ export declare class NodeSdkEngine implements GovernanceEngine {
84
84
  * amount happened to warm it.
85
85
  */
86
86
  preload(customerId: string): Promise<void>;
87
+ /**
88
+ * One mutation, no lookup first. Stigg refuses to provision a customer it already has - the
89
+ * whole mutation, subscription included - so an existing customer comes back as a duplicate and
90
+ * is left exactly as they were. Reading first would only add a round trip to every customer's
91
+ * first call in each process, to learn what the create already says.
92
+ *
93
+ * Here rather than on REST because the Node SDK creates the customer and the subscription in one
94
+ * mutation, and writes the entitlements it answers with straight into the cache - so the check
95
+ * that follows reads the new plan locally instead of fetching it.
96
+ *
97
+ * Throws only when Stigg did not answer. Any answer - created, duplicate, or refused - resolves,
98
+ * because the caller treats them differently: a refusal will be the same next time, and an
99
+ * unreachable server may not.
100
+ */
101
+ provisionCustomer(customerId: string, planId: string): Promise<ProvisionOutcome>;
87
102
  upsertEntity(): Promise<void>;
88
103
  archiveEntity(): Promise<void>;
89
104
  /**
@@ -52,6 +52,11 @@ const node_server_sdk_1 = __importStar(require("@stigg/node-server-sdk"));
52
52
  const errors_1 = require("../errors");
53
53
  const sdk_identity_1 = require("../sdk-identity");
54
54
  const report_1 = require("./report");
55
+ /**
56
+ * The API's own code for an error it answered with. Absent when nothing answered - a refused
57
+ * connection, a dropped socket - which is what tells a refusal apart from an outage.
58
+ */
59
+ const errorCode = (err) => err?.errorCode;
55
60
  const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
56
61
  /**
57
62
  * Drops keys that were never set, so a spread cannot un-set a default.
@@ -133,6 +138,39 @@ class NodeSdkEngine {
133
138
  async preload(customerId) {
134
139
  await this.client.reloadEntitlements(customerId);
135
140
  }
141
+ /**
142
+ * One mutation, no lookup first. Stigg refuses to provision a customer it already has - the
143
+ * whole mutation, subscription included - so an existing customer comes back as a duplicate and
144
+ * is left exactly as they were. Reading first would only add a round trip to every customer's
145
+ * first call in each process, to learn what the create already says.
146
+ *
147
+ * Here rather than on REST because the Node SDK creates the customer and the subscription in one
148
+ * mutation, and writes the entitlements it answers with straight into the cache - so the check
149
+ * that follows reads the new plan locally instead of fetching it.
150
+ *
151
+ * Throws only when Stigg did not answer. Any answer - created, duplicate, or refused - resolves,
152
+ * because the caller treats them differently: a refusal will be the same next time, and an
153
+ * unreachable server may not.
154
+ */
155
+ async provisionCustomer(customerId, planId) {
156
+ try {
157
+ const provisioned = await this.client.provisionCustomer({ customerId, subscriptionParams: { planId } });
158
+ return {
159
+ status: 'created',
160
+ decision: provisioned.subscriptionDecisionStrategy,
161
+ planId: provisioned.subscription?.planId,
162
+ };
163
+ }
164
+ catch (err) {
165
+ const code = errorCode(err);
166
+ if (code === 'DuplicatedEntityNotAllowed')
167
+ return { status: 'existing' };
168
+ if (code && code !== 'RequestTimeout') {
169
+ return { status: 'rejected', code, reason: err instanceof Error ? err.message : String(err) };
170
+ }
171
+ throw (0, errors_1.asEngineError)('provisionCustomer', err);
172
+ }
173
+ }
136
174
  async upsertEntity() {
137
175
  throw new errors_1.GovernanceNotEnabledError('The Node server SDK has no governance surface; entities need the REST client.');
138
176
  }
@@ -159,6 +159,18 @@ export declare class StiggAgentSDK {
159
159
  * rather than once each. Only the memoized path is shared - see `register`.
160
160
  */
161
161
  private readonly registering;
162
+ /**
163
+ * Customers this process has provisioned, by id - settled or still in flight. Shared by every
164
+ * client, so a customer seen by ten wrapped clients is provisioned once.
165
+ *
166
+ * A refusal is remembered like a success: an unknown plan or a key that cannot write will be
167
+ * refused the same way next time, and retrying it would spend a rate-limited call on every call.
168
+ * Only an unreachable Stigg is forgotten, so the customer's next call tries again.
169
+ *
170
+ * Capped at {@link KNOWN_CUSTOMERS_CAP}, oldest first. Forgetting a customer costs one more
171
+ * provisioning call, which Stigg answers as a duplicate - so the cap bounds memory, not safety.
172
+ */
173
+ private readonly customers;
162
174
  private started?;
163
175
  /**
164
176
  * When the attempt currently in `started` rejected, or undefined while it is pending or has
@@ -254,6 +266,18 @@ export declare class StiggAgentSDK {
254
266
  */
255
267
  private get contextDefaults();
256
268
  private newSession;
269
+ /**
270
+ * Creates the customer on `planId` the first time this process sees them, if Stigg does not
271
+ * already know them. Keyed by customer alone: once one exists, which plan a later call names
272
+ * is irrelevant - an existing customer is never moved.
273
+ */
274
+ private provisionCustomer;
275
+ /**
276
+ * The server picks the first subscription, and the plan asked for is only a request: a
277
+ * predefined free or trial plan can win, or no subscription may be created at all. Logging
278
+ * success regardless would hide a customer who landed somewhere nobody chose.
279
+ */
280
+ private describeProvisioned;
257
281
  /** The session the no-client paths share, built on first use so an unused SDK builds none. */
258
282
  private standalone;
259
283
  /**
@@ -120,6 +120,11 @@ const DEFAULT_WALLET = { currencyId: 'credits' };
120
120
  * for why remembering it at all is the point.
121
121
  */
122
122
  const RETRY_START_AFTER_MS = 2_000;
123
+ /**
124
+ * How many customers one process remembers having provisioned. A server serving an open-ended set
125
+ * of tenants would otherwise grow this for as long as it runs.
126
+ */
127
+ const KNOWN_CUSTOMERS_CAP = 10_000;
123
128
  /** The key shorthand and the options object, folded into the one object the config takes. */
124
129
  function toOptions(first, second) {
125
130
  return typeof first === 'string' ? { ...second, apiKey: first } : { ...first, ...second };
@@ -184,6 +189,18 @@ class StiggAgentSDK {
184
189
  * rather than once each. Only the memoized path is shared - see `register`.
185
190
  */
186
191
  registering = new Map();
192
+ /**
193
+ * Customers this process has provisioned, by id - settled or still in flight. Shared by every
194
+ * client, so a customer seen by ten wrapped clients is provisioned once.
195
+ *
196
+ * A refusal is remembered like a success: an unknown plan or a key that cannot write will be
197
+ * refused the same way next time, and retrying it would spend a rate-limited call on every call.
198
+ * Only an unreachable Stigg is forgotten, so the customer's next call tries again.
199
+ *
200
+ * Capped at {@link KNOWN_CUSTOMERS_CAP}, oldest first. Forgetting a customer costs one more
201
+ * provisioning call, which Stigg answers as a duplicate - so the cap bounds memory, not safety.
202
+ */
203
+ customers = new Map();
187
204
  started;
188
205
  /**
189
206
  * When the attempt currently in `started` rejected, or undefined while it is pending or has
@@ -443,6 +460,7 @@ class StiggAgentSDK {
443
460
  newSession(hooks) {
444
461
  const session = new session_1.GovernanceSession({
445
462
  engine: () => this.ensureStarted().then((state) => state.engine),
463
+ provisionCustomer: (customerId, planId) => this.provisionCustomer(customerId, planId),
446
464
  logger: this.config.logger,
447
465
  onError: this.config.onError,
448
466
  hooks: hooks ?? {},
@@ -450,6 +468,52 @@ class StiggAgentSDK {
450
468
  this.sessions.add(session);
451
469
  return session;
452
470
  }
471
+ /**
472
+ * Creates the customer on `planId` the first time this process sees them, if Stigg does not
473
+ * already know them. Keyed by customer alone: once one exists, which plan a later call names
474
+ * is irrelevant - an existing customer is never moved.
475
+ */
476
+ provisionCustomer(customerId, planId) {
477
+ const known = this.customers.get(customerId);
478
+ if (known)
479
+ return known;
480
+ const pending = this.ensureStarted().then(async ({ engine }) => {
481
+ const outcome = await engine.provisionCustomer?.(customerId, planId);
482
+ if (outcome?.status === 'created')
483
+ this.describeProvisioned(customerId, planId, outcome);
484
+ // Said once, here, rather than by every call that follows: the memo keeps the refusal, so
485
+ // later calls go straight to the check, which answers for a customer that does not exist.
486
+ if (outcome?.status === 'rejected') {
487
+ const error = new errors_1.EngineError('provisionCustomer', `Stigg refused to provision customer ${customerId} on plan ${planId}, and will not be ` +
488
+ `asked again by this process: ${outcome.reason}`);
489
+ (0, hooks_1.safely)(() => this.config.onError(error, { operation: 'provisionCustomer', customerId }), (reportErr) => this.config.logger.warn(`[stigg] onError threw while reporting ${error.message}: ${String(reportErr)}`));
490
+ }
491
+ });
492
+ this.customers.set(customerId, pending);
493
+ if (this.customers.size > KNOWN_CUSTOMERS_CAP) {
494
+ this.customers.delete(this.customers.keys().next().value);
495
+ }
496
+ // Only its own entry: one evicted by the cap may have been replaced by a newer attempt for the
497
+ // same customer by the time it fails, and deleting that one would re-ask a settled customer.
498
+ pending.catch(() => {
499
+ if (this.customers.get(customerId) === pending)
500
+ this.customers.delete(customerId);
501
+ });
502
+ return pending;
503
+ }
504
+ /**
505
+ * The server picks the first subscription, and the plan asked for is only a request: a
506
+ * predefined free or trial plan can win, or no subscription may be created at all. Logging
507
+ * success regardless would hide a customer who landed somewhere nobody chose.
508
+ */
509
+ describeProvisioned(customerId, planId, outcome) {
510
+ if (outcome.decision === 'REQUESTED_PLAN' && outcome.planId === planId) {
511
+ this.config.logger.info(`[stigg] customer ${customerId} provisioned on plan ${planId}`);
512
+ return;
513
+ }
514
+ this.config.logger.warn(`[stigg] customer ${customerId} was created, but not on plan ${planId}: Stigg chose ` +
515
+ `${outcome.decision}, and the customer is on ${outcome.planId ?? 'no plan'}.`);
516
+ }
453
517
  /** The session the no-client paths share, built on first use so an unused SDK builds none. */
454
518
  standalone() {
455
519
  this.loose ??= this.newSession();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stigg/agent-sdk-js",
3
- "version": "0.3.2",
3
+ "version": "0.4.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",