egaki 0.5.0 → 0.6.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
@@ -11,10 +11,18 @@ import { goke } from 'goke'
11
11
  import { z } from 'zod'
12
12
  import dedent from 'string-dedent'
13
13
  import {
14
+ APICallError,
14
15
  generateImage as aiGenerateImage,
15
16
  generateText,
16
17
  experimental_generateVideo as aiGenerateVideo,
18
+ NoImageGeneratedError,
19
+ NoVideoGeneratedError,
20
+ RetryError,
17
21
  } from 'ai'
22
+ import type { XaiImageModelOptions, XaiVideoModelOptions } from '@ai-sdk/xai'
23
+ import type { GoogleImageModelOptions, GoogleLanguageModelOptions, GoogleVideoModelOptions } from '@ai-sdk/google'
24
+ import type { GoogleVertexImageModelOptions, GoogleVertexVideoModelOptions } from '@ai-sdk/google-vertex'
25
+ import type { FalImageModelOptions, FalVideoModelOptions } from '@ai-sdk/fal'
18
26
  import { select, isCancel, cancel } from '@clack/prompts'
19
27
  import fs from 'node:fs'
20
28
  import path from 'node:path'
@@ -39,8 +47,11 @@ import {
39
47
  createVideoModel,
40
48
  shouldUseResponsesApi,
41
49
  } from './models.js'
42
- import { CATALOG } from './model-catalog.js'
43
- import { VIDEO_CATALOG } from './video-model-catalog.js'
50
+ import {
51
+ CATALOG,
52
+ VIDEO_CATALOG,
53
+ describeProviderValues,
54
+ } from './model-catalog.js'
44
55
  import {
45
56
  loginInteractive,
46
57
  loginNonInteractive,
@@ -56,23 +67,31 @@ import {
56
67
  } from './subscription.js'
57
68
  import { getValidChatGptAuth } from './chatgpt-auth.js'
58
69
 
70
+ // OpenAI doesn't export a providerOptions type for image models yet (vercel/ai#12437).
71
+ // These fields are what providerOptions.openai accepts, derived from
72
+ // node_modules/@ai-sdk/openai/src/image/openai-image-model-options.ts.
73
+ // NOTE: The SDK uses camelCase keys in providerOptions, then internally maps to
74
+ // snake_case for the OpenAI API. Always use camelCase here.
75
+ type OpenAIImageProviderOptions = {
76
+ quality?: 'standard' | 'low' | 'medium' | 'high' | 'auto'
77
+ outputFormat?: 'png' | 'jpeg' | 'webp'
78
+ outputCompression?: number
79
+ size?: `${number}x${number}`
80
+ partialImages?: number | null
81
+ background?: 'auto' | 'opaque' | 'transparent'
82
+ }
83
+
59
84
  const cli = goke('egaki')
60
85
 
61
86
  process.title = 'egaki'
62
87
 
63
- // Print clean error output for unhandled rejections (e.g. AI SDK APICallError).
64
- // The AI SDK errors include request bodies, response headers, and other noisy
65
- // properties that get dumped by Node's default handler. We strip those and just
66
- // print the message + stack trace.
67
88
  process.on('uncaughtException', (err) => {
68
- console.error(pc.red(err.message))
69
- if (err.stack) {
70
- const lines = err.stack.split('\n')
71
- const stackOnly = lines.filter((l) => l.trimStart().startsWith('at '))
72
- if (stackOnly.length > 0) {
73
- console.error(pc.dim(stackOnly.join('\n')))
74
- }
75
- }
89
+ printErrorDetails(err)
90
+ process.exit(1)
91
+ })
92
+
93
+ process.on('unhandledRejection', (reason) => {
94
+ printErrorDetails(reason)
76
95
  process.exit(1)
77
96
  })
78
97
 
@@ -129,8 +148,8 @@ cli
129
148
 
130
149
  // Non-interactive: --provider + --key or stdin
131
150
  if (options.provider) {
132
- // ChatGPT uses browser OAuth — skip key reading
133
- if (options.provider === 'chatgpt') {
151
+ // OAuth providers use browser flow — skip key reading
152
+ if (options.provider === 'chatgpt' || options.provider === 'xai-oauth') {
134
153
  await loginNonInteractive({ provider: options.provider, key: '' })
135
154
  return
136
155
  }
@@ -227,7 +246,7 @@ cli
227
246
  )
228
247
  .option(
229
248
  '-m, --model [model]',
230
- z.enum(IMAGE_MODELS).describe('Model ID for generation. If omitted, shows an interactive picker (or uses default in non-TTY mode)'),
249
+ z.string().describe('Model ID for generation. If omitted, shows an interactive picker (or uses default in non-TTY mode)'),
231
250
  )
232
251
  .option(
233
252
  '-o, --output [path]',
@@ -276,6 +295,38 @@ cli
276
295
  'Mask image for inpainting. Accepts a local file path or URL (http/https). White areas in the mask are replaced with generated content. Used together with --input',
277
296
  ),
278
297
  )
298
+ .option(
299
+ '--quality [level]',
300
+ z
301
+ .string()
302
+ .describe(
303
+ `Image quality level. ${describeProviderValues('quality', [CATALOG])}`,
304
+ ),
305
+ )
306
+ .option(
307
+ '--resolution [res]',
308
+ z
309
+ .string()
310
+ .describe(
311
+ `Output resolution. ${describeProviderValues('resolution', [CATALOG])}`,
312
+ ),
313
+ )
314
+ .option(
315
+ '--output-format [format]',
316
+ z
317
+ .string()
318
+ .describe(
319
+ `Output image format. ${describeProviderValues('output-format', [CATALOG])}`,
320
+ ),
321
+ )
322
+ .option(
323
+ '--negative-prompt [text]',
324
+ z
325
+ .string()
326
+ .describe(
327
+ 'Describe what to avoid in the generated image (Fal models)',
328
+ ),
329
+ )
279
330
  .option(
280
331
  '--allow-people',
281
332
  'Allow generating images of people (Imagen blocks people by default)',
@@ -358,6 +409,10 @@ cli
358
409
  inputImages,
359
410
  maskImage,
360
411
  allowPeople: options.allowPeople || false,
412
+ quality: options.quality,
413
+ resolution: options.resolution,
414
+ outputFormat: options.outputFormat,
415
+ negativePrompt: options.negativePrompt,
361
416
  json: options.json || false,
362
417
  stdout: options.stdout || false,
363
418
  })
