diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index 3ce25bfa9c..24e7c1fd0f 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -16,7 +16,7 @@ import { AuthType } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; import { - AVAILABLE_MODELS_QWEN, + getFilteredQwenModels, MAINLINE_CODER, MAINLINE_VLM, } from '../models/availableModels.js'; @@ -29,6 +29,19 @@ const mockedUseKeypress = vi.mocked(useKeypress); vi.mock('./shared/DescriptiveRadioButtonSelect.js', () => ({ DescriptiveRadioButtonSelect: vi.fn(() => null), })); + +// Helper to create getAvailableModelsForAuthType mock +const createMockGetAvailableModelsForAuthType = () => + vi.fn((t: AuthType) => { + if (t === AuthType.QWEN_OAUTH) { + return getFilteredQwenModels(true).map((m) => ({ + id: m.id, + label: m.label, + authType: AuthType.QWEN_OAUTH, + })); + } + return []; + }); const mockedSelect = vi.mocked(DescriptiveRadioButtonSelect); const renderComponent = ( @@ -54,7 +67,7 @@ const renderComponent = ( switchModel: vi.fn().mockResolvedValue(undefined), getAuthType: vi.fn(() => 'qwen-oauth'), getAllConfiguredModels: vi.fn(() => - AVAILABLE_MODELS_QWEN.map((m) => ({ + getFilteredQwenModels(true).map((m) => ({ id: m.id, label: m.label, description: m.description || '', @@ -116,24 +129,38 @@ describe('', () => { expect(mockedSelect).toHaveBeenCalledTimes(1); const props = mockedSelect.mock.calls[0][0]; - expect(props.items).toHaveLength(AVAILABLE_MODELS_QWEN.length); + expect(props.items).toHaveLength(getFilteredQwenModels(true).length); expect(props.items[0].value).toBe( `${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`, ); - expect(props.items[1].value).toBe( - `${AuthType.QWEN_OAUTH}::${MAINLINE_VLM}`, + // Find vision model in the list (it's not necessarily at index 1 anymore) + const visionModelItem = props.items.find( + (item) => + typeof item.value === 'string' && + item.value.endsWith(`::${MAINLINE_VLM}`), ); + expect(visionModelItem).toBeDefined(); expect(props.showNumbers).toBe(true); }); it('initializes with the model from ConfigContext', () => { const mockGetModel = vi.fn(() => MAINLINE_VLM); - renderComponent({}, { getModel: mockGetModel }); + renderComponent( + {}, + { + getModel: mockGetModel, + getAvailableModelsForAuthType: + createMockGetAvailableModelsForAuthType(), + }, + ); expect(mockGetModel).toHaveBeenCalled(); + // Calculate expected index dynamically based on model list + const qwenModels = getFilteredQwenModels(true); + const expectedIndex = qwenModels.findIndex((m) => m.id === MAINLINE_VLM); expect(mockedSelect).toHaveBeenCalledWith( expect.objectContaining({ - initialIndex: 1, + initialIndex: expectedIndex, }), undefined, ); @@ -151,10 +178,15 @@ describe('', () => { }); it('initializes with default coder model if getModel returns undefined', () => { - const mockGetModel = vi.fn(() => undefined); - // @ts-expect-error This test validates component robustness when getModel - // returns an unexpected undefined value. - renderComponent({}, { getModel: mockGetModel }); + const mockGetModel = vi.fn(() => undefined as unknown as string); + renderComponent( + {}, + { + getModel: mockGetModel, + getAvailableModelsForAuthType: + createMockGetAvailableModelsForAuthType(), + }, + ); expect(mockGetModel).toHaveBeenCalled(); @@ -170,7 +202,21 @@ describe('', () => { }); it('calls config.switchModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', async () => { - const { props, mockConfig, mockSettings } = renderComponent({}, {}); // Pass empty object for contextValue + const { props, mockConfig, mockSettings } = renderComponent( + {}, + { + getAvailableModelsForAuthType: vi.fn((t: AuthType) => { + if (t === AuthType.QWEN_OAUTH) { + return getFilteredQwenModels(true).map((m) => ({ + id: m.id, + label: m.label, + authType: AuthType.QWEN_OAUTH, + })); + } + return []; + }), + }, + ); const childOnSelect = mockedSelect.mock.calls[0][0].onSelect; expect(childOnSelect).toBeDefined(); @@ -203,7 +249,7 @@ describe('', () => { return [{ id: 'gpt-4', label: 'GPT-4', authType: t }]; } if (t === AuthType.QWEN_OAUTH) { - return AVAILABLE_MODELS_QWEN.map((m) => ({ + return getFilteredQwenModels(true).map((m) => ({ id: m.id, label: m.label, authType: AuthType.QWEN_OAUTH, @@ -305,8 +351,10 @@ describe('', () => { { getModel: mockGetModel, getAuthType: mockGetAuthType, + getAvailableModelsForAuthType: + createMockGetAvailableModelsForAuthType(), getAllConfiguredModels: vi.fn(() => - AVAILABLE_MODELS_QWEN.map((m) => ({ + getFilteredQwenModels(true).map((m) => ({ id: m.id, label: m.label, description: m.description || '', @@ -321,14 +369,16 @@ describe('', () => { , ); + // MAINLINE_CODER (qwen3-coder-next) is at index 0 expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0); mockGetModel.mockReturnValue(MAINLINE_VLM); const newMockConfig = { getModel: mockGetModel, getAuthType: mockGetAuthType, + getAvailableModelsForAuthType: createMockGetAvailableModelsForAuthType(), getAllConfiguredModels: vi.fn(() => - AVAILABLE_MODELS_QWEN.map((m) => ({ + getFilteredQwenModels(true).map((m) => ({ id: m.id, label: m.label, description: m.description || '', @@ -347,6 +397,9 @@ describe('', () => { // Should be called at least twice: initial render + re-render after context change expect(mockedSelect).toHaveBeenCalledTimes(2); - expect(mockedSelect.mock.calls[1][0].initialIndex).toBe(1); + // Calculate expected index for MAINLINE_VLM dynamically + const qwenModels = getFilteredQwenModels(true); + const expectedVlmIndex = qwenModels.findIndex((m) => m.id === MAINLINE_VLM); + expect(mockedSelect.mock.calls[1][0].initialIndex).toBe(expectedVlmIndex); }); }); diff --git a/packages/cli/src/ui/models/availableModels.test.ts b/packages/cli/src/ui/models/availableModels.test.ts index feac835c66..a17405d384 100644 --- a/packages/cli/src/ui/models/availableModels.test.ts +++ b/packages/cli/src/ui/models/availableModels.test.ts @@ -11,26 +11,23 @@ import { getOpenAIAvailableModelFromEnv, isVisionModel, getDefaultVisionModel, - AVAILABLE_MODELS_QWEN, MAINLINE_VLM, MAINLINE_CODER, } from './availableModels.js'; import { AuthType, type Config } from '@qwen-code/qwen-code-core'; describe('availableModels', () => { - describe('AVAILABLE_MODELS_QWEN', () => { + describe('Qwen models', () => { + const qwenModels = getFilteredQwenModels(true); + it('should include coder model', () => { - const coderModel = AVAILABLE_MODELS_QWEN.find( - (m) => m.id === MAINLINE_CODER, - ); + const coderModel = qwenModels.find((m) => m.id === MAINLINE_CODER); expect(coderModel).toBeDefined(); expect(coderModel?.isVision).toBeFalsy(); }); it('should include vision model', () => { - const visionModel = AVAILABLE_MODELS_QWEN.find( - (m) => m.id === MAINLINE_VLM, - ); + const visionModel = qwenModels.find((m) => m.id === MAINLINE_VLM); expect(visionModel).toBeDefined(); expect(visionModel?.isVision).toBe(true); }); @@ -39,7 +36,8 @@ describe('availableModels', () => { describe('getFilteredQwenModels', () => { it('should return all models when vision preview is enabled', () => { const models = getFilteredQwenModels(true); - expect(models.length).toBe(AVAILABLE_MODELS_QWEN.length); + const expected = getAvailableModelsForAuthType(AuthType.QWEN_OAUTH); + expect(models.length).toBe(expected.length); }); it('should filter out vision models when preview is disabled', () => { @@ -91,23 +89,34 @@ describe('availableModels', () => { it('should return hard-coded qwen models for qwen-oauth', () => { const models = getAvailableModelsForAuthType(AuthType.QWEN_OAUTH); - expect(models).toEqual(AVAILABLE_MODELS_QWEN); + expect(models).toEqual(getFilteredQwenModels(true)); }); - it('should return hard-coded qwen models even when config is provided', () => { + it('should use config models for qwen-oauth when config is provided', () => { const mockConfig = { - getAvailableModels: vi - .fn() - .mockReturnValue([ - { id: 'custom', label: 'Custom', authType: AuthType.QWEN_OAUTH }, - ]), + getAvailableModelsForAuthType: vi.fn().mockReturnValue([ + { + id: 'custom', + label: 'Custom', + description: 'Custom model', + authType: AuthType.QWEN_OAUTH, + isVision: false, + }, + ]), } as unknown as Config; const models = getAvailableModelsForAuthType( AuthType.QWEN_OAUTH, mockConfig, ); - expect(models).toEqual(AVAILABLE_MODELS_QWEN); + expect(models).toEqual([ + { + id: 'custom', + label: 'Custom', + description: 'Custom model', + isVision: false, + }, + ]); }); it('should use config.getAvailableModels for openai authType when available', () => { diff --git a/packages/cli/src/ui/models/availableModels.ts b/packages/cli/src/ui/models/availableModels.ts index 1cff984c82..a68cc69cc7 100644 --- a/packages/cli/src/ui/models/availableModels.ts +++ b/packages/cli/src/ui/models/availableModels.ts @@ -9,6 +9,7 @@ import { DEFAULT_QWEN_MODEL, type Config, type AvailableModel as CoreAvailableModel, + QWEN_OAUTH_MODELS, } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; @@ -22,27 +23,18 @@ export type AvailableModel = { export const MAINLINE_VLM = 'vision-model'; export const MAINLINE_CODER = DEFAULT_QWEN_MODEL; -export const AVAILABLE_MODELS_QWEN: AvailableModel[] = [ - { - id: MAINLINE_CODER, - label: MAINLINE_CODER, - get description() { - return t( - 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)', - ); - }, - }, - { - id: MAINLINE_VLM, - label: MAINLINE_VLM, - get description() { - return t( - 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)', - ); - }, - isVision: true, - }, -]; +const CACHED_QWEN_OAUTH_MODELS: AvailableModel[] = QWEN_OAUTH_MODELS.map( + (model) => ({ + id: model.id, + label: model.name ?? model.id, + description: model.description, + isVision: model.capabilities?.vision ?? false, + }), +); + +function getQwenOAuthModels(): readonly AvailableModel[] { + return CACHED_QWEN_OAUTH_MODELS; +} /** * Get available Qwen models filtered by vision model preview setting @@ -50,10 +42,11 @@ export const AVAILABLE_MODELS_QWEN: AvailableModel[] = [ export function getFilteredQwenModels( visionModelPreviewEnabled: boolean, ): AvailableModel[] { + const qwenModels = getQwenOAuthModels(); if (visionModelPreviewEnabled) { - return AVAILABLE_MODELS_QWEN; + return [...qwenModels]; } - return AVAILABLE_MODELS_QWEN.filter((model) => !model.isVision); + return qwenModels.filter((model) => !model.isVision); } /** @@ -104,18 +97,12 @@ function convertCoreModelToCliModel( * Get available models for the given authType. * * If a Config object is provided, uses config.getAvailableModelsForAuthType(). - * For qwen-oauth, always returns the hard-coded models. * Falls back to environment variables only when no config is provided. */ export function getAvailableModelsForAuthType( authType: AuthType, config?: Config, ): AvailableModel[] { - // For qwen-oauth, always use hard-coded models, this aligns with the API gateway. - if (authType === AuthType.QWEN_OAUTH) { - return AVAILABLE_MODELS_QWEN; - } - // Use config's model registry when available if (config) { try { @@ -134,6 +121,9 @@ export function getAvailableModelsForAuthType( // Fall back to environment variables for specific auth types (no config provided) switch (authType) { + case AuthType.QWEN_OAUTH: { + return [...getQwenOAuthModels()]; + } case AuthType.USE_OPENAI: { const openAIModel = getOpenAIAvailableModelFromEnv(); return openAIModel ? [openAIModel] : []; @@ -156,7 +146,7 @@ export function getDefaultVisionModel(): string { } export function isVisionModel(modelId: string): boolean { - return AVAILABLE_MODELS_QWEN.some( + return getQwenOAuthModels().some( (model) => model.id === modelId && model.isVision, ); } diff --git a/packages/core/src/config/models.ts b/packages/core/src/config/models.ts index a07dec7cec..5f4d8d0d3f 100644 --- a/packages/core/src/config/models.ts +++ b/packages/core/src/config/models.ts @@ -4,6 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -export const DEFAULT_QWEN_MODEL = 'coder-model'; -export const DEFAULT_QWEN_FLASH_MODEL = 'coder-model'; +export const DEFAULT_QWEN_MODEL = 'qwen3-coder-next'; +export const DEFAULT_QWEN_FLASH_MODEL = 'qwen3-coder-next'; export const DEFAULT_QWEN_EMBEDDING_MODEL = 'text-embedding-v4'; diff --git a/packages/core/src/core/tokenLimits.ts b/packages/core/src/core/tokenLimits.ts index c20bd16a7f..e79c00029f 100644 --- a/packages/core/src/core/tokenLimits.ts +++ b/packages/core/src/core/tokenLimits.ts @@ -115,12 +115,13 @@ const PATTERNS: Array<[RegExp, TokenCount]> = [ // ------------------- // Commercial Qwen3-Coder-Plus: 1M token context [/^qwen3-coder-plus(-.*)?$/, LIMITS['1m']], // catches "qwen3-coder-plus" and date variants + [/^qwen3-coder-next(-.*)?$/, LIMITS['1m']], // catches "qwen3-coder-next" and date variants // Commercial Qwen3-Coder-Flash: 1M token context [/^qwen3-coder-flash(-.*)?$/, LIMITS['1m']], // catches "qwen3-coder-flash" and date variants // Generic coder-model: same as qwen3-coder-plus (1M token context) - [/^coder-model$/, LIMITS['1m']], + [/^coder-model(-.*)?$/, LIMITS['1m']], // Commercial Qwen3-Max-Preview: 256K token context [/^qwen3-max(-preview)?(-.*)?$/, LIMITS['256k']], // catches "qwen3-max" or "qwen3-max-preview" and date variants @@ -198,9 +199,10 @@ const OUTPUT_PATTERNS: Array<[RegExp, TokenCount]> = [ // ------------------- // Qwen3-Coder-Plus: 65,536 max output tokens [/^qwen3-coder-plus(-.*)?$/, LIMITS['64k']], + [/^qwen3-coder-next(-.*)?$/, LIMITS['64k']], // Generic coder-model: same as qwen3-coder-plus (64K max output tokens) - [/^coder-model$/, LIMITS['64k']], + [/^coder-model(-.*)?$/, LIMITS['64k']], // Qwen3-Max: 65,536 max output tokens [/^qwen3-max(-preview)?(-.*)?$/, LIMITS['64k']], diff --git a/packages/core/src/models/constants.ts b/packages/core/src/models/constants.ts index 9b4cc2ce70..7a40c73338 100644 --- a/packages/core/src/models/constants.ts +++ b/packages/core/src/models/constants.ts @@ -88,24 +88,26 @@ export const AUTH_ENV_MAPPINGS = { } as const satisfies Record; export const DEFAULT_MODELS = { - openai: 'qwen3-coder-plus', + openai: 'qwen3-coder-next', 'qwen-oauth': DEFAULT_QWEN_MODEL, } as Partial>; -export const QWEN_OAUTH_ALLOWED_MODELS = [ - DEFAULT_QWEN_MODEL, - 'vision-model', -] as const; - /** * Hard-coded Qwen OAuth models that are always available. * These cannot be overridden by user configuration. */ export const QWEN_OAUTH_MODELS: ModelConfig[] = [ + { + id: 'qwen3-coder-next', + name: 'qwen3-coder-next', + description: 'The latest Qwen Coder model from Alibaba Cloud ModelStudio', + capabilities: { vision: false }, + }, { id: 'coder-model', name: 'coder-model', - description: 'The latest Qwen Coder model from Alibaba Cloud ModelStudio', + description: + 'The stable qwen3-coder-plus model from Alibaba Cloud ModelStudio', capabilities: { vision: false }, }, { @@ -115,3 +117,11 @@ export const QWEN_OAUTH_MODELS: ModelConfig[] = [ capabilities: { vision: true }, }, ]; + +/** + * Derive allowed models from QWEN_OAUTH_MODELS for authorization. + * This ensures single source of truth (SSOT). + */ +export const QWEN_OAUTH_ALLOWED_MODELS = QWEN_OAUTH_MODELS.map( + (model) => model.id, +) as readonly string[]; diff --git a/packages/core/src/models/modelConfigResolver.test.ts b/packages/core/src/models/modelConfigResolver.test.ts index a69ca678ed..1668cac917 100644 --- a/packages/core/src/models/modelConfigResolver.test.ts +++ b/packages/core/src/models/modelConfigResolver.test.ts @@ -95,7 +95,7 @@ describe('modelConfigResolver', () => { }, }); - expect(result.config.model).toBe('qwen3-coder-plus'); + expect(result.config.model).toBe('qwen3-coder-next'); expect(result.sources['model'].kind).toBe('default'); }); diff --git a/packages/core/src/models/modelRegistry.test.ts b/packages/core/src/models/modelRegistry.test.ts index 01ccc82079..7fbb9e5702 100644 --- a/packages/core/src/models/modelRegistry.test.ts +++ b/packages/core/src/models/modelRegistry.test.ts @@ -16,8 +16,9 @@ describe('ModelRegistry', () => { const qwenModels = registry.getModelsForAuthType(AuthType.QWEN_OAUTH); expect(qwenModels.length).toBe(QWEN_OAUTH_MODELS.length); - expect(qwenModels[0].id).toBe('coder-model'); - expect(qwenModels[1].id).toBe('vision-model'); + expect(qwenModels[0].id).toBe('qwen3-coder-next'); + expect(qwenModels[1].id).toBe('coder-model'); + expect(qwenModels[2].id).toBe('vision-model'); }); it('should initialize with empty config', () => { @@ -188,12 +189,12 @@ describe('ModelRegistry', () => { }); describe('getDefaultModelForAuthType', () => { - it('should return coder-model for qwen-oauth', () => { + it('should return qwen3-coder-next for qwen-oauth', () => { const registry = new ModelRegistry(); const defaultModel = registry.getDefaultModelForAuthType( AuthType.QWEN_OAUTH, ); - expect(defaultModel?.id).toBe('coder-model'); + expect(defaultModel?.id).toBe('qwen3-coder-next'); }); it('should return first model for other authTypes', () => { diff --git a/packages/core/src/models/modelsConfig.test.ts b/packages/core/src/models/modelsConfig.test.ts index 03a7248295..c459607f35 100644 --- a/packages/core/src/models/modelsConfig.test.ts +++ b/packages/core/src/models/modelsConfig.test.ts @@ -507,7 +507,7 @@ describe('ModelsConfig', () => { ); const gc = currentGenerationConfig(modelsConfig); - expect(gc.model).toBe('coder-model'); + expect(gc.model).toBe('qwen3-coder-next'); expect(gc.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN'); expect(gc.apiKeyEnvKey).toBeUndefined(); }); @@ -528,8 +528,8 @@ describe('ModelsConfig', () => { modelsConfig.syncAfterAuthRefresh(AuthType.QWEN_OAUTH, 'gpt-4o'); const gc = currentGenerationConfig(modelsConfig); - // Should use default qwen-oauth model (coder-model), not the OPENAI model - expect(gc.model).toBe('coder-model'); + // Should use default qwen-oauth model (qwen3-coder-next), not the OPENAI model + expect(gc.model).toBe('qwen3-coder-next'); expect(gc.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN'); expect(gc.apiKeyEnvKey).toBeUndefined(); }); @@ -559,7 +559,7 @@ describe('ModelsConfig', () => { const gc = currentGenerationConfig(modelsConfig); // Should use default qwen-oauth model, not preserve manual OpenAI credentials - expect(gc.model).toBe('coder-model'); + expect(gc.model).toBe('qwen3-coder-next'); expect(gc.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN'); // baseUrl should be set to qwen-oauth default, not preserved from manual OpenAI config expect(gc.baseUrl).toBe('DYNAMIC_QWEN_OAUTH_BASE_URL'); @@ -638,7 +638,7 @@ describe('ModelsConfig', () => { modelProvidersConfig, generationConfig: {}, }); - expect(config3.getModel()).toBe('coder-model'); // Falls back to DEFAULT_QWEN_MODEL + expect(config3.getModel()).toBe('qwen3-coder-next'); // Falls back to DEFAULT_QWEN_MODEL expect(config3.getGenerationConfig().model).toBeUndefined(); });