@stackfactor/agent-utils 1.4.1 → 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.
@@ -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:
@@ -24,9 +25,10 @@ Do NOT include raw newlines, tabs, or unescaped quotes inside JSON string values
24
25
  * Providers for which `getLLMModel` configures native, schema-constrained JSON
25
26
  * output (OpenAI `response_format`, Anthropic `output_config.format`, Gemini
26
27
  * `responseSchema` + JSON mode). For these the model emits JSON directly, so the
27
- * prompt-injected schema and the schema-validation retry are skipped. Other
28
- * providers (DeepSeek/Kimi/GLM via the OpenAI-compatible shim) have no native
29
- * support here and continue to rely on prompt instructions + validation.
28
+ * prompt-injected schema and the schema-validation retry are skipped. Every
29
+ * supported provider is listed here; the check remains so that adding one
30
+ * without native support falls back to prompt instructions + validation
31
+ * rather than silently skipping them.
30
32
  * @param modelName - The model identifier being routed
31
33
  * @returns `true` when native structured output is wired up for the provider
32
34
  */
@@ -97,29 +99,36 @@ const checkIfAIProviderConfigured = (config) => {
97
99
  }
98
100
  };
99
101
  /**
100
- * Reads a per-model rate from the flat cost constants the integration defines
101
- * in its `config.yaml` (exposed on the config object), e.g.
102
- * `claude-opus-4-7-input-token-costs`. Returns `0` when the constant is
103
- * 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.
104
105
  */
105
- const getModelRate = (modelName, config, kind) => {
106
- if (!config || !modelName)
107
- return 0;
108
- const rate = Number(config[`${modelName}-${kind}-costs`]);
109
- 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;
110
114
  };
