@goodandready/dsh-image-gen 0.10.31 → 0.10.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.
@@ -0,0 +1,163 @@
1
+ // fallback-router.js — Smart Provider Fallback Chain for image generation (#282).
2
+ // Automatically cascades across configured providers on 429, 5xx, timeouts,
3
+ // quota depletion, and network issues without failing the user's turn.
4
+
5
+ import { formatErrorMessage } from './provider-utils.js'
6
+
7
+ /**
8
+ * Checks if an error is strictly a prompt-level safety or client syntax violation
9
+ * that cannot be fixed by switching to a different provider.
10
+ *
11
+ * @param {Error|any} error
12
+ * @returns {boolean} true if fatal prompt/content violation, false if provider-level retryable
13
+ */
14
+ export function isFatalPromptError(error) {
15
+ const msg = (error?.message || String(error || '')).toLowerCase()
16
+ return (
17
+ msg.includes('content policy') ||
18
+ msg.includes('safety system') ||
19
+ msg.includes('nsfw') ||
20
+ msg.includes('moderation') ||
21
+ msg.includes('prompt is required') ||
22
+ msg.includes('empty prompt') ||
23
+ msg.includes('invalid_prompt') ||
24
+ msg.includes('unsupported image format')
25
+ )
26
+ }
27
+
28
+ /**
29
+ * Checks whether an error is retryable by switching to another provider.
30
+ * Covers 429 (rate limit), 5xx (server error), timeout, payment/quota depletion, and network dropouts.
31
+ *
32
+ * @param {Error|any} error
33
+ * @returns {boolean}
34
+ */
35
+ export function isRetryableProviderError(error) {
36
+ if (isFatalPromptError(error)) return false
37
+ const msg = (error?.message || String(error || '')).toLowerCase()
38
+ return (
39
+ msg.includes('429') ||
40
+ msg.includes('rate limit') ||
41
+ msg.includes('too many requests') ||
42
+ msg.includes('500') ||
43
+ msg.includes('502') ||
44
+ msg.includes('503') ||
45
+ msg.includes('504') ||
46
+ msg.includes('bad gateway') ||
47
+ msg.includes('service unavailable') ||
48
+ msg.includes('gateway timeout') ||
49
+ msg.includes('timeout') ||
50
+ msg.includes('timed out') ||
51
+ msg.includes('aborted') ||
52
+ msg.includes('402') ||
53
+ msg.includes('payment required') ||
54
+ msg.includes('insufficient_quota') ||
55
+ msg.includes('insufficient credits') ||
56
+ msg.includes('exceeded your current quota') ||
57
+ msg.includes('balance is insufficient') ||
58
+ msg.includes('quota') ||
59
+ msg.includes('credit') ||
60
+ msg.includes('unauthorized') ||
61
+ msg.includes('401') ||
62
+ msg.includes('invalid api key') ||
63
+ msg.includes('econnrefused') ||
64
+ msg.includes('enotfound') ||
65
+ msg.includes('fetch failed') ||
66
+ msg.includes('network')
67
+ )
68
+ }
69
+
70
+ /**
71
+ * Resolves the candidate evaluation order:
72
+ * Primary provider first, followed by explicit fallbackProviders, then remaining known providers.
73
+ *
74
+ * @param {string} primary
75
+ * @param {string[]} [configuredFallbacks=[]]
76
+ * @param {string[]} [allProviders=[]]
77
+ * @returns {string[]}
78
+ */
79
+ export function resolveFallbackChain(primary, configuredFallbacks = [], allProviders = []) {
80
+ const chain = [primary]
81
+ if (Array.isArray(configuredFallbacks)) {
82
+ for (const p of configuredFallbacks) {
83
+ if (typeof p === 'string' && p.trim() && !chain.includes(p.trim())) {
84
+ chain.push(p.trim())
85
+ }
86
+ }
87
+ }
88
+ if (Array.isArray(allProviders)) {
89
+ for (const p of allProviders) {
90
+ if (typeof p === 'string' && p.trim() && !chain.includes(p.trim())) {
91
+ chain.push(p.trim())
92
+ }
93
+ }
94
+ }
95
+ return chain
96
+ }
97
+
98
+ /**
99
+ * Executes image generation across a cascade of providers with full attempt diagnostics.
100
+ *
101
+ * @param {Record<string, (seed: number, prompt: string) => Promise<any>>} generators
102
+ * @param {string[]} chain Ordered list of providers to try
103
+ * @param {number} seed
104
+ * @param {string} prompt
105
+ * @param {object} [options]
106
+ * @returns {Promise<any>} Produced image with attached _fallback metadata
107
+ */
108
+ export async function executeWithFallback(generators, chain, seed, prompt, options = {}) {
109
+ const attempts = []
110
+ const primaryProvider = chain[0] || 'unknown'
111
+ const logger = options.logger || null
112
+
113
+ for (let i = 0; i < chain.length; i++) {
114
+ const providerKey = chain[i]
115
+ const generator = generators[providerKey]
116
+ if (typeof generator !== 'function') {
117
+ continue
118
+ }
119
+
120
+ const startTime = Date.now()
121
+ try {
122
+ const produced = await generator(seed, prompt)
123
+ if (produced) {
124
+ const isFallback = providerKey !== primaryProvider
125
+ produced._fallback = {
126
+ triggered: isFallback,
127
+ primaryProvider,
128
+ providerUsed: providerKey,
129
+ attempts: [
130
+ ...attempts,
131
+ { provider: providerKey, durationMs: Date.now() - startTime, success: true },
132
+ ],
133
+ }
134
+ if (isFallback && logger && typeof logger.info === 'function') {
135
+ logger.info(`[dsh-image-gen] Fallback triggered: ${primaryProvider} -> ${providerKey}`)
136
+ }
137
+ return produced
138
+ }
139
+ } catch (err) {
140
+ const durationMs = Date.now() - startTime
141
+ const formatted = formatErrorMessage(err, providerKey)
142
+ attempts.push({
143
+ provider: providerKey,
144
+ error: formatted,
145
+ durationMs,
146
+ success: false,
147
+ })
148
+
149
+ if (isFatalPromptError(err)) {
150
+ throw new Error(`Content or policy error on ${providerKey} (not cascading): ${formatted}`)
151
+ }
152
+
153
+ if (logger && typeof logger.warn === 'function') {
154
+ logger.warn(`[dsh-image-gen] Provider ${providerKey} failed: ${formatted}. Trying next candidate...`)
155
+ }
156
+ }
157
+ }
158
+
159
+ const failureSummary = attempts
160
+ .map((a) => `${a.provider} (${a.durationMs}ms): ${a.error}`)
161
+ .join('; ')
162
+ throw new Error(`Image generation cascade failed on all ${attempts.length} attempted providers: ${failureSummary}`)
163
+ }
package/lib/index.js CHANGED
@@ -264,6 +264,10 @@ export const Config = z.object({
264
264
  .boolean()
265
265
  .description('Content-addressed disk caching for identical generations (<50ms retrieval, zero API cost). On by default.')
266
266
  .default(true),
267
+ fallbackProviders: z
268
+ .array(z.string())
269
+ .description('Ordered list of fallback providers to cascade to if the primary encounters 429, 5xx, or quota limits.')
270
+ .default([]),
267
271
  })
268
272
 
269
273
  /** Keep a file stem safe for the filesystem. */
@@ -11,6 +11,9 @@ import { registerSketchTools } from './tools/sketch.js'
11
11
  import { registerSpritesheetTools } from './tools/spritesheet.js'
12
12
  import { registerPatternTools } from './tools/pattern.js'
13
13
  import { registerResponsiveTools } from './tools/responsive.js'
14
+ import { registerAnchorTools } from './tools/anchor.js'
15
+ import { registerUiAssetTools } from './tools/ui-asset.js'
16
+ import { registerStyleMatrixTools } from './tools/style-matrix.js'
14
17
 
15
18
  /**
16
19
  * Registers every image-gen tool on the host tools service.
@@ -29,4 +32,7 @@ export function registerAllTools(ctx, deps) {
29
32
  registerSpritesheetTools(ctx, deps)
30
33
  registerPatternTools(ctx, deps)
31
34
  registerResponsiveTools(ctx, deps)
35
+ registerAnchorTools(ctx, deps)
36
+ registerUiAssetTools(ctx, deps)
37
+ registerStyleMatrixTools(ctx, deps)
32
38
  }
@@ -0,0 +1,85 @@
1
+ // style-matrix-helpers.js — Helpers for generate_style_matrix tool (#286).
2
+
3
+ export const DEFAULT_MATRIX_STYLES = [
4
+ 'editorial_photo',
5
+ 'flat_vector',
6
+ 'clay_3d',
7
+ 'cyberpunk',
8
+ ]
9
+
10
+ export const MATRIX_PRESET_PROMPTS = {
11
+ editorial_photo: 'editorial magazine photograph, 35mm film, soft diffused cinematic lighting, award-winning shot, ultra-detailed',
12
+ flat_vector: 'minimal flat vector art, clean sharp lines, bold solid colors, geometric modern graphic illustration',
13
+ clay_3d: 'charming 3D claymation plasticine sculpt, handcrafted clay texture, stop-motion animation aesthetic, miniature depth of field',
14
+ cyberpunk: 'gritty cyberpunk scene, neon reflections, rain-slicked chrome, dark synthwave palette, high-tech dystopian atmosphere',
15
+ cinematic: 'cinematic still frame, 70mm Panavision anamorphic lens, dramatic moody lighting, atmospheric haze',
16
+ anime: 'vibrant modern anime keyframe, Makoto Shinkai aesthetic, luminous skies, crisp cel shading',
17
+ isometric_3d: 'isometric 3D diorama render, Blender 3D, orthographic perspective, cute stylized textures',
18
+ analog_film: 'vintage 1970s Kodachrome analog film print, authentic grain, faded warm tones, nostalgic vignette',
19
+ }
20
+
21
+ /**
22
+ * Normalizes input styles into exactly 4 distinct style strings.
23
+ *
24
+ * @param {string[]|undefined} inputStyles
25
+ * @returns {string[]}
26
+ */
27
+ export function normalizeMatrixStyles(inputStyles) {
28
+ const result = []
29
+ if (Array.isArray(inputStyles)) {
30
+ for (const s of inputStyles) {
31
+ if (typeof s === 'string' && s.trim()) {
32
+ const clean = s.trim().toLowerCase()
33
+ if (!result.includes(clean)) result.push(clean)
34
+ }
35
+ if (result.length === 4) break
36
+ }
37
+ }
38
+
39
+ // Backfill up to 4 using defaults
40
+ for (const def of DEFAULT_MATRIX_STYLES) {
41
+ if (result.length >= 4) break
42
+ if (!result.includes(def)) result.push(def)
43
+ }
44
+
45
+ return result.slice(0, 4)
46
+ }
47
+
48
+ /**
49
+ * Builds the full prompt for a matrix quadrant cell.
50
+ *
51
+ * @param {string} basePrompt
52
+ * @param {string} styleName
53
+ * @returns {string}
54
+ */
55
+ export function buildMatrixCellPrompt(basePrompt, styleName) {
56
+ const cleanBase = String(basePrompt || '').trim()
57
+ const presetDirective = MATRIX_PRESET_PROMPTS[styleName] || (styleName + ' style, aesthetic rendering')
58
+ return cleanBase + ', ' + presetDirective
59
+ }
60
+
61
+ /**
62
+ * Formats a 2x2 markdown presentation grid for chat models.
63
+ *
64
+ * @param {object} params
65
+ * @param {string} params.basePrompt
66
+ * @param {boolean} params.blindMode
67
+ * @param {Array<{id: string, style: string, path: string, url: string, seed: number}>} params.cells
68
+ * @returns {string}
69
+ */
70
+ export function formatMatrixMarkdown({ basePrompt, blindMode, cells }) {
71
+ const lines = [
72
+ '### Style Matrix (2×2 Benchmark): "' + basePrompt + '"',
73
+ blindMode ? '_Blind Comparison Mode: styles masked until evaluated_' : '',
74
+ '',
75
+ '| Cell | Style Preset | Seed | Path |',
76
+ '| :---: | :--- | :---: | :--- |',
77
+ ]
78
+
79
+ for (const c of cells) {
80
+ const styleLabel = blindMode ? ('Option ' + c.id) : ('**' + c.style + '**')
81
+ lines.push('| **' + c.id + '** | ' + styleLabel + ' | `' + c.seed + '` | `' + c.path + '` |')
82
+ }
83
+
84
+ return lines.filter(Boolean).join('\n')
85
+ }
@@ -0,0 +1,129 @@
1
+ // anchor.js — set_style_anchor tool (#283).
2
+ // Manages persistent visual style & character reference anchors in session context.
3
+
4
+ import { defineTool } from '@deepseek-ai/dsh-tools'
5
+ import {
6
+ setSessionAnchor,
7
+ getSessionAnchor,
8
+ clearSessionAnchor,
9
+ normalizeAnchorStrength,
10
+ } from '../anchor-helpers.js'
11
+ import { toLosslessJson } from '../providers.js'
12
+
13
+ export function registerAnchorTools(ctx, deps) {
14
+ const { live, resolveSource } = deps
15
+
16
+ ctx.effect(() => {
17
+ ctx.tools.register(
18
+ defineTool({
19
+ name: 'set_style_anchor',
20
+ description:
21
+ 'Set or clear a persistent visual anchor (character identity or artstyle) for this session. '
22
+ + 'Subsequent image generations automatically adhere to this reference to maintain visual continuity.',
23
+ parameters: {
24
+ image: {
25
+ type: 'string',
26
+ description: 'URL, local file path, or attachment id (sha256:...) to use as the visual anchor. Required unless clear is true.',
27
+ },
28
+ label: {
29
+ type: 'string',
30
+ description: 'Human-readable descriptor, e.g. "Protagonist Alex" or "Dark Synthwave 3D".',
31
+ },
32
+ mode: {
33
+ type: 'string',
34
+ enum: ['style', 'character'],
35
+ description: 'Anchor mode: "character" preserves facial/physical identity; "style" preserves artistic rendering, lighting, and palette. Default: style.',
36
+ },
37
+ strength: {
38
+ type: 'number',
39
+ description: 'Reference influence strength between 0.1 and 1.0 (default 0.65).',
40
+ },
41
+ clear: {
42
+ type: 'boolean',
43
+ description: 'When true, clears the active anchor for this session instead of setting a new one.',
44
+ },
45
+ },
46
+ output: {
47
+ schema: {
48
+ type: 'object',
49
+ additionalProperties: true,
50
+ properties: {
51
+ ok: { type: 'boolean' },
52
+ action: { type: 'string' },
53
+ anchor: {
54
+ type: 'object',
55
+ additionalProperties: true,
56
+ properties: {
57
+ image: { type: 'string' },
58
+ label: { type: 'string' },
59
+ mode: { type: 'string' },
60
+ strength: { type: 'number' },
61
+ updatedAt: { type: 'string' },
62
+ },
63
+ },
64
+ summary: { type: 'string' },
65
+ },
66
+ },
67
+ render(args, value) {
68
+ return [{ type: 'text', text: value.summary || 'Style anchor updated.' }]
69
+ },
70
+ },
71
+ isConcurrencySafe: () => true,
72
+ async execute(args, exec) {
73
+ const cfg = live ? live() : {}
74
+ if (cfg.enabled === false) {
75
+ throw new Error('Image generation plugin is disabled in settings.')
76
+ }
77
+
78
+ const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
79
+
80
+ if (args.clear) {
81
+ const existed = clearSessionAnchor(sessionId)
82
+ const summary = existed
83
+ ? 'Active visual style anchor was cleared for this session.'
84
+ : 'No active visual style anchor was set for this session.'
85
+ return toLosslessJson({
86
+ ok: true,
87
+ action: 'cleared',
88
+ summary,
89
+ })
90
+ }
91
+
92
+ if (!args.image || typeof args.image !== 'string' || !args.image.trim()) {
93
+ throw new Error('Parameter "image" (URL, file path, or sha256 attachment ID) is required to set an anchor.')
94
+ }
95
+
96
+ const imageRef = args.image.trim()
97
+ const mode = args.mode === 'character' ? 'character' : 'style'
98
+ const strength = normalizeAnchorStrength(args.strength)
99
+ const label = args.label ? String(args.label).trim() : (mode === 'character' ? 'Character Anchor' : 'Style Anchor')
100
+
101
+ // Validate source readability if it is a local path or attachment
102
+ if (resolveSource && (imageRef.startsWith('sha256:') || !imageRef.startsWith('http'))) {
103
+ try {
104
+ await resolveSource(ctx, exec, imageRef)
105
+ } catch (err) {
106
+ // Best effort check; non-blocking if remote URL or future attachment
107
+ }
108
+ }
109
+
110
+ const anchor = setSessionAnchor(sessionId, {
111
+ image: imageRef,
112
+ label,
113
+ mode,
114
+ strength,
115
+ })
116
+
117
+ const summary = `Visual anchor "${label}" (${mode} mode, strength: ${strength}) is now active for this session. Subsequent generations will align with this reference.`
118
+
119
+ return toLosslessJson({
120
+ ok: true,
121
+ action: 'set',
122
+ anchor,
123
+ summary,
124
+ })
125
+ },
126
+ }),
127
+ )
128
+ }, 'dsh-image-gen: tool set_style_anchor')
129
+ }
@@ -52,6 +52,8 @@ import { calculateGenerationCost, recordSpend, assertBudgetAvailable } from '../
52
52
  import { trackAndAssertLoopGuard } from '../loop-guard.js'
