diff --git a/README.md b/README.md index 38321178..6744c337 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,8 @@ The dashboard provides visual management for all account types: **Ollama Integration**: Run local open-source models (qwen3-coder, gpt-oss:20b) with full privacy. Use `ccs api create --preset ollama` - requires [Ollama v0.14.0+](https://ollama.com) installed. For cloud models, use `ccs api create --preset ollama-cloud`. +> **Copilot config behavior:** Opening the dashboard or other read-only Copilot endpoints does not rewrite `~/.ccs/copilot.settings.json`. If CCS detects deprecated Copilot model IDs such as `raptor-mini`, it shows warnings immediately and only persists replacements when you explicitly save the Copilot configuration. + **llama.cpp Integration**: Run a local llama.cpp OpenAI-compatible server and create a profile with `ccs api create --preset llamacpp`. CCS defaults to `http://127.0.0.1:8080`, matching the standard llama.cpp server port. **Azure Foundry**: Use `ccs api create --preset foundry` to set up Claude via Microsoft Azure AI Foundry. Requires Azure resource and API key from [ai.azure.com](https://ai.azure.com). diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 4c9d5930..04b132e6 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -25,6 +25,7 @@ import { getCcsDir } from '../utils/config-manager'; import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat'; import type { CLIProxyProvider } from '../cliproxy/types'; import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities'; +import { normalizeCopilotModelId } from '../copilot/copilot-model-normalizer'; import type { TargetType } from '../targets/target-adapter'; import type { ProfileType } from '../types/profile'; export type { ProfileType } from '../types/profile'; @@ -446,8 +447,9 @@ class ProfileDetector { if (unifiedConfig) { // Copilot profile (if enabled) if (unifiedConfig.copilot?.enabled) { + const currentCopilotModel = normalizeCopilotModelId(unifiedConfig.copilot.model); lines.push('GitHub Copilot (via copilot-api):'); - lines.push(` - copilot (model: ${unifiedConfig.copilot.model})`); + lines.push(` - copilot (model: ${currentCopilotModel})`); } // CLIProxy variants from unified config diff --git a/src/commands/copilot-command.ts b/src/commands/copilot-command.ts index 9fbf0c1a..40d36bcc 100644 --- a/src/commands/copilot-command.ts +++ b/src/commands/copilot-command.ts @@ -12,11 +12,12 @@ import { stopDaemon, getAvailableModels, isCopilotApiInstalled, + 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'; /** @@ -55,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. */ @@ -124,8 +137,8 @@ async function handleAuth(): Promise { * Handle status subcommand. */ async function handleStatus(): 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); @@ -182,8 +195,8 @@ async function handleStatus(): Promise { * Handle models subcommand. */ async function handleModels(): Promise { - const config = loadOrCreateUnifiedConfig(); - const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; + const { config: copilotConfig, warnings } = loadCopilotConfigWithWarnings(); + printCopilotWarnings(warnings.map((warning) => warning.message)); console.log('Available Copilot Models'); console.log('────────────────────────'); @@ -273,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) { @@ -311,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 7225b5ed..8f885cbd 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -11,8 +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 { 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'; @@ -21,16 +22,17 @@ import { stripClaudeCodeEnv } from '../utils/shell-executor'; * Get full copilot status (auth + daemon). */ export async function getCopilotStatus(config: CopilotConfig): Promise { + const normalizedConfig = normalizeCopilotConfigWithWarnings(config).config; const [auth, daemonRunning] = await Promise.all([ checkAuthStatus(), - isDaemonRunning(config.port), + isDaemonRunning(normalizedConfig.port), ]); return { auth, daemon: { running: daemonRunning, - port: config.port, + port: normalizedConfig.port, }, }; } @@ -43,17 +45,19 @@ export function generateCopilotEnv( config: CopilotConfig, claudeConfigDir?: string ): Record { + const normalizedConfig = normalizeCopilotConfigWithWarnings(config).config; + // Use mapped models if configured, otherwise fall back to default model - const opusModel = config.opus_model || config.model; - const sonnetModel = config.sonnet_model || config.model; - const haikuModel = config.haiku_model || config.model; + const opusModel = normalizedConfig.opus_model || normalizedConfig.model; + const sonnetModel = normalizedConfig.sonnet_model || normalizedConfig.model; + const haikuModel = normalizedConfig.haiku_model || normalizedConfig.model; // Use 127.0.0.1 instead of localhost for more reliable local connections // (bypasses DNS resolution and potential IPv6 issues) return { - ANTHROPIC_BASE_URL: `http://127.0.0.1:${config.port}`, + ANTHROPIC_BASE_URL: `http://127.0.0.1:${normalizedConfig.port}`, ANTHROPIC_AUTH_TOKEN: 'dummy', // copilot-api handles auth internally - ANTHROPIC_MODEL: config.model, + ANTHROPIC_MODEL: normalizedConfig.model, // Model tier mapping - allows different models for opus/sonnet/haiku ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel, ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel, @@ -79,6 +83,16 @@ export async function executeCopilotProfile( claudeConfigDir?: string, claudeCliPath: string = 'claude' ): Promise { + 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 { await ensureCopilotApi(); @@ -111,17 +125,17 @@ export async function executeCopilotProfile( } // Check if daemon is running or needs to be started - let daemonRunning = await isDaemonRunning(config.port); + let daemonRunning = await isDaemonRunning(normalizedConfig.port); if (!daemonRunning) { - if (config.auto_start) { + if (normalizedConfig.auto_start) { console.log(info('Starting copilot-api daemon...')); - const result = await startDaemon(config); + const result = await startDaemon(normalizedConfig); if (!result.success) { console.error(fail(`Failed to start daemon: ${result.error}`)); return 1; } - console.log(ok(`Daemon started on port ${config.port}`)); + console.log(ok(`Daemon started on port ${normalizedConfig.port}`)); daemonRunning = true; } else { console.error(fail('copilot-api daemon is not running.')); @@ -129,7 +143,7 @@ export async function executeCopilotProfile( console.error('Start the daemon:'); console.error(' ccs copilot start'); console.error('Fallback manual command:'); - console.error(` npx copilot-api start --port ${config.port}`); + console.error(` npx copilot-api start --port ${normalizedConfig.port}`); console.error(''); console.error('Or enable auto_start in config:'); console.error(' ccs config (then enable auto_start in Copilot section)'); @@ -138,7 +152,7 @@ export async function executeCopilotProfile( } // Generate environment for Claude - const copilotEnv = generateCopilotEnv(config, claudeConfigDir); + const copilotEnv = generateCopilotEnv(normalizedConfig, claudeConfigDir); // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles const globalEnvConfig = getGlobalEnvConfig(); @@ -156,7 +170,7 @@ export async function executeCopilotProfile( CCS_PROFILE_TYPE: 'copilot', }); - console.log(info(`Using GitHub Copilot proxy (model: ${config.model})`)); + console.log(info(`Using GitHub Copilot proxy (model: ${normalizedConfig.model})`)); console.log(''); // Spawn Claude CLI diff --git a/src/copilot/copilot-model-normalizer.ts b/src/copilot/copilot-model-normalizer.ts new file mode 100644 index 00000000..a2dc3517 --- /dev/null +++ b/src/copilot/copilot-model-normalizer.ts @@ -0,0 +1,271 @@ +import { DEFAULT_COPILOT_CONFIG, type CopilotConfig } from '../config/unified-config-types'; + +const LEGACY_COPILOT_MODEL_FALLBACKS: Readonly> = Object.freeze({ + // copilot-api v0.7.0 no longer advertises this ID in /v1/models and rejects it at runtime. + 'raptor-mini': DEFAULT_COPILOT_CONFIG.model, +}); + +export const DEPRECATED_COPILOT_MODEL_IDS = Object.freeze( + Object.keys(LEGACY_COPILOT_MODEL_FALLBACKS) +); + +export type CopilotNormalizationSource = 'config' | 'settings'; +export type CopilotNormalizationTier = 'default' | 'opus' | 'sonnet' | 'haiku'; + +export interface CopilotNormalizationWarning { + source: CopilotNormalizationSource; + tier: CopilotNormalizationTier; + original: string; + replacement: string; + message: string; +} + +export interface CopilotSettingsPayload { + env?: Record; + [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 { + return normalizeCopilotSettingsWithWarnings(settings, fallbackConfig).settings; +} diff --git a/src/copilot/copilot-models.ts b/src/copilot/copilot-models.ts index 667e6d06..a97c6ef7 100644 --- a/src/copilot/copilot-models.ts +++ b/src/copilot/copilot-models.ts @@ -155,16 +155,6 @@ export const DEFAULT_COPILOT_MODELS: CopilotModel[] = [ minPlan: 'pro', multiplier: 0.25, }, - - // Fine-tuned Models - { - id: 'raptor-mini', - name: 'Raptor Mini', - provider: 'openai', - minPlan: 'free', - multiplier: 0, - preview: true, - }, ]; /** diff --git a/src/copilot/index.ts b/src/copilot/index.ts index e692e09d..9323d9d3 100644 --- a/src/copilot/index.ts +++ b/src/copilot/index.ts @@ -44,6 +44,16 @@ export { getDefaultModel, } from './copilot-models'; +// Normalization +export { + DEPRECATED_COPILOT_MODEL_IDS, + normalizeCopilotConfig, + normalizeCopilotConfigWithWarnings, + normalizeCopilotModelId, + normalizeCopilotSettings, + normalizeCopilotSettingsWithWarnings, +} from './copilot-model-normalizer'; + // Usage export { normalizeCopilotUsage, diff --git a/src/web-server/routes/copilot-routes.ts b/src/web-server/routes/copilot-routes.ts index 625c160c..4c85d284 100644 --- a/src/web-server/routes/copilot-routes.ts +++ b/src/web-server/routes/copilot-routes.ts @@ -18,8 +18,12 @@ import { getCopilotApiInfo, getInstalledVersion as getCopilotInstalledVersion, } from '../../copilot'; -import { DEFAULT_COPILOT_CONFIG } from '../../config/unified-config-types'; -import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader'; +import { + normalizeCopilotConfigWithWarnings, + type CopilotNormalizationWarning, +} from '../../copilot/copilot-model-normalizer'; +import { DEFAULT_COPILOT_CONFIG, type CopilotConfig } from '../../config/unified-config-types'; +import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; import copilotSettingsRoutes from './copilot-settings-routes'; const router = Router(); @@ -39,13 +43,20 @@ function parseOptionalModel(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } +function loadEffectiveCopilotConfig(): { + config: CopilotConfig; + warnings: CopilotNormalizationWarning[]; +} { + const config = loadOrCreateUnifiedConfig(); + return normalizeCopilotConfigWithWarnings(config.copilot ?? DEFAULT_COPILOT_CONFIG); +} + /** * GET /api/copilot/status - Get Copilot status (auth + daemon + install info) */ router.get('/status', async (_req: Request, res: Response): Promise => { try { - const config = loadOrCreateUnifiedConfig(); - const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; + const { config: copilotConfig, warnings } = loadEffectiveCopilotConfig(); const status = await getCopilotStatus(copilotConfig); const installed = isCopilotApiInstalled(); const version = getCopilotInstalledVersion(); @@ -62,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 }); @@ -73,9 +85,8 @@ router.get('/status', async (_req: Request, res: Response): Promise => { */ router.get('/config', (_req: Request, res: Response): void => { try { - const config = loadOrCreateUnifiedConfig(); - const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; - res.json(copilotConfig); + const { config: copilotConfig, warnings } = loadEffectiveCopilotConfig(); + res.json({ ...copilotConfig, warnings }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } @@ -197,46 +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 - config.copilot = { - 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; + }); - saveUnifiedConfig(config); - res.json({ success: true, copilot: config.copilot }); + res.json({ success: true, copilot: nextCopilotConfig, warnings }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } @@ -271,9 +287,9 @@ router.get('/auth/status', async (_req: Request, res: Response): Promise = */ router.get('/models', async (_req: Request, res: Response): Promise => { try { - const config = loadOrCreateUnifiedConfig(); - const port = config.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port; - const currentModel = config.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model; + const { config: copilotConfig, warnings } = loadEffectiveCopilotConfig(); + const port = copilotConfig.port; + const currentModel = copilotConfig.model; const models = await getCopilotModels(port); const modelsWithCurrent = models.map((m) => ({ @@ -281,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 }); } @@ -292,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) { @@ -313,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 }); } @@ -324,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 19165f2e..015da32b 100644 --- a/src/web-server/routes/copilot-settings-routes.ts +++ b/src/web-server/routes/copilot-settings-routes.ts @@ -5,12 +5,63 @@ import { Router, Request, Response } from 'express'; import * as fs from 'fs'; import * as path from 'path'; +import { + normalizeCopilotConfigWithWarnings, + normalizeCopilotSettingsWithWarnings, +} from '../../copilot/copilot-model-normalizer'; +import { DEFAULT_COPILOT_CONFIG, type CopilotConfig } from '../../config/unified-config-types'; +import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; import { getCcsDir } from '../../utils/config-manager'; -import { DEFAULT_COPILOT_CONFIG } from '../../config/unified-config-types'; -import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader'; const router = Router(); +function serializeSettings(settings: Record): string { + return JSON.stringify(settings, null, 2) + '\n'; +} + +function writeSettingsFile( + settingsPath: string, + settings: Record, + previousContent?: string +): void { + const nextContent = serializeSettings(settings); + if (previousContent === nextContent) return; + const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`; + fs.writeFileSync(tempPath, nextContent); + fs.renameSync(tempPath, settingsPath); +} + +function restoreSettingsFile( + settingsPath: string, + previousContent: string | undefined, + existedBefore: boolean +): void { + 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, + }, + }; +} + /** * GET /api/copilot/settings/raw - Get raw copilot.settings.json * Returns the raw JSON content for editing in the code editor @@ -19,44 +70,39 @@ router.get('/raw', (_req: Request, res: Response): void => { try { const settingsPath = path.join(getCcsDir(), 'copilot.settings.json'); const config = loadOrCreateUnifiedConfig(); - const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; + 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 = { - 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, - }, - }; + 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 = JSON.parse(content); + 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 }); @@ -72,6 +118,16 @@ router.put('/raw', (req: Request, res: Response): void => { const { settings, expectedMtime } = req.body; const settingsPath = path.join(getCcsDir(), 'copilot.settings.json'); + 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) { const stat = fs.statSync(settingsPath); @@ -81,26 +137,40 @@ router.put('/raw', (req: Request, res: Response): void => { } } - // Write settings file atomically - const tempPath = settingsPath + '.tmp'; - fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); - fs.renameSync(tempPath, settingsPath); + const settingsResult = normalizeCopilotSettingsWithWarnings( + settings as Record, + configResult.config + ); + const existedBefore = fs.existsSync(settingsPath); + const previousContent = existedBefore ? fs.readFileSync(settingsPath, 'utf-8') : undefined; + writeSettingsFile( + settingsPath, + settingsResult.settings as Record, + previousContent + ); - // Also sync model mappings back to unified config - const config = loadOrCreateUnifiedConfig(); - const env = settings.env || {}; - - config.copilot = { - ...(config.copilot ?? DEFAULT_COPILOT_CONFIG), - model: env.ANTHROPIC_MODEL || config.copilot?.model || DEFAULT_COPILOT_CONFIG.model, - opus_model: env.ANTHROPIC_DEFAULT_OPUS_MODEL || undefined, - sonnet_model: env.ANTHROPIC_DEFAULT_SONNET_MODEL || undefined, - haiku_model: 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 86b3db96..233c1e42 100644 --- a/tests/unit/copilot/copilot-executor-env.test.ts +++ b/tests/unit/copilot/copilot-executor-env.test.ts @@ -13,6 +13,34 @@ const baseConfig: CopilotConfig = { }; describe('generateCopilotEnv', () => { + it('normalizes deprecated raptor-mini model selections to the safe default', () => { + const env = generateCopilotEnv({ + ...baseConfig, + model: 'raptor-mini', + opus_model: 'raptor-mini', + sonnet_model: 'raptor-mini', + haiku_model: 'raptor-mini', + }); + + expect(env.ANTHROPIC_MODEL).toBe('gpt-4.1'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-4.1'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-4.1'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-4.1'); + 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'); @@ -23,4 +51,3 @@ describe('generateCopilotEnv', () => { expect(env.CLAUDE_CONFIG_DIR).toBeUndefined(); }); }); - diff --git a/tests/unit/copilot/copilot-model-normalizer.test.ts b/tests/unit/copilot/copilot-model-normalizer.test.ts new file mode 100644 index 00000000..016f12ec --- /dev/null +++ b/tests/unit/copilot/copilot-model-normalizer.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'bun:test'; +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', () => { + it('tracks raptor-mini as deprecated', () => { + expect(DEPRECATED_COPILOT_MODEL_IDS).toContain('raptor-mini'); + }); + + it('normalizes deprecated model IDs to the safe default', () => { + expect(normalizeCopilotModelId('raptor-mini')).toBe(DEFAULT_COPILOT_CONFIG.model); + }); + + it('falls back deprecated tier overrides to the normalized base model', () => { + const result = normalizeCopilotConfigWithWarnings({ + ...DEFAULT_COPILOT_CONFIG, + model: 'claude-sonnet-4.5', + haiku_model: 'raptor-mini', + }); + + 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', () => { + const normalized = normalizeCopilotSettings( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:4141', + ANTHROPIC_MODEL: 'raptor-mini', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'raptor-mini', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'raptor-mini', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'raptor-mini', + EXTRA_FLAG: 'keep-me', + }, + }, + { + ...DEFAULT_COPILOT_CONFIG, + model: 'raptor-mini', + } + ); + + expect(normalized.env?.ANTHROPIC_MODEL).toBe('gpt-4.1'); + expect(normalized.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-4.1'); + expect(normalized.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-4.1'); + 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/copilot/copilot-models.test.ts b/tests/unit/copilot/copilot-models.test.ts index c7074ced..b82cb6fa 100644 --- a/tests/unit/copilot/copilot-models.test.ts +++ b/tests/unit/copilot/copilot-models.test.ts @@ -3,6 +3,10 @@ import * as http from 'http'; import { DEFAULT_COPILOT_MODELS, fetchModelsFromDaemon } from '../../../src/copilot/copilot-models'; describe('fetchModelsFromDaemon', () => { + it('does not advertise deprecated fallback models', () => { + expect(DEFAULT_COPILOT_MODELS.some((model) => model.id === 'raptor-mini')).toBe(false); + }); + it('falls back to defaults when daemon is unreachable', async () => { const models = await fetchModelsFromDaemon(9999); expect(models).toEqual(DEFAULT_COPILOT_MODELS); diff --git a/tests/unit/web-server/copilot-routes.test.ts b/tests/unit/web-server/copilot-routes.test.ts new file mode 100644 index 00000000..897f4476 --- /dev/null +++ b/tests/unit/web-server/copilot-routes.test.ts @@ -0,0 +1,311 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +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 = ''; +let tempDir = ''; +let originalCcsHome: string | undefined; + +let setGlobalConfigDir: (dir: string | undefined) => void; +let getCcsDir: () => string; +let loadOrCreateUnifiedConfig: () => { + copilot?: { + enabled?: boolean; + auto_start?: boolean; + port?: number; + account_type?: 'individual' | 'business' | 'enterprise'; + rate_limit?: number | null; + wait_on_limit?: boolean; + model?: string; + opus_model?: string; + sonnet_model?: string; + haiku_model?: string; + }; +}; +let saveUnifiedConfig: (config: { + copilot?: { + enabled?: boolean; + auto_start?: boolean; + port?: number; + account_type?: 'individual' | 'business' | 'enterprise'; + rate_limit?: number | null; + wait_on_limit?: boolean; + model?: string; + opus_model?: string; + sonnet_model?: string; + haiku_model?: string; + }; +}) => void; + +function seedCopilotConfig( + overrides: { + enabled?: boolean; + auto_start?: boolean; + port?: number; + account_type?: 'individual' | 'business' | 'enterprise'; + rate_limit?: number | null; + wait_on_limit?: boolean; + model?: string; + opus_model?: string; + sonnet_model?: string; + haiku_model?: string; + } = {} +): void { + const config = loadOrCreateUnifiedConfig(); + config.copilot = { + enabled: overrides.enabled ?? true, + auto_start: overrides.auto_start ?? false, + port: overrides.port ?? 4141, + account_type: overrides.account_type ?? 'individual', + rate_limit: overrides.rate_limit ?? null, + wait_on_limit: overrides.wait_on_limit ?? true, + model: overrides.model ?? 'gpt-4.1', + opus_model: overrides.opus_model, + sonnet_model: overrides.sonnet_model, + haiku_model: overrides.haiku_model, + }; + saveUnifiedConfig(config); +} + +function getCopilotSettingsPath(): string { + return path.join(getCcsDir(), 'copilot.settings.json'); +} + +function writeCopilotSettings(settings: Record): void { + fs.writeFileSync(getCopilotSettingsPath(), JSON.stringify(settings, null, 2) + '\n'); +} + +beforeAll(async () => { + originalCcsHome = process.env.CCS_HOME; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-copilot-routes-test-')); + process.env.CCS_HOME = tempDir; + + const configManager = await import('../../../src/utils/config-manager'); + setGlobalConfigDir = configManager.setGlobalConfigDir; + getCcsDir = configManager.getCcsDir; + setGlobalConfigDir(undefined); + + const unifiedConfig = await import('../../../src/config/unified-config-loader'); + loadOrCreateUnifiedConfig = unifiedConfig.loadOrCreateUnifiedConfig; + saveUnifiedConfig = unifiedConfig.saveUnifiedConfig; + + const copilotRoutesModule = await import('../../../src/web-server/routes/copilot-routes'); + + const app = express(); + app.use(express.json()); + app.use('/api/copilot', copilotRoutesModule.default); + + server = app.listen(0, '127.0.0.1'); + await new Promise((resolve) => server.on('listening', () => resolve())); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + baseUrl = `http://127.0.0.1:${address.port}`; +}); + +beforeEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + fs.mkdirSync(tempDir, { recursive: true }); + + setGlobalConfigDir(undefined); + const ccsDir = getCcsDir(); + if (!fs.existsSync(ccsDir)) { + fs.mkdirSync(ccsDir, { recursive: true }); + } + + seedCopilotConfig(); +}); + +afterAll(async () => { + if (server) { + await new Promise((resolve) => server.close(() => resolve())); + } + + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + setGlobalConfigDir(undefined); + + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe('Copilot Routes', () => { + it('GET /api/copilot/config returns normalized values and warnings without persisting', async () => { + seedCopilotConfig({ + model: 'claude-sonnet-4.5', + haiku_model: 'raptor-mini', + }); + + const response = await fetch(`${baseUrl}/api/copilot/config`); + expect(response.status).toBe(200); + + 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('claude-sonnet-4.5'); + expect(body.haiku_model).toBeUndefined(); + expect(body.warnings ?? []).toHaveLength(0); + + const persisted = loadOrCreateUnifiedConfig().copilot; + expect(persisted?.model).toBe('claude-sonnet-4.5'); + expect(persisted?.haiku_model).toBeUndefined(); + }); + + 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: 'claude-sonnet-4.5', + haiku_model: 'raptor-mini', + }), + }); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + success: boolean; + warnings?: Array<{ tier: string; replacement: string }>; + copilot: { + model: string; + opus_model?: string; + sonnet_model?: string; + haiku_model?: string; + }; + }; + expect(body.success).toBe(true); + 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('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 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_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); + + const body = (await response.json()) as { + settings: { + env: Record; + }; + effectiveSettings: { + env: Record; + }; + exists: boolean; + warnings?: Array<{ tier: string }>; + }; + expect(body.exists).toBe(true); + 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']); + + 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 () => { + const response = await fetch(`${baseUrl}/api/copilot/settings/raw`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + settings: { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:4141', + ANTHROPIC_AUTH_TOKEN: 'copilot-managed', + 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('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('claude-sonnet-4.5'); + expect( + normalizeCopilotConfig(persistedConfig ?? { ...DEFAULT_COPILOT_CONFIG }).haiku_model + ).toBeUndefined(); + expect(persistedConfig?.haiku_model).not.toBe('raptor-mini'); + }); + + 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`); + expect(response.status).toBe(200); + + 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(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/presets.ts b/ui/src/components/copilot/config-form/presets.ts index e7aad884..f882f932 100644 --- a/ui/src/components/copilot/config-form/presets.ts +++ b/ui/src/components/copilot/config-form/presets.ts @@ -23,14 +23,6 @@ export const FREE_PRESETS: ModelPreset[] = [ sonnet: 'gpt-5-mini', haiku: 'gpt-5-mini', }, - { - name: 'Raptor Mini (Free)', - description: 'Free tier - fine-tuned for coding', - default: 'raptor-mini', - opus: 'raptor-mini', - sonnet: 'raptor-mini', - haiku: 'raptor-mini', - }, ]; export const PAID_PRESETS: ModelPreset[] = [ 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' },