From 165cb1777a4246d1b5f6e31a24f9ad89fea4c78c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 28 Apr 2026 17:35:56 +0800 Subject: [PATCH] fix: keep provider sessions on compatible model capabilities Third-party Anthropic-compatible providers can expose Claude model names without supporting every first-party runtime capability. Desktop sessions now preserve provider-specific capability overrides, keep selected provider runtime state across reconnects and restarts, and keep the provider dialog behavior aligned with the updated presets. Constraint: Custom ANTHROPIC_BASE_URL endpoints must not be treated as first-party Anthropic for adaptive thinking support. Rejected: Rename provider default Sonnet models | that would hide the compatibility issue instead of fixing runtime capability detection. Confidence: high Scope-risk: moderate Directive: Do not enable Claude first-party capability defaults for custom Anthropic-compatible base URLs without provider verification. Tested: bun test src/utils/__tests__/thinking.test.ts src/server/__tests__/conversation-service.test.ts src/server/__tests__/provider-presets.test.ts src/server/__tests__/conversations.test.ts Tested: cd desktop && bun run lint Tested: cd desktop && bun run test src/components/shared/Modal.test.tsx --run Tested: git diff --check --- desktop/src/components/shared/Modal.test.tsx | 36 ++++++ desktop/src/components/shared/Modal.tsx | 7 +- desktop/src/pages/EmptySession.tsx | 6 +- desktop/src/pages/Settings.tsx | 50 +++++++- .../__tests__/conversation-service.test.ts | 27 +++++ src/server/__tests__/conversations.test.ts | 109 ++++++++++++++++++ src/server/__tests__/provider-presets.test.ts | 2 + src/server/config/providerPresets.json | 10 +- src/server/services/conversationService.ts | 6 + src/server/services/providerService.ts | 3 + src/server/ws/handler.ts | 15 +++ src/utils/__tests__/thinking.test.ts | 77 +++++++++++++ src/utils/model/modelSupportOverrides.ts | 4 +- src/utils/thinking.ts | 16 ++- 14 files changed, 349 insertions(+), 19 deletions(-) create mode 100644 desktop/src/components/shared/Modal.test.tsx create mode 100644 src/utils/__tests__/thinking.test.ts diff --git a/desktop/src/components/shared/Modal.test.tsx b/desktop/src/components/shared/Modal.test.tsx new file mode 100644 index 00000000..69958905 --- /dev/null +++ b/desktop/src/components/shared/Modal.test.tsx @@ -0,0 +1,36 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { Modal } from './Modal' + +describe('Modal', () => { + it('portals the dialog to body so the scrim covers the full app shell', () => { + const onClose = vi.fn() + const { container } = render( +
+ + Provider form + +
, + ) + + const dialog = screen.getByRole('dialog', { name: 'Provider' }) + + expect(container.contains(dialog)).toBe(false) + expect(document.body.contains(dialog)).toBe(true) + }) + + it('closes when the backdrop is clicked', () => { + const onClose = vi.fn() + render( + + Provider form + , + ) + + const backdrop = screen.getByRole('dialog').previousElementSibling + expect(backdrop).not.toBeNull() + fireEvent.click(backdrop!) + + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/desktop/src/components/shared/Modal.tsx b/desktop/src/components/shared/Modal.tsx index c1f5b278..adaa82c5 100644 --- a/desktop/src/components/shared/Modal.tsx +++ b/desktop/src/components/shared/Modal.tsx @@ -1,4 +1,5 @@ import { useEffect, type ReactNode } from 'react' +import { createPortal } from 'react-dom' type ModalProps = { open: boolean @@ -21,7 +22,7 @@ export function Modal({ open, onClose, title, children, width = 560, footer }: M if (!open) return null - return ( + return createPortal(
{/* Backdrop */}
{title && (
@@ -60,6 +62,7 @@ export function Modal({ open, onClose, title, children, width = 560, footer }: M
)}
-
+ , + document.body, ) } diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index fb764f2c..34c0f065 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -58,7 +58,6 @@ export function EmptySession() { const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([]) const createSession = useSessionStore((state) => state.createSession) const sendMessage = useChatStore((state) => state.sendMessage) - const setSessionRuntime = useChatStore((state) => state.setSessionRuntime) const connectToSession = useChatStore((state) => state.connectToSession) const setActiveView = useUIStore((state) => state.setActiveView) const addToast = useUIStore((state) => state.addToast) @@ -233,12 +232,11 @@ export function EmptySession() { modelId: settings.currentModel?.id ?? OFFICIAL_DEFAULT_MODEL_ID, } const sessionId = await createSession(workDir || undefined) + useSessionRuntimeStore.getState().setSelection(sessionId, draftSelection) + useSessionRuntimeStore.getState().clearSelection(DRAFT_RUNTIME_SELECTION_KEY) setActiveView('code') useTabStore.getState().openTab(sessionId, 'New Session') connectToSession(sessionId) - useSessionRuntimeStore.getState().setSelection(sessionId, draftSelection) - useSessionRuntimeStore.getState().clearSelection(DRAFT_RUNTIME_SELECTION_KEY) - setSessionRuntime(sessionId, draftSelection) const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({ type: attachment.type, name: attachment.name, diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 3e95bc25..5ff8c76a 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -358,6 +358,39 @@ function openExternalUrl(url: string) { .catch(() => window.open(url, '_blank', 'noopener,noreferrer')) } +const API_KEY_JSON_PLACEHOLDER = '••••••••' +const API_KEY_JSON_KEYS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'] as const + +function maskSettingsJsonSecrets(raw: string, apiKey: string): string { + if (!apiKey.trim()) return raw + try { + const parsed = JSON.parse(raw) as { env?: Record } + if (!parsed.env || typeof parsed.env !== 'object') return raw + let changed = false + for (const key of API_KEY_JSON_KEYS) { + if (parsed.env[key] === apiKey) { + parsed.env[key] = API_KEY_JSON_PLACEHOLDER + changed = true + } + } + return changed ? JSON.stringify(parsed, null, 2) : raw + } catch { + return raw + } +} + +function restoreSettingsJsonSecrets(settings: T, apiKey: string): T { + if (!apiKey.trim() || !settings || typeof settings !== 'object') return settings + const parsed = settings as { env?: Record } + if (!parsed.env || typeof parsed.env !== 'object') return settings + for (const key of API_KEY_JSON_KEYS) { + if (parsed.env[key] === API_KEY_JSON_PLACEHOLDER) { + parsed.env[key] = apiKey + } + } + return settings +} + function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderFormProps) { const { createProvider, updateProvider, testConfig } = useProviderStore() const fetchSettings = useSettingsStore((s) => s.fetchAll) @@ -446,6 +479,9 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || !requiresApiKey || apiKey.trim()) && models.main.trim() && !settingsJsonError const apiKeyUrl = selectedPreset.apiKeyUrl?.trim() const promoText = selectedPreset.promoText?.trim() + const displayedSettingsJson = showApiKey + ? settingsJson + : maskSettingsJsonSecrets(settingsJson, apiKey) const apiFormatItems = [ { value: 'anthropic' as const, @@ -486,7 +522,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF // settings never conflict with the user's global ~/.claude/settings.json. if (settingsJson.trim()) { try { - const parsed = JSON.parse(settingsJson) + const parsed = restoreSettingsJsonSecrets(JSON.parse(settingsJson), apiKey) const { providersApi } = await import('../api/providers') await providersApi.updateSettings(parsed) } catch { @@ -720,12 +756,12 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF