@goodandready/dsh-image-gen 0.11.27 → 0.11.28

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.
@@ -73,6 +73,10 @@ export function interpolateComfyWorkflow(workflowGraph, ctx) {
73
73
  const cfgVal = Number.isFinite(ctx.cfg) ? ctx.cfg : 7
74
74
  const modelVal = String(ctx.model ?? 'v1-5-pruned-emaonly.safetensors')
75
75
  const imageVal = String(ctx.image ?? '')
76
+ const faceImageVal = String(ctx.faceImage ?? ctx.face_image ?? '')
77
+ const faceWeightVal = Number.isFinite(ctx.faceWeight ?? ctx.face_weight) ? (ctx.faceWeight ?? ctx.face_weight) : 0.75
78
+ const refImageVal = String(ctx.referenceImage ?? ctx.reference_image ?? '')
79
+ const refWeightVal = Number.isFinite(ctx.referenceWeight ?? ctx.reference_weight) ? (ctx.referenceWeight ?? ctx.reference_weight) : 0.65
76
80
 
77
81
  function transformValue(val) {
78
82
  if (typeof val === 'string') {
@@ -83,6 +87,8 @@ export function interpolateComfyWorkflow(workflowGraph, ctx) {
83
87
  if (trimmed === '{{height}}') return heightVal
84
88
  if (trimmed === '{{steps}}') return stepsVal
85
89
  if (trimmed === '{{cfg}}') return cfgVal
90
+ if (trimmed === '{{face_weight}}' || trimmed === '{{faceWeight}}') return faceWeightVal
91
+ if (trimmed === '{{reference_weight}}' || trimmed === '{{referenceWeight}}') return refWeightVal
86
92
 
87
93
  // General string replacements
88
94
  return val
@@ -96,6 +102,14 @@ export function interpolateComfyWorkflow(workflowGraph, ctx) {
96
102
  .replace(/\{\{cfg\}\}/g, String(cfgVal))
97
103
  .replace(/\{\{model\}\}/g, modelVal)
98
104
  .replace(/\{\{image\}\}/g, imageVal)
105
+ .replace(/\{\{face_image\}\}/g, faceImageVal)
106
+ .replace(/\{\{faceImage\}\}/g, faceImageVal)
107
+ .replace(/\{\{face_weight\}\}/g, String(faceWeightVal))
108
+ .replace(/\{\{faceWeight\}\}/g, String(faceWeightVal))
109
+ .replace(/\{\{reference_image\}\}/g, refImageVal)
110
+ .replace(/\{\{referenceImage\}\}/g, refImageVal)
111
+ .replace(/\{\{reference_weight\}\}/g, String(refWeightVal))
112
+ .replace(/\{\{referenceWeight\}\}/g, String(refWeightVal))
99
113
  }
100
114
  if (Array.isArray(val)) {
101
115
  return val.map(transformValue)
@@ -72,8 +72,11 @@ export async function prepareGenerationPrompt({ ctx, cfg, args, exec, sessionId,
72
72
  let effectiveNegative = styleInfo.negativePrompt
73
73
  const activeAnchor = getSessionAnchor(sessionId)
74
74
  let resolvedFaceRef
75
+ let resolvedRefImage
75
76
 
76
77
  const faceRefInput = args.face_reference || (activeAnchor && (activeAnchor.mode === 'face' || activeAnchor.mode === 'character') ? activeAnchor.image : undefined)
78
+ const refImageInput = args.reference_image || (activeAnchor && activeAnchor.mode === 'style' ? activeAnchor.image : undefined)
79
+
77
80
  if (faceRefInput) {
78
81
  try {
79
82
  resolvedFaceRef = await resolveSource(ctx, exec, faceRefInput)
@@ -90,9 +93,22 @@ export async function prepareGenerationPrompt({ ctx, cfg, args, exec, sessionId,
90
93
  if (facePlan.negativePrompt) {
91
94
  effectiveNegative = effectiveNegative ? `${effectiveNegative}, ${facePlan.negativePrompt}` : facePlan.negativePrompt
92
95
  }
93
- } else if (args.reference_image) {
94
- effectivePrompt = `${effectivePrompt}, visual reference anchor`
95
- } else if (activeAnchor) {
96
+ }
97
+
98
+ if (refImageInput) {
99
+ try {
100
+ resolvedRefImage = await resolveSource(ctx, exec, refImageInput)
101
+ } catch (_err) {
102
+ resolvedRefImage = { url: refImageInput }
103
+ }
104
+ if (!faceRefInput) {
105
+ if (activeAnchor) {
106
+ effectivePrompt = applyAnchorPromptHints(activeAnchor, effectivePrompt)
107
+ } else {
108
+ effectivePrompt = `${effectivePrompt}, visual reference anchor`
109
+ }
110
+ }
111
+ } else if (!faceRefInput && activeAnchor) {
96
112
  effectivePrompt = applyAnchorPromptHints(activeAnchor, effectivePrompt)
97
113
  }
98
114
 
@@ -113,6 +129,7 @@ export async function prepareGenerationPrompt({ ctx, cfg, args, exec, sessionId,
113
129
  styleInfo,
114
130
  activeAnchor,
115
131
  resolvedFaceRef,
132
+ resolvedRefImage,
116
133
  }
117
134
  }
118
135
 
@@ -19,12 +19,18 @@ export function createCustomGenerator(deps, job) {
19
19
  const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio } = job
20
20
 
21
21
  async function custom(seedArg = seed, promptArg = prompt) {
22
+ if (job.faceReference && !source) {
23
+ throw new Error('Custom provider (OpenAI-compatible) does not support facial identity references (FaceID) — use ComfyUI (IP-Adapter/InstantID) or FAL')
24
+ }
22
25
  const base = String(cfg.customBaseURL || '').replace(/\/+$/, '')
23
- if (!base) throw new Error('Custom image provider: base URL is not configured (Settings → Image generation)')
26
+ if (!base) throw new Error('Custom image provider: base URL is not configured (Settings в†’ Image generation)')
24
27
  if (!cfg.customModel) throw new Error('Custom image provider: model is not configured')
25
28
 
26
29
  // Empty key reference indicates unauthenticated gateway.
27
30
  const key = cfg.customKeyEnv ? await resolveKey(cfg.customKeyEnv) : ''
31
+ if (job.faceReference && !source) {
32
+ throw new Error('Custom provider (OpenAI-compatible) does not support facial identity references (FaceID) — use ComfyUI (IP-Adapter/InstantID) or FAL')
33
+ }
28
34
  const isEdit = Boolean(source && source.bytes)
29
35
  const headers = {}
30
36
  if (!isEdit) {
@@ -34,6 +34,24 @@ export function createFalGenerator(deps, job) {
34
34
  if (strength !== undefined) {
35
35
  body.strength = strength
36
36
  }
37
+ } else if (job.faceReference && (job.faceReference.bytes || job.faceReference.url)) {
38
+ const refDataUri = job.faceReference.bytes
39
+ ? `data:${job.faceReference.mediaType || 'image/png'};base64,${Buffer.from(job.faceReference.bytes).toString('base64')}`
40
+ : job.faceReference.url
41
+ body.reference_image_url = refDataUri
42
+ body.face_image_url = refDataUri
43
+ if (job.faceStrength !== undefined) {
44
+ body.id_weight = job.faceStrength
45
+ body.reference_weight = job.faceStrength
46
+ }
47
+ } else if (job.referenceImage && (job.referenceImage.bytes || job.referenceImage.url)) {
48
+ const refDataUri = job.referenceImage.bytes
49
+ ? `data:${job.referenceImage.mediaType || 'image/png'};base64,${Buffer.from(job.referenceImage.bytes).toString('base64')}`
50
+ : job.referenceImage.url
51
+ body.reference_image_url = refDataUri
52
+ if (job.referenceStrength !== undefined) {
53
+ body.reference_weight = job.referenceStrength
54
+ }
37
55
  }
38
56
 
39
57
  if (typeof onProgress === 'function') {
@@ -38,6 +38,18 @@ export function createGeminiGenerator(deps, job) {
38
38
  data: sourceBase64,
39
39
  },
40
40
  })
41
+ } else {
42
+ const refTarget = job.faceReference || job.referenceImage
43
+ if (refTarget && refTarget.bytes) {
44
+ const refMime = refTarget.mediaType || 'image/png'
45
+ const refBase64 = Buffer.isBuffer(refTarget.bytes) ? refTarget.bytes.toString('base64') : Buffer.from(refTarget.bytes).toString('base64')
46
+ parts.push({
47
+ inlineData: {
48
+ mimeType: refMime,
49
+ data: refBase64,
50
+ },
51
+ })
52
+ }
41
53
  }
42
54
  parts.push({ text: promptArg })
43
55
 
@@ -92,6 +92,13 @@ export function createLocalGenerator(deps, job) {
92
92
  let workflowGraph
93
93
  if (cfg.comfyWorkflowJson) {
94
94
  const template = validateComfyWorkflow(cfg.comfyWorkflowJson)
95
+ const faceData = job.faceReference && job.faceReference.bytes
96
+ ? Buffer.from(job.faceReference.bytes).toString('base64')
97
+ : (job.faceReference?.url || '')
98
+ const refData = job.referenceImage && job.referenceImage.bytes
99
+ ? Buffer.from(job.referenceImage.bytes).toString('base64')
100
+ : (job.referenceImage?.url || '')
101
+
95
102
  workflowGraph = interpolateComfyWorkflow(template, {
96
103
  prompt: promptArg,
97
104
  negativePrompt: negativePrompt || '',
@@ -102,6 +109,10 @@ export function createLocalGenerator(deps, job) {
102
109
  cfg: cfg.localCfg ?? 7,
103
110
  model: cfg.localModel,
104
111
  image: source && source.bytes ? Buffer.from(source.bytes).toString('base64') : '',
112
+ faceImage: faceData,
113
+ faceWeight: job.faceStrength ?? 0.75,
114
+ referenceImage: refData,
115
+ referenceWeight: job.referenceStrength ?? 0.65,
105
116
  })
106
117
  } else {
107
118
  workflowGraph = buildDefaultComfyWorkflow({
@@ -31,6 +31,11 @@ export function createReplicateGenerator(deps, job) {
31
31
  image: `data:${source.mediaType || 'image/png'};base64,${Buffer.from(source.bytes).toString('base64')}`,
32
32
  ...(mask && mask.bytes ? { mask: `data:${mask.mediaType || 'image/png'};base64,${Buffer.from(mask.bytes).toString('base64')}` } : {}),
33
33
  ...(strength !== undefined ? { prompt_strength: strength } : {}),
34
+ } : (job.faceReference || job.referenceImage) ? {
35
+ face_image: (job.faceReference || job.referenceImage).bytes
36
+ ? `data:${(job.faceReference || job.referenceImage).mediaType || 'image/png'};base64,${Buffer.from((job.faceReference || job.referenceImage).bytes).toString('base64')}`
37
+ : (job.faceReference || job.referenceImage).url,
38
+ id_weight: job.faceStrength ?? 0.75,
34
39
  } : {}),
35
40
  },
36
41
  }
@@ -14,6 +14,9 @@ export function createSeedreamGenerator(deps, job) {
14
14
  const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio } = job
15
15
 
16
16
  async function seedream(seedArg = seed, promptArg = prompt) {
17
+ if (job.faceReference) {
18
+ throw new Error('Seedream does not support facial identity references (FaceID) — use ComfyUI (IP-Adapter/InstantID) or FAL')
19
+ }
17
20
  const key = await resolveKey(cfg.seedreamKeyEnv)
18
21
  const base = (cfg.seedreamBaseURL || 'https://api.bytedanceapi.com/v1').replace(/\/+$/, '')
19
22
  const timeoutSignal = typeof AbortSignal !== 'undefined' && AbortSignal.timeout
@@ -18,6 +18,9 @@ export function createSubscriptionGenerator(deps, job) {
18
18
  if (source) {
19
19
  return { ok: false, provider, reason: `${provider}: does not support image editing — use fal, custom, or local` }
20
20
  }
21
+ if (job.faceReference) {
22
+ return { ok: false, provider, reason: `${provider}: does not support facial identity references (FaceID) — use ComfyUI (IP-Adapter) or FAL` }
23
+ }
21
24
  const images = deps.subscriptionImages
22
25
  if (!images || typeof images.generate !== 'function') {
23
26
  return {
package/lib/providers.js CHANGED
@@ -640,7 +640,7 @@ export function toLosslessJson(val) {
640
640
  * Returns { ok: boolean, latencyMs: number, message: string }.
641
641
  */
642
642
  export async function testProviderConnection(deps, providerId) {
643
- const { fetchImpl = fetch, resolveKey, cfg } = deps
643
+ const { fetchImpl = fetch, resolveKey, cfg } = deps || {}
644
644
  const start = Date.now()
645
645
  const rawProvider = (typeof providerId === 'string' && providerId.trim())
646
646
  || (typeof cfg?.provider === 'string' && cfg.provider.trim())
@@ -648,128 +648,199 @@ export async function testProviderConnection(deps, providerId) {
648
648
  || 'fal'
649
649
  const p = rawProvider.toLowerCase()
650
650
 
651
+ const safeTimeout = (ms) => {
652
+ return typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(ms) : undefined
653
+ }
654
+
651
655
  try {
652
656
  if (p === 'fal') {
653
- const key = await resolveKey(cfg.apiKeyEnv || 'FAL_KEY')
654
- if (!key) return { ok: false, latencyMs: 0, message: 'FAL API key not configured' }
655
- const res = await fetchImpl('https://queue.fal.run/fal-ai/fast-sdxl/status', {
656
- headers: { Authorization: falAuthHeader(key) },
657
- signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(6000) : undefined,
658
- })
657
+ const key = await resolveKey(cfg?.apiKeyEnv || 'FAL_KEY')
658
+ if (!key) return { ok: false, state: 'not_configured', latencyMs: 0, message: 'FAL API key not configured' }
659
+ let res
660
+ try {
661
+ res = await fetchImpl('https://queue.fal.run/fal-ai/fast-sdxl/status', {
662
+ headers: { Authorization: falAuthHeader(key) },
663
+ signal: safeTimeout(6000),
664
+ })
665
+ } catch (netErr) {
666
+ return { ok: false, state: 'offline', latencyMs: Date.now() - start, message: `Fal queue unreachable: ${netErr.message || 'connection error'}` }
667
+ }
659
668
  const latencyMs = Date.now() - start
660
669
  if (res.status === 401 || res.status === 403) {
661
- return { ok: false, latencyMs, message: `Fal auth failed (HTTP ${res.status})` }
670
+ return { ok: false, state: 'unauthorized', latencyMs, message: `Fal auth failed (HTTP ${res.status})` }
671
+ }
672
+ if (res.status >= 500) {
673
+ return { ok: false, state: 'server_error', latencyMs, message: `Fal server error (HTTP ${res.status})` }
662
674
  }
663
- return { ok: true, latencyMs, message: 'Fal.ai queue reachable' }
675
+ return { ok: true, state: 'authenticated', latencyMs, message: 'Fal.ai queue reachable' }
664
676
  }
665
677
 
666
- if (p === 'comfyui') {
667
- const url = cfg.comfyuiUrl || cfg.localBaseURL || 'http://127.0.0.1:8188'
668
- const res = await fetchImpl(`${url.replace(/\/+$/, '')}/system_stats`, {
669
- signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(5000) : undefined,
670
- })
671
- const latencyMs = Date.now() - start
672
- if (res.ok) {
673
- const data = await res.json().catch(() => ({}))
674
- const vram = data?.devices?.[0]?.vram_free ? ` (${Math.round(data.devices[0].vram_free / 1048576)} MB free VRAM)` : ''
675
- return { ok: true, latencyMs, message: `ComfyUI operational${vram}` }
678
+ if (p === 'local' || p === 'comfyui' || p === 'a1111') {
679
+ const backend = (p === 'local' ? (cfg?.localBackend || 'comfyui') : p).toLowerCase()
680
+ if (backend === 'a1111') {
681
+ const url = cfg?.a1111Url || cfg?.localBaseURL || 'http://127.0.0.1:7860'
682
+ let res
683
+ try {
684
+ res = await fetchImpl(`${url.replace(/\/+$/, '')}/sdapi/v1/samplers`, {
685
+ signal: safeTimeout(5000),
686
+ })
687
+ } catch (netErr) {
688
+ return { ok: false, state: 'offline', latencyMs: Date.now() - start, message: `Automatic1111 offline (${netErr.message || 'connection refused'})` }
689
+ }
690
+ const latencyMs = Date.now() - start
691
+ if (res.status === 401 || res.status === 403) {
692
+ return { ok: false, state: 'unauthorized', latencyMs, message: `A1111 auth error (HTTP ${res.status})` }
693
+ }
694
+ if (res.status >= 500) {
695
+ return { ok: false, state: 'server_error', latencyMs, message: `A1111 server error (HTTP ${res.status})` }
696
+ }
697
+ if (res.ok) {
698
+ return { ok: true, state: 'model_available', latencyMs, message: 'Automatic1111 operational' }
699
+ }
700
+ return { ok: false, state: 'server_error', latencyMs, message: `A1111 returned HTTP ${res.status}` }
676
701
  }
677
- return { ok: false, latencyMs, message: `ComfyUI returned HTTP ${res.status}` }
678
- }
679
702
 
680
- if (p === 'a1111') {
681
- const url = cfg.a1111Url || cfg.localBaseURL || 'http://127.0.0.1:7860'
682
- const res = await fetchImpl(`${url.replace(/\/+$/, '')}/sdapi/v1/samplers`, {
683
- signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(5000) : undefined,
684
- })
703
+ // Default local is ComfyUI
704
+ const url = cfg?.comfyuiUrl || cfg?.localBaseURL || 'http://127.0.0.1:8188'
705
+ let res
706
+ try {
707
+ res = await fetchImpl(`${url.replace(/\/+$/, '')}/system_stats`, {
708
+ signal: safeTimeout(5000),
709
+ })
710
+ } catch (netErr) {
711
+ return { ok: false, state: 'offline', latencyMs: Date.now() - start, message: `ComfyUI offline (${netErr.message || 'connection refused'})` }
712
+ }
685
713
  const latencyMs = Date.now() - start
714
+ if (res.status === 401 || res.status === 403) {
715
+ return { ok: false, state: 'unauthorized', latencyMs, message: `ComfyUI auth error (HTTP ${res.status})` }
716
+ }
717
+ if (res.status >= 500) {
718
+ return { ok: false, state: 'server_error', latencyMs, message: `ComfyUI server error (HTTP ${res.status})` }
719
+ }
686
720
  if (res.ok) {
687
- return { ok: true, latencyMs, message: 'Automatic1111 operational' }
721
+ const data = await res.json().catch(() => ({}))
722
+ const vram = data?.devices?.[0]?.vram_free ? ` (${Math.round(data.devices[0].vram_free / 1048576)} MB free VRAM)` : ''
723
+ return { ok: true, state: 'model_available', latencyMs, message: `ComfyUI operational${vram}` }
688
724
  }
689
- return { ok: false, latencyMs, message: `A1111 returned HTTP ${res.status}` }
725
+ return { ok: false, state: 'server_error', latencyMs, message: `ComfyUI returned HTTP ${res.status}` }
690
726
  }
691
727
 
692
728
  if (p === 'custom') {
693
- const url = cfg.customEndpoint || cfg.customBaseURL || 'https://api.openai.com/v1'
694
- const key = await resolveKey(cfg.customApiKeyEnv || cfg.customKeyEnv || 'OPENAI_API_KEY')
729
+ const url = cfg?.customEndpoint || cfg?.customBaseURL || 'https://api.openai.com/v1'
730
+ const key = await resolveKey(cfg?.customApiKeyEnv || cfg?.customKeyEnv || 'OPENAI_API_KEY')
695
731
  const headers = key ? { Authorization: `Bearer ${key}` } : {}
696
- const res = await fetchImpl(`${url.replace(/\/+$/, '')}/models`, {
697
- headers,
698
- signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(6000) : undefined,
699
- })
732
+ let res
733
+ try {
734
+ res = await fetchImpl(`${url.replace(/\/+$/, '')}/models`, {
735
+ headers,
736
+ signal: safeTimeout(6000),
737
+ })
738
+ } catch (netErr) {
739
+ return { ok: false, state: 'offline', latencyMs: Date.now() - start, message: `Custom endpoint offline (${netErr.message || 'connection error'})` }
740
+ }
700
741
  const latencyMs = Date.now() - start
701
742
  if (res.status === 401 || res.status === 403) {
702
- return { ok: false, latencyMs, message: `Custom endpoint auth error (HTTP ${res.status})` }
743
+ return { ok: false, state: 'unauthorized', latencyMs, message: `Custom endpoint auth error (HTTP ${res.status})` }
744
+ }
745
+ if (res.status >= 500) {
746
+ return { ok: false, state: 'server_error', latencyMs, message: `Custom endpoint server error (HTTP ${res.status})` }
703
747
  }
704
748
  if (res.ok || res.status === 404 || res.status === 405) {
705
- return { ok: true, latencyMs, message: `Custom gateway reachable (HTTP ${res.status})` }
749
+ return { ok: true, state: res.ok ? 'authenticated' : 'reachable', latencyMs, message: `Custom gateway reachable (HTTP ${res.status})` }
706
750
  }
707
- return { ok: false, latencyMs, message: `Custom endpoint returned HTTP ${res.status}` }
751
+ return { ok: false, state: 'server_error', latencyMs, message: `Custom endpoint returned HTTP ${res.status}` }
708
752
  }
709
753
 
710
754
  if (p === 'subscription' || p === 'chatgpt' || p === 'grok' || p === 'codex') {
711
- const hasSub = Boolean(deps.ctx?.subscriptions)
755
+ const subService = deps?.subscriptionImages
756
+ || (deps?.ctx?.get && deps.ctx.get('subscriptionImages'))
757
+ || deps?.ctx?.subscriptionImages
758
+ const hasSub = Boolean(subService && typeof subService.generate === 'function')
712
759
  const latencyMs = Date.now() - start
713
760
  if (hasSub) {
714
- return { ok: true, latencyMs, message: 'Subscription bridge available' }
761
+ return { ok: true, state: 'authenticated', latencyMs, message: 'Subscription bridge available' }
715
762
  }
716
- return { ok: false, latencyMs, message: 'dsh-subscriptions plugin not installed' }
763
+ return { ok: false, state: 'not_configured', latencyMs, message: 'subscriptionImages service not available (requires dsh-subscriptions plugin)' }
717
764
  }
718
765
 
719
766
  if (p === 'replicate') {
720
- const key = await resolveKey(cfg.replicateKeyEnv || 'REPLICATE_API_TOKEN')
721
- if (!key) return { ok: false, latencyMs: 0, message: 'Replicate API token not configured' }
722
- const res = await fetchImpl('https://api.replicate.com/v1/models', {
723
- headers: { Authorization: `Bearer ${key}` },
724
- signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(6000) : undefined,
725
- })
767
+ const key = await resolveKey(cfg?.replicateKeyEnv || 'REPLICATE_API_TOKEN')
768
+ if (!key) return { ok: false, state: 'not_configured', latencyMs: 0, message: 'Replicate API token not configured' }
769
+ let res
770
+ try {
771
+ res = await fetchImpl('https://api.replicate.com/v1/models', {
772
+ headers: { Authorization: `Bearer ${key}` },
773
+ signal: safeTimeout(6000),
774
+ })
775
+ } catch (netErr) {
776
+ return { ok: false, state: 'offline', latencyMs: Date.now() - start, message: `Replicate unreachable (${netErr.message || 'connection error'})` }
777
+ }
726
778
  const latencyMs = Date.now() - start
727
779
  if (res.status === 401 || res.status === 403) {
728
- return { ok: false, latencyMs, message: `Replicate auth error (HTTP ${res.status})` }
780
+ return { ok: false, state: 'unauthorized', latencyMs, message: `Replicate auth error (HTTP ${res.status})` }
781
+ }
782
+ if (res.status >= 500) {
783
+ return { ok: false, state: 'server_error', latencyMs, message: `Replicate server error (HTTP ${res.status})` }
729
784
  }
730
785
  if (res.ok) {
731
- return { ok: true, latencyMs, message: 'Replicate API reachable' }
786
+ return { ok: true, state: 'authenticated', latencyMs, message: 'Replicate API reachable' }
732
787
  }
733
- return { ok: false, latencyMs, message: `Replicate returned HTTP ${res.status}` }
788
+ return { ok: false, state: 'server_error', latencyMs, message: `Replicate returned HTTP ${res.status}` }
734
789
  }
735
790
 
736
791
  if (p === 'gemini') {
737
- const key = await resolveKey(cfg.geminiKeyEnv || 'GEMINI_API_KEY')
738
- if (!key) return { ok: false, latencyMs: 0, message: 'Google Gemini API key not configured' }
739
- const model = cfg.geminiModel || 'gemini-2.0-flash-exp-image-generation'
740
- const res = await fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${model}?key=${encodeURIComponent(key)}`, {
741
- signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(6000) : undefined,
742
- })
792
+ const key = await resolveKey(cfg?.geminiKeyEnv || 'GEMINI_API_KEY')
793
+ if (!key) return { ok: false, state: 'not_configured', latencyMs: 0, message: 'Google Gemini API key not configured' }
794
+ const model = cfg?.geminiModel || 'gemini-2.0-flash-exp-image-generation'
795
+ let res
796
+ try {
797
+ res = await fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${model}?key=${encodeURIComponent(key)}`, {
798
+ signal: safeTimeout(6000),
799
+ })
800
+ } catch (netErr) {
801
+ return { ok: false, state: 'offline', latencyMs: Date.now() - start, message: `Google Gemini API unreachable (${netErr.message || 'connection error'})` }
802
+ }
743
803
  const latencyMs = Date.now() - start
744
804
  if (res.status === 400 || res.status === 401 || res.status === 403) {
745
- return { ok: false, latencyMs, message: `Gemini API auth error (HTTP ${res.status})` }
805
+ return { ok: false, state: 'unauthorized', latencyMs, message: `Gemini API auth error (HTTP ${res.status})` }
806
+ }
807
+ if (res.status >= 500) {
808
+ return { ok: false, state: 'server_error', latencyMs, message: `Gemini API server error (HTTP ${res.status})` }
746
809
  }
747
810
  if (res.ok) {
748
- return { ok: true, latencyMs, message: 'Gemini Vision API operational' }
811
+ return { ok: true, state: 'model_available', latencyMs, message: 'Gemini Vision API operational' }
749
812
  }
750
- return { ok: false, latencyMs, message: `Gemini returned HTTP ${res.status}` }
813
+ return { ok: false, state: 'server_error', latencyMs, message: `Gemini returned HTTP ${res.status}` }
751
814
  }
752
815
 
753
816
  if (p === 'seedream') {
754
- const url = cfg.seedreamBaseURL || 'https://api.bytedanceapi.com/v1'
755
- const key = await resolveKey(cfg.seedreamKeyEnv || 'SEEDREAM_API_KEY')
817
+ const url = cfg?.seedreamBaseURL || 'https://api.bytedanceapi.com/v1'
818
+ const key = await resolveKey(cfg?.seedreamKeyEnv || 'SEEDREAM_API_KEY')
756
819
  const headers = key ? { Authorization: `Bearer ${key}` } : {}
757
- const res = await fetchImpl(`${url.replace(/\/+$/, '')}/models`, {
758
- headers,
759
- signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(6000) : undefined,
760
- }).catch(() => null)
820
+ let res
821
+ try {
822
+ res = await fetchImpl(`${url.replace(/\/+$/, '')}/models`, {
823
+ headers,
824
+ signal: safeTimeout(6000),
825
+ })
826
+ } catch (netErr) {
827
+ return { ok: false, state: 'offline', latencyMs: Date.now() - start, message: `Seedream endpoint offline (${netErr.message || 'connection error'})` }
828
+ }
761
829
  const latencyMs = Date.now() - start
762
- if (res && (res.ok || res.status === 404 || res.status === 405)) {
763
- return { ok: true, latencyMs, message: 'Seedream endpoint reachable' }
830
+ if (res.status === 401 || res.status === 403) {
831
+ return { ok: false, state: 'unauthorized', latencyMs, message: `Seedream auth error (HTTP ${res.status})` }
764
832
  }
765
- if (res && (res.status === 401 || res.status === 403)) {
766
- return { ok: false, latencyMs, message: `Seedream auth error (HTTP ${res.status})` }
833
+ if (res.status >= 500) {
834
+ return { ok: false, state: 'server_error', latencyMs, message: `Seedream server error (HTTP ${res.status})` }
835
+ }
836
+ if (res.ok || res.status === 404 || res.status === 405) {
837
+ return { ok: true, state: res.ok ? 'authenticated' : 'reachable', latencyMs, message: 'Seedream endpoint reachable' }
767
838
  }
768
- return { ok: true, latencyMs, message: 'Seedream provider endpoint configured' }
839
+ return { ok: false, state: 'server_error', latencyMs, message: `Seedream returned HTTP ${res.status}` }
769
840
  }
770
841
 
771
- return { ok: true, latencyMs: Date.now() - start, message: `Provider "${p}" configured` }
842
+ return { ok: false, state: 'unsupported', latencyMs: Date.now() - start, message: `Unknown or unsupported provider: "${p}"` }
772
843
  } catch (err) {
773
- return { ok: false, latencyMs: Date.now() - start, message: err.message || 'Connection timeout' }
844
+ return { ok: false, state: 'offline', latencyMs: Date.now() - start, message: err.message || 'Connection timeout' }
774
845
  }
775
846
  }
@@ -49,13 +49,15 @@ export function registerAllTools(ctx, deps) {
49
49
  if (currentCfg && currentCfg.enabled === false) {
50
50
  throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
51
51
  }
52
- const callId = exec?.callId || exec?.rootCallId
52
+ const safeArgs = args || {}
53
+ const safeExec = exec || {}
54
+ const callId = safeExec.callId || safeExec.rootCallId
53
55
  if (callId) {
54
- createLiveProgressSession(callId, { signal: exec.signal })
55
- exec.onProgress = (update) => publishLiveProgress(callId, update)
56
+ createLiveProgressSession(callId, { signal: safeExec.signal })
57
+ safeExec.onProgress = (update) => publishLiveProgress(callId, update)
56
58
  }
57
59
  try {
58
- const res = await originalExecute(args, exec)
60
+ const res = await originalExecute(safeArgs, safeExec)
59
61
  if (callId) completeLiveProgress(callId)
60
62
  return res
61
63
  } catch (err) {
@@ -34,6 +34,11 @@ export function registerAnchorTools(ctx, deps) {
34
34
  enum: ['style', 'character', 'face'],
35
35
  description: 'Anchor mode: "face" preserves facial portrait identity via FaceID/IP-Adapter; "character" preserves overall character traits; "style" preserves aesthetic palette. Default: style.',
36
36
  },
37
+ anchor_type: {
38
+ type: 'string',
39
+ enum: ['style', 'character', 'face'],
40
+ description: 'Alias for mode ("style", "character", "face").',
41
+ },
37
42
  strength: {
38
43
  type: 'number',
39
44
  description: 'Reference influence strength between 0.1 and 1.0 (default 0.65).',
@@ -95,9 +100,11 @@ export function registerAnchorTools(ctx, deps) {
95
100
  }
96
101
 
97
102
  const imageRef = args.image.trim()
98
- const mode = args.mode === 'character' ? 'character' : 'style'
99
- const strength = normalizeAnchorStrength(args.strength)
100
- const label = args.label ? String(args.label).trim() : (mode === 'character' ? 'Character Anchor' : 'Style Anchor')
103
+ const rawMode = String(args.mode || args.anchor_type || '').toLowerCase().trim()
104
+ const mode = (rawMode === 'character' || rawMode === 'face') ? rawMode : 'style'
105
+ const strength = normalizeAnchorStrength(args.strength, mode === 'face' || mode === 'character' ? 0.75 : 0.65)
106
+ const defaultLabel = mode === 'face' ? 'Face Identity Anchor' : (mode === 'character' ? 'Character Anchor' : 'Style Anchor')
107
+ const label = args.label ? String(args.label).trim() : defaultLabel
101
108
 
102
109
  // Validate source readability if it is a local path or attachment
103
110
  if (resolveSource && (imageRef.startsWith('sha256:') || !imageRef.startsWith('http'))) {
@@ -107,6 +107,10 @@ export function registerCharacterSheetTools(ctx, deps) {
107
107
  throw new Error('Image generation is disabled in settings.')
108
108
  }
109
109
 
110
+ if (!args?.prompt || typeof args.prompt !== 'string' || !args.prompt.trim()) {
111
+ throw new Error('Character description prompt is required for character sheet generation.')
112
+ }
113
+
110
114
  const provider = PROVIDER_KEYS.includes(args.provider || cfg.provider)
111
115
  ? (args.provider || cfg.provider)
112
116
  : (PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal')
@@ -103,6 +103,10 @@ export function registerDiagramTools(ctx, deps) {
103
103
  throw new Error('Image generation is disabled in settings.')
104
104
  }
105
105
 
106
+ if (!args?.diagram || typeof args.diagram !== 'string' || !args.diagram.trim()) {
107
+ throw new Error('Diagram content (Mermaid code, ASCII, or description) is required for beautify_diagram.')
108
+ }
109
+
106
110
  const provider = PROVIDER_KEYS.includes(args.provider || cfg.provider)
107
111
  ? (args.provider || cfg.provider)
108
112
  : (PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal')
@@ -30,7 +30,10 @@ export function registerGenerationPackTool(ctx, deps) {
30
30
  },
31
31
  async execute(args, exec) {
32
32
  const ratios = Array.isArray(args.aspect_ratios) && args.aspect_ratios.length ? args.aspect_ratios : ['1:1', '16:9', '9:16']
33
- const generateTool = ctx.tools.get('generate_image')
33
+ const generateTool = ctx?.tools?.get ? ctx.tools.get('generate_image') : (ctx?.tools?.list?.find ? ctx.tools.list.find((t) => t.name === 'generate_image') : null)
34
+ if (!generateTool || typeof generateTool.execute !== 'function') {
35
+ throw new Error('Required tool "generate_image" is not available on host.')
36
+ }
34
37
  const results = []
35
38
  const warnings = []
36
39
  const seedBase = Math.floor(Math.random() * 100000)
@@ -300,7 +300,7 @@ export function registerGenerationTools(ctx, deps, options = {}) {
300
300
  const cfg = live()
301
301
 
302
302
  // #168: Fail-fast loop guard protecting against runaway agent loops
303
- const sessionId = exec.agent?.session?.id || exec.agent?.session?.header?.id || 'session'
303
+ const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
304
304
  if (cfg.loopGuardLimit > 0) {
305
305
  trackAndAssertLoopGuard(sessionId, {
306
306
  limit: cfg.loopGuardLimit,
@@ -326,6 +326,7 @@ export function registerGenerationTools(ctx, deps, options = {}) {
326
326
  effectiveGuidance,
327
327
  activeAnchor,
328
328
  resolvedFaceRef,
329
+ resolvedRefImage,
329
330
  } = await prepareGenerationPrompt({ ctx, cfg, args, exec, sessionId, provider, resolveSource })
330
331
 
331
332
  // #167: Assert daily budget availability before calling API
@@ -339,7 +340,28 @@ export function registerGenerationTools(ctx, deps, options = {}) {
339
340
  assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
340
341
  const providers = makeProviders(
341
342
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
342
- { prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: effectiveNegative, guidanceScale: effectiveGuidance, source, mask, strength: args.strength, quality: args.quality, style: args.style, aspectPixels, aspectRatio: args.aspect_ratio, faceReference: resolvedFaceRef, onProgress: exec?.onProgress, callId: exec?.callId },
343
+ {
344
+ prompt: effectivePrompt,
345
+ size,
346
+ format,
347
+ seed: args.seed,
348
+ signal: exec?.signal,
349
+ negativePrompt: effectiveNegative,
350
+ guidanceScale: effectiveGuidance,
351
+ source,
352
+ mask,
353
+ strength: args.strength,
354
+ quality: args.quality,
355
+ style: args.style,
356
+ aspectPixels,
357
+ aspectRatio: args.aspect_ratio,
358
+ faceReference: resolvedFaceRef,
359
+ faceStrength: args.face_strength || activeAnchor?.strength,
360
+ referenceImage: resolvedRefImage,
361
+ referenceStrength: args.reference_strength || activeAnchor?.strength,
362
+ onProgress: exec?.onProgress,
363
+ callId: exec?.callId,
364
+ },
343
365
  )
344
366
  // Subscription providers return {ok:false, reason} instead of throwing:
345
367
  // rejection reaches the model as text. Without this guard,
@@ -445,7 +467,7 @@ export function registerGenerationTools(ctx, deps, options = {}) {
445
467
  const stem = `${slugify(args.output_name || promptVal)}-${Date.now().toString(36)}-${seedVal}`
446
468
  const name = `${stem}.${extension}`
447
469
  const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: diskHit.bytes, mediaType: diskHit.mediaType, name })
448
- const sessionCwd = exec.agent?.session?.header?.cwd
470
+ const sessionCwd = exec?.agent?.session?.header?.cwd
449
471
  const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
450
472
  const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
451
473
  await mkdir(outDir, { recursive: true })
@@ -58,12 +58,12 @@ export function registerProcessingTools(ctx, deps) {
58
58
  assertBudgetAvailable(0.02, cfg.dailyBudgetUsd)
59
59
  const res = await removeBackgroundFal(
60
60
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
61
- { imageBytes: source.bytes, mediaType: source.mediaType, model: args.model, signal: exec.signal }
61
+ { imageBytes: source.bytes, mediaType: source.mediaType, model: args.model, signal: exec?.signal }
62
62
  )
63
63
  const stem = `${slugify(args.output_name || 'nobg')}-${Date.now().toString(36)}`
64
64
  const name = `${stem}.png`
65
65
  const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
66
- const sessionCwd = exec.agent?.session?.header?.cwd
66
+ const sessionCwd = exec?.agent?.session?.header?.cwd
67
67
  const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
68
68
  await mkdir(outDir, { recursive: true })
69
69
  const filePath = path.join(outDir, name)
@@ -129,12 +129,12 @@ export function registerProcessingTools(ctx, deps) {
129
129
  assertBudgetAvailable(0.025, cfg.dailyBudgetUsd)
130
130
  const res = await upscaleImageFal(
131
131
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
132
- { imageBytes: source.bytes, mediaType: source.mediaType, scale: args.scale, prompt: args.prompt, creativity: args.creativity, signal: exec.signal }
132
+ { imageBytes: source.bytes, mediaType: source.mediaType, scale: args.scale, prompt: args.prompt, creativity: args.creativity, signal: exec?.signal }
133
133
  )
134
134
  const stem = `${slugify(args.output_name || 'upscaled')}-${Date.now().toString(36)}`
135
135
  const name = `${stem}.png`
136
136
  const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
137
- const sessionCwd = exec.agent?.session?.header?.cwd
137
+ const sessionCwd = exec?.agent?.session?.header?.cwd
138
138
  const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
139
139
  await mkdir(outDir, { recursive: true })
140
140
  const filePath = path.join(outDir, name)
@@ -196,7 +196,7 @@ export function registerProcessingTools(ctx, deps) {
196
196
  const res = await traceToSvg(source.bytes, { colorMode: args.color_mode })
197
197
  const stem = `${slugify(args.output_name || 'vector')}-${Date.now().toString(36)}`
198
198
  const name = `${stem}.svg`
199
- const sessionCwd = exec.agent?.session?.header?.cwd
199
+ const sessionCwd = exec?.agent?.session?.header?.cwd
200
200
  const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
201
201
  await mkdir(outDir, { recursive: true })
202
202
  const filePath = path.join(outDir, name)
@@ -268,12 +268,12 @@ export function registerProcessingTools(ctx, deps) {
268
268
  }
269
269
  const res = await blendImagesFal(
270
270
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
271
- { images: sources, weights: args.weights, prompt: args.prompt, signal: exec.signal }
271
+ { images: sources, weights: args.weights, prompt: args.prompt, signal: exec?.signal }
272
272
  )
273
273
  const stem = `${slugify(args.output_name || 'blended')}-${Date.now().toString(36)}`
274
274
  const name = `${stem}.png`
275
275
  const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
276
- const sessionCwd = exec.agent?.session?.header?.cwd
276
+ const sessionCwd = exec?.agent?.session?.header?.cwd
277
277
  const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
278
278
  await mkdir(outDir, { recursive: true })
279
279
  const filePath = path.join(outDir, name)
@@ -151,7 +151,7 @@ export function registerStyleMatrixTools(ctx, deps) {
151
151
  const quadrantIds = ['A', 'B', 'C', 'D']
152
152
  const matrixId = `matrix-${Date.now().toString(36)}`
153
153
 
154
- const sessionCwd = exec.agent?.session?.header?.cwd
154
+ const sessionCwd = exec?.agent?.session?.header?.cwd
155
155
  const targetDir = cfg.outputDir || 'generated/images'
156
156
  const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
157
157
  await mkdir(outDir, { recursive: true })
@@ -168,7 +168,7 @@ export function registerStyleMatrixTools(ctx, deps) {
168
168
  size: 'custom',
169
169
  format: 'png',
170
170
  seed: cellSeed,
171
- signal: exec.signal,
171
+ signal: exec?.signal,
172
172
  aspectPixels,
173
173
  aspectRatio,
174
174
  },
@@ -158,7 +158,7 @@ export function registerThemePairTools(ctx, deps) {
158
158
  const chain = resolveFallbackChain(primaryProvider, cfg.fallbackProviders, PROVIDER_KEYS)
159
159
  const baseSeed = typeof args.seed === 'number' ? args.seed : Math.floor(Math.random() * 1000000)
160
160
 
161
- const sessionCwd = exec.agent?.session?.header?.cwd
161
+ const sessionCwd = exec?.agent?.session?.header?.cwd
162
162
  const targetDir = cfg.outputDir || 'generated/images'
163
163
  const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
164
164
  await mkdir(outDir, { recursive: true })
@@ -176,7 +176,7 @@ export function registerThemePairTools(ctx, deps) {
176
176
  aspectPixels,
177
177
  aspectRatio,
178
178
  seed: baseSeed,
179
- signal: exec.signal,
179
+ signal: exec?.signal,
180
180
  }
181
181
  const lightProviders = makeProviders(
182
182
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
@@ -214,7 +214,7 @@ export function registerThemePairTools(ctx, deps) {
214
214
  aspectPixels,
215
215
  aspectRatio,
216
216
  seed: baseSeed,
217
- signal: exec.signal,
217
+ signal: exec?.signal,
218
218
  }
219
219
  const darkProviders = makeProviders(
220
220
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
@@ -156,7 +156,7 @@ export function registerUiAssetTools(ctx, deps) {
156
156
  size: 'custom',
157
157
  format: 'png',
158
158
  seed: args.seed,
159
- signal: exec.signal,
159
+ signal: exec?.signal,
160
160
  aspectPixels,
161
161
  aspectRatio,
162
162
  },
@@ -187,7 +187,7 @@ export function registerUiAssetTools(ctx, deps) {
187
187
  try {
188
188
  const bgResult = await removeBackgroundFal(
189
189
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
190
- { imageBytes: finalBytes, mediaType, signal: exec.signal },
190
+ { imageBytes: finalBytes, mediaType, signal: exec?.signal },
191
191
  )
192
192
  if (bgResult && bgResult.bytes) {
193
193
  finalBytes = bgResult.bytes
@@ -216,7 +216,7 @@ export function registerUiAssetTools(ctx, deps) {
216
216
  meta: { provider, model: cfg.model || cfg.customModel, prompt: synthesizedPrompt, seed: seedVal },
217
217
  })
218
218
 
219
- const sessionCwd = exec.agent?.session?.header?.cwd
219
+ const sessionCwd = exec?.agent?.session?.header?.cwd
220
220
  const targetDir = cfg.outputDir || 'generated/images'
221
221
  const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
222
222
  await mkdir(outDir, { recursive: true })
@@ -137,6 +137,10 @@ export function registerVisionOcrTools(ctx, deps) {
137
137
  throw new Error('Image generation is disabled in settings.')
138
138
  }
139
139
 
140
+ if (!args?.image || typeof args.image !== 'string' || !args.image.trim()) {
141
+ throw new Error('Parameter "image" (URL, file path, or sha256 attachment ID) is required for text replacement.')
142
+ }
143
+
140
144
  const provider = PROVIDER_KEYS.includes(args.provider || cfg.provider)
141
145
  ? (args.provider || cfg.provider)
142
146
  : (PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal')
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.11.27",
4
- "description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers \u2014 the FAL queue, any OpenAI-compatible images API, or a ChatGPT/Grok subscription with no API key at all. The picture is shown inline in the conversation; the model receives either a link (works with any chat model) or the image itself (needs dsh-vision-bridge or a vision-capable model).",
3
+ "version": "0.11.28",
4
+ "description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers — the FAL queue, any OpenAI-compatible images API, or a ChatGPT/Grok subscription with no API key at all. The picture is shown inline in the conversation; the model receives either a link (works with any chat model) or the image itself (needs dsh-vision-bridge or a vision-capable model).",
5
5
  "keywords": [
6
6
  "deepseek-harness",
7
7
  "dsh",