ima2-gen 2.0.0 → 2.0.1

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 (64) hide show
  1. package/README.md +2 -11
  2. package/bin/commands/backfillThumbs.js +18 -0
  3. package/bin/commands/edit.js +7 -6
  4. package/bin/commands/gen.js +7 -6
  5. package/bin/commands/multimode.js +5 -4
  6. package/bin/commands/node.js +4 -4
  7. package/bin/ima2.js +7 -1
  8. package/bin/lib/config-store.js +1 -1
  9. package/docs/API.md +55 -4
  10. package/docs/CLI.md +9 -3
  11. package/docs/PROMPT_STUDIO.md +3 -1
  12. package/docs/migration/runtime-test-inventory.md +3 -1
  13. package/lib/agentRuntime.js +22 -16
  14. package/lib/agentSettings.js +1 -1
  15. package/lib/agyImageAdapter.js +232 -0
  16. package/lib/capabilities.js +2 -1
  17. package/lib/configKeys.js +1 -1
  18. package/lib/geminiApiImageAdapter.js +183 -0
  19. package/lib/grokImageAdapter.js +16 -9
  20. package/lib/grokMultimodeAdapter.js +2 -1
  21. package/lib/grokRuntime.js +3 -0
  22. package/lib/grokSizeMapper.js +13 -1
  23. package/lib/grokVideoAdapter.js +14 -7
  24. package/lib/historyList.js +18 -2
  25. package/lib/imageModels.js +15 -0
  26. package/lib/imageThumb.js +38 -0
  27. package/lib/providerOptions.js +36 -1
  28. package/lib/responsesFallback.js +52 -44
  29. package/lib/runtimeContext.js +27 -0
  30. package/lib/storageMigration.js +1 -1
  31. package/lib/thumbBackfill.js +59 -0
  32. package/lib/vertexAuth.js +44 -0
  33. package/lib/videoThumb.js +60 -0
  34. package/package.json +4 -2
  35. package/routes/auth.js +238 -0
  36. package/routes/edit.js +41 -7
  37. package/routes/generate.js +40 -12
  38. package/routes/history.js +13 -0
  39. package/routes/index.js +4 -0
  40. package/routes/keys.js +254 -0
  41. package/routes/multimode.js +39 -6
  42. package/routes/nodes.js +57 -35
  43. package/routes/quota.js +58 -7
  44. package/routes/video.js +7 -3
  45. package/server.js +123 -0
  46. package/ui/dist/.vite/manifest.json +12 -12
  47. package/ui/dist/assets/AgentWorkspace-CYv84Rus.js +3 -0
  48. package/ui/dist/assets/{CardNewsWorkspace-BN-ga1lG.js → CardNewsWorkspace-Dqyc1WZ1.js} +2 -2
  49. package/ui/dist/assets/{NodeCanvas-BbMa4IhI.js → NodeCanvas-ChEXzQbb.js} +2 -2
  50. package/ui/dist/assets/{PromptBuilderPanel-DRwBJRDQ.js → PromptBuilderPanel-B95ZufnR.js} +1 -1
  51. package/ui/dist/assets/{PromptImportDialog-Dp85kHCq.js → PromptImportDialog-DGOwFQET.js} +2 -2
  52. package/ui/dist/assets/{PromptImportDiscoverySection-BE8Q8MLD.js → PromptImportDiscoverySection-CgvdnR49.js} +1 -1
  53. package/ui/dist/assets/{PromptImportFolderSection-PtH5x0sc.js → PromptImportFolderSection-CfUye9J8.js} +1 -1
  54. package/ui/dist/assets/{PromptLibraryPanel-FnM9tHI9.js → PromptLibraryPanel-B9kndPw1.js} +2 -2
  55. package/ui/dist/assets/SettingsWorkspace-B3tgLrmF.js +1 -0
  56. package/ui/dist/assets/index-BhcvL0g-.js +1 -0
  57. package/ui/dist/assets/index-BtK3YhJc.js +39 -0
  58. package/ui/dist/assets/index-ClOLOjnA.css +1 -0
  59. package/ui/dist/index.html +2 -2
  60. package/ui/dist/assets/AgentWorkspace-C21zqdTZ.js +0 -3
  61. package/ui/dist/assets/SettingsWorkspace-MARPGyBL.js +0 -1
  62. package/ui/dist/assets/index-BAFI6htx.js +0 -42
  63. package/ui/dist/assets/index-BSXxr_Bt.js +0 -1
  64. 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,11 +3,14 @@ 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";
