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.
@@ -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) {
@@ -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 |
@@ -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
  }
@@ -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
- if (typeof err.code === "string" && err.code) return err.code;
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
- const err = new Error(options.safetyMessage || "Content generation refused after retries");
47
- err.code = "SAFETY_REFUSAL";
48
- err.status = 422;
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(message, { status, code = "OAUTH_UPSTREAM_ERROR", upstreamBodyChars, eventType, eventCount, cause } = {}) {
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
- return { imageB64, usage, webSearchCalls, revisedPrompt, eventCount };
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
- throw makeOAuthError(`OAuth proxy returned ${res.status}`, {
249
- status: res.status,
250
- upstreamBodyChars: text.length,
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
- throw new Error("No image data received from OAuth proxy (parsed " + eventCount + " events)");
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 referenceContent = references.map((b64) => ({
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/png;base64,${b64}`,
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/png;base64,${imageB64}` },
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
- throw makeOAuthError(`OAuth edit returned ${res.status}`, {
368
- status: res.status,
369
- upstreamBodyChars: text.length,
480
+ throwOAuthHttpError(res, text, {
481
+ requestId,
482
+ scope: "oauth-edit",
483
+ fallbackMessage: `OAuth edit returned ${res.status}`,
370
484
  });
371
485
  }
372
486
 
@@ -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 child = spawnImpl(command, [dir], {
16
- detached: true,
16
+ const isWin = platform === "win32";
17
+ const child = spawnImpl(command, isWin ? [`"${dir}"`] : [dir], {
18
+ detached: !isWin,
17
19
  stdio: "ignore",
18
- windowsHide: true,
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 }), 50).unref?.();
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",
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
  }
@@ -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({ error: firstErr.message, code: firstErr.code, requestId });
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({ error: err.message, code: fallbackCode, requestId });
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,