@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/README.md +2 -1
- package/lib/client.js +426 -390
- package/lib/client.js.map +1 -1
- package/lib/index.js +181 -5
- package/package.json +81 -81
- package/src/canvas-store.ts +375 -375
- package/src/client/ImageGenPanel.tsx +2823 -2807
- package/src/client/SettingsCard.tsx +1128 -1128
- package/src/client/locales.ts +1515 -1509
- package/src/client/panel.module.css +7 -0
- package/src/engine.ts +998 -845
- package/src/gallery-store.ts +311 -311
- package/src/history-store.ts +275 -275
- package/src/image-storage-path.ts +12 -12
- package/src/index.ts +430 -429
- package/src/model-catalog.ts +149 -124
- package/src/presets.ts +95 -86
- package/src/prompt-enhancer.ts +151 -137
- package/src/protocol.ts +580 -580
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
'
|
|
134
|
-
'9
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
*
|
|
149
|
-
|
|
150
|
-
'
|
|
151
|
-
'
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
function
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
)
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
const
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
return
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
if (
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
:
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
if (
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
if (
|
|
665
|
-
if (
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
)
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
const
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
const
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
+
}
|