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.
- package/README.md +2 -11
- package/bin/commands/backfillThumbs.js +18 -0
- package/bin/commands/edit.js +7 -6
- package/bin/commands/gen.js +7 -6
- package/bin/commands/multimode.js +5 -4
- package/bin/commands/node.js +4 -4
- package/bin/ima2.js +7 -1
- package/bin/lib/config-store.js +1 -1
- package/docs/API.md +55 -4
- package/docs/CLI.md +9 -3
- package/docs/PROMPT_STUDIO.md +3 -1
- package/docs/migration/runtime-test-inventory.md +3 -1
- package/lib/agentRuntime.js +22 -16
- package/lib/agentSettings.js +1 -1
- package/lib/agyImageAdapter.js +232 -0
- package/lib/capabilities.js +2 -1
- package/lib/configKeys.js +1 -1
- package/lib/geminiApiImageAdapter.js +183 -0
- package/lib/grokImageAdapter.js +16 -9
- package/lib/grokMultimodeAdapter.js +2 -1
- package/lib/grokRuntime.js +3 -0
- package/lib/grokSizeMapper.js +13 -1
- package/lib/grokVideoAdapter.js +14 -7
- package/lib/historyList.js +18 -2
- package/lib/imageModels.js +15 -0
- package/lib/imageThumb.js +38 -0
- package/lib/providerOptions.js +36 -1
- package/lib/responsesFallback.js +52 -44
- package/lib/runtimeContext.js +27 -0
- package/lib/storageMigration.js +1 -1
- package/lib/thumbBackfill.js +59 -0
- package/lib/vertexAuth.js +44 -0
- package/lib/videoThumb.js +60 -0
- package/package.json +4 -2
- package/routes/auth.js +238 -0
- package/routes/edit.js +41 -7
- package/routes/generate.js +40 -12
- package/routes/history.js +13 -0
- package/routes/index.js +4 -0
- package/routes/keys.js +254 -0
- package/routes/multimode.js +39 -6
- package/routes/nodes.js +57 -35
- package/routes/quota.js +58 -7
- package/routes/video.js +7 -3
- package/server.js +123 -0
- package/ui/dist/.vite/manifest.json +12 -12
- package/ui/dist/assets/AgentWorkspace-CYv84Rus.js +3 -0
- package/ui/dist/assets/{CardNewsWorkspace-BN-ga1lG.js → CardNewsWorkspace-Dqyc1WZ1.js} +2 -2
- package/ui/dist/assets/{NodeCanvas-BbMa4IhI.js → NodeCanvas-ChEXzQbb.js} +2 -2
- package/ui/dist/assets/{PromptBuilderPanel-DRwBJRDQ.js → PromptBuilderPanel-B95ZufnR.js} +1 -1
- package/ui/dist/assets/{PromptImportDialog-Dp85kHCq.js → PromptImportDialog-DGOwFQET.js} +2 -2
- package/ui/dist/assets/{PromptImportDiscoverySection-BE8Q8MLD.js → PromptImportDiscoverySection-CgvdnR49.js} +1 -1
- package/ui/dist/assets/{PromptImportFolderSection-PtH5x0sc.js → PromptImportFolderSection-CfUye9J8.js} +1 -1
- package/ui/dist/assets/{PromptLibraryPanel-FnM9tHI9.js → PromptLibraryPanel-B9kndPw1.js} +2 -2
- package/ui/dist/assets/SettingsWorkspace-B3tgLrmF.js +1 -0
- package/ui/dist/assets/index-BhcvL0g-.js +1 -0
- package/ui/dist/assets/index-BtK3YhJc.js +39 -0
- package/ui/dist/assets/index-ClOLOjnA.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/AgentWorkspace-C21zqdTZ.js +0 -3
- package/ui/dist/assets/SettingsWorkspace-MARPGyBL.js +0 -1
- package/ui/dist/assets/index-BAFI6htx.js +0 -42
- package/ui/dist/assets/index-BSXxr_Bt.js +0 -1
- 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
|
+
}
|
package/routes/multimode.js
CHANGED
|
@@ -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
|
-
|
|
221
|
-
await
|
|
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 === "
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 === "
|
|
265
|
-
? await
|
|
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
|
-
|
|
272
|
+
requestId,
|
|
273
|
+
references: parentB64
|
|
274
|
+
? [{ b64: parentB64, declaredMime: null, detectedMime: null }, ...(refCheck.refDetails || [])]
|
|
275
|
+
: refCheck.refDetails,
|
|
271
276
|
})
|
|
272
|
-
:
|
|
273
|
-
? await
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
:
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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 });
|