@stigg/agent-sdk-js 0.7.4 → 0.7.6

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 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) | | Your own harness, through `stigg.middleware()` |
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
- void session.ingestMeasurement(context, measurement);
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
- await this.middleware.startModel(runId, await this.attributionOf(step), 'adk.model');
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 context = resolved.eventName ? resolved : { ...resolved, eventName: { name: eventName } };
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.attributionOf(parentRunId, metadata), 'langchain.chat_model');
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.attributionOf(parentRunId, metadata), 'langchain.llm');
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 only has to be unique among this middleware's own steps in flight.
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 = String(++steps);
35
+ const runId = `${prefix}:${++steps}`;
34
36
  const context = contexts.get(params);
35
- await governance.startModel(runId, { stiggContexts: context ? [context] : [] }, event);
36
- return runId;
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();
@@ -66,11 +70,12 @@ function stiggVercelMiddleware({ stigg, stiggContext, hooks, }) {
66
70
  // usage stated: estimated from the reply, as a stream is, and counted as a metering failure.
67
71
  const responseId = result?.response?.id;
68
72
  const chars = (0, usage_1.contentChars)(result?.content);
73
+ served(trace, result?.response?.modelId);
69
74
  await governance.endModel(runId, (0, usage_1.measurementOf)(result?.usage, responseId) ?? (chars ? (0, usage_1.estimated)(chars, usage_1.NO_USAGE, responseId) : undefined));
70
75
  return result;
71
76
  },
