egaki 0.3.0 → 0.4.1
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 +51 -0
- package/README.md +15 -0
- 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 +234 -15
- package/dist/credentials.d.ts +17 -4
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +50 -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 +36 -3
- package/dist/models.d.ts +5 -0
- package/dist/models.d.ts.map +1 -1
- package/dist/models.js +11 -1
- package/package.json +3 -2
- package/src/chatgpt-auth.ts +387 -0
- package/src/cli.ts +304 -18
- package/src/credentials.ts +70 -17
- package/src/login.ts +49 -4
- package/src/model-catalog.ts +38 -3
- package/src/models.ts +11 -1
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
|
|
|
@@ -118,8 +127,13 @@ cli
|
|
|
118
127
|
|
|
119
128
|
// Non-interactive: --provider + --key or stdin
|
|
120
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
|
+
}
|
|
121
135
|
const key = options.key || (await readKeyFromStdin())
|
|
122
|
-
loginNonInteractive({ provider: options.provider, key })
|
|
136
|
+
await loginNonInteractive({ provider: options.provider, key })
|
|
123
137
|
return
|
|
124
138
|
}
|
|
125
139
|
|
|
@@ -291,23 +305,21 @@ cli
|
|
|
291
305
|
.example('# Pipe to another tool')
|
|
292
306
|
.example('egaki image "logo design" --stdout | convert - -resize 512x512 logo.png')
|
|
293
307
|
.action(async (prompt, options) => {
|
|
294
|
-
// 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.
|
|
295
309
|
injectCredentialsToEnv()
|
|
296
310
|
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
299
317
|
const config = getModelConfig(model)
|
|
318
|
+
const parsedAspectRatio = parseAspectRatioOption(options.aspectRatio)
|
|
300
319
|
|
|
301
320
|
if (!options.stdout) {
|
|
302
321
|
console.error(pc.dim(`Model: ${model}`))
|
|
303
322
|
console.error(pc.dim(`Prompt: ${prompt}`))
|
|
304
|
-
console.error(
|
|
305
|
-
pc.dim(
|
|
306
|
-
config.strategy === 'image'
|
|
307
|
-
? 'Mode: Image API (generateImage)'
|
|
308
|
-
: 'Mode: Text+Image (generateText)',
|
|
309
|
-
),
|
|
310
|
-
)
|
|
311
323
|
}
|
|
312
324
|
|
|
313
325
|
const inputImages = await readInputImages(options.input)
|
|
@@ -315,13 +327,34 @@ cli
|
|
|
315
327
|
? await readInputSource(options.mask)
|
|
316
328
|
: undefined
|
|
317
329
|
|
|
318
|
-
|
|
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') {
|
|
319
352
|
await generateWithImageModel({
|
|
320
353
|
prompt,
|
|
321
354
|
model,
|
|
322
355
|
outputPath,
|
|
323
|
-
count
|
|
324
|
-
aspectRatio:
|
|
356
|
+
count,
|
|
357
|
+
aspectRatio: parsedAspectRatio,
|
|
325
358
|
seed: options.seed,
|
|
326
359
|
inputImages,
|
|
327
360
|
maskImage,
|
|
@@ -422,8 +455,10 @@ cli
|
|
|
422
455
|
.action(async (prompt, options) => {
|
|
423
456
|
injectCredentialsToEnv()
|
|
424
457
|
|
|
425
|
-
|
|
426
|
-
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
|
|
427
462
|
const config = getModelConfig(model)
|
|
428
463
|
|
|
429
464
|
if (config.strategy !== 'video') {
|
|
@@ -445,7 +480,7 @@ cli
|
|
|
445
480
|
prompt,
|
|
446
481
|
model,
|
|
447
482
|
outputPath,
|
|
448
|
-
count
|
|
483
|
+
count,
|
|
449
484
|
aspectRatio: options.aspectRatio,
|
|
450
485
|
resolution: options.resolution,
|
|
451
486
|
duration: options.duration,
|
|
@@ -482,6 +517,9 @@ cli
|
|
|
482
517
|
.action(async (options) => {
|
|
483
518
|
const { CATALOG } = await import('./model-catalog.js')
|
|
484
519
|
const yaml = await import('js-yaml')
|
|
520
|
+
const providerStatuses = Object.fromEntries(
|
|
521
|
+
Object.keys(PROVIDERS).map((provider) => [provider, getKeyStatus(provider)]),
|
|
522
|
+
)
|
|
485
523
|
|
|
486
524
|
let models = options.type === 'video'
|
|
487
525
|
? VIDEO_CATALOG
|
|
@@ -502,6 +540,7 @@ cli
|
|
|
502
540
|
name: m.name,
|
|
503
541
|
...(m.description ? { description: m.description } : {}),
|
|
504
542
|
provider: m.provider,
|
|
543
|
+
auth: providerStatuses[m.provider] ?? { available: false, source: 'none' },
|
|
505
544
|
strategy: m.strategy,
|
|
506
545
|
released: m.released,
|
|
507
546
|
cost: formatCatalogCost(m.cost),
|
|
@@ -782,6 +821,199 @@ async function generateWithTextModel({
|
|
|
782
821
|
}
|
|
783
822
|
}
|
|
784
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
|
+
|
|
785
1017
|
// Generate using experimental_generateVideo (video models)
|
|
786
1018
|
async function generateWithVideoModel({
|
|
787
1019
|
prompt,
|
|
@@ -983,6 +1215,49 @@ function extensionFromMediaType(mediaType: string): string {
|
|
|
983
1215
|
return map[mediaType] || (mediaType.startsWith('video/') ? '.mp4' : '.png')
|
|
984
1216
|
}
|
|
985
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
|
+
|
|
986
1261
|
function normalizeResolutionKey(input?: string): string | undefined {
|
|
987
1262
|
if (!input) return undefined
|
|
988
1263
|
const normalized = input.trim().toLowerCase()
|
|
@@ -992,6 +1267,17 @@ function normalizeResolutionKey(input?: string): string | undefined {
|
|
|
992
1267
|
return normalized
|
|
993
1268
|
}
|
|
994
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
|
+
|
|
995
1281
|
function ensureExtension(filePath: string, ext: string): string {
|
|
996
1282
|
const parsed = path.parse(filePath)
|
|
997
1283
|
if (parsed.ext) {
|
package/src/credentials.ts
CHANGED
|
@@ -1,22 +1,37 @@
|
|
|
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)',
|
|
@@ -29,7 +44,7 @@ export const PROVIDERS: Record<
|
|
|
29
44
|
},
|
|
30
45
|
openai: {
|
|
31
46
|
envVar: 'OPENAI_API_KEY',
|
|
32
|
-
label: 'OpenAI (
|
|
47
|
+
label: 'OpenAI (API key)',
|
|
33
48
|
hint: 'Get your key at https://platform.openai.com/api-keys',
|
|
34
49
|
},
|
|
35
50
|
replicate: {
|
|
@@ -57,7 +72,9 @@ function getCredentialsPath(): string {
|
|
|
57
72
|
return path.join(getConfigDir(), 'credentials.json')
|
|
58
73
|
}
|
|
59
74
|
|
|
60
|
-
|
|
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>
|
|
61
78
|
|
|
62
79
|
export function readCredentials(): Credentials {
|
|
63
80
|
const filePath = getCredentialsPath()
|
|
@@ -83,6 +100,21 @@ export function saveProviderKey(provider: string, key: string): void {
|
|
|
83
100
|
writeCredentials(creds)
|
|
84
101
|
}
|
|
85
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
|
+
|
|
86
118
|
export function removeProviderKey(provider: string): void {
|
|
87
119
|
const creds = readCredentials()
|
|
88
120
|
delete creds[provider]
|
|
@@ -91,35 +123,56 @@ export function removeProviderKey(provider: string): void {
|
|
|
91
123
|
|
|
92
124
|
export function getProviderKey(provider: string): string | undefined {
|
|
93
125
|
const creds = readCredentials()
|
|
94
|
-
|
|
126
|
+
const val = creds[provider]
|
|
127
|
+
return typeof val === 'string' ? val : undefined
|
|
95
128
|
}
|
|
96
129
|
|
|
97
|
-
// Inject all stored credentials as env vars so the AI SDK
|
|
98
|
-
// providers pick them up automatically. Env vars already set
|
|
99
|
-
//
|
|
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).
|
|
100
133
|
export function injectCredentialsToEnv(): void {
|
|
101
134
|
const creds = readCredentials()
|
|
102
|
-
for (const [provider,
|
|
135
|
+
for (const [provider, value] of Object.entries(creds)) {
|
|
103
136
|
const info = PROVIDERS[provider]
|
|
104
|
-
if (!info)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (
|
|
108
|
-
|
|
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
|
|
109
144
|
}
|
|
110
145
|
}
|
|
111
146
|
}
|
|
112
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
|
+
|
|
113
156
|
// Check if a provider has a key available (from env or stored credentials).
|
|
114
157
|
// Returns the source of the key for display purposes.
|
|
115
158
|
export function getKeyStatus(provider: string): {
|
|
116
159
|
available: boolean
|
|
117
|
-
source: 'env' | 'stored' | 'none'
|
|
160
|
+
source: 'env' | 'stored' | 'oauth' | 'none'
|
|
118
161
|
} {
|
|
119
162
|
const info = PROVIDERS[provider]
|
|
120
163
|
if (!info) {
|
|
121
164
|
return { available: false, source: 'none' }
|
|
122
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
|
+
|
|
123
176
|
if (process.env[info.envVar]) {
|
|
124
177
|
return { available: true, source: 'env' }
|
|
125
178
|
}
|
package/src/login.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Login command — interactive and non-interactive API key management.
|
|
2
2
|
// Interactive mode uses clack prompts for provider selection and key input.
|
|
3
3
|
// Non-interactive mode supports --provider + --key flags and stdin piping.
|
|
4
|
+
// ChatGPT OAuth uses a browser flow instead of pasting a key.
|
|
4
5
|
import {
|
|
5
6
|
intro,
|
|
6
7
|
outro,
|
|
@@ -17,8 +18,10 @@ import {
|
|
|
17
18
|
saveProviderKey,
|
|
18
19
|
removeProviderKey,
|
|
19
20
|
getKeyStatus,
|
|
20
|
-
|
|
21
|
+
getChatGptAuth,
|
|
22
|
+
saveChatGptAuth,
|
|
21
23
|
} from './credentials.js'
|
|
24
|
+
import { chatGptOAuthLogin, extractPlanType } from './chatgpt-auth.js'
|
|
22
25
|
|
|
23
26
|
export async function loginInteractive(): Promise<void> {
|
|
24
27
|
intro(pc.bold('egaki login'))
|
|
@@ -32,6 +35,11 @@ export async function loginInteractive(): Promise<void> {
|
|
|
32
35
|
if (status.source === 'stored') {
|
|
33
36
|
return pc.green('(saved)')
|
|
34
37
|
}
|
|
38
|
+
if (status.source === 'oauth') {
|
|
39
|
+
const auth = getChatGptAuth()
|
|
40
|
+
const email = auth?.email
|
|
41
|
+
return pc.green(`(signed in${email ? ` as ${email}` : ''})`)
|
|
42
|
+
}
|
|
35
43
|
return pc.dim('(not configured)')
|
|
36
44
|
})()
|
|
37
45
|
// Highlight the egaki option as recommended
|
|
@@ -61,6 +69,14 @@ export async function loginInteractive(): Promise<void> {
|
|
|
61
69
|
process.exit(1)
|
|
62
70
|
}
|
|
63
71
|
|
|
72
|
+
// ChatGPT uses browser OAuth instead of key paste
|
|
73
|
+
if (provider === 'chatgpt') {
|
|
74
|
+
const auth = await chatGptOAuthLogin()
|
|
75
|
+
saveChatGptAuth(auth)
|
|
76
|
+
outro('Done — ChatGPT OAuth saved')
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
64
80
|
log.info(
|
|
65
81
|
`${pc.dim('Env var:')} ${info.envVar} ${pc.dim('(also used by the CLI when set)')}`,
|
|
66
82
|
)
|
|
@@ -91,13 +107,13 @@ export async function loginInteractive(): Promise<void> {
|
|
|
91
107
|
outro('Done')
|
|
92
108
|
}
|
|
93
109
|
|
|
94
|
-
export function loginNonInteractive({
|
|
110
|
+
export async function loginNonInteractive({
|
|
95
111
|
provider,
|
|
96
112
|
key,
|
|
97
113
|
}: {
|
|
98
114
|
provider: string
|
|
99
115
|
key: string
|
|
100
|
-
}): void {
|
|
116
|
+
}): Promise<void> {
|
|
101
117
|
const info = PROVIDERS[provider]
|
|
102
118
|
if (!info) {
|
|
103
119
|
const available = Object.keys(PROVIDERS).join(', ')
|
|
@@ -107,6 +123,14 @@ export function loginNonInteractive({
|
|
|
107
123
|
process.exit(1)
|
|
108
124
|
}
|
|
109
125
|
|
|
126
|
+
// ChatGPT uses browser OAuth — key flag is not needed
|
|
127
|
+
if (provider === 'chatgpt') {
|
|
128
|
+
const auth = await chatGptOAuthLogin()
|
|
129
|
+
saveChatGptAuth(auth)
|
|
130
|
+
console.log(pc.green('ChatGPT OAuth saved'))
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
110
134
|
if (!key || key.trim().length === 0) {
|
|
111
135
|
console.error(pc.red('API key cannot be empty'))
|
|
112
136
|
process.exit(1)
|
|
@@ -121,6 +145,26 @@ export function showLoginStatus(): void {
|
|
|
121
145
|
|
|
122
146
|
for (const [key, info] of Object.entries(PROVIDERS)) {
|
|
123
147
|
const status = getKeyStatus(key)
|
|
148
|
+
|
|
149
|
+
if (key === 'chatgpt') {
|
|
150
|
+
const auth = getChatGptAuth()
|
|
151
|
+
if (auth) {
|
|
152
|
+
const plan = extractPlanType(auth)
|
|
153
|
+
const email = auth.email ?? auth.accountId ?? 'unknown'
|
|
154
|
+
const expired = auth.expires < Date.now()
|
|
155
|
+
const expiryLabel = expired
|
|
156
|
+
? pc.yellow('(token expired, will auto-refresh)')
|
|
157
|
+
: pc.dim(`(token valid)`)
|
|
158
|
+
console.log(`${pc.green('*')} ${info.label} ${pc.green('(signed in)')}`)
|
|
159
|
+
console.log(pc.dim(` account: ${email}${plan ? `, plan: ${plan}` : ''}`))
|
|
160
|
+
console.log(` ${expiryLabel}`)
|
|
161
|
+
} else {
|
|
162
|
+
console.log(`${pc.dim('-')} ${info.label} ${pc.dim('(not signed in)')}`)
|
|
163
|
+
console.log(pc.dim(` run: egaki login → select ChatGPT`))
|
|
164
|
+
}
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
|
|
124
168
|
const icon = status.available ? pc.green('*') : pc.dim('-')
|
|
125
169
|
const source = (() => {
|
|
126
170
|
if (status.source === 'env') {
|
|
@@ -150,7 +194,8 @@ export function removeLogin(provider: string): void {
|
|
|
150
194
|
}
|
|
151
195
|
|
|
152
196
|
removeProviderKey(provider)
|
|
153
|
-
|
|
197
|
+
const label = provider === 'chatgpt' ? 'ChatGPT OAuth' : info.label
|
|
198
|
+
console.log(pc.green(`${label} key removed`))
|
|
154
199
|
}
|
|
155
200
|
|
|
156
201
|
// Read API key from stdin (for piping: echo "sk-xxx" | egaki login --provider google)
|