@stackfactor/agent-utils 1.0.21 → 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;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"}
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;
@@ -552,6 +582,122 @@ const throwErrorIfNotSuccessful = (response) => {
552
582
  }
553
583
  };
554
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
+ };
555
701
  /**
556
702
  * Parses raw LLM content as JSON and validates against an optional Zod schema,
557
703
  * returning the canonical JSON string. Tries a direct parse of the trimmed text
@@ -812,29 +958,59 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
812
958
  const expectedDurationMs = 15_000; // Tune: expected typical response time
813
959
  let activeMessages = messagesToSend;
814
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
+ };
815
967
  while (true) {
816
968
  let rawContent = "";
817
969
  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)));
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}`);
833
1006
  await onProgressReport({
834
- message: "Generating content...",
835
- progress: Math.min(progress, maxPercent - 5),
1007
+ message: `AI service is busy. Retrying in ${waitSeconds}s...`,
1008
+ progress: Math.min(calcCurrentProgress(), maxPercent - 5),
836
1009
  });
1010
+ await sleep(waitMs);
1011
+ continue;
837
1012
  }
1013
+ throw err;
838
1014
  }
839
1015
  }
840
1016
  recordCost(calculateTextCost(modelName, streamUsage, config));
@@ -915,8 +1091,28 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
915
1091
  let activeMessages = messagesToSend;
916
1092
  let attempt = 0;
917
1093
  while (true) {
918
- assertQuotaAvailable();
919
- const response = await llm.invoke(activeMessages);
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) {
1099
+ try {
1100
+ assertQuotaAvailable();
1101
+ response = await llm.invoke(activeMessages);
1102
+ break;
1103
+ }
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;
1112
+ }
1113
+ throw err;
1114
+ }
1115
+ }
920
1116
  recordCost(calculateTextCost(modelName, extractUsageFromInvoke(response), config));
921
1117
  const rawContent = response?.content || response;
922
1118
  // If not expecting JSON, return raw content directly
@@ -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;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"}
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;
@@ -547,6 +577,122 @@ const throwErrorIfNotSuccessful = (response) => {
547
577
  }
548
578
  };
549
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
+ };
550
696
  /**
551
697
  * Parses raw LLM content as JSON and validates against an optional Zod schema,
552
698
  * returning the canonical JSON string. Tries a direct parse of the trimmed text
@@ -807,29 +953,59 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
807
953
  const expectedDurationMs = 15_000; // Tune: expected typical response time
808
954
  let activeMessages = messagesToSend;
809
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
+ };
810
962
  while (true) {
811
963
  let rawContent = "";
812
964
  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)));
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}`);
828
1001
  await onProgressReport({
829
- message: "Generating content...",
830
- progress: Math.min(progress, maxPercent - 5),
1002
+ message: `AI service is busy. Retrying in ${waitSeconds}s...`,
1003
+ progress: Math.min(calcCurrentProgress(), maxPercent - 5),
831
1004
  });
1005
+ await sleep(waitMs);
1006
+ continue;
832
1007
  }
1008
+ throw err;
833
1009
  }
834
1010
  }
835
1011
  recordCost(calculateTextCost(modelName, streamUsage, config));
@@ -910,8 +1086,28 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
910
1086
  let activeMessages = messagesToSend;
911
1087
  let attempt = 0;
912
1088
  while (true) {
913
- assertQuotaAvailable();
914
- const response = await llm.invoke(activeMessages);
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) {
1094
+ try {
1095
+ assertQuotaAvailable();
1096
+ response = await llm.invoke(activeMessages);
1097
+ break;
1098
+ }
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;
1107
+ }
1108
+ throw err;
1109
+ }
1110
+ }
915
1111
  recordCost(calculateTextCost(modelName, extractUsageFromInvoke(response), config));
916
1112
  const rawContent = response?.content || response;
917
1113
  // If not expecting JSON, return raw content directly
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "restricted"
5
5
  },
6
- "version": "1.0.21",
6
+ "version": "1.0.22",
7
7
  "description": "",
8
8
  "main": "dist/cjs/index.js",
9
9
  "module": "dist/esm/index.js",