@@ -390,7 +445,7 @@ cli
390
445
  )
391
446
  .option(
392
447
  '-m, --model [model]',
393
- z.enum(VIDEO_MODELS).describe('Video model ID for generation. If omitted, shows an interactive picker (or uses default in non-TTY mode)'),
448
+ z.string().describe('Video model ID for generation. If omitted, shows an interactive picker (or uses default in non-TTY mode)'),
394
449
  )
395
450
  .option(
396
451
  '-o, --output [path]',
@@ -411,11 +466,11 @@ cli
411
466
  '--resolution [resolution]',
412
467
  z
413
468
  .string()
414
- .describe('Video resolution in WIDTHxHEIGHT or provider format (e.g. 1280x720, 720p)'),
469
+ .describe(`Video resolution. ${describeProviderValues('resolution', [VIDEO_CATALOG])}. Google: 720p, 1080p, 4k`),
415
470
  )
416
471
  .option(
417
472
  '--duration [seconds]',
418
- z.number().describe('Video duration in seconds (provider/model-specific limits apply)'),
473
+ z.number().describe('Video duration in seconds. Veo: 4-8s. xAI Grok: 1-15s. Kling: 5-10s. Wan: 2-15s'),
419
474
  )
420
475
  .option(
421
476
  '--fps [fps]',
@@ -430,7 +485,31 @@ cli
430
485
  z
431
486
  .string()
432
487
  .describe(
433
- 'Optional reference image for image-to-video. Accepts local file path or URL (http/https)',
488
+ 'Input file for video generation. For i2v: a reference image. For edit-video/extend-video: a source video. Accepts local file paths or URLs',
489
+ ),
490
+ )
491
+ .option(
492
+ '--mode [mode]',
493
+ z
494
+ .enum(['edit-video', 'extend-video', 'reference-to-video'])
495
+ .describe(
496
+ `Video operation mode. ${describeProviderValues('mode', [VIDEO_CATALOG])}`,
497
+ ),
498
+ )
499
+ .option(
500
+ '--reference-images [path]',
501
+ z
502
+ .array(z.string())
503
+ .describe(
504
+ 'Reference images for R2V generation, repeatable, 1-7 files or URLs (xAI)',
505
+ ),
506
+ )
507
+ .option(
508
+ '--negative-prompt [text]',
509
+ z
510
+ .string()
511
+ .describe(
512
+ 'Describe what to avoid in the generated video (Fal models)',
434
513
  ),
435
514
  )
436
515
  .option(
@@ -470,9 +549,39 @@ cli
470
549
  console.error(pc.dim('Mode: Video API (experimental_generateVideo)'))
471
550
  }
472
551
 
473
- const inputImage = options.input
474
- ? await readInputSource(options.input)
475
- : undefined
552
+ // Determine how --input is used based on --mode:
553
+ // - edit-video / extend-video: input is a source video, passed as videoUrl (needs URL)
554
+ // - everything else (i2v, t2v): input is a reference image, passed as bytes
555
+ const isVideoInputMode = options.mode === 'edit-video' || options.mode === 'extend-video'
556
+
557
+ // Validate mode-specific required inputs
558
+ if (isVideoInputMode && !options.input) {
559
+ console.error(pc.red(`--input is required with --mode ${options.mode}. Pass a local video file or URL.`))
560
+ process.exit(1)
561
+ }
562
+ if (options.mode === 'reference-to-video' && (!options.referenceImages || options.referenceImages.length === 0)) {
563
+ console.error(pc.red('--reference-images is required with --mode reference-to-video. Pass 1-7 image files or URLs.'))
564
+ process.exit(1)
565
+ }
566
+
567
+ let inputImage: Uint8Array | undefined
568
+ let videoUrl: string | undefined
569
+
570
+ if (options.input && isVideoInputMode) {
571
+ // Upload local file or use URL directly for video editing/extension
572
+ videoUrl = await resolveToUrl(options.input)
573
+ } else if (options.input) {
574
+ // Read as bytes for image-to-video
575
+ inputImage = await readInputSource(options.input)
576
+ }
577
+
578
+ // Resolve --reference-images: upload local files to get URLs
579
+ let resolvedReferenceImages = options.referenceImages
580
+ if (resolvedReferenceImages) {
581
+ resolvedReferenceImages = await Promise.all(
582
+ resolvedReferenceImages.map((ref: string) => resolveToUrl(ref)),
583
+ )
584
+ }
476
585
 
