diff --git a/docs/cursor-integration.md b/docs/cursor-integration.md index 1aa32cda..3fa9897d 100644 --- a/docs/cursor-integration.md +++ b/docs/cursor-integration.md @@ -5,6 +5,7 @@ This guide covers the local Cursor integration in CCS, including CLI setup, daem ## What It Provides - OpenAI-compatible local endpoint powered by Cursor credentials. +- Anthropic-compatible local endpoint at `/v1/messages` for Claude-native clients. - Cursor model list and chat completions via local daemon. - Dedicated dashboard page: `ccs config` -> `Cursor IDE`. @@ -61,6 +62,7 @@ ccs cursor stop - `auto_start`: disabled - Model list resolution: authenticated live fetch when available, with cached/default fallback. - Request model validation: if a requested model is not present in the available Cursor model catalog, daemon falls back to the resolved default model. +- Daemon API surface: `POST /v1/chat/completions`, `POST /v1/messages`, and `GET /v1/models`. These values are managed in unified config and can be updated from CLI or dashboard. diff --git a/src/cursor/cursor-anthropic-response.ts b/src/cursor/cursor-anthropic-response.ts new file mode 100644 index 00000000..ddf31803 --- /dev/null +++ b/src/cursor/cursor-anthropic-response.ts @@ -0,0 +1,121 @@ +import { DeltaAccumulator } from '../glmt/delta-accumulator'; +import { GlmtTransformer } from '../glmt/glmt-transformer'; +import { SSEParser } from '../glmt/sse-parser'; +import type { OpenAIResponse, SSEEvent } from '../glmt/pipeline'; + +function createErrorResponse(message: string): Response { + return new Response( + JSON.stringify({ + error: { + type: 'api_error', + message, + }, + }), + { + status: 502, + headers: { 'Content-Type': 'application/json' }, + } + ); +} + +function formatSseEvent(event: string, data: Record): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +} + +async function createAnthropicJsonResponse(response: Response): Promise { + try { + const openAiResponse = (await response.json()) as OpenAIResponse; + const anthropicResponse = new GlmtTransformer().transformResponse(openAiResponse); + return new Response(JSON.stringify(anthropicResponse), { + status: response.status, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error) { + return createErrorResponse( + `Failed to translate Cursor JSON response: ${(error as Error).message}` + ); + } +} + +function createAnthropicStreamingResponse(response: Response): Response { + const body = response.body; + if (!body) { + return createErrorResponse('Cursor stream ended before a response body was available'); + } + + const parser = new SSEParser(); + const transformer = new GlmtTransformer(); + const accumulator = new DeltaAccumulator({}); + const encoder = new TextEncoder(); + + const readable = new ReadableStream({ + async start(controller) { + const reader = body.getReader(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (!value) { + continue; + } + + const events = parser.parse(Buffer.from(value)); + events.forEach((event) => { + const anthropicEvents = transformer.transformDelta(event as SSEEvent, accumulator); + anthropicEvents.forEach((anthropicEvent) => { + controller.enqueue( + encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data)) + ); + }); + }); + } + + if (!accumulator.isFinalized() && accumulator.isMessageStarted()) { + transformer.finalizeDelta(accumulator).forEach((anthropicEvent) => { + controller.enqueue( + encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data)) + ); + }); + } + } catch (error) { + controller.enqueue( + encoder.encode( + formatSseEvent('error', { + type: 'error', + error: { + type: 'api_error', + message: `Failed to translate Cursor SSE response: ${(error as Error).message}`, + }, + }) + ) + ); + } finally { + reader.releaseLock(); + controller.close(); + } + }, + }); + + return new Response(readable, { + status: response.status, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); +} + +export async function createAnthropicProxyResponse(response: Response): Promise { + if (!response.ok) { + return response; + } + + const contentType = response.headers.get('content-type') || ''; + return contentType.includes('text/event-stream') + ? createAnthropicStreamingResponse(response) + : createAnthropicJsonResponse(response); +} diff --git a/src/cursor/cursor-anthropic-translator.ts b/src/cursor/cursor-anthropic-translator.ts new file mode 100644 index 00000000..4c84cdb5 --- /dev/null +++ b/src/cursor/cursor-anthropic-translator.ts @@ -0,0 +1,186 @@ +import type { CursorTool } from './cursor-protobuf-schema'; +import type { + AnthropicContentBlock, + CursorAnthropicRequest, + CursorOpenAIMessage, +} from './cursor-anthropic-types'; + +export interface TranslatedAnthropicRequest { + model?: string; + stream: boolean; + reasoning_effort?: string; + tools?: CursorTool[]; + messages: CursorOpenAIMessage[]; +} + +function assertObject(value: unknown, label: string): Record { + if (typeof value !== 'object' || value === null) { + throw new Error(`${label} must be an object`); + } + return value as Record; +} + +function flattenTextContent(content: unknown, label: string): string { + if (typeof content === 'string') { + return content; + } + if (!Array.isArray(content)) { + throw new Error(`${label} must be a string or content block array`); + } + + return content + .map((block, index) => { + const parsed = assertObject(block, `${label}[${index}]`); + if (parsed.type !== 'text') { + throw new Error(`${label}[${index}].type "${String(parsed.type)}" is not supported`); + } + return typeof parsed.text === 'string' ? parsed.text : ''; + }) + .join('\n'); +} + +function toToolResultContent(content: unknown, label: string): string { + if (content === undefined) { + return ''; + } + if (typeof content === 'string') { + return content; + } + if (Array.isArray(content)) { + return flattenTextContent(content, label); + } + return JSON.stringify(content); +} + +function mapThinkingToReasoningEffort( + thinking: CursorAnthropicRequest['thinking'] +): string | undefined { + if (!thinking) { + return undefined; + } + if (thinking.type === 'disabled') { + return undefined; + } + if (thinking.type !== 'enabled') { + throw new Error('thinking.type must be "enabled" or "disabled"'); + } + return typeof thinking.budget_tokens === 'number' && thinking.budget_tokens >= 8192 + ? 'high' + : 'medium'; +} + +export function translateAnthropicRequest(raw: unknown): TranslatedAnthropicRequest { + const request = assertObject(raw, 'request') as CursorAnthropicRequest; + const translatedMessages: CursorOpenAIMessage[] = []; + + if (request.system !== undefined) { + translatedMessages.push({ + role: 'system', + content: flattenTextContent(request.system, 'system'), + }); + } + + if (!Array.isArray(request.messages)) { + throw new Error('messages must be an array'); + } + + request.messages.forEach((message, messageIndex) => { + const role = message.role; + if (role !== 'user' && role !== 'assistant') { + throw new Error(`messages[${messageIndex}].role must be "user" or "assistant"`); + } + + const content = message.content; + if (typeof content === 'string') { + translatedMessages.push({ role, content }); + return; + } + + if (!Array.isArray(content)) { + throw new Error(`messages[${messageIndex}].content must be a string or array`); + } + + const textParts: string[] = []; + const toolCalls: NonNullable = []; + + content.forEach((block, blockIndex) => { + const parsed = assertObject( + block, + `messages[${messageIndex}].content[${blockIndex}]` + ) as unknown as AnthropicContentBlock; + + if (parsed.type === 'text') { + textParts.push(typeof parsed.text === 'string' ? parsed.text : ''); + return; + } + + if (parsed.type === 'tool_use') { + if (role !== 'assistant') { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}] tool_use requires assistant role` + ); + } + toolCalls.push({ + id: + typeof parsed.id === 'string' && parsed.id.length > 0 + ? parsed.id + : `toolu_${messageIndex}_${blockIndex}`, + type: 'function', + function: { + name: typeof parsed.name === 'string' ? parsed.name : 'tool', + arguments: JSON.stringify(parsed.input ?? {}), + }, + }); + return; + } + + if (parsed.type === 'tool_result') { + if (role !== 'user') { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}] tool_result requires user role` + ); + } + translatedMessages.push({ + role: 'tool', + tool_call_id: typeof parsed.tool_use_id === 'string' ? parsed.tool_use_id : '', + content: toToolResultContent( + parsed.content, + `messages[${messageIndex}].content[${blockIndex}].content` + ), + }); + return; + } + + throw new Error( + `messages[${messageIndex}].content[${blockIndex}].type "${String((parsed as { type?: unknown }).type)}" is not supported` + ); + }); + + if (role === 'assistant') { + translatedMessages.push({ + role, + content: textParts.join('\n'), + tool_calls: toolCalls.length > 0 ? toolCalls : undefined, + }); + return; + } + + if (textParts.length > 0 || toolCalls.length === 0) { + translatedMessages.push({ + role, + content: textParts.join('\n'), + }); + } + }); + + return { + model: + typeof request.model === 'string' && request.model.trim().length > 0 + ? request.model + : undefined, + stream: request.stream === true, + reasoning_effort: mapThinkingToReasoningEffort(request.thinking), + tools: Array.isArray(request.tools) ? request.tools : undefined, + messages: translatedMessages, + }; +} diff --git a/src/cursor/cursor-anthropic-types.ts b/src/cursor/cursor-anthropic-types.ts new file mode 100644 index 00000000..336fb75f --- /dev/null +++ b/src/cursor/cursor-anthropic-types.ts @@ -0,0 +1,48 @@ +import type { CursorTool } from './cursor-protobuf-schema'; + +export interface CursorOpenAIMessage { + role: string; + content: string; + name?: string; + tool_call_id?: string; + tool_calls?: Array<{ + id: string; + type: string; + function: { name: string; arguments: string }; + }>; +} + +export interface AnthropicTextBlock { + type: 'text'; + text?: string; +} + +export interface AnthropicToolUseBlock { + type: 'tool_use'; + id?: string; + name?: string; + input?: Record; +} + +export interface AnthropicToolResultBlock { + type: 'tool_result'; + tool_use_id?: string; + content?: unknown; +} + +export type AnthropicContentBlock = + | AnthropicTextBlock + | AnthropicToolUseBlock + | AnthropicToolResultBlock; + +export interface CursorAnthropicRequest { + model?: string; + messages?: Array<{ role?: string; content?: string | AnthropicContentBlock[] }>; + system?: string | AnthropicTextBlock[]; + stream?: boolean; + tools?: CursorTool[]; + thinking?: { + type?: string; + budget_tokens?: number; + }; +} diff --git a/src/cursor/cursor-daemon-entry.ts b/src/cursor/cursor-daemon-entry.ts index f6eb2f7d..a9fd1d2d 100644 --- a/src/cursor/cursor-daemon-entry.ts +++ b/src/cursor/cursor-daemon-entry.ts @@ -7,6 +7,8 @@ import * as http from 'http'; import { Readable } from 'stream'; import { CursorExecutor } from './cursor-executor'; +import { 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'; @@ -222,13 +224,20 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser return; } - if (method !== 'POST' || requestUrl !== '/v1/chat/completions') { + const isOpenAiRoute = method === 'POST' && requestUrl === '/v1/chat/completions'; + const isAnthropicRoute = method === 'POST' && requestUrl === '/v1/messages'; + + if (!isOpenAiRoute && !isAnthropicRoute) { writeJson(res, 404, { error: 'Not found' }); return; } - const parsedBody = (await readJsonBody(req)) as OpenAIChatRequest; - const messages = normalizeMessages(parsedBody.messages); + 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() @@ -301,7 +310,11 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser }, }); - await pipeWebResponseToNode(result.response, res); + 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'); diff --git a/tests/unit/cursor/cursor-anthropic-translator.test.ts b/tests/unit/cursor/cursor-anthropic-translator.test.ts new file mode 100644 index 00000000..3587b965 --- /dev/null +++ b/tests/unit/cursor/cursor-anthropic-translator.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'bun:test'; +import { createAnthropicProxyResponse } from '../../../src/cursor/cursor-anthropic-response'; +import { translateAnthropicRequest } from '../../../src/cursor/cursor-anthropic-translator'; + +describe('translateAnthropicRequest', () => { + it('maps Anthropic system, tool use, and tool result blocks into Cursor OpenAI messages', () => { + const translated = translateAnthropicRequest({ + model: 'claude-sonnet-4.5', + stream: true, + thinking: { type: 'enabled', budget_tokens: 9000 }, + tools: [{ name: 'search', description: 'Search docs', input_schema: { type: 'object' } }], + system: 'You are helpful.', + messages: [ + { role: 'user', content: [{ type: 'text', text: 'Find release notes' }] }, + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_1', name: 'search', input: { q: 'release' } }], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_1', + content: [{ type: 'text', text: 'v7.53.0' }], + }, + { type: 'text', text: 'Summarize it.' }, + ], + }, + ], + }); + + expect(translated.model).toBe('claude-sonnet-4.5'); + expect(translated.stream).toBe(true); + expect(translated.reasoning_effort).toBe('high'); + expect(translated.messages).toEqual([ + { role: 'system', content: 'You are helpful.' }, + { role: 'user', content: 'Find release notes' }, + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'toolu_1', + type: 'function', + function: { name: 'search', arguments: '{"q":"release"}' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'toolu_1', content: 'v7.53.0' }, + { role: 'user', content: 'Summarize it.' }, + ]); + }); + + it('rejects unsupported content blocks', () => { + expect(() => + translateAnthropicRequest({ + messages: [{ role: 'user', content: [{ type: 'image' }] }], + }) + ).toThrow('is not supported'); + }); +}); + +describe('createAnthropicProxyResponse', () => { + it('converts OpenAI JSON into Anthropic message JSON', async () => { + const response = new Response( + JSON.stringify({ + id: 'chatcmpl_1', + model: 'claude-sonnet-4.5', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Here is the result.', + reasoning_content: 'Need to call the tool first.', + tool_calls: [ + { + id: 'toolu_2', + type: 'function', + function: { name: 'search', arguments: '{"q":"cursor daemon"}' }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + usage: { prompt_tokens: 12, completion_tokens: 4, total_tokens: 16 }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + + const transformed = await createAnthropicProxyResponse(response); + const body = (await transformed.json()) as { + type: string; + model: string; + stop_reason: string; + content: Array<{ + type: string; + text?: string; + thinking?: string; + name?: string; + input?: Record; + }>; + }; + + expect(body.type).toBe('message'); + expect(body.model).toBe('claude-sonnet-4.5'); + expect(body.stop_reason).toBe('tool_use'); + expect(body.content.map((block) => block.type)).toEqual(['thinking', 'text', 'tool_use']); + expect(body.content[0]?.thinking).toContain('Need to call the tool first'); + expect(body.content[2]?.name).toBe('search'); + expect(body.content[2]?.input).toEqual({ q: 'cursor daemon' }); + }); + + it('converts OpenAI SSE chunks into Anthropic SSE events', async () => { + const openAiSse = [ + 'data: {"id":"chatcmpl_2","object":"chat.completion.chunk","created":1,"model":"claude-sonnet-4.5","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}\n\n', + 'data: {"id":"chatcmpl_2","object":"chat.completion.chunk","created":1,"model":"claude-sonnet-4.5","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":1,"total_tokens":6}}\n\n', + 'data: [DONE]\n\n', + ].join(''); + + const transformed = await createAnthropicProxyResponse( + new Response(openAiSse, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) + ); + + const body = await transformed.text(); + expect(body).toContain('event: message_start'); + expect(body).toContain('event: content_block_start'); + expect(body).toContain('"type":"text_delta"'); + expect(body).toContain('event: message_stop'); + }); +}); diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index 1ba7e553..8bc22d7a 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -151,34 +151,46 @@ describe('startDaemon', () => { const running = await isDaemonRunning(port); expect(running).toBe(true); - // Verify models endpoint exists and is OpenAI-compatible list shape - const modelsResponse = await fetch(`http://127.0.0.1:${port}/v1/models`); - expect(modelsResponse.status).toBe(200); - const modelsJson = (await modelsResponse.json()) as { object?: string; data?: unknown[] }; - expect(modelsJson.object).toBe('list'); - expect(Array.isArray(modelsJson.data)).toBe(true); + // Verify models endpoint exists and is OpenAI-compatible list shape + const modelsResponse = await fetch(`http://127.0.0.1:${port}/v1/models`); + expect(modelsResponse.status).toBe(200); + const modelsJson = (await modelsResponse.json()) as { object?: string; data?: unknown[] }; + expect(modelsJson.object).toBe('list'); + expect(Array.isArray(modelsJson.data)).toBe(true); - // Verify chat endpoint exists (requires auth, should not be 404) - const chatResponse = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: 'gpt-4.1', - messages: [{ role: 'user', content: 'hello' }], - }), - }); - expect(chatResponse.status).toBe(401); + // Verify chat endpoint exists (requires auth, should not be 404) + const chatResponse = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-4.1', + messages: [{ role: 'user', content: 'hello' }], + }), + }); + expect(chatResponse.status).toBe(401); + + const anthropicResponse = await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'claude-sonnet-4.5', + max_tokens: 256, + messages: [{ role: 'user', content: 'hello' }], + }), + }); + expect(anthropicResponse.status).toBe(401); // Stop const stopResult = await stopDaemon(); expect(stopResult.success).toBe(true); - // Verify stopped - const stillRunning = await isDaemonRunning(port); - expect(stillRunning).toBe(false); - }, - 35000 - ); + // Verify stopped + const stillRunning = await isDaemonRunning(port); + expect(stillRunning).toBe(false); + }, 35000); it('returns 404 for unknown routes', async () => { const port = 10000 + Math.floor(Math.random() * 50000); @@ -248,6 +260,20 @@ describe('startDaemon', () => { }); expect(invalidSchema.status).toBe(400); + const invalidAnthropic = await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'claude-sonnet-4.5', + max_tokens: 256, + messages: [{ role: 'user', content: [{ type: 'image' }] }], + }), + }); + expect(invalidAnthropic.status).toBe(400); + const oversized = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json' },