ima2-gen 3.21.0 → 3.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/bin/commands/grok.js +8 -1
  2. package/docs/API.md +4 -1
  3. package/docs/migration/runtime-test-inventory.md +164 -159
  4. package/lib/grokSizeMapper.js +2 -0
  5. package/lib/mcp/adapters/higgsfield.js +17 -10
  6. package/lib/mcp/adapters/higgsfieldUpload.js +77 -0
  7. package/lib/mcp/connectionManager.js +32 -0
  8. package/lib/mcp/modelsCatalog.js +12 -1
  9. package/lib/xaiAuth.js +35 -3
  10. package/lib/xaiDeviceLogin.js +30 -4
  11. package/package.json +2 -2
  12. package/routes/auth.js +13 -2
  13. package/routes/mcpMedia.js +21 -7
  14. package/routes/mcpMultishot.js +11 -7
  15. package/routes/mcpRecover.js +6 -2
  16. package/routes/quota.js +50 -14
  17. package/ui/dist/.vite/manifest.json +64 -64
  18. package/ui/dist/assets/{AgentWorkspace-CBOIYDSC.js → AgentWorkspace-CRJwrf5D.js} +1 -1
  19. package/ui/dist/assets/App-B7t5h5M_.js +9 -0
  20. package/ui/dist/assets/{AssetGenWorkspace-kjhZeUE8.js → AssetGenWorkspace-CR3k3lgk.js} +2 -2
  21. package/ui/dist/assets/{AssetMediaLightbox-B6YoVErG.js → AssetMediaLightbox-DOP-l7is.js} +1 -1
  22. package/ui/dist/assets/{AssetsWorkspace-D3yHm95L.js → AssetsWorkspace-i9IE-OMD.js} +1 -1
  23. package/ui/dist/assets/{CardNewsWorkspace-BVmfGpNh.js → CardNewsWorkspace-B238fPNf.js} +1 -1
  24. package/ui/dist/assets/{GenerationRequestLogPanel-lNQlY7ax.js → GenerationRequestLogPanel-Bznhoi1c.js} +1 -1
  25. package/ui/dist/assets/{HomeWorkspace-DoJRK1Oc.js → HomeWorkspace-DmQcpD4W.js} +1 -1
  26. package/ui/dist/assets/{KeyingPanel-CzXXD18n.js → KeyingPanel-C-9CZfhI.js} +1 -1
  27. package/ui/dist/assets/{NodeCanvas-Di9xPPrm.js → NodeCanvas-CtSwikbj.js} +1 -1
  28. package/ui/dist/assets/{PromptBuilderPanel-BRC8Or5u.js → PromptBuilderPanel-CWOtvCbq.js} +1 -1
  29. package/ui/dist/assets/{PromptImportDialog-DftvHziZ.js → PromptImportDialog-ski4y5rq.js} +2 -2
  30. package/ui/dist/assets/{PromptImportDiscoverySection-BRVea9Rt.js → PromptImportDiscoverySection-BxPnvWSF.js} +1 -1
  31. package/ui/dist/assets/{PromptImportFolderSection-y9o9rPoQ.js → PromptImportFolderSection-rt8TMgTU.js} +1 -1
  32. package/ui/dist/assets/{PromptLibraryPanel-CpseDORF.js → PromptLibraryPanel-evfChzKq.js} +2 -2
  33. package/ui/dist/assets/SettingsWorkspace-PzprnYiR.js +1 -0
  34. package/ui/dist/assets/{SpriteRecipeWorkspace-Bt9UmStQ.js → SpriteRecipeWorkspace-BidCI5hK.js} +1 -1
  35. package/ui/dist/assets/{index-DbV9f53B.js → index-CaFoTJlR.js} +8 -8
  36. package/ui/dist/assets/{index-C9gJqtx4.js → index-GbV8-z5Y.js} +4 -4
  37. package/ui/dist/assets/{pptxgen.es-CCODGbBd.js → pptxgen.es-BkpMsCHc.js} +1 -1
  38. package/ui/dist/assets/{promptBuilderStore-CF1EPSCL.js → promptBuilderStore-B7xCm5Ga.js} +1 -1
  39. package/ui/dist/assets/useAgentDialogFocus-C80ZVSWi.js +1 -0
  40. package/ui/dist/index.html +1 -1
  41. package/ui/dist/assets/App-BLBE-fTl.js +0 -9
  42. package/ui/dist/assets/SettingsWorkspace-BnvKUZsF.js +0 -1
  43. package/ui/dist/assets/useAgentDialogFocus-CNcAdwxO.js +0 -1
