@stackfactor/agent-utils 1.0.21 → 1.0.23

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/README.md CHANGED
@@ -62,32 +62,6 @@ const result = await langChain.runPromptWithModel(
62
62
 
63
63
  ---
64
64
 
65
- ### `langChain.runChatPromptWithModel(modelName, config, prompt, onProgressReport?)`
66
-
67
- Sends a conversational chat prompt to an LLM and returns a plain HTML response. Built for the StackFactor Mentor chat feature — system messages are wrapped with topic-constraint instructions that decline off-topic questions.
68
-
69
- | Parameter | Type | Default | Description |
70
- | ------------------ | -------------------- | ------- | ----------------------------------------------------- |
71
- | `modelName` | `string` | — | Model identifier |
72
- | `config` | `object` | — | API keys and optional `temperature` |
73
- | `prompt` | `string \| object[]` | — | Plain string or array of `{ role, content }` messages |
74
- | `onProgressReport` | `function \| null` | `null` | Streaming progress callback |
75
-
76
- **Returns:** Raw HTML string.
77
-
78
- ```typescript
79
- const html = await langChain.runChatPromptWithModel(
80
- "claude-3-5-sonnet",
81
- { anthropicAPIKey: "sk-ant-..." },
82
- [
83
- { role: "system", content: "Topic: JavaScript closures" },
84
- { role: "user", content: "Explain closures with an example" },
85
- ],
86
- );
87
- ```
88
-
89
- ---
90
-
91
65
  ### `langChain.createAIAgent(name, modelName, systemPrompt, tools?, responseFormat?, config, onReportProgress?, minPercent?, maxPercent?)`
92
66
 
93
67
  Constructs a LangChain agent with a model, system prompt, and tools. When `onReportProgress` is provided, a `report_progress` tool is automatically added.
@@ -1,7 +1,7 @@
1
1
  declare const _default: {
2
+ checkIfAIProviderConfigured: (config: any) => void;
2
3
  createAgent: (name: string, modelName: string, systemPrompt: string, tools: any[], responseFormat: any, config: any) => any;
3
4
  runAgent: (agent: any, prompt: string, config: any, onProgress?: Function | null) => Promise<any>;
4
- runChatPromptWithModel: (modelName: string, config: any, prompt: any, onProgressReport: any) => any;
5
5
  runPromptWithModel: (modelName: string, config: any, prompt: any, onProgressReport: any, minPercent?: number, maxPercent?: number, expectsJsonResponse?: boolean, schema?: any, agentName?: string, tools?: any[]) => Promise<any>;
6
6
  runPromptWithModelForImageGeneration: (modelName: string, config: any, prompt: string, options?: any) => Promise<any>;
7
7
  throwErrorIfNotSuccessful: (response: any) => string;
@@ -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":";0CAgD6C,GAAG,KAAG,IAAI;wBAke/C,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;oCAqWF,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;sDA6rBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,KACX,OAAO,CAAC,GAAG,CAAC;0CA18B8B,GAAG,KAAG,MAAM;;AAqgCzD,wBAOE"}
@@ -36,6 +36,14 @@ const assertQuotaAvailable = () => {
36
36
  throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.PAYMENT_REQUIRED, const_js_1.default.ERROR.QUOTA_EXHAUSTED);
37
37
  }
38
38
  };
