Allow OAuth model slots to switch providers
Add connected-account provider dropdowns for Main and Utility model slots in OAuth settings. Keep model search and selection scoped to each slot's chosen provider so multiple connected OAuth accounts can be used independently.
Alessandro committed
Jun 3, 2026 at 16:52 UTC
0202323ff5ee791d82b18959849b8b8a295b6746
5 files changed
+108
-24
plugins/_oauth/AGENTS.md
+1
@@ -25,6 +25,7 @@
25
- Add OAuth model providers in `_oauth/conf/model_providers.yaml`, not `_model_config/provider_metadata.yaml`.
26
- Provider cards and model slot actions must be driven by backend provider status. Do not reintroduce hardcoded frontend provider lists or fallback provider catalogs.
27
- OAuth account surfaces in settings, discovery, and onboarding must use the provider registry/status summary rather than Codex-only frontend state.
28
+- OAuth settings model slots must keep provider choice editable per slot, list only connected OAuth account providers, and persist the selected provider IDs into `chat_model.provider` and `utility_model.provider`.
29
- `helpers/providers/registry.py` is the source of truth for connectable OAuth providers.
30
- Usage-plan metadata belongs only to connectable providers. Do not add metadata-only subscription families for providers this plugin cannot connect.
31
- API handlers should remain provider-aware. Missing or blank `provider_id` defaults to Codex only for existing backward compatibility; falsey non-string IDs must not silently default.
plugins/_oauth/README.md
+2
@@ -6,6 +6,8 @@ Tokens in `auth.json` are password-equivalent credentials. Keep this plugin on t
6
7
The settings UI groups providers as account-backed connections. More than one account provider can be connected at the same time, and the Main/Utility model slots can choose models from any connected OAuth provider.
8
9
+Each model slot has its own provider selector. The selector lists connected OAuth accounts only, so Main and Utility can use different account-backed providers when more than one account is connected.
10
+
11
## Providers
12
13
### Codex/ChatGPT (`codex_oauth`)
plugins/_oauth/webui/config.html
+52
-7
@@ -221,7 +221,7 @@
221
<span class="oauth-model-icon material-symbols-outlined" x-text="slot.icon"></span>
222
<div class="oauth-model-title">
223
<strong x-text="slot.title"></strong>
224
- <span x-text="$store.oauthConfig.slotStatusLabel(slot.key)"></span>
224
+ <span x-show="$store.oauthConfig.slotStatusLabel(slot.key)" x-text="$store.oauthConfig.slotStatusLabel(slot.key)"></span>
225
</div>
226
<div class="oauth-model-actions">
227
<button
@@ -240,14 +240,28 @@
240
<p class="oauth-model-description" x-text="slot.description"></p>
241
242
<div class="oauth-model-picker" @click.outside="$store.oauthConfig.closeModelDropdown(slot.key)">
243
+ <label class="oauth-model-provider-field">
244
+ <span>Provider</span>
245
+ <select
246
+ :value="$store.oauthConfig.modelSlot(slot.key).provider"
247
+ @change="$store.oauthConfig.useProviderForSlot(slot.key, $event.target.value)"
248
+ :disabled="!$store.oauthConfig.connectedProviderCards().length"
249
+ >
250
+ <option value="" disabled>Choose connected provider</option>
251
+ <template x-for="provider in $store.oauthConfig.slotProviderChoices(slot.key)" :key="`${slot.key}-${provider.provider_id}`">
252
+ <option :value="provider.provider_id" x-text="$store.oauthConfig.modelProviderOptionLabel(provider)"></option>
253
+ </template>
254
+ </select>
255
+ </label>
256
+
257
<div class="oauth-model-input-row">
258
<input
259
type="text"
260
x-model="$store.oauthConfig.modelSlot(slot.key).name"
261
@input="$store.oauthConfig.markModelDirty(slot.key)"
262
@focus="$store.oauthConfig.openModelDropdown(slot.key)"
249
- :disabled="!$store.oauthConfig.slotUsesOauth(slot.key)"
250
- placeholder="Search or enter a provider model"
263
+ :disabled="!$store.oauthConfig.slotCanUseModels(slot.key)"
264
+ :placeholder="$store.oauthConfig.slotCanUseModels(slot.key) ? 'Search or enter a provider model' : 'Choose a connected provider first'"
265
/>
266
<button
267
class="oauth-model-search"
@@ -255,7 +269,7 @@
269
title="Check models"
270
aria-label="Check models"
271
@click="$store.oauthConfig.loadModels({ providerId: $store.oauthConfig.modelSlot(slot.key).provider, openDropdown: slot.key })"
258
- :disabled="!$store.oauthConfig.slotUsesOauth(slot.key) || !$store.oauthConfig.providerConnected($store.oauthConfig.modelSlot(slot.key).provider) || $store.oauthConfig.loadingModelsProvider === $store.oauthConfig.modelSlot(slot.key).provider"
272
+ :disabled="!$store.oauthConfig.slotCanUseModels(slot.key) || $store.oauthConfig.loadingModelsProvider === $store.oauthConfig.modelSlot(slot.key).provider"
273
>
274
<span class="material-symbols-outlined" x-text="$store.oauthConfig.loadingModelsProvider === $store.oauthConfig.modelSlot(slot.key).provider ? 'progress_activity' : 'search'"></span>
275
</button>
@@ -285,15 +299,15 @@
299
</template>
300
</section>
301
288
- <section class="oauth-models-panel" x-show="$store.oauthConfig.models.length">
302
+ <section class="oauth-models-panel" x-show="$store.oauthConfig.activeProviderModels().length">
303
<div class="oauth-section-head">
304
<div>
305
<h3>Available models</h3>
292
- <p>Available models from selected provider</p>
306
+ <p x-text="$store.oauthConfig.activeModelsDescription()"></p>
307
</div>
308
</div>
309
<div class="oauth-models">
296
- <template x-for="model in $store.oauthConfig.models" :key="model">
310
+ <template x-for="model in $store.oauthConfig.activeProviderModels()" :key="model">
311
<span x-text="model"></span>
312
</template>
313
</div>
@@ -932,7 +946,38 @@
946
947
.oauth-model-picker {
948
position: relative;
949
+ display: grid;
950
+ gap: 8px;
951
+ min-width: 0;
952
+ }
953
+
954
+ .oauth-model-provider-field {
955
+ display: grid;
956
min-width: 0;
957
+ gap: 6px;
958
+ }
959
+
960
+ .oauth-model-provider-field span {
961
+ color: var(--color-text-secondary);
962
+ font-size: 0.74rem;
963
+ font-weight: 750;
964
+ }
965
+
966
+ .oauth-model-provider-field select {
967
+ width: 100%;
968
+ min-width: 0;
969
+ min-height: 36px;
970
+ padding: 7px 10px;
971
+ border: 1px solid color-mix(in srgb, var(--color-border) 74%, transparent);
972
+ border-radius: 8px;
973
+ background: var(--color-input);
974
+ color: var(--color-text);
975
+ font: inherit;
976
+ font-size: 0.82rem;
977
+ }
978
+
979
+ .oauth-model-provider-field select:disabled {
980
+ opacity: .58;
981
}
982
983
.oauth-model-input-row {
plugins/_oauth/webui/oauth-config-store.js
+39
-14
@@ -504,19 +504,42 @@ export const store = createStore("oauthConfig", {
504
return found?.label || this.providerLabel(provider);
505
},
506
507
+ slotProviderChoices() {
508
+ return this.connectedProviderCards();
509
+ },
510
+
511
+ modelProviderOptionLabel(provider) {
512
+ return provider?.display_name || provider?.short_name || provider?.provider_id || "Connected account";
513
+ },
514
+
515
slotStatusLabel(key) {
516
const slot = this.modelSlot(key);
509
- if (this.slotUsesOauth(key)) return `Using ${this.providerLabel(slot.provider)}`;
517
+ if (this.slotUsesOauth(key)) return "";
518
return `Currently ${this.providerName(slot.provider)}`;
519
},
520
521
+ slotCanUseModels(key) {
522
+ const providerId = this.modelSlot(key).provider;
523
+ return this.isOauthProvider(providerId) && this.providerConnected(providerId);
524
+ },
525
+
526
+ activeProviderModels() {
527
+ if (!this.providerConnected(this.activeModelProvider)) return [];
528
+ return this.providerModels[this.activeModelProvider] || [];
529
+ },
530
+
531
+ activeModelsDescription() {
532
+ if (!this.providerConnected(this.activeModelProvider)) return "";
533
+ return `Available models from ${this.providerLabel(this.activeModelProvider)}`;
534
+ },
535
+
536
markModelDirty(key) {
537
this.modelConfigDirty = true;
538
this.modelSlotDirty = { ...this.modelSlotDirty, [key]: true };
539
},
540
541
useProviderForSlot(key, providerId) {
519
- if (!this.isOauthProvider(providerId)) return;
542
+ if (!this.providerConnected(providerId)) return;
543
const slot = this.modelSlot(key);
544
const previousProvider = slot.provider;
545
slot.provider = providerId;
@@ -526,7 +549,7 @@ export const store = createStore("oauthConfig", {
549
}
550
if (!slot.kwargs || typeof slot.kwargs !== "object") slot.kwargs = {};
551
this.activeModelProvider = providerId;
529
- this.models = this.providerModels[providerId] || [];
552
+ this.models = this.activeProviderModels();
553
this.markModelDirty(key);
554
if (this.models.length) {
555
this.openModelDropdown(key);
@@ -544,14 +567,15 @@ export const store = createStore("oauthConfig", {
567
utility.api_base = main.api_base || "";
568
utility.kwargs = clone(main.kwargs || {});
569
this.activeModelProvider = utility.provider || this.activeModelProvider;
570
+ this.models = this.activeProviderModels();
571
this.markModelDirty("utility_model");
572
},
573
574
openModelDropdown(key) {
551
- if (!this.slotUsesOauth(key)) return;
575
+ if (!this.slotCanUseModels(key)) return;
576
const providerId = this.modelSlot(key).provider;
577
this.activeModelProvider = providerId;
554
- this.models = this.providerModels[providerId] || [];
578
+ this.models = this.activeProviderModels();
579
this.modelDropdown[key] = { ...this.modelDropdown[key], open: true };
580
if (!this.models.length && !this.loadingModelsProvider && this.providerConnected(providerId)) {
581
void this.loadModels({ providerId, openDropdown: key, silent: true });
@@ -565,7 +589,7 @@ export const store = createStore("oauthConfig", {
589
filteredModels(key) {
590
const slot = this.modelSlot(key);
591
const query = String(slot.name || "").trim().toLowerCase();
568
- const models = this.providerModels[slot.provider] || this.models || [];
592
+ const models = this.providerModels[slot.provider] || [];
593
const filtered = query
594
? models.filter((model) => String(model).toLowerCase().includes(query))
595
: models;
@@ -574,13 +598,11 @@ export const store = createStore("oauthConfig", {
598
599
selectModel(key, model) {
600
const slot = this.modelSlot(key);
577
- const providerId = this.isOauthProvider(this.activeModelProvider)
578
- ? this.activeModelProvider
579
- : slot.provider;
580
- if (this.isOauthProvider(providerId)) {
581
- slot.provider = providerId;
582
- }
601
+ const providerId = slot.provider;
602
+ if (!this.providerConnected(providerId)) return;
603
slot.name = model;
604
+ this.activeModelProvider = providerId;
605
+ this.models = this.activeProviderModels();
606
this.markModelDirty(key);
607
this.closeModelDropdown(key);
608
},
@@ -638,8 +660,11 @@ export const store = createStore("oauthConfig", {
660
ui.quota_project_id = card.quota_project_id;
661
}
662
}
641
- if (!this.isOauthProvider(this.activeModelProvider)) {
642
- this.activeModelProvider = this.providerCards()[0]?.provider_id || CODEX_PROVIDER;
663
+ if (!this.providerConnected(this.activeModelProvider)) {
664
+ this.activeModelProvider = this.connectedProviderCards()[0]?.provider_id
665
+ || this.providerCards()[0]?.provider_id
666
+ || CODEX_PROVIDER;
667
+ this.models = this.activeProviderModels();
668
}
669
if (!this.isOauthProvider(this.selectedProviderId)) {
670
this.selectedProviderId = this.connectedProviderCards()[0]?.provider_id
tests/test_oauth_static.py
+14
-3
@@ -19,7 +19,11 @@ def test_oauth_settings_exposes_provider_cards_and_model_slots():
19
assert "provider_map" in store_js
20
assert "<h3>Accounts</h3>" not in config_html
21
assert "oauth-plan-catalog" not in config_html
22
- assert "oauth-model-provider-choice" not in config_html
22
+ assert "oauth-model-provider-field" in config_html
23
+ assert "slotProviderChoices(slot.key)" in config_html
24
+ assert "connectedProviderCards().length" in config_html
25
+ assert "useProviderForSlot(slot.key, $event.target.value)" in config_html
26
+ assert "slotCanUseModels(slot.key)" in config_html
27
assert "<span>Account</span>" not in config_html
28
assert "oauth-connected-panel" not in config_html
29
assert "oauth-provider-usage" in config_html
@@ -53,7 +57,8 @@ def test_oauth_settings_exposes_provider_specific_controls_and_generic_copy():
57
assert "oauth-auth-attempt" in config_html
58
assert "oauth-detail-metrics" not in config_html
59
assert "Codex/ChatGPT Account" not in config_html + store_js
56
- assert "Available models from selected provider" in config_html
60
+ assert "activeModelsDescription()" in config_html + store_js
61
+ assert "activeProviderModels()" in config_html + store_js
62
assert "Available models from Codex account" not in config_html
63
assert "disconnectProvider(card.provider_id)" in config_html
64
assert "disconnectProvider($store.oauthConfig.selectedProviderId)" not in config_html
@@ -78,7 +83,7 @@ def test_oauth_connect_buttons_disable_during_any_provider_connection():
83
def test_oauth_available_models_list_sits_above_advanced_without_borders():
84
config_html = (PROJECT_ROOT / "plugins/_oauth/webui/config.html").read_text(encoding="utf-8")
85
81
- assert "Available models from selected provider" in config_html
86
+ assert "activeModelsDescription()" in config_html
87
assert config_html.index("<h3>Providers</h3>") < config_html.index("Choose your models")
88
assert config_html.index("<h3>Providers</h3>") < config_html.index("<summary>Advanced</summary>")
89
assert config_html.index("Available models") < config_html.index("<summary>Advanced</summary>")
@@ -99,6 +104,12 @@ def test_oauth_model_slots_reuse_model_config_api():
104
assert "isOauthProvider" in store_js
105
assert "providerCards()" in store_js
106
assert "saveModelConfigIfDirty" in store_js
107
+ assert "slotProviderChoices()" in store_js
108
+ assert "modelProviderOptionLabel(provider)" in store_js
109
+ assert "return this.connectedProviderCards();" in store_js
110
+ assert "if (!this.providerConnected(providerId)) return;" in store_js
111
+ assert "const providerId = slot.provider;" in store_js
112
+ assert "const providerId = this.isOauthProvider(this.activeModelProvider)" not in store_js
113
114
115
def test_browser_callback_completion_is_observed_from_modal():