@@ -68,12 +68,43 @@ export class McpConnectionManager {
68
68
  return session;
69
69
  }
70
70
  status(provider) {
71
+ this.sweepExpiredPendingAuth();
71
72
  const descriptor = this.knownProvider(provider);
72
73
  if (!descriptor.enabled)
73
74
  return publicStatus(provider);
74
75
  const session = this.sessions.get(provider);
75
76
  return publicStatus(provider, session);
76
77
  }
78
+ /** Reap abandoned OAuth attempts: without a callback the pending entry, its
79
+ * candidate transport, and a stale auth_required session would otherwise
80
+ * linger for the process lifetime. Same teardown as rejectInvalidCallback. */
81
+ sweepExpiredPendingAuth() {
82
+ const now = this.now();
83
+ for (const [state, pending] of this.pendingAuth) {
84
+ if (pending.expiresAt >= now)
85
+ continue;
86
+ this.pendingAuth.delete(state);
87
+ removeCandidate(this.candidates, pending.provider, pending.transport);
88
+ void pending.transport.close().catch(() => undefined);
89
+ // Only a still-auth_required session belongs to the expired attempt — a
90
+ // successful reconnect may already have attached a connected session.
91
+ const session = this.sessions.get(pending.provider);
92
+ if (session?.state === "auth_required" && this.isCurrent(pending.provider, pending.generation)) {
93
+ this.markDisconnected(pending.provider);
94
+ }
95
+ }
96
+ }
97
+ /** A successful connect supersedes the provider's pending OAuth attempts;
98
+ * dropping them keeps their expiry from touching the attached session. */
99
+ dropProviderPendings(provider) {
100
+ for (const [state, pending] of this.pendingAuth) {
101
+ if (pending.provider !== provider)
102
+ continue;
103
+ this.pendingAuth.delete(state);
104
+ removeCandidate(this.candidates, provider, pending.transport);
105
+ void pending.transport.close().catch(() => undefined);
106
+ }
107
+ }
77
108
  isCurrent(provider, generation) {
78
109
  return this.generation(provider) === generation && !this.disconnectIntents.has(provider);
79
110
  }
