@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.
@@ -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;wCAmgBF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,KACpB,GAAG;oCAhZO,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;sDA4sBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,KACX,OAAO,CAAC,GAAG,CAAC;0CA5wB8B,GAAG,KAAG,MAAM;;AAu0BzD,wBAOE"}
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"}
@@ -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
- // Stream and report server-side progress based on time elapsed
732
- let rawContent = "";
733
- let chunkCount = 0;
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 startTime = Date.now();
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
- const streamUsage = { input_tokens: 0, output_tokens: 0 };
741
- assertQuotaAvailable();
742
- const stream = await llm.stream(messagesToSend);
743
- for await (const chunk of stream) {
744
- accumulateChunkUsage(streamUsage, chunk);
745
- const content = chunk?.content || chunk;
746
- if (typeof content === "string") {
747
- rawContent += content;
748
- chunkCount++;
749
- if (chunkCount % progressReportInterval === 0) {
750
- const elapsed = Date.now() - startTime;
751
- // Asymptotic curve: fast early progress that slows as it approaches max
752
- const progress = Math.round(minPercent +
753
- (maxPercent - minPercent - 5) *
754
- (1 - Math.exp(-elapsed / expectedDurationMs)));
755
- await onProgressReport({
756
- message: "Generating content...",
757
- progress: Math.min(progress, maxPercent - 5),
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
- recordCost(calculateTextCost(modelName, streamUsage, config));
763
- await onProgressReport({
764
- message: "Processing complete",
765
- progress: maxPercent,
766
- });
767
- if (!rawContent) {
768
- throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, "LLM returned no content");
769
- }
770
- // If not expecting JSON, return raw content directly
771
- if (!expectsJsonResponse) {
772
- return rawContent;
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 parsed = JSON.parse(trimmed);
780
- if (schema) {
781
- validateWithSchema(parsed, schema);
782
- }
783
- return JSON.stringify(parsed);
853
+ const result = parseAndValidateJSONResponse(rawContent, schema);
854
+ await onProgressReport({
855
+ message: "Processing complete",
856
+ progress: maxPercent,
857
+ });
858
+ return result;
784
859
  }
785
- catch (parseError) {
786
- if (parseError?.code === const_js_1.default.HTTP_CODES.UNPROCESSABLE_ENTITY) {
787
- throw parseError;
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
- // Simply invoke without streaming
849
- assertQuotaAvailable();
850
- const response = await llm.invoke(messagesToSend);
851
- recordCost(calculateTextCost(modelName, extractUsageFromInvoke(response), config));
852
- const rawContent = response?.content || response;
853
- // If not expecting JSON, return raw content directly
854
- if (!expectsJsonResponse) {
855
- return rawContent;
856
- }
857
- // If the response is already a string that looks like JSON, return it
858
- // Otherwise, try to extract JSON from potential markdown wrapping
859
- if (typeof rawContent === "string") {
860
- const trimmed = rawContent.trim();
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
- // Try to extract JSON from markdown or other wrapping
882
- const extracted = extractJSONFromResponse(rawContent);
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
- else {
904
- preview = "[empty response]";
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;wCAmgBF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,KACpB,GAAG;oCAhZO,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;sDA4sBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,KACX,OAAO,CAAC,GAAG,CAAC;0CA5wB8B,GAAG,KAAG,MAAM;;AAu0BzD,wBAOE"}
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"}
@@ -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
- // Stream and report server-side progress based on time elapsed
727
- let rawContent = "";
728
- let chunkCount = 0;
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 startTime = Date.now();
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
- const streamUsage = { input_tokens: 0, output_tokens: 0 };
736
- assertQuotaAvailable();
737
- const stream = await llm.stream(messagesToSend);
738
- for await (const chunk of stream) {
739
- accumulateChunkUsage(streamUsage, chunk);
740
- const content = chunk?.content || chunk;
741
- if (typeof content === "string") {
742
- rawContent += content;
743
- chunkCount++;
744
- if (chunkCount % progressReportInterval === 0) {
745
- const elapsed = Date.now() - startTime;
746
- // Asymptotic curve: fast early progress that slows as it approaches max
747
- const progress = Math.round(minPercent +
748
- (maxPercent - minPercent - 5) *
749
- (1 - Math.exp(-elapsed / expectedDurationMs)));
750
- await onProgressReport({
751
- message: "Generating content...",
752
- progress: Math.min(progress, maxPercent - 5),
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
- recordCost(calculateTextCost(modelName, streamUsage, config));
758
- await onProgressReport({
759
- message: "Processing complete",
760
- progress: maxPercent,
761
- });
762
- if (!rawContent) {
763
- throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, "LLM returned no content");
764
- }
765
- // If not expecting JSON, return raw content directly
766
- if (!expectsJsonResponse) {
767
- return rawContent;
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 parsed = JSON.parse(trimmed);
775
- if (schema) {
776
- validateWithSchema(parsed, schema);
777
- }
778
- return JSON.stringify(parsed);
848
+ const result = parseAndValidateJSONResponse(rawContent, schema);
849
+ await onProgressReport({
850
+ message: "Processing complete",
851
+ progress: maxPercent,
852
+ });
853
+ return result;
779
854
  }
780
- catch (parseError) {
781
- if (parseError?.code === constants.HTTP_CODES.UNPROCESSABLE_ENTITY) {
782
- throw parseError;
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
- // Simply invoke without streaming
844
- assertQuotaAvailable();
845
- const response = await llm.invoke(messagesToSend);
846
- recordCost(calculateTextCost(modelName, extractUsageFromInvoke(response), config));
847
- const rawContent = response?.content || response;
848
- // If not expecting JSON, return raw content directly
849
- if (!expectsJsonResponse) {
850
- return rawContent;
851
- }
852
- // If the response is already a string that looks like JSON, return it
853
- // Otherwise, try to extract JSON from potential markdown wrapping
854
- if (typeof rawContent === "string") {
855
- const trimmed = rawContent.trim();
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
- // Try to extract JSON from markdown or other wrapping
877
- const extracted = extractJSONFromResponse(rawContent);
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
- else {
899
- preview = "[empty response]";
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
  /**
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "restricted"
5
5
  },
6
- "version": "1.0.20",
6
+ "version": "1.0.21",
7
7
  "description": "",
8
8
  "main": "dist/cjs/index.js",
9
9
  "module": "dist/esm/index.js",