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客户端请求完全不受影响(原样透传)。
This commit is contained in:
@@ -761,7 +761,14 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock(
|
||||
|
||||
system := gjson.GetBytes(upstream.lastBody, "system")
|
||||
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) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
path string
|
||||
log string
|
||||
@@ -3905,11 +3976,11 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
|
||||
|
||||
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 提示词
|
||||
if !strings.Contains(strings.ToLower(reqModel), "haiku") &&
|
||||
!systemIncludesClaudeCodePrompt(parsed.System) {
|
||||
body = injectClaudeCodePrompt(body, parsed.System)
|
||||
body = rewriteSystemForNonClaudeCode(body, parsed.System)
|
||||
}
|
||||
|
||||
normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true}
|
||||
|
||||
Reference in New Issue
Block a user