@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.
@@ -2,6 +2,7 @@ import { buildPresentationMeta } from '../attachment-helper.js'
2
2
  import { registerGenerationPackTool } from './generation-pack.js'
3
3
  import { defineTool } from '@deepseek-ai/dsh-tools'
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,
@@ -15,7 +16,7 @@ import {
15
16
  } from '../providers.js'
16
17
  import { buildDualOutputMarkdown } from '../resolve-image.js'
17
18
  import { executeWithQualityGate } from '../quality-gate.js'
18
- import { calculateGenerationCost, assertBudgetAvailable } from '../cost-meter.js'
19
+ import { calculateGenerationCost, assertBudgetAvailable, reserveSpend } from '../cost-meter.js'
19
20
  import { trackAndAssertLoopGuard } from '../loop-guard.js'
20
21
  import { getCachedGeneration } from '../generation-cache.js'
21
22
  import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
@@ -316,8 +317,11 @@ export function registerGenerationTools(ctx, deps, options = {}) {
316
317
  let subscriptionImages
317
318
  try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (_err) { subscriptionImages = undefined }
318
319
 
319
- const source = args.source_image ? await resolveSource(ctx, exec, args.source_image) : undefined
320
- const mask = args.mask ? await resolveSource(ctx, exec, args.mask) : undefined
320
+ const sourceInput = args.source_image || args.image
321
+ const source = sourceInput ? await resolveSource(ctx, exec, sourceInput) : undefined
322
+
323
+ const maskInput = args.mask || args.mask_image
324
+ const mask = maskInput ? await resolveSource(ctx, exec, maskInput) : undefined
321
325
 
322
326
  const {
323
327
  enhanced,
@@ -378,14 +382,28 @@ export function registerGenerationTools(ctx, deps, options = {}) {
378
382
  async (s, p) => {
379
383
  let totalAttempts = 0
380
384
  let accumulatedCost = 0
381
- // #353: Pipeline evaluates raw gen.bytes first; saves artifact ONCE after acceptance
385
+ // #353, #355: Pipeline reserves budget per attempt, evaluates raw gen.bytes, and commits spend per attempt
386
+ const perAttemptCost = Number(estimatedCost / totalCount) || 0.02
382
387
  const rawGenerate = async (seedVal) => {
388
+ const reservation = reserveSpend(perAttemptCost, cfg.dailyBudgetUsd)
383
389
  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
390
+ let committed = false
391
+ try {
392
+ const gen = await providers[k](seedVal, p)
393
+ guard(gen)
394
+ const attemptCost = Number(gen.cost) || perAttemptCost
395
+ accumulatedCost += attemptCost
396
+ reservation.commit(attemptCost, {
397
+ ctx,
398
+ meta: { provider: k, model: cfg.model || cfg.customModel, prompt: p, seed: seedVal },
399
+ })
400
+ committed = true
401
+ return gen
402
+ } finally {
403
+ if (!committed) {
404
+ reservation.release()
405
+ }
406
+ }
389
407
  }
390
408
 
391
409
  const { generated: acceptedGen, qualityReport } = await executeWithQualityGate(
@@ -398,7 +416,7 @@ export function registerGenerationTools(ctx, deps, options = {}) {
398
416
  }
399
417
  )
400
418
 
401
- // Save the accepted artifact ONCE
419
+ // Save the accepted artifact ONCE (spend already committed per attempt)
402
420
  const saved = await saveGeneratedImageArtifact({
403
421
  ctx,
404
422
  exec,
@@ -417,6 +435,11 @@ export function registerGenerationTools(ctx, deps, options = {}) {
417
435
  slugify,
418
436
  estimatedCost: accumulatedCost || estimatedCost,
419
437
  totalCount,
438
+ recordSpend: false,
439
+ source,
440
+ mask,
441
+ resolvedFaceRef,
442
+ resolvedRefImage,
420
443
  })
421
444
 
422
445
  return {
@@ -437,7 +460,11 @@ export function registerGenerationTools(ctx, deps, options = {}) {
437
460
 
438
461
  const effectiveModel = provider === 'custom'
439
462
  ? (cfg.customModel || cfg.model || 'dall-e-3')
440
- : (cfg.model || cfg.customModel)
463
+ : (cfg.model || cfg.customModel || 'default')
464
+
465
+ const sourceBytes = source && source.bytes ? source.bytes : (args.image || args.source_image)
466
+ const maskBytes = mask && mask.bytes ? mask.bytes : (args.mask_image || args.mask)
467
+ const refBytes = (resolvedRefImage && resolvedRefImage.bytes) || (resolvedFaceRef && resolvedFaceRef.bytes) || args.reference_images || args.reference_image
441
468
 
442
469
  const h = computeGenerationHash({
443
470
  provider,
@@ -454,9 +481,9 @@ export function registerGenerationTools(ctx, deps, options = {}) {
454
481
  negativePrompt: args.negative_prompt || cfg.negativePrompt,
455
482
  quality: args.quality,
456
483
  strength: args.strength,
457
- sourceImage: args.image || args.source_image,
458
- maskImage: args.mask_image || args.mask,
459
- reference: args.reference_images || args.reference_image,
484
+ sourceImage: sourceBytes,
485
+ maskImage: maskBytes,
486
+ reference: refBytes,
460
487
  })
461
488
 
462
489
  // #170: Content-addressed disk cache check (<50ms return)
@@ -500,10 +527,25 @@ export function registerGenerationTools(ctx, deps, options = {}) {
500
527
  const hPrompt = h
501
528
  const found = findCachedGeneration(historyEntries, hPrompt)
502
529
  if (found) return await cachedResult(found)
530
+ const srcHash = (source && source.bytes) ? createHash('sha256').update(source.bytes).digest('hex') : undefined
531
+ const mskHash = (mask && mask.bytes) ? createHash('sha256').update(mask.bytes).digest('hex') : undefined
532
+ const rfHash = (resolvedRefImage && resolvedRefImage.bytes)
533
+ ? createHash('sha256').update(resolvedRefImage.bytes).digest('hex')
534
+ : ((resolvedFaceRef && resolvedFaceRef.bytes) ? createHash('sha256').update(resolvedFaceRef.bytes).digest('hex') : undefined)
535
+
503
536
  const requestedCriteria = {
537
+ provider,
538
+ model: effectiveModel,
539
+ cacheHash: h,
504
540
  format: args.output_format || cfg.defaultFormat || 'png',
505
541
  aspectRatio: args.aspect_ratio || cfg.defaultAspectRatio,
506
542
  size: args.aspect_ratio ? 'custom' : (args.image_size || cfg.defaultSize),
543
+ quality: args.quality,
544
+ style: args.style,
545
+ strength: args.strength,
546
+ sourceHash: srcHash,
547
+ maskHash: mskHash,
548
+ refHash: rfHash,
507
549
  }
508
550
  if (cfg.cacheBySeed) {
509
551
  const bySeed = await findCached(historyEntries, seedVal, promptVal, requestedCriteria)
@@ -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,39 +55,50 @@ 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)
59
- const res = await removeBackgroundFal(
60
- { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
61
- { imageBytes: source.bytes, mediaType: source.mediaType, model: args.model, signal: exec?.signal }
62
- )
63
- const stem = `${slugify(args.output_name || 'nobg')}-${Date.now().toString(36)}`
64
- const name = `${stem}.png`
65
- const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
66
- const sessionCwd = exec?.agent?.session?.header?.cwd
67
- const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
68
- await mkdir(outDir, { recursive: true })
69
- const filePath = path.join(outDir, name)
70
- await writeFile(filePath, res.bytes)
71
- recordSpend(0.02, { ctx, meta: { tool: 'remove_background', model: args.model || 'fal-ai/birefnet' } })
72
- const summary = buildDualOutputMarkdown({
73
- action: 'background removed',
74
- filePath,
75
- width: res.width || attachment.width,
76
- height: res.height || attachment.height,
77
- mediaType: 'image/png',
78
- provider: 'fal',
79
- model: args.model || 'fal-ai/birefnet',
80
- attachmentId: attachment.attachmentId,
81
- })
82
- return toLosslessJson({
83
- summary,
84
- path: filePath,
85
- url: localUrl,
86
- width: res.width || attachment.width,
87
- height: res.height || attachment.height,
88
- format: 'png',
89
- attachment,
90
- })
58
+ const resvNobg = reserveSpend(0.02, cfg.dailyBudgetUsd)
59
+ let committed = false
60
+ try {
61
+ const res = await removeBackgroundFal(
62
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
63
+ { imageBytes: source.bytes, mediaType: source.mediaType, model: args.model, signal: exec?.signal }
64
+ )
65
+ if (!res || !res.bytes) {
66
+ throw new Error('remove_background provider returned empty or invalid response')
67
+ }
68
+ const stem = `${slugify(args.output_name || 'nobg')}-${Date.now().toString(36)}`
69
+ const name = `${stem}.png`
70
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
71
+ const sessionCwd = exec?.agent?.session?.header?.cwd
72
+ const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
73
+ await mkdir(outDir, { recursive: true })
74
+ const filePath = path.join(outDir, name)
75
+ await writeFile(filePath, res.bytes)
76
+ resvNobg.commit(0.02, { ctx, meta: { tool: 'remove_background', model: args.model || 'fal-ai/birefnet' } })
77
+ committed = true
78
+ const summary = buildDualOutputMarkdown({
79
+ action: 'background removed',
80
+ filePath,
81
+ width: res.width || attachment.width,
82
+ height: res.height || attachment.height,
83
+ mediaType: 'image/png',
84
+ provider: 'fal',
85
+ model: args.model || 'fal-ai/birefnet',
86
+ attachmentId: attachment.attachmentId,
87
+ })
88
+ return toLosslessJson({
89
+ summary,
90
+ path: filePath,
91
+ url: localUrl,
92
+ width: res.width || attachment.width,
93
+ height: res.height || attachment.height,
94
+ format: 'png',
95
+ attachment,
96
+ })
97
+ } finally {
98
+ if (!committed) {
99
+ resvNobg.release()
100
+ }
101
+ }
91
102
  } catch (err) {
92
103
  throw new Error(sanitizeErrorAndLogs(err.message || err))
93
104
  }
@@ -126,39 +137,50 @@ export function registerProcessingTools(ctx, deps) {
126
137
  const source = await resolveSource(ctx, exec, args.image)
127
138
  if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
128
139
  const cfg = live()
129
- assertBudgetAvailable(0.025, cfg.dailyBudgetUsd)
130
- const res = await upscaleImageFal(
131
- { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
132
- { imageBytes: source.bytes, mediaType: source.mediaType, scale: args.scale, prompt: args.prompt, creativity: args.creativity, signal: exec?.signal }
133
- )
134
- const stem = `${slugify(args.output_name || 'upscaled')}-${Date.now().toString(36)}`
135
- const name = `${stem}.png`
136
- const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
137
- const sessionCwd = exec?.agent?.session?.header?.cwd
138
- const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
139
- await mkdir(outDir, { recursive: true })
140
- const filePath = path.join(outDir, name)
141
- await writeFile(filePath, res.bytes)
142
- recordSpend(0.025, { ctx, meta: { tool: 'upscale_image', scale: args.scale } })
143
- const summary = buildDualOutputMarkdown({
144
- action: `upscaled (${args.scale || 2}x)`,
145
- filePath,
146
- width: res.width || attachment.width,
147
- height: res.height || attachment.height,
148
- mediaType: 'image/png',
149
- provider: 'fal',
150
- model: 'fal-ai/clarity-upscaler',
151
- attachmentId: attachment.attachmentId,
152
- })
153
- return toLosslessJson({
154
- summary,
155
- path: filePath,
156
- url: localUrl,
157
- scale: args.scale || 2,
158
- width: res.width || attachment.width,
159
- height: res.height || attachment.height,
160
- attachment,
161
- })
140
+ const resvUpscale = reserveSpend(0.025, cfg.dailyBudgetUsd)
141
+ let committed = false
142
+ try {
143
+ const res = await upscaleImageFal(
144
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
145
+ { imageBytes: source.bytes, mediaType: source.mediaType, scale: args.scale, prompt: args.prompt, creativity: args.creativity, signal: exec?.signal }
146
+ )
147
+ if (!res || !res.bytes) {
148
+ throw new Error('upscale_image provider returned empty or invalid response')
149
+ }
150
+ const stem = `${slugify(args.output_name || 'upscaled')}-${Date.now().toString(36)}`
151
+ const name = `${stem}.png`
152
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
153
+ const sessionCwd = exec?.agent?.session?.header?.cwd
154
+ const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
155
+ await mkdir(outDir, { recursive: true })
156
+ const filePath = path.join(outDir, name)
157
+ await writeFile(filePath, res.bytes)
158
+ resvUpscale.commit(0.025, { ctx, meta: { tool: 'upscale_image', scale: args.scale } })
159
+ committed = true
160
+ const summary = buildDualOutputMarkdown({
161
+ action: `upscaled (${args.scale || 2}x)`,
162
+ filePath,
163
+ width: res.width || attachment.width,
164
+ height: res.height || attachment.height,
165
+ mediaType: 'image/png',
166
+ provider: 'fal',
167
+ model: 'fal-ai/clarity-upscaler',
168
+ attachmentId: attachment.attachmentId,
169
+ })
170
+ return toLosslessJson({
171
+ summary,
172
+ path: filePath,
173
+ url: localUrl,
174
+ scale: args.scale || 2,
175
+ width: res.width || attachment.width,
176
+ height: res.height || attachment.height,
177
+ attachment,
178
+ })
179
+ } finally {
180
+ if (!committed) {
181
+ resvUpscale.release()
182
+ }
183
+ }
162
184
  } catch (err) {
163
185
  throw new Error(sanitizeErrorAndLogs(err.message || err))
164
186
  }
@@ -254,50 +276,61 @@ export function registerProcessingTools(ctx, deps) {
254
276
  const cfg = live()
255
277
  const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
256
278
  trackAndAssertLoopGuard(sessionId, { limit: cfg.loopGuardLimit, prompt: args.prompt || '', exec })
257
- assertBudgetAvailable(0.05, cfg.dailyBudgetUsd)
258
279
  const refs = Array.isArray(args.images) ? args.images : [args.images].filter(Boolean)
259
- if (refs.length < 2) {
260
- throw new Error('At least two images are required for blending')
261
- }
262
- const sources = []
263
- for (const r of refs) {
264
- const s = await resolveSource(ctx, exec, r)
265
- if (s && s.bytes) sources.push(s)
266
- }
267
- if (sources.length < 2) {
268
- throw new Error('At least two valid images are required to blend')
269
- }
270
- const res = await blendImagesFal(
271
- { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
272
- { images: sources, weights: args.weights, prompt: args.prompt, signal: exec?.signal }
273
- )
274
- const stem = `${slugify(args.output_name || 'blended')}-${Date.now().toString(36)}`
275
- const name = `${stem}.png`
276
- const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
277
- const sessionCwd = exec?.agent?.session?.header?.cwd
278
- const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
279
- await mkdir(outDir, { recursive: true })
280
- const filePath = path.join(outDir, name)
281
- await writeFile(filePath, res.bytes)
282
- recordSpend(0.05, { ctx, meta: { tool: 'blend_images' } })
283
- const summary = buildDualOutputMarkdown({
284
- action: 'blended composition',
285
- filePath,
286
- width: res.width || attachment.width,
287
- height: res.height || attachment.height,
288
- mediaType: 'image/png',
289
- provider: 'fal',
290
- model: 'fal-ai/image-blend',
291
- attachmentId: attachment.attachmentId,
292
- })
293
- return toLosslessJson({
294
- summary,
295
- path: filePath,
296
- url: localUrl,
297
- width: res.width || attachment.width,
298
- height: res.height || attachment.height,
299
- attachment,
300
- })
280
+ if (refs.length < 2) {
281
+ throw new Error('At least two images are required for blending')
282
+ }
283
+ const sources = []
284
+ for (const r of refs) {
285
+ const s = await resolveSource(ctx, exec, r)
286
+ if (s && s.bytes) sources.push(s)
287
+ }
288
+ if (sources.length < 2) {
289
+ throw new Error('At least two valid images are required to blend')
290
+ }
291
+ const resvBlend = reserveSpend(0.05, cfg.dailyBudgetUsd)
292
+ let committed = false
293
+ try {
294
+ const res = await blendImagesFal(
295
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
296
+ { images: sources, weights: args.weights, prompt: args.prompt, signal: exec?.signal }
297
+ )
298
+ if (!res || !res.bytes) {
299
+ throw new Error('blend_images provider returned empty or invalid response')
300
+ }
301
+ const stem = `${slugify(args.output_name || 'blended')}-${Date.now().toString(36)}`
302
+ const name = `${stem}.png`
303
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
304
+ const sessionCwd = exec?.agent?.session?.header?.cwd
305
+ const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
306
+ await mkdir(outDir, { recursive: true })
307
+ const filePath = path.join(outDir, name)
308
+ await writeFile(filePath, res.bytes)
309
+ resvBlend.commit(0.05, { ctx, meta: { tool: 'blend_images' } })
310
+ committed = true
311
+ const summary = buildDualOutputMarkdown({
312
+ action: 'blended composition',
313
+ filePath,
314
+ width: res.width || attachment.width,
315
+ height: res.height || attachment.height,
316
+ mediaType: 'image/png',
317
+ provider: 'fal',
318
+ model: 'fal-ai/image-blend',
319
+ attachmentId: attachment.attachmentId,
320
+ })
321
+ return toLosslessJson({
322
+ summary,
323
+ path: filePath,
324
+ url: localUrl,
325
+ width: res.width || attachment.width,
326
+ height: res.height || attachment.height,
327
+ attachment,
328
+ })
329
+ } finally {
330
+ if (!committed) {
331
+ resvBlend.release()
332
+ }
333
+ }
301
334
  } catch (err) {
302
335
  throw new Error(sanitizeErrorAndLogs(err.message || err))
303
336
  }
@@ -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,8 +145,9 @@ export function registerStyleMatrixTools(ctx, deps) {
145
145
  size: cfg.defaultSize || '1024x1024',
146
146
  count: cellCount,
147
147
  })
148
- assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
149
-
148
+ const batchReservation = reserveSpend(estimatedCost, cfg.dailyBudgetUsd)
149
+ let committed = false
150
+ try {
150
151
  const chain = resolveFallbackChain(provider, cfg.fallbackProviders, PROVIDER_KEYS)
151
152
  const quadrantIds = ['A', 'B', 'C', 'D']
152
153
  const matrixId = `matrix-${Date.now().toString(36)}`
@@ -187,7 +188,7 @@ export function registerStyleMatrixTools(ctx, deps) {
187
188
  })
188
189
 
189
190
  const cellCost = Number(gen.cost) || +(estimatedCost / cellCount).toFixed(4)
190
- recordSpend(cellCost, {
191
+ batchReservation.commit(cellCost, {
191
192
  ctx,
192
193
  meta: { provider, model: cfg.model || cfg.customModel, prompt: cellPrompt, seed: cellSeed },
193
194
  })
@@ -236,6 +237,7 @@ export function registerStyleMatrixTools(ctx, deps) {
236
237
  cells,
237
238
  })
238
239
 
240
+ committed = true
239
241
  return toLosslessJson({
240
242
  matrix_id: matrixId,
241
243
  base_prompt: basePrompt,
@@ -244,6 +246,11 @@ export function registerStyleMatrixTools(ctx, deps) {
244
246
  cells,
245
247
  summary,
246
248
  })
249
+ } finally {
250
+ if (!committed) {
251
+ batchReservation.release()
252
+ }
253
+ }
247
254
  },
248
255
  }),
249
256
  )
@@ -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,8 +153,9 @@ export function registerThemePairTools(ctx, deps) {
153
153
  size: cfg.defaultSize || '1024x1024',
154
154
  count: 2,
155
155
  })
156
- assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
157
-
156
+ const batchReservation = reserveSpend(estimatedCost, cfg.dailyBudgetUsd)
157
+ let committed = false
158
+ try {
158
159
  const chain = resolveFallbackChain(primaryProvider, cfg.fallbackProviders, PROVIDER_KEYS)
159
160
  const baseSeed = typeof args.seed === 'number' ? args.seed : Math.floor(Math.random() * 1000000)
160
161
 
@@ -202,7 +203,7 @@ export function registerThemePairTools(ctx, deps) {
202
203
  height: lightGen.height || aspectPixels[1],
203
204
  })
204
205
  const halfCost = +(estimatedCost / 2).toFixed(4)
205
- recordSpend(Number(lightGen.cost) || halfCost, {
206
+ batchReservation.commit(Number(lightGen.cost) || halfCost, {
206
207
  ctx,
207
208
  meta: { provider: primaryProvider, model: cfg.model || cfg.customModel, prompt: lightPrompt },
208
209
  })
@@ -245,7 +246,7 @@ export function registerThemePairTools(ctx, deps) {
245
246
  width: darkGen.width || aspectPixels[0],
246
247
  height: darkGen.height || aspectPixels[1],
247
248
  })
248
- recordSpend(Number(darkGen.cost) || halfCost, {
249
+ batchReservation.commit(Number(darkGen.cost) || halfCost, {
249
250
  ctx,
250
251
  meta: { provider: primaryProvider, model: cfg.model || cfg.customModel, prompt: darkPrompt },
251
252
  })
@@ -277,6 +278,7 @@ export function registerThemePairTools(ctx, deps) {
277
278
  + `- Dark: ${darkFilename} (seed: ${baseSeed})\n\n`
278
279
  + `\`\`\`html\n${html}\n\`\`\``
279
280
 
281
+ committed = true
280
282
  return toLosslessJson({
281
283
  summary,
282
284
  prompt,
@@ -302,6 +304,11 @@ export function registerThemePairTools(ctx, deps) {
302
304
  css_snippet: css,
303
305
  attachment: lightAttachment,
304
306
  })
307
+ } finally {
308
+ if (!committed) {
309
+ batchReservation.release()
310
+ }
311
+ }
305
312
  },
306
313
  }),
307
314
  )
@@ -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,16 @@ 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
+ let committed = false
177
+ try {
178
+ gen = await executeWithFallback(providers, chain, seedVal, synthesizedPrompt, {
179
+ logger: ctx.logger,
180
+ })
179
181
 
180
182
  let finalBytes = gen.bytes
181
183
  let mediaType = gen.mediaType || 'image/png'
@@ -211,10 +213,11 @@ export function registerUiAssetTools(ctx, deps) {
211
213
  })
212
214
 
213
215
  const effectiveCost = Number(gen.cost) || estimatedCost
214
- recordSpend(effectiveCost, {
216
+ reservation.commit(effectiveCost, {
215
217
  ctx,
216
218
  meta: { provider, model: cfg.model || cfg.customModel, prompt: synthesizedPrompt, seed: seedVal },
217
219
  })
220
+ committed = true
218
221
 
219
222
  const sessionCwd = exec?.agent?.session?.header?.cwd
220
223
  const targetDir = cfg.outputDir || 'generated/images'
@@ -269,6 +272,11 @@ export function registerUiAssetTools(ctx, deps) {
269
272
  attachment,
270
273
  _fallback: gen._fallback,
271
274
  })
275
+ } finally {
276
+ if (!committed) {
277
+ reservation.release()
278
+ }
279
+ }
272
280
  },
273
281
  }),
274
282
  )
@@ -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,17 @@ export function registerVisionOcrTools(ctx, deps) {
252
252
  size: `${imgWidth}x${imgHeight}`,
253
253
  count: 1,
254
254
  })
255
- assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
256
-
257
- const gen = await executeWithFallback(
258
- providers,
259
- fallbackChain,
260
- seed,
261
- inpaintPrompt,
262
- { logger: ctx.logger },
263
- )
255
+ const reservation = reserveSpend(estimatedCost, cfg.dailyBudgetUsd)
256
+ let gen
257
+ let committed = false
258
+ try {
259
+ gen = await executeWithFallback(
260
+ providers,
261
+ fallbackChain,
262
+ seed,
263
+ inpaintPrompt,
264
+ { logger: ctx.logger },
265
+ )
264
266
 
265
267
  const inpaintedBytes = gen.bytes
266
268
  if (!inpaintedBytes) {
@@ -323,10 +325,11 @@ export function registerVisionOcrTools(ctx, deps) {
323
325
  })
324
326
 
325
327
  const effectiveCost = Number(gen.cost) || estimatedCost
326
- recordSpend(effectiveCost, {
328
+ reservation.commit(effectiveCost, {
327
329
  ctx,
328
330
  meta: { provider: usedProvider, model: cfg.model || cfg.customModel, prompt: inpaintPrompt, seed },
329
331
  })
332
+ committed = true
330
333
 
331
334
  const dualOutput = buildDualOutputMarkdown({
332
335
  action: 'generated',
@@ -359,6 +362,11 @@ export function registerVisionOcrTools(ctx, deps) {
359
362
  }
360
363
 
361
364
  return toLosslessJson(response)
365
+ } finally {
366
+ if (!committed) {
367
+ reservation.release()
368
+ }
369
+ }
362
370
  },
363
371
  })
364
372
  )