@stackfactor/agent-utils 1.0.19 → 1.0.21
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/const.d.ts +2 -0
- package/dist/cjs/const.d.ts.map +1 -1
- package/dist/cjs/const.js +2 -0
- package/dist/cjs/langChain.d.ts.map +1 -1
- package/dist/cjs/langChain.js +309 -118
- package/dist/esm/const.d.ts +2 -0
- package/dist/esm/const.d.ts.map +1 -1
- package/dist/esm/const.js +2 -0
- package/dist/esm/langChain.d.ts.map +1 -1
- package/dist/esm/langChain.js +309 -118
- package/package.json +1 -1
package/dist/cjs/const.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
declare const _default: {
|
|
2
2
|
HTTP_CODES: {
|
|
3
3
|
BAD_REQUEST: number;
|
|
4
|
+
PAYMENT_REQUIRED: number;
|
|
4
5
|
UNPROCESSABLE_ENTITY: number;
|
|
5
6
|
INTERNAL_SERVER_ERROR: number;
|
|
6
7
|
BAD_GATEWAY: number;
|
|
@@ -9,6 +10,7 @@ declare const _default: {
|
|
|
9
10
|
UNABLE_TO_GENERATE_CONTENT: string;
|
|
10
11
|
UNEXPECTED_ERROR: string;
|
|
11
12
|
UNSUPPORTED_MODEL: string;
|
|
13
|
+
QUOTA_EXHAUSTED: string;
|
|
12
14
|
};
|
|
13
15
|
};
|
|
14
16
|
export default _default;
|
package/dist/cjs/const.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"const.d.ts","sourceRoot":"","sources":["../../src/const.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"const.d.ts","sourceRoot":"","sources":["../../src/const.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,wBAgBE"}
|
package/dist/cjs/const.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.default = {
|
|
4
4
|
HTTP_CODES: {
|
|
5
5
|
BAD_REQUEST: 400,
|
|
6
|
+
PAYMENT_REQUIRED: 402,
|
|
6
7
|
UNPROCESSABLE_ENTITY: 422,
|
|
7
8
|
INTERNAL_SERVER_ERROR: 500,
|
|
8
9
|
BAD_GATEWAY: 502,
|
|
@@ -11,5 +12,6 @@ exports.default = {
|
|
|
11
12
|
UNABLE_TO_GENERATE_CONTENT: "Unable to generate content",
|
|
12
13
|
UNEXPECTED_ERROR: "An unexpected error occured. If the issue persists please contact the StackFactor support team at support@stackfactor.ai",
|
|
13
14
|
UNSUPPORTED_MODEL: "The specified model is not supported",
|
|
15
|
+
QUOTA_EXHAUSTED: "Agent session quota exhausted: no remaining budget for additional LLM calls.",
|
|
14
16
|
},
|
|
15
17
|
};
|
|
@@ -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":";wBAogBQ,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,KACV,GAAG;sBAyBG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,KAC1B,OAAO,CAAC,GAAG,CAAC;wCAkkBF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,KACpB,GAAG;oCA7XO,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,eACT,MAAM,eACN,MAAM,wBACG,OAAO,WACpB,GAAG,cACA,MAAM,UACV,GAAG,EAAE,KACX,OAAO,CAAC,GAAG,CAAC;sDAyrBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,KACX,OAAO,CAAC,GAAG,CAAC;0CA30B8B,GAAG,KAAG,MAAM;;AAs4BzD,wBAOE"}
|
package/dist/cjs/langChain.js
CHANGED
|
@@ -16,12 +16,157 @@ const zod_to_json_schema_1 = require("zod-to-json-schema");
|
|
|
16
16
|
const JSON_ESCAPE_INSTRUCTION = `
|
|
17
17
|
CRITICAL - Your response must be valid JSON. Escape ALL special characters in string values:
|
|
18
18
|
- Newlines → \\n
|
|
19
|
-
- Tabs → \\t
|
|
19
|
+
- Tabs → \\t
|
|
20
20
|
- Carriage returns → \\r
|
|
21
21
|
- Double quotes inside strings → \\"
|
|
22
22
|
- Backslashes → \\\\
|
|
23
23
|
Do NOT include raw newlines, tabs, or unescaped quotes inside JSON string values.
|
|
24
24
|
`.trim();
|
|
25
|
+
/**
|
|
26
|
+
* Reads `global.quota` (set by the StackFactor host before invoking `main()`) and
|
|
27
|
+
* throws a `PAYMENT_REQUIRED` error when the session has no remaining budget. No-op
|
|
28
|
+
* when `global.quota` is absent (e.g. local tests) or `remaining` is not a number,
|
|
29
|
+
* which keeps the library usable outside the StackFactor runtime.
|
|
30
|
+
*/
|
|
31
|
+
const assertQuotaAvailable = () => {
|
|
32
|
+
const quota = globalThis.quota;
|
|
33
|
+
if (!quota || typeof quota.remaining !== "number")
|
|
34
|
+
return;
|
|
35
|
+
if (quota.remaining <= 0) {
|
|
36
|
+
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.PAYMENT_REQUIRED, const_js_1.default.ERROR.QUOTA_EXHAUSTED);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Subtracts the given USD cost from `global.quota.remaining` and adds it to
|
|
41
|
+
* `global.quota.usedThisSession`. Silently no-ops when the quota globals are not
|
|
42
|
+
* present so callers do not need to branch. Negative, zero, or non-finite costs
|
|
43
|
+
* are ignored to keep counters monotonic.
|
|
44
|
+
*/
|
|
45
|
+
const recordCost = (cost) => {
|
|
46
|
+
if (!cost || cost <= 0 || !Number.isFinite(cost))
|
|
47
|
+
return;
|
|
48
|
+
const quota = globalThis.quota;
|
|
49
|
+
if (!quota)
|
|
50
|
+
return;
|
|
51
|
+
if (typeof quota.remaining === "number") {
|
|
52
|
+
quota.remaining -= cost;
|
|
53
|
+
}
|
|
54
|
+
if (typeof quota.usedThisSession === "number") {
|
|
55
|
+
quota.usedThisSession += cost;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
quota.usedThisSession = cost;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Looks up the per-model pricing entry from `config.modelPricing`. Returns `null`
|
|
63
|
+
* when pricing is not configured for the model, in which case cost recording
|
|
64
|
+
* becomes a no-op (the call still succeeds — pricing data is the host's
|
|
65
|
+
* responsibility, not the agent's).
|
|
66
|
+
*/
|
|
67
|
+
const getModelPrice = (modelName, config) => {
|
|
68
|
+
const pricing = config?.modelPricing;
|
|
69
|
+
if (!pricing)
|
|
70
|
+
return null;
|
|
71
|
+
return pricing[modelName] || null;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Computes USD cost for a text LLM call. `config.modelPricing[modelName]` is
|
|
75
|
+
* expected to provide `input` and `output` rates in dollars per million tokens.
|
|
76
|
+
*/
|
|
77
|
+
const calculateTextCost = (modelName, usage, config) => {
|
|
78
|
+
if (!usage)
|
|
79
|
+
return 0;
|
|
80
|
+
const price = getModelPrice(modelName, config);
|
|
81
|
+
if (!price)
|
|
82
|
+
return 0;
|
|
83
|
+
const inputCost = ((usage.input_tokens || 0) / 1_000_000) * (price.input || 0);
|
|
84
|
+
const outputCost = ((usage.output_tokens || 0) / 1_000_000) * (price.output || 0);
|
|
85
|
+
return inputCost + outputCost;
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Computes USD cost for an image generation call. `config.modelPricing[modelName]`
|
|
89
|
+
* is expected to provide a `perImage` rate in dollars.
|
|
90
|
+
*/
|
|
91
|
+
const calculateImageCost = (modelName, numImages, config) => {
|
|
92
|
+
const price = getModelPrice(modelName, config);
|
|
93
|
+
if (!price)
|
|
94
|
+
return 0;
|
|
95
|
+
return (numImages || 0) * (price.perImage || 0);
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Extracts a normalized token-usage object from a single LangChain `invoke()`
|
|
99
|
+
* response. Reads from `usage_metadata` first (standardized in LangChain v1),
|
|
100
|
+
* then falls back to provider-specific shapes under `response_metadata`.
|
|
101
|
+
*/
|
|
102
|
+
const extractUsageFromInvoke = (response) => {
|
|
103
|
+
if (!response)
|
|
104
|
+
return null;
|
|
105
|
+
const um = response.usage_metadata;
|
|
106
|
+
if (um) {
|
|
107
|
+
return {
|
|
108
|
+
input_tokens: um.input_tokens || 0,
|
|
109
|
+
output_tokens: um.output_tokens || 0,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const rm = response.response_metadata;
|
|
113
|
+
if (rm?.usage) {
|
|
114
|
+
return {
|
|
115
|
+
input_tokens: rm.usage.input_tokens || rm.usage.prompt_tokens || 0,
|
|
116
|
+
output_tokens: rm.usage.output_tokens || rm.usage.completion_tokens || 0,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (rm?.tokenUsage) {
|
|
120
|
+
return {
|
|
121
|
+
input_tokens: rm.tokenUsage.promptTokens || 0,
|
|
122
|
+
output_tokens: rm.tokenUsage.completionTokens || 0,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Accumulates token usage from a streaming chunk into a running total. LangChain
|
|
129
|
+
* typically attaches `usage_metadata` to the final chunk; earlier chunks carry no
|
|
130
|
+
* usage and are no-ops here.
|
|
131
|
+
*/
|
|
132
|
+
const accumulateChunkUsage = (acc, chunk) => {
|
|
133
|
+
if (!chunk)
|
|
134
|
+
return acc;
|
|
135
|
+
const um = chunk.usage_metadata;
|
|
136
|
+
if (um) {
|
|
137
|
+
acc.input_tokens += um.input_tokens || 0;
|
|
138
|
+
acc.output_tokens += um.output_tokens || 0;
|
|
139
|
+
return acc;
|
|
140
|
+
}
|
|
141
|
+
const rm = chunk.response_metadata;
|
|
142
|
+
if (rm?.usage) {
|
|
143
|
+
acc.input_tokens += rm.usage.input_tokens || rm.usage.prompt_tokens || 0;
|
|
144
|
+
acc.output_tokens +=
|
|
145
|
+
rm.usage.output_tokens || rm.usage.completion_tokens || 0;
|
|
146
|
+
}
|
|
147
|
+
return acc;
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* Sums token usage across every message in an agent invocation response. Each
|
|
151
|
+
* AIMessage in `response.messages` may carry its own `usage_metadata` (one per
|
|
152
|
+
* LLM round-trip the agent made).
|
|
153
|
+
*/
|
|
154
|
+
const sumAgentResponseUsage = (response) => {
|
|
155
|
+
const messages = response?.messages;
|
|
156
|
+
if (!Array.isArray(messages) || messages.length === 0)
|
|
157
|
+
return null;
|
|
158
|
+
const total = { input_tokens: 0, output_tokens: 0 };
|
|
159
|
+
let found = false;
|
|
160
|
+
for (const msg of messages) {
|
|
161
|
+
const um = msg?.usage_metadata;
|
|
162
|
+
if (um) {
|
|
163
|
+
total.input_tokens += um.input_tokens || 0;
|
|
164
|
+
total.output_tokens += um.output_tokens || 0;
|
|
165
|
+
found = true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return found ? total : null;
|
|
169
|
+
};
|
|
25
170
|
/**
|
|
26
171
|
* Converts a Zod validation error object into a human-readable multi-line string.
|
|
27
172
|
* Each failing field is described with its dot-notation path and a contextual message
|
|
@@ -377,12 +522,15 @@ const runAgent = async (agent, prompt, config, onProgress = null) => {
|
|
|
377
522
|
},
|
|
378
523
|
]
|
|
379
524
|
: undefined;
|
|
525
|
+
assertQuotaAvailable();
|
|
380
526
|
const response = await agent.invoke({
|
|
381
527
|
messages: [{ role: "user", content: prompt }],
|
|
382
528
|
}, {
|
|
383
529
|
recursionLimit: config.recursionLimit || 25,
|
|
384
530
|
...(callbacks ? { callbacks } : {}),
|
|
385
531
|
});
|
|
532
|
+
const modelName = agent.options?.model?.modelName || agent.options?.model?.model || "";
|
|
533
|
+
recordCost(calculateTextCost(modelName, sumAgentResponseUsage(response), config));
|
|
386
534
|
const endTime = Date.now();
|
|
387
535
|
const duration = endTime - startTime;
|
|
388
536
|
logger_js_1.default.log(null, logger_js_1.default.levels.info, `Agent "${agent.options?.name}" completed in ${Math.round(duration / 1000)} seconds.`);
|
|
@@ -403,6 +551,75 @@ const throwErrorIfNotSuccessful = (response) => {
|
|
|
403
551
|
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, const_js_1.default.ERROR.UNABLE_TO_GENERATE_CONTENT);
|
|
404
552
|
}
|
|
405
553
|
};
|
|
554
|
+
const MAX_VALIDATION_RETRIES = 3;
|
|
555
|
+
/**
|
|
556
|
+
* Parses raw LLM content as JSON and validates against an optional Zod schema,
|
|
557
|
+
* returning the canonical JSON string. Tries a direct parse of the trimmed text
|
|
558
|
+
* first, then falls back to `extractJSONFromResponse` for markdown-wrapped output.
|
|
559
|
+
* Throws `UNPROCESSABLE_ENTITY` when the parsed value fails schema validation, or
|
|
560
|
+
* `INTERNAL_SERVER_ERROR` when no JSON can be extracted from the response at all.
|
|
561
|
+
*/
|
|
562
|
+
const parseAndValidateJSONResponse = (rawContent, schema) => {
|
|
563
|
+
if (typeof rawContent === "string") {
|
|
564
|
+
const trimmed = rawContent.trim();
|
|
565
|
+
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
|
566
|
+
(trimmed.startsWith("[") && trimmed.endsWith("]"))) {
|
|
567
|
+
try {
|
|
568
|
+
const parsed = JSON.parse(trimmed);
|
|
569
|
+
if (schema) {
|
|
570
|
+
validateWithSchema(parsed, schema);
|
|
571
|
+
}
|
|
572
|
+
return JSON.stringify(parsed);
|
|
573
|
+
}
|
|
574
|
+
catch (parseError) {
|
|
575
|
+
if (parseError?.code === const_js_1.default.HTTP_CODES.UNPROCESSABLE_ENTITY) {
|
|
576
|
+
throw parseError;
|
|
577
|
+
}
|
|
578
|
+
// JSON.parse failed (not a validation error) — fall through to extraction.
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
const extracted = extractJSONFromResponse(rawContent);
|
|
582
|
+
if (extracted) {
|
|
583
|
+
if (schema) {
|
|
584
|
+
validateWithSchema(extracted, schema);
|
|
585
|
+
}
|
|
586
|
+
return JSON.stringify(extracted);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
let preview = "";
|
|
590
|
+
if (typeof rawContent === "string") {
|
|
591
|
+
preview = rawContent.substring(0, 100);
|
|
592
|
+
}
|
|
593
|
+
else if (typeof rawContent === "object" && rawContent !== null) {
|
|
594
|
+
preview = JSON.stringify(rawContent).substring(0, 100);
|
|
595
|
+
}
|
|
596
|
+
else if (rawContent !== undefined && rawContent !== null) {
|
|
597
|
+
preview = String(rawContent).substring(0, 100);
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
preview = "[empty response]";
|
|
601
|
+
}
|
|
602
|
+
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, `LLM returned non-JSON response when JSON was expected: ${preview}...`);
|
|
603
|
+
};
|
|
604
|
+
/**
|
|
605
|
+
* Appends the model's failed output as an assistant turn followed by a user turn
|
|
606
|
+
* containing the Zod validation errors. Including the bad assistant turn (not just
|
|
607
|
+
* the critique) lets the model see what it actually produced — without it,
|
|
608
|
+
* self-correction is guesswork. Callers pass the *current* message list (not the
|
|
609
|
+
* original base) so that across multiple retries the full failure history
|
|
610
|
+
* accumulates and the model can avoid oscillating between previous broken outputs.
|
|
611
|
+
*/
|
|
612
|
+
const buildValidationRetryMessages = (priorMessages, rawContent, validationError) => {
|
|
613
|
+
const assistantContent = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent);
|
|
614
|
+
return [
|
|
615
|
+
...priorMessages,
|
|
616
|
+
{ role: "assistant", content: assistantContent },
|
|
617
|
+
{
|
|
618
|
+
role: "user",
|
|
619
|
+
content: `The previous response failed schema validation with the following errors. Return a corrected JSON response that fixes ALL of these issues:\n\n${validationError.message}`,
|
|
620
|
+
},
|
|
621
|
+
];
|
|
622
|
+
};
|
|
406
623
|
/**
|
|
407
624
|
* Sends a prompt to an LLM and returns the response, with support for streaming,
|
|
408
625
|
* agentic execution, progress reporting, JSON extraction, and Zod schema validation.
|
|
@@ -580,80 +797,79 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
|
|
|
580
797
|
else {
|
|
581
798
|
messagesToSend = messages;
|
|
582
799
|
}
|
|
583
|
-
//
|
|
584
|
-
|
|
585
|
-
|
|
800
|
+
// Retry once on schema validation failure. The retry is silent to the progress
|
|
801
|
+
// callback — same "Generating content..." message and the progress curve
|
|
802
|
+
// continues from the original startTime so the bar doesn't visibly reset.
|
|
803
|
+
// Skipped when OpenAI native response_format is in use (a 422 there is a
|
|
804
|
+
// server-side schema bug, not a model output issue). Each attempt is billed
|
|
805
|
+
// independently via recordCost so retry cost is still visible in telemetry.
|
|
806
|
+
const canRetryOnValidation = expectsJsonResponse && !!schema && !useNativeSchema;
|
|
586
807
|
const progressReportInterval = 10; // Report every N chunks
|
|
587
|
-
const
|
|
808
|
+
const overallStartTime = Date.now();
|
|
588
809
|
// Use a time-based asymptotic curve: progress approaches maxPercent but never
|
|
589
810
|
// overshoots. This avoids the magic "expected length" constant — longer responses
|
|
590
811
|
// simply slow the curve down rather than exceeding the range.
|
|
591
812
|
const expectedDurationMs = 15_000; // Tune: expected typical response time
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
813
|
+
let activeMessages = messagesToSend;
|
|
814
|
+
let attempt = 0;
|
|
815
|
+
while (true) {
|
|
816
|
+
let rawContent = "";
|
|
817
|
+
let chunkCount = 0;
|
|
818
|
+
const streamUsage = { input_tokens: 0, output_tokens: 0 };
|
|
819
|
+
assertQuotaAvailable();
|
|
820
|
+
const stream = await llm.stream(activeMessages);
|
|
821
|
+
for await (const chunk of stream) {
|
|
822
|
+
accumulateChunkUsage(streamUsage, chunk);
|
|
823
|
+
const content = chunk?.content || chunk;
|
|
824
|
+
if (typeof content === "string") {
|
|
825
|
+
rawContent += content;
|
|
826
|
+
chunkCount++;
|
|
827
|
+
if (chunkCount % progressReportInterval === 0) {
|
|
828
|
+
const elapsed = Date.now() - overallStartTime;
|
|
829
|
+
// Asymptotic curve: fast early progress that slows as it approaches max
|
|
830
|
+
const progress = Math.round(minPercent +
|
|
831
|
+
(maxPercent - minPercent - 5) *
|
|
832
|
+
(1 - Math.exp(-elapsed / expectedDurationMs)));
|
|
833
|
+
await onProgressReport({
|
|
834
|
+
message: "Generating content...",
|
|
835
|
+
progress: Math.min(progress, maxPercent - 5),
|
|
836
|
+
});
|
|
837
|
+
}
|
|
608
838
|
}
|
|
609
839
|
}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
// Parse and validate JSON response
|
|
623
|
-
const trimmed = rawContent.trim();
|
|
624
|
-
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
|
625
|
-
(trimmed.startsWith("[") && trimmed.endsWith("]"))) {
|
|
840
|
+
recordCost(calculateTextCost(modelName, streamUsage, config));
|
|
841
|
+
if (!rawContent) {
|
|
842
|
+
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, "LLM returned no content");
|
|
843
|
+
}
|
|
844
|
+
// If not expecting JSON, return raw content directly
|
|
845
|
+
if (!expectsJsonResponse) {
|
|
846
|
+
await onProgressReport({
|
|
847
|
+
message: "Processing complete",
|
|
848
|
+
progress: maxPercent,
|
|
849
|
+
});
|
|
850
|
+
return rawContent;
|
|
851
|
+
}
|
|
626
852
|
try {
|
|
627
|
-
const
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
853
|
+
const result = parseAndValidateJSONResponse(rawContent, schema);
|
|
854
|
+
await onProgressReport({
|
|
855
|
+
message: "Processing complete",
|
|
856
|
+
progress: maxPercent,
|
|
857
|
+
});
|
|
858
|
+
return result;
|
|
632
859
|
}
|
|
633
|
-
catch (
|
|
634
|
-
|
|
635
|
-
|
|
860
|
+
catch (err) {
|
|
861
|
+
const isValidationFailure = err?.code === const_js_1.default.HTTP_CODES.UNPROCESSABLE_ENTITY;
|
|
862
|
+
if (isValidationFailure &&
|
|
863
|
+
canRetryOnValidation &&
|
|
864
|
+
attempt < MAX_VALIDATION_RETRIES) {
|
|
865
|
+
attempt++;
|
|
866
|
+
logger_js_1.default.log(null, logger_js_1.default.levels.warn, `Schema validation failed for "${modelName}", retrying (${attempt}/${MAX_VALIDATION_RETRIES}): ${err.message}`);
|
|
867
|
+
activeMessages = buildValidationRetryMessages(activeMessages, rawContent, err);
|
|
868
|
+
continue;
|
|
636
869
|
}
|
|
870
|
+
throw err;
|
|
637
871
|
}
|
|
638
872
|
}
|
|
639
|
-
const extracted = extractJSONFromResponse(rawContent);
|
|
640
|
-
if (extracted) {
|
|
641
|
-
if (schema) {
|
|
642
|
-
validateWithSchema(extracted, schema);
|
|
643
|
-
}
|
|
644
|
-
return JSON.stringify(extracted);
|
|
645
|
-
}
|
|
646
|
-
let preview = "";
|
|
647
|
-
if (typeof rawContent === "string") {
|
|
648
|
-
preview = rawContent.substring(0, 100);
|
|
649
|
-
}
|
|
650
|
-
else if (typeof rawContent === "object" && rawContent !== null) {
|
|
651
|
-
preview = JSON.stringify(rawContent).substring(0, 100);
|
|
652
|
-
}
|
|
653
|
-
else {
|
|
654
|
-
preview = "[empty response]";
|
|
655
|
-
}
|
|
656
|
-
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, `LLM returned non-JSON response when JSON was expected: ${preview}...`);
|
|
657
873
|
}
|
|
658
874
|
else {
|
|
659
875
|
// Non-streaming mode: use native response_format for OpenAI when schema is provided
|
|
@@ -693,65 +909,36 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
|
|
|
693
909
|
else {
|
|
694
910
|
messagesToSend = messages;
|
|
695
911
|
}
|
|
696
|
-
//
|
|
697
|
-
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
(trimmed.startsWith("[") && trimmed.endsWith("]"))) {
|
|
710
|
-
try {
|
|
711
|
-
// Parse and re-stringify
|
|
712
|
-
const parsed = JSON.parse(trimmed);
|
|
713
|
-
// Validate against schema if provided
|
|
714
|
-
if (schema) {
|
|
715
|
-
validateWithSchema(parsed, schema);
|
|
716
|
-
}
|
|
717
|
-
return JSON.stringify(parsed);
|
|
718
|
-
}
|
|
719
|
-
catch (parseError) {
|
|
720
|
-
// If it's a validation error, re-throw it
|
|
721
|
-
if (parseError?.code === const_js_1.default.HTTP_CODES.UNPROCESSABLE_ENTITY) {
|
|
722
|
-
throw parseError;
|
|
723
|
-
}
|
|
724
|
-
// Not valid JSON, try extraction
|
|
725
|
-
}
|
|
912
|
+
// Retry once on schema validation failure (see streaming branch for rationale).
|
|
913
|
+
// Each attempt is billed independently via recordCost so retry cost is visible.
|
|
914
|
+
const canRetryOnValidation = expectsJsonResponse && !!schema && !useNativeSchema;
|
|
915
|
+
let activeMessages = messagesToSend;
|
|
916
|
+
let attempt = 0;
|
|
917
|
+
while (true) {
|
|
918
|
+
assertQuotaAvailable();
|
|
919
|
+
const response = await llm.invoke(activeMessages);
|
|
920
|
+
recordCost(calculateTextCost(modelName, extractUsageFromInvoke(response), config));
|
|
921
|
+
const rawContent = response?.content || response;
|
|
922
|
+
// If not expecting JSON, return raw content directly
|
|
923
|
+
if (!expectsJsonResponse) {
|
|
924
|
+
return rawContent;
|
|
726
925
|
}
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
if (extracted) {
|
|
730
|
-
// Validate against schema if provided
|
|
731
|
-
if (schema) {
|
|
732
|
-
validateWithSchema(extracted, schema);
|
|
733
|
-
}
|
|
734
|
-
return JSON.stringify(extracted);
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
// Return raw content as last resort - but throw if JSON was expected
|
|
738
|
-
if (expectsJsonResponse) {
|
|
739
|
-
let preview = "";
|
|
740
|
-
if (typeof rawContent === "string") {
|
|
741
|
-
preview = rawContent.substring(0, 100);
|
|
742
|
-
}
|
|
743
|
-
else if (typeof rawContent === "object" && rawContent !== null) {
|
|
744
|
-
preview = JSON.stringify(rawContent).substring(0, 100);
|
|
745
|
-
}
|
|
746
|
-
else if (rawContent !== undefined && rawContent !== null) {
|
|
747
|
-
preview = String(rawContent).substring(0, 100);
|
|
926
|
+
try {
|
|
927
|
+
return parseAndValidateJSONResponse(rawContent, schema);
|
|
748
928
|
}
|
|
749
|
-
|
|
750
|
-
|
|
929
|
+
catch (err) {
|
|
930
|
+
const isValidationFailure = err?.code === const_js_1.default.HTTP_CODES.UNPROCESSABLE_ENTITY;
|
|
931
|
+
if (isValidationFailure &&
|
|
932
|
+
canRetryOnValidation &&
|
|
933
|
+
attempt < MAX_VALIDATION_RETRIES) {
|
|
934
|
+
attempt++;
|
|
935
|
+
logger_js_1.default.log(null, logger_js_1.default.levels.warn, `Schema validation failed for "${modelName}", retrying (${attempt}/${MAX_VALIDATION_RETRIES}): ${err.message}`);
|
|
936
|
+
activeMessages = buildValidationRetryMessages(activeMessages, rawContent, err);
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
throw err;
|
|
751
940
|
}
|
|
752
|
-
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, `LLM returned non-JSON response when JSON was expected: ${preview}...`);
|
|
753
941
|
}
|
|
754
|
-
return rawContent;
|
|
755
942
|
}
|
|
756
943
|
};
|
|
757
944
|
/**
|
|
@@ -877,7 +1064,9 @@ const generateImageWithOpenAI = async (modelName, config, prompt, options) => {
|
|
|
877
1064
|
requestParams.style = style;
|
|
878
1065
|
}
|
|
879
1066
|
}
|
|
1067
|
+
assertQuotaAvailable();
|
|
880
1068
|
const response = await openai.images.generate(requestParams);
|
|
1069
|
+
recordCost(calculateImageCost(modelName, response.data?.length || n, config));
|
|
881
1070
|
// Format response based on number of images
|
|
882
1071
|
if (n === 1) {
|
|
883
1072
|
const imageData = response.data[0];
|
|
@@ -975,6 +1164,7 @@ const generateImageWithGoogle = async (modelName, config, prompt, options) => {
|
|
|
975
1164
|
safetySettings: safetySettings,
|
|
976
1165
|
},
|
|
977
1166
|
};
|
|
1167
|
+
assertQuotaAvailable();
|
|
978
1168
|
const response = await ai.models.generateContent(req);
|
|
979
1169
|
// Extract images from response
|
|
980
1170
|
const images = [];
|
|
@@ -993,6 +1183,7 @@ const generateImageWithGoogle = async (modelName, config, prompt, options) => {
|
|
|
993
1183
|
if (images.length === 0) {
|
|
994
1184
|
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, `No images were generated by ${modelName}`);
|
|
995
1185
|
}
|
|
1186
|
+
recordCost(calculateImageCost(modelName, images.length, config));
|
|
996
1187
|
if (numberOfImages === 1 || images.length === 1) {
|
|
997
1188
|
return images[0];
|
|
998
1189
|
}
|
package/dist/esm/const.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
declare const _default: {
|
|
2
2
|
HTTP_CODES: {
|
|
3
3
|
BAD_REQUEST: number;
|
|
4
|
+
PAYMENT_REQUIRED: number;
|
|
4
5
|
UNPROCESSABLE_ENTITY: number;
|
|
5
6
|
INTERNAL_SERVER_ERROR: number;
|
|
6
7
|
BAD_GATEWAY: number;
|
|
@@ -9,6 +10,7 @@ declare const _default: {
|
|
|
9
10
|
UNABLE_TO_GENERATE_CONTENT: string;
|
|
10
11
|
UNEXPECTED_ERROR: string;
|
|
11
12
|
UNSUPPORTED_MODEL: string;
|
|
13
|
+
QUOTA_EXHAUSTED: string;
|
|
12
14
|
};
|
|
13
15
|
};
|
|
14
16
|
export default _default;
|
package/dist/esm/const.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"const.d.ts","sourceRoot":"","sources":["../../src/const.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"const.d.ts","sourceRoot":"","sources":["../../src/const.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,wBAgBE"}
|
package/dist/esm/const.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export default {
|
|
2
2
|
HTTP_CODES: {
|
|
3
3
|
BAD_REQUEST: 400,
|
|
4
|
+
PAYMENT_REQUIRED: 402,
|
|
4
5
|
UNPROCESSABLE_ENTITY: 422,
|
|
5
6
|
INTERNAL_SERVER_ERROR: 500,
|
|
6
7
|
BAD_GATEWAY: 502,
|
|
@@ -9,5 +10,6 @@ export default {
|
|
|
9
10
|
UNABLE_TO_GENERATE_CONTENT: "Unable to generate content",
|
|
10
11
|
UNEXPECTED_ERROR: "An unexpected error occured. If the issue persists please contact the StackFactor support team at support@stackfactor.ai",
|
|
11
12
|
UNSUPPORTED_MODEL: "The specified model is not supported",
|
|
13
|
+
QUOTA_EXHAUSTED: "Agent session quota exhausted: no remaining budget for additional LLM calls.",
|
|
12
14
|
},
|
|
13
15
|
};
|
|
@@ -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":";wBAogBQ,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,KACV,GAAG;sBAyBG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,KAC1B,OAAO,CAAC,GAAG,CAAC;wCAkkBF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,KACpB,GAAG;oCA7XO,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,eACT,MAAM,eACN,MAAM,wBACG,OAAO,WACpB,GAAG,cACA,MAAM,UACV,GAAG,EAAE,KACX,OAAO,CAAC,GAAG,CAAC;sDAyrBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,KACX,OAAO,CAAC,GAAG,CAAC;0CA30B8B,GAAG,KAAG,MAAM;;AAs4BzD,wBAOE"}
|
package/dist/esm/langChain.js
CHANGED
|
@@ -11,12 +11,157 @@ import { zodToJsonSchema } from "zod-to-json-schema";
|
|
|
11
11
|
const JSON_ESCAPE_INSTRUCTION = `
|
|
12
12
|
CRITICAL - Your response must be valid JSON. Escape ALL special characters in string values:
|
|
13
13
|
- Newlines → \\n
|
|
14
|
-
- Tabs → \\t
|
|
14
|
+
- Tabs → \\t
|
|
15
15
|
- Carriage returns → \\r
|
|
16
16
|
- Double quotes inside strings → \\"
|
|
17
17
|
- Backslashes → \\\\
|
|
18
18
|
Do NOT include raw newlines, tabs, or unescaped quotes inside JSON string values.
|
|
19
19
|
`.trim();
|
|
20
|
+
/**
|
|
21
|
+
* Reads `global.quota` (set by the StackFactor host before invoking `main()`) and
|
|
22
|
+
* throws a `PAYMENT_REQUIRED` error when the session has no remaining budget. No-op
|
|
23
|
+
* when `global.quota` is absent (e.g. local tests) or `remaining` is not a number,
|
|
24
|
+
* which keeps the library usable outside the StackFactor runtime.
|
|
25
|
+
*/
|
|
26
|
+
const assertQuotaAvailable = () => {
|
|
27
|
+
const quota = globalThis.quota;
|
|
28
|
+
if (!quota || typeof quota.remaining !== "number")
|
|
29
|
+
return;
|
|
30
|
+
if (quota.remaining <= 0) {
|
|
31
|
+
throw errorHandlingHelper.create(constants.HTTP_CODES.PAYMENT_REQUIRED, constants.ERROR.QUOTA_EXHAUSTED);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Subtracts the given USD cost from `global.quota.remaining` and adds it to
|
|
36
|
+
* `global.quota.usedThisSession`. Silently no-ops when the quota globals are not
|
|
37
|
+
* present so callers do not need to branch. Negative, zero, or non-finite costs
|
|
38
|
+
* are ignored to keep counters monotonic.
|
|
39
|
+
*/
|
|
40
|
+
const recordCost = (cost) => {
|
|
41
|
+
if (!cost || cost <= 0 || !Number.isFinite(cost))
|
|
42
|
+
return;
|
|
43
|
+
const quota = globalThis.quota;
|
|
44
|
+
if (!quota)
|
|
45
|
+
return;
|
|
46
|
+
if (typeof quota.remaining === "number") {
|
|
47
|
+
quota.remaining -= cost;
|
|
48
|
+
}
|
|
49
|
+
if (typeof quota.usedThisSession === "number") {
|
|
50
|
+
quota.usedThisSession += cost;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
quota.usedThisSession = cost;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Looks up the per-model pricing entry from `config.modelPricing`. Returns `null`
|
|
58
|
+
* when pricing is not configured for the model, in which case cost recording
|
|
59
|
+
* becomes a no-op (the call still succeeds — pricing data is the host's
|
|
60
|
+
* responsibility, not the agent's).
|
|
61
|
+
*/
|
|
62
|
+
const getModelPrice = (modelName, config) => {
|
|
63
|
+
const pricing = config?.modelPricing;
|
|
64
|
+
if (!pricing)
|
|
65
|
+
return null;
|
|
66
|
+
return pricing[modelName] || null;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Computes USD cost for a text LLM call. `config.modelPricing[modelName]` is
|
|
70
|
+
* expected to provide `input` and `output` rates in dollars per million tokens.
|
|
71
|
+
*/
|
|
72
|
+
const calculateTextCost = (modelName, usage, config) => {
|
|
73
|
+
if (!usage)
|
|
74
|
+
return 0;
|
|
75
|
+
const price = getModelPrice(modelName, config);
|
|
76
|
+
if (!price)
|
|
77
|
+
return 0;
|
|
78
|
+
const inputCost = ((usage.input_tokens || 0) / 1_000_000) * (price.input || 0);
|
|
79
|
+
const outputCost = ((usage.output_tokens || 0) / 1_000_000) * (price.output || 0);
|
|
80
|
+
return inputCost + outputCost;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Computes USD cost for an image generation call. `config.modelPricing[modelName]`
|
|
84
|
+
* is expected to provide a `perImage` rate in dollars.
|
|
85
|
+
*/
|
|
86
|
+
const calculateImageCost = (modelName, numImages, config) => {
|
|
87
|
+
const price = getModelPrice(modelName, config);
|
|
88
|
+
if (!price)
|
|
89
|
+
return 0;
|
|
90
|
+
return (numImages || 0) * (price.perImage || 0);
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Extracts a normalized token-usage object from a single LangChain `invoke()`
|
|
94
|
+
* response. Reads from `usage_metadata` first (standardized in LangChain v1),
|
|
95
|
+
* then falls back to provider-specific shapes under `response_metadata`.
|
|
96
|
+
*/
|
|
97
|
+
const extractUsageFromInvoke = (response) => {
|
|
98
|
+
if (!response)
|
|
99
|
+
return null;
|
|
100
|
+
const um = response.usage_metadata;
|
|
101
|
+
if (um) {
|
|
102
|
+
return {
|
|
103
|
+
input_tokens: um.input_tokens || 0,
|
|
104
|
+
output_tokens: um.output_tokens || 0,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const rm = response.response_metadata;
|
|
108
|
+
if (rm?.usage) {
|
|
109
|
+
return {
|
|
110
|
+
input_tokens: rm.usage.input_tokens || rm.usage.prompt_tokens || 0,
|
|
111
|
+
output_tokens: rm.usage.output_tokens || rm.usage.completion_tokens || 0,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (rm?.tokenUsage) {
|
|
115
|
+
return {
|
|
116
|
+
input_tokens: rm.tokenUsage.promptTokens || 0,
|
|
117
|
+
output_tokens: rm.tokenUsage.completionTokens || 0,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* Accumulates token usage from a streaming chunk into a running total. LangChain
|
|
124
|
+
* typically attaches `usage_metadata` to the final chunk; earlier chunks carry no
|
|
125
|
+
* usage and are no-ops here.
|
|
126
|
+
*/
|
|
127
|
+
const accumulateChunkUsage = (acc, chunk) => {
|
|
128
|
+
if (!chunk)
|
|
129
|
+
return acc;
|
|
130
|
+
const um = chunk.usage_metadata;
|
|
131
|
+
if (um) {
|
|
132
|
+
acc.input_tokens += um.input_tokens || 0;
|
|
133
|
+
acc.output_tokens += um.output_tokens || 0;
|
|
134
|
+
return acc;
|
|
135
|
+
}
|
|
136
|
+
const rm = chunk.response_metadata;
|
|
137
|
+
if (rm?.usage) {
|
|
138
|
+
acc.input_tokens += rm.usage.input_tokens || rm.usage.prompt_tokens || 0;
|
|
139
|
+
acc.output_tokens +=
|
|
140
|
+
rm.usage.output_tokens || rm.usage.completion_tokens || 0;
|
|
141
|
+
}
|
|
142
|
+
return acc;
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* Sums token usage across every message in an agent invocation response. Each
|
|
146
|
+
* AIMessage in `response.messages` may carry its own `usage_metadata` (one per
|
|
147
|
+
* LLM round-trip the agent made).
|
|
148
|
+
*/
|
|
149
|
+
const sumAgentResponseUsage = (response) => {
|
|
150
|
+
const messages = response?.messages;
|
|
151
|
+
if (!Array.isArray(messages) || messages.length === 0)
|
|
152
|
+
return null;
|
|
153
|
+
const total = { input_tokens: 0, output_tokens: 0 };
|
|
154
|
+
let found = false;
|
|
155
|
+
for (const msg of messages) {
|
|
156
|
+
const um = msg?.usage_metadata;
|
|
157
|
+
if (um) {
|
|
158
|
+
total.input_tokens += um.input_tokens || 0;
|
|
159
|
+
total.output_tokens += um.output_tokens || 0;
|
|
160
|
+
found = true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return found ? total : null;
|
|
164
|
+
};
|
|
20
165
|
/**
|
|
21
166
|
* Converts a Zod validation error object into a human-readable multi-line string.
|
|
22
167
|
* Each failing field is described with its dot-notation path and a contextual message
|
|
@@ -372,12 +517,15 @@ const runAgent = async (agent, prompt, config, onProgress = null) => {
|
|
|
372
517
|
},
|
|
373
518
|
]
|
|
374
519
|
: undefined;
|
|
520
|
+
assertQuotaAvailable();
|
|
375
521
|
const response = await agent.invoke({
|
|
376
522
|
messages: [{ role: "user", content: prompt }],
|
|
377
523
|
}, {
|
|
378
524
|
recursionLimit: config.recursionLimit || 25,
|
|
379
525
|
...(callbacks ? { callbacks } : {}),
|
|
380
526
|
});
|
|
527
|
+
const modelName = agent.options?.model?.modelName || agent.options?.model?.model || "";
|
|
528
|
+
recordCost(calculateTextCost(modelName, sumAgentResponseUsage(response), config));
|
|
381
529
|
const endTime = Date.now();
|
|
382
530
|
const duration = endTime - startTime;
|
|
383
531
|
logger.log(null, logger.levels.info, `Agent "${agent.options?.name}" completed in ${Math.round(duration / 1000)} seconds.`);
|
|
@@ -398,6 +546,75 @@ const throwErrorIfNotSuccessful = (response) => {
|
|
|
398
546
|
throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, constants.ERROR.UNABLE_TO_GENERATE_CONTENT);
|
|
399
547
|
}
|
|
400
548
|
};
|
|
549
|
+
const MAX_VALIDATION_RETRIES = 3;
|
|
550
|
+
/**
|
|
551
|
+
* Parses raw LLM content as JSON and validates against an optional Zod schema,
|
|
552
|
+
* returning the canonical JSON string. Tries a direct parse of the trimmed text
|
|
553
|
+
* first, then falls back to `extractJSONFromResponse` for markdown-wrapped output.
|
|
554
|
+
* Throws `UNPROCESSABLE_ENTITY` when the parsed value fails schema validation, or
|
|
555
|
+
* `INTERNAL_SERVER_ERROR` when no JSON can be extracted from the response at all.
|
|
556
|
+
*/
|
|
557
|
+
const parseAndValidateJSONResponse = (rawContent, schema) => {
|
|
558
|
+
if (typeof rawContent === "string") {
|
|
559
|
+
const trimmed = rawContent.trim();
|
|
560
|
+
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
|
561
|
+
(trimmed.startsWith("[") && trimmed.endsWith("]"))) {
|
|
562
|
+
try {
|
|
563
|
+
const parsed = JSON.parse(trimmed);
|
|
564
|
+
if (schema) {
|
|
565
|
+
validateWithSchema(parsed, schema);
|
|
566
|
+
}
|
|
567
|
+
return JSON.stringify(parsed);
|
|
568
|
+
}
|
|
569
|
+
catch (parseError) {
|
|
570
|
+
if (parseError?.code === constants.HTTP_CODES.UNPROCESSABLE_ENTITY) {
|
|
571
|
+
throw parseError;
|
|
572
|
+
}
|
|
573
|
+
// JSON.parse failed (not a validation error) — fall through to extraction.
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
const extracted = extractJSONFromResponse(rawContent);
|
|
577
|
+
if (extracted) {
|
|
578
|
+
if (schema) {
|
|
579
|
+
validateWithSchema(extracted, schema);
|
|
580
|
+
}
|
|
581
|
+
return JSON.stringify(extracted);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
let preview = "";
|
|
585
|
+
if (typeof rawContent === "string") {
|
|
586
|
+
preview = rawContent.substring(0, 100);
|
|
587
|
+
}
|
|
588
|
+
else if (typeof rawContent === "object" && rawContent !== null) {
|
|
589
|
+
preview = JSON.stringify(rawContent).substring(0, 100);
|
|
590
|
+
}
|
|
591
|
+
else if (rawContent !== undefined && rawContent !== null) {
|
|
592
|
+
preview = String(rawContent).substring(0, 100);
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
preview = "[empty response]";
|
|
596
|
+
}
|
|
597
|
+
throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, `LLM returned non-JSON response when JSON was expected: ${preview}...`);
|
|
598
|
+
};
|
|
599
|
+
/**
|
|
600
|
+
* Appends the model's failed output as an assistant turn followed by a user turn
|
|
601
|
+
* containing the Zod validation errors. Including the bad assistant turn (not just
|
|
602
|
+
* the critique) lets the model see what it actually produced — without it,
|
|
603
|
+
* self-correction is guesswork. Callers pass the *current* message list (not the
|
|
604
|
+
* original base) so that across multiple retries the full failure history
|
|
605
|
+
* accumulates and the model can avoid oscillating between previous broken outputs.
|
|
606
|
+
*/
|
|
607
|
+
const buildValidationRetryMessages = (priorMessages, rawContent, validationError) => {
|
|
608
|
+
const assistantContent = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent);
|
|
609
|
+
return [
|
|
610
|
+
...priorMessages,
|
|
611
|
+
{ role: "assistant", content: assistantContent },
|
|
612
|
+
{
|
|
613
|
+
role: "user",
|
|
614
|
+
content: `The previous response failed schema validation with the following errors. Return a corrected JSON response that fixes ALL of these issues:\n\n${validationError.message}`,
|
|
615
|
+
},
|
|
616
|
+
];
|
|
617
|
+
};
|
|
401
618
|
/**
|
|
402
619
|
* Sends a prompt to an LLM and returns the response, with support for streaming,
|
|
403
620
|
* agentic execution, progress reporting, JSON extraction, and Zod schema validation.
|
|
@@ -575,80 +792,79 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
|
|
|
575
792
|
else {
|
|
576
793
|
messagesToSend = messages;
|
|
577
794
|
}
|
|
578
|
-
//
|
|
579
|
-
|
|
580
|
-
|
|
795
|
+
// Retry once on schema validation failure. The retry is silent to the progress
|
|
796
|
+
// callback — same "Generating content..." message and the progress curve
|
|
797
|
+
// continues from the original startTime so the bar doesn't visibly reset.
|
|
798
|
+
// Skipped when OpenAI native response_format is in use (a 422 there is a
|
|
799
|
+
// server-side schema bug, not a model output issue). Each attempt is billed
|
|
800
|
+
// independently via recordCost so retry cost is still visible in telemetry.
|
|
801
|
+
const canRetryOnValidation = expectsJsonResponse && !!schema && !useNativeSchema;
|
|
581
802
|
const progressReportInterval = 10; // Report every N chunks
|
|
582
|
-
const
|
|
803
|
+
const overallStartTime = Date.now();
|
|
583
804
|
// Use a time-based asymptotic curve: progress approaches maxPercent but never
|
|
584
805
|
// overshoots. This avoids the magic "expected length" constant — longer responses
|
|
585
806
|
// simply slow the curve down rather than exceeding the range.
|
|
586
807
|
const expectedDurationMs = 15_000; // Tune: expected typical response time
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
808
|
+
let activeMessages = messagesToSend;
|
|
809
|
+
let attempt = 0;
|
|
810
|
+
while (true) {
|
|
811
|
+
let rawContent = "";
|
|
812
|
+
let chunkCount = 0;
|
|
813
|
+
const streamUsage = { input_tokens: 0, output_tokens: 0 };
|
|
814
|
+
assertQuotaAvailable();
|
|
815
|
+
const stream = await llm.stream(activeMessages);
|
|
816
|
+
for await (const chunk of stream) {
|
|
817
|
+
accumulateChunkUsage(streamUsage, chunk);
|
|
818
|
+
const content = chunk?.content || chunk;
|
|
819
|
+
if (typeof content === "string") {
|
|
820
|
+
rawContent += content;
|
|
821
|
+
chunkCount++;
|
|
822
|
+
if (chunkCount % progressReportInterval === 0) {
|
|
823
|
+
const elapsed = Date.now() - overallStartTime;
|
|
824
|
+
// Asymptotic curve: fast early progress that slows as it approaches max
|
|
825
|
+
const progress = Math.round(minPercent +
|
|
826
|
+
(maxPercent - minPercent - 5) *
|
|
827
|
+
(1 - Math.exp(-elapsed / expectedDurationMs)));
|
|
828
|
+
await onProgressReport({
|
|
829
|
+
message: "Generating content...",
|
|
830
|
+
progress: Math.min(progress, maxPercent - 5),
|
|
831
|
+
});
|
|
832
|
+
}
|
|
603
833
|
}
|
|
604
834
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
// Parse and validate JSON response
|
|
618
|
-
const trimmed = rawContent.trim();
|
|
619
|
-
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
|
620
|
-
(trimmed.startsWith("[") && trimmed.endsWith("]"))) {
|
|
835
|
+
recordCost(calculateTextCost(modelName, streamUsage, config));
|
|
836
|
+
if (!rawContent) {
|
|
837
|
+
throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, "LLM returned no content");
|
|
838
|
+
}
|
|
839
|
+
// If not expecting JSON, return raw content directly
|
|
840
|
+
if (!expectsJsonResponse) {
|
|
841
|
+
await onProgressReport({
|
|
842
|
+
message: "Processing complete",
|
|
843
|
+
progress: maxPercent,
|
|
844
|
+
});
|
|
845
|
+
return rawContent;
|
|
846
|
+
}
|
|
621
847
|
try {
|
|
622
|
-
const
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
848
|
+
const result = parseAndValidateJSONResponse(rawContent, schema);
|
|
849
|
+
await onProgressReport({
|
|
850
|
+
message: "Processing complete",
|
|
851
|
+
progress: maxPercent,
|
|
852
|
+
});
|
|
853
|
+
return result;
|
|
627
854
|
}
|
|
628
|
-
catch (
|
|
629
|
-
|
|
630
|
-
|
|
855
|
+
catch (err) {
|
|
856
|
+
const isValidationFailure = err?.code === constants.HTTP_CODES.UNPROCESSABLE_ENTITY;
|
|
857
|
+
if (isValidationFailure &&
|
|
858
|
+
canRetryOnValidation &&
|
|
859
|
+
attempt < MAX_VALIDATION_RETRIES) {
|
|
860
|
+
attempt++;
|
|
861
|
+
logger.log(null, logger.levels.warn, `Schema validation failed for "${modelName}", retrying (${attempt}/${MAX_VALIDATION_RETRIES}): ${err.message}`);
|
|
862
|
+
activeMessages = buildValidationRetryMessages(activeMessages, rawContent, err);
|
|
863
|
+
continue;
|
|
631
864
|
}
|
|
865
|
+
throw err;
|
|
632
866
|
}
|
|
633
867
|
}
|
|
634
|
-
const extracted = extractJSONFromResponse(rawContent);
|
|
635
|
-
if (extracted) {
|
|
636
|
-
if (schema) {
|
|
637
|
-
validateWithSchema(extracted, schema);
|
|
638
|
-
}
|
|
639
|
-
return JSON.stringify(extracted);
|
|
640
|
-
}
|
|
641
|
-
let preview = "";
|
|
642
|
-
if (typeof rawContent === "string") {
|
|
643
|
-
preview = rawContent.substring(0, 100);
|
|
644
|
-
}
|
|
645
|
-
else if (typeof rawContent === "object" && rawContent !== null) {
|
|
646
|
-
preview = JSON.stringify(rawContent).substring(0, 100);
|
|
647
|
-
}
|
|
648
|
-
else {
|
|
649
|
-
preview = "[empty response]";
|
|
650
|
-
}
|
|
651
|
-
throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, `LLM returned non-JSON response when JSON was expected: ${preview}...`);
|
|
652
868
|
}
|
|
653
869
|
else {
|
|
654
870
|
// Non-streaming mode: use native response_format for OpenAI when schema is provided
|
|
@@ -688,65 +904,36 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
|
|
|
688
904
|
else {
|
|
689
905
|
messagesToSend = messages;
|
|
690
906
|
}
|
|
691
|
-
//
|
|
692
|
-
|
|
693
|
-
const
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
(trimmed.startsWith("[") && trimmed.endsWith("]"))) {
|
|
705
|
-
try {
|
|
706
|
-
// Parse and re-stringify
|
|
707
|
-
const parsed = JSON.parse(trimmed);
|
|
708
|
-
// Validate against schema if provided
|
|
709
|
-
if (schema) {
|
|
710
|
-
validateWithSchema(parsed, schema);
|
|
711
|
-
}
|
|
712
|
-
return JSON.stringify(parsed);
|
|
713
|
-
}
|
|
714
|
-
catch (parseError) {
|
|
715
|
-
// If it's a validation error, re-throw it
|
|
716
|
-
if (parseError?.code === constants.HTTP_CODES.UNPROCESSABLE_ENTITY) {
|
|
717
|
-
throw parseError;
|
|
718
|
-
}
|
|
719
|
-
// Not valid JSON, try extraction
|
|
720
|
-
}
|
|
907
|
+
// Retry once on schema validation failure (see streaming branch for rationale).
|
|
908
|
+
// Each attempt is billed independently via recordCost so retry cost is visible.
|
|
909
|
+
const canRetryOnValidation = expectsJsonResponse && !!schema && !useNativeSchema;
|
|
910
|
+
let activeMessages = messagesToSend;
|
|
911
|
+
let attempt = 0;
|
|
912
|
+
while (true) {
|
|
913
|
+
assertQuotaAvailable();
|
|
914
|
+
const response = await llm.invoke(activeMessages);
|
|
915
|
+
recordCost(calculateTextCost(modelName, extractUsageFromInvoke(response), config));
|
|
916
|
+
const rawContent = response?.content || response;
|
|
917
|
+
// If not expecting JSON, return raw content directly
|
|
918
|
+
if (!expectsJsonResponse) {
|
|
919
|
+
return rawContent;
|
|
721
920
|
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
if (extracted) {
|
|
725
|
-
// Validate against schema if provided
|
|
726
|
-
if (schema) {
|
|
727
|
-
validateWithSchema(extracted, schema);
|
|
728
|
-
}
|
|
729
|
-
return JSON.stringify(extracted);
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
// Return raw content as last resort - but throw if JSON was expected
|
|
733
|
-
if (expectsJsonResponse) {
|
|
734
|
-
let preview = "";
|
|
735
|
-
if (typeof rawContent === "string") {
|
|
736
|
-
preview = rawContent.substring(0, 100);
|
|
737
|
-
}
|
|
738
|
-
else if (typeof rawContent === "object" && rawContent !== null) {
|
|
739
|
-
preview = JSON.stringify(rawContent).substring(0, 100);
|
|
740
|
-
}
|
|
741
|
-
else if (rawContent !== undefined && rawContent !== null) {
|
|
742
|
-
preview = String(rawContent).substring(0, 100);
|
|
921
|
+
try {
|
|
922
|
+
return parseAndValidateJSONResponse(rawContent, schema);
|
|
743
923
|
}
|
|
744
|
-
|
|
745
|
-
|
|
924
|
+
catch (err) {
|
|
925
|
+
const isValidationFailure = err?.code === constants.HTTP_CODES.UNPROCESSABLE_ENTITY;
|
|
926
|
+
if (isValidationFailure &&
|
|
927
|
+
canRetryOnValidation &&
|
|
928
|
+
attempt < MAX_VALIDATION_RETRIES) {
|
|
929
|
+
attempt++;
|
|
930
|
+
logger.log(null, logger.levels.warn, `Schema validation failed for "${modelName}", retrying (${attempt}/${MAX_VALIDATION_RETRIES}): ${err.message}`);
|
|
931
|
+
activeMessages = buildValidationRetryMessages(activeMessages, rawContent, err);
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
throw err;
|
|
746
935
|
}
|
|
747
|
-
throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, `LLM returned non-JSON response when JSON was expected: ${preview}...`);
|
|
748
936
|
}
|
|
749
|
-
return rawContent;
|
|
750
937
|
}
|
|
751
938
|
};
|
|
752
939
|
/**
|
|
@@ -872,7 +1059,9 @@ const generateImageWithOpenAI = async (modelName, config, prompt, options) => {
|
|
|
872
1059
|
requestParams.style = style;
|
|
873
1060
|
}
|
|
874
1061
|
}
|
|
1062
|
+
assertQuotaAvailable();
|
|
875
1063
|
const response = await openai.images.generate(requestParams);
|
|
1064
|
+
recordCost(calculateImageCost(modelName, response.data?.length || n, config));
|
|
876
1065
|
// Format response based on number of images
|
|
877
1066
|
if (n === 1) {
|
|
878
1067
|
const imageData = response.data[0];
|
|
@@ -970,6 +1159,7 @@ const generateImageWithGoogle = async (modelName, config, prompt, options) => {
|
|
|
970
1159
|
safetySettings: safetySettings,
|
|
971
1160
|
},
|
|
972
1161
|
};
|
|
1162
|
+
assertQuotaAvailable();
|
|
973
1163
|
const response = await ai.models.generateContent(req);
|
|
974
1164
|
// Extract images from response
|
|
975
1165
|
const images = [];
|
|
@@ -988,6 +1178,7 @@ const generateImageWithGoogle = async (modelName, config, prompt, options) => {
|
|
|
988
1178
|
if (images.length === 0) {
|
|
989
1179
|
throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, `No images were generated by ${modelName}`);
|
|
990
1180
|
}
|
|
1181
|
+
recordCost(calculateImageCost(modelName, images.length, config));
|
|
991
1182
|
if (numberOfImages === 1 || images.length === 1) {
|
|
992
1183
|
return images[0];
|
|
993
1184
|
}
|