feat(cliproxy): add sync API routes

- add /api/cliproxy/sync endpoints for local sync
- add /api/cliproxy/sync/aliases for model alias management
- add /api/cliproxy/sync/auto-sync for watcher toggle
- separate try-catch for config save vs watcher restart
This commit is contained in:
kaitranntt
2026-01-28 11:57:22 -05:00
parent cb6c21216d
commit 56500fee98
3 changed files with 247 additions and 1 deletions
+11 -1
View File
@@ -12,6 +12,7 @@ import path from 'path';
import { WebSocketServer } from 'ws';
import { setupWebSocket } from './websocket';
import { createSessionMiddleware, authMiddleware } from './middleware/auth-middleware';
import { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync';
export interface ServerOptions {
port: number;
@@ -93,7 +94,16 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
}
// WebSocket connection handler + file watcher
const { cleanup } = setupWebSocket(wss);
const { cleanup: wsCleanup } = setupWebSocket(wss);
// Start auto-sync watcher (if enabled in config)
startAutoSyncWatcher();
// Combined cleanup function
const cleanup = () => {
wsCleanup();
stopAutoSyncWatcher().catch(() => {});
};
// Start listening
return new Promise<ServerInstance>((resolve) => {
@@ -0,0 +1,234 @@
/**
* CLIProxy Sync Routes - Local sync management for CLIProxy profiles
*/
import { Router, Request, Response } from 'express';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import {
generateSyncPayload,
generateSyncPreview,
listAllAliases,
addProfileAlias,
removeProfileAlias,
getAutoSyncStatus,
restartAutoSyncWatcher,
syncToLocalConfig,
getLocalSyncStatus,
} from '../../cliproxy/sync';
import { saveUnifiedConfig } from '../../config/unified-config-loader';
const router = Router();
/**
* GET /api/cliproxy/sync/status - Get local sync status
* Returns: { configExists, configPath, currentKeyCount, syncableProfileCount }
*/
router.get('/status', async (_req: Request, res: Response): Promise<void> => {
try {
const status = getLocalSyncStatus();
res.json({
configured: status.configExists,
configPath: status.configPath,
currentKeyCount: status.currentKeyCount,
syncableProfileCount: status.syncableProfileCount,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/cliproxy/sync/preview - Get sync preview (dry run)
* Returns: { profiles: SyncPreviewItem[], payload: ClaudeKey[] }
*/
router.get('/preview', async (_req: Request, res: Response): Promise<void> => {
try {
const preview = generateSyncPreview();
const payload = generateSyncPayload();
// Mask API keys in payload for preview
const maskedPayload = payload.map((key) => ({
...key,
'api-key': maskApiKey(key['api-key']),
}));
res.json({
profiles: preview,
payload: maskedPayload,
count: payload.length,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/cliproxy/sync - Execute sync to local CLIProxy config
* Returns: { success, syncedCount, configPath, error? }
*/
router.post('/', async (_req: Request, res: Response): Promise<void> => {
try {
const result = syncToLocalConfig();
if (!result.success) {
res.status(500).json({
success: false,
error: result.error,
configPath: result.configPath,
});
return;
}
if (result.syncedCount === 0) {
res.json({
success: true,
syncedCount: 0,
message: 'No profiles to sync',
configPath: result.configPath,
});
return;
}
const preview = generateSyncPreview();
res.json({
success: true,
syncedCount: result.syncedCount,
configPath: result.configPath,
profiles: preview.map((p) => p.name),
});
} catch (error) {
res.status(500).json({
success: false,
error: (error as Error).message,
});
}
});
// ==================== Model Aliases ====================
/**
* GET /api/cliproxy/sync/aliases - List all model aliases
* Returns: { aliases: Record<string, ModelAlias[]> }
*/
router.get('/aliases', async (_req: Request, res: Response): Promise<void> => {
try {
const aliases = listAllAliases();
res.json({ aliases });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/cliproxy/sync/aliases - Add a model alias
* Body: { profile: string, from: string, to: string }
* Returns: { success: true }
*/
router.post('/aliases', async (req: Request, res: Response): Promise<void> => {
try {
const { profile, from, to } = req.body;
if (!profile || !from || !to) {
res.status(400).json({ error: 'Missing required fields: profile, from, to' });
return;
}
addProfileAlias(profile, from, to);
res.json({ success: true, profile, from, to });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* DELETE /api/cliproxy/sync/aliases - Remove a model alias
* Body: { profile: string, from: string }
* Returns: { success: boolean }
*/
router.delete('/aliases', async (req: Request, res: Response): Promise<void> => {
try {
const { profile, from } = req.body;
if (!profile || !from) {
res.status(400).json({ error: 'Missing required fields: profile, from' });
return;
}
const removed = removeProfileAlias(profile, from);
res.json({ success: removed, profile, from });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
// ==================== Auto-Sync ====================
/**
* GET /api/cliproxy/sync/auto-sync - Get auto-sync status
* Returns: { enabled, watching, syncing }
*/
router.get('/auto-sync', async (_req: Request, res: Response): Promise<void> => {
try {
const status = getAutoSyncStatus();
res.json(status);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* PUT /api/cliproxy/sync/auto-sync - Toggle auto-sync setting
* Body: { enabled: boolean }
* Returns: { success: true, enabled }
*/
router.put('/auto-sync', async (req: Request, res: Response): Promise<void> => {
try {
const { enabled } = req.body;
if (typeof enabled !== 'boolean') {
res.status(400).json({ error: 'Invalid field: enabled must be a boolean' });
return;
}
// Update config
const config = loadOrCreateUnifiedConfig();
if (!config.cliproxy) {
// Should not happen as loadOrCreate initializes it, but handle gracefully
res.status(500).json({ error: 'CLIProxy config not initialized' });
return;
}
// Save config
try {
config.cliproxy.auto_sync = enabled;
saveUnifiedConfig(config);
} catch (error) {
res.status(500).json({ error: `Failed to save config: ${(error as Error).message}` });
return;
}
// Restart watcher (separate operation)
try {
await restartAutoSyncWatcher();
} catch (watcherError) {
// Log but don't fail - config was saved successfully
console.warn('Watcher restart failed:', (watcherError as Error).message);
}
res.json({ success: true, enabled });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Mask API key for display (show first 4 and last 4 chars).
*/
function maskApiKey(key: string): string {
if (key.length <= 12) {
return '***';
}
return `${key.slice(0, 4)}...${key.slice(-4)}`;
}
export default router;
+2
View File
@@ -18,6 +18,7 @@ import settingsRoutes from './settings-routes';
import websearchRoutes from './websearch-routes';
import cliproxyAuthRoutes from './cliproxy-auth-routes';
import cliproxyStatsRoutes from './cliproxy-stats-routes';
import cliproxySyncRoutes from './cliproxy-sync-routes';
import copilotRoutes from './copilot-routes';
import miscRoutes from './misc-routes';
import cliproxyServerRoutes from './proxy-routes';
@@ -51,6 +52,7 @@ apiRoutes.use('/persist', persistRoutes);
apiRoutes.use('/cliproxy', variantRoutes);
apiRoutes.use('/cliproxy/auth', cliproxyAuthRoutes);
apiRoutes.use('/cliproxy', cliproxyStatsRoutes);
apiRoutes.use('/cliproxy/sync', cliproxySyncRoutes);
apiRoutes.use('/cliproxy/openai-compat', providerRoutes);
// ==================== WebSearch ====================