@goodandready/dsh-image-gen 0.11.30 → 0.11.32

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/client.js CHANGED
@@ -2698,7 +2698,7 @@ window.__ModuleLoader__.load({
2698
2698
 
2699
2699
  'f.geminiModel': 'Gemini Model ID',
2700
2700
  'f.geminiModelHint': 'Google Gemini image generation model identifier.',
2701
- 'p.geminiModel': 'gemini-2.0-flash-exp-image-generation',
2701
+ 'p.geminiModel': 'gemini-3.1-flash-image',
2702
2702
  'f.geminiKeyEnv': 'Gemini Key Reference',
2703
2703
  'f.geminiKeyEnvHint': 'Credential reference holding Google Gemini API key.',
2704
2704
  'p.geminiKeyEnv': 'GEMINI_API_KEY',
@@ -2977,7 +2977,7 @@ window.__ModuleLoader__.load({
2977
2977
 
2978
2978
  'f.geminiModel': 'Gemini 模型 ID',
2979
2979
  'f.geminiModelHint': 'Google Gemini 图像生成模型标识符。',
2980
- 'p.geminiModel': 'gemini-2.0-flash-exp-image-generation',
2980
+ 'p.geminiModel': 'gemini-3.1-flash-image',
2981
2981
  'f.geminiKeyEnv': 'Gemini 凭证名称',
2982
2982
  'f.geminiKeyEnvHint': '存储 Google Gemini 密钥的凭证名称。',
2983
2983
  'p.geminiKeyEnv': 'GEMINI_API_KEY',
@@ -177,7 +177,7 @@ export const Config = z.object({
177
177
  geminiModel: z
178
178
  .string()
179
179
  .description('Google Gemini image generation model identifier.')
180
- .default('gemini-2.0-flash-exp-image-generation')
180
+ .default('gemini-3.1-flash-image')
181
181
  .volatile(),
182
182
  geminiKeyEnv: z
183
183
  .string()
package/lib/cost-meter.js CHANGED
@@ -1,9 +1,10 @@
1
1
  // lib/cost-meter.js
2
- // Integration with dsh-cost-meter, rate card, and daily budget enforcer (#167)
2
+ // Integration with dsh-cost-meter, rate card, and daily budget enforcer (#167, #353, #355)
3
3
 
4
4
  import { homedir } from 'node:os'
5
5
  import { join } from 'node:path'
6
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
6
+ import { readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'node:fs'
7
+ import { randomBytes } from 'node:crypto'
7
8
  import { enforceSecurePermissions } from './security.js'
8
9
 
9
10
  /**
@@ -53,6 +54,27 @@ export const RATE_CARD = {
53
54
  },
54
55
  }
55
56
 
57
+ /** Active in-flight budget reservations for serialized concurrency (#355) */
58
+ const activeReservations = new Map()
59
+
60
+ /**
61
+ * Returns total currently active in-flight spend reservations in USD.
62
+ */
63
+ export function getTotalActiveReservations() {
64
+ let total = 0
65
+ for (const amount of activeReservations.values()) {
66
+ total += Number(amount) || 0
67
+ }
68
+ return +total.toFixed(4)
69
+ }
70
+
71
+ /**
72
+ * Clears all active in-flight reservations (for testing / session resets).
73
+ */
74
+ export function clearSpendReservations() {
75
+ activeReservations.clear()
76
+ }
77
+
56
78
  /**
57
79
  * Calculates estimated USD cost for a generation request.
58
80
  */
@@ -94,6 +116,7 @@ export function loadDailySpend() {
94
116
 
95
117
  /**
96
118
  * Records an image generation expense.
119
+ * Uses atomic sibling write + rename to guarantee crash-safe persistence (#355).
97
120
  */
98
121
  export function recordSpend(usdAmount, { ctx, meta = {} } = {}) {
99
122
  const amount = Number(usdAmount) || 0
@@ -106,8 +129,11 @@ export function recordSpend(usdAmount, { ctx, meta = {} } = {}) {
106
129
  current.lastUpdated = new Date().toISOString()
107
130
 
108
131
  try {
109
- mkdirSync(join(filePath, '..'), { recursive: true })
110
- writeFileSync(filePath, JSON.stringify(current, null, 2), 'utf8')
132
+ const dir = join(filePath, '..')
133
+ mkdirSync(dir, { recursive: true })
134
+ const tmpFile = `${filePath}.${randomBytes(6).toString('hex')}.tmp`
135
+ writeFileSync(tmpFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 })
136
+ renameSync(tmpFile, filePath)
111
137
  enforceSecurePermissions(filePath)
112
138
  } catch (err) {
113
139
  // Non-fatal write failure
@@ -130,7 +156,7 @@ export function recordSpend(usdAmount, { ctx, meta = {} } = {}) {
130
156
  }
131
157
 
132
158
  /**
133
- * Asserts that the generation does not exceed daily budget.
159
+ * Asserts that the generation does not exceed daily budget, accounting for active in-flight reservations (#353, #355).
134
160
  * @throws {Error} If daily budget is exceeded.
135
161
  */
136
162
  export function assertBudgetAvailable(estimatedCost, dailyBudgetUsd) {
@@ -139,11 +165,52 @@ export function assertBudgetAvailable(estimatedCost, dailyBudgetUsd) {
139
165
 
140
166
  const cost = Number(estimatedCost) || 0
141
167
  const current = loadDailySpend()
142
- const projected = +(current.totalSpendUsd + cost).toFixed(4)
168
+ const reserved = getTotalActiveReservations()
169
+ const projected = +(current.totalSpendUsd + reserved + cost).toFixed(4)
143
170
  if (projected > budget) {
144
171
  throw new Error(
145
- `Daily image generation budget exceeded: current spend $${current.totalSpendUsd.toFixed(2)} + estimated $${cost.toFixed(2)} exceeds limit $${budget.toFixed(2)} (Settings → Image generation → dailyBudgetUsd)`
172
+ `Daily image generation budget exceeded: current spend $${current.totalSpendUsd.toFixed(2)} + reserved $${reserved.toFixed(2)} + estimated $${cost.toFixed(2)} exceeds limit $${budget.toFixed(2)} (Settings → Image generation → dailyBudgetUsd)`
146
173
  )
147
174
  }
148
175
  return true
149
176
  }
177
+
178
+ /**
179
+ * Reserves estimated budget for an in-flight generation attempt (#353, #355).
180
+ * Returns a settlement handle with .commit(actualCost, opts) and .release().
181
+ *
182
+ * @param {number} estimatedCost
183
+ * @param {number} dailyBudgetUsd
184
+ * @returns {{ id: string, cost: number, commit: Function, release: Function }}
185
+ */
186
+ export function reserveSpend(estimatedCost, dailyBudgetUsd) {
187
+ const budget = Number(dailyBudgetUsd) || 0
188
+ const cost = Number(estimatedCost) || 0
189
+
190
+ if (budget > 0 && cost > 0) {
191
+ assertBudgetAvailable(cost, budget)
192
+ }
193
+
194
+ const id = `res_${Date.now()}_${randomBytes(4).toString('hex')}`
195
+ if (budget > 0 && cost > 0) {
196
+ activeReservations.set(id, cost)
197
+ }
198
+
199
+ let settled = false
200
+ return {
201
+ id,
202
+ cost,
203
+ commit(actualCost, opts) {
204
+ if (settled) return
205
+ settled = true
206
+ activeReservations.delete(id)
207
+ const effective = actualCost !== undefined ? Number(actualCost) : cost
208
+ return recordSpend(effective, opts)
209
+ },
210
+ release() {
211
+ if (settled) return
212
+ settled = true
213
+ activeReservations.delete(id)
214
+ },
215
+ }
216
+ }
@@ -359,6 +359,61 @@ export function decodeHtmlEntities(str) {
359
359
  .replace(/&/gi, '&')
360
360
  }
361
361
 
362
+ /**
363
+ * Escapes JSX-sensitive characters in SVG markup so text nodes and unquoted
364
+ * curly braces cannot be interpreted as JavaScript expressions in TSX export (#406).
365
+ * Replaces unquoted `{` and `}` outside attribute values with `{` and `}`.
366
+ * @param {string} content
367
+ * @returns {string}
368
+ */
369
+ export function escapeJsxText(content) {
370
+ if (!content || typeof content !== 'string') return ''
371
+ let result = ''
372
+ let inTag = false
373
+ let quote = null
374
+
375
+ for (let i = 0; i < content.length; i++) {
376
+ const ch = content[i]
377
+
378
+ if (!inTag) {
379
+ if (ch === '<') {
380
+ inTag = true
381
+ quote = null
382
+ result += ch
383
+ } else if (ch === '{') {
384
+ result += '&#123;'
385
+ } else if (ch === '}') {
386
+ result += '&#125;'
387
+ } else {
388
+ result += ch
389
+ }
390
+ } else {
391
+ if (quote) {
392
+ if (ch === quote) {
393
+ quote = null
394
+ }
395
+ result += ch
396
+ } else {
397
+ if (ch === '"' || ch === "'") {
398
+ quote = ch
399
+ result += ch
400
+ } else if (ch === '>') {
401
+ inTag = false
402
+ result += ch
403
+ } else if (ch === '{') {
404
+ result += '&#123;'
405
+ } else if (ch === '}') {
406
+ result += '&#125;'
407
+ } else {
408
+ result += ch
409
+ }
410
+ }
411
+ }
412
+ }
413
+
414
+ return result
415
+ }
416
+
362
417
  /**
363
418
  * Issue #176: optimize_vector_svg
364
419
  * Cleans, sanitizes, and minifies SVG content, normalizes viewBox, and exports React TSX component.
@@ -463,6 +518,8 @@ export function optimizeSvgContent(svgString, options = {}) {
463
518
  .replace(/stroke-linejoin=/g, 'strokeLinejoin=')
464
519
  .replace(/stroke-miterlimit=/g, 'strokeMiterlimit=')
465
520
 
521
+ jsxBody = escapeJsxText(jsxBody)
522
+
466
523
  const reactTsx = `import React from 'react';
467
524
 
468
525
  export interface ${componentName}Props extends React.SVGProps<SVGSVGElement> {
@@ -2,6 +2,7 @@
2
2
  // Extracted execution and prompt/caching helpers for generate_image tool (#349).
3
3
 
4
4
  import path from 'node:path'
5
+ import { createHash } from 'node:crypto'
5
6
  import { mkdir, writeFile } from 'node:fs/promises'
6
7
  import {
7
8
  computeGenerationHash,
@@ -152,6 +153,11 @@ export async function saveGeneratedImageArtifact({
152
153
  slugify,
153
154
  estimatedCost,
154
155
  totalCount,
156
+ recordSpend: shouldRecordSpend = true,
157
+ source,
158
+ mask,
159
+ resolvedFaceRef,
160
+ resolvedRefImage,
155
161
  }) {
156
162
  const targetFormat = format || (args.output_format || cfg.defaultFormat)
157
163
  const formatted = await ensureImageFormat(gen.bytes, targetFormat, { fallbackMime: gen.mediaType })
@@ -204,7 +210,11 @@ export async function saveGeneratedImageArtifact({
204
210
 
205
211
  const effectiveModel = provider === 'custom'
206
212
  ? (cfg.customModel || cfg.model || 'dall-e-3')
207
- : (cfg.model || cfg.customModel)
213
+ : (cfg.model || cfg.customModel || 'default')
214
+
215
+ const sourceBytes = source && source.bytes ? source.bytes : (args.image || args.source_image)
216
+ const maskBytes = mask && mask.bytes ? mask.bytes : (args.mask_image || args.mask)
217
+ const refBytes = (resolvedRefImage && resolvedRefImage.bytes) || (resolvedFaceRef && resolvedFaceRef.bytes) || args.reference_images || args.reference_image
208
218
 
209
219
  const cacheHash = computeGenerationHash({
210
220
  provider,
@@ -221,9 +231,9 @@ export async function saveGeneratedImageArtifact({
221
231
  negativePrompt: args.negative_prompt || cfg.negativePrompt,
222
232
  quality: args.quality,
223
233
  strength: args.strength,
224
- sourceImage: args.image || args.source_image,
225
- maskImage: args.mask_image || args.mask,
226
- reference: args.reference_images || args.reference_image,
234
+ sourceImage: sourceBytes,
235
+ maskImage: maskBytes,
236
+ reference: refBytes,
227
237
  })
228
238
 
229
239
  // #170: Store in content-addressed disk cache
@@ -234,19 +244,32 @@ export async function saveGeneratedImageArtifact({
234
244
  width: gen.width || attachment.width,
235
245
  height: gen.height || attachment.height,
236
246
  seed: gen.seed,
237
- meta: { prompt: promptArg, provider, model: cfg.model || cfg.customModel, cost: gen.cost },
247
+ meta: { prompt: promptArg, provider, model: effectiveModel, cost: gen.cost },
238
248
  })
239
249
  }
250
+
251
+ const sourceHash = (source && source.bytes) ? createHash('sha256').update(source.bytes).digest('hex') : undefined
252
+ const maskHash = (mask && mask.bytes) ? createHash('sha256').update(mask.bytes).digest('hex') : undefined
253
+ const refHash = (resolvedRefImage && resolvedRefImage.bytes)
254
+ ? createHash('sha256').update(resolvedRefImage.bytes).digest('hex')
255
+ : ((resolvedFaceRef && resolvedFaceRef.bytes) ? createHash('sha256').update(resolvedFaceRef.bytes).digest('hex') : undefined)
256
+
240
257
  const entry = {
241
258
  path: filePath,
242
259
  prompt: promptArg,
243
260
  provider,
244
- model: cfg.model,
261
+ model: effectiveModel,
245
262
  size,
246
263
  format,
247
264
  aspectRatio: args.aspect_ratio || cfg.defaultAspectRatio,
248
265
  seed: gen.seed,
249
266
  cacheHash,
267
+ sourceHash,
268
+ maskHash,
269
+ refHash,
270
+ quality: args.quality,
271
+ style: args.style,
272
+ strength: args.strength,
250
273
  thumbnailUrl: localUrl,
251
274
  width: gen.width || attachment.width,
252
275
  height: gen.height || attachment.height,
@@ -262,11 +285,13 @@ export async function saveGeneratedImageArtifact({
262
285
  await appendHistoryEntry(entry, { pruneDays: cfg.pruneDays, historyLimit })
263
286
  }
264
287
 
265
- // #167: Record spend in cost meter
266
- recordSpend(gen.cost || (estimatedCost / totalCount), {
267
- ctx,
268
- meta: { provider, model: cfg.model || cfg.customModel, prompt: promptArg, seed: gen.seed },
269
- })
288
+ // #167: Record spend in cost meter (unless caller already committed attempt spend)
289
+ if (shouldRecordSpend !== false) {
290
+ recordSpend(gen.cost || (estimatedCost / totalCount), {
291
+ ctx,
292
+ meta: { provider, model: cfg.model || cfg.customModel, prompt: promptArg, seed: gen.seed },
293
+ })
294
+ }
270
295
 
271
296
  return {
272
297
  path: filePath,
package/lib/history.js CHANGED
@@ -41,6 +41,22 @@ export async function findCachedByPrompt(entries, prompt, criteria = {}) {
41
41
  if (!prompt || !Array.isArray(entries)) return undefined
42
42
  return entries.find((e) => {
43
43
  if (e.prompt !== prompt) return false
44
+ if (criteria.cacheHash && e.cacheHash && criteria.cacheHash !== e.cacheHash) return false
45
+ if (criteria.provider && e.provider && criteria.provider.toLowerCase() !== e.provider.toLowerCase()) return false
46
+ if (criteria.model && e.model && criteria.model.toLowerCase() !== e.model.toLowerCase()) return false
47
+ if (criteria.sourceHash !== undefined && e.sourceHash !== undefined && criteria.sourceHash !== e.sourceHash) return false
48
+ if (criteria.sourceHash && !e.sourceHash) return false
49
+ if (!criteria.sourceHash && e.sourceHash) return false
50
+ if (criteria.maskHash !== undefined && e.maskHash !== undefined && criteria.maskHash !== e.maskHash) return false
51
+ if (criteria.maskHash && !e.maskHash) return false
52
+ if (!criteria.maskHash && e.maskHash) return false
53
+ if (criteria.refHash !== undefined && e.refHash !== undefined && criteria.refHash !== e.refHash) return false
54
+ if (criteria.refHash && !e.refHash) return false
55
+ if (!criteria.refHash && e.refHash) return false
56
+ if (criteria.quality !== undefined && e.quality !== undefined && criteria.quality !== e.quality) return false
57
+ if (criteria.style !== undefined && e.style !== undefined && criteria.style !== e.style) return false
58
+ if (criteria.strength !== undefined && e.strength !== undefined && criteria.strength !== e.strength) return false
59
+
44
60
  if (criteria.format) {
45
61
  const entryFmt = (e.format || (e.mediaType || '').replace('image/', '')).toLowerCase().replace('jpeg', 'jpg')
46
62
  const targetFmt = String(criteria.format).toLowerCase().replace('jpeg', 'jpg')
@@ -58,6 +74,22 @@ export async function findCached(entries, seed, prompt, criteria = {}) {
58
74
  if (seed === undefined || !Array.isArray(entries)) return undefined
59
75
  return entries.find((e) => {
60
76
  if (e.seed !== seed || e.prompt !== prompt) return false
77
+ if (criteria.cacheHash && e.cacheHash && criteria.cacheHash !== e.cacheHash) return false
78
+ if (criteria.provider && e.provider && criteria.provider.toLowerCase() !== e.provider.toLowerCase()) return false
79
+ if (criteria.model && e.model && criteria.model.toLowerCase() !== e.model.toLowerCase()) return false
80
+ if (criteria.sourceHash !== undefined && e.sourceHash !== undefined && criteria.sourceHash !== e.sourceHash) return false
81
+ if (criteria.sourceHash && !e.sourceHash) return false
82
+ if (!criteria.sourceHash && e.sourceHash) return false
83
+ if (criteria.maskHash !== undefined && e.maskHash !== undefined && criteria.maskHash !== e.maskHash) return false
84
+ if (criteria.maskHash && !e.maskHash) return false
85
+ if (!criteria.maskHash && e.maskHash) return false
86
+ if (criteria.refHash !== undefined && e.refHash !== undefined && criteria.refHash !== e.refHash) return false
87
+ if (criteria.refHash && !e.refHash) return false
88
+ if (!criteria.refHash && e.refHash) return false
89
+ if (criteria.quality !== undefined && e.quality !== undefined && criteria.quality !== e.quality) return false
90
+ if (criteria.style !== undefined && e.style !== undefined && criteria.style !== e.style) return false
91
+ if (criteria.strength !== undefined && e.strength !== undefined && criteria.strength !== e.strength) return false
92
+
61
93
  if (criteria.format) {
62
94
  const entryFmt = (e.format || (e.mediaType || '').replace('image/', '')).toLowerCase().replace('jpeg', 'jpg')
63
95
  const targetFmt = String(criteria.format).toLowerCase().replace('jpeg', 'jpg')
@@ -191,10 +191,13 @@ export function computeGenerationHash({
191
191
  } = {}) {
192
192
  const hashContent = (item) => {
193
193
  if (!item) return ''
194
- if (Buffer.isBuffer(item)) return createHash('sha256').update(item).digest('hex').slice(0, 16)
194
+ if (Buffer.isBuffer(item)) return createHash('sha256').update(item).digest('hex')
195
195
  if (typeof item === 'object' && item.bytes) {
196
196
  const b = Buffer.isBuffer(item.bytes) ? item.bytes : Buffer.from(item.bytes)
197
- return createHash('sha256').update(b).digest('hex').slice(0, 16)
197
+ return createHash('sha256').update(b).digest('hex')
198
+ }
199
+ if (Array.isArray(item)) {
200
+ return item.map(hashContent).filter(Boolean).join(',')
198
201
  }
199
202
  return String(item).trim()
200
203
  }
@@ -35,6 +35,11 @@ export function createFalGenerator(deps, job) {
35
35
  body.strength = strength
36
36
  }
37
37
  } else if (job.faceReference && (job.faceReference.bytes || job.faceReference.url)) {
38
+ // #375: Enforce model capability check before dispatching face identity anchors
39
+ const isFaceCapable = /instant-id|face-to-many|pulid|ip-adapter|face/i.test(targetModel)
40
+ if (!isFaceCapable) {
41
+ throw new Error(`Model "${targetModel}" on FAL does not support face identity anchors. Use a face-capable model (e.g. fal-ai/flux-lora/face-to-many, fal-ai/instant-id) or ComfyUI with FaceID workflow.`)
42
+ }
38
43
  const refDataUri = job.faceReference.bytes
39
44
  ? `data:${job.faceReference.mediaType || 'image/png'};base64,${Buffer.from(job.faceReference.bytes).toString('base64')}`
40
45
  : job.faceReference.url
@@ -45,6 +50,11 @@ export function createFalGenerator(deps, job) {
45
50
  body.reference_weight = job.faceStrength
46
51
  }
47
52
  } else if (job.referenceImage && (job.referenceImage.bytes || job.referenceImage.url)) {
53
+ // #375: Enforce model capability check before dispatching style reference anchors
54
+ const isRefCapable = /instant-id|face-to-many|pulid|ip-adapter|reference|redux/i.test(targetModel)
55
+ if (!isRefCapable) {
56
+ throw new Error(`Model "${targetModel}" on FAL does not support reference image anchors. Use a reference-capable model (e.g. fal-ai/flux/dev/redux, fal-ai/ip-adapter) or ComfyUI.`)
57
+ }
48
58
  const refDataUri = job.referenceImage.bytes
49
59
  ? `data:${job.referenceImage.mediaType || 'image/png'};base64,${Buffer.from(job.referenceImage.bytes).toString('base64')}`
50
60
  : job.referenceImage.url
@@ -15,10 +15,10 @@ export function createGeminiGenerator(deps, job) {
15
15
 
16
16
  async function gemini(seedArg = seed, promptArg = prompt) {
17
17
  const key = await resolveKey(cfg.geminiKeyEnv)
18
- let model = cfg.geminiModel || 'gemini-2.0-flash-exp-image-generation'
19
- // #364: Transparently migrate deprecated Imagen 3/4 IDs to Gemini multimodal model
20
- if (model.startsWith('imagen-') || model === 'imagen-3.0-generate-002') {
21
- model = 'gemini-2.0-flash-exp-image-generation'
18
+ let model = cfg.geminiModel || 'gemini-3.1-flash-image'
19
+ // #364: Transparently migrate deprecated Imagen 3/4 IDs and preview 2.0 to current Gemini multimodal model
20
+ if (model.startsWith('imagen-') || model.startsWith('gemini-2.0-flash-exp')) {
21
+ model = 'gemini-3.1-flash-image'
22
22
  }
23
23
 
24
24
  const timeoutSignal = typeof AbortSignal !== 'undefined' && AbortSignal.timeout
@@ -53,14 +53,22 @@ export function createGeminiGenerator(deps, job) {
53
53
  }
54
54
  parts.push({ text: promptArg })
55
55
 
56
- // Build unified imageConfig without quality/style overwrite collisions (#364)
56
+ // #364: Google Gemini ImageConfig only accepts aspectRatio and imageSize.
57
+ // Unofficial/deprecated fields like imageQuality or imageStyle must not be passed.
57
58
  const imageConfig = {}
58
- if (aspectRatio) imageConfig.aspectRatio = aspectRatio
59
- if (quality && quality !== 'auto') imageConfig.imageQuality = quality
60
- if (style && style !== 'none') imageConfig.imageStyle = style
59
+ if (aspectRatio) {
60
+ const ratioMap = {
61
+ square: '1:1',
62
+ portrait_4_3: '3:4',
63
+ landscape_4_3: '4:3',
64
+ portrait_16_9: '9:16',
65
+ landscape_16_9: '16:9',
66
+ }
67
+ imageConfig.aspectRatio = ratioMap[aspectRatio] || aspectRatio
68
+ }
61
69
 
62
70
  const generationConfig = {
63
- responseModalities: ['IMAGE'],
71
+ responseModalities: ['TEXT', 'IMAGE'],
64
72
  ...(Object.keys(imageConfig).length > 0 ? { imageConfig } : {}),
65
73
  }
66
74
 
@@ -43,6 +43,13 @@ export function createLocalGenerator(deps, job) {
43
43
  }
44
44
 
45
45
  if (kind === 'a1111') {
46
+ // #375: Local A1111 txt2img/img2img lacks identity adapters
47
+ if (job.faceReference) {
48
+ throw new Error('Local A1111 does not support facial identity references (FaceID) — use ComfyUI (IP-Adapter/InstantID) or FAL')
49
+ }
50
+ if (job.referenceImage) {
51
+ throw new Error('Local A1111 does not support visual style reference anchors — use ComfyUI (IP-Adapter) or FAL')
52
+ }
46
53
  if (typeof onProgress === 'function') {
47
54
  onProgress({ stage: 'Generating...', progress: undefined })
48
55
  }
@@ -88,6 +95,17 @@ export function createLocalGenerator(deps, job) {
88
95
  }
89
96
 
90
97
  // ComfyUI: submit via /prompt, poll /history/{prompt_id} until completed.
98
+ if (job.faceReference || job.referenceImage) {
99
+ const wf = cfg.comfyWorkflowJson || ''
100
+ const hasFace = wf.includes('face_image') || wf.includes('ApplyIPAdapter') || wf.includes('InstantID') || wf.includes('FaceID')
101
+ const hasRef = wf.includes('reference_image') || wf.includes('ApplyIPAdapter') || wf.includes('StyleModelApply')
102
+ if (job.faceReference && !hasFace) {
103
+ throw new Error('ComfyUI requires a custom workflow with FaceID or IP-Adapter nodes to use facial identity anchors.')
104
+ }
105
+ if (job.referenceImage && !hasRef) {
106
+ throw new Error('ComfyUI requires a custom workflow with reference or IP-Adapter nodes to use style reference anchors.')
107
+ }
108
+ }
91
109
  const promptId = `dsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
92
110
  let workflowGraph
93
111
  if (cfg.comfyWorkflowJson) {
package/lib/providers.js CHANGED
@@ -779,7 +779,8 @@ export async function testProviderConnection(deps, providerId) {
779
779
  if (p === 'gemini') {
780
780
  const key = await resolveKey(cfg?.geminiKeyEnv || 'GEMINI_API_KEY')
781
781
  if (!key) return { ok: false, state: 'not_configured', latencyMs: 0, message: 'Google Gemini API key not configured' }
782
- const model = cfg?.geminiModel || 'gemini-2.0-flash-exp-image-generation'
782
+ let model = cfg?.geminiModel || 'gemini-3.1-flash-image'
783
+ if (model.startsWith('imagen-') || model.startsWith('gemini-2.0-flash-exp')) model = 'gemini-3.1-flash-image'
783
784
  let res
784
785
  try {
785
786
  res = await fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${model}?key=${encodeURIComponent(key)}`, {
@@ -1,5 +1,5 @@
1
1
  // lib/quality-gate.js
2
- // Automatic Quality Gate and Silent Re-roll Engine (#166)
2
+ // Automatic Quality Gate and Silent Re-roll Engine (#166, #353)
3
3
 
4
4
  import { estimateSharpnessAndVariance } from './providers.js'
5
5
 
@@ -97,7 +97,24 @@ export async function executeWithQualityGate(generateFn, {
97
97
  const attempts = enabled ? Math.max(1, maxRerolls + 1) : 1
98
98
 
99
99
  for (let attempt = 0; attempt < attempts; attempt++) {
100
- lastGenerated = await generateFn(currentSeed)
100
+ try {
101
+ lastGenerated = await generateFn(currentSeed)
102
+ } catch (err) {
103
+ // #353: If a subsequent re-roll exceeds budget, abort further retries and preserve the initial output
104
+ if (lastGenerated && (err.message?.includes('budget exceeded') || err.message?.includes('Daily image generation budget'))) {
105
+ logger(`Quality gate re-roll halted by budget limit: ${err.message}`)
106
+ return {
107
+ generated: lastGenerated,
108
+ qualityReport: {
109
+ ...lastReport,
110
+ rerolls,
111
+ exhausted: true,
112
+ budgetExceeded: true,
113
+ },
114
+ }
115
+ }
116
+ throw err
117
+ }
101
118
 
102
119
  if (!enabled) {
103
120
  return {
@@ -15,7 +15,7 @@ import {
15
15
 
16
16
  } from '../providers.js'
17
17
  import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
18
- import { assertBudgetAvailable, calculateGenerationCost, recordSpend } from '../cost-meter.js'
18
+ import { assertBudgetAvailable, calculateGenerationCost, reserveSpend, recordSpend } from '../cost-meter.js'
19
19
  import { renderToolOutput } from '../attachment-helper.js'
20
20
  import {
21
21
  CHARACTER_SHEET_LAYOUTS,
@@ -152,15 +152,17 @@ export function registerCharacterSheetTools(ctx, deps) {
152
152
  size: cfg.defaultSize || '1024x1024',
153
153
  count: 1,
154
154
  })
155
- assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
156
-
157
- const gen = await executeWithFallback(
158
- providers,
159
- fallbackChain,
160
- seed,
161
- sheetPlan.prompt,
162
- { logger: ctx.logger },
163
- )
155
+ const reservation = reserveSpend(estimatedCost, cfg.dailyBudgetUsd)
156
+ let gen
157
+ let committed = false
158
+ try {
159
+ gen = await executeWithFallback(
160
+ providers,
161
+ fallbackChain,
162
+ seed,
163
+ sheetPlan.prompt,
164
+ { logger: ctx.logger },
165
+ )
164
166
 
165
167
  const usedProvider = gen._fallback?.providerUsed || provider
166
168
  const finalBytes = gen.bytes
@@ -196,10 +198,11 @@ export function registerCharacterSheetTools(ctx, deps) {
196
198
  }
197
199
 
198
200
  const effectiveCost = Number(gen.cost) || estimatedCost
199
- recordSpend(effectiveCost, {
201
+ reservation.commit(effectiveCost, {
200
202
  ctx,
201
203
  meta: { provider: usedProvider, model: cfg.model || cfg.customModel, prompt: sheetPlan.prompt, seed },
202
204
  })
205
+ committed = true
203
206
 
204
207
  const dualOutput = buildDualOutputMarkdown({
205
208
  action: 'generated',
@@ -232,6 +235,11 @@ export function registerCharacterSheetTools(ctx, deps) {
232
235
  }
233
236
 
234
237
  return toLosslessJson(response)
238
+ } finally {
239
+ if (!committed) {
240
+ reservation.release()
241
+ }
242
+ }
235
243
  },
236
244
  })
237
245
  )
@@ -13,7 +13,7 @@ import {
13
13
  } from '../providers.js'
14
14
  import { buildDualOutputMarkdown } from '../resolve-image.js'
15
15
  import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
16
- import { assertBudgetAvailable, calculateGenerationCost, recordSpend } from '../cost-meter.js'
16
+ import { assertBudgetAvailable, calculateGenerationCost, reserveSpend, recordSpend } from '../cost-meter.js'
17
17
  import { renderToolOutput } from '../attachment-helper.js'
18
18
  import {
19
19
  DIAGRAM_3D_STYLES,
@@ -148,15 +148,17 @@ export function registerDiagramTools(ctx, deps) {
148
148
  size: cfg.defaultSize || '1024x1024',
149
149
  count: 1,
150
150
  })
151
- assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
152
-
153
- const gen = await executeWithFallback(
154
- providers,
155
- fallbackChain,
156
- seed,
157
- plan.prompt,
158
- { logger: ctx.logger },
159
- )
151
+ const reservation = reserveSpend(estimatedCost, cfg.dailyBudgetUsd)
152
+ let gen
153
+ let committed = false
154
+ try {
155
+ gen = await executeWithFallback(
156
+ providers,
157
+ fallbackChain,
158
+ seed,
159
+ plan.prompt,
160
+ { logger: ctx.logger },
161
+ )
160
162
 
161
163
  const usedProvider = gen._fallback?.providerUsed || provider
162
164
  const finalBytes = gen.bytes
@@ -192,10 +194,11 @@ export function registerDiagramTools(ctx, deps) {
192
194
  }
193
195
 
194
196
  const effectiveCost = Number(gen.cost) || estimatedCost
195
- recordSpend(effectiveCost, {
197
+ reservation.commit(effectiveCost, {
196
198
  ctx,
197
199
  meta: { provider: usedProvider, model: cfg.model || cfg.customModel, prompt: plan.prompt, seed },
198
200
  })
201
+ committed = true
199
202
 
200
203
  const dualOutput = buildDualOutputMarkdown({
201
204
  action: 'generated',
@@ -227,6 +230,11 @@ export function registerDiagramTools(ctx, deps) {
227
230
  }
228
231
 
229
232
  return toLosslessJson(response)
233
+ } finally {
234
+ if (!committed) {
235
+ reservation.release()
236
+ }
237
+ }
230
238
  },
231
239
  })
232
240
  )