ima2-gen 3.24.1 → 3.25.0
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/.env.example +4 -0
- package/CHANGELOG.md +15 -0
- package/README.md +1 -0
- package/config.js +13 -0
- package/docs/API.md +2 -2
- package/docs/README.ja.md +1 -0
- package/docs/README.ko.md +1 -0
- package/docs/README.zh-CN.md +1 -0
- package/docs/README.zh-TW.md +1 -0
- package/docs/migration/runtime-test-inventory.md +6 -2
- package/lib/configKeys.js +2 -0
- package/lib/grokUpstreamRetry.js +7 -2
- package/lib/maskedEditComposite.js +84 -0
- package/lib/oauthImages.js +30 -8
- package/lib/oauthRateLimit.js +154 -0
- package/lib/responsesTransport.js +16 -1
- package/package.json +2 -2
- package/routes/edit.js +31 -7
package/.env.example
CHANGED
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
# IMA2_OAUTH_PROXY_PORT=10531 # that endpoint's port on 127.0.0.1; also accepted: OAUTH_PORT
|
|
18
18
|
# IMA2_OAUTH_STATUS_TIMEOUT_MS=3000
|
|
19
19
|
# IMA2_OAUTH_RESTART_DELAY_MS=5000
|
|
20
|
+
# IMA2_OAUTH_RATE_RETRY_MAX=5 # retries per job (plan + renders) after a per-minute GPT OAuth rate limit (0 disables)
|
|
21
|
+
# IMA2_OAUTH_RATE_RETRY_BASE_MS=8000 # wait = base x attempt (+-20% jitter) unless the reply says "try again in Ns"
|
|
22
|
+
# IMA2_OAUTH_RATE_RETRY_MAX_MS=45000 # cap for any single wait; a wordless 429 whose Retry-After exceeds it is not retried
|
|
23
|
+
# IMA2_OAUTH_RATE_RETRY_TOTAL_MS=120000 # cap for all waits of one job; no wait runs past the generation timeout
|
|
20
24
|
# IMA2_RESEARCH_SUFFIX="..." # appended to every image prompt
|
|
21
25
|
|
|
22
26
|
# ── Paths ──────────────────────────────────────────────────────────────
|
package/CHANGELOG.md
CHANGED
|
@@ -7,11 +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.25.0] - 2026-09-30
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Masked edits keep everything outside the mask** — after a masked edit the server restores the source wherever the mask is opaque and blends soft mask edges, so the model can no longer redraw the area you kept. Transparent source pixels stay transparent, and a result that comes back at another size is fitted to the source first. On by default; turn it off with `IMA2_PRESERVE_OUTSIDE_MASK=0` or `ima2 config set features.preserveOutsideMask false`. Composites run one at a time and sources above 4096 × 4096 keep the provider result; the sidecar records `maskOutsidePreserved` when the lock ran. (#340, thanks @ree9622)
|
|
15
|
+
- **GPT OAuth waits out per-minute rate limits** — a transient 429 ("rate limit reached … try again in Ns") on the planner or a render now waits and retries the same request with jitter, while usage, quota and billing limits still fail at once. Each job has one budget shared by the plan and every render: `IMA2_OAUTH_RATE_RETRY_MAX` (5 retries), `IMA2_OAUTH_RATE_RETRY_BASE_MS` (8000), `IMA2_OAUTH_RATE_RETRY_MAX_MS` (45000 per wait), `IMA2_OAUTH_RATE_RETRY_TOTAL_MS` (120000 in total), and no wait runs past the job's generation timeout. A job canceled during a rate limit ends as `499 GENERATION_CANCELED`. (#339, thanks @ree9622)
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Tests retry temp-directory cleanup on Windows file locks and give media events 30 seconds (#350).
|
|
20
|
+
|
|
21
|
+
## [3.24.1] - 2026-09-29
|
|
22
|
+
|
|
10
23
|
### Changed
|
|
11
24
|
|
|
12
25
|
- **Releases finish on their own** — a PR merged into dev during a release no longer fails it: the stable publish requires the tag to match and main, dev and preview to contain the release, and the tag job lands the release on dev by fast-forward or merge. `npm run release` pushes the admin-only `desktop-v` tag as soon as the release tag lands, which starts the desktop build.
|
|
13
26
|
- **`npm run release -- resume X.Y.Z`** finishes an already-tagged release (dev landing, desktop tag and build, stable publish, Pages) and skips what already landed. `--yes` now answers every prompt.
|
|
14
27
|
- Release waits only follow the publish run for their own ref and give a dispatch ten minutes to appear.
|
|
28
|
+
- The release dispatches publish.yml on the ref it publishes (`--ref vX.Y.Z`, `--ref preview`), so the package smoke compares the tarball with the release commit even when main has moved on; the smoke reads `PUBLISH_SHA`.
|
|
29
|
+
- **Resume finishes an untagged cut** — when a release stops after main and the npm preview proof but before the tag, `npm run release -- resume X.Y.Z` finds the version commit on main, re-checks the preview proof and mints the tag. The npm registry proof window grows from 15 to 35 minutes (publish verify steps 51, release waits 130), since a preview took longer than 15 minutes to appear.
|
|
15
30
|
- **Site: the desktop app comes right after the hero** — a new "Desktop app" section under the unchanged hero offers a one-click download for the visitor's OS (resolved from the latest desktop release, with a releases-page fallback), explains what the app adds (bundled server, tray, CLI attach/takeover, macOS updates) with a tray menu built from the real labels, and the header gains a Download link. The download blocks share one mounter.
|
|
16
31
|
|
|
17
32
|
## [3.24.0] - 2026-09-29
|
package/README.md
CHANGED
|
@@ -362,6 +362,7 @@ Config priority is `environment variables > ~/.ima2/config.json > built-in defau
|
|
|
362
362
|
| `IMA2_GROK_VIDEO_MODEL_DEFAULT` | `grok-imagine-video-1.5` | Default Grok video model |
|
|
363
363
|
| `IMA2_GROK_GENERATION_TIMEOUT_MS` | `300000` | Timeout for the final Grok Images API call |
|
|
364
364
|
| `IMA2_OAUTH_MASKED_EDIT_ENABLED` | `false` | Opt-in feature flag for masked-edit requests on the OAuth path (#31, groundwork only) |
|
|
365
|
+
| `IMA2_PRESERVE_OUTSIDE_MASK` | `true` | Restore source pixels wherever an edit mask is opaque, so masked edits cannot redraw the kept area (`0` keeps the raw provider result) |
|
|
365
366
|
| `GEMINI_API_KEY` | — | API key for `provider: "gemini-api"` direct Generative Language API path |
|
|
366
367
|
| `VERTEX_SERVICE_ACCOUNT_JSON` | — | Google service account JSON for Vertex AI auth with `provider: "gemini-api"`; takes priority over `GEMINI_API_KEY` when both are set |
|
|
367
368
|
| `IMA2_AGY_BIN` | `agy` on PATH | Explicit path to the Antigravity CLI binary for `provider: "agy"` |
|
package/config.js
CHANGED
|
@@ -179,6 +179,15 @@ export const config = {
|
|
|
179
179
|
statusTimeoutMs: pickInt(env.IMA2_OAUTH_STATUS_TIMEOUT_MS, fileCfg.oauth?.statusTimeoutMs, 3000),
|
|
180
180
|
generationTimeoutMs: pickInt(env.IMA2_OAUTH_GENERATION_TIMEOUT_MS, fileCfg.oauth?.generationTimeoutMs, 400 * 1000),
|
|
181
181
|
restartDelayMs: pickInt(env.IMA2_OAUTH_RESTART_DELAY_MS, fileCfg.oauth?.restartDelayMs, 5000),
|
|
182
|
+
// Backoff for per-minute rate limits (HTTP 429 "... per min", "try again in Ns"); usage caps
|
|
183
|
+
// and quotas are never retried. See lib/oauthRateLimit.ts.
|
|
184
|
+
rateLimitRetry: {
|
|
185
|
+
maxRetries: pickInt(env.IMA2_OAUTH_RATE_RETRY_MAX, fileCfg.oauth?.rateLimitRetry?.maxRetries, 5),
|
|
186
|
+
baseDelayMs: pickInt(env.IMA2_OAUTH_RATE_RETRY_BASE_MS, fileCfg.oauth?.rateLimitRetry?.baseDelayMs, 8000),
|
|
187
|
+
maxDelayMs: pickInt(env.IMA2_OAUTH_RATE_RETRY_MAX_MS, fileCfg.oauth?.rateLimitRetry?.maxDelayMs, 45_000),
|
|
188
|
+
// Retries and this total wait are one budget per job (plan + renders), inside generationTimeoutMs.
|
|
189
|
+
maxTotalWaitMs: pickInt(env.IMA2_OAUTH_RATE_RETRY_TOTAL_MS, fileCfg.oauth?.rateLimitRetry?.maxTotalWaitMs, 120_000),
|
|
190
|
+
},
|
|
182
191
|
// Provider-backed masked edit is off until upstream STEP-0 verification is recorded.
|
|
183
192
|
maskedEditEnabled: pickBool(env.IMA2_OAUTH_MASKED_EDIT_ENABLED, fileCfg.oauth?.maskedEditEnabled, false),
|
|
184
193
|
forceImageToolChoice: pickBool(env.IMA2_OAUTH_FORCE_IMAGE_TOOL_CHOICE, fileCfg.oauth?.forceImageToolChoice, true),
|
|
@@ -321,6 +330,10 @@ export const config = {
|
|
|
321
330
|
},
|
|
322
331
|
features: {
|
|
323
332
|
cardNews: pickBool(env.IMA2_CARD_NEWS, fileCfg.features?.cardNews, env.IMA2_DEV === "1"),
|
|
333
|
+
// Masked edits restore the source wherever the mask is opaque, because GPT Image
|
|
334
|
+
// treats the mask as guidance and may redraw the kept area. Opt out with
|
|
335
|
+
// IMA2_PRESERVE_OUTSIDE_MASK=0 to keep the raw provider result.
|
|
336
|
+
preserveOutsideMask: pickBool(env.IMA2_PRESERVE_OUTSIDE_MASK, fileCfg.features?.preserveOutsideMask, true),
|
|
324
337
|
},
|
|
325
338
|
cardNewsPlanner: {
|
|
326
339
|
enabled: pickBool(env.IMA2_CARD_NEWS_PLANNER, fileCfg.cardNewsPlanner?.enabled, true),
|
package/docs/API.md
CHANGED
|
@@ -70,7 +70,7 @@ Image generation supports OAuth, API-key, Grok, and Gemini (`agy` and `gemini-ap
|
|
|
70
70
|
- API-key generation covers classic generate, edit, mask-guided edit, multimode, and node generation.
|
|
71
71
|
- If `provider: "api"` is requested without an API key, routes fail before upstream with `401` and `API_KEY_REQUIRED`.
|
|
72
72
|
- Grok generation maps `size` to xAI `aspect_ratio` and `resolution`; it does not send an OpenAI-style `size` field upstream. Grok edit uses xAI `/v1/images/edits`; Grok mask edit remains unsupported and returns `GROK_MASK_UNSUPPORTED`.
|
|
73
|
-
- Mask edits are mask/selection guided edits,
|
|
73
|
+
- Mask edits are mask/selection guided edits. The model may redraw outside the mask, so the edit route restores source pixels wherever the mask is opaque before saving (`features.preserveOutsideMask`, env `IMA2_PRESERVE_OUTSIDE_MASK`, default on; the sidecar records `maskOutsidePreserved: true`). The output keeps the source dimensions. When the provider returns a different aspect ratio, the result is stretched to the source size before compositing, so the edited area may look stretched or squeezed. Transparent source pixels in the kept area stay transparent. Composites run one at a time and refuse sources above 4096 × 4096 pixels. If compositing fails or the source is above that bound, the provider result is saved unchanged and `maskOutsidePreserved` is omitted.
|
|
74
74
|
|
|
75
75
|
Grok video generation uses `POST /api/video/generate` (SSE). See the Video
|
|
76
76
|
Generation section below for the full endpoint specification.
|
|
@@ -406,7 +406,7 @@ Custom sizes are reduced to the closest xAI-supported aspect ratio and use
|
|
|
406
406
|
|
|
407
407
|
Image edit / image-to-image generation.
|
|
408
408
|
|
|
409
|
-
The request includes a prompt and image payload. `provider: "api"` sends the prompt and image through the shared Responses image adapter. Optional masks are forwarded as mask guidance,
|
|
409
|
+
The request includes a prompt and image payload. `provider: "api"` sends the prompt and image through the shared Responses image adapter. Optional masks are forwarded as mask guidance; after the provider returns, pixels where the mask is opaque are restored from the source image (see `IMA2_PRESERVE_OUTSIDE_MASK`).
|
|
410
410
|
|
|
411
411
|
With `provider: "grok"`, edit requests are sent to xAI `/v1/images/edits`
|
|
412
412
|
with the stored OAuth session. Masked Grok edits are rejected before
|
package/docs/README.ja.md
CHANGED
|
@@ -361,6 +361,7 @@ Full reference: [CLI.md](CLI.md).
|
|
|
361
361
|
| `IMA2_GROK_VIDEO_MODEL_DEFAULT` | `grok-imagine-video-1.5` | Default Grok video model |
|
|
362
362
|
| `IMA2_GROK_GENERATION_TIMEOUT_MS` | `300000` | Timeout for the final Grok Images API call |
|
|
363
363
|
| `IMA2_OAUTH_MASKED_EDIT_ENABLED` | `false` | Opt-in feature flag for masked-edit requests on the OAuth path (#31, groundwork only) |
|
|
364
|
+
| `IMA2_PRESERVE_OUTSIDE_MASK` | `true` | Restore source pixels wherever an edit mask is opaque, so masked edits cannot redraw the kept area (`0` keeps the raw provider result) |
|
|
364
365
|
| `GEMINI_API_KEY` | — | API key for `provider: "gemini-api"` direct Generative Language API path |
|
|
365
366
|
| `VERTEX_SERVICE_ACCOUNT_JSON` | — | Google service account JSON for Vertex AI auth with `provider: "gemini-api"`; takes priority over `GEMINI_API_KEY` when both are set |
|
|
366
367
|
| `IMA2_AGY_BIN` | `agy` on PATH | Explicit path to the Antigravity CLI binary for `provider: "agy"` |
|
package/docs/README.ko.md
CHANGED
|
@@ -361,6 +361,7 @@ Full reference: [CLI.md](CLI.md).
|
|
|
361
361
|
| `IMA2_GROK_VIDEO_MODEL_DEFAULT` | `grok-imagine-video-1.5` | Default Grok video model |
|
|
362
362
|
| `IMA2_GROK_GENERATION_TIMEOUT_MS` | `300000` | Timeout for the final Grok Images API call |
|
|
363
363
|
| `IMA2_OAUTH_MASKED_EDIT_ENABLED` | `false` | Opt-in feature flag for masked-edit requests on the OAuth path (#31, groundwork only) |
|
|
364
|
+
| `IMA2_PRESERVE_OUTSIDE_MASK` | `true` | Restore source pixels wherever an edit mask is opaque, so masked edits cannot redraw the kept area (`0` keeps the raw provider result) |
|
|
364
365
|
| `GEMINI_API_KEY` | — | API key for `provider: "gemini-api"` direct Generative Language API path |
|
|
365
366
|
| `VERTEX_SERVICE_ACCOUNT_JSON` | — | Google service account JSON for Vertex AI auth with `provider: "gemini-api"`; takes priority over `GEMINI_API_KEY` when both are set |
|
|
366
367
|
| `IMA2_AGY_BIN` | `agy` on PATH | Explicit path to the Antigravity CLI binary for `provider: "agy"` |
|
package/docs/README.zh-CN.md
CHANGED
|
@@ -361,6 +361,7 @@ Full reference: [docs/CLI.md](CLI.md).
|
|
|
361
361
|
| `IMA2_GROK_VIDEO_MODEL_DEFAULT` | `grok-imagine-video-1.5` | Default Grok video model |
|
|
362
362
|
| `IMA2_GROK_GENERATION_TIMEOUT_MS` | `300000` | Timeout for the final Grok Images API call |
|
|
363
363
|
| `IMA2_OAUTH_MASKED_EDIT_ENABLED` | `false` | Opt-in feature flag for masked-edit requests on the OAuth path (#31, groundwork only) |
|
|
364
|
+
| `IMA2_PRESERVE_OUTSIDE_MASK` | `true` | Restore source pixels wherever an edit mask is opaque, so masked edits cannot redraw the kept area (`0` keeps the raw provider result) |
|
|
364
365
|
| `GEMINI_API_KEY` | — | API key for `provider: "gemini-api"` direct Generative Language API path |
|
|
365
366
|
| `VERTEX_SERVICE_ACCOUNT_JSON` | — | Google service account JSON for Vertex AI auth with `provider: "gemini-api"`; takes priority over `GEMINI_API_KEY` when both are set |
|
|
366
367
|
| `IMA2_AGY_BIN` | `agy` on PATH | Explicit path to the Antigravity CLI binary for `provider: "agy"` |
|
package/docs/README.zh-TW.md
CHANGED
|
@@ -361,6 +361,7 @@ Full reference: [docs/CLI.md](CLI.md).
|
|
|
361
361
|
| `IMA2_GROK_VIDEO_MODEL_DEFAULT` | `grok-imagine-video-1.5` | Default Grok video model |
|
|
362
362
|
| `IMA2_GROK_GENERATION_TIMEOUT_MS` | `300000` | Timeout for the final Grok Images API call |
|
|
363
363
|
| `IMA2_OAUTH_MASKED_EDIT_ENABLED` | `false` | Opt-in feature flag for masked-edit requests on the OAuth path (#31, groundwork only) |
|
|
364
|
+
| `IMA2_PRESERVE_OUTSIDE_MASK` | `true` | Restore source pixels wherever an edit mask is opaque, so masked edits cannot redraw the kept area (`0` keeps the raw provider result) |
|
|
364
365
|
| `GEMINI_API_KEY` | — | API key for `provider: "gemini-api"` direct Generative Language API path |
|
|
365
366
|
| `VERTEX_SERVICE_ACCOUNT_JSON` | — | Google service account JSON for Vertex AI auth with `provider: "gemini-api"`; takes priority over `GEMINI_API_KEY` when both are set |
|
|
366
367
|
| `IMA2_AGY_BIN` | `agy` on PATH | Explicit path to the Antigravity CLI binary for `provider: "agy"` |
|
|
@@ -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: 536 (runtime: 243, contract: 293)
|
|
8
8
|
|
|
9
9
|
## Runtime-importing tests
|
|
10
10
|
- `tests/advertise-payload-contract.test.ts`
|
|
@@ -49,6 +49,7 @@ Total: 532 (runtime: 239, contract: 293)
|
|
|
49
49
|
- `tests/card-news-template.test.ts`
|
|
50
50
|
- `tests/chatgpt-auth-contract.test.ts`
|
|
51
51
|
- `tests/classic-generate-async.test.ts`
|
|
52
|
+
- `tests/cli-config-keys-contract.test.ts`
|
|
52
53
|
- `tests/cli-error-hints.test.ts`
|
|
53
54
|
- `tests/cli-lan-auth.test.ts`
|
|
54
55
|
- `tests/cli-lib.test.ts`
|
|
@@ -116,6 +117,7 @@ Total: 532 (runtime: 239, contract: 293)
|
|
|
116
117
|
- `tests/local-import-contract.test.ts`
|
|
117
118
|
- `tests/local-lan-policy.test.ts`
|
|
118
119
|
- `tests/logging.test.ts`
|
|
120
|
+
- `tests/masked-edit-composite.test.ts`
|
|
119
121
|
- `tests/mcp-character-route.test.ts`
|
|
120
122
|
- `tests/mcp-connection-manager.test.ts`
|
|
121
123
|
- `tests/mcp-connection-routes.test.ts`
|
|
@@ -162,6 +164,8 @@ Total: 532 (runtime: 239, contract: 293)
|
|
|
162
164
|
- `tests/oauth-cli-dependencies.test.ts`
|
|
163
165
|
- `tests/oauth-normalize.test.ts`
|
|
164
166
|
- `tests/oauth-proxy-error-safety.test.ts`
|
|
167
|
+
- `tests/oauth-rate-limit-lane.test.ts`
|
|
168
|
+
- `tests/oauth-rate-limit-retry.test.ts`
|
|
165
169
|
- `tests/open-directory.test.ts`
|
|
166
170
|
- `tests/openai-execution-parity.test.ts`
|
|
167
171
|
- `tests/openai-transport-parity.test.ts`
|
|
@@ -314,7 +318,6 @@ Total: 532 (runtime: 239, contract: 293)
|
|
|
314
318
|
- `tests/cli-capabilities-contract.test.ts`
|
|
315
319
|
- `tests/cli-character-contract.test.ts`
|
|
316
320
|
- `tests/cli-commands.test.ts`
|
|
317
|
-
- `tests/cli-config-keys-contract.test.ts`
|
|
318
321
|
- `tests/cli-default-output-dir-contract.test.ts`
|
|
319
322
|
- `tests/cli-defaults-command-contract.test.ts`
|
|
320
323
|
- `tests/cli-destructive-safety-contract.test.ts`
|
|
@@ -515,6 +518,7 @@ Total: 532 (runtime: 239, contract: 293)
|
|
|
515
518
|
- `tests/style-feature-removal-contract.test.ts`
|
|
516
519
|
- `tests/subprocess-deadline-contract.test.ts`
|
|
517
520
|
- `tests/test-runner-invocation.test.ts`
|
|
521
|
+
- `tests/test-temp-cleanup-contract.test.ts`
|
|
518
522
|
- `tests/toast-stack-contract.test.ts`
|
|
519
523
|
- `tests/tools-cli-contract.test.ts`
|
|
520
524
|
- `tests/transparent-background-ui-contract.test.ts`
|
package/lib/configKeys.js
CHANGED
|
@@ -8,6 +8,7 @@ export const WRITABLE_CONFIG_KEYS = new Set([
|
|
|
8
8
|
"apiProvider.defaultReasoningEffort",
|
|
9
9
|
"log.level",
|
|
10
10
|
"features.cardNews",
|
|
11
|
+
"features.preserveOutsideMask",
|
|
11
12
|
"cardNewsPlanner.enabled",
|
|
12
13
|
"cardNewsPlanner.model",
|
|
13
14
|
"cardNewsPlanner.timeoutMs",
|
|
@@ -48,6 +49,7 @@ export const KEY_TO_ENV = {
|
|
|
48
49
|
"apiProvider.defaultReasoningEffort": "IMA2_API_REASONING_EFFORT",
|
|
49
50
|
"log.level": "IMA2_LOG_LEVEL",
|
|
50
51
|
"features.cardNews": "IMA2_CARD_NEWS",
|
|
52
|
+
"features.preserveOutsideMask": "IMA2_PRESERVE_OUTSIDE_MASK",
|
|
51
53
|
"server.port": "IMA2_PORT",
|
|
52
54
|
"server.host": "IMA2_HOST",
|
|
53
55
|
"server.publicOrigins": "IMA2_PUBLIC_ORIGINS",
|
package/lib/grokUpstreamRetry.js
CHANGED
|
@@ -45,7 +45,8 @@ export function isConnectionResetError(err) {
|
|
|
45
45
|
|| msg.includes("connection reset by peer")
|
|
46
46
|
|| msg.includes("socket hang up");
|
|
47
47
|
}
|
|
48
|
-
|
|
48
|
+
/** Retry-After (seconds or HTTP date) in ms; also used by lib/oauthRateLimit.ts. */
|
|
49
|
+
export function retryAfterDelayMs(headers) {
|
|
49
50
|
const raw = headers.get("retry-after")?.trim();
|
|
50
51
|
if (!raw)
|
|
51
52
|
return undefined;
|
|
@@ -62,7 +63,11 @@ export function retryBackoffDelayMs(attempt, opts) {
|
|
|
62
63
|
if (retryAfter !== undefined)
|
|
63
64
|
return Math.min(retryAfter, opts.maxDelayMs);
|
|
64
65
|
const exp = Math.min(opts.baseDelayMs * (2 ** attempt), opts.maxDelayMs);
|
|
65
|
-
return
|
|
66
|
+
return jitterDelayMs(exp);
|
|
67
|
+
}
|
|
68
|
+
/** Spread a wait by ±20% so callers blocked in the same instant do not retry in lockstep. */
|
|
69
|
+
export function jitterDelayMs(ms, random = Math.random) {
|
|
70
|
+
return Math.floor(ms * (0.8 + random() * 0.4));
|
|
66
71
|
}
|
|
67
72
|
function abortError(signal) {
|
|
68
73
|
return signal?.reason ?? new DOMException("The operation was aborted", "AbortError");
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic pixel lock for masked edits.
|
|
3
|
+
*
|
|
4
|
+
* GPT Image models receive the edit mask as guidance, not as a pixel lock, so
|
|
5
|
+
* they often redraw the area the user meant to keep. After the provider
|
|
6
|
+
* returns, restore the source wherever the submitted mask is opaque (the
|
|
7
|
+
* OpenAI mask contract: alpha 0 = editable, alpha 255 = keep). Partial alpha
|
|
8
|
+
* blends source and result proportionally, so a soft mask edge stays soft.
|
|
9
|
+
*
|
|
10
|
+
* The blend covers all four RGBA channels, so a transparent source pixel in
|
|
11
|
+
* the kept area stays transparent instead of letting the result show through.
|
|
12
|
+
*
|
|
13
|
+
* The output keeps the source dimensions. When the backend returns a
|
|
14
|
+
* different size, the result is resized to the source first so the kept area
|
|
15
|
+
* stays byte-identical to the source instead of being resampled.
|
|
16
|
+
*
|
|
17
|
+
* Memory is bounded twice: every decode refuses inputs above
|
|
18
|
+
* MASKED_EDIT_MAX_PIXELS, and composites run one at a time, so at most one set
|
|
19
|
+
* of full-size raw buffers exists however many edits run in parallel. A
|
|
20
|
+
* refused input throws; the edit route then keeps the provider bytes.
|
|
21
|
+
*/
|
|
22
|
+
import sharp from "sharp";
|
|
23
|
+
/** 4096 x 4096: one composite then holds about 4 raw buffers of 64 MiB. */
|
|
24
|
+
export const MASKED_EDIT_MAX_PIXELS = 4096 * 4096;
|
|
25
|
+
function decode(input) {
|
|
26
|
+
return sharp(input, { limitInputPixels: MASKED_EDIT_MAX_PIXELS });
|
|
27
|
+
}
|
|
28
|
+
function encode(pipeline, format, quality) {
|
|
29
|
+
const q = Math.max(1, Math.min(100, Math.round(quality) || 100));
|
|
30
|
+
if (format === "jpeg")
|
|
31
|
+
return pipeline.jpeg({ quality: q });
|
|
32
|
+
if (format === "webp")
|
|
33
|
+
return pipeline.webp({ quality: q });
|
|
34
|
+
return pipeline.png();
|
|
35
|
+
}
|
|
36
|
+
function rgbaAt(input, width, height) {
|
|
37
|
+
return decode(input).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer();
|
|
38
|
+
}
|
|
39
|
+
function maskAlphaAt(mask, width, height) {
|
|
40
|
+
return decode(mask).resize(width, height, { fit: "fill" }).ensureAlpha().extractChannel("alpha").raw().toBuffer();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* result = source * keep + result * (1 - keep) per channel, keep = mask alpha / 255.
|
|
44
|
+
* Returns whether any output pixel is not fully opaque.
|
|
45
|
+
*/
|
|
46
|
+
function blendInto(result, source, keep) {
|
|
47
|
+
let translucent = false;
|
|
48
|
+
for (let pixel = 0, base = 0; pixel < keep.length; pixel += 1, base += 4) {
|
|
49
|
+
const k = keep[pixel];
|
|
50
|
+
if (k === 255)
|
|
51
|
+
source.copy(result, base, base, base + 4);
|
|
52
|
+
else if (k !== 0) {
|
|
53
|
+
for (let c = 0; c < 4; c += 1) {
|
|
54
|
+
result[base + c] = Math.round((source[base + c] * k + result[base + c] * (255 - k)) / 255);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (result[base + 3] < 255)
|
|
58
|
+
translucent = true;
|
|
59
|
+
}
|
|
60
|
+
return translucent;
|
|
61
|
+
}
|
|
62
|
+
async function composite(input) {
|
|
63
|
+
const { width, height } = await decode(input.source).metadata();
|
|
64
|
+
if (!width || !height)
|
|
65
|
+
throw new Error("masked edit source dimensions are unavailable");
|
|
66
|
+
if (width * height > MASKED_EDIT_MAX_PIXELS)
|
|
67
|
+
throw new Error("masked edit source exceeds the composite pixel limit");
|
|
68
|
+
const source = await rgbaAt(input.source, width, height);
|
|
69
|
+
const result = await rgbaAt(input.result, width, height);
|
|
70
|
+
const keep = await maskAlphaAt(input.mask, width, height);
|
|
71
|
+
const translucent = blendInto(result, source, keep);
|
|
72
|
+
// An all-opaque output is saved without an alpha channel, so alphaVerified
|
|
73
|
+
// keeps meaning "some pixel is actually transparent".
|
|
74
|
+
const pipeline = sharp(result, { raw: { width, height, channels: 4 } });
|
|
75
|
+
const output = translucent ? pipeline : pipeline.removeAlpha();
|
|
76
|
+
return encode(output, input.format ?? "png", input.quality ?? 100).toBuffer();
|
|
77
|
+
}
|
|
78
|
+
let queueTail = Promise.resolve();
|
|
79
|
+
/** Composites run one at a time; see the memory note above. */
|
|
80
|
+
export function preserveOutsideMask(input) {
|
|
81
|
+
const run = queueTail.then(() => composite(input));
|
|
82
|
+
queueTail = run.catch(() => undefined);
|
|
83
|
+
return run;
|
|
84
|
+
}
|
package/lib/oauthImages.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { logEvent } from "./logger.js";
|
|
15
15
|
import { postOAuthImages, postResponses } from "./responsesTransport.js";
|
|
16
|
+
import { createOAuthRateLimitBudget, oauthRateLimitRetryConfig, withOAuthRateLimitRetry, } from "./oauthRateLimit.js";
|
|
16
17
|
export const OAUTH_IMAGE_TOOL = "image_gen";
|
|
17
18
|
export const OAUTH_RENDER_MODEL = "gpt-image-2";
|
|
18
19
|
/** The ChatGPT images edit endpoint accepts at most five input images. */
|
|
@@ -128,7 +129,27 @@ export function renderFields(job) {
|
|
|
128
129
|
function extensionFor(mime) {
|
|
129
130
|
return mime === "image/jpeg" ? "jpg" : mime === "image/webp" ? "webp" : "png";
|
|
130
131
|
}
|
|
131
|
-
|
|
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
|
+
/** Replay a call the backend rejected with a per-minute rate limit; log every wait. */
|
|
140
|
+
function withRateLimitBackoff(job, budget, stage, request) {
|
|
141
|
+
return withOAuthRateLimitRetry(request, {
|
|
142
|
+
config: oauthRateLimitRetryConfig(job.ctx?.config?.oauth?.rateLimitRetry),
|
|
143
|
+
budget,
|
|
144
|
+
signal: job.signal,
|
|
145
|
+
onRetry: (info) => logEvent(job.scope, "rate_limit_retry", { requestId: job.requestId, stage, ...info }),
|
|
146
|
+
onExhausted: (info) => logEvent(job.scope, "rate_limit_exhausted", { requestId: job.requestId, stage, ...info }),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
async function renderOne(job, budget, prompt) {
|
|
150
|
+
return withRateLimitBackoff(job, budget, "render", () => renderOnce(job, prompt));
|
|
151
|
+
}
|
|
152
|
+
async function renderOnce(job, prompt) {
|
|
132
153
|
const fields = renderFields(job);
|
|
133
154
|
if (!job.images.length) {
|
|
134
155
|
return postOAuthImages({
|
|
@@ -174,18 +195,18 @@ function emptyDiagnostics() {
|
|
|
174
195
|
outputTextChars: 0,
|
|
175
196
|
};
|
|
176
197
|
}
|
|
177
|
-
async function plan(job) {
|
|
178
|
-
let result = await postResponses({
|
|
198
|
+
async function plan(job, budget) {
|
|
199
|
+
let result = await withRateLimitBackoff(job, budget, "plan", () => postResponses({
|
|
179
200
|
ctx: job.ctx, provider: "oauth", scope: `${job.scope}-plan`, requestId: job.requestId,
|
|
180
201
|
signal: job.signal, maxImages: 0, payload: buildPlannerPayload(job),
|
|
181
|
-
});
|
|
202
|
+
}));
|
|
182
203
|
let prompts = promptsFromCalls(result.functionCalls, job.maxImages);
|
|
183
204
|
if (!prompts.length) {
|
|
184
205
|
logEvent(job.scope, "plan_retry", { requestId: job.requestId, events: result.eventCount });
|
|
185
|
-
result = await postResponses({
|
|
206
|
+
result = await withRateLimitBackoff(job, budget, "plan", () => postResponses({
|
|
186
207
|
ctx: job.ctx, provider: "oauth", scope: `${job.scope}-plan`, requestId: job.requestId,
|
|
187
208
|
signal: job.signal, maxImages: 0, payload: buildPlannerPayload(job, true),
|
|
188
|
-
});
|
|
209
|
+
}));
|
|
189
210
|
prompts = promptsFromCalls(result.functionCalls, job.maxImages);
|
|
190
211
|
}
|
|
191
212
|
return { result, prompts };
|
|
@@ -195,9 +216,10 @@ export async function runOAuthImageJob(job) {
|
|
|
195
216
|
// prompt per stage, so it goes through the planner (its user text already carries the
|
|
196
217
|
// sequence instructions and the Direct fidelity rule).
|
|
197
218
|
const direct = job.mode === "direct" && job.maxImages <= 1;
|
|
219
|
+
const budget = rateLimitBudgetFor(job);
|
|
198
220
|
const planned = direct
|
|
199
221
|
? { result: null, prompts: Array.from({ length: Math.max(1, job.maxImages) }, () => job.directPrompt) }
|
|
200
|
-
: await plan(job);
|
|
222
|
+
: await plan(job, budget);
|
|
201
223
|
const base = planned.result;
|
|
202
224
|
const usage = {};
|
|
203
225
|
addUsage(usage, base?.usage);
|
|
@@ -215,7 +237,7 @@ export async function runOAuthImageJob(job) {
|
|
|
215
237
|
while (cursor < planned.prompts.length) {
|
|
216
238
|
const index = cursor++;
|
|
217
239
|
try {
|
|
218
|
-
slots[index]?.resolve({ status: "done", value: await renderOne(job, planned.prompts[index]) });
|
|
240
|
+
slots[index]?.resolve({ status: "done", value: await renderOne(job, budget, planned.prompts[index]) });
|
|
219
241
|
}
|
|
220
242
|
catch (error) {
|
|
221
243
|
slots[index]?.resolve({ status: "failed", error });
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backoff for GPT OAuth per-minute rate limits.
|
|
3
|
+
*
|
|
4
|
+
* The ChatGPT backend rejects a burst with HTTP 429 and a message such as
|
|
5
|
+
* "Rate limit reached for gpt-image-2-codex ... on input-images per min: Limit 4000,
|
|
6
|
+
* Used 4000. Please try again in 7.5s". That bucket refills within the minute, so waiting
|
|
7
|
+
* and sending the same request again succeeds. A usage cap (the 5-hour or weekly plan
|
|
8
|
+
* limit, `usage_limit_reached`, an exhausted quota or billing limit) does not refill in
|
|
9
|
+
* seconds and must fail fast, so it is excluded before any per-minute wording is matched.
|
|
10
|
+
*
|
|
11
|
+
* A 429 is a rejection before any work starts, so replaying the request cannot bill the
|
|
12
|
+
* user twice (unlike the pre-response retries in lib/grokUpstreamRetry.ts).
|
|
13
|
+
*
|
|
14
|
+
* One job (a plan plus its renders) spends one retry budget: a retry count, a total wait and
|
|
15
|
+
* the job's generation deadline, so a burst never stretches a job past its timeout.
|
|
16
|
+
*
|
|
17
|
+
* MUST stay a leaf module: it only borrows the abortable sleep, jitter and Retry-After parsing.
|
|
18
|
+
*/
|
|
19
|
+
import { jitterDelayMs, retryAfterDelayMs, sleepWithAbort } from "./grokUpstreamRetry.js";
|
|
20
|
+
/**
|
|
21
|
+
* Caps that no short wait can clear. Checked first so mixed wording never retries. A bare
|
|
22
|
+
* "billing" or "credit" is not matched: per-minute messages can link to the billing page or
|
|
23
|
+
* mention credits, so only an exhausted credit balance counts.
|
|
24
|
+
*/
|
|
25
|
+
const PERMANENT_LIMIT_RE = /usage[ _]?limit|usage cap|quota|billing[ _](?:hard[ _])?limit|hard limit|spending|insufficient[_ ]credits?|credits? (?:exhausted|depleted|balance)|out of credits|plan limit|monthly|weekly|daily|per day|\b5[- ]?hours?\b/i;
|
|
26
|
+
const TRANSIENT_LIMIT_RE = /rate[ _]?limit(?:ed| reached|_exceeded)|(?:requests|tokens|images|input-images) per min|per minute|try again in/i;
|
|
27
|
+
const PER_WINDOW_RE = /per min|per minute|try again in/i;
|
|
28
|
+
export const DEFAULT_OAUTH_RATE_LIMIT_RETRY = Object.freeze({
|
|
29
|
+
maxRetries: 5,
|
|
30
|
+
baseDelayMs: 8_000,
|
|
31
|
+
maxDelayMs: 45_000,
|
|
32
|
+
maxTotalWaitMs: 120_000,
|
|
33
|
+
});
|
|
34
|
+
/** `null` means "not a rate limit this module recognizes"; only "transient" is retried. */
|
|
35
|
+
export function classifyOAuthRateLimit({ status, text, retryAfterMs, maxDelayMs }) {
|
|
36
|
+
if (status === 401 || status === 403)
|
|
37
|
+
return null;
|
|
38
|
+
const s = String(text ?? "");
|
|
39
|
+
if (PERMANENT_LIMIT_RE.test(s))
|
|
40
|
+
return status === 429 || TRANSIENT_LIMIT_RE.test(s) ? "permanent" : null;
|
|
41
|
+
if (TRANSIENT_LIMIT_RE.test(s) && (status === 429 || PER_WINDOW_RE.test(s)))
|
|
42
|
+
return "transient";
|
|
43
|
+
// A wordless 429 with only Retry-After is a per-window bucket only when the wait fits one
|
|
44
|
+
// retry; a longer wait points at a cap the text did not name, so it surfaces unclassified.
|
|
45
|
+
if (status === 429 && retryAfterMs !== undefined) {
|
|
46
|
+
return retryAfterMs <= (maxDelayMs ?? DEFAULT_OAUTH_RATE_LIMIT_RETRY.maxDelayMs) ? "transient" : null;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
/** "Please try again in 7.5s" / "in 820ms" / "in 2 seconds" → milliseconds. */
|
|
51
|
+
export function parseRetryHintMs(text) {
|
|
52
|
+
const match = String(text ?? "").match(/try again in\s*([\d.]+)\s*(ms|milliseconds?|s|secs?|seconds?)\b/i);
|
|
53
|
+
if (!match)
|
|
54
|
+
return undefined;
|
|
55
|
+
const value = Number.parseFloat(match[1]);
|
|
56
|
+
if (!Number.isFinite(value) || value < 0)
|
|
57
|
+
return undefined;
|
|
58
|
+
return /^m/i.test(match[2]) ? value : value * 1000;
|
|
59
|
+
}
|
|
60
|
+
/** Fields the transport attaches to a rejected OAuth request. */
|
|
61
|
+
export function oauthRateLimitFields(status, text, headers, maxDelayMs) {
|
|
62
|
+
const headerMs = headers ? retryAfterDelayMs(headers) : undefined;
|
|
63
|
+
const kind = classifyOAuthRateLimit({ status, text, retryAfterMs: headerMs, maxDelayMs });
|
|
64
|
+
if (!kind)
|
|
65
|
+
return {};
|
|
66
|
+
const retryAfterMs = parseRetryHintMs(text) ?? headerMs;
|
|
67
|
+
return { rateLimit: kind, ...(kind === "transient" && retryAfterMs !== undefined ? { retryAfterMs } : {}) };
|
|
68
|
+
}
|
|
69
|
+
function nonNegativeInt(value, fallback) {
|
|
70
|
+
const n = Number(value);
|
|
71
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
|
|
72
|
+
}
|
|
73
|
+
export function oauthRateLimitRetryConfig(raw) {
|
|
74
|
+
const d = DEFAULT_OAUTH_RATE_LIMIT_RETRY;
|
|
75
|
+
const maxRetries = Math.min(nonNegativeInt(raw?.maxRetries, d.maxRetries), 20);
|
|
76
|
+
const baseDelayMs = nonNegativeInt(raw?.baseDelayMs, d.baseDelayMs);
|
|
77
|
+
const maxDelayMs = Math.max(baseDelayMs, nonNegativeInt(raw?.maxDelayMs, d.maxDelayMs));
|
|
78
|
+
const maxTotalWaitMs = nonNegativeInt(raw?.maxTotalWaitMs, d.maxTotalWaitMs);
|
|
79
|
+
return { maxRetries, baseDelayMs, maxDelayMs, maxTotalWaitMs };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The upstream hint wins when it is a real wait (≥ 1s); sub-second hints are unrealistic for a
|
|
83
|
+
* per-minute bucket, so the linear backoff (base × attempt) applies. The backoff is spread ±20%
|
|
84
|
+
* like lib/grokUpstreamRetry.ts; a hint only moves later (0 to +20%) so no retry lands before
|
|
85
|
+
* the bucket refills. Both are capped at `maxDelayMs`.
|
|
86
|
+
*/
|
|
87
|
+
export function oauthRateLimitDelayMs(attempt, hintMs, config, random = Math.random) {
|
|
88
|
+
if (hintMs !== undefined && hintMs >= 1000)
|
|
89
|
+
return Math.min(Math.ceil(hintMs * (1 + random() * 0.2)), config.maxDelayMs);
|
|
90
|
+
return Math.min(jitterDelayMs(config.baseDelayMs * Math.max(1, attempt), random), config.maxDelayMs);
|
|
91
|
+
}
|
|
92
|
+
export function isTransientOAuthRateLimit(error) {
|
|
93
|
+
return Boolean(error && typeof error === "object" && error.rateLimit === "transient");
|
|
94
|
+
}
|
|
95
|
+
function retryAfterOf(error) {
|
|
96
|
+
const value = error?.retryAfterMs;
|
|
97
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
98
|
+
}
|
|
99
|
+
function canceledError(cause) {
|
|
100
|
+
return Object.assign(new Error("Generation canceled"), { status: 499, code: "GENERATION_CANCELED", cause });
|
|
101
|
+
}
|
|
102
|
+
export function createOAuthRateLimitBudget(timeoutMs, now = Date.now) {
|
|
103
|
+
const bounded = typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0;
|
|
104
|
+
return { retries: 0, totalWaitMs: 0, deadlineAt: bounded ? now() + timeoutMs : undefined };
|
|
105
|
+
}
|
|
106
|
+
/** Why the budget cannot pay for `waitMs`, or null when it can. */
|
|
107
|
+
function budgetShortfall(budget, config, waitMs, now) {
|
|
108
|
+
if (budget.retries >= config.maxRetries)
|
|
109
|
+
return "retries";
|
|
110
|
+
if (budget.totalWaitMs + waitMs > config.maxTotalWaitMs)
|
|
111
|
+
return "total_wait";
|
|
112
|
+
if (budget.deadlineAt !== undefined && now + waitMs >= budget.deadlineAt)
|
|
113
|
+
return "deadline";
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
/** Run `request`, replaying it after a transient rate limit. Any other error surfaces as is. */
|
|
117
|
+
export async function withOAuthRateLimitRetry(request, options = {}) {
|
|
118
|
+
const config = options.config ?? DEFAULT_OAUTH_RATE_LIMIT_RETRY;
|
|
119
|
+
const budget = options.budget ?? createOAuthRateLimitBudget();
|
|
120
|
+
const sleep = options.sleep ?? sleepWithAbort;
|
|
121
|
+
const now = options.now ?? Date.now;
|
|
122
|
+
const signal = options.signal ?? undefined;
|
|
123
|
+
for (let attempt = 1;; attempt++) {
|
|
124
|
+
try {
|
|
125
|
+
return await request();
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (!isTransientOAuthRateLimit(error))
|
|
129
|
+
throw error;
|
|
130
|
+
// A job canceled while its request was being rate limited ends as a cancel, not a 429.
|
|
131
|
+
if (signal?.aborted)
|
|
132
|
+
throw canceledError(signal.reason ?? error);
|
|
133
|
+
const waitMs = oauthRateLimitDelayMs(attempt, retryAfterOf(error), config, options.random);
|
|
134
|
+
const reason = budgetShortfall(budget, config, waitMs, now());
|
|
135
|
+
if (reason) {
|
|
136
|
+
options.onExhausted?.({ retries: budget.retries, totalWaitMs: budget.totalWaitMs, reason });
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
budget.retries++;
|
|
140
|
+
budget.totalWaitMs += waitMs;
|
|
141
|
+
options.onRetry?.({ attempt: budget.retries, maxRetries: config.maxRetries, waitMs, totalWaitMs: budget.totalWaitMs });
|
|
142
|
+
try {
|
|
143
|
+
await sleep(waitMs, signal);
|
|
144
|
+
}
|
|
145
|
+
catch (sleepError) {
|
|
146
|
+
if (signal?.aborted)
|
|
147
|
+
throw canceledError(sleepError);
|
|
148
|
+
throw sleepError;
|
|
149
|
+
}
|
|
150
|
+
if (signal?.aborted)
|
|
151
|
+
throw canceledError(signal.reason);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -5,6 +5,7 @@ import { setJobPhase } from "./inflight.js";
|
|
|
5
5
|
import { parseJson, parseStream, safeDiagnosticLabel, } from "./responsesParse.js";
|
|
6
6
|
import { waitForOAuthReady } from "./oauthProxy.js";
|
|
7
7
|
import { oauthFetch } from "./codexBackend/index.js";
|
|
8
|
+
import { oauthRateLimitFields, oauthRateLimitRetryConfig } from "./oauthRateLimit.js";
|
|
8
9
|
const RESPONSES_ERROR_MARKER = "ima2ResponsesError";
|
|
9
10
|
function makeError(message, { status = 500, code = "RESPONSES_IMAGE_ERROR", cause, ...rest } = {}) {
|
|
10
11
|
const err = new Error(message);
|
|
@@ -63,6 +64,15 @@ function safeUpstreamClientMessage(upstream, status) {
|
|
|
63
64
|
return "OpenAI rate limited the image request.";
|
|
64
65
|
return "OpenAI rejected the image request.";
|
|
65
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Rate-limit kind and wait hint for a rejected GPT OAuth call, read from the raw upstream text
|
|
69
|
+
* before the client message is redacted. Only the kind and a number leave this function.
|
|
70
|
+
*/
|
|
71
|
+
function rateLimitFieldsOf(ctx, res, upstream, text) {
|
|
72
|
+
const raw = upstream ? [upstream.message, upstream.code, upstream.type].filter(Boolean).join(" ") : text.slice(0, 2000);
|
|
73
|
+
const { maxDelayMs } = oauthRateLimitRetryConfig(ctx?.config?.oauth?.rateLimitRetry);
|
|
74
|
+
return oauthRateLimitFields(res.status, raw, res.headers, maxDelayMs);
|
|
75
|
+
}
|
|
66
76
|
function apiAuthorizationHeader(apiKey) {
|
|
67
77
|
const key = typeof apiKey === "string" ? apiKey.trim() : "";
|
|
68
78
|
if (!key) {
|
|
@@ -138,6 +148,7 @@ export async function postResponses({ ctx, provider, scope, payload, requestId,
|
|
|
138
148
|
if (!res.ok) {
|
|
139
149
|
const text = await res.text();
|
|
140
150
|
const upstream = parseOpenAIErrorBody(text);
|
|
151
|
+
const rateLimit = provider === "api" ? {} : rateLimitFieldsOf(ctx, res, upstream, text);
|
|
141
152
|
if (res.status >= 400 && res.status < 500 && upstream?.message) {
|
|
142
153
|
throw makeError(safeUpstreamClientMessage(upstream, res.status), {
|
|
143
154
|
status: res.status,
|
|
@@ -147,11 +158,13 @@ export async function postResponses({ ctx, provider, scope, payload, requestId,
|
|
|
147
158
|
upstreamType: upstream.type,
|
|
148
159
|
upstreamParam: upstream.param,
|
|
149
160
|
upstreamMessageRedacted: true,
|
|
161
|
+
...rateLimit,
|
|
150
162
|
});
|
|
151
163
|
}
|
|
152
164
|
throw makeError(`${provider === "api" ? "OpenAI API" : "OAuth proxy"} returned ${res.status}`, {
|
|
153
165
|
status: res.status,
|
|
154
166
|
upstreamBodyChars: text.length,
|
|
167
|
+
...rateLimit,
|
|
155
168
|
});
|
|
156
169
|
}
|
|
157
170
|
if (requestId)
|
|
@@ -207,6 +220,7 @@ export async function postOAuthImages({ ctx, scope, kind, json, form, requestId,
|
|
|
207
220
|
const text = await res.text();
|
|
208
221
|
if (!res.ok) {
|
|
209
222
|
const upstream = parseOpenAIErrorBody(text);
|
|
223
|
+
const rateLimit = rateLimitFieldsOf(ctx, res, upstream, text);
|
|
210
224
|
if (res.status >= 400 && res.status < 500 && upstream?.message) {
|
|
211
225
|
throw makeError(safeUpstreamClientMessage(upstream, res.status), {
|
|
212
226
|
status: res.status,
|
|
@@ -216,9 +230,10 @@ export async function postOAuthImages({ ctx, scope, kind, json, form, requestId,
|
|
|
216
230
|
upstreamType: upstream.type,
|
|
217
231
|
upstreamParam: upstream.param,
|
|
218
232
|
upstreamMessageRedacted: true,
|
|
233
|
+
...rateLimit,
|
|
219
234
|
});
|
|
220
235
|
}
|
|
221
|
-
throw makeError(`GPT OAuth returned ${res.status}`, { status: res.status, upstreamBodyChars: text.length });
|
|
236
|
+
throw makeError(`GPT OAuth returned ${res.status}`, { status: res.status, upstreamBodyChars: text.length, ...rateLimit });
|
|
222
237
|
}
|
|
223
238
|
let parsed;
|
|
224
239
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ima2-gen",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.25.0",
|
|
4
4
|
"packageManager": "npm@11.18.0",
|
|
5
5
|
"description": "Local-first visual generation runtime and studio for people and coding agents, with reproducible image and video workflows across multiple providers.",
|
|
6
6
|
"type": "module",
|
|
@@ -131,5 +131,5 @@
|
|
|
131
131
|
"typescript-eslint": "8.70.0",
|
|
132
132
|
"yaml": "2.9.1"
|
|
133
133
|
},
|
|
134
|
-
"gitHead": "
|
|
134
|
+
"gitHead": "057de2c0639cd451c65bff6f484ae397e86d7447"
|
|
135
135
|
}
|
package/routes/edit.js
CHANGED
|
@@ -12,7 +12,7 @@ import { checkImageExecutionAdmission } from "../lib/providers/execution/admissi
|
|
|
12
12
|
import { resolveGrokQualityModel } from "../lib/imageModels.js";
|
|
13
13
|
import { startJob, finishJob, registerJobAbortController, isJobCanceled, isStartJobFailure, INFLIGHT_RETRY_AFTER_SECONDS } from "../lib/inflight.js";
|
|
14
14
|
import { isGenerationCanceledError, makeGenerationCanceledError, throwIfJobCanceled, } from "../lib/generationCancel.js";
|
|
15
|
-
import { logEvent, logError } from "../lib/logger.js";
|
|
15
|
+
import { logEvent, logError, logWarn } from "../lib/logger.js";
|
|
16
16
|
import { hasPngAlphaChannel, parsePngInfo } from "../lib/pngInfo.js";
|
|
17
17
|
import { verifyBufferAlpha } from "../lib/imageBackgroundParam.js";
|
|
18
18
|
import { decodeRawForAlpha } from "../lib/alphaDecode.js";
|
|
@@ -22,6 +22,7 @@ import { requireRuntimeContext } from "../lib/runtimeContext.js";
|
|
|
22
22
|
import { errorEnvelopeFields } from "../lib/errors/envelope.js";
|
|
23
23
|
import { upstreamLabelFields } from "../lib/diagnosticLabel.js";
|
|
24
24
|
import { getProviderSurfaceSupport } from "../lib/providers/derive.js";
|
|
25
|
+
import { preserveOutsideMask } from "../lib/maskedEditComposite.js";
|
|
25
26
|
function validateModeration(ctx, moderation) {
|
|
26
27
|
if (typeof moderation !== "string" || !ctx.config.oauth.validModeration.has(moderation)) {
|
|
27
28
|
return { error: "moderation must be one of: auto, low" };
|
|
@@ -72,12 +73,12 @@ function validateEditMask(imageB64, mask) {
|
|
|
72
73
|
return { error: "mask PNG must include an alpha channel", code: "EDIT_MASK_NO_ALPHA" };
|
|
73
74
|
}
|
|
74
75
|
const imageCheck = decodePngDataUrl(imageB64, "INVALID_EDIT_IMAGE_BASE64", "INVALID_EDIT_IMAGE_PNG");
|
|
75
|
-
if (imageCheck.error || !imageCheck.info)
|
|
76
|
+
if (imageCheck.error || !imageCheck.buffer || !imageCheck.info)
|
|
76
77
|
return imageCheck;
|
|
77
78
|
if (imageCheck.info.width !== maskCheck.info.width || imageCheck.info.height !== maskCheck.info.height) {
|
|
78
79
|
return { error: "mask dimensions must match image dimensions", code: "EDIT_MASK_DIMENSION_MISMATCH" };
|
|
79
80
|
}
|
|
80
|
-
return { mask: maskCheck.b64, maskBytes: maskCheck.buffer.length };
|
|
81
|
+
return { mask: maskCheck.b64, maskBytes: maskCheck.buffer.length, maskBuffer: maskCheck.buffer, sourceBuffer: imageCheck.buffer };
|
|
81
82
|
}
|
|
82
83
|
export function registerEditRoutes(app, ctxRaw) {
|
|
83
84
|
const ctx = requireRuntimeContext(ctxRaw);
|
|
@@ -240,7 +241,29 @@ export function registerEditRoutes(app, ctxRaw) {
|
|
|
240
241
|
? (resultMimeFromProvider || detectImageMimeFromB64(resultB64) || "image/png")
|
|
241
242
|
: "image/png";
|
|
242
243
|
const editExt = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" || activeProvider === "minimax" || activeProvider === "nai" ? imageFormatFromMime(editMime) : "png";
|
|
243
|
-
const
|
|
244
|
+
const providerBuffer = Buffer.from(resultB64, "base64");
|
|
245
|
+
// Masks are guidance for GPT Image models; restore the source wherever the
|
|
246
|
+
// mask is opaque so a masked edit cannot redraw the area the user kept.
|
|
247
|
+
const { maskBuffer, sourceBuffer } = maskCheck;
|
|
248
|
+
const kept = ctx.config.features?.preserveOutsideMask !== false && maskBuffer && sourceBuffer
|
|
249
|
+
? { mask: maskBuffer, source: sourceBuffer }
|
|
250
|
+
: null;
|
|
251
|
+
// The provider call is already paid for, so a failed composite falls back
|
|
252
|
+
// to the provider bytes instead of discarding the result with a 500.
|
|
253
|
+
let editBuffer = providerBuffer;
|
|
254
|
+
let maskOutsidePreserved = false;
|
|
255
|
+
if (kept) {
|
|
256
|
+
try {
|
|
257
|
+
editBuffer = await preserveOutsideMask({ ...kept, result: providerBuffer, format: editExt });
|
|
258
|
+
maskOutsidePreserved = true;
|
|
259
|
+
}
|
|
260
|
+
catch (compositeErr) {
|
|
261
|
+
const message = compositeErr instanceof Error ? compositeErr.message : String(compositeErr);
|
|
262
|
+
logWarn("edit", "mask_preserve_failed", { requestId, errorMessage: message.slice(0, 200) });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const editB64 = maskOutsidePreserved ? editBuffer.toString("base64") : resultB64;
|
|
266
|
+
throwIfJobCanceled(requestId);
|
|
244
267
|
const createdAt = Date.now();
|
|
245
268
|
// Semantic alpha verification: at least one pixel with alpha < 255.
|
|
246
269
|
// Never trust provider mime for transparency claims (same doctrine as
|
|
@@ -289,19 +312,20 @@ export function registerEditRoutes(app, ctxRaw) {
|
|
|
289
312
|
webSearchCalls,
|
|
290
313
|
webSearchEnabled,
|
|
291
314
|
...(providerUrl ? { providerUrl } : {}),
|
|
315
|
+
...(maskOutsidePreserved ? { maskOutsidePreserved: true } : {}),
|
|
292
316
|
};
|
|
293
317
|
await safeWriteSidecar(join(ctx.config.storage.generatedDir, filename + ".json"), meta);
|
|
294
318
|
invalidateHistoryIndex();
|
|
295
319
|
finishHttpStatus = 200;
|
|
296
|
-
finishMeta = { filename, imageChars:
|
|
320
|
+
finishMeta = { filename, imageChars: editB64.length };
|
|
297
321
|
logEvent("edit", "saved", {
|
|
298
322
|
requestId,
|
|
299
323
|
filename,
|
|
300
|
-
imageChars:
|
|
324
|
+
imageChars: editB64.length,
|
|
301
325
|
elapsedMs: Date.now() - startTime,
|
|
302
326
|
});
|
|
303
327
|
res.json({
|
|
304
|
-
image: `data:${editMime};base64,${
|
|
328
|
+
image: `data:${editMime};base64,${editB64}`,
|
|
305
329
|
elapsed,
|
|
306
330
|
reasoningEffort,
|
|
307
331
|
filename,
|