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..4befa207 100644 --- a/src/commands/copilot-command.ts +++ b/src/commands/copilot-command.ts @@ -12,6 +12,7 @@ import { stopDaemon, getAvailableModels, isCopilotApiInstalled, + normalizeCopilotConfig, } from '../copilot'; import type { CopilotModel } from '../copilot'; import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../config/unified-config-loader'; @@ -125,7 +126,7 @@ async function handleAuth(): Promise { */ async function handleStatus(): Promise { const config = loadOrCreateUnifiedConfig(); - const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; + const copilotConfig = normalizeCopilotConfig(config.copilot ?? DEFAULT_COPILOT_CONFIG); const status = await getCopilotStatus(copilotConfig); @@ -183,7 +184,7 @@ async function handleStatus(): Promise { */ async function handleModels(): Promise { const config = loadOrCreateUnifiedConfig(); - const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; + const copilotConfig = normalizeCopilotConfig(config.copilot ?? DEFAULT_COPILOT_CONFIG); console.log('Available Copilot Models'); console.log('────────────────────────'); diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index 7225b5ed..0a3b954f 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -11,6 +11,7 @@ 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 { CopilotStatus } from './types'; import { fail, info, ok } from '../utils/ui'; import { getWebSearchHookEnv } from '../utils/websearch-manager'; @@ -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 = normalizeCopilotConfig(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 = normalizeCopilotConfig(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,8 @@ export async function executeCopilotProfile( claudeConfigDir?: string, claudeCliPath: string = 'claude' ): Promise { + const normalizedConfig = normalizeCopilotConfig(config); + // Ensure copilot-api is installed (auto-install if missing, auto-update if outdated) try { await ensureCopilotApi(); @@ -111,17 +117,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 +135,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 +144,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 +162,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..94ffc916 --- /dev/null +++ b/src/copilot/copilot-model-normalizer.ts @@ -0,0 +1,97 @@ +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) +); + +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 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 CopilotSettingsPayload { + env?: Record; + [key: string]: unknown; +} + +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, + }, + }; +} 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..d3bbacc0 100644 --- a/src/copilot/index.ts +++ b/src/copilot/index.ts @@ -44,6 +44,14 @@ export { getDefaultModel, } from './copilot-models'; +// Normalization +export { + DEPRECATED_COPILOT_MODEL_IDS, + normalizeCopilotConfig, + normalizeCopilotModelId, + normalizeCopilotSettings, +} 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..8e7b7af8 100644 --- a/src/web-server/routes/copilot-routes.ts +++ b/src/web-server/routes/copilot-routes.ts @@ -18,7 +18,8 @@ import { getCopilotApiInfo, getInstalledVersion as getCopilotInstalledVersion, } from '../../copilot'; -import { DEFAULT_COPILOT_CONFIG } from '../../config/unified-config-types'; +import { normalizeCopilotConfig } 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 copilotSettingsRoutes from './copilot-settings-routes'; @@ -39,13 +40,30 @@ 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 { + const config = loadOrCreateUnifiedConfig(); + const copilotConfig = normalizeCopilotConfig(config.copilot ?? DEFAULT_COPILOT_CONFIG); + persistNormalizedCopilotConfig(config, copilotConfig); + return copilotConfig; +} + /** * 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 copilotConfig = loadNormalizedCopilotConfig(); const status = await getCopilotStatus(copilotConfig); const installed = isCopilotApiInstalled(); const version = getCopilotInstalledVersion(); @@ -73,9 +91,7 @@ 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); + res.json(loadNormalizedCopilotConfig()); } catch (error) { res.status(500).json({ error: (error as Error).message }); } @@ -200,7 +216,7 @@ router.put('/config', (req: Request, res: Response): void => { const config = loadOrCreateUnifiedConfig(); // Merge updates with existing config - config.copilot = { + const nextCopilotConfig = normalizeCopilotConfig({ enabled: (payload.enabled as boolean) ?? config.copilot?.enabled ?? DEFAULT_COPILOT_CONFIG.enabled, auto_start: @@ -233,10 +249,11 @@ router.put('/config', (req: Request, res: Response): void => { 'haiku_model' in payload ? parseOptionalModel(payload.haiku_model) : config.copilot?.haiku_model, - }; + }); + config.copilot = nextCopilotConfig; saveUnifiedConfig(config); - res.json({ success: true, copilot: config.copilot }); + res.json({ success: true, copilot: nextCopilotConfig }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } @@ -271,9 +288,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 copilotConfig = loadNormalizedCopilotConfig(); + const port = copilotConfig.port; + const currentModel = copilotConfig.model; const models = await getCopilotModels(port); const modelsWithCurrent = models.map((m) => ({ diff --git a/src/web-server/routes/copilot-settings-routes.ts b/src/web-server/routes/copilot-settings-routes.ts index 19165f2e..6c17a207 100644 --- a/src/web-server/routes/copilot-settings-routes.ts +++ b/src/web-server/routes/copilot-settings-routes.ts @@ -5,12 +5,43 @@ import { Router, Request, Response } from 'express'; import * as fs from 'fs'; import * as path from 'path'; +import { + normalizeCopilotConfig, + normalizeCopilotSettings, +} from '../../copilot/copilot-model-normalizer'; import { getCcsDir } from '../../utils/config-manager'; -import { DEFAULT_COPILOT_CONFIG } from '../../config/unified-config-types'; +import { DEFAULT_COPILOT_CONFIG, type CopilotConfig } 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}.tmp`; + fs.writeFileSync(tempPath, nextContent); + fs.renameSync(tempPath, settingsPath); +} + +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); +} + /** * GET /api/copilot/settings/raw - Get raw copilot.settings.json * Returns the raw JSON content for editing in the code editor @@ -19,7 +50,8 @@ 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 copilotConfig = normalizeCopilotConfig(config.copilot ?? DEFAULT_COPILOT_CONFIG); + persistNormalizedCopilotConfig(config, copilotConfig); // Default model for all tiers const defaultModel = copilotConfig.model; @@ -28,16 +60,19 @@ router.get('/raw', (_req: Request, res: Response): void => { 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 = 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 + ); res.json({ settings: defaultSettings, @@ -49,7 +84,33 @@ router.get('/raw', (_req: Request, res: Response): void => { } const content = fs.readFileSync(settingsPath, 'utf-8'); - const settings = JSON.parse(content); + 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 stat = fs.statSync(settingsPath); res.json({ @@ -71,6 +132,8 @@ 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); // Check for conflict if file exists and expectedMtime provided if (fs.existsSync(settingsPath) && expectedMtime) { @@ -81,22 +144,39 @@ 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 normalizedSettings = normalizeCopilotSettings( + settings as Record, + copilotConfig + ); + writeSettingsFile(settingsPath, normalizedSettings); // Also sync model mappings back to unified config - const config = loadOrCreateUnifiedConfig(); - const env = settings.env || {}; + const env = + normalizedSettings.env && + typeof normalizedSettings.env === 'object' && + !Array.isArray(normalizedSettings.env) + ? normalizedSettings.env + : {}; - config.copilot = { + config.copilot = normalizeCopilotConfig({ ...(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, - }; + 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); const stat = fs.statSync(settingsPath); diff --git a/tests/unit/copilot/copilot-executor-env.test.ts b/tests/unit/copilot/copilot-executor-env.test.ts index 86b3db96..cf92e27b 100644 --- a/tests/unit/copilot/copilot-executor-env.test.ts +++ b/tests/unit/copilot/copilot-executor-env.test.ts @@ -13,6 +13,22 @@ 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('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 +39,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..2b84c9fb --- /dev/null +++ b/tests/unit/copilot/copilot-model-normalizer.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'bun:test'; +import { DEFAULT_COPILOT_CONFIG } from '../../../src/config/unified-config-types'; +import { + DEPRECATED_COPILOT_MODEL_IDS, + normalizeCopilotConfig, + normalizeCopilotModelId, + normalizeCopilotSettings, +} 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('normalizes Copilot config tier mappings', () => { + const normalized = normalizeCopilotConfig({ + ...DEFAULT_COPILOT_CONFIG, + model: 'raptor-mini', + opus_model: 'raptor-mini', + sonnet_model: 'raptor-mini', + 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'); + }); + + 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'); + }); +}); 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..cd2a444b --- /dev/null +++ b/tests/unit/web-server/copilot-routes.test.ts @@ -0,0 +1,291 @@ +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'; + +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 normalizes and persists stale raptor-mini config', async () => { + seedCopilotConfig({ + model: 'raptor-mini', + opus_model: 'raptor-mini', + sonnet_model: 'raptor-mini', + 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; + 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.haiku_model).toBeUndefined(); + + 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(); + }); + + it('PUT /api/copilot/config normalizes stale raptor-mini payloads 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', + haiku_model: 'raptor-mini', + }), + }); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + success: boolean; + copilot: { + model: string; + opus_model?: string; + sonnet_model?: string; + haiku_model?: string; + }; + }; + 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'); + + 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(); + }); + + it('GET /api/copilot/settings/raw normalizes and persists stale raw settings', async () => { + seedCopilotConfig({ model: 'raptor-mini' }); + 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', + }, + }); + + const response = await fetch(`${baseUrl}/api/copilot/settings/raw`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + settings: { + env: Record; + }; + exists: boolean; + }; + 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'); + + 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'); + }); + + 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: 'raptor-mini', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'raptor-mini', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'raptor-mini', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'raptor-mini', + }, + }, + }), + }); + expect(response.status).toBe(200); + + 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'); + + 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(); + }); + + it('GET /api/copilot/models reports normalized current model for stale config', 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; + 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'); + }); +}); 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[] = [