mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
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:
@@ -26,6 +26,7 @@ import {
|
||||
getRelativeSettingsPath,
|
||||
getCompositeRelativeSettingsPath,
|
||||
updateSettingsModel,
|
||||
updateSettingsProviderAndModel,
|
||||
} from './variant-settings';
|
||||
import {
|
||||
VariantConfig,
|
||||
@@ -230,10 +231,30 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
|
||||
return { success: false, error: 'Composite variant update not supported' };
|
||||
}
|
||||
|
||||
// Update model in settings file if provided
|
||||
if (updates.model !== undefined && existing.settings) {
|
||||
const providerChanged =
|
||||
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());
|
||||
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
|
||||
@@ -272,7 +293,7 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
|
||||
success: true,
|
||||
variant: {
|
||||
provider: updates.provider ?? existing.provider,
|
||||
model: updates.model ?? existing.model,
|
||||
model: updates.model?.trim() || existing.model,
|
||||
account: updates.account !== undefined ? updates.account : existing.account,
|
||||
port: existing.port,
|
||||
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
|
||||
const settingsPath = createCompositeSettingsFile(
|
||||
name,
|
||||
mergedTiers,
|
||||
newDefaultTier,
|
||||
existing.port
|
||||
existing.port,
|
||||
settingsRef
|
||||
);
|
||||
|
||||
// Save updated composite config to unified config
|
||||
@@ -427,7 +452,7 @@ export function updateCompositeVariant(
|
||||
type: 'composite',
|
||||
default_tier: newDefaultTier,
|
||||
tiers: mergedTiers,
|
||||
settings: getCompositeRelativeSettingsPath(name),
|
||||
settings: settingsRef,
|
||||
port: existing.port,
|
||||
};
|
||||
saveCompositeVariantUnified(name, compositeConfig);
|
||||
@@ -441,6 +466,7 @@ export function updateCompositeVariant(
|
||||
default_tier: newDefaultTier,
|
||||
tiers: mergedTiers,
|
||||
port: existing.port,
|
||||
settings: settingsRef,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { warn } from '../../utils/ui';
|
||||
|
||||
/** Environment settings structure */
|
||||
interface SettingsEnv {
|
||||
[key: string]: string;
|
||||
ANTHROPIC_BASE_URL: string;
|
||||
ANTHROPIC_AUTH_TOKEN: string;
|
||||
ANTHROPIC_MODEL: string;
|
||||
@@ -30,7 +31,8 @@ interface SettingsEnv {
|
||||
}
|
||||
|
||||
interface SettingsFile {
|
||||
env: SettingsEnv;
|
||||
env: Record<string, string>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,23 +181,64 @@ export function createCompositeSettingsFile(
|
||||
name: string,
|
||||
tiers: { opus: CompositeTierConfig; sonnet: CompositeTierConfig; haiku: CompositeTierConfig },
|
||||
defaultTier: 'opus' | 'sonnet' | 'haiku',
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
port: number = CLIPROXY_DEFAULT_PORT,
|
||||
settingsPathOverride?: string
|
||||
): string {
|
||||
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 = {
|
||||
env: buildCompositeSettingsEnv(tiers, defaultTier, port),
|
||||
};
|
||||
const coreEnv = 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);
|
||||
|
||||
// Inject WebSearch hooks into variant settings
|
||||
ensureProfileHooks(`composite-${name}`);
|
||||
|
||||
// Inject Image Analyzer hooks into variant settings
|
||||
ensureImageAnalyzerHooks(`composite-${name}`);
|
||||
// Hook injectors target ~/.ccs/<profile>.settings.json; only run for default path.
|
||||
if (path.resolve(settingsPath) === path.resolve(defaultSettingsPath)) {
|
||||
ensureProfileHooks(`composite-${name}`);
|
||||
ensureImageAnalyzerHooks(`composite-${name}`);
|
||||
}
|
||||
|
||||
return settingsPath;
|
||||
}
|
||||
@@ -258,3 +301,61 @@ export function updateSettingsModel(settingsPath: string, model: string): void {
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -522,7 +522,14 @@ export async function handleEdit(
|
||||
)) 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 || '';
|
||||
if (changeModel) {
|
||||
const providerForModel = newProvider || (variant.provider as CLIProxyProfileName);
|
||||
|
||||
@@ -199,13 +199,14 @@ router.put('/:name', (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const persisted = result.variant;
|
||||
res.json({
|
||||
name,
|
||||
type: 'composite',
|
||||
default_tier,
|
||||
tiers,
|
||||
settings: result.variant?.settings,
|
||||
port: result.variant?.port,
|
||||
default_tier: persisted?.default_tier,
|
||||
tiers: persisted?.tiers,
|
||||
settings: persisted?.settings,
|
||||
port: persisted?.port,
|
||||
updated: true,
|
||||
});
|
||||
return;
|
||||
@@ -215,7 +216,8 @@ router.put('/:name', (req: Request, res: Response): void => {
|
||||
const result = updateVariant(name, { provider, account, model });
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user