39
+ const checkIfAIProviderConfigured = (config) => {
40
+ if (!config ||
41
+ !config.openAIAPIKey ||
42
+ !config.googleAPIKey ||
43
+ !config.anthropicAPIKey) {
44
+ throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, "The integration is not properly configured with all AI providers.");
45
+ }
46
+ };
39
47
  /**
40
48
  * Subtracts the given USD cost from `global.quota.remaining` and adds it to
41
49
  * `global.quota.usedThisSession`. Silently no-ops when the quota globals are not
@@ -522,14 +530,44 @@ const runAgent = async (agent, prompt, config, onProgress = null) => {
522
530
  },
523
531
  ]
524
532
  : 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
533
  const modelName = agent.options?.model?.modelName || agent.options?.model?.model || "";
534
+ // Wait + retry on 429 around the full agent run. The agent loop may issue
535
+ // many internal LLM calls, but a rate limit surfaces as a thrown error from
536
+ // the wrapping invoke. On retry we restart the agent run from scratch — any
537
+ // partial progress (tool calls, intermediate messages) is discarded, since
538
+ // the agent state isn't externally checkpointed. Cost is only recorded on a
539
+ // successful completion. Matches the behavior of the non-agentic paths.
540
+ let response;
541
+ let rateLimitAttempt = 0;
542
+ while (true) {
543
+ try {
544
+ assertQuotaAvailable();
545
+ response = await agent.invoke({
546
+ messages: [{ role: "user", content: prompt }],
547
+ }, {
548
+ recursionLimit: config.recursionLimit || 25,
549
+ ...(callbacks ? { callbacks } : {}),
550
+ });
551
+ break;
552
+ }
553
+ catch (err) {
554
+ if (isRateLimitError(err) && rateLimitAttempt < MAX_RATE_LIMIT_RETRIES) {
555
+ rateLimitAttempt++;
556
+ const { waitMs, source } = getRateLimitWaitMs(err);
557
+ const waitSeconds = Math.round(waitMs / 1000);
558
+ 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}`);
559
+ if (onProgress) {
560
+ onProgress({
561
+ type: "rate_limit",
562
+ message: `AI service is busy. Retrying in ${waitSeconds}s...`,
563
+ });
564
+ }
565
+ await sleep(waitMs);
566
+ continue;
567
+ }
568
+ throw err;
569
+ }
570
+ }
533
571
  recordCost(calculateTextCost(modelName, sumAgentResponseUsage(response), config));
534
572
  const endTime = Date.now();
535
573
  const duration = endTime - startTime;
@@ -552,6 +590,122 @@ const throwErrorIfNotSuccessful = (response) => {
552
590
  }
553
591
  };
554
592
  const MAX_VALIDATION_RETRIES = 3;
593
+ const MAX_RATE_LIMIT_RETRIES = 2;
594
+ const RATE_LIMIT_MIN_WAIT_MS = 30_000;
595
+ const RATE_LIMIT_MAX_WAIT_MS = 60_000;
596
+ // Cap on how long we'll honor a provider Retry-After hint. If a provider asks
597
+ // for longer than this, treat it as a signal that the request shouldn't be
598
+ // retried in-flight and fail fast instead of holding the caller open.
599
+ const RATE_LIMIT_MAX_HONORED_WAIT_MS = 5 * 60 * 1000;
600
+ // Small jitter added on top of an honored Retry-After so concurrent callers
601
+ // that all received the same hint don't wake at exactly the same instant.
602
+ const RATE_LIMIT_HONORED_JITTER_MS = 2_000;
603
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
604
+ /**
605
+ * Extracts a Retry-After hint (in milliseconds) from a provider 429 error.
606
+ * Different SDKs put headers in different places (top-level `headers`, nested
607
+ * `response.headers`, `responseHeaders`, or under `cause`), and some include a
608
+ * millisecond-precision `retry-after-ms` variant. Also checks the response body
609
+ * for `retry_after` / `retry_after_ms` fields (some providers put it there
610
+ * instead of headers). Standard `Retry-After` may be seconds or an HTTP date.
611
+ * Returns `null` when no usable hint is present.
612
+ */
613
+ const parseRetryAfterFromError = (err) => {
614
+ const headerSources = [
615
+ err?.headers,
616
+ err?.response?.headers,
617
+ err?.responseHeaders,
618
+ err?.cause?.headers,
619
+ err?.cause?.response?.headers,
620
+ ];
621
+ for (const headers of headerSources) {
622
+ if (!headers || typeof headers !== "object")
623
+ continue;
624
+ const retryAfterMs = headers["retry-after-ms"] ?? headers["Retry-After-Ms"];
625
+ if (retryAfterMs != null) {
626
+ const ms = Number(retryAfterMs);
627
+ if (Number.isFinite(ms) && ms > 0)
628
+ return ms;
629
+ }
630
+ const retryAfter = headers["retry-after"] ?? headers["Retry-After"];
631
+ if (retryAfter != null) {
632
+ const asNumber = Number(retryAfter);
633
+ if (Number.isFinite(asNumber) && asNumber > 0) {
634
+ return asNumber * 1000;
635
+ }
636
+ const dateMs = Date.parse(String(retryAfter));
637
+ if (!Number.isNaN(dateMs)) {
638
+ const delta = dateMs - Date.now();
639
+ if (delta > 0)
640
+ return delta;
641
+ }
642
+ }
643
+ }
644
+ const bodySources = [err?.error, err?.response?.data, err?.body];
645
+ for (const body of bodySources) {
646
+ if (!body || typeof body !== "object")
647
+ continue;
648
+ const retryAfterMs = body.retry_after_ms ?? body.retryAfterMs;
649
+ if (typeof retryAfterMs === "number" && retryAfterMs > 0) {
650
+ return retryAfterMs;
651
+ }
652
+ const retryAfterS = body.retry_after ?? body.retryAfter;
653
+ if (typeof retryAfterS === "number" && retryAfterS > 0) {
654
+ return retryAfterS * 1000;
655
+ }
656
+ }
657
+ return null;
658
+ };
659
+ /**
660
+ * Picks the back-off duration before retrying a 429. When the provider supplied
661
+ * a Retry-After hint, that value is honored (capped at
662
+ * RATE_LIMIT_MAX_HONORED_WAIT_MS, with small jitter added to avoid thundering
663
+ * herd on the same wake instant). When no hint is present, falls back to a
664
+ * uniform random wait in [RATE_LIMIT_MIN_WAIT_MS, RATE_LIMIT_MAX_WAIT_MS] —
665
+ * which is the right default for "we don't know how long this will last."
666
+ * Returns both the chosen ms and the source so callers can log it.
667
+ */
668
+ const getRateLimitWaitMs = (err) => {
669
+ const hint = parseRetryAfterFromError(err);
670
+ if (hint != null) {
671
+ const honored = Math.min(hint, RATE_LIMIT_MAX_HONORED_WAIT_MS);
672
+ const jitter = Math.floor(Math.random() * RATE_LIMIT_HONORED_JITTER_MS);
673
+ return {
674
+ waitMs: Math.max(honored + jitter, 1_000),
675
+ source: "retry-after",
676
+ };
677
+ }
678
+ const waitMs = RATE_LIMIT_MIN_WAIT_MS +
679
+ Math.floor(Math.random() * (RATE_LIMIT_MAX_WAIT_MS - RATE_LIMIT_MIN_WAIT_MS + 1));
680
+ return { waitMs, source: "random" };
681
+ };
682
+ /**
683
+ * Detects rate-limit (HTTP 429) errors across LangChain and provider SDK shapes.
684
+ * Different SDKs surface the status in different places (status, statusCode,
685
+ * response.status, code) and sometimes only in the message text, so this checks
686
+ * each known shape rather than assuming a single field.
687
+ */
688
+ const isRateLimitError = (err) => {
689
+ if (!err)
690
+ return false;
691
+ const statusCandidates = [
692
+ err.status,
693
+ err.statusCode,
694
+ err.response?.status,
695
+ err.response?.statusCode,
696
+ err.cause?.status,
697
+ err.cause?.statusCode,
698
+ err.code,
699
+ ];
700
+ if (statusCandidates.some((c) => c === 429 || c === "429"))
701
+ return true;
702
+ if (typeof err.code === "string" && /rate.?limit/i.test(err.code))
703
+ return true;
704
+ const message = typeof err.message === "string" ? err.message : "";
705
+ return (/\b429\b/.test(message) ||
706
+ /rate.?limit/i.test(message) ||
707
+ /too many requests/i.test(message));
708
+ };
555
709
  /**
556
710
  * Parses raw LLM content as JSON and validates against an optional Zod schema,
557
711
  * returning the canonical JSON string. Tries a direct parse of the trimmed text
@@ -812,29 +966,59 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
812
966
  const expectedDurationMs = 15_000; // Tune: expected typical response time
813
967
  let activeMessages = messagesToSend;
814
968
  let attempt = 0;
969
+ const calcCurrentProgress = () => {
970
+ const elapsed = Date.now() - overallStartTime;
971
+ return Math.round(minPercent +
972
+ (maxPercent - minPercent - 5) *
973
+ (1 - Math.exp(-elapsed / expectedDurationMs)));
974
+ };
815
975
  while (true) {
816
976
  let rawContent = "";
817
977
  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)));
978
+ let streamUsage = { input_tokens: 0, output_tokens: 0 };
979
+ // Inner loop: wait + retry on 429 around stream setup and consumption.
980
+ // Cost is only recorded on a successful stream partial streams that error
981
+ // out with a rate limit are not billed. A 429 fired mid-stream simply
982
+ // discards the partial output and restarts cleanly.
983
+ let rateLimitAttempt = 0;
984
+ while (true) {
985
+ rawContent = "";
986
+ chunkCount = 0;
987
+ streamUsage = { input_tokens: 0, output_tokens: 0 };
988
+ try {
989
+ assertQuotaAvailable();
990
+ const stream = await llm.stream(activeMessages);
991
+ for await (const chunk of stream) {
992
+ accumulateChunkUsage(streamUsage, chunk);
993
+ const content = chunk?.content || chunk;
994
+ if (typeof content === "string") {
995
+ rawContent += content;
996
+ chunkCount++;
997
+ if (chunkCount % progressReportInterval === 0) {
998
+ await onProgressReport({
999
+ message: "Generating content...",
1000
+ progress: Math.min(calcCurrentProgress(), maxPercent - 5),
1001
+ });
1002
+ }
1003
+ }
1004
+ }
1005
+ break; // stream completed without 429
1006
+ }
1007
+ catch (err) {
1008
+ if (isRateLimitError(err) &&
1009
+ rateLimitAttempt < MAX_RATE_LIMIT_RETRIES) {
1010
+ rateLimitAttempt++;
1011
+ const { waitMs, source } = getRateLimitWaitMs(err);
1012
+ const waitSeconds = Math.round(waitMs / 1000);
1013
+ 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
1014
  await onProgressReport({
834
- message: "Generating content...",
835
- progress: Math.min(progress, maxPercent - 5),
1015
+ message: `AI service is busy. Retrying in ${waitSeconds}s...`,
1016
+ progress: Math.min(calcCurrentProgress(), maxPercent - 5),
836
1017
  });
1018
+ await sleep(waitMs);
1019
+ continue;
837
1020
  }
1021
+ throw err;
838
1022
  }
839
1023
  }
840
1024
  recordCost(calculateTextCost(modelName, streamUsage, config));
@@ -915,8 +1099,28 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
915
1099
  let activeMessages = messagesToSend;
916
1100
  let attempt = 0;
917
1101
  while (true) {
918
- assertQuotaAvailable();
919
- const response = await llm.invoke(activeMessages);
1102
+ // Inner loop: wait + retry on 429. No progress callback in this branch, so
1103
+ // the wait is silent to the caller — only the warn log is emitted.
1104
+ let response;
1105
+ let rateLimitAttempt = 0;
1106
+ while (true) {
1107
+ try {
1108
+ assertQuotaAvailable();
1109
+ response = await llm.invoke(activeMessages);
1110
+ break;
1111
+ }
1112
+ catch (err) {
1113
+ if (isRateLimitError(err) &&
1114
+ rateLimitAttempt < MAX_RATE_LIMIT_RETRIES) {
1115
+ rateLimitAttempt++;
1116
+ const { waitMs, source } = getRateLimitWaitMs(err);
1117
+ 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}`);
1118
+ await sleep(waitMs);
1119
+ continue;
1120
+ }
1121
+ throw err;
1122
+ }
1123
+ }
920
1124
  recordCost(calculateTextCost(modelName, extractUsageFromInvoke(response), config));
921
1125
  const rawContent = response?.content || response;
922
1126
  // If not expecting JSON, return raw content directly
@@ -941,47 +1145,6 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
941
1145
  }
942
1146
  }
943
1147
  };
944
- /**
945
- * Sends a conversational chat prompt to an LLM and returns a plain HTML response
946
- * intended for end-user display. This function is purpose-built for the StackFactor
947
- * Mentor chat feature: when the prompt is an array of messages, each `system` message
948
- * is wrapped in an enhanced system prompt that instructs the model to answer only
949
- * questions related to the provided topic, decline off-topic questions, and return
950
- * results as simple HTML without markdown code-block notation. The call is delegated to
951
- * `runPromptWithModel` with `expectsJsonResponse` set to `false`.
952
- * @param modelName - The model identifier, e.g. `"gpt-4o"`, `"claude-3-5-sonnet"`
953
- * @param config - Configuration object with API keys and optional `temperature`
954
- * @param prompt - A plain string user message, or an array of `{ role, content }`
955
- * message objects; `system` messages have the topic-constraint wrapper applied
956
- * @param onProgressReport - Optional callback for streaming progress updates passed
957
- * through to `runPromptWithModel`
958
- * @returns The model's response as a raw HTML string
959
- */
960
- const runChatPromptWithModel = (modelName, config, prompt, onProgressReport) => {
961
- let messages = prompt;
962
- // If prompt is an array, check for system message and enhance it
963
- if (Array.isArray(prompt)) {
964
- messages = prompt.map((msg) => {
965
- if (msg.role === "system") {
966
- return {
967
- ...msg,
968
- content: `You are StackFactor Mentor, an AI assistant that helps users by providing information related to the specified topic.
969
-
970
- ### Objective:
971
- Respond to the user question considering just related to the selected topic and all previous interactions:
972
- - If the question is unrelated decline to respond.
973
- - Return the results as a simple HMTL but don't include notations for formatting blocks.
974
-
975
- ### TOPIC INFORMATION:\n
976
- ${msg.content}
977
- `,
978
- };
979
- }
980
- return msg;
981
- });
982
- }
983
- return runPromptWithModel(modelName, config, messages, onProgressReport, 0, 100, false);
984
- };
985
1148
  /**
986
1149
  * Determines the image generation provider for a given model name based on its prefix.
987
1150
  * `dall-e-` and `gpt-image-` prefixes map to `"openai"`. `gemini-` and `imagen-`
@@ -1243,9 +1406,9 @@ const runPromptWithModelForImageGeneration = async (modelName, config, prompt, o
1243
1406
  }
1244
1407
  };
1245
1408
  exports.default = {
1409
+ checkIfAIProviderConfigured,
1246
1410
  createAgent,
1247
1411
  runAgent,
1248
- runChatPromptWithModel,
1249
1412
  runPromptWithModel,
1250
1413
  runPromptWithModelForImageGeneration,
1251
1414
  throwErrorIfNotSuccessful,
@@ -1,7 +1,7 @@
1
1
  declare const _default: {
2
+ checkIfAIProviderConfigured: (config: any) => void;
2
3
  createAgent: (name: string, modelName: string, systemPrompt: string, tools: any[], responseFormat: any, config: any) => any;
3
4
  runAgent: (agent: any, prompt: string, config: any, onProgress?: Function | null) => Promise<any>;
4
- runChatPromptWithModel: (modelName: string, config: any, prompt: any, onProgressReport: any) => any;
5
5
  runPromptWithModel: (modelName: string, config: any, prompt: any, onProgressReport: any, minPercent?: number, maxPercent?: number, expectsJsonResponse?: boolean, schema?: any, agentName?: string, tools?: any[]) => Promise<any>;
6
6
  runPromptWithModelForImageGeneration: (modelName: string, config: any, prompt: string, options?: any) => Promise<any>;
7
7
  throwErrorIfNotSuccessful: (response: any) => string;
@@ -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":";0CAgD6C,GAAG,KAAG,IAAI;wBAke/C,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;oCAqWF,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;sDA6rBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,KACX,OAAO,CAAC,GAAG,CAAC;0CA18B8B,GAAG,KAAG,MAAM;;AAqgCzD,wBAOE"}
@@ -31,6 +31,14 @@ const assertQuotaAvailable = () => {
31
31
  throw errorHandlingHelper.create(constants.HTTP_CODES.PAYMENT_REQUIRED, constants.ERROR.QUOTA_EXHAUSTED);
32
32
  }
33
33
  };
34
+ const checkIfAIProviderConfigured = (config) => {
35
+ if (!config ||
36
+ !config.openAIAPIKey ||
37
+ !config.googleAPIKey ||
38
+ !config.anthropicAPIKey) {
39
+ throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, "The integration is not properly configured with all AI providers.");
40
+ }
41
+ };
34
42
  /**
35
43
  * Subtracts the given USD cost from `global.quota.remaining` and adds it to
36
44
  * `global.quota.usedThisSession`. Silently no-ops when the quota globals are not
@@ -517,14 +525,44 @@ const runAgent = async (agent, prompt, config, onProgress = null) => {
517
525
  },
518
526
  ]
519
527
  : 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
528
  const modelName = agent.options?.model?.modelName || agent.options?.model?.model || "";
529
+ // Wait + retry on 429 around the full agent run. The agent loop may issue
530
+ // many internal LLM calls, but a rate limit surfaces as a thrown error from
531
+ // the wrapping invoke. On retry we restart the agent run from scratch — any
532
+ // partial progress (tool calls, intermediate messages) is discarded, since
533
+ // the agent state isn't externally checkpointed. Cost is only recorded on a
534
+ // successful completion. Matches the behavior of the non-agentic paths.
535
+ let response;
536
+ let rateLimitAttempt = 0;
537
+ while (true) {
538
+ try {
539
+ assertQuotaAvailable();
540
+ response = await agent.invoke({
541
+ messages: [{ role: "user", content: prompt }],
542
+ }, {
543
+ recursionLimit: config.recursionLimit || 25,
544
+ ...(callbacks ? { callbacks } : {}),
545
+ });
546
+ break;
547
+ }
548
+ catch (err) {
549
+ if (isRateLimitError(err) && rateLimitAttempt < MAX_RATE_LIMIT_RETRIES) {
550
+ rateLimitAttempt++;
551
+ const { waitMs, source } = getRateLimitWaitMs(err);
552
+ const waitSeconds = Math.round(waitMs / 1000);
553
+ 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}`);
554
+ if (onProgress) {
555
+ onProgress({
556
+ type: "rate_limit",
557
+ message: `AI service is busy. Retrying in ${waitSeconds}s...`,
558
+ });
559
+ }
560
+ await sleep(waitMs);
561
+ continue;
562
+ }
563
+ throw err;
564
+ }
565
+ }
528
566
  recordCost(calculateTextCost(modelName, sumAgentResponseUsage(response), config));
529
567
  const endTime = Date.now();
530
568
  const duration = endTime - startTime;
@@ -547,6 +585,122 @@ const throwErrorIfNotSuccessful = (response) => {
547
585
  }
548
586
  };
549
587
  const MAX_VALIDATION_RETRIES = 3;
588
+ const MAX_RATE_LIMIT_RETRIES = 2;
589
+ const RATE_LIMIT_MIN_WAIT_MS = 30_000;
590
+ const RATE_LIMIT_MAX_WAIT_MS = 60_000;
591
+ // Cap on how long we'll honor a provider Retry-After hint. If a provider asks
592
+ // for longer than this, treat it as a signal that the request shouldn't be
593
+ // retried in-flight and fail fast instead of holding the caller open.
594
+ const RATE_LIMIT_MAX_HONORED_WAIT_MS = 5 * 60 * 1000;
595
+ // Small jitter added on top of an honored Retry-After so concurrent callers
596
+ // that all received the same hint don't wake at exactly the same instant.
597
+ const RATE_LIMIT_HONORED_JITTER_MS = 2_000;
598
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
599
+ /**
600
+ * Extracts a Retry-After hint (in milliseconds) from a provider 429 error.
601
+ * Different SDKs put headers in different places (top-level `headers`, nested
602
+ * `response.headers`, `responseHeaders`, or under `cause`), and some include a
603
+ * millisecond-precision `retry-after-ms` variant. Also checks the response body
604
+ * for `retry_after` / `retry_after_ms` fields (some providers put it there
605
+ * instead of headers). Standard `Retry-After` may be seconds or an HTTP date.
606
+ * Returns `null` when no usable hint is present.
607
+ */
608
+ const parseRetryAfterFromError = (err) => {
609
+ const headerSources = [
610
+ err?.headers,
611
+ err?.response?.headers,
612
+ err?.responseHeaders,
613
+ err?.cause?.headers,
614
+ err?.cause?.response?.headers,
615
+ ];
616
+ for (const headers of headerSources) {
617
+ if (!headers || typeof headers !== "object")
618
+ continue;
619
+ const retryAfterMs = headers["retry-after-ms"] ?? headers["Retry-After-Ms"];
620
+ if (retryAfterMs != null) {
621
+ const ms = Number(retryAfterMs);
622
+ if (Number.isFinite(ms) && ms > 0)
623
+ return ms;
624
+ }
625
+ const retryAfter = headers["retry-after"] ?? headers["Retry-After"];
626
+ if (retryAfter != null) {
627
+ const asNumber = Number(retryAfter);
628
+ if (Number.isFinite(asNumber) && asNumber > 0) {
629
+ return asNumber * 1000;
630
+ }
631
+ const dateMs = Date.parse(String(retryAfter));
632
+ if (!Number.isNaN(dateMs)) {
633
+ const delta = dateMs - Date.now();
634
+ if (delta > 0)
635
+ return delta;
636
+ }
637
+ }
638
+ }
639
+ const bodySources = [err?.error, err?.response?.data, err?.body];
640
+ for (const body of bodySources) {
641
+ if (!body || typeof body !== "object")
642
+ continue;
643
+ const retryAfterMs = body.retry_after_ms ?? body.retryAfterMs;
644
+ if (typeof retryAfterMs === "number" && retryAfterMs > 0) {
645
+ return retryAfterMs;
646
+ }
647
+ const retryAfterS = body.retry_after ?? body.retryAfter;
648
+ if (typeof retryAfterS === "number" && retryAfterS > 0) {
649
+ return retryAfterS * 1000;
650
+ }
651
+ }
652
+ return null;
653
+ };
654
+ /**
655
+ * Picks the back-off duration before retrying a 429. When the provider supplied
656
+ * a Retry-After hint, that value is honored (capped at
657
+ * RATE_LIMIT_MAX_HONORED_WAIT_MS, with small jitter added to avoid thundering
658
+ * herd on the same wake instant). When no hint is present, falls back to a
659
+ * uniform random wait in [RATE_LIMIT_MIN_WAIT_MS, RATE_LIMIT_MAX_WAIT_MS] —
660
+ * which is the right default for "we don't know how long this will last."
661
+ * Returns both the chosen ms and the source so callers can log it.
662
+ */
663
+ const getRateLimitWaitMs = (err) => {
664
+ const hint = parseRetryAfterFromError(err);
665
+ if (hint != null) {
666
+ const honored = Math.min(hint, RATE_LIMIT_MAX_HONORED_WAIT_MS);
667
+ const jitter = Math.floor(Math.random() * RATE_LIMIT_HONORED_JITTER_MS);
668
+ return {
669
+ waitMs: Math.max(honored + jitter, 1_000),
670
+ source: "retry-after",
671
+ };
672
+ }
673
+ const waitMs = RATE_LIMIT_MIN_WAIT_MS +
674
+ Math.floor(Math.random() * (RATE_LIMIT_MAX_WAIT_MS - RATE_LIMIT_MIN_WAIT_MS + 1));
675
+ return { waitMs, source: "random" };
676
+ };
677
+ /**
678
+ * Detects rate-limit (HTTP 429) errors across LangChain and provider SDK shapes.
679
+ * Different SDKs surface the status in different places (status, statusCode,
680
+ * response.status, code) and sometimes only in the message text, so this checks
681
+ * each known shape rather than assuming a single field.
682
+ */
683
+ const isRateLimitError = (err) => {
684
+ if (!err)
685
+ return false;
686
+ const statusCandidates = [
687
+ err.status,
688
+ err.statusCode,
689
+ err.response?.status,
690
+ err.response?.statusCode,
691
+ err.cause?.status,
692
+ err.cause?.statusCode,
693
+ err.code,
694
+ ];
695
+ if (statusCandidates.some((c) => c === 429 || c === "429"))
696
+ return true;
697
+ if (typeof err.code === "string" && /rate.?limit/i.test(err.code))
698
+ return true;
699
+ const message = typeof err.message === "string" ? err.message : "";
700
+ return (/\b429\b/.test(message) ||
701
+ /rate.?limit/i.test(message) ||
702
+ /too many requests/i.test(message));
703
+ };
550
704
  /**
551
705
  * Parses raw LLM content as JSON and validates against an optional Zod schema,
552
706
  * returning the canonical JSON string. Tries a direct parse of the trimmed text
@@ -807,29 +961,59 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
807
961
  const expectedDurationMs = 15_000; // Tune: expected typical response time
808
962
  let activeMessages = messagesToSend;
809
963
  let attempt = 0;
964
+ const calcCurrentProgress = () => {
965
+ const elapsed = Date.now() - overallStartTime;
966
+ return Math.round(minPercent +
967
+ (maxPercent - minPercent - 5) *
968
+ (1 - Math.exp(-elapsed / expectedDurationMs)));
969
+ };
810
970
  while (true) {
811
971
  let rawContent = "";
812
972
  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)));
973
+ let streamUsage = { input_tokens: 0, output_tokens: 0 };
974
+ // Inner loop: wait + retry on 429 around stream setup and consumption.
975
+ // Cost is only recorded on a successful stream partial streams that error
976
+ // out with a rate limit are not billed. A 429 fired mid-stream simply
977
+ // discards the partial output and restarts cleanly.
978
+ let rateLimitAttempt = 0;
979
+ while (true) {
980
+ rawContent = "";
981
+ chunkCount = 0;
982
+ streamUsage = { input_tokens: 0, output_tokens: 0 };
983
+ try {
984
+ assertQuotaAvailable();
985
+ const stream = await llm.stream(activeMessages);
986
+ for await (const chunk of stream) {
987
+ accumulateChunkUsage(streamUsage, chunk);
988
+ const content = chunk?.content || chunk;
989
+ if (typeof content === "string") {
990
+ rawContent += content;
991
+ chunkCount++;
992
+ if (chunkCount % progressReportInterval === 0) {
993
+ await onProgressReport({
994
+ message: "Generating content...",
995
+ progress: Math.min(calcCurrentProgress(), maxPercent - 5),
996
+ });
997
+ }
998
+ }
999
+ }
1000
+ break; // stream completed without 429
1001
+ }
1002
+ catch (err) {
1003
+ if (isRateLimitError(err) &&
1004
+ rateLimitAttempt < MAX_RATE_LIMIT_RETRIES) {
1005
+ rateLimitAttempt++;
1006
+ const { waitMs, source } = getRateLimitWaitMs(err);
1007
+ const waitSeconds = Math.round(waitMs / 1000);
1008
+ 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
1009
  await onProgressReport({
829
- message: "Generating content...",
830
- progress: Math.min(progress, maxPercent - 5),
1010
+ message: `AI service is busy. Retrying in ${waitSeconds}s...`,
1011
+ progress: Math.min(calcCurrentProgress(), maxPercent - 5),
831
1012
  });
1013
+ await sleep(waitMs);
1014
+ continue;
832
1015
  }
1016
+ throw err;
833
1017
  }
834
1018
  }
835
1019
  recordCost(calculateTextCost(modelName, streamUsage, config));
@@ -910,8 +1094,28 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
910
1094
  let activeMessages = messagesToSend;
911
1095
  let attempt = 0;
912
1096
  while (true) {
913
- assertQuotaAvailable();
914
- const response = await llm.invoke(activeMessages);
1097
+ // Inner loop: wait + retry on 429. No progress callback in this branch, so
1098
+ // the wait is silent to the caller — only the warn log is emitted.
1099
+ let response;
1100
+ let rateLimitAttempt = 0;
1101
+ while (true) {
1102
+ try {
1103
+ assertQuotaAvailable();
1104
+ response = await llm.invoke(activeMessages);
1105
+ break;
1106
+ }
1107
+ catch (err) {
1108
+ if (isRateLimitError(err) &&
1109
+ rateLimitAttempt < MAX_RATE_LIMIT_RETRIES) {
1110
+ rateLimitAttempt++;
1111
+ const { waitMs, source } = getRateLimitWaitMs(err);
1112
+ 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}`);
1113
+ await sleep(waitMs);
1114
+ continue;
1115
+ }
1116
+ throw err;
1117
+ }
1118
+ }
915
1119
  recordCost(calculateTextCost(modelName, extractUsageFromInvoke(response), config));
916
1120
  const rawContent = response?.content || response;
917
1121
  // If not expecting JSON, return raw content directly
@@ -936,47 +1140,6 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
936
1140
  }
937
1141
  }
938
1142
  };
939
- /**
940
- * Sends a conversational chat prompt to an LLM and returns a plain HTML response
941
- * intended for end-user display. This function is purpose-built for the StackFactor
942
- * Mentor chat feature: when the prompt is an array of messages, each `system` message
943
- * is wrapped in an enhanced system prompt that instructs the model to answer only
944
- * questions related to the provided topic, decline off-topic questions, and return
945
- * results as simple HTML without markdown code-block notation. The call is delegated to
946
- * `runPromptWithModel` with `expectsJsonResponse` set to `false`.
947
- * @param modelName - The model identifier, e.g. `"gpt-4o"`, `"claude-3-5-sonnet"`
948
- * @param config - Configuration object with API keys and optional `temperature`
949
- * @param prompt - A plain string user message, or an array of `{ role, content }`
950
- * message objects; `system` messages have the topic-constraint wrapper applied
951
- * @param onProgressReport - Optional callback for streaming progress updates passed
952
- * through to `runPromptWithModel`
953
- * @returns The model's response as a raw HTML string
954
- */
955
- const runChatPromptWithModel = (modelName, config, prompt, onProgressReport) => {
956
- let messages = prompt;
957
- // If prompt is an array, check for system message and enhance it
958
- if (Array.isArray(prompt)) {
959
- messages = prompt.map((msg) => {
960
- if (msg.role === "system") {
961
- return {
962
- ...msg,
963
- content: `You are StackFactor Mentor, an AI assistant that helps users by providing information related to the specified topic.
964
-
965
- ### Objective:
966
- Respond to the user question considering just related to the selected topic and all previous interactions:
967
- - If the question is unrelated decline to respond.
968
- - Return the results as a simple HMTL but don't include notations for formatting blocks.
969
-
970
- ### TOPIC INFORMATION:\n
971
- ${msg.content}
972
- `,
973
- };
974
- }
975
- return msg;
976
- });
977
- }
978
- return runPromptWithModel(modelName, config, messages, onProgressReport, 0, 100, false);
979
- };
980
1143
  /**
981
1144
  * Determines the image generation provider for a given model name based on its prefix.
982
1145
  * `dall-e-` and `gpt-image-` prefixes map to `"openai"`. `gemini-` and `imagen-`
@@ -1238,9 +1401,9 @@ const runPromptWithModelForImageGeneration = async (modelName, config, prompt, o
1238
1401
  }
1239
1402
  };
1240
1403
  export default {
1404
+ checkIfAIProviderConfigured,
1241
1405
  createAgent,
1242
1406
  runAgent,
1243
- runChatPromptWithModel,
1244
1407
  runPromptWithModel,
1245
1408
  runPromptWithModelForImageGeneration,
1246
1409
  throwErrorIfNotSuccessful,
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.23",
7
7
  "description": "",
8
8
  "main": "dist/cjs/index.js",
9
9
  "module": "dist/esm/index.js",