@stackfactor/agent-utils 1.0.20 → 1.0.22

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;wCA2xBF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,KACpB,GAAG;oCA1bO,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;sDAsvBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,KACX,OAAO,CAAC,GAAG,CAAC;0CAngC8B,GAAG,KAAG,MAAM;;AA8jCzD,wBAOE"}
@@ -522,14 +522,44 @@ const runAgent = async (agent, prompt, config, onProgress = null) => {
522
522
  },
523
523
  ]
524
524
  : undefined;
525
- assertQuotaAvailable();
526
- const response = await agent.invoke({
527
- messages: [{ role: "user", content: prompt }],
528
- }, {
529
- recursionLimit: config.recursionLimit || 25,
530
- ...(callbacks ? { callbacks } : {}),
531
- });
532
525
  const modelName = agent.options?.model?.modelName || agent.options?.model?.model || "";
526
+ // Wait + retry on 429 around the full agent run. The agent loop may issue
527
+ // many internal LLM calls, but a rate limit surfaces as a thrown error from
528
+ // the wrapping invoke. On retry we restart the agent run from scratch — any
529
+ // partial progress (tool calls, intermediate messages) is discarded, since
530
+ // the agent state isn't externally checkpointed. Cost is only recorded on a
531
+ // successful completion. Matches the behavior of the non-agentic paths.
532
+ let response;
533
+ let rateLimitAttempt = 0;
534
+ while (true) {
535
+ try {
536
+ assertQuotaAvailable();
537
+ response = await agent.invoke({
538
+ messages: [{ role: "user", content: prompt }],
539
+ }, {
540
+ recursionLimit: config.recursionLimit || 25,
541
+ ...(callbacks ? { callbacks } : {}),
542
+ });
543
+ break;
544
+ }
545
+ catch (err) {
546
+ if (isRateLimitError(err) && rateLimitAttempt < MAX_RATE_LIMIT_RETRIES) {
547
+ rateLimitAttempt++;
548
+ const { waitMs, source } = getRateLimitWaitMs(err);
549
+ const waitSeconds = Math.round(waitMs / 1000);
550
+ logger_js_1.default.log(null, logger_js_1.default.levels.warn, `Rate limited by "${modelName}" (agent), waiting ${waitSeconds}s before retry (${rateLimitAttempt}/${MAX_RATE_LIMIT_RETRIES}, source=${source}): ${err?.message ?? err}`);
551
+ if (onProgress) {
552
+ onProgress({
553
+ type: "rate_limit",
554
+ message: `AI service is busy. Retrying in ${waitSeconds}s...`,
555
+ });
556
+ }
557
+ await sleep(waitMs);
558
+ continue;
559
+ }
560
+ throw err;
561
+ }
562
+ }
533
563
  recordCost(calculateTextCost(modelName, sumAgentResponseUsage(response), config));
534
564
  const endTime = Date.now();
535
565
  const duration = endTime - startTime;
@@ -551,6 +581,191 @@ const throwErrorIfNotSuccessful = (response) => {
551
581
  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
582
  }
553
583
  };
