From d5468d0ef8ff09bd6125e8382de5159de0d52bc9 Mon Sep 17 00:00:00 2001 From: Weilin Cai <1261249659@qq.com> Date: Sun, 5 Apr 2026 12:48:08 +0800 Subject: [PATCH 1/3] feat: add cc-switch compatibility mode --- apps/site/content/docs/en/providers.mdx | 2 + apps/site/content/docs/zh/providers.mdx | 2 + src/__tests__/unit/provider-options.test.ts | 38 +++++++++++++++++++ .../unit/stale-default-provider.test.ts | 37 ++++++++++++++++++ src/app/api/providers/models/route.ts | 4 +- src/app/api/providers/options/route.ts | 4 +- src/app/api/settings/app/route.ts | 1 + src/app/chat/page.tsx | 37 +++++++++++++++--- src/components/chat/ChatView.tsx | 33 ++++++++++++++-- src/components/settings/GeneralSection.tsx | 36 ++++++++++++++++++ src/hooks/useCcSwitchCompatMode.ts | 31 +++++++++++++++ src/i18n/en.ts | 2 + src/i18n/zh.ts | 2 + src/lib/db.ts | 3 ++ src/lib/provider-doctor.ts | 13 ++++++- src/types/index.ts | 1 + 16 files changed, 232 insertions(+), 14 deletions(-) create mode 100644 src/__tests__/unit/provider-options.test.ts create mode 100644 src/hooks/useCcSwitchCompatMode.ts diff --git a/apps/site/content/docs/en/providers.mdx b/apps/site/content/docs/en/providers.mdx index ea8ba47f..7abc0a90 100644 --- a/apps/site/content/docs/en/providers.mdx +++ b/apps/site/content/docs/en/providers.mdx @@ -21,6 +21,8 @@ export ANTHROPIC_API_KEY="sk-ant-..." > **Note:** Configurations changed via `claude config set` or Claude Code's `/config` command are **not recognized by CodePilot**. CodePilot only reads shell environment variables and does not share Claude Code CLI's internal configuration. If you switched accounts/keys in the CLI via `cc switch` or similar methods, you need to manually reconfigure the corresponding key in CodePilot's **Settings > Providers**. +> If you mainly manage Claude Code through **cc-switch**, you can enable **Settings > General > Enable cc-switch Compatibility Mode**. This keeps the built-in `env` provider as a valid default target and remembers the selected effort level for each provider, so new chats start with the same defaults more reliably. + > After modifying environment variables, you need to **restart CodePilot** for changes to take effect. ### 2. Manually Adding Providers diff --git a/apps/site/content/docs/zh/providers.mdx b/apps/site/content/docs/zh/providers.mdx index 79c0638e..eef530b4 100644 --- a/apps/site/content/docs/zh/providers.mdx +++ b/apps/site/content/docs/zh/providers.mdx @@ -21,6 +21,8 @@ export ANTHROPIC_API_KEY="sk-ant-..." > **注意:** 通过 `claude config set` 或 Claude Code 的 `/config` 命令切换的配置**不会被 CodePilot 识别**。CodePilot 只读取 shell 环境变量,不共享 Claude Code CLI 的内部配置。如果你在 CLI 中通过 `cc switch` 或类似方式切换了账号/密钥,需要在 CodePilot 的 **设置 > 服务商** 中重新手动配置对应的密钥。 +> 如果你主要通过 **cc-switch** 管理 Claude Code,可以打开 **设置 > 通用 > 开启 cc-switch 兼容模式**。这样会把内置 `env` 服务商继续视为有效默认目标,并记住各服务商所选的 effort,让新对话更稳定地沿用同一套默认值。 + > 修改环境变量后需要**重启 CodePilot** 才能生效。 ### 2. 手动添加服务商 diff --git a/src/__tests__/unit/provider-options.test.ts b/src/__tests__/unit/provider-options.test.ts new file mode 100644 index 00000000..71796811 --- /dev/null +++ b/src/__tests__/unit/provider-options.test.ts @@ -0,0 +1,38 @@ +import { afterEach, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { getProviderOptions, setProviderOptions, getSetting, setSetting } from '../../lib/db'; + +describe('Provider options', () => { + let savedThinking: string | undefined; + let savedContext1m: string | undefined; + let savedEffort: string | undefined; + + beforeEach(() => { + savedThinking = getSetting('thinking_mode'); + savedContext1m = getSetting('context_1m'); + savedEffort = getSetting('effort'); + setSetting('thinking_mode', ''); + setSetting('context_1m', ''); + setSetting('effort', ''); + }); + + afterEach(() => { + setSetting('thinking_mode', savedThinking || ''); + setSetting('context_1m', savedContext1m || ''); + setSetting('effort', savedEffort || ''); + }); + + it('env provider options round-trip effort persistence', () => { + setProviderOptions('env', { + thinking_mode: 'enabled', + context_1m: true, + effort: 'max', + }); + + const options = getProviderOptions('env'); + assert.equal(options.thinking_mode, 'enabled'); + assert.equal(options.context_1m, true); + assert.equal(options.effort, 'max'); + }); +}); diff --git a/src/__tests__/unit/stale-default-provider.test.ts b/src/__tests__/unit/stale-default-provider.test.ts index 09dbf9f6..ec7f4155 100644 --- a/src/__tests__/unit/stale-default-provider.test.ts +++ b/src/__tests__/unit/stale-default-provider.test.ts @@ -62,12 +62,15 @@ describe('Stale default_provider_id cleanup', () => { // Save and restore original default + global default model provider let originalDefault: string | undefined; let originalGlobalProvider: string | undefined; + let originalCompatMode: string | undefined; beforeEach(() => { originalDefault = getDefaultProviderId(); originalGlobalProvider = getSetting('global_default_model_provider') || undefined; + originalCompatMode = getSetting('cc_switch_compat_mode') || undefined; // Clear global_default_model_provider so these tests exercise the legacy path setSetting('global_default_model_provider', ''); + setSetting('cc_switch_compat_mode', ''); cleanupTestProviders(); }); @@ -75,6 +78,7 @@ describe('Stale default_provider_id cleanup', () => { cleanupTestProviders(); // Restore originals setSetting('global_default_model_provider', originalGlobalProvider || ''); + setSetting('cc_switch_compat_mode', originalCompatMode || ''); if (originalDefault) { setDefaultProviderId(originalDefault); } @@ -150,6 +154,39 @@ describe('Stale default_provider_id cleanup', () => { }); }); + describe('providers/models route with cc-switch compat mode', () => { + it('keeps env as default_provider_id when compat mode is enabled', async () => { + const providerId = createTestProvider('__test_real_provider'); + setDefaultProviderId('env'); + setSetting('cc_switch_compat_mode', 'true'); + + const { GET } = await import('../../app/api/providers/models/route'); + const response = await GET(); + const data = await response.json() as { default_provider_id: string }; + + assert.equal(data.default_provider_id, 'env'); + assert.equal(getDefaultProviderId(), 'env'); + + deleteProvider(providerId); + }); + + it('still auto-heals env default when compat mode is disabled', async () => { + const providerId = createTestProvider('__test_real_provider'); + setDefaultProviderId('env'); + setSetting('cc_switch_compat_mode', ''); + + const { GET } = await import('../../app/api/providers/models/route'); + const response = await GET(); + const data = await response.json() as { default_provider_id: string }; + + assert.notEqual(data.default_provider_id, 'env'); + assert.ok(getProvider(data.default_provider_id), 'auto-healed default should point to a real provider'); + assert.equal(getDefaultProviderId(), data.default_provider_id); + + deleteProvider(providerId); + }); + }); + describe('error-classifier categorizes stale default correctly', () => { it('classifyError produces PROCESS_CRASH for exit code 1', async () => { const { classifyError } = await import('../../lib/error-classifier'); diff --git a/src/app/api/providers/models/route.ts b/src/app/api/providers/models/route.ts index 740d97e3..bb0d4f3b 100644 --- a/src/app/api/providers/models/route.ts +++ b/src/app/api/providers/models/route.ts @@ -43,6 +43,7 @@ export async function GET() { try { const providers = getAllProviders(); const groups: ProviderModelGroup[] = []; + const ccSwitchCompatMode = getSetting('cc_switch_compat_mode') === 'true'; // Always show the built-in Claude Code provider group. // Mark it as sdkProxyOnly if no direct API credentials exist — in that case @@ -192,7 +193,8 @@ export async function GET() { // Determine default provider — auto-heal stale references on read let defaultProviderId = getDefaultProviderId(); - if (defaultProviderId && !getProvider(defaultProviderId)) { + const defaultIsCompatEnv = ccSwitchCompatMode && defaultProviderId === 'env'; + if (defaultProviderId && !defaultIsCompatEnv && !getProvider(defaultProviderId)) { // Stale default (provider was deleted). Fix it now. const firstValid = groups.find(g => g.provider_id !== 'env'); defaultProviderId = firstValid?.provider_id || ''; diff --git a/src/app/api/providers/options/route.ts b/src/app/api/providers/options/route.ts index 36cf0676..d77713f1 100644 --- a/src/app/api/providers/options/route.ts +++ b/src/app/api/providers/options/route.ts @@ -4,7 +4,7 @@ import type { ProviderOptions } from '@/types'; /** * GET /api/providers/options?providerId=xxx - * Returns per-provider options (thinking_mode, context_1m). + * Returns per-provider options (thinking_mode, context_1m, effort). */ export async function GET(request: NextRequest) { const providerId = request.nextUrl.searchParams.get('providerId') || 'env'; @@ -14,7 +14,7 @@ export async function GET(request: NextRequest) { /** * PUT /api/providers/options - * Update per-provider options. Body: { providerId, options: { thinking_mode?, context_1m? } } + * Update per-provider options. Body: { providerId, options: { thinking_mode?, context_1m?, effort? } } */ export async function PUT(request: NextRequest) { try { diff --git a/src/app/api/settings/app/route.ts b/src/app/api/settings/app/route.ts index d2c1ec93..4781f107 100644 --- a/src/app/api/settings/app/route.ts +++ b/src/app/api/settings/app/route.ts @@ -13,6 +13,7 @@ const ALLOWED_KEYS = [ 'generative_ui_enabled', 'locale', 'thinking_mode', + 'cc_switch_compat_mode', 'theme_mode', 'theme_family', 'default_panel', diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 777a9661..5173d343 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -17,6 +17,7 @@ import { FolderPicker } from '@/components/chat/FolderPicker'; import { useNativeFolderPicker } from '@/hooks/useNativeFolderPicker'; import { useTranslation } from '@/hooks/useTranslation'; import { usePanel } from '@/hooks/usePanel'; +import { useCcSwitchCompatMode } from '@/hooks/useCcSwitchCompatMode'; interface ToolUseInfo { id: string; @@ -41,6 +42,7 @@ export default function NewChatPage() { const { setPendingApprovalSessionId } = usePanel(); const { t } = useTranslation(); const { isElectron, openNativePicker } = useNativeFolderPicker(); + const { enabled: ccSwitchCompatMode, ready: compatModeReady } = useCcSwitchCompatMode(); const [messages, setMessages] = useState([]); const [streamingContent, setStreamingContent] = useState(''); const [isStreaming, setIsStreaming] = useState(false); @@ -88,22 +90,45 @@ export default function NewChatPage() { // Provider options (thinking mode + 1M context) const [thinkingMode, setThinkingMode] = useState('adaptive'); const [context1m, setContext1m] = useState(false); + const [providerOptionsReady, setProviderOptionsReady] = useState(false); // Fetch provider-specific options (with abort to prevent stale responses on fast switch) useEffect(() => { const pid = currentProviderId || 'env'; const controller = new AbortController(); + setProviderOptionsReady(false); fetch(`/api/providers/options?providerId=${encodeURIComponent(pid)}`, { signal: controller.signal }) .then(r => r.ok ? r.json() : null) .then(data => { if (!controller.signal.aborted) { setThinkingMode(data?.options?.thinking_mode || 'adaptive'); setContext1m(!!data?.options?.context_1m); + if (ccSwitchCompatMode) { + setSelectedEffort(data?.options?.effort || undefined); + } } }) - .catch(() => {}); + .catch(() => {}) + .finally(() => { + if (!controller.signal.aborted) { + setProviderOptionsReady(true); + } + }); return () => controller.abort(); - }, [currentProviderId]); + }, [currentProviderId, ccSwitchCompatMode]); + + const handleEffortChange = useCallback((effort: string | undefined) => { + setSelectedEffort(effort); + if (!ccSwitchCompatMode) return; + fetch('/api/providers/options', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + providerId: currentProviderId || 'env', + options: { effort: effort || '' }, + }), + }).catch(() => {}); + }, [ccSwitchCompatMode, currentProviderId]); // Validate restored model/provider against actual available providers/models. // For NEW conversations, the global default model takes priority @@ -431,7 +456,7 @@ export default function NewChatPage() { if (isStreaming) return; // Wait for model/provider to be resolved from the global default before allowing send - if (!modelReady) return; + if (!modelReady || !compatModeReady || (ccSwitchCompatMode && !providerOptionsReady)) return; // Require a project directory before sending if (!workingDir.trim()) { @@ -702,7 +727,7 @@ export default function NewChatPage() { abortControllerRef.current = null; } }, - [isStreaming, router, workingDir, mode, currentModel, currentProviderId, permissionProfile, selectedEffort, thinkingMode, context1m, setPendingApprovalSessionId, t, hasProvider, modelReady] + [isStreaming, router, workingDir, mode, currentModel, currentProviderId, permissionProfile, selectedEffort, thinkingMode, context1m, setPendingApprovalSessionId, t, hasProvider, modelReady, compatModeReady, ccSwitchCompatMode, providerOptionsReady] ); const handleCommand = useCallback((command: string) => { @@ -800,7 +825,7 @@ export default function NewChatPage() { onSend={sendFirstMessage} onCommand={handleCommand} onStop={stopStreaming} - disabled={!modelReady} + disabled={!modelReady || !compatModeReady || (ccSwitchCompatMode && !providerOptionsReady)} isStreaming={isStreaming} modelName={currentModel} onModelChange={setCurrentModel} @@ -813,7 +838,7 @@ export default function NewChatPage() { }} workingDirectory={workingDir} effort={selectedEffort} - onEffortChange={setSelectedEffort} + onEffortChange={handleEffortChange} initialValue={prefillText} /> (initialMessages); const [permissionProfile, setPermissionProfile] = useState<'default' | 'full_access'>(initialPermissionProfile || 'default'); @@ -110,6 +112,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal const [thinkingMode, setThinkingMode] = useState('adaptive'); const [context1m, setContext1m] = useState(false); const [hasSummary, setHasSummary] = useState(initialHasSummary || false); + const [providerOptionsReady, setProviderOptionsReady] = useState(false); // Sync model/provider when session data loads useEffect(() => { if (modelName) setCurrentModel(modelName); }, [modelName]); @@ -119,19 +122,41 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal useEffect(() => { const pid = currentProviderId || 'env'; const controller = new AbortController(); + setProviderOptionsReady(false); fetch(`/api/providers/options?providerId=${encodeURIComponent(pid)}`, { signal: controller.signal }) .then(r => r.ok ? r.json() : null) .then(data => { if (!controller.signal.aborted) { setThinkingMode(data?.options?.thinking_mode || 'adaptive'); setContext1m(!!data?.options?.context_1m); + if (ccSwitchCompatMode) { + setSelectedEffort(data?.options?.effort || undefined); + } } }) - .catch(() => {}); + .catch(() => {}) + .finally(() => { + if (!controller.signal.aborted) { + setProviderOptionsReady(true); + } + }); return () => controller.abort(); - }, [currentProviderId]); + }, [currentProviderId, ccSwitchCompatMode]); useEffect(() => { if (initialPermissionProfile) setPermissionProfile(initialPermissionProfile); }, [initialPermissionProfile]); + const handleEffortChange = useCallback((effort: string | undefined) => { + setSelectedEffort(effort); + if (!ccSwitchCompatMode) return; + fetch('/api/providers/options', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + providerId: currentProviderId || 'env', + options: { effort: effort || '' }, + }), + }).catch(() => {}); + }, [ccSwitchCompatMode, currentProviderId]); + // Restore session-scoped last-generated images from sessionStorage useEffect(() => { loadLastGenerated(sessionId); }, [sessionId]); @@ -591,7 +616,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal onSend={sendMessage} onCommand={handleCommand} onStop={stopStreaming} - disabled={false} + disabled={!compatModeReady || (ccSwitchCompatMode && !providerOptionsReady)} isStreaming={isStreaming} sessionId={sessionId} modelName={currentModel} @@ -601,7 +626,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal workingDirectory={workingDirectory} onAssistantTrigger={checkAssistantTrigger} effort={selectedEffort} - onEffortChange={setSelectedEffort} + onEffortChange={handleEffortChange} sdkInitMeta={initMetaRef.current} isAssistantProject={isAssistantProject} hasMessages={messages.length > 0} diff --git a/src/components/settings/GeneralSection.tsx b/src/components/settings/GeneralSection.tsx index b0fe49bc..bae11d27 100644 --- a/src/components/settings/GeneralSection.tsx +++ b/src/components/settings/GeneralSection.tsx @@ -128,6 +128,8 @@ export function GeneralSection() { const [skipPermSaving, setSkipPermSaving] = useState(false); const [generativeUI, setGenerativeUI] = useState(true); const [generativeUISaving, setGenerativeUISaving] = useState(false); + const [ccSwitchCompatMode, setCcSwitchCompatMode] = useState(false); + const [ccSwitchCompatSaving, setCcSwitchCompatSaving] = useState(false); const [defaultPanel, setDefaultPanel] = useState('file_tree'); const { accountInfo } = useAccountInfo(); const { t, locale, setLocale } = useTranslation(); @@ -141,6 +143,7 @@ export function GeneralSection() { setSkipPermissions(appSettings.dangerously_skip_permissions === "true"); // generative_ui_enabled defaults to true when not set setGenerativeUI(appSettings.generative_ui_enabled !== "false"); + setCcSwitchCompatMode(appSettings.cc_switch_compat_mode === "true"); // default_panel defaults to 'file_tree' when not set setDefaultPanel(appSettings.default_panel || 'file_tree'); } @@ -215,6 +218,27 @@ export function GeneralSection() { } }; + const handleCcSwitchCompatToggle = async (checked: boolean) => { + setCcSwitchCompatSaving(true); + try { + const res = await fetch("/api/settings/app", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + settings: { cc_switch_compat_mode: checked ? "true" : "" }, + }), + }); + if (res.ok) { + setCcSwitchCompatMode(checked); + window.dispatchEvent(new Event('app-settings-changed')); + } + } catch { + // ignore + } finally { + setCcSwitchCompatSaving(false); + } + }; + return (
@@ -252,6 +276,18 @@ export function GeneralSection() { /> + + + + {/* Default panel */} { + setReady(false); + fetch('/api/settings/app') + .then(res => res.ok ? res.json() : null) + .then(data => { + setEnabled(data?.settings?.cc_switch_compat_mode === 'true'); + }) + .catch(() => { + setEnabled(false); + }) + .finally(() => { + setReady(true); + }); + }, []); + + useEffect(() => { + fetchSetting(); + window.addEventListener('app-settings-changed', fetchSetting); + return () => window.removeEventListener('app-settings-changed', fetchSetting); + }, [fetchSetting]); + + return { enabled, ready }; +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 78817fcf..1d87a0a6 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -100,6 +100,8 @@ const en = { 'settings.errorReportingDesc': 'Help improve CodePilot by anonymously reporting errors. No conversation content or API keys are included. Restart the app for the change to fully take effect.', 'settings.generativeUITitle': 'Generative UI', 'settings.generativeUIDesc': 'Enable interactive visualizations (charts, diagrams, mockups) in chat responses. Disabling saves tokens but removes visual generation capability.', + 'settings.ccSwitchCompatTitle': 'Enable cc-switch Compatibility Mode', + 'settings.ccSwitchCompatDesc': 'Keep the env-provider default model and remember the selected effort level for the current provider, so new conversations start with the same cc-switch-style defaults.', 'settings.defaultPanelTitle': 'Default Side Panel', 'settings.defaultPanelDesc': 'Side panel to auto-open when starting a new conversation', 'settings.defaultPanelNone': 'None', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 4cfbf043..150b6c5d 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -97,6 +97,8 @@ const zh: Record = { 'settings.errorReportingDesc': '帮助改进 CodePilot:匿名上报错误信息,不包含对话内容和 API Key。更改后需重启应用才能完全生效。', 'settings.generativeUITitle': '生成式 UI', 'settings.generativeUIDesc': '启用聊天中的交互式可视化功能(图表、流程图、原型图等)。关闭后可节省 token,但将无法生成可视化内容。', + 'settings.ccSwitchCompatTitle': '开启 cc-switch 兼容模式', + 'settings.ccSwitchCompatDesc': '保留 env 提供商的默认模型,并记住当前提供商所选的 effort,让新对话从一开始就使用与 cc-switch 一致的默认状态。', 'settings.defaultPanelTitle': '默认侧边面板', 'settings.defaultPanelDesc': '打开新对话时自动展开的侧边面板', 'settings.defaultPanelNone': '全关', diff --git a/src/lib/db.ts b/src/lib/db.ts index 9338005b..86922f9a 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1410,9 +1410,11 @@ export function getProviderOptions(providerId: string): import('@/types').Provid if (providerId === 'env') { const thinkingMode = getSetting('thinking_mode') || 'adaptive'; const context1m = getSetting('context_1m') === 'true'; + const effort = getSetting('effort') || undefined; return { thinking_mode: thinkingMode as 'adaptive' | 'enabled' | 'disabled', context_1m: context1m, + ...(effort ? { effort: effort as import('@/types').ProviderOptions['effort'] } : {}), }; } const provider = getProvider(providerId); @@ -1439,6 +1441,7 @@ export function setProviderOptions(providerId: string, options: import('@/types' if (providerId === 'env') { if (options.thinking_mode !== undefined) setSetting('thinking_mode', options.thinking_mode); if (options.context_1m !== undefined) setSetting('context_1m', options.context_1m ? 'true' : ''); + if (options.effort !== undefined) setSetting('effort', options.effort || ''); return; } const db = getDb(); diff --git a/src/lib/provider-doctor.ts b/src/lib/provider-doctor.ts index 1454062a..0f121637 100644 --- a/src/lib/provider-doctor.ts +++ b/src/lib/provider-doctor.ts @@ -291,6 +291,8 @@ async function runProviderProbe(): Promise { const providers = getAllProviders(); const defaultId = getDefaultProviderId(); + const ccSwitchCompatMode = getSetting('cc_switch_compat_mode') === 'true'; + const defaultIsCompatEnv = ccSwitchCompatMode && defaultId === 'env'; findings.push({ severity: 'ok', @@ -299,7 +301,16 @@ async function runProviderProbe(): Promise { }); if (defaultId) { - const defaultProvider = getProvider(defaultId); + const defaultProvider = defaultIsCompatEnv + ? { + id: 'env', + name: 'Claude Code', + protocol: 'anthropic', + provider_type: 'anthropic', + api_key: process.env.ANTHROPIC_API_KEY || '', + base_url: process.env.ANTHROPIC_BASE_URL || '', + } + : getProvider(defaultId); if (defaultProvider) { findings.push({ severity: 'ok', diff --git a/src/types/index.ts b/src/types/index.ts index 298b2fc7..fb8a594d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -268,6 +268,7 @@ export interface UpdateProviderRequest { export interface ProviderOptions { thinking_mode?: 'adaptive' | 'enabled' | 'disabled'; context_1m?: boolean; + effort?: 'low' | 'medium' | 'high' | 'max'; /** Global default model ID — used for new sessions */ default_model?: string; /** Global default model's provider ID — which provider the default model belongs to */ From 0af8419aabf27b0aa75ba239405012424d888c36 Mon Sep 17 00:00:00 2001 From: Weilin Cai <1261249659@qq.com> Date: Sun, 5 Apr 2026 13:11:11 +0800 Subject: [PATCH 2/3] fix: normalize compat-mode provider effort state --- src/__tests__/unit/provider-options.test.ts | 45 +++++++++++++++++++++ src/app/api/providers/options/route.ts | 25 +++++++++--- src/app/chat/page.tsx | 2 +- src/components/chat/ChatView.tsx | 2 +- src/hooks/useCcSwitchCompatMode.ts | 34 ++++++++++++---- src/lib/db.ts | 23 ++++++----- src/lib/provider-doctor.ts | 18 +++++++-- src/lib/provider-options.ts | 23 +++++++++++ 8 files changed, 144 insertions(+), 28 deletions(-) create mode 100644 src/lib/provider-options.ts diff --git a/src/__tests__/unit/provider-options.test.ts b/src/__tests__/unit/provider-options.test.ts index 71796811..76cf0509 100644 --- a/src/__tests__/unit/provider-options.test.ts +++ b/src/__tests__/unit/provider-options.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; +import { NextRequest } from 'next/server'; import { getProviderOptions, setProviderOptions, getSetting, setSetting } from '../../lib/db'; @@ -35,4 +36,48 @@ describe('Provider options', () => { assert.equal(options.context_1m, true); assert.equal(options.effort, 'max'); }); + + it('drops invalid stored effort values when rehydrating env provider options', () => { + setSetting('effort', 'invalid-effort'); + + const options = getProviderOptions('env'); + assert.equal(options.effort, undefined); + }); + + it('rejects invalid effort values in the provider options route', async () => { + const { PUT } = await import('../../app/api/providers/options/route'); + const request = new NextRequest('http://localhost/api/providers/options', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + providerId: 'env', + options: { effort: 'invalid-effort' }, + }), + }); + + const response = await PUT(request); + assert.equal(response.status, 400); + }); + + it('treats null effort as clearing the stored env effort', async () => { + setSetting('effort', 'max'); + + const { PUT } = await import('../../app/api/providers/options/route'); + const request = new NextRequest('http://localhost/api/providers/options', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + providerId: 'env', + options: { effort: null }, + }), + }); + + const response = await PUT(request); + const data = await response.json() as { options: { effort?: string } }; + + assert.equal(response.status, 200); + assert.equal(data.options.effort, undefined); + assert.equal(getProviderOptions('env').effort, undefined); + assert.equal(getSetting('effort'), ''); + }); }); diff --git a/src/app/api/providers/options/route.ts b/src/app/api/providers/options/route.ts index d77713f1..ba7f3996 100644 --- a/src/app/api/providers/options/route.ts +++ b/src/app/api/providers/options/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getProviderOptions, setProviderOptions } from '@/lib/db'; +import { normalizeProviderEffort, sanitizeProviderOptions } from '@/lib/provider-options'; import type { ProviderOptions } from '@/types'; /** @@ -18,17 +19,31 @@ export async function GET(request: NextRequest) { */ export async function PUT(request: NextRequest) { try { - const body = await request.json(); - const { providerId = 'env', options } = body as { providerId?: string; options: ProviderOptions }; + const body = await request.json() as { providerId?: string; options?: Record }; + const providerId = body.providerId || 'env'; + const options = body.options; if (!options || typeof options !== 'object') { return NextResponse.json({ error: 'Invalid options' }, { status: 400 }); } - // Merge with existing options (partial update) const existing = getProviderOptions(providerId); - const merged: ProviderOptions = { ...existing, ...options }; - setProviderOptions(providerId, merged); + const hasExplicitEffort = Object.prototype.hasOwnProperty.call(options, 'effort'); + const rawEffort = options.effort; + + if (hasExplicitEffort && rawEffort !== '' && rawEffort !== null && rawEffort !== undefined) { + if (!normalizeProviderEffort(rawEffort)) { + return NextResponse.json({ error: 'Invalid effort value' }, { status: 400 }); + } + } + + const merged: ProviderOptions = sanitizeProviderOptions({ ...existing, ...options } as ProviderOptions); + if (hasExplicitEffort && (rawEffort === '' || rawEffort === null || rawEffort === undefined)) { + delete (merged as { effort?: unknown }).effort; + setProviderOptions(providerId, { ...merged, effort: undefined }); + } else { + setProviderOptions(providerId, merged); + } return NextResponse.json({ options: merged }); } catch (error) { diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 5173d343..d40d5b4a 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -125,7 +125,7 @@ export default function NewChatPage() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ providerId: currentProviderId || 'env', - options: { effort: effort || '' }, + options: effort === undefined ? { effort: null } : { effort }, }), }).catch(() => {}); }, [ccSwitchCompatMode, currentProviderId]); diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 5a11d886..d0d8982e 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -152,7 +152,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ providerId: currentProviderId || 'env', - options: { effort: effort || '' }, + options: effort === undefined ? { effort: null } : { effort }, }), }).catch(() => {}); }, [ccSwitchCompatMode, currentProviderId]); diff --git a/src/hooks/useCcSwitchCompatMode.ts b/src/hooks/useCcSwitchCompatMode.ts index 55b0a347..1f7934d7 100644 --- a/src/hooks/useCcSwitchCompatMode.ts +++ b/src/hooks/useCcSwitchCompatMode.ts @@ -1,30 +1,48 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; export function useCcSwitchCompatMode(): { enabled: boolean; ready: boolean } { const [enabled, setEnabled] = useState(false); const [ready, setReady] = useState(false); + const abortControllerRef = useRef(null); + const mountedRef = useRef(true); const fetchSetting = useCallback(() => { - setReady(false); - fetch('/api/settings/app') + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + if (mountedRef.current) { + setReady(false); + } + fetch('/api/settings/app', { signal: controller.signal }) .then(res => res.ok ? res.json() : null) .then(data => { - setEnabled(data?.settings?.cc_switch_compat_mode === 'true'); + if (!controller.signal.aborted && mountedRef.current) { + setEnabled(data?.settings?.cc_switch_compat_mode === 'true'); + } }) - .catch(() => { - setEnabled(false); + .catch((error: unknown) => { + if ((error as { name?: string } | undefined)?.name === 'AbortError') return; + if (!controller.signal.aborted && mountedRef.current) { + setEnabled(false); + } }) .finally(() => { - setReady(true); + if (!controller.signal.aborted && mountedRef.current) { + setReady(true); + } }); }, []); useEffect(() => { fetchSetting(); window.addEventListener('app-settings-changed', fetchSetting); - return () => window.removeEventListener('app-settings-changed', fetchSetting); + return () => { + mountedRef.current = false; + abortControllerRef.current?.abort(); + window.removeEventListener('app-settings-changed', fetchSetting); + }; }, [fetchSetting]); return { enabled, ready }; diff --git a/src/lib/db.ts b/src/lib/db.ts index 86922f9a..ffe87e85 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -5,6 +5,7 @@ import fs from 'fs'; import os from 'os'; import type { ChatSession, Message, SettingsMap, TaskItem, TaskStatus, ApiProvider, CreateProviderRequest, UpdateProviderRequest, MediaJob, MediaJobStatus, MediaJobItem, MediaJobItemStatus, MediaContextEvent, BatchConfig, CustomCliTool, ScheduledTask } from '@/types'; import type { ChannelType, ChannelBinding } from './bridge/types'; +import { normalizeProviderEffort, sanitizeProviderOptions } from '@/lib/provider-options'; import { getLocalDateString, localDayStartAsUTC } from './utils'; const dataDir = process.env.CLAUDE_GUI_DATA_DIR || path.join(os.homedir(), '.codepilot'); @@ -1410,7 +1411,7 @@ export function getProviderOptions(providerId: string): import('@/types').Provid if (providerId === 'env') { const thinkingMode = getSetting('thinking_mode') || 'adaptive'; const context1m = getSetting('context_1m') === 'true'; - const effort = getSetting('effort') || undefined; + const effort = normalizeProviderEffort(getSetting('effort')); return { thinking_mode: thinkingMode as 'adaptive' | 'enabled' | 'disabled', context_1m: context1m, @@ -1420,7 +1421,7 @@ export function getProviderOptions(providerId: string): import('@/types').Provid const provider = getProvider(providerId); if (!provider) return {}; try { - return JSON.parse(provider.options_json || '{}'); + return sanitizeProviderOptions(JSON.parse(provider.options_json || '{}')); } catch { return {}; } } @@ -1429,25 +1430,27 @@ export function getProviderOptions(providerId: string): import('@/types').Provid * For DB providers, writes to options_json column. */ export function setProviderOptions(providerId: string, options: import('@/types').ProviderOptions): void { + const hasExplicitEffort = Object.prototype.hasOwnProperty.call(options, 'effort'); + const sanitizedOptions = sanitizeProviderOptions(options); if (providerId === '__global__') { - if (options.default_model !== undefined) setSetting('global_default_model', options.default_model); - if (options.default_model_provider !== undefined) setSetting('global_default_model_provider', options.default_model_provider); + if (sanitizedOptions.default_model !== undefined) setSetting('global_default_model', sanitizedOptions.default_model); + if (sanitizedOptions.default_model_provider !== undefined) setSetting('global_default_model_provider', sanitizedOptions.default_model_provider); // Sync legacy default_provider_id so backend consumers (doctor, repair, etc.) stay consistent - if ((options as Record).legacy_default_provider_id !== undefined) { - setSetting('default_provider_id', (options as Record).legacy_default_provider_id as string); + if ((sanitizedOptions as Record).legacy_default_provider_id !== undefined) { + setSetting('default_provider_id', (sanitizedOptions as Record).legacy_default_provider_id as string); } return; } if (providerId === 'env') { - if (options.thinking_mode !== undefined) setSetting('thinking_mode', options.thinking_mode); - if (options.context_1m !== undefined) setSetting('context_1m', options.context_1m ? 'true' : ''); - if (options.effort !== undefined) setSetting('effort', options.effort || ''); + if (sanitizedOptions.thinking_mode !== undefined) setSetting('thinking_mode', sanitizedOptions.thinking_mode); + if (sanitizedOptions.context_1m !== undefined) setSetting('context_1m', sanitizedOptions.context_1m ? 'true' : ''); + if (hasExplicitEffort) setSetting('effort', sanitizedOptions.effort || ''); return; } const db = getDb(); const now = new Date().toISOString().replace('T', ' ').split('.')[0]; db.prepare('UPDATE api_providers SET options_json = ?, updated_at = ? WHERE id = ?') - .run(JSON.stringify(options), now, providerId); + .run(JSON.stringify(sanitizedOptions), now, providerId); } // ── Provider Models ───────────────────────────────────────────── diff --git a/src/lib/provider-doctor.ts b/src/lib/provider-doctor.ts index 0f121637..1e342fc3 100644 --- a/src/lib/provider-doctor.ts +++ b/src/lib/provider-doctor.ts @@ -293,6 +293,11 @@ async function runProviderProbe(): Promise { const defaultId = getDefaultProviderId(); const ccSwitchCompatMode = getSetting('cc_switch_compat_mode') === 'true'; const defaultIsCompatEnv = ccSwitchCompatMode && defaultId === 'env'; + const compatEnvHasCredentials = !!( + process.env.ANTHROPIC_API_KEY || + process.env.ANTHROPIC_AUTH_TOKEN || + getSetting('anthropic_auth_token') + ); findings.push({ severity: 'ok', @@ -319,12 +324,19 @@ async function runProviderProbe(): Promise { }); // Check if default provider has a key - if (!defaultProvider.api_key) { + const defaultHasCredentials = defaultIsCompatEnv + ? compatEnvHasCredentials + : !!defaultProvider.api_key; + if (!defaultHasCredentials) { findings.push({ severity: 'warn', code: 'provider.default-no-key', - message: `Default provider "${defaultProvider.name}" has no API key`, - detail: JSON.stringify(maskKey(defaultProvider.api_key)), + message: defaultIsCompatEnv + ? `Default provider "${defaultProvider.name}" has no environment credentials` + : `Default provider "${defaultProvider.name}" has no API key`, + detail: defaultIsCompatEnv + ? 'Set ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, or the app auth token before using the env default provider.' + : JSON.stringify(maskKey(defaultProvider.api_key)), }); } } else { diff --git a/src/lib/provider-options.ts b/src/lib/provider-options.ts new file mode 100644 index 00000000..a02a5931 --- /dev/null +++ b/src/lib/provider-options.ts @@ -0,0 +1,23 @@ +import type { ProviderOptions } from '@/types'; + +export const VALID_PROVIDER_EFFORTS = ['low', 'medium', 'high', 'max'] as const; + +export function normalizeProviderEffort(value: unknown): ProviderOptions['effort'] | undefined { + if (typeof value !== 'string') return undefined; + return VALID_PROVIDER_EFFORTS.includes(value as typeof VALID_PROVIDER_EFFORTS[number]) + ? value as ProviderOptions['effort'] + : undefined; +} + +export function sanitizeProviderOptions(options: ProviderOptions): ProviderOptions { + const sanitized: ProviderOptions = { ...options }; + if (Object.prototype.hasOwnProperty.call(sanitized, 'effort')) { + const normalizedEffort = normalizeProviderEffort(sanitized.effort); + if (normalizedEffort) { + sanitized.effort = normalizedEffort; + } else { + delete (sanitized as { effort?: unknown }).effort; + } + } + return sanitized; +} From 457278beac3b2c3e08cd85eac11fba33c4a18af3 Mon Sep 17 00:00:00 2001 From: Weilin Cai <1261249659@qq.com> Date: Sun, 5 Apr 2026 14:41:47 +0800 Subject: [PATCH 3/3] fix: persist cc-switch defaults more reliably --- src/__tests__/unit/cc-switch-compat.test.ts | 30 ++++++ .../unit/legacy-provider-placeholder.test.ts | 95 +++++++++++++++++++ .../unit/stale-default-provider.test.ts | 28 +++++- src/app/api/providers/models/route.ts | 17 +++- src/app/api/providers/route.ts | 3 +- src/app/chat/page.tsx | 27 ++++-- src/components/chat/ChatView.tsx | 15 ++- src/lib/cc-switch-compat.ts | 18 ++++ src/lib/legacy-provider-placeholder.ts | 39 ++++++++ 9 files changed, 255 insertions(+), 17 deletions(-) create mode 100644 src/__tests__/unit/cc-switch-compat.test.ts create mode 100644 src/__tests__/unit/legacy-provider-placeholder.test.ts create mode 100644 src/lib/cc-switch-compat.ts create mode 100644 src/lib/legacy-provider-placeholder.ts diff --git a/src/__tests__/unit/cc-switch-compat.test.ts b/src/__tests__/unit/cc-switch-compat.test.ts new file mode 100644 index 00000000..80a2c59b --- /dev/null +++ b/src/__tests__/unit/cc-switch-compat.test.ts @@ -0,0 +1,30 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + buildCcSwitchCompatGlobalDefaultPayload, + shouldPersistCcSwitchCompatGlobalDefault, +} from '../../lib/cc-switch-compat'; + +describe('cc-switch compat helpers', () => { + it('only persists global defaults for the built-in Claude Code provider', () => { + assert.equal(shouldPersistCcSwitchCompatGlobalDefault(true, 'env', 'sonnet'), true); + assert.equal(shouldPersistCcSwitchCompatGlobalDefault(true, 'some-db-provider', 'sonnet'), false); + assert.equal(shouldPersistCcSwitchCompatGlobalDefault(false, 'env', 'sonnet'), false); + assert.equal(shouldPersistCcSwitchCompatGlobalDefault(true, 'env', ''), false); + }); + + it('builds a payload that reuses the env/Claude Code provider as the global default target', () => { + assert.deepEqual( + buildCcSwitchCompatGlobalDefaultPayload('env', 'opus'), + { + providerId: '__global__', + options: { + default_model: 'opus', + default_model_provider: 'env', + legacy_default_provider_id: 'env', + }, + }, + ); + }); +}); diff --git a/src/__tests__/unit/legacy-provider-placeholder.test.ts b/src/__tests__/unit/legacy-provider-placeholder.test.ts new file mode 100644 index 00000000..78e0d914 --- /dev/null +++ b/src/__tests__/unit/legacy-provider-placeholder.test.ts @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { filterVisibleProviders, isLegacyMigratedDefaultPlaceholder } from '@/lib/legacy-provider-placeholder'; +import { createProvider, deleteProvider } from '@/lib/db'; +import type { ApiProvider } from '@/types'; + +function buildProvider(overrides: Partial = {}): ApiProvider { + return { + id: 'provider-id', + name: 'Default', + provider_type: 'anthropic', + protocol: '', + base_url: '', + api_key: '', + is_active: 0, + sort_order: 0, + extra_env: '{}', + headers_json: '{}', + env_overrides_json: '{}', + role_models_json: '{}', + options_json: '{}', + notes: 'Migrated from settings', + created_at: '2026-04-05 00:00:00', + updated_at: '2026-04-05 00:00:00', + ...overrides, + }; +} + +describe('legacy migrated provider placeholder', () => { + it('detects the empty migrated Default provider', () => { + assert.equal(isLegacyMigratedDefaultPlaceholder(buildProvider()), true); + }); + + it('keeps user-managed providers visible', () => { + assert.equal( + isLegacyMigratedDefaultPlaceholder(buildProvider({ api_key: 'sk-real-key' })), + false, + ); + assert.equal( + isLegacyMigratedDefaultPlaceholder(buildProvider({ notes: 'Custom provider' })), + false, + ); + }); + + it('filters only the placeholder entry', () => { + const visibleProvider = buildProvider({ + id: 'real-provider', + name: 'OpenRouter', + provider_type: 'openrouter', + protocol: 'openrouter', + base_url: 'https://openrouter.ai/api/v1', + api_key: 'sk-visible', + notes: '', + }); + + assert.deepEqual( + filterVisibleProviders([buildProvider(), visibleProvider]).map(provider => provider.id), + ['real-provider'], + ); + }); + + it('does not expose the placeholder through provider APIs', async () => { + const placeholder = createProvider({ + name: 'Default', + provider_type: 'anthropic', + protocol: '', + base_url: '', + api_key: '', + extra_env: '{}', + notes: 'Migrated from settings', + }); + + try { + const providersRoute = await import('@/app/api/providers/route'); + const providersResponse = await providersRoute.GET(); + const providersData = await providersResponse.json() as { providers: ApiProvider[] }; + assert.equal( + providersData.providers.some(provider => provider.id === placeholder.id), + false, + ); + + const modelsRoute = await import('@/app/api/providers/models/route'); + const modelsResponse = await modelsRoute.GET(); + const modelsData = await modelsResponse.json() as { + groups: Array<{ provider_id: string; provider_name: string }>; + }; + assert.equal( + modelsData.groups.some(group => group.provider_id === placeholder.id || group.provider_name === 'Default'), + false, + ); + } finally { + deleteProvider(placeholder.id); + } + }); +}); diff --git a/src/__tests__/unit/stale-default-provider.test.ts b/src/__tests__/unit/stale-default-provider.test.ts index ec7f4155..9fe732d9 100644 --- a/src/__tests__/unit/stale-default-provider.test.ts +++ b/src/__tests__/unit/stale-default-provider.test.ts @@ -170,7 +170,28 @@ describe('Stale default_provider_id cleanup', () => { deleteProvider(providerId); }); - it('still auto-heals env default when compat mode is disabled', async () => { + it('exposes effort support metadata for built-in env models even without SDK cache', async () => { + setDefaultProviderId('env'); + setSetting('cc_switch_compat_mode', 'true'); + + const { GET } = await import('../../app/api/providers/models/route'); + const response = await GET(); + const data = await response.json() as { + groups: Array<{ + provider_id: string; + models: Array<{ value: string; supportsEffort?: boolean; supportedEffortLevels?: string[] }>; + }>; + }; + + const envGroup = data.groups.find(group => group.provider_id === 'env'); + const sonnet = envGroup?.models.find(model => model.value === 'sonnet'); + + assert.ok(envGroup, 'env group should exist'); + assert.equal(sonnet?.supportsEffort, true); + assert.deepEqual(sonnet?.supportedEffortLevels, ['low', 'medium', 'high', 'max']); + }); + + it('keeps env as the default when compat mode is disabled and env is still selectable', async () => { const providerId = createTestProvider('__test_real_provider'); setDefaultProviderId('env'); setSetting('cc_switch_compat_mode', ''); @@ -179,9 +200,8 @@ describe('Stale default_provider_id cleanup', () => { const response = await GET(); const data = await response.json() as { default_provider_id: string }; - assert.notEqual(data.default_provider_id, 'env'); - assert.ok(getProvider(data.default_provider_id), 'auto-healed default should point to a real provider'); - assert.equal(getDefaultProviderId(), data.default_provider_id); + assert.equal(data.default_provider_id, 'env'); + assert.equal(getDefaultProviderId(), 'env'); deleteProvider(providerId); }); diff --git a/src/app/api/providers/models/route.ts b/src/app/api/providers/models/route.ts index bb0d4f3b..3a36bc6a 100644 --- a/src/app/api/providers/models/route.ts +++ b/src/app/api/providers/models/route.ts @@ -2,14 +2,15 @@ import { NextResponse } from 'next/server'; import { getAllProviders, getDefaultProviderId, setDefaultProviderId, getProvider, getModelsForProvider, getSetting } from '@/lib/db'; import { getContextWindow } from '@/lib/model-context'; import { getDefaultModelsForProvider, inferProtocolFromLegacy, findPresetForLegacy } from '@/lib/provider-catalog'; +import { filterVisibleProviders } from '@/lib/legacy-provider-placeholder'; import type { Protocol } from '@/lib/provider-catalog'; import type { ErrorResponse, ProviderModelGroup } from '@/types'; // Default Claude model options (for the built-in 'env' provider) const DEFAULT_MODELS = [ - { value: 'sonnet', label: 'Sonnet 4.6' }, - { value: 'opus', label: 'Opus 4.6' }, - { value: 'haiku', label: 'Haiku 4.5' }, + { value: 'sonnet', label: 'Sonnet 4.6', supportsEffort: true, supportedEffortLevels: ['low', 'medium', 'high', 'max'] }, + { value: 'opus', label: 'Opus 4.6', supportsEffort: true, supportedEffortLevels: ['low', 'medium', 'high', 'max'] }, + { value: 'haiku', label: 'Haiku 4.5', supportsEffort: true, supportedEffortLevels: ['low', 'medium', 'high', 'max'] }, ]; interface ModelEntry { @@ -41,7 +42,7 @@ const MEDIA_PROVIDER_TYPES = new Set(['gemini-image']); export async function GET() { try { - const providers = getAllProviders(); + const providers = filterVisibleProviders(getAllProviders()); const groups: ProviderModelGroup[] = []; const ccSwitchCompatMode = getSetting('cc_switch_compat_mode') === 'true'; @@ -194,11 +195,17 @@ export async function GET() { // Determine default provider — auto-heal stale references on read let defaultProviderId = getDefaultProviderId(); const defaultIsCompatEnv = ccSwitchCompatMode && defaultProviderId === 'env'; - if (defaultProviderId && !defaultIsCompatEnv && !getProvider(defaultProviderId)) { + const hasVisibleDefaultProvider = !!defaultProviderId && groups.some(g => g.provider_id === defaultProviderId); + if (defaultProviderId && !defaultIsCompatEnv && !hasVisibleDefaultProvider && !getProvider(defaultProviderId)) { // Stale default (provider was deleted). Fix it now. const firstValid = groups.find(g => g.provider_id !== 'env'); defaultProviderId = firstValid?.provider_id || ''; setDefaultProviderId(defaultProviderId); + } else if (defaultProviderId && !defaultIsCompatEnv && !hasVisibleDefaultProvider) { + // Hidden legacy placeholder or otherwise non-selectable provider. + const firstValid = groups.find(g => g.provider_id !== 'env'); + defaultProviderId = firstValid?.provider_id || 'env'; + setDefaultProviderId(defaultProviderId); } defaultProviderId = defaultProviderId || groups[0]?.provider_id || ''; diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index 1650b5f9..a5fb076e 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAllProviders, createProvider, getSetting } from '@/lib/db'; +import { filterVisibleProviders } from '@/lib/legacy-provider-placeholder'; import type { ProviderResponse, ErrorResponse, CreateProviderRequest, ApiProvider } from '@/types'; function maskApiKey(provider: ApiProvider): ApiProvider { @@ -36,7 +37,7 @@ function detectEnvVars(): Record { export async function GET() { try { - const providers = getAllProviders().map(maskApiKey); + const providers = filterVisibleProviders(getAllProviders()).map(maskApiKey); const envDetected = detectEnvVars(); return NextResponse.json({ providers, diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index d40d5b4a..9bca195f 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -18,6 +18,7 @@ import { useNativeFolderPicker } from '@/hooks/useNativeFolderPicker'; import { useTranslation } from '@/hooks/useTranslation'; import { usePanel } from '@/hooks/usePanel'; import { useCcSwitchCompatMode } from '@/hooks/useCcSwitchCompatMode'; +import { buildCcSwitchCompatGlobalDefaultPayload, shouldPersistCcSwitchCompatGlobalDefault } from '@/lib/cc-switch-compat'; interface ToolUseInfo { id: string; @@ -130,6 +131,25 @@ export default function NewChatPage() { }).catch(() => {}); }, [ccSwitchCompatMode, currentProviderId]); + const handleProviderModelChange = useCallback((pid: string, model: string) => { + setCurrentProviderId(pid); + setCurrentModel(model); + localStorage.setItem('codepilot:last-provider-id', pid); + localStorage.setItem('codepilot:last-model', model); + + if (!shouldPersistCcSwitchCompatGlobalDefault(ccSwitchCompatMode, pid, model)) return; + + fetch('/api/providers/options', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildCcSwitchCompatGlobalDefaultPayload(pid, model)), + }) + .then(() => { + window.dispatchEvent(new Event('provider-changed')); + }) + .catch(() => {}); + }, [ccSwitchCompatMode]); + // Validate restored model/provider against actual available providers/models. // For NEW conversations, the global default model takes priority // over localStorage's last-model (which is a cross-session global memory). @@ -830,12 +850,7 @@ export default function NewChatPage() { modelName={currentModel} onModelChange={setCurrentModel} providerId={currentProviderId} - onProviderModelChange={(pid, model) => { - setCurrentProviderId(pid); - setCurrentModel(model); - localStorage.setItem('codepilot:last-provider-id', pid); - localStorage.setItem('codepilot:last-model', model); - }} + onProviderModelChange={handleProviderModelChange} workingDirectory={workingDir} effort={selectedEffort} onEffortChange={handleEffortChange} diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index d0d8982e..ee3b9104 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -20,6 +20,7 @@ import { useChatCommands } from '@/hooks/useChatCommands'; import { useAssistantTrigger } from '@/hooks/useAssistantTrigger'; import { useStreamSubscription } from '@/hooks/useStreamSubscription'; import { useCcSwitchCompatMode } from '@/hooks/useCcSwitchCompatMode'; +import { buildCcSwitchCompatGlobalDefaultPayload, shouldPersistCcSwitchCompatGlobalDefault } from '@/lib/cc-switch-compat'; import { startStream, stopStream, @@ -209,7 +210,19 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model, provider_id: newProviderId }), }).catch(() => {}); - }, [sessionId]); + + if (!shouldPersistCcSwitchCompatGlobalDefault(ccSwitchCompatMode, newProviderId, model)) return; + + fetch('/api/providers/options', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildCcSwitchCompatGlobalDefaultPayload(newProviderId, model)), + }) + .then(() => { + window.dispatchEvent(new Event('provider-changed')); + }) + .catch(() => {}); + }, [ccSwitchCompatMode, sessionId]); // ── Extracted hooks ── diff --git a/src/lib/cc-switch-compat.ts b/src/lib/cc-switch-compat.ts new file mode 100644 index 00000000..fb914c88 --- /dev/null +++ b/src/lib/cc-switch-compat.ts @@ -0,0 +1,18 @@ +export function shouldPersistCcSwitchCompatGlobalDefault( + enabled: boolean, + providerId: string | undefined, + model: string | undefined, +): boolean { + return enabled && providerId === 'env' && !!model; +} + +export function buildCcSwitchCompatGlobalDefaultPayload(providerId: string, model: string) { + return { + providerId: '__global__', + options: { + default_model: model, + default_model_provider: providerId, + legacy_default_provider_id: providerId, + }, + }; +} diff --git a/src/lib/legacy-provider-placeholder.ts b/src/lib/legacy-provider-placeholder.ts new file mode 100644 index 00000000..02481906 --- /dev/null +++ b/src/lib/legacy-provider-placeholder.ts @@ -0,0 +1,39 @@ +import type { ApiProvider } from '@/types'; + +function isBlank(value: string | undefined): boolean { + return !value || value.trim() === ''; +} + +function isEmptyJsonObject(value: string | undefined): boolean { + if (isBlank(value)) return true; + try { + const parsed = JSON.parse(value!); + return !!parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Object.keys(parsed).length === 0; + } catch { + return false; + } +} + +/** + * Older migrations could create a placeholder provider called "Default" + * even when the actual runtime path uses the built-in env/Claude Code provider. + * Hide only the fully empty migrated placeholder, not user-managed providers. + */ +export function isLegacyMigratedDefaultPlaceholder(provider: ApiProvider): boolean { + return ( + provider.name === 'Default' && + provider.notes === 'Migrated from settings' && + provider.provider_type === 'anthropic' && + isBlank(provider.base_url) && + isBlank(provider.api_key) && + isEmptyJsonObject(provider.extra_env) && + isEmptyJsonObject(provider.headers_json) && + isEmptyJsonObject(provider.env_overrides_json) && + isEmptyJsonObject(provider.role_models_json) && + isEmptyJsonObject(provider.options_json) + ); +} + +export function filterVisibleProviders(providers: ApiProvider[]): ApiProvider[] { + return providers.filter(provider => !isLegacyMigratedDefaultPlaceholder(provider)); +}