@goodandready/dsh-image-gen 0.11.29 → 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/providers.js CHANGED
@@ -536,19 +536,7 @@ export async function blendImageBuffers(images, weights) {
536
536
  const bytes = await sharp(out, { raw: { width: targetW, height: targetH, channels: 4 } }).png().toBuffer()
537
537
  return { bytes, width: targetW, height: targetH, mediaType: 'image/png', weights: normWeights }
538
538
  } catch (err) {
539
- // Fallback for mock or synthetic test buffers that cannot be parsed by sharp
540
- const maxLen = Math.max(...images.map(img => (img.bytes?.length || 0)))
541
- const out = Buffer.alloc(maxLen)
542
- for (let p = 0; p < maxLen; p++) {
543
- let val = 0
544
- for (let i = 0; i < images.length; i++) {
545
- const b = images[i].bytes || Buffer.alloc(0)
546
- const byteVal = p < b.length ? b[p] : 0
547
- val += byteVal * normWeights[i]
548
- }
549
- out[p] = Math.round(Math.min(255, Math.max(0, val)))
550
- }
551
- return { bytes: out, width: 0, height: 0, mediaType: 'image/png', weights: normWeights }
539
+ throw new Error(`Failed to decode image buffer for blending: ${err.message}`)
552
540
  }
553
541
  }
554
542
 
