@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 +4 -3
- package/package.json +2 -2
- package/src/auth-storage.ts +61 -47
- package/src/cli.ts +20 -16
- package/src/providers/openai-codex/request-transformer.ts +8 -0
- package/src/stream.ts +1 -10
- package/src/utils/oauth/index.ts +12 -3
- package/src/utils/oauth/openai-codex.ts +255 -0
- package/src/utils/oauth/types.ts +0 -1
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
|
|
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
|
|
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.
|
|
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.
|
|
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",
|
package/src/auth-storage.ts
CHANGED
|
@@ -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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
2485
|
-
*
|
|
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
|
-
|
|
2488
|
-
const
|
|
2489
|
-
const
|
|
2490
|
-
|
|
2491
|
-
const
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
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
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|
package/src/utils/oauth/index.ts
CHANGED
|
@@ -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
|
+
}
|