@goodandready/dsh-image-gen 0.10.22 → 0.10.24

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/lib/cost-meter.js CHANGED
@@ -4,6 +4,7 @@
4
4
  import { homedir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
  import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
7
+ import { enforceSecurePermissions } from './security.js'
7
8
 
8
9
  /**
9
10
  * Standard pricing table per provider and resolution (USD per generation).
@@ -87,7 +88,7 @@ export function loadDailySpend() {
87
88
  if (data && data.date === today) {
88
89
  return data
89
90
  }
90
- } catch {}
91
+ } catch { /* spend file unreadable; return zero */ }
91
92
  return { date: today, totalSpendUsd: 0, generations: 0 }
92
93
  }
93
94
 
@@ -107,6 +108,7 @@ export function recordSpend(usdAmount, { ctx, meta = {} } = {}) {
107
108
  try {
108
109
  mkdirSync(join(filePath, '..'), { recursive: true })
109
110
  writeFileSync(filePath, JSON.stringify(current, null, 2), 'utf8')
111
+ enforceSecurePermissions(filePath)
110
112
  } catch (err) {
111
113
  // Non-fatal write failure
112
114
  }
@@ -122,7 +124,7 @@ export function recordSpend(usdAmount, { ctx, meta = {} } = {}) {
122
124
  meta,
123
125
  })
124
126
  }
125
- } catch {}
127
+ } catch { /* cost-meter service unavailable */ }
126
128
 
127
129
  return current
128
130
  }
@@ -4,6 +4,7 @@
4
4
  import { homedir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, unlinkSync, readdirSync } from 'node:fs'
7
+ import { enforceSecurePermissions } from './security.js'
7
8
 
8
9
  const DEFAULT_CACHE_MAX_BYTES = 500 * 1024 * 1024 // 500 MB
9
10
 
@@ -107,6 +108,8 @@ export function setCachedGeneration(hash, { bytes, mediaType = 'image/png', widt
107
108
  writeFileSync(metaPath, JSON.stringify(cacheMeta, null, 2), 'utf8')
108
109
  const binBuf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)
109
110
  writeFileSync(dataPath, binBuf)
111
+ enforceSecurePermissions(metaPath)
112
+ enforceSecurePermissions(dataPath)
110
113
 
111
114
  setToL1(hash, {
112
115
  bytes: binBuf,
@@ -151,7 +154,7 @@ export function pruneCacheToLimit(maxBytes = DEFAULT_CACHE_MAX_BYTES) {
151
154
  size,
152
155
  lastAccessed: meta.lastAccessed || meta.createdAt || 0,
153
156
  })
154
- } catch {}
157
+ } catch { /* entry meta unreadable; skip */ }
155
158
  }
156
159
 
157
160
  if (totalSize <= maxBytes) return
@@ -165,7 +168,7 @@ export function pruneCacheToLimit(maxBytes = DEFAULT_CACHE_MAX_BYTES) {
165
168
  if (existsSync(entry.metaPath)) unlinkSync(entry.metaPath)
166
169
  if (existsSync(entry.dataPath)) unlinkSync(entry.dataPath)
167
170
  totalSize -= entry.size
168
- } catch {}
171
+ } catch { /* entry unlink failed; skip */ }
169
172
  }
170
- } catch {}
173
+ } catch { /* cache dir unreadable; prune skipped */ }
171
174
  }
package/lib/index.js CHANGED
@@ -15,6 +15,7 @@
15
15
  import z from '@deepseek-ai/schemastery'
16
16
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
17
17
  import { mkdir, readFile, writeFile } from 'node:fs/promises'
18
+ import { enforceSecurePermissions } from './security.js'
18
19
  import { existsSync, unlinkSync } from 'node:fs'
19
20
  import os from 'node:os'
20
21
  import path from 'node:path'