@@ -791,7 +779,8 @@ export async function testProviderConnection(deps, providerId) {
791
779
  if (p === 'gemini') {
792
780
  const key = await resolveKey(cfg?.geminiKeyEnv || 'GEMINI_API_KEY')
793
781
  if (!key) return { ok: false, state: 'not_configured', latencyMs: 0, message: 'Google Gemini API key not configured' }
794
- const model = cfg?.geminiModel || 'gemini-2.0-flash-exp-image-generation'
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'
795
784
  let res
796
785
  try {
797
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,20 @@ 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
+ 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
- recordSpend(effectiveCost, {
204
+ reservation.commit(effectiveCost, {
200
205
  ctx,
201
206
  meta: { provider: usedProvider, model: cfg.model || cfg.customModel, prompt: sheetPlan.prompt, seed },
202
207
  })
@@ -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
- 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
+ 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
- recordSpend(effectiveCost, {
200
+ reservation.commit(effectiveCost, {
196
201
  ctx,
197
202
  meta: { provider: usedProvider, model: cfg.model || cfg.customModel, prompt: plan.prompt, seed },
198
203
  })
@@ -87,7 +87,8 @@ export function registerEditingTools(ctx, deps, options = {}) {
87
87
  if (cfg && cfg.enabled === false) {
88
88
  throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
89
89
  }
90
- trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
90
+ const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
91
+ trackAndAssertLoopGuard(sessionId, { limit: cfg.loopGuardLimit, prompt: args.prompt || '', exec })
91
92
  const estimatedCostEdit = calculateGenerationCost({
92
93
  provider: cfg.provider || 'fal',
93
94
  model: cfg.model,
@@ -242,7 +243,8 @@ export function registerEditingTools(ctx, deps, options = {}) {
242
243
  if (cfg && cfg.enabled === false) {
243
244
  throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
244
245
  }
245
- trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
246
+ const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
247
+ trackAndAssertLoopGuard(sessionId, { limit: cfg.loopGuardLimit, prompt: args.prompt || '', exec })
246
248
  const count = normalizeCount(args.count || 1)
247
249
  const estimatedCostVary = calculateGenerationCost({
248
250
  provider: cfg.provider || 'fal',
@@ -383,7 +385,8 @@ export function registerEditingTools(ctx, deps, options = {}) {
383
385
  if (cfg && cfg.enabled === false) {
384
386
  throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
385
387
  }
386
- trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
388
+ const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
389
+ trackAndAssertLoopGuard(sessionId, { limit: cfg.loopGuardLimit, prompt: args.prompt || '', exec })
387
390
  const count = normalizeCount(args.count || 1)
388
391
  const estimatedCost = calculateGenerationCost({
389
392
  provider: cfg.provider || 'fal',
@@ -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 first; saves artifact ONCE after acceptance
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
- const gen = await providers[k](seedVal, p)
385
- guard(gen)
386
- const attemptCost = Number(gen.cost) || estimatedCost
387
- accumulatedCost += attemptCost
388
- return gen
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 {
@@ -500,12 +512,17 @@ export function registerGenerationTools(ctx, deps, options = {}) {
500
512
  const hPrompt = h
501
513
  const found = findCachedGeneration(historyEntries, hPrompt)
502
514
  if (found) return await cachedResult(found)
515
+ const requestedCriteria = {
516
+ format: args.output_format || cfg.defaultFormat || 'png',
517
+ aspectRatio: args.aspect_ratio || cfg.defaultAspectRatio,
518
+ size: args.aspect_ratio ? 'custom' : (args.image_size || cfg.defaultSize),
519
+ }
503
520
  if (cfg.cacheBySeed) {
504
- const bySeed = await findCached(historyEntries, seedVal, promptVal)
521
+ const bySeed = await findCached(historyEntries, seedVal, promptVal, requestedCriteria)
505
522
  if (bySeed) return await cachedResult(bySeed)
506
523
  }
507
524
  if (cfg.cacheByPrompt) {
508
- const byPrompt = await findCachedByPrompt(historyEntries, promptVal)
525
+ const byPrompt = await findCachedByPrompt(historyEntries, promptVal, requestedCriteria)
509
526
  if (byPrompt) return await cachedResult(byPrompt)
510
527
  }
511
528
  return undefined
@@ -67,7 +67,8 @@ export function registerPatternTools(ctx, deps) {
67
67
  async execute(args, exec) {
68
68
  try {
69
69
  const cfg = live()
70
- trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
70
+ const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
71
+ trackAndAssertLoopGuard(sessionId, { limit: cfg.loopGuardLimit, prompt: args.motif || '', exec })
71
72
  const density = normalizeDensity(args.density)
72
73
  const prompt = buildSeamlessPrompt(args.motif, args.style, density)
73
74
  const provider = cfg.provider || 'fal'
@@ -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
- assertBudgetAvailable(0.02, cfg.dailyBudgetUsd)
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
- recordSpend(0.02, { ctx, meta: { tool: 'remove_background', model: args.model || 'fal-ai/birefnet' } })
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
- assertBudgetAvailable(0.025, cfg.dailyBudgetUsd)
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
- recordSpend(0.025, { ctx, meta: { tool: 'upscale_image', scale: args.scale } })
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,
@@ -252,8 +252,9 @@ export function registerProcessingTools(ctx, deps) {
252
252
  async execute(args, exec) {
253
253
  try {
254
254
  const cfg = live()
255
- trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
256
- assertBudgetAvailable(0.05, cfg.dailyBudgetUsd)
255
+ const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
256
+ trackAndAssertLoopGuard(sessionId, { limit: cfg.loopGuardLimit, prompt: args.prompt || '', exec })
257
+ const resvBlend = reserveSpend(0.05, cfg.dailyBudgetUsd)
257
258
  const refs = Array.isArray(args.images) ? args.images : [args.images].filter(Boolean)
258
259
  if (refs.length < 2) {
259
260
  throw new Error('At least two images are required for blending')
@@ -278,7 +279,7 @@ export function registerProcessingTools(ctx, deps) {
278
279
  await mkdir(outDir, { recursive: true })
279
280
  const filePath = path.join(outDir, name)
280
281
  await writeFile(filePath, res.bytes)
281
- recordSpend(0.05, { ctx, meta: { tool: 'blend_images' } })
282
+ resvBlend.commit(0.05, { ctx, meta: { tool: 'blend_images' } })
282
283
  const summary = buildDualOutputMarkdown({
283
284
  action: 'blended composition',
284
285
  filePath,
@@ -101,7 +101,8 @@ export function registerResponsiveTools(ctx, deps) {
101
101
  async execute(args, exec) {
102
102
  try {
103
103
  const cfg = live()
104
- trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
104
+ const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
105
+ trackAndAssertLoopGuard(sessionId, { limit: cfg.loopGuardLimit, prompt: args.prompt || '', exec })
105
106
  if (!args.prompt || !String(args.prompt).trim()) {
106
107
  throw new Error('prompt is required for generate_responsive_mockups')
107
108
  }
@@ -87,7 +87,8 @@ export function registerSketchTools(ctx, deps) {
87
87
  async execute(args, exec) {
88
88
  try {
89
89
  const cfg = live()
90
- trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
90
+ const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
91
+ trackAndAssertLoopGuard(sessionId, { limit: cfg.loopGuardLimit, prompt: args.prompt || '', exec })
91
92
  const estimated = calculateGenerationCost({
92
93
  provider: cfg.provider || 'fal',
93
94
  model: cfg.model,
@@ -74,7 +74,8 @@ export function registerSpritesheetTools(ctx, deps) {
74
74
  async execute(args, exec) {
75
75
  try {
76
76
  const cfg = live()
77
- trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
77
+ const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
78
+ trackAndAssertLoopGuard(sessionId, { limit: cfg.loopGuardLimit, prompt: args.prompt || '', exec })
78
79
  const animation = String(args.animation || 'idle').toLowerCase()
79
80
  const animKey = ANIMATION_PRESETS[animation] ? animation : 'idle'
80
81
  const frameCount = clampFrames(args.frames, animKey)
@@ -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
- assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
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
- recordSpend(cellCost, {
190
+ batchReservation.commit(cellCost, {
191
191
  ctx,
192
192
  meta: { provider, model: cfg.model || cfg.customModel, prompt: cellPrompt, seed: cellSeed },
193
193
  })
@@ -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
- assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
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
- recordSpend(Number(lightGen.cost) || halfCost, {
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
- recordSpend(Number(darkGen.cost) || halfCost, {
248
+ batchReservation.commit(Number(darkGen.cost) || halfCost, {
249
249
  ctx,
250
250
  meta: { provider: primaryProvider, model: cfg.model || cfg.customModel, prompt: darkPrompt },
251
251
  })
@@ -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
- assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
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
- const gen = await executeWithFallback(providers, chain, seedVal, synthesizedPrompt, {
177
- logger: ctx.logger,
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
- recordSpend(effectiveCost, {
219
+ reservation.commit(effectiveCost, {
215
220
  ctx,
216
221
  meta: { provider, model: cfg.model || cfg.customModel, prompt: synthesizedPrompt, seed: seedVal },
217
222
  })
@@ -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,
@@ -150,8 +150,18 @@ export function registerVisionOcrTools(ctx, deps) {
150
150
  throw new Error(`Unable to resolve source image for text replacement: ${args.image}`)
151
151
  }
152
152
 
153
- const imgWidth = source.width || 1024
154
- const imgHeight = source.height || 1024
153
+ let imgWidth = 1024
154
+ let imgHeight = 1024
155
+ try {
156
+ const sharpMod = await import('sharp')
157
+ const sharp = sharpMod.default || sharpMod
158
+ const meta = await sharp(source.bytes).metadata()
159
+ imgWidth = meta.width || source.width || 1024
160
+ imgHeight = meta.height || source.height || 1024
161
+ } catch (_err) {
162
+ imgWidth = source.width || 1024
163
+ imgHeight = source.height || 1024
164
+ }
155
165
 
156
166
  // 1. Gather text items and bounding boxes
157
167
  const replacements = Array.isArray(args.replacements) ? args.replacements : []
@@ -162,13 +172,14 @@ export function registerVisionOcrTools(ctx, deps) {
162
172
  let bbox = item.bbox
163
173
  if (!bbox && item.find && ctx.tools?.get) {
164
174
  const groundTool = ctx.tools.get('vision_ground')
165
- if (groundTool && typeof groundTool.execute === 'function' && source.ref) {
175
+ if (groundTool && typeof groundTool.execute === 'function') {
166
176
  try {
167
177
  const gRes = await groundTool.execute({
168
- attachmentId: source.ref.startsWith('sha256:') ? source.ref : undefined,
178
+ image: args.image,
179
+ attachmentId: source.ref?.startsWith('sha256:') ? source.ref : (args.image?.startsWith('sha256:') ? args.image : undefined),
169
180
  target: `text "${item.find}"`,
170
181
  }, execCtx)
171
- if (gRes?.bbox && gRes.bbox.length === 4) {
182
+ if (gRes?.bbox && Array.isArray(gRes.bbox) && gRes.bbox.length === 4) {
172
183
  bbox = gRes.bbox
173
184
  }
174
185
  } catch (_err) {
@@ -241,15 +252,20 @@ export function registerVisionOcrTools(ctx, deps) {
241
252
  size: `${imgWidth}x${imgHeight}`,
242
253
  count: 1,
243
254
  })
244
- assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
245
-
246
- const gen = await executeWithFallback(
247
- providers,
248
- fallbackChain,
249
- seed,
250
- inpaintPrompt,
251
- { logger: ctx.logger },
252
- )
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
+ }
253
269
 
254
270
  const inpaintedBytes = gen.bytes
255
271
  if (!inpaintedBytes) {
@@ -258,13 +274,23 @@ export function registerVisionOcrTools(ctx, deps) {
258
274
 
259
275
  // 5. Composite typography overlay on inpainted background
260
276
  let finalBytes = inpaintedBytes
277
+ let composited = false
261
278
  try {
262
279
  const sharpMod = await import('sharp')
263
280
  const sharp = sharpMod.default || sharpMod
281
+ const inpaintMeta = await sharp(inpaintedBytes).metadata()
282
+ const compW = inpaintMeta.width || imgWidth
283
+ const compH = inpaintMeta.height || imgHeight
284
+ let compOverlay = overlayBuffer
285
+ if (compW !== imgWidth || compH !== imgHeight) {
286
+ const scaledOverlaySvg = buildTextOverlaySvg(compW, compH, overlayItems, args.font_family || 'sans-serif')
287
+ compOverlay = Buffer.from(scaledOverlaySvg, 'utf8')
288
+ }
264
289
  finalBytes = await sharp(inpaintedBytes)
265
- .composite([{ input: overlayBuffer, top: 0, left: 0 }])
290
+ .composite([{ input: compOverlay, top: 0, left: 0 }])
266
291
  .png()
267
292
  .toBuffer()
293
+ composited = true
268
294
  } catch (sharpErr) {
269
295
  if (ctx.logger?.warn) ctx.logger.warn(`Sharp compositing skipped: ${sharpErr.message}`)
270
296
  }
@@ -302,7 +328,7 @@ export function registerVisionOcrTools(ctx, deps) {
302
328
  })
303
329
 
304
330
  const effectiveCost = Number(gen.cost) || estimatedCost
305
- recordSpend(effectiveCost, {
331
+ reservation.commit(effectiveCost, {
306
332
  ctx,
307
333
  meta: { provider: usedProvider, model: cfg.model || cfg.customModel, prompt: inpaintPrompt, seed },
308
334
  })
@@ -322,7 +348,7 @@ export function registerVisionOcrTools(ctx, deps) {
322
348
 
323
349
  const response = {
324
350
  summary: dualOutput,
325
- replacedCount: overlayItems.length,
351
+ replacedCount: composited ? overlayItems.length : 0,
326
352
  bboxes,
327
353
  inpaintPrompt,
328
354
  targetLanguage: args.target_language || '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.11.29",
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",