ima2-gen 2.0.1 → 2.0.3

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 (100) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +10 -1
  3. package/bin/commands/backfillThumbs.js +6 -0
  4. package/bin/commands/gen.js +13 -5
  5. package/bin/commands/multimode.js +7 -5
  6. package/bin/commands/node.js +2 -1
  7. package/bin/ima2.js +14 -10
  8. package/config.js +8 -1
  9. package/docs/API.md +133 -9
  10. package/docs/CLI.md +6 -5
  11. package/docs/FAQ.ko.md +16 -0
  12. package/docs/FAQ.md +30 -0
  13. package/docs/README.ko.md +7 -3
  14. package/docs/migration/runtime-test-inventory.md +25 -1
  15. package/lib/agentCommandParser.js +10 -6
  16. package/lib/agentGenerationPlanner.js +96 -19
  17. package/lib/agentImageVideoGen.js +281 -0
  18. package/lib/agentPlannerModel.js +172 -0
  19. package/lib/agentQuestionResponder.js +9 -3
  20. package/lib/agentQueueStore.js +42 -0
  21. package/lib/agentQueueWorker.js +57 -7
  22. package/lib/agentRuntime.js +103 -274
  23. package/lib/agentSettings.js +9 -6
  24. package/lib/agentToolManifest.js +90 -0
  25. package/lib/agentTypes.js +1 -0
  26. package/lib/agyCli.js +31 -0
  27. package/lib/agyImageAdapter.js +104 -12
  28. package/lib/capabilities.js +7 -5
  29. package/lib/configKeys.js +6 -0
  30. package/lib/errorClassify.js +8 -7
  31. package/lib/eventBus.js +71 -0
  32. package/lib/geminiApiImageAdapter.js +24 -21
  33. package/lib/generationErrors.js +3 -1
  34. package/lib/generationRequestLog.js +25 -0
  35. package/lib/grokImageAdapter.js +76 -144
  36. package/lib/grokImageCore.js +153 -0
  37. package/lib/grokMultimodeAdapter.js +7 -4
  38. package/lib/grokVideoAdapter.js +3 -2
  39. package/lib/grokVideoCanvas.js +13 -0
  40. package/lib/grokVideoPlannerPrompt.js +52 -12
  41. package/lib/historyList.js +1 -0
  42. package/lib/inflight.js +54 -17
  43. package/lib/multimodeHelpers.js +11 -0
  44. package/lib/nodeHelpers.js +59 -0
  45. package/lib/oauthProxy/generators.js +6 -5
  46. package/lib/oauthProxy/prompts.js +60 -41
  47. package/lib/oauthProxy/streams.js +1 -1
  48. package/lib/promptBuilder/systemPrompt.js +2 -5
  49. package/lib/promptSafetyPolicy.js +1 -5
  50. package/lib/responsesDoctor.js +3 -3
  51. package/lib/responsesFallback.js +29 -15
  52. package/lib/responsesImageAdapter.js +11 -7
  53. package/lib/routeHelpers.js +44 -0
  54. package/lib/ssePublish.js +12 -0
  55. package/lib/storyboardPrefix.js +28 -0
  56. package/lib/thumbBackfill.js +16 -5
  57. package/node_modules/qs/CHANGELOG.md +10 -0
  58. package/node_modules/qs/README.md +1 -1
  59. package/node_modules/qs/dist/qs.js +15 -15
  60. package/node_modules/qs/eslint.config.mjs +1 -0
  61. package/node_modules/qs/lib/parse.js +52 -22
  62. package/node_modules/qs/lib/stringify.js +11 -4
  63. package/node_modules/qs/package.json +2 -2
  64. package/node_modules/qs/test/parse.js +49 -0
  65. package/node_modules/qs/test/stringify.js +129 -0
  66. package/package.json +4 -1
  67. package/routes/agent.js +19 -2
  68. package/routes/agy.js +48 -0
  69. package/routes/auth.js +6 -2
  70. package/routes/edit.js +7 -1
  71. package/routes/events.js +78 -0
  72. package/routes/generate.js +111 -128
  73. package/routes/generationRequestLog.js +16 -0
  74. package/routes/index.js +6 -0
  75. package/routes/keys.js +22 -2
  76. package/routes/multimode.js +100 -57
  77. package/routes/nodes.js +64 -108
  78. package/routes/video.js +100 -17
  79. package/skills/ima2/SKILL.md +98 -21
  80. package/ui/dist/.vite/manifest.json +22 -12
  81. package/ui/dist/assets/AgentWorkspace-qEMrATBP.js +3 -0
  82. package/ui/dist/assets/{CardNewsWorkspace-Dqyc1WZ1.js → CardNewsWorkspace-DoJnVHmn.js} +1 -1
  83. package/ui/dist/assets/GenerationRequestLogPanel-BDWRRLNV.js +1 -0
  84. package/ui/dist/assets/{NodeCanvas-ChEXzQbb.js → NodeCanvas-DIMo45sp.js} +3 -3
  85. package/ui/dist/assets/{PromptBuilderPanel-B95ZufnR.js → PromptBuilderPanel-BF3lcSJD.js} +2 -2
  86. package/ui/dist/assets/{PromptImportDialog-DGOwFQET.js → PromptImportDialog-Cy6ZymKG.js} +2 -2
  87. package/ui/dist/assets/{PromptImportDiscoverySection-CgvdnR49.js → PromptImportDiscoverySection-CzuW-8P2.js} +1 -1
  88. package/ui/dist/assets/PromptImportFolderSection-DP5ywnsv.js +1 -0
  89. package/ui/dist/assets/{PromptLibraryPanel-B9kndPw1.js → PromptLibraryPanel-CAsKr7CV.js} +2 -2
  90. package/ui/dist/assets/SettingsWorkspace-_PdPVsxi.js +1 -0
  91. package/ui/dist/assets/index-BUinlX2n.js +4 -0
  92. package/ui/dist/assets/index-CX3fge8X.css +1 -0
  93. package/ui/dist/assets/index-ygo6nfqx.js +23 -0
  94. package/ui/dist/index.html +2 -2
  95. package/ui/dist/assets/AgentWorkspace-CYv84Rus.js +0 -3
  96. package/ui/dist/assets/PromptImportFolderSection-CfUye9J8.js +0 -1
  97. package/ui/dist/assets/SettingsWorkspace-B3tgLrmF.js +0 -1
  98. package/ui/dist/assets/index-BhcvL0g-.js +0 -1
  99. package/ui/dist/assets/index-BtK3YhJc.js +0 -39
  100. package/ui/dist/assets/index-ClOLOjnA.css +0 -1
