From f1ed8912bbed42e72499b4065c0eb93667123f98 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Tue, 23 Jun 2026 13:43:14 +0800 Subject: [PATCH] feat(translator): wrap message-level system roles as user-visible reminders - Added `ClaudeMessageSystemReminderText` to convert system content into user-readable reminder text. - Updated translators (Antigravity, Gemini, Codex, OpenAI) to wrap `system` roles as reminders with `` tags. - Adjusted related test cases to validate reminder wrapping logic. --- .../claude/antigravity_claude_request.go | 11 ++++ .../claude/antigravity_claude_request_test.go | 4 +- .../codex/claude/codex_claude_request.go | 8 ++- .../codex/claude/codex_claude_request_test.go | 40 ++++++++++++- internal/translator/common/claude_system.go | 56 +++++++++++++++++++ .../gemini/claude/gemini_claude_request.go | 10 ++++ .../claude/gemini_claude_request_test.go | 4 +- .../openai/claude/openai_claude_request.go | 16 +++--- .../claude/openai_claude_request_test.go | 25 +++++---- 9 files changed, 146 insertions(+), 28 deletions(-) create mode 100644 internal/translator/common/claude_system.go diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index 94d600fb..0a23d808 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -12,6 +12,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" @@ -370,6 +371,16 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ clientContentJSON := []byte(`{"role":"","parts":[]}`) clientContentJSON, _ = sjson.SetBytes(clientContentJSON, "role", role) contentsResult := messageResult.Get("content") + if originalRole == "system" { + if reminderText, ok := translatorcommon.ClaudeMessageSystemReminderText(contentsResult); ok { + partJSON := []byte(`{}`) + partJSON, _ = sjson.SetBytes(partJSON, "text", reminderText) + clientContentJSON, _ = sjson.SetRawBytes(clientContentJSON, "parts.-1", partJSON) + contentsJSON, _ = sjson.SetRawBytes(contentsJSON, "-1", clientContentJSON) + hasContents = true + } + continue + } if contentsResult.IsArray() { contentResults := contentsResult.Array() numContents := len(contentResults) diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go index 67c200ac..f6b38564 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go @@ -162,13 +162,13 @@ func TestConvertClaudeRequestToAntigravity_ConvertsMessageSystemRoleToUserConten if got := contents[1].Get("role").String(); got != "user" { t.Fatalf("Expected message-level system content to be downgraded to user role, got %q", got) } - if got := contents[1].Get("parts.0.text").String(); got != "String mid-conversation rule" { + if got := contents[1].Get("parts.0.text").String(); got != "\nString mid-conversation rule\n" { t.Fatalf("Unexpected string message-level system content text: %q", got) } if got := contents[2].Get("role").String(); got != "user" { t.Fatalf("Expected array message-level system content to be downgraded to user role, got %q", got) } - if got := contents[2].Get("parts.0.text").String(); got != "Array mid-conversation rule" { + if got := contents[2].Get("parts.0.text").String(); got != "\nArray mid-conversation rule\n" { t.Fatalf("Unexpected array message-level system content text: %q", got) } diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index 8eded6fa..9de9de5b 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -14,6 +14,7 @@ import ( sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -88,7 +89,12 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) messageResult := messageResults[i] messageRole := messageResult.Get("role").String() if messageRole == "system" { - messageRole = "developer" + if reminderText, ok := translatorcommon.ClaudeMessageSystemReminderText(messageResult.Get("content")); ok { + message := []byte(`{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}`) + message, _ = sjson.SetBytes(message, "content.0.text", reminderText) + template, _ = sjson.SetRawBytes(template, "input.-1", message) + } + continue } newMessage := func() []byte { diff --git a/internal/translator/codex/claude/codex_claude_request_test.go b/internal/translator/codex/claude/codex_claude_request_test.go index abf893e4..d4d9c7de 100644 --- a/internal/translator/codex/claude/codex_claude_request_test.go +++ b/internal/translator/codex/claude/codex_claude_request_test.go @@ -43,7 +43,7 @@ func TestConvertClaudeRequestToCodex_SystemMessageScenarios(t *testing.T) { wantTexts: []string{"Be helpful"}, }, { - name: "System role in messages", + name: "Message system role does not become developer", inputJSON: `{ "model": "claude-3-opus", "messages": [ @@ -51,8 +51,7 @@ func TestConvertClaudeRequestToCodex_SystemMessageScenarios(t *testing.T) { {"role": "user", "content": "hello"} ] }`, - wantHasDeveloper: true, - wantTexts: []string{"Follow the project instructions"}, + wantHasDeveloper: false, }, { name: "Array system field with filtered billing header", @@ -102,6 +101,41 @@ func TestConvertClaudeRequestToCodex_SystemMessageScenarios(t *testing.T) { } } +func TestConvertClaudeRequestToCodex_MessageSystemRoleWrapsAsUserReminder(t *testing.T) { + inputJSON := `{ + "model": "claude-3-opus", + "system": [{"type": "text", "text": "Top-level rules"}], + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "system", "content": "Follow the project instructions"}, + {"role": "assistant", "content": [{"type": "text", "text": "ok"}]}, + {"role": "system", "content": [{"type": "text", "text": "Use the current repo"}]} + ] + }` + + result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false) + inputs := gjson.GetBytes(result, "input").Array() + if len(inputs) != 5 { + t.Fatalf("got %d input items, want 5: %s", len(inputs), gjson.GetBytes(result, "input").Raw) + } + + if got := inputs[0].Get("role").String(); got != "developer" { + t.Fatalf("top-level system role = %q, want developer", got) + } + if got := inputs[2].Get("role").String(); got != "user" { + t.Fatalf("message-level system role = %q, want user", got) + } + if got := inputs[2].Get("content.0.text").String(); got != "\nFollow the project instructions\n" { + t.Fatalf("unexpected first reminder text: %q", got) + } + if got := inputs[4].Get("role").String(); got != "user" { + t.Fatalf("array message-level system role = %q, want user", got) + } + if got := inputs[4].Get("content.0.text").String(); got != "\nUse the current repo\n" { + t.Fatalf("unexpected second reminder text: %q", got) + } +} + func TestConvertClaudeRequestToCodex_ParallelToolCalls(t *testing.T) { tests := []struct { name string diff --git a/internal/translator/common/claude_system.go b/internal/translator/common/claude_system.go new file mode 100644 index 00000000..3eef9bcd --- /dev/null +++ b/internal/translator/common/claude_system.go @@ -0,0 +1,56 @@ +package common + +import ( + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + "github.com/tidwall/gjson" +) + +const ( + claudeSystemReminderStart = "" + claudeSystemReminderEnd = "" +) + +// ClaudeMessageSystemReminderText converts a Claude message-level system value +// into ordinary user-visible reminder text for non-Claude upstream formats. +func ClaudeMessageSystemReminderText(content gjson.Result) (string, bool) { + parts := claudeSystemTextParts(content) + if len(parts) == 0 { + return "", false + } + text := strings.Join(parts, "\n") + if strings.TrimSpace(text) == "" { + return "", false + } + return claudeSystemReminderStart + "\n" + text + "\n" + claudeSystemReminderEnd, true +} + +func claudeSystemTextParts(content gjson.Result) []string { + if !content.Exists() { + return nil + } + if content.Type == gjson.String { + text := content.String() + if text == "" || util.IsClaudeCodeAttributionSystemText(text) { + return nil + } + return []string{text} + } + if !content.IsArray() { + return nil + } + parts := make([]string, 0) + content.ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() != "text" { + return true + } + text := item.Get("text").String() + if text == "" || util.IsClaudeCodeAttributionSystemText(text) { + return true + } + parts = append(parts, text) + return true + }) + return parts +} diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index e248445a..5443b86a 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" @@ -79,6 +80,15 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) contentJSON, _ = sjson.SetBytes(contentJSON, "role", role) contentsResult := messageResult.Get("content") + if roleResult.String() == "system" { + if reminderText, ok := translatorcommon.ClaudeMessageSystemReminderText(contentsResult); ok { + part := []byte(`{"text":""}`) + part, _ = sjson.SetBytes(part, "text", reminderText) + contentJSON, _ = sjson.SetRawBytes(contentJSON, "parts.-1", part) + out, _ = sjson.SetRawBytes(out, "contents.-1", contentJSON) + } + return true + } if contentsResult.IsArray() { contentsResult.ForEach(func(_, contentResult gjson.Result) bool { switch contentResult.Get("type").String() { diff --git a/internal/translator/gemini/claude/gemini_claude_request_test.go b/internal/translator/gemini/claude/gemini_claude_request_test.go index f40708b5..b317d91a 100644 --- a/internal/translator/gemini/claude/gemini_claude_request_test.go +++ b/internal/translator/gemini/claude/gemini_claude_request_test.go @@ -134,13 +134,13 @@ func TestConvertClaudeRequestToGemini_ConvertsMessageSystemRoleToUserContent(t * if got := contents[1].Get("role").String(); got != "user" { t.Fatalf("Expected message-level string system content to be downgraded to user role, got %q", got) } - if got := contents[1].Get("parts.0.text").String(); got != "String mid-conversation rule" { + if got := contents[1].Get("parts.0.text").String(); got != "\nString mid-conversation rule\n" { t.Fatalf("Unexpected string message-level system content text: %q", got) } if got := contents[2].Get("role").String(); got != "user" { t.Fatalf("Expected message-level array system content to be downgraded to user role, got %q", got) } - if got := contents[2].Get("parts.0.text").String(); got != "Array mid-conversation rule" { + if got := contents[2].Get("parts.0.text").String(); got != "\nArray mid-conversation rule\n" { t.Fatalf("Unexpected array message-level system content text: %q", got) } diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go index 2498f2f6..94d0f721 100644 --- a/internal/translator/openai/claude/openai_claude_request.go +++ b/internal/translator/openai/claude/openai_claude_request.go @@ -10,6 +10,7 @@ import ( sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -131,14 +132,6 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream if system := root.Get("system"); system.Exists() { appendSystemContent(system) } - if messages := root.Get("messages"); messages.Exists() && messages.IsArray() { - messages.ForEach(func(_, message gjson.Result) bool { - if message.Get("role").String() == "system" { - appendSystemContent(message.Get("content")) - } - return true - }) - } // Only add system message if it has content if hasSystemContent { messagesJSON, _ = sjson.SetRawBytes(messagesJSON, "-1", systemMsgJSON) @@ -148,10 +141,15 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream if messages := root.Get("messages"); messages.Exists() && messages.IsArray() { messages.ForEach(func(_, message gjson.Result) bool { role := message.Get("role").String() + contentResult := message.Get("content") if role == "system" { + if reminderText, ok := translatorcommon.ClaudeMessageSystemReminderText(contentResult); ok { + msgJSON := []byte(`{"role":"user","content":[{"type":"text","text":""}]}`) + msgJSON, _ = sjson.SetBytes(msgJSON, "content.0.text", reminderText) + messagesJSON, _ = sjson.SetRawBytes(messagesJSON, "-1", msgJSON) + } return true } - contentResult := message.Get("content") // Handle content if contentResult.Exists() && contentResult.IsArray() { diff --git a/internal/translator/openai/claude/openai_claude_request_test.go b/internal/translator/openai/claude/openai_claude_request_test.go index cbc57b52..40b33eaf 100644 --- a/internal/translator/openai/claude/openai_claude_request_test.go +++ b/internal/translator/openai/claude/openai_claude_request_test.go @@ -357,7 +357,7 @@ func validGPTChatReasoningSignature() string { return base64.URLEncoding.EncodeToString(raw) } -func TestConvertClaudeRequestToOpenAI_MidConversationSystemMessagesMoveToInitialSystem(t *testing.T) { +func TestConvertClaudeRequestToOpenAI_MessageSystemRoleWrapsAsUserReminder(t *testing.T) { inputJSON := `{ "model": "claude-sonnet-4-5", "system": [{"type": "text", "text": "Top-level rules"}], @@ -374,27 +374,30 @@ func TestConvertClaudeRequestToOpenAI_MidConversationSystemMessagesMoveToInitial resultJSON := gjson.ParseBytes(result) messages := resultJSON.Get("messages").Array() - if len(messages) != 4 { - t.Fatalf("Expected 4 messages, got %d: %s", len(messages), resultJSON.Get("messages").Raw) + if len(messages) != 6 { + t.Fatalf("Expected 6 messages, got %d: %s", len(messages), resultJSON.Get("messages").Raw) } roles := make([]string, 0, len(messages)) for _, message := range messages { roles = append(roles, message.Get("role").String()) } - if got, want := roles, []string{"system", "user", "assistant", "user"}; fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) { + if got, want := roles, []string{"system", "user", "user", "assistant", "user", "user"}; fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) { t.Fatalf("Unexpected message roles: got %v, want %v", got, want) } systemContent := messages[0].Get("content").Array() - if len(systemContent) != 3 { - t.Fatalf("Expected 3 system content items, got %d: %s", len(systemContent), messages[0].Get("content").Raw) + if len(systemContent) != 1 { + t.Fatalf("Expected only top-level system content, got %d items: %s", len(systemContent), messages[0].Get("content").Raw) } - wantTexts := []string{"Top-level rules", "String mid-conversation rule", "Array mid-conversation rule"} - for i, want := range wantTexts { - if got := systemContent[i].Get("text").String(); got != want { - t.Fatalf("system content[%d] = %q, want %q", i, got, want) - } + if got := systemContent[0].Get("text").String(); got != "Top-level rules" { + t.Fatalf("system content = %q, want Top-level rules", got) + } + if got := messages[2].Get("content.0.text").String(); got != "\nString mid-conversation rule\n" { + t.Fatalf("unexpected string reminder text: %q", got) + } + if got := messages[4].Get("content.0.text").String(); got != "\nArray mid-conversation rule\n" { + t.Fatalf("unexpected array reminder text: %q", got) } }