feat: add cli args & env variables for switch behavoir

This commit is contained in:
mingholy.lmh
2025-09-23 19:14:26 +08:00
parent 85a2b8d6e0
commit e4d16adf7b
12 changed files with 555 additions and 63 deletions

View File

@@ -82,6 +82,7 @@ export interface CliArgs {
includeDirectories: string[] | undefined; includeDirectories: string[] | undefined;
tavilyApiKey: string | undefined; tavilyApiKey: string | undefined;
screenReader: boolean | undefined; screenReader: boolean | undefined;
vlmSwitchMode: string | undefined;
} }
export async function parseArguments(settings: Settings): Promise<CliArgs> { export async function parseArguments(settings: Settings): Promise<CliArgs> {
@@ -249,6 +250,13 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
description: 'Enable screen reader mode for accessibility.', description: 'Enable screen reader mode for accessibility.',
default: false, default: false,
}) })
.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'],
})
.check((argv) => { .check((argv) => {
if (argv.prompt && argv['promptInteractive']) { if (argv.prompt && argv['promptInteractive']) {
throw new Error( throw new Error(
@@ -524,6 +532,9 @@ export async function loadCliConfig(
argv.screenReader !== undefined argv.screenReader !== undefined
? argv.screenReader ? argv.screenReader
: (settings.ui?.accessibility?.screenReader ?? false); : (settings.ui?.accessibility?.screenReader ?? false);
const vlmSwitchMode =
argv.vlmSwitchMode || settings.experimental?.vlmSwitchMode;
return new Config({ return new Config({
sessionId, sessionId,
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL, embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
@@ -630,6 +641,7 @@ export async function loadCliConfig(
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck, skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
enablePromptCompletion: settings.general?.enablePromptCompletion ?? false, enablePromptCompletion: settings.general?.enablePromptCompletion ?? false,
skipLoopDetection: settings.skipLoopDetection ?? false, skipLoopDetection: settings.skipLoopDetection ?? false,
vlmSwitchMode,
}); });
} }

View File

@@ -751,6 +751,16 @@ export const SETTINGS_SCHEMA = {
'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.', '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: true, showInDialog: true,
}, },
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,
},
}, },
}, },

View File

@@ -46,8 +46,8 @@ describe('ModelSwitchDialog', () => {
value: VisionSwitchOutcome.SwitchSessionToVL, value: VisionSwitchOutcome.SwitchSessionToVL,
}, },
{ {
label: 'Do not switch, show guidance', label: 'Continue with current model',
value: VisionSwitchOutcome.DisallowWithGuidance, value: VisionSwitchOutcome.ContinueWithCurrentModel,
}, },
]; ];
@@ -81,18 +81,18 @@ describe('ModelSwitchDialog', () => {
); );
}); });
it('should call onSelect with DisallowWithGuidance when third option is selected', () => { it('should call onSelect with ContinueWithCurrentModel when third option is selected', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />); render(<ModelSwitchDialog onSelect={mockOnSelect} />);
const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect; const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect;
onSelectCallback(VisionSwitchOutcome.DisallowWithGuidance); onSelectCallback(VisionSwitchOutcome.ContinueWithCurrentModel);
expect(mockOnSelect).toHaveBeenCalledWith( expect(mockOnSelect).toHaveBeenCalledWith(
VisionSwitchOutcome.DisallowWithGuidance, VisionSwitchOutcome.ContinueWithCurrentModel,
); );
}); });
it('should setup escape key handler to call onSelect with DisallowWithGuidance', () => { it('should setup escape key handler to call onSelect with ContinueWithCurrentModel', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />); render(<ModelSwitchDialog onSelect={mockOnSelect} />);
expect(mockUseKeypress).toHaveBeenCalledWith(expect.any(Function), { expect(mockUseKeypress).toHaveBeenCalledWith(expect.any(Function), {
@@ -104,7 +104,7 @@ describe('ModelSwitchDialog', () => {
keypressHandler({ name: 'escape' }); keypressHandler({ name: 'escape' });
expect(mockOnSelect).toHaveBeenCalledWith( expect(mockOnSelect).toHaveBeenCalledWith(
VisionSwitchOutcome.DisallowWithGuidance, VisionSwitchOutcome.ContinueWithCurrentModel,
); );
}); });
@@ -126,13 +126,9 @@ describe('ModelSwitchDialog', () => {
describe('VisionSwitchOutcome enum', () => { describe('VisionSwitchOutcome enum', () => {
it('should have correct enum values', () => { it('should have correct enum values', () => {
expect(VisionSwitchOutcome.SwitchOnce).toBe('switch_once'); expect(VisionSwitchOutcome.SwitchOnce).toBe('once');
expect(VisionSwitchOutcome.SwitchSessionToVL).toBe( expect(VisionSwitchOutcome.SwitchSessionToVL).toBe('session');
'switch_session_to_vl', expect(VisionSwitchOutcome.ContinueWithCurrentModel).toBe('persist');
);
expect(VisionSwitchOutcome.DisallowWithGuidance).toBe(
'disallow_with_guidance',
);
}); });
}); });
@@ -144,7 +140,7 @@ describe('ModelSwitchDialog', () => {
// Call multiple times // Call multiple times
onSelectCallback(VisionSwitchOutcome.SwitchOnce); onSelectCallback(VisionSwitchOutcome.SwitchOnce);
onSelectCallback(VisionSwitchOutcome.SwitchSessionToVL); onSelectCallback(VisionSwitchOutcome.SwitchSessionToVL);
onSelectCallback(VisionSwitchOutcome.DisallowWithGuidance); onSelectCallback(VisionSwitchOutcome.ContinueWithCurrentModel);
expect(mockOnSelect).toHaveBeenCalledTimes(3); expect(mockOnSelect).toHaveBeenCalledTimes(3);
expect(mockOnSelect).toHaveBeenNthCalledWith( expect(mockOnSelect).toHaveBeenNthCalledWith(
@@ -157,7 +153,7 @@ describe('ModelSwitchDialog', () => {
); );
expect(mockOnSelect).toHaveBeenNthCalledWith( expect(mockOnSelect).toHaveBeenNthCalledWith(
3, 3,
VisionSwitchOutcome.DisallowWithGuidance, VisionSwitchOutcome.ContinueWithCurrentModel,
); );
}); });
@@ -179,7 +175,7 @@ describe('ModelSwitchDialog', () => {
expect(mockOnSelect).toHaveBeenCalledTimes(2); expect(mockOnSelect).toHaveBeenCalledTimes(2);
expect(mockOnSelect).toHaveBeenCalledWith( expect(mockOnSelect).toHaveBeenCalledWith(
VisionSwitchOutcome.DisallowWithGuidance, VisionSwitchOutcome.ContinueWithCurrentModel,
); );
}); });
}); });

View File

@@ -14,9 +14,9 @@ import {
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
export enum VisionSwitchOutcome { export enum VisionSwitchOutcome {
SwitchOnce = 'switch_once', SwitchOnce = 'once',
SwitchSessionToVL = 'switch_session_to_vl', SwitchSessionToVL = 'session',
DisallowWithGuidance = 'disallow_with_guidance', ContinueWithCurrentModel = 'persist',
} }
export interface ModelSwitchDialogProps { export interface ModelSwitchDialogProps {
@@ -29,7 +29,7 @@ export const ModelSwitchDialog: React.FC<ModelSwitchDialogProps> = ({
useKeypress( useKeypress(
(key) => { (key) => {
if (key.name === 'escape') { if (key.name === 'escape') {
onSelect(VisionSwitchOutcome.DisallowWithGuidance); onSelect(VisionSwitchOutcome.ContinueWithCurrentModel);
} }
}, },
{ isActive: true }, { isActive: true },
@@ -45,8 +45,8 @@ export const ModelSwitchDialog: React.FC<ModelSwitchDialogProps> = ({
value: VisionSwitchOutcome.SwitchSessionToVL, value: VisionSwitchOutcome.SwitchSessionToVL,
}, },
{ {
label: 'Do not switch, show guidance', label: 'Continue with current model',
value: VisionSwitchOutcome.DisallowWithGuidance, value: VisionSwitchOutcome.ContinueWithCurrentModel,
}, },
]; ];

View File

@@ -175,11 +175,11 @@ describe('useVisionAutoSwitch helpers', () => {
expect(result).toEqual({ persistSessionModel: vl }); expect(result).toEqual({ persistSessionModel: vl });
}); });
it('maps DisallowWithGuidance to showGuidance', () => { it('maps ContinueWithCurrentModel to empty result', () => {
const result = processVisionSwitchOutcome( const result = processVisionSwitchOutcome(
VisionSwitchOutcome.DisallowWithGuidance, VisionSwitchOutcome.ContinueWithCurrentModel,
); );
expect(result).toEqual({ showGuidance: true }); expect(result).toEqual({});
}); });
}); });
@@ -205,6 +205,7 @@ describe('useVisionAutoSwitch hook', () => {
authType: AuthType, authType: AuthType,
initialModel: string, initialModel: string,
approvalMode: ApprovalMode = ApprovalMode.DEFAULT, approvalMode: ApprovalMode = ApprovalMode.DEFAULT,
vlmSwitchMode?: string,
) => { ) => {
let currentModel = initialModel; let currentModel = initialModel;
const mockConfig: Partial<Config> = { const mockConfig: Partial<Config> = {
@@ -213,6 +214,7 @@ describe('useVisionAutoSwitch hook', () => {
currentModel = m; currentModel = m;
}), }),
getApprovalMode: vi.fn(() => approvalMode), getApprovalMode: vi.fn(() => approvalMode),
getVlmSwitchMode: vi.fn(() => vlmSwitchMode),
getContentGeneratorConfig: vi.fn(() => ({ getContentGeneratorConfig: vi.fn(() => ({
authType, authType,
model: currentModel, model: currentModel,
@@ -281,11 +283,9 @@ describe('useVisionAutoSwitch hook', () => {
expect(onVisionSwitchRequired).not.toHaveBeenCalled(); expect(onVisionSwitchRequired).not.toHaveBeenCalled();
}); });
it('shows guidance and blocks when dialog returns showGuidance', async () => { it('continues with current model when dialog returns empty result', async () => {
const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus');
const onVisionSwitchRequired = vi const onVisionSwitchRequired = vi.fn().mockResolvedValue({}); // Empty result for ContinueWithCurrentModel
.fn()
.mockResolvedValue({ showGuidance: true });
const { result } = renderHook(() => const { result } = renderHook(() =>
useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired),
); );
@@ -300,11 +300,12 @@ describe('useVisionAutoSwitch hook', () => {
res = await result.current.handleVisionSwitch(parts, userTs, false); res = await result.current.handleVisionSwitch(parts, userTs, false);
}); });
expect(addItem).toHaveBeenCalledWith( // Should not add any guidance message
expect(addItem).not.toHaveBeenCalledWith(
{ type: MessageType.INFO, text: getVisionSwitchGuidanceMessage() }, { type: MessageType.INFO, text: getVisionSwitchGuidanceMessage() },
userTs, userTs,
); );
expect(res).toEqual({ shouldProceed: false }); expect(res).toEqual({ shouldProceed: true });
expect(config.setModel).not.toHaveBeenCalled(); expect(config.setModel).not.toHaveBeenCalled();
}); });
@@ -328,13 +329,19 @@ describe('useVisionAutoSwitch hook', () => {
}); });
expect(res).toEqual({ shouldProceed: true, originalModel: initialModel }); expect(res).toEqual({ shouldProceed: true, originalModel: initialModel });
expect(config.setModel).toHaveBeenCalledWith('qwen-vl-max-latest'); expect(config.setModel).toHaveBeenCalledWith('qwen-vl-max-latest', {
reason: 'vision_auto_switch',
context: 'User-prompted vision switch (one-time override)',
});
// Now restore // Now restore
act(() => { act(() => {
result.current.restoreOriginalModel(); result.current.restoreOriginalModel();
}); });
expect(config.setModel).toHaveBeenLastCalledWith(initialModel); 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 () => { it('persists session model when dialog requests persistence', async () => {
@@ -356,7 +363,10 @@ describe('useVisionAutoSwitch hook', () => {
}); });
expect(res).toEqual({ shouldProceed: true }); expect(res).toEqual({ shouldProceed: true });
expect(config.setModel).toHaveBeenCalledWith('qwen-vl-max-latest'); expect(config.setModel).toHaveBeenCalledWith('qwen-vl-max-latest', {
reason: 'vision_auto_switch',
context: 'User-prompted vision switch (session persistent)',
});
// Restore should be a no-op since no one-time override was used // Restore should be a no-op since no one-time override was used
act(() => { act(() => {
@@ -460,7 +470,10 @@ describe('useVisionAutoSwitch hook', () => {
shouldProceed: true, shouldProceed: true,
originalModel: initialModel, originalModel: initialModel,
}); });
expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel()); 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 () => { it('does not switch in YOLO mode when no images are present', async () => {
@@ -548,7 +561,10 @@ describe('useVisionAutoSwitch hook', () => {
}); });
// Verify model was switched // Verify model was switched
expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel()); expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel(), {
reason: 'vision_auto_switch',
context: 'YOLO mode auto-switch for image content',
});
// Now restore the original model // Now restore the original model
act(() => { act(() => {
@@ -556,7 +572,10 @@ describe('useVisionAutoSwitch hook', () => {
}); });
// Verify model was restored // Verify model was restored
expect(config.setModel).toHaveBeenLastCalledWith(initialModel); 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 () => { it('does not switch in YOLO mode when authType is not QWEN_OAUTH', async () => {
@@ -652,7 +671,184 @@ describe('useVisionAutoSwitch hook', () => {
shouldProceed: true, shouldProceed: true,
originalModel: initialModel, originalModel: initialModel,
}); });
expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel()); 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('qwen-vl-max-latest', {
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('qwen-vl-max-latest', {
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: 'qwen-vl-max-latest' });
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(); expect(onVisionSwitchRequired).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -166,11 +166,11 @@ export function processVisionSwitchOutcome(
case VisionSwitchOutcome.SwitchSessionToVL: case VisionSwitchOutcome.SwitchSessionToVL:
return { persistSessionModel: vlModelId }; return { persistSessionModel: vlModelId };
case VisionSwitchOutcome.DisallowWithGuidance: case VisionSwitchOutcome.ContinueWithCurrentModel:
return { showGuidance: true }; return {}; // Continue with current model, no changes needed
default: default:
return { showGuidance: true }; return {}; // Default to continuing with current model
} }
} }
@@ -256,42 +256,87 @@ export function useVisionAutoSwitch(
if (config.getApprovalMode() === ApprovalMode.YOLO) { if (config.getApprovalMode() === ApprovalMode.YOLO) {
const vlModelId = getDefaultVisionModel(); const vlModelId = getDefaultVisionModel();
originalModelRef.current = config.getModel(); originalModelRef.current = config.getModel();
config.setModel(vlModelId); config.setModel(vlModelId, {
reason: 'vision_auto_switch',
context: 'YOLO mode auto-switch for image content',
});
return { return {
shouldProceed: true, shouldProceed: true,
originalModel: originalModelRef.current, originalModel: originalModelRef.current,
}; };
} }
try { // Check if there's a default VLM switch mode configured
const visionSwitchResult = await onVisionSwitchRequired(query); const defaultVlmSwitchMode = config.getVlmSwitchMode();
if (defaultVlmSwitchMode) {
if (visionSwitchResult.showGuidance) { // Convert string value to VisionSwitchOutcome enum
// Show guidance and don't proceed with the request let outcome: VisionSwitchOutcome;
addItem( switch (defaultVlmSwitchMode) {
{ case 'once':
type: MessageType.INFO, outcome = VisionSwitchOutcome.SwitchOnce;
text: getVisionSwitchGuidanceMessage(), break;
}, case 'session':
userMessageTimestamp, outcome = VisionSwitchOutcome.SwitchSessionToVL;
); break;
return { shouldProceed: false }; 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) { if (visionSwitchResult.modelOverride) {
// One-time model override // One-time model override
originalModelRef.current = config.getModel(); originalModelRef.current = config.getModel();
config.setModel(visionSwitchResult.modelOverride); config.setModel(visionSwitchResult.modelOverride, {
reason: 'vision_auto_switch',
context: `Default VLM switch mode: ${defaultVlmSwitchMode} (one-time override)`,
});
return { return {
shouldProceed: true, shouldProceed: true,
originalModel: originalModelRef.current, originalModel: originalModelRef.current,
}; };
} else if (visionSwitchResult.persistSessionModel) { } else if (visionSwitchResult.persistSessionModel) {
// Persistent session model change // Persistent session model change
config.setModel(visionSwitchResult.persistSessionModel); config.setModel(visionSwitchResult.persistSessionModel, {
reason: 'vision_auto_switch',
context: `Default VLM switch mode: ${defaultVlmSwitchMode} (session persistent)`,
});
return { shouldProceed: true }; 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();
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
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 }; return { shouldProceed: true };
} catch (_error) { } catch (_error) {
// If vision switch dialog was cancelled or errored, don't proceed // If vision switch dialog was cancelled or errored, don't proceed
@@ -303,7 +348,10 @@ export function useVisionAutoSwitch(
const restoreOriginalModel = useCallback(() => { const restoreOriginalModel = useCallback(() => {
if (originalModelRef.current) { if (originalModelRef.current) {
config.setModel(originalModelRef.current); config.setModel(originalModelRef.current, {
reason: 'vision_auto_switch',
context: 'Restoring original model after vision switch',
});
originalModelRef.current = null; originalModelRef.current = null;
} }
}, [config]); }, [config]);

View File

@@ -10,9 +10,12 @@ export type AvailableModel = {
isVision?: boolean; isVision?: boolean;
}; };
export const MAINLINE_VLM = 'qwen-vl-max-latest';
export const MAINLINE_CODER = 'qwen3-coder-plus';
export const AVAILABLE_MODELS_QWEN: AvailableModel[] = [ export const AVAILABLE_MODELS_QWEN: AvailableModel[] = [
{ id: 'qwen3-coder-plus', label: 'qwen3-coder-plus' }, { id: MAINLINE_CODER, label: MAINLINE_CODER },
{ id: 'qwen-vl-max-latest', label: 'qwen-vl-max', isVision: true }, { id: MAINLINE_VLM, label: MAINLINE_VLM, isVision: true },
]; ];
/** /**
@@ -42,7 +45,7 @@ export function getOpenAIAvailableModelFromEnv(): AvailableModel | null {
* until our coding model supports multimodal. * until our coding model supports multimodal.
*/ */
export function getDefaultVisionModel(): string { export function getDefaultVisionModel(): string {
return 'qwen-vl-max-latest'; return MAINLINE_VLM;
} }
export function isVisionModel(modelId: string): boolean { export function isVisionModel(modelId: string): boolean {

View File

@@ -737,4 +737,85 @@ describe('setApprovalMode with folder trust', () => {
expect(() => config.setApprovalMode(ApprovalMode.AUTO_EDIT)).not.toThrow(); expect(() => config.setApprovalMode(ApprovalMode.AUTO_EDIT)).not.toThrow();
expect(() => config.setApprovalMode(ApprovalMode.DEFAULT)).not.toThrow(); expect(() => config.setApprovalMode(ApprovalMode.DEFAULT)).not.toThrow();
}); });
describe('Model Switch Logging', () => {
it('should log model switch when setModel is called with different model', async () => {
const config = new Config({
sessionId: 'test-model-switch',
targetDir: '.',
debugMode: false,
model: 'qwen3-coder-plus',
cwd: '.',
});
// Initialize the config to set up content generator
await config.initialize();
// Mock the logger's logModelSwitch method
const logModelSwitchSpy = vi.spyOn(config['logger']!, 'logModelSwitch');
// Change the model
config.setModel('qwen-vl-max-latest', {
reason: 'vision_auto_switch',
context: 'Test model switch',
});
// Verify that logModelSwitch was called with correct parameters
expect(logModelSwitchSpy).toHaveBeenCalledWith({
fromModel: 'qwen3-coder-plus',
toModel: 'qwen-vl-max-latest',
reason: 'vision_auto_switch',
context: 'Test model switch',
});
});
it('should not log when setModel is called with same model', async () => {
const config = new Config({
sessionId: 'test-same-model',
targetDir: '.',
debugMode: false,
model: 'qwen3-coder-plus',
cwd: '.',
});
// Initialize the config to set up content generator
await config.initialize();
// Mock the logger's logModelSwitch method
const logModelSwitchSpy = vi.spyOn(config['logger']!, 'logModelSwitch');
// Set the same model
config.setModel('qwen3-coder-plus');
// Verify that logModelSwitch was not called
expect(logModelSwitchSpy).not.toHaveBeenCalled();
});
it('should use default reason when no options provided', async () => {
const config = new Config({
sessionId: 'test-default-reason',
targetDir: '.',
debugMode: false,
model: 'qwen3-coder-plus',
cwd: '.',
});
// Initialize the config to set up content generator
await config.initialize();
// Mock the logger's logModelSwitch method
const logModelSwitchSpy = vi.spyOn(config['logger']!, 'logModelSwitch');
// Change the model without options
config.setModel('qwen-vl-max-latest');
// Verify that logModelSwitch was called with default reason
expect(logModelSwitchSpy).toHaveBeenCalledWith({
fromModel: 'qwen3-coder-plus',
toModel: 'qwen-vl-max-latest',
reason: 'manual',
context: undefined,
});
});
});
}); });

View File

@@ -56,6 +56,7 @@ import {
DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_FLASH_MODEL,
} from './models.js'; } from './models.js';
import { Storage } from './storage.js'; import { Storage } from './storage.js';
import { Logger, type ModelSwitchEvent } from '../core/logger.js';
// Re-export OAuth config type // Re-export OAuth config type
export type { AnyToolInvocation, MCPOAuthConfig }; export type { AnyToolInvocation, MCPOAuthConfig };
@@ -239,6 +240,7 @@ export interface ConfigParameters {
extensionManagement?: boolean; extensionManagement?: boolean;
enablePromptCompletion?: boolean; enablePromptCompletion?: boolean;
skipLoopDetection?: boolean; skipLoopDetection?: boolean;
vlmSwitchMode?: string;
} }
export class Config { export class Config {
@@ -330,9 +332,11 @@ export class Config {
private readonly extensionManagement: boolean; private readonly extensionManagement: boolean;
private readonly enablePromptCompletion: boolean = false; private readonly enablePromptCompletion: boolean = false;
private readonly skipLoopDetection: boolean; private readonly skipLoopDetection: boolean;
private readonly vlmSwitchMode: string | undefined;
private initialized: boolean = false; private initialized: boolean = false;
readonly storage: Storage; readonly storage: Storage;
private readonly fileExclusions: FileExclusions; private readonly fileExclusions: FileExclusions;
private logger: Logger | null = null;
constructor(params: ConfigParameters) { constructor(params: ConfigParameters) {
this.sessionId = params.sessionId; this.sessionId = params.sessionId;
@@ -424,8 +428,15 @@ export class Config {
this.extensionManagement = params.extensionManagement ?? false; this.extensionManagement = params.extensionManagement ?? false;
this.storage = new Storage(this.targetDir); this.storage = new Storage(this.targetDir);
this.enablePromptCompletion = params.enablePromptCompletion ?? false; this.enablePromptCompletion = params.enablePromptCompletion ?? false;
this.vlmSwitchMode = params.vlmSwitchMode;
this.fileExclusions = new FileExclusions(this); this.fileExclusions = new FileExclusions(this);
// Initialize logger asynchronously
this.logger = new Logger(this.sessionId, this.storage);
this.logger.initialize().catch((error) => {
console.debug('Failed to initialize logger:', error);
});
if (params.contextFileName) { if (params.contextFileName) {
setGeminiMdFilename(params.contextFileName); setGeminiMdFilename(params.contextFileName);
} }
@@ -517,11 +528,34 @@ export class Config {
return this.contentGeneratorConfig?.model || this.model; return this.contentGeneratorConfig?.model || this.model;
} }
setModel(newModel: string): void { setModel(
newModel: string,
options?: {
reason?: ModelSwitchEvent['reason'];
context?: string;
},
): void {
const oldModel = this.getModel();
if (this.contentGeneratorConfig) { if (this.contentGeneratorConfig) {
this.contentGeneratorConfig.model = newModel; this.contentGeneratorConfig.model = newModel;
} }
// Log the model switch if the model actually changed
if (oldModel !== newModel && this.logger) {
const switchEvent: ModelSwitchEvent = {
fromModel: oldModel,
toModel: newModel,
reason: options?.reason || 'manual',
context: options?.context,
};
// Log asynchronously to avoid blocking
this.logger.logModelSwitch(switchEvent).catch((error) => {
console.debug('Failed to log model switch:', error);
});
}
// Reinitialize chat with updated configuration while preserving history // Reinitialize chat with updated configuration while preserving history
const geminiClient = this.getGeminiClient(); const geminiClient = this.getGeminiClient();
if (geminiClient && geminiClient.isInitialized()) { if (geminiClient && geminiClient.isInitialized()) {
@@ -938,6 +972,10 @@ export class Config {
return this.skipLoopDetection; return this.skipLoopDetection;
} }
getVlmSwitchMode(): string | undefined {
return this.vlmSwitchMode;
}
async getGitService(): Promise<GitService> { async getGitService(): Promise<GitService> {
if (!this.gitService) { if (!this.gitService) {
this.gitService = new GitService(this.targetDir, this.storage); this.gitService = new GitService(this.targetDir, this.storage);

View File

@@ -755,4 +755,84 @@ describe('Logger', () => {
expect(logger['messageId']).toBe(0); expect(logger['messageId']).toBe(0);
}); });
}); });
describe('Model Switch Logging', () => {
it('should log model switch events correctly', async () => {
const testSessionId = 'test-session-model-switch';
const logger = new Logger(testSessionId, new Storage(process.cwd()));
await logger.initialize();
const modelSwitchEvent = {
fromModel: 'qwen3-coder-plus',
toModel: 'qwen-vl-max-latest',
reason: 'vision_auto_switch' as const,
context: 'YOLO mode auto-switch for image content',
};
await logger.logModelSwitch(modelSwitchEvent);
// Read the log file to verify the entry was written
const logContent = await fs.readFile(TEST_LOG_FILE_PATH, 'utf-8');
const logs: LogEntry[] = JSON.parse(logContent);
const modelSwitchLog = logs.find(
(log) =>
log.sessionId === testSessionId &&
log.type === MessageSenderType.MODEL_SWITCH,
);
expect(modelSwitchLog).toBeDefined();
expect(modelSwitchLog!.type).toBe(MessageSenderType.MODEL_SWITCH);
const loggedEvent = JSON.parse(modelSwitchLog!.message);
expect(loggedEvent.fromModel).toBe('qwen3-coder-plus');
expect(loggedEvent.toModel).toBe('qwen-vl-max-latest');
expect(loggedEvent.reason).toBe('vision_auto_switch');
expect(loggedEvent.context).toBe(
'YOLO mode auto-switch for image content',
);
});
it('should handle multiple model switch events', async () => {
const testSessionId = 'test-session-multiple-switches';
const logger = new Logger(testSessionId, new Storage(process.cwd()));
await logger.initialize();
// Log first switch
await logger.logModelSwitch({
fromModel: 'qwen3-coder-plus',
toModel: 'qwen-vl-max-latest',
reason: 'vision_auto_switch',
context: 'Auto-switch for image',
});
// Log second switch (restore)
await logger.logModelSwitch({
fromModel: 'qwen-vl-max-latest',
toModel: 'qwen3-coder-plus',
reason: 'vision_auto_switch',
context: 'Restoring original model',
});
// Read the log file to verify both entries were written
const logContent = await fs.readFile(TEST_LOG_FILE_PATH, 'utf-8');
const logs: LogEntry[] = JSON.parse(logContent);
const modelSwitchLogs = logs.filter(
(log) =>
log.sessionId === testSessionId &&
log.type === MessageSenderType.MODEL_SWITCH,
);
expect(modelSwitchLogs).toHaveLength(2);
const firstSwitch = JSON.parse(modelSwitchLogs[0].message);
expect(firstSwitch.fromModel).toBe('qwen3-coder-plus');
expect(firstSwitch.toModel).toBe('qwen-vl-max-latest');
const secondSwitch = JSON.parse(modelSwitchLogs[1].message);
expect(secondSwitch.fromModel).toBe('qwen-vl-max-latest');
expect(secondSwitch.toModel).toBe('qwen3-coder-plus');
});
});
}); });

View File

@@ -13,6 +13,7 @@ const LOG_FILE_NAME = 'logs.json';
export enum MessageSenderType { export enum MessageSenderType {
USER = 'user', USER = 'user',
MODEL_SWITCH = 'model_switch',
} }
export interface LogEntry { export interface LogEntry {
@@ -23,6 +24,13 @@ export interface LogEntry {
message: string; message: string;
} }
export interface ModelSwitchEvent {
fromModel: string;
toModel: string;
reason: 'vision_auto_switch' | 'manual' | 'fallback' | 'other';
context?: string;
}
// This regex matches any character that is NOT a letter (a-z, A-Z), // This regex matches any character that is NOT a letter (a-z, A-Z),
// a number (0-9), a hyphen (-), an underscore (_), or a dot (.). // a number (0-9), a hyphen (-), an underscore (_), or a dot (.).
@@ -270,6 +278,17 @@ export class Logger {
} }
} }
async logModelSwitch(event: ModelSwitchEvent): Promise<void> {
const message = JSON.stringify({
fromModel: event.fromModel,
toModel: event.toModel,
reason: event.reason,
context: event.context,
});
await this.logMessage(MessageSenderType.MODEL_SWITCH, message);
}
private _checkpointPath(tag: string): string { private _checkpointPath(tag: string): string {
if (!tag.length) { if (!tag.length) {
throw new Error('No checkpoint tag specified.'); throw new Error('No checkpoint tag specified.');

View File

@@ -111,6 +111,9 @@ const PATTERNS: Array<[RegExp, TokenCount]> = [
// Commercial Qwen3-Coder-Flash: 1M token context // Commercial Qwen3-Coder-Flash: 1M token context
[/^qwen3-coder-flash(-.*)?$/, LIMITS['1m']], // catches "qwen3-coder-flash" and date variants [/^qwen3-coder-flash(-.*)?$/, LIMITS['1m']], // catches "qwen3-coder-flash" and date variants
// Commercial Qwen3-Max-Preview: 256K token context
[/^qwen3-max-preview(-.*)?$/, LIMITS['256k']], // catches "qwen3-max-preview" and date variants
// Open-source Qwen3-Coder variants: 256K native // Open-source Qwen3-Coder variants: 256K native
[/^qwen3-coder-.*$/, LIMITS['256k']], [/^qwen3-coder-.*$/, LIMITS['256k']],
// Open-source Qwen3 2507 variants: 256K native // Open-source Qwen3 2507 variants: 256K native
@@ -166,8 +169,14 @@ const OUTPUT_PATTERNS: Array<[RegExp, TokenCount]> = [
// Qwen3-Coder-Plus: 65,536 max output tokens // Qwen3-Coder-Plus: 65,536 max output tokens
[/^qwen3-coder-plus(-.*)?$/, LIMITS['64k']], [/^qwen3-coder-plus(-.*)?$/, LIMITS['64k']],
// Qwen3-Max-Preview: 65,536 max output tokens
[/^qwen3-max-preview(-.*)?$/, LIMITS['64k']],
// Qwen-VL-Max-Latest: 8,192 max output tokens // Qwen-VL-Max-Latest: 8,192 max output tokens
[/^qwen-vl-max-latest$/, LIMITS['8k']], [/^qwen-vl-max-latest$/, LIMITS['8k']],
// Qwen3-VL-Plus: 8,192 max output tokens
[/^qwen3-vl-plus$/, LIMITS['8k']],
]; ];
/** /**