egaki 0.0.2 → 0.1.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/src/cli.ts CHANGED
@@ -10,7 +10,11 @@
10
10
  import { goke } from 'goke'
11
11
  import { z } from 'zod'
12
12
  import dedent from 'string-dedent'
13
- import { generateImage as aiGenerateImage, generateText } from 'ai'
13
+ import {
14
+ generateImage as aiGenerateImage,
15
+ generateText,
16
+ experimental_generateVideo as aiGenerateVideo,
17
+ } from 'ai'
14
18
  import fs from 'node:fs'
15
19
  import path from 'node:path'
16
20
  import pc from 'picocolors'
@@ -18,12 +22,15 @@ import pkg from '../package.json' with { type: 'json' }
18
22
  import { injectCredentialsToEnv, PROVIDERS } from './credentials.js'
19
23
  import {
20
24
  IMAGE_MODELS,
25
+ VIDEO_MODELS,
21
26
  DEFAULT_MODEL,
27
+ DEFAULT_VIDEO_MODEL,
22
28
  getModelConfig,
23
29
  createImageModel,
24
30
  createTextModel,
31
+ createVideoModel,
25
32
  } from './models.js'
26
- import type { ModelCost } from './model-catalog.js'
33
+ import { VIDEO_CATALOG } from './video-model-catalog.js'
27
34
  import {
28
35
  loginInteractive,
29
36
  loginNonInteractive,
@@ -131,13 +138,13 @@ cli
131
138
  Egaki subscription avoids managing one key per provider.
132
139
  Three plans: Starter ($9/mo, 100 credits), Pro ($29/mo, 500 credits),
133
140
  Unlimited ($99/mo, 2000 credits). One credit ≈ one standard image.
134
- Interactive mode: pick a plan, enter email, get checkout URL.
135
- Non-interactive: pass --email to get the checkout URL directly.
141
+ Interactive mode: pick a plan and get a checkout URL (email prefill optional).
142
+ Non-interactive: --email is optional and only pre-fills checkout.
136
143
  `,
137
144
  )
138
145
  .option(
139
146
  '-e, --email [email]',
140
- z.string().describe('Email for the subscription (skips interactive prompt)'),
147
+ z.string().describe('Optional email prefill for checkout (skips interactive prompt)'),
141
148
  )
142
149
  .option(
143
150
  '--plan [plan]',
@@ -147,6 +154,8 @@ cli
147
154
  .example('egaki subscribe')
148
155
  .example('# Non-interactive (for agents)')
149
156
  .example('egaki subscribe --email user@example.com --plan pro')
157
+ .example('# Non-interactive without email prefill')
158
+ .example('egaki subscribe --plan pro')
150
159
  .action(async (options) => {
151
160
  const isTTY = process.stdout.isTTY && process.stdin.isTTY
152
161
  if (!isTTY || options.email || options.plan) {
@@ -331,6 +340,118 @@ cli
331
340
  }
332
341
  })
333
342
 
343
+ // ─── video command ───────────────────────────────────────────────────────────
344
+
345
+ cli
346
+ .command(
347
+ 'video <prompt>',
348
+ dedent`
349
+ Generate videos from a text prompt (or image+text prompt for models that
350
+ support image-to-video). Uses AI SDK experimental_generateVideo under the hood.
351
+
352
+ Agent note: video generation can be slow. When invoking this command from
353
+ automation, use a command timeout of at least 5 minutes.
354
+ `,
355
+ )
356
+ .option(
357
+ '-m, --model [model]',
358
+ z.enum(VIDEO_MODELS).default(DEFAULT_VIDEO_MODEL).describe('Video model ID for generation'),
359
+ )
360
+ .option(
361
+ '-o, --output [path]',
362
+ z
363
+ .string()
364
+ .default('egaki-output.mp4')
365
+ .describe('Output file path (index suffix added when generating multiple)'),
366
+ )
367
+ .option(
368
+ '-n, --count [n]',
369
+ z.number().default(1).describe('Number of videos to generate'),
370
+ )
371
+ .option(
372
+ '--aspect-ratio [ratio]',
373
+ z.string().describe('Video aspect ratio in WIDTH:HEIGHT format (e.g. 16:9, 9:16)'),
374
+ )
375
+ .option(
376
+ '--resolution [resolution]',
377
+ z
378
+ .string()
379
+ .describe('Video resolution in WIDTHxHEIGHT or provider format (e.g. 1280x720, 720p)'),
380
+ )
381
+ .option(
382
+ '--duration [seconds]',
383
+ z.number().describe('Video duration in seconds (provider/model-specific limits apply)'),
384
+ )
385
+ .option(
386
+ '--fps [fps]',
387
+ z.number().describe('Frames per second for video models that support fps override'),
388
+ )
389
+ .option(
390
+ '--seed [seed]',
391
+ z.number().describe('Seed for reproducible video generation (model support varies)'),
392
+ )
393
+ .option(
394
+ '-i, --input [file]',
395
+ z
396
+ .string()
397
+ .describe(
398
+ 'Optional reference image for image-to-video. Accepts local file path or URL (http/https)',
399
+ ),
400
+ )
401
+ .option(
402
+ '--json',
403
+ 'Output result metadata as JSON to stdout (model, usage, warnings, file paths)',
404
+ )
405
+ .option(
406
+ '--stdout',
407
+ 'Write raw video bytes to stdout instead of saving to a file. Useful for piping to other tools',
408
+ )
409
+ .example('# Generate a video')
410
+ .example('egaki video "A paper airplane gliding through clouds" -o airplane.mp4')
411
+ .example('# Generate with Veo model + duration')
412
+ .example('egaki video "cinematic rainy street at night" -m veo-3.1-fast-generate-001 --duration 6')
413
+ .example('# Image-to-video (model support required)')
414
+ .example('egaki video "animate subtle camera pan" --model luma-ray-2 --input frame.png -o animated.mp4')
415
+ .example('# Generate multiple videos')
416
+ .example('egaki video "waves crashing on cliffs" -n 2 -o waves.mp4')
417
+ .action(async (prompt, options) => {
418
+ injectCredentialsToEnv()
419
+
420
+ const model = options.model
421
+ const outputPath = options.output
422
+ const config = getModelConfig(model)
423
+
424
+ if (config.strategy !== 'video') {
425
+ console.error(pc.red(`Model ${model} is not a video model`))
426
+ process.exit(1)
427
+ }
428
+
429
+ if (!options.stdout) {
430
+ console.error(pc.dim(`Model: ${model}`))
431
+ console.error(pc.dim(`Prompt: ${prompt}`))
432
+ console.error(pc.dim('Mode: Video API (experimental_generateVideo)'))
433
+ }
434
+
435
+ const inputImage = options.input
436
+ ? await readInputSource(options.input)
437
+ : undefined
438
+
439
+ await generateWithVideoModel({
440
+ prompt,
441
+ model,
442
+ outputPath,
443
+ count: options.count,
444
+ aspectRatio: options.aspectRatio,
445
+ resolution: options.resolution,
446
+ duration: options.duration,
447
+ fps: options.fps,
448
+ seed: options.seed,
449
+ inputImage,
450
+ json: options.json || false,
451
+ stdout: options.stdout || false,
452
+ })
453
+ })
454
+
334
455
  // ─── models command ──────────────────────────────────────────────────────────
335
456
 
336
457
  cli
@@ -345,12 +466,24 @@ cli
345
466
  '-p, --provider [provider]',
346
467
  z.string().describe('Filter models by provider name (e.g. google, openai, replicate, fal)'),
347
468
  )
469
+ .option(
470
+ '--type [type]',
471
+ z
472
+ .enum(['all', 'image', 'video'])
473
+ .default('all')
474
+ .describe('Filter by model type: image (image+text-image), video, or all'),
475
+ )
348
476
  .option('--json', 'Output as JSON instead of YAML')
349
477
  .action(async (options) => {
350
478
  const { CATALOG } = await import('./model-catalog.js')
351
479
  const yaml = await import('js-yaml')
352
480
 
353
- let models = CATALOG
481
+ let models = options.type === 'video'
482
+ ? VIDEO_CATALOG
483
+ : options.type === 'image'
484
+ ? CATALOG
485
+ : [...CATALOG, ...VIDEO_CATALOG]
486
+
354
487
  if (options.provider) {
355
488
  models = models.filter((m) => m.provider === options.provider)
356
489
  if (models.length === 0) {
@@ -366,18 +499,29 @@ cli
366
499
  provider: m.provider,
367
500
  strategy: m.strategy,
368
501
  released: m.released,
369
- cost:
370
- m.cost.type === 'per-image'
371
- ? `$${m.cost.perImage}/image`
372
- : `$${m.cost.inputPerM}/M input, $${m.cost.outputPerM}/M output`,
373
- features: {
374
- editing: m.features.editing,
375
- inpainting: m.features.inpainting,
376
- seed: m.features.seed,
377
- multipleImages: m.features.multipleImages,
378
- aspectRatios: m.features.aspectRatios.join(', ') || 'none',
379
- ...(m.features.sizes ? { sizes: m.features.sizes.join(', ') } : {}),
380
- },
502
+ cost: formatCatalogCost(m.cost),
503
+ features:
504
+ m.strategy === 'video'
505
+ ? {
506
+ textToVideo: m.features.textToVideo,
507
+ imageToVideo: m.features.imageToVideo,
508
+ capabilities: m.features.capabilities.join(', ') || 'none',
509
+ seed: m.features.seed,
510
+ multipleVideos: m.features.multipleVideos,
511
+ aspectRatios: m.features.aspectRatios?.join(', ') || 'none',
512
+ resolutions: m.features.resolutions?.join(', ') || 'unknown',
513
+ durationRangeSec: m.features.durationRangeSec
514
+ ? `${m.features.durationRangeSec.min}-${m.features.durationRangeSec.max}`
515
+ : 'unknown',
516
+ }
517
+ : {
518
+ editing: m.features.editing,
519
+ inpainting: m.features.inpainting,
520
+ seed: m.features.seed,
521
+ multipleImages: m.features.multipleImages,
522
+ aspectRatios: m.features.aspectRatios.join(', ') || 'none',
523
+ ...(m.features.sizes ? { sizes: m.features.sizes.join(', ') } : {}),
524
+ },
381
525
  }))
382
526
 
383
527
  if (options.json) {
@@ -629,24 +773,187 @@ async function generateWithTextModel({
629
773
  }
630
774
  }
631
775
 
776
+ // Generate using experimental_generateVideo (video models)
777
+ async function generateWithVideoModel({
778
+ prompt,
779
+ model,
780
+ outputPath,
781
+ count,
782
+ aspectRatio,
783
+ resolution,
784
+ duration,
785
+ fps,
786
+ seed,
787
+ inputImage,
788
+ json,
789
+ stdout,
790
+ }: {
791
+ prompt: string
792
+ model: string
793
+ outputPath: string
794
+ count: number
795
+ aspectRatio?: string
796
+ resolution?: string
797
+ duration?: number
798
+ fps?: number
799
+ seed?: number
800
+ inputImage?: Uint8Array
801
+ json: boolean
802
+ stdout: boolean
803
+ }) {
804
+ const videoModel = await createVideoModel(model)
805
+
806
+ if (!stdout) {
807
+ console.error(pc.cyan('Generating...'))
808
+ }
809
+
810
+ const result = await aiGenerateVideo({
811
+ model: videoModel,
812
+ prompt: inputImage
813
+ ? { image: inputImage, text: prompt }
814
+ : prompt,
815
+ n: count,
816
+ ...(aspectRatio ? { aspectRatio: aspectRatio as `${number}:${number}` } : {}),
817
+ ...(resolution ? { resolution: resolution as `${number}x${number}` } : {}),
818
+ ...(duration != null ? { duration } : {}),
819
+ ...(fps != null ? { fps } : {}),
820
+ ...(seed != null ? { seed } : {}),
821
+ })
822
+
823
+ if (stdout) {
824
+ const video = result.videos[0]
825
+ if (video) {
826
+ process.stdout.write(Buffer.from(video.uint8Array))
827
+ }
828
+ return
829
+ }
830
+
831
+ const savedFiles: string[] = []
832
+ for (let i = 0; i < result.videos.length; i++) {
833
+ const video = result.videos[i]!
834
+ const ext = extensionFromMediaType(video.mediaType)
835
+ const filePath =
836
+ result.videos.length === 1
837
+ ? ensureExtension(outputPath, ext)
838
+ : insertIndex(outputPath, i, ext)
839
+
840
+ fs.writeFileSync(filePath, video.uint8Array)
841
+ console.error(pc.green(`Saved: ${filePath}`))
842
+ savedFiles.push(filePath)
843
+ }
844
+
845
+ const config = getModelConfig(model)
846
+ const cost = calculateCost(config.cost, {
847
+ videosGenerated: result.videos.length,
848
+ durationSeconds: duration,
849
+ resolution,
850
+ }, result.videos.length)
851
+ if (cost != null) {
852
+ console.error(pc.dim(`Cost: ${formatCost(cost)}`))
853
+ }
854
+
855
+ if (json) {
856
+ const output = {
857
+ model,
858
+ files: savedFiles,
859
+ count: result.videos.length,
860
+ cost,
861
+ warnings: result.warnings,
862
+ responses: result.responses,
863
+ }
864
+ console.log(JSON.stringify(output, null, 2))
865
+ }
866
+ }
867
+
632
868
  // ─── cost helpers ────────────────────────────────────────────────────────────
633
869
 
634
870
  function calculateCost(
635
- cost: ModelCost,
636
- usage: { inputTokens?: number; outputTokens?: number; imagesGenerated?: number },
637
- imageCount: number,
871
+ cost: {
872
+ type: 'per-image'
873
+ perImage: number
874
+ } | {
875
+ type: 'per-token'
876
+ inputPerM: number
877
+ outputPerM: number
878
+ } | {
879
+ type: 'per-video-second'
880
+ defaultDurationSec: number
881
+ tiers: Array<{ resolution?: string; costPerSecond: number }>
882
+ } | {
883
+ type: 'unknown'
884
+ },
885
+ usage: {
886
+ inputTokens?: number
887
+ outputTokens?: number
888
+ imagesGenerated?: number
889
+ videosGenerated?: number
890
+ durationSeconds?: number
891
+ resolution?: string
892
+ },
893
+ count: number = 1,
638
894
  ): number | null {
639
895
  if (cost.type === 'per-image') {
640
- return cost.perImage * imageCount
896
+ return cost.perImage * count
641
897
  }
642
898
  if (cost.type === 'per-token' && usage.inputTokens != null && usage.outputTokens != null) {
643
899
  return (
644
900
  (usage.inputTokens * cost.inputPerM + usage.outputTokens * cost.outputPerM) / 1_000_000
645
901
  )
646
902
  }
903
+ if (cost.type === 'per-video-second') {
904
+ const durationSec = usage.durationSeconds ?? cost.defaultDurationSec
905
+ const resolution = normalizeResolutionKey(usage.resolution)
906
+ const tier =
907
+ cost.tiers.find((t) => normalizeResolutionKey(t.resolution) === resolution) ??
908
+ cost.tiers[0]
909
+ if (!tier) {
910
+ return null
911
+ }
912
+ return tier.costPerSecond * durationSec * count
913
+ }
647
914
  return null
648
915
  }
649
916
 
917
+ function formatCatalogCost(
918
+ cost: {
919
+ type: 'per-image'
920
+ perImage: number
921
+ } | {
922
+ type: 'per-token'
923
+ inputPerM: number
924
+ outputPerM: number
925
+ } | {
926
+ type: 'per-video-second'
927
+ defaultDurationSec: number
928
+ tiers: Array<{ resolution?: string; mode?: string; audio?: boolean; costPerSecond: number }>
929
+ } | {
930
+ type: 'unknown'
931
+ },
932
+ ): string {
933
+ if (cost.type === 'per-image') {
934
+ return `$${cost.perImage}/image`
935
+ }
936
+ if (cost.type === 'per-token') {
937
+ return `$${cost.inputPerM}/M input, $${cost.outputPerM}/M output`
938
+ }
939
+ if (cost.type === 'per-video-second') {
940
+ const tierText = cost.tiers
941
+ .map((t) => {
942
+ const parts = [
943
+ t.resolution,
944
+ t.mode ? `mode=${t.mode}` : undefined,
945
+ t.audio != null ? `audio=${t.audio}` : undefined,
946
+ ].filter(Boolean)
947
+ return parts.length > 0
948
+ ? `$${t.costPerSecond}/s (${parts.join(', ')})`
949
+ : `$${t.costPerSecond}/s`
950
+ })
951
+ .join('; ')
952
+ return `${tierText} (default duration ${cost.defaultDurationSec}s)`
953
+ }
954
+ return 'unknown'
955
+ }
956
+
650
957
  function formatCost(dollars: number): string {
651
958
  if (dollars < 0.01) {
652
959
  return `$${dollars.toFixed(4)}`
@@ -660,8 +967,20 @@ function extensionFromMediaType(mediaType: string): string {
660
967
  'image/jpeg': '.jpg',
661
968
  'image/webp': '.webp',
662
969
  'image/gif': '.gif',
970
+ 'video/mp4': '.mp4',
971
+ 'video/webm': '.webm',
972
+ 'video/quicktime': '.mov',
663
973
  }
664
- return map[mediaType] || '.png'
974
+ return map[mediaType] || (mediaType.startsWith('video/') ? '.mp4' : '.png')
975
+ }
976
+
977
+ function normalizeResolutionKey(input?: string): string | undefined {
978
+ if (!input) return undefined
979
+ const normalized = input.trim().toLowerCase()
980
+ if (normalized === '1920x1080') return '1080p'
981
+ if (normalized === '1280x720') return '720p'
982
+ if (normalized === '854x480' || normalized === '848x480') return '480p'
983
+ return normalized
665
984
  }
666
985
 
667
986
  function ensureExtension(filePath: string, ext: string): string {
@@ -39,7 +39,7 @@ export const PROVIDERS: Record<
39
39
  },
40
40
  }
41
41
 
42
- export const EGAKI_GATEWAY_URL = 'https://egaki.org/v1/ai'
42
+ export const EGAKI_GATEWAY_URL = 'https://egaki.org/v3/ai'
43
43
 
44
44
  function getConfigDir(): string {
45
45
  // XDG_CONFIG_HOME or ~/.config/egaki
@@ -94,6 +94,21 @@ const falImage = {
94
94
  strategy: 'image' as const,
95
95
  }
96
96
 
97
+ const bflImage = {
98
+ provider: 'bfl',
99
+ strategy: 'image' as const,
100
+ }
101
+
102
+ const recraftImage = {
103
+ provider: 'recraft',
104
+ strategy: 'image' as const,
105
+ }
106
+
107
+ const xaiImage = {
108
+ provider: 'xai',
109
+ strategy: 'image' as const,
110
+ }
111
+
97
112
  const fluxAspectRatios = ['1:1', '3:4', '4:3', '9:16', '16:9', '9:21', '21:9']
98
113
 
99
114
  // ─── catalog ─────────────────────────────────────────────────────────────────
@@ -181,6 +196,166 @@ export const CATALOG: ModelEntry[] = [
181
196
  cost: { type: 'per-token', inputPerM: 0.5, outputPerM: 3.0 },
182
197
  },
183
198
 
199
+ // ── BFL (AI Gateway) ───────────────────────────────────────────────────
200
+ {
201
+ id: 'flux-kontext-max',
202
+ name: 'FLUX Kontext Max',
203
+ released: '2025-06-12',
204
+ ...bflImage,
205
+ cost: { type: 'per-image', perImage: 0.08 },
206
+ features: {
207
+ editing: true,
208
+ inpainting: false,
209
+ aspectRatios: fluxAspectRatios,
210
+ seed: true,
211
+ multipleImages: false,
212
+ },
213
+ },
214
+ {
215
+ id: 'flux-kontext-pro',
216
+ name: 'FLUX Kontext Pro',
217
+ released: '2025-06-12',
218
+ ...bflImage,
219
+ cost: { type: 'per-image', perImage: 0.04 },
220
+ features: {
221
+ editing: true,
222
+ inpainting: false,
223
+ aspectRatios: fluxAspectRatios,
224
+ seed: true,
225
+ multipleImages: false,
226
+ },
227
+ },
228
+ {
229
+ id: 'flux-pro-1.0-fill',
230
+ name: 'FLUX Pro 1.0 Fill',
231
+ released: '2024-10-15',
232
+ ...bflImage,
233
+ cost: { type: 'per-image', perImage: 0.05 },
234
+ features: {
235
+ editing: true,
236
+ inpainting: true,
237
+ aspectRatios: fluxAspectRatios,
238
+ seed: true,
239
+ multipleImages: false,
240
+ },
241
+ },
242
+ {
243
+ id: 'flux-pro-1.1',
244
+ name: 'FLUX Pro 1.1',
245
+ released: '2024-10-01',
246
+ ...bflImage,
247
+ cost: { type: 'per-image', perImage: 0.04 },
248
+ features: {
249
+ editing: false,
250
+ inpainting: false,
251
+ aspectRatios: fluxAspectRatios,
252
+ seed: true,
253
+ multipleImages: false,
254
+ },
255
+ },
256
+ {
257
+ id: 'flux-pro-1.1-ultra',
258
+ name: 'FLUX Pro 1.1 Ultra',
259
+ released: '2024-11-06',
260
+ ...bflImage,
261
+ cost: { type: 'per-image', perImage: 0.06 },
262
+ features: {
263
+ editing: false,
264
+ inpainting: false,
265
+ aspectRatios: fluxAspectRatios,
266
+ seed: true,
267
+ multipleImages: false,
268
+ },
269
+ },
270
+
271
+ // ── Recraft (AI Gateway) ───────────────────────────────────────────────
272
+ {
273
+ id: 'recraft-v2',
274
+ name: 'Recraft v2',
275
+ released: '2024-08-19',
276
+ ...recraftImage,
277
+ cost: { type: 'per-image', perImage: 0.04 },
278
+ features: {
279
+ editing: false,
280
+ inpainting: false,
281
+ aspectRatios: ['1:1', '3:4', '4:3', '9:16', '16:9'],
282
+ seed: false,
283
+ multipleImages: false,
284
+ },
285
+ },
286
+ {
287
+ id: 'recraft-v3',
288
+ name: 'Recraft v3 (AI Gateway)',
289
+ released: '2024-10-29',
290
+ ...recraftImage,
291
+ cost: { type: 'per-image', perImage: 0.04 },
292
+ features: {
293
+ editing: false,
294
+ inpainting: false,
295
+ aspectRatios: ['1:1', '3:4', '4:3', '9:16', '16:9'],
296
+ seed: false,
297
+ multipleImages: false,
298
+ },
299
+ },
300
+ {
301
+ id: 'recraft-v4',
302
+ name: 'Recraft v4',
303
+ released: '2025-10-08',
304
+ ...recraftImage,
305
+ cost: { type: 'per-image', perImage: 0.04 },
306
+ features: {
307
+ editing: false,
308
+ inpainting: false,
309
+ aspectRatios: ['1:1', '3:4', '4:3', '9:16', '16:9'],
310
+ seed: false,
311
+ multipleImages: false,
312
+ },
313
+ },
314
+ {
315
+ id: 'recraft-v4-pro',
316
+ name: 'Recraft v4 Pro',
317
+ released: '2025-10-08',
318
+ ...recraftImage,
319
+ cost: { type: 'per-image', perImage: 0.25 },
320
+ features: {
321
+ editing: false,
322
+ inpainting: false,
323
+ aspectRatios: ['1:1', '3:4', '4:3', '9:16', '16:9'],
324
+ seed: false,
325
+ multipleImages: false,
326
+ },
327
+ },
328
+
329
+ // ── xAI (AI Gateway) ────────────────────────────────────────────────────
330
+ {
331
+ id: 'grok-imagine-image',
332
+ name: 'Grok Imagine Image',
333
+ released: '2026-03',
334
+ ...xaiImage,
335
+ cost: { type: 'per-image', perImage: 0.02 },
336
+ features: {
337
+ editing: true,
338
+ inpainting: false,
339
+ aspectRatios: ['1:1', '3:4', '4:3', '9:16', '16:9'],
340
+ seed: false,
341
+ multipleImages: true,
342
+ },
343
+ },
344
+ {
345
+ id: 'grok-imagine-image-pro',
346
+ name: 'Grok Imagine Image Pro',
347
+ released: '2026-03',
348
+ ...xaiImage,
349
+ cost: { type: 'per-image', perImage: 0.07 },
350
+ features: {
351
+ editing: true,
352
+ inpainting: false,
353
+ aspectRatios: ['1:1', '3:4', '4:3', '9:16', '16:9'],
354
+ seed: false,
355
+ multipleImages: true,
356
+ },
357
+ },
358
+
184
359
  // ── OpenAI ─────────────────────────────────────────────────────────────
185
360
  {
186
361
  id: 'dall-e-2',