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
14 changes: 2 additions & 12 deletions components/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { toast } from 'sonner';
import { type ProviderId } from '@/lib/ai/providers';
import { PROVIDERS, MONO_LOGO_PROVIDERS } from '@/lib/ai/providers';
import { cn } from '@/lib/utils';
import { getProviderTypeLabel } from './utils';
import { createCustomProviderSettings, getProviderTypeLabel } from './utils';
import { ProviderList } from './provider-list';
import { ProviderConfigPanel } from './provider-config-panel';
import { PDFSettings } from './pdf-settings';
Expand Down Expand Up @@ -456,17 +456,7 @@ export function SettingsDialog({ open, onOpenChange, initialSection }: SettingsD
const newProviderId = `custom-${Date.now()}` as ProviderId;
const updatedConfig = {
...providersConfig,
[newProviderId]: {
apiKey: '',
baseUrl: '',
models: [],
name: providerData.name,
type: providerData.type,
defaultBaseUrl: providerData.baseUrl || undefined,
icon: providerData.icon || undefined,
requiresApiKey: providerData.requiresApiKey,
isBuiltIn: false,
},
[newProviderId]: createCustomProviderSettings(providerData),
};
setProvidersConfig(updatedConfig);
setShowAddProviderDialog(false);
Expand Down
18 changes: 11 additions & 7 deletions components/settings/model-edit-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useI18n } from '@/lib/hooks/use-i18n';
import type { EditingModel } from '@/lib/types/settings';
import type { ProviderId } from '@/lib/ai/providers';
import { cn } from '@/lib/utils';
import { createVerifyModelRequest } from './utils';

interface ModelEditDialogProps {
open: boolean;
Expand Down Expand Up @@ -72,13 +73,16 @@ export function ModelEditDialog({
const response = await fetch('/api/verify-model', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
apiKey,
baseUrl,
model: `${providerId}:${editingModel.model.id}`,
providerType,
requiresApiKey,
}),
body: JSON.stringify(
createVerifyModelRequest({
providerId,
modelId: editingModel.model.id,
apiKey,
baseUrl,
providerType,
requiresApiKey,
}),
),
});

const data = await response.json();
Expand Down
19 changes: 11 additions & 8 deletions components/settings/model-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { useI18n } from '@/lib/hooks/use-i18n';
import type { ProviderId } from '@/lib/ai/providers';
import { MONO_LOGO_PROVIDERS } from '@/lib/ai/providers';
import type { ProvidersConfig } from '@/lib/types/settings';
import { formatContextWindow } from './utils';
import { createVerifyModelRequest, formatContextWindow } from './utils';

interface ModelSelectorProps {
providerId: ProviderId;
Expand Down Expand Up @@ -161,13 +161,16 @@ export function ModelSelector({
const response = await fetch('/api/verify-model', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
apiKey,
baseUrl,
model: `${pid}:${mid}`,
providerType: providerConfig.type,
requiresApiKey: providerConfig.requiresApiKey,
}),
body: JSON.stringify(
createVerifyModelRequest({
providerId: pid,
modelId: mid,
apiKey,
baseUrl,
providerType: providerConfig.type,
requiresApiKey: providerConfig.requiresApiKey,
}),
),
});

const data = await response.json();
Expand Down
19 changes: 11 additions & 8 deletions components/settings/provider-config-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
import { useI18n } from '@/lib/hooks/use-i18n';
import type { ProviderConfig } from '@/lib/ai/providers';
import type { ProvidersConfig } from '@/lib/types/settings';
import { formatContextWindow } from './utils';
import { createVerifyModelRequest, formatContextWindow } from './utils';
import { cn } from '@/lib/utils';

interface ProviderConfigPanelProps {
Expand Down Expand Up @@ -125,13 +125,16 @@ export function ProviderConfigPanel({
const response = await fetch('/api/verify-model', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
apiKey,
baseUrl,
model: `${provider.id}:${testModelId}`,
providerType: provider.type,
requiresApiKey: requiresApiKey,
}),
body: JSON.stringify(
createVerifyModelRequest({
providerId: provider.id,
modelId: testModelId,
apiKey,
baseUrl,
providerType: provider.type,
requiresApiKey,
}),
),
});

const data = await response.json();
Expand Down
46 changes: 46 additions & 0 deletions components/settings/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
import type { ProviderId, ProviderType } from '@/lib/types/provider';
import type { ProviderSettings } from '@/lib/types/settings';

interface NewCustomProviderConfig {
name: string;
type: ProviderType;
baseUrl: string;
icon: string;
requiresApiKey: boolean;
}

export function formatContextWindow(size?: number): string {
if (!size) return '-';

Expand Down Expand Up @@ -26,3 +37,38 @@ export function getProviderTypeLabel(type: string, t: (key: string) => string):
// If translation exists (not equal to key), use it; otherwise fallback to type
return translated !== translationKey ? translated : type;
}

export function createCustomProviderSettings(
providerData: NewCustomProviderConfig,
): ProviderSettings {
return {
apiKey: '',
baseUrl: providerData.baseUrl || '',
models: [],
name: providerData.name,
type: providerData.type,
defaultBaseUrl: providerData.baseUrl || undefined,
icon: providerData.icon || undefined,
requiresApiKey: providerData.requiresApiKey,
isBuiltIn: false,
};
}

interface VerifyModelRequestConfig {
providerId: ProviderId;
modelId: string;
apiKey?: string;
baseUrl?: string;
providerType?: ProviderType | string;
requiresApiKey?: boolean;
}

export function createVerifyModelRequest(config: VerifyModelRequestConfig) {
return {
apiKey: config.apiKey || '',
baseUrl: config.baseUrl || '',
model: `${config.providerId}:${config.modelId}`,
providerType: config.providerType,
requiresApiKey: config.requiresApiKey,
};
}
17 changes: 17 additions & 0 deletions lib/store/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,21 @@ function ensureBuiltInProviders(state: Partial<SettingsState>): void {
});
}

/**
* Custom providers created before #414 stored their actual endpoint in
* defaultBaseUrl while leaving baseUrl empty. Promote that persisted value
* during rehydrate so downstream request builders keep using baseUrl only.
*/
export function promoteLegacyCustomProviderBaseUrls(state: Partial<SettingsState>): void {
if (!state.providersConfig) return;

Object.values(state.providersConfig).forEach((config) => {
if (!config.isBuiltIn && !config.baseUrl && config.defaultBaseUrl) {
config.baseUrl = config.defaultBaseUrl;
}
});
}

/**
* Ensure imageProvidersConfig includes all built-in image providers.
* Called on every rehydrate so newly added image providers appear automatically.
Expand Down Expand Up @@ -1332,6 +1347,7 @@ export const useSettingsStore = create<SettingsState>()(

// Ensure providersConfig has all built-in providers (also in merge below)
ensureBuiltInProviders(state);
promoteLegacyCustomProviderBaseUrls(state);

// Ensure image/video configs have all built-in providers
ensureBuiltInImageProviders(state);
Expand Down Expand Up @@ -1464,6 +1480,7 @@ export const useSettingsStore = create<SettingsState>()(
merge: (persistedState, currentState) => {
const merged = { ...currentState, ...(persistedState as object) };
ensureBuiltInProviders(merged as Partial<SettingsState>);
promoteLegacyCustomProviderBaseUrls(merged as Partial<SettingsState>);
ensureBuiltInImageProviders(merged as Partial<SettingsState>);
ensureBuiltInVideoProviders(merged as Partial<SettingsState>);
ensureValidProviderSelections(merged as Partial<SettingsState>);
Expand Down
43 changes: 43 additions & 0 deletions tests/settings/custom-provider-baseurl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import {
createCustomProviderSettings,
createVerifyModelRequest,
} from '@/components/settings/utils';

describe('custom provider baseUrl persistence', () => {
it('stores the entered baseUrl on custom provider creation', () => {
const providerConfig = createCustomProviderSettings({
name: 'Test Provider',
type: 'openai',
baseUrl: 'https://example.com/v1',
icon: '',
requiresApiKey: true,
});

expect(providerConfig.baseUrl).toBe('https://example.com/v1');
expect(providerConfig.defaultBaseUrl).toBe('https://example.com/v1');
});

it('builds verify-model requests with the persisted baseUrl', () => {
const providerConfig = createCustomProviderSettings({
name: 'Test Provider',
type: 'openai',
baseUrl: 'https://example.com/v1',
icon: '',
requiresApiKey: true,
});

const request = createVerifyModelRequest({
providerId: 'custom-123',
modelId: 'test-model',
apiKey: 'sk-test',
baseUrl: providerConfig.baseUrl,
providerType: providerConfig.type,
requiresApiKey: providerConfig.requiresApiKey,
});

expect(request.baseUrl).toBe('https://example.com/v1');
expect(request.model).toBe('custom-123:test-model');
expect(request.providerType).toBe('openai');
});
});
56 changes: 56 additions & 0 deletions tests/store/settings-server-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -845,3 +845,59 @@ describe('fetchServerProviders — LLM cross-provider fallback', () => {
expect(store.getState().modelId).toBe('claude-sonnet-4-6');
});
});

describe('settings merge migration — custom provider baseUrl', () => {
beforeEach(() => {
vi.resetModules();
storage.clear();
mockFetch.mockReset();
});

it('promotes defaultBaseUrl into baseUrl for legacy custom providers', async () => {
const { promoteLegacyCustomProviderBaseUrls } = await import('@/lib/store/settings');
const state = {
providersConfig: {
'custom-123': {
apiKey: '',
baseUrl: '',
models: [{ id: 'test-model', name: 'Test Model' }],
name: 'Legacy Custom',
type: 'openai',
defaultBaseUrl: 'https://example.com/v1',
requiresApiKey: true,
isBuiltIn: false,
},
},
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- intentionally partial for unit test
promoteLegacyCustomProviderBaseUrls(state as any);

expect(state.providersConfig['custom-123'].baseUrl).toBe('https://example.com/v1');
expect(state.providersConfig['custom-123'].defaultBaseUrl).toBe('https://example.com/v1');
});

it('does not promote defaultBaseUrl for built-in providers', async () => {
const { promoteLegacyCustomProviderBaseUrls } = await import('@/lib/store/settings');
const state = {
providersConfig: {
openai: {
apiKey: '',
baseUrl: '',
models: [{ id: 'gpt-4o', name: 'GPT-4o' }],
name: 'OpenAI',
type: 'openai',
defaultBaseUrl: 'https://persisted-openai.example/v1',
requiresApiKey: true,
isBuiltIn: true,
},
},
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- intentionally partial for unit test
promoteLegacyCustomProviderBaseUrls(state as any);

expect(state.providersConfig.openai.baseUrl).toBe('');
expect(state.providersConfig.openai.defaultBaseUrl).toBe('https://persisted-openai.example/v1');
});
});
Loading