@stackfactor/agent-utils 1.0.19 → 1.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  declare const _default: {
2
2
  HTTP_CODES: {
3
3
  BAD_REQUEST: number;
4
+ PAYMENT_REQUIRED: number;
4
5
  UNPROCESSABLE_ENTITY: number;
5
6
  INTERNAL_SERVER_ERROR: number;
6
7
  BAD_GATEWAY: number;
@@ -9,6 +10,7 @@ declare const _default: {
9
10
  UNABLE_TO_GENERATE_CONTENT: string;
10
11
  UNEXPECTED_ERROR: string;
11
12
  UNSUPPORTED_MODEL: string;
13
+ QUOTA_EXHAUSTED: string;
12
14
  };
13
15
  };
14
16
  export default _default;
@@ -1 +1 @@
1
- {"version":3,"file":"const.d.ts","sourceRoot":"","sources":["../../src/const.ts"],"names":[],"mappings":";;;;;;;;;;;;;AAAA,wBAaE"}
1
+ {"version":3,"file":"const.d.ts","sourceRoot":"","sources":["../../src/const.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,wBAgBE"}
package/dist/cjs/const.js CHANGED
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.default = {
4
4
  HTTP_CODES: {
5
5
  BAD_REQUEST: 400,
6
+ PAYMENT_REQUIRED: 402,
6
7
  UNPROCESSABLE_ENTITY: 422,
7
8
  INTERNAL_SERVER_ERROR: 500,
8
9
  BAD_GATEWAY: 502,
@@ -11,5 +12,6 @@ exports.default = {
11
12
  UNABLE_TO_GENERATE_CONTENT: "Unable to generate content",
12
13
  UNEXPECTED_ERROR: "An unexpected error occured. If the issue persists please contact the StackFactor support team at support@stackfactor.ai",
13
14
  UNSUPPORTED_MODEL: "The specified model is not supported",
15
+ QUOTA_EXHAUSTED: "Agent session quota exhausted: no remaining budget for additional LLM calls.",
14
16
  },
15
17
  };
@@ -1 +1 @@
1
- {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":";wBAgWQ,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;wCAmfF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,KACpB,GAAG;oCAxYO,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;0CA7vB8B,GAAG,KAAG,MAAM;;AAwzBzD,wBAOE"}
1
+ {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":";wBAogBQ,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,KACV,GAAG;sBAyBG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,KAC1B,OAAO,CAAC,GAAG,CAAC;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"}
@@ -16,12 +16,157 @@ const zod_to_json_schema_1 = require("zod-to-json-schema");
16
16
  const JSON_ESCAPE_INSTRUCTION = `
17
17
  CRITICAL - Your response must be valid JSON. Escape ALL special characters in string values:
18
18
  - Newlines → \\n
19
- - Tabs → \\t
19
+ - Tabs → \\t
20
20
  - Carriage returns → \\r
21
21
  - Double quotes inside strings → \\"
22
22
  - Backslashes → \\\\
23
23
  Do NOT include raw newlines, tabs, or unescaped quotes inside JSON string values.
24
24
  `.trim();
25
+ /**
26
+ * Reads `global.quota` (set by the StackFactor host before invoking `main()`) and
27
+ * throws a `PAYMENT_REQUIRED` error when the session has no remaining budget. No-op
28
+ * when `global.quota` is absent (e.g. local tests) or `remaining` is not a number,
29
+ * which keeps the library usable outside the StackFactor runtime.
30
+ */
31
+ const assertQuotaAvailable = () => {
32
+ const quota = globalThis.quota;
33
+ if (!quota || typeof quota.remaining !== "number")
34
+ return;
35
+ if (quota.remaining <= 0) {
36
+ throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.PAYMENT_REQUIRED, const_js_1.default.ERROR.QUOTA_EXHAUSTED);
37
+ }
38
+ };
39
+ /**
40
+ * Subtracts the given USD cost from `global.quota.remaining` and adds it to
41
+ * `global.quota.usedThisSession`. Silently no-ops when the quota globals are not
42
+ * present so callers do not need to branch. Negative, zero, or non-finite costs
43
+ * are ignored to keep counters monotonic.
44
+ */
45
+ const recordCost = (cost) => {
46
+ if (!cost || cost <= 0 || !Number.isFinite(cost))
47
+ return;
48
+ const quota = globalThis.quota;
49
+ if (!quota)
50
+ return;
51
+ if (typeof quota.remaining === "number") {
52
+ quota.remaining -= cost;
53
+ }
54
+ if (typeof quota.usedThisSession === "number") {
55
+ quota.usedThisSession += cost;
56
+ }
57
+ else {
58
+ quota.usedThisSession = cost;
59
+ }
60
+ };
61
+ /**
62
+ * Looks up the per-model pricing entry from `config.modelPricing`. Returns `null`
63
+ * when pricing is not configured for the model, in which case cost recording
64
+ * becomes a no-op (the call still succeeds — pricing data is the host's
65
+ * responsibility, not the agent's).
66
+ */
67
+ const getModelPrice = (modelName, config) => {
68
+ const pricing = config?.modelPricing;
69
+ if (!pricing)
70
+ return null;
71
+ return pricing[modelName] || null;
72
+ };
73
+ /**
74
+ * Computes USD cost for a text LLM call. `config.modelPricing[modelName]` is
75
+ * expected to provide `input` and `output` rates in dollars per million tokens.
76
+ */
77
+ const calculateTextCost = (modelName, usage, config) => {
78
+ if (!usage)
79
+ return 0;
80
+ const price = getModelPrice(modelName, config);
81
+ if (!price)
82
+ return 0;
83
+ const inputCost = ((usage.input_tokens || 0) / 1_000_000) * (price.input || 0);
84
+ const outputCost = ((usage.output_tokens || 0) / 1_000_000) * (price.output || 0);
85
+ return inputCost + outputCost;
86
+ };
87
+ /**
88
+ * Computes USD cost for an image generation call. `config.modelPricing[modelName]`
89
+ * is expected to provide a `perImage` rate in dollars.
90
+ */
91
+ const calculateImageCost = (modelName, numImages, config) => {
92
+ const price = getModelPrice(modelName, config);
93
+ if (!price)
94
+ return 0;
95
+ return (numImages || 0) * (price.perImage || 0);
96
+ };
97
+ /**
98
+ * Extracts a normalized token-usage object from a single LangChain `invoke()`
99
+ * response. Reads from `usage_metadata` first (standardized in LangChain v1),
100
+ * then falls back to provider-specific shapes under `response_metadata`.
101
+ */
102
+ const extractUsageFromInvoke = (response) => {
103
+ if (!response)
104
+ return null;
105
+ const um = response.usage_metadata;
106
+ if (um) {
107
+ return {
108
+ input_tokens: um.input_tokens || 0,
109
+ output_tokens: um.output_tokens || 0,
110
+ };
111
+ }
112
+ const rm = response.response_metadata;
113
+ if (rm?.usage) {
114
+ return {
115
+ input_tokens: rm.usage.input_tokens || rm.usage.prompt_tokens || 0,
116
+ output_tokens: rm.usage.output_tokens || rm.usage.completion_tokens || 0,
117
+ };
118
+ }
119
+ if (rm?.tokenUsage) {
120
+ return {
121
+ input_tokens: rm.tokenUsage.promptTokens || 0,
122
+ output_tokens: rm.tokenUsage.completionTokens || 0,
123
+ };
124
+ }
125
+ return null;
126
+ };
127
+ /**
128
+ * Accumulates token usage from a streaming chunk into a running total. LangChain
129
+ * typically attaches `usage_metadata` to the final chunk; earlier chunks carry no
130
+ * usage and are no-ops here.
131
+ */
132
+ const accumulateChunkUsage = (acc, chunk) => {
133
+ if (!chunk)
134
+ return acc;
135
+ const um = chunk.usage_metadata;
136
+ if (um) {
137
+ acc.input_tokens += um.input_tokens || 0;
138
+ acc.output_tokens += um.output_tokens || 0;
139
+ return acc;
140
+ }
141
+ const rm = chunk.response_metadata;
142
+ if (rm?.usage) {
143
+ acc.input_tokens += rm.usage.input_tokens || rm.usage.prompt_tokens || 0;
144
+ acc.output_tokens +=
145
+ rm.usage.output_tokens || rm.usage.completion_tokens || 0;
146
+ }
147
+ return acc;
148
+ };
149
+ /**
150
+ * Sums token usage across every message in an agent invocation response. Each
151
+ * AIMessage in `response.messages` may carry its own `usage_metadata` (one per
152
+ * LLM round-trip the agent made).
153
+ */
154
+ const sumAgentResponseUsage = (response) => {
155
+ const messages = response?.messages;
156
+ if (!Array.isArray(messages) || messages.length === 0)
157
+ return null;
158
+ const total = { input_tokens: 0, output_tokens: 0 };
159
+ let found = false;
160
+ for (const msg of messages) {
161
+ const um = msg?.usage_metadata;
162
+ if (um) {
163
+ total.input_tokens += um.input_tokens || 0;
164
+ total.output_tokens += um.output_tokens || 0;
165
+ found = true;
166
+ }
167
+ }
168
+ return found ? total : null;
169
+ };
25
170
  /**
26
171
  * Converts a Zod validation error object into a human-readable multi-line string.
27
172
  * Each failing field is described with its dot-notation path and a contextual message
@@ -377,12 +522,15 @@ const runAgent = async (agent, prompt, config, onProgress = null) => {
377
522
  },
378
523
  ]
379
524
  : undefined;
525
+ assertQuotaAvailable();
380
526
  const response = await agent.invoke({
381
527
  messages: [{ role: "user", content: prompt }],
382
528
  }, {
383
529
  recursionLimit: config.recursionLimit || 25,
384
530
  ...(callbacks ? { callbacks } : {}),
385
531
  });
532
+ const modelName = agent.options?.model?.modelName || agent.options?.model?.model || "";
533
+ recordCost(calculateTextCost(modelName, sumAgentResponseUsage(response), config));
386
534
  const endTime = Date.now();
387
535
  const duration = endTime - startTime;
388
536
  logger_js_1.default.log(null, logger_js_1.default.levels.info, `Agent "${agent.options?.name}" completed in ${Math.round(duration / 1000)} seconds.`);
@@ -403,6 +551,75 @@ const throwErrorIfNotSuccessful = (response) => {
403
551
  throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, const_js_1.default.ERROR.UNABLE_TO_GENERATE_CONTENT);
404
552
  }
405
553
  };
554
+ const MAX_VALIDATION_RETRIES = 3;
555
+ /**
556
+ * Parses raw LLM content as JSON and validates against an optional Zod schema,
557
+ * returning the canonical JSON string. Tries a direct parse of the trimmed text
558
+ * first, then falls back to `extractJSONFromResponse` for markdown-wrapped output.
559
+ * Throws `UNPROCESSABLE_ENTITY` when the parsed value fails schema validation, or
560
+ * `INTERNAL_SERVER_ERROR` when no JSON can be extracted from the response at all.
561
+ */
562
+ const parseAndValidateJSONResponse = (rawContent, schema) => {
563
+ if (typeof rawContent === "string") {
564
+ const trimmed = rawContent.trim();
565
+ if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
566
+ (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
567
+ try {
568
+ const parsed = JSON.parse(trimmed);
569
+ if (schema) {
570
+ validateWithSchema(parsed, schema);
571
+ }
572
+ return JSON.stringify(parsed);
573
+ }
574
+ catch (parseError) {
575
+ if (parseError?.code === const_js_1.default.HTTP_CODES.UNPROCESSABLE_ENTITY) {
576
+ throw parseError;
577
+ }
578
+ // JSON.parse failed (not a validation error) — fall through to extraction.
579
+ }
580
+ }
581
+ const extracted = extractJSONFromResponse(rawContent);
582
+ if (extracted) {
583
+ if (schema) {
584
+ validateWithSchema(extracted, schema);
585
+ }
586
+ return JSON.stringify(extracted);
587
+ }
588
+ }
589
+ let preview = "";
590
+ if (typeof rawContent === "string") {
591
+ preview = rawContent.substring(0, 100);
592
+ }
593
+ else if (typeof rawContent === "object" && rawContent !== null) {
594
+ preview = JSON.stringify(rawContent).substring(0, 100);
595
+ }
596
+ else if (rawContent !== undefined && rawContent !== null) {
597
+ preview = String(rawContent).substring(0, 100);
598
+ }
599
+ else {
600
+ preview = "[empty response]";
601
+ }
602
+ 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}...`);
603
+ };
604
+ /**
605
+ * Appends the model's failed output as an assistant turn followed by a user turn
606
+ * containing the Zod validation errors. Including the bad assistant turn (not just
607
+ * the critique) lets the model see what it actually produced — without it,
608
+ * self-correction is guesswork. Callers pass the *current* message list (not the
609
+ * original base) so that across multiple retries the full failure history
610
+ * accumulates and the model can avoid oscillating between previous broken outputs.
611
+ */
612
+ const buildValidationRetryMessages = (priorMessages, rawContent, validationError) => {
613
+ const assistantContent = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent);
614
+ return [
615
+ ...priorMessages,
616
+ { role: "assistant", content: assistantContent },
617
+ {
618
+ role: "user",
619
+ 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}`,
620
+ },
621
+ ];
622
+ };
406
623
  /**
407
624
  * Sends a prompt to an LLM and returns the response, with support for streaming,
408
625
  * agentic execution, progress reporting, JSON extraction, and Zod schema validation.
@@ -580,80 +797,79 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
580
797
  else {
581
798
  messagesToSend = messages;
582
799
  }
583
- // Stream and report server-side progress based on time elapsed
584
- let rawContent = "";
585
- let chunkCount = 0;
800
+ // Retry once on schema validation failure. The retry is silent to the progress
801
+ // callback same "Generating content..." message and the progress curve
802
+ // continues from the original startTime so the bar doesn't visibly reset.
803
+ // Skipped when OpenAI native response_format is in use (a 422 there is a
804
+ // server-side schema bug, not a model output issue). Each attempt is billed
805
+ // independently via recordCost so retry cost is still visible in telemetry.
806
+ const canRetryOnValidation = expectsJsonResponse && !!schema && !useNativeSchema;
586
807
  const progressReportInterval = 10; // Report every N chunks
587
- const startTime = Date.now();
808
+ const overallStartTime = Date.now();
588
809
  // Use a time-based asymptotic curve: progress approaches maxPercent but never
589
810
  // overshoots. This avoids the magic "expected length" constant — longer responses
590
811
  // simply slow the curve down rather than exceeding the range.
591
812
  const expectedDurationMs = 15_000; // Tune: expected typical response time
592
- const stream = await llm.stream(messagesToSend);
593
- for await (const chunk of stream) {
594
- const content = chunk?.content || chunk;
595
- if (typeof content === "string") {
596
- rawContent += content;
597
- chunkCount++;
598
- if (chunkCount % progressReportInterval === 0) {
599
- const elapsed = Date.now() - startTime;
600
- // Asymptotic curve: fast early progress that slows as it approaches max
601
- const progress = Math.round(minPercent +
602
- (maxPercent - minPercent - 5) *
603
- (1 - Math.exp(-elapsed / expectedDurationMs)));
604
- await onProgressReport({
605
- message: "Generating content...",
606
- progress: Math.min(progress, maxPercent - 5),
607
- });
813
+ let activeMessages = messagesToSend;
814
+ let attempt = 0;
815
+ while (true) {
816
+ let rawContent = "";
817
+ 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)));
833
+ await onProgressReport({
834
+ message: "Generating content...",
835
+ progress: Math.min(progress, maxPercent - 5),
836
+ });
837
+ }
608
838
  }
609
839
  }
610
- }
611
- await onProgressReport({
612
- message: "Processing complete",
613
- progress: maxPercent,
614
- });
615
- if (!rawContent) {
616
- throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, "LLM returned no content");
617
- }
618
- // If not expecting JSON, return raw content directly
619
- if (!expectsJsonResponse) {
620
- return rawContent;
621
- }
622
- // Parse and validate JSON response
623
- const trimmed = rawContent.trim();
624
- if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
625
- (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
840
+ recordCost(calculateTextCost(modelName, streamUsage, config));
841
+ if (!rawContent) {
842
+ throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, "LLM returned no content");
843
+ }
844
+ // If not expecting JSON, return raw content directly
845
+ if (!expectsJsonResponse) {
846
+ await onProgressReport({
847
+ message: "Processing complete",
848
+ progress: maxPercent,
849
+ });
850
+ return rawContent;
851
+ }
626
852
  try {
627
- const parsed = JSON.parse(trimmed);
628
- if (schema) {
629
- validateWithSchema(parsed, schema);
630
- }
631
- return JSON.stringify(parsed);
853
+ const result = parseAndValidateJSONResponse(rawContent, schema);
854
+ await onProgressReport({
855
+ message: "Processing complete",
856
+ progress: maxPercent,
857
+ });
858
+ return result;
632
859
  }
633
- catch (parseError) {
634
- if (parseError?.code === const_js_1.default.HTTP_CODES.UNPROCESSABLE_ENTITY) {
635
- throw parseError;
860
+ catch (err) {
861
+ const isValidationFailure = err?.code === const_js_1.default.HTTP_CODES.UNPROCESSABLE_ENTITY;
862
+ if (isValidationFailure &&
863
+ canRetryOnValidation &&
864
+ attempt < MAX_VALIDATION_RETRIES) {
865
+ attempt++;
866
+ logger_js_1.default.log(null, logger_js_1.default.levels.warn, `Schema validation failed for "${modelName}", retrying (${attempt}/${MAX_VALIDATION_RETRIES}): ${err.message}`);
867
+ activeMessages = buildValidationRetryMessages(activeMessages, rawContent, err);
868
+ continue;
636
869
  }
870
+ throw err;
637
871
  }
638
872
  }
639
- const extracted = extractJSONFromResponse(rawContent);
640
- if (extracted) {
641
- if (schema) {
642
- validateWithSchema(extracted, schema);
643
- }
644
- return JSON.stringify(extracted);
645
- }
646
- let preview = "";
647
- if (typeof rawContent === "string") {
648
- preview = rawContent.substring(0, 100);
649
- }
650
- else if (typeof rawContent === "object" && rawContent !== null) {
651
- preview = JSON.stringify(rawContent).substring(0, 100);
652
- }
653
- else {
654
- preview = "[empty response]";
655
- }
656
- 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}...`);
657
873
  }
658
874
  else {
659
875
  // Non-streaming mode: use native response_format for OpenAI when schema is provided
@@ -693,65 +909,36 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
693
909
  else {
694
910
  messagesToSend = messages;
695
911
  }
696
- // Simply invoke without streaming
697
- const response = await llm.invoke(messagesToSend);
698
- const rawContent = response?.content || response;
699
- // If not expecting JSON, return raw content directly
700
- if (!expectsJsonResponse) {
701
- return rawContent;
702
- }
703
- // If the response is already a string that looks like JSON, return it
704
- // Otherwise, try to extract JSON from potential markdown wrapping
705
- if (typeof rawContent === "string") {
706
- const trimmed = rawContent.trim();
707
- // Check if it's already clean JSON
708
- if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
709
- (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
710
- try {
711
- // Parse and re-stringify
712
- const parsed = JSON.parse(trimmed);
713
- // Validate against schema if provided
714
- if (schema) {
715
- validateWithSchema(parsed, schema);
716
- }
717
- return JSON.stringify(parsed);
718
- }
719
- catch (parseError) {
720
- // If it's a validation error, re-throw it
721
- if (parseError?.code === const_js_1.default.HTTP_CODES.UNPROCESSABLE_ENTITY) {
722
- throw parseError;
723
- }
724
- // Not valid JSON, try extraction
725
- }
912
+ // Retry once on schema validation failure (see streaming branch for rationale).
913
+ // Each attempt is billed independently via recordCost so retry cost is visible.
914
+ const canRetryOnValidation = expectsJsonResponse && !!schema && !useNativeSchema;
915
+ let activeMessages = messagesToSend;
916
+ let attempt = 0;
917
+ while (true) {
918
+ assertQuotaAvailable();
919
+ const response = await llm.invoke(activeMessages);
920
+ recordCost(calculateTextCost(modelName, extractUsageFromInvoke(response), config));
921
+ const rawContent = response?.content || response;
922
+ // If not expecting JSON, return raw content directly
923
+ if (!expectsJsonResponse) {
924
+ return rawContent;
726
925
  }
727
- // Try to extract JSON from markdown or other wrapping
728
- const extracted = extractJSONFromResponse(rawContent);
729
- if (extracted) {
730
- // Validate against schema if provided
731
- if (schema) {
732
- validateWithSchema(extracted, schema);
733
- }
734
- return JSON.stringify(extracted);
735
- }
736
- }
737
- // Return raw content as last resort - but throw if JSON was expected
738
- if (expectsJsonResponse) {
739
- let preview = "";
740
- if (typeof rawContent === "string") {
741
- preview = rawContent.substring(0, 100);
742
- }
743
- else if (typeof rawContent === "object" && rawContent !== null) {
744
- preview = JSON.stringify(rawContent).substring(0, 100);
745
- }
746
- else if (rawContent !== undefined && rawContent !== null) {
747
- preview = String(rawContent).substring(0, 100);
926
+ try {
927
+ return parseAndValidateJSONResponse(rawContent, schema);
748
928
  }
749
- else {
750
- preview = "[empty response]";
929
+ catch (err) {
930
+ const isValidationFailure = err?.code === const_js_1.default.HTTP_CODES.UNPROCESSABLE_ENTITY;
931
+ if (isValidationFailure &&
932
+ canRetryOnValidation &&
933
+ attempt < MAX_VALIDATION_RETRIES) {
934
+ attempt++;
935
+ logger_js_1.default.log(null, logger_js_1.default.levels.warn, `Schema validation failed for "${modelName}", retrying (${attempt}/${MAX_VALIDATION_RETRIES}): ${err.message}`);
936
+ activeMessages = buildValidationRetryMessages(activeMessages, rawContent, err);
937
+ continue;
938
+ }
939
+ throw err;
751
940
  }
752
- 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}...`);
753
941
  }
754
- return rawContent;
755
942
  }
756
943
  };
757
944
  /**
@@ -877,7 +1064,9 @@ const generateImageWithOpenAI = async (modelName, config, prompt, options) => {
877
1064
  requestParams.style = style;
878
1065
  }
879
1066
  }
1067
+ assertQuotaAvailable();
880
1068
  const response = await openai.images.generate(requestParams);
1069
+ recordCost(calculateImageCost(modelName, response.data?.length || n, config));
881
1070
  // Format response based on number of images
882
1071
  if (n === 1) {
883
1072
  const imageData = response.data[0];
@@ -975,6 +1164,7 @@ const generateImageWithGoogle = async (modelName, config, prompt, options) => {
975
1164
  safetySettings: safetySettings,
976
1165
  },
977
1166
  };
1167
+ assertQuotaAvailable();
978
1168
  const response = await ai.models.generateContent(req);
979
1169
  // Extract images from response
980
1170
  const images = [];
@@ -993,6 +1183,7 @@ const generateImageWithGoogle = async (modelName, config, prompt, options) => {
993
1183
  if (images.length === 0) {
994
1184
  throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, `No images were generated by ${modelName}`);
995
1185
  }
1186
+ recordCost(calculateImageCost(modelName, images.length, config));
996
1187
  if (numberOfImages === 1 || images.length === 1) {
997
1188
  return images[0];
998
1189
  }
@@ -1,6 +1,7 @@
1
1
  declare const _default: {
2
2
  HTTP_CODES: {
3
3
  BAD_REQUEST: number;
4
+ PAYMENT_REQUIRED: number;
4
5
  UNPROCESSABLE_ENTITY: number;
5
6
  INTERNAL_SERVER_ERROR: number;
6
7
  BAD_GATEWAY: number;
@@ -9,6 +10,7 @@ declare const _default: {
9
10
  UNABLE_TO_GENERATE_CONTENT: string;
10
11
  UNEXPECTED_ERROR: string;
11
12
  UNSUPPORTED_MODEL: string;
13
+ QUOTA_EXHAUSTED: string;
12
14
  };
13
15
  };
14
16
  export default _default;
@@ -1 +1 @@
1
- {"version":3,"file":"const.d.ts","sourceRoot":"","sources":["../../src/const.ts"],"names":[],"mappings":";;;;;;;;;;;;;AAAA,wBAaE"}
1
+ {"version":3,"file":"const.d.ts","sourceRoot":"","sources":["../../src/const.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,wBAgBE"}
package/dist/esm/const.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export default {
2
2
  HTTP_CODES: {
3
3
  BAD_REQUEST: 400,
4
+ PAYMENT_REQUIRED: 402,
4
5
  UNPROCESSABLE_ENTITY: 422,
5
6
  INTERNAL_SERVER_ERROR: 500,
6
7
  BAD_GATEWAY: 502,
@@ -9,5 +10,6 @@ export default {
9
10
  UNABLE_TO_GENERATE_CONTENT: "Unable to generate content",
10
11
  UNEXPECTED_ERROR: "An unexpected error occured. If the issue persists please contact the StackFactor support team at support@stackfactor.ai",
11
12
  UNSUPPORTED_MODEL: "The specified model is not supported",
13
+ QUOTA_EXHAUSTED: "Agent session quota exhausted: no remaining budget for additional LLM calls.",
12
14
  },
13
15
  };
@@ -1 +1 @@
1
- {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":";wBAgWQ,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;wCAmfF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,KACpB,GAAG;oCAxYO,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;0CA7vB8B,GAAG,KAAG,MAAM;;AAwzBzD,wBAOE"}
1
+ {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":";wBAogBQ,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,KACV,GAAG;sBAyBG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,KAC1B,OAAO,CAAC,GAAG,CAAC;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"}
@@ -11,12 +11,157 @@ import { zodToJsonSchema } from "zod-to-json-schema";
11
11
  const JSON_ESCAPE_INSTRUCTION = `
12
12
  CRITICAL - Your response must be valid JSON. Escape ALL special characters in string values:
13
13
  - Newlines → \\n
14
- - Tabs → \\t
14
+ - Tabs → \\t
15
15
  - Carriage returns → \\r
16
16
  - Double quotes inside strings → \\"
17
17
  - Backslashes → \\\\
18
18
  Do NOT include raw newlines, tabs, or unescaped quotes inside JSON string values.
19
19
  `.trim();
20
+ /**
21
+ * Reads `global.quota` (set by the StackFactor host before invoking `main()`) and
22
+ * throws a `PAYMENT_REQUIRED` error when the session has no remaining budget. No-op
23
+ * when `global.quota` is absent (e.g. local tests) or `remaining` is not a number,
24
+ * which keeps the library usable outside the StackFactor runtime.
25
+ */
26
+ const assertQuotaAvailable = () => {
27
+ const quota = globalThis.quota;
28
+ if (!quota || typeof quota.remaining !== "number")
29
+ return;
30
+ if (quota.remaining <= 0) {
31
+ throw errorHandlingHelper.create(constants.HTTP_CODES.PAYMENT_REQUIRED, constants.ERROR.QUOTA_EXHAUSTED);
32
+ }
33
+ };
34
+ /**
35
+ * Subtracts the given USD cost from `global.quota.remaining` and adds it to
36
+ * `global.quota.usedThisSession`. Silently no-ops when the quota globals are not
37
+ * present so callers do not need to branch. Negative, zero, or non-finite costs
38
+ * are ignored to keep counters monotonic.
39
+ */
40
+ const recordCost = (cost) => {
41
+ if (!cost || cost <= 0 || !Number.isFinite(cost))
42
+ return;
43
+ const quota = globalThis.quota;
44
+ if (!quota)
45
+ return;
46
+ if (typeof quota.remaining === "number") {
47
+ quota.remaining -= cost;
48
+ }
49
+ if (typeof quota.usedThisSession === "number") {
50
+ quota.usedThisSession += cost;
51
+ }
52
+ else {
53
+ quota.usedThisSession = cost;
54
+ }
55
+ };
56
+ /**
57
+ * Looks up the per-model pricing entry from `config.modelPricing`. Returns `null`
58
+ * when pricing is not configured for the model, in which case cost recording
59
+ * becomes a no-op (the call still succeeds — pricing data is the host's
60
+ * responsibility, not the agent's).
61
+ */
62
+ const getModelPrice = (modelName, config) => {
63
+ const pricing = config?.modelPricing;
64
+ if (!pricing)
65
+ return null;
66
+ return pricing[modelName] || null;
67
+ };
68
+ /**
69
+ * Computes USD cost for a text LLM call. `config.modelPricing[modelName]` is
70
+ * expected to provide `input` and `output` rates in dollars per million tokens.
71
+ */
72
+ const calculateTextCost = (modelName, usage, config) => {
73
+ if (!usage)
74
+ return 0;
75
+ const price = getModelPrice(modelName, config);
76
+ if (!price)
77
+ return 0;
78
+ const inputCost = ((usage.input_tokens || 0) / 1_000_000) * (price.input || 0);
79
+ const outputCost = ((usage.output_tokens || 0) / 1_000_000) * (price.output || 0);
80
+ return inputCost + outputCost;
81
+ };
82
+ /**
83
+ * Computes USD cost for an image generation call. `config.modelPricing[modelName]`
84
+ * is expected to provide a `perImage` rate in dollars.
85
+ */
86
+ const calculateImageCost = (modelName, numImages, config) => {
87
+ const price = getModelPrice(modelName, config);
88
+ if (!price)
89
+ return 0;
90
+ return (numImages || 0) * (price.perImage || 0);
91
+ };
92
+ /**
93
+ * Extracts a normalized token-usage object from a single LangChain `invoke()`
94
+ * response. Reads from `usage_metadata` first (standardized in LangChain v1),
95
+ * then falls back to provider-specific shapes under `response_metadata`.
96
+ */
97
+ const extractUsageFromInvoke = (response) => {
98
+ if (!response)
99
+ return null;
100
+ const um = response.usage_metadata;
101
+ if (um) {
102
+ return {
103
+ input_tokens: um.input_tokens || 0,
104
+ output_tokens: um.output_tokens || 0,
105
+ };
106
+ }
107
+ const rm = response.response_metadata;
108
+ if (rm?.usage) {
109
+ return {
110
+ input_tokens: rm.usage.input_tokens || rm.usage.prompt_tokens || 0,
111
+ output_tokens: rm.usage.output_tokens || rm.usage.completion_tokens || 0,
112
+ };
113
+ }
114
+ if (rm?.tokenUsage) {
115
+ return {
116
+ input_tokens: rm.tokenUsage.promptTokens || 0,
117
+ output_tokens: rm.tokenUsage.completionTokens || 0,
118
+ };
119
+ }
120
+ return null;
121
+ };
122
+ /**
123
+ * Accumulates token usage from a streaming chunk into a running total. LangChain
124
+ * typically attaches `usage_metadata` to the final chunk; earlier chunks carry no
125
+ * usage and are no-ops here.
126
+ */
127
+ const accumulateChunkUsage = (acc, chunk) => {
128
+ if (!chunk)
129
+ return acc;
130
+ const um = chunk.usage_metadata;
131
+ if (um) {
132
+ acc.input_tokens += um.input_tokens || 0;
133
+ acc.output_tokens += um.output_tokens || 0;
134
+ return acc;
135
+ }
136
+ const rm = chunk.response_metadata;
137
+ if (rm?.usage) {
138
+ acc.input_tokens += rm.usage.input_tokens || rm.usage.prompt_tokens || 0;
139
+ acc.output_tokens +=
140
+ rm.usage.output_tokens || rm.usage.completion_tokens || 0;
141
+ }
142
+ return acc;
143
+ };
144
+ /**
145
+ * Sums token usage across every message in an agent invocation response. Each
146
+ * AIMessage in `response.messages` may carry its own `usage_metadata` (one per
147
+ * LLM round-trip the agent made).
148
+ */
149
+ const sumAgentResponseUsage = (response) => {
150
+ const messages = response?.messages;
151
+ if (!Array.isArray(messages) || messages.length === 0)
152
+ return null;
153
+ const total = { input_tokens: 0, output_tokens: 0 };
154
+ let found = false;
155
+ for (const msg of messages) {
156
+ const um = msg?.usage_metadata;
157
+ if (um) {
158
+ total.input_tokens += um.input_tokens || 0;
159
+ total.output_tokens += um.output_tokens || 0;
160
+ found = true;
161
+ }
162
+ }
163
+ return found ? total : null;
164
+ };
20
165
  /**
21
166
  * Converts a Zod validation error object into a human-readable multi-line string.
22
167
  * Each failing field is described with its dot-notation path and a contextual message
@@ -372,12 +517,15 @@ const runAgent = async (agent, prompt, config, onProgress = null) => {
372
517
  },
373
518
  ]
374
519
  : undefined;
520
+ assertQuotaAvailable();
375
521
  const response = await agent.invoke({
376
522
  messages: [{ role: "user", content: prompt }],
377
523
  }, {
378
524
  recursionLimit: config.recursionLimit || 25,
379
525
  ...(callbacks ? { callbacks } : {}),
380
526
  });
527
+ const modelName = agent.options?.model?.modelName || agent.options?.model?.model || "";
528
+ recordCost(calculateTextCost(modelName, sumAgentResponseUsage(response), config));
381
529
  const endTime = Date.now();
382
530
  const duration = endTime - startTime;
383
531
  logger.log(null, logger.levels.info, `Agent "${agent.options?.name}" completed in ${Math.round(duration / 1000)} seconds.`);
@@ -398,6 +546,75 @@ const throwErrorIfNotSuccessful = (response) => {
398
546
  throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, constants.ERROR.UNABLE_TO_GENERATE_CONTENT);
399
547
  }
400
548
  };
549
+ const MAX_VALIDATION_RETRIES = 3;
550
+ /**
551
+ * Parses raw LLM content as JSON and validates against an optional Zod schema,
552
+ * returning the canonical JSON string. Tries a direct parse of the trimmed text
553
+ * first, then falls back to `extractJSONFromResponse` for markdown-wrapped output.
554
+ * Throws `UNPROCESSABLE_ENTITY` when the parsed value fails schema validation, or
555
+ * `INTERNAL_SERVER_ERROR` when no JSON can be extracted from the response at all.
556
+ */
557
+ const parseAndValidateJSONResponse = (rawContent, schema) => {
558
+ if (typeof rawContent === "string") {
559
+ const trimmed = rawContent.trim();
560
+ if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
561
+ (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
562
+ try {
563
+ const parsed = JSON.parse(trimmed);
564
+ if (schema) {
565
+ validateWithSchema(parsed, schema);
566
+ }
567
+ return JSON.stringify(parsed);
568
+ }
569
+ catch (parseError) {
570
+ if (parseError?.code === constants.HTTP_CODES.UNPROCESSABLE_ENTITY) {
571
+ throw parseError;
572
+ }
573
+ // JSON.parse failed (not a validation error) — fall through to extraction.
574
+ }
575
+ }
576
+ const extracted = extractJSONFromResponse(rawContent);
577
+ if (extracted) {
578
+ if (schema) {
579
+ validateWithSchema(extracted, schema);
580
+ }
581
+ return JSON.stringify(extracted);
582
+ }
583
+ }
584
+ let preview = "";
585
+ if (typeof rawContent === "string") {
586
+ preview = rawContent.substring(0, 100);
587
+ }
588
+ else if (typeof rawContent === "object" && rawContent !== null) {
589
+ preview = JSON.stringify(rawContent).substring(0, 100);
590
+ }
591
+ else if (rawContent !== undefined && rawContent !== null) {
592
+ preview = String(rawContent).substring(0, 100);
593
+ }
594
+ else {
595
+ preview = "[empty response]";
596
+ }
597
+ throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, `LLM returned non-JSON response when JSON was expected: ${preview}...`);
598
+ };
599
+ /**
600
+ * Appends the model's failed output as an assistant turn followed by a user turn
601
+ * containing the Zod validation errors. Including the bad assistant turn (not just
602
+ * the critique) lets the model see what it actually produced — without it,
603
+ * self-correction is guesswork. Callers pass the *current* message list (not the
604
+ * original base) so that across multiple retries the full failure history
605
+ * accumulates and the model can avoid oscillating between previous broken outputs.
606
+ */
607
+ const buildValidationRetryMessages = (priorMessages, rawContent, validationError) => {
608
+ const assistantContent = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent);
609
+ return [
610
+ ...priorMessages,
611
+ { role: "assistant", content: assistantContent },
612
+ {
613
+ role: "user",
614
+ 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}`,
615
+ },
616
+ ];
617
+ };
401
618
  /**
402
619
  * Sends a prompt to an LLM and returns the response, with support for streaming,
403
620
  * agentic execution, progress reporting, JSON extraction, and Zod schema validation.
@@ -575,80 +792,79 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
575
792
  else {
576
793
  messagesToSend = messages;
577
794
  }
578
- // Stream and report server-side progress based on time elapsed
579
- let rawContent = "";
580
- let chunkCount = 0;
795
+ // Retry once on schema validation failure. The retry is silent to the progress
796
+ // callback same "Generating content..." message and the progress curve
797
+ // continues from the original startTime so the bar doesn't visibly reset.
798
+ // Skipped when OpenAI native response_format is in use (a 422 there is a
799
+ // server-side schema bug, not a model output issue). Each attempt is billed
800
+ // independently via recordCost so retry cost is still visible in telemetry.
801
+ const canRetryOnValidation = expectsJsonResponse && !!schema && !useNativeSchema;
581
802
  const progressReportInterval = 10; // Report every N chunks
582
- const startTime = Date.now();
803
+ const overallStartTime = Date.now();
583
804
  // Use a time-based asymptotic curve: progress approaches maxPercent but never
584
805
  // overshoots. This avoids the magic "expected length" constant — longer responses
585
806
  // simply slow the curve down rather than exceeding the range.
586
807
  const expectedDurationMs = 15_000; // Tune: expected typical response time
587
- const stream = await llm.stream(messagesToSend);
588
- for await (const chunk of stream) {
589
- const content = chunk?.content || chunk;
590
- if (typeof content === "string") {
591
- rawContent += content;
592
- chunkCount++;
593
- if (chunkCount % progressReportInterval === 0) {
594
- const elapsed = Date.now() - startTime;
595
- // Asymptotic curve: fast early progress that slows as it approaches max
596
- const progress = Math.round(minPercent +
597
- (maxPercent - minPercent - 5) *
598
- (1 - Math.exp(-elapsed / expectedDurationMs)));
599
- await onProgressReport({
600
- message: "Generating content...",
601
- progress: Math.min(progress, maxPercent - 5),
602
- });
808
+ let activeMessages = messagesToSend;
809
+ let attempt = 0;
810
+ while (true) {
811
+ let rawContent = "";
812
+ 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)));
828
+ await onProgressReport({
829
+ message: "Generating content...",
830
+ progress: Math.min(progress, maxPercent - 5),
831
+ });
832
+ }
603
833
  }
604
834
  }
605
- }
606
- await onProgressReport({
607
- message: "Processing complete",
608
- progress: maxPercent,
609
- });
610
- if (!rawContent) {
611
- throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, "LLM returned no content");
612
- }
613
- // If not expecting JSON, return raw content directly
614
- if (!expectsJsonResponse) {
615
- return rawContent;
616
- }
617
- // Parse and validate JSON response
618
- const trimmed = rawContent.trim();
619
- if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
620
- (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
835
+ recordCost(calculateTextCost(modelName, streamUsage, config));
836
+ if (!rawContent) {
837
+ throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, "LLM returned no content");
838
+ }
839
+ // If not expecting JSON, return raw content directly
840
+ if (!expectsJsonResponse) {
841
+ await onProgressReport({
842
+ message: "Processing complete",
843
+ progress: maxPercent,
844
+ });
845
+ return rawContent;
846
+ }
621
847
  try {
622
- const parsed = JSON.parse(trimmed);
623
- if (schema) {
624
- validateWithSchema(parsed, schema);
625
- }
626
- return JSON.stringify(parsed);
848
+ const result = parseAndValidateJSONResponse(rawContent, schema);
849
+ await onProgressReport({
850
+ message: "Processing complete",
851
+ progress: maxPercent,
852
+ });
853
+ return result;
627
854
  }
628
- catch (parseError) {
629
- if (parseError?.code === constants.HTTP_CODES.UNPROCESSABLE_ENTITY) {
630
- throw parseError;
855
+ catch (err) {
856
+ const isValidationFailure = err?.code === constants.HTTP_CODES.UNPROCESSABLE_ENTITY;
857
+ if (isValidationFailure &&
858
+ canRetryOnValidation &&
859
+ attempt < MAX_VALIDATION_RETRIES) {
860
+ attempt++;
861
+ logger.log(null, logger.levels.warn, `Schema validation failed for "${modelName}", retrying (${attempt}/${MAX_VALIDATION_RETRIES}): ${err.message}`);
862
+ activeMessages = buildValidationRetryMessages(activeMessages, rawContent, err);
863
+ continue;
631
864
  }
865
+ throw err;
632
866
  }
633
867
  }
634
- const extracted = extractJSONFromResponse(rawContent);
635
- if (extracted) {
636
- if (schema) {
637
- validateWithSchema(extracted, schema);
638
- }
639
- return JSON.stringify(extracted);
640
- }
641
- let preview = "";
642
- if (typeof rawContent === "string") {
643
- preview = rawContent.substring(0, 100);
644
- }
645
- else if (typeof rawContent === "object" && rawContent !== null) {
646
- preview = JSON.stringify(rawContent).substring(0, 100);
647
- }
648
- else {
649
- preview = "[empty response]";
650
- }
651
- throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, `LLM returned non-JSON response when JSON was expected: ${preview}...`);
652
868
  }
653
869
  else {
654
870
  // Non-streaming mode: use native response_format for OpenAI when schema is provided
@@ -688,65 +904,36 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
688
904
  else {
689
905
  messagesToSend = messages;
690
906
  }
691
- // Simply invoke without streaming
692
- const response = await llm.invoke(messagesToSend);
693
- const rawContent = response?.content || response;
694
- // If not expecting JSON, return raw content directly
695
- if (!expectsJsonResponse) {
696
- return rawContent;
697
- }
698
- // If the response is already a string that looks like JSON, return it
699
- // Otherwise, try to extract JSON from potential markdown wrapping
700
- if (typeof rawContent === "string") {
701
- const trimmed = rawContent.trim();
702
- // Check if it's already clean JSON
703
- if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
704
- (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
705
- try {
706
- // Parse and re-stringify
707
- const parsed = JSON.parse(trimmed);
708
- // Validate against schema if provided
709
- if (schema) {
710
- validateWithSchema(parsed, schema);
711
- }
712
- return JSON.stringify(parsed);
713
- }
714
- catch (parseError) {
715
- // If it's a validation error, re-throw it
716
- if (parseError?.code === constants.HTTP_CODES.UNPROCESSABLE_ENTITY) {
717
- throw parseError;
718
- }
719
- // Not valid JSON, try extraction
720
- }
907
+ // Retry once on schema validation failure (see streaming branch for rationale).
908
+ // Each attempt is billed independently via recordCost so retry cost is visible.
909
+ const canRetryOnValidation = expectsJsonResponse && !!schema && !useNativeSchema;
910
+ let activeMessages = messagesToSend;
911
+ let attempt = 0;
912
+ while (true) {
913
+ assertQuotaAvailable();
914
+ const response = await llm.invoke(activeMessages);
915
+ recordCost(calculateTextCost(modelName, extractUsageFromInvoke(response), config));
916
+ const rawContent = response?.content || response;
917
+ // If not expecting JSON, return raw content directly
918
+ if (!expectsJsonResponse) {
919
+ return rawContent;
721
920
  }
722
- // Try to extract JSON from markdown or other wrapping
723
- const extracted = extractJSONFromResponse(rawContent);
724
- if (extracted) {
725
- // Validate against schema if provided
726
- if (schema) {
727
- validateWithSchema(extracted, schema);
728
- }
729
- return JSON.stringify(extracted);
730
- }
731
- }
732
- // Return raw content as last resort - but throw if JSON was expected
733
- if (expectsJsonResponse) {
734
- let preview = "";
735
- if (typeof rawContent === "string") {
736
- preview = rawContent.substring(0, 100);
737
- }
738
- else if (typeof rawContent === "object" && rawContent !== null) {
739
- preview = JSON.stringify(rawContent).substring(0, 100);
740
- }
741
- else if (rawContent !== undefined && rawContent !== null) {
742
- preview = String(rawContent).substring(0, 100);
921
+ try {
922
+ return parseAndValidateJSONResponse(rawContent, schema);
743
923
  }
744
- else {
745
- preview = "[empty response]";
924
+ catch (err) {
925
+ const isValidationFailure = err?.code === constants.HTTP_CODES.UNPROCESSABLE_ENTITY;
926
+ if (isValidationFailure &&
927
+ canRetryOnValidation &&
928
+ attempt < MAX_VALIDATION_RETRIES) {
929
+ attempt++;
930
+ logger.log(null, logger.levels.warn, `Schema validation failed for "${modelName}", retrying (${attempt}/${MAX_VALIDATION_RETRIES}): ${err.message}`);
931
+ activeMessages = buildValidationRetryMessages(activeMessages, rawContent, err);
932
+ continue;
933
+ }
934
+ throw err;
746
935
  }
747
- throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, `LLM returned non-JSON response when JSON was expected: ${preview}...`);
748
936
  }
749
- return rawContent;
750
937
  }
751
938
  };
752
939
  /**
@@ -872,7 +1059,9 @@ const generateImageWithOpenAI = async (modelName, config, prompt, options) => {
872
1059
  requestParams.style = style;
873
1060
  }
874
1061
  }
1062
+ assertQuotaAvailable();
875
1063
  const response = await openai.images.generate(requestParams);
1064
+ recordCost(calculateImageCost(modelName, response.data?.length || n, config));
876
1065
  // Format response based on number of images
877
1066
  if (n === 1) {
878
1067
  const imageData = response.data[0];
@@ -970,6 +1159,7 @@ const generateImageWithGoogle = async (modelName, config, prompt, options) => {
970
1159
  safetySettings: safetySettings,
971
1160
  },
972
1161
  };
1162
+ assertQuotaAvailable();
973
1163
  const response = await ai.models.generateContent(req);
974
1164
  // Extract images from response
975
1165
  const images = [];
@@ -988,6 +1178,7 @@ const generateImageWithGoogle = async (modelName, config, prompt, options) => {
988
1178
  if (images.length === 0) {
989
1179
  throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, `No images were generated by ${modelName}`);
990
1180
  }
1181
+ recordCost(calculateImageCost(modelName, images.length, config));
991
1182
  if (numberOfImages === 1 || images.length === 1) {
992
1183
  return images[0];
993
1184
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "restricted"
5
5
  },
6
- "version": "1.0.19",
6
+ "version": "1.0.21",
7
7
  "description": "",
8
8
  "main": "dist/cjs/index.js",
9
9
  "module": "dist/esm/index.js",