@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 +2 -2
- package/lib/config-schema.js +1 -1
- package/lib/cost-meter.js +74 -7
- package/lib/frontend-assets.js +57 -0
- package/lib/generation-helpers.js +36 -11
- package/lib/history.js +32 -0
- package/lib/provider-utils.js +5 -2
- package/lib/providers/backends/fal.js +10 -0
- package/lib/providers/backends/gemini.js +17 -9
- package/lib/providers/backends/local.js +18 -0
- package/lib/providers.js +2 -1
- package/lib/quality-gate.js +19 -2
- package/lib/tools/character-sheet.js +19 -11
- package/lib/tools/diagram.js +19 -11
- package/lib/tools/generation.js +56 -14
- package/lib/tools/processing-basic.js +143 -110
- package/lib/tools/style-matrix.js +11 -4
- package/lib/tools/theme-pair.js +12 -5
- package/lib/tools/ui-asset.js +15 -7
- package/lib/tools/vision-ocr.js +19 -11
- package/package.json +2 -2
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-
|
|
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-
|
|
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',
|
package/lib/config-schema.js
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
|
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
|
+
}
|
package/lib/frontend-assets.js
CHANGED
|
@@ -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 += '{'
|
|
385
|
+
} else if (ch === '}') {
|
|
386
|
+
result += '}'
|
|
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 += '{'
|
|
405
|
+
} else if (ch === '}') {
|
|
406
|
+
result += '}'
|
|
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:
|
|
225
|
-
maskImage:
|
|
226
|
-
reference:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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')
|
package/lib/provider-utils.js
CHANGED
|
@@ -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')
|
|
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')
|
|
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-
|
|
19
|
-
// #364: Transparently migrate deprecated Imagen 3/4 IDs to Gemini multimodal model
|
|
20
|
-
if (model.startsWith('imagen-') || model
|
|
21
|
-
model = 'gemini-
|
|
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
|
-
//
|
|
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)
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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)}`, {
|
package/lib/quality-gate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
)
|
package/lib/tools/diagram.js
CHANGED
|
@@ -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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
)
|