@redstone-md/mapr 0.0.5-alpha → 0.0.6-alpha
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/README.md +20 -0
- package/index.ts +25 -2
- package/lib/ai-analyzer.ts +5 -3
- package/lib/cli-args.ts +16 -1
- package/lib/codex-auth.ts +275 -0
- package/lib/config-prompts.ts +74 -0
- package/lib/config.ts +164 -55
- package/lib/progress.ts +144 -0
- package/lib/provider.ts +216 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@ This repository is public for source visibility and collaboration. The license r
|
|
|
10
10
|
|
|
11
11
|
- Bun-only CLI/TUI with interactive setup through `@clack/prompts`
|
|
12
12
|
- OpenAI and OpenAI-compatible provider support
|
|
13
|
+
- OpenAI Codex family support with fast/reasoning selection
|
|
13
14
|
- Built-in provider presets for BlackBox AI, Nvidia NIM, and OnlySQ
|
|
14
15
|
- Model discovery with searchable selection
|
|
15
16
|
- Automatic context-window detection from provider model metadata when available
|
|
@@ -77,6 +78,14 @@ npx @redstone-md/mapr --help
|
|
|
77
78
|
- `onlysq` -> `https://api.onlysq.ru/ai/openai`
|
|
78
79
|
- `custom` -> any other OpenAI-compatible endpoint
|
|
79
80
|
|
|
81
|
+
## OpenAI Auth Modes
|
|
82
|
+
|
|
83
|
+
- `API key` uses the standard OpenAI API at `https://api.openai.com/v1`
|
|
84
|
+
- `Use existing Codex CLI auth` reuses the local `codex login` browser session from `~/.codex/auth.json`
|
|
85
|
+
- Codex CLI auth automatically switches OpenAI requests to `https://chatgpt.com/backend-api/codex`
|
|
86
|
+
- When the local Codex access token expires, Mapr refreshes it through the official OpenAI refresh-token flow before retrying the request
|
|
87
|
+
- If Codex CLI auth is missing, run `codex login` first and then re-run Mapr
|
|
88
|
+
|
|
80
89
|
## Usage
|
|
81
90
|
|
|
82
91
|
Interactive:
|
|
@@ -105,6 +114,17 @@ List models with detected context sizes when available:
|
|
|
105
114
|
npx @redstone-md/mapr --list-models --headless --provider-preset nvidia-nim --api-key secret
|
|
106
115
|
```
|
|
107
116
|
|
|
117
|
+
List models through an existing Codex CLI login:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
npx @redstone-md/mapr \
|
|
121
|
+
--headless \
|
|
122
|
+
--list-models \
|
|
123
|
+
--provider-type openai \
|
|
124
|
+
--auth-method codex-cli \
|
|
125
|
+
--codex-home ~/.codex
|
|
126
|
+
```
|
|
127
|
+
|
|
108
128
|
Useful flags:
|
|
109
129
|
|
|
110
130
|
- `--max-pages <n>` limits same-origin HTML pages
|
package/index.ts
CHANGED
|
@@ -9,7 +9,8 @@ import { AiBundleAnalyzer, chunkTextByBytes, deriveChunkSizeBytes } from "./lib/
|
|
|
9
9
|
import { getConfigOverrides, parseCliArgs, renderHelpText } from "./lib/cli-args";
|
|
10
10
|
import { ConfigManager } from "./lib/config";
|
|
11
11
|
import { BundleFormatter } from "./lib/formatter";
|
|
12
|
-
import { renderProgressBar } from "./lib/progress";
|
|
12
|
+
import { renderAdaptiveAnalysisProgressLine, renderProgressBar } from "./lib/progress";
|
|
13
|
+
import { findKnownModelInfo, isCodexModel } from "./lib/provider";
|
|
13
14
|
import { ReportWriter } from "./lib/reporter";
|
|
14
15
|
import { BundleScraper } from "./lib/scraper";
|
|
15
16
|
import { SWARM_AGENT_ORDER } from "./lib/swarm-prompts";
|
|
@@ -177,6 +178,7 @@ async function run(): Promise<void> {
|
|
|
177
178
|
const analysisConcurrency = await resolveAnalysisConcurrency(headless, args.analysisConcurrency, totalChunks);
|
|
178
179
|
const totalAgentTasks = Math.max(1, totalChunks * SWARM_AGENT_ORDER.length);
|
|
179
180
|
let completedAgentTasks = 0;
|
|
181
|
+
const analysisStartedAt = Date.now();
|
|
180
182
|
|
|
181
183
|
const analysisStep = spinner({ indicator: "timer" });
|
|
182
184
|
analysisStep.start(formatAnalysisProgress(0, totalAgentTasks, `Starting swarm analysis (${analysisConcurrency} lane${analysisConcurrency === 1 ? "" : "s"})`));
|
|
@@ -190,7 +192,24 @@ async function run(): Promise<void> {
|
|
|
190
192
|
completedAgentTasks += 1;
|
|
191
193
|
}
|
|
192
194
|
|
|
193
|
-
const progressLine =
|
|
195
|
+
const progressLine =
|
|
196
|
+
event.stage === "agent" && event.agent
|
|
197
|
+
? renderAdaptiveAnalysisProgressLine({
|
|
198
|
+
completed: completedAgentTasks,
|
|
199
|
+
total: totalAgentTasks,
|
|
200
|
+
elapsedMs: Date.now() - analysisStartedAt,
|
|
201
|
+
agent: event.agent,
|
|
202
|
+
state: event.state,
|
|
203
|
+
artifactUrl: event.artifactUrl,
|
|
204
|
+
...(event.chunkIndex !== undefined ? { chunkIndex: event.chunkIndex } : {}),
|
|
205
|
+
...(event.chunkCount !== undefined ? { chunkCount: event.chunkCount } : {}),
|
|
206
|
+
...(event.estimatedOutputTokens !== undefined
|
|
207
|
+
? { estimatedOutputTokens: event.estimatedOutputTokens }
|
|
208
|
+
: {}),
|
|
209
|
+
...(event.outputTokens !== undefined ? { outputTokens: event.outputTokens } : {}),
|
|
210
|
+
...(event.tokensPerSecond !== undefined ? { tokensPerSecond: event.tokensPerSecond } : {}),
|
|
211
|
+
})
|
|
212
|
+
: formatAnalysisProgress(completedAgentTasks, totalAgentTasks, event.message);
|
|
194
213
|
analysisStep.message(progressLine);
|
|
195
214
|
|
|
196
215
|
if (args.verboseAgents && event.stage === "agent" && event.state === "completed") {
|
|
@@ -247,12 +266,16 @@ async function run(): Promise<void> {
|
|
|
247
266
|
});
|
|
248
267
|
reportStep.stop(reportStatus === "partial" ? "Partial report written to disk" : "Report written to disk");
|
|
249
268
|
|
|
269
|
+
const selectedModelInfo = findKnownModelInfo(config.model);
|
|
250
270
|
const summaryLines = [
|
|
251
271
|
reportStatus === "partial" ? `${pc.yellow("Analysis incomplete.")}` : `${pc.green("Analysis complete.")}`,
|
|
252
272
|
`${pc.bold("Status:")} ${reportStatus === "partial" ? "partial report saved after error" : "complete"}`,
|
|
253
273
|
`${pc.bold("Target:")} ${scrapeResult.pageUrl}`,
|
|
254
274
|
`${pc.bold("Provider:")} ${config.providerName} (${config.model})`,
|
|
275
|
+
...(config.authMethod !== undefined ? [`${pc.bold("Auth:")} ${config.authMethod}`] : []),
|
|
255
276
|
`${pc.bold("Context size:")} ${config.modelContextSize.toLocaleString()} tokens`,
|
|
277
|
+
...(config.openAiMode !== undefined ? [`${pc.bold("OpenAI mode:")} ${config.openAiMode}`] : []),
|
|
278
|
+
...(isCodexModel(config.model) && selectedModelInfo?.usageLimitsNote ? [`${pc.bold("Codex limits:")} ${selectedModelInfo.usageLimitsNote}`] : []),
|
|
256
279
|
`${pc.bold("Concurrency:")} ${analysisConcurrency}`,
|
|
257
280
|
`${pc.bold("Local RAG:")} ${args.localRag ? "enabled" : "disabled"}`,
|
|
258
281
|
`${pc.bold("Pages:")} ${scrapeResult.htmlPages.length}`,
|
package/lib/ai-analyzer.ts
CHANGED
|
@@ -85,12 +85,14 @@ export class AiBundleAnalyzer {
|
|
|
85
85
|
private readonly localRagEnabled: boolean;
|
|
86
86
|
private readonly analysisConcurrency: number;
|
|
87
87
|
private readonly onProgress: ((event: AnalysisProgressEvent) => void) | undefined;
|
|
88
|
+
private readonly providerOptions: Record<string, unknown>;
|
|
88
89
|
|
|
89
90
|
public constructor(options: AnalyzerOptions) {
|
|
90
91
|
this.providerClient = new AiProviderClient(options.providerConfig);
|
|
91
92
|
this.chunkSizeBytes = options.chunkSizeBytes ?? deriveChunkSizeBytes(options.providerConfig.modelContextSize);
|
|
92
93
|
this.localRagEnabled = options.localRag ?? false;
|
|
93
94
|
this.analysisConcurrency = Math.max(1, Math.floor(options.analysisConcurrency ?? 1));
|
|
95
|
+
this.providerOptions = this.providerClient.getProviderOptions();
|
|
94
96
|
this.onProgress = options.onProgress;
|
|
95
97
|
}
|
|
96
98
|
|
|
@@ -271,7 +273,7 @@ export class AiBundleAnalyzer {
|
|
|
271
273
|
].join("\n"),
|
|
272
274
|
attempts: 4,
|
|
273
275
|
maxRetries: 2,
|
|
274
|
-
providerOptions:
|
|
276
|
+
providerOptions: this.providerOptions,
|
|
275
277
|
onRetry: (attempt, error) =>
|
|
276
278
|
this.emitAgentEvent(
|
|
277
279
|
"streaming",
|
|
@@ -306,7 +308,7 @@ export class AiBundleAnalyzer {
|
|
|
306
308
|
].join("\n"),
|
|
307
309
|
attempts: 4,
|
|
308
310
|
maxRetries: 2,
|
|
309
|
-
providerOptions:
|
|
311
|
+
providerOptions: this.providerOptions,
|
|
310
312
|
onRetry: (attempt, error) =>
|
|
311
313
|
this.emitAgentEvent(
|
|
312
314
|
"streaming",
|
|
@@ -346,7 +348,7 @@ export class AiBundleAnalyzer {
|
|
|
346
348
|
].join("\n"),
|
|
347
349
|
attempts: 4,
|
|
348
350
|
maxRetries: 2,
|
|
349
|
-
providerOptions:
|
|
351
|
+
providerOptions: this.providerOptions,
|
|
350
352
|
});
|
|
351
353
|
|
|
352
354
|
return finalAnalysisSchema.parse({
|
package/lib/cli-args.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
|
-
import { getProviderPreset, providerPresetSchema, providerTypeSchema } from "./provider";
|
|
3
|
+
import { authMethodSchema, getProviderPreset, openAiModeSchema, providerPresetSchema, providerTypeSchema } from "./provider";
|
|
4
4
|
|
|
5
5
|
const rawCliArgsSchema = z.object({
|
|
6
6
|
help: z.boolean().default(false),
|
|
@@ -14,8 +14,11 @@ const rawCliArgsSchema = z.object({
|
|
|
14
14
|
output: z.string().min(1).optional(),
|
|
15
15
|
providerType: providerTypeSchema.optional(),
|
|
16
16
|
providerPreset: providerPresetSchema.optional(),
|
|
17
|
+
openAiMode: openAiModeSchema.optional(),
|
|
18
|
+
authMethod: authMethodSchema.optional(),
|
|
17
19
|
providerName: z.string().min(1).optional(),
|
|
18
20
|
apiKey: z.string().min(1).optional(),
|
|
21
|
+
codexHomePath: z.string().min(1).optional(),
|
|
19
22
|
baseURL: z.string().url().optional(),
|
|
20
23
|
model: z.string().min(1).optional(),
|
|
21
24
|
contextSize: z.number().int().positive().optional(),
|
|
@@ -29,8 +32,11 @@ const cliConfigOverrideSchema = z
|
|
|
29
32
|
.object({
|
|
30
33
|
providerType: providerTypeSchema.optional(),
|
|
31
34
|
providerPreset: providerPresetSchema.optional(),
|
|
35
|
+
openAiMode: openAiModeSchema.optional(),
|
|
36
|
+
authMethod: authMethodSchema.optional(),
|
|
32
37
|
providerName: z.string().min(1).optional(),
|
|
33
38
|
apiKey: z.string().min(1).optional(),
|
|
39
|
+
codexHomePath: z.string().min(1).optional(),
|
|
34
40
|
baseURL: z.string().url().optional(),
|
|
35
41
|
model: z.string().min(1).optional(),
|
|
36
42
|
modelContextSize: z.number().int().positive().optional(),
|
|
@@ -54,8 +60,11 @@ const optionMap = new Map<string, keyof CliArgs>([
|
|
|
54
60
|
["--output", "output"],
|
|
55
61
|
["--provider-type", "providerType"],
|
|
56
62
|
["--provider-preset", "providerPreset"],
|
|
63
|
+
["--openai-mode", "openAiMode"],
|
|
64
|
+
["--auth-method", "authMethod"],
|
|
57
65
|
["--provider-name", "providerName"],
|
|
58
66
|
["--api-key", "apiKey"],
|
|
67
|
+
["--codex-home", "codexHomePath"],
|
|
59
68
|
["--base-url", "baseURL"],
|
|
60
69
|
["--model", "model"],
|
|
61
70
|
["--context-size", "contextSize"],
|
|
@@ -117,6 +126,8 @@ export function getConfigOverrides(args: CliArgs) {
|
|
|
117
126
|
const overrides: Record<string, unknown> = {};
|
|
118
127
|
|
|
119
128
|
if (args.providerType !== undefined) overrides.providerType = args.providerType;
|
|
129
|
+
if (args.openAiMode !== undefined) overrides.openAiMode = args.openAiMode;
|
|
130
|
+
if (args.authMethod !== undefined) overrides.authMethod = args.authMethod;
|
|
120
131
|
if (args.providerPreset !== undefined) {
|
|
121
132
|
const preset = getProviderPreset(args.providerPreset);
|
|
122
133
|
overrides.providerType = "openai-compatible";
|
|
@@ -126,6 +137,7 @@ export function getConfigOverrides(args: CliArgs) {
|
|
|
126
137
|
}
|
|
127
138
|
if (args.providerName !== undefined) overrides.providerName = args.providerName;
|
|
128
139
|
if (args.apiKey !== undefined) overrides.apiKey = args.apiKey;
|
|
140
|
+
if (args.codexHomePath !== undefined) overrides.codexHomePath = args.codexHomePath;
|
|
129
141
|
if (args.baseURL !== undefined) overrides.baseURL = args.baseURL;
|
|
130
142
|
if (args.model !== undefined) overrides.model = args.model;
|
|
131
143
|
if (args.contextSize !== undefined) overrides.modelContextSize = args.contextSize;
|
|
@@ -151,8 +163,11 @@ export function renderHelpText(): string {
|
|
|
151
163
|
"Provider options:",
|
|
152
164
|
" --provider-type <type> openai | openai-compatible",
|
|
153
165
|
" --provider-preset <preset> custom | blackbox | nvidia-nim | onlysq",
|
|
166
|
+
" --openai-mode <mode> fast | reasoning",
|
|
167
|
+
" --auth-method <method> api-key | codex-cli",
|
|
154
168
|
" --provider-name <name> Display name for the provider",
|
|
155
169
|
" --api-key <key> Provider API key",
|
|
170
|
+
" --codex-home <path> Path to the local Codex CLI home, defaults to ~/.codex",
|
|
156
171
|
" --base-url <url> Base URL for the provider",
|
|
157
172
|
" --model <id> Model identifier",
|
|
158
173
|
" --context-size <tokens> Model context window, for example 128000 or 512000",
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { readFile, writeFile } from "fs/promises";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
export const CODEX_REFRESH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
7
|
+
export const DEFAULT_CODEX_HOME_PATH = join(homedir(), ".codex");
|
|
8
|
+
export const DEFAULT_CHATGPT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex";
|
|
9
|
+
const DEFAULT_REFRESH_ENDPOINT = "https://auth.openai.com/oauth/token";
|
|
10
|
+
const TOKEN_REFRESH_LEEWAY_MS = 5 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
const codexTokenSchema = z
|
|
13
|
+
.object({
|
|
14
|
+
id_token: z.string().min(1),
|
|
15
|
+
access_token: z.string().min(1),
|
|
16
|
+
refresh_token: z.string().min(1),
|
|
17
|
+
account_id: z.string().min(1).nullable().optional(),
|
|
18
|
+
})
|
|
19
|
+
.strict();
|
|
20
|
+
|
|
21
|
+
const codexAuthFileSchema = z
|
|
22
|
+
.object({
|
|
23
|
+
auth_mode: z.string().optional(),
|
|
24
|
+
OPENAI_API_KEY: z.string().nullable().optional(),
|
|
25
|
+
tokens: codexTokenSchema.optional(),
|
|
26
|
+
last_refresh: z.string().min(1).optional(),
|
|
27
|
+
})
|
|
28
|
+
.strict();
|
|
29
|
+
|
|
30
|
+
const refreshResponseSchema = z
|
|
31
|
+
.object({
|
|
32
|
+
id_token: z.string().min(1).optional(),
|
|
33
|
+
access_token: z.string().min(1).optional(),
|
|
34
|
+
refresh_token: z.string().min(1).optional(),
|
|
35
|
+
})
|
|
36
|
+
.passthrough();
|
|
37
|
+
|
|
38
|
+
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
39
|
+
type HeaderLike = Headers | Array<[string, string]> | Record<string, string>;
|
|
40
|
+
|
|
41
|
+
export interface CodexCliAuthState {
|
|
42
|
+
accessToken: string;
|
|
43
|
+
refreshToken: string;
|
|
44
|
+
accountId: string;
|
|
45
|
+
planType?: string;
|
|
46
|
+
expiresAt?: number;
|
|
47
|
+
idToken: string;
|
|
48
|
+
authFilePath: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseJwtPayload(token: string): Record<string, unknown> {
|
|
52
|
+
const payload = token.split(".")[1];
|
|
53
|
+
if (!payload) {
|
|
54
|
+
throw new Error("Codex token is malformed.");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const normalizedPayload = payload.replace(/-/g, "+").replace(/_/g, "/");
|
|
58
|
+
const padding = normalizedPayload.length % 4 === 0 ? "" : "=".repeat(4 - (normalizedPayload.length % 4));
|
|
59
|
+
const decoded = Buffer.from(`${normalizedPayload}${padding}`, "base64").toString("utf8");
|
|
60
|
+
return z.record(z.string(), z.unknown()).parse(JSON.parse(decoded) as unknown);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function extractString(value: unknown): string | undefined {
|
|
64
|
+
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function extractAccountId(idToken: string, fallbackAccountId?: string): string | undefined {
|
|
68
|
+
const payload = parseJwtPayload(idToken);
|
|
69
|
+
const auth = z.record(z.string(), z.unknown()).safeParse(payload["https://api.openai.com/auth"]);
|
|
70
|
+
if (auth.success) {
|
|
71
|
+
return extractString(auth.data.chatgpt_account_id) ?? fallbackAccountId;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return fallbackAccountId;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function extractPlanType(idToken: string): string | undefined {
|
|
78
|
+
const payload = parseJwtPayload(idToken);
|
|
79
|
+
const auth = z.record(z.string(), z.unknown()).safeParse(payload["https://api.openai.com/auth"]);
|
|
80
|
+
if (!auth.success) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return extractString(auth.data.chatgpt_plan_type)?.toLowerCase();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function extractExpiresAt(accessToken: string): number | undefined {
|
|
88
|
+
const payload = parseJwtPayload(accessToken);
|
|
89
|
+
const exp = payload.exp;
|
|
90
|
+
return typeof exp === "number" && Number.isFinite(exp) ? exp * 1000 : undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function mergeHeaders(input: HeaderLike | undefined, overrides: Record<string, string>): Headers {
|
|
94
|
+
const headers = new Headers(input);
|
|
95
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
96
|
+
headers.set(key, value);
|
|
97
|
+
}
|
|
98
|
+
return headers;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class CodexCliAuthManager {
|
|
102
|
+
private readonly codexHomePath: string;
|
|
103
|
+
private readonly fetcher: FetchLike;
|
|
104
|
+
private refreshPromise: Promise<CodexCliAuthState> | null = null;
|
|
105
|
+
|
|
106
|
+
public constructor(options: { codexHomePath?: string; fetcher?: FetchLike } = {}) {
|
|
107
|
+
this.codexHomePath = options.codexHomePath ?? DEFAULT_CODEX_HOME_PATH;
|
|
108
|
+
this.fetcher = options.fetcher ?? fetch;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
public getCodexHomePath(): string {
|
|
112
|
+
return this.codexHomePath;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
public getAuthFilePath(): string {
|
|
116
|
+
return join(this.codexHomePath, "auth.json");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
public async loadState(): Promise<CodexCliAuthState> {
|
|
120
|
+
const raw = await readFile(this.getAuthFilePath(), "utf8");
|
|
121
|
+
const parsed = codexAuthFileSchema.parse(JSON.parse(raw) as unknown);
|
|
122
|
+
if (!parsed.tokens) {
|
|
123
|
+
throw new Error(`Codex CLI auth was not found at ${this.getAuthFilePath()}. Run \`codex login\` first.`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const accountId = extractAccountId(parsed.tokens.id_token, parsed.tokens.account_id ?? undefined);
|
|
127
|
+
if (!accountId) {
|
|
128
|
+
throw new Error("Codex CLI auth is missing a ChatGPT account identifier.");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
accessToken: parsed.tokens.access_token,
|
|
133
|
+
refreshToken: parsed.tokens.refresh_token,
|
|
134
|
+
accountId,
|
|
135
|
+
...(extractPlanType(parsed.tokens.id_token) !== undefined
|
|
136
|
+
? { planType: extractPlanType(parsed.tokens.id_token)! }
|
|
137
|
+
: {}),
|
|
138
|
+
...(extractExpiresAt(parsed.tokens.access_token) !== undefined
|
|
139
|
+
? { expiresAt: extractExpiresAt(parsed.tokens.access_token)! }
|
|
140
|
+
: {}),
|
|
141
|
+
idToken: parsed.tokens.id_token,
|
|
142
|
+
authFilePath: this.getAuthFilePath(),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
public async ensureFreshState(): Promise<CodexCliAuthState> {
|
|
147
|
+
const state = await this.loadState();
|
|
148
|
+
if (!this.shouldRefresh(state)) {
|
|
149
|
+
return state;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return this.refreshState();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
public async buildAuthHeaders(): Promise<Record<string, string>> {
|
|
156
|
+
const state = await this.ensureFreshState();
|
|
157
|
+
return {
|
|
158
|
+
Authorization: `Bearer ${state.accessToken}`,
|
|
159
|
+
"ChatGPT-Account-Id": state.accountId,
|
|
160
|
+
Accept: "application/json",
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
public createAuthenticatedFetch(baseFetch: FetchLike = this.fetcher): FetchLike {
|
|
165
|
+
return async (input, init) => {
|
|
166
|
+
const sourceRequest = input instanceof Request ? input : new Request(String(input), init);
|
|
167
|
+
const requestBody =
|
|
168
|
+
sourceRequest.method === "GET" || sourceRequest.method === "HEAD"
|
|
169
|
+
? undefined
|
|
170
|
+
: await sourceRequest.clone().arrayBuffer();
|
|
171
|
+
const execute = async (forceRefresh = false): Promise<Response> => {
|
|
172
|
+
const state = forceRefresh ? await this.refreshState() : await this.ensureFreshState();
|
|
173
|
+
const headers = mergeHeaders(sourceRequest.headers, {
|
|
174
|
+
Authorization: `Bearer ${state.accessToken}`,
|
|
175
|
+
"ChatGPT-Account-Id": state.accountId,
|
|
176
|
+
});
|
|
177
|
+
const request = new Request(sourceRequest, {
|
|
178
|
+
headers,
|
|
179
|
+
...(requestBody !== undefined ? { body: requestBody.slice(0) } : {}),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const response = await baseFetch(request);
|
|
183
|
+
|
|
184
|
+
if (response.status !== 401 || forceRefresh) {
|
|
185
|
+
return response;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return execute(true);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return execute(false);
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
public async refreshState(): Promise<CodexCliAuthState> {
|
|
196
|
+
if (!this.refreshPromise) {
|
|
197
|
+
this.refreshPromise = this.refreshStateInternal().finally(() => {
|
|
198
|
+
this.refreshPromise = null;
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return this.refreshPromise;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private shouldRefresh(state: CodexCliAuthState): boolean {
|
|
206
|
+
return state.expiresAt !== undefined && state.expiresAt - Date.now() <= TOKEN_REFRESH_LEEWAY_MS;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async refreshStateInternal(): Promise<CodexCliAuthState> {
|
|
210
|
+
const current = await this.loadState();
|
|
211
|
+
const response = await this.fetcher(DEFAULT_REFRESH_ENDPOINT, {
|
|
212
|
+
method: "POST",
|
|
213
|
+
headers: {
|
|
214
|
+
"Content-Type": "application/json",
|
|
215
|
+
Accept: "application/json",
|
|
216
|
+
},
|
|
217
|
+
body: JSON.stringify({
|
|
218
|
+
client_id: CODEX_REFRESH_CLIENT_ID,
|
|
219
|
+
grant_type: "refresh_token",
|
|
220
|
+
refresh_token: current.refreshToken,
|
|
221
|
+
}),
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const bodyText = await response.text();
|
|
225
|
+
if (!response.ok) {
|
|
226
|
+
let message = bodyText.trim();
|
|
227
|
+
try {
|
|
228
|
+
const parsedError = z.record(z.string(), z.unknown()).parse(JSON.parse(bodyText) as unknown);
|
|
229
|
+
const nestedError = parsedError.error;
|
|
230
|
+
if (typeof nestedError === "string") {
|
|
231
|
+
message = nestedError;
|
|
232
|
+
} else if (nestedError && typeof nestedError === "object" && "message" in nestedError) {
|
|
233
|
+
message = extractString((nestedError as Record<string, unknown>).message) ?? message;
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
// Keep the raw response body when the backend did not return JSON.
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
throw new Error(`Codex CLI auth refresh failed: ${response.status} ${message || response.statusText}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const refresh = refreshResponseSchema.parse(JSON.parse(bodyText) as unknown);
|
|
243
|
+
const nextIdToken = refresh.id_token ?? current.idToken;
|
|
244
|
+
const nextAccessToken = refresh.access_token ?? current.accessToken;
|
|
245
|
+
const nextRefreshToken = refresh.refresh_token ?? current.refreshToken;
|
|
246
|
+
const nextAccountId = extractAccountId(nextIdToken, current.accountId);
|
|
247
|
+
if (!nextAccountId) {
|
|
248
|
+
throw new Error("Codex CLI auth refresh returned a token without a ChatGPT account identifier.");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const persisted = {
|
|
252
|
+
auth_mode: "chatgpt",
|
|
253
|
+
OPENAI_API_KEY: null,
|
|
254
|
+
tokens: {
|
|
255
|
+
id_token: nextIdToken,
|
|
256
|
+
access_token: nextAccessToken,
|
|
257
|
+
refresh_token: nextRefreshToken,
|
|
258
|
+
account_id: nextAccountId,
|
|
259
|
+
},
|
|
260
|
+
last_refresh: new Date().toISOString(),
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
await writeFile(this.getAuthFilePath(), `${JSON.stringify(persisted, null, 2)}\n`, "utf8");
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
accessToken: nextAccessToken,
|
|
267
|
+
refreshToken: nextRefreshToken,
|
|
268
|
+
accountId: nextAccountId,
|
|
269
|
+
...(extractPlanType(nextIdToken) !== undefined ? { planType: extractPlanType(nextIdToken)! } : {}),
|
|
270
|
+
...(extractExpiresAt(nextAccessToken) !== undefined ? { expiresAt: extractExpiresAt(nextAccessToken)! } : {}),
|
|
271
|
+
idToken: nextIdToken,
|
|
272
|
+
authFilePath: this.getAuthFilePath(),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { select, text } from "@clack/prompts";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_CODEX_HOME_PATH } from "./codex-auth";
|
|
5
|
+
import { DEFAULT_MODEL_CONTEXT_SIZE } from "./provider";
|
|
6
|
+
|
|
7
|
+
type ExitIfCancelled = <T>(value: T) => T;
|
|
8
|
+
|
|
9
|
+
export async function promptForContextSize(defaultValue: number, exitIfCancelled: ExitIfCancelled): Promise<number> {
|
|
10
|
+
const rawValue = exitIfCancelled(
|
|
11
|
+
await text({
|
|
12
|
+
message: "Model context size in tokens",
|
|
13
|
+
placeholder: String(DEFAULT_MODEL_CONTEXT_SIZE),
|
|
14
|
+
initialValue: String(defaultValue),
|
|
15
|
+
validate(value) {
|
|
16
|
+
const parsed = z.coerce.number().int().positive().safeParse(value);
|
|
17
|
+
return parsed.success ? undefined : "Context size must be a positive integer.";
|
|
18
|
+
},
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
return z.coerce.number().int().positive().parse(rawValue);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function promptForAuthMethod<T>(
|
|
26
|
+
initialMethod: "api-key" | "codex-cli" | undefined,
|
|
27
|
+
exitIfCancelled: ExitIfCancelled,
|
|
28
|
+
): Promise<"api-key" | "codex-cli"> {
|
|
29
|
+
return exitIfCancelled(
|
|
30
|
+
await select({
|
|
31
|
+
message: "OpenAI auth method",
|
|
32
|
+
initialValue: initialMethod ?? "api-key",
|
|
33
|
+
options: [
|
|
34
|
+
{ value: "api-key", label: "API key", hint: "Use a standard OpenAI API key" },
|
|
35
|
+
{ value: "codex-cli", label: "Use existing Codex CLI auth", hint: "Reuse `codex login` browser sign-in" },
|
|
36
|
+
],
|
|
37
|
+
}),
|
|
38
|
+
) as "api-key" | "codex-cli";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function promptForCodexMode(
|
|
42
|
+
initialMode: "fast" | "reasoning" | undefined,
|
|
43
|
+
exitIfCancelled: ExitIfCancelled,
|
|
44
|
+
): Promise<"fast" | "reasoning"> {
|
|
45
|
+
return exitIfCancelled(
|
|
46
|
+
await select({
|
|
47
|
+
message: "Codex mode",
|
|
48
|
+
initialValue: initialMode ?? "reasoning",
|
|
49
|
+
options: [
|
|
50
|
+
{ value: "fast", label: "Fast", hint: "Prefer mini / lower-latency Codex variant" },
|
|
51
|
+
{ value: "reasoning", label: "Reasoning", hint: "Prefer max / deeper reasoning Codex variant" },
|
|
52
|
+
],
|
|
53
|
+
}),
|
|
54
|
+
) as "fast" | "reasoning";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function promptForCodexHomePath(
|
|
58
|
+
initialPath: string | undefined,
|
|
59
|
+
exitIfCancelled: ExitIfCancelled,
|
|
60
|
+
): Promise<string> {
|
|
61
|
+
return z.string().trim().min(1).parse(
|
|
62
|
+
exitIfCancelled(
|
|
63
|
+
await text({
|
|
64
|
+
message: "Codex CLI home path",
|
|
65
|
+
placeholder: DEFAULT_CODEX_HOME_PATH,
|
|
66
|
+
initialValue: initialPath ?? DEFAULT_CODEX_HOME_PATH,
|
|
67
|
+
validate(value) {
|
|
68
|
+
const parsed = z.string().trim().min(1).safeParse(value);
|
|
69
|
+
return parsed.success ? undefined : "Codex home path is required.";
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
}
|