diff --git a/src/cursor/cursor-anthropic-response.ts b/src/cursor/cursor-anthropic-response.ts index 6102de27..6575fd14 100644 --- a/src/cursor/cursor-anthropic-response.ts +++ b/src/cursor/cursor-anthropic-response.ts @@ -5,23 +5,59 @@ import type { OpenAIResponse, SSEEvent } from '../glmt/pipeline'; const JSON_TRANSLATION_ERROR_MESSAGE = 'Failed to translate Cursor JSON response'; const STREAM_TRANSLATION_ERROR_MESSAGE = 'Failed to translate Cursor SSE response'; +type ResponseHeaders = Headers | Record | Array<[string, string]>; -function createErrorResponse(message: string): Response { - return new Response( - JSON.stringify({ - error: { - type: 'api_error', - message, - }, - }), - { - status: 502, - headers: { 'Content-Type': 'application/json' }, - } - ); +interface AnthropicErrorPayload { + type: 'error'; + error: { + type: string; + message: string; + }; } -function formatSseEvent(event: string, data: Record): string { +function createAnthropicErrorPayload(type: string, message: string): AnthropicErrorPayload { + return { + type: 'error', + error: { + type, + message, + }, + }; +} + +function formatErrorForLog(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + +function logTranslationError(context: string, error: unknown): void { + console.error(`[cursor-anthropic-response] ${context}: ${formatErrorForLog(error)}`); +} + +export function createAnthropicErrorResponse( + status: number, + type: string, + message: string, + headers?: ResponseHeaders +): Response { + const responseHeaders = new Headers(headers); + responseHeaders.set('Content-Type', 'application/json'); + responseHeaders.delete('Content-Length'); + + return new Response(JSON.stringify(createAnthropicErrorPayload(type, message)), { + status, + headers: responseHeaders, + }); +} + +function formatSseEvent(event: string, data: unknown): string { return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; } @@ -31,33 +67,110 @@ function hasTranslatableChoices(value: unknown): value is OpenAIResponse { } const { choices } = value as OpenAIResponse; - return Array.isArray(choices) && choices.length > 0; + if (!Array.isArray(choices) || choices.length === 0) { + return false; + } + + const firstChoice = choices[0]; + if (typeof firstChoice !== 'object' || firstChoice === null) { + return false; + } + + const message = (firstChoice as { message?: unknown }).message; + return typeof message === 'object' && message !== null; +} + +function isSyntheticTransformationFallback(value: unknown): boolean { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { id?: unknown }).id === 'string' && + (value as { id: string }).id.startsWith('msg_error_') + ); +} + +async function createAnthropicErrorProxyResponse(response: Response): Promise { + const headers = new Headers(response.headers); + headers.delete('Content-Type'); + headers.delete('Content-Length'); + + let type = + response.status === 401 + ? 'authentication_error' + : response.status === 429 + ? 'rate_limit_error' + : response.status >= 400 && response.status < 500 + ? 'invalid_request_error' + : 'api_error'; + let message = `Cursor request failed with status ${response.status}`; + + try { + const contentType = (response.headers.get('content-type') || '').toLowerCase(); + if (contentType.includes('application/json')) { + const payload = (await response.json()) as { + error?: { type?: string; message?: string }; + message?: string; + }; + + if (typeof payload?.error?.type === 'string' && payload.error.type.trim().length > 0) { + type = payload.error.type; + } + + if (typeof payload?.error?.message === 'string' && payload.error.message.trim().length > 0) { + message = payload.error.message; + } else if (typeof payload?.message === 'string' && payload.message.trim().length > 0) { + message = payload.message; + } + } else { + const text = (await response.text()).trim(); + if (text.length > 0) { + message = text; + } + } + } catch (error) { + logTranslationError('Failed to parse Cursor error response', error); + } + + return createAnthropicErrorResponse(response.status, type, message, headers); } async function createAnthropicJsonResponse(response: Response): Promise { try { const openAiResponse = await response.json(); if (!hasTranslatableChoices(openAiResponse)) { - return createErrorResponse(JSON_TRANSLATION_ERROR_MESSAGE); + return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE); } const anthropicResponse = new GlmtTransformer().transformResponse(openAiResponse); + if (isSyntheticTransformationFallback(anthropicResponse)) { + logTranslationError( + 'Cursor JSON translation produced synthetic fallback response', + anthropicResponse + ); + return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE); + } + return new Response(JSON.stringify(anthropicResponse), { status: response.status, headers: { 'Content-Type': 'application/json' }, }); - } catch { - return createErrorResponse(JSON_TRANSLATION_ERROR_MESSAGE); + } catch (error) { + logTranslationError('Cursor JSON translation failed', error); + return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE); } } function createAnthropicStreamingResponse(response: Response): Response { const body = response.body; if (!body) { - return createErrorResponse('Cursor stream ended before a response body was available'); + return createAnthropicErrorResponse( + 502, + 'api_error', + 'Cursor stream ended before a response body was available' + ); } - const parser = new SSEParser(); + const parser = new SSEParser({ throwOnMalformedJson: true }); const transformer = new GlmtTransformer(); const accumulator = new DeltaAccumulator({}); const encoder = new TextEncoder(); @@ -94,16 +207,14 @@ function createAnthropicStreamingResponse(response: Response): Response { ); }); } - } catch { + } catch (error) { + logTranslationError('Cursor SSE translation failed', error); controller.enqueue( encoder.encode( - formatSseEvent('error', { - type: 'error', - error: { - type: 'api_error', - message: STREAM_TRANSLATION_ERROR_MESSAGE, - }, - }) + formatSseEvent( + 'error', + createAnthropicErrorPayload('api_error', STREAM_TRANSLATION_ERROR_MESSAGE) + ) ) ); } finally { @@ -125,11 +236,14 @@ function createAnthropicStreamingResponse(response: Response): Response { export async function createAnthropicProxyResponse(response: Response): Promise { if (!response.ok) { - return response; + return createAnthropicErrorProxyResponse(response); } - const contentType = response.headers.get('content-type') || ''; - return contentType.includes('text/event-stream') + const contentType = (response.headers.get('content-type') || '').toLowerCase(); + const isEventStream = + contentType === 'text/event-stream' || contentType.startsWith('text/event-stream;'); + + return isEventStream ? createAnthropicStreamingResponse(response) : createAnthropicJsonResponse(response); } diff --git a/src/cursor/cursor-anthropic-translator.ts b/src/cursor/cursor-anthropic-translator.ts index ce245bb7..6dbe7d92 100644 --- a/src/cursor/cursor-anthropic-translator.ts +++ b/src/cursor/cursor-anthropic-translator.ts @@ -157,10 +157,22 @@ export function translateAnthropicRequest(raw: unknown): TranslatedAnthropicRequ `messages[${messageIndex}].content[${blockIndex}] tool_result requires user role` ); } + if (typeof parsed.tool_use_id !== 'string' || parsed.tool_use_id.trim().length === 0) { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}].tool_use_id must be a non-empty string` + ); + } sawToolResult = true; + if (textParts.length > 0) { + translatedMessages.push({ + role, + content: textParts.join('\n'), + }); + textParts.length = 0; + } translatedMessages.push({ role: 'tool', - tool_call_id: typeof parsed.tool_use_id === 'string' ? parsed.tool_use_id : '', + tool_call_id: parsed.tool_use_id, content: toToolResultContent( parsed.content, `messages[${messageIndex}].content[${blockIndex}].content` diff --git a/src/cursor/cursor-daemon-entry.ts b/src/cursor/cursor-daemon-entry.ts index a9fd1d2d..bfcea61c 100644 --- a/src/cursor/cursor-daemon-entry.ts +++ b/src/cursor/cursor-daemon-entry.ts @@ -7,7 +7,10 @@ import * as http from 'http'; import { Readable } from 'stream'; import { CursorExecutor } from './cursor-executor'; -import { createAnthropicProxyResponse } from './cursor-anthropic-response'; +import { + createAnthropicErrorResponse, + createAnthropicProxyResponse, +} from './cursor-anthropic-response'; import { translateAnthropicRequest } from './cursor-anthropic-translator'; import { checkAuthStatus } from './cursor-auth'; import { getModelsForDaemon, resolveCursorRequestModel } from './cursor-models'; @@ -192,10 +195,12 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser const executor = new CursorExecutor(); const server = http.createServer(async (req, res) => { - try { - const method = req.method || 'GET'; - const requestUrl = req.url || '/'; + 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; @@ -224,9 +229,6 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser return; } - 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; @@ -246,22 +248,38 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser const authStatus = checkAuthStatus(); if (!authStatus.authenticated || !authStatus.credentials) { - writeJson(res, 401, { - error: { - type: 'authentication_error', - message: 'Cursor credentials not found. Run `ccs cursor auth` first.', - }, - }); + const message = 'Cursor credentials not found. Run `ccs 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) { - writeJson(res, 401, { - error: { - type: 'authentication_error', - message: 'Cursor credentials expired. Run `ccs cursor auth` again.', - }, - }); + const message = 'Cursor credentials expired. Run `ccs cursor auth` again.'; + if (isAnthropicRoute) { + await pipeWebResponseToNode( + createAnthropicErrorResponse(401, 'authentication_error', message), + res + ); + } else { + writeJson(res, 401, { + error: { + type: 'authentication_error', + message, + }, + }); + } return; } @@ -318,12 +336,20 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; const isPayloadTooLarge = message.includes('Request body too large'); - writeJson(res, isPayloadTooLarge ? 413 : 400, { - error: { - type: 'invalid_request_error', - message, - }, - }); + 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, + }, + }); + } } }); diff --git a/src/cursor/cursor-models.ts b/src/cursor/cursor-models.ts index 208a1a25..1bd9a31f 100644 --- a/src/cursor/cursor-models.ts +++ b/src/cursor/cursor-models.ts @@ -281,6 +281,48 @@ function getCatalogDefaultModelId(availableModels: CursorModel[]): string { return firstAvailable || DEFAULT_CURSOR_MODEL; } +function addLookupCandidate(candidates: Set, value: string): void { + const normalized = value.trim().toLowerCase(); + if (normalized) { + candidates.add(normalized); + } +} + +function buildCursorAnthropicModelLookupCandidates(requestedModel: string): string[] { + const candidates = new Set(); + const raw = requestedModel.trim().toLowerCase(); + addLookupCandidate(candidates, raw); + + let normalized = raw.replace(/^[a-z0-9_-]+\//, ''); + addLookupCandidate(candidates, normalized); + + while (true) { + const stripped = normalized + .replace(/\(\d+\)$/i, '') + .replace(/\[1m\]$/i, '') + .replace(/-thinking$/i, '') + .replace(/-\d{8}$/i, ''); + + if (stripped === normalized) { + break; + } + + normalized = stripped; + addLookupCandidate(candidates, normalized); + } + + const anthropicAliasMatch = normalized.match( + /^claude-(opus|sonnet|haiku)-(\d+)(?:[.-](\d+))?(?:-(1m|fast-mode))?$/i + ); + if (anthropicAliasMatch) { + const [, family, major, minor, variant] = anthropicAliasMatch; + const cursorModelId = `claude-${major}${minor ? `.${minor}` : ''}-${family.toLowerCase()}${variant ? `-${variant.toLowerCase()}` : ''}`; + addLookupCandidate(candidates, cursorModelId); + } + + return [...candidates]; +} + export function resolveCursorRequestModel( requestedModel: string | null | undefined, availableModels: CursorModel[] @@ -291,8 +333,12 @@ export function resolveCursorRequestModel( return fallbackModel; } - if (availableModels.some((model) => model.id === normalizedRequested)) { - return normalizedRequested; + const lookupCandidates = new Set(buildCursorAnthropicModelLookupCandidates(normalizedRequested)); + const matchedModel = availableModels.find((model) => + lookupCandidates.has(model.id.toLowerCase()) + ); + if (matchedModel) { + return matchedModel.id; } return fallbackModel; diff --git a/src/glmt/sse-parser.ts b/src/glmt/sse-parser.ts index eaeb00dd..9a731654 100644 --- a/src/glmt/sse-parser.ts +++ b/src/glmt/sse-parser.ts @@ -19,6 +19,7 @@ interface SSEParserOptions { maxBufferSize?: number; + throwOnMalformedJson?: boolean; } interface SSEEvent { @@ -33,11 +34,13 @@ export class SSEParser { private buffer: string; private eventCount: number; private maxBufferSize: number; + private throwOnMalformedJson: boolean; constructor(options: SSEParserOptions = {}) { this.buffer = ''; this.eventCount = 0; this.maxBufferSize = options.maxBufferSize || 1024 * 1024; // 1MB default + this.throwOnMalformedJson = options.throwOnMalformedJson === true; } /** @@ -92,6 +95,9 @@ export class SSEParser { data.substring(0, 100) ); } + if (this.throwOnMalformedJson) { + throw new Error(`Malformed SSE JSON event: ${(e as Error).message}`); + } } } } else if (line.startsWith('id: ')) { diff --git a/tests/integration/cursor-daemon-lifecycle.test.ts b/tests/integration/cursor-daemon-lifecycle.test.ts index db76fcbd..7a4e28ed 100644 --- a/tests/integration/cursor-daemon-lifecycle.test.ts +++ b/tests/integration/cursor-daemon-lifecycle.test.ts @@ -68,6 +68,13 @@ describe('cursor daemon lifecycle smoke', () => { }), }); expect(anthropicResponse.status).toBe(401); + const anthropicBody = (await anthropicResponse.json()) as { + type?: string; + error?: { type?: string; message?: string }; + }; + expect(anthropicBody.type).toBe('error'); + expect(anthropicBody.error?.type).toBe('authentication_error'); + expect(anthropicBody.error?.message).toContain('Run `ccs cursor auth` first'); const stopResult = await stopDaemon(); expect(stopResult.success).toBe(true); @@ -146,6 +153,13 @@ describe('cursor daemon lifecycle smoke', () => { }), }); expect(invalidAnthropic.status).toBe(400); + const invalidAnthropicBody = (await invalidAnthropic.json()) as { + type?: string; + error?: { type?: string; message?: string }; + }; + expect(invalidAnthropicBody.type).toBe('error'); + expect(invalidAnthropicBody.error?.type).toBe('invalid_request_error'); + expect(invalidAnthropicBody.error?.message).toContain('is not supported'); const oversized = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { method: 'POST', diff --git a/tests/unit/cursor/cursor-anthropic-translator.test.ts b/tests/unit/cursor/cursor-anthropic-translator.test.ts index e0dac78f..0abaec2b 100644 --- a/tests/unit/cursor/cursor-anthropic-translator.test.ts +++ b/tests/unit/cursor/cursor-anthropic-translator.test.ts @@ -90,6 +90,27 @@ describe('translateAnthropicRequest', () => { ]); }); + it('preserves mixed user text around tool_result blocks in order', () => { + const translated = translateAnthropicRequest({ + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'before' }, + { type: 'tool_result', tool_use_id: 'toolu_1', content: 'done' }, + { type: 'text', text: 'after' }, + ], + }, + ], + }); + + expect(translated.messages).toEqual([ + { role: 'user', content: 'before' }, + { role: 'tool', tool_call_id: 'toolu_1', content: 'done' }, + { role: 'user', content: 'after' }, + ]); + }); + it('handles empty messages arrays', () => { const translated = translateAnthropicRequest({ messages: [] }); @@ -150,6 +171,19 @@ describe('translateAnthropicRequest', () => { ]); }); + it('rejects tool_result blocks without a non-empty tool_use_id', () => { + expect(() => + translateAnthropicRequest({ + messages: [ + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: ' ', content: 'done' }], + }, + ], + }) + ).toThrow('tool_use_id must be a non-empty string'); + }); + it('falls back when tool_use input cannot be serialized', () => { const circular: Record = {}; circular.self = circular; @@ -241,7 +275,11 @@ describe('createAnthropicProxyResponse', () => { ); expect(transformed.status).toBe(502); - const body = (await transformed.json()) as { error?: { type?: string; message?: string } }; + const body = (await transformed.json()) as { + type?: string; + error?: { type?: string; message?: string }; + }; + expect(body.type).toBe('error'); expect(body.error?.type).toBe('api_error'); expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); }); @@ -261,7 +299,11 @@ describe('createAnthropicProxyResponse', () => { ); expect(transformed.status).toBe(502); - const body = (await transformed.json()) as { error?: { type?: string; message?: string } }; + const body = (await transformed.json()) as { + type?: string; + error?: { type?: string; message?: string }; + }; + expect(body.type).toBe('error'); expect(body.error?.type).toBe('api_error'); expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); }); @@ -282,7 +324,63 @@ describe('createAnthropicProxyResponse', () => { ); expect(transformed.status).toBe(502); - const body = (await transformed.json()) as { error?: { type?: string; message?: string } }; + const body = (await transformed.json()) as { + type?: string; + error?: { type?: string; message?: string }; + }; + expect(body.type).toBe('error'); + expect(body.error?.type).toBe('api_error'); + expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); + }); + + it('returns Anthropic error envelopes for non-OK upstream JSON errors', async () => { + const transformed = await createAnthropicProxyResponse( + new Response( + JSON.stringify({ + error: { + type: 'invalid_request_error', + message: '[400]: upstream rejected request', + }, + }), + { + status: 400, + headers: { 'Content-Type': 'application/json', 'Retry-After': '7' }, + } + ) + ); + + expect(transformed.status).toBe(400); + expect(transformed.headers.get('retry-after')).toBe('7'); + const body = (await transformed.json()) as { + type?: string; + error?: { type?: string; message?: string }; + }; + expect(body.type).toBe('error'); + expect(body.error?.type).toBe('invalid_request_error'); + expect(body.error?.message).toBe('[400]: upstream rejected request'); + }); + + it('returns 502 when Cursor response choices are malformed', async () => { + const transformed = await createAnthropicProxyResponse( + new Response( + JSON.stringify({ + id: 'chatcmpl_missing_message', + model: 'claude-sonnet-4.5', + choices: [{ index: 0 }], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); + + expect(transformed.status).toBe(502); + const body = (await transformed.json()) as { + type?: string; + error?: { type?: string; message?: string }; + }; + expect(body.type).toBe('error'); expect(body.error?.type).toBe('api_error'); expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); }); @@ -324,4 +422,19 @@ describe('createAnthropicProxyResponse', () => { expect(body).toContain('"error":{"type":"api_error"'); expect(body).toContain('Failed to translate Cursor SSE response'); }); + + it('emits Anthropic-style error events when SSE JSON is malformed', async () => { + const transformed = await createAnthropicProxyResponse( + new Response('data: {not-json}\n\n', { + status: 200, + headers: { 'Content-Type': 'text/event-stream; charset=utf-8' }, + }) + ); + + const body = await transformed.text(); + expect(body).toContain('event: error'); + expect(body).toContain('"type":"error"'); + expect(body).toContain('"error":{"type":"api_error"'); + expect(body).toContain('Failed to translate Cursor SSE response'); + }); }); diff --git a/tests/unit/cursor/cursor-models.test.ts b/tests/unit/cursor/cursor-models.test.ts index 0390f5e1..a5095b5e 100644 --- a/tests/unit/cursor/cursor-models.test.ts +++ b/tests/unit/cursor/cursor-models.test.ts @@ -57,6 +57,19 @@ describe('resolveCursorRequestModel', () => { expect(resolved).toBe('claude-4.6-opus'); }); + it('maps Anthropic family-first aliases to the matching Cursor model id', () => { + const resolved = resolveCursorRequestModel('claude-sonnet-4.5', DEFAULT_CURSOR_MODELS); + expect(resolved).toBe('claude-4.5-sonnet'); + }); + + it('strips provider, dated, and thinking suffixes before resolving Anthropic aliases', () => { + const resolved = resolveCursorRequestModel( + 'anthropic/claude-sonnet-4.5-20250929-thinking', + DEFAULT_CURSOR_MODELS + ); + expect(resolved).toBe('claude-4.5-sonnet'); + }); + it('falls back to default when requested model is unavailable', () => { const resolved = resolveCursorRequestModel('non-existent-model', DEFAULT_CURSOR_MODELS); expect(resolved).toBe(DEFAULT_CURSOR_MODEL);