@stackfactor/agent-utils 1.0.20 → 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/langChain.d.ts.map +1 -1
- package/dist/cjs/langChain.js +156 -123
- package/dist/esm/langChain.d.ts.map +1 -1
- package/dist/esm/langChain.js +156 -123
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
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;
|
|
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
|
@@ -551,6 +551,75 @@ const throwErrorIfNotSuccessful = (response) => {
|
|
|
551
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);
|
|
552
552
|
}
|
|
553
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
|
+
};
|
|
554
623
|
/**
|
|
555
624
|
* Sends a prompt to an LLM and returns the response, with support for streaming,
|
|
556
625
|
* agentic execution, progress reporting, JSON extraction, and Zod schema validation.
|
|
@@ -728,84 +797,79 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
|
|
|
728
797
|
else {
|
|
729
798
|
messagesToSend = messages;
|
|
730
799
|
}
|
|
731
|
-
//
|
|
732
|
-
|
|
733
|
-
|
|
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;
|
|
734
807
|
const progressReportInterval = 10; // Report every N chunks
|
|
735
|
-
const
|
|
808
|
+
const overallStartTime = Date.now();
|
|
736
809
|
// Use a time-based asymptotic curve: progress approaches maxPercent but never
|
|
737
810
|
// overshoots. This avoids the magic "expected length" constant — longer responses
|
|
738
811
|
// simply slow the curve down rather than exceeding the range.
|
|
739
812
|
const expectedDurationMs = 15_000; // Tune: expected typical response time
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
const
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
progress
|
|
758
|
-
|
|
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
|
+
}
|
|
759
838
|
}
|
|
760
839
|
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
}
|
|
774
|
-
// Parse and validate JSON response
|
|
775
|
-
const trimmed = rawContent.trim();
|
|
776
|
-
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
|
777
|
-
(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
|
+
}
|
|
778
852
|
try {
|
|
779
|
-
const
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
853
|
+
const result = parseAndValidateJSONResponse(rawContent, schema);
|
|
854
|
+
await onProgressReport({
|
|
855
|
+
message: "Processing complete",
|
|
856
|
+
progress: maxPercent,
|
|
857
|
+
});
|
|
858
|
+
return result;
|
|
784
859
|
}
|
|
785
|
-
catch (
|
|
786
|
-
|
|
787
|
-
|
|
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;
|
|
788
869
|
}
|
|
870
|
+
throw err;
|
|
789
871
|
}
|
|
790
872
|
}
|
|
791
|
-
const extracted = extractJSONFromResponse(rawContent);
|
|
792
|
-
if (extracted) {
|
|
793
|
-
if (schema) {
|
|
794
|
-
validateWithSchema(extracted, schema);
|
|
795
|
-
}
|
|
796
|
-
return JSON.stringify(extracted);
|
|
797
|
-
}
|
|
798
|
-
let preview = "";
|
|
799
|
-
if (typeof rawContent === "string") {
|
|
800
|
-
preview = rawContent.substring(0, 100);
|
|
801
|
-
}
|
|
802
|
-
else if (typeof rawContent === "object" && rawContent !== null) {
|
|
803
|
-
preview = JSON.stringify(rawContent).substring(0, 100);
|
|
804
|
-
}
|
|
805
|
-
else {
|
|
806
|
-
preview = "[empty response]";
|
|
807
|
-
}
|
|
808
|
-
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}...`);
|
|
809
873
|
}
|
|
810
874
|
else {
|
|
811
875
|
// Non-streaming mode: use native response_format for OpenAI when schema is provided
|
|
@@ -845,67 +909,36 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
|
|
|
845
909
|
else {
|
|
846
910
|
messagesToSend = messages;
|
|
847
911
|
}
|
|
848
|
-
//
|
|
849
|
-
|
|
850
|
-
const
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
// Check if it's already clean JSON
|
|
862
|
-
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
|
863
|
-
(trimmed.startsWith("[") && trimmed.endsWith("]"))) {
|
|
864
|
-
try {
|
|
865
|
-
// Parse and re-stringify
|
|
866
|
-
const parsed = JSON.parse(trimmed);
|
|
867
|
-
// Validate against schema if provided
|
|
868
|
-
if (schema) {
|
|
869
|
-
validateWithSchema(parsed, schema);
|
|
870
|
-
}
|
|
871
|
-
return JSON.stringify(parsed);
|
|
872
|
-
}
|
|
873
|
-
catch (parseError) {
|
|
874
|
-
// If it's a validation error, re-throw it
|
|
875
|
-
if (parseError?.code === const_js_1.default.HTTP_CODES.UNPROCESSABLE_ENTITY) {
|
|
876
|
-
throw parseError;
|
|
877
|
-
}
|
|
878
|
-
// Not valid JSON, try extraction
|
|
879
|
-
}
|
|
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;
|
|
880
925
|
}
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
if (extracted) {
|
|
884
|
-
// Validate against schema if provided
|
|
885
|
-
if (schema) {
|
|
886
|
-
validateWithSchema(extracted, schema);
|
|
887
|
-
}
|
|
888
|
-
return JSON.stringify(extracted);
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
// Return raw content as last resort - but throw if JSON was expected
|
|
892
|
-
if (expectsJsonResponse) {
|
|
893
|
-
let preview = "";
|
|
894
|
-
if (typeof rawContent === "string") {
|
|
895
|
-
preview = rawContent.substring(0, 100);
|
|
896
|
-
}
|
|
897
|
-
else if (typeof rawContent === "object" && rawContent !== null) {
|
|
898
|
-
preview = JSON.stringify(rawContent).substring(0, 100);
|
|
899
|
-
}
|
|
900
|
-
else if (rawContent !== undefined && rawContent !== null) {
|
|
901
|
-
preview = String(rawContent).substring(0, 100);
|
|
926
|
+
try {
|
|
927
|
+
return parseAndValidateJSONResponse(rawContent, schema);
|
|
902
928
|
}
|
|
903
|
-
|
|
904
|
-
|
|
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;
|
|
905
940
|
}
|
|
906
|
-
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}...`);
|
|
907
941
|
}
|
|
908
|
-
return rawContent;
|
|
909
942
|
}
|
|
910
943
|
};
|
|
911
944
|
/**
|
|
@@ -1 +1 @@
|
|
|
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;
|
|
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
|
@@ -546,6 +546,75 @@ const throwErrorIfNotSuccessful = (response) => {
|
|
|
546
546
|
throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, constants.ERROR.UNABLE_TO_GENERATE_CONTENT);
|
|
547
547
|
}
|
|
548
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
|
+
};
|
|
549
618
|
/**
|
|
550
619
|
* Sends a prompt to an LLM and returns the response, with support for streaming,
|
|
551
620
|
* agentic execution, progress reporting, JSON extraction, and Zod schema validation.
|
|
@@ -723,84 +792,79 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
|
|
|
723
792
|
else {
|
|
724
793
|
messagesToSend = messages;
|
|
725
794
|
}
|
|
726
|
-
//
|
|
727
|
-
|
|
728
|
-
|
|
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;
|
|
729
802
|
const progressReportInterval = 10; // Report every N chunks
|
|
730
|
-
const
|
|
803
|
+
const overallStartTime = Date.now();
|
|
731
804
|
// Use a time-based asymptotic curve: progress approaches maxPercent but never
|
|
732
805
|
// overshoots. This avoids the magic "expected length" constant — longer responses
|
|
733
806
|
// simply slow the curve down rather than exceeding the range.
|
|
734
807
|
const expectedDurationMs = 15_000; // Tune: expected typical response time
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
const
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
progress
|
|
753
|
-
|
|
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
|
+
}
|
|
754
833
|
}
|
|
755
834
|
}
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
}
|
|
769
|
-
// Parse and validate JSON response
|
|
770
|
-
const trimmed = rawContent.trim();
|
|
771
|
-
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
|
772
|
-
(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
|
+
}
|
|
773
847
|
try {
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
848
|
+
const result = parseAndValidateJSONResponse(rawContent, schema);
|
|
849
|
+
await onProgressReport({
|
|
850
|
+
message: "Processing complete",
|
|
851
|
+
progress: maxPercent,
|
|
852
|
+
});
|
|
853
|
+
return result;
|
|
779
854
|
}
|
|
780
|
-
catch (
|
|
781
|
-
|
|
782
|
-
|
|
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;
|
|
783
864
|
}
|
|
865
|
+
throw err;
|
|
784
866
|
}
|
|
785
867
|
}
|
|
786
|
-
const extracted = extractJSONFromResponse(rawContent);
|
|
787
|
-
if (extracted) {
|
|
788
|
-
if (schema) {
|
|
789
|
-
validateWithSchema(extracted, schema);
|
|
790
|
-
}
|
|
791
|
-
return JSON.stringify(extracted);
|
|
792
|
-
}
|
|
793
|
-
let preview = "";
|
|
794
|
-
if (typeof rawContent === "string") {
|
|
795
|
-
preview = rawContent.substring(0, 100);
|
|
796
|
-
}
|
|
797
|
-
else if (typeof rawContent === "object" && rawContent !== null) {
|
|
798
|
-
preview = JSON.stringify(rawContent).substring(0, 100);
|
|
799
|
-
}
|
|
800
|
-
else {
|
|
801
|
-
preview = "[empty response]";
|
|
802
|
-
}
|
|
803
|
-
throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, `LLM returned non-JSON response when JSON was expected: ${preview}...`);
|
|
804
868
|
}
|
|
805
869
|
else {
|
|
806
870
|
// Non-streaming mode: use native response_format for OpenAI when schema is provided
|
|
@@ -840,67 +904,36 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
|
|
|
840
904
|
else {
|
|
841
905
|
messagesToSend = messages;
|
|
842
906
|
}
|
|
843
|
-
//
|
|
844
|
-
|
|
845
|
-
const
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
// Check if it's already clean JSON
|
|
857
|
-
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
|
858
|
-
(trimmed.startsWith("[") && trimmed.endsWith("]"))) {
|
|
859
|
-
try {
|
|
860
|
-
// Parse and re-stringify
|
|
861
|
-
const parsed = JSON.parse(trimmed);
|
|
862
|
-
// Validate against schema if provided
|
|
863
|
-
if (schema) {
|
|
864
|
-
validateWithSchema(parsed, schema);
|
|
865
|
-
}
|
|
866
|
-
return JSON.stringify(parsed);
|
|
867
|
-
}
|
|
868
|
-
catch (parseError) {
|
|
869
|
-
// If it's a validation error, re-throw it
|
|
870
|
-
if (parseError?.code === constants.HTTP_CODES.UNPROCESSABLE_ENTITY) {
|
|
871
|
-
throw parseError;
|
|
872
|
-
}
|
|
873
|
-
// Not valid JSON, try extraction
|
|
874
|
-
}
|
|
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;
|
|
875
920
|
}
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
if (extracted) {
|
|
879
|
-
// Validate against schema if provided
|
|
880
|
-
if (schema) {
|
|
881
|
-
validateWithSchema(extracted, schema);
|
|
882
|
-
}
|
|
883
|
-
return JSON.stringify(extracted);
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
// Return raw content as last resort - but throw if JSON was expected
|
|
887
|
-
if (expectsJsonResponse) {
|
|
888
|
-
let preview = "";
|
|
889
|
-
if (typeof rawContent === "string") {
|
|
890
|
-
preview = rawContent.substring(0, 100);
|
|
891
|
-
}
|
|
892
|
-
else if (typeof rawContent === "object" && rawContent !== null) {
|
|
893
|
-
preview = JSON.stringify(rawContent).substring(0, 100);
|
|
894
|
-
}
|
|
895
|
-
else if (rawContent !== undefined && rawContent !== null) {
|
|
896
|
-
preview = String(rawContent).substring(0, 100);
|
|
921
|
+
try {
|
|
922
|
+
return parseAndValidateJSONResponse(rawContent, schema);
|
|
897
923
|
}
|
|
898
|
-
|
|
899
|
-
|
|
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;
|
|
900
935
|
}
|
|
901
|
-
throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, `LLM returned non-JSON response when JSON was expected: ${preview}...`);
|
|
902
936
|
}
|
|
903
|
-
return rawContent;
|
|
904
937
|
}
|
|
905
938
|
};
|
|
906
939
|
/**
|