Files
claudecodeui/server/src/modules/llm/llm.routes.ts

627 lines
20 KiB
TypeScript

import express, { type NextFunction, type Request, type Response } from 'express';
import multer from 'multer';
import path from 'node:path';
import { asyncHandler } from '@/shared/http/async-handler.js';
import { AppError } from '@/shared/utils/app-error.js';
import { createApiErrorResponse, createApiSuccessResponse } from '@/shared/http/api-response.js';
import { llmService } from '@/modules/llm/services/llm.service.js';
import { llmSessionsService } from '@/modules/llm/services/sessions.service.js';
import { llmAssetsService } from '@/modules/llm/services/assets.service.js';
import type { McpScope, McpTransport, UpsertMcpServerInput } from '@/modules/llm/services/mcp.service.js';
import { llmMcpService } from '@/modules/llm/services/mcp.service.js';
import { llmSkillsService } from '@/modules/llm/services/skills.service.js';
import { llmMessagesUnifier } from '@/modules/llm/services/messages-unifier.service.js';
import type { LLMProvider } from '@/shared/types/app.js';
import { logger } from '@/shared/utils/logger.js';
const router = express.Router();
const upload = multer({
storage: multer.memoryStorage(),
limits: {
files: 10,
fileSize: 20 * 1024 * 1024,
},
});
/**
* Safely reads an Express path parameter that may arrive as string or string[].
*/
const readPathParam = (value: unknown, name: string): string => {
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value) && typeof value[0] === 'string') {
return value[0];
}
throw new AppError(`${name} path parameter is invalid.`, {
code: 'INVALID_PATH_PARAMETER',
statusCode: 400,
});
};
const normalizeProviderParam = (value: unknown): string =>
readPathParam(value, 'provider').trim().toLowerCase();
/**
* Validates and normalizes rename payload.
*/
const parseRenamePayload = (payload: unknown): { summary: string } => {
if (!payload || typeof payload !== 'object') {
throw new AppError('Request body must be an object.', {
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
});
}
const body = payload as Record<string, unknown>;
const summary = typeof body.summary === 'string' ? body.summary.trim() : '';
if (!summary) {
throw new AppError('summary is required.', {
code: 'SUMMARY_REQUIRED',
statusCode: 400,
});
}
if (summary.length > 500) {
throw new AppError('summary must not exceed 500 characters.', {
code: 'SUMMARY_TOO_LONG',
statusCode: 400,
});
}
return { summary };
};
/**
* Reads optional query values and trims surrounding whitespace.
*/
const readOptionalQueryString = (value: unknown): string | undefined => {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : undefined;
};
/**
* Validates MCP scope query/body values.
*/
const parseMcpScope = (value: unknown): McpScope | undefined => {
if (value === undefined) {
return undefined;
}
const normalized = readOptionalQueryString(value);
if (!normalized) {
return undefined;
}
if (normalized === 'user' || normalized === 'local' || normalized === 'project') {
return normalized;
}
throw new AppError(`Unsupported MCP scope "${normalized}".`, {
code: 'INVALID_MCP_SCOPE',
statusCode: 400,
});
};
/**
* Validates MCP transport query/body values.
*/
const parseMcpTransport = (value: unknown): McpTransport => {
const normalized = readOptionalQueryString(value);
if (!normalized) {
throw new AppError('transport is required.', {
code: 'MCP_TRANSPORT_REQUIRED',
statusCode: 400,
});
}
if (normalized === 'stdio' || normalized === 'http' || normalized === 'sse') {
return normalized;
}
throw new AppError(`Unsupported MCP transport "${normalized}".`, {
code: 'INVALID_MCP_TRANSPORT',
statusCode: 400,
});
};
/**
* Parses and validates MCP upsert payload.
*/
const parseMcpUpsertPayload = (payload: unknown): UpsertMcpServerInput => {
if (!payload || typeof payload !== 'object') {
throw new AppError('Request body must be an object.', {
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
});
}
const body = payload as Record<string, unknown>;
const name = readOptionalQueryString(body.name);
if (!name) {
throw new AppError('name is required.', {
code: 'MCP_NAME_REQUIRED',
statusCode: 400,
});
}
const transport = parseMcpTransport(body.transport);
const scope = parseMcpScope(body.scope);
const workspacePath = readOptionalQueryString(body.workspacePath);
return {
name,
transport,
scope,
workspacePath,
command: readOptionalQueryString(body.command),
args: Array.isArray(body.args) ? body.args.filter((entry): entry is string => typeof entry === 'string') : undefined,
env: typeof body.env === 'object' && body.env !== null
? Object.fromEntries(
Object.entries(body.env as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string',
),
)
: undefined,
cwd: readOptionalQueryString(body.cwd),
url: readOptionalQueryString(body.url),
headers: typeof body.headers === 'object' && body.headers !== null
? Object.fromEntries(
Object.entries(body.headers as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string',
),
)
: undefined,
envVars: Array.isArray(body.envVars)
? body.envVars.filter((entry): entry is string => typeof entry === 'string')
: undefined,
bearerTokenEnvVar: readOptionalQueryString(body.bearerTokenEnvVar),
envHttpHeaders: typeof body.envHttpHeaders === 'object' && body.envHttpHeaders !== null
? Object.fromEntries(
Object.entries(body.envHttpHeaders as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string',
),
)
: undefined,
};
};
/**
* Converts any provider route parameter into the strongly typed provider union.
*/
const parseProvider = (value: unknown): LLMProvider => {
const normalized = normalizeProviderParam(value);
if (normalized === 'claude' || normalized === 'codex' || normalized === 'cursor' || normalized === 'gemini') {
return normalized;
}
throw new AppError(`Unsupported provider "${normalized}".`, {
code: 'UNSUPPORTED_PROVIDER',
statusCode: 400,
});
};
/**
* Enriches provider session snapshots with normalized message types for frontend rendering.
*/
const formatSessionSnapshot = (
provider: LLMProvider,
snapshot: {
sessionId: string;
events: Array<{
timestamp: string;
channel: 'sdk' | 'stdout' | 'stderr' | 'json' | 'system' | 'error';
message?: string;
data?: unknown;
}>;
},
) => ({
...snapshot,
messages: llmMessagesUnifier.normalizeSessionEvents(provider, snapshot.sessionId, snapshot.events),
});
router.get(
'/providers',
asyncHandler(async (_req: Request, res: Response) => {
res.json(createApiSuccessResponse({ providers: llmService.listProviders() }));
}),
);
router.get(
'/providers/:provider/models',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const models = await llmService.listModels(provider);
res.json(createApiSuccessResponse({ provider, models }));
}),
);
router.get(
'/providers/:provider/sessions',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const sessions = llmService.listSessions(provider).map((session) => formatSessionSnapshot(provider, session));
res.json(createApiSuccessResponse({ provider, sessions }));
}),
);
router.get(
'/providers/:provider/sessions/:sessionId',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const sessionId = readPathParam(req.params.sessionId, 'sessionId');
const session = llmService.getSession(provider, sessionId);
if (!session) {
throw new AppError(`Session "${sessionId}" not found for provider "${provider}".`, {
code: 'SESSION_NOT_FOUND',
statusCode: 404,
});
}
res.json(createApiSuccessResponse({ provider, session: formatSessionSnapshot(provider, session) }));
}),
);
router.post(
'/providers/:provider/sessions/start',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const snapshot = await llmService.startSession(provider, req.body);
const formattedSnapshot = formatSessionSnapshot(provider, snapshot);
res.status(202).json(
createApiSuccessResponse({
provider,
session: formattedSnapshot,
}),
);
}),
);
router.post(
'/providers/:provider/sessions/:sessionId/resume',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const sessionId = readPathParam(req.params.sessionId, 'sessionId');
const snapshot = await llmService.resumeSession(provider, sessionId, req.body);
res.status(202).json(createApiSuccessResponse({ provider, session: formatSessionSnapshot(provider, snapshot) }));
}),
);
router.post(
'/providers/:provider/sessions/:sessionId/stop',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const sessionId = readPathParam(req.params.sessionId, 'sessionId');
const stopped = await llmService.stopSession(provider, sessionId);
res.json(createApiSuccessResponse({ provider, sessionId, stopped }));
}),
);
router.patch(
'/providers/:provider/sessions/:sessionId/model',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const sessionId = readPathParam(req.params.sessionId, 'sessionId');
const model = typeof req.body?.model === 'string' ? req.body.model.trim() : '';
if (!model) {
throw new AppError('model is required.', {
code: 'MODEL_REQUIRED',
statusCode: 400,
});
}
await llmService.setSessionModel(provider, sessionId, model);
res.json(
createApiSuccessResponse({
provider,
sessionId,
model,
}),
);
}),
);
router.patch(
'/providers/:provider/sessions/:sessionId/thinking',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const sessionId = readPathParam(req.params.sessionId, 'sessionId');
const thinkingMode =
typeof req.body?.thinkingMode === 'string' ? req.body.thinkingMode.trim() : '';
if (!thinkingMode) {
throw new AppError('thinkingMode is required.', {
code: 'THINKING_MODE_REQUIRED',
statusCode: 400,
});
}
await llmService.setSessionThinkingMode(provider, sessionId, thinkingMode);
res.json(
createApiSuccessResponse({
provider,
sessionId,
thinkingMode,
}),
);
}),
);
/**
* Uploads one or more images into `.cloudcli/assets` so providers can reuse file paths.
*/
router.post(
'/assets/images',
upload.array('images', 10),
asyncHandler(async (req: Request, res: Response) => {
const workspacePath = readOptionalQueryString((req.body as Record<string, unknown> | undefined)?.workspacePath);
const filesValue = (req as Request & { files?: unknown }).files;
const files = Array.isArray(filesValue) ? filesValue as Array<{
originalname: string;
mimetype: string;
size: number;
buffer: Buffer;
}> : [];
const images = await llmAssetsService.storeUploadedImages(files, { workspacePath });
res.status(201).json(createApiSuccessResponse({ images }));
}),
);
/**
* Lists MCP servers for one provider grouped by user/local/project scopes.
*/
router.get(
'/providers/:provider/mcp/servers',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const workspacePath = readOptionalQueryString(req.query.workspacePath);
const scope = parseMcpScope(req.query.scope);
if (scope) {
const servers = await llmMcpService.listProviderServersForScope(
provider,
scope,
path.resolve(workspacePath ?? process.cwd()),
);
res.json(createApiSuccessResponse({ provider, scope, servers }));
return;
}
const groupedServers = await llmMcpService.listProviderServers(provider, { workspacePath });
res.json(createApiSuccessResponse({ provider, scopes: groupedServers }));
}),
);
/**
* Adds one MCP server for one provider and scope.
*/
router.post(
'/providers/:provider/mcp/servers',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const payload = parseMcpUpsertPayload(req.body);
const server = await llmMcpService.upsertProviderServer(provider, payload);
res.status(201).json(createApiSuccessResponse({ server }));
}),
);
/**
* Updates one provider MCP server definition.
*/
router.put(
'/providers/:provider/mcp/servers/:name',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const payload = parseMcpUpsertPayload({
...((req.body && typeof req.body === 'object') ? req.body as Record<string, unknown> : {}),
name: readPathParam(req.params.name, 'name'),
});
const server = await llmMcpService.upsertProviderServer(provider, payload);
res.json(createApiSuccessResponse({ server }));
}),
);
/**
* Removes one provider MCP server from its configured scope.
*/
router.delete(
'/providers/:provider/mcp/servers/:name',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const scope = parseMcpScope(req.query.scope);
const workspacePath = readOptionalQueryString(req.query.workspacePath);
const result = await llmMcpService.removeProviderServer(provider, {
name: readPathParam(req.params.name, 'name'),
scope,
workspacePath,
});
res.json(createApiSuccessResponse(result));
}),
);
/**
* Executes a lightweight startup/connectivity probe for one provider MCP server.
*/
router.post(
'/providers/:provider/mcp/servers/:name/run',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const body = (req.body as Record<string, unknown> | undefined) ?? {};
const scope = parseMcpScope(body.scope ?? req.query.scope);
const workspacePath = readOptionalQueryString(body.workspacePath ?? req.query.workspacePath);
const result = await llmMcpService.runProviderServer({
provider,
name: readPathParam(req.params.name, 'name'),
scope,
workspacePath,
});
res.json(createApiSuccessResponse(result));
}),
);
/**
* Adds one HTTP/stdio MCP server to every provider.
*/
router.post(
'/mcp/servers/global',
asyncHandler(async (req: Request, res: Response) => {
const payload = parseMcpUpsertPayload(req.body);
if (payload.scope === 'local') {
throw new AppError('Global MCP add supports only "user" or "project" scopes.', {
code: 'INVALID_GLOBAL_MCP_SCOPE',
statusCode: 400,
});
}
const results = await llmMcpService.addServerToAllProviders({
...payload,
scope: payload.scope === 'user' ? 'user' : 'project',
});
res.status(201).json(createApiSuccessResponse({ results }));
}),
);
/**
* Lists provider-specific skills from all documented skill directories.
*/
router.get(
'/providers/:provider/skills',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const workspacePath = readOptionalQueryString(req.query.workspacePath);
const skills = await llmSkillsService.listProviderSkills(provider, { workspacePath });
res.json(createApiSuccessResponse({ provider, skills }));
}),
);
/**
* Lists skills for one provider or for all providers in a single response.
*/
router.get(
'/skills',
asyncHandler(async (req: Request, res: Response) => {
const providerQuery = readOptionalQueryString(req.query.provider);
const workspacePath = readOptionalQueryString(req.query.workspacePath);
if (providerQuery) {
const provider = parseProvider(providerQuery);
const skills = await llmSkillsService.listProviderSkills(provider, { workspacePath });
res.json(createApiSuccessResponse({ provider, skills }));
return;
}
const providers: LLMProvider[] = ['claude', 'codex', 'cursor', 'gemini'];
const byProvider = Object.fromEntries(
await Promise.all(
providers.map(async (provider) => ([
provider,
await llmSkillsService.listProviderSkills(provider, { workspacePath }),
])),
),
);
res.json(createApiSuccessResponse({ providers: byProvider }));
}),
);
router.get(
'/sessions/:sessionId/messages',
asyncHandler(async (req: Request, res: Response) => {
const sessionId = readPathParam(req.params.sessionId, 'sessionId');
const history = await llmSessionsService.getSessionHistory(sessionId);
res.json(createApiSuccessResponse({
sessionId,
provider: history.provider,
messages: history.messages,
}));
}),
);
router.get(
'/sessions/:sessionId/history',
asyncHandler(async (req: Request, res: Response) => {
const sessionId = readPathParam(req.params.sessionId, 'sessionId');
const history = await llmSessionsService.getSessionHistory(sessionId);
res.json(createApiSuccessResponse(history));
}),
);
/**
* Renames one indexed session by writing the custom summary into DB.
*/
router.put(
'/sessions/:sessionId/rename',
asyncHandler(async (req: Request, res: Response) => {
const sessionId = readPathParam(req.params.sessionId, 'sessionId');
const { summary } = parseRenamePayload(req.body);
llmSessionsService.updateSessionCustomName(sessionId, summary);
res.json(createApiSuccessResponse({ sessionId, summary }));
}),
);
/**
* Returns DB-indexed sessions discovered by the session-processor scan.
*/
router.get(
'/sessions/index',
asyncHandler(async (req: Request, res: Response) => {
const provider =
typeof req.query.provider === 'string' ? req.query.provider.trim().toLowerCase() : undefined;
const sessions = llmSessionsService.listIndexedSessions(provider);
res.json(createApiSuccessResponse({ provider: provider ?? null, sessions }));
}),
);
/**
* Triggers provider disk scans and refreshes the shared sessions table.
*/
router.post(
'/sessions/sync',
asyncHandler(async (_req: Request, res: Response) => {
const syncResult = await llmSessionsService.synchronizeSessions();
res.json(createApiSuccessResponse(syncResult));
}),
);
/**
* Deletes provider-specific session artifacts and removes the DB row.
*/
router.delete(
'/sessions/:sessionId',
asyncHandler(async (req: Request, res: Response) => {
const sessionId = readPathParam(req.params.sessionId, 'sessionId');
const result = await llmSessionsService.deleteSessionArtifacts(sessionId);
res.json(createApiSuccessResponse(result));
}),
);
/**
* Normalizes route-level failures to a consistent JSON API shape.
*/
router.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
if (res.headersSent) {
return;
}
if (error instanceof AppError) {
res
.status(error.statusCode)
.json(createApiErrorResponse(error.code, error.message, undefined, error.details));
return;
}
const message = error instanceof Error ? error.message : 'Unexpected LLM route failure.';
logger.error(message, {
module: 'llm.routes',
});
res.status(500).json(createApiErrorResponse('INTERNAL_ERROR', message));
});
export default router;