@stackfactor/agent-utils 1.4.3 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/esm/index.js CHANGED
@@ -2,14 +2,16 @@ import constants from "./const.js";
2
2
  import errorHandling, { AppError } from "./errorHandling.js";
3
3
  import langChain from "./langChain.js";
4
4
  import logger from "./logger.js";
5
- import { serve } from "./serve.js";
5
+ import { serve, runOnce, isRunOnceExecution, EXECUTION_ENV } from "./serve.js";
6
6
  import { callAgent, checkAgent } from "./client.js";
7
7
  import * as runtimeContext from "./runtimeContext.js";
8
+ import { recordUsage, USAGE_UNITS } from "./usage.js";
8
9
  export { constants };
9
10
  export { errorHandling, AppError };
10
11
  export { langChain };
11
12
  export { logger };
12
- export { serve };
13
+ export { serve, runOnce, isRunOnceExecution, EXECUTION_ENV };
13
14
  export { callAgent };
14
15
  export { checkAgent };
15
16
  export { runtimeContext };
17
+ export { recordUsage, USAGE_UNITS };
@@ -1,8 +1,20 @@
1
+ type TokenUsage = {
2
+ input_tokens: number;
3
+ output_tokens: number;
4
+ total_tokens: number;
5
+ };
1
6
  /**
2
- * Accumulated usage and cost for a single agent run. Callers initialize an
7
+ * What a run used, by model, for the agent's own use: callers initialize an
3
8
  * empty object (`{}`) and pass it through `runPromptWithModel`; helpers
4
- * populate `cost` (USD total across all models) and per-model
5
- * `<model>_inputTokens` / `<model>_outputTokens` counters under `tokens`.
9
+ * populate per-model counters under `tokens` (`<model>_inputTokens`,
10
+ * `<model>_outputTokens`, ...).
11
+ *
12
+ * It is not how the platform learns what was used, and not what that costs:
13
+ * every call made through this module is counted into the request's usage
14
+ * ledger, with or without a tracker, and reported to the platform, which
15
+ * prices it (see `usage.ts`). `cost` stays 0 for that reason. A handler
16
+ * answers its data alone: a tracker it returned would reach the caller as
17
+ * part of the content.
6
18
  */
