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