x
This commit is contained in:
parent
ac7f95cbf9
commit
435ae221bc
@ -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"`
|
||||
|
||||
70
backend/internal/pkg/antigravity/claude_code_tool_map.go
Normal file
70
backend/internal/pkg/antigravity/claude_code_tool_map.go
Normal file
@ -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
|
||||
}
|
||||
160
backend/internal/pkg/antigravity/claude_code_tool_map_test.go
Normal file
160
backend/internal/pkg/antigravity/claude_code_tool_map_test.go
Normal file
@ -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
|
||||
}
|
||||
@ -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 用量统计
|
||||
|
||||
@ -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 安全设置
|
||||
|
||||
@ -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 = `<identity>
|
||||
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]"
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 != "" {
|
||||
|
||||
@ -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 != "" {
|
||||
|
||||
@ -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 模型
|
||||
|
||||
121
backend/internal/pkg/claude/constants_test.go
Normal file
121
backend/internal/pkg/claude/constants_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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 设置
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
159
backend/internal/repository/transport_audit.go
Normal file
159
backend/internal/repository/transport_audit.go
Normal file
@ -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)
|
||||
}
|
||||
95
backend/internal/repository/transport_audit_test.go
Normal file
95
backend/internal/repository/transport_audit_test.go
Normal file
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
147
backend/internal/server/routes/common_test.go
Normal file
147
backend/internal/server/routes/common_test.go
Normal file
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
143
backend/internal/service/bootstrap_preflight_test.go
Normal file
143
backend/internal/service/bootstrap_preflight_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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=<VERSION>.<fingerprint>; 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)
|
||||
|
||||
81
backend/internal/service/gateway_attribution_test.go
Normal file
81
backend/internal/service/gateway_attribution_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
47
backend/internal/service/gateway_claude_runtime_headers.go
Normal file
47
backend/internal/service/gateway_claude_runtime_headers.go
Normal file
@ -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
|
||||
}
|
||||
@ -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))
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user