@dickpy/dsh-imagegen 1.5.4 → 1.5.5

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
@@ -184,8 +184,14 @@ function requestSignal(source: AbortSignal | undefined, timeoutMs: number): { si
184
184
  }
185
185
  }
186
186
 
187
- /** Content-type extension hints for URL-fetched images. */
188
- function mimeOfExtension(path: string): string | undefined {
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 {
189
195
  const match = /\.([a-z0-9]+)$/i.exec(path)
190
196
  if (match === null) return undefined
191
197
  switch (match[1]!.toLowerCase()) {
@@ -358,29 +364,32 @@ async function normalizeItem(
358
364
  return { b64: parsed.base64, mime: detectImageMime(Buffer.from(parsed.base64, 'base64')) ?? parsed.mime, revisedPrompt }
359
365
  }
360
366
  const budget = requestSignal(signal, IMAGE_FETCH_TIMEOUT_MS)
361
- let response: Response
362
367
  try {
363
- response = await fetch(url, {
364
- ...isPresignedUrl(url) || upstream.apiKey === ''
365
- ? {}
366
- : { headers: { authorization: `Bearer ${upstream.apiKey}` } },
367
- signal: budget.signal,
368
- })
369
- } catch (error) {
370
- throw new ImageGenError(`failed to fetch the generated image url: ${error instanceof Error ? error.message : String(error)}`)
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 }
371
390
  } finally {
372
391
  budget.dispose()
373
392
  }
374
- if (!response.ok) {
375
- throw new ImageGenError(`failed to fetch the generated image url: HTTP ${response.status}`)
376
- }
377
- const buffer = Buffer.from(await response.arrayBuffer())
378
- const contentType = response.headers.get('content-type')
379
- const mime = detectImageMime(buffer)
380
- ?? (contentType !== null && contentType !== ''
381
- ? contentType.split(';')[0]!.trim()
382
- : mimeOfExtension(url) ?? 'image/png')
383
- return { b64: buffer.toString('base64'), mime, revisedPrompt }
384
393
  }
385
394
 
386
395
  /** Expand a provider image item whose URL may be a string or an array. */
@@ -477,52 +486,54 @@ async function pollAsyncTask(
477
486
  while (Date.now() < deadline) {
478
487
  const remaining = deadline - Date.now()
479
488
  const budget = requestSignal(signal, Math.min(ASYNC_POLL_REQUEST_TIMEOUT_MS, remaining))
480
- let response: Response
481
489
  try {
482
- response = await fetch(`${baseUrl}/tasks/${encodeURIComponent(taskId)}`, {
483
- method: 'GET',
484
- headers: { authorization: `Bearer ${upstream.apiKey.trim()}` },
485
- signal: budget.signal,
486
- })
487
- } catch (error) {
488
- if (signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
489
- const message = error instanceof Error ? error.message : String(error)
490
- if (/timeout|abort/i.test(message)) throw new ImageGenError('上游异步任务轮询超时', 'upstream-timeout')
491
- throw new ImageGenError(`无法轮询上游异步任务:${message}`, 'upstream-unreachable')
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
+ }
492
534
  } finally {
493
535
  budget.dispose()
494
536
  }
495
- let payload: unknown
496
- try {
497
- payload = await response.json()
498
- } catch {
499
- throw new ImageGenError(`上游任务接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
500
- }
501
- if (!response.ok || payload === null || typeof payload !== 'object') {
502
- throw new ImageGenError(asyncErrorMessage(payload, `上游任务轮询失败(HTTP ${response.status})`), 'upstream-rejected')
503
- }
504
- const record = payload as Record<string, unknown>
505
- const data = record.data
506
- const statusRecord = Array.isArray(data) ? data[0] : data !== null && typeof data === 'object' ? data : record
507
- const statusValue = statusRecord !== null && typeof statusRecord === 'object'
508
- ? (statusRecord as Record<string, unknown>).status
509
- : undefined
510
- const status = typeof statusValue === 'string' ? statusValue.toLowerCase() : ''
511
- if (ASYNC_FAILED_STATUSES.has(status)) {
512
- throw new ImageGenError(asyncErrorMessage(payload, `上游异步任务失败(${status || 'unknown'})`), 'upstream-rejected')
513
- }
514
- const nested = statusRecord !== null && typeof statusRecord === 'object' ? statusRecord as Record<string, unknown> : record
515
- const result = nested.result ?? (nested.output !== null && typeof nested.output === 'object' ? (nested.output as Record<string, unknown>).result : undefined) ?? record.result
516
- const resultRecord = result !== null && typeof result === 'object' ? result as Record<string, unknown> : undefined
517
- const images = resultRecord?.images ?? (nested.images ?? record.images)
518
- if (ASYNC_COMPLETED_STATUSES.has(status) || images !== undefined) {
519
- const items = Array.isArray(images) ? images.flatMap(imageItemsOf) : imageItemsOf(images)
520
- if (items.length > 0) return items
521
- if (ASYNC_COMPLETED_STATUSES.has(status)) throw new ImageGenError('上游异步任务完成但没有图片结果', 'upstream-empty')
522
- }
523
- if (status !== '' && !ASYNC_PENDING_STATUSES.has(status) && !ASYNC_COMPLETED_STATUSES.has(status)) {
524
- throw new ImageGenError(`上游返回了未知异步任务状态:${status}`, 'upstream-invalid')
525
- }
526
537
  await waitForPoll(Math.min(delay, Math.max(1, deadline - Date.now())), signal)
527
538
  delay = Math.min(5000, delay * 2)
528
539
  }
@@ -608,53 +619,57 @@ async function requestOneImage(
608
619
  }
609
620
 
610
621
  const budget = requestSignal(signal, UPSTREAM_TIMEOUT_MS)
611
- let response: Response
612
622
  try {
613
- // Seedream has no /images/edits endpoint: both modes hit generations.
614
- const endpoint = request.mode === 'edit' && !isSeedream(params.model)
615
- ? '/images/edits'
616
- : '/images/generations'
617
- response = await fetch(`${baseUrl}${endpoint}`, {
618
- method: 'POST',
619
- headers,
620
- body,
621
- signal: budget.signal,
622
- })
623
- } catch (error) {
624
- const message = error instanceof Error ? error.message : String(error)
625
- if (/aborter/i.test(message) || /timeout/i.test(message)) {
626
- throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
623
+ let response: Response
624
+ try {
625
+ // Seedream has no /images/edits endpoint: both modes hit generations.
626
+ const endpoint = request.mode === 'edit' && !isSeedream(params.model)
627
+ ? '/images/edits'
628
+ : '/images/generations'
629
+ response = await fetch(`${baseUrl}${endpoint}`, {
630
+ method: 'POST',
631
+ headers,
632
+ body,
633
+ signal: budget.signal,
634
+ })
635
+ } catch (error) {
636
+ if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
637
+ if (signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
638
+ throw new ImageGenError(`无法连接上游接口:${error instanceof Error ? error.message : String(error)}`, 'upstream-unreachable')
627
639
  }
628
- throw new ImageGenError(`无法连接上游接口:${message}`, 'upstream-unreachable')
629
- } finally {
630
- budget.dispose()
631
- }
632
640
 
633
- let payload: unknown
634
- try {
635
- payload = await response.json()
636
- } catch {
637
- throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
638
- }
639
- if (!response.ok || payload === null || typeof payload !== 'object') {
640
- throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
641
- }
641
+ let payload: unknown
642
+ try {
643
+ // The budget stays armed through the body read: a gateway that returns
644
+ // headers but never completes the body must not hang the task forever.
645
+ payload = await response.json()
646
+ } catch (error) {
647
+ if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
648
+ if (signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
649
+ throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
650
+ }
651
+ if (!response.ok || payload === null || typeof payload !== 'object') {
652
+ throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
653
+ }
642
654
 
643
- const record = payload as Record<string, unknown>
644
- const data = dataRecordsOf(record)
645
- if (data === undefined) {
646
- throw new ImageGenError('上游响应缺少 data 数组', 'upstream-invalid')
647
- }
648
- if (data.length === 0) {
649
- throw new ImageGenError('上游返回了 0 张图片', 'upstream-empty')
650
- }
651
- const asyncEntries = data.filter(entry => typeof entry.task_id === 'string' && entry.task_id.trim() !== '')
652
- if (asyncEntries.length > 0) {
653
- const asyncRecords = (await Promise.all(asyncEntries.map(entry => pollAsyncTask(baseUrl, upstream, entry.task_id as string, signal)))).flat()
654
- if (asyncRecords.length === 0) throw new ImageGenError('上游异步任务完成但没有图片结果', 'upstream-empty')
655
- return Promise.all(asyncRecords.flatMap(imageItemsOf).map(item => normalizeItem(item, upstream, signal)))
655
+ const record = payload as Record<string, unknown>
656
+ const data = dataRecordsOf(record)
657
+ if (data === undefined) {
658
+ throw new ImageGenError('上游响应缺少 data 数组', 'upstream-invalid')
659
+ }
660
+ if (data.length === 0) {
661
+ throw new ImageGenError('上游返回了 0 张图片', 'upstream-empty')
662
+ }
663
+ const asyncEntries = data.filter(entry => typeof entry.task_id === 'string' && entry.task_id.trim() !== '')
664
+ if (asyncEntries.length > 0) {
665
+ const asyncRecords = (await Promise.all(asyncEntries.map(entry => pollAsyncTask(baseUrl, upstream, entry.task_id as string, signal)))).flat()
666
+ if (asyncRecords.length === 0) throw new ImageGenError('上游异步任务完成但没有图片结果', 'upstream-empty')
667
+ return Promise.all(asyncRecords.flatMap(imageItemsOf).map(item => normalizeItem(item, upstream, signal)))
668
+ }
669
+ return Promise.all(data.flatMap(imageItemsOf).map(item => normalizeItem(item, upstream, signal)))
670
+ } finally {
671
+ budget.dispose()
656
672
  }
657
- return Promise.all(data.flatMap(imageItemsOf).map(item => normalizeItem(item, upstream, signal)))
658
673
  }
659
674
 
660
675
  /**
@@ -701,36 +716,36 @@ async function generateQwenImage(
701
716
  }
702
717
 
703
718
  const budget = requestSignal(options.signal, UPSTREAM_TIMEOUT_MS)
704
- let response: Response
705
719
  try {
706
- response = await fetch(`${baseUrl}/services/aigc/multimodal-generation/generation`, {
707
- method: 'POST',
708
- headers: {
709
- authorization: `Bearer ${upstream.apiKey.trim()}`,
710
- 'content-type': 'application/json',
711
- },
712
- body: JSON.stringify(body),
713
- signal: budget.signal,
714
- })
715
- } catch (error) {
716
- const message = error instanceof Error ? error.message : String(error)
717
- if (/aborter/i.test(message) || /timeout/i.test(message)) {
718
- throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
720
+ let response: Response
721
+ try {
722
+ response = await fetch(`${baseUrl}/services/aigc/multimodal-generation/generation`, {
723
+ method: 'POST',
724
+ headers: {
725
+ authorization: `Bearer ${upstream.apiKey.trim()}`,
726
+ 'content-type': 'application/json',
727
+ },
728
+ body: JSON.stringify(body),
729
+ signal: budget.signal,
730
+ })
731
+ } catch (error) {
732
+ if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
733
+ if (options.signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
734
+ throw new ImageGenError(`无法连接上游接口:${error instanceof Error ? error.message : String(error)}`, 'upstream-unreachable')
719
735
  }
720
- throw new ImageGenError(`无法连接上游接口:${message}`, 'upstream-unreachable')
721
- } finally {
722
- budget.dispose()
723
- }
724
736
 
725
- let payload: unknown
726
- try {
727
- payload = await response.json()
728
- } catch {
729
- throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
730
- }
731
- if (!response.ok || payload === null || typeof payload !== 'object') {
732
- throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
733
- }
737
+ let payload: unknown
738
+ try {
739
+ // Budget stays armed through the body read (same rationale as the OpenAI path).
740
+ payload = await response.json()
741
+ } catch (error) {
742
+ if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
743
+ if (options.signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
744
+ throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
745
+ }
746
+ if (!response.ok || payload === null || typeof payload !== 'object') {
747
+ throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
748
+ }
734
749
 
735
750
  // output.choices[].message.content[] mixes text and { image: url } items.
736
751
  const record = payload as Record<string, unknown>
@@ -759,6 +774,9 @@ async function generateQwenImage(
759
774
  return { b64: normalized.b64, mime: normalized.mime }
760
775
  }))
761
776
  return { images }
777
+ } finally {
778
+ budget.dispose()
779
+ }
762
780
  }
763
781
 
764
782
  /**
package/src/protocol.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  export const IMAGEGEN_SETTINGS_NAMESPACE = 'dsh-imagegen'
9
9
 
10
10
  /** Published package version shared by the host updater and the client UI. */
11
- export const PLUGIN_VERSION = '1.5.4'
11
+ export const PLUGIN_VERSION = '1.5.5'
12
12
 
13
13
  /** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
14
14
  export const SETTINGS_API = {