diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 27bc1f25..618d567c 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -687,8 +687,8 @@ type TLSProfileConfig struct { // 允许每个 sub2api 实例设置不同的默认指纹值,与其他实例区分。 // 所有字段为空时使用代码内置默认值。 type FingerprintDefaultsConfig struct { - // ClaudeCLIVersion: Claude CLI 版本号(如 "2.1.81"), - // 最终 User-Agent 为 "claude-cli/{version} (external, cli)" + // ClaudeCLIVersion: Claude CLI 版本号(如 "2.1.104"), + // 最终 User-Agent 为 "claude-cli/{version} (external, {entrypoint}[, agent-sdk/...][, client-app/...][, workload/...])" ClaudeCLIVersion string `mapstructure:"claude_cli_version"` // StainlessPackageVersion: @anthropic-ai/sdk 版本(如 "0.80.0") StainlessPackageVersion string `mapstructure:"stainless_package_version"` diff --git a/backend/internal/pkg/antigravity/claude_code_tool_map.go b/backend/internal/pkg/antigravity/claude_code_tool_map.go new file mode 100644 index 00000000..155ffeba --- /dev/null +++ b/backend/internal/pkg/antigravity/claude_code_tool_map.go @@ -0,0 +1,70 @@ +package antigravity + +import "strings" + +var claudeCodeBuiltinToolNameMap = map[string]string{ + "read": "Read", + "read_file": "Read", + "readfile": "Read", + "write": "Write", + "write_file": "Write", + "writefile": "Write", + "edit": "Edit", + "apply_patch": "Edit", + "applypatch": "Edit", + "bash": "Bash", + "execute_bash": "Bash", + "executebash": "Bash", + "exec_bash": "Bash", + "execbash": "Bash", + "glob": "Glob", + "list_files": "Glob", + "listfiles": "Glob", + "grep": "Grep", + "search_files": "Grep", + "searchfiles": "Grep", + "webfetch": "WebFetch", + "web_fetch": "WebFetch", + "fetch": "WebFetch", + "websearch": "WebSearch", + "web_search": "WebSearch", + "agent": "Agent", + "askuserquestion": "AskUserQuestion", + "ask_user_question": "AskUserQuestion", + "enterplanmode": "EnterPlanMode", + "enter_plan_mode": "EnterPlanMode", + "exitplanmode": "ExitPlanMode", + "exit_plan_mode": "ExitPlanMode", + "croncreate": "CronCreate", + "cron_create": "CronCreate", + "crondelete": "CronDelete", + "cron_delete": "CronDelete", + "schedulewakeup": "ScheduleWakeup", + "schedule_wakeup": "ScheduleWakeup", + "sendmessage": "SendMessage", + "send_message": "SendMessage", + "skill": "Skill", + "taskcreate": "TaskCreate", + "task_create": "TaskCreate", + "tasklist": "TaskList", + "task_list": "TaskList", + "taskoutput": "TaskOutput", + "task_output": "TaskOutput", + "taskstop": "TaskStop", + "task_stop": "TaskStop", + "taskupdate": "TaskUpdate", + "task_update": "TaskUpdate", +} + +func normalizeClaudeCodeToolName(name string) string { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return "" + } + + if mapped, ok := claudeCodeBuiltinToolNameMap[strings.ToLower(trimmed)]; ok { + return mapped + } + + return trimmed +} diff --git a/backend/internal/pkg/antigravity/claude_code_tool_map_test.go b/backend/internal/pkg/antigravity/claude_code_tool_map_test.go new file mode 100644 index 00000000..9e9beae4 --- /dev/null +++ b/backend/internal/pkg/antigravity/claude_code_tool_map_test.go @@ -0,0 +1,160 @@ +package antigravity + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeClaudeCodeToolName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + }{ + {name: "read alias", input: "read_file", expected: "Read"}, + {name: "grep alias", input: "search_files", expected: "Grep"}, + {name: "webfetch alias", input: "fetch", expected: "WebFetch"}, + {name: "plan alias", input: "enter_plan_mode", expected: "EnterPlanMode"}, + {name: "native passthrough", input: "TaskUpdate", expected: "TaskUpdate"}, + {name: "mcp passthrough", input: "mcp__github__list_prs", expected: "mcp__github__list_prs"}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.expected, normalizeClaudeCodeToolName(tt.input)) + }) + } +} + +func TestBuildPartsNormalizesClaudeCodeToolNames(t *testing.T) { + t.Parallel() + + toolIDToName := make(map[string]string) + assistantParts, stripped, err := buildParts(json.RawMessage(`[ + {"type":"tool_use","id":"tool-1","name":"read_file","input":{"file_path":"/tmp/demo.txt"}} + ]`), toolIDToName, false) + require.NoError(t, err) + require.False(t, stripped) + require.Len(t, assistantParts, 1) + require.NotNil(t, assistantParts[0].FunctionCall) + require.Equal(t, "Read", assistantParts[0].FunctionCall.Name) + require.Equal(t, "Read", toolIDToName["tool-1"]) + + userParts, stripped, err := buildParts(json.RawMessage(`[ + {"type":"tool_result","tool_use_id":"tool-1","content":[{"type":"text","text":"ok"}]} + ]`), toolIDToName, false) + require.NoError(t, err) + require.False(t, stripped) + require.Len(t, userParts, 1) + require.NotNil(t, userParts[0].FunctionResponse) + require.Equal(t, "Read", userParts[0].FunctionResponse.Name) +} + +func TestBuildToolsNormalizesClaudeCodeBuiltinNamesOnly(t *testing.T) { + t.Parallel() + + result := buildTools([]ClaudeTool{ + { + Name: "search_files", + Description: "Search the workspace", + InputSchema: map[string]any{ + "type": "object", + }, + }, + { + Name: "mcp__github__list_prs", + Description: "List pull requests", + InputSchema: map[string]any{ + "type": "object", + }, + }, + }) + + require.Len(t, result, 1) + require.Len(t, result[0].FunctionDeclarations, 2) + require.Equal(t, "Grep", result[0].FunctionDeclarations[0].Name) + require.Equal(t, "mcp__github__list_prs", result[0].FunctionDeclarations[1].Name) +} + +func TestNonStreamingProcessorNormalizesClaudeCodeToolName(t *testing.T) { + t.Parallel() + + processor := NewNonStreamingProcessor() + response := processor.Process(&GeminiResponse{ + Candidates: []GeminiCandidate{ + { + Content: &GeminiContent{ + Parts: []GeminiPart{ + { + FunctionCall: &GeminiFunctionCall{ + Name: "web_fetch", + Args: map[string]any{"url": "https://example.com"}, + }, + }, + }, + }, + FinishReason: "STOP", + }, + }, + }, "resp-1", "claude-sonnet-4-5") + + require.Len(t, response.Content, 1) + require.Equal(t, "tool_use", response.Content[0].Type) + require.Equal(t, "WebFetch", response.Content[0].Name) + require.True(t, strings.HasPrefix(response.Content[0].ID, "WebFetch-")) + require.NotNil(t, response.Content[0].Caller) + require.Equal(t, "direct", response.Content[0].Caller.Type) + require.Equal(t, "tool_use", response.StopReason) +} + +func TestStreamingProcessorNormalizesClaudeCodeToolName(t *testing.T) { + t.Parallel() + + processor := NewStreamingProcessor("claude-sonnet-4-5") + output := processor.processFunctionCall(&GeminiFunctionCall{ + Name: "search_files", + Args: map[string]any{"pattern": "TODO"}, + }, "") + + events := parseSSEDataEvents(t, output) + require.Len(t, events, 3) + + contentBlock, ok := events[0]["content_block"].(map[string]any) + require.True(t, ok) + require.Equal(t, "tool_use", contentBlock["type"]) + require.Equal(t, "Grep", contentBlock["name"]) + + toolID, ok := contentBlock["id"].(string) + require.True(t, ok) + require.True(t, strings.HasPrefix(toolID, "Grep-")) + + caller, ok := contentBlock["caller"].(map[string]any) + require.True(t, ok) + require.Equal(t, "direct", caller["type"]) +} + +func parseSSEDataEvents(t *testing.T, payload []byte) []map[string]any { + t.Helper() + + lines := strings.Split(string(payload), "\n") + events := make([]map[string]any, 0) + + for _, line := range lines { + if !strings.HasPrefix(line, "data: ") { + continue + } + + var event map[string]any + require.NoError(t, json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &event)) + events = append(events, event) + } + + return events +} diff --git a/backend/internal/pkg/antigravity/claude_types.go b/backend/internal/pkg/antigravity/claude_types.go index ce144bb9..8ad1c434 100644 --- a/backend/internal/pkg/antigravity/claude_types.go +++ b/backend/internal/pkg/antigravity/claude_types.go @@ -16,6 +16,7 @@ type ClaudeRequest struct { TopK *int `json:"top_k,omitempty"` Tools []ClaudeTool `json:"tools,omitempty"` Thinking *ThinkingConfig `json:"thinking,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` Metadata *ClaudeMetadata `json:"metadata,omitempty"` } @@ -72,9 +73,10 @@ type ContentBlock struct { Thinking string `json:"thinking,omitempty"` Signature string `json:"signature,omitempty"` // tool_use - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Input any `json:"input,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input any `json:"input,omitempty"` + Caller *ToolCaller `json:"caller,omitempty"` // tool_result ToolUseID string `json:"tool_use_id,omitempty"` Content json.RawMessage `json:"content,omitempty"` @@ -114,9 +116,15 @@ type ClaudeContentItem struct { Signature string `json:"signature,omitempty"` // tool_use - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Input any `json:"input,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input any `json:"input,omitempty"` + Caller *ToolCaller `json:"caller,omitempty"` +} + +// ToolCaller Claude Code tool_use 调用来源 +type ToolCaller struct { + Type string `json:"type"` } // ClaudeUsage Claude 用量统计 diff --git a/backend/internal/pkg/antigravity/gemini_types.go b/backend/internal/pkg/antigravity/gemini_types.go index 033dccbd..3ed149b9 100644 --- a/backend/internal/pkg/antigravity/gemini_types.go +++ b/backend/internal/pkg/antigravity/gemini_types.go @@ -117,7 +117,8 @@ type GeminiToolConfig struct { // GeminiFunctionCallingConfig 函数调用配置 type GeminiFunctionCallingConfig struct { - Mode string `json:"mode,omitempty"` // VALIDATED, AUTO, NONE + Mode string `json:"mode,omitempty"` // VALIDATED, AUTO, NONE, ANY + AllowedFunctionNames []string `json:"allowedFunctionNames,omitempty"` } // GeminiSafetySetting Gemini 安全设置 diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go index bb595099..6d71b575 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -1,12 +1,14 @@ package antigravity import ( + "bytes" "crypto/sha256" "encoding/binary" "encoding/json" "fmt" "log" "math/rand" + "regexp" "strconv" "strings" "sync" @@ -16,10 +18,16 @@ import ( ) var ( - sessionRand = rand.New(rand.NewSource(time.Now().UnixNano())) - sessionRandMutex sync.Mutex + sessionRand = rand.New(rand.NewSource(time.Now().UnixNano())) + sessionRandMutex sync.Mutex + legacyMetadataUserIDSessionPattern = regexp.MustCompile(`^user_[a-fA-F0-9]{64}_account_[a-fA-F0-9-]*_session_([a-fA-F0-9-]{36})$`) + plainSessionIDPattern = regexp.MustCompile(`^(session_)?[a-fA-F0-9-]{36}$`) ) +type claudeMetadataUserIDPayload struct { + SessionID string `json:"session_id"` +} + // generateStableSessionID 基于用户消息内容生成稳定的 session ID func generateStableSessionID(contents []GeminiContent) string { // 查找第一个 user 消息的文本 @@ -67,12 +75,54 @@ func EnsureGeminiRequestSessionID(body []byte, preferredSessionID string) ([]byt return json.Marshal(payload) } +func extractSessionIDFromMetadataUserID(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + + if strings.HasPrefix(raw, "{") { + var payload claudeMetadataUserIDPayload + if err := json.Unmarshal([]byte(raw), &payload); err == nil { + return strings.TrimSpace(payload.SessionID) + } + return "" + } + + if matches := legacyMetadataUserIDSessionPattern.FindStringSubmatch(raw); len(matches) == 2 { + return strings.TrimSpace(matches[1]) + } + + if plainSessionIDPattern.MatchString(raw) { + return raw + } + + return "" +} + +func resolveClaudeRequestSessionID(metadata *ClaudeMetadata, preferredSessionID string, contents []GeminiContent) string { + if metadata != nil { + if sessionID := extractSessionIDFromMetadataUserID(metadata.UserID); sessionID != "" { + return sessionID + } + } + + if sessionID := strings.TrimSpace(preferredSessionID); sessionID != "" { + return sessionID + } + + return generateStableSessionID(contents) +} + type TransformOptions struct { EnableIdentityPatch bool // IdentityPatch 可选:自定义注入到 systemInstruction 开头的身份防护提示词; // 为空时使用默认模板(包含 [IDENTITY_PATCH] 及 SYSTEM_PROMPT_BEGIN 标记)。 IdentityPatch string EnableMCPXML bool + // PreferredSessionID 可选:当 metadata.user_id 不可用于恢复真实会话时, + // 允许调用方显式指定 Antigravity 上游 request.sessionId。 + PreferredSessionID string } func DefaultTransformOptions() TransformOptions { @@ -113,11 +163,16 @@ func TransformClaudeToGemini(claudeReq *ClaudeRequest, projectID, mappedModel st // TransformClaudeToGeminiWithOptions 将 Claude 请求转换为 v1internal Gemini 格式(可配置身份补丁等行为) func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, mappedModel string, opts TransformOptions) ([]byte, error) { + normalizedReq, err := normalizeClaudeRequestForAntigravity(claudeReq) + if err != nil { + return nil, fmt.Errorf("normalize messages: %w", err) + } + // 用于存储 tool_use id -> name 映射 toolIDToName := make(map[string]string) // 检测是否有 web_search 工具 - hasWebSearchTool := hasWebSearchTool(claudeReq.Tools) + hasWebSearchTool := hasWebSearchTool(normalizedReq.Tools) // requestType 映射策略: // - Gemini 模型: "agent"(与 Antigravity 官方客户端一致) // - Claude 模型: 不设置(避免 Google 后端路由到容量受限的 agent 池,降低 503 率) @@ -135,27 +190,27 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map } // 检测是否启用 thinking - isThinkingEnabled := claudeReq.Thinking != nil && (claudeReq.Thinking.Type == "enabled" || claudeReq.Thinking.Type == "adaptive") + isThinkingEnabled := normalizedReq.Thinking != nil && (normalizedReq.Thinking.Type == "enabled" || normalizedReq.Thinking.Type == "adaptive") // 只有 Gemini 模型支持 dummy thought workaround // Claude 模型通过 Vertex/Google API 需要有效的 thought signatures allowDummyThought := strings.HasPrefix(targetModel, "gemini-") // 1. 构建 contents - contents, strippedThinking, err := buildContents(claudeReq.Messages, toolIDToName, isThinkingEnabled, allowDummyThought) + contents, strippedThinking, err := buildContents(normalizedReq.Messages, toolIDToName, isThinkingEnabled, allowDummyThought) if err != nil { return nil, fmt.Errorf("build contents: %w", err) } // 2. 构建 systemInstruction(使用 targetModel 而非原始请求模型,确保身份注入基于最终模型) - systemInstruction := buildSystemInstruction(claudeReq.System, targetModel, opts, claudeReq.Tools) + systemInstruction := buildSystemInstruction(normalizedReq.System, targetModel, opts, normalizedReq.Tools) // 3. 构建 generationConfig - reqForConfig := claudeReq + reqForConfig := normalizedReq if strippedThinking { // If we had to downgrade thinking blocks to plain text due to missing/invalid signatures, // disable upstream thinking mode to avoid signature/structure validation errors. - reqCopy := *claudeReq + reqCopy := *normalizedReq reqCopy.Thinking = nil reqForConfig = &reqCopy } @@ -167,31 +222,24 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map generationConfig := buildGenerationConfig(reqForConfig) // 4. 构建 tools - // Claude 模型: 不注入 Gemini functionDeclarations/toolConfig(映射 LSP 调用模式)。 - // Antigravity 官方客户端也不发送 functionDeclarations/toolConfig 给 v1internal API。 - // Claude Code 的工具定义已在 system prompt 里,模型通过 Claude 原生 tool_use 格式调用工具, - // Google v1internal 会将其透传给 Anthropic 后端。 - // Gemini 模型: 保持原有的 functionDeclarations,因为 Gemini 需要结构化的工具定义来触发 function_call。 + // 对 Claude / Gemini 模型都保留 functionDeclarations: + // - Claude 分支如果完全丢掉 tools,模型只能看到消息历史中的 tool_use/tool_result, + // 但拿不到当前可用工具定义,容易导致“能还原名字但不会继续发工具调用”。 + // - Gemini 分支原本就依赖 functionDeclarations 触发 function_call。 isClaudeModel := strings.HasPrefix(targetModel, "claude-") - var tools []GeminiToolDeclaration - if !isClaudeModel { - tools = buildTools(claudeReq.Tools) - } + tools := buildTools(normalizedReq.Tools) // 5. 构建内部请求 innerRequest := GeminiRequest{ - Contents: contents, - // 总是生成 sessionId,基于用户消息内容 - SessionID: generateStableSessionID(contents), + Contents: contents, + SessionID: resolveClaudeRequestSessionID(normalizedReq.Metadata, opts.PreferredSessionID, contents), } - // Gemini 模型需要 toolConfig;Claude 模型不需要(LSP 调用模式) - if !isClaudeModel { - innerRequest.ToolConfig = &GeminiToolConfig{ - FunctionCallingConfig: &GeminiFunctionCallingConfig{ - Mode: "VALIDATED", - }, - } + // Gemini 分支保持默认 VALIDATED; + // Claude 分支仅在声明了工具时附带 toolConfig,避免再把工具能力静默丢失。 + defaultValidated := !isClaudeModel || len(tools) > 0 + if toolConfig := buildToolConfig(normalizedReq.ToolChoice, defaultValidated); toolConfig != nil { + innerRequest.ToolConfig = toolConfig } if systemInstruction != nil { @@ -204,11 +252,6 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map innerRequest.Tools = tools } - // 如果提供了 metadata.user_id,优先使用 - if claudeReq.Metadata != nil && claudeReq.Metadata.UserID != "" { - innerRequest.SessionID = claudeReq.Metadata.UserID - } - // 6. 包装为 v1internal 请求 v1Req := V1InternalRequest{ Project: projectID, @@ -222,6 +265,319 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map return json.Marshal(v1Req) } +const ( + maxAntigravityToolDescriptionChars = 400 + maxAntigravitySchemaDescriptionChars = 200 + maxAntigravityToolResultChars = 200000 +) + +func normalizeClaudeRequestForAntigravity(claudeReq *ClaudeRequest) (*ClaudeRequest, error) { + if claudeReq == nil { + return nil, nil + } + + reqCopy := *claudeReq + if len(claudeReq.Messages) == 0 { + return &reqCopy, nil + } + + normalizedMessages, err := normalizeClaudeMessagesForAntigravity(claudeReq.Messages) + if err != nil { + return nil, err + } + reqCopy.Messages = normalizedMessages + return &reqCopy, nil +} + +func normalizeClaudeMessagesForAntigravity(messages []ClaudeMessage) ([]ClaudeMessage, error) { + normalized := make([]ClaudeMessage, 0, len(messages)+1) + pendingToolUseIDs := make([]string, 0) + + for _, message := range messages { + blocks, hasBlocks := parseClaudeMessageBlocks(message.Content) + + switch message.Role { + case "assistant": + if len(pendingToolUseIDs) > 0 { + synthetic, err := buildSyntheticToolResultMessage(pendingToolUseIDs) + if err != nil { + return nil, err + } + normalized = append(normalized, synthetic) + pendingToolUseIDs = pendingToolUseIDs[:0] + } + + if !hasBlocks { + normalized = append(normalized, cloneClaudeMessage(message)) + continue + } + + stripped := stripNonToolPartsAfterToolUse(reorderAssistantThinkingBlocks(blocks)) + pendingToolUseIDs = append(pendingToolUseIDs, collectToolUseIDs(stripped)...) + + nextMessage, err := buildClaudeMessageWithBlocks(message.Role, stripped) + if err != nil { + return nil, err + } + normalized = append(normalized, nextMessage) + + case "user": + if !hasBlocks { + if len(pendingToolUseIDs) > 0 { + synthetic, err := buildSyntheticToolResultMessage(pendingToolUseIDs) + if err != nil { + return nil, err + } + normalized = append(normalized, synthetic) + pendingToolUseIDs = pendingToolUseIDs[:0] + } + normalized = append(normalized, cloneClaudeMessage(message)) + continue + } + + parts := cloneJSONBlocks(blocks) + if len(pendingToolUseIDs) > 0 { + toolResults, nonToolResults := partitionToolResultBlocks(parts) + existingIDs := collectToolResultIDs(toolResults) + missingIDs := diffStringSlice(pendingToolUseIDs, existingIDs) + if len(missingIDs) > 0 { + parts = append(append(toolResults, buildSyntheticToolResultBlocks(missingIDs)...), nonToolResults...) + } + pendingToolUseIDs = pendingToolUseIDs[:0] + } + + toolResults, nonToolResults := partitionToolResultBlocks(parts) + switch { + case len(toolResults) == 0: + nextMessage, err := buildClaudeMessageWithBlocks(message.Role, parts) + if err != nil { + return nil, err + } + normalized = append(normalized, nextMessage) + case len(nonToolResults) == 0: + nextMessage, err := buildClaudeMessageWithBlocks(message.Role, toolResults) + if err != nil { + return nil, err + } + normalized = append(normalized, nextMessage) + default: + toolResultMessage, err := buildClaudeMessageWithBlocks(message.Role, toolResults) + if err != nil { + return nil, err + } + userTextMessage, err := buildClaudeMessageWithBlocks(message.Role, nonToolResults) + if err != nil { + return nil, err + } + normalized = append(normalized, toolResultMessage, userTextMessage) + } + + default: + normalized = append(normalized, cloneClaudeMessage(message)) + } + } + + if len(pendingToolUseIDs) > 0 { + synthetic, err := buildSyntheticToolResultMessage(pendingToolUseIDs) + if err != nil { + return nil, err + } + normalized = append(normalized, synthetic) + } + + return normalized, nil +} + +func parseClaudeMessageBlocks(content json.RawMessage) ([]map[string]any, bool) { + var blocks []map[string]any + if err := json.Unmarshal(content, &blocks); err != nil { + return nil, false + } + return blocks, true +} + +func cloneClaudeMessage(message ClaudeMessage) ClaudeMessage { + cloned := ClaudeMessage{Role: message.Role} + if len(message.Content) > 0 { + cloned.Content = append(json.RawMessage(nil), message.Content...) + } + return cloned +} + +func cloneJSONBlocks(blocks []map[string]any) []map[string]any { + cloned := make([]map[string]any, 0, len(blocks)) + for _, block := range blocks { + cloned = append(cloned, cloneJSONMap(block)) + } + return cloned +} + +func cloneJSONMap(block map[string]any) map[string]any { + if block == nil { + return nil + } + if cloned, ok := deepCopy(block).(map[string]any); ok { + return cloned + } + fallback := make(map[string]any, len(block)) + for key, value := range block { + fallback[key] = value + } + return fallback +} + +func buildClaudeMessageWithBlocks(role string, blocks []map[string]any) (ClaudeMessage, error) { + payload, err := json.Marshal(blocks) + if err != nil { + return ClaudeMessage{}, fmt.Errorf("marshal %s message blocks: %w", role, err) + } + return ClaudeMessage{Role: role, Content: payload}, nil +} + +func buildSyntheticToolResultMessage(toolUseIDs []string) (ClaudeMessage, error) { + return buildClaudeMessageWithBlocks("user", buildSyntheticToolResultBlocks(toolUseIDs)) +} + +func buildSyntheticToolResultBlocks(toolUseIDs []string) []map[string]any { + blocks := make([]map[string]any, 0, len(toolUseIDs)) + for _, toolUseID := range toolUseIDs { + if strings.TrimSpace(toolUseID) == "" { + continue + } + blocks = append(blocks, map[string]any{ + "type": "tool_result", + "tool_use_id": toolUseID, + "is_error": true, + "content": []map[string]any{ + { + "type": "text", + "text": "[tool_result missing; tool execution interrupted]", + }, + }, + }) + } + return blocks +} + +func reorderAssistantThinkingBlocks(blocks []map[string]any) []map[string]any { + thinkingBlocks := make([]map[string]any, 0) + otherBlocks := make([]map[string]any, 0, len(blocks)) + + for _, block := range blocks { + cloned := cloneJSONMap(block) + blockType, _ := cloned["type"].(string) + if blockType == "thinking" || blockType == "redacted_thinking" { + delete(cloned, "cache_control") + thinkingBlocks = append(thinkingBlocks, cloned) + continue + } + otherBlocks = append(otherBlocks, cloned) + } + + if len(thinkingBlocks) == 0 { + return otherBlocks + } + return append(thinkingBlocks, otherBlocks...) +} + +func stripNonToolPartsAfterToolUse(blocks []map[string]any) []map[string]any { + cleaned := make([]map[string]any, 0, len(blocks)) + seenToolUse := false + + for _, block := range blocks { + blockType, _ := block["type"].(string) + if blockType == "tool_use" { + seenToolUse = true + cleaned = append(cleaned, block) + continue + } + if !seenToolUse { + cleaned = append(cleaned, block) + continue + } + if isIgnorableTrailingTextBlock(block) { + continue + } + } + + return cleaned +} + +func isIgnorableTrailingTextBlock(block map[string]any) bool { + blockType, _ := block["type"].(string) + if blockType != "text" { + return false + } + text, _ := block["text"].(string) + trimmed := strings.TrimSpace(text) + return trimmed == "" || trimmed == "(no content)" +} + +func collectToolUseIDs(blocks []map[string]any) []string { + ids := make([]string, 0) + for _, block := range blocks { + blockType, _ := block["type"].(string) + if blockType != "tool_use" { + continue + } + id, _ := block["id"].(string) + if strings.TrimSpace(id) != "" { + ids = append(ids, id) + } + } + return ids +} + +func collectToolResultIDs(blocks []map[string]any) []string { + ids := make([]string, 0, len(blocks)) + for _, block := range blocks { + id, _ := block["tool_use_id"].(string) + if strings.TrimSpace(id) != "" { + ids = append(ids, id) + } + } + return ids +} + +func diffStringSlice(left, right []string) []string { + if len(left) == 0 { + return nil + } + seen := make(map[string]struct{}, len(right)) + for _, value := range right { + if strings.TrimSpace(value) != "" { + seen[value] = struct{}{} + } + } + + diff := make([]string, 0, len(left)) + for _, value := range left { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + diff = append(diff, value) + } + return diff +} + +func partitionToolResultBlocks(blocks []map[string]any) (toolResults []map[string]any, nonToolResults []map[string]any) { + toolResults = make([]map[string]any, 0) + nonToolResults = make([]map[string]any, 0) + for _, block := range blocks { + blockType, _ := block["type"].(string) + if blockType == "tool_result" { + toolResults = append(toolResults, block) + continue + } + nonToolResults = append(nonToolResults, block) + } + return toolResults, nonToolResults +} + // antigravityIdentity Antigravity identity 提示词 const antigravityIdentity = ` You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding. @@ -521,13 +877,14 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu case "tool_use": // 存储 id -> name 映射 - if block.ID != "" && block.Name != "" { - toolIDToName[block.ID] = block.Name + toolName := normalizeClaudeCodeToolName(block.Name) + if block.ID != "" && toolName != "" { + toolIDToName[block.ID] = toolName } part := GeminiPart{ FunctionCall: &GeminiFunctionCall{ - Name: block.Name, + Name: toolName, Args: block.Input, ID: block.ID, }, @@ -544,12 +901,12 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu case "tool_result": // 获取函数名 - funcName := block.Name + funcName := normalizeClaudeCodeToolName(block.Name) if funcName == "" { if name, ok := toolIDToName[block.ToolUseID]; ok { funcName = name } else { - funcName = block.ToolUseID + funcName = normalizeClaudeCodeToolName(block.ToolUseID) } } @@ -572,47 +929,84 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu } // parseToolResultContent 解析 tool_result 的 content -func parseToolResultContent(content json.RawMessage, isError bool) string { +func parseToolResultContent(content json.RawMessage, isError bool) any { if len(content) == 0 { - if isError { - return "Tool execution failed with no output." - } - return "Command executed successfully." + return defaultToolResultContent(isError) } // 尝试解析为字符串 var str string if err := json.Unmarshal(content, &str); err == nil { if strings.TrimSpace(str) == "" { - if isError { - return "Tool execution failed with no output." - } - return "Command executed successfully." + return defaultToolResultContent(isError) } - return str + return truncateInlineText(str, maxAntigravityToolResultChars) } - // 尝试解析为数组 + // 优先保留结构化 tool_result,避免上游把内容视为无效的纯文本降级。 var arr []map[string]any if err := json.Unmarshal(content, &arr); err == nil { - var texts []string - for _, item := range arr { - if text, ok := item["text"].(string); ok { - texts = append(texts, text) - } + sanitized := sanitizeToolResultBlocksForAntigravity(arr) + if len(sanitized) == 0 { + return defaultToolResultContent(isError) } - result := strings.Join(texts, "\n") - if strings.TrimSpace(result) == "" { - if isError { - return "Tool execution failed with no output." - } - return "Command executed successfully." + return sanitized + } + + var obj map[string]any + if err := json.Unmarshal(content, &obj); err == nil { + sanitized := sanitizeToolResultObjectForAntigravity(obj) + if len(sanitized) == 0 { + return defaultToolResultContent(isError) } - return result + return sanitized } // 返回原始 JSON - return string(content) + return truncateInlineText(string(content), maxAntigravityToolResultChars) +} + +func defaultToolResultContent(isError bool) string { + if isError { + return "Tool execution failed with no output." + } + return "Command executed successfully." +} + +func sanitizeToolResultBlocksForAntigravity(blocks []map[string]any) []map[string]any { + sanitized := make([]map[string]any, 0, len(blocks)) + for _, block := range blocks { + if isBase64ImageToolResultBlock(block) { + continue + } + cloned := cloneJSONMap(block) + if text, ok := cloned["text"].(string); ok { + cloned["text"] = truncateInlineText(text, maxAntigravityToolResultChars) + } + sanitized = append(sanitized, cloned) + } + return sanitized +} + +func sanitizeToolResultObjectForAntigravity(block map[string]any) map[string]any { + if isBase64ImageToolResultBlock(block) { + return nil + } + cloned := cloneJSONMap(block) + if text, ok := cloned["text"].(string); ok { + cloned["text"] = truncateInlineText(text, maxAntigravityToolResultChars) + } + return cloned +} + +func isBase64ImageToolResultBlock(block map[string]any) bool { + blockType, _ := block["type"].(string) + if blockType != "image" { + return false + } + source, _ := block["source"].(map[string]any) + sourceType, _ := source["type"].(string) + return sourceType == "base64" } // buildGenerationConfig 构建 generationConfig @@ -728,6 +1122,65 @@ func isWebSearchTool(tool ClaudeTool) bool { } } +func buildToolConfig(toolChoice json.RawMessage, defaultValidated bool) *GeminiToolConfig { + raw := bytes.TrimSpace(toolChoice) + if len(raw) == 0 { + if !defaultValidated { + return nil + } + return &GeminiToolConfig{ + FunctionCallingConfig: &GeminiFunctionCallingConfig{ + Mode: "VALIDATED", + }, + } + } + + choiceType := "" + toolName := "" + + if len(raw) > 0 && raw[0] == '"' { + var choice string + if err := json.Unmarshal(raw, &choice); err == nil { + choiceType = strings.TrimSpace(choice) + } + } else { + var choice map[string]any + if err := json.Unmarshal(raw, &choice); err == nil { + if value, ok := choice["type"].(string); ok { + choiceType = strings.TrimSpace(value) + } + if value, ok := choice["name"].(string); ok { + toolName = normalizeClaudeCodeToolName(value) + } + } + } + + mode := "" + switch strings.ToLower(choiceType) { + case "auto": + mode = "AUTO" + case "none": + mode = "NONE" + case "any", "required": + mode = "ANY" + case "tool": + mode = "ANY" + case "validated": + mode = "VALIDATED" + default: + if !defaultValidated { + return nil + } + mode = "VALIDATED" + } + + cfg := &GeminiFunctionCallingConfig{Mode: mode} + if toolName != "" && mode == "ANY" { + cfg.AllowedFunctionNames = []string{toolName} + } + return &GeminiToolConfig{FunctionCallingConfig: cfg} +} + // buildTools 构建 tools func buildTools(tools []ClaudeTool) []GeminiToolDeclaration { if len(tools) == 0 { @@ -758,12 +1211,12 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration { continue } description = tool.Custom.Description - inputSchema = tool.Custom.InputSchema + inputSchema = cloneStringAnyMap(tool.Custom.InputSchema) } else { // 标准格式: 从顶层字段获取 description = tool.Description - inputSchema = tool.InputSchema + inputSchema = cloneStringAnyMap(tool.InputSchema) } // 清理 JSON Schema @@ -778,9 +1231,11 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration { "properties": map[string]any{}, } } + description = compactToolDescriptionForAntigravity(description) + params = compactSchemaDescriptionsForAntigravity(params) funcDecls = append(funcDecls, GeminiFunctionDecl{ - Name: tool.Name, + Name: normalizeClaudeCodeToolName(tool.Name), Description: description, Parameters: params, }) @@ -809,3 +1264,64 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration { return declarations } + +func cloneStringAnyMap(input map[string]any) map[string]any { + if input == nil { + return nil + } + if cloned, ok := deepCopy(input).(map[string]any); ok { + return cloned + } + fallback := make(map[string]any, len(input)) + for key, value := range input { + fallback[key] = value + } + return fallback +} + +func compactToolDescriptionForAntigravity(description string) string { + if strings.TrimSpace(description) == "" { + return "" + } + lines := strings.Split(strings.ReplaceAll(description, "\r\n", "\n"), "\n") + compacted := make([]string, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + compacted = append(compacted, line) + if len(compacted) == 6 { + break + } + } + return truncateInlineText(strings.Join(compacted, " "), maxAntigravityToolDescriptionChars) +} + +func compactSchemaDescriptionsForAntigravity(schema map[string]any) map[string]any { + for key, value := range schema { + switch typed := value.(type) { + case string: + if key == "description" { + schema[key] = truncateInlineText(strings.Join(strings.Fields(typed), " "), maxAntigravitySchemaDescriptionChars) + } + case map[string]any: + schema[key] = compactSchemaDescriptionsForAntigravity(typed) + case []any: + for i, item := range typed { + if nested, ok := item.(map[string]any); ok { + typed[i] = compactSchemaDescriptionsForAntigravity(nested) + } + } + schema[key] = typed + } + } + return schema +} + +func truncateInlineText(text string, maxChars int) string { + if maxChars <= 0 || len(text) <= maxChars { + return text + } + return text[:maxChars] + "...[truncated " + strconv.Itoa(len(text)-maxChars) + " chars]" +} diff --git a/backend/internal/pkg/antigravity/request_transformer_test.go b/backend/internal/pkg/antigravity/request_transformer_test.go index a7ee8c2d..f5e01379 100644 --- a/backend/internal/pkg/antigravity/request_transformer_test.go +++ b/backend/internal/pkg/antigravity/request_transformer_test.go @@ -45,6 +45,75 @@ func TestEnsureGeminiRequestSessionID(t *testing.T) { }) } +func TestTransformClaudeToGeminiWithOptions_UsesMetadataSessionIDJSON(t *testing.T) { + claudeReq := &ClaudeRequest{ + Model: "claude-sonnet-4-5", + Messages: []ClaudeMessage{ + { + Role: "user", + Content: json.RawMessage(`[{"type":"text","text":"hello"}]`), + }, + }, + Metadata: &ClaudeMetadata{ + UserID: `{"device_id":"d61f76d0aabbccdd00112233445566778899aabbccddeeff0011223344556677","account_uuid":"acc-uuid","session_id":"c72554f2-1234-5678-abcd-123456789abc"}`, + }, + } + + body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "claude-sonnet-4-5", DefaultTransformOptions()) + require.NoError(t, err) + + var req V1InternalRequest + require.NoError(t, json.Unmarshal(body, &req)) + require.Equal(t, "c72554f2-1234-5678-abcd-123456789abc", req.Request.SessionID) +} + +func TestTransformClaudeToGeminiWithOptions_UsesMetadataSessionIDLegacy(t *testing.T) { + claudeReq := &ClaudeRequest{ + Model: "claude-sonnet-4-5", + Messages: []ClaudeMessage{ + { + Role: "user", + Content: json.RawMessage(`[{"type":"text","text":"hello"}]`), + }, + }, + Metadata: &ClaudeMetadata{ + UserID: "user_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2_account_550e8400-e29b-41d4-a716-446655440000_session_123e4567-e89b-12d3-a456-426614174000", + }, + } + + body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "claude-sonnet-4-5", DefaultTransformOptions()) + require.NoError(t, err) + + var req V1InternalRequest + require.NoError(t, json.Unmarshal(body, &req)) + require.Equal(t, "123e4567-e89b-12d3-a456-426614174000", req.Request.SessionID) +} + +func TestTransformClaudeToGeminiWithOptions_PrefersExplicitSessionWhenMetadataIsNotSessionPayload(t *testing.T) { + opts := DefaultTransformOptions() + opts.PreferredSessionID = "session-header-1" + + claudeReq := &ClaudeRequest{ + Model: "claude-sonnet-4-5", + Messages: []ClaudeMessage{ + { + Role: "user", + Content: json.RawMessage(`[{"type":"text","text":"hello"}]`), + }, + }, + Metadata: &ClaudeMetadata{ + UserID: "custom-user-42", + }, + } + + body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "claude-sonnet-4-5", opts) + require.NoError(t, err) + + var req V1InternalRequest + require.NoError(t, json.Unmarshal(body, &req)) + require.Equal(t, "session-header-1", req.Request.SessionID) +} + // TestBuildParts_ThinkingBlockWithoutSignature 测试thinking block无signature时的处理 func TestBuildParts_ThinkingBlockWithoutSignature(t *testing.T) { tests := []struct { @@ -513,3 +582,214 @@ func TestTransformClaudeToGeminiWithOptions_PreservesWebSearchAlongsideFunctions require.Equal(t, "get_weather", req.Request.Tools[0].FunctionDeclarations[0].Name) require.NotNil(t, req.Request.Tools[1].GoogleSearch) } + +func TestTransformClaudeToGeminiWithOptions_ClaudeModelKeepsToolsAndValidatedToolConfig(t *testing.T) { + claudeReq := &ClaudeRequest{ + Model: "claude-sonnet-4-5", + Messages: []ClaudeMessage{ + { + Role: "user", + Content: json.RawMessage(`[{"type":"text","text":"read the file"}]`), + }, + }, + Tools: []ClaudeTool{ + { + Name: "read_file", + Description: "Read a file", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "file_path": map[string]any{"type": "string"}, + }, + }, + }, + }, + } + + body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "claude-sonnet-4-5", DefaultTransformOptions()) + require.NoError(t, err) + + var req V1InternalRequest + require.NoError(t, json.Unmarshal(body, &req)) + require.Len(t, req.Request.Tools, 1) + require.Len(t, req.Request.Tools[0].FunctionDeclarations, 1) + require.Equal(t, "Read", req.Request.Tools[0].FunctionDeclarations[0].Name) + require.NotNil(t, req.Request.ToolConfig) + require.NotNil(t, req.Request.ToolConfig.FunctionCallingConfig) + require.Equal(t, "VALIDATED", req.Request.ToolConfig.FunctionCallingConfig.Mode) +} + +func TestTransformClaudeToGeminiWithOptions_ClaudeModelToolChoiceSpecificTool(t *testing.T) { + claudeReq := &ClaudeRequest{ + Model: "claude-sonnet-4-5", + ToolChoice: json.RawMessage(`{"type":"tool","name":"search_files"}`), + Messages: []ClaudeMessage{ + { + Role: "user", + Content: json.RawMessage(`[{"type":"text","text":"find todo"}]`), + }, + }, + Tools: []ClaudeTool{ + { + Name: "search_files", + Description: "Search files", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "pattern": map[string]any{"type": "string"}, + }, + }, + }, + }, + } + + body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "claude-sonnet-4-5", DefaultTransformOptions()) + require.NoError(t, err) + + var req V1InternalRequest + require.NoError(t, json.Unmarshal(body, &req)) + require.NotNil(t, req.Request.ToolConfig) + require.NotNil(t, req.Request.ToolConfig.FunctionCallingConfig) + require.Equal(t, "ANY", req.Request.ToolConfig.FunctionCallingConfig.Mode) + require.Equal(t, []string{"Grep"}, req.Request.ToolConfig.FunctionCallingConfig.AllowedFunctionNames) +} + +func TestTransformClaudeToGeminiWithOptions_NormalizesInterruptedToolHistory(t *testing.T) { + claudeReq := &ClaudeRequest{ + Model: "claude-sonnet-4-5", + Messages: []ClaudeMessage{ + { + Role: "assistant", + Content: json.RawMessage(`[ + {"type":"tool_use","id":"tool-1","name":"Bash","input":{"command":"pwd"}}, + {"type":"text","text":"(no content)"} + ]`), + }, + { + Role: "user", + Content: json.RawMessage(`[{"type":"text","text":"继续"}]`), + }, + }, + } + + body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "claude-sonnet-4-5", DefaultTransformOptions()) + require.NoError(t, err) + + var req V1InternalRequest + require.NoError(t, json.Unmarshal(body, &req)) + require.Len(t, req.Request.Contents, 3) + + first := req.Request.Contents[0] + require.Equal(t, "model", first.Role) + require.Len(t, first.Parts, 1) + require.NotNil(t, first.Parts[0].FunctionCall) + require.Equal(t, "tool-1", first.Parts[0].FunctionCall.ID) + + second := req.Request.Contents[1] + require.Equal(t, "user", second.Role) + require.Len(t, second.Parts, 1) + require.NotNil(t, second.Parts[0].FunctionResponse) + require.Equal(t, "tool-1", second.Parts[0].FunctionResponse.ID) + resultBlocks, ok := second.Parts[0].FunctionResponse.Response["result"].([]any) + require.True(t, ok) + require.Len(t, resultBlocks, 1) + resultBlock, ok := resultBlocks[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "text", resultBlock["type"]) + require.Equal(t, "[tool_result missing; tool execution interrupted]", resultBlock["text"]) + + third := req.Request.Contents[2] + require.Equal(t, "user", third.Role) + require.Len(t, third.Parts, 1) + require.Equal(t, "继续", third.Parts[0].Text) +} + +func TestNormalizeClaudeMessagesForAntigravity_ReordersThinkingAndSplitsToolResult(t *testing.T) { + messages := []ClaudeMessage{ + { + Role: "assistant", + Content: json.RawMessage(`[ + {"type":"text","text":"before"}, + {"type":"thinking","thinking":"deep thought","signature":"sig-1"}, + {"type":"tool_use","id":"tool-2","name":"Bash","input":{"command":"ls"}}, + {"type":"text","text":"(no content)"} + ]`), + }, + { + Role: "user", + Content: json.RawMessage(`[ + {"type":"tool_result","tool_use_id":"tool-2","content":[{"type":"text","text":"ok"}]}, + {"type":"text","text":"下一步"} + ]`), + }, + } + + normalized, err := normalizeClaudeMessagesForAntigravity(messages) + require.NoError(t, err) + require.Len(t, normalized, 3) + + var assistantBlocks []map[string]any + require.NoError(t, json.Unmarshal(normalized[0].Content, &assistantBlocks)) + require.Len(t, assistantBlocks, 3) + require.Equal(t, "thinking", assistantBlocks[0]["type"]) + require.Equal(t, "text", assistantBlocks[1]["type"]) + require.Equal(t, "tool_use", assistantBlocks[2]["type"]) + + var toolResultBlocks []map[string]any + require.NoError(t, json.Unmarshal(normalized[1].Content, &toolResultBlocks)) + require.Len(t, toolResultBlocks, 1) + require.Equal(t, "tool_result", toolResultBlocks[0]["type"]) + + var userTextBlocks []map[string]any + require.NoError(t, json.Unmarshal(normalized[2].Content, &userTextBlocks)) + require.Len(t, userTextBlocks, 1) + require.Equal(t, "text", userTextBlocks[0]["type"]) + require.Equal(t, "下一步", userTextBlocks[0]["text"]) +} + +func TestParseToolResultContent_PreservesStructuredBlocks(t *testing.T) { + content := json.RawMessage(`[ + {"type":"text","text":"hello"}, + {"type":"image","source":{"type":"base64","media_type":"image/png","data":"AAAA"}} + ]`) + + result := parseToolResultContent(content, false) + blocks, ok := result.([]map[string]any) + require.True(t, ok) + require.Len(t, blocks, 1) + require.Equal(t, "text", blocks[0]["type"]) + require.Equal(t, "hello", blocks[0]["text"]) +} + +func TestBuildTools_CompactsDescriptions(t *testing.T) { + longLine := strings.Repeat("schema detail ", 40) + result := buildTools([]ClaudeTool{ + { + Name: "describe", + Description: strings.Repeat("tool description\n", 20), + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{ + "type": "string", + "description": longLine, + }, + }, + }, + }, + }) + + require.Len(t, result, 1) + require.Len(t, result[0].FunctionDeclarations, 1) + + decl := result[0].FunctionDeclarations[0] + require.LessOrEqual(t, len(decl.Description), maxAntigravityToolDescriptionChars+32) + + props, ok := decl.Parameters["properties"].(map[string]any) + require.True(t, ok) + query, ok := props["query"].(map[string]any) + require.True(t, ok) + description, ok := query["description"].(string) + require.True(t, ok) + require.LessOrEqual(t, len(description), maxAntigravitySchemaDescriptionChars+32) +} diff --git a/backend/internal/pkg/antigravity/response_transformer.go b/backend/internal/pkg/antigravity/response_transformer.go index bc1fd32e..0688d7f9 100644 --- a/backend/internal/pkg/antigravity/response_transformer.go +++ b/backend/internal/pkg/antigravity/response_transformer.go @@ -121,17 +121,20 @@ func (p *NonStreamingProcessor) processPart(part *GeminiPart) { p.hasToolCall = true + toolName := normalizeClaudeCodeToolName(part.FunctionCall.Name) + // 生成 tool_use id toolID := part.FunctionCall.ID if toolID == "" { - toolID = fmt.Sprintf("%s-%s", part.FunctionCall.Name, generateRandomID()) + toolID = fmt.Sprintf("%s-%s", toolName, generateRandomID()) } item := ClaudeContentItem{ - Type: "tool_use", - ID: toolID, - Name: part.FunctionCall.Name, - Input: part.FunctionCall.Args, + Type: "tool_use", + ID: toolID, + Name: toolName, + Input: part.FunctionCall.Args, + Caller: &ToolCaller{Type: "direct"}, } if signature != "" { diff --git a/backend/internal/pkg/antigravity/stream_transformer.go b/backend/internal/pkg/antigravity/stream_transformer.go index 58982878..9c03bc24 100644 --- a/backend/internal/pkg/antigravity/stream_transformer.go +++ b/backend/internal/pkg/antigravity/stream_transformer.go @@ -329,17 +329,21 @@ func (p *StreamingProcessor) processFunctionCall(fc *GeminiFunctionCall, signatu var result bytes.Buffer p.usedTool = true + toolName := normalizeClaudeCodeToolName(fc.Name) toolID := fc.ID if toolID == "" { - toolID = fmt.Sprintf("%s-%s", fc.Name, generateRandomID()) + toolID = fmt.Sprintf("%s-%s", toolName, generateRandomID()) } toolUse := map[string]any{ "type": "tool_use", "id": toolID, - "name": fc.Name, + "name": toolName, "input": map[string]any{}, + "caller": map[string]any{ + "type": "direct", + }, } if signature != "" { diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index 081dece4..1952e32b 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -1,19 +1,220 @@ // Package claude provides constants and helpers for Claude API integration. package claude -import "strings" +import ( + "fmt" + "os" + "strings" +) // Claude Code 客户端相关常量 // DefaultCLIVersion 是当前模拟的 Claude CLI 版本 -const DefaultCLIVersion = "2.1.88" +var ( + DefaultCLIVersion = "2.1.104" + DefaultStainlessLang = "js" + DefaultStainlessPackageVersion = "0.81.0" + DefaultStainlessOS = "MacOS" + DefaultStainlessArch = "arm64" + DefaultStainlessRuntime = "node" + DefaultStainlessRuntimeVersion = "v24.3.0" + DefaultStainlessRetryCount = "0" + DefaultStainlessTimeout = "600" + DefaultXApp = "cli" + DefaultAnthropicVersion = "2023-06-01" +) + +// DeviceProfile 表示一组 Claude Code 客户端设备画像默认值。 +type DeviceProfile struct { + UserAgent string + StainlessLang string + StainlessPackageVersion string + StainlessOS string + StainlessArch string + StainlessRuntime string + StainlessRuntimeVersion string + StainlessRetryCount string + StainlessTimeout string + XApp string + AnthropicVersion string +} + +func trimEnv(key string) string { + return strings.TrimSpace(os.Getenv(key)) +} + +func envTruthy(key string) bool { + switch strings.ToLower(trimEnv(key)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +func envExplicitFalse(key string) bool { + switch strings.ToLower(trimEnv(key)) { + case "0", "false", "no", "off": + return true + default: + return false + } +} + +// CurrentEntrypoint returns the Claude Code entrypoint label used by the real CLI. +// The local bundle defaults to "cli" when CLAUDE_CODE_ENTRYPOINT is not set. +func CurrentEntrypoint() string { + if entrypoint := trimEnv("CLAUDE_CODE_ENTRYPOINT"); entrypoint != "" { + return entrypoint + } + return "cli" +} + +func currentAgentSDKVersion() string { + return trimEnv("CLAUDE_AGENT_SDK_VERSION") +} + +func currentClientApp() string { + return trimEnv("CLAUDE_AGENT_SDK_CLIENT_APP") +} + +// CurrentWorkload returns the process-scoped workload tag used for cc_workload attribution. +// Local Claude Code keeps this in-process; sub2api exposes an env fallback so the server can +// mirror cron/daemon callers when configured. +func CurrentWorkload() string { + return trimEnv("CLAUDE_CODE_WORKLOAD") +} + +func currentCLIUserAgentDescriptors() []string { + parts := []string{CurrentEntrypoint()} + if sdkVersion := currentAgentSDKVersion(); sdkVersion != "" { + parts = append(parts, "agent-sdk/"+sdkVersion) + } + if clientApp := currentClientApp(); clientApp != "" { + parts = append(parts, "client-app/"+clientApp) + } + if workload := CurrentWorkload(); workload != "" { + parts = append(parts, "workload/"+workload) + } + return parts +} + +func currentCodeUserAgentDescriptors() []string { + parts := make([]string, 0, 3) + if entrypoint := trimEnv("CLAUDE_CODE_ENTRYPOINT"); entrypoint != "" { + parts = append(parts, entrypoint) + } + if sdkVersion := currentAgentSDKVersion(); sdkVersion != "" { + parts = append(parts, "agent-sdk/"+sdkVersion) + } + if clientApp := currentClientApp(); clientApp != "" { + parts = append(parts, "client-app/"+clientApp) + } + return parts +} + +func formatUserAgent(cliVersion string) string { + version := strings.TrimSpace(cliVersion) + if version == "" { + version = DefaultCLIVersion + } + return fmt.Sprintf("claude-cli/%s (external, %s)", version, strings.Join(currentCLIUserAgentDescriptors(), ", ")) +} + +func DefaultUserAgent() string { + return formatUserAgent(DefaultCLIVersion) +} + +func DefaultCodeUserAgent() string { + return "claude-code/" + strings.TrimSpace(DefaultCLIVersion) +} + +// DetailedCodeUserAgent mirrors the local JqH() builder, which appends entrypoint / agent-sdk / +// client-app descriptors when they are present in the process environment. +func DetailedCodeUserAgent() string { + version := strings.TrimSpace(DefaultCLIVersion) + if version == "" { + version = "unknown" + } + parts := currentCodeUserAgentDescriptors() + if len(parts) == 0 { + return "claude-code/" + version + } + return fmt.Sprintf("claude-code/%s (%s)", version, strings.Join(parts, ", ")) +} + +// DefaultDeviceProfile 返回当前默认 Claude Code 设备画像。 +func DefaultDeviceProfile() DeviceProfile { + return DeviceProfile{ + UserAgent: DefaultUserAgent(), + StainlessLang: DefaultStainlessLang, + StainlessPackageVersion: DefaultStainlessPackageVersion, + StainlessOS: DefaultStainlessOS, + StainlessArch: DefaultStainlessArch, + StainlessRuntime: DefaultStainlessRuntime, + StainlessRuntimeVersion: DefaultStainlessRuntimeVersion, + StainlessRetryCount: DefaultStainlessRetryCount, + StainlessTimeout: DefaultStainlessTimeout, + XApp: DefaultXApp, + AnthropicVersion: DefaultAnthropicVersion, + } +} + +func buildDefaultHeaders(profile DeviceProfile) map[string]string { + return 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": profile.UserAgent, + "X-Stainless-Lang": profile.StainlessLang, + "X-Stainless-Package-Version": profile.StainlessPackageVersion, + "X-Stainless-OS": profile.StainlessOS, + "X-Stainless-Arch": profile.StainlessArch, + "X-Stainless-Runtime": profile.StainlessRuntime, + "X-Stainless-Runtime-Version": profile.StainlessRuntimeVersion, + "X-Stainless-Retry-Count": profile.StainlessRetryCount, + "X-Stainless-Timeout": profile.StainlessTimeout, + "X-App": profile.XApp, + "anthropic-version": profile.AnthropicVersion, + "anthropic-dangerous-direct-browser-access": "true", + } +} + +// DefaultHeadersSnapshot returns a fresh copy of the default Claude Code header skeleton. +// It re-evaluates env-backed runtime values like CLAUDE_CODE_ENTRYPOINT on each call. +func DefaultHeadersSnapshot() map[string]string { + return buildDefaultHeaders(DefaultDeviceProfile()) +} + +// OptionalAPIHeaders returns the local Claude Code env-driven optional headers that are only +// attached in remote / SDK / protected modes. +func OptionalAPIHeaders() map[string]string { + headers := map[string]string{} + if containerID := trimEnv("CLAUDE_CODE_CONTAINER_ID"); containerID != "" { + headers["x-claude-remote-container-id"] = containerID + } + if remoteSessionID := trimEnv("CLAUDE_CODE_REMOTE_SESSION_ID"); remoteSessionID != "" { + headers["x-claude-remote-session-id"] = remoteSessionID + } + if clientApp := currentClientApp(); clientApp != "" { + headers["x-client-app"] = clientApp + } + if envTruthy("CLAUDE_CODE_ADDITIONAL_PROTECTION") { + headers["x-anthropic-additional-protection"] = "true" + } + return headers +} + +// AttributionHeaderDisabled mirrors the official CLAUDE_CODE_ATTRIBUTION_HEADER=false toggle. +func AttributionHeaderDisabled() bool { + return envExplicitFalse("CLAUDE_CODE_ATTRIBUTION_HEADER") +} // Beta header 常量 const ( BetaOAuth = "oauth-2025-04-20" BetaClaudeCode = "claude-code-20250219" BetaInterleavedThinking = "interleaved-thinking-2025-05-14" - BetaFineGrainedToolStreaming = "fine-grained-tool-streaming-2025-05-14" + BetaFineGrainedToolStreaming = "fine-grained-tool-streaming-2025-05-14" BetaTokenCounting = "token-counting-2024-11-01" BetaContext1M = "context-1m-2025-08-07" BetaFastMode = "fast-mode-2026-02-01" @@ -61,7 +262,7 @@ const APIKeyBetaHeader = BetaClaudeCode + "," + BetaInterleavedThinking + "," + const APIKeyHaikuBetaHeader = BetaInterleavedThinking + "," + BetaEffort // ModelSupports1M 判断模型是否支持 1M context window。 -// 与 claude-code-2.1.88 bundle 中 modelSupports1M 逻辑保持一致: +// 与 claude-code-2.1.104 bundle 中 modelSupports1M 逻辑保持一致: // // claude-sonnet-4 系列 和 claude-opus-4-6 支持 1M context。 func ModelSupports1M(modelID string) bool { @@ -91,21 +292,7 @@ func GetAPIKeyBetaHeader(modelID string) string { } // 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/" + DefaultCLIVersion + " (external, cli)", - "X-Stainless-Lang": "js", - "X-Stainless-Package-Version": "0.74.0", - "X-Stainless-OS": "MacOS", - "X-Stainless-Arch": "arm64", - "X-Stainless-Runtime": "node", - "X-Stainless-Runtime-Version": "v24.3.0", - "X-Stainless-Retry-Count": "0", - "X-Stainless-Timeout": "600", - "X-App": "cli", - "anthropic-version": "2023-06-01", -} +var DefaultHeaders = buildDefaultHeaders(DefaultDeviceProfile()) // ApplyFingerprintOverrides 用配置覆盖默认指纹值(每个实例可设不同值) // cliVersion: Claude CLI 版本(如 "2.1.81") @@ -115,20 +302,21 @@ var DefaultHeaders = map[string]string{ // arch: 架构(如 "arm64") func ApplyFingerprintOverrides(cliVersion, pkgVersion, runtimeVersion, os_, arch string) { if cliVersion != "" { - DefaultHeaders["User-Agent"] = "claude-cli/" + cliVersion + " (external, cli)" + DefaultCLIVersion = strings.TrimSpace(cliVersion) } if pkgVersion != "" { - DefaultHeaders["X-Stainless-Package-Version"] = pkgVersion + DefaultStainlessPackageVersion = strings.TrimSpace(pkgVersion) } if runtimeVersion != "" { - DefaultHeaders["X-Stainless-Runtime-Version"] = runtimeVersion + DefaultStainlessRuntimeVersion = strings.TrimSpace(runtimeVersion) } if os_ != "" { - DefaultHeaders["X-Stainless-OS"] = os_ + DefaultStainlessOS = strings.TrimSpace(os_) } if arch != "" { - DefaultHeaders["X-Stainless-Arch"] = arch + DefaultStainlessArch = strings.TrimSpace(arch) } + DefaultHeaders = buildDefaultHeaders(DefaultDeviceProfile()) } // Model 表示一个 Claude 模型 diff --git a/backend/internal/pkg/claude/constants_test.go b/backend/internal/pkg/claude/constants_test.go new file mode 100644 index 00000000..b44ca96a --- /dev/null +++ b/backend/internal/pkg/claude/constants_test.go @@ -0,0 +1,121 @@ +package claude + +import "testing" + +func TestApplyFingerprintOverridesUpdatesSharedDefaults(t *testing.T) { + t.Setenv("CLAUDE_CODE_ENTRYPOINT", "") + t.Setenv("CLAUDE_AGENT_SDK_VERSION", "") + t.Setenv("CLAUDE_AGENT_SDK_CLIENT_APP", "") + t.Setenv("CLAUDE_CODE_WORKLOAD", "") + + originalVersion := DefaultCLIVersion + originalPackageVersion := DefaultStainlessPackageVersion + originalRuntimeVersion := DefaultStainlessRuntimeVersion + originalOS := DefaultStainlessOS + originalArch := DefaultStainlessArch + + t.Cleanup(func() { + ApplyFingerprintOverrides( + originalVersion, + originalPackageVersion, + originalRuntimeVersion, + originalOS, + originalArch, + ) + }) + + ApplyFingerprintOverrides("2.1.999", "0.99.0", "v99.0.0", "Linux", "x64") + + if got := DefaultCLIVersion; got != "2.1.999" { + t.Fatalf("DefaultCLIVersion = %q, want %q", got, "2.1.999") + } + if got := DefaultUserAgent(); got != "claude-cli/2.1.999 (external, cli)" { + t.Fatalf("DefaultUserAgent() = %q", got) + } + + profile := DefaultDeviceProfile() + if profile.StainlessPackageVersion != "0.99.0" { + t.Fatalf("DefaultDeviceProfile().StainlessPackageVersion = %q", profile.StainlessPackageVersion) + } + if profile.StainlessRuntimeVersion != "v99.0.0" { + t.Fatalf("DefaultDeviceProfile().StainlessRuntimeVersion = %q", profile.StainlessRuntimeVersion) + } + if profile.StainlessOS != "Linux" { + t.Fatalf("DefaultDeviceProfile().StainlessOS = %q", profile.StainlessOS) + } + if profile.StainlessArch != "x64" { + t.Fatalf("DefaultDeviceProfile().StainlessArch = %q", profile.StainlessArch) + } + + if got := DefaultHeaders["User-Agent"]; got != profile.UserAgent { + t.Fatalf("DefaultHeaders[User-Agent] = %q, want %q", got, profile.UserAgent) + } + if got := DefaultHeaders["X-Stainless-Package-Version"]; got != profile.StainlessPackageVersion { + t.Fatalf("DefaultHeaders[X-Stainless-Package-Version] = %q, want %q", got, profile.StainlessPackageVersion) + } + if got := DefaultHeaders["X-Stainless-Runtime-Version"]; got != profile.StainlessRuntimeVersion { + t.Fatalf("DefaultHeaders[X-Stainless-Runtime-Version] = %q, want %q", got, profile.StainlessRuntimeVersion) + } + if got := DefaultHeaders["X-Stainless-OS"]; got != profile.StainlessOS { + t.Fatalf("DefaultHeaders[X-Stainless-OS] = %q, want %q", got, profile.StainlessOS) + } + if got := DefaultHeaders["X-Stainless-Arch"]; got != profile.StainlessArch { + t.Fatalf("DefaultHeaders[X-Stainless-Arch] = %q, want %q", got, profile.StainlessArch) + } + if got := DefaultHeaders["anthropic-dangerous-direct-browser-access"]; got != "true" { + t.Fatalf("DefaultHeaders[anthropic-dangerous-direct-browser-access] = %q, want %q", got, "true") + } +} + +func TestDefaultUserAgentReflectsLocalRuntimeDescriptors(t *testing.T) { + t.Setenv("CLAUDE_CODE_ENTRYPOINT", "sdk-cli") + t.Setenv("CLAUDE_AGENT_SDK_VERSION", "0.0.77") + t.Setenv("CLAUDE_AGENT_SDK_CLIENT_APP", "cron-daemon") + t.Setenv("CLAUDE_CODE_WORKLOAD", "cron") + + got := DefaultUserAgent() + want := "claude-cli/2.1.104 (external, sdk-cli, agent-sdk/0.0.77, client-app/cron-daemon, workload/cron)" + if got != want { + t.Fatalf("DefaultUserAgent() = %q, want %q", got, want) + } +} + +func TestDetailedCodeUserAgentReflectsEntrypointAndSDK(t *testing.T) { + t.Setenv("CLAUDE_CODE_ENTRYPOINT", "remote") + t.Setenv("CLAUDE_AGENT_SDK_VERSION", "0.0.77") + t.Setenv("CLAUDE_AGENT_SDK_CLIENT_APP", "desktop") + + got := DetailedCodeUserAgent() + want := "claude-code/2.1.104 (remote, agent-sdk/0.0.77, client-app/desktop)" + if got != want { + t.Fatalf("DetailedCodeUserAgent() = %q, want %q", got, want) + } +} + +func TestOptionalAPIHeaders(t *testing.T) { + t.Setenv("CLAUDE_CODE_CONTAINER_ID", "ctr-123") + t.Setenv("CLAUDE_CODE_REMOTE_SESSION_ID", "remote-456") + t.Setenv("CLAUDE_AGENT_SDK_CLIENT_APP", "desktop") + t.Setenv("CLAUDE_CODE_ADDITIONAL_PROTECTION", "true") + + headers := OptionalAPIHeaders() + if got := headers["x-claude-remote-container-id"]; got != "ctr-123" { + t.Fatalf("x-claude-remote-container-id = %q", got) + } + if got := headers["x-claude-remote-session-id"]; got != "remote-456" { + t.Fatalf("x-claude-remote-session-id = %q", got) + } + if got := headers["x-client-app"]; got != "desktop" { + t.Fatalf("x-client-app = %q", got) + } + if got := headers["x-anthropic-additional-protection"]; got != "true" { + t.Fatalf("x-anthropic-additional-protection = %q", got) + } +} + +func TestAttributionHeaderDisabled(t *testing.T) { + t.Setenv("CLAUDE_CODE_ATTRIBUTION_HEADER", "false") + if !AttributionHeaderDisabled() { + t.Fatal("expected AttributionHeaderDisabled() to be true when env=false") + } +} diff --git a/backend/internal/pkg/claudemask/mask.go b/backend/internal/pkg/claudemask/mask.go index 58f01a43..db26bb82 100644 --- a/backend/internal/pkg/claudemask/mask.go +++ b/backend/internal/pkg/claudemask/mask.go @@ -4,6 +4,8 @@ package claudemask import ( "net/http" "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" ) // GoClientIndicators 是可能暴露 Go 客户端身份的 HTTP 头列表 @@ -23,14 +25,14 @@ var SuspiciousHeaders = []string{ // RequiredNodeHeaders 是 Node.js Claude Code 客户端必须有的头 var RequiredNodeHeaders = map[string]bool{ - "User-Agent": true, - "X-Stainless-Lang": true, - "X-Stainless-Runtime": true, - "X-Stainless-Runtime-Version": true, - "X-Stainless-Package-Version": true, - "X-Stainless-OS": true, - "X-Stainless-Arch": true, - "anthropic-version": true, + "User-Agent": true, + "X-Stainless-Lang": true, + "X-Stainless-Runtime": true, + "X-Stainless-Runtime-Version": true, + "X-Stainless-Package-Version": true, + "X-Stainless-OS": true, + "X-Stainless-Arch": true, + "anthropic-version": true, } // ValidateNodeEmulation 验证请求是否正确伪装为 Node.js 客户端 @@ -85,7 +87,7 @@ func CleanRequest(req *http.Request) { ua := req.Header.Get("User-Agent") if ua == "" || strings.Contains(ua, "Go-http-client") { // 如果 User-Agent 缺失或包含 Go 指示,设置为 Node.js 格式 - req.Header.Set("User-Agent", "claude-cli/2.1.88 (external, cli)") + req.Header.Set("User-Agent", claude.DefaultUserAgent()) } // 确保 Accept-Encoding 由 utls 而非 http.Client 设置 diff --git a/backend/internal/pkg/claudemask/mask_test.go b/backend/internal/pkg/claudemask/mask_test.go index b55820ed..442428b6 100644 --- a/backend/internal/pkg/claudemask/mask_test.go +++ b/backend/internal/pkg/claudemask/mask_test.go @@ -3,20 +3,22 @@ package claudemask import ( "net/http" "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" ) func TestValidateNodeEmulation_ValidRequest(t *testing.T) { req, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil) // 设置所有必需的 Node.js 头 - req.Header.Set("User-Agent", "claude-cli/2.1.88 (external, cli)") - req.Header.Set("X-Stainless-Lang", "js") - req.Header.Set("X-Stainless-Runtime", "node") - req.Header.Set("X-Stainless-Runtime-Version", "v24.3.0") - req.Header.Set("X-Stainless-Package-Version", "0.74.0") - req.Header.Set("X-Stainless-OS", "MacOS") - req.Header.Set("X-Stainless-Arch", "arm64") - req.Header.Set("anthropic-version", "2023-06-01") + req.Header.Set("User-Agent", claude.DefaultUserAgent()) + req.Header.Set("X-Stainless-Lang", claude.DefaultStainlessLang) + req.Header.Set("X-Stainless-Runtime", claude.DefaultStainlessRuntime) + req.Header.Set("X-Stainless-Runtime-Version", claude.DefaultStainlessRuntimeVersion) + req.Header.Set("X-Stainless-Package-Version", claude.DefaultStainlessPackageVersion) + req.Header.Set("X-Stainless-OS", claude.DefaultStainlessOS) + req.Header.Set("X-Stainless-Arch", claude.DefaultStainlessArch) + req.Header.Set("anthropic-version", claude.DefaultAnthropicVersion) isValid, errors := ValidateNodeEmulation(req) @@ -32,7 +34,7 @@ func TestValidateNodeEmulation_MissingHeaders(t *testing.T) { req, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil) // 只设置部分头 - req.Header.Set("User-Agent", "claude-cli/2.1.88 (external, cli)") + req.Header.Set("User-Agent", claude.DefaultUserAgent()) isValid, errors := ValidateNodeEmulation(req) @@ -51,14 +53,14 @@ func TestValidateNodeEmulation_InvalidRuntime(t *testing.T) { req, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil) // 设置所有必需的头,但 runtime 错误 - req.Header.Set("User-Agent", "claude-cli/2.1.88 (external, cli)") - req.Header.Set("X-Stainless-Lang", "js") + req.Header.Set("User-Agent", claude.DefaultUserAgent()) + req.Header.Set("X-Stainless-Lang", claude.DefaultStainlessLang) req.Header.Set("X-Stainless-Runtime", "go") // ❌ 应为 "node" - req.Header.Set("X-Stainless-Runtime-Version", "v24.3.0") - req.Header.Set("X-Stainless-Package-Version", "0.74.0") - req.Header.Set("X-Stainless-OS", "MacOS") - req.Header.Set("X-Stainless-Arch", "arm64") - req.Header.Set("anthropic-version", "2023-06-01") + req.Header.Set("X-Stainless-Runtime-Version", claude.DefaultStainlessRuntimeVersion) + req.Header.Set("X-Stainless-Package-Version", claude.DefaultStainlessPackageVersion) + req.Header.Set("X-Stainless-OS", claude.DefaultStainlessOS) + req.Header.Set("X-Stainless-Arch", claude.DefaultStainlessArch) + req.Header.Set("anthropic-version", claude.DefaultAnthropicVersion) isValid, errs := ValidateNodeEmulation(req) @@ -87,13 +89,13 @@ func TestValidateNodeEmulation_MissingUserAgent(t *testing.T) { req, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil) // 不设置 User-Agent - req.Header.Set("X-Stainless-Lang", "js") - req.Header.Set("X-Stainless-Runtime", "node") - req.Header.Set("X-Stainless-Runtime-Version", "v24.3.0") - req.Header.Set("X-Stainless-Package-Version", "0.74.0") - req.Header.Set("X-Stainless-OS", "MacOS") - req.Header.Set("X-Stainless-Arch", "arm64") - req.Header.Set("anthropic-version", "2023-06-01") + req.Header.Set("X-Stainless-Lang", claude.DefaultStainlessLang) + req.Header.Set("X-Stainless-Runtime", claude.DefaultStainlessRuntime) + req.Header.Set("X-Stainless-Runtime-Version", claude.DefaultStainlessRuntimeVersion) + req.Header.Set("X-Stainless-Package-Version", claude.DefaultStainlessPackageVersion) + req.Header.Set("X-Stainless-OS", claude.DefaultStainlessOS) + req.Header.Set("X-Stainless-Arch", claude.DefaultStainlessArch) + req.Header.Set("anthropic-version", claude.DefaultAnthropicVersion) isValid, errs := ValidateNodeEmulation(req) @@ -114,7 +116,7 @@ func TestCleanRequest_FixesGoUserAgent(t *testing.T) { CleanRequest(req) ua := req.Header.Get("User-Agent") - if ua != "claude-cli/2.1.88 (external, cli)" { + if ua != claude.DefaultUserAgent() { t.Errorf("expected fixed User-Agent, got: %s", ua) } } @@ -126,7 +128,7 @@ func TestCleanRequest_FixesMissingUserAgent(t *testing.T) { CleanRequest(req) ua := req.Header.Get("User-Agent") - if ua != "claude-cli/2.1.88 (external, cli)" { + if ua != claude.DefaultUserAgent() { t.Errorf("expected User-Agent to be set, got: %s", ua) } } @@ -136,13 +138,13 @@ func TestValidateAndClean(t *testing.T) { // 设置带有 Go 指示的 User-Agent,其他头正确 req.Header.Set("User-Agent", "Go-http-client/2.0") - req.Header.Set("X-Stainless-Lang", "js") - req.Header.Set("X-Stainless-Runtime", "node") - req.Header.Set("X-Stainless-Runtime-Version", "v24.3.0") - req.Header.Set("X-Stainless-Package-Version", "0.74.0") - req.Header.Set("X-Stainless-OS", "MacOS") - req.Header.Set("X-Stainless-Arch", "arm64") - req.Header.Set("anthropic-version", "2023-06-01") + req.Header.Set("X-Stainless-Lang", claude.DefaultStainlessLang) + req.Header.Set("X-Stainless-Runtime", claude.DefaultStainlessRuntime) + req.Header.Set("X-Stainless-Runtime-Version", claude.DefaultStainlessRuntimeVersion) + req.Header.Set("X-Stainless-Package-Version", claude.DefaultStainlessPackageVersion) + req.Header.Set("X-Stainless-OS", claude.DefaultStainlessOS) + req.Header.Set("X-Stainless-Arch", claude.DefaultStainlessArch) + req.Header.Set("anthropic-version", claude.DefaultAnthropicVersion) isValid, errors := ValidateAndClean(req) @@ -152,7 +154,7 @@ func TestValidateAndClean(t *testing.T) { } ua := req.Header.Get("User-Agent") - if ua != "claude-cli/2.1.88 (external, cli)" { + if ua != claude.DefaultUserAgent() { t.Errorf("expected cleaned User-Agent, got: %s", ua) } } diff --git a/backend/internal/pkg/telemetry/telemetry.go b/backend/internal/pkg/telemetry/telemetry.go index 045017cf..cc8f4622 100644 --- a/backend/internal/pkg/telemetry/telemetry.go +++ b/backend/internal/pkg/telemetry/telemetry.go @@ -1,8 +1,8 @@ // Package telemetry simulates the real Claude Code CLI's OTEL telemetry events. // // Real CLI emits events to two channels: -// 1. Anthropic event_logging/batch (first-party events) -// 2. Datadog log intake (third-party observability) +// 1. Anthropic event_logging/batch (first-party events) +// 2. Datadog log intake (third-party observability) // // Ported from antigravity/node-tls-proxy/proxy.js — see that file for JS original. package telemetry @@ -28,14 +28,22 @@ import ( // ─── Constants ─────────────────────────────────────────── const ( - ddAPIKey = "pubbbf48e6d78dae54bceaa4acf463299bf" - fakeNodeVersion = "v24.3.0" - buildTime = "2026-03-31T01:39:46Z" - sessionMaxAge = time.Hour - sessionCleanup = 5 * time.Minute - telemetryTimeout = 10 * time.Second + ddAPIKey = "pubbbf48e6d78dae54bceaa4acf463299bf" + buildTime = "2026-04-12T01:48:09Z" + sessionMaxAge = time.Hour + sessionCleanup = 5 * time.Minute + telemetryTimeout = 10 * time.Second + firstPartyBatchSize = 24 + firstPartyFlushInterval = 1500 * time.Millisecond + firstPartyRetryBaseDelay = 2 * time.Second + firstPartyRetryMaxAttempt = 3 + firstPartyFailedBatchCap = 64 ) +func fakeNodeVersion() string { + return claude.DefaultStainlessRuntimeVersion +} + // ─── Virtual Host Identity ─────────────────────────────── var ( @@ -161,6 +169,12 @@ type sessionState struct { StartTime time.Time RequestCount int64 RipgrepReported bool + LastModel string + LastBetas string + LastAuthToken string + LastAuthHeader string + LastRequestAt time.Time + LastStatusCode int } var ( @@ -207,6 +221,18 @@ func getOrCreateSession(deviceID string) *sessionState { return s } +func getSession(deviceID string) *sessionState { + sessionsMu.Lock() + defer sessionsMu.Unlock() + return sessions[deviceID] +} + +func deleteSession(deviceID string) { + sessionsMu.Lock() + delete(sessions, deviceID) + sessionsMu.Unlock() +} + func generateUUID() string { b := make([]byte, 16) rand.Read(b) @@ -245,27 +271,27 @@ func buildProcessMetrics(uptime float64) string { func buildEnvBlock(hostID hostIdentity) map[string]any { return map[string]any{ - "platform": "darwin", - "node_version": fakeNodeVersion, - "terminal": hostID.Terminal, - "package_managers": "npm,pnpm", - "runtimes": "deno,node", - "is_running_with_bun": true, - "is_ci": false, - "is_claubbit": false, - "is_github_action": false, - "is_claude_code_action": false, - "is_claude_ai_auth": false, - "version": claude.DefaultCLIVersion, - "arch": hostID.Arch, - "is_claude_code_remote": false, - "deployment_environment": "unknown-darwin", - "is_conductor": false, - "version_base": claude.DefaultCLIVersion, - "build_time": buildTime, - "is_local_agent_mode": false, - "vcs": "git", - "platform_raw": "darwin", + "platform": "darwin", + "node_version": fakeNodeVersion(), + "terminal": hostID.Terminal, + "package_managers": "npm,pnpm", + "runtimes": "deno,node", + "is_running_with_bun": true, + "is_ci": false, + "is_claubbit": false, + "is_github_action": false, + "is_claude_code_action": false, + "is_claude_ai_auth": false, + "version": claude.DefaultCLIVersion, + "arch": hostID.Arch, + "is_claude_code_remote": false, + "deployment_environment": "unknown-darwin", + "is_conductor": false, + "version_base": claude.DefaultCLIVersion, + "build_time": buildTime, + "is_local_agent_mode": false, + "vcs": "git", + "platform_raw": "darwin", } } @@ -322,24 +348,146 @@ func buildEvent(eventName string, session *sessionState, model, betas string, ex var httpClient = &http.Client{Timeout: telemetryTimeout} -func sendTelemetryEvents(events []eventWrapper, session *sessionState, authToken string) { +type queuedTelemetryBatch struct { + authToken string + events []eventWrapper + attempt int +} + +var ( + telemetryQueueMu sync.Mutex + pendingTelemetry = make(map[string][]eventWrapper) + flushTimer *time.Timer + failedTelemetryQueue []queuedTelemetryBatch + retryTimer *time.Timer +) + +func sendTelemetryEvents(events []eventWrapper, _ *sessionState, authToken string) { if len(events) == 0 { return } + enqueueTelemetryEvents(authToken, events) +} +func enqueueTelemetryEvents(authToken string, events []eventWrapper) { + if len(events) == 0 { + return + } + telemetryQueueMu.Lock() + pendingTelemetry[authToken] = append(pendingTelemetry[authToken], events...) + if len(pendingTelemetry[authToken]) >= firstPartyBatchSize { + batch := queuedTelemetryBatch{ + authToken: authToken, + events: append([]eventWrapper(nil), pendingTelemetry[authToken]...), + attempt: 1, + } + delete(pendingTelemetry, authToken) + telemetryQueueMu.Unlock() + go sendBatchWithRetry(batch) + return + } + scheduleFlushLocked() + telemetryQueueMu.Unlock() +} + +func scheduleFlushLocked() { + if flushTimer != nil { + return + } + flushTimer = time.AfterFunc(firstPartyFlushInterval, flushPendingTelemetryBatches) +} + +func flushPendingTelemetryBatches() { + telemetryQueueMu.Lock() + batches := make([]queuedTelemetryBatch, 0, len(pendingTelemetry)) + for authToken, events := range pendingTelemetry { + if len(events) == 0 { + continue + } + batches = append(batches, queuedTelemetryBatch{ + authToken: authToken, + events: append([]eventWrapper(nil), events...), + attempt: 1, + }) + } + pendingTelemetry = make(map[string][]eventWrapper) + flushTimer = nil + telemetryQueueMu.Unlock() + + for _, batch := range batches { + go sendBatchWithRetry(batch) + } +} + +func sendBatchWithRetry(batch queuedTelemetryBatch) { + if len(batch.events) == 0 { + return + } + if err := postTelemetryBatch(batch.authToken, batch.events); err == nil { + slog.Debug("telemetry_sent", "events", len(batch.events), "attempt", batch.attempt) + return + } else if batch.attempt < firstPartyRetryMaxAttempt { + delay := firstPartyRetryBaseDelay * time.Duration(1<<(batch.attempt-1)) + slog.Debug("telemetry_retry", "attempt", batch.attempt, "delay_ms", delay.Milliseconds(), "error", err.Error()) + next := batch + next.attempt++ + time.AfterFunc(delay, func() { + sendBatchWithRetry(next) + }) + return + } else { + slog.Debug("telemetry_queue_failed", "events", len(batch.events), "error", err.Error()) + queueFailedEvents(batch) + } +} + +func queueFailedEvents(batch queuedTelemetryBatch) { + telemetryQueueMu.Lock() + if len(failedTelemetryQueue) >= firstPartyFailedBatchCap { + failedTelemetryQueue = failedTelemetryQueue[1:] + } + failedTelemetryQueue = append(failedTelemetryQueue, queuedTelemetryBatch{ + authToken: batch.authToken, + events: append([]eventWrapper(nil), batch.events...), + attempt: 1, + }) + scheduleRetryFailedEventsLocked() + telemetryQueueMu.Unlock() +} + +func scheduleRetryFailedEventsLocked() { + if retryTimer != nil { + return + } + retryTimer = time.AfterFunc(firstPartyRetryBaseDelay*4, retryFailedEvents) +} + +func retryFailedEvents() { + telemetryQueueMu.Lock() + batches := failedTelemetryQueue + failedTelemetryQueue = nil + retryTimer = nil + telemetryQueueMu.Unlock() + + for _, batch := range batches { + go sendBatchWithRetry(batch) + } +} + +func postTelemetryBatch(authToken string, events []eventWrapper) error { payload := map[string]any{"events": events} body, err := json.Marshal(payload) if err != nil { - return + return err } req, err := http.NewRequest("POST", "https://api.anthropic.com/api/event_logging/batch", bytes.NewReader(body)) if err != nil { - return + return err } req.Header.Set("Accept", "application/json, text/plain, */*") req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", "claude-code/"+claude.DefaultCLIVersion) + req.Header.Set("User-Agent", claude.DefaultCodeUserAgent()) req.Header.Set("x-service-name", "claude-code") if authToken != "" { req.Header.Set("Authorization", "Bearer "+authToken) @@ -347,11 +495,13 @@ func sendTelemetryEvents(events []eventWrapper, session *sessionState, authToken resp, err := httpClient.Do(req) if err != nil { - slog.Debug("telemetry_error", "error", err.Error()) - return + return err } resp.Body.Close() - slog.Debug("telemetry_sent", "status", resp.StatusCode, "events", len(events)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("event_logging status %d", resp.StatusCode) + } + return nil } func sendDatadogLog(eventName string, session *sessionState, model string) { @@ -382,28 +532,28 @@ func sendDatadogLog(eventName string, session *sessionState, model string) { } entry := map[string]any{ - "ddsource": "nodejs", - "ddtags": fmt.Sprintf("event:%s,arch:%s,client_type:cli,model:%s,platform:darwin,user_type:external,version:%s,version_base:%s", eventName, hostID.Arch, model, claude.DefaultCLIVersion, claude.DefaultCLIVersion), - "message": eventName, - "service": "claude-code", - "hostname": "claude-code", - "env": "external", - "model": model, - "session_id": session.SessionID, - "user_type": "external", - "entrypoint": "cli", - "is_interactive": "true", - "client_type": "cli", - "process_metrics": pm, - "platform": "darwin", - "platform_raw": "darwin", - "arch": hostID.Arch, - "node_version": fakeNodeVersion, - "version": claude.DefaultCLIVersion, - "version_base": claude.DefaultCLIVersion, - "build_time": buildTime, - "deployment_environment": "unknown-darwin", - "vcs": "git", + "ddsource": "nodejs", + "ddtags": fmt.Sprintf("event:%s,arch:%s,client_type:cli,model:%s,platform:darwin,user_type:external,version:%s,version_base:%s", eventName, hostID.Arch, model, claude.DefaultCLIVersion, claude.DefaultCLIVersion), + "message": eventName, + "service": "claude-code", + "hostname": "claude-code", + "env": "external", + "model": model, + "session_id": session.SessionID, + "user_type": "external", + "entrypoint": "cli", + "is_interactive": "true", + "client_type": "cli", + "process_metrics": pm, + "platform": "darwin", + "platform_raw": "darwin", + "arch": hostID.Arch, + "node_version": fakeNodeVersion(), + "version": claude.DefaultCLIVersion, + "version_base": claude.DefaultCLIVersion, + "build_time": buildTime, + "deployment_environment": "unknown-darwin", + "vcs": "git", } body, err := json.Marshal([]any{entry}) @@ -451,6 +601,11 @@ func EmitPreRequest(accountSeed, authHeader, authToken, model, betaHeader string if betas == "" { betas = claude.DefaultBetaHeader } + session.LastModel = model + session.LastBetas = betas + session.LastAuthToken = authToken + session.LastAuthHeader = authHeader + session.LastRequestAt = time.Now() // First request: full startup sequence if session.RequestCount == 1 { @@ -519,6 +674,12 @@ func EmitPostRequest(accountSeed, authHeader, authToken, model, betaHeader strin if betas == "" { betas = claude.DefaultBetaHeader } + session.LastModel = model + session.LastBetas = betas + session.LastAuthToken = authToken + session.LastAuthHeader = authHeader + session.LastRequestAt = time.Now() + session.LastStatusCode = statusCode // Real CLI uses tengu_api_success on success, tengu_api_error on failure if statusCode < 400 { @@ -559,6 +720,53 @@ func EmitPostRequest(accountSeed, authHeader, authToken, model, betaHeader strin } } +// EmitExit fires a real tengu_exit event for an existing session. +func EmitExit(accountSeed, authHeader, authToken, model, betaHeader string, extraData map[string]any) { + authSuffix := authHeader + if len(authSuffix) > 16 { + authSuffix = authSuffix[len(authSuffix)-16:] + } + deviceID := generateDeviceID(accountSeed + ":" + authSuffix) + session := getSession(deviceID) + if session == nil { + return + } + + if model == "" { + model = session.LastModel + } + if model == "" { + model = "claude-sonnet-4-6" + } + if betaHeader == "" { + betaHeader = session.LastBetas + } + if betaHeader == "" { + betaHeader = claude.DefaultBetaHeader + } + if authToken == "" { + authToken = session.LastAuthToken + } + + payload := map[string]any{ + "last_session_id": session.SessionID, + "last_session_duration": time.Since(session.StartTime).Milliseconds(), + "last_session_request_count": session.RequestCount, + } + if session.LastStatusCode > 0 { + payload["last_session_status_code"] = session.LastStatusCode + } + for k, v := range extraData { + payload[k] = v + } + + go sendTelemetryEvents([]eventWrapper{ + buildEvent("tengu_exit", session, model, betaHeader, payload, ""), + }, session, authToken) + go sendDatadogLog("tengu_exit", session, model) + deleteSession(deviceID) +} + // Jitter returns a random delay to inject before forwarding a request. // 80% fast (80-300ms exponential), 20% slow (400-1200ms uniform). func Jitter() time.Duration { diff --git a/backend/internal/repository/claude_usage_service.go b/backend/internal/repository/claude_usage_service.go index b44adde2..f92634ac 100644 --- a/backend/internal/repository/claude_usage_service.go +++ b/backend/internal/repository/claude_usage_service.go @@ -8,6 +8,7 @@ import ( "net/http" "time" + claude "github.com/Wei-Shaw/sub2api/internal/pkg/claude" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" "github.com/Wei-Shaw/sub2api/internal/service" @@ -15,9 +16,6 @@ import ( const defaultClaudeUsageURL = "https://api.anthropic.com/api/oauth/usage" -// 默认 User-Agent,与用户抓包的请求一致 -const defaultUsageUserAgent = "claude-code/2.1.7" - type claudeUsageService struct { usageURL string allowPrivateHosts bool @@ -60,7 +58,7 @@ func (s *claudeUsageService) FetchUsageWithOptions(ctx context.Context, opts *se req.Header.Set("anthropic-beta", "oauth-2025-04-20") // 设置 User-Agent(优先使用缓存的 Fingerprint,否则使用默认值) - userAgent := defaultUsageUserAgent + userAgent := claude.DefaultCodeUserAgent() if opts.Fingerprint != nil && opts.Fingerprint.UserAgent != "" { userAgent = opts.Fingerprint.UserAgent } diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index 2c778730..914992e2 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -295,7 +295,7 @@ func (s *httpUpstreamService) getClientEntryWithTLS(proxyURL string, accountID i return nil, fmt.Errorf("build TLS fingerprint transport: %w", err) } - client := &http.Client{Transport: transport} + client := &http.Client{Transport: wrapTransportAuditIfEnabled(transport, "upstream_tls")} if s.shouldValidateResolvedIP() { client.CheckRedirect = s.redirectChecker } @@ -439,7 +439,7 @@ func (s *httpUpstreamService) getClientEntry(proxyURL string, accountID int64, a s.mu.Unlock() return nil, fmt.Errorf("build transport: %w", err) } - client := &http.Client{Transport: transport} + client := &http.Client{Transport: wrapTransportAuditIfEnabled(transport, "upstream")} if s.shouldValidateResolvedIP() { client.CheckRedirect = s.redirectChecker } diff --git a/backend/internal/repository/transport_audit.go b/backend/internal/repository/transport_audit.go new file mode 100644 index 00000000..5d17dd48 --- /dev/null +++ b/backend/internal/repository/transport_audit.go @@ -0,0 +1,159 @@ +package repository + +import ( + "crypto/tls" + "net/http" + "net/http/httptrace" + "os" + "strings" + "time" + + "log/slog" +) + +const transportAuditEnv = "SUB2API_DEBUG_TRANSPORT_AUDIT" + +type transportAuditState struct { + start time.Time + reused bool + wasIdle bool + idleTime time.Duration +} + +type transportAuditRoundTripper struct { + base http.RoundTripper + label string +} + +func transportAuditEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(transportAuditEnv))) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +func wrapTransportAuditIfEnabled(base http.RoundTripper, label string) http.RoundTripper { + if base == nil || !transportAuditEnabled() { + return base + } + return &transportAuditRoundTripper{ + base: base, + label: strings.TrimSpace(label), + } +} + +func (rt *transportAuditRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if rt == nil || rt.base == nil || req == nil { + return nil, http.ErrUseLastResponse + } + + state := &transportAuditState{start: time.Now()} + trace := &httptrace.ClientTrace{ + GotConn: func(info httptrace.GotConnInfo) { + state.reused = info.Reused + state.wasIdle = info.WasIdle + state.idleTime = info.IdleTime + }, + } + + req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace)) + resp, err := rt.base.RoundTrip(req) + if err != nil { + slog.Debug("transport_audit_error", + "label", rt.label, + "method", req.Method, + "url", safeAuditURL(req), + "elapsed_ms", time.Since(state.start).Milliseconds(), + "conn_reused", state.reused, + "conn_was_idle", state.wasIdle, + "conn_idle_ms", state.idleTime.Milliseconds(), + "error", err.Error(), + ) + return nil, err + } + + tlsState := resp.TLS + if tlsState == nil && req.TLS != nil { + tlsState = req.TLS + } + + slog.Debug("transport_audit", + "label", rt.label, + "method", req.Method, + "url", safeAuditURL(req), + "status", resp.StatusCode, + "proto", strings.TrimSpace(resp.Proto), + "elapsed_ms", time.Since(state.start).Milliseconds(), + "conn_reused", state.reused, + "conn_was_idle", state.wasIdle, + "conn_idle_ms", state.idleTime.Milliseconds(), + "tls_version", tlsVersionString(tlsState), + "tls_cipher", tlsCipherSuiteString(tlsState), + "tls_alpn", tlsNegotiatedProtocol(tlsState), + "tls_resumed", tlsDidResume(tlsState), + "tls_server_name", tlsServerName(tlsState), + ) + + return resp, nil +} + +func safeAuditURL(req *http.Request) string { + if req == nil || req.URL == nil { + return "" + } + u := *req.URL + u.RawQuery = "" + u.Fragment = "" + return u.String() +} + +func tlsVersionString(state *tls.ConnectionState) string { + if state == nil { + return "" + } + switch state.Version { + case tls.VersionTLS10: + return "TLS1.0" + case tls.VersionTLS11: + return "TLS1.1" + case tls.VersionTLS12: + return "TLS1.2" + case tls.VersionTLS13: + return "TLS1.3" + default: + if state.Version == 0 { + return "" + } + return "unknown" + } +} + +func tlsCipherSuiteString(state *tls.ConnectionState) string { + if state == nil || state.CipherSuite == 0 { + return "" + } + return tls.CipherSuiteName(state.CipherSuite) +} + +func tlsNegotiatedProtocol(state *tls.ConnectionState) string { + if state == nil { + return "" + } + return strings.TrimSpace(state.NegotiatedProtocol) +} + +func tlsDidResume(state *tls.ConnectionState) bool { + if state == nil { + return false + } + return state.DidResume +} + +func tlsServerName(state *tls.ConnectionState) string { + if state == nil { + return "" + } + return strings.TrimSpace(state.ServerName) +} diff --git a/backend/internal/repository/transport_audit_test.go b/backend/internal/repository/transport_audit_test.go new file mode 100644 index 00000000..85bc2305 --- /dev/null +++ b/backend/internal/repository/transport_audit_test.go @@ -0,0 +1,95 @@ +package repository + +import ( + "io" + "net/http" + "net/http/httptrace" + "os" + "strings" + "testing" +) + +func TestWrapTransportAuditIfEnabledDisabled(t *testing.T) { + t.Setenv(transportAuditEnv, "") + + base := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if httptrace.ContextClientTrace(r.Context()) != nil { + t.Fatalf("unexpected client trace when audit is disabled") + } + return &http.Response{ + StatusCode: 200, + Proto: "HTTP/1.1", + Body: io.NopCloser(strings.NewReader("ok")), + Request: r, + }, nil + }) + + wrapped := wrapTransportAuditIfEnabled(base, "plain") + if _, ok := wrapped.(*transportAuditRoundTripper); ok { + t.Fatalf("expected base transport when audit disabled") + } + + req, err := http.NewRequest(http.MethodGet, "https://api.anthropic.com/v1/messages?beta=true", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + if _, err := wrapped.RoundTrip(req); err != nil { + t.Fatalf("round trip: %v", err) + } +} + +func TestWrapTransportAuditIfEnabledEnabled(t *testing.T) { + t.Setenv(transportAuditEnv, "1") + + base := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if httptrace.ContextClientTrace(r.Context()) == nil { + t.Fatalf("expected client trace when audit is enabled") + } + return &http.Response{ + StatusCode: 200, + Proto: "HTTP/1.1", + Body: io.NopCloser(strings.NewReader("ok")), + Request: r, + }, nil + }) + + wrapped := wrapTransportAuditIfEnabled(base, "tlsfp") + if _, ok := wrapped.(*transportAuditRoundTripper); !ok { + t.Fatalf("expected wrapped transport when audit enabled") + } + + req, err := http.NewRequest(http.MethodGet, "https://api.anthropic.com/v1/messages?beta=true", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + if _, err := wrapped.RoundTrip(req); err != nil { + t.Fatalf("round trip: %v", err) + } +} + +func TestTransportAuditEnabled(t *testing.T) { + cases := []struct { + name string + raw string + want bool + }{ + {name: "empty", raw: "", want: false}, + {name: "one", raw: "1", want: true}, + {name: "true", raw: "true", want: true}, + {name: "on", raw: "on", want: true}, + {name: "no", raw: "no", want: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.raw == "" { + _ = os.Unsetenv(transportAuditEnv) + } else { + t.Setenv(transportAuditEnv, tc.raw) + } + if got := transportAuditEnabled(); got != tc.want { + t.Fatalf("transportAuditEnabled() = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/backend/internal/server/routes/common.go b/backend/internal/server/routes/common.go index b2861ddb..e118f3f9 100644 --- a/backend/internal/server/routes/common.go +++ b/backend/internal/server/routes/common.go @@ -11,8 +11,9 @@ import ( ) const ( - anthropicEventLoggingURL = "https://api.anthropic.com/api/event_logging/batch" - eventLoggingForwardTimeout = 8 * time.Second + anthropicEventLoggingURL = "https://api.anthropic.com/api/event_logging/batch" + eventLoggingForwardTimeout = 8 * time.Second + claudeCodeGrowthBookDateUpdated = "1970-01-01T00:00:00Z" ) // RegisterCommonRoutes 注册通用路由(健康检查、状态等) @@ -55,6 +56,60 @@ func RegisterCommonRoutes(r *gin.Engine) { c.Status(http.StatusOK) }) + // Claude Code 启动预检:本地 CLI 会在启动早期请求该端点。 + r.GET("/api/claude_cli/bootstrap", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "client_data": nil, + "additional_model_options": []any{}, + "additional_model_costs": gin.H{}, + }) + }) + + // Claude Code 组织级策略限制:源码 schema 为 { restrictions: { key: { allowed: boolean } } }。 + // 空对象表示当前没有下发任何限制。 + r.GET("/api/claude_code/policy_limits", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "restrictions": gin.H{}, + }) + }) + + // GrowthBook 特性拉取:真实 Claude Code 远端评估会命中 /api/eval/:clientKey, + // SDK 也支持 /api/features/:clientKey,并通过 x-sse-support 探测是否可订阅 SSE。 + r.GET("/api/features/:clientKey", func(c *gin.Context) { + c.Header("x-sse-support", "enabled") + c.JSON(http.StatusOK, gin.H{ + "features": gin.H{}, + "dateUpdated": claudeCodeGrowthBookDateUpdated, + }) + }) + r.POST("/api/eval/:clientKey", func(c *gin.Context) { + c.Header("x-sse-support", "enabled") + c.JSON(http.StatusOK, gin.H{ + "features": gin.H{}, + "dateUpdated": claudeCodeGrowthBookDateUpdated, + }) + }) + + writeGrowthBookSSE := func(c *gin.Context) { + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + c.Status(http.StatusOK) + c.SSEvent("features", gin.H{ + "features": gin.H{}, + "dateUpdated": claudeCodeGrowthBookDateUpdated, + }) + if flusher, ok := c.Writer.(http.Flusher); ok { + flusher.Flush() + } + } + + // 真实 Claude Code SDK 使用 /sub/:clientKey 订阅特性更新。 + r.GET("/sub/:clientKey", writeGrowthBookSSE) + // 兼容当前内部 bootstrap 预热器仍在使用的旧路径,避免本地联调时 404。 + r.GET("/sub/features/:clientKey", writeGrowthBookSSE) + // Setup status endpoint (always returns needs_setup: false in normal mode) // This is used by the frontend to detect when the service has restarted after setup r.GET("/setup/status", func(c *gin.Context) { diff --git a/backend/internal/server/routes/common_test.go b/backend/internal/server/routes/common_test.go new file mode 100644 index 00000000..815c08cf --- /dev/null +++ b/backend/internal/server/routes/common_test.go @@ -0,0 +1,147 @@ +package routes + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func newCommonRoutesTestRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + RegisterCommonRoutes(r) + return r +} + +func TestCommonRoutes_ClaudeCodeBootstrap(t *testing.T) { + r := newCommonRoutesTestRouter() + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/claude_cli/bootstrap", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + + var body struct { + ClientData any `json:"client_data"` + AdditionalModelOptions []map[string]any `json:"additional_model_options"` + AdditionalModelCosts map[string]map[string]any `json:"additional_model_costs"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if body.ClientData != nil { + t.Fatalf("client_data = %#v, want nil", body.ClientData) + } + if body.AdditionalModelOptions == nil || len(body.AdditionalModelOptions) != 0 { + t.Fatalf("additional_model_options = %#v, want empty array", body.AdditionalModelOptions) + } + if body.AdditionalModelCosts == nil || len(body.AdditionalModelCosts) != 0 { + t.Fatalf("additional_model_costs = %#v, want empty object", body.AdditionalModelCosts) + } +} + +func TestCommonRoutes_ClaudeCodePolicyLimits(t *testing.T) { + r := newCommonRoutesTestRouter() + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/claude_code/policy_limits", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + + var body struct { + Restrictions map[string]struct { + Allowed bool `json:"allowed"` + } `json:"restrictions"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if body.Restrictions == nil || len(body.Restrictions) != 0 { + t.Fatalf("restrictions = %#v, want empty object", body.Restrictions) + } +} + +func TestCommonRoutes_GrowthBookJSONRoutes(t *testing.T) { + r := newCommonRoutesTestRouter() + + testCases := []struct { + name string + method string + path string + }{ + {name: "features", method: http.MethodGet, path: "/api/features/sdk-zAZezfDKGoZuXXKe"}, + {name: "eval", method: http.MethodPost, path: "/api/eval/sdk-zAZezfDKGoZuXXKe"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest(tc.method, tc.path, strings.NewReader(`{"attributes":{}}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + if got := w.Header().Get("x-sse-support"); got != "enabled" { + t.Fatalf("x-sse-support = %q, want %q", got, "enabled") + } + + var body struct { + Features map[string]any `json:"features"` + DateUpdated string `json:"dateUpdated"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if body.Features == nil || len(body.Features) != 0 { + t.Fatalf("features = %#v, want empty object", body.Features) + } + if body.DateUpdated != claudeCodeGrowthBookDateUpdated { + t.Fatalf("dateUpdated = %q, want %q", body.DateUpdated, claudeCodeGrowthBookDateUpdated) + } + }) + } +} + +func TestCommonRoutes_GrowthBookSSERoutes(t *testing.T) { + r := newCommonRoutesTestRouter() + + testPaths := []string{ + "/sub/sdk-zAZezfDKGoZuXXKe", + "/sub/features/sdk-zAZezfDKGoZuXXKe", + } + + for _, path := range testPaths { + t.Run(path, func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, path, nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + if got := w.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/event-stream") { + t.Fatalf("Content-Type = %q, want text/event-stream*", got) + } + + body := w.Body.String() + if !strings.Contains(body, "event:features") { + t.Fatalf("SSE body missing features event: %q", body) + } + if !strings.Contains(body, `"dateUpdated":"`+claudeCodeGrowthBookDateUpdated+`"`) { + t.Fatalf("SSE body missing dateUpdated payload: %q", body) + } + }) + } +} diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index 879e78a3..118dbb0a 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -1093,8 +1093,6 @@ func applyThinkingModelSuffix(mappedModel string, thinkingEnabled bool) string { return mappedModel } - - // IsModelSupported 检查模型是否被支持 // 所有 claude- 和 gemini- 前缀的模型都能通过映射或透传支持 func (s *AntigravityGatewayService) IsModelSupported(requestedModel string) bool { @@ -1499,6 +1497,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, // Antigravity 上游要求必须包含身份提示词,否则会返回 429 transformOpts := s.getClaudeTransformOptions(ctx) transformOpts.EnableIdentityPatch = true // 强制启用,Antigravity 上游必需 + transformOpts.PreferredSessionID = sessionID // 转换 Claude 请求为 Gemini 格式 geminiBody, err := antigravity.TransformClaudeToGeminiWithOptions(&claudeReq, projectID, mappedModel, transformOpts) @@ -1591,7 +1590,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, logger.LegacyPrintf("service.antigravity_gateway", "Antigravity account %d: detected signature-related 400, retrying once (%s)", account.ID, stage.name) - retryGeminiBody, txErr := antigravity.TransformClaudeToGeminiWithOptions(&retryClaudeReq, projectID, mappedModel, s.getClaudeTransformOptions(ctx)) + retryGeminiBody, txErr := antigravity.TransformClaudeToGeminiWithOptions(&retryClaudeReq, projectID, mappedModel, transformOpts) if txErr != nil { continue } @@ -3170,7 +3169,6 @@ func buildGeminiStreamErrorEvent(status int, message string) string { return "event: error\ndata: " + string(data) + "\n\n" } - func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context, resp *http.Response, startTime time.Time) (*antigravityStreamResult, error) { c.Status(resp.StatusCode) c.Header("Cache-Control", "no-cache") diff --git a/backend/internal/service/antigravity_gateway_service_test.go b/backend/internal/service/antigravity_gateway_service_test.go index 8cb49d24..bba6d1ee 100644 --- a/backend/internal/service/antigravity_gateway_service_test.go +++ b/backend/internal/service/antigravity_gateway_service_test.go @@ -657,6 +657,63 @@ func TestAntigravityGatewayService_ForwardGemini_InjectsSessionIDIntoWrappedRequ require.Equal(t, "session-header-1", requestNode["sessionId"]) } +func TestAntigravityGatewayService_Forward_PropagatesSessionHeaderIntoClaudeTransform(t *testing.T) { + gin.SetMode(gin.TestMode) + writer := httptest.NewRecorder() + c, _ := gin.CreateTestContext(writer) + + body := []byte(`{ + "model":"claude-sonnet-4-5", + "max_tokens":64, + "messages":[ + {"role":"user","content":[{"type":"text","text":"hello"}]} + ] + }`) + + req := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + req.Header.Set("session_id", "session-header-1") + c.Request = req + + upstreamBody := []byte("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":8,\"candidatesTokenCount\":3}}}\n\n") + upstream := &queuedHTTPUpstreamStub{ + responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"X-Request-Id": []string{"req-session-claude-1"}}, + Body: io.NopCloser(bytes.NewReader(upstreamBody)), + }, + }, + } + + svc := &AntigravityGatewayService{ + settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}), + tokenProvider: &AntigravityTokenProvider{}, + httpUpstream: upstream, + } + + account := &Account{ + ID: 17, + Name: "acc-claude-session", + Platform: PlatformAntigravity, + Type: AccountTypeOAuth, + Status: StatusActive, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "token", + "project_id": "project-1", + }, + } + + result, err := svc.Forward(context.Background(), c, account, body, false) + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.requestBodies, 1) + + var wrapped antigravity.V1InternalRequest + require.NoError(t, json.Unmarshal(upstream.requestBodies[0], &wrapped)) + require.Equal(t, "session-header-1", wrapped.Request.SessionID) +} + func TestAntigravityGatewayService_ForwardGemini_RetriesCorruptedThoughtSignature(t *testing.T) { gin.SetMode(gin.TestMode) writer := httptest.NewRecorder() diff --git a/backend/internal/service/bootstrap_preflight.go b/backend/internal/service/bootstrap_preflight.go index ee957690..f973d9a1 100644 --- a/backend/internal/service/bootstrap_preflight.go +++ b/backend/internal/service/bootstrap_preflight.go @@ -10,6 +10,8 @@ import ( claude "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/telemetry" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" ) // backgroundSimulator simulates the real Claude Code CLI's background network behavior. @@ -22,6 +24,14 @@ type backgroundSimulator struct { baseURL string } +type BackgroundRequestOptions struct { + ProxyURL string + HTTPUpstream HTTPUpstream + TLSProfile *tlsfingerprint.Profile + InstanceSalt string + UseSharedUpstream bool +} + type accountBackgroundState struct { bootstrapAt time.Time growthbookAt time.Time @@ -32,6 +42,11 @@ type accountBackgroundState struct { exitTimer *time.Timer // fires tengu_exit after idle timeout accessToken string accountID int64 + proxyURL string + httpUpstream HTTPUpstream + tlsProfile *tlsfingerprint.Profile + instanceSalt string + useSharedUpstream bool } const ( @@ -54,7 +69,7 @@ func SetBootstrapBaseURL(baseURL string) { // TriggerBootstrapIfNeeded fires background simulation calls for the given OAuth account. // On first call per account: bootstrap + GrowthBook + policy_limits + start periodic timers. // On subsequent calls: refresh idle timer (delays tengu_exit). -func TriggerBootstrapIfNeeded(accountID int64, accessToken string) { +func TriggerBootstrapIfNeeded(accountID int64, accessToken string, opts *BackgroundRequestOptions) { bg := globalBgSim bg.mu.Lock() @@ -66,6 +81,7 @@ func TriggerBootstrapIfNeeded(accountID int64, accessToken string) { accessToken: accessToken, accountID: accountID, } + state.applyRequestOptions(opts) bg.called[accountID] = state bg.mu.Unlock() @@ -80,6 +96,7 @@ func TriggerBootstrapIfNeeded(accountID int64, accessToken string) { // Update token (may have been refreshed) state.accessToken = accessToken + state.applyRequestOptions(opts) // Bootstrap: 1 hour cooldown if time.Since(state.bootstrapAt) >= bootstrapCooldown { @@ -101,6 +118,43 @@ func (bg *backgroundSimulator) getBaseURL() string { return "https://api.anthropic.com" } +func (state *accountBackgroundState) applyRequestOptions(opts *BackgroundRequestOptions) { + if state == nil || opts == nil { + return + } + state.proxyURL = opts.ProxyURL + state.httpUpstream = opts.HTTPUpstream + state.tlsProfile = opts.TLSProfile + state.instanceSalt = opts.InstanceSalt + state.useSharedUpstream = opts.UseSharedUpstream +} + +func (bg *backgroundSimulator) applyBackgroundHeaders(req *http.Request, state *accountBackgroundState, contentType string) { + if req == nil || state == nil { + return + } + req.Header.Set("Accept", "application/json, text/plain, */*") + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + req.Header.Set("User-Agent", claude.DefaultCodeUserAgent()) + req.Header.Set("Authorization", "Bearer "+state.accessToken) + req.Header.Set("anthropic-beta", claude.BetaOAuth) +} + +func (bg *backgroundSimulator) doRequest(ctx context.Context, state *accountBackgroundState, req *http.Request) (*http.Response, error) { + if req == nil { + return nil, nil + } + if state != nil && state.useSharedUpstream && state.httpUpstream != nil { + if req.URL != nil && req.URL.Scheme == "https" { + return state.httpUpstream.DoWithTLS(req, state.proxyURL, state.accountID, 0, state.tlsProfile) + } + return state.httpUpstream.Do(req, state.proxyURL, state.accountID, 0) + } + return bg.client.Do(req.WithContext(ctx)) +} + // ─── Bootstrap ─────────────────────────────────────────── func (bg *backgroundSimulator) doBootstrap(state *accountBackgroundState) { @@ -113,14 +167,9 @@ func (bg *backgroundSimulator) doBootstrap(state *accountBackgroundState) { if err != nil { return } + bg.applyBackgroundHeaders(req, state, "application/json") - // Source: extracted/src/services/api/bootstrap.ts:85-91 - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", fmt.Sprintf("claude-code/%s", claude.DefaultCLIVersion)) - req.Header.Set("Authorization", "Bearer "+state.accessToken) - req.Header.Set("anthropic-beta", claude.BetaOAuth) - - resp, err := bg.client.Do(req) + resp, err := bg.doRequest(ctx, state, req) if err != nil { logger.LegacyPrintf("service.bootstrap", "Bootstrap preflight failed: %v", err) return @@ -148,12 +197,9 @@ func (bg *backgroundSimulator) doGrowthBookFetch(state *accountBackgroundState) if err != nil { return } + bg.applyBackgroundHeaders(req, state, "") - req.Header.Set("Authorization", "Bearer "+state.accessToken) - req.Header.Set("anthropic-beta", claude.BetaOAuth) - req.Header.Set("User-Agent", fmt.Sprintf("claude-code/%s", claude.DefaultCLIVersion)) - - resp, err := bg.client.Do(req) + resp, err := bg.doRequest(ctx, state, req) if err != nil { logger.LegacyPrintf("service.bootstrap", "GrowthBook fetch failed: %v", err) return @@ -178,13 +224,9 @@ func (bg *backgroundSimulator) doPolicyLimitsFetch(state *accountBackgroundState if err != nil { return } + bg.applyBackgroundHeaders(req, state, "application/json") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", fmt.Sprintf("claude-code/%s", claude.DefaultCLIVersion)) - req.Header.Set("Authorization", "Bearer "+state.accessToken) - req.Header.Set("anthropic-beta", claude.BetaOAuth) - - resp, err := bg.client.Do(req) + resp, err := bg.doRequest(ctx, state, req) if err != nil { logger.LegacyPrintf("service.bootstrap", "Policy limits fetch failed: %v", err) return @@ -249,7 +291,15 @@ func (bg *backgroundSimulator) fireExitEvent(state *accountBackgroundState) { // The event is a lightweight signal — just needs to exist in Anthropic's logs. // Real CLI sends it on process exit; we simulate on idle timeout. - logger.LegacyPrintf("service.bootstrap", "Session idle timeout, would fire tengu_exit: account=%d", state.accountID) + logger.LegacyPrintf("service.bootstrap", "Session idle timeout, firing tengu_exit: account=%d", state.accountID) + telemetry.EmitExit( + fmt.Sprintf("%d", state.accountID), + "Bearer "+state.accessToken, + state.accessToken, + "", + "", + nil, + ) // Clean up the state to allow fresh bootstrap on next request bg.mu.Lock() diff --git a/backend/internal/service/bootstrap_preflight_test.go b/backend/internal/service/bootstrap_preflight_test.go new file mode 100644 index 00000000..af289816 --- /dev/null +++ b/backend/internal/service/bootstrap_preflight_test.go @@ -0,0 +1,143 @@ +package service + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + claude "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" +) + +type bootstrapHTTPUpstreamStub struct { + doCalled bool + doWithTLSCalled bool + lastProxyURL string + lastAccountID int64 + lastConcurrency int + lastTLSProfile *tlsfingerprint.Profile + lastRequestMethod string +} + +func (s *bootstrapHTTPUpstreamStub) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) { + s.doCalled = true + s.lastProxyURL = proxyURL + s.lastAccountID = accountID + s.lastConcurrency = accountConcurrency + if req != nil { + s.lastRequestMethod = req.Method + } + return &http.Response{ + StatusCode: http.StatusNoContent, + Body: io.NopCloser(strings.NewReader("")), + Header: make(http.Header), + }, nil +} + +func (s *bootstrapHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) { + s.doWithTLSCalled = true + s.lastProxyURL = proxyURL + s.lastAccountID = accountID + s.lastConcurrency = accountConcurrency + s.lastTLSProfile = profile + if req != nil { + s.lastRequestMethod = req.Method + } + return &http.Response{ + StatusCode: http.StatusNoContent, + Body: io.NopCloser(strings.NewReader("")), + Header: make(http.Header), + }, nil +} + +func TestBackgroundSimulatorApplyBackgroundHeaders_UsesClaudeCodeBackgroundProfile(t *testing.T) { + bg := &backgroundSimulator{} + state := &accountBackgroundState{ + accessToken: "token-123", + accountID: 42, + instanceSalt: "salt-123", + } + + req, err := http.NewRequest(http.MethodGet, "https://api.anthropic.com/api/claude_cli/bootstrap", nil) + if err != nil { + t.Fatalf("NewRequest() error = %v", err) + } + + bg.applyBackgroundHeaders(req, state, "application/json") + + if got := req.Header.Get("Accept"); got != "application/json, text/plain, */*" { + t.Fatalf("Accept = %q", got) + } + if got := req.Header.Get("Content-Type"); got != "application/json" { + t.Fatalf("Content-Type = %q", got) + } + if got := req.Header.Get("User-Agent"); got != claude.DefaultCodeUserAgent() { + t.Fatalf("User-Agent = %q, want %q", got, claude.DefaultCodeUserAgent()) + } + if got := req.Header.Get("Authorization"); got != "Bearer token-123" { + t.Fatalf("Authorization = %q", got) + } + if got := req.Header.Get("anthropic-beta"); got != claude.BetaOAuth { + t.Fatalf("anthropic-beta = %q", got) + } + if got := req.Header.Get("anthropic-version"); got != "" { + t.Fatalf("anthropic-version = %q, want empty", got) + } + if got := req.Header.Get("x-app"); got != "" { + t.Fatalf("x-app = %q, want empty", got) + } + if got := req.Header.Get("X-Claude-Code-Session-Id"); got != "" { + t.Fatalf("X-Claude-Code-Session-Id = %q, want empty", got) + } + if got := req.Header.Get("x-client-request-id"); got != "" { + t.Fatalf("x-client-request-id = %q, want empty", got) + } +} + +func TestBackgroundSimulatorDoRequest_UsesSharedUpstreamForHTTPS(t *testing.T) { + stub := &bootstrapHTTPUpstreamStub{} + bg := &backgroundSimulator{} + profile := &tlsfingerprint.Profile{Name: "test-profile"} + state := &accountBackgroundState{ + accountID: 7, + proxyURL: "http://127.0.0.1:8080", + httpUpstream: stub, + tlsProfile: profile, + useSharedUpstream: true, + } + + req, err := http.NewRequest(http.MethodGet, "https://api.anthropic.com/api/claude_code/policy_limits", nil) + if err != nil { + t.Fatalf("NewRequest() error = %v", err) + } + + resp, err := bg.doRequest(context.Background(), state, req) + if err != nil { + t.Fatalf("doRequest() error = %v", err) + } + defer resp.Body.Close() + + if !stub.doWithTLSCalled { + t.Fatal("expected DoWithTLS to be called for https background requests") + } + if stub.doCalled { + t.Fatal("did not expect Do to be called for https background requests") + } + if stub.lastProxyURL != state.proxyURL { + t.Fatalf("proxyURL = %q, want %q", stub.lastProxyURL, state.proxyURL) + } + if stub.lastAccountID != state.accountID { + t.Fatalf("accountID = %d, want %d", stub.lastAccountID, state.accountID) + } + if stub.lastConcurrency != 0 { + t.Fatalf("accountConcurrency = %d, want 0", stub.lastConcurrency) + } + if stub.lastTLSProfile != profile { + t.Fatalf("TLS profile pointer mismatch") + } + if stub.lastRequestMethod != http.MethodGet { + t.Fatalf("request method = %q, want %q", stub.lastRequestMethod, http.MethodGet) + } +} diff --git a/backend/internal/service/gateway_attribution.go b/backend/internal/service/gateway_attribution.go index e8beb871..9e2d99bc 100644 --- a/backend/internal/service/gateway_attribution.go +++ b/backend/internal/service/gateway_attribution.go @@ -5,9 +5,11 @@ import ( "encoding/hex" "fmt" "regexp" + "strings" "sync" "time" + claude "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/google/uuid" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -23,6 +25,12 @@ const ( fingerprintSalt = "59cf53e54c78" ) +type attributionBlockOptions struct { + Entrypoint string + Workload string + OmitCCH bool +} + // computeAttributionFingerprint computes a 3-character hex fingerprint // matching the algorithm in the real Claude Code CLI. // @@ -82,12 +90,32 @@ func extractFirstUserMessageText(body []byte) string { // // Format: x-anthropic-billing-header: cc_version=.; cc_entrypoint=cli; cch=00000; // Source: extracted/src/constants/system.ts:73-95 -func buildAttributionBlock(cliVersion, fingerprint string) string { +func buildAttributionBlock(cliVersion, fingerprint string, opts attributionBlockOptions) string { + if claude.AttributionHeaderDisabled() { + return "" + } version := cliVersion + "." + fingerprint - // 2.1.89 起 cch=00000 出现在所有安装模式(含 npm 版),不再只限于原生二进制。 - // 原生二进制由 Bun 的 Zig 层在运行时将 00000 替换为真实 attestation hash; - // 普通安装版保持 00000 占位符不变。 - return fmt.Sprintf("x-anthropic-billing-header: cc_version=%s; cc_entrypoint=cli; cch=00000;", version) + entrypoint := strings.TrimSpace(opts.Entrypoint) + if entrypoint == "" { + entrypoint = claude.CurrentEntrypoint() + } + workload := strings.TrimSpace(opts.Workload) + if workload == "" { + workload = claude.CurrentWorkload() + } + + var b strings.Builder + b.Grow(96) + fmt.Fprintf(&b, "x-anthropic-billing-header: cc_version=%s; cc_entrypoint=%s;", version, entrypoint) + if !opts.OmitCCH { + // 2.1.89+ 的 Claude Code 在 1P / standard providers 下保留 cch=00000 占位符, + // 由下游 attestation / signing 逻辑在需要时替换。 + b.WriteString(" cch=00000;") + } + if workload != "" { + fmt.Fprintf(&b, " cc_workload=%s;", workload) + } + return b.String() } // injectAttributionBlock prepends the x-anthropic-billing-header attribution block @@ -95,11 +123,14 @@ func buildAttributionBlock(cliVersion, fingerprint string) string { // This must come BEFORE the "You are Claude Code" block. // // The real CLI injects this as system[0] with cache_control: {type: "ephemeral"}. -func injectAttributionBlock(body []byte, cliVersion string) []byte { +func injectAttributionBlock(body []byte, cliVersion string, opts attributionBlockOptions) []byte { // Compute fingerprint from the first user message firstMsgText := extractFirstUserMessageText(body) fingerprint := computeAttributionFingerprint(firstMsgText, cliVersion) - attribution := buildAttributionBlock(cliVersion, fingerprint) + attribution := buildAttributionBlock(cliVersion, fingerprint, opts) + if attribution == "" { + return body + } // Build the attribution text block as JSON attrBlock, err := marshalAnthropicSystemTextBlock(attribution, true) diff --git a/backend/internal/service/gateway_attribution_test.go b/backend/internal/service/gateway_attribution_test.go new file mode 100644 index 00000000..f946c1c3 --- /dev/null +++ b/backend/internal/service/gateway_attribution_test.go @@ -0,0 +1,81 @@ +package service + +import ( + "net/http" + "testing" +) + +func TestApplyClaudeRuntimeOptionalHeaders(t *testing.T) { + t.Setenv("CLAUDE_CODE_CONTAINER_ID", "ctr-123") + t.Setenv("CLAUDE_CODE_REMOTE_SESSION_ID", "remote-456") + t.Setenv("CLAUDE_AGENT_SDK_CLIENT_APP", "desktop") + t.Setenv("CLAUDE_CODE_ADDITIONAL_PROTECTION", "true") + + req, err := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages", nil) + if err != nil { + t.Fatalf("NewRequest() error = %v", err) + } + + applyClaudeRuntimeOptionalHeaders(req) + + if got := getHeaderRaw(req.Header, "x-claude-remote-container-id"); got != "ctr-123" { + t.Fatalf("x-claude-remote-container-id = %q", got) + } + if got := getHeaderRaw(req.Header, "x-claude-remote-session-id"); got != "remote-456" { + t.Fatalf("x-claude-remote-session-id = %q", got) + } + if got := getHeaderRaw(req.Header, "x-client-app"); got != "desktop" { + t.Fatalf("x-client-app = %q", got) + } + if got := getHeaderRaw(req.Header, "x-anthropic-additional-protection"); got != "true" { + t.Fatalf("x-anthropic-additional-protection = %q", got) + } +} + +func TestBuildAttributionBlock_UsesEntrypointAndWorkload(t *testing.T) { + t.Setenv("CLAUDE_CODE_ATTRIBUTION_HEADER", "") + + got := buildAttributionBlock("2.1.104", "abc", attributionBlockOptions{ + Entrypoint: "sdk-cli", + Workload: "cron", + }) + want := "x-anthropic-billing-header: cc_version=2.1.104.abc; cc_entrypoint=sdk-cli; cch=00000; cc_workload=cron;" + if got != want { + t.Fatalf("buildAttributionBlock() = %q, want %q", got, want) + } +} + +func TestBuildAttributionBlock_OmitsCCHForBedrockLikeProviders(t *testing.T) { + t.Setenv("CLAUDE_CODE_ATTRIBUTION_HEADER", "") + + got := buildAttributionBlock("2.1.104", "abc", attributionBlockOptions{ + Entrypoint: "cli", + OmitCCH: true, + }) + want := "x-anthropic-billing-header: cc_version=2.1.104.abc; cc_entrypoint=cli;" + if got != want { + t.Fatalf("buildAttributionBlock() = %q, want %q", got, want) + } +} + +func TestInjectAttributionBlock_DisabledByEnv(t *testing.T) { + t.Setenv("CLAUDE_CODE_ATTRIBUTION_HEADER", "false") + + body := []byte(`{"messages":[{"role":"user","content":"hello"}]}`) + got := injectAttributionBlock(body, "2.1.104", attributionBlockOptions{}) + if string(got) != string(body) { + t.Fatalf("injectAttributionBlock() should keep body unchanged when attribution disabled") + } +} + +func TestShouldOmitAttributionCCH(t *testing.T) { + if !shouldOmitAttributionCCH(&Account{Type: AccountTypeBedrock}, "") { + t.Fatal("expected bedrock account to omit cch") + } + if !shouldOmitAttributionCCH(&Account{Extra: map[string]any{"provider": "mantle"}}, "") { + t.Fatal("expected mantle provider to omit cch") + } + if shouldOmitAttributionCCH(&Account{Type: AccountTypeOAuth}, "oauth") { + t.Fatal("expected oauth account to keep cch") + } +} diff --git a/backend/internal/service/gateway_billing_header_test.go b/backend/internal/service/gateway_billing_header_test.go index ffc4091c..29cada78 100644 --- a/backend/internal/service/gateway_billing_header_test.go +++ b/backend/internal/service/gateway_billing_header_test.go @@ -21,7 +21,7 @@ func TestSyncBillingHeaderVersion(t *testing.T) { { name: "replaces cc_version preserving message-derived suffix", body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81.df2; cc_entrypoint=cli; cch=00000;"},{"type":"text","text":"You are Claude Code.","cache_control":{"type":"ephemeral"}}],"messages":[]}`, - userAgent: "claude-cli/2.1.22 (external, cli)", + userAgent: "claude-cli/2.1.22 (external, sdk-cli)", wantSub: "cc_version=2.1.22.df2", }, { diff --git a/backend/internal/service/gateway_claude_runtime_headers.go b/backend/internal/service/gateway_claude_runtime_headers.go new file mode 100644 index 00000000..d8d3dd9a --- /dev/null +++ b/backend/internal/service/gateway_claude_runtime_headers.go @@ -0,0 +1,47 @@ +package service + +import ( + "net/http" + "strings" + + claude "github.com/Wei-Shaw/sub2api/internal/pkg/claude" +) + +func applyClaudeRuntimeOptionalHeaders(req *http.Request) { + if req == nil { + return + } + for key, value := range claude.OptionalAPIHeaders() { + if strings.TrimSpace(value) == "" { + continue + } + setHeaderRaw(req.Header, resolveWireCasing(key), value) + } +} + +func attributionOptionsForRequest(account *Account, tokenType string) attributionBlockOptions { + return attributionBlockOptions{ + Entrypoint: claude.CurrentEntrypoint(), + Workload: claude.CurrentWorkload(), + OmitCCH: shouldOmitAttributionCCH(account, tokenType), + } +} + +func shouldOmitAttributionCCH(account *Account, tokenType string) bool { + if strings.EqualFold(strings.TrimSpace(tokenType), "bedrock") { + return true + } + if account == nil { + return false + } + if account.Type == AccountTypeBedrock { + return true + } + for _, key := range []string{"provider", "upstream_provider"} { + switch strings.ToLower(strings.TrimSpace(account.GetExtraString(key))) { + case "bedrock", "anthropicaws", "anthropic_aws", "mantle": + return true + } + } + return false +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 08780e5d..25aeb562 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -255,6 +255,10 @@ func buildClaudeMimicDebugLine(req *http.Request, body []byte, account *Account, interesting := []string{ "user-agent", "x-app", + "x-client-app", + "x-claude-remote-session-id", + "x-claude-remote-container-id", + "x-anthropic-additional-protection", "anthropic-dangerous-direct-browser-access", "anthropic-version", "anthropic-beta", @@ -373,6 +377,10 @@ var allowedHeaders = map[string]bool{ "accept-encoding": true, "x-claude-code-session-id": true, "x-client-request-id": true, + "x-client-app": true, + "x-claude-remote-session-id": true, + "x-claude-remote-container-id": true, + "x-anthropic-additional-protection": true, } // GatewayCache 定义网关服务的缓存操作接口。 @@ -4031,7 +4039,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A attrCLIVersion = v } } - body = injectAttributionBlock(body, attrCLIVersion) + body = injectAttributionBlock(body, attrCLIVersion, attributionOptionsForRequest(account, "oauth")) } // 强制执行 cache_control 块数量限制(最多 4 个) @@ -4068,20 +4076,6 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A return nil, err } - // Bootstrap 预热:模拟真实 CLI 启动时的 GET /api/claude_cli/bootstrap 调用 - // 真实 CLI 在首次 messages 请求前 fire-and-forget 调用此端点。 - if tokenType == "oauth" && token != "" { - TriggerBootstrapIfNeeded(account.ID, token) - // OTEL telemetry: emit pre-request events (tengu_started, tengu_api_query etc.) - go telemetry.EmitPreRequest( - fmt.Sprintf("%d", account.ID), - token, - token, - reqModel, - getHeaderRaw(c.Request.Header, "anthropic-beta"), - ) - } - // 获取代理URL(自定义 base URL 模式下,proxy 通过 buildCustomRelayURL 作为查询参数传递) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { @@ -4093,6 +4087,32 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A // 解析 TLS 指纹 profile(同一请求生命周期内不变,避免重试循环中重复解析) tlsProfile := s.tlsFPProfileService.ResolveTLSProfile(account) + // Bootstrap 预热:模拟真实 CLI 启动时的 GET /api/claude_cli/bootstrap 调用 + // 真实 CLI 在首次 messages 请求前 fire-and-forget 调用此端点。 + if tokenType == "oauth" && token != "" { + instanceSalt := "" + useSharedUpstream := proxyURL != "" || tlsProfile != nil + if s.cfg != nil { + instanceSalt = s.cfg.Gateway.InstanceSalt + useSharedUpstream = useSharedUpstream || s.cfg.Gateway.TLSFingerprint.Enabled + } + TriggerBootstrapIfNeeded(account.ID, token, &BackgroundRequestOptions{ + ProxyURL: proxyURL, + HTTPUpstream: s.httpUpstream, + TLSProfile: tlsProfile, + InstanceSalt: instanceSalt, + UseSharedUpstream: useSharedUpstream, + }) + // OTEL telemetry: emit pre-request events (tengu_started, tengu_api_query etc.) + go telemetry.EmitPreRequest( + fmt.Sprintf("%d", account.ID), + token, + token, + reqModel, + getHeaderRaw(c.Request.Header, "anthropic-beta"), + ) + } + // 调试日志:记录即将转发的账号信息 logger.LegacyPrintf("service.gateway", "[Forward] Using account: ID=%d Name=%s Platform=%s Type=%s TLSFingerprint=%v Proxy=%s", account.ID, account.Name, account.Platform, account.Type, tlsProfile, proxyURL) @@ -5744,6 +5764,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex if getHeaderRaw(req.Header, "x-client-request-id") == "" && tokenType == "oauth" { setHeaderRaw(req.Header, "x-client-request-id", uuid.New().String()) } + applyClaudeRuntimeOptionalHeaders(req) // === DEBUG: 打印上游转发请求(headers + body 摘要),与 CLIENT_ORIGINAL 对比 === s.debugLogGatewaySnapshot("UPSTREAM_FORWARD", req.Header, body, map[string]string{ @@ -5762,7 +5783,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex // 1. 清理任何可能暴露 Go 客户端身份的头 if ua := req.Header.Get("User-Agent"); ua == "" || strings.Contains(ua, "Go-http-client") { // User-Agent 缺失或包含 Go 指示,修复为 Node.js 格式 - setHeaderRaw(req.Header, "User-Agent", "claude-cli/2.1.88 (external, cli)") + setHeaderRaw(req.Header, "User-Agent", claude.DefaultUserAgent()) } // 2. 验证 Node.js 指纹完整性(用于调试日志) @@ -5858,7 +5879,7 @@ func applyClaudeOAuthHeaderDefaults(req *http.Request) { if getHeaderRaw(req.Header, "Accept") == "" { setHeaderRaw(req.Header, "Accept", "application/json") } - for key, value := range claude.DefaultHeaders { + for key, value := range claude.DefaultHeadersSnapshot() { if value == "" { continue } @@ -6192,7 +6213,7 @@ func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) { applyClaudeOAuthHeaderDefaults(req) // Then force key headers to match Claude Code fingerprint regardless of what the client sent. // 使用 resolveWireCasing 确保 key 与真实 wire format 一致(如 "x-app" 而非 "X-App") - for key, value := range claude.DefaultHeaders { + for key, value := range claude.DefaultHeadersSnapshot() { if value == "" { continue } @@ -8688,6 +8709,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con if getHeaderRaw(req.Header, "x-client-request-id") == "" && tokenType == "oauth" { setHeaderRaw(req.Header, "x-client-request-id", uuid.New().String()) } + applyClaudeRuntimeOptionalHeaders(req) if c != nil && tokenType == "oauth" { c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode)) diff --git a/backend/internal/service/header_util.go b/backend/internal/service/header_util.go index 1091070d..f98f3acf 100644 --- a/backend/internal/service/header_util.go +++ b/backend/internal/service/header_util.go @@ -31,16 +31,20 @@ var headerWireCasing = map[string]string{ "anthropic-version": "anthropic-version", "anthropic-beta": "anthropic-beta", "x-app": "x-app", + "x-client-app": "x-client-app", "content-type": "content-type", "accept-language": "accept-language", "sec-fetch-mode": "sec-fetch-mode", "accept-encoding": "accept-encoding", "authorization": "authorization", + "x-anthropic-additional-protection": "x-anthropic-additional-protection", // Claude Code 2.1.87+ 新增 header - "x-claude-code-session-id": "X-Claude-Code-Session-Id", - "x-client-request-id": "x-client-request-id", - "content-length": "content-length", + "x-claude-code-session-id": "X-Claude-Code-Session-Id", + "x-claude-remote-session-id": "x-claude-remote-session-id", + "x-claude-remote-container-id": "x-claude-remote-container-id", + "x-client-request-id": "x-client-request-id", + "content-length": "content-length", } // headerWireOrder 定义真实 Claude CLI 发送 header 的顺序(基于抓包)。 @@ -61,9 +65,13 @@ var headerWireOrder = []string{ "x-app", "User-Agent", "X-Claude-Code-Session-Id", + "x-claude-remote-container-id", + "x-claude-remote-session-id", + "x-client-app", "content-type", "anthropic-beta", "x-client-request-id", + "x-anthropic-additional-protection", "accept-language", "sec-fetch-mode", "accept-encoding", diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go index f5f180b0..91d452db 100644 --- a/backend/internal/service/identity_service.go +++ b/backend/internal/service/identity_service.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -24,17 +25,22 @@ var ( userAgentVersionRegex = regexp.MustCompile(`/(\d+)\.(\d+)\.(\d+)`) ) -// 默认指纹值(当客户端未提供时使用) -var defaultFingerprint = Fingerprint{ - UserAgent: "claude-cli/2.1.89 (external, cli)", - StainlessLang: "js", - StainlessPackageVersion: "0.74.0", - StainlessOS: "MacOS", - StainlessArch: "arm64", - StainlessRuntime: "node", - StainlessRuntimeVersion: "v24.3.0", +func defaultIdentityFingerprint() Fingerprint { + profile := claude.DefaultDeviceProfile() + return Fingerprint{ + UserAgent: profile.UserAgent, + StainlessLang: profile.StainlessLang, + StainlessPackageVersion: profile.StainlessPackageVersion, + StainlessOS: profile.StainlessOS, + StainlessArch: profile.StainlessArch, + StainlessRuntime: profile.StainlessRuntime, + StainlessRuntimeVersion: profile.StainlessRuntimeVersion, + } } +// 默认指纹值(当客户端未提供时使用) +var defaultFingerprint = defaultIdentityFingerprint() + // Fingerprint represents account fingerprint data type Fingerprint struct { ClientID string @@ -405,7 +411,7 @@ func parseUserAgentVersion(ua string) (major, minor, patch int, ok bool) { } // extractProduct 提取 User-Agent 中 "/" 前的产品名 -// 例如:claude-cli/2.1.22 (external, cli) -> "claude-cli" +// 例如:claude-cli/2.1.22 (external, sdk-cli) -> "claude-cli" func extractProduct(ua string) string { if idx := strings.Index(ua, "/"); idx > 0 { return strings.ToLower(ua[:idx]) diff --git a/backend/internal/service/identity_service_antigravity.go b/backend/internal/service/identity_service_antigravity.go index e725a7fb..01077331 100644 --- a/backend/internal/service/identity_service_antigravity.go +++ b/backend/internal/service/identity_service_antigravity.go @@ -1,5 +1,7 @@ package service +import "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + // ============================================================== // antigravity — identity_service 扩展 // @@ -14,21 +16,8 @@ package service // ApplyDefaultFingerprintOverrides 用配置覆盖 identity_service 的默认指纹 // 允许不同部署实例设置不同的 CLI/SDK 版本号,避免所有实例指纹相同 func ApplyDefaultFingerprintOverrides(cliVersion, pkgVersion, runtimeVersion, os_, arch string) { - if cliVersion != "" { - defaultFingerprint.UserAgent = "claude-cli/" + cliVersion + " (external, cli)" - } - if pkgVersion != "" { - defaultFingerprint.StainlessPackageVersion = pkgVersion - } - if runtimeVersion != "" { - defaultFingerprint.StainlessRuntimeVersion = runtimeVersion - } - if os_ != "" { - defaultFingerprint.StainlessOS = os_ - } - if arch != "" { - defaultFingerprint.StainlessArch = arch - } + claude.ApplyFingerprintOverrides(cliVersion, pkgVersion, runtimeVersion, os_, arch) + defaultFingerprint = defaultIdentityFingerprint() } // NewIdentityServiceWithSalt 创建带实例盐值的 IdentityService diff --git a/backend/internal/service/identity_service_order_test.go b/backend/internal/service/identity_service_order_test.go index d1e12274..14e2ff52 100644 --- a/backend/internal/service/identity_service_order_test.go +++ b/backend/internal/service/identity_service_order_test.go @@ -38,7 +38,7 @@ func TestIdentityService_RewriteUserID_PreservesTopLevelFieldOrder(t *testing.T) ) body := []byte(`{"alpha":1,"messages":[],"metadata":{"user_id":` + strconvQuote(originalUserID) + `},"max_tokens":64000,"thinking":{"type":"adaptive"},"output_config":{"effort":"high"},"stream":true}`) - result, err := svc.RewriteUserID(body, 123, "acc-uuid", "client-xyz", "claude-cli/2.1.78 (external, cli)") + result, err := svc.RewriteUserID(body, 123, "acc-uuid", "client-xyz", "claude-cli/2.1.78 (external, sdk-cli)") require.NoError(t, err) resultStr := string(result) @@ -68,7 +68,7 @@ func TestIdentityService_RewriteUserIDWithMasking_PreservesTopLevelFieldOrder(t }, } - result, err := svc.RewriteUserIDWithMasking(context.Background(), body, account, "acc-uuid", "client-xyz", "claude-cli/2.1.78 (external, cli)") + result, err := svc.RewriteUserIDWithMasking(context.Background(), body, account, "acc-uuid", "client-xyz", "claude-cli/2.1.78 (external, sdk-cli)") require.NoError(t, err) resultStr := string(result)