Add OAuth disconnect and remaining quota visibility
Allow users to disconnect their OpenAI account by clearing stored ChatGPT OAuth tokens while preserving unrelated auth data. Fetch and normalize Codex usage windows, then show remaining percentage and reset timing in the OAuth settings UI. Add focused tests for usage parsing and disconnect cleanup.
Alessandro committed
May 2, 2026 at 20:14 UTC
0da8f3dc2b640efbce22499053507837101fdf6f
5 files changed
+666
-1
plugins/_oauth/api/disconnect.py
new
+17
@@ -0,0 +1,17 @@
1
+from __future__ import annotations
2
+
3
+from helpers.api import ApiHandler, Request
4
+from plugins._oauth.helpers import codex
5
+
6
+
7
+class Disconnect(ApiHandler):
8
+ async def process(self, input: dict, request: Request) -> dict:
9
+ try:
10
+ result = codex.disconnect_auth()
11
+ return {
12
+ "ok": True,
13
+ **result,
14
+ "codex": codex.status(),
15
+ }
16
+ except Exception as exc:
17
+ return {"ok": False, "error": str(exc)}
plugins/_oauth/helpers/codex.py
+381
-1
@@ -10,7 +10,7 @@ import time
10
from dataclasses import dataclass
11
from datetime import datetime, timedelta, timezone
12
from pathlib import Path
13
-from typing import Any, Iterable
13
+from typing import Any, Iterable, Mapping
14
from urllib.parse import parse_qs, urlencode, urljoin, urlparse
15
16
import requests
@@ -25,6 +25,11 @@ REFRESH_INTERVAL = timedelta(minutes=55)
25
FALLBACK_CODEX_VERSION = "0.124.0"
26
OAUTH_ERROR_KEYS = {"error", "error_description"}
27
DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60
28
+USAGE_ENDPOINT_PATHS = (
29
+ "/backend-api/codex/usage",
30
+ "/backend-api/wham/usage",
31
+ "/api/codex/usage",
32
+)
33
34
35
@dataclass(frozen=True)
@@ -306,9 +311,160 @@ def status() -> dict[str, Any]:
311
"last_refresh": auth.last_refresh,
312
}
313
)
314
+ try:
315
+ result["usage"] = fetch_usage()
316
+ except Exception as exc:
317
+ result["usage"] = {"available": False, "error": str(exc)}
318
+ return result
319
+
320
+
321
+def disconnect_auth() -> dict[str, Any]:
322
+ cleared_paths: list[str] = []
323
+ removed_paths: list[str] = []
324
+ preserved_paths: list[str] = []
325
+
326
+ for path in resolve_auth_file_candidates():
327
+ if not path.is_file():
328
+ continue
329
+ try:
330
+ with path.open("r", encoding="utf-8") as handle:
331
+ data = json.load(handle)
332
+ except Exception:
333
+ continue
334
+ if not isinstance(data, dict) or not _contains_chatgpt_auth(data):
335
+ continue
336
+
337
+ cleaned = dict(data)
338
+ cleaned.pop("tokens", None)
339
+ cleaned.pop("last_refresh", None)
340
+ if _string(cleaned.get("auth_mode")).lower() == "chatgpt":
341
+ cleaned.pop("auth_mode", None)
342
+
343
+ cleared_paths.append(str(path))
344
+ if _has_meaningful_auth_data(cleaned):
345
+ write_auth_file(path, cleaned)
346
+ preserved_paths.append(str(path))
347
+ continue
348
+
349
+ path.unlink(missing_ok=True)
350
+ removed_paths.append(str(path))
351
+
352
+ return {
353
+ "disconnected": bool(cleared_paths),
354
+ "cleared_auth_files": cleared_paths,
355
+ "removed_auth_files": removed_paths,
356
+ "preserved_auth_files": preserved_paths,
357
+ }
358
+
359
+
360
+def fetch_usage() -> dict[str, Any]:
361
+ cfg = codex_config()
362
+ auth = load_auth()
363
+ errors: list[str] = []
364
+ headers = {
365
+ "Authorization": f"Bearer {auth.access_token}",
366
+ "ChatGPT-Account-Id": auth.account_id,
367
+ "Accept": "application/json",
368
+ "User-Agent": "codex-cli",
369
+ }
370
+
371
+ for url in usage_endpoint_candidates(cfg["upstream_base_url"]):
372
+ try:
373
+ response = requests.get(
374
+ url,
375
+ headers=headers,
376
+ timeout=max(5, min(cfg["request_timeout_seconds"], 30)),
377
+ )
378
+ except Exception as exc:
379
+ errors.append(str(exc))
380
+ continue
381
+
382
+ if not response.ok:
383
+ errors.append(upstream_error_message(response, "Failed to load Codex usage."))
384
+ continue
385
+
386
+ try:
387
+ payload = response.json()
388
+ except Exception:
389
+ payload = {}
390
+ usage = normalize_usage_payload(payload, response.headers)
391
+ if usage["available"]:
392
+ usage["endpoint_path"] = urlparse(url).path
393
+ return usage
394
+ errors.append("Usage endpoint returned no rate-limit data.")
395
+
396
+ suffix = f" {' '.join(errors[-2:])}" if errors else ""
397
+ raise RuntimeError(f"Failed to load Codex usage.{suffix}")
398
+
399
+
400
+def usage_endpoint_candidates(upstream_base_url: str) -> list[str]:
401
+ parsed = urlparse(upstream_base_url)
402
+ if not parsed.scheme or not parsed.netloc:
403
+ return []
404
+
405
+ root = f"{parsed.scheme}://{parsed.netloc}"
406
+ paths = list(USAGE_ENDPOINT_PATHS)
407
+ upstream_path = parsed.path.rstrip("/")
408
+ if upstream_path and upstream_path.endswith("/codex"):
409
+ paths.insert(0, f"{upstream_path}/usage")
410
+
411
+ result: list[str] = []
412
+ seen: set[str] = set()
413
+ for path in paths:
414
+ url = urljoin(root.rstrip("/") + "/", path.lstrip("/"))
415
+ if url in seen:
416
+ continue
417
+ seen.add(url)
418
+ result.append(url)
419
return result
420
421
422
+def normalize_usage_payload(
423
+ payload: Mapping[str, Any] | None,
424
+ headers: Mapping[str, Any] | None = None,
425
+) -> dict[str, Any]:
426
+ body = payload if isinstance(payload, Mapping) else {}
427
+ rate_limit = _record(body.get("rate_limit")) or _record(body.get("rateLimits"))
428
+ header_usage = _normalize_usage_headers(headers or {})
429
+
430
+ primary = (
431
+ _normalize_usage_window(rate_limit.get("primary_window"))
432
+ or _normalize_usage_window(rate_limit.get("primary"))
433
+ or _normalize_usage_window(body.get("primary_window"))
434
+ or header_usage.get("primary")
435
+ )
436
+ secondary = (
437
+ _normalize_usage_window(rate_limit.get("secondary_window"))
438
+ or _normalize_usage_window(rate_limit.get("secondary"))
439
+ or _normalize_usage_window(body.get("secondary_window"))
440
+ or header_usage.get("secondary")
441
+ )
442
+ code_review = _normalize_code_review_usage(body.get("code_review_rate_limit"))
443
+ additional = _normalize_additional_rate_limits(rate_limit.get("additional_rate_limits"))
444
+ credits = _normalize_credits(body.get("credits"))
445
+ plan_type = (
446
+ _string(body.get("plan_type"))
447
+ or _string(body.get("planType"))
448
+ or _string(header_usage.get("plan_type"))
449
+ )
450
+
451
+ return {
452
+ "available": bool(primary or secondary or code_review or additional),
453
+ "plan_type": plan_type,
454
+ "primary": primary,
455
+ "secondary": secondary,
456
+ "code_review": code_review,
457
+ "additional": additional,
458
+ "credits": credits,
459
+ "rate_limit_reached_type": _string(
460
+ rate_limit.get("rate_limit_reached_type")
461
+ or rate_limit.get("rateLimitReachedType")
462
+ or body.get("rate_limit_reached_type")
463
+ or body.get("rateLimitReachedType")
464
+ ),
465
+ }
466
+
467
+
468
def refresh_tokens(refresh_token: str) -> dict[str, str]:
469
cfg = codex_config()
470
response = requests.post(
@@ -867,3 +1023,227 @@ def _unique_paths(paths: list[Path]) -> list[Path]:
1023
seen.add(key)
1024
result.append(path)
1025
return result
1026
+
1027
+
1028
+def _contains_chatgpt_auth(data: dict[str, Any]) -> bool:
1029
+ tokens = _record(data.get("tokens"))
1030
+ if _string(data.get("auth_mode")).lower() == "chatgpt":
1031
+ return True
1032
+ return any(
1033
+ _string(tokens.get(key))
1034
+ for key in ("access_token", "refresh_token", "id_token", "account_id")
1035
+ )
1036
+
1037
+
1038
+def _has_meaningful_auth_data(data: dict[str, Any]) -> bool:
1039
+ for value in data.values():
1040
+ if value is None:
1041
+ continue
1042
+ if isinstance(value, str) and not value.strip():
1043
+ continue
1044
+ if isinstance(value, (dict, list, tuple, set)) and not value:
1045
+ continue
1046
+ return True
1047
+ return False
1048
+
1049
+
1050
+def _normalize_usage_headers(headers: Mapping[str, Any]) -> dict[str, Any]:
1051
+ lowered = {str(key).lower(): value for key, value in headers.items()}
1052
+ primary = _normalize_usage_window(
1053
+ {
1054
+ "used_percent": lowered.get("x-codex-primary-used-percent"),
1055
+ "window_minutes": lowered.get("x-codex-primary-window-minutes"),
1056
+ "reset_at": lowered.get("x-codex-primary-resets-at")
1057
+ or lowered.get("x-codex-primary-reset-at"),
1058
+ }
1059
+ )
1060
+ secondary = _normalize_usage_window(
1061
+ {
1062
+ "used_percent": lowered.get("x-codex-secondary-used-percent"),
1063
+ "window_minutes": lowered.get("x-codex-secondary-window-minutes"),
1064
+ "reset_at": lowered.get("x-codex-secondary-resets-at")
1065
+ or lowered.get("x-codex-secondary-reset-at"),
1066
+ }
1067
+ )
1068
+ return {
1069
+ "primary": primary,
1070
+ "secondary": secondary,
1071
+ "plan_type": _string(lowered.get("x-codex-plan-type")),
1072
+ }
1073
+
1074
+
1075
+def _normalize_code_review_usage(value: Any) -> dict[str, Any] | None:
1076
+ data = _record(value)
1077
+ if not data:
1078
+ return None
1079
+ window = (
1080
+ _normalize_usage_window(data.get("primary_window"))
1081
+ or _normalize_usage_window(data.get("primary"))
1082
+ or _normalize_usage_window(data)
1083
+ )
1084
+ if window:
1085
+ window["name"] = _string(data.get("name")) or "Code review"
1086
+ return window
1087
+
1088
+
1089
+def _normalize_additional_rate_limits(value: Any) -> list[dict[str, Any]]:
1090
+ if not isinstance(value, list):
1091
+ return []
1092
+ result: list[dict[str, Any]] = []
1093
+ for item in value:
1094
+ data = _record(item)
1095
+ if not data:
1096
+ continue
1097
+ window = (
1098
+ _normalize_usage_window(data.get("primary_window"))
1099
+ or _normalize_usage_window(data.get("primary"))
1100
+ or _normalize_usage_window(data)
1101
+ )
1102
+ if not window:
1103
+ continue
1104
+ name = (
1105
+ _string(data.get("name"))
1106
+ or _string(data.get("model"))
1107
+ or _string(data.get("limit_name"))
1108
+ or _string(data.get("limitName"))
1109
+ or _string(data.get("id"))
1110
+ )
1111
+ if name:
1112
+ window["name"] = name
1113
+ result.append(window)
1114
+ return result
1115
+
1116
+
1117
+def _normalize_usage_window(value: Any) -> dict[str, Any] | None:
1118
+ data = _record(value)
1119
+ used_percent = _number(
1120
+ _first_present(
1121
+ data.get("used_percent"),
1122
+ data.get("usedPercent"),
1123
+ data.get("utilization"),
1124
+ data.get("usage_percent"),
1125
+ )
1126
+ )
1127
+ if used_percent is None:
1128
+ return None
1129
+
1130
+ window_seconds = _number(
1131
+ _first_present(
1132
+ data.get("limit_window_seconds"),
1133
+ data.get("window_seconds"),
1134
+ data.get("windowSeconds"),
1135
+ data.get("windowDurationSeconds"),
1136
+ )
1137
+ )
1138
+ window_minutes = _number(
1139
+ _first_present(
1140
+ data.get("window_minutes"),
1141
+ data.get("windowMinutes"),
1142
+ data.get("windowDurationMins"),
1143
+ )
1144
+ )
1145
+ if window_seconds is None and window_minutes is not None:
1146
+ window_seconds = window_minutes * 60
1147
+ if window_minutes is None and window_seconds is not None:
1148
+ window_minutes = window_seconds / 60
1149
+
1150
+ reset_at = _epoch_seconds(
1151
+ _first_present(
1152
+ data.get("reset_at"),
1153
+ data.get("resets_at"),
1154
+ data.get("resetsAt"),
1155
+ data.get("resetAt"),
1156
+ )
1157
+ )
1158
+ used = max(0.0, min(100.0, used_percent))
1159
+ return {
1160
+ "used_percent": _clean_number(used),
1161
+ "remaining_percent": _clean_number(max(0.0, 100.0 - used)),
1162
+ "reset_at": reset_at,
1163
+ "resets_at_iso": _epoch_iso(reset_at),
1164
+ "window_seconds": _clean_number(window_seconds) if window_seconds is not None else None,
1165
+ "window_minutes": _clean_number(window_minutes) if window_minutes is not None else None,
1166
+ "label": _usage_window_label(window_seconds, window_minutes),
1167
+ }
1168
+
1169
+
1170
+def _normalize_credits(value: Any) -> dict[str, Any] | None:
1171
+ data = _record(value)
1172
+ if not data:
1173
+ return None
1174
+ balance = _number(data.get("balance"))
1175
+ return {
1176
+ "has_credits": bool(data.get("has_credits") or data.get("hasCredits")),
1177
+ "unlimited": bool(data.get("unlimited")),
1178
+ "balance": _clean_number(balance) if balance is not None else None,
1179
+ }
1180
+
1181
+
1182
+def _usage_window_label(seconds: float | None, minutes: float | None) -> str:
1183
+ if seconds is None and minutes is not None:
1184
+ seconds = minutes * 60
1185
+ if seconds is None:
1186
+ return ""
1187
+ if 17_940 <= seconds <= 18_060:
1188
+ return "5h"
1189
+ if 604_000 <= seconds <= 605_000:
1190
+ return "7d"
1191
+ if seconds >= 86_400 and seconds % 86_400 == 0:
1192
+ return f"{int(seconds // 86_400)}d"
1193
+ if seconds >= 3_600 and seconds % 3_600 == 0:
1194
+ return f"{int(seconds // 3_600)}h"
1195
+ if seconds >= 60 and seconds % 60 == 0:
1196
+ return f"{int(seconds // 60)}m"
1197
+ return ""
1198
+
1199
+
1200
+def _number(value: Any) -> float | None:
1201
+ if isinstance(value, bool) or value is None:
1202
+ return None
1203
+ if isinstance(value, (int, float)):
1204
+ return float(value)
1205
+ if isinstance(value, str):
1206
+ text = value.strip().rstrip("%")
1207
+ if not text:
1208
+ return None
1209
+ try:
1210
+ return float(text)
1211
+ except ValueError:
1212
+ return None
1213
+ return None
1214
+
1215
+
1216
+def _first_present(*values: Any) -> Any:
1217
+ for value in values:
1218
+ if value is None:
1219
+ continue
1220
+ if isinstance(value, str) and value == "":
1221
+ continue
1222
+ return value
1223
+ return None
1224
+
1225
+
1226
+def _epoch_seconds(value: Any) -> float | None:
1227
+ number = _number(value)
1228
+ if number is None or number <= 0:
1229
+ return None
1230
+ if number > 1_000_000_000_000:
1231
+ number = number / 1000
1232
+ return number
1233
+
1234
+
1235
+def _epoch_iso(value: float | None) -> str:
1236
+ if value is None:
1237
+ return ""
1238
+ try:
1239
+ return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
1240
+ except (OSError, ValueError):
1241
+ return ""
1242
+
1243
+
1244
+def _clean_number(value: float | None) -> int | float | None:
1245
+ if value is None:
1246
+ return None
1247
+ if float(value).is_integer():
1248
+ return int(value)
1249
+ return round(float(value), 2)
plugins/_oauth/webui/config.html
+110
@@ -54,6 +54,16 @@
54
<span class="material-symbols-outlined" x-text="$store.oauthConfig.loadingModels ? 'progress_activity' : 'view_list'"></span>
55
<span>Check Models</span>
56
</button>
57
+ <button
58
+ class="oauth-connect danger"
59
+ type="button"
60
+ @click="$store.oauthConfig.disconnectCodex()"
61
+ :disabled="$store.oauthConfig.disconnecting"
62
+ x-show="$store.oauthConfig.connected()"
63
+ >
64
+ <span class="material-symbols-outlined" x-text="$store.oauthConfig.disconnecting ? 'progress_activity' : 'link_off'"></span>
65
+ <span x-text="$store.oauthConfig.disconnecting ? 'Disconnecting' : 'Disconnect'"></span>
66
+ </button>
67
</div>
68
</section>
69
@@ -66,6 +76,24 @@
76
</button>
77
</section>
78
79
+ <section class="oauth-usage" x-show="$store.oauthConfig.connected() && $store.oauthConfig.usageWindows().length">
80
+ <template x-for="window in $store.oauthConfig.usageWindows()" :key="window.key">
81
+ <div class="oauth-usage-window">
82
+ <div class="oauth-usage-head">
83
+ <span>
84
+ <span x-text="window.title"></span>
85
+ <small x-show="$store.oauthConfig.formatWindowLabel(window)" x-text="$store.oauthConfig.formatWindowLabel(window)"></small>
86
+ </span>
87
+ <strong x-text="$store.oauthConfig.formatRemainingPercent(window)"></strong>
88
+ </div>
89
+ <div class="oauth-usage-bar" aria-hidden="true">
90
+ <i :style="{ width: $store.oauthConfig.usageWidth(window) }"></i>
91
+ </div>
92
+ <p x-show="$store.oauthConfig.formatReset(window)" x-text="`Resets in ${$store.oauthConfig.formatReset(window)}`"></p>
93
+ </div>
94
+ </template>
95
+ </section>
96
+
97
<section class="oauth-status-row">
98
<div>
99
<span>Status</span>
@@ -190,6 +218,13 @@
218
line-height: 1.4;
219
}
220
221
+ .oauth-primary {
222
+ display: flex;
223
+ flex-wrap: wrap;
224
+ justify-content: flex-end;
225
+ gap: 8px;
226
+ }
227
+
228
.oauth-connect {
229
display: inline-flex;
230
align-items: center;
@@ -213,6 +248,12 @@
248
color: var(--color-text);
249
}
250
251
+ .oauth-connect.danger {
252
+ border: 1px solid color-mix(in srgb, #f06464 40%, var(--color-border));
253
+ background: color-mix(in srgb, #f06464 16%, var(--color-panel));
254
+ color: var(--color-text);
255
+ }
256
+
257
.oauth-connect:disabled {
258
cursor: default;
259
opacity: .65;
@@ -240,6 +281,74 @@
281
letter-spacing: 0;
282
}
283
284
+ .oauth-usage {
285
+ display: grid;
286
+ grid-template-columns: repeat(2, minmax(0, 1fr));
287
+ gap: 10px;
288
+ padding: 12px 14px;
289
+ border: 1px solid var(--color-border);
290
+ border-radius: 8px;
291
+ }
292
+
293
+ .oauth-usage-window {
294
+ display: grid;
295
+ min-width: 0;
296
+ gap: 8px;
297
+ }
298
+
299
+ .oauth-usage-head {
300
+ display: flex;
301
+ align-items: center;
302
+ justify-content: space-between;
303
+ gap: 12px;
304
+ }
305
+
306
+ .oauth-usage-head span {
307
+ display: inline-flex;
308
+ min-width: 0;
309
+ align-items: center;
310
+ gap: 6px;
311
+ color: var(--color-text-secondary);
312
+ font-size: 0.78rem;
313
+ font-weight: 750;
314
+ }
315
+
316
+ .oauth-usage-head small {
317
+ padding: 2px 6px;
318
+ border-radius: 999px;
319
+ background: color-mix(in srgb, var(--color-border) 55%, transparent);
320
+ color: var(--color-text-secondary);
321
+ font-size: 0.7rem;
322
+ line-height: 1;
323
+ }
324
+
325
+ .oauth-usage-head strong {
326
+ font-size: 0.92rem;
327
+ white-space: nowrap;
328
+ }
329
+
330
+ .oauth-usage-bar {
331
+ overflow: hidden;
332
+ height: 8px;
333
+ border-radius: 999px;
334
+ background: color-mix(in srgb, var(--color-border) 54%, transparent);
335
+ }
336
+
337
+ .oauth-usage-bar i {
338
+ display: block;
339
+ width: 0;
340
+ height: 100%;
341
+ border-radius: inherit;
342
+ background: #35d07f;
343
+ transition: width .22s ease;
344
+ }
345
+
346
+ .oauth-usage-window p {
347
+ margin: 0;
348
+ color: var(--color-text-secondary);
349
+ font-size: 0.74rem;
350
+ }
351
+
352
.oauth-status-row {
353
display: grid;
354
grid-template-columns: repeat(2, minmax(0, 1fr)) auto;
@@ -372,6 +481,7 @@
481
@media (max-width: 720px) {
482
.oauth-hero,
483
.oauth-device,
484
+ .oauth-usage,
485
.oauth-status-row,
486
.oauth-grid,
487
.oauth-details div {
plugins/_oauth/webui/oauth-config-store.js
+72
@@ -10,6 +10,7 @@ const STATUS_API = "/plugins/_oauth/status";
10
const START_DEVICE_LOGIN_API = "/plugins/_oauth/start_device_login";
11
const POLL_DEVICE_LOGIN_API = "/plugins/_oauth/poll_device_login";
12
const MODELS_API = "/plugins/_oauth/models";
13
+const DISCONNECT_API = "/plugins/_oauth/disconnect";
14
const MAX_POLL_MS = 120000;
15
16
function ensureConfig(config) {
@@ -40,6 +41,7 @@ export const store = createStore("oauthConfig", {
41
status: null,
42
loadingStatus: false,
43
connecting: false,
44
+ disconnecting: false,
45
loadingModels: false,
46
models: [],
47
device: null,
@@ -79,6 +81,53 @@ export const store = createStore("oauthConfig", {
81
return this.connected() ? "Connected" : "Not connected";
82
},
83
84
+ usage() {
85
+ return this.status?.codex?.usage || null;
86
+ },
87
+
88
+ usageWindows() {
89
+ const usage = this.usage();
90
+ if (!usage?.available) return [];
91
+ return [
92
+ { key: "primary", title: "Session", ...(usage.primary || {}) },
93
+ { key: "secondary", title: "Week", ...(usage.secondary || {}) },
94
+ ].filter((window) => Number.isFinite(this.remainingPercent(window)));
95
+ },
96
+
97
+ usageWidth(window) {
98
+ const value = Math.max(0, Math.min(100, this.remainingPercent(window)));
99
+ return `${value}%`;
100
+ },
101
+
102
+ remainingPercent(window) {
103
+ const remaining = Number(window?.remaining_percent);
104
+ if (Number.isFinite(remaining)) return remaining;
105
+ const used = Number(window?.used_percent);
106
+ if (Number.isFinite(used)) return 100 - used;
107
+ return Number.NaN;
108
+ },
109
+
110
+ formatRemainingPercent(window) {
111
+ const number = this.remainingPercent(window);
112
+ if (!Number.isFinite(number)) return "0%";
113
+ return `${Math.round(number * 10) / 10}% left`;
114
+ },
115
+
116
+ formatWindowLabel(window) {
117
+ return window?.label || "";
118
+ },
119
+
120
+ formatReset(window) {
121
+ const seconds = Number(window?.reset_at || 0);
122
+ if (!Number.isFinite(seconds) || seconds <= 0) return "";
123
+ const remainingMs = Math.max(0, seconds * 1000 - Date.now());
124
+ const minutes = Math.round(remainingMs / 60000);
125
+ if (minutes < 60) return `${minutes}m`;
126
+ const hours = Math.round(minutes / 60);
127
+ if (hours < 48) return `${hours}h`;
128
+ return `${Math.round(hours / 24)}d`;
129
+ },
130
+
131
endpointUrl() {
132
const base = this.codex().proxy_base_path || "/oauth/codex";
133
return `${window.location.origin}${base}/v1`;
@@ -184,6 +233,29 @@ export const store = createStore("oauthConfig", {
233
}
234
},
235
236
+ async disconnectCodex() {
237
+ if (this.disconnecting || !this.connected()) return;
238
+ const confirmed = window.confirm("Disconnect this OpenAI account and remove stored OAuth tokens?");
239
+ if (!confirmed) return;
240
+
241
+ this.disconnecting = true;
242
+ try {
243
+ const response = await callJsonApi(DISCONNECT_API, {});
244
+ if (!response?.ok) throw new Error(response?.error || "Could not disconnect the account.");
245
+ this.status = response.codex ? { ok: true, codex: response.codex } : this.status;
246
+ this.models = [];
247
+ this.device = null;
248
+ this.connecting = false;
249
+ this.stopPolling();
250
+ void toastFrontendSuccess("OpenAI account disconnected.", "OAuth Connections");
251
+ await this.loadStatus();
252
+ } catch (error) {
253
+ void toastFrontendError(messageOf(error), "OAuth Connections");
254
+ } finally {
255
+ this.disconnecting = false;
256
+ }
257
+ },
258
+
259
cancelConnect() {
260
this.connecting = false;
261
this.device = null;
tests/test_oauth_codex.py
+86
@@ -132,6 +132,92 @@ def test_collect_completed_response_falls_back_to_text_deltas():
132
assert codex.collect_completed_response(FakeResponse()) == {"output": [], "output_text": "Hello"}
133
134
135
+def test_normalize_usage_payload_reads_codex_windows():
136
+ usage = codex.normalize_usage_payload(
137
+ {
138
+ "plan_type": "plus",
139
+ "rate_limit": {
140
+ "primary_window": {
141
+ "used_percent": 39,
142
+ "reset_at": 1_738_300_000,
143
+ "limit_window_seconds": 18_000,
144
+ },
145
+ "secondary_window": {
146
+ "used_percent": 15,
147
+ "reset_at": 1_738_900_000,
148
+ "limit_window_seconds": 604_800,
149
+ },
150
+ },
151
+ "credits": {"has_credits": True, "unlimited": False, "balance": 5.39},
152
+ }
153
+ )
154
+
155
+ assert usage["available"] is True
156
+ assert usage["plan_type"] == "plus"
157
+ assert usage["primary"]["used_percent"] == 39
158
+ assert usage["primary"]["remaining_percent"] == 61
159
+ assert usage["primary"]["label"] == "5h"
160
+ assert usage["secondary"]["used_percent"] == 15
161
+ assert usage["secondary"]["label"] == "7d"
162
+ assert usage["credits"]["balance"] == 5.39
163
+
164
+
165
+def test_normalize_usage_payload_accepts_zero_percent_headers():
166
+ usage = codex.normalize_usage_payload(
167
+ {},
168
+ {
169
+ "x-codex-primary-used-percent": "0",
170
+ "x-codex-primary-window-minutes": "300",
171
+ },
172
+ )
173
+
174
+ assert usage["available"] is True
175
+ assert usage["primary"]["used_percent"] == 0
176
+ assert usage["primary"]["remaining_percent"] == 100
177
+ assert usage["primary"]["label"] == "5h"
178
+
179
+
180
+def test_disconnect_auth_clears_chatgpt_tokens_and_preserves_api_key(tmp_path, monkeypatch):
181
+ private_auth = tmp_path / "private-auth.json"
182
+ shared_auth = tmp_path / "shared-auth.json"
183
+ private_auth.write_text(
184
+ json.dumps(
185
+ {
186
+ "auth_mode": "chatgpt",
187
+ "OPENAI_API_KEY": None,
188
+ "tokens": {
189
+ "access_token": "access",
190
+ "refresh_token": "refresh",
191
+ "id_token": "id",
192
+ "account_id": "account",
193
+ },
194
+ "last_refresh": "2026-01-01T00:00:00Z",
195
+ }
196
+ ),
197
+ encoding="utf-8",
198
+ )
199
+ shared_auth.write_text(
200
+ json.dumps(
201
+ {
202
+ "auth_mode": "chatgpt",
203
+ "OPENAI_API_KEY": "sk-keep",
204
+ "tokens": {"access_token": "access", "account_id": "account"},
205
+ "last_refresh": "2026-01-01T00:00:00Z",
206
+ }
207
+ ),
208
+ encoding="utf-8",
209
+ )
210
+ monkeypatch.setattr(codex, "resolve_auth_file_candidates", lambda: [private_auth, shared_auth])
211
+
212
+ result = codex.disconnect_auth()
213
+
214
+ assert result["disconnected"] is True
215
+ assert str(private_auth) in result["removed_auth_files"]
216
+ assert not private_auth.exists()
217
+ preserved = json.loads(shared_auth.read_text(encoding="utf-8"))
218
+ assert preserved == {"OPENAI_API_KEY": "sk-keep"}
219
+
220
+
221
def test_provider_config_uses_container_local_agent_zero_origin():
222
provider_path = Path(__file__).resolve().parents[1] / "plugins/_oauth/conf/model_providers.yaml"
223
provider_config = yaml.safe_load(provider_path.read_text(encoding="utf-8"))