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"))