@stigg/agent-sdk-js 1.0.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +135 -43
- package/dist/bindings/client/gateways/bedrock/bedrock-recipe.d.ts +17 -0
- package/dist/bindings/client/gateways/bedrock/bedrock-recipe.js +134 -0
- package/dist/bindings/client/gateways/bedrock/types.d.ts +13 -0
- package/dist/bindings/client/gateways/bedrock/types.js +2 -0
- package/dist/bindings/client/gateways/openrouter/openrouter-recipe.d.ts +5 -0
- package/dist/bindings/client/gateways/openrouter/openrouter-recipe.js +137 -34
- package/dist/bindings/client/gateways/openrouter/sdk-usage.d.ts +37 -0
- package/dist/bindings/client/gateways/openrouter/sdk-usage.js +63 -0
- package/dist/bindings/client/gateways/openrouter/types.d.ts +49 -0
- package/dist/bindings/client/providers/anthropic/anthropic-recipe.d.ts +5 -1
- package/dist/bindings/client/providers/anthropic/anthropic-recipe.js +103 -13
- package/dist/bindings/shared/token-usage.d.ts +30 -1
- package/dist/bindings/shared/token-usage.js +58 -3
- package/dist/bindings/shared/types.d.ts +16 -0
- package/dist/bindings/shared/weighted-metrics.js +5 -2
- package/dist/config.d.ts +26 -17
- package/dist/config.js +29 -27
- package/dist/core/context.d.ts +21 -12
- package/dist/core/context.js +76 -9
- package/dist/core/session.d.ts +38 -9
- package/dist/core/session.js +57 -12
- package/dist/decision-text.d.ts +3 -3
- package/dist/engine/decision.d.ts +11 -6
- package/dist/engine/decision.js +9 -2
- package/dist/engine/report.js +4 -1
- package/dist/errors.d.ts +14 -6
- package/dist/errors.js +13 -3
- package/dist/headers.d.ts +13 -0
- package/dist/headers.js +20 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.js +8 -2
- package/dist/stigg-agent-sdk.d.ts +29 -13
- package/dist/stigg-agent-sdk.js +103 -56
- package/dist/types.d.ts +124 -35
- package/dist/types.js +66 -16
- package/package.json +6 -3
package/dist/stigg-agent-sdk.js
CHANGED
|
@@ -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
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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
|
|
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(
|
|
110
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
/**
|
|
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.
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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 {
|
|
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 `
|
|
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
|
|
168
|
-
*
|
|
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
|
|
177
|
-
*
|
|
178
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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?:
|
|
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
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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<
|
|
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():
|
|
584
|
+
summary(): SessionSummary;
|
|
496
585
|
/** The last decision this client made, or undefined if it has made none. */
|
|
497
|
-
readonly lastDecision?:
|
|
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<
|
|
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.
|
|
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
|
|
72
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
|
95
|
-
|
|
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": "
|
|
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": [
|
|
@@ -57,13 +58,15 @@
|
|
|
57
58
|
"node": ">=22"
|
|
58
59
|
},
|
|
59
60
|
"dependencies": {
|
|
60
|
-
"@stigg/node-server-sdk": "6.
|
|
61
|
+
"@stigg/node-server-sdk": "6.1.0",
|
|
61
62
|
"@stigg/typescript": "0.1.0-beta.55",
|
|
62
63
|
"zod": "^4.6.1"
|
|
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",
|