diff --git a/src/cursor/cursor-anthropic-response.ts b/src/cursor/cursor-anthropic-response.ts index ddf31803..6102de27 100644 --- a/src/cursor/cursor-anthropic-response.ts +++ b/src/cursor/cursor-anthropic-response.ts @@ -3,6 +3,9 @@ import { GlmtTransformer } from '../glmt/glmt-transformer'; import { SSEParser } from '../glmt/sse-parser'; 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'; + function createErrorResponse(message: string): Response { return new Response( JSON.stringify({ @@ -22,18 +25,29 @@ function formatSseEvent(event: string, data: Record): string { return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; } +function hasTranslatableChoices(value: unknown): value is OpenAIResponse { + if (typeof value !== 'object' || value === null) { + return false; + } + + const { choices } = value as OpenAIResponse; + return Array.isArray(choices) && choices.length > 0; +} + async function createAnthropicJsonResponse(response: Response): Promise { try { - const openAiResponse = (await response.json()) as OpenAIResponse; + const openAiResponse = await response.json(); + if (!hasTranslatableChoices(openAiResponse)) { + return createErrorResponse(JSON_TRANSLATION_ERROR_MESSAGE); + } + 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}` - ); + } catch { + return createErrorResponse(JSON_TRANSLATION_ERROR_MESSAGE); } } @@ -80,14 +94,14 @@ function createAnthropicStreamingResponse(response: Response): Response { ); }); } - } catch (error) { + } catch { controller.enqueue( encoder.encode( formatSseEvent('error', { type: 'error', error: { type: 'api_error', - message: `Failed to translate Cursor SSE response: ${(error as Error).message}`, + message: STREAM_TRANSLATION_ERROR_MESSAGE, }, }) ) diff --git a/tests/unit/cursor/cursor-anthropic-translator.test.ts b/tests/unit/cursor/cursor-anthropic-translator.test.ts index 63d91201..e0dac78f 100644 --- a/tests/unit/cursor/cursor-anthropic-translator.test.ts +++ b/tests/unit/cursor/cursor-anthropic-translator.test.ts @@ -90,6 +90,12 @@ describe('translateAnthropicRequest', () => { ]); }); + it('handles empty messages arrays', () => { + const translated = translateAnthropicRequest({ messages: [] }); + + expect(translated.messages).toEqual([]); + }); + it('uses a distinct fallback prefix for missing tool_use ids', () => { const translated = translateAnthropicRequest({ messages: [ @@ -125,6 +131,25 @@ describe('translateAnthropicRequest', () => { ]); }); + it('returns empty string for tool_result blocks without content', () => { + const translated = translateAnthropicRequest({ + messages: [ + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_1' }], + }, + ], + }); + + expect(translated.messages).toEqual([ + { + role: 'tool', + tool_call_id: 'toolu_1', + content: '', + }, + ]); + }); + it('falls back when tool_use input cannot be serialized', () => { const circular: Record = {}; circular.self = circular; @@ -207,6 +232,61 @@ describe('createAnthropicProxyResponse', () => { expect(body.content[2]?.input).toEqual({ q: 'cursor daemon' }); }); + it('returns 502 when Cursor returns invalid JSON', async () => { + const transformed = await createAnthropicProxyResponse( + new Response('not json', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + expect(transformed.status).toBe(502); + const body = (await transformed.json()) as { error?: { type?: string; message?: string } }; + expect(body.error?.type).toBe('api_error'); + expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); + }); + + it('returns 502 when Cursor response is missing choices', async () => { + const transformed = await createAnthropicProxyResponse( + new Response( + JSON.stringify({ + id: 'chatcmpl_missing_choices', + model: 'claude-sonnet-4.5', + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); + + expect(transformed.status).toBe(502); + const body = (await transformed.json()) as { error?: { type?: string; message?: string } }; + expect(body.error?.type).toBe('api_error'); + expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); + }); + + it('returns 502 when Cursor response has empty choices', async () => { + const transformed = await createAnthropicProxyResponse( + new Response( + JSON.stringify({ + id: 'chatcmpl_empty_choices', + model: 'claude-sonnet-4.5', + choices: [], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); + + expect(transformed.status).toBe(502); + const body = (await transformed.json()) as { error?: { type?: string; message?: string } }; + expect(body.error?.type).toBe('api_error'); + expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); + }); + 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',