feat: Enhance CLI tools and settings management

- Added auto-initialization of CSRF token for state-changing requests in cli-manager.js.
- Refactored Claude CLI Tools configuration to separate tools and settings into cli-tools.json and cli-settings.json respectively.
- Introduced new interfaces for Claude CLI Tools and Settings, including support for tags and primary models.
- Implemented loading and saving functions for CLI settings, ensuring backward compatibility with legacy combined config.
- Updated functions to synchronize tags between CLI tools and configuration manager.
- Added error handling and logging for loading and saving configurations.
- Created initial cli-settings.json with default settings.
This commit is contained in:
catlog22
2026-01-08 22:00:07 +08:00
parent 0bd2cff5b7
commit ea5c0bc9a4
12 changed files with 1376 additions and 180 deletions

View File

@@ -34,6 +34,8 @@ import {
import {
loadClaudeCliTools,
saveClaudeCliTools,
loadClaudeCliSettings,
saveClaudeCliSettings,
updateClaudeToolEnabled,
updateClaudeCacheSettings,
getClaudeCliToolsInfo,
@@ -704,11 +706,13 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
// API: Get CLI Tools Config from .claude/cli-tools.json (with fallback to global)
if (pathname === '/api/cli/tools-config' && req.method === 'GET') {
try {
const config = loadClaudeCliTools(initialPath);
const toolsConfig = loadClaudeCliTools(initialPath);
const settingsConfig = loadClaudeCliSettings(initialPath);
const info = getClaudeCliToolsInfo(initialPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
...config,
tools: toolsConfig,
settings: settingsConfig,
_configInfo: info
}));
} catch (err) {
@@ -722,32 +726,55 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
if (pathname === '/api/cli/tools-config' && req.method === 'PUT') {
handlePostRequest(req, res, async (body: unknown) => {
try {
const updates = body as Partial<any>;
const config = loadClaudeCliTools(initialPath);
const updates = body as { tools?: any; settings?: any };
// Merge updates
const updatedConfig = {
...config,
...updates,
tools: { ...config.tools, ...(updates.tools || {}) },
settings: {
...config.settings,
...(updates.settings || {}),
// Update tools config if provided
if (updates.tools) {
const currentTools = loadClaudeCliTools(initialPath);
const updatedTools = {
...currentTools,
tools: { ...currentTools.tools, ...(updates.tools.tools || {}) },
customEndpoints: updates.tools.customEndpoints || currentTools.customEndpoints
};
saveClaudeCliTools(initialPath, updatedTools);
}
// Update settings config if provided
if (updates.settings) {
const currentSettings = loadClaudeCliSettings(initialPath);
const s = updates.settings;
// Deep merge: only update fields that are explicitly provided
const updatedSettings = {
...currentSettings,
// Scalar fields: only update if explicitly provided
...(s.defaultTool !== undefined && { defaultTool: s.defaultTool }),
...(s.promptFormat !== undefined && { promptFormat: s.promptFormat }),
...(s.nativeResume !== undefined && { nativeResume: s.nativeResume }),
...(s.recursiveQuery !== undefined && { recursiveQuery: s.recursiveQuery }),
...(s.codeIndexMcp !== undefined && { codeIndexMcp: s.codeIndexMcp }),
// Nested objects: deep merge
smartContext: {
...currentSettings.smartContext,
...(s.smartContext || {})
},
cache: {
...config.settings.cache,
...(updates.settings?.cache || {})
...currentSettings.cache,
...(s.cache || {})
}
}
};
};
saveClaudeCliSettings(initialPath, updatedSettings);
}
saveClaudeCliTools(initialPath, updatedConfig);
const toolsConfig = loadClaudeCliTools(initialPath);
const settingsConfig = loadClaudeCliSettings(initialPath);
broadcastToClients({
type: 'CLI_TOOLS_CONFIG_UPDATED',
payload: { config: updatedConfig, timestamp: new Date().toISOString() }
payload: { tools: toolsConfig, settings: settingsConfig, timestamp: new Date().toISOString() }
});
return { success: true, config: updatedConfig };
return { success: true, tools: toolsConfig, settings: settingsConfig };
} catch (err) {
return { error: (err as Error).message, status: 500 };
}
@@ -782,14 +809,14 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
handlePostRequest(req, res, async (body: unknown) => {
try {
const cacheSettings = body as { injectionMode?: string; defaultPrefix?: string; defaultSuffix?: string };
const config = updateClaudeCacheSettings(initialPath, cacheSettings as any);
const settings = updateClaudeCacheSettings(initialPath, cacheSettings as any);
broadcastToClients({
type: 'CLI_CACHE_SETTINGS_UPDATED',
payload: { cache: config.settings.cache, timestamp: new Date().toISOString() }
payload: { cache: settings.cache, timestamp: new Date().toISOString() }
});
return { success: true, config };
return { success: true, settings };
} catch (err) {
return { error: (err as Error).message, status: 500 };
}

View File

@@ -4,9 +4,35 @@
*/
import { fileURLToPath } from 'url';
import { dirname, join as pathJoin } from 'path';
import { z } from 'zod';
import { getSystemPython } from '../../utils/python-utils.js';
import type { RouteContext } from './types.js';
// ========== Input Validation Schemas ==========
/**
* Validation schema for ModelPoolConfig
* Used to validate incoming API requests for model pool operations
*/
const ModelPoolConfigSchema = z.object({
modelType: z.enum(['embedding', 'llm', 'reranker']),
enabled: z.boolean(),
targetModel: z.string().min(1, 'Target model is required'),
strategy: z.enum(['round_robin', 'latency_aware', 'weighted_random']),
autoDiscover: z.boolean(),
excludedProviderIds: z.array(z.string()).optional().default([]),
defaultCooldown: z.number().int().min(0).default(60),
defaultMaxConcurrentPerKey: z.number().int().min(1).default(4),
name: z.string().optional(),
description: z.string().optional(),
});
/**
* Partial schema for updating ModelPoolConfig
* All fields are optional for PATCH-like updates
*/
const ModelPoolConfigUpdateSchema = ModelPoolConfigSchema.partial();
// Get current module path for package-relative lookups
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -39,6 +65,12 @@ import {
getEmbeddingPoolConfig,
updateEmbeddingPoolConfig,
discoverProvidersForModel,
getModelPools,
getModelPool,
addModelPool,
updateModelPool,
deleteModelPool,
getAvailableModelsForType,
type ProviderCredential,
type CustomEndpoint,
type ProviderType,
@@ -856,6 +888,186 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise<boolean
return true;
}
// ========== Multi-Model Pool Management ==========
// GET /api/litellm-api/model-pools - Get all model pool configurations
if (pathname === '/api/litellm-api/model-pools' && req.method === 'GET') {
try {
const pools = getModelPools(initialPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ pools }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (err as Error).message }));
}
return true;
}
// GET /api/litellm-api/model-pools/:id - Get specific pool configuration
const poolGetMatch = pathname.match(/^\/api\/litellm-api\/model-pools\/([^/]+)$/);
if (poolGetMatch && req.method === 'GET') {
const poolId = decodeURIComponent(poolGetMatch[1]);
try {
const pool = getModelPool(initialPath, poolId);
if (!pool) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Pool not found' }));
return true;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ pool }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (err as Error).message }));
}
return true;
}
// POST /api/litellm-api/model-pools - Create new model pool
if (pathname === '/api/litellm-api/model-pools' && req.method === 'POST') {
handlePostRequest(req, res, async (body: unknown) => {
// Validate input using zod schema
const validationResult = ModelPoolConfigSchema.safeParse(body);
if (!validationResult.success) {
return {
error: 'Invalid request body',
details: validationResult.error.issues.map(e => ({
field: String(e.path.join('.')),
message: e.message
})),
status: 400
};
}
try {
const poolConfig = validationResult.data;
const result = addModelPool(initialPath, poolConfig);
broadcastToClients({
type: 'MODEL_POOL_CREATED',
payload: { poolId: result.poolId, timestamp: new Date().toISOString() }
});
return { success: true, ...result };
} catch (err) {
return { error: (err as Error).message, status: 500 };
}
});
return true;
}
// PUT /api/litellm-api/model-pools/:id - Update model pool
const poolPutMatch = pathname.match(/^\/api\/litellm-api\/model-pools\/([^/]+)$/);
if (poolPutMatch && req.method === 'PUT') {
const poolId = decodeURIComponent(poolPutMatch[1]);
handlePostRequest(req, res, async (body: unknown) => {
// Validate input using partial schema (all fields optional for updates)
const validationResult = ModelPoolConfigUpdateSchema.safeParse(body);
if (!validationResult.success) {
return {
error: 'Invalid request body',
details: validationResult.error.issues.map(e => ({
field: String(e.path.join('.')),
message: e.message
})),
status: 400
};
}
try {
const updates = validationResult.data;
const result = updateModelPool(initialPath, poolId, updates);
if (!result.success) {
return { error: 'Pool not found', status: 404 };
}
broadcastToClients({
type: 'MODEL_POOL_UPDATED',
payload: { poolId, syncResult: result.syncResult, timestamp: new Date().toISOString() }
});
return result;
} catch (err) {
return { error: (err as Error).message, status: 500 };
}
});
return true;
}
// DELETE /api/litellm-api/model-pools/:id - Delete model pool
const poolDeleteMatch = pathname.match(/^\/api\/litellm-api\/model-pools\/([^/]+)$/);
if (poolDeleteMatch && req.method === 'DELETE') {
const poolId = decodeURIComponent(poolDeleteMatch[1]);
try {
const result = deleteModelPool(initialPath, poolId);
if (!result.success) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Pool not found' }));
return true;
}
broadcastToClients({
type: 'MODEL_POOL_DELETED',
payload: { poolId, syncResult: result.syncResult, timestamp: new Date().toISOString() }
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (err as Error).message }));
}
return true;
}
// GET /api/litellm-api/model-pools/available-models/:modelType - Get available models for type
const availableModelsMatch = pathname.match(/^\/api\/litellm-api\/model-pools\/available-models\/([^/]+)$/);
if (availableModelsMatch && req.method === 'GET') {
const modelType = decodeURIComponent(availableModelsMatch[1]) as import('../../types/litellm-api-config.js').ModelPoolType;
try {
const availableModels = getAvailableModelsForType(initialPath, modelType);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ availableModels, modelType }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (err as Error).message }));
}
return true;
}
// GET /api/litellm-api/model-pools/discover/:modelType/:model - Discover providers for model
const discoverPoolMatch = pathname.match(/^\/api\/litellm-api\/model-pools\/discover\/([^/]+)\/([^/]+)$/);
if (discoverPoolMatch && req.method === 'GET') {
const modelType = decodeURIComponent(discoverPoolMatch[1]);
const targetModel = decodeURIComponent(discoverPoolMatch[2]);
try {
const discovered = discoverProvidersForModel(initialPath, targetModel);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
modelType,
targetModel,
discovered,
count: discovered.length,
}));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (err as Error).message }));
}
return true;
}
// POST /api/litellm-api/ccw-litellm/install - Install ccw-litellm package
if (pathname === '/api/litellm-api/ccw-litellm/install' && req.method === 'POST') {
handlePostRequest(req, res, async () => {

View File

@@ -550,15 +550,15 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
// Serve dashboard HTML
if (pathname === '/' || pathname === '/index.html') {
if (isLocalhostRequest(req)) {
const tokenResult = tokenManager.getOrCreateAuthToken();
setAuthCookie(res, tokenResult.token, tokenResult.expiresAt);
// Set session cookie and CSRF token for all requests
const tokenResult = tokenManager.getOrCreateAuthToken();
setAuthCookie(res, tokenResult.token, tokenResult.expiresAt);
const sessionId = getOrCreateSessionId(req, res);
const csrfToken = getCsrfTokenManager().generateToken(sessionId);
res.setHeader('X-CSRF-Token', csrfToken);
setCsrfCookie(res, csrfToken, 15 * 60);
const sessionId = getOrCreateSessionId(req, res);
const csrfToken = getCsrfTokenManager().generateToken(sessionId);
res.setHeader('X-CSRF-Token', csrfToken);
setCsrfCookie(res, csrfToken, 15 * 60);
}
const html = generateServerDashboard(initialPath);
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);