@dorely13/ai-imagegen-cli 0.2.0 → 0.4.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/dist/help.js +49 -21
- package/dist/index.js +1179 -452
- package/dist/index.js.map +1 -1
- package/dist/usage-log.js +40 -0
- package/package.json +45 -45
- package/skill/SKILL.md +139 -15
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { mkdir as
|
|
5
|
-
import { basename, dirname as
|
|
4
|
+
import { mkdir as mkdir5, readFile as readFile4, stat as stat2, writeFile as writeFile3 } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname as dirname5, extname, join as join5, resolve as resolve3 } from "node:path";
|
|
6
6
|
|
|
7
7
|
// ../../packages/core/dist/types.js
|
|
8
8
|
var ProviderError = class extends Error {
|
|
@@ -85,7 +85,9 @@ function normalizeOutputFormat(value) {
|
|
|
85
85
|
return "jpeg";
|
|
86
86
|
if (normalized === "webp")
|
|
87
87
|
return "webp";
|
|
88
|
-
|
|
88
|
+
if (normalized === "png" || normalized === "")
|
|
89
|
+
return "png";
|
|
90
|
+
throw new ProviderError({ kind: "usage", message: "Output format must be png, jpeg, or webp." });
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
// ../../packages/core/dist/image-size.js
|
|
@@ -226,20 +228,24 @@ var AUTH_ENDPOINT = "https://auth.openai.com/oauth/authorize";
|
|
|
226
228
|
var TOKEN_ENDPOINT = "https://auth.openai.com/oauth/token";
|
|
227
229
|
var CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
228
230
|
var SCOPE = "openid profile email offline_access";
|
|
229
|
-
var
|
|
231
|
+
var IMAGES_ENDPOINT = "https://chatgpt.com/backend-api/codex/images";
|
|
230
232
|
var DEFAULT_REDIRECT_URI = "http://localhost:1455/auth/callback";
|
|
231
233
|
var LOOPBACK_PORT = 1455;
|
|
232
234
|
var CALLBACK_PATH = "/auth/callback";
|
|
233
|
-
var DEFAULT_IMAGE_MODEL = "gpt-image-2.
|
|
234
|
-
var DEFAULT_ORCHESTRATION_MODEL = "gpt-5.6-luna";
|
|
235
|
+
var DEFAULT_IMAGE_MODEL = "gpt-image-2.6-flare";
|
|
235
236
|
var DEFAULT_QUALITY = "auto";
|
|
236
237
|
var REQUEST_TIMEOUT_SECONDS = 600;
|
|
237
|
-
var PARTIAL_IMAGES = 2;
|
|
238
238
|
var MAX_REFERENCE_IMAGES2 = 4;
|
|
239
239
|
var MAX_IMAGE_COUNT2 = 4;
|
|
240
240
|
var MIN_IMAGE_COUNT2 = 1;
|
|
241
241
|
var FLOW_TIMEOUT_MINUTES = 10;
|
|
242
242
|
var USER_AGENT = "ImageGenCli";
|
|
243
|
+
var TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1e3;
|
|
244
|
+
var TOKEN_REQUEST_TIMEOUT_MS = 30 * 1e3;
|
|
245
|
+
var REFRESH_LOCK_FILE = "codex-refresh.lock";
|
|
246
|
+
var REFRESH_LOCK_STALE_MS = 60 * 1e3;
|
|
247
|
+
var REFRESH_LOCK_WAIT_MS = 90 * 1e3;
|
|
248
|
+
var REFRESH_LOCK_POLL_MS = 100;
|
|
243
249
|
|
|
244
250
|
// ../../packages/providers-codex/dist/pkce.js
|
|
245
251
|
import { createHash, randomBytes } from "node:crypto";
|
|
@@ -282,7 +288,7 @@ function callbackPage(title, message) {
|
|
|
282
288
|
return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head><body style="font-family:sans-serif;padding:2rem"><h1>${title}</h1><p>${message}</p></body></html>`;
|
|
283
289
|
}
|
|
284
290
|
async function waitForOAuthCallback(timeoutMs = FLOW_TIMEOUT_MINUTES * 60 * 1e3, port = LOOPBACK_PORT) {
|
|
285
|
-
return new Promise((
|
|
291
|
+
return new Promise((resolve4, reject) => {
|
|
286
292
|
let server;
|
|
287
293
|
const timer = setTimeout(() => {
|
|
288
294
|
closeServer();
|
|
@@ -322,7 +328,7 @@ async function waitForOAuthCallback(timeoutMs = FLOW_TIMEOUT_MINUTES * 60 * 1e3,
|
|
|
322
328
|
res.writeHead(200, { "content-type": "text/html" });
|
|
323
329
|
res.end(callbackPage("Connected", "ImageGenCli is connected. You can close this tab."));
|
|
324
330
|
closeServer();
|
|
325
|
-
|
|
331
|
+
resolve4({ code, state });
|
|
326
332
|
} catch (error2) {
|
|
327
333
|
fail2(error2 instanceof Error ? error2 : new LoopbackError("OAuth callback failed.", "callback_error"));
|
|
328
334
|
}
|
|
@@ -340,7 +346,7 @@ async function waitForOAuthCallback(timeoutMs = FLOW_TIMEOUT_MINUTES * 60 * 1e3,
|
|
|
340
346
|
|
|
341
347
|
// ../../packages/providers-codex/dist/codex-auth.js
|
|
342
348
|
var ReauthRequiredError = class extends Error {
|
|
343
|
-
constructor(message = "Codex credentials are missing or expired. Run `imagegen auth login` to reconnect.") {
|
|
349
|
+
constructor(message = "Codex credentials are missing or expired. Run `imagegen auth codex login` to reconnect.") {
|
|
344
350
|
super(message);
|
|
345
351
|
this.name = "ReauthRequiredError";
|
|
346
352
|
}
|
|
@@ -353,10 +359,24 @@ function toTokenSet(body, fallbackRefreshToken) {
|
|
|
353
359
|
scope: body.scope
|
|
354
360
|
};
|
|
355
361
|
}
|
|
362
|
+
var REJECTED_GRANT_CODES = /* @__PURE__ */ new Set([
|
|
363
|
+
"invalid_grant",
|
|
364
|
+
"invalid_token",
|
|
365
|
+
"refresh_token_expired",
|
|
366
|
+
"refresh_token_reused",
|
|
367
|
+
"refresh_token_invalidated"
|
|
368
|
+
]);
|
|
356
369
|
async function readOAuthErrorCode(response) {
|
|
357
370
|
try {
|
|
358
371
|
const body = await response.json();
|
|
359
|
-
|
|
372
|
+
if (typeof body.error === "string")
|
|
373
|
+
return body.error;
|
|
374
|
+
if (typeof body.error === "object" && body.error !== null) {
|
|
375
|
+
const code = body.error["code"];
|
|
376
|
+
if (typeof code === "string")
|
|
377
|
+
return code;
|
|
378
|
+
}
|
|
379
|
+
return null;
|
|
360
380
|
} catch {
|
|
361
381
|
return null;
|
|
362
382
|
}
|
|
@@ -365,30 +385,31 @@ async function postTokenRequest(params) {
|
|
|
365
385
|
const response = await fetch(TOKEN_ENDPOINT, {
|
|
366
386
|
method: "POST",
|
|
367
387
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
368
|
-
body: new URLSearchParams(params)
|
|
388
|
+
body: new URLSearchParams(params),
|
|
389
|
+
signal: AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS)
|
|
369
390
|
});
|
|
370
391
|
if (!response.ok) {
|
|
371
392
|
const errorCode = await readOAuthErrorCode(response.clone());
|
|
372
|
-
if (response.status === 401 ||
|
|
373
|
-
return
|
|
393
|
+
if (response.status === 401 || errorCode !== null && REJECTED_GRANT_CODES.has(errorCode)) {
|
|
394
|
+
return { ok: false, reason: errorCode ?? `status ${response.status}` };
|
|
374
395
|
}
|
|
375
|
-
throw new Error(`Token request failed with status ${response.status}.`);
|
|
396
|
+
throw new Error(`Token request failed with status ${response.status}${errorCode ? ` (${errorCode})` : ""}.`);
|
|
376
397
|
}
|
|
377
398
|
const body = await response.json();
|
|
378
|
-
return toTokenSet(body, params["refresh_token"]);
|
|
399
|
+
return { ok: true, tokens: toTokenSet(body, params["refresh_token"]) };
|
|
379
400
|
}
|
|
380
401
|
async function exchangeCodeForTokens(args) {
|
|
381
|
-
const
|
|
402
|
+
const outcome = await postTokenRequest({
|
|
382
403
|
grant_type: "authorization_code",
|
|
383
404
|
client_id: CLIENT_ID,
|
|
384
405
|
code: args.code,
|
|
385
406
|
redirect_uri: args.redirectUri,
|
|
386
407
|
code_verifier: args.codeVerifier
|
|
387
408
|
});
|
|
388
|
-
if (!
|
|
389
|
-
throw new ReauthRequiredError(
|
|
409
|
+
if (!outcome.ok) {
|
|
410
|
+
throw new ReauthRequiredError(`The authorization code was rejected (${outcome.reason}). Run \`imagegen auth codex login\` again.`);
|
|
390
411
|
}
|
|
391
|
-
return tokens;
|
|
412
|
+
return outcome.tokens;
|
|
392
413
|
}
|
|
393
414
|
async function refreshTokens(refreshToken) {
|
|
394
415
|
return postTokenRequest({
|
|
@@ -397,8 +418,8 @@ async function refreshTokens(refreshToken) {
|
|
|
397
418
|
refresh_token: refreshToken
|
|
398
419
|
});
|
|
399
420
|
}
|
|
400
|
-
function
|
|
401
|
-
return tokens.expiresAt <= now;
|
|
421
|
+
function needsRefresh(tokens, now = Date.now()) {
|
|
422
|
+
return tokens.expiresAt - TOKEN_REFRESH_MARGIN_MS <= now;
|
|
402
423
|
}
|
|
403
424
|
var pendingVerifiers = /* @__PURE__ */ new Map();
|
|
404
425
|
function startPkceFlow(redirectUri2) {
|
|
@@ -424,65 +445,9 @@ function prunePendingVerifiers() {
|
|
|
424
445
|
}
|
|
425
446
|
}
|
|
426
447
|
|
|
427
|
-
// ../../packages/providers-codex/dist/
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
pendingRefresh = null;
|
|
431
|
-
constructor(store) {
|
|
432
|
-
this.store = store;
|
|
433
|
-
}
|
|
434
|
-
async exchangeAndSave(args) {
|
|
435
|
-
const tokens = await exchangeCodeForTokens(args);
|
|
436
|
-
await this.store.save(PROVIDER_NAME, tokens);
|
|
437
|
-
return tokens;
|
|
438
|
-
}
|
|
439
|
-
async status() {
|
|
440
|
-
const tokens = await this.store.load(PROVIDER_NAME);
|
|
441
|
-
if (!tokens)
|
|
442
|
-
return { connected: false };
|
|
443
|
-
if (!isTokenExpired(tokens))
|
|
444
|
-
return { connected: true, expiresAt: tokens.expiresAt };
|
|
445
|
-
if (!tokens.refreshToken)
|
|
446
|
-
return { connected: false };
|
|
447
|
-
try {
|
|
448
|
-
const refreshed = await this.getValidAccessToken();
|
|
449
|
-
void refreshed;
|
|
450
|
-
const current = await this.store.load(PROVIDER_NAME);
|
|
451
|
-
return { connected: true, expiresAt: current?.expiresAt };
|
|
452
|
-
} catch {
|
|
453
|
-
return { connected: false };
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
async logout() {
|
|
457
|
-
await this.store.clear(PROVIDER_NAME);
|
|
458
|
-
}
|
|
459
|
-
async getValidAccessToken() {
|
|
460
|
-
const tokens = await this.store.load(PROVIDER_NAME);
|
|
461
|
-
if (!tokens)
|
|
462
|
-
throw new ReauthRequiredError();
|
|
463
|
-
if (!isTokenExpired(tokens))
|
|
464
|
-
return tokens.accessToken;
|
|
465
|
-
if (!tokens.refreshToken) {
|
|
466
|
-
await this.store.clear(PROVIDER_NAME);
|
|
467
|
-
throw new ReauthRequiredError();
|
|
468
|
-
}
|
|
469
|
-
if (!this.pendingRefresh) {
|
|
470
|
-
this.pendingRefresh = this.performRefresh(tokens.refreshToken).finally(() => {
|
|
471
|
-
this.pendingRefresh = null;
|
|
472
|
-
});
|
|
473
|
-
}
|
|
474
|
-
return this.pendingRefresh;
|
|
475
|
-
}
|
|
476
|
-
async performRefresh(refreshToken) {
|
|
477
|
-
const refreshed = await refreshTokens(refreshToken);
|
|
478
|
-
if (!refreshed) {
|
|
479
|
-
await this.store.clear(PROVIDER_NAME);
|
|
480
|
-
throw new ReauthRequiredError();
|
|
481
|
-
}
|
|
482
|
-
await this.store.save(PROVIDER_NAME, refreshed);
|
|
483
|
-
return refreshed.accessToken;
|
|
484
|
-
}
|
|
485
|
-
};
|
|
448
|
+
// ../../packages/providers-codex/dist/refresh-lock.js
|
|
449
|
+
import { mkdir, open, rm, stat } from "node:fs/promises";
|
|
450
|
+
import { dirname, join as join2 } from "node:path";
|
|
486
451
|
|
|
487
452
|
// ../../packages/credentials/dist/index.js
|
|
488
453
|
import { homedir } from "node:os";
|
|
@@ -490,8 +455,8 @@ import { join } from "node:path";
|
|
|
490
455
|
function asKeytar(module) {
|
|
491
456
|
if (typeof module !== "object" || module === null)
|
|
492
457
|
return null;
|
|
493
|
-
const
|
|
494
|
-
const candidate =
|
|
458
|
+
const record3 = module;
|
|
459
|
+
const candidate = record3["default"] ?? module;
|
|
495
460
|
if (typeof candidate.getPassword === "function" && typeof candidate.setPassword === "function" && typeof candidate.deletePassword === "function") {
|
|
496
461
|
return candidate;
|
|
497
462
|
}
|
|
@@ -563,9 +528,9 @@ var FileCredentialStore = class {
|
|
|
563
528
|
return { backend: "file", location: this.filePath };
|
|
564
529
|
}
|
|
565
530
|
async readAll() {
|
|
566
|
-
const { readFile:
|
|
531
|
+
const { readFile: readFile5 } = await import("node:fs/promises");
|
|
567
532
|
try {
|
|
568
|
-
const raw = await
|
|
533
|
+
const raw = await readFile5(this.filePath, "utf8");
|
|
569
534
|
let value;
|
|
570
535
|
try {
|
|
571
536
|
value = JSON.parse(raw);
|
|
@@ -582,9 +547,9 @@ var FileCredentialStore = class {
|
|
|
582
547
|
}
|
|
583
548
|
}
|
|
584
549
|
async writeAll(all) {
|
|
585
|
-
const { mkdir:
|
|
586
|
-
const { dirname:
|
|
587
|
-
await
|
|
550
|
+
const { mkdir: mkdir6, writeFile: writeFile4, chmod } = await import("node:fs/promises");
|
|
551
|
+
const { dirname: dirname6 } = await import("node:path");
|
|
552
|
+
await mkdir6(dirname6(this.filePath), { recursive: true });
|
|
588
553
|
await writeFile4(this.filePath, JSON.stringify(all, null, 2), {
|
|
589
554
|
mode: 384
|
|
590
555
|
});
|
|
@@ -602,6 +567,133 @@ function defaultCredentialFilePath() {
|
|
|
602
567
|
return join(base, "ImageGenCli", "tokens.json");
|
|
603
568
|
}
|
|
604
569
|
|
|
570
|
+
// ../../packages/providers-codex/dist/refresh-lock.js
|
|
571
|
+
function defaultRefreshLockPath() {
|
|
572
|
+
return join2(dirname(defaultCredentialFilePath()), REFRESH_LOCK_FILE);
|
|
573
|
+
}
|
|
574
|
+
function createRefreshLock(options = {}) {
|
|
575
|
+
const path = options.path ?? defaultRefreshLockPath();
|
|
576
|
+
const staleMs = options.staleMs ?? REFRESH_LOCK_STALE_MS;
|
|
577
|
+
const waitMs = options.waitMs ?? REFRESH_LOCK_WAIT_MS;
|
|
578
|
+
const pollMs = options.pollMs ?? REFRESH_LOCK_POLL_MS;
|
|
579
|
+
return async (work) => {
|
|
580
|
+
await acquire(path, staleMs, waitMs, pollMs);
|
|
581
|
+
try {
|
|
582
|
+
return await work();
|
|
583
|
+
} finally {
|
|
584
|
+
await rm(path, { force: true });
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
async function acquire(path, staleMs, waitMs, pollMs) {
|
|
589
|
+
await mkdir(dirname(path), { recursive: true });
|
|
590
|
+
const deadline = Date.now() + waitMs;
|
|
591
|
+
for (; ; ) {
|
|
592
|
+
try {
|
|
593
|
+
const handle = await open(path, "wx");
|
|
594
|
+
try {
|
|
595
|
+
await handle.writeFile(`${process.pid}
|
|
596
|
+
`);
|
|
597
|
+
} finally {
|
|
598
|
+
await handle.close();
|
|
599
|
+
}
|
|
600
|
+
return;
|
|
601
|
+
} catch (error2) {
|
|
602
|
+
if (!isContention(error2))
|
|
603
|
+
throw error2;
|
|
604
|
+
}
|
|
605
|
+
if (await isStale(path, staleMs)) {
|
|
606
|
+
await rm(path, { force: true });
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
if (Date.now() >= deadline) {
|
|
610
|
+
throw new Error("Timed out waiting for another imagegen process to refresh Codex credentials.");
|
|
611
|
+
}
|
|
612
|
+
await new Promise((resolve4) => setTimeout(resolve4, pollMs + Math.random() * pollMs));
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
function isContention(error2) {
|
|
616
|
+
const code = error2.code;
|
|
617
|
+
return code === "EEXIST" || process.platform === "win32" && (code === "EPERM" || code === "EACCES");
|
|
618
|
+
}
|
|
619
|
+
async function isStale(path, staleMs) {
|
|
620
|
+
try {
|
|
621
|
+
return Date.now() - (await stat(path)).mtimeMs > staleMs;
|
|
622
|
+
} catch {
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ../../packages/providers-codex/dist/codex-token-manager.js
|
|
628
|
+
var CodexTokenManager = class {
|
|
629
|
+
store;
|
|
630
|
+
pendingRefresh = null;
|
|
631
|
+
lock;
|
|
632
|
+
refresh;
|
|
633
|
+
constructor(store, options = {}) {
|
|
634
|
+
this.store = store;
|
|
635
|
+
this.lock = options.lock ?? createRefreshLock();
|
|
636
|
+
this.refresh = options.refresh ?? refreshTokens;
|
|
637
|
+
}
|
|
638
|
+
async exchangeAndSave(args) {
|
|
639
|
+
const tokens = await exchangeCodeForTokens(args);
|
|
640
|
+
await this.store.save(PROVIDER_NAME, tokens);
|
|
641
|
+
return tokens;
|
|
642
|
+
}
|
|
643
|
+
async status() {
|
|
644
|
+
if (!await this.store.load(PROVIDER_NAME))
|
|
645
|
+
return { connected: false };
|
|
646
|
+
try {
|
|
647
|
+
await this.getValidAccessToken();
|
|
648
|
+
const current = await this.store.load(PROVIDER_NAME);
|
|
649
|
+
return { connected: true, expiresAt: current?.expiresAt };
|
|
650
|
+
} catch {
|
|
651
|
+
return { connected: false };
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
async logout() {
|
|
655
|
+
await this.store.clear(PROVIDER_NAME);
|
|
656
|
+
}
|
|
657
|
+
async getValidAccessToken() {
|
|
658
|
+
const tokens = await this.store.load(PROVIDER_NAME);
|
|
659
|
+
if (!tokens)
|
|
660
|
+
throw new ReauthRequiredError();
|
|
661
|
+
if (!needsRefresh(tokens))
|
|
662
|
+
return tokens.accessToken;
|
|
663
|
+
return this.refreshFrom(tokens.accessToken);
|
|
664
|
+
}
|
|
665
|
+
// The Codex image endpoint rejected this access token before its recorded expiry.
|
|
666
|
+
async recoverFromUnauthorized(rejectedAccessToken) {
|
|
667
|
+
return this.refreshFrom(rejectedAccessToken);
|
|
668
|
+
}
|
|
669
|
+
refreshFrom(staleAccessToken) {
|
|
670
|
+
if (!this.pendingRefresh) {
|
|
671
|
+
this.pendingRefresh = this.lock(() => this.refreshLocked(staleAccessToken)).finally(() => {
|
|
672
|
+
this.pendingRefresh = null;
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
return this.pendingRefresh;
|
|
676
|
+
}
|
|
677
|
+
async refreshLocked(staleAccessToken) {
|
|
678
|
+
const current = await this.store.load(PROVIDER_NAME);
|
|
679
|
+
if (!current)
|
|
680
|
+
throw new ReauthRequiredError();
|
|
681
|
+
if (current.accessToken !== staleAccessToken && !needsRefresh(current))
|
|
682
|
+
return current.accessToken;
|
|
683
|
+
if (!current.refreshToken) {
|
|
684
|
+
await this.store.clear(PROVIDER_NAME);
|
|
685
|
+
throw new ReauthRequiredError();
|
|
686
|
+
}
|
|
687
|
+
const outcome = await this.refresh(current.refreshToken);
|
|
688
|
+
if (!outcome.ok) {
|
|
689
|
+
await this.store.clear(PROVIDER_NAME);
|
|
690
|
+
throw new ReauthRequiredError(`Codex rejected the stored refresh token (${outcome.reason}). Run \`imagegen auth codex login\` to reconnect.`);
|
|
691
|
+
}
|
|
692
|
+
await this.store.save(PROVIDER_NAME, outcome.tokens);
|
|
693
|
+
return outcome.tokens.accessToken;
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
|
|
605
697
|
// ../../packages/providers-codex/dist/token-store.js
|
|
606
698
|
async function resolveTokenStore() {
|
|
607
699
|
const store = await resolveCredentialStore();
|
|
@@ -642,13 +734,18 @@ function extractAccountId(token) {
|
|
|
642
734
|
return accountId;
|
|
643
735
|
}
|
|
644
736
|
|
|
645
|
-
// ../../packages/providers-codex/dist/
|
|
737
|
+
// ../../packages/providers-codex/dist/codex-client.js
|
|
738
|
+
import sharp from "sharp";
|
|
739
|
+
|
|
740
|
+
// ../../packages/providers-codex/dist/errors.js
|
|
646
741
|
function classifyImageError(message) {
|
|
647
742
|
if (!message.trim())
|
|
648
743
|
return "unknown";
|
|
649
744
|
const lowerRate = message.includes("input-images") && message.includes("per min") && message.toLowerCase().includes("gpt-image");
|
|
650
745
|
if (lowerRate)
|
|
651
746
|
return "codex_image_input_rate_limit";
|
|
747
|
+
if (/usage limit/i.test(message))
|
|
748
|
+
return "usage_limit";
|
|
652
749
|
if (/rate limit|429|too many requests|please try again/i.test(message))
|
|
653
750
|
return "rate_limit";
|
|
654
751
|
if (/403|forbidden|cloudflare/i.test(message))
|
|
@@ -657,107 +754,26 @@ function classifyImageError(message) {
|
|
|
657
754
|
return "image_model_invalid";
|
|
658
755
|
return "api_error";
|
|
659
756
|
}
|
|
660
|
-
function
|
|
661
|
-
if (
|
|
662
|
-
return void 0;
|
|
663
|
-
const candidate = value[property];
|
|
664
|
-
return typeof candidate === "string" ? candidate : void 0;
|
|
665
|
-
}
|
|
666
|
-
function readEventError(event) {
|
|
667
|
-
const direct = readString(event, "message");
|
|
668
|
-
if (direct)
|
|
669
|
-
return direct;
|
|
670
|
-
if (typeof event !== "object" || event === null)
|
|
671
|
-
return void 0;
|
|
672
|
-
const record2 = event;
|
|
673
|
-
const nested = record2["error"];
|
|
674
|
-
if (typeof nested === "object" && nested !== null) {
|
|
675
|
-
const message = readString(nested, "message");
|
|
676
|
-
if (message)
|
|
677
|
-
return message;
|
|
678
|
-
}
|
|
679
|
-
const response = record2["response"];
|
|
680
|
-
if (typeof response === "object" && response !== null) {
|
|
681
|
-
const responseError = response["error"];
|
|
682
|
-
if (typeof responseError === "object" && responseError !== null) {
|
|
683
|
-
return readString(responseError, "message");
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
return void 0;
|
|
687
|
-
}
|
|
688
|
-
function tryReadImageItem(item) {
|
|
689
|
-
if (readString(item, "type") !== "image_generation_call")
|
|
690
|
-
return null;
|
|
691
|
-
const result = readString(item, "result");
|
|
692
|
-
if (!result)
|
|
757
|
+
function readErrorMessage(body) {
|
|
758
|
+
if (!body.trim())
|
|
693
759
|
return null;
|
|
694
|
-
let data;
|
|
695
760
|
try {
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
const finish = (image) => ({
|
|
707
|
-
...image,
|
|
708
|
-
responseId,
|
|
709
|
-
eventCount,
|
|
710
|
-
lastEventType
|
|
711
|
-
});
|
|
712
|
-
for (const raw of events) {
|
|
713
|
-
const line = raw.trim();
|
|
714
|
-
if (!line.startsWith("data:"))
|
|
715
|
-
continue;
|
|
716
|
-
const payload = line.slice("data:".length).trim();
|
|
717
|
-
if (payload === "[DONE]")
|
|
718
|
-
break;
|
|
719
|
-
let event;
|
|
720
|
-
try {
|
|
721
|
-
event = JSON.parse(payload);
|
|
722
|
-
} catch {
|
|
723
|
-
continue;
|
|
724
|
-
}
|
|
725
|
-
const type = readString(event, "type");
|
|
726
|
-
lastEventType = type ?? lastEventType;
|
|
727
|
-
eventCount += 1;
|
|
728
|
-
if (type === "response.created" && typeof event === "object" && event !== null) {
|
|
729
|
-
const inner = event["response"];
|
|
730
|
-
const id = readString(inner, "id");
|
|
731
|
-
if (id)
|
|
732
|
-
responseId = id;
|
|
733
|
-
}
|
|
734
|
-
if (type === "response.failed" || type === "error") {
|
|
735
|
-
throw new Error(`${classifyImageError(readEventError(event) ?? "")}: ${readEventError(event) ?? "Codex image generation failed."}`);
|
|
736
|
-
}
|
|
737
|
-
if (type === "response.output_item.done" && typeof event === "object" && event !== null) {
|
|
738
|
-
const item = event["item"];
|
|
739
|
-
const image = tryReadImageItem(item);
|
|
740
|
-
if (image)
|
|
741
|
-
return finish(image);
|
|
742
|
-
}
|
|
743
|
-
if (type === "response.completed" && typeof event === "object" && event !== null) {
|
|
744
|
-
const response = event["response"];
|
|
745
|
-
const id = readString(response, "id");
|
|
746
|
-
if (id)
|
|
747
|
-
responseId = id;
|
|
748
|
-
if (typeof response === "object" && response !== null) {
|
|
749
|
-
const output = response["output"];
|
|
750
|
-
if (Array.isArray(output)) {
|
|
751
|
-
for (const item of output) {
|
|
752
|
-
const image = tryReadImageItem(item);
|
|
753
|
-
if (image)
|
|
754
|
-
return finish(image);
|
|
755
|
-
}
|
|
756
|
-
}
|
|
761
|
+
const parsed = JSON.parse(body);
|
|
762
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
763
|
+
const record3 = parsed;
|
|
764
|
+
if (typeof record3["message"] === "string")
|
|
765
|
+
return record3["message"];
|
|
766
|
+
if (typeof record3["detail"] === "string")
|
|
767
|
+
return record3["detail"];
|
|
768
|
+
const error2 = record3["error"];
|
|
769
|
+
if (typeof error2 === "object" && error2 !== null && typeof error2["message"] === "string") {
|
|
770
|
+
return error2["message"];
|
|
757
771
|
}
|
|
758
772
|
}
|
|
773
|
+
} catch {
|
|
774
|
+
return body.slice(0, 500);
|
|
759
775
|
}
|
|
760
|
-
|
|
776
|
+
return null;
|
|
761
777
|
}
|
|
762
778
|
|
|
763
779
|
// ../../packages/providers-codex/dist/codex-client.js
|
|
@@ -779,112 +795,78 @@ function resolveSize(size) {
|
|
|
779
795
|
});
|
|
780
796
|
}
|
|
781
797
|
}
|
|
782
|
-
function
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
model: args.
|
|
786
|
-
|
|
787
|
-
size: args.size && args.size.trim() ? args.size.trim() : "auto",
|
|
788
|
-
output_format: args.outputFormat,
|
|
798
|
+
function buildBody(args) {
|
|
799
|
+
const body = {
|
|
800
|
+
prompt: args.prompt,
|
|
801
|
+
model: args.model,
|
|
802
|
+
size: args.size.size,
|
|
789
803
|
background: "auto"
|
|
790
804
|
};
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
805
|
+
const quality = args.quality.trim();
|
|
806
|
+
if (quality && quality.toLowerCase() !== "auto")
|
|
807
|
+
body["quality"] = quality;
|
|
808
|
+
if (args.images.length > 0)
|
|
809
|
+
body["images"] = args.images.map((image) => ({ image_url: toDataUrl(image) }));
|
|
810
|
+
return body;
|
|
811
|
+
}
|
|
812
|
+
var MASK_INSTRUCTION = "The last image is an edit mask, not content to reproduce. Apply the requested change only where the mask is transparent. Keep every area where the mask is opaque exactly as it is in the first image.";
|
|
813
|
+
async function normalizeMask(mask, source) {
|
|
814
|
+
const usage2 = (message) => new ProviderError({ kind: "usage", message });
|
|
815
|
+
let pixels;
|
|
816
|
+
let width;
|
|
817
|
+
let height;
|
|
818
|
+
let sourceWidth;
|
|
819
|
+
let sourceHeight;
|
|
820
|
+
try {
|
|
821
|
+
const decoded = await sharp(mask.data).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
822
|
+
({ width, height } = decoded.info);
|
|
823
|
+
pixels = decoded.data;
|
|
824
|
+
({ width: sourceWidth, height: sourceHeight } = await sharp(source.data).metadata());
|
|
825
|
+
} catch {
|
|
826
|
+
throw usage2("Mask and source must be readable PNG, JPEG, or WebP images.");
|
|
795
827
|
}
|
|
796
|
-
if (
|
|
797
|
-
|
|
828
|
+
if (width !== sourceWidth || height !== sourceHeight) {
|
|
829
|
+
throw usage2(`Mask is ${width}x${height} but the source is ${sourceWidth}x${sourceHeight}; they must match.`);
|
|
798
830
|
}
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
content.push({ type: "input_image", image_url: toDataUrl(reference), detail: "auto" });
|
|
831
|
+
let editable = false;
|
|
832
|
+
for (let i = 0; i < pixels.length; i += 4) {
|
|
833
|
+
const edit = pixels[i + 3] < 128;
|
|
834
|
+
editable ||= edit;
|
|
835
|
+
pixels.fill(edit ? 0 : 255, i, i + 4);
|
|
805
836
|
}
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
tool_choice: { type: "image_generation" },
|
|
811
|
-
stream: true,
|
|
812
|
-
store: false
|
|
813
|
-
};
|
|
837
|
+
if (!editable)
|
|
838
|
+
throw usage2("Mask has no transparent pixels; make the area to edit transparent.");
|
|
839
|
+
const data = await sharp(pixels, { raw: { width, height, channels: 4 } }).png().toBuffer();
|
|
840
|
+
return { data, contentType: "image/png" };
|
|
814
841
|
}
|
|
815
842
|
var CodexImageProvider = class {
|
|
816
843
|
tokens;
|
|
817
844
|
name = PROVIDER_NAME;
|
|
818
845
|
sizePolicy = { resolveSize: (size) => resolveSize(size) };
|
|
819
|
-
orchestrationModel;
|
|
820
846
|
imageModel;
|
|
821
847
|
timeoutSeconds;
|
|
822
|
-
partialImages;
|
|
823
848
|
fetchImpl;
|
|
824
849
|
constructor(tokens, options = {}) {
|
|
825
850
|
this.tokens = tokens;
|
|
826
|
-
this.orchestrationModel = options.model ?? process.env["IMAGEGEN_CODEX_MODEL"] ?? DEFAULT_ORCHESTRATION_MODEL;
|
|
827
851
|
this.imageModel = options.imageModel ?? process.env["IMAGEGEN_CODEX_IMAGE_MODEL"] ?? DEFAULT_IMAGE_MODEL;
|
|
828
852
|
this.timeoutSeconds = options.timeoutSeconds ?? REQUEST_TIMEOUT_SECONDS;
|
|
829
|
-
this.partialImages = options.partialImages ?? PARTIAL_IMAGES;
|
|
830
853
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
831
854
|
}
|
|
832
855
|
async generate(request) {
|
|
833
|
-
const
|
|
834
|
-
|
|
835
|
-
const imageModel2 = this.resolveImageModel(request);
|
|
836
|
-
return this.runAll({
|
|
837
|
-
format,
|
|
838
|
-
imageModel: imageModel2,
|
|
839
|
-
count: normalizeCount(request.count),
|
|
840
|
-
prompt: request.prompt,
|
|
841
|
-
size,
|
|
842
|
-
build: () => buildPayload({
|
|
843
|
-
model: this.orchestrationModel,
|
|
844
|
-
prompt: request.prompt,
|
|
845
|
-
references: request.referenceImages ?? [],
|
|
846
|
-
tool: baseImageTool({
|
|
847
|
-
size: size.size,
|
|
848
|
-
quality: request.quality ?? DEFAULT_QUALITY,
|
|
849
|
-
outputFormat: format,
|
|
850
|
-
imageModel: imageModel2,
|
|
851
|
-
action: "generate",
|
|
852
|
-
partialImages: this.partialImages
|
|
853
|
-
})
|
|
854
|
-
})
|
|
855
|
-
});
|
|
856
|
+
const references = (request.referenceImages ?? []).slice(0, MAX_REFERENCE_IMAGES2);
|
|
857
|
+
return this.runAll(request, references.length > 0 ? "edits" : "generations", references);
|
|
856
858
|
}
|
|
857
859
|
async edit(request) {
|
|
858
|
-
const
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
build: () => {
|
|
869
|
-
const tool = baseImageTool({
|
|
870
|
-
size: size.size,
|
|
871
|
-
quality: request.quality ?? DEFAULT_QUALITY,
|
|
872
|
-
outputFormat: format,
|
|
873
|
-
imageModel: imageModel2,
|
|
874
|
-
action: "edit",
|
|
875
|
-
partialImages: this.partialImages
|
|
876
|
-
});
|
|
877
|
-
if (request.mask) {
|
|
878
|
-
tool["input_image_mask"] = { image_url: toDataUrl(request.mask) };
|
|
879
|
-
}
|
|
880
|
-
return buildPayload({
|
|
881
|
-
model: this.orchestrationModel,
|
|
882
|
-
prompt: request.prompt,
|
|
883
|
-
references,
|
|
884
|
-
tool
|
|
885
|
-
});
|
|
886
|
-
}
|
|
887
|
-
});
|
|
860
|
+
const images = [request.sourceImage, ...request.referenceImages ?? []];
|
|
861
|
+
if (!request.mask)
|
|
862
|
+
return this.runAll(request, "edits", images.slice(0, MAX_REFERENCE_IMAGES2));
|
|
863
|
+
if (images.length + 1 > MAX_REFERENCE_IMAGES2) {
|
|
864
|
+
throw new ProviderError({ kind: "usage", message: `Codex accepts at most ${MAX_REFERENCE_IMAGES2} edit images; a mask uses one, leaving room for ${MAX_REFERENCE_IMAGES2 - 2} --reference images.` });
|
|
865
|
+
}
|
|
866
|
+
const mask = await normalizeMask(request.mask, request.sourceImage);
|
|
867
|
+
return this.runAll(request, "edits", [...images, mask], `${request.prompt}
|
|
868
|
+
|
|
869
|
+
${MASK_INSTRUCTION}`);
|
|
888
870
|
}
|
|
889
871
|
resolveImageModel(request) {
|
|
890
872
|
const model = (request.model ?? this.imageModel).trim();
|
|
@@ -896,116 +878,122 @@ var CodexImageProvider = class {
|
|
|
896
878
|
}
|
|
897
879
|
return model;
|
|
898
880
|
}
|
|
899
|
-
async runAll(
|
|
900
|
-
const
|
|
901
|
-
|
|
902
|
-
|
|
881
|
+
async runAll(request, endpoint, images, modelPrompt = request.prompt) {
|
|
882
|
+
const format = normalizeOutputFormat(request.format);
|
|
883
|
+
const size = this.sizePolicy.resolveSize(request.size);
|
|
884
|
+
const requestedModel = this.resolveImageModel(request);
|
|
885
|
+
const body = JSON.stringify(buildBody({ prompt: modelPrompt, model: requestedModel, size, quality: request.quality ?? DEFAULT_QUALITY, images }));
|
|
886
|
+
const results = [];
|
|
887
|
+
for (let i = 0, count = normalizeCount(request.count); i < count; i += 1) {
|
|
888
|
+
results.push(await this.sendOnce(endpoint, body, format, request.prompt));
|
|
889
|
+
}
|
|
890
|
+
return { provider: this.name, requestedModel, images: results };
|
|
891
|
+
}
|
|
892
|
+
async sendOnce(endpoint, body, format, prompt) {
|
|
893
|
+
let accessToken = await this.tokens.getValidAccessToken();
|
|
894
|
+
let response = await this.post(endpoint, accessToken, body);
|
|
895
|
+
if (response.status === 401) {
|
|
896
|
+
await response.body?.cancel();
|
|
897
|
+
accessToken = await this.tokens.recoverFromUnauthorized(accessToken);
|
|
898
|
+
response = await this.post(endpoint, accessToken, body);
|
|
899
|
+
}
|
|
900
|
+
const requestId = response.headers.get("x-codex-imagegen-request-id") ?? response.headers.get("x-oai-request-id") ?? response.headers.get("x-request-id") ?? void 0;
|
|
901
|
+
if (!response.ok) {
|
|
902
|
+
const errorText = await response.text().catch(() => "");
|
|
903
|
+
const kind = classifyImageError(errorText);
|
|
904
|
+
const detail = readErrorMessage(errorText);
|
|
905
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
906
|
+
throw new ProviderError({
|
|
907
|
+
kind,
|
|
908
|
+
message: detail ? `Codex image request returned ${response.status} (${kind}): ${detail}` : `Codex image request returned ${response.status} (${kind}).`,
|
|
909
|
+
statusCode: response.status,
|
|
910
|
+
requestId,
|
|
911
|
+
...Number.isFinite(retryAfter) && retryAfter > 0 ? { retryAfterSeconds: retryAfter } : {}
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
const payload = record(await response.json().catch(() => null));
|
|
915
|
+
const item = Array.isArray(payload["data"]) ? record(payload["data"][0]) : {};
|
|
916
|
+
if (typeof item["b64_json"] !== "string" || !item["b64_json"]) {
|
|
917
|
+
throw new ProviderError({ kind: "no_image", message: "Codex image request returned no image data.", requestId });
|
|
903
918
|
}
|
|
904
|
-
|
|
919
|
+
let data = Buffer.from(item["b64_json"], "base64");
|
|
920
|
+
let width;
|
|
921
|
+
let height;
|
|
922
|
+
try {
|
|
923
|
+
const metadata = await sharp(data).metadata();
|
|
924
|
+
if (!metadata.width || !metadata.height || !["png", "jpeg", "webp"].includes(metadata.format ?? ""))
|
|
925
|
+
throw new Error();
|
|
926
|
+
await sharp(data).stats();
|
|
927
|
+
if (metadata.format !== format)
|
|
928
|
+
data = await sharp(data).toFormat(format).toBuffer();
|
|
929
|
+
width = metadata.width;
|
|
930
|
+
height = metadata.height;
|
|
931
|
+
} catch {
|
|
932
|
+
throw new ProviderError({ kind: "malformed", message: "Codex returned an invalid raster image.", requestId });
|
|
933
|
+
}
|
|
934
|
+
return {
|
|
935
|
+
data,
|
|
936
|
+
mimeType: `image/${format}`,
|
|
937
|
+
width,
|
|
938
|
+
height,
|
|
939
|
+
prompt,
|
|
940
|
+
responseId: typeof item["generation_id"] === "string" ? item["generation_id"] : void 0,
|
|
941
|
+
// The endpoint accepts any requested model and currently names none in its response.
|
|
942
|
+
servedModel: typeof payload["model"] === "string" ? payload["model"] : void 0,
|
|
943
|
+
usage: {
|
|
944
|
+
requestId,
|
|
945
|
+
imageGeneration: payload["usage"],
|
|
946
|
+
// What the backend actually rendered; it may override requested size and quality.
|
|
947
|
+
served: { size: payload["size"], quality: payload["quality"], background: payload["background"] },
|
|
948
|
+
planLimits: readPlanLimits(response.headers)
|
|
949
|
+
}
|
|
950
|
+
};
|
|
905
951
|
}
|
|
906
|
-
async
|
|
907
|
-
const accessToken = await this.tokens.getValidAccessToken();
|
|
908
|
-
const accountId = extractAccountId(accessToken);
|
|
952
|
+
async post(endpoint, accessToken, body) {
|
|
909
953
|
const controller = new AbortController();
|
|
910
954
|
const timer = setTimeout(() => controller.abort(), this.timeoutSeconds * 1e3);
|
|
911
|
-
let response;
|
|
912
955
|
try {
|
|
913
|
-
|
|
956
|
+
return await this.fetchImpl(`${IMAGES_ENDPOINT}/${endpoint}`, {
|
|
914
957
|
method: "POST",
|
|
915
958
|
headers: {
|
|
916
959
|
authorization: `Bearer ${accessToken}`,
|
|
917
|
-
"chatgpt-account-id":
|
|
918
|
-
"OpenAI-Beta": "responses=experimental",
|
|
960
|
+
"chatgpt-account-id": extractAccountId(accessToken),
|
|
919
961
|
originator: "pi",
|
|
920
962
|
"user-agent": USER_AGENT,
|
|
921
|
-
accept: "
|
|
963
|
+
accept: "application/json",
|
|
922
964
|
"content-type": "application/json"
|
|
923
965
|
},
|
|
924
|
-
body
|
|
966
|
+
body,
|
|
925
967
|
signal: controller.signal
|
|
926
968
|
});
|
|
927
969
|
} finally {
|
|
928
970
|
clearTimeout(timer);
|
|
929
971
|
}
|
|
930
|
-
const requestId = response.headers.get("x-request-id") ?? response.headers.get("openai-request-id") ?? response.headers.get("request-id") ?? void 0;
|
|
931
|
-
if (!response.ok || !response.body) {
|
|
932
|
-
const body = await response.text().catch(() => "");
|
|
933
|
-
const kind = classifyImageError(body);
|
|
934
|
-
const detail = readErrorMessage(body);
|
|
935
|
-
throw new ProviderError({
|
|
936
|
-
kind,
|
|
937
|
-
message: detail ? `Codex image request returned ${response.status} (${kind}): ${detail}` : `Codex image request returned ${response.status} (${kind}).`,
|
|
938
|
-
statusCode: response.status,
|
|
939
|
-
requestId
|
|
940
|
-
});
|
|
941
|
-
}
|
|
942
|
-
const lines = await readSseLines(response.body);
|
|
943
|
-
let parsed;
|
|
944
|
-
try {
|
|
945
|
-
parsed = parseImageStreamEvents(lines);
|
|
946
|
-
} catch (error2) {
|
|
947
|
-
throw toProviderError(error2, requestId);
|
|
948
|
-
}
|
|
949
|
-
return {
|
|
950
|
-
data: parsed.data,
|
|
951
|
-
mimeType: `image/${format}`,
|
|
952
|
-
width: size.width,
|
|
953
|
-
height: size.height,
|
|
954
|
-
prompt,
|
|
955
|
-
responseId: parsed.responseId,
|
|
956
|
-
callId: parsed.callId
|
|
957
|
-
};
|
|
958
972
|
}
|
|
959
973
|
};
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
const
|
|
965
|
-
|
|
966
|
-
const
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
function readErrorMessage(body) {
|
|
984
|
-
if (!body.trim())
|
|
985
|
-
return null;
|
|
986
|
-
try {
|
|
987
|
-
const parsed = JSON.parse(body);
|
|
988
|
-
if (typeof parsed === "object" && parsed !== null) {
|
|
989
|
-
const record2 = parsed;
|
|
990
|
-
if (typeof record2["message"] === "string")
|
|
991
|
-
return record2["message"];
|
|
992
|
-
if (typeof record2["detail"] === "string")
|
|
993
|
-
return record2["detail"];
|
|
994
|
-
const error2 = record2["error"];
|
|
995
|
-
if (typeof error2 === "object" && error2 !== null && typeof error2["message"] === "string") {
|
|
996
|
-
return error2["message"];
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
} catch {
|
|
1000
|
-
return body.slice(0, 500);
|
|
1001
|
-
}
|
|
1002
|
-
return null;
|
|
1003
|
-
}
|
|
1004
|
-
function toProviderError(error2, requestId) {
|
|
1005
|
-
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
1006
|
-
const separator = message.indexOf(":");
|
|
1007
|
-
const kind = separator > 0 ? message.slice(0, separator) : "api_error";
|
|
1008
|
-
return new ProviderError({ kind, message, requestId });
|
|
974
|
+
function record(value) {
|
|
975
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
976
|
+
}
|
|
977
|
+
function readPlanLimits(headers) {
|
|
978
|
+
const number = (name) => {
|
|
979
|
+
const value = headers.get(`x-codex-${name}`);
|
|
980
|
+
const parsed = value === null ? Number.NaN : Number(value);
|
|
981
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
982
|
+
};
|
|
983
|
+
const window = (name) => {
|
|
984
|
+
const usedPercent = number(`${name}-used-percent`);
|
|
985
|
+
const windowMinutes = number(`${name}-window-minutes`);
|
|
986
|
+
if (usedPercent === void 0 || !windowMinutes)
|
|
987
|
+
return void 0;
|
|
988
|
+
return { usedPercent, windowMinutes, resetAt: number(`${name}-reset-at`) };
|
|
989
|
+
};
|
|
990
|
+
const limits = {
|
|
991
|
+
planType: headers.get("x-codex-plan-type") ?? void 0,
|
|
992
|
+
activeLimit: headers.get("x-codex-active-limit") ?? void 0,
|
|
993
|
+
primary: window("primary"),
|
|
994
|
+
secondary: window("secondary")
|
|
995
|
+
};
|
|
996
|
+
return Object.values(limits).some((value) => value !== void 0) ? limits : void 0;
|
|
1009
997
|
}
|
|
1010
998
|
|
|
1011
999
|
// ../../packages/providers-codex/dist/codex-entry.js
|
|
@@ -1049,7 +1037,7 @@ function createCodexEntry() {
|
|
|
1049
1037
|
}
|
|
1050
1038
|
|
|
1051
1039
|
// ../../packages/providers-google/dist/gemini-client.js
|
|
1052
|
-
import
|
|
1040
|
+
import sharp2 from "sharp";
|
|
1053
1041
|
|
|
1054
1042
|
// ../../packages/providers-google/dist/image-size.js
|
|
1055
1043
|
var DEFAULT_IMAGE_MODEL2 = "gemini-nano-banana-2.1";
|
|
@@ -1168,8 +1156,6 @@ var GeminiImageProvider = class {
|
|
|
1168
1156
|
const count = request.count ?? 1;
|
|
1169
1157
|
if (!Number.isInteger(count) || count < 1 || count > 4)
|
|
1170
1158
|
throw error("usage", "Image count must be from 1 to 4.");
|
|
1171
|
-
if (request.format && !["png", "jpeg", "jpg", "webp"].includes(request.format.trim().toLowerCase()))
|
|
1172
|
-
throw error("usage", "Output format must be png, jpeg, or webp.");
|
|
1173
1159
|
const format = normalizeOutputFormat(request.format);
|
|
1174
1160
|
const size = resolveGeminiSize(request.size, model);
|
|
1175
1161
|
const payload = {
|
|
@@ -1204,7 +1190,7 @@ var GeminiImageProvider = class {
|
|
|
1204
1190
|
const images = [];
|
|
1205
1191
|
for (let i = 0; i < count; i++)
|
|
1206
1192
|
images.push(await this.send(payload, key, format, request.prompt));
|
|
1207
|
-
return { provider: this.name,
|
|
1193
|
+
return { provider: this.name, requestedModel: model, images };
|
|
1208
1194
|
}
|
|
1209
1195
|
async send(payload, key, format, prompt) {
|
|
1210
1196
|
const controller = new AbortController();
|
|
@@ -1244,12 +1230,12 @@ var GeminiImageProvider = class {
|
|
|
1244
1230
|
if (typeof image.data !== "string" || !image.data || !/^[A-Za-z0-9+/]+={0,2}$/.test(image.data) || image.data.length % 4 !== 0)
|
|
1245
1231
|
throw error("malformed", "Gemini returned invalid inline image data.");
|
|
1246
1232
|
const original = Buffer.from(image.data, "base64");
|
|
1247
|
-
const metadata = await
|
|
1233
|
+
const metadata = await sharp2(original).metadata().catch(() => {
|
|
1248
1234
|
throw error("malformed", "Gemini returned undecodable image bytes.");
|
|
1249
1235
|
});
|
|
1250
1236
|
if (!metadata.width || !metadata.height || !["png", "jpeg", "webp"].includes(metadata.format ?? "") || image.mime_type !== `image/${metadata.format}`)
|
|
1251
1237
|
throw error("malformed", "Gemini returned an invalid image or inconsistent MIME type.");
|
|
1252
|
-
const data = metadata.format === format ? original : await
|
|
1238
|
+
const data = metadata.format === format ? original : await sharp2(original).toFormat(format).toBuffer();
|
|
1253
1239
|
return {
|
|
1254
1240
|
data,
|
|
1255
1241
|
mimeType: `image/${format}`,
|
|
@@ -1315,9 +1301,10 @@ function createGeminiEntry() {
|
|
|
1315
1301
|
}
|
|
1316
1302
|
|
|
1317
1303
|
// ../../packages/providers-configurable/dist/profiles.js
|
|
1318
|
-
import { readFile, mkdir, writeFile, rename, rm } from "node:fs/promises";
|
|
1319
|
-
import { dirname, join as
|
|
1304
|
+
import { readFile, mkdir as mkdir2, writeFile, rename, rm as rm2 } from "node:fs/promises";
|
|
1305
|
+
import { dirname as dirname2, isAbsolute, join as join3 } from "node:path";
|
|
1320
1306
|
import { randomUUID } from "node:crypto";
|
|
1307
|
+
var COMFY_FIELDS = ["startCommand", "workflow", "editWorkflow"];
|
|
1321
1308
|
var usage = (message) => new ProviderError({ kind: "usage", message });
|
|
1322
1309
|
var profileStore = () => resolveCredentialStore();
|
|
1323
1310
|
function validateApiKey(value) {
|
|
@@ -1333,14 +1320,25 @@ function validateProfile(value) {
|
|
|
1333
1320
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1334
1321
|
throw usage("Invalid endpoint profile.");
|
|
1335
1322
|
const p = value;
|
|
1336
|
-
if (Object.keys(p).some((k) => !["name", "api", "baseUrl", "model", "auth"].includes(k)))
|
|
1337
|
-
throw usage("Endpoint profiles accept only name, api, baseUrl, model, and
|
|
1323
|
+
if (Object.keys(p).some((k) => !["name", "api", "baseUrl", "model", "auth", ...COMFY_FIELDS].includes(k)))
|
|
1324
|
+
throw usage("Endpoint profiles accept only name, api, baseUrl, model, auth, startCommand, workflow, and editWorkflow; store keys through auth login.");
|
|
1338
1325
|
if (typeof p.name !== "string" || !/^[a-z][a-z0-9-]{0,63}$/.test(p.name) || ["codex", "gemini"].includes(p.name))
|
|
1339
1326
|
throw usage("Endpoint name must use lowercase letters, digits, and hyphens, and cannot be codex or gemini.");
|
|
1340
|
-
if (p.api !== "openrouter" && p.api !== "openai-images")
|
|
1341
|
-
throw usage("API format must be openrouter
|
|
1327
|
+
if (p.api !== "openrouter" && p.api !== "openai-images" && p.api !== "comfyui")
|
|
1328
|
+
throw usage("API format must be openrouter, openai-images, or comfyui.");
|
|
1342
1329
|
if (p.auth !== void 0 && p.auth !== "bearer" && p.auth !== "none")
|
|
1343
1330
|
throw usage("Auth must be bearer or none.");
|
|
1331
|
+
if (p.api === "comfyui" && p.auth === "bearer")
|
|
1332
|
+
throw usage("ComfyUI profiles do not use API keys. Use --auth none.");
|
|
1333
|
+
if (p.api !== "comfyui" && COMFY_FIELDS.some((k) => p[k] !== void 0))
|
|
1334
|
+
throw usage("--start-command, --workflow, and --edit-workflow apply only to comfyui profiles.");
|
|
1335
|
+
for (const field of COMFY_FIELDS) {
|
|
1336
|
+
const value2 = p[field];
|
|
1337
|
+
if (value2 !== void 0 && (typeof value2 !== "string" || !value2.trim()))
|
|
1338
|
+
throw usage(`${field} must not be blank.`);
|
|
1339
|
+
if (field !== "startCommand" && value2 && !isAbsolute(value2))
|
|
1340
|
+
throw usage(`${field} must be an absolute path.`);
|
|
1341
|
+
}
|
|
1344
1342
|
const raw = p.baseUrl ?? (p.api === "openrouter" ? "https://openrouter.ai/api/v1" : void 0);
|
|
1345
1343
|
let url;
|
|
1346
1344
|
try {
|
|
@@ -1355,18 +1353,24 @@ function validateProfile(value) {
|
|
|
1355
1353
|
throw usage("Base URL must use HTTPS (or loopback HTTP), without credentials, query, or fragment.");
|
|
1356
1354
|
if (p.model !== void 0 && (typeof p.model !== "string" || !p.model.trim()))
|
|
1357
1355
|
throw usage("Default model must not be blank.");
|
|
1356
|
+
if (p.api === "comfyui" && p.model === void 0)
|
|
1357
|
+
throw usage("ComfyUI profiles need --model: flux3-klein, flux3-klein-raw, or your name for the model in --workflow.");
|
|
1358
1358
|
return {
|
|
1359
1359
|
name: p.name,
|
|
1360
1360
|
api: p.api,
|
|
1361
1361
|
baseUrl: url.toString().replace(/\/+$/, ""),
|
|
1362
|
-
auth: p.auth ?? "bearer",
|
|
1363
|
-
...typeof p.model === "string" ? { model: p.model.trim() } : {}
|
|
1362
|
+
auth: p.auth ?? (p.api === "comfyui" ? "none" : "bearer"),
|
|
1363
|
+
...typeof p.model === "string" ? { model: p.model.trim() } : {},
|
|
1364
|
+
...Object.fromEntries(COMFY_FIELDS.flatMap((field) => {
|
|
1365
|
+
const value2 = p[field];
|
|
1366
|
+
return typeof value2 === "string" ? [[field, value2.trim()]] : [];
|
|
1367
|
+
}))
|
|
1364
1368
|
};
|
|
1365
1369
|
}
|
|
1366
1370
|
var ProfileRepository = class {
|
|
1367
1371
|
path;
|
|
1368
1372
|
credentials;
|
|
1369
|
-
constructor(path =
|
|
1373
|
+
constructor(path = join3(dirname2(defaultCredentialFilePath()), "providers.json"), credentials = profileStore) {
|
|
1370
1374
|
this.path = path;
|
|
1371
1375
|
this.credentials = credentials;
|
|
1372
1376
|
}
|
|
@@ -1431,7 +1435,7 @@ var ProfileRepository = class {
|
|
|
1431
1435
|
async save(profiles) {
|
|
1432
1436
|
const temporary = `${this.path}.${randomUUID()}.tmp`;
|
|
1433
1437
|
try {
|
|
1434
|
-
await
|
|
1438
|
+
await mkdir2(dirname2(this.path), { recursive: true });
|
|
1435
1439
|
await writeFile(temporary, JSON.stringify({ version: 1, profiles }, null, 2) + "\n", { mode: 384 });
|
|
1436
1440
|
await rename(temporary, this.path);
|
|
1437
1441
|
} catch {
|
|
@@ -1440,7 +1444,7 @@ var ProfileRepository = class {
|
|
|
1440
1444
|
message: "Could not save endpoint configuration."
|
|
1441
1445
|
});
|
|
1442
1446
|
} finally {
|
|
1443
|
-
await
|
|
1447
|
+
await rm2(temporary, { force: true }).catch(() => {
|
|
1444
1448
|
});
|
|
1445
1449
|
}
|
|
1446
1450
|
}
|
|
@@ -1449,9 +1453,9 @@ async function profileKey(profile, store) {
|
|
|
1449
1453
|
if (profile.auth === "none")
|
|
1450
1454
|
return void 0;
|
|
1451
1455
|
try {
|
|
1452
|
-
const
|
|
1453
|
-
if (
|
|
1454
|
-
return validateApiKey(
|
|
1456
|
+
const record3 = await store.load(profile.name);
|
|
1457
|
+
if (record3 && typeof record3.apiKey === "string")
|
|
1458
|
+
return validateApiKey(record3.apiKey);
|
|
1455
1459
|
} catch {
|
|
1456
1460
|
}
|
|
1457
1461
|
throw new ProviderError({
|
|
@@ -1461,7 +1465,7 @@ async function profileKey(profile, store) {
|
|
|
1461
1465
|
}
|
|
1462
1466
|
|
|
1463
1467
|
// ../../packages/providers-configurable/dist/provider.js
|
|
1464
|
-
import
|
|
1468
|
+
import sharp4 from "sharp";
|
|
1465
1469
|
|
|
1466
1470
|
// ../../packages/providers-configurable/dist/image-size.js
|
|
1467
1471
|
function imageSize(value, api) {
|
|
@@ -1481,13 +1485,30 @@ function imageSize(value, api) {
|
|
|
1481
1485
|
if (pixels && Number.isSafeInteger(Number(pixels[1])) && Number.isSafeInteger(Number(pixels[2])) && Number(pixels[1]) > 0 && Number(pixels[2]) > 0)
|
|
1482
1486
|
return { size: `${Number(pixels[1])}x${Number(pixels[2])}` };
|
|
1483
1487
|
const ratio = /^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/.exec(size);
|
|
1484
|
-
if (api
|
|
1488
|
+
if (api !== "openai-images" && ratio && Number(ratio[1]) > 0 && Number(ratio[2]) > 0 && Number.isFinite(Number(ratio[1]) / Number(ratio[2])))
|
|
1485
1489
|
return { aspect_ratio: `${Number(ratio[1])}:${Number(ratio[2])}` };
|
|
1486
1490
|
throw usage(api === "openai-images" ? "This API format requires WIDTHxHEIGHT or a preset; arbitrary aspect-only sizes are unsupported." : "Use WIDTHxHEIGHT, an aspect ratio, preset, or auto.");
|
|
1487
1491
|
}
|
|
1492
|
+
function comfySize(value) {
|
|
1493
|
+
const fields = imageSize(value, "comfyui");
|
|
1494
|
+
let width, height;
|
|
1495
|
+
if (fields.aspect_ratio) {
|
|
1496
|
+
const [w, h] = fields.aspect_ratio.split(":").map(Number);
|
|
1497
|
+
const scale = Math.sqrt(1024 * 1024 / (w * h));
|
|
1498
|
+
[width, height] = [w * scale, h * scale];
|
|
1499
|
+
} else if (fields.size)
|
|
1500
|
+
[width, height] = fields.size.split("x").map(Number);
|
|
1501
|
+
else
|
|
1502
|
+
return void 0;
|
|
1503
|
+
width = Math.round(width / 16) * 16;
|
|
1504
|
+
height = Math.round(height / 16) * 16;
|
|
1505
|
+
if (Math.min(width, height) < 256 || Math.max(width, height) > 2048)
|
|
1506
|
+
throw usage("ComfyUI image edges must be from 256 to 2048 pixels.");
|
|
1507
|
+
return { width, height };
|
|
1508
|
+
}
|
|
1488
1509
|
|
|
1489
1510
|
// ../../packages/providers-configurable/dist/http.js
|
|
1490
|
-
var
|
|
1511
|
+
var record2 = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
1491
1512
|
var EndpointHttp = class {
|
|
1492
1513
|
fetchImpl;
|
|
1493
1514
|
timeoutSeconds;
|
|
@@ -1511,9 +1532,9 @@ var EndpointHttp = class {
|
|
|
1511
1532
|
});
|
|
1512
1533
|
if (!response.ok) {
|
|
1513
1534
|
const status = response.status;
|
|
1514
|
-
const failure2 =
|
|
1515
|
-
const detail =
|
|
1516
|
-
const upstream =
|
|
1535
|
+
const failure2 = record2(await response.json().catch(() => null));
|
|
1536
|
+
const detail = record2(failure2.error);
|
|
1537
|
+
const upstream = record2(detail.metadata).provider_name;
|
|
1517
1538
|
const token = new Headers(init.headers).get("authorization")?.replace(/^Bearer\s+/i, "");
|
|
1518
1539
|
const upstreamProvider = typeof upstream === "string" && /^[A-Za-z0-9][A-Za-z0-9 ._()-]{0,63}$/.test(upstream) && !(token && upstream.includes(token)) && !/(?:sk-|AIza)/.test(upstream) ? upstream : void 0;
|
|
1519
1540
|
const retryHeader = response.headers.get("retry-after");
|
|
@@ -1537,7 +1558,7 @@ var EndpointHttp = class {
|
|
|
1537
1558
|
const value = await response.json();
|
|
1538
1559
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1539
1560
|
throw new Error();
|
|
1540
|
-
return
|
|
1561
|
+
return record2(value);
|
|
1541
1562
|
} catch {
|
|
1542
1563
|
throw new ProviderError({
|
|
1543
1564
|
kind: "malformed",
|
|
@@ -1615,6 +1636,625 @@ function openAIRequest(model, request, images, mask) {
|
|
|
1615
1636
|
return { path: "images/edits", body, json: false };
|
|
1616
1637
|
}
|
|
1617
1638
|
|
|
1639
|
+
// ../../packages/providers-configurable/dist/comfyui.js
|
|
1640
|
+
import { randomInt, randomUUID as randomUUID2 } from "node:crypto";
|
|
1641
|
+
import sharp3 from "sharp";
|
|
1642
|
+
|
|
1643
|
+
// ../../packages/providers-configurable/dist/comfyui-workflow.js
|
|
1644
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
1645
|
+
var TOKEN = /\{\{(\w+)\}\}/g;
|
|
1646
|
+
function usedTokens(graph) {
|
|
1647
|
+
const used = /* @__PURE__ */ new Set();
|
|
1648
|
+
for (const node of Object.values(graph))
|
|
1649
|
+
for (const value of Object.values(node.inputs))
|
|
1650
|
+
if (typeof value === "string")
|
|
1651
|
+
for (const match of value.matchAll(TOKEN))
|
|
1652
|
+
used.add(match[1]);
|
|
1653
|
+
return used;
|
|
1654
|
+
}
|
|
1655
|
+
async function loadWorkflow(path) {
|
|
1656
|
+
const invalid = (detail) => new ProviderError({
|
|
1657
|
+
kind: "configuration",
|
|
1658
|
+
message: `ComfyUI workflow ${path} ${detail}`
|
|
1659
|
+
});
|
|
1660
|
+
let parsed;
|
|
1661
|
+
try {
|
|
1662
|
+
parsed = JSON.parse(await readFile2(path, "utf8"));
|
|
1663
|
+
} catch {
|
|
1664
|
+
throw invalid("could not be read as JSON.");
|
|
1665
|
+
}
|
|
1666
|
+
const nodes = Object.values(record2(parsed));
|
|
1667
|
+
if (!nodes.length || nodes.some((node) => typeof record2(node).class_type !== "string" || typeof record2(node).inputs !== "object"))
|
|
1668
|
+
throw invalid("is not in API format. Export it from ComfyUI as API JSON.");
|
|
1669
|
+
const graph = parsed;
|
|
1670
|
+
const used = usedTokens(graph);
|
|
1671
|
+
const unknown = [...used].filter((t) => !/^(prompt|seed|width|height|image[1-9]\d*)$/.test(t));
|
|
1672
|
+
if (unknown.length)
|
|
1673
|
+
throw invalid(`uses unknown tokens: ${unknown.join(", ")}.`);
|
|
1674
|
+
if (!used.has("prompt") || !used.has("seed"))
|
|
1675
|
+
throw invalid("must use {{prompt}} and {{seed}}.");
|
|
1676
|
+
if (used.has("width") !== used.has("height"))
|
|
1677
|
+
throw invalid("must use {{width}} and {{height}} together.");
|
|
1678
|
+
const images = [...used].filter((t) => t.startsWith("image")).length;
|
|
1679
|
+
for (let i = 1; i <= images; i++)
|
|
1680
|
+
if (!used.has(`image${i}`))
|
|
1681
|
+
throw invalid(`must number its image tokens from {{image1}} in order.`);
|
|
1682
|
+
return { graph, images, sized: used.has("width") };
|
|
1683
|
+
}
|
|
1684
|
+
function fillWorkflow({ graph }, { prompt, seed, size, images }) {
|
|
1685
|
+
const values = {
|
|
1686
|
+
prompt,
|
|
1687
|
+
seed,
|
|
1688
|
+
...size ?? {},
|
|
1689
|
+
...Object.fromEntries(images.map((image, i) => [`image${i + 1}`, image]))
|
|
1690
|
+
};
|
|
1691
|
+
const fill = (value) => {
|
|
1692
|
+
if (typeof value !== "string")
|
|
1693
|
+
return value;
|
|
1694
|
+
const whole = /^\{\{(\w+)\}\}$/.exec(value);
|
|
1695
|
+
if (whole)
|
|
1696
|
+
return values[whole[1]];
|
|
1697
|
+
return value.replace(TOKEN, (_, token) => String(values[token]));
|
|
1698
|
+
};
|
|
1699
|
+
return Object.fromEntries(Object.entries(graph).map(([id, node]) => [
|
|
1700
|
+
id,
|
|
1701
|
+
{
|
|
1702
|
+
...node,
|
|
1703
|
+
inputs: Object.fromEntries(Object.entries(node.inputs).map(([key, value]) => [key, fill(value)]))
|
|
1704
|
+
}
|
|
1705
|
+
]));
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// ../../packages/providers-configurable/dist/validation.js
|
|
1709
|
+
function validateImageRequest(request, images) {
|
|
1710
|
+
if ((request.referenceImages?.length ?? 0) > 4)
|
|
1711
|
+
throw usage("At most four reference images are allowed.");
|
|
1712
|
+
const count = request.count ?? 1;
|
|
1713
|
+
if (!Number.isInteger(count) || count < 1 || count > 4)
|
|
1714
|
+
throw usage("Image count must be from 1 to 4.");
|
|
1715
|
+
for (const image of images)
|
|
1716
|
+
if (!["image/png", "image/jpeg", "image/webp"].includes(image.contentType))
|
|
1717
|
+
throw usage("Input images must be PNG, JPEG, or WebP.");
|
|
1718
|
+
const format = (request.format ?? "png").trim().toLowerCase().replace(/^jpg$/, "jpeg");
|
|
1719
|
+
if (!["png", "jpeg", "webp"].includes(format))
|
|
1720
|
+
throw usage("Output format must be png, jpeg, or webp.");
|
|
1721
|
+
return { count, format };
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// ../../packages/providers-configurable/dist/flux3-klein-prompts.js
|
|
1725
|
+
var FLUX3_KLEIN_GENERATE_PROMPT = `You are the prompt upsampler of a text-to-image model. Expand the user's short request into ONE detailed, self-contained caption: the exact text the image model receives. Output only the caption, with no greetings, explanations, refusals, questions, Markdown, headings, JSON, coordinates, ids or angle-bracket tokens, and no quotes around it. If the input is not clearly an image request, caption a visualization of its concept. The request may begin with a "TARGET CANVAS: W:H" line: compose for that canvas (wide spreads content sideways, tall stacks it) and never mention the ratio or the canvas.
|
|
1726
|
+
|
|
1727
|
+
Fidelity and planning: keep every explicit subject, attribute, count, spatial constraint, style cue and piece of wording; never contradict, substitute, drop or narrow the request, and keep an abstract concept abstract. Decide everything left open as ONE concrete, consistent plan; never offer alternatives or hedge. Never re-express a stated constraint through another mechanism; add camera optics (depth of field, bokeh, blur) only when asked for or implied. Without a stated style, plan an actual photograph and open with the medium convention ("Full-color outdoor photograph, ...", "Full-color indoor photograph, ...", "Studio photograph, ...", "Close-up photograph, ..."); keep "Photorealistic" for renders, and turn "photorealistic" or "realistic" requests for photos into the photograph convention. Give a photograph a few ordinary, non-ideal specifics drawn from the scene's own objects instead of the stock-photo ideal. Do not invent names, brands, logos or celebrities; keep user-named ones. A long request keeps its content but not its wording: drop appraisal and sales vocabulary, turn moods into observable physical facts, and add only new concrete content with positions. Time of day is lighting, not rendered text.
|
|
1728
|
+
|
|
1729
|
+
Caption format: one continuous prose block in the generative voice, never "this image shows", "we can see" or "is visible". Lead with medium and style, then scene, lighting and camera, then the subjects with concrete colors, materials and state. Every person in focus gets an observable profile: ethnicity, gender, age range, skin tone ("fair skin", "brown skin"), hair and clothing. Make the layout reconstructable from words: frame-relative positions (frame-left, frame-right, upper, lower, foreground, midground, background), sentence-initial anchors ("On the left, ...", "In the background, ..."), relations (holds, faces, behind) and a consistent order; describe up to about ten instances one by one, beyond that give the exact count and arrangement. Out-of-focus or distant planes are shapes, colors and silhouettes, never identities or readable text, and the background takes about a sixth of the caption. Be definite: no hedging adverbs, no quality or emotion judgments, no closing sentence about mood; state negations as what is present. Rendered text: transcribe every wording verbatim inside double quotes in its original language and script, keep the line breaks of multi-line text, and give its typography, color and placement; author concrete, domain-correct wording when the request needs text it does not supply, and name canonical structures (calendar, keyboard, periodic table) exactly with their full layout. All prose is English; only quoted text keeps its language. Typical length is 100 to 350 words, more for complex scenes.
|
|
1730
|
+
|
|
1731
|
+
Output only the caption.`;
|
|
1732
|
+
var FLUX3_KLEIN_EDIT_PROMPT = `You are the prompt upsampler of an image-editing model. You see the reference image(s), attached in order (the first image, the second image, ...), and the user's short request. Expand it into ONE detailed, self-contained instruction: the caption the editing model receives together with the same images. Output only the instruction as one continuous English prose block, with no greetings, explanations, Markdown, JSON, coordinates, ids or angle-bracket tokens. Read a vague or conversational request as the most plausible edit of the supplied images.
|
|
1733
|
+
|
|
1734
|
+
Pick the voice by task shape. When one reference stays and gets bounded changes, write an imperative editing instruction on it ("Remove the ...", "Change the ... to ...", "Zoom in on ..."); a whole-image transformation opens with its scope ("Convert the entire image into ...", "Apply a ... filter over the entire scene", "Horizontally flip the entire image so that ...", "Shift the camera viewpoint to ...", then where the major elements should now be). When the references are drawn into a newly composed frame (combining subjects, a new scene, a garment, object or style donor), write a generation request ("Create a photograph of ...", "Create an anime-style illustration of ...", "Generate a new image featuring ...") that says what to take from each image, by ordinal, and what is newly generated.
|
|
1735
|
+
|
|
1736
|
+
Keep every explicit edit, constraint, count, position, style cue and piece of wording, and decide what is left open as one concrete plan. Ground everything in what is actually visible: identify each referent unambiguously by its real appearance, never mention content that is not there, and use every image the user attached. State every end state concretely (which color, how large, which pose, which material) and give new content enough appearance to be generated. Cover the knock-on effects of the change (shadows, reflections, occlusion, perspective, scale, lighting) and say what fills a removed region. A zoom-in names what stays in frame and which neighbors are cropped out; an outpainting continues the scene in the same medium. Describe the change, not the whole scene, and close an editing instruction with one compact clause naming what stays unchanged; transferred people keep their identity, face and accessories unless asked otherwise. Transcribe on-image text verbatim in its original language inside double quotes, with line breaks as \\n, and match the original font, size and color when replacing it. Name recognizable public figures, brands and landmarks you can see. Directions are frame-relative; use no evaluative or quality words. Typical length is 50 to 150 words.
|
|
1737
|
+
|
|
1738
|
+
Output only the instruction.`;
|
|
1739
|
+
|
|
1740
|
+
// ../../packages/providers-configurable/dist/flux3-klein.js
|
|
1741
|
+
var FLUX3_KLEIN_CHECKPOINT = "flux3-image-klein-int8-convrot";
|
|
1742
|
+
var FLUX3_KLEIN_MODELS = [
|
|
1743
|
+
{
|
|
1744
|
+
id: "flux3-klein",
|
|
1745
|
+
name: "FLUX 3 Klein, prompt expanded by its upsampler",
|
|
1746
|
+
upsample: true
|
|
1747
|
+
},
|
|
1748
|
+
{
|
|
1749
|
+
id: "flux3-klein-raw",
|
|
1750
|
+
name: "FLUX 3 Klein, prompt used verbatim as the full caption",
|
|
1751
|
+
upsample: false
|
|
1752
|
+
}
|
|
1753
|
+
];
|
|
1754
|
+
var UPSAMPLER = "qwen3.5_9b_flux3_prompt_upsampler_w4a8.safetensors";
|
|
1755
|
+
var ENCODER = "qwen3vl_4b_int8_convrot.safetensors";
|
|
1756
|
+
var VAE = "flux2-vae.safetensors";
|
|
1757
|
+
var WEIGHTS = [
|
|
1758
|
+
{
|
|
1759
|
+
node: "UNETLoader",
|
|
1760
|
+
input: "unet_name",
|
|
1761
|
+
folder: "diffusion_models",
|
|
1762
|
+
file: `${FLUX3_KLEIN_CHECKPOINT}.safetensors`
|
|
1763
|
+
},
|
|
1764
|
+
{
|
|
1765
|
+
node: "CLIPLoader",
|
|
1766
|
+
input: "clip_name",
|
|
1767
|
+
folder: "text_encoders",
|
|
1768
|
+
file: ENCODER
|
|
1769
|
+
},
|
|
1770
|
+
{
|
|
1771
|
+
node: "VAELoader",
|
|
1772
|
+
input: "vae_name",
|
|
1773
|
+
folder: "vae",
|
|
1774
|
+
file: VAE
|
|
1775
|
+
}
|
|
1776
|
+
];
|
|
1777
|
+
var flux3KleinWeights = (model) => model.upsample ? [
|
|
1778
|
+
...WEIGHTS,
|
|
1779
|
+
{
|
|
1780
|
+
node: "CLIPLoader",
|
|
1781
|
+
input: "clip_name",
|
|
1782
|
+
folder: "text_encoders",
|
|
1783
|
+
file: UPSAMPLER
|
|
1784
|
+
}
|
|
1785
|
+
] : WEIGHTS;
|
|
1786
|
+
function flux3KleinModel(id) {
|
|
1787
|
+
const model = FLUX3_KLEIN_MODELS.find((m) => m.id === id);
|
|
1788
|
+
if (!model)
|
|
1789
|
+
throw usage(`Built-in ComfyUI models are ${FLUX3_KLEIN_MODELS.map((m) => m.id).join(" and ")}. Add --workflow to the profile for any other model.`);
|
|
1790
|
+
return model;
|
|
1791
|
+
}
|
|
1792
|
+
var STEPS = { low: 4, medium: 8, high: 16, auto: 16 };
|
|
1793
|
+
function flux3KleinSteps(quality) {
|
|
1794
|
+
const steps = STEPS[(quality ?? "auto").trim().toLowerCase()];
|
|
1795
|
+
if (!steps)
|
|
1796
|
+
throw usage("Quality must be low, medium, high, or auto.");
|
|
1797
|
+
return steps;
|
|
1798
|
+
}
|
|
1799
|
+
function upsamplerRequest(job) {
|
|
1800
|
+
if (job.images.length || !job.size || job.size.width === job.size.height)
|
|
1801
|
+
return job.prompt;
|
|
1802
|
+
const divisor = (a, b) => b ? divisor(b, a % b) : a;
|
|
1803
|
+
const shared = divisor(job.size.width, job.size.height);
|
|
1804
|
+
return `TARGET CANVAS: ${job.size.width / shared}:${job.size.height / shared}
|
|
1805
|
+
${job.prompt}`;
|
|
1806
|
+
}
|
|
1807
|
+
function flux3KleinWorkflow(job) {
|
|
1808
|
+
const editing = job.images.length > 0;
|
|
1809
|
+
const nodes = {
|
|
1810
|
+
model: {
|
|
1811
|
+
class_type: "UNETLoader",
|
|
1812
|
+
inputs: {
|
|
1813
|
+
unet_name: `${FLUX3_KLEIN_CHECKPOINT}.safetensors`,
|
|
1814
|
+
weight_dtype: "default"
|
|
1815
|
+
}
|
|
1816
|
+
},
|
|
1817
|
+
encoder: {
|
|
1818
|
+
class_type: "CLIPLoader",
|
|
1819
|
+
inputs: { clip_name: ENCODER, type: "flux3", device: "default" }
|
|
1820
|
+
},
|
|
1821
|
+
vae: { class_type: "VAELoader", inputs: { vae_name: VAE } }
|
|
1822
|
+
};
|
|
1823
|
+
job.images.forEach((image, i) => {
|
|
1824
|
+
nodes[`image${i}`] = { class_type: "LoadImage", inputs: { image } };
|
|
1825
|
+
});
|
|
1826
|
+
if (job.upsample) {
|
|
1827
|
+
nodes.upsampler_clip = {
|
|
1828
|
+
class_type: "CLIPLoader",
|
|
1829
|
+
inputs: { clip_name: UPSAMPLER, type: "flux3", device: "default" }
|
|
1830
|
+
};
|
|
1831
|
+
nodes.upsampler = {
|
|
1832
|
+
class_type: "TextGenerateV2",
|
|
1833
|
+
inputs: {
|
|
1834
|
+
clip: ["upsampler_clip", 0],
|
|
1835
|
+
prompt: upsamplerRequest(job),
|
|
1836
|
+
system_prompt: editing ? FLUX3_KLEIN_EDIT_PROMPT : FLUX3_KLEIN_GENERATE_PROMPT,
|
|
1837
|
+
max_length: 3584,
|
|
1838
|
+
sampling_mode: "on",
|
|
1839
|
+
"sampling_mode.temperature": 1,
|
|
1840
|
+
"sampling_mode.top_k": 1,
|
|
1841
|
+
"sampling_mode.top_p": 0.95,
|
|
1842
|
+
"sampling_mode.min_p": 0,
|
|
1843
|
+
"sampling_mode.repetition_penalty": 1.05,
|
|
1844
|
+
"sampling_mode.presence_penalty": editing ? 0 : 1.5,
|
|
1845
|
+
"sampling_mode.seed": 0,
|
|
1846
|
+
thinking: false,
|
|
1847
|
+
use_default_template: true,
|
|
1848
|
+
mtp: "off",
|
|
1849
|
+
...Object.fromEntries(job.images.map((_, i) => [`images.image${i + 1}`, [`image${i}`, 0]]))
|
|
1850
|
+
}
|
|
1851
|
+
};
|
|
1852
|
+
nodes.caption = {
|
|
1853
|
+
class_type: "PreviewAny",
|
|
1854
|
+
inputs: { source: ["upsampler", 0] }
|
|
1855
|
+
};
|
|
1856
|
+
}
|
|
1857
|
+
nodes.positive = {
|
|
1858
|
+
class_type: "CLIPTextEncode",
|
|
1859
|
+
inputs: {
|
|
1860
|
+
clip: ["encoder", 0],
|
|
1861
|
+
text: job.upsample ? ["upsampler", 0] : job.prompt
|
|
1862
|
+
}
|
|
1863
|
+
};
|
|
1864
|
+
nodes.negative = editing ? {
|
|
1865
|
+
class_type: "ConditioningZeroOut",
|
|
1866
|
+
inputs: { conditioning: ["positive", 0] }
|
|
1867
|
+
} : {
|
|
1868
|
+
class_type: "CLIPTextEncode",
|
|
1869
|
+
inputs: { clip: ["encoder", 0], text: "" }
|
|
1870
|
+
};
|
|
1871
|
+
if (editing)
|
|
1872
|
+
nodes.references = {
|
|
1873
|
+
class_type: "ReferenceMultiLatent",
|
|
1874
|
+
inputs: {
|
|
1875
|
+
positive: ["positive", 0],
|
|
1876
|
+
negative: ["negative", 0],
|
|
1877
|
+
vae: ["vae", 0],
|
|
1878
|
+
resolution: "resize",
|
|
1879
|
+
"resolution.megapixels": 1,
|
|
1880
|
+
...Object.fromEntries(job.images.map((_, i) => [`images.image${i}`, [`image${i}`, 0]]))
|
|
1881
|
+
}
|
|
1882
|
+
};
|
|
1883
|
+
if (!editing || job.size) {
|
|
1884
|
+
const { width, height } = job.size ?? { width: 1024, height: 1024 };
|
|
1885
|
+
nodes.latent = {
|
|
1886
|
+
class_type: "EmptyFlux2LatentImage",
|
|
1887
|
+
inputs: { width, height, batch_size: 1 }
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1890
|
+
nodes.sampler = {
|
|
1891
|
+
class_type: "KSampler",
|
|
1892
|
+
inputs: {
|
|
1893
|
+
model: ["model", 0],
|
|
1894
|
+
seed: job.seed,
|
|
1895
|
+
steps: job.steps,
|
|
1896
|
+
cfg: 1,
|
|
1897
|
+
sampler_name: "euler",
|
|
1898
|
+
scheduler: "simple",
|
|
1899
|
+
denoise: 1,
|
|
1900
|
+
positive: editing ? ["references", 0] : ["positive", 0],
|
|
1901
|
+
negative: editing ? ["references", 1] : ["negative", 0],
|
|
1902
|
+
// Without a requested size, edits keep the first reference's shape.
|
|
1903
|
+
latent_image: nodes.latent ? ["latent", 0] : ["references", 2]
|
|
1904
|
+
}
|
|
1905
|
+
};
|
|
1906
|
+
nodes.decode = {
|
|
1907
|
+
class_type: "VAEDecode",
|
|
1908
|
+
inputs: { samples: ["sampler", 0], vae: ["vae", 0] }
|
|
1909
|
+
};
|
|
1910
|
+
nodes.output = {
|
|
1911
|
+
class_type: "PreviewImage",
|
|
1912
|
+
inputs: { images: ["decode", 0] }
|
|
1913
|
+
};
|
|
1914
|
+
return nodes;
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
// ../../packages/providers-configurable/dist/comfyui.js
|
|
1918
|
+
var EXTENSIONS = {
|
|
1919
|
+
"image/png": "png",
|
|
1920
|
+
"image/jpeg": "jpg",
|
|
1921
|
+
"image/webp": "webp"
|
|
1922
|
+
};
|
|
1923
|
+
function comboOptions(spec) {
|
|
1924
|
+
if (!Array.isArray(spec))
|
|
1925
|
+
return void 0;
|
|
1926
|
+
const choices = spec[0] === "COMBO" ? record2(spec[1]).options : spec[0];
|
|
1927
|
+
return Array.isArray(choices) ? choices : void 0;
|
|
1928
|
+
}
|
|
1929
|
+
var comboChoices = (spec) => comboOptions(spec) ?? [];
|
|
1930
|
+
async function sourceShape(image) {
|
|
1931
|
+
try {
|
|
1932
|
+
const { width, height } = await sharp3(image.data).metadata();
|
|
1933
|
+
return comfySize(`${width}:${height}`);
|
|
1934
|
+
} catch (e) {
|
|
1935
|
+
if (e instanceof ProviderError)
|
|
1936
|
+
throw e;
|
|
1937
|
+
throw usage("Could not read the source image's dimensions.");
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
function executionFailure(messages) {
|
|
1941
|
+
const failure2 = Array.isArray(messages) ? messages.find((m) => Array.isArray(m) && m[0] === "execution_error") : void 0;
|
|
1942
|
+
const detail = record2(Array.isArray(failure2) ? failure2[1] : void 0);
|
|
1943
|
+
const node = typeof detail.node_type === "string" ? detail.node_type : "the workflow";
|
|
1944
|
+
const reason = typeof detail.exception_message === "string" ? detail.exception_message.replace(/\s+/g, " ").trim().slice(0, 300) : "";
|
|
1945
|
+
const memory = /out ?of ?memory/i.test(`${detail.exception_type} ${reason}`);
|
|
1946
|
+
return new ProviderError({
|
|
1947
|
+
kind: "server",
|
|
1948
|
+
message: `ComfyUI failed in ${node}${reason ? `: ${reason}` : "."}${memory ? " Close other GPU applications or request a smaller --size." : ""}`
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
async function encoded(data, format) {
|
|
1952
|
+
try {
|
|
1953
|
+
const result = await sharp3(data).toFormat(format).toBuffer({ resolveWithObject: true });
|
|
1954
|
+
return {
|
|
1955
|
+
data: result.data,
|
|
1956
|
+
width: result.info.width,
|
|
1957
|
+
height: result.info.height
|
|
1958
|
+
};
|
|
1959
|
+
} catch {
|
|
1960
|
+
throw new ProviderError({
|
|
1961
|
+
kind: "malformed",
|
|
1962
|
+
message: "ComfyUI returned an invalid raster image."
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
var ComfyUIImageProvider = class {
|
|
1967
|
+
name;
|
|
1968
|
+
profile;
|
|
1969
|
+
baseUrl;
|
|
1970
|
+
defaultModel;
|
|
1971
|
+
http;
|
|
1972
|
+
pollIntervalMs;
|
|
1973
|
+
constructor(profile, options = {}) {
|
|
1974
|
+
const valid = validateProfile(profile);
|
|
1975
|
+
this.profile = valid;
|
|
1976
|
+
this.name = valid.name;
|
|
1977
|
+
this.baseUrl = valid.baseUrl;
|
|
1978
|
+
this.defaultModel = valid.model;
|
|
1979
|
+
this.http = new EndpointHttp(options.fetchImpl, options.timeoutSeconds ?? 600);
|
|
1980
|
+
this.pollIntervalMs = options.pollIntervalMs ?? 500;
|
|
1981
|
+
}
|
|
1982
|
+
sizePolicy = {
|
|
1983
|
+
resolveSize: (size) => {
|
|
1984
|
+
const resolved = comfySize(size);
|
|
1985
|
+
if (!resolved)
|
|
1986
|
+
return { size: "auto" };
|
|
1987
|
+
const { width, height } = resolved;
|
|
1988
|
+
return {
|
|
1989
|
+
width,
|
|
1990
|
+
height,
|
|
1991
|
+
pixels: width * height,
|
|
1992
|
+
size: `${width}x${height}`
|
|
1993
|
+
};
|
|
1994
|
+
}
|
|
1995
|
+
};
|
|
1996
|
+
generate(request) {
|
|
1997
|
+
return this.run(request, request.referenceImages ?? []);
|
|
1998
|
+
}
|
|
1999
|
+
async edit(request) {
|
|
2000
|
+
if (request.mask)
|
|
2001
|
+
throw usage("ComfyUI profiles have no mask input. Describe the edit region in the prompt.");
|
|
2002
|
+
return this.run(request, [
|
|
2003
|
+
request.sourceImage,
|
|
2004
|
+
...request.referenceImages ?? []
|
|
2005
|
+
]);
|
|
2006
|
+
}
|
|
2007
|
+
get usesWorkflowFiles() {
|
|
2008
|
+
return Boolean(this.profile.workflow || this.profile.editWorkflow);
|
|
2009
|
+
}
|
|
2010
|
+
async models() {
|
|
2011
|
+
if (this.usesWorkflowFiles) {
|
|
2012
|
+
const { workflow, editWorkflow } = this.profile;
|
|
2013
|
+
const files = await Promise.all([workflow, editWorkflow].flatMap((f) => f ? [loadWorkflow(f)] : []));
|
|
2014
|
+
return this.reachable(() => this.http.run(async (http) => {
|
|
2015
|
+
for (const file of files)
|
|
2016
|
+
await this.requireInputs(http, file.graph);
|
|
2017
|
+
return [
|
|
2018
|
+
{
|
|
2019
|
+
id: this.defaultModel,
|
|
2020
|
+
name: `${this.defaultModel}, from this profile's workflow files`,
|
|
2021
|
+
capabilities: "reported",
|
|
2022
|
+
inputModalities: editWorkflow ? ["text", "image"] : ["text"],
|
|
2023
|
+
outputModalities: ["image"]
|
|
2024
|
+
}
|
|
2025
|
+
];
|
|
2026
|
+
}));
|
|
2027
|
+
}
|
|
2028
|
+
flux3KleinModel(this.defaultModel);
|
|
2029
|
+
return this.reachable(() => this.http.run(async (http) => {
|
|
2030
|
+
const models = [];
|
|
2031
|
+
for (const { id, name, upsample } of FLUX3_KLEIN_MODELS)
|
|
2032
|
+
if (!(await this.missingWeights(http, { id, name, upsample })).length)
|
|
2033
|
+
models.push({
|
|
2034
|
+
id,
|
|
2035
|
+
name,
|
|
2036
|
+
capabilities: "reported",
|
|
2037
|
+
inputModalities: ["text", "image"],
|
|
2038
|
+
outputModalities: ["image"]
|
|
2039
|
+
});
|
|
2040
|
+
return models;
|
|
2041
|
+
}));
|
|
2042
|
+
}
|
|
2043
|
+
async run(request, images) {
|
|
2044
|
+
const { count, format } = validateImageRequest(request, images);
|
|
2045
|
+
const plan = this.usesWorkflowFiles ? await this.workflowPlan(request, images) : this.kleinPlan(request);
|
|
2046
|
+
return this.reachable(async () => {
|
|
2047
|
+
await this.http.run((http) => plan.preflight(http));
|
|
2048
|
+
const uploaded = [];
|
|
2049
|
+
for (const image of images)
|
|
2050
|
+
uploaded.push(await this.upload(image));
|
|
2051
|
+
const results = [];
|
|
2052
|
+
for (let i = 0; i < count; i++)
|
|
2053
|
+
results.push(await this.render(plan, uploaded, randomInt(281474976710655), format));
|
|
2054
|
+
return {
|
|
2055
|
+
provider: this.name,
|
|
2056
|
+
requestedModel: plan.model,
|
|
2057
|
+
images: results
|
|
2058
|
+
};
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
kleinPlan(request) {
|
|
2062
|
+
const model = flux3KleinModel((request.model ?? this.defaultModel).trim());
|
|
2063
|
+
const steps = flux3KleinSteps(request.quality);
|
|
2064
|
+
const size = comfySize(request.size);
|
|
2065
|
+
return {
|
|
2066
|
+
model: model.id,
|
|
2067
|
+
preflight: async (http) => {
|
|
2068
|
+
const missing = await this.missingWeights(http, model);
|
|
2069
|
+
if (missing.length)
|
|
2070
|
+
throw new ProviderError({
|
|
2071
|
+
kind: "configuration",
|
|
2072
|
+
message: `ComfyUI is missing FLUX 3 Klein weights: ${missing.join(", ")}. Download them from black-forest-labs/flux-3-image-klein-comfy into its models folder.`
|
|
2073
|
+
});
|
|
2074
|
+
},
|
|
2075
|
+
graph: (images, seed) => flux3KleinWorkflow({
|
|
2076
|
+
prompt: request.prompt,
|
|
2077
|
+
upsample: model.upsample,
|
|
2078
|
+
images,
|
|
2079
|
+
size,
|
|
2080
|
+
steps,
|
|
2081
|
+
seed
|
|
2082
|
+
}),
|
|
2083
|
+
described: (outputs, seed) => {
|
|
2084
|
+
const caption = record2(outputs.caption).text;
|
|
2085
|
+
return {
|
|
2086
|
+
prompt: model.upsample && Array.isArray(caption) && typeof caption[0] === "string" ? caption[0] : request.prompt,
|
|
2087
|
+
servedModel: FLUX3_KLEIN_CHECKPOINT,
|
|
2088
|
+
usage: { served: { steps, seed, upsampled: model.upsample } }
|
|
2089
|
+
};
|
|
2090
|
+
}
|
|
2091
|
+
};
|
|
2092
|
+
}
|
|
2093
|
+
async workflowPlan(request, images) {
|
|
2094
|
+
const model = this.defaultModel;
|
|
2095
|
+
if (request.model && request.model.trim() !== model)
|
|
2096
|
+
throw usage(`Profile ${this.name} renders ${model} from its workflow files. Update the profile to change models.`);
|
|
2097
|
+
if ((request.quality ?? "auto").trim().toLowerCase() !== "auto")
|
|
2098
|
+
throw usage("Workflow files set their own sampling, so only --quality auto applies.");
|
|
2099
|
+
const path = images.length ? this.profile.editWorkflow : this.profile.workflow;
|
|
2100
|
+
if (!path)
|
|
2101
|
+
throw usage(images.length ? `Profile ${this.name} has no --edit-workflow, so it cannot take input images.` : `Profile ${this.name} has no --workflow, so it needs a source image.`);
|
|
2102
|
+
const workflow = await loadWorkflow(path);
|
|
2103
|
+
if (workflow.images !== images.length)
|
|
2104
|
+
throw usage(`This workflow takes ${workflow.images} input images, but the request has ${images.length}.`);
|
|
2105
|
+
const requested = comfySize(request.size);
|
|
2106
|
+
if (requested && !workflow.sized)
|
|
2107
|
+
throw usage("This workflow has fixed dimensions, so only --size auto applies.");
|
|
2108
|
+
const size = !workflow.sized ? void 0 : requested ?? (images[0] ? await sourceShape(images[0]) : { width: 1024, height: 1024 });
|
|
2109
|
+
return {
|
|
2110
|
+
model,
|
|
2111
|
+
preflight: (http) => this.requireInputs(http, workflow.graph),
|
|
2112
|
+
graph: (uploaded, seed) => fillWorkflow(workflow, {
|
|
2113
|
+
prompt: request.prompt,
|
|
2114
|
+
seed,
|
|
2115
|
+
size,
|
|
2116
|
+
images: uploaded
|
|
2117
|
+
}),
|
|
2118
|
+
described: (_, seed) => ({
|
|
2119
|
+
prompt: request.prompt,
|
|
2120
|
+
servedModel: model,
|
|
2121
|
+
usage: { served: { seed } }
|
|
2122
|
+
})
|
|
2123
|
+
};
|
|
2124
|
+
}
|
|
2125
|
+
async reachable(action) {
|
|
2126
|
+
try {
|
|
2127
|
+
return await action();
|
|
2128
|
+
} catch (e) {
|
|
2129
|
+
if (e instanceof ProviderError && e.kind === "transport") {
|
|
2130
|
+
const start = this.profile.startCommand;
|
|
2131
|
+
throw new ProviderError({
|
|
2132
|
+
kind: "transport",
|
|
2133
|
+
message: `ComfyUI is not reachable at ${this.baseUrl}. Start it${start ? ` with \`${start}\`` : ""} and retry.`
|
|
2134
|
+
});
|
|
2135
|
+
}
|
|
2136
|
+
throw e;
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
// Names what a workflow file needs instead of queueing a doomed job.
|
|
2140
|
+
async requireInputs(http, graph) {
|
|
2141
|
+
const specs = /* @__PURE__ */ new Map();
|
|
2142
|
+
const missing = /* @__PURE__ */ new Set();
|
|
2143
|
+
for (const node of Object.values(graph)) {
|
|
2144
|
+
if (!specs.has(node.class_type)) {
|
|
2145
|
+
const info = await http.json(`${this.baseUrl}/object_info/${encodeURIComponent(node.class_type)}`);
|
|
2146
|
+
specs.set(node.class_type, record2(info[node.class_type]));
|
|
2147
|
+
}
|
|
2148
|
+
const spec = record2(specs.get(node.class_type));
|
|
2149
|
+
if (!Object.keys(spec).length) {
|
|
2150
|
+
missing.add(`node type ${node.class_type}`);
|
|
2151
|
+
continue;
|
|
2152
|
+
}
|
|
2153
|
+
const inputs = {
|
|
2154
|
+
...record2(record2(spec.input).required),
|
|
2155
|
+
...record2(record2(spec.input).optional)
|
|
2156
|
+
};
|
|
2157
|
+
for (const [name, value] of Object.entries(node.inputs)) {
|
|
2158
|
+
if (typeof value !== "string" || value.includes("{{"))
|
|
2159
|
+
continue;
|
|
2160
|
+
if (comboOptions(inputs[name])?.includes(value) === false)
|
|
2161
|
+
missing.add(`${value} for ${node.class_type}.${name}`);
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
if (missing.size)
|
|
2165
|
+
throw new ProviderError({
|
|
2166
|
+
kind: "configuration",
|
|
2167
|
+
message: `ComfyUI is missing what this workflow needs: ${[...missing].join(", ")}. Install it under ComfyUI, restart the server, and retry.`
|
|
2168
|
+
});
|
|
2169
|
+
}
|
|
2170
|
+
async missingWeights(http, model) {
|
|
2171
|
+
const choices = /* @__PURE__ */ new Map();
|
|
2172
|
+
const missing = [];
|
|
2173
|
+
for (const weight of flux3KleinWeights(model)) {
|
|
2174
|
+
if (!choices.has(weight.node)) {
|
|
2175
|
+
const info = await http.json(`${this.baseUrl}/object_info/${weight.node}`);
|
|
2176
|
+
const inputs = record2(record2(record2(info[weight.node]).input).required);
|
|
2177
|
+
choices.set(weight.node, comboChoices(inputs[weight.input]));
|
|
2178
|
+
}
|
|
2179
|
+
if (!choices.get(weight.node)?.includes(weight.file))
|
|
2180
|
+
missing.push(`${weight.folder}/${weight.file}`);
|
|
2181
|
+
}
|
|
2182
|
+
return missing;
|
|
2183
|
+
}
|
|
2184
|
+
// The temp folder keeps inputs out of ComfyUI's library; it clears on restart.
|
|
2185
|
+
upload(image) {
|
|
2186
|
+
return this.http.run(async (http) => {
|
|
2187
|
+
const form = new FormData();
|
|
2188
|
+
form.set("image", new Blob([new Uint8Array(image.data)], { type: image.contentType }), `imagegen-${randomUUID2()}.${EXTENSIONS[image.contentType]}`);
|
|
2189
|
+
form.set("type", "temp");
|
|
2190
|
+
const body = await http.json(`${this.baseUrl}/upload/image`, {
|
|
2191
|
+
method: "POST",
|
|
2192
|
+
body: form
|
|
2193
|
+
});
|
|
2194
|
+
if (typeof body.name !== "string")
|
|
2195
|
+
throw new ProviderError({
|
|
2196
|
+
kind: "malformed",
|
|
2197
|
+
message: "ComfyUI did not return an uploaded image name."
|
|
2198
|
+
});
|
|
2199
|
+
return `${body.name} [temp]`;
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2202
|
+
render(plan, images, seed, format) {
|
|
2203
|
+
return this.http.run(async (http) => {
|
|
2204
|
+
const queued = await http.json(`${this.baseUrl}/prompt`, {
|
|
2205
|
+
method: "POST",
|
|
2206
|
+
headers: { "content-type": "application/json" },
|
|
2207
|
+
body: JSON.stringify({ prompt: plan.graph(images, seed) })
|
|
2208
|
+
}).catch((e) => {
|
|
2209
|
+
if (e instanceof ProviderError && e.statusCode === 400)
|
|
2210
|
+
throw new ProviderError({
|
|
2211
|
+
kind: "server",
|
|
2212
|
+
statusCode: 400,
|
|
2213
|
+
message: `ComfyUI rejected the ${plan.model} workflow. Check its console for the failing node, and update ComfyUI if a node is unknown.`
|
|
2214
|
+
});
|
|
2215
|
+
throw e;
|
|
2216
|
+
});
|
|
2217
|
+
if (typeof queued.prompt_id !== "string")
|
|
2218
|
+
throw new ProviderError({
|
|
2219
|
+
kind: "malformed",
|
|
2220
|
+
message: "ComfyUI did not return a prompt ID."
|
|
2221
|
+
});
|
|
2222
|
+
const outputs = record2((await this.finished(http, queued.prompt_id)).outputs);
|
|
2223
|
+
const saved = Object.values(outputs).map((output) => record2(output).images).find((list) => Array.isArray(list) && list.length);
|
|
2224
|
+
const file = record2(Array.isArray(saved) ? saved[0] : void 0);
|
|
2225
|
+
if (typeof file.filename !== "string")
|
|
2226
|
+
throw new ProviderError({
|
|
2227
|
+
kind: "no_image",
|
|
2228
|
+
message: "ComfyUI finished without an output image."
|
|
2229
|
+
});
|
|
2230
|
+
const query = new URLSearchParams({
|
|
2231
|
+
filename: file.filename,
|
|
2232
|
+
subfolder: typeof file.subfolder === "string" ? file.subfolder : "",
|
|
2233
|
+
type: typeof file.type === "string" ? file.type : "temp"
|
|
2234
|
+
});
|
|
2235
|
+
const data = await http.bytes(`${this.baseUrl}/view?${query}`);
|
|
2236
|
+
return {
|
|
2237
|
+
...await encoded(data, format),
|
|
2238
|
+
mimeType: `image/${format}`,
|
|
2239
|
+
responseId: queued.prompt_id,
|
|
2240
|
+
...plan.described(outputs, seed)
|
|
2241
|
+
};
|
|
2242
|
+
});
|
|
2243
|
+
}
|
|
2244
|
+
async finished(http, id) {
|
|
2245
|
+
for (; ; ) {
|
|
2246
|
+
const history = await http.json(`${this.baseUrl}/history/${id}`);
|
|
2247
|
+
const entry = record2(history[id]);
|
|
2248
|
+
const status = record2(entry.status);
|
|
2249
|
+
if (status.status_str === "error")
|
|
2250
|
+
throw executionFailure(status.messages);
|
|
2251
|
+
if (status.completed === true)
|
|
2252
|
+
return entry;
|
|
2253
|
+
await new Promise((resolve4) => setTimeout(resolve4, this.pollIntervalMs));
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
};
|
|
2257
|
+
|
|
1618
2258
|
// ../../packages/providers-configurable/dist/provider.js
|
|
1619
2259
|
var ConfigurableImageProvider = class {
|
|
1620
2260
|
getKey;
|
|
@@ -1672,14 +2312,14 @@ var ConfigurableImageProvider = class {
|
|
|
1672
2312
|
message: "Model discovery response must contain a data array."
|
|
1673
2313
|
});
|
|
1674
2314
|
return body.data.map((value) => {
|
|
1675
|
-
const model =
|
|
2315
|
+
const model = record2(value);
|
|
1676
2316
|
if (typeof model.id !== "string")
|
|
1677
2317
|
throw new ProviderError({
|
|
1678
2318
|
kind: "malformed",
|
|
1679
2319
|
message: "Discovered model has no string ID."
|
|
1680
2320
|
});
|
|
1681
|
-
const architecture =
|
|
1682
|
-
const supported =
|
|
2321
|
+
const architecture = record2(model.architecture);
|
|
2322
|
+
const supported = record2(model.supported_parameters);
|
|
1683
2323
|
const strings = (v) => Array.isArray(v) && v.every((s) => typeof s === "string") ? v : void 0;
|
|
1684
2324
|
return {
|
|
1685
2325
|
id: model.id,
|
|
@@ -1698,19 +2338,12 @@ var ConfigurableImageProvider = class {
|
|
|
1698
2338
|
const model = (request.model ?? this.profile.model ?? "").trim();
|
|
1699
2339
|
if (!model)
|
|
1700
2340
|
throw usage("Supply --model or configure a default model with providers update.");
|
|
1701
|
-
if ((request.referenceImages?.length ?? 0) > 4)
|
|
1702
|
-
throw usage("At most four reference images are allowed.");
|
|
1703
|
-
const count = request.count ?? 1;
|
|
1704
|
-
if (!Number.isInteger(count) || count < 1 || count > 4)
|
|
1705
|
-
throw usage("Image count must be from 1 to 4.");
|
|
1706
2341
|
if (mask && this.profile.api === "openrouter")
|
|
1707
2342
|
throw usage("OpenRouter image API does not expose masks. Describe the edit region in the prompt.");
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
if (!["png", "jpeg", "webp"].includes(format))
|
|
1713
|
-
throw usage("Output format must be png, jpeg, or webp.");
|
|
2343
|
+
const { count, format } = validateImageRequest(request, [
|
|
2344
|
+
...images,
|
|
2345
|
+
...mask ? [mask] : []
|
|
2346
|
+
]);
|
|
1714
2347
|
const quality = request.quality?.trim().toLowerCase();
|
|
1715
2348
|
if (quality && !/^[a-z][a-z0-9_-]*$/.test(quality))
|
|
1716
2349
|
throw usage("Quality must be a provider-supported quality name.");
|
|
@@ -1737,7 +2370,7 @@ var ConfigurableImageProvider = class {
|
|
|
1737
2370
|
kind: "malformed",
|
|
1738
2371
|
message: "Endpoint must return exactly one image for a single-image request."
|
|
1739
2372
|
});
|
|
1740
|
-
const image =
|
|
2373
|
+
const image = record2(body.data[0]);
|
|
1741
2374
|
let data;
|
|
1742
2375
|
if (typeof image.b64_json === "string") {
|
|
1743
2376
|
if (!image.b64_json || image.b64_json.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(image.b64_json))
|
|
@@ -1768,12 +2401,12 @@ var ConfigurableImageProvider = class {
|
|
|
1768
2401
|
message: "Endpoint returned no image bytes or download URL."
|
|
1769
2402
|
});
|
|
1770
2403
|
try {
|
|
1771
|
-
const metadata = await
|
|
2404
|
+
const metadata = await sharp4(data).metadata();
|
|
1772
2405
|
if (!metadata.width || !metadata.height || !["png", "jpeg", "webp"].includes(metadata.format ?? "") || typeof image.media_type === "string" && image.media_type !== `image/${metadata.format}`)
|
|
1773
2406
|
throw new Error();
|
|
1774
|
-
await
|
|
2407
|
+
await sharp4(data).stats();
|
|
1775
2408
|
if (metadata.format !== format)
|
|
1776
|
-
data = await
|
|
2409
|
+
data = await sharp4(data).toFormat(format).toBuffer();
|
|
1777
2410
|
return {
|
|
1778
2411
|
data,
|
|
1779
2412
|
width: metadata.width,
|
|
@@ -1789,12 +2422,15 @@ var ConfigurableImageProvider = class {
|
|
|
1789
2422
|
});
|
|
1790
2423
|
}
|
|
1791
2424
|
}));
|
|
1792
|
-
return { provider: this.name,
|
|
2425
|
+
return { provider: this.name, requestedModel: model, images: results };
|
|
1793
2426
|
}
|
|
1794
2427
|
};
|
|
2428
|
+
function createProfileProvider(profile) {
|
|
2429
|
+
return profile.api === "comfyui" ? new ComfyUIImageProvider(profile) : new ConfigurableImageProvider(profile, async () => profileKey(profile, await profileStore()));
|
|
2430
|
+
}
|
|
1795
2431
|
function createProfileEntry(profile) {
|
|
1796
2432
|
return {
|
|
1797
|
-
provider:
|
|
2433
|
+
provider: createProfileProvider(profile),
|
|
1798
2434
|
authStatus: async () => {
|
|
1799
2435
|
let configured = profile.auth === "none";
|
|
1800
2436
|
if (!configured)
|
|
@@ -1833,7 +2469,7 @@ async function readApiKey(fromStdin, input = process.stdin, output = process.std
|
|
|
1833
2469
|
const wasRaw = terminal.isRaw;
|
|
1834
2470
|
const wasFlowing = input.readableFlowing === true;
|
|
1835
2471
|
emitKeypressEvents(input);
|
|
1836
|
-
return new Promise((
|
|
2472
|
+
return new Promise((resolve4, reject) => {
|
|
1837
2473
|
let text = "";
|
|
1838
2474
|
const cleanup = () => {
|
|
1839
2475
|
input.removeListener("keypress", onKey);
|
|
@@ -1858,7 +2494,7 @@ async function readApiKey(fromStdin, input = process.stdin, output = process.std
|
|
|
1858
2494
|
}
|
|
1859
2495
|
if (key.name === "return" || key.name === "enter") {
|
|
1860
2496
|
cleanup();
|
|
1861
|
-
|
|
2497
|
+
resolve4(text);
|
|
1862
2498
|
return;
|
|
1863
2499
|
}
|
|
1864
2500
|
if (key.name === "backspace") {
|
|
@@ -1882,11 +2518,15 @@ async function readApiKey(fromStdin, input = process.stdin, output = process.std
|
|
|
1882
2518
|
|
|
1883
2519
|
// src/help.ts
|
|
1884
2520
|
var PROFILE_HELP = `Named endpoint profiles:
|
|
1885
|
-
imagegen providers add <name> --api <openrouter|openai-images>
|
|
1886
|
-
[--model <id>] [--auth <bearer|none>]
|
|
2521
|
+
imagegen providers add <name> --api <openrouter|openai-images|comfyui>
|
|
2522
|
+
[--base-url <url>] [--model <id>] [--auth <bearer|none>]
|
|
2523
|
+
[--start-command <text>] [--workflow <file>]
|
|
2524
|
+
[--edit-workflow <file>]
|
|
1887
2525
|
imagegen providers show <name>
|
|
1888
2526
|
imagegen providers update <name> [--api <format>] [--base-url <url>]
|
|
1889
2527
|
[--model <id>] [--auth <bearer|none>]
|
|
2528
|
+
[--start-command <text>] [--workflow <file>]
|
|
2529
|
+
[--edit-workflow <file>]
|
|
1890
2530
|
imagegen providers remove <name>
|
|
1891
2531
|
imagegen auth <name> login [--key-stdin]
|
|
1892
2532
|
imagegen auth <name> status [--show-path]
|
|
@@ -1894,8 +2534,9 @@ var PROFILE_HELP = `Named endpoint profiles:
|
|
|
1894
2534
|
imagegen models --provider <name>
|
|
1895
2535
|
|
|
1896
2536
|
OpenRouter defaults to https://openrouter.ai/api/v1. openai-images requires
|
|
1897
|
-
--base-url including the API prefix (e.g. https://api.openai.com/v1).
|
|
1898
|
-
|
|
2537
|
+
--base-url including the API prefix (e.g. https://api.openai.com/v1). comfyui
|
|
2538
|
+
requires --base-url of its server (e.g. http://127.0.0.1:8188) and --model, and
|
|
2539
|
+
uses no key. Keys use hidden terminal input or explicit --key-stdin and the existing vault/file
|
|
1899
2540
|
store. --auth none supports endpoints without keys. Config is saved in providers.json
|
|
1900
2541
|
alongside the credential file. Origin changes clear the saved key; removing a
|
|
1901
2542
|
profile removes its credentials. Logout preserves profile configuration.
|
|
@@ -1904,13 +2545,25 @@ Select named profiles with --provider. Model precedence: --model > profile defau
|
|
|
1904
2545
|
provide one if no default is saved. models explicitly discovers model metadata;
|
|
1905
2546
|
OpenAI-compatible model listings do not establish image capabilities.
|
|
1906
2547
|
|
|
1907
|
-
Supported protocols: OpenRouter dedicated Images API
|
|
1908
|
-
multipart endpoints,
|
|
2548
|
+
Supported protocols: OpenRouter dedicated Images API, OpenAI Images JSON /
|
|
2549
|
+
multipart endpoints, and local models through ComfyUI, not arbitrary HTTP
|
|
2550
|
+
templates. Options vary by model.
|
|
1909
2551
|
OpenRouter accepts aspect ratios; openai-images needs pixels or presets:
|
|
1910
2552
|
square 1024x1024, portrait 1024x1536, landscape 1536x1024, wide 1536x864.
|
|
1911
|
-
Auto options are omitted. Native masks require openai-images
|
|
2553
|
+
Auto options are omitted. Native masks require openai-images.
|
|
1912
2554
|
Outputs are measured and converted to PNG/JPEG/WebP without resizing. Count uses
|
|
1913
2555
|
separate single-image requests without automatic retries or model substitution.
|
|
2556
|
+
|
|
2557
|
+
comfyui profiles: --model names what the profile renders. Built in: flux3-klein
|
|
2558
|
+
expands the prompt and reports the rendered caption as prompt; flux3-klein-raw
|
|
2559
|
+
uses the prompt as the full caption; quality low, medium, and high run 4, 8, and
|
|
2560
|
+
16 steps. Any other model needs --workflow (text-to-image) and/or --edit-workflow
|
|
2561
|
+
(input images): ComfyUI API-format JSON using {{prompt}} and {{seed}}, optionally
|
|
2562
|
+
{{width}} and {{height}}, and {{image1}}, {{image2}}, ... for inputs. Workflow
|
|
2563
|
+
files set their own sampling, so only --quality auto applies.
|
|
2564
|
+
Sizes accept pixels, presets, or aspect ratios, round to multiples of 16, and need
|
|
2565
|
+
edges from 256 to 2048. Masks are rejected. The CLI does not start or stop ComfyUI;
|
|
2566
|
+
--start-command records how to start it and is quoted when it is not reachable.
|
|
1914
2567
|
`;
|
|
1915
2568
|
var IMAGE_OPTIONS = `Image options (generate, edit):
|
|
1916
2569
|
--prompt <text> Prompt text (required)
|
|
@@ -1938,32 +2591,41 @@ Gemini provider:
|
|
|
1938
2591
|
Model precedence: --model > constructor imageModel > IMAGEGEN_GEMINI_IMAGE_MODEL
|
|
1939
2592
|
> gemini-nano-banana-2.1. Calls are independent; use prior output as --source.
|
|
1940
2593
|
|
|
1941
|
-
|
|
1942
|
-
gpt-image-2.
|
|
1943
|
-
gpt-image-2.
|
|
1944
|
-
gpt-image-2
|
|
2594
|
+
Codex model requests (the backend chooses what actually runs):
|
|
2595
|
+
gpt-image-2.6-flare Requested by default
|
|
2596
|
+
gpt-image-2.6-sunburst Requested with --model
|
|
2597
|
+
gpt-image-2.5-flare Requested with --model
|
|
2598
|
+
gpt-image-2.5-sunburst Requested with --model
|
|
2599
|
+
gpt-image-2 Requested with --model
|
|
1945
2600
|
|
|
1946
|
-
|
|
2601
|
+
Codex accepts any model ID, including made-up ones, and does not report what ran;
|
|
2602
|
+
its Responses route rewrites these requests to gpt-image-2-codex. Use a named
|
|
2603
|
+
profile such as OpenRouter when a specific model matters.
|
|
1947
2604
|
Model precedence: --model > constructor imageModel (library callers) >
|
|
1948
|
-
IMAGEGEN_CODEX_IMAGE_MODEL > gpt-image-2.
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
Codex
|
|
1952
|
-
|
|
2605
|
+
IMAGEGEN_CODEX_IMAGE_MODEL > gpt-image-2.6-flare.
|
|
2606
|
+
JSON results report requestedModel, and servedModel per image (null unless the
|
|
2607
|
+
provider names one). Codex also reports served quality, size, and background, and
|
|
2608
|
+
may override what was requested. A Codex --mask is sent as the last edit image with a fixed
|
|
2609
|
+
instruction appended to the prompt; it steers the edit but does not guarantee
|
|
2610
|
+
untouched pixels outside it. It must match the source size, and uses one of the
|
|
2611
|
+
four edit-image slots.
|
|
2612
|
+
IMAGEGEN_USAGE_LOG=<file> appends one prompt-free JSON usage line per result.
|
|
2613
|
+
|
|
2614
|
+
Codex quality requests: auto (default), low, medium, high; Flare/Sunburst IDs also
|
|
2615
|
+
accept xhigh and max. gpt-image-2 rejects xhigh/max. The backend may serve another.
|
|
1953
2616
|
`;
|
|
1954
2617
|
var GENERATE_EXAMPLES = `Examples:
|
|
1955
2618
|
imagegen generate --provider gemini --size 16:9 --prompt "a lighthouse at dawn" --out ./gemini.png
|
|
1956
2619
|
imagegen generate --prompt "a lighthouse at dawn" --out ./lighthouse.png
|
|
1957
|
-
imagegen generate --model gpt-image-2 --prompt "a
|
|
1958
|
-
imagegen generate --model gpt-image-2.5-sunburst --quality max --prompt "a product photo" --out ./product.png
|
|
2620
|
+
imagegen generate --provider openrouter --model openai/gpt-image-2.6-sunburst --quality high --prompt "a product photo" --out ./product.png
|
|
1959
2621
|
`;
|
|
1960
2622
|
var EDIT_EXAMPLES = `Edit options:
|
|
1961
2623
|
--source <file> Source image (required)
|
|
1962
|
-
--mask <file> Optional edit mask
|
|
2624
|
+
--mask <file> Optional edit mask; transparent pixels mark the area to edit
|
|
1963
2625
|
|
|
1964
2626
|
Examples:
|
|
1965
2627
|
imagegen edit --format webp --source ./product.png --prompt "make the product blue" --out ./blue.webp
|
|
1966
|
-
imagegen edit --model gpt-image-2.
|
|
2628
|
+
imagegen edit --provider openrouter --model openai/gpt-image-2.6-sunburst --source ./product.png --prompt "add a ribbon" --out ./ribbon.png
|
|
1967
2629
|
`;
|
|
1968
2630
|
var USAGE = `imagegen - provider-agnostic image generation CLI
|
|
1969
2631
|
|
|
@@ -1981,6 +2643,7 @@ Usage:
|
|
|
1981
2643
|
imagegen skill
|
|
1982
2644
|
imagegen skill install --target <claude|codex|skills-dir> [--target <...>] [--force]
|
|
1983
2645
|
imagegen help [command]
|
|
2646
|
+
imagegen --version
|
|
1984
2647
|
|
|
1985
2648
|
${PROFILE_HELP}
|
|
1986
2649
|
${IMAGE_OPTIONS}
|
|
@@ -1996,6 +2659,7 @@ Auth options:
|
|
|
1996
2659
|
|
|
1997
2660
|
Global:
|
|
1998
2661
|
-h, --help Show help and exit 0
|
|
2662
|
+
--version Print the CLI version and exit 0
|
|
1999
2663
|
`;
|
|
2000
2664
|
var COMMAND_HELP = {
|
|
2001
2665
|
auth: `imagegen auth - manage provider credentials (one namespace per provider)
|
|
@@ -2102,9 +2766,9 @@ ${USAGE}`;
|
|
|
2102
2766
|
}
|
|
2103
2767
|
|
|
2104
2768
|
// src/skill.ts
|
|
2105
|
-
import { mkdir as
|
|
2769
|
+
import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
|
|
2106
2770
|
import { homedir as homedir2 } from "node:os";
|
|
2107
|
-
import { dirname as
|
|
2771
|
+
import { dirname as dirname3, join as join4, resolve } from "node:path";
|
|
2108
2772
|
var SKILL_NAME = "imagegen-cli";
|
|
2109
2773
|
var SkillExistsError = class extends Error {
|
|
2110
2774
|
constructor(path) {
|
|
@@ -2113,15 +2777,15 @@ var SkillExistsError = class extends Error {
|
|
|
2113
2777
|
}
|
|
2114
2778
|
};
|
|
2115
2779
|
async function skillText() {
|
|
2116
|
-
return
|
|
2780
|
+
return readFile3(new URL("../skill/SKILL.md", import.meta.url), "utf8");
|
|
2117
2781
|
}
|
|
2118
2782
|
function targetPath(target) {
|
|
2119
|
-
const root = target === "claude" ?
|
|
2120
|
-
return
|
|
2783
|
+
const root = target === "claude" ? join4(homedir2(), ".claude", "skills") : target === "codex" ? join4(homedir2(), ".agents", "skills") : resolve(target);
|
|
2784
|
+
return join4(root, SKILL_NAME, "SKILL.md");
|
|
2121
2785
|
}
|
|
2122
2786
|
async function existingText(path) {
|
|
2123
2787
|
try {
|
|
2124
|
-
return await
|
|
2788
|
+
return await readFile3(path, "utf8");
|
|
2125
2789
|
} catch (error2) {
|
|
2126
2790
|
if (error2.code === "ENOENT") return void 0;
|
|
2127
2791
|
throw error2;
|
|
@@ -2143,12 +2807,51 @@ async function installSkill(targets, force) {
|
|
|
2143
2807
|
}
|
|
2144
2808
|
for (const item of planned.values()) {
|
|
2145
2809
|
if (item.status === "up-to-date") continue;
|
|
2146
|
-
await
|
|
2810
|
+
await mkdir3(dirname3(item.path), { recursive: true });
|
|
2147
2811
|
await writeFile2(item.path, content, { encoding: "utf8", flag: item.status === "installed" ? "wx" : "w" });
|
|
2148
2812
|
}
|
|
2149
2813
|
return [...planned.values()];
|
|
2150
2814
|
}
|
|
2151
2815
|
|
|
2816
|
+
// src/usage-log.ts
|
|
2817
|
+
import { appendFile, mkdir as mkdir4 } from "node:fs/promises";
|
|
2818
|
+
import { dirname as dirname4, resolve as resolve2 } from "node:path";
|
|
2819
|
+
var USAGE_LOG_ENV = "IMAGEGEN_USAGE_LOG";
|
|
2820
|
+
function usageLogEntry(request, edit, result, paths) {
|
|
2821
|
+
return {
|
|
2822
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2823
|
+
command: edit ? "edit" : "generate",
|
|
2824
|
+
provider: result.provider,
|
|
2825
|
+
requestedModel: result.requestedModel,
|
|
2826
|
+
requested: {
|
|
2827
|
+
size: request.size ?? "auto",
|
|
2828
|
+
quality: request.quality ?? "auto",
|
|
2829
|
+
format: request.format ?? "png",
|
|
2830
|
+
count: request.count ?? 1,
|
|
2831
|
+
references: request.referenceImages?.length ?? 0,
|
|
2832
|
+
mask: edit ? edit.mask !== void 0 : void 0
|
|
2833
|
+
},
|
|
2834
|
+
images: result.images.map((image, i) => ({
|
|
2835
|
+
path: paths[i],
|
|
2836
|
+
width: image.width,
|
|
2837
|
+
height: image.height,
|
|
2838
|
+
bytes: image.data.byteLength,
|
|
2839
|
+
responseId: image.responseId,
|
|
2840
|
+
servedModel: image.servedModel ?? null,
|
|
2841
|
+
usage: image.usage
|
|
2842
|
+
}))
|
|
2843
|
+
};
|
|
2844
|
+
}
|
|
2845
|
+
async function appendUsageLog(entry) {
|
|
2846
|
+
const target = process.env[USAGE_LOG_ENV]?.trim();
|
|
2847
|
+
if (!target) return void 0;
|
|
2848
|
+
const path = resolve2(target);
|
|
2849
|
+
await mkdir4(dirname4(path), { recursive: true });
|
|
2850
|
+
await appendFile(path, `${JSON.stringify(entry)}
|
|
2851
|
+
`, "utf8");
|
|
2852
|
+
return path;
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2152
2855
|
// src/index.ts
|
|
2153
2856
|
var EXIT_USAGE = 2;
|
|
2154
2857
|
var EXIT_AUTH = 3;
|
|
@@ -2171,9 +2874,13 @@ function parseArgs(argv) {
|
|
|
2171
2874
|
flags.set("help", true);
|
|
2172
2875
|
continue;
|
|
2173
2876
|
}
|
|
2877
|
+
if (arg === "--version") {
|
|
2878
|
+
flags.set("version", true);
|
|
2879
|
+
continue;
|
|
2880
|
+
}
|
|
2174
2881
|
if (arg.startsWith("--")) {
|
|
2175
2882
|
const key = arg.slice(2);
|
|
2176
|
-
if (!["prompt", "out", "provider", "model", "reference", "size", "quality", "format", "count", "source", "mask", "timeout", "api", "base-url", "auth", "key-stdin", "no-open", "show-path", "target", "force"].includes(key)) {
|
|
2883
|
+
if (!["prompt", "out", "provider", "model", "reference", "size", "quality", "format", "count", "source", "mask", "timeout", "api", "base-url", "auth", "start-command", "workflow", "edit-workflow", "key-stdin", "no-open", "show-path", "target", "force"].includes(key)) {
|
|
2177
2884
|
fail("usage", `Unknown option --${key}. Run imagegen help.`, EXIT_USAGE);
|
|
2178
2885
|
}
|
|
2179
2886
|
if (key === "key-stdin" || key === "no-open" || key === "show-path" || key === "force") {
|
|
@@ -2207,6 +2914,10 @@ function imageModel(flags) {
|
|
|
2207
2914
|
if (model !== void 0 && !model) fail("usage", "--model must not be blank.", EXIT_USAGE);
|
|
2208
2915
|
return model;
|
|
2209
2916
|
}
|
|
2917
|
+
async function cliVersion() {
|
|
2918
|
+
const manifest = JSON.parse(await readFile4(new URL("../package.json", import.meta.url), "utf8"));
|
|
2919
|
+
return manifest.version;
|
|
2920
|
+
}
|
|
2210
2921
|
function redirectUri() {
|
|
2211
2922
|
return process.env["IMAGEGEN_CODEX_REDIRECT_URI"] ?? DEFAULT_REDIRECT_URI;
|
|
2212
2923
|
}
|
|
@@ -2224,7 +2935,7 @@ function redirectPort(uri) {
|
|
|
2224
2935
|
async function loadImageInput(path, label) {
|
|
2225
2936
|
let data;
|
|
2226
2937
|
try {
|
|
2227
|
-
data = await
|
|
2938
|
+
data = await readFile4(path);
|
|
2228
2939
|
} catch {
|
|
2229
2940
|
fail("io", `${label} not found: ${path}`, EXIT_IO);
|
|
2230
2941
|
}
|
|
@@ -2277,10 +2988,10 @@ function requestedProviders(flags) {
|
|
|
2277
2988
|
return raw;
|
|
2278
2989
|
}
|
|
2279
2990
|
async function resolveOutPaths(out, providerName, multiProvider, count, format) {
|
|
2280
|
-
const resolved =
|
|
2991
|
+
const resolved = resolve3(out);
|
|
2281
2992
|
let isDir = false;
|
|
2282
2993
|
try {
|
|
2283
|
-
const info = await
|
|
2994
|
+
const info = await stat2(resolved);
|
|
2284
2995
|
isDir = info.isDirectory();
|
|
2285
2996
|
} catch {
|
|
2286
2997
|
isDir = extname(resolved) === "";
|
|
@@ -2288,12 +2999,12 @@ async function resolveOutPaths(out, providerName, multiProvider, count, format)
|
|
|
2288
2999
|
const extension = `.${normalizeOutputFormat(format)}`;
|
|
2289
3000
|
const prefix = multiProvider ? `${providerName}-` : "";
|
|
2290
3001
|
if (isDir) {
|
|
2291
|
-
return Array.from({ length: count }, (_, i) =>
|
|
3002
|
+
return Array.from({ length: count }, (_, i) => join5(resolved, `${prefix}image-${i + 1}${extension}`));
|
|
2292
3003
|
}
|
|
2293
3004
|
if (count > 1 || multiProvider) {
|
|
2294
|
-
const dir =
|
|
3005
|
+
const dir = dirname5(resolved);
|
|
2295
3006
|
const base = basename(resolved, extname(resolved));
|
|
2296
|
-
return Array.from({ length: count }, (_, i) =>
|
|
3007
|
+
return Array.from({ length: count }, (_, i) => join5(dir, `${prefix}${base}-${i + 1}${extension}`));
|
|
2297
3008
|
}
|
|
2298
3009
|
return [resolved.endsWith(extension) ? resolved : `${resolved}${extension}`];
|
|
2299
3010
|
}
|
|
@@ -2301,14 +3012,16 @@ function printResult(providerName, result, paths) {
|
|
|
2301
3012
|
process.stdout.write(
|
|
2302
3013
|
`${JSON.stringify({
|
|
2303
3014
|
provider: providerName,
|
|
2304
|
-
|
|
3015
|
+
requestedModel: result.requestedModel,
|
|
2305
3016
|
images: result.images.map((image, i) => ({
|
|
2306
3017
|
path: paths[i],
|
|
2307
3018
|
mimeType: image.mimeType,
|
|
2308
3019
|
width: image.width,
|
|
2309
3020
|
height: image.height,
|
|
2310
3021
|
prompt: image.prompt,
|
|
2311
|
-
responseId: image.responseId
|
|
3022
|
+
responseId: image.responseId,
|
|
3023
|
+
servedModel: image.servedModel ?? null,
|
|
3024
|
+
served: image.usage?.["served"]
|
|
2312
3025
|
}))
|
|
2313
3026
|
})}
|
|
2314
3027
|
`
|
|
@@ -2318,7 +3031,7 @@ async function saveImages(result, paths) {
|
|
|
2318
3031
|
for (let i = 0; i < result.images.length; i += 1) {
|
|
2319
3032
|
const path = paths[i];
|
|
2320
3033
|
try {
|
|
2321
|
-
await
|
|
3034
|
+
await mkdir5(dirname5(path), { recursive: true });
|
|
2322
3035
|
await writeFile3(path, Buffer.from(result.images[i].data));
|
|
2323
3036
|
} catch {
|
|
2324
3037
|
fail("io", `Could not write output file: ${path}`, EXIT_IO);
|
|
@@ -2391,8 +3104,8 @@ async function runAuthLogin(providerName, flags) {
|
|
|
2391
3104
|
const waiter = waitForOAuthCallback(timeoutMinutes * 60 * 1e3, port);
|
|
2392
3105
|
if (!flags.get("no-open")) {
|
|
2393
3106
|
try {
|
|
2394
|
-
const { default:
|
|
2395
|
-
await
|
|
3107
|
+
const { default: open2 } = await import("open");
|
|
3108
|
+
await open2(url);
|
|
2396
3109
|
} catch {
|
|
2397
3110
|
process.stderr.write("Could not open a browser automatically. Open this URL manually:\n");
|
|
2398
3111
|
}
|
|
@@ -2525,10 +3238,21 @@ async function runImageRequest(request, edit, providers) {
|
|
|
2525
3238
|
const paths = await resolveOutPaths(request.out, entry.provider.name, multiProvider, result.images.length, request.format ?? "png");
|
|
2526
3239
|
await saveImages(result, paths);
|
|
2527
3240
|
printResult(entry.provider.name, result, paths);
|
|
3241
|
+
try {
|
|
3242
|
+
await appendUsageLog(usageLogEntry(request, edit, result, paths));
|
|
3243
|
+
} catch {
|
|
3244
|
+
process.stderr.write(`${JSON.stringify({ warning: { kind: "io", message: `Could not append to ${USAGE_LOG_ENV} file.` } })}
|
|
3245
|
+
`);
|
|
3246
|
+
}
|
|
2528
3247
|
}
|
|
2529
3248
|
}
|
|
2530
3249
|
async function main() {
|
|
2531
3250
|
const { flags, positionals } = parseArgs(process.argv.slice(2));
|
|
3251
|
+
if (flags.get("version")) {
|
|
3252
|
+
process.stdout.write(`${await cliVersion()}
|
|
3253
|
+
`);
|
|
3254
|
+
return;
|
|
3255
|
+
}
|
|
2532
3256
|
if (flags.get("help") || positionals.length === 0) {
|
|
2533
3257
|
process.stdout.write(helpFor(positionals[0]));
|
|
2534
3258
|
return;
|
|
@@ -2572,10 +3296,14 @@ async function main() {
|
|
|
2572
3296
|
`);
|
|
2573
3297
|
} else if (sub === "add" || sub === "update") {
|
|
2574
3298
|
const patch = {};
|
|
2575
|
-
for (const [flag, key] of [["api", "api"], ["base-url", "baseUrl"], ["auth", "auth"], ["model", "model"]]) {
|
|
3299
|
+
for (const [flag, key] of [["api", "api"], ["base-url", "baseUrl"], ["auth", "auth"], ["model", "model"], ["start-command", "startCommand"]]) {
|
|
2576
3300
|
const value = flagValue(flags, flag);
|
|
2577
3301
|
if (value !== void 0) patch[key] = value;
|
|
2578
3302
|
}
|
|
3303
|
+
for (const [flag, key] of [["workflow", "workflow"], ["edit-workflow", "editWorkflow"]]) {
|
|
3304
|
+
const value = flagValue(flags, flag);
|
|
3305
|
+
if (value !== void 0) patch[key] = resolve3(value);
|
|
3306
|
+
}
|
|
2579
3307
|
const profile = sub === "add" ? await repository.add({ name: subSub, ...patch }) : await repository.update(subSub, patch);
|
|
2580
3308
|
process.stdout.write(`${JSON.stringify({ provider: profile })}
|
|
2581
3309
|
`);
|
|
@@ -2586,8 +3314,7 @@ async function main() {
|
|
|
2586
3314
|
const selected = requestedProviders(flags);
|
|
2587
3315
|
if (!selected || selected.length !== 1) fail("usage", "Model discovery requires exactly one --provider <profile-name>.", EXIT_USAGE);
|
|
2588
3316
|
const profile = await new ProfileRepository().get(selected[0]);
|
|
2589
|
-
|
|
2590
|
-
process.stdout.write(`${JSON.stringify({ provider: profile.name, models: await provider.models() })}
|
|
3317
|
+
process.stdout.write(`${JSON.stringify({ provider: profile.name, models: await createProfileProvider(profile).models() })}
|
|
2591
3318
|
`);
|
|
2592
3319
|
return;
|
|
2593
3320
|
}
|