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