Improve Codex OAuth model setup UI

Add Main and Utility Codex model selectors to the OAuth plugin config and persist them through the existing model config API. Clean up the OAuth config layout by removing the redundant Check Models action, moving the available model list above Advanced, softening borders, and removing repeated account labels. Show account quota usage bars on the welcome dashboard Codex card and add static coverage for the selector, model list, and quota UI.

Alessandro committed May 22, 2026 at 17:22 UTC bb48fad754fdf4c2532a2c38270e441f0e214ab7
6 files changed +820 -30
plugins/_discovery/extensions/python/banners/10_discovery_cards.py
+41 -6
@@ -5,13 +5,42 @@ from helpers import plugins
5 class DiscoveryCardsExtension(Extension):
6 """Injects discovery cards into the banners list."""
7
8 - def _codex_oauth_connected(self) -> bool:
8 + def _codex_oauth_status(self) -> dict:
9 try:
10 from plugins._oauth.helpers import codex
11
12 - return bool(codex.status().get("connected"))
12 + status = codex.status()
13 + return status if isinstance(status, dict) else {}
14 except Exception:
14 - return False
15 + return {}
16 +
17 + def _codex_oauth_usage_windows(self, status: dict) -> list[dict]:
18 + usage = status.get("usage") if isinstance(status, dict) else {}
19 + if not isinstance(usage, dict) or not usage.get("available"):
20 + return []
21 +
22 + windows: list[dict] = []
23 + for key, title in (("primary", "Session"), ("secondary", "Week")):
24 + window = usage.get(key)
25 + if not isinstance(window, dict):
26 + continue
27 + remaining = window.get("remaining_percent")
28 + used = window.get("used_percent")
29 + if remaining is None and used is not None:
30 + try:
31 + remaining = max(0, min(100, 100 - float(used)))
32 + except (TypeError, ValueError):
33 + remaining = None
34 + if remaining is None:
35 + continue
36 + windows.append({
37 + "key": key,
38 + "title": title,
39 + "label": window.get("label") or "",
40 + "remaining_percent": remaining,
41 + "reset_at": window.get("reset_at") or 0,
42 + })
43 + return windows
44
45 async def execute(self, banners: list = [], frontend_context: dict = {}, **kwargs):
46 # Optional logic: only show specific cards if plugins aren't already configured.
@@ -20,7 +49,8 @@ class DiscoveryCardsExtension(Extension):
49 telegram_config = plugins.get_plugin_config("_telegram_integration") or {}
50 email_config = plugins.get_plugin_config("_email_integration") or {}
51 whatsapp_config = plugins.get_plugin_config("_whatsapp_integration") or {}
23 - codex_oauth_connected = self._codex_oauth_connected()
52 + codex_oauth_status = self._codex_oauth_status()
53 + codex_oauth_connected = bool(codex_oauth_status.get("connected"))
54
55 # 1. Plugin Hub Hero
56 banners.append({
@@ -88,7 +118,7 @@ class DiscoveryCardsExtension(Extension):
118 })
119
120 # 5. Codex/ChatGPT OAuth
91 - banners.append({
121 + codex_oauth_card = {
122 "id": "discovery-codex-oauth",
123 "type": "hero",
124 "placement": "after-features",
@@ -103,4 +133,9 @@ class DiscoveryCardsExtension(Extension):
133 "dismissible": True,
134 "priority": 40,
135 "show_in_onboarding": True
106 - })
136 + }
137 + if codex_oauth_connected:
138 + usage_windows = self._codex_oauth_usage_windows(codex_oauth_status)
139 + if usage_windows:
140 + codex_oauth_card["usage_windows"] = usage_windows
141 + banners.append(codex_oauth_card)
plugins/_discovery/extensions/webui/welcome-actions-end/discovery-cards.html
+106
@@ -29,6 +29,23 @@
29 <div class="discovery-hero-content">
30 <h3 class="discovery-hero-title" x-text="card.title"></h3>
31 <p class="discovery-hero-desc" x-text="card.description"></p>
32 + <div class="discovery-usage" x-show="(card.usage_windows || []).length">
33 + <template x-for="window in card.usage_windows" :key="`${card.id}-${window.key}`">
34 + <div class="discovery-usage-window">
35 + <div class="discovery-usage-head">
36 + <span>
37 + <span x-text="window.title"></span>
38 + <small x-show="window.label" x-text="window.label"></small>
39 + </span>
40 + <strong x-text="$store.discoveryStore.formatRemainingPercent(window)"></strong>
41 + </div>
42 + <div class="discovery-usage-bar" aria-hidden="true">
43 + <i :style="{ width: $store.discoveryStore.usageWidth(window) }"></i>
44 + </div>
45 + <p x-show="$store.discoveryStore.formatReset(window)" x-text="`Resets in ${$store.discoveryStore.formatReset(window)}`"></p>
46 + </div>
47 + </template>
48 + </div>
49 <button class="btn btn-ok"
50 type="button"
51 @click.stop="$store.discoveryStore.executeCta(card.cta_action)">
@@ -108,6 +125,23 @@
125 <div class="discovery-hero-content">
126 <h3 class="discovery-hero-title" x-text="card.title"></h3>
127 <p class="discovery-hero-desc" x-text="card.description"></p>
128 + <div class="discovery-usage" x-show="(card.usage_windows || []).length">
129 + <template x-for="window in card.usage_windows" :key="`${card.id}-${window.key}`">
130 + <div class="discovery-usage-window">
131 + <div class="discovery-usage-head">
132 + <span>
133 + <span x-text="window.title"></span>
134 + <small x-show="window.label" x-text="window.label"></small>
135 + </span>
136 + <strong x-text="$store.discoveryStore.formatRemainingPercent(window)"></strong>
137 + </div>
138 + <div class="discovery-usage-bar" aria-hidden="true">
139 + <i :style="{ width: $store.discoveryStore.usageWidth(window) }"></i>
140 + </div>
141 + <p x-show="$store.discoveryStore.formatReset(window)" x-text="`Resets in ${$store.discoveryStore.formatReset(window)}`"></p>
142 + </div>
143 + </template>
144 + </div>
145 <button class="btn btn-ok"
146 type="button"
147 @click.stop="$store.discoveryStore.executeCta(card.cta_action)">
@@ -224,6 +258,74 @@
258 max-width: 38ch;
259 }
260
261 + .discovery-usage {
262 + display: grid;
263 + grid-template-columns: repeat(2, minmax(0, 1fr));
264 + gap: 0.6rem;
265 + max-width: 520px;
266 + margin: -0.15rem 0 1rem;
267 + }
268 +
269 + .discovery-usage-window {
270 + display: grid;
271 + min-width: 0;
272 + gap: 0.42rem;
273 + }
274 +
275 + .discovery-usage-head {
276 + display: flex;
277 + align-items: center;
278 + justify-content: space-between;
279 + gap: 0.65rem;
280 + }
281 +
282 + .discovery-usage-head span {
283 + display: inline-flex;
284 + min-width: 0;
285 + align-items: center;
286 + gap: 0.35rem;
287 + color: var(--color-secondary);
288 + font-size: 0.75rem;
289 + font-weight: 700;
290 + }
291 +
292 + .discovery-usage-head small {
293 + padding: 0.12rem 0.34rem;
294 + border-radius: 999px;
295 + background: color-mix(in srgb, var(--color-border) 56%, transparent);
296 + color: var(--color-secondary);
297 + font-size: 0.68rem;
298 + line-height: 1;
299 + }
300 +
301 + .discovery-usage-head strong {
302 + color: var(--color-text);
303 + font-size: 0.78rem;
304 + white-space: nowrap;
305 + }
306 +
307 + .discovery-usage-bar {
308 + overflow: hidden;
309 + height: 0.48rem;
310 + border-radius: 999px;
311 + background: color-mix(in srgb, var(--color-border) 54%, transparent);
312 + }
313 +
314 + .discovery-usage-bar i {
315 + display: block;
316 + width: 0;
317 + height: 100%;
318 + border-radius: inherit;
319 + background: #35d07f;
320 + transition: width 0.22s ease;
321 + }
322 +
323 + .discovery-usage-window p {
324 + margin: 0;
325 + color: var(--color-secondary);
326 + font-size: 0.7rem;
327 + }
328 +
329 .discovery-cta-link,
330 .discovery-undismiss-btn {
331 display: inline-flex;
@@ -506,6 +608,10 @@
608 font-size: 1rem;
609 }
610
611 + .discovery-usage {
612 + grid-template-columns: 1fr;
613 + }
614 +
615 .discovery-feature-card {
616 grid-template-columns: minmax(0, 1fr) auto;
617 padding-right: 3rem;
plugins/_discovery/webui/discovery-store.js
+30
@@ -98,6 +98,36 @@ const model = {
98 }
99 },
100
101 + usageWidth(window) {
102 + const value = Math.max(0, Math.min(100, this.remainingPercent(window)));
103 + return `${value}%`;
104 + },
105 +
106 + remainingPercent(window) {
107 + const remaining = Number(window?.remaining_percent);
108 + if (Number.isFinite(remaining)) return remaining;
109 + const used = Number(window?.used_percent);
110 + if (Number.isFinite(used)) return 100 - used;
111 + return Number.NaN;
112 + },
113 +
114 + formatRemainingPercent(window) {
115 + const number = this.remainingPercent(window);
116 + if (!Number.isFinite(number)) return "0%";
117 + return `${Math.round(number * 10) / 10}% left`;
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 // --- Helpers (Private-ish) ---
132
133 _getDismissedIds() {
plugins/_oauth/webui/config.html
+336 -18
@@ -11,7 +11,7 @@
11 <template x-if="$store.oauthConfig && config">
12 <div
13 class="oauth"
14 - x-init="$store.oauthConfig.init(config)"
14 + x-init="$store.oauthConfig.init(config, context)"
15 x-effect="$store.oauthConfig.bindConfig(config)"
16 x-destroy="$store.oauthConfig.cleanup()"
17 >
@@ -44,16 +44,6 @@
44 <span class="material-symbols-outlined" x-text="$store.oauthConfig.connecting ? 'progress_activity' : 'login'"></span>
45 <span x-text="$store.oauthConfig.connecting ? 'Waiting' : 'Connect'"></span>
46 </button>
47 - <button
48 - class="oauth-connect secondary"
49 - type="button"
50 - @click="$store.oauthConfig.loadModels()"
51 - :disabled="$store.oauthConfig.loadingModels"
52 - x-show="$store.oauthConfig.connected()"
53 - >
54 - <span class="material-symbols-outlined" x-text="$store.oauthConfig.loadingModels ? 'progress_activity' : 'view_list'"></span>
55 - <span>Check Models</span>
56 - </button>
47 <button
48 class="oauth-connect danger"
49 type="button"
@@ -108,6 +98,117 @@
98 </button>
99 </section>
100
101 + <section class="oauth-model-config">
102 + <div class="oauth-section-head">
103 + <div>
104 + <h3>Agent Zero models</h3>
105 + <p>Select the Codex/ChatGPT models used by the Main and Utility model slots.</p>
106 + </div>
107 + <span class="oauth-save-chip" x-show="$store.oauthConfig.modelConfigDirty">Pending changes</span>
108 + </div>
109 +
110 + <div class="oauth-model-loading" x-show="$store.oauthConfig.modelConfigLoading">
111 + Loading model configuration...
112 + </div>
113 +
114 + <template x-if="$store.oauthConfig.modelConfig">
115 + <div class="oauth-model-grid">
116 + <template x-for="slot in $store.oauthConfig.modelSlots" :key="slot.key">
117 + <div class="oauth-model-card">
118 + <div class="oauth-model-head">
119 + <span class="oauth-model-icon material-symbols-outlined" x-text="slot.icon"></span>
120 + <div class="oauth-model-title">
121 + <strong x-text="slot.title"></strong>
122 + <span
123 + x-show="!$store.oauthConfig.slotUsesCodex(slot.key)"
124 + x-text="$store.oauthConfig.slotStatusLabel(slot.key)"
125 + ></span>
126 + </div>
127 + <div class="oauth-model-actions">
128 + <button
129 + class="oauth-model-action"
130 + type="button"
131 + x-show="!$store.oauthConfig.slotUsesCodex(slot.key)"
132 + @click="$store.oauthConfig.useCodexForSlot(slot.key)"
133 + >
134 + <span class="material-symbols-outlined">swap_horiz</span>
135 + <span>Use Codex</span>
136 + </button>
137 + <button
138 + class="oauth-model-action icon"
139 + type="button"
140 + title="Copy Main model"
141 + aria-label="Copy Main model"
142 + x-show="slot.key === 'utility_model' && $store.oauthConfig.slotUsesCodex('chat_model')"
143 + @click="$store.oauthConfig.copyMainToUtility()"
144 + >
145 + <span class="material-symbols-outlined">content_copy</span>
146 + </button>
147 + </div>
148 + </div>
149 +
150 + <p class="oauth-model-description" x-text="slot.description"></p>
151 +
152 + <div class="oauth-model-picker" @click.outside="$store.oauthConfig.closeModelDropdown(slot.key)">
153 + <div class="oauth-model-input-row">
154 + <input
155 + type="text"
156 + x-model="$store.oauthConfig.modelSlot(slot.key).name"
157 + @input="$store.oauthConfig.markModelDirty(slot.key)"
158 + @focus="$store.oauthConfig.openModelDropdown(slot.key)"
159 + :disabled="!$store.oauthConfig.slotUsesCodex(slot.key)"
160 + placeholder="Search or enter a Codex model"
161 + />
162 + <button
163 + class="oauth-model-search"
164 + type="button"
165 + title="Search available Codex models"
166 + aria-label="Search available Codex models"
167 + @click="$store.oauthConfig.loadModels({ openDropdown: slot.key })"
168 + :disabled="!$store.oauthConfig.slotUsesCodex(slot.key) || !$store.oauthConfig.connected() || $store.oauthConfig.loadingModels"
169 + >
170 + <span class="material-symbols-outlined" x-text="$store.oauthConfig.loadingModels ? 'progress_activity' : 'search'"></span>
171 + </button>
172 + </div>
173 +
174 + <div
175 + class="oauth-model-dropdown"
176 + x-show="$store.oauthConfig.modelDropdown[slot.key]?.open && !$store.oauthConfig.loadingModels"
177 + x-transition.opacity
178 + >
179 + <template x-for="model in $store.oauthConfig.filteredModels(slot.key)" :key="slot.key + model">
180 + <button
181 + type="button"
182 + class="oauth-model-item"
183 + @click="$store.oauthConfig.selectModel(slot.key, model)"
184 + x-text="model"
185 + ></button>
186 + </template>
187 + <div class="oauth-model-item muted" x-show="$store.oauthConfig.filteredModels(slot.key).length === 0">
188 + No models found. You can still type the model name manually.
189 + </div>
190 + </div>
191 + </div>
192 + </div>
193 + </template>
194 + </div>
195 + </template>
196 + </section>
197 +
198 + <section class="oauth-models-panel" x-show="$store.oauthConfig.models.length">
199 + <div class="oauth-section-head">
200 + <div>
201 + <h3>Available models</h3>
202 + <p>Available models from Codex account</p>
203 + </div>
204 + </div>
205 + <div class="oauth-models">
206 + <template x-for="model in $store.oauthConfig.models" :key="model">
207 + <span x-text="model"></span>
208 + </template>
209 + </div>
210 + </section>
211 +
212 <details class="oauth-advanced">
213 <summary>Advanced</summary>
214
@@ -157,12 +258,6 @@
258 </label>
259 </div>
260 </details>
160 -
161 - <div class="oauth-models" x-show="$store.oauthConfig.models.length">
162 - <template x-for="model in $store.oauthConfig.models" :key="model">
163 - <span x-text="model"></span>
164 - </template>
165 - </div>
261 </div>
262 </template>
263 </div>
@@ -393,6 +488,219 @@
488 cursor: pointer;
489 }
490
491 + .oauth-model-config {
492 + display: grid;
493 + gap: 12px;
494 + padding: 0;
495 + border: 0;
496 + }
497 +
498 + .oauth-models-panel {
499 + display: grid;
500 + gap: 10px;
501 + padding: 0;
502 + border: 0;
503 + }
504 +
505 + .oauth-section-head {
506 + display: flex;
507 + align-items: flex-start;
508 + justify-content: space-between;
509 + gap: 12px;
510 + }
511 +
512 + .oauth-section-head h3 {
513 + margin: 0 0 4px;
514 + font-size: 1rem;
515 + letter-spacing: 0;
516 + }
517 +
518 + .oauth-section-head p,
519 + .oauth-model-description,
520 + .oauth-model-loading {
521 + margin: 0;
522 + color: var(--color-text-secondary);
523 + font-size: 0.8rem;
524 + line-height: 1.35;
525 + }
526 +
527 + .oauth-save-chip {
528 + flex: 0 0 auto;
529 + padding: 3px 8px;
530 + border: 1px solid color-mix(in srgb, #d9ad68 46%, var(--color-border));
531 + border-radius: 999px;
532 + color: #d9ad68;
533 + font-size: 0.72rem;
534 + font-weight: 750;
535 + }
536 +
537 + .oauth-model-grid {
538 + display: grid;
539 + grid-template-columns: repeat(2, minmax(0, 1fr));
540 + gap: 12px;
541 + }
542 +
543 + .oauth-model-card {
544 + display: grid;
545 + min-width: 0;
546 + gap: 10px;
547 + padding: 0;
548 + border: 0;
549 + background: transparent;
550 + }
551 +
552 + .oauth-model-head {
553 + display: grid;
554 + grid-template-columns: auto minmax(0, 1fr) auto;
555 + align-items: center;
556 + gap: 10px;
557 + }
558 +
559 + .oauth-model-icon {
560 + display: grid;
561 + width: 34px;
562 + height: 34px;
563 + place-items: center;
564 + border-radius: 8px;
565 + background: color-mix(in srgb, var(--color-border) 52%, transparent);
566 + color: var(--color-text);
567 + font-size: 19px;
568 + }
569 +
570 + .oauth-model-title {
571 + display: grid;
572 + min-width: 0;
573 + gap: 2px;
574 + }
575 +
576 + .oauth-model-title strong {
577 + overflow: hidden;
578 + font-size: 0.88rem;
579 + text-overflow: ellipsis;
580 + white-space: nowrap;
581 + }
582 +
583 + .oauth-model-title span {
584 + overflow: hidden;
585 + color: var(--color-text-secondary);
586 + font-size: 0.74rem;
587 + font-weight: 700;
588 + text-overflow: ellipsis;
589 + white-space: nowrap;
590 + }
591 +
592 + .oauth-model-actions {
593 + display: flex;
594 + align-items: center;
595 + gap: 6px;
596 + }
597 +
598 + .oauth-model-action,
599 + .oauth-model-search {
600 + display: inline-flex;
601 + align-items: center;
602 + justify-content: center;
603 + border: 1px solid color-mix(in srgb, var(--color-border) 76%, transparent);
604 + border-radius: 8px;
605 + background: transparent;
606 + color: var(--color-text);
607 + cursor: pointer;
608 + }
609 +
610 + .oauth-model-action {
611 + gap: 5px;
612 + min-height: 32px;
613 + padding: 0 9px;
614 + font-size: 0.76rem;
615 + font-weight: 750;
616 + white-space: nowrap;
617 + }
618 +
619 + .oauth-model-action.icon,
620 + .oauth-model-search {
621 + width: 34px;
622 + height: 34px;
623 + padding: 0;
624 + }
625 +
626 + .oauth-model-action .material-symbols-outlined,
627 + .oauth-model-search .material-symbols-outlined {
628 + font-size: 18px;
629 + }
630 +
631 + .oauth-model-action:disabled,
632 + .oauth-model-search:disabled {
633 + cursor: default;
634 + opacity: .45;
635 + }
636 +
637 + .oauth-model-picker {
638 + position: relative;
639 + min-width: 0;
640 + }
641 +
642 + .oauth-model-input-row {
643 + display: grid;
644 + grid-template-columns: minmax(0, 1fr) auto;
645 + gap: 8px;
646 + }
647 +
648 + .oauth-model-input-row input {
649 + width: 100%;
650 + min-width: 0;
651 + min-height: 36px;
652 + padding: 7px 10px;
653 + border: 1px solid color-mix(in srgb, var(--color-border) 74%, transparent);
654 + border-radius: 8px;
655 + background: var(--color-input);
656 + color: var(--color-text);
657 + font: inherit;
658 + font-size: 0.82rem;
659 + }
660 +
661 + .oauth-model-input-row input:disabled {
662 + opacity: .58;
663 + }
664 +
665 + .oauth-model-dropdown {
666 + position: absolute;
667 + z-index: 20;
668 + right: 42px;
669 + left: 0;
670 + overflow: auto;
671 + max-height: 220px;
672 + margin-top: 6px;
673 + border: 1px solid var(--color-border);
674 + border-radius: 8px;
675 + background: var(--color-panel);
676 + box-shadow: 0 12px 28px rgba(0, 0, 0, .24);
677 + }
678 +
679 + .oauth-model-item {
680 + display: block;
681 + width: 100%;
682 + min-height: 34px;
683 + padding: 8px 10px;
684 + border: 0;
685 + background: transparent;
686 + color: var(--color-text);
687 + font: inherit;
688 + font-size: 0.8rem;
689 + text-align: left;
690 + cursor: pointer;
691 + }
692 +
693 + .oauth-model-item:hover,
694 + .oauth-model-item:focus-visible {
695 + background: color-mix(in srgb, var(--color-border) 42%, transparent);
696 + outline: none;
697 + }
698 +
699 + .oauth-model-item.muted {
700 + color: var(--color-text-secondary);
701 + cursor: default;
702 + }
703 +
704 .oauth-advanced {
705 border: 1px solid var(--color-border);
706 border-radius: 8px;
@@ -472,8 +780,8 @@
780
781 .oauth-models span {
782 padding: 5px 8px;
475 - border: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
783 border-radius: 999px;
784 + background: color-mix(in srgb, var(--color-border) 34%, transparent);
785 color: var(--color-text-secondary);
786 font-size: 0.76rem;
787 }
@@ -483,15 +791,25 @@
791 .oauth-device,
792 .oauth-usage,
793 .oauth-status-row,
794 + .oauth-model-grid,
795 + .oauth-model-head,
796 .oauth-grid,
797 .oauth-details div {
798 grid-template-columns: 1fr;
799 }
800
801 + .oauth-section-head {
802 + flex-direction: column;
803 + }
804 +
805 .oauth-primary {
806 width: 100%;
807 }
808
809 + .oauth-model-actions {
810 + justify-content: flex-start;
811 + }
812 +
813 .oauth-connect {
814 width: 100%;
815 }
plugins/_oauth/webui/oauth-config-store.js
+233 -6
@@ -1,17 +1,34 @@
1 import { createStore } from "/js/AlpineStore.js";
2 -import { callJsonApi } from "/js/api.js";
2 +import { callJsonApi, fetchApi } from "/js/api.js";
3 +import { store as modelConfigStore } from "/plugins/_model_config/webui/model-config-store.js";
4 import {
5 toastFrontendError,
6 toastFrontendInfo,
7 toastFrontendSuccess,
8 } from "/components/notifications/notification-store.js";
9
10 +const MODEL_CONFIG_API = "/plugins/_model_config";
11 const STATUS_API = "/plugins/_oauth/status";
12 const START_DEVICE_LOGIN_API = "/plugins/_oauth/start_device_login";
13 const POLL_DEVICE_LOGIN_API = "/plugins/_oauth/poll_device_login";
14 const MODELS_API = "/plugins/_oauth/models";
15 const DISCONNECT_API = "/plugins/_oauth/disconnect";
16 const MAX_POLL_MS = 120000;
17 +const CODEX_PROVIDER = "codex_oauth";
18 +const MODEL_SLOTS = [
19 + {
20 + key: "chat_model",
21 + title: "Main model",
22 + description: "Primary model for chat, reasoning, and browser tasks.",
23 + icon: "forum",
24 + },
25 + {
26 + key: "utility_model",
27 + title: "Utility model",
28 + description: "Background model for summaries, memory, and prompt preparation.",
29 + icon: "manufacturing",
30 + },
31 +];
32
33 function ensureConfig(config) {
34 if (!config || typeof config !== "object") return null;
@@ -32,6 +49,30 @@ function ensureConfig(config) {
49 return config;
50 }
51
52 +function clone(value) {
53 + return JSON.parse(JSON.stringify(value || {}));
54 +}
55 +
56 +function ensureModelSlot(config, key) {
57 + if (!config[key] || typeof config[key] !== "object") config[key] = {};
58 + config[key] = {
59 + provider: "",
60 + name: "",
61 + api_base: "",
62 + ctx_length: key === "utility_model" ? 128000 : 200000,
63 + ctx_history: key === "chat_model" ? 0.7 : undefined,
64 + ctx_input: key === "utility_model" ? 0.7 : undefined,
65 + vision: key === "chat_model" ? true : undefined,
66 + max_embeds: key === "chat_model" ? 10 : undefined,
67 + rl_requests: 0,
68 + rl_input: 0,
69 + rl_output: 0,
70 + kwargs: {},
71 + ...config[key],
72 + };
73 + if (!config[key].kwargs || typeof config[key].kwargs !== "object") config[key].kwargs = {};
74 +}
75 +
76 function messageOf(error) {
77 return error instanceof Error ? error.message : String(error);
78 }
@@ -44,13 +85,27 @@ export const store = createStore("oauthConfig", {
85 disconnecting: false,
86 loadingModels: false,
87 models: [],
88 + modelSlots: MODEL_SLOTS,
89 + modelConfig: null,
90 + modelConfigLoading: false,
91 + modelConfigSaving: false,
92 + modelConfigDirty: false,
93 + modelSlotDirty: {
94 + chat_model: false,
95 + utility_model: false,
96 + },
97 + modelDropdown: {
98 + chat_model: { open: false },
99 + utility_model: { open: false },
100 + },
101 device: null,
102 pollTimer: null,
103 pollStartedAt: 0,
104
51 - async init(config) {
105 + async init(config, context = null) {
106 this.bindConfig(config);
53 - await this.loadStatus();
107 + this.installSettingsHooks(context);
108 + await Promise.all([this.loadStatus(), this.loadModelConfig()]);
109 },
110
111 cleanup() {
@@ -58,6 +113,15 @@ export const store = createStore("oauthConfig", {
113 this.config = null;
114 this.status = null;
115 this.models = [];
116 + this.modelConfig = null;
117 + this.modelConfigLoading = false;
118 + this.modelConfigSaving = false;
119 + this.modelConfigDirty = false;
120 + this.modelSlotDirty = { chat_model: false, utility_model: false };
121 + this.modelDropdown = {
122 + chat_model: { open: false },
123 + utility_model: { open: false },
124 + };
125 this.device = null;
126 },
127
@@ -138,6 +202,168 @@ export const store = createStore("oauthConfig", {
202 return `${window.location.origin}${path}`;
203 },
204
205 + installSettingsHooks(context) {
206 + if (!context || context.__oauthConfigHooksInstalled) return;
207 +
208 + const originalSave = context.save.bind(context);
209 + context.save = async () => {
210 + context.error = null;
211 + try {
212 + await this.saveModelConfigIfDirty();
213 + } catch (error) {
214 + context.error = messageOf(error) || "Failed to save model selection.";
215 + return;
216 + }
217 + await originalSave();
218 + };
219 +
220 + context.__oauthConfigHooksInstalled = true;
221 + },
222 +
223 + async loadModelConfig() {
224 + if (this.modelConfigLoading) return;
225 + this.modelConfigLoading = true;
226 + try {
227 + await modelConfigStore.ensureLoaded();
228 + const response = await fetchApi(`${MODEL_CONFIG_API}/model_config_get`, {
229 + method: "POST",
230 + headers: { "Content-Type": "application/json" },
231 + body: JSON.stringify({}),
232 + });
233 + const data = await response.json().catch(() => ({}));
234 + const modelConfig = data.config && typeof data.config === "object" ? data.config : {};
235 + ensureModelSlot(modelConfig, "chat_model");
236 + ensureModelSlot(modelConfig, "utility_model");
237 + this.modelConfig = modelConfig;
238 + this.modelConfigDirty = false;
239 + this.modelSlotDirty = { chat_model: false, utility_model: false };
240 + } catch (error) {
241 + this.modelConfig = null;
242 + void toastFrontendError(messageOf(error), "OAuth Connections");
243 + } finally {
244 + this.modelConfigLoading = false;
245 + }
246 + },
247 +
248 + modelSlot(key) {
249 + if (!this.modelConfig) return {};
250 + ensureModelSlot(this.modelConfig, key);
251 + return this.modelConfig[key];
252 + },
253 +
254 + slotUsesCodex(key) {
255 + return this.modelSlot(key).provider === CODEX_PROVIDER;
256 + },
257 +
258 + providerName(provider) {
259 + if (!provider) return "Not configured";
260 + const found = (modelConfigStore.chatProviders || []).find((item) => item.value === provider);
261 + return found?.label || provider;
262 + },
263 +
264 + slotStatusLabel(key) {
265 + const slot = this.modelSlot(key);
266 + if (slot.provider === CODEX_PROVIDER) return "";
267 + return `Currently ${this.providerName(slot.provider)}`;
268 + },
269 +
270 + markModelDirty(key) {
271 + this.modelConfigDirty = true;
272 + this.modelSlotDirty = { ...this.modelSlotDirty, [key]: true };
273 + },
274 +
275 + useCodexForSlot(key) {
276 + const slot = this.modelSlot(key);
277 + const previousProvider = slot.provider;
278 + slot.provider = CODEX_PROVIDER;
279 + slot.api_base = "";
280 + if (previousProvider && previousProvider !== CODEX_PROVIDER) {
281 + slot.name = "";
282 + }
283 + if (!slot.kwargs || typeof slot.kwargs !== "object") slot.kwargs = {};
284 + this.markModelDirty(key);
285 + if (this.models.length) {
286 + this.openModelDropdown(key);
287 + } else {
288 + void this.loadModels({ openDropdown: key, silent: true });
289 + }
290 + },
291 +
292 + copyMainToUtility() {
293 + if (!this.modelConfig) return;
294 + const main = this.modelSlot("chat_model");
295 + const utility = this.modelSlot("utility_model");
296 + utility.provider = CODEX_PROVIDER;
297 + utility.name = main.name || "";
298 + utility.api_base = main.api_base || "";
299 + utility.kwargs = clone(main.kwargs || {});
300 + this.markModelDirty("utility_model");
301 + },
302 +
303 + openModelDropdown(key) {
304 + if (!this.slotUsesCodex(key)) return;
305 + this.modelDropdown[key] = { ...this.modelDropdown[key], open: true };
306 + if (!this.models.length && !this.loadingModels) {
307 + void this.loadModels({ openDropdown: key, silent: true });
308 + }
309 + },
310 +
311 + closeModelDropdown(key) {
312 + this.modelDropdown[key] = { ...this.modelDropdown[key], open: false };
313 + },
314 +
315 + filteredModels(key) {
316 + const query = String(this.modelSlot(key).name || "").trim().toLowerCase();
317 + const models = this.models || [];
318 + const filtered = query
319 + ? models.filter((model) => String(model).toLowerCase().includes(query))
320 + : models;
321 + return filtered.slice(0, 80);
322 + },
323 +
324 + selectModel(key, model) {
325 + const slot = this.modelSlot(key);
326 + slot.provider = CODEX_PROVIDER;
327 + slot.name = model;
328 + this.markModelDirty(key);
329 + this.closeModelDropdown(key);
330 + },
331 +
332 + validateModelConfig() {
333 + if (!this.modelConfigDirty) return;
334 + for (const slot of MODEL_SLOTS) {
335 + if (!this.modelSlotDirty[slot.key]) continue;
336 + const model = this.modelSlot(slot.key);
337 + if (model.provider === CODEX_PROVIDER && !String(model.name || "").trim()) {
338 + throw new Error(`Choose a ${slot.title} before saving.`);
339 + }
340 + }
341 + },
342 +
343 + async saveModelConfigIfDirty() {
344 + if (!this.modelConfigDirty || !this.modelConfig) return;
345 + this.validateModelConfig();
346 + this.modelConfigSaving = true;
347 + try {
348 + const response = await fetchApi(`${MODEL_CONFIG_API}/model_config_set`, {
349 + method: "POST",
350 + headers: { "Content-Type": "application/json" },
351 + body: JSON.stringify({
352 + project_name: "",
353 + agent_profile: "",
354 + config: this.modelConfig,
355 + }),
356 + });
357 + const data = await response.json().catch(() => ({}));
358 + if (!data?.ok) throw new Error(data?.error || "Could not save model selection.");
359 + this.modelConfigDirty = false;
360 + this.modelSlotDirty = { chat_model: false, utility_model: false };
361 + await modelConfigStore.refreshModelsSummary?.();
362 + } finally {
363 + this.modelConfigSaving = false;
364 + }
365 + },
366 +
367 async loadStatus() {
368 if (this.loadingStatus) return;
369 this.loadingStatus = true;
@@ -217,17 +443,18 @@ export const store = createStore("oauthConfig", {
443 this.pollTimer = null;
444 },
445
220 - async loadModels() {
446 + async loadModels({ openDropdown = "", silent = false } = {}) {
447 if (this.loadingModels) return;
448 this.loadingModels = true;
449 try {
450 const response = await callJsonApi(MODELS_API, {});
451 if (!response?.ok) throw new Error(response?.error || "Could not load Codex models.");
452 this.models = Array.isArray(response.models) ? response.models : [];
227 - void toastFrontendSuccess("Codex models loaded.", "OAuth Connections");
453 + if (openDropdown) this.openModelDropdown(openDropdown);
454 + if (!silent) void toastFrontendSuccess("Codex models loaded.", "OAuth Connections");
455 } catch (error) {
456 this.models = [];
230 - void toastFrontendError(messageOf(error), "OAuth Connections");
457 + if (!silent) void toastFrontendError(messageOf(error), "OAuth Connections");
458 } finally {
459 this.loadingModels = false;
460 }
tests/test_oauth_static.py new
+74
@@ -0,0 +1,74 @@
1 +from pathlib import Path
2 +
3 +
4 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
5 +
6 +
7 +def test_oauth_settings_exposes_codex_model_slots():
8 + config_html = (PROJECT_ROOT / "plugins/_oauth/webui/config.html").read_text(encoding="utf-8")
9 + store_js = (PROJECT_ROOT / "plugins/_oauth/webui/oauth-config-store.js").read_text(encoding="utf-8")
10 +
11 + assert "Agent Zero models" in config_html
12 + assert "Main model" in store_js
13 + assert "Utility model" in store_js
14 + assert "Use Codex" in config_html
15 + assert "Search available Codex models" in config_html
16 + assert "copyMainToUtility" in config_html + store_js
17 +
18 +
19 +def test_oauth_settings_remove_redundant_model_action_and_account_label():
20 + config_html = (PROJECT_ROOT / "plugins/_oauth/webui/config.html").read_text(encoding="utf-8")
21 + store_js = (PROJECT_ROOT / "plugins/_oauth/webui/oauth-config-store.js").read_text(encoding="utf-8")
22 +
23 + assert "Check Models" not in config_html
24 + assert "Codex/ChatGPT Account" not in config_html + store_js
25 +
26 +
27 +def test_oauth_available_models_list_sits_above_advanced_without_borders():
28 + config_html = (PROJECT_ROOT / "plugins/_oauth/webui/config.html").read_text(encoding="utf-8")
29 +
30 + assert "Available models from Codex account" in config_html
31 + assert config_html.index("Available models") < config_html.index("<summary>Advanced</summary>")
32 + assert ".oauth-models-panel {\n display: grid;\n gap: 10px;\n padding: 0;\n border: 0;\n }" in config_html
33 + model_chip_rule = config_html.split(".oauth-models span {", 1)[1].split("}", 1)[0]
34 + assert "border:" not in model_chip_rule
35 +
36 +
37 +def test_oauth_model_slots_reuse_model_config_api():
38 + store_js = (PROJECT_ROOT / "plugins/_oauth/webui/oauth-config-store.js").read_text(encoding="utf-8")
39 +
40 + assert 'import { store as modelConfigStore } from "/plugins/_model_config/webui/model-config-store.js";' in store_js
41 + assert 'const MODEL_CONFIG_API = "/plugins/_model_config";' in store_js
42 + assert "model_config_get" in store_js
43 + assert "model_config_set" in store_js
44 + assert 'const CODEX_PROVIDER = "codex_oauth";' in store_js
45 + assert "saveModelConfigIfDirty" in store_js
46 +
47 +
48 +def test_oauth_model_wrappers_do_not_add_box_borders_or_lateral_padding():
49 + config_html = (PROJECT_ROOT / "plugins/_oauth/webui/config.html").read_text(encoding="utf-8")
50 +
51 + assert ".oauth-model-config {\n display: grid;\n gap: 12px;\n padding: 0;\n border: 0;\n }" in config_html
52 + assert ".oauth-model-card {\n display: grid;" in config_html
53 + assert " padding: 0;\n border: 0;\n background: transparent;" in config_html
54 + assert "oauth-model-card.is-codex" not in config_html
55 + assert "'is-codex'" not in config_html
56 +
57 +
58 +def test_connected_codex_welcome_card_renders_usage_limit_bars():
59 + discovery_cards = (
60 + PROJECT_ROOT
61 + / "plugins/_discovery/extensions/python/banners/10_discovery_cards.py"
62 + ).read_text(encoding="utf-8")
63 + welcome_cards = (
64 + PROJECT_ROOT
65 + / "plugins/_discovery/extensions/webui/welcome-actions-end/discovery-cards.html"
66 + ).read_text(encoding="utf-8")
67 + discovery_store = (PROJECT_ROOT / "plugins/_discovery/webui/discovery-store.js").read_text(encoding="utf-8")
68 +
69 + assert "ChatGPT/Codex Connected" in discovery_cards
70 + assert "5h and weekly limits are ready." not in discovery_cards
71 + assert "usage_windows" in discovery_cards
72 + assert "discovery-usage" in welcome_cards
73 + assert "discovery-usage-bar" in welcome_cards
74 + assert "formatRemainingPercent(window)" in welcome_cards + discovery_store