@stigg/agent-sdk-js 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +5 -2
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.normalizedMetrics = exports.addMetrics = exports.METRIC_PATHS = exports.metricNamesMatchPaths = exports.COMPUTED_TOTALS = exports.metricName = exports.GATEWAY_METRICS = exports.OPENAI_METRICS = exports.TYPESAFE_METRICS = exports.TYPESAFE_COMPONENT_METRICS = exports.OPENAI_RESPONSES_METRICS = exports.OPENAI_CHAT_METRICS = exports.GEMINI_COMPONENT_METRICS = exports.GEMINI_METRICS = exports.ANTHROPIC_METRICS = exports.ANTHROPIC_COMPONENT_METRICS = exports.TOTAL_TOKENS = exports.addTokenUsage = void 0;
|
|
3
|
+
exports.normalizedMetrics = exports.addMetrics = exports.METRIC_PATHS = exports.metricNamesMatchPaths = exports.COMPUTED_TOTALS = exports.metricName = exports.BEDROCK_METRICS = exports.BEDROCK_PATHS = exports.GATEWAY_METRICS = exports.OPENAI_METRICS = exports.TYPESAFE_METRICS = exports.TYPESAFE_COMPONENT_METRICS = exports.OPENAI_RESPONSES_METRICS = exports.OPENAI_CHAT_METRICS = exports.GEMINI_COMPONENT_METRICS = exports.GEMINI_METRICS = exports.ANTHROPIC_METRICS = exports.ANTHROPIC_COMPONENT_METRICS = exports.TOTAL_TOKENS = exports.addTokenUsage = void 0;
|
|
4
4
|
exports.fromFlatTokenUsage = fromFlatTokenUsage;
|
|
5
5
|
exports.fromOpenAIUsage = fromOpenAIUsage;
|
|
6
6
|
exports.fromResponsesUsage = fromResponsesUsage;
|
|
7
|
+
exports.fromBedrockUsage = fromBedrockUsage;
|
|
7
8
|
exports.fromLangChainUsage = fromLangChainUsage;
|
|
8
9
|
exports.fromGeminiUsage = fromGeminiUsage;
|
|
9
10
|
exports.normalizeUsage = normalizeUsage;
|
|
@@ -64,8 +65,41 @@ function fromResponsesUsage(usage) {
|
|
|
64
65
|
input: Math.max(0, n(usage.input_tokens) - cacheRead),
|
|
65
66
|
output: n(usage.output_tokens),
|
|
66
67
|
cacheRead,
|
|
67
|
-
//
|
|
68
|
-
|
|
68
|
+
// OpenAI's own Responses API reports no cache writes, so this is 0 for it either way. A
|
|
69
|
+
// gateway mirroring the surface may report them - OpenRouter does - and reading the field
|
|
70
|
+
// costs nothing where it is absent, where the alternative was dropping a counter the customer
|
|
71
|
+
// was charged for.
|
|
72
|
+
cacheCreate: n(usage.input_tokens_details?.cache_write_tokens),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Bedrock's Converse usage, whose four counters are disjoint.
|
|
77
|
+
*
|
|
78
|
+
* Nothing is subtracted, unlike the OpenAI and Gemini readers: AWS states that `inputTokens`
|
|
79
|
+
* already excludes whatever was read from or written to the cache, so taking the cache back out
|
|
80
|
+
* would charge the call for less than it used.
|
|
81
|
+
*
|
|
82
|
+
* The total is read, not recomputed, like every other vendor that publishes one - but it is worth
|
|
83
|
+
* knowing what it covers. AWS documents `totalTokens` as the input and output of the call, and
|
|
84
|
+
* documents `inputTokens` as cache-exclusive, so on a cached call the stated total can be smaller
|
|
85
|
+
* than the four counters add up to. Both are reported: price the breakdown if the cache matters to
|
|
86
|
+
* you, the total if it does not, and see the README's overlap table before pricing both.
|
|
87
|
+
*/
|
|
88
|
+
function fromBedrockUsage(usage) {
|
|
89
|
+
if (!usage)
|
|
90
|
+
return (0, types_1.emptyTokenUsage)();
|
|
91
|
+
const counted = {
|
|
92
|
+
input: n(usage.inputTokens),
|
|
93
|
+
output: n(usage.outputTokens),
|
|
94
|
+
cacheRead: n(usage.cacheReadInputTokens),
|
|
95
|
+
cacheCreate: n(usage.cacheWriteInputTokens),
|
|
96
|
+
};
|
|
97
|
+
const stated = n(usage.totalTokens);
|
|
98
|
+
return {
|
|
99
|
+
...counted,
|
|
100
|
+
// The vendor's figure when it gave one; otherwise the sum, which is sound precisely because
|
|
101
|
+
// these four do not overlap.
|
|
102
|
+
...withTotal(stated || counted.input + counted.output + counted.cacheRead + counted.cacheCreate),
|
|
69
103
|
};
|
|
70
104
|
}
|
|
71
105
|
function fromLangChainUsage(usage) {
|
|
@@ -287,6 +321,26 @@ exports.OPENAI_METRICS = [
|
|
|
287
321
|
];
|
|
288
322
|
/** A gateway speaks the OpenAI shape and adds the price it charged, under its own field name. */
|
|
289
323
|
exports.GATEWAY_METRICS = [...exports.OPENAI_METRICS, 'cost'];
|
|
324
|
+
/**
|
|
325
|
+
* Bedrock's five, under its own names and with no price among them.
|
|
326
|
+
*
|
|
327
|
+
* Reported verbatim rather than translated into the OpenAI spelling, which is the rule everywhere
|
|
328
|
+
* except where one service is reachable through two different clients - see `sdk-usage.ts` for
|
|
329
|
+
* that exception. Bedrock is reached one way, so its own names are the honest ones, and a reader
|
|
330
|
+
* comparing a Stigg meter against an AWS bill is comparing like with like.
|
|
331
|
+
*
|
|
332
|
+
* No `cost`: Bedrock states no price on the response, which is the thing that most separates it
|
|
333
|
+
* from the other two gateways here. See the recipe for what that costs a run.
|
|
334
|
+
*/
|
|
335
|
+
exports.BEDROCK_PATHS = [
|
|
336
|
+
'inputTokens',
|
|
337
|
+
'outputTokens',
|
|
338
|
+
'cacheReadInputTokens',
|
|
339
|
+
'cacheWriteInputTokens',
|
|
340
|
+
'totalTokens',
|
|
341
|
+
];
|
|
342
|
+
/** Those five. Flat, so the paths are the names. */
|
|
343
|
+
exports.BEDROCK_METRICS = exports.BEDROCK_PATHS;
|
|
290
344
|
/** The leaf a dotted path reports under - `prompt_tokens_details.cached_tokens` -> `cached_tokens`. */
|
|
291
345
|
const metricName = (path) => path.split('.').pop();
|
|
292
346
|
exports.metricName = metricName;
|
|
@@ -331,6 +385,7 @@ exports.METRIC_PATHS = {
|
|
|
331
385
|
openaiResponses: OPENAI_RESPONSES_PATHS,
|
|
332
386
|
typesafe: TYPESAFE_PATHS,
|
|
333
387
|
gemini: GEMINI_PATHS,
|
|
388
|
+
bedrock: exports.BEDROCK_PATHS,
|
|
334
389
|
};
|
|
335
390
|
/**
|
|
336
391
|
* Reads the named keys off a vendor's usage object, dotted paths included.
|
|
@@ -39,8 +39,24 @@ export type ResponsesLikeUsage = {
|
|
|
39
39
|
total_tokens?: number | null;
|
|
40
40
|
input_tokens_details?: {
|
|
41
41
|
cached_tokens?: number | null;
|
|
42
|
+
cache_write_tokens?: number | null;
|
|
42
43
|
} | null;
|
|
43
44
|
};
|
|
45
|
+
/**
|
|
46
|
+
* Bedrock's Converse `usage`. Flat, camelCased, and **disjoint**.
|
|
47
|
+
*
|
|
48
|
+
* AWS is explicit that `inputTokens` counts only the tokens that were neither read from nor
|
|
49
|
+
* written to the cache - "total input tokens = inputTokens + cacheReadInputTokens +
|
|
50
|
+
* cacheWriteInputTokens" - so this shape behaves like Anthropic's and not like OpenAI's, where the
|
|
51
|
+
* cached count nests inside the prompt count. Subtracting here would undercount the call.
|
|
52
|
+
*/
|
|
53
|
+
export type BedrockLikeUsage = {
|
|
54
|
+
inputTokens?: number | null;
|
|
55
|
+
outputTokens?: number | null;
|
|
56
|
+
totalTokens?: number | null;
|
|
57
|
+
cacheReadInputTokens?: number | null;
|
|
58
|
+
cacheWriteInputTokens?: number | null;
|
|
59
|
+
};
|
|
44
60
|
/** LangChain's `usage_metadata`, as carried on an AIMessage. */
|
|
45
61
|
export type LangChainLikeUsage = {
|
|
46
62
|
input_tokens?: number | null;
|
|
@@ -38,7 +38,10 @@ function weightedMetrics(recipe, target, weights, options = {}) {
|
|
|
38
38
|
// counts as the no-op ingest it is rather than as a report of zero.
|
|
39
39
|
if (weighted <= 0)
|
|
40
40
|
return {};
|
|
41
|
-
//
|
|
42
|
-
|
|
41
|
+
// Reported as it came out of the weights. A credit currency takes fractions, so a turn worth
|
|
42
|
+
// a fifth of a unit costs a fifth - rounding here would be this SDK's arithmetic on someone
|
|
43
|
+
// else's price. A feature is the exception, since `POST /api/v1/usage` takes integers: choose
|
|
44
|
+
// `per` so the weights divide, or round in a mapper of your own.
|
|
45
|
+
return { [target]: weighted / per };
|
|
43
46
|
};
|
|
44
47
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -15,19 +15,6 @@ import { type Logger } from './logger';
|
|
|
15
15
|
import type { CacheOptions, NodeSdkClient } from './engine/node-sdk-engine';
|
|
16
16
|
import type { CheckErrorAction, IngestMode } from './types';
|
|
17
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
18
|
declare const dataSchema: z.ZodObject<{
|
|
32
19
|
apiKey: z.ZodString;
|
|
33
20
|
baseUrl: z.ZodOptional<z.ZodString>;
|
|
@@ -50,11 +37,33 @@ export type ErrorContext = {
|
|
|
50
37
|
customerId?: string;
|
|
51
38
|
};
|
|
52
39
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
40
|
+
* Everything the SDK can be told, in one object.
|
|
41
|
+
*
|
|
42
|
+
* All of it optional and all of it with a working default, including where to send traffic: the
|
|
43
|
+
* key falls back to `STIGG_SERVER_API_KEY` and the URL to the hosted API, so the smallest working
|
|
44
|
+
* program is `new StiggAgentSDK()`.
|
|
45
|
+
*
|
|
46
|
+
* One object rather than positional arguments so that each answer has exactly one place to go -
|
|
47
|
+
* a URL given in two places needs a runtime check to refuse both, and an optional argument in
|
|
48
|
+
* front of a required one forces `undefined` placeholders at the call site.
|
|
56
49
|
*/
|
|
57
50
|
export type StiggAgentSDKOptions = {
|
|
51
|
+
/**
|
|
52
|
+
* The server key, which names the environment. Falls back to `STIGG_SERVER_API_KEY`, the way
|
|
53
|
+
* every vendor SDK does - **including when `baseUrl` is set**, since a URL says where traffic
|
|
54
|
+
* goes and nothing about who is sending it.
|
|
55
|
+
*
|
|
56
|
+
* So a shell with that variable exported sends it to whatever `baseUrl` names, a local mock
|
|
57
|
+
* included. To run keyless on such a machine, unset the variable for the process rather than
|
|
58
|
+
* relying on `baseUrl` to suppress it: a deployment that genuinely needs no key is the case
|
|
59
|
+
* where nothing is configured anywhere, and that is the one the fallback below serves.
|
|
60
|
+
*/
|
|
61
|
+
apiKey?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Where to send traffic, when it is not the hosted API. Pass it alongside `apiKey` for a
|
|
64
|
+
* self-hosted deployment that still wants a key, or on its own for one that needs none.
|
|
65
|
+
*/
|
|
66
|
+
baseUrl?: string;
|
|
58
67
|
/**
|
|
59
68
|
* How checks are cached. See {@link CacheOptions} - it is the Node server SDK's own cache
|
|
60
69
|
* configuration, passed through, because that is where caching actually happens.
|
|
@@ -124,7 +133,7 @@ export type ResolvedConfig = Omit<Required<z.infer<typeof dataSchema>>, 'baseUrl
|
|
|
124
133
|
logger: Logger;
|
|
125
134
|
onError: (error: EngineError, context: ErrorContext) => void;
|
|
126
135
|
};
|
|
127
|
-
export declare function resolveConfig(
|
|
136
|
+
export declare function resolveConfig(options?: StiggAgentSDKOptions): ResolvedConfig;
|
|
128
137
|
/** The slice of config the engine factory needs. */
|
|
129
138
|
export type EngineConfig = {
|
|
130
139
|
apiKey: string;
|
package/dist/config.js
CHANGED
|
@@ -35,37 +35,39 @@ const resolveLogger = (logger) => {
|
|
|
35
35
|
return logger;
|
|
36
36
|
};
|
|
37
37
|
/**
|
|
38
|
-
* The
|
|
38
|
+
* The key to send, which is the environment's identity.
|
|
39
39
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
40
|
+
* The environment variable is tried before the keyless fallback, and that order is the whole of
|
|
41
|
+
* it. A `baseUrl` says *where* to send traffic and nothing about who is sending it, so the two
|
|
42
|
+
* are independent: a self-hosted deployment names its URL in options and keeps its key in
|
|
43
|
+
* `STIGG_SERVER_API_KEY`, and substituting a sentinel there would 401 with nothing in the config
|
|
44
|
+
* to say why.
|
|
45
|
+
*
|
|
46
|
+
* {@link KEYLESS} is the last resort, not the rule: it stands in for a deployment that needs no
|
|
47
|
+
* key at all - a local stand-in, or the built-in mock - and exists only because the schema below
|
|
48
|
+
* demands a non-empty string. Nothing authenticates with it.
|
|
49
|
+
*
|
|
50
|
+
* The failure for a missing key is raised here rather than left to zod, because "set
|
|
51
|
+
* STIGG_SERVER_API_KEY" is the actionable half and the schema only knows the field was too short.
|
|
44
52
|
*/
|
|
45
|
-
function
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
function resolveApiKey({ apiKey, baseUrl }) {
|
|
54
|
+
const named = apiKey?.trim();
|
|
55
|
+
if (named)
|
|
56
|
+
return named;
|
|
57
|
+
const fromEnv = process.env.STIGG_SERVER_API_KEY?.trim();
|
|
58
|
+
if (fromEnv)
|
|
59
|
+
return fromEnv;
|
|
60
|
+
// No key anywhere. A URL to send to is the one case where that is a configuration rather than
|
|
61
|
+
// an omission.
|
|
62
|
+
if (baseUrl)
|
|
63
|
+
return KEYLESS;
|
|
64
|
+
throw new Error('No Stigg API key - pass `apiKey`, or set STIGG_SERVER_API_KEY. To run without one, pass ' +
|
|
65
|
+
'`baseUrl` instead: a local stand-in, a self-hosted deployment, or the built-in mock.');
|
|
55
66
|
}
|
|
56
|
-
function resolveConfig(
|
|
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
|
-
}
|
|
67
|
+
function resolveConfig(options = {}) {
|
|
66
68
|
const parsed = dataSchema.parse({
|
|
67
|
-
apiKey:
|
|
68
|
-
baseUrl:
|
|
69
|
+
apiKey: resolveApiKey(options),
|
|
70
|
+
baseUrl: options.baseUrl,
|
|
69
71
|
checkErrorAction: options.checkErrorAction,
|
|
70
72
|
ingest: options.ingest,
|
|
71
73
|
timeoutMs: options.timeoutMs,
|
package/dist/core/context.d.ts
CHANGED
|
@@ -1,12 +1,5 @@
|
|
|
1
1
|
import type { CheckErrorAction, Entitlement, IngestMode, Metrics, MetricsMapper } from '../types';
|
|
2
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
3
|
/**
|
|
11
4
|
* What a call is governed by. Every field is optional here because each one may have been given
|
|
12
5
|
* at `wrap` instead - `customerId` is the only one that has to come from somewhere, and the
|
|
@@ -16,15 +9,22 @@ export type StiggContext = {
|
|
|
16
9
|
/** The tenant. Every governance call is scoped to one customer. */
|
|
17
10
|
customerId?: string;
|
|
18
11
|
/**
|
|
19
|
-
* The agent this call spends as. Travels as a dimension under
|
|
20
|
-
* which is the entire mechanism by which an agent is
|
|
21
|
-
*
|
|
12
|
+
* The agent this call spends as. Travels as a dimension under
|
|
13
|
+
* {@link StiggContext.attributionKey}, which is the entire mechanism by which an agent is
|
|
14
|
+
* attributed - so naming one without a key is refused rather than guessed at.
|
|
22
15
|
*
|
|
23
16
|
* Omitted, the call resolves no entity and is checked against the customer's own budget.
|
|
24
17
|
*/
|
|
25
18
|
agentId?: string;
|
|
26
|
-
/**
|
|
27
|
-
|
|
19
|
+
/**
|
|
20
|
+
* The key the agent's entity type declares, under which `agentId` is sent on every call.
|
|
21
|
+
* Required whenever `agentId` is given, and not defaulted: a guessed key resolves no entity, so
|
|
22
|
+
* the spend lands on the customer looking exactly like a call that was never governed.
|
|
23
|
+
*
|
|
24
|
+
* `register` takes an `entityTypeId` as well, because that call creates the entity. Attributing
|
|
25
|
+
* a call does not need to know what type it was created as - only what key to send it under.
|
|
26
|
+
*/
|
|
27
|
+
attributionKey?: string;
|
|
28
28
|
/**
|
|
29
29
|
* Extra attribution, sent verbatim on the check and the report. Every key must be an attribution
|
|
30
30
|
* key on one of the entity types involved, or it matches nothing and the usage rolls up to the
|
|
@@ -101,6 +101,15 @@ export type ResolvedContext = {
|
|
|
101
101
|
correlationId: string;
|
|
102
102
|
/** The entitlement's own id, resolved once - undefined when this context gates on nothing. */
|
|
103
103
|
checkedId?: string;
|
|
104
|
+
/**
|
|
105
|
+
* Kept so a per-call `agentId` can inherit it.
|
|
106
|
+
*
|
|
107
|
+
* The key belongs to the entity type, which is a property of the client, while the agent varies
|
|
108
|
+
* per call - so naming it once at `wrap` and then only the agent per call is the shape a
|
|
109
|
+
* multi-agent server wants. The folded dimension alone would not do: an override needs the key
|
|
110
|
+
* itself to build its own.
|
|
111
|
+
*/
|
|
112
|
+
attributionKey?: string;
|
|
104
113
|
};
|
|
105
114
|
/**
|
|
106
115
|
* What nobody told the SDK, and it will not guess.
|
package/dist/core/context.js
CHANGED
|
@@ -50,13 +50,13 @@ function agentDimension(context) {
|
|
|
50
50
|
if (!context.agentId.trim()) {
|
|
51
51
|
throw new Error('stiggContext.agentId is empty - an agent nobody can name is also one nothing can bill.');
|
|
52
52
|
}
|
|
53
|
-
if (!context.
|
|
54
|
-
throw new Error(`stiggContext.agentId is '${context.agentId}' but no
|
|
55
|
-
|
|
56
|
-
'
|
|
57
|
-
'
|
|
53
|
+
if (!context.attributionKey?.trim()) {
|
|
54
|
+
throw new Error(`stiggContext.agentId is '${context.agentId}' but no attributionKey says which key to send ` +
|
|
55
|
+
"it under. Pass the key the agent's entity type declares. Without it the agent resolves " +
|
|
56
|
+
'no entity and the spend lands on the customer, which looks exactly like a call that was ' +
|
|
57
|
+
'never governed.');
|
|
58
58
|
}
|
|
59
|
-
return { [context.
|
|
59
|
+
return { [context.attributionKey]: context.agentId };
|
|
60
60
|
}
|
|
61
61
|
/**
|
|
62
62
|
* Validates and derives, once. Everything expensive about a context happens here rather than on
|
|
@@ -71,6 +71,10 @@ function resolveContext(context, correlationId,
|
|
|
71
71
|
// `wrap` passes false: a client whose calls each name their own customer is legal, and its
|
|
72
72
|
// types already say which calls still owe one. Every path that reaches the engine resolves it.
|
|
73
73
|
{ requireCustomer = true, defaults } = {}) {
|
|
74
|
+
// Checked here as well as in `overrideContext`, and this is the half that matters more: `wrap`
|
|
75
|
+
// names an agent and its entitlement once for every call through the client, so a typo here
|
|
76
|
+
// governs *everything* by the fallback rather than one call.
|
|
77
|
+
assertKnownContext(context, requireCustomer ? 'stiggContext' : "wrap's stiggContext");
|
|
74
78
|
const customerId = context.customerId?.trim() ?? '';
|
|
75
79
|
if (!customerId && requireCustomer)
|
|
76
80
|
throw (0, exports.noCustomer)();
|
|
@@ -95,8 +99,58 @@ function resolveContext(context, correlationId,
|
|
|
95
99
|
idempotencyKey: context.idempotencyKey,
|
|
96
100
|
correlationId: context.correlationId?.trim() || correlationId,
|
|
97
101
|
checkedId: context.entitlement ? (0, types_1.entitlementId)(context.entitlement) : undefined,
|
|
102
|
+
attributionKey: context.attributionKey,
|
|
98
103
|
};
|
|
99
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Every field a `stiggContext` may carry.
|
|
107
|
+
*
|
|
108
|
+
* Typed as a total record of `keyof StiggContext` rather than written as a list, so a field added
|
|
109
|
+
* to the context without being added here is a compile error instead of a key this rejects.
|
|
110
|
+
*/
|
|
111
|
+
const CONTEXT_KEYS = {
|
|
112
|
+
customerId: true,
|
|
113
|
+
agentId: true,
|
|
114
|
+
attributionKey: true,
|
|
115
|
+
dimensions: true,
|
|
116
|
+
entitlement: true,
|
|
117
|
+
checkErrorAction: true,
|
|
118
|
+
usageMapper: true,
|
|
119
|
+
ingestMode: true,
|
|
120
|
+
eventName: true,
|
|
121
|
+
idempotencyKey: true,
|
|
122
|
+
correlationId: true,
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Refuses a context carrying a field this SDK does not read.
|
|
126
|
+
*
|
|
127
|
+
* Almost always a typo, and a silent one without this: the context is read key by key, so an
|
|
128
|
+
* unknown key simply never arrives. A call meaning to spend as one agent spends as whatever
|
|
129
|
+
* `wrap` was given instead, and the only trace is a bill against the wrong entity.
|
|
130
|
+
*
|
|
131
|
+
* The types catch it everywhere but `messages.stream`, which Anthropic declares generic -
|
|
132
|
+
* `stream<Params extends MessageStreamParams>(body: Params, ...)` - and a generic parameter
|
|
133
|
+
* infers from its argument rather than checking it for excess properties. `WrappedClient`
|
|
134
|
+
* intersects the overlay with the vendor's own type to stay assignable to it, and an intersection
|
|
135
|
+
* of call signatures is an overload set resolved in order, so a context the overlay rejects falls
|
|
136
|
+
* through to that signature and is taken. Hence the runtime guarantee, which covers every surface
|
|
137
|
+
* as well as JS callers and hand-typed call sites.
|
|
138
|
+
*
|
|
139
|
+
* Thrown rather than warned, like the other guards in this file: the call it would have governed
|
|
140
|
+
* is in front of money, and a warning is read after the bill.
|
|
141
|
+
*
|
|
142
|
+
* `Object.hasOwn` rather than `in`, which walks the prototype chain and so answers true for
|
|
143
|
+
* `toString`, `constructor` and `hasOwnProperty` - the names likeliest to arrive from a JS caller
|
|
144
|
+
* building a context out of an object it did not create.
|
|
145
|
+
*/
|
|
146
|
+
function assertKnownContext(context, where) {
|
|
147
|
+
for (const key of Object.keys(context)) {
|
|
148
|
+
if (Object.hasOwn(CONTEXT_KEYS, key))
|
|
149
|
+
continue;
|
|
150
|
+
throw new Error(`${where} has no field \`${key}\` - this SDK would ignore it, and the call would be ` +
|
|
151
|
+
`governed by whatever remained instead. Valid fields: ${Object.keys(CONTEXT_KEYS).join(', ')}.`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
100
154
|
/**
|
|
101
155
|
* One call's override, applied over what `wrap` already resolved.
|
|
102
156
|
*
|
|
@@ -106,6 +160,7 @@ function resolveContext(context, correlationId,
|
|
|
106
160
|
* fields last, because nothing before the response depends on them.
|
|
107
161
|
*/
|
|
108
162
|
function overrideContext(base, override) {
|
|
163
|
+
assertKnownContext(override, 'stiggContext');
|
|
109
164
|
// The cheap test first: these are the only fields that can change which budget is consulted,
|
|
110
165
|
// so a call overriding only, say, `idempotencyKey` re-derives nothing.
|
|
111
166
|
const rebased = override.customerId !== undefined ||
|
|
@@ -125,10 +180,21 @@ function overrideContext(base, override) {
|
|
|
125
180
|
entitlement = override.entitlement;
|
|
126
181
|
checkedId = (0, types_1.entitlementId)(override.entitlement);
|
|
127
182
|
}
|
|
183
|
+
// The key falls back to the client's own, so a call naming a different agent of the same type
|
|
184
|
+
// need only name the agent - which is the multi-agent case, and the common one.
|
|
185
|
+
const attributionKey = override.attributionKey ?? base.attributionKey;
|
|
186
|
+
const agent = agentDimension({ ...override, attributionKey });
|
|
128
187
|
// Merged per key, not replaced: a per-call tenant must not drop the client's own attribution.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
188
|
+
//
|
|
189
|
+
// With one exception. An override naming a *different* entity type writes its agent under a
|
|
190
|
+
// different key, and the client's own key would otherwise ride along beside it - two agent
|
|
191
|
+
// dimensions on one call, which governance resolves as two entities and bills accordingly.
|
|
192
|
+
// That one is dropped by name: it describes an agent this call is not acting as. Every other
|
|
193
|
+
// dimension the client set is kept, because none of them is claiming to be the agent.
|
|
194
|
+
const inherited = agent && base.attributionKey && attributionKey !== base.attributionKey
|
|
195
|
+
? Object.fromEntries(Object.entries(base.dimensions).filter(([key]) => key !== base.attributionKey))
|
|
196
|
+
: base.dimensions;
|
|
197
|
+
dimensions = override.dimensions || agent ? { ...inherited, ...override.dimensions, ...agent } : base.dimensions;
|
|
132
198
|
}
|
|
133
199
|
return {
|
|
134
200
|
customerId,
|
|
@@ -141,6 +207,7 @@ function overrideContext(base, override) {
|
|
|
141
207
|
usageMapper: override.usageMapper ?? base.usageMapper,
|
|
142
208
|
idempotencyKey: override.idempotencyKey ?? base.idempotencyKey,
|
|
143
209
|
correlationId: override.correlationId?.trim() || base.correlationId,
|
|
210
|
+
attributionKey: override.attributionKey ?? base.attributionKey,
|
|
144
211
|
};
|
|
145
212
|
}
|
|
146
213
|
/** Whether a context gates at all. The enforcement switch, read on every call. */
|
package/dist/core/session.d.ts
CHANGED
|
@@ -4,13 +4,35 @@ import { type ResolvedContext } from './context';
|
|
|
4
4
|
import type { GovernanceEngine } from '../engine/engine';
|
|
5
5
|
import type { ErrorContext } from '../config';
|
|
6
6
|
import type { Logger } from '../logger';
|
|
7
|
-
import { type
|
|
7
|
+
import { type AccessDecision, type IngestResult, type Measurement, type Metrics, type TokenUsage } from '../types';
|
|
8
8
|
/** Everything the session reports back while it works. Set once, at `wrap`. */
|
|
9
|
+
/**
|
|
10
|
+
* Who the observation was for, which the observation itself cannot say.
|
|
11
|
+
*
|
|
12
|
+
* Neither an {@link AccessDecision} nor an {@link IngestResult} names the caller: `deniedBy`
|
|
13
|
+
* appears only on a refusal and names the entity that blocked, not the agent that asked. So one
|
|
14
|
+
* client serving several agents produced a stream of events nothing could attribute, and the only
|
|
15
|
+
* way to tell them apart was a separate wrapped client per agent - each with its own session
|
|
16
|
+
* state, which is a real cost rather than an inconvenience.
|
|
17
|
+
*
|
|
18
|
+
* `correlationId` is the one to reach for when reconciling: it ties a decision to the ingest that
|
|
19
|
+
* followed it, which is what anyone chasing a billing discrepancy actually needs.
|
|
20
|
+
*/
|
|
21
|
+
export type HookContext = {
|
|
22
|
+
/** The tenant this call was scoped to. */
|
|
23
|
+
customerId: string;
|
|
24
|
+
/** What it was attributed by - the agent under its entity type's own key, plus any extra. */
|
|
25
|
+
dimensions: Record<string, string>;
|
|
26
|
+
/** Shared by every observation of one call, and sent on the wire as `x-stigg-run-id`. */
|
|
27
|
+
correlationId: string;
|
|
28
|
+
/** What the call gated on, when it gated on anything. Undefined on a metering-only run. */
|
|
29
|
+
entitlementId?: string;
|
|
30
|
+
};
|
|
9
31
|
export type SessionHooks = {
|
|
10
32
|
/** Every check this client makes. The whole decision, already formatted as `.summary`. */
|
|
11
|
-
onDecision?: (decision:
|
|
33
|
+
onDecision?: (decision: AccessDecision, context: HookContext) => void;
|
|
12
34
|
/** Every ingest this client makes - including the ones that failed. */
|
|
13
|
-
onIngest?: (result: IngestResult) => void;
|
|
35
|
+
onIngest?: (result: IngestResult, context: HookContext) => void;
|
|
14
36
|
};
|
|
15
37
|
export type SessionDeps = {
|
|
16
38
|
/**
|
|
@@ -28,7 +50,7 @@ export type SessionDeps = {
|
|
|
28
50
|
hooks: SessionHooks;
|
|
29
51
|
};
|
|
30
52
|
/** What a client did, for whoever wants to print it. Spans every context it served. */
|
|
31
|
-
export type
|
|
53
|
+
export type SessionSummary = {
|
|
32
54
|
checks: number;
|
|
33
55
|
ingested: number;
|
|
34
56
|
/**
|
|
@@ -40,7 +62,7 @@ export type GovernanceSummary = {
|
|
|
40
62
|
*/
|
|
41
63
|
reported: Metrics;
|
|
42
64
|
ingestFailures: number;
|
|
43
|
-
lastDecision?:
|
|
65
|
+
lastDecision?: AccessDecision;
|
|
44
66
|
};
|
|
45
67
|
export type IngestOptions = {
|
|
46
68
|
/** Overrides the context's own name for this one report - the vendor method that was gated. */
|
|
@@ -66,8 +88,10 @@ export declare class GovernanceSession {
|
|
|
66
88
|
private decision?;
|
|
67
89
|
/** The unmapped-feature warning is said once per client, not once per call. */
|
|
68
90
|
private warnedUnmapped;
|
|
91
|
+
/** Said once per client: the reason is the same every call, and so is the fix. */
|
|
92
|
+
private warnedRounded;
|
|
69
93
|
constructor(deps: SessionDeps);
|
|
70
|
-
get lastDecision():
|
|
94
|
+
get lastDecision(): AccessDecision | undefined;
|
|
71
95
|
/** The attribution to send downstream when part of the work happens in another process. */
|
|
72
96
|
headers(context: ResolvedContext): GovernanceHeaders;
|
|
73
97
|
/**
|
|
@@ -87,7 +111,7 @@ export declare class GovernanceSession {
|
|
|
87
111
|
* The enforcement path. Returns a decision, never throws - a refusal is a commercial event the
|
|
88
112
|
* application routes on, not an exception it recovers from.
|
|
89
113
|
*/
|
|
90
|
-
check(context: ResolvedContext, requestedUsage?: number): Promise<
|
|
114
|
+
check(context: ResolvedContext, requestedUsage?: number): Promise<AccessDecision>;
|
|
91
115
|
/** Applies the context's mapper to a token measurement. The path every binding uses. */
|
|
92
116
|
ingestTokens(context: ResolvedContext, usage: TokenUsage, options?: IngestOptions): Promise<IngestResult>;
|
|
93
117
|
/**
|
|
@@ -127,8 +151,8 @@ export declare class GovernanceSession {
|
|
|
127
151
|
*/
|
|
128
152
|
failedToMeasure(context: ResolvedContext, err: unknown): void;
|
|
129
153
|
/** Waits for in-flight ingests, so a caller knows what was actually delivered. */
|
|
130
|
-
drain(): Promise<
|
|
131
|
-
summary():
|
|
154
|
+
drain(): Promise<SessionSummary>;
|
|
155
|
+
summary(): SessionSummary;
|
|
132
156
|
/** `correlationId:n` - distinct per call, for a vendor that offered no id of its own. */
|
|
133
157
|
private nextKey;
|
|
134
158
|
/**
|
|
@@ -144,6 +168,11 @@ export declare class GovernanceSession {
|
|
|
144
168
|
* so the most that can be said is that nothing reported names what this call checks, and whether
|
|
145
169
|
* a meter closes that gap is configuration this process cannot see.
|
|
146
170
|
*/
|
|
171
|
+
/**
|
|
172
|
+
* Says that an attributed run rounded, because an overcharge the caller did not ask for must
|
|
173
|
+
* not be silent - that silence is the thing this SDK was told off for in the first place.
|
|
174
|
+
*/
|
|
175
|
+
private warnRounded;
|
|
147
176
|
private warnIfNothingDrains;
|
|
148
177
|
private record;
|
|
149
178
|
/**
|