@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.
- package/dist/cjs/langChain.d.ts.map +1 -1
- package/dist/cjs/langChain.js +354 -125
- package/dist/esm/langChain.d.ts.map +1 -1
- package/dist/esm/langChain.js +354 -125
- 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;
|
|
@@ -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
|
-
//
|
|
732
|
-
|
|
733
|
-
|
|
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
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
const
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
}
|
|
774
|
-
// Parse and validate JSON response
|
|
775
|
-
const trimmed = rawContent.trim();
|
|
776
|
-
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
|
777
|
-
(trimmed.startsWith("[") && trimmed.endsWith("]"))) {
|
|
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
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
1029
|
+
const result = parseAndValidateJSONResponse(rawContent, schema);
|
|
1030
|
+
await onProgressReport({
|
|
1031
|
+
message: "Processing complete",
|
|
1032
|
+
progress: maxPercent,
|
|
1033
|
+
});
|
|
1034
|
+
return result;
|
|
784
1035
|
}
|
|
785
|
-
catch (
|
|
786
|
-
|
|
787
|
-
|
|
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
|
-
//
|
|
849
|
-
|
|
850
|
-
const
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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 (
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
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
|
-
|
|
1113
|
+
throw err;
|
|
879
1114
|
}
|
|
880
1115
|
}
|
|
881
|
-
|
|
882
|
-
const
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|
-
|
|
901
|
-
|
|
1122
|
+
try {
|
|
1123
|
+
return parseAndValidateJSONResponse(rawContent, schema);
|
|
902
1124
|
}
|
|
903
|
-
|
|
904
|
-
|
|
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;
|
|
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;
|
|
@@ -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
|
-
//
|
|
727
|
-
|
|
728
|
-
|
|
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
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
}
|
|
769
|
-
// Parse and validate JSON response
|
|
770
|
-
const trimmed = rawContent.trim();
|
|
771
|
-
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
|
772
|
-
(trimmed.startsWith("[") && trimmed.endsWith("]"))) {
|
|
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
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
1024
|
+
const result = parseAndValidateJSONResponse(rawContent, schema);
|
|
1025
|
+
await onProgressReport({
|
|
1026
|
+
message: "Processing complete",
|
|
1027
|
+
progress: maxPercent,
|
|
1028
|
+
});
|
|
1029
|
+
return result;
|
|
779
1030
|
}
|
|
780
|
-
catch (
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
//
|
|
844
|
-
|
|
845
|
-
const
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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 (
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
-
|
|
1108
|
+
throw err;
|
|
874
1109
|
}
|
|
875
1110
|
}
|
|
876
|
-
|
|
877
|
-
const
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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
|
-
|
|
896
|
-
|
|
1117
|
+
try {
|
|
1118
|
+
return parseAndValidateJSONResponse(rawContent, schema);
|
|
897
1119
|
}
|
|
898
|
-
|
|
899
|
-
|
|
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
|
/**
|