@goodandready/dsh-image-gen 0.11.21 → 0.11.23

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.
@@ -105,14 +105,53 @@ export function formatA1111Parameters(metadata = {}) {
105
105
  import { createHash } from 'node:crypto'
106
106
 
107
107
  /** Deterministic sha256 hash for image generation caching. */
108
- export function computeGenerationHash({ provider, model, prompt, seed, size, style }) {
108
+ /**
109
+ * Deterministic sha256 hash for image generation caching (#170, #357).
110
+ * Incorporates full provider job identity: provider, model, prompt, seed, size, style,
111
+ * format, aspect ratio, negative prompt, quality, strength, and source/mask image content hashes.
112
+ */
113
+ export function computeGenerationHash({
114
+ provider,
115
+ model,
116
+ prompt,
117
+ seed,
118
+ size,
119
+ style,
120
+ format,
121
+ aspectRatio,
122
+ negativePrompt,
123
+ quality,
124
+ strength,
125
+ sourceImage,
126
+ maskImage,
127
+ reference,
128
+ } = {}) {
129
+ const hashContent = (item) => {
130
+ if (!item) return ''
131
+ if (Buffer.isBuffer(item)) return createHash('sha256').update(item).digest('hex').slice(0, 16)
132
+ if (typeof item === 'object' && item.bytes) {
133
+ const b = Buffer.isBuffer(item.bytes) ? item.bytes : Buffer.from(item.bytes)
134
+ return createHash('sha256').update(b).digest('hex').slice(0, 16)
135
+ }
136
+ return String(item).trim()
137
+ }
138
+
109
139
  const norm = [
140
+ 'v2', // namespace versioning (#357)
110
141
  String(provider || '').trim().toLowerCase(),
111
142
  String(model || '').trim().toLowerCase(),
112
143
  String(prompt || '').trim(),
113
144
  String(seed ?? ''),
114
145
  String(size || '').trim().toLowerCase(),
115
146
  String(style || '').trim().toLowerCase(),
147
+ String(format || '').trim().toLowerCase(),
148
+ String(aspectRatio || '').trim().toLowerCase(),
149
+ String(negativePrompt || '').trim(),
150
+ String(quality || '').trim().toLowerCase(),
151
+ strength !== undefined && strength !== null ? String(strength) : '',
152
+ hashContent(sourceImage),
153
+ hashContent(maskImage),
154
+ hashContent(reference),
116
155
  ].join('|')
117
156
  return createHash('sha256').update(norm).digest('hex')
118
157
  }
@@ -23,16 +23,27 @@ export function createCustomGenerator(deps, job) {
23
23
 
24
24
  // Empty key reference indicates unauthenticated gateway.
25
25
  const key = cfg.customKeyEnv ? await resolveKey(cfg.customKeyEnv) : ''
26
- const headers = { 'Content-Type': 'application/json' }
26
+ const isEdit = Boolean(source && source.bytes)
27
+ const headers = {}
28
+ if (!isEdit) {
29
+ headers['Content-Type'] = 'application/json'
30
+ }
27
31
  if (key) headers.Authorization = `Bearer ${key}`
28
32
 
29
33
  // Omit response_format; modern models return either base64 or URL.
30
- const endpoint = buildEndpointUrl(base, source ? 'images/edits' : 'images/generations')
34
+ const endpoint = buildEndpointUrl(base, isEdit ? 'images/edits' : 'images/generations')
31
35
  const res = await fetchImpl(endpoint, {
32
36
  method: 'POST',
33
37
  headers,
34
- body: source
35
- ? buildEditForm({ source, mask, prompt: promptArg, size: cfg.customSize || (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size, strength })
38
+ body: isEdit
39
+ ? buildEditForm({
40
+ model: cfg.customModel,
41
+ source,
42
+ mask,
43
+ prompt: promptArg,
44
+ size: cfg.customSize || (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size,
45
+ strength,
46
+ })
36
47
  : JSON.stringify({
37
48
  model: cfg.customModel,
38
49
  prompt: promptArg,
@@ -24,7 +24,14 @@ export function createLocalGenerator(deps, job) {
24
24
  const base = String(cfg.localBaseURL || '').replace(/\/+$/, '')
25
25
  if (!base) throw new Error('Local image provider: server address is not configured (Settings → Image generation)')
26
26
  const [width, height] = pxSize(aspectPixels, size)
27
- const kind = cfg.localKind === 'a1111' ? 'a1111' : 'comfyui'
27
+ const rawKind = cfg.localKind || 'a1111'
28
+ const kind = (rawKind === 'a1111' || rawKind === 'automatic1111')
29
+ ? 'a1111'
30
+ : (rawKind === 'comfyui' ? 'comfyui' : null)
31
+
32
+ if (!kind) {
33
+ throw new Error(`Unknown local generation backend kind "${rawKind}". Expected "a1111" or "comfyui".`)
34
+ }
28
35
 
29
36
  if (kind === 'a1111') {
30
37
  const isImg2Img = Boolean(source && source.bytes)
@@ -86,8 +86,9 @@ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollInterval
86
86
  }
87
87
  }
88
88
 
89
- export function buildEditForm({ source, mask, prompt, size, strength }) {
89
+ export function buildEditForm({ source, mask, prompt, size, strength, model }) {
90
90
  const form = new FormData()
91
+ if (model) form.append('model', model)
91
92
  form.append('image', new Blob([source.bytes], { type: source.mediaType || 'image/png' }), 'source.png')
92
93
  if (mask) form.append('mask', new Blob([mask.bytes], { type: mask.mediaType || 'image/png' }), 'mask.png')
93
94
  form.append('prompt', prompt)
@@ -26,6 +26,31 @@ import { registerDiagramTools } from './tools/diagram.js'
26
26
  * @param {object} deps shared runtime from apply()
27
27
  */
28
28
  export function registerAllTools(ctx, deps) {
29
+ const originalToolsRegister = ctx?.tools?.register ? ctx.tools.register.bind(ctx.tools) : null
30
+ const wrappedTools = originalToolsRegister ? Object.create(ctx.tools, {
31
+ register: {
32
+ value: (def) => {
33
+ const originalExecute = def.execute
34
+ const wrappedDef = {
35
+ ...def,
36
+ execute: async (args, exec) => {
37
+ const currentCfg = typeof deps.live === 'function' ? deps.live() : (deps.config || {})
38
+ if (currentCfg && currentCfg.enabled === false) {
39
+ throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
40
+ }
41
+ return originalExecute(args, exec)
42
+ },
43
+ }
44
+ return originalToolsRegister(wrappedDef)
45
+ },
46
+ writable: true,
47
+ configurable: true,
48
+ },
49
+ }) : ctx?.tools
50
+
51
+ const targetCtx = originalToolsRegister ? Object.create(ctx, {
52
+ tools: { value: wrappedTools, writable: true, configurable: true }
53
+ }) : ctx
29
54
  const cfg = typeof deps.live === 'function' ? deps.live() : (deps.config || {})
30
55
  const profile = cfg.toolsetProfile || 'minimal'
31
56
  const isAll = profile === 'all'
@@ -36,37 +61,37 @@ export function registerAllTools(ctx, deps) {
36
61
  const creative = isAll || Boolean(sets.creative || cfg.toolsetCreative)
37
62
 
38
63
  // 1. Core tools: generate_image, edit_image, inspect_image_quality (<= 3 tools in minimal)
39
- registerGenerationTools(ctx, deps, { coreOnly: !creative })
40
- registerEditingTools(ctx, deps, { coreOnly: !processing })
41
- registerInspectTools(ctx, deps, { coreOnly: !processing })
64
+ registerGenerationTools(targetCtx, deps, { coreOnly: !creative })
65
+ registerEditingTools(targetCtx, deps, { coreOnly: !processing })
66
+ registerInspectTools(targetCtx, deps, { coreOnly: !processing })
42
67
 
43
68
  // 2. Processing toolset
44
69
  if (processing) {
45
- registerProcessingTools(ctx, deps)
46
- registerProcessingAdvancedTools(ctx, deps)
47
- registerVisionOcrTools(ctx, deps)
70
+ registerProcessingTools(targetCtx, deps)
71
+ registerProcessingAdvancedTools(targetCtx, deps)
72
+ registerVisionOcrTools(targetCtx, deps)
48
73
  }
49
74
 
50
75
  // 3. Design toolset
51
76
  if (design) {
52
- registerAnchorTools(ctx, deps)
53
- registerUiAssetTools(ctx, deps)
54
- registerStyleMatrixTools(ctx, deps)
55
- registerThemePairTools(ctx, deps)
56
- registerCharacterSheetTools(ctx, deps)
57
- registerDiagramTools(ctx, deps)
77
+ registerAnchorTools(targetCtx, deps)
78
+ registerUiAssetTools(targetCtx, deps)
79
+ registerStyleMatrixTools(targetCtx, deps)
80
+ registerThemePairTools(targetCtx, deps)
81
+ registerCharacterSheetTools(targetCtx, deps)
82
+ registerDiagramTools(targetCtx, deps)
58
83
  }
59
84
 
60
85
  // 4. Frontend toolset
61
86
  if (frontend) {
62
- registerFrontendTools(ctx, deps)
63
- registerResponsiveTools(ctx, deps)
87
+ registerFrontendTools(targetCtx, deps)
88
+ registerResponsiveTools(targetCtx, deps)
64
89
  }
65
90
 
66
91
  // 5. Creative toolset
67
92
  if (creative) {
68
- registerSketchTools(ctx, deps)
69
- registerSpritesheetTools(ctx, deps)
70
- registerPatternTools(ctx, deps)
93
+ registerSketchTools(targetCtx, deps)
94
+ registerSpritesheetTools(targetCtx, deps)
95
+ registerPatternTools(targetCtx, deps)
71
96
  }
72
97
  }
@@ -1,5 +1,5 @@
1
- import { sanitizeErrorAndLogs } from '../security.js'
2
- // character-sheet.js — generate_character_sheet tool (#181).
1
+ import { buildDualOutputMarkdown } from '../resolve-image.js'
2
+ // character-sheet.js — generate_character_sheet tool (#181, #358).
3
3
  // Generates multi-angle character model sheets and turnarounds with visual consistency.
4
4
 
5
5
  import { defineTool } from '@deepseek-ai/dsh-tools'
@@ -7,12 +7,15 @@ import path from 'node:path'
7
7
  import { mkdir, writeFile } from 'node:fs/promises'
8
8
  import {
9
9
  ASPECT_RATIOS,
10
+ PROVIDER_KEYS,
10
11
  buildSidecar,
11
12
  makeProviders,
12
13
  toLosslessJson,
13
14
  saveAttachmentSafe,
15
+
14
16
  } from '../providers.js'
15
17
  import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
18
+ import { assertBudgetAvailable, calculateGenerationCost, recordSpend } from '../cost-meter.js'
16
19
  import { renderToolOutput } from '../attachment-helper.js'
17
20
  import {
18
21
  CHARACTER_SHEET_LAYOUTS,
@@ -23,7 +26,7 @@ import {
23
26
  export function registerCharacterSheetTools(ctx, deps) {
24
27
  const {
25
28
  live,
26
- slugify,
29
+ slugify = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-'),
27
30
  resolveApiKey,
28
31
  } = deps
29
32
 
@@ -77,6 +80,8 @@ export function registerCharacterSheetTools(ctx, deps) {
77
80
  },
78
81
  seed: { type: 'integer' },
79
82
  provider: { type: 'string' },
83
+ path: { type: 'string' },
84
+ url: { type: 'string' },
80
85
  attachment: {
81
86
  type: 'object',
82
87
  additionalProperties: true,
@@ -88,13 +93,24 @@ export function registerCharacterSheetTools(ctx, deps) {
88
93
  byteSize: { type: 'integer' },
89
94
  },
90
95
  },
96
+ _fallback: {
97
+ type: 'object',
98
+ additionalProperties: true,
99
+ },
91
100
  },
92
101
  },
93
102
  render: (_args, value) => renderToolOutput(value),
94
103
  },
95
104
  async execute(args, execCtx) {
96
- const cfg = live.value
97
- const activeProvider = args.provider || cfg.activeProvider || 'fal'
105
+ const cfg = typeof live === 'function' ? live() : (live?.value || {})
106
+ if (cfg.enabled === false) {
107
+ throw new Error('Image generation is disabled in settings.')
108
+ }
109
+
110
+ const provider = PROVIDER_KEYS.includes(args.provider || cfg.provider)
111
+ ? (args.provider || cfg.provider)
112
+ : (PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal')
113
+
98
114
  const seed = typeof args.seed === 'number' ? args.seed : Math.floor(Math.random() * 2147483647)
99
115
 
100
116
  const sheetPlan = buildCharacterSheetPrompt({
@@ -104,60 +120,98 @@ export function registerCharacterSheetTools(ctx, deps) {
104
120
  negativePrompt: args.negativePrompt,
105
121
  })
106
122
 
107
- const providers = makeProviders({
108
- fetchImpl: fetch,
109
- resolveKey: (id) => resolveApiKey(cfg, id),
110
- cfg,
111
- })
123
+ const aspectPixels = ASPECT_RATIOS[sheetPlan.aspectRatio] || ASPECT_RATIOS['16:9']
112
124
 
113
- const fallbackChain = resolveFallbackChain(activeProvider, cfg)
125
+ let subscriptionImages
126
+ try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (_err) { subscriptionImages = undefined }
114
127
 
115
- const aspectPixels = ASPECT_RATIOS[sheetPlan.aspectRatio] || ASPECT_RATIOS['16:9']
128
+ const providers = makeProviders(
129
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
130
+ {
131
+ prompt: sheetPlan.prompt,
132
+ negativePrompt: sheetPlan.negativePrompt,
133
+ size: 'custom',
134
+ format: 'png',
135
+ seed,
136
+ signal: execCtx?.signal,
137
+ aspectPixels,
138
+ aspectRatio: sheetPlan.aspectRatio,
139
+ quality: 'high',
140
+ },
141
+ )
116
142
 
117
- const jobPayload = {
118
- prompt: sheetPlan.prompt,
119
- negativePrompt: sheetPlan.negativePrompt,
120
- aspectRatio: sheetPlan.aspectRatio,
121
- aspectPixels,
122
- format: 'png',
123
- seed,
124
- quality: 'high',
125
- }
143
+ const fallbackChain = resolveFallbackChain(provider, cfg.fallbackProviders, PROVIDER_KEYS)
126
144
 
127
- const { result: genResult, provider: usedProvider } = await executeWithFallback(
128
- fallbackChain,
145
+ const estimatedCost = calculateGenerationCost({
146
+ provider,
147
+ model: cfg.model || cfg.customModel || 'default',
148
+ size: cfg.defaultSize || '1024x1024',
149
+ count: 1,
150
+ })
151
+ assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
152
+
153
+ const gen = await executeWithFallback(
129
154
  providers,
130
- jobPayload,
131
- { signal: execCtx?.signal }
155
+ fallbackChain,
156
+ seed,
157
+ sheetPlan.prompt,
158
+ { logger: ctx.logger },
132
159
  )
133
160
 
161
+ const usedProvider = gen._fallback?.providerUsed || provider
162
+ const finalBytes = gen.bytes
134
163
  const timestamp = Date.now()
135
164
  const safeSlug = slugify(args.prompt.slice(0, 30)) || 'character-sheet'
136
165
  const filename = `character-sheet-${sheetPlan.layout}-${safeSlug}-${timestamp}.png`
137
166
 
138
167
  let attachment = null
139
- if (genResult.bytes) {
140
- const outDir = path.resolve(process.cwd(), cfg.outputDir || 'output')
141
- await mkdir(outDir, { recursive: true })
142
- const diskPath = path.join(outDir, filename)
143
- await writeFile(diskPath, Buffer.from(genResult.bytes))
168
+ let localUrl = ''
169
+ let diskPath = ''
170
+ if (finalBytes) {
171
+ const saveRes = await saveAttachmentSafe(ctx, {
172
+ bytes: finalBytes,
173
+ mediaType: 'image/png',
174
+ name: filename,
175
+ })
176
+ attachment = saveRes?.attachment || null
177
+ localUrl = saveRes?.localUrl || ''
144
178
 
145
- attachment = await saveAttachmentSafe(
146
- ctx,
147
- Buffer.from(genResult.bytes),
148
- 'image/png',
149
- filename,
150
- diskPath
151
- )
152
- } else if (genResult.url) {
179
+ const sessionCwd = execCtx?.agent?.session?.header?.cwd
180
+ const targetDir = cfg.outputDir || 'generated/images'
181
+ const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
182
+ await mkdir(outDir, { recursive: true })
183
+ diskPath = path.join(outDir, filename)
184
+ await writeFile(diskPath, Buffer.from(finalBytes))
185
+ } else if (gen.url) {
153
186
  attachment = {
154
- url: genResult.url,
187
+ url: gen.url,
155
188
  mediaType: 'image/png',
156
189
  filename,
157
190
  }
191
+ localUrl = gen.url
158
192
  }
159
193
 
194
+ const effectiveCost = Number(gen.cost) || estimatedCost
195
+ recordSpend(effectiveCost, {
196
+ ctx,
197
+ meta: { provider: usedProvider, model: cfg.model || cfg.customModel, prompt: sheetPlan.prompt, seed },
198
+ })
199
+
200
+ const dualOutput = buildDualOutputMarkdown({
201
+ action: 'generated',
202
+ filePath: diskPath || filename,
203
+ width: aspectPixels[0],
204
+ height: aspectPixels[1],
205
+ mediaType: 'image/png',
206
+ seed,
207
+ provider: usedProvider,
208
+ model: cfg.model || cfg.customModel || 'character-sheet',
209
+ cost: gen.cost,
210
+ attachmentId: attachment?.attachmentId,
211
+ })
212
+
160
213
  const response = {
214
+ summary: dualOutput,
161
215
  prompt: sheetPlan.prompt,
162
216
  characterDescription: args.prompt,
163
217
  layout: sheetPlan.layout,
@@ -167,16 +221,13 @@ export function registerCharacterSheetTools(ctx, deps) {
167
221
  seed,
168
222
  provider: usedProvider,
169
223
  attachment,
224
+ path: diskPath,
225
+ url: localUrl,
226
+ attachmentId: attachment?.attachmentId,
227
+ _fallback: gen._fallback,
170
228
  }
171
229
 
172
- if (attachment?.attachmentId) {
173
- response.attachmentId = attachment.attachmentId
174
- }
175
- if (attachment?.url) {
176
- response.url = attachment.url
177
- }
178
-
179
- return response
230
+ return toLosslessJson(response)
180
231
  },
181
232
  })
182
233
  )
@@ -1,5 +1,4 @@
1
- import { sanitizeErrorAndLogs } from '../security.js'
2
- // diagram.js — beautify_diagram tool (#185).
1
+ // diagram.js — beautify_diagram tool (#185, #359).
3
2
  // Transforms raw Mermaid / Graphviz diagrams into high-end 3D isometric architectural illustrations.
4
3
 
5
4
  import { defineTool } from '@deepseek-ai/dsh-tools'
@@ -7,10 +6,14 @@ import path from 'node:path'
7
6
  import { mkdir, writeFile } from 'node:fs/promises'
8
7
  import {
9
8
  ASPECT_RATIOS,
9
+ PROVIDER_KEYS,
10
10
  makeProviders,
11
+ toLosslessJson,
11
12
  saveAttachmentSafe,
12
13
  } from '../providers.js'
14
+ import { buildDualOutputMarkdown } from '../resolve-image.js'
13
15
  import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
16
+ import { assertBudgetAvailable, calculateGenerationCost, recordSpend } from '../cost-meter.js'
14
17
  import { renderToolOutput } from '../attachment-helper.js'
15
18
  import {
16
19
  DIAGRAM_3D_STYLES,
@@ -20,7 +23,7 @@ import {
20
23
  export function registerDiagramTools(ctx, deps) {
21
24
  const {
22
25
  live,
23
- slugify,
26
+ slugify = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-'),
24
27
  resolveApiKey,
25
28
  } = deps
26
29
 
@@ -74,6 +77,8 @@ export function registerDiagramTools(ctx, deps) {
74
77
  prompt: { type: 'string' },
75
78
  seed: { type: 'integer' },
76
79
  provider: { type: 'string' },
80
+ path: { type: 'string' },
81
+ url: { type: 'string' },
77
82
  attachment: {
78
83
  type: 'object',
79
84
  additionalProperties: true,
@@ -84,13 +89,24 @@ export function registerDiagramTools(ctx, deps) {
84
89
  filename: { type: 'string' },
85
90
  },
86
91
  },
92
+ _fallback: {
93
+ type: 'object',
94
+ additionalProperties: true,
95
+ },
87
96
  },
88
97
  },
89
98
  render: (_args, value) => renderToolOutput(value),
90
99
  },
91
100
  async execute(args, execCtx) {
92
- const cfg = live?.value || (typeof live === 'function' ? live() : {})
93
- const activeProvider = args.provider || cfg.activeProvider || 'fal'
101
+ const cfg = typeof live === 'function' ? live() : (live?.value || {})
102
+ if (cfg.enabled === false) {
103
+ throw new Error('Image generation is disabled in settings.')
104
+ }
105
+
106
+ const provider = PROVIDER_KEYS.includes(args.provider || cfg.provider)
107
+ ? (args.provider || cfg.provider)
108
+ : (PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal')
109
+
94
110
  const seed = typeof args.seed === 'number' ? args.seed : Math.floor(Math.random() * 2147483647)
95
111
 
96
112
  const plan = buildDiagramIllustrationPrompt({
@@ -100,57 +116,98 @@ export function registerDiagramTools(ctx, deps) {
100
116
  aspectRatio: args.aspect_ratio || '16:9',
101
117
  })
102
118
 
103
- const providers = makeProviders({
104
- fetchImpl: fetch,
105
- resolveKey: (id) => resolveApiKey(cfg, id),
106
- cfg,
107
- })
108
- const fallbackChain = resolveFallbackChain(activeProvider, cfg)
109
119
  const aspectPixels = ASPECT_RATIOS[plan.aspectRatio] || ASPECT_RATIOS['16:9']
110
120
 
111
- const jobPayload = {
112
- prompt: plan.prompt,
113
- negativePrompt: plan.negativePrompt,
114
- aspectRatio: plan.aspectRatio,
115
- aspectPixels,
116
- format: 'png',
117
- seed,
118
- quality: 'high',
119
- }
121
+ let subscriptionImages
122
+ try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (_err) { subscriptionImages = undefined }
120
123
 
121
- const { result: genResult, provider: usedProvider } = await executeWithFallback(
122
- fallbackChain,
124
+ const providers = makeProviders(
125
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
126
+ {
127
+ prompt: plan.prompt,
128
+ negativePrompt: plan.negativePrompt,
129
+ size: 'custom',
130
+ format: 'png',
131
+ seed,
132
+ signal: execCtx?.signal,
133
+ aspectPixels,
134
+ aspectRatio: plan.aspectRatio,
135
+ quality: 'high',
136
+ },
137
+ )
138
+
139
+ const fallbackChain = resolveFallbackChain(provider, cfg.fallbackProviders, PROVIDER_KEYS)
140
+
141
+ const estimatedCost = calculateGenerationCost({
142
+ provider,
143
+ model: cfg.model || cfg.customModel || 'default',
144
+ size: cfg.defaultSize || '1024x1024',
145
+ count: 1,
146
+ })
147
+ assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
148
+
149
+ const gen = await executeWithFallback(
123
150
  providers,
124
- jobPayload,
125
- { signal: execCtx?.signal }
151
+ fallbackChain,
152
+ seed,
153
+ plan.prompt,
154
+ { logger: ctx.logger },
126
155
  )
127
156
 
128
- const outDir = path.resolve(process.cwd(), cfg.outputDir || 'output')
129
- await mkdir(outDir, { recursive: true })
157
+ const usedProvider = gen._fallback?.providerUsed || provider
158
+ const finalBytes = gen.bytes
130
159
  const timestamp = Date.now()
131
160
  const safeSlug = slugify(args.title || 'architecture-diagram') || 'diagram'
132
161
  const filename = `diagram-3d-${safeSlug}-${timestamp}.png`
133
162
 
134
163
  let attachment = null
135
- if (genResult.bytes) {
136
- const diskPath = path.join(outDir, filename)
137
- await writeFile(diskPath, Buffer.from(genResult.bytes))
138
- attachment = await saveAttachmentSafe(
139
- ctx,
140
- Buffer.from(genResult.bytes),
141
- 'image/png',
142
- filename,
143
- diskPath
144
- )
145
- } else if (genResult.url) {
164
+ let localUrl = ''
165
+ let diskPath = ''
166
+ if (finalBytes) {
167
+ const saveRes = await saveAttachmentSafe(ctx, {
168
+ bytes: finalBytes,
169
+ mediaType: 'image/png',
170
+ name: filename,
171
+ })
172
+ attachment = saveRes?.attachment || null
173
+ localUrl = saveRes?.localUrl || ''
174
+
175
+ const sessionCwd = execCtx?.agent?.session?.header?.cwd
176
+ const targetDir = cfg.outputDir || 'generated/images'
177
+ const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
178
+ await mkdir(outDir, { recursive: true })
179
+ diskPath = path.join(outDir, filename)
180
+ await writeFile(diskPath, Buffer.from(finalBytes))
181
+ } else if (gen.url) {
146
182
  attachment = {
147
- url: genResult.url,
183
+ url: gen.url,
148
184
  mediaType: 'image/png',
149
185
  filename,
150
186
  }
187
+ localUrl = gen.url
151
188
  }
152
189
 
153
- return {
190
+ const effectiveCost = Number(gen.cost) || estimatedCost
191
+ recordSpend(effectiveCost, {
192
+ ctx,
193
+ meta: { provider: usedProvider, model: cfg.model || cfg.customModel, prompt: plan.prompt, seed },
194
+ })
195
+
196
+ const dualOutput = buildDualOutputMarkdown({
197
+ action: 'generated',
198
+ filePath: diskPath || filename,
199
+ width: aspectPixels[0],
200
+ height: aspectPixels[1],
201
+ mediaType: 'image/png',
202
+ seed,
203
+ provider: usedProvider,
204
+ model: cfg.model || cfg.customModel || 'beautify-diagram',
205
+ cost: gen.cost,
206
+ attachmentId: attachment?.attachmentId,
207
+ })
208
+
209
+ const response = {
210
+ summary: dualOutput,
154
211
  title: args.title || 'System Architecture',
155
212
  style: plan.style,
156
213
  aspectRatio: plan.aspectRatio,
@@ -159,9 +216,13 @@ export function registerDiagramTools(ctx, deps) {
159
216
  seed,
160
217
  provider: usedProvider,
161
218
  attachment,
162
- url: attachment?.url,
219
+ path: diskPath,
220
+ url: localUrl,
163
221
  attachmentId: attachment?.attachmentId,
222
+ _fallback: gen._fallback,
164
223
  }
224
+
225
+ return toLosslessJson(response)
165
226
  },
166
227
  })
167
228
  )
@@ -84,6 +84,9 @@ export function registerEditingTools(ctx, deps, options = {}) {
84
84
  async execute(args, exec) {
85
85
  try {
86
86
  const cfg = live()
87
+ if (cfg && cfg.enabled === false) {
88
+ throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
89
+ }
87
90
  trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
88
91
  const estimatedCostEdit = calculateGenerationCost({
89
92
  provider: cfg.provider || 'fal',
@@ -219,6 +222,9 @@ export function registerEditingTools(ctx, deps, options = {}) {
219
222
  async execute(args, exec) {
220
223
  try {
221
224
  const cfg = live()
225
+ if (cfg && cfg.enabled === false) {
226
+ throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
227
+ }
222
228
  trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
223
229
  const count = normalizeCount(args.count || 1)
224
230
  const estimatedCostVary = calculateGenerationCost({
@@ -357,6 +363,9 @@ export function registerEditingTools(ctx, deps, options = {}) {
357
363
  async execute(args, exec) {
358
364
  try {
359
365
  const cfg = live()
366
+ if (cfg && cfg.enabled === false) {
367
+ throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
368
+ }
360
369
  trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
361
370
  const count = normalizeCount(args.count || 1)
362
371
  const estimatedCost = calculateGenerationCost({