diff --git a/src/server/__tests__/provider-presets.test.ts b/src/server/__tests__/provider-presets.test.ts index d4a627c1..f4f79126 100644 --- a/src/server/__tests__/provider-presets.test.ts +++ b/src/server/__tests__/provider-presets.test.ts @@ -98,7 +98,9 @@ describe('provider presets API', () => { expect(kimi?.authStrategy).toBe('auth_token') expect(kimi?.defaultModels.main).toBe('kimi-k2.7-code') expect(kimi?.defaultEnv?.CC_HAHA_SEND_DISABLED_THINKING).toBeUndefined() - expect(kimi?.defaultEnv?.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES).toBe('thinking') + expect(kimi?.defaultEnv?.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES).toBe( + 'thinking,required_thinking', + ) expect(minimax?.authStrategy).toBe('auth_token') expect(minimax?.defaultModels.main).toBe('MiniMax-M3[1m]') expect(minimax?.defaultEnv?.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('1000000') diff --git a/src/server/__tests__/provider-runtime-env.test.ts b/src/server/__tests__/provider-runtime-env.test.ts index 39683c00..755a0411 100644 --- a/src/server/__tests__/provider-runtime-env.test.ts +++ b/src/server/__tests__/provider-runtime-env.test.ts @@ -247,7 +247,7 @@ describe('providerRuntimeEnv', () => { expect(kimiEnv).toMatchObject({ ANTHROPIC_BASE_URL: 'https://api.moonshot.cn/anthropic', ANTHROPIC_MODEL: 'kimi-k2.7-code', - ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES: 'thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES: 'thinking,required_thinking', }) expect(JSON.parse(kimiEnv!.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toMatchObject({ 'kimi-k2.7-code': 262144, diff --git a/src/server/config/providerPresets.json b/src/server/config/providerPresets.json index 3dacf9fa..9ac241de 100644 --- a/src/server/config/providerPresets.json +++ b/src/server/config/providerPresets.json @@ -86,9 +86,9 @@ "apiKeyUrl": "https://platform.kimi.com/console/api-keys", "authStrategy": "auth_token", "defaultEnv": { - "ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES": "thinking", - "ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES": "thinking", - "ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES": "thinking" + "ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES": "thinking,required_thinking", + "ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES": "thinking,required_thinking", + "ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES": "thinking,required_thinking" }, "modelContextWindows": { "kimi-k2.7-code": 262144, diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index 701b6365..b1cad6ec 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -183,6 +183,7 @@ import { endQueryProfile, queryCheckpoint } from "src/utils/queryProfiler.js"; import { modelSupportsAdaptiveThinking, modelSupportsThinking, + resolveModelThinkingEnabled, shouldSendExplicitDisabledThinking, type ThinkingConfig, } from "src/utils/thinking.js"; @@ -1655,9 +1656,11 @@ async function* queryModel( : []; const extraBodyParams = getExtraBodyParams(bedrockBetas); - const hasThinking = + const hasThinking = resolveModelThinkingEnabled( + options.model, thinkingConfig.type !== 'disabled' && - !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_THINKING) + !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_THINKING), + ) const modelCanThink = modelSupportsThinking(options.model) const sendsExplicitDisabledThinking = !hasThinking && (modelCanThink || shouldSendExplicitDisabledThinking()) diff --git a/src/services/api/claudeRequiredThinking.test.ts b/src/services/api/claudeRequiredThinking.test.ts new file mode 100644 index 00000000..50031c62 --- /dev/null +++ b/src/services/api/claudeRequiredThinking.test.ts @@ -0,0 +1,132 @@ +import { expect, test } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { enableConfigs } from '../../utils/config.js' +import { queryWithModel } from './claude.js' + +function sseEvent(name: string, data: unknown): string { + return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n` +} + +function successfulResponse(): string { + return [ + sseEvent('message_start', { + type: 'message_start', + message: { + id: 'msg_required_thinking', + type: 'message', + role: 'assistant', + model: 'kimi-k2.7-code', + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 0 }, + }, + }), + sseEvent('content_block_start', { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }), + sseEvent('content_block_delta', { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'OK' }, + }), + sseEvent('content_block_stop', { type: 'content_block_stop', index: 0 }), + sseEvent('message_delta', { + type: 'message_delta', + delta: { stop_reason: 'end_turn', stop_sequence: null }, + usage: { output_tokens: 1 }, + }), + sseEvent('message_stop', { type: 'message_stop' }), + ].join('') +} + +const ENV_KEYS = [ + 'NODE_ENV', + 'CLAUDE_CONFIG_DIR', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES', + 'ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES', + 'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES', +] as const + +test('keeps required-thinking models enabled when the caller requests disabled thinking', async () => { + const requests: Array> = [] + const server = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + async fetch(request) { + requests.push(await request.json() as Record) + return new Response(successfulResponse(), { + headers: { 'content-type': 'text/event-stream' }, + }) + }, + }) + const configDir = await mkdtemp(join(tmpdir(), 'cc-haha-required-thinking-')) + const originalEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])) + const globals = globalThis as typeof globalThis & { MACRO?: { BUILD_TIME: string } } + const originalMacro = globals.MACRO + + try { + globals.MACRO = { BUILD_TIME: '' } + process.env.NODE_ENV = 'production' + process.env.CLAUDE_CONFIG_DIR = configDir + delete process.env.CLAUDE_CODE_USE_BEDROCK + delete process.env.CLAUDE_CODE_USE_VERTEX + delete process.env.CLAUDE_CODE_USE_FOUNDRY + process.env.ANTHROPIC_BASE_URL = `http://127.0.0.1:${server.port}` + process.env.ANTHROPIC_AUTH_TOKEN = 'loopback-test-key' + process.env.ANTHROPIC_API_KEY = '' + process.env.ANTHROPIC_MODEL = 'kimi-k2.7-code' + process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'kimi-k2.7-code' + process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'kimi-k2.7-code' + process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'kimi-k2.7-code' + process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES = + 'thinking,required_thinking' + process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES = + 'thinking,required_thinking' + process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES = + 'thinking,required_thinking' + enableConfigs() + + const result = await queryWithModel({ + userPrompt: 'Reply exactly OK', + signal: new AbortController().signal, + options: { + model: 'kimi-k2.7-code', + querySource: 'insights', + agents: [], + isNonInteractiveSession: true, + hasAppendSystemPrompt: false, + mcpTools: [], + }, + }) + + expect(result.message.content).toEqual([{ type: 'text', text: 'OK' }]) + expect(requests).toHaveLength(1) + expect(requests[0]?.model).toBe('kimi-k2.7-code') + expect(requests[0]?.thinking).toMatchObject({ type: 'enabled' }) + } finally { + for (const key of ENV_KEYS) { + const value = originalEnv[key] + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + if (originalMacro === undefined) delete globals.MACRO + else globals.MACRO = originalMacro + server.stop(true) + await rm(configDir, { recursive: true, force: true }) + } +}, 10_000) diff --git a/src/utils/__tests__/thinking.test.ts b/src/utils/__tests__/thinking.test.ts index 3dfec9a3..12d340b3 100644 --- a/src/utils/__tests__/thinking.test.ts +++ b/src/utils/__tests__/thinking.test.ts @@ -4,7 +4,9 @@ import { resolveSideQueryThinkingConfig } from '../sideQuery.js' import { modelSupportsEffort, modelSupportsMaxEffort } from '../effort.js' import { modelSupportsAdaptiveThinking, + modelRequiresThinking, modelSupportsThinking, + resolveModelThinkingEnabled, shouldSendExplicitDisabledThinking, } from '../thinking.js' @@ -109,13 +111,18 @@ describe('provider-aware thinking support', () => { test('Kimi preset models declare thinking without effort passthrough', () => { process.env.ANTHROPIC_BASE_URL = 'https://api.moonshot.cn/anthropic' process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'kimi-k2.7-code' - process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES = 'thinking' + process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES = + 'thinking,required_thinking' clearCapabilityCache() expect(modelSupportsThinking('kimi-k2.7-code')).toBe(true) expect(modelSupportsAdaptiveThinking('kimi-k2.7-code')).toBe(false) + expect(modelRequiresThinking('kimi-k2.7-code')).toBe(true) + expect(resolveModelThinkingEnabled('kimi-k2.7-code', false)).toBe(true) expect(modelSupportsEffort('kimi-k2.7-code')).toBe(false) expect(modelSupportsMaxEffort('kimi-k2.7-code')).toBe(false) + expect(modelRequiresThinking('kimi-k2.6')).toBe(false) + expect(resolveModelThinkingEnabled('kimi-k2.6', false)).toBe(false) }) test('third-party base URLs do not default unknown model names to effort support', () => { diff --git a/src/utils/model/modelSupportOverrides.ts b/src/utils/model/modelSupportOverrides.ts index 56cb2519..0697bfb0 100644 --- a/src/utils/model/modelSupportOverrides.ts +++ b/src/utils/model/modelSupportOverrides.ts @@ -5,6 +5,7 @@ export type ModelCapabilityOverride = | 'effort' | 'max_effort' | 'thinking' + | 'required_thinking' | 'adaptive_thinking' | 'interleaved_thinking' diff --git a/src/utils/thinking.ts b/src/utils/thinking.ts index f4e49a0c..8aa1922e 100644 --- a/src/utils/thinking.ts +++ b/src/utils/thinking.ts @@ -116,6 +116,17 @@ export function modelSupportsThinking(model: string): boolean { return canonical.includes('sonnet-4') || canonical.includes('opus-4') } +export function modelRequiresThinking(model: string): boolean { + return get3PModelCapabilityOverride(model, 'required_thinking') === true +} + +export function resolveModelThinkingEnabled( + model: string, + requestedEnabled: boolean, +): boolean { + return modelRequiresThinking(model) || requestedEnabled +} + function isMiniMaxAnthropicEndpoint(): boolean { const baseUrl = process.env.ANTHROPIC_BASE_URL?.toLowerCase() ?? '' return baseUrl.includes('minimax') || baseUrl.includes('minimaxi')