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.
- package/bin/commands/grok.js +8 -1
- package/docs/API.md +4 -1
- package/docs/migration/runtime-test-inventory.md +164 -159
- package/lib/grokSizeMapper.js +2 -0
- package/lib/mcp/adapters/higgsfield.js +17 -10
- package/lib/mcp/adapters/higgsfieldUpload.js +77 -0
- package/lib/mcp/connectionManager.js +32 -0
- package/lib/mcp/modelsCatalog.js +12 -1
- package/lib/xaiAuth.js +35 -3
- package/lib/xaiDeviceLogin.js +30 -4
- package/package.json +2 -2
- package/routes/auth.js +13 -2
- package/routes/mcpMedia.js +21 -7
- package/routes/mcpMultishot.js +11 -7
- package/routes/mcpRecover.js +6 -2
- package/routes/quota.js +50 -14
- package/ui/dist/.vite/manifest.json +64 -64
- package/ui/dist/assets/{AgentWorkspace-CBOIYDSC.js → AgentWorkspace-CRJwrf5D.js} +1 -1
- package/ui/dist/assets/App-B7t5h5M_.js +9 -0
- package/ui/dist/assets/{AssetGenWorkspace-kjhZeUE8.js → AssetGenWorkspace-CR3k3lgk.js} +2 -2
- package/ui/dist/assets/{AssetMediaLightbox-B6YoVErG.js → AssetMediaLightbox-DOP-l7is.js} +1 -1
- package/ui/dist/assets/{AssetsWorkspace-D3yHm95L.js → AssetsWorkspace-i9IE-OMD.js} +1 -1
- package/ui/dist/assets/{CardNewsWorkspace-BVmfGpNh.js → CardNewsWorkspace-B238fPNf.js} +1 -1
- package/ui/dist/assets/{GenerationRequestLogPanel-lNQlY7ax.js → GenerationRequestLogPanel-Bznhoi1c.js} +1 -1
- package/ui/dist/assets/{HomeWorkspace-DoJRK1Oc.js → HomeWorkspace-DmQcpD4W.js} +1 -1
- package/ui/dist/assets/{KeyingPanel-CzXXD18n.js → KeyingPanel-C-9CZfhI.js} +1 -1
- package/ui/dist/assets/{NodeCanvas-Di9xPPrm.js → NodeCanvas-CtSwikbj.js} +1 -1
- package/ui/dist/assets/{PromptBuilderPanel-BRC8Or5u.js → PromptBuilderPanel-CWOtvCbq.js} +1 -1
- package/ui/dist/assets/{PromptImportDialog-DftvHziZ.js → PromptImportDialog-ski4y5rq.js} +2 -2
- package/ui/dist/assets/{PromptImportDiscoverySection-BRVea9Rt.js → PromptImportDiscoverySection-BxPnvWSF.js} +1 -1
- package/ui/dist/assets/{PromptImportFolderSection-y9o9rPoQ.js → PromptImportFolderSection-rt8TMgTU.js} +1 -1
- package/ui/dist/assets/{PromptLibraryPanel-CpseDORF.js → PromptLibraryPanel-evfChzKq.js} +2 -2
- package/ui/dist/assets/SettingsWorkspace-PzprnYiR.js +1 -0
- package/ui/dist/assets/{SpriteRecipeWorkspace-Bt9UmStQ.js → SpriteRecipeWorkspace-BidCI5hK.js} +1 -1
- package/ui/dist/assets/{index-DbV9f53B.js → index-CaFoTJlR.js} +8 -8
- package/ui/dist/assets/{index-C9gJqtx4.js → index-GbV8-z5Y.js} +4 -4
- package/ui/dist/assets/{pptxgen.es-CCODGbBd.js → pptxgen.es-BkpMsCHc.js} +1 -1
- package/ui/dist/assets/{promptBuilderStore-CF1EPSCL.js → promptBuilderStore-B7xCm5Ga.js} +1 -1
- package/ui/dist/assets/useAgentDialogFocus-C80ZVSWi.js +1 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/App-BLBE-fTl.js +0 -9
- package/ui/dist/assets/SettingsWorkspace-BnvKUZsF.js +0 -1
- 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,
|
package/lib/mcp/modelsCatalog.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/lib/xaiDeviceLogin.js
CHANGED
|
@@ -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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
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": "
|
|
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
|
-
|
|
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) => {
|
package/routes/mcpMedia.js
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
529
|
+
referenceVideoUrl = await upload(manager, input.localReferenceVideoPath, {
|
|
516
530
|
fileName: basename(input.localReferenceVideoPath), mimeType, maxBytes: VIDEO_INPUT_MAX_BYTES,
|
|
517
531
|
});
|
|
518
532
|
}
|
package/routes/mcpMultishot.js
CHANGED
|
@@ -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
|
|
45
|
+
let firstScenePath = null;
|
|
46
46
|
if (firstSceneFilename) {
|
|
47
47
|
try {
|
|
48
|
-
|
|
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,
|
package/routes/mcpRecover.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
-
|
|
310
|
+
codexResult,
|
|
275
311
|
fetchGrokBilling(),
|
|
276
312
|
fetchNaiQuota(ctx),
|
|
277
313
|
]);
|