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/CHANGELOG.md +70 -0
- package/README.md +32 -5
- package/dist/chatgpt-auth.d.ts +25 -0
- package/dist/chatgpt-auth.d.ts.map +1 -0
- package/dist/chatgpt-auth.js +18 -11
- package/dist/cli.js +246 -19
- package/dist/credentials.d.ts +17 -4
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +55 -10
- package/dist/login.d.ts +1 -1
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +44 -3
- package/dist/model-catalog.d.ts.map +1 -1
- package/dist/model-catalog.js +87 -3
- package/dist/models.d.ts +5 -0
- package/dist/models.d.ts.map +1 -1
- package/dist/models.js +58 -8
- package/dist/video-model-catalog.d.ts.map +1 -1
- package/dist/video-model-catalog.js +61 -0
- package/package.json +5 -2
- package/skills/egaki/SKILL.md +100 -0
- package/src/chatgpt-auth.ts +387 -0
- package/src/cli.ts +317 -22
- package/src/credentials.ts +75 -17
- package/src/login.ts +49 -4
- package/src/model-catalog.ts +92 -3
- package/src/models.ts +59 -8
- package/src/video-model-catalog.ts +61 -0
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 {
|
|
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
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
|
321
|
-
aspectRatio:
|
|
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
|
-
|
|
421
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/credentials.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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,
|
|
135
|
+
for (const [provider, value] of Object.entries(creds)) {
|
|
98
136
|
const info = PROVIDERS[provider]
|
|
99
|
-
if (!info)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (
|
|
103
|
-
|
|
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
|
}
|