Skip to content

Commit 3856364

Browse files
committed
fix(settings): persist custom provider baseUrl on creation (#414)
1 parent 082fcbc commit 3856364

8 files changed

Lines changed: 197 additions & 35 deletions

File tree

components/settings/index.tsx

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import { toast } from 'sonner';
3434
import { type ProviderId } from '@/lib/ai/providers';
3535
import { PROVIDERS, MONO_LOGO_PROVIDERS } from '@/lib/ai/providers';
3636
import { cn } from '@/lib/utils';
37-
import { getProviderTypeLabel } from './utils';
37+
import { createCustomProviderSettings, getProviderTypeLabel } from './utils';
3838
import { ProviderList } from './provider-list';
3939
import { ProviderConfigPanel } from './provider-config-panel';
4040
import { PDFSettings } from './pdf-settings';
@@ -456,17 +456,7 @@ export function SettingsDialog({ open, onOpenChange, initialSection }: SettingsD
456456
const newProviderId = `custom-${Date.now()}` as ProviderId;
457457
const updatedConfig = {
458458
...providersConfig,
459-
[newProviderId]: {
460-
apiKey: '',
461-
baseUrl: '',
462-
models: [],
463-
name: providerData.name,
464-
type: providerData.type,
465-
defaultBaseUrl: providerData.baseUrl || undefined,
466-
icon: providerData.icon || undefined,
467-
requiresApiKey: providerData.requiresApiKey,
468-
isBuiltIn: false,
469-
},
459+
[newProviderId]: createCustomProviderSettings(providerData),
470460
};
471461
setProvidersConfig(updatedConfig);
472462
setShowAddProviderDialog(false);

components/settings/model-edit-dialog.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useI18n } from '@/lib/hooks/use-i18n';
1111
import type { EditingModel } from '@/lib/types/settings';
1212
import type { ProviderId } from '@/lib/ai/providers';
1313
import { cn } from '@/lib/utils';
14+
import { createVerifyModelRequest } from './utils';
1415

1516
interface ModelEditDialogProps {
1617
open: boolean;
@@ -72,13 +73,16 @@ export function ModelEditDialog({
7273
const response = await fetch('/api/verify-model', {
7374
method: 'POST',
7475
headers: { 'Content-Type': 'application/json' },
75-
body: JSON.stringify({
76-
apiKey,
77-
baseUrl,
78-
model: `${providerId}:${editingModel.model.id}`,
79-
providerType,
80-
requiresApiKey,
81-
}),
76+
body: JSON.stringify(
77+
createVerifyModelRequest({
78+
providerId,
79+
modelId: editingModel.model.id,
80+
apiKey,
81+
baseUrl,
82+
providerType,
83+
requiresApiKey,
84+
}),
85+
),
8286
});
8387

8488
const data = await response.json();

components/settings/model-selector.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { useI18n } from '@/lib/hooks/use-i18n';
2121
import type { ProviderId } from '@/lib/ai/providers';
2222
import { MONO_LOGO_PROVIDERS } from '@/lib/ai/providers';
2323
import type { ProvidersConfig } from '@/lib/types/settings';
24-
import { formatContextWindow } from './utils';
24+
import { createVerifyModelRequest, formatContextWindow } from './utils';
2525

2626
interface ModelSelectorProps {
2727
providerId: ProviderId;
@@ -161,13 +161,16 @@ export function ModelSelector({
161161
const response = await fetch('/api/verify-model', {
162162
method: 'POST',
163163
headers: { 'Content-Type': 'application/json' },
164-
body: JSON.stringify({
165-
apiKey,
166-
baseUrl,
167-
model: `${pid}:${mid}`,
168-
providerType: providerConfig.type,
169-
requiresApiKey: providerConfig.requiresApiKey,
170-
}),
164+
body: JSON.stringify(
165+
createVerifyModelRequest({
166+
providerId: pid,
167+
modelId: mid,
168+
apiKey,
169+
baseUrl,
170+
providerType: providerConfig.type,
171+
requiresApiKey: providerConfig.requiresApiKey,
172+
}),
173+
),
171174
});
172175

173176
const data = await response.json();

components/settings/provider-config-panel.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import {
3434
import { useI18n } from '@/lib/hooks/use-i18n';
3535
import type { ProviderConfig } from '@/lib/ai/providers';
3636
import type { ProvidersConfig } from '@/lib/types/settings';
37-
import { formatContextWindow } from './utils';
37+
import { createVerifyModelRequest, formatContextWindow } from './utils';
3838
import { cn } from '@/lib/utils';
3939

4040
interface ProviderConfigPanelProps {
@@ -125,13 +125,16 @@ export function ProviderConfigPanel({
125125
const response = await fetch('/api/verify-model', {
126126
method: 'POST',
127127
headers: { 'Content-Type': 'application/json' },
128-
body: JSON.stringify({
129-
apiKey,
130-
baseUrl,
131-
model: `${provider.id}:${testModelId}`,
132-
providerType: provider.type,
133-
requiresApiKey: requiresApiKey,
134-
}),
128+
body: JSON.stringify(
129+
createVerifyModelRequest({
130+
providerId: provider.id,
131+
modelId: testModelId,
132+
apiKey,
133+
baseUrl,
134+
providerType: provider.type,
135+
requiresApiKey,
136+
}),
137+
),
135138
});
136139

137140
const data = await response.json();

components/settings/utils.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
import type { ProviderId, ProviderType } from '@/lib/types/provider';
2+
import type { ProviderSettings } from '@/lib/types/settings';
3+
4+
interface NewCustomProviderConfig {
5+
name: string;
6+
type: ProviderType;
7+
baseUrl: string;
8+
icon: string;
9+
requiresApiKey: boolean;
10+
}
11+
112
export function formatContextWindow(size?: number): string {
213
if (!size) return '-';
314

@@ -26,3 +37,38 @@ export function getProviderTypeLabel(type: string, t: (key: string) => string):
2637
// If translation exists (not equal to key), use it; otherwise fallback to type
2738
return translated !== translationKey ? translated : type;
2839
}
40+
41+
export function createCustomProviderSettings(
42+
providerData: NewCustomProviderConfig,
43+
): ProviderSettings {
44+
return {
45+
apiKey: '',
46+
baseUrl: providerData.baseUrl || '',
47+
models: [],
48+
name: providerData.name,
49+
type: providerData.type,
50+
defaultBaseUrl: providerData.baseUrl || undefined,
51+
icon: providerData.icon || undefined,
52+
requiresApiKey: providerData.requiresApiKey,
53+
isBuiltIn: false,
54+
};
55+
}
56+
57+
interface VerifyModelRequestConfig {
58+
providerId: ProviderId;
59+
modelId: string;
60+
apiKey?: string;
61+
baseUrl?: string;
62+
providerType?: ProviderType | string;
63+
requiresApiKey?: boolean;
64+
}
65+
66+
export function createVerifyModelRequest(config: VerifyModelRequestConfig) {
67+
return {
68+
apiKey: config.apiKey || '',
69+
baseUrl: config.baseUrl || '',
70+
model: `${config.providerId}:${config.modelId}`,
71+
providerType: config.providerType,
72+
requiresApiKey: config.requiresApiKey,
73+
};
74+
}

lib/store/settings.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,21 @@ function ensureBuiltInProviders(state: Partial<SettingsState>): void {
473473
});
474474
}
475475

476+
/**
477+
* Custom providers created before #414 stored their actual endpoint in
478+
* defaultBaseUrl while leaving baseUrl empty. Promote that persisted value
479+
* during rehydrate so downstream request builders keep using baseUrl only.
480+
*/
481+
export function promoteLegacyCustomProviderBaseUrls(state: Partial<SettingsState>): void {
482+
if (!state.providersConfig) return;
483+
484+
Object.values(state.providersConfig).forEach((config) => {
485+
if (!config.isBuiltIn && !config.baseUrl && config.defaultBaseUrl) {
486+
config.baseUrl = config.defaultBaseUrl;
487+
}
488+
});
489+
}
490+
476491
/**
477492
* Ensure imageProvidersConfig includes all built-in image providers.
478493
* Called on every rehydrate so newly added image providers appear automatically.
@@ -1332,6 +1347,7 @@ export const useSettingsStore = create<SettingsState>()(
13321347

13331348
// Ensure providersConfig has all built-in providers (also in merge below)
13341349
ensureBuiltInProviders(state);
1350+
promoteLegacyCustomProviderBaseUrls(state);
13351351

13361352
// Ensure image/video configs have all built-in providers
13371353
ensureBuiltInImageProviders(state);
@@ -1464,6 +1480,7 @@ export const useSettingsStore = create<SettingsState>()(
14641480
merge: (persistedState, currentState) => {
14651481
const merged = { ...currentState, ...(persistedState as object) };
14661482
ensureBuiltInProviders(merged as Partial<SettingsState>);
1483+
promoteLegacyCustomProviderBaseUrls(merged as Partial<SettingsState>);
14671484
ensureBuiltInImageProviders(merged as Partial<SettingsState>);
14681485
ensureBuiltInVideoProviders(merged as Partial<SettingsState>);
14691486
ensureValidProviderSelections(merged as Partial<SettingsState>);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
createCustomProviderSettings,
4+
createVerifyModelRequest,
5+
} from '@/components/settings/utils';
6+
7+
describe('custom provider baseUrl persistence', () => {
8+
it('stores the entered baseUrl on custom provider creation', () => {
9+
const providerConfig = createCustomProviderSettings({
10+
name: 'Test Provider',
11+
type: 'openai',
12+
baseUrl: 'https://example.com/v1',
13+
icon: '',
14+
requiresApiKey: true,
15+
});
16+
17+
expect(providerConfig.baseUrl).toBe('https://example.com/v1');
18+
expect(providerConfig.defaultBaseUrl).toBe('https://example.com/v1');
19+
});
20+
21+
it('builds verify-model requests with the persisted baseUrl', () => {
22+
const providerConfig = createCustomProviderSettings({
23+
name: 'Test Provider',
24+
type: 'openai',
25+
baseUrl: 'https://example.com/v1',
26+
icon: '',
27+
requiresApiKey: true,
28+
});
29+
30+
const request = createVerifyModelRequest({
31+
providerId: 'custom-123',
32+
modelId: 'test-model',
33+
apiKey: 'sk-test',
34+
baseUrl: providerConfig.baseUrl,
35+
providerType: providerConfig.type,
36+
requiresApiKey: providerConfig.requiresApiKey,
37+
});
38+
39+
expect(request.baseUrl).toBe('https://example.com/v1');
40+
expect(request.model).toBe('custom-123:test-model');
41+
expect(request.providerType).toBe('openai');
42+
});
43+
});

tests/store/settings-server-sync.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -845,3 +845,59 @@ describe('fetchServerProviders — LLM cross-provider fallback', () => {
845845
expect(store.getState().modelId).toBe('claude-sonnet-4-6');
846846
});
847847
});
848+
849+
describe('settings merge migration — custom provider baseUrl', () => {
850+
beforeEach(() => {
851+
vi.resetModules();
852+
storage.clear();
853+
mockFetch.mockReset();
854+
});
855+
856+
it('promotes defaultBaseUrl into baseUrl for legacy custom providers', async () => {
857+
const { promoteLegacyCustomProviderBaseUrls } = await import('@/lib/store/settings');
858+
const state = {
859+
providersConfig: {
860+
'custom-123': {
861+
apiKey: '',
862+
baseUrl: '',
863+
models: [{ id: 'test-model', name: 'Test Model' }],
864+
name: 'Legacy Custom',
865+
type: 'openai',
866+
defaultBaseUrl: 'https://example.com/v1',
867+
requiresApiKey: true,
868+
isBuiltIn: false,
869+
},
870+
},
871+
};
872+
873+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- intentionally partial for unit test
874+
promoteLegacyCustomProviderBaseUrls(state as any);
875+
876+
expect(state.providersConfig['custom-123'].baseUrl).toBe('https://example.com/v1');
877+
expect(state.providersConfig['custom-123'].defaultBaseUrl).toBe('https://example.com/v1');
878+
});
879+
880+
it('does not promote defaultBaseUrl for built-in providers', async () => {
881+
const { promoteLegacyCustomProviderBaseUrls } = await import('@/lib/store/settings');
882+
const state = {
883+
providersConfig: {
884+
openai: {
885+
apiKey: '',
886+
baseUrl: '',
887+
models: [{ id: 'gpt-4o', name: 'GPT-4o' }],
888+
name: 'OpenAI',
889+
type: 'openai',
890+
defaultBaseUrl: 'https://persisted-openai.example/v1',
891+
requiresApiKey: true,
892+
isBuiltIn: true,
893+
},
894+
},
895+
};
896+
897+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- intentionally partial for unit test
898+
promoteLegacyCustomProviderBaseUrls(state as any);
899+
900+
expect(state.providersConfig.openai.baseUrl).toBe('');
901+
expect(state.providersConfig.openai.defaultBaseUrl).toBe('https://persisted-openai.example/v1');
902+
});
903+
});

0 commit comments

Comments
 (0)