Prevent empty provider model slots from reaching runtime

Provider presets and custom entries can leave haiku, sonnet, or opus blank when the main model is the intended fallback. Normalize those mappings at both the Settings UI and ProviderService boundaries so persisted provider config and generated runtime env stay consistent.

Constraint: Users may configure only a main model for custom providers.
Rejected: Keep blank secondary model slots | runtime env would still receive empty ANTHROPIC_DEFAULT_* values.
Confidence: high
Scope-risk: narrow
Directive: Keep UI and ProviderService normalization aligned for provider model mapping changes.
Tested: Not run per maintainer request.
Not-tested: Local test suite skipped by request.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-19 01:23:30 +08:00
parent 611ebd0645
commit dbe72e9668
5 changed files with 133 additions and 9 deletions

View File

@ -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(<Settings />)
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 = [
{

View File

@ -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<string, unknown> }
@ -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<string, string>) || {}
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

View File

@ -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()
})

View File

@ -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<string, string>
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({

View File

@ -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 }),