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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,6 @@ export class DynamicCommandLocalizationService {
items: TranslationItem[],
signal: AbortSignal,
): Promise<Map<string, string>> {
const model = config.getFastModel() ?? config.getModel();
const targetLanguageName = getLanguageNameForTranslationTarget(language);
const translations = new Map<string, string>();

Expand All @@ -391,7 +390,6 @@ export class DynamicCommandLocalizationService {
try {
response = await runSideQuery<Record<string, unknown>>(config, {
purpose: 'dynamic-command-localization',
model,
contents: [{ role: 'user', parts: [{ text: prompt }] }],
schema: {
type: 'object',
Expand Down
117 changes: 111 additions & 6 deletions packages/cli/src/ui/commands/modelCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,108 @@ describe('modelCommand', () => {
});
});

it('should reject unavailable fast models for the current auth type', async () => {
it('should set fast models configured under another auth type', async () => {
const setValue = vi.fn();
const setFastModel = vi.fn();
mockContext = createMockCommandContext({
invocation: {
raw: '/model --fast deepseek-v4-flash',
name: 'model',
args: '--fast deepseek-v4-flash',
},
services: {
config: {
getContentGeneratorConfig: vi.fn().mockReturnValue({
model: 'claude-opus-4-7',
authType: AuthType.USE_ANTHROPIC,
}),
getAllConfiguredModels: vi.fn().mockReturnValue([
{
id: 'deepseek-v4-flash',
label: 'deepseek-v4-flash',
authType: AuthType.USE_OPENAI,
},
{
id: 'claude-opus-4-7',
label: 'claude-opus-4-7',
authType: AuthType.USE_ANTHROPIC,
},
]),
setFastModel,
},
settings: createMockSettings(setValue),
},
});

const result = await modelCommand.action!(
mockContext,
'--fast deepseek-v4-flash',
);

expect(setValue).toHaveBeenCalledWith(
expect.any(String),
'fastModel',
'deepseek-v4-flash',
);
expect(setFastModel).toHaveBeenCalledWith('deepseek-v4-flash');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Fast Model: deepseek-v4-flash',
});
});

it('should set authType-qualified fast model selectors', async () => {
const setValue = vi.fn();
const setFastModel = vi.fn();
mockContext = createMockCommandContext({
invocation: {
raw: '/model --fast openai:deepseek-v4-flash',
name: 'model',
args: '--fast openai:deepseek-v4-flash',
},
services: {
config: {
getContentGeneratorConfig: vi.fn().mockReturnValue({
model: 'claude-opus-4-7',
authType: AuthType.USE_ANTHROPIC,
}),
getAvailableModelsForAuthType: vi.fn((authType: AuthType) =>
authType === AuthType.USE_OPENAI
? [
{
id: 'deepseek-v4-flash',
label: 'deepseek-v4-flash',
authType: AuthType.USE_OPENAI,
},
]
: [],
),
setFastModel,
},
settings: createMockSettings(setValue),
},
});

const result = await modelCommand.action!(
mockContext,
'--fast openai:deepseek-v4-flash',
);

expect(setValue).toHaveBeenCalledWith(
expect.any(String),
'fastModel',
'openai:deepseek-v4-flash',
);
expect(setFastModel).toHaveBeenCalledWith('openai:deepseek-v4-flash');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Fast Model: openai:deepseek-v4-flash',
});
});

it('should reject unavailable fast models across all auth types', async () => {
const setValue = vi.fn();
const setFastModel = vi.fn();
mockContext = createMockCommandContext({
Expand All @@ -410,9 +511,13 @@ describe('modelCommand', () => {
model: 'qwen-plus',
authType: AuthType.USE_OPENAI,
}),
getAvailableModelsForAuthType: vi
.fn()
.mockReturnValue([{ id: 'qwen-turbo', label: 'Qwen Turbo' }]),
getAllConfiguredModels: vi.fn().mockReturnValue([
{
id: 'qwen-turbo',
label: 'Qwen Turbo',
authType: AuthType.USE_OPENAI,
},
]),
setFastModel,
},
settings: createMockSettings(setValue),
Expand All @@ -430,8 +535,8 @@ describe('modelCommand', () => {
type: 'message',
messageType: 'error',
content:
"Fast model 'missing-model' is not available for auth type 'openai'.\n" +
"Available models for 'openai': qwen-turbo.\n" +
"Fast model 'missing-model' is not configured for any auth type.\n" +
'Configured models: qwen-turbo.\n' +
'Configure models in settings.modelProviders or run /model to select an available model.',
});
});
Expand Down
55 changes: 47 additions & 8 deletions packages/cli/src/ui/commands/modelCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
AuthType,
type AvailableModel,
type Config,
resolveModelId,
} from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
import { parseAcpModelOption } from '../../utils/acpModelUtils.js';
Expand Down Expand Up @@ -77,6 +78,25 @@ function formatUnavailableModelMessage(
);
}

function formatUnavailableFastModelMessage(
modelName: string,
availableModels: AvailableModel[],
): string {
const availableModelIds = Array.from(
new Set(availableModels.map((model) => model.id)),
);
const availableModelsLine =
availableModelIds.length === 0
? 'No models are configured.'
: `Configured models: ${availableModelIds.join(', ')}.`;

return (
`Fast model '${modelName}' is not configured for any auth type.\n` +
`${availableModelsLine}\n` +
'Configure models in settings.modelProviders or run /model to select an available model.'
);
}

// Get an array of the available model IDs as strings
function getAvailableModelIds(context: CommandContext) {
const { services } = context;
Expand Down Expand Up @@ -174,17 +194,36 @@ export const modelCommand: SlashCommand = {
};
}

const availableModels = config.getAvailableModelsForAuthType(authType);
if (!availableModels.some((model) => model.id === modelName)) {
const selector = (() => {
try {
return resolveModelId(modelName);
} catch {
return undefined;
}
})();
if (!selector) {
return {
type: 'message',
messageType: 'error',
content: formatUnavailableModelMessage(
'Fast model',
modelName,
authType,
availableModels,
),
content: formatUnavailableFastModelMessage(modelName, []),
};
}

const availableModels = selector.authType
? config.getAvailableModelsForAuthType(selector.authType)
: config.getAllConfiguredModels();
if (!availableModels.some((model) => model.id === selector.modelId)) {
return {
type: 'message',
messageType: 'error',
content: selector.authType
? formatUnavailableModelMessage(
'Fast model',
selector.modelId,
selector.authType,
availableModels,
)
: formatUnavailableFastModelMessage(modelName, availableModels),
};
}

Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/ui/commands/renameCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,9 @@ export const renameCommand: SlashCommand = {
// the main model here because `--auto` is a deliberate opt-in to the
// sentence-case fast-model flow, and surprising a user with a main-
// model call would defeat the purpose.
if (!config.getFastModel()) {
const fastModel =
config.getFastModelForSideQuery?.() ?? config.getFastModel();
if (!fastModel) {
return {
type: 'message',
messageType: 'error',
Expand Down
102 changes: 102 additions & 0 deletions packages/cli/src/ui/components/ModelDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,108 @@ describe('<ModelDialog />', () => {
expect(props.onClose).toHaveBeenCalledTimes(1);
});

it('stores authType-qualified selectors in fast model mode', async () => {
const setFastModel = vi.fn();
const { props, mockSettings } = renderComponent({ isFastModelMode: true }, {
getAuthType: vi.fn(() => AuthType.USE_ANTHROPIC),
getModel: vi.fn(() => 'claude-opus-4-7'),
getAllConfiguredModels: vi.fn(() => [
{
id: 'deepseek-v4-flash',
label: 'deepseek-v4-flash',
authType: AuthType.USE_OPENAI,
},
{
id: 'claude-opus-4-7',
label: 'claude-opus-4-7',
authType: AuthType.USE_ANTHROPIC,
},
]),
getContentGeneratorConfig: vi.fn(() => ({
authType: AuthType.USE_ANTHROPIC,
model: 'claude-opus-4-7',
})),
setFastModel,
} as unknown as Partial<Config>);

const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
await childOnSelect(`${AuthType.USE_OPENAI}::deepseek-v4-flash`);

expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'fastModel',
'openai:deepseek-v4-flash',
);
expect(setFastModel).toHaveBeenCalledWith('openai:deepseek-v4-flash');
expect(props.onClose).toHaveBeenCalledTimes(1);
});

it('highlights the cross-auth row for a bare fast-model setting', () => {
// `/model --fast deepseek-v4-flash` validates across all providers and
// persists the bare model id. When the dialog re-opens, it must locate
// the right row even though the setting carries no authType prefix —
// otherwise the highlight falls back to the current auth's first row
// and Enter would silently overwrite the setting.
const mockSettings = {
isTrusted: true,
user: { settings: {} },
workspace: { settings: {} },
merged: { fastModel: 'deepseek-v4-flash' },
setValue: vi.fn(),
} as unknown as LoadedSettings;

const allModels = [
{
id: 'claude-opus-4-7',
label: 'claude-opus-4-7',
description: '',
authType: AuthType.USE_ANTHROPIC,
},
{
id: 'deepseek-v4-flash',
label: 'deepseek-v4-flash',
description: '',
authType: AuthType.USE_OPENAI,
},
];

render(
<SettingsContext.Provider value={mockSettings}>
<ConfigContext.Provider
value={
{
getModel: vi.fn(() => 'claude-opus-4-7'),
getAuthType: vi.fn(() => AuthType.USE_ANTHROPIC),
getAllConfiguredModels: vi.fn(() => allModels),
getContentGeneratorConfig: vi.fn(() => ({
authType: AuthType.USE_ANTHROPIC,
model: 'claude-opus-4-7',
})),
getModelsConfig: vi.fn(() => ({
getGenerationConfig: vi.fn(() => ({ baseUrl: undefined })),
})),
getActiveRuntimeModelSnapshot: vi.fn(() => undefined),
getUsageStatisticsEnabled: vi.fn(() => false),
getSessionId: vi.fn(() => 'session'),
getDebugMode: vi.fn(() => false),
getUseModelRouter: vi.fn(() => false),
getProxy: vi.fn(() => undefined),
} as unknown as Config
}
>
<ModelDialog onClose={vi.fn()} isFastModelMode={true} />
</ConfigContext.Provider>
</SettingsContext.Provider>,
);

const items = mockedSelect.mock.calls[0][0].items;
const deepseekIndex = items.findIndex((item) =>
String(item.value).includes('deepseek-v4-flash'),
);
expect(deepseekIndex).toBeGreaterThanOrEqual(0);
expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(deepseekIndex);
});

it('blocks switching to qwen-oauth from another authType (discontinued)', async () => {
const switchModel = vi.fn().mockResolvedValue(undefined);
const getAuthType = vi.fn(() => AuthType.USE_OPENAI);
Expand Down
Loading
Loading