fix(cliproxy): keep variant updates atomic and persisted

- require model when changing single-variant provider to avoid mixed config

- rewrite provider-specific core env when provider changes while preserving custom fields

- preserve custom composite settings path and return persisted composite PUT state
This commit is contained in:
Tam Nhu Tran
2026-02-13 06:19:12 +07:00
parent 1a23f912a4
commit 7bb9ceb4af
4 changed files with 160 additions and 24 deletions
+32 -6
View File
@@ -26,6 +26,7 @@ import {
getRelativeSettingsPath, getRelativeSettingsPath,
getCompositeRelativeSettingsPath, getCompositeRelativeSettingsPath,
updateSettingsModel, updateSettingsModel,
updateSettingsProviderAndModel,
} from './variant-settings'; } from './variant-settings';
import { import {
VariantConfig, VariantConfig,
@@ -230,10 +231,30 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
return { success: false, error: 'Composite variant update not supported' }; return { success: false, error: 'Composite variant update not supported' };
} }
// Update model in settings file if provided const providerChanged =
if (updates.model !== undefined && existing.settings) { updates.provider !== undefined && updates.provider !== existing.provider;
const hasModelUpdate = updates.model !== undefined && updates.model.trim().length > 0;
if (providerChanged && !hasModelUpdate) {
return {
success: false,
error: 'Changing provider requires model update in the same request',
};
}
// Update settings file
if (existing.settings) {
const settingsPath = existing.settings.replace(/^~/, os.homedir()); const settingsPath = existing.settings.replace(/^~/, os.homedir());
updateSettingsModel(settingsPath, updates.model); if (providerChanged) {
updateSettingsProviderAndModel(
settingsPath,
updates.provider as CLIProxyProfileName,
updates.model?.trim() || '',
existing.port
);
} else if (updates.model !== undefined) {
updateSettingsModel(settingsPath, updates.model);
}
} }
// Update config entry if provider or account changed // Update config entry if provider or account changed
@@ -272,7 +293,7 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
success: true, success: true,
variant: { variant: {
provider: updates.provider ?? existing.provider, provider: updates.provider ?? existing.provider,
model: updates.model ?? existing.model, model: updates.model?.trim() || existing.model,
account: updates.account !== undefined ? updates.account : existing.account, account: updates.account !== undefined ? updates.account : existing.account,
port: existing.port, port: existing.port,
settings: existing.settings, settings: existing.settings,
@@ -414,12 +435,16 @@ export function updateCompositeVariant(
} }
} }
// Preserve existing settings path when configured; otherwise use default path.
const settingsRef = existing.settings || getCompositeRelativeSettingsPath(name);
// Create new settings file with updated config // Create new settings file with updated config
const settingsPath = createCompositeSettingsFile( const settingsPath = createCompositeSettingsFile(
name, name,
mergedTiers, mergedTiers,
newDefaultTier, newDefaultTier,
existing.port existing.port,
settingsRef
); );
// Save updated composite config to unified config // Save updated composite config to unified config
@@ -427,7 +452,7 @@ export function updateCompositeVariant(
type: 'composite', type: 'composite',
default_tier: newDefaultTier, default_tier: newDefaultTier,
tiers: mergedTiers, tiers: mergedTiers,
settings: getCompositeRelativeSettingsPath(name), settings: settingsRef,
port: existing.port, port: existing.port,
}; };
saveCompositeVariantUnified(name, compositeConfig); saveCompositeVariantUnified(name, compositeConfig);
@@ -441,6 +466,7 @@ export function updateCompositeVariant(
default_tier: newDefaultTier, default_tier: newDefaultTier,
tiers: mergedTiers, tiers: mergedTiers,
port: existing.port, port: existing.port,
settings: settingsRef,
}, },
}; };
} catch (error) { } catch (error) {
+113 -12
View File
@@ -21,6 +21,7 @@ import { warn } from '../../utils/ui';
/** Environment settings structure */ /** Environment settings structure */
interface SettingsEnv { interface SettingsEnv {
[key: string]: string;
ANTHROPIC_BASE_URL: string; ANTHROPIC_BASE_URL: string;
ANTHROPIC_AUTH_TOKEN: string; ANTHROPIC_AUTH_TOKEN: string;
ANTHROPIC_MODEL: string; ANTHROPIC_MODEL: string;
@@ -30,7 +31,8 @@ interface SettingsEnv {
} }
interface SettingsFile { interface SettingsFile {
env: SettingsEnv; env: Record<string, string>;
[key: string]: unknown;
} }
/** /**
@@ -179,23 +181,64 @@ export function createCompositeSettingsFile(
name: string, name: string,
tiers: { opus: CompositeTierConfig; sonnet: CompositeTierConfig; haiku: CompositeTierConfig }, tiers: { opus: CompositeTierConfig; sonnet: CompositeTierConfig; haiku: CompositeTierConfig },
defaultTier: 'opus' | 'sonnet' | 'haiku', defaultTier: 'opus' | 'sonnet' | 'haiku',
port: number = CLIPROXY_DEFAULT_PORT port: number = CLIPROXY_DEFAULT_PORT,
settingsPathOverride?: string
): string { ): string {
const ccsDir = getCcsDir(); const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `composite-${name}.settings.json`); const defaultSettingsPath = path.join(ccsDir, `composite-${name}.settings.json`);
const settingsPath = settingsPathOverride
? (() => {
const expanded = expandPath(settingsPathOverride);
return path.isAbsolute(expanded) ? expanded : path.join(ccsDir, expanded);
})()
: defaultSettingsPath;
const settingsDir = path.dirname(settingsPath);
const settings: SettingsFile = { const coreEnv = buildCompositeSettingsEnv(tiers, defaultTier, port);
env: buildCompositeSettingsEnv(tiers, defaultTier, port), let settings: SettingsFile = { env: coreEnv };
};
ensureDir(ccsDir); // Preserve non-core env vars and non-env fields (hooks/presets/etc.) when regenerating.
if (fs.existsSync(settingsPath)) {
try {
const content = fs.readFileSync(settingsPath, 'utf-8');
const parsed = JSON.parse(content) as SettingsFile;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const existingEnv =
parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env)
? (parsed.env as Record<string, string>)
: {};
const {
ANTHROPIC_BASE_URL: _baseUrl,
ANTHROPIC_AUTH_TOKEN: _authToken,
ANTHROPIC_MODEL: _model,
ANTHROPIC_DEFAULT_OPUS_MODEL: _opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: _sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: _haiku,
...extraEnv
} = existingEnv;
settings = {
...parsed,
env: {
...extraEnv,
...coreEnv,
},
};
}
} catch {
// Invalid JSON — overwrite with a clean settings object.
}
}
ensureDir(settingsDir);
writeSettings(settingsPath, settings); writeSettings(settingsPath, settings);
// Inject WebSearch hooks into variant settings // Hook injectors target ~/.ccs/<profile>.settings.json; only run for default path.
ensureProfileHooks(`composite-${name}`); if (path.resolve(settingsPath) === path.resolve(defaultSettingsPath)) {
ensureProfileHooks(`composite-${name}`);
// Inject Image Analyzer hooks into variant settings ensureImageAnalyzerHooks(`composite-${name}`);
ensureImageAnalyzerHooks(`composite-${name}`); }
return settingsPath; return settingsPath;
} }
@@ -258,3 +301,61 @@ export function updateSettingsModel(settingsPath: string, model: string): void {
// Ignore errors - settings file may be invalid // Ignore errors - settings file may be invalid
} }
} }
/**
* Update provider + model core env vars in an existing single-provider settings file.
* Preserves non-core env vars and top-level settings keys (hooks, presets, etc.).
*/
export function updateSettingsProviderAndModel(
settingsPath: string,
provider: CLIProxyProfileName,
model: string,
port: number = CLIPROXY_DEFAULT_PORT
): void {
const resolvedPath = expandPath(settingsPath);
const fileName = path.basename(resolvedPath);
if (fileName.startsWith('composite-')) {
console.log(
warn('Cannot update provider/model for composite variant. Edit config.yaml tiers directly.')
);
return;
}
const coreEnv = buildSettingsEnv(provider, model, port);
let settings: SettingsFile = { env: coreEnv };
if (fs.existsSync(resolvedPath)) {
try {
const content = fs.readFileSync(resolvedPath, 'utf8');
const parsed = JSON.parse(content) as SettingsFile;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const existingEnv =
parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env)
? (parsed.env as Record<string, string>)
: {};
const {
ANTHROPIC_BASE_URL: _baseUrl,
ANTHROPIC_AUTH_TOKEN: _authToken,
ANTHROPIC_MODEL: _model,
ANTHROPIC_DEFAULT_OPUS_MODEL: _opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: _sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: _haiku,
...extraEnv
} = existingEnv;
settings = {
...parsed,
env: {
...extraEnv,
...coreEnv,
},
};
}
} catch {
// Keep default and overwrite malformed file.
}
}
ensureDir(path.dirname(resolvedPath));
writeSettings(resolvedPath, settings);
}
+8 -1
View File
@@ -522,7 +522,14 @@ export async function handleEdit(
)) as CLIProxyProfileName; )) as CLIProxyProfileName;
} }
const changeModel = await InteractivePrompt.confirm('Change model?', { default: false }); const providerChanged = !!(newProvider && newProvider !== variant.provider);
if (providerChanged) {
console.log(info('Provider changed. Model selection is required.'));
}
const changeModel = providerChanged
? true
: await InteractivePrompt.confirm('Change model?', { default: false });
let newModel = variant.model || ''; let newModel = variant.model || '';
if (changeModel) { if (changeModel) {
const providerForModel = newProvider || (variant.provider as CLIProxyProfileName); const providerForModel = newProvider || (variant.provider as CLIProxyProfileName);
+7 -5
View File
@@ -199,13 +199,14 @@ router.put('/:name', (req: Request, res: Response): void => {
return; return;
} }
const persisted = result.variant;
res.json({ res.json({
name, name,
type: 'composite', type: 'composite',
default_tier, default_tier: persisted?.default_tier,
tiers, tiers: persisted?.tiers,
settings: result.variant?.settings, settings: persisted?.settings,
port: result.variant?.port, port: persisted?.port,
updated: true, updated: true,
}); });
return; return;
@@ -215,7 +216,8 @@ router.put('/:name', (req: Request, res: Response): void => {
const result = updateVariant(name, { provider, account, model }); const result = updateVariant(name, { provider, account, model });
if (!result.success) { if (!result.success) {
res.status(404).json({ error: result.error }); const status = result.error?.includes('not found') ? 404 : 400;
res.status(status).json({ error: result.error });
return; return;
} }