diff --git a/docs/users/configuration/auth.md b/docs/users/configuration/auth.md index 1d5d302406..7d357bae53 100644 --- a/docs/users/configuration/auth.md +++ b/docs/users/configuration/auth.md @@ -292,7 +292,7 @@ qwen --model "qwen3-coder-plus" # In another terminal -qwen --model "qwen3-coder-next" +qwen --model "qwen3.5-plus" ``` ## Security notes diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index c31ffa216c..2440d68045 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -137,7 +137,6 @@ export interface CliArgs { googleSearchEngineId: string | undefined; webSearchDefault: string | undefined; screenReader: boolean | undefined; - vlmSwitchMode: string | undefined; inputFormat?: string | undefined; outputFormat: string | undefined; includePartialMessages?: boolean; @@ -426,13 +425,6 @@ export async function parseArguments(): Promise { type: 'boolean', description: 'Enable screen reader mode for accessibility.', }) - .option('vlm-switch-mode', { - type: 'string', - choices: ['once', 'session', 'persist'], - description: - 'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). Overrides settings files.', - default: process.env['VLM_SWITCH_MODE'], - }) .option('input-format', { type: 'string', choices: ['text', 'stream-json'], @@ -903,9 +895,6 @@ export async function loadCliConfig( ? argv.screenReader : (settings.ui?.accessibility?.screenReader ?? false); - const vlmSwitchMode = - argv.vlmSwitchMode || settings.experimental?.vlmSwitchMode; - let sessionId: string | undefined; let sessionData: ResumedSessionData | undefined; @@ -1002,6 +991,7 @@ export async function loadCliConfig( modelProvidersConfig, generationConfigSources: resolvedCliConfig.sources, generationConfig: resolvedCliConfig.generationConfig, + warnings: resolvedCliConfig.warnings, cliVersion: await getCliVersion(), webSearch: buildWebSearchConfig(argv, settings, selectedAuthType), summarizeToolOutput: settings.model?.summarizeToolOutput, @@ -1016,7 +1006,6 @@ export async function loadCliConfig( skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck, skipLoopDetection: settings.model?.skipLoopDetection ?? false, skipStartupContext: settings.model?.skipStartupContext ?? false, - vlmSwitchMode, truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold, truncateToolOutputLines: settings.tools?.truncateToolOutputLines, enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation, diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index fe2f63bd18..e261cc7234 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -122,8 +122,6 @@ const MIGRATION_MAP: Record = { skipStartupContext: 'model.skipStartupContext', enableOpenAILogging: 'model.enableOpenAILogging', tavilyApiKey: 'advanced.tavilyApiKey', - vlmSwitchMode: 'experimental.vlmSwitchMode', - 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 fc902234fc..cfde449ca0 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 283baee26b..8263251cef 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1176,38 +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, - }, - vlmSwitchMode: { - type: 'string', - label: 'VLM Switch Mode', - category: 'Experimental', - requiresRestart: false, - default: undefined as string | undefined, - description: - 'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). If not set, user will be prompted each time. This is a temporary experimental feature.', - showInDialog: false, - }, - }, - }, } as const satisfies SettingsSchema; export type SettingsSchemaType = typeof SETTINGS_SCHEMA; diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 44c4c29d7f..6c48658ade 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -48,6 +48,7 @@ vi.mock('./config/config.js', () => ({ getSandbox: vi.fn(() => false), getQuestion: vi.fn(() => ''), isInteractive: () => false, + getWarnings: vi.fn(() => []), } as unknown as Config), parseArguments: vi.fn().mockResolvedValue({}), isDebugMode: vi.fn(() => false), @@ -177,6 +178,7 @@ describe('gemini.tsx main function', () => { getGeminiMdFileCount: () => 0, getProjectRoot: () => '/', getOutputFormat: () => OutputFormat.TEXT, + getWarnings: () => [], } as unknown as Config; }); vi.mocked(loadSettings).mockReturnValue({ @@ -341,6 +343,7 @@ describe('gemini.tsx main function', () => { getProjectRoot: () => '/', getInputFormat: () => 'stream-json', getContentGeneratorConfig: () => ({ authType: 'test-auth' }), + getWarnings: () => [], } as unknown as Config; vi.mocked(loadCliConfig).mockResolvedValue(configStub); @@ -438,6 +441,7 @@ describe('gemini.tsx main function kitty protocol', () => { getExperimentalZedIntegration: () => false, getScreenReader: () => false, getGeminiMdFileCount: () => 0, + getWarnings: () => [], } as unknown as Config); vi.mocked(loadSettings).mockReturnValue({ errors: [], @@ -483,7 +487,6 @@ describe('gemini.tsx main function kitty protocol', () => { googleSearchEngineId: undefined, webSearchDefault: undefined, screenReader: undefined, - vlmSwitchMode: undefined, inputFormat: undefined, outputFormat: undefined, includePartialMessages: undefined, diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 08c0631a8b..c5e742ee66 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -411,6 +411,7 @@ export async function main() { useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true, })), ...getSettingsWarnings(settings), + ...config.getWarnings(), ]), ]; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 53e1ea9e33..856facf757 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -100,8 +100,6 @@ import { t } from '../i18n/index.js'; import { useWelcomeBack } from './hooks/useWelcomeBack.js'; import { useDialogClose } from './hooks/useDialogClose.js'; import { useInitializationAuthError } from './hooks/useInitializationAuthError.js'; -import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js'; -import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js'; import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js'; import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js'; import { useAttentionNotifications } from './hooks/useAttentionNotifications.js'; @@ -496,18 +494,6 @@ export const AppContainer = (props: AppContainerProps) => { closeAgentsManagerDialog, } = useAgentsManagerDialog(); - // Vision model auto-switch dialog state (must be before slashCommandActions) - const [isVisionSwitchDialogOpen, setIsVisionSwitchDialogOpen] = - useState(false); - const [visionSwitchResolver, setVisionSwitchResolver] = useState<{ - resolve: (result: { - modelOverride?: string; - persistSessionModel?: string; - showGuidance?: boolean; - }) => void; - reject: () => void; - } | null>(null); - const slashCommandActions = useMemo( () => ({ openAuthDialog, @@ -571,32 +557,6 @@ export const AppContainer = (props: AppContainerProps) => { logger, ); - // Vision switch handlers - const handleVisionSwitchRequired = useCallback( - async (_query: unknown) => - new Promise<{ - modelOverride?: string; - persistSessionModel?: string; - showGuidance?: boolean; - }>((resolve, reject) => { - setVisionSwitchResolver({ resolve, reject }); - setIsVisionSwitchDialogOpen(true); - }), - [], - ); - - const handleVisionSwitchSelect = useCallback( - (outcome: VisionSwitchOutcome) => { - setIsVisionSwitchDialogOpen(false); - if (visionSwitchResolver) { - const result = processVisionSwitchOutcome(outcome); - visionSwitchResolver.resolve(result); - setVisionSwitchResolver(null); - } - }, - [visionSwitchResolver], - ); - // onDebugMessage should log to debug logfile, not update footer debugMessage const onDebugMessage = useCallback( (message: string) => { @@ -687,11 +647,9 @@ export const AppContainer = (props: AppContainerProps) => { setModelSwitchedFromQuotaError, refreshStatic, () => cancelHandlerRef.current(), - settings.merged.experimental?.visionModelPreview ?? false, // visionModelPreviewEnabled setEmbeddedShellFocused, terminalWidth, terminalHeight, - handleVisionSwitchRequired, // onVisionSwitchRequired ); // Track whether suggestions are visible for Tab key handling @@ -846,7 +804,6 @@ export const AppContainer = (props: AppContainerProps) => { !isThemeDialogOpen && !isEditorDialogOpen && !showWelcomeBackDialog && - !isVisionSwitchDialogOpen && welcomeBackChoice !== 'restart' && geminiClient?.isInitialized?.() ) { @@ -862,7 +819,6 @@ export const AppContainer = (props: AppContainerProps) => { isThemeDialogOpen, isEditorDialogOpen, showWelcomeBackDialog, - isVisionSwitchDialogOpen, welcomeBackChoice, geminiClient, ]); @@ -1334,7 +1290,6 @@ export const AppContainer = (props: AppContainerProps) => { isThemeDialogOpen || isSettingsDialogOpen || isModelDialogOpen || - isVisionSwitchDialogOpen || isPermissionsDialogOpen || isAuthDialogOpen || isAuthenticating || @@ -1446,8 +1401,6 @@ export const AppContainer = (props: AppContainerProps) => { extensionsUpdateState, activePtyId, embeddedShellFocused, - // Vision switch dialog - isVisionSwitchDialogOpen, // Welcome back dialog showWelcomeBackDialog, welcomeBackInfo, @@ -1538,8 +1491,6 @@ export const AppContainer = (props: AppContainerProps) => { activePtyId, historyManager, embeddedShellFocused, - // Vision switch dialog - isVisionSwitchDialogOpen, // Welcome back dialog showWelcomeBackDialog, welcomeBackInfo, @@ -1581,8 +1532,6 @@ export const AppContainer = (props: AppContainerProps) => { refreshStatic, handleFinalSubmit, handleClearScreen, - // Vision switch dialog - handleVisionSwitchSelect, // Welcome back dialog handleWelcomeBackSelection, handleWelcomeBackClose, @@ -1626,7 +1575,6 @@ export const AppContainer = (props: AppContainerProps) => { refreshStatic, handleFinalSubmit, handleClearScreen, - handleVisionSwitchSelect, handleWelcomeBackSelection, handleWelcomeBackClose, // Subagent dialogs diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index dbb6f22075..c79e911195 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -32,7 +32,6 @@ import process from 'node:process'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js'; import { WelcomeBackDialog } from './WelcomeBackDialog.js'; -import { ModelSwitchDialog } from './ModelSwitchDialog.js'; import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js'; import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js'; import { SessionPicker } from './SessionPicker.js'; @@ -236,10 +235,6 @@ export const DialogManager = ({ if (uiState.isModelDialogOpen) { return ; } - if (uiState.isVisionSwitchDialogOpen) { - return ; - } - if (uiState.isAuthDialogOpen || uiState.authError) { return ( diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index 3ce25bfa9c..7e05bdc43e 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -12,14 +12,10 @@ import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSel import { ConfigContext } from '../contexts/ConfigContext.js'; import { SettingsContext } from '../contexts/SettingsContext.js'; import type { Config } from '@qwen-code/qwen-code-core'; -import { AuthType } from '@qwen-code/qwen-code-core'; +import { AuthType, DEFAULT_QWEN_MODEL } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; -import { - AVAILABLE_MODELS_QWEN, - MAINLINE_CODER, - MAINLINE_VLM, -} from '../models/availableModels.js'; +import { getFilteredQwenModels } from '../models/availableModels.js'; vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn(), @@ -29,6 +25,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().map((m) => ({ + id: m.id, + label: m.label, + authType: AuthType.QWEN_OAUTH, + })); + } + return []; + }); const mockedSelect = vi.mocked(DescriptiveRadioButtonSelect); const renderComponent = ( @@ -49,12 +58,12 @@ const renderComponent = ( const mockConfig = { // --- Functions used by ModelDialog --- - getModel: vi.fn(() => MAINLINE_CODER), + getModel: vi.fn(() => DEFAULT_QWEN_MODEL), setModel: vi.fn().mockResolvedValue(undefined), switchModel: vi.fn().mockResolvedValue(undefined), getAuthType: vi.fn(() => 'qwen-oauth'), getAllConfiguredModels: vi.fn(() => - AVAILABLE_MODELS_QWEN.map((m) => ({ + getFilteredQwenModels().map((m) => ({ id: m.id, label: m.label, description: m.description || '', @@ -68,7 +77,7 @@ const renderComponent = ( getDebugMode: vi.fn(() => false), getContentGeneratorConfig: vi.fn(() => ({ authType: AuthType.QWEN_OAUTH, - model: MAINLINE_CODER, + model: DEFAULT_QWEN_MODEL, })), getUseModelRouter: vi.fn(() => false), getProxy: vi.fn(() => undefined), @@ -116,24 +125,34 @@ 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().length); + // coder-model is the only model and it has vision capability expect(props.items[0].value).toBe( - `${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`, - ); - expect(props.items[1].value).toBe( - `${AuthType.QWEN_OAUTH}::${MAINLINE_VLM}`, + `${AuthType.QWEN_OAUTH}::${DEFAULT_QWEN_MODEL}`, ); expect(props.showNumbers).toBe(true); }); it('initializes with the model from ConfigContext', () => { - const mockGetModel = vi.fn(() => MAINLINE_VLM); - renderComponent({}, { getModel: mockGetModel }); + const mockGetModel = vi.fn(() => DEFAULT_QWEN_MODEL); + renderComponent( + {}, + { + getModel: mockGetModel, + getAvailableModelsForAuthType: + createMockGetAvailableModelsForAuthType(), + }, + ); expect(mockGetModel).toHaveBeenCalled(); + // Calculate expected index dynamically based on model list + const qwenModels = getFilteredQwenModels(); + const expectedIndex = qwenModels.findIndex( + (m) => m.id === DEFAULT_QWEN_MODEL, + ); expect(mockedSelect).toHaveBeenCalledWith( expect.objectContaining({ - initialIndex: 1, + initialIndex: expectedIndex, }), undefined, ); @@ -151,14 +170,19 @@ 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(); - // When getModel returns undefined, preferredModel falls back to MAINLINE_CODER + // When getModel returns undefined, preferredModel falls back to DEFAULT_QWEN_MODEL // which has index 0, so initialIndex should be 0 expect(mockedSelect).toHaveBeenCalledWith( expect.objectContaining({ @@ -170,22 +194,36 @@ 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().map((m) => ({ + id: m.id, + label: m.label, + authType: AuthType.QWEN_OAUTH, + })); + } + return []; + }), + }, + ); const childOnSelect = mockedSelect.mock.calls[0][0].onSelect; expect(childOnSelect).toBeDefined(); - await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`); + await childOnSelect(`${AuthType.QWEN_OAUTH}::${DEFAULT_QWEN_MODEL}`); expect(mockConfig?.switchModel).toHaveBeenCalledWith( AuthType.QWEN_OAUTH, - MAINLINE_CODER, + DEFAULT_QWEN_MODEL, undefined, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, 'model.name', - MAINLINE_CODER, + DEFAULT_QWEN_MODEL, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, @@ -203,7 +241,7 @@ describe('', () => { return [{ id: 'gpt-4', label: 'GPT-4', authType: t }]; } if (t === AuthType.QWEN_OAUTH) { - return AVAILABLE_MODELS_QWEN.map((m) => ({ + return getFilteredQwenModels().map((m) => ({ id: m.id, label: m.label, authType: AuthType.QWEN_OAUTH, @@ -217,7 +255,7 @@ describe('', () => { getModel: vi.fn(() => 'gpt-4'), getContentGeneratorConfig: vi.fn(() => ({ authType: AuthType.QWEN_OAUTH, - model: MAINLINE_CODER, + model: DEFAULT_QWEN_MODEL, })), // Add switchModel to the mock object (not the type) switchModel, @@ -231,17 +269,17 @@ describe('', () => { ); const childOnSelect = mockedSelect.mock.calls[0][0].onSelect; - await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`); + await childOnSelect(`${AuthType.QWEN_OAUTH}::${DEFAULT_QWEN_MODEL}`); expect(switchModel).toHaveBeenCalledWith( AuthType.QWEN_OAUTH, - MAINLINE_CODER, + DEFAULT_QWEN_MODEL, { requireCachedCredentials: true }, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, 'model.name', - MAINLINE_CODER, + DEFAULT_QWEN_MODEL, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, @@ -290,7 +328,7 @@ describe('', () => { }); it('updates initialIndex when config context changes', () => { - const mockGetModel = vi.fn(() => MAINLINE_CODER); + const mockGetModel = vi.fn(() => DEFAULT_QWEN_MODEL); const mockGetAuthType = vi.fn(() => 'qwen-oauth'); const mockSettings = { isTrusted: true, @@ -305,8 +343,10 @@ describe('', () => { { getModel: mockGetModel, getAuthType: mockGetAuthType, + getAvailableModelsForAuthType: + createMockGetAvailableModelsForAuthType(), getAllConfiguredModels: vi.fn(() => - AVAILABLE_MODELS_QWEN.map((m) => ({ + getFilteredQwenModels().map((m) => ({ id: m.id, label: m.label, description: m.description || '', @@ -321,14 +361,16 @@ describe('', () => { , ); + // DEFAULT_QWEN_MODEL (coder-model) is at index 0 expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0); - mockGetModel.mockReturnValue(MAINLINE_VLM); + mockGetModel.mockReturnValue(DEFAULT_QWEN_MODEL); const newMockConfig = { getModel: mockGetModel, getAuthType: mockGetAuthType, + getAvailableModelsForAuthType: createMockGetAvailableModelsForAuthType(), getAllConfiguredModels: vi.fn(() => - AVAILABLE_MODELS_QWEN.map((m) => ({ + getFilteredQwenModels().map((m) => ({ id: m.id, label: m.label, description: m.description || '', @@ -347,6 +389,11 @@ 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 DEFAULT_QWEN_MODEL dynamically + const qwenModels = getFilteredQwenModels(); + const expectedCoderIndex = qwenModels.findIndex( + (m) => m.id === DEFAULT_QWEN_MODEL, + ); + expect(mockedSelect.mock.calls[1][0].initialIndex).toBe(expectedCoderIndex); }); }); diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 8c102890f4..8fdbbe38d2 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -11,6 +11,7 @@ import { AuthType, ModelSlashCommandEvent, logModelSlashCommand, + MAINLINE_CODER_MODEL, type AvailableModel as CoreAvailableModel, type ContentGeneratorConfig, type ContentGeneratorConfigSource, @@ -22,7 +23,6 @@ import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSel import { ConfigContext } from '../contexts/ConfigContext.js'; import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; -import { MAINLINE_CODER } from '../models/availableModels.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import { t } from '../../i18n/index.js'; @@ -293,7 +293,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { [availableModelEntries], ); - const preferredModelId = config?.getModel() || MAINLINE_CODER; + const preferredModelId = config?.getModel() || MAINLINE_CODER_MODEL; // Check if current model is a runtime model // Runtime snapshot ID is already in $runtime|${authType}|${modelId} format const activeRuntimeSnapshot = config?.getActiveRuntimeModelSnapshot?.(); 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 63c85f972d..0000000000 --- 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 97bfc53a31..0000000000 --- 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/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 7534b6d3aa..1965ceb264 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -17,7 +17,6 @@ import { import { type SettingScope } from '../../config/settings.js'; import { type CodingPlanRegion } from '../../constants/codingPlan.js'; import type { AuthState } from '../types.js'; -import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js'; // OpenAICredentials type (previously imported from OpenAIKeyPrompt) export interface OpenAICredentials { apiKey: string; @@ -68,8 +67,6 @@ export interface UIActions { refreshStatic: () => void; handleFinalSubmit: (value: string) => void; handleClearScreen: () => void; - // Vision switch dialog - handleVisionSwitchSelect: (outcome: VisionSwitchOutcome) => void; // Welcome back dialog handleWelcomeBackSelection: (choice: 'continue' | 'restart') => void; handleWelcomeBackClose: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index f8d52faa12..9d1a21e831 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -115,8 +115,6 @@ export interface UIState { extensionsUpdateState: Map; activePtyId: number | undefined; embeddedShellFocused: boolean; - // Vision switch dialog - isVisionSwitchDialogOpen: boolean; // Welcome back dialog showWelcomeBackDialog: boolean; welcomeBackInfo: { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index edf0e0576b..e855eefc31 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -74,14 +74,6 @@ const mockParseAndFormatApiError = vi.hoisted(() => ); const mockLogApiCancel = vi.hoisted(() => vi.fn()); -// Vision auto-switch mocks (hoisted) -const mockHandleVisionSwitch = vi.hoisted(() => - vi.fn().mockResolvedValue({ shouldProceed: true }), -); -const mockRestoreOriginalModel = vi.hoisted(() => - vi.fn().mockResolvedValue(undefined), -); - vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { const actualCoreModule = (await importOriginal()) as any; return { @@ -104,13 +96,6 @@ vi.mock('./useReactToolScheduler.js', async (importOriginal) => { }; }); -vi.mock('./useVisionAutoSwitch.js', () => ({ - useVisionAutoSwitch: vi.fn(() => ({ - handleVisionSwitch: mockHandleVisionSwitch, - restoreOriginalModel: mockRestoreOriginalModel, - })), -})); - vi.mock('./shellCommandProcessor.js', () => ({ useShellCommandProcessor: vi.fn().mockReturnValue({ handleShellCommand: vi.fn(), @@ -306,7 +291,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -472,7 +456,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -557,7 +540,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -670,7 +652,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -784,7 +765,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -901,7 +881,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, cancelSubmitSpy, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -945,7 +924,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, vi.fn(), - false, setShellInputFocusedSpy, // Pass the spy here 80, 24, @@ -1273,7 +1251,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -1331,7 +1308,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -1825,7 +1801,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -1881,7 +1856,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -1938,7 +1912,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2035,7 +2008,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled vi.fn(), 80, 24, @@ -2090,7 +2062,6 @@ describe('useGeminiStream', () => { vi.fn(), // setModelSwitched vi.fn(), // onEditorClose vi.fn(), // onCancelSubmit - false, // visionModelPreviewEnabled vi.fn(), // setShellInputFocused 80, // terminalWidth 24, // terminalHeight @@ -2164,7 +2135,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2256,7 +2226,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2317,7 +2286,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2418,7 +2386,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled vi.fn(), // setShellInputFocused 80, 24, @@ -2489,7 +2456,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2548,7 +2514,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2736,187 +2701,6 @@ describe('useGeminiStream', () => { }); // --- New tests focused on recent modifications --- - describe('Vision Auto Switch Integration', () => { - it('should call handleVisionSwitch and proceed to send when allowed', async () => { - mockHandleVisionSwitch.mockResolvedValueOnce({ shouldProceed: true }); - mockSendMessageStream.mockReturnValue( - (async function* () { - yield { type: ServerGeminiEventType.Content, value: 'ok' }; - yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; - })(), - ); - - const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), - [], - mockAddItem, - mockConfig, - mockLoadedSettings, - mockOnDebugMessage, - mockHandleSlashCommand, - false, - () => 'vscode' as EditorType, - () => {}, - () => Promise.resolve(), - false, - () => {}, - () => {}, - () => {}, - false, // visionModelPreviewEnabled - vi.fn(), // setShellInputFocused - 80, - 24, - ), - ); - - await act(async () => { - await result.current.submitQuery('image prompt'); - }); - - await waitFor(() => { - expect(mockHandleVisionSwitch).toHaveBeenCalled(); - expect(mockSendMessageStream).toHaveBeenCalled(); - }); - }); - - it('should gate submission when handleVisionSwitch returns shouldProceed=false', async () => { - mockHandleVisionSwitch.mockResolvedValueOnce({ shouldProceed: false }); - - const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), - [], - mockAddItem, - mockConfig, - mockLoadedSettings, - mockOnDebugMessage, - mockHandleSlashCommand, - false, - () => 'vscode' as EditorType, - () => {}, - () => Promise.resolve(), - false, - () => {}, - () => {}, - () => {}, - false, // visionModelPreviewEnabled - vi.fn(), // setShellInputFocused - 80, - 24, - ), - ); - - await act(async () => { - await result.current.submitQuery('vision-gated'); - }); - - // No call to API, no restoreOriginalModel needed since no override occurred - expect(mockSendMessageStream).not.toHaveBeenCalled(); - expect(mockRestoreOriginalModel).not.toHaveBeenCalled(); - - // Next call allowed (flag reset path) - mockHandleVisionSwitch.mockResolvedValueOnce({ shouldProceed: true }); - mockSendMessageStream.mockReturnValue( - (async function* () { - yield { type: ServerGeminiEventType.Content, value: 'ok' }; - yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; - })(), - ); - await act(async () => { - await result.current.submitQuery('after-gate'); - }); - await waitFor(() => { - expect(mockSendMessageStream).toHaveBeenCalled(); - }); - }); - }); - - describe('Model restore on completion and errors', () => { - it('should restore model after successful stream completion', async () => { - mockSendMessageStream.mockReturnValue( - (async function* () { - yield { type: ServerGeminiEventType.Content, value: 'content' }; - yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; - })(), - ); - - const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), - [], - mockAddItem, - mockConfig, - mockLoadedSettings, - mockOnDebugMessage, - mockHandleSlashCommand, - false, - () => 'vscode' as EditorType, - () => {}, - () => Promise.resolve(), - false, - () => {}, - () => {}, - () => {}, - false, // visionModelPreviewEnabled - vi.fn(), // setShellInputFocused - 80, - 24, - ), - ); - - await act(async () => { - await result.current.submitQuery('restore-success'); - }); - - await waitFor(() => { - expect(mockRestoreOriginalModel).toHaveBeenCalledTimes(1); - }); - }); - - it('should restore model when an error occurs during streaming', async () => { - const testError = new Error('stream failure'); - mockSendMessageStream.mockReturnValue( - (async function* () { - yield { type: ServerGeminiEventType.Content, value: 'content' }; - throw testError; - })(), - ); - - const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), - [], - mockAddItem, - mockConfig, - mockLoadedSettings, - mockOnDebugMessage, - mockHandleSlashCommand, - false, - () => 'vscode' as EditorType, - () => {}, - () => Promise.resolve(), - false, - () => {}, - () => {}, - () => {}, - false, // visionModelPreviewEnabled - vi.fn(), // setShellInputFocused - 80, - 24, - ), - ); - - await act(async () => { - await result.current.submitQuery('restore-error'); - }); - - await waitFor(() => { - expect(mockRestoreOriginalModel).toHaveBeenCalledTimes(1); - }); - }); - }); - describe('Loop Detection Confirmation', () => { beforeEach(() => { // Add mock for getLoopDetectionService to the config diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 5bebbac7e6..2da4eed530 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -35,6 +35,8 @@ import { ToolConfirmationOutcome, logApiCancel, ApiCancelEvent, + isSupportedImageMimeType, + getUnsupportedImageFormatWarning, } from '@qwen-code/qwen-code-core'; import { type Part, type PartListUnion, FinishReason } from '@google/genai'; import type { @@ -46,7 +48,6 @@ import type { import { StreamingState, MessageType, ToolCallStatus } from '../types.js'; import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js'; import { useShellCommandProcessor } from './shellCommandProcessor.js'; -import { useVisionAutoSwitch } from './useVisionAutoSwitch.js'; import { handleAtCommand } from './atCommandProcessor.js'; import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js'; import { useStateAndRef } from './useStateAndRef.js'; @@ -68,6 +69,60 @@ import { t } from '../../i18n/index.js'; const debugLogger = createDebugLogger('GEMINI_STREAM'); +/** + * Checks if image parts have supported formats and returns unsupported ones + */ +function checkImageFormatsSupport(parts: PartListUnion): { + hasImages: boolean; + hasUnsupportedFormats: boolean; + unsupportedMimeTypes: string[]; +} { + const unsupportedMimeTypes: string[] = []; + let hasImages = false; + + if (typeof parts === 'string') { + return { + hasImages: false, + hasUnsupportedFormats: false, + unsupportedMimeTypes: [], + }; + } + + const partsArray = Array.isArray(parts) ? parts : [parts]; + + for (const part of partsArray) { + if (typeof part === 'string') continue; + + let mimeType: string | undefined; + + // Check inlineData + if ( + 'inlineData' in part && + part.inlineData?.mimeType?.startsWith('image/') + ) { + hasImages = true; + mimeType = part.inlineData.mimeType; + } + + // Check fileData + if ('fileData' in part && part.fileData?.mimeType?.startsWith('image/')) { + hasImages = true; + mimeType = part.fileData.mimeType; + } + + // Check if the mime type is supported + if (mimeType && !isSupportedImageMimeType(mimeType)) { + unsupportedMimeTypes.push(mimeType); + } + } + + return { + hasImages, + hasUnsupportedFormats: unsupportedMimeTypes.length > 0, + unsupportedMimeTypes, + }; +} + enum StreamProcessingStatus { Completed, UserCancelled, @@ -106,15 +161,9 @@ export const useGeminiStream = ( setModelSwitchedFromQuotaError: React.Dispatch>, onEditorClose: () => void, onCancelSubmit: () => void, - visionModelPreviewEnabled: boolean, setShellInputFocused: (value: boolean) => void, terminalWidth: number, terminalHeight: number, - onVisionSwitchRequired?: (query: PartListUnion) => Promise<{ - modelOverride?: string; - persistSessionModel?: string; - showGuidance?: boolean; - }>, ) => { const [initError, setInitError] = useState(null); const abortControllerRef = useRef(null); @@ -278,12 +327,6 @@ export const useGeminiStream = ( terminalHeight, ); - const { handleVisionSwitch, restoreOriginalModel } = useVisionAutoSwitch( - config, - addItem, - visionModelPreviewEnabled, - onVisionSwitchRequired, - ); const activePtyId = activeShellPtyId || activeToolPtyId; useEffect(() => { @@ -1028,16 +1071,18 @@ export const useGeminiStream = ( return; } - // Handle vision switch requirement - const visionSwitchResult = await handleVisionSwitch( - queryToSend, - userMessageTimestamp, - options?.isContinuation || false, - ); - - if (!visionSwitchResult.shouldProceed) { - isSubmittingQueryRef.current = false; - return; + // Check image format support for non-continuations + if (!options?.isContinuation) { + const formatCheck = checkImageFormatsSupport(queryToSend); + if (formatCheck.hasUnsupportedFormats) { + addItem( + { + type: MessageType.INFO, + text: getUnsupportedImageFormatWarning(), + }, + userMessageTimestamp, + ); + } } const finalQueryToSend = queryToSend; @@ -1081,10 +1126,6 @@ export const useGeminiStream = ( ); if (processingStatus === StreamProcessingStatus.UserCancelled) { - // Restore original model if it was temporarily overridden - restoreOriginalModel().catch((error) => { - debugLogger.error('Failed to restore original model:', error); - }); isSubmittingQueryRef.current = false; return; } @@ -1097,17 +1138,7 @@ export const useGeminiStream = ( loopDetectedRef.current = false; handleLoopDetectedEvent(); } - - // Restore original model if it was temporarily overridden - restoreOriginalModel().catch((error) => { - debugLogger.error('Failed to restore original model:', error); - }); } catch (error: unknown) { - // Restore original model if it was temporarily overridden - restoreOriginalModel().catch((error) => { - debugLogger.error('Failed to restore original model:', error); - }); - if (error instanceof UnauthorizedError) { onAuthError('Session expired or is unauthorized.'); } else if (!isNodeError(error) || error.name !== 'AbortError') { @@ -1143,8 +1174,6 @@ export const useGeminiStream = ( startNewPrompt, getPromptCount, handleLoopDetectedEvent, - handleVisionSwitch, - restoreOriginalModel, ], ); diff --git a/packages/cli/src/ui/hooks/useVisionAutoSwitch.test.ts b/packages/cli/src/ui/hooks/useVisionAutoSwitch.test.ts deleted file mode 100644 index 782986ce94..0000000000 --- a/packages/cli/src/ui/hooks/useVisionAutoSwitch.test.ts +++ /dev/null @@ -1,874 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; -import type { Part, PartListUnion } from '@google/genai'; -import { AuthType, type Config, ApprovalMode } from '@qwen-code/qwen-code-core'; - -// Mock the image format functions from core package -vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { - const actual = (await importOriginal()) as Record; - return { - ...actual, - isSupportedImageMimeType: vi.fn((mimeType: string) => - [ - 'image/png', - 'image/jpeg', - 'image/jpg', - 'image/gif', - 'image/webp', - ].includes(mimeType), - ), - getUnsupportedImageFormatWarning: vi.fn( - () => - 'Only the following image formats are supported: BMP, JPEG, JPG, PNG, TIFF, WEBP, HEIC. Other formats may not work as expected.', - ), - }; -}); -import { - shouldOfferVisionSwitch, - processVisionSwitchOutcome, - getVisionSwitchGuidanceMessage, - useVisionAutoSwitch, -} from './useVisionAutoSwitch.js'; -import { VisionSwitchOutcome } from '../components/ModelSwitchDialog.js'; -import { MessageType } from '../types.js'; -import { getDefaultVisionModel } from '../models/availableModels.js'; - -describe('useVisionAutoSwitch helpers', () => { - describe('shouldOfferVisionSwitch', () => { - it('returns false when authType is not QWEN_OAUTH', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.USE_GEMINI, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(false); - }); - - it('returns false when current model is already a vision model', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'vision-model', - true, - ); - expect(result).toBe(false); - }); - - it('returns true when image parts exist, QWEN_OAUTH, and model is not vision', () => { - const parts: PartListUnion = [ - { text: 'hello' }, - { inlineData: { mimeType: 'image/jpeg', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(true); - }); - - it('detects image when provided as a single Part object (non-array)', () => { - const singleImagePart: PartListUnion = { - fileData: { mimeType: 'image/gif', fileUri: 'file://image.gif' }, - } as Part; - const result = shouldOfferVisionSwitch( - singleImagePart, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(true); - }); - - it('returns false when parts contain no images', () => { - const parts: PartListUnion = [{ text: 'just text' }]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(false); - }); - - it('returns false when parts is a plain string', () => { - const parts: PartListUnion = 'plain text'; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(false); - }); - - it('returns false when visionModelPreviewEnabled is false', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - false, - ); - expect(result).toBe(false); - }); - - it('returns true when image parts exist in YOLO mode context', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(true); - }); - - it('returns false when no image parts exist in YOLO mode context', () => { - const parts: PartListUnion = [{ text: 'just text' }]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(false); - }); - - it('returns false when already using vision model in YOLO mode context', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'vision-model', - true, - ); - expect(result).toBe(false); - }); - - it('returns false when authType is not QWEN_OAUTH in YOLO mode context', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.USE_GEMINI, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(false); - }); - }); - - describe('processVisionSwitchOutcome', () => { - it('maps SwitchOnce to a one-time model override', () => { - const vl = getDefaultVisionModel(); - const result = processVisionSwitchOutcome(VisionSwitchOutcome.SwitchOnce); - expect(result).toEqual({ modelOverride: vl }); - }); - - it('maps SwitchSessionToVL to a persistent session model', () => { - const vl = getDefaultVisionModel(); - const result = processVisionSwitchOutcome( - VisionSwitchOutcome.SwitchSessionToVL, - ); - expect(result).toEqual({ persistSessionModel: vl }); - }); - - it('maps ContinueWithCurrentModel to empty result', () => { - const result = processVisionSwitchOutcome( - VisionSwitchOutcome.ContinueWithCurrentModel, - ); - expect(result).toEqual({}); - }); - }); - - describe('getVisionSwitchGuidanceMessage', () => { - it('returns the expected guidance message', () => { - const vl = getDefaultVisionModel(); - const expected = - 'To use images with your query, you can:\n' + - `• Use /model set ${vl} to switch to a vision-capable model\n` + - '• Or remove the image and provide a text description instead'; - expect(getVisionSwitchGuidanceMessage()).toBe(expected); - }); - }); -}); - -describe('useVisionAutoSwitch hook', () => { - type AddItemFn = ( - item: { type: MessageType; text: string }, - ts: number, - ) => any; - - const createMockConfig = ( - authType: AuthType, - initialModel: string, - approvalMode: ApprovalMode = ApprovalMode.DEFAULT, - vlmSwitchMode?: string, - ) => { - let currentModel = initialModel; - const mockConfig: Partial = { - getModel: vi.fn(() => currentModel), - setModel: vi.fn(async (m: string) => { - currentModel = m; - }), - getApprovalMode: vi.fn(() => approvalMode), - getVlmSwitchMode: vi.fn(() => vlmSwitchMode), - getContentGeneratorConfig: vi.fn(() => ({ - authType, - model: currentModel, - apiKey: 'test-key', - vertexai: false, - })), - }; - return mockConfig as Config; - }; - - let addItem: AddItemFn; - - beforeEach(() => { - vi.clearAllMocks(); - addItem = vi.fn(); - }); - - it('returns shouldProceed=true immediately for continuations', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, vi.fn()), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, Date.now(), true); - }); - expect(res).toEqual({ shouldProceed: true }); - expect(addItem).not.toHaveBeenCalled(); - }); - - it('does nothing when authType is not QWEN_OAUTH', async () => { - const config = createMockConfig(AuthType.USE_GEMINI, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 123, false); - }); - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - it('does nothing when there are no image parts', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [{ text: 'no images here' }]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 456, false); - }); - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - it('continues with current model when dialog returns empty result', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn().mockResolvedValue({}); // Empty result for ContinueWithCurrentModel - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - const userTs = 1010; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, userTs, false); - }); - - // Should not add any guidance message - expect(addItem).not.toHaveBeenCalledWith( - { type: MessageType.INFO, text: getVisionSwitchGuidanceMessage() }, - userTs, - ); - expect(res).toEqual({ shouldProceed: true }); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('applies a one-time override and returns originalModel, then restores', async () => { - const initialModel = 'qwen3-coder-plus'; - const config = createMockConfig(AuthType.QWEN_OAUTH, initialModel); - const onVisionSwitchRequired = vi - .fn() - .mockResolvedValue({ modelOverride: 'coder-model' }); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 2020, false); - }); - - expect(res).toEqual({ shouldProceed: true, originalModel: initialModel }); - expect(config.setModel).toHaveBeenCalledWith('coder-model', { - reason: 'vision_auto_switch', - context: 'User-prompted vision switch (one-time override)', - }); - - // Now restore - await act(async () => { - await result.current.restoreOriginalModel(); - }); - expect(config.setModel).toHaveBeenLastCalledWith(initialModel, { - reason: 'vision_auto_switch', - context: 'Restoring original model after vision switch', - }); - }); - - it('persists session model when dialog requests persistence', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi - .fn() - .mockResolvedValue({ persistSessionModel: 'coder-model' }); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 3030, false); - }); - - expect(res).toEqual({ shouldProceed: true }); - expect(config.setModel).toHaveBeenCalledWith('coder-model', { - reason: 'vision_auto_switch', - context: 'User-prompted vision switch (session persistent)', - }); - - // Restore should be a no-op since no one-time override was used - await act(async () => { - await result.current.restoreOriginalModel(); - }); - // Last call should still be the persisted model set - expect((config.setModel as any).mock.calls.pop()?.[0]).toBe('coder-model'); - }); - - it('returns shouldProceed=true when dialog returns no special flags', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn().mockResolvedValue({}); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 4040, false); - }); - expect(res).toEqual({ shouldProceed: true }); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('blocks when dialog throws or is cancelled', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn().mockRejectedValue(new Error('x')); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 5050, false); - }); - expect(res).toEqual({ shouldProceed: false }); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('does nothing when visionModelPreviewEnabled is false', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - false, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 6060, false); - }); - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - describe('YOLO mode behavior', () => { - it('automatically switches to vision model in YOLO mode without showing dialog', async () => { - const initialModel = 'qwen3-coder-plus'; - const config = createMockConfig( - AuthType.QWEN_OAUTH, - initialModel, - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); // Should not be called in YOLO mode - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 7070, false); - }); - - // Should automatically switch without calling the dialog - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - expect(res).toEqual({ - shouldProceed: true, - originalModel: initialModel, - }); - expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel(), { - reason: 'vision_auto_switch', - context: 'YOLO mode auto-switch for image content', - }); - }); - - it('does not switch in YOLO mode when no images are present', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [{ text: 'no images here' }]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 8080, false); - }); - - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('does not switch in YOLO mode when already using vision model', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'vision-model', - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 9090, false); - }); - - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('restores original model after YOLO mode auto-switch', async () => { - const initialModel = 'qwen3-coder-plus'; - const config = createMockConfig( - AuthType.QWEN_OAUTH, - initialModel, - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - // First, trigger the auto-switch - await act(async () => { - await result.current.handleVisionSwitch(parts, 10100, false); - }); - - // Verify model was switched - expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel(), { - reason: 'vision_auto_switch', - context: 'YOLO mode auto-switch for image content', - }); - - // Now restore the original model - await act(async () => { - await result.current.restoreOriginalModel(); - }); - - // Verify model was restored - expect(config.setModel).toHaveBeenLastCalledWith(initialModel, { - reason: 'vision_auto_switch', - context: 'Restoring original model after vision switch', - }); - }); - - it('does not switch in YOLO mode when authType is not QWEN_OAUTH', async () => { - const config = createMockConfig( - AuthType.USE_GEMINI, - 'qwen3-coder-plus', - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 11110, false); - }); - - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('does not switch in YOLO mode when visionModelPreviewEnabled is false', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - false, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 12120, false); - }); - - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('handles multiple image formats in YOLO mode', async () => { - const initialModel = 'qwen3-coder-plus'; - const config = createMockConfig( - AuthType.QWEN_OAUTH, - initialModel, - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { text: 'Here are some images:' }, - { inlineData: { mimeType: 'image/jpeg', data: '...' } }, - { fileData: { mimeType: 'image/png', fileUri: 'file://image.png' } }, - { text: 'Please analyze them.' }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 13130, false); - }); - - expect(res).toEqual({ - shouldProceed: true, - originalModel: initialModel, - }); - expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel(), { - reason: 'vision_auto_switch', - context: 'YOLO mode auto-switch for image content', - }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - }); - - describe('VLM switch mode default behavior', () => { - it('should automatically switch once when vlmSwitchMode is "once"', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.DEFAULT, - 'once', - ); - const onVisionSwitchRequired = vi.fn(); // Should not be called - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/jpeg', data: 'base64data' } }, - ]; - - const switchResult = await result.current.handleVisionSwitch( - parts, - Date.now(), - false, - ); - - expect(switchResult.shouldProceed).toBe(true); - expect(switchResult.originalModel).toBe('qwen3-coder-plus'); - expect(config.setModel).toHaveBeenCalledWith('vision-model', { - reason: 'vision_auto_switch', - context: 'Default VLM switch mode: once (one-time override)', - }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - it('should switch session when vlmSwitchMode is "session"', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.DEFAULT, - 'session', - ); - const onVisionSwitchRequired = vi.fn(); // Should not be called - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/jpeg', data: 'base64data' } }, - ]; - - const switchResult = await result.current.handleVisionSwitch( - parts, - Date.now(), - false, - ); - - expect(switchResult.shouldProceed).toBe(true); - expect(switchResult.originalModel).toBeUndefined(); // No original model for session switch - expect(config.setModel).toHaveBeenCalledWith('vision-model', { - reason: 'vision_auto_switch', - context: 'Default VLM switch mode: session (session persistent)', - }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - it('should continue with current model when vlmSwitchMode is "persist"', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.DEFAULT, - 'persist', - ); - const onVisionSwitchRequired = vi.fn(); // Should not be called - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/jpeg', data: 'base64data' } }, - ]; - - const switchResult = await result.current.handleVisionSwitch( - parts, - Date.now(), - false, - ); - - expect(switchResult.shouldProceed).toBe(true); - expect(switchResult.originalModel).toBeUndefined(); - expect(config.setModel).not.toHaveBeenCalled(); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - it('should fall back to user prompt when vlmSwitchMode is not set', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.DEFAULT, - undefined, // No default mode - ); - const onVisionSwitchRequired = vi - .fn() - .mockResolvedValue({ modelOverride: 'vision-model' }); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/jpeg', data: 'base64data' } }, - ]; - - const switchResult = await result.current.handleVisionSwitch( - parts, - Date.now(), - false, - ); - - expect(switchResult.shouldProceed).toBe(true); - expect(onVisionSwitchRequired).toHaveBeenCalledWith(parts); - }); - - it('should fall back to persist behavior when vlmSwitchMode has invalid value', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.DEFAULT, - 'invalid-value', - ); - const onVisionSwitchRequired = vi.fn(); // Should not be called - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/jpeg', data: 'base64data' } }, - ]; - - const switchResult = await result.current.handleVisionSwitch( - parts, - Date.now(), - false, - ); - - expect(switchResult.shouldProceed).toBe(true); - expect(switchResult.originalModel).toBeUndefined(); - // For invalid values, it should continue with current model (persist behavior) - expect(config.setModel).not.toHaveBeenCalled(); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/cli/src/ui/hooks/useVisionAutoSwitch.ts b/packages/cli/src/ui/hooks/useVisionAutoSwitch.ts deleted file mode 100644 index f489c843a6..0000000000 --- a/packages/cli/src/ui/hooks/useVisionAutoSwitch.ts +++ /dev/null @@ -1,363 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { type PartListUnion, type Part } from '@google/genai'; -import { AuthType, type Config, ApprovalMode } from '@qwen-code/qwen-code-core'; -import { useCallback, useRef } from 'react'; -import { VisionSwitchOutcome } from '../components/ModelSwitchDialog.js'; -import { - getDefaultVisionModel, - isVisionModel, -} from '../models/availableModels.js'; -import { MessageType } from '../types.js'; -import type { UseHistoryManagerReturn } from './useHistoryManager.js'; -import { - isSupportedImageMimeType, - getUnsupportedImageFormatWarning, -} from '@qwen-code/qwen-code-core'; - -/** - * Checks if a PartListUnion contains image parts - */ -function hasImageParts(parts: PartListUnion): boolean { - if (typeof parts === 'string') { - return false; - } - - if (Array.isArray(parts)) { - return parts.some((part) => { - // Skip string parts - if (typeof part === 'string') return false; - return isImagePart(part); - }); - } - - // If it's a single Part (not a string), check if it's an image - if (typeof parts === 'object') { - return isImagePart(parts); - } - - return false; -} - -/** - * Checks if a single Part is an image part - */ -function isImagePart(part: Part): boolean { - // Check for inlineData with image mime type - if ('inlineData' in part && part.inlineData?.mimeType?.startsWith('image/')) { - return true; - } - - // Check for fileData with image mime type - if ('fileData' in part && part.fileData?.mimeType?.startsWith('image/')) { - return true; - } - - return false; -} - -/** - * Checks if image parts have supported formats and returns unsupported ones - */ -function checkImageFormatsSupport(parts: PartListUnion): { - hasImages: boolean; - hasUnsupportedFormats: boolean; - unsupportedMimeTypes: string[]; -} { - const unsupportedMimeTypes: string[] = []; - let hasImages = false; - - if (typeof parts === 'string') { - return { - hasImages: false, - hasUnsupportedFormats: false, - unsupportedMimeTypes: [], - }; - } - - const partsArray = Array.isArray(parts) ? parts : [parts]; - - for (const part of partsArray) { - if (typeof part === 'string') continue; - - let mimeType: string | undefined; - - // Check inlineData - if ( - 'inlineData' in part && - part.inlineData?.mimeType?.startsWith('image/') - ) { - hasImages = true; - mimeType = part.inlineData.mimeType; - } - - // Check fileData - if ('fileData' in part && part.fileData?.mimeType?.startsWith('image/')) { - hasImages = true; - mimeType = part.fileData.mimeType; - } - - // Check if the mime type is supported - if (mimeType && !isSupportedImageMimeType(mimeType)) { - unsupportedMimeTypes.push(mimeType); - } - } - - return { - hasImages, - hasUnsupportedFormats: unsupportedMimeTypes.length > 0, - unsupportedMimeTypes, - }; -} - -/** - * Determines if we should offer vision switch for the given parts, auth type, and current model - */ -export function shouldOfferVisionSwitch( - parts: PartListUnion, - authType: AuthType, - currentModel: string, - visionModelPreviewEnabled: boolean = true, -): boolean { - // Only trigger for qwen-oauth - if (authType !== AuthType.QWEN_OAUTH) { - return false; - } - - // If vision model preview is disabled, never offer vision switch - if (!visionModelPreviewEnabled) { - return false; - } - - // If current model is already a vision model, no need to switch - if (isVisionModel(currentModel)) { - return false; - } - - // Check if the current message contains image parts - return hasImageParts(parts); -} - -/** - * Interface for vision switch result - */ -export interface VisionSwitchResult { - modelOverride?: string; - persistSessionModel?: string; - showGuidance?: boolean; -} - -/** - * Processes the vision switch outcome and returns the appropriate result - */ -export function processVisionSwitchOutcome( - outcome: VisionSwitchOutcome, -): VisionSwitchResult { - const vlModelId = getDefaultVisionModel(); - - switch (outcome) { - case VisionSwitchOutcome.SwitchOnce: - return { modelOverride: vlModelId }; - - case VisionSwitchOutcome.SwitchSessionToVL: - return { persistSessionModel: vlModelId }; - - case VisionSwitchOutcome.ContinueWithCurrentModel: - return {}; // Continue with current model, no changes needed - - default: - return {}; // Default to continuing with current model - } -} - -/** - * Gets the guidance message for when vision switch is disallowed - */ -export function getVisionSwitchGuidanceMessage(): string { - const vlModelId = getDefaultVisionModel(); - return `To use images with your query, you can: -• Use /model set ${vlModelId} to switch to a vision-capable model -• Or remove the image and provide a text description instead`; -} - -/** - * Interface for vision switch handling result - */ -export interface VisionSwitchHandlingResult { - shouldProceed: boolean; - originalModel?: string; -} - -/** - * Custom hook for handling vision model auto-switching - */ -export function useVisionAutoSwitch( - config: Config, - addItem: UseHistoryManagerReturn['addItem'], - visionModelPreviewEnabled: boolean = true, - onVisionSwitchRequired?: (query: PartListUnion) => Promise<{ - modelOverride?: string; - persistSessionModel?: string; - showGuidance?: boolean; - }>, -) { - const originalModelRef = useRef(null); - - const handleVisionSwitch = useCallback( - async ( - query: PartListUnion, - userMessageTimestamp: number, - isContinuation: boolean, - ): Promise => { - // Skip vision switch handling for continuations or if no handler provided - if (isContinuation || !onVisionSwitchRequired) { - return { shouldProceed: true }; - } - - const contentGeneratorConfig = config.getContentGeneratorConfig(); - - // Only handle qwen-oauth auth type - if (contentGeneratorConfig?.authType !== AuthType.QWEN_OAUTH) { - return { shouldProceed: true }; - } - - // Check image format support first - const formatCheck = checkImageFormatsSupport(query); - - // If there are unsupported image formats, show warning - if (formatCheck.hasUnsupportedFormats) { - addItem( - { - type: MessageType.INFO, - text: getUnsupportedImageFormatWarning(), - }, - userMessageTimestamp, - ); - // Continue processing but with warning shown - } - - // Check if vision switch is needed - if ( - !shouldOfferVisionSwitch( - query, - contentGeneratorConfig.authType, - config.getModel(), - visionModelPreviewEnabled, - ) - ) { - return { shouldProceed: true }; - } - - // In YOLO mode, automatically switch to vision model without user interaction - if (config.getApprovalMode() === ApprovalMode.YOLO) { - const vlModelId = getDefaultVisionModel(); - originalModelRef.current = config.getModel(); - await config.setModel(vlModelId, { - reason: 'vision_auto_switch', - context: 'YOLO mode auto-switch for image content', - }); - return { - shouldProceed: true, - originalModel: originalModelRef.current, - }; - } - - // Check if there's a default VLM switch mode configured - const defaultVlmSwitchMode = config.getVlmSwitchMode(); - if (defaultVlmSwitchMode) { - // Convert string value to VisionSwitchOutcome enum - let outcome: VisionSwitchOutcome; - switch (defaultVlmSwitchMode) { - case 'once': - outcome = VisionSwitchOutcome.SwitchOnce; - break; - case 'session': - outcome = VisionSwitchOutcome.SwitchSessionToVL; - break; - case 'persist': - outcome = VisionSwitchOutcome.ContinueWithCurrentModel; - break; - default: - // Invalid value, fall back to prompting user - outcome = VisionSwitchOutcome.ContinueWithCurrentModel; - } - - // Process the default outcome - const visionSwitchResult = processVisionSwitchOutcome(outcome); - - if (visionSwitchResult.modelOverride) { - // One-time model override - originalModelRef.current = config.getModel(); - await config.setModel(visionSwitchResult.modelOverride, { - reason: 'vision_auto_switch', - context: `Default VLM switch mode: ${defaultVlmSwitchMode} (one-time override)`, - }); - return { - shouldProceed: true, - originalModel: originalModelRef.current, - }; - } else if (visionSwitchResult.persistSessionModel) { - // Persistent session model change - await config.setModel(visionSwitchResult.persistSessionModel, { - reason: 'vision_auto_switch', - context: `Default VLM switch mode: ${defaultVlmSwitchMode} (session persistent)`, - }); - return { shouldProceed: true }; - } - - // For ContinueWithCurrentModel or any other case, proceed with current model - return { shouldProceed: true }; - } - - try { - const visionSwitchResult = await onVisionSwitchRequired(query); - - if (visionSwitchResult.modelOverride) { - // One-time model override - originalModelRef.current = config.getModel(); - await config.setModel(visionSwitchResult.modelOverride, { - reason: 'vision_auto_switch', - context: 'User-prompted vision switch (one-time override)', - }); - return { - shouldProceed: true, - originalModel: originalModelRef.current, - }; - } else if (visionSwitchResult.persistSessionModel) { - // Persistent session model change - await config.setModel(visionSwitchResult.persistSessionModel, { - reason: 'vision_auto_switch', - context: 'User-prompted vision switch (session persistent)', - }); - return { shouldProceed: true }; - } - - // For ContinueWithCurrentModel or any other case, proceed with current model - return { shouldProceed: true }; - } catch (_error) { - // If vision switch dialog was cancelled or errored, don't proceed - return { shouldProceed: false }; - } - }, - [config, addItem, visionModelPreviewEnabled, onVisionSwitchRequired], - ); - - const restoreOriginalModel = useCallback(async () => { - if (originalModelRef.current) { - await config.setModel(originalModelRef.current, { - reason: 'vision_auto_switch', - context: 'Restoring original model after vision switch', - }); - originalModelRef.current = null; - } - }, [config]); - - return { - handleVisionSwitch, - restoreOriginalModel, - }; -} diff --git a/packages/cli/src/ui/models/availableModels.test.ts b/packages/cli/src/ui/models/availableModels.test.ts index feac835c66..767fb6f060 100644 --- a/packages/cli/src/ui/models/availableModels.test.ts +++ b/packages/cli/src/ui/models/availableModels.test.ts @@ -9,42 +9,30 @@ import { getAvailableModelsForAuthType, getFilteredQwenModels, 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', () => { - it('should include coder model', () => { - const coderModel = AVAILABLE_MODELS_QWEN.find( - (m) => m.id === MAINLINE_CODER, - ); - expect(coderModel).toBeDefined(); - expect(coderModel?.isVision).toBeFalsy(); + describe('Qwen models', () => { + const qwenModels = getFilteredQwenModels(); + + it('should include only coder-model', () => { + expect(qwenModels.length).toBe(1); + expect(qwenModels[0].id).toBe('coder-model'); }); - it('should include vision model', () => { - const visionModel = AVAILABLE_MODELS_QWEN.find( - (m) => m.id === MAINLINE_VLM, - ); - expect(visionModel).toBeDefined(); - expect(visionModel?.isVision).toBe(true); + it('should have coder-model with vision capability', () => { + const coderModel = qwenModels[0]; + expect(coderModel.isVision).toBe(true); }); }); describe('getFilteredQwenModels', () => { - it('should return all models when vision preview is enabled', () => { - const models = getFilteredQwenModels(true); - expect(models.length).toBe(AVAILABLE_MODELS_QWEN.length); - }); - - it('should filter out vision models when preview is disabled', () => { - const models = getFilteredQwenModels(false); - expect(models.every((m) => !m.isVision)).toBe(true); + it('should return coder-model with vision capability', () => { + const models = getFilteredQwenModels(); + expect(models.length).toBe(1); + expect(models[0].id).toBe('coder-model'); + expect(models[0].isVision).toBe(true); }); }); @@ -91,23 +79,36 @@ 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.length).toBe(1); + expect(models[0].id).toBe('coder-model'); + expect(models[0].isVision).toBe(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', () => { @@ -182,24 +183,4 @@ describe('availableModels', () => { expect(models).toEqual([]); }); }); - - describe('isVisionModel', () => { - it('should return true for vision model', () => { - expect(isVisionModel(MAINLINE_VLM)).toBe(true); - }); - - it('should return false for non-vision model', () => { - expect(isVisionModel(MAINLINE_CODER)).toBe(false); - }); - - it('should return false for unknown model', () => { - expect(isVisionModel('unknown-model')).toBe(false); - }); - }); - - describe('getDefaultVisionModel', () => { - it('should return the vision model ID', () => { - expect(getDefaultVisionModel()).toBe(MAINLINE_VLM); - }); - }); }); diff --git a/packages/cli/src/ui/models/availableModels.ts b/packages/cli/src/ui/models/availableModels.ts index 0b97276421..def4f12a7a 100644 --- a/packages/cli/src/ui/models/availableModels.ts +++ b/packages/cli/src/ui/models/availableModels.ts @@ -6,9 +6,9 @@ import { AuthType, - DEFAULT_QWEN_MODEL, type Config, type AvailableModel as CoreAvailableModel, + QWEN_OAUTH_MODELS, } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; @@ -19,41 +19,25 @@ export type AvailableModel = { isVision?: boolean; }; -export const MAINLINE_VLM = 'vision-model'; -export const MAINLINE_CODER = DEFAULT_QWEN_MODEL; +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, + }), +); -export const AVAILABLE_MODELS_QWEN: AvailableModel[] = [ - { - id: MAINLINE_CODER, - label: MAINLINE_CODER, - get description() { - return t( - 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance', - ); - }, - }, - { - 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, - }, -]; +function getQwenOAuthModels(): readonly AvailableModel[] { + return CACHED_QWEN_OAUTH_MODELS; +} /** - * Get available Qwen models filtered by vision model preview setting + * Get available Qwen models + * coder-model now has vision capabilities by default. */ -export function getFilteredQwenModels( - visionModelPreviewEnabled: boolean, -): AvailableModel[] { - if (visionModelPreviewEnabled) { - return AVAILABLE_MODELS_QWEN; - } - return AVAILABLE_MODELS_QWEN.filter((model) => !model.isVision); +export function getFilteredQwenModels(): AvailableModel[] { + return [...getQwenOAuthModels()]; } /** @@ -104,18 +88,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 +112,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] : []; @@ -146,17 +127,3 @@ export function getAvailableModelsForAuthType( return []; } } - -/** - * Hard code the default vision model as a string literal, - * until our coding model supports multimodal. - */ -export function getDefaultVisionModel(): string { - return MAINLINE_VLM; -} - -export function isVisionModel(modelId: string): boolean { - return AVAILABLE_MODELS_QWEN.some( - (model) => model.id === modelId && model.isVision, - ); -} diff --git a/packages/cli/src/utils/modelConfigUtils.test.ts b/packages/cli/src/utils/modelConfigUtils.test.ts index 0d39ed06ea..97cea9974d 100644 --- a/packages/cli/src/utils/modelConfigUtils.test.ts +++ b/packages/cli/src/utils/modelConfigUtils.test.ts @@ -506,7 +506,7 @@ describe('modelConfigUtils', () => { ); }); - it('should log warnings from resolveModelConfig', () => { + it('should return warnings from resolveModelConfig', () => { const argv = {}; const settings = makeMockSettings(); const selectedAuthType = AuthType.USE_OPENAI; @@ -521,14 +521,13 @@ describe('modelConfigUtils', () => { warnings: ['Warning 1', 'Warning 2'], }); - resolveCliGenerationConfig({ + const result = resolveCliGenerationConfig({ argv, settings, selectedAuthType, }); - expect(mockWriteStderrLine).toHaveBeenCalledWith('Warning 1'); - expect(mockWriteStderrLine).toHaveBeenCalledWith('Warning 2'); + expect(result.warnings).toEqual(['Warning 1', 'Warning 2']); }); it('should use custom env when provided', () => { diff --git a/packages/cli/src/utils/modelConfigUtils.ts b/packages/cli/src/utils/modelConfigUtils.ts index 948e6b253e..aa5ac5e82e 100644 --- a/packages/cli/src/utils/modelConfigUtils.ts +++ b/packages/cli/src/utils/modelConfigUtils.ts @@ -13,7 +13,6 @@ import { type ProviderModelConfig, } from '@qwen-code/qwen-code-core'; import type { Settings } from '../config/settings.js'; -import { writeStderrLine } from './stdioHelpers.js'; export interface CliGenerationConfigInputs { argv: { @@ -42,6 +41,8 @@ export interface ResolvedCliGenerationConfig { generationConfig: Partial; /** Source attribution for each resolved field */ sources: ContentGeneratorConfigSources; + /** Warnings generated during resolution */ + warnings: string[]; } export function getAuthTypeFromEnv(): AuthType | undefined { @@ -130,11 +131,6 @@ export function resolveCliGenerationConfig( const resolved = resolveModelConfig(configSources); - // Log warnings if any - for (const warning of resolved.warnings) { - writeStderrLine(warning); - } - // Resolve OpenAI logging config (CLI-specific, not part of core resolver) const enableOpenAILogging = (typeof argv.openaiLogging === 'undefined' @@ -158,5 +154,6 @@ export function resolveCliGenerationConfig( baseUrl: resolved.config.baseUrl || '', generationConfig, sources: resolved.sources, + warnings: resolved.warnings, }; } diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 4d2af04b3f..2be01125ff 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -370,9 +370,9 @@ describe('Server Config (config.ts)', () => { // Spy after initial refresh to ensure model switch does not re-trigger refreshAuth. const refreshSpy = vi.spyOn(config, 'refreshAuth'); - await config.switchModel(AuthType.QWEN_OAUTH, 'vision-model'); + await config.switchModel(AuthType.QWEN_OAUTH, 'coder-model'); - expect(config.getModel()).toBe('vision-model'); + expect(config.getModel()).toBe('coder-model'); expect(refreshSpy).not.toHaveBeenCalled(); // Called once during initial refreshAuth + once during handleModelChange diffing. expect( diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index e1598a6411..98b72c9c22 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -364,7 +364,6 @@ export interface ConfigParameters { skipNextSpeakerCheck?: boolean; shellExecutionConfig?: ShellExecutionConfig; skipLoopDetection?: boolean; - vlmSwitchMode?: string; truncateToolOutputThreshold?: number; truncateToolOutputLines?: number; enableToolOutputTruncation?: boolean; @@ -378,6 +377,8 @@ export interface ConfigParameters { channel?: string; /** Model providers configuration grouped by authType */ modelProvidersConfig?: ModelProvidersConfig; + /** Warnings generated during configuration resolution */ + warnings?: string[]; } function normalizeConfigOutputFormat( @@ -508,7 +509,7 @@ export class Config { private shellExecutionConfig: ShellExecutionConfig; private readonly skipLoopDetection: boolean; private readonly skipStartupContext: boolean; - private readonly vlmSwitchMode: string | undefined; + private readonly warnings: string[]; private initialized: boolean = false; readonly storage: Storage; private readonly fileExclusions: FileExclusions; @@ -610,6 +611,7 @@ export class Config { this.trustedFolder = params.trustedFolder; this.skipLoopDetection = params.skipLoopDetection ?? false; this.skipStartupContext = params.skipStartupContext ?? false; + this.warnings = params.warnings ?? []; // Web search this.webSearch = params.webSearch; @@ -632,7 +634,6 @@ export class Config { this.channel = params.channel; this.defaultFileEncoding = params.defaultFileEncoding ?? FileEncoding.UTF8; this.storage = new Storage(this.targetDir); - this.vlmSwitchMode = params.vlmSwitchMode; this.inputFormat = params.inputFormat ?? InputFormat.TEXT; this.fileExclusions = new FileExclusions(this); this.eventEmitter = params.eventEmitter; @@ -842,6 +843,15 @@ export class Config { return this.sessionId; } + /** + * Returns warnings generated during configuration resolution. + * These warnings are collected from model configuration resolution + * and should be displayed to the user during startup. + */ + getWarnings(): string[] { + return this.warnings; + } + getDebugLogger(): DebugLogger { return this.debugLogger; } @@ -1562,10 +1572,6 @@ export class Config { return this.skipStartupContext; } - getVlmSwitchMode(): string | undefined { - return this.vlmSwitchMode; - } - getEnableToolOutputTruncation(): boolean { return this.enableToolOutputTruncation; } diff --git a/packages/core/src/config/models.ts b/packages/core/src/config/models.ts index a07dec7cec..a507b7fa74 100644 --- a/packages/core/src/config/models.ts +++ b/packages/core/src/config/models.ts @@ -7,3 +7,4 @@ export const DEFAULT_QWEN_MODEL = 'coder-model'; export const DEFAULT_QWEN_FLASH_MODEL = 'coder-model'; export const DEFAULT_QWEN_EMBEDDING_MODEL = 'text-embedding-v4'; +export const MAINLINE_CODER_MODEL = 'qwen3.5-plus'; diff --git a/packages/core/src/core/contentGenerator.test.ts b/packages/core/src/core/contentGenerator.test.ts index eef7f5ac84..bb8e5f7418 100644 --- a/packages/core/src/core/contentGenerator.test.ts +++ b/packages/core/src/core/contentGenerator.test.ts @@ -90,11 +90,11 @@ describe('createContentGeneratorConfig', () => { it('should preserve provided fields and set authType for QWEN_OAUTH', () => { const cfg = createContentGeneratorConfig(mockConfig, AuthType.QWEN_OAUTH, { - model: 'vision-model', + model: 'coder-model', apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN', }); expect(cfg.authType).toBe(AuthType.QWEN_OAUTH); - expect(cfg.model).toBe('vision-model'); + expect(cfg.model).toBe('coder-model'); expect(cfg.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN'); }); diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts index a57bbacb77..f9d7a0fd6e 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts @@ -884,9 +884,9 @@ describe('DashScopeOpenAICompatibleProvider', () => { ).toBe(true); }); - it('should set high resolution flag for the vision-model alias', () => { + it('should set high resolution flag for the coder-model model', () => { const request: OpenAI.Chat.ChatCompletionCreateParams = { - model: 'vision-model', + model: 'coder-model', messages: [ { role: 'user', @@ -899,12 +899,13 @@ describe('DashScopeOpenAICompatibleProvider', () => { ], }, ], - max_tokens: 9000, + max_tokens: 100000, // Exceeds the 64K limit }; const result = provider.buildRequest(request, 'test-prompt-id'); - expect(result.max_tokens).toBe(8192); // Limited to model's output limit (8K) + // coder-model has 64K output limit, so max_tokens should be capped + expect(result.max_tokens).toBe(65536); expect( (result as { vl_high_resolution_images?: boolean }) .vl_high_resolution_images, diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index ccf201e240..c2134914a4 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 === 'vision-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/prompts.ts b/packages/core/src/core/prompts.ts index 60c2c56d99..b6879ce5a8 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -801,10 +801,6 @@ function getToolCallExamples(model?: string): string { if (/coder-model/i.test(model)) { return qwenCoderToolCallExamples; } - // Match vision-model pattern (same as qwen3-vl) - if (/vision-model/i.test(model)) { - return qwenVlToolCallExamples; - } } return generalToolCallExamples; diff --git a/packages/core/src/core/tokenLimits.ts b/packages/core/src/core/tokenLimits.ts index ae6cbd9e25..2419e51a1b 100644 --- a/packages/core/src/core/tokenLimits.ts +++ b/packages/core/src/core/tokenLimits.ts @@ -149,9 +149,6 @@ const PATTERNS: Array<[RegExp, TokenCount]> = [ [/^qwen3-vl-plus$/, LIMITS['256k']], // Qwen3-VL-Plus: 256K input [/^qwen-vl-max.*$/, LIMITS['128k']], - // Generic vision-model: same as qwen-vl-max (128K token context) - [/^vision-model$/, LIMITS['128k']], - // ------------------- // ByteDance Seed-OSS (512K) // ------------------- @@ -214,9 +211,6 @@ const OUTPUT_PATTERNS: Array<[RegExp, TokenCount]> = [ // Qwen-VL-Max-Latest: 8,192 max output tokens [/^qwen-vl-max-latest$/, LIMITS['8k']], - // Generic vision-model: same as qwen-vl-max-latest (8K max output tokens) - [/^vision-model$/, LIMITS['8k']], - // Qwen3-VL-Plus: 32K max output tokens [/^qwen3-vl-plus$/, LIMITS['32k']], diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c76fd2f8d8..de3f9f96db 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,6 +18,7 @@ export { DEFAULT_QWEN_MODEL, DEFAULT_QWEN_FLASH_MODEL, DEFAULT_QWEN_EMBEDDING_MODEL, + MAINLINE_CODER_MODEL, } from './config/models.js'; export { type AvailableModel, diff --git a/packages/core/src/models/constants.ts b/packages/core/src/models/constants.ts index 9e5d15009e..025e3b9cfd 100644 --- a/packages/core/src/models/constants.ts +++ b/packages/core/src/models/constants.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { DEFAULT_QWEN_MODEL } from '../config/models.js'; +import { DEFAULT_QWEN_MODEL, MAINLINE_CODER_MODEL } from '../config/models.js'; import type { ModelConfig } from './types.js'; @@ -88,15 +88,10 @@ export const AUTH_ENV_MAPPINGS = { } as const satisfies Record; export const DEFAULT_MODELS = { - openai: 'qwen3-coder-plus', + openai: MAINLINE_CODER_MODEL, '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. @@ -107,12 +102,14 @@ export const QWEN_OAUTH_MODELS: ModelConfig[] = [ name: 'coder-model', description: 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance', - capabilities: { vision: false }, - }, - { - id: 'vision-model', - name: 'vision-model', - description: 'The latest Qwen Vision model from Alibaba Cloud ModelStudio', 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..978949b2cf 100644 --- a/packages/core/src/models/modelConfigResolver.test.ts +++ b/packages/core/src/models/modelConfigResolver.test.ts @@ -10,7 +10,7 @@ import { validateModelConfig, } from './modelConfigResolver.js'; import { AuthType } from '../core/contentGenerator.js'; -import { DEFAULT_QWEN_MODEL } from '../config/models.js'; +import { DEFAULT_QWEN_MODEL, MAINLINE_CODER_MODEL } from '../config/models.js'; describe('modelConfigResolver', () => { describe('resolveModelConfig', () => { @@ -95,7 +95,7 @@ describe('modelConfigResolver', () => { }, }); - expect(result.config.model).toBe('qwen3-coder-plus'); + expect(result.config.model).toBe(MAINLINE_CODER_MODEL); expect(result.sources['model'].kind).toBe('default'); }); @@ -157,17 +157,17 @@ describe('modelConfigResolver', () => { expect(result.sources['apiKey'].kind).toBe('computed'); }); - it('allows vision-model for Qwen OAuth', () => { + it('allows coder-model for Qwen OAuth', () => { const result = resolveModelConfig({ authType: AuthType.QWEN_OAUTH, cli: { - model: 'vision-model', + model: 'coder-model', }, settings: {}, env: {}, }); - expect(result.config.model).toBe('vision-model'); + expect(result.config.model).toBe('coder-model'); expect(result.sources['model'].kind).toBe('cli'); }); diff --git a/packages/core/src/models/modelConfigResolver.ts b/packages/core/src/models/modelConfigResolver.ts index 1afad58eb7..c7db1611cb 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}'.`, + `Warning: Unsupported Qwen OAuth model '${requestedModel}', falling back to '${DEFAULT_QWEN_MODEL}'.${extraMessage}`, ); } resolvedModel = DEFAULT_QWEN_MODEL; diff --git a/packages/core/src/models/modelRegistry.test.ts b/packages/core/src/models/modelRegistry.test.ts index 01ccc82079..9005dd52a6 100644 --- a/packages/core/src/models/modelRegistry.test.ts +++ b/packages/core/src/models/modelRegistry.test.ts @@ -17,7 +17,6 @@ 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'); }); it('should initialize with empty config', () => { diff --git a/packages/core/src/models/modelsConfig.test.ts b/packages/core/src/models/modelsConfig.test.ts index 03a7248295..25268aebe5 100644 --- a/packages/core/src/models/modelsConfig.test.ts +++ b/packages/core/src/models/modelsConfig.test.ts @@ -454,7 +454,7 @@ describe('ModelsConfig', () => { }); // Switching within qwen-oauth triggers applyResolvedModelDefaults(). - await modelsConfig.switchModel(AuthType.QWEN_OAUTH, 'vision-model'); + await modelsConfig.switchModel(AuthType.QWEN_OAUTH, 'coder-model'); const gc = currentGenerationConfig(modelsConfig); expect(gc.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN'); diff --git a/packages/core/src/models/modelsConfig.ts b/packages/core/src/models/modelsConfig.ts index 9311c92793..a77d1d06b1 100644 --- a/packages/core/src/models/modelsConfig.ts +++ b/packages/core/src/models/modelsConfig.ts @@ -308,10 +308,11 @@ export class ModelsConfig { newModel: string, metadata?: ModelSwitchMetadata, ): Promise { - // Special case: qwen-oauth VLM auto-switch - hot update in place + // Special case: qwen-oauth model switch - hot update in place + // coder-model supports vision capabilities and can be hot-updated if ( this.currentAuthType === AuthType.QWEN_OAUTH && - (newModel === DEFAULT_QWEN_MODEL || newModel === 'vision-model') + newModel === DEFAULT_QWEN_MODEL ) { this.strictModelProviderSelection = false; this._generationConfig.model = newModel; @@ -782,7 +783,7 @@ export class ModelsConfig { * - We're checking if switching between two models within the SAME authType needs refresh * * Examples: - * - Qwen OAuth: coder-model -> vision-model (same authType, hot-update safe) + * - Qwen OAuth: coder-model switches (same authType, hot-update safe) * - OpenAI: model-a -> model-b with same envKey (same authType, hot-update safe) * - OpenAI: gpt-4 -> deepseek-chat with different envKey (same authType, needs refresh) * @@ -799,7 +800,7 @@ export class ModelsConfig { } // For Qwen OAuth, model switches within the same authType can always be hot-updated - // (coder-model <-> vision-model don't require ContentGenerator recreation) + // (coder-model supports vision capabilities and doesn't require ContentGenerator recreation) if (authType === AuthType.QWEN_OAUTH) { return false; }