mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
dcf844b7e7
commit
9ecc178c7a
@ -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 />)
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 域名预检',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 */ }
|
||||
|
||||
@ -16,6 +16,7 @@ export type UserSettings = {
|
||||
model?: string
|
||||
modelContext?: string
|
||||
effort?: EffortLevel
|
||||
alwaysThinkingEnabled?: boolean
|
||||
permissionMode?: PermissionMode
|
||||
theme?: ThemeMode
|
||||
skipWebFetchPreflight?: boolean
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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')
|
||||
|
||||
79
src/server/__tests__/title-service.test.ts
Normal file
79
src/server/__tests__/title-service.test.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user