ima2-gen 1.1.3 → 1.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/commands/edit.js +2 -1
- package/bin/commands/gen.js +2 -1
- package/docs/API.md +1 -0
- package/lib/errorClassify.js +39 -1
- package/lib/generationErrors.js +51 -5
- package/lib/oauthProxy.js +128 -14
- package/lib/openDirectory.js +14 -4
- package/lib/referenceImageCompress.js +75 -0
- package/package.json +2 -1
- package/routes/generate.js +18 -3
- package/routes/nodes.js +12 -4
- package/ui/dist/assets/index-DHeTnSPD.css +1 -0
- package/ui/dist/assets/index-fDTlOt4w.js +23 -0
- package/ui/dist/assets/index-fDTlOt4w.js.map +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-CGqx6Kly.js +0 -23
- package/ui/dist/assets/index-CGqx6Kly.js.map +0 -1
- package/ui/dist/assets/index-CwHxiNDv.css +0 -1
package/bin/commands/edit.js
CHANGED
|
@@ -2,6 +2,7 @@ import { parseArgs } from "../lib/args.js";
|
|
|
2
2
|
import { resolveServer, request } from "../lib/client.js";
|
|
3
3
|
import { fileToDataUri, dataUriToFile, defaultOutName } from "../lib/files.js";
|
|
4
4
|
import { out, die, dieWithError, color, json } from "../lib/output.js";
|
|
5
|
+
import { config } from "../../config.js";
|
|
5
6
|
|
|
6
7
|
const VALID_MODES = new Set(["auto", "direct"]);
|
|
7
8
|
const VALID_MODERATION = new Set(["auto", "low"]);
|
|
@@ -87,7 +88,7 @@ export default async function editCmd(argv) {
|
|
|
87
88
|
|
|
88
89
|
const image = resp.image;
|
|
89
90
|
if (!image) die(1, "server returned no image");
|
|
90
|
-
const target = args.out || defaultOutName(0, 1)
|
|
91
|
+
const target = args.out || `${config.storage.generatedDir}/${defaultOutName(0, 1)}`;
|
|
91
92
|
await dataUriToFile(image, target);
|
|
92
93
|
|
|
93
94
|
if (args.json) {
|
package/bin/commands/gen.js
CHANGED
|
@@ -2,6 +2,7 @@ import { parseArgs } from "../lib/args.js";
|
|
|
2
2
|
import { resolveServer, request, normalizeGenerate } from "../lib/client.js";
|
|
3
3
|
import { fileToDataUri, dataUriToFile, defaultOutName, readStdin } from "../lib/files.js";
|
|
4
4
|
import { out, die, dieWithError, color, json } from "../lib/output.js";
|
|
5
|
+
import { config } from "../../config.js";
|
|
5
6
|
|
|
6
7
|
const VALID_MODES = new Set(["auto", "direct"]);
|
|
7
8
|
const VALID_MODERATION = new Set(["auto", "low"]);
|
|
@@ -139,7 +140,7 @@ export default async function genCmd(argv) {
|
|
|
139
140
|
} else if (outDir) {
|
|
140
141
|
target = `${outDir}/${defaultOutName(i, norm.images.length)}`;
|
|
141
142
|
} else {
|
|
142
|
-
target = defaultOutName(i, norm.images.length)
|
|
143
|
+
target = `${config.storage.generatedDir}/${defaultOutName(i, norm.images.length)}`;
|
|
143
144
|
}
|
|
144
145
|
await dataUriToFile(im.image, target);
|
|
145
146
|
savedPaths.push(target);
|
package/docs/API.md
CHANGED
|
@@ -207,6 +207,7 @@ Style-sheet extraction can require an API key/openai client. This does not reope
|
|
|
207
207
|
| `APIKEY_DISABLED` | API-key image generation is disabled |
|
|
208
208
|
| `INVALID_IMAGE_MODEL` | Model name is unknown or unsupported |
|
|
209
209
|
| `IMAGE_MODEL_UNSUPPORTED` | Model exists but cannot use image generation |
|
|
210
|
+
| `INVALID_REQUEST` | Upstream request parameters are invalid; raw provider details may be included as `upstreamCode`, `upstreamType`, and `upstreamParam` |
|
|
210
211
|
| `INVALID_MODERATION` | Moderation value is not `auto` or `low` |
|
|
211
212
|
| `SAFETY_REFUSAL` | Upstream safety refusal |
|
|
212
213
|
| `MODERATION_REFUSED` | Content generation refused by moderation |
|
package/lib/errorClassify.js
CHANGED
|
@@ -2,7 +2,33 @@
|
|
|
2
2
|
// Pattern-match upstream OpenAI / OAuth / network errors into stable ImaErrorCode
|
|
3
3
|
// values so the UI can surface localized, actionable messages with CTAs.
|
|
4
4
|
|
|
5
|
-
/** @typedef {"REF_TOO_LARGE"|"REF_NOT_BASE64"|"REF_EMPTY"|"REF_TOO_MANY"|"MODERATION_REFUSED"|"UPSTREAM_5XX"|"AUTH_CHATGPT_EXPIRED"|"AUTH_API_KEY_INVALID"|"NETWORK_FAILED"|"OAUTH_UNAVAILABLE"|"INVALID_MODERATION"|"APIKEY_DISABLED"|"SAFETY_REFUSAL"|"DB_ERROR"|"UNKNOWN"} ImaErrorCode */
|
|
5
|
+
/** @typedef {"REF_TOO_LARGE"|"REF_NOT_BASE64"|"REF_EMPTY"|"REF_TOO_MANY"|"MODERATION_REFUSED"|"UPSTREAM_5XX"|"AUTH_CHATGPT_EXPIRED"|"AUTH_API_KEY_INVALID"|"NETWORK_FAILED"|"OAUTH_UNAVAILABLE"|"INVALID_REQUEST"|"INVALID_MODERATION"|"APIKEY_DISABLED"|"SAFETY_REFUSAL"|"EMPTY_RESPONSE"|"OAUTH_UPSTREAM_ERROR"|"DB_ERROR"|"UNKNOWN"} ImaErrorCode */
|
|
6
|
+
|
|
7
|
+
const INVALID_REQUEST_CODES = new Set([
|
|
8
|
+
"bad_request",
|
|
9
|
+
"invalid_request",
|
|
10
|
+
"invalid_request_error",
|
|
11
|
+
"invalid_value",
|
|
12
|
+
"invalid_size",
|
|
13
|
+
"invalid_type",
|
|
14
|
+
"invalid_parameter",
|
|
15
|
+
"missing_required_parameter",
|
|
16
|
+
"unsupported_parameter",
|
|
17
|
+
"unsupported_value",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Normalize provider-specific request/validation codes into app codes.
|
|
22
|
+
* @param {string | undefined | null} code
|
|
23
|
+
* @returns {ImaErrorCode}
|
|
24
|
+
*/
|
|
25
|
+
export function classifyUpstreamErrorCode(code) {
|
|
26
|
+
const s = String(code || "").toLowerCase();
|
|
27
|
+
if (!s) return "UNKNOWN";
|
|
28
|
+
if (INVALID_REQUEST_CODES.has(s)) return "INVALID_REQUEST";
|
|
29
|
+
if (s.includes("moderation_blocked") || s.includes("moderation refused")) return "MODERATION_REFUSED";
|
|
30
|
+
return "UNKNOWN";
|
|
31
|
+
}
|
|
6
32
|
|
|
7
33
|
/**
|
|
8
34
|
* Classify an upstream error message into an ImaErrorCode.
|
|
@@ -54,6 +80,18 @@ export function classifyUpstreamError(msg) {
|
|
|
54
80
|
return "OAUTH_UNAVAILABLE";
|
|
55
81
|
}
|
|
56
82
|
|
|
83
|
+
if (
|
|
84
|
+
s.includes("invalid_request_error") ||
|
|
85
|
+
s.includes("invalid_value") ||
|
|
86
|
+
s.includes("invalid size") ||
|
|
87
|
+
s.includes("invalid request") ||
|
|
88
|
+
s.includes("requested resolution") ||
|
|
89
|
+
s.includes("minimum pixel budget") ||
|
|
90
|
+
s.includes("unsupported value")
|
|
91
|
+
) {
|
|
92
|
+
return "INVALID_REQUEST";
|
|
93
|
+
}
|
|
94
|
+
|
|
57
95
|
if (s.includes("an error occurred while processing") || /\b5\d\d\b/.test(s)) {
|
|
58
96
|
return "UPSTREAM_5XX";
|
|
59
97
|
}
|
package/lib/generationErrors.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { classifyUpstreamError } from "./errorClassify.js";
|
|
1
|
+
import { classifyUpstreamError, classifyUpstreamErrorCode } from "./errorClassify.js";
|
|
2
2
|
|
|
3
3
|
const PASSTHROUGH_CODES = new Set([
|
|
4
4
|
"OAUTH_UNAVAILABLE",
|
|
@@ -6,23 +6,45 @@ const PASSTHROUGH_CODES = new Set([
|
|
|
6
6
|
"AUTH_CHATGPT_EXPIRED",
|
|
7
7
|
"AUTH_API_KEY_INVALID",
|
|
8
8
|
"UPSTREAM_5XX",
|
|
9
|
+
"INVALID_REQUEST",
|
|
10
|
+
"OAUTH_UPSTREAM_ERROR",
|
|
9
11
|
]);
|
|
10
12
|
|
|
11
13
|
const SAFETY_CODES = new Set(["SAFETY_REFUSAL", "MODERATION_REFUSED", "moderation_blocked"]);
|
|
12
14
|
|
|
13
15
|
export function errorCodeFrom(err) {
|
|
14
16
|
if (!err) return "UNKNOWN";
|
|
15
|
-
|
|
17
|
+
const upstreamCode = classifyUpstreamErrorCode(err.upstreamCode);
|
|
18
|
+
if (upstreamCode !== "UNKNOWN") return upstreamCode;
|
|
19
|
+
const upstreamType = classifyUpstreamErrorCode(err.upstreamType);
|
|
20
|
+
if (upstreamType !== "UNKNOWN") return upstreamType;
|
|
21
|
+
// Known app-level codes pass through directly (before message heuristic)
|
|
22
|
+
if (PASSTHROUGH_CODES.has(err.code) || SAFETY_CODES.has(err.code)) return err.code;
|
|
23
|
+
const rawCode = classifyUpstreamErrorCode(err.code);
|
|
24
|
+
if (rawCode !== "UNKNOWN") return rawCode;
|
|
16
25
|
const direct = classifyUpstreamError(err.message);
|
|
17
26
|
if (direct !== "UNKNOWN") return direct;
|
|
27
|
+
const status = Number(err.status);
|
|
28
|
+
if (Number.isFinite(status) && status >= 400 && status < 500 && !SAFETY_CODES.has(err.code)) {
|
|
29
|
+
return "INVALID_REQUEST";
|
|
30
|
+
}
|
|
31
|
+
if (typeof err.code === "string" && err.code) return err.code;
|
|
18
32
|
if (err.cause) return errorCodeFrom(err.cause);
|
|
19
33
|
return "UNKNOWN";
|
|
20
34
|
}
|
|
21
35
|
|
|
36
|
+
export function isNonRetryableGenerationError(err) {
|
|
37
|
+
const code = errorCodeFrom(err);
|
|
38
|
+
if (SAFETY_CODES.has(code)) return false;
|
|
39
|
+
const status = Number(err?.status);
|
|
40
|
+
return code === "INVALID_REQUEST" || (Number.isFinite(status) && status >= 400 && status < 500);
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
export function statusForErrorCode(code, fallback = 500) {
|
|
23
44
|
if (code === "OAUTH_UNAVAILABLE" || code === "NETWORK_FAILED") return 503;
|
|
24
45
|
if (code === "AUTH_CHATGPT_EXPIRED" || code === "AUTH_API_KEY_INVALID") return 401;
|
|
25
46
|
if (code === "UPSTREAM_5XX") return 502;
|
|
47
|
+
if (code === "INVALID_REQUEST") return 400;
|
|
26
48
|
if (code === "SAFETY_REFUSAL" || code === "MODERATION_REFUSED" || code === "moderation_blocked") return 422;
|
|
27
49
|
return fallback;
|
|
28
50
|
}
|
|
@@ -34,6 +56,11 @@ export function normalizeGenerationFailure(lastErr, options = {}) {
|
|
|
34
56
|
err.code = code;
|
|
35
57
|
err.status = lastErr?.status || statusForErrorCode(code);
|
|
36
58
|
err.cause = lastErr;
|
|
59
|
+
if (lastErr?.upstreamCode) err.upstreamCode = lastErr.upstreamCode;
|
|
60
|
+
if (lastErr?.upstreamType) err.upstreamType = lastErr.upstreamType;
|
|
61
|
+
if (lastErr?.upstreamParam) err.upstreamParam = lastErr.upstreamParam;
|
|
62
|
+
if (lastErr?.eventType) err.eventType = lastErr.eventType;
|
|
63
|
+
if (typeof lastErr?.eventCount === "number") err.eventCount = lastErr.eventCount;
|
|
37
64
|
return err;
|
|
38
65
|
}
|
|
39
66
|
if (SAFETY_CODES.has(code)) {
|
|
@@ -43,9 +70,28 @@ export function normalizeGenerationFailure(lastErr, options = {}) {
|
|
|
43
70
|
err.cause = lastErr;
|
|
44
71
|
return err;
|
|
45
72
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
73
|
+
// Empty response with metadata → likely a technical limitation (unsupported size/quality/model)
|
|
74
|
+
if (typeof lastErr?.eventCount === "number") {
|
|
75
|
+
const meta = [];
|
|
76
|
+
if (lastErr.size) meta.push(`size=${lastErr.size}`);
|
|
77
|
+
if (lastErr.quality) meta.push(`quality=${lastErr.quality}`);
|
|
78
|
+
if (lastErr.model) meta.push(`model=${lastErr.model}`);
|
|
79
|
+
const msg = meta.length
|
|
80
|
+
? `No image data returned. This may be an unsupported ${meta.join(", ")} combination. Try a different size or model.`
|
|
81
|
+
: "No image data returned from the image backend. Try a different size, quality, or prompt.";
|
|
82
|
+
const err = new Error(msg);
|
|
83
|
+
err.code = "EMPTY_RESPONSE";
|
|
84
|
+
err.status = 422;
|
|
85
|
+
err.cause = lastErr;
|
|
86
|
+
if (lastErr.size) err.size = lastErr.size;
|
|
87
|
+
if (lastErr.quality) err.quality = lastErr.quality;
|
|
88
|
+
if (lastErr.model) err.model = lastErr.model;
|
|
89
|
+
return err;
|
|
90
|
+
}
|
|
91
|
+
// Unrecognized errors → UNKNOWN (do not pretend they are safety refusals)
|
|
92
|
+
const err = new Error(lastErr?.message || options.proxyMessage || "Image generation failed");
|
|
93
|
+
err.code = "UNKNOWN";
|
|
94
|
+
err.status = lastErr?.status || 500;
|
|
49
95
|
err.cause = lastErr;
|
|
50
96
|
return err;
|
|
51
97
|
}
|
package/lib/oauthProxy.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { setJobPhase } from "./inflight.js";
|
|
2
2
|
import { config } from "../config.js";
|
|
3
3
|
import { logEvent } from "./logger.js";
|
|
4
|
+
import { classifyUpstreamError, classifyUpstreamErrorCode } from "./errorClassify.js";
|
|
5
|
+
import { compressReferenceB64ForOAuth } from "./referenceImageCompress.js";
|
|
4
6
|
|
|
5
7
|
const RESEARCH_SUFFIX = config.oauth.researchSuffix;
|
|
6
8
|
|
|
@@ -87,17 +89,88 @@ function extractPartialImage(data) {
|
|
|
87
89
|
return { b64, index, eventType: data.type };
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
function makeOAuthError(
|
|
92
|
+
function makeOAuthError(
|
|
93
|
+
message,
|
|
94
|
+
{
|
|
95
|
+
status,
|
|
96
|
+
code = "OAUTH_UPSTREAM_ERROR",
|
|
97
|
+
upstreamBodyChars,
|
|
98
|
+
upstreamCode,
|
|
99
|
+
upstreamType,
|
|
100
|
+
upstreamParam,
|
|
101
|
+
eventType,
|
|
102
|
+
eventCount,
|
|
103
|
+
cause,
|
|
104
|
+
} = {},
|
|
105
|
+
) {
|
|
91
106
|
const err = new Error(message);
|
|
92
107
|
err.code = code;
|
|
93
108
|
if (status) err.status = status;
|
|
94
109
|
if (typeof upstreamBodyChars === "number") err.upstreamBodyChars = upstreamBodyChars;
|
|
110
|
+
if (upstreamCode) err.upstreamCode = upstreamCode;
|
|
111
|
+
if (upstreamType) err.upstreamType = upstreamType;
|
|
112
|
+
if (upstreamParam) err.upstreamParam = upstreamParam;
|
|
95
113
|
if (eventType) err.eventType = eventType;
|
|
96
114
|
if (typeof eventCount === "number") err.eventCount = eventCount;
|
|
97
115
|
if (cause) err.cause = cause;
|
|
98
116
|
return err;
|
|
99
117
|
}
|
|
100
118
|
|
|
119
|
+
export function parseOpenAIErrorBody(text) {
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(text);
|
|
122
|
+
const error = parsed?.error;
|
|
123
|
+
if (!error || typeof error !== "object") return null;
|
|
124
|
+
const message = typeof error.message === "string" ? error.message : "";
|
|
125
|
+
if (!message) return null;
|
|
126
|
+
return {
|
|
127
|
+
message,
|
|
128
|
+
code: typeof error.code === "string" ? error.code : null,
|
|
129
|
+
type: typeof error.type === "string" ? error.type : null,
|
|
130
|
+
param: typeof error.param === "string" ? error.param : null,
|
|
131
|
+
};
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizedOAuthCode(upstreamError) {
|
|
138
|
+
const byCode = classifyUpstreamErrorCode(upstreamError?.code);
|
|
139
|
+
if (byCode !== "UNKNOWN") return byCode;
|
|
140
|
+
const byType = classifyUpstreamErrorCode(upstreamError?.type);
|
|
141
|
+
if (byType !== "UNKNOWN") return byType;
|
|
142
|
+
const byMessage = classifyUpstreamError(upstreamError?.message);
|
|
143
|
+
if (byMessage !== "UNKNOWN") return byMessage;
|
|
144
|
+
return "OAUTH_UPSTREAM_ERROR";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function throwOAuthHttpError(res, text, { requestId, scope, fallbackMessage }) {
|
|
148
|
+
const upstream = parseOpenAIErrorBody(text);
|
|
149
|
+
const isClientError = res.status >= 400 && res.status < 500;
|
|
150
|
+
if (isClientError && upstream?.message) {
|
|
151
|
+
logEvent(scope || "oauth", "upstream_client_error", {
|
|
152
|
+
requestId,
|
|
153
|
+
status: res.status,
|
|
154
|
+
code: upstream.code,
|
|
155
|
+
type: upstream.type,
|
|
156
|
+
param: upstream.param,
|
|
157
|
+
errorChars: text.length,
|
|
158
|
+
});
|
|
159
|
+
throw makeOAuthError(upstream.message, {
|
|
160
|
+
status: res.status,
|
|
161
|
+
code: normalizedOAuthCode(upstream),
|
|
162
|
+
upstreamBodyChars: text.length,
|
|
163
|
+
upstreamCode: upstream.code,
|
|
164
|
+
upstreamType: upstream.type,
|
|
165
|
+
upstreamParam: upstream.param,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
throw makeOAuthError(fallbackMessage, {
|
|
169
|
+
status: res.status,
|
|
170
|
+
upstreamBodyChars: text.length,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
101
174
|
async function fetchOAuth(url, init, { requestId, scope } = {}) {
|
|
102
175
|
try {
|
|
103
176
|
return await fetch(url, init);
|
|
@@ -112,6 +185,9 @@ async function fetchOAuth(url, init, { requestId, scope } = {}) {
|
|
|
112
185
|
}
|
|
113
186
|
|
|
114
187
|
async function readImageStream(res, { requestId = null, scope = "oauth", onPartialImage = null } = {}) {
|
|
188
|
+
/** @type {Record<string, number>} */
|
|
189
|
+
const eventTypes = {};
|
|
190
|
+
let parseSkipCount = 0;
|
|
115
191
|
const reader = res.body.getReader();
|
|
116
192
|
const decoder = new TextDecoder();
|
|
117
193
|
let buffer = "";
|
|
@@ -136,6 +212,8 @@ async function readImageStream(res, { requestId = null, scope = "oauth", onParti
|
|
|
136
212
|
try {
|
|
137
213
|
const data = JSON.parse(eventData);
|
|
138
214
|
eventCount++;
|
|
215
|
+
const t = typeof data.type === "string" ? data.type : "_unknown";
|
|
216
|
+
eventTypes[t] = (eventTypes[t] || 0) + 1;
|
|
139
217
|
|
|
140
218
|
const partial = extractPartialImage(data);
|
|
141
219
|
if (partial) {
|
|
@@ -177,11 +255,16 @@ async function readImageStream(res, { requestId = null, scope = "oauth", onParti
|
|
|
177
255
|
}
|
|
178
256
|
} catch (e) {
|
|
179
257
|
if (e.message && !e.message.startsWith("Unexpected")) throw e;
|
|
258
|
+
parseSkipCount++;
|
|
180
259
|
}
|
|
181
260
|
}
|
|
182
261
|
}
|
|
183
262
|
|
|
184
|
-
|
|
263
|
+
if (parseSkipCount > 0) {
|
|
264
|
+
logEvent(scope, "parse_skip", { requestId, count: parseSkipCount });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return { imageB64, usage, webSearchCalls, revisedPrompt, eventCount, eventTypes };
|
|
185
268
|
}
|
|
186
269
|
|
|
187
270
|
export async function generateViaOAuth(
|
|
@@ -245,9 +328,10 @@ export async function generateViaOAuth(
|
|
|
245
328
|
if (!res.ok) {
|
|
246
329
|
const text = await res.text();
|
|
247
330
|
logEvent("oauth", "error_response", { requestId, status: res.status, errorChars: text.length });
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
331
|
+
throwOAuthHttpError(res, text, {
|
|
332
|
+
requestId,
|
|
333
|
+
scope: "oauth",
|
|
334
|
+
fallbackMessage: `OAuth proxy returned ${res.status}`,
|
|
251
335
|
});
|
|
252
336
|
}
|
|
253
337
|
|
|
@@ -268,12 +352,12 @@ export async function generateViaOAuth(
|
|
|
268
352
|
throw new Error("No image data in response (non-stream mode)");
|
|
269
353
|
}
|
|
270
354
|
|
|
271
|
-
const { imageB64, usage, webSearchCalls, revisedPrompt, eventCount } = await readImageStream(res, {
|
|
355
|
+
const { imageB64, usage, webSearchCalls, revisedPrompt, eventCount, eventTypes } = await readImageStream(res, {
|
|
272
356
|
requestId,
|
|
273
357
|
scope: "oauth",
|
|
274
358
|
onPartialImage: options.onPartialImage,
|
|
275
359
|
});
|
|
276
|
-
logEvent("oauth", "stream_end", { requestId, events: eventCount, hasImage: !!imageB64 });
|
|
360
|
+
logEvent("oauth", "stream_end", { requestId, events: eventCount, hasImage: !!imageB64, eventTypes });
|
|
277
361
|
|
|
278
362
|
if (!imageB64) {
|
|
279
363
|
logEvent("oauth", "retry_json", { requestId });
|
|
@@ -297,9 +381,23 @@ export async function generateViaOAuth(
|
|
|
297
381
|
return { b64: item.result, usage: json.usage, webSearchCalls, revisedPrompt: retryRevised };
|
|
298
382
|
}
|
|
299
383
|
}
|
|
384
|
+
} else {
|
|
385
|
+
const text = await retryRes.text();
|
|
386
|
+
logEvent("oauth", "retry_error_response", { requestId, status: retryRes.status, errorChars: text.length });
|
|
387
|
+
throwOAuthHttpError(retryRes, text, {
|
|
388
|
+
requestId,
|
|
389
|
+
scope: "oauth",
|
|
390
|
+
fallbackMessage: `OAuth proxy returned ${retryRes.status}`,
|
|
391
|
+
});
|
|
300
392
|
}
|
|
301
393
|
|
|
302
|
-
|
|
394
|
+
const emptyErr = new Error("No image data received from OAuth proxy (parsed " + eventCount + " events)");
|
|
395
|
+
emptyErr.eventCount = eventCount;
|
|
396
|
+
emptyErr.eventTypes = eventTypes;
|
|
397
|
+
emptyErr.size = size;
|
|
398
|
+
emptyErr.quality = quality;
|
|
399
|
+
emptyErr.model = model;
|
|
400
|
+
throw emptyErr;
|
|
303
401
|
}
|
|
304
402
|
|
|
305
403
|
return { b64: imageB64, usage, webSearchCalls, revisedPrompt };
|
|
@@ -313,10 +411,22 @@ export async function editViaOAuth(prompt, imageB64, quality, size, moderation =
|
|
|
313
411
|
const textPrompt = searchMode === "on"
|
|
314
412
|
? buildEditResearchTextPrompt(prompt, mode)
|
|
315
413
|
: buildEditTextPrompt(prompt, mode);
|
|
414
|
+
const imageForRequest = await compressReferenceB64ForOAuth(imageB64, {
|
|
415
|
+
maxB64Bytes: ctx.config?.limits?.maxRefB64Bytes,
|
|
416
|
+
force: true,
|
|
417
|
+
});
|
|
316
418
|
const references = Array.isArray(options.references) ? options.references : [];
|
|
317
|
-
const
|
|
419
|
+
const referenceImagesForRequest = await Promise.all(
|
|
420
|
+
references.map((b64) =>
|
|
421
|
+
compressReferenceB64ForOAuth(b64, {
|
|
422
|
+
maxB64Bytes: ctx.config?.limits?.maxRefB64Bytes,
|
|
423
|
+
force: true,
|
|
424
|
+
}),
|
|
425
|
+
),
|
|
426
|
+
);
|
|
427
|
+
const referenceContent = referenceImagesForRequest.map(({ b64 }) => ({
|
|
318
428
|
type: "input_image",
|
|
319
|
-
image_url: `data:image/
|
|
429
|
+
image_url: `data:image/jpeg;base64,${b64}`,
|
|
320
430
|
}));
|
|
321
431
|
const tools = [
|
|
322
432
|
...(searchMode === "on" ? [{ type: "web_search" }] : []),
|
|
@@ -330,6 +440,9 @@ export async function editViaOAuth(prompt, imageB64, quality, size, moderation =
|
|
|
330
440
|
inputImageCount: 1 + references.length,
|
|
331
441
|
parentImagePresent: true,
|
|
332
442
|
webSearchEnabled: searchMode === "on",
|
|
443
|
+
inputImageCompressed: imageForRequest.compressed,
|
|
444
|
+
inputImageChars: imageForRequest.inputBytes,
|
|
445
|
+
inputImageRequestChars: imageForRequest.outputBytes,
|
|
333
446
|
});
|
|
334
447
|
|
|
335
448
|
const res = await fetchOAuth(`${oauthUrl}/v1/responses`, {
|
|
@@ -342,7 +455,7 @@ export async function editViaOAuth(prompt, imageB64, quality, size, moderation =
|
|
|
342
455
|
{
|
|
343
456
|
role: "user",
|
|
344
457
|
content: [
|
|
345
|
-
{ type: "input_image", image_url: `data:image/
|
|
458
|
+
{ type: "input_image", image_url: `data:image/jpeg;base64,${imageForRequest.b64}` },
|
|
346
459
|
...referenceContent,
|
|
347
460
|
{ type: "input_text", text: textPrompt },
|
|
348
461
|
],
|
|
@@ -364,9 +477,10 @@ export async function editViaOAuth(prompt, imageB64, quality, size, moderation =
|
|
|
364
477
|
if (!res.ok) {
|
|
365
478
|
const text = await res.text();
|
|
366
479
|
logEvent("oauth-edit", "error_response", { requestId, status: res.status, errorChars: text.length });
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
480
|
+
throwOAuthHttpError(res, text, {
|
|
481
|
+
requestId,
|
|
482
|
+
scope: "oauth-edit",
|
|
483
|
+
fallbackMessage: `OAuth edit returned ${res.status}`,
|
|
370
484
|
});
|
|
371
485
|
}
|
|
372
486
|
|
package/lib/openDirectory.js
CHANGED
|
@@ -5,6 +5,7 @@ export async function openDirectory(dir, options = {}) {
|
|
|
5
5
|
await mkdir(dir, { recursive: true });
|
|
6
6
|
const platform = options.platform || process.platform;
|
|
7
7
|
const spawnImpl = options.spawnImpl || spawn;
|
|
8
|
+
const settleMs = Number.isFinite(options.settleMs) ? options.settleMs : 250;
|
|
8
9
|
const command =
|
|
9
10
|
platform === "darwin" ? "open"
|
|
10
11
|
: platform === "win32" ? "explorer"
|
|
@@ -12,10 +13,11 @@ export async function openDirectory(dir, options = {}) {
|
|
|
12
13
|
|
|
13
14
|
return new Promise((resolve) => {
|
|
14
15
|
try {
|
|
15
|
-
const
|
|
16
|
-
|
|
16
|
+
const isWin = platform === "win32";
|
|
17
|
+
const child = spawnImpl(command, isWin ? [`"${dir}"`] : [dir], {
|
|
18
|
+
detached: !isWin,
|
|
17
19
|
stdio: "ignore",
|
|
18
|
-
windowsHide:
|
|
20
|
+
windowsHide: !isWin,
|
|
19
21
|
});
|
|
20
22
|
let settled = false;
|
|
21
23
|
const done = (result) => {
|
|
@@ -26,8 +28,16 @@ export async function openDirectory(dir, options = {}) {
|
|
|
26
28
|
child.on("error", (err) => {
|
|
27
29
|
done({ ok: false, error: err.message || String(err) });
|
|
28
30
|
});
|
|
31
|
+
child.on("exit", (code) => {
|
|
32
|
+
if (platform === "win32") {
|
|
33
|
+
done({ ok: true });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (code === 0) done({ ok: true });
|
|
37
|
+
else if (code != null) done({ ok: false, error: `${command} exited with code ${code}` });
|
|
38
|
+
});
|
|
29
39
|
child.unref?.();
|
|
30
|
-
setTimeout(() => done({ ok: true }),
|
|
40
|
+
setTimeout(() => done({ ok: true }), settleMs).unref?.();
|
|
31
41
|
} catch (err) {
|
|
32
42
|
resolve({ ok: false, error: err?.message || String(err) });
|
|
33
43
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import sharp from "sharp";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MAX_B64_BYTES = 6 * 1024 * 1024;
|
|
4
|
+
const DEFAULT_MAX_EDGE = 3840;
|
|
5
|
+
const DEFAULT_QUALITY_LADDER = [85, 75, 65, 55];
|
|
6
|
+
const FALLBACK_MAX_EDGE = 2048;
|
|
7
|
+
const FALLBACK_QUALITY_LADDER = [75, 65, 55];
|
|
8
|
+
|
|
9
|
+
function stripDataUrlPrefix(value) {
|
|
10
|
+
return String(value || "").replace(/^data:[^;]+;base64,/, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function toBase64(buffer) {
|
|
14
|
+
return buffer.toString("base64");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function encodeJpegWithinBudget(input, {
|
|
18
|
+
maxB64Bytes,
|
|
19
|
+
maxEdge,
|
|
20
|
+
qualityLadder,
|
|
21
|
+
}) {
|
|
22
|
+
for (const quality of qualityLadder) {
|
|
23
|
+
const out = await sharp(input, { failOn: "none" })
|
|
24
|
+
.rotate()
|
|
25
|
+
.resize({
|
|
26
|
+
width: maxEdge,
|
|
27
|
+
height: maxEdge,
|
|
28
|
+
fit: "inside",
|
|
29
|
+
withoutEnlargement: true,
|
|
30
|
+
})
|
|
31
|
+
.flatten({ background: "#ffffff" })
|
|
32
|
+
.jpeg({ quality, progressive: true })
|
|
33
|
+
.toBuffer();
|
|
34
|
+
const b64 = toBase64(out);
|
|
35
|
+
if (b64.length <= maxB64Bytes) return { b64, compressed: true, quality, maxEdge };
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function compressReferenceB64ForOAuth(imageB64, options = {}) {
|
|
41
|
+
const rawB64 = stripDataUrlPrefix(imageB64);
|
|
42
|
+
const maxB64Bytes = options.maxB64Bytes ?? DEFAULT_MAX_B64_BYTES;
|
|
43
|
+
const maxEdge = options.maxEdge ?? DEFAULT_MAX_EDGE;
|
|
44
|
+
const qualityLadder = options.qualityLadder ?? DEFAULT_QUALITY_LADDER;
|
|
45
|
+
if (!rawB64) return { b64: rawB64, compressed: false, inputBytes: 0, outputBytes: 0 };
|
|
46
|
+
|
|
47
|
+
const input = Buffer.from(rawB64, "base64");
|
|
48
|
+
const inputBytes = rawB64.length;
|
|
49
|
+
if (!options.force && inputBytes <= maxB64Bytes) {
|
|
50
|
+
return { b64: rawB64, compressed: false, inputBytes, outputBytes: inputBytes };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const primary = await encodeJpegWithinBudget(input, {
|
|
54
|
+
maxB64Bytes,
|
|
55
|
+
maxEdge,
|
|
56
|
+
qualityLadder,
|
|
57
|
+
});
|
|
58
|
+
if (primary) {
|
|
59
|
+
return { ...primary, inputBytes, outputBytes: primary.b64.length };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const fallback = await encodeJpegWithinBudget(input, {
|
|
63
|
+
maxB64Bytes,
|
|
64
|
+
maxEdge: options.fallbackMaxEdge ?? FALLBACK_MAX_EDGE,
|
|
65
|
+
qualityLadder: options.fallbackQualityLadder ?? FALLBACK_QUALITY_LADDER,
|
|
66
|
+
});
|
|
67
|
+
if (fallback) {
|
|
68
|
+
return { ...fallback, inputBytes, outputBytes: fallback.b64.length };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const err = new Error(`Reference image remains above ${maxB64Bytes} base64 bytes after compression`);
|
|
72
|
+
err.code = "REF_TOO_LARGE";
|
|
73
|
+
err.status = 400;
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ima2-gen",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
4
4
|
"description": "Local OAuth image generation studio with classic and node workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"express": "^5.1.0",
|
|
57
57
|
"openai": "^5.8.2",
|
|
58
58
|
"openai-oauth": "^1.0.2",
|
|
59
|
+
"sharp": "^0.34.5",
|
|
59
60
|
"ulid": "^3.0.2"
|
|
60
61
|
}
|
|
61
62
|
}
|
package/routes/generate.js
CHANGED
|
@@ -6,7 +6,7 @@ import { classifyUpstreamError } from "../lib/errorClassify.js";
|
|
|
6
6
|
import { normalizeOAuthParams } from "../lib/oauthNormalize.js";
|
|
7
7
|
import { normalizeImageModel } from "../lib/imageModels.js";
|
|
8
8
|
import { generateViaOAuth } from "../lib/oauthProxy.js";
|
|
9
|
-
import { normalizeGenerationFailure } from "../lib/generationErrors.js";
|
|
9
|
+
import { isNonRetryableGenerationError, normalizeGenerationFailure } from "../lib/generationErrors.js";
|
|
10
10
|
import { startJob, finishJob } from "../lib/inflight.js";
|
|
11
11
|
import { getStyleSheet } from "../lib/sessionStore.js";
|
|
12
12
|
import { renderStyleSheetPrefix } from "../lib/styleSheet.js";
|
|
@@ -146,6 +146,7 @@ export function registerGenerateRoutes(app, ctx) {
|
|
|
146
146
|
lastErr = new Error("Empty response (safety refusal)");
|
|
147
147
|
} catch (e) {
|
|
148
148
|
lastErr = e;
|
|
149
|
+
if (isNonRetryableGenerationError(e)) break;
|
|
149
150
|
}
|
|
150
151
|
if (attempt < MAX_RETRIES) {
|
|
151
152
|
logEvent("generate", "retry", { requestId, attempt: attempt + 1, errorCode: lastErr?.code });
|
|
@@ -207,7 +208,14 @@ export function registerGenerateRoutes(app, ctx) {
|
|
|
207
208
|
finishStatus = "error";
|
|
208
209
|
finishHttpStatus = status;
|
|
209
210
|
finishErrorCode = firstErr.code;
|
|
210
|
-
return res.status(status).json({
|
|
211
|
+
return res.status(status).json({
|
|
212
|
+
error: firstErr.message,
|
|
213
|
+
code: firstErr.code,
|
|
214
|
+
upstreamCode: firstErr.upstreamCode || null,
|
|
215
|
+
upstreamType: firstErr.upstreamType || null,
|
|
216
|
+
upstreamParam: firstErr.upstreamParam || null,
|
|
217
|
+
requestId,
|
|
218
|
+
});
|
|
211
219
|
}
|
|
212
220
|
finishStatus = "error";
|
|
213
221
|
finishHttpStatus = 500;
|
|
@@ -256,7 +264,14 @@ export function registerGenerateRoutes(app, ctx) {
|
|
|
256
264
|
finishHttpStatus = err.status || 500;
|
|
257
265
|
finishErrorCode = fallbackCode || "GENERATE_FAILED";
|
|
258
266
|
logError("generate", "error", err, { requestId, code: finishErrorCode });
|
|
259
|
-
res.status(err.status || 500).json({
|
|
267
|
+
res.status(err.status || 500).json({
|
|
268
|
+
error: err.message,
|
|
269
|
+
code: fallbackCode,
|
|
270
|
+
upstreamCode: err.upstreamCode || null,
|
|
271
|
+
upstreamType: err.upstreamType || null,
|
|
272
|
+
upstreamParam: err.upstreamParam || null,
|
|
273
|
+
requestId,
|
|
274
|
+
});
|
|
260
275
|
} finally {
|
|
261
276
|
finishJob(requestId, {
|
|
262
277
|
status: finishStatus,
|
package/routes/nodes.js
CHANGED
|
@@ -12,7 +12,7 @@ import { classifyUpstreamError } from "../lib/errorClassify.js";
|
|
|
12
12
|
import { normalizeOAuthParams } from "../lib/oauthNormalize.js";
|
|
13
13
|
import { normalizeImageModel } from "../lib/imageModels.js";
|
|
14
14
|
import { generateViaOAuth, editViaOAuth } from "../lib/oauthProxy.js";
|
|
15
|
-
import { normalizeGenerationFailure } from "../lib/generationErrors.js";
|
|
15
|
+
import { isNonRetryableGenerationError, normalizeGenerationFailure } from "../lib/generationErrors.js";
|
|
16
16
|
import { getStyleSheet } from "../lib/sessionStore.js";
|
|
17
17
|
import { renderStyleSheetPrefix } from "../lib/styleSheet.js";
|
|
18
18
|
import { logEvent, logError } from "../lib/logger.js";
|
|
@@ -48,6 +48,7 @@ function writeNodeError(res, status, code, message, parentNodeId, details = {})
|
|
|
48
48
|
res.status(status).json({
|
|
49
49
|
error: { code, message },
|
|
50
50
|
parentNodeId,
|
|
51
|
+
status,
|
|
51
52
|
...details,
|
|
52
53
|
});
|
|
53
54
|
}
|
|
@@ -271,6 +272,7 @@ export function registerNodeRoutes(app, ctx) {
|
|
|
271
272
|
lastErr = new Error("Empty response (safety refusal)");
|
|
272
273
|
} catch (e) {
|
|
273
274
|
lastErr = e;
|
|
275
|
+
if (isNonRetryableGenerationError(e)) break;
|
|
274
276
|
}
|
|
275
277
|
if (attempt < MAX_RETRIES) {
|
|
276
278
|
logEvent("node", "retry", {
|
|
@@ -297,7 +299,7 @@ export function registerNodeRoutes(app, ctx) {
|
|
|
297
299
|
requestId,
|
|
298
300
|
operation,
|
|
299
301
|
finalCode: finishErrorCode,
|
|
300
|
-
upstreamCode: lastErr?.code,
|
|
302
|
+
upstreamCode: lastErr?.upstreamCode || lastErr?.code,
|
|
301
303
|
errorEventType: lastErr?.eventType,
|
|
302
304
|
errorEventCount: lastErr?.eventCount,
|
|
303
305
|
attempts: MAX_RETRIES + 1,
|
|
@@ -311,7 +313,9 @@ export function registerNodeRoutes(app, ctx) {
|
|
|
311
313
|
finalErr.message,
|
|
312
314
|
parentNodeId,
|
|
313
315
|
{
|
|
314
|
-
upstreamCode: lastErr?.code || null,
|
|
316
|
+
upstreamCode: lastErr?.upstreamCode || lastErr?.code || null,
|
|
317
|
+
upstreamType: lastErr?.upstreamType || null,
|
|
318
|
+
upstreamParam: lastErr?.upstreamParam || null,
|
|
315
319
|
errorEventType: lastErr?.eventType || null,
|
|
316
320
|
errorEventCount: lastErr?.eventCount ?? null,
|
|
317
321
|
},
|
|
@@ -401,7 +405,11 @@ export function registerNodeRoutes(app, ctx) {
|
|
|
401
405
|
finishHttpStatus = err.status || 500;
|
|
402
406
|
finishErrorCode = code;
|
|
403
407
|
logError("node", "error", err, { requestId, code, parentNodeId, sessionId, clientNodeId });
|
|
404
|
-
writeNodeError(res, err.status || 500, code, err.message, parentNodeId
|
|
408
|
+
writeNodeError(res, err.status || 500, code, err.message, parentNodeId, {
|
|
409
|
+
upstreamCode: err.upstreamCode || null,
|
|
410
|
+
upstreamType: err.upstreamType || null,
|
|
411
|
+
upstreamParam: err.upstreamParam || null,
|
|
412
|
+
});
|
|
405
413
|
} finally {
|
|
406
414
|
finishJob(requestId, {
|
|
407
415
|
status: finishStatus,
|