@stigg/agent-sdk-js 0.0.0-stage → 1.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 +1147 -2
- package/dist/bindings/client/core/registry.d.ts +12 -0
- package/dist/bindings/client/core/registry.js +22 -0
- package/dist/bindings/client/core/types.d.ts +117 -0
- package/dist/bindings/client/core/types.js +13 -0
- package/dist/bindings/client/core/wrap-client.d.ts +6 -0
- package/dist/bindings/client/core/wrap-client.js +258 -0
- package/dist/bindings/client/gateways/litellm/litellm-recipe.d.ts +17 -0
- package/dist/bindings/client/gateways/litellm/litellm-recipe.js +171 -0
- package/dist/bindings/client/gateways/litellm/types.d.ts +30 -0
- package/dist/bindings/client/gateways/litellm/types.js +2 -0
- package/dist/bindings/client/gateways/openrouter/openrouter-recipe.d.ts +15 -0
- package/dist/bindings/client/gateways/openrouter/openrouter-recipe.js +141 -0
- package/dist/bindings/client/gateways/openrouter/types.d.ts +29 -0
- package/dist/bindings/client/gateways/openrouter/types.js +2 -0
- package/dist/bindings/client/providers/anthropic/anthropic-recipe.d.ts +34 -0
- package/dist/bindings/client/providers/anthropic/anthropic-recipe.js +91 -0
- package/dist/bindings/client/providers/gemini/gemini-recipe.d.ts +54 -0
- package/dist/bindings/client/providers/gemini/gemini-recipe.js +243 -0
- package/dist/bindings/client/providers/openai/openai-recipe.d.ts +43 -0
- package/dist/bindings/client/providers/openai/openai-recipe.js +80 -0
- package/dist/bindings/client/providers/typesafe/typesafe-recipe.d.ts +33 -0
- package/dist/bindings/client/providers/typesafe/typesafe-recipe.js +73 -0
- package/dist/bindings/proxy/headers.d.ts +6 -0
- package/dist/bindings/proxy/headers.js +12 -0
- package/dist/bindings/proxy/index.d.ts +21 -0
- package/dist/bindings/proxy/index.js +18 -0
- package/dist/bindings/proxy/types.d.ts +8 -0
- package/dist/bindings/proxy/types.js +2 -0
- package/dist/bindings/shared/estimate.d.ts +27 -0
- package/dist/bindings/shared/estimate.js +38 -0
- package/dist/bindings/shared/meter-stream.d.ts +18 -0
- package/dist/bindings/shared/meter-stream.js +151 -0
- package/dist/bindings/shared/token-usage.d.ts +148 -0
- package/dist/bindings/shared/token-usage.js +414 -0
- package/dist/bindings/shared/types.d.ts +79 -0
- package/dist/bindings/shared/types.js +2 -0
- package/dist/bindings/shared/weighted-metrics.d.ts +37 -0
- package/dist/bindings/shared/weighted-metrics.js +44 -0
- package/dist/config.d.ts +136 -0
- package/dist/config.js +97 -0
- package/dist/core/context.d.ts +151 -0
- package/dist/core/context.js +154 -0
- package/dist/core/dimensions.d.ts +22 -0
- package/dist/core/dimensions.js +47 -0
- package/dist/core/hooks.d.ts +21 -0
- package/dist/core/hooks.js +47 -0
- package/dist/core/session.d.ts +161 -0
- package/dist/core/session.js +403 -0
- package/dist/decision-text.d.ts +15 -0
- package/dist/decision-text.js +54 -0
- package/dist/engine/composite-engine.d.ts +72 -0
- package/dist/engine/composite-engine.js +135 -0
- package/dist/engine/create-engine.d.ts +16 -0
- package/dist/engine/create-engine.js +93 -0
- package/dist/engine/decision.d.ts +37 -0
- package/dist/engine/decision.js +128 -0
- package/dist/engine/engine.d.ts +166 -0
- package/dist/engine/engine.js +11 -0
- package/dist/engine/node-sdk-engine.d.ts +125 -0
- package/dist/engine/node-sdk-engine.js +262 -0
- package/dist/engine/report.d.ts +106 -0
- package/dist/engine/report.js +185 -0
- package/dist/engine/rest-governance-engine.d.ts +47 -0
- package/dist/engine/rest-governance-engine.js +220 -0
- package/dist/errors.d.ts +78 -0
- package/dist/errors.js +151 -0
- package/dist/headers.d.ts +27 -0
- package/dist/headers.js +63 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +85 -0
- package/dist/internal.d.ts +33 -0
- package/dist/internal.js +81 -0
- package/dist/logger.d.ts +15 -0
- package/dist/logger.js +20 -0
- package/dist/sdk-identity.d.ts +36 -0
- package/dist/sdk-identity.js +43 -0
- package/dist/stigg-agent-sdk.d.ts +242 -0
- package/dist/stigg-agent-sdk.js +594 -0
- package/dist/types.d.ts +531 -0
- package/dist/types.js +97 -0
- package/package.json +80 -3
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.usageEntries = exports.creditConsumption = void 0;
|
|
4
|
+
exports.creditResult = creditResult;
|
|
5
|
+
exports.usageResult = usageResult;
|
|
6
|
+
exports.reportMetrics = reportMetrics;
|
|
7
|
+
exports.eventDimensions = eventDimensions;
|
|
8
|
+
exports.missingEventError = missingEventError;
|
|
9
|
+
exports.meterMismatchError = meterMismatchError;
|
|
10
|
+
/**
|
|
11
|
+
* The shape of an ingest, which both transports share even though none of their calls do.
|
|
12
|
+
*
|
|
13
|
+
* Stigg has two ingest APIs and they are not interchangeable. The usage report takes a fixed DTO -
|
|
14
|
+
* a `featureId` and a whole-number `value` per metric - and aggregates on arrival, so the next
|
|
15
|
+
* check sees the spend. The event report takes the call as one event and lets Stigg's own meters
|
|
16
|
+
* turn it into usage seconds later, for a fraction of the latency and a far higher rate limit.
|
|
17
|
+
* Which one a run wants is a product decision - immediacy against throughput - so the caller
|
|
18
|
+
* chooses it and this file only carries the choice out.
|
|
19
|
+
*
|
|
20
|
+
* What that costs each metric:
|
|
21
|
+
*
|
|
22
|
+
* - **`'sync'`** sends one usage report per metric, batched into the one bulk call both clients
|
|
23
|
+
* have, because a chat turn otherwise costs four round trips.
|
|
24
|
+
* - **`'async'`** sends **one event for the whole turn**, every metric on its own dimension. One
|
|
25
|
+
* event can feed any number of meters - each picks its number with `aggregation.field` - so
|
|
26
|
+
* fanning out per metric would buy nothing and cost a request each.
|
|
27
|
+
*
|
|
28
|
+
* A metric named after the run's own credit currency is neither: it draws the wallet directly,
|
|
29
|
+
* because a usage report takes only a `featureId` and there is no event path from a raw event to
|
|
30
|
+
* a currency. It follows the mode only in how it waits - `consumeCredits` under `'sync'`, which
|
|
31
|
+
* answers with a balance, and `consumeCreditsAsync` under `'async'`, which does not.
|
|
32
|
+
*
|
|
33
|
+
* That orchestration lives here, and with it the parts of an ingest that are the same whichever
|
|
34
|
+
* client makes it: the consumption payload, the per-metric idempotency key, and how a bulk ack is
|
|
35
|
+
* read. Each engine still passes its own calls, so a vendor method is only ever named by the file
|
|
36
|
+
* that owns that client - but the split between wallet and features, and what a turn is taken to
|
|
37
|
+
* have cost, cannot come to mean one thing over REST and another over the SDK.
|
|
38
|
+
*/
|
|
39
|
+
const errors_1 = require("../errors");
|
|
40
|
+
const types_1 = require("../types");
|
|
41
|
+
/**
|
|
42
|
+
* Keeps every metric of a turn deduping on its own - and on the same key whichever path sends it,
|
|
43
|
+
* since a metric that names the run's currency is a wallet draw here and a usage entry elsewhere.
|
|
44
|
+
*/
|
|
45
|
+
const metricKey = (params, featureId) => `${params.idempotencyKey}:${featureId}`;
|
|
46
|
+
/** A draw on the wallet. `consume` and `consumeAsync` take it unchanged, on both transports. */
|
|
47
|
+
const creditConsumption = (customerId, params, currencyId, amount) => ({
|
|
48
|
+
customerId,
|
|
49
|
+
currencyId,
|
|
50
|
+
amount,
|
|
51
|
+
idempotencyKey: metricKey(params, currencyId),
|
|
52
|
+
dimensions: params.dimensions,
|
|
53
|
+
});
|
|
54
|
+
exports.creditConsumption = creditConsumption;
|
|
55
|
+
/** One bulk usage report, one entry per metric. REST adds its own `updateBehavior` on top. */
|
|
56
|
+
const usageEntries = (customerId, params, metrics) => Object.entries(metrics).map(([featureId, value]) => ({
|
|
57
|
+
customerId,
|
|
58
|
+
featureId,
|
|
59
|
+
value,
|
|
60
|
+
dimensions: params.dimensions,
|
|
61
|
+
idempotencyKey: metricKey(params, featureId),
|
|
62
|
+
}));
|
|
63
|
+
exports.usageEntries = usageEntries;
|
|
64
|
+
/**
|
|
65
|
+
* What a wallet draw amounts to, read the same way whichever client made it.
|
|
66
|
+
*
|
|
67
|
+
* Only the call and where the balance sits on its response differ between the transports; this is
|
|
68
|
+
* everything after that. `creditsConsumed` is the metric's own value because a draw is spent at
|
|
69
|
+
* par - no feature mapping, no rate - and `replayed` is `false` under `'sync'` rather than
|
|
70
|
+
* unknown: the response carries the wallet's running balance but not this call's own deduction,
|
|
71
|
+
* so a replayed key is indistinguishable from a fresh one. Under `'async'` nothing comes back at
|
|
72
|
+
* all, so it stays undefined.
|
|
73
|
+
*/
|
|
74
|
+
function creditResult(currencyId, amount, currentUsage, mode, source) {
|
|
75
|
+
return {
|
|
76
|
+
reported: { [currencyId]: amount },
|
|
77
|
+
currentUsage: currentUsage ?? undefined,
|
|
78
|
+
creditsConsumed: amount,
|
|
79
|
+
replayed: mode === 'async' ? undefined : false,
|
|
80
|
+
mode,
|
|
81
|
+
source,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** What a bulk usage report amounts to, read the same way whichever client answered. */
|
|
85
|
+
function usageResult(acks, metrics, source) {
|
|
86
|
+
const credited = acks.filter((ack) => ack.credit);
|
|
87
|
+
const last = acks[acks.length - 1];
|
|
88
|
+
return {
|
|
89
|
+
reported: metrics,
|
|
90
|
+
currentUsage: last?.credit?.currentUsage ?? last?.currentUsage ?? undefined,
|
|
91
|
+
// Absent when nothing reported was credit-backed - which is what the run needs to know.
|
|
92
|
+
creditsConsumed: credited.length ? credited.reduce((sum, ack) => sum + (ack.credit?.consumed ?? 0), 0) : undefined,
|
|
93
|
+
// Zero consumed against a credit-backed feature means these keys were already counted.
|
|
94
|
+
replayed: credited.length ? credited.every((ack) => ack.credit?.consumed === 0) : false,
|
|
95
|
+
mode: 'sync',
|
|
96
|
+
source,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const merge = (into, add) => ({
|
|
100
|
+
...into,
|
|
101
|
+
reported: { ...into.reported, ...add.reported },
|
|
102
|
+
creditsConsumed: into.creditsConsumed === undefined && add.creditsConsumed === undefined
|
|
103
|
+
? undefined
|
|
104
|
+
: (into.creditsConsumed ?? 0) + (add.creditsConsumed ?? 0),
|
|
105
|
+
currentUsage: add.currentUsage ?? into.currentUsage,
|
|
106
|
+
// Only a report where *every* metric was a replay is a replay: a partial one still spent. One
|
|
107
|
+
// half that cannot say - an event - makes the whole turn unable to say, rather than say `false`.
|
|
108
|
+
replayed: into.replayed === undefined || add.replayed === undefined ? undefined : into.replayed && add.replayed,
|
|
109
|
+
});
|
|
110
|
+
async function reportMetrics(params, calls) {
|
|
111
|
+
// No entitlement means no wallet, so every metric is a feature - which is exactly what a run that
|
|
112
|
+
// gates on nothing is reporting.
|
|
113
|
+
const walletId = params.entitlement && (0, types_1.entitlementKind)(params.entitlement) === 'credit'
|
|
114
|
+
? (0, types_1.entitlementId)(params.entitlement)
|
|
115
|
+
: undefined;
|
|
116
|
+
const wallet = {};
|
|
117
|
+
const features = {};
|
|
118
|
+
for (const [featureId, value] of Object.entries(params.metrics)) {
|
|
119
|
+
// A zero reaches here only under `'async'`, where it is kept so an event's dimensions hold
|
|
120
|
+
// the same shape on a turn that measured nothing. A wallet draw has no dimensions to hold -
|
|
121
|
+
// it is a consumption, not an event - so a zero would buy the shape nothing and spend a real
|
|
122
|
+
// call and this turn's idempotency key to do it, leaving a 0 in the customer's ledger.
|
|
123
|
+
if (featureId === walletId) {
|
|
124
|
+
if (value !== 0)
|
|
125
|
+
wallet[featureId] = value;
|
|
126
|
+
}
|
|
127
|
+
else
|
|
128
|
+
features[featureId] = value;
|
|
129
|
+
}
|
|
130
|
+
let result = { reported: {}, replayed: true, mode: params.mode, source: 'rest' };
|
|
131
|
+
let reported = false;
|
|
132
|
+
if (Object.keys(features).length) {
|
|
133
|
+
result = merge(result, params.mode === 'async'
|
|
134
|
+
? await calls.event(features, eventDimensions(params, features))
|
|
135
|
+
: await calls.usage(features).catch((err) => {
|
|
136
|
+
throw (0, errors_1.isMeterMismatchError)(err) ? meterMismatchError(features) : (0, errors_1.asEngineError)('ingest', err);
|
|
137
|
+
}));
|
|
138
|
+
reported = true;
|
|
139
|
+
}
|
|
140
|
+
for (const [featureId, value] of Object.entries(wallet)) {
|
|
141
|
+
// The features above may already be at Stigg. If the wallet draw rejects now, that spend is
|
|
142
|
+
// real and the error has to carry it, or the turn reads as though nothing was metered.
|
|
143
|
+
result = merge(result, await (params.mode === 'async' ? calls.creditsAsync : calls.credits)(featureId, value).catch((err) => {
|
|
144
|
+
throw (0, errors_1.asEngineError)('ingest', err, { reported: result.reported, replayed: result.replayed });
|
|
145
|
+
}));
|
|
146
|
+
reported = true;
|
|
147
|
+
}
|
|
148
|
+
// Nothing was sent, so nothing was replayed either - saying otherwise would read as a duplicate.
|
|
149
|
+
return reported ? result : { ...result, replayed: false };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* What one event carries, and in which order.
|
|
153
|
+
*
|
|
154
|
+
* Three namespaces flatten into one map, so the order is the whole design. Attribution is applied
|
|
155
|
+
* last and wins outright: a gateway passes vendor fields through, and a vendor field that happened
|
|
156
|
+
* to be called `agentId` would otherwise re-attribute the bill to an entity that never ran. The
|
|
157
|
+
* raw block sits under its own prefix beneath both, where it can be metered deliberately but
|
|
158
|
+
* cannot displace anything.
|
|
159
|
+
*/
|
|
160
|
+
function eventDimensions(params, metrics) {
|
|
161
|
+
return { ...params.raw, ...metrics, ...params.dimensions };
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The one thing the caller has to fix themselves, so it reads the same whichever client found it.
|
|
165
|
+
*
|
|
166
|
+
* An in-process run derives the name from the vendor call it wrapped, so this is reachable only
|
|
167
|
+
* out of process, where there is no recipe to derive it from.
|
|
168
|
+
*/
|
|
169
|
+
function missingEventError() {
|
|
170
|
+
return new errors_1.EngineError('ingest', 'async ingest reports one event per call, and the event has to be named. Set ' +
|
|
171
|
+
"`stiggContext.eventName` to the name your Stigg meter filters on, or use `ingestMode: 'sync'`.");
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* A sync report to a feature only a meter can fill.
|
|
175
|
+
*
|
|
176
|
+
* The rejection used to be a routing signal - the engines retried it as an event and remembered.
|
|
177
|
+
* That made the transport a property of the catalog, discovered one rejection at a time, and
|
|
178
|
+
* silently moved a run onto an eventually-consistent path it never asked for. Now it says so.
|
|
179
|
+
*/
|
|
180
|
+
function meterMismatchError(metrics) {
|
|
181
|
+
const names = Object.keys(metrics).join(', ');
|
|
182
|
+
return new errors_1.EngineError('ingest', `${names} ${Object.keys(metrics).length > 1 ? 'are' : 'is'} backed by a raw-events meter, which the ` +
|
|
183
|
+
"usage report cannot fill. Report this run with `ingest: 'async'`, or point it at a feature " +
|
|
184
|
+
'whose value your application computes.');
|
|
185
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { EngineCapabilities, EngineCheckParams, EngineCheckResult, EngineReportParams, EngineReportResult, EntityInput, EntityPlacement, GovernanceEngine } from './engine';
|
|
2
|
+
export type RestGovernanceEngineOptions = {
|
|
3
|
+
apiKey: string;
|
|
4
|
+
baseUrl?: string;
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
};
|
|
7
|
+
/** The governance check endpoint is `/api/v1-beta/` and flagged experimental. */
|
|
8
|
+
export declare class RestGovernanceEngine implements GovernanceEngine {
|
|
9
|
+
/**
|
|
10
|
+
* `strictCheck: true`, and it only became true by deletion.
|
|
11
|
+
*
|
|
12
|
+
* This client holds no cache and never did - every check it serves is a live question to a
|
|
13
|
+
* fail-closed API. What made the promise weak was a decision cache above it, and with that gone
|
|
14
|
+
* there is no window in which two processes can each approve the same budget here. It
|
|
15
|
+
* implements no `preload` for the same reason: there is nothing a round trip would fill.
|
|
16
|
+
*
|
|
17
|
+
* This is the path attributed governance checks take - per-agent budgets - which is exactly
|
|
18
|
+
* where the stricter answer is worth a round trip per call.
|
|
19
|
+
*/
|
|
20
|
+
readonly capabilities: EngineCapabilities;
|
|
21
|
+
private readonly client;
|
|
22
|
+
constructor(options: RestGovernanceEngineOptions);
|
|
23
|
+
/** Is governance enabled for this account? A 403 here is an account state, not a failure. */
|
|
24
|
+
probe(): Promise<'enabled' | 'not-enabled'>;
|
|
25
|
+
/**
|
|
26
|
+
* Upsert is idempotent, so this is safe to call on every sight of an agent. Placement is a
|
|
27
|
+
* second call because `parentId` lives on the assignment, not the entity - the entity upsert
|
|
28
|
+
* rejects it outright.
|
|
29
|
+
*/
|
|
30
|
+
upsertEntity(customerId: string, entity: EntityInput, placement?: EntityPlacement): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* The `usageLimit`/`cadence` half of an assignment upsert. `usageLimit` is sent as given;
|
|
33
|
+
* `usageLimitOnCreate` is sent only when no assignment exists yet, because governance requires
|
|
34
|
+
* both fields on create and preserves both when they are omitted on update.
|
|
35
|
+
*/
|
|
36
|
+
private resolveBudget;
|
|
37
|
+
check(customerId: string, { entitlement, requestedUsage, dimensions }: EngineCheckParams): Promise<EngineCheckResult>;
|
|
38
|
+
/** The four calls this client makes; `reportMetrics` decides which of them each metric is. */
|
|
39
|
+
report(customerId: string, params: EngineReportParams): Promise<EngineReportResult>;
|
|
40
|
+
/** The feature path: one bulk call, because a turn's metrics are otherwise four round trips. */
|
|
41
|
+
private reportUsage;
|
|
42
|
+
/** The credit path - no feature mapping. See the Node SDK engine's `consumeCredits`. */
|
|
43
|
+
private consumeCredits;
|
|
44
|
+
private reportAsEvent;
|
|
45
|
+
close(): Promise<void>;
|
|
46
|
+
archiveEntity(customerId: string, entityId: string): Promise<void>;
|
|
47
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.RestGovernanceEngine = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* The engine half that runs on the REST client: everything governance-only.
|
|
9
|
+
*
|
|
10
|
+
* Entities, assignments and the dimensions-aware check that returns `chains` exist only here -
|
|
11
|
+
* the GraphQL SDK has no governance surface at all. This is the only file in the package that
|
|
12
|
+
* imports `@stigg/typescript`, so the beta API's drift is contained to one file.
|
|
13
|
+
*/
|
|
14
|
+
const typescript_1 = __importDefault(require("@stigg/typescript"));
|
|
15
|
+
const errors_1 = require("../errors");
|
|
16
|
+
const types_1 = require("../types");
|
|
17
|
+
const sdk_identity_1 = require("../sdk-identity");
|
|
18
|
+
const report_1 = require("./report");
|
|
19
|
+
/** The governance check endpoint is `/api/v1-beta/` and flagged experimental. */
|
|
20
|
+
class RestGovernanceEngine {
|
|
21
|
+
/**
|
|
22
|
+
* `strictCheck: true`, and it only became true by deletion.
|
|
23
|
+
*
|
|
24
|
+
* This client holds no cache and never did - every check it serves is a live question to a
|
|
25
|
+
* fail-closed API. What made the promise weak was a decision cache above it, and with that gone
|
|
26
|
+
* there is no window in which two processes can each approve the same budget here. It
|
|
27
|
+
* implements no `preload` for the same reason: there is nothing a round trip would fill.
|
|
28
|
+
*
|
|
29
|
+
* This is the path attributed governance checks take - per-agent budgets - which is exactly
|
|
30
|
+
* where the stricter answer is worth a round trip per call.
|
|
31
|
+
*/
|
|
32
|
+
capabilities = { strictCheck: true, chains: true, provisioning: true };
|
|
33
|
+
client;
|
|
34
|
+
constructor(options) {
|
|
35
|
+
this.client = new typescript_1.default({
|
|
36
|
+
apiKey: options.apiKey,
|
|
37
|
+
// This client sends no Stigg identity of its own - only a generic user agent - so without
|
|
38
|
+
// these the governance half of an agent-sdk process is unattributable. Attributed checks
|
|
39
|
+
// go only here, which makes it the half worth being able to see.
|
|
40
|
+
defaultHeaders: sdk_identity_1.AGENT_SDK_REST_HEADERS,
|
|
41
|
+
...(options.baseUrl ? { baseURL: options.baseUrl } : {}),
|
|
42
|
+
// A fail-closed 503 should reach the error policy quickly, not after two backoffs.
|
|
43
|
+
timeout: options.timeoutMs ?? 3_000,
|
|
44
|
+
maxRetries: 1,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/** Is governance enabled for this account? A 403 here is an account state, not a failure. */
|
|
48
|
+
async probe() {
|
|
49
|
+
try {
|
|
50
|
+
await this.client.v1Beta.entityTypes.list({ limit: 1 });
|
|
51
|
+
return 'enabled';
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
if ((0, errors_1.looksLikeGovernanceNotEnabled)(err))
|
|
55
|
+
return 'not-enabled';
|
|
56
|
+
throw (0, errors_1.asEngineError)('probe', err);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Upsert is idempotent, so this is safe to call on every sight of an agent. Placement is a
|
|
61
|
+
* second call because `parentId` lives on the assignment, not the entity - the entity upsert
|
|
62
|
+
* rejects it outright.
|
|
63
|
+
*/
|
|
64
|
+
async upsertEntity(customerId, entity, placement) {
|
|
65
|
+
try {
|
|
66
|
+
await this.client.v1Beta.customers.entities.upsert(customerId, {
|
|
67
|
+
entities: [
|
|
68
|
+
{
|
|
69
|
+
id: entity.id,
|
|
70
|
+
entityTypeId: entity.entityTypeId,
|
|
71
|
+
...(entity.displayName ? { displayName: entity.displayName } : {}),
|
|
72
|
+
...(entity.metadata ? { metadata: entity.metadata } : {}),
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
if (!placement)
|
|
77
|
+
return;
|
|
78
|
+
const capability = (0, types_1.entitlementRef)(placement.entitlement);
|
|
79
|
+
// `usageLimitOnCreate` seeds a budget without ever overwriting one. The server preserves
|
|
80
|
+
// omitted fields on update, so the only question is whether this is an update - hence the
|
|
81
|
+
// read. A budget raised by an admin between runs therefore survives the next provision.
|
|
82
|
+
const budget = await this.resolveBudget(customerId, entity.id, capability, placement);
|
|
83
|
+
await this.client.v1Beta.customers.assignments.upsert(customerId, {
|
|
84
|
+
assignments: [
|
|
85
|
+
{
|
|
86
|
+
entityId: entity.id,
|
|
87
|
+
...capability,
|
|
88
|
+
// Tri-state: omitted leaves the parent alone, null makes it a root, an id re-homes it.
|
|
89
|
+
...(placement.parentId === undefined ? {} : { parentId: placement.parentId }),
|
|
90
|
+
...budget,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
throw (0, errors_1.asGovernanceError)('provision', err);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The `usageLimit`/`cadence` half of an assignment upsert. `usageLimit` is sent as given;
|
|
101
|
+
* `usageLimitOnCreate` is sent only when no assignment exists yet, because governance requires
|
|
102
|
+
* both fields on create and preserves both when they are omitted on update.
|
|
103
|
+
*/
|
|
104
|
+
async resolveBudget(customerId, entityId, capability, placement) {
|
|
105
|
+
const cadence = placement.cadence ? { cadence: placement.cadence } : {};
|
|
106
|
+
if (placement.usageLimit !== undefined)
|
|
107
|
+
return { usageLimit: placement.usageLimit, ...cadence };
|
|
108
|
+
if (placement.usageLimitOnCreate === undefined)
|
|
109
|
+
return cadence;
|
|
110
|
+
const existing = await this.client.v1Beta.customers.assignments.list(customerId, {
|
|
111
|
+
entityId,
|
|
112
|
+
...capability,
|
|
113
|
+
limit: 1,
|
|
114
|
+
});
|
|
115
|
+
// Already assigned: leave the limit and its window exactly as they are.
|
|
116
|
+
if (existing.data.length > 0)
|
|
117
|
+
return {};
|
|
118
|
+
return { usageLimit: placement.usageLimitOnCreate, ...cadence };
|
|
119
|
+
}
|
|
120
|
+
async check(customerId, { entitlement, requestedUsage, dimensions }) {
|
|
121
|
+
try {
|
|
122
|
+
const response = await this.client.v1Beta.customers.entitlements.check(customerId, {
|
|
123
|
+
...(0, types_1.entitlementRef)(entitlement),
|
|
124
|
+
requestedUsage,
|
|
125
|
+
// One dimensions map does three jobs: resolves the entity, attributes usage up every
|
|
126
|
+
// level of the tree, and selects any dimension-scoped sub-budget.
|
|
127
|
+
dimensions,
|
|
128
|
+
});
|
|
129
|
+
const data = response.data;
|
|
130
|
+
return {
|
|
131
|
+
isGranted: data.isGranted,
|
|
132
|
+
accessDeniedReason: data.accessDeniedReason,
|
|
133
|
+
usageLimit: data.usageLimit ?? null,
|
|
134
|
+
currentUsage: data.currentUsage,
|
|
135
|
+
chains: data.chains?.map((chain) => chain.map((node) => ({
|
|
136
|
+
entityId: node.entityId,
|
|
137
|
+
isGranted: node.isGranted,
|
|
138
|
+
usageLimit: node.usageLimit,
|
|
139
|
+
currentUsage: node.currentUsage,
|
|
140
|
+
scopeEntityIds: node.scopeEntityIds,
|
|
141
|
+
}))),
|
|
142
|
+
source: 'rest',
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
throw (0, errors_1.asEngineError)('check', err);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** The four calls this client makes; `reportMetrics` decides which of them each metric is. */
|
|
150
|
+
async report(customerId, params) {
|
|
151
|
+
return (0, report_1.reportMetrics)(params, {
|
|
152
|
+
credits: (featureId, value) => this.consumeCredits(customerId, params, featureId, value),
|
|
153
|
+
creditsAsync: (featureId, value) => this.consumeCredits(customerId, params, featureId, value, 'async'),
|
|
154
|
+
event: (metrics, dimensions) => this.reportAsEvent(customerId, params, metrics, dimensions),
|
|
155
|
+
usage: (metrics) => this.reportUsage(customerId, params, metrics),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/** The feature path: one bulk call, because a turn's metrics are otherwise four round trips. */
|
|
159
|
+
async reportUsage(customerId, params, metrics) {
|
|
160
|
+
const response = await this.client.v1.usage.report({
|
|
161
|
+
// Explicit here and defaulted on the SDK: a report is a delta on this turn's own usage, not
|
|
162
|
+
// the running total, which is the one place these two clients could be read as disagreeing.
|
|
163
|
+
usages: (0, report_1.usageEntries)(customerId, params, metrics).map((usage) => ({ ...usage, updateBehavior: 'DELTA' })),
|
|
164
|
+
});
|
|
165
|
+
return (0, report_1.usageResult)(response.data ?? [], metrics, 'rest');
|
|
166
|
+
}
|
|
167
|
+
/** The credit path - no feature mapping. See the Node SDK engine's `consumeCredits`. */
|
|
168
|
+
async consumeCredits(customerId, params, featureId, value, mode = 'sync') {
|
|
169
|
+
// The wallet's own id. A metric only reaches this path when it names the currency the run
|
|
170
|
+
// checks, so the metric name and the currency are the same string.
|
|
171
|
+
const consumption = (0, report_1.creditConsumption)(customerId, params, featureId, value);
|
|
172
|
+
try {
|
|
173
|
+
// Same consumption either way - the amount is ours to state, so a run reporting to its own
|
|
174
|
+
// currency keeps an exact local budget in both modes. The async form just loses the balance.
|
|
175
|
+
const response = mode === 'async' ? undefined : await this.client.v1.credits.consumption.consume(consumption);
|
|
176
|
+
if (mode === 'async')
|
|
177
|
+
await this.client.v1.credits.consumption.consumeAsync({ consumptions: [consumption] });
|
|
178
|
+
return (0, report_1.creditResult)(featureId, value, response?.data.credit?.currentUsage, mode, 'rest');
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
throw (0, errors_1.asEngineError)('ingest', err);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async reportAsEvent(customerId, params, metrics, dimensions) {
|
|
185
|
+
const event = params.event;
|
|
186
|
+
if (!event)
|
|
187
|
+
throw (0, report_1.missingEventError)();
|
|
188
|
+
try {
|
|
189
|
+
await this.client.v1.events.report({
|
|
190
|
+
events: [
|
|
191
|
+
{
|
|
192
|
+
customerId,
|
|
193
|
+
eventName: event.name,
|
|
194
|
+
// The turn's own key: one event, so no per-feature suffix to keep them apart.
|
|
195
|
+
idempotencyKey: params.idempotencyKey,
|
|
196
|
+
dimensions,
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
});
|
|
200
|
+
return { reported: metrics, mode: 'async', source: 'rest' };
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
throw (0, errors_1.asEngineError)('ingest', err);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
async close() {
|
|
207
|
+
// Nothing held open - fetch-based, no background cache. Declared so the composite can close
|
|
208
|
+
// every delegate without asking which kind it is.
|
|
209
|
+
}
|
|
210
|
+
async archiveEntity(customerId, entityId) {
|
|
211
|
+
try {
|
|
212
|
+
// Soft delete: still readable by id, and not leaf-gated - archive children yourself.
|
|
213
|
+
await this.client.v1Beta.customers.entities.archive(customerId, { ids: [entityId] });
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
throw (0, errors_1.asGovernanceError)('archive', err);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
exports.RestGovernanceEngine = RestGovernanceEngine;
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A flat error hierarchy with `isX()` guards, so callers can branch without `instanceof`
|
|
3
|
+
* surviving across bundles or duplicated copies of this package.
|
|
4
|
+
*/
|
|
5
|
+
import type { GovernanceDecision, Metrics } from './types';
|
|
6
|
+
/**
|
|
7
|
+
* Thrown only where there is no return path for a decision - inside a wrapped client method,
|
|
8
|
+
* which must hand back the vendor's own response type or nothing at all. Everywhere else a
|
|
9
|
+
* denial is a returned `GovernanceDecision`.
|
|
10
|
+
*/
|
|
11
|
+
export declare class AccessDeniedError extends Error {
|
|
12
|
+
readonly decision: GovernanceDecision;
|
|
13
|
+
constructor(decision: GovernanceDecision);
|
|
14
|
+
}
|
|
15
|
+
/** Anything the engine could not complete: transport, timeout, 4xx/5xx, bad input. */
|
|
16
|
+
export declare class EngineError extends Error {
|
|
17
|
+
readonly status?: number;
|
|
18
|
+
readonly operation: string;
|
|
19
|
+
/**
|
|
20
|
+
* What the server had already accepted when this failed.
|
|
21
|
+
*
|
|
22
|
+
* An ingest is up to two calls - the features, then the wallet - so a rejection on the second
|
|
23
|
+
* leaves the first one's spend sitting at Stigg. Reporting that turn as nothing landed would
|
|
24
|
+
* understate a bill the customer is going to see, which is the one direction a meter must not
|
|
25
|
+
* be wrong in.
|
|
26
|
+
*/
|
|
27
|
+
readonly reported?: Metrics;
|
|
28
|
+
/**
|
|
29
|
+
* Whether what landed was a replay. Carried with `reported`, because counting those metrics
|
|
30
|
+
* again is the one thing a caller must not do with keys Stigg had already seen - and
|
|
31
|
+
* `undefined` means here what it means everywhere: the event API could not say.
|
|
32
|
+
*/
|
|
33
|
+
readonly replayed?: boolean;
|
|
34
|
+
constructor(operation: string, message: string, options?: {
|
|
35
|
+
status?: number;
|
|
36
|
+
cause?: unknown;
|
|
37
|
+
reported?: Metrics;
|
|
38
|
+
replayed?: boolean;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/** Governance is not enabled for this Stigg account. An account state, not a transient failure. */
|
|
42
|
+
export declare class GovernanceNotEnabledError extends Error {
|
|
43
|
+
constructor(detail?: string);
|
|
44
|
+
}
|
|
45
|
+
/** Returns the decision when `err` is a governance denial - the one-liner for a binding catch. */
|
|
46
|
+
export declare function isAccessDenied(err: unknown): GovernanceDecision | undefined;
|
|
47
|
+
export declare const isEngineError: (err: unknown) => err is EngineError;
|
|
48
|
+
export declare const isGovernanceNotEnabled: (err: unknown) => err is GovernanceNotEnabledError;
|
|
49
|
+
/**
|
|
50
|
+
* True when Stigg refused a usage report because the feature is backed by a raw-events meter.
|
|
51
|
+
* Ingest forks by meter type, and this error is the definitive signal for which fork to take -
|
|
52
|
+
* `meterType` alone does not say (a raw-events feature can report `INCREMENTAL`).
|
|
53
|
+
*/
|
|
54
|
+
export declare function isMeterMismatchError(err: unknown): boolean;
|
|
55
|
+
/** Whatever a client threw, as the seam's own error - keeping the status where there was one. */
|
|
56
|
+
export declare function asEngineError(operation: string, err: unknown, partial?: {
|
|
57
|
+
reported?: Metrics;
|
|
58
|
+
replayed?: boolean;
|
|
59
|
+
}): EngineError;
|
|
60
|
+
/**
|
|
61
|
+
* True when a failure looks like "this transport is not answering" rather than "Stigg refused".
|
|
62
|
+
*
|
|
63
|
+
* The distinction decides whether another transport is worth trying: a 404 on the GraphQL
|
|
64
|
+
* endpoint or a refused connection says nothing about the request, while a 400 or a 403 says the
|
|
65
|
+
* request itself is wrong and would be wrong on any transport. Deliberately narrow - demoting on
|
|
66
|
+
* a business error would hide a real problem behind a silent re-route.
|
|
67
|
+
*/
|
|
68
|
+
export declare function isTransportFailure(err: unknown): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* The seam's error for an operation that only governance can serve.
|
|
71
|
+
*
|
|
72
|
+
* Entities and assignments exist nowhere else, so a 403 on one of them is the account saying the
|
|
73
|
+
* feature is off - a state the caller can act on - rather than a call that failed. Every other
|
|
74
|
+
* failure is an ordinary engine error.
|
|
75
|
+
*/
|
|
76
|
+
export declare function asGovernanceError(operation: string, err: unknown): Error;
|
|
77
|
+
/** Recognizes the account-state 403 in whatever shape the transport surfaced it. */
|
|
78
|
+
export declare function looksLikeGovernanceNotEnabled(err: unknown): boolean;
|