qwen-code/packages/cli/src/ui/commands/modelCommand.test.ts
jinye 4b9f597fa8
fix(model): remember selected provider when multiple share a model id (#5173) (#5179)
* fix(model): disambiguate providers sharing a model id by baseUrl (#5173)

When multiple modelProviders.openai entries share the same model id but
have different baseUrl values, selecting one in the model picker was not
remembered across restarts — startup always resolved to the first id match.

The gap was at the persistence -> startup-resolution boundary:
- persistModelSelection only saved model.name, dropping the baseUrl that
  distinguishes the providers.
- resolveCliGenerationConfig matched providers by id alone (first hit).

Fix (CLI only; runtime switching and ModelRegistry composite keys were
already correct):
- Add optional model.baseUrl setting paired with model.name.
- ModelDialog persists the selected provider's baseUrl, and clears it when
  the selection has none (prevents a stale disambiguator).
- /model <id> is an id-only switch, so it clears model.baseUrl too.
- resolveCliGenerationConfig disambiguates by the persisted baseUrl when the
  model comes from settings, falling back to the first id match when the
  paired provider was edited/removed or no baseUrl was saved.

This keeps the persistence layer consistent with ModelRegistry's existing
id+baseUrl composite key and syncAfterAuthRefresh's exact-match path.

* fix(model): clear model.baseUrl on ACP setModel and provider install

Round-1 review follow-up: extend the "any writer of model.name must set or
clear model.baseUrl" invariant to the remaining id-only persistence paths so a
stale disambiguator from a prior model-picker selection can't restore the wrong
provider on next launch.

- ACP Session.setModel clears model.baseUrl alongside model.name.
- Provider install plan (core) clears model.baseUrl when selecting a model id.

* fix(auth): disambiguate provider by baseUrl in pre-flight auth validation

Round-2 review follow-up: validateAuthMethod / hasApiKeyForAuth looked up the
provider by model id only, so for duplicate-id configs the auth and Anthropic
baseUrl checks could validate the first provider's credentials instead of the
selected one.

- findModelConfig now prefers the exact id+baseUrl match and falls back to the
  first id match (mirrors resolveCliGenerationConfig).
- New resolveSelectedModel helper pairs the resolved/persisted model id with its
  baseUrl (runtime generationConfig first, then settings.model.{name,baseUrl}).
- Thread the baseUrl into both the API-key and Anthropic-baseUrl lookups.

* fix(auth): pair model with its own baseUrl; document cross-scope assumption

Round-3 review follow-up:
- resolveSelectedModel no longer falls back to settings.model.baseUrl when a
  live Config is present, so a runtime-selected model is never paired with a
  stale persisted baseUrl from a previous selection.
- Document the merged-settings assumption at the resolver disambiguation site:
  every writer pairs model.name + model.baseUrl in the same scope, and the
  id-only fallback bounds the blast radius if a hand-edited config desyncs them
  across scopes.

* fix(model): use empty-string tombstone to clear model.baseUrl across scopes

Round-4 review follow-up: clearing model.baseUrl by writing undefined drops the
key from JSON, so a higher-priority scope's clear could not override a stale
model.baseUrl left in a lower-priority scope — the stale disambiguator would
resurface on merge and re-select the wrong duplicate provider after restart
(e.g. pick a provider in user scope, then /model <id> in a workspace that has
its own modelProviders).

All clear sites (model picker, /model, ACP setModel, provider install) now write
an empty-string tombstone, which is a present value that overrides lower scopes
on merge. The resolver and auth lookup already treat '' as "no disambiguator".

* fix(acp): clear model.baseUrl when setCoreValue sets model.name

Round-5 review follow-up: the generic qwen/settings/setCoreValue RPC
(ACP/serve) can change model.name directly without touching model.baseUrl,
leaving a stale disambiguator from a prior model-picker selection that would
re-select the wrong duplicate provider on next launch. Clear it with the same
empty-string tombstone used by the other id-only model.name writers.

* fix(model): warn on silent provider fallback; pair baseUrl with resolved config

Address review comments:
- Emit a warning when the persisted model.baseUrl no longer matches any provider
  and resolution falls back to the first id match (thread 1 [Critical]).
- Source effectiveBaseUrl from the resolved config (after?.baseUrl) rather than
  the picker entry alone, so the persisted (model.name, model.baseUrl) pair
  stays consistent even if switchModel transforms the id (thread 2 [Critical]).

* test(model): cover effectiveBaseUrl fallback to picker entry baseUrl

Add a ModelDialog test where switchModel succeeds but getContentGeneratorConfig
returns a config without baseUrl, exercising the
'after?.baseUrl ?? selectedEntry?.model.baseUrl' fallback. Without coverage, a
regression there would silently write an empty-string tombstone and resolve the
wrong same-id provider on next launch.
2026-06-18 10:18:41 +08:00

647 lines
19 KiB
TypeScript

/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { modelCommand } from './modelCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import {
AuthType,
type ContentGeneratorConfig,
type Config,
} from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
// Helper function to create a mock config
function createMockConfig(
contentGeneratorConfig: ContentGeneratorConfig | null,
): Partial<Config> {
return {
getContentGeneratorConfig: vi.fn().mockReturnValue(contentGeneratorConfig),
};
}
function createMockSettings(setValue = vi.fn()): Partial<LoadedSettings> {
return {
merged: {},
user: { settings: {} },
workspace: { settings: {} },
isTrusted: false,
setValue,
} as unknown as Partial<LoadedSettings>;
}
describe('modelCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
vi.clearAllMocks();
});
it('should have the correct name and description', () => {
expect(modelCommand.name).toBe('model');
expect(modelCommand.description).toBe(
'Switch the model for this session (--fast for suggestion model, [model-id] to switch immediately).',
);
});
it('should return error when config is not available', async () => {
mockContext.services.config = null;
const result = await modelCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
});
});
it('should return error when content generator config is not available', async () => {
const mockConfig = createMockConfig(null);
mockContext.services.config = mockConfig as Config;
const result = await modelCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Content generator configuration not available.',
});
});
it('should return error when auth type is not available', async () => {
const mockConfig = createMockConfig({
model: 'test-model',
authType: undefined,
});
mockContext.services.config = mockConfig as Config;
const result = await modelCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Authentication type not available.',
});
});
it('should return dialog action for QWEN_OAUTH auth type', async () => {
const mockConfig = createMockConfig({
model: 'test-model',
authType: AuthType.QWEN_OAUTH,
});
mockContext.services.config = mockConfig as Config;
const result = await modelCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'model',
});
});
it('should return dialog action for USE_OPENAI auth type', async () => {
const mockConfig = createMockConfig({
model: 'test-model',
authType: AuthType.USE_OPENAI,
});
mockContext.services.config = mockConfig as Config;
const result = await modelCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'model',
});
});
it('should return dialog action for unsupported auth types', async () => {
const mockConfig = createMockConfig({
model: 'test-model',
authType: 'UNSUPPORTED_AUTH_TYPE' as AuthType,
});
mockContext.services.config = mockConfig as Config;
const result = await modelCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'model',
});
});
it('should handle undefined auth type', async () => {
const mockConfig = createMockConfig({
model: 'test-model',
authType: undefined,
});
mockContext.services.config = mockConfig as Config;
const result = await modelCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Authentication type not available.',
});
});
it('should switch the main model directly in interactive mode when args are provided', async () => {
const setValue = vi.fn();
const switchModel = vi.fn().mockResolvedValue(undefined);
mockContext = createMockCommandContext({
invocation: { raw: '/model qwen-max', name: 'model', args: 'qwen-max' },
services: {
config: {
getContentGeneratorConfig: vi.fn().mockReturnValue({
model: 'qwen-plus',
authType: AuthType.QWEN_OAUTH,
}),
getAvailableModelsForAuthType: vi
.fn()
.mockReturnValue([{ id: 'qwen-max', label: 'Qwen Max' }]),
switchModel,
},
settings: createMockSettings(setValue),
},
});
const result = await modelCommand.action!(mockContext, 'qwen-max');
expect(switchModel).toHaveBeenCalledWith(
AuthType.QWEN_OAUTH,
'qwen-max',
undefined,
);
expect(setValue).toHaveBeenCalledWith(
expect.any(String),
'model.name',
'qwen-max',
);
// `/model <id>` is an id-only switch, so any baseUrl disambiguator left by
// a previous model-picker selection must be cleared (empty-string tombstone)
// to avoid resolving to a different provider on next launch.
expect(setValue).toHaveBeenCalledWith(
expect.any(String),
'model.baseUrl',
'',
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Model: qwen-max',
});
});
it('should not persist the model when direct model validation fails', async () => {
const setValue = vi.fn();
const switchModel = vi.fn();
mockContext = createMockCommandContext({
invocation: {
raw: '/model missing-model',
name: 'model',
args: 'missing-model',
},
services: {
config: {
getContentGeneratorConfig: vi.fn().mockReturnValue({
model: 'qwen-plus',
authType: AuthType.QWEN_OAUTH,
}),
switchModel,
getAvailableModelsForAuthType: vi.fn().mockReturnValue([]),
},
settings: createMockSettings(setValue),
},
});
const result = await modelCommand.action!(mockContext, 'missing-model');
expect(switchModel).not.toHaveBeenCalled();
expect(setValue).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
"Model 'missing-model' is not available for auth type 'qwen-oauth'.\n" +
"No models are configured for auth type 'qwen-oauth'.\n" +
'Configure models in settings.modelProviders or run /model to select an available model.',
});
});
it('should not persist the model when direct model switching fails after validation', async () => {
const setValue = vi.fn();
const switchError = new Error('Refresh failed');
const switchModel = vi.fn().mockRejectedValue(switchError);
mockContext = createMockCommandContext({
invocation: {
raw: '/model qwen-max',
name: 'model',
args: 'qwen-max',
},
services: {
config: {
getContentGeneratorConfig: vi.fn().mockReturnValue({
model: 'qwen-plus',
authType: AuthType.QWEN_OAUTH,
}),
switchModel,
getAvailableModelsForAuthType: vi
.fn()
.mockReturnValue([{ id: 'qwen-max', label: 'Qwen Max' }]),
},
settings: createMockSettings(setValue),
},
});
await expect(modelCommand.action!(mockContext, 'qwen-max')).rejects.toThrow(
'Refresh failed',
);
expect(switchModel).toHaveBeenCalledWith(
AuthType.QWEN_OAUTH,
'qwen-max',
undefined,
);
expect(setValue).not.toHaveBeenCalled();
});
it('should explain how to configure models when direct switching fails', async () => {
const setValue = vi.fn();
const switchModel = vi.fn();
mockContext = createMockCommandContext({
invocation: {
raw: '/model definitely-not-a-model',
name: 'model',
args: 'definitely-not-a-model',
},
services: {
config: {
getContentGeneratorConfig: vi.fn().mockReturnValue({
model: 'qwen-plus',
authType: AuthType.USE_OPENAI,
}),
getAvailableModelsForAuthType: vi
.fn()
.mockReturnValue([{ id: 'gpt-4', label: 'GPT-4' }]),
switchModel,
},
settings: createMockSettings(setValue),
},
});
const result = await modelCommand.action!(
mockContext,
'definitely-not-a-model',
);
expect(switchModel).not.toHaveBeenCalled();
expect(setValue).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
"Model 'definitely-not-a-model' is not available for auth type 'openai'.\n" +
"Available models for 'openai': gpt-4.\n" +
'Configure models in settings.modelProviders or run /model to select an available model.',
});
});
it('should explain when no models are configured for direct switching', async () => {
const setValue = vi.fn();
const switchModel = vi
.fn()
.mockRejectedValue(
new Error("Model 'gpt-4o' not found for authType 'openai'"),
);
mockContext = createMockCommandContext({
invocation: {
raw: '/model gpt-4o',
name: 'model',
args: 'gpt-4o',
},
services: {
config: {
getContentGeneratorConfig: vi.fn().mockReturnValue({
model: 'qwen-plus',
authType: AuthType.USE_OPENAI,
}),
getAvailableModelsForAuthType: vi.fn().mockReturnValue([]),
switchModel,
},
settings: createMockSettings(setValue),
},
});
const result = await modelCommand.action!(mockContext, 'gpt-4o');
expect(setValue).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
"Model 'gpt-4o' is not available for auth type 'openai'.\n" +
"No models are configured for auth type 'openai'.\n" +
'Configure models in settings.modelProviders or run /model to select an available model.',
});
});
it('should switch provider-qualified models through switchModel', async () => {
const setValue = vi.fn();
const switchModel = vi.fn().mockResolvedValue(undefined);
mockContext = createMockCommandContext({
invocation: {
raw: `/model gpt-4(${AuthType.USE_OPENAI})`,
name: 'model',
args: `gpt-4(${AuthType.USE_OPENAI})`,
},
services: {
config: {
getContentGeneratorConfig: vi.fn().mockReturnValue({
model: 'qwen-plus',
authType: AuthType.QWEN_OAUTH,
}),
getAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
getAvailableModelsForAuthType: vi
.fn()
.mockReturnValue([{ id: 'gpt-4', label: 'GPT-4' }]),
switchModel,
},
settings: createMockSettings(setValue),
},
});
const result = await modelCommand.action!(
mockContext,
`gpt-4(${AuthType.USE_OPENAI})`,
);
expect(switchModel).toHaveBeenCalledWith(
AuthType.USE_OPENAI,
'gpt-4',
undefined,
);
expect(setValue).toHaveBeenCalledWith(
expect.any(String),
'security.auth.selectedType',
AuthType.USE_OPENAI,
);
expect(setValue).toHaveBeenCalledWith(
expect.any(String),
'model.name',
'gpt-4',
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Model: gpt-4',
});
});
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({
invocation: {
raw: '/model --fast missing-model',
name: 'model',
args: '--fast missing-model',
},
services: {
config: {
getContentGeneratorConfig: vi.fn().mockReturnValue({
model: 'qwen-plus',
authType: AuthType.USE_OPENAI,
}),
getAllConfiguredModels: vi.fn().mockReturnValue([
{
id: 'qwen-turbo',
label: 'Qwen Turbo',
authType: AuthType.USE_OPENAI,
},
]),
setFastModel,
},
settings: createMockSettings(setValue),
},
});
const result = await modelCommand.action!(
mockContext,
'--fast missing-model',
);
expect(setValue).not.toHaveBeenCalled();
expect(setFastModel).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
"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.',
});
});
it('should not treat model IDs prefixed with --fast as the --fast flag', async () => {
const setValue = vi.fn();
const switchModel = vi.fn().mockResolvedValue(undefined);
mockContext = createMockCommandContext({
invocation: {
raw: '/model --fast-model',
name: 'model',
args: '--fast-model',
},
services: {
config: {
getContentGeneratorConfig: vi.fn().mockReturnValue({
model: 'qwen-plus',
authType: AuthType.USE_OPENAI,
}),
getAvailableModelsForAuthType: vi
.fn()
.mockReturnValue([{ id: '--fast-model', label: '--fast-model' }]),
switchModel,
},
settings: createMockSettings(setValue),
},
});
const result = await modelCommand.action!(mockContext, '--fast-model');
expect(switchModel).toHaveBeenCalledWith(
AuthType.USE_OPENAI,
'--fast-model',
undefined,
);
expect(setValue).toHaveBeenCalledWith(
expect.any(String),
'model.name',
'--fast-model',
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Model: --fast-model',
});
});
describe('non-interactive mode', () => {
it('should return current model without triggering dialog when no args', async () => {
mockContext = createMockCommandContext({
executionMode: 'non_interactive',
services: {
config: {
getContentGeneratorConfig: vi.fn().mockReturnValue({
model: 'qwen-max',
authType: AuthType.QWEN_OAUTH,
}),
getModel: vi.fn().mockReturnValue('qwen-max'),
},
},
});
const result = await modelCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('qwen-max'),
});
expect((result as { type: string }).type).toBe('message');
});
it('should return current fast model without triggering dialog for --fast no args', async () => {
mockContext = createMockCommandContext({
executionMode: 'non_interactive',
invocation: { args: '--fast' },
services: {
config: {
getContentGeneratorConfig: vi.fn().mockReturnValue({
model: 'qwen-max',
authType: AuthType.QWEN_OAUTH,
}),
getModel: vi.fn().mockReturnValue('qwen-max'),
},
settings: {
merged: { fastModel: 'qwen-turbo' } as Record<string, unknown>,
},
},
});
const result = await modelCommand.action!(mockContext, '--fast');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('qwen-turbo'),
});
});
});
});