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
"backend/internal/vscode:/vscode.git/clone" did not exist on "839ab37d407d857461f8677094df9ffff04f92e5"
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
Hide 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) {
...
@@ -47,9 +47,6 @@ func (h *UserHandler) GetProfile(c *gin.Context) {
return
return
}
}
// 清空notes字段,普通用户不应看到备注
userData
.
Notes
=
""
response
.
Success
(
c
,
dto
.
UserFromService
(
userData
))
response
.
Success
(
c
,
dto
.
UserFromService
(
userData
))
}
}
...
@@ -105,8 +102,5 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
...
@@ -105,8 +102,5 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
return
return
}
}
// 清空notes字段,普通用户不应看到备注
updatedUser
.
Notes
=
""
response
.
Success
(
c
,
dto
.
UserFromService
(
updatedUser
))
response
.
Success
(
c
,
dto
.
UserFromService
(
updatedUser
))
}
}
backend/internal/handler/wire.go
View file @
2fe8932c
...
@@ -70,6 +70,7 @@ func ProvideHandlers(
...
@@ -70,6 +70,7 @@ func ProvideHandlers(
gatewayHandler
*
GatewayHandler
,
gatewayHandler
*
GatewayHandler
,
openaiGatewayHandler
*
OpenAIGatewayHandler
,
openaiGatewayHandler
*
OpenAIGatewayHandler
,
settingHandler
*
SettingHandler
,
settingHandler
*
SettingHandler
,
totpHandler
*
TotpHandler
,
)
*
Handlers
{
)
*
Handlers
{
return
&
Handlers
{
return
&
Handlers
{
Auth
:
authHandler
,
Auth
:
authHandler
,
...
@@ -82,6 +83,7 @@ func ProvideHandlers(
...
@@ -82,6 +83,7 @@ func ProvideHandlers(
Gateway
:
gatewayHandler
,
Gateway
:
gatewayHandler
,
OpenAIGateway
:
openaiGatewayHandler
,
OpenAIGateway
:
openaiGatewayHandler
,
Setting
:
settingHandler
,
Setting
:
settingHandler
,
Totp
:
totpHandler
,
}
}
}
}
...
@@ -96,6 +98,7 @@ var ProviderSet = wire.NewSet(
...
@@ -96,6 +98,7 @@ var ProviderSet = wire.NewSet(
NewSubscriptionHandler
,
NewSubscriptionHandler
,
NewGatewayHandler
,
NewGatewayHandler
,
NewOpenAIGatewayHandler
,
NewOpenAIGatewayHandler
,
NewTotpHandler
,
ProvideSettingHandler
,
ProvideSettingHandler
,
// Admin handlers
// Admin handlers
...
...
backend/internal/middleware/rate_limiter_integration_test.go
View file @
2fe8932c
...
@@ -7,6 +7,9 @@ import (
...
@@ -7,6 +7,9 @@ import (
"fmt"
"fmt"
"net/http"
"net/http"
"net/http/httptest"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"testing"
"testing"
"time"
"time"
...
@@ -88,6 +91,7 @@ func performRequest(router *gin.Engine) *httptest.ResponseRecorder {
...
@@ -88,6 +91,7 @@ func performRequest(router *gin.Engine) *httptest.ResponseRecorder {
func
startRedis
(
t
*
testing
.
T
,
ctx
context
.
Context
)
*
redis
.
Client
{
func
startRedis
(
t
*
testing
.
T
,
ctx
context
.
Context
)
*
redis
.
Client
{
t
.
Helper
()
t
.
Helper
()
ensureDockerAvailable
(
t
)
redisContainer
,
err
:=
tcredis
.
Run
(
ctx
,
redisImageTag
)
redisContainer
,
err
:=
tcredis
.
Run
(
ctx
,
redisImageTag
)
require
.
NoError
(
t
,
err
)
require
.
NoError
(
t
,
err
)
...
@@ -112,3 +116,43 @@ func startRedis(t *testing.T, ctx context.Context) *redis.Client {
...
@@ -112,3 +116,43 @@ func startRedis(t *testing.T, ctx context.Context) *redis.Client {
return
rdb
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 (
...
@@ -16,15 +16,6 @@ import (
"time"
"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 端点)
// NewAPIRequestWithURL 使用指定的 base URL 创建 Antigravity API 请求(v1internal 端点)
func
NewAPIRequestWithURL
(
ctx
context
.
Context
,
baseURL
,
action
,
accessToken
string
,
body
[]
byte
)
(
*
http
.
Request
,
error
)
{
func
NewAPIRequestWithURL
(
ctx
context
.
Context
,
baseURL
,
action
,
accessToken
string
,
body
[]
byte
)
(
*
http
.
Request
,
error
)
{
// 构建 URL,流式请求添加 ?alt=sse 参数
// 构建 URL,流式请求添加 ?alt=sse 参数
...
@@ -39,23 +30,11 @@ func NewAPIRequestWithURL(ctx context.Context, baseURL, action, accessToken stri
...
@@ -39,23 +30,11 @@ func NewAPIRequestWithURL(ctx context.Context, baseURL, action, accessToken stri
return
nil
,
err
return
nil
,
err
}
}
// 基础 Headers
// 基础 Headers
(与 Antigravity-Manager 保持一致,只设置这 3 个)
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
req
.
Header
.
Set
(
"Authorization"
,
"Bearer "
+
accessToken
)
req
.
Header
.
Set
(
"Authorization"
,
"Bearer "
+
accessToken
)
req
.
Header
.
Set
(
"User-Agent"
,
UserAgent
)
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
return
req
,
nil
}
}
...
@@ -195,12 +174,15 @@ func isConnectionError(err error) bool {
...
@@ -195,12 +174,15 @@ func isConnectionError(err error) bool {
}
}
// shouldFallbackToNextURL 判断是否应切换到下一个 URL
// shouldFallbackToNextURL 判断是否应切换到下一个 URL
//
仅连接错误和 HTTP 429
触发 URL 降级
//
与 Antigravity-Manager 保持一致:连接错误、429、408、404、5xx
触发 URL 降级
func
shouldFallbackToNextURL
(
err
error
,
statusCode
int
)
bool
{
func
shouldFallbackToNextURL
(
err
error
,
statusCode
int
)
bool
{
if
isConnectionError
(
err
)
{
if
isConnectionError
(
err
)
{
return
true
return
true
}
}
return
statusCode
==
http
.
StatusTooManyRequests
return
statusCode
==
http
.
StatusTooManyRequests
||
statusCode
==
http
.
StatusRequestTimeout
||
statusCode
==
http
.
StatusNotFound
||
statusCode
>=
500
}
}
// ExchangeCode 用 authorization code 交换 token
// ExchangeCode 用 authorization code 交换 token
...
@@ -321,11 +303,8 @@ func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadC
...
@@ -321,11 +303,8 @@ func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadC
return
nil
,
nil
,
fmt
.
Errorf
(
"序列化请求失败: %w"
,
err
)
return
nil
,
nil
,
fmt
.
Errorf
(
"序列化请求失败: %w"
,
err
)
}
}
// 获取可用的 URL 列表
// 固定顺序:prod -> daily
availableURLs
:=
DefaultURLAvailability
.
GetAvailableURLs
()
availableURLs
:=
BaseURLs
if
len
(
availableURLs
)
==
0
{
availableURLs
=
BaseURLs
// 所有 URL 都不可用时,重试所有
}
var
lastErr
error
var
lastErr
error
for
urlIdx
,
baseURL
:=
range
availableURLs
{
for
urlIdx
,
baseURL
:=
range
availableURLs
{
...
@@ -343,7 +322,6 @@ func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadC
...
@@ -343,7 +322,6 @@ func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadC
if
err
!=
nil
{
if
err
!=
nil
{
lastErr
=
fmt
.
Errorf
(
"loadCodeAssist 请求失败: %w"
,
err
)
lastErr
=
fmt
.
Errorf
(
"loadCodeAssist 请求失败: %w"
,
err
)
if
shouldFallbackToNextURL
(
err
,
0
)
&&
urlIdx
<
len
(
availableURLs
)
-
1
{
if
shouldFallbackToNextURL
(
err
,
0
)
&&
urlIdx
<
len
(
availableURLs
)
-
1
{
DefaultURLAvailability
.
MarkUnavailable
(
baseURL
)
log
.
Printf
(
"[antigravity] loadCodeAssist URL fallback: %s -> %s"
,
baseURL
,
availableURLs
[
urlIdx
+
1
])
log
.
Printf
(
"[antigravity] loadCodeAssist URL fallback: %s -> %s"
,
baseURL
,
availableURLs
[
urlIdx
+
1
])
continue
continue
}
}
...
@@ -358,7 +336,6 @@ func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadC
...
@@ -358,7 +336,6 @@ func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadC
// 检查是否需要 URL 降级
// 检查是否需要 URL 降级
if
shouldFallbackToNextURL
(
nil
,
resp
.
StatusCode
)
&&
urlIdx
<
len
(
availableURLs
)
-
1
{
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
])
log
.
Printf
(
"[antigravity] loadCodeAssist URL fallback (HTTP %d): %s -> %s"
,
resp
.
StatusCode
,
baseURL
,
availableURLs
[
urlIdx
+
1
])
continue
continue
}
}
...
@@ -376,6 +353,8 @@ func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadC
...
@@ -376,6 +353,8 @@ func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadC
var
rawResp
map
[
string
]
any
var
rawResp
map
[
string
]
any
_
=
json
.
Unmarshal
(
respBodyBytes
,
&
rawResp
)
_
=
json
.
Unmarshal
(
respBodyBytes
,
&
rawResp
)
// 标记成功的 URL,下次优先使用
DefaultURLAvailability
.
MarkSuccess
(
baseURL
)
return
&
loadResp
,
rawResp
,
nil
return
&
loadResp
,
rawResp
,
nil
}
}
...
@@ -412,11 +391,8 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
...
@@ -412,11 +391,8 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
return
nil
,
nil
,
fmt
.
Errorf
(
"序列化请求失败: %w"
,
err
)
return
nil
,
nil
,
fmt
.
Errorf
(
"序列化请求失败: %w"
,
err
)
}
}
// 获取可用的 URL 列表
// 固定顺序:prod -> daily
availableURLs
:=
DefaultURLAvailability
.
GetAvailableURLs
()
availableURLs
:=
BaseURLs
if
len
(
availableURLs
)
==
0
{
availableURLs
=
BaseURLs
// 所有 URL 都不可用时,重试所有
}
var
lastErr
error
var
lastErr
error
for
urlIdx
,
baseURL
:=
range
availableURLs
{
for
urlIdx
,
baseURL
:=
range
availableURLs
{
...
@@ -434,7 +410,6 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
...
@@ -434,7 +410,6 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
if
err
!=
nil
{
if
err
!=
nil
{
lastErr
=
fmt
.
Errorf
(
"fetchAvailableModels 请求失败: %w"
,
err
)
lastErr
=
fmt
.
Errorf
(
"fetchAvailableModels 请求失败: %w"
,
err
)
if
shouldFallbackToNextURL
(
err
,
0
)
&&
urlIdx
<
len
(
availableURLs
)
-
1
{
if
shouldFallbackToNextURL
(
err
,
0
)
&&
urlIdx
<
len
(
availableURLs
)
-
1
{
DefaultURLAvailability
.
MarkUnavailable
(
baseURL
)
log
.
Printf
(
"[antigravity] fetchAvailableModels URL fallback: %s -> %s"
,
baseURL
,
availableURLs
[
urlIdx
+
1
])
log
.
Printf
(
"[antigravity] fetchAvailableModels URL fallback: %s -> %s"
,
baseURL
,
availableURLs
[
urlIdx
+
1
])
continue
continue
}
}
...
@@ -449,7 +424,6 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
...
@@ -449,7 +424,6 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
// 检查是否需要 URL 降级
// 检查是否需要 URL 降级
if
shouldFallbackToNextURL
(
nil
,
resp
.
StatusCode
)
&&
urlIdx
<
len
(
availableURLs
)
-
1
{
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
])
log
.
Printf
(
"[antigravity] fetchAvailableModels URL fallback (HTTP %d): %s -> %s"
,
resp
.
StatusCode
,
baseURL
,
availableURLs
[
urlIdx
+
1
])
continue
continue
}
}
...
@@ -467,6 +441,8 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
...
@@ -467,6 +441,8 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
var
rawResp
map
[
string
]
any
var
rawResp
map
[
string
]
any
_
=
json
.
Unmarshal
(
respBodyBytes
,
&
rawResp
)
_
=
json
.
Unmarshal
(
respBodyBytes
,
&
rawResp
)
// 标记成功的 URL,下次优先使用
DefaultURLAvailability
.
MarkSuccess
(
baseURL
)
return
&
modelsResp
,
rawResp
,
nil
return
&
modelsResp
,
rawResp
,
nil
}
}
...
...
backend/internal/pkg/antigravity/gemini_types.go
View file @
2fe8932c
...
@@ -143,9 +143,10 @@ type GeminiResponse struct {
...
@@ -143,9 +143,10 @@ type GeminiResponse struct {
// GeminiCandidate Gemini 候选响应
// GeminiCandidate Gemini 候选响应
type
GeminiCandidate
struct
{
type
GeminiCandidate
struct
{
Content
*
GeminiContent
`json:"content,omitempty"`
Content
*
GeminiContent
`json:"content,omitempty"`
FinishReason
string
`json:"finishReason,omitempty"`
FinishReason
string
`json:"finishReason,omitempty"`
Index
int
`json:"index,omitempty"`
Index
int
`json:"index,omitempty"`
GroundingMetadata
*
GeminiGroundingMetadata
`json:"groundingMetadata,omitempty"`
}
}
// GeminiUsageMetadata Gemini 用量元数据
// GeminiUsageMetadata Gemini 用量元数据
...
@@ -156,6 +157,23 @@ type GeminiUsageMetadata struct {
...
@@ -156,6 +157,23 @@ type GeminiUsageMetadata struct {
TotalTokenCount
int
`json:"totalTokenCount,omitempty"`
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 默认安全设置(关闭所有过滤)
// DefaultSafetySettings 默认安全设置(关闭所有过滤)
var
DefaultSafetySettings
=
[]
GeminiSafetySetting
{
var
DefaultSafetySettings
=
[]
GeminiSafetySetting
{
{
Category
:
"HARM_CATEGORY_HARASSMENT"
,
Threshold
:
"OFF"
},
{
Category
:
"HARM_CATEGORY_HARASSMENT"
,
Threshold
:
"OFF"
},
...
...
backend/internal/pkg/antigravity/oauth.go
View file @
2fe8932c
...
@@ -32,8 +32,8 @@ const (
...
@@ -32,8 +32,8 @@ const (
"https://www.googleapis.com/auth/cclog "
+
"https://www.googleapis.com/auth/cclog "
+
"https://www.googleapis.com/auth/experimentsandconfigs"
"https://www.googleapis.com/auth/experimentsandconfigs"
// User-Agent(
模拟官方客户端
)
// User-Agent(
与 Antigravity-Manager 保持一致
)
UserAgent
=
"antigravity/1.1
04.0 darwin/arm
64"
UserAgent
=
"antigravity/1.1
5.8 windows/amd
64"
// Session 过期时间
// Session 过期时间
SessionTTL
=
30
*
time
.
Minute
SessionTTL
=
30
*
time
.
Minute
...
@@ -42,22 +42,21 @@ const (
...
@@ -42,22 +42,21 @@ const (
URLAvailabilityTTL
=
5
*
time
.
Minute
URLAvailabilityTTL
=
5
*
time
.
Minute
)
)
// BaseURLs 定义 Antigravity API 端点,按优先级排序
// BaseURLs 定义 Antigravity API 端点(与 Antigravity-Manager 保持一致)
// fallback 顺序: sandbox → daily → prod
var
BaseURLs
=
[]
string
{
var
BaseURLs
=
[]
string
{
"https://daily-cloudcode-pa.sandbox.googleapis.com"
,
// sandbox
"https://cloudcode-pa.googleapis.com"
,
// prod (优先)
"https://daily-cloudcode-pa.googleapis.com"
,
// daily
"https://daily-cloudcode-pa.sandbox.googleapis.com"
,
// daily sandbox (备用)
"https://cloudcode-pa.googleapis.com"
,
// prod
}
}
// BaseURL 默认 URL(保持向后兼容)
// BaseURL 默认 URL(保持向后兼容)
var
BaseURL
=
BaseURLs
[
0
]
var
BaseURL
=
BaseURLs
[
0
]
// URLAvailability 管理 URL 可用性状态(带 TTL 自动恢复)
// URLAvailability 管理 URL 可用性状态(带 TTL 自动恢复
和动态优先级
)
type
URLAvailability
struct
{
type
URLAvailability
struct
{
mu
sync
.
RWMutex
mu
sync
.
RWMutex
unavailable
map
[
string
]
time
.
Time
// URL -> 恢复时间
unavailable
map
[
string
]
time
.
Time
// URL -> 恢复时间
ttl
time
.
Duration
ttl
time
.
Duration
lastSuccess
string
// 最近成功请求的 URL,优先使用
}
}
// DefaultURLAvailability 全局 URL 可用性管理器
// DefaultURLAvailability 全局 URL 可用性管理器
...
@@ -78,6 +77,15 @@ func (u *URLAvailability) MarkUnavailable(url string) {
...
@@ -78,6 +77,15 @@ func (u *URLAvailability) MarkUnavailable(url string) {
u
.
unavailable
[
url
]
=
time
.
Now
()
.
Add
(
u
.
ttl
)
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 是否可用
// IsAvailable 检查 URL 是否可用
func
(
u
*
URLAvailability
)
IsAvailable
(
url
string
)
bool
{
func
(
u
*
URLAvailability
)
IsAvailable
(
url
string
)
bool
{
u
.
mu
.
RLock
()
u
.
mu
.
RLock
()
...
@@ -89,14 +97,29 @@ func (u *URLAvailability) IsAvailable(url string) bool {
...
@@ -89,14 +97,29 @@ func (u *URLAvailability) IsAvailable(url string) bool {
return
time
.
Now
()
.
After
(
expiry
)
return
time
.
Now
()
.
After
(
expiry
)
}
}
// GetAvailableURLs 返回可用的 URL 列表(保持优先级顺序)
// GetAvailableURLs 返回可用的 URL 列表
// 最近成功的 URL 优先,其他按默认顺序
func
(
u
*
URLAvailability
)
GetAvailableURLs
()
[]
string
{
func
(
u
*
URLAvailability
)
GetAvailableURLs
()
[]
string
{
u
.
mu
.
RLock
()
u
.
mu
.
RLock
()
defer
u
.
mu
.
RUnlock
()
defer
u
.
mu
.
RUnlock
()
now
:=
time
.
Now
()
now
:=
time
.
Now
()
result
:=
make
([]
string
,
0
,
len
(
BaseURLs
))
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
{
for
_
,
url
:=
range
BaseURLs
{
// 跳过已添加的 lastSuccess
if
url
==
u
.
lastSuccess
{
continue
}
expiry
,
exists
:=
u
.
unavailable
[
url
]
expiry
,
exists
:=
u
.
unavailable
[
url
]
if
!
exists
||
now
.
After
(
expiry
)
{
if
!
exists
||
now
.
After
(
expiry
)
{
result
=
append
(
result
,
url
)
result
=
append
(
result
,
url
)
...
@@ -240,24 +263,3 @@ func BuildAuthorizationURL(state, codeChallenge string) string {
...
@@ -240,24 +263,3 @@ func BuildAuthorizationURL(state, codeChallenge string) string {
return
fmt
.
Sprintf
(
"%s?%s"
,
AuthorizeURL
,
params
.
Encode
())
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 (
...
@@ -7,13 +7,11 @@ import (
"fmt"
"fmt"
"log"
"log"
"math/rand"
"math/rand"
"os"
"strconv"
"strconv"
"strings"
"strings"
"sync"
"sync"
"time"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/google/uuid"
)
)
...
@@ -54,6 +52,9 @@ func DefaultTransformOptions() TransformOptions {
...
@@ -54,6 +52,9 @@ func DefaultTransformOptions() TransformOptions {
}
}
}
}
// webSearchFallbackModel web_search 请求使用的降级模型
const
webSearchFallbackModel
=
"gemini-2.5-flash"
// TransformClaudeToGemini 将 Claude 请求转换为 v1internal Gemini 格式
// TransformClaudeToGemini 将 Claude 请求转换为 v1internal Gemini 格式
func
TransformClaudeToGemini
(
claudeReq
*
ClaudeRequest
,
projectID
,
mappedModel
string
)
([]
byte
,
error
)
{
func
TransformClaudeToGemini
(
claudeReq
*
ClaudeRequest
,
projectID
,
mappedModel
string
)
([]
byte
,
error
)
{
return
TransformClaudeToGeminiWithOptions
(
claudeReq
,
projectID
,
mappedModel
,
DefaultTransformOptions
())
return
TransformClaudeToGeminiWithOptions
(
claudeReq
,
projectID
,
mappedModel
,
DefaultTransformOptions
())
...
@@ -64,12 +65,23 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
...
@@ -64,12 +65,23 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
// 用于存储 tool_use id -> name 映射
// 用于存储 tool_use id -> name 映射
toolIDToName
:=
make
(
map
[
string
]
string
)
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
// 检测是否启用 thinking
isThinkingEnabled
:=
claudeReq
.
Thinking
!=
nil
&&
claudeReq
.
Thinking
.
Type
==
"enabled"
isThinkingEnabled
:=
claudeReq
.
Thinking
!=
nil
&&
claudeReq
.
Thinking
.
Type
==
"enabled"
// 只有 Gemini 模型支持 dummy thought workaround
// 只有 Gemini 模型支持 dummy thought workaround
// Claude 模型通过 Vertex/Google API 需要有效的 thought signatures
// Claude 模型通过 Vertex/Google API 需要有效的 thought signatures
allowDummyThought
:=
strings
.
HasPrefix
(
mapped
Model
,
"gemini-"
)
allowDummyThought
:=
strings
.
HasPrefix
(
target
Model
,
"gemini-"
)
// 1. 构建 contents
// 1. 构建 contents
contents
,
strippedThinking
,
err
:=
buildContents
(
claudeReq
.
Messages
,
toolIDToName
,
isThinkingEnabled
,
allowDummyThought
)
contents
,
strippedThinking
,
err
:=
buildContents
(
claudeReq
.
Messages
,
toolIDToName
,
isThinkingEnabled
,
allowDummyThought
)
...
@@ -78,7 +90,7 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
...
@@ -78,7 +90,7 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
}
}
// 2. 构建 systemInstruction
// 2. 构建 systemInstruction
systemInstruction
:=
buildSystemInstruction
(
claudeReq
.
System
,
claudeReq
.
Model
,
opts
)
systemInstruction
:=
buildSystemInstruction
(
claudeReq
.
System
,
claudeReq
.
Model
,
opts
,
claudeReq
.
Tools
)
// 3. 构建 generationConfig
// 3. 构建 generationConfig
reqForConfig
:=
claudeReq
reqForConfig
:=
claudeReq
...
@@ -89,6 +101,11 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
...
@@ -89,6 +101,11 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
reqCopy
.
Thinking
=
nil
reqCopy
.
Thinking
=
nil
reqForConfig
=
&
reqCopy
reqForConfig
=
&
reqCopy
}
}
if
targetModel
!=
""
&&
targetModel
!=
reqForConfig
.
Model
{
reqCopy
:=
*
reqForConfig
reqCopy
.
Model
=
targetModel
reqForConfig
=
&
reqCopy
}
generationConfig
:=
buildGenerationConfig
(
reqForConfig
)
generationConfig
:=
buildGenerationConfig
(
reqForConfig
)
// 4. 构建 tools
// 4. 构建 tools
...
@@ -127,8 +144,8 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
...
@@ -127,8 +144,8 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
Project
:
projectID
,
Project
:
projectID
,
RequestID
:
"agent-"
+
uuid
.
New
()
.
String
(),
RequestID
:
"agent-"
+
uuid
.
New
()
.
String
(),
UserAgent
:
"antigravity"
,
// 固定值,与官方客户端一致
UserAgent
:
"antigravity"
,
// 固定值,与官方客户端一致
RequestType
:
"agent"
,
RequestType
:
requestType
,
Model
:
mapped
Model
,
Model
:
target
Model
,
Request
:
innerRequest
,
Request
:
innerRequest
,
}
}
...
@@ -154,8 +171,40 @@ func GetDefaultIdentityPatch() string {
...
@@ -154,8 +171,40 @@ func GetDefaultIdentityPatch() string {
return
antigravityIdentity
return
antigravityIdentity
}
}
// buildSystemInstruction 构建 systemInstruction
// mcpXMLProtocol MCP XML 工具调用协议(与 Antigravity-Manager 保持一致)
func
buildSystemInstruction
(
system
json
.
RawMessage
,
modelName
string
,
opts
TransformOptions
)
*
GeminiContent
{
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
var
parts
[]
GeminiPart
// 先解析用户的 system prompt,检测是否已包含 Antigravity identity
// 先解析用户的 system prompt,检测是否已包含 Antigravity identity
...
@@ -167,10 +216,14 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans
...
@@ -167,10 +216,14 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans
var
sysStr
string
var
sysStr
string
if
err
:=
json
.
Unmarshal
(
system
,
&
sysStr
);
err
==
nil
{
if
err
:=
json
.
Unmarshal
(
system
,
&
sysStr
);
err
==
nil
{
if
strings
.
TrimSpace
(
sysStr
)
!=
""
{
if
strings
.
TrimSpace
(
sysStr
)
!=
""
{
userSystemParts
=
append
(
userSystemParts
,
GeminiPart
{
Text
:
sysStr
})
if
strings
.
Contains
(
sysStr
,
"You are Antigravity"
)
{
if
strings
.
Contains
(
sysStr
,
"You are Antigravity"
)
{
userHasAntigravityIdentity
=
true
userHasAntigravityIdentity
=
true
}
}
// 过滤 OpenCode 默认提示词
filtered
:=
filterOpenCodePrompt
(
sysStr
)
if
filtered
!=
""
{
userSystemParts
=
append
(
userSystemParts
,
GeminiPart
{
Text
:
filtered
})
}
}
}
}
else
{
}
else
{
// 尝试解析为数组
// 尝试解析为数组
...
@@ -178,10 +231,14 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans
...
@@ -178,10 +231,14 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans
if
err
:=
json
.
Unmarshal
(
system
,
&
sysBlocks
);
err
==
nil
{
if
err
:=
json
.
Unmarshal
(
system
,
&
sysBlocks
);
err
==
nil
{
for
_
,
block
:=
range
sysBlocks
{
for
_
,
block
:=
range
sysBlocks
{
if
block
.
Type
==
"text"
&&
strings
.
TrimSpace
(
block
.
Text
)
!=
""
{
if
block
.
Type
==
"text"
&&
strings
.
TrimSpace
(
block
.
Text
)
!=
""
{
userSystemParts
=
append
(
userSystemParts
,
GeminiPart
{
Text
:
block
.
Text
})
if
strings
.
Contains
(
block
.
Text
,
"You are Antigravity"
)
{
if
strings
.
Contains
(
block
.
Text
,
"You are Antigravity"
)
{
userHasAntigravityIdentity
=
true
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
...
@@ -200,6 +257,16 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans
// 添加用户的 system prompt
// 添加用户的 system prompt
parts
=
append
(
parts
,
userSystemParts
...
)
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
{
if
len
(
parts
)
==
0
{
return
nil
return
nil
}
}
...
@@ -300,8 +367,10 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu
...
@@ -300,8 +367,10 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu
Text
:
block
.
Thinking
,
Text
:
block
.
Thinking
,
Thought
:
true
,
Thought
:
true
,
}
}
// 保留原有 signature(Claude 模型需要有效的 signature)
// signature 处理:
if
block
.
Signature
!=
""
{
// - Claude 模型(allowDummyThought=false):必须是上游返回的真实 signature(dummy 视为缺失)
// - Gemini 模型(allowDummyThought=true):优先透传真实 signature,缺失时使用 dummy signature
if
block
.
Signature
!=
""
&&
(
allowDummyThought
||
block
.
Signature
!=
dummyThoughtSignature
)
{
part
.
ThoughtSignature
=
block
.
Signature
part
.
ThoughtSignature
=
block
.
Signature
}
else
if
!
allowDummyThought
{
}
else
if
!
allowDummyThought
{
// Claude 模型需要有效 signature;在缺失时降级为普通文本,并在上层禁用 thinking mode。
// Claude 模型需要有效 signature;在缺失时降级为普通文本,并在上层禁用 thinking mode。
...
@@ -340,12 +409,12 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu
...
@@ -340,12 +409,12 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu
},
},
}
}
// tool_use 的 signature 处理:
// tool_use 的 signature 处理:
// - Gemini 模型:使用 dummy signature(跳过 thought_signature 校验)
// - Claude 模型(allowDummyThought=false):必须是上游返回的真实 signature(dummy 视为缺失)
// - Claude 模型:透传上游返回的真实 signature(Vertex/Google 需要完整签名链路)
// - Gemini 模型(allowDummyThought=true):优先透传真实 signature,缺失时使用 dummy signature
if
allowDummyThought
{
if
block
.
Signature
!=
""
&&
(
allowDummyThought
||
block
.
Signature
!=
dummyThoughtSignature
)
{
part
.
ThoughtSignature
=
dummyThoughtSignature
}
else
if
block
.
Signature
!=
""
&&
block
.
Signature
!=
dummyThoughtSignature
{
part
.
ThoughtSignature
=
block
.
Signature
part
.
ThoughtSignature
=
block
.
Signature
}
else
if
allowDummyThought
{
part
.
ThoughtSignature
=
dummyThoughtSignature
}
}
parts
=
append
(
parts
,
part
)
parts
=
append
(
parts
,
part
)
...
@@ -429,6 +498,11 @@ func buildGenerationConfig(req *ClaudeRequest) *GeminiGenerationConfig {
...
@@ -429,6 +498,11 @@ func buildGenerationConfig(req *ClaudeRequest) *GeminiGenerationConfig {
StopSequences
:
DefaultStopSequences
,
StopSequences
:
DefaultStopSequences
,
}
}
// 如果请求中指定了 MaxTokens,使用请求值
if
req
.
MaxTokens
>
0
{
config
.
MaxOutputTokens
=
req
.
MaxTokens
}
// Thinking 配置
// Thinking 配置
if
req
.
Thinking
!=
nil
&&
req
.
Thinking
.
Type
==
"enabled"
{
if
req
.
Thinking
!=
nil
&&
req
.
Thinking
.
Type
==
"enabled"
{
config
.
ThinkingConfig
=
&
GeminiThinkingConfig
{
config
.
ThinkingConfig
=
&
GeminiThinkingConfig
{
...
@@ -458,37 +532,43 @@ func buildGenerationConfig(req *ClaudeRequest) *GeminiGenerationConfig {
...
@@ -458,37 +532,43 @@ func buildGenerationConfig(req *ClaudeRequest) *GeminiGenerationConfig {
return
config
return
config
}
}
func
hasWebSearchTool
(
tools
[]
ClaudeTool
)
bool
{
for
_
,
tool
:=
range
tools
{
if
isWebSearchTool
(
tool
)
{
return
true
}
}
return
false
}
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
}
}
// buildTools 构建 tools
// buildTools 构建 tools
func
buildTools
(
tools
[]
ClaudeTool
)
[]
GeminiToolDeclaration
{
func
buildTools
(
tools
[]
ClaudeTool
)
[]
GeminiToolDeclaration
{
if
len
(
tools
)
==
0
{
if
len
(
tools
)
==
0
{
return
nil
return
nil
}
}
// 检查是否有 web_search 工具
hasWebSearch
:=
hasWebSearchTool
(
tools
)
hasWebSearch
:=
false
for
_
,
tool
:=
range
tools
{
if
tool
.
Name
==
"web_search"
{
hasWebSearch
=
true
break
}
}
if
hasWebSearch
{
// Web Search 工具映射
return
[]
GeminiToolDeclaration
{{
GoogleSearch
:
&
GeminiGoogleSearch
{
EnhancedContent
:
&
GeminiEnhancedContent
{
ImageSearch
:
&
GeminiImageSearch
{
MaxResultCount
:
5
,
},
},
},
}}
}
// 普通工具
// 普通工具
var
funcDecls
[]
GeminiFunctionDecl
var
funcDecls
[]
GeminiFunctionDecl
for
_
,
tool
:=
range
tools
{
for
_
,
tool
:=
range
tools
{
if
isWebSearchTool
(
tool
)
{
continue
}
// 跳过无效工具名称
// 跳过无效工具名称
if
strings
.
TrimSpace
(
tool
.
Name
)
==
""
{
if
strings
.
TrimSpace
(
tool
.
Name
)
==
""
{
log
.
Printf
(
"Warning: skipping tool with empty name"
)
log
.
Printf
(
"Warning: skipping tool with empty name"
)
...
@@ -514,11 +594,14 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
...
@@ -514,11 +594,14 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
}
}
// 清理 JSON Schema
// 清理 JSON Schema
params
:=
cleanJSONSchema
(
inputSchema
)
// 1. 深度清理 [undefined] 值
DeepCleanUndefined
(
inputSchema
)
// 2. 转换为符合 Gemini v1internal 的 schema
params
:=
CleanJSONSchema
(
inputSchema
)
// 为 nil schema 提供默认值
// 为 nil schema 提供默认值
if
params
==
nil
{
if
params
==
nil
{
params
=
map
[
string
]
any
{
params
=
map
[
string
]
any
{
"type"
:
"
OBJECT"
,
"type"
:
"
object"
,
// lowercase type
"properties"
:
map
[
string
]
any
{},
"properties"
:
map
[
string
]
any
{},
}
}
}
}
...
@@ -531,243 +614,23 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
...
@@ -531,243 +614,23 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
}
}
if
len
(
funcDecls
)
==
0
{
if
len
(
funcDecls
)
==
0
{
return
nil
if
!
hasWebSearch
{
return
nil
}
// Web Search 工具映射
return
[]
GeminiToolDeclaration
{{
GoogleSearch
:
&
GeminiGoogleSearch
{
EnhancedContent
:
&
GeminiEnhancedContent
{
ImageSearch
:
&
GeminiImageSearch
{
MaxResultCount
:
5
,
},
},
},
}}
}
}
return
[]
GeminiToolDeclaration
{{
return
[]
GeminiToolDeclaration
{{
FunctionDeclarations
:
funcDecls
,
FunctionDeclarations
:
funcDecls
,
}}
}}
}
}
// 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
}
}
backend/internal/pkg/antigravity/request_transformer_test.go
View file @
2fe8932c
...
@@ -100,7 +100,7 @@ func TestBuildParts_ToolUseSignatureHandling(t *testing.T) {
...
@@ -100,7 +100,7 @@ func TestBuildParts_ToolUseSignatureHandling(t *testing.T) {
{"type": "tool_use", "id": "t1", "name": "Bash", "input": {"command": "ls"}, "signature": "sig_tool_abc"}
{"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
)
toolIDToName
:=
make
(
map
[
string
]
string
)
parts
,
_
,
err
:=
buildParts
(
json
.
RawMessage
(
content
),
toolIDToName
,
true
)
parts
,
_
,
err
:=
buildParts
(
json
.
RawMessage
(
content
),
toolIDToName
,
true
)
if
err
!=
nil
{
if
err
!=
nil
{
...
@@ -109,6 +109,23 @@ func TestBuildParts_ToolUseSignatureHandling(t *testing.T) {
...
@@ -109,6 +109,23 @@ func TestBuildParts_ToolUseSignatureHandling(t *testing.T) {
if
len
(
parts
)
!=
1
||
parts
[
0
]
.
FunctionCall
==
nil
{
if
len
(
parts
)
!=
1
||
parts
[
0
]
.
FunctionCall
==
nil
{
t
.
Fatalf
(
"expected 1 functionCall part, got %+v"
,
parts
)
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
{
if
parts
[
0
]
.
ThoughtSignature
!=
dummyThoughtSignature
{
t
.
Fatalf
(
"expected dummy tool signature %q, got %q"
,
dummyThoughtSignature
,
parts
[
0
]
.
ThoughtSignature
)
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
...
@@ -3,6 +3,8 @@ package antigravity
import
(
import
(
"encoding/json"
"encoding/json"
"fmt"
"fmt"
"log"
"strings"
)
)
// TransformGeminiToClaude 将 Gemini 响应转换为 Claude 格式(非流式)
// TransformGeminiToClaude 将 Gemini 响应转换为 Claude 格式(非流式)
...
@@ -18,6 +20,15 @@ func TransformGeminiToClaude(geminiResp []byte, originalModel string) ([]byte, *
...
@@ -18,6 +20,15 @@ func TransformGeminiToClaude(geminiResp []byte, originalModel string) ([]byte, *
v1Resp
.
Response
=
directResp
v1Resp
.
Response
=
directResp
v1Resp
.
ResponseID
=
directResp
.
ResponseID
v1Resp
.
ResponseID
=
directResp
.
ResponseID
v1Resp
.
ModelVersion
=
directResp
.
ModelVersion
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,
...
@@ -63,6 +74,12 @@ func (p *NonStreamingProcessor) Process(geminiResp *GeminiResponse, responseID,
p
.
processPart
(
&
part
)
p
.
processPart
(
&
part
)
}
}
if
len
(
geminiResp
.
Candidates
)
>
0
{
if
grounding
:=
geminiResp
.
Candidates
[
0
]
.
GroundingMetadata
;
grounding
!=
nil
{
p
.
processGrounding
(
grounding
)
}
}
// 刷新剩余内容
// 刷新剩余内容
p
.
flushThinking
()
p
.
flushThinking
()
p
.
flushText
()
p
.
flushText
()
...
@@ -166,16 +183,20 @@ func (p *NonStreamingProcessor) processPart(part *GeminiPart) {
...
@@ -166,16 +183,20 @@ func (p *NonStreamingProcessor) processPart(part *GeminiPart) {
p
.
trailingSignature
=
""
p
.
trailingSignature
=
""
}
}
p
.
textBuilder
+=
part
.
Text
// 非空 text 带签名 - 特殊处理:先输出 text,再输出空 thinking 块
// 非空 text 带签名 - 立即刷新并输出空 thinking 块
if
signature
!=
""
{
if
signature
!=
""
{
p
.
flushText
()
p
.
contentBlocks
=
append
(
p
.
contentBlocks
,
ClaudeContentItem
{
Type
:
"text"
,
Text
:
part
.
Text
,
})
p
.
contentBlocks
=
append
(
p
.
contentBlocks
,
ClaudeContentItem
{
p
.
contentBlocks
=
append
(
p
.
contentBlocks
,
ClaudeContentItem
{
Type
:
"thinking"
,
Type
:
"thinking"
,
Thinking
:
""
,
Thinking
:
""
,
Signature
:
signature
,
Signature
:
signature
,
})
})
}
else
{
// 普通 text (无签名) - 累积到 builder
p
.
textBuilder
+=
part
.
Text
}
}
}
}
}
}
...
@@ -190,6 +211,18 @@ func (p *NonStreamingProcessor) processPart(part *GeminiPart) {
...
@@ -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
// flushText 刷新 text builder
func
(
p
*
NonStreamingProcessor
)
flushText
()
{
func
(
p
*
NonStreamingProcessor
)
flushText
()
{
if
p
.
textBuilder
==
""
{
if
p
.
textBuilder
==
""
{
...
@@ -223,6 +256,14 @@ func (p *NonStreamingProcessor) buildResponse(geminiResp *GeminiResponse, respon
...
@@ -223,6 +256,14 @@ func (p *NonStreamingProcessor) buildResponse(geminiResp *GeminiResponse, respon
var
finishReason
string
var
finishReason
string
if
len
(
geminiResp
.
Candidates
)
>
0
{
if
len
(
geminiResp
.
Candidates
)
>
0
{
finishReason
=
geminiResp
.
Candidates
[
0
]
.
FinishReason
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"
stopReason
:=
"end_turn"
...
@@ -262,6 +303,44 @@ func (p *NonStreamingProcessor) buildResponse(geminiResp *GeminiResponse, respon
...
@@ -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
// generateRandomID 生成随机 ID
func
generateRandomID
()
string
{
func
generateRandomID
()
string
{
const
chars
=
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
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 (
...
@@ -4,6 +4,7 @@ import (
"bytes"
"bytes"
"encoding/json"
"encoding/json"
"fmt"
"fmt"
"log"
"strings"
"strings"
)
)
...
@@ -27,6 +28,8 @@ type StreamingProcessor struct {
...
@@ -27,6 +28,8 @@ type StreamingProcessor struct {
pendingSignature
string
pendingSignature
string
trailingSignature
string
trailingSignature
string
originalModel
string
originalModel
string
webSearchQueries
[]
string
groundingChunks
[]
GeminiGroundingChunk
// 累计 usage
// 累计 usage
inputTokens
int
inputTokens
int
...
@@ -93,9 +96,21 @@ func (p *StreamingProcessor) ProcessLine(line string) []byte {
...
@@ -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
{
if
len
(
geminiResp
.
Candidates
)
>
0
{
finishReason
:=
geminiResp
.
Candidates
[
0
]
.
FinishReason
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
!=
""
{
if
finishReason
!=
""
{
_
,
_
=
result
.
Write
(
p
.
emitFinish
(
finishReason
))
_
,
_
=
result
.
Write
(
p
.
emitFinish
(
finishReason
))
}
}
...
@@ -200,6 +215,20 @@ func (p *StreamingProcessor) processPart(part *GeminiPart) []byte {
...
@@ -200,6 +215,20 @@ func (p *StreamingProcessor) processPart(part *GeminiPart) []byte {
return
result
.
Bytes
()
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
// processThinking 处理 thinking
func
(
p
*
StreamingProcessor
)
processThinking
(
text
,
signature
string
)
[]
byte
{
func
(
p
*
StreamingProcessor
)
processThinking
(
text
,
signature
string
)
[]
byte
{
var
result
bytes
.
Buffer
var
result
bytes
.
Buffer
...
@@ -417,6 +446,23 @@ func (p *StreamingProcessor) emitFinish(finishReason string) []byte {
...
@@ -417,6 +446,23 @@ func (p *StreamingProcessor) emitFinish(finishReason string) []byte {
p
.
trailingSignature
=
""
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
// 确定 stop_reason
stopReason
:=
"end_turn"
stopReason
:=
"end_turn"
if
p
.
usedTool
{
if
p
.
usedTool
{
...
...
backend/internal/pkg/claude/constants.go
View file @
2fe8932c
...
@@ -16,7 +16,12 @@ const (
...
@@ -16,7 +16,12 @@ const (
const
DefaultBetaHeader
=
BetaClaudeCode
+
","
+
BetaOAuth
+
","
+
BetaInterleavedThinking
+
","
+
BetaFineGrainedToolStreaming
const
DefaultBetaHeader
=
BetaClaudeCode
+
","
+
BetaOAuth
+
","
+
BetaInterleavedThinking
+
","
+
BetaFineGrainedToolStreaming
// MessageBetaHeaderNoTools /v1/messages 在无工具时的 beta header
// 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
// MessageBetaHeaderWithTools /v1/messages 在有工具时的 beta header
const
MessageBetaHeaderWithTools
=
BetaClaudeCode
+
","
+
BetaOAuth
+
","
+
BetaInterleavedThinking
const
MessageBetaHeaderWithTools
=
BetaClaudeCode
+
","
+
BetaOAuth
+
","
+
BetaInterleavedThinking
...
@@ -35,13 +40,15 @@ const APIKeyHaikuBetaHeader = BetaInterleavedThinking
...
@@ -35,13 +40,15 @@ const APIKeyHaikuBetaHeader = BetaInterleavedThinking
// DefaultHeaders 是 Claude Code 客户端默认请求头。
// DefaultHeaders 是 Claude Code 客户端默认请求头。
var
DefaultHeaders
=
map
[
string
]
string
{
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-Lang"
:
"js"
,
"X-Stainless-Package-Version"
:
"0.70.0"
,
"X-Stainless-Package-Version"
:
"0.70.0"
,
"X-Stainless-OS"
:
"Linux"
,
"X-Stainless-OS"
:
"Linux"
,
"X-Stainless-Arch"
:
"
x
64"
,
"X-Stainless-Arch"
:
"
arm
64"
,
"X-Stainless-Runtime"
:
"node"
,
"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-Retry-Count"
:
"0"
,
"X-Stainless-Timeout"
:
"600"
,
"X-Stainless-Timeout"
:
"600"
,
"X-App"
:
"cli"
,
"X-App"
:
"cli"
,
...
...
backend/internal/pkg/gemini/models.go
View file @
2fe8932c
...
@@ -16,14 +16,11 @@ type ModelsListResponse struct {
...
@@ -16,14 +16,11 @@ type ModelsListResponse struct {
func
DefaultModels
()
[]
Model
{
func
DefaultModels
()
[]
Model
{
methods
:=
[]
string
{
"generateContent"
,
"streamGenerateContent"
}
methods
:=
[]
string
{
"generateContent"
,
"streamGenerateContent"
}
return
[]
Model
{
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-2.0-flash"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-1.5-pro"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-2.5-flash"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-1.5-flash"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-2.5-pro"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-1.5-flash-8b"
,
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 {
...
@@ -12,10 +12,10 @@ type Model struct {
// DefaultModels is the curated Gemini model list used by the admin UI "test account" flow.
// DefaultModels is the curated Gemini model list used by the admin UI "test account" flow.
var
DefaultModels
=
[]
Model
{
var
DefaultModels
=
[]
Model
{
{
ID
:
"gemini-2.0-flash"
,
Type
:
"model"
,
DisplayName
:
"Gemini 2.0 Flash"
,
CreatedAt
:
""
},
{
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-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-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.
// DefaultTestModel is the default model to preselect in test flows.
...
...
backend/internal/pkg/oauth/oauth.go
View file @
2fe8932c
...
@@ -13,20 +13,26 @@ import (
...
@@ -13,20 +13,26 @@ import (
"time"
"time"
)
)
// Claude OAuth Constants
(from CRS project)
// Claude OAuth Constants
const
(
const
(
// OAuth Client ID for Claude
// OAuth Client ID for Claude
ClientID
=
"9d1c250a-e61b-44d9-88ed-5944d1962f5e"
ClientID
=
"9d1c250a-e61b-44d9-88ed-5944d1962f5e"
// OAuth endpoints
// OAuth endpoints
AuthorizeURL
=
"https://claude.ai/oauth/authorize"
AuthorizeURL
=
"https://claude.ai/oauth/authorize"
TokenURL
=
"https://console.anthropic.com/v1/oauth/token"
TokenURL
=
"https://platform.claude.com/v1/oauth/token"
RedirectURI
=
"https://console.anthropic.com/oauth/code/callback"
RedirectURI
=
"https://platform.claude.com/oauth/code/callback"
// Scopes
// Scopes - Browser URL (includes org:create_api_key for user authorization)
ScopeProfile
=
"user:profile"
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"
ScopeInference
=
"user:inference"
// Code Verifier character set (RFC 7636 compliant)
codeVerifierCharset
=
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
// Session TTL
// Session TTL
SessionTTL
=
30
*
time
.
Minute
SessionTTL
=
30
*
time
.
Minute
)
)
...
@@ -53,7 +59,6 @@ func NewSessionStore() *SessionStore {
...
@@ -53,7 +59,6 @@ func NewSessionStore() *SessionStore {
sessions
:
make
(
map
[
string
]
*
OAuthSession
),
sessions
:
make
(
map
[
string
]
*
OAuthSession
),
stopCh
:
make
(
chan
struct
{}),
stopCh
:
make
(
chan
struct
{}),
}
}
// Start cleanup goroutine
go
store
.
cleanup
()
go
store
.
cleanup
()
return
store
return
store
}
}
...
@@ -78,7 +83,6 @@ func (s *SessionStore) Get(sessionID string) (*OAuthSession, bool) {
...
@@ -78,7 +83,6 @@ func (s *SessionStore) Get(sessionID string) (*OAuthSession, bool) {
if
!
ok
{
if
!
ok
{
return
nil
,
false
return
nil
,
false
}
}
// Check if expired
if
time
.
Since
(
session
.
CreatedAt
)
>
SessionTTL
{
if
time
.
Since
(
session
.
CreatedAt
)
>
SessionTTL
{
return
nil
,
false
return
nil
,
false
}
}
...
@@ -122,13 +126,13 @@ func GenerateRandomBytes(n int) ([]byte, error) {
...
@@ -122,13 +126,13 @@ func GenerateRandomBytes(n int) ([]byte, error) {
return
b
,
nil
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
)
{
func
GenerateState
()
(
string
,
error
)
{
bytes
,
err
:=
GenerateRandomBytes
(
32
)
bytes
,
err
:=
GenerateRandomBytes
(
32
)
if
err
!=
nil
{
if
err
!=
nil
{
return
""
,
err
return
""
,
err
}
}
return
hex
.
EncodeToString
(
bytes
),
nil
return
base64URLEncode
(
bytes
),
nil
}
}
// GenerateSessionID generates a unique session ID
// GenerateSessionID generates a unique session ID
...
@@ -140,13 +144,30 @@ func GenerateSessionID() (string, error) {
...
@@ -140,13 +144,30 @@ func GenerateSessionID() (string, error) {
return
hex
.
EncodeToString
(
bytes
),
nil
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
)
{
func
GenerateCodeVerifier
()
(
string
,
error
)
{
bytes
,
err
:=
GenerateRandomBytes
(
32
)
const
targetLen
=
32
if
err
!=
nil
{
charsetLen
:=
len
(
codeVerifierCharset
)
return
""
,
err
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
}
for
_
,
b
:=
range
randBuf
{
if
int
(
b
)
<
limit
{
result
=
append
(
result
,
codeVerifierCharset
[
int
(
b
)
%
charsetLen
])
if
len
(
result
)
>=
targetLen
{
break
}
}
}
}
}
return
base64URLEncode
(
bytes
),
nil
return
base64URLEncode
(
result
),
nil
}
}
// GenerateCodeChallenge generates a PKCE code challenge using S256 method
// GenerateCodeChallenge generates a PKCE code challenge using S256 method
...
@@ -158,42 +179,31 @@ func GenerateCodeChallenge(verifier string) string {
...
@@ -158,42 +179,31 @@ func GenerateCodeChallenge(verifier string) string {
// base64URLEncode encodes bytes to base64url without padding
// base64URLEncode encodes bytes to base64url without padding
func
base64URLEncode
(
data
[]
byte
)
string
{
func
base64URLEncode
(
data
[]
byte
)
string
{
encoded
:=
base64
.
URLEncoding
.
EncodeToString
(
data
)
encoded
:=
base64
.
URLEncoding
.
EncodeToString
(
data
)
// Remove padding
return
strings
.
TrimRight
(
encoded
,
"="
)
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
{
func
BuildAuthorizationURL
(
state
,
codeChallenge
,
scope
string
)
string
{
params
:=
url
.
Values
{}
encodedRedirectURI
:=
url
.
QueryEscape
(
RedirectURI
)
params
.
Set
(
"response_type"
,
"code"
)
encodedScope
:=
strings
.
ReplaceAll
(
url
.
QueryEscape
(
scope
),
"%20"
,
"+"
)
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
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"
,
type
TokenRequest
struct
{
AuthorizeURL
,
GrantType
string
`json:"grant_type"`
ClientID
,
ClientID
string
`json:"client_id"`
encodedRedirectURI
,
Code
string
`json:"code"`
encodedScope
,
RedirectURI
string
`json:"redirect_uri"`
codeChallenge
,
CodeVerifier
string
`json:"code_verifier"`
state
,
State
string
`json:"state"`
)
}
}
// TokenResponse represents the token response from OAuth provider
// TokenResponse represents the token response from OAuth provider
type
TokenResponse
struct
{
type
TokenResponse
struct
{
AccessToken
string
`json:"access_token"`
AccessToken
string
`json:"access_token"`
TokenType
string
`json:"token_type"`
TokenType
string
`json:"token_type"`
ExpiresIn
int64
`json:"expires_in"`
ExpiresIn
int64
`json:"expires_in"`
RefreshToken
string
`json:"refresh_token,omitempty"`
RefreshToken
string
`json:"refresh_token,omitempty"`
Scope
string
`json:"scope,omitempty"`
Scope
string
`json:"scope,omitempty"`
// Organization and Account info from OAuth response
Organization
*
OrgInfo
`json:"organization,omitempty"`
Organization
*
OrgInfo
`json:"organization,omitempty"`
Account
*
AccountInfo
`json:"account,omitempty"`
Account
*
AccountInfo
`json:"account,omitempty"`
}
}
...
@@ -205,33 +215,6 @@ type OrgInfo struct {
...
@@ -205,33 +215,6 @@ type OrgInfo struct {
// AccountInfo represents account info from OAuth response
// AccountInfo represents account info from OAuth response
type
AccountInfo
struct
{
type
AccountInfo
struct
{
UUID
string
`json:"uuid"`
UUID
string
`json:"uuid"`
}
EmailAddress
string
`json:"email_address"`
// 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
,
}
}
}
backend/internal/pkg/response/response.go
View file @
2fe8932c
...
@@ -2,6 +2,7 @@
...
@@ -2,6 +2,7 @@
package
response
package
response
import
(
import
(
"log"
"math"
"math"
"net/http"
"net/http"
...
@@ -74,6 +75,12 @@ func ErrorFrom(c *gin.Context, err error) bool {
...
@@ -74,6 +75,12 @@ func ErrorFrom(c *gin.Context, err error) bool {
}
}
statusCode
,
status
:=
infraerrors
.
ToHTTP
(
err
)
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
)
ErrorWithDetails
(
c
,
statusCode
,
status
.
Message
,
status
.
Reason
,
status
.
Metadata
)
return
true
return
true
}
}
...
@@ -162,11 +169,11 @@ func ParsePagination(c *gin.Context) (page, pageSize int) {
...
@@ -162,11 +169,11 @@ func ParsePagination(c *gin.Context) (page, pageSize int) {
// 支持 page_size 和 limit 两种参数名
// 支持 page_size 和 limit 两种参数名
if
ps
:=
c
.
Query
(
"page_size"
);
ps
!=
""
{
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
pageSize
=
val
}
}
}
else
if
l
:=
c
.
Query
(
"limit"
);
l
!=
""
{
}
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
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