feat: add cached model settings per provider with automatic restoration

Preserve user's model preferences for each AI provider separately and restore them when switching between providers, preventing unwanted model resets during provider changes
This commit is contained in:
Andrew Beal 2026-06-29 19:31:32 +01:00
parent d31c92b35c
commit 938837c9c1
8 changed files with 120 additions and 214 deletions

View file

@ -14,7 +14,7 @@ import {
modelMatchesProvider
} from "Enums/ApiProvider";
const DEFAULT_SETTINGS: IVaultkeeperAISettings = {
export const DEFAULT_SETTINGS: IVaultkeeperAISettings = {
firstTimeStart: true,
chatMode: ChatMode.ReadOnly,
@ -25,6 +25,25 @@ const DEFAULT_SETTINGS: IVaultkeeperAISettings = {
planningModel: AIProviderModel.ClaudeOpus_4_8,
quickActionModel: AIProviderModel.ClaudeHaiku_4_5,
cachedModelSettings: {
[AIProvider.Claude]: {
planningModel: DEFAULT_PLANNING_MODEL_BY_PROVIDER[AIProvider.Claude],
quickActionModel: DEFAULT_QUICK_MODEL_BY_PROVIDER[AIProvider.Claude]
},
[AIProvider.OpenAI]: {
planningModel: DEFAULT_PLANNING_MODEL_BY_PROVIDER[AIProvider.OpenAI],
quickActionModel: DEFAULT_QUICK_MODEL_BY_PROVIDER[AIProvider.OpenAI]
},
[AIProvider.Gemini]: {
planningModel: DEFAULT_PLANNING_MODEL_BY_PROVIDER[AIProvider.Gemini],
quickActionModel: DEFAULT_QUICK_MODEL_BY_PROVIDER[AIProvider.Gemini]
},
[AIProvider.Mistral]: {
planningModel: DEFAULT_PLANNING_MODEL_BY_PROVIDER[AIProvider.Mistral],
quickActionModel: DEFAULT_QUICK_MODEL_BY_PROVIDER[AIProvider.Mistral]
}
},
apiKeys: {
claude: "",
openai: "",
@ -59,6 +78,8 @@ export interface IVaultkeeperAISettings {
planningModel: AIProviderModel;
quickActionModel: AIProviderModel;
cachedModelSettings: Record<AIProvider, ProviderModelCache>;
apiKeys: {
claude: string;
openai: string;
@ -82,6 +103,11 @@ export interface IVaultkeeperAISettings {
hideDrawerElements: boolean;
}
export interface ProviderModelCache {
planningModel: AIProviderModel;
quickActionModel: AIProviderModel;
}
type SettingKey = keyof IVaultkeeperAISettings;
type SettingsChangedCallback = ((changedKeys: SettingKey[]) => void) | ((changedKeys: SettingKey[]) => Promise<void>);
@ -99,7 +125,7 @@ export class SettingsService {
this.plugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedSettings);
this.settingsSnapshot = JSON.stringify(this.settings);
this.ensureValidModels();
void this.ensureValidModels();
}
public subscribeToSettingsChanged(callback: SettingsChangedCallback): object {
@ -153,6 +179,34 @@ export class SettingsService {
}
}
public async ensureValidModels(): Promise<void> {
await this.updateSettings(settings => {
if (!isvalidProvider(settings.provider)) {
settings.provider = DEFAULT_SETTINGS.provider;
}
if (!isValidProviderModel(settings.model) || !modelMatchesProvider(settings.model, settings.provider)) {
settings.model = DEFAULT_MODEL_BY_PROVIDER[settings.provider];
}
if (!isValidProviderModel(settings.planningModel) || !modelMatchesProvider(settings.planningModel, settings.provider)) {
settings.planningModel = DEFAULT_PLANNING_MODEL_BY_PROVIDER[settings.provider];
}
if (!isValidProviderModel(settings.quickActionModel) || !modelMatchesProvider(settings.quickActionModel, settings.provider)) {
settings.quickActionModel = DEFAULT_QUICK_MODEL_BY_PROVIDER[settings.provider];
}
const cached = settings.cachedModelSettings[settings.provider];
if (!isValidProviderModel(cached.planningModel) || !modelMatchesProvider(cached.planningModel, settings.provider)) {
settings.cachedModelSettings[settings.provider].planningModel = DEFAULT_PLANNING_MODEL_BY_PROVIDER[settings.provider];
}
if (!isValidProviderModel(cached.quickActionModel) || !modelMatchesProvider(cached.quickActionModel, settings.provider)) {
settings.cachedModelSettings[settings.provider].quickActionModel = DEFAULT_QUICK_MODEL_BY_PROVIDER[settings.provider];
}
});
}
private async saveSettings() {
const oldSettings = JSON.parse(this.settingsSnapshot) as IVaultkeeperAISettings;
await this.plugin.saveData(this.settings);
@ -171,26 +225,4 @@ export class SettingsService {
}
}
private ensureValidModels(): void {
void this.updateSettings(settings => {
let provider = settings.provider;
if (!isvalidProvider(provider)) {
provider = DEFAULT_SETTINGS.provider;
}
if (!isValidProviderModel(this.settings.model) || !modelMatchesProvider(this.settings.model, provider)) {
settings.model = DEFAULT_MODEL_BY_PROVIDER[provider];
}
if (!isValidProviderModel(this.settings.planningModel) || !modelMatchesProvider(this.settings.planningModel, provider)) {
settings.planningModel = DEFAULT_PLANNING_MODEL_BY_PROVIDER[provider];
}
if (!isValidProviderModel(this.settings.quickActionModel) || !modelMatchesProvider(this.settings.quickActionModel, provider)) {
settings.quickActionModel = DEFAULT_QUICK_MODEL_BY_PROVIDER[provider];
}
});
}
}

View file

@ -1,11 +1,10 @@
import { AIProvider, AIProviderModel, fromModel, isValidProviderModel } from "Enums/ApiProvider";
import { AIProvider, AIProviderModel, DEFAULT_PLANNING_MODEL_BY_PROVIDER, DEFAULT_QUICK_MODEL_BY_PROVIDER, fromModel, isValidProviderModel } from "Enums/ApiProvider";
import { Copy } from "Enums/Copy";
import { Selector } from "Enums/Selector";
import type VaultkeeperAIPlugin from "main";
import { HelpModal } from "Modals/HelpModal";
import { DropdownComponent, PluginSettingTab, Setting, ToggleComponent, setIcon, setTooltip } from "obsidian";
import { Resolve } from "Services/DependencyService";
import type { EventService } from "Services/EventService";
import type { SettingsService } from "Services/SettingsService";
import { Services } from "Services/Services";
import { closePluginSettings } from "Helpers/Helpers";
@ -16,7 +15,6 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
private readonly plugin: VaultkeeperAIPlugin;
private readonly settingsService: SettingsService;
private readonly memoriesService: MemoriesService;
private readonly eventService: EventService;
private apiKeySetting: Setting | null = null;
private apiKeyInputEl: HTMLInputElement | null = null;
@ -34,7 +32,6 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
this.settingsService = Resolve<SettingsService>(Services.SettingsService);
this.memoriesService = Resolve<MemoriesService>(Services.MemoriesService);
this.eventService = Resolve<EventService>(Services.EventService);
}
public display() {
@ -54,15 +51,23 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
return;
}
await this.settingsService.updateSettings(settings => {
settings.model = value;
settings.provider = fromModel(value);
settings.model = value;
const cached = settings.cachedModelSettings[settings.provider];
if (cached.planningModel) {
settings.planningModel = cached.planningModel;
}
if (cached.quickActionModel) {
settings.quickActionModel = cached.quickActionModel;
}
});
if (this.apiKeyInputEl) {
this.apiKeyInputEl.value = this.settingsService.getApiKeyForCurrentModel();
this.highlightApiKey();
}
this.updateFileDisclaimer();
await this.settingsService.ensureValidModels();
await this.updateModelDropdowns();
this.updateFileDisclaimer();
RegisterAiProvider();
});
});
@ -87,6 +92,7 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
}
await this.settingsService.updateSettings(settings => {
settings.planningModel = value;
settings.cachedModelSettings[settings.provider].planningModel = value;
});
RegisterAiProvider();
});
@ -106,6 +112,7 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
}
await this.settingsService.updateSettings(settings => {
settings.quickActionModel = value;
settings.cachedModelSettings[settings.provider].quickActionModel = value;
});
RegisterAiProvider();
});
@ -378,7 +385,7 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
this.populateModelDropdown(this.planningModelDropdown, currentProvider);
if (planningProvider !== currentProvider) {
settings.planningModel = settings.model;
settings.planningModel = settings.cachedModelSettings[currentProvider].planningModel ?? DEFAULT_PLANNING_MODEL_BY_PROVIDER[currentProvider];
}
this.planningModelDropdown.setValue(settings.planningModel);
@ -390,7 +397,7 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
this.populateModelDropdown(this.quickActionModelDropdown);
if (quickActionProvider !== currentProvider) {
settings.quickActionModel = settings.model;
settings.quickActionModel = settings.cachedModelSettings[currentProvider].quickActionModel ?? DEFAULT_QUICK_MODEL_BY_PROVIDER[currentProvider];
}
this.quickActionModelDropdown.setValue(settings.quickActionModel);

