@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/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: anthropicBudgets[clampReasoning(options.reasoning)!],
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 models use thinkingBudget
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: budgets[effort],
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(model: Model<"google-generative-ai">, effort: ClampedReasoningEffort): number {
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"
@@ -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
+ }
@@ -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 = "anthropic" | "github-copilot" | "google-gemini-cli" | "google-antigravity";
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;