diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index e3bc5da63..e261cc723 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -122,7 +122,6 @@ const MIGRATION_MAP: Record = { skipStartupContext: 'model.skipStartupContext', enableOpenAILogging: 'model.enableOpenAILogging', tavilyApiKey: 'advanced.tavilyApiKey', - visionModelPreview: 'experimental.visionModelPreview', }; // Settings that need boolean inversion during migration (V1 -> V3) diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index fc902234f..cfde449ca 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -28,7 +28,7 @@ describe('SettingsSchema', () => { 'mcp', 'security', 'advanced', - 'experimental', + 'webSearch', ]; expectedSettings.forEach((setting) => { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index a97c0df50..8263251ce 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1176,28 +1176,6 @@ const SETTINGS_SCHEMA = { description: 'Configuration for web search providers.', showInDialog: false, }, - - experimental: { - type: 'object', - label: 'Experimental', - category: 'Experimental', - requiresRestart: true, - default: {}, - description: 'Setting to enable experimental features', - showInDialog: false, - properties: { - visionModelPreview: { - type: 'boolean', - label: 'Vision Model Preview', - category: 'Experimental', - requiresRestart: false, - default: true, - description: - 'Enable vision model support and auto-switching functionality. When disabled, vision models like qwen-vl-max-latest will be hidden and auto-switching will not occur.', - showInDialog: false, - }, - }, - }, } as const satisfies SettingsSchema; export type SettingsSchemaType = typeof SETTINGS_SCHEMA; diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index d73b2368e..f636db7c3 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -18,7 +18,6 @@ import { SettingScope } from '../../config/settings.js'; import { getFilteredQwenModels, MAINLINE_CODER, - MAINLINE_VLM, } from '../models/availableModels.js'; vi.mock('../hooks/useKeypress.js', () => ({ @@ -134,14 +133,11 @@ describe('', () => { expect(props.items[0].value).toBe( `${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`, ); - expect(props.items[0].value).toBe( - `${AuthType.QWEN_OAUTH}::${MAINLINE_VLM}`, - ); expect(props.showNumbers).toBe(true); }); it('initializes with the model from ConfigContext', () => { - const mockGetModel = vi.fn(() => MAINLINE_VLM); + const mockGetModel = vi.fn(() => MAINLINE_CODER); renderComponent( {}, { @@ -154,7 +150,7 @@ describe('', () => { expect(mockGetModel).toHaveBeenCalled(); // Calculate expected index dynamically based on model list const qwenModels = getFilteredQwenModels(); - const expectedIndex = qwenModels.findIndex((m) => m.id === MAINLINE_VLM); + const expectedIndex = qwenModels.findIndex((m) => m.id === MAINLINE_CODER); expect(mockedSelect).toHaveBeenCalledWith( expect.objectContaining({ initialIndex: expectedIndex, @@ -369,7 +365,7 @@ describe('', () => { // MAINLINE_CODER (qwen3.5-plus) is at index 0 expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0); - mockGetModel.mockReturnValue(MAINLINE_VLM); + mockGetModel.mockReturnValue(MAINLINE_CODER); const newMockConfig = { getModel: mockGetModel, getAuthType: mockGetAuthType, @@ -394,9 +390,11 @@ describe('', () => { // Should be called at least twice: initial render + re-render after context change expect(mockedSelect).toHaveBeenCalledTimes(2); - // Calculate expected index for MAINLINE_VLM dynamically + // Calculate expected index for MAINLINE_CODER dynamically const qwenModels = getFilteredQwenModels(); - const expectedVlmIndex = qwenModels.findIndex((m) => m.id === MAINLINE_VLM); - expect(mockedSelect.mock.calls[1][0].initialIndex).toBe(expectedVlmIndex); + const expectedCoderIndex = qwenModels.findIndex( + (m) => m.id === MAINLINE_CODER, + ); + expect(mockedSelect.mock.calls[1][0].initialIndex).toBe(expectedCoderIndex); }); }); diff --git a/packages/cli/src/ui/components/ModelSwitchDialog.test.tsx b/packages/cli/src/ui/components/ModelSwitchDialog.test.tsx deleted file mode 100644 index 63c85f972..000000000 --- a/packages/cli/src/ui/components/ModelSwitchDialog.test.tsx +++ /dev/null @@ -1,184 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { render } from 'ink-testing-library'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { ModelSwitchDialog, VisionSwitchOutcome } from './ModelSwitchDialog.js'; - -// Mock the useKeypress hook -const mockUseKeypress = vi.hoisted(() => vi.fn()); -vi.mock('../hooks/useKeypress.js', () => ({ - useKeypress: mockUseKeypress, -})); - -// Mock the RadioButtonSelect component -const mockRadioButtonSelect = vi.hoisted(() => vi.fn()); -vi.mock('./shared/RadioButtonSelect.js', () => ({ - RadioButtonSelect: mockRadioButtonSelect, -})); - -describe('ModelSwitchDialog', () => { - const mockOnSelect = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - - // Mock RadioButtonSelect to return a simple div - mockRadioButtonSelect.mockReturnValue( - React.createElement('div', { 'data-testid': 'radio-select' }), - ); - }); - - it('should setup RadioButtonSelect with correct options', () => { - render(); - - const expectedItems = [ - { - key: 'switch-once', - label: 'Switch for this request only', - value: VisionSwitchOutcome.SwitchOnce, - }, - { - key: 'switch-session', - label: 'Switch session to vision model', - value: VisionSwitchOutcome.SwitchSessionToVL, - }, - { - key: 'continue', - label: 'Continue with current model', - value: VisionSwitchOutcome.ContinueWithCurrentModel, - }, - ]; - - const callArgs = mockRadioButtonSelect.mock.calls[0][0]; - expect(callArgs.items).toEqual(expectedItems); - expect(callArgs.initialIndex).toBe(0); - expect(callArgs.isFocused).toBe(true); - }); - - it('should call onSelect when an option is selected', () => { - render(); - - const callArgs = mockRadioButtonSelect.mock.calls[0][0]; - expect(typeof callArgs.onSelect).toBe('function'); - - // Simulate selection of "Switch for this request only" - const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect; - onSelectCallback(VisionSwitchOutcome.SwitchOnce); - - expect(mockOnSelect).toHaveBeenCalledWith(VisionSwitchOutcome.SwitchOnce); - }); - - it('should call onSelect with SwitchSessionToVL when second option is selected', () => { - render(); - - const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect; - onSelectCallback(VisionSwitchOutcome.SwitchSessionToVL); - - expect(mockOnSelect).toHaveBeenCalledWith( - VisionSwitchOutcome.SwitchSessionToVL, - ); - }); - - it('should call onSelect with ContinueWithCurrentModel when third option is selected', () => { - render(); - - const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect; - onSelectCallback(VisionSwitchOutcome.ContinueWithCurrentModel); - - expect(mockOnSelect).toHaveBeenCalledWith( - VisionSwitchOutcome.ContinueWithCurrentModel, - ); - }); - - it('should setup escape key handler to call onSelect with ContinueWithCurrentModel', () => { - render(); - - expect(mockUseKeypress).toHaveBeenCalledWith(expect.any(Function), { - isActive: true, - }); - - // Simulate escape key press - const keypressHandler = mockUseKeypress.mock.calls[0][0]; - keypressHandler({ name: 'escape' }); - - expect(mockOnSelect).toHaveBeenCalledWith( - VisionSwitchOutcome.ContinueWithCurrentModel, - ); - }); - - it('should not call onSelect for non-escape keys', () => { - render(); - - const keypressHandler = mockUseKeypress.mock.calls[0][0]; - keypressHandler({ name: 'enter' }); - - expect(mockOnSelect).not.toHaveBeenCalled(); - }); - - it('should set initial index to 0 (first option)', () => { - render(); - - const callArgs = mockRadioButtonSelect.mock.calls[0][0]; - expect(callArgs.initialIndex).toBe(0); - }); - - describe('VisionSwitchOutcome enum', () => { - it('should have correct enum values', () => { - expect(VisionSwitchOutcome.SwitchOnce).toBe('once'); - expect(VisionSwitchOutcome.SwitchSessionToVL).toBe('session'); - expect(VisionSwitchOutcome.ContinueWithCurrentModel).toBe('persist'); - }); - }); - - it('should handle multiple onSelect calls correctly', () => { - render(); - - const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect; - - // Call multiple times - onSelectCallback(VisionSwitchOutcome.SwitchOnce); - onSelectCallback(VisionSwitchOutcome.SwitchSessionToVL); - onSelectCallback(VisionSwitchOutcome.ContinueWithCurrentModel); - - expect(mockOnSelect).toHaveBeenCalledTimes(3); - expect(mockOnSelect).toHaveBeenNthCalledWith( - 1, - VisionSwitchOutcome.SwitchOnce, - ); - expect(mockOnSelect).toHaveBeenNthCalledWith( - 2, - VisionSwitchOutcome.SwitchSessionToVL, - ); - expect(mockOnSelect).toHaveBeenNthCalledWith( - 3, - VisionSwitchOutcome.ContinueWithCurrentModel, - ); - }); - - it('should pass isFocused prop to RadioButtonSelect', () => { - render(); - - const callArgs = mockRadioButtonSelect.mock.calls[0][0]; - expect(callArgs.isFocused).toBe(true); - }); - - it('should handle escape key multiple times', () => { - render(); - - const keypressHandler = mockUseKeypress.mock.calls[0][0]; - - // Call escape multiple times - keypressHandler({ name: 'escape' }); - keypressHandler({ name: 'escape' }); - - expect(mockOnSelect).toHaveBeenCalledTimes(2); - expect(mockOnSelect).toHaveBeenCalledWith( - VisionSwitchOutcome.ContinueWithCurrentModel, - ); - }); -}); diff --git a/packages/cli/src/ui/components/ModelSwitchDialog.tsx b/packages/cli/src/ui/components/ModelSwitchDialog.tsx deleted file mode 100644 index 97bfc53a3..000000000 --- a/packages/cli/src/ui/components/ModelSwitchDialog.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Box, Text } from 'ink'; -import { Colors } from '../colors.js'; -import { - RadioButtonSelect, - type RadioSelectItem, -} from './shared/RadioButtonSelect.js'; -import { useKeypress } from '../hooks/useKeypress.js'; - -export enum VisionSwitchOutcome { - SwitchOnce = 'once', - SwitchSessionToVL = 'session', - ContinueWithCurrentModel = 'persist', -} - -export interface ModelSwitchDialogProps { - onSelect: (outcome: VisionSwitchOutcome) => void; -} - -export const ModelSwitchDialog: React.FC = ({ - onSelect, -}) => { - useKeypress( - (key) => { - if (key.name === 'escape') { - onSelect(VisionSwitchOutcome.ContinueWithCurrentModel); - } - }, - { isActive: true }, - ); - - const options: Array> = [ - { - key: 'switch-once', - label: 'Switch for this request only', - value: VisionSwitchOutcome.SwitchOnce, - }, - { - key: 'switch-session', - label: 'Switch session to vision model', - value: VisionSwitchOutcome.SwitchSessionToVL, - }, - { - key: 'continue', - label: 'Continue with current model', - value: VisionSwitchOutcome.ContinueWithCurrentModel, - }, - ]; - - const handleSelect = (outcome: VisionSwitchOutcome) => { - onSelect(outcome); - }; - - return ( - - - Vision Model Switch Required - - Your message contains an image, but the current model doesn't - support vision. - - How would you like to proceed? - - - - - - - - Press Enter to select, Esc to cancel - - - ); -}; diff --git a/packages/cli/src/ui/models/availableModels.test.ts b/packages/cli/src/ui/models/availableModels.test.ts index 4df19bbc9..767fb6f06 100644 --- a/packages/cli/src/ui/models/availableModels.test.ts +++ b/packages/cli/src/ui/models/availableModels.test.ts @@ -9,14 +9,8 @@ import { getAvailableModelsForAuthType, getFilteredQwenModels, getOpenAIAvailableModelFromEnv, - isVisionModel, - getDefaultVisionModel, } from './availableModels.js'; -import { - AuthType, - type Config, - DEFAULT_QWEN_MODEL, -} from '@qwen-code/qwen-code-core'; +import { AuthType, type Config } from '@qwen-code/qwen-code-core'; describe('availableModels', () => { describe('Qwen models', () => { @@ -189,20 +183,4 @@ describe('availableModels', () => { expect(models).toEqual([]); }); }); - - describe('isVisionModel', () => { - it('should return true for coder-model with vision capability', () => { - expect(isVisionModel('coder-model')).toBe(true); - }); - - it('should return false for unknown model', () => { - expect(isVisionModel('unknown-model')).toBe(false); - }); - }); - - describe('getDefaultVisionModel', () => { - it('should return the default model ID', () => { - expect(getDefaultVisionModel()).toBe(DEFAULT_QWEN_MODEL); - }); - }); }); diff --git a/packages/cli/src/ui/models/availableModels.ts b/packages/cli/src/ui/models/availableModels.ts index df1d29edb..6830221a3 100644 --- a/packages/cli/src/ui/models/availableModels.ts +++ b/packages/cli/src/ui/models/availableModels.ts @@ -20,11 +20,8 @@ export type AvailableModel = { isVision?: boolean; }; -// Re-export constants from core for backwards compatibility -export { - DEFAULT_QWEN_MODEL as MAINLINE_CODER, - DEFAULT_QWEN_MODEL as MAINLINE_VLM, -}; +// Re-export constant from core for backwards compatibility +export { DEFAULT_QWEN_MODEL as MAINLINE_CODER }; const CACHED_QWEN_OAUTH_MODELS: AvailableModel[] = QWEN_OAUTH_MODELS.map( (model) => ({ @@ -134,21 +131,3 @@ export function getAvailableModelsForAuthType( return []; } } - -/** - * coder-model now has vision capabilities by default. - * This function is kept for backwards compatibility but always returns the current model. - */ -export function getDefaultVisionModel(): string { - return DEFAULT_QWEN_MODEL; -} - -/** - * coder-model now has vision capabilities by default. - * This function is kept for backwards compatibility but always returns true for the default model. - */ -export function isVisionModel(modelId: string): boolean { - return getQwenOAuthModels().some( - (model) => model.id === modelId && model.isVision, - ); -} diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index ff253b8a5..c2134914a 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -279,6 +279,18 @@ export class DashScopeOpenAICompatibleProvider return contentArray; } + /** + * Vision-capable model patterns. + * Supports exact matches and prefix patterns for easy extension. + */ + private static readonly VISION_MODEL_EXACT_MATCHES = new Set(['coder-model']); + + private static readonly VISION_MODEL_PREFIX_PATTERNS = [ + 'qwen-vl', // qwen-vl-max, qwen-vl-max-latest, etc. + 'qwen3-vl-plus', // qwen3-vl-plus variants + 'qwen3.5-plus', // qwen3.5-plus (has built-in vision capabilities) + ]; + private isVisionModel(model: string | undefined): boolean { if (!model) { return false; @@ -286,16 +298,20 @@ export class DashScopeOpenAICompatibleProvider const normalized = model.toLowerCase(); - if (normalized === 'coder-model') { + // Check exact matches + if ( + DashScopeOpenAICompatibleProvider.VISION_MODEL_EXACT_MATCHES.has( + normalized, + ) + ) { return true; } - if (normalized.startsWith('qwen-vl')) { - return true; - } - - if (normalized.startsWith('qwen3-vl-plus')) { - return true; + // Check prefix patterns + for (const prefix of DashScopeOpenAICompatibleProvider.VISION_MODEL_PREFIX_PATTERNS) { + if (normalized.startsWith(prefix)) { + return true; + } } return false; diff --git a/packages/core/src/core/tokenLimits.ts b/packages/core/src/core/tokenLimits.ts index 2566f097b..2419e51a1 100644 --- a/packages/core/src/core/tokenLimits.ts +++ b/packages/core/src/core/tokenLimits.ts @@ -198,7 +198,6 @@ const OUTPUT_PATTERNS: Array<[RegExp, TokenCount]> = [ // ------------------- // Qwen3-Coder-Plus: 65,536 max output tokens [/^qwen3-coder-plus(-.*)?$/, LIMITS['64k']], - [/^qwen3.5-plus(-.*)?$/, LIMITS['64k']], // Qwen3.5-Plus: 65,536 max output tokens [/^qwen3\.5-plus(-.*)?$/, LIMITS['64k']], diff --git a/packages/core/src/models/constants.ts b/packages/core/src/models/constants.ts index b5c26f009..c3295e684 100644 --- a/packages/core/src/models/constants.ts +++ b/packages/core/src/models/constants.ts @@ -102,7 +102,7 @@ export const QWEN_OAUTH_MODELS: ModelConfig[] = [ name: 'coder-model', description: 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance', - capabilities: { vision: false }, + capabilities: { vision: true }, }, ]; diff --git a/packages/core/src/models/modelConfigResolver.ts b/packages/core/src/models/modelConfigResolver.ts index 1afad58eb..ba7017b65 100644 --- a/packages/core/src/models/modelConfigResolver.ts +++ b/packages/core/src/models/modelConfigResolver.ts @@ -295,8 +295,13 @@ function resolveQwenOAuthConfig( : settingsSource('model.name'); } else { if (requestedModel) { + const isVisionModel = + requestedModel.includes('vl') || requestedModel.includes('vision'); + const extraMessage = isVisionModel + ? ` Note: vision-model has been removed since coder-model now supports vision capabilities.` + : ''; warnings.push( - `Unsupported Qwen OAuth model '${requestedModel}', falling back to '${DEFAULT_QWEN_MODEL}'.`, + `Unsupported Qwen OAuth model '${requestedModel}', falling back to '${DEFAULT_QWEN_MODEL}'.${extraMessage}`, ); } resolvedModel = DEFAULT_QWEN_MODEL; diff --git a/packages/core/src/qwen/qwenOAuth2.ts b/packages/core/src/qwen/qwenOAuth2.ts index 1cac5bc35..4a7761300 100644 --- a/packages/core/src/qwen/qwenOAuth2.ts +++ b/packages/core/src/qwen/qwenOAuth2.ts @@ -24,7 +24,7 @@ import { const debugLogger = createDebugLogger('QWEN_OAUTH'); // OAuth Endpoints -const QWEN_OAUTH_BASE_URL = 'https://pre4-chat.qwen.ai'; +const QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai'; const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code`; const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`;