@@ -32,13 +33,14 @@ import {
32
33
 
33
34
  import { buildDualOutputMarkdown, resolveConversationImage, analyzeImageWithVision } from './resolve-image.js'
34
35
  import { registerAllTools } from './register-tools.js'
36
+ import { registerPluginUpdater } from './updater.js'
35
37
 
36
38
 
37
39
  export { IMAGE_SIZES, OUTPUT_FORMATS, PROVIDER_KEYS, buildSidecar, normalizeMediaType, resolveConversationImage, analyzeImageWithVision }
38
40
 
39
41
 
40
42
  /**
41
- * Сохраняет сгенерированный ассет в воркспейс, регистрирует в attachments и возвращает Dual-Output (#150).
43
+ * Saves generated asset to workspace, registers with attachments, and returns Dual-Output (#150).
42
44
  */
43
45
 
44
46
 
@@ -126,9 +128,8 @@ export const name = 'dsh-image-gen'
126
128
  /** Settings namespace the Web card edits. */
127
129
  const NS = 'dsh-image-gen'
128
130
 
129
- // Плагин раньше назывался dsh-fal-image-gen, и у тех, кто им пользовался, все
130
- // настройки лежат под старым именем. Оно читается как запасное и переносится
131
- // под новое имя один раз — молча, при первом запуске после обновления.
131
+ // The plugin was previously named dsh-fal-image-gen. Any settings saved
132
+ // under the legacy namespace are read as fallback and migrated once on startup.
132
133
  const LEGACY_NS = 'dsh-fal-image-gen'
133
134
  export const inject = ['tools', 'attachments', 'credentials', 'webServer', 'settings', 'llm', 'systemPrompt']
134
135
 
@@ -320,7 +321,7 @@ export const Config = z.object({
320
321
  })
321
322
 
322
323
  /** Keep a file stem safe for the filesystem. */
323
- /** Прочитать исходное изображение для правки: путь к файлу или attachment id. */
324
+ /** Read source image for editing: filesystem path or attachment id. */
324
325
  export async function resolveSource(ctx, exec, ref) {
325
326
  if (!ref) return undefined
326
327
  const sessionCwd = exec.agent?.session?.header?.cwd
@@ -368,14 +369,14 @@ export async function resolveApiKey(ctx, ref) {
368
369
  }
369
370
 
370
371
  /**
371
- * Перенести настройки из-под старого имени плагина.
372
+ * Migrate settings from legacy plugin name.
372
373
  *
373
- * Берётся сырой пользовательский слой: значения по умолчанию переносить незачем,
374
- * а отличить их от заданных руками можно только по нему. Если под новым именем
375
- * человек уже что-то задал, не трогаем ничего — его выбор новее.
374
+ * Uses raw user layer: default values do not need migration,
375
+ * distinguishable only from user overrides. If settings are already configured
376
+ * under the new namespace, preserve them as newer choices.
376
377
  *
377
- * Старый блок остаётся в файле нетронутым: удалять чужие строки из настроек
378
- * пользователя плагину не по чину, а лишним он не мешает.
378
+ * The legacy block is left untouched in settings to avoid unexpected deletions.
379
+ * and causes no conflict with the active namespace.
379
380
  */
380
381
  function migrateLegacySettings(sctx, scope) {
381
382
  try {
@@ -387,19 +388,19 @@ function migrateLegacySettings(sctx, scope) {
387
388
  if (mine && typeof mine === 'object' && Object.keys(mine).length > 0) return
388
389
  scope.update(structuredClone(legacy))
389
390
  } catch (cannotMigrate) {
390
- // Настройки не перенеслись — плагин работает на значениях по умолчанию,
391
- // и человек задаст своё в карточке. Ронять из-за этого запуск незачем.
391
+ // Settings migration skipped — fallback to default schema configuration,
392
+ // and user may configure settings via UI card. No crash required.
392
393
  }
393
394
  }
394
395
 
395
- /** Каталог истории: общий для всех устройств, переживает рестарт. */
396
+ /** History directory: persists across restarts. */
396
397
  export function historyFile() {
397
398
  return path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'dsh-image-gen', 'history.json')
398
399
  }
399
400
 
400
- /** Прочитать историю из файла (пусто, если файла нет). */
401
- /** Найти запись истории по seed+prompt, если файл ещё существует. */
402
- /** Найти запись истории по промпту (без учёта seed), если файл существует. */
401
+ /** Read history from disk file (empty array if not found). */
402
+ /** Find history entry matching seed+prompt if file exists. */
403
+ /** Find history entry matching prompt text if file exists. */
403
404
  export function findCachedGeneration(entries, hash) {
404
405
  if (!hash || !Array.isArray(entries)) return undefined
405
406
  return entries.find((e) => e.cacheHash === hash)
@@ -414,7 +415,7 @@ export async function findCached(entries, seed, prompt) {
414
415
  return entries.find((e) => e.seed === seed && e.prompt === prompt)
415
416
  }
416
417
 
417
- /** Вернуть запись из кэша, если файл существует; иначе undefined. */
418
+ /** Return cached entry if file exists on disk, otherwise undefined. */
418
419
  export async function cachedResult(entry) {
419
420
  if (!entry || !entry.path) return undefined
420
421
  if (!entry.path || !existsSync(entry.path)) return undefined
@@ -440,7 +441,7 @@ export async function cachedResult(entry) {
440
441
  }
441
442
  }
442
443
 
443
- /** Удалить файлы и записи истории старше pruneDays дней (включая .json sidecar). */
444
+ /** Prune files and history records older than pruneDays (including sidecars). */
444
445
  export async function pruneHistory(entries, pruneDays) {
445
446
  if (!pruneDays || pruneDays <= 0) return entries
446
447
  const cutoff = Date.now() - pruneDays * 86400000
@@ -448,7 +449,7 @@ export async function pruneHistory(entries, pruneDays) {
448
449
  for (const e of entries) {
449
450
  const created = e.createdAt ? Date.parse(e.createdAt) : NaN
450
451
  if (Number.isFinite(created) && created < cutoff) {
451
- try { unlinkSync(e.path) } catch (err) { /* файл уже удалён */ }
452
+ try { unlinkSync(e.path) } catch (err) { /* file already deleted */ }
452
453
  try { unlinkSync(e.path.replace(/\.[^.]+$/, '.json')) } catch (err) { /* sidecar */ }
453
454
  continue
454
455
  }
@@ -467,20 +468,21 @@ export async function readHistory() {
467
468
  }
468
469
  }
469
470
 
470
- /** Записать историю в файл (перезапись целиком). */
471
+ /** Save history to disk file (atomic overwrite). */
471
472
  export async function writeHistory(entries) {
472
473
  try {
473
474
  await mkdir(path.dirname(historyFile()), { recursive: true })
474
475
  await writeFile(historyFile(), JSON.stringify(entries, null, 2))
475
- } catch (e) { /* история не критична */ }
476
+ enforceSecurePermissions(historyFile())
477
+ } catch (e) { /* history write failure is non-fatal */ }
476
478
  }
477
479
 
478
- /** Отфильтровать записи, чьи файлы ещё существуют; новые первыми. */
480
+ /** Filter entries whose files exist on disk; newest first. */
479
481
  export function filterHistory(entries, exists) {
480
482
  return entries.filter((e) => exists(e.path)).slice(0, 50)
481
483
  }
482
484
 
483
- /** Собрать текст из итератора llm.stream (text-delta / block-end). */
485
+ /** Collect text chunks from llm.stream iterator. */
484
486
  export async function collectText(iterable) {
485
487
  let out = ''
486
488
  let sawDelta = false
@@ -498,7 +500,7 @@ export async function collectText(iterable) {
498
500
  return out.trim()
499
501
  }
500
502
 
501
- /** Развернуть короткий промпт через чат-модель; при ошибке вернуть исходный. */
503
+ /** Expand short prompt via chat model; return original prompt on error. */
502
504
  export function buildEnhancePromptSystemMessage(provider, model) {
503
505
  const isFlux = String(model || '').toLowerCase().includes('flux') || provider === 'fal'
504
506
  if (isFlux) {
@@ -507,7 +509,7 @@ export function buildEnhancePromptSystemMessage(provider, model) {
507
509
  return 'You are an expert prompt engineer for Stable Diffusion models. Expand the user prompt into detailed comma-separated descriptive visual tags including subject, composition, studio lighting, materials, and artistic medium. Reply with ONLY the expanded prompt, no commentary.'
508
510
  }
509
511
 
510
- /** Развернуть короткий промпт через чат-модель; при ошибке вернуть исходный. */
512
+ /** Expand short prompt via chat model; return original prompt on error. */
511
513
  export async function enhancePrompt(ctx, cfg, prompt, signal, provider) {
512
514
  if (!cfg.enhancePrompt) return { prompt, enhanced: false }
513
515
  if (String(prompt).length >= (cfg.enhanceBelowChars || 200)) return { prompt, enhanced: false }
@@ -617,10 +619,8 @@ export function apply(ctx, config) {
617
619
  }
618
620
  })()
619
621
 
620
- // Два адреса, один обработчик. Новый — тот, что уходит в новые сообщения;
621
- // старый остался от прежнего имени плагина, и по нему сделаны ссылки в уже
622
- // отправленных: сними его — и картинки в истории разговоров перестанут
623
- // показываться.
622
+ // Two routes, one handler: new route for new messages, legacy route for
623
+ // backward compatibility with conversation history.
624
624
  for (const path of ['/dsh-image-gen/image', '/dsh-fal-image-gen/image']) {
625
625
  ctx.effect(() => ctx.webServer.register({
626
626
  kind: 'exact',
@@ -629,7 +629,14 @@ export function apply(ctx, config) {
629
629
  }), `dsh-image-gen: image route ${path}`)
630
630
  }
631
631
 
632
- // Диагностика подключения к провайдерам
632
+ // One-click plugin updater route per DSH standard
633
+ ctx.effect(() => registerPluginUpdater(ctx, {
634
+ endpoint: '/api/dsh-image-gen/update',
635
+ packageName: '@goodandready/dsh-image-gen',
636
+ manifestUrl: new URL('../package.json', import.meta.url),
637
+ }), 'dsh-image-gen: plugin updater route')
638
+
639
+ // Provider connection diagnostics probe
633
640
  ctx.effect(() => ctx.webServer.register({
634
641
  kind: 'exact',
635
642
  path: '/dsh-image-gen/diagnostics/test',
@@ -658,7 +665,7 @@ export function apply(ctx, config) {
658
665
  },
659
666
  }), 'dsh-image-gen: diagnostics test route')
660
667
 
661
- // История генераций: in-memory список, отфильтрованный по существованию файлов.
668
+ // Generation history: in-memory list filtered by filesystem existence.
662
669
  ctx.effect(() => ctx.webServer.register({
663
670
  kind: 'exact',
664
671
  path: '/dsh-image-gen/history',
package/lib/loop-guard.js CHANGED
@@ -55,6 +55,7 @@ export function trackAndAssertLoopGuard(sessionId, {
55
55
  /**
56
56
  * Resets loop guard counter for a session.
57
57
  */
58
+ /** @internal — test helper; not part of the plugin runtime API */
58
59
  export function resetLoopGuard(sessionId) {
59
60
  const sid = String(sessionId || 'default_session')
60
61
  sessionStates.delete(sid)
@@ -63,6 +64,7 @@ export function resetLoopGuard(sessionId) {
63
64
  /**
64
65
  * Gets current state for debugging/testing.
65
66
  */
67
+ /** @internal — test helper; not part of the plugin runtime API */
66
68
  export function getLoopGuardState(sessionId) {
67
69
  const sid = String(sessionId || 'default_session')
68
70
  return sessionStates.get(sid) || { count: 0, lastPrompt: '', lastTimestamp: 0 }
@@ -185,10 +185,3 @@ export function polishPrompt(basePrompt, stylePreset = null, {
185
185
  }
186
186
  }
187
187
 
188
- export function listCuratedStyles() {
189
- return Object.values(CURATED_STYLES).map(s => ({
190
- id: s.id,
191
- label: s.label,
192
- guidanceScale: s.guidanceScale,
193
- }))
194
- }
@@ -0,0 +1,302 @@
1
+ // lib/provider-utils.js
2
+ // Shared provider utilities and size/format constants extracted from providers.js (#239)
3
+
4
+ export function resolveApiKeyCandidates(ref) {
5
+ if (!ref) return []
6
+ const candidates = [ref]
7
+ if (ref === 'FAL_API_KEY') candidates.push('FAL_KEY')
8
+ else if (ref === 'FAL_KEY') candidates.push('FAL_API_KEY')
9
+ return candidates
10
+ }
11
+
12
+
13
+ export const PROVIDER_MAX_COUNTS = {
14
+ fal: 4,
15
+ replicate: 4,
16
+ custom: 10,
17
+ seedream: 10,
18
+ gemini: 4,
19
+ codex: 1,
20
+ grok: 1,
21
+ local: 4,
22
+ }
23
+
24
+ export function normalizeCount(count, max = 4) {
25
+ const n = Math.floor(Number(count))
26
+ if (!Number.isFinite(n)) return 1
27
+ return Math.max(1, Math.min(max, n))
28
+ }
29
+
30
+ /** @internal — test helper for validating provider count limits */
31
+ export function clampProviderCount(provider, count) {
32
+ const max = PROVIDER_MAX_COUNTS[provider] || 4
33
+ return normalizeCount(count, max)
34
+ }
35
+
36
+ export function buildEndpointUrl(baseURL, pathSuffix) {
37
+ const base = String(baseURL || '').trim().replace(/\/+$/, '')
38
+ const suffix = String(pathSuffix || '').trim().replace(/^\/+/, '')
39
+ return `${base}/${suffix}`
40
+ }
41
+
42
+ /** @internal — test helper for abort signal simulation */
43
+ export function createAbortError(message = 'Image generation cancelled') {
44
+ const err = new Error(message)
45
+ err.name = 'AbortError'
46
+ return err
47
+ }
48
+
49
+
50
+ /** Fast Laplacian-like sharpness and entropy estimator across image scanlines. */
51
+ export function estimateSharpnessAndVariance(bytes) {
52
+ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes || [])
53
+ if (!buf || buf.length < 64) {
54
+ return { score: 0, passed: false, isBlank: true, reason: 'Empty or corrupt image buffer' }
55
+ }
56
+ let sum = 0
57
+ let diffSum = 0
58
+ const sampleStep = Math.max(1, Math.floor(buf.length / 4096))
59
+ let count = 0
60
+ for (let i = 8; i < buf.length - sampleStep; i += sampleStep) {
61
+ const v = buf[i]
62
+ const nextV = buf[i + sampleStep]
63
+ sum += v
64
+ diffSum += Math.abs(v - nextV)
65
+ count++
66
+ }
67
+ const avgDiff = count > 0 ? diffSum / count : 0
68
+ if (avgDiff < 2) {
69
+ return { score: 0.1, passed: false, isBlank: true, reason: 'Image appears blank or solid monochrome' }
70
+ }
71
+ const score = Math.min(0.98, Math.max(0.3, +(0.5 + (avgDiff / 255) * 0.5).toFixed(2)))
72
+ return { score, passed: score >= 0.5, isBlank: false, avgDiff: +avgDiff.toFixed(2) }
73
+ }
74
+
75
+ /** Adaptive color palette quantizer for SVG vectorization. */
76
+ export function quantizePalette(colorMode = 'color', paletteSize = 16) {
77
+ if (colorMode === 'binary') return ['#000000', '#ffffff']
78
+ if (colorMode === 'grayscale') return ['#000000', '#444444', '#888888', '#cccccc', '#ffffff']
79
+ // Standard vibrant UI vector palette
80
+ return [
81
+ '#000000', '#ffffff', '#e11d48', '#2563eb',
82
+ '#16a34a', '#ca8a04', '#9333ea', '#0891b2',
83
+ '#475569', '#64748b', '#94a3b8', '#cbd5e1'
84
+ ].slice(0, Math.max(2, paletteSize))
85
+ }
86
+
87
+
88
+ /** Format parameters in standard Automatic1111 / ComfyUI text format for drag-and-drop support. */
89
+ export function formatA1111Parameters(metadata = {}) {
90
+ if (typeof metadata === 'string') return metadata
91
+ const prompt = metadata.prompt || ''
92
+ const neg = metadata.negative_prompt || metadata.negativePrompt || ''
93
+ const seed = metadata.seed ?? ''
94
+ const size = metadata.size || (metadata.width && metadata.height ? `${metadata.width}x${metadata.height}` : '1024x1024')
95
+ const model = metadata.model || metadata.provider || ''
96
+ const steps = metadata.steps || 20
97
+ const cfg = metadata.cfg_scale || metadata.guidance_scale || 7
98
+
99
+ let out = prompt
100
+ if (neg) out += `\nNegative prompt: ${neg}`
101
+ out += `\nSteps: ${steps}, Sampler: Euler, CFG scale: ${cfg}, Seed: ${seed}, Size: ${size}, Model: ${model}`
102
+ return out
103
+ }
104
+
105
+
106
+ import { createHash } from 'node:crypto'
107
+
108
+ /** Deterministic sha256 hash for image generation caching. */
109
+ export function computeGenerationHash({ provider, model, prompt, seed, size, style }) {
110
+ const norm = [
111
+ String(provider || '').trim().toLowerCase(),
112
+ String(model || '').trim().toLowerCase(),
113
+ String(prompt || '').trim(),
114
+ String(seed ?? ''),
115
+ String(size || '').trim().toLowerCase(),
116
+ String(style || '').trim().toLowerCase(),
117
+ ].join('|')
118
+ return createHash('sha256').update(norm).digest('hex')
119
+ }
120
+
121
+ // Image generation provider implementations.
122
+ //
123
+ // A provider receives a generation job and returns finished image bytes. Everything
124
+ // that follows (attachments, workspace files, links, card rendering) is shared
125
+ // across all providers and lives in index.js.
126
+ //
127
+ // Network is injected via fetchImpl and keys via resolveKey, enabling fully isolated unit tests.
128
+
129
+ export const PROVIDER_KEYS = ['fal', 'custom', 'codex', 'grok', 'local', 'seedream', 'gemini', 'replicate']
130
+
131
+ /** Clamp count from tool arguments to range 1..4. */
132
+ /** Provider fallback ordering: primary provider first, followed by PROVIDER_KEYS. */
133
+ export function fallbackOrder(primary) {
134
+ return [primary, ...PROVIDER_KEYS.filter((k) => k !== primary)]
135
+ }
136
+
137
+ /**
138
+ * Iterates through candidate generators, returning the first successful result.
139
+ * Throws an aggregate error if all candidates fail.
140
+ * @param generators - array of (key, seed) => Promise<generated> functions.
141
+ * @param order - candidate key evaluation order.
142
+ */
143
+
144
+ /** Calculate exponential backoff delay with jitter. */
145
+ export function calculateBackoff(attempt, baseInterval = 500, maxInterval = 5000, jitterFactor = 0.2) {
146
+ const exp = Math.min(maxInterval, baseInterval * Math.pow(1.3, attempt))
147
+ const jitter = exp * jitterFactor * (Math.random() * 2 - 1)
148
+ return Math.max(100, Math.floor(exp + jitter))
149
+ }
150
+
151
+ /** Check if an error is a fatal client error that should NOT be cascaded to other providers. */
152
+ export function isFatalClientError(error) {
153
+ const msg = (error?.message || String(error || '')).toLowerCase()
154
+ return (
155
+ msg.includes('content policy') ||
156
+ msg.includes('safety system') ||
157
+ msg.includes('nsfw') ||
158
+ msg.includes('moderation') ||
159
+ msg.includes('bad request (http 400') ||
160
+ msg.includes('invalid_prompt') ||
161
+ msg.includes('prompt is required') ||
162
+ msg.includes('unsupported image format') ||
163
+ msg.includes('402 payment required') ||
164
+ msg.includes('insufficient_quota') ||
165
+ msg.includes('insufficient credits') ||
166
+ msg.includes('exceeded your current quota') ||
167
+ msg.includes('balance is insufficient')
168
+ )
169
+ }
170
+
171
+ /**
172
+ * Safely extract a human-readable message from any error, object, or response.
173
+ * Completely eliminates "[object Object]" and prevents duplicate provider prefixes (e.g. "codex: codex: ...").
174
+ */
175
+ export function formatErrorMessage(e, providerKey = '') {
176
+ if (e === null || e === undefined) {
177
+ return providerKey ? `${providerKey}: unknown error` : 'unknown error'
178
+ }
179
+
180
+ let msg = ''
181
+ if (typeof e === 'string') {
182
+ msg = e
183
+ } else if (typeof e === 'object') {
184
+ if (typeof e.message === 'string' && e.message && e.message !== '[object Object]') {
185
+ msg = e.message
186
+ } else if (typeof e.detail === 'string' && e.detail) {
187
+ msg = e.detail
188
+ } else if (e.error) {
189
+ if (typeof e.error === 'string') msg = e.error
190
+ else if (typeof e.error.message === 'string') msg = e.error.message
191
+ else if (typeof e.error.detail === 'string') msg = e.error.detail
192
+ else {
193
+ try { msg = JSON.stringify(e.error) } catch { msg = String(e.error) }
194
+ }
195
+ } else if (typeof e.statusText === 'string' && e.statusText) {
196
+ msg = `HTTP ${e.status || ''} ${e.statusText}`.trim()
197
+ } else if (typeof e.cause === 'string') {
198
+ msg = e.cause
199
+ } else if (e.cause && typeof e.cause.message === 'string') {
200
+ msg = e.cause.message
201
+ }
202
+
203
+ if (!msg || msg === '[object Object]') {
204
+ try {
205
+ const json = JSON.stringify(e)
206
+ if (json && json !== '{}') {
207
+ msg = json.slice(0, 500)
208
+ }
209
+ } catch { /* response body not JSON; use raw text */ }
210
+ }
211
+
212
+ if (!msg || msg === '[object Object]') {
213
+ const keys = Object.getOwnPropertyNames(e)
214
+ if (keys.length) {
215
+ msg = `error [${keys.join(', ')}]`
216
+ } else {
217
+ msg = String(e)
218
+ }
219
+ }
220
+ } else {
221
+ msg = String(e)
222
+ }
223
+
224
+ msg = String(msg || 'unknown error').trim()
225
+
226
+ if (providerKey) {
227
+ const prefixRegex = new RegExp(`^${providerKey}\\s*:\\s*`, 'i')
228
+ while (prefixRegex.test(msg)) {
229
+ msg = msg.replace(prefixRegex, '').trim()
230
+ }
231
+ }
232
+
233
+ if (!msg || msg === '[object Object]') {
234
+ msg = 'unknown error object'
235
+ } else if (msg.includes('[object Object]')) {
236
+ msg = msg.replace(/\[object Object\]/g, 'unknown error object').trim()
237
+ }
238
+
239
+ if (providerKey) {
240
+ return `${providerKey}: ${msg}`
241
+ }
242
+
243
+ return msg
244
+ }
245
+
246
+ export function resolveSubscriptionSize(size, aspectPixels, aspectRatio) {
247
+ if (size && SUBSCRIPTION_SIZES[size]) {
248
+ return SUBSCRIPTION_SIZES[size]
249
+ }
250
+ const ratio = String(aspectRatio || '').trim()
251
+ if (ratio === '16:9' || ratio === '3:2' || ratio === '4:3') {
252
+ return '1536x1024'
253
+ }
254
+ if (ratio === '9:16' || ratio === '2:3' || ratio === '3:4') {
255
+ return '1024x1536'
256
+ }
257
+ if (ratio === '1:1') {
258
+ return '1024x1024'
259
+ }
260
+ if (Array.isArray(aspectPixels) && aspectPixels.length === 2) {
261
+ const [w, h] = aspectPixels
262
+ if (w > h) return '1536x1024'
263
+ if (h > w) return '1024x1536'
264
+ return '1024x1024'
265
+ }
266
+ return '1024x1024'
267
+ }
268
+
269
+ export const SUBSCRIPTION_SIZES = {
270
+ square_hd: '1024x1024',
271
+ square: '1024x1024',
272
+ portrait_4_3: '1024x1536',
273
+ portrait_16_9: '1024x1536',
274
+ landscape_4_3: '1536x1024',
275
+ landscape_16_9: '1536x1024',
276
+ }
277
+
278
+ /** Image sizes accepted by fal-ai/flux-2/klein (and most FAL flux models). */
279
+ export const IMAGE_SIZES = [
280
+ 'square_hd',
281
+ 'square',
282
+ 'portrait_4_3',
283
+ 'portrait_16_9',
284
+ 'landscape_4_3',
285
+ 'landscape_16_9',
286
+ ]
287
+
288
+ /** Output formats accepted by the model. */
289
+ export const OUTPUT_FORMATS = ['png', 'jpeg', 'webp']
290
+
291
+ // Named sizes are a unified abstraction across all providers.
292
+ // FAL accepts named identifiers; OpenAI-compatible gateways require WxH resolution.
293
+ export const SIZE_PIXELS = {
294
+ square_hd: '1024x1024',
295
+ square: '512x512',
296
+ portrait_4_3: '768x1024',
297
+ portrait_16_9: '576x1024',
298
+ landscape_4_3: '1024x768',
299
+ landscape_16_9: '1024x576',
300
+ }
301
+
302
+ /** Normalize a raw key into a FAL `Authorization: Key <key>` value. */