mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
fix(copilot): stop mutating config on read
This commit is contained in:
@@ -12,12 +12,12 @@ import {
|
||||
stopDaemon,
|
||||
getAvailableModels,
|
||||
isCopilotApiInstalled,
|
||||
normalizeCopilotConfig,
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -56,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.
|
||||
*/
|
||||
@@ -125,8 +137,8 @@ async function handleAuth(): Promise<number> {
|
||||
* Handle status subcommand.
|
||||
*/
|
||||
async function handleStatus(): Promise<number> {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const copilotConfig = normalizeCopilotConfig(config.copilot ?? DEFAULT_COPILOT_CONFIG);
|
||||
const { config: copilotConfig, warnings } = loadCopilotConfigWithWarnings();
|
||||
printCopilotWarnings(warnings.map((warning) => warning.message));
|
||||
|
||||
const status = await getCopilotStatus(copilotConfig);
|
||||
|
||||
@@ -183,8 +195,8 @@ async function handleStatus(): Promise<number> {
|
||||
* Handle models subcommand.
|
||||
*/
|
||||
async function handleModels(): Promise<number> {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const copilotConfig = normalizeCopilotConfig(config.copilot ?? DEFAULT_COPILOT_CONFIG);
|
||||
const { config: copilotConfig, warnings } = loadCopilotConfigWithWarnings();
|
||||
printCopilotWarnings(warnings.map((warning) => warning.message));
|
||||
|
||||
console.log('Available Copilot Models');
|
||||
console.log('────────────────────────');
|
||||
@@ -274,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) {
|
||||
@@ -312,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}...`));
|
||||
|
||||
|
||||
@@ -11,9 +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 { normalizeCopilotConfig } from './copilot-model-normalizer';
|
||||
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';
|
||||
@@ -22,7 +22,7 @@ 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 normalizedConfig = normalizeCopilotConfigWithWarnings(config).config;
|
||||
const [auth, daemonRunning] = await Promise.all([
|
||||
checkAuthStatus(),
|
||||
isDaemonRunning(normalizedConfig.port),
|
||||
@@ -45,7 +45,7 @@ export function generateCopilotEnv(
|
||||
config: CopilotConfig,
|
||||
claudeConfigDir?: string
|
||||
): Record<string, string> {
|
||||
const normalizedConfig = normalizeCopilotConfig(config);
|
||||
const normalizedConfig = normalizeCopilotConfigWithWarnings(config).config;
|
||||
|
||||
// Use mapped models if configured, otherwise fall back to default model
|
||||
const opusModel = normalizedConfig.opus_model || normalizedConfig.model;
|
||||
@@ -83,7 +83,15 @@ export async function executeCopilotProfile(
|
||||
claudeConfigDir?: string,
|
||||
claudeCliPath: string = 'claude'
|
||||
): Promise<number> {
|
||||
const normalizedConfig = normalizeCopilotConfig(config);
|
||||
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 {
|
||||
|
||||
@@ -9,38 +9,15 @@ 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 type CopilotNormalizationSource = 'config' | 'settings';
|
||||
export type CopilotNormalizationTier = 'default' | 'opus' | 'sonnet' | 'haiku';
|
||||
|
||||
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 CopilotNormalizationWarning {
|
||||
source: CopilotNormalizationSource;
|
||||
tier: CopilotNormalizationTier;
|
||||
original: string;
|
||||
replacement: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface CopilotSettingsPayload {
|
||||
@@ -48,50 +25,247 @@ export interface CopilotSettingsPayload {
|
||||
[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 {
|
||||
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,
|
||||
},
|
||||
};
|
||||
return normalizeCopilotSettingsWithWarnings(settings, fallbackConfig).settings;
|
||||
}
|
||||
|
||||
@@ -48,8 +48,10 @@ export {
|
||||
export {
|
||||
DEPRECATED_COPILOT_MODEL_IDS,
|
||||
normalizeCopilotConfig,
|
||||
normalizeCopilotConfigWithWarnings,
|
||||
normalizeCopilotModelId,
|
||||
normalizeCopilotSettings,
|
||||
normalizeCopilotSettingsWithWarnings,
|
||||
} from './copilot-model-normalizer';
|
||||
|
||||
// Usage
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -29,6 +29,18 @@ describe('generateCopilotEnv', () => {
|
||||
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');
|
||||
|
||||
@@ -3,8 +3,10 @@ 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', () => {
|
||||
@@ -16,19 +18,18 @@ describe('copilot-model-normalizer', () => {
|
||||
expect(normalizeCopilotModelId('raptor-mini')).toBe(DEFAULT_COPILOT_CONFIG.model);
|
||||
});
|
||||
|
||||
it('normalizes Copilot config tier mappings', () => {
|
||||
const normalized = normalizeCopilotConfig({
|
||||
it('falls back deprecated tier overrides to the normalized base model', () => {
|
||||
const result = normalizeCopilotConfigWithWarnings({
|
||||
...DEFAULT_COPILOT_CONFIG,
|
||||
model: 'raptor-mini',
|
||||
opus_model: 'raptor-mini',
|
||||
sonnet_model: 'raptor-mini',
|
||||
model: 'claude-sonnet-4.5',
|
||||
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');
|
||||
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', () => {
|
||||
@@ -55,4 +56,25 @@ describe('copilot-model-normalizer', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ 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 = '';
|
||||
@@ -139,11 +141,9 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
describe('Copilot Routes', () => {
|
||||
it('GET /api/copilot/config normalizes and persists stale raptor-mini config', async () => {
|
||||
it('GET /api/copilot/config returns normalized values and warnings without persisting', async () => {
|
||||
seedCopilotConfig({
|
||||
model: 'raptor-mini',
|
||||
opus_model: 'raptor-mini',
|
||||
sonnet_model: 'raptor-mini',
|
||||
model: 'claude-sonnet-4.5',
|
||||
haiku_model: 'raptor-mini',
|
||||
});
|
||||
|
||||
@@ -152,30 +152,26 @@ describe('Copilot Routes', () => {
|
||||
|
||||
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('gpt-4.1');
|
||||
expect(body.opus_model).toBeUndefined();
|
||||
expect(body.sonnet_model).toBeUndefined();
|
||||
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('gpt-4.1');
|
||||
expect(persisted?.opus_model).toBeUndefined();
|
||||
expect(persisted?.sonnet_model).toBeUndefined();
|
||||
expect(persisted?.model).toBe('claude-sonnet-4.5');
|
||||
expect(persisted?.haiku_model).toBeUndefined();
|
||||
});
|
||||
|
||||
it('PUT /api/copilot/config normalizes stale raptor-mini payloads before save', async () => {
|
||||
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: 'raptor-mini',
|
||||
opus_model: 'raptor-mini',
|
||||
sonnet_model: 'raptor-mini',
|
||||
model: 'claude-sonnet-4.5',
|
||||
haiku_model: 'raptor-mini',
|
||||
}),
|
||||
});
|
||||
@@ -183,6 +179,7 @@ describe('Copilot Routes', () => {
|
||||
|
||||
const body = (await response.json()) as {
|
||||
success: boolean;
|
||||
warnings?: Array<{ tier: string; replacement: string }>;
|
||||
copilot: {
|
||||
model: string;
|
||||
opus_model?: string;
|
||||
@@ -191,30 +188,30 @@ describe('Copilot Routes', () => {
|
||||
};
|
||||
};
|
||||
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');
|
||||
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('gpt-4.1');
|
||||
expect(persisted?.opus_model).toBeUndefined();
|
||||
expect(persisted?.sonnet_model).toBeUndefined();
|
||||
expect(persisted?.haiku_model).toBeUndefined();
|
||||
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 normalizes and persists stale raw settings', async () => {
|
||||
seedCopilotConfig({ model: '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_MODEL: 'raptor-mini',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'raptor-mini',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'raptor-mini',
|
||||
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);
|
||||
@@ -223,20 +220,21 @@ describe('Copilot Routes', () => {
|
||||
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_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');
|
||||
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']);
|
||||
|
||||
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');
|
||||
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 () => {
|
||||
@@ -248,32 +246,52 @@ describe('Copilot Routes', () => {
|
||||
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',
|
||||
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('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');
|
||||
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('gpt-4.1');
|
||||
expect(persistedConfig?.opus_model).toBeUndefined();
|
||||
expect(persistedConfig?.sonnet_model).toBeUndefined();
|
||||
expect(persistedConfig?.haiku_model).toBeUndefined();
|
||||
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('GET /api/copilot/models reports normalized current model for stale config', async () => {
|
||||
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`);
|
||||
@@ -281,11 +299,13 @@ describe('Copilot Routes', () => {
|
||||
|
||||
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(loadOrCreateUnifiedConfig().copilot?.model).toBe('gpt-4.1');
|
||||
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 */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user