@@ -150,6 +181,7 @@ export class McpConnectionManager {
150
181
  if (!this.isCurrent(provider, generation))
151
182
  return this.closeStale(provider, transport);
152
183
  removeCandidate(this.candidates, provider, transport);
184
+ this.dropProviderPendings(provider);
153
185
  Object.assign(session, {
154
186
  state: "connected",
155
187
  client,
@@ -74,6 +74,16 @@ function syntheticDuration(record) {
74
74
  return null;
75
75
  return { name: "duration", type: "number", min, max };
76
76
  }
77
+ // Provider media-role names project to ima2's canonical input-role vocabulary —
78
+ // the same shape the runway static catalog emits. Higgsfield's generic media
79
+ // roles are *_references; `text` is implied for every model since medias are
80
+ // optional inputs and a prompt always applies (optional only for
81
+ // marketing_studio_*), never a declared requirement.
82
+ const INPUT_ROLE_CANONICAL = {
83
+ image: "image_references",
84
+ video: "video_references",
85
+ audio: "audio_references",
86
+ };
77
87
  function parseCapabilities(record) {
78
88
  const parameters = Array.isArray(record.parameters)
79
89
  ? record.parameters.slice(0, 100).map(parseParameter).filter((item) => Boolean(item))
@@ -82,7 +92,8 @@ function parseCapabilities(record) {
82
92
  if (duration && !parameters.some((parameter) => parameter.name === "duration"))
83
93
  parameters.push(duration);
84
94
  const mediaItems = Array.isArray(record.medias) ? record.medias.slice(0, 50) : [];
85
- const inputRoles = boundedStrings(mediaItems.flatMap((item) => (item && typeof item === "object" ? item.roles ?? [] : [])), 100, 64);
95
+ const declared = boundedStrings(mediaItems.flatMap((item) => (item && typeof item === "object" ? item.roles ?? [] : [])), 100, 64);
96
+ const inputRoles = [...new Set(["text", ...declared.map((role) => INPUT_ROLE_CANONICAL[role] ?? role)])];
86
97
  return {
87
98
  source: "provider-declared",
88
99
  aspectRatios: boundedStrings(record.aspect_ratios, 50, 24),
package/lib/xaiAuth.js CHANGED
@@ -316,7 +316,14 @@ async function performRefresh(stored, opts) {
316
316
  ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
317
317
  ...(opts.deps !== undefined ? { deps: opts.deps } : {}),
318
318
  });
319
- const merged = { ...stored, ...fresh };
319
+ // The file is the single source of truth and other writers (progrok, a device login,
320
+ // a logout) can move it while a refresh is in flight — never clobber or resurrect it.
321
+ const current = loadGrokCredentials(opts.homeDir);
322
+ if (!current)
323
+ throw new GrokAuthError("GROK_AUTH_REQUIRED", LOGIN_REQUIRED_MESSAGE);
324
+ if (current.refreshToken !== stored.refreshToken)
325
+ return current;
326
+ const merged = { ...current, ...fresh };
320
327
  // An unknown new expiry must erase the old one rather than inherit a stale deadline.
321
328
  if (fresh.expiresAt === undefined)
322
329
  delete merged.expiresAt;
@@ -332,7 +339,16 @@ function runRefreshFlight(stored, opts) {
332
339
  const existing = refreshFlight;
333
340
  if (existing && now() - existing.startedAt <= FLIGHT_STALE_MS)
334
341
  return existing.promise;
335
- const flight = { startedAt: now(), promise: performRefresh(stored, opts) };
342
+ // The flight is shared, so no caller's signal may reach it: one aborted request must
343
+ // not fail every other caller joined to the same refresh (callers abort via their
344
+ // own await in getGrokAccessToken instead).
345
+ const shared = {
346
+ ...(opts.forceRefresh !== undefined ? { forceRefresh: opts.forceRefresh } : {}),
347
+ ...(opts.rejectedAccessToken !== undefined ? { rejectedAccessToken: opts.rejectedAccessToken } : {}),
348
+ ...(opts.homeDir !== undefined ? { homeDir: opts.homeDir } : {}),
349
+ ...(opts.deps !== undefined ? { deps: opts.deps } : {}),
350
+ };
351
+ const flight = { startedAt: now(), promise: performRefresh(stored, shared) };
336
352
  // Release only when we are still the current flight, so a slow earlier refresh cannot
337
353
  // clear a newer one (OpenCodex index.ts:537 pattern).
338
354
  flight.promise = flight.promise.finally(() => {
@@ -359,9 +375,25 @@ export async function getGrokAccessToken(opts = {}) {
359
375
  if (!stored.refreshToken) {
360
376
  throw new GrokAuthError("GROK_AUTH_REQUIRED", `Grok session expired and cannot be refreshed. ${LOGIN_REQUIRED_MESSAGE}`);
361
377
  }
362
- const fresh = await runRefreshFlight(stored, opts);
378
+ // A caller that is already dead must not start a shared refresh: its fetch would
379
+ // still hit the server and still write the file.
380
+ if (opts.signal?.aborted)
381
+ throw opts.signal.reason;
382
+ const fresh = await raceWithAbort(runRefreshFlight(stored, opts), opts.signal);
363
383
  return fresh.accessToken;
364
384
  }
385
+ /** A caller bails on its own signal without cancelling the shared flight. */
386
+ function raceWithAbort(promise, signal) {
387
+ if (!signal)
388
+ return promise;
389
+ if (signal.aborted)
390
+ return Promise.reject(signal.reason);
391
+ return new Promise((resolve, reject) => {
392
+ const onAbort = () => reject(signal.reason);
393
+ signal.addEventListener("abort", onAbort, { once: true });
394
+ promise.then((value) => { signal.removeEventListener("abort", onAbort); resolve(value); }, (error) => { signal.removeEventListener("abort", onAbort); reject(error); });
395
+ });
396
+ }
365
397
  /** Test-only: clears the single-flight slot and the terminal-failure negative cache. */
366
398
  export function __resetGrokAuthStateForTest() {
367
399
  refreshFlight = undefined;
@@ -14,6 +14,8 @@ const MIN_POLL_INTERVAL_SECONDS = 5;
14
14
  const SLOW_DOWN_STEP_MS = 5_000;
15
15
  const DEVICE_REQUEST_TIMEOUT_MS = 15_000;
16
16
  const TOKEN_REQUEST_TIMEOUT_MS = 10_000;
17
+ /** Consecutive transport/5xx poll failures tolerated before the login gives up. */
18
+ const MAX_CONSECUTIVE_POLL_FAILURES = 3;
17
19
  function defaultSleep(ms) {
18
20
  return new Promise((resolve) => {
19
21
  setTimeout(resolve, ms);
@@ -54,10 +56,20 @@ async function requestDeviceCode(endpoint, deps) {
54
56
  };
55
57
  }
56
58
  async function pollTokenOnce(tokenEndpoint, deviceCode, deps) {
57
- const response = await deps.doFetch(tokenEndpoint, {
58
- ...formEncoded({ grant_type: DEVICE_CODE_GRANT, client_id: XAI_OAUTH_CLIENT_ID, device_code: deviceCode }),
59
- signal: requestSignal(deps.signal, TOKEN_REQUEST_TIMEOUT_MS),
60
- });
59
+ let response;
60
+ try {
61
+ response = await deps.doFetch(tokenEndpoint, {
62
+ ...formEncoded({ grant_type: DEVICE_CODE_GRANT, client_id: XAI_OAUTH_CLIENT_ID, device_code: deviceCode }),
63
+ signal: requestSignal(deps.signal, TOKEN_REQUEST_TIMEOUT_MS),
64
+ });
65
+ }
66
+ catch (error) {
67
+ if (deps.signal?.aborted)
68
+ throw error;
69
+ // A reset or one timed-out poll must not kill an interactive login the user is
70
+ // still completing; the caller tolerates a few consecutive failures.
71
+ return { kind: "retryable" };
72
+ }
61
73
  if (response.ok)
62
74
  return { kind: "token", payload: (await response.json()) };
63
75
  const raw = await response.text();
@@ -74,6 +86,8 @@ async function pollTokenOnce(tokenEndpoint, deviceCode, deps) {
74
86
  return { kind: "pending" };
75
87
  if (oauthError === "slow_down")
76
88
  return { kind: "slow_down" };
89
+ if (response.status === 429 || response.status >= 500)
90
+ return { kind: "retryable", status: response.status };
77
91
  throw new Error(`xAI device login failed: ${oauthError ?? `HTTP ${response.status}`}`);
78
92
  }
79
93
  /**
@@ -85,6 +99,8 @@ async function pollUntilAuthorized(tokenEndpoint, grant, deps) {
85
99
  const deadlineMs = grant.expiresIn * 1000;
86
100
  let intervalMs = Math.max(grant.intervalSeconds, MIN_POLL_INTERVAL_SECONDS) * 1000;
87
101
  let sleptMs = 0;
102
+ let consecutiveFailures = 0;
103
+ let lastFailureStatus;
88
104
  while (Math.max(Date.now() - startedAt, sleptMs) < deadlineMs) {
89
105
  await deps.sleep(intervalMs);
90
106
  sleptMs += intervalMs;
@@ -93,6 +109,16 @@ async function pollUntilAuthorized(tokenEndpoint, grant, deps) {
93
109
  const outcome = await pollTokenOnce(tokenEndpoint, grant.deviceCode, deps);
94
110
  if (outcome.kind === "token")
95
111
  return outcome.payload;
112
+ if (outcome.kind === "retryable") {
113
+ consecutiveFailures += 1;
114
+ if (outcome.status !== undefined)
115
+ lastFailureStatus = outcome.status;
116
+ if (consecutiveFailures >= MAX_CONSECUTIVE_POLL_FAILURES) {
117
+ throw new Error(`xAI device login failed: the token endpoint kept failing (last: HTTP ${lastFailureStatus ?? "network"})`);
118
+ }
119
+ continue;
120
+ }
121
+ consecutiveFailures = 0;
96
122
  if (outcome.kind === "slow_down")
97
123
  intervalMs += SLOW_DOWN_STEP_MS;
98
124
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ima2-gen",
3
- "version": "3.21.0",
3
+ "version": "3.22.0",
4
4
  "packageManager": "npm@11.18.0",
5
5
  "description": "Local-first visual generation runtime and studio for people and coding agents, with reproducible image and video workflows across multiple providers.",
6
6
  "type": "module",
@@ -129,5 +129,5 @@
129
129
  "typescript-eslint": "8.70.0",
130
130
  "yaml": "2.9.1"
131
131
  },
132
- "gitHead": "b502a70fb703fd700ae1788c232cb6411db0c323"
132
+ "gitHead": "6cf759090e426f0f3d2395de187531b293bc25ea"
133
133
  }
package/routes/auth.js CHANGED
@@ -2,7 +2,8 @@ import { randomBytes } from "node:crypto";
2
2
  import { runChatgptLogin } from "../lib/chatgptLogin.js";
3
3
  import { runXaiDeviceLogin } from "../lib/xaiDeviceLogin.js";
4
4
  const MAX_CONCURRENT_SESSIONS = 20;
5
- const PROMPT_TIMEOUT_MS = 30_000;
5
+ // Must outlast the slowest healthy prompt path: discovery (30s) + device code (15s).
6
+ const PROMPT_TIMEOUT_MS = 60_000;
6
7
  const sessions = new Map();
7
8
  function sid() {
8
9
  return randomBytes(16).toString("hex");
@@ -98,7 +99,17 @@ function startCodexLogin(flow, ctx) {
98
99
  return session.email ? { email: session.email } : {};
99
100
  }, () => { ctx?.restartOAuthProxy?.(); }, liveCodexLogins);
100
101
  }
102
+ /**
103
+ * Every grok login that has started but not settled. The ~/.progrok/auth.json file is a
104
+ * singleton like codex's localhost port: two overlapping device logins would race on the
105
+ * same file, so a new start aborts all pending ones before its own first await.
106
+ */
107
+ const liveGrokLogins = new Set();
101
108
  function startGrokLogin(ctx) {
109
+ cancelPending("grok");
110
+ for (const controller of liveGrokLogins)
111
+ controller.abort();
112
+ liveGrokLogins.clear();
102
113
  return startSession("grok", async (signal, onPrompt) => {
103
114
  const creds = await runXaiDeviceLogin({
104
115
  signal,
@@ -106,7 +117,7 @@ function startGrokLogin(ctx) {
106
117
  onUserCode: (info) => onPrompt({ flow: "device", url: info.verificationUrl, userCode: info.userCode, expiresIn: info.expiresIn }),
107
118
  });
108
119
  return creds.email ? { email: creds.email } : {};
109
- });
120
+ }, undefined, liveGrokLogins);
110
121
  }
111
122
  export function registerAuthRoutes(app, ctx) {
112
123
  app.post("/api/auth/switch", async (req, res) => {
@@ -16,6 +16,7 @@ import { commitMediaResult } from "../lib/mcp/commitMediaResult.js";
16
16
  import { appendMcpJobLog, logMcpJobError } from "../lib/mcp/jobLog.js";
17
17
  import { buildRunwayActionCall, REFERENCE_TAG_PATTERN, runwayAdapter } from "../lib/mcp/adapters/runway.js";
18
18
  import { uploadLocalMediaToRunway } from "../lib/mcp/adapters/runwayUpload.js";
19
+ import { importUrlToHiggsfield, uploadLocalMediaToHiggsfield } from "../lib/mcp/adapters/higgsfieldUpload.js";
19
20
  import { resolveMediaAction } from "../lib/mcp/mediaWorkflowRouter.js";
20
21
  import { loadEffectiveSnapshot } from "../lib/mcp/snapshotStore.js";
21
22
  import { scrubValue } from "../lib/mcp/sanitizer.js";
@@ -57,7 +58,7 @@ export async function localMediaPath(generatedDir, filename, options) {
57
58
  throw new Error(`${options.label} has an unsupported extension`);
58
59
  return resolved;
59
60
  }
60
- function imageMime(filePath) {
61
+ export function imageMime(filePath) {
61
62
  const ext = extname(filePath).toLowerCase();
62
63
  return ext === ".png" ? "image/png" : ext === ".webp" ? "image/webp" : "image/jpeg";
63
64
  }
@@ -260,7 +261,7 @@ async function runMediaAction(input) {
260
261
  const code = errorCode(error);
261
262
  // Secret-scrub (030): tool-error text can embed signed URLs/emails from the provider.
262
263
  console.error(`[mcp-action ERROR] requestId=${requestId} operation=${input.operation} code=${code} message=${scrubValue(String(error?.message ?? "").slice(0, 500))} stack=${scrubValue(String(error?.stack ?? "").slice(0, 300))}`);
263
- void logMcpJobError(ctx.config.storage.generatedDir, { requestId, provider: "runway" }, error);
264
+ void logMcpJobError(ctx.config.storage.generatedDir, { requestId, provider: input.provider }, error);
264
265
  finishJob(requestId, { status: "error", errorCode: code });
265
266
  publishJobEvent(requestId, "error", { code, message: "media action failed", ...errorEnvelopeFields(error) });
266
267
  }
@@ -276,7 +277,7 @@ function actionForOperation(operation) {
276
277
  return "edit-video-submit";
277
278
  return "edit-video";
278
279
  }
279
- function extensionFor(kind, contentType, url) {
280
+ export function extensionFor(kind, contentType, url) {
280
281
  const fromUrl = url.match(/\.(png|jpe?g|webp|mp4|mov|webm)(?:\?|$)/i)?.[1]?.toLowerCase();
281
282
  if (fromUrl)
282
283
  return fromUrl === "jpeg" ? "jpg" : fromUrl;
@@ -298,6 +299,7 @@ export function registerMcpMediaRoutes(app, ctxRaw, depsPartial = {}) {
298
299
  download: depsPartial.download ?? downloadMediaResult,
299
300
  writeSidecar: depsPartial.writeSidecar ?? atomicWriteJson,
300
301
  upload: depsPartial.upload ?? uploadLocalMediaToRunway,
302
+ uploadHiggsfield: depsPartial.uploadHiggsfield ?? uploadLocalMediaToHiggsfield,
301
303
  concat: depsPartial.concat ?? concatVideos,
302
304
  ...(depsPartial.adapters ? { adapters: depsPartial.adapters } : {}),
303
305
  };
@@ -488,31 +490,43 @@ async function runMcpMediaJob(input) {
488
490
  setJobPhase(requestId, "uploading");
489
491
  publishJobEvent(requestId, "progress", { phase: "uploading", current: uploadCurrent, total: uploadTotal });
490
492
  };
493
+ // Media input upload is provider-specific: runway hosts assets via
494
+ // init_upload, higgsfield requires media_upload + media_confirm media_ids
495
+ // (higgsfield cannot consume runway-hosted URLs or other https values).
496
+ const upload = adapter.provider === "higgsfield" ? deps.uploadHiggsfield : deps.upload;
491
497
  let startFrameUrl = input.startFrameUrl;
492
498
  if (input.localStartFramePath) {
493
499
  publishUploading();
494
- startFrameUrl = await deps.upload(manager, input.localStartFramePath, {
500
+ startFrameUrl = await upload(manager, input.localStartFramePath, {
495
501
  fileName: basename(input.localStartFramePath), mimeType: imageMime(input.localStartFramePath),
496
502
  });
497
503
  }
504
+ else if (adapter.provider === "higgsfield" && startFrameUrl && /^https:/i.test(startFrameUrl)) {
505
+ startFrameUrl = await importUrlToHiggsfield(manager, startFrameUrl, "image");
506
+ }
498
507
  let endFrameUrl;
499
508
  if (input.localEndFramePath) {
500
509
  publishUploading();
501
- endFrameUrl = await deps.upload(manager, input.localEndFramePath, {
510
+ endFrameUrl = await upload(manager, input.localEndFramePath, {
502
511
  fileName: basename(input.localEndFramePath), mimeType: imageMime(input.localEndFramePath),
503
512
  });
504
513
  }
505
514
  const referenceImages = [];
506
515
  for (const entry of input.localReferences ?? []) {
507
516
  publishUploading();
508
- const url = await deps.upload(manager, entry.path, { fileName: basename(entry.path), mimeType: imageMime(entry.path) });
517
+ const url = await upload(manager, entry.path, { fileName: basename(entry.path), mimeType: imageMime(entry.path) });
509
518
  referenceImages.push({ url, ...(entry.tag ? { tag: entry.tag } : {}) });
510
519
  }
511
520
  let referenceVideoUrl;
512
521
  if (input.localReferenceVideoPath) {
522
+ // higgsfield declares no video-input role — surface the contract error
523
+ // before a media_upload the adapter will reject anyway.
524
+ if (adapter.provider === "higgsfield") {
525
+ throw new Error("MCP_INPUT_ROLE_UNSUPPORTED:higgsfield:video_references");
526
+ }
513
527
  publishUploading();
514
528
  const mimeType = extname(input.localReferenceVideoPath).toLowerCase() === ".mov" ? "video/quicktime" : "video/mp4";
515
- referenceVideoUrl = await deps.upload(manager, input.localReferenceVideoPath, {
529
+ referenceVideoUrl = await upload(manager, input.localReferenceVideoPath, {
516
530
  fileName: basename(input.localReferenceVideoPath), mimeType, maxBytes: VIDEO_INPUT_MAX_BYTES,
517
531
  });
518
532
  }
@@ -13,7 +13,7 @@ import { buildMultishotCall, runwayAdapter } from "../lib/mcp/adapters/runway.js
13
13
  import { uploadLocalMediaToRunway } from "../lib/mcp/adapters/runwayUpload.js";
14
14
  import { atomicWriteJson } from "../lib/atomicWrite.js";
15
15
  import { requireRuntimeContext } from "../lib/runtimeContext.js";
16
- import { localMediaPath, IMAGE_INPUT_MAX_BYTES } from "./mcpMedia.js";
16
+ import { localMediaPath, imageMime, IMAGE_INPUT_MAX_BYTES } from "./mcpMedia.js";
17
17
  import { errorEnvelopeFields } from "../lib/errors/envelope.js";
18
18
  export function registerMcpMultishotRoutes(app, ctxRaw) {
19
19
  const ctx = requireRuntimeContext(ctxRaw);
@@ -42,16 +42,12 @@ export function registerMcpMultishotRoutes(app, ctxRaw) {
42
42
  const sound = typeof req.body?.sound === "boolean" ? req.body.sound : undefined;
43
43
  const firstSceneFilename = typeof req.body?.firstSceneFilename === "string" && req.body.firstSceneFilename
44
44
  ? req.body.firstSceneFilename : null;
45
- let firstSceneImageUrl;
45
+ let firstScenePath = null;
46
46
  if (firstSceneFilename) {
47
47
  try {
48
- const resolved = await localMediaPath(ctx.config.storage.generatedDir, firstSceneFilename, {
48
+ firstScenePath = await localMediaPath(ctx.config.storage.generatedDir, firstSceneFilename, {
49
49
  label: "first scene image", maxBytes: IMAGE_INPUT_MAX_BYTES, extensions: /\.(png|jpe?g|webp)$/i,
50
50
  });
51
- setJobPhase("multishot-upload", "uploading");
52
- firstSceneImageUrl = await uploadLocalMediaToRunway(manager, resolved, {
53
- fileName: basename(resolved), mimeType: "image/png",
54
- });
55
51
  }
56
52
  catch (error) {
57
53
  return res.status(400).json({ error: { code: "INVALID_FIRST_SCENE", message: String(error?.message ?? error).slice(0, 120) } });
@@ -67,6 +63,14 @@ export function registerMcpMultishotRoutes(app, ctxRaw) {
67
63
  const abort = new AbortController();
68
64
  registerJobAbortController(requestId, abort);
69
65
  try {
66
+ let firstSceneImageUrl;
67
+ if (firstScenePath) {
68
+ setJobPhase(requestId, "uploading");
69
+ publishJobEvent(requestId, "progress", { phase: "uploading" });
70
+ firstSceneImageUrl = await uploadLocalMediaToRunway(manager, firstScenePath, {
71
+ fileName: basename(firstScenePath), mimeType: imageMime(firstScenePath),
72
+ });
73
+ }
70
74
  const plan = buildMultishotCall({
71
75
  storyPrompt: prompt ?? undefined, shots: shots ?? undefined,
72
76
  duration, resolution, aspectRatio, sound, firstSceneImageUrl,
@@ -13,6 +13,7 @@ import { runwayAdapter } from "../lib/mcp/adapters/runway.js";
13
13
  import { higgsfieldAdapter } from "../lib/mcp/adapters/higgsfield.js";
14
14
  import { requireRuntimeContext } from "../lib/runtimeContext.js";
15
15
  import { errorEnvelopeFields } from "../lib/errors/envelope.js";
16
+ import { extensionFor } from "./mcpMedia.js";
16
17
  const ADAPTERS = {
17
18
  runway: runwayAdapter,
18
19
  higgsfield: higgsfieldAdapter,
@@ -42,7 +43,7 @@ async function runRecoverJob(input) {
42
43
  await commitMediaResult({
43
44
  ctx, deps, requestId, kind,
44
45
  tempPath: download.tempPath, cleanup: download.cleanup,
45
- ext: download.contentType.includes("png") ? "png" : kind === "video" ? "mp4" : "jpg",
46
+ ext: extensionFor(kind, download.contentType, outputUrl),
46
47
  meta: {
47
48
  requestId, mediaType: kind, provider: adapter.provider,
48
49
  providerTransport: "mcp-streamable-http",
@@ -87,7 +88,10 @@ export function registerMcpRecoverRoutes(app, ctxRaw, depsPartial = {}) {
87
88
  if (!manager || manager.status(adapter.provider).state !== "connected") {
88
89
  return res.status(409).json({ error: { code: "MCP_NOT_CONNECTED", message: `connect ${adapter.provider} first` } });
89
90
  }
90
- const requestId = `mcpr_${Date.now()}_${randomBytes(4).toString("hex")}`;
91
+ // Same caller-requestId contract as /api/mcp/generate: a retried recover with
92
+ // the same requestId dedupes via startJob instead of committing the asset twice.
93
+ const requestId = typeof req.body?.requestId === "string" && req.body.requestId
94
+ ? req.body.requestId : `mcpr_${Date.now()}_${randomBytes(4).toString("hex")}`;
91
95
  const started = startJob({ requestId, kind: "mcp-recover", prompt: `recover ${provider} task ${taskId}`, meta: { provider, taskId } });
92
96
  if (started && isStartJobFailure(started)) {
93
97
  return res.status(started.code === "TOO_MANY_JOBS" ? 429 : 409).json({ error: { code: started.code, message: "cannot start job" } });
package/routes/quota.js CHANGED
@@ -4,21 +4,50 @@ import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { fetchNaiSubscription } from "../lib/naiSubscription.js";
6
6
  import { readChatgptAccess } from "../lib/chatgptAuth.js";
7
+ import { authHeaders, codexSessionStore } from "../lib/codexBackend/client.js";
7
8
  import { logWarn } from "../lib/logger.js";
8
- /** Same file GPT OAuth reads (ima2 store first, then Codex CLI files). */
9
- function readCodexTokens() {
10
- const access = readChatgptAccess();
11
- return access ? { access_token: access.accessToken, account_id: access.accountId } : null;
9
+ const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
10
+ const CODEX_SESSION_TIMEOUT_MS = 8_000;
11
+ /** A hung token endpoint must not stall the whole quota response. */
12
+ function withSessionTimeout(work) {
13
+ return Promise.race([
14
+ work,
15
+ new Promise((_resolve, reject) => {
16
+ setTimeout(() => reject(new Error("codex session read timed out")), CODEX_SESSION_TIMEOUT_MS).unref();
17
+ }),
18
+ ]);
12
19
  }
13
- async function fetchCodexUsage(tokens) {
20
+ /** Same session the GPT OAuth lane uses: the file is re-read and a near-expired token refreshes. */
21
+ async function readCodexSession() {
14
22
  try {
15
- const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
16
- headers: {
17
- Authorization: `Bearer ${tokens.access_token}`,
18
- "ChatGPT-Account-Id": tokens.account_id,
19
- },
20
- signal: AbortSignal.timeout(8000),
21
- });
23
+ return { session: await withSessionTimeout(codexSessionStore().get()) };
24
+ }
25
+ catch {
26
+ // A session file exists but the store could not serve it (a refresh or read
27
+ // failure): that is a quota error, not a logged-out account.
28
+ return readChatgptAccess() ? "error" : "none";
29
+ }
30
+ }
31
+ function codexUsageRequest(session) {
32
+ return fetch(CODEX_USAGE_URL, {
33
+ headers: authHeaders(session),
34
+ signal: AbortSignal.timeout(8000),
35
+ });
36
+ }
37
+ async function fetchCodexUsage(session) {
38
+ try {
39
+ let resp = await codexUsageRequest(session);
40
+ if (resp.status === 401) {
41
+ // The one-shot recovery codexUpstream uses: an invalidated token refreshes once and retries.
42
+ try {
43
+ const fresh = await withSessionTimeout(codexSessionStore().refresh(session.accessToken));
44
+ if (fresh.accessToken !== session.accessToken) {
45
+ await resp.body?.cancel().catch(() => undefined);
46
+ resp = await codexUsageRequest(fresh);
47
+ }
48
+ }
49
+ catch { /* the first response stands */ }
50
+ }
22
51
  if (!resp.ok) {
23
52
  if (resp.status === 401 || resp.status === 403)
24
53
  return { provider: "codex", authenticated: false, windows: [] };
@@ -269,9 +298,16 @@ export async function fetchNaiQuota(ctx) {
269
298
  export function registerQuotaRoutes(app, ctx) {
270
299
  app.get("/api/quota", async (_req, res) => {
271
300
  try {
272
- const tokens = readCodexTokens();
301
+ const read = await readCodexSession();
302
+ const codexResult = typeof read === "object"
303
+ ? fetchCodexUsage(read.session)
304
+ : Promise.resolve({
305
+ provider: "codex",
306
+ ...(read === "error" ? { error: true } : { authenticated: false }),
307
+ windows: [],
308
+ });
273
309
  const [codex, grok, nai] = await Promise.all([
274
- tokens ? fetchCodexUsage(tokens) : Promise.resolve({ provider: "codex", authenticated: false, windows: [] }),
310
+ codexResult,
275
311
  fetchGrokBilling(),
276
312
  fetchNaiQuota(ctx),
277
313
  ]);