Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 69 additions & 16 deletions packages/cli/src/ui/components/ModelDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 = (
Expand All @@ -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 || '',
Expand Down Expand Up @@ -116,24 +129,38 @@ describe('<ModelDialog />', () => {
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,
);
Expand All @@ -151,10 +178,15 @@ describe('<ModelDialog />', () => {
});

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();

Expand All @@ -170,7 +202,21 @@ describe('<ModelDialog />', () => {
});

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();
Expand Down Expand Up @@ -203,7 +249,7 @@ describe('<ModelDialog />', () => {
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,
Expand Down Expand Up @@ -305,8 +351,10 @@ describe('<ModelDialog />', () => {
{
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 || '',
Expand All @@ -321,14 +369,16 @@ describe('<ModelDialog />', () => {
</SettingsContext.Provider>,
);

// 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 || '',
Expand All @@ -347,6 +397,9 @@ describe('<ModelDialog />', () => {

// 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);
});
});
43 changes: 26 additions & 17 deletions packages/cli/src/ui/models/availableModels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
50 changes: 20 additions & 30 deletions packages/cli/src/ui/models/availableModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -22,38 +23,30 @@ 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
*/
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);
}

/**
Expand Down Expand Up @@ -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 {
Expand All @@ -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] : [];
Expand All @@ -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,
);
}
4 changes: 2 additions & 2 deletions packages/core/src/config/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
6 changes: 4 additions & 2 deletions packages/core/src/core/tokenLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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']],
Expand Down
Loading