@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 CHANGED
@@ -2698,7 +2698,7 @@ window.__ModuleLoader__.load({
2698
2698
 
2699
2699
  'f.geminiModel': 'Gemini Model ID',
2700
2700
  'f.geminiModelHint': 'Google Gemini image generation model identifier.',
2701
- 'p.geminiModel': 'gemini-2.0-flash-exp-image-generation',
2701
+ 'p.geminiModel': 'gemini-3.1-flash-image',
2702
2702
  'f.geminiKeyEnv': 'Gemini Key Reference',
2703
2703
  'f.geminiKeyEnvHint': 'Credential reference holding Google Gemini API key.',
2704
2704
  'p.geminiKeyEnv': 'GEMINI_API_KEY',
@@ -2977,7 +2977,7 @@ window.__ModuleLoader__.load({
2977
2977
 
2978
2978
  'f.geminiModel': 'Gemini 模型 ID',
2979
2979
  'f.geminiModelHint': 'Google Gemini 图像生成模型标识符。',
2980
- 'p.geminiModel': 'gemini-2.0-flash-exp-image-generation',
2980
+ 'p.geminiModel': 'gemini-3.1-flash-image',
2981
2981
  'f.geminiKeyEnv': 'Gemini 凭证名称',
2982
2982
  'f.geminiKeyEnvHint': '存储 Google Gemini 密钥的凭证名称。',
2983
2983
  'p.geminiKeyEnv': 'GEMINI_API_KEY',
@@ -177,7 +177,7 @@ export const Config = z.object({
177
177
  geminiModel: z
178
178
  .string()
179
179
  .description('Google Gemini image generation model identifier.')
180
- .default('gemini-2.0-flash-exp-image-generation')
180
+ .default('gemini-3.1-flash-image')
181
181
  .volatile(),
182
182
  geminiKeyEnv: z
183
183
  .string()
package/lib/cost-meter.js CHANGED
@@ -1,9 +1,10 @@
1
1
  // lib/cost-meter.js
2
- // Integration with dsh-cost-meter, rate card, and daily budget enforcer (#167)
2
+ // Integration with dsh-cost-meter, rate card, and daily budget enforcer (#167, #353, #355)
3
3
 
4
4
  import { homedir } from 'node:os'
5
5
  import { join } from 'node:path'
6
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
6
+ import { readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'node:fs'
7
+ import { randomBytes } from 'node:crypto'
7
8
  import { enforceSecurePermissions } from './security.js'
8
9
 
9
10
  /**
@@ -53,6 +54,27 @@ export const RATE_CARD = {
53
54
  },
54
55
  }
55
56
 
57
+ /** Active in-flight budget reservations for serialized concurrency (#355) */
58
+ const activeReservations = new Map()
59
+
60
+ /**
61
+ * Returns total currently active in-flight spend reservations in USD.
62
+ */
63
+ export function getTotalActiveReservations() {
64
+ let total = 0
65
+ for (const amount of activeReservations.values()) {
66
+ total += Number(amount) || 0
67
+ }
68
+ return +total.toFixed(4)
69
+ }
70
+
71
+ /**
72
+ * Clears all active in-flight reservations (for testing / session resets).
73
+ */
74
+ export function clearSpendReservations() {
75
+ activeReservations.clear()
76
+ }
77
+
56
78
  /**
57
79
  * Calculates estimated USD cost for a generation request.
58
80
  */
@@ -94,6 +116,7 @@ export function loadDailySpend() {
94
116
 
95
117
  /**
96
118
  * Records an image generation expense.
119
+ * Uses atomic sibling write + rename to guarantee crash-safe persistence (#355).
97
120
  */
98
121
  export function recordSpend(usdAmount, { ctx, meta = {} } = {}) {
99
122
  const amount = Number(usdAmount) || 0
@@ -106,8 +129,11 @@ export function recordSpend(usdAmount, { ctx, meta = {} } = {}) {
106
129
  current.lastUpdated = new Date().toISOString()
107
130
 
108
131
  try {
109
- mkdirSync(join(filePath, '..'), { recursive: true })
110
- writeFileSync(filePath, JSON.stringify(current, null, 2), 'utf8')
132
+ const dir = join(filePath, '..')
133
+ mkdirSync(dir, { recursive: true })
134
+ const tmpFile = `${filePath}.${randomBytes(6).toString('hex')}.tmp`
135
+ writeFileSync(tmpFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 })
136
+ renameSync(tmpFile, filePath)
111
137
  enforceSecurePermissions(filePath)
112
138
  } catch (err) {
113
139
  // Non-fatal write failure
@@ -130,7 +156,7 @@ export function recordSpend(usdAmount, { ctx, meta = {} } = {}) {
130
156
  }
131
157
 
132
158
  /**
133
- * Asserts that the generation does not exceed daily budget.
159
+ * Asserts that the generation does not exceed daily budget, accounting for active in-flight reservations (#353, #355).
134
160
  * @throws {Error} If daily budget is exceeded.
135
161
  */
136
162
  export function assertBudgetAvailable(estimatedCost, dailyBudgetUsd) {
@@ -139,11 +165,52 @@ export function assertBudgetAvailable(estimatedCost, dailyBudgetUsd) {
139
165
 
140
166
  const cost = Number(estimatedCost) || 0
141
167
  const current = loadDailySpend()
142
- const projected = +(current.totalSpendUsd + cost).toFixed(4)
168
+ const reserved = getTotalActiveReservations()
169
+ const projected = +(current.totalSpendUsd + reserved + cost).toFixed(4)
143
170
  if (projected > budget) {
144
171
  throw new Error(
145
- `Daily image generation budget exceeded: current spend $${current.totalSpendUsd.toFixed(2)} + estimated $${cost.toFixed(2)} exceeds limit $${budget.toFixed(2)} (Settings → Image generation → dailyBudgetUsd)`
172
+ `Daily image generation budget exceeded: current spend $${current.totalSpendUsd.toFixed(2)} + reserved $${reserved.toFixed(2)} + estimated $${cost.toFixed(2)} exceeds limit $${budget.toFixed(2)} (Settings → Image generation → dailyBudgetUsd)`
146
173
  )
147
174
  }
148
175
  return true
149
176
  }
177
+
178
+ /**
179
+ * Reserves estimated budget for an in-flight generation attempt (#353, #355).
180
+ * Returns a settlement handle with .commit(actualCost, opts) and .release().
181
+ *
182
+ * @param {number} estimatedCost
183
+ * @param {number} dailyBudgetUsd
184
+ * @returns {{ id: string, cost: number, commit: Function, release: Function }}
185
+ */
186
+ export function reserveSpend(estimatedCost, dailyBudgetUsd) {
187
+ const budget = Number(dailyBudgetUsd) || 0
188
+ const cost = Number(estimatedCost) || 0
189
+
190
+ if (budget > 0 && cost > 0) {
191
+ assertBudgetAvailable(cost, budget)
192
+ }
193
+
194
+ const id = `res_${Date.now()}_${randomBytes(4).toString('hex')}`
195
+ if (budget > 0 && cost > 0) {
196
+ activeReservations.set(id, cost)
197
+ }
198
+
199
+ let settled = false
200
+ return {
201
+ id,
202
+ cost,
203
+ commit(actualCost, opts) {
204
+ if (settled) return
205
+ settled = true
206
+ activeReservations.delete(id)
207
+ const effective = actualCost !== undefined ? Number(actualCost) : cost
208
+ return recordSpend(effective, opts)
209
+ },
210
+ release() {
211
+ if (settled) return
212
+ settled = true
213
+ activeReservations.delete(id)
214
+ },
215
+ }
216
+ }
@@ -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
- recordSpend(gen.cost || (estimatedCost / totalCount), {
267
- ctx,
268
- meta: { provider, model: cfg.model || cfg.customModel, prompt: promptArg, seed: gen.seed },
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-2.0-flash-exp-image-generation'
19
- // #364: Transparently migrate deprecated Imagen 3/4 IDs to Gemini multimodal model
20
- if (model.startsWith('imagen-') || model === 'imagen-3.0-generate-002') {
21
- model = 'gemini-2.0-flash-exp-image-generation'
18
+ let model = cfg.geminiModel || 'gemini-3.1-flash-image'
19
+ // #364: Transparently migrate deprecated Imagen 3/4 IDs and preview 2.0 to current Gemini multimodal model
20
+ if (model.startsWith('imagen-') || model.startsWith('gemini-2.0-flash-exp')) {
21
+ model = 'gemini-3.1-flash-image'
22
22
  }
23
23
 
24
24
  const timeoutSignal = typeof AbortSignal !== 'undefined' && AbortSignal.timeout
@@ -53,14 +53,22 @@ export function createGeminiGenerator(deps, job) {
53
53
  }
54
54
  parts.push({ text: promptArg })
55
55
 
56
- // Build unified imageConfig without quality/style overwrite collisions (#364)
56
+ // #364: Google Gemini ImageConfig only accepts aspectRatio and imageSize.
57
+ // Unofficial/deprecated fields like imageQuality or imageStyle must not be passed.
57
58
  const imageConfig = {}
58
- if (aspectRatio) imageConfig.aspectRatio = aspectRatio
59
- if (quality && quality !== 'auto') imageConfig.imageQuality = quality
60
- if (style && style !== 'none') imageConfig.imageStyle = style
59
+ if (aspectRatio) {
60
+ const ratioMap = {
61
+ square: '1:1',
62
+ portrait_4_3: '3:4',
63
+ landscape_4_3: '4:3',
64
+ portrait_16_9: '9:16',
65
+ landscape_16_9: '16:9',
66
+ }
67
+ imageConfig.aspectRatio = ratioMap[aspectRatio] || aspectRatio
68
+ }
61
69
 
62
70
  const generationConfig = {
63
- responseModalities: ['IMAGE'],
71
+ responseModalities: ['TEXT', 'IMAGE'],
64
72
  ...(Object.keys(imageConfig).length > 0 ? { imageConfig } : {}),
65
73
  }
66
74
 
@@ -43,6 +43,13 @@ export function createLocalGenerator(deps, job) {
43
43
  }
44
44
 
45
45
  if (kind === 'a1111') {
46
+ // #375: Local A1111 txt2img/img2img lacks identity adapters
47
+ if (job.faceReference) {
48
+ throw new Error('Local A1111 does not support facial identity references (FaceID) — use ComfyUI (IP-Adapter/InstantID) or FAL')
49
+ }
50
+ if (job.referenceImage) {
51
+ throw new Error('Local A1111 does not support visual style reference anchors — use ComfyUI (IP-Adapter) or FAL')
52
+ }
46
53
  if (typeof onProgress === 'function') {
47
54
  onProgress({ stage: 'Generating...', progress: undefined })
48
55
  }
@@ -88,6 +95,17 @@ export function createLocalGenerator(deps, job) {
88
95
  }
89
96
 
90
97
  // ComfyUI: submit via /prompt, poll /history/{prompt_id} until completed.
98
+ if (job.faceReference || job.referenceImage) {
99
+ const wf = cfg.comfyWorkflowJson || ''
100
+ const hasFace = wf.includes('face_image') || wf.includes('ApplyIPAdapter') || wf.includes('InstantID') || wf.includes('FaceID')
101
+ const hasRef = wf.includes('reference_image') || wf.includes('ApplyIPAdapter') || wf.includes('StyleModelApply')
102
+ if (job.faceReference && !hasFace) {
103
+ throw new Error('ComfyUI requires a custom workflow with FaceID or IP-Adapter nodes to use facial identity anchors.')
104
+ }
105
+ if (job.referenceImage && !hasRef) {
106
+ throw new Error('ComfyUI requires a custom workflow with reference or IP-Adapter nodes to use style reference anchors.')
107
+ }
108
+ }
91
109
  const promptId = `dsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
92
110
  let workflowGraph
93
111
  if (cfg.comfyWorkflowJson) {
package/lib/providers.js CHANGED
@@ -779,7 +779,8 @@ export async function testProviderConnection(deps, providerId) {
779
779
  if (p === 'gemini') {
780
780
  const key = await resolveKey(cfg?.geminiKeyEnv || 'GEMINI_API_KEY')
781
781
  if (!key) return { ok: false, state: 'not_configured', latencyMs: 0, message: 'Google Gemini API key not configured' }
782
- const model = cfg?.geminiModel || 'gemini-2.0-flash-exp-image-generation'
782
+ let model = cfg?.geminiModel || 'gemini-3.1-flash-image'
783
+ if (model.startsWith('imagen-') || model.startsWith('gemini-2.0-flash-exp')) model = 'gemini-3.1-flash-image'
783
784
  let res
784
785
  try {
785
786
  res = await fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${model}?key=${encodeURIComponent(key)}`, {
@@ -1,5 +1,5 @@
1
1
  // lib/quality-gate.js
2
- // Automatic Quality Gate and Silent Re-roll Engine (#166)
2
+ // Automatic Quality Gate and Silent Re-roll Engine (#166, #353)
3
3
 
4
4
  import { estimateSharpnessAndVariance } from './providers.js'
5
5
 
@@ -97,7 +97,24 @@ export async function executeWithQualityGate(generateFn, {
97
97
  const attempts = enabled ? Math.max(1, maxRerolls + 1) : 1
98
98
 
99
99
  for (let attempt = 0; attempt < attempts; attempt++) {
100
- lastGenerated = await generateFn(currentSeed)
100
+ try {
101
+ lastGenerated = await generateFn(currentSeed)
102
+ } catch (err) {
103
+ // #353: If a subsequent re-roll exceeds budget, abort further retries and preserve the initial output
104
+ if (lastGenerated && (err.message?.includes('budget exceeded') || err.message?.includes('Daily image generation budget'))) {
105
+ logger(`Quality gate re-roll halted by budget limit: ${err.message}`)
106
+ return {
107
+ generated: lastGenerated,
108
+ qualityReport: {
109
+ ...lastReport,
110
+ rerolls,
111
+ exhausted: true,
112
+ budgetExceeded: true,
113
+ },
114
+ }
115
+ }
116
+ throw err
117
+ }
101
118
 
102
119
  if (!enabled) {
103
120
  return {
@@ -15,7 +15,7 @@ import {
15
15
 
16
16
  } from '../providers.js'
17
17
  import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
18
- import { assertBudgetAvailable, calculateGenerationCost, recordSpend } from '../cost-meter.js'
18
+ import { assertBudgetAvailable, calculateGenerationCost, reserveSpend, recordSpend } from '../cost-meter.js'
19
19
  import { renderToolOutput } from '../attachment-helper.js'
20
20
  import {
21
21
  CHARACTER_SHEET_LAYOUTS,
@@ -152,15 +152,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
  })
@@ -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 {
@@ -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,
@@ -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
- assertBudgetAvailable(0.05, cfg.dailyBudgetUsd)
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
- recordSpend(0.05, { ctx, meta: { tool: 'blend_images' } })
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
- 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,
@@ -252,15 +252,20 @@ 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
+ 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
- recordSpend(effectiveCost, {
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.30",
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",