Merge pull request #718 from kaitranntt/kai/fix/702-raptor-mini-copilot-model-guardrails

fix: normalize stale copilot raptor-mini model
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-03-15 15:01:31 -04:00
commit f6100a5588
17 files changed
+1049 -159

No files matched your search

+2
View File
@@ -120,6 +120,8 @@ The dashboard provides visual management for all account types:
**Ollama Integration**: Run local open-source models (qwen3-coder, gpt-oss:20b) with full privacy. Use `ccs api create --preset ollama` - requires [Ollama v0.14.0+](https://ollama.com) installed. For cloud models, use `ccs api create --preset ollama-cloud`.
> **Copilot config behavior:** Opening the dashboard or other read-only Copilot endpoints does not rewrite `~/.ccs/copilot.settings.json`. If CCS detects deprecated Copilot model IDs such as `raptor-mini`, it shows warnings immediately and only persists replacements when you explicitly save the Copilot configuration.
**llama.cpp Integration**: Run a local llama.cpp OpenAI-compatible server and create a profile with `ccs api create --preset llamacpp`. CCS defaults to `http://127.0.0.1:8080`, matching the standard llama.cpp server port.
**Azure Foundry**: Use `ccs api create --preset foundry` to set up Claude via Microsoft Azure AI Foundry. Requires Azure resource and API key from [ai.azure.com](https://ai.azure.com).
+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
+22 -9
View File
@@ -12,11 +12,12 @@ import {
stopDaemon,
getAvailableModels,
isCopilotApiInstalled,
normalizeCopilotConfigWithWarnings,
} from '../copilot';
import type { CopilotModel } from '../copilot';
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../config/unified-config-loader';
import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types';
import { ok, fail, info, color } from '../utils/ui';
import { ok, fail, info, color, warn } from '../utils/ui';
import { normalizeCopilotSubcommand } from '../copilot/constants';
/**
@@ -55,6 +56,18 @@ export async function handleCopilotCommand(args: string[]): Promise<number> {
}
}
function loadCopilotConfigWithWarnings() {
const config = loadOrCreateUnifiedConfig();
return normalizeCopilotConfigWithWarnings(config.copilot ?? DEFAULT_COPILOT_CONFIG);
}
function printCopilotWarnings(messages: string[]): void {
if (messages.length === 0) return;
messages.forEach((message) => console.log(warn(message)));
console.log(warn('Run `ccs config` and save the Copilot section to persist these replacements.'));
console.log('');
}
/**
* Show help for copilot commands.
*/
@@ -124,8 +137,8 @@ async function handleAuth(): Promise<number> {
* Handle status subcommand.
*/
async function handleStatus(): Promise<number> {
const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
const { config: copilotConfig, warnings } = loadCopilotConfigWithWarnings();
printCopilotWarnings(warnings.map((warning) => warning.message));
const status = await getCopilotStatus(copilotConfig);
@@ -182,8 +195,8 @@ async function handleStatus(): Promise<number> {
* Handle models subcommand.
*/
async function handleModels(): Promise<number> {
const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
const { config: copilotConfig, warnings } = loadCopilotConfigWithWarnings();
printCopilotWarnings(warnings.map((warning) => warning.message));
console.log('Available Copilot Models');
console.log('────────────────────────');
@@ -273,8 +286,8 @@ function formatResetDate(resetDate: string | null): string {
* Handle usage subcommand.
*/
async function handleUsage(): Promise<number> {
const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
const { config: copilotConfig, warnings } = loadCopilotConfigWithWarnings();
printCopilotWarnings(warnings.map((warning) => warning.message));
const status = await getCopilotStatus(copilotConfig);
if (!status.daemon.running) {
@@ -311,8 +324,8 @@ async function handleUsage(): Promise<number> {
* Handle start subcommand.
*/
async function handleStart(): Promise<number> {
const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
const { config: copilotConfig, warnings } = loadCopilotConfigWithWarnings();
printCopilotWarnings(warnings.map((warning) => warning.message));
console.log(info(`Starting copilot-api daemon on port ${copilotConfig.port}...`));
+29 -15
View File
@@ -11,8 +11,9 @@ import { getGlobalEnvConfig } from '../config/unified-config-loader';
import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth';
import { isDaemonRunning, startDaemon } from './copilot-daemon';
import { ensureCopilotApi } from './copilot-package-manager';
import { normalizeCopilotConfigWithWarnings } from './copilot-model-normalizer';
import { CopilotStatus } from './types';
import { fail, info, ok } from '../utils/ui';
import { fail, info, ok, warn } from '../utils/ui';
import { getWebSearchHookEnv } from '../utils/websearch-manager';
import { getImageAnalysisHookEnv } from '../utils/hooks';
import { stripClaudeCodeEnv } from '../utils/shell-executor';
@@ -21,16 +22,17 @@ import { stripClaudeCodeEnv } from '../utils/shell-executor';
* Get full copilot status (auth + daemon).
*/
export async function getCopilotStatus(config: CopilotConfig): Promise<CopilotStatus> {
const normalizedConfig = normalizeCopilotConfigWithWarnings(config).config;
const [auth, daemonRunning] = await Promise.all([
checkAuthStatus(),
isDaemonRunning(config.port),
isDaemonRunning(normalizedConfig.port),
]);
return {
auth,
daemon: {
running: daemonRunning,
port: config.port,
port: normalizedConfig.port,
},
};
}
@@ -43,17 +45,19 @@ export function generateCopilotEnv(
config: CopilotConfig,
claudeConfigDir?: string
): Record<string, string> {
const normalizedConfig = normalizeCopilotConfigWithWarnings(config).config;
// Use mapped models if configured, otherwise fall back to default model
const opusModel = config.opus_model || config.model;
const sonnetModel = config.sonnet_model || config.model;
const haikuModel = config.haiku_model || config.model;
const opusModel = normalizedConfig.opus_model || normalizedConfig.model;
const sonnetModel = normalizedConfig.sonnet_model || normalizedConfig.model;
const haikuModel = normalizedConfig.haiku_model || normalizedConfig.model;
// Use 127.0.0.1 instead of localhost for more reliable local connections
// (bypasses DNS resolution and potential IPv6 issues)
return {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${config.port}`,
ANTHROPIC_BASE_URL: `http://127.0.0.1:${normalizedConfig.port}`,
ANTHROPIC_AUTH_TOKEN: 'dummy', // copilot-api handles auth internally
ANTHROPIC_MODEL: config.model,
ANTHROPIC_MODEL: normalizedConfig.model,
// Model tier mapping - allows different models for opus/sonnet/haiku
ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel,
@@ -79,6 +83,16 @@ export async function executeCopilotProfile(
claudeConfigDir?: string,
claudeCliPath: string = 'claude'
): Promise<number> {
const { config: normalizedConfig, warnings } = normalizeCopilotConfigWithWarnings(config);
if (warnings.length > 0) {
warnings.forEach(({ message }) => console.log(warn(message)));
console.log(
warn('Run `ccs config` and save the Copilot section to persist these replacements.')
);
console.log('');
}
// Ensure copilot-api is installed (auto-install if missing, auto-update if outdated)
try {
await ensureCopilotApi();
@@ -111,17 +125,17 @@ export async function executeCopilotProfile(
}
// Check if daemon is running or needs to be started
let daemonRunning = await isDaemonRunning(config.port);
let daemonRunning = await isDaemonRunning(normalizedConfig.port);
if (!daemonRunning) {
if (config.auto_start) {
if (normalizedConfig.auto_start) {
console.log(info('Starting copilot-api daemon...'));
const result = await startDaemon(config);
const result = await startDaemon(normalizedConfig);
if (!result.success) {
console.error(fail(`Failed to start daemon: ${result.error}`));
return 1;
}
console.log(ok(`Daemon started on port ${config.port}`));
console.log(ok(`Daemon started on port ${normalizedConfig.port}`));
daemonRunning = true;
} else {
console.error(fail('copilot-api daemon is not running.'));
@@ -129,7 +143,7 @@ export async function executeCopilotProfile(
console.error('Start the daemon:');
console.error(' ccs copilot start');
console.error('Fallback manual command:');
console.error(` npx copilot-api start --port ${config.port}`);
console.error(` npx copilot-api start --port ${normalizedConfig.port}`);
console.error('');
console.error('Or enable auto_start in config:');
console.error(' ccs config (then enable auto_start in Copilot section)');
@@ -138,7 +152,7 @@ export async function executeCopilotProfile(
}
// Generate environment for Claude
const copilotEnv = generateCopilotEnv(config, claudeConfigDir);
const copilotEnv = generateCopilotEnv(normalizedConfig, claudeConfigDir);
// Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles
const globalEnvConfig = getGlobalEnvConfig();
@@ -156,7 +170,7 @@ export async function executeCopilotProfile(
CCS_PROFILE_TYPE: 'copilot',
});
console.log(info(`Using GitHub Copilot proxy (model: ${config.model})`));
console.log(info(`Using GitHub Copilot proxy (model: ${normalizedConfig.model})`));
console.log('');
// Spawn Claude CLI
+271
View File
@@ -0,0 +1,271 @@
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)
);
export type CopilotNormalizationSource = 'config' | 'settings';
export type CopilotNormalizationTier = 'default' | 'opus' | 'sonnet' | 'haiku';
export interface CopilotNormalizationWarning {
source: CopilotNormalizationSource;
tier: CopilotNormalizationTier;
original: string;
replacement: string;
message: string;
}
export interface CopilotSettingsPayload {
env?: Record<string, unknown>;
[key: string]: unknown;
}
export interface NormalizedCopilotConfigResult {
config: CopilotConfig;
warnings: CopilotNormalizationWarning[];
}
export interface NormalizedCopilotSettingsResult {
settings: CopilotSettingsPayload;
effectiveConfig: CopilotConfig;
warnings: CopilotNormalizationWarning[];
}
function trimModelId(value: string | null | undefined): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function getTierLabel(tier: CopilotNormalizationTier): string {
switch (tier) {
case 'default':
return 'default';
case 'opus':
return 'Opus';
case 'sonnet':
return 'Sonnet';
case 'haiku':
return 'Haiku';
}
}
function createWarning(
source: CopilotNormalizationSource,
tier: CopilotNormalizationTier,
original: string,
replacement: string
): CopilotNormalizationWarning {
return {
source,
tier,
original,
replacement,
message: `Copilot ${getTierLabel(tier)} model '${original}' is no longer supported. CCS will use '${replacement}' instead.`,
};
}
function appendWarning(
warnings: CopilotNormalizationWarning[],
warning?: CopilotNormalizationWarning
): void {
if (!warning) return;
const key = `${warning.source}:${warning.tier}:${warning.original.toLowerCase()}:${warning.replacement.toLowerCase()}`;
if (
warnings.some(
(entry) =>
`${entry.source}:${entry.tier}:${entry.original.toLowerCase()}:${entry.replacement.toLowerCase()}` ===
key
)
) {
return;
}
warnings.push(warning);
}
function normalizeRequiredModelSelection(
model: string | null | undefined,
fallbackModel: string,
source: CopilotNormalizationSource,
tier: CopilotNormalizationTier
): { value: string; warning?: CopilotNormalizationWarning } {
const normalizedFallback = trimModelId(fallbackModel) ?? DEFAULT_COPILOT_CONFIG.model;
const trimmedModel = trimModelId(model) ?? normalizedFallback;
const replacement = LEGACY_COPILOT_MODEL_FALLBACKS[trimmedModel.toLowerCase()];
if (!replacement) return { value: trimmedModel };
return {
value: replacement,
warning: createWarning(source, tier, trimmedModel, replacement),
};
}
function normalizeOptionalModelSelection(
model: string | null | undefined,
fallbackModel: string,
source: CopilotNormalizationSource,
tier: CopilotNormalizationTier
): { value?: string; warning?: CopilotNormalizationWarning } {
const trimmedModel = trimModelId(model);
if (!trimmedModel) return { value: undefined };
if (!LEGACY_COPILOT_MODEL_FALLBACKS[trimmedModel.toLowerCase()]) {
return { value: trimmedModel };
}
const replacement = trimModelId(fallbackModel) ?? DEFAULT_COPILOT_CONFIG.model;
return {
value: replacement,
warning: createWarning(source, tier, trimmedModel, replacement),
};
}
function getSettingsEnv(settings: CopilotSettingsPayload): Record<string, unknown> {
return settings.env && typeof settings.env === 'object' && !Array.isArray(settings.env)
? settings.env
: {};
}
function getSettingsPayload(settings: CopilotSettingsPayload): CopilotSettingsPayload {
return settings && typeof settings === 'object' && !Array.isArray(settings) ? settings : {};
}
export function normalizeCopilotModelId(
model: string | null | undefined,
fallbackModel: string = DEFAULT_COPILOT_CONFIG.model
): string {
return normalizeRequiredModelSelection(model, fallbackModel, 'config', 'default').value;
}
export function normalizeCopilotConfigWithWarnings(
config: CopilotConfig
): NormalizedCopilotConfigResult {
const warnings: CopilotNormalizationWarning[] = [];
const baseModel = normalizeRequiredModelSelection(
config.model,
DEFAULT_COPILOT_CONFIG.model,
'config',
'default'
);
appendWarning(warnings, baseModel.warning);
const opusModel = normalizeOptionalModelSelection(
config.opus_model,
baseModel.value,
'config',
'opus'
);
const sonnetModel = normalizeOptionalModelSelection(
config.sonnet_model,
baseModel.value,
'config',
'sonnet'
);
const haikuModel = normalizeOptionalModelSelection(
config.haiku_model,
baseModel.value,
'config',
'haiku'
);
appendWarning(warnings, opusModel.warning);
appendWarning(warnings, sonnetModel.warning);
appendWarning(warnings, haikuModel.warning);
return {
config: {
...config,
model: baseModel.value,
opus_model: opusModel.value,
sonnet_model: sonnetModel.value,
haiku_model: haikuModel.value,
},
warnings,
};
}
export function normalizeCopilotConfig(config: CopilotConfig): CopilotConfig {
return normalizeCopilotConfigWithWarnings(config).config;
}
export function normalizeCopilotSettingsWithWarnings(
settings: CopilotSettingsPayload,
fallbackConfig: CopilotConfig = DEFAULT_COPILOT_CONFIG
): NormalizedCopilotSettingsResult {
const configResult = normalizeCopilotConfigWithWarnings(fallbackConfig);
const warnings = [...configResult.warnings];
const payload = getSettingsPayload(settings);
const rawEnv = getSettingsEnv(payload);
const baseModel = normalizeRequiredModelSelection(
typeof rawEnv.ANTHROPIC_MODEL === 'string' ? rawEnv.ANTHROPIC_MODEL : configResult.config.model,
configResult.config.model,
'settings',
'default'
);
appendWarning(warnings, baseModel.warning);
const opusModel = normalizeOptionalModelSelection(
typeof rawEnv.ANTHROPIC_DEFAULT_OPUS_MODEL === 'string'
? rawEnv.ANTHROPIC_DEFAULT_OPUS_MODEL
: configResult.config.opus_model,
baseModel.value,
'settings',
'opus'
);
const sonnetModel = normalizeOptionalModelSelection(
typeof rawEnv.ANTHROPIC_DEFAULT_SONNET_MODEL === 'string'
? rawEnv.ANTHROPIC_DEFAULT_SONNET_MODEL
: configResult.config.sonnet_model,
baseModel.value,
'settings',
'sonnet'
);
const rawHaikuModel =
typeof rawEnv.ANTHROPIC_SMALL_FAST_MODEL === 'string'
? rawEnv.ANTHROPIC_SMALL_FAST_MODEL
: typeof rawEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL === 'string'
? rawEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL
: configResult.config.haiku_model;
const haikuModel = normalizeOptionalModelSelection(
rawHaikuModel,
baseModel.value,
'settings',
'haiku'
);
appendWarning(warnings, opusModel.warning);
appendWarning(warnings, sonnetModel.warning);
appendWarning(warnings, haikuModel.warning);
const effectiveConfig = {
...configResult.config,
model: baseModel.value,
opus_model: opusModel.value ?? baseModel.value,
sonnet_model: sonnetModel.value ?? baseModel.value,
haiku_model: haikuModel.value ?? baseModel.value,
};
return {
settings: {
...payload,
env: {
...rawEnv,
ANTHROPIC_MODEL: effectiveConfig.model,
ANTHROPIC_DEFAULT_OPUS_MODEL: effectiveConfig.opus_model,
ANTHROPIC_DEFAULT_SONNET_MODEL: effectiveConfig.sonnet_model,
ANTHROPIC_DEFAULT_HAIKU_MODEL: effectiveConfig.haiku_model,
ANTHROPIC_SMALL_FAST_MODEL: effectiveConfig.haiku_model,
},
},
effectiveConfig,
warnings,
};
}
export function normalizeCopilotSettings(
settings: CopilotSettingsPayload,
fallbackConfig: CopilotConfig = DEFAULT_COPILOT_CONFIG
): CopilotSettingsPayload {
return normalizeCopilotSettingsWithWarnings(settings, fallbackConfig).settings;
}
-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,
},
];
/**
+10
View File
@@ -44,6 +44,16 @@ export {
getDefaultModel,
} from './copilot-models';
// Normalization
export {
DEPRECATED_COPILOT_MODEL_IDS,
normalizeCopilotConfig,
normalizeCopilotConfigWithWarnings,
normalizeCopilotModelId,
normalizeCopilotSettings,
normalizeCopilotSettingsWithWarnings,
} from './copilot-model-normalizer';
// Usage
export {
normalizeCopilotUsage,
+70 -55
View File
@@ -18,8 +18,12 @@ import {
getCopilotApiInfo,
getInstalledVersion as getCopilotInstalledVersion,
} from '../../copilot';
import { DEFAULT_COPILOT_CONFIG } from '../../config/unified-config-types';
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader';
import {
normalizeCopilotConfigWithWarnings,
type CopilotNormalizationWarning,
} from '../../copilot/copilot-model-normalizer';
import { DEFAULT_COPILOT_CONFIG, type CopilotConfig } from '../../config/unified-config-types';
import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
import copilotSettingsRoutes from './copilot-settings-routes';
const router = Router();
@@ -39,13 +43,20 @@ function parseOptionalModel(value: unknown): string | undefined {
return trimmed.length > 0 ? trimmed : undefined;
}
function loadEffectiveCopilotConfig(): {
config: CopilotConfig;
warnings: CopilotNormalizationWarning[];
} {
const config = loadOrCreateUnifiedConfig();
return normalizeCopilotConfigWithWarnings(config.copilot ?? DEFAULT_COPILOT_CONFIG);
}
/**
* GET /api/copilot/status - Get Copilot status (auth + daemon + install info)
*/
router.get('/status', async (_req: Request, res: Response): Promise<void> => {
try {
const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
const { config: copilotConfig, warnings } = loadEffectiveCopilotConfig();
const status = await getCopilotStatus(copilotConfig);
const installed = isCopilotApiInstalled();
const version = getCopilotInstalledVersion();
@@ -62,6 +73,7 @@ router.get('/status', async (_req: Request, res: Response): Promise<void> => {
auto_start: copilotConfig.auto_start,
rate_limit: copilotConfig.rate_limit,
wait_on_limit: copilotConfig.wait_on_limit,
warnings,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
@@ -73,9 +85,8 @@ router.get('/status', async (_req: Request, res: Response): Promise<void> => {
*/
router.get('/config', (_req: Request, res: Response): void => {
try {
const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
res.json(copilotConfig);
const { config: copilotConfig, warnings } = loadEffectiveCopilotConfig();
res.json({ ...copilotConfig, warnings });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
@@ -197,46 +208,51 @@ router.put('/config', (req: Request, res: Response): void => {
return;
}
const config = loadOrCreateUnifiedConfig();
let nextCopilotConfig: CopilotConfig = { ...DEFAULT_COPILOT_CONFIG };
let warnings: CopilotNormalizationWarning[] = [];
// Merge updates with existing config
config.copilot = {
enabled:
(payload.enabled as boolean) ?? config.copilot?.enabled ?? DEFAULT_COPILOT_CONFIG.enabled,
auto_start:
(payload.auto_start as boolean) ??
config.copilot?.auto_start ??
DEFAULT_COPILOT_CONFIG.auto_start,
port: (payload.port as number) ?? config.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port,
account_type:
(payload.account_type as 'individual' | 'business' | 'enterprise') ??
config.copilot?.account_type ??
DEFAULT_COPILOT_CONFIG.account_type,
rate_limit:
payload.rate_limit !== undefined
? (payload.rate_limit as number | null)
: (config.copilot?.rate_limit ?? DEFAULT_COPILOT_CONFIG.rate_limit),
wait_on_limit:
(payload.wait_on_limit as boolean) ??
config.copilot?.wait_on_limit ??
DEFAULT_COPILOT_CONFIG.wait_on_limit,
model: normalizedModel ?? config.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model,
opus_model:
'opus_model' in payload
? parseOptionalModel(payload.opus_model)
: config.copilot?.opus_model,
sonnet_model:
'sonnet_model' in payload
? parseOptionalModel(payload.sonnet_model)
: config.copilot?.sonnet_model,
haiku_model:
'haiku_model' in payload
? parseOptionalModel(payload.haiku_model)
: config.copilot?.haiku_model,
};
mutateUnifiedConfig((config) => {
const currentConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
const result = normalizeCopilotConfigWithWarnings({
enabled:
(payload.enabled as boolean) ?? currentConfig.enabled ?? DEFAULT_COPILOT_CONFIG.enabled,
auto_start:
(payload.auto_start as boolean) ??
currentConfig.auto_start ??
DEFAULT_COPILOT_CONFIG.auto_start,
port: (payload.port as number) ?? currentConfig.port ?? DEFAULT_COPILOT_CONFIG.port,
account_type:
(payload.account_type as 'individual' | 'business' | 'enterprise') ??
currentConfig.account_type ??
DEFAULT_COPILOT_CONFIG.account_type,
rate_limit:
payload.rate_limit !== undefined
? (payload.rate_limit as number | null)
: (currentConfig.rate_limit ?? DEFAULT_COPILOT_CONFIG.rate_limit),
wait_on_limit:
(payload.wait_on_limit as boolean) ??
currentConfig.wait_on_limit ??
DEFAULT_COPILOT_CONFIG.wait_on_limit,
model: normalizedModel ?? currentConfig.model ?? DEFAULT_COPILOT_CONFIG.model,
opus_model:
'opus_model' in payload
? parseOptionalModel(payload.opus_model)
: currentConfig.opus_model,
sonnet_model:
'sonnet_model' in payload
? parseOptionalModel(payload.sonnet_model)
: currentConfig.sonnet_model,
haiku_model:
'haiku_model' in payload
? parseOptionalModel(payload.haiku_model)
: currentConfig.haiku_model,
});
config.copilot = result.config;
nextCopilotConfig = result.config;
warnings = result.warnings;
});
saveUnifiedConfig(config);
res.json({ success: true, copilot: config.copilot });
res.json({ success: true, copilot: nextCopilotConfig, warnings });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
@@ -271,9 +287,9 @@ router.get('/auth/status', async (_req: Request, res: Response): Promise<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 { config: copilotConfig, warnings } = loadEffectiveCopilotConfig();
const port = copilotConfig.port;
const currentModel = copilotConfig.model;
const models = await getCopilotModels(port);
const modelsWithCurrent = models.map((m) => ({
@@ -281,7 +297,7 @@ router.get('/models', async (_req: Request, res: Response): Promise<void> => {
isCurrent: m.id === currentModel,
}));
res.json({ models: modelsWithCurrent, current: currentModel });
res.json({ models: modelsWithCurrent, current: currentModel, warnings });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
@@ -292,8 +308,8 @@ router.get('/models', async (_req: Request, res: Response): Promise<void> => {
*/
router.get('/usage', async (_req: Request, res: Response): Promise<void> => {
try {
const config = loadOrCreateUnifiedConfig();
const port = config.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port;
const { config: copilotConfig, warnings } = loadEffectiveCopilotConfig();
const port = copilotConfig.port;
const daemonRunning = await isDaemonRunning(port);
if (!daemonRunning) {
@@ -313,7 +329,7 @@ router.get('/usage', async (_req: Request, res: Response): Promise<void> => {
return;
}
res.json(usage);
res.json({ ...usage, warnings });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
@@ -324,10 +340,9 @@ router.get('/usage', async (_req: Request, res: Response): Promise<void> => {
*/
router.post('/daemon/start', async (_req: Request, res: Response): Promise<void> => {
try {
const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
const { config: copilotConfig, warnings } = loadEffectiveCopilotConfig();
const result = await startCopilotDaemon(copilotConfig);
res.json(result);
res.json({ ...result, warnings });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
+109 -39
View File
@@ -5,12 +5,63 @@
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import {
normalizeCopilotConfigWithWarnings,
normalizeCopilotSettingsWithWarnings,
} from '../../copilot/copilot-model-normalizer';
import { DEFAULT_COPILOT_CONFIG, type CopilotConfig } from '../../config/unified-config-types';
import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
import { getCcsDir } from '../../utils/config-manager';
import { DEFAULT_COPILOT_CONFIG } from '../../config/unified-config-types';
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader';
const router = Router();
function serializeSettings(settings: Record<string, 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}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
fs.writeFileSync(tempPath, nextContent);
fs.renameSync(tempPath, settingsPath);
}
function restoreSettingsFile(
settingsPath: string,
previousContent: string | undefined,
existedBefore: boolean
): void {
if (!existedBefore) {
if (fs.existsSync(settingsPath)) {
fs.unlinkSync(settingsPath);
}
return;
}
const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.restore.tmp`;
fs.writeFileSync(tempPath, previousContent ?? '');
fs.renameSync(tempPath, settingsPath);
}
function buildDefaultSettings(copilotConfig: CopilotConfig) {
return {
env: {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${copilotConfig.port}`,
ANTHROPIC_AUTH_TOKEN: 'copilot-managed',
ANTHROPIC_MODEL: copilotConfig.model,
ANTHROPIC_DEFAULT_OPUS_MODEL: copilotConfig.opus_model || copilotConfig.model,
ANTHROPIC_DEFAULT_SONNET_MODEL: copilotConfig.sonnet_model || copilotConfig.model,
ANTHROPIC_DEFAULT_HAIKU_MODEL: copilotConfig.haiku_model || copilotConfig.model,
ANTHROPIC_SMALL_FAST_MODEL: copilotConfig.haiku_model || copilotConfig.model,
},
};
}
/**
* GET /api/copilot/settings/raw - Get raw copilot.settings.json
* Returns the raw JSON content for editing in the code editor
@@ -19,44 +70,39 @@ router.get('/raw', (_req: Request, res: Response): void => {
try {
const settingsPath = path.join(getCcsDir(), 'copilot.settings.json');
const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
const configResult = normalizeCopilotConfigWithWarnings(
config.copilot ?? DEFAULT_COPILOT_CONFIG
);
// Default model for all tiers
const defaultModel = copilotConfig.model;
// If file doesn't exist, return default structure with all model mappings
if (!fs.existsSync(settingsPath)) {
// Create settings structure matching CLIProxy pattern
// Use 127.0.0.1 instead of localhost for more reliable local connections
const defaultSettings = {
env: {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${copilotConfig.port}`,
ANTHROPIC_AUTH_TOKEN: 'copilot-managed',
ANTHROPIC_MODEL: defaultModel,
ANTHROPIC_DEFAULT_OPUS_MODEL: copilotConfig.opus_model || defaultModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: copilotConfig.sonnet_model || defaultModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: copilotConfig.haiku_model || defaultModel,
},
};
const defaultSettings = normalizeCopilotSettingsWithWarnings(
buildDefaultSettings(configResult.config),
configResult.config
);
res.json({
settings: defaultSettings,
settings: defaultSettings.settings,
effectiveSettings: defaultSettings.settings,
mtime: Date.now(),
path: `~/.ccs/copilot.settings.json`,
exists: false,
warnings: defaultSettings.warnings,
});
return;
}
const content = fs.readFileSync(settingsPath, 'utf-8');
const settings = JSON.parse(content);
const rawSettings = JSON.parse(content) as Record<string, unknown>;
const settingsResult = normalizeCopilotSettingsWithWarnings(rawSettings, configResult.config);
const stat = fs.statSync(settingsPath);
res.json({
settings,
settings: rawSettings,
effectiveSettings: settingsResult.settings,
mtime: stat.mtimeMs,
path: `~/.ccs/copilot.settings.json`,
exists: true,
warnings: settingsResult.warnings,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
@@ -72,6 +118,16 @@ router.put('/raw', (req: Request, res: Response): void => {
const { settings, expectedMtime } = req.body;
const settingsPath = path.join(getCcsDir(), 'copilot.settings.json');
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
res.status(400).json({ error: 'settings must be a JSON object' });
return;
}
const currentConfig = loadOrCreateUnifiedConfig();
const configResult = normalizeCopilotConfigWithWarnings(
currentConfig.copilot ?? DEFAULT_COPILOT_CONFIG
);
// Check for conflict if file exists and expectedMtime provided
if (fs.existsSync(settingsPath) && expectedMtime) {
const stat = fs.statSync(settingsPath);
@@ -81,26 +137,40 @@ router.put('/raw', (req: Request, res: Response): void => {
}
}
// Write settings file atomically
const tempPath = settingsPath + '.tmp';
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
fs.renameSync(tempPath, settingsPath);
const settingsResult = normalizeCopilotSettingsWithWarnings(
settings as Record<string, unknown>,
configResult.config
);
const existedBefore = fs.existsSync(settingsPath);
const previousContent = existedBefore ? fs.readFileSync(settingsPath, 'utf-8') : undefined;
writeSettingsFile(
settingsPath,
settingsResult.settings as Record<string, unknown>,
previousContent
);
// Also sync model mappings back to unified config
const config = loadOrCreateUnifiedConfig();
const env = settings.env || {};
config.copilot = {
...(config.copilot ?? DEFAULT_COPILOT_CONFIG),
model: env.ANTHROPIC_MODEL || config.copilot?.model || DEFAULT_COPILOT_CONFIG.model,
opus_model: env.ANTHROPIC_DEFAULT_OPUS_MODEL || undefined,
sonnet_model: env.ANTHROPIC_DEFAULT_SONNET_MODEL || undefined,
haiku_model: env.ANTHROPIC_DEFAULT_HAIKU_MODEL || undefined,
};
saveUnifiedConfig(config);
try {
mutateUnifiedConfig((config) => {
config.copilot = settingsResult.effectiveConfig;
});
} catch (error) {
try {
restoreSettingsFile(settingsPath, previousContent, existedBefore);
} catch (rollbackError) {
throw new Error(
`Failed to sync unified config after writing Copilot settings: ${(error as Error).message}. Rollback also failed: ${(rollbackError as Error).message}`
);
}
throw error;
}
const stat = fs.statSync(settingsPath);
res.json({ success: true, mtime: stat.mtimeMs });
res.json({
success: true,
mtime: stat.mtimeMs,
settings: settingsResult.settings,
warnings: settingsResult.warnings,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
@@ -13,6 +13,34 @@ const baseConfig: CopilotConfig = {
};
describe('generateCopilotEnv', () => {
it('normalizes deprecated raptor-mini model selections to the safe default', () => {
const env = generateCopilotEnv({
...baseConfig,
model: 'raptor-mini',
opus_model: 'raptor-mini',
sonnet_model: 'raptor-mini',
haiku_model: 'raptor-mini',
});
expect(env.ANTHROPIC_MODEL).toBe('gpt-4.1');
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-4.1');
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-4.1');
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-4.1');
expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe('gpt-4.1');
});
it('falls back deprecated haiku overrides to the selected base model', () => {
const env = generateCopilotEnv({
...baseConfig,
model: 'claude-sonnet-4.5',
haiku_model: 'raptor-mini',
});
expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4.5');
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4.5');
expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe('claude-sonnet-4.5');
});
it('includes inherited CLAUDE_CONFIG_DIR when provided', () => {
const env = generateCopilotEnv(baseConfig, '/tmp/.ccs/instances/pro');
expect(env.CLAUDE_CONFIG_DIR).toBe('/tmp/.ccs/instances/pro');
@@ -23,4 +51,3 @@ describe('generateCopilotEnv', () => {
expect(env.CLAUDE_CONFIG_DIR).toBeUndefined();
});
});
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'bun:test';
import { DEFAULT_COPILOT_CONFIG } from '../../../src/config/unified-config-types';
import {
DEPRECATED_COPILOT_MODEL_IDS,
normalizeCopilotConfig,
normalizeCopilotConfigWithWarnings,
normalizeCopilotModelId,
normalizeCopilotSettings,
normalizeCopilotSettingsWithWarnings,
} from '../../../src/copilot/copilot-model-normalizer';
describe('copilot-model-normalizer', () => {
it('tracks raptor-mini as deprecated', () => {
expect(DEPRECATED_COPILOT_MODEL_IDS).toContain('raptor-mini');
});
it('normalizes deprecated model IDs to the safe default', () => {
expect(normalizeCopilotModelId('raptor-mini')).toBe(DEFAULT_COPILOT_CONFIG.model);
});
it('falls back deprecated tier overrides to the normalized base model', () => {
const result = normalizeCopilotConfigWithWarnings({
...DEFAULT_COPILOT_CONFIG,
model: 'claude-sonnet-4.5',
haiku_model: 'raptor-mini',
});
expect(result.config.model).toBe('claude-sonnet-4.5');
expect(result.config.haiku_model).toBe('claude-sonnet-4.5');
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0]?.message).toContain("'raptor-mini'");
expect(result.warnings[0]?.message).toContain("'claude-sonnet-4.5'");
});
it('normalizes raw settings payloads without dropping unrelated env keys', () => {
const normalized = normalizeCopilotSettings(
{
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:4141',
ANTHROPIC_MODEL: 'raptor-mini',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'raptor-mini',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'raptor-mini',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'raptor-mini',
EXTRA_FLAG: 'keep-me',
},
},
{
...DEFAULT_COPILOT_CONFIG,
model: 'raptor-mini',
}
);
expect(normalized.env?.ANTHROPIC_MODEL).toBe('gpt-4.1');
expect(normalized.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-4.1');
expect(normalized.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-4.1');
expect(normalized.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-4.1');
expect(normalized.env?.EXTRA_FLAG).toBe('keep-me');
});
it('keeps small-fast and haiku env settings in lockstep', () => {
const result = normalizeCopilotSettingsWithWarnings(
{
env: {
ANTHROPIC_MODEL: 'claude-sonnet-4.5',
ANTHROPIC_SMALL_FAST_MODEL: 'raptor-mini',
},
},
{
...DEFAULT_COPILOT_CONFIG,
model: 'claude-sonnet-4.5',
}
);
expect(result.settings.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4.5');
expect(result.settings.env?.ANTHROPIC_SMALL_FAST_MODEL).toBe('claude-sonnet-4.5');
expect(result.effectiveConfig.haiku_model).toBe('claude-sonnet-4.5');
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0]?.tier).toBe('haiku');
});
});
@@ -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,311 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import express from 'express';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { Server } from 'http';
import { normalizeCopilotConfig } from '../../../src/copilot/copilot-model-normalizer';
import { DEFAULT_COPILOT_CONFIG } from '../../../src/config/unified-config-types';
let server: Server;
let baseUrl = '';
let tempDir = '';
let originalCcsHome: string | undefined;
let setGlobalConfigDir: (dir: string | undefined) => void;
let getCcsDir: () => string;
let loadOrCreateUnifiedConfig: () => {
copilot?: {
enabled?: boolean;
auto_start?: boolean;
port?: number;
account_type?: 'individual' | 'business' | 'enterprise';
rate_limit?: number | null;
wait_on_limit?: boolean;
model?: string;
opus_model?: string;
sonnet_model?: string;
haiku_model?: string;
};
};
let saveUnifiedConfig: (config: {
copilot?: {
enabled?: boolean;
auto_start?: boolean;
port?: number;
account_type?: 'individual' | 'business' | 'enterprise';
rate_limit?: number | null;
wait_on_limit?: boolean;
model?: string;
opus_model?: string;
sonnet_model?: string;
haiku_model?: string;
};
}) => void;
function seedCopilotConfig(
overrides: {
enabled?: boolean;
auto_start?: boolean;
port?: number;
account_type?: 'individual' | 'business' | 'enterprise';
rate_limit?: number | null;
wait_on_limit?: boolean;
model?: string;
opus_model?: string;
sonnet_model?: string;
haiku_model?: string;
} = {}
): void {
const config = loadOrCreateUnifiedConfig();
config.copilot = {
enabled: overrides.enabled ?? true,
auto_start: overrides.auto_start ?? false,
port: overrides.port ?? 4141,
account_type: overrides.account_type ?? 'individual',
rate_limit: overrides.rate_limit ?? null,
wait_on_limit: overrides.wait_on_limit ?? true,
model: overrides.model ?? 'gpt-4.1',
opus_model: overrides.opus_model,
sonnet_model: overrides.sonnet_model,
haiku_model: overrides.haiku_model,
};
saveUnifiedConfig(config);
}
function getCopilotSettingsPath(): string {
return path.join(getCcsDir(), 'copilot.settings.json');
}
function writeCopilotSettings(settings: Record<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 returns normalized values and warnings without persisting', async () => {
seedCopilotConfig({
model: 'claude-sonnet-4.5',
haiku_model: 'raptor-mini',
});
const response = await fetch(`${baseUrl}/api/copilot/config`);
expect(response.status).toBe(200);
const body = (await response.json()) as {
model: string;
warnings?: Array<{ tier: string; message: string }>;
opus_model?: string;
sonnet_model?: string;
haiku_model?: string;
};
expect(body.model).toBe('claude-sonnet-4.5');
expect(body.haiku_model).toBeUndefined();
expect(body.warnings ?? []).toHaveLength(0);
const persisted = loadOrCreateUnifiedConfig().copilot;
expect(persisted?.model).toBe('claude-sonnet-4.5');
expect(persisted?.haiku_model).toBeUndefined();
});
it('PUT /api/copilot/config normalizes deprecated overrides before save', async () => {
const response = await fetch(`${baseUrl}/api/copilot/config`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
model: 'claude-sonnet-4.5',
haiku_model: 'raptor-mini',
}),
});
expect(response.status).toBe(200);
const body = (await response.json()) as {
success: boolean;
warnings?: Array<{ tier: string; replacement: string }>;
copilot: {
model: string;
opus_model?: string;
sonnet_model?: string;
haiku_model?: string;
};
};
expect(body.success).toBe(true);
expect(body.copilot.model).toBe('claude-sonnet-4.5');
expect(body.copilot.haiku_model).toBe('claude-sonnet-4.5');
expect(body.warnings?.[0]?.tier).toBe('haiku');
expect(body.warnings?.[0]?.replacement).toBe('claude-sonnet-4.5');
const persisted = loadOrCreateUnifiedConfig().copilot;
expect(persisted?.model).toBe('claude-sonnet-4.5');
expect(
normalizeCopilotConfig(persisted ?? { ...DEFAULT_COPILOT_CONFIG }).haiku_model
).toBeUndefined();
expect(persisted?.haiku_model).not.toBe('raptor-mini');
});
it('GET /api/copilot/settings/raw keeps stored JSON untouched and returns effective warnings', async () => {
seedCopilotConfig({ model: 'claude-sonnet-4.5' });
writeCopilotSettings({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:4141',
ANTHROPIC_AUTH_TOKEN: 'copilot-managed',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'raptor-mini',
ANTHROPIC_SMALL_FAST_MODEL: 'raptor-mini',
},
});
const beforeContent = fs.readFileSync(getCopilotSettingsPath(), 'utf-8');
const response = await fetch(`${baseUrl}/api/copilot/settings/raw`);
expect(response.status).toBe(200);
const body = (await response.json()) as {
settings: {
env: Record<string, string>;
};
effectiveSettings: {
env: Record<string, string>;
};
exists: boolean;
warnings?: Array<{ tier: string }>;
};
expect(body.exists).toBe(true);
expect(body.settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('raptor-mini');
expect(body.settings.env.ANTHROPIC_SMALL_FAST_MODEL).toBe('raptor-mini');
expect(body.effectiveSettings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4.5');
expect(body.effectiveSettings.env.ANTHROPIC_SMALL_FAST_MODEL).toBe('claude-sonnet-4.5');
expect(body.warnings?.map((warning) => warning.tier)).toEqual(['haiku']);
expect(fs.readFileSync(getCopilotSettingsPath(), 'utf-8')).toBe(beforeContent);
expect(loadOrCreateUnifiedConfig().copilot?.model).toBe('claude-sonnet-4.5');
});
it('PUT /api/copilot/settings/raw normalizes stale models before save and syncs unified config', async () => {
const response = await fetch(`${baseUrl}/api/copilot/settings/raw`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
settings: {
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:4141',
ANTHROPIC_AUTH_TOKEN: 'copilot-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4.5',
ANTHROPIC_SMALL_FAST_MODEL: 'raptor-mini',
},
},
}),
});
expect(response.status).toBe(200);
const body = (await response.json()) as {
success: boolean;
warnings?: Array<{ tier: string; replacement: string }>;
};
expect(body.success).toBe(true);
expect(body.warnings?.[0]?.tier).toBe('haiku');
expect(body.warnings?.[0]?.replacement).toBe('claude-sonnet-4.5');
const persistedSettings = JSON.parse(fs.readFileSync(getCopilotSettingsPath(), 'utf-8')) as {
env: Record<string, string>;
};
expect(persistedSettings.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4.5');
expect(persistedSettings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4.5');
expect(persistedSettings.env.ANTHROPIC_SMALL_FAST_MODEL).toBe('claude-sonnet-4.5');
const persistedConfig = loadOrCreateUnifiedConfig().copilot;
expect(persistedConfig?.model).toBe('claude-sonnet-4.5');
expect(
normalizeCopilotConfig(persistedConfig ?? { ...DEFAULT_COPILOT_CONFIG }).haiku_model
).toBeUndefined();
expect(persistedConfig?.haiku_model).not.toBe('raptor-mini');
});
it('PUT /api/copilot/settings/raw rejects non-object settings payloads', async () => {
const response = await fetch(`${baseUrl}/api/copilot/settings/raw`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
settings: ['invalid'],
}),
});
expect(response.status).toBe(400);
const body = (await response.json()) as { error: string };
expect(body.error).toBe('settings must be a JSON object');
});
it('GET /api/copilot/models reports normalized current model without persisting', async () => {
seedCopilotConfig({ model: 'raptor-mini' });
const response = await fetch(`${baseUrl}/api/copilot/models`);
expect(response.status).toBe(200);
const body = (await response.json()) as {
current: string;
warnings?: Array<{ tier: string }>;
models: Array<{ id: string; isCurrent?: boolean }>;
};
expect(body.current).toBe('gpt-4.1');
expect(body.models.some((model) => model.id === 'gpt-4.1' && model.isCurrent)).toBe(true);
expect(body.warnings?.map((warning) => warning.tier)).toEqual(['default']);
expect(loadOrCreateUnifiedConfig().copilot?.model).toBe('raptor-mini');
});
});
@@ -9,8 +9,9 @@
/* eslint-disable react-refresh/only-export-components */
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Code2 } from 'lucide-react';
import { AlertTriangle, Code2 } from 'lucide-react';
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
import { HeaderSection } from './header-section';
@@ -43,6 +44,7 @@ export function CopilotConfigForm() {
haikuModel,
isRawJsonValid,
hasChanges,
normalizationWarnings,
conflictDialog,
updateField,
applyPreset,
@@ -77,6 +79,26 @@ export function CopilotConfigForm() {
onSave={handleSave}
/>
{normalizationWarnings.length > 0 && (
<div className="px-6 pt-4 shrink-0">
<Alert variant="warning">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Deprecated Copilot models detected</AlertTitle>
<AlertDescription className="space-y-2">
<p>
Loading this page did not rewrite your files. Save the Copilot configuration to
persist these replacements.
</p>
<div className="space-y-1">
{normalizationWarnings.map((warning) => (
<p key={warning.message}>{warning.message}</p>
))}
</div>
</AlertDescription>
</Alert>
</div>
)}
{/* Split Layout */}
<div className="flex-1 flex divide-x overflow-hidden">
{/* Left Column: Friendly UI */}
@@ -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[] = [
@@ -5,6 +5,7 @@
import { useState, useMemo, useCallback } from 'react';
import { useCopilot } from '@/hooks/use-copilot';
import type { CopilotNormalizationWarning } from '@/hooks/use-copilot';
import { isApiConflictError } from '@/lib/api-client';
import { toast } from 'sonner';
import type { ModelPreset } from './types';
@@ -18,6 +19,17 @@ function checkMissingFields(settings: { env?: Record<string, string> } | undefin
return REQUIRED_ENV_KEYS.filter((key) => !env[key]?.trim());
}
function dedupeWarnings(
warnings: CopilotNormalizationWarning[] | undefined
): CopilotNormalizationWarning[] {
if (!warnings || warnings.length === 0) return [];
const unique = new Map<string, CopilotNormalizationWarning>();
warnings.forEach((warning) => {
unique.set(warning.message, warning);
});
return [...unique.values()];
}
export function useCopilotConfigForm() {
const {
config,
@@ -128,11 +140,20 @@ export function useCopilotConfigForm() {
[currentSettingsForValidation]
);
const handleSave = async () => {
const normalizationWarnings = useMemo(
() => dedupeWarnings([...(config?.warnings ?? []), ...(rawSettings?.warnings ?? [])]),
[config?.warnings, rawSettings?.warnings]
);
const handleSave = async ({
overwriteRawSettings = false,
}: { overwriteRawSettings?: boolean } = {}) => {
try {
const saveWarnings: CopilotNormalizationWarning[] = [];
// Save config changes
if (Object.keys(localOverrides).length > 0) {
await updateConfigAsync({
const configResult = await updateConfigAsync({
enabled,
auto_start: autoStart,
port,
@@ -144,26 +165,39 @@ export function useCopilotConfigForm() {
sonnet_model: sonnetModel || undefined,
haiku_model: haikuModel || undefined,
});
saveWarnings.push(...(configResult.warnings ?? []));
}
// Save raw JSON changes (no blocking validation - runtime uses defaults)
let missing: string[] = [];
if (rawJsonEdits !== null && isRawJsonValid) {
const settingsToSave = JSON.parse(rawJsonContent);
const missing = checkMissingFields(settingsToSave);
missing = checkMissingFields(settingsToSave);
await saveRawSettingsAsync({
const saveResult = await saveRawSettingsAsync({
settings: settingsToSave,
expectedMtime: rawSettings?.mtime,
expectedMtime: overwriteRawSettings ? undefined : rawSettings?.mtime,
});
saveWarnings.push(...(saveResult.warnings ?? []));
}
// Show warning if fields missing
if (missing.length > 0) {
toast.success('Copilot configuration saved', {
description: `Missing fields will use defaults: ${missing.join(', ')}`,
});
} else {
toast.success('Copilot configuration saved');
}
const uniqueWarnings = dedupeWarnings(saveWarnings);
const descriptions: string[] = [];
if (uniqueWarnings.length > 0) {
descriptions.push(uniqueWarnings.map((warning) => warning.message).join(' '));
}
if (missing.length > 0) {
descriptions.push(`Missing fields will use defaults: ${missing.join(', ')}`);
}
if (uniqueWarnings.length > 0) {
toast.warning('Copilot configuration saved with model adjustments', {
description: descriptions.join(' '),
});
} else if (descriptions.length > 0) {
toast.success('Copilot configuration saved', {
description: descriptions.join(' '),
});
} else {
toast.success('Copilot configuration saved');
}
@@ -183,8 +217,7 @@ export function useCopilotConfigForm() {
const handleConflictResolve = async (overwrite: boolean) => {
setConflictDialog(false);
if (overwrite) {
await refetchRawSettings();
handleSave();
await handleSave({ overwriteRawSettings: true });
} else {
setRawJsonEdits(null);
}
@@ -217,6 +250,7 @@ export function useCopilotConfigForm() {
haikuModel,
isRawJsonValid,
hasChanges,
normalizationWarnings,
// Dialog state
conflictDialog,
+38 -5
View File
@@ -9,6 +9,14 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ApiConflictError, withApiBase } from '@/lib/api-client';
// Types
export interface CopilotNormalizationWarning {
source: 'config' | 'settings';
tier: 'default' | 'opus' | 'sonnet' | 'haiku';
original: string;
replacement: string;
message: string;
}
export interface CopilotStatus {
enabled: boolean;
installed: boolean;
@@ -23,6 +31,10 @@ export interface CopilotStatus {
wait_on_limit: boolean;
}
export interface CopilotStatusResponse extends CopilotStatus {
warnings?: CopilotNormalizationWarning[];
}
export interface CopilotInfo {
installed: boolean;
version: string | null;
@@ -51,6 +63,10 @@ export interface CopilotConfig {
haiku_model?: string;
}
export interface CopilotConfigResponse extends CopilotConfig {
warnings?: CopilotNormalizationWarning[];
}
/** GitHub Copilot plan tiers */
export type CopilotPlanTier = 'free' | 'pro' | 'pro+' | 'business' | 'enterprise';
@@ -80,25 +96,33 @@ export interface CopilotRawSettings {
settings: {
env?: Record<string, string>;
};
effectiveSettings?: {
env?: Record<string, string>;
};
mtime: number;
path: string;
exists: boolean;
warnings?: CopilotNormalizationWarning[];
}
// API functions
async function fetchCopilotStatus(): Promise<CopilotStatus> {
async function fetchCopilotStatus(): Promise<CopilotStatusResponse> {
const res = await fetch(withApiBase('/copilot/status'));
if (!res.ok) throw new Error('Failed to fetch copilot status');
return res.json();
}
async function fetchCopilotConfig(): Promise<CopilotConfig> {
async function fetchCopilotConfig(): Promise<CopilotConfigResponse> {
const res = await fetch(withApiBase('/copilot/config'));
if (!res.ok) throw new Error('Failed to fetch copilot config');
return res.json();
}
async function fetchCopilotModels(): Promise<{ models: CopilotModel[]; current: string }> {
async function fetchCopilotModels(): Promise<{
models: CopilotModel[];
current: string;
warnings?: CopilotNormalizationWarning[];
}> {
const res = await fetch(withApiBase('/copilot/models'));
if (!res.ok) throw new Error('Failed to fetch copilot models');
return res.json();
@@ -110,7 +134,11 @@ async function fetchCopilotRawSettings(): Promise<CopilotRawSettings> {
return res.json();
}
async function updateCopilotConfig(config: Partial<CopilotConfig>): Promise<{ success: boolean }> {
async function updateCopilotConfig(config: Partial<CopilotConfig>): Promise<{
success: boolean;
copilot: CopilotConfig;
warnings?: CopilotNormalizationWarning[];
}> {
const res = await fetch(withApiBase('/copilot/config'), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -123,7 +151,12 @@ async function updateCopilotConfig(config: Partial<CopilotConfig>): Promise<{ su
async function saveCopilotRawSettings(data: {
settings: CopilotRawSettings['settings'];
expectedMtime?: number;
}): Promise<{ success: boolean; mtime: number }> {
}): Promise<{
success: boolean;
mtime: number;
settings?: CopilotRawSettings['settings'];
warnings?: CopilotNormalizationWarning[];
}> {
const res = await fetch(withApiBase('/copilot/settings/raw'), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },