diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index e0d1a53e..f8c6b75f 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -12,17 +12,23 @@ import "encoding/json" // AnthropicRequest is the request body for POST /v1/messages. type AnthropicRequest struct { - Model string `json:"model"` - MaxTokens int `json:"max_tokens"` - System json.RawMessage `json:"system,omitempty"` // string or []AnthropicContentBlock - Messages []AnthropicMessage `json:"messages"` - Tools []AnthropicTool `json:"tools,omitempty"` - Stream bool `json:"stream,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - TopP *float64 `json:"top_p,omitempty"` - StopSeqs []string `json:"stop_sequences,omitempty"` - Thinking *AnthropicThinking `json:"thinking,omitempty"` - ToolChoice json.RawMessage `json:"tool_choice,omitempty"` + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + System json.RawMessage `json:"system,omitempty"` // string or []AnthropicContentBlock + Messages []AnthropicMessage `json:"messages"` + Tools []AnthropicTool `json:"tools,omitempty"` + Stream bool `json:"stream,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + StopSeqs []string `json:"stop_sequences,omitempty"` + Thinking *AnthropicThinking `json:"thinking,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` + // Metadata 会被原样透传给上游。OAuth/Claude-Code 路径依赖 metadata.user_id + // 参与上游的"是否为官方 Claude Code 请求"判定;如果经由本结构体重新序列化 + // 时丢弃该字段,网关侧后续的 metadata 重写(ensureClaudeOAuthMetadataUserID/ + // RewriteUserIDWithMasking) 在 body 里拿不到起点,就无法重建一个合法的 + // user_id,进而导致请求被归类为第三方 app。 + Metadata json.RawMessage `json:"metadata,omitempty"` OutputConfig *AnthropicOutputConfig `json:"output_config,omitempty"` } @@ -76,10 +82,18 @@ type AnthropicImageSource struct { // AnthropicTool describes a tool available to the model. type AnthropicTool struct { - Type string `json:"type,omitempty"` // e.g. "web_search_20250305" for server tools - Name string `json:"name"` - Description string `json:"description,omitempty"` - InputSchema json.RawMessage `json:"input_schema"` // JSON Schema object + Type string `json:"type,omitempty"` // e.g. "web_search_20250305" for server tools + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema json.RawMessage `json:"input_schema"` // JSON Schema object + CacheControl *AnthropicCacheControl `json:"cache_control,omitempty"` +} + +// AnthropicCacheControl 对应 Anthropic API 的 cache_control 字段。 +// ttl 默认由调用方决定;本项目策略见 claude.DefaultCacheControlTTL。 +type AnthropicCacheControl struct { + Type string `json:"type"` // "ephemeral" + TTL string `json:"ttl,omitempty"` // "5m" / "1h" / 省略=默认 5m(由 Anthropic 判定) } // AnthropicResponse is the non-streaming response from POST /v1/messages. diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index 21c723d2..aa59ba64 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -4,6 +4,12 @@ package claude // Claude Code 客户端相关常量 // Beta header 常量 +// +// 这里的常量对齐真实 Claude Code CLI 的最新流量(截至 2026-04)。 +// 选型参考:与 Parrot (src/transform/cc_mimicry.py) 的 BETAS 保持一致, +// 原因:Anthropic 上游会基于 anthropic-beta 的完整集合判定请求来源; +// 缺少任何"官方 Claude Code 请求才会带"的 beta,都会被降级到第三方额度, +// 对应报错:`Third-party apps now draw from your extra usage, not your plan limits.` const ( BetaOAuth = "oauth-2025-04-20" BetaClaudeCode = "claude-code-20250219" @@ -12,6 +18,13 @@ const ( BetaTokenCounting = "token-counting-2024-11-01" BetaContext1M = "context-1m-2025-08-07" BetaFastMode = "fast-mode-2026-02-01" + + // 新增(对齐官方 CLI 2.1.9x 以来的流量) + BetaPromptCachingScope = "prompt-caching-scope-2026-01-05" + BetaEffort = "effort-2025-11-24" + BetaRedactThinking = "redact-thinking-2026-02-12" + BetaContextManagement = "context-management-2025-06-27" + BetaExtendedCacheTTL = "extended-cache-ttl-2025-04-11" ) // DroppedBetas 是转发时需要从 anthropic-beta header 中移除的 beta token 列表。 @@ -44,11 +57,43 @@ const APIKeyBetaHeader = BetaClaudeCode + "," + BetaInterleavedThinking + "," + // APIKeyHaikuBetaHeader Haiku 模型在 API-key 账号下使用的 anthropic-beta header(不包含 oauth / claude-code) const APIKeyHaikuBetaHeader = BetaInterleavedThinking +// DefaultCacheControlTTL 是网关代理为自己生成的 cache_control 块默认使用的 ttl。 +// 真实 Claude Code CLI 当前使用 "1h",但本仓策略是"客户端透传 ttl 优先; +// 客户端缺省时统一使用 5m",这样既不浪费 1h 缓存额度,也保留客户端自定义能力。 +const DefaultCacheControlTTL = "5m" + +// CLICurrentVersion 是 sub2api 当前对外伪装的 Claude Code CLI 版本号(三段 semver)。 +// 用于 billing attribution block 中的 cc_version=X.Y.Z.{fp} 前缀以及 fingerprint 计算。 +// 必须与 DefaultHeaders["User-Agent"] 中的版本号严格一致;不一致会被 Anthropic 判第三方。 +const CLICurrentVersion = "2.1.92" + +// FullClaudeCodeMimicryBetas 返回最"像"真实 Claude Code CLI 的完整 beta 列表, +// 用于 OAuth 账号伪装成 Claude Code 时使用。 +// 顺序与真实 CLI 抓包一致。 +// +// 使用建议: +// - OAuth 账号 + 非 haiku:追加这整份列表,再按需保留 client 带来的 beta。 +// - OAuth 账号 + haiku:Anthropic 对 haiku 不做 third-party 判定,使用 HaikuBetaHeader 即可。 +// - API-key 账号:不要使用本函数,参见 APIKeyBetaHeader。 +func FullClaudeCodeMimicryBetas() []string { + return []string{ + BetaClaudeCode, + BetaOAuth, + BetaInterleavedThinking, + BetaPromptCachingScope, + BetaEffort, + BetaRedactThinking, + BetaContextManagement, + BetaExtendedCacheTTL, + } +} + // DefaultHeaders 是 Claude Code 客户端默认请求头。 var DefaultHeaders = map[string]string{ // Keep these in sync with recent Claude CLI traffic to reduce the chance // that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage. - "User-Agent": "claude-cli/2.1.22 (external, cli)", + // 版本参考:对齐 Parrot (src/transform/cc_mimicry.py:49) 的 CLI_USER_AGENT。 + "User-Agent": "claude-cli/2.1.92 (external, cli)", "X-Stainless-Lang": "js", "X-Stainless-Package-Version": "0.70.0", "X-Stainless-OS": "Linux", diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 5be1f733..428231ee 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -762,8 +762,14 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock( system := gjson.GetBytes(upstream.lastBody, "system") require.True(t, system.Exists()) 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()) + arr := system.Array() + require.Len(t, arr, 2, "system array should have billing block + cc prompt block") + + require.Contains(t, arr[0].Get("text").String(), "x-anthropic-billing-header:") + require.Contains(t, arr[0].Get("text").String(), "cc_version=") + + require.Equal(t, claudeCodeSystemPrompt, arr[1].Get("text").String()) + require.Equal(t, "ephemeral", arr[1].Get("cache_control.type").String()) // 原始 system prompt 应迁移至 messages 中 messages := gjson.GetBytes(upstream.lastBody, "messages") diff --git a/backend/internal/service/gateway_billing_block.go b/backend/internal/service/gateway_billing_block.go new file mode 100644 index 00000000..45c307fd --- /dev/null +++ b/backend/internal/service/gateway_billing_block.go @@ -0,0 +1,98 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + + "github.com/tidwall/gjson" +) + +// fingerprintSalt 是计算 cc_version 后缀指纹的盐值。 +// +// 来源:与 Parrot src/transform/cc_mimicry.py 的 FINGERPRINT_SALT 完全一致; +// 这是真实 Claude Code CLI 抓包推导出的常量,改动会导致 fp 与 CLI 不一致, +// 进一步触发 Anthropic 的第三方检测。 +const fingerprintSalt = "59cf53e54c78" + +// computeClaudeCodeFingerprint 复刻真实 Claude Code CLI 的 cc_version 指纹算法: +// +// 1. 取 messages 中第一条 role=user 的纯文本(首块 text) +// 2. 取该文本的第 4、7、20 字符(不足以 '0' 补齐) +// 3. SHA256(SALT + chars + cc_version) 取 hex 前 3 字符 +// +// 算法来自 Parrot src/transform/cc_mimicry.py:compute_fingerprint,与官方 CLI 字节对齐。 +// 任何偏差都会导致 cc_version=X.Y.Z.{fp} 在上游侧与真实 CLI 不一致。 +func computeClaudeCodeFingerprint(body []byte, version string) string { + firstText := extractFirstUserText(body) + indices := []int{4, 7, 20} + chars := make([]byte, 0, 3) + for _, i := range indices { + if i < len(firstText) { + chars = append(chars, firstText[i]) + } else { + chars = append(chars, '0') + } + } + sum := sha256.Sum256([]byte(fingerprintSalt + string(chars) + version)) + return hex.EncodeToString(sum[:])[:3] +} + +// extractFirstUserText 提取 messages 中第一条 user 消息的首段 text 内容。 +// 兼容 string 和 []block 两种 content 格式。 +func extractFirstUserText(body []byte) string { + messages := gjson.GetBytes(body, "messages") + if !messages.IsArray() { + return "" + } + first := "" + messages.ForEach(func(_, msg gjson.Result) bool { + if msg.Get("role").String() != "user" { + return true + } + content := msg.Get("content") + if content.Type == gjson.String { + first = content.String() + return false + } + if content.IsArray() { + content.ForEach(func(_, block gjson.Result) bool { + if block.Get("type").String() == "text" { + first = block.Get("text").String() + return false + } + return true + }) + return false + } + return false + }) + return first +} + +// buildBillingAttributionBlockJSON 构造 system 数组的 billing attribution block。 +// +// 形态严格对齐真实 Claude Code CLI: +// +// {"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.92.{fp}; cc_entrypoint=cli; cch=00000;"} +// +// cch=00000 是签名占位符,由 signBillingHeaderCCH 在 buildUpstreamRequest 阶段 +// 替换为基于完整 body 的 xxhash64 5 位十六进制摘要。 +// +// 此 block 不带 cache_control(与真实 CLI 一致;cache breakpoint 由后续的 +// Claude Code prompt block 承担)。 +func buildBillingAttributionBlockJSON(body []byte, cliVersion string) ([]byte, error) { + if cliVersion == "" { + return nil, fmt.Errorf("cliVersion required") + } + fp := computeClaudeCodeFingerprint(body, cliVersion) + text := fmt.Sprintf( + "x-anthropic-billing-header: cc_version=%s.%s; cc_entrypoint=cli; cch=00000;", + cliVersion, fp, + ) + return json.Marshal(map[string]string{ + "type": "text", + "text": text, + }) +} diff --git a/backend/internal/service/gateway_body_order_test.go b/backend/internal/service/gateway_body_order_test.go index 641522f0..e6c9de7d 100644 --- a/backend/internal/service/gateway_body_order_test.go +++ b/backend/internal/service/gateway_body_order_test.go @@ -41,12 +41,13 @@ func TestNormalizeClaudeOAuthRequestBody_PreservesTopLevelFieldOrder(t *testing. resultStr := string(result) require.Equal(t, claude.NormalizeModelID("claude-3-5-sonnet-latest"), modelID) - assertJSONTokenOrder(t, resultStr, `"alpha"`, `"model"`, `"system"`, `"messages"`, `"omega"`, `"tools"`, `"metadata"`) - require.NotContains(t, resultStr, `"temperature"`) + assertJSONTokenOrder(t, resultStr, `"alpha"`, `"model"`, `"temperature"`, `"system"`, `"messages"`, `"omega"`, `"tools"`, `"metadata"`, `"max_tokens"`) + require.Contains(t, resultStr, `"temperature":0.2`) require.NotContains(t, resultStr, `"tool_choice"`) require.Contains(t, resultStr, `"system":"`+claudeCodeSystemPrompt+`"`) require.Contains(t, resultStr, `"tools":[]`) require.Contains(t, resultStr, `"metadata":{"user_id":"user-1"}`) + require.Contains(t, resultStr, `"max_tokens":128000`) } func TestInjectClaudeCodePrompt_PreservesFieldOrder(t *testing.T) { diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index 37b38f76..c531667e 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -85,15 +85,16 @@ func (s *GatewayService) ForwardAsChatCompletions( return nil, fmt.Errorf("marshal anthropic request: %w", err) } - // 6. Apply Claude Code mimicry for OAuth accounts - isClaudeCode := false // CC API is never Claude Code + // 6. Apply Claude Code mimicry for OAuth accounts. + // Chat Completions 协议进来的请求永远不是 Claude Code 客户端,所以对 OAuth 账号 + // 必须完整执行 /v1/messages 主路径上的伪装链路(system 重写 + normalize + metadata 注入), + // 否则会被 Anthropic 判为第三方应用并扣 extra usage。 + // 见 applyClaudeCodeOAuthMimicryToBody 的 godoc。 + isClaudeCode := false shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode if shouldMimicClaudeCode { - if !strings.Contains(strings.ToLower(mappedModel), "haiku") && - !systemIncludesClaudeCodePrompt(anthropicReq.System) { - anthropicBody = injectClaudeCodePrompt(anthropicBody, anthropicReq.System) - } + anthropicBody = s.applyClaudeCodeOAuthMimicryToBody(ctx, c, account, anthropicBody, anthropicReq.System, mappedModel) } // 7. Enforce cache_control block limit @@ -312,7 +313,14 @@ func (s *GatewayService) handleCCBufferedFromAnthropic( if s.responseHeaderFilter != nil { responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) } - c.JSON(http.StatusOK, ccResp) + // Marshal then bytes-replace so tool name mapping is reversed at byte level + // (parity with Parrot non-stream flow that marshals → restore → emit). + if respBytes, err := json.Marshal(ccResp); err == nil { + respBytes = reverseToolNamesIfPresent(c, respBytes) + c.Data(http.StatusOK, "application/json; charset=utf-8", respBytes) + } else { + c.JSON(http.StatusOK, ccResp) + } return &ForwardResult{ RequestID: requestID, @@ -383,7 +391,10 @@ func (s *GatewayService) handleCCStreamingFromAnthropic( if err != nil { return false } - if _, err := fmt.Fprint(c.Writer, sse); err != nil { + // Reverse tool name mapping: fake → real, per-chunk bytes.Replace. + // c 可能持有请求侧注入的 ToolNameRewrite;无则仅做静态前缀还原。 + out := string(reverseToolNamesIfPresent(c, []byte(sse))) + if _, err := fmt.Fprint(c.Writer, out); err != nil { return true // client disconnected } return false diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 2c917112..647193d6 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -82,15 +82,16 @@ func (s *GatewayService) ForwardAsResponses( return nil, fmt.Errorf("marshal anthropic request: %w", err) } - // 6. Apply Claude Code mimicry for OAuth accounts (non-Claude-Code endpoints) - isClaudeCode := false // Responses API is never Claude Code + // 6. Apply Claude Code mimicry for OAuth accounts (non-Claude-Code endpoints). + // OpenAI Responses 协议进来的请求永远不是 Claude Code 客户端,所以对 OAuth 账号 + // 必须完整执行 /v1/messages 主路径上的伪装链路(system 重写 + normalize + metadata 注入), + // 否则会被 Anthropic 判为第三方应用并扣 extra usage。 + // 见 applyClaudeCodeOAuthMimicryToBody 的 godoc。 + isClaudeCode := false shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode if shouldMimicClaudeCode { - if !strings.Contains(strings.ToLower(mappedModel), "haiku") && - !systemIncludesClaudeCodePrompt(anthropicReq.System) { - anthropicBody = injectClaudeCodePrompt(anthropicBody, anthropicReq.System) - } + anthropicBody = s.applyClaudeCodeOAuthMimicryToBody(ctx, c, account, anthropicBody, anthropicReq.System, mappedModel) } // 7. Enforce cache_control block limit @@ -331,7 +332,12 @@ func (s *GatewayService) handleResponsesBufferedStreamingResponse( if s.responseHeaderFilter != nil { responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) } - c.JSON(http.StatusOK, responsesResp) + if respBytes, err := json.Marshal(responsesResp); err == nil { + respBytes = reverseToolNamesIfPresent(c, respBytes) + c.Data(http.StatusOK, "application/json; charset=utf-8", respBytes) + } else { + c.JSON(http.StatusOK, responsesResp) + } return &ForwardResult{ RequestID: requestID, @@ -419,7 +425,8 @@ func (s *GatewayService) handleResponsesStreamingResponse( ) continue } - if _, err := fmt.Fprint(c.Writer, sse); err != nil { + out := string(reverseToolNamesIfPresent(c, []byte(sse))) + if _, err := fmt.Fprint(c.Writer, out); err != nil { logger.L().Info("forward_as_responses stream: client disconnected", zap.String("request_id", requestID), ) @@ -439,7 +446,8 @@ func (s *GatewayService) handleResponsesStreamingResponse( if err != nil { continue } - fmt.Fprint(c.Writer, sse) //nolint:errcheck + out := string(reverseToolNamesIfPresent(c, []byte(sse))) + fmt.Fprint(c.Writer, out) //nolint:errcheck } c.Writer.Flush() } diff --git a/backend/internal/service/gateway_messages_cache.go b/backend/internal/service/gateway_messages_cache.go new file mode 100644 index 00000000..cb5384ba --- /dev/null +++ b/backend/internal/service/gateway_messages_cache.go @@ -0,0 +1,141 @@ +package service + +import ( + "fmt" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// stripMessageCacheControl 移除 $.messages[*].content[*].cache_control。 +// 与 Parrot _strip_message_cache_control 语义一致。 +// +// 为什么必须整体清空:客户端(特别是 Claude Code)经常把 cache_control 打在 +// "当前最后一条 user message" 上;下一轮对话 messages 追加后,原本的最后一条 +// 变成中间某条,cache_control 还挂着就导致"前缀签名变化",破坏缓存命中。 +// 统一由代理重新打断点(addMessageCacheBreakpoints)才能在多轮间稳定。 +func stripMessageCacheControl(body []byte) []byte { + messages := gjson.GetBytes(body, "messages") + if !messages.IsArray() { + return body + } + msgIdx := -1 + messages.ForEach(func(_, msg gjson.Result) bool { + msgIdx++ + content := msg.Get("content") + if !content.IsArray() { + return true + } + blockIdx := -1 + content.ForEach(func(_, block gjson.Result) bool { + blockIdx++ + if !block.Get("cache_control").Exists() { + return true + } + path := fmt.Sprintf("messages.%d.content.%d.cache_control", msgIdx, blockIdx) + if next, err := sjson.DeleteBytes(body, path); err == nil { + body = next + } + return true + }) + return true + }) + return body +} + +// addMessageCacheBreakpoints 在 messages 上注入两个稳定的 cache 断点: +// 1. 最后一条 message +// 2. 当 messages 数量 ≥ 4 时,倒数第二个 role=user 的 message +// +// 与 Parrot add_cache_breakpoints 一致。两个断点 + system prompt block 的断点 +// + tools[-1] 的断点共同构成最多 4 个断点(Anthropic 上限)。 +// +// cache_control ttl 策略: +// - 若目标 block 已有 cache_control.ttl → 不覆盖 +// - 否则写入 {"type":"ephemeral","ttl": claude.DefaultCacheControlTTL} +// +// 调用前应先 stripMessageCacheControl 以保证幂等和稳定。 +func addMessageCacheBreakpoints(body []byte) []byte { + messages := gjson.GetBytes(body, "messages") + if !messages.IsArray() { + return body + } + arr := messages.Array() + if len(arr) == 0 { + return body + } + + body = injectCacheControlOnLastContentBlock(body, len(arr)-1, &arr[len(arr)-1]) + + if len(arr) >= 4 { + userCount := 0 + for i := len(arr) - 1; i >= 0; i-- { + if arr[i].Get("role").String() != "user" { + continue + } + userCount++ + if userCount == 2 { + body = injectCacheControlOnLastContentBlock(body, i, &arr[i]) + break + } + } + } + + return body +} + +// injectCacheControlOnLastContentBlock 把 cache_control 断点打在 messages[idx] +// 的最后一个 content block 上。若 content 是 string,先升级成单块 text 数组 +// (对齐 Parrot _inject_cache_on_msg 的行为)。 +// +// msg 是调用方已持有的 gjson.Result 快照,用于省一次 GetBytes。 +func injectCacheControlOnLastContentBlock(body []byte, idx int, msg *gjson.Result) []byte { + content := msg.Get("content") + + if content.Type == gjson.String { + text := content.String() + blockRaw := fmt.Sprintf( + `[{"type":"text","text":%s,"cache_control":{"type":"ephemeral","ttl":%q}}]`, + mustJSONString(text), claude.DefaultCacheControlTTL, + ) + if next, err := sjson.SetRawBytes(body, fmt.Sprintf("messages.%d.content", idx), []byte(blockRaw)); err == nil { + body = next + } + return body + } + + if !content.IsArray() { + return body + } + contentArr := content.Array() + if len(contentArr) == 0 { + return body + } + lastBlockIdx := len(contentArr) - 1 + lastBlock := contentArr[lastBlockIdx] + + if cc := lastBlock.Get("cache_control"); cc.Exists() && cc.Get("ttl").String() != "" { + return body + } + + pathPrefix := fmt.Sprintf("messages.%d.content.%d.cache_control", idx, lastBlockIdx) + existingCC := lastBlock.Get("cache_control") + if existingCC.Exists() { + if next, err := sjson.SetBytes(body, pathPrefix+".ttl", claude.DefaultCacheControlTTL); err == nil { + body = next + } + return body + } + raw := fmt.Sprintf(`{"type":"ephemeral","ttl":%q}`, claude.DefaultCacheControlTTL) + if next, err := sjson.SetRawBytes(body, pathPrefix, []byte(raw)); err == nil { + body = next + } + return body +} + +// mustJSONString 把一个 Go string 序列化为合法 JSON string(含引号), +// 用于 sjson.SetRawBytes 场景下手工拼 JSON。 +func mustJSONString(s string) string { + return fmt.Sprintf("%q", s) +} diff --git a/backend/internal/service/gateway_prompt_test.go b/backend/internal/service/gateway_prompt_test.go index e27e18aa..443486ab 100644 --- a/backend/internal/service/gateway_prompt_test.go +++ b/backend/internal/service/gateway_prompt_test.go @@ -378,16 +378,27 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) { err := json.Unmarshal(result, &parsed) require.NoError(t, err) - // system 应为 array 格式: [{type: "text", text: "...", cache_control: {type: "ephemeral"}}] + // system 应为 array 格式,对齐真实 Claude Code CLI 的 2-block 形态: + // [0] billing attribution block (x-anthropic-billing-header: cc_version=...;) + // [1] Claude Code prompt block (带 cache_control) 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.Len(t, systemArr, 2, "system array should have exactly 2 blocks (billing + cc prompt)") + + billingBlock, ok := systemArr[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "text", billingBlock["type"]) + require.Contains(t, billingBlock["text"], "x-anthropic-billing-header:") + require.Contains(t, billingBlock["text"], "cc_version=") + require.Contains(t, billingBlock["text"], "cc_entrypoint=cli") + require.Contains(t, billingBlock["text"], "cch=00000") + + systemBlock, ok := systemArr[1].(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.True(t, ok, "cc prompt block should have cache_control") require.Equal(t, "ephemeral", cc["type"]) // 检查 messages diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 5a91d0de..d9fa81ea 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -850,6 +850,7 @@ func (s *GatewayService) hashContent(content string) string { type anthropicCacheControlPayload struct { Type string `json:"type"` + TTL string `json:"ttl,omitempty"` } type anthropicSystemTextBlockPayload struct { @@ -898,7 +899,10 @@ func marshalAnthropicSystemTextBlock(text string, includeCacheControl bool) ([]b Text: text, } if includeCacheControl { - block.CacheControl = &anthropicCacheControlPayload{Type: "ephemeral"} + block.CacheControl = &anthropicCacheControlPayload{ + Type: "ephemeral", + TTL: claude.DefaultCacheControlTTL, + } } return json.Marshal(block) } @@ -1074,19 +1078,52 @@ func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAu } } - if gjson.GetBytes(out, "temperature").Exists() { - if next, ok := deleteJSONPathBytes(out, "temperature"); ok { + // temperature:真实 Claude Code CLI 总是发送 temperature(默认 1,客户端可覆盖)。 + // 之前的实现直接 delete 会导致 payload 缺字段,与真实 CLI 字节级不一致。 + // 策略:客户端传了什么就透传;没传则补默认 1。 + if !gjson.GetBytes(out, "temperature").Exists() { + if next, ok := setJSONValueBytes(out, "temperature", 1); ok { out = next modified = true } } - if gjson.GetBytes(out, "tool_choice").Exists() { - if next, ok := deleteJSONPathBytes(out, "tool_choice"); ok { + + // max_tokens:真实 CLI 的默认值是 128000。缺失时补齐以对齐指纹。 + if !gjson.GetBytes(out, "max_tokens").Exists() { + if next, ok := setJSONValueBytes(out, "max_tokens", 128000); ok { out = next modified = true } } + // context_management:thinking.type 为 enabled/adaptive 时,真实 CLI 会自动 + // 附带 {"edits":[{"type":"clear_thinking_20251015","keep":"all"}]}。 + // 客户端显式传了就透传;否则按 CLI 行为补齐。 + if !gjson.GetBytes(out, "context_management").Exists() { + thinkingType := gjson.GetBytes(out, "thinking.type").String() + if thinkingType == "enabled" || thinkingType == "adaptive" { + const cmDefault = `{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]}` + if next, ok := setJSONRawBytes(out, "context_management", []byte(cmDefault)); ok { + out = next + modified = true + } + } + } + + // tool_choice:与 Parrot 对齐,不再无条件删除。 + // - 客户端传了 {"type":"tool","name":"X"} → 保留结构,name 由 + // applyToolNameRewriteToBody 同步映射为假名 + // - 其他形态(auto/any/none)原样透传 + // 如果 body 里完全没有 tools(空数组),tool_choice 没意义时才删除 + if !gjson.GetBytes(out, "tools").IsArray() || len(gjson.GetBytes(out, "tools").Array()) == 0 { + if gjson.GetBytes(out, "tool_choice").Exists() { + if next, ok := deleteJSONPathBytes(out, "tool_choice"); ok { + out = next + modified = true + } + } + } + if !modified { return body, modelID } @@ -1128,6 +1165,135 @@ func (s *GatewayService) buildOAuthMetadataUserID(parsed *ParsedRequest, account return FormatMetadataUserID(userID, accountUUID, sessionID, uaVersion) } +// applyClaudeCodeOAuthMimicryToBody 将"非 Claude Code 客户端 + Claude OAuth 账号" +// 路径上原本只在 /v1/messages 里做的完整伪装应用到任意 body 上。 +// +// 这是 /v1/messages 主路径上 rewriteSystemForNonClaudeCode + +// normalizeClaudeOAuthRequestBody 流程的通用版,供 OpenAI 协议兼容层 +// (ForwardAsChatCompletions / ForwardAsResponses) 复用。 +// +// 未抽离之前,OpenAI 协议兼容层仅做 injectClaudeCodePrompt(前置追加), +// 而仓内 /v1/messages 路径自己的注释明确说过"仅前置追加无法通过 Anthropic +// 第三方检测";那条注释就是本函数存在的根因。 +// +// 参数: +// - ctx / c:用于读取指纹和 gateway settings;c 可为 nil(如 count_tokens)。 +// - account:必须是 OAuth 账号,且调用方已判断不是 Claude Code 客户端。 +// - body:已经 marshal 成 Anthropic /v1/messages 格式的请求体。 +// - systemRaw:body 中原始 system 字段(用于判断是否需要 rewrite)。 +// - model:最终会发给上游的模型 ID(用于 haiku 旁路 + metadata 版本选择)。 +// +// 返回:改写后的 body。即使中间任何一步失败,也会退化成原 body(不会 panic)。 +func (s *GatewayService) applyClaudeCodeOAuthMimicryToBody( + ctx context.Context, + c *gin.Context, + account *Account, + body []byte, + systemRaw any, + model string, +) []byte { + if account == nil || !account.IsOAuth() || len(body) == 0 { + return body + } + + systemRewritten := false + if !strings.Contains(strings.ToLower(model), "haiku") { + body = rewriteSystemForNonClaudeCode(body, systemRaw) + systemRewritten = true + } + + normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten} + + if s.identityService != nil && c != nil && c.Request != nil { + if fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header); err == nil && fp != nil { + mimicMPT := false + if s.settingService != nil { + _, mimicMPT, _ = s.settingService.GetGatewayForwardingSettings(ctx) + } + if !mimicMPT { + if uid := s.buildOAuthMetadataUserIDFromBody(ctx, account, fp, body); uid != "" { + normalizeOpts.injectMetadata = true + normalizeOpts.metadataUserID = uid + } + } + } + } + + body, _ = normalizeClaudeOAuthRequestBody(body, model, normalizeOpts) + + // Phase D+E+F: messages cache 策略 + 工具名混淆 + tools[-1] 断点 + // 对齐 Parrot transform_request 里剩余的字段级改写。三步顺序有语义约束: + // 1) strip:先清除客户端的 messages[*].cache_control(多轮稳定性) + // 2) breakpoints:再注入 2 个断点(最后一条 + 倒数第二个 user turn) + // 3) tool rewrite:最后改 tools[*].name / tool_choice.name 并在 tools[-1] + // 上打断点;mapping 存入 gin.Context 供响应侧 bytes.Replace 还原。 + body = stripMessageCacheControl(body) + body = addMessageCacheBreakpoints(body) + + if rw := buildToolNameRewriteFromBody(body); rw != nil { + body = applyToolNameRewriteToBody(body, rw) + if c != nil { + c.Set(toolNameRewriteKey, rw) + } + } else { + body = applyToolsLastCacheBreakpoint(body) + } + + return body +} + +// buildOAuthMetadataUserIDFromBody 是 buildOAuthMetadataUserID 的变体, +// 适用于调用方手上没有 ParsedRequest 的场景(如 OpenAI 协议兼容层)。 +// +// 与 buildOAuthMetadataUserID 的唯一区别: +// - session hash 从 body 本体按同样规则重算,而不是读取 ParsedRequest 缓存值。 +// - 如果 body 里已经存在 metadata.user_id,则返回空(由 ensureClaudeOAuthMetadataUserID +// 自行决定是否覆盖)。 +func (s *GatewayService) buildOAuthMetadataUserIDFromBody( + ctx context.Context, + account *Account, + fp *Fingerprint, + body []byte, +) string { + _ = ctx + if account == nil { + return "" + } + if existing := gjson.GetBytes(body, "metadata.user_id").String(); existing != "" { + return "" + } + + userID := strings.TrimSpace(account.GetClaudeUserID()) + if userID == "" && fp != nil { + userID = fp.ClientID + } + if userID == "" { + userID = generateClientID() + } + + sessionID := uuid.NewString() + if hash := hashBodyForSessionSeed(body); hash != "" { + sessionID = generateSessionUUID(fmt.Sprintf("%d::%s", account.ID, hash)) + } + + var uaVersion string + if fp != nil { + uaVersion = ExtractCLIVersion(fp.UserAgent) + } + accountUUID := strings.TrimSpace(account.GetExtraString("account_uuid")) + return FormatMetadataUserID(userID, accountUUID, sessionID, uaVersion) +} + +// hashBodyForSessionSeed 为 sessionID 提供一个稳定但仅对本次请求特征化的种子。 +// 复用 SHA-256 + 截断,与 generateSessionUUID 的输入格式对齐。 +func hashBodyForSessionSeed(body []byte) string { + if len(body) == 0 { + return "" + } + sum := sha256.Sum256(body) + return fmt.Sprintf("%x", sum[:16]) +} + // GenerateSessionUUID creates a deterministic UUID4 from a seed string. func GenerateSessionUUID(seed string) string { return generateSessionUUID(seed) @@ -3738,17 +3904,20 @@ func rewriteSystemForNonClaudeCode(body []byte, system any) []byte { originalSystemText = strings.Join(parts, "\n\n") } - // 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"}, - }, + // 2. 构造 system 数组,对齐真实 Claude Code CLI 的 2-block 形态: + // [0] billing attribution block(cc_version={cliVer}.{fp}; cc_entrypoint=cli; cch=00000;) + // [1] "You are Claude Code..." prompt block(带 cache_control 作为稳定缓存断点) + // + // billing block 的 cch=00000 是占位符,会被 buildUpstreamRequest 里的 + // signBillingHeaderCCH 替换成 xxhash64 签名。缺失 billing block 的系统 payload + // 是 Anthropic 判定第三方的关键信号之一(真实 CLI 每个请求都带)。 + billingBlock, billingErr := buildBillingAttributionBlockJSON(body, claude.CLICurrentVersion) + ccPromptBlock, ccErr := marshalAnthropicSystemTextBlock(claudeCodeSystemPrompt, true) + if billingErr != nil || ccErr != nil { + logger.LegacyPrintf("service.gateway", "Warning: failed to build system blocks (billing=%v, cc=%v)", billingErr, ccErr) + return body } - out, ok := setJSONValueBytes(body, "system", claudeCodeSystemBlock) + out, ok := setJSONRawBytes(body, "system", buildJSONArrayRaw([][]byte{billingBlock, ccPromptBlock})) if !ok { logger.LegacyPrintf("service.gateway", "Warning: failed to set Claude Code system prompt") return body @@ -3985,15 +4154,21 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A }) } - isClaudeCode := isClaudeCodeRequest(ctx, c, parsed) - shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode + // OAuth 账号无条件走完整 mimicry,与 Parrot 对齐。 + // 不再检查 isClaudeCodeRequest —— 即使客户端自称 Claude Code(opencode 等 + // 第三方工具会伪装 UA / X-App / system prompt),它的伪装往往不完整(缺 billing + // block / 工具名混淆 / cache 策略等),被 Anthropic 判为 third-party。 + // 无条件覆盖不会对真正的 Claude Code 造成问题,因为我们的伪装更完整。 + shouldMimicClaudeCode := account.IsOAuth() if shouldMimicClaudeCode { - // 非 Claude Code 客户端:将 system 替换为 Claude Code 标识,原始 system 迁移至 messages - // 条件:1) OAuth/SetupToken 账号 2) 不是 Claude Code 客户端 3) 不是 Haiku 模型 4) system 中还没有 Claude Code 提示词 + // 与 Parrot 对齐:OAuth 账号无条件重写 system(即使客户端已发了 Claude Code + // 风格的 system prompt)。原因:第三方工具(opencode 等)会发 "You are Claude + // Code..." system prompt 但缺少 billing attribution block,导致 Anthropic + // 检测到"有 CC prompt 但无 billing block"的不一致而判为 third-party。 + // Parrot 的 transform_request 从不检查客户端 system 内容,直接覆盖。 systemRewritten := false - if !strings.Contains(strings.ToLower(reqModel), "haiku") && - !systemIncludesClaudeCodePrompt(parsed.System) { + if !strings.Contains(strings.ToLower(reqModel), "haiku") { body = rewriteSystemForNonClaudeCode(body, parsed.System) systemRewritten = true } @@ -4017,6 +4192,18 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A } body, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts) + + // D/E/F: messages cache 策略 + 工具名混淆 + tools[-1] 断点 + // 与 forward_as_chat_completions / forward_as_responses 路径对齐, + // 保证原生 /v1/messages 路径也经过完整的 Parrot 字段级改写。 + body = stripMessageCacheControl(body) + body = addMessageCacheBreakpoints(body) + if rw := buildToolNameRewriteFromBody(body); rw != nil { + body = applyToolNameRewriteToBody(body, rw) + c.Set(toolNameRewriteKey, rw) + } else { + body = applyToolsLastCacheBreakpoint(body) + } } // 强制执行 cache_control 块数量限制(最多 4 个) @@ -4955,7 +5142,8 @@ func (s *GatewayService) handleStreamingResponseAnthropicAPIKeyPassthrough( } if !clientDisconnected { - if _, err := io.WriteString(w, line); err != nil { + restored := string(reverseToolNamesIfPresent(c, []byte(line))) + if _, err := io.WriteString(w, restored); err != nil { clientDisconnected = true logger.LegacyPrintf("service.gateway", "[Anthropic passthrough] Client disconnected during streaming, continue draining upstream for usage: account=%d", account.ID) } else if _, err := io.WriteString(w, "\n"); err != nil { @@ -5125,6 +5313,7 @@ func (s *GatewayService) handleNonStreamingResponseAnthropicAPIKeyPassthrough( if contentType == "" { contentType = "application/json" } + body = reverseToolNamesIfPresent(c, body) c.Data(resp.StatusCode, contentType, body) return usage, nil } @@ -5580,13 +5769,19 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex setHeaderRaw(req.Header, "x-api-key", token) } - // 白名单透传headers(恢复真实 wire casing) - for key, values := range clientHeaders { - lowerKey := strings.ToLower(key) - if allowedHeaders[lowerKey] { - wireKey := resolveWireCasing(key) - for _, v := range values { - addHeaderRaw(req.Header, wireKey, v) + // 白名单透传 headers + // OAuth mimicry 路径:跳过客户端 header 透传,与 Parrot 对齐。 + // Parrot 的 build_upstream_headers 只发 9 个精确 header,不透传任何客户端 header。 + // 透传客户端 header 会引入不一致的 x-stainless-* / anthropic-beta / user-agent / + // x-claude-code-session-id 等值,和我们注入的伪装 header 冲突,被 Anthropic 判 third-party。 + if !(tokenType == "oauth" && mimicClaudeCode) { + for key, values := range clientHeaders { + lowerKey := strings.ToLower(key) + if allowedHeaders[lowerKey] { + wireKey := resolveWireCasing(key) + for _, v := range values { + addHeaderRaw(req.Header, wireKey, v) + } } } } @@ -5627,7 +5822,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex // Haiku models are exempt from third-party detection and don't need it. requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking} if !strings.Contains(strings.ToLower(modelID), "haiku") { - requiredBetas = []string{claude.BetaClaudeCode, claude.BetaOAuth, claude.BetaInterleavedThinking} + requiredBetas = claude.FullClaudeCodeMimicryBetas() } setHeaderRaw(req.Header, "anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, effectiveDropSet)) } else { @@ -6099,6 +6294,11 @@ func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) { if isStream { setHeaderRaw(req.Header, "x-stainless-helper-method", "stream") } + // Real Claude CLI 每个请求都会生成一个新的 UUID 放在 x-client-request-id。 + // 上游会以此作为会话/请求指纹的一部分,缺失或重复都可能触发第三方判定。 + if getHeaderRaw(req.Header, "x-client-request-id") == "" { + setHeaderRaw(req.Header, "x-client-request-id", uuid.NewString()) + } } func truncateForLog(b []byte, maxBytes int) string { @@ -6864,7 +7064,8 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http for _, block := range outputBlocks { if !clientDisconnected { - if _, werr := fmt.Fprint(w, block); werr != nil { + restored := reverseToolNamesIfPresent(c, []byte(block)) + if _, werr := fmt.Fprint(w, string(restored)); werr != nil { clientDisconnected = true logger.LegacyPrintf("service.gateway", "Client disconnected during streaming, continuing to drain upstream for billing") break @@ -7206,6 +7407,8 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h } } + body = reverseToolNamesIfPresent(c, body) + // 写入响应 c.Data(resp.StatusCode, contentType, body) @@ -8194,12 +8397,19 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, // Pre-filter: strip empty text blocks to prevent upstream 400. body = StripEmptyTextBlocks(body) - isClaudeCode := isClaudeCodeRequest(ctx, c, parsed) - shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode + shouldMimicClaudeCode := account.IsOAuth() if shouldMimicClaudeCode { normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true} body, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts) + + body = stripMessageCacheControl(body) + body = addMessageCacheBreakpoints(body) + if rw := buildToolNameRewriteFromBody(body); rw != nil { + body = applyToolNameRewriteToBody(body, rw) + } else { + body = applyToolsLastCacheBreakpoint(body) + } } // Antigravity 账户不支持 count_tokens,返回 404 让客户端 fallback 到本地估算。 @@ -8623,7 +8833,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con applyClaudeCodeMimicHeaders(req, false) incomingBeta := getHeaderRaw(req.Header, "anthropic-beta") - requiredBetas := []string{claude.BetaClaudeCode, claude.BetaOAuth, claude.BetaInterleavedThinking, claude.BetaTokenCounting} + requiredBetas := append(claude.FullClaudeCodeMimicryBetas(), claude.BetaTokenCounting) setHeaderRaw(req.Header, "anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, ctEffectiveDropSet)) } else { clientBetaHeader := getHeaderRaw(req.Header, "anthropic-beta") diff --git a/backend/internal/service/gateway_tool_rewrite.go b/backend/internal/service/gateway_tool_rewrite.go new file mode 100644 index 00000000..c76cab62 --- /dev/null +++ b/backend/internal/service/gateway_tool_rewrite.go @@ -0,0 +1,313 @@ +package service + +import ( + "fmt" + "hash/fnv" + "math/rand" + "sort" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// toolNameRewriteKey 是 gin.Context 上存 ToolNameRewrite 映射的 key。 +// 请求阶段写入,响应阶段读取,用于 bytes 级逆向还原假名 → 真名。 +const toolNameRewriteKey = "claude_tool_name_rewrite" + +// staticToolNameRewrites 是"静态前缀映射",与 Parrot src/transform/cc_mimicry.py +// TOOL_NAME_REWRITES 完全一致。只有以这些前缀开头的工具会被重写。 +var staticToolNameRewrites = map[string]string{ + "sessions_": "cc_sess_", + "session_": "cc_ses_", +} + +// fakeToolNamePrefixes 是"动态映射"的前缀池,与 Parrot _FAKE_PREFIXES 一致。 +// 当 tools 数量 > dynamicToolMapThreshold 时随机选用其中前缀生成可读假名。 +var fakeToolNamePrefixes = []string{ + "analyze_", "compute_", "fetch_", "generate_", "lookup_", "modify_", + "process_", "query_", "render_", "resolve_", "sync_", "update_", + "validate_", "convert_", "extract_", "manage_", "monitor_", "parse_", + "review_", "search_", "transform_", "handle_", "invoke_", "notify_", +} + +// dynamicToolMapThreshold 与 Parrot 一致:tools 数量超过 5 才启用动态映射。 +// 少量工具不需要混淆(一般是 Claude Code 自己的核心工具 bash/edit/read 等)。 +const dynamicToolMapThreshold = 5 + +// ToolNameRewrite 是单次请求内的工具名混淆映射。 +// - Forward: real → fake,请求阶段在 body 上应用。 +// - Reverse: fake → real,响应阶段对每个 chunk 做 bytes.Replace 还原。 +// +// ReverseOrdered 是按假名长度倒序的 (fake, real) 列表,用于防止短假名是长假名的 +// 子串时 bytes.Replace 先被吃掉(对齐 Parrot _restore_tool_names_in_chunk 的 +// `sorted(..., key=lambda x: len(x[1]), reverse=True)`)。 +type ToolNameRewrite struct { + Forward map[string]string + Reverse map[string]string + ReverseOrdered [][2]string +} + +// buildDynamicToolMap 构造 tools 的动态假名映射。 +// +// 与 Parrot _build_dynamic_tool_map 语义等价: +// - tools 数量 ≤ dynamicToolMapThreshold 时返回 nil(不做动态映射,走静态 fallback) +// - 同一组 tool_names 在同进程内映射稳定(保证 cache 命中) +// +// Parrot 用 `random.Random(hash(tuple(tool_names)))` 作 seed + shuffle 前缀池; +// Go 无法字节级复刻 Python hash,但"稳定性"和"前缀池打散"两个不变量都保留: +// 用 fnv64a(strings.Join(names, "\x00")) 作 seed 喂 math/rand.New。 +// 字节级不同不影响上游判定(Anthropic 不会验证我们的随机种子算法)。 +func buildDynamicToolMap(toolNames []string) map[string]string { + if len(toolNames) <= dynamicToolMapThreshold { + return nil + } + h := fnv.New64a() + for i, n := range toolNames { + if i > 0 { + _, _ = h.Write([]byte{0}) + } + _, _ = h.Write([]byte(n)) + } + rng := rand.New(rand.NewSource(int64(h.Sum64()))) + + available := make([]string, len(fakeToolNamePrefixes)) + copy(available, fakeToolNamePrefixes) + rng.Shuffle(len(available), func(i, j int) { available[i], available[j] = available[j], available[i] }) + + mapping := make(map[string]string, len(toolNames)) + for i, name := range toolNames { + prefix := available[i%len(available)] + headLen := 3 + if len(name) < 3 { + headLen = len(name) + } + fake := fmt.Sprintf("%s%s%02d", prefix, name[:headLen], i) + mapping[name] = fake + } + return mapping +} + +// sanitizeToolName 把真名转成假名。 +// 与 Parrot _sanitize_tool_name 语义一致:动态映射优先,再走静态前缀映射。 +func sanitizeToolName(name string, dynamic map[string]string) string { + if dynamic != nil { + if fake, ok := dynamic[name]; ok { + return fake + } + } + for prefix, replacement := range staticToolNameRewrites { + if strings.HasPrefix(name, prefix) { + return replacement + name[len(prefix):] + } + } + return name +} + +// shouldMimicToolName 指示某个 tool 是否需要重命名。 +// server tool(type != "" 且不是 "function" / "custom")是 Anthropic 协议语义的一部分, +// 比如 "web_search_20250305" / "computer_20250124";误改会导致上游拒绝。 +func shouldMimicToolName(toolType string) bool { + if toolType == "" || toolType == "function" || toolType == "custom" { + return true + } + return false +} + +// buildToolNameRewriteFromBody 扫描 body 的 tools[*].name,构造 ToolNameRewrite +// 并返回它。若不需要混淆(tools 数量不足 + 没有匹配静态前缀的工具)返回 nil。 +// +// 注意:只扫描,不改 body。真正的 body 改写在 applyToolNameRewriteToBody。 +func buildToolNameRewriteFromBody(body []byte) *ToolNameRewrite { + tools := gjson.GetBytes(body, "tools") + if !tools.IsArray() { + return nil + } + + mimicableNames := make([]string, 0) + toolsArr := tools.Array() + for _, t := range toolsArr { + if !shouldMimicToolName(t.Get("type").String()) { + continue + } + name := t.Get("name").String() + if name == "" { + continue + } + mimicableNames = append(mimicableNames, name) + } + + dynamic := buildDynamicToolMap(mimicableNames) + + rw := &ToolNameRewrite{ + Forward: make(map[string]string), + Reverse: make(map[string]string), + } + for _, name := range mimicableNames { + fake := sanitizeToolName(name, dynamic) + if fake == name { + continue + } + rw.Forward[name] = fake + rw.Reverse[fake] = name + } + if len(rw.Forward) == 0 { + return nil + } + + rw.ReverseOrdered = make([][2]string, 0, len(rw.Reverse)) + for fake, real := range rw.Reverse { + rw.ReverseOrdered = append(rw.ReverseOrdered, [2]string{fake, real}) + } + sort.SliceStable(rw.ReverseOrdered, func(i, j int) bool { + return len(rw.ReverseOrdered[i][0]) > len(rw.ReverseOrdered[j][0]) + }) + + return rw +} + +// applyToolNameRewriteToBody 把已构造的 ToolNameRewrite 应用到 body 上: +// - 改写 $.tools[*].name(仅对 shouldMimicToolName 通过的 tool) +// - 在 $.tools[last].cache_control 上打 ephemeral 缓存断点(Parrot 行为对齐, +// ttl 客户端已有则透传,否则默认 claude.DefaultCacheControlTTL) +// - 改写 $.tool_choice.name(仅当 $.tool_choice.type == "tool") +// +// 历史 $.messages[*].content[*].name(tool_use)不在请求侧改写——这与 Parrot 一致; +// 响应侧 bytes.Replace 会连带还原它们。 +func applyToolNameRewriteToBody(body []byte, rw *ToolNameRewrite) []byte { + if rw == nil || len(rw.Forward) == 0 { + body = applyToolsLastCacheBreakpoint(body) + return body + } + + tools := gjson.GetBytes(body, "tools") + if tools.IsArray() { + idx := -1 + tools.ForEach(func(_, t gjson.Result) bool { + idx++ + if !shouldMimicToolName(t.Get("type").String()) { + return true + } + name := t.Get("name").String() + if name == "" { + return true + } + fake, ok := rw.Forward[name] + if !ok { + return true + } + if next, err := sjson.SetBytes(body, fmt.Sprintf("tools.%d.name", idx), fake); err == nil { + body = next + } + return true + }) + } + + if tc := gjson.GetBytes(body, "tool_choice"); tc.Exists() && tc.Get("type").String() == "tool" { + name := tc.Get("name").String() + if fake, ok := rw.Forward[name]; ok { + if next, err := sjson.SetBytes(body, "tool_choice.name", fake); err == nil { + body = next + } + } + } + + body = applyToolsLastCacheBreakpoint(body) + return body +} + +// applyToolsLastCacheBreakpoint 在 tools 数组最后一个工具上注入 cache_control +// 断点,对齐 Parrot `tools[-1]["cache_control"] = {"type":"ephemeral","ttl":"1h"}` +// 行为,但 ttl 按本仓规则: +// - 客户端已为该 tool 显式设置 cache_control.ttl → 完全透传不覆盖 +// - 否则注入 {"type":"ephemeral","ttl": claude.DefaultCacheControlTTL} +// +// 纯副作用函数,tools 不存在或为空数组时 no-op。 +func applyToolsLastCacheBreakpoint(body []byte) []byte { + tools := gjson.GetBytes(body, "tools") + if !tools.IsArray() { + return body + } + arr := tools.Array() + if len(arr) == 0 { + return body + } + lastIdx := len(arr) - 1 + existingCC := arr[lastIdx].Get("cache_control") + + if existingCC.Exists() && existingCC.Get("ttl").String() != "" { + return body + } + + if existingCC.Exists() { + if next, err := sjson.SetBytes(body, fmt.Sprintf("tools.%d.cache_control.ttl", lastIdx), claude.DefaultCacheControlTTL); err == nil { + body = next + } + return body + } + + raw := fmt.Sprintf(`{"type":"ephemeral","ttl":%q}`, claude.DefaultCacheControlTTL) + if next, err := sjson.SetRawBytes(body, fmt.Sprintf("tools.%d.cache_control", lastIdx), []byte(raw)); err == nil { + body = next + } + return body +} + +// restoreToolNamesInBytes 对 bytes chunk 做逆向还原:假名 → 真名。 +// 按 ReverseOrdered 的假名长度倒序逐个 bytes.Replace,防止子串冲突 +// (与 Parrot _restore_tool_names_in_chunk 的 sorted(..., reverse=True) 等价)。 +// 再做静态前缀还原(cc_sess_ → sessions_ / cc_ses_ → session_)。 +// +// rw 可为 nil;nil 时仍会做静态前缀还原。 +func restoreToolNamesInBytes(data []byte, rw *ToolNameRewrite) []byte { + if rw != nil { + for _, pair := range rw.ReverseOrdered { + fake, real := pair[0], pair[1] + if fake == "" || fake == real { + continue + } + data = replaceAllBytes(data, fake, real) + } + } + for prefix, replacement := range staticToolNameRewrites { + data = replaceAllBytes(data, replacement, prefix) + } + return data +} + +// replaceAllBytes 是 bytes.ReplaceAll 的便捷封装,避免每个调用点各自做 []byte 转换。 +func replaceAllBytes(data []byte, from, to string) []byte { + if len(data) == 0 || from == to || !strings.Contains(string(data), from) { + return data + } + return []byte(strings.ReplaceAll(string(data), from, to)) +} + +// toolNameRewriteFromContext 从 gin.Context 取出请求阶段保存的工具名映射。 +// 找不到(c==nil 或 key 不存在或类型不对)时返回 nil;调用方必须能处理 nil。 +func toolNameRewriteFromContext(c interface { + Get(string) (any, bool) +}) *ToolNameRewrite { + if c == nil { + return nil + } + raw, ok := c.Get(toolNameRewriteKey) + if !ok || raw == nil { + return nil + } + rw, _ := raw.(*ToolNameRewrite) + return rw +} + +// reverseToolNamesIfPresent 是响应侧 5 处注入点的统一封装:从 c 取出 mapping +// 并对 chunk 做 bytes 级假名→真名替换。c 没有 mapping 时仍会做静态前缀还原。 +func reverseToolNamesIfPresent(c interface { + Get(string) (any, bool) +}, chunk []byte) []byte { + rw := toolNameRewriteFromContext(c) + if rw == nil && len(staticToolNameRewrites) == 0 { + return chunk + } + return restoreToolNamesInBytes(chunk, rw) +} diff --git a/backend/internal/service/gateway_tool_rewrite_test.go b/backend/internal/service/gateway_tool_rewrite_test.go new file mode 100644 index 00000000..8f0e3939 --- /dev/null +++ b/backend/internal/service/gateway_tool_rewrite_test.go @@ -0,0 +1,185 @@ +package service + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestBuildDynamicToolMap_BelowThreshold(t *testing.T) { + // Parrot 行为:tools 数量 ≤ 5 时不做动态映射。 + names := []string{"bash", "edit", "read", "write", "search"} + require.Nil(t, buildDynamicToolMap(names)) +} + +func TestBuildDynamicToolMap_AboveThresholdIsStable(t *testing.T) { + // Parrot 不变量:同一组 tool_names 在同进程内映射稳定(保证 cache 命中)。 + names := []string{"alpha", "beta", "gamma", "delta", "epsilon", "zeta"} + a := buildDynamicToolMap(names) + b := buildDynamicToolMap(names) + require.NotNil(t, a) + require.Equal(t, a, b, "same input tool_names must yield identical mapping") + require.Len(t, a, 6) + for _, name := range names { + require.Contains(t, a, name) + require.NotEqual(t, name, a[name]) + } +} + +func TestSanitizeToolName_StaticPrefix(t *testing.T) { + require.Equal(t, "cc_sess_list", sanitizeToolName("sessions_list", nil)) + require.Equal(t, "cc_ses_get", sanitizeToolName("session_get", nil)) + require.Equal(t, "bash", sanitizeToolName("bash", nil)) +} + +func TestSanitizeToolName_DynamicTakesPrecedence(t *testing.T) { + dyn := map[string]string{"sessions_list": "analyze_ses00"} + got := sanitizeToolName("sessions_list", dyn) + require.Equal(t, "analyze_ses00", got, "dynamic mapping wins over static prefix") +} + +func TestRestoreToolNamesInBytes_LongestFirst(t *testing.T) { + // 当假名 "abc_12" 是另一个更长假名的子串(真实场景极少但算法必须防御)时, + // 长的必须先替换。本测试用显式构造的映射来验证排序不变量。 + rw := &ToolNameRewrite{ + Forward: map[string]string{"foo": "abc_12", "bar": "abc_12_ext"}, + Reverse: map[string]string{"abc_12": "foo", "abc_12_ext": "bar"}, + } + // 手工构造 ReverseOrdered:长的在前 + rw.ReverseOrdered = [][2]string{ + {"abc_12_ext", "bar"}, + {"abc_12", "foo"}, + } + data := []byte(`{"tool":"abc_12_ext","other":"abc_12"}`) + restored := string(restoreToolNamesInBytes(data, rw)) + require.Equal(t, `{"tool":"bar","other":"foo"}`, restored) +} + +func TestRestoreToolNamesInBytes_StaticPrefixRollback(t *testing.T) { + data := []byte(`{"name":"sessions_list","id":"cc_ses_xyz"}`) + got := string(restoreToolNamesInBytes(data, nil)) + require.Equal(t, `{"name":"sessions_list","id":"session_xyz"}`, got) +} + +func TestApplyToolNameRewriteToBody_RenamesToolsAndToolChoice(t *testing.T) { + body := []byte(`{"tools":[{"name":"sessions_list","input_schema":{}},{"name":"session_get","input_schema":{}},{"name":"web_search","type":"web_search_20250305"}],"tool_choice":{"type":"tool","name":"sessions_list"}}`) + rw := buildToolNameRewriteFromBody(body) + require.NotNil(t, rw) + require.Contains(t, rw.Forward, "sessions_list") + require.Contains(t, rw.Forward, "session_get") + // web_search is a server tool, not rewritten + require.NotContains(t, rw.Forward, "web_search") + + out := applyToolNameRewriteToBody(body, rw) + + // tools[0].name and tools[1].name rewritten; tools[2].name untouched + require.Equal(t, "cc_sess_list", gjson.GetBytes(out, "tools.0.name").String()) + require.Equal(t, "cc_ses_get", gjson.GetBytes(out, "tools.1.name").String()) + require.Equal(t, "web_search", gjson.GetBytes(out, "tools.2.name").String()) + + // tool_choice.name rewritten + require.Equal(t, "cc_sess_list", gjson.GetBytes(out, "tool_choice.name").String()) + require.Equal(t, "tool", gjson.GetBytes(out, "tool_choice.type").String()) +} + +func TestApplyToolsLastCacheBreakpoint_InjectsDefault(t *testing.T) { + body := []byte(`{"tools":[{"name":"a","input_schema":{}},{"name":"b","input_schema":{}}]}`) + out := applyToolsLastCacheBreakpoint(body) + require.Equal(t, "ephemeral", gjson.GetBytes(out, "tools.1.cache_control.type").String()) + require.Equal(t, "5m", gjson.GetBytes(out, "tools.1.cache_control.ttl").String()) + // First tool untouched + require.False(t, gjson.GetBytes(out, "tools.0.cache_control").Exists()) +} + +func TestApplyToolsLastCacheBreakpoint_PassesThroughClientTTL(t *testing.T) { + body := []byte(`{"tools":[{"name":"a","input_schema":{},"cache_control":{"type":"ephemeral","ttl":"1h"}}]}`) + out := applyToolsLastCacheBreakpoint(body) + // User-provided ttl must be preserved. + require.Equal(t, "1h", gjson.GetBytes(out, "tools.0.cache_control.ttl").String()) +} + +func TestStripMessageCacheControl(t *testing.T) { + body := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral"}}]}]}`) + out := stripMessageCacheControl(body) + require.False(t, gjson.GetBytes(out, "messages.0.content.0.cache_control").Exists()) +} + +func TestAddMessageCacheBreakpoints_LastMessageOnly(t *testing.T) { + body := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`) + out := addMessageCacheBreakpoints(body) + require.Equal(t, "ephemeral", gjson.GetBytes(out, "messages.0.content.0.cache_control.type").String()) + require.Equal(t, "5m", gjson.GetBytes(out, "messages.0.content.0.cache_control.ttl").String()) +} + +func TestAddMessageCacheBreakpoints_SecondToLastUserTurn(t *testing.T) { + // Parrot 不变量:messages ≥ 4 时才打第二个断点,且位置是"倒数第二个 user turn"。 + body := []byte(`{"messages":[ + {"role":"user","content":[{"type":"text","text":"q1"}]}, + {"role":"assistant","content":[{"type":"text","text":"a1"}]}, + {"role":"user","content":[{"type":"text","text":"q2"}]}, + {"role":"assistant","content":[{"type":"text","text":"a2"}]} + ]}`) + out := addMessageCacheBreakpoints(body) + // 最后一条 assistant 被打断点 + require.Equal(t, "ephemeral", gjson.GetBytes(out, "messages.3.content.0.cache_control.type").String()) + // 倒数第二个 user turn = index 0(唯一另一个 user) + require.Equal(t, "ephemeral", gjson.GetBytes(out, "messages.0.content.0.cache_control.type").String()) + // 其他不打断点 + require.False(t, gjson.GetBytes(out, "messages.1.content.0.cache_control").Exists()) + require.False(t, gjson.GetBytes(out, "messages.2.content.0.cache_control").Exists()) +} + +func TestAddMessageCacheBreakpoints_StringContentPromoted(t *testing.T) { + body := []byte(`{"messages":[{"role":"user","content":"hi"}]}`) + out := addMessageCacheBreakpoints(body) + // content 升级成数组 + require.True(t, gjson.GetBytes(out, "messages.0.content").IsArray()) + require.Equal(t, "text", gjson.GetBytes(out, "messages.0.content.0.type").String()) + require.Equal(t, "hi", gjson.GetBytes(out, "messages.0.content.0.text").String()) + require.Equal(t, "5m", gjson.GetBytes(out, "messages.0.content.0.cache_control.ttl").String()) +} + +func TestBuildToolNameRewriteFromBody_ReverseOrderedByLengthDesc(t *testing.T) { + // 超过阈值触发动态映射,验证 ReverseOrdered 按假名长度倒序排列 + body := []byte(`{"tools":[ + {"name":"t1","input_schema":{}}, + {"name":"t2","input_schema":{}}, + {"name":"t3","input_schema":{}}, + {"name":"t4","input_schema":{}}, + {"name":"t5","input_schema":{}}, + {"name":"t6","input_schema":{}} + ]}`) + rw := buildToolNameRewriteFromBody(body) + require.NotNil(t, rw) + require.NotEmpty(t, rw.ReverseOrdered) + for i := 1; i < len(rw.ReverseOrdered); i++ { + require.GreaterOrEqual(t, len(rw.ReverseOrdered[i-1][0]), len(rw.ReverseOrdered[i][0]), + "ReverseOrdered must be sorted by fake-name length descending") + } +} + +func TestRestoreToolNamesInBytes_NoMapping_NoStaticMatch_IsNoop(t *testing.T) { + data := []byte("plain text without any tool names") + require.Equal(t, string(data), string(restoreToolNamesInBytes(data, nil))) +} + +// Ensure the fake name format follows Parrot's "{prefix}{name[:3]}{i:02d}". +func TestBuildDynamicToolMap_FakeNameShape(t *testing.T) { + names := []string{"alphabet", "bravo", "charlie", "delta", "echo", "foxtrot"} + m := buildDynamicToolMap(names) + require.NotNil(t, m) + for _, name := range names { + fake, ok := m[name] + require.True(t, ok) + // fake = prefix + head3 + "%02d" + // ends with two decimal digits + require.Regexp(t, `^[a-z]+_[a-z0-9]{1,3}\d{2}$`, fake) + head := name + if len(head) > 3 { + head = head[:3] + } + require.True(t, strings.Contains(fake, head), "fake %q should contain head3 %q of %q", fake, head, name) + } +} diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go index 3d706508..665922e3 100644 --- a/backend/internal/service/identity_service.go +++ b/backend/internal/service/identity_service.go @@ -26,7 +26,7 @@ var ( // 默认指纹值(当客户端未提供时使用) var defaultFingerprint = Fingerprint{ - UserAgent: "claude-cli/2.1.22 (external, cli)", + UserAgent: "claude-cli/2.1.92 (external, cli)", StainlessLang: "js", StainlessPackageVersion: "0.70.0", StainlessOS: "Linux",