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():