fix(cli): restore model switching after subscription logout

This commit is contained in:
luoyun committed 2026-11-07 17:29:04 +08:00
1 parent 5890329390
commit 093536a477
16 files changed
+211 -53

No files matched your search

@@ -109,15 +109,37 @@ describe('model picker independence from subscription authentication', () => {
messageType: 'info',
content: 'Model: fallback',
});
expect(switchModel).toHaveBeenCalledWith(
AuthType.USE_OPENAI,
'fallback',
undefined,
);
expect(switchModel).toHaveBeenCalledWith(AuthType.USE_OPENAI, 'fallback', {
initializeIfNeeded: true,
});
expect(refreshSubscriptionModels).not.toHaveBeenCalled();
expect(context.ui.addItem).not.toHaveBeenCalled();
});
it('rejects an explicit model that violates the auth policy', async () => {
const switchModel = vi.fn();
const context = createMockCommandContext({
services: {
config: {
getContentGeneratorConfig: vi.fn().mockReturnValue(undefined),
switchModel,
},
settings: {
merged: {
security: { auth: { enforcedType: AuthType.QWEN_SUBSCRIPTION } },
},
},
},
});
expect(await modelCommand.action!(context, 'fallback(openai)')).toEqual({
type: 'message',
messageType: 'error',
content: 'Authentication is restricted by policy.',
});
expect(switchModel).not.toHaveBeenCalled();
});
it.each([AuthType.QWEN_SUBSCRIPTION, AuthType.USE_OPENAI])(
'reports invalid credentials for an explicitly selected subscription from %s',
async (currentAuthType) => {
@@ -182,7 +204,7 @@ describe('model picker independence from subscription authentication', () => {
expect(switchModel).toHaveBeenCalledWith(
AuthType.QWEN_SUBSCRIPTION,
'replacement',
undefined,
{ initializeIfNeeded: true },
);
expect(context.ui.addItem).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ text: 'Loading models from provider…' }),
@@ -276,7 +276,7 @@ describe('modelCommand', () => {
expect(switchModel).toHaveBeenCalledWith(
AuthType.USE_OPENAI,
'qwen3.6-plus',
undefined,
{ initializeIfNeeded: true },
);
},
);
@@ -296,6 +296,7 @@ describe('modelCommand', () => {
AuthType.QWEN_OAUTH,
'coder-model',
{
initializeIfNeeded: true,
requireCachedCredentials: true,
},
);
@@ -331,11 +332,9 @@ describe('modelCommand', () => {
const result = await modelCommand.action!(mockContext, 'qwen-max');
expect(switchModel).toHaveBeenCalledWith(
AuthType.QWEN_OAUTH,
'qwen-max',
undefined,
);
expect(switchModel).toHaveBeenCalledWith(AuthType.QWEN_OAUTH, 'qwen-max', {
initializeIfNeeded: true,
});
expect(setValue).toHaveBeenCalledWith(
expect.any(String),
'model.name',
@@ -436,11 +435,9 @@ describe('modelCommand', () => {
await modelCommand.action!(mockContext, 'qwen-max');
expect(switchModel).toHaveBeenCalledWith(
AuthType.QWEN_OAUTH,
'qwen-max',
undefined,
);
expect(switchModel).toHaveBeenCalledWith(AuthType.QWEN_OAUTH, 'qwen-max', {
initializeIfNeeded: true,
});
expect(setValue).not.toHaveBeenCalled();
});
@@ -770,11 +767,9 @@ describe('modelCommand', () => {
'Refresh failed',
);
expect(switchModel).toHaveBeenCalledWith(
AuthType.QWEN_OAUTH,
'qwen-max',
undefined,
);
expect(switchModel).toHaveBeenCalledWith(AuthType.QWEN_OAUTH, 'qwen-max', {
initializeIfNeeded: true,
});
expect(setValue).not.toHaveBeenCalled();
});
@@ -888,11 +883,9 @@ describe('modelCommand', () => {
`gpt-4(${AuthType.USE_OPENAI})`,
);
expect(switchModel).toHaveBeenCalledWith(
AuthType.USE_OPENAI,
'gpt-4',
undefined,
);
expect(switchModel).toHaveBeenCalledWith(AuthType.USE_OPENAI, 'gpt-4', {
initializeIfNeeded: true,
});
expect(setValue).toHaveBeenCalledWith(
expect.any(String),
'security.auth.selectedType',
@@ -2296,7 +2289,7 @@ describe('modelCommand', () => {
expect(switchModel).toHaveBeenCalledWith(
AuthType.USE_OPENAI,
'--fast-model',
undefined,
{ initializeIfNeeded: true },
);
expect(setValue).toHaveBeenCalledWith(
expect.any(String),
+15 -6
View File
@@ -38,6 +38,10 @@ import {
} from '../../utils/acpModelUtils.js';
import { formatAuxModelSelectorForDisplay } from '../../utils/aux-model-selector.js';
import { recordDaemonSessionModelFromConfig } from '../../acp-integration/session-model-persistence.js';
import {
modelAuthPolicyError,
switchPrimaryModel,
} from '../models/primary-model-switch.js';
import {
formatUnsupportedVoiceModelMessage,
isSelectableVoiceModel,
@@ -174,7 +178,9 @@ async function switchMainModel(
persistSelection = true,
): Promise<string> {
if (parsed.authType) {
await config.switchModel(
await switchPrimaryModel(
config,
settings,
parsed.authType,
parsed.modelId,
parsed.authType === AuthType.QWEN_OAUTH &&
@@ -201,7 +207,7 @@ async function switchMainModel(
return parsed.modelId;
}
await config.switchModel(currentAuthType, parsed.modelId, undefined);
await switchPrimaryModel(config, settings, currentAuthType, parsed.modelId);
if (persistSelection) {
persistSetting(settings, 'model.name', parsed.modelId, scopeOverride);
persistSetting(settings, 'model.baseUrl', '', scopeOverride);
@@ -1128,10 +1134,13 @@ export const modelCommand: SlashCommand = {
...persistScopeSpread(scopeOverride),
};
}
if (
modelName &&
(parsed.authType ?? authType) === AuthType.QWEN_SUBSCRIPTION
) {
const requestedAuthType = parsed.authType ?? authType;
const policyError =
modelName && modelAuthPolicyError(settings, requestedAuthType);
if (policyError) {
return { type: 'message', messageType: 'error', content: policyError };
}
if (modelName && requestedAuthType === AuthType.QWEN_SUBSCRIPTION) {
context.ui.addItem(
{ type: MessageType.INFO, text: t('Loading models from provider…') },
Date.now(),
@@ -243,7 +243,7 @@ describe('<ModelDialog />', () => {
expect(mockConfig.switchModel).toHaveBeenCalledWith(
AuthType.QWEN_SUBSCRIPTION,
model,
{ baseUrl },
{ baseUrl, initializeIfNeeded: true },
);
const feedback = vi
.mocked(mockHistoryManager.addItem)
@@ -964,6 +964,7 @@ describe('<ModelDialog />', () => {
expect(switchModel).toHaveBeenCalledWith(AuthType.USE_OPENAI, 'gpt-4', {
baseUrl: undefined,
initializeIfNeeded: true,
});
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
@@ -1027,6 +1028,7 @@ describe('<ModelDialog />', () => {
'qwen3.7-max',
{
baseUrl: 'https://idealab.example.com/v1',
initializeIfNeeded: true,
},
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
@@ -1180,7 +1182,7 @@ describe('<ModelDialog />', () => {
expect(switchModel).toHaveBeenCalledWith(
AuthType.USE_OPENAI,
'MiniMax-M3',
{ baseUrl: 'https://api.minimaxi.com/v1' },
{ baseUrl: 'https://api.minimaxi.com/v1', initializeIfNeeded: true },
);
} finally {
if (previousMinimaxKey === undefined) {
+14 -7
View File
@@ -33,6 +33,7 @@ import { ConfigContext } from '../contexts/ConfigContext.js';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import { switchPrimaryModel } from '../models/primary-model-switch.js';
import { t } from '../../i18n/index.js';
import { publicProviderBaseUrl } from '../../utils/acpModelUtils.js';
import {
@@ -1210,13 +1211,19 @@ export function ModelDialog({
selectionInFlightRef.current = true;
try {
await config.switchModel(selectedAuthType, modelId, {
...(selectedAuthType !== authType &&
selectedAuthType === AuthType.QWEN_OAUTH
? { requireCachedCredentials: true }
: {}),
baseUrl: selectedBaseUrl,
});
await switchPrimaryModel(
config,
settings,
selectedAuthType,
modelId,
{
...(selectedAuthType !== authType &&
selectedAuthType === AuthType.QWEN_OAUTH
? { requireCachedCredentials: true }
: {}),
baseUrl: selectedBaseUrl,
},
);
selectionCommittedRef.current = true;
} finally {
selectionInFlightRef.current = false;
@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '@qwen-code/qwen-code-core';
import type { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js';
import type { LoadedSettings } from '../../config/settings.js';
export function modelAuthPolicyError(
settings: LoadedSettings | undefined,
authType: AuthType | undefined,
): string | undefined {
const enforcedType = settings?.merged?.security?.auth?.enforcedType;
if (enforcedType && authType && enforcedType !== authType) {
return 'Authentication is restricted by policy.';
}
return undefined;
}
export async function switchPrimaryModel(
config: Config,
settings: LoadedSettings,
authType: AuthType,
modelId: string,
options?: Parameters<Config['switchModel']>[2],
): Promise<void> {
const policyError = modelAuthPolicyError(settings, authType);
if (policyError) throw new Error(policyError);
await config.switchModel(authType, modelId, {
...options,
initializeIfNeeded: true,
});
}
@@ -477,7 +477,10 @@ describe('applyModelSelection', () => {
expect(switchModel).toHaveBeenCalledWith(
AuthType.USE_OPENAI,
'm2',
expect.objectContaining({ baseUrl: 'https://provider.example' }),
expect.objectContaining({
baseUrl: 'https://provider.example',
initializeIfNeeded: true,
}),
);
expect(written).toContainEqual({
scope: SettingScope.User,
+2 -1
View File
@@ -65,6 +65,7 @@ import { SettingScope } from '../../config/settings.js';
import type { LoadedSettings } from '../../config/settings.js';
import { loadMcpApprovals } from '../../config/mcpApprovals.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import { switchPrimaryModel } from '../models/primary-model-switch.js';
import { t } from '../../i18n/index.js';
import { extensionComponentsSummary } from '../../services/extension-components-summary.js';
import { publicProviderBaseUrl } from '../../utils/acpModelUtils.js';
@@ -628,7 +629,7 @@ export async function applyModelSelection(
let after: ContentGeneratorConfig | undefined;
try {
await config.switchModel(selectedAuthType, modelId, {
await switchPrimaryModel(config, settings, selectedAuthType, modelId, {
...(selectedAuthType !== authType &&
selectedAuthType === AuthType.QWEN_OAUTH
? { requireCachedCredentials: true }
+30
View File
@@ -1213,6 +1213,36 @@ describe('Server Config (config.ts)', () => {
return config;
};
it('initializes an API model when switching from a signed-out subscription', async () => {
const config = makeConfig({
authType: AuthType.QWEN_SUBSCRIPTION,
model: 'qwen3.8-max',
modelProvidersConfig: {
openai: [
{
id: 'api-model',
generationConfig: { contextWindowSize: 64000 },
},
],
},
});
expect(
config.getContentGenerator({ allowUninitialized: true }),
).toBeUndefined();
await config.switchModel(AuthType.USE_OPENAI, 'api-model', {
initializeIfNeeded: true,
});
expect(
config.getContentGenerator({ allowUninitialized: true }),
).toBeDefined();
expect(config.getContentGeneratorConfig()).toMatchObject({
authType: AuthType.USE_OPENAI,
model: 'api-model',
contextWindowSize: 64000,
});
});
it('reuses the same account catalog while rebuilding model capabilities and reasoning', async () => {
const config = await subscriptionConfig();
await config.switchModel(AuthType.QWEN_SUBSCRIPTION, 'qwen3.8-flash');
+4 -2
View File
@@ -544,6 +544,7 @@ import {
type ResolvedModelConfig,
type RuntimeModelSnapshot,
} from '../models/index.js';
import type { ModelSwitchOptions } from '../models/modelsConfig.js';
import { resolveModelId } from '../utils/modelId.js';
import type { WebSearchSettings } from '../tools/web-search.js';
import type { ClaudeMarketplaceConfig } from '../extension/claude-converter.js';
@@ -7778,10 +7779,11 @@ export class Config {
private async handleModelChange(
authType: AuthType,
requiresRefresh: boolean,
initializeIfNeeded?: boolean,
): Promise<void> {
this.initialResolvedAuthType = undefined;
if (!this.contentGeneratorConfig) {
if (authType === AuthType.QWEN_SUBSCRIPTION)
if (initializeIfNeeded || authType === AuthType.QWEN_SUBSCRIPTION)
await this.refreshAuth(authType);
return;
}
@@ -7981,7 +7983,7 @@ export class Config {
async switchModel(
authType: AuthType,
modelId: string,
options?: { requireCachedCredentials?: boolean; baseUrl?: string },
options?: ModelSwitchOptions,
): Promise<void> {
await this.modelsConfig.switchModel(authType, modelId, options);
this.notifyModelChangeListeners();
+17
View File
@@ -30,6 +30,8 @@ import type {
} from '@google/genai';
import { ApiError } from '@google/genai';
import { AuthType, type ContentGenerator } from './contentGenerator.js';
import { QwenSubscriptionSession } from '../qwen/subscription-session.js';
import { SUBSCRIPTION_SIGN_IN_REQUIRED_MESSAGE } from '../qwen/subscription-errors.js';
import { getPlanModeSystemReminder } from './prompts.js';
import {
LlmChat,
@@ -1463,6 +1465,21 @@ describe('LlmChat', async () => {
const sendAny = (message: Message, promptId: string, target = chat) =>
target.sendMessageStream('test-model', { message }, promptId);
it('reports signed-out subscription before processing the request', async () => {
mockConfig.getCurrentAuthType = vi
.fn()
.mockReturnValue(AuthType.QWEN_SUBSCRIPTION);
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue(undefined);
vi.spyOn(QwenSubscriptionSession.prototype, 'isSignedIn').mockReturnValue(
false,
);
await expect(sendAny('hello', 'signed-out')).rejects.toThrow(
SUBSCRIPTION_SIGN_IN_REQUIRED_MESSAGE,
);
expect(mockContentGenerator.generateContentStream).not.toHaveBeenCalled();
});
/** A user prompt followed by the model's (unanswered) call. */
const afterCall = (
prompt: string,
+11
View File
@@ -13,6 +13,9 @@ import {
import { logToolResultSize } from '../telemetry/loggers.js';
import { ToolResultSizeEvent } from '../telemetry/types.js';
import { createHash } from 'node:crypto';
import { AuthType } from '../utils/auth-type.js';
import { QwenSubscriptionSession } from '../qwen/subscription-session.js';
import { SUBSCRIPTION_SIGN_IN_REQUIRED_MESSAGE } from '../qwen/subscription-errors.js';
// DISCLAIMER: This is a copied version of https://github.com/googleapis/js-genai/blob/main/src/chats.ts with the intention of working around a key bug
// where function responses are not treated as "valid" responses: https://b.corp.google.com/issues/420354090
@@ -3402,6 +3405,14 @@ export class LlmChat {
if (exactRoute) {
model = exactRoute.model;
}
if (
!exactRoute &&
this.config.getCurrentAuthType?.() === AuthType.QWEN_SUBSCRIPTION &&
!this.config.getContentGeneratorConfig() &&
!new QwenSubscriptionSession().isSignedIn()
) {
throw new Error(SUBSCRIPTION_SIGN_IN_REQUIRED_MESSAGE);
}
// Both arms are one call: for a non-exact send `exactRoute` is
// undefined, and `resolvedModelIdentity`'s second parameter defaults to
// `getContentGeneratorConfig()` — including when passed an explicit
@@ -374,6 +374,7 @@ describe('model API selection', () => {
expect(onModelChange).toHaveBeenLastCalledWith(
AuthType.USE_OPENAI_RESPONSES,
true,
undefined,
);
await config.switchModel(AuthType.USE_OPENAI, 'shared', { baseUrl });
expect(config.getGenerationConfig().apiKey).toBe('injected-key');
@@ -2206,7 +2206,11 @@ describe('Qwen Subscription model selection', () => {
extra_body: undefined,
modalities: {},
});
expect(changed).toHaveBeenCalledWith(AuthType.QWEN_SUBSCRIPTION, true);
expect(changed).toHaveBeenCalledWith(
AuthType.QWEN_SUBSCRIPTION,
true,
undefined,
);
});
it('restores the subscription selection with a placeholder token', () => {
+22 -4
View File
@@ -54,8 +54,15 @@ const debugLogger = createDebugLogger('ModelsConfig');
export type OnModelChangeCallback = (
authType: AuthType,
requiresRefresh: boolean,
initializeIfNeeded?: boolean,
) => Promise<void>;
export interface ModelSwitchOptions {
requireCachedCredentials?: boolean;
baseUrl?: string;
initializeIfNeeded?: boolean;
}
/**
* Options for creating ModelsConfig
*/
@@ -533,12 +540,15 @@ export class ModelsConfig {
async switchModel(
authType: AuthType,
modelId: string,
options?: { requireCachedCredentials?: boolean; baseUrl?: string },
options?: ModelSwitchOptions,
): Promise<void> {
// Check if this is a RuntimeModelSnapshot reference
const runtimeModelSnapshotId = this.extractRuntimeModelSnapshotId(modelId);
if (runtimeModelSnapshotId) {
await this.switchToRuntimeModel(runtimeModelSnapshotId);
await this.switchToRuntimeModel(
runtimeModelSnapshotId,
options?.initializeIfNeeded,
);
return;
}
@@ -625,7 +635,11 @@ export class ModelsConfig {
: this.checkRequiresRefresh(previousModelId);
if (this.onModelChange) {
await this.onModelChange(authType, requiresRefresh);
await this.onModelChange(
authType,
requiresRefresh,
options?.initializeIfNeeded,
);
}
} catch (error) {
// Rollback on error
@@ -1375,7 +1389,10 @@ export class ModelsConfig {
*
* @param snapshotId - The ID of the RuntimeModelSnapshot to switch to
*/
async switchToRuntimeModel(snapshotId: string): Promise<void> {
async switchToRuntimeModel(
snapshotId: string,
initializeIfNeeded?: boolean,
): Promise<void> {
const runtimeModelSnapshot = this.runtimeModelSnapshots.get(snapshotId);
if (!runtimeModelSnapshot) {
throw new Error(`Runtime model snapshot '${snapshotId}' not found`);
@@ -1438,6 +1455,7 @@ export class ModelsConfig {
await this.onModelChange(
runtimeModelSnapshot.authType,
requiresRefresh,
initializeIfNeeded,
);
}
} catch (error) {
@@ -16,6 +16,9 @@ import {
const debugLogger = createDebugLogger('QWEN_SUBSCRIPTION');
export const SUBSCRIPTION_SIGN_IN_REQUIRED_MESSAGE =
'Your login credentials are no longer valid. Run /auth to sign in again.';
export function isSubscriptionSessionExpired(
error: unknown,
): error is SubscriptionOAuthError {
@@ -103,7 +106,7 @@ export function subscriptionErrorMessage(
error.code === 'invalid_token' ||
error.status === 401
)
return 'Your login credentials are no longer valid. Run /auth to sign in again.';
return SUBSCRIPTION_SIGN_IN_REQUIRED_MESSAGE;
if (error.status === 429) return 'Too many requests. Please try again later.';
if (error.stage === 'credentials' || error.stage === 'cleanup')
return 'Could not read or save Qwen credentials. Check local file permissions and disk space.';