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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
37 changes: 37 additions & 0 deletions desktop/src/components/settings/ProviderFormModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ProviderFormModal open={true} onClose={onClose} mode="edit" provider={provider} presets={[deepseekPreset]} />,
)

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,
Expand Down Expand Up @@ -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(
<ProviderFormModal open={true} onClose={vi.fn()} mode="edit" provider={provider} presets={[deepseekPreset]} />,
)
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(
<ProviderFormModal open={true} onClose={vi.fn()} mode="edit" provider={provider} presets={[deepseekPreset]} />,
Expand Down
14 changes: 14 additions & 0 deletions desktop/src/components/settings/ProviderFormModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiFormat>(provider?.apiFormat ?? initialPreset.apiFormat ?? 'anthropic')
const [modelsApiFormat, setModelsApiFormat] = useState<ApiFormat>(apiFormat)
const [authStrategy, setAuthStrategy] = useState<ProviderAuthStrategy>(provider?.authStrategy ?? getPresetAuthStrategy(initialPreset))
const [apiKey, setApiKey] = useState(provider?.apiKey ?? '')
const [showApiKey, setShowApiKey] = useState(false)
Expand All @@ -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<string | null>(null)
const [saveError, setSaveError] = useState<string | null>(null)
const [discoveryCount, setDiscoveryCount] = useState<number | null>(null)
const [discoveredModels, setDiscoveredModels] = useState<ProviderModel[]>([])
const [newModelId, setNewModelId] = useState('')
Expand Down Expand Up @@ -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 ?? '')
Expand Down Expand Up @@ -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([])
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -870,6 +876,11 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, onCreated }
width={720}
footer={
<>
{saveError && (
<p role="alert" className="mr-auto self-center text-xs text-[var(--color-error)] [overflow-wrap:anywhere]">
{t('settings.providers.saveFailed', { error: saveError })}
</p>
)}
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={handleSubmit} disabled={!canSubmit} loading={isSubmitting}>
{mode === 'create' ? t('common.add') : t('common.save')}
Expand Down Expand Up @@ -996,6 +1007,9 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, onCreated }
{discoveryError && (
<p role="alert" className="mb-2 text-[11px] text-[var(--color-error)]">{discoveryError}</p>
)}
{models.length > 0 && apiFormat !== modelsApiFormat && (
<p role="status" className="mb-2 text-[11px] text-[var(--color-warning)]">{t('settings.providers.apiFormatChangedModels')}</p>
)}
{discoveryCount !== null && (
<p role="status" className="mb-2 text-[11px] text-[var(--color-text-secondary)]">
{discoveryCount > 0
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ export const en = {
'settings.providers.customPreset': 'Custom Provider',
'settings.providers.connected': 'Connected ({latency}ms)',
'settings.providers.failed': 'Failed: {error}',
'settings.providers.saveFailed': 'Could not save: {error}',
'settings.providers.connectivityOk': 'Connected ({latency}ms)',
'settings.providers.connectivityFailed': 'Connection failed: {error}',
'settings.providers.confirmDelete': 'Delete provider "{name}"? This cannot be undone.',
Expand All @@ -318,6 +319,7 @@ export const en = {
'settings.providers.models': 'Models',
'settings.providers.fetchModels': 'Fetch models',
'settings.providers.modelsFound': '{count} models found',
'settings.providers.apiFormatChangedModels': 'Model IDs can differ between API formats. Fetch models again and check the list below.',
'settings.providers.noModelsReturned': 'No models returned',
'settings.providers.noModelMatches': 'No matching models',
'settings.providers.modelId': 'Model ID',
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.customPreset': '自定义服务商',
'settings.providers.connected': '已连接 ({latency}ms)',
'settings.providers.failed': '失败: {error}',
'settings.providers.saveFailed': '保存失败: {error}',
'settings.providers.connectivityOk': '连接成功 ({latency}ms)',
'settings.providers.connectivityFailed': '连接失败: {error}',
'settings.providers.confirmDelete': '删除服务商 "{name}"?此操作不可撤销。',
Expand All @@ -320,6 +321,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.models': '模型',
'settings.providers.fetchModels': '获取模型',
'settings.providers.modelsFound': '已找到 {count} 个模型',
'settings.providers.apiFormatChangedModels': '不同 API 格式下的模型 ID 可能不同,请重新获取模型并检查下面的列表。',
'settings.providers.noModelsReturned': '未返回任何模型',
'settings.providers.noModelMatches': '没有匹配的模型',
'settings.providers.modelId': '模型 ID',
Expand Down
27 changes: 26 additions & 1 deletion src/server/__tests__/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1277,7 +1277,7 @@ describe('Providers API', () => {
expect(res.status).toBe(400)
})

test('POST /api/providers/models should return 502 for upstream authentication errors', async () => {
test('POST /api/providers/models should return 400 when the upstream rejects the credentials', async () => {
const server = Bun.serve({
hostname: '127.0.0.1',
port: 0,
Expand All @@ -1295,6 +1295,31 @@ describe('Providers API', () => {
const res = await handleProvidersApi(req, url, segments)
const body = (await res.json()) as { error: string }

expect(res.status).toBe(400)
expect(body.error).toBe('MODEL_DISCOVERY_FAILED')
} finally {
server.stop(true)
}
})

test('POST /api/providers/models should return 502 when the upstream fails with a server error', async () => {
const server = Bun.serve({
hostname: '127.0.0.1',
port: 0,
fetch() {
return new Response('boom', { status: 503 })
},
})

try {
const { req, url, segments } = makeRequest('POST', '/api/providers/models', {
baseUrl: `http://127.0.0.1:${server.port}/v1`,
apiKey: 'sk-test',
apiFormat: 'openai_chat',
})
const res = await handleProvidersApi(req, url, segments)
const body = (await res.json()) as { error: string }

expect(res.status).toBe(502)
expect(body.error).toBe('MODEL_DISCOVERY_FAILED')
} finally {
Expand Down
3 changes: 2 additions & 1 deletion src/server/services/providerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,8 @@ export class ProviderService {
private async fetchModels(target: UpstreamTarget): Promise<ProviderModel[]> {
const result = await fetchUpstreamModels(target)
if (!result.ok) {
throw new ApiError(502, result.error, 'MODEL_DISCOVERY_FAILED')
const upstreamRejected = result.httpStatus !== undefined && result.httpStatus >= 400 && result.httpStatus < 500
throw new ApiError(upstreamRejected ? 400 : 502, result.error, 'MODEL_DISCOVERY_FAILED')
}
return result.models
}
Expand Down
Loading