@hienlh/ppm 0.20.1 → 0.20.3
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/CHANGELOG.md +21 -0
- package/CLAUDE.md +1 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +5 -1
- package/dist/web/assets/accounts-settings-section-DzDmIsPe.js +1 -0
- package/dist/web/assets/{ai-resource-editor-BLctP1Pr.js → ai-resource-editor-D7C9wmwA.js} +1 -1
- package/dist/web/assets/{ai-settings-section-0hSDp7xx.js → ai-settings-section-BW5a07-Z.js} +1 -1
- package/dist/web/assets/{ai-settings-section-CTEU2uGa.js → ai-settings-section-DZuwllLy.js} +1 -1
- package/dist/web/assets/{appearance-settings-section-BkcoSV0E.js → appearance-settings-section-RfH2N1rj.js} +1 -1
- package/dist/web/assets/chat-tab-Bz0pk0_E.js +15 -0
- package/dist/web/assets/{code-editor-C4kKhamq.js → code-editor-C4o1SyuO.js} +2 -2
- package/dist/web/assets/{conflict-editor-BEULKwHk.js → conflict-editor-Dk65mBIe.js} +1 -1
- package/dist/web/assets/{csv-preview-k3eM8nFf.js → csv-preview-DENi3CFz.js} +1 -1
- package/dist/web/assets/{database-viewer-Cslir1dr.js → database-viewer-BiBC7ulB.js} +1 -1
- package/dist/web/assets/{diff-viewer-DzAIvBbn.js → diff-viewer-CSvZJHak.js} +1 -1
- package/dist/web/assets/{explorer-body-D6H-4cz9.js → explorer-body-QXS0h4AF.js} +1 -1
- package/dist/web/assets/{explorer-window-content-C5W_JzCz.js → explorer-window-content-CQgqoRsj.js} +1 -1
- package/dist/web/assets/{extension-manager-section-DwicI3CA.js → extension-manager-section-CyC7wX35.js} +1 -1
- package/dist/web/assets/{extension-webview-Z3HhC8Pd.js → extension-webview-Da8SIlRa.js} +1 -1
- package/dist/web/assets/{files-settings-section-ByV7bRbB.js → files-settings-section-D8cebM51.js} +1 -1
- package/dist/web/assets/{general-settings-section-nLksKHur.js → general-settings-section-1HVTvlIw.js} +1 -1
- package/dist/web/assets/{git-log-panel-iUXltiTB.js → git-log-panel-C7a4rp8f.js} +1 -1
- package/dist/web/assets/{glide-data-grid-C1QB4DHt.js → glide-data-grid-BT9p_4AN.js} +1 -1
- package/dist/web/assets/{group-chat-tab-ezXKVVpg.js → group-chat-tab-BUHGnUyW.js} +1 -1
- package/dist/web/assets/index-CuYtJ5xF.css +2 -0
- package/dist/web/assets/{index-CJWbFurD.js → index-kGmcC9cb.js} +5 -5
- package/dist/web/assets/{jira-watcher-section-DwARwskB.js → jira-watcher-section-BxzwW4uQ.js} +1 -1
- package/dist/web/assets/{keybindings-store-S7YLGd8B.js → keybindings-store-DjJum4OS.js} +1 -1
- package/dist/web/assets/{keyboard-shortcuts-section-BoUDvdDy.js → keyboard-shortcuts-section-DDKG3BU1.js} +1 -1
- package/dist/web/assets/{markdown-renderer-DKdfiSIX.js → markdown-renderer-dHeak2mE.js} +1 -1
- package/dist/web/assets/{markdown-renderer-DYFmMC_8.js → markdown-renderer-iBw1fCI_.js} +1 -1
- package/dist/web/assets/{mobile-explorer-sheet-CMdqLCV-.js → mobile-explorer-sheet-CzL8SLtz.js} +1 -1
- package/dist/web/assets/{notification-store-DpSYFj36.js → notification-store-DVi6Ds71.js} +1 -1
- package/dist/web/assets/{notifications-settings-section-CIf3-3xd.js → notifications-settings-section-shQ-ugKh.js} +1 -1
- package/dist/web/assets/{pdf-preview-C-7IdkIK.js → pdf-preview-DB26KSzm.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BYXFYlLg.js → postgres-viewer-CJe2meyF.js} +1 -1
- package/dist/web/assets/{ppmbot-settings-section-B9dXvQ6p.js → ppmbot-settings-section-Ba4c9AI9.js} +1 -1
- package/dist/web/assets/{process-table-B6NeTWt8.js → process-table-PXwLsMuG.js} +1 -1
- package/dist/web/assets/{proxy-settings-section-CUqq7pdU.js → proxy-settings-section-BHBhK6HC.js} +1 -1
- package/dist/web/assets/{query-audit-section-C1TYrPIp.js → query-audit-section-BD5ZVqDz.js} +1 -1
- package/dist/web/assets/{remote-desktop-mobile-sheet-DuKwKe5F.js → remote-desktop-mobile-sheet-Di4QG8VX.js} +2 -2
- package/dist/web/assets/{remote-desktop-mobile-view-DxMTtsa6.js → remote-desktop-mobile-view-DzoZvAqc.js} +1 -1
- package/dist/web/assets/{remote-desktop-readiness-gate-BwjfLWqg.js → remote-desktop-readiness-gate-CHO-KJ9Z.js} +1 -1
- package/dist/web/assets/{remote-desktop-window-content-BIl4N5Hn.js → remote-desktop-window-content-X324gYbW.js} +1 -1
- package/dist/web/assets/{schedules-settings-section-C0qOb7GI.js → schedules-settings-section-a4-h6THc.js} +1 -1
- package/dist/web/assets/{settings-body-qbrJXX_A.js → settings-body-Fq7BVDw2.js} +2 -2
- package/dist/web/assets/{settings-tab-BKRgYVxf.js → settings-tab-CVLQy-ie.js} +1 -1
- package/dist/web/assets/{settings-window-content-DWsxJDu-.js → settings-window-content-Bi7M0iaZ.js} +1 -1
- package/dist/web/assets/{sql-query-editor-CsZOpCW3.js → sql-query-editor-D1r265fl.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-DUrnCaP7.js → sqlite-viewer-DHkIotRl.js} +1 -1
- package/dist/web/assets/{system-monitor-body-C5cMpg7I.js → system-monitor-body-y6VUkYTL.js} +2 -2
- package/dist/web/assets/{system-monitor-tab-C66ywbVZ.js → system-monitor-tab-DRAOejhk.js} +1 -1
- package/dist/web/assets/{system-monitor-window-content-CENaXYJ-.js → system-monitor-window-content-B5i57xHD.js} +1 -1
- package/dist/web/assets/{tab-host-window-content-HViABGoS.js → tab-host-window-content-SkyPFFlc.js} +1 -1
- package/dist/web/assets/{team-member-sheet-CK6eyBRL.js → team-member-sheet-BB63u-Lf.js} +2 -2
- package/dist/web/assets/{team-member-window-content-D6wxg37P.js → team-member-window-content-D6tVTXuV.js} +1 -1
- package/dist/web/assets/{terminal-tab-BmyTawmN.js → terminal-tab-BYaHAkI5.js} +1 -1
- package/dist/web/assets/{tool-cards-y-XJcJHo.js → tool-cards-DRJu20sL.js} +2 -2
- package/dist/web/assets/use-accounts-data-Ba1_uvEE.js +1 -0
- package/dist/web/assets/{use-monaco-theme-BkM5rMgL.js → use-monaco-theme-Uakgj3hJ.js} +1 -1
- package/dist/web/assets/{use-remote-desktop-display-choice-BT2r1td1.js → use-remote-desktop-display-choice-Cd3Xalca.js} +1 -1
- package/dist/web/assets/{use-websocket-BvVTwMjI.js → use-websocket-BC_EGf5q.js} +1 -1
- package/dist/web/assets/{video-preview-BEaofjN7.js → video-preview-BUb43zWb.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/docs/deployment-guide.md +10 -0
- package/docs/lessons-learned.md +14 -0
- package/package.json +1 -1
- package/src/providers/codex-app-server/codex-event-mapper.ts +11 -0
- package/src/providers/codex-app-server/codex-history.ts +118 -45
- package/src/providers/codex-app-server/codex-provider.ts +277 -15
- package/src/providers/codex-app-server/codex-rollout-header.ts +99 -0
- package/src/providers/codex-app-server/codex-rollout-items.ts +6 -0
- package/src/providers/codex-app-server/codex-rollout-transfer.ts +58 -0
- package/src/providers/codex-app-server/codex-subagent-thread.ts +103 -0
- package/src/providers/codex-app-server/codex-usage-limit.ts +69 -0
- package/src/providers/codex-app-server/codex-usage-parser.ts +31 -14
- package/src/server/routes/chat.ts +23 -2
- package/src/server/routes/codex-accounts.ts +32 -3
- package/src/server/ws/chat.ts +15 -11
- package/src/services/codex-account-cooldown.ts +56 -0
- package/src/services/codex-account-login.ts +109 -34
- package/src/services/codex-account.service.ts +56 -10
- package/src/services/codex-login-callback.ts +57 -0
- package/src/web/components/chat/chat-tab.tsx +8 -2
- package/src/web/components/chat/codex-usage-panel.tsx +129 -165
- package/src/web/components/chat/usage-badge.tsx +38 -129
- package/src/web/components/chat/usage-panel-shell.tsx +153 -0
- package/src/web/components/settings/accounts/account-card.tsx +20 -3
- package/src/web/components/settings/accounts/codex-accounts-section.tsx +9 -2
- package/src/web/components/settings/accounts/codex-add-account-dialog.tsx +47 -27
- package/src/web/components/settings/accounts/codex-usage-rows.tsx +17 -6
- package/src/web/components/settings/accounts/use-codex-accounts.ts +90 -44
- package/src/web/components/ui/tooltip.tsx +14 -1
- package/src/web/hooks/use-usage.ts +13 -1
- package/dist/web/assets/accounts-settings-section-_sG1AqtH.js +0 -1
- package/dist/web/assets/chat-tab-CJi4uYb9.js +0 -15
- package/dist/web/assets/index-CoaVwAF6.css +0 -2
- package/dist/web/assets/use-accounts-data-BbOb2QNP.js +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Codex account login orchestration. Logs codex into a per-account CODEX_HOME
|
|
3
3
|
* via a short-lived app-server, verifies with account/read, then persists the
|
|
4
|
-
* account.
|
|
4
|
+
* account. Supports API keys, device codes and browser OAuth callbacks.
|
|
5
5
|
* - apiKey: instant (account/login/start stores the key; validated on first use).
|
|
6
6
|
* - chatgptDeviceCode: returns a user-code + URL; the app-server's
|
|
7
7
|
* account/login/completed notification finalizes the account in the
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { randomUUID } from "node:crypto";
|
|
17
17
|
import { mkdirSync, rmSync } from "node:fs";
|
|
18
|
-
import {
|
|
18
|
+
import { buildCodexCallback, checkCodexLoginPort, parseBrowserLogin, type BrowserLoginCallback } from "./codex-login-callback.ts";
|
|
19
|
+
import { CodexJsonRpcClient, CONTROL_REQUEST_TIMEOUT_MS } from "../providers/codex-app-server/codex-jsonrpc-client.ts";
|
|
19
20
|
import { codexAccountHome, createCodexAccount, type CodexAccount } from "./codex-account.service.ts";
|
|
20
21
|
|
|
21
22
|
const CLIENT_INFO = { name: "ppm", title: "PPM", version: "0.0.0" };
|
|
@@ -29,13 +30,13 @@ export interface LoginClient {
|
|
|
29
30
|
start(opts?: { cwd?: string; codexHome?: string }): void;
|
|
30
31
|
onNotification(fn: (n: { method: string; params?: unknown }) => void): void;
|
|
31
32
|
onClose(fn: (code: number | null) => void): void;
|
|
32
|
-
request<T = unknown>(method: string, params?: unknown): Promise<T>;
|
|
33
|
+
request<T = unknown>(method: string, params?: unknown, timeoutMs?: number): Promise<T>;
|
|
33
34
|
notify(method: string, params?: unknown): void;
|
|
34
35
|
close(): void;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
async function handshake(c: LoginClient): Promise<void> {
|
|
38
|
-
await c.request("initialize", { clientInfo: CLIENT_INFO, capabilities: CAPABILITIES });
|
|
39
|
+
await c.request("initialize", { clientInfo: CLIENT_INFO, capabilities: CAPABILITIES }, CONTROL_REQUEST_TIMEOUT_MS);
|
|
39
40
|
c.notify("initialized");
|
|
40
41
|
}
|
|
41
42
|
|
|
@@ -72,7 +73,10 @@ export type DeviceLoginStatus =
|
|
|
72
73
|
| { state: "done"; account: CodexAccount }
|
|
73
74
|
| { state: "error"; error: string };
|
|
74
75
|
|
|
75
|
-
interface
|
|
76
|
+
interface ChatGptLogin {
|
|
77
|
+
method: "device" | "browser";
|
|
78
|
+
browser?: BrowserLoginCallback;
|
|
79
|
+
callbackAbort?: AbortController;
|
|
76
80
|
client: LoginClient; home: string; label?: string;
|
|
77
81
|
status: DeviceLoginStatus;
|
|
78
82
|
timer: ReturnType<typeof setTimeout>;
|
|
@@ -80,10 +84,14 @@ interface DeviceLogin {
|
|
|
80
84
|
* home must survive this window even if the browser walks away. */
|
|
81
85
|
finalizing: boolean;
|
|
82
86
|
}
|
|
83
|
-
const
|
|
87
|
+
const pendingLogins = new Map<string, ChatGptLogin>();
|
|
88
|
+
|
|
89
|
+
type ChatGptLoginStart = { id: string; userCode: string; verificationUrl: string; authUrl: string };
|
|
90
|
+
type CompletionNotification = { success?: boolean; error?: string | null };
|
|
84
91
|
|
|
85
92
|
/** How long an unfinished authorization may sit before it is reaped. */
|
|
86
93
|
const DEVICE_LOGIN_TTL = 200_000;
|
|
94
|
+
const BROWSER_LOGIN_TTL = 600_000;
|
|
87
95
|
/** How long a finished result stays readable for the polling browser. */
|
|
88
96
|
const RESULT_GRACE_MS = 120_000;
|
|
89
97
|
|
|
@@ -92,12 +100,21 @@ function unref(t: ReturnType<typeof setTimeout>): ReturnType<typeof setTimeout>
|
|
|
92
100
|
return t;
|
|
93
101
|
}
|
|
94
102
|
|
|
103
|
+
function hasPendingBrowserLogin(): boolean {
|
|
104
|
+
return [...pendingLogins.values()].some((p) => p.method === "browser" && p.status.state === "pending");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isCurrentPendingLogin(id: string, p: ChatGptLogin): boolean {
|
|
108
|
+
return pendingLogins.get(id) === p && p.status.state === "pending" && !p.finalizing;
|
|
109
|
+
}
|
|
110
|
+
|
|
95
111
|
/** Forget a flow entirely: stop its app-server, optionally drop its home. */
|
|
96
|
-
function
|
|
97
|
-
const p =
|
|
112
|
+
function disposeLogin(id: string, rmHome: boolean): void {
|
|
113
|
+
const p = pendingLogins.get(id);
|
|
98
114
|
if (!p) return;
|
|
99
|
-
|
|
115
|
+
pendingLogins.delete(id);
|
|
100
116
|
clearTimeout(p.timer);
|
|
117
|
+
p.callbackAbort?.abort();
|
|
101
118
|
try { p.client.close(); } catch { /* ignore */ }
|
|
102
119
|
if (rmHome) { try { rmSync(p.home, { recursive: true, force: true }); } catch { /* ignore */ } }
|
|
103
120
|
}
|
|
@@ -106,26 +123,30 @@ function disposeDeviceLogin(id: string, rmHome: boolean): void {
|
|
|
106
123
|
* event that trails a success cannot overwrite it. The entry lingers for
|
|
107
124
|
* RESULT_GRACE_MS — that window is what makes a lost poll response harmless. */
|
|
108
125
|
function settle(id: string, status: Exclude<DeviceLoginStatus, { state: "pending" }>): void {
|
|
109
|
-
const p =
|
|
126
|
+
const p = pendingLogins.get(id);
|
|
110
127
|
if (!p || p.status.state !== "pending") return;
|
|
111
128
|
p.status = status;
|
|
112
129
|
clearTimeout(p.timer);
|
|
130
|
+
p.callbackAbort?.abort();
|
|
113
131
|
try { p.client.close(); } catch { /* ignore */ }
|
|
114
132
|
// A failure leaves no account owning the dir, so the half-built home goes now.
|
|
115
133
|
// A success must keep it: createCodexAccount adopted it as the account's home.
|
|
116
134
|
if (status.state === "error") { try { rmSync(p.home, { recursive: true, force: true }); } catch { /* ignore */ } }
|
|
117
|
-
p.timer = unref(setTimeout(() => {
|
|
135
|
+
p.timer = unref(setTimeout(() => { pendingLogins.delete(id); }, RESULT_GRACE_MS));
|
|
118
136
|
}
|
|
119
137
|
|
|
120
138
|
/** Turn the app-server's completion notification into a persisted account. */
|
|
121
|
-
async function
|
|
122
|
-
const p =
|
|
123
|
-
if (!p || p.status.state !== "pending") return;
|
|
124
|
-
if (!notif?.success) { settle(id, { state: "error", error: notif?.error || "login not completed" }); return; }
|
|
139
|
+
async function finalizeLogin(id: string, notif: CompletionNotification): Promise<void> {
|
|
140
|
+
const p = pendingLogins.get(id);
|
|
141
|
+
if (!p || p.status.state !== "pending" || p.finalizing) return;
|
|
142
|
+
if (!notif?.success) { settle(id, { state: "error", error: p.method === "browser" ? "ChatGPT sign-in failed. Start a new browser login and try again." : notif?.error || "login not completed" }); return; }
|
|
125
143
|
p.finalizing = true;
|
|
144
|
+
// Authorization has completed; account/read has its own bounded timeout.
|
|
145
|
+
clearTimeout(p.timer);
|
|
126
146
|
try {
|
|
127
|
-
const read = await p.client.request<AccountRead>("account/read", {});
|
|
147
|
+
const read = await p.client.request<AccountRead>("account/read", {}, CONTROL_REQUEST_TIMEOUT_MS);
|
|
128
148
|
if (!read?.account) throw new Error("login completed but account is empty");
|
|
149
|
+
if (p.status.state !== "pending") return;
|
|
129
150
|
const account = createCodexAccount({
|
|
130
151
|
id, label: p.label || read.account.email || "ChatGPT", type: "chatgpt",
|
|
131
152
|
planType: read.account.planType ?? null,
|
|
@@ -138,43 +159,97 @@ async function finalizeDeviceLogin(id: string, notif: { success?: boolean; error
|
|
|
138
159
|
|
|
139
160
|
/** Begin a ChatGPT device-code login. Returns the code/URL the user enters in a
|
|
140
161
|
* browser; the caller then polls getDeviceLoginStatus. */
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
162
|
+
async function startChatGptLogin(
|
|
163
|
+
method: "device" | "browser",
|
|
164
|
+
label: string | undefined,
|
|
165
|
+
makeClient: () => LoginClient,
|
|
166
|
+
): Promise<ChatGptLoginStart> {
|
|
167
|
+
if (method === "browser" && hasPendingBrowserLogin()) {
|
|
168
|
+
throw new Error("A browser login is already in progress. Finish or cancel it first.");
|
|
169
|
+
}
|
|
145
170
|
const id = randomUUID();
|
|
146
171
|
const home = codexAccountHome(id);
|
|
147
172
|
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
148
173
|
const client = makeClient();
|
|
149
174
|
client.onNotification((n) => {
|
|
150
175
|
if (n.method === "account/login/completed") {
|
|
151
|
-
void
|
|
176
|
+
void finalizeLogin(id, (n.params ?? {}) as CompletionNotification);
|
|
152
177
|
}
|
|
153
178
|
});
|
|
154
179
|
client.onClose(() => settle(id, { state: "error", error: "login process exited" }));
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
// Registered before the first I/O so a notification (or an immediate exit)
|
|
159
|
-
// arriving mid-handshake still finds its entry.
|
|
160
|
-
deviceLogins.set(id, { client, home, label, status: { state: "pending" }, timer, finalizing: false });
|
|
180
|
+
const timer = unref(setTimeout(() => settle(id, { state: "error", error: "timed out" }), method === "browser" ? BROWSER_LOGIN_TTL : DEVICE_LOGIN_TTL));
|
|
181
|
+
const pending: ChatGptLogin = { method, client, home, label, status: { state: "pending" }, timer, finalizing: false };
|
|
182
|
+
pendingLogins.set(id, pending);
|
|
161
183
|
try {
|
|
184
|
+
if (method === "browser") await checkCodexLoginPort();
|
|
162
185
|
client.start({ codexHome: home });
|
|
163
186
|
await handshake(client);
|
|
164
|
-
const start = await client.request<{ userCode?: string; verificationUrl?: string }>(
|
|
165
|
-
"account/login/start", { type: "chatgptDeviceCode" },
|
|
187
|
+
const start = await client.request<{ userCode?: string; verificationUrl?: string; authUrl?: string }>(
|
|
188
|
+
"account/login/start", { type: method === "browser" ? "chatgpt" : "chatgptDeviceCode" }, CONTROL_REQUEST_TIMEOUT_MS,
|
|
166
189
|
);
|
|
167
|
-
|
|
190
|
+
if (method === "browser") pending.browser = parseBrowserLogin(start.authUrl ?? "");
|
|
191
|
+
return { id, userCode: start?.userCode ?? "", verificationUrl: start?.verificationUrl ?? "", authUrl: start?.authUrl ?? "" };
|
|
168
192
|
} catch (e) {
|
|
169
|
-
|
|
193
|
+
disposeLogin(id, true);
|
|
170
194
|
throw e;
|
|
171
195
|
}
|
|
172
196
|
}
|
|
173
197
|
|
|
198
|
+
export async function startDeviceLogin(label?: string, makeClient: () => LoginClient = () => new CodexJsonRpcClient()) {
|
|
199
|
+
const { id, userCode, verificationUrl } = await startChatGptLogin("device", label, makeClient);
|
|
200
|
+
return { id, userCode, verificationUrl };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function startBrowserLogin(label?: string, makeClient: () => LoginClient = () => new CodexJsonRpcClient()) {
|
|
204
|
+
const { id, authUrl } = await startChatGptLogin("browser", label, makeClient);
|
|
205
|
+
return { id, authUrl };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Submission is acknowledged immediately. Only the app-server notification and
|
|
209
|
+
* account/read determine success, even if the forwarding HTTP request drops. */
|
|
210
|
+
export function submitBrowserCallback(id: string, callbackUrl: string, send: typeof fetch = fetch): void {
|
|
211
|
+
const p = pendingLogins.get(id);
|
|
212
|
+
if (!p || p.method !== "browser" || !p.browser) throw new Error("No browser login found. Start a new login.");
|
|
213
|
+
if (p.status.state === "done" || p.finalizing) return;
|
|
214
|
+
if (p.status.state === "error") throw new Error(p.status.error);
|
|
215
|
+
const browser = p.browser;
|
|
216
|
+
const target = buildCodexCallback(callbackUrl, browser);
|
|
217
|
+
if (browser.submitted) return;
|
|
218
|
+
browser.submitted = true;
|
|
219
|
+
const controller = new AbortController();
|
|
220
|
+
p.callbackAbort = controller;
|
|
221
|
+
const timeout = unref(setTimeout(() => controller.abort(), 30_000));
|
|
222
|
+
void (async () => {
|
|
223
|
+
try {
|
|
224
|
+
const response = await send(target, { redirect: "manual", signal: controller.signal });
|
|
225
|
+
// Do not render or log the OAuth response body or follow its redirect.
|
|
226
|
+
await response.body?.cancel();
|
|
227
|
+
if (response.status >= 400 && isCurrentPendingLogin(id, p)) {
|
|
228
|
+
settle(id, { state: "error", error: "Codex rejected the callback. Start a new browser login." });
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
// A closed callback socket may mean successful login. Polling owns the result.
|
|
232
|
+
if (isCurrentPendingLogin(id, p)) browser.submitted = false;
|
|
233
|
+
} finally {
|
|
234
|
+
clearTimeout(timeout);
|
|
235
|
+
if (p.callbackAbort === controller) p.callbackAbort = undefined;
|
|
236
|
+
}
|
|
237
|
+
})();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function getBrowserLoginStatus(id: string): DeviceLoginStatus {
|
|
241
|
+
const p = pendingLogins.get(id);
|
|
242
|
+
return p?.method === "browser" ? p.status : { state: "error", error: "no pending browser login for that id" };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function cancelBrowserLogin(id: string): void {
|
|
246
|
+
if (pendingLogins.get(id)?.method === "browser") cancelDeviceLogin(id);
|
|
247
|
+
}
|
|
248
|
+
|
|
174
249
|
/** Read a device-code flow's outcome. Idempotent — repeat polls are free and a
|
|
175
250
|
* dropped response costs one tick, not the flow. */
|
|
176
251
|
export function getDeviceLoginStatus(id: string): DeviceLoginStatus {
|
|
177
|
-
return
|
|
252
|
+
return pendingLogins.get(id)?.status ?? { state: "error", error: "no pending device login for that id" };
|
|
178
253
|
}
|
|
179
254
|
|
|
180
255
|
/** Abandon a flow the user cancelled. A login already being written is left
|
|
@@ -182,7 +257,7 @@ export function getDeviceLoginStatus(id: string): DeviceLoginStatus {
|
|
|
182
257
|
* authorization the user completed. A finished one keeps its home: an account
|
|
183
258
|
* owns it now. */
|
|
184
259
|
export function cancelDeviceLogin(id: string): void {
|
|
185
|
-
const p =
|
|
260
|
+
const p = pendingLogins.get(id);
|
|
186
261
|
if (!p || p.finalizing) return;
|
|
187
|
-
|
|
262
|
+
disposeLogin(id, p.status.state !== "done");
|
|
188
263
|
}
|
|
@@ -15,6 +15,7 @@ import { getPpmDir } from "./ppm-dir.ts";
|
|
|
15
15
|
import { configService } from "./config.service.ts";
|
|
16
16
|
import { encrypt, decrypt } from "../lib/account-crypto.ts";
|
|
17
17
|
import { getOrFetchUsage } from "./provider-usage/usage-registry.ts";
|
|
18
|
+
import { isCodexAccountUsageLimited } from "./codex-account-cooldown.ts";
|
|
18
19
|
import type { UsageInfo } from "../providers/provider.interface.ts";
|
|
19
20
|
|
|
20
21
|
export type CodexStrategy = "round-robin" | "fill-first" | "lowest-usage";
|
|
@@ -162,6 +163,22 @@ export function setCodexStrategy(strategy: CodexStrategy): void {
|
|
|
162
163
|
/** Five-hour utilisation at or above which an account is skipped while others have room. */
|
|
163
164
|
const FIVE_HOUR_SKIP_THRESHOLD = 0.95;
|
|
164
165
|
|
|
166
|
+
/**
|
|
167
|
+
* The utilisation an account should be judged on, as a 0-1 fraction.
|
|
168
|
+
*
|
|
169
|
+
* Not every plan has a short window. A ChatGPT Business account reports a weekly quota and
|
|
170
|
+
* nothing else, so reading `fiveHour` alone finds nothing there and reports the account as
|
|
171
|
+
* unreadable — which sends it to the back of `lowest-usage` and exempts it from the cap
|
|
172
|
+
* skip, for an account whose usage is in fact perfectly well known. Falling through to the
|
|
173
|
+
* weekly figure judges it on the only limit it actually has.
|
|
174
|
+
*
|
|
175
|
+
* `Infinity` still means genuinely unreadable: a failed fetch de-prioritises the account
|
|
176
|
+
* rather than reading as a flattering 0%.
|
|
177
|
+
*/
|
|
178
|
+
export function codexUsageLevel(usage?: UsageInfo): number {
|
|
179
|
+
return usage?.fiveHour ?? usage?.sevenDay ?? Number.POSITIVE_INFINITY;
|
|
180
|
+
}
|
|
181
|
+
|
|
165
182
|
/**
|
|
166
183
|
* Drop accounts with no five-hour room left, unless that would leave nothing.
|
|
167
184
|
*
|
|
@@ -183,6 +200,20 @@ function withFiveHourRoom(
|
|
|
183
200
|
return room.length > 0 ? room : accts;
|
|
184
201
|
}
|
|
185
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Drop accounts Codex itself refused for an exhausted quota, unless that would leave nothing.
|
|
205
|
+
*
|
|
206
|
+
* Complements the five-hour skip rather than repeating it. That one reads a usage figure
|
|
207
|
+
* served from a two-minute cache, so an account that hit its cap moments ago still looks
|
|
208
|
+
* like it has room; this one reads the refusal Codex just gave us, which is current by
|
|
209
|
+
* definition. Soft for the same reason as the skip above: a park is a guess at when the
|
|
210
|
+
* quota returns, and handing back a parked account beats handing back nothing.
|
|
211
|
+
*/
|
|
212
|
+
function withQuotaLeft(accts: CodexAccount[]): CodexAccount[] {
|
|
213
|
+
const usable = accts.filter((a) => !isCodexAccountUsageLimited(a.id));
|
|
214
|
+
return usable.length > 0 ? usable : accts;
|
|
215
|
+
}
|
|
216
|
+
|
|
186
217
|
/**
|
|
187
218
|
* Pick an account by strategy. `usageOf` (optional, from P4) enables lowest-usage;
|
|
188
219
|
* without it lowest-usage falls back to round-robin.
|
|
@@ -190,16 +221,20 @@ function withFiveHourRoom(
|
|
|
190
221
|
* `usageOf` also drives the five-hour skip, so a caller that supplies it gets an account
|
|
191
222
|
* with room rather than merely the least-used one among several that are all capped.
|
|
192
223
|
*/
|
|
193
|
-
export function selectCodexAccount(opts?: { strategy?: CodexStrategy; usageOf?: (id: string) => number }): CodexAccount | null {
|
|
224
|
+
export function selectCodexAccount(opts?: { strategy?: CodexStrategy; usageOf?: (id: string) => number; exclude?: string[] }): CodexAccount | null {
|
|
194
225
|
// Disabled is a hard exclusion with no fallback, unlike the five-hour skip below. Being
|
|
195
226
|
// capped means a slower turn; being switched off is the user saying don't use this, and
|
|
196
227
|
// handing it back anyway because nothing else was left would ignore them.
|
|
197
|
-
|
|
228
|
+
//
|
|
229
|
+
// `exclude` is hard for the same reason: its only caller is the rotation away from an
|
|
230
|
+
// account that just refused the turn, and falling back to it would rotate in a circle.
|
|
231
|
+
const excluded = new Set(opts?.exclude ?? []);
|
|
232
|
+
const all = listCodexAccounts().filter((a) => a.status !== "disabled" && !excluded.has(a.id));
|
|
198
233
|
if (all.length === 0) return null;
|
|
199
234
|
if (all.length === 1) return all[0]!;
|
|
200
235
|
const strategy = opts?.strategy ?? getCodexStrategy();
|
|
201
236
|
const usageOf = opts?.usageOf;
|
|
202
|
-
const accts = withFiveHourRoom(all, usageOf);
|
|
237
|
+
const accts = withFiveHourRoom(withQuotaLeft(all), usageOf);
|
|
203
238
|
if (accts.length === 1) return accts[0]!;
|
|
204
239
|
if (strategy === "fill-first") return accts[0]!;
|
|
205
240
|
if (strategy === "lowest-usage" && usageOf) {
|
|
@@ -225,9 +260,10 @@ export function selectCodexAccount(opts?: { strategy?: CodexStrategy; usageOf?:
|
|
|
225
260
|
* null rather than guessing at one.
|
|
226
261
|
*/
|
|
227
262
|
export function peekCodexAccount(): CodexAccount | null {
|
|
228
|
-
// Status
|
|
229
|
-
// behind a fetch and stays out of reach here; the consuming pick
|
|
230
|
-
|
|
263
|
+
// Status and the quota park are the two things this can filter on without going async, so
|
|
264
|
+
// it does both. Usage lives behind a fetch and stays out of reach here; the consuming pick
|
|
265
|
+
// endpoint reads it instead.
|
|
266
|
+
const accts = withQuotaLeft(listCodexAccounts().filter((a) => a.status !== "disabled"));
|
|
231
267
|
if (accts.length === 0) return null;
|
|
232
268
|
if (accts.length === 1) return accts[0]!;
|
|
233
269
|
return getCodexStrategy() === "fill-first" ? accts[0]! : null;
|
|
@@ -240,9 +276,19 @@ export async function resolveCodexAccountForSession(sessionId?: string): Promise
|
|
|
240
276
|
const sticky = sessionId ? getSessionCodexAccount(sessionId) : null;
|
|
241
277
|
// A binding is held for the prompt cache, not honoured unconditionally: an account the
|
|
242
278
|
// user has switched off has to let go of the sessions sitting on it, or turning it off
|
|
243
|
-
// would do nothing for exactly the conversations already using it.
|
|
244
|
-
|
|
245
|
-
|
|
279
|
+
// would do nothing for exactly the conversations already using it. An account parked for
|
|
280
|
+
// an exhausted quota lets go for a different reason — it would refuse the turn, and
|
|
281
|
+
// spending one to rediscover that is worse than starting on an account that can answer.
|
|
282
|
+
if (sticky) {
|
|
283
|
+
const a = getCodexAccount(sticky);
|
|
284
|
+
if (a && a.status !== "disabled" && !isCodexAccountUsageLimited(a.id)) return a;
|
|
285
|
+
}
|
|
286
|
+
// Usage is only worth fetching when it can change the answer, and with at most one
|
|
287
|
+
// account able to serve it cannot. Parked accounts count as unable for the same reason
|
|
288
|
+
// they are skipped below, so a pool of two where one is out of quota resolves without a
|
|
289
|
+
// network read at all.
|
|
290
|
+
const usable = listCodexAccounts().filter((a) => a.status !== "disabled" && !isCodexAccountUsageLimited(a.id));
|
|
291
|
+
if (usable.length <= 1) return selectCodexAccount();
|
|
246
292
|
// Usage is read for every strategy, not just lowest-usage: it is also what lets the
|
|
247
293
|
// selector skip an account with no five-hour room left. Reading it only for lowest-usage
|
|
248
294
|
// left that skip inert under round-robin, which is the default — sessions kept landing on
|
|
@@ -252,7 +298,7 @@ export async function resolveCodexAccountForSession(sessionId?: string): Promise
|
|
|
252
298
|
// A failed usage fetch (broken/expired account) yields {} → treat as +Infinity so
|
|
253
299
|
// lowest-usage de-prioritizes it instead of preferring it as "0% used".
|
|
254
300
|
const usages = await getAllCodexUsages();
|
|
255
|
-
return selectCodexAccount({ usageOf: (id) => usages[id]
|
|
301
|
+
return selectCodexAccount({ usageOf: (id) => codexUsageLevel(usages[id]) });
|
|
256
302
|
}
|
|
257
303
|
|
|
258
304
|
// ── Usage (per account) ──
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createServer } from "node:net";
|
|
2
|
+
|
|
3
|
+
export interface BrowserLoginCallback {
|
|
4
|
+
redirectUri: string;
|
|
5
|
+
state: string;
|
|
6
|
+
submitted: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Codex may cancel an existing listener on its preferred port when starting.
|
|
10
|
+
* Refuse an occupied port instead of interrupting another application's login. */
|
|
11
|
+
export async function checkCodexLoginPort(): Promise<void> {
|
|
12
|
+
await new Promise<void>((resolve, reject) => {
|
|
13
|
+
const server = createServer();
|
|
14
|
+
server.once("error", () => reject(new Error("Codex login port 1455 is busy. Finish the other browser login or use device code.")));
|
|
15
|
+
server.listen(1455, "127.0.0.1", () => server.close(() => resolve()));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseBrowserLogin(authUrl: string): BrowserLoginCallback {
|
|
20
|
+
const auth = new URL(authUrl);
|
|
21
|
+
if (auth.protocol !== "https:" || !["auth.openai.com", "chatgpt.com"].includes(auth.hostname) || auth.username || auth.password) {
|
|
22
|
+
throw new Error("Codex returned an unsupported login URL.");
|
|
23
|
+
}
|
|
24
|
+
const redirect = new URL(auth.searchParams.get("redirect_uri") ?? "");
|
|
25
|
+
if (redirect.protocol !== "http:" || redirect.hostname !== "localhost" || !["1455", "1457"].includes(redirect.port)
|
|
26
|
+
|| redirect.pathname !== "/auth/callback" || redirect.search || redirect.hash || redirect.username || redirect.password) {
|
|
27
|
+
throw new Error("Codex returned an unsupported callback URL.");
|
|
28
|
+
}
|
|
29
|
+
const state = auth.searchParams.get("state");
|
|
30
|
+
if (!state) throw new Error("Codex returned a login URL without state.");
|
|
31
|
+
return { redirectUri: redirect.href, state, submitted: false };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Never fetch the user-supplied URL. Validate it against this pending login,
|
|
35
|
+
* then rebuild a request to the fixed loopback listener using only code/state. */
|
|
36
|
+
export function buildCodexCallback(callbackUrl: string, login: BrowserLoginCallback): URL {
|
|
37
|
+
if (typeof callbackUrl !== "string" || callbackUrl.length > 16_384) throw new Error("Paste the full localhost callback URL.");
|
|
38
|
+
let url: URL;
|
|
39
|
+
try { url = new URL(callbackUrl.trim()); } catch { throw new Error("Paste the full localhost callback URL."); }
|
|
40
|
+
const expected = new URL(login.redirectUri);
|
|
41
|
+
if (url.origin !== expected.origin || url.pathname !== expected.pathname || url.username || url.password || url.hash) {
|
|
42
|
+
throw new Error("Use the localhost callback URL from this Codex login.");
|
|
43
|
+
}
|
|
44
|
+
const state = url.searchParams.get("state");
|
|
45
|
+
// Codex also accepts this exact onboarding suffix; it still binds to the original state.
|
|
46
|
+
if (url.searchParams.getAll("state").length !== 1 || (state !== login.state && state !== `${login.state}.onboarding_entrypoint=life_sciences`)) {
|
|
47
|
+
throw new Error("This URL belongs to a different login. Use the latest sign-in link.");
|
|
48
|
+
}
|
|
49
|
+
const code = url.searchParams.get("code");
|
|
50
|
+
if (url.searchParams.has("error") || !code || url.searchParams.getAll("code").length !== 1) {
|
|
51
|
+
throw new Error("This URL has no authorization code. Complete ChatGPT sign-in first.");
|
|
52
|
+
}
|
|
53
|
+
expected.hostname = "127.0.0.1";
|
|
54
|
+
expected.searchParams.set("code", code);
|
|
55
|
+
expected.searchParams.set("state", state!);
|
|
56
|
+
return expected;
|
|
57
|
+
}
|
|
@@ -89,7 +89,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
89
89
|
|
|
90
90
|
// Usage runs independently — auto-refreshes on interval. Scoped to this session so the
|
|
91
91
|
// account shown is the one bound to it, not whichever session ran most recently.
|
|
92
|
-
const { usageInfo, usageLoading, lastFetchedAt, refreshUsage } =
|
|
92
|
+
const { usageInfo, usageLoading, lastFetchedAt, refreshUsage, reloadUsage } =
|
|
93
93
|
useUsage(projectName, providerId, sessionId ?? undefined);
|
|
94
94
|
|
|
95
95
|
// Draft auto-save/restore
|
|
@@ -183,6 +183,12 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
183
183
|
// other source is the usage endpoint on a two-minute poll — so without this the badge
|
|
184
184
|
// sits on the old account long after the switch, which reads as the button not working.
|
|
185
185
|
setServingAccount({ id: accountId, label });
|
|
186
|
+
// The header chip reports the account BOUND to this session, and that binding has
|
|
187
|
+
// just changed — so re-read it now instead of leaving the chip on the previous
|
|
188
|
+
// account's figures for up to the two-minute poll. Before the first message a
|
|
189
|
+
// session has no binding at all, so those figures were blank, and the chip sat at
|
|
190
|
+
// "--%" beside a panel already showing the chosen account's quota.
|
|
191
|
+
void reloadUsage();
|
|
186
192
|
// Switching costs a full prompt-cache write, so it is worth saying out loud rather
|
|
187
193
|
// than letting the chip quietly change.
|
|
188
194
|
toast.success("This chat will use the selected account from the next message.");
|
|
@@ -198,7 +204,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
198
204
|
metadata: { ...metadata, pickedAccountId: accountId, pickedAccountLabel: label, pickedAccountProvider: providerId },
|
|
199
205
|
});
|
|
200
206
|
return null;
|
|
201
|
-
}, [sessionId, projectName, tabId, metadata, providerId, updateTab]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
207
|
+
}, [sessionId, projectName, tabId, metadata, providerId, updateTab, reloadUsage]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
202
208
|
|
|
203
209
|
/**
|
|
204
210
|
* The account this chat is on, as far as the UI can tell.
|