ima2-gen 3.26.1 → 3.26.2

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 (38) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/docs/migration/runtime-test-inventory.md +8 -1
  3. package/lib/oauthImages.js +45 -30
  4. package/lib/oauthJobDeadline.js +70 -0
  5. package/lib/oauthProxy/runtime.js +15 -5
  6. package/lib/oauthRateLimit.js +6 -7
  7. package/lib/providers/adapters/openaiExecution.js +41 -15
  8. package/lib/responsesTransport.js +117 -109
  9. package/package.json +6 -6
  10. package/ui/dist/.vite/manifest.json +69 -69
  11. package/ui/dist/assets/{AgentWorkspace-DGEWNErN.js → AgentWorkspace-B49xr58Q.js} +1 -1
  12. package/ui/dist/assets/App-Bq_IUqRU.js +9 -0
  13. package/ui/dist/assets/{AssetGenWorkspace-woj0orEW.js → AssetGenWorkspace-BkF-1HG-.js} +2 -2
  14. package/ui/dist/assets/{AssetMediaLightbox-39eeVLgg.js → AssetMediaLightbox-CxfO1QUc.js} +1 -1
  15. package/ui/dist/assets/{AssetsWorkspace-feT-v8BY.js → AssetsWorkspace-BkWynV74.js} +1 -1
  16. package/ui/dist/assets/{CardNewsWorkspace-Bce7Qq0s.js → CardNewsWorkspace-BKSfq0oA.js} +1 -1
  17. package/ui/dist/assets/{GenerationRequestLogPanel-D6jO_fXj.js → GenerationRequestLogPanel-Bvlv8UCB.js} +1 -1
  18. package/ui/dist/assets/{HomeWorkspace-C7SABT2o.js → HomeWorkspace-Ck7uEYz4.js} +1 -1
  19. package/ui/dist/assets/{KeyingPanel-DWaV9fRG.js → KeyingPanel-B5yKvszy.js} +1 -1
  20. package/ui/dist/assets/{NodeCanvas-ybOBBYto.js → NodeCanvas-Bf9bEiX2.js} +3 -3
  21. package/ui/dist/assets/{PromptBuilderPanel-C1DLHD9Q.js → PromptBuilderPanel-Bz2e5lKO.js} +1 -1
  22. package/ui/dist/assets/{PromptImportDialog-OGyYU5K5.js → PromptImportDialog-ctIs_XxZ.js} +2 -2
  23. package/ui/dist/assets/{PromptImportDiscoverySection-Cwigr40k.js → PromptImportDiscoverySection-CDppFhg4.js} +1 -1
  24. package/ui/dist/assets/{PromptImportFolderSection-B1XqjRbl.js → PromptImportFolderSection-Cvd73I4D.js} +1 -1
  25. package/ui/dist/assets/{PromptLibraryPanel-v7hIoYy-.js → PromptLibraryPanel-DtP62WHm.js} +2 -2
  26. package/ui/dist/assets/{SettingsWorkspace-CGZXJLgN.js → SettingsWorkspace-5IEsIqqb.js} +1 -1
  27. package/ui/dist/assets/{SpriteRecipeWorkspace-Cm5wizr5.js → SpriteRecipeWorkspace-DQsk4cc5.js} +1 -1
  28. package/ui/dist/assets/{index-CUMOJ4Vx.js → index-CRUYCtE2.js} +3 -3
  29. package/ui/dist/assets/index-CizoKwOj.css +1 -0
  30. package/ui/dist/assets/index-WbTkAEzk.js +23 -0
  31. package/ui/dist/assets/{pptxgen.es-XHiluGIr.js → pptxgen.es-B6CA1Typ.js} +1 -1
  32. package/ui/dist/assets/{promptBuilderStore-BKNNtgtk.js → promptBuilderStore-CJBR66MD.js} +1 -1
  33. package/ui/dist/assets/useAgentDialogFocus-CyM46tgI.js +1 -0
  34. package/ui/dist/index.html +2 -2
  35. package/ui/dist/assets/App-b7KJO01r.js +0 -9
  36. package/ui/dist/assets/index-CJ-sBrSx.js +0 -23
  37. package/ui/dist/assets/index-Dznqt1dP.css +0 -1
  38. package/ui/dist/assets/useAgentDialogFocus-CZabQdET.js +0 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.26.2] - 2026-10-04
