@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,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
+ };