mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +00:00
371 lines
11 KiB
TypeScript
371 lines
11 KiB
TypeScript
/**
|
|
* Cursor Daemon Entry
|
|
*
|
|
* Dedicated child-process entrypoint for local OpenAI-compatible Cursor proxy.
|
|
*/
|
|
|
|
import * as http from 'http';
|
|
import { Readable } from 'stream';
|
|
import { CursorExecutor } from './cursor-executor';
|
|
import {
|
|
createAnthropicErrorResponse,
|
|
createAnthropicProxyResponse,
|
|
} from './cursor-anthropic-response';
|
|
import { translateAnthropicRequest } from './cursor-anthropic-translator';
|
|
import { checkAuthStatus } from './cursor-auth';
|
|
import { getModelsForDaemon, resolveCursorRequestModel } from './cursor-models';
|
|
import type { CursorTool } from './cursor-protobuf-schema';
|
|
|
|
interface DaemonRuntimeOptions {
|
|
port: number;
|
|
ghostMode: boolean;
|
|
}
|
|
|
|
interface OpenAIMessage {
|
|
role: string;
|
|
content?: string | Array<{ type: string; text?: string }> | null;
|
|
name?: string;
|
|
tool_call_id?: string;
|
|
tool_calls?: Array<{
|
|
id: string;
|
|
type: string;
|
|
function: { name: string; arguments: string };
|
|
}>;
|
|
}
|
|
|
|
interface NormalizedOpenAIMessage {
|
|
role: string;
|
|
content: string | Array<{ type: string; text?: string }>;
|
|
name?: string;
|
|
tool_call_id?: string;
|
|
tool_calls?: Array<{
|
|
id: string;
|
|
type: string;
|
|
function: { name: string; arguments: string };
|
|
}>;
|
|
}
|
|
|
|
interface OpenAIChatRequest {
|
|
model?: string;
|
|
stream?: boolean;
|
|
reasoning_effort?: string;
|
|
tools?: CursorTool[];
|
|
messages?: OpenAIMessage[];
|
|
}
|
|
|
|
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB
|
|
|
|
function writeJson(res: http.ServerResponse, statusCode: number, payload: unknown): void {
|
|
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(payload));
|
|
}
|
|
|
|
function readJsonBody(req: http.IncomingMessage): Promise<unknown> {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
let total = 0;
|
|
let settled = false;
|
|
|
|
const resolveOnce = (payload: unknown) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve(payload);
|
|
};
|
|
|
|
const rejectOnce = (error: Error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
reject(error);
|
|
};
|
|
|
|
req.on('data', (chunk: Buffer) => {
|
|
total += chunk.length;
|
|
if (total > MAX_BODY_SIZE) {
|
|
// Stop processing body, but avoid force-closing socket so caller can return 413 cleanly.
|
|
req.pause();
|
|
rejectOnce(new Error('Request body too large (max 10MB)'));
|
|
return;
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
|
|
req.on('end', () => {
|
|
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
if (!raw) {
|
|
resolveOnce({});
|
|
return;
|
|
}
|
|
try {
|
|
resolveOnce(JSON.parse(raw));
|
|
} catch {
|
|
rejectOnce(new Error('Invalid JSON in request body'));
|
|
}
|
|
});
|
|
|
|
req.on('error', (error) => {
|
|
rejectOnce(error instanceof Error ? error : new Error(String(error)));
|
|
});
|
|
});
|
|
}
|
|
|
|
function normalizeMessages(raw: unknown): NormalizedOpenAIMessage[] {
|
|
if (!Array.isArray(raw)) {
|
|
throw new Error('messages must be an array');
|
|
}
|
|
|
|
return raw.map((message, index) => {
|
|
if (typeof message !== 'object' || message === null) {
|
|
throw new Error(`messages[${index}] must be an object`);
|
|
}
|
|
|
|
const m = message as Record<string, unknown>;
|
|
if (typeof m.role !== 'string' || !m.role) {
|
|
throw new Error(`messages[${index}].role must be a non-empty string`);
|
|
}
|
|
|
|
const content = m.content;
|
|
if (
|
|
content !== undefined &&
|
|
content !== null &&
|
|
typeof content !== 'string' &&
|
|
!Array.isArray(content)
|
|
) {
|
|
throw new Error(`messages[${index}].content must be string, array, or null`);
|
|
}
|
|
|
|
return {
|
|
role: m.role,
|
|
content: (content ?? '') as NormalizedOpenAIMessage['content'],
|
|
name: typeof m.name === 'string' ? m.name : undefined,
|
|
tool_call_id: typeof m.tool_call_id === 'string' ? m.tool_call_id : undefined,
|
|
tool_calls: Array.isArray(m.tool_calls)
|
|
? (m.tool_calls as NormalizedOpenAIMessage['tool_calls'])
|
|
: undefined,
|
|
};
|
|
});
|
|
}
|
|
|
|
async function pipeWebResponseToNode(response: Response, res: http.ServerResponse): Promise<void> {
|
|
res.statusCode = response.status;
|
|
response.headers.forEach((value, key) => {
|
|
res.setHeader(key, value);
|
|
});
|
|
|
|
if (!response.body) {
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
const nodeStream = Readable.fromWeb(response.body as unknown as ReadableStream<Uint8Array>);
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
nodeStream.on('error', reject);
|
|
nodeStream.on('end', resolve);
|
|
nodeStream.pipe(res);
|
|
});
|
|
}
|
|
|
|
function parseArgs(argv: string[]): DaemonRuntimeOptions {
|
|
let port = 20129;
|
|
let ghostMode = true;
|
|
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
if (arg === '--port' && argv[i + 1]) {
|
|
const parsed = parseInt(argv[i + 1], 10);
|
|
if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) {
|
|
port = parsed;
|
|
}
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--ghost-mode' && argv[i + 1]) {
|
|
const value = argv[i + 1];
|
|
ghostMode = value !== 'false' && value !== '0';
|
|
i++;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return { port, ghostMode };
|
|
}
|
|
|
|
export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Server {
|
|
const executor = new CursorExecutor();
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
const method = req.method || 'GET';
|
|
const requestUrl = req.url || '/';
|
|
const isOpenAiRoute = method === 'POST' && requestUrl === '/v1/chat/completions';
|
|
const isAnthropicRoute = method === 'POST' && requestUrl === '/v1/messages';
|
|
|
|
try {
|
|
if (method === 'GET' && requestUrl === '/health') {
|
|
writeJson(res, 200, { ok: true, service: 'cursor-daemon' });
|
|
return;
|
|
}
|
|
|
|
if (method === 'GET' && requestUrl === '/v1/models') {
|
|
const authStatus = checkAuthStatus();
|
|
const models = await getModelsForDaemon({
|
|
credentials:
|
|
authStatus.authenticated && !authStatus.expired && authStatus.credentials
|
|
? {
|
|
accessToken: authStatus.credentials.accessToken,
|
|
machineId: authStatus.credentials.machineId,
|
|
ghostMode: options.ghostMode,
|
|
}
|
|
: null,
|
|
});
|
|
|
|
const data = models.map((model) => ({
|
|
id: model.id,
|
|
object: 'model',
|
|
created: 0,
|
|
owned_by: model.provider,
|
|
}));
|
|
writeJson(res, 200, { object: 'list', data });
|
|
return;
|
|
}
|
|
|
|
if (!isOpenAiRoute && !isAnthropicRoute) {
|
|
writeJson(res, 404, { error: 'Not found' });
|
|
return;
|
|
}
|
|
|
|
const rawBody = await readJsonBody(req);
|
|
const anthropicBody = isAnthropicRoute ? translateAnthropicRequest(rawBody) : undefined;
|
|
const parsedBody = anthropicBody ?? ((rawBody as OpenAIChatRequest) || {});
|
|
const messages = anthropicBody
|
|
? anthropicBody.messages
|
|
: normalizeMessages(parsedBody.messages);
|
|
const requestedModel =
|
|
typeof parsedBody.model === 'string' && parsedBody.model.trim().length > 0
|
|
? parsedBody.model.trim()
|
|
: undefined;
|
|
const stream = parsedBody.stream === true;
|
|
|
|
const authStatus = checkAuthStatus();
|
|
if (!authStatus.authenticated || !authStatus.credentials) {
|
|
const message = 'Cursor credentials not found. Run `ccs legacy cursor auth` first.';
|
|
if (isAnthropicRoute) {
|
|
await pipeWebResponseToNode(
|
|
createAnthropicErrorResponse(401, 'authentication_error', message),
|
|
res
|
|
);
|
|
} else {
|
|
writeJson(res, 401, {
|
|
error: {
|
|
type: 'authentication_error',
|
|
message,
|
|
},
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (authStatus.expired) {
|
|
const message = 'Cursor credentials expired. Run `ccs legacy cursor auth` again.';
|
|
if (isAnthropicRoute) {
|
|
await pipeWebResponseToNode(
|
|
createAnthropicErrorResponse(401, 'authentication_error', message),
|
|
res
|
|
);
|
|
} else {
|
|
writeJson(res, 401, {
|
|
error: {
|
|
type: 'authentication_error',
|
|
message,
|
|
},
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
const daemonCredentials = {
|
|
accessToken: authStatus.credentials.accessToken,
|
|
machineId: authStatus.credentials.machineId,
|
|
ghostMode: options.ghostMode,
|
|
};
|
|
const availableModels = await getModelsForDaemon({
|
|
credentials: daemonCredentials,
|
|
});
|
|
const model = resolveCursorRequestModel(requestedModel, availableModels);
|
|
if (
|
|
requestedModel &&
|
|
requestedModel !== model &&
|
|
(process.env.CCS_DEBUG === '1' || process.env.CCS_DEBUG === 'true')
|
|
) {
|
|
console.error(
|
|
`[cursor] Requested model "${requestedModel}" is unavailable; falling back to "${model}".`
|
|
);
|
|
}
|
|
|
|
const abortController = new AbortController();
|
|
const abortOnDisconnect = () => {
|
|
if (!abortController.signal.aborted && !res.writableEnded) {
|
|
abortController.abort();
|
|
}
|
|
};
|
|
|
|
req.on('aborted', abortOnDisconnect);
|
|
req.on('close', abortOnDisconnect);
|
|
res.on('close', abortOnDisconnect);
|
|
|
|
const result = await executor.execute({
|
|
model,
|
|
stream,
|
|
signal: abortController.signal,
|
|
credentials: daemonCredentials,
|
|
body: {
|
|
messages,
|
|
tools: Array.isArray(parsedBody.tools) ? parsedBody.tools : undefined,
|
|
reasoning_effort:
|
|
typeof parsedBody.reasoning_effort === 'string'
|
|
? parsedBody.reasoning_effort
|
|
: undefined,
|
|
},
|
|
});
|
|
|
|
const outgoingResponse = isAnthropicRoute
|
|
? await createAnthropicProxyResponse(result.response)
|
|
: result.response;
|
|
|
|
await pipeWebResponseToNode(outgoingResponse, res);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
const isPayloadTooLarge = message.includes('Request body too large');
|
|
const status = isPayloadTooLarge ? 413 : 400;
|
|
if (isAnthropicRoute) {
|
|
await pipeWebResponseToNode(
|
|
createAnthropicErrorResponse(status, 'invalid_request_error', message),
|
|
res
|
|
);
|
|
} else {
|
|
writeJson(res, status, {
|
|
error: {
|
|
type: 'invalid_request_error',
|
|
message,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
server.listen(options.port, '127.0.0.1');
|
|
return server;
|
|
}
|
|
|
|
if (require.main === module) {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
const server = startCursorDaemonServer(options);
|
|
|
|
const shutdown = () => {
|
|
server.close();
|
|
};
|
|
|
|
process.on('SIGTERM', shutdown);
|
|
process.on('SIGINT', shutdown);
|
|
}
|