@f5-sales-demo/pi-ai 20.19.2 → 20.19.4

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 CHANGED
@@ -46,7 +46,8 @@ Unified LLM API with automatic model discovery, provider configuration, token an
46
46
 
47
47
  ## Supported Providers
48
48
 
49
- - **OpenAI** (usage-based Platform API access)
49
+ - **OpenAI** (usage-based Platform API access through `OPENAI_API_KEY`)
50
+ - **OpenAI Codex** (ChatGPT Plus/Pro subscription through browser OAuth)
50
51
  - **Anthropic**
51
52
  - **Google**
52
53
  - **Vertex AI** (Gemini via Vertex AI)
@@ -1046,7 +1047,7 @@ bunx @f5-sales-demo/pi-ai list # list available providers
1046
1047
  Credentials are saved to `agent.db` in the agent directory. `/login qianfan` opens the Qianfan console and stores the pasted API key.
1047
1048
 
1048
1049
  `login` supports OAuth providers (Anthropic, GitHub Copilot, Gemini CLI, Antigravity) and API-key onboarding flows.
1049
- OpenAI uses usage-based Platform API access through `OPENAI_API_KEY`. For ChatGPT subscription access, use the official Codex CLI and run `codex login`.
1050
+ OpenAI has two credential paths. Use `OPENAI_API_KEY` for usage-based Platform API access. Use `login openai-codex` for ChatGPT Plus/Pro subscription OAuth; `login openai` explains the API-key path.
1050
1051
 
1051
1052
  For the current OpenAI-compatible integrations, API-key onboarding covers Together, Moonshot, Qianfan, NVIDIA, NanoGPT, Hugging Face, Venice, Xiaomi, vLLM, LiteLLM, Cloudflare AI Gateway, and Qwen Portal. Ollama is typically local and unauthenticated; set `OLLAMA_API_KEY` only when your Ollama deployment enforces bearer auth.
1052
1053
 
