feat(cli): Add ability to switch models non-interactively from the cli - #3783
Conversation
This fulfills request QwenLM#3410
wenshao
left a comment
There was a problem hiding this comment.
Second-opinion review with pai/glm-5 (previous review used glm-5.1). The 4 findings from the prior review (missing await, missing persistence, dead code, typo) are all confirmed and still apply. Here are additional findings not previously reported:
[Critical] config.setModel() rollback leaves misleading state — When switchModel throws (model exists in global registry but not for current auth type), it calls rollbackToStateSnapshot internally. Without await, the user sees "Model: X" while the runtime state has actually rolled back. This is a false-positive confirmation — strictly worse than a silent no-op.
[Suggestion] No model name validation — args.trim().split(' ')[0] can produce an empty string (e.g., /model passes args !== '' but trim+split yields ""). config.setModel() accepts any string without validation (raw model override path). Consider validating non-empty after extracting first token, and optionally warning if the model is not in the registry.
[Suggestion] Inconsistent model name parsing — Interactive path uses args.trim().split(' ')[0] (first token) while non-interactive path uses args.trim() (full string). /model some name with spaces produces "some" in interactive vs "some name with spaces" in non-interactive. Consider aligning the parsing strategy.
[Suggestion] Completion handler doesn't suggest model names — The completion handler only suggests --fast. With the new feature, /model qwen3 should tab-complete matching model IDs. config.getAvailableModels() is available but unused.
[Suggestion] Command description doesn't document new feature — The description only mentions --fast. Users have no indication that /model <model-name> is valid. Update the i18n string to mention both usage patterns.
[Nice to have] Missing space after // — //Handle and //Use should be // Handle and // Use per codebase convention.
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
This reverts commit 7b49c44.
Empty model names are now ignored. ```/model ``` (with trailing whitespace) will still open the interactive model picker.
Realigned the non-interactive path to use the same ```args.trim().split(' ')[0]``` logic. Valid model IDs can not contain spaces anyway. If preferred, this specific change can be reverted and the new code can use the old logic instead.
|
I have merged and implemented the proposed changes. A few notes:
|
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
This reverts commit 0600b23.
|
Updated with newest requested changes. |
wenshao
left a comment
There was a problem hiding this comment.
Test gap: The new /model <name> feature has zero test coverage. No tests exist for: interactive mode model switching with valid/invalid model names, non-interactive mode model switching, the getAvailableModelIds helper, or the updated completion handler. The only test change is the description string update.
[Suggestion] _context misleading prefix: The completion handler parameter is named _context (underscore convention for unused), but it's passed to getAvailableModelIds(_context). Rename to context.
| messageType: 'error', | ||
| content: t('Settings service not available.'), | ||
| }; | ||
| } |
There was a problem hiding this comment.
[Critical] Two issues at this setModel call:
-
Validation too late:
hasModel()check runs AFTERconfig.setModel()+settings.setValue(). An invalid model name (e.g.,/model gpt4instead ofgpt-4) gets silently set and persisted; the(not in model registry)suffix is onlyinfolevel, not a guard. MovehasModel()beforesetModeland return an error for unknown models. -
No error handling: No
try/catcharoundsetModel+setValue. IfsetModelthrows, the error propagates unhandled and may crash the session. IfsetModelsucceeds butsetValuefails, the model is switched in memory but not persisted — user sees success but the change is lost on restart with no warning.
— deepseek-v4-pro via Qwen Code /review
There was a problem hiding this comment.
Returning an error for unknown models is explicitly not the intended behavior. Per the feature request, the new syntax should allow the user to use model IDs that are not already in the local registry.
This should only be relevant in the raw path, which does not have any internal validation. I can add try/catch blocks to address the other cases, but explicitly guarding against unknown model IDs would defeat the purpose of the feature. A note is already provided when the model ID is unknown, and clear 404 errors are already safely passed to the user if they switch to a model that's not available upstream and then attempt to use it.
There was a problem hiding this comment.
I also question if this needed since the other cases already fail gracefully on their own.
| @@ -122,12 +176,12 @@ export const modelCommand: SlashCommand = { | |||
| content: t('Settings service not available.'), | |||
There was a problem hiding this comment.
[Critical] The non-interactive path has no hasModel() check at all (unlike the interactive path which at least appends (not in model registry) after the fact). Users switching models in non-interactive/ACP/CI mode get zero indication whether the model name is valid — it's silently persisted regardless.
Extract the shared model-switching logic (both paths do setModel + setValue + return info) into a single helper that includes the registry check, and use it in both paths. This eliminates both the code duplication and the behavioral divergence.
— deepseek-v4-pro via Qwen Code /review
There was a problem hiding this comment.
A lot of the proposed changes for this PR have focused on differences between the interactive and non-interactive paths. At this point, I would propose that the non-interactive path be removed entirely, since its syntax is almost identical to the new feature.
To ensure the previous behavior is fully maintained, the check for non-interactive calls would be moved to the case with no arguments, where it would decide whether to open the interactive model switcher or simply print the model name.
Since this marks a sizeable refactor of modelCommand, should I go ahead with this change here or save it for a separate PR for organization?
| @@ -110,10 +128,46 @@ export const modelCommand: SlashCommand = { | |||
| }; | |||
There was a problem hiding this comment.
[Suggestion] Interactive and non-interactive model-switch blocks are nearly identical — consider extracting a shared helper
Both blocks extract modelName the same way (args.trim().split(' ')[0]), guard with modelName.trim(), check settings availability, call config.setModel() then settings.setValue(). The only difference is the hasModel() check in the interactive path. This duplication caused the setModel/setValue ordering inconsistency in the previous version and makes future maintenance error-prone.
| }; | |
| async function switchModel( | |
| config: Config, | |
| settings: LoadedSettings, | |
| authType: AuthType, | |
| modelName: string, | |
| ): Promise<MessageActionReturn> { | |
| if (!settings) { | |
| return { type: 'message', messageType: 'error', content: t('Settings service not available.') }; | |
| } | |
| await config.setModel(modelName); | |
| settings.setValue( | |
| getPersistScopeForModelSelection(settings), | |
| 'model.name', | |
| modelName, | |
| ); | |
| const suffix = config.getModelsConfig().hasModel(authType, modelName) | |
| ? '' | |
| : ' (not in model registry)'; | |
| return { type: 'message', messageType: 'info', content: t('Model') + ': ' + modelName + suffix }; | |
| } |
— pai/glm-5.1 via Qwen Code /review
There was a problem hiding this comment.
Addressed in a different comment where I propose merging the two paths.
| @@ -122,12 +176,12 @@ export const modelCommand: SlashCommand = { | |||
| content: t('Settings service not available.'), | |||
There was a problem hiding this comment.
[Suggestion] Non-interactive path silently persists unregistered models with no warning
The interactive path appends " (not in model registry)" when hasModel() returns false, but the non-interactive/ACP path returns a plain "Model: X" with no indication the model is unrecognized. Automation pipelines and ACP integrations get zero feedback when switching to an invalid model.
| content: t('Settings service not available.'), | |
| await config.setModel(modelName); | |
| settings.setValue( | |
| getPersistScopeForModelSelection(settings), | |
| 'model.name', | |
| modelName, | |
| ); | |
| if (config.getModelsConfig().hasModel(authType, modelName)) { | |
| return { | |
| type: 'message', | |
| messageType: 'info', | |
| content: t('Model') + ': ' + modelName, | |
| }; | |
| } else { | |
| return { | |
| type: 'message', | |
| messageType: 'info', | |
| content: t('Model') + ': ' + modelName + ' (not in model registry)', | |
| }; | |
| } |
— pai/glm-5.1 via Qwen Code /review
There was a problem hiding this comment.
This is intended behavior, as mentioned in a separate comment.
| expect(modelCommand.name).toBe('model'); | ||
| expect(modelCommand.description).toBe( | ||
| 'Switch the model for this session (--fast for suggestion model)', | ||
| 'Switch the model for this session (--fast for suggestion model, [model-id] to switch immediately).', |
There was a problem hiding this comment.
[Suggestion] No test coverage for the new interactive immediate-switch feature
The core of this PR — /model <model-id> in interactive mode — has zero test cases. All existing interactive tests pass args = '' (dialog path). Consider adding tests for: (1) valid model switch with setModel/setValue called, (2) unregistered model showing "(not in model registry)", (3) multi-argument input using only first token, (4) settings unavailable guard.
— pai/glm-5.1 via Qwen Code /review
There was a problem hiding this comment.
--fast is already not tested with model IDs, and most of these changes are beyond the scope of this PR. Testing with any specific valid or invalid model ID is difficult since the registry is user-configurable and therefore not stable. If there is a specific "default" registry to test against, this can be implemented; but again, this is asking for a sizeable update to the unit tests which is probably best handled separately.
|
I've reviewed the new proposed changes and made comments. Here is a summary of my input.
I have fixed the Awaiting comment on the other requests. |
|
Still awaiting input on my proposal to merge the interactive and non-interactive paths. |
wenshao
left a comment
There was a problem hiding this comment.
Thanks for the iteration — CI is now fully green and the previous critical findings are resolved. Two remaining items before approval:
[Blocker] i18n regression introduced by this PR
-
The new description key at
modelCommand.ts:33-35('Switch the model for this session (--fast for suggestion model, [model-id] to switch immediately).') does not exist in any of the 9 locale files (en.js,zh.js,zh-TW.js,ja.js,ca.js,de.js,fr.js,pt.js,ru.js). Perpackages/cli/src/i18n/index.ts:236,t()falls back to the raw key when the translation is missing — so all non-English users will see the English description in place of their previously translated string, and the old key becomes dead weight in every locale. Please replace the old key with the new key in each locale file (English fallback is fine for non-English ones; native translators can backfill later). -
' (not in model registry)'at line 163 is hardcoded English (string concatenation, not wrapped int()). Same impact: non-English users always see this in English. Either wrap it viat()and add the key to the locales, or extract a translatable suffix.
On the proposal to merge interactive and non-interactive paths
Let's keep that as a follow-up PR — the divergence (hasModel check is intentionally interactive-only) is by design and already explained in your replies; widening the diff now would re-open review surface that has converged. Happy to land the refactor separately once this is in.
|
Thank you for the prompt and clear feedback. i18n has been updated. |
wenshao
left a comment
There was a problem hiding this comment.
Second-opinion review with deepseek-v4-pro (previous review used deepseek-v4-pro). This PR has been through extensive review already; most findings from this pass overlap with previously discussed issues.
One new finding that hasn't been discussed:
[Suggestion] modelName.trim() is redundant — At lines 135 and 173, args.trim().split(' ')[0] already yields a trimmed string (no leading/trailing whitespace after split), so the if (modelName.trim()) check's .trim() is a no-op. Change to if (modelName).
— deepseek-v4-pro via Qwen Code /review
| // Commands - Model | ||
| // ============================================================================ | ||
| 'Switch the model for this session (--fast for suggestion model)': | ||
| 'Switch the model for this session (--fast for suggestion model, [model-id] to switch immediately).': |
There was a problem hiding this comment.
[Suggestion] The updated translation key now documents /model [model-id], but the localized value still translates only the old --fast behavior. This fixes the missing-key fallback, but command help in this locale still omits the feature added by this PR.
Please update the localized text to mention direct model-id switching as well. If native translations are unavailable, temporarily using the English value would preserve feature parity across locales. The same stale value pattern appears in the other non-English locale files changed by this PR.
— gpt-5.5 via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
All previously raised blockers resolved (i18n keys added across all 9 locales; (not in model registry) wrapped in t(); CI fully green). Verified the new /model <model-id> flow locally — main switch, --fast, no-args display, multi-token handling, whitespace-only fallthrough, and settings persistence all behave as advertised. LGTM.
#3783) * Add ability to switch models non-interactively from the cli This fulfills request #3410 * Update packages/cli/src/ui/commands/modelCommand.ts Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com> * Update packages/cli/src/ui/commands/modelCommand.ts Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com> * Update packages/cli/src/ui/commands/modelCommand.ts Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com> * Update packages/cli/src/ui/commands/modelCommand.ts Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com> * Update packages/cli/src/ui/commands/modelCommand.ts Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com> * Revert "Update packages/cli/src/ui/commands/modelCommand.ts" This reverts commit 7b49c44. * Protect against empty model name; align non-interactive behavior Empty model names are now ignored. ```/model ``` (with trailing whitespace) will still open the interactive model picker. Realigned the non-interactive path to use the same ```args.trim().split(' ')[0]``` logic. Valid model IDs can not contain spaces anyway. If preferred, this specific change can be reverted and the new code can use the old logic instead. * Warn if model is not in registry * Update command description * Updated modelCommand test to reflect new description * Implement auto-complete with model IDs * Update packages/cli/src/ui/commands/modelCommand.ts Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com> * Update packages/cli/src/ui/commands/modelCommand.ts Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com> * Revert "Update packages/cli/src/ui/commands/modelCommand.ts" This reverts commit 0600b23. * Update modelCommand.ts * Update modelCommand.ts * Update modelCommand.ts * Update/use i18n keys * Corrected en i18n * removed redundant .trim() on modelName check --------- Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
QwenLM#3783) * Add ability to switch models non-interactively from the cli This fulfills request QwenLM#3410 * Update packages/cli/src/ui/commands/modelCommand.ts Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com> * Update packages/cli/src/ui/commands/modelCommand.ts Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com> * Update packages/cli/src/ui/commands/modelCommand.ts Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com> * Update packages/cli/src/ui/commands/modelCommand.ts Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com> * Update packages/cli/src/ui/commands/modelCommand.ts Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com> * Revert "Update packages/cli/src/ui/commands/modelCommand.ts" This reverts commit 7b49c44. * Protect against empty model name; align non-interactive behavior Empty model names are now ignored. ```/model ``` (with trailing whitespace) will still open the interactive model picker. Realigned the non-interactive path to use the same ```args.trim().split(' ')[0]``` logic. Valid model IDs can not contain spaces anyway. If preferred, this specific change can be reverted and the new code can use the old logic instead. * Warn if model is not in registry * Update command description * Updated modelCommand test to reflect new description * Implement auto-complete with model IDs * Update packages/cli/src/ui/commands/modelCommand.ts Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com> * Update packages/cli/src/ui/commands/modelCommand.ts Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com> * Revert "Update packages/cli/src/ui/commands/modelCommand.ts" This reverts commit 0600b23. * Update modelCommand.ts * Update modelCommand.ts * Update modelCommand.ts * Update/use i18n keys * Corrected en i18n * removed redundant .trim() on modelName check --------- Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Summary
/model,/model --fast, and/model --fast [model-name]still function as previously. This PR adds a new syntax/model [model-name]which allows for immediately switching the active model without using the interactive model selector.qwen --model [model-name]; the intent of this feature is to replicate this functionality at runtime. This functionality also brings greater parity with the existing functionality of other coding agents.This is a small PR with no platform-specific or core changes.
This PR contains no AI-generated code.
Validation
All test scripts passed.
Functionality before changes:

Functionality after changes:

Scope / Risk
--modeland is intended./modelwith invalid syntax. Extraneous arguments were previously ignored. Now, the first extraneous argument is interpreted as a model name. Existing correct syntax is unchanged, and the changes explicitly do not take effect in non-interactive mode. This should not break any workflows or orchestration flows, but the change should be noted.Linked Issues / Bugs