View file

@ -0,0 +1,12 @@
import { DEFAULT_SETTINGS, type IVaultkeeperAISettings } from 'Services/SettingsService';
/**
* Builds a complete IVaultkeeperAISettings for tests, starting from the real
* DEFAULT_SETTINGS so the mock can never drift from the production shape. Adding
* a new required setting only requires updating DEFAULT_SETTINGS, not every test.
*/
export function makeTestSettings(
overrides: Partial<IVaultkeeperAISettings> = {}
): IVaultkeeperAISettings {
return structuredClone({ ...DEFAULT_SETTINGS, ...overrides });
}

View file

@ -1,9 +1,9 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { SettingsService, type IVaultkeeperAISettings } from '../../Services/SettingsService';
import { makeTestSettings } from '../Helpers/makeTestSettings';
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
import { Services } from '../../Services/Services';
import { AIProvider, AIProviderModel, fromModel } from '../../Enums/ApiProvider';
import { ChatMode } from 'Enums/ChatMode';
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
describe('SettingsService', () => {
let settingsService: SettingsService;
@ -90,30 +90,15 @@ describe('SettingsService', () => {
describe('getApiKeyForProvider', () => {
beforeEach(() => {
const loadedSettings: IVaultkeeperAISettings = {
firstTimeStart: false,
const loadedSettings = makeTestSettings({
provider: AIProvider.Claude,
model: AIProviderModel.ClaudeSonnet_4_6,
planningModel: AIProviderModel.ClaudeOpus_4_8,
apiKeys: {
claude: 'claude-api-key',
openai: 'openai-api-key',
gemini: 'gemini-api-key', mistral: ''
},
exclusions: [],
userInstruction: '',
searchResultsLimit: 15,
snippetSizeLimit: 300,
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false,
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6,
enableContextMenuActions: false,
enableToolbarActions: false,
hideDrawerElements: false,
chatMode: ChatMode.ReadOnly
};
}
});
settingsService = new SettingsService(loadedSettings);
});
@ -141,30 +126,15 @@ describe('SettingsService', () => {
describe('getApiKeyForCurrentModel', () => {
it('should return Claude key when current model is Claude', () => {
const loadedSettings: IVaultkeeperAISettings = {
firstTimeStart: false,
const loadedSettings = makeTestSettings({
provider: AIProvider.Claude,
model: AIProviderModel.ClaudeSonnet_4_6,
planningModel: AIProviderModel.ClaudeOpus_4_8,
apiKeys: {
claude: 'claude-key',
openai: 'openai-key',
gemini: 'gemini-key', mistral: ''
},
exclusions: [],
userInstruction: '',
searchResultsLimit: 15,
snippetSizeLimit: 300,
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false,
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6,
enableContextMenuActions: false,
enableToolbarActions: false,
hideDrawerElements: false,
chatMode: ChatMode.ReadOnly
};
}
});
settingsService = new SettingsService(loadedSettings);
const key = settingsService.getApiKeyForCurrentModel();
@ -172,30 +142,15 @@ describe('SettingsService', () => {
});
it('should return OpenAI key when current model is GPT', () => {
const loadedSettings: IVaultkeeperAISettings = {
firstTimeStart: false,
const loadedSettings = makeTestSettings({
provider: AIProvider.OpenAI,
model: AIProviderModel.GPT_5_4_Mini,
planningModel: AIProviderModel.GPT_5_5,
apiKeys: {
claude: 'claude-key',
openai: 'openai-key',
gemini: 'gemini-key', mistral: ''
},
exclusions: [],
userInstruction: '',
searchResultsLimit: 15,
snippetSizeLimit: 300,
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false,
provider: AIProvider.OpenAI,
quickActionModel: AIProviderModel.GPT_5_4_Mini,
enableContextMenuActions: false,
enableToolbarActions: false,
hideDrawerElements: false,
chatMode: ChatMode.ReadOnly
};
}
});
settingsService = new SettingsService(loadedSettings);
const key = settingsService.getApiKeyForCurrentModel();
@ -203,30 +158,15 @@ describe('SettingsService', () => {
});
it('should return Gemini key when current model is Gemini', () => {
const loadedSettings: IVaultkeeperAISettings = {
firstTimeStart: false,
const loadedSettings = makeTestSettings({
provider: AIProvider.Gemini,
model: AIProviderModel.GeminiFlash_3_5_Flash,
planningModel: AIProviderModel.GeminiPro_3_1_Preview,
apiKeys: {
claude: 'claude-key',
openai: 'openai-key',
gemini: 'gemini-key', mistral: ''
},
exclusions: [],
userInstruction: '',
searchResultsLimit: 15,
snippetSizeLimit: 300,
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false,
provider: AIProvider.Gemini,
quickActionModel: AIProviderModel.GeminiFlash_3_5_Flash,
enableContextMenuActions: false,
enableToolbarActions: false,
hideDrawerElements: false,
chatMode: ChatMode.ReadOnly
};
}
});
settingsService = new SettingsService(loadedSettings);
const key = settingsService.getApiKeyForCurrentModel();
@ -262,30 +202,10 @@ describe('SettingsService', () => {
describe('setApiKeyForProvider', () => {
beforeEach(() => {
const loadedSettings: IVaultkeeperAISettings = {
firstTimeStart: false,
model: AIProviderModel.ClaudeSonnet_4_6,
planningModel: AIProviderModel.ClaudeOpus_4_8,
apiKeys: {
claude: '',
openai: '',
gemini: '', mistral: ''
},
exclusions: [],
userInstruction: '',
searchResultsLimit: 15,
snippetSizeLimit: 300,
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false,
const loadedSettings = makeTestSettings({
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6,
enableContextMenuActions: false,
enableToolbarActions: false,
hideDrawerElements: false,
chatMode: ChatMode.ReadOnly
};
model: AIProviderModel.ClaudeSonnet_4_6
});
settingsService = new SettingsService(loadedSettings);
});
@ -330,30 +250,17 @@ describe('SettingsService', () => {
describe('saveSettings', () => {
beforeEach(() => {
const loadedSettings: IVaultkeeperAISettings = {
firstTimeStart: false,
const loadedSettings = makeTestSettings({
provider: AIProvider.Claude,
model: AIProviderModel.ClaudeSonnet_4_6,
planningModel: AIProviderModel.ClaudeOpus_4_8,
apiKeys: {
claude: 'test-key',
openai: '',
gemini: '', mistral: ''
},
exclusions: ['node_modules'],
userInstruction: 'Be helpful',
searchResultsLimit: 15,
snippetSizeLimit: 300,
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false,
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6,
enableContextMenuActions: false,
enableToolbarActions: false,
hideDrawerElements: false,
chatMode: ChatMode.ReadOnly
};
userInstruction: 'Be helpful'
});
settingsService = new SettingsService(loadedSettings);
mockPlugin.saveData.mockClear();
});

View file

@ -3,10 +3,10 @@ import { TFile, TFolder, TAbstractFile, MetadataCache, FileManager } from 'obsid
import { VaultCacheService } from '../../Services/VaultCacheService';
import { VaultService } from '../../Services/VaultService';
import { SettingsService, type IVaultkeeperAISettings } from '../../Services/SettingsService';
import { makeTestSettings } from '../Helpers/makeTestSettings';
import { SanitiserService } from '../../Services/SanitiserService';
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
import { Services } from '../../Services/Services';
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
import type VaultkeeperAIPlugin from '../../main';
import { ChatMode } from '../../Enums/ChatMode';
@ -88,30 +88,17 @@ const mockPlugin = {
registerEvent: vi.fn()
} as unknown as VaultkeeperAIPlugin;
const mockSettings: IVaultkeeperAISettings = {
const mockSettings: IVaultkeeperAISettings = makeTestSettings({
firstTimeStart: false,
model: AIProviderModel.ClaudeSonnet_4_6,
planningModel: AIProviderModel.ClaudeSonnet_4_6,
apiKeys: {
claude: 'test-claude-key',
openai: 'test-openai-key',
gemini: 'test-gemini-key', mistral: 'test-mistral-key'
},
exclusions: [],
userInstruction: '',
searchResultsLimit: 15,
snippetSizeLimit: 300,
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false,
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6,
enableContextMenuActions: false,
enableToolbarActions: false,
hideDrawerElements: false,
chatMode: ChatMode.Edit
};
});
let settingsService: SettingsService;
let vaultService: VaultService;

View file

@ -5,7 +5,7 @@ import { RegisterSingleton, DeregisterAllServices } from '../../Services/Depende
import { Services } from '../../Services/Services';
import { SanitiserService } from '../../Services/SanitiserService';
import { SettingsService, type IVaultkeeperAISettings } from '../../Services/SettingsService';
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
import { makeTestSettings } from '../Helpers/makeTestSettings';
import { Exception } from '../../Helpers/Exception';
import * as PDFHelper from '../../Helpers/DocumentHelper';
import type { IPageText } from '../../Types/SearchTypes';
@ -48,30 +48,17 @@ const mockFileManager = {
trashFile: ReturnType<typeof vi.fn>;
};
const mockSettings: IVaultkeeperAISettings = {
const mockSettings: IVaultkeeperAISettings = makeTestSettings({
firstTimeStart: false,
model: AIProviderModel.ClaudeSonnet_4_6,
planningModel: AIProviderModel.ClaudeSonnet_4_6,
apiKeys: {
claude: 'test-claude-key',
openai: 'test-openai-key',
gemini: 'test-gemini-key', mistral: 'test-mistral-key'
},
exclusions: [],
userInstruction: '',
searchResultsLimit: 15,
snippetSizeLimit: 300,
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false,
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6,
enableContextMenuActions: false,
enableToolbarActions: false,
hideDrawerElements: false,
chatMode: ChatMode.Edit
};
});
const mockPlugin = {
app: {

View file

@ -2,10 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TFile, TFolder, TAbstractFile, Vault, FileManager } from 'obsidian';
import { VaultService } from '../../Services/VaultService';
import { SettingsService, type IVaultkeeperAISettings } from '../../Services/SettingsService';
import { makeTestSettings } from '../Helpers/makeTestSettings';
import { SanitiserService } from '../../Services/SanitiserService';
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
import { Services } from '../../Services/Services';
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
import type VaultkeeperAIPlugin from '../../main';
import { ChatMode } from '../../Enums/ChatMode';
@ -50,30 +50,17 @@ const mockPlugin = {
saveData: vi.fn().mockResolvedValue(undefined)
} as unknown as VaultkeeperAIPlugin;
const mockSettings: IVaultkeeperAISettings = {
const mockSettings: IVaultkeeperAISettings = makeTestSettings({
firstTimeStart: false,
model: AIProviderModel.ClaudeSonnet_4_6,
planningModel: AIProviderModel.ClaudeSonnet_4_6,
apiKeys: {
claude: 'test-claude-key',
openai: 'test-openai-key',
gemini: 'test-gemini-key', mistral: 'test-mistral-key'
},
exclusions: [],
userInstruction: '',
searchResultsLimit: 15,
snippetSizeLimit: 300,
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false,
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6,
enableContextMenuActions: false,
enableToolbarActions: false,
hideDrawerElements: false,
chatMode: ChatMode.Edit
};
});
let settingsService: SettingsService;
let vaultService: VaultService;

View file

@ -6,7 +6,7 @@ import { RegisterSingleton, DeregisterAllServices } from '../../Services/Depende
import { Services } from '../../Services/Services';
import { SanitiserService } from '../../Services/SanitiserService';
import { SettingsService, type IVaultkeeperAISettings } from '../../Services/SettingsService';
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
import { makeTestSettings } from '../Helpers/makeTestSettings';
import { Exception } from '../../Helpers/Exception';
import { ChatMode } from '../../Enums/ChatMode';
@ -47,30 +47,17 @@ const mockFileManager = {
};
// Create a mutable settings object that tests can modify
const mockSettings: IVaultkeeperAISettings = {
const mockSettings: IVaultkeeperAISettings = makeTestSettings({
firstTimeStart: false,
model: AIProviderModel.ClaudeSonnet_4_6,
planningModel: AIProviderModel.ClaudeSonnet_4_6,
apiKeys: {
claude: 'test-claude-key',
openai: 'test-openai-key',
gemini: 'test-gemini-key', mistral: 'test-mistral-key'
},
exclusions: [],
userInstruction: '',
searchResultsLimit: 15,
snippetSizeLimit: 300,
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false,
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6,
enableContextMenuActions: false,
enableToolbarActions: false,
hideDrawerElements: false,
chatMode: ChatMode.Edit
};
});
const mockPlugin = {
app: {