fix: prevent third-party thinking mode mismatches

Desktop users can now disable thinking for new sessions, and Anthropic-compatible providers that opt in receive explicit disabled-thinking requests across main turns, side queries, and AI title generation.

Constraint: DeepSeek/Kimi/GLM Anthropic-compatible endpoints need a non-thinking path without scattering provider-specific logic through the CLI.
Rejected: Per-model if/else branches | centralized provider preset env keeps the native CLI surface smaller and easier to audit.
Confidence: high
Scope-risk: moderate
Tested: bun test src/utils/__tests__/thinking.test.ts src/server/__tests__/title-service.test.ts src/server/__tests__/provider-presets.test.ts src/server/__tests__/conversations.test.ts
Tested: cd desktop && bun run test -- generalSettings.test.tsx
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Tested: agent-browser DeepSeek desktop E2E with transparent proxy captured main and title requests with thinking.type=disabled
This commit is contained in:
程序员阿江(Relakkes) 2026-05-01 10:13:10 +08:00
parent dcf844b7e7
commit 9ecc178c7a
17 changed files with 303 additions and 19 deletions

View File

@ -106,7 +106,11 @@ describe('Settings > General tab', () => {
useSettingsStore.setState({
locale: 'en',
thinkingEnabled: true,
skipWebFetchPreflight: true,
setThinkingEnabled: vi.fn().mockImplementation(async (enabled: boolean) => {
useSettingsStore.setState({ thinkingEnabled: enabled })
}),
setSkipWebFetchPreflight: vi.fn().mockImplementation(async (enabled: boolean) => {
useSettingsStore.setState({ skipWebFetchPreflight: enabled })
}),
@ -150,6 +154,18 @@ describe('Settings > General tab', () => {
expect(useSettingsStore.getState().setSkipWebFetchPreflight).toHaveBeenCalledWith(false)
})
it('lets the user disable thinking mode for new sessions', () => {
render(<Settings />)
fireEvent.click(screen.getByText('General'))
const toggle = screen.getByLabelText('Enable thinking mode')
expect(toggle).toBeChecked()
fireEvent.click(toggle)
expect(useSettingsStore.getState().setThinkingEnabled).toHaveBeenCalledWith(false)
})
it('keeps extension tabs available alongside the terminal tab', () => {
render(<Settings />)

View File

@ -482,6 +482,10 @@ export const en = {
'settings.general.effort.medium': 'Medium',
'settings.general.effort.high': 'High',
'settings.general.effort.max': 'Max',
'settings.general.thinkingTitle': 'Thinking Mode',
'settings.general.thinkingDescription': 'Controls whether new sessions start with model thinking enabled. When off, compatible providers such as DeepSeek receive an explicit non-thinking parameter.',
'settings.general.thinkingEnabled': 'Enable thinking mode',
'settings.general.thinkingHint': 'Turn this off to start new sessions with --thinking disabled; useful for DeepSeek V4 Flash/Pro and other non-thinking workflows.',
'settings.general.webFetchPreflightTitle': 'WebFetch Preflight',
'settings.general.webFetchPreflightDescription': 'Desktop sessions skip Claude\'s domain preflight by default to avoid false failures on third-party providers and restricted networks.',
'settings.general.webFetchPreflightEnabled': 'Skip WebFetch domain preflight',

View File

@ -484,6 +484,10 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.effort.medium': '中',
'settings.general.effort.high': '高',
'settings.general.effort.max': '最大',
'settings.general.thinkingTitle': '思考模式',
'settings.general.thinkingDescription': '控制新会话是否启用模型思考。关闭后DeepSeek 等兼容供应商会收到显式非思考模式参数。',
'settings.general.thinkingEnabled': '启用思考模式',
'settings.general.thinkingHint': '关闭后会以 --thinking disabled 启动新会话;适合 DeepSeek V4 Flash/Pro 等需要非思考模式的模型。',
'settings.general.webFetchPreflightTitle': 'WebFetch 预检',
'settings.general.webFetchPreflightDescription': '桌面端默认跳过 Claude 的域名预检,避免第三方服务商或受限网络下出现误报失败。',
'settings.general.webFetchPreflightEnabled': '跳过 WebFetch 域名预检',

View File

@ -872,6 +872,8 @@ function GeneralSettings() {
const {
effortLevel,
setEffort,
thinkingEnabled,
setThinkingEnabled,
locale,
setLocale,
theme,
@ -957,6 +959,28 @@ function GeneralSettings() {
))}
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.thinkingTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.thinkingDescription')}</p>
<label className="flex items-start gap-3 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 cursor-pointer hover:border-[var(--color-border-focus)] transition-colors">
<input
type="checkbox"
aria-label={t('settings.general.thinkingEnabled')}
checked={thinkingEnabled}
onChange={(e) => void setThinkingEnabled(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-[var(--color-border)] text-[var(--color-brand)] focus:ring-[var(--color-brand)]"
/>
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.general.thinkingEnabled')}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-1 leading-5">
{t('settings.general.thinkingHint')}
</div>
</div>
</label>
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.webFetchPreflightTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.webFetchPreflightDescription')}</p>

View File

@ -19,6 +19,7 @@ type SettingsStore = {
permissionMode: PermissionMode
currentModel: ModelInfo | null
effortLevel: EffortLevel
thinkingEnabled: boolean
availableModels: ModelInfo[]
activeProviderName: string | null
locale: Locale
@ -31,6 +32,7 @@ type SettingsStore = {
setPermissionMode: (mode: PermissionMode) => Promise<void>
setModel: (modelId: string) => Promise<void>
setEffort: (level: EffortLevel) => Promise<void>
setThinkingEnabled: (enabled: boolean) => Promise<void>
setLocale: (locale: Locale) => void
setTheme: (theme: ThemeMode) => Promise<void>
setSkipWebFetchPreflight: (enabled: boolean) => Promise<void>
@ -40,6 +42,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
permissionMode: 'default',
currentModel: null,
effortLevel: 'medium',
thinkingEnabled: true,
availableModels: [],
activeProviderName: null,
locale: getStoredLocale(),
@ -66,6 +69,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
activeProviderName: modelsRes.provider?.name ?? null,
currentModel: model,
effortLevel: level,
thinkingEnabled: userSettings.alwaysThinkingEnabled !== false,
theme,
skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false,
isLoading: false,
@ -105,6 +109,16 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
}
},
setThinkingEnabled: async (enabled) => {
const prev = get().thinkingEnabled
set({ thinkingEnabled: enabled })
try {
await settingsApi.updateUser({ alwaysThinkingEnabled: enabled ? undefined : false })
} catch {
set({ thinkingEnabled: prev })
}
},
setLocale: (locale) => {
set({ locale })
try { localStorage.setItem(LOCALE_STORAGE_KEY, locale) } catch { /* noop */ }

View File

@ -16,6 +16,7 @@ export type UserSettings = {
model?: string
modelContext?: string
effort?: EffortLevel
alwaysThinkingEnabled?: boolean
permissionMode?: PermissionMode
theme?: ThemeMode
skipWebFetchPreflight?: boolean

View File

@ -138,6 +138,22 @@ describe('ConversationService', () => {
])
})
it('should pass disabled thinking to the CLI runtime args', () => {
const svc = new ConversationService()
expect((svc as any).getRuntimeArgs({
model: 'deepseek-v4-pro',
effort: 'medium',
thinking: 'disabled',
})).toEqual([
'--model',
'deepseek-v4-pro',
'--effort',
'medium',
'--thinking',
'disabled',
])
})
it('should return false when sending interrupt to non-existent session', () => {
const svc = new ConversationService()
const result = svc.sendInterrupt('no-such-session')
@ -632,6 +648,41 @@ describe('WebSocket Chat Integration', () => {
expect(statusMsgs[0].state).toBe('thinking')
})
it('should start desktop sessions with disabled thinking when configured', async () => {
const sessionId = `chat-thinking-disabled-${crypto.randomUUID()}`
const originalStartSession = conversationService.startSession.bind(conversationService)
const startOptions: Array<{ thinking?: string; model?: string }> = []
conversationService.startSession = (async function patchedStartSession(
sid: string,
workDir: string,
sdkUrl: string,
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
) {
if (sid === sessionId) {
startOptions.push({ thinking: options?.thinking, model: options?.model })
}
return originalStartSession(sid, workDir, sdkUrl, options)
}) as typeof conversationService.startSession
try {
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({ alwaysThinkingEnabled: false }, null, 2),
'utf-8',
)
const messages = await runTurn(sessionId, 'Hello without thinking')
expect(messages.some((m) => m.type === 'message_complete')).toBe(true)
expect(startOptions).toEqual([{ thinking: 'disabled', model: undefined }])
} finally {
conversationService.startSession = originalStartSession as typeof conversationService.startSession
conversationService.stopSession(sessionId)
await fs.writeFile(path.join(tmpDir, 'settings.json'), '{}\n', 'utf-8')
}
})
it('should continue chat when SDK init arrives only after the first user turn', async () => {
const messages = await withMockInitMode('on_first_user', () =>
runTurn('chat-test-lazy-init', 'Hello after lazy init'),
@ -901,7 +952,7 @@ describe('WebSocket Chat Integration', () => {
sid: string,
workDir: string,
sdkUrl: string,
options?: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null },
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
) {
startCalls.push({ sessionId: sid })
return originalStartSession(sid, workDir, sdkUrl, options)
@ -1037,7 +1088,7 @@ describe('WebSocket Chat Integration', () => {
sid: string,
workDir: string,
sdkUrl: string,
options?: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null },
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
) {
startCalls.push({ sessionId: sid, options })
return originalStartSession(sid, workDir, sdkUrl, options)
@ -1102,7 +1153,7 @@ describe('WebSocket Chat Integration', () => {
sid: string,
workDir: string,
sdkUrl: string,
options?: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null },
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
) {
startCalls.push({ sessionId: sid, options })
if (startCalls.length === 1) {
@ -1205,7 +1256,7 @@ describe('WebSocket Chat Integration', () => {
sid: string,
workDir: string,
sdkUrl: string,
options?: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null },
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
) {
startCalls.push({ sessionId: sid, options })
return originalStartSession(sid, workDir, sdkUrl, options)
@ -1386,7 +1437,7 @@ describe('WebSocket Chat Integration', () => {
sid: string,
workDir: string,
sdkUrl: string,
options?: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null },
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
) {
startCalls.push({ sessionId: sid, options })
return originalStartSession(sid, workDir, sdkUrl, options)
@ -1532,7 +1583,7 @@ describe('WebSocket Chat Integration', () => {
sid: string,
workDir: string,
sdkUrl: string,
options?: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null },
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
) {
startCalls.push({ sessionId: sid, options })
return originalStartSession(sid, workDir, sdkUrl, options)

View File

@ -80,12 +80,14 @@ describe('provider presets API', () => {
expect(deepseek?.defaultModels.haiku).toBe('deepseek-v4-flash')
expect(deepseek?.defaultModels.sonnet).toBe('deepseek-v4-pro')
expect(deepseek?.defaultModels.opus).toBe('deepseek-v4-pro')
expect(deepseek?.defaultEnv?.CC_HAHA_SEND_DISABLED_THINKING).toBe('1')
expect(zhipu?.defaultModels.main).toBe('glm-5.1')
expect(zhipu?.defaultModels.haiku).toBe('glm-4.5-air')
expect(zhipu?.defaultModels.sonnet).toBe('glm-5-turbo')
expect(zhipu?.defaultModels.opus).toBe('glm-5.1')
expect(kimi?.baseUrl).toBe('https://api.kimi.com/coding')
expect(kimi?.defaultModels.main).toBe('kimi-k2.6')
expect(kimi?.defaultEnv?.CC_HAHA_SEND_DISABLED_THINKING).toBe('1')
expect(minimax?.defaultModels.main).toBe('MiniMax-M2.7')
expect(jiekouai?.baseUrl).toBe('https://api.jiekou.ai/anthropic')
expect(jiekouai?.defaultModels.main).toBe('claude-sonnet-4-6')
@ -118,6 +120,7 @@ describe('provider presets API', () => {
expect(deepseek?.apiKeyUrl).toBe('https://platform.deepseek.com/api_keys')
expect(zhipu?.apiKeyUrl).toBe('https://www.bigmodel.cn/invite?icode=d41B2qi8Z5xNwTGLNPPF3OZLO2QH3C0EBTSr%2BArzMw4%3D')
expect(zhipu?.promoText).toContain('cc-haha')
expect(zhipu?.defaultEnv?.CC_HAHA_SEND_DISABLED_THINKING).toBe('1')
expect(kimi?.apiKeyUrl).toBe('https://platform.kimi.com/console/api-keys')
expect(minimax?.apiKeyUrl).toBe('https://platform.minimaxi.com/subscribe/token-plan?code=1TG2Cseab2&source=link')
expect(jiekouai?.apiKeyUrl).toBe('https://jiekou.ai/referral?invited_code=OBNU3K')

View File

@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
import { generateTitle } from '../services/titleService.js'
describe('titleService', () => {
let tmpDir: string
let originalConfigDir: string | undefined
beforeEach(async () => {
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'title-service-test-'))
process.env.CLAUDE_CONFIG_DIR = tmpDir
})
afterEach(async () => {
restoreEnv('CLAUDE_CONFIG_DIR', originalConfigDir)
await fs.rm(tmpDir, { recursive: true, force: true })
})
test('sends disabled thinking for opted-in providers when desktop thinking is off', async () => {
let requestBody: Record<string, unknown> | null = null
const server = Bun.serve({
hostname: '127.0.0.1',
port: 0,
async fetch(req) {
requestBody = await req.json() as Record<string, unknown>
return Response.json({
content: [{ type: 'text', text: '{"title":"Trace ok"}' }],
})
},
})
try {
const providerId = 'deepseek-test'
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({ alwaysThinkingEnabled: false }, null, 2),
)
await fs.writeFile(
path.join(tmpDir, 'cc-haha', 'providers.json'),
JSON.stringify({
activeId: providerId,
providers: [
{
id: providerId,
presetId: 'deepseek',
name: 'DeepSeek',
apiKey: 'test-key',
baseUrl: `http://127.0.0.1:${server.port}/anthropic`,
apiFormat: 'anthropic',
models: {
main: 'deepseek-v4-pro',
haiku: 'deepseek-v4-pro',
sonnet: 'deepseek-v4-pro',
opus: 'deepseek-v4-pro',
},
},
],
}, null, 2),
)
await expect(generateTitle('请只回复 trace-ok')).resolves.toBe('Trace ok')
expect(requestBody?.thinking).toEqual({ type: 'disabled' })
} finally {
server.stop(true)
}
})
})
function restoreEnv(key: string, value: string | undefined) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}

View File

@ -26,7 +26,10 @@
},
"needsApiKey": true,
"websiteUrl": "https://platform.deepseek.com",
"apiKeyUrl": "https://platform.deepseek.com/api_keys"
"apiKeyUrl": "https://platform.deepseek.com/api_keys",
"defaultEnv": {
"CC_HAHA_SEND_DISABLED_THINKING": "1"
}
},
{
"id": "zhipuglm",
@ -42,7 +45,10 @@
"needsApiKey": true,
"websiteUrl": "https://open.bigmodel.cn",
"apiKeyUrl": "https://www.bigmodel.cn/invite?icode=d41B2qi8Z5xNwTGLNPPF3OZLO2QH3C0EBTSr%2BArzMw4%3D",
"promoText": "智谱 GLM 为 cc-haha 用户准备了专属邀请福利,使用此链接注册后可领取新用户权益。"
"promoText": "智谱 GLM 为 cc-haha 用户准备了专属邀请福利,使用此链接注册后可领取新用户权益。",
"defaultEnv": {
"CC_HAHA_SEND_DISABLED_THINKING": "1"
}
},
{
"id": "kimi",
@ -57,7 +63,10 @@
},
"needsApiKey": true,
"websiteUrl": "https://platform.moonshot.cn",
"apiKeyUrl": "https://platform.kimi.com/console/api-keys"
"apiKeyUrl": "https://platform.kimi.com/console/api-keys",
"defaultEnv": {
"CC_HAHA_SEND_DISABLED_THINKING": "1"
}
},
{
"id": "minimax",

View File

@ -49,6 +49,7 @@ type SessionStartOptions = {
permissionMode?: string
model?: string
effort?: string
thinking?: 'enabled' | 'adaptive' | 'disabled'
providerId?: string | null
}
@ -589,6 +590,10 @@ export class ConversationService {
args.push('--effort', options.effort)
}
if (options?.thinking) {
args.push('--thinking', options.thinking)
}
return args
}
@ -615,6 +620,7 @@ export class ConversationService {
'ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES',
'CC_HAHA_SEND_DISABLED_THINKING',
] as const
const cleanEnv = { ...process.env }
@ -738,6 +744,7 @@ export class ConversationService {
'ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES',
'CC_HAHA_SEND_DISABLED_THINKING',
].some((key) => typeof env[key] === 'string' && env[key]!.trim().length > 0)
} catch {
return false

View File

@ -7,7 +7,10 @@
*/
import { ProviderService } from './providerService.js'
import { SettingsService } from './settingsService.js'
import { sessionService } from './sessionService.js'
import { PROVIDER_PRESETS } from '../config/providerPresets.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
const TITLE_MAX_LEN = 50
@ -69,6 +72,7 @@ export async function generateTitle(
const model = resolvedProvider.models.haiku || resolvedProvider.models.main
const url = `${resolvedProvider.baseUrl.replace(/\/+$/, '')}/v1/messages`
const shouldDisableThinking = await shouldDisableThinkingForTitle(resolvedProvider.presetId)
const response = await fetch(url, {
method: 'POST',
@ -82,6 +86,7 @@ export async function generateTitle(
max_tokens: 100,
system: TITLE_SYSTEM_PROMPT,
messages: [{ role: 'user', content: trimmed.slice(0, 2000) }],
...(shouldDisableThinking && { thinking: { type: 'disabled' } }),
}),
signal: AbortSignal.timeout(15_000),
})
@ -108,6 +113,14 @@ export async function generateTitle(
}
}
async function shouldDisableThinkingForTitle(presetId: string): Promise<boolean> {
const settings = await new SettingsService().getUserSettings()
if (settings.alwaysThinkingEnabled !== false) return false
const presetEnv = PROVIDER_PRESETS.find((preset) => preset.id === presetId)?.defaultEnv
return isEnvTruthy(presetEnv?.CC_HAHA_SEND_DISABLED_THINKING)
}
/**
* Persist an AI-generated title to the session's JSONL file.
*/

View File

@ -1236,6 +1236,7 @@ type RuntimeSettings = {
permissionMode?: string
model?: string
effort?: string
thinking?: 'disabled'
providerId?: string | null
}
@ -1259,11 +1260,13 @@ async function getRuntimeSettings(sessionId?: string): Promise<RuntimeSettings>
typeof userSettings.effort === 'string' && userSettings.effort.trim()
? userSettings.effort
: undefined
const thinking = resolveDesktopThinkingMode(userSettings)
return {
permissionMode: await settingsService.getPermissionMode().catch(() => undefined),
model: runtimeOverride.modelId,
effort,
thinking,
providerId: runtimeOverride.providerId,
}
}
@ -1294,6 +1297,7 @@ async function getDefaultRuntimeSettings(): Promise<RuntimeSettings> {
typeof userSettings.effort === 'string' && userSettings.effort.trim()
? userSettings.effort
: undefined
const thinking = resolveDesktopThinkingMode(userSettings)
let model: string | undefined
if (resolvedActiveId) {
@ -1320,10 +1324,17 @@ async function getDefaultRuntimeSettings(): Promise<RuntimeSettings> {
permissionMode: await settingsService.getPermissionMode().catch(() => undefined),
model,
effort,
thinking,
providerId: resolvedActiveId,
}
}
function resolveDesktopThinkingMode(
settings: Record<string, unknown>,
): 'disabled' | undefined {
return settings.alwaysThinkingEnabled === false ? 'disabled' : undefined
}
async function buildSessionStartupDiagnosticMessage(
sessionId: string,
cause: string,

View File

@ -181,6 +181,7 @@ import { endQueryProfile, queryCheckpoint } from 'src/utils/queryProfiler.js'
import {
modelSupportsAdaptiveThinking,
modelSupportsThinking,
shouldSendExplicitDisabledThinking,
type ThinkingConfig,
} from 'src/utils/thinking.js'
import {
@ -1627,6 +1628,10 @@ async function* queryModel(
type: 'enabled',
} satisfies BetaMessageStreamParams['thinking']
}
} else if (shouldSendExplicitDisabledThinking()) {
thinking = {
type: 'disabled',
} as unknown as BetaMessageStreamParams['thinking']
}
// Get API context management strategies if enabled

View File

@ -1,6 +1,11 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { get3PModelCapabilityOverride } from '../model/modelSupportOverrides.js'
import { modelSupportsAdaptiveThinking, modelSupportsThinking } from '../thinking.js'
import { resolveSideQueryThinkingConfig } from '../sideQuery.js'
import {
modelSupportsAdaptiveThinking,
modelSupportsThinking,
shouldSendExplicitDisabledThinking,
} from '../thinking.js'
describe('provider-aware thinking support', () => {
let originalBaseUrl: string | undefined
@ -9,6 +14,7 @@ describe('provider-aware thinking support', () => {
let originalBedrock: string | undefined
let originalVertex: string | undefined
let originalFoundry: string | undefined
let originalExplicitDisabledThinking: string | undefined
beforeEach(() => {
originalBaseUrl = process.env.ANTHROPIC_BASE_URL
@ -17,6 +23,7 @@ describe('provider-aware thinking support', () => {
originalBedrock = process.env.CLAUDE_CODE_USE_BEDROCK
originalVertex = process.env.CLAUDE_CODE_USE_VERTEX
originalFoundry = process.env.CLAUDE_CODE_USE_FOUNDRY
originalExplicitDisabledThinking = process.env.CC_HAHA_SEND_DISABLED_THINKING
delete process.env.CLAUDE_CODE_USE_BEDROCK
delete process.env.CLAUDE_CODE_USE_VERTEX
@ -30,6 +37,7 @@ describe('provider-aware thinking support', () => {
restoreEnv('CLAUDE_CODE_USE_BEDROCK', originalBedrock)
restoreEnv('CLAUDE_CODE_USE_VERTEX', originalVertex)
restoreEnv('CLAUDE_CODE_USE_FOUNDRY', originalFoundry)
restoreEnv('CC_HAHA_SEND_DISABLED_THINKING', originalExplicitDisabledThinking)
clearCapabilityCache()
})
@ -60,6 +68,24 @@ describe('provider-aware thinking support', () => {
expect(modelSupportsThinking('claude-sonnet-4-6')).toBe(true)
expect(modelSupportsAdaptiveThinking('claude-sonnet-4-6')).toBe(true)
})
test('only sends explicit disabled thinking when the provider opts in', () => {
delete process.env.CC_HAHA_SEND_DISABLED_THINKING
expect(shouldSendExplicitDisabledThinking()).toBe(false)
process.env.CC_HAHA_SEND_DISABLED_THINKING = '1'
expect(shouldSendExplicitDisabledThinking()).toBe(true)
})
test('side queries inherit explicit disabled thinking for opted-in providers', () => {
delete process.env.CC_HAHA_SEND_DISABLED_THINKING
expect(resolveSideQueryThinkingConfig(undefined, 1024)).toBeUndefined()
process.env.CC_HAHA_SEND_DISABLED_THINKING = '1'
expect(resolveSideQueryThinkingConfig(undefined, 1024)).toEqual({ type: 'disabled' })
expect(resolveSideQueryThinkingConfig(false, 1024)).toEqual({ type: 'disabled' })
expect(resolveSideQueryThinkingConfig(256, 1024)).toEqual({ type: 'enabled', budget_tokens: 256 })
})
})
function restoreEnv(key: string, value: string | undefined) {

View File

@ -17,6 +17,7 @@ import { getAnthropicClient } from '../services/api/client.js'
import { getModelBetas, modelSupportsStructuredOutputs } from './betas.js'
import { computeFingerprint } from './fingerprint.js'
import { normalizeModelStringForAPI } from './model/model.js'
import { shouldSendExplicitDisabledThinking } from './thinking.js'
type MessageParam = Anthropic.MessageParam
type TextBlockParam = Anthropic.TextBlockParam
@ -166,15 +167,7 @@ export async function sideQuery(opts: SideQueryOptions): Promise<BetaMessage> {
: []),
].filter((block): block is TextBlockParam => block !== null)
let thinkingConfig: BetaThinkingConfigParam | undefined
if (thinking === false) {
thinkingConfig = { type: 'disabled' }
} else if (thinking !== undefined) {
thinkingConfig = {
type: 'enabled',
budget_tokens: Math.min(thinking, max_tokens - 1),
}
}
const thinkingConfig = resolveSideQueryThinkingConfig(thinking, max_tokens)
const normalizedModel = normalizeModelStringForAPI(model)
const start = Date.now()
@ -220,3 +213,22 @@ export async function sideQuery(opts: SideQueryOptions): Promise<BetaMessage> {
return response
}
export function resolveSideQueryThinkingConfig(
thinking: SideQueryOptions['thinking'],
maxTokens: number,
): BetaThinkingConfigParam | undefined {
if (
thinking === false ||
(thinking === undefined && shouldSendExplicitDisabledThinking())
) {
return { type: 'disabled' }
}
if (thinking !== undefined) {
return {
type: 'enabled',
budget_tokens: Math.min(thinking, maxTokens - 1),
}
}
return undefined
}

View File

@ -6,6 +6,7 @@ import { getCanonicalName } from './model/model.js'
import { get3PModelCapabilityOverride } from './model/modelSupportOverrides.js'
import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from './model/providers.js'
import { getSettingsWithErrors } from './settings/settings.js'
import { isEnvTruthy } from './envUtils.js'
export type ThinkingConfig =
| { type: 'adaptive' }
@ -170,3 +171,7 @@ export function shouldEnableThinkingByDefault(): boolean {
// Enable thinking by default unless explicitly disabled.
return true
}
export function shouldSendExplicitDisabledThinking(): boolean {
return isEnvTruthy(process.env.CC_HAHA_SEND_DISABLED_THINKING)
}