egaki 0.2.0 → 0.4.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
@@ -18,8 +18,15 @@ import {
18
18
  import fs from 'node:fs'
19
19
  import path from 'node:path'
20
20
  import pc from 'picocolors'
21
+ import { createParser, type EventSourceMessage } from 'eventsource-parser'
21
22
  import pkg from '../package.json' with { type: 'json' }
22
- import { injectCredentialsToEnv, PROVIDERS } from './credentials.js'
23
+ import {
24
+ injectCredentialsToEnv,
25
+ PROVIDERS,
26
+ getChatGptAuth,
27
+ saveChatGptAuth,
28
+ getKeyStatus,
29
+ } from './credentials.js'
23
30
  import {
24
31
  IMAGE_MODELS,
25
32
  VIDEO_MODELS,
@@ -29,6 +36,7 @@ import {
29
36
  createImageModel,
30
37
  createTextModel,
31
38
  createVideoModel,
39
+ shouldUseResponsesApi,
32
40
  } from './models.js'
33
41
  import { VIDEO_CATALOG } from './video-model-catalog.js'
34
42
  import {
@@ -44,6 +52,7 @@ import {
44
52
  unsubscribe,
45
53
  showUsage,
46
54
  } from './subscription.js'
55
+ import { getValidChatGptAuth } from './chatgpt-auth.js'
47
56
 
48
57
  const cli = goke('egaki')
49
58
 
@@ -98,6 +107,7 @@ cli
98
107
  .example('egaki login')
99
108
  .example('# Non-interactive login with flags')
100
109
  .example('egaki login --provider google --key AIza...')
110
+ .example('egaki login --provider vertex --key AIza...')
101
111
  .example('# Pipe key from stdin (useful in CI/scripts)')
102
112
  .example('echo "AIza..." | egaki login --provider google')
103
113
  .example('# Show configured providers')
@@ -117,8 +127,13 @@ cli
117
127
 
118
128
  // Non-interactive: --provider + --key or stdin
119
129
  if (options.provider) {
130
+ // ChatGPT uses browser OAuth — skip key reading
131
+ if (options.provider === 'chatgpt') {
132
+ await loginNonInteractive({ provider: options.provider, key: '' })
133
+ return
134
+ }
120
135
  const key = options.key || (await readKeyFromStdin())
121
- loginNonInteractive({ provider: options.provider, key })
136
+ await loginNonInteractive({ provider: options.provider, key })
122
137
  return
123
138
  }
124
139
 
@@ -283,28 +298,28 @@ cli
283
298
  .example('egaki image "fill with flowers" --input photo.jpg --mask mask.png')
284
299
  .example('# Generate with Gemini multimodal at 4K')
285
300
  .example('egaki image "dreamy landscape" -m gemini-2.5-flash-image --image-size 4K')
301
+ .example('# Route through Vertex AI (Google Cloud billing)')
302
+ .example('egaki image "product photo on marble" -m vertex/imagen-4.0-generate-001')
286
303
  .example('# Generate multiple images')
287
304
  .example('egaki image "abstract art" -n 4 -o art.png')
288
305
  .example('# Pipe to another tool')
289
306
  .example('egaki image "logo design" --stdout | convert - -resize 512x512 logo.png')
290
307
  .action(async (prompt, options) => {
291
- // Inject stored API keys as env vars before calling the AI SDK
308
+ // Inject stored API keys as env vars before calling the AI SDK.
292
309
  injectCredentialsToEnv()
293
310
 
294
- const model = options.model
295
- const outputPath = options.output
311
+ // goke infers schema .default() values via Zod's input type, which leaves
312
+ // options.model / options.output / options.count as `T | undefined` even
313
+ // though the runtime always resolves them. Apply the same defaults here.
314
+ const model = options.model ?? DEFAULT_MODEL
315
+ const outputPath = options.output ?? 'egaki-output.png'
316
+ const count = options.count ?? 1
296
317
  const config = getModelConfig(model)
318
+ const parsedAspectRatio = parseAspectRatioOption(options.aspectRatio)
297
319
 
298
320
  if (!options.stdout) {
299
321
  console.error(pc.dim(`Model: ${model}`))
300
322
  console.error(pc.dim(`Prompt: ${prompt}`))
301
- console.error(
302
- pc.dim(
303
- config.strategy === 'image'
304
- ? 'Mode: Image API (generateImage)'
305
- : 'Mode: Text+Image (generateText)',
306
- ),
307
- )
308
323
  }
309
324
 
310
325
  const inputImages = await readInputImages(options.input)
@@ -312,13 +327,34 @@ cli
312
327
  ? await readInputSource(options.mask)
313
328
  : undefined
314
329
 
315
- if (config.strategy === 'image') {
330
+ // When using ChatGPT OAuth with OpenAI image models, route through the
331
+ // Responses API + imageGeneration tool instead of the Image API.
332
+ // The Codex OAuth client lacks the `api.model.images.request` scope.
333
+ const useResponsesApi = config.strategy === 'image' && shouldUseResponsesApi(model)
334
+
335
+ if (useResponsesApi) {
336
+ if (!options.stdout) {
337
+ console.error(pc.dim('Mode: Responses API (ChatGPT OAuth)'))
338
+ }
339
+ await generateWithResponsesApi({
340
+ prompt,
341
+ model,
342
+ outputPath,
343
+ count,
344
+ aspectRatio: parsedAspectRatio,
345
+ seed: options.seed,
346
+ inputImages,
347
+ maskImage,
348
+ json: options.json || false,
349
+ stdout: options.stdout || false,
350
+ })
351
+ } else if (config.strategy === 'image') {
316
352
  await generateWithImageModel({
317
353
  prompt,
318
354
  model,
319
355
  outputPath,
320
- count: options.count,
321
- aspectRatio: options.aspectRatio as `${number}:${number}` | undefined,
356
+ count,
357
+ aspectRatio: parsedAspectRatio,
322
358
  seed: options.seed,
323
359
  inputImages,
324
360
  maskImage,
@@ -410,6 +446,8 @@ cli
410
446
  .example('egaki video "A paper airplane gliding through clouds" -o airplane.mp4')
411
447
  .example('# Generate with Veo model + duration')
412
448
  .example('egaki video "cinematic rainy street at night" -m veo-3.1-fast-generate-001 --duration 6')
449
+ .example('# Route through Vertex AI (Google Cloud billing)')
450
+ .example('egaki video "storm over mountains" -m vertex/veo-3.1-fast-generate-001 --duration 6')
413
451
  .example('# Image-to-video (model support required)')
414
452
  .example('egaki video "animate subtle camera pan" --model luma-ray-2 --input frame.png -o animated.mp4')
415
453
  .example('# Generate multiple videos')
@@ -417,8 +455,10 @@ cli
417
455
  .action(async (prompt, options) => {
418
456
  injectCredentialsToEnv()
419
457
 
420
- const model = options.model
421
- const outputPath = options.output
458
+ // Apply the same .default(...) values goke's input-type inference drops.
459
+ const model = options.model ?? DEFAULT_VIDEO_MODEL
460
+ const outputPath = options.output ?? 'egaki-output.mp4'
461
+ const count = options.count ?? 1
422
462
  const config = getModelConfig(model)
423
463
 
424
464
  if (config.strategy !== 'video') {
@@ -440,7 +480,7 @@ cli
440
480
  prompt,
441
481
  model,
442
482
  outputPath,
443
- count: options.count,
483
+ count,
444
484
  aspectRatio: options.aspectRatio,
445
485
  resolution: options.resolution,
446
486
  duration: options.duration,
@@ -477,6 +517,9 @@ cli
477
517
  .action(async (options) => {
478
518
  const { CATALOG } = await import('./model-catalog.js')
479
519
  const yaml = await import('js-yaml')
520
+ const providerStatuses = Object.fromEntries(
521
+ Object.keys(PROVIDERS).map((provider) => [provider, getKeyStatus(provider)]),
522
+ )
480
523
 
481
524
  let models = options.type === 'video'
482
525
  ? VIDEO_CATALOG
@@ -497,6 +540,7 @@ cli
497
540
  name: m.name,
498
541
  ...(m.description ? { description: m.description } : {}),
499
542
  provider: m.provider,
543
+ auth: providerStatuses[m.provider] ?? { available: false, source: 'none' },
500
544
  strategy: m.strategy,
501
545
  released: m.released,
502
546
  cost: formatCatalogCost(m.cost),
@@ -597,8 +641,12 @@ async function generateWithImageModel({
597
641
  ? { text: prompt, images: inputImages, ...(maskImage ? { mask: maskImage } : {}) }
598
642
  : prompt
599
643
 
644
+ const config = getModelConfig(model)
600
645
  const imageModel = await createImageModel(model)
601
646
 
647
+ // Use the correct providerOptions key based on provider (google vs vertex)
648
+ const providerOptionsKey = config.provider === 'vertex' ? 'vertex' : 'google'
649
+
602
650
  if (!stdout) {
603
651
  console.error(pc.cyan('Generating...'))
604
652
  }
@@ -610,7 +658,7 @@ async function generateWithImageModel({
610
658
  ...(aspectRatio ? { aspectRatio } : {}),
611
659
  ...(seed !== undefined ? { seed } : {}),
612
660
  providerOptions: {
613
- google: {
661
+ [providerOptionsKey]: {
614
662
  ...(allowPeople ? { personGeneration: 'allow_all' } : {}),
615
663
  ...(aspectRatio ? { aspectRatio } : {}),
616
664
  },
@@ -640,7 +688,6 @@ async function generateWithImageModel({
640
688
  savedFiles.push(filePath)
641
689
  }
642
690
 
643
- const config = getModelConfig(model)
644
691
  const cost = calculateCost(config.cost, result.usage, result.images.length)
645
692
  if (cost != null) {
646
693
  console.error(pc.dim(`Cost: ${formatCost(cost)}`))
@@ -680,6 +727,8 @@ async function generateWithTextModel({
680
727
  stdout: boolean
681
728
  }) {
682
729
  const textModel = await createTextModel(model)
730
+ const config = getModelConfig(model)
731
+ const providerOptionsKey = config.provider === 'vertex' ? 'vertex' : 'google'
683
732
 
684
733
  if (!stdout) {
685
734
  console.error(pc.cyan('Generating...'))
@@ -705,7 +754,7 @@ async function generateWithTextModel({
705
754
  model: textModel,
706
755
  ...(messages ? { messages } : { prompt }),
707
756
  providerOptions: {
708
- google: {
757
+ [providerOptionsKey]: {
709
758
  responseModalities: ['TEXT', 'IMAGE'],
710
759
  ...(imageSize || aspectRatio
711
760
  ? {
@@ -750,7 +799,6 @@ async function generateWithTextModel({
750
799
  savedFiles.push(filePath)
751
800
  }
752
801
 
753
- const config = getModelConfig(model)
754
802
  const cost = calculateCost(config.cost, result.usage, imageFiles.length)
755
803
  if (cost != null) {
756
804
  console.error(pc.dim(`Cost: ${formatCost(cost)}`))
@@ -773,6 +821,199 @@ async function generateWithTextModel({
773
821
  }
774
822
  }
775
823
 
824
+ // Generate using the OpenAI Responses API with imageGeneration tool.
825
+ // Used when auth comes from ChatGPT OAuth (no Image API scope).
826
+ async function generateWithResponsesApi({
827
+ prompt,
828
+ model,
829
+ outputPath,
830
+ count,
831
+ aspectRatio,
832
+ seed,
833
+ inputImages,
834
+ maskImage,
835
+ json,
836
+ stdout,
837
+ }: {
838
+ prompt: string
839
+ model: string
840
+ outputPath: string
841
+ count: number
842
+ aspectRatio?: `${number}:${number}`
843
+ seed?: number
844
+ inputImages: Uint8Array[]
845
+ maskImage?: Uint8Array
846
+ json: boolean
847
+ stdout: boolean
848
+ }) {
849
+ if (count !== 1) {
850
+ console.error(pc.red('ChatGPT image generation currently supports exactly one output image.'))
851
+ process.exit(1)
852
+ }
853
+
854
+ if (seed !== undefined) {
855
+ console.error(pc.red('ChatGPT image generation does not support --seed.'))
856
+ process.exit(1)
857
+ }
858
+
859
+ if (maskImage) {
860
+ console.error(pc.red('ChatGPT image generation does not support --mask yet.'))
861
+ process.exit(1)
862
+ }
863
+
864
+ const size = aspectRatio ? chatGptImageSizeFromAspectRatio(aspectRatio) : undefined
865
+ if (aspectRatio && !size) {
866
+ console.error(
867
+ pc.red(
868
+ 'ChatGPT image generation only supports --aspect-ratio 1:1, 3:2, or 2:3.',
869
+ ),
870
+ )
871
+ process.exit(1)
872
+ }
873
+
874
+ const storedAuth = getChatGptAuth()
875
+ if (!storedAuth?.accountId) {
876
+ console.error(pc.red('Missing ChatGPT account metadata. Please run `egaki login --provider chatgpt` again.'))
877
+ process.exit(1)
878
+ }
879
+
880
+ const auth = await getValidChatGptAuth(storedAuth, saveChatGptAuth)
881
+ if (auth instanceof Error) {
882
+ console.error(pc.red(auth.message))
883
+ process.exit(1)
884
+ }
885
+
886
+ if (!stdout) {
887
+ console.error(pc.cyan('Generating...'))
888
+ }
889
+
890
+ const content: Array<{ type: 'input_text'; text: string } | { type: 'input_image'; image_url: string }> = [
891
+ { type: 'input_text', text: prompt },
892
+ ...inputImages.map((image) => ({
893
+ type: 'input_image' as const,
894
+ image_url: imageBytesToDataUrl(image),
895
+ })),
896
+ ]
897
+
898
+ const response = await fetch('https://chatgpt.com/backend-api/codex/responses', {
899
+ method: 'POST',
900
+ headers: {
901
+ Authorization: `Bearer ${auth.access}`,
902
+ 'ChatGPT-Account-ID': auth.accountId,
903
+ 'Content-Type': 'application/json',
904
+ },
905
+ body: JSON.stringify({
906
+ model: 'gpt-5.4',
907
+ instructions: 'You are Codex.',
908
+ input: [
909
+ {
910
+ type: 'message',
911
+ role: 'user',
912
+ content,
913
+ },
914
+ ],
915
+ tools: [
916
+ {
917
+ type: 'image_generation',
918
+ model,
919
+ size: 'auto',
920
+ quality: 'auto',
921
+ output_format: 'png',
922
+ output_compression: 100,
923
+ moderation: 'auto',
924
+ ...(size ? { size } : {}),
925
+ },
926
+ ],
927
+ tool_choice: 'auto',
928
+ parallel_tool_calls: true,
929
+ stream: true,
930
+ store: false,
931
+ include: [],
932
+ }),
933
+ })
934
+
935
+ if (!response.ok || !response.body) {
936
+ const body = await response.text().catch(() => '')
937
+ console.error(pc.red(`ChatGPT image generation failed: ${response.status}`))
938
+ if (body) console.error(pc.dim(body))
939
+ process.exit(1)
940
+ }
941
+
942
+ let imageBase64: string | undefined
943
+ let revisedPrompt: string | null = null
944
+
945
+ const processSseMessage = (message: EventSourceMessage) => {
946
+ if (!message.data) return
947
+ try {
948
+ const event = JSON.parse(message.data) as {
949
+ type?: string
950
+ partial_image_b64?: string
951
+ item?: { type?: string; result?: string; revised_prompt?: string | null }
952
+ }
953
+ if (
954
+ event.type === 'response.image_generation_call.partial_image' &&
955
+ event.partial_image_b64 &&
956
+ !imageBase64
957
+ ) {
958
+ imageBase64 = event.partial_image_b64
959
+ }
960
+ if (
961
+ event.type === 'response.output_item.done' &&
962
+ event.item?.type === 'image_generation_call'
963
+ ) {
964
+ if (event.item.result) imageBase64 = event.item.result
965
+ revisedPrompt = event.item.revised_prompt ?? revisedPrompt
966
+ }
967
+ } catch {
968
+ // Ignore keepalive and partial parse noise.
969
+ }
970
+ }
971
+
972
+ const parser = createParser({
973
+ onEvent: processSseMessage,
974
+ })
975
+
976
+ const decoder = new TextDecoder()
977
+
978
+ for await (const chunk of response.body) {
979
+ parser.feed(decoder.decode(chunk, { stream: true }))
980
+ }
981
+ parser.feed(decoder.decode())
982
+
983
+ if (!imageBase64) {
984
+ console.error(pc.red('No images generated.'))
985
+ process.exit(1)
986
+ }
987
+
988
+ const imageBytes = Buffer.from(imageBase64, 'base64')
989
+ const mediaType = 'image/png'
990
+
991
+ if (stdout) {
992
+ process.stdout.write(imageBytes)
993
+ return
994
+ }
995
+
996
+ const savedFiles: string[] = []
997
+ const filePath = ensureExtension(outputPath, extensionFromMediaType(mediaType))
998
+ fs.writeFileSync(filePath, imageBytes)
999
+ console.error(pc.green(`Saved: ${filePath}`))
1000
+ savedFiles.push(filePath)
1001
+
1002
+ if (revisedPrompt && !json) {
1003
+ console.error(pc.dim(`Revised prompt: ${revisedPrompt}`))
1004
+ }
1005
+
1006
+ if (json) {
1007
+ const output = {
1008
+ model,
1009
+ files: savedFiles,
1010
+ count: 1,
1011
+ revisedPrompt,
1012
+ }
1013
+ console.log(JSON.stringify(output, null, 2))
1014
+ }
1015
+ }
1016
+
776
1017
  // Generate using experimental_generateVideo (video models)
777
1018
  async function generateWithVideoModel({
778
1019
  prompt,
@@ -974,6 +1215,49 @@ function extensionFromMediaType(mediaType: string): string {
974
1215
  return map[mediaType] || (mediaType.startsWith('video/') ? '.mp4' : '.png')
975
1216
  }
976
1217
 
1218
+ function inferImageMediaType(image: Uint8Array): string {
1219
+ if (image.length >= 8 && image[0] === 0x89 && image[1] === 0x50 && image[2] === 0x4e && image[3] === 0x47) {
1220
+ return 'image/png'
1221
+ }
1222
+ if (image.length >= 3 && image[0] === 0xff && image[1] === 0xd8 && image[2] === 0xff) {
1223
+ return 'image/jpeg'
1224
+ }
1225
+ if (
1226
+ image.length >= 12 &&
1227
+ image[0] === 0x52 && image[1] === 0x49 && image[2] === 0x46 && image[3] === 0x46 &&
1228
+ image[8] === 0x57 && image[9] === 0x45 && image[10] === 0x42 && image[11] === 0x50
1229
+ ) {
1230
+ return 'image/webp'
1231
+ }
1232
+ if (
1233
+ image.length >= 6 &&
1234
+ image[0] === 0x47 && image[1] === 0x49 && image[2] === 0x46 && image[3] === 0x38
1235
+ ) {
1236
+ return 'image/gif'
1237
+ }
1238
+ return 'image/png'
1239
+ }
1240
+
1241
+ function imageBytesToDataUrl(image: Uint8Array): string {
1242
+ const mediaType = inferImageMediaType(image)
1243
+ return `data:${mediaType};base64,${Buffer.from(image).toString('base64')}`
1244
+ }
1245
+
1246
+ function chatGptImageSizeFromAspectRatio(
1247
+ aspectRatio: `${number}:${number}`,
1248
+ ): '1024x1024' | '1536x1024' | '1024x1536' | undefined {
1249
+ switch (aspectRatio) {
1250
+ case '1:1':
1251
+ return '1024x1024'
1252
+ case '3:2':
1253
+ return '1536x1024'
1254
+ case '2:3':
1255
+ return '1024x1536'
1256
+ default:
1257
+ return undefined
1258
+ }
1259
+ }
1260
+
977
1261
  function normalizeResolutionKey(input?: string): string | undefined {
978
1262
  if (!input) return undefined
979
1263
  const normalized = input.trim().toLowerCase()
@@ -983,6 +1267,17 @@ function normalizeResolutionKey(input?: string): string | undefined {
983
1267
  return normalized
984
1268
  }
985
1269
 
1270
+ function isAspectRatio(input: string): input is `${number}:${number}` {
1271
+ return /^\d+:\d+$/.test(input)
1272
+ }
1273
+
1274
+ function parseAspectRatioOption(input?: string): `${number}:${number}` | undefined {
1275
+ if (!input || !isAspectRatio(input)) {
1276
+ return undefined
1277
+ }
1278
+ return input
1279
+ }
1280
+
986
1281
  function ensureExtension(filePath: string, ext: string): string {
987
1282
  const parsed = path.parse(filePath)
988
1283
  if (parsed.ext) {
@@ -1,30 +1,50 @@
1
1
  // Credential storage for egaki.
2
2
  // Reads and writes API keys from ~/.config/egaki/credentials.json.
3
3
  // Keys are stored per-provider and injected as env vars at runtime.
4
+ //
5
+ // Most providers store a plain API key string. The 'chatgpt' provider stores
6
+ // a structured ChatGptAuth object with OAuth tokens for the Codex backend flow.
4
7
  import fs from 'node:fs'
5
8
  import path from 'node:path'
6
9
  import os from 'node:os'
10
+ import type { ChatGptAuth } from './chatgpt-auth.js'
11
+
12
+ export type ProviderInfo = {
13
+ envVar: string
14
+ label: string
15
+ hint: string
16
+ /** If true, this provider uses OAuth instead of a plain API key */
17
+ oauth?: boolean
18
+ }
7
19
 
8
20
  // Maps provider names to their expected env var names.
9
21
  // When a key is stored, we set the corresponding env var before
10
22
  // calling the AI SDK so the provider picks it up automatically.
11
- export const PROVIDERS: Record<
12
- string,
13
- { envVar: string; label: string; hint: string }
14
- > = {
23
+ export const PROVIDERS: Record<string, ProviderInfo> = {
15
24
  egaki: {
16
25
  envVar: 'EGAKI_API_KEY',
17
26
  label: 'Egaki (all models, one subscription)',
18
27
  hint: 'Subscribe at https://egaki.org/buy or run: egaki subscribe',
19
28
  },
29
+ chatgpt: {
30
+ envVar: 'OPENAI_API_KEY',
31
+ label: 'ChatGPT (use your subscription)',
32
+ hint: 'Sign in with your ChatGPT account via browser',
33
+ oauth: true,
34
+ },
20
35
  google: {
21
36
  envVar: 'GOOGLE_GENERATIVE_AI_API_KEY',
22
37
  label: 'Google AI (Gemini, Imagen)',
23
38
  hint: 'Get your key at https://aistudio.google.com/apikey',
24
39
  },
40
+ vertex: {
41
+ envVar: 'GOOGLE_VERTEX_API_KEY',
42
+ label: 'Google Vertex AI (Gemini, Imagen, Veo via Google Cloud)',
43
+ hint: 'Get an API key at https://console.cloud.google.com/apis/credentials',
44
+ },
25
45
  openai: {
26
46
  envVar: 'OPENAI_API_KEY',
27
- label: 'OpenAI (DALL-E)',
47
+ label: 'OpenAI (API key)',
28
48
  hint: 'Get your key at https://platform.openai.com/api-keys',
29
49
  },
30
50
  replicate: {
@@ -52,7 +72,9 @@ function getCredentialsPath(): string {
52
72
  return path.join(getConfigDir(), 'credentials.json')
53
73
  }
54
74
 
55
- export type Credentials = Record<string, string>
75
+ /** A credential value is either a plain API key string or a ChatGPT OAuth object. */
76
+ export type CredentialValue = string | ChatGptAuth
77
+ export type Credentials = Record<string, CredentialValue>
56
78
 
57
79
  export function readCredentials(): Credentials {
58
80
  const filePath = getCredentialsPath()
@@ -78,6 +100,21 @@ export function saveProviderKey(provider: string, key: string): void {
78
100
  writeCredentials(creds)
79
101
  }
80
102
 
103
+ export function saveChatGptAuth(auth: ChatGptAuth): void {
104
+ const creds = readCredentials()
105
+ creds['chatgpt'] = auth
106
+ writeCredentials(creds)
107
+ }
108
+
109
+ export function getChatGptAuth(): ChatGptAuth | undefined {
110
+ const creds = readCredentials()
111
+ const val = creds['chatgpt']
112
+ if (val && typeof val === 'object') {
113
+ return val
114
+ }
115
+ return undefined
116
+ }
117
+
81
118
  export function removeProviderKey(provider: string): void {
82
119
  const creds = readCredentials()
83
120
  delete creds[provider]
@@ -86,35 +123,56 @@ export function removeProviderKey(provider: string): void {
86
123
 
87
124
  export function getProviderKey(provider: string): string | undefined {
88
125
  const creds = readCredentials()
89
- return creds[provider]
126
+ const val = creds[provider]
127
+ return typeof val === 'string' ? val : undefined
90
128
  }
91
129
 
92
- // Inject all stored credentials as env vars so the AI SDK
93
- // providers pick them up automatically. Env vars already set
94
- // by the user take precedence (we don't overwrite them).
130
+ // Inject all stored non-ChatGPT credentials as env vars so the AI SDK
131
+ // providers pick them up automatically. Env vars already set by the user take
132
+ // precedence (we don't overwrite them).
95
133
  export function injectCredentialsToEnv(): void {
96
134
  const creds = readCredentials()
97
- for (const [provider, key] of Object.entries(creds)) {
135
+ for (const [provider, value] of Object.entries(creds)) {
98
136
  const info = PROVIDERS[provider]
99
- if (!info) {
100
- continue
101
- }
102
- if (!process.env[info.envVar]) {
103
- process.env[info.envVar] = key
137
+ if (!info) continue
138
+
139
+ // Skip ChatGPT OAuth entries — handled separately
140
+ if (provider === 'chatgpt') continue
141
+
142
+ if (typeof value === 'string' && !process.env[info.envVar]) {
143
+ process.env[info.envVar] = value
104
144
  }
105
145
  }
106
146
  }
107
147
 
148
+ /**
149
+ * Returns true when the current run should use the ChatGPT/Codex backend flow
150
+ * for OpenAI image models instead of a direct OpenAI API key.
151
+ */
152
+ export function shouldUseChatGptBackend(): boolean {
153
+ return !process.env['OPENAI_API_KEY'] && Boolean(getChatGptAuth())
154
+ }
155
+
108
156
  // Check if a provider has a key available (from env or stored credentials).
109
157
  // Returns the source of the key for display purposes.
110
158
  export function getKeyStatus(provider: string): {
111
159
  available: boolean
112
- source: 'env' | 'stored' | 'none'
160
+ source: 'env' | 'stored' | 'oauth' | 'none'
113
161
  } {
114
162
  const info = PROVIDERS[provider]
115
163
  if (!info) {
116
164
  return { available: false, source: 'none' }
117
165
  }
166
+
167
+ // ChatGPT OAuth is a special case
168
+ if (provider === 'chatgpt') {
169
+ const auth = getChatGptAuth()
170
+ if (auth) {
171
+ return { available: true, source: 'oauth' }
172
+ }
173
+ return { available: false, source: 'none' }
174
+ }
175
+
118
176
  if (process.env[info.envVar]) {
119
177
  return { available: true, source: 'env' }
120
178
  }