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)
}
}