Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions apps/frontend/src/features/settings/hooks/useSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,52 @@ describe('useSettings tab deep-linking', () => {
expect(result.current.activeTab).toBe('Notifications');
});
});

describe('useSettings base URL handling across providers', () => {
const OLLAMA_SAVED: AiProviderPublicConfig = {
provider: 'ollama',
model: 'llama3.2',
baseUrl: 'http://localhost:11434',
hasApiKey: false,
apiKeyLast4: null,
};

it('does not carry a saved Ollama base URL into a fixed-endpoint provider', async () => {
// The reported sequence: Ollama is saved, the user switches to a hosted
// provider, and the backend answers "AI provider destination is not
// permitted." because a fixed destination rejects any base URL -- while
// the form shows no base URL field to clear, so the value is invisible.
vi.mocked(aiService.getConfig).mockResolvedValue(OLLAMA_SAVED);
vi.mocked(aiService.testConfig).mockResolvedValue({ ok: true, message: 'Connected.' });
vi.mocked(aiService.saveConfig).mockResolvedValue(EMPTY_CONFIG);

const { result } = renderHook(() => useSettings(), { wrapper: wrapper() });
await waitFor(() => expect(result.current.baseUrl).toBe('http://localhost:11434'));

act(() => result.current.setProvider('openai'));
expect(result.current.baseUrl).toBe('');

await act(async () => {
await result.current.testAiConfig();
});
expect(vi.mocked(aiService.testConfig).mock.lastCall?.[0].baseUrl).toBeUndefined();

await act(async () => {
await result.current.saveAiConfig();
});
expect(vi.mocked(aiService.saveConfig).mock.lastCall?.[0].baseUrl).toBeUndefined();
});

it('still sends the base URL for a provider that requires one', async () => {
vi.mocked(aiService.getConfig).mockResolvedValue(OLLAMA_SAVED);
vi.mocked(aiService.saveConfig).mockResolvedValue(OLLAMA_SAVED);

const { result } = renderHook(() => useSettings(), { wrapper: wrapper() });
await waitFor(() => expect(result.current.baseUrl).toBe('http://localhost:11434'));

await act(async () => {
await result.current.saveAiConfig();
});
expect(vi.mocked(aiService.saveConfig).mock.lastCall?.[0].baseUrl).toBe('http://localhost:11434');
});
});
23 changes: 19 additions & 4 deletions apps/frontend/src/features/settings/hooks/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,27 @@ export function useSettings() {
(nextProvider: AiProvider) => {
setProvider(nextProvider);
setModel(capabilityByProvider.get(nextProvider)?.defaultModel ?? '');
// A provider with a fixed endpoint has no base URL field, so a value
// left over from a previously selected provider would be invisible here
// and still be sent -- and the backend denies any base URL on a fixed
// destination, which surfaced as "AI provider destination is not
// permitted." with nothing on screen to correct.
if (!capabilityByProvider.get(nextProvider)?.requiresBaseUrl) {
setBaseUrl('');
}
setStatusMessage(null);
setError(null);
},
[capabilityByProvider],
);

// Belt and braces: never send a base URL for a provider that does not take
// one, whatever the field happens to hold.
const baseUrlForRequest = useCallback(() => {
if (!capabilityByProvider.get(provider)?.requiresBaseUrl) return undefined;
return baseUrl.trim() || undefined;
}, [baseUrl, capabilityByProvider, provider]);

const saveAiConfig = useCallback(async () => {
setLoading(true);
setError(null);
Expand All @@ -104,7 +119,7 @@ export function useSettings() {
provider,
apiKey: apiKey.trim() || undefined,
model: model.trim() || defaultModel,
baseUrl: baseUrl.trim() || undefined,
baseUrl: baseUrlForRequest(),
});
setAiConfig(config);
setApiKey('');
Expand All @@ -114,7 +129,7 @@ export function useSettings() {
} finally {
setLoading(false);
}
}, [apiKey, baseUrl, capabilityByProvider, model, provider]);
}, [apiKey, baseUrlForRequest, capabilityByProvider, model, provider]);

const testAiConfig = useCallback(async () => {
setTesting(true);
Expand All @@ -126,15 +141,15 @@ export function useSettings() {
provider,
apiKey: apiKey.trim() || undefined,
model: model.trim() || defaultModel,
baseUrl: baseUrl.trim() || undefined,
baseUrl: baseUrlForRequest(),
});
setStatusMessage(response.message);
} catch (caught) {
setError(getErrorMessage(caught));
} finally {
setTesting(false);
}
}, [apiKey, baseUrl, capabilityByProvider, model, provider]);
}, [apiKey, baseUrlForRequest, capabilityByProvider, model, provider]);

return {
tabs: settingsTabs,
Expand Down
Loading