@dickpy/dsh-imagegen 1.5.6 → 1.5.7

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/src/engine.ts CHANGED
@@ -1,845 +1,998 @@
1
- /**
2
- * Upstream proxy engine: forwards a generate request to the configured
3
- * OpenAI-compatible image endpoint (/images/generations for text-to-image,
4
- * /images/edits for image-to-image) and normalizes the response to base64
5
- * images so the browser never fetches the upstream itself.
6
- *
7
- * Framework-free (no cordis imports) so the route layer and tests can drive
8
- * it directly.
9
- */
10
-
11
- import type { GeneratedImage, GenerateRequest, GenerateResult } from './protocol.ts'
12
- import { detectImageMime } from './image-format.ts'
13
- import { modelFamily } from './model-catalog.ts'
14
-
15
- /** The upstream credentials the panel's settings card configures. */
16
- export interface UpstreamConfig {
17
- /** Base URL of the OpenAI-compatible endpoint, e.g. https://api.openai.com/v1 */
18
- apiUrl: string
19
- /** Bearer API key. */
20
- apiKey: string
21
- }
22
-
23
- /** A generation failure with a user-presentable message. */
24
- export class ImageGenError extends Error {
25
- /** Stable wire code. */
26
- readonly code: string
27
-
28
- constructor(message: string, code = 'generate-failed') {
29
- super(message)
30
- this.name = 'ImageGenError'
31
- this.code = code
32
- }
33
- }
34
-
35
- /** Total budget for the upstream generation call (image models are slow). */
36
- const UPSTREAM_TIMEOUT_MS = 240_000
37
-
38
- /** Budget for downloading one result image URL. */
39
- const IMAGE_FETCH_TIMEOUT_MS = 60_000
40
-
41
- /** Cap on the reference image payload (edit mode), in bytes. */
42
- const MAX_EDIT_IMAGE_BYTES = 10 * 1024 * 1024
43
-
44
- /** Sizes dall-e-3 accepts; anything else falls back to its square default. */
45
- const DALLE3_SIZES = new Set(['1024x1024', '1792x1024', '1024x1792'])
46
-
47
- /** The wire model id for a request: `upstream` (host-filled alias mapping)
48
- * wins, then the alias, then the family default. */
49
- function wireModel(request: GenerateRequest): string {
50
- const upstream = request.upstream?.trim()
51
- if (upstream !== undefined && upstream !== '') return upstream
52
- const alias = request.model.trim()
53
- return alias === '' ? 'gpt-image-2' : alias
54
- }
55
-
56
- /** Whether the model is an xAI Grok Imagine model (grok-imagine-image,
57
- * grok-imagine-image-2.0, …). Grok Imagine speaks JSON on both endpoints
58
- * and exposes its own aspect-ratio / response-format knobs instead of the
59
- * OpenAI size/quality/detail passthrough. */
60
- function isGrokImagine(model: string): boolean {
61
- return modelFamily(model) === 'grok'
62
- }
63
-
64
- /** Whether the model belongs to the Google Nano Banana family (nanobanana2 /
65
- * nanobanana2-lite / nanobanana-pro, plus the official Gemini image IDs the
66
- * gateways expose). OpenAI-compatible gateways serve these with their own
67
- * aspect_ratio / image_size vocabulary instead of the OpenAI size/quality
68
- * passthrough. */
69
- function isNanoBanana(model: string): boolean {
70
- return modelFamily(model) === 'nanobanana'
71
- }
72
-
73
- /** Whether the model belongs to the ByteDance Seedream family (seedream-5.0-pro,
74
- * seedream-5.0, seedream-4.x, doubao-seedream-…). OpenAI-compatible gateways
75
- * serve Seedream through a unified generate-and-edit architecture:
76
- * generation AND editing both go to /images/generations and reference images
77
- * are a JSON URL / data-URL array. */
78
- function isSeedream(model: string): boolean {
79
- return modelFamily(model) === 'seedream'
80
- }
81
-
82
- /** Whether the model uses the official Zhipu image-generation contract. */
83
- function isZhipuImage(model: string): boolean {
84
- return modelFamily(model) === 'zhipu'
85
- }
86
-
87
- /** Whether the model is Alibaba Qwen-Image, which speaks the DashScope native
88
- * multimodal-generation contract (NOT OpenAI-compatible): a chat-style
89
- * messages body, `宽*高` pixel sizes, and image URLs in the reply content. */
90
- function isQwenImage(model: string): boolean {
91
- return modelFamily(model) === 'qwen'
92
- }
93
-
94
- function isGlmImage(model: string): boolean {
95
- return /^glm-image(?:-|$)/i.test(model.trim())
96
- }
97
-
98
- /** Whether this is the official Volcengine Ark model naming convention. */
99
- function isVolcSeedream(model: string): boolean {
100
- return /^doubao-seedream(?:-|$)/i.test(model.trim())
101
- }
102
-
103
- /** Volcengine uses `size` for the output tier, not the panel's aspect ratio. */
104
- function seedreamSize(quality: string): string {
105
- // Seedream 5.0 Pro currently caps at 2K; keep 4K requests valid by
106
- // degrading them to the highest supported tier instead of sending 4K.
107
- if (quality === '1k') return '1K'
108
- return '2K'
109
- }
110
-
111
- /** The panel's aspect ratios mapped to Qwen-Image's `宽*高` pixel sizes.
112
- * The classic series (qwen-image / -plus / -max) documents this fixed list;
113
- * 2.0 / 3.0-series models accept any size within their pixel budget and
114
- * recommend the larger set. */
115
- const QWEN_SIZE_CLASSIC: Readonly<Record<string, string>> = {
116
- '16:9': '1664*928',
117
- '21:9': '1664*928',
118
- '4:3': '1472*1104',
119
- '3:2': '1472*1104',
120
- '1:1': '1328*1328',
121
- '3:4': '1104*1472',
122
- '2:3': '1104*1472',
123
- '9:16': '928*1664',
124
- }
125
-
126
- const QWEN_SIZE_HD: Readonly<Record<string, string>> = {
127
- '16:9': '2688*1536',
128
- '21:9': '2688*1536',
129
- '4:3': '2368*1728',
130
- '3:2': '2368*1728',
131
- '1:1': '2048*2048',
132
- '3:4': '1728*2368',
133
- '2:3': '1728*2368',
134
- '9:16': '1536*2688',
135
- }
136
-
137
- /** Versioned ids (qwen-image-2.0 / -3.0-pro / …) take the large size set. */
138
- function isVersionedQwenImage(model: string): boolean {
139
- return /^qwen-image-\d+\.\d/i.test(model.trim())
140
- }
141
-
142
- function qwenSize(model: string, ratio: string): string | undefined {
143
- if (ratio === '' || ratio === 'auto') return undefined
144
- return (isVersionedQwenImage(model) ? QWEN_SIZE_HD : QWEN_SIZE_CLASSIC)[ratio]
145
- }
146
-
147
- /** The panel's aspect ratios mapped to the closest OpenAI pixel size
148
- * (gpt-image-2 / generic OpenAI-compatible endpoints). */
149
- const OPENAI_SIZE_BY_RATIO: Readonly<Record<string, string>> = {
150
- '1:1': '1024x1024',
151
- '3:4': '1024x1536',
152
- '4:3': '1536x1024',
153
- '9:16': '1024x1792',
154
- '2:3': '1024x1536',
155
- '3:2': '1536x1024',
156
- '16:9': '1792x1024',
157
- '21:9': '1792x1024',
158
- }
159
-
160
- /** Panel ratios that need renaming for a model's vocabulary. Grok documents
161
- * 20:9 as its ultra-wide ratio, so the panel's 21:9 label is sent as 20:9. */
162
- const GROK_ASPECT_ALIASES: Readonly<Record<string, string>> = {
163
- '21:9': '20:9',
164
- }
165
-
166
- /**
167
- * One request-scoped timeout that is cleared as soon as its fetch settles.
168
- * AbortSignal.timeout() cannot be disposed early; using it inside a long-lived
169
- * task queue leaves an otherwise idle Node process holding every timeout.
170
- */
171
- function requestSignal(source: AbortSignal | undefined, timeoutMs: number): { signal: AbortSignal; dispose: () => void } {
172
- const controller = new AbortController()
173
- const abortFromSource = () => { controller.abort(source?.reason) }
174
- if (source?.aborted === true) abortFromSource()
175
- else source?.addEventListener('abort', abortFromSource, { once: true })
176
- const timeout = setTimeout(() => { controller.abort(new DOMException('The operation timed out.', 'TimeoutError')) }, timeoutMs)
177
- timeout.unref()
178
- return {
179
- signal: controller.signal,
180
- dispose: () => {
181
- clearTimeout(timeout)
182
- source?.removeEventListener('abort', abortFromSource)
183
- },
184
- }
185
- }
186
-
187
- /** Whether an error was produced by a requestSignal budget timeout. These can
188
- * surface from the fetch call itself or from reading the response body, so the
189
- * budget must stay armed until the body has been consumed. */
190
- function isBudgetTimeout(error: unknown): boolean {
191
- return (error instanceof DOMException || error instanceof Error) && error.name === 'TimeoutError'
192
- }
193
-
194
- /** Content-type extension hints for URL-fetched images. */function mimeOfExtension(path: string): string | undefined {
195
- const match = /\.([a-z0-9]+)$/i.exec(path)
196
- if (match === null) return undefined
197
- switch (match[1]!.toLowerCase()) {
198
- case 'png': return 'image/png'
199
- case 'jpg':
200
- case 'jpeg': return 'image/jpeg'
201
- case 'webp': return 'image/webp'
202
- case 'gif': return 'image/gif'
203
- default: return undefined
204
- }
205
- }
206
-
207
- /** Parse `data:<mime>;base64,<payload>` into its parts; undefined when malformed. */
208
- function parseDataUrl(dataUrl: string): { mime: string; base64: string } | undefined {
209
- const match = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(dataUrl.trim())
210
- if (match === null || match[3] === undefined) return undefined
211
- if (match[2] === undefined) {
212
- // Plain (non-base64) data URLs are not supported for reference images.
213
- return undefined
214
- }
215
- return { mime: match[1] ?? 'application/octet-stream', base64: match[3] }
216
- }
217
-
218
- /** Strip a data: prefix from an upstream b64 payload if a gateway added one. */
219
- function bareBase64(value: string): string {
220
- const parsed = parseDataUrl(value)
221
- return parsed !== undefined && parsed.base64 !== undefined ? parsed.base64 : value
222
- }
223
-
224
- /** Whether a result URL carries cloud-storage signing credentials. */
225
- function isPresignedUrl(value: string): boolean {
226
- let url: URL
227
- try {
228
- url = new URL(value)
229
- } catch {
230
- return false
231
- }
232
- const params = new Set(Array.from(url.searchParams.keys(), key => key.toLowerCase()))
233
- if (params.has('x-goog-signature') || params.has('x-goog-credential')) return true
234
- if (params.has('x-amz-signature') || params.has('x-amz-credential')) return true
235
- return params.has('signature') && (
236
- params.has('expires') || params.has('googleaccessid') || params.has('awsaccesskeyid')
237
- )
238
- }
239
-
240
- /** Clamp the requested image count into the API-accepted range. */
241
- function clampCount(n: number): number {
242
- if (!Number.isFinite(n)) return 1
243
- return Math.min(4, Math.max(1, Math.round(n)))
244
- }
245
-
246
- /** Pick the effective per-model request parameters. Never includes `n`: the
247
- * batch parameter is rejected by Responses-API-based gateways (tools[0].n),
248
- * so the count is satisfied by parallel single-image requests instead. */
249
- function effectiveParams(request: GenerateRequest): {
250
- model: string
251
- size?: string
252
- quality?: string
253
- detail?: string
254
- aspect_ratio?: string
255
- image_size?: string
256
- resolution?: string
257
- response_format?: string
258
- } {
259
- const model = wireModel(request)
260
- // dall-e-3 has no quality/detail knobs and only produces one image.
261
- if (model === 'dall-e-3') {
262
- const pixel = OPENAI_SIZE_BY_RATIO[request.size]
263
- const size = (pixel !== undefined && DALLE3_SIZES.has(pixel)) ? pixel : '1024x1024'
264
- return { model, size }
265
- }
266
- // Grok Imagine: the panel's aspect ratios are sent as-is (21:9 aliased to
267
- // the documented 20:9), the clarity tiers become the resolution parameter
268
- // (the API documents 1k / 2k only, so 4k falls back to 2k), and base64
269
- // output keeps the temporary signed result URLs from expiring before the
270
- // host downloads them.
271
- if (isGrokImagine(model)) {
272
- return {
273
- model,
274
- ...request.size !== '' && request.size !== 'auto'
275
- ? { aspect_ratio: GROK_ASPECT_ALIASES[request.size] ?? request.size }
276
- : {},
277
- ...request.quality !== '' && request.quality !== 'auto'
278
- ? { resolution: request.quality === '4k' ? '2k' : request.quality }
279
- : {},
280
- response_format: 'b64_json',
281
- }
282
- }
283
- // Google Nano Banana: the panel's aspect ratios are sent as-is (the family
284
- // documents 1:1 … 21:9 natively), the clarity tiers become image_size
285
- // (1K / 2K / 4K — Gen 1 and 2-Lite are 1K-only upstream, but which gateway
286
- // rejects higher tiers is its own call), and base64 output keeps any signed
287
- // result URLs from expiring before the host downloads them.
288
- if (isNanoBanana(model)) {
289
- return {
290
- model,
291
- ...request.size !== '' && request.size !== 'auto'
292
- ? { aspect_ratio: request.size }
293
- : {},
294
- ...request.quality !== '' && request.quality !== 'auto'
295
- ? { image_size: request.quality.toUpperCase() }
296
- : {},
297
- response_format: 'b64_json',
298
- }
299
- }
300
- // ByteDance Seedream: the official Volcengine Ark API uses `size` for the
301
- // resolution tier (1K / 2K), not the panel's aspect-ratio value. It returns
302
- // temporary URLs, so ask Ark for URL output and let the host download it.
303
- // Other compatible gateways retain the base64 response fallback.
304
- if (isSeedream(model)) {
305
- return {
306
- model,
307
- size: seedreamSize(request.quality),
308
- response_format: isVolcSeedream(model) ? 'url' : 'b64_json',
309
- }
310
- }
311
- // Zhipu's official image API accepts OpenAI-style JSON but uses its own
312
- // quality vocabulary. GLM-Image currently supports hd only; CogView uses
313
- // the standard tier. Size remains a valid custom pixel size for both.
314
- if (isZhipuImage(model)) {
315
- return {
316
- model,
317
- ...request.size !== '' && request.size !== 'auto' && OPENAI_SIZE_BY_RATIO[request.size] !== undefined
318
- ? { size: OPENAI_SIZE_BY_RATIO[request.size] }
319
- : {},
320
- quality: isGlmImage(model) ? 'hd' : 'standard',
321
- }
322
- }
323
- // OpenAI-compatible endpoints: nearest pixel size, clarity tiers mapped to
324
- // the quality levels (1k→low / 2k→medium / 4k→high), detail passthrough.
325
- return {
326
- model,
327
- ...request.size !== '' && request.size !== 'auto' && OPENAI_SIZE_BY_RATIO[request.size] !== undefined
328
- ? { size: OPENAI_SIZE_BY_RATIO[request.size] }
329
- : {},
330
- ...request.quality === '1k' ? { quality: 'low' } : {},
331
- ...request.quality === '2k' ? { quality: 'medium' } : {},
332
- ...request.quality === '4k' ? { quality: 'high' } : {},
333
- ...request.detail !== '' ? { detail: request.detail } : {},
334
- }
335
- }
336
-
337
- /** How many single-image requests to issue for the requested image count. */
338
- function effectiveCount(request: GenerateRequest): number {
339
- const model = wireModel(request)
340
- if (model === 'dall-e-3') return 1
341
- return clampCount(request.n)
342
- }
343
-
344
- /** Normalize one upstream data item into a base64 image. */
345
- async function normalizeItem(
346
- item: Record<string, unknown>,
347
- upstream: UpstreamConfig,
348
- signal?: AbortSignal,
349
- ): Promise<{ b64: string; mime: string; revisedPrompt?: string }> {
350
- const revisedPrompt = typeof item.revised_prompt === 'string' ? item.revised_prompt : undefined
351
- if (typeof item.b64_json === 'string' && item.b64_json.trim() !== '') {
352
- const b64 = bareBase64(item.b64_json)
353
- if (b64.trim() !== '') {
354
- return { b64, mime: detectImageMime(Buffer.from(b64, 'base64')) ?? 'image/png', revisedPrompt }
355
- }
356
- }
357
- if (typeof item.url !== 'string' || item.url === '') {
358
- throw new ImageGenError('upstream image item has neither b64_json nor url')
359
- }
360
- const url = item.url
361
- if (url.startsWith('data:')) {
362
- const parsed = parseDataUrl(url)
363
- if (parsed === undefined) throw new ImageGenError('upstream returned a malformed data: url')
364
- return { b64: parsed.base64, mime: detectImageMime(Buffer.from(parsed.base64, 'base64')) ?? parsed.mime, revisedPrompt }
365
- }
366
- const budget = requestSignal(signal, IMAGE_FETCH_TIMEOUT_MS)
367
- try {
368
- let response: Response
369
- try {
370
- response = await fetch(url, {
371
- ...isPresignedUrl(url) || upstream.apiKey === ''
372
- ? {}
373
- : { headers: { authorization: `Bearer ${upstream.apiKey}` } },
374
- signal: budget.signal,
375
- })
376
- } catch (error) {
377
- throw new ImageGenError(`failed to fetch the generated image url: ${error instanceof Error ? error.message : String(error)}`)
378
- }
379
- if (!response.ok) {
380
- throw new ImageGenError(`failed to fetch the generated image url: HTTP ${response.status}`)
381
- }
382
- // Budget stays armed through the body read so a stalled download cannot hang the task.
383
- const buffer = Buffer.from(await response.arrayBuffer())
384
- const contentType = response.headers.get('content-type')
385
- const mime = detectImageMime(buffer)
386
- ?? (contentType !== null && contentType !== ''
387
- ? contentType.split(';')[0]!.trim()
388
- : mimeOfExtension(url) ?? 'image/png')
389
- return { b64: buffer.toString('base64'), mime, revisedPrompt }
390
- } finally {
391
- budget.dispose()
392
- }
393
- }
394
-
395
- /** Expand a provider image item whose URL may be a string or an array. */
396
- function imageItemsOf(value: unknown): Array<Record<string, unknown>> {
397
- if (value === null || typeof value !== 'object') return []
398
- const item = value as Record<string, unknown>
399
- if (Array.isArray(item.url)) {
400
- return item.url.filter((url): url is string => typeof url === 'string' && url !== '').map(url => ({ ...item, url }))
401
- }
402
- return [item]
403
- }
404
-
405
- /** Return the data records from the response shapes shared by sync gateways. */
406
- function dataRecordsOf(payload: Record<string, unknown>): Array<Record<string, unknown>> | undefined {
407
- const data = Array.isArray(payload.data)
408
- ? payload.data
409
- : payload.data !== null && typeof payload.data === 'object'
410
- ? [payload.data]
411
- : Array.isArray(payload.images)
412
- ? payload.images
413
- : Array.isArray(payload.output)
414
- ? payload.output
415
- : undefined
416
- if (data === undefined) return undefined
417
- return data.filter((entry): entry is Record<string, unknown> => entry !== null && typeof entry === 'object')
418
- }
419
-
420
- const ASYNC_PENDING_STATUSES = new Set(['submitted', 'pending', 'processing', 'running', 'in_progress', 'queued'])
421
- const ASYNC_COMPLETED_STATUSES = new Set(['completed', 'succeeded', 'success', 'done'])
422
- const ASYNC_FAILED_STATUSES = new Set(['failed', 'failure', 'cancelled', 'canceled', 'error'])
423
- const ASYNC_POLL_MAX_MS = 240_000
424
- const ASYNC_POLL_REQUEST_TIMEOUT_MS = 30_000
425
-
426
- /** Read a provider error message from the common nested locations. */
427
- function asyncErrorMessage(payload: unknown, fallback: string): string {
428
- if (payload !== null && typeof payload === 'object') {
429
- const record = payload as Record<string, unknown>
430
- const candidates: unknown[] = [record.message, record.error]
431
- const data = record.data
432
- const entries = Array.isArray(data) ? data : [data]
433
- for (const entry of entries) {
434
- if (entry === null || typeof entry !== 'object') continue
435
- const item = entry as Record<string, unknown>
436
- candidates.push(item.message, item.error)
437
- const nested = item.error
438
- if (nested !== null && typeof nested === 'object') candidates.push((nested as Record<string, unknown>).message)
439
- }
440
- for (const candidate of candidates) {
441
- if (typeof candidate === 'string' && candidate.trim() !== '') return candidate
442
- if (candidate !== null && typeof candidate === 'object') {
443
- const message = (candidate as Record<string, unknown>).message
444
- if (typeof message === 'string' && message.trim() !== '') return message
445
- }
446
- }
447
- }
448
- return fallback
449
- }
450
-
451
- /** Wait between async-provider polls, but wake immediately when cancelled. */
452
- function waitForPoll(ms: number, signal?: AbortSignal): Promise<void> {
453
- return new Promise((resolve, reject) => {
454
- if (signal?.aborted === true) {
455
- reject(signal.reason ?? new DOMException('The operation was aborted.', 'AbortError'))
456
- return
457
- }
458
- const onAbort = () => {
459
- clearTimeout(timer)
460
- signal?.removeEventListener('abort', onAbort)
461
- reject(signal?.reason ?? new DOMException('The operation was aborted.', 'AbortError'))
462
- }
463
- const done = () => {
464
- signal?.removeEventListener('abort', onAbort)
465
- resolve()
466
- }
467
- const timer = setTimeout(done, ms)
468
- timer.unref()
469
- signal?.addEventListener('abort', onAbort, { once: true })
470
- })
471
- }
472
-
473
- /**
474
- * Poll one apib/apimart-style provider task until it yields image records.
475
- * The total deadline is shared by every poll and the final image downloads;
476
- * local task cancellation propagates through every request and sleep.
477
- */
478
- async function pollAsyncTask(
479
- baseUrl: string,
480
- upstream: UpstreamConfig,
481
- taskId: string,
482
- signal?: AbortSignal,
483
- ): Promise<Array<Record<string, unknown>>> {
484
- const deadline = Date.now() + ASYNC_POLL_MAX_MS
485
- let delay = 1000
486
- while (Date.now() < deadline) {
487
- const remaining = deadline - Date.now()
488
- const budget = requestSignal(signal, Math.min(ASYNC_POLL_REQUEST_TIMEOUT_MS, remaining))
489
- try {
490
- let response: Response
491
- try {
492
- response = await fetch(`${baseUrl}/tasks/${encodeURIComponent(taskId)}`, {
493
- method: 'GET',
494
- headers: { authorization: `Bearer ${upstream.apiKey.trim()}` },
495
- signal: budget.signal,
496
- })
497
- } catch (error) {
498
- if (signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
499
- if (isBudgetTimeout(error)) throw new ImageGenError('上游异步任务轮询超时', 'upstream-timeout')
500
- throw new ImageGenError(`无法轮询上游异步任务:${error instanceof Error ? error.message : String(error)}`, 'upstream-unreachable')
501
- }
502
- let payload: unknown
503
- try {
504
- payload = await response.json()
505
- } catch (error) {
506
- if (isBudgetTimeout(error)) throw new ImageGenError('上游异步任务轮询超时', 'upstream-timeout')
507
- throw new ImageGenError(`上游任务接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
508
- }
509
- if (!response.ok || payload === null || typeof payload !== 'object') {
510
- throw new ImageGenError(asyncErrorMessage(payload, `上游任务轮询失败(HTTP ${response.status})`), 'upstream-rejected')
511
- }
512
- const record = payload as Record<string, unknown>
513
- const data = record.data
514
- const statusRecord = Array.isArray(data) ? data[0] : data !== null && typeof data === 'object' ? data : record
515
- const statusValue = statusRecord !== null && typeof statusRecord === 'object'
516
- ? (statusRecord as Record<string, unknown>).status
517
- : undefined
518
- const status = typeof statusValue === 'string' ? statusValue.toLowerCase() : ''
519
- if (ASYNC_FAILED_STATUSES.has(status)) {
520
- throw new ImageGenError(asyncErrorMessage(payload, `上游异步任务失败(${status || 'unknown'})`), 'upstream-rejected')
521
- }
522
- const nested = statusRecord !== null && typeof statusRecord === 'object' ? statusRecord as Record<string, unknown> : record
523
- const result = nested.result ?? (nested.output !== null && typeof nested.output === 'object' ? (nested.output as Record<string, unknown>).result : undefined) ?? record.result
524
- const resultRecord = result !== null && typeof result === 'object' ? result as Record<string, unknown> : undefined
525
- const images = resultRecord?.images ?? (nested.images ?? record.images)
526
- if (ASYNC_COMPLETED_STATUSES.has(status) || images !== undefined) {
527
- const items = Array.isArray(images) ? images.flatMap(imageItemsOf) : imageItemsOf(images)
528
- if (items.length > 0) return items
529
- if (ASYNC_COMPLETED_STATUSES.has(status)) throw new ImageGenError('上游异步任务完成但没有图片结果', 'upstream-empty')
530
- }
531
- if (status !== '' && !ASYNC_PENDING_STATUSES.has(status) && !ASYNC_COMPLETED_STATUSES.has(status)) {
532
- throw new ImageGenError(`上游返回了未知异步任务状态:${status}`, 'upstream-invalid')
533
- }
534
- } finally {
535
- budget.dispose()
536
- }
537
- await waitForPoll(Math.min(delay, Math.max(1, deadline - Date.now())), signal)
538
- delay = Math.min(5000, delay * 2)
539
- }
540
- throw new ImageGenError('上游异步任务轮询超时(240 秒)', 'upstream-timeout')
541
- }
542
-
543
- /**
544
- * Issue one single-image request (never sends `n`). The response is kept as a
545
- * list so a gateway that happens to return several images per call still works.
546
- */
547
- async function requestOneImage(
548
- baseUrl: string,
549
- upstream: UpstreamConfig,
550
- request: GenerateRequest,
551
- params: ReturnType<typeof effectiveParams>,
552
- signal?: AbortSignal,
553
- ): Promise<GeneratedImage[]> {
554
- const headers: Record<string, string> = {
555
- authorization: `Bearer ${upstream.apiKey.trim()}`,
556
- }
557
- let body: BodyInit
558
- if (request.mode === 'edit') {
559
- if (typeof request.image !== 'string' || request.image === '') {
560
- throw new ImageGenError('图生图需要上传参考图片', 'edit-image-missing')
561
- }
562
- const decodeReference = (dataUrl: string): { bytes: Buffer; mime: string; filename: string } => {
563
- const parsed = parseDataUrl(dataUrl)
564
- if (parsed === undefined) throw new ImageGenError('参考图片格式无效', 'edit-image-invalid')
565
- let bytes: Buffer
566
- try {
567
- bytes = Buffer.from(parsed.base64, 'base64')
568
- } catch {
569
- throw new ImageGenError('参考图片数据无法解码', 'edit-image-invalid')
570
- }
571
- if (bytes.byteLength > MAX_EDIT_IMAGE_BYTES) {
572
- throw new ImageGenError('参考图片超过 10MB 上限', 'edit-image-too-large')
573
- }
574
- return { bytes, mime: parsed.mime, filename: `reference.${extensionOf(parsed.mime)}` }
575
- }
576
- const primary = decodeReference(request.image)
577
- const extras = (request.images ?? [])
578
- .filter(img => typeof img === 'string' && img !== '')
579
- .slice(0, 4)
580
- .map(decodeReference)
581
- // Grok Imagine /images/edits takes a JSON image_url object (a base64 data
582
- // URI is accepted) instead of OpenAI's multipart form-data upload.
583
- if (isGrokImagine(params.model)) {
584
- headers['content-type'] = 'application/json'
585
- body = JSON.stringify({
586
- model: params.model,
587
- prompt: request.prompt,
588
- image: { url: request.image, type: 'image_url' },
589
- ...params.aspect_ratio !== undefined ? { aspect_ratio: params.aspect_ratio } : {},
590
- response_format: 'b64_json',
591
- })
592
- } else if (isNanoBanana(params.model)) {
593
- // Nano Banana OpenAI-compatible gateways accept the standard multipart
594
- // edit upload, with the family's own aspect_ratio / image_size knobs.
595
- const form = new FormData()
596
- form.append('image', new Blob([primary.bytes], { type: primary.mime }), primary.filename)
597
- form.append('prompt', request.prompt)
598
- form.append('model', params.model)
599
- if (params.aspect_ratio !== undefined) form.append('aspect_ratio', params.aspect_ratio)
600
- if (params.image_size !== undefined) form.append('image_size', params.image_size)
601
- body = form
602
- } else if (isSeedream(params.model)) {
603
- // Seedream unifies generation and editing on /images/generations; the
604
- // reference image is a JSON URL / data-URL array, never multipart, and
605
- // the protocol natively accepts several references.
606
- headers['content-type'] = 'application/json'
607
- body = JSON.stringify({
608
- model: params.model,
609
- prompt: request.prompt,
610
- image: [request.image, ...(request.images ?? []).filter(img => typeof img === 'string' && img !== '').slice(0, 4)],
611
- ...params.size !== undefined ? { size: params.size } : {},
612
- ...params.resolution !== undefined ? { resolution: params.resolution } : {},
613
- response_format: isVolcSeedream(params.model) ? 'url' : 'b64_json',
614
- })
615
- } else {
616
- const form = new FormData()
617
- if (extras.length > 0) {
618
- // OpenAI-style multi-reference upload: repeat the image[] field so
619
- // every connected canvas reference reaches the gateway.
620
- for (const [index, reference] of [primary, ...extras].entries()) {
621
- form.append('image[]', new Blob([reference.bytes], { type: reference.mime }), `reference-${index}.${extensionOf(reference.mime)}`)
622
- }
623
- } else {
624
- form.append('image', new Blob([primary.bytes], { type: primary.mime }), primary.filename)
625
- }
626
- form.append('prompt', request.prompt)
627
- form.append('model', params.model)
628
- if (params.size !== undefined) form.append('size', params.size)
629
- if (params.quality !== undefined) form.append('quality', params.quality)
630
- if (params.detail !== undefined) form.append('detail', params.detail)
631
- body = form
632
- }
633
- } else {
634
- headers['content-type'] = 'application/json'
635
- body = JSON.stringify({ prompt: request.prompt, ...params } as Record<string, unknown>)
636
- }
637
-
638
- const budget = requestSignal(signal, UPSTREAM_TIMEOUT_MS)
639
- try {
640
- let response: Response
641
- try {
642
- // Seedream has no /images/edits endpoint: both modes hit generations.
643
- const endpoint = request.mode === 'edit' && !isSeedream(params.model)
644
- ? '/images/edits'
645
- : '/images/generations'
646
- response = await fetch(`${baseUrl}${endpoint}`, {
647
- method: 'POST',
648
- headers,
649
- body,
650
- signal: budget.signal,
651
- })
652
- } catch (error) {
653
- if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
654
- if (signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
655
- throw new ImageGenError(`无法连接上游接口:${error instanceof Error ? error.message : String(error)}`, 'upstream-unreachable')
656
- }
657
-
658
- let payload: unknown
659
- try {
660
- // The budget stays armed through the body read: a gateway that returns
661
- // headers but never completes the body must not hang the task forever.
662
- payload = await response.json()
663
- } catch (error) {
664
- if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
665
- if (signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
666
- throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
667
- }
668
- if (!response.ok || payload === null || typeof payload !== 'object') {
669
- throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
670
- }
671
-
672
- const record = payload as Record<string, unknown>
673
- const data = dataRecordsOf(record)
674
- if (data === undefined) {
675
- throw new ImageGenError('上游响应缺少 data 数组', 'upstream-invalid')
676
- }
677
- if (data.length === 0) {
678
- throw new ImageGenError('上游返回了 0 张图片', 'upstream-empty')
679
- }
680
- const asyncEntries = data.filter(entry => typeof entry.task_id === 'string' && entry.task_id.trim() !== '')
681
- if (asyncEntries.length > 0) {
682
- const asyncRecords = (await Promise.all(asyncEntries.map(entry => pollAsyncTask(baseUrl, upstream, entry.task_id as string, signal)))).flat()
683
- if (asyncRecords.length === 0) throw new ImageGenError('上游异步任务完成但没有图片结果', 'upstream-empty')
684
- return Promise.all(asyncRecords.flatMap(imageItemsOf).map(item => normalizeItem(item, upstream, signal)))
685
- }
686
- return Promise.all(data.flatMap(imageItemsOf).map(item => normalizeItem(item, upstream, signal)))
687
- } finally {
688
- budget.dispose()
689
- }
690
- }
691
-
692
- /**
693
- * Qwen-Image (DashScope native multimodal-generation): one chat-style request
694
- * carries the prompt (plus the reference image for edit mode) and answers
695
- * synchronously with image URLs in the reply content. The versioned series
696
- * batches natively (n 6; the panel caps at 4), the classic series is
697
- * single-image per call.
698
- */
699
- async function generateQwenImage(
700
- baseUrl: string,
701
- upstream: UpstreamConfig,
702
- request: GenerateRequest,
703
- options: { signal?: AbortSignal },
704
- ): Promise<GenerateResult> {
705
- const model = wireModel(request)
706
- const content: Array<Record<string, unknown>> = []
707
- if (request.mode === 'edit') {
708
- if (typeof request.image !== 'string' || request.image === '') {
709
- throw new ImageGenError('图生图需要上传参考图片', 'edit-image-missing')
710
- }
711
- // DashScope multimodal messages take the reference image as a content
712
- // item; a base64 data URI rides in the same field as a remote URL.
713
- const parsed = parseDataUrl(request.image)
714
- if (parsed === undefined) throw new ImageGenError('参考图片格式无效', 'edit-image-invalid')
715
- const bytes = Buffer.from(parsed.base64, 'base64')
716
- if (bytes.byteLength > MAX_EDIT_IMAGE_BYTES) {
717
- throw new ImageGenError('参考图片超过 10MB 上限', 'edit-image-too-large')
718
- }
719
- content.push({ image: request.image })
720
- }
721
- content.push({ text: request.prompt })
722
-
723
- const batchable = isVersionedQwenImage(model)
724
- const count = batchable ? clampCount(request.n) : 1
725
- const size = qwenSize(model, request.size)
726
- const body = {
727
- model,
728
- input: { messages: [{ role: 'user', content }] },
729
- parameters: {
730
- ...size !== undefined ? { size } : {},
731
- ...count > 1 ? { n: count } : {},
732
- },
733
- }
734
-
735
- const budget = requestSignal(options.signal, UPSTREAM_TIMEOUT_MS)
736
- try {
737
- let response: Response
738
- try {
739
- response = await fetch(`${baseUrl}/services/aigc/multimodal-generation/generation`, {
740
- method: 'POST',
741
- headers: {
742
- authorization: `Bearer ${upstream.apiKey.trim()}`,
743
- 'content-type': 'application/json',
744
- },
745
- body: JSON.stringify(body),
746
- signal: budget.signal,
747
- })
748
- } catch (error) {
749
- if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
750
- if (options.signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
751
- throw new ImageGenError(`无法连接上游接口:${error instanceof Error ? error.message : String(error)}`, 'upstream-unreachable')
752
- }
753
-
754
- let payload: unknown
755
- try {
756
- // Budget stays armed through the body read (same rationale as the OpenAI path).
757
- payload = await response.json()
758
- } catch (error) {
759
- if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
760
- if (options.signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
761
- throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
762
- }
763
- if (!response.ok || payload === null || typeof payload !== 'object') {
764
- throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
765
- }
766
-
767
- // output.choices[].message.content[] mixes text and { image: url } items.
768
- const record = payload as Record<string, unknown>
769
- const output = record.output as Record<string, unknown> | undefined
770
- const choices = output !== undefined && Array.isArray(output.choices) ? output.choices : []
771
- const urls: string[] = []
772
- for (const choice of choices) {
773
- const message = choice !== null && typeof choice === 'object'
774
- ? (choice as Record<string, unknown>).message
775
- : undefined
776
- const items = message !== null && typeof message === 'object' && Array.isArray((message as Record<string, unknown>).content)
777
- ? (message as Record<string, unknown>).content as unknown[]
778
- : []
779
- for (const item of items) {
780
- if (item !== null && typeof item === 'object') {
781
- const image = (item as Record<string, unknown>).image
782
- if (typeof image === 'string' && image !== '') urls.push(image)
783
- }
784
- }
785
- }
786
- if (urls.length === 0) {
787
- throw new ImageGenError('上游响应缺少图片内容', 'upstream-empty')
788
- }
789
- const images = await Promise.all(urls.map(async url => {
790
- const normalized = await normalizeItem({ url }, upstream)
791
- return { b64: normalized.b64, mime: normalized.mime }
792
- }))
793
- return { images }
794
- } finally {
795
- budget.dispose()
796
- }
797
- }
798
-
799
- /**
800
- * Forward one generate request to the configured endpoint. The requested image
801
- * count is satisfied with N parallel single-image requests (the `n` batch
802
- * parameter is never sent, because Responses-API-based gateways reject it as
803
- * `tools[0].n`), then the results are flattened in order.
804
- */
805
- export async function generateImage(upstream: UpstreamConfig, request: GenerateRequest, options: { signal?: AbortSignal } = {}): Promise<GenerateResult> {
806
- const baseUrl = upstream.apiUrl.trim().replace(/\/+$/, '')
807
- if (baseUrl === '') throw new ImageGenError('api_url 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
808
- if (upstream.apiKey.trim() === '') throw new ImageGenError('api_key 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
809
- if (isQwenImage(wireModel(request))) return generateQwenImage(baseUrl, upstream, request, options)
810
- if (request.mode === 'edit' && isZhipuImage(wireModel(request))) {
811
- throw new ImageGenError('智谱 GLM-Image 当前仅支持文生图,请切换到文生图模式或选择支持图生图的模型', 'edit-unsupported')
812
- }
813
- const params = effectiveParams(request)
814
- const count = effectiveCount(request)
815
- const batches = await Promise.all(
816
- Array.from({ length: count }, () => requestOneImage(baseUrl, upstream, request, params, options.signal)),
817
- )
818
- return { images: batches.flat() }
819
- }
820
-
821
- /** Human-readable failure message from an upstream error payload. */
822
- function upstreamMessage(payload: unknown, status: number): string {
823
- if (payload !== null && typeof payload === 'object') {
824
- const record = payload as Record<string, unknown>
825
- const error = record.error
826
- if (error !== null && typeof error === 'object') {
827
- const message = (error as Record<string, unknown>).message
828
- if (typeof message === 'string' && message !== '') return message
829
- }
830
- if (typeof record.message === 'string' && record.message !== '') return record.message
831
- if (typeof record.error === 'string' && record.error !== '') return record.error
832
- }
833
- return `上游接口拒绝请求(HTTP ${status})`
834
- }
835
-
836
- /** File extension for a MIME type (multipart reference image). */
837
- function extensionOf(mime: string): string {
838
- switch (mime.split(';')[0]!.trim()) {
839
- case 'image/jpeg': return 'jpg'
840
- case 'image/webp': return 'webp'
841
- case 'image/gif': return 'gif'
842
- case 'image/png':
843
- default: return 'png'
844
- }
845
- }
1
+ /**
2
+ * Upstream proxy engine: forwards a generate request to the configured
3
+ * OpenAI-compatible image endpoint (/images/generations for text-to-image,
4
+ * /images/edits for image-to-image) and normalizes the response to base64
5
+ * images so the browser never fetches the upstream itself.
6
+ *
7
+ * Framework-free (no cordis imports) so the route layer and tests can drive
8
+ * it directly.
9
+ */
10
+
11
+ import type { GeneratedImage, GenerateRequest, GenerateResult } from './protocol.ts'
12
+ import { detectImageMime } from './image-format.ts'
13
+ import { modelFamily, promptCharLimit } from './model-catalog.ts'
14
+
15
+ /** The upstream credentials the panel's settings card configures. */
16
+ export interface UpstreamConfig {
17
+ /** Base URL of the OpenAI-compatible endpoint, e.g. https://api.openai.com/v1 */
18
+ apiUrl: string
19
+ /** Bearer API key. */
20
+ apiKey: string
21
+ }
22
+
23
+ /** A generation failure with a user-presentable message. */
24
+ export class ImageGenError extends Error {
25
+ /** Stable wire code. */
26
+ readonly code: string
27
+
28
+ constructor(message: string, code = 'generate-failed') {
29
+ super(message)
30
+ this.name = 'ImageGenError'
31
+ this.code = code
32
+ }
33
+ }
34
+
35
+ /** Total budget for the upstream generation call (image models are slow). */
36
+ const UPSTREAM_TIMEOUT_MS = 240_000
37
+
38
+ /** Budget for downloading one result image URL. */
39
+ const IMAGE_FETCH_TIMEOUT_MS = 60_000
40
+
41
+ /** Cap on the reference image payload (edit mode), in bytes. */
42
+ const MAX_EDIT_IMAGE_BYTES = 10 * 1024 * 1024
43
+
44
+ /** Sizes dall-e-3 accepts; anything else falls back to its square default. */
45
+ const DALLE3_SIZES = new Set(['1024x1024', '1792x1024', '1024x1792'])
46
+
47
+ /** The wire model id for a request: `upstream` (host-filled alias mapping)
48
+ * wins, then the alias, then the family default. */
49
+ function wireModel(request: GenerateRequest): string {
50
+ const upstream = request.upstream?.trim()
51
+ if (upstream !== undefined && upstream !== '') return upstream
52
+ const alias = request.model.trim()
53
+ return alias === '' ? 'gpt-image-2' : alias
54
+ }
55
+
56
+ /** Whether the model is an xAI Grok Imagine model (grok-imagine-image,
57
+ * grok-imagine-image-2.0, …). Grok Imagine speaks JSON on both endpoints
58
+ * and exposes its own aspect-ratio / response-format knobs instead of the
59
+ * OpenAI size/quality/detail passthrough. */
60
+ function isGrokImagine(model: string): boolean {
61
+ return modelFamily(model) === 'grok'
62
+ }
63
+
64
+ /** Whether the model belongs to the Google Nano Banana family (nanobanana2 /
65
+ * nanobanana2-lite / nanobanana-pro, plus the official Gemini image IDs the
66
+ * gateways expose). OpenAI-compatible gateways serve these with their own
67
+ * aspect_ratio / image_size vocabulary instead of the OpenAI size/quality
68
+ * passthrough. */
69
+ function isNanoBanana(model: string): boolean {
70
+ return modelFamily(model) === 'nanobanana'
71
+ }
72
+
73
+ /** Whether the model belongs to the ByteDance Seedream family (seedream-5.0-pro,
74
+ * seedream-5.0, seedream-4.x, doubao-seedream-…). OpenAI-compatible gateways
75
+ * serve Seedream through a unified generate-and-edit architecture:
76
+ * generation AND editing both go to /images/generations and reference images
77
+ * are a JSON URL / data-URL array. */
78
+ function isSeedream(model: string): boolean {
79
+ return modelFamily(model) === 'seedream'
80
+ }
81
+
82
+ /** Whether the model uses the official Zhipu image-generation contract. */
83
+ function isZhipuImage(model: string): boolean {
84
+ return modelFamily(model) === 'zhipu'
85
+ }
86
+
87
+ /** Whether the model is Alibaba Qwen-Image, which speaks the DashScope native
88
+ * multimodal-generation contract (NOT OpenAI-compatible): a chat-style
89
+ * messages body, `宽*高` pixel sizes, and image URLs in the reply content. */
90
+ function isQwenImage(model: string): boolean {
91
+ return modelFamily(model) === 'qwen'
92
+ }
93
+
94
+ /** Whether the model is MiniMax image-01, which speaks MiniMax's native
95
+ * `/image_generation` contract (NOT OpenAI-compatible): `aspect_ratio`,
96
+ * `subject_reference` for image-to-image, `data.image_base64[]` results, and
97
+ * errors reported as HTTP 200 + non-zero `base_resp.status_code`. */
98
+ function isMiniMaxImage(model: string): boolean {
99
+ return modelFamily(model) === 'minimax'
100
+ }
101
+
102
+ /** Aspect ratios MiniMax image-01 documents (the panel vocabulary is a superset). */
103
+ const MINIMAX_RATIOS = new Set(['1:1', '16:9', '4:3', '3:2', '2:3', '3:4', '9:16', '21:9'])
104
+
105
+ /** MiniMax caps one request at 9 images. */
106
+ const MINIMAX_MAX_N = 9
107
+
108
+ /** MiniMax image-01 rejects prompts of 1500+ characters; the exact number
109
+ * lives in model-catalog.ts so the panel counter and this guard agree. */
110
+
111
+ function isGlmImage(model: string): boolean {
112
+ return /^glm-image(?:-|$)/i.test(model.trim())
113
+ }
114
+
115
+ /** Whether this is the official Volcengine Ark model naming convention. */
116
+ function isVolcSeedream(model: string): boolean {
117
+ return /^doubao-seedream(?:-|$)/i.test(model.trim())
118
+ }
119
+
120
+ /** Volcengine uses `size` for the output tier, not the panel's aspect ratio. */
121
+ function seedreamSize(quality: string): string {
122
+ // Seedream 5.0 Pro currently caps at 2K; keep 4K requests valid by
123
+ // degrading them to the highest supported tier instead of sending 4K.
124
+ if (quality === '1k') return '1K'
125
+ return '2K'
126
+ }
127
+
128
+ /** The panel's aspect ratios mapped to Qwen-Image's `宽*高` pixel sizes.
129
+ * The classic series (qwen-image / -plus / -max) documents this fixed list;
130
+ * 2.0 / 3.0-series models accept any size within their pixel budget and
131
+ * recommend the larger set. */
132
+ const QWEN_SIZE_CLASSIC: Readonly<Record<string, string>> = {
133
+ '16:9': '1664*928',
134
+ '21:9': '1664*928',
135
+ '4:3': '1472*1104',
136
+ '3:2': '1472*1104',
137
+ '1:1': '1328*1328',
138
+ '3:4': '1104*1472',
139
+ '2:3': '1104*1472',
140
+ '9:16': '928*1664',
141
+ }
142
+
143
+ const QWEN_SIZE_HD: Readonly<Record<string, string>> = {
144
+ '16:9': '2688*1536',
145
+ '21:9': '2688*1536',
146
+ '4:3': '2368*1728',
147
+ '3:2': '2368*1728',
148
+ '1:1': '2048*2048',
149
+ '3:4': '1728*2368',
150
+ '2:3': '1728*2368',
151
+ '9:16': '1536*2688',
152
+ }
153
+
154
+ /** Versioned ids (qwen-image-2.0 / -3.0-pro / …) take the large size set. */
155
+ function isVersionedQwenImage(model: string): boolean {
156
+ return /^qwen-image-\d+\.\d/i.test(model.trim())
157
+ }
158
+
159
+ function qwenSize(model: string, ratio: string): string | undefined {
160
+ if (ratio === '' || ratio === 'auto') return undefined
161
+ return (isVersionedQwenImage(model) ? QWEN_SIZE_HD : QWEN_SIZE_CLASSIC)[ratio]
162
+ }
163
+
164
+ /** The panel's aspect ratios mapped to the closest OpenAI pixel size
165
+ * (gpt-image-2 / generic OpenAI-compatible endpoints). */
166
+ const OPENAI_SIZE_BY_RATIO: Readonly<Record<string, string>> = {
167
+ '1:1': '1024x1024',
168
+ '3:4': '1024x1536',
169
+ '4:3': '1536x1024',
170
+ '9:16': '1024x1792',
171
+ '2:3': '1024x1536',
172
+ '3:2': '1536x1024',
173
+ '16:9': '1792x1024',
174
+ '21:9': '1792x1024',
175
+ }
176
+
177
+ /** Panel ratios that need renaming for a model's vocabulary. Grok documents
178
+ * 20:9 as its ultra-wide ratio, so the panel's 21:9 label is sent as 20:9. */
179
+ const GROK_ASPECT_ALIASES: Readonly<Record<string, string>> = {
180
+ '21:9': '20:9',
181
+ }
182
+
183
+ /**
184
+ * One request-scoped timeout that is cleared as soon as its fetch settles.
185
+ * AbortSignal.timeout() cannot be disposed early; using it inside a long-lived
186
+ * task queue leaves an otherwise idle Node process holding every timeout.
187
+ */
188
+ function requestSignal(source: AbortSignal | undefined, timeoutMs: number): { signal: AbortSignal; dispose: () => void } {
189
+ const controller = new AbortController()
190
+ const abortFromSource = () => { controller.abort(source?.reason) }
191
+ if (source?.aborted === true) abortFromSource()
192
+ else source?.addEventListener('abort', abortFromSource, { once: true })
193
+ const timeout = setTimeout(() => { controller.abort(new DOMException('The operation timed out.', 'TimeoutError')) }, timeoutMs)
194
+ timeout.unref()
195
+ return {
196
+ signal: controller.signal,
197
+ dispose: () => {
198
+ clearTimeout(timeout)
199
+ source?.removeEventListener('abort', abortFromSource)
200
+ },
201
+ }
202
+ }
203
+
204
+ /** Whether an error was produced by a requestSignal budget timeout. These can
205
+ * surface from the fetch call itself or from reading the response body, so the
206
+ * budget must stay armed until the body has been consumed. */
207
+ function isBudgetTimeout(error: unknown): boolean {
208
+ return (error instanceof DOMException || error instanceof Error) && error.name === 'TimeoutError'
209
+ }
210
+
211
+ /** Content-type extension hints for URL-fetched images. */function mimeOfExtension(path: string): string | undefined {
212
+ const match = /\.([a-z0-9]+)$/i.exec(path)
213
+ if (match === null) return undefined
214
+ switch (match[1]!.toLowerCase()) {
215
+ case 'png': return 'image/png'
216
+ case 'jpg':
217
+ case 'jpeg': return 'image/jpeg'
218
+ case 'webp': return 'image/webp'
219
+ case 'gif': return 'image/gif'
220
+ default: return undefined
221
+ }
222
+ }
223
+
224
+ /** Parse `data:<mime>;base64,<payload>` into its parts; undefined when malformed. */
225
+ function parseDataUrl(dataUrl: string): { mime: string; base64: string } | undefined {
226
+ const match = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(dataUrl.trim())
227
+ if (match === null || match[3] === undefined) return undefined
228
+ if (match[2] === undefined) {
229
+ // Plain (non-base64) data URLs are not supported for reference images.
230
+ return undefined
231
+ }
232
+ return { mime: match[1] ?? 'application/octet-stream', base64: match[3] }
233
+ }
234
+
235
+ /** Strip a data: prefix from an upstream b64 payload if a gateway added one. */
236
+ function bareBase64(value: string): string {
237
+ const parsed = parseDataUrl(value)
238
+ return parsed !== undefined && parsed.base64 !== undefined ? parsed.base64 : value
239
+ }
240
+
241
+ /** Whether a result URL carries cloud-storage signing credentials. */
242
+ function isPresignedUrl(value: string): boolean {
243
+ let url: URL
244
+ try {
245
+ url = new URL(value)
246
+ } catch {
247
+ return false
248
+ }
249
+ const params = new Set(Array.from(url.searchParams.keys(), key => key.toLowerCase()))
250
+ if (params.has('x-goog-signature') || params.has('x-goog-credential')) return true
251
+ if (params.has('x-amz-signature') || params.has('x-amz-credential')) return true
252
+ return params.has('signature') && (
253
+ params.has('expires') || params.has('googleaccessid') || params.has('awsaccesskeyid')
254
+ )
255
+ }
256
+
257
+ /**
258
+ * Whether a result URL lives on the same origin as the configured API base.
259
+ * The upstream Bearer key is only ever forwarded to this origin: a provider
260
+ * (or a compromised relay) that hands back an image URL on a foreign host
261
+ * must not be able to harvest the key through that download.
262
+ */
263
+ function isSameOriginAsApi(value: string, apiUrl: string): boolean {
264
+ try {
265
+ return new URL(value).origin === new URL(apiUrl).origin
266
+ } catch {
267
+ return false
268
+ }
269
+ }
270
+
271
+ /** Clamp the requested image count into the API-accepted range. */
272
+ function clampCount(n: number): number {
273
+ if (!Number.isFinite(n)) return 1
274
+ return Math.min(4, Math.max(1, Math.round(n)))
275
+ }
276
+
277
+ /** Pick the effective per-model request parameters. Never includes `n`: the
278
+ * batch parameter is rejected by Responses-API-based gateways (tools[0].n),
279
+ * so the count is satisfied by parallel single-image requests instead. */
280
+ function effectiveParams(request: GenerateRequest): {
281
+ model: string
282
+ size?: string
283
+ quality?: string
284
+ detail?: string
285
+ aspect_ratio?: string
286
+ image_size?: string
287
+ resolution?: string
288
+ response_format?: string
289
+ } {
290
+ const model = wireModel(request)
291
+ // dall-e-3 has no quality/detail knobs and only produces one image.
292
+ if (model === 'dall-e-3') {
293
+ const pixel = OPENAI_SIZE_BY_RATIO[request.size]
294
+ const size = (pixel !== undefined && DALLE3_SIZES.has(pixel)) ? pixel : '1024x1024'
295
+ return { model, size }
296
+ }
297
+ // Grok Imagine: the panel's aspect ratios are sent as-is (21:9 aliased to
298
+ // the documented 20:9), the clarity tiers become the resolution parameter
299
+ // (the API documents 1k / 2k only, so 4k falls back to 2k), and base64
300
+ // output keeps the temporary signed result URLs from expiring before the
301
+ // host downloads them.
302
+ if (isGrokImagine(model)) {
303
+ return {
304
+ model,
305
+ ...request.size !== '' && request.size !== 'auto'
306
+ ? { aspect_ratio: GROK_ASPECT_ALIASES[request.size] ?? request.size }
307
+ : {},
308
+ ...request.quality !== '' && request.quality !== 'auto'
309
+ ? { resolution: request.quality === '4k' ? '2k' : request.quality }
310
+ : {},
311
+ response_format: 'b64_json',
312
+ }
313
+ }
314
+ // Google Nano Banana: the panel's aspect ratios are sent as-is (the family
315
+ // documents 1:1 … 21:9 natively), the clarity tiers become image_size
316
+ // (1K / 2K / 4K — Gen 1 and 2-Lite are 1K-only upstream, but which gateway
317
+ // rejects higher tiers is its own call), and base64 output keeps any signed
318
+ // result URLs from expiring before the host downloads them.
319
+ if (isNanoBanana(model)) {
320
+ return {
321
+ model,
322
+ ...request.size !== '' && request.size !== 'auto'
323
+ ? { aspect_ratio: request.size }
324
+ : {},
325
+ ...request.quality !== '' && request.quality !== 'auto'
326
+ ? { image_size: request.quality.toUpperCase() }
327
+ : {},
328
+ response_format: 'b64_json',
329
+ }
330
+ }
331
+ // ByteDance Seedream: the official Volcengine Ark API uses `size` for the
332
+ // resolution tier (1K / 2K), not the panel's aspect-ratio value. It returns
333
+ // temporary URLs, so ask Ark for URL output and let the host download it.
334
+ // Other compatible gateways retain the base64 response fallback.
335
+ if (isSeedream(model)) {
336
+ return {
337
+ model,
338
+ size: seedreamSize(request.quality),
339
+ response_format: isVolcSeedream(model) ? 'url' : 'b64_json',
340
+ }
341
+ }
342
+ // Zhipu's official image API accepts OpenAI-style JSON but uses its own
343
+ // quality vocabulary. GLM-Image currently supports hd only; CogView uses
344
+ // the standard tier. Size remains a valid custom pixel size for both.
345
+ if (isZhipuImage(model)) {
346
+ return {
347
+ model,
348
+ ...request.size !== '' && request.size !== 'auto' && OPENAI_SIZE_BY_RATIO[request.size] !== undefined
349
+ ? { size: OPENAI_SIZE_BY_RATIO[request.size] }
350
+ : {},
351
+ quality: isGlmImage(model) ? 'hd' : 'standard',
352
+ }
353
+ }
354
+ // OpenAI-compatible endpoints: nearest pixel size, clarity tiers mapped to
355
+ // the quality levels (1k→low / 2k→medium / 4k→high), detail passthrough.
356
+ return {
357
+ model,
358
+ ...request.size !== '' && request.size !== 'auto' && OPENAI_SIZE_BY_RATIO[request.size] !== undefined
359
+ ? { size: OPENAI_SIZE_BY_RATIO[request.size] }
360
+ : {},
361
+ ...request.quality === '1k' ? { quality: 'low' } : {},
362
+ ...request.quality === '2k' ? { quality: 'medium' } : {},
363
+ ...request.quality === '4k' ? { quality: 'high' } : {},
364
+ ...request.detail !== '' ? { detail: request.detail } : {},
365
+ }
366
+ }
367
+
368
+ /** How many single-image requests to issue for the requested image count. */
369
+ function effectiveCount(request: GenerateRequest): number {
370
+ const model = wireModel(request)
371
+ if (model === 'dall-e-3') return 1
372
+ return clampCount(request.n)
373
+ }
374
+
375
+ /** Normalize one upstream data item into a base64 image. */
376
+ async function normalizeItem(
377
+ item: Record<string, unknown>,
378
+ upstream: UpstreamConfig,
379
+ signal?: AbortSignal,
380
+ ): Promise<{ b64: string; mime: string; revisedPrompt?: string }> {
381
+ const revisedPrompt = typeof item.revised_prompt === 'string' ? item.revised_prompt : undefined
382
+ if (typeof item.b64_json === 'string' && item.b64_json.trim() !== '') {
383
+ const b64 = bareBase64(item.b64_json)
384
+ if (b64.trim() !== '') {
385
+ return { b64, mime: detectImageMime(Buffer.from(b64, 'base64')) ?? 'image/png', revisedPrompt }
386
+ }
387
+ }
388
+ if (typeof item.url !== 'string' || item.url === '') {
389
+ throw new ImageGenError('upstream image item has neither b64_json nor url')
390
+ }
391
+ const url = item.url
392
+ if (url.startsWith('data:')) {
393
+ const parsed = parseDataUrl(url)
394
+ if (parsed === undefined) throw new ImageGenError('upstream returned a malformed data: url')
395
+ return { b64: parsed.base64, mime: detectImageMime(Buffer.from(parsed.base64, 'base64')) ?? parsed.mime, revisedPrompt }
396
+ }
397
+ const budget = requestSignal(signal, IMAGE_FETCH_TIMEOUT_MS)
398
+ try {
399
+ let response: Response
400
+ try {
401
+ // Forward the key only to the API's own origin, and never to a
402
+ // presigned object-storage URL (which carries its own credentials).
403
+ const forwardKey = upstream.apiKey !== ''
404
+ && !isPresignedUrl(url)
405
+ && isSameOriginAsApi(url, upstream.apiUrl)
406
+ response = await fetch(url, {
407
+ ...forwardKey
408
+ ? { headers: { authorization: `Bearer ${upstream.apiKey}` } }
409
+ : {},
410
+ signal: budget.signal,
411
+ })
412
+ } catch (error) {
413
+ throw new ImageGenError(`failed to fetch the generated image url: ${error instanceof Error ? error.message : String(error)}`)
414
+ }
415
+ if (!response.ok) {
416
+ throw new ImageGenError(`failed to fetch the generated image url: HTTP ${response.status}`)
417
+ }
418
+ // Budget stays armed through the body read so a stalled download cannot hang the task.
419
+ const buffer = Buffer.from(await response.arrayBuffer())
420
+ const contentType = response.headers.get('content-type')
421
+ const mime = detectImageMime(buffer)
422
+ ?? (contentType !== null && contentType !== ''
423
+ ? contentType.split(';')[0]!.trim()
424
+ : mimeOfExtension(url) ?? 'image/png')
425
+ return { b64: buffer.toString('base64'), mime, revisedPrompt }
426
+ } finally {
427
+ budget.dispose()
428
+ }
429
+ }
430
+
431
+ /** Expand a provider image item whose URL may be a string or an array. */
432
+ function imageItemsOf(value: unknown): Array<Record<string, unknown>> {
433
+ if (value === null || typeof value !== 'object') return []
434
+ const item = value as Record<string, unknown>
435
+ if (Array.isArray(item.url)) {
436
+ return item.url.filter((url): url is string => typeof url === 'string' && url !== '').map(url => ({ ...item, url }))
437
+ }
438
+ return [item]
439
+ }
440
+
441
+ /** Return the data records from the response shapes shared by sync gateways. */
442
+ function dataRecordsOf(payload: Record<string, unknown>): Array<Record<string, unknown>> | undefined {
443
+ const data = Array.isArray(payload.data)
444
+ ? payload.data
445
+ : payload.data !== null && typeof payload.data === 'object'
446
+ ? [payload.data]
447
+ : Array.isArray(payload.images)
448
+ ? payload.images
449
+ : Array.isArray(payload.output)
450
+ ? payload.output
451
+ : undefined
452
+ if (data === undefined) return undefined
453
+ return data.filter((entry): entry is Record<string, unknown> => entry !== null && typeof entry === 'object')
454
+ }
455
+
456
+ const ASYNC_PENDING_STATUSES = new Set(['submitted', 'pending', 'processing', 'running', 'in_progress', 'queued'])
457
+ const ASYNC_COMPLETED_STATUSES = new Set(['completed', 'succeeded', 'success', 'done'])
458
+ const ASYNC_FAILED_STATUSES = new Set(['failed', 'failure', 'cancelled', 'canceled', 'error'])
459
+ const ASYNC_POLL_MAX_MS = 240_000
460
+ const ASYNC_POLL_REQUEST_TIMEOUT_MS = 30_000
461
+
462
+ /** Read a provider error message from the common nested locations. */
463
+ function asyncErrorMessage(payload: unknown, fallback: string): string {
464
+ if (payload !== null && typeof payload === 'object') {
465
+ const record = payload as Record<string, unknown>
466
+ const candidates: unknown[] = [record.message, record.error]
467
+ const data = record.data
468
+ const entries = Array.isArray(data) ? data : [data]
469
+ for (const entry of entries) {
470
+ if (entry === null || typeof entry !== 'object') continue
471
+ const item = entry as Record<string, unknown>
472
+ candidates.push(item.message, item.error)
473
+ const nested = item.error
474
+ if (nested !== null && typeof nested === 'object') candidates.push((nested as Record<string, unknown>).message)
475
+ }
476
+ for (const candidate of candidates) {
477
+ if (typeof candidate === 'string' && candidate.trim() !== '') return candidate
478
+ if (candidate !== null && typeof candidate === 'object') {
479
+ const message = (candidate as Record<string, unknown>).message
480
+ if (typeof message === 'string' && message.trim() !== '') return message
481
+ }
482
+ }
483
+ }
484
+ return fallback
485
+ }
486
+
487
+ /** Wait between async-provider polls, but wake immediately when cancelled. */
488
+ function waitForPoll(ms: number, signal?: AbortSignal): Promise<void> {
489
+ return new Promise((resolve, reject) => {
490
+ if (signal?.aborted === true) {
491
+ reject(signal.reason ?? new DOMException('The operation was aborted.', 'AbortError'))
492
+ return
493
+ }
494
+ const onAbort = () => {
495
+ clearTimeout(timer)
496
+ signal?.removeEventListener('abort', onAbort)
497
+ reject(signal?.reason ?? new DOMException('The operation was aborted.', 'AbortError'))
498
+ }
499
+ const done = () => {
500
+ signal?.removeEventListener('abort', onAbort)
501
+ resolve()
502
+ }
503
+ const timer = setTimeout(done, ms)
504
+ timer.unref()
505
+ signal?.addEventListener('abort', onAbort, { once: true })
506
+ })
507
+ }
508
+
509
+ /**
510
+ * Poll one apib/apimart-style provider task until it yields image records.
511
+ * The total deadline is shared by every poll and the final image downloads;
512
+ * local task cancellation propagates through every request and sleep.
513
+ */
514
+ async function pollAsyncTask(
515
+ baseUrl: string,
516
+ upstream: UpstreamConfig,
517
+ taskId: string,
518
+ signal?: AbortSignal,
519
+ ): Promise<Array<Record<string, unknown>>> {
520
+ const deadline = Date.now() + ASYNC_POLL_MAX_MS
521
+ let delay = 1000
522
+ while (Date.now() < deadline) {
523
+ const remaining = deadline - Date.now()
524
+ const budget = requestSignal(signal, Math.min(ASYNC_POLL_REQUEST_TIMEOUT_MS, remaining))
525
+ try {
526
+ let response: Response
527
+ try {
528
+ response = await fetch(`${baseUrl}/tasks/${encodeURIComponent(taskId)}`, {
529
+ method: 'GET',
530
+ headers: { authorization: `Bearer ${upstream.apiKey.trim()}` },
531
+ signal: budget.signal,
532
+ })
533
+ } catch (error) {
534
+ if (signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
535
+ if (isBudgetTimeout(error)) throw new ImageGenError('上游异步任务轮询超时', 'upstream-timeout')
536
+ throw new ImageGenError(`无法轮询上游异步任务:${error instanceof Error ? error.message : String(error)}`, 'upstream-unreachable')
537
+ }
538
+ let payload: unknown
539
+ try {
540
+ payload = await response.json()
541
+ } catch (error) {
542
+ if (isBudgetTimeout(error)) throw new ImageGenError('上游异步任务轮询超时', 'upstream-timeout')
543
+ throw new ImageGenError(`上游任务接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
544
+ }
545
+ if (!response.ok || payload === null || typeof payload !== 'object') {
546
+ throw new ImageGenError(asyncErrorMessage(payload, `上游任务轮询失败(HTTP ${response.status})`), 'upstream-rejected')
547
+ }
548
+ const record = payload as Record<string, unknown>
549
+ const data = record.data
550
+ const statusRecord = Array.isArray(data) ? data[0] : data !== null && typeof data === 'object' ? data : record
551
+ const statusValue = statusRecord !== null && typeof statusRecord === 'object'
552
+ ? (statusRecord as Record<string, unknown>).status
553
+ : undefined
554
+ const status = typeof statusValue === 'string' ? statusValue.toLowerCase() : ''
555
+ if (ASYNC_FAILED_STATUSES.has(status)) {
556
+ throw new ImageGenError(asyncErrorMessage(payload, `上游异步任务失败(${status || 'unknown'})`), 'upstream-rejected')
557
+ }
558
+ const nested = statusRecord !== null && typeof statusRecord === 'object' ? statusRecord as Record<string, unknown> : record
559
+ const result = nested.result ?? (nested.output !== null && typeof nested.output === 'object' ? (nested.output as Record<string, unknown>).result : undefined) ?? record.result
560
+ const resultRecord = result !== null && typeof result === 'object' ? result as Record<string, unknown> : undefined
561
+ const images = resultRecord?.images ?? (nested.images ?? record.images)
562
+ if (ASYNC_COMPLETED_STATUSES.has(status) || images !== undefined) {
563
+ const items = Array.isArray(images) ? images.flatMap(imageItemsOf) : imageItemsOf(images)
564
+ if (items.length > 0) return items
565
+ if (ASYNC_COMPLETED_STATUSES.has(status)) throw new ImageGenError('上游异步任务完成但没有图片结果', 'upstream-empty')
566
+ }
567
+ if (status !== '' && !ASYNC_PENDING_STATUSES.has(status) && !ASYNC_COMPLETED_STATUSES.has(status)) {
568
+ throw new ImageGenError(`上游返回了未知异步任务状态:${status}`, 'upstream-invalid')
569
+ }
570
+ } finally {
571
+ budget.dispose()
572
+ }
573
+ await waitForPoll(Math.min(delay, Math.max(1, deadline - Date.now())), signal)
574
+ delay = Math.min(5000, delay * 2)
575
+ }
576
+ throw new ImageGenError('上游异步任务轮询超时(240 秒)', 'upstream-timeout')
577
+ }
578
+
579
+ /**
580
+ * Issue one single-image request (never sends `n`). The response is kept as a
581
+ * list so a gateway that happens to return several images per call still works.
582
+ */
583
+ async function requestOneImage(
584
+ baseUrl: string,
585
+ upstream: UpstreamConfig,
586
+ request: GenerateRequest,
587
+ params: ReturnType<typeof effectiveParams>,
588
+ signal?: AbortSignal,
589
+ ): Promise<GeneratedImage[]> {
590
+ const headers: Record<string, string> = {
591
+ authorization: `Bearer ${upstream.apiKey.trim()}`,
592
+ }
593
+ let body: BodyInit
594
+ if (request.mode === 'edit') {
595
+ if (typeof request.image !== 'string' || request.image === '') {
596
+ throw new ImageGenError('图生图需要上传参考图片', 'edit-image-missing')
597
+ }
598
+ const decodeReference = (dataUrl: string): { bytes: Buffer; mime: string; filename: string } => {
599
+ const parsed = parseDataUrl(dataUrl)
600
+ if (parsed === undefined) throw new ImageGenError('参考图片格式无效', 'edit-image-invalid')
601
+ let bytes: Buffer
602
+ try {
603
+ bytes = Buffer.from(parsed.base64, 'base64')
604
+ } catch {
605
+ throw new ImageGenError('参考图片数据无法解码', 'edit-image-invalid')
606
+ }
607
+ if (bytes.byteLength > MAX_EDIT_IMAGE_BYTES) {
608
+ throw new ImageGenError('参考图片超过 10MB 上限', 'edit-image-too-large')
609
+ }
610
+ return { bytes, mime: parsed.mime, filename: `reference.${extensionOf(parsed.mime)}` }
611
+ }
612
+ const primary = decodeReference(request.image)
613
+ const extras = (request.images ?? [])
614
+ .filter(img => typeof img === 'string' && img !== '')
615
+ .slice(0, 4)
616
+ .map(decodeReference)
617
+ // Grok Imagine /images/edits takes a JSON image_url object (a base64 data
618
+ // URI is accepted) instead of OpenAI's multipart form-data upload.
619
+ if (isGrokImagine(params.model)) {
620
+ headers['content-type'] = 'application/json'
621
+ body = JSON.stringify({
622
+ model: params.model,
623
+ prompt: request.prompt,
624
+ image: { url: request.image, type: 'image_url' },
625
+ ...params.aspect_ratio !== undefined ? { aspect_ratio: params.aspect_ratio } : {},
626
+ response_format: 'b64_json',
627
+ })
628
+ } else if (isNanoBanana(params.model)) {
629
+ // Nano Banana OpenAI-compatible gateways accept the standard multipart
630
+ // edit upload, with the family's own aspect_ratio / image_size knobs.
631
+ const form = new FormData()
632
+ form.append('image', new Blob([primary.bytes], { type: primary.mime }), primary.filename)
633
+ form.append('prompt', request.prompt)
634
+ form.append('model', params.model)
635
+ if (params.aspect_ratio !== undefined) form.append('aspect_ratio', params.aspect_ratio)
636
+ if (params.image_size !== undefined) form.append('image_size', params.image_size)
637
+ body = form
638
+ } else if (isSeedream(params.model)) {
639
+ // Seedream unifies generation and editing on /images/generations; the
640
+ // reference image is a JSON URL / data-URL array, never multipart, and
641
+ // the protocol natively accepts several references.
642
+ headers['content-type'] = 'application/json'
643
+ body = JSON.stringify({
644
+ model: params.model,
645
+ prompt: request.prompt,
646
+ image: [request.image, ...(request.images ?? []).filter(img => typeof img === 'string' && img !== '').slice(0, 4)],
647
+ ...params.size !== undefined ? { size: params.size } : {},
648
+ ...params.resolution !== undefined ? { resolution: params.resolution } : {},
649
+ response_format: isVolcSeedream(params.model) ? 'url' : 'b64_json',
650
+ })
651
+ } else {
652
+ const form = new FormData()
653
+ if (extras.length > 0) {
654
+ // OpenAI-style multi-reference upload: repeat the image[] field so
655
+ // every connected canvas reference reaches the gateway.
656
+ for (const [index, reference] of [primary, ...extras].entries()) {
657
+ form.append('image[]', new Blob([reference.bytes], { type: reference.mime }), `reference-${index}.${extensionOf(reference.mime)}`)
658
+ }
659
+ } else {
660
+ form.append('image', new Blob([primary.bytes], { type: primary.mime }), primary.filename)
661
+ }
662
+ form.append('prompt', request.prompt)
663
+ form.append('model', params.model)
664
+ if (params.size !== undefined) form.append('size', params.size)
665
+ if (params.quality !== undefined) form.append('quality', params.quality)
666
+ if (params.detail !== undefined) form.append('detail', params.detail)
667
+ body = form
668
+ }
669
+ } else {
670
+ headers['content-type'] = 'application/json'
671
+ body = JSON.stringify({ prompt: request.prompt, ...params } as Record<string, unknown>)
672
+ }
673
+
674
+ const budget = requestSignal(signal, UPSTREAM_TIMEOUT_MS)
675
+ try {
676
+ let response: Response
677
+ try {
678
+ // Seedream has no /images/edits endpoint: both modes hit generations.
679
+ const endpoint = request.mode === 'edit' && !isSeedream(params.model)
680
+ ? '/images/edits'
681
+ : '/images/generations'
682
+ response = await fetch(`${baseUrl}${endpoint}`, {
683
+ method: 'POST',
684
+ headers,
685
+ body,
686
+ signal: budget.signal,
687
+ })
688
+ } catch (error) {
689
+ if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
690
+ if (signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
691
+ throw new ImageGenError(`无法连接上游接口:${error instanceof Error ? error.message : String(error)}`, 'upstream-unreachable')
692
+ }
693
+
694
+ let payload: unknown
695
+ try {
696
+ // The budget stays armed through the body read: a gateway that returns
697
+ // headers but never completes the body must not hang the task forever.
698
+ payload = await response.json()
699
+ } catch (error) {
700
+ if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
701
+ if (signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
702
+ throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
703
+ }
704
+ if (!response.ok || payload === null || typeof payload !== 'object') {
705
+ throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
706
+ }
707
+
708
+ const record = payload as Record<string, unknown>
709
+ const data = dataRecordsOf(record)
710
+ if (data === undefined) {
711
+ throw new ImageGenError('上游响应缺少 data 数组', 'upstream-invalid')
712
+ }
713
+ if (data.length === 0) {
714
+ throw new ImageGenError('上游返回了 0 张图片', 'upstream-empty')
715
+ }
716
+ const asyncEntries = data.filter(entry => typeof entry.task_id === 'string' && entry.task_id.trim() !== '')
717
+ if (asyncEntries.length > 0) {
718
+ const asyncRecords = (await Promise.all(asyncEntries.map(entry => pollAsyncTask(baseUrl, upstream, entry.task_id as string, signal)))).flat()
719
+ if (asyncRecords.length === 0) throw new ImageGenError('上游异步任务完成但没有图片结果', 'upstream-empty')
720
+ return Promise.all(asyncRecords.flatMap(imageItemsOf).map(item => normalizeItem(item, upstream, signal)))
721
+ }
722
+ return Promise.all(data.flatMap(imageItemsOf).map(item => normalizeItem(item, upstream, signal)))
723
+ } finally {
724
+ budget.dispose()
725
+ }
726
+ }
727
+
728
+ /**
729
+ * Qwen-Image (DashScope native multimodal-generation): one chat-style request
730
+ * carries the prompt (plus the reference image for edit mode) and answers
731
+ * synchronously with image URLs in the reply content. The versioned series
732
+ * batches natively (n ≤ 6; the panel caps at 4), the classic series is
733
+ * single-image per call.
734
+ */
735
+ async function generateQwenImage(
736
+ baseUrl: string,
737
+ upstream: UpstreamConfig,
738
+ request: GenerateRequest,
739
+ options: { signal?: AbortSignal },
740
+ ): Promise<GenerateResult> {
741
+ const model = wireModel(request)
742
+ const content: Array<Record<string, unknown>> = []
743
+ if (request.mode === 'edit') {
744
+ if (typeof request.image !== 'string' || request.image === '') {
745
+ throw new ImageGenError('图生图需要上传参考图片', 'edit-image-missing')
746
+ }
747
+ // DashScope multimodal messages take the reference image as a content
748
+ // item; a base64 data URI rides in the same field as a remote URL.
749
+ const parsed = parseDataUrl(request.image)
750
+ if (parsed === undefined) throw new ImageGenError('参考图片格式无效', 'edit-image-invalid')
751
+ const bytes = Buffer.from(parsed.base64, 'base64')
752
+ if (bytes.byteLength > MAX_EDIT_IMAGE_BYTES) {
753
+ throw new ImageGenError('参考图片超过 10MB 上限', 'edit-image-too-large')
754
+ }
755
+ content.push({ image: request.image })
756
+ }
757
+ content.push({ text: request.prompt })
758
+
759
+ const batchable = isVersionedQwenImage(model)
760
+ const count = batchable ? clampCount(request.n) : 1
761
+ const size = qwenSize(model, request.size)
762
+ const body = {
763
+ model,
764
+ input: { messages: [{ role: 'user', content }] },
765
+ parameters: {
766
+ ...size !== undefined ? { size } : {},
767
+ ...count > 1 ? { n: count } : {},
768
+ },
769
+ }
770
+
771
+ const budget = requestSignal(options.signal, UPSTREAM_TIMEOUT_MS)
772
+ try {
773
+ let response: Response
774
+ try {
775
+ response = await fetch(`${baseUrl}/services/aigc/multimodal-generation/generation`, {
776
+ method: 'POST',
777
+ headers: {
778
+ authorization: `Bearer ${upstream.apiKey.trim()}`,
779
+ 'content-type': 'application/json',
780
+ },
781
+ body: JSON.stringify(body),
782
+ signal: budget.signal,
783
+ })
784
+ } catch (error) {
785
+ if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
786
+ if (options.signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
787
+ throw new ImageGenError(`无法连接上游接口:${error instanceof Error ? error.message : String(error)}`, 'upstream-unreachable')
788
+ }
789
+
790
+ let payload: unknown
791
+ try {
792
+ // Budget stays armed through the body read (same rationale as the OpenAI path).
793
+ payload = await response.json()
794
+ } catch (error) {
795
+ if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
796
+ if (options.signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
797
+ throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
798
+ }
799
+ if (!response.ok || payload === null || typeof payload !== 'object') {
800
+ throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
801
+ }
802
+
803
+ // output.choices[].message.content[] mixes text and { image: url } items.
804
+ const record = payload as Record<string, unknown>
805
+ const output = record.output as Record<string, unknown> | undefined
806
+ const choices = output !== undefined && Array.isArray(output.choices) ? output.choices : []
807
+ const urls: string[] = []
808
+ for (const choice of choices) {
809
+ const message = choice !== null && typeof choice === 'object'
810
+ ? (choice as Record<string, unknown>).message
811
+ : undefined
812
+ const items = message !== null && typeof message === 'object' && Array.isArray((message as Record<string, unknown>).content)
813
+ ? (message as Record<string, unknown>).content as unknown[]
814
+ : []
815
+ for (const item of items) {
816
+ if (item !== null && typeof item === 'object') {
817
+ const image = (item as Record<string, unknown>).image
818
+ if (typeof image === 'string' && image !== '') urls.push(image)
819
+ }
820
+ }
821
+ }
822
+ if (urls.length === 0) {
823
+ throw new ImageGenError('上游响应缺少图片内容', 'upstream-empty')
824
+ }
825
+ const images = await Promise.all(urls.map(async url => {
826
+ const normalized = await normalizeItem({ url }, upstream)
827
+ return { b64: normalized.b64, mime: normalized.mime }
828
+ }))
829
+ return { images }
830
+ } finally {
831
+ budget.dispose()
832
+ }
833
+ }
834
+
835
+ /**
836
+ * MiniMax image-01 (native `/image_generation`): one JSON request that batches
837
+ * up to 9 images and returns them inline as base64. Image-to-image rides the
838
+ * `subject_reference` array (a character reference, data URL accepted). The
839
+ * endpoint answers HTTP 200 even on failure, so `base_resp.status_code` is the
840
+ * real verdict.
841
+ */
842
+ async function generateMiniMaxImage(
843
+ baseUrl: string,
844
+ upstream: UpstreamConfig,
845
+ request: GenerateRequest,
846
+ options: { signal?: AbortSignal },
847
+ ): Promise<GenerateResult> {
848
+ const model = wireModel(request)
849
+ // image-01 rejects long prompts upstream ("prompt length must be less than
850
+ // 1500"); fail fast with a clear local message instead of a wasted round trip.
851
+ const promptLimit = promptCharLimit(model)
852
+ if (promptLimit !== null && request.prompt.length >= promptLimit) {
853
+ throw new ImageGenError(`MiniMax image-01 要求提示词少于 ${promptLimit} 字符(当前 ${request.prompt.length}),请精简后重试`, 'prompt-too-long')
854
+ }
855
+ const body: Record<string, unknown> = {
856
+ model,
857
+ prompt: request.prompt,
858
+ response_format: 'base64',
859
+ }
860
+ const ratio = request.size.trim()
861
+ if (ratio !== '' && ratio !== 'auto') {
862
+ if (!MINIMAX_RATIOS.has(ratio)) {
863
+ throw new ImageGenError(`MiniMax image-01 不支持 ${ratio} 宽高比,可选:${Array.from(MINIMAX_RATIOS).join(' / ')}`, 'size-unsupported')
864
+ }
865
+ body.aspect_ratio = ratio
866
+ }
867
+ const count = Math.min(MINIMAX_MAX_N, clampCount(request.n))
868
+ if (count > 1) body.n = count
869
+ if (request.mode === 'edit') {
870
+ if (typeof request.image !== 'string' || request.image === '') {
871
+ throw new ImageGenError('图生图需要上传参考图片', 'edit-image-missing')
872
+ }
873
+ const parsed = parseDataUrl(request.image)
874
+ if (parsed === undefined) throw new ImageGenError('参考图片格式无效', 'edit-image-invalid')
875
+ const bytes = Buffer.from(parsed.base64, 'base64')
876
+ if (bytes.byteLength > MAX_EDIT_IMAGE_BYTES) {
877
+ throw new ImageGenError('参考图片超过 10MB 上限', 'edit-image-too-large')
878
+ }
879
+ // image-01 takes exactly one character reference per request.
880
+ body.subject_reference = [{ type: 'character', image_file: request.image }]
881
+ }
882
+
883
+ const budget = requestSignal(options.signal, UPSTREAM_TIMEOUT_MS)
884
+ try {
885
+ let response: Response
886
+ try {
887
+ response = await fetch(`${baseUrl}/image_generation`, {
888
+ method: 'POST',
889
+ headers: {
890
+ authorization: `Bearer ${upstream.apiKey.trim()}`,
891
+ 'content-type': 'application/json',
892
+ },
893
+ body: JSON.stringify(body),
894
+ signal: budget.signal,
895
+ })
896
+ } catch (error) {
897
+ if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
898
+ if (options.signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
899
+ throw new ImageGenError(`无法连接上游接口:${error instanceof Error ? error.message : String(error)}`, 'upstream-unreachable')
900
+ }
901
+
902
+ let payload: unknown
903
+ try {
904
+ payload = await response.json()
905
+ } catch (error) {
906
+ if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
907
+ if (options.signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
908
+ throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
909
+ }
910
+ if (payload === null || typeof payload !== 'object') {
911
+ throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
912
+ }
913
+ const record = payload as Record<string, unknown>
914
+ const baseResp = record.base_resp
915
+ if (baseResp !== null && typeof baseResp === 'object') {
916
+ const status = (baseResp as Record<string, unknown>).status_code
917
+ if (typeof status === 'number' && status !== 0) {
918
+ const msg = (baseResp as Record<string, unknown>).status_msg
919
+ throw new ImageGenError(
920
+ `MiniMax 拒绝请求(${status}):${typeof msg === 'string' && msg !== '' ? msg : 'unknown error'}`,
921
+ status === 1004 || status === 2049 ? 'upstream-unauthorized' : 'upstream-rejected',
922
+ )
923
+ }
924
+ }
925
+ if (!response.ok) throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
926
+
927
+ const data = record.data
928
+ const b64s = data !== null && typeof data === 'object' && Array.isArray((data as Record<string, unknown>).image_base64)
929
+ ? ((data as Record<string, unknown>).image_base64 as unknown[]).filter((item): item is string => typeof item === 'string' && item.trim() !== '')
930
+ : []
931
+ const urls = data !== null && typeof data === 'object' && Array.isArray((data as Record<string, unknown>).image_urls)
932
+ ? ((data as Record<string, unknown>).image_urls as unknown[]).filter((item): item is string => typeof item === 'string' && item !== '')
933
+ : []
934
+ if (b64s.length === 0 && urls.length === 0) {
935
+ throw new ImageGenError('上游响应缺少图片内容', 'upstream-empty')
936
+ }
937
+ const images: GeneratedImage[] = b64s.map(raw => {
938
+ const b64 = bareBase64(raw)
939
+ return { b64, mime: detectImageMime(Buffer.from(b64, 'base64')) ?? 'image/jpeg' }
940
+ })
941
+ for (const url of urls) {
942
+ const normalized = await normalizeItem({ url }, upstream, options.signal)
943
+ images.push({ b64: normalized.b64, mime: normalized.mime })
944
+ }
945
+ return { images }
946
+ } finally {
947
+ budget.dispose()
948
+ }
949
+ }
950
+
951
+ /**
952
+ * Forward one generate request to the configured endpoint. The requested image
953
+ * count is satisfied with N parallel single-image requests (the `n` batch
954
+ * parameter is never sent, because Responses-API-based gateways reject it as
955
+ * `tools[0].n`), then the results are flattened in order.
956
+ */
957
+ export async function generateImage(upstream: UpstreamConfig, request: GenerateRequest, options: { signal?: AbortSignal } = {}): Promise<GenerateResult> {
958
+ const baseUrl = upstream.apiUrl.trim().replace(/\/+$/, '')
959
+ if (baseUrl === '') throw new ImageGenError('api_url 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
960
+ if (upstream.apiKey.trim() === '') throw new ImageGenError('api_key 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
961
+ if (isQwenImage(wireModel(request))) return generateQwenImage(baseUrl, upstream, request, options)
962
+ if (isMiniMaxImage(wireModel(request))) return generateMiniMaxImage(baseUrl, upstream, request, options)
963
+ if (request.mode === 'edit' && isZhipuImage(wireModel(request))) {
964
+ throw new ImageGenError('智谱 GLM-Image 当前仅支持文生图,请切换到文生图模式或选择支持图生图的模型', 'edit-unsupported')
965
+ }
966
+ const params = effectiveParams(request)
967
+ const count = effectiveCount(request)
968
+ const batches = await Promise.all(
969
+ Array.from({ length: count }, () => requestOneImage(baseUrl, upstream, request, params, options.signal)),
970
+ )
971
+ return { images: batches.flat() }
972
+ }
973
+
974
+ /** Human-readable failure message from an upstream error payload. */
975
+ function upstreamMessage(payload: unknown, status: number): string {
976
+ if (payload !== null && typeof payload === 'object') {
977
+ const record = payload as Record<string, unknown>
978
+ const error = record.error
979
+ if (error !== null && typeof error === 'object') {
980
+ const message = (error as Record<string, unknown>).message
981
+ if (typeof message === 'string' && message !== '') return message
982
+ }
983
+ if (typeof record.message === 'string' && record.message !== '') return record.message
984
+ if (typeof record.error === 'string' && record.error !== '') return record.error
985
+ }
986
+ return `上游接口拒绝请求(HTTP ${status})`
987
+ }
988
+
989
+ /** File extension for a MIME type (multipart reference image). */
990
+ function extensionOf(mime: string): string {
991
+ switch (mime.split(';')[0]!.trim()) {
992
+ case 'image/jpeg': return 'jpg'
993
+ case 'image/webp': return 'webp'
994
+ case 'image/gif': return 'gif'
995
+ case 'image/png':
996
+ default: return 'png'
997
+ }
998
+ }