111
- /**
112
- * Adds a single call's usage to the caller-supplied tracker. Initializes
113
- * `cost` and `tokens` lazily so callers can pass `{}`. No-ops when the
114
- * tracker is null/undefined.
115
- */
116
- const updateUsageTracker = (tracker, modelName, usage, config) => {
117
- if (!tracker || !usage || !modelName)
115
+ /** Makes `{}` a tracker. `cost` stays 0: the platform prices what was used. */
116
+ const prepareTracker = (tracker) => {
117
+ if (!tracker)
118
118
  return;
119
119
  if (typeof tracker.cost !== "number")
120
120
  tracker.cost = 0;
121
121
  if (!tracker.tokens || typeof tracker.tokens !== "object")
122
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);
123
132
  const inputTokens = usage.input_tokens || 0;
124
133
  const visibleOutputTokens = usage.output_tokens || 0;
125
134
  const totalTokens = usage.total_tokens || 0;
@@ -128,40 +137,24 @@ const updateUsageTracker = (tracker, modelName, usage, config) => {
128
137
  // `total - input - visibleOutput` is the thinking output the caller was still
129
138
  // billed for. Providers that already fold reasoning into `output_tokens`
130
139
  // (Anthropic, OpenAI) report `total == input + output`, so this adds 0.
131
- // Reasoning is charged at the output rate, so we treat it as output for both
132
- // the token counters and the cost.
140
+ // Reasoning is charged at the output rate, so it is counted as output.
133
141
  const reasoningTokens = totalTokens > 0
134
142
  ? Math.max(0, totalTokens - inputTokens - visibleOutputTokens)
135
143
  : 0;
136
- const outputTokens = visibleOutputTokens + reasoningTokens;
137
- const inputRate = getModelRate(modelName, config, "input-token");
138
- const outputRate = getModelRate(modelName, config, "output-token");
139
- const addedCost = (inputTokens / 1_000_000) * inputRate +
140
- (outputTokens / 1_000_000) * outputRate;
141
- if (Number.isFinite(addedCost) && addedCost > 0)
142
- tracker.cost += addedCost;
143
- const inputKey = `${modelName}_inputTokens`;
144
- const outputKey = `${modelName}_outputTokens`;
145
- tracker.tokens[inputKey] = (tracker.tokens[inputKey] || 0) + inputTokens;
146
- tracker.tokens[outputKey] = (tracker.tokens[outputKey] || 0) + outputTokens;
144
+ countUsage(tracker, modelName, "inputTokens", inputTokens);
145
+ countUsage(tracker, modelName, "outputTokens", visibleOutputTokens + reasoningTokens);
147
146
  };
148
147
  /**
149
- * Adds a single image-generation call's usage to the caller-supplied tracker.
150
- * Extracts token counts from provider-specific response shapes: OpenAI returns
151
- * `response.usage` with `input_tokens_details.{text_tokens,image_tokens}` and
152
- * `output_tokens`; Google returns `response.usageMetadata` with
153
- * `promptTokensDetails` (modality-keyed) and `candidatesTokenCount` for the
154
- * generated image. Reads rates from `<model>-input-token-costs`,
155
- * `<model>-image-input-token-costs`, and `<model>-image-output-token-costs`.
156
- * 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.
157
154
  */
158
- const updateImageUsageTracker = (tracker, modelName, response, config, provider) => {
159
- if (!tracker || !modelName || !response)
155
+ const updateImageUsageTracker = (tracker, modelName, response, _config, provider) => {
156
+ if (!modelName || !response)
160
157
  return;
161
- if (typeof tracker.cost !== "number")
162
- tracker.cost = 0;
163
- if (!tracker.tokens || typeof tracker.tokens !== "object")
164
- tracker.tokens = {};
165
158
  let textInputTokens = 0;
166
159
  let imageInputTokens = 0;
167
160
  let imageOutputTokens = 0;
@@ -198,85 +191,42 @@ const updateImageUsageTracker = (tracker, modelName, response, config, provider)
198
191
  }
199
192
  imageOutputTokens = Number(um.candidatesTokenCount) || 0;
200
193
  }
201
- const textInputRate = getModelRate(modelName, config, "input-token");
202
- // Reference-image input tokens are billed at the model's input rate. Configs
203
- // that don't define a dedicated `<model>-image-input-token-costs` (the common
204
- // case — image models charge all input at one rate and only differ on output)
205
- // fall back to the text input rate rather than silently billing image input
206
- // at $0.
207
- const imageInputRate = getModelRate(modelName, config, "image-input-token") || textInputRate;
208
- const imageOutputRate = getModelRate(modelName, config, "image-output-token");
209
- const addedCost = (textInputTokens / 1_000_000) * textInputRate +
210
- (imageInputTokens / 1_000_000) * imageInputRate +
211
- (imageOutputTokens / 1_000_000) * imageOutputRate;
212
- if (Number.isFinite(addedCost) && addedCost > 0)
213
- tracker.cost += addedCost;
214
- if (textInputTokens > 0) {
215
- const key = `${modelName}_inputTokens`;
216
- tracker.tokens[key] = (tracker.tokens[key] || 0) + textInputTokens;
217
- }
218
- if (imageInputTokens > 0) {
219
- const key = `${modelName}_imageInputTokens`;
220
- tracker.tokens[key] = (tracker.tokens[key] || 0) + imageInputTokens;
221
- }
222
- if (imageOutputTokens > 0) {
223
- const key = `${modelName}_imageOutputTokens`;
224
- tracker.tokens[key] = (tracker.tokens[key] || 0) + imageOutputTokens;
225
- }
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);
226
200
  };
227
201
  /**
228
- * Adds character-billed usage (e.g. ElevenLabs TTS) to the caller-supplied
229
- * tracker. There is no provider response to parse — billing is deterministic
230
- * from the input character count. Reads the rate from
231
- * `<model>-character-costs` (USD per million characters) and accumulates
232
- * 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.
233
205
  */
