mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
c6be08e471
commit
2c5c904c0a
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '権限モード',
|
||||
|
||||
@ -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': '권한 모드',
|
||||
|
||||
@ -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 Search;OpenAI 代理格式會保留完整工具 schema。',
|
||||
'settings.providers.disableExperimentalBetas': '關閉實驗性 Beta 標頭',
|
||||
'settings.providers.disableExperimentalBetasDesc': '為此服務商設定 CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1,避免第三方通道拒絕 beta API 形態。',
|
||||
|
||||
// Settings > Permissions
|
||||
'settings.permissions.title': '許可權模式',
|
||||
|
||||
@ -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 Search;OpenAI 代理格式会保留完整工具 schema。',
|
||||
'settings.providers.disableExperimentalBetas': '关闭实验性 Beta 头',
|
||||
'settings.providers.disableExperimentalBetasDesc': '为此服务商设置 CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1,避免第三方通道拒绝 beta API 形态。',
|
||||
|
||||
// Settings > Permissions
|
||||
'settings.permissions.title': '权限模式',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 ───────────────────────────────────────────
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user