From c29388fa47f4b6a4037e57ff9cfc984340f7afde Mon Sep 17 00:00:00 2001 From: Diao Shengjia <104132148+GoDiao@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:20:00 +0800 Subject: [PATCH] fix(providers): show save errors and flag model ids after an API format change - The provider form shows the server's error when a save is rejected and stays open, instead of a Save button that does nothing. - Model discovery returns 400 again when the upstream rejects the request (4xx) and 502 for other failures. - Changing the API format while the list has models shows a hint to fetch models again, since providers like OpenRouter return different ids per format. --- CHANGELOG.md | 3 ++ .../settings/ProviderFormModal.test.tsx | 37 +++++++++++++++++++ .../components/settings/ProviderFormModal.tsx | 14 +++++++ desktop/src/i18n/locales/en.ts | 2 + desktop/src/i18n/locales/zh.ts | 2 + src/server/__tests__/providers.test.ts | 27 +++++++++++++- src/server/services/providerService.ts | 3 +- 7 files changed, 86 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af612f6..3f17d1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ All notable changes to DreamCoder will be documented in this file. - Groups of tool calls, such as "Read 3 files", are collapsed by default, also while they run. A group with a failed step stays collapsed and shows a red marker (#150). - **Thinking and tool calls between two pieces of reply text fold into one group** (#170), so a short turn no longer pushes the answer far down the page. The summary counts thinking too, e.g. "ran 2 commands, read 1 file, thought 4 times", and the expanded group lists the steps in their original order. Thinking with no tool calls around it is still its own row. - **Faster desktop startup** (#152, #161): KaTeX, Mermaid, the diff viewer, the Doctor panel, the Settings pages, the workspace panel and session search load the first time they are needed, which cuts the JavaScript loaded at startup by about half. Thanks @ibrahimmaq14. +- **A provider holds any number of models, in order, and an optional fast model** (#159). The first model is the main model. Settings → Providers can fetch the provider's model list and pick from it, and the generated settings JSON follows the list. Session titles use the fast model, or the session's model when there is none. Thanks @AIR-Xie. + - Existing providers are migrated on first start, and the old file is kept as `providers.json.bak-before-migration-*`. Older versions cannot read the migrated file: restore that backup before downgrading. ### Removed @@ -41,6 +43,7 @@ All notable changes to DreamCoder will be documented in this file. - **Smoother streaming replies** (#154, #171): a reply that is still streaming is no longer re-parsed and redrawn in full on every update. Finished paragraphs, lists, tables and code blocks are drawn once and stay in place; only the part still being written is redrawn. Text selected in the finished part stays selected while the reply continues. Code in a streaming reply uses the lighter highlighter and gets full highlighting when the reply ends, so code blocks no longer flicker. Following the bottom of the reply now scrolls at most once per frame. - New sessions start on the active provider's main model. Before, activating a provider saved its main model in a hidden setting, and that saved value kept overriding the provider after its main model was edited (#150). - The selected model card in the dark theme had light pink colors with text that was hard to read (#150). +- Saving a provider that the server rejects now shows the error in the form. Before, the Save button did nothing and the form gave no sign of why (#175). ## [0.6.1] — 2026-10-21 diff --git a/desktop/src/components/settings/ProviderFormModal.test.tsx b/desktop/src/components/settings/ProviderFormModal.test.tsx index 857035f..9bf7e20 100644 --- a/desktop/src/components/settings/ProviderFormModal.test.tsx +++ b/desktop/src/components/settings/ProviderFormModal.test.tsx @@ -248,6 +248,25 @@ describe('ProviderFormModal edit mode', () => { }) }) + it('shows the save error and keeps the modal open when saving fails', async () => { + updateProviderMock.mockRejectedValue(new Error('Base URL is invalid')) + const onClose = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + expect(await screen.findByText('Could not save: Base URL is invalid')).toBeInTheDocument() + expect(onClose).not.toHaveBeenCalled() + + updateProviderMock.mockResolvedValue(provider) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)) + expect(screen.queryByText(/Could not save/)).not.toBeInTheDocument() + }) + it('does not restore the preset fast model when editing a provider without one', async () => { const providerWithoutFast: SavedProvider = { ...provider, @@ -405,6 +424,24 @@ describe('ProviderFormModal edit mode', () => { expect(screen.getByRole('status')).toHaveTextContent('2 models found') }) + it('asks to fetch models again after the API format changes until a fetch succeeds', async () => { + render( + , + ) + const hint = 'Model IDs can differ between API formats. Fetch models again and check the list below.' + expect(screen.queryByText(hint)).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /Anthropic Messages/ })) + fireEvent.click(screen.getByRole('button', { name: /OpenAI Chat Completions/ })) + expect(screen.getByText(hint)).toBeInTheDocument() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Fetch models' })) + }) + expect(discoverModelsMock).toHaveBeenCalledWith(expect.objectContaining({ apiFormat: 'openai_chat' })) + expect(screen.queryByText(hint)).not.toBeInTheDocument() + }) + it('clears discovered models when the connection changes and ignores stale responses', async () => { render( , diff --git a/desktop/src/components/settings/ProviderFormModal.tsx b/desktop/src/components/settings/ProviderFormModal.tsx index 97165db..da7497b 100644 --- a/desktop/src/components/settings/ProviderFormModal.tsx +++ b/desktop/src/components/settings/ProviderFormModal.tsx @@ -484,6 +484,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, onCreated } const [name, setName] = useState(provider?.name ?? getPresetDisplayName(initialPreset, t)) const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl) const [apiFormat, setApiFormat] = useState(provider?.apiFormat ?? initialPreset.apiFormat ?? 'anthropic') + const [modelsApiFormat, setModelsApiFormat] = useState(apiFormat) const [authStrategy, setAuthStrategy] = useState(provider?.authStrategy ?? getPresetAuthStrategy(initialPreset)) const [apiKey, setApiKey] = useState(provider?.apiKey ?? '') const [showApiKey, setShowApiKey] = useState(false) @@ -506,6 +507,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, onCreated } const [isTesting, setIsTesting] = useState(false) const [isDiscovering, setIsDiscovering] = useState(false) const [discoveryError, setDiscoveryError] = useState(null) + const [saveError, setSaveError] = useState(null) const [discoveryCount, setDiscoveryCount] = useState(null) const [discoveredModels, setDiscoveredModels] = useState([]) const [newModelId, setNewModelId] = useState('') @@ -581,6 +583,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, onCreated } setName(getPresetDisplayName(preset, t)) setBaseUrl(preset.baseUrl) setApiFormat(preset.apiFormat ?? 'anthropic') + setModelsApiFormat(preset.apiFormat ?? 'anthropic') setAuthStrategy(getPresetAuthStrategy(preset)) setModels(preset.defaultModels.map((model) => ({ ...model }))) setFastModel(preset.defaultFastModel ?? '') @@ -731,6 +734,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, onCreated } if (requestId !== discoveryRequestRef.current) return setDiscoveredModels(fetched) setDiscoveryCount(fetched.length) + setModelsApiFormat(apiFormat) } catch (err) { if (requestId !== discoveryRequestRef.current) return setDiscoveredModels([]) @@ -779,6 +783,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, onCreated } const parsedAutoCompactWindow = parseAutoCompactWindowInput(autoCompactWindow) const parsedModelContextWindows = buildModelContextWindows(models, modelContextInputs) setIsSubmitting(true) + setSaveError(null) try { if (settingsJson.trim()) { try { @@ -825,6 +830,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, onCreated } onClose() } catch (err) { console.error('Failed to save provider:', err) + setSaveError(err instanceof Error ? err.message : String(err)) } finally { setIsSubmitting(false) } @@ -870,6 +876,11 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, onCreated } width={720} footer={ <> + {saveError && ( +

+ {t('settings.providers.saveFailed', { error: saveError })} +

+ )}