@@ -5,6 +5,7 @@ import { randomBytes } from "crypto";
5
5
  import { detectImageMimeFromB64, summarizeReferencePayload, validateAndNormalizeRefs } from "../lib/refs.js";
6
6
  import { generateImageThumbnailFromBuffer } from "../lib/imageThumb.js";
7
7
  import { classifyUpstreamError } from "../lib/errorClassify.js";
8
+ import { appendGenerationRequestLog } from "../lib/generationRequestLog.js";
8
9
  import { normalizeOAuthParams } from "../lib/oauthNormalize.js";
9
10
  import { resolveProviderOptions } from "../lib/providerOptions.js";
10
11
  import { generateViaResponses } from "../lib/responsesImageAdapter.js";
@@ -12,7 +13,7 @@ import { generateViaGrok, planGrokImage } from "../lib/grokImageAdapter.js";
12
13
  import { generateViaAgy } from "../lib/agyImageAdapter.js";
13
14
  import { generateViaGeminiApi } from "../lib/geminiApiImageAdapter.js";
14
15
  import { isNonRetryableGenerationError, normalizeGenerationFailure } from "../lib/generationErrors.js";
15
- import { startJob, finishJob, registerJobAbortController, isJobCanceled } from "../lib/inflight.js";
16
+ import { startJob, finishJob, registerJobAbortController, isJobCanceled, isStartJobFailure, setJobPhase, INFLIGHT_RETRY_AFTER_SECONDS, } from "../lib/inflight.js";
16
17
  import { isGenerationCanceledError, makeGenerationCanceledError, throwIfJobCanceled, } from "../lib/generationCancel.js";
17
18
  import { logEvent, logError } from "../lib/logger.js";
18
19
  import { embedImageMetadataBestEffort } from "../lib/imageMetadataStore.js";
@@ -20,57 +21,44 @@ import { invalidateHistoryIndex } from "../lib/historyIndex.js";
20
21
  import { normalizeComposerInsertedPrompts, normalizeComposerPrompt, } from "../lib/composerSnapshot.js";
21
22
  import { errInfo } from "../lib/errInfo.js";