11
+
12
+ ### Added
13
+
14
+ - **Video sound instructions** — composer presets add one prompt chip for music, ambience, effects or no dialogue. Selection can be replaced, toggled or cleared, persists across model changes/reload, and uses localized labels. The picker fits narrow screens and stays hidden in Node mode, whose prompts are independent. (#364, #371)
15
+
16
+ ### Fixed
17
+
18
+ - **One OAuth time budget across retries** — readiness, planning, rendering and rate-limit waits share a job deadline, while prepared executions retain the remaining budget across caller retries and concurrent calls. Expiry is `504 OAUTH_IMAGE_TIMEOUT`; explicit cancellation remains `499`. Completed images and local persistence callbacks are preserved. (#351, #370)
19
+ - **Composer audio feedback** — unsupported audio drops now explain the limitation instead of suggesting an audio reference was attached. Image attachment and existing video CLI guidance remain available. (#364, #371)
20
+ - **Dry-run release safety** — `--dry-run` refuses when dev needs promotion, including when combined with `--promote --approve --yes`. Already-promoted main can still dispatch hosted validation without publishing branches, tags or packages.
21
+
22
+ ### Changed
23
+
24
+ - Updated the reviewed runtime, lint, node-canvas and CodeQL dependency pins, including MCP SDK, Codex, OpenAI, sharp and XYFlow. (#369)
25
+
26
+ ### Security
27
+
28
+ - Pinned the browser PPTX dependency tree to `image-size` 2.0.4 and removed its obsolete audit exceptions. The root braces advisory [GHSA-vfj7-8cjw-p6xm](https://github.com/advisories/GHSA-vfj7-8cjw-p6xm) retains one evidence-backed exception through 2026-10-17: the inspected application path disables glob parsing, with real-package and dependency-path regression guards. This is not a claim that the vulnerable dependency was removed.
29
+
10
30
  ## [3.26.1] - 2026-10-02
11
31
 
12
32
  ### Fixed
@@ -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: 545 (runtime: 247, contract: 298)
7
+ Total: 552 (runtime: 251, contract: 301)
8
8
 
9
9
  ## Runtime-importing tests
10
10
  - `tests/advertise-payload-contract.test.ts`
@@ -162,6 +162,8 @@ Total: 545 (runtime: 247, contract: 298)
162
162
  - `tests/node-template-portable.test.ts`
163
163
  - `tests/node-validation-error-contract.test.ts`
164
164
  - `tests/oauth-cli-dependencies.test.ts`
165
+ - `tests/oauth-execution-deadline.test.ts`
166
+ - `tests/oauth-job-timeout.test.ts`
165
167
  - `tests/oauth-normalize.test.ts`
166
168
  - `tests/oauth-proxy-error-safety.test.ts`
167
169
  - `tests/oauth-rate-limit-lane.test.ts`
@@ -196,6 +198,7 @@ Total: 545 (runtime: 247, contract: 298)
196
198
  - `tests/refs-size.test.ts`
197
199
  - `tests/request-logging.test.ts`
198
200
  - `tests/responses-adapter-safety.test.ts`
201
+ - `tests/responses-api-abort-compat.test.ts`
199
202
  - `tests/responses-empty-taxonomy.test.ts`
200
203
  - `tests/responses-parse-diagnostics.test.ts`
201
204
  - `tests/runtime-context-normalize.test.ts`
@@ -226,6 +229,7 @@ Total: 545 (runtime: 247, contract: 298)
226
229
  - `tests/storage-migration.test.ts`
227
230
  - `tests/structured-filename-pipelines.test.ts`
228
231
  - `tests/style-sheet.test.ts`
232
+ - `tests/system-trash-glob-safety.test.ts`
229
233
  - `tests/terminal-jobs-restart.test.ts`
230
234
  - `tests/thumb-backfill.test.ts`
231
235
  - `tests/transparent-alpha-verification.test.ts`
@@ -282,6 +286,7 @@ Total: 545 (runtime: 247, contract: 298)
282
286
  - `tests/async-capacity-retry-contract.test.ts`
283
287
  - `tests/async-stream-subscribe-order.test.ts`
284
288
  - `tests/audit-gate-contract.test.ts`
289
+ - `tests/audit-trash-graph-contract.test.ts`
285
290
  - `tests/backend-hardening-contract.test.ts`
286
291
  - `tests/background-cleanup-brush-rasterize.test.ts`
287
292
  - `tests/background-cleanup-mask-compose.test.ts`
@@ -550,6 +555,8 @@ Total: 545 (runtime: 247, contract: 298)
550
555
  - `tests/video-frame-local-file.test.ts`
551
556
  - `tests/video-gallery-refresh-contract.test.ts`
552
557
  - `tests/video-inflight-kind-contract.test.ts`
558
+ - `tests/video-sound-intent-contract.test.ts`
559
+ - `tests/video-sound-intent-ui-contract.test.ts`
553
560
  - `tests/video-ui-source-count.test.ts`
554
561
  - `tests/vite-dev-port-contract.test.ts`
555
562
  - `tests/wand-erase.test.ts`
@@ -11,6 +11,8 @@
11
11
  * The result keeps ParsedResponsesResult's shape so routes, SSE, history and error
12
12
  * diagnostics stay unchanged.
13
13
  */
14
+ import { config } from "../config.js";
15
+ import { createOAuthJobDeadline, throwIfOAuthAborted } from "./oauthJobDeadline.js";
14
16
  import { logEvent } from "./logger.js";
15
17
  import { postOAuthImages, postResponses } from "./responsesTransport.js";
16
18
  import { createOAuthRateLimitBudget, oauthRateLimitRetryConfig, withOAuthRateLimitRetry, } from "./oauthRateLimit.js";
@@ -129,13 +131,6 @@ export function renderFields(job) {
129
131
  function extensionFor(mime) {
130
132
  return mime === "image/jpeg" ? "jpg" : mime === "image/webp" ? "webp" : "png";
131
133
  }
132
- /**
133
- * One retry budget per job: the plan and every render draw from the same retry count and total
134
- * wait, and no wait may run past the job's generation timeout.
135
- */
136
- function rateLimitBudgetFor(job) {
137
- return createOAuthRateLimitBudget(job.ctx?.config?.oauth?.generationTimeoutMs ?? 400 * 1000);
138
- }
139
134
  /** Replay a call the backend rejected with a per-minute rate limit; log every wait. */
140
135
  function withRateLimitBackoff(job, budget, stage, request) {
141
136
  return withOAuthRateLimitRetry(request, {
@@ -211,45 +206,34 @@ async function plan(job, budget) {
211
206
  }
212
207
  return { result, prompts };
213
208
  }
214
- export async function runOAuthImageJob(job) {
215
- // Direct mode renders the user's prompt verbatim, but a multi-image request still needs one
216
- // prompt per stage, so it goes through the planner (its user text already carries the
217
- // sequence instructions and the Direct fidelity rule).
218
- const direct = job.mode === "direct" && job.maxImages <= 1;
219
- const budget = rateLimitBudgetFor(job);
220
- const planned = direct
221
- ? { result: null, prompts: Array.from({ length: Math.max(1, job.maxImages) }, () => job.directPrompt) }
222
- : await plan(job, budget);
223
- const base = planned.result;
224
- const usage = {};
225
- addUsage(usage, base?.usage);
226
- const eventTypes = { ...(base?.eventTypes ?? {}) };
227
- const diagnostics = base?.diagnostics ? { ...base.diagnostics } : emptyDiagnostics();
228
- const kind = job.images.length ? "images.edits" : "images.generations";
229
- logEvent(job.scope, "plan_done", { requestId: job.requestId, direct, prompts: planned.prompts.length });
230
- const slots = planned.prompts.map(() => {
209
+ function startRenders(job, budget, prompts) {
210
+ const slots = prompts.map(() => {
231
211
  let resolve;
232
212
  const promise = new Promise((r) => { resolve = r; });
233
213
  return { promise, resolve };
234
214
  });
235
215
  let cursor = 0;
236
216
  const worker = async () => {
237
- while (cursor < planned.prompts.length) {
217
+ while (cursor < prompts.length) {
238
218
  const index = cursor++;
239
219
  try {
240
- slots[index]?.resolve({ status: "done", value: await renderOne(job, budget, planned.prompts[index]) });
220
+ slots[index]?.resolve({ status: "done", value: await renderOne(job, budget, prompts[index]) });
241
221
  }
242
222
  catch (error) {
243
223
  slots[index]?.resolve({ status: "failed", error });
244
224
  }
245
225
  }
246
226
  };
247
- const workers = Array.from({ length: Math.min(RENDER_CONCURRENCY, planned.prompts.length) }, () => worker());
227
+ const workers = Array.from({ length: Math.min(RENDER_CONCURRENCY, prompts.length) }, () => worker());
228
+ return { slots, workers };
229
+ }
230
+ async function collectRenders(job, batch, context) {
231
+ const { prompts, direct, eventTypes, usage, kind } = context;
248
232
  const images = [];
249
233
  const originalIndexes = [];
250
234
  let firstError;
251
235
  // Deliver in stage order so sequences persist and stream in the order the planner wrote them.
252
- for (const [index, slot] of slots.entries()) {
236
+ for (const [index, slot] of batch.slots.entries()) {
253
237
  const settled = await slot.promise;
254
238
  eventTypes[kind] = (eventTypes[kind] ?? 0) + 1;
255
239
  if (settled.status === "failed") {
@@ -261,12 +245,33 @@ export async function runOAuthImageJob(job) {
261
245
  const b64 = settled.value.images[0]?.b64;
262
246
  if (!b64)
263
247
  continue;
264
- const image = { b64, revisedPrompt: direct ? null : planned.prompts[index] };
248
+ const image = { b64, revisedPrompt: direct ? null : prompts[index] };
265
249
  images.push(image);
266
250
  originalIndexes.push(index);
267
251
  await job.onFinalImage?.(image, index);
268
252
  }
269
- await Promise.all(workers);
253
+ await Promise.all(batch.workers);
254
+ return { images, originalIndexes, firstError };
255
+ }
256
+ async function runOAuthImageJobWithinDeadline(job, budget) {
257
+ // Direct mode renders the user's prompt verbatim, but a multi-image request still needs one
258
+ // prompt per stage, so it goes through the planner (its user text already carries the
259
+ // sequence instructions and the Direct fidelity rule).
260
+ const direct = job.mode === "direct" && job.maxImages <= 1;
261
+ const planned = direct
262
+ ? { result: null, prompts: Array.from({ length: Math.max(1, job.maxImages) }, () => job.directPrompt) }
263
+ : await plan(job, budget);
264
+ const base = planned.result;
265
+ const usage = {};
266
+ addUsage(usage, base?.usage);
267
+ const eventTypes = { ...(base?.eventTypes ?? {}) };
268
+ const diagnostics = base?.diagnostics ? { ...base.diagnostics } : emptyDiagnostics();
269
+ const kind = job.images.length ? "images.edits" : "images.generations";
270
+ logEvent(job.scope, "plan_done", { requestId: job.requestId, direct, prompts: planned.prompts.length });
271
+ const batch = startRenders(job, budget, planned.prompts);
272
+ const { images, originalIndexes, firstError } = await collectRenders(job, batch, {
273
+ prompts: planned.prompts, direct, eventTypes, usage, kind,
274
+ });
270
275
  // A single render has no partial result to return: surface its own error.
271
276
  if (planned.prompts.length === 1 && firstError !== undefined)
272
277
  throw firstError;
@@ -289,3 +294,13 @@ export async function runOAuthImageJob(job) {
289
294
  ...(firstError !== undefined ? { error: firstError } : {}),
290
295
  };
291
296
  }
297
+ export async function runOAuthImageJob(job) {
298
+ const lifetime = createOAuthJobDeadline(job.ctx.config?.oauth?.generationTimeoutMs ?? config.oauth.generationTimeoutMs, job.signal);
299
+ try {
300
+ throwIfOAuthAborted(lifetime.signal);
301
+ return await runOAuthImageJobWithinDeadline({ ...job, signal: lifetime.signal }, createOAuthRateLimitBudget(lifetime.timeoutMs));
302
+ }
303
+ finally {
304
+ lifetime.dispose();
305
+ }
306
+ }
@@ -0,0 +1,70 @@
1
+ /** One OAuth image job owns its deadline; reasons are safe to surface to clients. */
2
+ export class OAuthJobAbort extends Error {
3
+ name = "AbortError";
4
+ status;
5
+ code;
6
+ constructor(kind) {
7
+ super(kind !== "cancel" ? "OAuth image generation timed out" : "Generation canceled");
8
+ this.status = kind === "cancel" ? 499 : 504;
9
+ this.code = kind === "cancel" ? "GENERATION_CANCELED"
10
+ : kind === "job-timeout" ? "OAUTH_IMAGE_TIMEOUT" : "RESPONSES_IMAGE_TIMEOUT";
11
+ }
12
+ }
13
+ export function normalizeOAuthTimeout(value) {
14
+ return Number.isFinite(value) && value > 0 ? Math.max(1, Math.ceil(value)) : 0;
15
+ }
16
+ export function oauthAbortError(signal) {
17
+ return signal?.reason instanceof OAuthJobAbort ? signal.reason : new OAuthJobAbort("cancel");
18
+ }
19
+ export function throwIfOAuthAborted(signal) {
20
+ if (signal?.aborted)
21
+ throw oauthAbortError(signal);
22
+ }
23
+ function createDeadline(timeoutMs, parent, kind) {
24
+ const controller = new AbortController();
25
+ const onCancel = () => controller.abort(oauthAbortError(parent));
26
+ if (parent?.aborted)
27
+ onCancel();
28
+ else
29
+ parent?.addEventListener("abort", onCancel, { once: true });
30
+ const duration = normalizeOAuthTimeout(timeoutMs);
31
+ const timer = duration && !controller.signal.aborted
32
+ ? setTimeout(() => controller.abort(new OAuthJobAbort(kind)), duration)
33
+ : undefined;
34
+ return {
35
+ signal: controller.signal,
36
+ timeoutMs: duration,
37
+ dispose() {
38
+ clearTimeout(timer);
39
+ parent?.removeEventListener("abort", onCancel);
40
+ },
41
+ };
42
+ }
43
+ export function createOAuthJobDeadline(timeoutMs, parent) {
44
+ return createDeadline(timeoutMs, parent, "job-timeout");
45
+ }
46
+ export function createOAuthRequestDeadline(timeoutMs, parent) {
47
+ return createDeadline(timeoutMs, parent, "request-timeout");
48
+ }
49
+ /** Observe late settlement even when abort wins; do not race irreversible local writes. */
50
+ export async function withOAuthAbort(run, signal) {
51
+ throwIfOAuthAborted(signal);
52
+ if (!signal)
53
+ return run();
54
+ let onAbort = () => { };
55
+ const aborted = new Promise((_resolve, reject) => {
56
+ onAbort = () => reject(oauthAbortError(signal));
57
+ signal.addEventListener("abort", onAbort, { once: true });
58
+ if (signal.aborted)
59
+ onAbort();
60
+ });
61
+ try {
62
+ return await Promise.race([
63
+ Promise.resolve().then(() => { throwIfOAuthAborted(signal); return run(); }),
64
+ aborted,
65
+ ]);
66
+ }
67
+ finally {
68
+ signal.removeEventListener("abort", onAbort);
69
+ }
70
+ }
@@ -1,3 +1,4 @@
1
+ import { throwIfOAuthAborted, withOAuthAbort } from "../oauthJobDeadline.js";
1
2
  import { config } from "../../config.js";
2
3
  import { logEvent } from "../logger.js";
3
4
  import { isAbortError, makeOAuthError } from "./errors.js";
@@ -52,7 +53,8 @@ export function createOAuthGenerationTimeout(ctx = {}, requestId = null, scope =
52
53
  isTimeoutError: (err) => timedOut && isAbortError(err),
53
54
  };
54
55
  }
55
- export async function waitForOAuthReady(ctx = {}) {
56
+ export async function waitForOAuthReady(ctx = {}, signal) {
57
+ throwIfOAuthAborted(signal);
56
58
  if (!ctx || ctx.oauthReadyState === undefined)
57
59
  return;
58
60
  // A new login may have landed since the proxy started (or gave up), possibly from a CLI that
@@ -66,11 +68,19 @@ export async function waitForOAuthReady(ctx = {}) {
66
68
  }
67
69
  const timeoutMs = ctx.config?.oauth?.statusTimeoutMs ?? config.oauth.statusTimeoutMs;
68
70
  if (ctx.oauthReadyPromise) {
69
- await Promise.race([
70
- ctx.oauthReadyPromise,
71
- new Promise((resolve) => setTimeout(resolve, timeoutMs)),
72
- ]);
71
+ const ready = ctx.oauthReadyPromise;
72
+ let timer;
73
+ try {
74
+ await withOAuthAbort(() => Promise.race([
75
+ ready,
76
+ new Promise((resolve) => { timer = setTimeout(resolve, timeoutMs); }),
77
+ ]), signal);
78
+ }
79
+ finally {
80
+ clearTimeout(timer);
81
+ }
73
82
  }
83
+ throwIfOAuthAborted(signal);
74
84
  const finalState = ctx.oauthReadyState;
75
85
  if (finalState !== "ready" && finalState !== "disabled") {
76
86
  throw makeOAuthError("OAuth proxy is not ready yet", { code: "OAUTH_UNAVAILABLE", status: 503 });
@@ -14,8 +14,9 @@
14
14
  * One job (a plan plus its renders) spends one retry budget: a retry count, a total wait and
15
15
  * the job's generation deadline, so a burst never stretches a job past its timeout.
16
16
  *
17
- * MUST stay a leaf module: it only borrows the abortable sleep, jitter and Retry-After parsing.
17
+ * MUST stay a leaf module: only retry primitives and the dependency-free job abort helper.
18
18
  */
19
+ import { oauthAbortError, throwIfOAuthAborted } from "./oauthJobDeadline.js";
19
20
  import { jitterDelayMs, retryAfterDelayMs, sleepWithAbort } from "./grokUpstreamRetry.js";
20
21
  /**
21
22
  * Caps that no short wait can clear. Checked first so mixed wording never retries. A bare
@@ -96,9 +97,6 @@ function retryAfterOf(error) {
96
97
  const value = error?.retryAfterMs;
97
98
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
98
99
  }
99
- function canceledError(cause) {
100
- return Object.assign(new Error("Generation canceled"), { status: 499, code: "GENERATION_CANCELED", cause });
101
- }
102
100
  export function createOAuthRateLimitBudget(timeoutMs, now = Date.now) {
103
101
  const bounded = typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0;
104
102
  return { retries: 0, totalWaitMs: 0, deadlineAt: bounded ? now() + timeoutMs : undefined };
@@ -121,6 +119,7 @@ export async function withOAuthRateLimitRetry(request, options = {}) {
121
119
  const now = options.now ?? Date.now;
122
120
  const signal = options.signal ?? undefined;
123
121
  for (let attempt = 1;; attempt++) {
122
+ throwIfOAuthAborted(signal);
124
123
  try {
125
124
  return await request();
126
125
  }
@@ -129,7 +128,7 @@ export async function withOAuthRateLimitRetry(request, options = {}) {
129
128
  throw error;
130
129
  // A job canceled while its request was being rate limited ends as a cancel, not a 429.
131
130
  if (signal?.aborted)
132
- throw canceledError(signal.reason ?? error);
131
+ throw oauthAbortError(signal);
133
132
  const waitMs = oauthRateLimitDelayMs(attempt, retryAfterOf(error), config, options.random);
134
133
  const reason = budgetShortfall(budget, config, waitMs, now());
135
134
  if (reason) {
@@ -144,11 +143,11 @@ export async function withOAuthRateLimitRetry(request, options = {}) {
144
143
  }
145
144
  catch (sleepError) {
146
145
  if (signal?.aborted)
147
- throw canceledError(sleepError);
146
+ throw oauthAbortError(signal);
148
147
  throw sleepError;
149
148
  }
150
149
  if (signal?.aborted)
151
- throw canceledError(signal.reason);
150
+ throw oauthAbortError(signal);
152
151
  }
153
152
  }
154
153
  }
@@ -1,15 +1,41 @@
1
+ import { config } from "../../../config.js";
2
+ import { createOAuthJobDeadline, normalizeOAuthTimeout, OAuthJobAbort, throwIfOAuthAborted } from "../../oauthJobDeadline.js";
1
3
  import { isNonRetryableGenerationError, normalizeGenerationFailure } from "../../generationErrors.js";
2
4
  import { throwIfJobCanceled } from "../../generationCancel.js";
3
5
  import { logEvent } from "../../logger.js";
4
6
  import { generateViaResponses, editViaResponses, generateMultimodeViaResponses } from "./openaiOperations.js";
7
+ /** One prepared OAuth operation owns its remaining budget across caller retries. */
8
+ function withPreparedOAuthBudget(ctx, request, run) {
9
+ if (request.provider === "api")
10
+ return () => run(request.signal);
11
+ let deadlineAt;
12
+ return async () => {
13
+ throwIfOAuthAborted(request.signal);
14
+ if (deadlineAt === undefined) {
15
+ const duration = normalizeOAuthTimeout(ctx.config?.oauth?.generationTimeoutMs ?? config.oauth.generationTimeoutMs);
16
+ deadlineAt = duration > 0 ? performance.now() + duration : Infinity;
17
+ }
18
+ const remaining = deadlineAt - performance.now();
19
+ if (remaining <= 0)
20
+ throw new OAuthJobAbort("job-timeout");
21
+ const lifetime = createOAuthJobDeadline(remaining, request.signal);
22
+ try {
23
+ return await run(lifetime.signal);
24
+ }
25
+ finally {
26
+ lifetime.dispose();
27
+ }
28
+ };
29
+ }
5
30
  export function isOpenaiRequest(request) {
6
31
  return request.provider === "oauth" || request.provider === "api";
7
32
  }
8
33
  function prepareOpenaiClassic(ctx, request, _progress) {
9
34
  const { provider: activeProvider, prompt: generationPrompt, requestId, background: backgroundParams } = request;
10
35
  const { model: imageModel, imageToolModel, quality, size: effectiveSize, moderation, mode: normalizedPromptMode, reasoningEffort, webSearchEnabled } = request.options;
11
- // Scalars are captured at prepare; references, signal and ctx stay live per attempt.
12
- const generateOne = async () => {
36
+ // Scalars are captured at prepare; references and ctx stay live per attempt.
37
+ // API keeps the caller signal live; OAuth uses the invocation deadline signal.
38
+ const generateOne = async (signal) => {
13
39
  const MAX_RETRIES = 1;
14
40
  let lastErr;
15
41
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
@@ -18,7 +44,7 @@ function prepareOpenaiClassic(ctx, request, _progress) {
18
44
  model: imageModel, imageToolModel,
19
45
  reasoningEffort,
20
46
  webSearchEnabled,
21
- signal: request.signal,
47
+ signal: activeProvider === "api" ? request.signal : signal,
22
48
  allowPromptOnlyOAuthFallback: activeProvider !== "api",
23
49
  ...(backgroundParams ? { background: backgroundParams.background } : {}),
24
50
  ...(backgroundParams?.outputFormat ? { outputFormat: backgroundParams.outputFormat } : {}),
@@ -44,9 +70,9 @@ function prepareOpenaiClassic(ctx, request, _progress) {
44
70
  safetyMessage: "Content generation refused after retries",
45
71
  });
46
72
  };
47
- return { execute: async () => {
48
- return { kind: "single", value: await generateOne() };
49
- } };
73
+ return { execute: withPreparedOAuthBudget(ctx, request, async (signal) => {
74
+ return { kind: "single", value: await generateOne(signal) };
75
+ }) };
50
76
  }
51
77
  async function executeOpenaiNode(ctx, request, progress) {
52
78
  const { provider, sourceImage: parentB64, prompt: generationPrompt, references, requestId, signal, searchMode, options } = request;
@@ -80,14 +106,14 @@ async function executeOpenaiMultimode(ctx, request, progress) {
80
106
  export async function prepareOpenaiExecution(ctx, request, progress = {}) {
81
107
  switch (request.surface) {
82
108
  case "classic": return prepareOpenaiClassic(ctx, request, progress);
83
- case "node": return { execute: async () => {
84
- return { kind: "single", value: await executeOpenaiNode(ctx, request, progress) };
85
- } };
86
- case "edit": return { execute: async () => {
87
- return { kind: "single", value: await executeOpenaiEdit(ctx, request) };
88
- } };
89
- case "multimode": return { execute: async () => {
90
- return { kind: "sequence", value: await executeOpenaiMultimode(ctx, request, progress) };
91
- } };
109
+ case "node": return { execute: withPreparedOAuthBudget(ctx, request, async (signal) => {
110
+ return { kind: "single", value: await executeOpenaiNode(ctx, { ...request, signal }, progress) };
111
+ }) };
112
+ case "edit": return { execute: withPreparedOAuthBudget(ctx, request, async (signal) => {
113
+ return { kind: "single", value: await executeOpenaiEdit(ctx, { ...request, signal }) };
114
+ }) };
115
+ case "multimode": return { execute: withPreparedOAuthBudget(ctx, request, async (signal) => {
116
+ return { kind: "sequence", value: await executeOpenaiMultimode(ctx, { ...request, signal }, progress) };
117
+ }) };
92
118
  }
93
119
  }