@goodandready/dsh-image-gen 0.11.30 → 0.11.31
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/generation-helpers.js +8 -5
- 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 +16 -11
- package/lib/tools/diagram.js +16 -11
- package/lib/tools/generation.js +20 -8
- package/lib/tools/processing-basic.js +7 -7
- package/lib/tools/style-matrix.js +3 -3
- package/lib/tools/theme-pair.js +4 -4
- package/lib/tools/ui-asset.js +12 -7
- package/lib/tools/vision-ocr.js +16 -11
- package/package.json +1 -1
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
|
+
}
|
|
@@ -152,6 +152,7 @@ export async function saveGeneratedImageArtifact({
|
|
|
152
152
|
slugify,
|
|
153
153
|
estimatedCost,
|
|
154
154
|
totalCount,
|
|
155
|
+
recordSpend: shouldRecordSpend = true,
|
|
155
156
|
}) {
|
|
156
157
|
const targetFormat = format || (args.output_format || cfg.defaultFormat)
|
|
157
158
|
const formatted = await ensureImageFormat(gen.bytes, targetFormat, { fallbackMime: gen.mediaType })
|
|
@@ -262,11 +263,13 @@ export async function saveGeneratedImageArtifact({
|
|
|
262
263
|
await appendHistoryEntry(entry, { pruneDays: cfg.pruneDays, historyLimit })
|
|
263
264
|
}
|
|
264
265
|
|
|
265
|
-
// #167: Record spend in cost meter
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
266
|
+
// #167: Record spend in cost meter (unless caller already committed attempt spend)
|
|
267
|
+
if (shouldRecordSpend !== false) {
|
|
268
|
+
recordSpend(gen.cost || (estimatedCost / totalCount), {
|
|
269
|
+
ctx,
|
|
270
|
+
meta: { provider, model: cfg.model || cfg.customModel, prompt: promptArg, seed: gen.seed },
|
|
271
|
+
})
|
|
272
|
+
}
|
|
270
273
|
|
|
271
274
|
return {
|
|
272
275
|
path: filePath,
|
|
@@ -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,20 @@ 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
|
+
try {
|
|
158
|
+
gen = await executeWithFallback(
|
|
159
|
+
providers,
|
|
160
|
+
fallbackChain,
|
|
161
|
+
seed,
|
|
162
|
+
sheetPlan.prompt,
|
|
163
|
+
{ logger: ctx.logger },
|
|
164
|
+
)
|
|
165
|
+
} catch (err) {
|
|
166
|
+
reservation.release()
|
|
167
|
+
throw err
|
|
168
|
+
}
|
|
164
169
|
|
|
165
170
|
const usedProvider = gen._fallback?.providerUsed || provider
|
|
166
171
|
const finalBytes = gen.bytes
|
|
@@ -196,7 +201,7 @@ export function registerCharacterSheetTools(ctx, deps) {
|
|
|
196
201
|
}
|
|
197
202
|
|
|
198
203
|
const effectiveCost = Number(gen.cost) || estimatedCost
|
|
199
|
-
|
|
204
|
+
reservation.commit(effectiveCost, {
|
|
200
205
|
ctx,
|
|
201
206
|
meta: { provider: usedProvider, model: cfg.model || cfg.customModel, prompt: sheetPlan.prompt, seed },
|
|
202
207
|
})
|
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,20 @@ 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
|
+
try {
|
|
154
|
+
gen = await executeWithFallback(
|
|
155
|
+
providers,
|
|
156
|
+
fallbackChain,
|
|
157
|
+
seed,
|
|
158
|
+
plan.prompt,
|
|
159
|
+
{ logger: ctx.logger },
|
|
160
|
+
)
|
|
161
|
+
} catch (err) {
|
|
162
|
+
reservation.release()
|
|
163
|
+
throw err
|
|
164
|
+
}
|
|
160
165
|
|
|
161
166
|
const usedProvider = gen._fallback?.providerUsed || provider
|
|
162
167
|
const finalBytes = gen.bytes
|
|
@@ -192,7 +197,7 @@ export function registerDiagramTools(ctx, deps) {
|
|
|
192
197
|
}
|
|
193
198
|
|
|
194
199
|
const effectiveCost = Number(gen.cost) || estimatedCost
|
|
195
|
-
|
|
200
|
+
reservation.commit(effectiveCost, {
|
|
196
201
|
ctx,
|
|
197
202
|
meta: { provider: usedProvider, model: cfg.model || cfg.customModel, prompt: plan.prompt, seed },
|
|
198
203
|
})
|
package/lib/tools/generation.js
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
} from '../providers.js'
|
|
16
16
|
import { buildDualOutputMarkdown } from '../resolve-image.js'
|
|
17
17
|
import { executeWithQualityGate } from '../quality-gate.js'
|
|
18
|
-
import { calculateGenerationCost, assertBudgetAvailable } from '../cost-meter.js'
|
|
18
|
+
import { calculateGenerationCost, assertBudgetAvailable, reserveSpend } from '../cost-meter.js'
|
|
19
19
|
import { trackAndAssertLoopGuard } from '../loop-guard.js'
|
|
20
20
|
import { getCachedGeneration } from '../generation-cache.js'
|
|
21
21
|
import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
|
|
@@ -378,14 +378,25 @@ export function registerGenerationTools(ctx, deps, options = {}) {
|
|
|
378
378
|
async (s, p) => {
|
|
379
379
|
let totalAttempts = 0
|
|
380
380
|
let accumulatedCost = 0
|
|
381
|
-
// #353: Pipeline evaluates raw gen.bytes
|
|
381
|
+
// #353, #355: Pipeline reserves budget per attempt, evaluates raw gen.bytes, and commits spend per attempt
|
|
382
|
+
const perAttemptCost = Number(estimatedCost / totalCount) || 0.02
|
|
382
383
|
const rawGenerate = async (seedVal) => {
|
|
384
|
+
const reservation = reserveSpend(perAttemptCost, cfg.dailyBudgetUsd)
|
|
383
385
|
totalAttempts++
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
386
|
+
try {
|
|
387
|
+
const gen = await providers[k](seedVal, p)
|
|
388
|
+
guard(gen)
|
|
389
|
+
const attemptCost = Number(gen.cost) || perAttemptCost
|
|
390
|
+
accumulatedCost += attemptCost
|
|
391
|
+
reservation.commit(attemptCost, {
|
|
392
|
+
ctx,
|
|
393
|
+
meta: { provider: k, model: cfg.model || cfg.customModel, prompt: p, seed: seedVal },
|
|
394
|
+
})
|
|
395
|
+
return gen
|
|
396
|
+
} catch (err) {
|
|
397
|
+
reservation.release()
|
|
398
|
+
throw err
|
|
399
|
+
}
|
|
389
400
|
}
|
|
390
401
|
|
|
391
402
|
const { generated: acceptedGen, qualityReport } = await executeWithQualityGate(
|
|
@@ -398,7 +409,7 @@ export function registerGenerationTools(ctx, deps, options = {}) {
|
|
|
398
409
|
}
|
|
399
410
|
)
|
|
400
411
|
|
|
401
|
-
// Save the accepted artifact ONCE
|
|
412
|
+
// Save the accepted artifact ONCE (spend already committed per attempt)
|
|
402
413
|
const saved = await saveGeneratedImageArtifact({
|
|
403
414
|
ctx,
|
|
404
415
|
exec,
|
|
@@ -417,6 +428,7 @@ export function registerGenerationTools(ctx, deps, options = {}) {
|
|
|
417
428
|
slugify,
|
|
418
429
|
estimatedCost: accumulatedCost || estimatedCost,
|
|
419
430
|
totalCount,
|
|
431
|
+
recordSpend: false,
|
|
420
432
|
})
|
|
421
433
|
|
|
422
434
|
return {
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
saveAttachmentSafe,
|
|
16
16
|
} from '../providers.js'
|
|
17
17
|
import { buildDualOutputMarkdown } from '../resolve-image.js'
|
|
18
|
-
import { assertBudgetAvailable, recordSpend } from '../cost-meter.js'
|
|
18
|
+
import { assertBudgetAvailable, reserveSpend, recordSpend } from '../cost-meter.js'
|
|
19
19
|
import { trackAndAssertLoopGuard } from '../loop-guard.js'
|
|
20
20
|
import { sanitizeErrorAndLogs } from '../security.js'
|
|
21
21
|
|
|
@@ -55,7 +55,7 @@ export function registerProcessingTools(ctx, deps) {
|
|
|
55
55
|
const source = await resolveSource(ctx, exec, args.image)
|
|
56
56
|
if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
|
|
57
57
|
const cfg = live()
|
|
58
|
-
|
|
58
|
+
const resvNobg = reserveSpend(0.02, cfg.dailyBudgetUsd)
|
|
59
59
|
const res = await removeBackgroundFal(
|
|
60
60
|
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
|
|
61
61
|
{ imageBytes: source.bytes, mediaType: source.mediaType, model: args.model, signal: exec?.signal }
|
|
@@ -68,7 +68,7 @@ export function registerProcessingTools(ctx, deps) {
|
|
|
68
68
|
await mkdir(outDir, { recursive: true })
|
|
69
69
|
const filePath = path.join(outDir, name)
|
|
70
70
|
await writeFile(filePath, res.bytes)
|
|
71
|
-
|
|
71
|
+
resvNobg.commit(0.02, { ctx, meta: { tool: 'remove_background', model: args.model || 'fal-ai/birefnet' } })
|
|
72
72
|
const summary = buildDualOutputMarkdown({
|
|
73
73
|
action: 'background removed',
|
|
74
74
|
filePath,
|
|
@@ -126,7 +126,7 @@ export function registerProcessingTools(ctx, deps) {
|
|
|
126
126
|
const source = await resolveSource(ctx, exec, args.image)
|
|
127
127
|
if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
|
|
128
128
|
const cfg = live()
|
|
129
|
-
|
|
129
|
+
const resvUpscale = reserveSpend(0.025, cfg.dailyBudgetUsd)
|
|
130
130
|
const res = await upscaleImageFal(
|
|
131
131
|
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
|
|
132
132
|
{ imageBytes: source.bytes, mediaType: source.mediaType, scale: args.scale, prompt: args.prompt, creativity: args.creativity, signal: exec?.signal }
|
|
@@ -139,7 +139,7 @@ export function registerProcessingTools(ctx, deps) {
|
|
|
139
139
|
await mkdir(outDir, { recursive: true })
|
|
140
140
|
const filePath = path.join(outDir, name)
|
|
141
141
|
await writeFile(filePath, res.bytes)
|
|
142
|
-
|
|
142
|
+
resvUpscale.commit(0.025, { ctx, meta: { tool: 'upscale_image', scale: args.scale } })
|
|
143
143
|
const summary = buildDualOutputMarkdown({
|
|
144
144
|
action: `upscaled (${args.scale || 2}x)`,
|
|
145
145
|
filePath,
|
|
@@ -254,7 +254,7 @@ export function registerProcessingTools(ctx, deps) {
|
|
|
254
254
|
const cfg = live()
|
|
255
255
|
const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
|
|
256
256
|
trackAndAssertLoopGuard(sessionId, { limit: cfg.loopGuardLimit, prompt: args.prompt || '', exec })
|
|
257
|
-
|
|
257
|
+
const resvBlend = reserveSpend(0.05, cfg.dailyBudgetUsd)
|
|
258
258
|
const refs = Array.isArray(args.images) ? args.images : [args.images].filter(Boolean)
|
|
259
259
|
if (refs.length < 2) {
|
|
260
260
|
throw new Error('At least two images are required for blending')
|
|
@@ -279,7 +279,7 @@ export function registerProcessingTools(ctx, deps) {
|
|
|
279
279
|
await mkdir(outDir, { recursive: true })
|
|
280
280
|
const filePath = path.join(outDir, name)
|
|
281
281
|
await writeFile(filePath, res.bytes)
|
|
282
|
-
|
|
282
|
+
resvBlend.commit(0.05, { ctx, meta: { tool: 'blend_images' } })
|
|
283
283
|
const summary = buildDualOutputMarkdown({
|
|
284
284
|
action: 'blended composition',
|
|
285
285
|
filePath,
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
saveAttachmentSafe,
|
|
15
15
|
} from '../providers.js'
|
|
16
16
|
import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
|
|
17
|
-
import { assertBudgetAvailable, calculateGenerationCost, recordSpend } from '../cost-meter.js'
|
|
17
|
+
import { assertBudgetAvailable, calculateGenerationCost, reserveSpend, recordSpend } from '../cost-meter.js'
|
|
18
18
|
import {
|
|
19
19
|
normalizeMatrixStyles,
|
|
20
20
|
buildMatrixCellPrompt,
|
|
@@ -145,7 +145,7 @@ export function registerStyleMatrixTools(ctx, deps) {
|
|
|
145
145
|
size: cfg.defaultSize || '1024x1024',
|
|
146
146
|
count: cellCount,
|
|
147
147
|
})
|
|
148
|
-
|
|
148
|
+
const batchReservation = reserveSpend(estimatedCost, cfg.dailyBudgetUsd)
|
|
149
149
|
|
|
150
150
|
const chain = resolveFallbackChain(provider, cfg.fallbackProviders, PROVIDER_KEYS)
|
|
151
151
|
const quadrantIds = ['A', 'B', 'C', 'D']
|
|
@@ -187,7 +187,7 @@ export function registerStyleMatrixTools(ctx, deps) {
|
|
|
187
187
|
})
|
|
188
188
|
|
|
189
189
|
const cellCost = Number(gen.cost) || +(estimatedCost / cellCount).toFixed(4)
|
|
190
|
-
|
|
190
|
+
batchReservation.commit(cellCost, {
|
|
191
191
|
ctx,
|
|
192
192
|
meta: { provider, model: cfg.model || cfg.customModel, prompt: cellPrompt, seed: cellSeed },
|
|
193
193
|
})
|
package/lib/tools/theme-pair.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
saveAttachmentSafe,
|
|
14
14
|
} from '../providers.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
|
THEME_STYLES,
|
|
@@ -153,7 +153,7 @@ export function registerThemePairTools(ctx, deps) {
|
|
|
153
153
|
size: cfg.defaultSize || '1024x1024',
|
|
154
154
|
count: 2,
|
|
155
155
|
})
|
|
156
|
-
|
|
156
|
+
const batchReservation = reserveSpend(estimatedCost, cfg.dailyBudgetUsd)
|
|
157
157
|
|
|
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)
|
|
@@ -202,7 +202,7 @@ export function registerThemePairTools(ctx, deps) {
|
|
|
202
202
|
height: lightGen.height || aspectPixels[1],
|
|
203
203
|
})
|
|
204
204
|
const halfCost = +(estimatedCost / 2).toFixed(4)
|
|
205
|
-
|
|
205
|
+
batchReservation.commit(Number(lightGen.cost) || halfCost, {
|
|
206
206
|
ctx,
|
|
207
207
|
meta: { provider: primaryProvider, model: cfg.model || cfg.customModel, prompt: lightPrompt },
|
|
208
208
|
})
|
|
@@ -245,7 +245,7 @@ export function registerThemePairTools(ctx, deps) {
|
|
|
245
245
|
width: darkGen.width || aspectPixels[0],
|
|
246
246
|
height: darkGen.height || aspectPixels[1],
|
|
247
247
|
})
|
|
248
|
-
|
|
248
|
+
batchReservation.commit(Number(darkGen.cost) || halfCost, {
|
|
249
249
|
ctx,
|
|
250
250
|
meta: { provider: primaryProvider, model: cfg.model || cfg.customModel, prompt: darkPrompt },
|
|
251
251
|
})
|
package/lib/tools/ui-asset.js
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
saveAttachmentSafe,
|
|
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 { buildDualOutputMarkdown } from '../resolve-image.js'
|
|
20
20
|
import {
|
|
21
21
|
UI_ASSET_TYPES,
|
|
@@ -168,14 +168,19 @@ export function registerUiAssetTools(ctx, deps) {
|
|
|
168
168
|
size: cfg.defaultSize || '1024x1024',
|
|
169
169
|
count: 1,
|
|
170
170
|
})
|
|
171
|
-
|
|
172
|
-
|
|
171
|
+
const reservation = reserveSpend(estimatedCost, cfg.dailyBudgetUsd)
|
|
173
172
|
const chain = resolveFallbackChain(provider, cfg.fallbackProviders, PROVIDER_KEYS)
|
|
174
173
|
const seedVal = args.seed ?? Math.floor(Math.random() * 100000)
|
|
175
174
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
175
|
+
let gen
|
|
176
|
+
try {
|
|
177
|
+
gen = await executeWithFallback(providers, chain, seedVal, synthesizedPrompt, {
|
|
178
|
+
logger: ctx.logger,
|
|
179
|
+
})
|
|
180
|
+
} catch (err) {
|
|
181
|
+
reservation.release()
|
|
182
|
+
throw err
|
|
183
|
+
}
|
|
179
184
|
|
|
180
185
|
let finalBytes = gen.bytes
|
|
181
186
|
let mediaType = gen.mediaType || 'image/png'
|
|
@@ -211,7 +216,7 @@ export function registerUiAssetTools(ctx, deps) {
|
|
|
211
216
|
})
|
|
212
217
|
|
|
213
218
|
const effectiveCost = Number(gen.cost) || estimatedCost
|
|
214
|
-
|
|
219
|
+
reservation.commit(effectiveCost, {
|
|
215
220
|
ctx,
|
|
216
221
|
meta: { provider, model: cfg.model || cfg.customModel, prompt: synthesizedPrompt, seed: seedVal },
|
|
217
222
|
})
|
package/lib/tools/vision-ocr.js
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from '../providers.js'
|
|
13
13
|
import { buildDualOutputMarkdown } from '../resolve-image.js'
|
|
14
14
|
import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
|
|
15
|
-
import { assertBudgetAvailable, calculateGenerationCost, recordSpend } from '../cost-meter.js'
|
|
15
|
+
import { assertBudgetAvailable, calculateGenerationCost, reserveSpend, recordSpend } from '../cost-meter.js'
|
|
16
16
|
import { renderToolOutput } from '../attachment-helper.js'
|
|
17
17
|
import {
|
|
18
18
|
normalizeBbox,
|
|
@@ -252,15 +252,20 @@ export function registerVisionOcrTools(ctx, deps) {
|
|
|
252
252
|
size: `${imgWidth}x${imgHeight}`,
|
|
253
253
|
count: 1,
|
|
254
254
|
})
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
255
|
+
const reservation = reserveSpend(estimatedCost, cfg.dailyBudgetUsd)
|
|
256
|
+
let gen
|
|
257
|
+
try {
|
|
258
|
+
gen = await executeWithFallback(
|
|
259
|
+
providers,
|
|
260
|
+
fallbackChain,
|
|
261
|
+
seed,
|
|
262
|
+
inpaintPrompt,
|
|
263
|
+
{ logger: ctx.logger },
|
|
264
|
+
)
|
|
265
|
+
} catch (err) {
|
|
266
|
+
reservation.release()
|
|
267
|
+
throw err
|
|
268
|
+
}
|
|
264
269
|
|
|
265
270
|
const inpaintedBytes = gen.bytes
|
|
266
271
|
if (!inpaintedBytes) {
|
|
@@ -323,7 +328,7 @@ export function registerVisionOcrTools(ctx, deps) {
|
|
|
323
328
|
})
|
|
324
329
|
|
|
325
330
|
const effectiveCost = Number(gen.cost) || estimatedCost
|
|
326
|
-
|
|
331
|
+
reservation.commit(effectiveCost, {
|
|
327
332
|
ctx,
|
|
328
333
|
meta: { provider: usedProvider, model: cfg.model || cfg.customModel, prompt: inpaintPrompt, seed },
|
|
329
334
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-image-gen",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.31",
|
|
4
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",
|