diff --git a/packages/cli/src/ui/commands/model-selection-auth.repro.test.ts b/packages/cli/src/ui/commands/model-selection-auth.repro.test.ts index 5c4a6e7b939..66b64faa2de 100644 --- a/packages/cli/src/ui/commands/model-selection-auth.repro.test.ts +++ b/packages/cli/src/ui/commands/model-selection-auth.repro.test.ts @@ -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…' }), diff --git a/packages/cli/src/ui/commands/modelCommand.test.ts b/packages/cli/src/ui/commands/modelCommand.test.ts index 7900eadce6f..55771035c58 100644 --- a/packages/cli/src/ui/commands/modelCommand.test.ts +++ b/packages/cli/src/ui/commands/modelCommand.test.ts @@ -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), diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index 807519518c7..0a143820159 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -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 { 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(), diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index 0de6421ca4c..1ee7cec03bd 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -243,7 +243,7 @@ describe('', () => { expect(mockConfig.switchModel).toHaveBeenCalledWith( AuthType.QWEN_SUBSCRIPTION, model, - { baseUrl }, + { baseUrl, initializeIfNeeded: true }, ); const feedback = vi .mocked(mockHistoryManager.addItem) @@ -964,6 +964,7 @@ describe('', () => { expect(switchModel).toHaveBeenCalledWith(AuthType.USE_OPENAI, 'gpt-4', { baseUrl: undefined, + initializeIfNeeded: true, }); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, @@ -1027,6 +1028,7 @@ describe('', () => { 'qwen3.7-max', { baseUrl: 'https://idealab.example.com/v1', + initializeIfNeeded: true, }, ); expect(mockSettings.setValue).toHaveBeenCalledWith( @@ -1180,7 +1182,7 @@ describe('', () => { 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) { diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index d190d1c8881..c02f1e6b3f9 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -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; diff --git a/packages/cli/src/ui/models/primary-model-switch.ts b/packages/cli/src/ui/models/primary-model-switch.ts new file mode 100644 index 00000000000..845e0966824 --- /dev/null +++ b/packages/cli/src/ui/models/primary-model-switch.ts @@ -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[2], +): Promise { + const policyError = modelAuthPolicyError(settings, authType); + if (policyError) throw new Error(policyError); + await config.switchModel(authType, modelId, { + ...options, + initializeIfNeeded: true, + }); +} diff --git a/packages/cli/src/ui/opentui/dialog-data.test.ts b/packages/cli/src/ui/opentui/dialog-data.test.ts index 80b0a6085d5..7da6927b821 100644 --- a/packages/cli/src/ui/opentui/dialog-data.test.ts +++ b/packages/cli/src/ui/opentui/dialog-data.test.ts @@ -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, diff --git a/packages/cli/src/ui/opentui/dialog-data.ts b/packages/cli/src/ui/opentui/dialog-data.ts index 32a5db2fecb..49162ff72b2 100644 --- a/packages/cli/src/ui/opentui/dialog-data.ts +++ b/packages/cli/src/ui/opentui/dialog-data.ts @@ -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 } diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 5dc165edb1e..fff2586f407 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -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'); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 1beaa69cbe9..f2d36cb7d66 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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 { 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 { await this.modelsConfig.switchModel(authType, modelId, options); this.notifyModelChangeListeners(); diff --git a/packages/core/src/core/llm-chat.test.ts b/packages/core/src/core/llm-chat.test.ts index 634c12076c3..2a3f1c47a4c 100644 --- a/packages/core/src/core/llm-chat.test.ts +++ b/packages/core/src/core/llm-chat.test.ts @@ -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, diff --git a/packages/core/src/core/llm-chat.ts b/packages/core/src/core/llm-chat.ts index 927d6f393c0..f7875618db9 100644 --- a/packages/core/src/core/llm-chat.ts +++ b/packages/core/src/core/llm-chat.ts @@ -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 diff --git a/packages/core/src/models/model-api-selection.test.ts b/packages/core/src/models/model-api-selection.test.ts index c7a5f5a7179..e3a5168bce9 100644 --- a/packages/core/src/models/model-api-selection.test.ts +++ b/packages/core/src/models/model-api-selection.test.ts @@ -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'); diff --git a/packages/core/src/models/modelsConfig.test.ts b/packages/core/src/models/modelsConfig.test.ts index 94bf5539e54..ca620507f81 100644 --- a/packages/core/src/models/modelsConfig.test.ts +++ b/packages/core/src/models/modelsConfig.test.ts @@ -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', () => { diff --git a/packages/core/src/models/modelsConfig.ts b/packages/core/src/models/modelsConfig.ts index 7b0bbb0c689..b546615b867 100644 --- a/packages/core/src/models/modelsConfig.ts +++ b/packages/core/src/models/modelsConfig.ts @@ -54,8 +54,15 @@ const debugLogger = createDebugLogger('ModelsConfig'); export type OnModelChangeCallback = ( authType: AuthType, requiresRefresh: boolean, + initializeIfNeeded?: boolean, ) => Promise; +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 { // 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 { + async switchToRuntimeModel( + snapshotId: string, + initializeIfNeeded?: boolean, + ): Promise { 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) { diff --git a/packages/core/src/qwen/subscription-errors.ts b/packages/core/src/qwen/subscription-errors.ts index 0f61b93cfe8..461a875861a 100644 --- a/packages/core/src/qwen/subscription-errors.ts +++ b/packages/core/src/qwen/subscription-errors.ts @@ -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.';