diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 6e19db32..e7661aad 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -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") }) } } diff --git a/backend/internal/service/gateway_prompt_test.go b/backend/internal/service/gateway_prompt_test.go index 356536b0..d0f5a8c0 100644 --- a/backend/internal/service/gateway_prompt_test.go +++ b/backend/internal/service/gateway_prompt_test.go @@ -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"]) + } + }) + } +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 16bfcd8e..f410d69b 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -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}