diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index e7661aad..5be1f733 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -761,7 +761,9 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock( system := gjson.GetBytes(upstream.lastBody, "system") require.True(t, system.Exists()) - require.Equal(t, claudeCodeSystemPrompt, system.String()) + require.True(t, system.IsArray(), "system should be an array") + require.Equal(t, claudeCodeSystemPrompt, system.Array()[0].Get("text").String()) + require.Equal(t, "ephemeral", system.Array()[0].Get("cache_control.type").String()) // 原始 system prompt 应迁移至 messages 中 messages := gjson.GetBytes(upstream.lastBody, "messages") diff --git a/backend/internal/service/gateway_prompt_test.go b/backend/internal/service/gateway_prompt_test.go index d0f5a8c0..e27e18aa 100644 --- a/backend/internal/service/gateway_prompt_test.go +++ b/backend/internal/service/gateway_prompt_test.go @@ -284,7 +284,7 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) { name string body string system any - wantSystemStr string // system 应为纯字符串 + wantSystemText string // system array 第一个 block 的 text wantMessagesLen int // messages 数组长度 wantFirstMsgRole string // 第一条消息的 role wantFirstMsgText string // 第一条消息的 content[0].text @@ -294,21 +294,21 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) { name: "nil system - no messages injected", body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`, system: nil, - wantSystemStr: claudeCodeSystemPrompt, + wantSystemText: claudeCodeSystemPrompt, wantMessagesLen: 1, // 原始 1 条消息,不注入 }, { name: "empty string system - no messages injected", body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`, system: "", - wantSystemStr: claudeCodeSystemPrompt, + wantSystemText: 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, + wantSystemText: claudeCodeSystemPrompt, wantMessagesLen: 3, // instruction + ack + original wantFirstMsgRole: "user", wantFirstMsgText: "[System Instructions]\nYou are a personal assistant running inside OpenClaw.", @@ -318,7 +318,7 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) { name: "system equals Claude Code prompt - no messages injected", body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`, system: claudeCodeSystemPrompt, - wantSystemStr: claudeCodeSystemPrompt, + wantSystemText: claudeCodeSystemPrompt, wantMessagesLen: 1, }, { @@ -328,7 +328,7 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) { map[string]any{"type": "text", "text": "First instruction"}, map[string]any{"type": "text", "text": "Second instruction"}, }, - wantSystemStr: claudeCodeSystemPrompt, + wantSystemText: claudeCodeSystemPrompt, wantMessagesLen: 3, wantFirstMsgRole: "user", wantFirstMsgText: "[System Instructions]\nFirst instruction\n\nSecond instruction", @@ -338,14 +338,14 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) { name: "empty array system - no messages injected", body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`, system: []any{}, - wantSystemStr: claudeCodeSystemPrompt, + wantSystemText: 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, + wantSystemText: claudeCodeSystemPrompt, wantMessagesLen: 3, wantFirstMsgRole: "user", wantFirstMsgText: "[System Instructions]\nCustom prompt", @@ -355,14 +355,14 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) { name: "json.RawMessage nil system", body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`, system: json.RawMessage(nil), - wantSystemStr: claudeCodeSystemPrompt, + wantSystemText: 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, + wantSystemText: claudeCodeSystemPrompt, wantMessagesLen: 5, // 2 injected + 3 original wantFirstMsgRole: "user", wantFirstMsgText: "[System Instructions]\nBe helpful", @@ -378,10 +378,17 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) { 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) + // system 应为 array 格式: [{type: "text", text: "...", cache_control: {type: "ephemeral"}}] + systemArr, ok := parsed["system"].([]any) + require.True(t, ok, "system should be an array, got %T", parsed["system"]) + require.Len(t, systemArr, 1, "system array should have exactly 1 block") + systemBlock, ok := systemArr[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "text", systemBlock["type"]) + require.Equal(t, tt.wantSystemText, systemBlock["text"]) + cc, ok := systemBlock["cache_control"].(map[string]any) + require.True(t, ok, "system block should have cache_control") + require.Equal(t, "ephemeral", cc["type"]) // 检查 messages messages, ok := parsed["messages"].([]any) diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 4ed78e93..fbbebc21 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -3739,8 +3739,17 @@ func rewriteSystemForNonClaudeCode(body []byte, system any) []byte { originalSystemText = strings.Join(parts, "\n\n") } - // 2. 将 system 替换为 Claude Code 标准提示词(纯字符串,通过 Anthropic 检测) - out, ok := setJSONValueBytes(body, "system", claudeCodeSystemPrompt) + // 2. 将 system 替换为 Claude Code 标准提示词(array 格式,与真实 Claude Code 一致) + // 真实 Claude Code 始终以 [{type: "text", text: "...", cache_control: {type: "ephemeral"}}] 发送 system。 + // 使用 string 格式会被 Anthropic 检测为第三方应用。 + claudeCodeSystemBlock := []map[string]any{ + { + "type": "text", + "text": claudeCodeSystemPrompt, + "cache_control": map[string]string{"type": "ephemeral"}, + }, + } + out, ok := setJSONValueBytes(body, "system", claudeCodeSystemBlock) if !ok { logger.LegacyPrintf("service.gateway", "Warning: failed to set Claude Code system prompt") return body @@ -3978,12 +3987,17 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A if shouldMimicClaudeCode { // 非 Claude Code 客户端:将 system 替换为 Claude Code 标识,原始 system 迁移至 messages // 条件:1) OAuth/SetupToken 账号 2) 不是 Claude Code 客户端 3) 不是 Haiku 模型 4) system 中还没有 Claude Code 提示词 + systemRewritten := false if !strings.Contains(strings.ToLower(reqModel), "haiku") && !systemIncludesClaudeCodePrompt(parsed.System) { body = rewriteSystemForNonClaudeCode(body, parsed.System) + systemRewritten = true } - normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true} + // system 被重写时保留 CC prompt 的 cache_control: ephemeral(匹配真实 Claude Code 行为); + // 未重写时(haiku / 已含 CC 前缀)剥离客户端 cache_control,与原有行为一致。 + // 两种情况下 enforceCacheControlLimit 都会兜底处理上限。 + normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten} if s.identityService != nil { fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header) if err == nil && fp != nil { @@ -5605,7 +5619,6 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex // Build effective drop set: merge static defaults with dynamic beta policy filter rules policyFilterSet := s.getBetaPolicyFilterSet(ctx, c, account, modelID) effectiveDropSet := mergeDropSets(policyFilterSet) - effectiveDropWithClaudeCodeSet := mergeDropSets(policyFilterSet, claude.BetaClaudeCode) // 处理 anthropic-beta header(OAuth 账号需要包含 oauth beta) if tokenType == "oauth" { @@ -5616,11 +5629,16 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex applyClaudeCodeMimicHeaders(req, reqStream) incomingBeta := getHeaderRaw(req.Header, "anthropic-beta") - // Match real Claude CLI traffic (per mitmproxy reports): - // messages requests typically use only oauth + interleaved-thinking. - // Also drop claude-code beta if a downstream client added it. + // Claude Code OAuth credentials are scoped to Claude Code. + // Non-haiku models MUST include claude-code beta for Anthropic to recognize + // this as a legitimate Claude Code request; without it, the request is + // rejected as third-party ("out of extra usage"). + // Haiku models are exempt from third-party detection and don't need it. requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking} - setHeaderRaw(req.Header, "anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, effectiveDropWithClaudeCodeSet)) + if !strings.Contains(strings.ToLower(modelID), "haiku") { + requiredBetas = []string{claude.BetaClaudeCode, claude.BetaOAuth, claude.BetaInterleavedThinking} + } + setHeaderRaw(req.Header, "anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, effectiveDropSet)) } else { // Claude Code 客户端:尽量透传原始 header,仅补齐 oauth beta clientBetaHeader := getHeaderRaw(req.Header, "anthropic-beta")