7
19
  export type UsageTracker = {
8
20
  cost: number;
@@ -43,6 +55,11 @@ export type UsageTracker = {
43
55
  */
44
56
  export declare const getLLMModel: (modelName: string, config: any, schema?: any) => any;
45
57
  declare const _default: {
58
+ canRetryValidation: (expectsJsonResponse: boolean, schema: any) => boolean;
59
+ isRetryableJsonFailure: (error: any) => boolean;
60
+ buildAgenticRetryPrompt: (userPrompt: string, rawContent: any, error: any) => string;
61
+ parseAgenticJsonResponse: (rawContent: any, schema: any) => string;
62
+ supportsNativeSchema: (modelName: string) => boolean;
46
63
  checkIfAIProviderConfigured: (config: any) => void;
47
64
  createAgent: (name: string, modelName: string, systemPrompt: string, tools: any[], responseFormat: any, config: any, usageTracker?: UsageTracker | null) => any;
48
65
  runAgent: (agent: any, prompt: string, config: any, onProgress?: Function | null, usageTracker?: UsageTracker | null) => Promise<any>;
@@ -50,7 +67,10 @@ declare const _default: {
50
67
  runPromptWithModelForImageGeneration: (modelName: string, config: any, prompt: string, options?: any, usageTracker?: UsageTracker | null) => Promise<any>;
51
68
  runPromptWithModelForVideoGeneration: (modelName: string, config: any, prompt: string, options?: any, usageTracker?: UsageTracker | null) => Promise<any>;
52
69
  throwErrorIfNotSuccessful: (response: any) => string;
53
- updateUsageTrackerForCharacters: (tracker: UsageTracker | null | undefined, modelName: string, characterCount: number, config: any) => void;
70
+ updateImageUsageTracker: (tracker: UsageTracker | null | undefined, modelName: string, response: any, _config: any, provider: "openai" | "google") => void;
71
+ updateUsageTracker: (tracker: UsageTracker | null | undefined, modelName: string, usage: TokenUsage | null, _config?: any) => void;
72
+ updateUsageTrackerForCharacters: (tracker: UsageTracker | null | undefined, modelName: string, characterCount: number, _config?: any) => void;
73
+ updateVideoUsageTracker: (tracker: UsageTracker | null | undefined, modelName: string, seconds: number, _config?: any) => void;
54
74
  validateModel: (selectedModel: string, supportedModels: string[]) => string;
55
75
  };
56
76
  export default _default;
@@ -1 +1 @@
1
- {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":"AA6GA;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE;QAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IACvC;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACtD,CAAC;AA0lBF;;;;;;;;;;;;;;;;;;;;GAoBG;AAGH,eAAO,MAAM,WAAW,GACtB,WAAW,MAAM,EACjB,QAAQ,GAAG,EACX,SAAQ,GAAU,KACjB,GAwHF,CAAC;;0CA3uB2C,GAAG,KAAG,IAAI;wBA8vB/C,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,iBACG,YAAY,GAAG,IAAI,KAChC,GAAG;sBAoCG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,iBACb,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;oCAkYF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,eACT,MAAM,eACN,MAAM,wBACG,OAAO,WACpB,GAAG,cACA,MAAM,UACV,GAAG,EAAE,iBACE,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;sDA2tBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,iBACE,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;sDAmTF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,iBACE,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;0CA7zC8B,GAAG,KAAG,MAAM;+CA1uB9C,YAAY,GAAG,IAAI,GAAG,SAAS,aAC7B,MAAM,kBACD,MAAM,UACd,GAAG,KACV,IAAI;mCA0vBU,MAAM,mBACJ,MAAM,EAAE,KACxB,MAAM;;AAg2CT,wBAUE"}
1
+ {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":"AAoGA,KAAK,UAAU,GAAG;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IAKtB,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE;QAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IACvC;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACtD,CAAC;AAmiBF;;;;;;;;;;;;;;;;;;;;GAoBG;AAGH,eAAO,MAAM,WAAW,GACtB,WAAW,MAAM,EACjB,QAAQ,GAAG,EACX,SAAQ,GAAU,KACjB,GAwHF,CAAC;;8CA4Z+C,OAAO,UAAU,GAAG,KAAG,OAAO;oCAWxC,GAAG,KAAG,OAAO;0CAgBtC,MAAM,cACN,GAAG,SACR,GAAG,KACT,MAAM;2CA0CqC,GAAG,UAAU,GAAG,KAAG,MAAM;sCAvvC9B,MAAM,KAAG,OAAO;0CA+FZ,GAAG,KAAG,IAAI;wBAusB/C,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,iBACG,YAAY,GAAG,IAAI,KAChC,GAAG;sBAoCG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,iBACb,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;oCA6fF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,eACT,MAAM,eACN,MAAM,wBACG,OAAO,WACpB,GAAG,cACA,MAAM,UACV,GAAG,EAAE,iBACE,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;sDA0sBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,iBACE,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;sDAmTF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,iBACE,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;0CAv6C8B,GAAG,KAAG,MAAM;uCA3wB9C,YAAY,GAAG,IAAI,GAAG,SAAS,aAC7B,MAAM,YACP,GAAG,WACJ,GAAG,YACF,QAAQ,GAAG,QAAQ,KAC5B,IAAI;kCA5CI,YAAY,GAAG,IAAI,GAAG,SAAS,aAC7B,MAAM,SACV,UAAU,GAAG,IAAI,YACd,GAAG,KACZ,IAAI;+CA0FI,YAAY,GAAG,IAAI,GAAG,SAAS,aAC7B,MAAM,kBACD,MAAM,YACZ,GAAG,KACZ,IAAI;uCAoBI,YAAY,GAAG,IAAI,GAAG,SAAS,aAC7B,MAAM,WACR,MAAM,YACL,GAAG,KACZ,IAAI;mCA4sBU,MAAM,mBACJ,MAAM,EAAE,KACxB,MAAM;;AA08CT,wBAkBE"}
@@ -10,6 +10,7 @@ import logger from "./logger.js";
10
10
  import { z } from "zod";
11
11
  import { transformJSONSchema } from "@anthropic-ai/sdk/lib/transform-json-schema";
12
12
  import { getAbortSignal } from "./runtimeContext.js";
13
+ import { recordUsage } from "./usage.js";
13
14
  import { assertTavilyUsable, buildTavilyTools, getTavilyOptions, } from "./tavily.js";
14
15
  const JSON_ESCAPE_INSTRUCTION = `
15
16
  CRITICAL - Your response must be valid JSON. Escape ALL special characters in string values:
@@ -98,29 +99,36 @@ const checkIfAIProviderConfigured = (config) => {
98
99
  }
99
100
  };
100
101
  /**
101
- * Reads a per-model rate from the flat cost constants the integration defines
102
- * in its `config.yaml` (exposed on the config object), e.g.
103
- * `claude-opus-4-7-input-token-costs`. Returns `0` when the constant is
104
- * absent or not numeric, which leaves cost contribution zero.
102
+ * Counts `amount` of `unit` for `modelName`: into the request's usage ledger,
103
+ * which is reported to the platform, and into the caller's tracker when it
104
+ * passed one. Nothing is counted for an amount that is not positive.
105
105
  */
106
- const getModelRate = (modelName, config, kind) => {
107
- if (!config || !modelName)
108
- return 0;
109
- const rate = Number(config[`${modelName}-${kind}-costs`]);
110
- return Number.isFinite(rate) ? rate : 0;
106
+ const countUsage = (tracker, modelName, unit, amount) => {
107
+ if (!Number.isFinite(amount) || amount <= 0)
108
+ return;
109
+ recordUsage(modelName, unit, amount);
110
+ if (!tracker)
111
+ return;
112
+ const key = `${modelName}_${unit}`;
113
+ tracker.tokens[key] = (tracker.tokens[key] || 0) + amount;
111
114
  };
112
- /**
113
- * Adds a single call's usage to the caller-supplied tracker. Initializes
114
- * `cost` and `tokens` lazily so callers can pass `{}`. No-ops when the
115
- * tracker is null/undefined.
116
- */
117
- const updateUsageTracker = (tracker, modelName, usage, config) => {
118
- if (!tracker || !usage || !modelName)
115
+ /** Makes `{}` a tracker. `cost` stays 0: the platform prices what was used. */
116
+ const prepareTracker = (tracker) => {
117
+ if (!tracker)
119
118
  return;
120
119
  if (typeof tracker.cost !== "number")
121
120
  tracker.cost = 0;
122
121
  if (!tracker.tokens || typeof tracker.tokens !== "object")
123
122
  tracker.tokens = {};
123
+ };
124
+ /**
125
+ * Counts a single call's token usage. `config` is no longer read: it carried
126
+ * the rates, and what usage costs is the platform's to say.
127
+ */
128
+ const updateUsageTracker = (tracker, modelName, usage, _config) => {
129
+ if (!usage || !modelName)
130
+ return;
131
+ prepareTracker(tracker);
124
132
  const inputTokens = usage.input_tokens || 0;
125
133
  const visibleOutputTokens = usage.output_tokens || 0;
126
134
  const totalTokens = usage.total_tokens || 0;
@@ -129,40 +137,24 @@ const updateUsageTracker = (tracker, modelName, usage, config) => {
129
137
  // `total - input - visibleOutput` is the thinking output the caller was still
130
138
  // billed for. Providers that already fold reasoning into `output_tokens`
131
139
  // (Anthropic, OpenAI) report `total == input + output`, so this adds 0.
132
- // Reasoning is charged at the output rate, so we treat it as output for both
133
- // the token counters and the cost.
140
+ // Reasoning is charged at the output rate, so it is counted as output.
134
141
  const reasoningTokens = totalTokens > 0
135
142
  ? Math.max(0, totalTokens - inputTokens - visibleOutputTokens)
136
143
  : 0;
137
- const outputTokens = visibleOutputTokens + reasoningTokens;
138
- const inputRate = getModelRate(modelName, config, "input-token");
139
- const outputRate = getModelRate(modelName, config, "output-token");
140
- const addedCost = (inputTokens / 1_000_000) * inputRate +
141
- (outputTokens / 1_000_000) * outputRate;
142
- if (Number.isFinite(addedCost) && addedCost > 0)
143
- tracker.cost += addedCost;
144
- const inputKey = `${modelName}_inputTokens`;
145
- const outputKey = `${modelName}_outputTokens`;
146
- tracker.tokens[inputKey] = (tracker.tokens[inputKey] || 0) + inputTokens;
147
- tracker.tokens[outputKey] = (tracker.tokens[outputKey] || 0) + outputTokens;
144
+ countUsage(tracker, modelName, "inputTokens", inputTokens);
145
+ countUsage(tracker, modelName, "outputTokens", visibleOutputTokens + reasoningTokens);
148
146
  };
149
147
  /**
150
- * Adds a single image-generation call's usage to the caller-supplied tracker.
151
- * Extracts token counts from provider-specific response shapes: OpenAI returns
152
- * `response.usage` with `input_tokens_details.{text_tokens,image_tokens}` and
153
- * `output_tokens`; Google returns `response.usageMetadata` with
154
- * `promptTokensDetails` (modality-keyed) and `candidatesTokenCount` for the
155
- * generated image. Reads rates from `<model>-input-token-costs`,
156
- * `<model>-image-input-token-costs`, and `<model>-image-output-token-costs`.
157
- * No-ops when the tracker or response usage info is absent.
148
+ * Counts a single image-generation call's usage. Extracts token counts from
149
+ * provider-specific response shapes: OpenAI returns `response.usage` with
150
+ * `input_tokens_details.{text_tokens,image_tokens}` and `output_tokens`;
151
+ * Google returns `response.usageMetadata` with `promptTokensDetails`
152
+ * (modality-keyed) and `candidatesTokenCount` for the generated image.
153
+ * No-ops when the response carries no usage.
158
154
  */
159
- const updateImageUsageTracker = (tracker, modelName, response, config, provider) => {
160
- if (!tracker || !modelName || !response)
155
+ const updateImageUsageTracker = (tracker, modelName, response, _config, provider) => {
156
+ if (!modelName || !response)
161
157
  return;
162
- if (typeof tracker.cost !== "number")
163
- tracker.cost = 0;
164
- if (!tracker.tokens || typeof tracker.tokens !== "object")
165
- tracker.tokens = {};
166
158
  let textInputTokens = 0;
167
159
  let imageInputTokens = 0;
168
160
  let imageOutputTokens = 0;
@@ -199,85 +191,42 @@ const updateImageUsageTracker = (tracker, modelName, response, config, provider)
199
191
  }
200
192
  imageOutputTokens = Number(um.candidatesTokenCount) || 0;
201
193
  }
202
- const textInputRate = getModelRate(modelName, config, "input-token");
203
- // Reference-image input tokens are billed at the model's input rate. Configs
204
- // that don't define a dedicated `<model>-image-input-token-costs` (the common
205
- // case — image models charge all input at one rate and only differ on output)
206
- // fall back to the text input rate rather than silently billing image input
207
- // at $0.
208
- const imageInputRate = getModelRate(modelName, config, "image-input-token") || textInputRate;
209
- const imageOutputRate = getModelRate(modelName, config, "image-output-token");
210
- const addedCost = (textInputTokens / 1_000_000) * textInputRate +
211
- (imageInputTokens / 1_000_000) * imageInputRate +
212
- (imageOutputTokens / 1_000_000) * imageOutputRate;
213
- if (Number.isFinite(addedCost) && addedCost > 0)
214
- tracker.cost += addedCost;
215
- if (textInputTokens > 0) {
216
- const key = `${modelName}_inputTokens`;
217
- tracker.tokens[key] = (tracker.tokens[key] || 0) + textInputTokens;
218
- }
219
- if (imageInputTokens > 0) {
220
- const key = `${modelName}_imageInputTokens`;
221
- tracker.tokens[key] = (tracker.tokens[key] || 0) + imageInputTokens;
222
- }
223
- if (imageOutputTokens > 0) {
224
- const key = `${modelName}_imageOutputTokens`;
225
- tracker.tokens[key] = (tracker.tokens[key] || 0) + imageOutputTokens;
226
- }
194
+ prepareTracker(tracker);
195
+ countUsage(tracker, modelName, "inputTokens", textInputTokens);
196
+ // The platform prices an image a model is given as its other input, unless
197
+ // the model has a rate of its own for it.
198
+ countUsage(tracker, modelName, "imageInputTokens", imageInputTokens);
199
+ countUsage(tracker, modelName, "imageOutputTokens", imageOutputTokens);
227
200
  };
228
201
  /**
229
- * Adds character-billed usage (e.g. ElevenLabs TTS) to the caller-supplied
230
- * tracker. There is no provider response to parse — billing is deterministic
231
- * from the input character count. Reads the rate from
232
- * `<model>-character-costs` (USD per million characters) and accumulates
233
- * under `<model>_characters`. No-ops on missing tracker / model / count.
202
+ * Counts character-billed usage (e.g. ElevenLabs TTS). There is no provider
203
+ * response to parse — billing is deterministic from the input character
204
+ * count. No-ops on a missing model or count.
234
205
  */
235
- const updateUsageTrackerForCharacters = (tracker, modelName, characterCount, config) => {
236
- if (!tracker || !modelName || !characterCount)
206
+ const updateUsageTrackerForCharacters = (tracker, modelName, characterCount, _config) => {
207
+ if (!modelName || !characterCount)
237
208
  return;
238
- if (typeof tracker.cost !== "number")
239
- tracker.cost = 0;
240
- if (!tracker.tokens || typeof tracker.tokens !== "object")
241
- tracker.tokens = {};
242
- const rate = getModelRate(modelName, config, "character");
243
- const addedCost = (characterCount / 1_000_000) * rate;
244
- if (Number.isFinite(addedCost) && addedCost > 0)
245
- tracker.cost += addedCost;
246
- const key = `${modelName}_characters`;
247
- tracker.tokens[key] = (tracker.tokens[key] || 0) + characterCount;
209
+ prepareTracker(tracker);
210
+ countUsage(tracker, modelName, "characters", characterCount);
248
211
  };
249
212
  /**
250
- * Adds second-billed usage (video generation) to the caller-supplied tracker.
251
- *
252
- * Video is the first output here that providers bill by DURATION rather than by
253
- * tokens or characters, so the rate constant `<model>-second-costs` is read as
254
- * USD PER SECOND — not per million, as the `input-token` and `character` rates
255
- * are. A Veo or Sora second costs cents, and a per-million reading would bill
256
- * it as effectively free.
213
+ * Counts second-billed usage (video generation): providers bill video by
214
+ * DURATION rather than by tokens or characters.
257
215
  *
258
- * The rate is per model, not per model-and-resolution, so a caller that lets a
259
- * tenant change resolution on a model whose price varies by it (sora-2-pro is
260
- * $0.30/$0.50/$0.70 at 720p/1024p/1080p) must pin the resolution its constant
261
- * describes, or it will bill the wrong number.
216
+ * The platform's rate is per model, not per model-and-resolution, so a caller
217
+ * that lets a tenant change resolution on a model whose price varies by it
218
+ * (sora-2-pro is $0.30/$0.50/$0.70 at 720p/1024p/1080p) must pin the
219
+ * resolution the rate describes, or the wrong number is charged.
262
220
  *
263
- * @param tracker - The accumulator to add to; no-ops when null
264
- * @param modelName - The video model id, used for both the rate and the counter key
221
+ * @param tracker - The caller's accumulator, when it passed one
222
+ * @param modelName - The video model id
265
223
  * @param seconds - Billed duration of the generated clip
266
- * @param config - Integration config carrying `<model>-second-costs`
267
224
  */
268
- const updateVideoUsageTracker = (tracker, modelName, seconds, config) => {
269
- if (!tracker || !modelName || !seconds)
225
+ const updateVideoUsageTracker = (tracker, modelName, seconds, _config) => {
226
+ if (!modelName || !seconds)
270
227
  return;
271
- if (typeof tracker.cost !== "number")
272
- tracker.cost = 0;
273
- if (!tracker.tokens || typeof tracker.tokens !== "object")
274
- tracker.tokens = {};
275
- const rate = getModelRate(modelName, config, "second");
276
- const addedCost = seconds * rate;
277
- if (Number.isFinite(addedCost) && addedCost > 0)
278
- tracker.cost += addedCost;
279
- const key = `${modelName}_seconds`;
280
- tracker.tokens[key] = (tracker.tokens[key] || 0) + seconds;
228
+ prepareTracker(tracker);
229
+ countUsage(tracker, modelName, "seconds", seconds);
281
230
  };
282
231
  /**
283
232
  * Extracts a normalized token-usage object from a single LangChain `invoke()`
@@ -1093,6 +1042,65 @@ const parseAndValidateJSONResponse = (rawContent, schema) => {
1093
1042
  * original base) so that across multiple retries the full failure history
1094
1043
  * accumulates and the model can avoid oscillating between previous broken outputs.
1095
1044
  */
1045
+ /**
1046
+ * Whether a response that fails schema validation is retried with the errors
1047
+ * appended (buildValidationRetryMessages), up to MAX_VALIDATION_RETRIES.
1048
+ *
1049
+ * This used to be skipped whenever the model supports native structured
1050
+ * output, on the reasoning that a natively constrained response cannot be
1051
+ * invalid, so a failure had to be a schema bug worth surfacing at once. It
1052
+ * can be invalid, and routinely is: constrained decoding enforces the JSON
1053
+ * *shape*, not the Zod schema. Anthropic's SDK strips `enum` into a
1054
+ * description before sending, Gemini ignores `additionalProperties`, and no
1055
+ * provider runs a refine or a superRefine. Production, 2026-09-23/24: one
1056
+ * agent failed one call in six on extra keys the strict schema refused, and
1057
+ * another was rejected three runs in a row at its last stage on a value off
1058
+ * an enumerated list — every one on a native-schema model, every one reported
1059
+ * straight to the user, and every one restarted from zero by the caller
1060
+ * because the stage that failed was never asked again.
1061
+ *
1062
+ * So the retry now applies whenever a schema is in play. A genuine schema bug
1063
+ * still surfaces: it fails the same way three times and the last error is
1064
+ * thrown as before, at the cost of two extra calls.
1065
+ * @param expectsJsonResponse - Whether the call expects JSON at all
1066
+ * @param schema - The Zod schema the response is validated against, if any
1067
+ * @returns Whether a validation failure is retried
1068
+ */
1069
+ const canRetryValidation = (expectsJsonResponse, schema) => !!expectsJsonResponse && !!schema;
1070
+ /**
1071
+ * Whether a failure of the JSON stage is one the model can fix if asked again:
1072
+ * a response that failed schema validation, or one that was not JSON at all
1073
+ * (prose, or JSON with a preamble the extractor could not recover). Everything
1074
+ * else — no messages, a provider error, a transport error — is not.
1075
+ * @param error - The failure
1076
+ * @returns Whether a retry with the errors appended makes sense
1077
+ */
1078
+ const isRetryableJsonFailure = (error) => error?.code === constants.HTTP_CODES.UNPROCESSABLE_ENTITY ||
1079
+ /non-JSON response when JSON was expected/.test(String(error?.message ?? ""));
1080
+ /**
1081
+ * The agentic run takes one user prompt rather than a message list, so a retry
1082
+ * carries the failed output and the errors inside that prompt: the model sees
1083
+ * what it produced and what was wrong with it, the same information
1084
+ * buildValidationRetryMessages gives the non-agentic paths. Previous attempts
1085
+ * accumulate so the model does not oscillate between earlier broken outputs.
1086
+ * @param userPrompt - The prompt of the attempt that failed
1087
+ * @param rawContent - What the model answered
1088
+ * @param error - Why it was refused
1089
+ * @returns The user prompt for the next attempt
1090
+ */
1091
+ const buildAgenticRetryPrompt = (userPrompt, rawContent, error) => {
1092
+ const previous = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent);
1093
+ return `${userPrompt}
1094
+
1095
+ ---
1096
+ Your previous response was rejected:
1097
+ ${String(error?.message ?? error)}
1098
+
1099
+ Your previous response was:
1100
+ ${previous}
1101
+
1102
+ Return a corrected JSON response that fixes ALL of these issues. Respond with the JSON only.`;
1103
+ };
1096
1104
  const buildValidationRetryMessages = (priorMessages, rawContent, validationError) => {
1097
1105
  const assistantContent = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent);
1098
1106
  return [
@@ -1104,6 +1112,58 @@ const buildValidationRetryMessages = (priorMessages, rawContent, validationError
1104
1112
  },
1105
1113
  ];
1106
1114
  };
1115
+ /**
1116
+ * The agentic path's JSON stage: the agent's final text as validated JSON.
1117
+ * Direct parse when it looks like JSON, the markdown/preamble extractor
1118
+ * otherwise; a schema failure throws UNPROCESSABLE_ENTITY (validateWithSchema),
1119
+ * anything the extractor cannot recover throws the "non-JSON response" error.
1120
+ * Both are what runPromptWithModel's agentic retry loop asks the model to fix.
1121
+ * @param rawContent - The agent's final message text
1122
+ * @param schema - The Zod schema to validate against, if any
1123
+ * @returns The JSON, re-serialised
1124
+ */
1125
+ const parseAgenticJsonResponse = (rawContent, schema) => {
1126
+ if (typeof rawContent === "string") {
1127
+ const trimmed = rawContent.trim();
1128
+ if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
1129
+ (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
1130
+ try {
1131
+ const parsed = JSON.parse(trimmed);
1132
+ if (schema) {
1133
+ validateWithSchema(parsed, schema);
1134
+ }
1135
+ return JSON.stringify(parsed);
1136
+ }
1137
+ catch (parseError) {
1138
+ if (parseError?.code === constants.HTTP_CODES.UNPROCESSABLE_ENTITY) {
1139
+ throw parseError;
1140
+ }
1141
+ }
1142
+ }
1143
+ // Try to extract JSON from markdown or other wrapping
1144
+ const extracted = extractJSONFromResponse(rawContent);
1145
+ if (extracted) {
1146
+ if (schema) {
1147
+ validateWithSchema(extracted, schema);
1148
+ }
1149
+ return JSON.stringify(extracted);
1150
+ }
1151
+ }
1152
+ let preview = "";
1153
+ if (typeof rawContent === "string") {
1154
+ preview = rawContent.substring(0, 100);
1155
+ }
1156
+ else if (typeof rawContent === "object" && rawContent !== null) {
1157
+ preview = JSON.stringify(rawContent).substring(0, 100);
1158
+ }
1159
+ else if (rawContent !== undefined && rawContent !== null) {
1160
+ preview = String(rawContent).substring(0, 100);
1161
+ }
1162
+ else {
1163
+ preview = "[empty response]";
1164
+ }
1165
+ throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, `Agent returned non-JSON response when JSON was expected: ${preview}...`);
1166
+ };
1107
1167
  /**
1108
1168
  * Sends a prompt to an LLM and returns the response, with support for streaming,
1109
1169
  * agentic execution, progress reporting, JSON extraction, and Zod schema validation.
@@ -1186,65 +1246,43 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1186
1246
  // being reconciled from the response afterwards.
1187
1247
  const agent = createAgent(agentName, modelName, systemPrompt, [...tools], null, // responseFormat
1188
1248
  config, usageTracker);
1189
- // Run the agent with progress callback
1190
- const response = await runAgent(agent, userPrompt, config, onProgressReport || null, usageTracker);
1191
- // Extract content from agent response
1192
- const messages = response?.messages || [];
1193
- if (messages.length === 0) {
1194
- throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, "Agent returned no messages");
1195
- }
1196
- const lastMessage = messages[messages.length - 1];
1197
- // Flattens the array content blocks that Gemini/Claude agent responses and
1198
- // any server-tool turn (e.g. web search) return.
1199
- const rawContent = extractTextContent(lastMessage?.content);
1200
- // If not expecting JSON, return raw content directly
1201
- if (!expectsJsonResponse) {
1202
- return rawContent;
1203
- }
1204
- // Process JSON response similar to non-agentic path
1205
- if (typeof rawContent === "string") {
1206
- const trimmed = rawContent.trim();
1207
- if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
1208
- (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
1209
- try {
1210
- const parsed = JSON.parse(trimmed);
1211
- if (schema) {
1212
- validateWithSchema(parsed, schema);
1213
- }
1214
- return JSON.stringify(parsed);
1215
- }
1216
- catch (parseError) {
1217
- if (parseError?.code === constants.HTTP_CODES.UNPROCESSABLE_ENTITY) {
1218
- throw parseError;
1219
- }
1220
- }
1221
- }
1222
- // Try to extract JSON from markdown or other wrapping
1223
- const extracted = extractJSONFromResponse(rawContent);
1224
- if (extracted) {
1225
- if (schema) {
1226
- validateWithSchema(extracted, schema);
1227
- }
1228
- return JSON.stringify(extracted);
1229
- }
1230
- }
1231
- if (expectsJsonResponse) {
1232
- let preview = "";
1233
- if (typeof rawContent === "string") {
1234
- preview = rawContent.substring(0, 100);
1249
+ // The JSON stage is retried like the non-agentic paths (canRetryValidation):
1250
+ // the agent is run again with its rejected output and the errors appended
1251
+ // to the user prompt, up to MAX_VALIDATION_RETRIES. Until now this path
1252
+ // had no retry at all, and it is the one every tool-using generation takes.
1253
+ let activeUserPrompt = userPrompt;
1254
+ let attempt = 0;
1255
+ for (;;) {
1256
+ // Run the agent with progress callback
1257
+ const response = await runAgent(agent, activeUserPrompt, config, onProgressReport || null, usageTracker);
1258
+ // Extract content from agent response
1259
+ const messages = response?.messages || [];
1260
+ if (messages.length === 0) {
1261
+ throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, "Agent returned no messages");
1235
1262
  }
1236
- else if (typeof rawContent === "object" && rawContent !== null) {
1237
- preview = JSON.stringify(rawContent).substring(0, 100);
1263
+ const lastMessage = messages[messages.length - 1];
1264
+ // Flattens the array content blocks that Gemini/Claude agent responses and
1265
+ // any server-tool turn (e.g. web search) return.
1266
+ const rawContent = extractTextContent(lastMessage?.content);
1267
+ // If not expecting JSON, return raw content directly
1268
+ if (!expectsJsonResponse) {
1269
+ return rawContent;
1238
1270
  }
1239
- else if (rawContent !== undefined && rawContent !== null) {
1240
- preview = String(rawContent).substring(0, 100);
1271
+ try {
1272
+ return parseAgenticJsonResponse(rawContent, schema);
1241
1273
  }
1242
- else {
1243
- preview = "[empty response]";
1274
+ catch (err) {
1275
+ if (canRetryValidation(expectsJsonResponse, schema) &&
1276
+ isRetryableJsonFailure(err) &&
1277
+ attempt < MAX_VALIDATION_RETRIES) {
1278
+ attempt++;
1279
+ logger.log(null, logger.levels.warn, `Schema validation failed for "${modelName}", retrying (${attempt}/${MAX_VALIDATION_RETRIES}): ${err.message}`);
1280
+ activeUserPrompt = buildAgenticRetryPrompt(activeUserPrompt, rawContent, err);
1281
+ continue;
1282
+ }
1283
+ throw err;
1244
1284
  }
1245
- throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, `Agent returned non-JSON response when JSON was expected: ${preview}...`);
1246
1285
  }
1247
- return rawContent;
1248
1286
  }
1249
1287
  let messages;
1250
1288
  if (typeof prompt === "string") {
@@ -1289,13 +1327,13 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1289
1327
  else {
1290
1328
  messagesToSend = messages;
1291
1329
  }
1292
- // Retry once on schema validation failure. The retry is silent to the progress
1330
+ // Retry on schema validation failure. The retry is silent to the progress
1293
1331
  // callback — same "Generating content..." message and the progress curve
1294
1332
  // continues from the original startTime so the bar doesn't visibly reset.
1295
- // Skipped when OpenAI native response_format is in use (a 422 there is a
1296
- // server-side schema bug, not a model output issue). Each attempt accumulates
1297
- // independently into usageTracker so retry usage is still visible.
1298
- const canRetryOnValidation = expectsJsonResponse && !!schema && !useNativeSchema;
1333
+ // Each attempt accumulates independently into usageTracker so retry usage
1334
+ // is still visible. Native structured output no longer exempts a model:
1335
+ // see canRetryValidation.
1336
+ const canRetryOnValidation = canRetryValidation(expectsJsonResponse, schema);
1299
1337
  const progressReportInterval = 10; // Report every N chunks
1300
1338
  const overallStartTime = Date.now();
1301
1339
  // Use a time-based asymptotic curve: progress approaches maxPercent but never
@@ -1447,9 +1485,9 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1447
1485
  else {
1448
1486
  messagesToSend = messages;
1449
1487
  }
1450
- // Retry once on schema validation failure (see streaming branch for rationale).
1488
+ // Retry on schema validation failure (see streaming branch for rationale).
1451
1489
  // Each attempt accumulates independently into usageTracker.
1452
- const canRetryOnValidation = expectsJsonResponse && !!schema && !useNativeSchema;
1490
+ const canRetryOnValidation = canRetryValidation(expectsJsonResponse, schema);
1453
1491
  let activeMessages = messagesToSend;
1454
1492
  let attempt = 0;
1455
1493
  while (true) {
@@ -1958,6 +1996,11 @@ const runPromptWithModelForVideoGeneration = async (modelName, config, prompt, o
1958
1996
  }
1959
1997
  };
1960
1998
  export default {
1999
+ canRetryValidation,
2000
+ isRetryableJsonFailure,
2001
+ buildAgenticRetryPrompt,
2002
+ parseAgenticJsonResponse,
2003
+ supportsNativeSchema,
1961
2004
  checkIfAIProviderConfigured,
1962
2005
  createAgent,
1963
2006
  runAgent,
@@ -1965,6 +2008,9 @@ export default {
1965
2008
  runPromptWithModelForImageGeneration,
1966
2009
  runPromptWithModelForVideoGeneration,
1967
2010
  throwErrorIfNotSuccessful,
2011
+ updateImageUsageTracker,
2012
+ updateUsageTracker,
1968
2013
  updateUsageTrackerForCharacters,
2014
+ updateVideoUsageTracker,
1969
2015
  validateModel,
1970
2016
  };
@@ -1,3 +1,4 @@
1
+ import type { UsageLedger } from "./usage.js";
1
2
  /**
2
3
  * Per-request execution context for an agent invocation.
3
4
  *
@@ -25,6 +26,8 @@ export interface RequestContext {
25
26
  user: any;
26
27
  tenant: any;
27
28
  } | null;
29
+ /** What the request used and has not reported to the platform yet (see usage). */
30
+ usage?: UsageLedger;
28
31
  }
29
32
  /** Run `fn` with `ctx` bound as the active request context. */
30
33
  export declare const runWithContext: <T>(ctx: RequestContext, fn: () => Promise<T>) => Promise<T>;
@@ -1 +1 @@
1
- {"version":3,"file":"runtimeContext.d.ts","sourceRoot":"","sources":["../../src/runtimeContext.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;GAUG;AACH,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,GAAG,CAAC;IACZ,MAAM,EAAE,GAAG,CAAC;IACZ,OAAO,EAAE,GAAG,CAAC;IACb,KAAK,EAAE,GAAG,CAAC;IACX,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;IAC7E,0EAA0E;IAC1E,MAAM,EAAE,WAAW,GAAG,IAAI,CAAC;IAC3B,4EAA4E;IAC5E,OAAO,CAAC,EAAE;QAAE,IAAI,EAAE,GAAG,CAAC;QAAC,MAAM,EAAE,GAAG,CAAA;KAAE,GAAG,IAAI,CAAC;CAC7C;AAID,+DAA+D;AAC/D,eAAO,MAAM,cAAc,GAAI,CAAC,EAC9B,KAAK,cAAc,EACnB,IAAI,MAAM,OAAO,CAAC,CAAC,CAAC,KACnB,OAAO,CAAC,CAAC,CAAyB,CAAC;AAEtC,oEAAoE;AACpE,eAAO,MAAM,UAAU,QAAO,cAAc,GAAG,SAA+B,CAAC;AAE/E,oDAAoD;AACpD,eAAO,MAAM,cAAc,QAAO,WAAW,GAAG,IACZ,CAAC;AAIrC;;;;;GAKG;AACH,eAAO,MAAM,cAAc,QAAO,IAUjC,CAAC"}
1
+ {"version":3,"file":"runtimeContext.d.ts","sourceRoot":"","sources":["../../src/runtimeContext.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C;;;;;;;;;;GAUG;AACH,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,GAAG,CAAC;IACZ,MAAM,EAAE,GAAG,CAAC;IACZ,OAAO,EAAE,GAAG,CAAC;IACb,KAAK,EAAE,GAAG,CAAC;IACX,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;IAC7E,0EAA0E;IAC1E,MAAM,EAAE,WAAW,GAAG,IAAI,CAAC;IAC3B,4EAA4E;IAC5E,OAAO,CAAC,EAAE;QAAE,IAAI,EAAE,GAAG,CAAC;QAAC,MAAM,EAAE,GAAG,CAAA;KAAE,GAAG,IAAI,CAAC;IAC5C,kFAAkF;IAClF,KAAK,CAAC,EAAE,WAAW,CAAC;CACrB;AAID,+DAA+D;AAC/D,eAAO,MAAM,cAAc,GAAI,CAAC,EAC9B,KAAK,cAAc,EACnB,IAAI,MAAM,OAAO,CAAC,CAAC,CAAC,KACnB,OAAO,CAAC,CAAC,CAAyB,CAAC;AAEtC,oEAAoE;AACpE,eAAO,MAAM,UAAU,QAAO,cAAc,GAAG,SAA+B,CAAC;AAE/E,oDAAoD;AACpD,eAAO,MAAM,cAAc,QAAO,WAAW,GAAG,IACZ,CAAC;AAIrC;;;;;GAKG;AACH,eAAO,MAAM,cAAc,QAAO,IAUjC,CAAC"}