ima2-gen 2.0.0 → 2.0.2

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.
Files changed (87) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +12 -12
  3. package/bin/commands/backfillThumbs.js +24 -0
  4. package/bin/commands/edit.js +7 -6
  5. package/bin/commands/gen.js +13 -6
  6. package/bin/commands/multimode.js +5 -4
  7. package/bin/commands/node.js +4 -4
  8. package/bin/ima2.js +21 -11
  9. package/bin/lib/config-store.js +1 -1
  10. package/docs/API.md +184 -10
  11. package/docs/CLI.md +11 -4
  12. package/docs/FAQ.ko.md +16 -0
  13. package/docs/FAQ.md +30 -0
  14. package/docs/PROMPT_STUDIO.md +3 -1
  15. package/docs/README.ko.md +7 -3
  16. package/docs/migration/runtime-test-inventory.md +17 -1
  17. package/lib/agentImageVideoGen.js +261 -0
  18. package/lib/agentRuntime.js +11 -260
  19. package/lib/agentSettings.js +1 -1
  20. package/lib/agyImageAdapter.js +259 -0
  21. package/lib/capabilities.js +2 -1
  22. package/lib/configKeys.js +1 -1
  23. package/lib/errorClassify.js +8 -7
  24. package/lib/eventBus.js +71 -0
  25. package/lib/geminiApiImageAdapter.js +179 -0
  26. package/lib/generationErrors.js +3 -1
  27. package/lib/grokImageAdapter.js +74 -128
  28. package/lib/grokImageCore.js +153 -0
  29. package/lib/grokMultimodeAdapter.js +7 -4
  30. package/lib/grokRuntime.js +3 -0
  31. package/lib/grokSizeMapper.js +13 -1
  32. package/lib/grokVideoAdapter.js +14 -7
  33. package/lib/grokVideoCanvas.js +13 -0
  34. package/lib/grokVideoPlannerPrompt.js +53 -6
  35. package/lib/historyList.js +19 -2
  36. package/lib/imageModels.js +15 -0
  37. package/lib/imageThumb.js +38 -0
  38. package/lib/inflight.js +54 -17
  39. package/lib/multimodeHelpers.js +10 -0
  40. package/lib/nodeHelpers.js +59 -0
  41. package/lib/oauthProxy/prompts.js +30 -36
  42. package/lib/promptBuilder/systemPrompt.js +2 -5
  43. package/lib/promptSafetyPolicy.js +1 -5
  44. package/lib/providerOptions.js +36 -1
  45. package/lib/responsesFallback.js +53 -44
  46. package/lib/routeHelpers.js +44 -0
  47. package/lib/runtimeContext.js +27 -0
  48. package/lib/ssePublish.js +12 -0
  49. package/lib/storageMigration.js +1 -1
  50. package/lib/storyboardPrefix.js +28 -0
  51. package/lib/thumbBackfill.js +70 -0
  52. package/lib/vertexAuth.js +44 -0
  53. package/lib/videoThumb.js +60 -0
  54. package/package.json +7 -2
  55. package/routes/agy.js +44 -0
  56. package/routes/auth.js +242 -0
  57. package/routes/edit.js +48 -8
  58. package/routes/events.js +78 -0
  59. package/routes/generate.js +135 -135
  60. package/routes/history.js +13 -0
  61. package/routes/index.js +8 -0
  62. package/routes/keys.js +254 -0
  63. package/routes/multimode.js +138 -62
  64. package/routes/nodes.js +107 -129
  65. package/routes/quota.js +58 -7
  66. package/routes/video.js +107 -20
  67. package/server.js +123 -0
  68. package/skills/ima2/SKILL.md +98 -21
  69. package/ui/dist/.vite/manifest.json +12 -12
  70. package/ui/dist/assets/AgentWorkspace-Dth6YijN.js +3 -0
  71. package/ui/dist/assets/{CardNewsWorkspace-BN-ga1lG.js → CardNewsWorkspace-Dav3K5CT.js} +2 -2
  72. package/ui/dist/assets/{NodeCanvas-BbMa4IhI.js → NodeCanvas-C4ifFzB1.js} +2 -2
  73. package/ui/dist/assets/{PromptBuilderPanel-DRwBJRDQ.js → PromptBuilderPanel-CEcyU9PL.js} +1 -1
  74. package/ui/dist/assets/{PromptImportDialog-Dp85kHCq.js → PromptImportDialog-CgQ94Gth.js} +2 -2
  75. package/ui/dist/assets/{PromptImportDiscoverySection-BE8Q8MLD.js → PromptImportDiscoverySection-CuzyzbNI.js} +1 -1
  76. package/ui/dist/assets/{PromptImportFolderSection-PtH5x0sc.js → PromptImportFolderSection-DHLGlO6l.js} +1 -1
  77. package/ui/dist/assets/{PromptLibraryPanel-FnM9tHI9.js → PromptLibraryPanel-BOe18we8.js} +2 -2
  78. package/ui/dist/assets/SettingsWorkspace-Cdgnm4Wa.js +1 -0
  79. package/ui/dist/assets/index-C5PSahkr.js +1 -0
  80. package/ui/dist/assets/index-Dn2AhL6d.css +1 -0
  81. package/ui/dist/assets/index-Tjqx6wUV.js +23 -0
  82. package/ui/dist/index.html +2 -2
  83. package/ui/dist/assets/AgentWorkspace-C21zqdTZ.js +0 -3
  84. package/ui/dist/assets/SettingsWorkspace-MARPGyBL.js +0 -1
  85. package/ui/dist/assets/index-BAFI6htx.js +0 -42
  86. package/ui/dist/assets/index-BSXxr_Bt.js +0 -1
  87. package/ui/dist/assets/index-DS-ADE7U.css +0 -1
