Commit f568ec76 authored by shaw's avatar shaw Committed by 陈曦
Browse files

fix: 非Claude Code客户端system prompt迁移至messages以绕过第三方应用检测

Anthropic近期引入基于system参数内容的第三方应用检测机制,原有的前置追加
Claude Code提示词策略无法通过检测(后续内容仍为非Claude Code格式触发429)。

新策略:对非Claude Code客户端的OAuth/SetupToken账号请求,将system字段
完整替换为Claude Code标识提示词,原始system内容作为user/assistant消息对
注入messages开头,模型仍接收完整指令。

仅影响/v1/messages路径,chat_completions和responses路径保持原有逻辑不变。
真正的Claude Code客户端请求完全不受影响(原样透传)。
parent cec5a3bf
...@@ -761,7 +761,14 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock( ...@@ -761,7 +761,14 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock(
system := gjson.GetBytes(upstream.lastBody, "system") system := gjson.GetBytes(upstream.lastBody, "system")
require.True(t, system.Exists()) require.True(t, system.Exists())
require.Contains(t, system.Raw, "x-anthropic-billing-header keep") require.Equal(t, claudeCodeSystemPrompt, system.String())
// 原始 system prompt 应迁移至 messages 中
messages := gjson.GetBytes(upstream.lastBody, "messages")
require.True(t, messages.IsArray())
firstMsg := messages.Array()[0]
require.Equal(t, "user", firstMsg.Get("role").String())
require.Contains(t, firstMsg.Get("content.0.text").String(), "x-anthropic-billing-header keep")
}) })
} }
} }
......
...@@ -278,3 +278,141 @@ func TestInjectClaudeCodePrompt(t *testing.T) { ...@@ -278,3 +278,141 @@ func TestInjectClaudeCodePrompt(t *testing.T) {
}) })
} }
} }
func TestRewriteSystemForNonClaudeCode(t *testing.T) {
tests := []struct {
name string
body string
system any
wantSystemStr string // system 应为纯字符串
wantMessagesLen int // messages 数组长度
wantFirstMsgRole string // 第一条消息的 role
wantFirstMsgText string // 第一条消息的 content[0].text
wantAckMsgText string // 第二条消息的 content[0].text
}{
{
name: "nil system - no messages injected",
body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
system: nil,
wantSystemStr: claudeCodeSystemPrompt,
wantMessagesLen: 1, // 原始 1 条消息,不注入
},
{
name: "empty string system - no messages injected",
body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
system: "",
wantSystemStr: claudeCodeSystemPrompt,
wantMessagesLen: 1,
},
{
name: "custom string system - migrated to messages",
body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
system: "You are a personal assistant running inside OpenClaw.",
wantSystemStr: claudeCodeSystemPrompt,
wantMessagesLen: 3, // instruction + ack + original
wantFirstMsgRole: "user",
wantFirstMsgText: "[System Instructions]\nYou are a personal assistant running inside OpenClaw.",
wantAckMsgText: "Understood. I will follow these instructions.",
},
{
name: "system equals Claude Code prompt - no messages injected",
body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
system: claudeCodeSystemPrompt,
wantSystemStr: claudeCodeSystemPrompt,
wantMessagesLen: 1,
},
{
name: "array system with custom blocks - text joined and migrated",
body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
system: []any{
map[string]any{"type": "text", "text": "First instruction"},
map[string]any{"type": "text", "text": "Second instruction"},
},
wantSystemStr: claudeCodeSystemPrompt,
wantMessagesLen: 3,
wantFirstMsgRole: "user",
wantFirstMsgText: "[System Instructions]\nFirst instruction\n\nSecond instruction",
wantAckMsgText: "Understood. I will follow these instructions.",
},
{
name: "empty array system - no messages injected",
body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
system: []any{},
wantSystemStr: claudeCodeSystemPrompt,
wantMessagesLen: 1,
},
{
name: "json.RawMessage string system",
body: `{"model":"claude-3","system":"Custom prompt","messages":[{"role":"user","content":"hello"}]}`,
system: json.RawMessage(`"Custom prompt"`),
wantSystemStr: claudeCodeSystemPrompt,
wantMessagesLen: 3,
wantFirstMsgRole: "user",
wantFirstMsgText: "[System Instructions]\nCustom prompt",
wantAckMsgText: "Understood. I will follow these instructions.",
},
{
name: "json.RawMessage nil system",
body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
system: json.RawMessage(nil),
wantSystemStr: claudeCodeSystemPrompt,
wantMessagesLen: 1,
},
{
name: "multiple original messages preserved",
body: `{"model":"claude-3","messages":[{"role":"user","content":"msg1"},{"role":"assistant","content":"resp1"},{"role":"user","content":"msg2"}]}`,
system: "Be helpful",
wantSystemStr: claudeCodeSystemPrompt,
wantMessagesLen: 5, // 2 injected + 3 original
wantFirstMsgRole: "user",
wantFirstMsgText: "[System Instructions]\nBe helpful",
wantAckMsgText: "Understood. I will follow these instructions.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := rewriteSystemForNonClaudeCode([]byte(tt.body), tt.system)
var parsed map[string]any
err := json.Unmarshal(result, &parsed)
require.NoError(t, err)
// system 应为纯字符串
systemVal, ok := parsed["system"].(string)
require.True(t, ok, "system should be a string, got %T", parsed["system"])
require.Equal(t, tt.wantSystemStr, systemVal)
// 检查 messages
messages, ok := parsed["messages"].([]any)
require.True(t, ok, "messages should be an array")
require.Len(t, messages, tt.wantMessagesLen)
if tt.wantFirstMsgRole != "" && len(messages) >= 2 {
// 检查注入的 instruction 消息
firstMsg, ok := messages[0].(map[string]any)
require.True(t, ok)
require.Equal(t, tt.wantFirstMsgRole, firstMsg["role"])
firstContent, ok := firstMsg["content"].([]any)
require.True(t, ok)
require.Len(t, firstContent, 1)
firstBlock, ok := firstContent[0].(map[string]any)
require.True(t, ok)
require.Equal(t, tt.wantFirstMsgText, firstBlock["text"])
// 检查注入的 ack 消息
ackMsg, ok := messages[1].(map[string]any)
require.True(t, ok)
require.Equal(t, "assistant", ackMsg["role"])
ackContent, ok := ackMsg["content"].([]any)
require.True(t, ok)
require.Len(t, ackContent, 1)
ackBlock, ok := ackContent[0].(map[string]any)
require.True(t, ok)
require.Equal(t, tt.wantAckMsgText, ackBlock["text"])
}
})
}
}
...@@ -3714,6 +3714,77 @@ func injectClaudeCodePrompt(body []byte, system any) []byte { ...@@ -3714,6 +3714,77 @@ func injectClaudeCodePrompt(body []byte, system any) []byte {
return result return result
} }
// rewriteSystemForNonClaudeCode 将非 Claude Code 客户端的 system prompt 迁移至 messages,
// system 字段仅保留 Claude Code 标识提示词。
// Anthropic 基于 system 参数内容检测第三方应用,仅前置追加 Claude Code 提示词
// 无法通过检测,因为后续内容仍为非 Claude Code 格式。
// 策略:将原始 system prompt 提取并注入为 user/assistant 消息对,system 仅保留 Claude Code 标识。
func rewriteSystemForNonClaudeCode(body []byte, system any) []byte {
system = normalizeSystemParam(system)
// 1. 提取原始 system prompt 文本
var originalSystemText string
switch v := system.(type) {
case string:
originalSystemText = strings.TrimSpace(v)
case []any:
var parts []string
for _, item := range v {
if m, ok := item.(map[string]any); ok {
if text, ok := m["text"].(string); ok && strings.TrimSpace(text) != "" {
parts = append(parts, text)
}
}
}
originalSystemText = strings.Join(parts, "\n\n")
}
// 2. 将 system 替换为 Claude Code 标准提示词(纯字符串,通过 Anthropic 检测)
out, ok := setJSONValueBytes(body, "system", claudeCodeSystemPrompt)
if !ok {
logger.LegacyPrintf("service.gateway", "Warning: failed to set Claude Code system prompt")
return body
}
// 3. 将原始 system prompt 作为 user/assistant 消息对注入到 messages 开头
// 模型仍通过 messages 接收完整指令,保留客户端功能
ccPromptTrimmed := strings.TrimSpace(claudeCodeSystemPrompt)
if originalSystemText != "" && originalSystemText != ccPromptTrimmed && !hasClaudeCodePrefix(originalSystemText) {
instrMsg, err1 := json.Marshal(map[string]any{
"role": "user",
"content": []map[string]any{
{"type": "text", "text": "[System Instructions]\n" + originalSystemText},
},
})
ackMsg, err2 := json.Marshal(map[string]any{
"role": "assistant",
"content": []map[string]any{
{"type": "text", "text": "Understood. I will follow these instructions."},
},
})
if err1 != nil || err2 != nil {
logger.LegacyPrintf("service.gateway", "Warning: failed to marshal system-to-messages injection")
return out
}
// 重建 messages 数组:[instruction, ack, ...originalMessages]
items := [][]byte{instrMsg, ackMsg}
messagesResult := gjson.GetBytes(out, "messages")
if messagesResult.IsArray() {
messagesResult.ForEach(func(_, msg gjson.Result) bool {
items = append(items, []byte(msg.Raw))
return true
})
}
if next, setOk := setJSONRawBytes(out, "messages", buildJSONArrayRaw(items)); setOk {
out = next
}
}
return out
}
type cacheControlPath struct { type cacheControlPath struct {
path string path string
log string log string
...@@ -3905,11 +3976,11 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A ...@@ -3905,11 +3976,11 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
if shouldMimicClaudeCode { if shouldMimicClaudeCode {
// 智能注入 Claude Code 系统提示词(仅 OAuth/SetupToken 账号需要) // Claude Code 客户端:将 system 替换为 Claude Code 标识,原始 system 迁移至 messages
// 条件:1) OAuth/SetupToken 账号 2) 不是 Claude Code 客户端 3) 不是 Haiku 模型 4) system 中还没有 Claude Code 提示词 // 条件:1) OAuth/SetupToken 账号 2) 不是 Claude Code 客户端 3) 不是 Haiku 模型 4) system 中还没有 Claude Code 提示词
if !strings.Contains(strings.ToLower(reqModel), "haiku") && if !strings.Contains(strings.ToLower(reqModel), "haiku") &&
!systemIncludesClaudeCodePrompt(parsed.System) { !systemIncludesClaudeCodePrompt(parsed.System) {
body = injectClaudeCodePrompt(body, parsed.System) body = rewriteSystemForNonClaudeCode(body, parsed.System)
} }
normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true} normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true}
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment