mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
fix: handle system messages in OpenAI proxy (#1403)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user