@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.
- package/dist/cjs/langChain.d.ts.map +1 -1
- package/dist/cjs/langChain.js +222 -26
- package/dist/esm/langChain.d.ts.map +1 -1
- package/dist/esm/langChain.js +222 -26
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":";wBAogBQ,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,KACV,GAAG;sBAyBG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,KAC1B,OAAO,CAAC,GAAG,CAAC;
|
|
1
|
+
{"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":";wBAogBQ,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,KACV,GAAG;sBAyBG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,KAC1B,OAAO,CAAC,GAAG,CAAC;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"}
|
package/dist/cjs/langChain.js
CHANGED
|
@@ -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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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:
|
|
835
|
-
progress: Math.min(
|
|
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
|
-
|
|
919
|
-
|
|
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;
|
|
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"}
|
package/dist/esm/langChain.js
CHANGED
|
@@ -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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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:
|
|
830
|
-
progress: Math.min(
|
|
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
|
-
|
|
914
|
-
|
|
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
|