584
+ const MAX_VALIDATION_RETRIES = 3;
585
+ const MAX_RATE_LIMIT_RETRIES = 2;
586
+ const RATE_LIMIT_MIN_WAIT_MS = 30_000;
587
+ const RATE_LIMIT_MAX_WAIT_MS = 60_000;
588
+ // Cap on how long we'll honor a provider Retry-After hint. If a provider asks
589
+ // for longer than this, treat it as a signal that the request shouldn't be
590
+ // retried in-flight and fail fast instead of holding the caller open.
591
+ const RATE_LIMIT_MAX_HONORED_WAIT_MS = 5 * 60 * 1000;
592
+ // Small jitter added on top of an honored Retry-After so concurrent callers
593
+ // that all received the same hint don't wake at exactly the same instant.
594
+ const RATE_LIMIT_HONORED_JITTER_MS = 2_000;
595
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
596
+ /**
597
+ * Extracts a Retry-After hint (in milliseconds) from a provider 429 error.
598
+ * Different SDKs put headers in different places (top-level `headers`, nested
599
+ * `response.headers`, `responseHeaders`, or under `cause`), and some include a
600
+ * millisecond-precision `retry-after-ms` variant. Also checks the response body
601
+ * for `retry_after` / `retry_after_ms` fields (some providers put it there
602
+ * instead of headers). Standard `Retry-After` may be seconds or an HTTP date.
603
+ * Returns `null` when no usable hint is present.
604
+ */
605
+ const parseRetryAfterFromError = (err) => {
606
+ const headerSources = [
607
+ err?.headers,
608
+ err?.response?.headers,
609
+ err?.responseHeaders,
610
+ err?.cause?.headers,
611
+ err?.cause?.response?.headers,
612
+ ];
613
+ for (const headers of headerSources) {
614
+ if (!headers || typeof headers !== "object")
615
+ continue;
616
+ const retryAfterMs = headers["retry-after-ms"] ?? headers["Retry-After-Ms"];
617
+ if (retryAfterMs != null) {
618
+ const ms = Number(retryAfterMs);
619
+ if (Number.isFinite(ms) && ms > 0)
620
+ return ms;
621
+ }
622
+ const retryAfter = headers["retry-after"] ?? headers["Retry-After"];
623
+ if (retryAfter != null) {
624
+ const asNumber = Number(retryAfter);
625
+ if (Number.isFinite(asNumber) && asNumber > 0) {
626
+ return asNumber * 1000;
627
+ }
628
+ const dateMs = Date.parse(String(retryAfter));
629
+ if (!Number.isNaN(dateMs)) {
630
+ const delta = dateMs - Date.now();
631
+ if (delta > 0)
632
+ return delta;
633
+ }
634
+ }
635
+ }
636
+ const bodySources = [err?.error, err?.response?.data, err?.body];
637
+ for (const body of bodySources) {
638
+ if (!body || typeof body !== "object")
639
+ continue;
640
+ const retryAfterMs = body.retry_after_ms ?? body.retryAfterMs;
641
+ if (typeof retryAfterMs === "number" && retryAfterMs > 0) {
642
+ return retryAfterMs;
643
+ }
644
+ const retryAfterS = body.retry_after ?? body.retryAfter;
645
+ if (typeof retryAfterS === "number" && retryAfterS > 0) {
646
+ return retryAfterS * 1000;
647
+ }
648
+ }
649
+ return null;
650
+ };
651
+ /**
652
+ * Picks the back-off duration before retrying a 429. When the provider supplied
653
+ * a Retry-After hint, that value is honored (capped at
654
+ * RATE_LIMIT_MAX_HONORED_WAIT_MS, with small jitter added to avoid thundering
655
+ * herd on the same wake instant). When no hint is present, falls back to a
656
+ * uniform random wait in [RATE_LIMIT_MIN_WAIT_MS, RATE_LIMIT_MAX_WAIT_MS] —
657
+ * which is the right default for "we don't know how long this will last."
658
+ * Returns both the chosen ms and the source so callers can log it.
659
+ */
660
+ const getRateLimitWaitMs = (err) => {
661
+ const hint = parseRetryAfterFromError(err);
662
+ if (hint != null) {
663
+ const honored = Math.min(hint, RATE_LIMIT_MAX_HONORED_WAIT_MS);
664
+ const jitter = Math.floor(Math.random() * RATE_LIMIT_HONORED_JITTER_MS);
665
+ return {
666
+ waitMs: Math.max(honored + jitter, 1_000),
667
+ source: "retry-after",
668
+ };
669
+ }
670
+ const waitMs = RATE_LIMIT_MIN_WAIT_MS +
671
+ Math.floor(Math.random() * (RATE_LIMIT_MAX_WAIT_MS - RATE_LIMIT_MIN_WAIT_MS + 1));
672
+ return { waitMs, source: "random" };
673
+ };
674
+ /**
675
+ * Detects rate-limit (HTTP 429) errors across LangChain and provider SDK shapes.
676
+ * Different SDKs surface the status in different places (status, statusCode,
677
+ * response.status, code) and sometimes only in the message text, so this checks
678
+ * each known shape rather than assuming a single field.
679
+ */
680
+ const isRateLimitError = (err) => {
681
+ if (!err)
682
+ return false;
683
+ const statusCandidates = [
684
+ err.status,
685
+ err.statusCode,
686
+ err.response?.status,
687
+ err.response?.statusCode,
688
+ err.cause?.status,
689
+ err.cause?.statusCode,
690
+ err.code,
691
+ ];
692
+ if (statusCandidates.some((c) => c === 429 || c === "429"))
693
+ return true;
694
+ if (typeof err.code === "string" && /rate.?limit/i.test(err.code))
695
+ return true;
696
+ const message = typeof err.message === "string" ? err.message : "";
697
+ return (/\b429\b/.test(message) ||
698
+ /rate.?limit/i.test(message) ||
699
+ /too many requests/i.test(message));
700
+ };
701
+ /**
702
+ * Parses raw LLM content as JSON and validates against an optional Zod schema,
703
+ * returning the canonical JSON string. Tries a direct parse of the trimmed text
704
+ * first, then falls back to `extractJSONFromResponse` for markdown-wrapped output.
705
+ * Throws `UNPROCESSABLE_ENTITY` when the parsed value fails schema validation, or
706
+ * `INTERNAL_SERVER_ERROR` when no JSON can be extracted from the response at all.
707
+ */
708
+ const parseAndValidateJSONResponse = (rawContent, schema) => {
709
+ if (typeof rawContent === "string") {
710
+ const trimmed = rawContent.trim();
711
+ if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
712
+ (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
713
+ try {
714
+ const parsed = JSON.parse(trimmed);
715
+ if (schema) {
716
+ validateWithSchema(parsed, schema);
717
+ }
718
+ return JSON.stringify(parsed);
719
+ }
720
+ catch (parseError) {
721
+ if (parseError?.code === const_js_1.default.HTTP_CODES.UNPROCESSABLE_ENTITY) {
722
+ throw parseError;
723
+ }
724
+ // JSON.parse failed (not a validation error) — fall through to extraction.
725
+ }
726
+ }
727
+ const extracted = extractJSONFromResponse(rawContent);
728
+ if (extracted) {
729
+ if (schema) {
730
+ validateWithSchema(extracted, schema);
731
+ }
732
+ return JSON.stringify(extracted);
733
+ }
734
+ }
735
+ let preview = "";
736
+ if (typeof rawContent === "string") {
737
+ preview = rawContent.substring(0, 100);
738
+ }
739
+ else if (typeof rawContent === "object" && rawContent !== null) {
740
+ preview = JSON.stringify(rawContent).substring(0, 100);
741
+ }
742
+ else if (rawContent !== undefined && rawContent !== null) {
743
+ preview = String(rawContent).substring(0, 100);
744
+ }
745
+ else {
746
+ preview = "[empty response]";
747
+ }
748
+ 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}...`);
749
+ };
750
+ /**
751
+ * Appends the model's failed output as an assistant turn followed by a user turn
752
+ * containing the Zod validation errors. Including the bad assistant turn (not just
753
+ * the critique) lets the model see what it actually produced — without it,
754
+ * self-correction is guesswork. Callers pass the *current* message list (not the
755
+ * original base) so that across multiple retries the full failure history
756
+ * accumulates and the model can avoid oscillating between previous broken outputs.
757
+ */
758
+ const buildValidationRetryMessages = (priorMessages, rawContent, validationError) => {
759
+ const assistantContent = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent);
760
+ return [
761
+ ...priorMessages,
762
+ { role: "assistant", content: assistantContent },
763
+ {
764
+ role: "user",
765
+ 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}`,
766
+ },
767
+ ];
768
+ };
554
769
  /**
555
770
  * Sends a prompt to an LLM and returns the response, with support for streaming,
556
771
  * agentic execution, progress reporting, JSON extraction, and Zod schema validation.
@@ -728,84 +943,109 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
728
943
  else {
729
944
  messagesToSend = messages;
730
945
  }
731
- // Stream and report server-side progress based on time elapsed
732
- let rawContent = "";
733
- let chunkCount = 0;
946
+ // Retry once on schema validation failure. The retry is silent to the progress
947
+ // callback same "Generating content..." message and the progress curve
948
+ // continues from the original startTime so the bar doesn't visibly reset.
949
+ // Skipped when OpenAI native response_format is in use (a 422 there is a
950
+ // server-side schema bug, not a model output issue). Each attempt is billed
951
+ // independently via recordCost so retry cost is still visible in telemetry.
952
+ const canRetryOnValidation = expectsJsonResponse && !!schema && !useNativeSchema;
734
953
  const progressReportInterval = 10; // Report every N chunks
735
- const startTime = Date.now();
954
+ const overallStartTime = Date.now();
736
955
  // Use a time-based asymptotic curve: progress approaches maxPercent but never
737
956
  // overshoots. This avoids the magic "expected length" constant — longer responses
738
957
  // simply slow the curve down rather than exceeding the range.
739
958
  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
- });
959
+ let activeMessages = messagesToSend;
960
+ let attempt = 0;
961
+ const calcCurrentProgress = () => {
962
+ const elapsed = Date.now() - overallStartTime;
963
+ return Math.round(minPercent +
964
+ (maxPercent - minPercent - 5) *
965
+ (1 - Math.exp(-elapsed / expectedDurationMs)));
966
+ };
967
+ while (true) {
968
+ let rawContent = "";
969
+ let chunkCount = 0;
970
+ let streamUsage = { input_tokens: 0, output_tokens: 0 };
971
+ // Inner loop: wait + retry on 429 around stream setup and consumption.
972
+ // Cost is only recorded on a successful stream — partial streams that error
973
+ // out with a rate limit are not billed. A 429 fired mid-stream simply
974
+ // discards the partial output and restarts cleanly.
975
+ let rateLimitAttempt = 0;
976
+ while (true) {
977
+ rawContent = "";
978
+ chunkCount = 0;
979
+ streamUsage = { input_tokens: 0, output_tokens: 0 };
980
+ try {
981
+ assertQuotaAvailable();
982
+ const stream = await llm.stream(activeMessages);
983
+ for await (const chunk of stream) {
984
+ accumulateChunkUsage(streamUsage, chunk);
985
+ const content = chunk?.content || chunk;
986
+ if (typeof content === "string") {
987
+ rawContent += content;
988
+ chunkCount++;
989
+ if (chunkCount % progressReportInterval === 0) {
990
+ await onProgressReport({
991
+ message: "Generating content...",
992
+ progress: Math.min(calcCurrentProgress(), maxPercent - 5),
993
+ });
994
+ }
995
+ }
996
+ }
997
+ break; // stream completed without 429
998
+ }
999
+ catch (err) {
1000
+ if (isRateLimitError(err) &&
1001
+ rateLimitAttempt < MAX_RATE_LIMIT_RETRIES) {
1002
+ rateLimitAttempt++;
1003
+ const { waitMs, source } = getRateLimitWaitMs(err);
1004
+ const waitSeconds = Math.round(waitMs / 1000);
1005
+ logger_js_1.default.log(null, logger_js_1.default.levels.warn, `Rate limited by "${modelName}", waiting ${waitSeconds}s before retry (${rateLimitAttempt}/${MAX_RATE_LIMIT_RETRIES}, source=${source}): ${err?.message ?? err}`);
1006
+ await onProgressReport({
1007
+ message: `AI service is busy. Retrying in ${waitSeconds}s...`,
1008
+ progress: Math.min(calcCurrentProgress(), maxPercent - 5),
1009
+ });
1010
+ await sleep(waitMs);
1011
+ continue;
1012
+ }
1013
+ throw err;
759
1014
  }
760
1015
  }
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("]"))) {
1016
+ recordCost(calculateTextCost(modelName, streamUsage, config));
1017
+ if (!rawContent) {
1018
+ throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, "LLM returned no content");
1019
+ }
1020
+ // If not expecting JSON, return raw content directly
1021
+ if (!expectsJsonResponse) {
1022
+ await onProgressReport({
1023
+ message: "Processing complete",
1024
+ progress: maxPercent,
1025
+ });
1026
+ return rawContent;
1027
+ }
778
1028
  try {
779
- const parsed = JSON.parse(trimmed);
780
- if (schema) {
781
- validateWithSchema(parsed, schema);
782
- }
783
- return JSON.stringify(parsed);
1029
+ const result = parseAndValidateJSONResponse(rawContent, schema);
1030
+ await onProgressReport({
1031
+ message: "Processing complete",
1032
+ progress: maxPercent,
1033
+ });
1034
+ return result;
784
1035
  }
785
- catch (parseError) {
786
- if (parseError?.code === const_js_1.default.HTTP_CODES.UNPROCESSABLE_ENTITY) {
787
- throw parseError;
1036
+ catch (err) {
1037
+ const isValidationFailure = err?.code === const_js_1.default.HTTP_CODES.UNPROCESSABLE_ENTITY;
1038
+ if (isValidationFailure &&
1039
+ canRetryOnValidation &&
1040
+ attempt < MAX_VALIDATION_RETRIES) {
1041
+ attempt++;
1042
+ logger_js_1.default.log(null, logger_js_1.default.levels.warn, `Schema validation failed for "${modelName}", retrying (${attempt}/${MAX_VALIDATION_RETRIES}): ${err.message}`);
1043
+ activeMessages = buildValidationRetryMessages(activeMessages, rawContent, err);
1044
+ continue;
788
1045
  }
1046
+ throw err;
789
1047
  }
790
1048
  }
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
1049
  }
810
1050
  else {
811
1051
  // Non-streaming mode: use native response_format for OpenAI when schema is provided
@@ -845,67 +1085,56 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
845
1085
  else {
846
1086
  messagesToSend = messages;
847
1087
  }
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("]"))) {
1088
+ // Retry once on schema validation failure (see streaming branch for rationale).
1089
+ // Each attempt is billed independently via recordCost so retry cost is visible.
1090
+ const canRetryOnValidation = expectsJsonResponse && !!schema && !useNativeSchema;
1091
+ let activeMessages = messagesToSend;
1092
+ let attempt = 0;
1093
+ while (true) {
1094
+ // Inner loop: wait + retry on 429. No progress callback in this branch, so
1095
+ // the wait is silent to the caller — only the warn log is emitted.
1096
+ let response;
1097
+ let rateLimitAttempt = 0;
1098
+ while (true) {
864
1099
  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);
1100
+ assertQuotaAvailable();
1101
+ response = await llm.invoke(activeMessages);
1102
+ break;
872
1103
  }
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;
1104
+ catch (err) {
1105
+ if (isRateLimitError(err) &&
1106
+ rateLimitAttempt < MAX_RATE_LIMIT_RETRIES) {
1107
+ rateLimitAttempt++;
1108
+ const { waitMs, source } = getRateLimitWaitMs(err);
1109
+ logger_js_1.default.log(null, logger_js_1.default.levels.warn, `Rate limited by "${modelName}", waiting ${Math.round(waitMs / 1000)}s before retry (${rateLimitAttempt}/${MAX_RATE_LIMIT_RETRIES}, source=${source}): ${err?.message ?? err}`);
1110
+ await sleep(waitMs);
1111
+ continue;
877
1112
  }
878
- // Not valid JSON, try extraction
1113
+ throw err;
879
1114
  }
880
1115
  }
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);
1116
+ recordCost(calculateTextCost(modelName, extractUsageFromInvoke(response), config));
1117
+ const rawContent = response?.content || response;
1118
+ // If not expecting JSON, return raw content directly
1119
+ if (!expectsJsonResponse) {
1120
+ return rawContent;
899
1121
  }
900
- else if (rawContent !== undefined && rawContent !== null) {
901
- preview = String(rawContent).substring(0, 100);
1122
+ try {
1123
+ return parseAndValidateJSONResponse(rawContent, schema);
902
1124
  }
903
- else {
904
- preview = "[empty response]";
1125
+ catch (err) {
1126
+ const isValidationFailure = err?.code === const_js_1.default.HTTP_CODES.UNPROCESSABLE_ENTITY;
1127
+ if (isValidationFailure &&
1128
+ canRetryOnValidation &&
1129
+ attempt < MAX_VALIDATION_RETRIES) {
1130
+ attempt++;
1131
+ logger_js_1.default.log(null, logger_js_1.default.levels.warn, `Schema validation failed for "${modelName}", retrying (${attempt}/${MAX_VALIDATION_RETRIES}): ${err.message}`);
1132
+ activeMessages = buildValidationRetryMessages(activeMessages, rawContent, err);
1133
+ continue;
1134
+ }
1135
+ throw err;
905
1136
  }
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
1137
  }
908
- return rawContent;
909
1138
  }
910
1139
  };
911
1140
  /**
@@ -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;wCA2xBF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,KACpB,GAAG;oCA1bO,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;sDAsvBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,KACX,OAAO,CAAC,GAAG,CAAC;0CAngC8B,GAAG,KAAG,MAAM;;AA8jCzD,wBAOE"}
@@ -517,14 +517,44 @@ const runAgent = async (agent, prompt, config, onProgress = null) => {
517
517
  },
518
518
  ]
519
519
  : undefined;
520
- assertQuotaAvailable();
521
- const response = await agent.invoke({
522
- messages: [{ role: "user", content: prompt }],
523
- }, {
524
- recursionLimit: config.recursionLimit || 25,
525
- ...(callbacks ? { callbacks } : {}),
526
- });
527
520
  const modelName = agent.options?.model?.modelName || agent.options?.model?.model || "";
521
+ // Wait + retry on 429 around the full agent run. The agent loop may issue
522
+ // many internal LLM calls, but a rate limit surfaces as a thrown error from
523
+ // the wrapping invoke. On retry we restart the agent run from scratch — any
524
+ // partial progress (tool calls, intermediate messages) is discarded, since
525
+ // the agent state isn't externally checkpointed. Cost is only recorded on a
526
+ // successful completion. Matches the behavior of the non-agentic paths.
527
+ let response;
528
+ let rateLimitAttempt = 0;
529
+ while (true) {
530
+ try {
531
+ assertQuotaAvailable();
532
+ response = await agent.invoke({
533
+ messages: [{ role: "user", content: prompt }],
534
+ }, {
535
+ recursionLimit: config.recursionLimit || 25,
536
+ ...(callbacks ? { callbacks } : {}),
537
+ });
538
+ break;
539
+ }
540
+ catch (err) {
541
+ if (isRateLimitError(err) && rateLimitAttempt < MAX_RATE_LIMIT_RETRIES) {
542
+ rateLimitAttempt++;
543
+ const { waitMs, source } = getRateLimitWaitMs(err);
544
+ const waitSeconds = Math.round(waitMs / 1000);
545
+ logger.log(null, logger.levels.warn, `Rate limited by "${modelName}" (agent), waiting ${waitSeconds}s before retry (${rateLimitAttempt}/${MAX_RATE_LIMIT_RETRIES}, source=${source}): ${err?.message ?? err}`);
546
+ if (onProgress) {
547
+ onProgress({
548
+ type: "rate_limit",
549
+ message: `AI service is busy. Retrying in ${waitSeconds}s...`,
550
+ });
551
+ }
552
+ await sleep(waitMs);
553
+ continue;
554
+ }
555
+ throw err;
556
+ }
557
+ }
528
558
  recordCost(calculateTextCost(modelName, sumAgentResponseUsage(response), config));
529
559
  const endTime = Date.now();
530
560
  const duration = endTime - startTime;
@@ -546,6 +576,191 @@ const throwErrorIfNotSuccessful = (response) => {
546
576
  throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, constants.ERROR.UNABLE_TO_GENERATE_CONTENT);
547
577
  }
548
578
  };
579
+ const MAX_VALIDATION_RETRIES = 3;
580
+ const MAX_RATE_LIMIT_RETRIES = 2;
581
+ const RATE_LIMIT_MIN_WAIT_MS = 30_000;
582
+ const RATE_LIMIT_MAX_WAIT_MS = 60_000;
583
+ // Cap on how long we'll honor a provider Retry-After hint. If a provider asks
584
+ // for longer than this, treat it as a signal that the request shouldn't be
585
+ // retried in-flight and fail fast instead of holding the caller open.
586
+ const RATE_LIMIT_MAX_HONORED_WAIT_MS = 5 * 60 * 1000;
587
+ // Small jitter added on top of an honored Retry-After so concurrent callers
588
+ // that all received the same hint don't wake at exactly the same instant.
589
+ const RATE_LIMIT_HONORED_JITTER_MS = 2_000;
590
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
591
+ /**
592
+ * Extracts a Retry-After hint (in milliseconds) from a provider 429 error.
593
+ * Different SDKs put headers in different places (top-level `headers`, nested
594
+ * `response.headers`, `responseHeaders`, or under `cause`), and some include a
595
+ * millisecond-precision `retry-after-ms` variant. Also checks the response body
596
+ * for `retry_after` / `retry_after_ms` fields (some providers put it there
597
+ * instead of headers). Standard `Retry-After` may be seconds or an HTTP date.
598
+ * Returns `null` when no usable hint is present.
599
+ */
600
+ const parseRetryAfterFromError = (err) => {
601
+ const headerSources = [
602
+ err?.headers,
603
+ err?.response?.headers,
604
+ err?.responseHeaders,
605
+ err?.cause?.headers,
606
+ err?.cause?.response?.headers,
607
+ ];
608
+ for (const headers of headerSources) {
609
+ if (!headers || typeof headers !== "object")
610
+ continue;
611
+ const retryAfterMs = headers["retry-after-ms"] ?? headers["Retry-After-Ms"];
612
+ if (retryAfterMs != null) {
613
+ const ms = Number(retryAfterMs);
614
+ if (Number.isFinite(ms) && ms > 0)
615
+ return ms;
616
+ }
617
+ const retryAfter = headers["retry-after"] ?? headers["Retry-After"];
618
+ if (retryAfter != null) {
619
+ const asNumber = Number(retryAfter);
620
+ if (Number.isFinite(asNumber) && asNumber > 0) {
621
+ return asNumber * 1000;
622
+ }
623
+ const dateMs = Date.parse(String(retryAfter));
624
+ if (!Number.isNaN(dateMs)) {
625
+ const delta = dateMs - Date.now();
626
+ if (delta > 0)
627
+ return delta;
628
+ }
629
+ }
630
+ }
631
+ const bodySources = [err?.error, err?.response?.data, err?.body];
632
+ for (const body of bodySources) {
633
+ if (!body || typeof body !== "object")
634
+ continue;
635
+ const retryAfterMs = body.retry_after_ms ?? body.retryAfterMs;
636
+ if (typeof retryAfterMs === "number" && retryAfterMs > 0) {
637
+ return retryAfterMs;
638
+ }
639
+ const retryAfterS = body.retry_after ?? body.retryAfter;
640
+ if (typeof retryAfterS === "number" && retryAfterS > 0) {
641
+ return retryAfterS * 1000;
642
+ }
643
+ }
644
+ return null;
645
+ };
646
+ /**
647
+ * Picks the back-off duration before retrying a 429. When the provider supplied
648
+ * a Retry-After hint, that value is honored (capped at
649
+ * RATE_LIMIT_MAX_HONORED_WAIT_MS, with small jitter added to avoid thundering
650
+ * herd on the same wake instant). When no hint is present, falls back to a
651
+ * uniform random wait in [RATE_LIMIT_MIN_WAIT_MS, RATE_LIMIT_MAX_WAIT_MS] —
652
+ * which is the right default for "we don't know how long this will last."
653
+ * Returns both the chosen ms and the source so callers can log it.
654
+ */
655
+ const getRateLimitWaitMs = (err) => {
656
+ const hint = parseRetryAfterFromError(err);
657
+ if (hint != null) {
658
+ const honored = Math.min(hint, RATE_LIMIT_MAX_HONORED_WAIT_MS);
659
+ const jitter = Math.floor(Math.random() * RATE_LIMIT_HONORED_JITTER_MS);
660
+ return {
661
+ waitMs: Math.max(honored + jitter, 1_000),
662
+ source: "retry-after",
663
+ };
664
+ }
665
+ const waitMs = RATE_LIMIT_MIN_WAIT_MS +
666
+ Math.floor(Math.random() * (RATE_LIMIT_MAX_WAIT_MS - RATE_LIMIT_MIN_WAIT_MS + 1));
667
+ return { waitMs, source: "random" };
668
+ };
669
+ /**
670
+ * Detects rate-limit (HTTP 429) errors across LangChain and provider SDK shapes.
671
+ * Different SDKs surface the status in different places (status, statusCode,
672
+ * response.status, code) and sometimes only in the message text, so this checks
673
+ * each known shape rather than assuming a single field.
674
+ */
675
+ const isRateLimitError = (err) => {
676
+ if (!err)
677
+ return false;
678
+ const statusCandidates = [
679
+ err.status,
680
+ err.statusCode,
681
+ err.response?.status,
682
+ err.response?.statusCode,
683
+ err.cause?.status,
684
+ err.cause?.statusCode,
685
+ err.code,
686
+ ];
687
+ if (statusCandidates.some((c) => c === 429 || c === "429"))
688
+ return true;
689
+ if (typeof err.code === "string" && /rate.?limit/i.test(err.code))
690
+ return true;
691
+ const message = typeof err.message === "string" ? err.message : "";
692
+ return (/\b429\b/.test(message) ||
693
+ /rate.?limit/i.test(message) ||
694
+ /too many requests/i.test(message));
695
+ };
696
+ /**
697
+ * Parses raw LLM content as JSON and validates against an optional Zod schema,
698
+ * returning the canonical JSON string. Tries a direct parse of the trimmed text
699
+ * first, then falls back to `extractJSONFromResponse` for markdown-wrapped output.
700
+ * Throws `UNPROCESSABLE_ENTITY` when the parsed value fails schema validation, or
701
+ * `INTERNAL_SERVER_ERROR` when no JSON can be extracted from the response at all.
702
+ */
703
+ const parseAndValidateJSONResponse = (rawContent, schema) => {
704
+ if (typeof rawContent === "string") {
705
+ const trimmed = rawContent.trim();
706
+ if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
707
+ (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
708
+ try {
709
+ const parsed = JSON.parse(trimmed);
710
+ if (schema) {
711
+ validateWithSchema(parsed, schema);
712
+ }
713
+ return JSON.stringify(parsed);
714
+ }
715
+ catch (parseError) {
716
+ if (parseError?.code === constants.HTTP_CODES.UNPROCESSABLE_ENTITY) {
717
+ throw parseError;
718
+ }
719
+ // JSON.parse failed (not a validation error) — fall through to extraction.
720
+ }
721
+ }
722
+ const extracted = extractJSONFromResponse(rawContent);
723
+ if (extracted) {
724
+ if (schema) {
725
+ validateWithSchema(extracted, schema);
726
+ }
727
+ return JSON.stringify(extracted);
728
+ }
729
+ }
730
+ let preview = "";
731
+ if (typeof rawContent === "string") {
732
+ preview = rawContent.substring(0, 100);
733
+ }
734
+ else if (typeof rawContent === "object" && rawContent !== null) {
735
+ preview = JSON.stringify(rawContent).substring(0, 100);
736
+ }
737
+ else if (rawContent !== undefined && rawContent !== null) {
738
+ preview = String(rawContent).substring(0, 100);
739
+ }
740
+ else {
741
+ preview = "[empty response]";
742
+ }
743
+ throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, `LLM returned non-JSON response when JSON was expected: ${preview}...`);
744
+ };
745
+ /**
746
+ * Appends the model's failed output as an assistant turn followed by a user turn
747
+ * containing the Zod validation errors. Including the bad assistant turn (not just
748
+ * the critique) lets the model see what it actually produced — without it,
749
+ * self-correction is guesswork. Callers pass the *current* message list (not the
750
+ * original base) so that across multiple retries the full failure history
751
+ * accumulates and the model can avoid oscillating between previous broken outputs.
752
+ */
753
+ const buildValidationRetryMessages = (priorMessages, rawContent, validationError) => {
754
+ const assistantContent = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent);
755
+ return [
756
+ ...priorMessages,
757
+ { role: "assistant", content: assistantContent },
758
+ {
759
+ role: "user",
760
+ 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}`,
761
+ },
762
+ ];
763
+ };
549
764
  /**
550
765
  * Sends a prompt to an LLM and returns the response, with support for streaming,
551
766
  * agentic execution, progress reporting, JSON extraction, and Zod schema validation.
@@ -723,84 +938,109 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
723
938
  else {
724
939
  messagesToSend = messages;
725
940
  }
726
- // Stream and report server-side progress based on time elapsed
727
- let rawContent = "";
728
- let chunkCount = 0;
941
+ // Retry once on schema validation failure. The retry is silent to the progress
942
+ // callback same "Generating content..." message and the progress curve
943
+ // continues from the original startTime so the bar doesn't visibly reset.
944
+ // Skipped when OpenAI native response_format is in use (a 422 there is a
945
+ // server-side schema bug, not a model output issue). Each attempt is billed
946
+ // independently via recordCost so retry cost is still visible in telemetry.
947
+ const canRetryOnValidation = expectsJsonResponse && !!schema && !useNativeSchema;
729
948
  const progressReportInterval = 10; // Report every N chunks
730
- const startTime = Date.now();
949
+ const overallStartTime = Date.now();
731
950
  // Use a time-based asymptotic curve: progress approaches maxPercent but never
732
951
  // overshoots. This avoids the magic "expected length" constant — longer responses
733
952
  // simply slow the curve down rather than exceeding the range.
734
953
  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
- });
954
+ let activeMessages = messagesToSend;
955
+ let attempt = 0;
956
+ const calcCurrentProgress = () => {
957
+ const elapsed = Date.now() - overallStartTime;
958
+ return Math.round(minPercent +
959
+ (maxPercent - minPercent - 5) *
960
+ (1 - Math.exp(-elapsed / expectedDurationMs)));
961
+ };
962
+ while (true) {
963
+ let rawContent = "";
964
+ let chunkCount = 0;
965
+ let streamUsage = { input_tokens: 0, output_tokens: 0 };
966
+ // Inner loop: wait + retry on 429 around stream setup and consumption.
967
+ // Cost is only recorded on a successful stream — partial streams that error
968
+ // out with a rate limit are not billed. A 429 fired mid-stream simply
969
+ // discards the partial output and restarts cleanly.
970
+ let rateLimitAttempt = 0;
971
+ while (true) {
972
+ rawContent = "";
973
+ chunkCount = 0;
974
+ streamUsage = { input_tokens: 0, output_tokens: 0 };
975
+ try {
976
+ assertQuotaAvailable();
977
+ const stream = await llm.stream(activeMessages);
978
+ for await (const chunk of stream) {
979
+ accumulateChunkUsage(streamUsage, chunk);
980
+ const content = chunk?.content || chunk;
981
+ if (typeof content === "string") {
982
+ rawContent += content;
983
+ chunkCount++;
984
+ if (chunkCount % progressReportInterval === 0) {
985
+ await onProgressReport({
986
+ message: "Generating content...",
987
+ progress: Math.min(calcCurrentProgress(), maxPercent - 5),
988
+ });
989
+ }
990
+ }
991
+ }
992
+ break; // stream completed without 429
993
+ }
994
+ catch (err) {
995
+ if (isRateLimitError(err) &&
996
+ rateLimitAttempt < MAX_RATE_LIMIT_RETRIES) {
997
+ rateLimitAttempt++;
998
+ const { waitMs, source } = getRateLimitWaitMs(err);
999
+ const waitSeconds = Math.round(waitMs / 1000);
1000
+ logger.log(null, logger.levels.warn, `Rate limited by "${modelName}", waiting ${waitSeconds}s before retry (${rateLimitAttempt}/${MAX_RATE_LIMIT_RETRIES}, source=${source}): ${err?.message ?? err}`);
1001
+ await onProgressReport({
1002
+ message: `AI service is busy. Retrying in ${waitSeconds}s...`,
1003
+ progress: Math.min(calcCurrentProgress(), maxPercent - 5),
1004
+ });
1005
+ await sleep(waitMs);
1006
+ continue;
1007
+ }
1008
+ throw err;
754
1009
  }
755
1010
  }
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("]"))) {
1011
+ recordCost(calculateTextCost(modelName, streamUsage, config));
1012
+ if (!rawContent) {
1013
+ throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, "LLM returned no content");
1014
+ }
1015
+ // If not expecting JSON, return raw content directly
1016
+ if (!expectsJsonResponse) {
1017
+ await onProgressReport({
1018
+ message: "Processing complete",
1019
+ progress: maxPercent,
1020
+ });
1021
+ return rawContent;
1022
+ }
773
1023
  try {
774
- const parsed = JSON.parse(trimmed);
775
- if (schema) {
776
- validateWithSchema(parsed, schema);
777
- }
778
- return JSON.stringify(parsed);
1024
+ const result = parseAndValidateJSONResponse(rawContent, schema);
1025
+ await onProgressReport({
1026
+ message: "Processing complete",
1027
+ progress: maxPercent,
1028
+ });
1029
+ return result;
779
1030
  }
780
- catch (parseError) {
781
- if (parseError?.code === constants.HTTP_CODES.UNPROCESSABLE_ENTITY) {
782
- throw parseError;
1031
+ catch (err) {
1032
+ const isValidationFailure = err?.code === constants.HTTP_CODES.UNPROCESSABLE_ENTITY;
1033
+ if (isValidationFailure &&
1034
+ canRetryOnValidation &&
1035
+ attempt < MAX_VALIDATION_RETRIES) {
1036
+ attempt++;
1037
+ logger.log(null, logger.levels.warn, `Schema validation failed for "${modelName}", retrying (${attempt}/${MAX_VALIDATION_RETRIES}): ${err.message}`);
1038
+ activeMessages = buildValidationRetryMessages(activeMessages, rawContent, err);
1039
+ continue;
783
1040
  }
1041
+ throw err;
784
1042
  }
785
1043
  }
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
1044
  }
805
1045
  else {
806
1046
  // Non-streaming mode: use native response_format for OpenAI when schema is provided
@@ -840,67 +1080,56 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
840
1080
  else {
841
1081
  messagesToSend = messages;
842
1082
  }
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("]"))) {
1083
+ // Retry once on schema validation failure (see streaming branch for rationale).
1084
+ // Each attempt is billed independently via recordCost so retry cost is visible.
1085
+ const canRetryOnValidation = expectsJsonResponse && !!schema && !useNativeSchema;
1086
+ let activeMessages = messagesToSend;
1087
+ let attempt = 0;
1088
+ while (true) {
1089
+ // Inner loop: wait + retry on 429. No progress callback in this branch, so
1090
+ // the wait is silent to the caller — only the warn log is emitted.
1091
+ let response;
1092
+ let rateLimitAttempt = 0;
1093
+ while (true) {
859
1094
  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);
1095
+ assertQuotaAvailable();
1096
+ response = await llm.invoke(activeMessages);
1097
+ break;
867
1098
  }
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;
1099
+ catch (err) {
1100
+ if (isRateLimitError(err) &&
1101
+ rateLimitAttempt < MAX_RATE_LIMIT_RETRIES) {
1102
+ rateLimitAttempt++;
1103
+ const { waitMs, source } = getRateLimitWaitMs(err);
1104
+ logger.log(null, logger.levels.warn, `Rate limited by "${modelName}", waiting ${Math.round(waitMs / 1000)}s before retry (${rateLimitAttempt}/${MAX_RATE_LIMIT_RETRIES}, source=${source}): ${err?.message ?? err}`);
1105
+ await sleep(waitMs);
1106
+ continue;
872
1107
  }
873
- // Not valid JSON, try extraction
1108
+ throw err;
874
1109
  }
875
1110
  }
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);
1111
+ recordCost(calculateTextCost(modelName, extractUsageFromInvoke(response), config));
1112
+ const rawContent = response?.content || response;
1113
+ // If not expecting JSON, return raw content directly
1114
+ if (!expectsJsonResponse) {
1115
+ return rawContent;
894
1116
  }
895
- else if (rawContent !== undefined && rawContent !== null) {
896
- preview = String(rawContent).substring(0, 100);
1117
+ try {
1118
+ return parseAndValidateJSONResponse(rawContent, schema);
897
1119
  }
898
- else {
899
- preview = "[empty response]";
1120
+ catch (err) {
1121
+ const isValidationFailure = err?.code === constants.HTTP_CODES.UNPROCESSABLE_ENTITY;
1122
+ if (isValidationFailure &&
1123
+ canRetryOnValidation &&
1124
+ attempt < MAX_VALIDATION_RETRIES) {
1125
+ attempt++;
1126
+ logger.log(null, logger.levels.warn, `Schema validation failed for "${modelName}", retrying (${attempt}/${MAX_VALIDATION_RETRIES}): ${err.message}`);
1127
+ activeMessages = buildValidationRetryMessages(activeMessages, rawContent, err);
1128
+ continue;
1129
+ }
1130
+ throw err;
900
1131
  }
901
- throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, `LLM returned non-JSON response when JSON was expected: ${preview}...`);
902
1132
  }
903
- return rawContent;
904
1133
  }
905
1134
  };
906
1135
  /**
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.22",
7
7
  "description": "",
8
8
  "main": "dist/cjs/index.js",
9
9
  "module": "dist/esm/index.js",