234
- const updateUsageTrackerForCharacters = (tracker, modelName, characterCount, config) => {
235
- if (!tracker || !modelName || !characterCount)
206
+ const updateUsageTrackerForCharacters = (tracker, modelName, characterCount, _config) => {
207
+ if (!modelName || !characterCount)
236
208
  return;
237
- if (typeof tracker.cost !== "number")
238
- tracker.cost = 0;
239
- if (!tracker.tokens || typeof tracker.tokens !== "object")
240
- tracker.tokens = {};
241
- const rate = getModelRate(modelName, config, "character");
242
- const addedCost = (characterCount / 1_000_000) * rate;
243
- if (Number.isFinite(addedCost) && addedCost > 0)
244
- tracker.cost += addedCost;
245
- const key = `${modelName}_characters`;
246
- tracker.tokens[key] = (tracker.tokens[key] || 0) + characterCount;
209
+ prepareTracker(tracker);
210
+ countUsage(tracker, modelName, "characters", characterCount);
247
211
  };
248
212
  /**
249
- * Adds second-billed usage (video generation) to the caller-supplied tracker.
250
- *
251
- * Video is the first output here that providers bill by DURATION rather than by
252
- * tokens or characters, so the rate constant `<model>-second-costs` is read as
253
- * USD PER SECOND — not per million, as the `input-token` and `character` rates
254
- * are. A Veo or Sora second costs cents, and a per-million reading would bill
255
- * it as effectively free.
213
+ * Counts second-billed usage (video generation): providers bill video by
214
+ * DURATION rather than by tokens or characters.
256
215
  *
257
- * The rate is per model, not per model-and-resolution, so a caller that lets a
258
- * tenant change resolution on a model whose price varies by it (sora-2-pro is
259
- * $0.30/$0.50/$0.70 at 720p/1024p/1080p) must pin the resolution its constant
260
- * 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.
261
220
  *
262
- * @param tracker - The accumulator to add to; no-ops when null
263
- * @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
264
223
  * @param seconds - Billed duration of the generated clip
265
- * @param config - Integration config carrying `<model>-second-costs`
266
224
  */
267
- const updateVideoUsageTracker = (tracker, modelName, seconds, config) => {
268
- if (!tracker || !modelName || !seconds)
225
+ const updateVideoUsageTracker = (tracker, modelName, seconds, _config) => {
226
+ if (!modelName || !seconds)
269
227
  return;
270
- if (typeof tracker.cost !== "number")
271
- tracker.cost = 0;
272
- if (!tracker.tokens || typeof tracker.tokens !== "object")
273
- tracker.tokens = {};
274
- const rate = getModelRate(modelName, config, "second");
275
- const addedCost = seconds * rate;
276
- if (Number.isFinite(addedCost) && addedCost > 0)
277
- tracker.cost += addedCost;
278
- const key = `${modelName}_seconds`;
279
- tracker.tokens[key] = (tracker.tokens[key] || 0) + seconds;
228
+ prepareTracker(tracker);
229
+ countUsage(tracker, modelName, "seconds", seconds);
280
230
  };
281
231
  /**
282
232
  * Extracts a normalized token-usage object from a single LangChain `invoke()`
@@ -537,36 +487,6 @@ const extractJSONFromResponse = (text) => {
537
487
  }
538
488
  return null;
539
489
  };
540
- /**
541
- * Maps a model name prefix to its OpenAI-compatible provider configuration. DeepSeek,
542
- * Kimi (Moonshot), and GLM (Zhipu) all expose OpenAI-compatible chat completion APIs
543
- * and can be routed through `ChatOpenAI` with a custom base URL and API key.
544
- * @param modelName - The model identifier to inspect
545
- * @param config - Configuration object containing provider-specific API keys
546
- * @returns An object with `apiKey` and `baseURL` when the model matches a known
547
- * OpenAI-compatible provider, or `null` otherwise
548
- */
549
- const getOpenAICompatibleProvider = (modelName, config) => {
550
- if (modelName.startsWith("deepseek-")) {
551
- return {
552
- apiKey: config.deepSeekAPIKey,
553
- baseURL: "https://api.deepseek.com/v1",
554
- };
555
- }
556
- if (modelName.startsWith("kimi-") || modelName.startsWith("moonshot-")) {
557
- return {
558
- apiKey: config.kimiAPIKey,
559
- baseURL: "https://api.moonshot.ai/v1",
560
- };
561
- }
562
- if (modelName.startsWith("glm-")) {
563
- return {
564
- apiKey: config.glmAPIKey,
565
- baseURL: "https://open.bigmodel.cn/api/paas/v4/",
566
- };
567
- }
568
- return null;
569
- };
570
490
  /**
571
491
  * Whether a model accepts the `temperature` sampling parameter at all. Some
572
492
  * models reject it with an HTTP 400, so it must be omitted rather than clamped:
@@ -576,7 +496,7 @@ const getOpenAICompatibleProvider = (modelName, config) => {
576
496
  * accept it.
577
497
  * - OpenAI reasoning models (the GPT-5 family and the o-series) reject it;
578
498
  * gpt-4o still accepts it.
579
- * DeepSeek, Kimi/Moonshot, GLM, and Gemini all accept temperature.
499
+ * Gemini accepts temperature.
580
500
  * @param modelName - the model identifier being routed
581
501
  * @returns `true` when the model accepts a `temperature` parameter
582
502
  */
@@ -597,18 +517,14 @@ const modelSupportsTemperature = (modelName) => {
597
517
  };
598
518
  /**
599
519
  * Per-provider valid `temperature` range, used to clamp caller-supplied values.
600
- * Anthropic, Kimi (Moonshot), and GLM (Zhipu) cap at 1.0; OpenAI, Gemini, and
601
- * DeepSeek accept up to 2.0. Only consulted for models that support temperature
602
- * at all (see `modelSupportsTemperature`).
520
+ * Anthropic caps at 1.0; OpenAI and Gemini accept up to 2.0. Only consulted for
521
+ * models that support temperature at all (see `modelSupportsTemperature`).
603
522
  */
604
523
  const getTemperatureRange = (modelName) => {
605
- if (modelName.startsWith("claude-") ||
606
- modelName.startsWith("kimi-") ||
607
- modelName.startsWith("moonshot-") ||
608
- modelName.startsWith("glm-")) {
524
+ if (modelName.startsWith("claude-")) {
609
525
  return { min: 0, max: 1 };
610
526
  }
611
- // gpt-, gemini-, deepseek-
527
+ // gpt-, gemini-
612
528
  return { min: 0, max: 2 };
613
529
  };
614
530
  /**
@@ -637,46 +553,6 @@ const resolveTemperatureSetting = (modelName, config) => {
637
553
  }
638
554
  return { temperature: clamped };
639
555
  };
640
- /**
641
- * Gemini's ceiling on `maxOutputTokens`. The API validates the field and
642
- * rejects the request outright rather than clamping it:
643
- * "Unable to submit request because it has a maxOutputTokens value of N but the
644
- * supported range is from 1 (inclusive) to 65537 (exclusive)" (HTTP 400,
645
- * INVALID_ARGUMENT).
646
- *
647
- * Set to 64000 rather than that 65536 validation ceiling: the Pro models report
648
- * a generation capacity of 64000, so 64000 is the value every model across the
649
- * Gemini 3 line (3.1 Pro, 3.5/3.7/3.8 Flash) can both accept and actually
650
- * produce. One safe constant beats a per-model table that would need editing
651
- * every time Google ships a model — and the 1536 tokens of headroom given up on
652
- * Flash are far beyond what any caller asks for.
653
- *
654
- * Deliberately Gemini-only. Anthropic and OpenAI take the caller's `maxTokens`
655
- * unchanged, since neither rejects the values configured today.
656
- */
657
- const GEMINI_MAX_OUTPUT_TOKENS = 64000;
658
- /**
659
- * Resolves `maxOutputTokens` for a Gemini model. Callers share one `maxTokens`
660
- * setting across every provider, and the other providers tolerate values far
661
- * above what Gemini accepts, so anything above Gemini's ceiling is clamped here
662
- * (with a warning) instead of being sent through to a guaranteed HTTP 400. With
663
- * no caller value the ceiling itself is used, which is what the previous
664
- * 200000 default was reaching for.
665
- * @param modelName - The Gemini model identifier being routed
666
- * @param config - Configuration object; only `maxTokens` is consulted
667
- * @returns A token count within Gemini's supported range
668
- */
669
- const resolveGeminiMaxOutputTokens = (modelName, config) => {
670
- const maxTokens = config?.maxTokens;
671
- if (typeof maxTokens !== "number" || !Number.isFinite(maxTokens)) {
672
- return GEMINI_MAX_OUTPUT_TOKENS;
673
- }
674
- if (maxTokens > GEMINI_MAX_OUTPUT_TOKENS) {
675
- logger.log(null, logger.levels.warn, `maxTokens ${maxTokens} exceeds the ${GEMINI_MAX_OUTPUT_TOKENS}-token output ceiling of "${modelName}"; clamping to ${GEMINI_MAX_OUTPUT_TOKENS}.`);
676
- return GEMINI_MAX_OUTPUT_TOKENS;
677
- }
678
- return maxTokens;
679
- };
680
556
  /**
681
557
  * Flattens LangChain message content into plain text. Content is a string for
682
558
  * ordinary completions, but providers switch to an array of blocks whenever the
@@ -704,23 +580,22 @@ const extractTextContent = (content) => {
704
580
  /**
705
581
  * Instantiates and returns the appropriate LangChain chat model based on the model
706
582
  * name prefix. `claude-` maps to `ChatAnthropic`, `gemini-` maps to
707
- * `ChatGoogleGenerativeAI`, and `gpt-` maps to `ChatOpenAI`. DeepSeek (`deepseek-`),
708
- * Kimi/Moonshot (`kimi-`, `moonshot-`), and GLM/Zhipu (`glm-`) models are routed
709
- * through `ChatOpenAI` against each provider's OpenAI-compatible endpoint. When a Zod
583
+ * `ChatGoogleGenerativeAI`, and `gpt-` maps to `ChatOpenAI`. When a Zod
710
584
  * `schema` is provided, native structured output is configured per provider: OpenAI
711
585
  * via `response_format` with `json_schema`, Anthropic via `output_config.format`, and
712
586
  * Gemini via JSON mode (`json: true`) plus `responseSchema`. In every case the model
713
587
  * emits JSON as the message text, so the caller's parse/validate pipeline is unchanged.
714
- * The schema is ignored for the OpenAI-compatible providers (DeepSeek/Kimi/GLM), which
715
- * have no native structured-output support here. Throws a `BAD_REQUEST` error for
716
- * unrecognised model names.
588
+ * Throws a `BAD_REQUEST` error for unrecognised model names.
589
+ *
590
+ * Output token limits are not caller-configurable. Anthropic requires
591
+ * `max_tokens` on every request and @langchain/anthropic defaults it to 4096,
592
+ * so the Claude branch pins it explicitly; OpenAI and Gemini are sent no limit
593
+ * and fall back to each provider's own default, which is that model's maximum.
717
594
  * @param modelName - The model identifier, e.g. `"gpt-4o"`, `"claude-3-5-sonnet"`,
718
- * `"gemini-1.5-pro"`, `"deepseek-chat"`, `"kimi-k2-0905-preview"`, `"glm-4.6"`
595
+ * `"gemini-1.5-pro"`
719
596
  * @param config - Configuration object containing API keys (`openAIAPIKey`,
720
- * `anthropicAPIKey`, `googleAPIKey`, `deepSeekAPIKey`, `kimiAPIKey`, `glmAPIKey`),
721
- * optional `maxTokens`, and optional `temperature`
597
+ * `anthropicAPIKey`, `googleAPIKey`) and optional `temperature`
722
598
  * @param schema - Optional Zod schema used to configure native structured JSON output
723
- * for GPT / Claude / Gemini models; ignored for OpenAI-compatible providers
724
599
  * @returns A configured LangChain chat model (or bound runnable) instance
725
600
  */
726
601
  // Exported (but not re-exported from index.ts / the package's public entry
@@ -731,12 +606,17 @@ export const getLLMModel = (modelName, config, schema = null) => {
731
606
  const modelSettings = resolveTemperatureSetting(modelName, config);
732
607
  // Claude models (Anthropic)
733
608
  if (modelName.startsWith("claude-")) {
609
+ // Anthropic requires `max_tokens` on every request and @langchain/anthropic
610
+ // defaults it to 4096 when omitted — low enough to truncate agentic turns
611
+ // mid-tool-call — so it is pinned here rather than left to the library.
612
+ // 64000 is Anthropic's recommended value for streamed requests and sits
613
+ // within the 128000-token output limit of every Claude model routed here.
614
+ //
734
615
  // Anthropic's SDK rejects non-streamed requests when max_tokens is large
735
- // enough that the operation could exceed 10 minutes (threshold: 21333).
736
- // Auto-enable LangChain streaming above that cutoff so `.invoke()` routes
737
- // through the streaming endpoint internally — callers see no interface
738
- // change, but the guard is bypassed.
739
- const maxTokens = config.maxTokens || 16384;
616
+ // enough that the operation could exceed 10 minutes (threshold: 21333), so
617
+ // streaming is always on: `.invoke()` routes through the streaming endpoint
618
+ // internally and callers see no interface change.
619
+ const maxTokens = 64000;
740
620
  // Native structured output: Anthropic's `output_config.format` constrains
741
621
  // decoding to the schema while still emitting JSON as the message text, so
742
622
  // the downstream streaming + parse/validate pipeline is unchanged.
@@ -757,7 +637,7 @@ export const getLLMModel = (modelName, config, schema = null) => {
757
637
  apiKey: config.anthropicAPIKey,
758
638
  maxTokens,
759
639
  modelName: modelName,
760
- streaming: maxTokens > 16384,
640
+ streaming: true,
761
641
  invocationKwargs: { cache_control: { type: "ephemeral" } },
762
642
  ...outputConfig,
763
643
  ...modelSettings,
@@ -768,7 +648,9 @@ export const getLLMModel = (modelName, config, schema = null) => {
768
648
  else if (modelName.startsWith("gemini-")) {
769
649
  const model = new ChatGoogleGenerativeAI({
770
650
  apiKey: config.googleAPIKey,
771
- maxOutputTokens: resolveGeminiMaxOutputTokens(modelName, config),
651
+ // No `maxOutputTokens`: omitting it leaves Gemini's own default, which is
652
+ // the model's maximum output. Sending a value meant tracking a per-model
653
+ // ceiling that the API rejects outright rather than clamps.
772
654
  model: modelName,
773
655
  // `json: true` forces responseMimeType=application/json, guaranteeing the
774
656
  // model returns valid JSON text (never prose) for the parse pipeline.
@@ -790,17 +672,26 @@ export const getLLMModel = (modelName, config, schema = null) => {
790
672
  else if (modelName.startsWith("gpt-")) {
791
673
  const openAISettings = {
792
674
  apiKey: config.openAIAPIKey,
793
- max_tokens: config.maxTokens || 200000,
675
+ // No output limit is sent: OpenAI defaults to the model's maximum. The
676
+ // snake_case `max_tokens` that used to sit here was never a recognised
677
+ // ChatOpenAI field and was silently dropped by LangChain.
794
678
  modelName: modelName,
795
679
  ...modelSettings,
796
680
  };
797
- // gpt-5.6's /v1/chat/completions endpoint 400s whenever function tools are
798
- // bound, because it rejects the model's own server-side default reasoning
799
- // effort in that combination ("use /v1/responses or set reasoning_effort
800
- // to 'none'"); forcing it to "none" here avoids the collision. Scoped to
801
- // exactly gpt-5.6 — sibling gpt-5.x models are not confirmed to share the
802
- // bug.
803
- const reasoningEffortOverride = modelName === "gpt-5.6" ? { reasoning_effort: "none" } : {};
681
+ // The gpt-5.6 family's /v1/chat/completions endpoint 400s whenever function
682
+ // tools are bound, because it rejects the model's own server-side default
683
+ // reasoning effort in that combination ("use /v1/responses or set
684
+ // reasoning_effort to 'none'"); forcing it to "none" here avoids the
685
+ // collision. The restriction belongs to the family, not to one id: the
686
+ // `gpt-5.6` alias resolves to `gpt-5.6-sol`, and `gpt-5.6-terra` /
687
+ // `gpt-5.6-luna` are separately addressable ids that hit the same 400, as
688
+ // do dated snapshots (`gpt-5.6-2026-08-22`). Matching the alias alone left
689
+ // every one of those unpatched, so match the whole family. Still excludes
690
+ // sibling releases (gpt-5.5, gpt-5.7): they are not confirmed to share the
691
+ // bug, and reasoning effort is worth keeping wherever it is accepted.
692
+ const reasoningEffortOverride = /^gpt-5\.6($|-)/.test(modelName)
693
+ ? { reasoning_effort: "none" }
694
+ : {};
804
695
  // Use native structured output with a JSON schema. `modelKwargs` is spread
805
696
  // verbatim into the Chat Completions request. Merge (not overwrite) with
806
697
  // the reasoning-effort override above, since both may apply at once.
@@ -823,17 +714,6 @@ export const getLLMModel = (modelName, config, schema = null) => {
823
714
  }
824
715
  return new ChatOpenAI(openAISettings);
825
716
  }
826
- // OpenAI-compatible providers: DeepSeek, Kimi (Moonshot), GLM (Zhipu)
827
- const openAICompatible = getOpenAICompatibleProvider(modelName, config);
828
- if (openAICompatible) {
829
- return new ChatOpenAI({
830
- apiKey: openAICompatible.apiKey,
831
- maxTokens: config.maxTokens || 200000,
832
- modelName: modelName,
833
- configuration: { baseURL: openAICompatible.baseURL },
834
- ...modelSettings,
835
- });
836
- }
837
717
  throw errorHandlingHelper.create(constants.HTTP_CODES.BAD_REQUEST, constants.ERROR.UNSUPPORTED_MODEL + ": " + modelName);
838
718
  };
839
719
  /**
@@ -1162,6 +1042,65 @@ const parseAndValidateJSONResponse = (rawContent, schema) => {
1162
1042
  * original base) so that across multiple retries the full failure history
1163
1043
  * accumulates and the model can avoid oscillating between previous broken outputs.
1164
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
+ };
1165
1104
  const buildValidationRetryMessages = (priorMessages, rawContent, validationError) => {
1166
1105
  const assistantContent = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent);
1167
1106
  return [
@@ -1173,6 +1112,58 @@ const buildValidationRetryMessages = (priorMessages, rawContent, validationError
1173
1112
  },
1174
1113
  ];
1175
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
+ };
1176
1167
  /**
1177
1168
  * Sends a prompt to an LLM and returns the response, with support for streaming,
1178
1169
  * agentic execution, progress reporting, JSON extraction, and Zod schema validation.
@@ -1255,65 +1246,43 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1255
1246
  // being reconciled from the response afterwards.
1256
1247
  const agent = createAgent(agentName, modelName, systemPrompt, [...tools], null, // responseFormat
1257
1248
  config, usageTracker);
1258
- // Run the agent with progress callback
1259
- const response = await runAgent(agent, userPrompt, config, onProgressReport || null, usageTracker);
1260
- // Extract content from agent response
1261
- const messages = response?.messages || [];
1262
- if (messages.length === 0) {
1263
- throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, "Agent returned no messages");
1264
- }
1265
- const lastMessage = messages[messages.length - 1];
1266
- // Flattens the array content blocks that Gemini/Claude agent responses and
1267
- // any server-tool turn (e.g. web search) return.
1268
- const rawContent = extractTextContent(lastMessage?.content);
1269
- // If not expecting JSON, return raw content directly
1270
- if (!expectsJsonResponse) {
1271
- return rawContent;
1272
- }
1273
- // Process JSON response similar to non-agentic path
1274
- if (typeof rawContent === "string") {
1275
- const trimmed = rawContent.trim();
1276
- if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
1277
- (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
1278
- try {
1279
- const parsed = JSON.parse(trimmed);
1280
- if (schema) {
1281
- validateWithSchema(parsed, schema);
1282
- }
1283
- return JSON.stringify(parsed);
1284
- }
1285
- catch (parseError) {
1286
- if (parseError?.code === constants.HTTP_CODES.UNPROCESSABLE_ENTITY) {
1287
- throw parseError;
1288
- }
1289
- }
1290
- }
1291
- // Try to extract JSON from markdown or other wrapping
1292
- const extracted = extractJSONFromResponse(rawContent);
1293
- if (extracted) {
1294
- if (schema) {
1295
- validateWithSchema(extracted, schema);
1296
- }
1297
- return JSON.stringify(extracted);
1298
- }
1299
- }
1300
- if (expectsJsonResponse) {
1301
- let preview = "";
1302
- if (typeof rawContent === "string") {
1303
- 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");
1304
1262
  }
1305
- else if (typeof rawContent === "object" && rawContent !== null) {
1306
- 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;
1307
1270
  }
1308
- else if (rawContent !== undefined && rawContent !== null) {
1309
- preview = String(rawContent).substring(0, 100);
1271
+ try {
1272
+ return parseAgenticJsonResponse(rawContent, schema);
1310
1273
  }
1311
- else {
1312
- 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;
1313
1284
  }
1314
- throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, `Agent returned non-JSON response when JSON was expected: ${preview}...`);
1315
1285
  }
1316
- return rawContent;
1317
1286
  }
1318
1287
  let messages;
1319
1288
  if (typeof prompt === "string") {
@@ -1358,13 +1327,13 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1358
1327
  else {
1359
1328
  messagesToSend = messages;
1360
1329
  }
1361
- // 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
1362
1331
  // callback — same "Generating content..." message and the progress curve
1363
1332
  // continues from the original startTime so the bar doesn't visibly reset.
1364
- // Skipped when OpenAI native response_format is in use (a 422 there is a
1365
- // server-side schema bug, not a model output issue). Each attempt accumulates
1366
- // independently into usageTracker so retry usage is still visible.
1367
- 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);
1368
1337
  const progressReportInterval = 10; // Report every N chunks
1369
1338
  const overallStartTime = Date.now();
1370
1339
  // Use a time-based asymptotic curve: progress approaches maxPercent but never
@@ -1516,9 +1485,9 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1516
1485
  else {
1517
1486
  messagesToSend = messages;
1518
1487
  }
1519
- // Retry once on schema validation failure (see streaming branch for rationale).
1488
+ // Retry on schema validation failure (see streaming branch for rationale).
1520
1489
  // Each attempt accumulates independently into usageTracker.
1521
- const canRetryOnValidation = expectsJsonResponse && !!schema && !useNativeSchema;
1490
+ const canRetryOnValidation = canRetryValidation(expectsJsonResponse, schema);
1522
1491
  let activeMessages = messagesToSend;
1523
1492
  let attempt = 0;
1524
1493
  while (true) {
@@ -2027,6 +1996,11 @@ const runPromptWithModelForVideoGeneration = async (modelName, config, prompt, o
2027
1996
  }
2028
1997
  };
2029
1998
  export default {
1999
+ canRetryValidation,
2000
+ isRetryableJsonFailure,
2001
+ buildAgenticRetryPrompt,
2002
+ parseAgenticJsonResponse,
2003
+ supportsNativeSchema,
2030
2004
  checkIfAIProviderConfigured,
2031
2005
  createAgent,
2032
2006
  runAgent,
@@ -2034,6 +2008,9 @@ export default {
2034
2008
  runPromptWithModelForImageGeneration,
2035
2009
  runPromptWithModelForVideoGeneration,
2036
2010
  throwErrorIfNotSuccessful,
2011
+ updateImageUsageTracker,
2012
+ updateUsageTracker,
2037
2013
  updateUsageTrackerForCharacters,
2014
+ updateVideoUsageTracker,
2038
2015
  validateModel,
2039
2016
  };