From 7bd404960270fddf29e1c2129c64d033221362cc Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 15 Mar 2026 14:53:43 -0400 Subject: [PATCH] fix(copilot): stop mutating config on read --- src/commands/copilot-command.ts | 32 +- src/copilot/copilot-executor.ts | 18 +- src/copilot/copilot-model-normalizer.ts | 320 ++++++++++++++---- src/copilot/index.ts | 2 + src/web-server/routes/copilot-routes.ts | 128 ++++--- .../routes/copilot-settings-routes.ts | 182 +++++----- .../unit/copilot/copilot-executor-env.test.ts | 12 + .../copilot/copilot-model-normalizer.test.ts | 40 ++- tests/unit/web-server/copilot-routes.test.ts | 122 ++++--- .../components/copilot/config-form/index.tsx | 24 +- .../config-form/use-copilot-config-form.ts | 64 +++- ui/src/hooks/use-copilot.ts | 43 ++- 12 files changed, 657 insertions(+), 330 deletions(-) diff --git a/src/commands/copilot-command.ts b/src/commands/copilot-command.ts index 4befa207..40d36bcc 100644 --- a/src/commands/copilot-command.ts +++ b/src/commands/copilot-command.ts @@ -12,12 +12,12 @@ import { stopDaemon, getAvailableModels, isCopilotApiInstalled, - normalizeCopilotConfig, + normalizeCopilotConfigWithWarnings, } from '../copilot'; import type { CopilotModel } from '../copilot'; import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../config/unified-config-loader'; import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types'; -import { ok, fail, info, color } from '../utils/ui'; +import { ok, fail, info, color, warn } from '../utils/ui'; import { normalizeCopilotSubcommand } from '../copilot/constants'; /** @@ -56,6 +56,18 @@ export async function handleCopilotCommand(args: string[]): Promise { } } +function loadCopilotConfigWithWarnings() { + const config = loadOrCreateUnifiedConfig(); + return normalizeCopilotConfigWithWarnings(config.copilot ?? DEFAULT_COPILOT_CONFIG); +} + +function printCopilotWarnings(messages: string[]): void { + if (messages.length === 0) return; + messages.forEach((message) => console.log(warn(message))); + console.log(warn('Run `ccs config` and save the Copilot section to persist these replacements.')); + console.log(''); +} + /** * Show help for copilot commands. */ @@ -125,8 +137,8 @@ async function handleAuth(): Promise { * Handle status subcommand. */ async function handleStatus(): Promise { - const config = loadOrCreateUnifiedConfig(); - const copilotConfig = normalizeCopilotConfig(config.copilot ?? DEFAULT_COPILOT_CONFIG); + const { config: copilotConfig, warnings } = loadCopilotConfigWithWarnings(); + printCopilotWarnings(warnings.map((warning) => warning.message)); const status = await getCopilotStatus(copilotConfig); @@ -183,8 +195,8 @@ async function handleStatus(): Promise { * Handle models subcommand. */ async function handleModels(): Promise { - const config = loadOrCreateUnifiedConfig(); - const copilotConfig = normalizeCopilotConfig(config.copilot ?? DEFAULT_COPILOT_CONFIG); + const { config: copilotConfig, warnings } = loadCopilotConfigWithWarnings(); + printCopilotWarnings(warnings.map((warning) => warning.message)); console.log('Available Copilot Models'); console.log('────────────────────────'); @@ -274,8 +286,8 @@ function formatResetDate(resetDate: string | null): string { * Handle usage subcommand. */ async function handleUsage(): Promise { - const config = loadOrCreateUnifiedConfig(); - const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; + const { config: copilotConfig, warnings } = loadCopilotConfigWithWarnings(); + printCopilotWarnings(warnings.map((warning) => warning.message)); const status = await getCopilotStatus(copilotConfig); if (!status.daemon.running) { @@ -312,8 +324,8 @@ async function handleUsage(): Promise { * Handle start subcommand. */ async function handleStart(): Promise { - const config = loadOrCreateUnifiedConfig(); - const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; + const { config: copilotConfig, warnings } = loadCopilotConfigWithWarnings(); + printCopilotWarnings(warnings.map((warning) => warning.message)); console.log(info(`Starting copilot-api daemon on port ${copilotConfig.port}...`)); diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index 0a3b954f..8f885cbd 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -11,9 +11,9 @@ import { getGlobalEnvConfig } from '../config/unified-config-loader'; import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth'; import { isDaemonRunning, startDaemon } from './copilot-daemon'; import { ensureCopilotApi } from './copilot-package-manager'; -import { normalizeCopilotConfig } from './copilot-model-normalizer'; +import { normalizeCopilotConfigWithWarnings } from './copilot-model-normalizer'; import { CopilotStatus } from './types'; -import { fail, info, ok } from '../utils/ui'; +import { fail, info, ok, warn } from '../utils/ui'; import { getWebSearchHookEnv } from '../utils/websearch-manager'; import { getImageAnalysisHookEnv } from '../utils/hooks'; import { stripClaudeCodeEnv } from '../utils/shell-executor'; @@ -22,7 +22,7 @@ import { stripClaudeCodeEnv } from '../utils/shell-executor'; * Get full copilot status (auth + daemon). */ export async function getCopilotStatus(config: CopilotConfig): Promise { - const normalizedConfig = normalizeCopilotConfig(config); + const normalizedConfig = normalizeCopilotConfigWithWarnings(config).config; const [auth, daemonRunning] = await Promise.all([ checkAuthStatus(), isDaemonRunning(normalizedConfig.port), @@ -45,7 +45,7 @@ export function generateCopilotEnv( config: CopilotConfig, claudeConfigDir?: string ): Record { - const normalizedConfig = normalizeCopilotConfig(config); + const normalizedConfig = normalizeCopilotConfigWithWarnings(config).config; // Use mapped models if configured, otherwise fall back to default model const opusModel = normalizedConfig.opus_model || normalizedConfig.model; @@ -83,7 +83,15 @@ export async function executeCopilotProfile( claudeConfigDir?: string, claudeCliPath: string = 'claude' ): Promise { - const normalizedConfig = normalizeCopilotConfig(config); + const { config: normalizedConfig, warnings } = normalizeCopilotConfigWithWarnings(config); + + if (warnings.length > 0) { + warnings.forEach(({ message }) => console.log(warn(message))); + console.log( + warn('Run `ccs config` and save the Copilot section to persist these replacements.') + ); + console.log(''); + } // Ensure copilot-api is installed (auto-install if missing, auto-update if outdated) try { diff --git a/src/copilot/copilot-model-normalizer.ts b/src/copilot/copilot-model-normalizer.ts index 94ffc916..a2dc3517 100644 --- a/src/copilot/copilot-model-normalizer.ts +++ b/src/copilot/copilot-model-normalizer.ts @@ -9,38 +9,15 @@ export const DEPRECATED_COPILOT_MODEL_IDS = Object.freeze( Object.keys(LEGACY_COPILOT_MODEL_FALLBACKS) ); -function trimModelId(value: string | null | undefined): string | undefined { - if (typeof value !== 'string') return undefined; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} +export type CopilotNormalizationSource = 'config' | 'settings'; +export type CopilotNormalizationTier = 'default' | 'opus' | 'sonnet' | 'haiku'; -export function normalizeCopilotModelId( - model: string | null | undefined, - fallbackModel: string = DEFAULT_COPILOT_CONFIG.model -): string { - const normalizedFallback = trimModelId(fallbackModel) ?? DEFAULT_COPILOT_CONFIG.model; - const trimmedModel = trimModelId(model) ?? normalizedFallback; - return LEGACY_COPILOT_MODEL_FALLBACKS[trimmedModel.toLowerCase()] ?? trimmedModel; -} - -export function normalizeOptionalCopilotModelId( - model: string | null | undefined, - fallbackModel: string -): string | undefined { - const trimmedModel = trimModelId(model); - return trimmedModel ? normalizeCopilotModelId(trimmedModel, fallbackModel) : undefined; -} - -export function normalizeCopilotConfig(config: CopilotConfig): CopilotConfig { - const model = normalizeCopilotModelId(config.model); - return { - ...config, - model, - opus_model: normalizeOptionalCopilotModelId(config.opus_model, model), - sonnet_model: normalizeOptionalCopilotModelId(config.sonnet_model, model), - haiku_model: normalizeOptionalCopilotModelId(config.haiku_model, model), - }; +export interface CopilotNormalizationWarning { + source: CopilotNormalizationSource; + tier: CopilotNormalizationTier; + original: string; + replacement: string; + message: string; } export interface CopilotSettingsPayload { @@ -48,50 +25,247 @@ export interface CopilotSettingsPayload { [key: string]: unknown; } +export interface NormalizedCopilotConfigResult { + config: CopilotConfig; + warnings: CopilotNormalizationWarning[]; +} + +export interface NormalizedCopilotSettingsResult { + settings: CopilotSettingsPayload; + effectiveConfig: CopilotConfig; + warnings: CopilotNormalizationWarning[]; +} + +function trimModelId(value: string | null | undefined): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function getTierLabel(tier: CopilotNormalizationTier): string { + switch (tier) { + case 'default': + return 'default'; + case 'opus': + return 'Opus'; + case 'sonnet': + return 'Sonnet'; + case 'haiku': + return 'Haiku'; + } +} + +function createWarning( + source: CopilotNormalizationSource, + tier: CopilotNormalizationTier, + original: string, + replacement: string +): CopilotNormalizationWarning { + return { + source, + tier, + original, + replacement, + message: `Copilot ${getTierLabel(tier)} model '${original}' is no longer supported. CCS will use '${replacement}' instead.`, + }; +} + +function appendWarning( + warnings: CopilotNormalizationWarning[], + warning?: CopilotNormalizationWarning +): void { + if (!warning) return; + const key = `${warning.source}:${warning.tier}:${warning.original.toLowerCase()}:${warning.replacement.toLowerCase()}`; + if ( + warnings.some( + (entry) => + `${entry.source}:${entry.tier}:${entry.original.toLowerCase()}:${entry.replacement.toLowerCase()}` === + key + ) + ) { + return; + } + warnings.push(warning); +} + +function normalizeRequiredModelSelection( + model: string | null | undefined, + fallbackModel: string, + source: CopilotNormalizationSource, + tier: CopilotNormalizationTier +): { value: string; warning?: CopilotNormalizationWarning } { + const normalizedFallback = trimModelId(fallbackModel) ?? DEFAULT_COPILOT_CONFIG.model; + const trimmedModel = trimModelId(model) ?? normalizedFallback; + const replacement = LEGACY_COPILOT_MODEL_FALLBACKS[trimmedModel.toLowerCase()]; + if (!replacement) return { value: trimmedModel }; + return { + value: replacement, + warning: createWarning(source, tier, trimmedModel, replacement), + }; +} + +function normalizeOptionalModelSelection( + model: string | null | undefined, + fallbackModel: string, + source: CopilotNormalizationSource, + tier: CopilotNormalizationTier +): { value?: string; warning?: CopilotNormalizationWarning } { + const trimmedModel = trimModelId(model); + if (!trimmedModel) return { value: undefined }; + if (!LEGACY_COPILOT_MODEL_FALLBACKS[trimmedModel.toLowerCase()]) { + return { value: trimmedModel }; + } + const replacement = trimModelId(fallbackModel) ?? DEFAULT_COPILOT_CONFIG.model; + return { + value: replacement, + warning: createWarning(source, tier, trimmedModel, replacement), + }; +} + +function getSettingsEnv(settings: CopilotSettingsPayload): Record { + return settings.env && typeof settings.env === 'object' && !Array.isArray(settings.env) + ? settings.env + : {}; +} + +function getSettingsPayload(settings: CopilotSettingsPayload): CopilotSettingsPayload { + return settings && typeof settings === 'object' && !Array.isArray(settings) ? settings : {}; +} + +export function normalizeCopilotModelId( + model: string | null | undefined, + fallbackModel: string = DEFAULT_COPILOT_CONFIG.model +): string { + return normalizeRequiredModelSelection(model, fallbackModel, 'config', 'default').value; +} + +export function normalizeCopilotConfigWithWarnings( + config: CopilotConfig +): NormalizedCopilotConfigResult { + const warnings: CopilotNormalizationWarning[] = []; + const baseModel = normalizeRequiredModelSelection( + config.model, + DEFAULT_COPILOT_CONFIG.model, + 'config', + 'default' + ); + appendWarning(warnings, baseModel.warning); + + const opusModel = normalizeOptionalModelSelection( + config.opus_model, + baseModel.value, + 'config', + 'opus' + ); + const sonnetModel = normalizeOptionalModelSelection( + config.sonnet_model, + baseModel.value, + 'config', + 'sonnet' + ); + const haikuModel = normalizeOptionalModelSelection( + config.haiku_model, + baseModel.value, + 'config', + 'haiku' + ); + appendWarning(warnings, opusModel.warning); + appendWarning(warnings, sonnetModel.warning); + appendWarning(warnings, haikuModel.warning); + + return { + config: { + ...config, + model: baseModel.value, + opus_model: opusModel.value, + sonnet_model: sonnetModel.value, + haiku_model: haikuModel.value, + }, + warnings, + }; +} + +export function normalizeCopilotConfig(config: CopilotConfig): CopilotConfig { + return normalizeCopilotConfigWithWarnings(config).config; +} + +export function normalizeCopilotSettingsWithWarnings( + settings: CopilotSettingsPayload, + fallbackConfig: CopilotConfig = DEFAULT_COPILOT_CONFIG +): NormalizedCopilotSettingsResult { + const configResult = normalizeCopilotConfigWithWarnings(fallbackConfig); + const warnings = [...configResult.warnings]; + const payload = getSettingsPayload(settings); + const rawEnv = getSettingsEnv(payload); + + const baseModel = normalizeRequiredModelSelection( + typeof rawEnv.ANTHROPIC_MODEL === 'string' ? rawEnv.ANTHROPIC_MODEL : configResult.config.model, + configResult.config.model, + 'settings', + 'default' + ); + appendWarning(warnings, baseModel.warning); + + const opusModel = normalizeOptionalModelSelection( + typeof rawEnv.ANTHROPIC_DEFAULT_OPUS_MODEL === 'string' + ? rawEnv.ANTHROPIC_DEFAULT_OPUS_MODEL + : configResult.config.opus_model, + baseModel.value, + 'settings', + 'opus' + ); + const sonnetModel = normalizeOptionalModelSelection( + typeof rawEnv.ANTHROPIC_DEFAULT_SONNET_MODEL === 'string' + ? rawEnv.ANTHROPIC_DEFAULT_SONNET_MODEL + : configResult.config.sonnet_model, + baseModel.value, + 'settings', + 'sonnet' + ); + const rawHaikuModel = + typeof rawEnv.ANTHROPIC_SMALL_FAST_MODEL === 'string' + ? rawEnv.ANTHROPIC_SMALL_FAST_MODEL + : typeof rawEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL === 'string' + ? rawEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL + : configResult.config.haiku_model; + const haikuModel = normalizeOptionalModelSelection( + rawHaikuModel, + baseModel.value, + 'settings', + 'haiku' + ); + appendWarning(warnings, opusModel.warning); + appendWarning(warnings, sonnetModel.warning); + appendWarning(warnings, haikuModel.warning); + + const effectiveConfig = { + ...configResult.config, + model: baseModel.value, + opus_model: opusModel.value ?? baseModel.value, + sonnet_model: sonnetModel.value ?? baseModel.value, + haiku_model: haikuModel.value ?? baseModel.value, + }; + + return { + settings: { + ...payload, + env: { + ...rawEnv, + ANTHROPIC_MODEL: effectiveConfig.model, + ANTHROPIC_DEFAULT_OPUS_MODEL: effectiveConfig.opus_model, + ANTHROPIC_DEFAULT_SONNET_MODEL: effectiveConfig.sonnet_model, + ANTHROPIC_DEFAULT_HAIKU_MODEL: effectiveConfig.haiku_model, + ANTHROPIC_SMALL_FAST_MODEL: effectiveConfig.haiku_model, + }, + }, + effectiveConfig, + warnings, + }; +} + export function normalizeCopilotSettings( settings: CopilotSettingsPayload, fallbackConfig: CopilotConfig = DEFAULT_COPILOT_CONFIG ): CopilotSettingsPayload { - const normalizedConfig = normalizeCopilotConfig(fallbackConfig); - const rawEnv = - settings.env && typeof settings.env === 'object' && !Array.isArray(settings.env) - ? settings.env - : {}; - - const model = normalizeCopilotModelId( - typeof rawEnv.ANTHROPIC_MODEL === 'string' ? rawEnv.ANTHROPIC_MODEL : normalizedConfig.model, - normalizedConfig.model - ); - const opusModel = - normalizeOptionalCopilotModelId( - typeof rawEnv.ANTHROPIC_DEFAULT_OPUS_MODEL === 'string' - ? rawEnv.ANTHROPIC_DEFAULT_OPUS_MODEL - : normalizedConfig.opus_model, - model - ) ?? model; - const sonnetModel = - normalizeOptionalCopilotModelId( - typeof rawEnv.ANTHROPIC_DEFAULT_SONNET_MODEL === 'string' - ? rawEnv.ANTHROPIC_DEFAULT_SONNET_MODEL - : normalizedConfig.sonnet_model, - model - ) ?? model; - const haikuModel = - normalizeOptionalCopilotModelId( - typeof rawEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL === 'string' - ? rawEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL - : normalizedConfig.haiku_model, - model - ) ?? model; - - return { - ...settings, - env: { - ...rawEnv, - ANTHROPIC_MODEL: model, - ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel, - ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel, - ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel, - }, - }; + return normalizeCopilotSettingsWithWarnings(settings, fallbackConfig).settings; } diff --git a/src/copilot/index.ts b/src/copilot/index.ts index d3bbacc0..9323d9d3 100644 --- a/src/copilot/index.ts +++ b/src/copilot/index.ts @@ -48,8 +48,10 @@ export { export { DEPRECATED_COPILOT_MODEL_IDS, normalizeCopilotConfig, + normalizeCopilotConfigWithWarnings, normalizeCopilotModelId, normalizeCopilotSettings, + normalizeCopilotSettingsWithWarnings, } from './copilot-model-normalizer'; // Usage diff --git a/src/web-server/routes/copilot-routes.ts b/src/web-server/routes/copilot-routes.ts index 8e7b7af8..4c85d284 100644 --- a/src/web-server/routes/copilot-routes.ts +++ b/src/web-server/routes/copilot-routes.ts @@ -18,9 +18,12 @@ import { getCopilotApiInfo, getInstalledVersion as getCopilotInstalledVersion, } from '../../copilot'; -import { normalizeCopilotConfig } from '../../copilot/copilot-model-normalizer'; +import { + normalizeCopilotConfigWithWarnings, + type CopilotNormalizationWarning, +} from '../../copilot/copilot-model-normalizer'; import { DEFAULT_COPILOT_CONFIG, type CopilotConfig } from '../../config/unified-config-types'; -import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader'; +import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; import copilotSettingsRoutes from './copilot-settings-routes'; const router = Router(); @@ -40,22 +43,12 @@ function parseOptionalModel(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -function persistNormalizedCopilotConfig( - config: ReturnType, - copilotConfig: CopilotConfig -): void { - const current = JSON.stringify(config.copilot ?? DEFAULT_COPILOT_CONFIG); - const next = JSON.stringify(copilotConfig); - if (current === next) return; - config.copilot = copilotConfig; - saveUnifiedConfig(config); -} - -function loadNormalizedCopilotConfig(): CopilotConfig { +function loadEffectiveCopilotConfig(): { + config: CopilotConfig; + warnings: CopilotNormalizationWarning[]; +} { const config = loadOrCreateUnifiedConfig(); - const copilotConfig = normalizeCopilotConfig(config.copilot ?? DEFAULT_COPILOT_CONFIG); - persistNormalizedCopilotConfig(config, copilotConfig); - return copilotConfig; + return normalizeCopilotConfigWithWarnings(config.copilot ?? DEFAULT_COPILOT_CONFIG); } /** @@ -63,7 +56,7 @@ function loadNormalizedCopilotConfig(): CopilotConfig { */ router.get('/status', async (_req: Request, res: Response): Promise => { try { - const copilotConfig = loadNormalizedCopilotConfig(); + const { config: copilotConfig, warnings } = loadEffectiveCopilotConfig(); const status = await getCopilotStatus(copilotConfig); const installed = isCopilotApiInstalled(); const version = getCopilotInstalledVersion(); @@ -80,6 +73,7 @@ router.get('/status', async (_req: Request, res: Response): Promise => { auto_start: copilotConfig.auto_start, rate_limit: copilotConfig.rate_limit, wait_on_limit: copilotConfig.wait_on_limit, + warnings, }); } catch (error) { res.status(500).json({ error: (error as Error).message }); @@ -91,7 +85,8 @@ router.get('/status', async (_req: Request, res: Response): Promise => { */ router.get('/config', (_req: Request, res: Response): void => { try { - res.json(loadNormalizedCopilotConfig()); + const { config: copilotConfig, warnings } = loadEffectiveCopilotConfig(); + res.json({ ...copilotConfig, warnings }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } @@ -213,47 +208,51 @@ router.put('/config', (req: Request, res: Response): void => { return; } - const config = loadOrCreateUnifiedConfig(); + let nextCopilotConfig: CopilotConfig = { ...DEFAULT_COPILOT_CONFIG }; + let warnings: CopilotNormalizationWarning[] = []; - // Merge updates with existing config - const nextCopilotConfig = normalizeCopilotConfig({ - enabled: - (payload.enabled as boolean) ?? config.copilot?.enabled ?? DEFAULT_COPILOT_CONFIG.enabled, - auto_start: - (payload.auto_start as boolean) ?? - config.copilot?.auto_start ?? - DEFAULT_COPILOT_CONFIG.auto_start, - port: (payload.port as number) ?? config.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port, - account_type: - (payload.account_type as 'individual' | 'business' | 'enterprise') ?? - config.copilot?.account_type ?? - DEFAULT_COPILOT_CONFIG.account_type, - rate_limit: - payload.rate_limit !== undefined - ? (payload.rate_limit as number | null) - : (config.copilot?.rate_limit ?? DEFAULT_COPILOT_CONFIG.rate_limit), - wait_on_limit: - (payload.wait_on_limit as boolean) ?? - config.copilot?.wait_on_limit ?? - DEFAULT_COPILOT_CONFIG.wait_on_limit, - model: normalizedModel ?? config.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model, - opus_model: - 'opus_model' in payload - ? parseOptionalModel(payload.opus_model) - : config.copilot?.opus_model, - sonnet_model: - 'sonnet_model' in payload - ? parseOptionalModel(payload.sonnet_model) - : config.copilot?.sonnet_model, - haiku_model: - 'haiku_model' in payload - ? parseOptionalModel(payload.haiku_model) - : config.copilot?.haiku_model, + mutateUnifiedConfig((config) => { + const currentConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; + const result = normalizeCopilotConfigWithWarnings({ + enabled: + (payload.enabled as boolean) ?? currentConfig.enabled ?? DEFAULT_COPILOT_CONFIG.enabled, + auto_start: + (payload.auto_start as boolean) ?? + currentConfig.auto_start ?? + DEFAULT_COPILOT_CONFIG.auto_start, + port: (payload.port as number) ?? currentConfig.port ?? DEFAULT_COPILOT_CONFIG.port, + account_type: + (payload.account_type as 'individual' | 'business' | 'enterprise') ?? + currentConfig.account_type ?? + DEFAULT_COPILOT_CONFIG.account_type, + rate_limit: + payload.rate_limit !== undefined + ? (payload.rate_limit as number | null) + : (currentConfig.rate_limit ?? DEFAULT_COPILOT_CONFIG.rate_limit), + wait_on_limit: + (payload.wait_on_limit as boolean) ?? + currentConfig.wait_on_limit ?? + DEFAULT_COPILOT_CONFIG.wait_on_limit, + model: normalizedModel ?? currentConfig.model ?? DEFAULT_COPILOT_CONFIG.model, + opus_model: + 'opus_model' in payload + ? parseOptionalModel(payload.opus_model) + : currentConfig.opus_model, + sonnet_model: + 'sonnet_model' in payload + ? parseOptionalModel(payload.sonnet_model) + : currentConfig.sonnet_model, + haiku_model: + 'haiku_model' in payload + ? parseOptionalModel(payload.haiku_model) + : currentConfig.haiku_model, + }); + config.copilot = result.config; + nextCopilotConfig = result.config; + warnings = result.warnings; }); - config.copilot = nextCopilotConfig; - saveUnifiedConfig(config); - res.json({ success: true, copilot: nextCopilotConfig }); + res.json({ success: true, copilot: nextCopilotConfig, warnings }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } @@ -288,7 +287,7 @@ router.get('/auth/status', async (_req: Request, res: Response): Promise = */ router.get('/models', async (_req: Request, res: Response): Promise => { try { - const copilotConfig = loadNormalizedCopilotConfig(); + const { config: copilotConfig, warnings } = loadEffectiveCopilotConfig(); const port = copilotConfig.port; const currentModel = copilotConfig.model; const models = await getCopilotModels(port); @@ -298,7 +297,7 @@ router.get('/models', async (_req: Request, res: Response): Promise => { isCurrent: m.id === currentModel, })); - res.json({ models: modelsWithCurrent, current: currentModel }); + res.json({ models: modelsWithCurrent, current: currentModel, warnings }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } @@ -309,8 +308,8 @@ router.get('/models', async (_req: Request, res: Response): Promise => { */ router.get('/usage', async (_req: Request, res: Response): Promise => { try { - const config = loadOrCreateUnifiedConfig(); - const port = config.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port; + const { config: copilotConfig, warnings } = loadEffectiveCopilotConfig(); + const port = copilotConfig.port; const daemonRunning = await isDaemonRunning(port); if (!daemonRunning) { @@ -330,7 +329,7 @@ router.get('/usage', async (_req: Request, res: Response): Promise => { return; } - res.json(usage); + res.json({ ...usage, warnings }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } @@ -341,10 +340,9 @@ router.get('/usage', async (_req: Request, res: Response): Promise => { */ router.post('/daemon/start', async (_req: Request, res: Response): Promise => { try { - const config = loadOrCreateUnifiedConfig(); - const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; + const { config: copilotConfig, warnings } = loadEffectiveCopilotConfig(); const result = await startCopilotDaemon(copilotConfig); - res.json(result); + res.json({ ...result, warnings }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } diff --git a/src/web-server/routes/copilot-settings-routes.ts b/src/web-server/routes/copilot-settings-routes.ts index 6c17a207..015da32b 100644 --- a/src/web-server/routes/copilot-settings-routes.ts +++ b/src/web-server/routes/copilot-settings-routes.ts @@ -6,12 +6,12 @@ import { Router, Request, Response } from 'express'; import * as fs from 'fs'; import * as path from 'path'; import { - normalizeCopilotConfig, - normalizeCopilotSettings, + normalizeCopilotConfigWithWarnings, + normalizeCopilotSettingsWithWarnings, } from '../../copilot/copilot-model-normalizer'; -import { getCcsDir } from '../../utils/config-manager'; import { DEFAULT_COPILOT_CONFIG, type CopilotConfig } from '../../config/unified-config-types'; -import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader'; +import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; +import { getCcsDir } from '../../utils/config-manager'; const router = Router(); @@ -26,20 +26,40 @@ function writeSettingsFile( ): void { const nextContent = serializeSettings(settings); if (previousContent === nextContent) return; - const tempPath = `${settingsPath}.tmp`; + const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`; fs.writeFileSync(tempPath, nextContent); fs.renameSync(tempPath, settingsPath); } -function persistNormalizedCopilotConfig( - config: ReturnType, - copilotConfig: CopilotConfig +function restoreSettingsFile( + settingsPath: string, + previousContent: string | undefined, + existedBefore: boolean ): void { - const current = JSON.stringify(config.copilot ?? DEFAULT_COPILOT_CONFIG); - const next = JSON.stringify(copilotConfig); - if (current === next) return; - config.copilot = copilotConfig; - saveUnifiedConfig(config); + if (!existedBefore) { + if (fs.existsSync(settingsPath)) { + fs.unlinkSync(settingsPath); + } + return; + } + + const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.restore.tmp`; + fs.writeFileSync(tempPath, previousContent ?? ''); + fs.renameSync(tempPath, settingsPath); +} + +function buildDefaultSettings(copilotConfig: CopilotConfig) { + return { + env: { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${copilotConfig.port}`, + ANTHROPIC_AUTH_TOKEN: 'copilot-managed', + ANTHROPIC_MODEL: copilotConfig.model, + ANTHROPIC_DEFAULT_OPUS_MODEL: copilotConfig.opus_model || copilotConfig.model, + ANTHROPIC_DEFAULT_SONNET_MODEL: copilotConfig.sonnet_model || copilotConfig.model, + ANTHROPIC_DEFAULT_HAIKU_MODEL: copilotConfig.haiku_model || copilotConfig.model, + ANTHROPIC_SMALL_FAST_MODEL: copilotConfig.haiku_model || copilotConfig.model, + }, + }; } /** @@ -50,74 +70,39 @@ router.get('/raw', (_req: Request, res: Response): void => { try { const settingsPath = path.join(getCcsDir(), 'copilot.settings.json'); const config = loadOrCreateUnifiedConfig(); - const copilotConfig = normalizeCopilotConfig(config.copilot ?? DEFAULT_COPILOT_CONFIG); - persistNormalizedCopilotConfig(config, copilotConfig); + const configResult = normalizeCopilotConfigWithWarnings( + config.copilot ?? DEFAULT_COPILOT_CONFIG + ); - // Default model for all tiers - const defaultModel = copilotConfig.model; - - // If file doesn't exist, return default structure with all model mappings if (!fs.existsSync(settingsPath)) { - // Create settings structure matching CLIProxy pattern - // Use 127.0.0.1 instead of localhost for more reliable local connections - const defaultSettings = normalizeCopilotSettings( - { - env: { - ANTHROPIC_BASE_URL: `http://127.0.0.1:${copilotConfig.port}`, - ANTHROPIC_AUTH_TOKEN: 'copilot-managed', - ANTHROPIC_MODEL: defaultModel, - ANTHROPIC_DEFAULT_OPUS_MODEL: copilotConfig.opus_model || defaultModel, - ANTHROPIC_DEFAULT_SONNET_MODEL: copilotConfig.sonnet_model || defaultModel, - ANTHROPIC_DEFAULT_HAIKU_MODEL: copilotConfig.haiku_model || defaultModel, - }, - }, - copilotConfig + const defaultSettings = normalizeCopilotSettingsWithWarnings( + buildDefaultSettings(configResult.config), + configResult.config ); res.json({ - settings: defaultSettings, + settings: defaultSettings.settings, + effectiveSettings: defaultSettings.settings, mtime: Date.now(), path: `~/.ccs/copilot.settings.json`, exists: false, + warnings: defaultSettings.warnings, }); return; } const content = fs.readFileSync(settingsPath, 'utf-8'); - const settings = normalizeCopilotSettings( - JSON.parse(content) as Record, - copilotConfig - ); - writeSettingsFile(settingsPath, settings, content); - - const env = - settings.env && typeof settings.env === 'object' && !Array.isArray(settings.env) - ? settings.env - : {}; - persistNormalizedCopilotConfig(config, { - ...copilotConfig, - model: typeof env.ANTHROPIC_MODEL === 'string' ? env.ANTHROPIC_MODEL : copilotConfig.model, - opus_model: - typeof env.ANTHROPIC_DEFAULT_OPUS_MODEL === 'string' - ? env.ANTHROPIC_DEFAULT_OPUS_MODEL - : undefined, - sonnet_model: - typeof env.ANTHROPIC_DEFAULT_SONNET_MODEL === 'string' - ? env.ANTHROPIC_DEFAULT_SONNET_MODEL - : undefined, - haiku_model: - typeof env.ANTHROPIC_DEFAULT_HAIKU_MODEL === 'string' - ? env.ANTHROPIC_DEFAULT_HAIKU_MODEL - : undefined, - }); - + const rawSettings = JSON.parse(content) as Record; + const settingsResult = normalizeCopilotSettingsWithWarnings(rawSettings, configResult.config); const stat = fs.statSync(settingsPath); res.json({ - settings, + settings: rawSettings, + effectiveSettings: settingsResult.settings, mtime: stat.mtimeMs, path: `~/.ccs/copilot.settings.json`, exists: true, + warnings: settingsResult.warnings, }); } catch (error) { res.status(500).json({ error: (error as Error).message }); @@ -132,8 +117,16 @@ router.put('/raw', (req: Request, res: Response): void => { try { const { settings, expectedMtime } = req.body; const settingsPath = path.join(getCcsDir(), 'copilot.settings.json'); - const config = loadOrCreateUnifiedConfig(); - const copilotConfig = normalizeCopilotConfig(config.copilot ?? DEFAULT_COPILOT_CONFIG); + + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { + res.status(400).json({ error: 'settings must be a JSON object' }); + return; + } + + const currentConfig = loadOrCreateUnifiedConfig(); + const configResult = normalizeCopilotConfigWithWarnings( + currentConfig.copilot ?? DEFAULT_COPILOT_CONFIG + ); // Check for conflict if file exists and expectedMtime provided if (fs.existsSync(settingsPath) && expectedMtime) { @@ -144,43 +137,40 @@ router.put('/raw', (req: Request, res: Response): void => { } } - const normalizedSettings = normalizeCopilotSettings( + const settingsResult = normalizeCopilotSettingsWithWarnings( settings as Record, - copilotConfig + configResult.config + ); + const existedBefore = fs.existsSync(settingsPath); + const previousContent = existedBefore ? fs.readFileSync(settingsPath, 'utf-8') : undefined; + writeSettingsFile( + settingsPath, + settingsResult.settings as Record, + previousContent ); - writeSettingsFile(settingsPath, normalizedSettings); - // Also sync model mappings back to unified config - const env = - normalizedSettings.env && - typeof normalizedSettings.env === 'object' && - !Array.isArray(normalizedSettings.env) - ? normalizedSettings.env - : {}; - - config.copilot = normalizeCopilotConfig({ - ...(config.copilot ?? DEFAULT_COPILOT_CONFIG), - model: - typeof env.ANTHROPIC_MODEL === 'string' - ? env.ANTHROPIC_MODEL - : config.copilot?.model || DEFAULT_COPILOT_CONFIG.model, - opus_model: - typeof env.ANTHROPIC_DEFAULT_OPUS_MODEL === 'string' - ? env.ANTHROPIC_DEFAULT_OPUS_MODEL - : undefined, - sonnet_model: - typeof env.ANTHROPIC_DEFAULT_SONNET_MODEL === 'string' - ? env.ANTHROPIC_DEFAULT_SONNET_MODEL - : undefined, - haiku_model: - typeof env.ANTHROPIC_DEFAULT_HAIKU_MODEL === 'string' - ? env.ANTHROPIC_DEFAULT_HAIKU_MODEL - : undefined, - }); - saveUnifiedConfig(config); + try { + mutateUnifiedConfig((config) => { + config.copilot = settingsResult.effectiveConfig; + }); + } catch (error) { + try { + restoreSettingsFile(settingsPath, previousContent, existedBefore); + } catch (rollbackError) { + throw new Error( + `Failed to sync unified config after writing Copilot settings: ${(error as Error).message}. Rollback also failed: ${(rollbackError as Error).message}` + ); + } + throw error; + } const stat = fs.statSync(settingsPath); - res.json({ success: true, mtime: stat.mtimeMs }); + res.json({ + success: true, + mtime: stat.mtimeMs, + settings: settingsResult.settings, + warnings: settingsResult.warnings, + }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } diff --git a/tests/unit/copilot/copilot-executor-env.test.ts b/tests/unit/copilot/copilot-executor-env.test.ts index cf92e27b..233c1e42 100644 --- a/tests/unit/copilot/copilot-executor-env.test.ts +++ b/tests/unit/copilot/copilot-executor-env.test.ts @@ -29,6 +29,18 @@ describe('generateCopilotEnv', () => { expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe('gpt-4.1'); }); + it('falls back deprecated haiku overrides to the selected base model', () => { + const env = generateCopilotEnv({ + ...baseConfig, + model: 'claude-sonnet-4.5', + haiku_model: 'raptor-mini', + }); + + expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4.5'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4.5'); + expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe('claude-sonnet-4.5'); + }); + it('includes inherited CLAUDE_CONFIG_DIR when provided', () => { const env = generateCopilotEnv(baseConfig, '/tmp/.ccs/instances/pro'); expect(env.CLAUDE_CONFIG_DIR).toBe('/tmp/.ccs/instances/pro'); diff --git a/tests/unit/copilot/copilot-model-normalizer.test.ts b/tests/unit/copilot/copilot-model-normalizer.test.ts index 2b84c9fb..016f12ec 100644 --- a/tests/unit/copilot/copilot-model-normalizer.test.ts +++ b/tests/unit/copilot/copilot-model-normalizer.test.ts @@ -3,8 +3,10 @@ import { DEFAULT_COPILOT_CONFIG } from '../../../src/config/unified-config-types import { DEPRECATED_COPILOT_MODEL_IDS, normalizeCopilotConfig, + normalizeCopilotConfigWithWarnings, normalizeCopilotModelId, normalizeCopilotSettings, + normalizeCopilotSettingsWithWarnings, } from '../../../src/copilot/copilot-model-normalizer'; describe('copilot-model-normalizer', () => { @@ -16,19 +18,18 @@ describe('copilot-model-normalizer', () => { expect(normalizeCopilotModelId('raptor-mini')).toBe(DEFAULT_COPILOT_CONFIG.model); }); - it('normalizes Copilot config tier mappings', () => { - const normalized = normalizeCopilotConfig({ + it('falls back deprecated tier overrides to the normalized base model', () => { + const result = normalizeCopilotConfigWithWarnings({ ...DEFAULT_COPILOT_CONFIG, - model: 'raptor-mini', - opus_model: 'raptor-mini', - sonnet_model: 'raptor-mini', + model: 'claude-sonnet-4.5', haiku_model: 'raptor-mini', }); - expect(normalized.model).toBe('gpt-4.1'); - expect(normalized.opus_model).toBe('gpt-4.1'); - expect(normalized.sonnet_model).toBe('gpt-4.1'); - expect(normalized.haiku_model).toBe('gpt-4.1'); + expect(result.config.model).toBe('claude-sonnet-4.5'); + expect(result.config.haiku_model).toBe('claude-sonnet-4.5'); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]?.message).toContain("'raptor-mini'"); + expect(result.warnings[0]?.message).toContain("'claude-sonnet-4.5'"); }); it('normalizes raw settings payloads without dropping unrelated env keys', () => { @@ -55,4 +56,25 @@ describe('copilot-model-normalizer', () => { expect(normalized.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-4.1'); expect(normalized.env?.EXTRA_FLAG).toBe('keep-me'); }); + + it('keeps small-fast and haiku env settings in lockstep', () => { + const result = normalizeCopilotSettingsWithWarnings( + { + env: { + ANTHROPIC_MODEL: 'claude-sonnet-4.5', + ANTHROPIC_SMALL_FAST_MODEL: 'raptor-mini', + }, + }, + { + ...DEFAULT_COPILOT_CONFIG, + model: 'claude-sonnet-4.5', + } + ); + + expect(result.settings.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4.5'); + expect(result.settings.env?.ANTHROPIC_SMALL_FAST_MODEL).toBe('claude-sonnet-4.5'); + expect(result.effectiveConfig.haiku_model).toBe('claude-sonnet-4.5'); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]?.tier).toBe('haiku'); + }); }); diff --git a/tests/unit/web-server/copilot-routes.test.ts b/tests/unit/web-server/copilot-routes.test.ts index cd2a444b..897f4476 100644 --- a/tests/unit/web-server/copilot-routes.test.ts +++ b/tests/unit/web-server/copilot-routes.test.ts @@ -4,6 +4,8 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import type { Server } from 'http'; +import { normalizeCopilotConfig } from '../../../src/copilot/copilot-model-normalizer'; +import { DEFAULT_COPILOT_CONFIG } from '../../../src/config/unified-config-types'; let server: Server; let baseUrl = ''; @@ -139,11 +141,9 @@ afterAll(async () => { }); describe('Copilot Routes', () => { - it('GET /api/copilot/config normalizes and persists stale raptor-mini config', async () => { + it('GET /api/copilot/config returns normalized values and warnings without persisting', async () => { seedCopilotConfig({ - model: 'raptor-mini', - opus_model: 'raptor-mini', - sonnet_model: 'raptor-mini', + model: 'claude-sonnet-4.5', haiku_model: 'raptor-mini', }); @@ -152,30 +152,26 @@ describe('Copilot Routes', () => { const body = (await response.json()) as { model: string; + warnings?: Array<{ tier: string; message: string }>; opus_model?: string; sonnet_model?: string; haiku_model?: string; }; - expect(body.model).toBe('gpt-4.1'); - expect(body.opus_model).toBeUndefined(); - expect(body.sonnet_model).toBeUndefined(); + expect(body.model).toBe('claude-sonnet-4.5'); expect(body.haiku_model).toBeUndefined(); + expect(body.warnings ?? []).toHaveLength(0); const persisted = loadOrCreateUnifiedConfig().copilot; - expect(persisted?.model).toBe('gpt-4.1'); - expect(persisted?.opus_model).toBeUndefined(); - expect(persisted?.sonnet_model).toBeUndefined(); + expect(persisted?.model).toBe('claude-sonnet-4.5'); expect(persisted?.haiku_model).toBeUndefined(); }); - it('PUT /api/copilot/config normalizes stale raptor-mini payloads before save', async () => { + it('PUT /api/copilot/config normalizes deprecated overrides before save', async () => { const response = await fetch(`${baseUrl}/api/copilot/config`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ - model: 'raptor-mini', - opus_model: 'raptor-mini', - sonnet_model: 'raptor-mini', + model: 'claude-sonnet-4.5', haiku_model: 'raptor-mini', }), }); @@ -183,6 +179,7 @@ describe('Copilot Routes', () => { const body = (await response.json()) as { success: boolean; + warnings?: Array<{ tier: string; replacement: string }>; copilot: { model: string; opus_model?: string; @@ -191,30 +188,30 @@ describe('Copilot Routes', () => { }; }; expect(body.success).toBe(true); - expect(body.copilot.model).toBe('gpt-4.1'); - expect(body.copilot.opus_model).toBe('gpt-4.1'); - expect(body.copilot.sonnet_model).toBe('gpt-4.1'); - expect(body.copilot.haiku_model).toBe('gpt-4.1'); + expect(body.copilot.model).toBe('claude-sonnet-4.5'); + expect(body.copilot.haiku_model).toBe('claude-sonnet-4.5'); + expect(body.warnings?.[0]?.tier).toBe('haiku'); + expect(body.warnings?.[0]?.replacement).toBe('claude-sonnet-4.5'); const persisted = loadOrCreateUnifiedConfig().copilot; - expect(persisted?.model).toBe('gpt-4.1'); - expect(persisted?.opus_model).toBeUndefined(); - expect(persisted?.sonnet_model).toBeUndefined(); - expect(persisted?.haiku_model).toBeUndefined(); + expect(persisted?.model).toBe('claude-sonnet-4.5'); + expect( + normalizeCopilotConfig(persisted ?? { ...DEFAULT_COPILOT_CONFIG }).haiku_model + ).toBeUndefined(); + expect(persisted?.haiku_model).not.toBe('raptor-mini'); }); - it('GET /api/copilot/settings/raw normalizes and persists stale raw settings', async () => { - seedCopilotConfig({ model: 'raptor-mini' }); + it('GET /api/copilot/settings/raw keeps stored JSON untouched and returns effective warnings', async () => { + seedCopilotConfig({ model: 'claude-sonnet-4.5' }); writeCopilotSettings({ env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:4141', ANTHROPIC_AUTH_TOKEN: 'copilot-managed', - ANTHROPIC_MODEL: 'raptor-mini', - ANTHROPIC_DEFAULT_OPUS_MODEL: 'raptor-mini', - ANTHROPIC_DEFAULT_SONNET_MODEL: 'raptor-mini', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'raptor-mini', + ANTHROPIC_SMALL_FAST_MODEL: 'raptor-mini', }, }); + const beforeContent = fs.readFileSync(getCopilotSettingsPath(), 'utf-8'); const response = await fetch(`${baseUrl}/api/copilot/settings/raw`); expect(response.status).toBe(200); @@ -223,20 +220,21 @@ describe('Copilot Routes', () => { settings: { env: Record; }; + effectiveSettings: { + env: Record; + }; exists: boolean; + warnings?: Array<{ tier: string }>; }; expect(body.exists).toBe(true); - expect(body.settings.env.ANTHROPIC_MODEL).toBe('gpt-4.1'); - expect(body.settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-4.1'); - expect(body.settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-4.1'); - expect(body.settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-4.1'); + expect(body.settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('raptor-mini'); + expect(body.settings.env.ANTHROPIC_SMALL_FAST_MODEL).toBe('raptor-mini'); + expect(body.effectiveSettings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4.5'); + expect(body.effectiveSettings.env.ANTHROPIC_SMALL_FAST_MODEL).toBe('claude-sonnet-4.5'); + expect(body.warnings?.map((warning) => warning.tier)).toEqual(['haiku']); - const persistedSettings = JSON.parse(fs.readFileSync(getCopilotSettingsPath(), 'utf-8')) as { - env: Record; - }; - expect(persistedSettings.env.ANTHROPIC_MODEL).toBe('gpt-4.1'); - expect(persistedSettings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-4.1'); - expect(loadOrCreateUnifiedConfig().copilot?.model).toBe('gpt-4.1'); + expect(fs.readFileSync(getCopilotSettingsPath(), 'utf-8')).toBe(beforeContent); + expect(loadOrCreateUnifiedConfig().copilot?.model).toBe('claude-sonnet-4.5'); }); it('PUT /api/copilot/settings/raw normalizes stale models before save and syncs unified config', async () => { @@ -248,32 +246,52 @@ describe('Copilot Routes', () => { env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:4141', ANTHROPIC_AUTH_TOKEN: 'copilot-managed', - ANTHROPIC_MODEL: 'raptor-mini', - ANTHROPIC_DEFAULT_OPUS_MODEL: 'raptor-mini', - ANTHROPIC_DEFAULT_SONNET_MODEL: 'raptor-mini', - ANTHROPIC_DEFAULT_HAIKU_MODEL: 'raptor-mini', + ANTHROPIC_MODEL: 'claude-sonnet-4.5', + ANTHROPIC_SMALL_FAST_MODEL: 'raptor-mini', }, }, }), }); expect(response.status).toBe(200); + const body = (await response.json()) as { + success: boolean; + warnings?: Array<{ tier: string; replacement: string }>; + }; + expect(body.success).toBe(true); + expect(body.warnings?.[0]?.tier).toBe('haiku'); + expect(body.warnings?.[0]?.replacement).toBe('claude-sonnet-4.5'); + const persistedSettings = JSON.parse(fs.readFileSync(getCopilotSettingsPath(), 'utf-8')) as { env: Record; }; - expect(persistedSettings.env.ANTHROPIC_MODEL).toBe('gpt-4.1'); - expect(persistedSettings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-4.1'); - expect(persistedSettings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-4.1'); - expect(persistedSettings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-4.1'); + expect(persistedSettings.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4.5'); + expect(persistedSettings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4.5'); + expect(persistedSettings.env.ANTHROPIC_SMALL_FAST_MODEL).toBe('claude-sonnet-4.5'); const persistedConfig = loadOrCreateUnifiedConfig().copilot; - expect(persistedConfig?.model).toBe('gpt-4.1'); - expect(persistedConfig?.opus_model).toBeUndefined(); - expect(persistedConfig?.sonnet_model).toBeUndefined(); - expect(persistedConfig?.haiku_model).toBeUndefined(); + expect(persistedConfig?.model).toBe('claude-sonnet-4.5'); + expect( + normalizeCopilotConfig(persistedConfig ?? { ...DEFAULT_COPILOT_CONFIG }).haiku_model + ).toBeUndefined(); + expect(persistedConfig?.haiku_model).not.toBe('raptor-mini'); }); - it('GET /api/copilot/models reports normalized current model for stale config', async () => { + it('PUT /api/copilot/settings/raw rejects non-object settings payloads', async () => { + const response = await fetch(`${baseUrl}/api/copilot/settings/raw`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + settings: ['invalid'], + }), + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { error: string }; + expect(body.error).toBe('settings must be a JSON object'); + }); + + it('GET /api/copilot/models reports normalized current model without persisting', async () => { seedCopilotConfig({ model: 'raptor-mini' }); const response = await fetch(`${baseUrl}/api/copilot/models`); @@ -281,11 +299,13 @@ describe('Copilot Routes', () => { const body = (await response.json()) as { current: string; + warnings?: Array<{ tier: string }>; models: Array<{ id: string; isCurrent?: boolean }>; }; expect(body.current).toBe('gpt-4.1'); expect(body.models.some((model) => model.id === 'gpt-4.1' && model.isCurrent)).toBe(true); - expect(loadOrCreateUnifiedConfig().copilot?.model).toBe('gpt-4.1'); + expect(body.warnings?.map((warning) => warning.tier)).toEqual(['default']); + expect(loadOrCreateUnifiedConfig().copilot?.model).toBe('raptor-mini'); }); }); diff --git a/ui/src/components/copilot/config-form/index.tsx b/ui/src/components/copilot/config-form/index.tsx index 059477ed..0acec851 100644 --- a/ui/src/components/copilot/config-form/index.tsx +++ b/ui/src/components/copilot/config-form/index.tsx @@ -9,8 +9,9 @@ /* eslint-disable react-refresh/only-export-components */ import { Skeleton } from '@/components/ui/skeleton'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Code2 } from 'lucide-react'; +import { AlertTriangle, Code2 } from 'lucide-react'; import { ConfirmDialog } from '@/components/shared/confirm-dialog'; import { HeaderSection } from './header-section'; @@ -43,6 +44,7 @@ export function CopilotConfigForm() { haikuModel, isRawJsonValid, hasChanges, + normalizationWarnings, conflictDialog, updateField, applyPreset, @@ -77,6 +79,26 @@ export function CopilotConfigForm() { onSave={handleSave} /> + {normalizationWarnings.length > 0 && ( +
+ + + Deprecated Copilot models detected + +

+ Loading this page did not rewrite your files. Save the Copilot configuration to + persist these replacements. +

+
+ {normalizationWarnings.map((warning) => ( +

{warning.message}

+ ))} +
+
+
+
+ )} + {/* Split Layout */}
{/* Left Column: Friendly UI */} diff --git a/ui/src/components/copilot/config-form/use-copilot-config-form.ts b/ui/src/components/copilot/config-form/use-copilot-config-form.ts index 461c5688..32b5ccfd 100644 --- a/ui/src/components/copilot/config-form/use-copilot-config-form.ts +++ b/ui/src/components/copilot/config-form/use-copilot-config-form.ts @@ -5,6 +5,7 @@ import { useState, useMemo, useCallback } from 'react'; import { useCopilot } from '@/hooks/use-copilot'; +import type { CopilotNormalizationWarning } from '@/hooks/use-copilot'; import { isApiConflictError } from '@/lib/api-client'; import { toast } from 'sonner'; import type { ModelPreset } from './types'; @@ -18,6 +19,17 @@ function checkMissingFields(settings: { env?: Record } | undefin return REQUIRED_ENV_KEYS.filter((key) => !env[key]?.trim()); } +function dedupeWarnings( + warnings: CopilotNormalizationWarning[] | undefined +): CopilotNormalizationWarning[] { + if (!warnings || warnings.length === 0) return []; + const unique = new Map(); + warnings.forEach((warning) => { + unique.set(warning.message, warning); + }); + return [...unique.values()]; +} + export function useCopilotConfigForm() { const { config, @@ -128,11 +140,20 @@ export function useCopilotConfigForm() { [currentSettingsForValidation] ); - const handleSave = async () => { + const normalizationWarnings = useMemo( + () => dedupeWarnings([...(config?.warnings ?? []), ...(rawSettings?.warnings ?? [])]), + [config?.warnings, rawSettings?.warnings] + ); + + const handleSave = async ({ + overwriteRawSettings = false, + }: { overwriteRawSettings?: boolean } = {}) => { try { + const saveWarnings: CopilotNormalizationWarning[] = []; + // Save config changes if (Object.keys(localOverrides).length > 0) { - await updateConfigAsync({ + const configResult = await updateConfigAsync({ enabled, auto_start: autoStart, port, @@ -144,26 +165,39 @@ export function useCopilotConfigForm() { sonnet_model: sonnetModel || undefined, haiku_model: haikuModel || undefined, }); + saveWarnings.push(...(configResult.warnings ?? [])); } // Save raw JSON changes (no blocking validation - runtime uses defaults) + let missing: string[] = []; if (rawJsonEdits !== null && isRawJsonValid) { const settingsToSave = JSON.parse(rawJsonContent); - const missing = checkMissingFields(settingsToSave); + missing = checkMissingFields(settingsToSave); - await saveRawSettingsAsync({ + const saveResult = await saveRawSettingsAsync({ settings: settingsToSave, - expectedMtime: rawSettings?.mtime, + expectedMtime: overwriteRawSettings ? undefined : rawSettings?.mtime, }); + saveWarnings.push(...(saveResult.warnings ?? [])); + } - // Show warning if fields missing - if (missing.length > 0) { - toast.success('Copilot configuration saved', { - description: `Missing fields will use defaults: ${missing.join(', ')}`, - }); - } else { - toast.success('Copilot configuration saved'); - } + const uniqueWarnings = dedupeWarnings(saveWarnings); + const descriptions: string[] = []; + if (uniqueWarnings.length > 0) { + descriptions.push(uniqueWarnings.map((warning) => warning.message).join(' ')); + } + if (missing.length > 0) { + descriptions.push(`Missing fields will use defaults: ${missing.join(', ')}`); + } + + if (uniqueWarnings.length > 0) { + toast.warning('Copilot configuration saved with model adjustments', { + description: descriptions.join(' '), + }); + } else if (descriptions.length > 0) { + toast.success('Copilot configuration saved', { + description: descriptions.join(' '), + }); } else { toast.success('Copilot configuration saved'); } @@ -183,8 +217,7 @@ export function useCopilotConfigForm() { const handleConflictResolve = async (overwrite: boolean) => { setConflictDialog(false); if (overwrite) { - await refetchRawSettings(); - handleSave(); + await handleSave({ overwriteRawSettings: true }); } else { setRawJsonEdits(null); } @@ -217,6 +250,7 @@ export function useCopilotConfigForm() { haikuModel, isRawJsonValid, hasChanges, + normalizationWarnings, // Dialog state conflictDialog, diff --git a/ui/src/hooks/use-copilot.ts b/ui/src/hooks/use-copilot.ts index 6b497e54..300f6558 100644 --- a/ui/src/hooks/use-copilot.ts +++ b/ui/src/hooks/use-copilot.ts @@ -9,6 +9,14 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { ApiConflictError, withApiBase } from '@/lib/api-client'; // Types +export interface CopilotNormalizationWarning { + source: 'config' | 'settings'; + tier: 'default' | 'opus' | 'sonnet' | 'haiku'; + original: string; + replacement: string; + message: string; +} + export interface CopilotStatus { enabled: boolean; installed: boolean; @@ -23,6 +31,10 @@ export interface CopilotStatus { wait_on_limit: boolean; } +export interface CopilotStatusResponse extends CopilotStatus { + warnings?: CopilotNormalizationWarning[]; +} + export interface CopilotInfo { installed: boolean; version: string | null; @@ -51,6 +63,10 @@ export interface CopilotConfig { haiku_model?: string; } +export interface CopilotConfigResponse extends CopilotConfig { + warnings?: CopilotNormalizationWarning[]; +} + /** GitHub Copilot plan tiers */ export type CopilotPlanTier = 'free' | 'pro' | 'pro+' | 'business' | 'enterprise'; @@ -80,25 +96,33 @@ export interface CopilotRawSettings { settings: { env?: Record; }; + effectiveSettings?: { + env?: Record; + }; mtime: number; path: string; exists: boolean; + warnings?: CopilotNormalizationWarning[]; } // API functions -async function fetchCopilotStatus(): Promise { +async function fetchCopilotStatus(): Promise { const res = await fetch(withApiBase('/copilot/status')); if (!res.ok) throw new Error('Failed to fetch copilot status'); return res.json(); } -async function fetchCopilotConfig(): Promise { +async function fetchCopilotConfig(): Promise { const res = await fetch(withApiBase('/copilot/config')); if (!res.ok) throw new Error('Failed to fetch copilot config'); return res.json(); } -async function fetchCopilotModels(): Promise<{ models: CopilotModel[]; current: string }> { +async function fetchCopilotModels(): Promise<{ + models: CopilotModel[]; + current: string; + warnings?: CopilotNormalizationWarning[]; +}> { const res = await fetch(withApiBase('/copilot/models')); if (!res.ok) throw new Error('Failed to fetch copilot models'); return res.json(); @@ -110,7 +134,11 @@ async function fetchCopilotRawSettings(): Promise { return res.json(); } -async function updateCopilotConfig(config: Partial): Promise<{ success: boolean }> { +async function updateCopilotConfig(config: Partial): Promise<{ + success: boolean; + copilot: CopilotConfig; + warnings?: CopilotNormalizationWarning[]; +}> { const res = await fetch(withApiBase('/copilot/config'), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -123,7 +151,12 @@ async function updateCopilotConfig(config: Partial): Promise<{ su async function saveCopilotRawSettings(data: { settings: CopilotRawSettings['settings']; expectedMtime?: number; -}): Promise<{ success: boolean; mtime: number }> { +}): Promise<{ + success: boolean; + mtime: number; + settings?: CopilotRawSettings['settings']; + warnings?: CopilotNormalizationWarning[]; +}> { const res = await fetch(withApiBase('/copilot/settings/raw'), { method: 'PUT', headers: { 'Content-Type': 'application/json' },