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.
- package/CHANGELOG.md +20 -0
- package/docs/migration/runtime-test-inventory.md +8 -1
- package/lib/oauthImages.js +45 -30
- package/lib/oauthJobDeadline.js +70 -0
- package/lib/oauthProxy/runtime.js +15 -5
- package/lib/oauthRateLimit.js +6 -7
- package/lib/providers/adapters/openaiExecution.js +41 -15
- package/lib/responsesTransport.js +117 -109
- package/package.json +6 -6
- package/ui/dist/.vite/manifest.json +69 -69
- package/ui/dist/assets/{AgentWorkspace-DGEWNErN.js → AgentWorkspace-B49xr58Q.js} +1 -1
- package/ui/dist/assets/App-Bq_IUqRU.js +9 -0
- package/ui/dist/assets/{AssetGenWorkspace-woj0orEW.js → AssetGenWorkspace-BkF-1HG-.js} +2 -2
- package/ui/dist/assets/{AssetMediaLightbox-39eeVLgg.js → AssetMediaLightbox-CxfO1QUc.js} +1 -1
- package/ui/dist/assets/{AssetsWorkspace-feT-v8BY.js → AssetsWorkspace-BkWynV74.js} +1 -1
- package/ui/dist/assets/{CardNewsWorkspace-Bce7Qq0s.js → CardNewsWorkspace-BKSfq0oA.js} +1 -1
- package/ui/dist/assets/{GenerationRequestLogPanel-D6jO_fXj.js → GenerationRequestLogPanel-Bvlv8UCB.js} +1 -1
- package/ui/dist/assets/{HomeWorkspace-C7SABT2o.js → HomeWorkspace-Ck7uEYz4.js} +1 -1
- package/ui/dist/assets/{KeyingPanel-DWaV9fRG.js → KeyingPanel-B5yKvszy.js} +1 -1
- package/ui/dist/assets/{NodeCanvas-ybOBBYto.js → NodeCanvas-Bf9bEiX2.js} +3 -3
- package/ui/dist/assets/{PromptBuilderPanel-C1DLHD9Q.js → PromptBuilderPanel-Bz2e5lKO.js} +1 -1
- package/ui/dist/assets/{PromptImportDialog-OGyYU5K5.js → PromptImportDialog-ctIs_XxZ.js} +2 -2
- package/ui/dist/assets/{PromptImportDiscoverySection-Cwigr40k.js → PromptImportDiscoverySection-CDppFhg4.js} +1 -1
- package/ui/dist/assets/{PromptImportFolderSection-B1XqjRbl.js → PromptImportFolderSection-Cvd73I4D.js} +1 -1
- package/ui/dist/assets/{PromptLibraryPanel-v7hIoYy-.js → PromptLibraryPanel-DtP62WHm.js} +2 -2
- package/ui/dist/assets/{SettingsWorkspace-CGZXJLgN.js → SettingsWorkspace-5IEsIqqb.js} +1 -1
- package/ui/dist/assets/{SpriteRecipeWorkspace-Cm5wizr5.js → SpriteRecipeWorkspace-DQsk4cc5.js} +1 -1
- package/ui/dist/assets/{index-CUMOJ4Vx.js → index-CRUYCtE2.js} +3 -3
- package/ui/dist/assets/index-CizoKwOj.css +1 -0
- package/ui/dist/assets/index-WbTkAEzk.js +23 -0
- package/ui/dist/assets/{pptxgen.es-XHiluGIr.js → pptxgen.es-B6CA1Typ.js} +1 -1
- package/ui/dist/assets/{promptBuilderStore-BKNNtgtk.js → promptBuilderStore-CJBR66MD.js} +1 -1
- package/ui/dist/assets/useAgentDialogFocus-CyM46tgI.js +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/App-b7KJO01r.js +0 -9
- package/ui/dist/assets/index-CJ-sBrSx.js +0 -23
- package/ui/dist/assets/index-Dznqt1dP.css +0 -1
- 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:
|
|
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`
|
package/lib/oauthImages.js
CHANGED
|
@@ -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
|
-
|
|
215
|
-
|
|
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 <
|
|
217
|
+
while (cursor < prompts.length) {
|
|
238
218
|
const index = cursor++;
|
|
239
219
|
try {
|
|
240
|
-
slots[index]?.resolve({ status: "done", value: await renderOne(job, budget,
|
|
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,
|
|
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 :
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 });
|
package/lib/oauthRateLimit.js
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
146
|
+
throw oauthAbortError(signal);
|
|
148
147
|
throw sleepError;
|
|
149
148
|
}
|
|
150
149
|
if (signal?.aborted)
|
|
151
|
-
throw
|
|
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
|
|
12
|
-
|
|
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
|
}
|