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 `<system-reminder>` tags.
- Adjusted related test cases to validate reminder wrapping logic.
This commit is contained in:
Luis Pater
2026-06-23 13:43:14 +08:00
parent 7c390a7a2e
commit f1ed8912bb
9 changed files with 146 additions and 28 deletions
@@ -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)
@@ -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 != "<system-reminder>\nString mid-conversation rule\n</system-reminder>" {
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 != "<system-reminder>\nArray mid-conversation rule\n</system-reminder>" {
t.Fatalf("Unexpected array message-level system content text: %q", got)
}
@@ -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 {
@@ -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 != "<system-reminder>\nFollow the project instructions\n</system-reminder>" {
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 != "<system-reminder>\nUse the current repo\n</system-reminder>" {
t.Fatalf("unexpected second reminder text: %q", got)
}
}
func TestConvertClaudeRequestToCodex_ParallelToolCalls(t *testing.T) {
tests := []struct {
name string
@@ -0,0 +1,56 @@
package common
import (
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
)
const (
claudeSystemReminderStart = "<system-reminder>"
claudeSystemReminderEnd = "</system-reminder>"
)
// 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
}
@@ -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() {
@@ -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 != "<system-reminder>\nString mid-conversation rule\n</system-reminder>" {
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 != "<system-reminder>\nArray mid-conversation rule\n</system-reminder>" {
t.Fatalf("Unexpected array message-level system content text: %q", got)
}
@@ -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() {
@@ -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 != "<system-reminder>\nString mid-conversation rule\n</system-reminder>" {
t.Fatalf("unexpected string reminder text: %q", got)
}
if got := messages[4].Get("content.0.text").String(); got != "<system-reminder>\nArray mid-conversation rule\n</system-reminder>" {
t.Fatalf("unexpected array reminder text: %q", got)
}
}