@oh-my-pi/pi-ai 3.15.1 → 3.20.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/package.json +1 -1
- package/src/bun-imports.d.ts +14 -0
- package/src/cli.ts +16 -1
- package/src/index.ts +2 -0
- package/src/models.generated.ts +20 -20
- package/src/models.ts +16 -9
- package/src/providers/google-shared.ts +1 -1
- package/src/providers/google-vertex.ts +355 -0
- package/src/providers/openai-codex/constants.ts +25 -0
- package/src/providers/openai-codex/prompts/codex-instructions.md +105 -0
- package/src/providers/openai-codex/prompts/codex.ts +217 -0
- package/src/providers/openai-codex/prompts/pi-codex-bridge.ts +48 -0
- package/src/providers/openai-codex/request-transformer.ts +328 -0
- package/src/providers/openai-codex/response-handler.ts +133 -0
- package/src/providers/openai-codex-responses.ts +619 -0
- package/src/stream.ts +116 -7
- package/src/types.ts +9 -1
- package/src/utils/oauth/index.ts +14 -0
- package/src/utils/oauth/openai-codex.ts +334 -0
- package/src/utils/oauth/types.ts +7 -1
package/src/stream.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
1
4
|
import { supportsXhigh } from "./models";
|
|
2
5
|
import { type AnthropicOptions, streamAnthropic } from "./providers/anthropic";
|
|
3
6
|
import { type GoogleOptions, streamGoogle } from "./providers/google";
|
|
@@ -6,6 +9,8 @@ import {
|
|
|
6
9
|
type GoogleThinkingLevel,
|
|
7
10
|
streamGoogleGeminiCli,
|
|
8
11
|
} from "./providers/google-gemini-cli";
|
|
12
|
+
import { type GoogleVertexOptions, streamGoogleVertex } from "./providers/google-vertex";
|
|
13
|
+
import { type OpenAICodexResponsesOptions, streamOpenAICodexResponses } from "./providers/openai-codex-responses";
|
|
9
14
|
import { type OpenAICompletionsOptions, streamOpenAICompletions } from "./providers/openai-completions";
|
|
10
15
|
import { type OpenAIResponsesOptions, streamOpenAIResponses } from "./providers/openai-responses";
|
|
11
16
|
import type {
|
|
@@ -20,6 +25,17 @@ import type {
|
|
|
20
25
|
SimpleStreamOptions,
|
|
21
26
|
} from "./types";
|
|
22
27
|
|
|
28
|
+
const VERTEX_ADC_CREDENTIALS_PATH = join(homedir(), ".config", "gcloud", "application_default_credentials.json");
|
|
29
|
+
|
|
30
|
+
let cachedVertexAdcCredentialsExists: boolean | null = null;
|
|
31
|
+
|
|
32
|
+
function hasVertexAdcCredentials(): boolean {
|
|
33
|
+
if (cachedVertexAdcCredentialsExists === null) {
|
|
34
|
+
cachedVertexAdcCredentialsExists = existsSync(VERTEX_ADC_CREDENTIALS_PATH);
|
|
35
|
+
}
|
|
36
|
+
return cachedVertexAdcCredentialsExists;
|
|
37
|
+
}
|
|
38
|
+
|
|
23
39
|
/**
|
|
24
40
|
* Get API key for provider from known environment variables, e.g. OPENAI_API_KEY.
|
|
25
41
|
*
|
|
@@ -38,6 +54,19 @@ export function getEnvApiKey(provider: any): string | undefined {
|
|
|
38
54
|
return process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY;
|
|
39
55
|
}
|
|
40
56
|
|
|
57
|
+
// Vertex AI uses Application Default Credentials, not API keys.
|
|
58
|
+
// Auth is configured via `gcloud auth application-default login`.
|
|
59
|
+
if (provider === "google-vertex") {
|
|
60
|
+
const hasCredentials = hasVertexAdcCredentials();
|
|
61
|
+
const hasProject = !!(process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT);
|
|
62
|
+
const hasLocation = !!process.env.GOOGLE_CLOUD_LOCATION;
|
|
63
|
+
|
|
64
|
+
if (hasCredentials && hasProject && hasLocation) {
|
|
65
|
+
return "<authenticated>";
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
41
70
|
const envMap: Record<string, string> = {
|
|
42
71
|
openai: "OPENAI_API_KEY",
|
|
43
72
|
google: "GEMINI_API_KEY",
|
|
@@ -58,6 +87,11 @@ export function stream<TApi extends Api>(
|
|
|
58
87
|
context: Context,
|
|
59
88
|
options?: OptionsForApi<TApi>,
|
|
60
89
|
): AssistantMessageEventStream {
|
|
90
|
+
// Vertex AI uses Application Default Credentials, not API keys
|
|
91
|
+
if (model.api === "google-vertex") {
|
|
92
|
+
return streamGoogleVertex(model as Model<"google-vertex">, context, options as GoogleVertexOptions);
|
|
93
|
+
}
|
|
94
|
+
|
|
61
95
|
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
|
|
62
96
|
if (!apiKey) {
|
|
63
97
|
throw new Error(`No API key for provider: ${model.provider}`);
|
|
@@ -75,6 +109,9 @@ export function stream<TApi extends Api>(
|
|
|
75
109
|
case "openai-responses":
|
|
76
110
|
return streamOpenAIResponses(model as Model<"openai-responses">, context, providerOptions as any);
|
|
77
111
|
|
|
112
|
+
case "openai-codex-responses":
|
|
113
|
+
return streamOpenAICodexResponses(model as Model<"openai-codex-responses">, context, providerOptions as any);
|
|
114
|
+
|
|
78
115
|
case "google-generative-ai":
|
|
79
116
|
return streamGoogle(model as Model<"google-generative-ai">, context, providerOptions);
|
|
80
117
|
|
|
@@ -107,6 +144,12 @@ export function streamSimple<TApi extends Api>(
|
|
|
107
144
|
context: Context,
|
|
108
145
|
options?: SimpleStreamOptions,
|
|
109
146
|
): AssistantMessageEventStream {
|
|
147
|
+
// Vertex AI uses Application Default Credentials, not API keys
|
|
148
|
+
if (model.api === "google-vertex") {
|
|
149
|
+
const providerOptions = mapOptionsForApi(model, options, undefined);
|
|
150
|
+
return stream(model, context, providerOptions);
|
|
151
|
+
}
|
|
152
|
+
|
|
110
153
|
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
|
|
111
154
|
if (!apiKey) {
|
|
112
155
|
throw new Error(`No API key for provider: ${model.provider}`);
|
|
@@ -147,6 +190,8 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
147
190
|
return { ...base, thinkingEnabled: false } satisfies AnthropicOptions;
|
|
148
191
|
}
|
|
149
192
|
|
|
193
|
+
// Claude requires max_tokens > thinking.budget_tokens
|
|
194
|
+
// So we need to ensure maxTokens accounts for both thinking and output
|
|
150
195
|
const anthropicBudgets = {
|
|
151
196
|
minimal: 1024,
|
|
152
197
|
low: 2048,
|
|
@@ -154,10 +199,21 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
154
199
|
high: 16384,
|
|
155
200
|
};
|
|
156
201
|
|
|
202
|
+
const minOutputTokens = 1024;
|
|
203
|
+
let thinkingBudget = anthropicBudgets[clampReasoning(options.reasoning)!];
|
|
204
|
+
// Caller's maxTokens is the desired output; add thinking budget on top, capped at model limit
|
|
205
|
+
const maxTokens = Math.min((base.maxTokens || 0) + thinkingBudget, model.maxTokens);
|
|
206
|
+
|
|
207
|
+
// If not enough room for thinking + output, reduce thinking budget
|
|
208
|
+
if (maxTokens <= thinkingBudget) {
|
|
209
|
+
thinkingBudget = Math.max(0, maxTokens - minOutputTokens);
|
|
210
|
+
}
|
|
211
|
+
|
|
157
212
|
return {
|
|
158
213
|
...base,
|
|
214
|
+
maxTokens,
|
|
159
215
|
thinkingEnabled: true,
|
|
160
|
-
thinkingBudgetTokens:
|
|
216
|
+
thinkingBudgetTokens: thinkingBudget,
|
|
161
217
|
} satisfies AnthropicOptions;
|
|
162
218
|
}
|
|
163
219
|
|
|
@@ -173,6 +229,12 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
173
229
|
reasoningEffort: supportsXhigh(model) ? options?.reasoning : clampReasoning(options?.reasoning),
|
|
174
230
|
} satisfies OpenAIResponsesOptions;
|
|
175
231
|
|
|
232
|
+
case "openai-codex-responses":
|
|
233
|
+
return {
|
|
234
|
+
...base,
|
|
235
|
+
reasoningEffort: supportsXhigh(model) ? options?.reasoning : clampReasoning(options?.reasoning),
|
|
236
|
+
} satisfies OpenAICodexResponsesOptions;
|
|
237
|
+
|
|
176
238
|
case "google-generative-ai": {
|
|
177
239
|
// Explicitly disable thinking when reasoning is not specified
|
|
178
240
|
// This is needed because Gemini has "dynamic thinking" enabled by default
|
|
@@ -222,7 +284,9 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
222
284
|
} satisfies GoogleGeminiCliOptions;
|
|
223
285
|
}
|
|
224
286
|
|
|
225
|
-
// Gemini 2.x
|
|
287
|
+
// Models using thinkingBudget (Gemini 2.x, Claude via Antigravity)
|
|
288
|
+
// Claude requires max_tokens > thinking.budget_tokens
|
|
289
|
+
// So we need to ensure maxTokens accounts for both thinking and output
|
|
226
290
|
const budgets: Record<ClampedReasoningEffort, number> = {
|
|
227
291
|
minimal: 1024,
|
|
228
292
|
low: 2048,
|
|
@@ -230,15 +294,57 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
230
294
|
high: 16384,
|
|
231
295
|
};
|
|
232
296
|
|
|
297
|
+
const minOutputTokens = 1024;
|
|
298
|
+
let thinkingBudget = budgets[effort];
|
|
299
|
+
// Caller's maxTokens is the desired output; add thinking budget on top, capped at model limit
|
|
300
|
+
const maxTokens = Math.min((base.maxTokens || 0) + thinkingBudget, model.maxTokens);
|
|
301
|
+
|
|
302
|
+
// If not enough room for thinking + output, reduce thinking budget
|
|
303
|
+
if (maxTokens <= thinkingBudget) {
|
|
304
|
+
thinkingBudget = Math.max(0, maxTokens - minOutputTokens);
|
|
305
|
+
}
|
|
306
|
+
|
|
233
307
|
return {
|
|
234
308
|
...base,
|
|
309
|
+
maxTokens,
|
|
235
310
|
thinking: {
|
|
236
311
|
enabled: true,
|
|
237
|
-
budgetTokens:
|
|
312
|
+
budgetTokens: thinkingBudget,
|
|
238
313
|
},
|
|
239
314
|
} satisfies GoogleGeminiCliOptions;
|
|
240
315
|
}
|
|
241
316
|
|
|
317
|
+
case "google-vertex": {
|
|
318
|
+
// Explicitly disable thinking when reasoning is not specified
|
|
319
|
+
// This is needed because Gemini has "dynamic thinking" enabled by default
|
|
320
|
+
if (!options?.reasoning) {
|
|
321
|
+
return { ...base, thinking: { enabled: false } } satisfies GoogleVertexOptions;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const googleModel = model as Model<"google-vertex">;
|
|
325
|
+
const effort = clampReasoning(options.reasoning)!;
|
|
326
|
+
|
|
327
|
+
// Gemini 3 models use thinkingLevel exclusively instead of thinkingBudget.
|
|
328
|
+
// https://ai.google.dev/gemini-api/docs/thinking#set-budget
|
|
329
|
+
if (isGemini3ProModel(googleModel) || isGemini3FlashModel(googleModel)) {
|
|
330
|
+
return {
|
|
331
|
+
...base,
|
|
332
|
+
thinking: {
|
|
333
|
+
enabled: true,
|
|
334
|
+
level: getGemini3ThinkingLevel(effort, googleModel),
|
|
335
|
+
},
|
|
336
|
+
} satisfies GoogleVertexOptions;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
...base,
|
|
341
|
+
thinking: {
|
|
342
|
+
enabled: true,
|
|
343
|
+
budgetTokens: getGoogleBudget(googleModel, effort),
|
|
344
|
+
},
|
|
345
|
+
} satisfies GoogleVertexOptions;
|
|
346
|
+
}
|
|
347
|
+
|
|
242
348
|
default: {
|
|
243
349
|
// Exhaustiveness check
|
|
244
350
|
const _exhaustive: never = model.api;
|
|
@@ -249,19 +355,19 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
249
355
|
|
|
250
356
|
type ClampedReasoningEffort = Exclude<ReasoningEffort, "xhigh">;
|
|
251
357
|
|
|
252
|
-
function isGemini3ProModel(model: Model<"google-generative-ai">): boolean {
|
|
358
|
+
function isGemini3ProModel(model: Model<"google-generative-ai"> | Model<"google-vertex">): boolean {
|
|
253
359
|
// Covers gemini-3-pro, gemini-3-pro-preview, and possible other prefixed ids in the future
|
|
254
360
|
return model.id.includes("3-pro");
|
|
255
361
|
}
|
|
256
362
|
|
|
257
|
-
function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean {
|
|
363
|
+
function isGemini3FlashModel(model: Model<"google-generative-ai"> | Model<"google-vertex">): boolean {
|
|
258
364
|
// Covers gemini-3-flash, gemini-3-flash-preview, and possible other prefixed ids in the future
|
|
259
365
|
return model.id.includes("3-flash");
|
|
260
366
|
}
|
|
261
367
|
|
|
262
368
|
function getGemini3ThinkingLevel(
|
|
263
369
|
effort: ClampedReasoningEffort,
|
|
264
|
-
model: Model<"google-generative-ai">,
|
|
370
|
+
model: Model<"google-generative-ai"> | Model<"google-vertex">,
|
|
265
371
|
): GoogleThinkingLevel {
|
|
266
372
|
if (isGemini3ProModel(model)) {
|
|
267
373
|
// Gemini 3 Pro only supports LOW/HIGH (for now)
|
|
@@ -312,7 +418,10 @@ function getGeminiCliThinkingLevel(effort: ClampedReasoningEffort, modelId: stri
|
|
|
312
418
|
}
|
|
313
419
|
}
|
|
314
420
|
|
|
315
|
-
function getGoogleBudget(
|
|
421
|
+
function getGoogleBudget(
|
|
422
|
+
model: Model<"google-generative-ai"> | Model<"google-vertex">,
|
|
423
|
+
effort: ClampedReasoningEffort,
|
|
424
|
+
): number {
|
|
316
425
|
// See https://ai.google.dev/gemini-api/docs/thinking#set-budget
|
|
317
426
|
if (model.id.includes("2.5-pro")) {
|
|
318
427
|
const budgets: Record<ClampedReasoningEffort, number> = {
|
package/src/types.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { AnthropicOptions } from "./providers/anthropic";
|
|
2
2
|
import type { GoogleOptions } from "./providers/google";
|
|
3
3
|
import type { GoogleGeminiCliOptions } from "./providers/google-gemini-cli";
|
|
4
|
+
import type { GoogleVertexOptions } from "./providers/google-vertex";
|
|
5
|
+
import type { OpenAICodexResponsesOptions } from "./providers/openai-codex-responses";
|
|
4
6
|
import type { OpenAICompletionsOptions } from "./providers/openai-completions";
|
|
5
7
|
import type { OpenAIResponsesOptions } from "./providers/openai-responses";
|
|
6
8
|
import type { AssistantMessageEventStream } from "./utils/event-stream";
|
|
@@ -10,16 +12,20 @@ export type { AssistantMessageEventStream } from "./utils/event-stream";
|
|
|
10
12
|
export type Api =
|
|
11
13
|
| "openai-completions"
|
|
12
14
|
| "openai-responses"
|
|
15
|
+
| "openai-codex-responses"
|
|
13
16
|
| "anthropic-messages"
|
|
14
17
|
| "google-generative-ai"
|
|
15
|
-
| "google-gemini-cli"
|
|
18
|
+
| "google-gemini-cli"
|
|
19
|
+
| "google-vertex";
|
|
16
20
|
|
|
17
21
|
export interface ApiOptionsMap {
|
|
18
22
|
"anthropic-messages": AnthropicOptions;
|
|
19
23
|
"openai-completions": OpenAICompletionsOptions;
|
|
20
24
|
"openai-responses": OpenAIResponsesOptions;
|
|
25
|
+
"openai-codex-responses": OpenAICodexResponsesOptions;
|
|
21
26
|
"google-generative-ai": GoogleOptions;
|
|
22
27
|
"google-gemini-cli": GoogleGeminiCliOptions;
|
|
28
|
+
"google-vertex": GoogleVertexOptions;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
// Compile-time exhaustiveness check - this will fail if ApiOptionsMap doesn't have all KnownApi keys
|
|
@@ -39,7 +45,9 @@ export type KnownProvider =
|
|
|
39
45
|
| "google"
|
|
40
46
|
| "google-gemini-cli"
|
|
41
47
|
| "google-antigravity"
|
|
48
|
+
| "google-vertex"
|
|
42
49
|
| "openai"
|
|
50
|
+
| "openai-codex"
|
|
43
51
|
| "github-copilot"
|
|
44
52
|
| "xai"
|
|
45
53
|
| "groq"
|
package/src/utils/oauth/index.ts
CHANGED
|
@@ -28,6 +28,11 @@ export {
|
|
|
28
28
|
loginGeminiCli,
|
|
29
29
|
refreshGoogleCloudToken,
|
|
30
30
|
} from "./google-gemini-cli";
|
|
31
|
+
// OpenAI Codex (ChatGPT OAuth)
|
|
32
|
+
export {
|
|
33
|
+
loginOpenAICodex,
|
|
34
|
+
refreshOpenAICodexToken,
|
|
35
|
+
} from "./openai-codex";
|
|
31
36
|
|
|
32
37
|
export * from "./types";
|
|
33
38
|
|
|
@@ -39,6 +44,7 @@ import { refreshAnthropicToken } from "./anthropic";
|
|
|
39
44
|
import { refreshGitHubCopilotToken } from "./github-copilot";
|
|
40
45
|
import { refreshAntigravityToken } from "./google-antigravity";
|
|
41
46
|
import { refreshGoogleCloudToken } from "./google-gemini-cli";
|
|
47
|
+
import { refreshOpenAICodexToken } from "./openai-codex";
|
|
42
48
|
import type { OAuthCredentials, OAuthProvider, OAuthProviderInfo } from "./types";
|
|
43
49
|
|
|
44
50
|
/**
|
|
@@ -74,6 +80,9 @@ export async function refreshOAuthToken(
|
|
|
74
80
|
}
|
|
75
81
|
newCredentials = await refreshAntigravityToken(credentials.refresh, credentials.projectId);
|
|
76
82
|
break;
|
|
83
|
+
case "openai-codex":
|
|
84
|
+
newCredentials = await refreshOpenAICodexToken(credentials.refresh);
|
|
85
|
+
break;
|
|
77
86
|
default:
|
|
78
87
|
throw new Error(`Unknown OAuth provider: ${provider}`);
|
|
79
88
|
}
|
|
@@ -139,5 +148,10 @@ export function getOAuthProviders(): OAuthProviderInfo[] {
|
|
|
139
148
|
name: "Antigravity (Gemini 3, Claude, GPT-OSS)",
|
|
140
149
|
available: true,
|
|
141
150
|
},
|
|
151
|
+
{
|
|
152
|
+
id: "openai-codex",
|
|
153
|
+
name: "ChatGPT Plus/Pro (Codex Subscription)",
|
|
154
|
+
available: true,
|
|
155
|
+
},
|
|
142
156
|
];
|
|
143
157
|
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI Codex (ChatGPT OAuth) flow
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { generatePKCE } from "./pkce";
|
|
6
|
+
import type { OAuthCredentials, OAuthPrompt } from "./types";
|
|
7
|
+
|
|
8
|
+
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
9
|
+
const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
|
|
10
|
+
const TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
11
|
+
const REDIRECT_URI = "http://localhost:1455/auth/callback";
|
|
12
|
+
const SCOPE = "openid profile email offline_access";
|
|
13
|
+
const JWT_CLAIM_PATH = "https://api.openai.com/auth";
|
|
14
|
+
|
|
15
|
+
const SUCCESS_HTML = `<!doctype html>
|
|
16
|
+
<html lang="en">
|
|
17
|
+
<head>
|
|
18
|
+
<meta charset="utf-8" />
|
|
19
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
20
|
+
<title>Authentication successful</title>
|
|
21
|
+
</head>
|
|
22
|
+
<body>
|
|
23
|
+
<p>Authentication successful. Return to your terminal to continue.</p>
|
|
24
|
+
</body>
|
|
25
|
+
</html>`;
|
|
26
|
+
|
|
27
|
+
type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number };
|
|
28
|
+
type TokenFailure = { type: "failed" };
|
|
29
|
+
type TokenResult = TokenSuccess | TokenFailure;
|
|
30
|
+
|
|
31
|
+
type JwtPayload = {
|
|
32
|
+
[JWT_CLAIM_PATH]?: {
|
|
33
|
+
chatgpt_account_id?: string;
|
|
34
|
+
};
|
|
35
|
+
[key: string]: unknown;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function createState(): string {
|
|
39
|
+
const bytes = new Uint8Array(16);
|
|
40
|
+
crypto.getRandomValues(bytes);
|
|
41
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseAuthorizationInput(input: string): { code?: string; state?: string } {
|
|
45
|
+
const value = input.trim();
|
|
46
|
+
if (!value) return {};
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const url = new URL(value);
|
|
50
|
+
return {
|
|
51
|
+
code: url.searchParams.get("code") ?? undefined,
|
|
52
|
+
state: url.searchParams.get("state") ?? undefined,
|
|
53
|
+
};
|
|
54
|
+
} catch {
|
|
55
|
+
// not a URL
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (value.includes("#")) {
|
|
59
|
+
const [code, state] = value.split("#", 2);
|
|
60
|
+
return { code, state };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (value.includes("code=")) {
|
|
64
|
+
const params = new URLSearchParams(value);
|
|
65
|
+
return {
|
|
66
|
+
code: params.get("code") ?? undefined,
|
|
67
|
+
state: params.get("state") ?? undefined,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { code: value };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function decodeJwt(token: string): JwtPayload | null {
|
|
75
|
+
try {
|
|
76
|
+
const parts = token.split(".");
|
|
77
|
+
if (parts.length !== 3) return null;
|
|
78
|
+
const payload = parts[1] ?? "";
|
|
79
|
+
const decoded = Buffer.from(payload, "base64").toString("utf-8");
|
|
80
|
+
return JSON.parse(decoded) as JwtPayload;
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function exchangeAuthorizationCode(
|
|
87
|
+
code: string,
|
|
88
|
+
verifier: string,
|
|
89
|
+
redirectUri: string = REDIRECT_URI,
|
|
90
|
+
): Promise<TokenResult> {
|
|
91
|
+
const response = await fetch(TOKEN_URL, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
94
|
+
body: new URLSearchParams({
|
|
95
|
+
grant_type: "authorization_code",
|
|
96
|
+
client_id: CLIENT_ID,
|
|
97
|
+
code,
|
|
98
|
+
code_verifier: verifier,
|
|
99
|
+
redirect_uri: redirectUri,
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
const text = await response.text().catch(() => "");
|
|
105
|
+
console.error("[openai-codex] code->token failed:", response.status, text);
|
|
106
|
+
return { type: "failed" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const json = (await response.json()) as {
|
|
110
|
+
access_token?: string;
|
|
111
|
+
refresh_token?: string;
|
|
112
|
+
expires_in?: number;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") {
|
|
116
|
+
console.error("[openai-codex] token response missing fields:", json);
|
|
117
|
+
return { type: "failed" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
type: "success",
|
|
122
|
+
access: json.access_token,
|
|
123
|
+
refresh: json.refresh_token,
|
|
124
|
+
expires: Date.now() + json.expires_in * 1000,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function refreshAccessToken(refreshToken: string): Promise<TokenResult> {
|
|
129
|
+
try {
|
|
130
|
+
const response = await fetch(TOKEN_URL, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
133
|
+
body: new URLSearchParams({
|
|
134
|
+
grant_type: "refresh_token",
|
|
135
|
+
refresh_token: refreshToken,
|
|
136
|
+
client_id: CLIENT_ID,
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
const text = await response.text().catch(() => "");
|
|
142
|
+
console.error("[openai-codex] Token refresh failed:", response.status, text);
|
|
143
|
+
return { type: "failed" };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const json = (await response.json()) as {
|
|
147
|
+
access_token?: string;
|
|
148
|
+
refresh_token?: string;
|
|
149
|
+
expires_in?: number;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") {
|
|
153
|
+
console.error("[openai-codex] Token refresh response missing fields:", json);
|
|
154
|
+
return { type: "failed" };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
type: "success",
|
|
159
|
+
access: json.access_token,
|
|
160
|
+
refresh: json.refresh_token,
|
|
161
|
+
expires: Date.now() + json.expires_in * 1000,
|
|
162
|
+
};
|
|
163
|
+
} catch (error) {
|
|
164
|
+
console.error("[openai-codex] Token refresh error:", error);
|
|
165
|
+
return { type: "failed" };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function createAuthorizationFlow(): Promise<{ verifier: string; state: string; url: string }> {
|
|
170
|
+
const { verifier, challenge } = await generatePKCE();
|
|
171
|
+
const state = createState();
|
|
172
|
+
|
|
173
|
+
const url = new URL(AUTHORIZE_URL);
|
|
174
|
+
url.searchParams.set("response_type", "code");
|
|
175
|
+
url.searchParams.set("client_id", CLIENT_ID);
|
|
176
|
+
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
|
177
|
+
url.searchParams.set("scope", SCOPE);
|
|
178
|
+
url.searchParams.set("code_challenge", challenge);
|
|
179
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
180
|
+
url.searchParams.set("state", state);
|
|
181
|
+
url.searchParams.set("id_token_add_organizations", "true");
|
|
182
|
+
url.searchParams.set("codex_cli_simplified_flow", "true");
|
|
183
|
+
url.searchParams.set("originator", "codex_cli_rs");
|
|
184
|
+
|
|
185
|
+
return { verifier, state, url: url.toString() };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
type OAuthServerInfo = {
|
|
189
|
+
close: () => void;
|
|
190
|
+
waitForCode: () => Promise<{ code: string } | null>;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
function startLocalOAuthServer(state: string): Promise<OAuthServerInfo> {
|
|
194
|
+
let lastCode: string | null = null;
|
|
195
|
+
|
|
196
|
+
return new Promise((resolve) => {
|
|
197
|
+
try {
|
|
198
|
+
const server = Bun.serve({
|
|
199
|
+
port: 1455,
|
|
200
|
+
hostname: "127.0.0.1",
|
|
201
|
+
fetch(req) {
|
|
202
|
+
try {
|
|
203
|
+
const url = new URL(req.url);
|
|
204
|
+
if (url.pathname !== "/auth/callback") {
|
|
205
|
+
return new Response("Not found", { status: 404 });
|
|
206
|
+
}
|
|
207
|
+
if (url.searchParams.get("state") !== state) {
|
|
208
|
+
return new Response("State mismatch", { status: 400 });
|
|
209
|
+
}
|
|
210
|
+
const code = url.searchParams.get("code");
|
|
211
|
+
if (!code) {
|
|
212
|
+
return new Response("Missing authorization code", { status: 400 });
|
|
213
|
+
}
|
|
214
|
+
lastCode = code;
|
|
215
|
+
return new Response(SUCCESS_HTML, {
|
|
216
|
+
status: 200,
|
|
217
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
218
|
+
});
|
|
219
|
+
} catch {
|
|
220
|
+
return new Response("Internal error", { status: 500 });
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
resolve({
|
|
226
|
+
close: () => server.stop(),
|
|
227
|
+
waitForCode: async () => {
|
|
228
|
+
const sleep = () => new Promise((r) => setTimeout(r, 100));
|
|
229
|
+
for (let i = 0; i < 600; i += 1) {
|
|
230
|
+
if (lastCode) return { code: lastCode };
|
|
231
|
+
await sleep();
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
} catch (err) {
|
|
237
|
+
const code = (err as { code?: string }).code;
|
|
238
|
+
console.error(
|
|
239
|
+
"[openai-codex] Failed to bind http://127.0.0.1:1455 (",
|
|
240
|
+
code,
|
|
241
|
+
") Falling back to manual paste.",
|
|
242
|
+
);
|
|
243
|
+
resolve({
|
|
244
|
+
close: () => {},
|
|
245
|
+
waitForCode: async () => null,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function getAccountId(accessToken: string): string | null {
|
|
252
|
+
const payload = decodeJwt(accessToken);
|
|
253
|
+
const auth = payload?.[JWT_CLAIM_PATH];
|
|
254
|
+
const accountId = auth?.chatgpt_account_id;
|
|
255
|
+
return typeof accountId === "string" && accountId.length > 0 ? accountId : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Login with OpenAI Codex OAuth
|
|
260
|
+
*/
|
|
261
|
+
export async function loginOpenAICodex(options: {
|
|
262
|
+
onAuth: (info: { url: string; instructions?: string }) => void;
|
|
263
|
+
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
|
|
264
|
+
onProgress?: (message: string) => void;
|
|
265
|
+
}): Promise<OAuthCredentials> {
|
|
266
|
+
const { verifier, state, url } = await createAuthorizationFlow();
|
|
267
|
+
const server = await startLocalOAuthServer(state);
|
|
268
|
+
|
|
269
|
+
options.onAuth({ url, instructions: "A browser window should open. Complete login to finish." });
|
|
270
|
+
|
|
271
|
+
let code: string | undefined;
|
|
272
|
+
try {
|
|
273
|
+
const result = await server.waitForCode();
|
|
274
|
+
if (result?.code) {
|
|
275
|
+
code = result.code;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!code) {
|
|
279
|
+
const input = await options.onPrompt({
|
|
280
|
+
message: "Paste the authorization code (or full redirect URL):",
|
|
281
|
+
});
|
|
282
|
+
const parsed = parseAuthorizationInput(input);
|
|
283
|
+
if (parsed.state && parsed.state !== state) {
|
|
284
|
+
throw new Error("State mismatch");
|
|
285
|
+
}
|
|
286
|
+
code = parsed.code;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (!code) {
|
|
290
|
+
throw new Error("Missing authorization code");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const tokenResult = await exchangeAuthorizationCode(code, verifier);
|
|
294
|
+
if (tokenResult.type !== "success") {
|
|
295
|
+
throw new Error("Token exchange failed");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const accountId = getAccountId(tokenResult.access);
|
|
299
|
+
if (!accountId) {
|
|
300
|
+
throw new Error("Failed to extract accountId from token");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
access: tokenResult.access,
|
|
305
|
+
refresh: tokenResult.refresh,
|
|
306
|
+
expires: tokenResult.expires,
|
|
307
|
+
accountId,
|
|
308
|
+
};
|
|
309
|
+
} finally {
|
|
310
|
+
server.close();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Refresh OpenAI Codex OAuth token
|
|
316
|
+
*/
|
|
317
|
+
export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAuthCredentials> {
|
|
318
|
+
const result = await refreshAccessToken(refreshToken);
|
|
319
|
+
if (result.type !== "success") {
|
|
320
|
+
throw new Error("Failed to refresh OpenAI Codex token");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const accountId = getAccountId(result.access);
|
|
324
|
+
if (!accountId) {
|
|
325
|
+
throw new Error("Failed to extract accountId from token");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
access: result.access,
|
|
330
|
+
refresh: result.refresh,
|
|
331
|
+
expires: result.expires,
|
|
332
|
+
accountId,
|
|
333
|
+
};
|
|
334
|
+
}
|
package/src/utils/oauth/types.ts
CHANGED
|
@@ -5,9 +5,15 @@ export type OAuthCredentials = {
|
|
|
5
5
|
enterpriseUrl?: string;
|
|
6
6
|
projectId?: string;
|
|
7
7
|
email?: string;
|
|
8
|
+
accountId?: string;
|
|
8
9
|
};
|
|
9
10
|
|
|
10
|
-
export type OAuthProvider =
|
|
11
|
+
export type OAuthProvider =
|
|
12
|
+
| "anthropic"
|
|
13
|
+
| "github-copilot"
|
|
14
|
+
| "google-gemini-cli"
|
|
15
|
+
| "google-antigravity"
|
|
16
|
+
| "openai-codex";
|
|
11
17
|
|
|
12
18
|
export type OAuthPrompt = {
|
|
13
19
|
message: string;
|