@stigg/agent-sdk-js 0.7.3 → 0.7.4

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.
@@ -62,9 +62,11 @@ function stiggVercelMiddleware({ stigg, stiggContext, hooks, }) {
62
62
  governance.abandon(runId);
63
63
  throw err;
64
64
  }
65
- // Awaited, so the agent's next step is checked against this one's spend. Never throws; no
66
- // usage at all is reported as a metering failure.
67
- await governance.endModel(runId, (0, usage_1.measurementOf)(result?.usage, result?.response?.id));
65
+ // Awaited, so the agent's next step is checked against this one's spend. Never throws. No
66
+ // usage stated: estimated from the reply, as a stream is, and counted as a metering failure.
67
+ const responseId = result?.response?.id;
68
+ const chars = (0, usage_1.contentChars)(result?.content);
69
+ await governance.endModel(runId, (0, usage_1.measurementOf)(result?.usage, responseId) ?? (chars ? (0, usage_1.estimated)(chars, usage_1.NO_USAGE, responseId) : undefined));
68
70
  return result;
69
71
  },
70
72
  async wrapStream({ doStream, params }) {
@@ -95,6 +97,24 @@ function stiggVercelMiddleware({ stigg, stiggContext, hooks, }) {
95
97
  },
96
98
  };
97
99
  }
100
+ /** What `incomplete` says for a stream that ended without its usage, by how it ended. */
101
+ const STREAM_GAPS = {
102
+ ended: usage_1.NO_USAGE,
103
+ failed: 'the stream failed before the model stated its usage, so its output was estimated',
104
+ timedOut: 'the call timed out before the model stated its usage, so its output was estimated',
105
+ stopped: 'the call was stopped before any output, so its input was not measured',
106
+ };
107
+ /** A timeout's abort is not the caller stopping. */
108
+ const isTimeout = (reason) => reason?.name === 'TimeoutError';
109
+ /**
110
+ * Why a reader cancelled: the AI SDK cancels an attempt it retries with the provider's error, which
111
+ * is a failure; any other reason is the caller stopping.
112
+ */
113
+ function cancelEnd(reason) {
114
+ if (isTimeout(reason))
115
+ return 'timedOut';
116
+ return reason instanceof Error && reason.name !== 'AbortError' ? 'failed' : 'stopped';
117
+ }
98
118
  /** What a stream has told us so far. */
99
119
  function streamTally() {
100
120
  let usage;
@@ -110,14 +130,19 @@ function streamTally() {
110
130
  else if (typeof part?.delta === 'string')
111
131
  chars += part.delta.length;
112
132
  },
113
- /** The usage the model stated; else an estimate; else nothing, for an attempt that produced nothing. */
114
- measurement() {
133
+ /**
134
+ * The usage the model stated; else an estimate; else nothing, for an attempt that produced
135
+ * nothing. A stream the caller stopped is estimated without counting as a metering failure -
136
+ * unless there was nothing to estimate, while the provider still counted its input.
137
+ */
138
+ measurement(end) {
115
139
  const measured = (0, usage_1.measurementOf)(usage, responseId);
116
140
  if (measured)
117
141
  return measured;
118
142
  if (chars === 0 && responseId === undefined)
119
143
  return undefined;
120
- return (0, usage_1.estimated)(chars);
144
+ const gap = end === 'stopped' && chars > 0 ? undefined : STREAM_GAPS[end];
145
+ return (0, usage_1.estimated)(chars, gap, responseId);
121
146
  },
122
147
  };
123
148
  }
@@ -134,27 +159,28 @@ function observeStream(result, signal, done) {
134
159
  }
135
160
  const tally = streamTally();
136
161
  let reported = false;
137
- const report = () => {
162
+ const report = (end) => {
138
163
  if (reported)
139
164
  return;
140
165
  reported = true;
141
166
  // Every step of a loop shares the signal, which may live as long as the process.
142
- signal?.removeEventListener('abort', report);
143
- done(tally.measurement());
167
+ signal?.removeEventListener('abort', onAbort);
168
+ done(tally.measurement(end));
144
169
  };
145
- signal?.addEventListener('abort', report, { once: true });
170
+ const onAbort = () => report(isTimeout(signal?.reason) ? 'timedOut' : 'stopped');
171
+ signal?.addEventListener('abort', onAbort, { once: true });
146
172
  if (signal?.aborted)
147
- report();
173
+ onAbort();
148
174
  // A source of its own rather than a TransformStream, whose flush never runs on a cancel.
149
175
  const reader = source.getReader();
150
176
  result.stream = new ReadableStream({
151
177
  async pull(controller) {
152
178
  const next = await reader.read().catch((err) => {
153
- report();
179
+ report('failed');
154
180
  throw err;
155
181
  });
156
182
  if (next.done) {
157
- report();
183
+ report('ended');
158
184
  controller.close();
159
185
  return;
160
186
  }
@@ -162,7 +188,7 @@ function observeStream(result, signal, done) {
162
188
  controller.enqueue(next.value);
163
189
  },
164
190
  async cancel(reason) {
165
- report();
191
+ report(cancelEnd(reason));
166
192
  await reader.cancel(reason);
167
193
  },
168
194
  });
@@ -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
- /** A stream stopped before stating usage: its output, estimated from the text. Its input is unknown. */
29
- export declare function estimated(chars: number): Measurement;
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
- /** A stream stopped before stating usage: its output, estimated from the text. Its input is unknown. */
47
- function estimated(chars) {
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: 'the stream ended before the model stated its usage',
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stigg/agent-sdk-js",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
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",
@@ -123,6 +123,7 @@
123
123
  "@opentelemetry/sdk-trace-node": "^2.0.1",
124
124
  "@typesafe-ai/sdk": "^0.6.0",
125
125
  "ai": "^7.0.153",
126
+ "ai-v6": "npm:ai@^6.0.320",
126
127
  "cloudflare": "^7.3.0",
127
128
  "dotenv": "^17.4.2",
128
129
  "groq-sdk": "^1.6.0",