Files
ccs/src/cliproxy/services/variant-settings.ts
T
Tam Nhu Tran 74868972dd fix(cliproxy): add session check on delete and atomic settings writes
- Block variant deletion when CLIProxy has active sessions
- Use temp file + rename pattern for atomic settings writes
- Prevents partial writes on crash/interruption
2026-02-12 11:01:49 +07:00

261 lines
7.7 KiB
TypeScript

/**
* CLIProxy Variant Settings File Manager
*
* Handles creation of settings.json files for CLIProxy variants.
* These files contain environment variables for Claude CLI integration.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CLIProxyProfileName } from '../../auth/profile-detector';
import { getCcsDir } from '../../utils/config-manager';
import { expandPath } from '../../utils/helpers';
import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator';
import { CLIProxyProvider } from '../types';
import { CompositeTierConfig } from '../../config/unified-config-types';
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector';
import { getEffectiveApiKey } from '../auth-token-manager';
import { warn } from '../../utils/ui';
/** Environment settings structure */
interface SettingsEnv {
ANTHROPIC_BASE_URL: string;
ANTHROPIC_AUTH_TOKEN: string;
ANTHROPIC_MODEL: string;
ANTHROPIC_DEFAULT_OPUS_MODEL: string;
ANTHROPIC_DEFAULT_SONNET_MODEL: string;
ANTHROPIC_DEFAULT_HAIKU_MODEL: string;
}
interface SettingsFile {
env: SettingsEnv;
}
/**
* Build settings env object for a variant
*/
function buildSettingsEnv(
provider: CLIProxyProfileName,
model: string,
port: number = CLIPROXY_DEFAULT_PORT
): SettingsEnv {
const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, port);
return {
ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '',
ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '',
ANTHROPIC_MODEL: model,
ANTHROPIC_DEFAULT_OPUS_MODEL: model,
ANTHROPIC_DEFAULT_SONNET_MODEL: model,
ANTHROPIC_DEFAULT_HAIKU_MODEL: baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || model,
};
}
/**
* Ensure directory exists
*/
function ensureDir(dir: string): void {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
/**
* Write settings file atomically using temp file + rename.
* The renameSync is atomic on POSIX systems, preventing partial writes on crash.
*/
function writeSettings(filePath: string, settings: SettingsFile): void {
const tempPath = `${filePath}.tmp.${process.pid}`;
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
fs.renameSync(tempPath, filePath);
}
/**
* Get settings file path for a variant
*/
export function getSettingsFilePath(provider: CLIProxyProfileName, name: string): string {
const ccsDir = getCcsDir();
return path.join(ccsDir, `${provider}-${name}.settings.json`);
}
/**
* Get settings file name (without path)
*/
export function getSettingsFileName(provider: CLIProxyProfileName, name: string): string {
return `${provider}-${name}.settings.json`;
}
/**
* Get relative settings path with ~ prefix
*/
export function getRelativeSettingsPath(provider: CLIProxyProfileName, name: string): string {
return `~/.ccs/${getSettingsFileName(provider, name)}`;
}
/**
* Create settings.json file for CLIProxy variant (legacy mode)
*/
export function createSettingsFile(
name: string,
provider: CLIProxyProfileName,
model: string,
port: number = CLIPROXY_DEFAULT_PORT
): string {
const ccsDir = getCcsDir();
const settingsPath = getSettingsFilePath(provider, name);
const settings: SettingsFile = {
env: buildSettingsEnv(provider, model, port),
};
ensureDir(ccsDir);
writeSettings(settingsPath, settings);
// Inject WebSearch hooks into variant settings
ensureProfileHooks(`${provider}-${name}`);
// Inject Image Analyzer hooks into variant settings
ensureImageAnalyzerHooks(`${provider}-${name}`);
return settingsPath;
}
/**
* Create settings.json file for CLIProxy variant (unified mode)
*/
export function createSettingsFileUnified(
name: string,
provider: CLIProxyProfileName,
model: string,
port: number = CLIPROXY_DEFAULT_PORT
): string {
const ccsDir = getCcsDir(); // Use centralized function for CCS_HOME support
const settingsPath = path.join(ccsDir, getSettingsFileName(provider, name));
const settings: SettingsFile = {
env: buildSettingsEnv(provider, model, port),
};
ensureDir(ccsDir);
writeSettings(settingsPath, settings);
// Inject WebSearch hooks into variant settings
ensureProfileHooks(`${provider}-${name}`);
// Inject Image Analyzer hooks into variant settings
ensureImageAnalyzerHooks(`${provider}-${name}`);
return settingsPath;
}
/**
* Build settings env object for a composite variant.
* Uses root URL (no /api/provider/ path) for model-based routing.
*/
function buildCompositeSettingsEnv(
tiers: { opus: CompositeTierConfig; sonnet: CompositeTierConfig; haiku: CompositeTierConfig },
defaultTier: 'opus' | 'sonnet' | 'haiku',
port: number = CLIPROXY_DEFAULT_PORT
): SettingsEnv {
const defaultModel = tiers[defaultTier].model;
return {
// Root URL — CLIProxyAPI routes based on model name, no provider prefix
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(),
ANTHROPIC_MODEL: defaultModel,
ANTHROPIC_DEFAULT_OPUS_MODEL: tiers.opus.model,
ANTHROPIC_DEFAULT_SONNET_MODEL: tiers.sonnet.model,
ANTHROPIC_DEFAULT_HAIKU_MODEL: tiers.haiku.model,
};
}
/**
* Create settings.json file for a composite variant.
*/
export function createCompositeSettingsFile(
name: string,
tiers: { opus: CompositeTierConfig; sonnet: CompositeTierConfig; haiku: CompositeTierConfig },
defaultTier: 'opus' | 'sonnet' | 'haiku',
port: number = CLIPROXY_DEFAULT_PORT
): string {
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `composite-${name}.settings.json`);
const settings: SettingsFile = {
env: buildCompositeSettingsEnv(tiers, defaultTier, port),
};
ensureDir(ccsDir);
writeSettings(settingsPath, settings);
// Inject WebSearch hooks into variant settings
ensureProfileHooks(`composite-${name}`);
// Inject Image Analyzer hooks into variant settings
ensureImageAnalyzerHooks(`composite-${name}`);
return settingsPath;
}
/**
* Get relative settings path for a composite variant
*/
export function getCompositeRelativeSettingsPath(name: string): string {
return `~/.ccs/composite-${name}.settings.json`;
}
/**
* Delete settings file if it exists.
* Uses expandPath() for cross-platform path handling.
*/
export function deleteSettingsFile(settingsPath: string): boolean {
const resolvedPath = expandPath(settingsPath);
if (fs.existsSync(resolvedPath)) {
fs.unlinkSync(resolvedPath);
return true;
}
return false;
}
/**
* Update model in an existing settings file
*/
export function updateSettingsModel(settingsPath: string, model: string): void {
const fileName = path.basename(settingsPath);
if (fileName.startsWith('composite-')) {
console.log(
warn('Cannot update model for composite variant. Edit config.yaml tiers directly.')
);
return;
}
const resolvedPath = settingsPath.replace(/^~/, os.homedir());
if (!fs.existsSync(resolvedPath)) {
return;
}
try {
const content = fs.readFileSync(resolvedPath, 'utf8');
const settings = JSON.parse(content) as SettingsFile;
if (model) {
settings.env = settings.env || ({} as SettingsEnv);
settings.env.ANTHROPIC_MODEL = model;
settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = model;
settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = model;
} else {
// Clear model settings to use defaults
delete (settings.env as unknown as Record<string, string>).ANTHROPIC_MODEL;
}
const tempPath = `${resolvedPath}.tmp.${process.pid}`;
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
fs.renameSync(tempPath, resolvedPath);
} catch {
// Ignore errors - settings file may be invalid
}
}