477
586
  await generateWithVideoModel({
478
587
  prompt,
@@ -485,6 +594,10 @@ cli
485
594
  fps: options.fps,
486
595
  seed: options.seed,
487
596
  inputImage,
597
+ mode: options.mode,
598
+ videoUrl,
599
+ referenceImages: resolvedReferenceImages,
600
+ negativePrompt: options.negativePrompt,
488
601
  json: options.json || false,
489
602
  stdout: options.stdout || false,
490
603
  })
@@ -518,11 +631,12 @@ cli
518
631
  Object.keys(PROVIDERS).map((provider) => [provider, getKeyStatus(provider)]),
519
632
  )
520
633
 
521
- let models = options.type === 'video'
522
- ? VIDEO_CATALOG
523
- : options.type === 'image'
524
- ? CATALOG
525
- : [...CATALOG, ...VIDEO_CATALOG]
634
+ let models: Array<typeof CATALOG[number] | typeof VIDEO_CATALOG[number]> =
635
+ options.type === 'video'
636
+ ? [...VIDEO_CATALOG]
637
+ : options.type === 'image'
638
+ ? [...CATALOG]
639
+ : [...CATALOG, ...VIDEO_CATALOG]
526
640
 
527
641
  if (options.provider) {
528
642
  models = models.filter((m) => m.provider === options.provider)
@@ -572,9 +686,142 @@ cli
572
686
  }
573
687
  })
574
688
 
689
+ /** Upload gateway base URL for temporary file uploads. */
690
+ const UPLOAD_GATEWAY_BASE = 'https://egaki.org'
691
+ /** Max upload size in bytes (must match gateway limit). */
692
+ const MAX_UPLOAD_SIZE = 100 * 1024 * 1024
693
+
575
694
  cli.help()
576
695
  cli.version(pkg.version)
577
- cli.parse()
696
+ await cli.parse()
697
+
698
+ // ─── error output helpers ────────────────────────────────────────────────────
699
+
700
+ type PrintableValue = unknown
701
+ type ErrorDetailsInput = Parameters<typeof APICallError.isInstance>[0]
702
+
703
+ async function runProviderCall<T>(operation: string, call: () => Promise<T>): Promise<T> {
704
+ const result = await call().catch((error) => {
705
+ printErrorDetails(error, operation)
706
+ process.exit(1)
707
+ })
708
+ return result
709
+ }
710
+
711
+ function printErrorDetails(error: ErrorDetailsInput, operation?: string): void {
712
+ const prefix = operation ? `${operation} failed` : 'Command failed'
713
+ console.error(pc.red(pc.bold(prefix)))
714
+
715
+ if (APICallError.isInstance(error)) {
716
+ printKeyValue('name', error.name)
717
+ printKeyValue('message', error.message)
718
+ printKeyValue('statusCode', error.statusCode)
719
+ printKeyValue('url', error.url)
720
+ printKeyValue('isRetryable', error.isRetryable)
721
+ printJsonBlock('responseBody', parseJsonLike(error.responseBody))
722
+ printJsonBlock('responseHeaders', error.responseHeaders)
723
+ printJsonBlock('requestBodyValues', error.requestBodyValues)
724
+ printJsonBlock('data', error.data)
725
+ printCause(error.cause)
726
+ printStack(error)
727
+ return
728
+ }
729
+
730
+ if (NoImageGeneratedError.isInstance(error) || NoVideoGeneratedError.isInstance(error)) {
731
+ printKeyValue('name', error.name)
732
+ printKeyValue('message', error.message)
733
+ printJsonBlock('responses', error.responses)
734
+ printCause(error.cause)
735
+ printStack(error)
736
+ return
737
+ }
738
+
739
+ if (RetryError.isInstance(error)) {
740
+ printKeyValue('name', error.name)
741
+ printKeyValue('message', error.message)
742
+ printKeyValue('reason', error.reason)
743
+ printJsonBlock('errors', error.errors.map((e) => serializeUnknown(e)))
744
+ printCause(error.cause)
745
+ printStack(error)
746
+ return
747
+ }
748
+
749
+ if (error instanceof Error) {
750
+ printKeyValue('name', error.name)
751
+ printKeyValue('message', error.message)
752
+ printJsonBlock('details', serializeOwnProperties(error))
753
+ printCause(error.cause)
754
+ printStack(error)
755
+ return
756
+ }
757
+
758
+ printJsonBlock('error', serializeUnknown(error))
759
+ }
760
+
761
+ function printKeyValue(key: string, value: PrintableValue): void {
762
+ if (value === undefined) return
763
+ console.error(`${pc.dim(`${key}:`)} ${String(value)}`)
764
+ }
765
+
766
+ function printJsonBlock(label: string, value: PrintableValue): void {
767
+ if (value === undefined) return
768
+ const serialized = stableStringify(value)
769
+ if (!serialized || serialized === '{}') return
770
+ console.error(pc.dim(`${label}:`))
771
+ console.error(serialized)
772
+ }
773
+
774
+ function printCause(cause: Error['cause']): void {
775
+ if (cause === undefined) return
776
+ console.error(pc.dim('cause:'))
777
+ console.error(stableStringify(serializeUnknown(cause)))
778
+ }
779
+
780
+ function printStack(error: Error): void {
781
+ if (!error.stack) return
782
+ console.error(pc.dim('stack:'))
783
+ console.error(pc.dim(error.stack))
784
+ }
785
+
786
+ function parseJsonLike(value?: string): PrintableValue {
787
+ if (typeof value !== 'string') return value
788
+ try {
789
+ return JSON.parse(value)
790
+ } catch {
791
+ return value
792
+ }
793
+ }
794
+
795
+ function serializeUnknown(value: ErrorDetailsInput): PrintableValue {
796
+ if (value instanceof Error) {
797
+ return {
798
+ name: value.name,
799
+ message: value.message,
800
+ ...serializeOwnProperties(value),
801
+ ...(value.cause === undefined ? {} : { cause: serializeUnknown(value.cause) }),
802
+ ...(value.stack ? { stack: value.stack } : {}),
803
+ }
804
+ }
805
+ return value
806
+ }
807
+
808
+ function serializeOwnProperties(error: Error): Record<string, PrintableValue> {
809
+ return Object.fromEntries(
810
+ Object.entries(error).map(([key, value]) => [key, serializeUnknown(value)]),
811
+ )
812
+ }
813
+
814
+ function stableStringify(value: PrintableValue): string | undefined {
815
+ const seen = new WeakSet<object>()
816
+ return JSON.stringify(value, (_key, item) => {
817
+ if (typeof item === 'bigint') return item.toString()
818
+ if (item instanceof Date) return item.toISOString()
819
+ if (typeof item !== 'object' || item === null) return item
820
+ if (seen.has(item)) return '[Circular]'
821
+ seen.add(item)
822
+ return item
823
+ }, 2)
824
+ }
578
825
 
