fix: normalize stale copilot raptor-mini model

This commit is contained in:
Tam Nhu Tran
2026-03-10 17:55:28 +07:00
parent 447033cb03
commit c409a4522c
13 changed files with 633 additions and 72 deletions
+3 -1
View File
@@ -25,6 +25,7 @@ import { getCcsDir } from '../utils/config-manager';
import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat'; import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat';
import type { CLIProxyProvider } from '../cliproxy/types'; import type { CLIProxyProvider } from '../cliproxy/types';
import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities'; 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 { TargetType } from '../targets/target-adapter';
import type { ProfileType } from '../types/profile'; import type { ProfileType } from '../types/profile';
export type { ProfileType } from '../types/profile'; export type { ProfileType } from '../types/profile';
@@ -446,8 +447,9 @@ class ProfileDetector {
if (unifiedConfig) { if (unifiedConfig) {
// Copilot profile (if enabled) // Copilot profile (if enabled)
if (unifiedConfig.copilot?.enabled) { if (unifiedConfig.copilot?.enabled) {
const currentCopilotModel = normalizeCopilotModelId(unifiedConfig.copilot.model);
lines.push('GitHub Copilot (via copilot-api):'); lines.push('GitHub Copilot (via copilot-api):');
lines.push(` - copilot (model: ${unifiedConfig.copilot.model})`); lines.push(` - copilot (model: ${currentCopilotModel})`);
} }
// CLIProxy variants from unified config // CLIProxy variants from unified config
+3 -2
View File
@@ -12,6 +12,7 @@ import {
stopDaemon, stopDaemon,
getAvailableModels, getAvailableModels,
isCopilotApiInstalled, isCopilotApiInstalled,
normalizeCopilotConfig,
} from '../copilot'; } from '../copilot';
import type { CopilotModel } from '../copilot'; import type { CopilotModel } from '../copilot';
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../config/unified-config-loader'; import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../config/unified-config-loader';
@@ -125,7 +126,7 @@ async function handleAuth(): Promise<number> {
*/ */
async function handleStatus(): Promise<number> { async function handleStatus(): Promise<number> {
const config = loadOrCreateUnifiedConfig(); const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; const copilotConfig = normalizeCopilotConfig(config.copilot ?? DEFAULT_COPILOT_CONFIG);
const status = await getCopilotStatus(copilotConfig); const status = await getCopilotStatus(copilotConfig);
@@ -183,7 +184,7 @@ async function handleStatus(): Promise<number> {
*/ */
async function handleModels(): Promise<number> { async function handleModels(): Promise<number> {
const config = loadOrCreateUnifiedConfig(); 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('Available Copilot Models');
console.log('────────────────────────'); console.log('────────────────────────');
+20 -14
View File
@@ -11,6 +11,7 @@ import { getGlobalEnvConfig } from '../config/unified-config-loader';
import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth'; import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth';
import { isDaemonRunning, startDaemon } from './copilot-daemon'; import { isDaemonRunning, startDaemon } from './copilot-daemon';
import { ensureCopilotApi } from './copilot-package-manager'; import { ensureCopilotApi } from './copilot-package-manager';
import { normalizeCopilotConfig } from './copilot-model-normalizer';
import { CopilotStatus } from './types'; import { CopilotStatus } from './types';
import { fail, info, ok } from '../utils/ui'; import { fail, info, ok } from '../utils/ui';
import { getWebSearchHookEnv } from '../utils/websearch-manager'; import { getWebSearchHookEnv } from '../utils/websearch-manager';
@@ -21,16 +22,17 @@ import { stripClaudeCodeEnv } from '../utils/shell-executor';
* Get full copilot status (auth + daemon). * Get full copilot status (auth + daemon).
*/ */
export async function getCopilotStatus(config: CopilotConfig): Promise<CopilotStatus> { export async function getCopilotStatus(config: CopilotConfig): Promise<CopilotStatus> {
const normalizedConfig = normalizeCopilotConfig(config);
const [auth, daemonRunning] = await Promise.all([ const [auth, daemonRunning] = await Promise.all([
checkAuthStatus(), checkAuthStatus(),
isDaemonRunning(config.port), isDaemonRunning(normalizedConfig.port),
]); ]);
return { return {
auth, auth,
daemon: { daemon: {
running: daemonRunning, running: daemonRunning,
port: config.port, port: normalizedConfig.port,
}, },
}; };
} }
@@ -43,17 +45,19 @@ export function generateCopilotEnv(
config: CopilotConfig, config: CopilotConfig,
claudeConfigDir?: string claudeConfigDir?: string
): Record<string, string> { ): Record<string, string> {
const normalizedConfig = normalizeCopilotConfig(config);
// Use mapped models if configured, otherwise fall back to default model // Use mapped models if configured, otherwise fall back to default model
const opusModel = config.opus_model || config.model; const opusModel = normalizedConfig.opus_model || normalizedConfig.model;
const sonnetModel = config.sonnet_model || config.model; const sonnetModel = normalizedConfig.sonnet_model || normalizedConfig.model;
const haikuModel = config.haiku_model || config.model; const haikuModel = normalizedConfig.haiku_model || normalizedConfig.model;
// Use 127.0.0.1 instead of localhost for more reliable local connections // Use 127.0.0.1 instead of localhost for more reliable local connections
// (bypasses DNS resolution and potential IPv6 issues) // (bypasses DNS resolution and potential IPv6 issues)
return { 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_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 // Model tier mapping - allows different models for opus/sonnet/haiku
ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel, ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel, ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel,
@@ -79,6 +83,8 @@ export async function executeCopilotProfile(
claudeConfigDir?: string, claudeConfigDir?: string,
claudeCliPath: string = 'claude' claudeCliPath: string = 'claude'
): Promise<number> { ): Promise<number> {
const normalizedConfig = normalizeCopilotConfig(config);
// Ensure copilot-api is installed (auto-install if missing, auto-update if outdated) // Ensure copilot-api is installed (auto-install if missing, auto-update if outdated)
try { try {
await ensureCopilotApi(); await ensureCopilotApi();
@@ -111,17 +117,17 @@ export async function executeCopilotProfile(
} }
// Check if daemon is running or needs to be started // 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 (!daemonRunning) {
if (config.auto_start) { if (normalizedConfig.auto_start) {
console.log(info('Starting copilot-api daemon...')); console.log(info('Starting copilot-api daemon...'));
const result = await startDaemon(config); const result = await startDaemon(normalizedConfig);
if (!result.success) { if (!result.success) {
console.error(fail(`Failed to start daemon: ${result.error}`)); console.error(fail(`Failed to start daemon: ${result.error}`));
return 1; return 1;
} }
console.log(ok(`Daemon started on port ${config.port}`)); console.log(ok(`Daemon started on port ${normalizedConfig.port}`));
daemonRunning = true; daemonRunning = true;
} else { } else {
console.error(fail('copilot-api daemon is not running.')); console.error(fail('copilot-api daemon is not running.'));
@@ -129,7 +135,7 @@ export async function executeCopilotProfile(
console.error('Start the daemon:'); console.error('Start the daemon:');
console.error(' ccs copilot start'); console.error(' ccs copilot start');
console.error('Fallback manual command:'); 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('');
console.error('Or enable auto_start in config:'); console.error('Or enable auto_start in config:');
console.error(' ccs config (then enable auto_start in Copilot section)'); console.error(' ccs config (then enable auto_start in Copilot section)');
@@ -138,7 +144,7 @@ export async function executeCopilotProfile(
} }
// Generate environment for Claude // 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 // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles
const globalEnvConfig = getGlobalEnvConfig(); const globalEnvConfig = getGlobalEnvConfig();
@@ -156,7 +162,7 @@ export async function executeCopilotProfile(
CCS_PROFILE_TYPE: 'copilot', 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(''); console.log('');
// Spawn Claude CLI // Spawn Claude CLI
+97
View File
@@ -0,0 +1,97 @@
import { DEFAULT_COPILOT_CONFIG, type CopilotConfig } from '../config/unified-config-types';
const LEGACY_COPILOT_MODEL_FALLBACKS: Readonly<Record<string, string>> = 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<string, unknown>;
[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,
},
};
}
-10
View File
@@ -155,16 +155,6 @@ export const DEFAULT_COPILOT_MODELS: CopilotModel[] = [
minPlan: 'pro', minPlan: 'pro',
multiplier: 0.25, multiplier: 0.25,
}, },
// Fine-tuned Models
{
id: 'raptor-mini',
name: 'Raptor Mini',
provider: 'openai',
minPlan: 'free',
multiplier: 0,
preview: true,
},
]; ];
/** /**
+8
View File
@@ -44,6 +44,14 @@ export {
getDefaultModel, getDefaultModel,
} from './copilot-models'; } from './copilot-models';
// Normalization
export {
DEPRECATED_COPILOT_MODEL_IDS,
normalizeCopilotConfig,
normalizeCopilotModelId,
normalizeCopilotSettings,
} from './copilot-model-normalizer';
// Usage // Usage
export { export {
normalizeCopilotUsage, normalizeCopilotUsage,
+29 -12
View File
@@ -18,7 +18,8 @@ import {
getCopilotApiInfo, getCopilotApiInfo,
getInstalledVersion as getCopilotInstalledVersion, getInstalledVersion as getCopilotInstalledVersion,
} from '../../copilot'; } 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 { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader';
import copilotSettingsRoutes from './copilot-settings-routes'; import copilotSettingsRoutes from './copilot-settings-routes';
@@ -39,13 +40,30 @@ function parseOptionalModel(value: unknown): string | undefined {
return trimmed.length > 0 ? trimmed : undefined; return trimmed.length > 0 ? trimmed : undefined;
} }
function persistNormalizedCopilotConfig(
config: ReturnType<typeof loadOrCreateUnifiedConfig>,
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) * GET /api/copilot/status - Get Copilot status (auth + daemon + install info)
*/ */
router.get('/status', async (_req: Request, res: Response): Promise<void> => { router.get('/status', async (_req: Request, res: Response): Promise<void> => {
try { try {
const config = loadOrCreateUnifiedConfig(); const copilotConfig = loadNormalizedCopilotConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
const status = await getCopilotStatus(copilotConfig); const status = await getCopilotStatus(copilotConfig);
const installed = isCopilotApiInstalled(); const installed = isCopilotApiInstalled();
const version = getCopilotInstalledVersion(); const version = getCopilotInstalledVersion();
@@ -73,9 +91,7 @@ router.get('/status', async (_req: Request, res: Response): Promise<void> => {
*/ */
router.get('/config', (_req: Request, res: Response): void => { router.get('/config', (_req: Request, res: Response): void => {
try { try {
const config = loadOrCreateUnifiedConfig(); res.json(loadNormalizedCopilotConfig());
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
res.json(copilotConfig);
} catch (error) { } catch (error) {
res.status(500).json({ error: (error as Error).message }); res.status(500).json({ error: (error as Error).message });
} }
@@ -200,7 +216,7 @@ router.put('/config', (req: Request, res: Response): void => {
const config = loadOrCreateUnifiedConfig(); const config = loadOrCreateUnifiedConfig();
// Merge updates with existing config // Merge updates with existing config
config.copilot = { const nextCopilotConfig = normalizeCopilotConfig({
enabled: enabled:
(payload.enabled as boolean) ?? config.copilot?.enabled ?? DEFAULT_COPILOT_CONFIG.enabled, (payload.enabled as boolean) ?? config.copilot?.enabled ?? DEFAULT_COPILOT_CONFIG.enabled,
auto_start: auto_start:
@@ -233,10 +249,11 @@ router.put('/config', (req: Request, res: Response): void => {
'haiku_model' in payload 'haiku_model' in payload
? parseOptionalModel(payload.haiku_model) ? parseOptionalModel(payload.haiku_model)
: config.copilot?.haiku_model, : config.copilot?.haiku_model,
}; });
config.copilot = nextCopilotConfig;
saveUnifiedConfig(config); saveUnifiedConfig(config);
res.json({ success: true, copilot: config.copilot }); res.json({ success: true, copilot: nextCopilotConfig });
} catch (error) { } catch (error) {
res.status(500).json({ error: (error as Error).message }); res.status(500).json({ error: (error as Error).message });
} }
@@ -271,9 +288,9 @@ router.get('/auth/status', async (_req: Request, res: Response): Promise<void> =
*/ */
router.get('/models', async (_req: Request, res: Response): Promise<void> => { router.get('/models', async (_req: Request, res: Response): Promise<void> => {
try { try {
const config = loadOrCreateUnifiedConfig(); const copilotConfig = loadNormalizedCopilotConfig();
const port = config.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port; const port = copilotConfig.port;
const currentModel = config.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model; const currentModel = copilotConfig.model;
const models = await getCopilotModels(port); const models = await getCopilotModels(port);
const modelsWithCurrent = models.map((m) => ({ const modelsWithCurrent = models.map((m) => ({
+104 -24
View File
@@ -5,12 +5,43 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import {
normalizeCopilotConfig,
normalizeCopilotSettings,
} from '../../copilot/copilot-model-normalizer';
import { getCcsDir } from '../../utils/config-manager'; 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'; import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader';
const router = Router(); const router = Router();
function serializeSettings(settings: Record<string, unknown>): string {
return JSON.stringify(settings, null, 2) + '\n';
}
function writeSettingsFile(
settingsPath: string,
settings: Record<string, unknown>,
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<typeof loadOrCreateUnifiedConfig>,
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 * GET /api/copilot/settings/raw - Get raw copilot.settings.json
* Returns the raw JSON content for editing in the code editor * Returns the raw JSON content for editing in the code editor
@@ -19,7 +50,8 @@ router.get('/raw', (_req: Request, res: Response): void => {
try { try {
const settingsPath = path.join(getCcsDir(), 'copilot.settings.json'); const settingsPath = path.join(getCcsDir(), 'copilot.settings.json');
const config = loadOrCreateUnifiedConfig(); 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 // Default model for all tiers
const defaultModel = copilotConfig.model; const defaultModel = copilotConfig.model;
@@ -28,16 +60,19 @@ router.get('/raw', (_req: Request, res: Response): void => {
if (!fs.existsSync(settingsPath)) { if (!fs.existsSync(settingsPath)) {
// Create settings structure matching CLIProxy pattern // Create settings structure matching CLIProxy pattern
// Use 127.0.0.1 instead of localhost for more reliable local connections // Use 127.0.0.1 instead of localhost for more reliable local connections
const defaultSettings = { const defaultSettings = normalizeCopilotSettings(
env: { {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${copilotConfig.port}`, env: {
ANTHROPIC_AUTH_TOKEN: 'copilot-managed', ANTHROPIC_BASE_URL: `http://127.0.0.1:${copilotConfig.port}`,
ANTHROPIC_MODEL: defaultModel, ANTHROPIC_AUTH_TOKEN: 'copilot-managed',
ANTHROPIC_DEFAULT_OPUS_MODEL: copilotConfig.opus_model || defaultModel, ANTHROPIC_MODEL: defaultModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: copilotConfig.sonnet_model || defaultModel, ANTHROPIC_DEFAULT_OPUS_MODEL: copilotConfig.opus_model || defaultModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: copilotConfig.haiku_model || defaultModel, ANTHROPIC_DEFAULT_SONNET_MODEL: copilotConfig.sonnet_model || defaultModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: copilotConfig.haiku_model || defaultModel,
},
}, },
}; copilotConfig
);
res.json({ res.json({
settings: defaultSettings, settings: defaultSettings,
@@ -49,7 +84,33 @@ router.get('/raw', (_req: Request, res: Response): void => {
} }
const content = fs.readFileSync(settingsPath, 'utf-8'); const content = fs.readFileSync(settingsPath, 'utf-8');
const settings = JSON.parse(content); const settings = normalizeCopilotSettings(
JSON.parse(content) as Record<string, unknown>,
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); const stat = fs.statSync(settingsPath);
res.json({ res.json({
@@ -71,6 +132,8 @@ router.put('/raw', (req: Request, res: Response): void => {
try { try {
const { settings, expectedMtime } = req.body; const { settings, expectedMtime } = req.body;
const settingsPath = path.join(getCcsDir(), 'copilot.settings.json'); 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 // Check for conflict if file exists and expectedMtime provided
if (fs.existsSync(settingsPath) && expectedMtime) { if (fs.existsSync(settingsPath) && expectedMtime) {
@@ -81,22 +144,39 @@ router.put('/raw', (req: Request, res: Response): void => {
} }
} }
// Write settings file atomically const normalizedSettings = normalizeCopilotSettings(
const tempPath = settingsPath + '.tmp'; settings as Record<string, unknown>,
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); copilotConfig
fs.renameSync(tempPath, settingsPath); );
writeSettingsFile(settingsPath, normalizedSettings);
// Also sync model mappings back to unified config // Also sync model mappings back to unified config
const config = loadOrCreateUnifiedConfig(); const env =
const env = settings.env || {}; normalizedSettings.env &&
typeof normalizedSettings.env === 'object' &&
!Array.isArray(normalizedSettings.env)
? normalizedSettings.env
: {};
config.copilot = { config.copilot = normalizeCopilotConfig({
...(config.copilot ?? DEFAULT_COPILOT_CONFIG), ...(config.copilot ?? DEFAULT_COPILOT_CONFIG),
model: env.ANTHROPIC_MODEL || config.copilot?.model || DEFAULT_COPILOT_CONFIG.model, model:
opus_model: env.ANTHROPIC_DEFAULT_OPUS_MODEL || undefined, typeof env.ANTHROPIC_MODEL === 'string'
sonnet_model: env.ANTHROPIC_DEFAULT_SONNET_MODEL || undefined, ? env.ANTHROPIC_MODEL
haiku_model: env.ANTHROPIC_DEFAULT_HAIKU_MODEL || undefined, : 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); saveUnifiedConfig(config);
const stat = fs.statSync(settingsPath); const stat = fs.statSync(settingsPath);
@@ -13,6 +13,22 @@ const baseConfig: CopilotConfig = {
}; };
describe('generateCopilotEnv', () => { 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', () => { it('includes inherited CLAUDE_CONFIG_DIR when provided', () => {
const env = generateCopilotEnv(baseConfig, '/tmp/.ccs/instances/pro'); const env = generateCopilotEnv(baseConfig, '/tmp/.ccs/instances/pro');
expect(env.CLAUDE_CONFIG_DIR).toBe('/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(); expect(env.CLAUDE_CONFIG_DIR).toBeUndefined();
}); });
}); });
@@ -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');
});
});
@@ -3,6 +3,10 @@ import * as http from 'http';
import { DEFAULT_COPILOT_MODELS, fetchModelsFromDaemon } from '../../../src/copilot/copilot-models'; import { DEFAULT_COPILOT_MODELS, fetchModelsFromDaemon } from '../../../src/copilot/copilot-models';
describe('fetchModelsFromDaemon', () => { 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 () => { it('falls back to defaults when daemon is unreachable', async () => {
const models = await fetchModelsFromDaemon(9999); const models = await fetchModelsFromDaemon(9999);
expect(models).toEqual(DEFAULT_COPILOT_MODELS); expect(models).toEqual(DEFAULT_COPILOT_MODELS);
@@ -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<string, unknown>): 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<void>((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<void>((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<string, string>;
};
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<string, string>;
};
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<string, string>;
};
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');
});
});
@@ -23,14 +23,6 @@ export const FREE_PRESETS: ModelPreset[] = [
sonnet: 'gpt-5-mini', sonnet: 'gpt-5-mini',
haiku: '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[] = [ export const PAID_PRESETS: ModelPreset[] = [