72
- async wrapStream({ doStream, params }) {
73
- 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');
74
79
  let result;
75
80
  try {
76
81
  result = await doStream();
@@ -79,9 +84,10 @@ function stiggVercelMiddleware({ stigg, stiggContext, hooks, }) {
79
84
  governance.abandon(runId);
80
85
  throw err;
81
86
  }
82
- observeStream(result, params?.abortSignal, (measurement) => {
87
+ observeStream(result, params?.abortSignal, (measurement, modelId) => {
83
88
  if (!measurement)
84
89
  return governance.abandon(runId);
90
+ served(trace, modelId);
85
91
  const call = callOf(params);
86
92
  const pending = reporting.get(call) ?? new Set();
87
93
  reporting.set(call, pending);
@@ -97,6 +103,22 @@ function stiggVercelMiddleware({ stigg, stiggContext, hooks, }) {
97
103
  },
98
104
  };
99
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
+ }
100
122
  /** What `incomplete` says for a stream that ended without its usage, by how it ended. */
101
123
  const STREAM_GAPS = {
102
124
  ended: usage_1.NO_USAGE,
@@ -119,14 +141,21 @@ function cancelEnd(reason) {
119
141
  function streamTally() {
120
142
  let usage;
121
143
  let responseId;
144
+ let modelId;
122
145
  let chars = 0;
123
146
  return {
147
+ /** The model the stream's metadata said served, if it said. */
148
+ modelId: () => modelId,
124
149
  /** Never throws: a malformed part is simply not counted. */
125
150
  record(part) {
126
151
  if (part?.type === 'finish')
127
152
  usage = part.usage;
128
- else if (part?.type === 'response-metadata' && typeof part.id === 'string')
129
- responseId = part.id;
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
+ }
130
159
  else if (typeof part?.delta === 'string')
131
160
  chars += part.delta.length;
132
161
  },
@@ -165,7 +194,7 @@ function observeStream(result, signal, done) {
165
194
  reported = true;
166
195
  // Every step of a loop shares the signal, which may live as long as the process.
167
196
  signal?.removeEventListener('abort', onAbort);
168
- done(tally.measurement(end));
197
+ done(tally.measurement(end), tally.modelId());
169
198
  };
170
199
  const onAbort = () => report(isTimeout(signal?.reason) ? 'timedOut' : 'stopped');
171
200
  signal?.addEventListener('abort', onAbort, { once: true });
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
+ }
@@ -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.
@@ -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
  }
@@ -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 = {
@@ -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,
@@ -122,9 +122,14 @@ export type EntityPlacement = {
122
122
  /**
123
123
  * Set the budget only when the assignment does not exist yet. An existing one keeps its limit
124
124
  * and cadence, so a raise made in the dashboard survives the next run. Mutually exclusive with
125
- * `usageLimit` - it costs one extra read, which is why it is not the default.
125
+ * `usageLimit` - it costs one extra read, which is why a number is never seeded unless asked for.
126
+ *
127
+ * `null` seeds an unlimited assignment - the metering-only one, which puts the agent in the tree
128
+ * and tracks its usage without its own assignment refusing it. It is what a budget with no limit
129
+ * sends, so that case pays the read on every call. Unlike a numeric seed it still applies the
130
+ * caller's `cadence` to an existing assignment, as a budget with no limit always has.
126
131
  */
127
- usageLimitOnCreate?: number;
132
+ usageLimitOnCreate?: number | null;
128
133
  cadence?: string;
129
134
  /** The entities the budget is scoped to - its own assignment beside the agent's whole one. */
130
135
  scopeEntityIds?: string[];
@@ -16,6 +16,8 @@ const errors_1 = require("../errors");
16
16
  const types_1 = require("../types");
17
17
  const sdk_identity_1 = require("../sdk-identity");
18
18
  const report_1 = require("./report");
19
+ /** The window an unlimited assignment is created with when the caller named none. */
20
+ const TRACKING_CADENCE = 'P1M';
19
21
  /** The governance check endpoint is `/api/v1-beta/` and flagged experimental. */
20
22
  class RestGovernanceEngine {
21
23
  /**
@@ -116,11 +118,16 @@ class RestGovernanceEngine {
116
118
  const scope = scopeOf(placement.scopeEntityIds);
117
119
  // Every page: the one with this scope can sit past the first, and missing it would overwrite
118
120
  // an admin's limit with the seed.
121
+ const tracking = placement.usageLimitOnCreate === null;
119
122
  for await (const row of this.client.v1Beta.customers.assignments.list(customerId, { entityId, ...capability })) {
120
- // Already assigned: leave the limit and its window exactly as they are.
123
+ // Already assigned: leave the limit and its window exactly as they are - except that a
124
+ // metering-only call still moves the window it named, as a budget with no limit always did.
121
125
  if (scopeOf(row.scopeEntityIds) === scope)
122
- return {};
126
+ return tracking ? cadence : {};
123
127
  }
128
+ // Governance requires a window on create, and a metering-only caller has no reason to pick one.
129
+ if (tracking)
130
+ return { usageLimit: null, cadence: placement.cadence ?? TRACKING_CADENCE };
124
131
  return { usageLimit: placement.usageLimitOnCreate, ...cadence };
125
132
  }
126
133
  async check(customerId, { entitlement, requestedUsage, 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<"cached_tokens" | "prompt_tokens" | "completion_tokens" | "input_tokens" | "output_tokens" | "total_tokens" | "cost"> | import("./bindings/client/core/types").ClientRecipe<"prompt_tokens" | "completion_tokens" | "input_tokens" | "output_tokens" | "cache_read_input_tokens" | "cache_creation_input_tokens" | "total_tokens" | "reasoning_tokens"> | import("./bindings/client/core/types").ClientRecipe<"cached_tokens" | "prompt_tokens" | "completion_tokens" | "input_tokens" | "output_tokens" | "cache_read_input_tokens" | "cache_creation_input_tokens" | "total_tokens"> | {
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
  /**
@@ -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
@@ -151,7 +152,8 @@ function toPlacement(input) {
151
152
  if (onCreate && typeof input.budget.limit !== 'number') {
152
153
  throw new Error(`Agent ${input.agentId} asked to seed a budget with no limit to seed. ` +
153
154
  "`budget.apply: 'on-create'` writes a cap the first time and never again, so there is " +
154
- 'nothing to write without `budget.limit`.');
155
+ 'nothing to write without `budget.limit`. To seed an unlimited, metering-only budget, ' +
156
+ 'leave out both `limit` and `apply`.');
155
157
  }
156
158
  // Each entry names an entity, and a blank one names none: the budget would be scoped to an
157
159
  // entity no request resolves, and apply to nothing.
@@ -159,12 +161,15 @@ function toPlacement(input) {
159
161
  throw new Error(`Agent ${input.agentId} has a budget.scope that is not a list of entity ids: ${JSON.stringify(scope)}. ` +
160
162
  'Leave it out, or pass [], for the whole budget.');
161
163
  }
164
+ // No limit at all is metering only: an agent with no assignment is in nobody's tree and its usage
165
+ // is counted against nothing, so it gets an unlimited one - seeded, so a cap set later survives.
166
+ const tracking = !onCreate && input.budget.limit === undefined;
162
167
  return {
163
168
  parentId,
164
169
  entitlement,
165
170
  cadence,
166
171
  usageLimit: onCreate ? undefined : input.budget.limit,
167
- usageLimitOnCreate: onCreate ? (input.budget.limit ?? undefined) : undefined,
172
+ usageLimitOnCreate: tracking ? null : onCreate ? (input.budget.limit ?? undefined) : undefined,
168
173
  scopeEntityIds: scope?.length ? scope : undefined,
169
174
  };
170
175
  }
@@ -186,6 +191,8 @@ class StiggAgentSDK {
186
191
  clients;
187
192
  /** Every session this SDK handed out, so `flush()` can drain all of them at once. */
188
193
  sessions = new Set();
194
+ /** One for the whole SDK, so every client and middleware writes to the same AUDR client. */
195
+ audr;
189
196
  /** The session the no-client paths share. Built on first use. */
190
197
  loose;
191
198
  /**
@@ -227,6 +234,8 @@ class StiggAgentSDK {
227
234
  constructor(first, second) {
228
235
  this.config = (0, config_1.resolveConfig)(toOptions(first, second));
229
236
  this.clients = (0, registry_1.createClientRegistry)(exports.DEFAULT_RECIPES);
237
+ if (this.config.audr)
238
+ this.audr = (0, audr_1.createAudrEmitter)(this.config.audr, (message) => this.config.logger.warn(message));
230
239
  }
231
240
  /** Releases what this SDK's engine holds open. */
232
241
  async close() {
@@ -529,6 +538,7 @@ class StiggAgentSDK {
529
538
  logger: this.config.logger,
530
539
  onError: this.config.onError,
531
540
  hooks: hooks ?? {},
541
+ audr: this.audr,
532
542
  });
533
543
  this.sessions.add(session);
534
544
  return session;
package/dist/types.d.ts CHANGED
@@ -509,10 +509,21 @@ export type AgentBudget = {
509
509
  * A scoped budget governs only the requests it names: one naming no model, or a model with no
510
510
  * budget, is limited by the agent's whole budget and its ancestors' alone - with neither, by
511
511
  * nothing. Register the whole budget too.
512
+ *
513
+ * A scope with no `limit` meters that scope only: it is created unlimited, like a whole budget
514
+ * with no limit, so it shows that scope's usage and refuses nothing.
512
515
  */
513
516
  scope?: string[];
514
517
  } & ({
515
- /** The limit, written on every call. `null` clears whatever is assigned. */
518
+ /**
519
+ * The limit, written on every call. `null` clears whatever is assigned.
520
+ *
521
+ * Omitted, the agent is metering only: the first call creates an unlimited assignment, so its
522
+ * usage is tracked in the governance tree and its own assignment never refuses it - its
523
+ * ancestors' budgets and the customer's entitlement still can. Later calls leave the limit
524
+ * assigned by then - a cap set in the dashboard included - untouched, though a `cadence` they
525
+ * pass is still applied. `cadence` defaults to `'P1M'` on that create.
526
+ */
516
527
  limit?: number | null;
517
528
  /** Writes the limit every time. The default. */
518
529
  apply?: 'always';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stigg/agent-sdk-js",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
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",