From 2c5c904c0a84a6ad36dd8bae2705013dca8c18eb 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: Thu, 2 Jul 2026 21:00:34 +0800 Subject: [PATCH] fix(provider): add experimental beta kill switch (#929) Add a provider-level disableExperimentalBetas setting that writes CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 into managed provider runtime env, keeps stale parent env isolated, and exposes the toggle in desktop provider settings. Tested: bun test src/server/__tests__/provider-runtime-env.test.ts --test-name-pattern "experimental betas|stale proxy" Tested: bun test src/server/__tests__/providers.test.ts --test-name-pattern "experimental betas|POST /api/providers should create a provider|PUT /api/providers/:id should update a provider" Tested: bun test src/server/__tests__/conversation-service.test.ts --test-name-pattern "experimental beta kill switch" Tested: cd desktop && bun run test -- --run src/__tests__/generalSettings.test.tsx -t "experimental beta headers|Tool Search" Tested: cd desktop && bun run test -- --run src/lib/__tests__/providerSettingsJson.test.ts Tested: bun run check:server Tested: cd desktop && bun run build Not-tested: bun run verify Confidence: high Scope-risk: moderate --- .../src/__tests__/generalSettings.test.tsx | 70 ++++++++++++++++ desktop/src/i18n/locales/en.ts | 2 + desktop/src/i18n/locales/jp.ts | 2 + desktop/src/i18n/locales/kr.ts | 2 + desktop/src/i18n/locales/zh-TW.ts | 2 + desktop/src/i18n/locales/zh.ts | 2 + .../__tests__/providerSettingsJson.test.ts | 1 + desktop/src/lib/providerSettingsJson.ts | 1 + desktop/src/pages/Settings.tsx | 81 ++++++++++++++++++- desktop/src/types/provider.ts | 3 + .../__tests__/conversation-service.test.ts | 38 +++++++++ .../__tests__/provider-runtime-env.test.ts | 30 +++++++ src/server/__tests__/providers.test.ts | 38 ++++++++- src/server/services/conversationService.ts | 1 + src/server/services/providerRuntimeEnv.ts | 22 ++++- src/server/services/providerService.ts | 5 ++ src/server/types/provider.ts | 4 + src/utils/managedEnvConstants.ts | 1 + 18 files changed, 298 insertions(+), 7 deletions(-) diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 91b52ce0..2cce5ce3 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -1941,6 +1941,76 @@ describe('Settings > Providers tab', () => { })) }) + it('defaults experimental beta headers on and persists a provider disable', async () => { + MOCK_GET_SETTINGS.mockResolvedValue({ env: { EXISTING_ENV: '1' } }) + providerStoreState.createProvider = vi.fn().mockResolvedValue({ + id: 'provider-new', + presetId: 'custom', + name: 'Custom', + apiKey: 'sk-test', + baseUrl: 'https://api.example.com/anthropic', + apiFormat: 'anthropic', + disableExperimentalBetas: true, + models: { + main: 'custom-main', + haiku: 'custom-main', + sonnet: 'custom-main', + opus: 'custom-main', + }, + }) + providerStoreState.presets = [ + { + id: 'custom', + name: 'Custom', + baseUrl: 'https://api.example.com/anthropic', + apiFormat: 'anthropic', + defaultModels: { + main: 'custom-main', + haiku: '', + sonnet: '', + opus: '', + }, + needsApiKey: true, + websiteUrl: '', + }, + ] + + render() + + fireEvent.click(screen.getByRole('button', { name: /Add Provider/i })) + const dialog = screen.getByRole('dialog') + const disableBetasCheckbox = within(dialog).getByRole('checkbox', { name: 'Disable experimental beta headers' }) + const settingsTextarea = await waitFor(() => { + const textarea = dialog.querySelector('textarea') + expect(textarea?.value).toContain('"ANTHROPIC_MODEL"') + return textarea as HTMLTextAreaElement + }) + + expect(disableBetasCheckbox).not.toBeChecked() + expect(settingsTextarea.value).not.toContain('CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS') + + fireEvent.click(disableBetasCheckbox) + expect(disableBetasCheckbox).toBeChecked() + await waitFor(() => { + expect(settingsTextarea.value).toContain('"CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "1"') + }) + + fireEvent.change(within(dialog).getByPlaceholderText('sk-...'), { target: { value: 'sk-test' } }) + fireEvent.click(within(dialog).getByRole('button', { name: /Save|Add/i })) + + await waitFor(() => { + expect(providerStoreState.createProvider).toHaveBeenCalledWith(expect.objectContaining({ + disableExperimentalBetas: true, + })) + }) + expect(MOCK_UPDATE_SETTINGS).toHaveBeenCalledWith(expect.objectContaining({ + env: expect.objectContaining({ + EXISTING_ENV: '1', + CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: '1', + }), + })) + }) + it('saves 1M model declarations for the main and role mappings', async () => { providerStoreState.createProvider = vi.fn().mockResolvedValue({ id: 'provider-new', diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index dfeed882..590c2bf7 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -461,6 +461,8 @@ export const en = { 'settings.providers.toolSearchEnabled': 'Enable Tool Search', 'settings.providers.toolSearchDesc': 'Load MCP and deferred tools on demand to reduce initial tool schema tokens. Disable it for weak models or providers that reject tool references.', 'settings.providers.toolSearchUnsupported': 'Only Anthropic Messages providers support Tool Search here. OpenAI proxy formats keep full tool schemas available.', + 'settings.providers.disableExperimentalBetas': 'Disable experimental beta headers', + 'settings.providers.disableExperimentalBetasDesc': 'Sets CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 for this provider to avoid beta API shapes that third-party gateways may reject.', // Settings > Permissions 'settings.permissions.title': 'Permission Mode', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 1189b2dd..040d8b3f 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -463,6 +463,8 @@ export const jp: Record = { 'settings.providers.toolSearchEnabled': 'Tool Search を有効にする', 'settings.providers.toolSearchDesc': 'MCP と遅延ツールを必要に応じて読み込み、初回のツール schema トークンを減らします。弱いモデルや tool_reference を拒否するプロバイダーでは無効にできます。', 'settings.providers.toolSearchUnsupported': 'ここでは Anthropic Messages 形式のプロバイダーのみ Tool Search をサポートします。OpenAI プロキシ形式では完全なツール schema を維持します。', + 'settings.providers.disableExperimentalBetas': '実験的な Beta ヘッダーを無効化', + 'settings.providers.disableExperimentalBetasDesc': 'このプロバイダーに CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 を設定し、サードパーティゲートウェイが拒否する可能性のある beta API 形式を避けます。', // Settings > Permissions 'settings.permissions.title': '権限モード', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 087ab3e6..bf05206f 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -463,6 +463,8 @@ export const kr: Record = { 'settings.providers.toolSearchEnabled': 'Tool Search 사용', 'settings.providers.toolSearchDesc': 'MCP와 지연 도구를 필요할 때 로드해 초기 도구 schema 토큰을 줄입니다. 약한 모델이나 tool_reference를 거부하는 공급자에서는 끌 수 있습니다.', 'settings.providers.toolSearchUnsupported': '여기서는 Anthropic Messages 형식 공급자만 Tool Search를 지원합니다. OpenAI 프록시 형식은 전체 도구 schema를 유지합니다.', + 'settings.providers.disableExperimentalBetas': '실험적 Beta 헤더 비활성화', + 'settings.providers.disableExperimentalBetasDesc': '이 공급자에 CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1을 설정해 타사 게이트웨이가 거부할 수 있는 beta API 형식을 피합니다.', // Settings > Permissions 'settings.permissions.title': '권한 모드', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 2859d07c..7dd451b6 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -463,6 +463,8 @@ export const zh: Record = { 'settings.providers.toolSearchEnabled': '啟用 Tool Search', 'settings.providers.toolSearchDesc': '按需載入 MCP 和延遲工具,減少首輪工具 schema token。弱模型或不支援 tool_reference 的服務商可以關閉。', 'settings.providers.toolSearchUnsupported': '目前僅 Anthropic Messages 格式支援 Tool Search;OpenAI 代理格式會保留完整工具 schema。', + 'settings.providers.disableExperimentalBetas': '關閉實驗性 Beta 標頭', + 'settings.providers.disableExperimentalBetasDesc': '為此服務商設定 CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1,避免第三方通道拒絕 beta API 形態。', // Settings > Permissions 'settings.permissions.title': '許可權模式', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 66ab2e80..abd12f06 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -463,6 +463,8 @@ export const zh: Record = { 'settings.providers.toolSearchEnabled': '启用 Tool Search', 'settings.providers.toolSearchDesc': '按需加载 MCP 和延迟工具,减少首轮工具 schema token。弱模型或不支持 tool_reference 的服务商可以关闭。', 'settings.providers.toolSearchUnsupported': '当前仅 Anthropic Messages 格式支持 Tool Search;OpenAI 代理格式会保留完整工具 schema。', + 'settings.providers.disableExperimentalBetas': '关闭实验性 Beta 头', + 'settings.providers.disableExperimentalBetasDesc': '为此服务商设置 CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1,避免第三方通道拒绝 beta API 形态。', // Settings > Permissions 'settings.permissions.title': '权限模式', diff --git a/desktop/src/lib/__tests__/providerSettingsJson.test.ts b/desktop/src/lib/__tests__/providerSettingsJson.test.ts index d6dd7300..f1741b08 100644 --- a/desktop/src/lib/__tests__/providerSettingsJson.test.ts +++ b/desktop/src/lib/__tests__/providerSettingsJson.test.ts @@ -68,6 +68,7 @@ describe('provider settings JSON helpers', () => { ANTHROPIC_BASE_URL: 'https://old.example.com', ANTHROPIC_MODEL: 'old-model', CLAUDE_CODE_MODEL_CONTEXT_WINDOWS: '{"old":100000}', + CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: '1', CC_HAHA_OPENAI_OAUTH_PROVIDER: '1', OPENAI_CODEX_OAUTH_FILE: '/tmp/openai-oauth.json', CC_HAHA_SEND_DISABLED_THINKING: '1', diff --git a/desktop/src/lib/providerSettingsJson.ts b/desktop/src/lib/providerSettingsJson.ts index 486908a2..954834da 100644 --- a/desktop/src/lib/providerSettingsJson.ts +++ b/desktop/src/lib/providerSettingsJson.ts @@ -22,6 +22,7 @@ const PROVIDER_SETTINGS_JSON_ENV_KEYS = new Set([ 'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES', 'ANTHROPIC_SMALL_FAST_MODEL', 'ENABLE_TOOL_SEARCH', + 'CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS', 'CC_HAHA_OPENAI_OAUTH_PROVIDER', 'CLAUDE_CODE_AUTO_COMPACT_WINDOW', 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index d47d4db5..177e340f 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -705,6 +705,7 @@ function requirePreset(preset: ProviderPreset | undefined): ProviderPreset { const AUTO_COMPACT_WINDOW_ENV_KEY = 'CLAUDE_CODE_AUTO_COMPACT_WINDOW' const MODEL_CONTEXT_WINDOWS_ENV_KEY = 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS' +const DISABLE_EXPERIMENTAL_BETAS_ENV_KEY = 'CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS' const MODEL_CONTEXT_WINDOW_MIN = 16000 const MODEL_CONTEXT_WINDOW_MAX = 10000000 const MODEL_1M_CONTEXT_WINDOW = 1000000 @@ -961,6 +962,17 @@ function applyToolSearchEnv( } } +function applyDisableExperimentalBetasEnv( + env: Record, + disableExperimentalBetas: boolean, +): void { + if (disableExperimentalBetas) { + env[DISABLE_EXPERIMENTAL_BETAS_ENV_KEY] = '1' + } else { + delete env[DISABLE_EXPERIMENTAL_BETAS_ENV_KEY] + } +} + function updateSettingsJsonToolSearch( raw: string, apiFormat: ApiFormat, @@ -980,6 +992,24 @@ function updateSettingsJsonToolSearch( } } +function updateSettingsJsonDisableExperimentalBetas( + raw: string, + disableExperimentalBetas: boolean, +): string { + try { + const parsed = JSON.parse(raw || '{}') as { env?: Record } + const existingEnv = parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env) + ? parsed.env + : {} + const env = { ...existingEnv } + applyDisableExperimentalBetasEnv(env, disableExperimentalBetas) + parsed.env = env + return JSON.stringify(parsed, null, 2) + } catch { + return raw + } +} + function readToolSearchEnabledFromEnv(env: Record): boolean { const value = env.ENABLE_TOOL_SEARCH if (typeof value === 'boolean') return value @@ -994,6 +1024,18 @@ function readToolSearchEnabledFromEnv(env: Record): boolean { return true } +function readDisableExperimentalBetasFromEnv(env: Record): boolean { + const value = env[DISABLE_EXPERIMENTAL_BETAS_ENV_KEY] + if (typeof value === 'boolean') return value + if (typeof value === 'number') return value !== 0 + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase() + if (['0', 'false', 'off', 'no'].includes(normalized)) return false + if (['1', 'true', 'on', 'yes'].includes(normalized)) return true + } + return false +} + function updateSettingsJsonAutoCompactWindow(raw: string, value: string): string { try { const parsed = JSON.parse(raw || '{}') as { env?: Record } @@ -1069,6 +1111,7 @@ function updateSettingsJsonProviderConnection( baseUrl: string, proxyBaseUrl: string, toolSearchEnabled = true, + disableExperimentalBetas = false, ): string { try { const parsed = JSON.parse(raw || '{}') as { env?: Record } @@ -1079,6 +1122,7 @@ function updateSettingsJsonProviderConnection( delete env.ANTHROPIC_API_KEY delete env.ANTHROPIC_AUTH_TOKEN applyToolSearchEnv(env, apiFormat, toolSearchEnabled) + applyDisableExperimentalBetasEnv(env, disableExperimentalBetas) env.ANTHROPIC_BASE_URL = apiFormat !== 'anthropic' ? proxyBaseUrl : baseUrl Object.assign(env, buildSettingsJsonAuthEnv(apiFormat, authStrategy, apiKey, preset)) parsed.env = env @@ -1161,6 +1205,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF : getPresetAutoCompactWindow(initialPreset), ) const [toolSearchEnabled, setToolSearchEnabled] = useState(provider?.toolSearchEnabled ?? true) + const [disableExperimentalBetas, setDisableExperimentalBetas] = useState(provider?.disableExperimentalBetas ?? false) const [showContextSettings, setShowContextSettings] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const [testResult, setTestResult] = useState(null) @@ -1201,6 +1246,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF ANTHROPIC_DEFAULT_OPUS_MODEL: runtimeModels.opus, } applyToolSearchEnv(mergedEnv, apiFormat, toolSearchEnabled) + applyDisableExperimentalBetasEnv(mergedEnv, disableExperimentalBetas) const merged = { ...settings, skipWebFetchPreflight: settings.skipWebFetchPreflight ?? true, @@ -1231,6 +1277,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF setModelContextInputs(nextModelContextInputs) setAutoCompactWindow(getPresetAutoCompactWindow(preset)) setToolSearchEnabled(true) + setDisableExperimentalBetas(false) setShowContextSettings(false) setTestResult(null) } @@ -1320,25 +1367,29 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF } const handleBaseUrlChange = (value: string) => { setBaseUrl(value) - setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, authStrategy, apiKey, selectedPreset, value, providerProxyBaseUrl, toolSearchEnabled)) + setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, authStrategy, apiKey, selectedPreset, value, providerProxyBaseUrl, toolSearchEnabled, disableExperimentalBetas)) } const handleApiKeyChange = (value: string) => { setApiKey(value) - setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, authStrategy, value, selectedPreset, baseUrl, providerProxyBaseUrl, toolSearchEnabled)) + setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, authStrategy, value, selectedPreset, baseUrl, providerProxyBaseUrl, toolSearchEnabled, disableExperimentalBetas)) } const handleApiFormatChange = (value: ApiFormat) => { setApiFormat(value) - setSettingsJson((current) => updateSettingsJsonProviderConnection(current, value, authStrategy, apiKey, selectedPreset, baseUrl, providerProxyBaseUrl, toolSearchEnabled)) + setSettingsJson((current) => updateSettingsJsonProviderConnection(current, value, authStrategy, apiKey, selectedPreset, baseUrl, providerProxyBaseUrl, toolSearchEnabled, disableExperimentalBetas)) } const handleAuthStrategyChange = (value: ProviderAuthStrategy) => { setAuthStrategy(value) - setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, value, apiKey, selectedPreset, baseUrl, providerProxyBaseUrl, toolSearchEnabled)) + setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, value, apiKey, selectedPreset, baseUrl, providerProxyBaseUrl, toolSearchEnabled, disableExperimentalBetas)) } const handleToolSearchToggle = (enabled: boolean) => { if (toolSearchUnsupported) return setToolSearchEnabled(enabled) setSettingsJson((current) => updateSettingsJsonToolSearch(current, apiFormat, enabled)) } + const handleDisableExperimentalBetasToggle = (disabled: boolean) => { + setDisableExperimentalBetas(disabled) + setSettingsJson((current) => updateSettingsJsonDisableExperimentalBetas(current, disabled)) + } const handleModelChange = (slot: ModelSlot, value: string) => { const hasMarker = hasModel1mMarker(value) const nextModels = { ...models, [slot]: stripModel1mMarker(value) } @@ -1429,6 +1480,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF ...(parsedAutoCompactWindow !== undefined && { autoCompactWindow: parsedAutoCompactWindow }), ...(Object.keys(parsedModelContextWindows).length > 0 && { modelContextWindows: parsedModelContextWindows }), toolSearchEnabled, + ...(disableExperimentalBetas && { disableExperimentalBetas }), notes: notes.trim() || undefined, }) } else if (provider) { @@ -1444,6 +1496,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF ? parsedModelContextWindows : null, toolSearchEnabled, + disableExperimentalBetas, notes: notes.trim() || undefined, } if (apiKey.trim()) input.apiKey = apiKey.trim() @@ -1614,6 +1667,25 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF + +