@goodandready/dsh-image-gen 0.11.39 → 0.11.41
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/batch-queue.js +283 -0
- package/lib/capabilities.js +433 -0
- package/lib/client.js +5 -0
- package/lib/config-schema.js +5 -0
- package/lib/index.js +117 -0
- package/lib/inpaint-staging.js +236 -0
- package/lib/project-profiles.js +234 -0
- package/lib/register-tools.js +16 -0
- package/lib/tools/batch-queue.js +134 -0
- package/lib/tools/capabilities.js +139 -0
- package/lib/tools/inpaint-staging.js +116 -0
- package/lib/tools/project-profiles.js +139 -0
- package/package.json +1 -1
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resilient Batch Queue & Job Reconciliation (#437)
|
|
7
|
+
* Provides persistent queuing for image generation & processing series,
|
|
8
|
+
* with concurrency throttling, budget pre-checks, cancellation, and restart recovery.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export function getQueueStoragePath(customPath) {
|
|
12
|
+
if (customPath) return customPath
|
|
13
|
+
if (process.env.DSH_BATCH_QUEUE_PATH) return process.env.DSH_BATCH_QUEUE_PATH
|
|
14
|
+
const home = os.homedir() || '/tmp'
|
|
15
|
+
return path.join(home, '.dsh', 'dsh-image-gen', 'batch-queue.json')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function loadBatchQueues(storagePath) {
|
|
19
|
+
const filePath = getQueueStoragePath(storagePath)
|
|
20
|
+
try {
|
|
21
|
+
if (!fs.existsSync(filePath)) return []
|
|
22
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
23
|
+
const parsed = JSON.parse(raw)
|
|
24
|
+
return Array.isArray(parsed) ? parsed : []
|
|
25
|
+
} catch (err) {
|
|
26
|
+
return []
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function saveBatchQueue(batch, storagePath) {
|
|
31
|
+
const filePath = getQueueStoragePath(storagePath)
|
|
32
|
+
const dir = path.dirname(filePath)
|
|
33
|
+
if (!fs.existsSync(dir)) {
|
|
34
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const existing = loadBatchQueues(storagePath)
|
|
38
|
+
const index = existing.findIndex((b) => b.batchId === batch.batchId)
|
|
39
|
+
if (index >= 0) {
|
|
40
|
+
existing[index] = batch
|
|
41
|
+
} else {
|
|
42
|
+
existing.push(batch)
|
|
43
|
+
}
|
|
44
|
+
fs.writeFileSync(filePath, JSON.stringify(existing, null, 2), 'utf8')
|
|
45
|
+
return batch
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getBatchQueue(batchId, storagePath) {
|
|
49
|
+
const queues = loadBatchQueues(storagePath)
|
|
50
|
+
return queues.find((b) => b.batchId === batchId) || null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Startup reconciliation (#437):
|
|
55
|
+
* Interrupted jobs (stuck in 'running' during crash/restart) are marked 'interrupted'
|
|
56
|
+
* and NEVER blindly retried without explicit user confirmation.
|
|
57
|
+
*/
|
|
58
|
+
export function reconcileQueueOnStartup(storagePath) {
|
|
59
|
+
const queues = loadBatchQueues(storagePath)
|
|
60
|
+
let updatedCount = 0
|
|
61
|
+
|
|
62
|
+
for (const batch of queues) {
|
|
63
|
+
let batchModified = false
|
|
64
|
+
for (const item of batch.items || []) {
|
|
65
|
+
if (item.status === 'running') {
|
|
66
|
+
item.status = 'interrupted'
|
|
67
|
+
item.interruptedAt = new Date().toISOString()
|
|
68
|
+
item.interruptionNotice = 'Server was restarted while this job was active; not retried to prevent duplicate billing.'
|
|
69
|
+
batchModified = true
|
|
70
|
+
updatedCount++
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (batchModified) {
|
|
74
|
+
if (batch.status === 'running') {
|
|
75
|
+
batch.status = 'interrupted'
|
|
76
|
+
}
|
|
77
|
+
saveBatchQueue(batch, storagePath)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { reconciledJobs: updatedCount }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Enqueues a new batch of generation/processing jobs with budget pre-flight verification.
|
|
86
|
+
*/
|
|
87
|
+
export function enqueueBatch(params, storagePath) {
|
|
88
|
+
const {
|
|
89
|
+
name = 'Unnamed Batch',
|
|
90
|
+
items = [],
|
|
91
|
+
concurrency = 2,
|
|
92
|
+
maxBudget = Infinity,
|
|
93
|
+
} = params
|
|
94
|
+
|
|
95
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
96
|
+
throw new Error('Batch requires a non-empty array of items')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Pre-calculate total estimated cost
|
|
100
|
+
let totalEstimatedCost = 0
|
|
101
|
+
const preparedItems = items.map((item, idx) => {
|
|
102
|
+
const cost = typeof item.estimatedCost === 'number' ? item.estimatedCost : 0.02
|
|
103
|
+
totalEstimatedCost += cost
|
|
104
|
+
return {
|
|
105
|
+
id: item.id || `item_${idx + 1}`,
|
|
106
|
+
tool: item.tool || 'generate_image',
|
|
107
|
+
params: item.params || {},
|
|
108
|
+
estimatedCost: cost,
|
|
109
|
+
status: 'queued',
|
|
110
|
+
createdAt: new Date().toISOString(),
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
if (totalEstimatedCost > maxBudget) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Batch estimated cost ($${totalEstimatedCost.toFixed(3)}) exceeds allowed budget limit ($${maxBudget.toFixed(3)}). Pre-flight rejected.`
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const batchId = `batch_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
|
121
|
+
const batch = {
|
|
122
|
+
batchId,
|
|
123
|
+
name,
|
|
124
|
+
status: 'queued',
|
|
125
|
+
concurrency: Math.max(1, Math.min(10, concurrency)),
|
|
126
|
+
totalEstimatedCost,
|
|
127
|
+
createdAt: new Date().toISOString(),
|
|
128
|
+
updatedAt: new Date().toISOString(),
|
|
129
|
+
items: preparedItems,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
saveBatchQueue(batch, storagePath)
|
|
133
|
+
return batch
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Computes progress summary for a batch.
|
|
138
|
+
*/
|
|
139
|
+
export function getBatchProgress(batch) {
|
|
140
|
+
if (!batch || !Array.isArray(batch.items)) {
|
|
141
|
+
return { total: 0, completed: 0, failed: 0, queued: 0, running: 0, cancelled: 0, percent: 0 }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const items = batch.items
|
|
145
|
+
const total = items.length
|
|
146
|
+
const completed = items.filter((i) => i.status === 'succeeded').length
|
|
147
|
+
const failed = items.filter((i) => i.status === 'failed').length
|
|
148
|
+
const running = items.filter((i) => i.status === 'running').length
|
|
149
|
+
const queued = items.filter((i) => i.status === 'queued').length
|
|
150
|
+
const cancelled = items.filter((i) => i.status === 'cancelled').length
|
|
151
|
+
const interrupted = items.filter((i) => i.status === 'interrupted').length
|
|
152
|
+
|
|
153
|
+
const percent = total > 0 ? Math.round(((completed + failed) / total) * 100) : 0
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
total,
|
|
157
|
+
completed,
|
|
158
|
+
failed,
|
|
159
|
+
running,
|
|
160
|
+
queued,
|
|
161
|
+
cancelled,
|
|
162
|
+
interrupted,
|
|
163
|
+
percent,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Cancels an entire batch or specific item.
|
|
169
|
+
*/
|
|
170
|
+
export function cancelBatch(batchId, itemId = null, storagePath) {
|
|
171
|
+
const batch = getBatchQueue(batchId, storagePath)
|
|
172
|
+
if (!batch) {
|
|
173
|
+
return { ok: false, error: `Batch "${batchId}" not found` }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const timestamp = new Date().toISOString()
|
|
177
|
+
|
|
178
|
+
if (itemId) {
|
|
179
|
+
const item = batch.items.find((i) => i.id === itemId)
|
|
180
|
+
if (!item) return { ok: false, error: `Item "${itemId}" not found in batch` }
|
|
181
|
+
if (item.status === 'queued') {
|
|
182
|
+
item.status = 'cancelled'
|
|
183
|
+
item.cancelledAt = timestamp
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
batch.status = 'cancelled'
|
|
187
|
+
for (const it of batch.items) {
|
|
188
|
+
if (it.status === 'queued') {
|
|
189
|
+
it.status = 'cancelled'
|
|
190
|
+
it.cancelledAt = timestamp
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
batch.updatedAt = timestamp
|
|
196
|
+
saveBatchQueue(batch, storagePath)
|
|
197
|
+
return { ok: true, batchId, status: batch.status, progress: getBatchProgress(batch) }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Resets failed items in a batch back to 'queued'.
|
|
202
|
+
*/
|
|
203
|
+
export function retryFailedBatchItems(batchId, targetItemIds = null, storagePath) {
|
|
204
|
+
const batch = getBatchQueue(batchId, storagePath)
|
|
205
|
+
if (!batch) {
|
|
206
|
+
return { ok: false, error: `Batch "${batchId}" not found` }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let resetCount = 0
|
|
210
|
+
for (const it of batch.items) {
|
|
211
|
+
if (it.status === 'failed' || it.status === 'interrupted') {
|
|
212
|
+
if (!targetItemIds || targetItemIds.includes(it.id)) {
|
|
213
|
+
it.status = 'queued'
|
|
214
|
+
it.error = null
|
|
215
|
+
it.interruptedAt = null
|
|
216
|
+
resetCount++
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (resetCount > 0) {
|
|
222
|
+
batch.status = 'queued'
|
|
223
|
+
batch.updatedAt = new Date().toISOString()
|
|
224
|
+
saveBatchQueue(batch, storagePath)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return { ok: true, resetCount, progress: getBatchProgress(batch) }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Processes queued items with concurrency limits using the supplied executor.
|
|
232
|
+
*/
|
|
233
|
+
export async function processBatchItems(batchId, executorFn, storagePath) {
|
|
234
|
+
const batch = getBatchQueue(batchId, storagePath)
|
|
235
|
+
if (!batch) throw new Error(`Batch "${batchId}" not found`)
|
|
236
|
+
|
|
237
|
+
batch.status = 'running'
|
|
238
|
+
saveBatchQueue(batch, storagePath)
|
|
239
|
+
|
|
240
|
+
const concurrency = batch.concurrency || 2
|
|
241
|
+
let runningCount = 0
|
|
242
|
+
|
|
243
|
+
const queuedItems = batch.items.filter((i) => i.status === 'queued')
|
|
244
|
+
|
|
245
|
+
for (const item of queuedItems) {
|
|
246
|
+
// Check if batch was cancelled while processing
|
|
247
|
+
const currentBatch = getBatchQueue(batchId, storagePath)
|
|
248
|
+
if (currentBatch?.status === 'cancelled') {
|
|
249
|
+
break
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
item.status = 'running'
|
|
253
|
+
item.startedAt = new Date().toISOString()
|
|
254
|
+
saveBatchQueue(batch, storagePath)
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const res = await executorFn(item.tool, item.params)
|
|
258
|
+
item.status = 'succeeded'
|
|
259
|
+
item.result = res
|
|
260
|
+
item.completedAt = new Date().toISOString()
|
|
261
|
+
} catch (err) {
|
|
262
|
+
item.status = 'failed'
|
|
263
|
+
item.error = err.message
|
|
264
|
+
item.completedAt = new Date().toISOString()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
saveBatchQueue(batch, storagePath)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const finalProgress = getBatchProgress(batch)
|
|
271
|
+
if (finalProgress.queued === 0 && finalProgress.running === 0) {
|
|
272
|
+
batch.status = finalProgress.failed > 0 ? 'completed_with_errors' : 'completed'
|
|
273
|
+
}
|
|
274
|
+
batch.updatedAt = new Date().toISOString()
|
|
275
|
+
saveBatchQueue(batch, storagePath)
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
ok: true,
|
|
279
|
+
batchId,
|
|
280
|
+
status: batch.status,
|
|
281
|
+
progress: getBatchProgress(batch),
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability Registry and Model Selector (#434)
|
|
3
|
+
* Provides comprehensive capability mapping, strict pre-flight validation,
|
|
4
|
+
* cost estimation, and recommendation for task-specific models and providers.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const CAPABILITY_REGISTRY = [
|
|
8
|
+
// Fal.ai models
|
|
9
|
+
{
|
|
10
|
+
provider: 'fal',
|
|
11
|
+
modelId: 'fal-ai/flux/dev',
|
|
12
|
+
displayName: 'FLUX.1 [dev]',
|
|
13
|
+
capabilities: ['txt2img', 'img2img', 'inpaint', 'high_res', 'text_rendering'],
|
|
14
|
+
maxResolution: 2048,
|
|
15
|
+
supportedAspectRatios: ['1:1', '16:9', '9:16', '4:3', '3:4', '21:9'],
|
|
16
|
+
features: {
|
|
17
|
+
textRendering: true,
|
|
18
|
+
facePreservation: false,
|
|
19
|
+
maskInpainting: true,
|
|
20
|
+
referenceConditioning: false,
|
|
21
|
+
controlnet: false,
|
|
22
|
+
},
|
|
23
|
+
estimatedCostUSD: 0.025,
|
|
24
|
+
pricingSource: 'fal.ai/pricing (2026-09)',
|
|
25
|
+
verifiedDate: '2026-09-15',
|
|
26
|
+
tier: 'pro',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
provider: 'fal',
|
|
30
|
+
modelId: 'fal-ai/flux/schnell',
|
|
31
|
+
displayName: 'FLUX.1 [schnell]',
|
|
32
|
+
capabilities: ['txt2img', 'text_rendering'],
|
|
33
|
+
maxResolution: 1024,
|
|
34
|
+
supportedAspectRatios: ['1:1', '16:9', '9:16', '4:3', '3:4'],
|
|
35
|
+
features: {
|
|
36
|
+
textRendering: true,
|
|
37
|
+
facePreservation: false,
|
|
38
|
+
maskInpainting: false,
|
|
39
|
+
referenceConditioning: false,
|
|
40
|
+
controlnet: false,
|
|
41
|
+
},
|
|
42
|
+
estimatedCostUSD: 0.003,
|
|
43
|
+
pricingSource: 'fal.ai/pricing (2026-09)',
|
|
44
|
+
verifiedDate: '2026-09-15',
|
|
45
|
+
tier: 'economy',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
provider: 'fal',
|
|
49
|
+
modelId: 'fal-ai/flux-general/image-to-image',
|
|
50
|
+
displayName: 'FLUX.1 Image-to-Image',
|
|
51
|
+
capabilities: ['img2img', 'reference'],
|
|
52
|
+
maxResolution: 1536,
|
|
53
|
+
supportedAspectRatios: ['1:1', '16:9', '9:16', '4:3', '3:4'],
|
|
54
|
+
features: {
|
|
55
|
+
textRendering: true,
|
|
56
|
+
facePreservation: false,
|
|
57
|
+
maskInpainting: false,
|
|
58
|
+
referenceConditioning: true,
|
|
59
|
+
controlnet: false,
|
|
60
|
+
},
|
|
61
|
+
estimatedCostUSD: 0.03,
|
|
62
|
+
pricingSource: 'fal.ai/pricing (2026-09)',
|
|
63
|
+
verifiedDate: '2026-09-15',
|
|
64
|
+
tier: 'pro',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
provider: 'fal',
|
|
68
|
+
modelId: 'fal-ai/recraft-v3',
|
|
69
|
+
displayName: 'Recraft v3',
|
|
70
|
+
capabilities: ['txt2img', 'vector', 'text_rendering', 'high_res'],
|
|
71
|
+
maxResolution: 2048,
|
|
72
|
+
supportedAspectRatios: ['1:1', '16:9', '9:16', '4:3', '3:4'],
|
|
73
|
+
features: {
|
|
74
|
+
textRendering: true,
|
|
75
|
+
facePreservation: false,
|
|
76
|
+
maskInpainting: false,
|
|
77
|
+
referenceConditioning: false,
|
|
78
|
+
controlnet: false,
|
|
79
|
+
},
|
|
80
|
+
estimatedCostUSD: 0.04,
|
|
81
|
+
pricingSource: 'fal.ai/pricing (2026-09)',
|
|
82
|
+
verifiedDate: '2026-09-15',
|
|
83
|
+
tier: 'pro',
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
// Google Gemini Imagen models
|
|
87
|
+
{
|
|
88
|
+
provider: 'gemini',
|
|
89
|
+
modelId: 'imagen-3.0-generate-002',
|
|
90
|
+
displayName: 'Google Imagen 3.0',
|
|
91
|
+
capabilities: ['txt2img', 'text_rendering', 'high_res'],
|
|
92
|
+
maxResolution: 2048,
|
|
93
|
+
supportedAspectRatios: ['1:1', '16:9', '9:16', '4:3', '3:4'],
|
|
94
|
+
features: {
|
|
95
|
+
textRendering: true,
|
|
96
|
+
facePreservation: false,
|
|
97
|
+
maskInpainting: false,
|
|
98
|
+
referenceConditioning: false,
|
|
99
|
+
controlnet: false,
|
|
100
|
+
},
|
|
101
|
+
estimatedCostUSD: 0.03,
|
|
102
|
+
pricingSource: 'ai.google.dev (2026-09)',
|
|
103
|
+
verifiedDate: '2026-09-01',
|
|
104
|
+
tier: 'pro',
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
provider: 'gemini',
|
|
108
|
+
modelId: 'imagen-3.0-capability-001',
|
|
109
|
+
displayName: 'Google Imagen 3.0 Editing',
|
|
110
|
+
capabilities: ['txt2img', 'img2img', 'inpaint', 'outpaint'],
|
|
111
|
+
maxResolution: 1536,
|
|
112
|
+
supportedAspectRatios: ['1:1', '16:9', '9:16', '4:3', '3:4'],
|
|
113
|
+
features: {
|
|
114
|
+
textRendering: true,
|
|
115
|
+
facePreservation: false,
|
|
116
|
+
maskInpainting: true,
|
|
117
|
+
referenceConditioning: true,
|
|
118
|
+
controlnet: false,
|
|
119
|
+
},
|
|
120
|
+
estimatedCostUSD: 0.035,
|
|
121
|
+
pricingSource: 'ai.google.dev (2026-09)',
|
|
122
|
+
verifiedDate: '2026-09-01',
|
|
123
|
+
tier: 'pro',
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
// Replicate models
|
|
127
|
+
{
|
|
128
|
+
provider: 'replicate',
|
|
129
|
+
modelId: 'black-forest-labs/flux-1.1-pro',
|
|
130
|
+
displayName: 'Replicate FLUX 1.1 Pro',
|
|
131
|
+
capabilities: ['txt2img', 'text_rendering', 'high_res'],
|
|
132
|
+
maxResolution: 2048,
|
|
133
|
+
supportedAspectRatios: ['1:1', '16:9', '9:16', '4:3', '3:4', '21:9'],
|
|
134
|
+
features: {
|
|
135
|
+
textRendering: true,
|
|
136
|
+
facePreservation: false,
|
|
137
|
+
maskInpainting: false,
|
|
138
|
+
referenceConditioning: false,
|
|
139
|
+
controlnet: false,
|
|
140
|
+
},
|
|
141
|
+
estimatedCostUSD: 0.04,
|
|
142
|
+
pricingSource: 'replicate.com/pricing',
|
|
143
|
+
verifiedDate: '2026-09-10',
|
|
144
|
+
tier: 'pro',
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
provider: 'replicate',
|
|
148
|
+
modelId: 'stability-ai/sdxl',
|
|
149
|
+
displayName: 'Replicate SDXL',
|
|
150
|
+
capabilities: ['txt2img', 'img2img', 'inpaint'],
|
|
151
|
+
maxResolution: 1024,
|
|
152
|
+
supportedAspectRatios: ['1:1', '16:9', '9:16', '4:3', '3:4'],
|
|
153
|
+
features: {
|
|
154
|
+
textRendering: false,
|
|
155
|
+
facePreservation: false,
|
|
156
|
+
maskInpainting: true,
|
|
157
|
+
referenceConditioning: true,
|
|
158
|
+
controlnet: false,
|
|
159
|
+
},
|
|
160
|
+
estimatedCostUSD: 0.018,
|
|
161
|
+
pricingSource: 'replicate.com/pricing',
|
|
162
|
+
verifiedDate: '2026-09-10',
|
|
163
|
+
tier: 'economy',
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
// Local (ComfyUI / Stable Diffusion / A1111)
|
|
167
|
+
{
|
|
168
|
+
provider: 'local',
|
|
169
|
+
modelId: 'sdxl_base',
|
|
170
|
+
displayName: 'Local ComfyUI SDXL Base',
|
|
171
|
+
capabilities: ['txt2img', 'img2img', 'inpaint', 'high_res', 'face_preservation', 'controlnet'],
|
|
172
|
+
maxResolution: 2048,
|
|
173
|
+
supportedAspectRatios: ['1:1', '16:9', '9:16', '4:3', '3:4', '21:9'],
|
|
174
|
+
features: {
|
|
175
|
+
textRendering: false,
|
|
176
|
+
facePreservation: true,
|
|
177
|
+
maskInpainting: true,
|
|
178
|
+
referenceConditioning: true,
|
|
179
|
+
controlnet: true,
|
|
180
|
+
},
|
|
181
|
+
estimatedCostUSD: 0.0,
|
|
182
|
+
pricingSource: 'local-hardware (0 USD)',
|
|
183
|
+
verifiedDate: '2026-10-01',
|
|
184
|
+
tier: 'free',
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
provider: 'local',
|
|
188
|
+
modelId: 'flux_local',
|
|
189
|
+
displayName: 'Local ComfyUI FLUX',
|
|
190
|
+
capabilities: ['txt2img', 'img2img', 'text_rendering', 'high_res'],
|
|
191
|
+
maxResolution: 2048,
|
|
192
|
+
supportedAspectRatios: ['1:1', '16:9', '9:16', '4:3', '3:4', '21:9'],
|
|
193
|
+
features: {
|
|
194
|
+
textRendering: true,
|
|
195
|
+
facePreservation: false,
|
|
196
|
+
maskInpainting: false,
|
|
197
|
+
referenceConditioning: false,
|
|
198
|
+
controlnet: false,
|
|
199
|
+
},
|
|
200
|
+
estimatedCostUSD: 0.0,
|
|
201
|
+
pricingSource: 'local-hardware (0 USD)',
|
|
202
|
+
verifiedDate: '2026-10-01',
|
|
203
|
+
tier: 'free',
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
// Seedream backend
|
|
207
|
+
{
|
|
208
|
+
provider: 'seedream',
|
|
209
|
+
modelId: 'seedream-v2',
|
|
210
|
+
displayName: 'Seedream v2',
|
|
211
|
+
capabilities: ['txt2img', 'img2img'],
|
|
212
|
+
maxResolution: 1024,
|
|
213
|
+
supportedAspectRatios: ['1:1', '16:9', '9:16'],
|
|
214
|
+
features: {
|
|
215
|
+
textRendering: false,
|
|
216
|
+
facePreservation: false,
|
|
217
|
+
maskInpainting: false,
|
|
218
|
+
referenceConditioning: false,
|
|
219
|
+
controlnet: false,
|
|
220
|
+
},
|
|
221
|
+
estimatedCostUSD: 0.015,
|
|
222
|
+
pricingSource: 'seedream API',
|
|
223
|
+
verifiedDate: '2026-08-20',
|
|
224
|
+
tier: 'economy',
|
|
225
|
+
},
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Returns the entire capability registry with metadata.
|
|
230
|
+
*/
|
|
231
|
+
export function getCapabilityRegistry() {
|
|
232
|
+
return [...CAPABILITY_REGISTRY]
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Filter models based on required operations and features.
|
|
237
|
+
* Unknown capabilities are strictly treated as unsupported.
|
|
238
|
+
*/
|
|
239
|
+
export function filterModelsByCapabilities(requirements = {}, options = {}) {
|
|
240
|
+
const {
|
|
241
|
+
operations = [],
|
|
242
|
+
textRendering = false,
|
|
243
|
+
facePreservation = false,
|
|
244
|
+
maskInpainting = false,
|
|
245
|
+
referenceConditioning = false,
|
|
246
|
+
controlnet = false,
|
|
247
|
+
minResolution = 0,
|
|
248
|
+
aspectRatio = null,
|
|
249
|
+
} = requirements
|
|
250
|
+
|
|
251
|
+
const maxCost = requirements.maxCost ?? requirements.maxCostUSD ?? Infinity
|
|
252
|
+
|
|
253
|
+
const configuredProviders = Array.isArray(options.configuredProviders)
|
|
254
|
+
? options.configuredProviders
|
|
255
|
+
: (Array.isArray(requirements.configuredProviders) ? requirements.configuredProviders : null)
|
|
256
|
+
|
|
257
|
+
return CAPABILITY_REGISTRY.map((model) => {
|
|
258
|
+
const missing = []
|
|
259
|
+
|
|
260
|
+
if (configuredProviders && !configuredProviders.includes(model.provider)) {
|
|
261
|
+
missing.push(`provider_unconfigured:${model.provider}`)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
for (const op of operations) {
|
|
265
|
+
if (!model.capabilities.includes(op)) {
|
|
266
|
+
missing.push(`operation:${op}`)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (textRendering && !model.features.textRendering) {
|
|
271
|
+
missing.push('feature:textRendering')
|
|
272
|
+
}
|
|
273
|
+
if (facePreservation && !model.features.facePreservation) {
|
|
274
|
+
missing.push('feature:facePreservation')
|
|
275
|
+
}
|
|
276
|
+
if (maskInpainting && !model.features.maskInpainting) {
|
|
277
|
+
missing.push('feature:maskInpainting')
|
|
278
|
+
}
|
|
279
|
+
if (referenceConditioning && !model.features.referenceConditioning) {
|
|
280
|
+
missing.push('feature:referenceConditioning')
|
|
281
|
+
}
|
|
282
|
+
if (controlnet && !model.features.controlnet) {
|
|
283
|
+
missing.push('feature:controlnet')
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (minResolution > 0 && model.maxResolution < minResolution) {
|
|
287
|
+
missing.push(`resolution:${model.maxResolution}<${minResolution}`)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (aspectRatio && !model.supportedAspectRatios.includes(aspectRatio)) {
|
|
291
|
+
missing.push(`aspectRatio:${aspectRatio}`)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (model.estimatedCostUSD > maxCost) {
|
|
295
|
+
missing.push(`cost:${model.estimatedCostUSD}>${maxCost}`)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
...model,
|
|
300
|
+
compatible: missing.length === 0,
|
|
301
|
+
missingCapabilities: missing,
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Evaluates requirements and recommends the best model.
|
|
308
|
+
*/
|
|
309
|
+
export function recommendModelForTask(requirements = {}, options = {}) {
|
|
310
|
+
const evaluated = filterModelsByCapabilities(requirements, options)
|
|
311
|
+
const compatible = evaluated.filter((m) => m.compatible)
|
|
312
|
+
|
|
313
|
+
if (compatible.length === 0) {
|
|
314
|
+
const partial = [...evaluated].sort((a, b) => a.missingCapabilities.length - b.missingCapabilities.length)
|
|
315
|
+
return {
|
|
316
|
+
ok: false,
|
|
317
|
+
recommended: null,
|
|
318
|
+
message: 'No model in registry completely satisfies all specified task requirements.',
|
|
319
|
+
alternatives: partial.slice(0, 3).map((m) => ({
|
|
320
|
+
provider: m.provider,
|
|
321
|
+
modelId: m.modelId,
|
|
322
|
+
displayName: m.displayName,
|
|
323
|
+
missing: m.missingCapabilities,
|
|
324
|
+
estimatedCostUSD: m.estimatedCostUSD,
|
|
325
|
+
})),
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Rank compatible models:
|
|
330
|
+
// 1. Configured providers preferred
|
|
331
|
+
// 2. Cost-effectiveness
|
|
332
|
+
// 3. Higher max resolution
|
|
333
|
+
compatible.sort((a, b) => {
|
|
334
|
+
if (a.estimatedCostUSD !== b.estimatedCostUSD) {
|
|
335
|
+
return a.estimatedCostUSD - b.estimatedCostUSD
|
|
336
|
+
}
|
|
337
|
+
return b.maxResolution - a.maxResolution
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
const best = compatible[0]
|
|
341
|
+
const reasons = []
|
|
342
|
+
if (best.estimatedCostUSD === 0) reasons.push('Zero API cost (local backend)')
|
|
343
|
+
else reasons.push(`Estimated cost: $${best.estimatedCostUSD.toFixed(3)}`)
|
|
344
|
+
if (best.features.textRendering && requirements.textRendering) reasons.push('Native high-precision text rendering')
|
|
345
|
+
if (best.features.facePreservation && requirements.facePreservation) reasons.push('Facial identity retention supported')
|
|
346
|
+
if (best.features.maskInpainting && requirements.maskInpainting) reasons.push('Pixel-accurate mask inpainting supported')
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
ok: true,
|
|
350
|
+
recommended: best,
|
|
351
|
+
recommendationReason: reasons.join('; '),
|
|
352
|
+
allCompatibleCount: compatible.length,
|
|
353
|
+
alternatives: compatible.slice(1, 4).map((m) => ({
|
|
354
|
+
provider: m.provider,
|
|
355
|
+
modelId: m.modelId,
|
|
356
|
+
displayName: m.displayName,
|
|
357
|
+
estimatedCostUSD: m.estimatedCostUSD,
|
|
358
|
+
})),
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Pre-flight validation before provider dispatch (#434).
|
|
364
|
+
* Rejects unsupported capabilities BEFORE submitting to provider to avoid wasting credits or losing inputs.
|
|
365
|
+
*/
|
|
366
|
+
export function validateModelCapability(provider, modelId, requestedFeatures = {}) {
|
|
367
|
+
const model = CAPABILITY_REGISTRY.find(
|
|
368
|
+
(m) => m.provider === provider && (m.modelId === modelId || !modelId)
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
if (!model) {
|
|
372
|
+
// Unknown model/provider: return strict unknown warning
|
|
373
|
+
return {
|
|
374
|
+
valid: false,
|
|
375
|
+
status: 'unknown_model',
|
|
376
|
+
reason: `Provider "${provider}" with model "${modelId || 'default'}" is not documented in capability registry.`,
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const {
|
|
381
|
+
operation,
|
|
382
|
+
requiresMask,
|
|
383
|
+
requiresReference,
|
|
384
|
+
requiresFacePreservation,
|
|
385
|
+
requiresTextRender,
|
|
386
|
+
} = requestedFeatures
|
|
387
|
+
|
|
388
|
+
if (operation && !model.capabilities.includes(operation)) {
|
|
389
|
+
return {
|
|
390
|
+
valid: false,
|
|
391
|
+
status: 'unsupported_operation',
|
|
392
|
+
reason: `Operation "${operation}" is not supported by model "${model.displayName}" (${model.provider}).`,
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (requiresMask && !model.features.maskInpainting) {
|
|
397
|
+
return {
|
|
398
|
+
valid: false,
|
|
399
|
+
status: 'unsupported_feature',
|
|
400
|
+
reason: `Mask inpainting is requested but not supported by "${model.displayName}". Discarding mask would corrupt generation.`,
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (requiresReference && !model.features.referenceConditioning && !model.capabilities.includes('img2img')) {
|
|
405
|
+
return {
|
|
406
|
+
valid: false,
|
|
407
|
+
status: 'unsupported_feature',
|
|
408
|
+
reason: `Reference image conditioning is requested but not supported by "${model.displayName}".`,
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (requiresFacePreservation && !model.features.facePreservation) {
|
|
413
|
+
return {
|
|
414
|
+
valid: false,
|
|
415
|
+
status: 'unsupported_feature',
|
|
416
|
+
reason: `Face preservation identity transfer is requested but not supported by "${model.displayName}".`,
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (requiresTextRender && !model.features.textRendering) {
|
|
421
|
+
return {
|
|
422
|
+
valid: false,
|
|
423
|
+
status: 'unsupported_feature',
|
|
424
|
+
reason: `High-fidelity text rendering is required but "${model.displayName}" lacks native text layout capabilities.`,
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
valid: true,
|
|
430
|
+
status: 'supported',
|
|
431
|
+
model,
|
|
432
|
+
}
|
|
433
|
+
}
|