Polish OAuth model selection

Simplify provider cards and model-slot copy, align model pickers with the main model configuration UI, and infer a sole connected OAuth provider only when it does not silently replace a different saved provider.\n\nAdd focused static coverage for the updated picker and provider-default behavior.

Alessandro committed Jul 10, 2026 at 17:29 UTC 877d063d3c5f2f1ac468d982c924c9f8bb156537
7 files changed +139 -68
plugins/_model_config/AGENTS.md
+1
@@ -21,6 +21,7 @@
21 - Applying a model preset may inherit durable tuning such as context windows and rate limits, but must replace or clear per-slot `kwargs` so provider-specific extra params never leak across model providers.
22 - Repair provider-specific model-config aliases at the model-config read/build boundary; keep provider-specific repairs out of provider-agnostic core wrappers such as `models.py`.
23 - `modelConfig.createPresetEditor()` owns local preset drafts, row actions, and stable UI-only row keys so deletion or renaming cannot rebind nested model fields.
24 +- Model-name catalogs open below the input from either a field click or the embedded magnifier.
25
26 ## Work Guidance
27
plugins/_model_config/webui/model-field.html
+18 -6
@@ -41,22 +41,27 @@
41 <div class="field">
42 <div class="field-label">
43 <div class="field-title">Model name</div>
44 - <div class="field-description">Model identifier. Click the search icon to browse available models.</div>
44 + <div class="field-description">Model identifier. Click the field or search icon to browse available models.</div>
45 </div>
46 <div class="field-control" style="position:relative;"
47 x-data="{ results: [], open: false, searching: false,
48 - doSearch() { this.searching = true; $store.modelConfig.searchModels(_prov, model.name, searchType, _apiBase).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); },
48 + doSearch() { if (this.searching) return; this.searching = true; $store.modelConfig.searchModels(_prov, model.name, searchType, _apiBase).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); },
49 + openSearch(trigger) { trigger?.scrollIntoView({ block: 'center' }); this.doSearch(); },
50 grouped() { return $store.modelConfig.groupResults(this.results, model.name); }
51 }"
52 @click.outside="open = false">
53 <input type="text" x-model="model.name" style="padding-right:32px;"
54 + @click="openSearch($el)"
55 @keydown.enter.prevent="doSearch()" />
54 - <span class="model-search-btn"
55 - @click="if (!searching) doSearch()"
56 - title="Search available models">
56 + <button class="model-search-btn"
57 + type="button"
58 + @click="openSearch($el)"
59 + :disabled="searching"
60 + title="Search available models"
61 + aria-label="Search available models">
62 <span class="material-symbols-outlined" :style="searching && 'opacity:0'">search</span>
63 <span class="material-symbols-outlined model-search-spinner" :style="!searching && 'opacity:0'">progress_activity</span>
59 - </span>
64 + </button>
65 <div class="model-search-results" x-show="open && results.length > 0" x-transition.opacity>
66 <template x-for="m in grouped().matched" :key="'m_'+m">
67 <div class="model-search-item matched" @click="model.name = m; open = false;" x-text="m"></div>
@@ -289,8 +294,12 @@
294 transform: translateY(-50%);
295 width: 20px;
296 height: 20px;
297 + padding: 0;
298 display: grid;
299 place-items: center;
300 + border: 0;
301 + background: transparent;
302 + color: var(--color-text);
303 cursor: pointer;
304 user-select: none;
305 opacity: 0.6;
@@ -299,6 +308,9 @@
308 .model-search-btn:hover {
309 opacity: 1;
310 }
311 + .model-search-btn:disabled {
312 + cursor: default;
313 + }
314 .model-search-btn > span {
315 grid-area: 1 / 1;
316 font-size: 18px;
plugins/_oauth/AGENTS.md
+4 -1
@@ -28,7 +28,10 @@
28 - OAuth settings pending-auth controls such as device codes, manual callback input, and provider setup fields must render inline under the relevant provider row, not as a detached section below all providers.
29 - OAuth device-code polling must honor provider `interval`, `expires_at`, and `slow_down` updates; do not poll immediately or keep a stale fixed interval after a provider asks the client to slow down.
30 - 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`.
31 -- OAuth settings must dispatch `model-setup-changed` when a provider connection completes, but model selection remains explicit and must not be filled automatically.
31 +- When exactly one OAuth provider is connected, use it as an unsaved default only for empty slots or slots already using that provider. A different saved provider must keep the explicit `Choose connected provider` prompt until the user opts into the switch.
32 +- OAuth provider rows show a status pill only for connected accounts; model catalogs open from the model-slot field or its embedded magnifier, not from provider-row model-check actions.
33 +- OAuth model-slot fields must match `_model_config` input and below-field dropdown geometry while opening the catalog when the field is clicked.
34 +- OAuth settings must dispatch `model-setup-changed` when a provider connection completes; provider defaults may be inferred from one connected account, but model-name selection remains explicit.
35 - `helpers/providers/registry.py` is the source of truth for connectable OAuth providers.
36 - The models API must preserve the legacy plain `models` slug list and may add `model_metadata` entries for richer provider catalogs.
37 - OAuth provider config must not expose the dummy `oauth` API key in `conf/model_providers.yaml`; the dummy key is a runtime-only shim supplied by the `get_api_key` extension after the account provider reports connected.
plugins/_oauth/webui/config.html
+62 -53
@@ -40,7 +40,7 @@
40 <strong x-text="card.display_name"></strong>
41 <span x-text="$store.oauthConfig.providerReadinessLabel(card.provider_id)"></span>
42 </div>
43 - <span class="oauth-account-state" :class="{ connected: card.connected }" x-text="card.connected ? 'Connected' : 'Available'"></span>
43 + <span class="oauth-account-state connected" x-show="card.connected">Connected</span>
44 <div class="oauth-row-actions">
45 <button
46 class="oauth-connect compact"
@@ -52,15 +52,6 @@
52 <span class="material-symbols-outlined" x-text="$store.oauthConfig.connectingProvider === card.provider_id ? 'progress_activity' : ($store.oauthConfig.providerSetupReady(card.provider_id) ? 'login' : 'tune')"></span>
53 <span x-text="$store.oauthConfig.providerPrimaryLabel(card.provider_id)"></span>
54 </button>
55 - <button
56 - class="oauth-connect secondary compact"
57 - type="button"
58 - @click.stop="$store.oauthConfig.loadModels({ providerId: card.provider_id })"
59 - :disabled="!card.connected || $store.oauthConfig.loadingModelsProvider === card.provider_id"
60 - >
61 - <span class="material-symbols-outlined" x-text="$store.oauthConfig.loadingModelsProvider === card.provider_id ? 'progress_activity' : 'search'"></span>
62 - <span>Check models</span>
63 - </button>
55 <button
56 class="oauth-connect danger compact"
57 type="button"
@@ -178,7 +169,6 @@
169 <div class="oauth-section-head">
170 <div>
171 <h3>Choose your models</h3>
181 - <p>Select account-backed models used by the Main model and Utility model slots.</p>
172 </div>
173 <span class="oauth-save-chip" x-show="$store.oauthConfig.modelConfigDirty">Pending changes</span>
174 </div>
@@ -211,12 +201,10 @@
201 </div>
202 </div>
203
214 - <p class="oauth-model-description" x-text="slot.description"></p>
215 -
204 <div class="oauth-model-picker" @click.outside="$store.oauthConfig.closeModelDropdown(slot.key)">
205 <label class="oauth-model-provider-field">
218 - <span>Provider</span>
206 <select
207 + :aria-label="`${slot.title} provider`"
208 :value="$store.oauthConfig.slotCanUseModels(slot.key) ? $store.oauthConfig.modelSlot(slot.key).provider : ''"
209 @change="$store.oauthConfig.useProviderForSlot(slot.key, $event.target.value)"
210 :disabled="!$store.oauthConfig.connectedProviderCards().length"
@@ -233,16 +221,16 @@
221 type="text"
222 x-model="$store.oauthConfig.modelSlot(slot.key).name"
223 @input="$store.oauthConfig.markModelDirty(slot.key)"
236 - @focus="$store.oauthConfig.openModelDropdown(slot.key)"
224 + @click="$store.oauthConfig.openModelDropdown(slot.key, $el)"
225 :disabled="!$store.oauthConfig.slotCanUseModels(slot.key)"
226 :placeholder="$store.oauthConfig.slotCanUseModels(slot.key) ? 'Search or enter a provider model' : 'Choose a connected provider first'"
227 />
228 <button
229 class="oauth-model-search"
230 type="button"
243 - title="Check models"
244 - aria-label="Check models"
245 - @click="$store.oauthConfig.loadModels({ providerId: $store.oauthConfig.modelSlot(slot.key).provider, openDropdown: slot.key })"
231 + title="Browse available models"
232 + aria-label="Browse available models"
233 + @click="$store.oauthConfig.openModelDropdown(slot.key, $el)"
234 :disabled="!$store.oauthConfig.slotCanUseModels(slot.key) || $store.oauthConfig.loadingModelsProvider === $store.oauthConfig.modelSlot(slot.key).provider"
235 >
236 <span class="material-symbols-outlined" x-text="$store.oauthConfig.loadingModelsProvider === $store.oauthConfig.modelSlot(slot.key).provider ? 'progress_activity' : 'search'"></span>
@@ -808,7 +796,6 @@
796 }
797
798 .oauth-section-head p,
811 - .oauth-model-description,
799 .oauth-model-loading {
800 margin: 0;
801 color: var(--color-text-secondary);
@@ -889,41 +876,34 @@
876 gap: 6px;
877 }
878
892 - .oauth-model-action,
893 - .oauth-model-search {
879 + .oauth-model-action {
880 display: inline-flex;
881 align-items: center;
882 justify-content: center;
883 + gap: 5px;
884 + min-height: 32px;
885 + padding: 0 9px;
886 border: 1px solid color-mix(in srgb, var(--color-border) 76%, transparent);
887 border-radius: 8px;
888 background: transparent;
889 color: var(--color-text);
901 - cursor: pointer;
902 - }
903 -
904 - .oauth-model-action {
905 - gap: 5px;
906 - min-height: 32px;
907 - padding: 0 9px;
890 font-size: 0.76rem;
891 font-weight: 750;
892 white-space: nowrap;
893 + cursor: pointer;
894 }
895
913 - .oauth-model-action.icon,
914 - .oauth-model-search {
896 + .oauth-model-action.icon {
897 width: 34px;
898 height: 34px;
899 padding: 0;
900 }
901
920 - .oauth-model-action .material-symbols-outlined,
921 - .oauth-model-search .material-symbols-outlined {
902 + .oauth-model-action .material-symbols-outlined {
903 font-size: 18px;
904 }
905
925 - .oauth-model-action:disabled,
926 - .oauth-model-search:disabled {
906 + .oauth-model-action:disabled {
907 cursor: default;
908 opacity: .45;
909 }
@@ -965,40 +945,68 @@
945 }
946
947 .oauth-model-input-row {
968 - display: grid;
969 - grid-template-columns: minmax(0, 1fr) auto;
970 - gap: 8px;
948 + position: relative;
949 }
950
951 .oauth-model-input-row input {
952 width: 100%;
953 min-width: 0;
976 - min-height: 36px;
977 - padding: 7px 10px;
978 - border: 1px solid color-mix(in srgb, var(--color-border) 74%, transparent);
979 - border-radius: 8px;
980 - background: var(--color-input);
954 + padding: .5rem 32px .5rem .5rem;
955 + border: 1px solid var(--color-border);
956 + border-radius: .25rem;
957 + background: var(--color-background);
958 color: var(--color-text);
982 - font: inherit;
983 - font-size: 0.82rem;
959 + font-family: "Rubik", Arial, Helvetica, sans-serif;
960 }
961
962 .oauth-model-input-row input:disabled {
963 opacity: .58;
964 }
965
966 + .oauth-model-search {
967 + position: absolute;
968 + z-index: 1;
969 + top: 50%;
970 + right: 8px;
971 + display: grid;
972 + width: 20px;
973 + height: 20px;
974 + padding: 0;
975 + transform: translateY(-50%);
976 + place-items: center;
977 + border: 0;
978 + background: transparent;
979 + color: var(--color-text);
980 + cursor: pointer;
981 + opacity: .6;
982 + }
983 +
984 + .oauth-model-search:hover {
985 + opacity: 1;
986 + }
987 +
988 + .oauth-model-search:disabled {
989 + cursor: default;
990 + opacity: .45;
991 + }
992 +
993 + .oauth-model-search .material-symbols-outlined {
994 + font-size: 18px;
995 + }
996 +
997 .oauth-model-dropdown {
998 position: absolute;
992 - z-index: 20;
993 - right: 42px;
999 + z-index: 50;
1000 + top: calc(100% + 4px);
1001 + right: 0;
1002 left: 0;
1003 overflow: auto;
996 - max-height: 220px;
997 - margin-top: 6px;
1004 + max-height: 200px;
1005 + padding: 4px;
1006 border: 1px solid var(--color-border);
999 - border-radius: 8px;
1000 - background: var(--color-panel);
1001 - box-shadow: 0 12px 28px rgba(0, 0, 0, .24);
1007 + border-radius: 6px;
1008 + background: var(--color-input);
1009 + box-shadow: 0 4px 12px rgba(0, 0, 0, .3);
1010 }
1011
1012 .oauth-model-item {
@@ -1006,9 +1014,10 @@
1014 flex-direction: column;
1015 gap: 3px;
1016 width: 100%;
1009 - min-height: 34px;
1010 - padding: 8px 10px;
1017 + min-height: 30px;
1018 + padding: 5px 8px;
1019 border: 0;
1020 + border-radius: 4px;
1021 background: transparent;
1022 color: var(--color-text);
1023 font: inherit;
plugins/_oauth/webui/oauth-config-store.js
+29 -5
@@ -26,13 +26,11 @@ const MODEL_SLOTS = [
26 {
27 key: "chat_model",
28 title: "Main model",
29 - description: "Primary model for chat, reasoning, and browser tasks.",
29 icon: "forum",
30 },
31 {
32 key: "utility_model",
33 title: "Utility model",
35 - description: "Background model for summaries, memory, and prompt preparation.",
34 icon: "manufacturing",
35 },
36 ];
@@ -120,6 +118,7 @@ export const store = createStore("oauthConfig", {
118 modelConfigLoading: false,
119 modelConfigSaving: false,
120 modelConfigDirty: false,
121 + modelSlotCurrentProviders: {},
122 modelSlotDirty: {
123 chat_model: false,
124 utility_model: false,
@@ -140,6 +139,7 @@ export const store = createStore("oauthConfig", {
139 this.bindConfig(config);
140 this.installSettingsHooks(context);
141 await Promise.all([this.loadStatus(), this.loadModelConfig()]);
142 + this.applySoleConnectedProviderDefaults();
143 },
144
145 cleanup() {
@@ -163,6 +163,7 @@ export const store = createStore("oauthConfig", {
163 this.modelConfigLoading = false;
164 this.modelConfigSaving = false;
165 this.modelConfigDirty = false;
166 + this.modelSlotCurrentProviders = {};
167 this.modelSlotDirty = { chat_model: false, utility_model: false };
168 this.modelDropdown = {
169 chat_model: { open: false },
@@ -504,6 +505,9 @@ export const store = createStore("oauthConfig", {
505 ensureModelSlot(modelConfig, "chat_model");
506 ensureModelSlot(modelConfig, "utility_model");
507 this.modelConfig = modelConfig;
508 + this.modelSlotCurrentProviders = Object.fromEntries(
509 + MODEL_SLOTS.map((slot) => [slot.key, modelConfig[slot.key].provider || ""]),
510 + );
511 this.modelConfigDirty = false;
512 this.modelSlotDirty = { chat_model: false, utility_model: false };
513 } catch (error) {
@@ -542,10 +546,28 @@ export const store = createStore("oauthConfig", {
546 return provider?.display_name || provider?.short_name || provider?.provider_id || "Connected account";
547 },
548
549 + applySoleConnectedProviderDefaults() {
550 + const providers = this.connectedProviderCards();
551 + if (providers.length !== 1 || !this.modelConfig) return;
552 + const providerId = providers[0].provider_id;
553 + for (const slot of MODEL_SLOTS) {
554 + const model = this.modelSlot(slot.key);
555 + if (model.provider && model.provider !== providerId) continue;
556 + if (this.providerConnected(model.provider)) continue;
557 + model.provider = providerId;
558 + model.name = "";
559 + model.api_base = "";
560 + model.kwargs = {};
561 + }
562 + this.activeModelProvider = providerId;
563 + this.models = this.activeProviderModels();
564 + },
565 +
566 slotStatusLabel(key) {
567 const slot = this.modelSlot(key);
547 - if (this.slotUsesOauth(key)) return "";
548 - return `Currently ${this.providerName(slot.provider)}`;
568 + const currentProvider = this.modelSlotCurrentProviders[key] ?? slot.provider;
569 + if (this.isOauthProvider(currentProvider)) return "";
570 + return `Currently ${this.providerName(currentProvider)}`;
571 },
572
573 slotCanUseModels(key) {
@@ -609,8 +631,9 @@ export const store = createStore("oauthConfig", {
631 this.markModelDirty("utility_model");
632 },
633
612 - openModelDropdown(key) {
634 + openModelDropdown(key, trigger = null) {
635 if (!this.slotCanUseModels(key)) return;
636 + trigger?.scrollIntoView({ block: "center" });
637 const providerId = this.modelSlot(key).provider;
638 this.activeModelProvider = providerId;
639 this.models = this.activeProviderModels();
@@ -721,6 +744,7 @@ export const store = createStore("oauthConfig", {
744 || this.providerCards()[0]?.provider_id
745 || "";
746 }
747 + this.applySoleConnectedProviderDefaults();
748 } catch (error) {
749 void toastFrontendError(messageOf(error), "OAuth Connections");
750 } finally {
tests/test_model_config_api_keys.py
+9
@@ -242,6 +242,15 @@ def test_model_config_provider_switch_resets_custom_api_base():
242 assert '@change="model.api_base = \'\'"' in provider_select
243
244
245 +def test_model_config_model_field_opens_search_on_click():
246 + model_field_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "model-field.html"
247 + content = model_field_path.read_text(encoding="utf-8")
248 +
249 + assert '@click="openSearch($el)"' in content
250 + assert '<button class="model-search-btn"' in content
251 + assert 'aria-label="Search available models"' in content
252 +
253 +
254 def test_model_config_primary_context_controls_are_outside_advanced_settings():
255 model_field_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "model-field.html"
256 content = model_field_path.read_text(encoding="utf-8")
tests/test_oauth_static.py
+16 -3
@@ -21,6 +21,8 @@ def test_oauth_settings_exposes_provider_cards_and_model_slots():
21 assert "oauth-plan-catalog" not in config_html
22 assert "oauth-model-provider-field" in config_html
23 assert "slotProviderChoices(slot.key)" in config_html
24 + assert "<span>Provider</span>" not in config_html
25 + assert ':aria-label="`${slot.title} provider`"' in config_html
26 assert "connectedProviderCards().length" in config_html
27 assert "useProviderForSlot(slot.key, $event.target.value)" in config_html
28 assert ":value=\"$store.oauthConfig.slotCanUseModels(slot.key) ? $store.oauthConfig.modelSlot(slot.key).provider : ''\"" in config_html
@@ -42,8 +44,14 @@ def test_oauth_settings_exposes_provider_specific_controls_and_generic_copy():
44 config_html = (PROJECT_ROOT / "plugins/_oauth/webui/config.html").read_text(encoding="utf-8")
45 store_js = (PROJECT_ROOT / "plugins/_oauth/webui/oauth-config-store.js").read_text(encoding="utf-8")
46
45 - assert "Check Models" not in config_html
46 - assert "Check models" in config_html
47 + assert "Check models" not in config_html
48 + assert "card.connected ? 'Connected' : 'Available'" not in config_html
49 + assert '<span class="oauth-account-state connected" x-show="card.connected">Connected</span>' in config_html
50 + assert "Select account-backed models used by the Main model and Utility model slots." not in config_html
51 + assert "Primary model for chat, reasoning, and browser tasks." not in config_html + store_js
52 + assert "Background model for summaries, memory, and prompt preparation." not in config_html + store_js
53 + assert '@click="$store.oauthConfig.openModelDropdown(slot.key, $el)"' in config_html
54 + assert "top: calc(100% + 4px);" in config_html
55 assert "enterprise_domain" in config_html + store_js
56 assert "manualCallback" in config_html + store_js
57 assert "Paste callback URL, query string, or code" in config_html + store_js
@@ -117,7 +125,12 @@ def test_oauth_model_slots_reuse_model_config_api():
125 assert "if (!this.providerConnected(providerId)) return;" in store_js
126 assert "const providerId = slot.provider;" in store_js
127 assert "const providerId = this.isOauthProvider(this.activeModelProvider)" not in store_js
120 - assert "autoApplyConnectedProviderIfNeeded" not in store_js
128 + assert "applySoleConnectedProviderDefaults" in store_js
129 + assert "this.applySoleConnectedProviderDefaults();" in store_js
130 + assert "if (providers.length !== 1 || !this.modelConfig) return;" in store_js
131 + assert "if (model.provider && model.provider !== providerId) continue;" in store_js
132 + assert "model.name = \"\";" in store_js
133 + assert "this.modelSlotCurrentProviders[key] ?? slot.provider" in store_js
134 assert "currentChatModelConfigured" not in store_js
135 assert "providerDefaultModel" not in store_js
136 assert 'new CustomEvent("model-setup-changed"' in store_js