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 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
+3 -2
View File
@@ -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<number> {
*/
async function handleStatus(): Promise<number> {
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<number> {
*/
async function handleModels(): Promise<number> {
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('────────────────────────');
+20 -14
View File
@@ -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<CopilotStatus> {
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<string, string> {
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<number> {
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
+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',
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,
} from './copilot-models';
// Normalization
export {
DEPRECATED_COPILOT_MODEL_IDS,
normalizeCopilotConfig,
normalizeCopilotModelId,
normalizeCopilotSettings,
} from './copilot-model-normalizer';
// Usage
export {
normalizeCopilotUsage,
+29 -12
View File
@@ -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<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)
*/
router.get('/status', async (_req: Request, res: Response): Promise<void> => {
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<void> => {
*/
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<void> =
*/
router.get('/models', async (_req: Request, res: Response): Promise<void> => {
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) => ({
+104 -24
View File
@@ -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, 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
* 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<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);
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<string, unknown>,
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);
@@ -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();
});
});
@@ -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';
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);
@@ -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',
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[] = [