@stackfactor/agent-utils 1.0.0
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/const.d.ts +15 -0
- package/dist/cjs/const.d.ts.map +1 -0
- package/dist/cjs/const.js +15 -0
- package/dist/cjs/errorHandling.d.ts +20 -0
- package/dist/cjs/errorHandling.d.ts.map +1 -0
- package/dist/cjs/errorHandling.js +23 -0
- package/dist/cjs/index.d.ts +9 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +14 -0
- package/dist/cjs/langChain.d.ts +10 -0
- package/dist/cjs/langChain.d.ts.map +1 -0
- package/dist/cjs/langChain.js +1241 -0
- package/dist/cjs/logger.d.ts +29 -0
- package/dist/cjs/logger.d.ts.map +1 -0
- package/dist/cjs/logger.js +77 -0
- package/dist/cjs/package.json +1 -0
- package/dist/esm/const.d.ts +15 -0
- package/dist/esm/const.d.ts.map +1 -0
- package/dist/esm/const.js +13 -0
- package/dist/esm/errorHandling.d.ts +20 -0
- package/dist/esm/errorHandling.d.ts.map +1 -0
- package/dist/esm/errorHandling.js +21 -0
- package/dist/esm/index.d.ts +9 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +8 -0
- package/dist/esm/langChain.d.ts +10 -0
- package/dist/esm/langChain.d.ts.map +1 -0
- package/dist/esm/langChain.js +1236 -0
- package/dist/esm/logger.d.ts +29 -0
- package/dist/esm/logger.d.ts.map +1 -0
- package/dist/esm/logger.js +72 -0
- package/dist/esm/package.json +1 -0
- package/package.json +57 -0
|
@@ -0,0 +1,1241 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const openai_1 = require("@langchain/openai");
|
|
7
|
+
const anthropic_1 = require("@langchain/anthropic");
|
|
8
|
+
const google_genai_1 = require("@langchain/google-genai");
|
|
9
|
+
const openai_2 = require("openai");
|
|
10
|
+
const genai_1 = require("@google/genai");
|
|
11
|
+
const const_js_1 = __importDefault(require("./const.js"));
|
|
12
|
+
const langchain_1 = require("langchain");
|
|
13
|
+
const errorHandling_js_1 = __importDefault(require("./errorHandling.js"));
|
|
14
|
+
const logger_js_1 = __importDefault(require("./logger.js"));
|
|
15
|
+
const zod_1 = require("zod");
|
|
16
|
+
const zod_to_json_schema_1 = require("zod-to-json-schema");
|
|
17
|
+
const NDJSON_SYSTEM_PROMPT = `
|
|
18
|
+
You are a streaming JSON generator.
|
|
19
|
+
|
|
20
|
+
You MUST output NDJSON (newline-delimited JSON).
|
|
21
|
+
That means: ONE valid JSON object per line.
|
|
22
|
+
|
|
23
|
+
Allowed event types:
|
|
24
|
+
- "progress": short status update for the user
|
|
25
|
+
- "final": final complete result
|
|
26
|
+
|
|
27
|
+
Rules:
|
|
28
|
+
- Output ONLY NDJSON lines (no markdown, no extra text).
|
|
29
|
+
- Each line must be valid JSON.
|
|
30
|
+
- "progress" must include: type, message, pct (with values between {minPercent} and {maxPercent})
|
|
31
|
+
- "final" must include: type, content
|
|
32
|
+
- End with EXACTLY ONE "final" event.
|
|
33
|
+
- Do NOT reveal hidden chain-of-thought.
|
|
34
|
+
|
|
35
|
+
CRITICAL - Escape special characters in ALL string values:
|
|
36
|
+
- Newlines must be written as \\n (two characters: backslash + n)
|
|
37
|
+
- Tabs must be written as \\t (two characters: backslash + t)
|
|
38
|
+
- Carriage returns must be written as \\r (two characters: backslash + r)
|
|
39
|
+
- Double quotes inside strings must be written as \\"
|
|
40
|
+
- Backslashes must be written as \\\\
|
|
41
|
+
This ensures valid JSON output.
|
|
42
|
+
`.trim();
|
|
43
|
+
const JSON_ESCAPE_INSTRUCTION = `
|
|
44
|
+
CRITICAL - Your response must be valid JSON. Escape ALL special characters in string values:
|
|
45
|
+
- Newlines → \\n
|
|
46
|
+
- Tabs → \\t
|
|
47
|
+
- Carriage returns → \\r
|
|
48
|
+
- Double quotes inside strings → \\"
|
|
49
|
+
- Backslashes → \\\\
|
|
50
|
+
Do NOT include raw newlines, tabs, or unescaped quotes inside JSON string values.
|
|
51
|
+
`.trim();
|
|
52
|
+
const REPORT_PROGRESS_TOOL = `
|
|
53
|
+
---
|
|
54
|
+
### Report Progress to User
|
|
55
|
+
BEFORE EACH task or step CALL the tool report_progress with:
|
|
56
|
+
message: brief description of work you plan to do that makes sense for individuals in Learning and Development or Business Strategy roles. Don't include JSON word in any of the messages.
|
|
57
|
+
percent: cumulative completion percentage with values between {minPercent}% and {maxPercent}%. Don't report percentage completion outside of this range.
|
|
58
|
+
`;
|
|
59
|
+
/**
|
|
60
|
+
* Converts a Zod validation error object into a human-readable multi-line string.
|
|
61
|
+
* Each failing field is described with its dot-notation path and a contextual message
|
|
62
|
+
* that depends on the Zod error code (e.g. `invalid_type`, `too_small`, `invalid_enum_value`).
|
|
63
|
+
* The output is prefixed with `"Schema validation failed:"` followed by a bulleted list.
|
|
64
|
+
* @param zodError - The Zod `ZodError` instance whose `errors` array will be formatted
|
|
65
|
+
* @returns A formatted string describing all validation failures
|
|
66
|
+
*/
|
|
67
|
+
const formatZodErrors = (zodError) => {
|
|
68
|
+
const errors = zodError.errors.map((err) => {
|
|
69
|
+
const path = err.path.length > 0 ? err.path.join(".") : "root";
|
|
70
|
+
// Provide more context based on error type
|
|
71
|
+
switch (err.code) {
|
|
72
|
+
case "invalid_type":
|
|
73
|
+
return `Field "${path}": Expected ${err.expected}, but received ${err.received}`;
|
|
74
|
+
case "invalid_literal":
|
|
75
|
+
return `Field "${path}": Expected literal value ${JSON.stringify(err.expected)}, but received ${JSON.stringify(err.received)}`;
|
|
76
|
+
case "unrecognized_keys":
|
|
77
|
+
return `Field "${path}": Unrecognized keys: ${err.keys.join(", ")}`;
|
|
78
|
+
case "invalid_union":
|
|
79
|
+
return `Field "${path}": Invalid union - none of the expected types matched`;
|
|
80
|
+
case "invalid_enum_value":
|
|
81
|
+
return `Field "${path}": Invalid enum value. Expected one of: ${err.options.join(", ")}`;
|
|
82
|
+
case "invalid_string":
|
|
83
|
+
return `Field "${path}": Invalid string format (${err.validation})`;
|
|
84
|
+
case "too_small":
|
|
85
|
+
return `Field "${path}": Value is too small (minimum: ${err.minimum})`;
|
|
86
|
+
case "too_big":
|
|
87
|
+
return `Field "${path}": Value is too large (maximum: ${err.maximum})`;
|
|
88
|
+
default:
|
|
89
|
+
return `Field "${path}": ${err.message}`;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
return `Schema validation failed:\n - ${errors.join("\n - ")}`;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Validates a parsed JavaScript value against a Zod schema using `safeParse`. On
|
|
96
|
+
* success the validated (and potentially transformed) data is returned. On failure a
|
|
97
|
+
* formatted error is thrown with HTTP status `UNPROCESSABLE_ENTITY` and a message
|
|
98
|
+
* produced by `formatZodErrors`.
|
|
99
|
+
* @param data - The parsed value to validate (typically the result of `JSON.parse`)
|
|
100
|
+
* @param schema - A Zod schema object that exposes a `safeParse` method
|
|
101
|
+
* @returns The validated data as returned by Zod's `safeParse` result
|
|
102
|
+
*/
|
|
103
|
+
const validateWithSchema = (data, schema) => {
|
|
104
|
+
const result = schema.safeParse(data);
|
|
105
|
+
if (!result.success) {
|
|
106
|
+
const formattedErrors = formatZodErrors(result.error);
|
|
107
|
+
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.UNPROCESSABLE_ENTITY, formattedErrors);
|
|
108
|
+
}
|
|
109
|
+
return result.data;
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* Determines whether a string looks like an HTML document rather than a JSON or plain-
|
|
113
|
+
* text response. Checks for common HTML start patterns such as `<!DOCTYPE`, `<html`,
|
|
114
|
+
* `<head`, `<body`, and `<?xml`. This is used to detect cases where an LLM API returns
|
|
115
|
+
* an error page or redirect instead of the expected content.
|
|
116
|
+
* @param text - The string to inspect
|
|
117
|
+
* @returns `true` if the string appears to be an HTML document, `false` otherwise
|
|
118
|
+
*/
|
|
119
|
+
const isHTMLResponse = (text) => {
|
|
120
|
+
if (!text || typeof text !== "string")
|
|
121
|
+
return false;
|
|
122
|
+
const trimmed = text.trim();
|
|
123
|
+
// Check for common HTML indicators
|
|
124
|
+
return (trimmed.startsWith("<!DOCTYPE") ||
|
|
125
|
+
trimmed.startsWith("<html") ||
|
|
126
|
+
trimmed.startsWith("<HTML") ||
|
|
127
|
+
trimmed.startsWith("<a ") ||
|
|
128
|
+
trimmed.startsWith("<div") ||
|
|
129
|
+
trimmed.startsWith("<p>") ||
|
|
130
|
+
trimmed.startsWith("<head") ||
|
|
131
|
+
trimmed.startsWith("<body") ||
|
|
132
|
+
trimmed.startsWith("<?xml") ||
|
|
133
|
+
/<html[\s>]/i.test(trimmed) ||
|
|
134
|
+
/<head[\s>]/i.test(trimmed) ||
|
|
135
|
+
/<body[\s>]/i.test(trimmed));
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* Attempts to extract and parse a JSON value from a raw LLM response string that may
|
|
139
|
+
* include markdown code fences, invalid escape sequences, control characters, or other
|
|
140
|
+
* extraneous formatting. Falls back through multiple strategies in order:
|
|
141
|
+
* 1. Direct parse after stripping fences and fixing `\'` escapes
|
|
142
|
+
* 2. Extract and parse the first `{...}` object block
|
|
143
|
+
* 3. Same as above after sanitising control characters and normalising line endings
|
|
144
|
+
* 4. Extract and parse the first `[...]` array block (with the same sanitisation)
|
|
145
|
+
* 5. Strip all non-printable characters and retry both object and array extraction
|
|
146
|
+
*
|
|
147
|
+
* Returns `null` if every strategy fails. Throws an error with `BAD_GATEWAY` if the
|
|
148
|
+
* response appears to be an HTML page (likely an API error or rate-limit redirect).
|
|
149
|
+
* @param text - The raw response string from the LLM
|
|
150
|
+
* @returns The parsed JavaScript value, or `null` if no JSON could be extracted
|
|
151
|
+
*/
|
|
152
|
+
const extractJSONFromResponse = (text) => {
|
|
153
|
+
if (!text || typeof text !== "string")
|
|
154
|
+
return null;
|
|
155
|
+
// Check for HTML responses (usually API errors or redirects)
|
|
156
|
+
if (isHTMLResponse(text)) {
|
|
157
|
+
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.BAD_GATEWAY, "LLM API returned HTML instead of JSON. This may indicate an API error, rate limit, or network issue. Please retry.");
|
|
158
|
+
}
|
|
159
|
+
// Remove markdown code blocks and fix invalid escape sequences
|
|
160
|
+
const cleaned = text
|
|
161
|
+
.replace(/```json\s*/gi, "")
|
|
162
|
+
.replace(/```\s*/g, "")
|
|
163
|
+
.replace(/\\'/g, "'") // Fix invalid \' escape (not valid in JSON)
|
|
164
|
+
.trim();
|
|
165
|
+
// Try parsing directly first
|
|
166
|
+
try {
|
|
167
|
+
return JSON.parse(cleaned);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// Continue to fallback methods
|
|
171
|
+
}
|
|
172
|
+
// Try to find JSON object boundaries
|
|
173
|
+
const jsonMatch = cleaned.match(/\{[\s\S]*\}/);
|
|
174
|
+
if (jsonMatch) {
|
|
175
|
+
try {
|
|
176
|
+
return JSON.parse(jsonMatch[0]);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// Try cleaning special characters
|
|
180
|
+
try {
|
|
181
|
+
const sanitized = jsonMatch[0]
|
|
182
|
+
.replace(/[\u0000-\u001F\u007F-\u009F]/g, " ") // Remove control characters
|
|
183
|
+
.replace(/\r\n/g, "\\n") // Normalize line endings
|
|
184
|
+
.replace(/\r/g, "\\n")
|
|
185
|
+
.replace(/\t/g, "\\t"); // Escape tabs
|
|
186
|
+
return JSON.parse(sanitized);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Continue to next fallback
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Try to find JSON array boundaries
|
|
194
|
+
const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
|
|
195
|
+
if (arrayMatch) {
|
|
196
|
+
try {
|
|
197
|
+
return JSON.parse(arrayMatch[0]);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
try {
|
|
201
|
+
const sanitized = arrayMatch[0]
|
|
202
|
+
.replace(/[\u0000-\u001F\u007F-\u009F]/g, " ")
|
|
203
|
+
.replace(/\r\n/g, "\\n")
|
|
204
|
+
.replace(/\r/g, "\\n")
|
|
205
|
+
.replace(/\t/g, "\\t");
|
|
206
|
+
return JSON.parse(sanitized);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// Continue to next fallback
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Last resort: strip all non-printable except standard whitespace
|
|
214
|
+
try {
|
|
215
|
+
const stripped = cleaned.replace(/[^\x20-\x7E\n]/g, " ");
|
|
216
|
+
const finalMatch = stripped.match(/\{[\s\S]*\}/) || stripped.match(/\[[\s\S]*\]/);
|
|
217
|
+
if (finalMatch) {
|
|
218
|
+
return JSON.parse(finalMatch[0]);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// All attempts failed
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
};
|
|
226
|
+
/**
|
|
227
|
+
* Attempts to parse a JSON string without throwing. On failure it tries a second parse
|
|
228
|
+
* after sanitising Unicode control characters and normalising Windows-style line endings
|
|
229
|
+
* to `\n`. Returns `null` if both attempts fail, making it safe to call in stream
|
|
230
|
+
* processing loops where individual NDJSON lines may be malformed.
|
|
231
|
+
* @param line - A single line of text expected to contain a JSON value
|
|
232
|
+
* @returns The parsed value, or `null` if parsing failed after both attempts
|
|
233
|
+
*/
|
|
234
|
+
const safeJsonParse = (line) => {
|
|
235
|
+
try {
|
|
236
|
+
return JSON.parse(line);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Try with special character handling
|
|
240
|
+
try {
|
|
241
|
+
const sanitized = line
|
|
242
|
+
.replace(/[\u0000-\u001F\u007F-\u009F]/g, " ")
|
|
243
|
+
.replace(/\r\n/g, "\\n")
|
|
244
|
+
.replace(/\r/g, "\\n");
|
|
245
|
+
return JSON.parse(sanitized);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
/**
|
|
253
|
+
* Creates a LangChain tool named `report_progress` that agents can call to emit
|
|
254
|
+
* progress updates during multi-step execution. When the tool is invoked by the agent,
|
|
255
|
+
* it clamps the reported percentage to the `[minPercent, maxPercent]` range, forwards
|
|
256
|
+
* the update to the `onProgress` callback, and returns a JSON acknowledgement string to
|
|
257
|
+
* the agent. If the callback throws, the tool returns a JSON error acknowledgement
|
|
258
|
+
* instead of propagating the exception.
|
|
259
|
+
* @param onProgress - Async or sync callback invoked with `{ progress, message }` on
|
|
260
|
+
* each agent progress report
|
|
261
|
+
* @param minPercent - The lower bound of the percentage range the agent is allowed to
|
|
262
|
+
* report; defaults to `0`
|
|
263
|
+
* @param maxPercent - The upper bound of the percentage range the agent is allowed to
|
|
264
|
+
* report; defaults to `100`
|
|
265
|
+
* @returns A LangChain tool instance configured with a Zod schema for `{ stage, message, percent }`
|
|
266
|
+
*/
|
|
267
|
+
const getAIProgressTool = (onProgress, minPercent = 0, maxPercent = 100) => langchain_1.tool(async ({ stage, message, percent }) => {
|
|
268
|
+
try {
|
|
269
|
+
// Clamp percent to be within minPercent and maxPercent bounds
|
|
270
|
+
const rawPct = typeof percent === "number" ? percent : 0;
|
|
271
|
+
const clampedPct = Math.max(minPercent, Math.min(maxPercent, rawPct));
|
|
272
|
+
if (onProgress) {
|
|
273
|
+
await onProgress({
|
|
274
|
+
progress: clampedPct,
|
|
275
|
+
message: message,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return JSON.stringify({
|
|
279
|
+
acknowledged: true,
|
|
280
|
+
receivedAt: Date.now(),
|
|
281
|
+
stage,
|
|
282
|
+
percent: clampedPct,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
return JSON.stringify({
|
|
287
|
+
acknowledged: false,
|
|
288
|
+
error: err?.message || String(err),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}, {
|
|
292
|
+
name: "report_progress",
|
|
293
|
+
description: `Mandatory after each phase: call with {stage, message, percent}. Percent must be between ${minPercent} and ${maxPercent} and monotonically increase.`,
|
|
294
|
+
schema: zod_1.z.object({
|
|
295
|
+
stage: zod_1.z.string().describe("Current stage of the process"),
|
|
296
|
+
message: zod_1.z.string().describe("Progress message to display"),
|
|
297
|
+
percent: zod_1.z
|
|
298
|
+
.number()
|
|
299
|
+
.min(minPercent)
|
|
300
|
+
.max(maxPercent)
|
|
301
|
+
.describe(`Progress percentage (${minPercent}-${maxPercent})`),
|
|
302
|
+
}),
|
|
303
|
+
});
|
|
304
|
+
/**
|
|
305
|
+
* Instantiates and returns the appropriate LangChain chat model based on the model
|
|
306
|
+
* name prefix. `claude-` maps to `ChatAnthropic`, `gemini-` maps to
|
|
307
|
+
* `ChatGoogleGenerativeAI`, and `gpt-` maps to `ChatOpenAI`. When a Zod `schema` is
|
|
308
|
+
* provided for a GPT model, native `response_format` with `json_schema` is configured
|
|
309
|
+
* on the OpenAI instance for structured output. Throws a `BAD_REQUEST` error for
|
|
310
|
+
* unrecognised model names.
|
|
311
|
+
* @param modelName - The model identifier, e.g. `"gpt-4o"`, `"claude-3-5-sonnet"`,
|
|
312
|
+
* `"gemini-1.5-pro"`
|
|
313
|
+
* @param config - Configuration object containing API keys (`openAIAPIKey`,
|
|
314
|
+
* `anthropicAPIKey`, `googleAPIKey`), optional `maxTokens`, and optional `temperature`
|
|
315
|
+
* @param schema - Optional Zod schema used to configure structured JSON output for
|
|
316
|
+
* OpenAI GPT models via `response_format`; ignored for other providers
|
|
317
|
+
* @returns A configured LangChain chat model instance
|
|
318
|
+
*/
|
|
319
|
+
const getLLMModel = (modelName, config, schema = null) => {
|
|
320
|
+
const modelSettings = {
|
|
321
|
+
...(config.temperature ? { temperature: config.temperature } : {}),
|
|
322
|
+
};
|
|
323
|
+
// Claude models (Anthropic)
|
|
324
|
+
if (modelName.startsWith("claude-")) {
|
|
325
|
+
return new anthropic_1.ChatAnthropic({
|
|
326
|
+
apiKey: config.anthropicAPIKey,
|
|
327
|
+
maxTokens: config.maxTokens || 200000,
|
|
328
|
+
modelName: modelName,
|
|
329
|
+
...modelSettings,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
// Gemini models (Google)
|
|
333
|
+
else if (modelName.startsWith("gemini-")) {
|
|
334
|
+
return new google_genai_1.ChatGoogleGenerativeAI({
|
|
335
|
+
apiKey: config.googleAPIKey,
|
|
336
|
+
maxOutputTokens: config.maxTokens || 200000,
|
|
337
|
+
model: modelName,
|
|
338
|
+
...modelSettings,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
// GPT models (OpenAI)
|
|
342
|
+
else if (modelName.startsWith("gpt-")) {
|
|
343
|
+
const openAISettings = {
|
|
344
|
+
apiKey: config.openAIAPIKey,
|
|
345
|
+
max_tokens: config.maxTokens || 200000,
|
|
346
|
+
modelName: modelName,
|
|
347
|
+
...modelSettings,
|
|
348
|
+
};
|
|
349
|
+
// Use native response_format with JSON schema for structured output
|
|
350
|
+
if (schema) {
|
|
351
|
+
const jsonSchema = (0, zod_to_json_schema_1.zodToJsonSchema)(schema, { target: "openApi3" });
|
|
352
|
+
openAISettings.modelKwargs = {
|
|
353
|
+
response_format: {
|
|
354
|
+
type: "json_schema",
|
|
355
|
+
json_schema: {
|
|
356
|
+
name: "response_schema",
|
|
357
|
+
strict: true,
|
|
358
|
+
schema: jsonSchema,
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
return new openai_1.ChatOpenAI(openAISettings);
|
|
364
|
+
}
|
|
365
|
+
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.BAD_REQUEST, const_js_1.default.ERROR.UNSUPPORTED_MODEL + ": " + modelName);
|
|
366
|
+
};
|
|
367
|
+
/**
|
|
368
|
+
* Constructs a LangChain agent configured with a specified model, system prompt, and
|
|
369
|
+
* set of tools. When an `onReportProgress` callback is provided, the NDJSON progress-
|
|
370
|
+
* reporting instruction block is appended to the system prompt and a `report_progress`
|
|
371
|
+
* tool is added to the tools array so the agent can emit incremental progress updates
|
|
372
|
+
* during execution. The percentage range for progress reporting is bounded by
|
|
373
|
+
* `minPercent` and `maxPercent`.
|
|
374
|
+
* @param name - A human-readable display name for the agent
|
|
375
|
+
* @param modelName - The LLM identifier passed to `getLLMModel` (e.g. `"gpt-4o"`)
|
|
376
|
+
* @param systemPrompt - The base system prompt describing the agent's role and behaviour
|
|
377
|
+
* @param tools - Array of LangChain tool instances the agent may invoke; defaults to
|
|
378
|
+
* an empty array
|
|
379
|
+
* @param responseFormat - Optional structured response format descriptor passed to the
|
|
380
|
+
* LangChain agent constructor
|
|
381
|
+
* @param config - Configuration object forwarded to `getLLMModel` (API keys, temperature, etc.)
|
|
382
|
+
* @param onReportProgress - Optional callback for progress updates; when provided the
|
|
383
|
+
* progress tool is added and the system prompt is extended
|
|
384
|
+
* @param minPercent - Minimum progress percentage the agent is allowed to report;
|
|
385
|
+
* defaults to `0`
|
|
386
|
+
* @param maxPercent - Maximum progress percentage the agent is allowed to report;
|
|
387
|
+
* defaults to `100`
|
|
388
|
+
* @returns A configured LangChain agent instance ready to be run with `runAIAgent`
|
|
389
|
+
*/
|
|
390
|
+
const createAIAgent = (name, modelName, systemPrompt, tools = [], responseFormat, config, onReportProgress = null, minPercent = 0, maxPercent = 100) => {
|
|
391
|
+
//Prepare the complete system prompt with progress reporting instructions if needed
|
|
392
|
+
const completeSystemPrompt = `
|
|
393
|
+
${systemPrompt}
|
|
394
|
+
${onReportProgress
|
|
395
|
+
? REPORT_PROGRESS_TOOL.replace(`{minPercent}`, minPercent).replace(`{maxPercent}`, maxPercent)
|
|
396
|
+
: ""}
|
|
397
|
+
`.trim();
|
|
398
|
+
// Create the agent with the specified model, system prompt, tools, and response format
|
|
399
|
+
const agent = (0, langchain_1.createAgent)({
|
|
400
|
+
name: name,
|
|
401
|
+
model: getLLMModel(modelName, config),
|
|
402
|
+
systemPrompt: completeSystemPrompt,
|
|
403
|
+
tools: onReportProgress
|
|
404
|
+
? [...tools, getAIProgressTool(onReportProgress, minPercent, maxPercent)]
|
|
405
|
+
: tools,
|
|
406
|
+
...(responseFormat ? { responseFormat: responseFormat } : {}),
|
|
407
|
+
});
|
|
408
|
+
return agent;
|
|
409
|
+
};
|
|
410
|
+
/**
|
|
411
|
+
* Executes a LangChain agent with a single user prompt and returns the raw agent
|
|
412
|
+
* response. When an `onProgress` callback is provided, LangChain callbacks are
|
|
413
|
+
* registered to forward `tool_start`, `tool_end` (including structured progress data
|
|
414
|
+
* from the `report_progress` tool), `agent_action`, and error events to the caller.
|
|
415
|
+
* Execution time is logged at the info level on completion.
|
|
416
|
+
* @param agent - A LangChain agent instance created by `createAIAgent`
|
|
417
|
+
* @param prompt - The user message string to send to the agent
|
|
418
|
+
* @param config - Configuration object; `config.recursionLimit` controls the maximum
|
|
419
|
+
* number of agent steps (defaults to `25` when not specified)
|
|
420
|
+
* @param onProgress - Optional callback invoked with progress event objects throughout
|
|
421
|
+
* agent execution
|
|
422
|
+
* @returns The raw response object returned by the agent's `invoke` method
|
|
423
|
+
*/
|
|
424
|
+
const runAIAgent = async (agent, prompt, config, onProgress = null) => {
|
|
425
|
+
const startTime = Date.now();
|
|
426
|
+
// Build callbacks for progress reporting if onProgress is provided
|
|
427
|
+
// const agentDisplayName = `${agent.options?.name} AI Agent`;
|
|
428
|
+
const callbacks = onProgress
|
|
429
|
+
? [
|
|
430
|
+
{
|
|
431
|
+
handleToolStart: (toolInfo) => {
|
|
432
|
+
if (toolInfo.name)
|
|
433
|
+
onProgress({
|
|
434
|
+
type: "tool_start",
|
|
435
|
+
message: `Using "${toolInfo.name}" tool`,
|
|
436
|
+
tool: toolInfo.name,
|
|
437
|
+
});
|
|
438
|
+
},
|
|
439
|
+
handleToolEnd: (output) => {
|
|
440
|
+
try {
|
|
441
|
+
const progressInfo = extractJSONFromResponse(output.content);
|
|
442
|
+
if (progressInfo &&
|
|
443
|
+
typeof progressInfo === "object" &&
|
|
444
|
+
progressInfo.percent &&
|
|
445
|
+
progressInfo.message) {
|
|
446
|
+
onProgress({
|
|
447
|
+
progress: progressInfo.percent,
|
|
448
|
+
message: progressInfo.message,
|
|
449
|
+
output: typeof output === "string"
|
|
450
|
+
? output
|
|
451
|
+
: JSON.stringify(output),
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
catch { }
|
|
456
|
+
},
|
|
457
|
+
handleAgentAction: (action) => {
|
|
458
|
+
onProgress({
|
|
459
|
+
type: "agent_action",
|
|
460
|
+
message: action.log || `Calling "${action.tool}" action`,
|
|
461
|
+
tool: action.tool,
|
|
462
|
+
input: action.toolInput,
|
|
463
|
+
});
|
|
464
|
+
},
|
|
465
|
+
// handleLLMStart: () => {
|
|
466
|
+
// onProgress({
|
|
467
|
+
// type: "llm_start",
|
|
468
|
+
// message: `${agentDisplayName} is thinking`,
|
|
469
|
+
// });
|
|
470
|
+
// },
|
|
471
|
+
// handleLLMEnd: () => {
|
|
472
|
+
// onProgress({
|
|
473
|
+
// type: "llm_end",
|
|
474
|
+
// message: `${agentDisplayName} has completed processing`,
|
|
475
|
+
// });
|
|
476
|
+
// },
|
|
477
|
+
handleChainError: (err) => {
|
|
478
|
+
onProgress({
|
|
479
|
+
type: "error",
|
|
480
|
+
message: `Error: ${err?.message || String(err)}`,
|
|
481
|
+
});
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
]
|
|
485
|
+
: undefined;
|
|
486
|
+
const response = await agent.invoke({
|
|
487
|
+
messages: [{ role: "user", content: prompt }],
|
|
488
|
+
}, {
|
|
489
|
+
recursionLimit: config.recursionLimit || 25,
|
|
490
|
+
...(callbacks ? { callbacks } : {}),
|
|
491
|
+
});
|
|
492
|
+
const endTime = Date.now();
|
|
493
|
+
const duration = endTime - startTime;
|
|
494
|
+
logger_js_1.default.log(null, logger_js_1.default.levels.info, `Agent "${agent.options?.name}" completed in ${Math.round(duration / 1000)} seconds.`);
|
|
495
|
+
return response;
|
|
496
|
+
};
|
|
497
|
+
/**
|
|
498
|
+
* Guards that a response value is a non-empty string. If the response is a string it is
|
|
499
|
+
* returned unchanged. If not, a formatted error with `INTERNAL_SERVER_ERROR` and the
|
|
500
|
+
* `UNABLE_TO_GENERATE_CONTENT` message is thrown.
|
|
501
|
+
* @param response - The value to check, typically the raw content returned by an LLM
|
|
502
|
+
* @returns The response string when it is valid
|
|
503
|
+
*/
|
|
504
|
+
const throwErrorIfNotSuccessful = (response) => {
|
|
505
|
+
if (response && typeof response === "string") {
|
|
506
|
+
return response;
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, const_js_1.default.ERROR.UNABLE_TO_GENERATE_CONTENT);
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
/**
|
|
513
|
+
* Sends a prompt to an LLM and returns the response, with support for streaming,
|
|
514
|
+
* agentic execution, progress reporting, JSON extraction, and Zod schema validation.
|
|
515
|
+
* The function operates in one of three modes depending on configuration:
|
|
516
|
+
*
|
|
517
|
+
* - **Agentic** (`config.agentic === true`): Creates a LangChain agent via
|
|
518
|
+
* `createAIAgent`, runs it with `runAIAgent`, then extracts and validates the final
|
|
519
|
+
* JSON from the last message in the agent's response.
|
|
520
|
+
* - **Streaming with progress** (`onProgressReport` provided, non-agentic): Uses the
|
|
521
|
+
* LLM's stream API. For OpenAI GPT models with a schema, progress is reported based
|
|
522
|
+
* on output character count. For other models, the response is expected in NDJSON
|
|
523
|
+
* format with `progress` and `final` event lines.
|
|
524
|
+
* - **Non-streaming** (no `onProgressReport`, non-agentic): Directly invokes the LLM
|
|
525
|
+
* and extracts JSON from the response.
|
|
526
|
+
*
|
|
527
|
+
* When `expectsJsonResponse` is `true`, JSON escape instructions are prepended to the
|
|
528
|
+
* system prompt and the parsed result is optionally validated against `schema`.
|
|
529
|
+
* @param modelName - The model identifier, e.g. `"gpt-4o"`, `"claude-3-5-sonnet"`,
|
|
530
|
+
* `"gemini-1.5-pro"`
|
|
531
|
+
* @param config - Configuration object with API keys, `temperature`, optional `agentic`
|
|
532
|
+
* flag, and optional `recursionLimit`
|
|
533
|
+
* @param prompt - The prompt to send; either a plain string (user message only) or an
|
|
534
|
+
* array of `{ role, content }` message objects
|
|
535
|
+
* @param onProgressReport - Optional async callback invoked with `{ message, progress }`
|
|
536
|
+
* objects throughout execution; enables streaming mode when provided
|
|
537
|
+
* @param minPercent - Minimum progress percentage to report; defaults to `0`
|
|
538
|
+
* @param maxPercent - Maximum progress percentage to report; defaults to `100`
|
|
539
|
+
* @param expectsJsonResponse - When `true` (the default), JSON escape instructions are
|
|
540
|
+
* injected into the system prompt and the response is parsed as JSON
|
|
541
|
+
* @param schema - Optional Zod schema; when provided the parsed JSON response is
|
|
542
|
+
* validated and an `UNPROCESSABLE_ENTITY` error is thrown on mismatch
|
|
543
|
+
* @param agentName - Display name for the agent in agentic mode; defaults to
|
|
544
|
+
* `"StackFactor"`
|
|
545
|
+
* @param tools - Additional LangChain tools available to the agent in agentic mode;
|
|
546
|
+
* defaults to an empty array
|
|
547
|
+
* @returns The LLM response as a JSON string (when `expectsJsonResponse` is `true`) or
|
|
548
|
+
* as raw content (when `false`)
|
|
549
|
+
*/
|
|
550
|
+
const runPromptWithModel = async (modelName, config, prompt, onProgressReport, minPercent = 0, maxPercent = 100, expectsJsonResponse = true, schema = null, agentName = "StackFactor", tools = []) => {
|
|
551
|
+
// Agentic mode: use an agent with tools instead of simple LLM invocation
|
|
552
|
+
if (config.agentic === true) {
|
|
553
|
+
// Extract system prompt and user prompt from the prompt parameter
|
|
554
|
+
let systemPrompt = "";
|
|
555
|
+
let userPrompt = "";
|
|
556
|
+
if (typeof prompt === "string") {
|
|
557
|
+
userPrompt = prompt;
|
|
558
|
+
}
|
|
559
|
+
else if (Array.isArray(prompt)) {
|
|
560
|
+
// Extract system message if present
|
|
561
|
+
const systemMessage = prompt.find((msg) => msg.role === "system");
|
|
562
|
+
if (systemMessage) {
|
|
563
|
+
systemPrompt = systemMessage.content;
|
|
564
|
+
}
|
|
565
|
+
// Extract and combine user messages
|
|
566
|
+
userPrompt = prompt
|
|
567
|
+
.filter((msg) => msg.role === "user")
|
|
568
|
+
.map((msg) => msg.content)
|
|
569
|
+
.join("\n\n");
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
throw new Error("Prompt must be a string or array of messages");
|
|
573
|
+
}
|
|
574
|
+
// Inject JSON formatting instructions when JSON response is expected
|
|
575
|
+
if (expectsJsonResponse) {
|
|
576
|
+
systemPrompt = systemPrompt
|
|
577
|
+
? `${JSON_ESCAPE_INSTRUCTION}\n\n---\n\n${systemPrompt}`
|
|
578
|
+
: JSON_ESCAPE_INSTRUCTION;
|
|
579
|
+
}
|
|
580
|
+
// Build tools array, adding progress reporting tool if callback provided
|
|
581
|
+
// Create a shared progress tracker for both tool and callbacks
|
|
582
|
+
const progressTracker = { current: minPercent };
|
|
583
|
+
const agentTools = [...tools];
|
|
584
|
+
if (onProgressReport) {
|
|
585
|
+
// Wrap the progress callback to update the tracker
|
|
586
|
+
const trackingProgressCallback = (data) => {
|
|
587
|
+
if (typeof data.progress === "number") {
|
|
588
|
+
progressTracker.current = data.progress;
|
|
589
|
+
}
|
|
590
|
+
onProgressReport(data);
|
|
591
|
+
};
|
|
592
|
+
agentTools.push(getAIProgressTool(trackingProgressCallback, minPercent, maxPercent));
|
|
593
|
+
}
|
|
594
|
+
// Create the agent with tools
|
|
595
|
+
const agent = createAIAgent(agentName, modelName, systemPrompt, agentTools, null, // responseFormat
|
|
596
|
+
config, null, // onReportProgress handled via tools array
|
|
597
|
+
minPercent, maxPercent);
|
|
598
|
+
// Run the agent with callback that includes current progress
|
|
599
|
+
const callbackWithProgress = onProgressReport
|
|
600
|
+
? (data) => onProgressReport({ ...data, progress: progressTracker.current })
|
|
601
|
+
: null;
|
|
602
|
+
const response = await runAIAgent(agent, userPrompt, config, callbackWithProgress);
|
|
603
|
+
// Extract content from agent response
|
|
604
|
+
const messages = response?.messages || [];
|
|
605
|
+
if (messages.length === 0) {
|
|
606
|
+
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, "Agent returned no messages");
|
|
607
|
+
}
|
|
608
|
+
const lastMessage = messages[messages.length - 1];
|
|
609
|
+
const rawContent = lastMessage?.content || "";
|
|
610
|
+
// If not expecting JSON, return raw content directly
|
|
611
|
+
if (!expectsJsonResponse) {
|
|
612
|
+
return rawContent;
|
|
613
|
+
}
|
|
614
|
+
// Process JSON response similar to non-agentic path
|
|
615
|
+
if (typeof rawContent === "string") {
|
|
616
|
+
const trimmed = rawContent.trim();
|
|
617
|
+
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
|
618
|
+
(trimmed.startsWith("[") && trimmed.endsWith("]"))) {
|
|
619
|
+
try {
|
|
620
|
+
const parsed = JSON.parse(trimmed);
|
|
621
|
+
if (schema) {
|
|
622
|
+
validateWithSchema(parsed, schema);
|
|
623
|
+
}
|
|
624
|
+
return JSON.stringify(parsed);
|
|
625
|
+
}
|
|
626
|
+
catch (parseError) {
|
|
627
|
+
if (parseError?.code === const_js_1.default.HTTP_CODES.UNPROCESSABLE_ENTITY) {
|
|
628
|
+
throw parseError;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
// Try to extract JSON from markdown or other wrapping
|
|
633
|
+
const extracted = extractJSONFromResponse(rawContent);
|
|
634
|
+
if (extracted) {
|
|
635
|
+
if (schema) {
|
|
636
|
+
validateWithSchema(extracted, schema);
|
|
637
|
+
}
|
|
638
|
+
return JSON.stringify(extracted);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if (expectsJsonResponse) {
|
|
642
|
+
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, `Agent returned non-JSON response when JSON was expected: ${rawContent.substring(0, 100)}...`);
|
|
643
|
+
}
|
|
644
|
+
return rawContent;
|
|
645
|
+
}
|
|
646
|
+
let messages;
|
|
647
|
+
if (typeof prompt === "string") {
|
|
648
|
+
messages = [{ role: "user", content: prompt }];
|
|
649
|
+
}
|
|
650
|
+
else if (Array.isArray(prompt)) {
|
|
651
|
+
messages = prompt;
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
throw new Error("Prompt must be a string or array of messages");
|
|
655
|
+
}
|
|
656
|
+
if (onProgressReport) {
|
|
657
|
+
// Check if we can use native response_format (OpenAI with schema)
|
|
658
|
+
const useNativeStreamingSchema = expectsJsonResponse && schema && modelName.startsWith("gpt-");
|
|
659
|
+
if (useNativeStreamingSchema) {
|
|
660
|
+
// OpenAI with schema: use native response_format and stream with chunk-based progress
|
|
661
|
+
const llm = getLLMModel(modelName, config, schema);
|
|
662
|
+
const messagesToSend = messages.length > 0 && messages[0].role === "system"
|
|
663
|
+
? messages
|
|
664
|
+
: [
|
|
665
|
+
{ role: "system", content: "Respond with valid JSON." },
|
|
666
|
+
...messages,
|
|
667
|
+
];
|
|
668
|
+
let rawContent = "";
|
|
669
|
+
let lastProgressReport = 0;
|
|
670
|
+
const progressInterval = 1000; // Report progress every 1000 characters
|
|
671
|
+
const stream = await llm.stream(messagesToSend);
|
|
672
|
+
for await (const chunk of stream) {
|
|
673
|
+
const content = chunk?.content || chunk;
|
|
674
|
+
if (typeof content === "string") {
|
|
675
|
+
rawContent += content;
|
|
676
|
+
// Report progress based on content length
|
|
677
|
+
if (rawContent.length - lastProgressReport >= progressInterval) {
|
|
678
|
+
lastProgressReport = rawContent.length;
|
|
679
|
+
const progress = Math.min(maxPercent - 5, // Reserve last 5% for completion
|
|
680
|
+
minPercent +
|
|
681
|
+
Math.floor((rawContent.length / 5000) * (maxPercent - minPercent)));
|
|
682
|
+
await onProgressReport({
|
|
683
|
+
message: "Generating content...",
|
|
684
|
+
progress: progress,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
// Report completion
|
|
690
|
+
await onProgressReport({
|
|
691
|
+
message: "Processing complete",
|
|
692
|
+
progress: maxPercent,
|
|
693
|
+
});
|
|
694
|
+
// Parse and validate the response
|
|
695
|
+
if (rawContent) {
|
|
696
|
+
try {
|
|
697
|
+
const parsed = JSON.parse(rawContent.trim());
|
|
698
|
+
if (schema) {
|
|
699
|
+
validateWithSchema(parsed, schema);
|
|
700
|
+
}
|
|
701
|
+
return JSON.stringify(parsed);
|
|
702
|
+
}
|
|
703
|
+
catch (parseError) {
|
|
704
|
+
if (parseError?.code === const_js_1.default.HTTP_CODES.UNPROCESSABLE_ENTITY) {
|
|
705
|
+
throw parseError;
|
|
706
|
+
}
|
|
707
|
+
// Try to extract JSON
|
|
708
|
+
const extracted = extractJSONFromResponse(rawContent);
|
|
709
|
+
if (extracted) {
|
|
710
|
+
if (schema) {
|
|
711
|
+
validateWithSchema(extracted, schema);
|
|
712
|
+
}
|
|
713
|
+
return JSON.stringify(extracted);
|
|
714
|
+
}
|
|
715
|
+
if (expectsJsonResponse) {
|
|
716
|
+
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, `LLM returned non-JSON response when JSON was expected: ${rawContent.substring(0, 100)}...`);
|
|
717
|
+
}
|
|
718
|
+
return rawContent;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, "LLM returned no content");
|
|
722
|
+
}
|
|
723
|
+
// Non-OpenAI or no schema: use NDJSON streaming approach
|
|
724
|
+
const llm = getLLMModel(modelName, config);
|
|
725
|
+
let ndjsonSystemContent = NDJSON_SYSTEM_PROMPT.replace(`{minPercent}`, minPercent).replace(`{maxPercent}`, maxPercent);
|
|
726
|
+
// If schema is provided, include it in the system prompt
|
|
727
|
+
if (expectsJsonResponse && schema) {
|
|
728
|
+
const jsonSchema = (0, zod_to_json_schema_1.zodToJsonSchema)(schema, { target: "openApi3" });
|
|
729
|
+
ndjsonSystemContent += `\n\nThe "content" field in the "final" event MUST conform to this JSON schema:\n${JSON.stringify(jsonSchema, null, 2)}`;
|
|
730
|
+
}
|
|
731
|
+
let messagesWithNDJSON;
|
|
732
|
+
if (messages.length > 0 && messages[0].role === "system") {
|
|
733
|
+
messagesWithNDJSON = [
|
|
734
|
+
{
|
|
735
|
+
role: "system",
|
|
736
|
+
content: `${ndjsonSystemContent}\n\n---\n\n${messages[0].content}`,
|
|
737
|
+
},
|
|
738
|
+
...messages.slice(1),
|
|
739
|
+
];
|
|
740
|
+
}
|
|
741
|
+
else {
|
|
742
|
+
messagesWithNDJSON = [
|
|
743
|
+
{
|
|
744
|
+
role: "system",
|
|
745
|
+
content: ndjsonSystemContent,
|
|
746
|
+
},
|
|
747
|
+
...messages,
|
|
748
|
+
];
|
|
749
|
+
}
|
|
750
|
+
// Stream the response and parse NDJSON events
|
|
751
|
+
let buffer = "";
|
|
752
|
+
let finalContent = "";
|
|
753
|
+
let rawContent = "";
|
|
754
|
+
const stream = await llm.stream(messagesWithNDJSON);
|
|
755
|
+
for await (const chunk of stream) {
|
|
756
|
+
const content = chunk?.content || chunk;
|
|
757
|
+
if (typeof content === "string") {
|
|
758
|
+
buffer += content;
|
|
759
|
+
rawContent += content;
|
|
760
|
+
const lines = buffer.split("\n");
|
|
761
|
+
buffer = lines.pop() || "";
|
|
762
|
+
for (const line of lines) {
|
|
763
|
+
const trimmedLine = line.trim();
|
|
764
|
+
if (!trimmedLine)
|
|
765
|
+
continue;
|
|
766
|
+
const parsed = safeJsonParse(trimmedLine);
|
|
767
|
+
if (parsed) {
|
|
768
|
+
if (parsed.type === "progress") {
|
|
769
|
+
const clampedProgress = Math.max(minPercent, Math.min(maxPercent, parsed.pct));
|
|
770
|
+
await onProgressReport({
|
|
771
|
+
message: parsed.message,
|
|
772
|
+
progress: clampedProgress,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
else if (parsed.type === "final") {
|
|
776
|
+
if (parsed.content) {
|
|
777
|
+
finalContent = parsed.content;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (buffer.trim()) {
|
|
785
|
+
const parsed = safeJsonParse(buffer.trim());
|
|
786
|
+
if (parsed && parsed.type === "final" && parsed.content) {
|
|
787
|
+
finalContent = parsed.content;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (finalContent) {
|
|
791
|
+
if (typeof finalContent === "object") {
|
|
792
|
+
if (expectsJsonResponse && schema) {
|
|
793
|
+
validateWithSchema(finalContent, schema);
|
|
794
|
+
}
|
|
795
|
+
return JSON.stringify(finalContent);
|
|
796
|
+
}
|
|
797
|
+
if (typeof finalContent === "string") {
|
|
798
|
+
try {
|
|
799
|
+
const parsed = JSON.parse(finalContent);
|
|
800
|
+
if (expectsJsonResponse && schema) {
|
|
801
|
+
validateWithSchema(parsed, schema);
|
|
802
|
+
}
|
|
803
|
+
return JSON.stringify(parsed);
|
|
804
|
+
}
|
|
805
|
+
catch (parseError) {
|
|
806
|
+
if (parseError?.code === const_js_1.default.HTTP_CODES.UNPROCESSABLE_ENTITY) {
|
|
807
|
+
throw parseError;
|
|
808
|
+
}
|
|
809
|
+
return finalContent;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return finalContent;
|
|
813
|
+
}
|
|
814
|
+
if (rawContent) {
|
|
815
|
+
const lines = rawContent.split("\n").filter((l) => l.trim());
|
|
816
|
+
let extractedContent = "";
|
|
817
|
+
for (const line of lines) {
|
|
818
|
+
const parsed = safeJsonParse(line.trim());
|
|
819
|
+
if (parsed && parsed.type === "final" && parsed.content) {
|
|
820
|
+
extractedContent = parsed.content;
|
|
821
|
+
break;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
if (extractedContent) {
|
|
825
|
+
try {
|
|
826
|
+
const parsed = JSON.parse(extractedContent);
|
|
827
|
+
if (expectsJsonResponse && schema) {
|
|
828
|
+
validateWithSchema(parsed, schema);
|
|
829
|
+
}
|
|
830
|
+
return JSON.stringify(parsed);
|
|
831
|
+
}
|
|
832
|
+
catch {
|
|
833
|
+
return extractedContent;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
const extracted = extractJSONFromResponse(rawContent);
|
|
837
|
+
if (extracted) {
|
|
838
|
+
if (expectsJsonResponse && schema) {
|
|
839
|
+
validateWithSchema(extracted, schema);
|
|
840
|
+
}
|
|
841
|
+
return JSON.stringify(extracted);
|
|
842
|
+
}
|
|
843
|
+
if (expectsJsonResponse) {
|
|
844
|
+
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, `LLM returned non-JSON response when JSON was expected: ${rawContent.substring(0, 100)}...`);
|
|
845
|
+
}
|
|
846
|
+
return rawContent;
|
|
847
|
+
}
|
|
848
|
+
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, "LLM returned no content");
|
|
849
|
+
}
|
|
850
|
+
else {
|
|
851
|
+
// Non-streaming mode: use native response_format for OpenAI when schema is provided
|
|
852
|
+
const useNativeSchema = expectsJsonResponse && schema && modelName.startsWith("gpt-");
|
|
853
|
+
const llm = getLLMModel(modelName, config, useNativeSchema ? schema : null);
|
|
854
|
+
// Add escape instruction to help LLM produce valid JSON (only if expecting JSON)
|
|
855
|
+
// For non-OpenAI models with schema, also include schema in prompt as fallback
|
|
856
|
+
let messagesToSend;
|
|
857
|
+
if (expectsJsonResponse) {
|
|
858
|
+
let systemContent = JSON_ESCAPE_INSTRUCTION;
|
|
859
|
+
// Include schema in prompt for non-OpenAI models (OpenAI uses native response_format)
|
|
860
|
+
if (schema && !modelName.startsWith("gpt-")) {
|
|
861
|
+
const jsonSchema = (0, zod_to_json_schema_1.zodToJsonSchema)(schema, { target: "openApi3" });
|
|
862
|
+
systemContent += `\n\nYour response MUST conform to this JSON schema:\n${JSON.stringify(jsonSchema, null, 2)}`;
|
|
863
|
+
}
|
|
864
|
+
if (messages.length > 0 && messages[0].role === "system") {
|
|
865
|
+
// Combine JSON escape instruction with user's system message
|
|
866
|
+
messagesToSend = [
|
|
867
|
+
{
|
|
868
|
+
role: "system",
|
|
869
|
+
content: `${systemContent}\n\n---\n\n${messages[0].content}`,
|
|
870
|
+
},
|
|
871
|
+
...messages.slice(1),
|
|
872
|
+
];
|
|
873
|
+
}
|
|
874
|
+
else {
|
|
875
|
+
// Prepend JSON escape instruction as system message
|
|
876
|
+
messagesToSend = [
|
|
877
|
+
{
|
|
878
|
+
role: "system",
|
|
879
|
+
content: systemContent,
|
|
880
|
+
},
|
|
881
|
+
...messages,
|
|
882
|
+
];
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
else {
|
|
886
|
+
messagesToSend = messages;
|
|
887
|
+
}
|
|
888
|
+
// Simply invoke without streaming
|
|
889
|
+
const response = await llm.invoke(messagesToSend);
|
|
890
|
+
const rawContent = response?.content || response;
|
|
891
|
+
// If not expecting JSON, return raw content directly
|
|
892
|
+
if (!expectsJsonResponse) {
|
|
893
|
+
return rawContent;
|
|
894
|
+
}
|
|
895
|
+
// If the response is already a string that looks like JSON, return it
|
|
896
|
+
// Otherwise, try to extract JSON from potential markdown wrapping
|
|
897
|
+
if (typeof rawContent === "string") {
|
|
898
|
+
const trimmed = rawContent.trim();
|
|
899
|
+
// Check if it's already clean JSON
|
|
900
|
+
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
|
901
|
+
(trimmed.startsWith("[") && trimmed.endsWith("]"))) {
|
|
902
|
+
try {
|
|
903
|
+
// Parse and re-stringify
|
|
904
|
+
const parsed = JSON.parse(trimmed);
|
|
905
|
+
// Validate against schema if provided
|
|
906
|
+
if (schema) {
|
|
907
|
+
validateWithSchema(parsed, schema);
|
|
908
|
+
}
|
|
909
|
+
return JSON.stringify(parsed);
|
|
910
|
+
}
|
|
911
|
+
catch (parseError) {
|
|
912
|
+
// If it's a validation error, re-throw it
|
|
913
|
+
if (parseError?.code === const_js_1.default.HTTP_CODES.UNPROCESSABLE_ENTITY) {
|
|
914
|
+
throw parseError;
|
|
915
|
+
}
|
|
916
|
+
// Not valid JSON, try extraction
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
// Try to extract JSON from markdown or other wrapping
|
|
920
|
+
const extracted = extractJSONFromResponse(rawContent);
|
|
921
|
+
if (extracted) {
|
|
922
|
+
// Validate against schema if provided
|
|
923
|
+
if (schema) {
|
|
924
|
+
validateWithSchema(extracted, schema);
|
|
925
|
+
}
|
|
926
|
+
return JSON.stringify(extracted);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
// Return raw content as last resort - but throw if JSON was expected
|
|
930
|
+
if (expectsJsonResponse) {
|
|
931
|
+
const preview = typeof rawContent === "string" ? rawContent.substring(0, 100) : "";
|
|
932
|
+
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}...`);
|
|
933
|
+
}
|
|
934
|
+
return rawContent;
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
/**
|
|
938
|
+
* Sends a conversational chat prompt to an LLM and returns a plain HTML response
|
|
939
|
+
* intended for end-user display. This function is purpose-built for the StackFactor
|
|
940
|
+
* Mentor chat feature: when the prompt is an array of messages, each `system` message
|
|
941
|
+
* is wrapped in an enhanced system prompt that instructs the model to answer only
|
|
942
|
+
* questions related to the provided topic, decline off-topic questions, and return
|
|
943
|
+
* results as simple HTML without markdown code-block notation. The call is delegated to
|
|
944
|
+
* `runPromptWithModel` with `expectsJsonResponse` set to `false`.
|
|
945
|
+
* @param modelName - The model identifier, e.g. `"gpt-4o"`, `"claude-3-5-sonnet"`
|
|
946
|
+
* @param config - Configuration object with API keys and optional `temperature`
|
|
947
|
+
* @param prompt - A plain string user message, or an array of `{ role, content }`
|
|
948
|
+
* message objects; `system` messages have the topic-constraint wrapper applied
|
|
949
|
+
* @param onProgressReport - Optional callback for streaming progress updates passed
|
|
950
|
+
* through to `runPromptWithModel`
|
|
951
|
+
* @returns The model's response as a raw HTML string
|
|
952
|
+
*/
|
|
953
|
+
const runChatPromptWithModel = (modelName, config, prompt, onProgressReport) => {
|
|
954
|
+
let messages = prompt;
|
|
955
|
+
// If prompt is an array, check for system message and enhance it
|
|
956
|
+
if (Array.isArray(prompt)) {
|
|
957
|
+
messages = prompt.map((msg) => {
|
|
958
|
+
if (msg.role === "system") {
|
|
959
|
+
return {
|
|
960
|
+
...msg,
|
|
961
|
+
content: `You are StackFactor Mentor, an AI assistant that helps users by providing information related to the specified topic.
|
|
962
|
+
|
|
963
|
+
### Objective:
|
|
964
|
+
Respond to the user question considering just related to the selected topic and all previous interactions:
|
|
965
|
+
- If the question is unrelated decline to respond.
|
|
966
|
+
- Return the results as a simple HMTL but don't include notations for formatting blocks.
|
|
967
|
+
|
|
968
|
+
### TOPIC INFORMATION:\n
|
|
969
|
+
${msg.content}
|
|
970
|
+
`,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
return msg;
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
return runPromptWithModel(modelName, config, messages, onProgressReport, 0, 100, false);
|
|
977
|
+
};
|
|
978
|
+
/**
|
|
979
|
+
* Determines the image generation provider for a given model name based on its prefix.
|
|
980
|
+
* `dall-e-` and `gpt-image-` prefixes map to `"openai"`. `gemini-` and `imagen-`
|
|
981
|
+
* prefixes map to `"google"`. Returns `null` for any unrecognised prefix.
|
|
982
|
+
* @param modelName - The image model identifier to inspect
|
|
983
|
+
* @returns `"openai"`, `"google"`, or `null` when the provider cannot be determined
|
|
984
|
+
*/
|
|
985
|
+
const getImageModelProvider = (modelName) => {
|
|
986
|
+
// OpenAI models
|
|
987
|
+
if (modelName.startsWith("dall-e-") || modelName.startsWith("gpt-image-")) {
|
|
988
|
+
return "openai";
|
|
989
|
+
}
|
|
990
|
+
// Google models (Gemini and Imagen)
|
|
991
|
+
if (modelName.startsWith("gemini-") || modelName.startsWith("imagen-")) {
|
|
992
|
+
return "google";
|
|
993
|
+
}
|
|
994
|
+
return null;
|
|
995
|
+
};
|
|
996
|
+
/**
|
|
997
|
+
* Generates an image using an OpenAI image model (DALL-E 2, DALL-E 3, or GPT Image
|
|
998
|
+
* variants). Validates the `size` parameter against each model's allowed dimensions and
|
|
999
|
+
* enforces single-image (`n=1`) constraints for models that do not support batch
|
|
1000
|
+
* generation. Returns a single image descriptor object when `n === 1`, or an object
|
|
1001
|
+
* with an `images` array for batch requests. Each descriptor contains `url` or
|
|
1002
|
+
* `b64_json` depending on `options.responseFormat`, plus a `revisedPrompt` field when
|
|
1003
|
+
* provided by the API.
|
|
1004
|
+
* @param modelName - The OpenAI image model identifier (e.g. `"dall-e-3"`,
|
|
1005
|
+
* `"gpt-image-1.5"`)
|
|
1006
|
+
* @param config - Configuration object; must include `openAIAPIKey`
|
|
1007
|
+
* @param prompt - The text prompt describing the image to generate
|
|
1008
|
+
* @param options - Generation options including `size`, `style`, `responseFormat`
|
|
1009
|
+
* (`"url"` or `"b64_json"`), and `n` (number of images)
|
|
1010
|
+
* @returns An object with `url`, `b64_json`, and `revisedPrompt` for a single image,
|
|
1011
|
+
* or `{ images: [...] }` for multiple images
|
|
1012
|
+
*/
|
|
1013
|
+
const generateImageWithOpenAI = async (modelName, config, prompt, options) => {
|
|
1014
|
+
const { size = "1024x1024", style = "vivid", responseFormat = "url", n = 1, } = options;
|
|
1015
|
+
if (!config.openAIAPIKey) {
|
|
1016
|
+
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.BAD_REQUEST, "OpenAI API key is required for OpenAI image generation");
|
|
1017
|
+
}
|
|
1018
|
+
// Model-specific validations
|
|
1019
|
+
const singleImageModels = ["dall-e-3", "gpt-image-1.5", "gpt-image-1-mini"];
|
|
1020
|
+
if (singleImageModels.includes(modelName) && n > 1) {
|
|
1021
|
+
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.BAD_REQUEST, `${modelName} only supports generating 1 image at a time (n=1)`);
|
|
1022
|
+
}
|
|
1023
|
+
// Validate size for each model
|
|
1024
|
+
const validSizesDallE2 = ["256x256", "512x512", "1024x1024"];
|
|
1025
|
+
const validSizesDallE3 = ["1024x1024", "1792x1024", "1024x1792"];
|
|
1026
|
+
const validSizesGptImage = ["1024x1024", "1536x1024", "1024x1536", "auto"];
|
|
1027
|
+
let validSizes;
|
|
1028
|
+
if (modelName === "dall-e-2") {
|
|
1029
|
+
validSizes = validSizesDallE2;
|
|
1030
|
+
}
|
|
1031
|
+
else if (modelName === "dall-e-3") {
|
|
1032
|
+
validSizes = validSizesDallE3;
|
|
1033
|
+
}
|
|
1034
|
+
else {
|
|
1035
|
+
// gpt-image models
|
|
1036
|
+
validSizes = validSizesGptImage;
|
|
1037
|
+
}
|
|
1038
|
+
if (!validSizes.includes(size)) {
|
|
1039
|
+
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.BAD_REQUEST, `Invalid size "${size}" for ${modelName}. Valid sizes: ${validSizes.join(", ")}`);
|
|
1040
|
+
}
|
|
1041
|
+
const openai = new openai_2.OpenAI({ apiKey: config.openAIAPIKey });
|
|
1042
|
+
const requestParams = {
|
|
1043
|
+
model: modelName,
|
|
1044
|
+
prompt: prompt,
|
|
1045
|
+
n: n,
|
|
1046
|
+
size: size,
|
|
1047
|
+
};
|
|
1048
|
+
// Add quality/style for models that support it
|
|
1049
|
+
const supportsQualityStyle = [
|
|
1050
|
+
"dall-e-3",
|
|
1051
|
+
"gpt-image-1.5",
|
|
1052
|
+
"gpt-image-1-mini",
|
|
1053
|
+
];
|
|
1054
|
+
if (supportsQualityStyle.includes(modelName)) {
|
|
1055
|
+
//requestParams.quality = quality;
|
|
1056
|
+
if (modelName === "dall-e-3") {
|
|
1057
|
+
requestParams.style = style;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
const response = await openai.images.generate(requestParams);
|
|
1061
|
+
// Format response based on number of images
|
|
1062
|
+
if (n === 1) {
|
|
1063
|
+
const imageData = response.data[0];
|
|
1064
|
+
return {
|
|
1065
|
+
url: responseFormat === "url" ? imageData.url : undefined,
|
|
1066
|
+
b64_json: responseFormat === "b64_json" ? imageData.b64_json : undefined,
|
|
1067
|
+
revisedPrompt: imageData.revised_prompt,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
else {
|
|
1071
|
+
return {
|
|
1072
|
+
images: response.data.map((img) => ({
|
|
1073
|
+
url: responseFormat === "url" ? img.url : undefined,
|
|
1074
|
+
b64_json: responseFormat === "b64_json" ? img.b64_json : undefined,
|
|
1075
|
+
revisedPrompt: img.revised_prompt,
|
|
1076
|
+
})),
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
};
|
|
1080
|
+
/**
|
|
1081
|
+
* Generates an image using a Google AI model (Imagen or Gemini image variants) via the
|
|
1082
|
+
* `@google/genai` SDK. Strips transparent-background requests from the prompt because
|
|
1083
|
+
* Google models do not support alpha channels. Validates the `aspectRatio` against
|
|
1084
|
+
* allowed values (`"1:1"`, `"3:4"`, `"4:3"`, `"9:16"`, `"16:9"`). Applies model-
|
|
1085
|
+
* specific generation config: Imagen models receive `numberOfImages`, `aspectRatio`,
|
|
1086
|
+
* `outputMimeType`, and optional `negativePrompt`; Gemini models receive `temperature`
|
|
1087
|
+
* and `topP`. Safety settings are disabled for image generation. Throws an error if no
|
|
1088
|
+
* images are returned.
|
|
1089
|
+
* @param modelName - The Google model identifier (e.g. `"imagen-4.0-generate-001"`,
|
|
1090
|
+
* `"gemini-3.0-pro-image"`)
|
|
1091
|
+
* @param config - Configuration object; must include `googleAPIKey`
|
|
1092
|
+
* @param prompt - The text prompt describing the image to generate
|
|
1093
|
+
* @param options - Generation options including `aspectRatio`, `numberOfImages`, and
|
|
1094
|
+
* optional `negativePrompt` (Imagen only)
|
|
1095
|
+
* @returns A single image descriptor `{ b64_json, mimeType }` when one image is
|
|
1096
|
+
* requested, or `{ images: [...] }` for multiple images
|
|
1097
|
+
*/
|
|
1098
|
+
const generateImageWithGoogle = async (modelName, config, prompt, options) => {
|
|
1099
|
+
const { aspectRatio = "1:1", numberOfImages = 1, negativePrompt = "", } = options;
|
|
1100
|
+
if (!config.googleAPIKey) {
|
|
1101
|
+
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.BAD_REQUEST, "Google API key is required for Google image generation");
|
|
1102
|
+
}
|
|
1103
|
+
// Remove transparent background requests as Google models don't support it
|
|
1104
|
+
const cleanedPrompt = prompt
|
|
1105
|
+
.replace(/\s*and transparent background\s*/gi, " ")
|
|
1106
|
+
.replace(/\s*with transparent background\s*/gi, " ")
|
|
1107
|
+
.replace(/\s*on transparent background\s*/gi, " ")
|
|
1108
|
+
.replace(/\s*transparent background\s*/gi, " ")
|
|
1109
|
+
.trim();
|
|
1110
|
+
// Valid aspect ratios for Google Imagen/Gemini
|
|
1111
|
+
const validAspectRatios = ["1:1", "3:4", "4:3", "9:16", "16:9"];
|
|
1112
|
+
if (!validAspectRatios.includes(aspectRatio)) {
|
|
1113
|
+
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.BAD_REQUEST, `Invalid aspectRatio "${aspectRatio}" for ${modelName}. Valid ratios: ${validAspectRatios.join(", ")}`);
|
|
1114
|
+
}
|
|
1115
|
+
// Initialize GoogleGenAI SDK
|
|
1116
|
+
const ai = new genai_1.GoogleGenAI({
|
|
1117
|
+
apiKey: config.googleAPIKey,
|
|
1118
|
+
});
|
|
1119
|
+
// Determine the model type
|
|
1120
|
+
const isImagenModel = modelName.startsWith("imagen-");
|
|
1121
|
+
// Build generation config based on model type
|
|
1122
|
+
const generationConfig = {
|
|
1123
|
+
responseModalities: ["IMAGE", "TEXT"],
|
|
1124
|
+
...(isImagenModel
|
|
1125
|
+
? {
|
|
1126
|
+
// Imagen-specific config
|
|
1127
|
+
numberOfImages: numberOfImages,
|
|
1128
|
+
aspectRatio: aspectRatio,
|
|
1129
|
+
outputMimeType: "image/png",
|
|
1130
|
+
...(negativePrompt ? { negativePrompt: negativePrompt } : {}),
|
|
1131
|
+
}
|
|
1132
|
+
: {
|
|
1133
|
+
// Gemini-specific config
|
|
1134
|
+
temperature: 1,
|
|
1135
|
+
topP: 0.95,
|
|
1136
|
+
}),
|
|
1137
|
+
};
|
|
1138
|
+
// Safety settings (disable for image generation)
|
|
1139
|
+
const safetySettings = [
|
|
1140
|
+
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "OFF" },
|
|
1141
|
+
{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "OFF" },
|
|
1142
|
+
{ category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "OFF" },
|
|
1143
|
+
{ category: "HARM_CATEGORY_HARASSMENT", threshold: "OFF" },
|
|
1144
|
+
];
|
|
1145
|
+
const req = {
|
|
1146
|
+
model: modelName,
|
|
1147
|
+
contents: [
|
|
1148
|
+
{
|
|
1149
|
+
role: "user",
|
|
1150
|
+
parts: [{ text: cleanedPrompt }],
|
|
1151
|
+
},
|
|
1152
|
+
],
|
|
1153
|
+
config: {
|
|
1154
|
+
...generationConfig,
|
|
1155
|
+
safetySettings: safetySettings,
|
|
1156
|
+
},
|
|
1157
|
+
};
|
|
1158
|
+
const response = await ai.models.generateContent(req);
|
|
1159
|
+
// Extract images from response
|
|
1160
|
+
const images = [];
|
|
1161
|
+
const candidates = response.candidates || [];
|
|
1162
|
+
for (const candidate of candidates) {
|
|
1163
|
+
const parts = candidate.content?.parts || [];
|
|
1164
|
+
for (const part of parts) {
|
|
1165
|
+
if (part.inlineData) {
|
|
1166
|
+
images.push({
|
|
1167
|
+
b64_json: part.inlineData.data,
|
|
1168
|
+
mimeType: part.inlineData.mimeType || "image/png",
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
if (images.length === 0) {
|
|
1174
|
+
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, `No images were generated by ${modelName}`);
|
|
1175
|
+
}
|
|
1176
|
+
if (numberOfImages === 1 || images.length === 1) {
|
|
1177
|
+
return images[0];
|
|
1178
|
+
}
|
|
1179
|
+
return { images };
|
|
1180
|
+
};
|
|
1181
|
+
/**
|
|
1182
|
+
* Top-level entry point for AI image generation. Routes the request to either
|
|
1183
|
+
* `generateImageWithOpenAI` or `generateImageWithGoogle` based on the model name prefix
|
|
1184
|
+
* detected by `getImageModelProvider`. Logs the total generation time on success.
|
|
1185
|
+
* Re-throws already-formatted errors directly; wraps raw provider errors into a
|
|
1186
|
+
* formatted `INTERNAL_SERVER_ERROR`.
|
|
1187
|
+
*
|
|
1188
|
+
* Supported OpenAI models: `"dall-e-2"`, `"dall-e-3"`, `"gpt-image-1.5"`,
|
|
1189
|
+
* `"gpt-image-1-mini"`.
|
|
1190
|
+
* Supported Google models: `"imagen-4.0-generate-001"`,
|
|
1191
|
+
* `"imagen-4.0-fast-generate-001"`, `"imagen-4.0-ultra-generate-001"`,
|
|
1192
|
+
* `"gemini-3.0-pro-image"`, `"gemini-2.5-flash-image"`.
|
|
1193
|
+
* @param modelName - The image model identifier; must start with a recognised prefix
|
|
1194
|
+
* @param config - Configuration object with provider API keys (`openAIAPIKey` or
|
|
1195
|
+
* `googleAPIKey`)
|
|
1196
|
+
* @param prompt - The text prompt describing the image to generate
|
|
1197
|
+
* @param options - Provider-specific generation options (see `generateImageWithOpenAI`
|
|
1198
|
+
* and `generateImageWithGoogle` for full option sets); defaults to `{}`
|
|
1199
|
+
* @returns The generated image data object returned by the provider-specific function
|
|
1200
|
+
*/
|
|
1201
|
+
const runPromptWithModelForImageGeneration = async (modelName, config, prompt, options = {}) => {
|
|
1202
|
+
const provider = getImageModelProvider(modelName);
|
|
1203
|
+
if (!provider) {
|
|
1204
|
+
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.BAD_REQUEST, `Unable to determine provider for model: ${modelName}. Model name should start with 'gpt-image-', 'gemini-', or 'imagen-'.`);
|
|
1205
|
+
}
|
|
1206
|
+
const startTime = Date.now();
|
|
1207
|
+
try {
|
|
1208
|
+
let result;
|
|
1209
|
+
if (provider === "openai") {
|
|
1210
|
+
result = await generateImageWithOpenAI(modelName, config, prompt, options);
|
|
1211
|
+
}
|
|
1212
|
+
else if (provider === "google") {
|
|
1213
|
+
result = await generateImageWithGoogle(modelName, config, prompt, options);
|
|
1214
|
+
}
|
|
1215
|
+
const endTime = Date.now();
|
|
1216
|
+
const duration = endTime - startTime;
|
|
1217
|
+
logger_js_1.default.log(null, logger_js_1.default.levels.info, `Image generation with "${modelName}" completed in ${Math.round(duration / 1000)} seconds.`);
|
|
1218
|
+
return result;
|
|
1219
|
+
}
|
|
1220
|
+
catch (error) {
|
|
1221
|
+
// Re-throw if already a formatted error
|
|
1222
|
+
if (error?.code && error?.message) {
|
|
1223
|
+
throw error;
|
|
1224
|
+
}
|
|
1225
|
+
// Handle provider-specific errors
|
|
1226
|
+
if (error?.status || error?.code) {
|
|
1227
|
+
const statusCode = error.status || const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR;
|
|
1228
|
+
const message = error.message || "Image generation failed";
|
|
1229
|
+
throw errorHandling_js_1.default.create(statusCode, message);
|
|
1230
|
+
}
|
|
1231
|
+
throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, `Image generation failed: ${error?.message || String(error)}`);
|
|
1232
|
+
}
|
|
1233
|
+
};
|
|
1234
|
+
exports.default = {
|
|
1235
|
+
createAIAgent,
|
|
1236
|
+
runAIAgent,
|
|
1237
|
+
runChatPromptWithModel,
|
|
1238
|
+
runPromptWithModel,
|
|
1239
|
+
runPromptWithModelForImageGeneration,
|
|
1240
|
+
throwErrorIfNotSuccessful,
|
|
1241
|
+
};
|