diff --git a/src/proxy/server/messages-route.ts b/src/proxy/server/messages-route.ts index 5165ae2b..f33ff24f 100644 --- a/src/proxy/server/messages-route.ts +++ b/src/proxy/server/messages-route.ts @@ -42,15 +42,93 @@ function isDirectOpenAIReasoningChatModel( ); } +function isMiniMaxOpenAICompatProfile(profile: OpenAICompatProfileConfig): boolean { + try { + return new URL(profile.baseUrl).hostname.toLowerCase().includes('minimax'); + } catch { + return false; + } +} + +function prependTextToContent( + content: ProxyOpenAIRequest['messages'][number]['content'], + text: string +): ProxyOpenAIRequest['messages'][number]['content'] { + if (Array.isArray(content)) { + return [{ type: 'text', text }, ...content]; + } + if (typeof content === 'string') { + return `${text}\n\n${content}`; + } + return text; +} + +function extractTextContent(content: ProxyOpenAIRequest['messages'][number]['content']): string { + if (typeof content === 'string') { + return content; + } + if (!Array.isArray(content)) { + return ''; + } + return content + .filter((part): part is { type: 'text'; text: string } => part.type === 'text') + .map((part) => part.text) + .join('\n\n'); +} + +function shapeMiniMaxChatPayload(payload: ProxyOpenAIRequest): ProxyOpenAIRequest { + const systemMessages: string[] = []; + let removedSystemMessage = false; + const messages = payload.messages.filter((message) => { + if (message.role !== 'system') { + return true; + } + removedSystemMessage = true; + const systemText = extractTextContent(message.content).trim(); + if (systemText.length > 0) { + systemMessages.push(systemText); + } + return false; + }); + + if (!removedSystemMessage) { + return payload; + } + + if (systemMessages.length === 0) { + return { ...payload, messages }; + } + + const systemPrefix = systemMessages.join('\n\n'); + const firstUserIndex = messages.findIndex((message) => message.role === 'user'); + + if (firstUserIndex >= 0) { + messages[firstUserIndex] = { + ...messages[firstUserIndex], + content: prependTextToContent(messages[firstUserIndex].content, systemPrefix), + }; + } else { + messages.unshift({ role: 'user', content: systemPrefix }); + } + + return { ...payload, messages }; +} + function shapeUpstreamChatPayload( payload: ProxyOpenAIRequest, profile: OpenAICompatProfileConfig ): ProxyOpenAIRequest { - if (!isDirectOpenAIReasoningChatModel(profile, payload.model)) { - return payload; + let shaped = payload; + + if (isMiniMaxOpenAICompatProfile(profile)) { + shaped = shapeMiniMaxChatPayload(shaped); } - const shaped = { ...payload }; + if (!isDirectOpenAIReasoningChatModel(profile, shaped.model)) { + return shaped; + } + + shaped = { ...shaped }; if (shaped.max_tokens !== undefined) { shaped.max_completion_tokens = shaped.max_tokens; diff --git a/src/proxy/transformers/request-transformer.ts b/src/proxy/transformers/request-transformer.ts index f1f9346f..d1bfbb2d 100644 --- a/src/proxy/transformers/request-transformer.ts +++ b/src/proxy/transformers/request-transformer.ts @@ -40,7 +40,7 @@ type AnthropicContentBlock = | { type: string; [key: string]: unknown }; interface AnthropicMessage { - role?: 'user' | 'assistant' | string; + role?: 'system' | 'user' | 'assistant' | string; content?: string | AnthropicContentBlock[]; } @@ -435,8 +435,8 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { messagesValue.forEach((message, messageIndex) => { const parsedMessage = assertObject(message, `messages[${messageIndex}]`) as AnthropicMessage; const role = parsedMessage.role; - if (role !== 'user' && role !== 'assistant') { - throw new Error(`messages[${messageIndex}].role must be "user" or "assistant"`); + if (role !== 'system' && role !== 'user' && role !== 'assistant') { + throw new Error(`messages[${messageIndex}].role must be "system", "user", or "assistant"`); } if (pendingToolUseIds && pendingToolUseIds.size > 0 && role !== 'user') { @@ -446,6 +446,14 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { } const content = parsedMessage.content; + if (role === 'system') { + translatedMessages.push({ + role: 'system', + content: flattenTextContent(content, `messages[${messageIndex}].content`), + }); + return; + } + if (typeof content === 'string') { if (pendingToolUseIds && pendingToolUseIds.size > 0) { throw new Error( diff --git a/tests/unit/proxy/messages-route.test.ts b/tests/unit/proxy/messages-route.test.ts index b4fcbbe9..bcf4a031 100644 --- a/tests/unit/proxy/messages-route.test.ts +++ b/tests/unit/proxy/messages-route.test.ts @@ -97,6 +97,12 @@ beforeEach(() => { ANTHROPIC_MODEL: 'sonar-pro', CCS_DROID_PROVIDER: 'generic-chat-completion-api', }), + mm: writeSettings('mm', { + ANTHROPIC_BASE_URL: 'https://api.minimax.io/v1', + ANTHROPIC_AUTH_TOKEN: 'minimax_token', + ANTHROPIC_MODEL: 'MiniMax-M2.7', + CCS_DROID_PROVIDER: 'openai', + }), }; fs.writeFileSync( @@ -242,4 +248,102 @@ describe('handleProxyMessagesRequest', () => { expect(closeCalls).toBe(0); expect(res.statusCode).toBe(502); }); + + it('moves system messages into the first user message for MiniMax OpenAI-compatible upstreams', async () => { + const activeProfile = buildProfile('mm'); + let capturedBody: unknown; + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = JSON.parse(String(init?.body)); + return new Response( + JSON.stringify({ + id: 'chatcmpl_1', + object: 'chat.completion', + created: 1, + model: 'MiniMax-M2.7', + choices: [{ index: 0, message: { role: 'assistant', content: 'ok' } }], + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + }) as typeof globalThis.fetch; + + const req = new FakeRequest({ + 'x-api-key': 'local-token', + }); + const res = new FakeResponse(); + const pending = handleProxyMessagesRequest( + req as never, + res as never, + activeProfile, + 'local-token' + ); + req.end( + JSON.stringify({ + model: 'MiniMax-M2.7', + stream: false, + messages: [ + { role: 'system', content: 'Use Turkish.' }, + { role: 'user', content: 'bu hangi model' }, + ], + }) + ); + await pending; + + expect(capturedBody).toMatchObject({ + model: 'MiniMax-M2.7', + messages: [{ role: 'user', content: 'Use Turkish.\n\nbu hangi model' }], + }); + expect((capturedBody as { messages: Array<{ role: string }> }).messages).not.toContainEqual( + expect.objectContaining({ role: 'system' }) + ); + }); + + it('strips blank system messages for MiniMax OpenAI-compatible upstreams', async () => { + const activeProfile = buildProfile('mm'); + let capturedBody: unknown; + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = JSON.parse(String(init?.body)); + return new Response( + JSON.stringify({ + id: 'chatcmpl_1', + object: 'chat.completion', + created: 1, + model: 'MiniMax-M2.7', + choices: [{ index: 0, message: { role: 'assistant', content: 'ok' } }], + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + }) as typeof globalThis.fetch; + + const req = new FakeRequest({ + 'x-api-key': 'local-token', + }); + const res = new FakeResponse(); + const pending = handleProxyMessagesRequest( + req as never, + res as never, + activeProfile, + 'local-token' + ); + req.end( + JSON.stringify({ + model: 'MiniMax-M2.7', + stream: false, + messages: [ + { role: 'system', content: [{ type: 'text', text: '' }] }, + { role: 'user', content: 'bu hangi model' }, + ], + }) + ); + await pending; + + expect(capturedBody).toMatchObject({ + model: 'MiniMax-M2.7', + messages: [{ role: 'user', content: 'bu hangi model' }], + }); + expect((capturedBody as { messages: Array<{ role: string }> }).messages).not.toContainEqual( + expect.objectContaining({ role: 'system' }) + ); + }); }); diff --git a/tests/unit/proxy/transformers/request-transformer.test.ts b/tests/unit/proxy/transformers/request-transformer.test.ts index 543361a1..1047c2b1 100644 --- a/tests/unit/proxy/transformers/request-transformer.test.ts +++ b/tests/unit/proxy/transformers/request-transformer.test.ts @@ -43,6 +43,23 @@ describe('ProxyRequestTransformer', () => { }); }); + it('accepts Claude Code system messages in the messages array', () => { + const transformer = new ProxyRequestTransformer(); + const result = transformer.transform({ + messages: [ + { role: 'user', content: 'hello' }, + { role: 'system', content: [{ type: 'text', text: 'answer tersely' }] }, + { role: 'user', content: 'which model is this?' }, + ], + }); + + expect(result.messages).toEqual([ + { role: 'user', content: 'hello' }, + { role: 'system', content: 'answer tersely' }, + { role: 'user', content: 'which model is this?' }, + ]); + }); + it('translates base64 image blocks into OpenAI image_url parts', () => { const transformer = new ProxyRequestTransformer(); const result = transformer.transform({