mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 07:12:18 +00:00
fix(copilot): stop mutating config on read
This commit is contained in:
1 parent
c409a4522c
commit
7bd4049602
12 files changed
+657
-330
No files matched your search
@@ -18,9 +18,12 @@ import {
|
||||
getCopilotApiInfo,
|
||||
getInstalledVersion as getCopilotInstalledVersion,
|
||||
} from '../../copilot';
|
||||
import { normalizeCopilotConfig } from '../../copilot/copilot-model-normalizer';
|
||||
import {
|
||||
normalizeCopilotConfigWithWarnings,
|
||||
type CopilotNormalizationWarning,
|
||||
} 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, mutateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import copilotSettingsRoutes from './copilot-settings-routes';
|
||||
|
||||
const router = Router();
|
||||
@@ -40,22 +43,12 @@ 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 {
|
||||
function loadEffectiveCopilotConfig(): {
|
||||
config: CopilotConfig;
|
||||
warnings: CopilotNormalizationWarning[];
|
||||
} {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const copilotConfig = normalizeCopilotConfig(config.copilot ?? DEFAULT_COPILOT_CONFIG);
|
||||
persistNormalizedCopilotConfig(config, copilotConfig);
|
||||
return copilotConfig;
|
||||
return normalizeCopilotConfigWithWarnings(config.copilot ?? DEFAULT_COPILOT_CONFIG);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,7 +56,7 @@ function loadNormalizedCopilotConfig(): CopilotConfig {
|
||||
*/
|
||||
router.get('/status', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const copilotConfig = loadNormalizedCopilotConfig();
|
||||
const { config: copilotConfig, warnings } = loadEffectiveCopilotConfig();
|
||||
const status = await getCopilotStatus(copilotConfig);
|
||||
const installed = isCopilotApiInstalled();
|
||||
const version = getCopilotInstalledVersion();
|
||||
@@ -80,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 });
|
||||
@@ -91,7 +85,8 @@ router.get('/status', async (_req: Request, res: Response): Promise<void> => {
|
||||
*/
|
||||
router.get('/config', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
res.json(loadNormalizedCopilotConfig());
|
||||
const { config: copilotConfig, warnings } = loadEffectiveCopilotConfig();
|
||||
res.json({ ...copilotConfig, warnings });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
@@ -213,47 +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
|
||||
const nextCopilotConfig = normalizeCopilotConfig({
|
||||
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;
|
||||
});
|
||||
|
||||
config.copilot = nextCopilotConfig;
|
||||
saveUnifiedConfig(config);
|
||||
res.json({ success: true, copilot: nextCopilotConfig });
|
||||
res.json({ success: true, copilot: nextCopilotConfig, warnings });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
@@ -288,7 +287,7 @@ router.get('/auth/status', async (_req: Request, res: Response): Promise<void> =
|
||||
*/
|
||||
router.get('/models', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const copilotConfig = loadNormalizedCopilotConfig();
|
||||
const { config: copilotConfig, warnings } = loadEffectiveCopilotConfig();
|
||||
const port = copilotConfig.port;
|
||||
const currentModel = copilotConfig.model;
|
||||
const models = await getCopilotModels(port);
|
||||
@@ -298,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 });
|
||||
}
|
||||
@@ -309,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) {
|
||||
@@ -330,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 });
|
||||
}
|
||||
@@ -341,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 });
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@ import { Router, Request, Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
normalizeCopilotConfig,
|
||||
normalizeCopilotSettings,
|
||||
normalizeCopilotConfigWithWarnings,
|
||||
normalizeCopilotSettingsWithWarnings,
|
||||
} from '../../copilot/copilot-model-normalizer';
|
||||
import { getCcsDir } from '../../utils/config-manager';
|
||||
import { DEFAULT_COPILOT_CONFIG, type CopilotConfig } from '../../config/unified-config-types';
|
||||
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { getCcsDir } from '../../utils/config-manager';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -26,20 +26,40 @@ function writeSettingsFile(
|
||||
): void {
|
||||
const nextContent = serializeSettings(settings);
|
||||
if (previousContent === nextContent) return;
|
||||
const tempPath = `${settingsPath}.tmp`;
|
||||
const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
||||
fs.writeFileSync(tempPath, nextContent);
|
||||
fs.renameSync(tempPath, settingsPath);
|
||||
}
|
||||
|
||||
function persistNormalizedCopilotConfig(
|
||||
config: ReturnType<typeof loadOrCreateUnifiedConfig>,
|
||||
copilotConfig: CopilotConfig
|
||||
function restoreSettingsFile(
|
||||
settingsPath: string,
|
||||
previousContent: string | undefined,
|
||||
existedBefore: boolean
|
||||
): void {
|
||||
const current = JSON.stringify(config.copilot ?? DEFAULT_COPILOT_CONFIG);
|
||||
const next = JSON.stringify(copilotConfig);
|
||||
if (current === next) return;
|
||||
config.copilot = copilotConfig;
|
||||
saveUnifiedConfig(config);
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,74 +70,39 @@ router.get('/raw', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const settingsPath = path.join(getCcsDir(), 'copilot.settings.json');
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const copilotConfig = normalizeCopilotConfig(config.copilot ?? DEFAULT_COPILOT_CONFIG);
|
||||
persistNormalizedCopilotConfig(config, copilotConfig);
|
||||
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 = 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
|
||||
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 = 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 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 });
|
||||
@@ -132,8 +117,16 @@ 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);
|
||||
|
||||
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) {
|
||||
@@ -144,43 +137,40 @@ router.put('/raw', (req: Request, res: Response): void => {
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedSettings = normalizeCopilotSettings(
|
||||
const settingsResult = normalizeCopilotSettingsWithWarnings(
|
||||
settings as Record<string, unknown>,
|
||||
copilotConfig
|
||||
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
|
||||
);
|
||||
writeSettingsFile(settingsPath, normalizedSettings);
|
||||
|
||||
// Also sync model mappings back to unified config
|
||||
const env =
|
||||
normalizedSettings.env &&
|
||||
typeof normalizedSettings.env === 'object' &&
|
||||
!Array.isArray(normalizedSettings.env)
|
||||
? normalizedSettings.env
|
||||
: {};
|
||||
|
||||
config.copilot = normalizeCopilotConfig({
|
||||
...(config.copilot ?? DEFAULT_COPILOT_CONFIG),
|
||||
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);
|
||||
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 });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user