@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.
- package/lib/anchor-helpers.js +103 -0
- package/lib/client.js +462 -52
- package/lib/fallback-router.js +163 -0
- package/lib/index.js +4 -0
- package/lib/register-tools.js +6 -0
- package/lib/style-matrix-helpers.js +85 -0
- package/lib/tools/anchor.js +129 -0
- package/lib/tools/generation.js +21 -5
- package/lib/tools/style-matrix.js +227 -0
- package/lib/tools/ui-asset.js +238 -0
- package/lib/ui-asset-helpers.js +44 -0
- package/package.json +1 -1
|
@@ -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. */
|
package/lib/register-tools.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/tools/generation.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
)
|