ima2-gen 1.1.13 → 1.1.14

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.
Files changed (53) hide show
  1. package/README.md +10 -1
  2. package/bin/commands/doctor.js +195 -0
  3. package/bin/commands/doctor.ts +202 -0
  4. package/bin/ima2.js +3 -105
  5. package/bin/ima2.ts +3 -109
  6. package/config.js +1 -0
  7. package/config.ts +5 -0
  8. package/docs/CLI.md +36 -0
  9. package/docs/FAQ.ko.md +82 -2
  10. package/docs/FAQ.md +85 -2
  11. package/docs/PROMPT_STUDIO.ko.md +111 -0
  12. package/docs/PROMPT_STUDIO.md +115 -0
  13. package/docs/README.ko.md +8 -1
  14. package/docs/migration/runtime-test-inventory.md +6 -1
  15. package/lib/agentRuntime.js +9 -2
  16. package/lib/agentRuntime.ts +8 -2
  17. package/lib/errorClassify.js +1 -1
  18. package/lib/errorClassify.ts +1 -1
  19. package/lib/generationErrors.js +121 -23
  20. package/lib/generationErrors.ts +100 -13
  21. package/lib/responsesDoctor.js +386 -0
  22. package/lib/responsesDoctor.ts +456 -0
  23. package/lib/responsesErrors.js +57 -0
  24. package/lib/responsesErrors.ts +83 -0
  25. package/lib/responsesFallback.js +72 -0
  26. package/lib/responsesFallback.ts +114 -0
  27. package/lib/responsesImageAdapter.js +121 -174
  28. package/lib/responsesImageAdapter.ts +136 -211
  29. package/lib/responsesParse.js +324 -0
  30. package/lib/responsesParse.ts +452 -0
  31. package/lib/responsesTools.js +15 -0
  32. package/lib/responsesTools.ts +28 -0
  33. package/package.json +1 -1
  34. package/routes/edit.js +26 -1
  35. package/routes/edit.ts +26 -1
  36. package/routes/generate.js +40 -0
  37. package/routes/generate.ts +47 -0
  38. package/ui/dist/.vite/manifest.json +12 -12
  39. package/ui/dist/assets/{AgentWorkspace-BJe9yxPA.js → AgentWorkspace-B6YNOZHi.js} +1 -1
  40. package/ui/dist/assets/{CardNewsWorkspace-BBLdwzYU.js → CardNewsWorkspace-EFVeg4l_.js} +1 -1
  41. package/ui/dist/assets/{NodeCanvas-BSZ527J4.js → NodeCanvas-iM6yjHvO.js} +1 -1
  42. package/ui/dist/assets/{PromptBuilderPanel-Y2VygFc0.js → PromptBuilderPanel-C3GdLDCl.js} +1 -1
  43. package/ui/dist/assets/{PromptImportDialog-C6lFV-LL.js → PromptImportDialog-DS9vrc_w.js} +2 -2
  44. package/ui/dist/assets/{PromptImportDiscoverySection-D8YJFhND.js → PromptImportDiscoverySection-DHFEt_FA.js} +1 -1
  45. package/ui/dist/assets/{PromptImportFolderSection-ywfcQolW.js → PromptImportFolderSection-BQxb1zs5.js} +1 -1
  46. package/ui/dist/assets/{PromptLibraryPanel-fk4KmrGy.js → PromptLibraryPanel-NhMKVGfU.js} +2 -2
  47. package/ui/dist/assets/{SettingsWorkspace-DL5vhAHQ.js → SettingsWorkspace-FjKjaDqj.js} +1 -1
  48. package/ui/dist/assets/index-BAN6lKgf.js +28 -0
  49. package/ui/dist/assets/{index-BLx55BOg.js → index-BbFZyM92.js} +1 -1
  50. package/ui/dist/assets/index-DK1faG9Z.css +1 -0
  51. package/ui/dist/index.html +2 -2
  52. package/ui/dist/assets/index-ByViUJfx.css +0 -1
  53. package/ui/dist/assets/index-Ci36vcFD.js +0 -28
