mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-10 18:36:47 +08:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user