diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts
index a45884e04..711bf3e8e 100644
--- a/packages/cli/src/config/settingsSchema.ts
+++ b/packages/cli/src/config/settingsSchema.ts
@@ -116,6 +116,29 @@ const SETTINGS_SCHEMA = {
mergeStrategy: MergeStrategy.REPLACE,
},
+ // Coding Plan configuration
+ codingPlan: {
+ type: 'object',
+ label: 'Coding Plan',
+ category: 'Model',
+ requiresRestart: false,
+ default: {},
+ description: 'Coding Plan template version tracking and configuration.',
+ showInDialog: false,
+ properties: {
+ version: {
+ type: 'string',
+ label: 'Coding Plan Template Version',
+ category: 'Model',
+ requiresRestart: false,
+ default: undefined as string | undefined,
+ description:
+ 'SHA256 hash of the Coding Plan template. Used to detect template updates.',
+ showInDialog: false,
+ },
+ },
+ },
+
// Environment variables fallback
env: {
type: 'object',
diff --git a/packages/cli/src/constants/codingPlanTemplates.ts b/packages/cli/src/constants/codingPlan.ts
similarity index 56%
rename from packages/cli/src/constants/codingPlanTemplates.ts
rename to packages/cli/src/constants/codingPlan.ts
index 8cedab91f..e55aeb93d 100644
--- a/packages/cli/src/constants/codingPlanTemplates.ts
+++ b/packages/cli/src/constants/codingPlan.ts
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
+import { createHash } from 'node:crypto';
import type { ProviderModelConfig as ModelConfig } from '@qwen-code/qwen-code-core';
/**
@@ -18,9 +19,9 @@ export type CodingPlanTemplate = ModelConfig[];
export const CODING_PLAN_ENV_KEY = 'BAILIAN_CODING_PLAN_API_KEY';
/**
- * CODING_PLAN_TEMPLATE defines the model configurations for coding-plan mode.
+ * CODING_PLAN_MODELS defines the model configurations for coding-plan mode.
*/
-export const CODING_PLAN_TEMPLATE: CodingPlanTemplate = [
+export const CODING_PLAN_MODELS: CodingPlanTemplate = [
{
id: 'qwen3-coder-plus',
name: 'qwen3-coder-plus',
@@ -32,7 +33,7 @@ export const CODING_PLAN_TEMPLATE: CodingPlanTemplate = [
id: 'qwen3-max-2026-01-23',
name: 'qwen3-max-2026-01-23',
description:
- 'qwen3 max model from Bailian Coding Plan with thinking enabled',
+ 'qwen3-max model with thinking enabled from Bailian Coding Plan',
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {
@@ -42,3 +43,19 @@ export const CODING_PLAN_TEMPLATE: CodingPlanTemplate = [
},
},
];
+
+/**
+ * Computes the version hash for the coding plan template.
+ * Uses SHA256 of the JSON-serialized template for deterministic versioning.
+ * @returns Hexadecimal string representing the template version
+ */
+export function computeCodingPlanVersion(): string {
+ const templateString = JSON.stringify(CODING_PLAN_MODELS);
+ return createHash('sha256').update(templateString).digest('hex');
+}
+
+/**
+ * Current version of the coding plan template.
+ * Computed at runtime from the template content.
+ */
+export const CODING_PLAN_VERSION = computeCodingPlanVersion();
diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js
index ce2594d41..063af8715 100644
--- a/packages/cli/src/i18n/locales/de.js
+++ b/packages/cli/src/i18n/locales/de.js
@@ -1395,6 +1395,18 @@ export default {
'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.':
'Unterstützte Authentifizierungstypen: openai, anthropic, gemini, vertex-ai, usw.',
+ // ============================================================================
+ // Coding Plan Authentication
+ // ============================================================================
+ 'New model configurations are available for Bailian Coding Plan. Update now?':
+ 'Neue Modellkonfigurationen sind für Bailian Coding Plan verfügbar. Jetzt aktualisieren?',
+ 'Coding Plan configuration updated successfully. New models are now available.':
+ 'Coding Plan-Konfiguration erfolgreich aktualisiert. Neue Modelle sind jetzt verfügbar.',
+ 'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
+ 'Coding Plan API-Schlüssel nicht gefunden. Bitte authentifizieren Sie sich erneut mit Coding Plan.',
+ 'Failed to update Coding Plan configuration: {{message}}':
+ 'Fehler beim Aktualisieren der Coding Plan-Konfiguration: {{message}}',
+
// ============================================================================
// Auth Dialog - View Titles and Labels
// ============================================================================
diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js
index bfb70e6cb..ab03a9512 100644
--- a/packages/cli/src/i18n/locales/en.js
+++ b/packages/cli/src/i18n/locales/en.js
@@ -1375,6 +1375,14 @@ export default {
'API key cannot be empty.': 'API key cannot be empty.',
'API key is stored in settings.env. You can migrate it to a .env file for better security.':
'API key is stored in settings.env. You can migrate it to a .env file for better security.',
+ 'New model configurations are available for Bailian Coding Plan. Update now?':
+ 'New model configurations are available for Bailian Coding Plan. Update now?',
+ 'Coding Plan configuration updated successfully. New models are now available.':
+ 'Coding Plan configuration updated successfully. New models are now available.',
+ 'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
+ 'Coding Plan API key not found. Please re-authenticate with Coding Plan.',
+ 'Failed to update Coding Plan configuration: {{message}}':
+ 'Failed to update Coding Plan configuration: {{message}}',
// ============================================================================
// Custom API-KEY Configuration
diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js
index 764bf7313..348aabc88 100644
--- a/packages/cli/src/i18n/locales/ja.js
+++ b/packages/cli/src/i18n/locales/ja.js
@@ -906,6 +906,18 @@ export default {
'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.':
'サポートされている認証タイプ:openai、anthropic、gemini、vertex-ai など',
+ // ============================================================================
+ // Coding Plan Authentication
+ // ============================================================================
+ 'New model configurations are available for Bailian Coding Plan. Update now?':
+ 'Bailian Coding Plan の新しいモデル設定が利用可能です。今すぐ更新しますか?',
+ 'Coding Plan configuration updated successfully. New models are now available.':
+ 'Coding Plan の設定が正常に更新されました。新しいモデルが利用可能になりました。',
+ 'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
+ 'Coding Plan の API キーが見つかりません。Coding Plan で再認証してください。',
+ 'Failed to update Coding Plan configuration: {{message}}':
+ 'Coding Plan の設定更新に失敗しました: {{message}}',
+
// ============================================================================
// Auth Dialog - View Titles and Labels
// ============================================================================
diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js
index b6ad6d5fd..fb8aff23d 100644
--- a/packages/cli/src/i18n/locales/pt.js
+++ b/packages/cli/src/i18n/locales/pt.js
@@ -1409,6 +1409,18 @@ export default {
'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.':
'Tipos de autenticação suportados: openai, anthropic, gemini, vertex-ai, etc.',
+ // ============================================================================
+ // Coding Plan Authentication
+ // ============================================================================
+ 'New model configurations are available for Bailian Coding Plan. Update now?':
+ 'Novas configurações de modelo estão disponíveis para o Bailian Coding Plan. Atualizar agora?',
+ 'Coding Plan configuration updated successfully. New models are now available.':
+ 'Configuração do Coding Plan atualizada com sucesso. Novos modelos agora estão disponíveis.',
+ 'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
+ 'Chave de API do Coding Plan não encontrada. Por favor, re-autentique com o Coding Plan.',
+ 'Failed to update Coding Plan configuration: {{message}}':
+ 'Falha ao atualizar a configuração do Coding Plan: {{message}}',
+
// ============================================================================
// Auth Dialog - View Titles and Labels
// ============================================================================
diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js
index ab5a9fa89..70c428197 100644
--- a/packages/cli/src/i18n/locales/ru.js
+++ b/packages/cli/src/i18n/locales/ru.js
@@ -1399,6 +1399,18 @@ export default {
'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.':
'Поддерживаемые типы аутентификации: openai, anthropic, gemini, vertex-ai и др.',
+ // ============================================================================
+ // Coding Plan Authentication
+ // ============================================================================
+ 'New model configurations are available for Bailian Coding Plan. Update now?':
+ 'Доступны новые конфигурации моделей для Bailian Coding Plan. Обновить сейчас?',
+ 'Coding Plan configuration updated successfully. New models are now available.':
+ 'Конфигурация Coding Plan успешно обновлена. Новые модели теперь доступны.',
+ 'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
+ 'API-ключ Coding Plan не найден. Пожалуйста, повторно авторизуйтесь с Coding Plan.',
+ 'Failed to update Coding Plan configuration: {{message}}':
+ 'Не удалось обновить конфигурацию Coding Plan: {{message}}',
+
// ============================================================================
// Auth Dialog - View Titles and Labels
// ============================================================================
diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js
index 7399a08f2..cb7b7a7f6 100644
--- a/packages/cli/src/i18n/locales/zh.js
+++ b/packages/cli/src/i18n/locales/zh.js
@@ -1210,6 +1210,14 @@ export default {
'API key cannot be empty.': 'API Key 不能为空。',
'API key is stored in settings.env. You can migrate it to a .env file for better security.':
'API Key 已存储在 settings.env 中。您可以将其迁移到 .env 文件以获得更好的安全性。',
+ 'New model configurations are available for Bailian Coding Plan. Update now?':
+ '百炼 Coding Plan 有新模型配置可用。是否立即更新?',
+ 'Coding Plan configuration updated successfully. New models are now available.':
+ 'Coding Plan 配置更新成功。新模型现已可用。',
+ 'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
+ '未找到 Coding Plan API Key。请重新通过 Coding Plan 认证。',
+ 'Failed to update Coding Plan configuration: {{message}}':
+ '更新 Coding Plan 配置失败:{{message}}',
// ============================================================================
// Custom API-KEY Configuration
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index 7ac34def2..9c546004c 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -94,6 +94,7 @@ import {
useSettingInputRequests,
usePluginChoiceRequests,
} from './hooks/useExtensionUpdates.js';
+import { useCodingPlanUpdates } from './hooks/useCodingPlanUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { t } from '../i18n/index.js';
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
@@ -232,6 +233,9 @@ export const AppContainer = (props: AppContainerProps) => {
config.getWorkingDir(),
);
+ const { codingPlanUpdateRequest, dismissCodingPlanUpdate } =
+ useCodingPlanUpdates(settings, config, historyManager.addItem);
+
const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false);
const openPermissionsDialog = useCallback(
() => setPermissionsDialogOpen(true),
@@ -1276,6 +1280,7 @@ export const AppContainer = (props: AppContainerProps) => {
!!shellConfirmationRequest ||
!!confirmationRequest ||
confirmUpdateExtensionRequests.length > 0 ||
+ !!codingPlanUpdateRequest ||
settingInputRequests.length > 0 ||
pluginChoiceRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
@@ -1340,6 +1345,7 @@ export const AppContainer = (props: AppContainerProps) => {
shellConfirmationRequest,
confirmationRequest,
confirmUpdateExtensionRequests,
+ codingPlanUpdateRequest,
settingInputRequests,
pluginChoiceRequests,
loopDetectionConfirmationRequest,
@@ -1430,6 +1436,7 @@ export const AppContainer = (props: AppContainerProps) => {
shellConfirmationRequest,
confirmationRequest,
confirmUpdateExtensionRequests,
+ codingPlanUpdateRequest,
settingInputRequests,
pluginChoiceRequests,
loopDetectionConfirmationRequest,
@@ -1514,6 +1521,7 @@ export const AppContainer = (props: AppContainerProps) => {
exitEditorDialog,
closeSettingsDialog,
closeModelDialog,
+ dismissCodingPlanUpdate,
closePermissionsDialog,
setShellModeActive,
vimHandleInput,
@@ -1559,6 +1567,7 @@ export const AppContainer = (props: AppContainerProps) => {
exitEditorDialog,
closeSettingsDialog,
closeModelDialog,
+ dismissCodingPlanUpdate,
closePermissionsDialog,
setShellModeActive,
vimHandleInput,
diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts
index a327152c2..0ea157af5 100644
--- a/packages/cli/src/ui/auth/useAuth.ts
+++ b/packages/cli/src/ui/auth/useAuth.ts
@@ -30,9 +30,10 @@ import { AuthState, MessageType } from '../types.js';
import type { HistoryItem } from '../types.js';
import { t } from '../../i18n/index.js';
import {
- CODING_PLAN_TEMPLATE,
+ CODING_PLAN_MODELS,
CODING_PLAN_ENV_KEY,
-} from '../../constants/codingPlanTemplates.js';
+ CODING_PLAN_VERSION,
+} from '../../constants/codingPlan.js';
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
@@ -303,7 +304,7 @@ export const useAuthCommand = (
process.env[envKeyName] = apiKey;
// Generate model configs from template
- const newConfigs: ProviderModelConfig[] = CODING_PLAN_TEMPLATE.map(
+ const newConfigs: ProviderModelConfig[] = CODING_PLAN_MODELS.map(
(templateConfig) => ({
...templateConfig,
envKey: envKeyName,
@@ -316,22 +317,21 @@ export const useAuthCommand = (
settings.merged.modelProviders as ModelProvidersConfig | undefined
)?.[AuthType.USE_OPENAI] || [];
- // Deduplicate: check if config with same id, baseUrl, and envKey exists
- const isDuplicate = (config: ProviderModelConfig) =>
- existingConfigs.some(
- (existing) =>
- existing.id === config.id &&
- existing.baseUrl === config.baseUrl &&
- existing.envKey === config.envKey,
+ // Identify Coding Plan configs by baseUrl + envKey
+ // Remove existing Coding Plan configs to ensure template changes are applied
+ const isCodingPlanConfig = (config: ProviderModelConfig) =>
+ config.envKey === envKeyName &&
+ CODING_PLAN_MODELS.some(
+ (template) => template.baseUrl === config.baseUrl,
);
- // Filter out duplicates and replace existing ones
- const uniqueNewConfigs = newConfigs.filter(
- (config) => !isDuplicate(config),
+ // Filter out existing Coding Plan configs, keep user custom configs
+ const nonCodingPlanConfigs = existingConfigs.filter(
+ (existing) => !isCodingPlanConfig(existing),
);
- // Unshift new configs to the beginning
- const updatedConfigs = [...uniqueNewConfigs, ...existingConfigs];
+ // Add new Coding Plan configs at the beginning
+ const updatedConfigs = [...newConfigs, ...nonCodingPlanConfigs];
// Persist to modelProviders
settings.setValue(
@@ -347,6 +347,13 @@ export const useAuthCommand = (
AuthType.USE_OPENAI,
);
+ // Persist coding plan version for future update detection
+ settings.setValue(
+ persistScope,
+ 'codingPlan.version',
+ CODING_PLAN_VERSION,
+ );
+
// If there are configs, use the first one as the model
if (updatedConfigs.length > 0 && updatedConfigs[0]?.id) {
settings.setValue(persistScope, 'model.name', updatedConfigs[0].id);
diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx
index b73ab1287..dbb6f2207 100644
--- a/packages/cli/src/ui/components/DialogManager.tsx
+++ b/packages/cli/src/ui/components/DialogManager.tsx
@@ -122,6 +122,15 @@ export const DialogManager = ({
/>
);
}
+ if (uiState.codingPlanUpdateRequest) {
+ return (
+
+ );
+ }
if (uiState.settingInputRequests.length > 0) {
const request = uiState.settingInputRequests[0];
// Use settingName as key to force re-mount when switching between different settings
diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx
index f93ee84eb..ed339e6fa 100644
--- a/packages/cli/src/ui/contexts/UIActionsContext.tsx
+++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx
@@ -51,6 +51,7 @@ export interface UIActions {
exitEditorDialog: () => void;
closeSettingsDialog: () => void;
closeModelDialog: () => void;
+ dismissCodingPlanUpdate: () => void;
closePermissionsDialog: () => void;
setShellModeActive: (value: boolean) => void;
vimHandleInput: (key: Key) => boolean;
diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx
index 6a48d3eca..f8d52faa1 100644
--- a/packages/cli/src/ui/contexts/UIStateContext.tsx
+++ b/packages/cli/src/ui/contexts/UIStateContext.tsx
@@ -32,6 +32,7 @@ import type { UpdateObject } from '../utils/updateCheck.js';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
+import { type CodingPlanUpdateRequest } from '../hooks/useCodingPlanUpdates.js';
export interface UIState {
history: HistoryItem[];
@@ -60,6 +61,7 @@ export interface UIState {
shellConfirmationRequest: ShellConfirmationRequest | null;
confirmationRequest: ConfirmationRequest | null;
confirmUpdateExtensionRequests: ConfirmationRequest[];
+ codingPlanUpdateRequest: CodingPlanUpdateRequest | undefined;
settingInputRequests: SettingInputRequest[];
pluginChoiceRequests: PluginChoiceRequest[];
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts
new file mode 100644
index 000000000..a004fbdcb
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts
@@ -0,0 +1,288 @@
+/**
+ * @license
+ * Copyright 2025 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { useCodingPlanUpdates } from './useCodingPlanUpdates.js';
+import { CODING_PLAN_ENV_KEY } from '../../constants/codingPlan.js';
+import { AuthType } from '@qwen-code/qwen-code-core';
+
+// Mock the constants module
+vi.mock('../../constants/codingPlan.js', async () => {
+ const actual = await vi.importActual('../../constants/codingPlan.js');
+ return {
+ ...actual,
+ CODING_PLAN_VERSION: 'test-version-hash',
+ CODING_PLAN_MODELS: [
+ {
+ id: 'test-model-1',
+ name: 'Test Model 1',
+ baseUrl: 'https://test.example.com/v1',
+ description: 'Test model 1',
+ envKey: 'BAILIAN_CODING_PLAN_API_KEY',
+ },
+ {
+ id: 'test-model-2',
+ name: 'Test Model 2',
+ baseUrl: 'https://test.example.com/v1',
+ description: 'Test model 2',
+ envKey: 'BAILIAN_CODING_PLAN_API_KEY',
+ },
+ ],
+ };
+});
+
+describe('useCodingPlanUpdates', () => {
+ const mockSettings = {
+ merged: {
+ modelProviders: {},
+ codingPlan: {},
+ },
+ setValue: vi.fn(),
+ isTrusted: true,
+ workspace: { settings: {} },
+ user: { settings: {} },
+ };
+
+ const mockConfig = {
+ reloadModelProvidersConfig: vi.fn(),
+ refreshAuth: vi.fn(),
+ };
+
+ const mockAddItem = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ delete process.env[CODING_PLAN_ENV_KEY];
+ });
+
+ describe('version comparison', () => {
+ it('should not show update prompt when no version is stored', () => {
+ mockSettings.merged.codingPlan = {};
+
+ const { result } = renderHook(() =>
+ useCodingPlanUpdates(
+ mockSettings as never,
+ mockConfig as never,
+ mockAddItem,
+ ),
+ );
+
+ expect(result.current.codingPlanUpdateRequest).toBeUndefined();
+ });
+
+ it('should not show update prompt when versions match', () => {
+ mockSettings.merged.codingPlan = { version: 'test-version-hash' };
+
+ const { result } = renderHook(() =>
+ useCodingPlanUpdates(
+ mockSettings as never,
+ mockConfig as never,
+ mockAddItem,
+ ),
+ );
+
+ expect(result.current.codingPlanUpdateRequest).toBeUndefined();
+ });
+
+ it('should show update prompt when versions differ', async () => {
+ mockSettings.merged.codingPlan = { version: 'old-version-hash' };
+
+ const { result } = renderHook(() =>
+ useCodingPlanUpdates(
+ mockSettings as never,
+ mockConfig as never,
+ mockAddItem,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(result.current.codingPlanUpdateRequest).toBeDefined();
+ });
+
+ expect(result.current.codingPlanUpdateRequest?.prompt).toContain(
+ 'New model configurations',
+ );
+ });
+ });
+
+ describe('update execution', () => {
+ it('should execute update when user confirms', async () => {
+ process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
+ mockSettings.merged.codingPlan = { version: 'old-version-hash' };
+ mockSettings.merged.modelProviders = {
+ [AuthType.USE_OPENAI]: [
+ {
+ id: 'test-model-1',
+ baseUrl: 'https://test.example.com/v1',
+ envKey: CODING_PLAN_ENV_KEY,
+ },
+ {
+ id: 'custom-model',
+ baseUrl: 'https://custom.example.com',
+ envKey: 'CUSTOM_API_KEY',
+ },
+ ],
+ };
+ mockConfig.refreshAuth.mockResolvedValue(undefined);
+
+ const { result } = renderHook(() =>
+ useCodingPlanUpdates(
+ mockSettings as never,
+ mockConfig as never,
+ mockAddItem,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(result.current.codingPlanUpdateRequest).toBeDefined();
+ });
+
+ // Confirm the update
+ await result.current.codingPlanUpdateRequest!.onConfirm(true);
+
+ // Wait for async update to complete
+ await waitFor(() => {
+ // Should update model providers (at least 2 calls: modelProviders + version)
+ expect(mockSettings.setValue).toHaveBeenCalled();
+ });
+
+ // Should update version
+ expect(mockSettings.setValue).toHaveBeenCalledWith(
+ expect.anything(),
+ 'codingPlan.version',
+ 'test-version-hash',
+ );
+
+ // Should reload and refresh auth
+ expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled();
+ expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI);
+
+ // Should show success message
+ expect(mockAddItem).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'info',
+ text: expect.stringContaining('updated successfully'),
+ }),
+ expect.any(Number),
+ );
+ });
+
+ it('should not execute update when user declines', async () => {
+ mockSettings.merged.codingPlan = { version: 'old-version-hash' };
+
+ const { result } = renderHook(() =>
+ useCodingPlanUpdates(
+ mockSettings as never,
+ mockConfig as never,
+ mockAddItem,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(result.current.codingPlanUpdateRequest).toBeDefined();
+ });
+
+ // Decline the update
+ await result.current.codingPlanUpdateRequest!.onConfirm(false);
+
+ // Should not update anything
+ expect(mockSettings.setValue).not.toHaveBeenCalled();
+ expect(mockConfig.reloadModelProvidersConfig).not.toHaveBeenCalled();
+ });
+
+ it('should preserve non-Coding Plan configs during update', async () => {
+ process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
+ mockSettings.merged.codingPlan = { version: 'old-version-hash' };
+ const customConfig = {
+ id: 'custom-model',
+ baseUrl: 'https://custom.example.com',
+ envKey: 'CUSTOM_API_KEY',
+ };
+ mockSettings.merged.modelProviders = {
+ [AuthType.USE_OPENAI]: [
+ {
+ id: 'test-model-1',
+ baseUrl: 'https://test.example.com/v1',
+ envKey: CODING_PLAN_ENV_KEY,
+ },
+ customConfig,
+ ],
+ };
+ mockConfig.refreshAuth.mockResolvedValue(undefined);
+
+ const { result } = renderHook(() =>
+ useCodingPlanUpdates(
+ mockSettings as never,
+ mockConfig as never,
+ mockAddItem,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(result.current.codingPlanUpdateRequest).toBeDefined();
+ });
+
+ await result.current.codingPlanUpdateRequest!.onConfirm(true);
+
+ // Wait for async update to complete
+ await waitFor(() => {
+ // Should preserve custom config - verify setValue was called
+ expect(mockSettings.setValue).toHaveBeenCalled();
+ });
+ });
+
+ it('should handle missing API key error', async () => {
+ mockSettings.merged.codingPlan = { version: 'old-version-hash' };
+
+ const { result } = renderHook(() =>
+ useCodingPlanUpdates(
+ mockSettings as never,
+ mockConfig as never,
+ mockAddItem,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(result.current.codingPlanUpdateRequest).toBeDefined();
+ });
+
+ await result.current.codingPlanUpdateRequest!.onConfirm(true);
+
+ // Should show error message
+ expect(mockAddItem).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'error',
+ }),
+ expect.any(Number),
+ );
+ });
+ });
+
+ describe('dismissUpdate', () => {
+ it('should clear update request when dismissed', async () => {
+ mockSettings.merged.codingPlan = { version: 'old-version-hash' };
+
+ const { result } = renderHook(() =>
+ useCodingPlanUpdates(
+ mockSettings as never,
+ mockConfig as never,
+ mockAddItem,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(result.current.codingPlanUpdateRequest).toBeDefined();
+ });
+
+ result.current.dismissCodingPlanUpdate();
+
+ await waitFor(() => {
+ expect(result.current.codingPlanUpdateRequest).toBeUndefined();
+ });
+ });
+ });
+});
diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts
new file mode 100644
index 000000000..85584def8
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts
@@ -0,0 +1,201 @@
+/**
+ * @license
+ * Copyright 2025 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useCallback, useEffect, useState } from 'react';
+import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core';
+import { AuthType } from '@qwen-code/qwen-code-core';
+import type { LoadedSettings } from '../../config/settings.js';
+import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
+import {
+ CODING_PLAN_MODELS,
+ CODING_PLAN_ENV_KEY,
+ CODING_PLAN_VERSION,
+} from '../../constants/codingPlan.js';
+import { t } from '../../i18n/index.js';
+
+export interface CodingPlanUpdateRequest {
+ prompt: string;
+ onConfirm: (confirmed: boolean) => void;
+}
+
+/**
+ * Checks if a config is a Coding Plan configuration by matching baseUrl and envKey.
+ * This ensures only configs from the Coding Plan provider are identified.
+ */
+function isCodingPlanConfig(config: {
+ baseUrl?: string;
+ envKey?: string;
+}): boolean {
+ return (
+ config.envKey === CODING_PLAN_ENV_KEY &&
+ CODING_PLAN_MODELS.some((template) => template.baseUrl === config.baseUrl)
+ );
+}
+
+/**
+ * Hook for detecting and handling Coding Plan template updates.
+ * Compares the persisted version with the current template version
+ * and prompts the user to update if they differ.
+ */
+export function useCodingPlanUpdates(
+ settings: LoadedSettings,
+ config: Config,
+ addItem: (
+ item: { type: 'info' | 'error' | 'warning'; text: string },
+ timestamp: number,
+ ) => void,
+) {
+ const [updateRequest, setUpdateRequest] = useState<
+ CodingPlanUpdateRequest | undefined
+ >();
+
+ /**
+ * Execute the Coding Plan configuration update.
+ * Removes old Coding Plan configs and replaces them with new ones from the template.
+ */
+ const executeUpdate = useCallback(async () => {
+ try {
+ const persistScope = getPersistScopeForModelSelection(settings);
+
+ // Get current configs
+ const currentConfigs =
+ (
+ settings.merged.modelProviders as
+ | Record>>
+ | undefined
+ )?.[AuthType.USE_OPENAI] || [];
+
+ // Filter out Coding Plan configs (keep user custom configs)
+ const nonCodingPlanConfigs = currentConfigs.filter(
+ (cfg) =>
+ !isCodingPlanConfig({
+ baseUrl: cfg['baseUrl'] as string | undefined,
+ envKey: cfg['envKey'] as string | undefined,
+ }),
+ );
+
+ // Generate new configs from template with the stored API key
+ const apiKey = process.env[CODING_PLAN_ENV_KEY];
+ if (!apiKey) {
+ throw new Error(
+ t(
+ 'Coding Plan API key not found. Please re-authenticate with Coding Plan.',
+ ),
+ );
+ }
+
+ const newConfigs = CODING_PLAN_MODELS.map((templateConfig) => ({
+ ...templateConfig,
+ envKey: CODING_PLAN_ENV_KEY,
+ }));
+
+ // Combine: new Coding Plan configs at the front, user configs preserved
+ const updatedConfigs = [
+ ...newConfigs,
+ ...(nonCodingPlanConfigs as Array>),
+ ] as Array>;
+
+ // Persist updated model providers
+ settings.setValue(
+ persistScope,
+ `modelProviders.${AuthType.USE_OPENAI}`,
+ updatedConfigs,
+ );
+
+ // Update the version
+ settings.setValue(
+ persistScope,
+ 'codingPlan.version',
+ CODING_PLAN_VERSION,
+ );
+
+ // Hot-reload model providers configuration
+ const updatedModelProviders = {
+ ...(settings.merged.modelProviders as
+ | Record
+ | undefined),
+ [AuthType.USE_OPENAI]: updatedConfigs,
+ };
+ config.reloadModelProvidersConfig(
+ updatedModelProviders as unknown as ModelProvidersConfig,
+ );
+
+ // Refresh auth with the new configuration
+ await config.refreshAuth(AuthType.USE_OPENAI);
+
+ addItem(
+ {
+ type: 'info',
+ text: t(
+ 'Coding Plan configuration updated successfully. New models are now available.',
+ ),
+ },
+ Date.now(),
+ );
+
+ return true;
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : String(error);
+ addItem(
+ {
+ type: 'error',
+ text: t('Failed to update Coding Plan configuration: {{message}}', {
+ message: errorMessage,
+ }),
+ },
+ Date.now(),
+ );
+ return false;
+ }
+ }, [settings, config, addItem]);
+
+ /**
+ * Check for version mismatch and prompt user for update if needed.
+ */
+ const checkForUpdates = useCallback(() => {
+ const savedVersion = (
+ settings.merged as { codingPlan?: { version?: string } }
+ ).codingPlan?.version;
+
+ // If no version is stored, user hasn't used Coding Plan yet - skip check
+ if (!savedVersion) {
+ return;
+ }
+
+ // If versions match, no update needed
+ if (savedVersion === CODING_PLAN_VERSION) {
+ return;
+ }
+
+ // Version mismatch - prompt user for update
+ setUpdateRequest({
+ prompt: t(
+ 'New model configurations are available for Bailian Coding Plan. Update now?',
+ ),
+ onConfirm: async (confirmed: boolean) => {
+ setUpdateRequest(undefined);
+ if (confirmed) {
+ await executeUpdate();
+ }
+ },
+ });
+ }, [settings, executeUpdate]);
+
+ // Check for updates on mount
+ useEffect(() => {
+ checkForUpdates();
+ }, [checkForUpdates]);
+
+ const dismissCodingPlanUpdate = useCallback(() => {
+ setUpdateRequest(undefined);
+ }, []);
+
+ return {
+ codingPlanUpdateRequest: updateRequest,
+ dismissCodingPlanUpdate,
+ };
+}