Skip to content
43 changes: 43 additions & 0 deletions packages/cli/src/ui/hooks/useProviderUpdates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
CODING_PLAN_ENV_KEY,
codingPlanProvider,
TOKEN_PLAN_BASE_URL,
TOKEN_PLAN_ENV_KEY,
tokenPlanProvider,
buildProviderTemplate,
computeModelListVersion,
Expand Down Expand Up @@ -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),
};
Expand All @@ -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];
Expand Down Expand Up @@ -303,6 +314,38 @@ 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<string, unknown>)[
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();
});

Expand Down
14 changes: 12 additions & 2 deletions packages/cli/src/ui/hooks/useProviderUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
computeModelListVersion,
getDefaultModelIds,
PROVIDER_METADATA_NS,
providerMatchesCredentials,
resolveBaseUrl,
resolveMetadataKey,
resolveOwnsModel,
Expand Down Expand Up @@ -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),
Expand All @@ -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();
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3131,6 +3131,7 @@ export class Config {
modelProvidersConfig,
providerProtocolConfig,
);
this.baseLlmClient?.clearPerModelGeneratorCache();
}

/**
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/core/contentGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ export type ContentGeneratorConfig = {
customHeaders?: Record<string, string>;
// Extra body parameters to be merged into the request body
extra_body?: Record<string, unknown>;
// 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;
Expand Down
131 changes: 130 additions & 1 deletion packages/core/src/core/openaiContentGenerator/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -670,6 +675,130 @@ 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,
},
])('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: 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) => ({
Expand Down
40 changes: 33 additions & 7 deletions packages/core/src/core/openaiContentGenerator/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,19 @@ 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 thinkingMandatory =
this.contentGeneratorConfig.thinkingMandatory === true;
const reasoningDisabled =
request.config?.thinkingConfig?.includeThoughts === false ||
this.contentGeneratorConfig.reasoning === false;
Expand All @@ -840,13 +853,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
Expand Down Expand Up @@ -898,6 +909,21 @@ export class ContentGenerationPipeline {
}
}

if (
thinkingMandatory &&
isDashScope &&
(model.startsWith('qwen') || model === 'coder-model')
) {
const typed = providerRequest as unknown as Record<string, unknown>;
// DashScope rejects forced tool selection while thinking is enabled.
if (
typed['enable_thinking'] !== false &&
typed['tool_choice'] === 'required'
) {
delete typed['tool_choice'];
}
}

return providerRequest;
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/models/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const MODEL_GENERATION_CONFIG_FIELDS = [
'contextWindowSize',
'customHeaders',
'extra_body',
'thinkingMandatory',
'modalities',
'splitToolMedia',
'toolResultContentFormat',
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/models/content-generator-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,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', () => {
Expand Down
Loading
Loading