@goodandready/dsh-image-gen 0.8.0

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/index.js ADDED
@@ -0,0 +1,389 @@
1
+ // dsh-image-gen: a `generate_image` tool with pluggable providers.
2
+ //
3
+ // A provider turns a prompt into image bytes; everything after that is shared:
4
+ // 1. save the bytes through ctx.attachments (renders in the conversation);
5
+ // 2. write a durable copy under <workspace>/<outputDir>;
6
+ // 3. hand the tool card a same-origin URL that outlives the provider's link.
7
+ //
8
+ // Providers live in providers.js — the FAL queue protocol and any
9
+ // OpenAI-compatible images API. Which one runs is the `provider` setting.
10
+ //
11
+ // API keys are resolved per call through the credentials service (Settings ->
12
+ // Credentials, or $DSH_HOME/.credentials.yaml) under the configured reference,
13
+ // falling back to the process environment.
14
+
15
+ import z from '@deepseek-ai/schemastery'
16
+ import { defineTool } from '@deepseek-ai/dsh-tools'
17
+ import { credentialRef } from '@deepseek-ai/dsh-credentials'
18
+ import { mkdir, writeFile } from 'node:fs/promises'
19
+ import path from 'node:path'
20
+ import {
21
+ IMAGE_SIZES,
22
+ OUTPUT_FORMATS,
23
+ PROVIDER_KEYS,
24
+ makeProviders,
25
+ normalizeMediaType,
26
+ } from './providers.js'
27
+
28
+ export { IMAGE_SIZES, OUTPUT_FORMATS, PROVIDER_KEYS, normalizeMediaType }
29
+
30
+ export const name = 'dsh-image-gen'
31
+
32
+ /** Settings namespace the Web card edits. */
33
+ const NS = 'dsh-image-gen'
34
+
35
+ // Плагин раньше назывался dsh-fal-image-gen, и у тех, кто им пользовался, все
36
+ // настройки лежат под старым именем. Оно читается как запасное и переносится
37
+ // под новое имя один раз — молча, при первом запуске после обновления.
38
+ const LEGACY_NS = 'dsh-fal-image-gen'
39
+ export const inject = ['tools', 'attachments', 'credentials', 'webServer', 'settings']
40
+
41
+ export const Config = z.object({
42
+ provider: z
43
+ .string()
44
+ .description(`Which provider generates the image. One of: ${PROVIDER_KEYS.join(', ')}. `
45
+ + '"fal" uses the FAL queue below; "custom" uses the OpenAI-compatible API configured under it.')
46
+ .default('fal'),
47
+ model: z
48
+ .string()
49
+ .description('FAL model id, called as {baseURL}/{model}.')
50
+ .default('fal-ai/flux-2/klein/9b'),
51
+ apiKeyEnv: z
52
+ .string()
53
+ .description('Credential reference / env var holding the FAL API key (the "Key " auth prefix is added automatically when missing).')
54
+ .default('FAL_API_KEY'),
55
+ baseURL: z
56
+ .string()
57
+ .description('FAL queue base URL.')
58
+ .default('https://queue.fal.run'),
59
+ defaultSize: z
60
+ .string()
61
+ .description(`Default image size when the tool call omits image_size. One of: ${IMAGE_SIZES.join(', ')}.`)
62
+ .default('landscape_4_3'),
63
+ defaultFormat: z
64
+ .string()
65
+ .description(`Default output format. One of: ${OUTPUT_FORMATS.join(', ')}.`)
66
+ .default('png'),
67
+ pollIntervalMs: z
68
+ .number()
69
+ .description('Status polling interval in milliseconds.')
70
+ .default(2000),
71
+ timeoutMs: z
72
+ .number()
73
+ .description('Total generation timeout in milliseconds (submit + poll + download).')
74
+ .default(180000),
75
+ deliverAs: z
76
+ .string()
77
+ .description(
78
+ 'How the finished image reaches the conversation. '
79
+ + '"link": the tool returns a link and the card renders the picture from it — the chat model only ever sees text, so this works with any model. '
80
+ + '"image": the tool returns the image itself — the picture is part of the result, which a text-only chat model cannot read, so this mode needs dsh-vision-bridge (or a vision-capable chat model).'
81
+ )
82
+ .default('link'),
83
+ customBaseURL: z
84
+ .string()
85
+ .description('provider=custom: API root without a trailing slash, e.g. https://api.openai.com/v1. '
86
+ + 'The request goes to {customBaseURL}/images/generations.')
87
+ .default(''),
88
+ customModel: z
89
+ .string()
90
+ .description('provider=custom: model id, e.g. gpt-image-1.')
91
+ .default(''),
92
+ customKeyEnv: z
93
+ .string()
94
+ .description('provider=custom: credential reference / env var holding the API key. '
95
+ + 'Empty means no authorization header, for gateways that need none.')
96
+ .default('OPENAI_API_KEY'),
97
+ subscriptionQuality: z
98
+ .string()
99
+ .description('provider=codex or grok: quality asked of the subscription — low, medium, high or empty '
100
+ + 'for the provider default.')
101
+ .default(''),
102
+ customSize: z
103
+ .string()
104
+ .description('provider=custom: fixed size sent to the API, e.g. 1024x1024. '
105
+ + 'Empty means the named size is translated automatically — set this only for an API picky about sizes.')
106
+ .default(''),
107
+ outputDir: z
108
+ .string()
109
+ .description('Where generated images are saved. A relative path resolves against the session working directory; an absolute path is used as given.')
110
+ .default('generated/images'),
111
+ })
112
+
113
+ /** Keep a file stem safe for the filesystem. */
114
+ export function slugify(input) {
115
+ const stem = String(input ?? '')
116
+ .toLowerCase()
117
+ .replace(/[^a-z0-9\u0400-\u04ff]+/gi, '-')
118
+ .replace(/^-+|-+$/g, '')
119
+ .slice(0, 48)
120
+ return stem || 'image'
121
+ }
122
+
123
+ /** Resolve an API key for one call (credentials service first, then env). */
124
+ export async function resolveApiKey(ctx, ref) {
125
+ if (!ref) return ''
126
+ try {
127
+ const resolved = await ctx.credentials.resolve(credentialRef(ref))
128
+ if (resolved && resolved.value) return resolved.value
129
+ } catch {
130
+ // fall through to the environment
131
+ }
132
+ const fromEnv = process.env[ref]
133
+ if (fromEnv) return fromEnv
134
+ throw new Error(
135
+ `API key not configured: set credential/env "${ref}" (Web: Settings → Credentials, or add "${ref}: <key>" to $DSH_HOME/.credentials.yaml)`,
136
+ )
137
+ }
138
+
139
+ /**
140
+ * Перенести настройки из-под старого имени плагина.
141
+ *
142
+ * Берётся сырой пользовательский слой: значения по умолчанию переносить незачем,
143
+ * а отличить их от заданных руками можно только по нему. Если под новым именем
144
+ * человек уже что-то задал, не трогаем ничего — его выбор новее.
145
+ *
146
+ * Старый блок остаётся в файле нетронутым: удалять чужие строки из настроек
147
+ * пользователя плагину не по чину, а лишним он не мешает.
148
+ */
149
+ function migrateLegacySettings(sctx, scope) {
150
+ try {
151
+ const readSection = sctx.settings.section
152
+ if (typeof readSection !== 'function') return
153
+ const legacy = readSection.call(sctx.settings, LEGACY_NS)
154
+ if (!legacy || typeof legacy !== 'object' || Object.keys(legacy).length === 0) return
155
+ const mine = readSection.call(sctx.settings, NS)
156
+ if (mine && typeof mine === 'object' && Object.keys(mine).length > 0) return
157
+ scope.update(structuredClone(legacy))
158
+ } catch (cannotMigrate) {
159
+ // Настройки не перенеслись — плагин работает на значениях по умолчанию,
160
+ // и человек задаст своё в карточке. Ронять из-за этого запуск незачем.
161
+ }
162
+ }
163
+
164
+ export function apply(ctx, config) {
165
+
166
+ // The Web card edits this namespace; without registering it the card binds to
167
+ // a namespace nobody declared, stays unready and renders nothing — which is
168
+ // why the plugin's settings tab was empty. Reading through getConfig() also
169
+ // means an edit applies to the next call instead of after a restart.
170
+ let getConfig = () => config
171
+ const live = () => Config(structuredClone(getConfig() ?? {})) ?? config
172
+
173
+ ctx.inject(['settings'], (sctx) => {
174
+ const scope = sctx.settings.register(NS, Config, { base: config })
175
+ migrateLegacySettings(sctx, scope)
176
+ getConfig = () => scope.get() ?? config
177
+ sctx.effect(() => () => {
178
+ getConfig = () => config
179
+ })
180
+ })
181
+
182
+ // Serve the stored image so the tool card can show it inline. Tool cards do
183
+ // not render image blocks — only assistant messages do — so the picture a
184
+ // tool produces needs a URL of its own.
185
+ //
186
+ // Ids are content-addressed (`sha256:<hex>`), the store verifies them, and
187
+ // the route is same-origin like every other plugin route.
188
+ const imageHandler = (() => {
189
+ return async (req, res) => {
190
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
191
+ res.writeHead(405, { 'Content-Type': 'application/json' })
192
+ res.end(JSON.stringify({ error: 'GET only' }))
193
+ return
194
+ }
195
+ const query = new URL(req.url ?? '/', 'http://x').searchParams
196
+ const id = query.get('id') ?? ''
197
+ if (!/^sha256:[0-9a-f]{64}$/.test(id)) {
198
+ res.writeHead(400, { 'Content-Type': 'application/json' })
199
+ res.end(JSON.stringify({ error: 'bad attachment id' }))
200
+ return
201
+ }
202
+ // The store verifies the whole reference, not just the id: mediaType,
203
+ // byte count and dimensions must match what it probes from the bytes.
204
+ // The tool result carries all of them, so the card sends them back.
205
+ // Nothing is gained by forging them — the bytes are still verified
206
+ // against the sha256 in the id, and a mismatch is simply a 404.
207
+ const ref = {
208
+ attachmentId: id,
209
+ mediaType: query.get('mt') || 'image/png',
210
+ bytes: Number(query.get('b')) || 0,
211
+ width: Number(query.get('w')) || 0,
212
+ height: Number(query.get('h')) || 0,
213
+ }
214
+ try {
215
+ const stored = await ctx.attachments.readImage(ref)
216
+ res.writeHead(200, {
217
+ 'Content-Type': stored.ref?.mediaType || 'image/png',
218
+ // Content-addressed: the bytes behind an id never change.
219
+ 'Cache-Control': 'public, max-age=31536000, immutable',
220
+ })
221
+ res.end(Buffer.from(stored.data))
222
+ } catch (error) {
223
+ res.writeHead(404, { 'Content-Type': 'application/json' })
224
+ res.end(JSON.stringify({ error: String(error && error.message ? error.message : error) }))
225
+ }
226
+ }
227
+ })()
228
+
229
+ // Два адреса, один обработчик. Новый — тот, что уходит в новые сообщения;
230
+ // старый остался от прежнего имени плагина, и по нему сделаны ссылки в уже
231
+ // отправленных: сними его — и картинки в истории разговоров перестанут
232
+ // показываться.
233
+ for (const path of ['/dsh-image-gen/image', '/dsh-fal-image-gen/image']) {
234
+ ctx.effect(() => ctx.webServer.register({
235
+ kind: 'exact',
236
+ path,
237
+ handler: imageHandler,
238
+ }), `dsh-image-gen: image route ${path}`)
239
+ }
240
+
241
+ ctx.tools.register(
242
+ defineTool({
243
+ name: 'generate_image',
244
+ description:
245
+ 'Generate an image with the configured image provider. '
246
+ + 'Saves the image to the session workspace and shows it in the conversation. '
247
+ + 'Depending on how the deployment is configured the result carries either the image itself or a link to it; '
248
+ + 'when you only get a link, answer from the prompt and the link rather than claiming to see the picture. '
249
+ + 'Use for any text-to-image request.',
250
+ parameters: {
251
+ prompt: {
252
+ type: 'string',
253
+ required: true,
254
+ description: 'Detailed description of the image to generate (subject, style, lighting, composition, colors).',
255
+ },
256
+ image_size: {
257
+ type: 'string',
258
+ description: `One of: ${IMAGE_SIZES.join(', ')}. Default: ${config.defaultSize}.`,
259
+ },
260
+ seed: {
261
+ type: 'integer',
262
+ description: 'Optional seed for reproducible output.',
263
+ },
264
+ output_format: {
265
+ type: 'string',
266
+ enum: OUTPUT_FORMATS,
267
+ description: `Output format. Default: ${config.defaultFormat}.`,
268
+ },
269
+ output_name: {
270
+ type: 'string',
271
+ description: 'Optional file name stem for the saved image (defaults to a slug of the prompt).',
272
+ },
273
+ },
274
+ output: {
275
+ schema: {
276
+ type: 'object',
277
+ additionalProperties: false,
278
+ properties: {
279
+ path: { type: 'string' },
280
+ url: { type: 'string' },
281
+ width: { type: 'integer' },
282
+ height: { type: 'integer' },
283
+ seed: { type: 'integer' },
284
+ prompt: { type: 'string' },
285
+ format: { type: 'string' },
286
+ attachment: {
287
+ type: 'object',
288
+ additionalProperties: false,
289
+ properties: {
290
+ attachmentId: { type: 'string' },
291
+ mediaType: { type: 'string' },
292
+ bytes: { type: 'integer' },
293
+ width: { type: 'integer' },
294
+ height: { type: 'integer' },
295
+ name: { type: 'string' },
296
+ },
297
+ },
298
+ },
299
+ },
300
+ render(args, value) {
301
+ const summary = `Image generated (${value.width}×${value.height}, ${value.format}, seed ${value.seed}): ${value.path}`
302
+ // "link": the result stays text-only, so a chat model without vision
303
+ // reads it fine; the card turns the link back into a picture.
304
+ // "image": the picture travels in the result itself, which only a
305
+ // vision-capable model — or dsh-vision-bridge — can handle.
306
+ if (live().deliverAs !== 'image') {
307
+ return [{ type: 'text', text: value.url ? `${summary}\n${value.url}` : summary }]
308
+ }
309
+ const blocks = [{ type: 'text', text: summary }]
310
+ if (value.attachment) blocks.push({ type: 'image', attachment: value.attachment })
311
+ return blocks
312
+ },
313
+ },
314
+ isConcurrencySafe: () => false,
315
+ timeoutMs: config.timeoutMs + 30000,
316
+ async execute(args, exec) {
317
+ const cfg = live()
318
+ const provider = PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal'
319
+ const deliverAs = cfg.deliverAs
320
+ const size = args.image_size ?? cfg.defaultSize
321
+ if (!IMAGE_SIZES.includes(size)) {
322
+ throw new Error(`Invalid image_size "${size}". One of: ${IMAGE_SIZES.join(', ')}`)
323
+ }
324
+ const format = args.output_format ?? cfg.defaultFormat
325
+ if (!OUTPUT_FORMATS.includes(format)) {
326
+ throw new Error(`Invalid output_format "${format}". One of: ${OUTPUT_FORMATS.join(', ')}`)
327
+ }
328
+ // Служба подписок необязательна: без неё эти два провайдера просто
329
+ // отказываются, а остальные работают как работали.
330
+ let subscriptionImages
331
+ try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (noService) { subscriptionImages = undefined }
332
+
333
+ const providers = makeProviders(
334
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
335
+ { prompt: args.prompt, size, format, seed: args.seed, signal: exec.signal },
336
+ )
337
+ const generated = await providers[provider]()
338
+ const bytes = generated.bytes
339
+
340
+ const mediaType = generated.mediaType
341
+ const extension = mediaType === 'image/jpeg' ? 'jpg' : mediaType === 'image/webp' ? 'webp' : 'png'
342
+ const stem = `${slugify(args.output_name || args.prompt)}-${Date.now().toString(36)}`
343
+ const name = `${stem}.${extension}`
344
+
345
+ const attachment = await ctx.attachments.saveImage({
346
+ data: new Uint8Array(bytes),
347
+ mediaType,
348
+ name,
349
+ })
350
+
351
+ // Relative outputDir belongs to the session's workspace, not to the
352
+ // directory the harness happens to be started from.
353
+ const sessionCwd = exec.agent?.session?.header?.cwd
354
+ const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir)
355
+ await mkdir(outDir, { recursive: true })
356
+ const filePath = path.join(outDir, name)
357
+ await writeFile(filePath, bytes)
358
+
359
+ // The plugin's own route outlives the fal.media link, and the card
360
+ // needs the reference anyway to verify the bytes.
361
+ const localUrl = '/dsh-image-gen/image?id=' + encodeURIComponent(attachment.attachmentId)
362
+ + '&mt=' + encodeURIComponent(attachment.mediaType)
363
+ + '&b=' + encodeURIComponent(String(attachment.bytes))
364
+ + '&w=' + encodeURIComponent(String(attachment.width))
365
+ + '&h=' + encodeURIComponent(String(attachment.height))
366
+
367
+ return {
368
+ path: filePath,
369
+ // Прямой ссылки может и не быть: провайдер, отдающий base64, ничего
370
+ // не публикует. Тогда и в режиме "image" остаётся наша ссылка.
371
+ url: deliverAs === 'image' && generated.sourceUrl ? generated.sourceUrl : localUrl,
372
+ width: generated.width || attachment.width,
373
+ height: generated.height || attachment.height,
374
+ seed: generated.seed,
375
+ prompt: args.prompt,
376
+ format: mediaType.replace('image/', ''),
377
+ attachment: {
378
+ attachmentId: attachment.attachmentId,
379
+ mediaType: attachment.mediaType,
380
+ bytes: attachment.bytes,
381
+ width: attachment.width,
382
+ height: attachment.height,
383
+ name: attachment.name,
384
+ },
385
+ }
386
+ },
387
+ }),
388
+ )
389
+ }
@@ -0,0 +1,266 @@
1
+ // Провайдеры генерации изображений.
2
+ //
3
+ // Провайдер получает задание и возвращает готовые байты картинки. Всё, что
4
+ // происходит дальше — вложение, файл в рабочей папке, ссылка и карточка
5
+ // в разговоре — общее для всех провайдеров и живёт в index.js.
6
+ //
7
+ // Сеть приходит параметром (fetchImpl), ключ — через resolveKey, поэтому оба
8
+ // провайдера проверяются юнит-тестами без единого реального запроса.
9
+
10
+ export const PROVIDER_KEYS = ['fal', 'custom', 'codex', 'grok']
11
+
12
+ // Размеры, которыми оперируют подписки, — свой набор, не похожий на именованные
13
+ // размеры FAL. Перевод один к одному по смыслу: квадрат, вертикаль, горизонталь.
14
+ export const SUBSCRIPTION_SIZES = {
15
+ square_hd: '1024x1024',
16
+ square: '1024x1024',
17
+ portrait_4_3: '1024x1536',
18
+ portrait_16_9: '1024x1536',
19
+ landscape_4_3: '1536x1024',
20
+ landscape_16_9: '1536x1024',
21
+ }
22
+
23
+ /** Image sizes accepted by fal-ai/flux-2/klein (and most FAL flux models). */
24
+ export const IMAGE_SIZES = [
25
+ 'square_hd',
26
+ 'square',
27
+ 'portrait_4_3',
28
+ 'portrait_16_9',
29
+ 'landscape_4_3',
30
+ 'landscape_16_9',
31
+ ]
32
+
33
+ /** Output formats accepted by the model. */
34
+ export const OUTPUT_FORMATS = ['png', 'jpeg', 'webp']
35
+
36
+ // Именованные размеры — единый язык инструмента: он не должен меняться от того,
37
+ // какой провайдер включён. FAL понимает их как есть, OpenAI-совместимые API
38
+ // хотят ШxВ — для них перевод.
39
+ export const SIZE_PIXELS = {
40
+ square_hd: '1024x1024',
41
+ square: '512x512',
42
+ portrait_4_3: '768x1024',
43
+ portrait_16_9: '576x1024',
44
+ landscape_4_3: '1024x768',
45
+ landscape_16_9: '1024x576',
46
+ }
47
+
48
+ /** Normalize a raw key into a FAL `Authorization: Key <key>` value. */
49
+ export function falAuthHeader(key) {
50
+ const trimmed = String(key ?? '').trim()
51
+ if (!trimmed) return ''
52
+ return trimmed.startsWith('Key ') || trimmed.startsWith('key ')
53
+ ? trimmed
54
+ : `Key ${trimmed}`
55
+ }
56
+
57
+ /** Map a content type onto the attachment service's supported set. */
58
+ export function normalizeMediaType(contentType, fallbackFormat) {
59
+ const raw = String(contentType ?? '').toLowerCase()
60
+ if (raw.includes('jpeg') || raw.includes('jpg')) return 'image/jpeg'
61
+ if (raw.includes('webp')) return 'image/webp'
62
+ if (raw.includes('png')) return 'image/png'
63
+ if (fallbackFormat === 'jpeg') return 'image/jpeg'
64
+ if (fallbackFormat === 'webp') return 'image/webp'
65
+ return 'image/png'
66
+ }
67
+
68
+ /** Submit a generation job to the FAL queue. */
69
+ export async function submitJob(fetchImpl, baseURL, model, key, body, signal) {
70
+ const res = await fetchImpl(`${baseURL}/${model}`, {
71
+ method: 'POST',
72
+ headers: {
73
+ Authorization: falAuthHeader(key),
74
+ 'Content-Type': 'application/json',
75
+ },
76
+ body: JSON.stringify(body),
77
+ signal,
78
+ })
79
+ const data = await res.json().catch(() => ({}))
80
+ if (!res.ok || !data.request_id) {
81
+ const detail = typeof data.detail === 'string' ? data.detail : JSON.stringify(data).slice(0, 600)
82
+ throw new Error(`FAL submit failed (HTTP ${res.status}): ${detail}`)
83
+ }
84
+ return data
85
+ }
86
+
87
+ /** Poll the FAL status endpoint until completion, failure, timeout, or abort. */
88
+ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollIntervalMs, timeoutMs) {
89
+ const deadline = Date.now() + timeoutMs
90
+ for (;;) {
91
+ if (signal.aborted) throw new Error('FAL generation cancelled')
92
+ if (Date.now() > deadline) throw new Error(`FAL generation timed out after ${timeoutMs} ms`)
93
+ await new Promise((resolve) => {
94
+ const timer = setTimeout(resolve, pollIntervalMs)
95
+ signal.addEventListener('abort', () => {
96
+ clearTimeout(timer)
97
+ resolve()
98
+ }, { once: true })
99
+ })
100
+ if (signal.aborted) throw new Error('FAL generation cancelled')
101
+ const res = await fetchImpl(statusUrl, { headers: { Authorization: falAuthHeader(key) }, signal })
102
+ const data = await res.json().catch(() => ({}))
103
+ const status = data.status
104
+ if (status === 'COMPLETED') return data
105
+ if (status === 'ERROR' || data.error || data.detail) {
106
+ throw new Error(`FAL generation failed: ${JSON.stringify(data).slice(0, 600)}`)
107
+ }
108
+ if (status !== 'IN_QUEUE' && status !== 'IN_PROGRESS') {
109
+ throw new Error(`Unexpected FAL status "${status}": ${JSON.stringify(data).slice(0, 300)}`)
110
+ }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * @param deps {{fetchImpl: Function, resolveKey: (ref: string) => Promise<string>, cfg: object}}
116
+ * @param job {{prompt: string, size: string, format: string, seed: number|undefined, signal: AbortSignal}}
117
+ * @returns провайдеры по ключу; каждый отдаёт
118
+ * {bytes, mediaType, width, height, seed, sourceUrl}. Нулевые width/height
119
+ * означают «спроси у службы вложений»: она всё равно измеряет байты сама.
120
+ */
121
+ export function makeProviders(deps, job) {
122
+ const { fetchImpl, resolveKey, cfg } = deps
123
+ const { prompt, size, format, seed, signal } = job
124
+
125
+ async function fal() {
126
+ const key = await resolveKey(cfg.apiKeyEnv)
127
+ const body = { prompt, image_size: size, num_images: 1 }
128
+ if (seed !== undefined) body.seed = seed
129
+ if (format !== 'png') body.output_format = format
130
+
131
+ const submit = await submitJob(fetchImpl, cfg.baseURL, cfg.model, key, body, signal)
132
+ const statusUrl = submit.status_url || `${cfg.baseURL}/${cfg.model}/requests/${submit.request_id}/status`
133
+ const statusBody = await pollStatus(fetchImpl, statusUrl, key, signal, cfg.pollIntervalMs, cfg.timeoutMs)
134
+
135
+ const resultRes = await fetchImpl(statusBody.response_url, {
136
+ headers: { Authorization: falAuthHeader(key) },
137
+ signal,
138
+ })
139
+ const result = await resultRes.json().catch(() => ({}))
140
+ const image = result.images && result.images[0]
141
+ if (!image || !image.url) {
142
+ throw new Error(`FAL returned no images: ${JSON.stringify(result).slice(0, 600)}`)
143
+ }
144
+ const download = await fetchImpl(image.url, { signal })
145
+ if (!download.ok) {
146
+ throw new Error(`Failed to download generated image (HTTP ${download.status})`)
147
+ }
148
+ return {
149
+ bytes: Buffer.from(await download.arrayBuffer()),
150
+ mediaType: normalizeMediaType(image.content_type, format),
151
+ width: image.width ?? 0,
152
+ height: image.height ?? 0,
153
+ seed: result.seed ?? seed ?? 0,
154
+ sourceUrl: image.url,
155
+ }
156
+ }
157
+
158
+ // Любой OpenAI-совместимый API картинок. Один запрос вместо очереди FAL.
159
+ async function custom() {
160
+ const base = String(cfg.customBaseURL || '').replace(/\/+$/, '')
161
+ if (!base) throw new Error('Custom image provider: base URL is not configured (Settings → Image generation)')
162
+ if (!cfg.customModel) throw new Error('Custom image provider: model is not configured')
163
+
164
+ // Пустая ссылка на ключ — значит провайдер без авторизации, например
165
+ // локальный шлюз. Это законный случай, а не недонастройка.
166
+ const key = cfg.customKeyEnv ? await resolveKey(cfg.customKeyEnv) : ''
167
+ const headers = { 'Content-Type': 'application/json' }
168
+ if (key) headers.Authorization = `Bearer ${key}`
169
+
170
+ // response_format не отправляем: новые модели OpenAI его отвергают, а ответ
171
+ // всё равно приходит либо base64, либо ссылкой — принимаем оба.
172
+ const res = await fetchImpl(`${base}/images/generations`, {
173
+ method: 'POST',
174
+ headers,
175
+ body: JSON.stringify({
176
+ model: cfg.customModel,
177
+ prompt,
178
+ n: 1,
179
+ size: cfg.customSize || SIZE_PIXELS[size] || size,
180
+ }),
181
+ signal,
182
+ })
183
+ const data = await res.json().catch(() => ({}))
184
+ if (!res.ok) {
185
+ const detail = data?.error?.message || JSON.stringify(data).slice(0, 600)
186
+ throw new Error(`Image API failed (HTTP ${res.status}): ${detail}`)
187
+ }
188
+ const item = data?.data?.[0]
189
+ if (!item) {
190
+ throw new Error(`Image API returned no images: ${JSON.stringify(data).slice(0, 600)}`)
191
+ }
192
+
193
+ if (item.b64_json) {
194
+ return {
195
+ bytes: Buffer.from(item.b64_json, 'base64'),
196
+ mediaType: normalizeMediaType(data.output_format || '', format),
197
+ width: 0,
198
+ height: 0,
199
+ seed: seed ?? 0,
200
+ sourceUrl: '',
201
+ }
202
+ }
203
+ if (!item.url) {
204
+ throw new Error(`Image API returned neither b64_json nor url: ${JSON.stringify(item).slice(0, 300)}`)
205
+ }
206
+ const download = await fetchImpl(item.url, { signal })
207
+ if (!download.ok) {
208
+ throw new Error(`Failed to download generated image (HTTP ${download.status})`)
209
+ }
210
+ const contentType = download.headers && typeof download.headers.get === 'function'
211
+ ? download.headers.get('content-type')
212
+ : ''
213
+ return {
214
+ bytes: Buffer.from(await download.arrayBuffer()),
215
+ mediaType: normalizeMediaType(contentType, format),
216
+ width: 0,
217
+ height: 0,
218
+ seed: seed ?? 0,
219
+ sourceUrl: item.url,
220
+ }
221
+ }
222
+
223
+ // Генерация на подписке: аккаунт и токен живут в плагине подписок, здесь
224
+ // только запрос и разбор ответа. Токен сюда не попадает вовсе — служба
225
+ // отдаёт готовую картинку, а не ключ доступа.
226
+ function subscription(provider) {
227
+ return async function generate() {
228
+ const images = deps.subscriptionImages
229
+ if (!images || typeof images.generate !== 'function') {
230
+ return {
231
+ ok: false,
232
+ provider,
233
+ reason: `${provider}: нужен плагин подписок — он держит вход в аккаунт`,
234
+ }
235
+ }
236
+ let produced
237
+ try {
238
+ produced = await images.generate({
239
+ provider,
240
+ prompt,
241
+ size: SUBSCRIPTION_SIZES[size] || '1024x1024',
242
+ quality: cfg.subscriptionQuality || undefined,
243
+ signal,
244
+ })
245
+ } catch (e) {
246
+ return { ok: false, provider, reason: `${provider}: ${String(e && e.message || e)}` }
247
+ }
248
+ const first = Array.isArray(produced) ? produced[0] : null
249
+ if (!first || !first.b64_json) {
250
+ return { ok: false, provider, reason: `${provider}: в ответе нет картинки` }
251
+ }
252
+ return {
253
+ bytes: Buffer.from(first.b64_json, 'base64'),
254
+ // Подписки отдают png; формата в ответе нет, поэтому объявляем прямо.
255
+ mediaType: 'image/png',
256
+ width: 0,
257
+ height: 0,
258
+ seed: seed ?? 0,
259
+ sourceUrl: '',
260
+ revisedPrompt: first.revisedPrompt || '',
261
+ }
262
+ }
263
+ }
264
+
265
+ return { fal, custom, codex: subscription('codex'), grok: subscription('grok') }
266
+ }