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