mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-05 20:22:03 +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,
|
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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user