53
53
  import { sanitizeErrorAndLogs } from '../security.js'
54
54
  import { getCachedGeneration, setCachedGeneration } from '../generation-cache.js'
55
+ import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
56
+ import { getSessionAnchor, applyAnchorPromptHints } from '../anchor-helpers.js'
55
57
  import {
56
58
  extractDesignTokens,
57
59
  generateCssGradient,
@@ -173,6 +175,14 @@ export function registerGenerationTools(ctx, deps) {
173
175
  type: 'boolean',
174
176
  description: 'Optional flag to bypass content-addressed cache and force a new API generation.',
175
177
  },
178
+ reference_image: {
179
+ type: 'string',
180
+ description: 'Optional reference image (URL, path, or sha256:...) for character or style consistency.',
181
+ },
182
+ reference_strength: {
183
+ type: 'number',
184
+ description: 'Optional strength of reference anchor influence (0.1 to 1.0).',
185
+ },
176
186
  },
177
187
  output: {
178
188
  schema: {
@@ -266,7 +276,13 @@ export function registerGenerationTools(ctx, deps) {
266
276
  const enhanced = await enhancePrompt(ctx, cfg, args.prompt, exec.signal, provider)
267
277
  const effectiveStyle = args.style || cfg.stylePreset
268
278
  const styleInfo = resolveStylePreset(effectiveStyle, args.negative_prompt, args.guidance_scale)
269
- const effectivePrompt = styleInfo.promptSuffix ? `${enhanced.prompt}, ${styleInfo.promptSuffix}` : enhanced.prompt
279
+ let effectivePrompt = styleInfo.promptSuffix ? `${enhanced.prompt}, ${styleInfo.promptSuffix}` : enhanced.prompt
280
+ const activeAnchor = getSessionAnchor(sessionId)
281
+ if (args.reference_image) {
282
+ effectivePrompt = `${effectivePrompt}, visual reference anchor`
283
+ } else if (activeAnchor) {
284
+ effectivePrompt = applyAnchorPromptHints(activeAnchor, effectivePrompt)
285
+ }
270
286
 
271
287
  // #165: Negative prompt sanitizer for diffusion models
272
288
  let effectiveNegative = styleInfo.negativePrompt
@@ -419,7 +435,7 @@ export function registerGenerationTools(ctx, deps) {
419
435
  const seedBase = args.seed ?? Math.floor(Math.random() * 100000)
420
436
  // Fallback cascade: evaluate configured provider, falling back to alternates
421
437
  // (fal -> custom -> codex -> grok), accumulating failure diagnostics.
422
- const order = fallbackOrder(provider)
438
+ const order = resolveFallbackChain(provider, cfg.fallbackProviders, PROVIDER_KEYS)
423
439
  const qgEnabled = args.quality_gate ?? cfg.qualityGate
424
440
  const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [
425
441
  k,
@@ -512,7 +528,7 @@ export function registerGenerationTools(ctx, deps) {
512
528
  const tasks = batchPrompts.map((item, i) => async () => {
513
529
  const text = typeof item === 'string' ? item : (item && item.text) || ''
514
530
  const cached = await checkCache(seedBase + i, text)
515
- return cached || await tryGenerate(generators, order, seedBase + i, text)
531
+ return cached || await executeWithFallback(generators, order, seedBase + i, text, { logger: ctx.logger })
516
532
  })
517
533
  const parallelResults = await asyncPool(tasks, 3)
518
534
  images.push(...parallelResults)
@@ -520,7 +536,7 @@ export function registerGenerationTools(ctx, deps) {
520
536
  const count = normalizeCount(args.count)
521
537
  const tasks = Array.from({ length: count }, (_, i) => async () => {
522
538
  const cached = await checkCache(seedBase + i, effectivePrompt)
523
- return cached || await tryGenerate(generators, order, seedBase + i)
539
+ return cached || await executeWithFallback(generators, order, seedBase + i, effectivePrompt, { logger: ctx.logger })
524
540
  })
525
541
  const parallelResults = await asyncPool(tasks, 3)
526
542
  images.push(...parallelResults)
@@ -538,7 +554,7 @@ export function registerGenerationTools(ctx, deps) {
538
554
  cost: first.cost,
539
555
  attachmentId: first.attachment?.attachmentId || 'N/A',
540
556
  })
541
- return toLosslessJson({ summary: dualOutputSummary, channel: provider, provider, model: cfg.model || cfg.customModel, ...first, images })
557
+ return toLosslessJson({ summary: dualOutputSummary, channel: provider, provider, model: cfg.model || cfg.customModel, _fallback: first._fallback, anchor: activeAnchor ? { label: activeAnchor.label, mode: activeAnchor.mode } : undefined, ...first, images })
542
558
  },
543
559
  }),
544
560
  )