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/nodes.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { mkdir } from "fs/promises";
2
- import { newNodeId, saveNode, loadNodeB64, loadNodeMeta, loadAssetB64, } from "../lib/nodeStore.js";
3
- import { startJob, finishJob, registerJobAbortController, isJobCanceled } from "../lib/inflight.js";
2
+ import { newNodeId, saveNode, loadNodeMeta, loadAssetB64, } from "../lib/nodeStore.js";
3
+ import { startJob, finishJob, registerJobAbortController, isJobCanceled, isStartJobFailure, INFLIGHT_RETRY_AFTER_SECONDS } from "../lib/inflight.js";
4
4
  import { isGenerationCanceledError, makeGenerationCanceledError, throwIfJobCanceled, } from "../lib/generationCancel.js";
5
5
  import { detectImageMimeFromB64, summarizeReferencePayload, validateAndNormalizeRefs } from "../lib/refs.js";
6
6
  import { classifyUpstreamError } from "../lib/errorClassify.js";
@@ -8,76 +8,22 @@ 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";
14
16
  import { requireRuntimeContext } from "../lib/runtimeContext.js";
15
- function asUpstream(e) {
16
- return (e && typeof e === "object" ? e : {});
17
- }
18
- function validateModeration(ctx, moderation) {
19
- if (typeof moderation !== "string" || !ctx.config.oauth.validModeration.has(moderation)) {
20
- return { error: "moderation must be one of: auto, low" };
21
- }
22
- return { moderation };
23
- }
24
- function wantsSse(req) {
25
- const accept = typeof req.headers.accept === "string" ? req.headers.accept : "";
26
- return accept.includes("text/event-stream");
27
- }
28
- function writeSse(res, event, data) {
29
- res.write(`event: ${event}\n`);
30
- res.write(`data: ${JSON.stringify(data)}\n\n`);
31
- }
32
- function writeNodeError(res, status, code, message, parentNodeId, details = {}) {
33
- if (res.headersSent) {
34
- writeSse(res, "error", {
35
- error: { code, message },
36
- parentNodeId,
37
- status,
38
- ...details,
39
- });
40
- res.end();
41
- return;
42
- }
43
- res.status(status).json({
44
- error: { code, message },
45
- parentNodeId,
46
- status,
47
- ...details,
48
- });
49
- }
50
- function dataUrlFromB64(format, b64) {
51
- return `data:image/${format === "jpeg" ? "jpeg" : format};base64,${b64}`;
52
- }
53
- function imageFormatFromMime(mime) {
54
- if (mime === "image/jpeg")
55
- return "jpeg";
56
- if (mime === "image/webp")
57
- return "webp";
58
- return "png";
59
- }
60
- async function loadParentNodeB64(ctx, nodeId) {
61
- for (const ext of ["png", "jpeg", "webp"]) {
62
- const meta = await loadNodeMeta(ctx.rootDir, nodeId, ext, ctx.config.storage.generatedDir);
63
- if (meta)
64
- return loadNodeB64(ctx.rootDir, `${nodeId}.${ext}`, ctx.config.storage.generatedDir);
65
- }
66
- return loadNodeB64(ctx.rootDir, `${nodeId}.png`, ctx.config.storage.generatedDir);
67
- }
68
- function toGrokReferences(parentB64, refs) {
69
- const parentMime = parentB64 ? detectImageMimeFromB64(parentB64) : null;
70
- const parentRefs = parentB64
71
- ? [{ b64: parentB64, declaredMime: parentMime, detectedMime: parentMime }]
72
- : [];
73
- const normalizedRefs = refs.map((ref) => typeof ref === "string" ? { b64: ref } : ref);
74
- return [...parentRefs, ...normalizedRefs];
75
- }
17
+ import { validateModeration, imageFormatFromMime, writeSse, dataUrlFromB64 } from "../lib/routeHelpers.js";
18
+ import { publish } from "../lib/eventBus.js";
19
+ import { publishJobEvent } from "../lib/ssePublish.js";
20
+ import { asUpstream, wantsSse, writeNodeError, loadParentNodeB64, toGrokReferences, nodeErrorDetails, } from "../lib/nodeHelpers.js";
76
21
  export function registerNodeRoutes(app, ctxRaw) {
77
22
  const ctx = requireRuntimeContext(ctxRaw);
78
23
  app.post("/api/node/generate", async (req, res) => {
79
24
  const body = (req.body ?? {});
80
- const streamResponse = wantsSse(req);
25
+ const asyncMode = body.async === true;
26
+ const streamResponse = !asyncMode && wantsSse(req);
81
27
  const parentNodeId = (typeof body.parentNodeId === "string" ? body.parentNodeId : null);
82
28
  const requestId = typeof body.requestId === "string" ? body.requestId : (req.id ?? "");
83
29
  const sessionId = typeof body.sessionId === "string" ? body.sessionId : null;
@@ -89,21 +35,6 @@ export function registerNodeRoutes(app, ctxRaw) {
89
35
  let finishCanceled = false;
90
36
  const cancelController = new AbortController();
91
37
  const referencePayload = summarizeReferencePayload(body.references);
92
- startJob({
93
- requestId,
94
- kind: "node",
95
- prompt: body.prompt,
96
- meta: {
97
- kind: "node",
98
- sessionId,
99
- parentNodeId,
100
- clientNodeId,
101
- refsCount: referencePayload.refsCount,
102
- referenceBytes: referencePayload.referenceBytes,
103
- referenceB64Chars: referencePayload.referenceB64Chars,
104
- },
105
- });
106
- registerJobAbortController(requestId, cancelController);
107
38
  try {
108
39
  const { prompt, quality: rawQuality = "medium", size = "1024x1024", format = "png", moderation = "low", references = [], externalSrc = null, mode: promptMode = "auto", contextMode: rawContextMode = "parent-plus-refs", searchMode: rawSearchMode = "on", model: rawModel, reasoningEffort: rawReasoningEffort, } = body;
109
40
  const { provider = "oauth" } = body;
@@ -135,7 +66,7 @@ export function registerNodeRoutes(app, ctxRaw) {
135
66
  const effectiveSize = providerOptions.size;
136
67
  const webSearchEnabled = providerOptions.webSearchEnabled;
137
68
  const activeProvider = providerOptions.provider;
138
- const effectiveImageModel = activeProvider === "grok" && quality === "high"
69
+ const effectiveImageModel = (activeProvider === "grok" || activeProvider === "grok-api") && quality === "high"
139
70
  ? "grok-imagine-image-quality"
140
71
  : imageModel;
141
72
  if (contextMode === "ancestry") {
@@ -193,19 +124,47 @@ export function registerNodeRoutes(app, ctxRaw) {
193
124
  const refsForRequest = contextMode === "parent-only" ? [] : (refCheck.refDetails || refCheck.refs);
194
125
  const parentImagePresent = !!parentB64;
195
126
  const inputImageCount = (parentImagePresent ? 1 : 0) + refsForRequest.length;
196
- if (activeProvider === "grok" && inputImageCount > 3) {
127
+ if ((activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api") && inputImageCount > 3) {
197
128
  finishStatus = "error";
198
129
  finishHttpStatus = 400;
199
- finishErrorCode = "GROK_REF_TOO_MANY";
130
+ const code = activeProvider === "agy" ? "AGY_REF_TOO_MANY" : "GROK_REF_TOO_MANY";
200
131
  return res.status(400).json({
201
132
  error: {
202
- code: "GROK_REF_TOO_MANY",
203
- message: "Grok image editing supports up to 3 reference images.",
133
+ code,
134
+ message: `${activeProvider === "agy" ? "Agy" : "Grok"} image editing supports up to 3 reference images.`,
204
135
  },
205
- code: "GROK_REF_TOO_MANY",
136
+ code,
206
137
  parentNodeId,
207
138
  });
208
139
  }
140
+ const started = startJob({
141
+ requestId,
142
+ kind: "node",
143
+ prompt: body.prompt,
144
+ meta: {
145
+ kind: "node",
146
+ sessionId,
147
+ parentNodeId,
148
+ clientNodeId,
149
+ refsCount: referencePayload.refsCount,
150
+ referenceBytes: referencePayload.referenceBytes,
151
+ referenceB64Chars: referencePayload.referenceB64Chars,
152
+ },
153
+ });
154
+ if (started && isStartJobFailure(started)) {
155
+ finishStatus = "error";
156
+ finishHttpStatus = started.code === "TOO_MANY_JOBS" ? 429 : 409;
157
+ finishErrorCode = started.code;
158
+ if (started.code === "TOO_MANY_JOBS") {
159
+ res.setHeader("Retry-After", String(INFLIGHT_RETRY_AFTER_SECONDS));
160
+ }
161
+ return writeNodeError(res, finishHttpStatus, started.code, started.code === "TOO_MANY_JOBS"
162
+ ? "Too many concurrent generation jobs"
163
+ : "Request ID already in use", parentNodeId, {}, requestId);
164
+ }
165
+ registerJobAbortController(requestId, cancelController);
166
+ if (asyncMode)
167
+ res.status(202).json({ requestId });
209
168
  logEvent("node", "request", {
210
169
  requestId,
211
170
  operation,
@@ -229,6 +188,7 @@ export function registerNodeRoutes(app, ctxRaw) {
229
188
  promptChars: prompt.length,
230
189
  promptMode: normalizedPromptMode,
231
190
  });
191
+ const emitProgress = streamResponse || asyncMode;
232
192
  if (streamResponse) {
233
193
  res.writeHead(200, {
234
194
  "Content-Type": "text/event-stream; charset=utf-8",
@@ -236,9 +196,14 @@ export function registerNodeRoutes(app, ctxRaw) {
236
196
  Connection: "keep-alive",
237
197
  });
238
198
  writeSse(res, "phase", { requestId, phase: "streaming" });
199
+ publish(requestId, "phase", { requestId, phase: "streaming" });
200
+ }
201
+ else if (asyncMode) {
202
+ publish(requestId, "phase", { requestId, phase: "streaming" });
239
203
  }
240
204
  let b64, usage, webSearchCalls = 0, revisedPrompt = null;
241
- let resultFormat = activeProvider === "grok" ? "jpeg" : format;
205
+ const grokDirectApiKey = activeProvider === "grok-api" ? ctx.xaiApiKey : undefined;
206
+ let resultFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" ? "jpeg" : format;
242
207
  const MAX_RETRIES = 1;
243
208
  let lastErr = null;
244
209
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
@@ -261,46 +226,66 @@ export function registerNodeRoutes(app, ctxRaw) {
261
226
  searchMode,
262
227
  webSearchEnabled,
263
228
  });
264
- const r = activeProvider === "grok"
265
- ? await generateViaGrok(prompt, ctx, {
229
+ const r = activeProvider === "gemini-api"
230
+ ? await generateViaGeminiApi(parentB64 ? `Edit this image: ${prompt}` : prompt, requireRuntimeContext(ctx), {
266
231
  model: effectiveImageModel,
267
232
  size: effectiveSize,
268
- requestId,
269
233
  signal: cancelController.signal,
270
- references: toGrokReferences(parentB64, refsForRequest),
234
+ requestId,
235
+ references: parentB64
236
+ ? [{ b64: parentB64, declaredMime: null, detectedMime: null }, ...(refCheck.refDetails || [])]
237
+ : refCheck.refDetails,
271
238
  })
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,
239
+ : activeProvider === "agy"
240
+ ? await generateViaAgy(parentB64 ? `Edit this image: ${prompt}` : prompt, {
241
+ references: parentB64
242
+ ? [{ b64: parentB64, declaredMime: null, detectedMime: null }]
243
+ : undefined,
279
244
  signal: cancelController.signal,
245
+ requestId,
280
246
  })
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
- });
247
+ : activeProvider === "grok" || activeProvider === "grok-api"
248
+ ? await generateViaGrok(prompt, ctx, {
249
+ model: effectiveImageModel,
250
+ size: effectiveSize,
251
+ requestId,
252
+ signal: cancelController.signal,
253
+ references: toGrokReferences(parentB64, refsForRequest),
254
+ directApiKey: grokDirectApiKey,
255
+ })
256
+ : parentB64
257
+ ? await editViaResponses(activeProvider, prompt, parentB64, quality, effectiveSize, moderation, normalizedPromptMode, ctx, requestId, {
258
+ model: effectiveImageModel,
259
+ references: refsForRequest,
260
+ searchMode,
261
+ reasoningEffort,
262
+ webSearchEnabled,
263
+ signal: cancelController.signal,
264
+ })
265
+ : await generateViaResponses(activeProvider, prompt, quality, effectiveSize, moderation, refsForRequest, requestId, normalizedPromptMode, ctx, {
266
+ model: effectiveImageModel,
267
+ reasoningEffort,
268
+ webSearchEnabled,
269
+ signal: cancelController.signal,
270
+ partialImages: emitProgress ? 2 : 0,
271
+ onPartialImage: emitProgress
272
+ ? (partial) => {
273
+ if (isJobCanceled(requestId))
274
+ return;
275
+ const pd = { requestId, image: dataUrlFromB64(format, partial.b64), index: partial.index };
276
+ if (streamResponse)
277
+ writeSse(res, "partial", pd);
278
+ publish(requestId, "partial", pd);
279
+ }
280
+ : null,
281
+ });
297
282
  throwIfJobCanceled(requestId);
298
283
  if (r.b64) {
299
284
  b64 = r.b64;
300
285
  usage = r.usage;
301
286
  webSearchCalls = r.webSearchCalls || 0;
302
287
  revisedPrompt = r.revisedPrompt || null;
303
- if (activeProvider === "grok") {
288
+ if (activeProvider === "grok" || activeProvider === "grok-api" || activeProvider === "gemini-api") {
304
289
  resultFormat = imageFormatFromMime(("mime" in r ? r.mime : undefined) || detectImageMimeFromB64(r.b64) || "image/jpeg");
305
290
  }
306
291
  break;
@@ -346,18 +331,7 @@ export function registerNodeRoutes(app, ctxRaw) {
346
331
  outerHttpAlreadyCommitted: res.headersSent,
347
332
  sseErrorSent: streamResponse,
348
333
  });
349
- return writeNodeError(res, finishHttpStatus ?? 500, finishErrorCode ?? "NODE_GEN_FAILED", finalErr.message, parentNodeId, {
350
- upstreamCode: lastErr?.upstreamCode || lastErr?.code || null,
351
- upstreamType: lastErr?.upstreamType || null,
352
- upstreamParam: lastErr?.upstreamParam || null,
353
- errorEventType: lastErr?.eventType || null,
354
- errorEventCount: lastErr?.eventCount ?? null,
355
- diagnosticReason: finalErr.diagnosticReason || lastErr?.diagnosticReason || null,
356
- retryKind: finalErr.retryKind || lastErr?.retryKind || null,
357
- referencesDroppedOnRetry: finalErr.referencesDroppedOnRetry ?? lastErr?.referencesDroppedOnRetry ?? null,
358
- refsCount: finalErr.refsCount ?? lastErr?.refsCount ?? null,
359
- inputImageCount: finalErr.inputImageCount ?? lastErr?.inputImageCount ?? null,
360
- });
334
+ return writeNodeError(res, finishHttpStatus ?? 500, finishErrorCode ?? "NODE_GEN_FAILED", finalErr.message, parentNodeId, nodeErrorDetails(finalErr, lastErr), requestId);
361
335
  }
362
336
  const nodeId = newNodeId();
363
337
  throwIfJobCanceled(requestId);
@@ -433,7 +407,11 @@ export function registerNodeRoutes(app, ctxRaw) {
433
407
  revisedPrompt,
434
408
  promptMode: normalizedPromptMode,
435
409
  };
436
- if (streamResponse) {
410
+ publishJobEvent(requestId, "done", payload);
411
+ if (res.writableEnded) {
412
+ // async mode — response already sent
413
+ }
414
+ else if (streamResponse) {
437
415
  writeSse(res, "done", payload);
438
416
  res.end();
439
417
  }
@@ -450,7 +428,7 @@ export function registerNodeRoutes(app, ctxRaw) {
450
428
  finishCanceled = true;
451
429
  finishHttpStatus = canceled.status;
452
430
  finishErrorCode = canceled.code;
453
- return writeNodeError(res, canceled.status, canceled.code, canceled.message, parentNodeId);
431
+ return writeNodeError(res, canceled.status, canceled.code, canceled.message, parentNodeId, {}, requestId);
454
432
  }
455
433
  finishStatus = "error";
456
434
  finishHttpStatus = err.status || 500;
@@ -460,7 +438,7 @@ export function registerNodeRoutes(app, ctxRaw) {
460
438
  upstreamCode: ext.upstreamCode || null,
461
439
  upstreamType: ext.upstreamType || null,
462
440
  upstreamParam: ext.upstreamParam || null,
463
- });
441
+ }, requestId);
464
442
  }
465
443
  finally {
466
444
  finishJob(requestId, {
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
  }