@stigg/agent-sdk-js 0.7.3 → 0.7.5
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 +2 -3
- package/dist/bindings/client/core/wrap-client.d.ts +7 -0
- package/dist/bindings/client/core/wrap-client.js +31 -3
- package/dist/bindings/orchestrator/adk/plugin.js +6 -1
- package/dist/bindings/orchestrator/core/agent-middleware.d.ts +7 -0
- package/dist/bindings/orchestrator/core/agent-middleware.js +6 -1
- package/dist/bindings/orchestrator/langchain/callback-handler.d.ts +6 -0
- package/dist/bindings/orchestrator/langchain/callback-handler.js +27 -2
- package/dist/bindings/orchestrator/vercel-ai/middleware.d.ts +3 -1
- package/dist/bindings/orchestrator/vercel-ai/middleware.js +81 -26
- package/dist/bindings/orchestrator/vercel-ai/usage.d.ts +9 -2
- package/dist/bindings/orchestrator/vercel-ai/usage.js +24 -4
- package/dist/config.d.ts +10 -0
- package/dist/config.js +2 -0
- package/dist/core/audr.d.ts +107 -0
- package/dist/core/audr.js +154 -0
- package/dist/core/context.d.ts +12 -0
- package/dist/core/context.js +2 -0
- package/dist/core/session.d.ts +3 -0
- package/dist/core/session.js +7 -0
- package/dist/index.d.ts +1 -0
- package/dist/stigg-agent-sdk.d.ts +3 -1
- package/dist/stigg-agent-sdk.js +6 -0
- package/package.json +7 -1
package/README.md
CHANGED
|
@@ -148,8 +148,6 @@ closed, and conflating them is how an outage takes an application down.
|
|
|
148
148
|
| <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/gemini.svg" width="18" alt=""> | Gemini | <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/typesafe.png" width="18" alt=""> | TypeSafe |
|
|
149
149
|
| <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/groq.svg" width="18" alt=""> | Groq | | |
|
|
150
150
|
|
|
151
|
-
**Frameworks** — [Vercel AI SDK](docs/vercel-ai.md): any model it runs, through `generateText`, `streamText` and agents. Model calls only, for now.
|
|
152
|
-
|
|
153
151
|
**Gateways** — what was actually *charged*, and which model actually served after routing:
|
|
154
152
|
|
|
155
153
|
| | Gateway | | Gateway |
|
|
@@ -163,7 +161,8 @@ closed, and conflating them is how an outage takes an application down.
|
|
|
163
161
|
| | Orchestrator | | Orchestrator |
|
|
164
162
|
| --- | --- | --- | --- |
|
|
165
163
|
| <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/langchain.svg" width="18" alt=""> | [LangChain](docs/langchain.md) | <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/langgraph.svg" width="18" alt=""> | [LangGraph](docs/langchain.md#a-hand-built-langgraph) |
|
|
166
|
-
| <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/google-adk.png" width="18" alt=""> | [Google ADK](docs/guide.md#google-adk-one-plugin-per-runner) | |
|
|
164
|
+
| <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/google-adk.png" width="18" alt=""> | [Google ADK](docs/guide.md#google-adk-one-plugin-per-runner) | <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/vercel.svg" width="18" alt=""> | [Vercel AI SDK](docs/vercel-ai.md) (model calls, for now) |
|
|
165
|
+
| | Your own harness, through `stigg.middleware()` | | |
|
|
167
166
|
|
|
168
167
|
Each binding is written against the *shape a vendor returns*, not against its SDK — so installing
|
|
169
168
|
this package pulls in **no vendor packages at all**, and you install only the one you use.
|
|
@@ -4,3 +4,10 @@ import type { ClientRecipe } from './types';
|
|
|
4
4
|
export declare function wrapClient<T extends object>(client: T, recipe: ClientRecipe, session: GovernanceSession, context: ResolvedContext): T;
|
|
5
5
|
/** Whether this client is one `wrap(...)` handed back. */
|
|
6
6
|
export declare function isGoverned(client: object): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* The model a call used, for its AUDR record: the response's word for it before the request's,
|
|
9
|
+
* since a router can serve another. Read by shape across vendors - `model` for most,
|
|
10
|
+
* `modelVersion` on a Gemini response, `input.modelId` on a Bedrock command, and a bare string
|
|
11
|
+
* for Cloudflare's `ai.run(model, inputs)`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function modelOf(...sources: unknown[]): string | undefined;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.wrapClient = wrapClient;
|
|
4
4
|
exports.isGoverned = isGoverned;
|
|
5
|
+
exports.modelOf = modelOf;
|
|
5
6
|
/**
|
|
6
7
|
* The client binding: a drop-in wrapper that gates before the call and meters after it.
|
|
7
8
|
*
|
|
@@ -37,7 +38,7 @@ function wrapClient(client, recipe, session, context) {
|
|
|
37
38
|
const copy = Object.create(client);
|
|
38
39
|
let patched = 0;
|
|
39
40
|
for (const method of recipe.methods) {
|
|
40
|
-
if (patch(copy, client, session, context, method, (0, types_2.eventNameFor)(recipe, method)))
|
|
41
|
+
if (patch(copy, client, session, context, method, (0, types_2.eventNameFor)(recipe, method), recipe.vendor))
|
|
41
42
|
patched += 1;
|
|
42
43
|
}
|
|
43
44
|
// A recipe whose paths resolve to nothing produces a client that is stamped, looks governed,
|
|
@@ -84,7 +85,7 @@ function ownPath(copy, source, path) {
|
|
|
84
85
|
}
|
|
85
86
|
return { owner, realOwner };
|
|
86
87
|
}
|
|
87
|
-
function patch(copy, source, session, base, method, eventName) {
|
|
88
|
+
function patch(copy, source, session, base, method, eventName, vendor) {
|
|
88
89
|
const resolved = ownPath(copy, source, method.path.slice(0, -1));
|
|
89
90
|
if (!resolved)
|
|
90
91
|
return false;
|
|
@@ -150,12 +151,19 @@ function patch(copy, source, session, base, method, eventName) {
|
|
|
150
151
|
if (!decision.hasAccess)
|
|
151
152
|
throw new errors_1.AccessDeniedError(decision);
|
|
152
153
|
}
|
|
154
|
+
const startedAt = Date.now();
|
|
153
155
|
const result = await collect(original.apply(target, args), params);
|
|
154
156
|
// Whatever the recipe reports - tokens for most vendors, real spend plus the model that
|
|
155
157
|
// served it for one that knows better. The wrapper stays out of that judgement.
|
|
156
158
|
const ingest = (measured) => {
|
|
157
159
|
const measurement = (0, types_1.toMeasurement)(measured);
|
|
158
|
-
|
|
160
|
+
// What an AUDR record names and the measurement does not. Read when the usage arrives, so
|
|
161
|
+
// a stream's ingest finds the same answer a completed response does.
|
|
162
|
+
const traced = {
|
|
163
|
+
...context,
|
|
164
|
+
audr: { provider: vendor, model: modelOf(result.data, params, args[0]), startedAt },
|
|
165
|
+
};
|
|
166
|
+
void session.ingestMeasurement(traced, measurement);
|
|
159
167
|
// Reported what it could, and said what it could not. A measurement that does not cover
|
|
160
168
|
// everything the vendor charged for is a metering failure even though the ingest itself
|
|
161
169
|
// succeeded - so it goes out *and* is raised, rather than one or the other.
|
|
@@ -182,6 +190,26 @@ function patch(copy, source, session, base, method, eventName) {
|
|
|
182
190
|
};
|
|
183
191
|
return true;
|
|
184
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* The model a call used, for its AUDR record: the response's word for it before the request's,
|
|
195
|
+
* since a router can serve another. Read by shape across vendors - `model` for most,
|
|
196
|
+
* `modelVersion` on a Gemini response, `input.modelId` on a Bedrock command, and a bare string
|
|
197
|
+
* for Cloudflare's `ai.run(model, inputs)`.
|
|
198
|
+
*/
|
|
199
|
+
function modelOf(...sources) {
|
|
200
|
+
for (const source of sources) {
|
|
201
|
+
if (typeof source === 'string' && source)
|
|
202
|
+
return source;
|
|
203
|
+
if (typeof source !== 'object' || source === null)
|
|
204
|
+
continue;
|
|
205
|
+
const fields = source;
|
|
206
|
+
for (const value of [fields.model, fields.modelVersion, fields.modelId, fields.input?.modelId]) {
|
|
207
|
+
if (typeof value === 'string' && value)
|
|
208
|
+
return value;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
185
213
|
/**
|
|
186
214
|
* How much to ask permission for, with the one answer a gate must never send.
|
|
187
215
|
*
|
|
@@ -111,7 +111,12 @@ class StiggAdkPlugin extends adk_1.BasePlugin {
|
|
|
111
111
|
const runId = `adk:${invocationId}:${++this.seq}`;
|
|
112
112
|
const step = { agent: agentName, adkContext: callbackContext, model: llmRequest.model };
|
|
113
113
|
try {
|
|
114
|
-
|
|
114
|
+
// The invocation is the AUDR run: every model call of one `runAsync`, whichever agent made it.
|
|
115
|
+
const attribution = {
|
|
116
|
+
...(await this.attributionOf(step)),
|
|
117
|
+
audr: { model: llmRequest.model, runId: invocationId },
|
|
118
|
+
};
|
|
119
|
+
await this.middleware.startModel(runId, attribution, 'adk.model');
|
|
115
120
|
}
|
|
116
121
|
catch (err) {
|
|
117
122
|
const decision = (0, errors_1.isAccessDenied)(err);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ResolvedContext, type StiggContext } from '../../../core/context';
|
|
2
2
|
import type { GovernanceSession } from '../../../core/session';
|
|
3
|
+
import type { AudrTrace } from '../../../core/audr';
|
|
3
4
|
import { type Entitlement, type Measurement, type MiddlewareOptions, type MiddlewareTools, type ToolUsageMapper } from '../../../types';
|
|
4
5
|
/** Who a step is for, as the framework stated it on that step. */
|
|
5
6
|
export type StepAttribution = {
|
|
@@ -11,6 +12,12 @@ export type StepAttribution = {
|
|
|
11
12
|
* applies a call's: the customer, dimensions, and anything else a call can override.
|
|
12
13
|
*/
|
|
13
14
|
stiggContexts?: readonly StiggContext[];
|
|
15
|
+
/**
|
|
16
|
+
* What a model step called and where it sits in the framework's run tree, for its AUDR record.
|
|
17
|
+
* Optional: without it a record still goes out. Kept, not copied, until the step ends - so a
|
|
18
|
+
* binding can set `model` to the one the reply says served, which the start cannot know.
|
|
19
|
+
*/
|
|
20
|
+
audr?: Pick<AudrTrace, 'provider' | 'model' | 'runId' | 'spanId' | 'parentSpanId'>;
|
|
14
21
|
};
|
|
15
22
|
/** A tool call the model issued in its reply: the id the tool step will refer back to, and what it named. */
|
|
16
23
|
export type IssuedToolCall = {
|
|
@@ -253,8 +253,13 @@ function createAgentMiddleware(session, config) {
|
|
|
253
253
|
return {
|
|
254
254
|
async startModel(runId, attribution, eventName) {
|
|
255
255
|
const resolved = contextFor(agentFor(attribution.agent).context, attribution);
|
|
256
|
-
const
|
|
256
|
+
const named = resolved.eventName ? resolved : { ...resolved, eventName: { name: eventName } };
|
|
257
|
+
// The framework's run id is this step's span, so a record lines up with the trace it is in.
|
|
258
|
+
const audr = Object.assign(attribution.audr ?? {}, { spanId: attribution.audr?.spanId ?? runId });
|
|
259
|
+
const context = { ...named, audr };
|
|
257
260
|
await check(context);
|
|
261
|
+
// Started once the check is through, as `wrap` times a call: the record times the model, not Stigg.
|
|
262
|
+
audr.startedAt = Date.now();
|
|
258
263
|
rememberStep(runId, { context });
|
|
259
264
|
},
|
|
260
265
|
// Awaited by the binding, so the agent's next check sees this spend. Safe to wait on: ingest
|
|
@@ -67,6 +67,12 @@ export declare class StiggLangChainCallbackHandler extends BaseCallbackHandler {
|
|
|
67
67
|
handleToolStart(tool: unknown, input: string, runId: string, parentRunId?: string, _tags?: string[], metadata?: Record<string, unknown>, runName?: string, toolCallId?: string): Promise<void>;
|
|
68
68
|
handleToolEnd(output: unknown, runId: string): Promise<void>;
|
|
69
69
|
handleToolError(_err: unknown, runId: string): void;
|
|
70
|
+
/**
|
|
71
|
+
* A model step's attribution, plus what its AUDR record names: the provider and model LangChain
|
|
72
|
+
* states in its `ls_*` metadata, and the step's place in the run tree - the top-level run as the
|
|
73
|
+
* AUDR run, the run directly above as the parent span.
|
|
74
|
+
*/
|
|
75
|
+
private modelAttributionOf;
|
|
70
76
|
/**
|
|
71
77
|
* Who a step is for: the configured agent above it in the run tree, and the `stiggContext`s the
|
|
72
78
|
* run stated on the way down. With no agent above it, a name from metadata is passed on only as a
|
|
@@ -117,6 +117,14 @@ class RunTree {
|
|
|
117
117
|
return run.name;
|
|
118
118
|
return this.agentAt(run.parent);
|
|
119
119
|
}
|
|
120
|
+
/** The top of the tree above a run, as far as this handler saw it start. */
|
|
121
|
+
rootOf(runId) {
|
|
122
|
+
let current = runId;
|
|
123
|
+
for (let parent = this.open.get(current)?.parent; parent !== undefined; parent = this.open.get(current)?.parent) {
|
|
124
|
+
current = parent;
|
|
125
|
+
}
|
|
126
|
+
return current;
|
|
127
|
+
}
|
|
120
128
|
isAgent(name) {
|
|
121
129
|
return this.agents.has(name);
|
|
122
130
|
}
|
|
@@ -179,11 +187,11 @@ class StiggLangChainCallbackHandler extends base_1.BaseCallbackHandler {
|
|
|
179
187
|
this.runs.end(runId);
|
|
180
188
|
}
|
|
181
189
|
handleChatModelStart(_llm, _messages, runId, parentRunId, _extraParams, _tags, metadata) {
|
|
182
|
-
return this.middleware.startModel(runId, this.
|
|
190
|
+
return this.middleware.startModel(runId, this.modelAttributionOf(runId, parentRunId, metadata), 'langchain.chat_model');
|
|
183
191
|
}
|
|
184
192
|
// The completion-model path, for an LLM that is not a chat model. Same gate, same meter.
|
|
185
193
|
handleLLMStart(_llm, _prompts, runId, parentRunId, _extraParams, _tags, metadata) {
|
|
186
|
-
return this.middleware.startModel(runId, this.
|
|
194
|
+
return this.middleware.startModel(runId, this.modelAttributionOf(runId, parentRunId, metadata), 'langchain.llm');
|
|
187
195
|
}
|
|
188
196
|
handleLLMEnd(output, runId) {
|
|
189
197
|
const result = output;
|
|
@@ -216,6 +224,23 @@ class StiggLangChainCallbackHandler extends base_1.BaseCallbackHandler {
|
|
|
216
224
|
this.runs.end(runId);
|
|
217
225
|
this.middleware.abandon(runId);
|
|
218
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* A model step's attribution, plus what its AUDR record names: the provider and model LangChain
|
|
229
|
+
* states in its `ls_*` metadata, and the step's place in the run tree - the top-level run as the
|
|
230
|
+
* AUDR run, the run directly above as the parent span.
|
|
231
|
+
*/
|
|
232
|
+
modelAttributionOf(runId, parentRunId, metadata) {
|
|
233
|
+
const text = (value) => (typeof value === 'string' && value ? value : undefined);
|
|
234
|
+
return {
|
|
235
|
+
...this.attributionOf(parentRunId, metadata),
|
|
236
|
+
audr: {
|
|
237
|
+
provider: text(metadata?.ls_provider),
|
|
238
|
+
model: text(metadata?.ls_model_name),
|
|
239
|
+
runId: parentRunId === undefined ? runId : this.runs.rootOf(parentRunId),
|
|
240
|
+
parentSpanId: parentRunId,
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
219
244
|
/**
|
|
220
245
|
* Who a step is for: the configured agent above it in the run tree, and the `stiggContext`s the
|
|
221
246
|
* run stated on the way down. With no agent above it, a name from metadata is passed on only as a
|
|
@@ -33,10 +33,12 @@ export type StiggVercelMiddleware = {
|
|
|
33
33
|
wrapGenerate(options: {
|
|
34
34
|
doGenerate: () => PromiseLike<any>;
|
|
35
35
|
params: any;
|
|
36
|
+
model?: any;
|
|
36
37
|
}): Promise<any>;
|
|
37
38
|
wrapStream(options: {
|
|
38
39
|
doStream: () => PromiseLike<any>;
|
|
39
40
|
params: any;
|
|
41
|
+
model?: any;
|
|
40
42
|
}): Promise<any>;
|
|
41
43
|
/** What this middleware did, and what is still in flight. */
|
|
42
44
|
readonly stigg: GovernedAgentMiddleware['stigg'];
|
|
@@ -47,4 +49,4 @@ export declare function stiggVercelMiddleware({ stigg, stiggContext, hooks, }: S
|
|
|
47
49
|
* call's `abortSignal` - which is how the AI SDK stops a stream, without cancelling it.
|
|
48
50
|
* ponytail: a reader that stops with no `abortSignal` is not reported; nothing tells this layer.
|
|
49
51
|
*/
|
|
50
|
-
export declare function observeStream(result: any, signal: AbortSignal | undefined, done: (measurement: Measurement | undefined) => void): void;
|
|
52
|
+
export declare function observeStream(result: any, signal: AbortSignal | undefined, done: (measurement: Measurement | undefined, modelId?: string) => void): void;
|
|
@@ -17,7 +17,9 @@ function stiggVercelMiddleware({ stigg, stiggContext, hooks, }) {
|
|
|
17
17
|
// `doGenerate()` takes no arguments: the model is called with what `transformParams` returned.
|
|
18
18
|
// So the context comes out there, and is found again by that params object.
|
|
19
19
|
const contexts = new WeakMap();
|
|
20
|
-
// A step's id
|
|
20
|
+
// A step's id is also its AUDR span, so it is unique across middlewares, not only among this
|
|
21
|
+
// one's steps in flight: one id per middleware, and a counter under it.
|
|
22
|
+
const prefix = crypto.randomUUID();
|
|
21
23
|
let steps = 0;
|
|
22
24
|
// A stream reports when it ends, after `wrapStream` has returned - so the call's next step waits
|
|
23
25
|
// for those reports here, and is checked against their spend. Keyed by the call: the AI SDK hands
|
|
@@ -27,13 +29,15 @@ function stiggVercelMiddleware({ stigg, stiggContext, hooks, }) {
|
|
|
27
29
|
const reporting = new Map();
|
|
28
30
|
const callOf = (params) => contexts.get(params) ?? JSON.stringify([stiggContext?.customerId, stiggContext?.agentId]);
|
|
29
31
|
/** Checks the step, as the agent and customer this call names. Refused: `AccessDeniedError`. */
|
|
30
|
-
const start = async (params, event) => {
|
|
32
|
+
const start = async (params, model, event) => {
|
|
31
33
|
// Once: a call's steps run one after another, so nothing of its own lands while this waits.
|
|
32
34
|
await Promise.all(reporting.get(callOf(params)) ?? []);
|
|
33
|
-
const runId =
|
|
35
|
+
const runId = `${prefix}:${++steps}`;
|
|
34
36
|
const context = contexts.get(params);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
// Kept by the middleware until the step ends, so the model the reply names can still land on it.
|
|
38
|
+
const trace = audrOf(model);
|
|
39
|
+
await governance.startModel(runId, { stiggContexts: context ? [context] : [], audr: trace }, event);
|
|
40
|
+
return { runId, trace };
|
|
37
41
|
};
|
|
38
42
|
return {
|
|
39
43
|
specificationVersion: 'v3',
|
|
@@ -52,8 +56,8 @@ function stiggVercelMiddleware({ stigg, stiggContext, hooks, }) {
|
|
|
52
56
|
contexts.set(stripped, context);
|
|
53
57
|
return stripped;
|
|
54
58
|
},
|
|
55
|
-
async wrapGenerate({ doGenerate, params }) {
|
|
56
|
-
const runId = await start(params, 'vercel-ai.generate');
|
|
59
|
+
async wrapGenerate({ doGenerate, params, model }) {
|
|
60
|
+
const { runId, trace } = await start(params, model, 'vercel-ai.generate');
|
|
57
61
|
let result;
|
|
58
62
|
try {
|
|
59
63
|
result = await doGenerate();
|
|
@@ -62,13 +66,16 @@ function stiggVercelMiddleware({ stigg, stiggContext, hooks, }) {
|
|
|
62
66
|
governance.abandon(runId);
|
|
63
67
|
throw err;
|
|
64
68
|
}
|
|
65
|
-
// Awaited, so the agent's next step is checked against this one's spend. Never throws
|
|
66
|
-
// usage
|
|
67
|
-
|
|
69
|
+
// Awaited, so the agent's next step is checked against this one's spend. Never throws. No
|
|
70
|
+
// usage stated: estimated from the reply, as a stream is, and counted as a metering failure.
|
|
71
|
+
const responseId = result?.response?.id;
|
|
72
|
+
const chars = (0, usage_1.contentChars)(result?.content);
|
|
73
|
+
served(trace, result?.response?.modelId);
|
|
74
|
+
await governance.endModel(runId, (0, usage_1.measurementOf)(result?.usage, responseId) ?? (chars ? (0, usage_1.estimated)(chars, usage_1.NO_USAGE, responseId) : undefined));
|
|
68
75
|
return result;
|
|
69
76
|
},
|
|
70
|
-
async wrapStream({ doStream, params }) {
|
|
71
|
-
const runId = await start(params, 'vercel-ai.stream');
|
|
77
|
+
async wrapStream({ doStream, params, model }) {
|
|
78
|
+
const { runId, trace } = await start(params, model, 'vercel-ai.stream');
|
|
72
79
|
let result;
|
|
73
80
|
try {
|
|
74
81
|
result = await doStream();
|
|
@@ -77,9 +84,10 @@ function stiggVercelMiddleware({ stigg, stiggContext, hooks, }) {
|
|
|
77
84
|
governance.abandon(runId);
|
|
78
85
|
throw err;
|
|
79
86
|
}
|
|
80
|
-
observeStream(result, params?.abortSignal, (measurement) => {
|
|
87
|
+
observeStream(result, params?.abortSignal, (measurement, modelId) => {
|
|
81
88
|
if (!measurement)
|
|
82
89
|
return governance.abandon(runId);
|
|
90
|
+
served(trace, modelId);
|
|
83
91
|
const call = callOf(params);
|
|
84
92
|
const pending = reporting.get(call) ?? new Set();
|
|
85
93
|
reporting.set(call, pending);
|
|
@@ -95,29 +103,75 @@ function stiggVercelMiddleware({ stigg, stiggContext, hooks, }) {
|
|
|
95
103
|
},
|
|
96
104
|
};
|
|
97
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* What an AUDR record names for the model the AI SDK called. Its `provider` is the provider and the
|
|
108
|
+
* API it went through - `anthropic.messages`, `openai.chat` - and the vendor is the part before the dot.
|
|
109
|
+
*/
|
|
110
|
+
function audrOf(model) {
|
|
111
|
+
const text = (value) => (typeof value === 'string' && value ? value : undefined);
|
|
112
|
+
return {
|
|
113
|
+
provider: text(model?.provider)?.split('.')[0],
|
|
114
|
+
model: text(model?.modelId),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** The model the reply says served, over the one the call asked for: a gateway or an alias can differ. */
|
|
118
|
+
function served(trace, modelId) {
|
|
119
|
+
if (typeof modelId === 'string' && modelId)
|
|
120
|
+
trace.model = modelId;
|
|
121
|
+
}
|
|
122
|
+
/** What `incomplete` says for a stream that ended without its usage, by how it ended. */
|
|
123
|
+
const STREAM_GAPS = {
|
|
124
|
+
ended: usage_1.NO_USAGE,
|
|
125
|
+
failed: 'the stream failed before the model stated its usage, so its output was estimated',
|
|
126
|
+
timedOut: 'the call timed out before the model stated its usage, so its output was estimated',
|
|
127
|
+
stopped: 'the call was stopped before any output, so its input was not measured',
|
|
128
|
+
};
|
|
129
|
+
/** A timeout's abort is not the caller stopping. */
|
|
130
|
+
const isTimeout = (reason) => reason?.name === 'TimeoutError';
|
|
131
|
+
/**
|
|
132
|
+
* Why a reader cancelled: the AI SDK cancels an attempt it retries with the provider's error, which
|
|
133
|
+
* is a failure; any other reason is the caller stopping.
|
|
134
|
+
*/
|
|
135
|
+
function cancelEnd(reason) {
|
|
136
|
+
if (isTimeout(reason))
|
|
137
|
+
return 'timedOut';
|
|
138
|
+
return reason instanceof Error && reason.name !== 'AbortError' ? 'failed' : 'stopped';
|
|
139
|
+
}
|
|
98
140
|
/** What a stream has told us so far. */
|
|
99
141
|
function streamTally() {
|
|
100
142
|
let usage;
|
|
101
143
|
let responseId;
|
|
144
|
+
let modelId;
|
|
102
145
|
let chars = 0;
|
|
103
146
|
return {
|
|
147
|
+
/** The model the stream's metadata said served, if it said. */
|
|
148
|
+
modelId: () => modelId,
|
|
104
149
|
/** Never throws: a malformed part is simply not counted. */
|
|
105
150
|
record(part) {
|
|
106
151
|
if (part?.type === 'finish')
|
|
107
152
|
usage = part.usage;
|
|
108
|
-
else if (part?.type === 'response-metadata'
|
|
109
|
-
|
|
153
|
+
else if (part?.type === 'response-metadata') {
|
|
154
|
+
if (typeof part.id === 'string')
|
|
155
|
+
responseId = part.id;
|
|
156
|
+
if (typeof part.modelId === 'string')
|
|
157
|
+
modelId = part.modelId;
|
|
158
|
+
}
|
|
110
159
|
else if (typeof part?.delta === 'string')
|
|
111
160
|
chars += part.delta.length;
|
|
112
161
|
},
|
|
113
|
-
/**
|
|
114
|
-
|
|
162
|
+
/**
|
|
163
|
+
* The usage the model stated; else an estimate; else nothing, for an attempt that produced
|
|
164
|
+
* nothing. A stream the caller stopped is estimated without counting as a metering failure -
|
|
165
|
+
* unless there was nothing to estimate, while the provider still counted its input.
|
|
166
|
+
*/
|
|
167
|
+
measurement(end) {
|
|
115
168
|
const measured = (0, usage_1.measurementOf)(usage, responseId);
|
|
116
169
|
if (measured)
|
|
117
170
|
return measured;
|
|
118
171
|
if (chars === 0 && responseId === undefined)
|
|
119
172
|
return undefined;
|
|
120
|
-
|
|
173
|
+
const gap = end === 'stopped' && chars > 0 ? undefined : STREAM_GAPS[end];
|
|
174
|
+
return (0, usage_1.estimated)(chars, gap, responseId);
|
|
121
175
|
},
|
|
122
176
|
};
|
|
123
177
|
}
|
|
@@ -134,27 +188,28 @@ function observeStream(result, signal, done) {
|
|
|
134
188
|
}
|
|
135
189
|
const tally = streamTally();
|
|
136
190
|
let reported = false;
|
|
137
|
-
const report = () => {
|
|
191
|
+
const report = (end) => {
|
|
138
192
|
if (reported)
|
|
139
193
|
return;
|
|
140
194
|
reported = true;
|
|
141
195
|
// Every step of a loop shares the signal, which may live as long as the process.
|
|
142
|
-
signal?.removeEventListener('abort',
|
|
143
|
-
done(tally.measurement());
|
|
196
|
+
signal?.removeEventListener('abort', onAbort);
|
|
197
|
+
done(tally.measurement(end), tally.modelId());
|
|
144
198
|
};
|
|
145
|
-
signal?.
|
|
199
|
+
const onAbort = () => report(isTimeout(signal?.reason) ? 'timedOut' : 'stopped');
|
|
200
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
146
201
|
if (signal?.aborted)
|
|
147
|
-
|
|
202
|
+
onAbort();
|
|
148
203
|
// A source of its own rather than a TransformStream, whose flush never runs on a cancel.
|
|
149
204
|
const reader = source.getReader();
|
|
150
205
|
result.stream = new ReadableStream({
|
|
151
206
|
async pull(controller) {
|
|
152
207
|
const next = await reader.read().catch((err) => {
|
|
153
|
-
report();
|
|
208
|
+
report('failed');
|
|
154
209
|
throw err;
|
|
155
210
|
});
|
|
156
211
|
if (next.done) {
|
|
157
|
-
report();
|
|
212
|
+
report('ended');
|
|
158
213
|
controller.close();
|
|
159
214
|
return;
|
|
160
215
|
}
|
|
@@ -162,7 +217,7 @@ function observeStream(result, signal, done) {
|
|
|
162
217
|
controller.enqueue(next.value);
|
|
163
218
|
},
|
|
164
219
|
async cancel(reason) {
|
|
165
|
-
report();
|
|
220
|
+
report(cancelEnd(reason));
|
|
166
221
|
await reader.cancel(reason);
|
|
167
222
|
},
|
|
168
223
|
});
|
|
@@ -25,5 +25,12 @@ export type ModelUsage = {
|
|
|
25
25
|
};
|
|
26
26
|
/** One call's usage, or undefined when the model stated no counter - not a call that cost nothing. */
|
|
27
27
|
export declare function measurementOf(usage: ModelUsage | undefined, responseId?: string): Measurement | undefined;
|
|
28
|
-
/**
|
|
29
|
-
export declare
|
|
28
|
+
/** What `incomplete` says when the model never stated its usage: a gap in metering, not a stop. */
|
|
29
|
+
export declare const NO_USAGE = "the model stated no usage, so its output was estimated";
|
|
30
|
+
/**
|
|
31
|
+
* A call with no usage stated: its output, estimated from the text. Its input is unknown.
|
|
32
|
+
* `incomplete` is set only for a metering gap - a call the caller stopped is not one.
|
|
33
|
+
*/
|
|
34
|
+
export declare function estimated(chars: number, incomplete?: string, responseId?: string): Measurement;
|
|
35
|
+
/** The characters a generated reply holds: its text, its reasoning and its tool calls' input. */
|
|
36
|
+
export declare function contentChars(content: unknown): number;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.VERCEL_AI_METRICS = void 0;
|
|
3
|
+
exports.NO_USAGE = exports.VERCEL_AI_METRICS = void 0;
|
|
4
4
|
exports.measurementOf = measurementOf;
|
|
5
5
|
exports.estimated = estimated;
|
|
6
|
+
exports.contentChars = contentChars;
|
|
6
7
|
exports.VERCEL_AI_METRICS = [
|
|
7
8
|
'input_tokens',
|
|
8
9
|
'output_tokens',
|
|
@@ -43,16 +44,35 @@ function measurementOf(usage, responseId) {
|
|
|
43
44
|
...(responseId ? { idempotencyKey: responseId } : {}),
|
|
44
45
|
};
|
|
45
46
|
}
|
|
46
|
-
/**
|
|
47
|
-
|
|
47
|
+
/** What `incomplete` says when the model never stated its usage: a gap in metering, not a stop. */
|
|
48
|
+
exports.NO_USAGE = 'the model stated no usage, so its output was estimated';
|
|
49
|
+
/**
|
|
50
|
+
* A call with no usage stated: its output, estimated from the text. Its input is unknown.
|
|
51
|
+
* `incomplete` is set only for a metering gap - a call the caller stopped is not one.
|
|
52
|
+
*/
|
|
53
|
+
function estimated(chars, incomplete, responseId) {
|
|
48
54
|
const output = chars > 0 ? Math.max(1, Math.ceil(chars / CHARS_PER_OUTPUT_TOKEN)) : 0;
|
|
49
55
|
return {
|
|
50
56
|
metrics: output ? { output_tokens: output, total_tokens: output } : {},
|
|
51
57
|
usage: { input: 0, output, cacheRead: 0, cacheCreate: 0 },
|
|
52
58
|
...(output ? { estimated: true } : {}),
|
|
53
|
-
incomplete
|
|
59
|
+
...(incomplete ? { incomplete } : {}),
|
|
60
|
+
// Keyed by the response, as a stated usage is: a replayed reply is still one report.
|
|
61
|
+
...(responseId ? { idempotencyKey: responseId } : {}),
|
|
54
62
|
};
|
|
55
63
|
}
|
|
64
|
+
/** The characters a generated reply holds: its text, its reasoning and its tool calls' input. */
|
|
65
|
+
function contentChars(content) {
|
|
66
|
+
if (!Array.isArray(content))
|
|
67
|
+
return 0;
|
|
68
|
+
let chars = 0;
|
|
69
|
+
for (const part of content) {
|
|
70
|
+
const text = part?.type === 'tool-call' ? part.input : part?.text;
|
|
71
|
+
if (typeof text === 'string')
|
|
72
|
+
chars += text.length;
|
|
73
|
+
}
|
|
74
|
+
return chars;
|
|
75
|
+
}
|
|
56
76
|
function numbersOnly(stated) {
|
|
57
77
|
const metrics = {};
|
|
58
78
|
for (const [name, value] of Object.entries(stated)) {
|
package/dist/config.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ 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
|
+
import type { AudrOptions } from './core/audr';
|
|
18
19
|
declare const dataSchema: z.ZodObject<{
|
|
19
20
|
apiKey: z.ZodString;
|
|
20
21
|
baseUrl: z.ZodOptional<z.ZodString>;
|
|
@@ -125,6 +126,14 @@ export type StiggAgentSDKOptions = {
|
|
|
125
126
|
* Replace it to route an `EngineError` and its {@link ErrorContext} into your own telemetry.
|
|
126
127
|
*/
|
|
127
128
|
onError?: (error: EngineError, context: ErrorContext) => void;
|
|
129
|
+
/**
|
|
130
|
+
* Also write an AUDR record (https://openaudr.dev) for every model call this SDK measures, to
|
|
131
|
+
* the application's own `@openaudr/audr` client. Off unless set.
|
|
132
|
+
*
|
|
133
|
+
* For systems other than Stigg - Chargebee, Lago, an OTel collector. Stigg already receives this
|
|
134
|
+
* usage directly, so do not point the client at a Stigg sink: every call would count twice.
|
|
135
|
+
*/
|
|
136
|
+
audr?: AudrOptions;
|
|
128
137
|
};
|
|
129
138
|
export type ResolvedConfig = Omit<Required<z.infer<typeof dataSchema>>, 'baseUrl'> & {
|
|
130
139
|
baseUrl?: string;
|
|
@@ -132,6 +141,7 @@ export type ResolvedConfig = Omit<Required<z.infer<typeof dataSchema>>, 'baseUrl
|
|
|
132
141
|
client?: NodeSdkClient;
|
|
133
142
|
logger: Logger;
|
|
134
143
|
onError: (error: EngineError, context: ErrorContext) => void;
|
|
144
|
+
audr?: AudrOptions;
|
|
135
145
|
};
|
|
136
146
|
export declare function resolveConfig(options?: StiggAgentSDKOptions): ResolvedConfig;
|
|
137
147
|
/** The slice of config the engine factory needs. */
|
package/dist/config.js
CHANGED
|
@@ -92,6 +92,7 @@ function resolveConfig(options = {}) {
|
|
|
92
92
|
...parsed,
|
|
93
93
|
cache: options.cache,
|
|
94
94
|
client: options.client,
|
|
95
|
+
audr: options.audr,
|
|
95
96
|
logger,
|
|
96
97
|
// Never silent, never fatal. Replace it to route into your own telemetry.
|
|
97
98
|
onError: options.onError ?? ((error, context) => logger.warn(`[stigg] ${context.operation} failed: ${error.message}`)),
|
|
@@ -112,6 +113,7 @@ function resolveConfig(options = {}) {
|
|
|
112
113
|
...config,
|
|
113
114
|
apiKey: config.apiKey ? '[redacted]' : undefined,
|
|
114
115
|
client: config.client ? '[client supplied by the caller]' : undefined,
|
|
116
|
+
audr: config.audr ? { environment: config.audr.environment, client: '[client supplied by the caller]' } : undefined,
|
|
115
117
|
});
|
|
116
118
|
for (const key of ['toJSON', Symbol.for('nodejs.util.inspect.custom')]) {
|
|
117
119
|
Object.defineProperty(config, key, { value: redacted, enumerable: false, writable: false });
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { Measurement } from '../types';
|
|
2
|
+
import type { ResolvedContext } from './context';
|
|
3
|
+
/** The spec version these records are written to. */
|
|
4
|
+
export declare const AUDR_SPEC_VERSION = "1.1.0";
|
|
5
|
+
/** How this SDK signs its records: the harness, under its own package name. */
|
|
6
|
+
export declare const AUDR_EMITTER: {
|
|
7
|
+
readonly component: "harness";
|
|
8
|
+
readonly name: "@stigg/agent-sdk-js";
|
|
9
|
+
readonly version: string;
|
|
10
|
+
};
|
|
11
|
+
/** AUDR's environments. A sink ignores a record that names none, so one is required here. */
|
|
12
|
+
export declare const AUDR_ENVIRONMENTS: readonly ["production", "staging", "development", "test", "evaluation"];
|
|
13
|
+
export type AudrEnvironment = (typeof AUDR_ENVIRONMENTS)[number];
|
|
14
|
+
/**
|
|
15
|
+
* One AUDR record, as this SDK writes it - a subset of the spec's, structurally compatible with
|
|
16
|
+
* `@openaudr/audr`'s `AudrRecord`, so the host's `Client` takes it as is.
|
|
17
|
+
*/
|
|
18
|
+
export type AudrRecord = {
|
|
19
|
+
spec_version: string;
|
|
20
|
+
record_id: string;
|
|
21
|
+
emitter: {
|
|
22
|
+
component: 'harness';
|
|
23
|
+
name: string;
|
|
24
|
+
version: string;
|
|
25
|
+
};
|
|
26
|
+
resource: {
|
|
27
|
+
type: 'model';
|
|
28
|
+
operation: 'generation';
|
|
29
|
+
modality: 'text';
|
|
30
|
+
provider: string;
|
|
31
|
+
name: string;
|
|
32
|
+
};
|
|
33
|
+
run: {
|
|
34
|
+
run_id: string;
|
|
35
|
+
span_id: string;
|
|
36
|
+
parent_span_id?: string;
|
|
37
|
+
name?: string;
|
|
38
|
+
};
|
|
39
|
+
attribution: {
|
|
40
|
+
environment: AudrEnvironment;
|
|
41
|
+
account_id: string;
|
|
42
|
+
feature_id?: string;
|
|
43
|
+
labels?: Record<string, string>;
|
|
44
|
+
};
|
|
45
|
+
usage: {
|
|
46
|
+
llm: {
|
|
47
|
+
input_tokens?: number;
|
|
48
|
+
output_tokens?: number;
|
|
49
|
+
cache_read_tokens?: number;
|
|
50
|
+
cache_write_tokens?: number;
|
|
51
|
+
requests: number;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
timing: {
|
|
55
|
+
event_time: string;
|
|
56
|
+
duration_ms?: number;
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
/** What this SDK needs of an `@openaudr/audr` `Client`: somewhere to hand a record. */
|
|
60
|
+
export type AudrClientLike = {
|
|
61
|
+
/** Validates and queues the record. The real one returns at once and never throws. */
|
|
62
|
+
record(record: AudrRecord): unknown;
|
|
63
|
+
};
|
|
64
|
+
export type AudrOptions = {
|
|
65
|
+
/**
|
|
66
|
+
* The host's own AUDR client - `new Client(sink)` from `@openaudr/audr`. Its sink decides where
|
|
67
|
+
* records go; this SDK never creates, flushes or shuts it down.
|
|
68
|
+
*/
|
|
69
|
+
client: AudrClientLike;
|
|
70
|
+
/** Which environment the records are for. Every record carries the customer, as `account_id`. */
|
|
71
|
+
environment: AudrEnvironment;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* What a binding knows about one call that the measurement does not: who served it, which model,
|
|
75
|
+
* where it sits in a run tree, and when it started. Never sent to Stigg.
|
|
76
|
+
*/
|
|
77
|
+
export type AudrTrace = {
|
|
78
|
+
/** The vendor that was called - the recipe's own name for it. */
|
|
79
|
+
provider?: string;
|
|
80
|
+
/** The model the vendor says served, falling back to the one the request named. */
|
|
81
|
+
model?: string;
|
|
82
|
+
/**
|
|
83
|
+
* The run this call belongs to, when a framework's run tree says. Otherwise the correlation id
|
|
84
|
+
* when the caller stated one, and the call alone when it did not.
|
|
85
|
+
*/
|
|
86
|
+
runId?: string;
|
|
87
|
+
/** This call's own span. Generated when the binding has none. */
|
|
88
|
+
spanId?: string;
|
|
89
|
+
/** The span this call ran under, when a framework's run tree says. */
|
|
90
|
+
parentSpanId?: string;
|
|
91
|
+
/** `Date.now()` when the call started, for `timing.duration_ms`. */
|
|
92
|
+
startedAt?: number;
|
|
93
|
+
};
|
|
94
|
+
/** Emits one record per measured model call. Built once per SDK, shared by every session. */
|
|
95
|
+
export type AudrEmitter = {
|
|
96
|
+
/** Returns what the client's `record()` did, so a client whose record() is async is still caught. */
|
|
97
|
+
emit(context: ResolvedContext, measurement: Measurement): unknown;
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Refused at construction rather than at the first call: a client that cannot take a record, or an
|
|
101
|
+
* environment a sink will ignore, would otherwise drop every record without a word.
|
|
102
|
+
*/
|
|
103
|
+
export declare function createAudrEmitter(options: AudrOptions, warn: (message: string) => void): AudrEmitter;
|
|
104
|
+
/** The record for one call, or nothing when there is no token usage to state. */
|
|
105
|
+
export declare function toRecord(context: ResolvedContext, measurement: Measurement, environment: AudrEnvironment, now?: number): AudrRecord | undefined;
|
|
106
|
+
/** A UUIDv7 (RFC 9562): 48 bits of Unix milliseconds, then random bits. AUDR's `record_id`. */
|
|
107
|
+
export declare function uuidv7(now?: number): string;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AUDR_ENVIRONMENTS = exports.AUDR_EMITTER = exports.AUDR_SPEC_VERSION = void 0;
|
|
4
|
+
exports.createAudrEmitter = createAudrEmitter;
|
|
5
|
+
exports.toRecord = toRecord;
|
|
6
|
+
exports.uuidv7 = uuidv7;
|
|
7
|
+
const sdk_identity_1 = require("../sdk-identity");
|
|
8
|
+
/** The spec version these records are written to. */
|
|
9
|
+
exports.AUDR_SPEC_VERSION = '1.1.0';
|
|
10
|
+
/** How this SDK signs its records: the harness, under its own package name. */
|
|
11
|
+
exports.AUDR_EMITTER = {
|
|
12
|
+
component: 'harness',
|
|
13
|
+
name: '@stigg/agent-sdk-js',
|
|
14
|
+
version: sdk_identity_1.AGENT_SDK_VERSION,
|
|
15
|
+
};
|
|
16
|
+
/** AUDR's environments. A sink ignores a record that names none, so one is required here. */
|
|
17
|
+
exports.AUDR_ENVIRONMENTS = [
|
|
18
|
+
'production',
|
|
19
|
+
'staging',
|
|
20
|
+
'development',
|
|
21
|
+
'test',
|
|
22
|
+
'evaluation',
|
|
23
|
+
];
|
|
24
|
+
/**
|
|
25
|
+
* Refused at construction rather than at the first call: a client that cannot take a record, or an
|
|
26
|
+
* environment a sink will ignore, would otherwise drop every record without a word.
|
|
27
|
+
*/
|
|
28
|
+
function createAudrEmitter(options, warn) {
|
|
29
|
+
if (typeof options?.client?.record !== 'function') {
|
|
30
|
+
throw new Error("`audr.client` has no record() method. Pass the application's own AUDR client - " +
|
|
31
|
+
'`new Client(sink)` from `@openaudr/audr`.');
|
|
32
|
+
}
|
|
33
|
+
if (!exports.AUDR_ENVIRONMENTS.includes(options.environment)) {
|
|
34
|
+
throw new Error(`\`audr.environment\` is '${String(options.environment)}' - it must be one of ` +
|
|
35
|
+
`${exports.AUDR_ENVIRONMENTS.join(', ')}. A sink ignores a record that names none.`);
|
|
36
|
+
}
|
|
37
|
+
const { client, environment } = options;
|
|
38
|
+
let warnedShort = false;
|
|
39
|
+
return {
|
|
40
|
+
emit(context, measurement) {
|
|
41
|
+
// An estimate is this SDK's number, not the vendor's, and an incomplete measurement fills
|
|
42
|
+
// what the vendor left out with zeros - AUDR has no way to say either. A downstream system
|
|
43
|
+
// counts what a record states, so it must be only what the provider stated.
|
|
44
|
+
if (measurement.estimated || measurement.incomplete) {
|
|
45
|
+
if (!warnedShort) {
|
|
46
|
+
warnedShort = true;
|
|
47
|
+
warn('[stigg] an estimated or incomplete measurement was not emitted as an AUDR record: AUDR carries only ' +
|
|
48
|
+
'stated usage.');
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
const record = toRecord(context, measurement, environment);
|
|
53
|
+
return record ? client.record(record) : undefined;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** The record for one call, or nothing when there is no token usage to state. */
|
|
58
|
+
function toRecord(context, measurement, environment, now = Date.now()) {
|
|
59
|
+
const llm = llmUsage(measurement.usage);
|
|
60
|
+
if (!llm)
|
|
61
|
+
return undefined;
|
|
62
|
+
const trace = context.audr ?? {};
|
|
63
|
+
const labels = labelsOf({ ...context.dimensions, ...measurement.dimensions });
|
|
64
|
+
const spanId = trace.spanId || crypto.randomUUID();
|
|
65
|
+
const record = {
|
|
66
|
+
spec_version: exports.AUDR_SPEC_VERSION,
|
|
67
|
+
record_id: uuidv7(now),
|
|
68
|
+
emitter: { ...exports.AUDR_EMITTER },
|
|
69
|
+
resource: {
|
|
70
|
+
type: 'model',
|
|
71
|
+
operation: 'generation',
|
|
72
|
+
// Required for a model. `text` is the adapters' own default: no recipe here reads a call's
|
|
73
|
+
// modality, and a vision or audio call is still metered by the tokens it states.
|
|
74
|
+
modality: 'text',
|
|
75
|
+
provider: providerSlug(trace.provider),
|
|
76
|
+
name: trace.model || 'unknown',
|
|
77
|
+
},
|
|
78
|
+
run: {
|
|
79
|
+
// A generated correlation id is the wrap's, not a run's: one client wrapped at module scope
|
|
80
|
+
// would make every call it ever makes, for every customer, a single run.
|
|
81
|
+
run_id: runIdOf(trace.runId ?? (context.correlationStated ? context.correlationId : spanId)),
|
|
82
|
+
span_id: spanId,
|
|
83
|
+
...(trace.parentSpanId ? { parent_span_id: trace.parentSpanId } : {}),
|
|
84
|
+
...(context.agent ? { name: context.agent } : {}),
|
|
85
|
+
},
|
|
86
|
+
attribution: {
|
|
87
|
+
environment,
|
|
88
|
+
account_id: context.customerId,
|
|
89
|
+
...(context.checkedId ? { feature_id: context.checkedId } : {}),
|
|
90
|
+
...(labels ? { labels } : {}),
|
|
91
|
+
},
|
|
92
|
+
usage: { llm },
|
|
93
|
+
timing: {
|
|
94
|
+
event_time: new Date(now).toISOString(),
|
|
95
|
+
...(trace.startedAt !== undefined && now >= trace.startedAt ? { duration_ms: now - trace.startedAt } : {}),
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
return record;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The four counters, already in AUDR's terms: `TokenUsage` is disjoint, so `input` excludes cache
|
|
102
|
+
* reads and writes as AUDR's `input_tokens` does. Reasoning stays inside `output` - AUDR subtracts
|
|
103
|
+
* it only when it is reported apart, and this SDK does not report it apart.
|
|
104
|
+
*/
|
|
105
|
+
function llmUsage(usage) {
|
|
106
|
+
if (!usage)
|
|
107
|
+
return undefined;
|
|
108
|
+
const counters = {
|
|
109
|
+
input_tokens: count(usage.input),
|
|
110
|
+
output_tokens: count(usage.output),
|
|
111
|
+
cache_read_tokens: count(usage.cacheRead),
|
|
112
|
+
cache_write_tokens: count(usage.cacheCreate),
|
|
113
|
+
};
|
|
114
|
+
const stated = Object.fromEntries(Object.entries(counters).filter(([, value]) => value !== undefined));
|
|
115
|
+
return { ...stated, requests: 1 };
|
|
116
|
+
}
|
|
117
|
+
/** A counter AUDR can carry: a whole, non-negative number. Anything else is left unstated. */
|
|
118
|
+
function count(value) {
|
|
119
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined;
|
|
120
|
+
}
|
|
121
|
+
/** AUDR's provider slug: lowercase, each run of other characters a `-`. */
|
|
122
|
+
function providerSlug(name) {
|
|
123
|
+
const slug = (name ?? '')
|
|
124
|
+
.toLowerCase()
|
|
125
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
126
|
+
.replace(/^-+|-+$/g, '');
|
|
127
|
+
return slug || 'unknown';
|
|
128
|
+
}
|
|
129
|
+
/** AUDR bounds a run id to 8-64 characters; one outside that is padded or cut rather than lost. */
|
|
130
|
+
function runIdOf(id) {
|
|
131
|
+
if (id.length > 64)
|
|
132
|
+
return id.slice(0, 64);
|
|
133
|
+
return id.length < 8 ? id.padEnd(8, '_') : id;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The call's dimensions, as labels: AUDR's metadata that is not usage, which is what they are to
|
|
137
|
+
* anyone but Stigg. Keys and values AUDR would reject are dropped, not the record.
|
|
138
|
+
*/
|
|
139
|
+
function labelsOf(dimensions) {
|
|
140
|
+
const labels = Object.entries(dimensions).filter(([key, value]) => /^[a-zA-Z0-9_.-]{1,64}$/.test(key) && typeof value === 'string' && value.length <= 256);
|
|
141
|
+
return labels.length ? Object.fromEntries(labels) : undefined;
|
|
142
|
+
}
|
|
143
|
+
/** A UUIDv7 (RFC 9562): 48 bits of Unix milliseconds, then random bits. AUDR's `record_id`. */
|
|
144
|
+
function uuidv7(now = Date.now()) {
|
|
145
|
+
// The global Web Crypto, not `node:crypto`: that import belongs to one file (see
|
|
146
|
+
// test/module-boundaries.test.ts), and Node 22 has this one built in.
|
|
147
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
148
|
+
for (let i = 0; i < 6; i++)
|
|
149
|
+
bytes[i] = Math.floor(now / 2 ** (8 * (5 - i))) & 0xff;
|
|
150
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x70;
|
|
151
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
152
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
153
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
154
|
+
}
|
package/dist/core/context.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CheckErrorAction, Entitlement, IngestMode, Metrics, MetricsMapper } from '../types';
|
|
2
2
|
import type { EventIngest } from '../engine/engine';
|
|
3
|
+
import type { AudrTrace } from './audr';
|
|
3
4
|
/**
|
|
4
5
|
* What a call is governed by. Every field is optional here because each one may have been given
|
|
5
6
|
* at `wrap` instead - `customerId` is the only one that has to come from somewhere, and the
|
|
@@ -111,6 +112,12 @@ export type ResolvedContext = {
|
|
|
111
112
|
usageMapper: MetricsMapper;
|
|
112
113
|
idempotencyKey?: string;
|
|
113
114
|
correlationId: string;
|
|
115
|
+
/**
|
|
116
|
+
* Whether a context stated `correlationId`, rather than taking the one its wrap generated. Only
|
|
117
|
+
* a stated id says which calls belong to one run; a generated one spans every call - and every
|
|
118
|
+
* customer - through the client.
|
|
119
|
+
*/
|
|
120
|
+
correlationStated?: true;
|
|
114
121
|
/** The entitlement's own id, resolved once - undefined when this context gates on nothing. */
|
|
115
122
|
checkedId?: string;
|
|
116
123
|
/**
|
|
@@ -130,6 +137,11 @@ export type ResolvedContext = {
|
|
|
130
137
|
agent?: string;
|
|
131
138
|
/** The paid tool this step ran, when a middleware checked one - a label, like `agent`. */
|
|
132
139
|
tool?: string;
|
|
140
|
+
/**
|
|
141
|
+
* What the binding knows about the call for its AUDR record - provider, model, span. Set per
|
|
142
|
+
* call, read only when AUDR emission is on, and never sent to Stigg.
|
|
143
|
+
*/
|
|
144
|
+
audr?: AudrTrace;
|
|
133
145
|
};
|
|
134
146
|
/**
|
|
135
147
|
* What nobody told the SDK, and it will not guess.
|
package/dist/core/context.js
CHANGED
|
@@ -128,6 +128,7 @@ function resolveContext(context, correlationId,
|
|
|
128
128
|
usageMapper: context.usageMapper ?? types_1.vendorMetrics,
|
|
129
129
|
idempotencyKey: context.idempotencyKey,
|
|
130
130
|
correlationId: context.correlationId?.trim() || correlationId,
|
|
131
|
+
...(context.correlationId?.trim() ? { correlationStated: true } : {}),
|
|
131
132
|
checkedId: context.entitlement ? (0, types_1.entitlementId)(context.entitlement) : undefined,
|
|
132
133
|
attributionKey: context.attributionKey,
|
|
133
134
|
};
|
|
@@ -281,6 +282,7 @@ function overrideContext(base, override) {
|
|
|
281
282
|
usageMapper: override.usageMapper ?? base.usageMapper,
|
|
282
283
|
idempotencyKey: override.idempotencyKey ?? base.idempotencyKey,
|
|
283
284
|
correlationId: override.correlationId?.trim() || base.correlationId,
|
|
285
|
+
...(override.correlationId?.trim() || base.correlationStated ? { correlationStated: true } : {}),
|
|
284
286
|
attributionKey: override.attributionKey ?? base.attributionKey,
|
|
285
287
|
};
|
|
286
288
|
}
|
package/dist/core/session.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { EngineError } from '../errors';
|
|
2
2
|
import { type GovernanceHeaders } from '../headers';
|
|
3
|
+
import type { AudrEmitter } from './audr';
|
|
3
4
|
import { type ResolvedContext } from './context';
|
|
4
5
|
import type { GovernanceEngine } from '../engine/engine';
|
|
5
6
|
import type { ErrorContext } from '../config';
|
|
@@ -66,6 +67,8 @@ export type SessionDeps = {
|
|
|
66
67
|
* where an observability tool's trace is already open around the graph; off for `wrap`.
|
|
67
68
|
*/
|
|
68
69
|
tracing?: boolean;
|
|
70
|
+
/** Writes an AUDR record for each measured call. Absent unless `audr` was passed at construction. */
|
|
71
|
+
audr?: AudrEmitter;
|
|
69
72
|
};
|
|
70
73
|
/** What a client did, for whoever wants to print it. Spans every context it served. */
|
|
71
74
|
export type SessionSummary = {
|
package/dist/core/session.js
CHANGED
|
@@ -168,6 +168,13 @@ class GovernanceSession {
|
|
|
168
168
|
// they both set, because it saw the call.
|
|
169
169
|
const dimensions = measured.dimensions ? { ...context.dimensions, ...measured.dimensions } : context.dimensions;
|
|
170
170
|
const idempotencyKey = measured.idempotencyKey ?? options.idempotencyKey ?? context.idempotencyKey ?? this.nextKey(context);
|
|
171
|
+
// Before the report, and whatever becomes of it: the call happened either way, and the record
|
|
172
|
+
// is a separate receipt for someone else. Here because this is the last point that still has
|
|
173
|
+
// the token counters - the mapper below turns them into Stigg's metrics.
|
|
174
|
+
const { audr } = this.deps;
|
|
175
|
+
if (audr) {
|
|
176
|
+
(0, hooks_1.safely)(() => audr.emit(context, measured), (err) => this.reportError(asEngineError('audr', err), 'audr', context, idempotencyKey));
|
|
177
|
+
}
|
|
171
178
|
try {
|
|
172
179
|
return this.send(context, context.usageMapper(measured), {
|
|
173
180
|
dimensions,
|
package/dist/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export type { StiggContext } from './core/context';
|
|
|
15
15
|
export type { SessionSummary, HookContext, IngestOptions, SessionHooks } from './core/session';
|
|
16
16
|
export { RAW_PREFIX, flattenRaw } from './core/dimensions';
|
|
17
17
|
export type { CacheOptions, ErrorContext, NodeSdkClient, StiggAgentSDKOptions } from './config';
|
|
18
|
+
export type { AudrClientLike, AudrEnvironment, AudrOptions, AudrRecord, AudrTrace } from './core/audr';
|
|
18
19
|
export { describeDecision, remedyMessage } from './decision-text';
|
|
19
20
|
export { GOVERNANCE_HEADERS, parseGovernanceHeaders } from './headers';
|
|
20
21
|
export type { GovernanceHeaders, ParsedGovernanceHeaders } from './headers';
|
|
@@ -77,7 +77,7 @@ export declare const DEFAULT_RECIPES: ({
|
|
|
77
77
|
} | undefined;
|
|
78
78
|
attachStream: (stream: any, ingest: (measurement: import("./types").Measurement) => void) => void;
|
|
79
79
|
}[];
|
|
80
|
-
} | import("./bindings/client/core/types").ClientRecipe<"
|
|
80
|
+
} | import("./bindings/client/core/types").ClientRecipe<"cost" | "input_tokens" | "output_tokens" | "cached_tokens" | "prompt_tokens" | "completion_tokens" | "total_tokens"> | import("./bindings/client/core/types").ClientRecipe<"input_tokens" | "output_tokens" | "prompt_tokens" | "completion_tokens" | "cache_read_input_tokens" | "cache_creation_input_tokens" | "total_tokens" | "reasoning_tokens"> | import("./bindings/client/core/types").ClientRecipe<"input_tokens" | "output_tokens" | "cached_tokens" | "prompt_tokens" | "completion_tokens" | "cache_read_input_tokens" | "cache_creation_input_tokens" | "total_tokens"> | {
|
|
81
81
|
vendor: string;
|
|
82
82
|
metrics: readonly ["prompt_tokens", "completion_tokens", "cached_tokens", "total_tokens"];
|
|
83
83
|
totalTokensMetric: "total_tokens";
|
|
@@ -146,6 +146,8 @@ export declare class StiggAgentSDK {
|
|
|
146
146
|
private readonly clients;
|
|
147
147
|
/** Every session this SDK handed out, so `flush()` can drain all of them at once. */
|
|
148
148
|
private readonly sessions;
|
|
149
|
+
/** One for the whole SDK, so every client and middleware writes to the same AUDR client. */
|
|
150
|
+
private readonly audr?;
|
|
149
151
|
/** The session the no-client paths share. Built on first use. */
|
|
150
152
|
private loose?;
|
|
151
153
|
/**
|
package/dist/stigg-agent-sdk.js
CHANGED
|
@@ -41,6 +41,7 @@ const token_usage_1 = require("./bindings/shared/token-usage");
|
|
|
41
41
|
const context_1 = require("./core/context");
|
|
42
42
|
const session_1 = require("./core/session");
|
|
43
43
|
const hooks_1 = require("./core/hooks");
|
|
44
|
+
const audr_1 = require("./core/audr");
|
|
44
45
|
const types_1 = require("./types");
|
|
45
46
|
/**
|
|
46
47
|
* Recipes are data - method paths and usage extractors - so wiring them here costs nothing at
|
|
@@ -186,6 +187,8 @@ class StiggAgentSDK {
|
|
|
186
187
|
clients;
|
|
187
188
|
/** Every session this SDK handed out, so `flush()` can drain all of them at once. */
|
|
188
189
|
sessions = new Set();
|
|
190
|
+
/** One for the whole SDK, so every client and middleware writes to the same AUDR client. */
|
|
191
|
+
audr;
|
|
189
192
|
/** The session the no-client paths share. Built on first use. */
|
|
190
193
|
loose;
|
|
191
194
|
/**
|
|
@@ -227,6 +230,8 @@ class StiggAgentSDK {
|
|
|
227
230
|
constructor(first, second) {
|
|
228
231
|
this.config = (0, config_1.resolveConfig)(toOptions(first, second));
|
|
229
232
|
this.clients = (0, registry_1.createClientRegistry)(exports.DEFAULT_RECIPES);
|
|
233
|
+
if (this.config.audr)
|
|
234
|
+
this.audr = (0, audr_1.createAudrEmitter)(this.config.audr, (message) => this.config.logger.warn(message));
|
|
230
235
|
}
|
|
231
236
|
/** Releases what this SDK's engine holds open. */
|
|
232
237
|
async close() {
|
|
@@ -529,6 +534,7 @@ class StiggAgentSDK {
|
|
|
529
534
|
logger: this.config.logger,
|
|
530
535
|
onError: this.config.onError,
|
|
531
536
|
hooks: hooks ?? {},
|
|
537
|
+
audr: this.audr,
|
|
532
538
|
});
|
|
533
539
|
this.sessions.add(session);
|
|
534
540
|
return session;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stigg/agent-sdk-js",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.5",
|
|
4
4
|
"description": "Stigg agent SDK (JS) - run-lifecycle governance for agentic stacks, with examples per layer",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"typings": "dist/index.d.ts",
|
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"@google/adk": ">=2.3.0 <3",
|
|
88
88
|
"@langchain/core": ">=1.0.0 <2",
|
|
89
|
+
"@openaudr/audr": ">=0.2.0 <0.3",
|
|
89
90
|
"@opentelemetry/api": "^1.9.0"
|
|
90
91
|
},
|
|
91
92
|
"peerDependenciesMeta": {
|
|
@@ -95,6 +96,9 @@
|
|
|
95
96
|
"@langchain/core": {
|
|
96
97
|
"optional": true
|
|
97
98
|
},
|
|
99
|
+
"@openaudr/audr": {
|
|
100
|
+
"optional": true
|
|
101
|
+
},
|
|
98
102
|
"@opentelemetry/api": {
|
|
99
103
|
"optional": true
|
|
100
104
|
}
|
|
@@ -114,6 +118,7 @@
|
|
|
114
118
|
"@langfuse/otel": "^6.0.0",
|
|
115
119
|
"@langfuse/tracing": "^6.0.0",
|
|
116
120
|
"@mistralai/mistralai": "^2.7.0",
|
|
121
|
+
"@openaudr/audr": "0.2.0",
|
|
117
122
|
"@openrouter/sdk": "^1.4.25",
|
|
118
123
|
"@opentelemetry/api": "^1.9.0",
|
|
119
124
|
"@opentelemetry/core": "^2.12.0",
|
|
@@ -123,6 +128,7 @@
|
|
|
123
128
|
"@opentelemetry/sdk-trace-node": "^2.0.1",
|
|
124
129
|
"@typesafe-ai/sdk": "^0.6.0",
|
|
125
130
|
"ai": "^7.0.153",
|
|
131
|
+
"ai-v6": "npm:ai@^6.0.320",
|
|
126
132
|
"cloudflare": "^7.3.0",
|
|
127
133
|
"dotenv": "^17.4.2",
|
|
128
134
|
"groq-sdk": "^1.6.0",
|