Skip to content
72 changes: 72 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,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<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();
});

it('does not refresh auth before auth initialization completes', async () => {
mockConfig.getContentGeneratorConfig.mockReturnValue(undefined as never);
(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.reloadModelProvidersConfig).toHaveBeenCalled();
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
158 changes: 157 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,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) => ({
Expand Down
Loading
Loading