@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.
- package/dist/cjs/langChain.d.ts.map +1 -1
- package/dist/cjs/langChain.js +237 -27
- package/dist/esm/langChain.d.ts.map +1 -1
- package/dist/esm/langChain.js +237 -27
- package/package.json +2 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":"
|
|
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"}
|
package/dist/cjs/langChain.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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 = {
|
|
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
|
|
451
|
-
*
|
|
452
|
-
* model
|
|
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
|
|
459
|
-
*
|
|
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
|
-
|
|
464
|
-
|
|
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
|
-
|
|
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 = (
|
|
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
|
|
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 = (
|
|
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 = {
|
|
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
|
|
1146
|
-
|
|
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
|
-
//
|
|
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 =
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
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":"
|
|
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"}
|
package/dist/esm/langChain.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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 = {
|
|
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
|
|
446
|
-
*
|
|
447
|
-
* model
|
|
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
|
|
454
|
-
*
|
|
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
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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 = {
|
|
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
|
|
1141
|
-
|
|
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
|
-
//
|
|
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 =
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
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.
|
|
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"
|