@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,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A price per metric, as the mapper a run reports through.
|
|
3
|
+
*
|
|
4
|
+
* Writing one by hand is a reduce over a record and three details that are easy to miss: Stigg
|
|
5
|
+
* rejects fractions, a turn that measured nothing must report nothing rather than a zero, and a
|
|
6
|
+
* counter the response omitted is absent rather than zero. This gets all three right once.
|
|
7
|
+
*/
|
|
8
|
+
import type { MetricsMapper } from '../../types';
|
|
9
|
+
import type { ClientRecipe } from '../client/core/types';
|
|
10
|
+
export type WeightedMetricsOptions = {
|
|
11
|
+
/**
|
|
12
|
+
* How many weighted units make one of `target`. Defaults to 1, so a weight reads as the price
|
|
13
|
+
* of one unit of that metric: `output_tokens: 0.5` is half a credit per output token.
|
|
14
|
+
*
|
|
15
|
+
* Set it when the weights are easier to write per thousand than per one - `per: 1000` with
|
|
16
|
+
* `output_tokens: 5` prices five credits per thousand output tokens.
|
|
17
|
+
*/
|
|
18
|
+
per?: number;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Prices what a vendor reported into one metric of your own.
|
|
22
|
+
*
|
|
23
|
+
* The weights are keyed to the recipe's own metric names, so a typo does not compile. Naming one
|
|
24
|
+
* is optional: a metric with no weight is free, and a table that prices nothing meters nothing.
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* // Half a credit per output token, a whole one per input token, cache reads free.
|
|
28
|
+
* metrics: weightedMetrics(anthropicRecipe, 'data_credit', {
|
|
29
|
+
* input_tokens: 1,
|
|
30
|
+
* output_tokens: 0.5,
|
|
31
|
+
* })
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* Prices what the vendor actually said, under its own names. For one vocabulary across several
|
|
35
|
+
* vendors, `normalizedMetrics` reports the four neutral counters instead.
|
|
36
|
+
*/
|
|
37
|
+
export declare function weightedMetrics<M extends string>(recipe: ClientRecipe<M>, target: string, weights: Partial<Record<M, number>>, options?: WeightedMetricsOptions): MetricsMapper;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.weightedMetrics = weightedMetrics;
|
|
4
|
+
/**
|
|
5
|
+
* Prices what a vendor reported into one metric of your own.
|
|
6
|
+
*
|
|
7
|
+
* The weights are keyed to the recipe's own metric names, so a typo does not compile. Naming one
|
|
8
|
+
* is optional: a metric with no weight is free, and a table that prices nothing meters nothing.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* // Half a credit per output token, a whole one per input token, cache reads free.
|
|
12
|
+
* metrics: weightedMetrics(anthropicRecipe, 'data_credit', {
|
|
13
|
+
* input_tokens: 1,
|
|
14
|
+
* output_tokens: 0.5,
|
|
15
|
+
* })
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* Prices what the vendor actually said, under its own names. For one vocabulary across several
|
|
19
|
+
* vendors, `normalizedMetrics` reports the four neutral counters instead.
|
|
20
|
+
*/
|
|
21
|
+
function weightedMetrics(recipe, target, weights, options = {}) {
|
|
22
|
+
const per = options.per && options.per > 0 ? options.per : 1;
|
|
23
|
+
// The one thing this cannot report its way out of: an empty target is a feature id of `''`,
|
|
24
|
+
// which nothing downstream rejects - the value is validated, the key is not - so the turn
|
|
25
|
+
// reports to a feature that does not exist and the budget never moves.
|
|
26
|
+
if (!target.trim()) {
|
|
27
|
+
throw new Error('weightedMetrics needs a target to price into - an empty one reports to no feature.');
|
|
28
|
+
}
|
|
29
|
+
// Deduplicated, because the reducer walks the declaration: a recipe listing a name twice would
|
|
30
|
+
// charge for it twice, which is the one direction a meter must not be wrong in.
|
|
31
|
+
const priced = [...new Set(recipe.metrics)];
|
|
32
|
+
return ({ metrics }) => {
|
|
33
|
+
if (!metrics)
|
|
34
|
+
return {};
|
|
35
|
+
// An unpriced metric is free rather than an error, so a table names only what it charges for.
|
|
36
|
+
const weighted = priced.reduce((sum, name) => sum + (metrics[name] ?? 0) * (weights[name] ?? 0), 0);
|
|
37
|
+
// Nothing measured is not the same as nothing owed: report no metric at all, which the run
|
|
38
|
+
// counts as the no-op ingest it is rather than as a report of zero.
|
|
39
|
+
if (weighted <= 0)
|
|
40
|
+
return {};
|
|
41
|
+
// Stigg takes whole units, and rounding down would bill nothing for a turn that did happen.
|
|
42
|
+
return { [target]: Math.ceil(weighted / per) };
|
|
43
|
+
};
|
|
44
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client configuration - everything that is true of one Stigg *environment*, and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* A Stigg API key is environment-bound, so the key identifies the environment: one instance per
|
|
5
|
+
* environment, like the Node server SDK. That draws the line for what belongs here. Anything that
|
|
6
|
+
* varies within an environment - which customer, which agent, what it bills against, who wants to
|
|
7
|
+
* watch it happen - is context for the call that needs it, not configuration for the process.
|
|
8
|
+
*
|
|
9
|
+
* The data half (ids, urls, numbers, enums) goes through zod so a typo fails at construction with
|
|
10
|
+
* a readable message instead of as a 404 three calls later. The collaborator half (logger,
|
|
11
|
+
* onError) is injected as-is - those are substitution seams, not data to validate.
|
|
12
|
+
*/
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
import { type Logger } from './logger';
|
|
15
|
+
import type { CacheOptions, NodeSdkClient } from './engine/node-sdk-engine';
|
|
16
|
+
import type { CheckErrorAction, IngestMode } from './types';
|
|
17
|
+
import type { EngineError } from './errors';
|
|
18
|
+
/**
|
|
19
|
+
* Where the SDK sends its traffic, and how it identifies itself there.
|
|
20
|
+
*
|
|
21
|
+
* Either a server key - which names an environment on the hosted API - or a base URL that needs
|
|
22
|
+
* no key, which is a local stand-in, a self-hosted deployment, or the built-in mock. They are one
|
|
23
|
+
* positional parameter rather than two options fields precisely so they cannot both be given:
|
|
24
|
+
* "an API key or a URL" is the real rule, and a union says it without a runtime check to fail.
|
|
25
|
+
*
|
|
26
|
+
* A self-hosted deployment that *does* want a key passes the key here and the URL in options.
|
|
27
|
+
*/
|
|
28
|
+
export type StiggTarget = string | {
|
|
29
|
+
baseUrl: string;
|
|
30
|
+
};
|
|
31
|
+
declare const dataSchema: z.ZodObject<{
|
|
32
|
+
apiKey: z.ZodString;
|
|
33
|
+
baseUrl: z.ZodOptional<z.ZodString>;
|
|
34
|
+
checkErrorAction: z.ZodDefault<z.ZodEnum<{
|
|
35
|
+
deny: "deny";
|
|
36
|
+
allow: "allow";
|
|
37
|
+
}>>;
|
|
38
|
+
ingest: z.ZodDefault<z.ZodEnum<{
|
|
39
|
+
sync: "sync";
|
|
40
|
+
async: "async";
|
|
41
|
+
}>>;
|
|
42
|
+
timeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
43
|
+
}, z.core.$strip>;
|
|
44
|
+
export type { CacheOptions, NodeSdkClient };
|
|
45
|
+
/** Reported to `onError` alongside the failure, so a swallowed ingest is still diagnosable. */
|
|
46
|
+
export type ErrorContext = {
|
|
47
|
+
operation: string;
|
|
48
|
+
runId?: string;
|
|
49
|
+
idempotencyKey?: string;
|
|
50
|
+
customerId?: string;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* The second constructor argument: policy and transport, all of it optional and all of it with a
|
|
54
|
+
* working default. Nothing required lives here - the one thing the SDK cannot infer is the
|
|
55
|
+
* target, and that is the first argument.
|
|
56
|
+
*/
|
|
57
|
+
export type StiggAgentSDKOptions = {
|
|
58
|
+
/**
|
|
59
|
+
* How checks are cached. See {@link CacheOptions} - it is the Node server SDK's own cache
|
|
60
|
+
* configuration, passed through, because that is where caching actually happens.
|
|
61
|
+
*/
|
|
62
|
+
cache?: CacheOptions;
|
|
63
|
+
/**
|
|
64
|
+
* A Node server SDK client you already have, used instead of building one.
|
|
65
|
+
*
|
|
66
|
+
* Pass it when the application uses `@stigg/node-server-sdk` directly. It is the only way to
|
|
67
|
+
* settle ownership: `Stigg.initialize` keeps one global instance per process and hands it back
|
|
68
|
+
* when the API key matches, so without this the SDK may be handed your client, believe it built
|
|
69
|
+
* it, and close it out from under you on `stigg.close()` - taking its cache and socket along.
|
|
70
|
+
* A client passed here is borrowed and never closed.
|
|
71
|
+
*
|
|
72
|
+
* It replaces only the cached half. The governance REST client is still built from the API key,
|
|
73
|
+
* because the Node SDK has no governance surface. Mutually exclusive with
|
|
74
|
+
* {@link StiggAgentSDKOptions.cache}, which configures a client this SDK is no longer building.
|
|
75
|
+
*/
|
|
76
|
+
client?: NodeSdkClient;
|
|
77
|
+
/**
|
|
78
|
+
* @deprecated Ignored. There is no local decision cache to put a TTL on any more - the Node
|
|
79
|
+
* server SDK's entitlement cache serves these checks and keeps itself fresh, so there is no
|
|
80
|
+
* window to size. Configure caching with {@link StiggAgentSDKOptions.cache}; for a hard limit
|
|
81
|
+
* with no cached state at all, there is nothing to turn off on the attributed governance path.
|
|
82
|
+
*/
|
|
83
|
+
cacheTtlMs?: number;
|
|
84
|
+
/**
|
|
85
|
+
* What happens to a call when the check itself fails - Stigg unreachable, a timeout, a 5xx.
|
|
86
|
+
*
|
|
87
|
+
* Defaults to `'allow'`: a soft limit, so an outage does not take the application down with it.
|
|
88
|
+
* Pass `'deny'` to mirror governance's own fail-closed behaviour. Overridable per call through
|
|
89
|
+
* `stiggContext`, and by `wrap`'s own context for every call through one client.
|
|
90
|
+
*/
|
|
91
|
+
checkErrorAction?: CheckErrorAction;
|
|
92
|
+
/**
|
|
93
|
+
* Which of Stigg's ingest APIs a run reports through. Defaults to `'async'`: the event API, which
|
|
94
|
+
* costs a fraction of the latency and takes a far higher rate. Overridable per run, and see
|
|
95
|
+
* {@link IngestMode} for what the two trade against each other.
|
|
96
|
+
*/
|
|
97
|
+
ingest?: IngestMode;
|
|
98
|
+
timeoutMs?: number;
|
|
99
|
+
/**
|
|
100
|
+
* Where the SDK narrates what it did. Defaults to `'console'`, because the things it says are
|
|
101
|
+
* the ones you need before anything is configured - which transport resolved, that a run is
|
|
102
|
+
* gating nothing, that what was reported names no entitlement the budget converts.
|
|
103
|
+
*
|
|
104
|
+
* Pass `'none'` to silence it, or your own logger to route it.
|
|
105
|
+
*/
|
|
106
|
+
logger?: Logger | 'console' | 'none';
|
|
107
|
+
/**
|
|
108
|
+
* Every instrumentation failure, as structure rather than as a sentence.
|
|
109
|
+
*
|
|
110
|
+
* The one observability hook that is environment-scoped, because it is the only one that fires
|
|
111
|
+
* with no run to belong to: engine resolution, a bare `register()`, `close()`. Watching a
|
|
112
|
+
* decision or an ingest is per-run and lives on `run.start(..., { hooks })`.
|
|
113
|
+
*
|
|
114
|
+
* It is also the only place metering failures surface at all - they are swallowed so they
|
|
115
|
+
* cannot break a run - so it defaults to a warning through `logger` rather than to silence.
|
|
116
|
+
* Replace it to route an `EngineError` and its {@link ErrorContext} into your own telemetry.
|
|
117
|
+
*/
|
|
118
|
+
onError?: (error: EngineError, context: ErrorContext) => void;
|
|
119
|
+
};
|
|
120
|
+
export type ResolvedConfig = Omit<Required<z.infer<typeof dataSchema>>, 'baseUrl'> & {
|
|
121
|
+
baseUrl?: string;
|
|
122
|
+
cache?: CacheOptions;
|
|
123
|
+
client?: NodeSdkClient;
|
|
124
|
+
logger: Logger;
|
|
125
|
+
onError: (error: EngineError, context: ErrorContext) => void;
|
|
126
|
+
};
|
|
127
|
+
export declare function resolveConfig(target?: StiggTarget, baseUrl?: string, options?: StiggAgentSDKOptions): ResolvedConfig;
|
|
128
|
+
/** The slice of config the engine factory needs. */
|
|
129
|
+
export type EngineConfig = {
|
|
130
|
+
apiKey: string;
|
|
131
|
+
baseUrl?: string;
|
|
132
|
+
timeoutMs: number;
|
|
133
|
+
cache?: CacheOptions;
|
|
134
|
+
client?: NodeSdkClient;
|
|
135
|
+
logger: Logger;
|
|
136
|
+
};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveConfig = resolveConfig;
|
|
4
|
+
/**
|
|
5
|
+
* Client configuration - everything that is true of one Stigg *environment*, and nothing else.
|
|
6
|
+
*
|
|
7
|
+
* A Stigg API key is environment-bound, so the key identifies the environment: one instance per
|
|
8
|
+
* environment, like the Node server SDK. That draws the line for what belongs here. Anything that
|
|
9
|
+
* varies within an environment - which customer, which agent, what it bills against, who wants to
|
|
10
|
+
* watch it happen - is context for the call that needs it, not configuration for the process.
|
|
11
|
+
*
|
|
12
|
+
* The data half (ids, urls, numbers, enums) goes through zod so a typo fails at construction with
|
|
13
|
+
* a readable message instead of as a 404 three calls later. The collaborator half (logger,
|
|
14
|
+
* onError) is injected as-is - those are substitution seams, not data to validate.
|
|
15
|
+
*/
|
|
16
|
+
const zod_1 = require("zod");
|
|
17
|
+
const context_1 = require("./core/context");
|
|
18
|
+
const logger_1 = require("./logger");
|
|
19
|
+
/** The key sent when the target is a URL that needs none. Never reaches a real environment. */
|
|
20
|
+
const KEYLESS = 'offline-mock';
|
|
21
|
+
const dataSchema = zod_1.z.object({
|
|
22
|
+
apiKey: zod_1.z.string().min(1),
|
|
23
|
+
baseUrl: zod_1.z.string().url().optional(),
|
|
24
|
+
// Both default through the context's own table rather than restating it: the two used to
|
|
25
|
+
// disagree, and the one written here was the one nothing read.
|
|
26
|
+
checkErrorAction: zod_1.z.enum(['deny', 'allow']).default(context_1.CONTEXT_DEFAULTS.checkErrorAction),
|
|
27
|
+
ingest: zod_1.z.enum(['sync', 'async']).default(context_1.CONTEXT_DEFAULTS.ingestMode),
|
|
28
|
+
timeoutMs: zod_1.z.number().int().positive().default(3_000),
|
|
29
|
+
});
|
|
30
|
+
const resolveLogger = (logger) => {
|
|
31
|
+
if (logger === 'console' || logger === undefined)
|
|
32
|
+
return logger_1.consoleLogger;
|
|
33
|
+
if (logger === 'none')
|
|
34
|
+
return logger_1.noopLogger;
|
|
35
|
+
return logger;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* The target, split into the key and the URL the transport actually needs.
|
|
39
|
+
*
|
|
40
|
+
* An absent target falls back to the environment variable, the way every vendor SDK does, so the
|
|
41
|
+
* smallest working program is `new StiggAgentSDK()`. The failure for a missing key is raised here
|
|
42
|
+
* rather than left to zod, because "set STIGG_SERVER_API_KEY" is the actionable half and the
|
|
43
|
+
* schema only knows the field was too short.
|
|
44
|
+
*/
|
|
45
|
+
function resolveTarget(target) {
|
|
46
|
+
if (typeof target === 'object')
|
|
47
|
+
return { apiKey: KEYLESS, baseUrl: target.baseUrl };
|
|
48
|
+
const apiKey = (target ?? process.env.STIGG_SERVER_API_KEY)?.trim();
|
|
49
|
+
if (!apiKey) {
|
|
50
|
+
throw new Error('No Stigg API key - pass a server key as the first argument, or set STIGG_SERVER_API_KEY. ' +
|
|
51
|
+
'To run without one, pass a target with a `baseUrl` instead: a local stand-in, a ' +
|
|
52
|
+
'self-hosted deployment, or the built-in mock.');
|
|
53
|
+
}
|
|
54
|
+
return { apiKey };
|
|
55
|
+
}
|
|
56
|
+
function resolveConfig(target, baseUrl, options = {}) {
|
|
57
|
+
const resolved = resolveTarget(target);
|
|
58
|
+
// A keyless target *is* a base URL, so naming a second one is two answers to one question.
|
|
59
|
+
// Refused rather than silently preferred: whichever the SDK picked, the other would be a URL
|
|
60
|
+
// the caller believes it is talking to and is not.
|
|
61
|
+
if (resolved.baseUrl && baseUrl) {
|
|
62
|
+
throw new Error(`This SDK was given a target with its own baseUrl (${resolved.baseUrl}) and a second one ` +
|
|
63
|
+
`(${baseUrl}). Pass the URL once: as the target when it needs no key, or as the second ` +
|
|
64
|
+
'argument alongside a key.');
|
|
65
|
+
}
|
|
66
|
+
const parsed = dataSchema.parse({
|
|
67
|
+
apiKey: resolved.apiKey,
|
|
68
|
+
baseUrl: resolved.baseUrl ?? baseUrl,
|
|
69
|
+
checkErrorAction: options.checkErrorAction,
|
|
70
|
+
ingest: options.ingest,
|
|
71
|
+
timeoutMs: options.timeoutMs,
|
|
72
|
+
});
|
|
73
|
+
// Refused rather than silently preferred, like the two-URL case above: `cache` configures a
|
|
74
|
+
// client, and a client that was handed in is already configured. Whichever the SDK picked, the
|
|
75
|
+
// other would be settings the caller believes are in force and are not.
|
|
76
|
+
if (options.client && options.cache) {
|
|
77
|
+
throw new Error('This SDK was given both a `client` and `cache` options. The cache options configure a ' +
|
|
78
|
+
'client at construction, and the one you passed is already built - so they would be ' +
|
|
79
|
+
'ignored. Pass one: `client` for a client you own, or `cache` to have the SDK build one.');
|
|
80
|
+
}
|
|
81
|
+
const logger = resolveLogger(options.logger);
|
|
82
|
+
// Said rather than ignored in silence: someone who set this was sizing an overshoot window, and
|
|
83
|
+
// the answer to that question is now different rather than smaller.
|
|
84
|
+
if (options.cacheTtlMs !== undefined) {
|
|
85
|
+
logger.warn('[stigg] cacheTtlMs is ignored - this SDK no longer caches decisions. Checks are served by ' +
|
|
86
|
+
"the Node server SDK's entitlement cache, which invalidates on updates and writes reported " +
|
|
87
|
+
'usage back, so there is no TTL to size. Configure it with `cache` instead.');
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
...parsed,
|
|
91
|
+
cache: options.cache,
|
|
92
|
+
client: options.client,
|
|
93
|
+
logger,
|
|
94
|
+
// Never silent, never fatal. Replace it to route into your own telemetry.
|
|
95
|
+
onError: options.onError ?? ((error, context) => logger.warn(`[stigg] ${context.operation} failed: ${error.message}`)),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { CheckErrorAction, Entitlement, IngestMode, Metrics, MetricsMapper } from '../types';
|
|
2
|
+
import type { EventIngest } from '../engine/engine';
|
|
3
|
+
/** The entity type an agent is provisioned and attributed as. Both halves, because they must agree. */
|
|
4
|
+
export type AgentType = {
|
|
5
|
+
/** The governance entity type, as it exists in the account. */
|
|
6
|
+
entityTypeId: string;
|
|
7
|
+
/** The attribution key that type declares, under which `agentId` is sent on every call. */
|
|
8
|
+
attributionKey: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* What a call is governed by. Every field is optional here because each one may have been given
|
|
12
|
+
* at `wrap` instead - `customerId` is the only one that has to come from somewhere, and the
|
|
13
|
+
* wrapped client's types say which calls still owe it.
|
|
14
|
+
*/
|
|
15
|
+
export type StiggContext = {
|
|
16
|
+
/** The tenant. Every governance call is scoped to one customer. */
|
|
17
|
+
customerId?: string;
|
|
18
|
+
/**
|
|
19
|
+
* The agent this call spends as. Travels as a dimension under {@link AgentType.attributionKey},
|
|
20
|
+
* which is the entire mechanism by which an agent is attributed - so naming one without an
|
|
21
|
+
* `agentType` is refused rather than guessed at.
|
|
22
|
+
*
|
|
23
|
+
* Omitted, the call resolves no entity and is checked against the customer's own budget.
|
|
24
|
+
*/
|
|
25
|
+
agentId?: string;
|
|
26
|
+
/** The entity type `agentId` is identified as. Required whenever `agentId` is given. */
|
|
27
|
+
agentType?: AgentType;
|
|
28
|
+
/**
|
|
29
|
+
* Extra attribution, sent verbatim on the check and the report. Every key must be an attribution
|
|
30
|
+
* key on one of the entity types involved, or it matches nothing and the usage rolls up to the
|
|
31
|
+
* customer instead.
|
|
32
|
+
*
|
|
33
|
+
* Overriding this **merges** per key over the wrap's own, so a per-call `{ tenantId }` does not
|
|
34
|
+
* silently drop a client-wide `{ region }`.
|
|
35
|
+
*/
|
|
36
|
+
dimensions?: Record<string, string>;
|
|
37
|
+
/**
|
|
38
|
+
* What this call **gates on** - a credit currency or a metered feature, never both. **Omit it
|
|
39
|
+
* and the call gates nothing**: no check leaves the process, nothing can be refused, and
|
|
40
|
+
* metering is unchanged. The presence of this field is the enforcement switch, because a call
|
|
41
|
+
* with nothing to check against cannot be enforced, and a separate flag would only be a second
|
|
42
|
+
* place for the two to disagree.
|
|
43
|
+
*/
|
|
44
|
+
entitlement?: Entitlement;
|
|
45
|
+
/**
|
|
46
|
+
* What a check resolves to when the engine could not answer it. Defaults to `'allow'` - a soft
|
|
47
|
+
* limit, so an outage does not take the application down with it.
|
|
48
|
+
*
|
|
49
|
+
* Worth knowing what that trades: reporting fails in the same outage and is swallowed, so the
|
|
50
|
+
* spend allowed through is neither capped nor recorded afterwards. `'deny'` mirrors governance,
|
|
51
|
+
* which is fail-closed server-side.
|
|
52
|
+
*/
|
|
53
|
+
checkErrorAction?: CheckErrorAction;
|
|
54
|
+
/**
|
|
55
|
+
* What to report for each completed call. Defaults to the vendor's own metric names and values
|
|
56
|
+
* exactly as they came back, so what lands in Stigg reconciles against that vendor's invoice.
|
|
57
|
+
*
|
|
58
|
+
* The name on the left is a Stigg feature or credit id, and metering the one the call checks -
|
|
59
|
+
* {@link StiggContext.entitlement} - is what makes the budget move.
|
|
60
|
+
*/
|
|
61
|
+
usageMapper?: MetricsMapper;
|
|
62
|
+
/** Which of Stigg's ingest APIs this call reports through. Defaults to `'async'`. */
|
|
63
|
+
ingestMode?: IngestMode;
|
|
64
|
+
/**
|
|
65
|
+
* The event name to report under in `'async'` mode. Defaults to the vendor call that produced
|
|
66
|
+
* the usage - `anthropic.messages.create` - which is what a Stigg meter filters on.
|
|
67
|
+
*/
|
|
68
|
+
eventName?: EventIngest;
|
|
69
|
+
/**
|
|
70
|
+
* The key that deduplicates this report. Defaults to the vendor's own request id when it has
|
|
71
|
+
* one - stable across the vendor's retries, which a local counter cannot be - and to a
|
|
72
|
+
* generated key otherwise.
|
|
73
|
+
*/
|
|
74
|
+
idempotencyKey?: string;
|
|
75
|
+
/**
|
|
76
|
+
* Correlates work that continues in another process, as `x-stigg-run-id`. Defaults to the id
|
|
77
|
+
* the wrap generated, so a gateway sees one value per client unless a call says otherwise.
|
|
78
|
+
*/
|
|
79
|
+
correlationId?: string;
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* A context with everything decided and everything derivable already derived.
|
|
83
|
+
*
|
|
84
|
+
* Nothing here is optional that the call path would otherwise have to default, and nothing is
|
|
85
|
+
* computed that a call could be asked to compute twice.
|
|
86
|
+
*/
|
|
87
|
+
export type ResolvedContext = {
|
|
88
|
+
/**
|
|
89
|
+
* Empty only for a client wrapped without one, whose calls each name their own. Every path that
|
|
90
|
+
* reaches the engine resolves it first, so nothing downstream has to re-check it.
|
|
91
|
+
*/
|
|
92
|
+
customerId: string;
|
|
93
|
+
entitlement?: Entitlement;
|
|
94
|
+
/** The agent folded in, so the check and the report send one object and neither re-derives it. */
|
|
95
|
+
dimensions: Record<string, string>;
|
|
96
|
+
checkErrorAction: CheckErrorAction;
|
|
97
|
+
ingestMode: IngestMode;
|
|
98
|
+
eventName?: EventIngest;
|
|
99
|
+
usageMapper: MetricsMapper;
|
|
100
|
+
idempotencyKey?: string;
|
|
101
|
+
correlationId: string;
|
|
102
|
+
/** The entitlement's own id, resolved once - undefined when this context gates on nothing. */
|
|
103
|
+
checkedId?: string;
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* What nobody told the SDK, and it will not guess.
|
|
107
|
+
*
|
|
108
|
+
* Exported because the constructor's own schema defaults to these: two copies of "what happens
|
|
109
|
+
* when nothing says" is how a configured `deny` came to be parsed, stored and then ignored in
|
|
110
|
+
* favour of an `allow` written somewhere else.
|
|
111
|
+
*
|
|
112
|
+
* `allow` is the soft limit - an unreachable Stigg does not take the application down with it.
|
|
113
|
+
*/
|
|
114
|
+
export declare const CONTEXT_DEFAULTS: {
|
|
115
|
+
readonly checkErrorAction: "allow";
|
|
116
|
+
readonly ingestMode: "async";
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Validates and derives, once. Everything expensive about a context happens here rather than on
|
|
120
|
+
* the call path - see the note at the top of this file for why that split exists.
|
|
121
|
+
*/
|
|
122
|
+
/** The one field a call cannot proceed without, said the same way wherever it is missing. */
|
|
123
|
+
export declare const noCustomer: () => Error;
|
|
124
|
+
/**
|
|
125
|
+
* The client-wide policy a context falls back to before the SDK's own defaults.
|
|
126
|
+
*
|
|
127
|
+
* The chain is per-call, then `wrap`, then the constructor, then {@link CONTEXT_DEFAULTS}. The
|
|
128
|
+
* constructor link is this parameter: without it a configured policy is inert, because every
|
|
129
|
+
* context would land on the defaults instead.
|
|
130
|
+
*/
|
|
131
|
+
export type ContextDefaults = {
|
|
132
|
+
checkErrorAction?: CheckErrorAction;
|
|
133
|
+
ingestMode?: IngestMode;
|
|
134
|
+
};
|
|
135
|
+
export declare function resolveContext(context: StiggContext, correlationId: string, { requireCustomer, defaults }?: {
|
|
136
|
+
requireCustomer?: boolean;
|
|
137
|
+
defaults?: ContextDefaults;
|
|
138
|
+
}): ResolvedContext;
|
|
139
|
+
/**
|
|
140
|
+
* One call's override, applied over what `wrap` already resolved.
|
|
141
|
+
*
|
|
142
|
+
* Only reached when a call actually carried a `stiggContext`; a call that did not reuses the
|
|
143
|
+
* resolved object as-is. The ordering below is the one the caller asked for: the base identity
|
|
144
|
+
* first, because a changed customer or agent changes which budget is consulted, and the metering
|
|
145
|
+
* fields last, because nothing before the response depends on them.
|
|
146
|
+
*/
|
|
147
|
+
export declare function overrideContext(base: ResolvedContext, override: StiggContext): ResolvedContext;
|
|
148
|
+
/** Whether a context gates at all. The enforcement switch, read on every call. */
|
|
149
|
+
export declare const isEnforced: (context: ResolvedContext) => boolean;
|
|
150
|
+
/** Reported metrics, accumulated across a client's calls. */
|
|
151
|
+
export declare const addMetrics: (into: Metrics, add: Metrics) => void;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.addMetrics = exports.isEnforced = exports.noCustomer = exports.CONTEXT_DEFAULTS = void 0;
|
|
4
|
+
exports.resolveContext = resolveContext;
|
|
5
|
+
exports.overrideContext = overrideContext;
|
|
6
|
+
/**
|
|
7
|
+
* The governance context: who a call is for, what gates it, and what it reports.
|
|
8
|
+
*
|
|
9
|
+
* One shape, given in two places. `wrap(client, { stiggContext })` sets the defaults for every
|
|
10
|
+
* call through that client; `client.messages.create({ ..., stiggContext })` overrides them for one
|
|
11
|
+
* call. That is the whole of the configuration surface - there is no run to open and nothing to
|
|
12
|
+
* keep in sync between a setup step and a call site.
|
|
13
|
+
*
|
|
14
|
+
* ## Why resolution is split in two
|
|
15
|
+
*
|
|
16
|
+
* A gated call sits directly in front of a network request the application is paying for, so the
|
|
17
|
+
* SDK's own work has to disappear next to it. The split below is what makes that true:
|
|
18
|
+
*
|
|
19
|
+
* `resolveContext` - runs once, at `wrap`. Validates, folds the agent into the dimensions,
|
|
20
|
+
* and precomputes everything a call would otherwise derive: the cache key,
|
|
21
|
+
* the entitlement id, the mapper.
|
|
22
|
+
* `overrideContext` - runs only when a call actually carries a `stiggContext`, and only touches
|
|
23
|
+
* the keys that call named.
|
|
24
|
+
*
|
|
25
|
+
* So a call that overrides nothing does no merging, no validation and no allocation: it reads the
|
|
26
|
+
* object `wrap` already built. A client wrapped without a customer is the exception - its calls
|
|
27
|
+
* each name their own, so each one carries an override and pays for the merge.
|
|
28
|
+
*/
|
|
29
|
+
const types_1 = require("../types");
|
|
30
|
+
/**
|
|
31
|
+
* What nobody told the SDK, and it will not guess.
|
|
32
|
+
*
|
|
33
|
+
* Exported because the constructor's own schema defaults to these: two copies of "what happens
|
|
34
|
+
* when nothing says" is how a configured `deny` came to be parsed, stored and then ignored in
|
|
35
|
+
* favour of an `allow` written somewhere else.
|
|
36
|
+
*
|
|
37
|
+
* `allow` is the soft limit - an unreachable Stigg does not take the application down with it.
|
|
38
|
+
*/
|
|
39
|
+
exports.CONTEXT_DEFAULTS = { checkErrorAction: 'allow', ingestMode: 'async' };
|
|
40
|
+
/**
|
|
41
|
+
* The agent, as the dimension it travels as.
|
|
42
|
+
*
|
|
43
|
+
* Refused rather than defaulted when the type is missing: a guessed attribution key resolves no
|
|
44
|
+
* entity, so the spend lands on the customer and the output is indistinguishable from a call that
|
|
45
|
+
* was never governed. There is no error to read later, which is why there is one here.
|
|
46
|
+
*/
|
|
47
|
+
function agentDimension(context) {
|
|
48
|
+
if (context.agentId === undefined)
|
|
49
|
+
return undefined;
|
|
50
|
+
if (!context.agentId.trim()) {
|
|
51
|
+
throw new Error('stiggContext.agentId is empty - an agent nobody can name is also one nothing can bill.');
|
|
52
|
+
}
|
|
53
|
+
if (!context.agentType) {
|
|
54
|
+
throw new Error(`stiggContext.agentId is '${context.agentId}' but no agentType says which key to send it under. ` +
|
|
55
|
+
'Pass `agentType: { entityTypeId, attributionKey }` - the pair the entity type was created ' +
|
|
56
|
+
'with. Without it the agent resolves no entity and the spend lands on the customer, which ' +
|
|
57
|
+
'looks exactly like a call that was never governed.');
|
|
58
|
+
}
|
|
59
|
+
return { [context.agentType.attributionKey]: context.agentId };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Validates and derives, once. Everything expensive about a context happens here rather than on
|
|
63
|
+
* the call path - see the note at the top of this file for why that split exists.
|
|
64
|
+
*/
|
|
65
|
+
/** The one field a call cannot proceed without, said the same way wherever it is missing. */
|
|
66
|
+
const noCustomer = () => new Error('No customerId - every governance call is scoped to a tenant. Give `wrap(client, ' +
|
|
67
|
+
'{ stiggContext: { customerId } })` a default for every call through this client, or name ' +
|
|
68
|
+
'one per call in `stiggContext`.');
|
|
69
|
+
exports.noCustomer = noCustomer;
|
|
70
|
+
function resolveContext(context, correlationId,
|
|
71
|
+
// `wrap` passes false: a client whose calls each name their own customer is legal, and its
|
|
72
|
+
// types already say which calls still owe one. Every path that reaches the engine resolves it.
|
|
73
|
+
{ requireCustomer = true, defaults } = {}) {
|
|
74
|
+
const customerId = context.customerId?.trim() ?? '';
|
|
75
|
+
if (!customerId && requireCustomer)
|
|
76
|
+
throw (0, exports.noCustomer)();
|
|
77
|
+
// Naming none is legal and means ungated. Naming an empty one is a typo that would meter into
|
|
78
|
+
// the dark, so it is refused here rather than three calls later.
|
|
79
|
+
const invalid = (0, types_1.entitlementError)(context.entitlement);
|
|
80
|
+
if (invalid)
|
|
81
|
+
throw invalid;
|
|
82
|
+
const agent = agentDimension(context);
|
|
83
|
+
const dimensions = agent ? { ...context.dimensions, ...agent } : (context.dimensions ?? {});
|
|
84
|
+
return {
|
|
85
|
+
customerId,
|
|
86
|
+
// Normalized, not passed through: `isEnforced` asks `!== undefined` while every other reader
|
|
87
|
+
// asks for truthiness, so a JS caller's `entitlement: null` would be enforced by one and
|
|
88
|
+
// absent to the rest - a call that runs the gate and then reports itself ungated.
|
|
89
|
+
entitlement: context.entitlement ?? undefined,
|
|
90
|
+
dimensions,
|
|
91
|
+
checkErrorAction: context.checkErrorAction ?? defaults?.checkErrorAction ?? exports.CONTEXT_DEFAULTS.checkErrorAction,
|
|
92
|
+
ingestMode: context.ingestMode ?? defaults?.ingestMode ?? exports.CONTEXT_DEFAULTS.ingestMode,
|
|
93
|
+
eventName: context.eventName,
|
|
94
|
+
usageMapper: context.usageMapper ?? types_1.vendorMetrics,
|
|
95
|
+
idempotencyKey: context.idempotencyKey,
|
|
96
|
+
correlationId: context.correlationId?.trim() || correlationId,
|
|
97
|
+
checkedId: context.entitlement ? (0, types_1.entitlementId)(context.entitlement) : undefined,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* One call's override, applied over what `wrap` already resolved.
|
|
102
|
+
*
|
|
103
|
+
* Only reached when a call actually carried a `stiggContext`; a call that did not reuses the
|
|
104
|
+
* resolved object as-is. The ordering below is the one the caller asked for: the base identity
|
|
105
|
+
* first, because a changed customer or agent changes which budget is consulted, and the metering
|
|
106
|
+
* fields last, because nothing before the response depends on them.
|
|
107
|
+
*/
|
|
108
|
+
function overrideContext(base, override) {
|
|
109
|
+
// The cheap test first: these are the only fields that can change which budget is consulted,
|
|
110
|
+
// so a call overriding only, say, `idempotencyKey` re-derives nothing.
|
|
111
|
+
const rebased = override.customerId !== undefined ||
|
|
112
|
+
override.agentId !== undefined ||
|
|
113
|
+
override.dimensions !== undefined ||
|
|
114
|
+
override.entitlement !== undefined;
|
|
115
|
+
let customerId = base.customerId;
|
|
116
|
+
let dimensions = base.dimensions;
|
|
117
|
+
let entitlement = base.entitlement;
|
|
118
|
+
let checkedId = base.checkedId;
|
|
119
|
+
if (rebased) {
|
|
120
|
+
customerId = override.customerId?.trim() || base.customerId;
|
|
121
|
+
if (override.entitlement !== undefined) {
|
|
122
|
+
const invalid = (0, types_1.entitlementError)(override.entitlement);
|
|
123
|
+
if (invalid)
|
|
124
|
+
throw invalid;
|
|
125
|
+
entitlement = override.entitlement;
|
|
126
|
+
checkedId = (0, types_1.entitlementId)(override.entitlement);
|
|
127
|
+
}
|
|
128
|
+
// Merged per key, not replaced: a per-call tenant must not drop the client's own attribution.
|
|
129
|
+
const agent = agentDimension({ ...override, agentType: override.agentType });
|
|
130
|
+
dimensions =
|
|
131
|
+
override.dimensions || agent ? { ...base.dimensions, ...override.dimensions, ...agent } : base.dimensions;
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
customerId,
|
|
135
|
+
entitlement,
|
|
136
|
+
dimensions,
|
|
137
|
+
checkedId,
|
|
138
|
+
checkErrorAction: override.checkErrorAction ?? base.checkErrorAction,
|
|
139
|
+
ingestMode: override.ingestMode ?? base.ingestMode,
|
|
140
|
+
eventName: override.eventName ?? base.eventName,
|
|
141
|
+
usageMapper: override.usageMapper ?? base.usageMapper,
|
|
142
|
+
idempotencyKey: override.idempotencyKey ?? base.idempotencyKey,
|
|
143
|
+
correlationId: override.correlationId?.trim() || base.correlationId,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** Whether a context gates at all. The enforcement switch, read on every call. */
|
|
147
|
+
const isEnforced = (context) => context.entitlement !== undefined;
|
|
148
|
+
exports.isEnforced = isEnforced;
|
|
149
|
+
/** Reported metrics, accumulated across a client's calls. */
|
|
150
|
+
const addMetrics = (into, add) => {
|
|
151
|
+
for (const [name, value] of Object.entries(add))
|
|
152
|
+
into[name] = (into[name] ?? 0) + value;
|
|
153
|
+
};
|
|
154
|
+
exports.addMetrics = addMetrics;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vendor's usage object, reduced to something an event can carry.
|
|
3
|
+
*
|
|
4
|
+
* Stigg dimensions are a flat map of scalars, so a nested object has to be flattened or dropped -
|
|
5
|
+
* there is no third option, and stringifying a subtree would put something no meter can aggregate
|
|
6
|
+
* into a field a meter is meant to read.
|
|
7
|
+
*
|
|
8
|
+
* Everything here is a limit rather than a translation, because the input is not ours: a vendor
|
|
9
|
+
* adds fields whenever it likes, and this runs on every metered call. Depth, count and type are
|
|
10
|
+
* all capped so a response that grows a large nested block cannot grow the event with it.
|
|
11
|
+
*/
|
|
12
|
+
/** Prefix for everything read off the vendor's object, so it can never shadow a metric. */
|
|
13
|
+
export declare const RAW_PREFIX = "raw.";
|
|
14
|
+
/**
|
|
15
|
+
* The vendor's own usage block as event dimensions, `raw.`-prefixed and flattened with dotted keys.
|
|
16
|
+
*
|
|
17
|
+
* Fractions are kept: an event states what the vendor said and a meter is free to sum decimals,
|
|
18
|
+
* which is the one place a gateway's `cost` can be reported honestly. `null`, `undefined`, arrays
|
|
19
|
+
* and anything past the depth cap are dropped rather than coerced - a zero that was never reported
|
|
20
|
+
* is worse than a counter that is absent.
|
|
21
|
+
*/
|
|
22
|
+
export declare function flattenRaw(raw: Record<string, unknown> | undefined): Record<string, string | number | boolean>;
|