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
This commit is contained in:
程序员阿江(Relakkes) 2026-07-02 21:00:34 +08:00
parent c6be08e471
commit 2c5c904c0a
18 changed files with 298 additions and 7 deletions

View File

@ -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(<Settings />)
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',

View File

@ -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',

View File

@ -463,6 +463,8 @@ export const jp: Record<TranslationKey, string> = {
'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': '権限モード',

View File

@ -463,6 +463,8 @@ export const kr: Record<TranslationKey, string> = {
'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': '권한 모드',

View File

@ -463,6 +463,8 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.toolSearchEnabled': '啟用 Tool Search',
'settings.providers.toolSearchDesc': '按需載入 MCP 和延遲工具,減少首輪工具 schema token。弱模型或不支援 tool_reference 的服務商可以關閉。',
'settings.providers.toolSearchUnsupported': '目前僅 Anthropic Messages 格式支援 Tool SearchOpenAI 代理格式會保留完整工具 schema。',
'settings.providers.disableExperimentalBetas': '關閉實驗性 Beta 標頭',
'settings.providers.disableExperimentalBetasDesc': '為此服務商設定 CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1避免第三方通道拒絕 beta API 形態。',
// Settings > Permissions
'settings.permissions.title': '許可權模式',

View File

@ -463,6 +463,8 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.toolSearchEnabled': '启用 Tool Search',
'settings.providers.toolSearchDesc': '按需加载 MCP 和延迟工具,减少首轮工具 schema token。弱模型或不支持 tool_reference 的服务商可以关闭。',
'settings.providers.toolSearchUnsupported': '当前仅 Anthropic Messages 格式支持 Tool SearchOpenAI 代理格式会保留完整工具 schema。',
'settings.providers.disableExperimentalBetas': '关闭实验性 Beta 头',
'settings.providers.disableExperimentalBetasDesc': '为此服务商设置 CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1避免第三方通道拒绝 beta API 形态。',
// Settings > Permissions
'settings.permissions.title': '权限模式',

View File

@ -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',

View File

@ -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',

View File

@ -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<string, unknown>,
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<string, unknown> }
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<string, unknown>): boolean {
const value = env.ENABLE_TOOL_SEARCH
if (typeof value === 'boolean') return value
@ -994,6 +1024,18 @@ function readToolSearchEnabledFromEnv(env: Record<string, unknown>): boolean {
return true
}
function readDisableExperimentalBetasFromEnv(env: Record<string, unknown>): 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<string, unknown> }
@ -1069,6 +1111,7 @@ function updateSettingsJsonProviderConnection(
baseUrl: string,
proxyBaseUrl: string,
toolSearchEnabled = true,
disableExperimentalBetas = false,
): string {
try {
const parsed = JSON.parse(raw || '{}') as { env?: Record<string, unknown> }
@ -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<ProviderTestResult | null>(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
</div>
</label>
<label className="relative flex cursor-pointer items-start gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-3 transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]">
<input
type="checkbox"
aria-label={t('settings.providers.disableExperimentalBetas')}
checked={disableExperimentalBetas}
onChange={(e) => handleDisableExperimentalBetasToggle(e.target.checked)}
className={SETTINGS_CHECKBOX_INPUT_CLASS}
/>
<SettingsCheckboxMark checked={disableExperimentalBetas} />
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.providers.disableExperimentalBetas')}
</div>
<div className="mt-1 text-xs leading-5 text-[var(--color-text-tertiary)]">
{t('settings.providers.disableExperimentalBetasDesc')}
</div>
</div>
</label>
<div className="flex flex-col gap-1">
<label htmlFor="provider-api-key" className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.providers.apiKey')}
@ -1859,6 +1931,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
setAuthStrategy(nextAuthStrategy)
}
setToolSearchEnabled(readToolSearchEnabledFromEnv(env))
setDisableExperimentalBetas(readDisableExperimentalBetasFromEnv(env))
if (env[AUTO_COMPACT_WINDOW_ENV_KEY] !== undefined) {
setAutoCompactWindow(String(env[AUTO_COMPACT_WINDOW_ENV_KEY]))
} else {

View File

@ -41,6 +41,7 @@ export type SavedProvider = {
autoCompactWindow?: number
modelContextWindows?: ModelContextWindows
toolSearchEnabled?: boolean
disableExperimentalBetas?: boolean
notes?: string
}
@ -57,6 +58,7 @@ export type CreateProviderInput = {
autoCompactWindow?: number
modelContextWindows?: ModelContextWindows
toolSearchEnabled?: boolean
disableExperimentalBetas?: boolean
notes?: string
}
@ -72,6 +74,7 @@ export type UpdateProviderInput = {
autoCompactWindow?: number | null
modelContextWindows?: ModelContextWindows | null
toolSearchEnabled?: boolean
disableExperimentalBetas?: boolean
notes?: string
}

View File

@ -23,6 +23,7 @@ describe('ConversationService', () => {
let originalProviderManagedByHost: string | undefined
let originalDiagnosticsFile: string | undefined
let originalAttributionHeader: string | undefined
let originalDisableExperimentalBetas: string | undefined
let originalResumeInterruptedTurn: string | undefined
let originalTraceApiCalls: string | undefined
let originalTraceProviderId: string | undefined
@ -46,6 +47,7 @@ describe('ConversationService', () => {
originalProviderManagedByHost = process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST
originalDiagnosticsFile = process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
originalAttributionHeader = process.env.CLAUDE_CODE_ATTRIBUTION_HEADER
originalDisableExperimentalBetas = process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS
originalResumeInterruptedTurn = process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN
originalTraceApiCalls = process.env.CC_HAHA_TRACE_API_CALLS
originalTraceProviderId = process.env.CC_HAHA_TRACE_PROVIDER_ID
@ -69,6 +71,7 @@ describe('ConversationService', () => {
delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST
delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER
delete process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS
delete process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN
delete process.env.CC_HAHA_TRACE_API_CALLS
delete process.env.CC_HAHA_TRACE_PROVIDER_ID
@ -109,6 +112,9 @@ describe('ConversationService', () => {
if (originalAttributionHeader === undefined) delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER
else process.env.CLAUDE_CODE_ATTRIBUTION_HEADER = originalAttributionHeader
if (originalDisableExperimentalBetas === undefined) delete process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS
else process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = originalDisableExperimentalBetas
if (originalResumeInterruptedTurn === undefined) delete process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN
else process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN = originalResumeInterruptedTurn
@ -486,6 +492,38 @@ describe('ConversationService', () => {
expect(env.CC_HAHA_TRACE_PROVIDER_FORMAT).toBeUndefined()
})
test('buildChildEnv isolates experimental beta kill switch for session-scoped providers', async () => {
process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = '1'
const providerService = new ProviderService()
const provider = await providerService.addProvider({
presetId: 'custom',
name: 'Betas Managed',
apiKey: 'provider-key',
baseUrl: 'https://api.betas.example',
apiFormat: 'anthropic',
models: {
main: 'claude-sonnet-4-6',
haiku: '',
sonnet: '',
opus: '',
},
})
const service = new ConversationService() as any
const defaultEnv = (await service.buildChildEnv('/tmp', undefined, {
providerId: provider.id,
})) as Record<string, string>
expect(defaultEnv.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS).toBeUndefined()
await providerService.updateProvider(provider.id, { disableExperimentalBetas: true })
const disabledEnv = (await service.buildChildEnv('/tmp', undefined, {
providerId: provider.id,
})) as Record<string, string>
expect(disabledEnv.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS).toBe('1')
})
test('buildChildEnv injects trace provider metadata for desktop sdk session-scoped providers', async () => {
const providerService = new ProviderService()
const provider = await providerService.addProvider({

View File

@ -87,6 +87,7 @@ describe('providerRuntimeEnv', () => {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:3456/proxy',
ANTHROPIC_API_KEY: 'proxy-managed',
ANTHROPIC_MODEL: 'deepseek-v4-pro',
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: '1',
DISABLE_AUTOUPDATER: '1',
},
tmpDir,
@ -103,6 +104,7 @@ describe('providerRuntimeEnv', () => {
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.5',
DISABLE_AUTOUPDATER: '1',
})
expect(env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS).toBeUndefined()
})
test('honors disabled tool search for native Anthropic providers', async () => {
@ -133,6 +135,34 @@ describe('providerRuntimeEnv', () => {
expect(env.ENABLE_TOOL_SEARCH).toBe('false')
})
test('honors disabled experimental betas for active providers', async () => {
await writeJson(path.join(tmpDir, 'cc-haha', 'providers.json'), {
activeId: 'provider-1',
providers: [
{
id: 'provider-1',
presetId: 'custom',
name: 'Experimental Betas Off',
apiKey: 'sk-active',
authStrategy: 'auth_token',
baseUrl: 'https://api.example.com/anthropic',
apiFormat: 'anthropic',
disableExperimentalBetas: true,
models: {
main: 'active-main',
haiku: 'active-main',
sonnet: 'active-main',
opus: 'active-main',
},
},
],
})
const env = readActiveProviderManagedEnv(tmpDir)
expect(env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS).toBe('1')
})
test('keeps providers readable when stored tool search values are stringly typed', async () => {
await writeJson(path.join(tmpDir, 'cc-haha', 'providers.json'), {
activeId: 'provider-1',

View File

@ -846,6 +846,36 @@ describe('ProviderService', () => {
expect(runtimeEnv.ENABLE_TOOL_SEARCH).toBe('false')
})
test('should persist disabled experimental betas on activation and runtime env', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
disableExperimentalBetas: true,
}))
expect(provider.disableExperimentalBetas).toBe(true)
const config = await readProvidersConfig()
expect((config.providers as Array<Record<string, unknown>>)[0]?.disableExperimentalBetas).toBe(true)
await svc.activateProvider(provider.id)
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS).toBe('1')
const runtimeEnv = await svc.getProviderRuntimeEnv(provider.id)
expect(runtimeEnv.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS).toBe('1')
const updated = await svc.updateProvider(provider.id, { disableExperimentalBetas: false })
expect(updated.disableExperimentalBetas).toBeUndefined()
const clearedSettings = await readSettings()
const clearedEnv = clearedSettings.env as Record<string, string>
expect(clearedEnv.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS).toBeUndefined()
const clearedRuntimeEnv = await svc.getProviderRuntimeEnv(provider.id)
expect(clearedRuntimeEnv.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS).toBeUndefined()
})
test('should preserve attribution header for Claude-prefixed provider models', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
@ -1718,6 +1748,7 @@ describe('Providers API', () => {
apiKey: 'sk-test',
apiFormat: 'anthropic',
autoCompactWindow: 64000,
disableExperimentalBetas: true,
models: {
main: 'gpt-4',
haiku: 'gpt-4-haiku',
@ -1728,10 +1759,11 @@ describe('Providers API', () => {
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(201)
const body = (await res.json()) as { provider: { name: string; models: { main: string }; autoCompactWindow: number } }
const body = (await res.json()) as { provider: { name: string; models: { main: string }; autoCompactWindow: number; disableExperimentalBetas?: boolean } }
expect(body.provider.name).toBe('New Provider')
expect(body.provider.models.main).toBe('gpt-4')
expect(body.provider.autoCompactWindow).toBe(64000)
expect(body.provider.disableExperimentalBetas).toBe(true)
})
test('POST /api/providers should return 400 for invalid input', async () => {
@ -1841,12 +1873,14 @@ describe('Providers API', () => {
const { req, url, segments } = makeRequest('PUT', `/api/providers/${added.id}`, {
name: 'Renamed Provider',
disableExperimentalBetas: true,
})
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(200)
const body = (await res.json()) as { provider: { name: string } }
const body = (await res.json()) as { provider: { name: string; disableExperimentalBetas?: boolean } }
expect(body.provider.name).toBe('Renamed Provider')
expect(body.provider.disableExperimentalBetas).toBe(true)
})
// ─── DELETE /api/providers/:id ───────────────────────────────────────────

View File

@ -1057,6 +1057,7 @@ export class ConversationService {
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES',
'CC_HAHA_SEND_DISABLED_THINKING',
'CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS',
'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
'CLAUDE_CODE_ATTRIBUTION_HEADER',
'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS',

View File

@ -28,6 +28,7 @@ export const MANAGED_PROVIDER_ENV_KEYS = [
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'ENABLE_TOOL_SEARCH',
'CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS',
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES',
@ -100,6 +101,17 @@ function normalizeToolSearchEnabled(value: unknown): boolean {
return true
}
function normalizeDisableExperimentalBetas(value: unknown): boolean {
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
}
export function normalizeModelMapping(models: SavedProvider['models']): SavedProvider['models'] {
const main = models.main.trim()
return {
@ -142,7 +154,11 @@ function applyModel1mSupportMapping(
}
export function normalizeSavedProvider(provider: SavedProvider): SavedProvider {
const { model1mSupport: rawModel1mSupport, ...rest } = provider
const {
disableExperimentalBetas: rawDisableExperimentalBetas,
model1mSupport: rawModel1mSupport,
...rest
} = provider
const rawProvider = provider as SavedProvider & Record<string, unknown>
const model1mSupport = normalizeModel1mSupport(rawModel1mSupport)
return {
@ -151,6 +167,7 @@ export function normalizeSavedProvider(provider: SavedProvider): SavedProvider {
runtimeKind: provider.runtimeKind ?? 'anthropic_compatible',
models: normalizeModelMapping(provider.models),
toolSearchEnabled: normalizeToolSearchEnabled(rawProvider.toolSearchEnabled),
...(normalizeDisableExperimentalBetas(rawDisableExperimentalBetas) ? { disableExperimentalBetas: true } : {}),
...(model1mSupport !== undefined ? { model1mSupport } : {}),
}
}
@ -345,6 +362,9 @@ export function buildProviderManagedEnv(
...(apiFormat === 'anthropic' && {
ENABLE_TOOL_SEARCH: provider.toolSearchEnabled === false ? 'false' : 'true',
}),
...(provider.disableExperimentalBetas === true && {
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: '1',
}),
ANTHROPIC_BASE_URL: baseUrl,
...buildProviderAuthEnv(provider, presetDefaultEnv, needsProxy),
ANTHROPIC_MODEL: runtimeModels.main,

View File

@ -228,6 +228,7 @@ export class ProviderService {
...(input.autoCompactWindow !== undefined && { autoCompactWindow: input.autoCompactWindow }),
...(input.modelContextWindows !== undefined && { modelContextWindows: input.modelContextWindows }),
toolSearchEnabled: input.toolSearchEnabled ?? true,
...(input.disableExperimentalBetas === true && { disableExperimentalBetas: true }),
...(input.notes !== undefined && { notes: input.notes }),
}
@ -256,6 +257,7 @@ export class ProviderService {
...(typeof input.autoCompactWindow === 'number' && { autoCompactWindow: input.autoCompactWindow }),
...(input.modelContextWindows !== undefined && input.modelContextWindows !== null && { modelContextWindows: input.modelContextWindows }),
...(input.toolSearchEnabled !== undefined && { toolSearchEnabled: input.toolSearchEnabled }),
...(input.disableExperimentalBetas === true && { disableExperimentalBetas: true }),
...(input.notes !== undefined && { notes: input.notes }),
}
if (input.model1mSupport === null) {
@ -267,6 +269,9 @@ export class ProviderService {
if (input.modelContextWindows === null) {
delete updated.modelContextWindows
}
if (input.disableExperimentalBetas === false) {
delete updated.disableExperimentalBetas
}
index.providers[idx] = updated
await this.writeIndex(index)

View File

@ -56,6 +56,7 @@ export const ModelContextWindowsSchema = z.record(
z.number().int().min(16000).max(10000000),
)
export const ToolSearchEnabledSchema = z.boolean()
export const DisableExperimentalBetasSchema = z.boolean()
export const SavedProviderSchema = z.object({
id: z.string(),
@ -71,6 +72,7 @@ export const SavedProviderSchema = z.object({
autoCompactWindow: AutoCompactWindowSchema.optional(),
modelContextWindows: ModelContextWindowsSchema.optional(),
toolSearchEnabled: ToolSearchEnabledSchema.optional(),
disableExperimentalBetas: DisableExperimentalBetasSchema.optional(),
notes: z.string().optional(),
})
@ -94,6 +96,7 @@ export const CreateProviderSchema = z.object({
autoCompactWindow: AutoCompactWindowSchema.optional(),
modelContextWindows: ModelContextWindowsSchema.optional(),
toolSearchEnabled: ToolSearchEnabledSchema.optional(),
disableExperimentalBetas: DisableExperimentalBetasSchema.optional(),
notes: z.string().optional(),
})
@ -109,6 +112,7 @@ export const UpdateProviderSchema = z.object({
autoCompactWindow: AutoCompactWindowSchema.nullable().optional(),
modelContextWindows: ModelContextWindowsSchema.nullable().optional(),
toolSearchEnabled: ToolSearchEnabledSchema.optional(),
disableExperimentalBetas: DisableExperimentalBetasSchema.optional(),
notes: z.string().optional(),
})

View File

@ -60,6 +60,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([
'ANTHROPIC_SMALL_FAST_MODEL',
'ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION',
'ENABLE_TOOL_SEARCH',
'CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS',
'CLAUDE_CODE_SUBAGENT_MODEL',
'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
'CLAUDE_CODE_ATTRIBUTION_HEADER',