diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx
index 977ba0bc..19601308 100644
--- a/desktop/src/__tests__/generalSettings.test.tsx
+++ b/desktop/src/__tests__/generalSettings.test.tsx
@@ -878,6 +878,10 @@ describe('Settings > Providers tab', () => {
MOCK_DELETE_PROVIDER.mockReset()
MOCK_GET_SETTINGS.mockResolvedValue({})
MOCK_UPDATE_SETTINGS.mockResolvedValue({})
+ useSettingsStore.setState({
+ locale: 'en',
+ fetchAll: vi.fn().mockResolvedValue(undefined),
+ })
providerStoreState.providers = [
{
id: 'provider-1',
@@ -972,6 +976,58 @@ describe('Settings > Providers tab', () => {
expect(within(dialog).getByText('Requests will be translated via the local proxy')).toBeInTheDocument()
})
+ it('normalizes blank model mappings to the main model when saving a provider', async () => {
+ providerStoreState.createProvider = vi.fn().mockResolvedValue({
+ id: 'provider-new',
+ presetId: 'custom',
+ name: 'Custom',
+ apiKey: 'sk-test',
+ baseUrl: 'https://api.example.com/anthropic',
+ apiFormat: 'anthropic',
+ models: {
+ main: 'gpt-5.5',
+ haiku: 'gpt-5.5',
+ sonnet: 'gpt-5.5',
+ opus: 'gpt-5.5',
+ },
+ })
+ providerStoreState.presets = [
+ {
+ id: 'custom',
+ name: 'Custom',
+ baseUrl: 'https://api.example.com/anthropic',
+ apiFormat: 'anthropic',
+ defaultModels: {
+ main: '',
+ haiku: '',
+ sonnet: '',
+ opus: '',
+ },
+ needsApiKey: true,
+ websiteUrl: '',
+ },
+ ]
+
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: /Add Provider|添加服务商/i }))
+ const dialog = screen.getByRole('dialog')
+ fireEvent.change(within(dialog).getByPlaceholderText('sk-...'), { target: { value: 'sk-test' } })
+ fireEvent.change(within(dialog).getByLabelText(/Main Model|主模型/i), { target: { value: 'gpt-5.5' } })
+ fireEvent.click(within(dialog).getByRole('button', { name: /Save|Add|保存|添加/i }))
+
+ await waitFor(() => {
+ expect(providerStoreState.createProvider).toHaveBeenCalledWith(expect.objectContaining({
+ models: {
+ main: 'gpt-5.5',
+ haiku: 'gpt-5.5',
+ sonnet: 'gpt-5.5',
+ opus: 'gpt-5.5',
+ },
+ }))
+ })
+ })
+
it('hides the API key by default and reveals it from the eye button', () => {
providerStoreState.presets = [
{
diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx
index db7d2c93..3aee9958 100644
--- a/desktop/src/pages/Settings.tsx
+++ b/desktop/src/pages/Settings.tsx
@@ -513,6 +513,16 @@ function buildModelContextWindows(
return windows
}
+function normalizeModelMapping(models: ModelMapping): ModelMapping {
+ const main = models.main.trim()
+ return {
+ main,
+ haiku: models.haiku.trim() || main,
+ sonnet: models.sonnet.trim() || main,
+ opus: models.opus.trim() || main,
+ }
+}
+
function updateSettingsJsonAutoCompactWindow(raw: string, value: string): string {
try {
const parsed = JSON.parse(raw || '{}') as { env?: Record }
@@ -685,6 +695,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
const needsProxy = apiFormat !== 'anthropic'
const autoCompactWindowEnv = autoCompactWindow.trim()
const modelContextWindows = buildModelContextWindows(models, modelContextInputs)
+ const normalizedModels = normalizeModelMapping(models)
const existingEnv = (settings.env as Record) || {}
const cleanedEnv = stripProviderSettingsJsonEnv(existingEnv, presetDefaultEnvKeys)
const merged = {
@@ -699,10 +710,10 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
: {}),
ANTHROPIC_BASE_URL: needsProxy ? 'http://127.0.0.1:3456/proxy' : baseUrl,
...buildSettingsJsonAuthEnv(apiFormat, authStrategy, apiKey, selectedPreset),
- ANTHROPIC_MODEL: models.main,
- ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
- ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
- ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
+ ANTHROPIC_MODEL: normalizedModels.main,
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: normalizedModels.haiku,
+ ANTHROPIC_DEFAULT_SONNET_MODEL: normalizedModels.sonnet,
+ ANTHROPIC_DEFAULT_OPUS_MODEL: normalizedModels.opus,
},
}
setSettingsJson(JSON.stringify(merged, null, 2))
@@ -830,7 +841,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
setModels(nextModels)
setModelContextInputs(nextInputs)
setSettingsJson((current) => updateSettingsJsonModelContextWindows(
- updateSettingsJsonModels(current, nextModels),
+ updateSettingsJsonModels(current, normalizeModelMapping(nextModels)),
buildModelContextWindows(nextModels, nextInputs),
))
}
@@ -858,6 +869,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
const handleSubmit = async () => {
if (!canSubmit) return
+ const normalizedModels = normalizeModelMapping(models)
const parsedAutoCompactWindow = parseAutoCompactWindowInput(autoCompactWindow)
const parsedModelContextWindows = buildModelContextWindows(models, modelContextInputs)
setIsSubmitting(true)
@@ -882,7 +894,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
authStrategy,
baseUrl: baseUrl.trim(),
apiFormat,
- models,
+ models: normalizedModels,
...(parsedAutoCompactWindow !== undefined && { autoCompactWindow: parsedAutoCompactWindow }),
...(Object.keys(parsedModelContextWindows).length > 0 && { modelContextWindows: parsedModelContextWindows }),
notes: notes.trim() || undefined,
@@ -893,7 +905,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
baseUrl: baseUrl.trim(),
authStrategy,
apiFormat,
- models,
+ models: normalizedModels,
autoCompactWindow: parsedAutoCompactWindow ?? null,
modelContextWindows: Object.keys(parsedModelContextWindows).length > 0
? parsedModelContextWindows
diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts
index 10dcf615..0f4eefe3 100644
--- a/src/server/__tests__/conversation-service.test.ts
+++ b/src/server/__tests__/conversation-service.test.ts
@@ -276,6 +276,9 @@ describe('ConversationService', () => {
expect(env.ANTHROPIC_BASE_URL).toBe(`http://127.0.0.1:3456/proxy/providers/${provider.id}`)
expect(env.ANTHROPIC_API_KEY).toBe('proxy-managed')
expect(env.ANTHROPIC_MODEL).toBe('kimi-k2.6')
+ expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('kimi-k2.6')
+ expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('kimi-k2.6')
+ expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('kimi-k2.6')
expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1')
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined()
})
diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts
index b08e561c..40754b85 100644
--- a/src/server/__tests__/providers.test.ts
+++ b/src/server/__tests__/providers.test.ts
@@ -153,6 +153,28 @@ describe('ProviderService', () => {
expect(provider.models.main).toBe('model-main')
})
+ test('should normalize empty model mappings to the main model when adding a provider', async () => {
+ const svc = new ProviderService()
+ const provider = await svc.addProvider(sampleInput({
+ models: {
+ main: 'gpt-5.5',
+ haiku: '',
+ sonnet: ' ',
+ opus: '',
+ },
+ }))
+
+ expect(provider.models).toEqual({
+ main: 'gpt-5.5',
+ haiku: 'gpt-5.5',
+ sonnet: 'gpt-5.5',
+ opus: 'gpt-5.5',
+ })
+
+ const config = await readProvidersConfig()
+ expect((config.providers as Array<{ models: unknown }>)[0]?.models).toEqual(provider.models)
+ })
+
test('new providers should not be auto-activated', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput())
@@ -361,6 +383,27 @@ describe('ProviderService', () => {
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined()
})
+ test('should normalize empty model mappings before syncing settings', async () => {
+ const svc = new ProviderService()
+ const provider = await svc.addProvider(sampleInput({
+ models: {
+ main: 'gpt-5.5',
+ haiku: '',
+ sonnet: '',
+ opus: '',
+ },
+ }))
+
+ await svc.activateProvider(provider.id)
+
+ const settings = await readSettings()
+ const env = settings.env as Record
+ expect(env.ANTHROPIC_MODEL).toBe('gpt-5.5')
+ expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.5')
+ expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.5')
+ expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.5')
+ })
+
test('updating active provider should override and clear model context windows', async () => {
const svc = new ProviderService()
const added = await svc.addProvider(sampleInput({
diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts
index 2d4202f0..22ef7f80 100644
--- a/src/server/services/providerService.ts
+++ b/src/server/services/providerService.ts
@@ -85,6 +85,16 @@ function isSavedProvider(value: unknown): value is SavedProvider {
)
}
+function normalizeModelMapping(models: SavedProvider['models']): SavedProvider['models'] {
+ const main = models.main.trim()
+ return {
+ main,
+ haiku: models.haiku.trim() || main,
+ sonnet: models.sonnet.trim() || main,
+ opus: models.opus.trim() || main,
+ }
+}
+
function normalizeProvidersIndex(value: unknown): ProvidersIndex | null {
if (!isRecord(value) || !Array.isArray(value.providers)) {
return null
@@ -258,7 +268,7 @@ export class ProviderService {
...(input.authStrategy !== undefined && { authStrategy: input.authStrategy }),
baseUrl: input.baseUrl,
apiFormat: input.apiFormat ?? 'anthropic',
- models: input.models,
+ models: normalizeModelMapping(input.models),
...(input.autoCompactWindow !== undefined && { autoCompactWindow: input.autoCompactWindow }),
...(input.modelContextWindows !== undefined && { modelContextWindows: input.modelContextWindows }),
...(input.notes !== undefined && { notes: input.notes }),
@@ -282,7 +292,7 @@ export class ProviderService {
...(input.authStrategy !== undefined && { authStrategy: input.authStrategy }),
...(input.baseUrl !== undefined && { baseUrl: input.baseUrl }),
...(input.apiFormat !== undefined && { apiFormat: input.apiFormat }),
- ...(input.models !== undefined && { models: input.models }),
+ ...(input.models !== undefined && { models: normalizeModelMapping(input.models) }),
...(typeof input.autoCompactWindow === 'number' && { autoCompactWindow: input.autoCompactWindow }),
...(input.modelContextWindows !== undefined && input.modelContextWindows !== null && { modelContextWindows: input.modelContextWindows }),
...(input.notes !== undefined && { notes: input.notes }),