diff --git a/packages/cli/src/ui/hooks/useProviderUpdates.test.ts b/packages/cli/src/ui/hooks/useProviderUpdates.test.ts index b616057878a..c2a593bf602 100644 --- a/packages/cli/src/ui/hooks/useProviderUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useProviderUpdates.test.ts @@ -12,6 +12,7 @@ import { CODING_PLAN_ENV_KEY, codingPlanProvider, TOKEN_PLAN_BASE_URL, + TOKEN_PLAN_ENV_KEY, tokenPlanProvider, buildProviderTemplate, computeModelListVersion, @@ -65,6 +66,11 @@ describe('useProviderUpdates', () => { const mockConfig = { reloadModelProvidersConfig: vi.fn(), refreshAuth: vi.fn(), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + authType: AuthType.USE_OPENAI, + baseUrl: CODING_PLAN_CHINA_BASE_URL, + apiKeyEnvKey: CODING_PLAN_ENV_KEY, + }), getModel: vi.fn().mockReturnValue('qwen3.5-plus'), getModelsConfig: vi.fn(() => mockModelsConfig), }; @@ -75,6 +81,11 @@ describe('useProviderUpdates', () => { vi.clearAllMocks(); mockSettings.merged['modelProviders'] = {}; mockSettings.merged[PROVIDER_METADATA_NS] = {}; + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.USE_OPENAI, + baseUrl: CODING_PLAN_CHINA_BASE_URL, + apiKeyEnvKey: CODING_PLAN_ENV_KEY, + }); mockConfig.getModel.mockReturnValue('qwen3.5-plus'); mockModelsConfig.syncAfterAuthRefresh.mockClear(); delete process.env[CODING_PLAN_ENV_KEY]; @@ -303,6 +314,67 @@ describe('useProviderUpdates', () => { ); expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); expect(mockModelsConfig.syncAfterAuthRefresh).not.toHaveBeenCalled(); + expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); + }); + + it('does not refresh auth when updating an inactive provider on the same protocol', async () => { + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.USE_OPENAI, + baseUrl: TOKEN_PLAN_BASE_URL, + apiKeyEnvKey: TOKEN_PLAN_ENV_KEY, + }); + (mockSettings.merged[PROVIDER_METADATA_NS] as Record)[ + METADATA_KEY + ] = { + baseUrl: CODING_PLAN_CHINA_BASE_URL, + version: 'old-version-hash', + }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: chinaTemplate, + }; + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.providerUpdateRequest).toBeDefined(); + }); + await result.current.providerUpdateRequest!.onConfirm('update'); + + expect(mockConfig.refreshAuth).not.toHaveBeenCalled(); + }); + + it('does not refresh auth before auth initialization completes', async () => { + mockConfig.getContentGeneratorConfig.mockReturnValue(undefined as never); + (mockSettings.merged[PROVIDER_METADATA_NS] as Record)[ + METADATA_KEY + ] = { + baseUrl: CODING_PLAN_CHINA_BASE_URL, + version: 'old-version-hash', + }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: chinaTemplate, + }; + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.providerUpdateRequest).toBeDefined(); + }); + await result.current.providerUpdateRequest!.onConfirm('update'); + + expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); expect(mockConfig.refreshAuth).not.toHaveBeenCalled(); }); diff --git a/packages/cli/src/ui/hooks/useProviderUpdates.ts b/packages/cli/src/ui/hooks/useProviderUpdates.ts index 02eefaae6eb..ec38cf8063b 100644 --- a/packages/cli/src/ui/hooks/useProviderUpdates.ts +++ b/packages/cli/src/ui/hooks/useProviderUpdates.ts @@ -18,6 +18,7 @@ import { computeModelListVersion, getDefaultModelIds, PROVIDER_METADATA_NS, + providerMatchesCredentials, resolveBaseUrl, resolveMetadataKey, resolveOwnsModel, @@ -257,6 +258,14 @@ export function useProviderUpdates( if (previousModelStillAvailable) { delete installPlan.modelSelection; } + const activeConfig = config.getContentGeneratorConfig(); + const updatesActiveProvider = + activeConfig?.authType === providerCfg.protocol && + providerMatchesCredentials( + providerCfg, + activeConfig.baseUrl, + activeConfig.apiKeyEnvKey, + ); await applyProviderInstallPlan(installPlan, { settings: createLoadedSettingsAdapter(settings), @@ -265,8 +274,9 @@ export function useProviderUpdates( config .getModelsConfig() .syncAfterAuthRefresh(authType, modelId, baseUrl), - refreshAuth: (authType) => config.refreshAuth(authType), - doRefreshAuth: false, + ...(updatesActiveProvider && { + refreshAuth: (authType) => config.refreshAuth(authType), + }), }); const activeModel = config.getModel(); diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 9d08aa0be3e..1d155a86fbd 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -6727,6 +6727,20 @@ describe('BaseLlmClient Lifecycle', () => { config, ); }); + + it('clears per-model generators when provider config is reloaded', async () => { + const config = new Config(baseParams); + vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({ + config: { model: 'gemini-flash', apiKey: 'test-key' }, + sources: {}, + }); + await config.refreshAuth(AuthType.USE_GEMINI); + + const llmService = config.getBaseLlmClient(); + config.reloadModelProvidersConfig({}); + + expect(llmService.clearPerModelGeneratorCache).toHaveBeenCalledOnce(); + }); }); describe('Model Switching and Config Updates', () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 7d95333ff26..56520b11a96 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -3131,6 +3131,7 @@ export class Config { modelProvidersConfig, providerProtocolConfig, ); + this.baseLlmClient?.clearPerModelGeneratorCache(); } /** diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index ebfb79d4b3c..cb53eeacd9a 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -137,6 +137,9 @@ export type ContentGeneratorConfig = { customHeaders?: Record; // Extra body parameters to be merged into the request body extra_body?: Record; + // When true, the model rejects enable_thinking=false with a 400 error + // (e.g. qwen3.8-max-preview), so thinking must never be disabled on the wire. + thinkingMandatory?: boolean; // Supported input modalities. Unsupported media types are replaced with text // placeholders. Leave undefined to use automatic detection from model name. modalities?: InputModalities; diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts index a41c28a726e..29e2a408814 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts @@ -8,7 +8,12 @@ import type { Mock } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import type OpenAI from 'openai'; import type { GenerateContentParameters } from '@google/genai'; -import { GenerateContentResponse, Type, FinishReason } from '@google/genai'; +import { + FinishReason, + FunctionCallingConfigMode, + GenerateContentResponse, + Type, +} from '@google/genai'; import type { ErrorHandler, PipelineConfig } from './types.js'; import { ContentGenerationPipeline, @@ -670,6 +675,157 @@ describe('ContentGenerationPipeline', () => { expect(apiCall.enable_thinking).toBe(false); }); + it.each([ + { + name: 'keep thinking for a thinkingMandatory model on Token Plan side queries', + baseUrl: + 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', + model: 'qwen3.8-max-preview', + extraBody: { enable_thinking: true }, + thinkingMandatory: true, + reasoning: undefined, + includeThoughts: false, + expectedThinking: true, + expectedToolChoice: undefined, + }, + { + name: 'apply thinkingMandatory to any qwen model on any DashScope endpoint', + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + model: 'qwen3.9-turbo', + extraBody: { enable_thinking: true }, + thinkingMandatory: true, + reasoning: undefined, + includeThoughts: false, + expectedThinking: true, + expectedToolChoice: undefined, + }, + { + name: 'never emit the disable even under the reasoning opt-out', + baseUrl: + 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', + model: 'qwen3.8-max-preview', + extraBody: { enable_thinking: true }, + thinkingMandatory: true, + reasoning: false, + includeThoughts: false, + expectedThinking: true, + expectedToolChoice: undefined, + }, + { + name: 'still force-disable hybrid models that only declare extra_body.enable_thinking', + baseUrl: + 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', + model: 'qwen3.7-max', + extraBody: { enable_thinking: true }, + thinkingMandatory: undefined, + reasoning: undefined, + includeThoughts: false, + expectedThinking: false, + expectedToolChoice: 'required', + }, + { + name: 'allow automatic tool selection when mandatory thinking stays on', + baseUrl: + 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', + model: 'qwen3.8-max-preview', + extraBody: { enable_thinking: true }, + thinkingMandatory: true, + reasoning: undefined, + includeThoughts: true, + expectedThinking: true, + expectedToolChoice: undefined, + }, + { + name: 'not inherit mandatory thinking through request.model overrides', + baseUrl: + 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', + model: 'qwen3.8-max-preview', + requestModel: 'qwen3.7-max', + extraBody: { enable_thinking: true }, + thinkingMandatory: true, + reasoning: undefined, + includeThoughts: false, + expectedThinking: false, + expectedToolChoice: 'required', + }, + { + name: 'drop a contradictory thinking disable for aliased mandatory models', + baseUrl: + 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', + model: 'token-plan-model-alias', + extraBody: { enable_thinking: false }, + thinkingMandatory: true, + reasoning: undefined, + includeThoughts: false, + expectedThinking: undefined, + expectedToolChoice: undefined, + }, + ])('should $name', async (testCase) => { + mockContentGeneratorConfig = { + ...mockContentGeneratorConfig, + baseUrl: testCase.baseUrl, + model: testCase.model, + extra_body: testCase.extraBody, + thinkingMandatory: testCase.thinkingMandatory, + reasoning: testCase.reasoning, + } as ContentGeneratorConfig; + mockConfig = { + ...mockConfig, + contentGeneratorConfig: mockContentGeneratorConfig, + }; + pipeline = new ContentGenerationPipeline(mockConfig); + + // Simulate the provider merging user extra_body last (see dashscope.ts). + (mockProvider.buildRequest as Mock).mockImplementation((req) => ({ + ...req, + ...(testCase.extraBody ?? {}), + })); + + const request: GenerateContentParameters = { + model: + ('requestModel' in testCase ? testCase.requestModel : undefined) ?? + testCase.model, + contents: [{ parts: [{ text: 'Summarize' }], role: 'user' }], + config: { + thinkingConfig: { includeThoughts: testCase.includeThoughts }, + tools: [ + { + functionDeclarations: [ + { + name: 'respond_in_schema', + parameters: { type: Type.OBJECT, properties: {} }, + }, + ], + }, + ], + toolConfig: { + functionCallingConfig: { mode: FunctionCallingConfigMode.ANY }, + }, + }, + }; + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + { role: 'user', content: 'Summarize' }, + ]); + (mockConverter.convertGeminiToolsToOpenAI as Mock).mockResolvedValue([ + { type: 'function', function: { name: 'respond_in_schema' } }, + ]); + (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue({ + id: 'r', + choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + } as OpenAI.Chat.ChatCompletion); + + await pipeline.execute(request, 'side-query:permissions-classifier'); + + const apiCall = (mockClient.chat.completions.create as Mock).mock + .calls[0][0]; + expect(apiCall.enable_thinking).toBe(testCase.expectedThinking); + expect(apiCall.tool_choice).toBe(testCase.expectedToolChoice); + }); + it('should strip reasoning key from extra_body when thinking is disabled', async () => { // Arrange — provider injects reasoning via extra_body (mockProvider.buildRequest as Mock).mockImplementation((req) => ({ diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index 6f65b978ee0..eea26e11026 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -815,6 +815,21 @@ export class ContentGenerationPipeline { // not just remove the effort knob — otherwise providers whose default // is "thinking enabled" (DeepSeek V4+, qwen3) keep paying thinking // latency/cost. + // + // Exception: `thinkingMandatory` marks models that reject + // `enable_thinking: false` with a 400 (e.g. qwen3.8-max-preview on + // DashScope Token Plan gateways — set by the preset, or by users via + // model generation config). For these, never emit the disable on the + // wire: a "disabled" shape is a guaranteed request failure, so the flag + // also overrides the config-level `reasoning: false` opt-out. + const model = (context.model ?? '').toLowerCase(); + const isDashScope = DashScopeOpenAICompatibleProvider.isDashScopeProvider( + this.contentGeneratorConfig, + ); + const configModel = (this.contentGeneratorConfig.model ?? '').toLowerCase(); + const thinkingMandatory = + this.contentGeneratorConfig.thinkingMandatory === true && + model === configModel; const reasoningDisabled = request.config?.thinkingConfig?.includeThoughts === false || this.contentGeneratorConfig.reasoning === false; @@ -840,13 +855,11 @@ export class ContentGenerationPipeline { // config/models.ts, aliased to Qwen 3.6 Plus hybrid) — it doesn't // start with `qwen` but is the most common hybrid-thinking model // for first-time users, so it must be covered. - const model = (context.model ?? '').toLowerCase(); - if (model.startsWith('qwen') || model === 'coder-model') { - if ( - DashScopeOpenAICompatibleProvider.isDashScopeProvider( - this.contentGeneratorConfig, - ) - ) { + if ( + !thinkingMandatory && + (model.startsWith('qwen') || model === 'coder-model') + ) { + if (isDashScope) { typed['enable_thinking'] = false; } else { // Non-DashScope OpenAI-compatible servers (vLLM, SGLang, ...) render @@ -898,6 +911,17 @@ export class ContentGenerationPipeline { } } + if (thinkingMandatory && isDashScope) { + const typed = providerRequest as unknown as Record; + // DashScope rejects forced tool selection while thinking is enabled. + if (typed['enable_thinking'] === false) { + delete typed['enable_thinking']; + } + if (typed['tool_choice'] === 'required') { + delete typed['tool_choice']; + } + } + return providerRequest; } diff --git a/packages/core/src/hooks/promptHookIntegration.test.ts b/packages/core/src/hooks/promptHookIntegration.test.ts index d8496884ada..1eb9322419a 100644 --- a/packages/core/src/hooks/promptHookIntegration.test.ts +++ b/packages/core/src/hooks/promptHookIntegration.test.ts @@ -43,6 +43,15 @@ describe('Prompt Hook Integration', () => { embedContent: vi.fn(), useSummarizedThinking: vi.fn().mockReturnValue(false), }), + getBaseLlmClient: vi.fn().mockReturnValue({ + resolveForModel: vi.fn().mockResolvedValue({ + contentGenerator: { + generateContent: mockGenerateContent, + }, + contentGeneratorConfig: { model: 'qwen-max' }, + model: 'qwen-max', + }), + }), getProjectRoot: vi.fn().mockReturnValue('/test/project'), getAllowedHttpHookUrls: vi.fn().mockReturnValue([]), getHooks: vi.fn().mockReturnValue({}), diff --git a/packages/core/src/hooks/promptHookRunner.test.ts b/packages/core/src/hooks/promptHookRunner.test.ts index 8fef034118e..3218a23d35b 100644 --- a/packages/core/src/hooks/promptHookRunner.test.ts +++ b/packages/core/src/hooks/promptHookRunner.test.ts @@ -15,12 +15,20 @@ describe('PromptHookRunner', () => { let promptRunner: PromptHookRunner; let mockConfig: Config; let mockGenerateContent: ReturnType; + let mockResolveForModel: ReturnType; beforeEach(() => { vi.clearAllMocks(); // Create mock generateContent function mockGenerateContent = vi.fn(); + mockResolveForModel = vi.fn().mockResolvedValue({ + contentGenerator: { + generateContent: mockGenerateContent, + }, + contentGeneratorConfig: { model: 'qwen-max' }, + model: 'qwen-max', + }); // Create mock config mockConfig = { @@ -36,6 +44,9 @@ describe('PromptHookRunner', () => { embedContent: vi.fn(), useSummarizedThinking: vi.fn().mockReturnValue(false), }), + getBaseLlmClient: vi.fn().mockReturnValue({ + resolveForModel: mockResolveForModel, + }), } as unknown as Config; promptRunner = new PromptHookRunner(mockConfig); @@ -201,6 +212,31 @@ describe('PromptHookRunner', () => { const callArg = mockGenerateContent.mock.calls[0][0]; expect(callArg.model).toBe('qwen-max'); + expect(mockResolveForModel).toHaveBeenCalledWith('qwen-max', { + failClosed: true, + }); + }); + + it('should shape requests for the resolved override model', async () => { + mockResolveForModel.mockResolvedValue({ + contentGenerator: { generateContent: mockGenerateContent }, + contentGeneratorConfig: { + model: 'qwen-max', + reasoning: { effort: 'high' }, + }, + model: 'qwen-max', + }); + mockGenerateContent.mockResolvedValue(createMockResponse('{"ok": true}')); + + await promptRunner.execute( + createMockConfig({ model: 'fast' }), + HookEventName.PreToolUse, + createMockInput(), + ); + + const callArg = mockGenerateContent.mock.calls[0][0]; + expect(callArg.model).toBe('qwen-max'); + expect(callArg.config?.temperature).toBeUndefined(); }); it('should handle response wrapped in markdown code block', async () => { @@ -333,6 +369,39 @@ describe('PromptHookRunner', () => { expect(result.outcome).toBe('cancelled'); }); + it('should time out while resolving an override model', async () => { + vi.useFakeTimers(); + let finishResolution: (() => void) | undefined; + mockResolveForModel.mockReturnValue( + new Promise((resolve) => { + finishResolution = () => + resolve({ + contentGenerator: { generateContent: mockGenerateContent }, + contentGeneratorConfig: { model: 'qwen-max' }, + model: 'qwen-max', + }); + }), + ); + + try { + const execution = promptRunner.execute( + createMockConfig({ model: 'qwen-max', timeout: 0.1 }), + HookEventName.PreToolUse, + createMockInput(), + ); + + await vi.advanceTimersByTimeAsync(100); + const result = await execution; + + expect(result.outcome).toBe('cancelled'); + finishResolution?.(); + await Promise.resolve(); + expect(mockGenerateContent).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it('should handle abort signal (already aborted)', async () => { const controller = new AbortController(); controller.abort(); diff --git a/packages/core/src/hooks/promptHookRunner.ts b/packages/core/src/hooks/promptHookRunner.ts index 4e9b524267a..74c8534d032 100644 --- a/packages/core/src/hooks/promptHookRunner.ts +++ b/packages/core/src/hooks/promptHookRunner.ts @@ -16,6 +16,7 @@ import type { HookEventName, } from './types.js'; import type { Config } from '../config/config.js'; +import type { ContentGeneratorConfig } from '../core/contentGenerator.js'; import type { Content, GenerateContentResponse } from '@google/genai'; const debugLogger = createDebugLogger('PROMPT_HOOK_RUNNER'); @@ -187,8 +188,10 @@ export class PromptHookRunner { * Check whether the current prompt hook model should be treated as a * reasoning model for request-shaping compatibility. */ - private isReasoningModel(model: string): boolean { - const reasoningConfig = this.config.getContentGeneratorConfig().reasoning; + private isReasoningModel( + model: string, + reasoningConfig: ContentGeneratorConfig['reasoning'], + ): boolean { if (reasoningConfig !== undefined && reasoningConfig !== false) { return true; } @@ -210,18 +213,6 @@ export class PromptHookRunner { timeoutMs: number, signal?: AbortSignal, ): Promise { - const generator = this.config.getContentGenerator(); - if (!generator) { - const error = new Error( - 'ContentGenerator not available - make sure you are authenticated', - ); - debugLogger.error( - 'Prompt hook failed: ContentGenerator not available', - error, - ); - throw error; - } - // Build contents array const contents: Content[] = [ { @@ -240,15 +231,54 @@ export class PromptHookRunner { // Create timeout promise that also aborts the request let timeoutId: ReturnType | undefined; + let abortHandler: (() => void) | undefined; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { internalAbortController.abort(); reject(new Error(`Prompt hook timed out after ${timeoutMs}ms`)); }, timeoutMs); }); + const abortPromise = new Promise((_, reject) => { + if (!signal) return; + if (signal.aborted) { + reject(new Error('Prompt hook execution aborted')); + return; + } + abortHandler = () => reject(new Error('Prompt hook execution aborted')); + signal.addEventListener('abort', abortHandler, { once: true }); + }); try { - const isReasoningModel = this.isReasoningModel(model); + let generator = this.config.getContentGenerator(); + let requestModel = model; + let reasoningConfig = this.config.getContentGeneratorConfig().reasoning; + if (!generator) { + const error = new Error( + 'ContentGenerator not available - make sure you are authenticated', + ); + debugLogger.error( + 'Prompt hook failed: ContentGenerator not available', + error, + ); + throw error; + } + if (model !== this.getModel()) { + const resolved = await Promise.race([ + this.config + .getBaseLlmClient() + .resolveForModel(model, { failClosed: true }), + timeoutPromise, + ...(signal ? [abortPromise] : []), + ]); + generator = resolved.contentGenerator; + requestModel = resolved.model; + reasoningConfig = resolved.contentGeneratorConfig.reasoning; + } + + const isReasoningModel = this.isReasoningModel( + requestModel, + reasoningConfig, + ); const requestConfig = { abortSignal: internalSignal, systemInstruction: { @@ -269,21 +299,22 @@ export class PromptHookRunner { // output budget on hidden thoughts for hook evaluation. reasoning: false, // Thoughts are filtered out post-hoc anyway; skip generating - // them so we don't pay for reasoning tokens we discard. + // them so we don't pay to generate reasoning tokens we discard. thinkingConfig: { includeThoughts: false }, }; - // Race between LLM call and timeout + internalSignal.throwIfAborted(); const response = await Promise.race([ generator.generateContent( { - model, + model: requestModel, contents, config: requestConfig, }, 'prompt_hook', ), timeoutPromise, + ...(signal ? [abortPromise] : []), ]); const finishReason = (response as GenerateContentResponse).candidates?.[0] @@ -314,6 +345,9 @@ export class PromptHookRunner { if (timeoutId) { clearTimeout(timeoutId); } + if (signal && abortHandler) { + signal.removeEventListener('abort', abortHandler); + } // Trigger reverse-cleanup of the parent-signal listener on the // success path; no-op if already aborted via parent/timeout. internalAbortController.abort(); diff --git a/packages/core/src/models/constants.ts b/packages/core/src/models/constants.ts index 761c34851ed..d08a19cc328 100644 --- a/packages/core/src/models/constants.ts +++ b/packages/core/src/models/constants.ts @@ -30,6 +30,7 @@ export const MODEL_GENERATION_CONFIG_FIELDS = [ 'contextWindowSize', 'customHeaders', 'extra_body', + 'thinkingMandatory', 'modalities', 'splitToolMedia', 'toolResultContentFormat', diff --git a/packages/core/src/models/content-generator-config.test.ts b/packages/core/src/models/content-generator-config.test.ts index a0035c072f5..a028a007991 100644 --- a/packages/core/src/models/content-generator-config.test.ts +++ b/packages/core/src/models/content-generator-config.test.ts @@ -72,6 +72,19 @@ describe('buildAgentContentGeneratorConfig', () => { expect(result.contextWindowSize).toBe(128000); expect(result.extra_body).toEqual({ custom: 'value' }); }); + + it('does not inherit mandatory thinking from another model', () => { + const config = createMockConfig({ + ...parentConfig, + thinkingMandatory: true, + }); + + const result = buildAgentContentGeneratorConfig(config, 'custom-model', { + authType: 'openai', + }); + + expect(result.thinkingMandatory).toBeUndefined(); + }); }); describe('cross-provider, no registry match', () => { @@ -210,6 +223,24 @@ describe('buildAgentContentGeneratorConfig', () => { 'https://registry.example.com', ); }); + + it('does not inherit mandatory thinking from another same-provider model', () => { + const config = createMockConfig( + { ...parentConfig, thinkingMandatory: true }, + { + ...resolvedModel, + authType: 'openai' as ResolvedModelConfig['authType'], + }, + ); + + const result = buildAgentContentGeneratorConfig( + config, + 'registry-model-id', + { authType: 'openai' }, + ); + + expect(result.thinkingMandatory).toBeUndefined(); + }); }); describe('edge cases', () => { diff --git a/packages/core/src/models/content-generator-config.ts b/packages/core/src/models/content-generator-config.ts index a68b3dbfbf9..2fc3200ae71 100644 --- a/packages/core/src/models/content-generator-config.ts +++ b/packages/core/src/models/content-generator-config.ts @@ -82,6 +82,10 @@ export function buildAgentContentGeneratorConfig( return nextConfig; } + if (modelId && modelId !== parentConfig.model) { + nextConfig.thinkingMandatory = undefined; + } + nextConfig.apiKey = resolveCredentialField( authOverrides.apiKey, sameProvider ? parentConfig.apiKey : undefined, @@ -163,12 +167,12 @@ function applyResolvedModelConfig( : undefined; } - // Apply registry-defined generation config fields. Cross-provider - // clearing is already handled by buildAgentContentGeneratorConfig, - // so here we only overwrite when the registry provides a value. + // Cross-provider fields are cleared by buildAgentContentGeneratorConfig. + // Same-provider fields inherit unless the registry overrides them, except + // model capabilities such as thinkingMandatory, which must not leak. for (const field of MODEL_GENERATION_CONFIG_FIELDS) { const registryValue = resolvedModel.generationConfig[field]; - if (registryValue !== undefined) { + if (registryValue !== undefined || field === 'thinkingMandatory') { // eslint-disable-next-line @typescript-eslint/no-explicit-any (targetConfig as any)[field] = registryValue; } diff --git a/packages/core/src/models/types.ts b/packages/core/src/models/types.ts index 4295a5892b8..668eebe6ccf 100644 --- a/packages/core/src/models/types.ts +++ b/packages/core/src/models/types.ts @@ -39,6 +39,7 @@ export type ModelGenerationConfig = Pick< | 'reasoning' | 'customHeaders' | 'extra_body' + | 'thinkingMandatory' | 'contextWindowSize' | 'modalities' | 'splitToolMedia' diff --git a/packages/core/src/providers/presets/alibaba-token-plan.ts b/packages/core/src/providers/presets/alibaba-token-plan.ts index 3431a57084e..5b51849ad31 100644 --- a/packages/core/src/providers/presets/alibaba-token-plan.ts +++ b/packages/core/src/providers/presets/alibaba-token-plan.ts @@ -33,6 +33,7 @@ const TOKEN_PLAN_MODELS: ModelSpec[] = [ id: 'qwen3.8-max-preview', contextWindowSize: 1000000, enableThinking: true, + thinkingMandatory: true, modalities: { image: true, video: true }, }, { diff --git a/packages/core/src/providers/provider-config.ts b/packages/core/src/providers/provider-config.ts index 23cb0581cab..5d5603155c8 100644 --- a/packages/core/src/providers/provider-config.ts +++ b/packages/core/src/providers/provider-config.ts @@ -59,7 +59,10 @@ export function resolveOwnsModel( } function buildGenerationConfig( - spec: Pick, + spec: Pick< + ModelSpec, + 'enableThinking' | 'thinkingMandatory' | 'contextWindowSize' | 'modalities' + >, ): ProviderModelConfig['generationConfig'] | undefined { const parts: ProviderModelConfig['generationConfig'] = {}; let hasAny = false; @@ -67,6 +70,10 @@ function buildGenerationConfig( parts.extra_body = { enable_thinking: true }; hasAny = true; } + if (spec.thinkingMandatory) { + parts.thinkingMandatory = true; + hasAny = true; + } if (spec.contextWindowSize) { parts.contextWindowSize = spec.contextWindowSize; hasAny = true; diff --git a/packages/core/src/providers/types.ts b/packages/core/src/providers/types.ts index abe356ce55d..b871283a524 100644 --- a/packages/core/src/providers/types.ts +++ b/packages/core/src/providers/types.ts @@ -20,6 +20,7 @@ export interface ModelSpec { id: string; contextWindowSize?: number; enableThinking?: boolean; + thinkingMandatory?: boolean; modalities?: InputModalities; description?: string; }