package/routes/keys.js ADDED
@@ -0,0 +1,254 @@
1
+ import { readFile, writeFile, rename } from "node:fs/promises";
2
+ import { initVertexAuth, clearVertexAuth } from "../lib/vertexAuth.js";
3
+ // Atomic + 0600 config write: temp file then rename, so a crash or concurrent
4
+ // save can't corrupt config.json (which may hold API keys). Rename also forces
5
+ // 0600 perms even if a looser-perm config pre-existed.
6
+ async function writeConfigAtomic(cfgPath, data) {
7
+ const tmp = `${cfgPath}.${process.pid}.tmp`;
8
+ await writeFile(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
9
+ await rename(tmp, cfgPath);
10
+ }
11
+ const KEY_PREFIX_MAP = {
12
+ openai: ["sk-"],
13
+ xai: ["xai-"],
14
+ gemini: ["AI"],
15
+ };
16
+ const VALIDATE_URL_MAP = {
17
+ openai: "https://api.openai.com/v1/models",
18
+ xai: "https://api.x.ai/v1/models",
19
+ gemini: "https://generativelanguage.googleapis.com/v1beta/models",
20
+ };
21
+ const CONFIG_KEY_MAP = {
22
+ openai: "apiKey",
23
+ xai: "xaiApiKey",
24
+ gemini: "geminiApiKey",
25
+ };
26
+ function isKeyProvider(v) {
27
+ return v === "openai" || v === "xai" || v === "gemini";
28
+ }
29
+ function maskKey(key) {
30
+ if (key.length <= 10)
31
+ return "***";
32
+ return `${key.slice(0, 4)}..${key.slice(-2)}`;
33
+ }
34
+ function keySourceForProvider(ctx, provider) {
35
+ if (provider === "openai")
36
+ return { key: ctx.apiKey, source: ctx.apiKeySource || "none" };
37
+ if (provider === "xai")
38
+ return { key: ctx.xaiApiKey, source: ctx.xaiApiKeySource || "none" };
39
+ if (provider === "gemini")
40
+ return { key: ctx.geminiApiKey, source: ctx.geminiApiKeySource || "none" };
41
+ return { key: undefined, source: "none" };
42
+ }
43
+ export function mountKeyRoutes(app, ctx) {
44
+ app.get("/api/keys/status", (_req, res) => {
45
+ const status = {};
46
+ for (const provider of ["openai", "xai", "gemini"]) {
47
+ const { key, source } = keySourceForProvider(ctx, provider);
48
+ status[provider] = {
49
+ configured: !!key,
50
+ source,
51
+ valid: !!key,
52
+ maskedKey: key ? maskKey(key) : null,
53
+ };
54
+ }
55
+ const vertexJson = ctx.vertexServiceAccountJson;
56
+ const vertexSource = vertexJson
57
+ ? (process.env.VERTEX_SERVICE_ACCOUNT_JSON ? "env" : "config")
58
+ : "none";
59
+ status.vertex = {
60
+ configured: !!vertexJson,
61
+ source: vertexSource,
62
+ valid: !!vertexJson,
63
+ maskedKey: ctx.vertexProjectId ? `project: ${ctx.vertexProjectId}` : null,
64
+ };
65
+ res.json(status);
66
+ });
67
+ // Vertex JSON — dedicated route (before generic :provider)
68
+ app.put("/api/keys/vertex", async (req, res) => {
69
+ const { serviceAccountJson } = req.body;
70
+ if (!serviceAccountJson || typeof serviceAccountJson !== "string") {
71
+ return res.status(400).json({ ok: false, error: "Missing serviceAccountJson", code: "MISSING_KEY" });
72
+ }
73
+ const trimmed = serviceAccountJson.trim();
74
+ if (trimmed.length > 50 * 1024) {
75
+ return res.status(400).json({ ok: false, error: "Service account JSON too large (max 50KB)", code: "KEY_TOO_LARGE" });
76
+ }
77
+ let parsed;
78
+ try {
79
+ parsed = JSON.parse(trimmed);
80
+ }
81
+ catch {
82
+ return res.status(400).json({ ok: false, error: "Invalid JSON", code: "INVALID_JSON" });
83
+ }
84
+ if (parsed.type !== "service_account" || !parsed.project_id) {
85
+ return res.status(400).json({
86
+ ok: false,
87
+ error: "JSON must be a Google Cloud service account (type: service_account, project_id required)",
88
+ code: "INVALID_SERVICE_ACCOUNT",
89
+ });
90
+ }
91
+ // Validate by initializing auth (catches key format issues)
92
+ try {
93
+ initVertexAuth(trimmed);
94
+ }
95
+ catch {
96
+ return res.status(400).json({ ok: false, error: "Service account validation failed", code: "KEY_VALIDATION_FAILED" });
97
+ }
98
+ // Save to config.json
99
+ const cfgPath = ctx.config.storage.configFile;
100
+ let existing = {};
101
+ try {
102
+ existing = JSON.parse(await readFile(cfgPath, "utf-8"));
103
+ }
104
+ catch { /* new file */ }
105
+ existing.vertexServiceAccountJson = trimmed;
106
+ existing.geminiAuthMode = "vertex";
107
+ await writeConfigAtomic(cfgPath, existing);
108
+ // Hot-update runtime
109
+ ctx.vertexServiceAccountJson = trimmed;
110
+ ctx.vertexProjectId = parsed.project_id;
111
+ ctx.hasVertexKey = true;
112
+ ctx.geminiAuthMode = "vertex";
113
+ return res.json({ ok: true, provider: "vertex", source: "config", valid: true, projectId: parsed.project_id });
114
+ });
115
+ app.delete("/api/keys/vertex", async (_req, res) => {
116
+ const source = ctx.vertexServiceAccountJson
117
+ ? (process.env.VERTEX_SERVICE_ACCOUNT_JSON ? "env" : "config")
118
+ : "none";
119
+ if (source === "env") {
120
+ return res.status(400).json({ ok: false, error: "Cannot remove env-sourced key", code: "ENV_KEY_IMMUTABLE" });
121
+ }
122
+ const cfgPath = ctx.config.storage.configFile;
123
+ let existing = {};
124
+ try {
125
+ existing = JSON.parse(await readFile(cfgPath, "utf-8"));
126
+ }
127
+ catch { /* ignore */ }
128
+ delete existing.vertexServiceAccountJson;
129
+ await writeConfigAtomic(cfgPath, existing);
130
+ clearVertexAuth();
131
+ ctx.vertexServiceAccountJson = undefined;
132
+ ctx.vertexProjectId = undefined;
133
+ ctx.hasVertexKey = false;
134
+ return res.json({ ok: true, provider: "vertex", removed: true });
135
+ });
136
+ app.put("/api/keys/:provider", async (req, res) => {
137
+ const { provider } = req.params;
138
+ if (!isKeyProvider(provider)) {
139
+ return res.status(400).json({ ok: false, error: "Invalid provider", code: "INVALID_PROVIDER" });
140
+ }
141
+ const { apiKey } = req.body;
142
+ if (!apiKey || typeof apiKey !== "string" || apiKey.trim().length === 0) {
143
+ return res.status(400).json({ ok: false, error: "Missing apiKey", code: "MISSING_KEY" });
144
+ }
145
+ const trimmed = apiKey.trim();
146
+ if (trimmed.length > 512) {
147
+ return res.status(400).json({ ok: false, error: "API key too large", code: "KEY_TOO_LARGE" });
148
+ }
149
+ // Format check
150
+ const validPrefix = KEY_PREFIX_MAP[provider].some((p) => trimmed.startsWith(p));
151
+ if (!validPrefix) {
152
+ return res.status(400).json({
153
+ ok: false,
154
+ error: `Invalid key format for ${provider}: expected prefix ${KEY_PREFIX_MAP[provider].join(" or ")}`,
155
+ code: "INVALID_KEY_FORMAT",
156
+ });
157
+ }
158
+ // Validate against provider API
159
+ try {
160
+ const url = VALIDATE_URL_MAP[provider];
161
+ const opts = { signal: AbortSignal.timeout(10_000) };
162
+ if (provider === "gemini") {
163
+ opts.headers = { "x-goog-api-key": trimmed };
164
+ const validateRes = await fetch(url, opts);
165
+ if (!validateRes.ok)
166
+ throw new Error(`HTTP ${validateRes.status}`);
167
+ }
168
+ else {
169
+ opts.headers = { Authorization: `Bearer ${trimmed}` };
170
+ const validateRes = await fetch(url, opts);
171
+ if (!validateRes.ok)
172
+ throw new Error(`HTTP ${validateRes.status}`);
173
+ }
174
+ }
175
+ catch (e) {
176
+ return res.status(400).json({
177
+ ok: false,
178
+ error: `API key validation failed: ${e.message || "unknown"}`,
179
+ code: "KEY_VALIDATION_FAILED",
180
+ });
181
+ }
182
+ // Save to config.json
183
+ const cfgPath = ctx.config.storage.configFile;
184
+ let existing = {};
185
+ try {
186
+ existing = JSON.parse(await readFile(cfgPath, "utf-8"));
187
+ }
188
+ catch { /* new file */ }
189
+ existing[CONFIG_KEY_MAP[provider]] = trimmed;
190
+ await writeConfigAtomic(cfgPath, existing);
191
+ // Hot-update runtime context
192
+ if (provider === "openai") {
193
+ ctx.apiKey = trimmed;
194
+ ctx.apiKeySource = "config";
195
+ ctx.hasApiKey = true;
196
+ try {
197
+ const OpenAI = (await import("openai")).default;
198
+ ctx.openai = new OpenAI({ apiKey: trimmed });
199
+ }
200
+ catch { /* ignore */ }
201
+ }
202
+ else if (provider === "xai") {
203
+ ctx.xaiApiKey = trimmed;
204
+ ctx.xaiApiKeySource = "config";
205
+ ctx.hasXaiApiKey = true;
206
+ }
207
+ else if (provider === "gemini") {
208
+ ctx.geminiApiKey = trimmed;
209
+ ctx.geminiApiKeySource = "config";
210
+ ctx.hasGeminiApiKey = true;
211
+ ctx.geminiAuthMode = "apikey";
212
+ existing[CONFIG_KEY_MAP[provider]] = trimmed;
213
+ existing.geminiAuthMode = "apikey";
214
+ }
215
+ return res.json({ ok: true, provider, source: "config", valid: true });
216
+ });
217
+ app.delete("/api/keys/:provider", async (req, res) => {
218
+ const { provider } = req.params;
219
+ if (!isKeyProvider(provider)) {
220
+ return res.status(400).json({ ok: false, error: "Invalid provider", code: "INVALID_PROVIDER" });
221
+ }
222
+ const { source } = keySourceForProvider(ctx, provider);
223
+ if (source === "env") {
224
+ return res.status(400).json({ ok: false, error: "Cannot remove env-sourced key", code: "ENV_KEY_IMMUTABLE" });
225
+ }
226
+ // Remove from config.json
227
+ const cfgPath = ctx.config.storage.configFile;
228
+ let existing = {};
229
+ try {
230
+ existing = JSON.parse(await readFile(cfgPath, "utf-8"));
231
+ }
232
+ catch { /* ignore */ }
233
+ delete existing[CONFIG_KEY_MAP[provider]];
234
+ await writeConfigAtomic(cfgPath, existing);
235
+ // Clear runtime
236
+ if (provider === "openai") {
237
+ ctx.apiKey = undefined;
238
+ ctx.apiKeySource = "none";
239
+ ctx.hasApiKey = false;
240
+ ctx.openai = null;
241
+ }
242
+ else if (provider === "xai") {
243
+ ctx.xaiApiKey = undefined;
244
+ ctx.xaiApiKeySource = "none";
245
+ ctx.hasXaiApiKey = false;
246
+ }
247
+ else if (provider === "gemini") {
248
+ ctx.geminiApiKey = undefined;
249
+ ctx.geminiApiKeySource = "none";
250
+ ctx.hasGeminiApiKey = false;
251
+ }
252
+ return res.json({ ok: true, provider, removed: true });
253
+ });
254
+ }
@@ -3,12 +3,15 @@ import { safeWriteSidecar } from "../lib/atomicWrite.js";
3
3
  import { join } from "path";
4
4
  import { randomBytes } from "crypto";
5
5
  import { detectImageMimeFromB64, summarizeReferencePayload, validateAndNormalizeRefs } from "../lib/refs.js";
6
+ import { generateImageThumbnailFromBuffer } from "../lib/imageThumb.js";
6
7
  import { classifyUpstreamError } from "../lib/errorClassify.js";
7
8
  import { normalizeOAuthParams } from "../lib/oauthNormalize.js";
8
9
  import { resolveProviderOptions } from "../lib/providerOptions.js";
9
10
  import { generateMultimodeViaResponses } from "../lib/responsesImageAdapter.js";
10
11
  import { generateMultimodeViaGrok } from "../lib/grokMultimodeAdapter.js";
11
- import { startJob, finishJob, registerJobAbortController, isJobCanceled } from "../lib/inflight.js";
12
+ import { generateViaAgy } from "../lib/agyImageAdapter.js";
13
+ import { generateViaGeminiApi } from "../lib/geminiApiImageAdapter.js";
14
+ import { startJob, finishJob, registerJobAbortController, isJobCanceled, isStartJobFailure, INFLIGHT_RETRY_AFTER_SECONDS } from "../lib/inflight.js";
12
15
  import { isGenerationCanceledError, makeGenerationCanceledError, throwIfJobCanceled, } from "../lib/generationCancel.js";
13
16
  import { logEvent, logError } from "../lib/logger.js";
14
17
  import { embedImageMetadataBestEffort } from "../lib/imageMetadataStore.js";
@@ -16,37 +19,35 @@ import { invalidateHistoryIndex } from "../lib/historyIndex.js";
16
19
  import { normalizeComposerInsertedPrompts, normalizeComposerPrompt, } from "../lib/composerSnapshot.js";
17
20
  import { errInfo } from "../lib/errInfo.js";
18
21
  import { requireRuntimeContext } from "../lib/runtimeContext.js";
19
- function sendSse(res, event, data) {
20
- res.write(`event: ${event}\n`);
21
- res.write(`data: ${JSON.stringify(data)}\n\n`);
22
- }
23
- function validateModeration(ctx, moderation) {
24
- if (typeof moderation !== "string" || !ctx.config.oauth.validModeration.has(moderation)) {
25
- return { error: "moderation must be one of: auto, low" };
22
+ import { validateModeration, imageFormatFromMime, writeSse } from "../lib/routeHelpers.js";
23
+ import { publish } from "../lib/eventBus.js";
24
+ import { publishJobEvent } from "../lib/ssePublish.js";
25
+ import { normalizeMaxImages, sequenceStatus, } from "../lib/multimodeHelpers.js";
26
+ function dualEmitMultimode(res, requestId, event, data) {
27
+ if (!res.writableEnded)
28
+ writeSse(res, event, data);
29
+ if (event === "done") {
30
+ publishJobEvent(requestId, event, data);
31
+ }
32
+ else {
33
+ publish(requestId, event, data);
26
34
  }
27
- return { moderation };
28
- }
29
- function normalizeMaxImages(value) {
30
- return Math.min(8, Math.max(1, Math.trunc(Number(value) || 1)));
31
- }
32
- function sequenceStatus(returned, requested) {
33
- if (returned <= 0)
34
- return "empty";
35
- if (returned < requested)
36
- return "partial";
37
- return "complete";
38
35
  }
39
- function imageFormatFromMime(mime) {
40
- if (mime === "image/jpeg")
41
- return "jpeg";
42
- if (mime === "image/webp")
43
- return "webp";
44
- return "png";
36
+ function respondMultimodeValidationError(res, requestId, asyncMode, status, payload) {
37
+ publish(requestId, "error", payload);
38
+ if (asyncMode && !res.headersSent) {
39
+ return res.status(status).json(payload);
40
+ }
41
+ if (!res.writableEnded) {
42
+ writeSse(res, "error", payload);
43
+ res.end();
44
+ }
45
45
  }
46
46
  export function registerMultimodeRoutes(app, ctxRaw) {
47
47
  const ctx = requireRuntimeContext(ctxRaw);
48
48
  app.post("/api/generate/multimode", async (req, res) => {
49
49
  const requestId = typeof req.body?.requestId === "string" ? req.body.requestId : req.id;
50
+ const asyncMode = req.body?.async === true;
50
51
  let finishStatus = "completed";
51
52
  let finishHttpStatus = 200;
52
53
  let finishErrorCode;
@@ -71,10 +72,12 @@ export function registerMultimodeRoutes(app, ctxRaw) {
71
72
  let latestUsage = null;
72
73
  let latestWebSearchCalls = 0;
73
74
  let latestExtraIgnored = 0;
74
- res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
75
- res.setHeader("Cache-Control", "no-cache, no-transform");
76
- res.setHeader("Connection", "keep-alive");
77
- res.flushHeaders?.();
75
+ if (!asyncMode) {
76
+ res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
77
+ res.setHeader("Cache-Control", "no-cache, no-transform");
78
+ res.setHeader("Connection", "keep-alive");
79
+ res.flushHeaders?.();
80
+ }
78
81
  try {
79
82
  const { prompt, quality: rawQuality = "medium", size = "1024x1024", format = "png", moderation = "low", provider = "auto", references = [], mode: promptMode = "auto", model: rawModel, reasoningEffort: rawReasoningEffort, webSearchEnabled: rawWebSearchEnabled = true, } = req.body;
80
83
  const composerPrompt = normalizeComposerPrompt(req.body?.composerPrompt);
@@ -93,8 +96,12 @@ export function registerMultimodeRoutes(app, ctxRaw) {
93
96
  finishStatus = "error";
94
97
  finishHttpStatus = providerOptions.status;
95
98
  finishErrorCode = providerOptions.code;
96
- sendSse(res, "error", { error: providerOptions.error, code: providerOptions.code, status: providerOptions.status, requestId });
97
- return;
99
+ return respondMultimodeValidationError(res, requestId, asyncMode, providerOptions.status, {
100
+ error: providerOptions.error,
101
+ code: providerOptions.code,
102
+ status: providerOptions.status,
103
+ requestId,
104
+ });
98
105
  }
99
106
  const imageModel = providerOptions.model;
100
107
  const reasoningEffort = providerOptions.reasoningEffort;
@@ -105,28 +112,41 @@ export function registerMultimodeRoutes(app, ctxRaw) {
105
112
  finishStatus = "error";
106
113
  finishHttpStatus = 400;
107
114
  finishErrorCode = "PROMPT_REQUIRED";
108
- sendSse(res, "error", { error: "Prompt is required", code: finishErrorCode, status: 400, requestId });
109
- return;
115
+ return respondMultimodeValidationError(res, requestId, asyncMode, 400, {
116
+ error: "Prompt is required",
117
+ code: finishErrorCode,
118
+ status: 400,
119
+ requestId,
120
+ });
110
121
  }
111
122
  const moderationCheck = validateModeration(ctx, moderation);
112
123
  if (moderationCheck.error) {
113
124
  finishStatus = "error";
114
125
  finishHttpStatus = 400;
115
126
  finishErrorCode = "INVALID_MODERATION";
116
- sendSse(res, "error", { error: moderationCheck.error, code: finishErrorCode, status: 400, requestId });
117
- return;
127
+ return respondMultimodeValidationError(res, requestId, asyncMode, 400, {
128
+ error: moderationCheck.error,
129
+ code: finishErrorCode,
130
+ status: 400,
131
+ requestId,
132
+ });
118
133
  }
119
134
  const refCheckResult = validateAndNormalizeRefs(references);
120
135
  if (refCheckResult.error) {
121
136
  finishStatus = "error";
122
137
  finishHttpStatus = 400;
123
138
  finishErrorCode = refCheckResult.code;
124
- sendSse(res, "error", { error: refCheckResult.error, code: refCheckResult.code, status: 400, requestId });
125
- return;
139
+ return respondMultimodeValidationError(res, requestId, asyncMode, 400, {
140
+ error: refCheckResult.error,
141
+ code: refCheckResult.code,
142
+ status: 400,
143
+ requestId,
144
+ });
126
145
  }
127
146
  const refCheck = refCheckResult;
147
+ const incomingProviderUrl = typeof req.body?.providerUrl === "string" && req.body.providerUrl.startsWith("http") ? req.body.providerUrl : null;
128
148
  const referencePayload = summarizeReferencePayload(references);
129
- startJob({
149
+ const started = startJob({
130
150
  requestId,
131
151
  kind: "multimode",
132
152
  prompt,
@@ -143,7 +163,25 @@ export function registerMultimodeRoutes(app, ctxRaw) {
143
163
  composerInsertedPrompts,
144
164
  },
145
165
  });
166
+ if (started && isStartJobFailure(started)) {
167
+ finishStatus = "error";
168
+ finishHttpStatus = started.code === "TOO_MANY_JOBS" ? 429 : 409;
169
+ finishErrorCode = started.code;
170
+ if (started.code === "TOO_MANY_JOBS") {
171
+ res.setHeader("Retry-After", String(INFLIGHT_RETRY_AFTER_SECONDS));
172
+ }
173
+ return respondMultimodeValidationError(res, requestId, asyncMode, finishHttpStatus, {
174
+ error: started.code === "TOO_MANY_JOBS"
175
+ ? "Too many concurrent generation jobs"
176
+ : "Request ID already in use",
177
+ code: started.code,
178
+ status: finishHttpStatus,
179
+ requestId,
180
+ });
181
+ }
146
182
  registerJobAbortController(requestId, cancelController);
183
+ if (asyncMode)
184
+ res.status(202).json({ requestId });
147
185
  logEvent("multimode", "request", {
148
186
  requestId,
149
187
  quality,
@@ -158,7 +196,7 @@ export function registerMultimodeRoutes(app, ctxRaw) {
158
196
  });
159
197
  const startTime = Date.now();
160
198
  const mimeMap = { png: "image/png", jpeg: "image/jpeg", webp: "image/webp" };
161
- const mmFormat = activeProvider === "grok" ? "jpeg" : String(format);
199
+ const mmFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" ? "jpeg" : String(format);
162
200
  const mime = mimeMap[mmFormat] || "image/png";
163
201
  const sequenceId = `seq_${Date.now().toString(36)}_${randomBytes(4).toString("hex")}`;
164
202
  routeMaxImages = maxImages;
@@ -179,12 +217,13 @@ export function registerMultimodeRoutes(app, ctxRaw) {
179
217
  if (persistedIndexes.has(index))
180
218
  return;
181
219
  throwIfJobCanceled(requestId);
182
- const resultMime = activeProvider === "grok"
220
+ const resultMime = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api"
183
221
  ? (image.mime || detectImageMimeFromB64(image.b64) || mime)
184
222
  : mime;
185
- const resultFormat = activeProvider === "grok" ? imageFormatFromMime(resultMime) : mmFormat;
223
+ const resultFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" ? imageFormatFromMime(resultMime) : mmFormat;
186
224
  const rand = randomBytes(ctx.config.ids.generatedHexBytes).toString("hex");
187
225
  const filename = `${Date.now()}_${rand}_multimode_${index}.${resultFormat}`;
226
+ const createdAt = Date.now();
188
227
  const meta = {
189
228
  kind: "multimode-image",
190
229
  generationStrategy: "one-call-text-sequence",
@@ -207,22 +246,27 @@ export function registerMultimodeRoutes(app, ctxRaw) {
207
246
  moderation,
208
247
  model: activeProvider === "grok" ? (quality === "high" ? "grok-imagine-image-quality" : imageModel) : imageModel,
209
248
  provider: activeProvider,
210
- createdAt: Date.now(),
249
+ createdAt,
211
250
  usage: latestUsage,
212
251
  webSearchCalls: latestWebSearchCalls,
213
252
  webSearchEnabled,
214
253
  refsCount: refCheck.refs.length,
254
+ ...(image.providerUrl ? { providerUrl: image.providerUrl } : {}),
215
255
  };
216
256
  const rawBuffer = Buffer.from(image.b64, "base64");
217
257
  const embedded = await embedImageMetadataBestEffort(rawBuffer, resultFormat, meta, {
218
258
  version: ctx.packageVersion,
219
259
  });
220
- await writeFile(join(ctx.config.storage.generatedDir, filename), embedded.buffer);
221
- await safeWriteSidecar(join(ctx.config.storage.generatedDir, filename + ".json"), meta);
260
+ const mmFilePath = join(ctx.config.storage.generatedDir, filename);
261
+ await writeFile(mmFilePath, embedded.buffer);
262
+ await safeWriteSidecar(mmFilePath + ".json", meta);
263
+ generateImageThumbnailFromBuffer(embedded.buffer, mmFilePath).catch(() => { });
222
264
  invalidateHistoryIndex();
223
265
  const item = {
224
266
  image: `data:${resultMime};base64,${image.b64}`,
225
267
  filename,
268
+ createdAt,
269
+ ...(image.providerUrl ? { providerUrl: image.providerUrl } : {}),
226
270
  revisedPrompt: image.revisedPrompt || null,
227
271
  sequenceId,
228
272
  sequenceIndex: index + 1,
@@ -232,19 +276,50 @@ export function registerMultimodeRoutes(app, ctxRaw) {
232
276
  };
233
277
  persistedIndexes.add(index);
234
278
  images.push(item);
235
- sendSse(res, "image", item);
279
+ dualEmitMultimode(res, requestId, "image", item);
236
280
  };
237
- sendSse(res, "phase", { phase: "streaming", requestId, sequenceId, maxImages });
281
+ dualEmitMultimode(res, requestId, "phase", { phase: "streaming", requestId, sequenceId, maxImages });
238
282
  let generated;
239
- if (activeProvider === "grok") {
283
+ if (activeProvider === "gemini-api") {
284
+ const r = await generateViaGeminiApi(prompt, requireRuntimeContext(ctx), {
285
+ model: imageModel,
286
+ size: effectiveSize,
287
+ signal: cancelController.signal,
288
+ requestId,
289
+ references: refCheck.refDetails,
290
+ });
291
+ generated = {
292
+ images: [{ b64: r.b64, revisedPrompt: r.revisedPrompt }],
293
+ usage: r.usage,
294
+ webSearchCalls: r.webSearchCalls,
295
+ };
296
+ }
297
+ else if (activeProvider === "agy") {
298
+ const r = await generateViaAgy(prompt, {
299
+ references: refCheck.refDetails,
300
+ signal: cancelController.signal,
301
+ requestId,
302
+ });
303
+ generated = {
304
+ images: [{ b64: r.b64, revisedPrompt: r.revisedPrompt }],
305
+ usage: r.usage,
306
+ webSearchCalls: r.webSearchCalls,
307
+ };
308
+ }
309
+ else if (activeProvider === "grok" || activeProvider === "grok-api") {
310
+ const directApiKey = activeProvider === "grok-api" ? ctx.xaiApiKey : undefined;
240
311
  const grokModel = quality === "high" ? "grok-imagine-image-quality" : imageModel;
312
+ const grokRefs = incomingProviderUrl
313
+ ? [{ b64: "", url: incomingProviderUrl }, ...refCheck.refDetails]
314
+ : refCheck.refDetails;
241
315
  generated = await generateMultimodeViaGrok(prompt, ctx, {
242
316
  model: grokModel,
243
317
  maxImages,
244
318
  size: effectiveSize,
245
319
  signal: cancelController.signal,
246
320
  requestId,
247
- references: refCheck.refDetails,
321
+ references: grokRefs,
322
+ directApiKey,
248
323
  onFinalImage: async (image, index) => {
249
324
  const totalReturned = Math.max(index + 1, images.length + 1);
250
325
  await persistAndSendImage(image, index, totalReturned, sequenceStatus(totalReturned, maxImages));
@@ -257,14 +332,14 @@ export function registerMultimodeRoutes(app, ctxRaw) {
257
332
  maxImages,
258
333
  reasoningEffort,
259
334
  webSearchEnabled,
260
- onPartialImage: (partial) => isJobCanceled(requestId)
261
- ? undefined
262
- : sendSse(res, "partial", {
263
- image: `data:${mime};base64,${partial.b64}`,
264
- requestId,
265
- sequenceId,
266
- index: partial.index,
267
- }),
335
+ onPartialImage: (partial) => {
336
+ if (isJobCanceled(requestId))
337
+ return;
338
+ const pd = { image: `data:${mime};base64,${partial.b64}`, requestId, sequenceId, index: partial.index };
339
+ if (!res.writableEnded && !res.destroyed)
340
+ writeSse(res, "partial", pd);
341
+ publish(requestId, "partial", pd);
342
+ },
268
343
  onFinalImage: async (image, index) => {
269
344
  const totalReturned = Math.max(index + 1, images.length + 1);
270
345
  await persistAndSendImage(image, index, totalReturned, sequenceStatus(totalReturned, maxImages));
@@ -287,7 +362,7 @@ export function registerMultimodeRoutes(app, ctxRaw) {
287
362
  finishHttpStatus = 422;
288
363
  finishErrorCode = "EMPTY_RESPONSE";
289
364
  finishMeta = { sequenceId, filenames: [], imageCount: 0, maxImages, status, composerPrompt: routeComposerPrompt, composerInsertedPrompts: routeComposerInsertedPrompts };
290
- sendSse(res, "error", {
365
+ dualEmitMultimode(res, requestId, "error", {
291
366
  error: "No image data returned from the multimode stream",
292
367
  code: finishErrorCode,
293
368
  status: finishHttpStatus,
@@ -306,7 +381,7 @@ export function registerMultimodeRoutes(app, ctxRaw) {
306
381
  composerInsertedPrompts: routeComposerInsertedPrompts,
307
382
  };
308
383
  finishHttpStatus = 200;
309
- sendSse(res, "done", {
384
+ dualEmitMultimode(res, requestId, "done", {
310
385
  ok: true,
311
386
  requestId,
312
387
  sequenceId,
@@ -345,7 +420,7 @@ export function registerMultimodeRoutes(app, ctxRaw) {
345
420
  finishCanceled = true;
346
421
  finishHttpStatus = canceled.status;
347
422
  finishErrorCode = canceled.code;
348
- sendSse(res, "error", {
423
+ dualEmitMultimode(res, requestId, "error", {
349
424
  error: canceled.message,
350
425
  code: canceled.code,
351
426
  status: canceled.status,
@@ -368,7 +443,7 @@ export function registerMultimodeRoutes(app, ctxRaw) {
368
443
  composerPrompt: routeComposerPrompt,
369
444
  composerInsertedPrompts: routeComposerInsertedPrompts,
370
445
  };
371
- sendSse(res, "done", {
446
+ dualEmitMultimode(res, requestId, "done", {
372
447
  ok: true,
373
448
  partial: true,
374
449
  requestId,
@@ -406,7 +481,7 @@ export function registerMultimodeRoutes(app, ctxRaw) {
406
481
  finishHttpStatus = err.status || 500;
407
482
  finishErrorCode = fallbackCode || "MULTIMODE_GENERATE_FAILED";
408
483
  logError("multimode", "error", err.raw, { requestId, code: finishErrorCode });
409
- sendSse(res, "error", {
484
+ dualEmitMultimode(res, requestId, "error", {
410
485
  error: err.message,
411
486
  code: finishErrorCode,
412
487
  status: finishHttpStatus,
@@ -424,7 +499,8 @@ export function registerMultimodeRoutes(app, ctxRaw) {
424
499
  errorCode: finishErrorCode,
425
500
  meta: finishMeta,
426
501
  });
427
- res.end();
502
+ if (!res.writableEnded)
503
+ res.end();
428
504
  }
429
505
  });
430
506
  }