x
Some checks failed
CI / test (push) Failing after 1m32s
CI / golangci-lint (push) Failing after 31s
Security Scan / backend-security (push) Failing after 1m32s
Security Scan / frontend-security (push) Failing after 9s

This commit is contained in:
win 2026-04-16 19:11:47 +08:00
parent ac7f95cbf9
commit 435ae221bc
33 changed files with 2741 additions and 292 deletions

View File

@ -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"`

View 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
}

View 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
}

View File

@ -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 用量统计

View File

@ -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 安全设置

View File

@ -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 模型需要 toolConfigClaude 模型不需要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]"
}

View File

@ -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)
}

View File

@ -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 != "" {

View File

@ -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 != "" {

View File

@ -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 模型

View 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")
}
}

View File

@ -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 设置

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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
}

View 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)
}

View 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)
}
})
}
}

View File

@ -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) {

View 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)
}
})
}
}

View File

@ -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")

View File

@ -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()

View File

@ -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()

View 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)
}
}

View File

@ -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)

View 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")
}
}

View File

@ -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",
},
{

View 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
}

View File

@ -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))

View File

@ -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",

View File

@ -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])

View File

@ -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

View File

@ -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)