Status
@@ -190,6 +218,13 @@
line-height: 1.4;
}
+ .oauth-primary {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 8px;
+ }
+
.oauth-connect {
display: inline-flex;
align-items: center;
@@ -213,6 +248,12 @@
color: var(--color-text);
}
+ .oauth-connect.danger {
+ border: 1px solid color-mix(in srgb, #f06464 40%, var(--color-border));
+ background: color-mix(in srgb, #f06464 16%, var(--color-panel));
+ color: var(--color-text);
+ }
+
.oauth-connect:disabled {
cursor: default;
opacity: .65;
@@ -240,6 +281,74 @@
letter-spacing: 0;
}
+ .oauth-usage {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ padding: 12px 14px;
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ }
+
+ .oauth-usage-window {
+ display: grid;
+ min-width: 0;
+ gap: 8px;
+ }
+
+ .oauth-usage-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ }
+
+ .oauth-usage-head span {
+ display: inline-flex;
+ min-width: 0;
+ align-items: center;
+ gap: 6px;
+ color: var(--color-text-secondary);
+ font-size: 0.78rem;
+ font-weight: 750;
+ }
+
+ .oauth-usage-head small {
+ padding: 2px 6px;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--color-border) 55%, transparent);
+ color: var(--color-text-secondary);
+ font-size: 0.7rem;
+ line-height: 1;
+ }
+
+ .oauth-usage-head strong {
+ font-size: 0.92rem;
+ white-space: nowrap;
+ }
+
+ .oauth-usage-bar {
+ overflow: hidden;
+ height: 8px;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--color-border) 54%, transparent);
+ }
+
+ .oauth-usage-bar i {
+ display: block;
+ width: 0;
+ height: 100%;
+ border-radius: inherit;
+ background: #35d07f;
+ transition: width .22s ease;
+ }
+
+ .oauth-usage-window p {
+ margin: 0;
+ color: var(--color-text-secondary);
+ font-size: 0.74rem;
+ }
+
.oauth-status-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)) auto;
@@ -372,6 +481,7 @@
@media (max-width: 720px) {
.oauth-hero,
.oauth-device,
+ .oauth-usage,
.oauth-status-row,
.oauth-grid,
.oauth-details div {
diff --git a/plugins/_oauth/webui/oauth-config-store.js b/plugins/_oauth/webui/oauth-config-store.js
index 2d3d1bcca..2e5fb0fc5 100644
--- a/plugins/_oauth/webui/oauth-config-store.js
+++ b/plugins/_oauth/webui/oauth-config-store.js
@@ -10,6 +10,7 @@ const STATUS_API = "/plugins/_oauth/status";
const START_DEVICE_LOGIN_API = "/plugins/_oauth/start_device_login";
const POLL_DEVICE_LOGIN_API = "/plugins/_oauth/poll_device_login";
const MODELS_API = "/plugins/_oauth/models";
+const DISCONNECT_API = "/plugins/_oauth/disconnect";
const MAX_POLL_MS = 120000;
function ensureConfig(config) {
@@ -40,6 +41,7 @@ export const store = createStore("oauthConfig", {
status: null,
loadingStatus: false,
connecting: false,
+ disconnecting: false,
loadingModels: false,
models: [],
device: null,
@@ -79,6 +81,53 @@ export const store = createStore("oauthConfig", {
return this.connected() ? "Connected" : "Not connected";
},
+ usage() {
+ return this.status?.codex?.usage || null;
+ },
+
+ usageWindows() {
+ const usage = this.usage();
+ if (!usage?.available) return [];
+ return [
+ { key: "primary", title: "Session", ...(usage.primary || {}) },
+ { key: "secondary", title: "Week", ...(usage.secondary || {}) },
+ ].filter((window) => Number.isFinite(this.remainingPercent(window)));
+ },
+
+ usageWidth(window) {
+ const value = Math.max(0, Math.min(100, this.remainingPercent(window)));
+ return `${value}%`;
+ },
+
+ remainingPercent(window) {
+ const remaining = Number(window?.remaining_percent);
+ if (Number.isFinite(remaining)) return remaining;
+ const used = Number(window?.used_percent);
+ if (Number.isFinite(used)) return 100 - used;
+ return Number.NaN;
+ },
+
+ formatRemainingPercent(window) {
+ const number = this.remainingPercent(window);
+ if (!Number.isFinite(number)) return "0%";
+ return `${Math.round(number * 10) / 10}% left`;
+ },
+
+ formatWindowLabel(window) {
+ return window?.label || "";
+ },
+
+ formatReset(window) {
+ const seconds = Number(window?.reset_at || 0);
+ if (!Number.isFinite(seconds) || seconds <= 0) return "";
+ const remainingMs = Math.max(0, seconds * 1000 - Date.now());
+ const minutes = Math.round(remainingMs / 60000);
+ if (minutes < 60) return `${minutes}m`;
+ const hours = Math.round(minutes / 60);
+ if (hours < 48) return `${hours}h`;
+ return `${Math.round(hours / 24)}d`;
+ },
+
endpointUrl() {
const base = this.codex().proxy_base_path || "/oauth/codex";
return `${window.location.origin}${base}/v1`;
@@ -184,6 +233,29 @@ export const store = createStore("oauthConfig", {
}
},
+ async disconnectCodex() {
+ if (this.disconnecting || !this.connected()) return;
+ const confirmed = window.confirm("Disconnect this OpenAI account and remove stored OAuth tokens?");
+ if (!confirmed) return;
+
+ this.disconnecting = true;
+ try {
+ const response = await callJsonApi(DISCONNECT_API, {});
+ if (!response?.ok) throw new Error(response?.error || "Could not disconnect the account.");
+ this.status = response.codex ? { ok: true, codex: response.codex } : this.status;
+ this.models = [];
+ this.device = null;
+ this.connecting = false;
+ this.stopPolling();
+ void toastFrontendSuccess("OpenAI account disconnected.", "OAuth Connections");
+ await this.loadStatus();
+ } catch (error) {
+ void toastFrontendError(messageOf(error), "OAuth Connections");
+ } finally {
+ this.disconnecting = false;
+ }
+ },
+
cancelConnect() {
this.connecting = false;
this.device = null;
diff --git a/tests/test_oauth_codex.py b/tests/test_oauth_codex.py
index 1730dd3fd..7219360d3 100644
--- a/tests/test_oauth_codex.py
+++ b/tests/test_oauth_codex.py
@@ -132,6 +132,92 @@ def test_collect_completed_response_falls_back_to_text_deltas():
assert codex.collect_completed_response(FakeResponse()) == {"output": [], "output_text": "Hello"}
+def test_normalize_usage_payload_reads_codex_windows():
+ usage = codex.normalize_usage_payload(
+ {
+ "plan_type": "plus",
+ "rate_limit": {
+ "primary_window": {
+ "used_percent": 39,
+ "reset_at": 1_738_300_000,
+ "limit_window_seconds": 18_000,
+ },
+ "secondary_window": {
+ "used_percent": 15,
+ "reset_at": 1_738_900_000,
+ "limit_window_seconds": 604_800,
+ },
+ },
+ "credits": {"has_credits": True, "unlimited": False, "balance": 5.39},
+ }
+ )
+
+ assert usage["available"] is True
+ assert usage["plan_type"] == "plus"
+ assert usage["primary"]["used_percent"] == 39
+ assert usage["primary"]["remaining_percent"] == 61
+ assert usage["primary"]["label"] == "5h"
+ assert usage["secondary"]["used_percent"] == 15
+ assert usage["secondary"]["label"] == "7d"
+ assert usage["credits"]["balance"] == 5.39
+
+
+def test_normalize_usage_payload_accepts_zero_percent_headers():
+ usage = codex.normalize_usage_payload(
+ {},
+ {
+ "x-codex-primary-used-percent": "0",
+ "x-codex-primary-window-minutes": "300",
+ },
+ )
+
+ assert usage["available"] is True
+ assert usage["primary"]["used_percent"] == 0
+ assert usage["primary"]["remaining_percent"] == 100
+ assert usage["primary"]["label"] == "5h"
+
+
+def test_disconnect_auth_clears_chatgpt_tokens_and_preserves_api_key(tmp_path, monkeypatch):
+ private_auth = tmp_path / "private-auth.json"
+ shared_auth = tmp_path / "shared-auth.json"
+ private_auth.write_text(
+ json.dumps(
+ {
+ "auth_mode": "chatgpt",
+ "OPENAI_API_KEY": None,
+ "tokens": {
+ "access_token": "access",
+ "refresh_token": "refresh",
+ "id_token": "id",
+ "account_id": "account",
+ },
+ "last_refresh": "2026-01-01T00:00:00Z",
+ }
+ ),
+ encoding="utf-8",
+ )
+ shared_auth.write_text(
+ json.dumps(
+ {
+ "auth_mode": "chatgpt",
+ "OPENAI_API_KEY": "sk-keep",
+ "tokens": {"access_token": "access", "account_id": "account"},
+ "last_refresh": "2026-01-01T00:00:00Z",
+ }
+ ),
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(codex, "resolve_auth_file_candidates", lambda: [private_auth, shared_auth])
+
+ result = codex.disconnect_auth()
+
+ assert result["disconnected"] is True
+ assert str(private_auth) in result["removed_auth_files"]
+ assert not private_auth.exists()
+ preserved = json.loads(shared_auth.read_text(encoding="utf-8"))
+ assert preserved == {"OPENAI_API_KEY": "sk-keep"}
+
+
def test_provider_config_uses_container_local_agent_zero_origin():
provider_path = Path(__file__).resolve().parents[1] / "plugins/_oauth/conf/model_providers.yaml"
provider_config = yaml.safe_load(provider_path.read_text(encoding="utf-8"))