@stackfactor/agent-utils 1.2.10 → 1.2.13

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.
@@ -1 +1 @@
1
- {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":"AA2BA;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE;QAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;CACxC,CAAC;;0CAE2C,GAAG,KAAG,IAAI;wBA+jB/C,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,KACV,GAAG;sBA0BG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,iBACb,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;oCA4XF,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;sDAktBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,iBACE,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;0CAt/B8B,GAAG,KAAG,MAAM;+CApjB9C,YAAY,GAAG,IAAI,GAAG,SAAS,aAC7B,MAAM,kBACD,MAAM,UACd,GAAG,KACV,IAAI;mCAokBU,MAAM,mBACJ,MAAM,EAAE,KACxB,MAAM;;AA6hCT,wBASE"}
1
+ {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":"AAuGA;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE;QAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;CACxC,CAAC;;0CAE2C,GAAG,KAAG,IAAI;wBAktB/C,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,KACV,GAAG;sBA0BG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,iBACb,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;oCA4XF,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;0CA//B8B,GAAG,KAAG,MAAM;+CAprB9C,YAAY,GAAG,IAAI,GAAG,SAAS,aAC7B,MAAM,kBACD,MAAM,UACd,GAAG,KACV,IAAI;mCAosBU,MAAM,mBACJ,MAAM,EAAE,KACxB,MAAM;;AAsiCT,wBASE"}
@@ -12,7 +12,8 @@ const const_js_1 = __importDefault(require("./const.js"));
12
12
  const langchain_1 = require("langchain");
13
13
  const errorHandling_js_1 = __importDefault(require("./errorHandling.js"));
14
14
  const logger_js_1 = __importDefault(require("./logger.js"));
15
- const zod_to_json_schema_1 = require("zod-to-json-schema");
15
+ const zod_1 = require("zod");
16
+ const transform_json_schema_1 = require("@anthropic-ai/sdk/lib/transform-json-schema");
16
17
  const runtimeContext_js_1 = require("./runtimeContext.js");
17
18
  const JSON_ESCAPE_INSTRUCTION = `
18
19
  CRITICAL - Your response must be valid JSON. Escape ALL special characters in string values:
@@ -23,6 +24,74 @@ CRITICAL - Your response must be valid JSON. Escape ALL special characters in st
23
24
  - Backslashes → \\\\
24
25
  Do NOT include raw newlines, tabs, or unescaped quotes inside JSON string values.
25
26
  `.trim();
27
+ /**
28
+ * Providers for which `getLLMModel` configures native, schema-constrained JSON
29
+ * output (OpenAI `response_format`, Anthropic `output_config.format`, Gemini
30
+ * `responseSchema` + JSON mode). For these the model emits JSON directly, so the
31
+ * prompt-injected schema and the schema-validation retry are skipped. Other
32
+ * providers (DeepSeek/Kimi/GLM via the OpenAI-compatible shim) have no native
33
+ * support here and continue to rely on prompt instructions + validation.
34
+ * @param modelName - The model identifier being routed
35
+ * @returns `true` when native structured output is wired up for the provider
36
+ */
37
+ const supportsNativeSchema = (modelName) => modelName.startsWith("gpt-") ||
38
+ modelName.startsWith("claude-") ||
39
+ modelName.startsWith("gemini-");
40
+ /**
41
+ * Converts a Zod v4 schema to a JSON Schema using zod's built-in
42
+ * `z.toJSONSchema`. The classic `zod-to-json-schema` package only understands
43
+ * zod v3 and silently returns `{}` for v4 schemas, which would send empty
44
+ * schemas to every provider. Targets OpenAPI 3.0 — the dialect omits the
45
+ * `$schema` keyword and is the widest-compatible across providers — and inlines
46
+ * reused subschemas so the result carries no `$ref`/`$defs` (which Gemini's
47
+ * `responseSchema` rejects).
48
+ * @param schema - A Zod schema
49
+ * @returns The equivalent JSON Schema object
50
+ */
51
+ const buildJsonSchema = (schema) => zod_1.z.toJSONSchema(schema, { target: "openapi-3.0", reused: "inline" });
52
+ /**
53
+ * Recursively enforces OpenAI strict structured-output rules on a JSON Schema:
54
+ * every object gets `additionalProperties: false` and lists all of its
55
+ * properties in `required`. OpenAI's `strict: true` mode rejects optional
56
+ * properties, so all fields are marked required (matching OpenAI's own schema
57
+ * transform). Mutates and returns the schema.
58
+ * @param s - A JSON Schema object (mutated in place)
59
+ * @returns The same object, made strict-compatible
60
+ */
61
+ const strictifyJsonSchema = (s) => {
62
+ if (!s || typeof s !== "object")
63
+ return s;
64
+ if (s.type === "object" && s.properties) {
65
+ s.additionalProperties = false;
66
+ s.required = Object.keys(s.properties);
67
+ for (const key of Object.keys(s.properties)) {
68
+ strictifyJsonSchema(s.properties[key]);
69
+ }
70
+ }
71
+ if (s.type === "array" && s.items)
72
+ strictifyJsonSchema(s.items);
73
+ return s;
74
+ };
75
+ /**
76
+ * Recursively removes JSON Schema keywords that Google Gemini's `responseSchema`
77
+ * (an OpenAPI 3.0 subset) rejects — notably `additionalProperties`, which the
78
+ * API returns a 400 for. Mutates and returns the schema.
79
+ * @param s - A JSON Schema object (mutated in place)
80
+ * @returns The same object, accepted by Gemini's `responseSchema`
81
+ */
82
+ const sanitizeGeminiSchema = (s) => {
83
+ if (!s || typeof s !== "object")
84
+ return s;
85
+ delete s.additionalProperties;
86
+ if (s.properties) {
87
+ for (const key of Object.keys(s.properties)) {
88
+ sanitizeGeminiSchema(s.properties[key]);
89
+ }
90
+ }
91
+ if (s.items)
92
+ sanitizeGeminiSchema(s.items);
93
+ return s;
94
+ };
26
95
  const checkIfAIProviderConfigured = (config) => {
27
96
  if (!config ||
28
97
  !config.openAIAPIKey ||
@@ -56,7 +125,19 @@ const updateUsageTracker = (tracker, modelName, usage, config) => {
56
125
  if (!tracker.tokens || typeof tracker.tokens !== "object")
57
126
  tracker.tokens = {};
58
127
  const inputTokens = usage.input_tokens || 0;
59
- const outputTokens = usage.output_tokens || 0;
128
+ const visibleOutputTokens = usage.output_tokens || 0;
129
+ const totalTokens = usage.total_tokens || 0;
130
+ // Recover reasoning/"thinking" tokens the provider leaves out of
131
+ // `output_tokens`. Gemini reports its thoughts only in `totalTokenCount`, so
132
+ // `total - input - visibleOutput` is the thinking output the caller was still
133
+ // billed for. Providers that already fold reasoning into `output_tokens`
134
+ // (Anthropic, OpenAI) report `total == input + output`, so this adds 0.
135
+ // Reasoning is charged at the output rate, so we treat it as output for both
136
+ // the token counters and the cost.
137
+ const reasoningTokens = totalTokens > 0
138
+ ? Math.max(0, totalTokens - inputTokens - visibleOutputTokens)
139
+ : 0;
140
+ const outputTokens = visibleOutputTokens + reasoningTokens;
60
141
  const inputRate = getModelRate(modelName, config, "input-token");
61
142
  const outputRate = getModelRate(modelName, config, "output-token");
62
143
  const addedCost = (inputTokens / 1_000_000) * inputRate +
@@ -122,7 +203,12 @@ const updateImageUsageTracker = (tracker, modelName, response, config, provider)
122
203
  imageOutputTokens = Number(um.candidatesTokenCount) || 0;
123
204
  }
124
205
  const textInputRate = getModelRate(modelName, config, "input-token");
125
- const imageInputRate = getModelRate(modelName, config, "image-input-token");
206
+ // Reference-image input tokens are billed at the model's input rate. Configs
207
+ // that don't define a dedicated `<model>-image-input-token-costs` (the common
208
+ // case — image models charge all input at one rate and only differ on output)
209
+ // fall back to the text input rate rather than silently billing image input
210
+ // at $0.
211
+ const imageInputRate = getModelRate(modelName, config, "image-input-token") || textInputRate;
126
212
  const imageOutputRate = getModelRate(modelName, config, "image-output-token");
127
213
  const addedCost = (textInputTokens / 1_000_000) * textInputRate +
128
214
  (imageInputTokens / 1_000_000) * imageInputRate +
@@ -176,6 +262,7 @@ const extractUsageFromInvoke = (response) => {
176
262
  return {
177
263
  input_tokens: um.input_tokens || 0,
178
264
  output_tokens: um.output_tokens || 0,
265
+ total_tokens: um.total_tokens || 0,
179
266
  };
180
267
  }
181
268
  const rm = response.response_metadata;
@@ -183,12 +270,14 @@ const extractUsageFromInvoke = (response) => {
183
270
  return {
184
271
  input_tokens: rm.usage.input_tokens || rm.usage.prompt_tokens || 0,
185
272
  output_tokens: rm.usage.output_tokens || rm.usage.completion_tokens || 0,
273
+ total_tokens: rm.usage.total_tokens || rm.usage.total_token_count || 0,
186
274
  };
187
275
  }
188
276
  if (rm?.tokenUsage) {
189
277
  return {
190
278
  input_tokens: rm.tokenUsage.promptTokens || 0,
191
279
  output_tokens: rm.tokenUsage.completionTokens || 0,
280
+ total_tokens: rm.tokenUsage.totalTokens || 0,
192
281
  };
193
282
  }
194
283
  return null;
@@ -205,6 +294,7 @@ const accumulateChunkUsage = (acc, chunk) => {
205
294
  if (um) {
206
295
  acc.input_tokens += um.input_tokens || 0;
207
296
  acc.output_tokens += um.output_tokens || 0;
297
+ acc.total_tokens += um.total_tokens || 0;
208
298
  return acc;
209
299
  }
210
300
  const rm = chunk.response_metadata;
@@ -212,6 +302,8 @@ const accumulateChunkUsage = (acc, chunk) => {
212
302
  acc.input_tokens += rm.usage.input_tokens || rm.usage.prompt_tokens || 0;
213
303
  acc.output_tokens +=
214
304
  rm.usage.output_tokens || rm.usage.completion_tokens || 0;
305
+ acc.total_tokens +=
306
+ rm.usage.total_tokens || rm.usage.total_token_count || 0;
215
307
  }
216
308
  return acc;
217
309
  };
@@ -224,13 +316,18 @@ const sumAgentResponseUsage = (response) => {
224
316
  const messages = response?.messages;
225
317
  if (!Array.isArray(messages) || messages.length === 0)
226
318
  return null;
227
- const total = { input_tokens: 0, output_tokens: 0 };
319
+ const total = {
320
+ input_tokens: 0,
321
+ output_tokens: 0,
322
+ total_tokens: 0,
323
+ };
228
324
  let found = false;
229
325
  for (const msg of messages) {
230
326
  const um = msg?.usage_metadata;
231
327
  if (um) {
232
328
  total.input_tokens += um.input_tokens || 0;
233
329
  total.output_tokens += um.output_tokens || 0;
330
+ total.total_tokens += um.total_tokens || 0;
234
331
  found = true;
235
332
  }
236
333
  }
@@ -441,28 +538,102 @@ const getOpenAICompatibleProvider = (modelName, config) => {
441
538
  }
442
539
  return null;
443
540
  };
541
+ /**
542
+ * Whether a model accepts the `temperature` sampling parameter at all. Some
543
+ * models reject it with an HTTP 400, so it must be omitted rather than clamped:
544
+ * - Anthropic removed sampling params (temperature/top_p/top_k) on Claude
545
+ * Opus 4.7 and later (Opus 4.7/4.8) and on the 5-series (Sonnet 5, Fable 5,
546
+ * Mythos 5). Opus 4.6, Sonnet 4.6, claude-3-5-sonnet, and Haiku 4.5 still
547
+ * accept it.
548
+ * - OpenAI reasoning models (the GPT-5 family and the o-series) reject it;
549
+ * gpt-4o still accepts it.
550
+ * DeepSeek, Kimi/Moonshot, GLM, and Gemini all accept temperature.
551
+ * @param modelName - the model identifier being routed
552
+ * @returns `true` when the model accepts a `temperature` parameter
553
+ */
554
+ const modelSupportsTemperature = (modelName) => {
555
+ if (!modelName)
556
+ return false;
557
+ // OpenAI reasoning families reject sampling params (gpt-5*, o1/o3/...).
558
+ if (/^gpt-5/.test(modelName) || /^o\d/.test(modelName))
559
+ return false;
560
+ // Anthropic dropped sampling params on Opus 4.7+ and the 5-series.
561
+ if (/^claude-opus-4-(7|8|9|\d\d)\b/.test(modelName))
562
+ return false;
563
+ if (/^claude-(sonnet|opus|haiku)-5\b/.test(modelName))
564
+ return false;
565
+ if (/^claude-(fable|mythos)-5\b/.test(modelName))
566
+ return false;
567
+ return true;
568
+ };
569
+ /**
570
+ * Per-provider valid `temperature` range, used to clamp caller-supplied values.
571
+ * Anthropic, Kimi (Moonshot), and GLM (Zhipu) cap at 1.0; OpenAI, Gemini, and
572
+ * DeepSeek accept up to 2.0. Only consulted for models that support temperature
573
+ * at all (see `modelSupportsTemperature`).
574
+ */
575
+ const getTemperatureRange = (modelName) => {
576
+ if (modelName.startsWith("claude-") ||
577
+ modelName.startsWith("kimi-") ||
578
+ modelName.startsWith("moonshot-") ||
579
+ modelName.startsWith("glm-")) {
580
+ return { min: 0, max: 1 };
581
+ }
582
+ // gpt-, gemini-, deepseek-
583
+ return { min: 0, max: 2 };
584
+ };
585
+ /**
586
+ * Resolves the `temperature` model setting for a given model. Applied in order:
587
+ * 1. presence/type — only a finite number is forwarded (`temperature: 0` is
588
+ * valid; `undefined`/`null`/non-numeric leaves the provider default);
589
+ * 2. support — models that reject the parameter (Opus 4.7+, GPT-5/o-series)
590
+ * get it omitted, with a warning, to avoid an HTTP 400;
591
+ * 3. range — values outside the provider's range are clamped, with a warning.
592
+ * Returns an object to spread into the LangChain model settings (`{}` when the
593
+ * field should be omitted).
594
+ */
595
+ const resolveTemperatureSetting = (modelName, config) => {
596
+ const temperature = config?.temperature;
597
+ if (typeof temperature !== "number" || !Number.isFinite(temperature)) {
598
+ return {};
599
+ }
600
+ if (!modelSupportsTemperature(modelName)) {
601
+ logger_js_1.default.log(null, logger_js_1.default.levels.warn, `Model "${modelName}" does not accept a temperature parameter; ignoring configured temperature ${temperature}.`);
602
+ return {};
603
+ }
604
+ const { min, max } = getTemperatureRange(modelName);
605
+ const clamped = Math.min(Math.max(temperature, min), max);
606
+ if (clamped !== temperature) {
607
+ logger_js_1.default.log(null, logger_js_1.default.levels.warn, `Temperature ${temperature} is outside the supported range [${min}, ${max}] for "${modelName}"; clamping to ${clamped}.`);
608
+ }
609
+ return { temperature: clamped };
610
+ };
444
611
  /**
445
612
  * Instantiates and returns the appropriate LangChain chat model based on the model
446
613
  * name prefix. `claude-` maps to `ChatAnthropic`, `gemini-` maps to
447
614
  * `ChatGoogleGenerativeAI`, and `gpt-` maps to `ChatOpenAI`. DeepSeek (`deepseek-`),
448
615
  * Kimi/Moonshot (`kimi-`, `moonshot-`), and GLM/Zhipu (`glm-`) models are routed
449
616
  * through `ChatOpenAI` against each provider's OpenAI-compatible endpoint. When a Zod
450
- * `schema` is provided for a GPT model, native `response_format` with `json_schema` is
451
- * configured for structured output. Throws a `BAD_REQUEST` error for unrecognised
452
- * model names.
617
+ * `schema` is provided, native structured output is configured per provider: OpenAI
618
+ * via `response_format` with `json_schema`, Anthropic via `output_config.format`, and
619
+ * Gemini via JSON mode (`json: true`) plus `responseSchema`. In every case the model
620
+ * emits JSON as the message text, so the caller's parse/validate pipeline is unchanged.
621
+ * The schema is ignored for the OpenAI-compatible providers (DeepSeek/Kimi/GLM), which
622
+ * have no native structured-output support here. Throws a `BAD_REQUEST` error for
623
+ * unrecognised model names.
453
624
  * @param modelName - The model identifier, e.g. `"gpt-4o"`, `"claude-3-5-sonnet"`,
454
625
  * `"gemini-1.5-pro"`, `"deepseek-chat"`, `"kimi-k2-0905-preview"`, `"glm-4.6"`
455
626
  * @param config - Configuration object containing API keys (`openAIAPIKey`,
456
627
  * `anthropicAPIKey`, `googleAPIKey`, `deepSeekAPIKey`, `kimiAPIKey`, `glmAPIKey`),
457
628
  * optional `maxTokens`, and optional `temperature`
458
- * @param schema - Optional Zod schema used to configure structured JSON output for
459
- * OpenAI GPT models via `response_format`; ignored for other providers
460
- * @returns A configured LangChain chat model instance
629
+ * @param schema - Optional Zod schema used to configure native structured JSON output
630
+ * for GPT / Claude / Gemini models; ignored for OpenAI-compatible providers
631
+ * @returns A configured LangChain chat model (or bound runnable) instance
461
632
  */
462
633
  const getLLMModel = (modelName, config, schema = null) => {
463
- const modelSettings = {
464
- ...(config.temperature ? { temperature: config.temperature } : {}),
465
- };
634
+ // Resolve `temperature` with presence/support/range handling (see
635
+ // resolveTemperatureSetting). Applied uniformly to every provider below.
636
+ const modelSettings = resolveTemperatureSetting(modelName, config);
466
637
  // Claude models (Anthropic)
467
638
  if (modelName.startsWith("claude-")) {
468
639
  // Anthropic's SDK rejects non-streamed requests when max_tokens is large
@@ -471,23 +642,53 @@ const getLLMModel = (modelName, config, schema = null) => {
471
642
  // through the streaming endpoint internally — callers see no interface
472
643
  // change, but the guard is bypassed.
473
644
  const maxTokens = config.maxTokens || 16384;
645
+ // Native structured output: Anthropic's `output_config.format` constrains
646
+ // decoding to the schema while still emitting JSON as the message text, so
647
+ // the downstream streaming + parse/validate pipeline is unchanged.
648
+ // `transformJSONSchema` applies the same strict-schema normalisation
649
+ // (additionalProperties: false, required fields) that @langchain/anthropic
650
+ // uses internally, which the API requires.
651
+ const outputConfig = schema
652
+ ? {
653
+ outputConfig: {
654
+ format: {
655
+ type: "json_schema",
656
+ schema: (0, transform_json_schema_1.transformJSONSchema)(zod_1.z.toJSONSchema(schema)),
657
+ },
658
+ },
659
+ }
660
+ : {};
474
661
  return new anthropic_1.ChatAnthropic({
475
662
  apiKey: config.anthropicAPIKey,
476
663
  maxTokens,
477
664
  modelName: modelName,
478
665
  streaming: maxTokens > 16384,
479
666
  invocationKwargs: { cache_control: { type: "ephemeral" } },
667
+ ...outputConfig,
480
668
  ...modelSettings,
481
669
  });
482
670
  }
483
671
  // Gemini models (Google)
484
672
  else if (modelName.startsWith("gemini-")) {
485
- return new google_genai_1.ChatGoogleGenerativeAI({
673
+ const model = new google_genai_1.ChatGoogleGenerativeAI({
486
674
  apiKey: config.googleAPIKey,
487
675
  maxOutputTokens: config.maxTokens || 200000,
488
676
  model: modelName,
677
+ // `json: true` forces responseMimeType=application/json, guaranteeing the
678
+ // model returns valid JSON text (never prose) for the parse pipeline.
679
+ ...(schema ? { json: true } : {}),
489
680
  ...modelSettings,
490
681
  });
682
+ // `responseSchema` additionally constrains the output shape. It is a
683
+ // call-time option (not a constructor field), so it is bound onto the model
684
+ // via `withConfig`. Gemini's schema is an OpenAPI 3.0 subset, so
685
+ // `additionalProperties` is stripped. Output stays text, so the
686
+ // parse/validate pipeline is unchanged.
687
+ if (schema) {
688
+ const jsonSchema = sanitizeGeminiSchema(buildJsonSchema(schema));
689
+ return model.withConfig({ responseSchema: jsonSchema });
690
+ }
691
+ return model;
491
692
  }
492
693
  // GPT models (OpenAI)
493
694
  else if (modelName.startsWith("gpt-")) {
@@ -499,7 +700,7 @@ const getLLMModel = (modelName, config, schema = null) => {
499
700
  };
500
701
  // Use native response_format with JSON schema for structured output
501
702
  if (schema) {
502
- const jsonSchema = (0, zod_to_json_schema_1.zodToJsonSchema)(schema, { target: "openApi3" });
703
+ const jsonSchema = strictifyJsonSchema(buildJsonSchema(schema));
503
704
  openAISettings.modelKwargs = {
504
705
  response_format: {
505
706
  type: "json_schema",
@@ -999,7 +1200,7 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
999
1200
  }
1000
1201
  if (onProgressReport) {
1001
1202
  // Streaming mode: use server-side chunk-based progress for all models
1002
- const useNativeSchema = expectsJsonResponse && schema && modelName.startsWith("gpt-");
1203
+ const useNativeSchema = expectsJsonResponse && !!schema && supportsNativeSchema(modelName);
1003
1204
  const llm = getLLMModel(modelName, config, useNativeSchema ? schema : null);
1004
1205
  // Build messages with JSON instructions if needed
1005
1206
  let messagesToSend;
@@ -1008,7 +1209,7 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1008
1209
  ? "Respond with valid JSON."
1009
1210
  : JSON_ESCAPE_INSTRUCTION;
1010
1211
  if (schema && !useNativeSchema) {
1011
- const jsonSchema = (0, zod_to_json_schema_1.zodToJsonSchema)(schema, { target: "openApi3" });
1212
+ const jsonSchema = buildJsonSchema(schema);
1012
1213
  systemContent += `\n\nYour response MUST conform to this JSON schema:\n${JSON.stringify(jsonSchema, null, 2)}`;
1013
1214
  }
1014
1215
  if (messages.length > 0 && messages[0].role === "system") {
@@ -1054,7 +1255,11 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1054
1255
  while (true) {
1055
1256
  let rawContent = "";
1056
1257
  let chunkCount = 0;
1057
- let streamUsage = { input_tokens: 0, output_tokens: 0 };
1258
+ let streamUsage = {
1259
+ input_tokens: 0,
1260
+ output_tokens: 0,
1261
+ total_tokens: 0,
1262
+ };
1058
1263
  // Inner loop: wait + retry on 429 around stream setup and consumption.
1059
1264
  // Usage is only recorded on a successful stream — partial streams that
1060
1265
  // error out with a rate limit are not counted. A 429 fired mid-stream
@@ -1063,7 +1268,7 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1063
1268
  while (true) {
1064
1269
  rawContent = "";
1065
1270
  chunkCount = 0;
1066
- streamUsage = { input_tokens: 0, output_tokens: 0 };
1271
+ streamUsage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
1067
1272
  try {
1068
1273
  // Honour caller cancellation: passing the signal tears down the
1069
1274
  // upstream HTTP request so a cancelled call stops billing tokens.
@@ -1142,17 +1347,22 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1142
1347
  }
1143
1348
  }
1144
1349
  else {
1145
- // Non-streaming mode: use native response_format for OpenAI when schema is provided
1146
- const useNativeSchema = expectsJsonResponse && schema && modelName.startsWith("gpt-");
1350
+ // Non-streaming mode: use native structured output when the provider
1351
+ // supports it (OpenAI/Anthropic/Gemini); otherwise fall back to prompt
1352
+ // instructions + validation retry.
1353
+ const useNativeSchema = expectsJsonResponse && !!schema && supportsNativeSchema(modelName);
1147
1354
  const llm = getLLMModel(modelName, config, useNativeSchema ? schema : null);
1148
- // Add escape instruction to help LLM produce valid JSON (only if expecting JSON)
1149
- // For non-OpenAI models with schema, also include schema in prompt as fallback
1355
+ // Add escape instruction to help LLM produce valid JSON (only if expecting JSON).
1356
+ // When native structured output is in use the model is already constrained,
1357
+ // so a light instruction suffices and the schema is not re-injected.
1150
1358
  let messagesToSend;
1151
1359
  if (expectsJsonResponse) {
1152
- let systemContent = JSON_ESCAPE_INSTRUCTION;
1153
- // Include schema in prompt for non-OpenAI models (OpenAI uses native response_format)
1154
- if (schema && !modelName.startsWith("gpt-")) {
1155
- const jsonSchema = (0, zod_to_json_schema_1.zodToJsonSchema)(schema, { target: "openApi3" });
1360
+ let systemContent = useNativeSchema
1361
+ ? "Respond with valid JSON."
1362
+ : JSON_ESCAPE_INSTRUCTION;
1363
+ // Include the schema in the prompt only when not using native output.
1364
+ if (schema && !useNativeSchema) {
1365
+ const jsonSchema = buildJsonSchema(schema);
1156
1366
  systemContent += `\n\nYour response MUST conform to this JSON schema:\n${JSON.stringify(jsonSchema, null, 2)}`;
1157
1367
  }
1158
1368
  if (messages.length > 0 && messages[0].role === "system") {
@@ -1 +1 @@
1
- {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":"AA2BA;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE;QAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;CACxC,CAAC;;0CAE2C,GAAG,KAAG,IAAI;wBA+jB/C,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,KACV,GAAG;sBA0BG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,iBACb,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;oCA4XF,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;sDAktBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,iBACE,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;0CAt/B8B,GAAG,KAAG,MAAM;+CApjB9C,YAAY,GAAG,IAAI,GAAG,SAAS,aAC7B,MAAM,kBACD,MAAM,UACd,GAAG,KACV,IAAI;mCAokBU,MAAM,mBACJ,MAAM,EAAE,KACxB,MAAM;;AA6hCT,wBASE"}
1
+ {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":"AAuGA;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE;QAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;CACxC,CAAC;;0CAE2C,GAAG,KAAG,IAAI;wBAktB/C,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,KACV,GAAG;sBA0BG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,iBACb,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;oCA4XF,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;0CA//B8B,GAAG,KAAG,MAAM;+CAprB9C,YAAY,GAAG,IAAI,GAAG,SAAS,aAC7B,MAAM,kBACD,MAAM,UACd,GAAG,KACV,IAAI;mCAosBU,MAAM,mBACJ,MAAM,EAAE,KACxB,MAAM;;AAsiCT,wBASE"}
@@ -7,7 +7,8 @@ import constants from "./const.js";
7
7
  import { createAgent as createLangChainAgent } from "langchain";
8
8
  import errorHandlingHelper from "./errorHandling.js";
9
9
  import logger from "./logger.js";
10
- import { zodToJsonSchema } from "zod-to-json-schema";
10
+ import { z } from "zod";
11
+ import { transformJSONSchema } from "@anthropic-ai/sdk/lib/transform-json-schema";
11
12
  import { getAbortSignal } from "./runtimeContext.js";
12
13
  const JSON_ESCAPE_INSTRUCTION = `
13
14
  CRITICAL - Your response must be valid JSON. Escape ALL special characters in string values:
@@ -18,6 +19,74 @@ CRITICAL - Your response must be valid JSON. Escape ALL special characters in st
18
19
  - Backslashes → \\\\
19
20
  Do NOT include raw newlines, tabs, or unescaped quotes inside JSON string values.
20
21
  `.trim();
22
+ /**
23
+ * Providers for which `getLLMModel` configures native, schema-constrained JSON
24
+ * output (OpenAI `response_format`, Anthropic `output_config.format`, Gemini
25
+ * `responseSchema` + JSON mode). For these the model emits JSON directly, so the
26
+ * prompt-injected schema and the schema-validation retry are skipped. Other
27
+ * providers (DeepSeek/Kimi/GLM via the OpenAI-compatible shim) have no native
28
+ * support here and continue to rely on prompt instructions + validation.
29
+ * @param modelName - The model identifier being routed
30
+ * @returns `true` when native structured output is wired up for the provider
31
+ */
32
+ const supportsNativeSchema = (modelName) => modelName.startsWith("gpt-") ||
33
+ modelName.startsWith("claude-") ||
34
+ modelName.startsWith("gemini-");
35
+ /**
36
+ * Converts a Zod v4 schema to a JSON Schema using zod's built-in
37
+ * `z.toJSONSchema`. The classic `zod-to-json-schema` package only understands
38
+ * zod v3 and silently returns `{}` for v4 schemas, which would send empty
39
+ * schemas to every provider. Targets OpenAPI 3.0 — the dialect omits the
40
+ * `$schema` keyword and is the widest-compatible across providers — and inlines
41
+ * reused subschemas so the result carries no `$ref`/`$defs` (which Gemini's
42
+ * `responseSchema` rejects).
43
+ * @param schema - A Zod schema
44
+ * @returns The equivalent JSON Schema object
45
+ */
46
+ const buildJsonSchema = (schema) => z.toJSONSchema(schema, { target: "openapi-3.0", reused: "inline" });
47
+ /**
48
+ * Recursively enforces OpenAI strict structured-output rules on a JSON Schema:
49
+ * every object gets `additionalProperties: false` and lists all of its
50
+ * properties in `required`. OpenAI's `strict: true` mode rejects optional
51
+ * properties, so all fields are marked required (matching OpenAI's own schema
52
+ * transform). Mutates and returns the schema.
53
+ * @param s - A JSON Schema object (mutated in place)
54
+ * @returns The same object, made strict-compatible
55
+ */
56
+ const strictifyJsonSchema = (s) => {
57
+ if (!s || typeof s !== "object")
58
+ return s;
59
+ if (s.type === "object" && s.properties) {
60
+ s.additionalProperties = false;
61
+ s.required = Object.keys(s.properties);
62
+ for (const key of Object.keys(s.properties)) {
63
+ strictifyJsonSchema(s.properties[key]);
64
+ }
65
+ }
66
+ if (s.type === "array" && s.items)
67
+ strictifyJsonSchema(s.items);
68
+ return s;
69
+ };
70
+ /**
71
+ * Recursively removes JSON Schema keywords that Google Gemini's `responseSchema`
72
+ * (an OpenAPI 3.0 subset) rejects — notably `additionalProperties`, which the
73
+ * API returns a 400 for. Mutates and returns the schema.
74
+ * @param s - A JSON Schema object (mutated in place)
75
+ * @returns The same object, accepted by Gemini's `responseSchema`
76
+ */
77
+ const sanitizeGeminiSchema = (s) => {
78
+ if (!s || typeof s !== "object")
79
+ return s;
80
+ delete s.additionalProperties;
81
+ if (s.properties) {
82
+ for (const key of Object.keys(s.properties)) {
83
+ sanitizeGeminiSchema(s.properties[key]);
84
+ }
85
+ }
86
+ if (s.items)
87
+ sanitizeGeminiSchema(s.items);
88
+ return s;
89
+ };
21
90
  const checkIfAIProviderConfigured = (config) => {
22
91
  if (!config ||
23
92
  !config.openAIAPIKey ||
@@ -51,7 +120,19 @@ const updateUsageTracker = (tracker, modelName, usage, config) => {
51
120
  if (!tracker.tokens || typeof tracker.tokens !== "object")
52
121
  tracker.tokens = {};
53
122
  const inputTokens = usage.input_tokens || 0;
54
- const outputTokens = usage.output_tokens || 0;
123
+ const visibleOutputTokens = usage.output_tokens || 0;
124
+ const totalTokens = usage.total_tokens || 0;
125
+ // Recover reasoning/"thinking" tokens the provider leaves out of
126
+ // `output_tokens`. Gemini reports its thoughts only in `totalTokenCount`, so
127
+ // `total - input - visibleOutput` is the thinking output the caller was still
128
+ // billed for. Providers that already fold reasoning into `output_tokens`
129
+ // (Anthropic, OpenAI) report `total == input + output`, so this adds 0.
130
+ // Reasoning is charged at the output rate, so we treat it as output for both
131
+ // the token counters and the cost.
132
+ const reasoningTokens = totalTokens > 0
133
+ ? Math.max(0, totalTokens - inputTokens - visibleOutputTokens)
134
+ : 0;
135
+ const outputTokens = visibleOutputTokens + reasoningTokens;
55
136
  const inputRate = getModelRate(modelName, config, "input-token");
56
137
  const outputRate = getModelRate(modelName, config, "output-token");
57
138
  const addedCost = (inputTokens / 1_000_000) * inputRate +
@@ -117,7 +198,12 @@ const updateImageUsageTracker = (tracker, modelName, response, config, provider)
117
198
  imageOutputTokens = Number(um.candidatesTokenCount) || 0;
118
199
  }
119
200
  const textInputRate = getModelRate(modelName, config, "input-token");
120
- const imageInputRate = getModelRate(modelName, config, "image-input-token");
201
+ // Reference-image input tokens are billed at the model's input rate. Configs
202
+ // that don't define a dedicated `<model>-image-input-token-costs` (the common
203
+ // case — image models charge all input at one rate and only differ on output)
204
+ // fall back to the text input rate rather than silently billing image input
205
+ // at $0.
206
+ const imageInputRate = getModelRate(modelName, config, "image-input-token") || textInputRate;
121
207
  const imageOutputRate = getModelRate(modelName, config, "image-output-token");
122
208
  const addedCost = (textInputTokens / 1_000_000) * textInputRate +
123
209
  (imageInputTokens / 1_000_000) * imageInputRate +
@@ -171,6 +257,7 @@ const extractUsageFromInvoke = (response) => {
171
257
  return {
172
258
  input_tokens: um.input_tokens || 0,
173
259
  output_tokens: um.output_tokens || 0,
260
+ total_tokens: um.total_tokens || 0,
174
261
  };
175
262
  }
176
263
  const rm = response.response_metadata;
@@ -178,12 +265,14 @@ const extractUsageFromInvoke = (response) => {
178
265
  return {
179
266
  input_tokens: rm.usage.input_tokens || rm.usage.prompt_tokens || 0,
180
267
  output_tokens: rm.usage.output_tokens || rm.usage.completion_tokens || 0,
268
+ total_tokens: rm.usage.total_tokens || rm.usage.total_token_count || 0,
181
269
  };
182
270
  }
183
271
  if (rm?.tokenUsage) {
184
272
  return {
185
273
  input_tokens: rm.tokenUsage.promptTokens || 0,
186
274
  output_tokens: rm.tokenUsage.completionTokens || 0,
275
+ total_tokens: rm.tokenUsage.totalTokens || 0,
187
276
  };
188
277
  }
189
278
  return null;
@@ -200,6 +289,7 @@ const accumulateChunkUsage = (acc, chunk) => {
200
289
  if (um) {
201
290
  acc.input_tokens += um.input_tokens || 0;
202
291
  acc.output_tokens += um.output_tokens || 0;
292
+ acc.total_tokens += um.total_tokens || 0;
203
293
  return acc;
204
294
  }
205
295
  const rm = chunk.response_metadata;
@@ -207,6 +297,8 @@ const accumulateChunkUsage = (acc, chunk) => {
207
297
  acc.input_tokens += rm.usage.input_tokens || rm.usage.prompt_tokens || 0;
208
298
  acc.output_tokens +=
209
299
  rm.usage.output_tokens || rm.usage.completion_tokens || 0;
300
+ acc.total_tokens +=
301
+ rm.usage.total_tokens || rm.usage.total_token_count || 0;
210
302
  }
211
303
  return acc;
212
304
  };
@@ -219,13 +311,18 @@ const sumAgentResponseUsage = (response) => {
219
311
  const messages = response?.messages;
220
312
  if (!Array.isArray(messages) || messages.length === 0)
221
313
  return null;
222
- const total = { input_tokens: 0, output_tokens: 0 };
314
+ const total = {
315
+ input_tokens: 0,
316
+ output_tokens: 0,
317
+ total_tokens: 0,
318
+ };
223
319
  let found = false;
224
320
  for (const msg of messages) {
225
321
  const um = msg?.usage_metadata;
226
322
  if (um) {
227
323
  total.input_tokens += um.input_tokens || 0;
228
324
  total.output_tokens += um.output_tokens || 0;
325
+ total.total_tokens += um.total_tokens || 0;
229
326
  found = true;
230
327
  }
231
328
  }
@@ -436,28 +533,102 @@ const getOpenAICompatibleProvider = (modelName, config) => {
436
533
  }
437
534
  return null;
438
535
  };
536
+ /**
537
+ * Whether a model accepts the `temperature` sampling parameter at all. Some
538
+ * models reject it with an HTTP 400, so it must be omitted rather than clamped:
539
+ * - Anthropic removed sampling params (temperature/top_p/top_k) on Claude
540
+ * Opus 4.7 and later (Opus 4.7/4.8) and on the 5-series (Sonnet 5, Fable 5,
541
+ * Mythos 5). Opus 4.6, Sonnet 4.6, claude-3-5-sonnet, and Haiku 4.5 still
542
+ * accept it.
543
+ * - OpenAI reasoning models (the GPT-5 family and the o-series) reject it;
544
+ * gpt-4o still accepts it.
545
+ * DeepSeek, Kimi/Moonshot, GLM, and Gemini all accept temperature.
546
+ * @param modelName - the model identifier being routed
547
+ * @returns `true` when the model accepts a `temperature` parameter
548
+ */
549
+ const modelSupportsTemperature = (modelName) => {
550
+ if (!modelName)
551
+ return false;
552
+ // OpenAI reasoning families reject sampling params (gpt-5*, o1/o3/...).
553
+ if (/^gpt-5/.test(modelName) || /^o\d/.test(modelName))
554
+ return false;
555
+ // Anthropic dropped sampling params on Opus 4.7+ and the 5-series.
556
+ if (/^claude-opus-4-(7|8|9|\d\d)\b/.test(modelName))
557
+ return false;
558
+ if (/^claude-(sonnet|opus|haiku)-5\b/.test(modelName))
559
+ return false;
560
+ if (/^claude-(fable|mythos)-5\b/.test(modelName))
561
+ return false;
562
+ return true;
563
+ };
564
+ /**
565
+ * Per-provider valid `temperature` range, used to clamp caller-supplied values.
566
+ * Anthropic, Kimi (Moonshot), and GLM (Zhipu) cap at 1.0; OpenAI, Gemini, and
567
+ * DeepSeek accept up to 2.0. Only consulted for models that support temperature
568
+ * at all (see `modelSupportsTemperature`).
569
+ */
570
+ const getTemperatureRange = (modelName) => {
571
+ if (modelName.startsWith("claude-") ||
572
+ modelName.startsWith("kimi-") ||
573
+ modelName.startsWith("moonshot-") ||
574
+ modelName.startsWith("glm-")) {
575
+ return { min: 0, max: 1 };
576
+ }
577
+ // gpt-, gemini-, deepseek-
578
+ return { min: 0, max: 2 };
579
+ };
580
+ /**
581
+ * Resolves the `temperature` model setting for a given model. Applied in order:
582
+ * 1. presence/type — only a finite number is forwarded (`temperature: 0` is
583
+ * valid; `undefined`/`null`/non-numeric leaves the provider default);
584
+ * 2. support — models that reject the parameter (Opus 4.7+, GPT-5/o-series)
585
+ * get it omitted, with a warning, to avoid an HTTP 400;
586
+ * 3. range — values outside the provider's range are clamped, with a warning.
587
+ * Returns an object to spread into the LangChain model settings (`{}` when the
588
+ * field should be omitted).
589
+ */
590
+ const resolveTemperatureSetting = (modelName, config) => {
591
+ const temperature = config?.temperature;
592
+ if (typeof temperature !== "number" || !Number.isFinite(temperature)) {
593
+ return {};
594
+ }
595
+ if (!modelSupportsTemperature(modelName)) {
596
+ logger.log(null, logger.levels.warn, `Model "${modelName}" does not accept a temperature parameter; ignoring configured temperature ${temperature}.`);
597
+ return {};
598
+ }
599
+ const { min, max } = getTemperatureRange(modelName);
600
+ const clamped = Math.min(Math.max(temperature, min), max);
601
+ if (clamped !== temperature) {
602
+ logger.log(null, logger.levels.warn, `Temperature ${temperature} is outside the supported range [${min}, ${max}] for "${modelName}"; clamping to ${clamped}.`);
603
+ }
604
+ return { temperature: clamped };
605
+ };
439
606
  /**
440
607
  * Instantiates and returns the appropriate LangChain chat model based on the model
441
608
  * name prefix. `claude-` maps to `ChatAnthropic`, `gemini-` maps to
442
609
  * `ChatGoogleGenerativeAI`, and `gpt-` maps to `ChatOpenAI`. DeepSeek (`deepseek-`),
443
610
  * Kimi/Moonshot (`kimi-`, `moonshot-`), and GLM/Zhipu (`glm-`) models are routed
444
611
  * through `ChatOpenAI` against each provider's OpenAI-compatible endpoint. When a Zod
445
- * `schema` is provided for a GPT model, native `response_format` with `json_schema` is
446
- * configured for structured output. Throws a `BAD_REQUEST` error for unrecognised
447
- * model names.
612
+ * `schema` is provided, native structured output is configured per provider: OpenAI
613
+ * via `response_format` with `json_schema`, Anthropic via `output_config.format`, and
614
+ * Gemini via JSON mode (`json: true`) plus `responseSchema`. In every case the model
615
+ * emits JSON as the message text, so the caller's parse/validate pipeline is unchanged.
616
+ * The schema is ignored for the OpenAI-compatible providers (DeepSeek/Kimi/GLM), which
617
+ * have no native structured-output support here. Throws a `BAD_REQUEST` error for
618
+ * unrecognised model names.
448
619
  * @param modelName - The model identifier, e.g. `"gpt-4o"`, `"claude-3-5-sonnet"`,
449
620
  * `"gemini-1.5-pro"`, `"deepseek-chat"`, `"kimi-k2-0905-preview"`, `"glm-4.6"`
450
621
  * @param config - Configuration object containing API keys (`openAIAPIKey`,
451
622
  * `anthropicAPIKey`, `googleAPIKey`, `deepSeekAPIKey`, `kimiAPIKey`, `glmAPIKey`),
452
623
  * optional `maxTokens`, and optional `temperature`
453
- * @param schema - Optional Zod schema used to configure structured JSON output for
454
- * OpenAI GPT models via `response_format`; ignored for other providers
455
- * @returns A configured LangChain chat model instance
624
+ * @param schema - Optional Zod schema used to configure native structured JSON output
625
+ * for GPT / Claude / Gemini models; ignored for OpenAI-compatible providers
626
+ * @returns A configured LangChain chat model (or bound runnable) instance
456
627
  */
457
628
  const getLLMModel = (modelName, config, schema = null) => {
458
- const modelSettings = {
459
- ...(config.temperature ? { temperature: config.temperature } : {}),
460
- };
629
+ // Resolve `temperature` with presence/support/range handling (see
630
+ // resolveTemperatureSetting). Applied uniformly to every provider below.
631
+ const modelSettings = resolveTemperatureSetting(modelName, config);
461
632
  // Claude models (Anthropic)
462
633
  if (modelName.startsWith("claude-")) {
463
634
  // Anthropic's SDK rejects non-streamed requests when max_tokens is large
@@ -466,23 +637,53 @@ const getLLMModel = (modelName, config, schema = null) => {
466
637
  // through the streaming endpoint internally — callers see no interface
467
638
  // change, but the guard is bypassed.
468
639
  const maxTokens = config.maxTokens || 16384;
640
+ // Native structured output: Anthropic's `output_config.format` constrains
641
+ // decoding to the schema while still emitting JSON as the message text, so
642
+ // the downstream streaming + parse/validate pipeline is unchanged.
643
+ // `transformJSONSchema` applies the same strict-schema normalisation
644
+ // (additionalProperties: false, required fields) that @langchain/anthropic
645
+ // uses internally, which the API requires.
646
+ const outputConfig = schema
647
+ ? {
648
+ outputConfig: {
649
+ format: {
650
+ type: "json_schema",
651
+ schema: transformJSONSchema(z.toJSONSchema(schema)),
652
+ },
653
+ },
654
+ }
655
+ : {};
469
656
  return new ChatAnthropic({
470
657
  apiKey: config.anthropicAPIKey,
471
658
  maxTokens,
472
659
  modelName: modelName,
473
660
  streaming: maxTokens > 16384,
474
661
  invocationKwargs: { cache_control: { type: "ephemeral" } },
662
+ ...outputConfig,
475
663
  ...modelSettings,
476
664
  });
477
665
  }
478
666
  // Gemini models (Google)
479
667
  else if (modelName.startsWith("gemini-")) {
480
- return new ChatGoogleGenerativeAI({
668
+ const model = new ChatGoogleGenerativeAI({
481
669
  apiKey: config.googleAPIKey,
482
670
  maxOutputTokens: config.maxTokens || 200000,
483
671
  model: modelName,
672
+ // `json: true` forces responseMimeType=application/json, guaranteeing the
673
+ // model returns valid JSON text (never prose) for the parse pipeline.
674
+ ...(schema ? { json: true } : {}),
484
675
  ...modelSettings,
485
676
  });
677
+ // `responseSchema` additionally constrains the output shape. It is a
678
+ // call-time option (not a constructor field), so it is bound onto the model
679
+ // via `withConfig`. Gemini's schema is an OpenAPI 3.0 subset, so
680
+ // `additionalProperties` is stripped. Output stays text, so the
681
+ // parse/validate pipeline is unchanged.
682
+ if (schema) {
683
+ const jsonSchema = sanitizeGeminiSchema(buildJsonSchema(schema));
684
+ return model.withConfig({ responseSchema: jsonSchema });
685
+ }
686
+ return model;
486
687
  }
487
688
  // GPT models (OpenAI)
488
689
  else if (modelName.startsWith("gpt-")) {
@@ -494,7 +695,7 @@ const getLLMModel = (modelName, config, schema = null) => {
494
695
  };
495
696
  // Use native response_format with JSON schema for structured output
496
697
  if (schema) {
497
- const jsonSchema = zodToJsonSchema(schema, { target: "openApi3" });
698
+ const jsonSchema = strictifyJsonSchema(buildJsonSchema(schema));
498
699
  openAISettings.modelKwargs = {
499
700
  response_format: {
500
701
  type: "json_schema",
@@ -994,7 +1195,7 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
994
1195
  }
995
1196
  if (onProgressReport) {
996
1197
  // Streaming mode: use server-side chunk-based progress for all models
997
- const useNativeSchema = expectsJsonResponse && schema && modelName.startsWith("gpt-");
1198
+ const useNativeSchema = expectsJsonResponse && !!schema && supportsNativeSchema(modelName);
998
1199
  const llm = getLLMModel(modelName, config, useNativeSchema ? schema : null);
999
1200
  // Build messages with JSON instructions if needed
1000
1201
  let messagesToSend;
@@ -1003,7 +1204,7 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1003
1204
  ? "Respond with valid JSON."
1004
1205
  : JSON_ESCAPE_INSTRUCTION;
1005
1206
  if (schema && !useNativeSchema) {
1006
- const jsonSchema = zodToJsonSchema(schema, { target: "openApi3" });
1207
+ const jsonSchema = buildJsonSchema(schema);
1007
1208
  systemContent += `\n\nYour response MUST conform to this JSON schema:\n${JSON.stringify(jsonSchema, null, 2)}`;
1008
1209
  }
1009
1210
  if (messages.length > 0 && messages[0].role === "system") {
@@ -1049,7 +1250,11 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1049
1250
  while (true) {
1050
1251
  let rawContent = "";
1051
1252
  let chunkCount = 0;
1052
- let streamUsage = { input_tokens: 0, output_tokens: 0 };
1253
+ let streamUsage = {
1254
+ input_tokens: 0,
1255
+ output_tokens: 0,
1256
+ total_tokens: 0,
1257
+ };
1053
1258
  // Inner loop: wait + retry on 429 around stream setup and consumption.
1054
1259
  // Usage is only recorded on a successful stream — partial streams that
1055
1260
  // error out with a rate limit are not counted. A 429 fired mid-stream
@@ -1058,7 +1263,7 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1058
1263
  while (true) {
1059
1264
  rawContent = "";
1060
1265
  chunkCount = 0;
1061
- streamUsage = { input_tokens: 0, output_tokens: 0 };
1266
+ streamUsage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
1062
1267
  try {
1063
1268
  // Honour caller cancellation: passing the signal tears down the
1064
1269
  // upstream HTTP request so a cancelled call stops billing tokens.
@@ -1137,17 +1342,22 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1137
1342
  }
1138
1343
  }
1139
1344
  else {
1140
- // Non-streaming mode: use native response_format for OpenAI when schema is provided
1141
- const useNativeSchema = expectsJsonResponse && schema && modelName.startsWith("gpt-");
1345
+ // Non-streaming mode: use native structured output when the provider
1346
+ // supports it (OpenAI/Anthropic/Gemini); otherwise fall back to prompt
1347
+ // instructions + validation retry.
1348
+ const useNativeSchema = expectsJsonResponse && !!schema && supportsNativeSchema(modelName);
1142
1349
  const llm = getLLMModel(modelName, config, useNativeSchema ? schema : null);
1143
- // Add escape instruction to help LLM produce valid JSON (only if expecting JSON)
1144
- // For non-OpenAI models with schema, also include schema in prompt as fallback
1350
+ // Add escape instruction to help LLM produce valid JSON (only if expecting JSON).
1351
+ // When native structured output is in use the model is already constrained,
1352
+ // so a light instruction suffices and the schema is not re-injected.
1145
1353
  let messagesToSend;
1146
1354
  if (expectsJsonResponse) {
1147
- let systemContent = JSON_ESCAPE_INSTRUCTION;
1148
- // Include schema in prompt for non-OpenAI models (OpenAI uses native response_format)
1149
- if (schema && !modelName.startsWith("gpt-")) {
1150
- const jsonSchema = zodToJsonSchema(schema, { target: "openApi3" });
1355
+ let systemContent = useNativeSchema
1356
+ ? "Respond with valid JSON."
1357
+ : JSON_ESCAPE_INSTRUCTION;
1358
+ // Include the schema in the prompt only when not using native output.
1359
+ if (schema && !useNativeSchema) {
1360
+ const jsonSchema = buildJsonSchema(schema);
1151
1361
  systemContent += `\n\nYour response MUST conform to this JSON schema:\n${JSON.stringify(jsonSchema, null, 2)}`;
1152
1362
  }
1153
1363
  if (messages.length > 0 && messages[0].role === "system") {
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.2.10",
6
+ "version": "1.2.13",
7
7
  "description": "",
8
8
  "main": "dist/cjs/index.js",
9
9
  "module": "dist/esm/index.js",
@@ -51,8 +51,7 @@
51
51
  "mongoose": "^9.3.1",
52
52
  "openai": "^6.32.0",
53
53
  "winston": "^3.19.0",
54
- "zod": "^4.3.6",
55
- "zod-to-json-schema": "^3.25.1"
54
+ "zod": "^4.3.6"
56
55
  },
57
56
  "devDependencies": {
58
57
  "typescript": "^5.9.3"