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 — 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] ? '••••••••••••' : ''"
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="