@@ -1138,7 +1139,7 @@ const response = await complete(
1138
1139
 
1139
1140
  ### Provider Notes
1140
1141
 
1141
- **OpenAI**: Set `OPENAI_API_KEY` for usage-based Platform API access. ChatGPT subscription access is available through the official Codex CLI, not this library.
1142
+ **OpenAI**: Set `OPENAI_API_KEY` for usage-based Platform API access, or use the `openai-codex` OAuth provider for ChatGPT Plus/Pro subscription access.
1142
1143
 
1143
1144
  **GitHub Copilot**: If you get "The requested model is not supported" error, enable the model manually in VS Code: open Copilot Chat, click the model selector, select the model (warning icon), and click "Enable".
1144
1145
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/pi-ai",
4
- "version": "20.19.2",
4
+ "version": "20.19.4",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -46,7 +46,7 @@
46
46
  "@anthropic-ai/sdk": "^0.115",
47
47
  "@aws-sdk/client-bedrock-runtime": "^3",
48
48
  "@bufbuild/protobuf": "^2.11",
49
- "@f5-sales-demo/pi-utils": "20.19.2",
49
+ "@f5-sales-demo/pi-utils": "20.19.4",
50
50
  "@google/genai": "^2.15",
51
51
  "@sinclair/typebox": "^0.34",
52
52
  "@smithy/node-http-handler": "^4.4",
@@ -51,6 +51,7 @@ import { loginMoonshot } from "./utils/oauth/moonshot";
51
51
  import { loginNanoGPT } from "./utils/oauth/nanogpt";
52
52
  import { loginNvidia } from "./utils/oauth/nvidia";
53
53
  import { loginOllama } from "./utils/oauth/ollama";
54
+ import { loginOpenAICodex } from "./utils/oauth/openai-codex";
54
55
  import { loginOpenCode } from "./utils/oauth/opencode";
55
56
  import { loginParallel } from "./utils/oauth/parallel";
56
57
  import { loginPerplexity } from "./utils/oauth/perplexity";
@@ -174,8 +175,9 @@ const USAGE_CACHE_PREFIX = "usage_cache:";
174
175
  const USAGE_REPORT_TTL_MS = 30_000;
175
176
  const DEFAULT_USAGE_REQUEST_TIMEOUT_MS = 3_000;
176
177
  const DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 10_000;
177
- const LEGACY_OPENAI_CODEX_PROVIDER = "openai-codex";
178
- const LEGACY_OPENAI_CODEX_DISABLED_CAUSE = "unsupported OpenAI Codex OAuth; remove manually with logout";
178
+
179
+ /** Exact marker written by #3210 when valid ChatGPT credentials were regression-disabled. */
180
+ export const OPENAI_CODEX_REGRESSION_DISABLED_CAUSE = "unsupported OpenAI Codex OAuth; remove manually with logout";
179
181
 
180
182
  type UsageCacheEntry<T> = {
181
183
  value: T;
@@ -345,9 +347,7 @@ export class AuthStorage {
345
347
  * Used for CLI --api-key flag.
346
348
  */
347
349
  setRuntimeApiKey(provider: string, apiKey: string): void {
348
- provider = canonicalizeOAuthProviderId(provider);
349
- if (provider === LEGACY_OPENAI_CODEX_PROVIDER) return;
350
- this.#runtimeOverrides.set(provider, apiKey);
350
+ this.#runtimeOverrides.set(canonicalizeOAuthProviderId(provider), apiKey);
351
351
  }
352
352
 
353
353
  /**
@@ -658,13 +658,6 @@ export class AuthStorage {
658
658
  */
659
659
  async set(provider: string, credential: AuthCredentialEntry): Promise<void> {
660
660
  provider = canonicalizeOAuthProviderId(provider);
661
- if (provider === LEGACY_OPENAI_CODEX_PROVIDER) {
662
- this.#store.replaceAuthCredentialsForProvider(provider, Array.isArray(credential) ? credential : [credential]);
663
- this.#store.disableLegacyOpenAICodexCredentials();
664
- this.#data.delete(provider);
665
- this.#resetProviderAssignments(provider);
666
- return;
667
- }
668
661
  const normalized = Array.isArray(credential) ? credential : [credential];
669
662
  const deduped = this.#dedupeOAuthCredentials(provider, normalized);
670
663
  const stored = this.#store.replaceAuthCredentialsForProvider(provider, deduped);
@@ -689,12 +682,6 @@ export class AuthStorage {
689
682
  */
690
683
  async remove(provider: string): Promise<void> {
691
684
  provider = canonicalizeOAuthProviderId(provider);
692
- if (provider === LEGACY_OPENAI_CODEX_PROVIDER) {
693
- this.#store.hardDeleteAuthCredentialsForProvider(provider);
694
- this.#data.delete(provider);
695
- this.#resetProviderAssignments(provider);
696
- return;
697
- }
698
685
  this.#store.deleteAuthCredentialsForProvider(provider, "deleted by user");
699
686
  this.#data.delete(provider);
700
687
  this.#resetProviderAssignments(provider);
@@ -720,7 +707,6 @@ export class AuthStorage {
720
707
  */
721
708
  hasAuth(provider: string): boolean {
722
709
  provider = canonicalizeOAuthProviderId(provider);
723
- if (provider === LEGACY_OPENAI_CODEX_PROVIDER) return false;
724
710
  if (this.#runtimeOverrides.has(provider)) return true;
725
711
  if (this.#getCredentialsForProvider(provider).length > 0) return true;
726
712
  if (getEnvApiKey(provider)) return true;
@@ -829,10 +815,14 @@ export class AuthStorage {
829
815
  },
830
816
  );
831
817
  break;
818
+ case "openai-codex":
819
+ credentials = await loginOpenAICodex({
820
+ ...ctrl,
821
+ onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
822
+ });
823
+ break;
832
824
  case "openai":
833
825
  throw new Error("OpenAI uses usage-based API access. Set OPENAI_API_KEY instead of signing in.");
834
- case "openai-codex":
835
- throw new Error("OpenAI Codex OAuth is unsupported. Use official codex or set OPENAI_API_KEY for xcsh.");
836
826
  case "gitlab-duo":
837
827
  credentials = await loginGitLabDuo({
838
828
  ...ctrl,
@@ -1936,7 +1926,6 @@ export class AuthStorage {
1936
1926
  */
1937
1927
  async peekApiKey(provider: string): Promise<string | undefined> {
1938
1928
  provider = canonicalizeOAuthProviderId(provider);
1939
- if (provider === LEGACY_OPENAI_CODEX_PROVIDER) return undefined;
1940
1929
  const runtimeKey = this.#runtimeOverrides.get(provider);
1941
1930
  if (runtimeKey) {
1942
1931
  return runtimeKey;
@@ -1979,7 +1968,6 @@ export class AuthStorage {
1979
1968
  */
1980
1969
  async getApiKey(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined> {
1981
1970
  provider = canonicalizeOAuthProviderId(provider);
1982
- if (provider === LEGACY_OPENAI_CODEX_PROVIDER) return undefined;
1983
1971
  // Runtime override takes highest priority
1984
1972
  const runtimeKey = this.#runtimeOverrides.get(provider);
1985
1973
  if (runtimeKey) {
@@ -2241,7 +2229,7 @@ export class AuthCredentialStore {
2241
2229
  "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at",
2242
2230
  );
2243
2231
  this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`);
2244
- this.disableLegacyOpenAICodexCredentials();
2232
+ this.reactivateRegressionDisabledOpenAICodexCredentials();
2245
2233
  }
2246
2234
 
2247
2235
  static async open(dbPath: string = getAgentDbPath()): Promise<AuthCredentialStore> {
@@ -2481,22 +2469,57 @@ export class AuthCredentialStore {
2481
2469
  }
2482
2470
 
2483
2471
  /**
2484
- * List disabled credentials for inspection or explicit user-directed removal.
2485
- * Legacy OpenAI Codex rows stay here; they are never reactivated automatically.
2472
+ * Reactivate credentials disabled solely by #3210. Rows disabled for any other reason are untouched.
2473
+ * When the same stored identity is already active, the regression-disabled duplicate is removed.
2486
2474
  */
2487
- listDisabledAuthCredentials(provider: string): StoredAuthCredential[] {
2488
- const rows = this.#listDisabledByProviderStmt.all(provider) as AuthRow[];
2489
- const results: StoredAuthCredential[] = [];
2490
- for (const row of rows) {
2491
- const credential = deserializeCredential(row);
2492
- if (credential) results.push(toStoredAuthCredential(row, credential));
2493
- }
2494
- return results;
2495
- }
2475
+ reactivateRegressionDisabledOpenAICodexCredentials(): { reactivated: number; deduplicated: number } {
2476
+ const provider = "openai-codex";
2477
+ const migrate = this.#db.transaction(() => {
2478
+ const activeRows = this.#listActiveByProviderStmt.all(provider) as AuthRow[];
2479
+ const activeIdentities = new Set(
2480
+ activeRows.flatMap(row => {
2481
+ const identity = resolveRowCredentialIdentityKey(provider, row);
2482
+ return identity ? [identity] : [];
2483
+ }),
2484
+ );
2485
+ const disabledRows = this.#db
2486
+ .prepare(
2487
+ "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause = ? ORDER BY id ASC",
2488
+ )
2489
+ .all(provider, OPENAI_CODEX_REGRESSION_DISABLED_CAUSE) as AuthRow[];
2490
+ const reactivate = this.#db.prepare(
2491
+ "UPDATE auth_credentials SET disabled_cause = NULL, updated_at = strftime('%s','now') WHERE id = ? AND disabled_cause = ?",
2492
+ );
2496
2493
 
2497
- /** Disable unsupported Codex OAuth rows without deleting the user's stored data. */
2498
- disableLegacyOpenAICodexCredentials(): void {
2499
- this.deleteAuthCredentialsForProvider(LEGACY_OPENAI_CODEX_PROVIDER, LEGACY_OPENAI_CODEX_DISABLED_CAUSE);
2494
+ let reactivated = 0;
2495
+ let deduplicated = 0;
2496
+ for (const row of disabledRows) {
2497
+ const identity = resolveRowCredentialIdentityKey(provider, row);
2498
+ if (identity && activeIdentities.has(identity)) {
2499
+ this.#hardDeleteStmt.run(row.id);
2500
+ deduplicated += 1;
2501
+ continue;
2502
+ }
2503
+ const credential = deserializeCredential(row);
2504
+ if (credential?.type === "oauth") {
2505
+ // #3210 may have kept access-token expiry metadata beyond the token's server-side
2506
+ // validity. Force the first post-migration use through the normal refresh path.
2507
+ const serialized = serializeCredential(provider, { ...credential, expires: 0 });
2508
+ if (serialized) {
2509
+ this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, row.id);
2510
+ }
2511
+ }
2512
+ const result = reactivate.run(row.id, OPENAI_CODEX_REGRESSION_DISABLED_CAUSE);
2513
+ if (result.changes > 0) {
2514
+ reactivated += 1;
2515
+ if (identity) activeIdentities.add(identity);
2516
+ }
2517
+ }
2518
+
2519
+ return { reactivated, deduplicated };
2520
+ });
2521
+
2522
+ return migrate();
2500
2523
  }
2501
2524
 
2502
2525
  replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[] {
@@ -2656,15 +2679,6 @@ export class AuthCredentialStore {
2656
2679
  }
2657
2680
  }
2658
2681
 
2659
- /** Permanently remove credentials only after an explicit user logout/removal request. */
2660
- hardDeleteAuthCredentialsForProvider(provider: string): void {
2661
- try {
2662
- this.#db.prepare("DELETE FROM auth_credentials WHERE provider = ?").run(provider);
2663
- } catch {
2664
- // Ignore delete failures
2665
- }
2666
- }
2667
-
2668
2682
  getCache(key: string): string | null {
2669
2683
  try {
2670
2684
  const row = this.#getCacheStmt.get(key) as { value?: string } | undefined;
package/src/cli.ts CHANGED
@@ -12,6 +12,7 @@ import { loginKilo } from "./utils/oauth/kilo";
12
12
  import { loginKimi } from "./utils/oauth/kimi";
13
13
  import { loginMiniMaxCode, loginMiniMaxCodeCn } from "./utils/oauth/minimax-code";
14
14
  import { loginNanoGPT } from "./utils/oauth/nanogpt";
15
+ import { loginOpenAICodex } from "./utils/oauth/openai-codex";
15
16
  import { loginParallel } from "./utils/oauth/parallel";
16
17
  import { loginTavily } from "./utils/oauth/tavily";
17
18
  import type { OAuthCredentials, OAuthProvider } from "./utils/oauth/types";
@@ -126,6 +127,20 @@ async function login(provider: OAuthProvider): Promise<void> {
126
127
  },
127
128
  });
128
129
  break;
130
+ case "openai-codex":
131
+ credentials = await loginOpenAICodex({
132
+ onAuth(info) {
133
+ const { url, instructions } = info;
134
+ console.log(`\nOpen this URL in your browser:\n${url}`);
135
+ if (instructions) console.log(instructions);
136
+ console.log();
137
+ },
138
+ async onPrompt(p) {
139
+ return await promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
140
+ },
141
+ });
142
+ break;
143
+
129
144
  case "kimi-code":
130
145
  credentials = await loginKimi({
131
146
  onAuth(info) {
@@ -321,9 +336,8 @@ Providers:
321
336
  github-copilot GitHub Copilot
322
337
  google-gemini-cli Google Gemini CLI
323
338
  google-antigravity Antigravity (Gemini 3, Claude, GPT-OSS)
324
-
325
- OpenAI models use usage-based Platform API access. Set OPENAI_API_KEY, then select an OpenAI model.
326
- For ChatGPT subscription access, use the official codex CLI: codex login
339
+ openai-codex ChatGPT Plus/Pro (Codex subscription)
340
+ openai OpenAI Responses API (usage-based; set OPENAI_API_KEY)
327
341
  kimi-code Kimi Code
328
342
  kilo Kilo Gateway
329
343
  kagi Kagi
@@ -416,15 +430,6 @@ Examples:
416
430
  console.error("No provider selected");
417
431
  process.exit(1);
418
432
  }
419
- if (provider === "openai-codex") {
420
- if (storage.listDisabledAuthCredentials(provider).length === 0) {
421
- console.error("No disabled legacy OpenAI Codex credentials found");
422
- process.exit(1);
423
- }
424
- storage.hardDeleteAuthCredentialsForProvider(provider);
425
- console.log("Removed disabled legacy OpenAI Codex credentials");
426
- return;
427
- }
428
433
 
429
434
  const oauth = storage.getOAuth(provider);
430
435
  const apiKey = storage.getApiKey(provider);
@@ -467,10 +472,9 @@ Examples:
467
472
  process.exit(1);
468
473
  }
469
474
  if (provider === "openai") {
470
- console.log(
471
- "OpenAI Responses API uses usage-based Platform API access. Set OPENAI_API_KEY, then select an OpenAI model.",
472
- );
473
- console.log("For ChatGPT subscription access, use the official codex CLI: codex login");
475
+ console.log("OpenAI Responses API uses usage-based Platform API access.");
476
+ console.log("Set OPENAI_API_KEY, then select an OpenAI model.");
477
+ console.log("For ChatGPT subscription access, use: login openai-codex");
474
478
  return;
475
479
  }
476
480
 
@@ -85,6 +85,14 @@ export async function transformRequestBody(
85
85
  ): Promise<RequestBody> {
86
86
  body.store = false;
87
87
  body.stream = true;
88
+ // The ChatGPT Codex backend rejects sampling controls with `Unsupported parameter`.
89
+ // Generic stream options may supply defaults, so remove them at the provider boundary.
90
+ delete body.temperature;
91
+ delete body.top_p;
92
+ delete body.top_k;
93
+ delete body.min_p;
94
+ delete body.presence_penalty;
95
+ delete body.repetition_penalty;
88
96
 
89
97
  if (body.input && Array.isArray(body.input)) {
90
98
  body.input = filterInput(body.input);
package/src/stream.ts CHANGED
@@ -80,6 +80,7 @@ const serviceProviderMap: Record<string, KeyResolver> = {
80
80
  "opencode-go": "OPENCODE_API_KEY",
81
81
  "opencode-zen": "OPENCODE_API_KEY",
82
82
  cursor: "CURSOR_ACCESS_TOKEN",
83
+ "openai-codex": "OPENAI_CODEX_OAUTH_TOKEN",
83
84
  "azure-openai-responses": "AZURE_OPENAI_API_KEY",
84
85
  exa: "EXA_API_KEY",
85
86
  jina: "JINA_API_KEY",
@@ -163,11 +164,6 @@ export function stream<TApi extends Api>(
163
164
  context: Context,
164
165
  options?: OptionsForApi<TApi>,
165
166
  ): AssistantMessageEventStream {
166
- if (model.provider === "openai-codex") {
167
- throw new Error(
168
- "OpenAI Codex subscription access is unsupported in xcsh. Use official codex or an OpenAI API-key model.",
169
- );
170
- }
171
167
  // Check custom API registry first (extension-provided APIs like "vertex-claude-api")
172
168
  const customApiProvider = getCustomApi(model.api);
173
169
  if (customApiProvider) {
@@ -248,11 +244,6 @@ export function streamSimple<TApi extends Api>(
248
244
  context: Context,
249
245
  options?: SimpleStreamOptions,
250
246
  ): AssistantMessageEventStream {
251
- if (model.provider === "openai-codex") {
252
- throw new Error(
253
- "OpenAI Codex subscription access is unsupported in xcsh. Use official codex or an OpenAI API-key model.",
254
- );
255
- }
256
247
  // Check custom API registry first (extension-provided APIs)
257
248
  const customApiProvider = getCustomApi(model.api);
258
249
  if (customApiProvider) {
@@ -8,6 +8,7 @@ import { refreshGitLabDuoToken } from "./gitlab-duo";
8
8
  import { refreshAntigravityToken } from "./google-antigravity";
9
9
  import { refreshGoogleCloudToken } from "./google-gemini-cli";
10
10
  import { refreshKimiToken } from "./kimi";
11
+ import { refreshOpenAICodexToken } from "./openai-codex";
11
12
  import type {
12
13
  OAuthCredentials,
13
14
  OAuthProvider,
@@ -96,6 +97,9 @@ export { loginNanoGPT } from "./nanogpt";
96
97
  export { loginNvidia } from "./nvidia";
97
98
  // Ollama (optional API key)
98
99
  export { loginOllama } from "./ollama";
100
+ export type { OpenAICodexLoginOptions } from "./openai-codex";
101
+ // OpenAI Codex (ChatGPT OAuth)
102
+ export { loginOpenAICodex, refreshOpenAICodexToken } from "./openai-codex";
99
103
  // OpenCode Zen / OpenCode Go (API key)
100
104
  export { loginOpenCode } from "./opencode";
101
105
  // Parallel (API key)
@@ -137,6 +141,11 @@ const builtInOAuthProviders: OAuthProviderInfo[] = [
137
141
  name: "Alibaba Coding Plan",
138
142
  available: true,
139
143
  },
144
+ {
145
+ id: "openai-codex",
146
+ name: "ChatGPT Plus/Pro (Codex Subscription)",
147
+ available: true,
148
+ },
140
149
  {
141
150
  id: "openai",
142
151
  name: "OpenAI Responses API (usage-based API access)",
@@ -380,6 +389,9 @@ export async function refreshOAuthToken(
380
389
  }
381
390
  newCredentials = await refreshAntigravityToken(credentials.refresh, credentials.projectId);
382
391
  break;
392
+ case "openai-codex":
393
+ newCredentials = await refreshOpenAICodexToken(credentials.refresh);
394
+ break;
383
395
  case "openai":
384
396
  newCredentials = credentials;
385
397
  break;
@@ -459,9 +471,6 @@ export async function getOAuthApiKey(
459
471
  if (!creds) {
460
472
  return null;
461
473
  }
462
- if (provider === "openai-codex") {
463
- return null;
464
- }
465
474
 
466
475
  if (provider === "perplexity") {
467
476
  const normalizedExpires =
@@ -0,0 +1,255 @@
1
+ /**
2
+ * OpenAI Codex (ChatGPT OAuth) flow.
3
+ */
4
+ import { isRecord } from "../../utils";
5
+ import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server";
6
+ import { generatePKCE } from "./pkce";
7
+ import type { OAuthController, OAuthCredentials } from "./types";
8
+
9
+ const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
10
+ const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
11
+ const TOKEN_URL = "https://auth.openai.com/oauth/token";
12
+ const CALLBACK_PORT = 1455;
13
+ const CALLBACK_PATH = "/auth/callback";
14
+ const CALLBACK_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
15
+ const SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke";
16
+ const JWT_CLAIM_PATH = "https://api.openai.com/auth";
17
+ const JWT_PROFILE_CLAIM = "https://api.openai.com/profile";
18
+ const TOKEN_REQUEST_TIMEOUT_MS = 15_000;
19
+
20
+ type JwtPayload = {
21
+ [JWT_CLAIM_PATH]?: {
22
+ chatgpt_account_id?: string;
23
+ };
24
+ [JWT_PROFILE_CLAIM]?: {
25
+ email?: string;
26
+ };
27
+ [key: string]: unknown;
28
+ };
29
+
30
+ function decodeJwt(token: string): JwtPayload | null {
31
+ try {
32
+ const parts = token.split(".");
33
+ if (parts.length !== 3) return null;
34
+ const payload = parts[1] ?? "";
35
+ const decoded = Buffer.from(payload, "base64").toString("utf-8");
36
+ return JSON.parse(decoded) as JwtPayload;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ function getTokenProfile(accessToken: string): { accountId?: string; email?: string } {
43
+ const payload = decodeJwt(accessToken);
44
+ const auth = payload?.[JWT_CLAIM_PATH];
45
+ const accountId = auth?.chatgpt_account_id;
46
+ const email = payload?.[JWT_PROFILE_CLAIM]?.email?.trim().toLowerCase();
47
+ return {
48
+ accountId: typeof accountId === "string" && accountId.length > 0 ? accountId : undefined,
49
+ email: typeof email === "string" && email.length > 0 ? email : undefined,
50
+ };
51
+ }
52
+
53
+ interface PKCE {
54
+ verifier: string;
55
+ challenge: string;
56
+ }
57
+
58
+ function describeTokenEndpointValue(value: unknown): string | undefined {
59
+ if (typeof value === "string") {
60
+ const trimmed = value.trim();
61
+ return trimmed.length > 0 ? trimmed : undefined;
62
+ }
63
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
64
+ if (!isRecord(value)) return undefined;
65
+
66
+ const code = describeTokenEndpointValue(value.code ?? value.error);
67
+ const message = describeTokenEndpointValue(value.message ?? value.error_description ?? value.description);
68
+ if (code && message && code !== message) return `${code}: ${message}`;
69
+ return code ?? message;
70
+ }
71
+
72
+ function redactTokenEndpointDetail(detail: string): string {
73
+ return detail
74
+ .replace(/(https?:\/\/[^\s?]+)\?[^\s)]+/gi, "$1?[REDACTED]")
75
+ .replace(/\bBearer\s+[^\s,;]+/gi, "Bearer [REDACTED]")
76
+ .replace(
77
+ /\b(access_token|refresh_token|id_token|authorization_code|code|token|state)\b(\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi,
78
+ "$1$2[REDACTED]",
79
+ )
80
+ .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[REDACTED]")
81
+ .slice(0, 500);
82
+ }
83
+
84
+ /** Format useful OAuth token endpoint failures without echoing credentials or callback query strings. */
85
+ export function formatOpenAICodexTokenEndpointError(status: number, bodyText: string): string {
86
+ const trimmed = bodyText.trim();
87
+ if (trimmed.length === 0) return `${status}`;
88
+
89
+ let detail: string | undefined;
90
+ try {
91
+ const body: unknown = JSON.parse(trimmed);
92
+ if (isRecord(body)) {
93
+ const error = describeTokenEndpointValue(body.error);
94
+ const description = describeTokenEndpointValue(body.error_description);
95
+ const message = describeTokenEndpointValue(body.message);
96
+ detail =
97
+ error && description && error !== description
98
+ ? `${error}: ${description}`
99
+ : (error ?? description ?? message);
100
+ } else {
101
+ detail = describeTokenEndpointValue(body);
102
+ }
103
+ } catch {
104
+ detail = trimmed;
105
+ }
106
+
107
+ return detail ? `${status} ${redactTokenEndpointDetail(detail)}` : `${status}`;
108
+ }
109
+
110
+ /** Build the browser OAuth URL; exported for regression coverage. */
111
+ export function createOpenAICodexAuthorizationUrl(args: {
112
+ state: string;
113
+ redirectUri: string;
114
+ challenge: string;
115
+ originator?: string;
116
+ }): string {
117
+ const searchParams = new URLSearchParams({
118
+ response_type: "code",
119
+ client_id: CLIENT_ID,
120
+ redirect_uri: args.redirectUri,
121
+ scope: SCOPE,
122
+ code_challenge: args.challenge,
123
+ code_challenge_method: "S256",
124
+ state: args.state,
125
+ id_token_add_organizations: "true",
126
+ codex_cli_simplified_flow: "true",
127
+ originator: args.originator?.trim() || "pi",
128
+ });
129
+
130
+ return `${AUTHORIZE_URL}?${searchParams.toString()}`;
131
+ }
132
+
133
+ class OpenAICodexOAuthFlow extends OAuthCallbackFlow {
134
+ constructor(
135
+ ctrl: OAuthController,
136
+ private readonly pkce: PKCE,
137
+ private readonly originator: string,
138
+ ) {
139
+ super(ctrl, {
140
+ preferredPort: CALLBACK_PORT,
141
+ callbackPath: CALLBACK_PATH,
142
+ redirectUri: CALLBACK_URI,
143
+ } satisfies OAuthCallbackFlowOptions);
144
+ }
145
+
146
+ async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
147
+ return {
148
+ url: createOpenAICodexAuthorizationUrl({
149
+ state,
150
+ redirectUri,
151
+ challenge: this.pkce.challenge,
152
+ originator: this.originator,
153
+ }),
154
+ instructions: "A browser window should open. Complete login to finish.",
155
+ };
156
+ }
157
+
158
+ async exchangeToken(code: string, _state: string, redirectUri: string): Promise<OAuthCredentials> {
159
+ return exchangeCodeForToken(code, this.pkce.verifier, redirectUri);
160
+ }
161
+ }
162
+
163
+ async function exchangeCodeForToken(code: string, verifier: string, redirectUri: string): Promise<OAuthCredentials> {
164
+ const tokenResponse = await fetch(TOKEN_URL, {
165
+ method: "POST",
166
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
167
+ body: new URLSearchParams({
168
+ grant_type: "authorization_code",
169
+ client_id: CLIENT_ID,
170
+ code,
171
+ code_verifier: verifier,
172
+ redirect_uri: redirectUri,
173
+ }),
174
+ signal: AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS),
175
+ });
176
+
177
+ if (!tokenResponse.ok) {
178
+ const bodyText = await tokenResponse.text();
179
+ throw new Error(`Token exchange failed: ${formatOpenAICodexTokenEndpointError(tokenResponse.status, bodyText)}`);
180
+ }
181
+
182
+ const tokenData = (await tokenResponse.json()) as {
183
+ access_token?: string;
184
+ refresh_token?: string;
185
+ expires_in?: number;
186
+ };
187
+
188
+ if (!tokenData.access_token || !tokenData.refresh_token || typeof tokenData.expires_in !== "number") {
189
+ throw new Error("Token response missing required fields");
190
+ }
191
+
192
+ const { accountId, email } = getTokenProfile(tokenData.access_token);
193
+ if (!accountId) {
194
+ throw new Error("Failed to extract accountId from token");
195
+ }
196
+
197
+ return {
198
+ access: tokenData.access_token,
199
+ refresh: tokenData.refresh_token,
200
+ expires: Date.now() + tokenData.expires_in * 1000,
201
+ accountId,
202
+ email,
203
+ };
204
+ }
205
+
206
+ export type OpenAICodexLoginOptions = OAuthController & {
207
+ /** Optional originator value. The default matches xcsh Codex request headers. */
208
+ originator?: string;
209
+ };
210
+
211
+ export async function loginOpenAICodex(options: OpenAICodexLoginOptions): Promise<OAuthCredentials> {
212
+ const pkce = await generatePKCE();
213
+ const originator = options.originator?.trim() || "pi";
214
+ const flow = new OpenAICodexOAuthFlow(options, pkce, originator);
215
+ return flow.login();
216
+ }
217
+
218
+ export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAuthCredentials> {
219
+ const response = await fetch(TOKEN_URL, {
220
+ method: "POST",
221
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
222
+ body: new URLSearchParams({
223
+ grant_type: "refresh_token",
224
+ refresh_token: refreshToken,
225
+ client_id: CLIENT_ID,
226
+ }),
227
+ signal: AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS),
228
+ });
229
+
230
+ if (!response.ok) {
231
+ const bodyText = await response.text();
232
+ throw new Error(
233
+ `OpenAI Codex token refresh failed: ${formatOpenAICodexTokenEndpointError(response.status, bodyText)}`,
234
+ );
235
+ }
236
+
237
+ const tokenData = (await response.json()) as {
238
+ access_token?: string;
239
+ refresh_token?: string;
240
+ expires_in?: number;
241
+ };
242
+
243
+ if (!tokenData.access_token || typeof tokenData.expires_in !== "number") {
244
+ throw new Error("Token response missing required fields");
245
+ }
246
+
247
+ const { accountId, email } = getTokenProfile(tokenData.access_token);
248
+ return {
249
+ access: tokenData.access_token,
250
+ refresh: tokenData.refresh_token || refreshToken,
251
+ expires: Date.now() + tokenData.expires_in * 1000,
252
+ accountId,
253
+ email,
254
+ };
255
+ }
@@ -34,7 +34,6 @@ export type OAuthProvider =
34
34
  | "nanogpt"
35
35
  | "ollama"
36
36
  | "openai"
37
- /** Legacy ID retained only so stored credentials can be explicitly removed. */
38
37
  | "openai-codex"
39
38
  | "opencode-go"
40
39
  | "opencode-zen"