@@ -0,0 +1,115 @@
1
+ # Prompt Studio Manual
2
+
3
+ Prompt Studio is the Classic workspace profile for repeated image iteration. It
4
+ keeps the image viewer in the center, recent generations on the side, and the
5
+ composer plus generation controls close to the current image.
6
+
7
+ Use this page when you are not sure what a Prompt Studio control does or when
8
+ you want a reproducible way to report a workspace issue.
9
+
10
+ ## Feature Map
11
+
12
+ | Area | What it does | Notes |
13
+ |---|---|---|
14
+ | Composer | Holds the prompt for the next request. | Selecting an existing image is view-only. It should not overwrite the composer. |
15
+ | Multimode | Starts several separate image requests from the current prompt. | Each slot is a candidate output, not a collage panel or a guaranteed scene sequence. |
16
+ | 1:1 Direct | Sends the prompt through with less rewriting by the app. | Use it for exact wording, strict prompt experiments, or provider-side prompt syntax. |
17
+ | Model quick menu | Changes the image model and reasoning effort from the sidebar header. | The full Settings workspace remains the detailed configuration page. |
18
+ | Recent generations | Shows the visible Prompt Studio history domain. | Arrow keys move inside the same visible recent domain instead of hidden older rows. |
19
+ | Gallery | Browses saved local images, All/Favorites tabs, and folders. | Favorite toggles should preserve the gallery viewport you were browsing. |
20
+ | Prompt library | Imports saved prompt text into the composer intentionally. | Library insert/continue actions are explicit prompt imports; passive image selection is not. |
21
+
22
+ ## Multimode Prompting
23
+
24
+ Multimode repeats one generation request shape across several candidate slots.
25
+ It is useful when you want alternatives, not when you need one combined
26
+ multi-panel image.
27
+
28
+ For related candidates, put the shared subject first and the variation rule
29
+ second:
30
+
31
+ ```text
32
+ Same character design in every image: a silver-haired courier in a red raincoat.
33
+ Make 4 alternatives that vary only camera angle, lighting, and background street.
34
+ Keep face, outfit, age, and color palette consistent.
35
+ ```
36
+
37
+ For unrelated candidates, say so directly:
38
+
39
+ ```text
40
+ Create 4 unrelated sticker ideas for a local image generation app.
41
+ Each image should use a different mascot, color palette, and composition.
42
+ ```
43
+
44
+ If you need a true two-panel comic, contact sheet, before/after comparison, or
45
+ collage, ask for that in a single normal image request instead of relying on
46
+ multimode slots:
47
+
48
+ ```text
49
+ Create one 2-panel comparison image. Left panel: rough sketch UI. Right panel:
50
+ polished Prompt Studio UI. Add small labels inside each panel.
51
+ ```
52
+
53
+ ## Direct Mode
54
+
55
+ Use **1:1 Direct** when the exact prompt text matters. It is helpful for:
56
+
57
+ - comparing prompt wording changes,
58
+ - preserving a structured prompt template,
59
+ - using provider-specific instruction style,
60
+ - avoiding app-side phrasing changes during troubleshooting.
61
+
62
+ Direct mode can be used together with Multimode. In that case, each multimode
63
+ slot receives the same direct prompt request shape.
64
+
65
+ ## Reasoning Effort
66
+
67
+ Reasoning effort controls how much planning the selected model may spend before
68
+ or during generation. Start with the default for everyday work. Raise it when
69
+ the prompt has many constraints, references, or composition requirements.
70
+
71
+ The sidebar model label opens quick settings for both model and reasoning. The
72
+ Settings workspace still shows the full model configuration and explanatory
73
+ copy.
74
+
75
+ ## Gallery And Prompt Safety
76
+
77
+ Prompt Studio separates browsing from composing:
78
+
79
+ - Passive image selection is view-only.
80
+ - Clicking a history or gallery image focuses the image for viewing.
81
+ - Favorite on/off changes the saved image metadata and should not jump your
82
+ gallery browsing position.
83
+ - The All and Favorites tabs are browsing filters. Switching between them does
84
+ not intentionally import a prompt.
85
+ - Prompt Library insert, "continue from this image", and explicit reuse actions
86
+ are the actions that intentionally change the composer.
87
+
88
+ Before generating, glance at the composer if you were using explicit prompt
89
+ import actions. Passive image selection should leave your draft alone.
90
+
91
+ ## Issue #75 Closeout Notes
92
+
93
+ The v1.1.13 Prompt Studio fixes tightened these user-visible contracts:
94
+
95
+ - concurrent multimode completions keep their slot/request identity,
96
+ - keyboard navigation follows the visible recent history domain,
97
+ - the gallery button remains reachable beside recent history,
98
+ - long prompts no longer starve the default image viewer,
99
+ - Direct and Multimode states can be seen at the same time,
100
+ - gallery favorite toggles and tab changes preserve the browsing viewport,
101
+ - passive image selection does not refill the composer.
102
+
103
+ ## Reporting A Prompt Studio Problem
104
+
105
+ When opening an issue, include:
106
+
107
+ - `ima2-gen` version and operating system,
108
+ - browser and viewport size if layout is involved,
109
+ - workspace profile, mode toggles, model, reasoning effort, and image count,
110
+ - numbered steps from a fresh `ima2 serve` session,
111
+ - whether the problem happens in All, Favorites, or recent history,
112
+ - safe screenshots or screen recordings if they do not reveal private prompts.
113
+
114
+ Do not share ChatGPT cookies, OAuth token files, API keys, raw upstream
115
+ responses, private prompt history, or generated base64 data.
package/docs/README.ko.md CHANGED
@@ -82,6 +82,10 @@ API 키가 env/config에 있으면 생성 엔드포인트에서 `provider: "api"
82
82
  4. 한 장을 만들거나, multimode를 켜서 같은 프롬프트에서 여러 후보 슬롯을 만듭니다.
83
83
  5. 생성 후 복사, 다운로드, 이어서 작업, Canvas Mode 정리를 선택합니다.
84
84
 
85
+ Prompt Studio의 각 컨트롤, 멀티모드 작성법, 1:1 Direct, 추론 강도, 갤러리
86
+ 즐겨찾기 동작은 [Prompt Studio 사용 설명서](PROMPT_STUDIO.ko.md)에 정리되어
87
+ 있습니다.
88
+
85
89
  ![하나의 프롬프트에서 네 후보 슬롯이 생성 중이고 sidebar에 active job history가 보이는 multimode sequence 화면](../assets/screenshots/multimode-sequence.png)
86
90
 
87
91
  ### Node mode
@@ -186,7 +190,10 @@ environment variables > ~/.ima2/config.json > built-in defaults
186
190
 
187
191
  엔드포인트 목록은 [API Reference](API.md)로 분리했습니다.
188
192
 
189
- 자주 묻는 질문은 [FAQ](FAQ.ko.md)에 정리했습니다. 업데이트 예전 이미지가 안 보이면 [예전 이미지 복구 안내](RECOVER_OLD_IMAGES.md)를 먼저 확인하세요.
193
+ 자주 묻는 질문은 [FAQ](FAQ.ko.md)에 정리했습니다. Prompt Studio 기능은
194
+ [Prompt Studio 사용 설명서](PROMPT_STUDIO.ko.md)를 확인하세요. 업데이트 후
195
+ 예전 이미지가 안 보이면 [예전 이미지 복구 안내](RECOVER_OLD_IMAGES.md)를
196
+ 먼저 확인하세요.
190
197
 
191
198
  ## 문제 해결
192
199
 
@@ -4,7 +4,7 @@ Generated by `npm run test:inventory` (script: `scripts/classify-tests.mjs`).
4
4
 
5
5
  _Tests considered "runtime-importing" if they import from `../lib/`, `../routes/`, `../bin/`, `../server`, or `../config`._
6
6
 
7
- Total: 156 (runtime: 46, contract: 110)
7
+ Total: 161 (runtime: 49, contract: 112)
8
8
 
9
9
  ## Runtime-importing tests
10
10
  - `tests/agent-mode-auto-planner-contract.test.ts`
@@ -47,6 +47,9 @@ Total: 156 (runtime: 46, contract: 110)
47
47
  - `tests/reference-image-compress.test.ts`
48
48
  - `tests/refs-size.test.ts`
49
49
  - `tests/request-logging.test.ts`
50
+ - `tests/responses-adapter-safety.test.ts`
51
+ - `tests/responses-empty-taxonomy.test.ts`
52
+ - `tests/responses-parse-diagnostics.test.ts`
50
53
  - `tests/runtime-context-normalize.test.ts`
51
54
  - `tests/runtime-ports.test.ts`
52
55
  - `tests/serve-ui-build-contract.test.ts`
@@ -123,6 +126,7 @@ Total: 156 (runtime: 46, contract: 110)
123
126
  - `tests/inflight-list-tooltip-contract.test.js`
124
127
  - `tests/inflight-reload-race.test.js`
125
128
  - `tests/inflight-reload-reconcile-contract.test.js`
129
+ - `tests/issue75-prompt-studio-state-contract.test.js`
126
130
  - `tests/mobile-generate-entry-contract.test.js`
127
131
  - `tests/multimode-backend-contract.test.js`
128
132
  - `tests/multimode-concurrent-store-contract.test.js`
@@ -150,6 +154,7 @@ Total: 156 (runtime: 46, contract: 110)
150
154
  - `tests/prompt-import-folder-ui-contract.test.js`
151
155
  - `tests/prompt-import-search-ux-contract.test.js`
152
156
  - `tests/prompt-library-ui-contract.test.js`
157
+ - `tests/prompt-studio-docs-contract.test.js`
153
158
  - `tests/prompt-studio-ui-contract.test.js`
154
159
  - `tests/server-fallback-contract.test.js`
155
160
  - `tests/server.test.js`
@@ -187,7 +187,9 @@ async function generateAgentImageWithRetry(ctx, sessionId, prompt, manifest, web
187
187
  }
188
188
  catch (error) {
189
189
  lastError = error;
190
- if (!isTextOnlyResult(error) || attempt === 1)
190
+ if (!isTextOnlyResult(error))
191
+ throw error;
192
+ if (attempt === 1)
191
193
  break;
192
194
  appendAgentTurn({
193
195
  sessionId,
@@ -264,7 +266,12 @@ function forceImagePrompt(prompt) {
264
266
  }
265
267
  function isTextOnlyResult(error) {
266
268
  const err = errInfo(error);
267
- return err.code === "EMPTY_RESPONSE" || err.message.includes("No image data");
269
+ return [
270
+ "EMPTY_RESPONSE",
271
+ "IMAGE_TOOL_NOT_CALLED",
272
+ "WEB_SEARCH_ONLY_RESPONSE",
273
+ "IMAGE_TOOL_COMPLETED_WITHOUT_RESULT",
274
+ ].includes(err.code || "") || err.message.includes("No image data");
268
275
  }
269
276
  function textOnlyError(cause) {
270
277
  const err = new Error("Agent result did not include an image artifact.");
@@ -252,7 +252,8 @@ async function generateAgentImageWithRetry(
252
252
  if (result.image) return result;
253
253
  } catch (error) {
254
254
  lastError = error;
255
- if (!isTextOnlyResult(error) || attempt === 1) break;
255
+ if (!isTextOnlyResult(error)) throw error;
256
+ if (attempt === 1) break;
256
257
  appendAgentTurn({
257
258
  sessionId,
258
259
  role: "tool",
@@ -357,7 +358,12 @@ function forceImagePrompt(prompt: string) {
357
358
 
358
359
  function isTextOnlyResult(error: unknown) {
359
360
  const err = errInfo(error);
360
- return err.code === "EMPTY_RESPONSE" || err.message.includes("No image data");
361
+ return [
362
+ "EMPTY_RESPONSE",
363
+ "IMAGE_TOOL_NOT_CALLED",
364
+ "WEB_SEARCH_ONLY_RESPONSE",
365
+ "IMAGE_TOOL_COMPLETED_WITHOUT_RESULT",
366
+ ].includes(err.code || "") || err.message.includes("No image data");
361
367
  }
362
368
 
363
369
  function textOnlyError(cause: unknown) {
@@ -1,7 +1,7 @@
1
1
  // 0.09.8 — upstream error classifier.
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
- /** @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 */
4
+ /** @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"|"STREAM_PARSE_FAILED"|"IMAGE_TOOL_NOT_CALLED"|"WEB_SEARCH_ONLY_RESPONSE"|"IMAGE_TOOL_FAILED"|"IMAGE_TOOL_COMPLETED_WITHOUT_RESULT"|"OAUTH_IMAGE_CAPABILITY_UNAVAILABLE"|"RESPONSES_STREAM_ERROR"|"OAUTH_UPSTREAM_ERROR"|"DB_ERROR"|"UNKNOWN"} ImaErrorCode */
5
5
  const INVALID_REQUEST_CODES = new Set([
6
6
  "bad_request",
7
7
  "invalid_request",
@@ -2,7 +2,7 @@
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_REQUEST"|"INVALID_MODERATION"|"APIKEY_DISABLED"|"SAFETY_REFUSAL"|"EMPTY_RESPONSE"|"OAUTH_UPSTREAM_ERROR"|"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"|"STREAM_PARSE_FAILED"|"IMAGE_TOOL_NOT_CALLED"|"WEB_SEARCH_ONLY_RESPONSE"|"IMAGE_TOOL_FAILED"|"IMAGE_TOOL_COMPLETED_WITHOUT_RESULT"|"OAUTH_IMAGE_CAPABILITY_UNAVAILABLE"|"RESPONSES_STREAM_ERROR"|"OAUTH_UPSTREAM_ERROR"|"DB_ERROR"|"UNKNOWN"} ImaErrorCode */
6
6
 
7
7
  const INVALID_REQUEST_CODES = new Set([
8
8
  "bad_request",
@@ -1,4 +1,6 @@
1
1
  import { classifyUpstreamError, classifyUpstreamErrorCode } from "./errorClassify.js";
2
+ import { safeDiagnosticLabel } from "./responsesParse.js";
3
+ import { RESPONSE_DIAGNOSTIC_CODES } from "./responsesErrors.js";
2
4
  const PASSTHROUGH_CODES = new Set([
3
5
  "OAUTH_UNAVAILABLE",
4
6
  "NETWORK_FAILED",
@@ -22,13 +24,50 @@ function diagnosticReasonFrom(err) {
22
24
  return err.diagnosticReason;
23
25
  if (Number(err?.referenceMismatchCount) > 0)
24
26
  return "reference_mime_mismatch_candidate";
27
+ const responseDiagnosticReason = responseDiagnosticReasonFrom(err);
28
+ if (responseDiagnosticReason)
29
+ return responseDiagnosticReason;
25
30
  if (has4kSize(err?.size))
26
31
  return "experimental_4k_empty_response";
27
32
  return null;
28
33
  }
34
+ function responseDiagnosticReasonFrom(err) {
35
+ if (!err?.responseDiagnostics || typeof err.responseDiagnostics !== "object")
36
+ return null;
37
+ const diagnostics = err.responseDiagnostics;
38
+ const bytesRead = Number(diagnostics.streamStats?.bytesRead);
39
+ if (Number.isFinite(bytesRead) && bytesRead > 0 && Number(err.eventCount) === 0)
40
+ return "stream_parse_failed";
41
+ if (diagnostics.imageCallFailed === true)
42
+ return "image_tool_failed";
43
+ if (diagnostics.imageCallCompleted === true && Number(diagnostics.imageResultCount) === 0)
44
+ return "image_tool_completed_without_result";
45
+ if (diagnostics.imageCallSeen !== true && Number(err.webSearchCalls) > 0)
46
+ return "web_search_only_response";
47
+ if (diagnostics.imageCallSeen !== true && diagnostics.messageOutputSeen === true)
48
+ return "image_tool_not_called";
49
+ return null;
50
+ }
51
+ function responseDiagnosticCodeFrom(err) {
52
+ const reason = responseDiagnosticReasonFrom(err);
53
+ if (reason === "stream_parse_failed")
54
+ return "STREAM_PARSE_FAILED";
55
+ if (reason === "web_search_only_response")
56
+ return "WEB_SEARCH_ONLY_RESPONSE";
57
+ if (reason === "image_tool_not_called")
58
+ return "IMAGE_TOOL_NOT_CALLED";
59
+ if (reason === "image_tool_failed")
60
+ return "IMAGE_TOOL_FAILED";
61
+ if (reason === "image_tool_completed_without_result")
62
+ return "IMAGE_TOOL_COMPLETED_WITHOUT_RESULT";
63
+ return null;
64
+ }
29
65
  export function errorCodeFrom(err) {
30
66
  if (!err)
31
67
  return "UNKNOWN";
68
+ const appCode = err.code;
69
+ if (RESPONSE_DIAGNOSTIC_CODES.has(appCode) || appCode === "EMPTY_RESPONSE")
70
+ return appCode;
32
71
  const upstreamCode = classifyUpstreamErrorCode(err.upstreamCode);
33
72
  if (upstreamCode !== "UNKNOWN")
34
73
  return upstreamCode;
@@ -36,14 +75,17 @@ export function errorCodeFrom(err) {
36
75
  if (upstreamType !== "UNKNOWN")
37
76
  return upstreamType;
38
77
  // Known app-level codes pass through directly (before message heuristic)
39
- if (PASSTHROUGH_CODES.has(err.code) || SAFETY_CODES.has(err.code))
40
- return err.code;
78
+ if (PASSTHROUGH_CODES.has(appCode) || SAFETY_CODES.has(appCode))
79
+ return appCode;
41
80
  const rawCode = classifyUpstreamErrorCode(err.code);
42
81
  if (rawCode !== "UNKNOWN")
43
82
  return rawCode;
44
83
  const direct = classifyUpstreamError(err.message);
45
84
  if (direct !== "UNKNOWN")
46
85
  return direct;
86
+ const responseDiagnosticCode = responseDiagnosticCodeFrom(err);
87
+ if (responseDiagnosticCode)
88
+ return responseDiagnosticCode;
47
89
  const status = Number(err.status);
48
90
  if (Number.isFinite(status) && status >= 400 && status < 500 && !SAFETY_CODES.has(err.code)) {
49
91
  return "INVALID_REQUEST";
@@ -70,14 +112,66 @@ export function statusForErrorCode(code, fallback = 500) {
70
112
  return 401;
71
113
  if (code === "UPSTREAM_5XX")
72
114
  return 502;
115
+ if (code === "RESPONSES_STREAM_ERROR")
116
+ return 502;
73
117
  if (code === "OAUTH_IMAGE_TIMEOUT")
74
118
  return 504;
75
119
  if (code === "INVALID_REQUEST")
76
120
  return 400;
121
+ if (RESPONSE_DIAGNOSTIC_CODES.has(code))
122
+ return 422;
77
123
  if (code === "SAFETY_REFUSAL" || code === "MODERATION_REFUSED" || code === "moderation_blocked")
78
124
  return 422;
79
125
  return fallback;
80
126
  }
127
+ function copyEmptyResponseMetadata(target, source) {
128
+ if (!source)
129
+ return;
130
+ if (typeof source.eventCount === "number")
131
+ target.eventCount = source.eventCount;
132
+ if (source.eventTypes)
133
+ target.eventTypes = source.eventTypes;
134
+ if (typeof source.webSearchCalls === "number")
135
+ target.webSearchCalls = source.webSearchCalls;
136
+ if (source.responseDiagnostics)
137
+ target.responseDiagnostics = source.responseDiagnostics;
138
+ if (typeof source.webSearchEnabled === "boolean")
139
+ target.webSearchEnabled = source.webSearchEnabled;
140
+ if (Array.isArray(source.toolTypes))
141
+ target.toolTypes = source.toolTypes;
142
+ if (source.toolChoiceKind)
143
+ target.toolChoiceKind = source.toolChoiceKind;
144
+ if (typeof source.promptChars === "number")
145
+ target.promptChars = source.promptChars;
146
+ if (typeof source.refsCount === "number")
147
+ target.refsCount = source.refsCount;
148
+ if (typeof source.inputImageCount === "number")
149
+ target.inputImageCount = source.inputImageCount;
150
+ if (Array.isArray(source.referenceDiagnostics))
151
+ target.referenceDiagnostics = source.referenceDiagnostics;
152
+ if (typeof source.referenceMismatchCount === "number")
153
+ target.referenceMismatchCount = source.referenceMismatchCount;
154
+ if (source.retryKind)
155
+ target.retryKind = source.retryKind;
156
+ if (typeof source.initialEventCount === "number")
157
+ target.initialEventCount = source.initialEventCount;
158
+ if (source.initialEventTypes)
159
+ target.initialEventTypes = source.initialEventTypes;
160
+ if (typeof source.referencesDroppedOnRetry === "boolean")
161
+ target.referencesDroppedOnRetry = source.referencesDroppedOnRetry;
162
+ if (typeof source.developerPromptDroppedOnRetry === "boolean")
163
+ target.developerPromptDroppedOnRetry = source.developerPromptDroppedOnRetry;
164
+ if (typeof source.webSearchDroppedOnRetry === "boolean")
165
+ target.webSearchDroppedOnRetry = source.webSearchDroppedOnRetry;
166
+ if (typeof source.fallbackEventCount === "number")
167
+ target.fallbackEventCount = source.fallbackEventCount;
168
+ if (source.fallbackEventTypes)
169
+ target.fallbackEventTypes = source.fallbackEventTypes;
170
+ if (typeof source.fallbackImageCallSeen === "boolean")
171
+ target.fallbackImageCallSeen = source.fallbackImageCallSeen;
172
+ if (typeof source.fallbackImageResultCount === "number")
173
+ target.fallbackImageResultCount = source.fallbackImageResultCount;
174
+ }
81
175
  export function normalizeGenerationFailure(lastErr, options = {}) {
82
176
  const code = errorCodeFrom(lastErr);
83
177
  if (PASSTHROUGH_CODES.has(code)) {
@@ -86,11 +180,11 @@ export function normalizeGenerationFailure(lastErr, options = {}) {
86
180
  err.status = lastErr?.status || statusForErrorCode(code);
87
181
  err.cause = lastErr;
88
182
  if (lastErr?.upstreamCode)
89
- err.upstreamCode = lastErr.upstreamCode;
183
+ err.upstreamCode = safeDiagnosticLabel(lastErr.upstreamCode);
90
184
  if (lastErr?.upstreamType)
91
- err.upstreamType = lastErr.upstreamType;
185
+ err.upstreamType = safeDiagnosticLabel(lastErr.upstreamType);
92
186
  if (lastErr?.upstreamParam)
93
- err.upstreamParam = lastErr.upstreamParam;
187
+ err.upstreamParam = safeDiagnosticLabel(lastErr.upstreamParam);
94
188
  if (lastErr?.eventType)
95
189
  err.eventType = lastErr.eventType;
96
190
  if (typeof lastErr?.eventCount === "number")
@@ -104,6 +198,23 @@ export function normalizeGenerationFailure(lastErr, options = {}) {
104
198
  err.cause = lastErr;
105
199
  return err;
106
200
  }
201
+ if (RESPONSE_DIAGNOSTIC_CODES.has(code)) {
202
+ const err = new Error(lastErr?.message || "Image generation did not return image data");
203
+ err.code = code;
204
+ err.status = lastErr?.status || statusForErrorCode(code, 422);
205
+ err.cause = lastErr;
206
+ if (lastErr?.upstreamCode)
207
+ err.upstreamCode = safeDiagnosticLabel(lastErr.upstreamCode);
208
+ if (lastErr?.upstreamType)
209
+ err.upstreamType = safeDiagnosticLabel(lastErr.upstreamType);
210
+ if (lastErr?.upstreamParam)
211
+ err.upstreamParam = safeDiagnosticLabel(lastErr.upstreamParam);
212
+ if (lastErr?.eventType)
213
+ err.eventType = lastErr.eventType;
214
+ copyEmptyResponseMetadata(err, lastErr);
215
+ err.diagnosticReason = diagnosticReasonFrom(lastErr) || code.toLowerCase();
216
+ return err;
217
+ }
107
218
  // Empty response with metadata → likely a technical limitation (unsupported size/quality/model)
108
219
  if (typeof lastErr?.eventCount === "number") {
109
220
  const meta = [];
@@ -126,24 +237,11 @@ export function normalizeGenerationFailure(lastErr, options = {}) {
126
237
  err.quality = lastErr.quality;
127
238
  if (lastErr.model)
128
239
  err.model = lastErr.model;
129
- if (typeof lastErr.eventCount === "number")
130
- err.eventCount = lastErr.eventCount;
131
- if (lastErr.eventTypes)
132
- err.eventTypes = lastErr.eventTypes;
133
- if (typeof lastErr.refsCount === "number")
134
- err.refsCount = lastErr.refsCount;
135
- if (typeof lastErr.inputImageCount === "number")
136
- err.inputImageCount = lastErr.inputImageCount;
137
- if (Array.isArray(lastErr.referenceDiagnostics))
138
- err.referenceDiagnostics = lastErr.referenceDiagnostics;
139
- if (typeof lastErr.referenceMismatchCount === "number")
140
- err.referenceMismatchCount = lastErr.referenceMismatchCount;
141
- if (lastErr.retryKind)
142
- err.retryKind = lastErr.retryKind;
143
- if (typeof lastErr.referencesDroppedOnRetry === "boolean")
144
- err.referencesDroppedOnRetry = lastErr.referencesDroppedOnRetry;
145
- if (typeof lastErr.developerPromptDroppedOnRetry === "boolean")
146
- err.developerPromptDroppedOnRetry = lastErr.developerPromptDroppedOnRetry;
240
+ if (lastErr.provider)
241
+ err.provider = lastErr.provider;
242
+ if (lastErr.moderation)
243
+ err.moderation = lastErr.moderation;
244
+ copyEmptyResponseMetadata(err, lastErr);
147
245
  const diagnosticReason = diagnosticReasonFrom(lastErr);
148
246
  if (diagnosticReason)
149
247
  err.diagnosticReason = diagnosticReason;
@@ -1,4 +1,6 @@
1
1
  import { classifyUpstreamError, classifyUpstreamErrorCode } from "./errorClassify.js";
2
+ import { safeDiagnosticLabel } from "./responsesParse.js";
3
+ import { RESPONSE_DIAGNOSTIC_CODES } from "./responsesErrors.js";
2
4
 
3
5
  const PASSTHROUGH_CODES = new Set([
4
6
  "OAUTH_UNAVAILABLE",
@@ -34,14 +36,29 @@ export interface UpstreamErr {
34
36
  eventType?: string;
35
37
  eventCount?: number;
36
38
  eventTypes?: unknown;
39
+ webSearchCalls?: number;
40
+ responseDiagnostics?: unknown;
41
+ webSearchEnabled?: boolean;
42
+ toolTypes?: unknown;
43
+ toolChoiceKind?: string;
44
+ promptChars?: number;
37
45
  quality?: string;
46
+ moderation?: string;
38
47
  model?: string;
48
+ provider?: string;
39
49
  refsCount?: number;
40
50
  inputImageCount?: number;
41
51
  referenceDiagnostics?: unknown;
42
52
  retryKind?: string;
53
+ initialEventCount?: number;
54
+ initialEventTypes?: unknown;
43
55
  referencesDroppedOnRetry?: boolean;
44
56
  developerPromptDroppedOnRetry?: boolean;
57
+ webSearchDroppedOnRetry?: boolean;
58
+ fallbackEventCount?: number;
59
+ fallbackEventTypes?: unknown;
60
+ fallbackImageCallSeen?: boolean;
61
+ fallbackImageResultCount?: number;
45
62
  name?: string;
46
63
  stack?: string;
47
64
  }
@@ -49,22 +66,57 @@ export interface UpstreamErr {
49
66
  function diagnosticReasonFrom(err: UpstreamErr | null | undefined) {
50
67
  if (typeof err?.diagnosticReason === "string" && err.diagnosticReason) return err.diagnosticReason;
51
68
  if (Number(err?.referenceMismatchCount) > 0) return "reference_mime_mismatch_candidate";
69
+ const responseDiagnosticReason = responseDiagnosticReasonFrom(err);
70
+ if (responseDiagnosticReason) return responseDiagnosticReason;
52
71
  if (has4kSize(err?.size)) return "experimental_4k_empty_response";
53
72
  return null;
54
73
  }
55
74
 
75
+ function responseDiagnosticReasonFrom(err: UpstreamErr | null | undefined) {
76
+ if (!err?.responseDiagnostics || typeof err.responseDiagnostics !== "object") return null;
77
+ const diagnostics = err.responseDiagnostics as {
78
+ imageCallSeen?: unknown;
79
+ imageCallCompleted?: unknown;
80
+ imageCallFailed?: unknown;
81
+ imageResultCount?: unknown;
82
+ messageOutputSeen?: unknown;
83
+ streamStats?: { bytesRead?: unknown };
84
+ };
85
+ const bytesRead = Number(diagnostics.streamStats?.bytesRead);
86
+ if (Number.isFinite(bytesRead) && bytesRead > 0 && Number(err.eventCount) === 0) return "stream_parse_failed";
87
+ if (diagnostics.imageCallFailed === true) return "image_tool_failed";
88
+ if (diagnostics.imageCallCompleted === true && Number(diagnostics.imageResultCount) === 0) return "image_tool_completed_without_result";
89
+ if (diagnostics.imageCallSeen !== true && Number(err.webSearchCalls) > 0) return "web_search_only_response";
90
+ if (diagnostics.imageCallSeen !== true && diagnostics.messageOutputSeen === true) return "image_tool_not_called";
91
+ return null;
92
+ }
93
+
94
+ function responseDiagnosticCodeFrom(err: UpstreamErr | null | undefined) {
95
+ const reason = responseDiagnosticReasonFrom(err);
96
+ if (reason === "stream_parse_failed") return "STREAM_PARSE_FAILED";
97
+ if (reason === "web_search_only_response") return "WEB_SEARCH_ONLY_RESPONSE";
98
+ if (reason === "image_tool_not_called") return "IMAGE_TOOL_NOT_CALLED";
99
+ if (reason === "image_tool_failed") return "IMAGE_TOOL_FAILED";
100
+ if (reason === "image_tool_completed_without_result") return "IMAGE_TOOL_COMPLETED_WITHOUT_RESULT";
101
+ return null;
102
+ }
103
+
56
104
  export function errorCodeFrom(err: UpstreamErr | null | undefined): string {
57
105
  if (!err) return "UNKNOWN";
106
+ const appCode = err.code as string;
107
+ if (RESPONSE_DIAGNOSTIC_CODES.has(appCode) || appCode === "EMPTY_RESPONSE") return appCode;
58
108
  const upstreamCode = classifyUpstreamErrorCode(err.upstreamCode);
59
109
  if (upstreamCode !== "UNKNOWN") return upstreamCode;
60
110
  const upstreamType = classifyUpstreamErrorCode(err.upstreamType);
61
111
  if (upstreamType !== "UNKNOWN") return upstreamType;
62
112
  // Known app-level codes pass through directly (before message heuristic)
63
- if (PASSTHROUGH_CODES.has(err.code as string) || SAFETY_CODES.has(err.code as string)) return err.code as string;
113
+ if (PASSTHROUGH_CODES.has(appCode) || SAFETY_CODES.has(appCode)) return appCode;
64
114
  const rawCode = classifyUpstreamErrorCode(err.code);
65
115
  if (rawCode !== "UNKNOWN") return rawCode;
66
116
  const direct = classifyUpstreamError(err.message);
67
117
  if (direct !== "UNKNOWN") return direct;
118
+ const responseDiagnosticCode = responseDiagnosticCodeFrom(err);
119
+ if (responseDiagnosticCode) return responseDiagnosticCode;
68
120
  const status = Number(err.status);
69
121
  if (Number.isFinite(status) && status >= 400 && status < 500 && !SAFETY_CODES.has(err.code as string)) {
70
122
  return "INVALID_REQUEST";
@@ -86,12 +138,40 @@ export function statusForErrorCode(code: string, fallback = 500) {
86
138
  if (code === "AUTH_CHATGPT_EXPIRED" || code === "AUTH_API_KEY_INVALID") return 401;
87
139
  if (code === "API_KEY_REQUIRED") return 401;
88
140
  if (code === "UPSTREAM_5XX") return 502;
141
+ if (code === "RESPONSES_STREAM_ERROR") return 502;
89
142
  if (code === "OAUTH_IMAGE_TIMEOUT") return 504;
90
143
  if (code === "INVALID_REQUEST") return 400;
144
+ if (RESPONSE_DIAGNOSTIC_CODES.has(code)) return 422;
91
145
  if (code === "SAFETY_REFUSAL" || code === "MODERATION_REFUSED" || code === "moderation_blocked") return 422;
92
146
  return fallback;
93
147
  }
94
148
 
149
+ function copyEmptyResponseMetadata(target: any, source: UpstreamErr | null | undefined) {
150
+ if (!source) return;
151
+ if (typeof source.eventCount === "number") target.eventCount = source.eventCount;
152
+ if (source.eventTypes) target.eventTypes = source.eventTypes;
153
+ if (typeof source.webSearchCalls === "number") target.webSearchCalls = source.webSearchCalls;
154
+ if (source.responseDiagnostics) target.responseDiagnostics = source.responseDiagnostics;
155
+ if (typeof source.webSearchEnabled === "boolean") target.webSearchEnabled = source.webSearchEnabled;
156
+ if (Array.isArray(source.toolTypes)) target.toolTypes = source.toolTypes;
157
+ if (source.toolChoiceKind) target.toolChoiceKind = source.toolChoiceKind;
158
+ if (typeof source.promptChars === "number") target.promptChars = source.promptChars;
159
+ if (typeof source.refsCount === "number") target.refsCount = source.refsCount;
160
+ if (typeof source.inputImageCount === "number") target.inputImageCount = source.inputImageCount;
161
+ if (Array.isArray(source.referenceDiagnostics)) target.referenceDiagnostics = source.referenceDiagnostics;
162
+ if (typeof source.referenceMismatchCount === "number") target.referenceMismatchCount = source.referenceMismatchCount;
163
+ if (source.retryKind) target.retryKind = source.retryKind;
164
+ if (typeof source.initialEventCount === "number") target.initialEventCount = source.initialEventCount;
165
+ if (source.initialEventTypes) target.initialEventTypes = source.initialEventTypes;
166
+ if (typeof source.referencesDroppedOnRetry === "boolean") target.referencesDroppedOnRetry = source.referencesDroppedOnRetry;
167
+ if (typeof source.developerPromptDroppedOnRetry === "boolean") target.developerPromptDroppedOnRetry = source.developerPromptDroppedOnRetry;
168
+ if (typeof source.webSearchDroppedOnRetry === "boolean") target.webSearchDroppedOnRetry = source.webSearchDroppedOnRetry;
169
+ if (typeof source.fallbackEventCount === "number") target.fallbackEventCount = source.fallbackEventCount;
170
+ if (source.fallbackEventTypes) target.fallbackEventTypes = source.fallbackEventTypes;
171
+ if (typeof source.fallbackImageCallSeen === "boolean") target.fallbackImageCallSeen = source.fallbackImageCallSeen;
172
+ if (typeof source.fallbackImageResultCount === "number") target.fallbackImageResultCount = source.fallbackImageResultCount;
173
+ }
174
+
95
175
  export function normalizeGenerationFailure(lastErr: UpstreamErr | null | undefined, options: any = {}) {
96
176
  const code = errorCodeFrom(lastErr);
97
177
  if (PASSTHROUGH_CODES.has(code)) {
@@ -99,9 +179,9 @@ export function normalizeGenerationFailure(lastErr: UpstreamErr | null | undefin
99
179
  err.code = code;
100
180
  err.status = lastErr?.status || statusForErrorCode(code);
101
181
  err.cause = lastErr;
102
- if (lastErr?.upstreamCode) err.upstreamCode = lastErr.upstreamCode;
103
- if (lastErr?.upstreamType) err.upstreamType = lastErr.upstreamType;
104
- if (lastErr?.upstreamParam) err.upstreamParam = lastErr.upstreamParam;
182
+ if (lastErr?.upstreamCode) err.upstreamCode = safeDiagnosticLabel(lastErr.upstreamCode);
183
+ if (lastErr?.upstreamType) err.upstreamType = safeDiagnosticLabel(lastErr.upstreamType);
184
+ if (lastErr?.upstreamParam) err.upstreamParam = safeDiagnosticLabel(lastErr.upstreamParam);
105
185
  if (lastErr?.eventType) err.eventType = lastErr.eventType;
106
186
  if (typeof lastErr?.eventCount === "number") err.eventCount = lastErr.eventCount;
107
187
  return err;
@@ -113,6 +193,19 @@ export function normalizeGenerationFailure(lastErr: UpstreamErr | null | undefin
113
193
  err.cause = lastErr;
114
194
  return err;
115
195
  }
196
+ if (RESPONSE_DIAGNOSTIC_CODES.has(code)) {
197
+ const err: any = new Error(lastErr?.message || "Image generation did not return image data");
198
+ err.code = code;
199
+ err.status = lastErr?.status || statusForErrorCode(code, 422);
200
+ err.cause = lastErr;
201
+ if (lastErr?.upstreamCode) err.upstreamCode = safeDiagnosticLabel(lastErr.upstreamCode);
202
+ if (lastErr?.upstreamType) err.upstreamType = safeDiagnosticLabel(lastErr.upstreamType);
203
+ if (lastErr?.upstreamParam) err.upstreamParam = safeDiagnosticLabel(lastErr.upstreamParam);
204
+ if (lastErr?.eventType) err.eventType = lastErr.eventType;
205
+ copyEmptyResponseMetadata(err, lastErr);
206
+ err.diagnosticReason = diagnosticReasonFrom(lastErr) || code.toLowerCase();
207
+ return err;
208
+ }
116
209
  // Empty response with metadata → likely a technical limitation (unsupported size/quality/model)
117
210
  if (typeof lastErr?.eventCount === "number") {
118
211
  const meta: string[] = [];
@@ -129,15 +222,9 @@ export function normalizeGenerationFailure(lastErr: UpstreamErr | null | undefin
129
222
  if (lastErr.size) err.size = lastErr.size;
130
223
  if (lastErr.quality) err.quality = lastErr.quality;
131
224
  if (lastErr.model) err.model = lastErr.model;
132
- if (typeof lastErr.eventCount === "number") err.eventCount = lastErr.eventCount;
133
- if (lastErr.eventTypes) err.eventTypes = lastErr.eventTypes;
134
- if (typeof lastErr.refsCount === "number") err.refsCount = lastErr.refsCount;
135
- if (typeof lastErr.inputImageCount === "number") err.inputImageCount = lastErr.inputImageCount;
136
- if (Array.isArray(lastErr.referenceDiagnostics)) err.referenceDiagnostics = lastErr.referenceDiagnostics;
137
- if (typeof lastErr.referenceMismatchCount === "number") err.referenceMismatchCount = lastErr.referenceMismatchCount;
138
- if (lastErr.retryKind) err.retryKind = lastErr.retryKind;
139
- if (typeof lastErr.referencesDroppedOnRetry === "boolean") err.referencesDroppedOnRetry = lastErr.referencesDroppedOnRetry;
140
- if (typeof lastErr.developerPromptDroppedOnRetry === "boolean") err.developerPromptDroppedOnRetry = lastErr.developerPromptDroppedOnRetry;
225
+ if (lastErr.provider) err.provider = lastErr.provider;
226
+ if (lastErr.moderation) err.moderation = lastErr.moderation;
227
+ copyEmptyResponseMetadata(err, lastErr);
141
228
  const diagnosticReason = diagnosticReasonFrom(lastErr);
142
229
  if (diagnosticReason) err.diagnosticReason = diagnosticReason;
143
230
  return err;