22
23
  import { requireRuntimeContext } from "../lib/runtimeContext.js";
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" };
26
- }
27
- return { moderation };
28
- }
29
- function imageFormatFromMime(mime) {
30
- if (mime === "image/jpeg")
31
- return "jpeg";
32
- if (mime === "image/webp")
33
- return "webp";
34
- return "png";
35
- }
24
+ import { STORYBOARD_PREFIX } from "../lib/storyboardPrefix.js";
25
+ import { validateModeration, imageFormatFromMime, upstreamErrorFields } from "../lib/routeHelpers.js";
26
+ import { publish } from "../lib/eventBus.js";
27
+ import { publishJobEvent } from "../lib/ssePublish.js";
36
28
  export function registerGenerateRoutes(app, ctxRaw) {
37
29
  const ctx = requireRuntimeContext(ctxRaw);
38
30
  app.post("/api/generate", async (req, res) => {
39
31
  const requestId = typeof req.body?.requestId === "string" ? req.body.requestId : req.id;
32
+ const asyncMode = req.body?.async === true;
40
33
  let finishStatus = "completed";
41
34
  let finishHttpStatus;
42
35
  let finishErrorCode;
43
36
  let finishMeta = {};
44
37
  let finishCanceled = false;
45
38
  const cancelController = new AbortController();
39
+ const fail = (status, payload) => {
40
+ finishStatus = "error";
41
+ finishHttpStatus = status;
42
+ finishErrorCode = typeof payload.code === "string" ? payload.code : finishErrorCode;
43
+ if (asyncMode && res.headersSent) {
44
+ publish(requestId, "error", { ...payload, status, requestId });
45
+ return;
46
+ }
47
+ return res.status(status).json(payload);
48
+ };
49
+ const succeed = (payload) => {
50
+ if (asyncMode) {
51
+ publishJobEvent(requestId, "done", payload);
52
+ return;
53
+ }
54
+ res.json(payload);
55
+ };
46
56
  try {
47
57
  const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : null;
48
58
  const clientNodeId = typeof req.body?.clientNodeId === "string" ? req.body.clientNodeId : null;
49
59
  const { prompt, quality: rawQuality = "medium", size = "1024x1024", format = "png", moderation = "low", provider = "auto", n = 1, references = [], mode: promptMode = "auto", model: rawModel, reasoningEffort: rawReasoningEffort, webSearchEnabled: rawWebSearchEnabled = true, } = req.body;
50
60
  const storyboardActive = req.body?.storyboard === true;
51
- const storyboardPrefix = storyboardActive
52
- ? [
53
- "[STORYBOARD MODE — Video Production Keyframe]",
54
- "This image is a keyframe for a multi-shot VIDEO storyboard. It will be animated via image-to-video.",
55
- "The prompt and all injected instructions MUST be in English.",
56
- "",
57
- "CHARACTER LOCK:",
58
- "- Identify each character by 2-3 VISUAL identifiers (clothing color + physique + position/props). Never by name alone.",
59
- "- Copy character descriptions VERBATIM from the reference/prior frame. Do NOT rephrase or drift.",
60
- "",
61
- "SCENE CONTINUITY:",
62
- "- Lock lighting direction, color palette, environment, and art style to prior frames.",
63
- "- Change ONLY: action, shot scale, camera angle, or expression.",
64
- "- Reference image = canonical anchor. Preserve it faithfully.",
65
- "",
66
- "VIDEO-READY COMPOSITION:",
67
- "- Frame for animation: leave space for motion, avoid static-only poses.",
68
- "- Use descriptive caption format: shot type + subject action + environment + technical (lens, lighting) + mood.",
69
- "- Specify intended camera movement for the video phase (e.g. 'slow dolly-in', 'static wide').",
70
- "- End pose must be stable and suitable for video continuation.",
71
- "",
72
- ].join("\n") + "\n"
73
- : "";
61
+ const storyboardPrefix = storyboardActive ? STORYBOARD_PREFIX : "";
74
62
  const composerPrompt = normalizeComposerPrompt(req.body?.composerPrompt);
75
63
  const composerInsertedPrompts = normalizeComposerInsertedPrompts(req.body?.composerInsertedPrompts);
76
64
  const { quality, warnings: qualityWarnings } = normalizeOAuthParams({ provider, quality: rawQuality });
@@ -82,10 +70,7 @@ export function registerGenerateRoutes(app, ctxRaw) {
82
70
  rawWebSearchEnabled,
83
71
  });
84
72
  if (providerOptions.error) {
85
- finishStatus = "error";
86
- finishHttpStatus = providerOptions.status;
87
- finishErrorCode = providerOptions.code;
88
- return res.status(providerOptions.status).json({ error: providerOptions.error, code: providerOptions.code });
73
+ return fail(providerOptions.status, { error: providerOptions.error, code: providerOptions.code });
89
74
  }
90
75
  const imageModel = providerOptions.model;
91
76
  const reasoningEffort = providerOptions.reasoningEffort;
@@ -95,13 +80,35 @@ export function registerGenerateRoutes(app, ctxRaw) {
95
80
  const normalizedPromptMode = promptMode === "direct" ? "direct" : "auto";
96
81
  const generationPrompt = storyboardPrefix + prompt;
97
82
  if (!prompt)
98
- return res.status(400).json({ error: "Prompt is required" });
83
+ return fail(400, { error: "Prompt is required" });
99
84
  const moderationCheck = validateModeration(ctx, moderation);
100
85
  if (moderationCheck.error)
101
- return res.status(400).json({ error: moderationCheck.error });
102
- const count = Math.min(Math.max(parseInt(n) || 1, 1), 8);
86
+ return fail(400, { error: moderationCheck.error });
87
+ const maxGeneratedImages = Math.max(1, Math.trunc(Number(ctx.config.limits.maxGeneratedImages) || 1));
88
+ const count = Math.min(Math.max(parseInt(n) || 1, 1), maxGeneratedImages);
103
89
  const referencePayload = summarizeReferencePayload(references);
104
- startJob({
90
+ const refCheckResult = validateAndNormalizeRefs(references);
91
+ if (refCheckResult.error) {
92
+ return fail(400, { error: refCheckResult.error, code: refCheckResult.code });
93
+ }
94
+ const refCheck = refCheckResult;
95
+ const incomingProviderUrl = typeof req.body?.providerUrl === "string" && req.body.providerUrl.startsWith("http")
96
+ ? req.body.providerUrl
97
+ : null;
98
+ const grokRefs = incomingProviderUrl
99
+ ? [{ b64: "", url: incomingProviderUrl, declaredMime: "image/png", detectedMime: "image/png" }, ...refCheck.refDetails]
100
+ : refCheck.refDetails;
101
+ const providerRefCount = activeProvider === "grok" || activeProvider === "grok-api"
102
+ ? grokRefs.length
103
+ : refCheck.refs.length;
104
+ if ((activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api") && providerRefCount > 3) {
105
+ return fail(400, {
106
+ error: `${activeProvider === "agy" ? "Agy" : "Grok"} image editing supports up to 3 reference images`,
107
+ code: activeProvider === "agy" ? "AGY_REF_TOO_MANY" : "GROK_REF_TOO_MANY",
108
+ requestId,
109
+ });
110
+ }
111
+ const started = startJob({
105
112
  requestId,
106
113
  kind: "classic",
107
114
  prompt,
@@ -114,32 +121,33 @@ export function registerGenerateRoutes(app, ctxRaw) {
114
121
  model: imageModel,
115
122
  size: effectiveSize,
116
123
  n: count,
117
- refsCount: referencePayload.refsCount,
124
+ refsCount: providerRefCount,
118
125
  referenceBytes: referencePayload.referenceBytes,
119
126
  referenceB64Chars: referencePayload.referenceB64Chars,
120
127
  composerPrompt,
121
128
  composerInsertedPrompts,
122
129
  },
123
130
  });
124
- registerJobAbortController(requestId, cancelController);
125
- const refCheckResult = validateAndNormalizeRefs(references);
126
- if (refCheckResult.error) {
127
- finishStatus = "error";
128
- finishHttpStatus = 400;
129
- finishErrorCode = refCheckResult.code;
130
- return res.status(400).json({ error: refCheckResult.error, code: refCheckResult.code });
131
- }
132
- const refCheck = refCheckResult;
133
- if ((activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api") && refCheck.refs.length > 3) {
134
- finishStatus = "error";
135
- finishHttpStatus = 400;
136
- finishErrorCode = activeProvider === "agy" ? "AGY_REF_TOO_MANY" : "GROK_REF_TOO_MANY";
137
- return res.status(400).json({
138
- error: `${activeProvider === "agy" ? "Agy" : "Grok"} image editing supports up to 3 reference images`,
139
- code: activeProvider === "agy" ? "AGY_REF_TOO_MANY" : "GROK_REF_TOO_MANY",
131
+ if (started && isStartJobFailure(started)) {
132
+ const status = started.code === "TOO_MANY_JOBS" ? 429 : 409;
133
+ if (started.code === "TOO_MANY_JOBS") {
134
+ res.setHeader("Retry-After", String(INFLIGHT_RETRY_AFTER_SECONDS));
135
+ }
136
+ return fail(status, {
137
+ error: started.code === "TOO_MANY_JOBS"
138
+ ? "Too many concurrent generation jobs"
139
+ : "Request ID already in use",
140
+ code: started.code,
140
141
  requestId,
141
142
  });
142
143
  }
144
+ registerJobAbortController(requestId, cancelController);
145
+ if (asyncMode) {
146
+ res.status(202).json({ requestId, async: true });
147
+ }
148
+ setJobPhase(requestId, "streaming");
149
+ if (asyncMode)
150
+ publish(requestId, "phase", { requestId, phase: "streaming" });
143
151
  const client = req.get("x-ima2-client") || "ui";
144
152
  const referenceDiagnostics = refCheck.referenceDiagnostics || [];
145
153
  const referenceMismatchCount = referenceDiagnostics.filter((ref) => ref.warnings?.includes("mime_mismatch")).length;
@@ -152,7 +160,7 @@ export function registerGenerateRoutes(app, ctxRaw) {
152
160
  size: effectiveSize,
153
161
  moderation,
154
162
  n: count,
155
- refs: refCheck.refs.length,
163
+ refs: providerRefCount,
156
164
  referenceBytes: referencePayload.referenceBytes,
157
165
  referenceMismatchCount,
158
166
  refDetectedMimes: [...new Set(referenceDiagnostics.map((ref) => ref.detectedMime).filter(Boolean))].join(","),
@@ -175,8 +183,8 @@ export function registerGenerateRoutes(app, ctxRaw) {
175
183
  size: effectiveSize,
176
184
  signal: cancelController.signal,
177
185
  requestId,
178
- referenceCount: refCheck.refs.length,
179
- references: refCheck.refDetails,
186
+ referenceCount: grokRefs.length,
187
+ references: grokRefs,
180
188
  directApiKey: grokDirectApiKey,
181
189
  })
182
190
  : null;
@@ -210,7 +218,7 @@ export function registerGenerateRoutes(app, ctxRaw) {
210
218
  requestId,
211
219
  plannedPrompt: sharedGrokPlan?.prompt,
212
220
  webSearchCalls: sharedGrokPlan?.webSearchCalls,
213
- references: refCheck.refDetails,
221
+ references: grokRefs,
214
222
  directApiKey: grokDirectApiKey,
215
223
  });
216
224
  throwIfJobCanceled(requestId);
@@ -275,6 +283,11 @@ export function registerGenerateRoutes(app, ctxRaw) {
275
283
  }
276
284
  const rand = randomBytes(ctx.config.ids.generatedHexBytes).toString("hex");
277
285
  const filename = `${Date.now()}_${rand}_${images.length}.${resultFormat}`;
286
+ const createdAt = Date.now();
287
+ const valueWithProviderUrl = r.value;
288
+ const providerUrl = typeof valueWithProviderUrl.providerUrl === "string"
289
+ ? valueWithProviderUrl.providerUrl
290
+ : undefined;
278
291
  const meta = {
279
292
  kind: "classic",
280
293
  requestId,
@@ -293,11 +306,12 @@ export function registerGenerateRoutes(app, ctxRaw) {
293
306
  model: activeProvider === "grok" ? (quality === "high" ? "grok-imagine-image-quality" : imageModel) : imageModel,
294
307
  reasoningEffort,
295
308
  provider: activeProvider,
296
- createdAt: Date.now(),
309
+ createdAt,
310
+ ...(providerUrl ? { providerUrl } : {}),
297
311
  usage: r.value.usage || null,
298
312
  webSearchCalls: r.value.webSearchCalls || 0,
299
313
  webSearchEnabled,
300
- refsCount: refCheck.refs.length,
314
+ refsCount: providerRefCount,
301
315
  };
302
316
  const rawBuffer = Buffer.from(r.value.b64, "base64");
303
317
  const embedded = await embedImageMetadataBestEffort(rawBuffer, resultFormat, meta, {
@@ -320,6 +334,8 @@ export function registerGenerateRoutes(app, ctxRaw) {
320
334
  image: `data:${resultMime};base64,${r.value.b64}`,
321
335
  filename,
322
336
  revisedPrompt: r.value.revisedPrompt || null,
337
+ ...(providerUrl ? { providerUrl } : {}),
338
+ createdAt,
323
339
  });
324
340
  if (r.value.usage) {
325
341
  const usageValue = r.value.usage;
@@ -349,47 +365,20 @@ export function registerGenerateRoutes(app, ctxRaw) {
349
365
  const status = firstErr.status || 500;
350
366
  if (isGenerationCanceledError(firstErr)) {
351
367
  finishCanceled = true;
352
- finishHttpStatus = firstErr.status;
353
- finishErrorCode = firstErr.code;
354
- return res.status(firstErr.status).json({
368
+ return fail(firstErr.status, {
355
369
  error: firstErr.message,
356
370
  code: firstErr.code,
357
371
  requestId,
358
372
  });
359
373
  }
360
- finishStatus = "error";
361
- finishHttpStatus = status;
362
- finishErrorCode = firstErr.code;
363
- return res.status(status).json({
374
+ return fail(status, {
364
375
  error: firstErr.message,
365
376
  code: firstErr.code,
366
- upstreamCode: firstErr.upstreamCode || null,
367
- upstreamType: firstErr.upstreamType || null,
368
- upstreamParam: firstErr.upstreamParam || null,
369
- diagnosticReason: firstErr.diagnosticReason || null,
370
- retryKind: firstErr.retryKind || null,
371
- initialEventCount: firstErr.initialEventCount ?? null,
372
- initialEventTypes: firstErr.initialEventTypes || null,
373
- referencesDroppedOnRetry: firstErr.referencesDroppedOnRetry ?? null,
374
- developerPromptDroppedOnRetry: firstErr.developerPromptDroppedOnRetry ?? null,
375
- webSearchDroppedOnRetry: firstErr.webSearchDroppedOnRetry ?? null,
376
- fallbackEventCount: firstErr.fallbackEventCount ?? null,
377
- fallbackEventTypes: firstErr.fallbackEventTypes || null,
378
- fallbackImageCallSeen: firstErr.fallbackImageCallSeen ?? null,
379
- fallbackImageResultCount: firstErr.fallbackImageResultCount ?? null,
380
- errorEventCount: firstErr.eventCount ?? null,
381
- eventTypes: firstErr.eventTypes || null,
382
- webSearchCalls: firstErr.webSearchCalls ?? null,
383
- responseDiagnostics: firstErr.responseDiagnostics || null,
384
- toolTypes: firstErr.toolTypes || null,
385
- toolChoiceKind: firstErr.toolChoiceKind || null,
377
+ ...upstreamErrorFields(firstErr),
386
378
  requestId,
387
379
  });
388
380
  }
389
- finishStatus = "error";
390
- finishHttpStatus = 500;
391
- finishErrorCode = "GENERATE_ALL_FAILED";
392
- return res.status(500).json({ error: "All generation attempts failed" });
381
+ return fail(500, { error: "All generation attempts failed", code: "GENERATE_ALL_FAILED", requestId });
393
382
  }
394
383
  const elapsed = +((Date.now() - startTime) / 1000).toFixed(1);
395
384
  // Persist elapsed (computed after the generation loop) into each image's sidecar.
@@ -431,7 +420,15 @@ export function registerGenerateRoutes(app, ctxRaw) {
431
420
  elapsedMs: Date.now() - startTime,
432
421
  filename: images[0].filename,
433
422
  });
434
- res.json({ image: images[0].image, elapsed, filename: images[0].filename, requestId, ...extra });
423
+ succeed({
424
+ image: images[0].image,
425
+ elapsed,
426
+ filename: images[0].filename,
427
+ requestId,
428
+ providerUrl: images[0].providerUrl ?? null,
429
+ createdAt: images[0].createdAt,
430
+ ...extra,
431
+ });
435
432
  }
436
433
  else {
437
434
  finishHttpStatus = 200;
@@ -441,7 +438,7 @@ export function registerGenerateRoutes(app, ctxRaw) {
441
438
  imageCount: images.length,
442
439
  elapsedMs: Date.now() - startTime,
443
440
  });
444
- res.json({ images, elapsed, count: images.length, requestId, ...extra });
441
+ succeed({ images, elapsed, count: images.length, requestId, ...extra });
445
442
  }
446
443
  }
447
444
  catch (e) {
@@ -451,41 +448,18 @@ export function registerGenerateRoutes(app, ctxRaw) {
451
448
  if (isGenerationCanceledError(err.raw) || isJobCanceled(requestId)) {
452
449
  const canceled = makeGenerationCanceledError();
453
450
  finishCanceled = true;
454
- finishHttpStatus = canceled.status;
455
- finishErrorCode = canceled.code;
456
- return res.status(canceled.status).json({
451
+ return fail(canceled.status, {
457
452
  error: canceled.message,
458
453
  code: canceled.code,
459
454
  requestId,
460
455
  });
461
456
  }
462
- finishStatus = "error";
463
- finishHttpStatus = err.status || 500;
464
457
  finishErrorCode = fallbackCode || "GENERATE_FAILED";
465
458
  logError("generate", "error", err.raw, { requestId, code: finishErrorCode });
466
- res.status(err.status || 500).json({
459
+ fail(err.status || 500, {
467
460
  error: err.message,
468
- code: fallbackCode,
469
- upstreamCode: ext.upstreamCode || null,
470
- upstreamType: ext.upstreamType || null,
471
- upstreamParam: ext.upstreamParam || null,
472
- diagnosticReason: ext.diagnosticReason || null,
473
- retryKind: ext.retryKind || null,
474
- initialEventCount: ext.initialEventCount ?? null,
475
- initialEventTypes: ext.initialEventTypes || null,
476
- referencesDroppedOnRetry: ext.referencesDroppedOnRetry ?? null,
477
- developerPromptDroppedOnRetry: ext.developerPromptDroppedOnRetry ?? null,
478
- webSearchDroppedOnRetry: ext.webSearchDroppedOnRetry ?? null,
479
- fallbackEventCount: ext.fallbackEventCount ?? null,
480
- fallbackEventTypes: ext.fallbackEventTypes || null,
481
- fallbackImageCallSeen: ext.fallbackImageCallSeen ?? null,
482
- fallbackImageResultCount: ext.fallbackImageResultCount ?? null,
483
- errorEventCount: ext.eventCount ?? null,
484
- eventTypes: ext.eventTypes || null,
485
- webSearchCalls: ext.webSearchCalls ?? null,
486
- responseDiagnostics: ext.responseDiagnostics || null,
487
- toolTypes: ext.toolTypes || null,
488
- toolChoiceKind: ext.toolChoiceKind || null,
461
+ code: finishErrorCode,
462
+ ...upstreamErrorFields(ext),
489
463
  requestId,
490
464
  });
491
465
  }
@@ -497,6 +471,15 @@ export function registerGenerateRoutes(app, ctxRaw) {
497
471
  errorCode: finishErrorCode,
498
472
  meta: finishMeta,
499
473
  });
474
+ appendGenerationRequestLog(ctx.config.storage.generationRequestLogFile, {
475
+ id: randomBytes(8).toString("hex"),
476
+ requestId,
477
+ createdAt: Date.now(),
478
+ prompt: typeof req.body?.prompt === "string" ? req.body.prompt : "",
479
+ requested: parseInt(req.body?.n) || 1,
480
+ succeeded: finishStatus === "completed" ? (finishMeta.imageCount ?? 1) : 0,
481
+ error: finishStatus === "error" ? (finishErrorCode ?? "unknown") : null,
482
+ }).catch(() => { });
500
483
  }
501
484
  });
502
485
  }
@@ -0,0 +1,16 @@
1
+ import { listGenerationRequestLog } from "../lib/generationRequestLog.js";
2
+ import { requireRuntimeContext } from "../lib/runtimeContext.js";
3
+ export function registerGenerationRequestLogRoutes(app, ctxRaw) {
4
+ const ctx = requireRuntimeContext(ctxRaw);
5
+ app.get("/api/generation-requests", async (_req, res) => {
6
+ try {
7
+ const items = await listGenerationRequestLog(ctx.config.storage.generationRequestLogFile);
8
+ res.json({ items });
9
+ }
10
+ catch (error) {
11
+ res.status(500).json({
12
+ error: error instanceof Error ? error.message : "Could not read generation request log",
13
+ });
14
+ }
15
+ });
16
+ }
package/routes/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { registerCapabilitiesRoutes } from "./capabilities.js";
2
+ import { registerEventsRoute } from "./events.js";
2
3
  import { registerHealthRoutes } from "./health.js";
3
4
  import { registerHistoryRoutes } from "./history.js";
4
5
  import { registerSessionRoutes } from "./sessions.js";
@@ -18,14 +19,17 @@ import { registerImageImportRoutes } from "./imageImport.js";
18
19
  import { registerPromptBuilderRoutes } from "./promptBuilder.js";
19
20
  import { registerAgentRoutes } from "./agent.js";
20
21
  import { registerGrokRoutes } from "./grok.js";
22
+ import { registerAgyRoutes } from "./agy.js";
21
23
  import { registerVideoRoutes } from "./video.js";
22
24
  import { registerVideoExtendedRoutes } from "./videoExtended.js";
23
25
  import { registerQuotaRoutes } from "./quota.js";
24
26
  import { registerAuthRoutes } from "./auth.js";
25
27
  import { mountKeyRoutes } from "./keys.js";
28
+ import { registerGenerationRequestLogRoutes } from "./generationRequestLog.js";
26
29
  import { requireRuntimeContext } from "../lib/runtimeContext.js";
27
30
  export function configureRoutes(app, ctxRaw) {
28
31
  const ctx = requireRuntimeContext(ctxRaw);
32
+ registerEventsRoute(app, ctx);
29
33
  registerHealthRoutes(app, ctx);
30
34
  registerCapabilitiesRoutes(app, ctx);
31
35
  registerStorageRoutes(app, ctx);
@@ -47,9 +51,11 @@ export function configureRoutes(app, ctxRaw) {
47
51
  registerPromptRoutes(app, ctx);
48
52
  registerPromptImportRoutes(app, ctx);
49
53
  registerGrokRoutes(app, ctx);
54
+ registerAgyRoutes(app);
50
55
  registerVideoRoutes(app, ctx);
51
56
  registerVideoExtendedRoutes(app, ctx);
52
57
  registerQuotaRoutes(app, ctx);
53
58
  registerAuthRoutes(app);
54
59
  mountKeyRoutes(app, ctx);
60
+ registerGenerationRequestLogRoutes(app, ctx);
55
61
  }
package/routes/keys.js CHANGED
@@ -62,8 +62,28 @@ export function mountKeyRoutes(app, ctx) {
62
62
  valid: !!vertexJson,
63
63
  maskedKey: ctx.vertexProjectId ? `project: ${ctx.vertexProjectId}` : null,
64
64
  };
65
+ status.geminiAuthMode = ctx.geminiAuthMode
66
+ || (vertexJson && !ctx.geminiApiKey ? "vertex" : "apikey");
65
67
  res.json(status);
66
68
  });
69
+ // Persist the Gemini auth mode chosen in the settings dropdown, so reopening
70
+ // settings (or restarting the server) keeps the user's selection.
71
+ app.put("/api/keys/gemini-auth-mode", async (req, res) => {
72
+ const { mode } = req.body;
73
+ if (mode !== "apikey" && mode !== "vertex") {
74
+ return res.status(400).json({ ok: false, error: "mode must be apikey|vertex", code: "INVALID_MODE" });
75
+ }
76
+ const cfgPath = ctx.config.storage.configFile;
77
+ let existing = {};
78
+ try {
79
+ existing = JSON.parse(await readFile(cfgPath, "utf-8"));
80
+ }
81
+ catch { /* new file */ }
82
+ existing.geminiAuthMode = mode;
83
+ await writeConfigAtomic(cfgPath, existing);
84
+ ctx.geminiAuthMode = mode;
85
+ return res.json({ ok: true, geminiAuthMode: mode });
86
+ });
67
87
  // Vertex JSON — dedicated route (before generic :provider)
68
88
  app.put("/api/keys/vertex", async (req, res) => {
69
89
  const { serviceAccountJson } = req.body;
@@ -187,6 +207,8 @@ export function mountKeyRoutes(app, ctx) {
187
207
  }
188
208
  catch { /* new file */ }
189
209
  existing[CONFIG_KEY_MAP[provider]] = trimmed;
210
+ if (provider === "gemini")
211
+ existing.geminiAuthMode = "apikey";
190
212
  await writeConfigAtomic(cfgPath, existing);
191
213
  // Hot-update runtime context
192
214
  if (provider === "openai") {
@@ -209,8 +231,6 @@ export function mountKeyRoutes(app, ctx) {
209
231
  ctx.geminiApiKeySource = "config";
210
232
  ctx.hasGeminiApiKey = true;
211
233
  ctx.geminiAuthMode = "apikey";
212
- existing[CONFIG_KEY_MAP[provider]] = trimmed;
213
- existing.geminiAuthMode = "apikey";
214
234
  }
215
235
  return res.json({ ok: true, provider, source: "config", valid: true });
216
236
  });