fix(_model_config): prevent Settings Save from clobbering API keys saved via Configure Models

keyboardstaff committed Mar 29, 2026 at 00:28 UTC c16134e6242415dabe681765e53014c7850804e9
5 files changed +43 -17
plugins/_model_config/webui/api-keys-mixin.js
+31
@@ -1,4 +1,5 @@
1 const API_BASE = "/plugins/_model_config";
2 +const API_KEY_PLACEHOLDER = "************";
3
4 export const apiKeysState = {
5 apiKeyStatus: {},
@@ -17,6 +18,14 @@ export const apiKeysMethods = {
18 );
19 },
20
21 + _syncApiKeysToSettingsStore(savedKeys) {
22 + const settingsApiKeys = globalThis.Alpine?.store('settings')?.settings?.api_keys;
23 + if (!settingsApiKeys) return;
24 + for (const [provider, value] of Object.entries(savedKeys)) {
25 + settingsApiKeys[provider] = value.trim() ? API_KEY_PLACEHOLDER : '';
26 + }
27 + },
28 +
29 _ensureApiKeySlot(provider) {
30 if (!provider) return;
31 if (!(provider in this.apiKeyValues)) {
@@ -37,6 +46,13 @@ export const apiKeysMethods = {
46 this._setApiKeyDirty(provider, true);
47 },
48
49 + setApiKeyValue(provider, value) {
50 + if (!provider) return;
51 + this._ensureApiKeySlot(provider);
52 + this.apiKeyValues = { ...this.apiKeyValues, [provider]: value };
53 + this._setApiKeyDirty(provider, true);
54 + },
55 +
56 async refreshApiKeyStatus() {
57 await this.ensureLoaded();
58 const res = await fetchApi(`${API_BASE}/api_keys`, {
@@ -113,6 +129,11 @@ export const apiKeysMethods = {
129 }
130 this.apiKeyValues = nextValues;
131 this.apiKeyDirty = nextDirty;
132 +
133 + // Sync saved keys into the Settings store so Settings Save
134 + // won't overwrite just-saved keys with stale empty values.
135 + this._syncApiKeysToSettingsStore(normalized);
136 +
137 return data;
138 },
139
@@ -158,4 +179,14 @@ export const apiKeysMethods = {
179 }
180 return this.saveApiKeys(updates);
181 },
182 +
183 + async persistAllDirtyApiKeys() {
184 + const updates = {};
185 + for (const [provider, isDirty] of Object.entries(this.apiKeyDirty)) {
186 + if (!isDirty) continue;
187 + const value = this.apiKeyValues[provider];
188 + updates[provider] = typeof value === 'string' ? value : '';
189 + }
190 + return this.saveApiKeys(updates);
191 + },
192 };
plugins/_model_config/webui/api-keys.html
+3 -10
@@ -35,14 +35,7 @@
35 this.saving = true;
36 this.error = '';
37 try {
38 - const updates = {};
39 - const dirty = $store.modelConfig.apiKeyDirty;
40 - const values = $store.modelConfig.apiKeyValues;
41 - for (const provider of Object.keys(dirty)) {
42 - if (!dirty[provider]) continue;
43 - updates[provider] = values[provider] || '';
44 - }
45 - await $store.modelConfig.saveApiKeys(updates);
38 + await $store.modelConfig.persistAllDirtyApiKeys();
39 await $store.modelConfig.refreshApiKeyStatus();
40 window.closeModal?.();
41 } catch (e) {
@@ -78,10 +71,10 @@
71 </div>
72 <div class="field-control" style="position:relative;" x-data="{ showKey: false }">
73 <input :type="showKey ? 'text' : 'password'"
81 - x-model="$store.modelConfig.apiKeyValues[provider.value]"
74 + :value="$store.modelConfig.apiKeyValues[provider.value]"
75 :placeholder="provider.has_key ? '••••••••••••' : ''"
76 autocomplete="off"
84 - @input="$store.modelConfig.touchApiKey(provider.value)"
77 + @input="$store.modelConfig.setApiKeyValue(provider.value, $el.value)"
78 style="padding-right:32px;" />
79 <span class="material-symbols-outlined eye-toggle"
80 @click="
plugins/_model_config/webui/main.html
+5 -3
@@ -11,6 +11,8 @@
11 x-init="
12 await $store.modelConfig.ensureLoaded();
13 await $store.modelConfig.loadGlobalPresets();
14 + $store.modelConfig.resetApiKeyDrafts();
15 + await $store.modelConfig.refreshApiKeyStatus();
16 ">
17 <template x-if="$store.modelConfig._loaded && $store.modelConfig._presetsLoaded">
18 <div class="presets-page" x-data="{ presets: JSON.parse(JSON.stringify($store.modelConfig.globalPresets)) }">
@@ -46,12 +48,12 @@
48 </div>
49
50 <div class="preset-subheader">Main Model</div>
49 - <div x-data="{ model: preset.chat, modelType: 'chat', providers: $store.modelConfig.chatProviders, searchType: 'chat', apiKeyMode: 'inline' }">
51 + <div x-data="{ model: preset.chat, modelType: 'chat', providers: $store.modelConfig.chatProviders, searchType: 'chat', apiKeyMode: 'store' }">
52 <x-component path="/plugins/_model_config/webui/model-field.html"></x-component>
53 </div>
54
55 <div class="preset-subheader">Utility Model <span style="opacity:0.5; font-size:0.75rem;">(optional &#x2014; falls back to the configured Utility Model)</span></div>
54 - <div x-data="{ model: preset.utility, modelType: 'utility', providers: $store.modelConfig.chatProviders, searchType: 'chat', apiKeyMode: 'inline', providerFallback: preset.chat.provider, apiBaseFallback: preset.chat.api_base }">
56 + <div x-data="{ model: preset.utility, modelType: 'utility', providers: $store.modelConfig.chatProviders, searchType: 'chat', apiKeyMode: 'store', providerFallback: preset.chat.provider, apiBaseFallback: preset.chat.api_base }">
57 <x-component path="/plugins/_model_config/webui/model-field.html"></x-component>
58 </div>
59 </div>
@@ -83,7 +85,7 @@
85 </div>
86
87 <div class="presets-footer">
86 - <button class="button" @click="$store.modelConfig.saveGlobalPresets(presets)">
88 + <button class="button" @click="(async () => { await $store.modelConfig.persistAllDirtyApiKeys(); await $store.modelConfig.saveGlobalPresets(presets); })()">
89 <span class="icon material-symbols-outlined">save</span> Save Presets
90 </button>
91 </div>
plugins/_model_config/webui/model-config-store.js
+2 -2
@@ -154,12 +154,12 @@ export const store = createStore("modelConfig", {
154 },
155
156 async saveGlobalPresets(presets) {
157 - // Strip UI-only fields before saving
157 + // Strip UI-only and globally-managed fields before saving
158 const clean = presets.map(p => {
159 const c = { name: p.name };
160 for (const slot of ['chat', 'utility']) {
161 if (p[slot]) {
162 - const { _kwargs_text, ...rest } = p[slot];
162 + const { _kwargs_text, api_key, ...rest } = p[slot];
163 c[slot] = rest;
164 }
165 }
plugins/_model_config/webui/model-field.html
+2 -2
@@ -79,10 +79,10 @@
79 </div>
80 <div class="field-control" style="position:relative;" x-data="{ showKey: false }">
81 <input :type="showKey ? 'text' : 'password'"
82 - x-model="$store.modelConfig.apiKeyValues[model.provider]"
82 + :value="$store.modelConfig.apiKeyValues[model.provider]"
83 :placeholder="$store.modelConfig.apiKeyStatus[model.provider] ? '&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;' : ''"
84 autocomplete="off"
85 - @input="$store.modelConfig.touchApiKey(model.provider)"
85 + @input="$store.modelConfig.setApiKeyValue(model.provider, $el.value)"
86 style="padding-right:32px;" />
87 <span class="material-symbols-outlined eye-toggle"
88 @click="