12
+ import { generateViaAgy } from "../lib/agyImageAdapter.js";
13
+ import { generateViaGeminiApi } from "../lib/geminiApiImageAdapter.js";
11
14
  import { startJob, finishJob, registerJobAbortController, isJobCanceled } from "../lib/inflight.js";
12
15
  import { isGenerationCanceledError, makeGenerationCanceledError, throwIfJobCanceled, } from "../lib/generationCancel.js";
13
16
  import { logEvent, logError } from "../lib/logger.js";
@@ -158,7 +161,7 @@ export function registerMultimodeRoutes(app, ctxRaw) {
158
161
  });
159
162
  const startTime = Date.now();
160
163
  const mimeMap = { png: "image/png", jpeg: "image/jpeg", webp: "image/webp" };
161
- const mmFormat = activeProvider === "grok" ? "jpeg" : String(format);
164
+ const mmFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" ? "jpeg" : String(format);
162
165
  const mime = mimeMap[mmFormat] || "image/png";
163
166
  const sequenceId = `seq_${Date.now().toString(36)}_${randomBytes(4).toString("hex")}`;
164
167
  routeMaxImages = maxImages;
@@ -179,10 +182,10 @@ export function registerMultimodeRoutes(app, ctxRaw) {
179
182
  if (persistedIndexes.has(index))
180
183
  return;
181
184
  throwIfJobCanceled(requestId);
182
- const resultMime = activeProvider === "grok"
185
+ const resultMime = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api"
183
186
  ? (image.mime || detectImageMimeFromB64(image.b64) || mime)
184
187
  : mime;
185
- const resultFormat = activeProvider === "grok" ? imageFormatFromMime(resultMime) : mmFormat;
188
+ const resultFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" ? imageFormatFromMime(resultMime) : mmFormat;
186
189
  const rand = randomBytes(ctx.config.ids.generatedHexBytes).toString("hex");
187
190
  const filename = `${Date.now()}_${rand}_multimode_${index}.${resultFormat}`;
188
191
  const meta = {
@@ -217,8 +220,10 @@ export function registerMultimodeRoutes(app, ctxRaw) {
217
220
  const embedded = await embedImageMetadataBestEffort(rawBuffer, resultFormat, meta, {
218
221
  version: ctx.packageVersion,
219
222
  });
220
- await writeFile(join(ctx.config.storage.generatedDir, filename), embedded.buffer);
221
- await safeWriteSidecar(join(ctx.config.storage.generatedDir, filename + ".json"), meta);
223
+ const mmFilePath = join(ctx.config.storage.generatedDir, filename);
224
+ await writeFile(mmFilePath, embedded.buffer);
225
+ await safeWriteSidecar(mmFilePath + ".json", meta);
226
+ generateImageThumbnailFromBuffer(embedded.buffer, mmFilePath).catch(() => { });
222
227
  invalidateHistoryIndex();
223
228
  const item = {
224
229
  image: `data:${resultMime};base64,${image.b64}`,
@@ -236,7 +241,34 @@ export function registerMultimodeRoutes(app, ctxRaw) {
236
241
  };
237
242
  sendSse(res, "phase", { phase: "streaming", requestId, sequenceId, maxImages });
238
243
  let generated;
239
- if (activeProvider === "grok") {
244
+ if (activeProvider === "gemini-api") {
245
+ const r = await generateViaGeminiApi(prompt, requireRuntimeContext(ctx), {
246
+ model: imageModel,
247
+ size: effectiveSize,
248
+ signal: cancelController.signal,
249
+ requestId,
250
+ references: refCheck.refDetails,
251
+ });
252
+ generated = {
253
+ images: [{ b64: r.b64, revisedPrompt: r.revisedPrompt }],
254
+ usage: r.usage,
255
+ webSearchCalls: r.webSearchCalls,
256
+ };
257
+ }
258
+ else if (activeProvider === "agy") {
259
+ const r = await generateViaAgy(prompt, {
260
+ references: refCheck.refDetails,
261
+ signal: cancelController.signal,
262
+ requestId,
263
+ });
264
+ generated = {
265
+ images: [{ b64: r.b64, revisedPrompt: r.revisedPrompt }],
266
+ usage: r.usage,
267
+ webSearchCalls: r.webSearchCalls,
268
+ };
269
+ }
270
+ else if (activeProvider === "grok" || activeProvider === "grok-api") {
271
+ const directApiKey = activeProvider === "grok-api" ? ctx.xaiApiKey : undefined;
240
272
  const grokModel = quality === "high" ? "grok-imagine-image-quality" : imageModel;
241
273
  generated = await generateMultimodeViaGrok(prompt, ctx, {
242
274
  model: grokModel,
@@ -245,6 +277,7 @@ export function registerMultimodeRoutes(app, ctxRaw) {
245
277
  signal: cancelController.signal,
246
278
  requestId,
247
279
  references: refCheck.refDetails,
280
+ directApiKey,
248
281
  onFinalImage: async (image, index) => {
249
282
  const totalReturned = Math.max(index + 1, images.length + 1);
250
283
  await persistAndSendImage(image, index, totalReturned, sequenceStatus(totalReturned, maxImages));
package/routes/nodes.js CHANGED
@@ -8,6 +8,8 @@ import { normalizeOAuthParams } from "../lib/oauthNormalize.js";
8
8
  import { resolveProviderOptions } from "../lib/providerOptions.js";
9
9
  import { generateViaResponses, editViaResponses } from "../lib/responsesImageAdapter.js";
10
10
  import { generateViaGrok } from "../lib/grokImageAdapter.js";
11
+ import { generateViaAgy } from "../lib/agyImageAdapter.js";
12
+ import { generateViaGeminiApi } from "../lib/geminiApiImageAdapter.js";
11
13
  import { isNonRetryableGenerationError, normalizeGenerationFailure } from "../lib/generationErrors.js";
12
14
  import { logEvent, logError } from "../lib/logger.js";
13
15
  import { errInfo } from "../lib/errInfo.js";
@@ -135,7 +137,7 @@ export function registerNodeRoutes(app, ctxRaw) {
135
137
  const effectiveSize = providerOptions.size;
136
138
  const webSearchEnabled = providerOptions.webSearchEnabled;
137
139
  const activeProvider = providerOptions.provider;
138
- const effectiveImageModel = activeProvider === "grok" && quality === "high"
140
+ const effectiveImageModel = (activeProvider === "grok" || activeProvider === "grok-api") && quality === "high"
139
141
  ? "grok-imagine-image-quality"
140
142
  : imageModel;
141
143
  if (contextMode === "ancestry") {
@@ -193,16 +195,16 @@ export function registerNodeRoutes(app, ctxRaw) {
193
195
  const refsForRequest = contextMode === "parent-only" ? [] : (refCheck.refDetails || refCheck.refs);
194
196
  const parentImagePresent = !!parentB64;
195
197
  const inputImageCount = (parentImagePresent ? 1 : 0) + refsForRequest.length;
196
- if (activeProvider === "grok" && inputImageCount > 3) {
198
+ if ((activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api") && inputImageCount > 3) {
197
199
  finishStatus = "error";
198
200
  finishHttpStatus = 400;
199
- finishErrorCode = "GROK_REF_TOO_MANY";
201
+ const code = activeProvider === "agy" ? "AGY_REF_TOO_MANY" : "GROK_REF_TOO_MANY";
200
202
  return res.status(400).json({
201
203
  error: {
202
- code: "GROK_REF_TOO_MANY",
203
- message: "Grok image editing supports up to 3 reference images.",
204
+ code,
205
+ message: `${activeProvider === "agy" ? "Agy" : "Grok"} image editing supports up to 3 reference images.`,
204
206
  },
205
- code: "GROK_REF_TOO_MANY",
207
+ code,
206
208
  parentNodeId,
207
209
  });
208
210
  }
@@ -238,7 +240,8 @@ export function registerNodeRoutes(app, ctxRaw) {
238
240
  writeSse(res, "phase", { requestId, phase: "streaming" });
239
241
  }
240
242
  let b64, usage, webSearchCalls = 0, revisedPrompt = null;
241
- let resultFormat = activeProvider === "grok" ? "jpeg" : format;
243
+ const grokDirectApiKey = activeProvider === "grok-api" ? ctx.xaiApiKey : undefined;
244
+ let resultFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" ? "jpeg" : format;
242
245
  const MAX_RETRIES = 1;
243
246
  let lastErr = null;
244
247
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
@@ -261,46 +264,65 @@ export function registerNodeRoutes(app, ctxRaw) {
261
264
  searchMode,
262
265
  webSearchEnabled,
263
266
  });
264
- const r = activeProvider === "grok"
265
- ? await generateViaGrok(prompt, ctx, {
267
+ const r = activeProvider === "gemini-api"
268
+ ? await generateViaGeminiApi(parentB64 ? `Edit this image: ${prompt}` : prompt, requireRuntimeContext(ctx), {
266
269
  model: effectiveImageModel,
267
270
  size: effectiveSize,
268
- requestId,
269
271
  signal: cancelController.signal,
270
- references: toGrokReferences(parentB64, refsForRequest),
272
+ requestId,
273
+ references: parentB64
274
+ ? [{ b64: parentB64, declaredMime: null, detectedMime: null }, ...(refCheck.refDetails || [])]
275
+ : refCheck.refDetails,
271
276
  })
272
- : parentB64
273
- ? await editViaResponses(activeProvider, prompt, parentB64, quality, effectiveSize, moderation, normalizedPromptMode, ctx, requestId, {
274
- model: effectiveImageModel,
275
- references: refsForRequest,
276
- searchMode,
277
- reasoningEffort,
278
- webSearchEnabled,
277
+ : activeProvider === "agy"
278
+ ? await generateViaAgy(parentB64 ? `Edit this image: ${prompt}` : prompt, {
279
+ references: parentB64
280
+ ? [{ b64: parentB64, declaredMime: null, detectedMime: null }]
281
+ : undefined,
279
282
  signal: cancelController.signal,
283
+ requestId,
280
284
  })
281
- : await generateViaResponses(activeProvider, prompt, quality, effectiveSize, moderation, refsForRequest, requestId, normalizedPromptMode, ctx, {
282
- model: effectiveImageModel,
283
- reasoningEffort,
284
- webSearchEnabled,
285
- signal: cancelController.signal,
286
- partialImages: streamResponse ? 2 : 0,
287
- onPartialImage: streamResponse
288
- ? (partial) => isJobCanceled(requestId)
289
- ? undefined
290
- : writeSse(res, "partial", {
291
- requestId,
292
- image: dataUrlFromB64(format, partial.b64),
293
- index: partial.index,
294
- })
295
- : null,
296
- });
285
+ : activeProvider === "grok" || activeProvider === "grok-api"
286
+ ? await generateViaGrok(prompt, ctx, {
287
+ model: effectiveImageModel,
288
+ size: effectiveSize,
289
+ requestId,
290
+ signal: cancelController.signal,
291
+ references: toGrokReferences(parentB64, refsForRequest),
292
+ directApiKey: grokDirectApiKey,
293
+ })
294
+ : parentB64
295
+ ? await editViaResponses(activeProvider, prompt, parentB64, quality, effectiveSize, moderation, normalizedPromptMode, ctx, requestId, {
296
+ model: effectiveImageModel,
297
+ references: refsForRequest,
298
+ searchMode,
299
+ reasoningEffort,
300
+ webSearchEnabled,
301
+ signal: cancelController.signal,
302
+ })
303
+ : await generateViaResponses(activeProvider, prompt, quality, effectiveSize, moderation, refsForRequest, requestId, normalizedPromptMode, ctx, {
304
+ model: effectiveImageModel,
305
+ reasoningEffort,
306
+ webSearchEnabled,
307
+ signal: cancelController.signal,
308
+ partialImages: streamResponse ? 2 : 0,
309
+ onPartialImage: streamResponse
310
+ ? (partial) => isJobCanceled(requestId)
311
+ ? undefined
312
+ : writeSse(res, "partial", {
313
+ requestId,
314
+ image: dataUrlFromB64(format, partial.b64),
315
+ index: partial.index,
316
+ })
317
+ : null,
318
+ });
297
319
  throwIfJobCanceled(requestId);
298
320
  if (r.b64) {
299
321
  b64 = r.b64;
300
322
  usage = r.usage;
301
323
  webSearchCalls = r.webSearchCalls || 0;
302
324
  revisedPrompt = r.revisedPrompt || null;
303
- if (activeProvider === "grok") {
325
+ if (activeProvider === "grok" || activeProvider === "grok-api" || activeProvider === "gemini-api") {
304
326
  resultFormat = imageFormatFromMime(("mime" in r ? r.mime : undefined) || detectImageMimeFromB64(r.b64) || "image/jpeg");
305
327
  }
306
328
  break;
package/routes/quota.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  function readCodexTokens() {
@@ -53,14 +53,65 @@ async function fetchCodexUsage(tokens) {
53
53
  return { provider: "codex", error: true, windows: [] };
54
54
  }
55
55
  }
56
+ function grokTierFromLimit(val) {
57
+ if (val >= 150_000)
58
+ return "SuperGrok Heavy";
59
+ if (val >= 15_000)
60
+ return "SuperGrok";
61
+ return `SuperGrok (${val} val)`;
62
+ }
63
+ async function fetchGrokBilling() {
64
+ try {
65
+ const authPath = join(homedir(), ".progrok", "auth.json");
66
+ if (!existsSync(authPath))
67
+ return { provider: "grok", authenticated: false, windows: [] };
68
+ const auth = JSON.parse(readFileSync(authPath, "utf8"));
69
+ if (!auth.accessToken)
70
+ return { provider: "grok", authenticated: false, windows: [] };
71
+ const headers = { Authorization: `Bearer ${auth.accessToken}` };
72
+ const [billingRes, userRes] = await Promise.allSettled([
73
+ fetch("https://cli-chat-proxy.grok.com/v1/billing", { headers, signal: AbortSignal.timeout(8000) }),
74
+ fetch("https://cli-chat-proxy.grok.com/v1/user", { headers, signal: AbortSignal.timeout(5000) }),
75
+ ]);
76
+ if (billingRes.status !== "fulfilled" || !billingRes.value.ok) {
77
+ return { provider: "grok", authenticated: true, windows: [] };
78
+ }
79
+ const billing = (await billingRes.value.json()).config;
80
+ const limit = billing.monthlyLimit.val;
81
+ const used = billing.used.val;
82
+ let email = null;
83
+ if (userRes.status === "fulfilled" && userRes.value.ok) {
84
+ const user = await userRes.value.json();
85
+ email = user.email ?? null;
86
+ }
87
+ const tier = grokTierFromLimit(limit);
88
+ return {
89
+ provider: "grok",
90
+ account: { email, plan: tier },
91
+ windows: [{
92
+ label: "monthly",
93
+ percent: limit > 0 ? Math.round((used / limit) * 100) : 0,
94
+ resetsAt: billing.billingPeriodEnd,
95
+ }],
96
+ billing: { usedUsd: used / 100, limitUsd: limit / 100 },
97
+ };
98
+ }
99
+ catch {
100
+ return { provider: "grok", error: true, windows: [] };
101
+ }
102
+ }
56
103
  export function registerQuotaRoutes(app, _ctx) {
57
104
  app.get("/api/quota", async (_req, res) => {
58
- const tokens = readCodexTokens();
59
- if (!tokens) {
60
- res.json({ codex: { provider: "codex", authenticated: false, windows: [] } });
61
- return;
105
+ try {
106
+ const tokens = readCodexTokens();
107
+ const [codex, grok] = await Promise.all([
108
+ tokens ? fetchCodexUsage(tokens) : Promise.resolve({ provider: "codex", authenticated: false, windows: [] }),
109
+ fetchGrokBilling(),
110
+ ]);
111
+ res.json({ codex, grok });
112
+ }
113
+ catch (e) {
114
+ res.status(500).json({ error: "Failed to fetch quota" });
62
115
  }
63
- const codex = await fetchCodexUsage(tokens);
64
- res.json({ codex });
65
116
  });
66
117
  }
package/routes/video.js CHANGED
@@ -13,6 +13,7 @@ import { extractGeneratedVideoFrameB64 } from "../lib/videoFrameExtract.js";
13
13
  import { normalizeGrokVideoModel, normalizeVideoResolution, normalizeVideoAspectRatio, normalizeVideoDuration, deriveVideoMode, clampVideoDuration, MAX_REF2V_REFERENCES, } from "../lib/imageModels.js";
14
14
  import { errInfo } from "../lib/errInfo.js";
15
15
  import { requireRuntimeContext } from "../lib/runtimeContext.js";
16
+ import { generateVideoThumbnail } from "../lib/videoThumb.js";
16
17
  function sendSse(res, event, data) {
17
18
  res.write(`event: ${event}\n`);
18
19
  res.write(`data: ${JSON.stringify(data)}\n\n`);
@@ -80,8 +81,8 @@ export function registerVideoRoutes(app, ctxRaw) {
80
81
  const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : null;
81
82
  const clientNodeId = typeof req.body?.clientNodeId === "string" ? req.body.clientNodeId : null;
82
83
  const topic = typeof req.body?.topic === "string" ? req.body.topic.trim() : "";
83
- if (provider !== "grok")
84
- return fail(400, "VIDEO_PROVIDER_UNSUPPORTED", "video generation requires provider 'grok'");
84
+ if (provider !== "grok" && provider !== "grok-api")
85
+ return fail(400, provider === "agy" ? "AGY_VIDEO_UNSUPPORTED" : "VIDEO_PROVIDER_UNSUPPORTED", provider === "agy" ? "Gemini (agy) does not support video generation" : "video generation requires provider 'grok' or 'grok-api'");
85
86
  const storyboardActive = req.body?.storyboard === true;
86
87
  const storyboardPrefix = storyboardActive
87
88
  ? [
@@ -205,6 +206,7 @@ export function registerVideoRoutes(app, ctxRaw) {
205
206
  : activePrompt;
206
207
  const effectivePrompt = storyboardPrefix + basePrompt;
207
208
  const plannerModel = typeof req.body?.plannerModel === "string" ? req.body.plannerModel.trim() : undefined;
209
+ const directApiKey = provider === "grok-api" ? ctx.xaiApiKey : undefined;
208
210
  const result = await generateVideoViaGrok(effectivePrompt, ctx, {
209
211
  model: modelCheck.model,
210
212
  mode,
@@ -217,6 +219,7 @@ export function registerVideoRoutes(app, ctxRaw) {
217
219
  requestId,
218
220
  continuityLineage: parentLineage,
219
221
  plannerModel: plannerModel || undefined,
222
+ directApiKey,
220
223
  onEvent,
221
224
  });
222
225
  const rand = randomBytes(ctx.config.ids.generatedHexBytes).toString("hex");
@@ -237,7 +240,7 @@ export function registerVideoRoutes(app, ctxRaw) {
237
240
  prompt: activePrompt,
238
241
  userPrompt: activePrompt,
239
242
  revisedPrompt: result.revisedPrompt,
240
- provider: "grok",
243
+ provider,
241
244
  model: result.effectiveModel,
242
245
  requestedModel: result.requestedModel,
243
246
  effectiveModel: result.effectiveModel,
@@ -261,6 +264,7 @@ export function registerVideoRoutes(app, ctxRaw) {
261
264
  ...(storyboardActive ? { storyboard: true } : {}),
262
265
  };
263
266
  await saveGeneratedVideoArtifact(ctx, filename, result.videoBuffer, meta);
267
+ generateVideoThumbnail(join(ctx.config.storage.generatedDir, filename)).catch(() => { });
264
268
  invalidateHistoryIndex();
265
269
  finishMeta = { filename, xaiVideoRequestId: result.xaiVideoRequestId };
266
270
  logEvent("video", "saved", { requestId, filename, bytes: result.videoBuffer.length, elapsedMs: Date.now() - startTime });