579
826
  // ─── interactive model pickers ──────────────────────────────────────────────
580
827
 
@@ -668,6 +915,71 @@ function isUrl(input: string): boolean {
668
915
  return /^https?:\/\//i.test(input)
669
916
  }
670
917
 
918
+ function mimeTypeFromExtension(ext: string): string {
919
+ const map: Record<string, string> = {
920
+ '.mp4': 'video/mp4',
921
+ '.webm': 'video/webm',
922
+ '.mov': 'video/quicktime',
923
+ '.avi': 'video/x-msvideo',
924
+ '.png': 'image/png',
925
+ '.jpg': 'image/jpeg',
926
+ '.jpeg': 'image/jpeg',
927
+ '.webp': 'image/webp',
928
+ '.gif': 'image/gif',
929
+ }
930
+ return map[ext.toLowerCase()] || 'application/octet-stream'
931
+ }
932
+
933
+ /**
934
+ * Upload a local file to the egaki gateway and return the public URL.
935
+ * Used when a flag that expects a URL receives a local file path instead.
936
+ */
937
+ async function uploadFileToGateway(filePath: string): Promise<string> {
938
+ const resolved = path.resolve(filePath)
939
+ if (!fs.existsSync(resolved)) {
940
+ console.error(pc.red(`File not found: ${resolved}`))
941
+ process.exit(1)
942
+ }
943
+
944
+ const stat = fs.statSync(resolved)
945
+ if (stat.size > MAX_UPLOAD_SIZE) {
946
+ console.error(pc.red(`File too large: ${resolved} (${Math.round(stat.size / 1024 / 1024)}MB). Maximum is ${MAX_UPLOAD_SIZE / 1024 / 1024}MB`))
947
+ process.exit(1)
948
+ }
949
+
950
+ const ext = path.extname(resolved)
951
+ const contentType = mimeTypeFromExtension(ext)
952
+ const fileBytes = fs.readFileSync(resolved)
953
+
954
+ console.error(pc.dim(`Uploading ${filePath}...`))
955
+
956
+ const response = await fetch(`${UPLOAD_GATEWAY_BASE}/upload`, {
957
+ method: 'POST',
958
+ headers: { 'Content-Type': contentType },
959
+ body: fileBytes,
960
+ })
961
+
962
+ if (!response.ok) {
963
+ const body = await response.text().catch(() => '')
964
+ console.error(pc.red(`Upload failed: ${response.status} ${response.statusText}`))
965
+ if (body) console.error(pc.dim(body))
966
+ process.exit(1)
967
+ }
968
+
969
+ const result = await response.json() as { url: string }
970
+ console.error(pc.dim(`Uploaded: ${result.url}`))
971
+ return result.url
972
+ }
973
+
974
+ /**
975
+ * Resolve a value that should be a URL: if it's already a URL, return as-is.
976
+ * If it's a local file path, upload it to the gateway and return the public URL.
977
+ */
978
+ async function resolveToUrl(input: string): Promise<string> {
979
+ if (isUrl(input)) return input
980
+ return uploadFileToGateway(input)
981
+ }
982
+
671
983
  async function readInputSource(input: string): Promise<Uint8Array> {
672
984
  if (isUrl(input)) {
673
985
  console.error(pc.dim(`Fetching ${input}...`))
@@ -695,6 +1007,137 @@ async function readInputImages(
695
1007
  return Promise.all(inputs.map((f) => readInputSource(f)))
696
1008
  }
697
1009
 
1010
+ // ─── type-safe provider option builders ──────────────────────────────────────
1011
+ // Each builder switches on provider name and constructs a typed object using
1012
+ // `satisfies` with the SDK's exported type. This catches typos in keys and
1013
+ // invalid enum values at compile time instead of runtime.
1014
+
1015
+ type ImageOptionInputs = {
1016
+ aspectRatio?: `${number}:${number}`
1017
+ allowPeople: boolean
1018
+ quality?: string
1019
+ resolution?: string
1020
+ outputFormat?: string
1021
+ negativePrompt?: string
1022
+ }
1023
+
1024
+ // Return type uses a mapped type that preserves the typed structure while
1025
+ // remaining compatible with the AI SDK's ProviderOptions (Record<string, JSONObject>).
1026
+ // The satisfies checks catch key typos and invalid enum values at compile time,
1027
+ // then we widen to the SDK's expected shape at the boundary.
1028
+ type ProviderOptionsResult = Record<string, Record<string, string | number | boolean | string[] | null | undefined | Record<string, string | number | boolean | null | undefined>>>
1029
+
1030
+ function buildImageProviderOptions(
1031
+ provider: string,
1032
+ opts: ImageOptionInputs,
1033
+ ): ProviderOptionsResult {
1034
+ switch (provider) {
1035
+ case 'google': {
1036
+ const googleOpts = {
1037
+ ...(opts.allowPeople ? { personGeneration: 'allow_all' as const } : {}),
1038
+ ...(opts.aspectRatio ? { aspectRatio: opts.aspectRatio as GoogleImageModelOptions['aspectRatio'] } : {}),
1039
+ } satisfies GoogleImageModelOptions
1040
+ return { google: googleOpts }
1041
+ }
1042
+ case 'vertex': {
1043
+ const vertexOpts = {
1044
+ ...(opts.allowPeople ? { personGeneration: 'allow_all' as const } : {}),
1045
+ ...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
1046
+ } satisfies GoogleVertexImageModelOptions
1047
+ return { vertex: vertexOpts }
1048
+ }
1049
+ case 'xai': {
1050
+ const xaiOpts = {
1051
+ ...(opts.quality ? { quality: opts.quality as XaiImageModelOptions['quality'] } : {}),
1052
+ ...(opts.resolution ? { resolution: opts.resolution as XaiImageModelOptions['resolution'] } : {}),
1053
+ ...(opts.outputFormat ? { output_format: opts.outputFormat } : {}),
1054
+ } satisfies XaiImageModelOptions
1055
+ return { xai: xaiOpts }
1056
+ }
1057
+ case 'openai': {
1058
+ const openaiOpts = {
1059
+ ...(opts.quality ? { quality: opts.quality as OpenAIImageProviderOptions['quality'] } : {}),
1060
+ ...(opts.outputFormat ? { outputFormat: opts.outputFormat as OpenAIImageProviderOptions['outputFormat'] } : {}),
1061
+ } satisfies OpenAIImageProviderOptions
1062
+ return { openai: openaiOpts }
1063
+ }
1064
+ case 'fal': {
1065
+ const falOpts = {
1066
+ ...(opts.outputFormat ? { outputFormat: opts.outputFormat } : {}),
1067
+ ...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
1068
+ } satisfies FalImageModelOptions
1069
+ return { fal: falOpts }
1070
+ }
1071
+ default:
1072
+ // Providers routed through the gateway (bfl, recraft, etc.) pass options
1073
+ // as-is since they don't have local SDK types.
1074
+ return {}
1075
+ }
1076
+ }
1077
+
1078
+ type VideoOptionInputs = {
1079
+ resolution?: string
1080
+ mode?: string
1081
+ videoUrl?: string
1082
+ referenceImages?: string[]
1083
+ negativePrompt?: string
1084
+ model: string
1085
+ }
1086
+
1087
+ function buildVideoProviderOptions(
1088
+ provider: string,
1089
+ opts: VideoOptionInputs,
1090
+ ): ProviderOptionsResult | undefined {
1091
+ switch (provider) {
1092
+ case 'xai': {
1093
+ // xAI video modes are a discriminated union; build the correct variant.
1094
+ const base = {
1095
+ ...(opts.resolution ? { resolution: opts.resolution as '480p' | '720p' } : {}),
1096
+ }
1097
+ if (opts.mode === 'edit-video' && opts.videoUrl) {
1098
+ const xaiOpts = { ...base, mode: 'edit-video' as const, videoUrl: opts.videoUrl } satisfies XaiVideoModelOptions
1099
+ return { xai: xaiOpts }
1100
+ }
1101
+ if (opts.mode === 'extend-video' && opts.videoUrl) {
1102
+ const xaiOpts = { ...base, mode: 'extend-video' as const, videoUrl: opts.videoUrl } satisfies XaiVideoModelOptions
1103
+ return { xai: xaiOpts }
1104
+ }
1105
+ if (opts.mode === 'reference-to-video' && opts.referenceImages) {
1106
+ const xaiOpts = { ...base, mode: 'reference-to-video' as const, referenceImageUrls: opts.referenceImages } satisfies XaiVideoModelOptions
1107
+ return { xai: xaiOpts }
1108
+ }
1109
+ // Default: text-to-video or image-to-video (no mode)
1110
+ if (Object.keys(base).length === 0) return undefined
1111
+ const xaiOpts = { ...base } satisfies XaiVideoModelOptions
1112
+ return { xai: xaiOpts }
1113
+ }
1114
+ case 'fal': {
1115
+ const falOpts = {
1116
+ ...(opts.resolution ? { resolution: opts.resolution } : {}),
1117
+ ...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
1118
+ } satisfies FalVideoModelOptions
1119
+ if (Object.keys(falOpts).length === 0) return undefined
1120
+ return { fal: falOpts }
1121
+ }
1122
+ case 'google': {
1123
+ const googleOpts = {
1124
+ ...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
1125
+ } satisfies GoogleVideoModelOptions
1126
+ if (Object.keys(googleOpts).length === 0) return undefined
1127
+ return { google: googleOpts }
1128
+ }
1129
+ case 'vertex': {
1130
+ const vertexOpts = {
1131
+ ...(opts.negativePrompt ? { negativePrompt: opts.negativePrompt } : {}),
1132
+ } satisfies GoogleVertexVideoModelOptions
1133
+ if (Object.keys(vertexOpts).length === 0) return undefined
1134
+ return { vertex: vertexOpts }
1135
+ }
1136
+ default:
1137
+ return undefined
1138
+ }
1139
+ }
1140
+
698
1141
  // Generate using the dedicated generateImage API (Imagen models)
699
1142
  async function generateWithImageModel({
700
1143
  prompt,
@@ -706,6 +1149,10 @@ async function generateWithImageModel({
706
1149
  inputImages,
707
1150
  maskImage,
708
1151
  allowPeople,
1152
+ quality,
1153
+ resolution,
1154
+ outputFormat,
1155
+ negativePrompt,
709
1156
  json,
710
1157
  stdout,
711
1158
  }: {
@@ -718,6 +1165,10 @@ async function generateWithImageModel({
718
1165
  inputImages: Uint8Array[]
719
1166
  maskImage?: Uint8Array
720
1167
  allowPeople: boolean
1168
+ quality?: string
1169
+ resolution?: string
1170
+ outputFormat?: string
1171
+ negativePrompt?: string
721
1172
  json: boolean
722
1173
  stdout: boolean
723
1174
  }) {
@@ -729,65 +1180,53 @@ async function generateWithImageModel({
729
1180
  const config = getModelConfig(model)
730
1181
  const imageModel = await createImageModel(model)
731
1182
 
732
- // Use the correct providerOptions key based on provider (google vs vertex)
733
- const providerOptionsKey = config.provider === 'vertex' ? 'vertex' : 'google'
1183
+ // Build type-safe provider options using the SDK types with `satisfies`.
1184
+ // Each provider has its own shape; we switch on provider to get compile-time safety.
1185
+ const providerOptions = buildImageProviderOptions(config.provider, {
1186
+ aspectRatio,
1187
+ allowPeople,
1188
+ quality,
1189
+ resolution,
1190
+ outputFormat,
1191
+ negativePrompt,
1192
+ })
734
1193
 
735
1194
  if (!stdout) {
736
1195
  console.error(pc.cyan('Generating...'))
737
1196
  }
738
1197
 
739
- const result = await aiGenerateImage({
1198
+ const result = await runProviderCall('Image generation', () => aiGenerateImage({
740
1199
  model: imageModel,
741
1200
  prompt: imagePrompt,
742
1201
  n: count,
743
1202
  ...(aspectRatio ? { aspectRatio } : {}),
744
1203
  ...(seed !== undefined ? { seed } : {}),
745
- providerOptions: {
746
- [providerOptionsKey]: {
747
- ...(allowPeople ? { personGeneration: 'allow_all' } : {}),
748
- ...(aspectRatio ? { aspectRatio } : {}),
749
- },
750
- },
751
- })
1204
+ providerOptions,
1205
+ }))
1206
+
1207
+ const files = result.images.map((img) => ({
1208
+ uint8Array: img.uint8Array,
1209
+ mediaType: img.mediaType,
1210
+ }))
752
1211
 
753
1212
  if (stdout) {
754
- // Write first image to stdout for piping
755
- const image = result.images[0]
756
- if (image) {
757
- process.stdout.write(Buffer.from(image.uint8Array))
758
- }
1213
+ writeFirstToStdout(files)
759
1214
  return
760
1215
  }
761
1216
 
762
- const savedFiles: string[] = []
763
- for (let i = 0; i < result.images.length; i++) {
764
- const image = result.images[i]!
765
- const ext = extensionFromMediaType(image.mediaType)
766
- const filePath =
767
- result.images.length === 1
768
- ? ensureExtension(outputPath, ext)
769
- : insertIndex(outputPath, i, ext)
770
-
771
- fs.writeFileSync(filePath, image.uint8Array)
772
- console.error(pc.green(`Saved: ${filePath}`))
773
- savedFiles.push(filePath)
774
- }
775
-
1217
+ const savedFiles = saveGeneratedFiles(files, outputPath)
776
1218
  const cost = calculateCost(config.cost, result.usage, result.images.length)
777
- if (cost != null) {
778
- console.error(pc.dim(`Cost: ${formatCost(cost)}`))
779
- }
1219
+ printCost(cost)
780
1220
 
781
1221
  if (json) {
782
- const output = {
1222
+ console.log(JSON.stringify({
783
1223
  model,
784
1224
  files: savedFiles,
785
1225
  count: result.images.length,
786
1226
  cost,
787
1227
  usage: result.usage,
788
1228
  warnings: result.warnings,
789
- }
790
- console.log(JSON.stringify(output, null, 2))
1229
+ }, null, 2))
791
1230
  }
792
1231
  }
793
1232
 
@@ -813,7 +1252,6 @@ async function generateWithTextModel({
813
1252
  }) {
814
1253
  const textModel = await createTextModel(model)
815
1254
  const config = getModelConfig(model)
816
- const providerOptionsKey = config.provider === 'vertex' ? 'vertex' : 'google'
817
1255
 
818
1256
  if (!stdout) {
819
1257
  console.error(pc.cyan('Generating...'))
@@ -835,27 +1273,32 @@ async function generateWithTextModel({
835
1273
  ]
836
1274
  : undefined
837
1275
 
838
- const result = await generateText({
1276
+ // Type-safe Google/Vertex language model options with image generation config.
1277
+ // aspectRatio is cast to the Google SDK's enum type since CLI input is a free string.
1278
+ type GoogleAspectRatio = NonNullable<NonNullable<GoogleLanguageModelOptions['imageConfig']>['aspectRatio']>
1279
+ const googleOpts = {
1280
+ responseModalities: ['TEXT', 'IMAGE'],
1281
+ ...(imageSize || aspectRatio
1282
+ ? {
1283
+ imageConfig: {
1284
+ ...(imageSize ? { imageSize } : {}),
1285
+ ...(aspectRatio ? { aspectRatio: aspectRatio as GoogleAspectRatio } : {}),
1286
+ },
1287
+ }
1288
+ : {}),
1289
+ } satisfies GoogleLanguageModelOptions
1290
+
1291
+ const providerOptionsKey = config.provider === 'vertex' ? 'vertex' : 'google'
1292
+
1293
+ const result = await runProviderCall('Text/image generation', () => generateText({
839
1294
  model: textModel,
840
1295
  ...(messages ? { messages } : { prompt }),
841
1296
  providerOptions: {
842
- [providerOptionsKey]: {
843
- responseModalities: ['TEXT', 'IMAGE'],
844
- ...(imageSize || aspectRatio
845
- ? {
846
- imageConfig: {
847
- ...(imageSize ? { imageSize } : {}),
848
- ...(aspectRatio ? { aspectRatio } : {}),
849
- },
850
- }
851
- : {}),
852
- },
1297
+ [providerOptionsKey]: googleOpts,
853
1298
  },
854
- })
1299
+ }))
855
1300
 
856
- const imageFiles = result.files.filter((f) => {
857
- return f.mediaType.startsWith('image/')
858
- })
1301
+ const imageFiles = result.files.filter((f) => f.mediaType.startsWith('image/'))
859
1302
 
860
1303
  if (imageFiles.length === 0) {
861
1304
  console.error(pc.red('No images generated.'))
@@ -863,46 +1306,27 @@ async function generateWithTextModel({
863
1306
  }
864
1307
 
865
1308
  if (stdout) {
866
- const file = imageFiles[0]
867
- if (file) {
868
- process.stdout.write(Buffer.from(file.uint8Array))
869
- }
1309
+ writeFirstToStdout(imageFiles)
870
1310
  return
871
1311
  }
872
1312
 
873
- const savedFiles: string[] = []
874
- for (let i = 0; i < imageFiles.length; i++) {
875
- const file = imageFiles[i]!
876
- const ext = extensionFromMediaType(file.mediaType)
877
- const filePath =
878
- imageFiles.length === 1
879
- ? ensureExtension(outputPath, ext)
880
- : insertIndex(outputPath, i, ext)
881
-
882
- fs.writeFileSync(filePath, file.uint8Array)
883
- console.error(pc.green(`Saved: ${filePath}`))
884
- savedFiles.push(filePath)
885
- }
886
-
1313
+ const savedFiles = saveGeneratedFiles(imageFiles, outputPath)
887
1314
  const cost = calculateCost(config.cost, result.usage, imageFiles.length)
888
- if (cost != null) {
889
- console.error(pc.dim(`Cost: ${formatCost(cost)}`))
890
- }
1315
+ printCost(cost)
891
1316
 
892
1317
  if (result.text && !json) {
893
1318
  console.error(pc.dim(result.text))
894
1319
  }
895
1320
 
896
1321
  if (json) {
897
- const output = {
1322
+ console.log(JSON.stringify({
898
1323
  model,
899
1324
  files: savedFiles,
900
1325
  count: imageFiles.length,
901
1326
  text: result.text || null,
902
1327
  cost,
903
1328
  usage: result.usage,
904
- }
905
- console.log(JSON.stringify(output, null, 2))
1329
+ }, null, 2))
906
1330
  }
907
1331
  }
908
1332
 
@@ -1111,6 +1535,10 @@ async function generateWithVideoModel({
1111
1535
  fps,
1112
1536
  seed,
1113
1537
  inputImage,
1538
+ mode,
1539
+ videoUrl,
1540
+ referenceImages,
1541
+ negativePrompt,
1114
1542
  json,
1115
1543
  stdout,
1116
1544
  }: {
@@ -1124,16 +1552,38 @@ async function generateWithVideoModel({
1124
1552
  fps?: number
1125
1553
  seed?: number
1126
1554
  inputImage?: Uint8Array
1555
+ mode?: string
1556
+ videoUrl?: string
1557
+ referenceImages?: string[]
1558
+ negativePrompt?: string
1127
1559
  json: boolean
1128
1560
  stdout: boolean
1129
1561
  }) {
1130
1562
  const videoModel = await createVideoModel(model)
1563
+ const config = getModelConfig(model)
1564
+
1565
+ // Validate that the model supports video-url input before building options.
1566
+ // Only models with a 'video-url' providerOption (currently xAI) accept edit/extend mode.
1567
+ if (videoUrl && !config.providerOptions?.some((opt) => opt.flag === 'video-url')) {
1568
+ console.error(pc.red(`Model ${model} does not support video URL input for editing/extension`))
1569
+ process.exit(1)
1570
+ }
1571
+
1572
+ // Build type-safe provider options for video generation.
1573
+ const providerOptions = buildVideoProviderOptions(config.provider, {
1574
+ resolution,
1575
+ mode,
1576
+ videoUrl,
1577
+ referenceImages,
1578
+ negativePrompt,
1579
+ model,
1580
+ })
1131
1581
 
1132
1582
  if (!stdout) {
1133
1583
  console.error(pc.cyan('Generating...'))
1134
1584
  }
1135
1585
 
1136
- const result = await aiGenerateVideo({
1586
+ const result = await runProviderCall('Video generation', () => aiGenerateVideo({
1137
1587
  model: videoModel,
1138
1588
  prompt: inputImage
1139
1589
  ? { image: inputImage, text: prompt }
@@ -1144,50 +1594,88 @@ async function generateWithVideoModel({
1144
1594
  ...(duration != null ? { duration } : {}),
1145
1595
  ...(fps != null ? { fps } : {}),
1146
1596
  ...(seed != null ? { seed } : {}),
1147
- })
1597
+ ...(providerOptions ? { providerOptions } : {}),
1598
+ }))
1599
+
1600
+ const files = result.videos.map((v: { uint8Array: Uint8Array; mediaType: string }) => ({
1601
+ uint8Array: v.uint8Array,
1602
+ mediaType: v.mediaType,
1603
+ }))
1148
1604
 
1149
1605
  if (stdout) {
1150
- const video = result.videos[0]
1151
- if (video) {
1152
- process.stdout.write(Buffer.from(video.uint8Array))
1153
- }
1606
+ writeFirstToStdout(files)
1154
1607
  return
1155
1608
  }
1156
1609
 
1157
- const savedFiles: string[] = []
1158
- for (let i = 0; i < result.videos.length; i++) {
1159
- const video = result.videos[i]!
1160
- const ext = extensionFromMediaType(video.mediaType)
1161
- const filePath =
1162
- result.videos.length === 1
1163
- ? ensureExtension(outputPath, ext)
1164
- : insertIndex(outputPath, i, ext)
1165
-
1166
- fs.writeFileSync(filePath, video.uint8Array)
1167
- console.error(pc.green(`Saved: ${filePath}`))
1168
- savedFiles.push(filePath)
1169
- }
1170
-
1171
- const config = getModelConfig(model)
1610
+ const savedFiles = saveGeneratedFiles(files, outputPath)
1172
1611
  const cost = calculateCost(config.cost, {
1173
1612
  videosGenerated: result.videos.length,
1174
1613
  durationSeconds: duration,
1175
1614
  resolution,
1176
1615
  }, result.videos.length)
1177
- if (cost != null) {
1178
- console.error(pc.dim(`Cost: ${formatCost(cost)}`))
1179
- }
1616
+ printCost(cost)
1180
1617
 
1181
1618
  if (json) {
1182
- const output = {
1619
+ console.log(JSON.stringify({
1183
1620
  model,
1184
1621
  files: savedFiles,
1185
1622
  count: result.videos.length,
1186
1623
  cost,
1187
1624
  warnings: result.warnings,
1188
1625
  responses: result.responses,
1189
- }
1190
- console.log(JSON.stringify(output, null, 2))
1626
+ }, null, 2))
1627
+ }
1628
+ }
1629
+
1630
+ // ─── shared output helpers ───────────────────────────────────────────────────
1631
+ // Common logic for saving generated files, calculating cost, and printing JSON
1632
+ // metadata. Used by all generation paths to avoid repeating boilerplate.
1633
+
1634
+ type GeneratedFile = {
1635
+ uint8Array: Uint8Array
1636
+ mediaType: string
1637
+ }
1638
+
1639
+ /**
1640
+ * Save generated files to disk. Returns the list of saved file paths.
1641
+ * Handles single vs multi-file naming with index suffixes.
1642
+ */
1643
+ function saveGeneratedFiles(
1644
+ files: GeneratedFile[],
1645
+ outputPath: string,
1646
+ ): string[] {
1647
+ const savedFiles: string[] = []
1648
+ for (let i = 0; i < files.length; i++) {
1649
+ const file = files[i]!
1650
+ const ext = extensionFromMediaType(file.mediaType)
1651
+ const filePath =
1652
+ files.length === 1
1653
+ ? ensureExtension(outputPath, ext)
1654
+ : insertIndex(outputPath, i, ext)
1655
+
1656
+ fs.writeFileSync(filePath, file.uint8Array)
1657
+ console.error(pc.green(`Saved: ${filePath}`))
1658
+ savedFiles.push(filePath)
1659
+ }
1660
+ return savedFiles
1661
+ }
1662
+
1663
+ /**
1664
+ * Print estimated cost to stderr if calculable.
1665
+ */
1666
+ function printCost(cost: number | null): void {
1667
+ if (cost != null) {
1668
+ console.error(pc.dim(`Cost: ${formatCost(cost)}`))
1669
+ }
1670
+ }
1671
+
1672
+ /**
1673
+ * Write the first file's raw bytes to stdout (for piping to other tools).
1674
+ */
1675
+ function writeFirstToStdout(files: GeneratedFile[]): void {
1676
+ const file = files[0]
1677
+ if (file) {
1678
+ process.stdout.write(Buffer.from(file.uint8Array))
1191
1679
  }
1192
1680
  }
1193
1681