Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
2fe8932c
Unverified
Commit
2fe8932c
authored
Feb 03, 2026
by
Call White
Committed by
GitHub
Feb 03, 2026
Browse files
Merge pull request #3 from cyhhao/main
merge to main
parents
2f2e76f9
adb77af1
Changes
267
Show whitespace changes
Inline
Side-by-side
backend/internal/handler/user_handler.go
View file @
2fe8932c
...
...
@@ -47,9 +47,6 @@ func (h *UserHandler) GetProfile(c *gin.Context) {
return
}
// 清空notes字段,普通用户不应看到备注
userData
.
Notes
=
""
response
.
Success
(
c
,
dto
.
UserFromService
(
userData
))
}
...
...
@@ -105,8 +102,5 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
return
}
// 清空notes字段,普通用户不应看到备注
updatedUser
.
Notes
=
""
response
.
Success
(
c
,
dto
.
UserFromService
(
updatedUser
))
}
backend/internal/handler/wire.go
View file @
2fe8932c
...
...
@@ -70,6 +70,7 @@ func ProvideHandlers(
gatewayHandler
*
GatewayHandler
,
openaiGatewayHandler
*
OpenAIGatewayHandler
,
settingHandler
*
SettingHandler
,
totpHandler
*
TotpHandler
,
)
*
Handlers
{
return
&
Handlers
{
Auth
:
authHandler
,
...
...
@@ -82,6 +83,7 @@ func ProvideHandlers(
Gateway
:
gatewayHandler
,
OpenAIGateway
:
openaiGatewayHandler
,
Setting
:
settingHandler
,
Totp
:
totpHandler
,
}
}
...
...
@@ -96,6 +98,7 @@ var ProviderSet = wire.NewSet(
NewSubscriptionHandler
,
NewGatewayHandler
,
NewOpenAIGatewayHandler
,
NewTotpHandler
,
ProvideSettingHandler
,
// Admin handlers
...
...
backend/internal/middleware/rate_limiter_integration_test.go
View file @
2fe8932c
...
...
@@ -7,6 +7,9 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"testing"
"time"
...
...
@@ -88,6 +91,7 @@ func performRequest(router *gin.Engine) *httptest.ResponseRecorder {
func
startRedis
(
t
*
testing
.
T
,
ctx
context
.
Context
)
*
redis
.
Client
{
t
.
Helper
()
ensureDockerAvailable
(
t
)
redisContainer
,
err
:=
tcredis
.
Run
(
ctx
,
redisImageTag
)
require
.
NoError
(
t
,
err
)
...
...
@@ -112,3 +116,43 @@ func startRedis(t *testing.T, ctx context.Context) *redis.Client {
return
rdb
}
func
ensureDockerAvailable
(
t
*
testing
.
T
)
{
t
.
Helper
()
if
dockerAvailable
()
{
return
}
t
.
Skip
(
"Docker 未启用,跳过依赖 testcontainers 的集成测试"
)
}
func
dockerAvailable
()
bool
{
if
os
.
Getenv
(
"DOCKER_HOST"
)
!=
""
{
return
true
}
socketCandidates
:=
[]
string
{
"/var/run/docker.sock"
,
filepath
.
Join
(
os
.
Getenv
(
"XDG_RUNTIME_DIR"
),
"docker.sock"
),
filepath
.
Join
(
userHomeDir
(),
".docker"
,
"run"
,
"docker.sock"
),
filepath
.
Join
(
userHomeDir
(),
".docker"
,
"desktop"
,
"docker.sock"
),
filepath
.
Join
(
"/run/user"
,
strconv
.
Itoa
(
os
.
Getuid
()),
"docker.sock"
),
}
for
_
,
socket
:=
range
socketCandidates
{
if
socket
==
""
{
continue
}
if
_
,
err
:=
os
.
Stat
(
socket
);
err
==
nil
{
return
true
}
}
return
false
}
func
userHomeDir
()
string
{
home
,
err
:=
os
.
UserHomeDir
()
if
err
!=
nil
{
return
""
}
return
home
}
backend/internal/pkg/antigravity/client.go
View file @
2fe8932c
...
...
@@ -16,15 +16,6 @@ import (
"time"
)
// resolveHost 从 URL 解析 host
func
resolveHost
(
urlStr
string
)
string
{
parsed
,
err
:=
url
.
Parse
(
urlStr
)
if
err
!=
nil
{
return
""
}
return
parsed
.
Host
}
// NewAPIRequestWithURL 使用指定的 base URL 创建 Antigravity API 请求(v1internal 端点)
func
NewAPIRequestWithURL
(
ctx
context
.
Context
,
baseURL
,
action
,
accessToken
string
,
body
[]
byte
)
(
*
http
.
Request
,
error
)
{
// 构建 URL,流式请求添加 ?alt=sse 参数
...
...
@@ -39,23 +30,11 @@ func NewAPIRequestWithURL(ctx context.Context, baseURL, action, accessToken stri
return
nil
,
err
}
// 基础 Headers
// 基础 Headers
(与 Antigravity-Manager 保持一致,只设置这 3 个)
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
req
.
Header
.
Set
(
"Authorization"
,
"Bearer "
+
accessToken
)
req
.
Header
.
Set
(
"User-Agent"
,
UserAgent
)
// Accept Header 根据请求类型设置
if
isStream
{
req
.
Header
.
Set
(
"Accept"
,
"text/event-stream"
)
}
else
{
req
.
Header
.
Set
(
"Accept"
,
"application/json"
)
}
// 显式设置 Host Header
if
host
:=
resolveHost
(
apiURL
);
host
!=
""
{
req
.
Host
=
host
}
return
req
,
nil
}
...
...
@@ -195,12 +174,15 @@ func isConnectionError(err error) bool {
}
// shouldFallbackToNextURL 判断是否应切换到下一个 URL
//
仅连接错误和 HTTP 429
触发 URL 降级
//
与 Antigravity-Manager 保持一致:连接错误、429、408、404、5xx
触发 URL 降级
func
shouldFallbackToNextURL
(
err
error
,
statusCode
int
)
bool
{
if
isConnectionError
(
err
)
{
return
true
}
return
statusCode
==
http
.
StatusTooManyRequests
return
statusCode
==
http
.
StatusTooManyRequests
||
statusCode
==
http
.
StatusRequestTimeout
||
statusCode
==
http
.
StatusNotFound
||
statusCode
>=
500
}
// ExchangeCode 用 authorization code 交换 token
...
...
@@ -321,11 +303,8 @@ func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadC
return
nil
,
nil
,
fmt
.
Errorf
(
"序列化请求失败: %w"
,
err
)
}
// 获取可用的 URL 列表
availableURLs
:=
DefaultURLAvailability
.
GetAvailableURLs
()
if
len
(
availableURLs
)
==
0
{
availableURLs
=
BaseURLs
// 所有 URL 都不可用时,重试所有
}
// 固定顺序:prod -> daily
availableURLs
:=
BaseURLs
var
lastErr
error
for
urlIdx
,
baseURL
:=
range
availableURLs
{
...
...
@@ -343,7 +322,6 @@ func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadC
if
err
!=
nil
{
lastErr
=
fmt
.
Errorf
(
"loadCodeAssist 请求失败: %w"
,
err
)
if
shouldFallbackToNextURL
(
err
,
0
)
&&
urlIdx
<
len
(
availableURLs
)
-
1
{
DefaultURLAvailability
.
MarkUnavailable
(
baseURL
)
log
.
Printf
(
"[antigravity] loadCodeAssist URL fallback: %s -> %s"
,
baseURL
,
availableURLs
[
urlIdx
+
1
])
continue
}
...
...
@@ -358,7 +336,6 @@ func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadC
// 检查是否需要 URL 降级
if
shouldFallbackToNextURL
(
nil
,
resp
.
StatusCode
)
&&
urlIdx
<
len
(
availableURLs
)
-
1
{
DefaultURLAvailability
.
MarkUnavailable
(
baseURL
)
log
.
Printf
(
"[antigravity] loadCodeAssist URL fallback (HTTP %d): %s -> %s"
,
resp
.
StatusCode
,
baseURL
,
availableURLs
[
urlIdx
+
1
])
continue
}
...
...
@@ -376,6 +353,8 @@ func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadC
var
rawResp
map
[
string
]
any
_
=
json
.
Unmarshal
(
respBodyBytes
,
&
rawResp
)
// 标记成功的 URL,下次优先使用
DefaultURLAvailability
.
MarkSuccess
(
baseURL
)
return
&
loadResp
,
rawResp
,
nil
}
...
...
@@ -412,11 +391,8 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
return
nil
,
nil
,
fmt
.
Errorf
(
"序列化请求失败: %w"
,
err
)
}
// 获取可用的 URL 列表
availableURLs
:=
DefaultURLAvailability
.
GetAvailableURLs
()
if
len
(
availableURLs
)
==
0
{
availableURLs
=
BaseURLs
// 所有 URL 都不可用时,重试所有
}
// 固定顺序:prod -> daily
availableURLs
:=
BaseURLs
var
lastErr
error
for
urlIdx
,
baseURL
:=
range
availableURLs
{
...
...
@@ -434,7 +410,6 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
if
err
!=
nil
{
lastErr
=
fmt
.
Errorf
(
"fetchAvailableModels 请求失败: %w"
,
err
)
if
shouldFallbackToNextURL
(
err
,
0
)
&&
urlIdx
<
len
(
availableURLs
)
-
1
{
DefaultURLAvailability
.
MarkUnavailable
(
baseURL
)
log
.
Printf
(
"[antigravity] fetchAvailableModels URL fallback: %s -> %s"
,
baseURL
,
availableURLs
[
urlIdx
+
1
])
continue
}
...
...
@@ -449,7 +424,6 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
// 检查是否需要 URL 降级
if
shouldFallbackToNextURL
(
nil
,
resp
.
StatusCode
)
&&
urlIdx
<
len
(
availableURLs
)
-
1
{
DefaultURLAvailability
.
MarkUnavailable
(
baseURL
)
log
.
Printf
(
"[antigravity] fetchAvailableModels URL fallback (HTTP %d): %s -> %s"
,
resp
.
StatusCode
,
baseURL
,
availableURLs
[
urlIdx
+
1
])
continue
}
...
...
@@ -467,6 +441,8 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
var
rawResp
map
[
string
]
any
_
=
json
.
Unmarshal
(
respBodyBytes
,
&
rawResp
)
// 标记成功的 URL,下次优先使用
DefaultURLAvailability
.
MarkSuccess
(
baseURL
)
return
&
modelsResp
,
rawResp
,
nil
}
...
...
backend/internal/pkg/antigravity/gemini_types.go
View file @
2fe8932c
...
...
@@ -146,6 +146,7 @@ type GeminiCandidate struct {
Content
*
GeminiContent
`json:"content,omitempty"`
FinishReason
string
`json:"finishReason,omitempty"`
Index
int
`json:"index,omitempty"`
GroundingMetadata
*
GeminiGroundingMetadata
`json:"groundingMetadata,omitempty"`
}
// GeminiUsageMetadata Gemini 用量元数据
...
...
@@ -156,6 +157,23 @@ type GeminiUsageMetadata struct {
TotalTokenCount
int
`json:"totalTokenCount,omitempty"`
}
// GeminiGroundingMetadata Gemini grounding 元数据(Web Search)
type
GeminiGroundingMetadata
struct
{
WebSearchQueries
[]
string
`json:"webSearchQueries,omitempty"`
GroundingChunks
[]
GeminiGroundingChunk
`json:"groundingChunks,omitempty"`
}
// GeminiGroundingChunk Gemini grounding chunk
type
GeminiGroundingChunk
struct
{
Web
*
GeminiGroundingWeb
`json:"web,omitempty"`
}
// GeminiGroundingWeb Gemini grounding web 信息
type
GeminiGroundingWeb
struct
{
Title
string
`json:"title,omitempty"`
URI
string
`json:"uri,omitempty"`
}
// DefaultSafetySettings 默认安全设置(关闭所有过滤)
var
DefaultSafetySettings
=
[]
GeminiSafetySetting
{
{
Category
:
"HARM_CATEGORY_HARASSMENT"
,
Threshold
:
"OFF"
},
...
...
backend/internal/pkg/antigravity/oauth.go
View file @
2fe8932c
...
...
@@ -32,8 +32,8 @@ const (
"https://www.googleapis.com/auth/cclog "
+
"https://www.googleapis.com/auth/experimentsandconfigs"
// User-Agent(
模拟官方客户端
)
UserAgent
=
"antigravity/1.1
04.0 darwin/arm
64"
// User-Agent(
与 Antigravity-Manager 保持一致
)
UserAgent
=
"antigravity/1.1
5.8 windows/amd
64"
// Session 过期时间
SessionTTL
=
30
*
time
.
Minute
...
...
@@ -42,22 +42,21 @@ const (
URLAvailabilityTTL
=
5
*
time
.
Minute
)
// BaseURLs 定义 Antigravity API 端点,按优先级排序
// fallback 顺序: sandbox → daily → prod
// BaseURLs 定义 Antigravity API 端点(与 Antigravity-Manager 保持一致)
var
BaseURLs
=
[]
string
{
"https://daily-cloudcode-pa.sandbox.googleapis.com"
,
// sandbox
"https://daily-cloudcode-pa.googleapis.com"
,
// daily
"https://cloudcode-pa.googleapis.com"
,
// prod
"https://cloudcode-pa.googleapis.com"
,
// prod (优先)
"https://daily-cloudcode-pa.sandbox.googleapis.com"
,
// daily sandbox (备用)
}
// BaseURL 默认 URL(保持向后兼容)
var
BaseURL
=
BaseURLs
[
0
]
// URLAvailability 管理 URL 可用性状态(带 TTL 自动恢复)
// URLAvailability 管理 URL 可用性状态(带 TTL 自动恢复
和动态优先级
)
type
URLAvailability
struct
{
mu
sync
.
RWMutex
unavailable
map
[
string
]
time
.
Time
// URL -> 恢复时间
ttl
time
.
Duration
lastSuccess
string
// 最近成功请求的 URL,优先使用
}
// DefaultURLAvailability 全局 URL 可用性管理器
...
...
@@ -78,6 +77,15 @@ func (u *URLAvailability) MarkUnavailable(url string) {
u
.
unavailable
[
url
]
=
time
.
Now
()
.
Add
(
u
.
ttl
)
}
// MarkSuccess 标记 URL 请求成功,将其设为优先使用
func
(
u
*
URLAvailability
)
MarkSuccess
(
url
string
)
{
u
.
mu
.
Lock
()
defer
u
.
mu
.
Unlock
()
u
.
lastSuccess
=
url
// 成功后清除该 URL 的不可用标记
delete
(
u
.
unavailable
,
url
)
}
// IsAvailable 检查 URL 是否可用
func
(
u
*
URLAvailability
)
IsAvailable
(
url
string
)
bool
{
u
.
mu
.
RLock
()
...
...
@@ -89,14 +97,29 @@ func (u *URLAvailability) IsAvailable(url string) bool {
return
time
.
Now
()
.
After
(
expiry
)
}
// GetAvailableURLs 返回可用的 URL 列表(保持优先级顺序)
// GetAvailableURLs 返回可用的 URL 列表
// 最近成功的 URL 优先,其他按默认顺序
func
(
u
*
URLAvailability
)
GetAvailableURLs
()
[]
string
{
u
.
mu
.
RLock
()
defer
u
.
mu
.
RUnlock
()
now
:=
time
.
Now
()
result
:=
make
([]
string
,
0
,
len
(
BaseURLs
))
// 如果有最近成功的 URL 且可用,放在最前面
if
u
.
lastSuccess
!=
""
{
expiry
,
exists
:=
u
.
unavailable
[
u
.
lastSuccess
]
if
!
exists
||
now
.
After
(
expiry
)
{
result
=
append
(
result
,
u
.
lastSuccess
)
}
}
// 添加其他可用的 URL(按默认顺序)
for
_
,
url
:=
range
BaseURLs
{
// 跳过已添加的 lastSuccess
if
url
==
u
.
lastSuccess
{
continue
}
expiry
,
exists
:=
u
.
unavailable
[
url
]
if
!
exists
||
now
.
After
(
expiry
)
{
result
=
append
(
result
,
url
)
...
...
@@ -240,24 +263,3 @@ func BuildAuthorizationURL(state, codeChallenge string) string {
return
fmt
.
Sprintf
(
"%s?%s"
,
AuthorizeURL
,
params
.
Encode
())
}
// GenerateMockProjectID 生成随机 project_id(当 API 不返回时使用)
// 格式:{形容词}-{名词}-{5位随机字符}
func
GenerateMockProjectID
()
string
{
adjectives
:=
[]
string
{
"useful"
,
"bright"
,
"swift"
,
"calm"
,
"bold"
}
nouns
:=
[]
string
{
"fuze"
,
"wave"
,
"spark"
,
"flow"
,
"core"
}
randBytes
,
_
:=
GenerateRandomBytes
(
7
)
adj
:=
adjectives
[
int
(
randBytes
[
0
])
%
len
(
adjectives
)]
noun
:=
nouns
[
int
(
randBytes
[
1
])
%
len
(
nouns
)]
// 生成 5 位随机字符(a-z0-9)
const
charset
=
"abcdefghijklmnopqrstuvwxyz0123456789"
suffix
:=
make
([]
byte
,
5
)
for
i
:=
0
;
i
<
5
;
i
++
{
suffix
[
i
]
=
charset
[
int
(
randBytes
[
i
+
2
])
%
len
(
charset
)]
}
return
fmt
.
Sprintf
(
"%s-%s-%s"
,
adj
,
noun
,
string
(
suffix
))
}
backend/internal/pkg/antigravity/request_transformer.go
View file @
2fe8932c
...
...
@@ -7,13 +7,11 @@ import (
"fmt"
"log"
"math/rand"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
...
...
@@ -54,6 +52,9 @@ func DefaultTransformOptions() TransformOptions {
}
}
// webSearchFallbackModel web_search 请求使用的降级模型
const
webSearchFallbackModel
=
"gemini-2.5-flash"
// TransformClaudeToGemini 将 Claude 请求转换为 v1internal Gemini 格式
func
TransformClaudeToGemini
(
claudeReq
*
ClaudeRequest
,
projectID
,
mappedModel
string
)
([]
byte
,
error
)
{
return
TransformClaudeToGeminiWithOptions
(
claudeReq
,
projectID
,
mappedModel
,
DefaultTransformOptions
())
...
...
@@ -64,12 +65,23 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
// 用于存储 tool_use id -> name 映射
toolIDToName
:=
make
(
map
[
string
]
string
)
// 检测是否有 web_search 工具
hasWebSearchTool
:=
hasWebSearchTool
(
claudeReq
.
Tools
)
requestType
:=
"agent"
targetModel
:=
mappedModel
if
hasWebSearchTool
{
requestType
=
"web_search"
if
targetModel
!=
webSearchFallbackModel
{
targetModel
=
webSearchFallbackModel
}
}
// 检测是否启用 thinking
isThinkingEnabled
:=
claudeReq
.
Thinking
!=
nil
&&
claudeReq
.
Thinking
.
Type
==
"enabled"
// 只有 Gemini 模型支持 dummy thought workaround
// Claude 模型通过 Vertex/Google API 需要有效的 thought signatures
allowDummyThought
:=
strings
.
HasPrefix
(
mapped
Model
,
"gemini-"
)
allowDummyThought
:=
strings
.
HasPrefix
(
target
Model
,
"gemini-"
)
// 1. 构建 contents
contents
,
strippedThinking
,
err
:=
buildContents
(
claudeReq
.
Messages
,
toolIDToName
,
isThinkingEnabled
,
allowDummyThought
)
...
...
@@ -78,7 +90,7 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
}
// 2. 构建 systemInstruction
systemInstruction
:=
buildSystemInstruction
(
claudeReq
.
System
,
claudeReq
.
Model
,
opts
)
systemInstruction
:=
buildSystemInstruction
(
claudeReq
.
System
,
claudeReq
.
Model
,
opts
,
claudeReq
.
Tools
)
// 3. 构建 generationConfig
reqForConfig
:=
claudeReq
...
...
@@ -89,6 +101,11 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
reqCopy
.
Thinking
=
nil
reqForConfig
=
&
reqCopy
}
if
targetModel
!=
""
&&
targetModel
!=
reqForConfig
.
Model
{
reqCopy
:=
*
reqForConfig
reqCopy
.
Model
=
targetModel
reqForConfig
=
&
reqCopy
}
generationConfig
:=
buildGenerationConfig
(
reqForConfig
)
// 4. 构建 tools
...
...
@@ -127,8 +144,8 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
Project
:
projectID
,
RequestID
:
"agent-"
+
uuid
.
New
()
.
String
(),
UserAgent
:
"antigravity"
,
// 固定值,与官方客户端一致
RequestType
:
"agent"
,
Model
:
mapped
Model
,
RequestType
:
requestType
,
Model
:
target
Model
,
Request
:
innerRequest
,
}
...
...
@@ -154,8 +171,40 @@ func GetDefaultIdentityPatch() string {
return
antigravityIdentity
}
// buildSystemInstruction 构建 systemInstruction
func
buildSystemInstruction
(
system
json
.
RawMessage
,
modelName
string
,
opts
TransformOptions
)
*
GeminiContent
{
// mcpXMLProtocol MCP XML 工具调用协议(与 Antigravity-Manager 保持一致)
const
mcpXMLProtocol
=
`
==== MCP XML 工具调用协议 (Workaround) ====
当你需要调用名称以 `
+
"`mcp__`"
+
` 开头的 MCP 工具时:
1) 优先尝试 XML 格式调用:输出 `
+
"`<mcp__tool_name>{
\"
arg
\"
:
\"
value
\"
}</mcp__tool_name>`"
+
`。
2) 必须直接输出 XML 块,无需 markdown 包装,内容为 JSON 格式的入参。
3) 这种方式具有更高的连通性和容错性,适用于大型结果返回场景。
===========================================`
// hasMCPTools 检测是否有 mcp__ 前缀的工具
func
hasMCPTools
(
tools
[]
ClaudeTool
)
bool
{
for
_
,
tool
:=
range
tools
{
if
strings
.
HasPrefix
(
tool
.
Name
,
"mcp__"
)
{
return
true
}
}
return
false
}
// filterOpenCodePrompt 过滤 OpenCode 默认提示词,只保留用户自定义指令
func
filterOpenCodePrompt
(
text
string
)
string
{
if
!
strings
.
Contains
(
text
,
"You are an interactive CLI tool"
)
{
return
text
}
// 提取 "Instructions from:" 及之后的部分
if
idx
:=
strings
.
Index
(
text
,
"Instructions from:"
);
idx
>=
0
{
return
text
[
idx
:
]
}
// 如果没有自定义指令,返回空
return
""
}
// buildSystemInstruction 构建 systemInstruction(与 Antigravity-Manager 保持一致)
func
buildSystemInstruction
(
system
json
.
RawMessage
,
modelName
string
,
opts
TransformOptions
,
tools
[]
ClaudeTool
)
*
GeminiContent
{
var
parts
[]
GeminiPart
// 先解析用户的 system prompt,检测是否已包含 Antigravity identity
...
...
@@ -167,10 +216,14 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans
var
sysStr
string
if
err
:=
json
.
Unmarshal
(
system
,
&
sysStr
);
err
==
nil
{
if
strings
.
TrimSpace
(
sysStr
)
!=
""
{
userSystemParts
=
append
(
userSystemParts
,
GeminiPart
{
Text
:
sysStr
})
if
strings
.
Contains
(
sysStr
,
"You are Antigravity"
)
{
userHasAntigravityIdentity
=
true
}
// 过滤 OpenCode 默认提示词
filtered
:=
filterOpenCodePrompt
(
sysStr
)
if
filtered
!=
""
{
userSystemParts
=
append
(
userSystemParts
,
GeminiPart
{
Text
:
filtered
})
}
}
}
else
{
// 尝试解析为数组
...
...
@@ -178,10 +231,14 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans
if
err
:=
json
.
Unmarshal
(
system
,
&
sysBlocks
);
err
==
nil
{
for
_
,
block
:=
range
sysBlocks
{
if
block
.
Type
==
"text"
&&
strings
.
TrimSpace
(
block
.
Text
)
!=
""
{
userSystemParts
=
append
(
userSystemParts
,
GeminiPart
{
Text
:
block
.
Text
})
if
strings
.
Contains
(
block
.
Text
,
"You are Antigravity"
)
{
userHasAntigravityIdentity
=
true
}
// 过滤 OpenCode 默认提示词
filtered
:=
filterOpenCodePrompt
(
block
.
Text
)
if
filtered
!=
""
{
userSystemParts
=
append
(
userSystemParts
,
GeminiPart
{
Text
:
filtered
})
}
}
}
}
...
...
@@ -200,6 +257,16 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans
// 添加用户的 system prompt
parts
=
append
(
parts
,
userSystemParts
...
)
// 检测是否有 MCP 工具,如有则注入 XML 调用协议
if
hasMCPTools
(
tools
)
{
parts
=
append
(
parts
,
GeminiPart
{
Text
:
mcpXMLProtocol
})
}
// 如果用户没有提供 Antigravity 身份,添加结束标记
if
!
userHasAntigravityIdentity
{
parts
=
append
(
parts
,
GeminiPart
{
Text
:
"
\n
--- [SYSTEM_PROMPT_END] ---"
})
}
if
len
(
parts
)
==
0
{
return
nil
}
...
...
@@ -300,8 +367,10 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu
Text
:
block
.
Thinking
,
Thought
:
true
,
}
// 保留原有 signature(Claude 模型需要有效的 signature)
if
block
.
Signature
!=
""
{
// signature 处理:
// - Claude 模型(allowDummyThought=false):必须是上游返回的真实 signature(dummy 视为缺失)
// - Gemini 模型(allowDummyThought=true):优先透传真实 signature,缺失时使用 dummy signature
if
block
.
Signature
!=
""
&&
(
allowDummyThought
||
block
.
Signature
!=
dummyThoughtSignature
)
{
part
.
ThoughtSignature
=
block
.
Signature
}
else
if
!
allowDummyThought
{
// Claude 模型需要有效 signature;在缺失时降级为普通文本,并在上层禁用 thinking mode。
...
...
@@ -340,12 +409,12 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu
},
}
// tool_use 的 signature 处理:
// - Gemini 模型:使用 dummy signature(跳过 thought_signature 校验)
// - Claude 模型:透传上游返回的真实 signature(Vertex/Google 需要完整签名链路)
if
allowDummyThought
{
part
.
ThoughtSignature
=
dummyThoughtSignature
}
else
if
block
.
Signature
!=
""
&&
block
.
Signature
!=
dummyThoughtSignature
{
// - Claude 模型(allowDummyThought=false):必须是上游返回的真实 signature(dummy 视为缺失)
// - Gemini 模型(allowDummyThought=true):优先透传真实 signature,缺失时使用 dummy signature
if
block
.
Signature
!=
""
&&
(
allowDummyThought
||
block
.
Signature
!=
dummyThoughtSignature
)
{
part
.
ThoughtSignature
=
block
.
Signature
}
else
if
allowDummyThought
{
part
.
ThoughtSignature
=
dummyThoughtSignature
}
parts
=
append
(
parts
,
part
)
...
...
@@ -429,6 +498,11 @@ func buildGenerationConfig(req *ClaudeRequest) *GeminiGenerationConfig {
StopSequences
:
DefaultStopSequences
,
}
// 如果请求中指定了 MaxTokens,使用请求值
if
req
.
MaxTokens
>
0
{
config
.
MaxOutputTokens
=
req
.
MaxTokens
}
// Thinking 配置
if
req
.
Thinking
!=
nil
&&
req
.
Thinking
.
Type
==
"enabled"
{
config
.
ThinkingConfig
=
&
GeminiThinkingConfig
{
...
...
@@ -458,37 +532,43 @@ func buildGenerationConfig(req *ClaudeRequest) *GeminiGenerationConfig {
return
config
}
// buildTools 构建 t
ool
s
func
buildTools
(
tools
[]
ClaudeTool
)
[]
GeminiToolDeclaration
{
if
len
(
tools
)
==
0
{
return
nil
func
hasWebSearchTool
(
tools
[]
ClaudeTool
)
b
ool
{
for
_
,
tool
:=
range
tools
{
if
isWebSearchTool
(
tool
)
{
return
true
}
}
return
false
}
// 检查是否有 web_search 工具
hasWebSearch
:=
false
for
_
,
tool
:=
range
tools
{
if
tool
.
Name
==
"web_search"
{
hasWebSearch
=
true
break
func
isWebSearchTool
(
tool
ClaudeTool
)
bool
{
if
strings
.
HasPrefix
(
tool
.
Type
,
"web_search"
)
||
tool
.
Type
==
"google_search"
{
return
true
}
name
:=
strings
.
TrimSpace
(
tool
.
Name
)
switch
name
{
case
"web_search"
,
"google_search"
,
"web_search_20250305"
:
return
true
default
:
return
false
}
}
if
hasWebSearch
{
// Web Search 工具映射
return
[]
GeminiToolDeclaration
{{
GoogleSearch
:
&
GeminiGoogleSearch
{
EnhancedContent
:
&
GeminiEnhancedContent
{
ImageSearch
:
&
GeminiImageSearch
{
MaxResultCount
:
5
,
},
},
},
}}
// buildTools 构建 tools
func
buildTools
(
tools
[]
ClaudeTool
)
[]
GeminiToolDeclaration
{
if
len
(
tools
)
==
0
{
return
nil
}
hasWebSearch
:=
hasWebSearchTool
(
tools
)
// 普通工具
var
funcDecls
[]
GeminiFunctionDecl
for
_
,
tool
:=
range
tools
{
if
isWebSearchTool
(
tool
)
{
continue
}
// 跳过无效工具名称
if
strings
.
TrimSpace
(
tool
.
Name
)
==
""
{
log
.
Printf
(
"Warning: skipping tool with empty name"
)
...
...
@@ -514,11 +594,14 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
}
// 清理 JSON Schema
params
:=
cleanJSONSchema
(
inputSchema
)
// 1. 深度清理 [undefined] 值
DeepCleanUndefined
(
inputSchema
)
// 2. 转换为符合 Gemini v1internal 的 schema
params
:=
CleanJSONSchema
(
inputSchema
)
// 为 nil schema 提供默认值
if
params
==
nil
{
params
=
map
[
string
]
any
{
"type"
:
"
OBJECT"
,
"type"
:
"
object"
,
// lowercase type
"properties"
:
map
[
string
]
any
{},
}
}
...
...
@@ -531,243 +614,23 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
}
if
len
(
funcDecls
)
==
0
{
if
!
hasWebSearch
{
return
nil
}
// Web Search 工具映射
return
[]
GeminiToolDeclaration
{{
FunctionDeclarations
:
funcDecls
,
GoogleSearch
:
&
GeminiGoogleSearch
{
EnhancedContent
:
&
GeminiEnhancedContent
{
ImageSearch
:
&
GeminiImageSearch
{
MaxResultCount
:
5
,
},
},
},
}}
}
// cleanJSONSchema 清理 JSON Schema,移除 Antigravity/Gemini 不支持的字段
// 参考 proxycast 的实现,确保 schema 符合 JSON Schema draft 2020-12
func
cleanJSONSchema
(
schema
map
[
string
]
any
)
map
[
string
]
any
{
if
schema
==
nil
{
return
nil
}
cleaned
:=
cleanSchemaValue
(
schema
,
"$"
)
result
,
ok
:=
cleaned
.
(
map
[
string
]
any
)
if
!
ok
{
return
nil
}
// 确保有 type 字段(默认 OBJECT)
if
_
,
hasType
:=
result
[
"type"
];
!
hasType
{
result
[
"type"
]
=
"OBJECT"
}
// 确保有 properties 字段(默认空对象)
if
_
,
hasProps
:=
result
[
"properties"
];
!
hasProps
{
result
[
"properties"
]
=
make
(
map
[
string
]
any
)
}
// 验证 required 中的字段都存在于 properties 中
if
required
,
ok
:=
result
[
"required"
]
.
([]
any
);
ok
{
if
props
,
ok
:=
result
[
"properties"
]
.
(
map
[
string
]
any
);
ok
{
validRequired
:=
make
([]
any
,
0
,
len
(
required
))
for
_
,
r
:=
range
required
{
if
reqName
,
ok
:=
r
.
(
string
);
ok
{
if
_
,
exists
:=
props
[
reqName
];
exists
{
validRequired
=
append
(
validRequired
,
r
)
}
}
}
if
len
(
validRequired
)
>
0
{
result
[
"required"
]
=
validRequired
}
else
{
delete
(
result
,
"required"
)
}
}
}
return
result
}
var
schemaValidationKeys
=
map
[
string
]
bool
{
"minLength"
:
true
,
"maxLength"
:
true
,
"pattern"
:
true
,
"minimum"
:
true
,
"maximum"
:
true
,
"exclusiveMinimum"
:
true
,
"exclusiveMaximum"
:
true
,
"multipleOf"
:
true
,
"uniqueItems"
:
true
,
"minItems"
:
true
,
"maxItems"
:
true
,
"minProperties"
:
true
,
"maxProperties"
:
true
,
"patternProperties"
:
true
,
"propertyNames"
:
true
,
"dependencies"
:
true
,
"dependentSchemas"
:
true
,
"dependentRequired"
:
true
,
}
var
warnedSchemaKeys
sync
.
Map
func
schemaCleaningWarningsEnabled
()
bool
{
// 可通过环境变量强制开关,方便排查:SUB2API_SCHEMA_CLEAN_WARN=true/false
if
v
:=
strings
.
TrimSpace
(
os
.
Getenv
(
"SUB2API_SCHEMA_CLEAN_WARN"
));
v
!=
""
{
switch
strings
.
ToLower
(
v
)
{
case
"1"
,
"true"
,
"yes"
,
"on"
:
return
true
case
"0"
,
"false"
,
"no"
,
"off"
:
return
false
}
}
// 默认:非 release 模式下输出(debug/test)
return
gin
.
Mode
()
!=
gin
.
ReleaseMode
}
func
warnSchemaKeyRemovedOnce
(
key
,
path
string
)
{
if
!
schemaCleaningWarningsEnabled
()
{
return
}
if
!
schemaValidationKeys
[
key
]
{
return
}
if
_
,
loaded
:=
warnedSchemaKeys
.
LoadOrStore
(
key
,
struct
{}{});
loaded
{
return
}
log
.
Printf
(
"[SchemaClean] removed unsupported JSON Schema validation field key=%q path=%q"
,
key
,
path
)
}
// excludedSchemaKeys 不支持的 schema 字段
// 基于 Claude API (Vertex AI) 的实际支持情况
// 支持: type, description, enum, properties, required, additionalProperties, items
// 不支持: minItems, maxItems, minLength, maxLength, pattern, minimum, maximum 等验证字段
var
excludedSchemaKeys
=
map
[
string
]
bool
{
// 元 schema 字段
"$schema"
:
true
,
"$id"
:
true
,
"$ref"
:
true
,
// 字符串验证(Gemini 不支持)
"minLength"
:
true
,
"maxLength"
:
true
,
"pattern"
:
true
,
// 数字验证(Claude API 通过 Vertex AI 不支持这些字段)
"minimum"
:
true
,
"maximum"
:
true
,
"exclusiveMinimum"
:
true
,
"exclusiveMaximum"
:
true
,
"multipleOf"
:
true
,
// 数组验证(Claude API 通过 Vertex AI 不支持这些字段)
"uniqueItems"
:
true
,
"minItems"
:
true
,
"maxItems"
:
true
,
// 组合 schema(Gemini 不支持)
"oneOf"
:
true
,
"anyOf"
:
true
,
"allOf"
:
true
,
"not"
:
true
,
"if"
:
true
,
"then"
:
true
,
"else"
:
true
,
"$defs"
:
true
,
"definitions"
:
true
,
// 对象验证(仅保留 properties/required/additionalProperties)
"minProperties"
:
true
,
"maxProperties"
:
true
,
"patternProperties"
:
true
,
"propertyNames"
:
true
,
"dependencies"
:
true
,
"dependentSchemas"
:
true
,
"dependentRequired"
:
true
,
// 其他不支持的字段
"default"
:
true
,
"const"
:
true
,
"examples"
:
true
,
"deprecated"
:
true
,
"readOnly"
:
true
,
"writeOnly"
:
true
,
"contentMediaType"
:
true
,
"contentEncoding"
:
true
,
// Claude 特有字段
"strict"
:
true
,
}
// cleanSchemaValue 递归清理 schema 值
func
cleanSchemaValue
(
value
any
,
path
string
)
any
{
switch
v
:=
value
.
(
type
)
{
case
map
[
string
]
any
:
result
:=
make
(
map
[
string
]
any
)
for
k
,
val
:=
range
v
{
// 跳过不支持的字段
if
excludedSchemaKeys
[
k
]
{
warnSchemaKeyRemovedOnce
(
k
,
path
)
continue
}
// 特殊处理 type 字段
if
k
==
"type"
{
result
[
k
]
=
cleanTypeValue
(
val
)
continue
}
// 特殊处理 format 字段:只保留 Gemini 支持的 format 值
if
k
==
"format"
{
if
formatStr
,
ok
:=
val
.
(
string
);
ok
{
// Gemini 只支持 date-time, date, time
if
formatStr
==
"date-time"
||
formatStr
==
"date"
||
formatStr
==
"time"
{
result
[
k
]
=
val
}
// 其他 format 值直接跳过
}
continue
}
// 特殊处理 additionalProperties:Claude API 只支持布尔值,不支持 schema 对象
if
k
==
"additionalProperties"
{
if
boolVal
,
ok
:=
val
.
(
bool
);
ok
{
result
[
k
]
=
boolVal
}
else
{
// 如果是 schema 对象,转换为 false(更安全的默认值)
result
[
k
]
=
false
}
continue
}
// 递归清理所有值
result
[
k
]
=
cleanSchemaValue
(
val
,
path
+
"."
+
k
)
}
return
result
case
[]
any
:
// 递归处理数组中的每个元素
cleaned
:=
make
([]
any
,
0
,
len
(
v
))
for
i
,
item
:=
range
v
{
cleaned
=
append
(
cleaned
,
cleanSchemaValue
(
item
,
fmt
.
Sprintf
(
"%s[%d]"
,
path
,
i
)))
}
return
cleaned
default
:
return
value
}
}
// cleanTypeValue 处理 type 字段,转换为大写
func
cleanTypeValue
(
value
any
)
any
{
switch
v
:=
value
.
(
type
)
{
case
string
:
return
strings
.
ToUpper
(
v
)
case
[]
any
:
// 联合类型 ["string", "null"] -> 取第一个非 null 类型
for
_
,
t
:=
range
v
{
if
ts
,
ok
:=
t
.
(
string
);
ok
&&
ts
!=
"null"
{
return
strings
.
ToUpper
(
ts
)
}
}
// 如果只有 null,返回 STRING
return
"STRING"
default
:
return
value
}
return
[]
GeminiToolDeclaration
{{
FunctionDeclarations
:
funcDecls
,
}}
}
backend/internal/pkg/antigravity/request_transformer_test.go
View file @
2fe8932c
...
...
@@ -100,7 +100,7 @@ func TestBuildParts_ToolUseSignatureHandling(t *testing.T) {
{"type": "tool_use", "id": "t1", "name": "Bash", "input": {"command": "ls"}, "signature": "sig_tool_abc"}
]`
t
.
Run
(
"Gemini
uses dummy
tool_use signature"
,
func
(
t
*
testing
.
T
)
{
t
.
Run
(
"Gemini
preserves provided
tool_use signature"
,
func
(
t
*
testing
.
T
)
{
toolIDToName
:=
make
(
map
[
string
]
string
)
parts
,
_
,
err
:=
buildParts
(
json
.
RawMessage
(
content
),
toolIDToName
,
true
)
if
err
!=
nil
{
...
...
@@ -109,6 +109,23 @@ func TestBuildParts_ToolUseSignatureHandling(t *testing.T) {
if
len
(
parts
)
!=
1
||
parts
[
0
]
.
FunctionCall
==
nil
{
t
.
Fatalf
(
"expected 1 functionCall part, got %+v"
,
parts
)
}
if
parts
[
0
]
.
ThoughtSignature
!=
"sig_tool_abc"
{
t
.
Fatalf
(
"expected preserved tool signature %q, got %q"
,
"sig_tool_abc"
,
parts
[
0
]
.
ThoughtSignature
)
}
})
t
.
Run
(
"Gemini falls back to dummy tool_use signature when missing"
,
func
(
t
*
testing
.
T
)
{
contentNoSig
:=
`[
{"type": "tool_use", "id": "t1", "name": "Bash", "input": {"command": "ls"}}
]`
toolIDToName
:=
make
(
map
[
string
]
string
)
parts
,
_
,
err
:=
buildParts
(
json
.
RawMessage
(
contentNoSig
),
toolIDToName
,
true
)
if
err
!=
nil
{
t
.
Fatalf
(
"buildParts() error = %v"
,
err
)
}
if
len
(
parts
)
!=
1
||
parts
[
0
]
.
FunctionCall
==
nil
{
t
.
Fatalf
(
"expected 1 functionCall part, got %+v"
,
parts
)
}
if
parts
[
0
]
.
ThoughtSignature
!=
dummyThoughtSignature
{
t
.
Fatalf
(
"expected dummy tool signature %q, got %q"
,
dummyThoughtSignature
,
parts
[
0
]
.
ThoughtSignature
)
}
...
...
backend/internal/pkg/antigravity/response_transformer.go
View file @
2fe8932c
...
...
@@ -3,6 +3,8 @@ package antigravity
import
(
"encoding/json"
"fmt"
"log"
"strings"
)
// TransformGeminiToClaude 将 Gemini 响应转换为 Claude 格式(非流式)
...
...
@@ -18,6 +20,15 @@ func TransformGeminiToClaude(geminiResp []byte, originalModel string) ([]byte, *
v1Resp
.
Response
=
directResp
v1Resp
.
ResponseID
=
directResp
.
ResponseID
v1Resp
.
ModelVersion
=
directResp
.
ModelVersion
}
else
if
len
(
v1Resp
.
Response
.
Candidates
)
==
0
{
// 第一次解析成功但 candidates 为空,说明是直接的 GeminiResponse 格式
var
directResp
GeminiResponse
if
err2
:=
json
.
Unmarshal
(
geminiResp
,
&
directResp
);
err2
!=
nil
{
return
nil
,
nil
,
fmt
.
Errorf
(
"parse gemini response as direct: %w"
,
err2
)
}
v1Resp
.
Response
=
directResp
v1Resp
.
ResponseID
=
directResp
.
ResponseID
v1Resp
.
ModelVersion
=
directResp
.
ModelVersion
}
// 使用处理器转换
...
...
@@ -63,6 +74,12 @@ func (p *NonStreamingProcessor) Process(geminiResp *GeminiResponse, responseID,
p
.
processPart
(
&
part
)
}
if
len
(
geminiResp
.
Candidates
)
>
0
{
if
grounding
:=
geminiResp
.
Candidates
[
0
]
.
GroundingMetadata
;
grounding
!=
nil
{
p
.
processGrounding
(
grounding
)
}
}
// 刷新剩余内容
p
.
flushThinking
()
p
.
flushText
()
...
...
@@ -166,16 +183,20 @@ func (p *NonStreamingProcessor) processPart(part *GeminiPart) {
p
.
trailingSignature
=
""
}
p
.
textBuilder
+=
part
.
Text
// 非空 text 带签名 - 立即刷新并输出空 thinking 块
// 非空 text 带签名 - 特殊处理:先输出 text,再输出空 thinking 块
if
signature
!=
""
{
p
.
flushText
()
p
.
contentBlocks
=
append
(
p
.
contentBlocks
,
ClaudeContentItem
{
Type
:
"text"
,
Text
:
part
.
Text
,
})
p
.
contentBlocks
=
append
(
p
.
contentBlocks
,
ClaudeContentItem
{
Type
:
"thinking"
,
Thinking
:
""
,
Signature
:
signature
,
})
}
else
{
// 普通 text (无签名) - 累积到 builder
p
.
textBuilder
+=
part
.
Text
}
}
}
...
...
@@ -190,6 +211,18 @@ func (p *NonStreamingProcessor) processPart(part *GeminiPart) {
}
}
func
(
p
*
NonStreamingProcessor
)
processGrounding
(
grounding
*
GeminiGroundingMetadata
)
{
groundingText
:=
buildGroundingText
(
grounding
)
if
groundingText
==
""
{
return
}
p
.
flushThinking
()
p
.
flushText
()
p
.
textBuilder
+=
groundingText
p
.
flushText
()
}
// flushText 刷新 text builder
func
(
p
*
NonStreamingProcessor
)
flushText
()
{
if
p
.
textBuilder
==
""
{
...
...
@@ -223,6 +256,14 @@ func (p *NonStreamingProcessor) buildResponse(geminiResp *GeminiResponse, respon
var
finishReason
string
if
len
(
geminiResp
.
Candidates
)
>
0
{
finishReason
=
geminiResp
.
Candidates
[
0
]
.
FinishReason
if
finishReason
==
"MALFORMED_FUNCTION_CALL"
{
log
.
Printf
(
"[Antigravity] MALFORMED_FUNCTION_CALL detected in response for model %s"
,
originalModel
)
if
geminiResp
.
Candidates
[
0
]
.
Content
!=
nil
{
if
b
,
err
:=
json
.
Marshal
(
geminiResp
.
Candidates
[
0
]
.
Content
);
err
==
nil
{
log
.
Printf
(
"[Antigravity] Malformed content: %s"
,
string
(
b
))
}
}
}
}
stopReason
:=
"end_turn"
...
...
@@ -262,6 +303,44 @@ func (p *NonStreamingProcessor) buildResponse(geminiResp *GeminiResponse, respon
}
}
func
buildGroundingText
(
grounding
*
GeminiGroundingMetadata
)
string
{
if
grounding
==
nil
{
return
""
}
var
builder
strings
.
Builder
if
len
(
grounding
.
WebSearchQueries
)
>
0
{
_
,
_
=
builder
.
WriteString
(
"
\n\n
---
\n
Web search queries: "
)
_
,
_
=
builder
.
WriteString
(
strings
.
Join
(
grounding
.
WebSearchQueries
,
", "
))
}
if
len
(
grounding
.
GroundingChunks
)
>
0
{
var
links
[]
string
for
i
,
chunk
:=
range
grounding
.
GroundingChunks
{
if
chunk
.
Web
==
nil
{
continue
}
title
:=
strings
.
TrimSpace
(
chunk
.
Web
.
Title
)
if
title
==
""
{
title
=
"Source"
}
uri
:=
strings
.
TrimSpace
(
chunk
.
Web
.
URI
)
if
uri
==
""
{
uri
=
"#"
}
links
=
append
(
links
,
fmt
.
Sprintf
(
"[%d] [%s](%s)"
,
i
+
1
,
title
,
uri
))
}
if
len
(
links
)
>
0
{
_
,
_
=
builder
.
WriteString
(
"
\n\n
Sources:
\n
"
)
_
,
_
=
builder
.
WriteString
(
strings
.
Join
(
links
,
"
\n
"
))
}
}
return
builder
.
String
()
}
// generateRandomID 生成随机 ID
func
generateRandomID
()
string
{
const
chars
=
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
...
...
backend/internal/pkg/antigravity/schema_cleaner.go
0 → 100644
View file @
2fe8932c
package
antigravity
import
(
"fmt"
"strings"
)
// CleanJSONSchema 清理 JSON Schema,移除 Antigravity/Gemini 不支持的字段
// 参考 Antigravity-Manager/src-tauri/src/proxy/common/json_schema.rs 实现
// 确保 schema 符合 JSON Schema draft 2020-12 且适配 Gemini v1internal
func
CleanJSONSchema
(
schema
map
[
string
]
any
)
map
[
string
]
any
{
if
schema
==
nil
{
return
nil
}
// 0. 预处理:展开 $ref (Schema Flattening)
// (Go map 是引用的,直接修改 schema)
flattenRefs
(
schema
,
extractDefs
(
schema
))
// 递归清理
cleaned
:=
cleanJSONSchemaRecursive
(
schema
)
result
,
ok
:=
cleaned
.
(
map
[
string
]
any
)
if
!
ok
{
return
nil
}
return
result
}
// extractDefs 提取并移除定义的 helper
func
extractDefs
(
schema
map
[
string
]
any
)
map
[
string
]
any
{
defs
:=
make
(
map
[
string
]
any
)
if
d
,
ok
:=
schema
[
"$defs"
]
.
(
map
[
string
]
any
);
ok
{
for
k
,
v
:=
range
d
{
defs
[
k
]
=
v
}
delete
(
schema
,
"$defs"
)
}
if
d
,
ok
:=
schema
[
"definitions"
]
.
(
map
[
string
]
any
);
ok
{
for
k
,
v
:=
range
d
{
defs
[
k
]
=
v
}
delete
(
schema
,
"definitions"
)
}
return
defs
}
// flattenRefs 递归展开 $ref
func
flattenRefs
(
schema
map
[
string
]
any
,
defs
map
[
string
]
any
)
{
if
len
(
defs
)
==
0
{
return
// 无需展开
}
// 检查并替换 $ref
if
ref
,
ok
:=
schema
[
"$ref"
]
.
(
string
);
ok
{
delete
(
schema
,
"$ref"
)
// 解析引用名 (例如 #/$defs/MyType -> MyType)
parts
:=
strings
.
Split
(
ref
,
"/"
)
refName
:=
parts
[
len
(
parts
)
-
1
]
if
defSchema
,
exists
:=
defs
[
refName
];
exists
{
if
defMap
,
ok
:=
defSchema
.
(
map
[
string
]
any
);
ok
{
// 合并定义内容 (不覆盖现有 key)
for
k
,
v
:=
range
defMap
{
if
_
,
has
:=
schema
[
k
];
!
has
{
schema
[
k
]
=
deepCopy
(
v
)
// 需深拷贝避免共享引用
}
}
// 递归处理刚刚合并进来的内容
flattenRefs
(
schema
,
defs
)
}
}
}
// 遍历子节点
for
_
,
v
:=
range
schema
{
if
subMap
,
ok
:=
v
.
(
map
[
string
]
any
);
ok
{
flattenRefs
(
subMap
,
defs
)
}
else
if
subArr
,
ok
:=
v
.
([]
any
);
ok
{
for
_
,
item
:=
range
subArr
{
if
itemMap
,
ok
:=
item
.
(
map
[
string
]
any
);
ok
{
flattenRefs
(
itemMap
,
defs
)
}
}
}
}
}
// deepCopy 深拷贝 (简单实现,仅针对 JSON 类型)
func
deepCopy
(
src
any
)
any
{
if
src
==
nil
{
return
nil
}
switch
v
:=
src
.
(
type
)
{
case
map
[
string
]
any
:
dst
:=
make
(
map
[
string
]
any
)
for
k
,
val
:=
range
v
{
dst
[
k
]
=
deepCopy
(
val
)
}
return
dst
case
[]
any
:
dst
:=
make
([]
any
,
len
(
v
))
for
i
,
val
:=
range
v
{
dst
[
i
]
=
deepCopy
(
val
)
}
return
dst
default
:
return
src
}
}
// cleanJSONSchemaRecursive 递归核心清理逻辑
// 返回处理后的值 (通常是 input map,但可能修改内部结构)
func
cleanJSONSchemaRecursive
(
value
any
)
any
{
schemaMap
,
ok
:=
value
.
(
map
[
string
]
any
)
if
!
ok
{
return
value
}
// 0. [NEW] 合并 allOf
mergeAllOf
(
schemaMap
)
// 1. [CRITICAL] 深度递归处理子项
if
props
,
ok
:=
schemaMap
[
"properties"
]
.
(
map
[
string
]
any
);
ok
{
for
_
,
v
:=
range
props
{
cleanJSONSchemaRecursive
(
v
)
}
// Go 中不需要像 Rust 那样显式处理 nullable_keys remove required,
// 因为我们在子项处理中会正确设置 type 和 description
}
else
if
items
,
ok
:=
schemaMap
[
"items"
];
ok
{
// [FIX] Gemini 期望 "items" 是单个 Schema 对象(列表验证),而不是数组(元组验证)。
if
itemsArr
,
ok
:=
items
.
([]
any
);
ok
{
// 策略:将元组 [A, B] 视为 A、B 中的最佳匹配项。
best
:=
extractBestSchemaFromUnion
(
itemsArr
)
if
best
==
nil
{
// 回退到通用字符串
best
=
map
[
string
]
any
{
"type"
:
"string"
}
}
// 用处理后的对象替换原有数组
cleanedBest
:=
cleanJSONSchemaRecursive
(
best
)
schemaMap
[
"items"
]
=
cleanedBest
}
else
{
cleanJSONSchemaRecursive
(
items
)
}
}
else
{
// 遍历所有值递归
for
_
,
v
:=
range
schemaMap
{
if
_
,
isMap
:=
v
.
(
map
[
string
]
any
);
isMap
{
cleanJSONSchemaRecursive
(
v
)
}
else
if
arr
,
isArr
:=
v
.
([]
any
);
isArr
{
for
_
,
item
:=
range
arr
{
cleanJSONSchemaRecursive
(
item
)
}
}
}
}
// 2. [FIX] 处理 anyOf/oneOf 联合类型: 合并属性而非直接删除
var
unionArray
[]
any
typeStr
,
_
:=
schemaMap
[
"type"
]
.
(
string
)
if
typeStr
==
""
||
typeStr
==
"object"
{
if
anyOf
,
ok
:=
schemaMap
[
"anyOf"
]
.
([]
any
);
ok
{
unionArray
=
anyOf
}
else
if
oneOf
,
ok
:=
schemaMap
[
"oneOf"
]
.
([]
any
);
ok
{
unionArray
=
oneOf
}
}
if
len
(
unionArray
)
>
0
{
if
bestBranch
:=
extractBestSchemaFromUnion
(
unionArray
);
bestBranch
!=
nil
{
if
bestMap
,
ok
:=
bestBranch
.
(
map
[
string
]
any
);
ok
{
// 合并分支内容
for
k
,
v
:=
range
bestMap
{
if
k
==
"properties"
{
targetProps
,
_
:=
schemaMap
[
"properties"
]
.
(
map
[
string
]
any
)
if
targetProps
==
nil
{
targetProps
=
make
(
map
[
string
]
any
)
schemaMap
[
"properties"
]
=
targetProps
}
if
sourceProps
,
ok
:=
v
.
(
map
[
string
]
any
);
ok
{
for
pk
,
pv
:=
range
sourceProps
{
if
_
,
exists
:=
targetProps
[
pk
];
!
exists
{
targetProps
[
pk
]
=
deepCopy
(
pv
)
}
}
}
}
else
if
k
==
"required"
{
targetReq
,
_
:=
schemaMap
[
"required"
]
.
([]
any
)
if
sourceReq
,
ok
:=
v
.
([]
any
);
ok
{
for
_
,
rv
:=
range
sourceReq
{
// 简单的去重添加
exists
:=
false
for
_
,
tr
:=
range
targetReq
{
if
tr
==
rv
{
exists
=
true
break
}
}
if
!
exists
{
targetReq
=
append
(
targetReq
,
rv
)
}
}
schemaMap
[
"required"
]
=
targetReq
}
}
else
if
_
,
exists
:=
schemaMap
[
k
];
!
exists
{
schemaMap
[
k
]
=
deepCopy
(
v
)
}
}
}
}
}
// 3. [SAFETY] 检查当前对象是否为 JSON Schema 节点
looksLikeSchema
:=
hasKey
(
schemaMap
,
"type"
)
||
hasKey
(
schemaMap
,
"properties"
)
||
hasKey
(
schemaMap
,
"items"
)
||
hasKey
(
schemaMap
,
"enum"
)
||
hasKey
(
schemaMap
,
"anyOf"
)
||
hasKey
(
schemaMap
,
"oneOf"
)
||
hasKey
(
schemaMap
,
"allOf"
)
if
looksLikeSchema
{
// 4. [ROBUST] 约束迁移
migrateConstraints
(
schemaMap
)
// 5. [CRITICAL] 白名单过滤
allowedFields
:=
map
[
string
]
bool
{
"type"
:
true
,
"description"
:
true
,
"properties"
:
true
,
"required"
:
true
,
"items"
:
true
,
"enum"
:
true
,
"title"
:
true
,
}
for
k
:=
range
schemaMap
{
if
!
allowedFields
[
k
]
{
delete
(
schemaMap
,
k
)
}
}
// 6. [SAFETY] 处理空 Object
if
t
,
_
:=
schemaMap
[
"type"
]
.
(
string
);
t
==
"object"
{
hasProps
:=
false
if
props
,
ok
:=
schemaMap
[
"properties"
]
.
(
map
[
string
]
any
);
ok
&&
len
(
props
)
>
0
{
hasProps
=
true
}
if
!
hasProps
{
schemaMap
[
"properties"
]
=
map
[
string
]
any
{
"reason"
:
map
[
string
]
any
{
"type"
:
"string"
,
"description"
:
"Reason for calling this tool"
,
},
}
schemaMap
[
"required"
]
=
[]
any
{
"reason"
}
}
}
// 7. [SAFETY] Required 字段对齐
if
props
,
ok
:=
schemaMap
[
"properties"
]
.
(
map
[
string
]
any
);
ok
{
if
req
,
ok
:=
schemaMap
[
"required"
]
.
([]
any
);
ok
{
var
validReq
[]
any
for
_
,
r
:=
range
req
{
if
rStr
,
ok
:=
r
.
(
string
);
ok
{
if
_
,
exists
:=
props
[
rStr
];
exists
{
validReq
=
append
(
validReq
,
r
)
}
}
}
if
len
(
validReq
)
>
0
{
schemaMap
[
"required"
]
=
validReq
}
else
{
delete
(
schemaMap
,
"required"
)
}
}
}
// 8. 处理 type 字段 (Lowercase + Nullable 提取)
isEffectivelyNullable
:=
false
if
typeVal
,
exists
:=
schemaMap
[
"type"
];
exists
{
var
selectedType
string
switch
v
:=
typeVal
.
(
type
)
{
case
string
:
lower
:=
strings
.
ToLower
(
v
)
if
lower
==
"null"
{
isEffectivelyNullable
=
true
selectedType
=
"string"
// fallback
}
else
{
selectedType
=
lower
}
case
[]
any
:
// ["string", "null"]
for
_
,
t
:=
range
v
{
if
ts
,
ok
:=
t
.
(
string
);
ok
{
lower
:=
strings
.
ToLower
(
ts
)
if
lower
==
"null"
{
isEffectivelyNullable
=
true
}
else
if
selectedType
==
""
{
selectedType
=
lower
}
}
}
if
selectedType
==
""
{
selectedType
=
"string"
}
}
schemaMap
[
"type"
]
=
selectedType
}
else
{
// 默认 object 如果有 properties (虽然上面白名单过滤可能删了 type 如果它不在... 但 type 必在 allowlist)
// 如果没有 type,但有 properties,补一个
if
hasKey
(
schemaMap
,
"properties"
)
{
schemaMap
[
"type"
]
=
"object"
}
else
{
// 默认为 string ? or object? Gemini 通常需要明确 type
schemaMap
[
"type"
]
=
"object"
}
}
if
isEffectivelyNullable
{
desc
,
_
:=
schemaMap
[
"description"
]
.
(
string
)
if
!
strings
.
Contains
(
desc
,
"nullable"
)
{
if
desc
!=
""
{
desc
+=
" "
}
desc
+=
"(nullable)"
schemaMap
[
"description"
]
=
desc
}
}
// 9. Enum 值强制转字符串
if
enumVals
,
ok
:=
schemaMap
[
"enum"
]
.
([]
any
);
ok
{
hasNonString
:=
false
for
i
,
val
:=
range
enumVals
{
if
_
,
isStr
:=
val
.
(
string
);
!
isStr
{
hasNonString
=
true
if
val
==
nil
{
enumVals
[
i
]
=
"null"
}
else
{
enumVals
[
i
]
=
fmt
.
Sprintf
(
"%v"
,
val
)
}
}
}
// If we mandated string values, we must ensure type is string
if
hasNonString
{
schemaMap
[
"type"
]
=
"string"
}
}
}
return
schemaMap
}
func
hasKey
(
m
map
[
string
]
any
,
k
string
)
bool
{
_
,
ok
:=
m
[
k
]
return
ok
}
func
migrateConstraints
(
m
map
[
string
]
any
)
{
constraints
:=
[]
struct
{
key
string
label
string
}{
{
"minLength"
,
"minLen"
},
{
"maxLength"
,
"maxLen"
},
{
"pattern"
,
"pattern"
},
{
"minimum"
,
"min"
},
{
"maximum"
,
"max"
},
{
"multipleOf"
,
"multipleOf"
},
{
"exclusiveMinimum"
,
"exclMin"
},
{
"exclusiveMaximum"
,
"exclMax"
},
{
"minItems"
,
"minItems"
},
{
"maxItems"
,
"maxItems"
},
{
"propertyNames"
,
"propertyNames"
},
{
"format"
,
"format"
},
}
var
hints
[]
string
for
_
,
c
:=
range
constraints
{
if
val
,
ok
:=
m
[
c
.
key
];
ok
&&
val
!=
nil
{
hints
=
append
(
hints
,
fmt
.
Sprintf
(
"%s: %v"
,
c
.
label
,
val
))
}
}
if
len
(
hints
)
>
0
{
suffix
:=
fmt
.
Sprintf
(
" [Constraint: %s]"
,
strings
.
Join
(
hints
,
", "
))
desc
,
_
:=
m
[
"description"
]
.
(
string
)
if
!
strings
.
Contains
(
desc
,
suffix
)
{
m
[
"description"
]
=
desc
+
suffix
}
}
}
// mergeAllOf 合并 allOf
func
mergeAllOf
(
m
map
[
string
]
any
)
{
allOf
,
ok
:=
m
[
"allOf"
]
.
([]
any
)
if
!
ok
{
return
}
delete
(
m
,
"allOf"
)
mergedProps
:=
make
(
map
[
string
]
any
)
mergedReq
:=
make
(
map
[
string
]
bool
)
otherFields
:=
make
(
map
[
string
]
any
)
for
_
,
sub
:=
range
allOf
{
if
subMap
,
ok
:=
sub
.
(
map
[
string
]
any
);
ok
{
// Props
if
props
,
ok
:=
subMap
[
"properties"
]
.
(
map
[
string
]
any
);
ok
{
for
k
,
v
:=
range
props
{
mergedProps
[
k
]
=
v
}
}
// Required
if
reqs
,
ok
:=
subMap
[
"required"
]
.
([]
any
);
ok
{
for
_
,
r
:=
range
reqs
{
if
s
,
ok
:=
r
.
(
string
);
ok
{
mergedReq
[
s
]
=
true
}
}
}
// Others
for
k
,
v
:=
range
subMap
{
if
k
!=
"properties"
&&
k
!=
"required"
&&
k
!=
"allOf"
{
if
_
,
exists
:=
otherFields
[
k
];
!
exists
{
otherFields
[
k
]
=
v
}
}
}
}
}
// Apply
for
k
,
v
:=
range
otherFields
{
if
_
,
exists
:=
m
[
k
];
!
exists
{
m
[
k
]
=
v
}
}
if
len
(
mergedProps
)
>
0
{
existProps
,
_
:=
m
[
"properties"
]
.
(
map
[
string
]
any
)
if
existProps
==
nil
{
existProps
=
make
(
map
[
string
]
any
)
m
[
"properties"
]
=
existProps
}
for
k
,
v
:=
range
mergedProps
{
if
_
,
exists
:=
existProps
[
k
];
!
exists
{
existProps
[
k
]
=
v
}
}
}
if
len
(
mergedReq
)
>
0
{
existReq
,
_
:=
m
[
"required"
]
.
([]
any
)
var
validReqs
[]
any
for
_
,
r
:=
range
existReq
{
if
s
,
ok
:=
r
.
(
string
);
ok
{
validReqs
=
append
(
validReqs
,
s
)
delete
(
mergedReq
,
s
)
// already exists
}
}
// append new
for
r
:=
range
mergedReq
{
validReqs
=
append
(
validReqs
,
r
)
}
m
[
"required"
]
=
validReqs
}
}
// extractBestSchemaFromUnion 从 anyOf/oneOf 中选取最佳分支
func
extractBestSchemaFromUnion
(
unionArray
[]
any
)
any
{
var
bestOption
any
bestScore
:=
-
1
for
_
,
item
:=
range
unionArray
{
score
:=
scoreSchemaOption
(
item
)
if
score
>
bestScore
{
bestScore
=
score
bestOption
=
item
}
}
return
bestOption
}
func
scoreSchemaOption
(
val
any
)
int
{
m
,
ok
:=
val
.
(
map
[
string
]
any
)
if
!
ok
{
return
0
}
typeStr
,
_
:=
m
[
"type"
]
.
(
string
)
if
hasKey
(
m
,
"properties"
)
||
typeStr
==
"object"
{
return
3
}
if
hasKey
(
m
,
"items"
)
||
typeStr
==
"array"
{
return
2
}
if
typeStr
!=
""
&&
typeStr
!=
"null"
{
return
1
}
return
0
}
// DeepCleanUndefined 深度清理值为 "[undefined]" 的字段
func
DeepCleanUndefined
(
value
any
)
{
if
value
==
nil
{
return
}
switch
v
:=
value
.
(
type
)
{
case
map
[
string
]
any
:
for
k
,
val
:=
range
v
{
if
s
,
ok
:=
val
.
(
string
);
ok
&&
s
==
"[undefined]"
{
delete
(
v
,
k
)
continue
}
DeepCleanUndefined
(
val
)
}
case
[]
any
:
for
_
,
val
:=
range
v
{
DeepCleanUndefined
(
val
)
}
}
}
backend/internal/pkg/antigravity/stream_transformer.go
View file @
2fe8932c
...
...
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"log"
"strings"
)
...
...
@@ -27,6 +28,8 @@ type StreamingProcessor struct {
pendingSignature
string
trailingSignature
string
originalModel
string
webSearchQueries
[]
string
groundingChunks
[]
GeminiGroundingChunk
// 累计 usage
inputTokens
int
...
...
@@ -93,9 +96,21 @@ func (p *StreamingProcessor) ProcessLine(line string) []byte {
}
}
if
len
(
geminiResp
.
Candidates
)
>
0
{
p
.
captureGrounding
(
geminiResp
.
Candidates
[
0
]
.
GroundingMetadata
)
}
// 检查是否结束
if
len
(
geminiResp
.
Candidates
)
>
0
{
finishReason
:=
geminiResp
.
Candidates
[
0
]
.
FinishReason
if
finishReason
==
"MALFORMED_FUNCTION_CALL"
{
log
.
Printf
(
"[Antigravity] MALFORMED_FUNCTION_CALL detected in stream for model %s"
,
p
.
originalModel
)
if
geminiResp
.
Candidates
[
0
]
.
Content
!=
nil
{
if
b
,
err
:=
json
.
Marshal
(
geminiResp
.
Candidates
[
0
]
.
Content
);
err
==
nil
{
log
.
Printf
(
"[Antigravity] Malformed content: %s"
,
string
(
b
))
}
}
}
if
finishReason
!=
""
{
_
,
_
=
result
.
Write
(
p
.
emitFinish
(
finishReason
))
}
...
...
@@ -200,6 +215,20 @@ func (p *StreamingProcessor) processPart(part *GeminiPart) []byte {
return
result
.
Bytes
()
}
func
(
p
*
StreamingProcessor
)
captureGrounding
(
grounding
*
GeminiGroundingMetadata
)
{
if
grounding
==
nil
{
return
}
if
len
(
grounding
.
WebSearchQueries
)
>
0
&&
len
(
p
.
webSearchQueries
)
==
0
{
p
.
webSearchQueries
=
append
([]
string
(
nil
),
grounding
.
WebSearchQueries
...
)
}
if
len
(
grounding
.
GroundingChunks
)
>
0
&&
len
(
p
.
groundingChunks
)
==
0
{
p
.
groundingChunks
=
append
([]
GeminiGroundingChunk
(
nil
),
grounding
.
GroundingChunks
...
)
}
}
// processThinking 处理 thinking
func
(
p
*
StreamingProcessor
)
processThinking
(
text
,
signature
string
)
[]
byte
{
var
result
bytes
.
Buffer
...
...
@@ -417,6 +446,23 @@ func (p *StreamingProcessor) emitFinish(finishReason string) []byte {
p
.
trailingSignature
=
""
}
if
len
(
p
.
webSearchQueries
)
>
0
||
len
(
p
.
groundingChunks
)
>
0
{
groundingText
:=
buildGroundingText
(
&
GeminiGroundingMetadata
{
WebSearchQueries
:
p
.
webSearchQueries
,
GroundingChunks
:
p
.
groundingChunks
,
})
if
groundingText
!=
""
{
_
,
_
=
result
.
Write
(
p
.
startBlock
(
BlockTypeText
,
map
[
string
]
any
{
"type"
:
"text"
,
"text"
:
""
,
}))
_
,
_
=
result
.
Write
(
p
.
emitDelta
(
"text_delta"
,
map
[
string
]
any
{
"text"
:
groundingText
,
}))
_
,
_
=
result
.
Write
(
p
.
endBlock
())
}
}
// 确定 stop_reason
stopReason
:=
"end_turn"
if
p
.
usedTool
{
...
...
backend/internal/pkg/claude/constants.go
View file @
2fe8932c
...
...
@@ -16,7 +16,12 @@ const (
const
DefaultBetaHeader
=
BetaClaudeCode
+
","
+
BetaOAuth
+
","
+
BetaInterleavedThinking
+
","
+
BetaFineGrainedToolStreaming
// MessageBetaHeaderNoTools /v1/messages 在无工具时的 beta header
const
MessageBetaHeaderNoTools
=
BetaOAuth
+
","
+
BetaInterleavedThinking
//
// NOTE: Claude Code OAuth credentials are scoped to Claude Code. When we "mimic"
// Claude Code for non-Claude-Code clients, we must include the claude-code beta
// even if the request doesn't use tools, otherwise upstream may reject the
// request as a non-Claude-Code API request.
const
MessageBetaHeaderNoTools
=
BetaClaudeCode
+
","
+
BetaOAuth
+
","
+
BetaInterleavedThinking
// MessageBetaHeaderWithTools /v1/messages 在有工具时的 beta header
const
MessageBetaHeaderWithTools
=
BetaClaudeCode
+
","
+
BetaOAuth
+
","
+
BetaInterleavedThinking
...
...
@@ -35,13 +40,15 @@ const APIKeyHaikuBetaHeader = BetaInterleavedThinking
// DefaultHeaders 是 Claude Code 客户端默认请求头。
var
DefaultHeaders
=
map
[
string
]
string
{
"User-Agent"
:
"claude-cli/2.1.2 (external, cli)"
,
// Keep these in sync with recent Claude CLI traffic to reduce the chance
// that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage.
"User-Agent"
:
"claude-cli/2.1.22 (external, cli)"
,
"X-Stainless-Lang"
:
"js"
,
"X-Stainless-Package-Version"
:
"0.70.0"
,
"X-Stainless-OS"
:
"Linux"
,
"X-Stainless-Arch"
:
"
x
64"
,
"X-Stainless-Arch"
:
"
arm
64"
,
"X-Stainless-Runtime"
:
"node"
,
"X-Stainless-Runtime-Version"
:
"v24.3.0"
,
"X-Stainless-Runtime-Version"
:
"v24.
1
3.0"
,
"X-Stainless-Retry-Count"
:
"0"
,
"X-Stainless-Timeout"
:
"600"
,
"X-App"
:
"cli"
,
...
...
backend/internal/pkg/gemini/models.go
View file @
2fe8932c
...
...
@@ -16,14 +16,11 @@ type ModelsListResponse struct {
func
DefaultModels
()
[]
Model
{
methods
:=
[]
string
{
"generateContent"
,
"streamGenerateContent"
}
return
[]
Model
{
{
Name
:
"models/gemini-3-pro-preview"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-3-flash-preview"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-2.5-pro"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-2.5-flash"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-2.0-flash"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-1.5-pro"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-1.5-flash"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-1.5-flash-8b"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-2.5-flash"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-2.5-pro"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-3-flash-preview"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-3-pro-preview"
,
SupportedGenerationMethods
:
methods
},
}
}
...
...
backend/internal/pkg/geminicli/models.go
View file @
2fe8932c
...
...
@@ -12,10 +12,10 @@ type Model struct {
// DefaultModels is the curated Gemini model list used by the admin UI "test account" flow.
var
DefaultModels
=
[]
Model
{
{
ID
:
"gemini-2.0-flash"
,
Type
:
"model"
,
DisplayName
:
"Gemini 2.0 Flash"
,
CreatedAt
:
""
},
{
ID
:
"gemini-2.5-pro"
,
Type
:
"model"
,
DisplayName
:
"Gemini 2.5 Pro"
,
CreatedAt
:
""
},
{
ID
:
"gemini-2.5-flash"
,
Type
:
"model"
,
DisplayName
:
"Gemini 2.5 Flash"
,
CreatedAt
:
""
},
{
ID
:
"gemini-
3
-pro
-preview
"
,
Type
:
"model"
,
DisplayName
:
"Gemini
3
Pro
Preview
"
,
CreatedAt
:
""
},
{
ID
:
"gemini-
2.5
-pro"
,
Type
:
"model"
,
DisplayName
:
"Gemini
2.5
Pro"
,
CreatedAt
:
""
},
{
ID
:
"gemini-3-flash-preview"
,
Type
:
"model"
,
DisplayName
:
"Gemini 3 Flash Preview"
,
CreatedAt
:
""
},
{
ID
:
"gemini-3-pro-preview"
,
Type
:
"model"
,
DisplayName
:
"Gemini 3 Pro Preview"
,
CreatedAt
:
""
},
}
// DefaultTestModel is the default model to preselect in test flows.
...
...
backend/internal/pkg/oauth/oauth.go
View file @
2fe8932c
...
...
@@ -13,20 +13,26 @@ import (
"time"
)
// Claude OAuth Constants
(from CRS project)
// Claude OAuth Constants
const
(
// OAuth Client ID for Claude
ClientID
=
"9d1c250a-e61b-44d9-88ed-5944d1962f5e"
// OAuth endpoints
AuthorizeURL
=
"https://claude.ai/oauth/authorize"
TokenURL
=
"https://console.anthropic.com/v1/oauth/token"
RedirectURI
=
"https://console.anthropic.com/oauth/code/callback"
// Scopes
ScopeProfile
=
"user:profile"
TokenURL
=
"https://platform.claude.com/v1/oauth/token"
RedirectURI
=
"https://platform.claude.com/oauth/code/callback"
// Scopes - Browser URL (includes org:create_api_key for user authorization)
ScopeOAuth
=
"org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers"
// Scopes - Internal API call (org:create_api_key not supported in API)
ScopeAPI
=
"user:profile user:inference user:sessions:claude_code user:mcp_servers"
// Scopes - Setup token (inference only)
ScopeInference
=
"user:inference"
// Code Verifier character set (RFC 7636 compliant)
codeVerifierCharset
=
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
// Session TTL
SessionTTL
=
30
*
time
.
Minute
)
...
...
@@ -53,7 +59,6 @@ func NewSessionStore() *SessionStore {
sessions
:
make
(
map
[
string
]
*
OAuthSession
),
stopCh
:
make
(
chan
struct
{}),
}
// Start cleanup goroutine
go
store
.
cleanup
()
return
store
}
...
...
@@ -78,7 +83,6 @@ func (s *SessionStore) Get(sessionID string) (*OAuthSession, bool) {
if
!
ok
{
return
nil
,
false
}
// Check if expired
if
time
.
Since
(
session
.
CreatedAt
)
>
SessionTTL
{
return
nil
,
false
}
...
...
@@ -122,13 +126,13 @@ func GenerateRandomBytes(n int) ([]byte, error) {
return
b
,
nil
}
// GenerateState generates a random state string for OAuth
// GenerateState generates a random state string for OAuth
(base64url encoded)
func
GenerateState
()
(
string
,
error
)
{
bytes
,
err
:=
GenerateRandomBytes
(
32
)
if
err
!=
nil
{
return
""
,
err
}
return
hex
.
EncodeToString
(
bytes
),
nil
return
base64URLEncode
(
bytes
),
nil
}
// GenerateSessionID generates a unique session ID
...
...
@@ -140,13 +144,30 @@ func GenerateSessionID() (string, error) {
return
hex
.
EncodeToString
(
bytes
),
nil
}
// GenerateCodeVerifier generates a PKCE code verifier
(32 bytes -> base64url)
// GenerateCodeVerifier generates a PKCE code verifier
using character set method
func
GenerateCodeVerifier
()
(
string
,
error
)
{
bytes
,
err
:=
GenerateRandomBytes
(
32
)
if
err
!=
nil
{
const
targetLen
=
32
charsetLen
:=
len
(
codeVerifierCharset
)
limit
:=
256
-
(
256
%
charsetLen
)
result
:=
make
([]
byte
,
0
,
targetLen
)
randBuf
:=
make
([]
byte
,
targetLen
*
2
)
for
len
(
result
)
<
targetLen
{
if
_
,
err
:=
rand
.
Read
(
randBuf
);
err
!=
nil
{
return
""
,
err
}
return
base64URLEncode
(
bytes
),
nil
for
_
,
b
:=
range
randBuf
{
if
int
(
b
)
<
limit
{
result
=
append
(
result
,
codeVerifierCharset
[
int
(
b
)
%
charsetLen
])
if
len
(
result
)
>=
targetLen
{
break
}
}
}
}
return
base64URLEncode
(
result
),
nil
}
// GenerateCodeChallenge generates a PKCE code challenge using S256 method
...
...
@@ -158,32 +179,22 @@ func GenerateCodeChallenge(verifier string) string {
// base64URLEncode encodes bytes to base64url without padding
func
base64URLEncode
(
data
[]
byte
)
string
{
encoded
:=
base64
.
URLEncoding
.
EncodeToString
(
data
)
// Remove padding
return
strings
.
TrimRight
(
encoded
,
"="
)
}
// BuildAuthorizationURL builds the OAuth authorization URL
// BuildAuthorizationURL builds the OAuth authorization URL
with correct parameter order
func
BuildAuthorizationURL
(
state
,
codeChallenge
,
scope
string
)
string
{
params
:=
url
.
Values
{}
params
.
Set
(
"response_type"
,
"code"
)
params
.
Set
(
"client_id"
,
ClientID
)
params
.
Set
(
"redirect_uri"
,
RedirectURI
)
params
.
Set
(
"scope"
,
scope
)
params
.
Set
(
"state"
,
state
)
params
.
Set
(
"code_challenge"
,
codeChallenge
)
params
.
Set
(
"code_challenge_method"
,
"S256"
)
return
fmt
.
Sprintf
(
"%s?%s"
,
AuthorizeURL
,
params
.
Encode
())
}
// TokenRequest represents the token exchange request body
type
TokenRequest
struct
{
GrantType
string
`json:"grant_type"`
ClientID
string
`json:"client_id"`
Code
string
`json:"code"`
RedirectURI
string
`json:"redirect_uri"`
CodeVerifier
string
`json:"code_verifier"`
State
string
`json:"state"`
encodedRedirectURI
:=
url
.
QueryEscape
(
RedirectURI
)
encodedScope
:=
strings
.
ReplaceAll
(
url
.
QueryEscape
(
scope
),
"%20"
,
"+"
)
return
fmt
.
Sprintf
(
"%s?code=true&client_id=%s&response_type=code&redirect_uri=%s&scope=%s&code_challenge=%s&code_challenge_method=S256&state=%s"
,
AuthorizeURL
,
ClientID
,
encodedRedirectURI
,
encodedScope
,
codeChallenge
,
state
,
)
}
// TokenResponse represents the token response from OAuth provider
...
...
@@ -193,7 +204,6 @@ type TokenResponse struct {
ExpiresIn
int64
`json:"expires_in"`
RefreshToken
string
`json:"refresh_token,omitempty"`
Scope
string
`json:"scope,omitempty"`
// Organization and Account info from OAuth response
Organization
*
OrgInfo
`json:"organization,omitempty"`
Account
*
AccountInfo
`json:"account,omitempty"`
}
...
...
@@ -206,32 +216,5 @@ type OrgInfo struct {
// AccountInfo represents account info from OAuth response
type
AccountInfo
struct
{
UUID
string
`json:"uuid"`
}
// RefreshTokenRequest represents the refresh token request
type
RefreshTokenRequest
struct
{
GrantType
string
`json:"grant_type"`
RefreshToken
string
`json:"refresh_token"`
ClientID
string
`json:"client_id"`
}
// BuildTokenRequest creates a token exchange request
func
BuildTokenRequest
(
code
,
codeVerifier
,
state
string
)
*
TokenRequest
{
return
&
TokenRequest
{
GrantType
:
"authorization_code"
,
ClientID
:
ClientID
,
Code
:
code
,
RedirectURI
:
RedirectURI
,
CodeVerifier
:
codeVerifier
,
State
:
state
,
}
}
// BuildRefreshTokenRequest creates a refresh token request
func
BuildRefreshTokenRequest
(
refreshToken
string
)
*
RefreshTokenRequest
{
return
&
RefreshTokenRequest
{
GrantType
:
"refresh_token"
,
RefreshToken
:
refreshToken
,
ClientID
:
ClientID
,
}
EmailAddress
string
`json:"email_address"`
}
backend/internal/pkg/response/response.go
View file @
2fe8932c
...
...
@@ -2,6 +2,7 @@
package
response
import
(
"log"
"math"
"net/http"
...
...
@@ -74,6 +75,12 @@ func ErrorFrom(c *gin.Context, err error) bool {
}
statusCode
,
status
:=
infraerrors
.
ToHTTP
(
err
)
// Log internal errors with full details for debugging
if
statusCode
>=
500
&&
c
.
Request
!=
nil
{
log
.
Printf
(
"[ERROR] %s %s
\n
Error: %s"
,
c
.
Request
.
Method
,
c
.
Request
.
URL
.
Path
,
err
.
Error
())
}
ErrorWithDetails
(
c
,
statusCode
,
status
.
Message
,
status
.
Reason
,
status
.
Metadata
)
return
true
}
...
...
@@ -162,11 +169,11 @@ func ParsePagination(c *gin.Context) (page, pageSize int) {
// 支持 page_size 和 limit 两种参数名
if
ps
:=
c
.
Query
(
"page_size"
);
ps
!=
""
{
if
val
,
err
:=
parseInt
(
ps
);
err
==
nil
&&
val
>
0
&&
val
<=
100
{
if
val
,
err
:=
parseInt
(
ps
);
err
==
nil
&&
val
>
0
&&
val
<=
100
0
{
pageSize
=
val
}
}
else
if
l
:=
c
.
Query
(
"limit"
);
l
!=
""
{
if
val
,
err
:=
parseInt
(
l
);
err
==
nil
&&
val
>
0
&&
val
<=
100
{
if
val
,
err
:=
parseInt
(
l
);
err
==
nil
&&
val
>
0
&&
val
<=
100
0
{
pageSize
=
val
}
}
...
...
backend/internal/pkg/tlsfingerprint/dialer.go
0 → 100644
View file @
2fe8932c
// Package tlsfingerprint provides TLS fingerprint simulation for HTTP clients.
// It uses the utls library to create TLS connections that mimic Node.js/Claude Code clients.
package
tlsfingerprint
import
(
"bufio"
"context"
"encoding/base64"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
utls
"github.com/refraction-networking/utls"
"golang.org/x/net/proxy"
)
// Profile contains TLS fingerprint configuration.
type
Profile
struct
{
Name
string
// Profile name for identification
CipherSuites
[]
uint16
Curves
[]
uint16
PointFormats
[]
uint8
EnableGREASE
bool
}
// Dialer creates TLS connections with custom fingerprints.
type
Dialer
struct
{
profile
*
Profile
baseDialer
func
(
ctx
context
.
Context
,
network
,
addr
string
)
(
net
.
Conn
,
error
)
}
// HTTPProxyDialer creates TLS connections through HTTP/HTTPS proxies with custom fingerprints.
// It handles the CONNECT tunnel establishment before performing TLS handshake.
type
HTTPProxyDialer
struct
{
profile
*
Profile
proxyURL
*
url
.
URL
}
// SOCKS5ProxyDialer creates TLS connections through SOCKS5 proxies with custom fingerprints.
// It uses golang.org/x/net/proxy to establish the SOCKS5 tunnel.
type
SOCKS5ProxyDialer
struct
{
profile
*
Profile
proxyURL
*
url
.
URL
}
// Default TLS fingerprint values captured from Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)
// Captured using: tshark -i lo -f "tcp port 8443" -Y "tls.handshake.type == 1" -V
// JA3 Hash: 1a28e69016765d92e3b381168d68922c
//
// Note: JA3/JA4 may have slight variations due to:
// - Session ticket presence/absence
// - Extension negotiation state
var
(
// defaultCipherSuites contains all 59 cipher suites from Claude CLI
// Order is critical for JA3 fingerprint matching
defaultCipherSuites
=
[]
uint16
{
// TLS 1.3 cipher suites (MUST be first)
0x1302
,
// TLS_AES_256_GCM_SHA384
0x1303
,
// TLS_CHACHA20_POLY1305_SHA256
0x1301
,
// TLS_AES_128_GCM_SHA256
// ECDHE + AES-GCM
0xc02f
,
// TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
0xc02b
,
// TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
0xc030
,
// TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
0xc02c
,
// TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
// DHE + AES-GCM
0x009e
,
// TLS_DHE_RSA_WITH_AES_128_GCM_SHA256
// ECDHE/DHE + AES-CBC-SHA256/384
0xc027
,
// TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256
0x0067
,
// TLS_DHE_RSA_WITH_AES_128_CBC_SHA256
0xc028
,
// TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384
0x006b
,
// TLS_DHE_RSA_WITH_AES_256_CBC_SHA256
// DHE-DSS/RSA + AES-GCM
0x00a3
,
// TLS_DHE_DSS_WITH_AES_256_GCM_SHA384
0x009f
,
// TLS_DHE_RSA_WITH_AES_256_GCM_SHA384
// ChaCha20-Poly1305
0xcca9
,
// TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
0xcca8
,
// TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
0xccaa
,
// TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256
// AES-CCM (256-bit)
0xc0af
,
// TLS_ECDHE_ECDSA_WITH_AES_256_CCM_8
0xc0ad
,
// TLS_ECDHE_ECDSA_WITH_AES_256_CCM
0xc0a3
,
// TLS_DHE_RSA_WITH_AES_256_CCM_8
0xc09f
,
// TLS_DHE_RSA_WITH_AES_256_CCM
// ARIA (256-bit)
0xc05d
,
// TLS_ECDHE_ECDSA_WITH_ARIA_256_GCM_SHA384
0xc061
,
// TLS_ECDHE_RSA_WITH_ARIA_256_GCM_SHA384
0xc057
,
// TLS_DHE_DSS_WITH_ARIA_256_GCM_SHA384
0xc053
,
// TLS_DHE_RSA_WITH_ARIA_256_GCM_SHA384
// DHE-DSS + AES-GCM (128-bit)
0x00a2
,
// TLS_DHE_DSS_WITH_AES_128_GCM_SHA256
// AES-CCM (128-bit)
0xc0ae
,
// TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8
0xc0ac
,
// TLS_ECDHE_ECDSA_WITH_AES_128_CCM
0xc0a2
,
// TLS_DHE_RSA_WITH_AES_128_CCM_8
0xc09e
,
// TLS_DHE_RSA_WITH_AES_128_CCM
// ARIA (128-bit)
0xc05c
,
// TLS_ECDHE_ECDSA_WITH_ARIA_128_GCM_SHA256
0xc060
,
// TLS_ECDHE_RSA_WITH_ARIA_128_GCM_SHA256
0xc056
,
// TLS_DHE_DSS_WITH_ARIA_128_GCM_SHA256
0xc052
,
// TLS_DHE_RSA_WITH_ARIA_128_GCM_SHA256
// ECDHE/DHE + AES-CBC-SHA384/256 (more)
0xc024
,
// TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384
0x006a
,
// TLS_DHE_DSS_WITH_AES_256_CBC_SHA256
0xc023
,
// TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256
0x0040
,
// TLS_DHE_DSS_WITH_AES_128_CBC_SHA256
// ECDHE/DHE + AES-CBC-SHA (legacy)
0xc00a
,
// TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
0xc014
,
// TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA
0x0039
,
// TLS_DHE_RSA_WITH_AES_256_CBC_SHA
0x0038
,
// TLS_DHE_DSS_WITH_AES_256_CBC_SHA
0xc009
,
// TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA
0xc013
,
// TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA
0x0033
,
// TLS_DHE_RSA_WITH_AES_128_CBC_SHA
0x0032
,
// TLS_DHE_DSS_WITH_AES_128_CBC_SHA
// RSA + AES-GCM/CCM/ARIA (non-PFS, 256-bit)
0x009d
,
// TLS_RSA_WITH_AES_256_GCM_SHA384
0xc0a1
,
// TLS_RSA_WITH_AES_256_CCM_8
0xc09d
,
// TLS_RSA_WITH_AES_256_CCM
0xc051
,
// TLS_RSA_WITH_ARIA_256_GCM_SHA384
// RSA + AES-GCM/CCM/ARIA (non-PFS, 128-bit)
0x009c
,
// TLS_RSA_WITH_AES_128_GCM_SHA256
0xc0a0
,
// TLS_RSA_WITH_AES_128_CCM_8
0xc09c
,
// TLS_RSA_WITH_AES_128_CCM
0xc050
,
// TLS_RSA_WITH_ARIA_128_GCM_SHA256
// RSA + AES-CBC (non-PFS, legacy)
0x003d
,
// TLS_RSA_WITH_AES_256_CBC_SHA256
0x003c
,
// TLS_RSA_WITH_AES_128_CBC_SHA256
0x0035
,
// TLS_RSA_WITH_AES_256_CBC_SHA
0x002f
,
// TLS_RSA_WITH_AES_128_CBC_SHA
// Renegotiation indication
0x00ff
,
// TLS_EMPTY_RENEGOTIATION_INFO_SCSV
}
// defaultCurves contains the 10 supported groups from Claude CLI (including FFDHE)
defaultCurves
=
[]
utls
.
CurveID
{
utls
.
X25519
,
// 0x001d
utls
.
CurveP256
,
// 0x0017 (secp256r1)
utls
.
CurveID
(
0x001e
),
// x448
utls
.
CurveP521
,
// 0x0019 (secp521r1)
utls
.
CurveP384
,
// 0x0018 (secp384r1)
utls
.
CurveID
(
0x0100
),
// ffdhe2048
utls
.
CurveID
(
0x0101
),
// ffdhe3072
utls
.
CurveID
(
0x0102
),
// ffdhe4096
utls
.
CurveID
(
0x0103
),
// ffdhe6144
utls
.
CurveID
(
0x0104
),
// ffdhe8192
}
// defaultPointFormats contains all 3 point formats from Claude CLI
defaultPointFormats
=
[]
uint8
{
0
,
// uncompressed
1
,
// ansiX962_compressed_prime
2
,
// ansiX962_compressed_char2
}
// defaultSignatureAlgorithms contains the 20 signature algorithms from Claude CLI
defaultSignatureAlgorithms
=
[]
utls
.
SignatureScheme
{
0x0403
,
// ecdsa_secp256r1_sha256
0x0503
,
// ecdsa_secp384r1_sha384
0x0603
,
// ecdsa_secp521r1_sha512
0x0807
,
// ed25519
0x0808
,
// ed448
0x0809
,
// rsa_pss_pss_sha256
0x080a
,
// rsa_pss_pss_sha384
0x080b
,
// rsa_pss_pss_sha512
0x0804
,
// rsa_pss_rsae_sha256
0x0805
,
// rsa_pss_rsae_sha384
0x0806
,
// rsa_pss_rsae_sha512
0x0401
,
// rsa_pkcs1_sha256
0x0501
,
// rsa_pkcs1_sha384
0x0601
,
// rsa_pkcs1_sha512
0x0303
,
// ecdsa_sha224
0x0301
,
// rsa_pkcs1_sha224
0x0302
,
// dsa_sha224
0x0402
,
// dsa_sha256
0x0502
,
// dsa_sha384
0x0602
,
// dsa_sha512
}
)
// NewDialer creates a new TLS fingerprint dialer.
// baseDialer is used for TCP connection establishment (supports proxy scenarios).
// If baseDialer is nil, direct TCP dial is used.
func
NewDialer
(
profile
*
Profile
,
baseDialer
func
(
ctx
context
.
Context
,
network
,
addr
string
)
(
net
.
Conn
,
error
))
*
Dialer
{
if
baseDialer
==
nil
{
baseDialer
=
(
&
net
.
Dialer
{})
.
DialContext
}
return
&
Dialer
{
profile
:
profile
,
baseDialer
:
baseDialer
}
}
// NewHTTPProxyDialer creates a new TLS fingerprint dialer that works through HTTP/HTTPS proxies.
// It establishes a CONNECT tunnel before performing TLS handshake with custom fingerprint.
func
NewHTTPProxyDialer
(
profile
*
Profile
,
proxyURL
*
url
.
URL
)
*
HTTPProxyDialer
{
return
&
HTTPProxyDialer
{
profile
:
profile
,
proxyURL
:
proxyURL
}
}
// NewSOCKS5ProxyDialer creates a new TLS fingerprint dialer that works through SOCKS5 proxies.
// It establishes a SOCKS5 tunnel before performing TLS handshake with custom fingerprint.
func
NewSOCKS5ProxyDialer
(
profile
*
Profile
,
proxyURL
*
url
.
URL
)
*
SOCKS5ProxyDialer
{
return
&
SOCKS5ProxyDialer
{
profile
:
profile
,
proxyURL
:
proxyURL
}
}
// DialTLSContext establishes a TLS connection through SOCKS5 proxy with the configured fingerprint.
// Flow: SOCKS5 CONNECT to target -> TLS handshake with utls on the tunnel
func
(
d
*
SOCKS5ProxyDialer
)
DialTLSContext
(
ctx
context
.
Context
,
network
,
addr
string
)
(
net
.
Conn
,
error
)
{
slog
.
Debug
(
"tls_fingerprint_socks5_connecting"
,
"proxy"
,
d
.
proxyURL
.
Host
,
"target"
,
addr
)
// Step 1: Create SOCKS5 dialer
var
auth
*
proxy
.
Auth
if
d
.
proxyURL
.
User
!=
nil
{
username
:=
d
.
proxyURL
.
User
.
Username
()
password
,
_
:=
d
.
proxyURL
.
User
.
Password
()
auth
=
&
proxy
.
Auth
{
User
:
username
,
Password
:
password
,
}
}
// Determine proxy address
proxyAddr
:=
d
.
proxyURL
.
Host
if
d
.
proxyURL
.
Port
()
==
""
{
proxyAddr
=
net
.
JoinHostPort
(
d
.
proxyURL
.
Hostname
(),
"1080"
)
// Default SOCKS5 port
}
socksDialer
,
err
:=
proxy
.
SOCKS5
(
"tcp"
,
proxyAddr
,
auth
,
proxy
.
Direct
)
if
err
!=
nil
{
slog
.
Debug
(
"tls_fingerprint_socks5_dialer_failed"
,
"error"
,
err
)
return
nil
,
fmt
.
Errorf
(
"create SOCKS5 dialer: %w"
,
err
)
}
// Step 2: Establish SOCKS5 tunnel to target
slog
.
Debug
(
"tls_fingerprint_socks5_establishing_tunnel"
,
"target"
,
addr
)
conn
,
err
:=
socksDialer
.
Dial
(
"tcp"
,
addr
)
if
err
!=
nil
{
slog
.
Debug
(
"tls_fingerprint_socks5_connect_failed"
,
"error"
,
err
)
return
nil
,
fmt
.
Errorf
(
"SOCKS5 connect: %w"
,
err
)
}
slog
.
Debug
(
"tls_fingerprint_socks5_tunnel_established"
)
// Step 3: Perform TLS handshake on the tunnel with utls fingerprint
host
,
_
,
err
:=
net
.
SplitHostPort
(
addr
)
if
err
!=
nil
{
host
=
addr
}
slog
.
Debug
(
"tls_fingerprint_socks5_starting_handshake"
,
"host"
,
host
)
// Build ClientHello specification from profile (Node.js/Claude CLI fingerprint)
spec
:=
buildClientHelloSpecFromProfile
(
d
.
profile
)
slog
.
Debug
(
"tls_fingerprint_socks5_clienthello_spec"
,
"cipher_suites"
,
len
(
spec
.
CipherSuites
),
"extensions"
,
len
(
spec
.
Extensions
),
"compression_methods"
,
spec
.
CompressionMethods
,
"tls_vers_max"
,
fmt
.
Sprintf
(
"0x%04x"
,
spec
.
TLSVersMax
),
"tls_vers_min"
,
fmt
.
Sprintf
(
"0x%04x"
,
spec
.
TLSVersMin
))
if
d
.
profile
!=
nil
{
slog
.
Debug
(
"tls_fingerprint_socks5_using_profile"
,
"name"
,
d
.
profile
.
Name
,
"grease"
,
d
.
profile
.
EnableGREASE
)
}
// Create uTLS connection on the tunnel
tlsConn
:=
utls
.
UClient
(
conn
,
&
utls
.
Config
{
ServerName
:
host
,
},
utls
.
HelloCustom
)
if
err
:=
tlsConn
.
ApplyPreset
(
spec
);
err
!=
nil
{
slog
.
Debug
(
"tls_fingerprint_socks5_apply_preset_failed"
,
"error"
,
err
)
_
=
conn
.
Close
()
return
nil
,
fmt
.
Errorf
(
"apply TLS preset: %w"
,
err
)
}
if
err
:=
tlsConn
.
Handshake
();
err
!=
nil
{
slog
.
Debug
(
"tls_fingerprint_socks5_handshake_failed"
,
"error"
,
err
)
_
=
conn
.
Close
()
return
nil
,
fmt
.
Errorf
(
"TLS handshake failed: %w"
,
err
)
}
state
:=
tlsConn
.
ConnectionState
()
slog
.
Debug
(
"tls_fingerprint_socks5_handshake_success"
,
"version"
,
fmt
.
Sprintf
(
"0x%04x"
,
state
.
Version
),
"cipher_suite"
,
fmt
.
Sprintf
(
"0x%04x"
,
state
.
CipherSuite
),
"alpn"
,
state
.
NegotiatedProtocol
)
return
tlsConn
,
nil
}
// DialTLSContext establishes a TLS connection through HTTP proxy with the configured fingerprint.
// Flow: TCP connect to proxy -> CONNECT tunnel -> TLS handshake with utls
func
(
d
*
HTTPProxyDialer
)
DialTLSContext
(
ctx
context
.
Context
,
network
,
addr
string
)
(
net
.
Conn
,
error
)
{
slog
.
Debug
(
"tls_fingerprint_http_proxy_connecting"
,
"proxy"
,
d
.
proxyURL
.
Host
,
"target"
,
addr
)
// Step 1: TCP connect to proxy server
var
proxyAddr
string
if
d
.
proxyURL
.
Port
()
!=
""
{
proxyAddr
=
d
.
proxyURL
.
Host
}
else
{
// Default ports
if
d
.
proxyURL
.
Scheme
==
"https"
{
proxyAddr
=
net
.
JoinHostPort
(
d
.
proxyURL
.
Hostname
(),
"443"
)
}
else
{
proxyAddr
=
net
.
JoinHostPort
(
d
.
proxyURL
.
Hostname
(),
"80"
)
}
}
dialer
:=
&
net
.
Dialer
{}
conn
,
err
:=
dialer
.
DialContext
(
ctx
,
"tcp"
,
proxyAddr
)
if
err
!=
nil
{
slog
.
Debug
(
"tls_fingerprint_http_proxy_connect_failed"
,
"error"
,
err
)
return
nil
,
fmt
.
Errorf
(
"connect to proxy: %w"
,
err
)
}
slog
.
Debug
(
"tls_fingerprint_http_proxy_connected"
,
"proxy_addr"
,
proxyAddr
)
// Step 2: Send CONNECT request to establish tunnel
req
:=
&
http
.
Request
{
Method
:
"CONNECT"
,
URL
:
&
url
.
URL
{
Opaque
:
addr
},
Host
:
addr
,
Header
:
make
(
http
.
Header
),
}
// Add proxy authentication if present
if
d
.
proxyURL
.
User
!=
nil
{
username
:=
d
.
proxyURL
.
User
.
Username
()
password
,
_
:=
d
.
proxyURL
.
User
.
Password
()
auth
:=
base64
.
StdEncoding
.
EncodeToString
([]
byte
(
username
+
":"
+
password
))
req
.
Header
.
Set
(
"Proxy-Authorization"
,
"Basic "
+
auth
)
}
slog
.
Debug
(
"tls_fingerprint_http_proxy_sending_connect"
,
"target"
,
addr
)
if
err
:=
req
.
Write
(
conn
);
err
!=
nil
{
_
=
conn
.
Close
()
slog
.
Debug
(
"tls_fingerprint_http_proxy_write_failed"
,
"error"
,
err
)
return
nil
,
fmt
.
Errorf
(
"write CONNECT request: %w"
,
err
)
}
// Step 3: Read CONNECT response
br
:=
bufio
.
NewReader
(
conn
)
resp
,
err
:=
http
.
ReadResponse
(
br
,
req
)
if
err
!=
nil
{
_
=
conn
.
Close
()
slog
.
Debug
(
"tls_fingerprint_http_proxy_read_response_failed"
,
"error"
,
err
)
return
nil
,
fmt
.
Errorf
(
"read CONNECT response: %w"
,
err
)
}
defer
func
()
{
_
=
resp
.
Body
.
Close
()
}()
if
resp
.
StatusCode
!=
http
.
StatusOK
{
_
=
conn
.
Close
()
slog
.
Debug
(
"tls_fingerprint_http_proxy_connect_failed_status"
,
"status_code"
,
resp
.
StatusCode
,
"status"
,
resp
.
Status
)
return
nil
,
fmt
.
Errorf
(
"proxy CONNECT failed: %s"
,
resp
.
Status
)
}
slog
.
Debug
(
"tls_fingerprint_http_proxy_tunnel_established"
)
// Step 4: Perform TLS handshake on the tunnel with utls fingerprint
host
,
_
,
err
:=
net
.
SplitHostPort
(
addr
)
if
err
!=
nil
{
host
=
addr
}
slog
.
Debug
(
"tls_fingerprint_http_proxy_starting_handshake"
,
"host"
,
host
)
// Build ClientHello specification (reuse the shared method)
spec
:=
buildClientHelloSpecFromProfile
(
d
.
profile
)
slog
.
Debug
(
"tls_fingerprint_http_proxy_clienthello_spec"
,
"cipher_suites"
,
len
(
spec
.
CipherSuites
),
"extensions"
,
len
(
spec
.
Extensions
))
if
d
.
profile
!=
nil
{
slog
.
Debug
(
"tls_fingerprint_http_proxy_using_profile"
,
"name"
,
d
.
profile
.
Name
,
"grease"
,
d
.
profile
.
EnableGREASE
)
}
// Create uTLS connection on the tunnel
// Note: TLS 1.3 cipher suites are handled automatically by utls when TLS 1.3 is in SupportedVersions
tlsConn
:=
utls
.
UClient
(
conn
,
&
utls
.
Config
{
ServerName
:
host
,
},
utls
.
HelloCustom
)
if
err
:=
tlsConn
.
ApplyPreset
(
spec
);
err
!=
nil
{
slog
.
Debug
(
"tls_fingerprint_http_proxy_apply_preset_failed"
,
"error"
,
err
)
_
=
conn
.
Close
()
return
nil
,
fmt
.
Errorf
(
"apply TLS preset: %w"
,
err
)
}
if
err
:=
tlsConn
.
HandshakeContext
(
ctx
);
err
!=
nil
{
slog
.
Debug
(
"tls_fingerprint_http_proxy_handshake_failed"
,
"error"
,
err
)
_
=
conn
.
Close
()
return
nil
,
fmt
.
Errorf
(
"TLS handshake failed: %w"
,
err
)
}
state
:=
tlsConn
.
ConnectionState
()
slog
.
Debug
(
"tls_fingerprint_http_proxy_handshake_success"
,
"version"
,
fmt
.
Sprintf
(
"0x%04x"
,
state
.
Version
),
"cipher_suite"
,
fmt
.
Sprintf
(
"0x%04x"
,
state
.
CipherSuite
),
"alpn"
,
state
.
NegotiatedProtocol
)
return
tlsConn
,
nil
}
// DialTLSContext establishes a TLS connection with the configured fingerprint.
// This method is designed to be used as http.Transport.DialTLSContext.
func
(
d
*
Dialer
)
DialTLSContext
(
ctx
context
.
Context
,
network
,
addr
string
)
(
net
.
Conn
,
error
)
{
// Establish TCP connection using base dialer (supports proxy)
slog
.
Debug
(
"tls_fingerprint_dialing_tcp"
,
"addr"
,
addr
)
conn
,
err
:=
d
.
baseDialer
(
ctx
,
network
,
addr
)
if
err
!=
nil
{
slog
.
Debug
(
"tls_fingerprint_tcp_dial_failed"
,
"error"
,
err
)
return
nil
,
err
}
slog
.
Debug
(
"tls_fingerprint_tcp_connected"
,
"addr"
,
addr
)
// Extract hostname for SNI
host
,
_
,
err
:=
net
.
SplitHostPort
(
addr
)
if
err
!=
nil
{
host
=
addr
}
slog
.
Debug
(
"tls_fingerprint_sni_hostname"
,
"host"
,
host
)
// Build ClientHello specification
spec
:=
d
.
buildClientHelloSpec
()
slog
.
Debug
(
"tls_fingerprint_clienthello_spec"
,
"cipher_suites"
,
len
(
spec
.
CipherSuites
),
"extensions"
,
len
(
spec
.
Extensions
))
// Log profile info
if
d
.
profile
!=
nil
{
slog
.
Debug
(
"tls_fingerprint_using_profile"
,
"name"
,
d
.
profile
.
Name
,
"grease"
,
d
.
profile
.
EnableGREASE
)
}
else
{
slog
.
Debug
(
"tls_fingerprint_using_default_profile"
)
}
// Create uTLS connection
// Note: TLS 1.3 cipher suites are handled automatically by utls when TLS 1.3 is in SupportedVersions
tlsConn
:=
utls
.
UClient
(
conn
,
&
utls
.
Config
{
ServerName
:
host
,
},
utls
.
HelloCustom
)
// Apply fingerprint
if
err
:=
tlsConn
.
ApplyPreset
(
spec
);
err
!=
nil
{
slog
.
Debug
(
"tls_fingerprint_apply_preset_failed"
,
"error"
,
err
)
_
=
conn
.
Close
()
return
nil
,
err
}
slog
.
Debug
(
"tls_fingerprint_preset_applied"
)
// Perform TLS handshake
if
err
:=
tlsConn
.
HandshakeContext
(
ctx
);
err
!=
nil
{
slog
.
Debug
(
"tls_fingerprint_handshake_failed"
,
"error"
,
err
,
"local_addr"
,
conn
.
LocalAddr
(),
"remote_addr"
,
conn
.
RemoteAddr
())
_
=
conn
.
Close
()
return
nil
,
fmt
.
Errorf
(
"TLS handshake failed: %w"
,
err
)
}
// Log successful handshake details
state
:=
tlsConn
.
ConnectionState
()
slog
.
Debug
(
"tls_fingerprint_handshake_success"
,
"version"
,
fmt
.
Sprintf
(
"0x%04x"
,
state
.
Version
),
"cipher_suite"
,
fmt
.
Sprintf
(
"0x%04x"
,
state
.
CipherSuite
),
"alpn"
,
state
.
NegotiatedProtocol
)
return
tlsConn
,
nil
}
// buildClientHelloSpec constructs the ClientHello specification based on the profile.
func
(
d
*
Dialer
)
buildClientHelloSpec
()
*
utls
.
ClientHelloSpec
{
return
buildClientHelloSpecFromProfile
(
d
.
profile
)
}
// toUTLSCurves converts uint16 slice to utls.CurveID slice.
func
toUTLSCurves
(
curves
[]
uint16
)
[]
utls
.
CurveID
{
result
:=
make
([]
utls
.
CurveID
,
len
(
curves
))
for
i
,
c
:=
range
curves
{
result
[
i
]
=
utls
.
CurveID
(
c
)
}
return
result
}
// buildClientHelloSpecFromProfile constructs ClientHelloSpec from a Profile.
// This is a standalone function that can be used by both Dialer and HTTPProxyDialer.
func
buildClientHelloSpecFromProfile
(
profile
*
Profile
)
*
utls
.
ClientHelloSpec
{
// Get cipher suites
var
cipherSuites
[]
uint16
if
profile
!=
nil
&&
len
(
profile
.
CipherSuites
)
>
0
{
cipherSuites
=
profile
.
CipherSuites
}
else
{
cipherSuites
=
defaultCipherSuites
}
// Get curves
var
curves
[]
utls
.
CurveID
if
profile
!=
nil
&&
len
(
profile
.
Curves
)
>
0
{
curves
=
toUTLSCurves
(
profile
.
Curves
)
}
else
{
curves
=
defaultCurves
}
// Get point formats
var
pointFormats
[]
uint8
if
profile
!=
nil
&&
len
(
profile
.
PointFormats
)
>
0
{
pointFormats
=
profile
.
PointFormats
}
else
{
pointFormats
=
defaultPointFormats
}
// Check if GREASE is enabled
enableGREASE
:=
profile
!=
nil
&&
profile
.
EnableGREASE
extensions
:=
make
([]
utls
.
TLSExtension
,
0
,
16
)
if
enableGREASE
{
extensions
=
append
(
extensions
,
&
utls
.
UtlsGREASEExtension
{})
}
// SNI extension - MUST be explicitly added for HelloCustom mode
// utls will populate the server name from Config.ServerName
extensions
=
append
(
extensions
,
&
utls
.
SNIExtension
{})
// Claude CLI extension order (captured from tshark):
// server_name(0), ec_point_formats(11), supported_groups(10), session_ticket(35),
// alpn(16), encrypt_then_mac(22), extended_master_secret(23),
// signature_algorithms(13), supported_versions(43),
// psk_key_exchange_modes(45), key_share(51)
extensions
=
append
(
extensions
,
&
utls
.
SupportedPointsExtension
{
SupportedPoints
:
pointFormats
},
&
utls
.
SupportedCurvesExtension
{
Curves
:
curves
},
&
utls
.
SessionTicketExtension
{},
&
utls
.
ALPNExtension
{
AlpnProtocols
:
[]
string
{
"http/1.1"
}},
&
utls
.
GenericExtension
{
Id
:
22
},
&
utls
.
ExtendedMasterSecretExtension
{},
&
utls
.
SignatureAlgorithmsExtension
{
SupportedSignatureAlgorithms
:
defaultSignatureAlgorithms
},
&
utls
.
SupportedVersionsExtension
{
Versions
:
[]
uint16
{
utls
.
VersionTLS13
,
utls
.
VersionTLS12
,
}},
&
utls
.
PSKKeyExchangeModesExtension
{
Modes
:
[]
uint8
{
utls
.
PskModeDHE
}},
&
utls
.
KeyShareExtension
{
KeyShares
:
[]
utls
.
KeyShare
{
{
Group
:
utls
.
X25519
},
}},
)
if
enableGREASE
{
extensions
=
append
(
extensions
,
&
utls
.
UtlsGREASEExtension
{})
}
return
&
utls
.
ClientHelloSpec
{
CipherSuites
:
cipherSuites
,
CompressionMethods
:
[]
uint8
{
0
},
// null compression only (standard)
Extensions
:
extensions
,
TLSVersMax
:
utls
.
VersionTLS13
,
TLSVersMin
:
utls
.
VersionTLS10
,
}
}
backend/internal/pkg/tlsfingerprint/dialer_integration_test.go
0 → 100644
View file @
2fe8932c
//go:build integration
// Package tlsfingerprint provides TLS fingerprint simulation for HTTP clients.
//
// Integration tests for verifying TLS fingerprint correctness.
// These tests make actual network requests to external services and should be run manually.
//
// Run with: go test -v -tags=integration ./internal/pkg/tlsfingerprint/...
package
tlsfingerprint
import
(
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"
)
// skipIfExternalServiceUnavailable checks if the external service is available.
// If not, it skips the test instead of failing.
func
skipIfExternalServiceUnavailable
(
t
*
testing
.
T
,
err
error
)
{
t
.
Helper
()
if
err
!=
nil
{
// Check for common network/TLS errors that indicate external service issues
errStr
:=
err
.
Error
()
if
strings
.
Contains
(
errStr
,
"certificate has expired"
)
||
strings
.
Contains
(
errStr
,
"certificate is not yet valid"
)
||
strings
.
Contains
(
errStr
,
"connection refused"
)
||
strings
.
Contains
(
errStr
,
"no such host"
)
||
strings
.
Contains
(
errStr
,
"network is unreachable"
)
||
strings
.
Contains
(
errStr
,
"timeout"
)
{
t
.
Skipf
(
"skipping test: external service unavailable: %v"
,
err
)
}
t
.
Fatalf
(
"failed to get fingerprint: %v"
,
err
)
}
}
// TestJA3Fingerprint verifies the JA3/JA4 fingerprint matches expected value.
// This test uses tls.peet.ws to verify the fingerprint.
// Expected JA3 hash: 1a28e69016765d92e3b381168d68922c (Claude CLI / Node.js 20.x)
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4 (d=domain) or t13i5911h1_... (i=IP)
func
TestJA3Fingerprint
(
t
*
testing
.
T
)
{
// Skip if network is unavailable or if running in short mode
if
testing
.
Short
()
{
t
.
Skip
(
"skipping integration test in short mode"
)
}
profile
:=
&
Profile
{
Name
:
"Claude CLI Test"
,
EnableGREASE
:
false
,
}
dialer
:=
NewDialer
(
profile
,
nil
)
client
:=
&
http
.
Client
{
Transport
:
&
http
.
Transport
{
DialTLSContext
:
dialer
.
DialTLSContext
,
},
Timeout
:
30
*
time
.
Second
,
}
// Use tls.peet.ws fingerprint detection API
ctx
,
cancel
:=
context
.
WithTimeout
(
context
.
Background
(),
30
*
time
.
Second
)
defer
cancel
()
req
,
err
:=
http
.
NewRequestWithContext
(
ctx
,
"GET"
,
"https://tls.peet.ws/api/all"
,
nil
)
if
err
!=
nil
{
t
.
Fatalf
(
"failed to create request: %v"
,
err
)
}
req
.
Header
.
Set
(
"User-Agent"
,
"Claude Code/2.0.0 Node.js/20.0.0"
)
resp
,
err
:=
client
.
Do
(
req
)
skipIfExternalServiceUnavailable
(
t
,
err
)
defer
func
()
{
_
=
resp
.
Body
.
Close
()
}()
body
,
err
:=
io
.
ReadAll
(
resp
.
Body
)
if
err
!=
nil
{
t
.
Fatalf
(
"failed to read response: %v"
,
err
)
}
var
fpResp
FingerprintResponse
if
err
:=
json
.
Unmarshal
(
body
,
&
fpResp
);
err
!=
nil
{
t
.
Logf
(
"Response body: %s"
,
string
(
body
))
t
.
Fatalf
(
"failed to parse fingerprint response: %v"
,
err
)
}
// Log all fingerprint information
t
.
Logf
(
"JA3: %s"
,
fpResp
.
TLS
.
JA3
)
t
.
Logf
(
"JA3 Hash: %s"
,
fpResp
.
TLS
.
JA3Hash
)
t
.
Logf
(
"JA4: %s"
,
fpResp
.
TLS
.
JA4
)
t
.
Logf
(
"PeetPrint: %s"
,
fpResp
.
TLS
.
PeetPrint
)
t
.
Logf
(
"PeetPrint Hash: %s"
,
fpResp
.
TLS
.
PeetPrintHash
)
// Verify JA3 hash matches expected value
expectedJA3Hash
:=
"1a28e69016765d92e3b381168d68922c"
if
fpResp
.
TLS
.
JA3Hash
==
expectedJA3Hash
{
t
.
Logf
(
"✓ JA3 hash matches expected value: %s"
,
expectedJA3Hash
)
}
else
{
t
.
Errorf
(
"✗ JA3 hash mismatch: got %s, expected %s"
,
fpResp
.
TLS
.
JA3Hash
,
expectedJA3Hash
)
}
// Verify JA4 fingerprint
// JA4 format: t[version][sni][cipher_count][ext_count][alpn]_[cipher_hash]_[ext_hash]
// Expected: t13d5910h1 (d=domain) or t13i5910h1 (i=IP)
// The suffix _a33745022dd6_1f22a2ca17c4 should match
expectedJA4Suffix
:=
"_a33745022dd6_1f22a2ca17c4"
if
strings
.
HasSuffix
(
fpResp
.
TLS
.
JA4
,
expectedJA4Suffix
)
{
t
.
Logf
(
"✓ JA4 suffix matches expected value: %s"
,
expectedJA4Suffix
)
}
else
{
t
.
Errorf
(
"✗ JA4 suffix mismatch: got %s, expected suffix %s"
,
fpResp
.
TLS
.
JA4
,
expectedJA4Suffix
)
}
// Verify JA4 prefix (t13d5911h1 or t13i5911h1)
// d = domain (SNI present), i = IP (no SNI)
// Since we connect to tls.peet.ws (domain), we expect 'd'
expectedJA4Prefix
:=
"t13d5911h1"
if
strings
.
HasPrefix
(
fpResp
.
TLS
.
JA4
,
expectedJA4Prefix
)
{
t
.
Logf
(
"✓ JA4 prefix matches: %s (t13=TLS1.3, d=domain, 59=ciphers, 11=extensions, h1=HTTP/1.1)"
,
expectedJA4Prefix
)
}
else
{
// Also accept 'i' variant for IP connections
altPrefix
:=
"t13i5911h1"
if
strings
.
HasPrefix
(
fpResp
.
TLS
.
JA4
,
altPrefix
)
{
t
.
Logf
(
"✓ JA4 prefix matches (IP variant): %s"
,
altPrefix
)
}
else
{
t
.
Errorf
(
"✗ JA4 prefix mismatch: got %s, expected %s or %s"
,
fpResp
.
TLS
.
JA4
,
expectedJA4Prefix
,
altPrefix
)
}
}
// Verify JA3 contains expected cipher suites (TLS 1.3 ciphers at the beginning)
if
strings
.
Contains
(
fpResp
.
TLS
.
JA3
,
"4866-4867-4865"
)
{
t
.
Logf
(
"✓ JA3 contains expected TLS 1.3 cipher suites"
)
}
else
{
t
.
Logf
(
"Warning: JA3 does not contain expected TLS 1.3 cipher suites"
)
}
// Verify extension list (should be 11 extensions including SNI)
// Expected: 0-11-10-35-16-22-23-13-43-45-51
expectedExtensions
:=
"0-11-10-35-16-22-23-13-43-45-51"
if
strings
.
Contains
(
fpResp
.
TLS
.
JA3
,
expectedExtensions
)
{
t
.
Logf
(
"✓ JA3 contains expected extension list: %s"
,
expectedExtensions
)
}
else
{
t
.
Logf
(
"Warning: JA3 extension list may differ"
)
}
}
// TestProfileExpectation defines expected fingerprint values for a profile.
type
TestProfileExpectation
struct
{
Profile
*
Profile
ExpectedJA3
string
// Expected JA3 hash (empty = don't check)
ExpectedJA4
string
// Expected full JA4 (empty = don't check)
JA4CipherHash
string
// Expected JA4 cipher hash - the stable middle part (empty = don't check)
}
// TestAllProfiles tests multiple TLS fingerprint profiles against tls.peet.ws.
// Run with: go test -v -tags=integration -run TestAllProfiles ./internal/pkg/tlsfingerprint/...
func
TestAllProfiles
(
t
*
testing
.
T
)
{
if
testing
.
Short
()
{
t
.
Skip
(
"skipping integration test in short mode"
)
}
// Define all profiles to test with their expected fingerprints
// These profiles are from config.yaml gateway.tls_fingerprint.profiles
profiles
:=
[]
TestProfileExpectation
{
{
// Linux x64 Node.js v22.17.1
// Expected JA3 Hash: 1a28e69016765d92e3b381168d68922c
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4
Profile
:
&
Profile
{
Name
:
"linux_x64_node_v22171"
,
EnableGREASE
:
false
,
CipherSuites
:
[]
uint16
{
4866
,
4867
,
4865
,
49199
,
49195
,
49200
,
49196
,
158
,
49191
,
103
,
49192
,
107
,
163
,
159
,
52393
,
52392
,
52394
,
49327
,
49325
,
49315
,
49311
,
49245
,
49249
,
49239
,
49235
,
162
,
49326
,
49324
,
49314
,
49310
,
49244
,
49248
,
49238
,
49234
,
49188
,
106
,
49187
,
64
,
49162
,
49172
,
57
,
56
,
49161
,
49171
,
51
,
50
,
157
,
49313
,
49309
,
49233
,
156
,
49312
,
49308
,
49232
,
61
,
60
,
53
,
47
,
255
},
Curves
:
[]
uint16
{
29
,
23
,
30
,
25
,
24
,
256
,
257
,
258
,
259
,
260
},
PointFormats
:
[]
uint8
{
0
,
1
,
2
},
},
JA4CipherHash
:
"a33745022dd6"
,
// stable part
},
{
// MacOS arm64 Node.js v22.18.0
// Expected JA3 Hash: 70cb5ca646080902703ffda87036a5ea
// Expected JA4: t13d5912h1_a33745022dd6_dbd39dd1d406
Profile
:
&
Profile
{
Name
:
"macos_arm64_node_v22180"
,
EnableGREASE
:
false
,
CipherSuites
:
[]
uint16
{
4866
,
4867
,
4865
,
49199
,
49195
,
49200
,
49196
,
158
,
49191
,
103
,
49192
,
107
,
163
,
159
,
52393
,
52392
,
52394
,
49327
,
49325
,
49315
,
49311
,
49245
,
49249
,
49239
,
49235
,
162
,
49326
,
49324
,
49314
,
49310
,
49244
,
49248
,
49238
,
49234
,
49188
,
106
,
49187
,
64
,
49162
,
49172
,
57
,
56
,
49161
,
49171
,
51
,
50
,
157
,
49313
,
49309
,
49233
,
156
,
49312
,
49308
,
49232
,
61
,
60
,
53
,
47
,
255
},
Curves
:
[]
uint16
{
29
,
23
,
30
,
25
,
24
,
256
,
257
,
258
,
259
,
260
},
PointFormats
:
[]
uint8
{
0
,
1
,
2
},
},
JA4CipherHash
:
"a33745022dd6"
,
// stable part (same cipher suites)
},
}
for
_
,
tc
:=
range
profiles
{
tc
:=
tc
// capture range variable
t
.
Run
(
tc
.
Profile
.
Name
,
func
(
t
*
testing
.
T
)
{
fp
:=
fetchFingerprint
(
t
,
tc
.
Profile
)
if
fp
==
nil
{
return
// fetchFingerprint already called t.Fatal
}
t
.
Logf
(
"Profile: %s"
,
tc
.
Profile
.
Name
)
t
.
Logf
(
" JA3: %s"
,
fp
.
JA3
)
t
.
Logf
(
" JA3 Hash: %s"
,
fp
.
JA3Hash
)
t
.
Logf
(
" JA4: %s"
,
fp
.
JA4
)
t
.
Logf
(
" PeetPrint: %s"
,
fp
.
PeetPrint
)
t
.
Logf
(
" PeetPrintHash: %s"
,
fp
.
PeetPrintHash
)
// Verify expectations
if
tc
.
ExpectedJA3
!=
""
{
if
fp
.
JA3Hash
==
tc
.
ExpectedJA3
{
t
.
Logf
(
" ✓ JA3 hash matches: %s"
,
tc
.
ExpectedJA3
)
}
else
{
t
.
Errorf
(
" ✗ JA3 hash mismatch: got %s, expected %s"
,
fp
.
JA3Hash
,
tc
.
ExpectedJA3
)
}
}
if
tc
.
ExpectedJA4
!=
""
{
if
fp
.
JA4
==
tc
.
ExpectedJA4
{
t
.
Logf
(
" ✓ JA4 matches: %s"
,
tc
.
ExpectedJA4
)
}
else
{
t
.
Errorf
(
" ✗ JA4 mismatch: got %s, expected %s"
,
fp
.
JA4
,
tc
.
ExpectedJA4
)
}
}
// Check JA4 cipher hash (stable middle part)
// JA4 format: prefix_cipherHash_extHash
if
tc
.
JA4CipherHash
!=
""
{
if
strings
.
Contains
(
fp
.
JA4
,
"_"
+
tc
.
JA4CipherHash
+
"_"
)
{
t
.
Logf
(
" ✓ JA4 cipher hash matches: %s"
,
tc
.
JA4CipherHash
)
}
else
{
t
.
Errorf
(
" ✗ JA4 cipher hash mismatch: got %s, expected cipher hash %s"
,
fp
.
JA4
,
tc
.
JA4CipherHash
)
}
}
})
}
}
// fetchFingerprint makes a request to tls.peet.ws and returns the TLS fingerprint info.
func
fetchFingerprint
(
t
*
testing
.
T
,
profile
*
Profile
)
*
TLSInfo
{
t
.
Helper
()
dialer
:=
NewDialer
(
profile
,
nil
)
client
:=
&
http
.
Client
{
Transport
:
&
http
.
Transport
{
DialTLSContext
:
dialer
.
DialTLSContext
,
},
Timeout
:
30
*
time
.
Second
,
}
ctx
,
cancel
:=
context
.
WithTimeout
(
context
.
Background
(),
30
*
time
.
Second
)
defer
cancel
()
req
,
err
:=
http
.
NewRequestWithContext
(
ctx
,
"GET"
,
"https://tls.peet.ws/api/all"
,
nil
)
if
err
!=
nil
{
t
.
Fatalf
(
"failed to create request: %v"
,
err
)
return
nil
}
req
.
Header
.
Set
(
"User-Agent"
,
"Claude Code/2.0.0 Node.js/20.0.0"
)
resp
,
err
:=
client
.
Do
(
req
)
skipIfExternalServiceUnavailable
(
t
,
err
)
defer
func
()
{
_
=
resp
.
Body
.
Close
()
}()
body
,
err
:=
io
.
ReadAll
(
resp
.
Body
)
if
err
!=
nil
{
t
.
Fatalf
(
"failed to read response: %v"
,
err
)
return
nil
}
var
fpResp
FingerprintResponse
if
err
:=
json
.
Unmarshal
(
body
,
&
fpResp
);
err
!=
nil
{
t
.
Logf
(
"Response body: %s"
,
string
(
body
))
t
.
Fatalf
(
"failed to parse fingerprint response: %v"
,
err
)
return
nil
}
return
&
fpResp
.
TLS
}
backend/internal/pkg/tlsfingerprint/dialer_test.go
0 → 100644
View file @
2fe8932c
// Package tlsfingerprint provides TLS fingerprint simulation for HTTP clients.
//
// Unit tests for TLS fingerprint dialer.
// Integration tests that require external network are in dialer_integration_test.go
// and require the 'integration' build tag.
//
// Run unit tests: go test -v ./internal/pkg/tlsfingerprint/...
// Run integration tests: go test -v -tags=integration ./internal/pkg/tlsfingerprint/...
package
tlsfingerprint
import
(
"net/url"
"testing"
)
// FingerprintResponse represents the response from tls.peet.ws/api/all.
type
FingerprintResponse
struct
{
IP
string
`json:"ip"`
TLS
TLSInfo
`json:"tls"`
HTTP2
any
`json:"http2"`
}
// TLSInfo contains TLS fingerprint details.
type
TLSInfo
struct
{
JA3
string
`json:"ja3"`
JA3Hash
string
`json:"ja3_hash"`
JA4
string
`json:"ja4"`
PeetPrint
string
`json:"peetprint"`
PeetPrintHash
string
`json:"peetprint_hash"`
ClientRandom
string
`json:"client_random"`
SessionID
string
`json:"session_id"`
}
// TestDialerWithProfile tests that different profiles produce different fingerprints.
func
TestDialerWithProfile
(
t
*
testing
.
T
)
{
// Create two dialers with different profiles
profile1
:=
&
Profile
{
Name
:
"Profile 1 - No GREASE"
,
EnableGREASE
:
false
,
}
profile2
:=
&
Profile
{
Name
:
"Profile 2 - With GREASE"
,
EnableGREASE
:
true
,
}
dialer1
:=
NewDialer
(
profile1
,
nil
)
dialer2
:=
NewDialer
(
profile2
,
nil
)
// Build specs and compare
// Note: We can't directly compare JA3 without making network requests
// but we can verify the specs are different
spec1
:=
dialer1
.
buildClientHelloSpec
()
spec2
:=
dialer2
.
buildClientHelloSpec
()
// Profile with GREASE should have more extensions
if
len
(
spec2
.
Extensions
)
<=
len
(
spec1
.
Extensions
)
{
t
.
Error
(
"expected GREASE profile to have more extensions"
)
}
}
// TestHTTPProxyDialerBasic tests HTTP proxy dialer creation.
// Note: This is a unit test - actual proxy testing requires a proxy server.
func
TestHTTPProxyDialerBasic
(
t
*
testing
.
T
)
{
profile
:=
&
Profile
{
Name
:
"Test Profile"
,
EnableGREASE
:
false
,
}
// Test that dialer is created without panic
proxyURL
:=
mustParseURL
(
"http://proxy.example.com:8080"
)
dialer
:=
NewHTTPProxyDialer
(
profile
,
proxyURL
)
if
dialer
==
nil
{
t
.
Fatal
(
"expected dialer to be created"
)
}
if
dialer
.
profile
!=
profile
{
t
.
Error
(
"expected profile to be set"
)
}
if
dialer
.
proxyURL
!=
proxyURL
{
t
.
Error
(
"expected proxyURL to be set"
)
}
}
// TestSOCKS5ProxyDialerBasic tests SOCKS5 proxy dialer creation.
// Note: This is a unit test - actual proxy testing requires a proxy server.
func
TestSOCKS5ProxyDialerBasic
(
t
*
testing
.
T
)
{
profile
:=
&
Profile
{
Name
:
"Test Profile"
,
EnableGREASE
:
false
,
}
// Test that dialer is created without panic
proxyURL
:=
mustParseURL
(
"socks5://proxy.example.com:1080"
)
dialer
:=
NewSOCKS5ProxyDialer
(
profile
,
proxyURL
)
if
dialer
==
nil
{
t
.
Fatal
(
"expected dialer to be created"
)
}
if
dialer
.
profile
!=
profile
{
t
.
Error
(
"expected profile to be set"
)
}
if
dialer
.
proxyURL
!=
proxyURL
{
t
.
Error
(
"expected proxyURL to be set"
)
}
}
// TestBuildClientHelloSpec tests ClientHello spec construction.
func
TestBuildClientHelloSpec
(
t
*
testing
.
T
)
{
// Test with nil profile (should use defaults)
spec
:=
buildClientHelloSpecFromProfile
(
nil
)
if
len
(
spec
.
CipherSuites
)
==
0
{
t
.
Error
(
"expected cipher suites to be set"
)
}
if
len
(
spec
.
Extensions
)
==
0
{
t
.
Error
(
"expected extensions to be set"
)
}
// Verify default cipher suites are used
if
len
(
spec
.
CipherSuites
)
!=
len
(
defaultCipherSuites
)
{
t
.
Errorf
(
"expected %d cipher suites, got %d"
,
len
(
defaultCipherSuites
),
len
(
spec
.
CipherSuites
))
}
// Test with custom profile
customProfile
:=
&
Profile
{
Name
:
"Custom"
,
EnableGREASE
:
false
,
CipherSuites
:
[]
uint16
{
0x1301
,
0x1302
},
}
spec
=
buildClientHelloSpecFromProfile
(
customProfile
)
if
len
(
spec
.
CipherSuites
)
!=
2
{
t
.
Errorf
(
"expected 2 cipher suites, got %d"
,
len
(
spec
.
CipherSuites
))
}
}
// TestToUTLSCurves tests curve ID conversion.
func
TestToUTLSCurves
(
t
*
testing
.
T
)
{
input
:=
[]
uint16
{
0x001d
,
0x0017
,
0x0018
}
result
:=
toUTLSCurves
(
input
)
if
len
(
result
)
!=
len
(
input
)
{
t
.
Errorf
(
"expected %d curves, got %d"
,
len
(
input
),
len
(
result
))
}
for
i
,
curve
:=
range
result
{
if
uint16
(
curve
)
!=
input
[
i
]
{
t
.
Errorf
(
"curve %d: expected 0x%04x, got 0x%04x"
,
i
,
input
[
i
],
uint16
(
curve
))
}
}
}
// Helper function to parse URL without error handling.
func
mustParseURL
(
rawURL
string
)
*
url
.
URL
{
u
,
err
:=
url
.
Parse
(
rawURL
)
if
err
!=
nil
{
panic
(
err
)
}
return
u
}
backend/internal/pkg/tlsfingerprint/registry.go
0 → 100644
View file @
2fe8932c
// Package tlsfingerprint provides TLS fingerprint simulation for HTTP clients.
package
tlsfingerprint
import
(
"log/slog"
"sort"
"sync"
"github.com/Wei-Shaw/sub2api/internal/config"
)
// DefaultProfileName is the name of the built-in Claude CLI profile.
const
DefaultProfileName
=
"claude_cli_v2"
// Registry manages TLS fingerprint profiles.
// It holds a collection of profiles that can be used for TLS fingerprint simulation.
// Profiles are selected based on account ID using modulo operation.
type
Registry
struct
{
mu
sync
.
RWMutex
profiles
map
[
string
]
*
Profile
profileNames
[]
string
// Sorted list of profile names for deterministic selection
}
// NewRegistry creates a new TLS fingerprint profile registry.
// It initializes with the built-in default profile.
func
NewRegistry
()
*
Registry
{
r
:=
&
Registry
{
profiles
:
make
(
map
[
string
]
*
Profile
),
profileNames
:
make
([]
string
,
0
),
}
// Register the built-in default profile
r
.
registerBuiltinProfile
()
return
r
}
// NewRegistryFromConfig creates a new registry and loads profiles from config.
// If the config has custom profiles defined, they will be merged with the built-in default.
func
NewRegistryFromConfig
(
cfg
*
config
.
TLSFingerprintConfig
)
*
Registry
{
r
:=
NewRegistry
()
if
cfg
==
nil
||
!
cfg
.
Enabled
{
slog
.
Debug
(
"tls_registry_disabled"
,
"reason"
,
"disabled or no config"
)
return
r
}
// Load custom profiles from config
for
name
,
profileCfg
:=
range
cfg
.
Profiles
{
profile
:=
&
Profile
{
Name
:
profileCfg
.
Name
,
EnableGREASE
:
profileCfg
.
EnableGREASE
,
CipherSuites
:
profileCfg
.
CipherSuites
,
Curves
:
profileCfg
.
Curves
,
PointFormats
:
profileCfg
.
PointFormats
,
}
// If the profile has empty values, they will use defaults in dialer
r
.
RegisterProfile
(
name
,
profile
)
slog
.
Debug
(
"tls_registry_loaded_profile"
,
"key"
,
name
,
"name"
,
profileCfg
.
Name
)
}
slog
.
Debug
(
"tls_registry_initialized"
,
"profile_count"
,
len
(
r
.
profileNames
),
"profiles"
,
r
.
profileNames
)
return
r
}
// registerBuiltinProfile adds the default Claude CLI profile to the registry.
func
(
r
*
Registry
)
registerBuiltinProfile
()
{
defaultProfile
:=
&
Profile
{
Name
:
"Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)"
,
EnableGREASE
:
false
,
// Node.js does not use GREASE
// Empty slices will cause dialer to use built-in defaults
CipherSuites
:
nil
,
Curves
:
nil
,
PointFormats
:
nil
,
}
r
.
RegisterProfile
(
DefaultProfileName
,
defaultProfile
)
}
// RegisterProfile adds or updates a profile in the registry.
func
(
r
*
Registry
)
RegisterProfile
(
name
string
,
profile
*
Profile
)
{
r
.
mu
.
Lock
()
defer
r
.
mu
.
Unlock
()
// Check if this is a new profile
_
,
exists
:=
r
.
profiles
[
name
]
r
.
profiles
[
name
]
=
profile
if
!
exists
{
r
.
profileNames
=
append
(
r
.
profileNames
,
name
)
// Keep names sorted for deterministic selection
sort
.
Strings
(
r
.
profileNames
)
}
}
// GetProfile returns a profile by name.
// Returns nil if the profile does not exist.
func
(
r
*
Registry
)
GetProfile
(
name
string
)
*
Profile
{
r
.
mu
.
RLock
()
defer
r
.
mu
.
RUnlock
()
return
r
.
profiles
[
name
]
}
// GetDefaultProfile returns the built-in default profile.
func
(
r
*
Registry
)
GetDefaultProfile
()
*
Profile
{
return
r
.
GetProfile
(
DefaultProfileName
)
}
// GetProfileByAccountID returns a profile for the given account ID.
// The profile is selected using: profileNames[accountID % len(profiles)]
// This ensures deterministic profile assignment for each account.
func
(
r
*
Registry
)
GetProfileByAccountID
(
accountID
int64
)
*
Profile
{
r
.
mu
.
RLock
()
defer
r
.
mu
.
RUnlock
()
if
len
(
r
.
profileNames
)
==
0
{
return
nil
}
// Use modulo to select profile index
// Use absolute value to handle negative IDs (though unlikely)
idx
:=
accountID
if
idx
<
0
{
idx
=
-
idx
}
selectedIndex
:=
int
(
idx
%
int64
(
len
(
r
.
profileNames
)))
selectedName
:=
r
.
profileNames
[
selectedIndex
]
return
r
.
profiles
[
selectedName
]
}
// ProfileCount returns the number of registered profiles.
func
(
r
*
Registry
)
ProfileCount
()
int
{
r
.
mu
.
RLock
()
defer
r
.
mu
.
RUnlock
()
return
len
(
r
.
profiles
)
}
// ProfileNames returns a sorted list of all registered profile names.
func
(
r
*
Registry
)
ProfileNames
()
[]
string
{
r
.
mu
.
RLock
()
defer
r
.
mu
.
RUnlock
()
// Return a copy to prevent modification
names
:=
make
([]
string
,
len
(
r
.
profileNames
))
copy
(
names
,
r
.
profileNames
)
return
names
}
// Global registry instance for convenience
var
globalRegistry
*
Registry
var
globalRegistryOnce
sync
.
Once
// GlobalRegistry returns the global TLS fingerprint registry.
// The registry is lazily initialized with the default profile.
func
GlobalRegistry
()
*
Registry
{
globalRegistryOnce
.
Do
(
func
()
{
globalRegistry
=
NewRegistry
()
})
return
globalRegistry
}
// InitGlobalRegistry initializes the global registry with configuration.
// This should be called during application startup.
// It is safe to call multiple times; subsequent calls will update the registry.
func
InitGlobalRegistry
(
cfg
*
config
.
TLSFingerprintConfig
)
*
Registry
{
globalRegistryOnce
.
Do
(
func
()
{
globalRegistry
=
NewRegistryFromConfig
(
cfg
)
})
return
globalRegistry
}
Prev
1
2
3
4
5
6
7
8
…
14
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment