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
6d20ab80
Unverified
Commit
6d20ab80
authored
Apr 25, 2026
by
Wesley Liddick
Committed by
GitHub
Apr 25, 2026
Browse files
Merge pull request #1914 from keh4l/feat/cc-mimicry-parity
fix(claude): align Claude Code OAuth mimicry with real CLI traffic
parents
aa8ee33b
bdbd2916
Changes
13
Hide whitespace changes
Inline
Side-by-side
backend/internal/pkg/apicompat/types.go
View file @
6d20ab80
...
@@ -12,17 +12,23 @@ import "encoding/json"
...
@@ -12,17 +12,23 @@ import "encoding/json"
// AnthropicRequest is the request body for POST /v1/messages.
// AnthropicRequest is the request body for POST /v1/messages.
type
AnthropicRequest
struct
{
type
AnthropicRequest
struct
{
Model
string
`json:"model"`
Model
string
`json:"model"`
MaxTokens
int
`json:"max_tokens"`
MaxTokens
int
`json:"max_tokens"`
System
json
.
RawMessage
`json:"system,omitempty"`
// string or []AnthropicContentBlock
System
json
.
RawMessage
`json:"system,omitempty"`
// string or []AnthropicContentBlock
Messages
[]
AnthropicMessage
`json:"messages"`
Messages
[]
AnthropicMessage
`json:"messages"`
Tools
[]
AnthropicTool
`json:"tools,omitempty"`
Tools
[]
AnthropicTool
`json:"tools,omitempty"`
Stream
bool
`json:"stream,omitempty"`
Stream
bool
`json:"stream,omitempty"`
Temperature
*
float64
`json:"temperature,omitempty"`
Temperature
*
float64
`json:"temperature,omitempty"`
TopP
*
float64
`json:"top_p,omitempty"`
TopP
*
float64
`json:"top_p,omitempty"`
StopSeqs
[]
string
`json:"stop_sequences,omitempty"`
StopSeqs
[]
string
`json:"stop_sequences,omitempty"`
Thinking
*
AnthropicThinking
`json:"thinking,omitempty"`
Thinking
*
AnthropicThinking
`json:"thinking,omitempty"`
ToolChoice
json
.
RawMessage
`json:"tool_choice,omitempty"`
ToolChoice
json
.
RawMessage
`json:"tool_choice,omitempty"`
// Metadata 会被原样透传给上游。OAuth/Claude-Code 路径依赖 metadata.user_id
// 参与上游的"是否为官方 Claude Code 请求"判定;如果经由本结构体重新序列化
// 时丢弃该字段,网关侧后续的 metadata 重写(ensureClaudeOAuthMetadataUserID/
// RewriteUserIDWithMasking) 在 body 里拿不到起点,就无法重建一个合法的
// user_id,进而导致请求被归类为第三方 app。
Metadata
json
.
RawMessage
`json:"metadata,omitempty"`
OutputConfig
*
AnthropicOutputConfig
`json:"output_config,omitempty"`
OutputConfig
*
AnthropicOutputConfig
`json:"output_config,omitempty"`
}
}
...
@@ -76,10 +82,18 @@ type AnthropicImageSource struct {
...
@@ -76,10 +82,18 @@ type AnthropicImageSource struct {
// AnthropicTool describes a tool available to the model.
// AnthropicTool describes a tool available to the model.
type
AnthropicTool
struct
{
type
AnthropicTool
struct
{
Type
string
`json:"type,omitempty"`
// e.g. "web_search_20250305" for server tools
Type
string
`json:"type,omitempty"`
// e.g. "web_search_20250305" for server tools
Name
string
`json:"name"`
Name
string
`json:"name"`
Description
string
`json:"description,omitempty"`
Description
string
`json:"description,omitempty"`
InputSchema
json
.
RawMessage
`json:"input_schema"`
// JSON Schema object
InputSchema
json
.
RawMessage
`json:"input_schema"`
// JSON Schema object
CacheControl
*
AnthropicCacheControl
`json:"cache_control,omitempty"`
}
// AnthropicCacheControl 对应 Anthropic API 的 cache_control 字段。
// ttl 默认由调用方决定;本项目策略见 claude.DefaultCacheControlTTL。
type
AnthropicCacheControl
struct
{
Type
string
`json:"type"`
// "ephemeral"
TTL
string
`json:"ttl,omitempty"`
// "5m" / "1h" / 省略=默认 5m(由 Anthropic 判定)
}
}
// AnthropicResponse is the non-streaming response from POST /v1/messages.
// AnthropicResponse is the non-streaming response from POST /v1/messages.
...
...
backend/internal/pkg/claude/constants.go
View file @
6d20ab80
...
@@ -4,6 +4,12 @@ package claude
...
@@ -4,6 +4,12 @@ package claude
// Claude Code 客户端相关常量
// Claude Code 客户端相关常量
// Beta header 常量
// Beta header 常量
//
// 这里的常量对齐真实 Claude Code CLI 的最新流量(截至 2026-04)。
// 选型参考:与 Parrot (src/transform/cc_mimicry.py) 的 BETAS 保持一致,
// 原因:Anthropic 上游会基于 anthropic-beta 的完整集合判定请求来源;
// 缺少任何"官方 Claude Code 请求才会带"的 beta,都会被降级到第三方额度,
// 对应报错:`Third-party apps now draw from your extra usage, not your plan limits.`
const
(
const
(
BetaOAuth
=
"oauth-2025-04-20"
BetaOAuth
=
"oauth-2025-04-20"
BetaClaudeCode
=
"claude-code-20250219"
BetaClaudeCode
=
"claude-code-20250219"
...
@@ -12,6 +18,13 @@ const (
...
@@ -12,6 +18,13 @@ const (
BetaTokenCounting
=
"token-counting-2024-11-01"
BetaTokenCounting
=
"token-counting-2024-11-01"
BetaContext1M
=
"context-1m-2025-08-07"
BetaContext1M
=
"context-1m-2025-08-07"
BetaFastMode
=
"fast-mode-2026-02-01"
BetaFastMode
=
"fast-mode-2026-02-01"
// 新增(对齐官方 CLI 2.1.9x 以来的流量)
BetaPromptCachingScope
=
"prompt-caching-scope-2026-01-05"
BetaEffort
=
"effort-2025-11-24"
BetaRedactThinking
=
"redact-thinking-2026-02-12"
BetaContextManagement
=
"context-management-2025-06-27"
BetaExtendedCacheTTL
=
"extended-cache-ttl-2025-04-11"
)
)
// DroppedBetas 是转发时需要从 anthropic-beta header 中移除的 beta token 列表。
// DroppedBetas 是转发时需要从 anthropic-beta header 中移除的 beta token 列表。
...
@@ -44,11 +57,43 @@ const APIKeyBetaHeader = BetaClaudeCode + "," + BetaInterleavedThinking + "," +
...
@@ -44,11 +57,43 @@ const APIKeyBetaHeader = BetaClaudeCode + "," + BetaInterleavedThinking + "," +
// APIKeyHaikuBetaHeader Haiku 模型在 API-key 账号下使用的 anthropic-beta header(不包含 oauth / claude-code)
// APIKeyHaikuBetaHeader Haiku 模型在 API-key 账号下使用的 anthropic-beta header(不包含 oauth / claude-code)
const
APIKeyHaikuBetaHeader
=
BetaInterleavedThinking
const
APIKeyHaikuBetaHeader
=
BetaInterleavedThinking
// DefaultCacheControlTTL 是网关代理为自己生成的 cache_control 块默认使用的 ttl。
// 真实 Claude Code CLI 当前使用 "1h",但本仓策略是"客户端透传 ttl 优先;
// 客户端缺省时统一使用 5m",这样既不浪费 1h 缓存额度,也保留客户端自定义能力。
const
DefaultCacheControlTTL
=
"5m"
// CLICurrentVersion 是 sub2api 当前对外伪装的 Claude Code CLI 版本号(三段 semver)。
// 用于 billing attribution block 中的 cc_version=X.Y.Z.{fp} 前缀以及 fingerprint 计算。
// 必须与 DefaultHeaders["User-Agent"] 中的版本号严格一致;不一致会被 Anthropic 判第三方。
const
CLICurrentVersion
=
"2.1.92"
// FullClaudeCodeMimicryBetas 返回最"像"真实 Claude Code CLI 的完整 beta 列表,
// 用于 OAuth 账号伪装成 Claude Code 时使用。
// 顺序与真实 CLI 抓包一致。
//
// 使用建议:
// - OAuth 账号 + 非 haiku:追加这整份列表,再按需保留 client 带来的 beta。
// - OAuth 账号 + haiku:Anthropic 对 haiku 不做 third-party 判定,使用 HaikuBetaHeader 即可。
// - API-key 账号:不要使用本函数,参见 APIKeyBetaHeader。
func
FullClaudeCodeMimicryBetas
()
[]
string
{
return
[]
string
{
BetaClaudeCode
,
BetaOAuth
,
BetaInterleavedThinking
,
BetaPromptCachingScope
,
BetaEffort
,
BetaRedactThinking
,
BetaContextManagement
,
BetaExtendedCacheTTL
,
}
}
// DefaultHeaders 是 Claude Code 客户端默认请求头。
// DefaultHeaders 是 Claude Code 客户端默认请求头。
var
DefaultHeaders
=
map
[
string
]
string
{
var
DefaultHeaders
=
map
[
string
]
string
{
// Keep these in sync with recent Claude CLI traffic to reduce the chance
// 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.
// that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage.
"User-Agent"
:
"claude-cli/2.1.22 (external, cli)"
,
// 版本参考:对齐 Parrot (src/transform/cc_mimicry.py:49) 的 CLI_USER_AGENT。
"User-Agent"
:
"claude-cli/2.1.92 (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"
,
...
...
backend/internal/service/gateway_anthropic_apikey_passthrough_test.go
View file @
6d20ab80
...
@@ -762,8 +762,14 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock(
...
@@ -762,8 +762,14 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock(
system
:=
gjson
.
GetBytes
(
upstream
.
lastBody
,
"system"
)
system
:=
gjson
.
GetBytes
(
upstream
.
lastBody
,
"system"
)
require
.
True
(
t
,
system
.
Exists
())
require
.
True
(
t
,
system
.
Exists
())
require
.
True
(
t
,
system
.
IsArray
(),
"system should be an array"
)
require
.
True
(
t
,
system
.
IsArray
(),
"system should be an array"
)
require
.
Equal
(
t
,
claudeCodeSystemPrompt
,
system
.
Array
()[
0
]
.
Get
(
"text"
)
.
String
())
arr
:=
system
.
Array
()
require
.
Equal
(
t
,
"ephemeral"
,
system
.
Array
()[
0
]
.
Get
(
"cache_control.type"
)
.
String
())
require
.
Len
(
t
,
arr
,
2
,
"system array should have billing block + cc prompt block"
)
require
.
Contains
(
t
,
arr
[
0
]
.
Get
(
"text"
)
.
String
(),
"x-anthropic-billing-header:"
)
require
.
Contains
(
t
,
arr
[
0
]
.
Get
(
"text"
)
.
String
(),
"cc_version="
)
require
.
Equal
(
t
,
claudeCodeSystemPrompt
,
arr
[
1
]
.
Get
(
"text"
)
.
String
())
require
.
Equal
(
t
,
"ephemeral"
,
arr
[
1
]
.
Get
(
"cache_control.type"
)
.
String
())
// 原始 system prompt 应迁移至 messages 中
// 原始 system prompt 应迁移至 messages 中
messages
:=
gjson
.
GetBytes
(
upstream
.
lastBody
,
"messages"
)
messages
:=
gjson
.
GetBytes
(
upstream
.
lastBody
,
"messages"
)
...
...
backend/internal/service/gateway_billing_block.go
0 → 100644
View file @
6d20ab80
package
service
import
(
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"github.com/tidwall/gjson"
)
// fingerprintSalt 是计算 cc_version 后缀指纹的盐值。
//
// 来源:与 Parrot src/transform/cc_mimicry.py 的 FINGERPRINT_SALT 完全一致;
// 这是真实 Claude Code CLI 抓包推导出的常量,改动会导致 fp 与 CLI 不一致,
// 进一步触发 Anthropic 的第三方检测。
const
fingerprintSalt
=
"59cf53e54c78"
// computeClaudeCodeFingerprint 复刻真实 Claude Code CLI 的 cc_version 指纹算法:
//
// 1. 取 messages 中第一条 role=user 的纯文本(首块 text)
// 2. 取该文本的第 4、7、20 字符(不足以 '0' 补齐)
// 3. SHA256(SALT + chars + cc_version) 取 hex 前 3 字符
//
// 算法来自 Parrot src/transform/cc_mimicry.py:compute_fingerprint,与官方 CLI 字节对齐。
// 任何偏差都会导致 cc_version=X.Y.Z.{fp} 在上游侧与真实 CLI 不一致。
func
computeClaudeCodeFingerprint
(
body
[]
byte
,
version
string
)
string
{
firstText
:=
extractFirstUserText
(
body
)
indices
:=
[]
int
{
4
,
7
,
20
}
chars
:=
make
([]
byte
,
0
,
3
)
for
_
,
i
:=
range
indices
{
if
i
<
len
(
firstText
)
{
chars
=
append
(
chars
,
firstText
[
i
])
}
else
{
chars
=
append
(
chars
,
'0'
)
}
}
sum
:=
sha256
.
Sum256
([]
byte
(
fingerprintSalt
+
string
(
chars
)
+
version
))
return
hex
.
EncodeToString
(
sum
[
:
])[
:
3
]
}
// extractFirstUserText 提取 messages 中第一条 user 消息的首段 text 内容。
// 兼容 string 和 []block 两种 content 格式。
func
extractFirstUserText
(
body
[]
byte
)
string
{
messages
:=
gjson
.
GetBytes
(
body
,
"messages"
)
if
!
messages
.
IsArray
()
{
return
""
}
first
:=
""
messages
.
ForEach
(
func
(
_
,
msg
gjson
.
Result
)
bool
{
if
msg
.
Get
(
"role"
)
.
String
()
!=
"user"
{
return
true
}
content
:=
msg
.
Get
(
"content"
)
if
content
.
Type
==
gjson
.
String
{
first
=
content
.
String
()
return
false
}
if
content
.
IsArray
()
{
content
.
ForEach
(
func
(
_
,
block
gjson
.
Result
)
bool
{
if
block
.
Get
(
"type"
)
.
String
()
==
"text"
{
first
=
block
.
Get
(
"text"
)
.
String
()
return
false
}
return
true
})
return
false
}
return
false
})
return
first
}
// buildBillingAttributionBlockJSON 构造 system 数组的 billing attribution block。
//
// 形态严格对齐真实 Claude Code CLI:
//
// {"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.92.{fp}; cc_entrypoint=cli; cch=00000;"}
//
// cch=00000 是签名占位符,由 signBillingHeaderCCH 在 buildUpstreamRequest 阶段
// 替换为基于完整 body 的 xxhash64 5 位十六进制摘要。
//
// 此 block 不带 cache_control(与真实 CLI 一致;cache breakpoint 由后续的
// Claude Code prompt block 承担)。
func
buildBillingAttributionBlockJSON
(
body
[]
byte
,
cliVersion
string
)
([]
byte
,
error
)
{
if
cliVersion
==
""
{
return
nil
,
fmt
.
Errorf
(
"cliVersion required"
)
}
fp
:=
computeClaudeCodeFingerprint
(
body
,
cliVersion
)
text
:=
fmt
.
Sprintf
(
"x-anthropic-billing-header: cc_version=%s.%s; cc_entrypoint=cli; cch=00000;"
,
cliVersion
,
fp
,
)
return
json
.
Marshal
(
map
[
string
]
string
{
"type"
:
"text"
,
"text"
:
text
,
})
}
backend/internal/service/gateway_body_order_test.go
View file @
6d20ab80
...
@@ -41,12 +41,13 @@ func TestNormalizeClaudeOAuthRequestBody_PreservesTopLevelFieldOrder(t *testing.
...
@@ -41,12 +41,13 @@ func TestNormalizeClaudeOAuthRequestBody_PreservesTopLevelFieldOrder(t *testing.
resultStr
:=
string
(
result
)
resultStr
:=
string
(
result
)
require
.
Equal
(
t
,
claude
.
NormalizeModelID
(
"claude-3-5-sonnet-latest"
),
modelID
)
require
.
Equal
(
t
,
claude
.
NormalizeModelID
(
"claude-3-5-sonnet-latest"
),
modelID
)
assertJSONTokenOrder
(
t
,
resultStr
,
`"alpha"`
,
`"model"`
,
`"system"`
,
`"messages"`
,
`"omega"`
,
`"tools"`
,
`"metadata"`
)
assertJSONTokenOrder
(
t
,
resultStr
,
`"alpha"`
,
`"model"`
,
`"temperature"`
,
`"system"`
,
`"messages"`
,
`"omega"`
,
`"tools"`
,
`"metadata"`
,
`"max_tokens"`
)
require
.
Not
Contains
(
t
,
resultStr
,
`"temperature"`
)
require
.
Contains
(
t
,
resultStr
,
`"temperature"
:0.2
`
)
require
.
NotContains
(
t
,
resultStr
,
`"tool_choice"`
)
require
.
NotContains
(
t
,
resultStr
,
`"tool_choice"`
)
require
.
Contains
(
t
,
resultStr
,
`"system":"`
+
claudeCodeSystemPrompt
+
`"`
)
require
.
Contains
(
t
,
resultStr
,
`"system":"`
+
claudeCodeSystemPrompt
+
`"`
)
require
.
Contains
(
t
,
resultStr
,
`"tools":[]`
)
require
.
Contains
(
t
,
resultStr
,
`"tools":[]`
)
require
.
Contains
(
t
,
resultStr
,
`"metadata":{"user_id":"user-1"}`
)
require
.
Contains
(
t
,
resultStr
,
`"metadata":{"user_id":"user-1"}`
)
require
.
Contains
(
t
,
resultStr
,
`"max_tokens":128000`
)
}
}
func
TestInjectClaudeCodePrompt_PreservesFieldOrder
(
t
*
testing
.
T
)
{
func
TestInjectClaudeCodePrompt_PreservesFieldOrder
(
t
*
testing
.
T
)
{
...
...
backend/internal/service/gateway_forward_as_chat_completions.go
View file @
6d20ab80
...
@@ -85,15 +85,16 @@ func (s *GatewayService) ForwardAsChatCompletions(
...
@@ -85,15 +85,16 @@ func (s *GatewayService) ForwardAsChatCompletions(
return
nil
,
fmt
.
Errorf
(
"marshal anthropic request: %w"
,
err
)
return
nil
,
fmt
.
Errorf
(
"marshal anthropic request: %w"
,
err
)
}
}
// 6. Apply Claude Code mimicry for OAuth accounts
// 6. Apply Claude Code mimicry for OAuth accounts.
isClaudeCode
:=
false
// CC API is never Claude Code
// Chat Completions 协议进来的请求永远不是 Claude Code 客户端,所以对 OAuth 账号
// 必须完整执行 /v1/messages 主路径上的伪装链路(system 重写 + normalize + metadata 注入),
// 否则会被 Anthropic 判为第三方应用并扣 extra usage。
// 见 applyClaudeCodeOAuthMimicryToBody 的 godoc。
isClaudeCode
:=
false
shouldMimicClaudeCode
:=
account
.
IsOAuth
()
&&
!
isClaudeCode
shouldMimicClaudeCode
:=
account
.
IsOAuth
()
&&
!
isClaudeCode
if
shouldMimicClaudeCode
{
if
shouldMimicClaudeCode
{
if
!
strings
.
Contains
(
strings
.
ToLower
(
mappedModel
),
"haiku"
)
&&
anthropicBody
=
s
.
applyClaudeCodeOAuthMimicryToBody
(
ctx
,
c
,
account
,
anthropicBody
,
anthropicReq
.
System
,
mappedModel
)
!
systemIncludesClaudeCodePrompt
(
anthropicReq
.
System
)
{
anthropicBody
=
injectClaudeCodePrompt
(
anthropicBody
,
anthropicReq
.
System
)
}
}
}
// 7. Enforce cache_control block limit
// 7. Enforce cache_control block limit
...
@@ -312,7 +313,14 @@ func (s *GatewayService) handleCCBufferedFromAnthropic(
...
@@ -312,7 +313,14 @@ func (s *GatewayService) handleCCBufferedFromAnthropic(
if
s
.
responseHeaderFilter
!=
nil
{
if
s
.
responseHeaderFilter
!=
nil
{
responseheaders
.
WriteFilteredHeaders
(
c
.
Writer
.
Header
(),
resp
.
Header
,
s
.
responseHeaderFilter
)
responseheaders
.
WriteFilteredHeaders
(
c
.
Writer
.
Header
(),
resp
.
Header
,
s
.
responseHeaderFilter
)
}
}
c
.
JSON
(
http
.
StatusOK
,
ccResp
)
// Marshal then bytes-replace so tool name mapping is reversed at byte level
// (parity with Parrot non-stream flow that marshals → restore → emit).
if
respBytes
,
err
:=
json
.
Marshal
(
ccResp
);
err
==
nil
{
respBytes
=
reverseToolNamesIfPresent
(
c
,
respBytes
)
c
.
Data
(
http
.
StatusOK
,
"application/json; charset=utf-8"
,
respBytes
)
}
else
{
c
.
JSON
(
http
.
StatusOK
,
ccResp
)
}
return
&
ForwardResult
{
return
&
ForwardResult
{
RequestID
:
requestID
,
RequestID
:
requestID
,
...
@@ -383,7 +391,10 @@ func (s *GatewayService) handleCCStreamingFromAnthropic(
...
@@ -383,7 +391,10 @@ func (s *GatewayService) handleCCStreamingFromAnthropic(
if
err
!=
nil
{
if
err
!=
nil
{
return
false
return
false
}
}
if
_
,
err
:=
fmt
.
Fprint
(
c
.
Writer
,
sse
);
err
!=
nil
{
// Reverse tool name mapping: fake → real, per-chunk bytes.Replace.
// c 可能持有请求侧注入的 ToolNameRewrite;无则仅做静态前缀还原。
out
:=
string
(
reverseToolNamesIfPresent
(
c
,
[]
byte
(
sse
)))
if
_
,
err
:=
fmt
.
Fprint
(
c
.
Writer
,
out
);
err
!=
nil
{
return
true
// client disconnected
return
true
// client disconnected
}
}
return
false
return
false
...
...
backend/internal/service/gateway_forward_as_responses.go
View file @
6d20ab80
...
@@ -82,15 +82,16 @@ func (s *GatewayService) ForwardAsResponses(
...
@@ -82,15 +82,16 @@ func (s *GatewayService) ForwardAsResponses(
return
nil
,
fmt
.
Errorf
(
"marshal anthropic request: %w"
,
err
)
return
nil
,
fmt
.
Errorf
(
"marshal anthropic request: %w"
,
err
)
}
}
// 6. Apply Claude Code mimicry for OAuth accounts (non-Claude-Code endpoints)
// 6. Apply Claude Code mimicry for OAuth accounts (non-Claude-Code endpoints).
isClaudeCode
:=
false
// Responses API is never Claude Code
// OpenAI Responses 协议进来的请求永远不是 Claude Code 客户端,所以对 OAuth 账号
// 必须完整执行 /v1/messages 主路径上的伪装链路(system 重写 + normalize + metadata 注入),
// 否则会被 Anthropic 判为第三方应用并扣 extra usage。
// 见 applyClaudeCodeOAuthMimicryToBody 的 godoc。
isClaudeCode
:=
false
shouldMimicClaudeCode
:=
account
.
IsOAuth
()
&&
!
isClaudeCode
shouldMimicClaudeCode
:=
account
.
IsOAuth
()
&&
!
isClaudeCode
if
shouldMimicClaudeCode
{
if
shouldMimicClaudeCode
{
if
!
strings
.
Contains
(
strings
.
ToLower
(
mappedModel
),
"haiku"
)
&&
anthropicBody
=
s
.
applyClaudeCodeOAuthMimicryToBody
(
ctx
,
c
,
account
,
anthropicBody
,
anthropicReq
.
System
,
mappedModel
)
!
systemIncludesClaudeCodePrompt
(
anthropicReq
.
System
)
{
anthropicBody
=
injectClaudeCodePrompt
(
anthropicBody
,
anthropicReq
.
System
)
}
}
}
// 7. Enforce cache_control block limit
// 7. Enforce cache_control block limit
...
@@ -331,7 +332,12 @@ func (s *GatewayService) handleResponsesBufferedStreamingResponse(
...
@@ -331,7 +332,12 @@ func (s *GatewayService) handleResponsesBufferedStreamingResponse(
if
s
.
responseHeaderFilter
!=
nil
{
if
s
.
responseHeaderFilter
!=
nil
{
responseheaders
.
WriteFilteredHeaders
(
c
.
Writer
.
Header
(),
resp
.
Header
,
s
.
responseHeaderFilter
)
responseheaders
.
WriteFilteredHeaders
(
c
.
Writer
.
Header
(),
resp
.
Header
,
s
.
responseHeaderFilter
)
}
}
c
.
JSON
(
http
.
StatusOK
,
responsesResp
)
if
respBytes
,
err
:=
json
.
Marshal
(
responsesResp
);
err
==
nil
{
respBytes
=
reverseToolNamesIfPresent
(
c
,
respBytes
)
c
.
Data
(
http
.
StatusOK
,
"application/json; charset=utf-8"
,
respBytes
)
}
else
{
c
.
JSON
(
http
.
StatusOK
,
responsesResp
)
}
return
&
ForwardResult
{
return
&
ForwardResult
{
RequestID
:
requestID
,
RequestID
:
requestID
,
...
@@ -419,7 +425,8 @@ func (s *GatewayService) handleResponsesStreamingResponse(
...
@@ -419,7 +425,8 @@ func (s *GatewayService) handleResponsesStreamingResponse(
)
)
continue
continue
}
}
if
_
,
err
:=
fmt
.
Fprint
(
c
.
Writer
,
sse
);
err
!=
nil
{
out
:=
string
(
reverseToolNamesIfPresent
(
c
,
[]
byte
(
sse
)))
if
_
,
err
:=
fmt
.
Fprint
(
c
.
Writer
,
out
);
err
!=
nil
{
logger
.
L
()
.
Info
(
"forward_as_responses stream: client disconnected"
,
logger
.
L
()
.
Info
(
"forward_as_responses stream: client disconnected"
,
zap
.
String
(
"request_id"
,
requestID
),
zap
.
String
(
"request_id"
,
requestID
),
)
)
...
@@ -439,7 +446,8 @@ func (s *GatewayService) handleResponsesStreamingResponse(
...
@@ -439,7 +446,8 @@ func (s *GatewayService) handleResponsesStreamingResponse(
if
err
!=
nil
{
if
err
!=
nil
{
continue
continue
}
}
fmt
.
Fprint
(
c
.
Writer
,
sse
)
//nolint:errcheck
out
:=
string
(
reverseToolNamesIfPresent
(
c
,
[]
byte
(
sse
)))
fmt
.
Fprint
(
c
.
Writer
,
out
)
//nolint:errcheck
}
}
c
.
Writer
.
Flush
()
c
.
Writer
.
Flush
()
}
}
...
...
backend/internal/service/gateway_messages_cache.go
0 → 100644
View file @
6d20ab80
package
service
import
(
"fmt"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// stripMessageCacheControl 移除 $.messages[*].content[*].cache_control。
// 与 Parrot _strip_message_cache_control 语义一致。
//
// 为什么必须整体清空:客户端(特别是 Claude Code)经常把 cache_control 打在
// "当前最后一条 user message" 上;下一轮对话 messages 追加后,原本的最后一条
// 变成中间某条,cache_control 还挂着就导致"前缀签名变化",破坏缓存命中。
// 统一由代理重新打断点(addMessageCacheBreakpoints)才能在多轮间稳定。
func
stripMessageCacheControl
(
body
[]
byte
)
[]
byte
{
messages
:=
gjson
.
GetBytes
(
body
,
"messages"
)
if
!
messages
.
IsArray
()
{
return
body
}
msgIdx
:=
-
1
messages
.
ForEach
(
func
(
_
,
msg
gjson
.
Result
)
bool
{
msgIdx
++
content
:=
msg
.
Get
(
"content"
)
if
!
content
.
IsArray
()
{
return
true
}
blockIdx
:=
-
1
content
.
ForEach
(
func
(
_
,
block
gjson
.
Result
)
bool
{
blockIdx
++
if
!
block
.
Get
(
"cache_control"
)
.
Exists
()
{
return
true
}
path
:=
fmt
.
Sprintf
(
"messages.%d.content.%d.cache_control"
,
msgIdx
,
blockIdx
)
if
next
,
err
:=
sjson
.
DeleteBytes
(
body
,
path
);
err
==
nil
{
body
=
next
}
return
true
})
return
true
})
return
body
}
// addMessageCacheBreakpoints 在 messages 上注入两个稳定的 cache 断点:
// 1. 最后一条 message
// 2. 当 messages 数量 ≥ 4 时,倒数第二个 role=user 的 message
//
// 与 Parrot add_cache_breakpoints 一致。两个断点 + system prompt block 的断点
// + tools[-1] 的断点共同构成最多 4 个断点(Anthropic 上限)。
//
// cache_control ttl 策略:
// - 若目标 block 已有 cache_control.ttl → 不覆盖
// - 否则写入 {"type":"ephemeral","ttl": claude.DefaultCacheControlTTL}
//
// 调用前应先 stripMessageCacheControl 以保证幂等和稳定。
func
addMessageCacheBreakpoints
(
body
[]
byte
)
[]
byte
{
messages
:=
gjson
.
GetBytes
(
body
,
"messages"
)
if
!
messages
.
IsArray
()
{
return
body
}
arr
:=
messages
.
Array
()
if
len
(
arr
)
==
0
{
return
body
}
body
=
injectCacheControlOnLastContentBlock
(
body
,
len
(
arr
)
-
1
,
&
arr
[
len
(
arr
)
-
1
])
if
len
(
arr
)
>=
4
{
userCount
:=
0
for
i
:=
len
(
arr
)
-
1
;
i
>=
0
;
i
--
{
if
arr
[
i
]
.
Get
(
"role"
)
.
String
()
!=
"user"
{
continue
}
userCount
++
if
userCount
==
2
{
body
=
injectCacheControlOnLastContentBlock
(
body
,
i
,
&
arr
[
i
])
break
}
}
}
return
body
}
// injectCacheControlOnLastContentBlock 把 cache_control 断点打在 messages[idx]
// 的最后一个 content block 上。若 content 是 string,先升级成单块 text 数组
// (对齐 Parrot _inject_cache_on_msg 的行为)。
//
// msg 是调用方已持有的 gjson.Result 快照,用于省一次 GetBytes。
func
injectCacheControlOnLastContentBlock
(
body
[]
byte
,
idx
int
,
msg
*
gjson
.
Result
)
[]
byte
{
content
:=
msg
.
Get
(
"content"
)
if
content
.
Type
==
gjson
.
String
{
text
:=
content
.
String
()
blockRaw
:=
fmt
.
Sprintf
(
`[{"type":"text","text":%s,"cache_control":{"type":"ephemeral","ttl":%q}}]`
,
mustJSONString
(
text
),
claude
.
DefaultCacheControlTTL
,
)
if
next
,
err
:=
sjson
.
SetRawBytes
(
body
,
fmt
.
Sprintf
(
"messages.%d.content"
,
idx
),
[]
byte
(
blockRaw
));
err
==
nil
{
body
=
next
}
return
body
}
if
!
content
.
IsArray
()
{
return
body
}
contentArr
:=
content
.
Array
()
if
len
(
contentArr
)
==
0
{
return
body
}
lastBlockIdx
:=
len
(
contentArr
)
-
1
lastBlock
:=
contentArr
[
lastBlockIdx
]
if
cc
:=
lastBlock
.
Get
(
"cache_control"
);
cc
.
Exists
()
&&
cc
.
Get
(
"ttl"
)
.
String
()
!=
""
{
return
body
}
pathPrefix
:=
fmt
.
Sprintf
(
"messages.%d.content.%d.cache_control"
,
idx
,
lastBlockIdx
)
existingCC
:=
lastBlock
.
Get
(
"cache_control"
)
if
existingCC
.
Exists
()
{
if
next
,
err
:=
sjson
.
SetBytes
(
body
,
pathPrefix
+
".ttl"
,
claude
.
DefaultCacheControlTTL
);
err
==
nil
{
body
=
next
}
return
body
}
raw
:=
fmt
.
Sprintf
(
`{"type":"ephemeral","ttl":%q}`
,
claude
.
DefaultCacheControlTTL
)
if
next
,
err
:=
sjson
.
SetRawBytes
(
body
,
pathPrefix
,
[]
byte
(
raw
));
err
==
nil
{
body
=
next
}
return
body
}
// mustJSONString 把一个 Go string 序列化为合法 JSON string(含引号),
// 用于 sjson.SetRawBytes 场景下手工拼 JSON。
func
mustJSONString
(
s
string
)
string
{
return
fmt
.
Sprintf
(
"%q"
,
s
)
}
backend/internal/service/gateway_prompt_test.go
View file @
6d20ab80
...
@@ -378,16 +378,27 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) {
...
@@ -378,16 +378,27 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) {
err
:=
json
.
Unmarshal
(
result
,
&
parsed
)
err
:=
json
.
Unmarshal
(
result
,
&
parsed
)
require
.
NoError
(
t
,
err
)
require
.
NoError
(
t
,
err
)
// system 应为 array 格式: [{type: "text", text: "...", cache_control: {type: "ephemeral"}}]
// system 应为 array 格式,对齐真实 Claude Code CLI 的 2-block 形态:
// [0] billing attribution block (x-anthropic-billing-header: cc_version=...;)
// [1] Claude Code prompt block (带 cache_control)
systemArr
,
ok
:=
parsed
[
"system"
]
.
([]
any
)
systemArr
,
ok
:=
parsed
[
"system"
]
.
([]
any
)
require
.
True
(
t
,
ok
,
"system should be an array, got %T"
,
parsed
[
"system"
])
require
.
True
(
t
,
ok
,
"system should be an array, got %T"
,
parsed
[
"system"
])
require
.
Len
(
t
,
systemArr
,
1
,
"system array should have exactly 1 block"
)
require
.
Len
(
t
,
systemArr
,
2
,
"system array should have exactly 2 blocks (billing + cc prompt)"
)
systemBlock
,
ok
:=
systemArr
[
0
]
.
(
map
[
string
]
any
)
billingBlock
,
ok
:=
systemArr
[
0
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
"text"
,
billingBlock
[
"type"
])
require
.
Contains
(
t
,
billingBlock
[
"text"
],
"x-anthropic-billing-header:"
)
require
.
Contains
(
t
,
billingBlock
[
"text"
],
"cc_version="
)
require
.
Contains
(
t
,
billingBlock
[
"text"
],
"cc_entrypoint=cli"
)
require
.
Contains
(
t
,
billingBlock
[
"text"
],
"cch=00000"
)
systemBlock
,
ok
:=
systemArr
[
1
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
"text"
,
systemBlock
[
"type"
])
require
.
Equal
(
t
,
"text"
,
systemBlock
[
"type"
])
require
.
Equal
(
t
,
tt
.
wantSystemText
,
systemBlock
[
"text"
])
require
.
Equal
(
t
,
tt
.
wantSystemText
,
systemBlock
[
"text"
])
cc
,
ok
:=
systemBlock
[
"cache_control"
]
.
(
map
[
string
]
any
)
cc
,
ok
:=
systemBlock
[
"cache_control"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
,
"
system
block should have cache_control"
)
require
.
True
(
t
,
ok
,
"
cc prompt
block should have cache_control"
)
require
.
Equal
(
t
,
"ephemeral"
,
cc
[
"type"
])
require
.
Equal
(
t
,
"ephemeral"
,
cc
[
"type"
])
// 检查 messages
// 检查 messages
...
...
backend/internal/service/gateway_service.go
View file @
6d20ab80
...
@@ -850,6 +850,7 @@ func (s *GatewayService) hashContent(content string) string {
...
@@ -850,6 +850,7 @@ func (s *GatewayService) hashContent(content string) string {
type
anthropicCacheControlPayload
struct
{
type
anthropicCacheControlPayload
struct
{
Type
string
`json:"type"`
Type
string
`json:"type"`
TTL
string
`json:"ttl,omitempty"`
}
}
type
anthropicSystemTextBlockPayload
struct
{
type
anthropicSystemTextBlockPayload
struct
{
...
@@ -898,7 +899,10 @@ func marshalAnthropicSystemTextBlock(text string, includeCacheControl bool) ([]b
...
@@ -898,7 +899,10 @@ func marshalAnthropicSystemTextBlock(text string, includeCacheControl bool) ([]b
Text
:
text
,
Text
:
text
,
}
}
if
includeCacheControl
{
if
includeCacheControl
{
block
.
CacheControl
=
&
anthropicCacheControlPayload
{
Type
:
"ephemeral"
}
block
.
CacheControl
=
&
anthropicCacheControlPayload
{
Type
:
"ephemeral"
,
TTL
:
claude
.
DefaultCacheControlTTL
,
}
}
}
return
json
.
Marshal
(
block
)
return
json
.
Marshal
(
block
)
}
}
...
@@ -1074,19 +1078,52 @@ func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAu
...
@@ -1074,19 +1078,52 @@ func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAu
}
}
}
}
if
gjson
.
GetBytes
(
out
,
"temperature"
)
.
Exists
()
{
// temperature:真实 Claude Code CLI 总是发送 temperature(默认 1,客户端可覆盖)。
if
next
,
ok
:=
deleteJSONPathBytes
(
out
,
"temperature"
);
ok
{
// 之前的实现直接 delete 会导致 payload 缺字段,与真实 CLI 字节级不一致。
// 策略:客户端传了什么就透传;没传则补默认 1。
if
!
gjson
.
GetBytes
(
out
,
"temperature"
)
.
Exists
()
{
if
next
,
ok
:=
setJSONValueBytes
(
out
,
"temperature"
,
1
);
ok
{
out
=
next
out
=
next
modified
=
true
modified
=
true
}
}
}
}
if
gjson
.
GetBytes
(
out
,
"tool_choice"
)
.
Exists
()
{
if
next
,
ok
:=
deleteJSONPathBytes
(
out
,
"tool_choice"
);
ok
{
// max_tokens:真实 CLI 的默认值是 128000。缺失时补齐以对齐指纹。
if
!
gjson
.
GetBytes
(
out
,
"max_tokens"
)
.
Exists
()
{
if
next
,
ok
:=
setJSONValueBytes
(
out
,
"max_tokens"
,
128000
);
ok
{
out
=
next
out
=
next
modified
=
true
modified
=
true
}
}
}
}
// context_management:thinking.type 为 enabled/adaptive 时,真实 CLI 会自动
// 附带 {"edits":[{"type":"clear_thinking_20251015","keep":"all"}]}。
// 客户端显式传了就透传;否则按 CLI 行为补齐。
if
!
gjson
.
GetBytes
(
out
,
"context_management"
)
.
Exists
()
{
thinkingType
:=
gjson
.
GetBytes
(
out
,
"thinking.type"
)
.
String
()
if
thinkingType
==
"enabled"
||
thinkingType
==
"adaptive"
{
const
cmDefault
=
`{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]}`
if
next
,
ok
:=
setJSONRawBytes
(
out
,
"context_management"
,
[]
byte
(
cmDefault
));
ok
{
out
=
next
modified
=
true
}
}
}
// tool_choice:与 Parrot 对齐,不再无条件删除。
// - 客户端传了 {"type":"tool","name":"X"} → 保留结构,name 由
// applyToolNameRewriteToBody 同步映射为假名
// - 其他形态(auto/any/none)原样透传
// 如果 body 里完全没有 tools(空数组),tool_choice 没意义时才删除
if
!
gjson
.
GetBytes
(
out
,
"tools"
)
.
IsArray
()
||
len
(
gjson
.
GetBytes
(
out
,
"tools"
)
.
Array
())
==
0
{
if
gjson
.
GetBytes
(
out
,
"tool_choice"
)
.
Exists
()
{
if
next
,
ok
:=
deleteJSONPathBytes
(
out
,
"tool_choice"
);
ok
{
out
=
next
modified
=
true
}
}
}
if
!
modified
{
if
!
modified
{
return
body
,
modelID
return
body
,
modelID
}
}
...
@@ -1128,6 +1165,135 @@ func (s *GatewayService) buildOAuthMetadataUserID(parsed *ParsedRequest, account
...
@@ -1128,6 +1165,135 @@ func (s *GatewayService) buildOAuthMetadataUserID(parsed *ParsedRequest, account
return
FormatMetadataUserID
(
userID
,
accountUUID
,
sessionID
,
uaVersion
)
return
FormatMetadataUserID
(
userID
,
accountUUID
,
sessionID
,
uaVersion
)
}
}
// applyClaudeCodeOAuthMimicryToBody 将"非 Claude Code 客户端 + Claude OAuth 账号"
// 路径上原本只在 /v1/messages 里做的完整伪装应用到任意 body 上。
//
// 这是 /v1/messages 主路径上 rewriteSystemForNonClaudeCode +
// normalizeClaudeOAuthRequestBody 流程的通用版,供 OpenAI 协议兼容层
// (ForwardAsChatCompletions / ForwardAsResponses) 复用。
//
// 未抽离之前,OpenAI 协议兼容层仅做 injectClaudeCodePrompt(前置追加),
// 而仓内 /v1/messages 路径自己的注释明确说过"仅前置追加无法通过 Anthropic
// 第三方检测";那条注释就是本函数存在的根因。
//
// 参数:
// - ctx / c:用于读取指纹和 gateway settings;c 可为 nil(如 count_tokens)。
// - account:必须是 OAuth 账号,且调用方已判断不是 Claude Code 客户端。
// - body:已经 marshal 成 Anthropic /v1/messages 格式的请求体。
// - systemRaw:body 中原始 system 字段(用于判断是否需要 rewrite)。
// - model:最终会发给上游的模型 ID(用于 haiku 旁路 + metadata 版本选择)。
//
// 返回:改写后的 body。即使中间任何一步失败,也会退化成原 body(不会 panic)。
func
(
s
*
GatewayService
)
applyClaudeCodeOAuthMimicryToBody
(
ctx
context
.
Context
,
c
*
gin
.
Context
,
account
*
Account
,
body
[]
byte
,
systemRaw
any
,
model
string
,
)
[]
byte
{
if
account
==
nil
||
!
account
.
IsOAuth
()
||
len
(
body
)
==
0
{
return
body
}
systemRewritten
:=
false
if
!
strings
.
Contains
(
strings
.
ToLower
(
model
),
"haiku"
)
{
body
=
rewriteSystemForNonClaudeCode
(
body
,
systemRaw
)
systemRewritten
=
true
}
normalizeOpts
:=
claudeOAuthNormalizeOptions
{
stripSystemCacheControl
:
!
systemRewritten
}
if
s
.
identityService
!=
nil
&&
c
!=
nil
&&
c
.
Request
!=
nil
{
if
fp
,
err
:=
s
.
identityService
.
GetOrCreateFingerprint
(
ctx
,
account
.
ID
,
c
.
Request
.
Header
);
err
==
nil
&&
fp
!=
nil
{
mimicMPT
:=
false
if
s
.
settingService
!=
nil
{
_
,
mimicMPT
,
_
=
s
.
settingService
.
GetGatewayForwardingSettings
(
ctx
)
}
if
!
mimicMPT
{
if
uid
:=
s
.
buildOAuthMetadataUserIDFromBody
(
ctx
,
account
,
fp
,
body
);
uid
!=
""
{
normalizeOpts
.
injectMetadata
=
true
normalizeOpts
.
metadataUserID
=
uid
}
}
}
}
body
,
_
=
normalizeClaudeOAuthRequestBody
(
body
,
model
,
normalizeOpts
)
// Phase D+E+F: messages cache 策略 + 工具名混淆 + tools[-1] 断点
// 对齐 Parrot transform_request 里剩余的字段级改写。三步顺序有语义约束:
// 1) strip:先清除客户端的 messages[*].cache_control(多轮稳定性)
// 2) breakpoints:再注入 2 个断点(最后一条 + 倒数第二个 user turn)
// 3) tool rewrite:最后改 tools[*].name / tool_choice.name 并在 tools[-1]
// 上打断点;mapping 存入 gin.Context 供响应侧 bytes.Replace 还原。
body
=
stripMessageCacheControl
(
body
)
body
=
addMessageCacheBreakpoints
(
body
)
if
rw
:=
buildToolNameRewriteFromBody
(
body
);
rw
!=
nil
{
body
=
applyToolNameRewriteToBody
(
body
,
rw
)
if
c
!=
nil
{
c
.
Set
(
toolNameRewriteKey
,
rw
)
}
}
else
{
body
=
applyToolsLastCacheBreakpoint
(
body
)
}
return
body
}
// buildOAuthMetadataUserIDFromBody 是 buildOAuthMetadataUserID 的变体,
// 适用于调用方手上没有 ParsedRequest 的场景(如 OpenAI 协议兼容层)。
//
// 与 buildOAuthMetadataUserID 的唯一区别:
// - session hash 从 body 本体按同样规则重算,而不是读取 ParsedRequest 缓存值。
// - 如果 body 里已经存在 metadata.user_id,则返回空(由 ensureClaudeOAuthMetadataUserID
// 自行决定是否覆盖)。
func
(
s
*
GatewayService
)
buildOAuthMetadataUserIDFromBody
(
ctx
context
.
Context
,
account
*
Account
,
fp
*
Fingerprint
,
body
[]
byte
,
)
string
{
_
=
ctx
if
account
==
nil
{
return
""
}
if
existing
:=
gjson
.
GetBytes
(
body
,
"metadata.user_id"
)
.
String
();
existing
!=
""
{
return
""
}
userID
:=
strings
.
TrimSpace
(
account
.
GetClaudeUserID
())
if
userID
==
""
&&
fp
!=
nil
{
userID
=
fp
.
ClientID
}
if
userID
==
""
{
userID
=
generateClientID
()
}
sessionID
:=
uuid
.
NewString
()
if
hash
:=
hashBodyForSessionSeed
(
body
);
hash
!=
""
{
sessionID
=
generateSessionUUID
(
fmt
.
Sprintf
(
"%d::%s"
,
account
.
ID
,
hash
))
}
var
uaVersion
string
if
fp
!=
nil
{
uaVersion
=
ExtractCLIVersion
(
fp
.
UserAgent
)
}
accountUUID
:=
strings
.
TrimSpace
(
account
.
GetExtraString
(
"account_uuid"
))
return
FormatMetadataUserID
(
userID
,
accountUUID
,
sessionID
,
uaVersion
)
}
// hashBodyForSessionSeed 为 sessionID 提供一个稳定但仅对本次请求特征化的种子。
// 复用 SHA-256 + 截断,与 generateSessionUUID 的输入格式对齐。
func
hashBodyForSessionSeed
(
body
[]
byte
)
string
{
if
len
(
body
)
==
0
{
return
""
}
sum
:=
sha256
.
Sum256
(
body
)
return
fmt
.
Sprintf
(
"%x"
,
sum
[
:
16
])
}
// GenerateSessionUUID creates a deterministic UUID4 from a seed string.
// GenerateSessionUUID creates a deterministic UUID4 from a seed string.
func
GenerateSessionUUID
(
seed
string
)
string
{
func
GenerateSessionUUID
(
seed
string
)
string
{
return
generateSessionUUID
(
seed
)
return
generateSessionUUID
(
seed
)
...
@@ -3738,17 +3904,20 @@ func rewriteSystemForNonClaudeCode(body []byte, system any) []byte {
...
@@ -3738,17 +3904,20 @@ func rewriteSystemForNonClaudeCode(body []byte, system any) []byte {
originalSystemText
=
strings
.
Join
(
parts
,
"
\n\n
"
)
originalSystemText
=
strings
.
Join
(
parts
,
"
\n\n
"
)
}
}
// 2. 将 system 替换为 Claude Code 标准提示词(array 格式,与真实 Claude Code 一致)
// 2. 构造 system 数组,对齐真实 Claude Code CLI 的 2-block 形态:
// 真实 Claude Code 始终以 [{type: "text", text: "...", cache_control: {type: "ephemeral"}}] 发送 system。
// [0] billing attribution block(cc_version={cliVer}.{fp}; cc_entrypoint=cli; cch=00000;)
// 使用 string 格式会被 Anthropic 检测为第三方应用。
// [1] "You are Claude Code..." prompt block(带 cache_control 作为稳定缓存断点)
claudeCodeSystemBlock
:=
[]
map
[
string
]
any
{
//
{
// billing block 的 cch=00000 是占位符,会被 buildUpstreamRequest 里的
"type"
:
"text"
,
// signBillingHeaderCCH 替换成 xxhash64 签名。缺失 billing block 的系统 payload
"text"
:
claudeCodeSystemPrompt
,
// 是 Anthropic 判定第三方的关键信号之一(真实 CLI 每个请求都带)。
"cache_control"
:
map
[
string
]
string
{
"type"
:
"ephemeral"
},
billingBlock
,
billingErr
:=
buildBillingAttributionBlockJSON
(
body
,
claude
.
CLICurrentVersion
)
},
ccPromptBlock
,
ccErr
:=
marshalAnthropicSystemTextBlock
(
claudeCodeSystemPrompt
,
true
)
if
billingErr
!=
nil
||
ccErr
!=
nil
{
logger
.
LegacyPrintf
(
"service.gateway"
,
"Warning: failed to build system blocks (billing=%v, cc=%v)"
,
billingErr
,
ccErr
)
return
body
}
}
out
,
ok
:=
setJSON
Value
Bytes
(
body
,
"system"
,
claudeCodeSystem
Block
)
out
,
ok
:=
setJSON
Raw
Bytes
(
body
,
"system"
,
buildJSONArrayRaw
([][]
byte
{
billingBlock
,
ccPrompt
Block
})
)
if
!
ok
{
if
!
ok
{
logger
.
LegacyPrintf
(
"service.gateway"
,
"Warning: failed to set Claude Code system prompt"
)
logger
.
LegacyPrintf
(
"service.gateway"
,
"Warning: failed to set Claude Code system prompt"
)
return
body
return
body
...
@@ -3985,15 +4154,21 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
...
@@ -3985,15 +4154,21 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
})
})
}
}
isClaudeCode
:=
isClaudeCodeRequest
(
ctx
,
c
,
parsed
)
// OAuth 账号无条件走完整 mimicry,与 Parrot 对齐。
shouldMimicClaudeCode
:=
account
.
IsOAuth
()
&&
!
isClaudeCode
// 不再检查 isClaudeCodeRequest —— 即使客户端自称 Claude Code(opencode 等
// 第三方工具会伪装 UA / X-App / system prompt),它的伪装往往不完整(缺 billing
// block / 工具名混淆 / cache 策略等),被 Anthropic 判为 third-party。
// 无条件覆盖不会对真正的 Claude Code 造成问题,因为我们的伪装更完整。
shouldMimicClaudeCode
:=
account
.
IsOAuth
()
if
shouldMimicClaudeCode
{
if
shouldMimicClaudeCode
{
// 非 Claude Code 客户端:将 system 替换为 Claude Code 标识,原始 system 迁移至 messages
// 与 Parrot 对齐:OAuth 账号无条件重写 system(即使客户端已发了 Claude Code
// 条件:1) OAuth/SetupToken 账号 2) 不是 Claude Code 客户端 3) 不是 Haiku 模型 4) system 中还没有 Claude Code 提示词
// 风格的 system prompt)。原因:第三方工具(opencode 等)会发 "You are Claude
// Code..." system prompt 但缺少 billing attribution block,导致 Anthropic
// 检测到"有 CC prompt 但无 billing block"的不一致而判为 third-party。
// Parrot 的 transform_request 从不检查客户端 system 内容,直接覆盖。
systemRewritten
:=
false
systemRewritten
:=
false
if
!
strings
.
Contains
(
strings
.
ToLower
(
reqModel
),
"haiku"
)
&&
if
!
strings
.
Contains
(
strings
.
ToLower
(
reqModel
),
"haiku"
)
{
!
systemIncludesClaudeCodePrompt
(
parsed
.
System
)
{
body
=
rewriteSystemForNonClaudeCode
(
body
,
parsed
.
System
)
body
=
rewriteSystemForNonClaudeCode
(
body
,
parsed
.
System
)
systemRewritten
=
true
systemRewritten
=
true
}
}
...
@@ -4017,6 +4192,18 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
...
@@ -4017,6 +4192,18 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
}
}
body
,
reqModel
=
normalizeClaudeOAuthRequestBody
(
body
,
reqModel
,
normalizeOpts
)
body
,
reqModel
=
normalizeClaudeOAuthRequestBody
(
body
,
reqModel
,
normalizeOpts
)
// D/E/F: messages cache 策略 + 工具名混淆 + tools[-1] 断点
// 与 forward_as_chat_completions / forward_as_responses 路径对齐,
// 保证原生 /v1/messages 路径也经过完整的 Parrot 字段级改写。
body
=
stripMessageCacheControl
(
body
)
body
=
addMessageCacheBreakpoints
(
body
)
if
rw
:=
buildToolNameRewriteFromBody
(
body
);
rw
!=
nil
{
body
=
applyToolNameRewriteToBody
(
body
,
rw
)
c
.
Set
(
toolNameRewriteKey
,
rw
)
}
else
{
body
=
applyToolsLastCacheBreakpoint
(
body
)
}
}
}
// 强制执行 cache_control 块数量限制(最多 4 个)
// 强制执行 cache_control 块数量限制(最多 4 个)
...
@@ -4955,7 +5142,8 @@ func (s *GatewayService) handleStreamingResponseAnthropicAPIKeyPassthrough(
...
@@ -4955,7 +5142,8 @@ func (s *GatewayService) handleStreamingResponseAnthropicAPIKeyPassthrough(
}
}
if
!
clientDisconnected
{
if
!
clientDisconnected
{
if
_
,
err
:=
io
.
WriteString
(
w
,
line
);
err
!=
nil
{
restored
:=
string
(
reverseToolNamesIfPresent
(
c
,
[]
byte
(
line
)))
if
_
,
err
:=
io
.
WriteString
(
w
,
restored
);
err
!=
nil
{
clientDisconnected
=
true
clientDisconnected
=
true
logger
.
LegacyPrintf
(
"service.gateway"
,
"[Anthropic passthrough] Client disconnected during streaming, continue draining upstream for usage: account=%d"
,
account
.
ID
)
logger
.
LegacyPrintf
(
"service.gateway"
,
"[Anthropic passthrough] Client disconnected during streaming, continue draining upstream for usage: account=%d"
,
account
.
ID
)
}
else
if
_
,
err
:=
io
.
WriteString
(
w
,
"
\n
"
);
err
!=
nil
{
}
else
if
_
,
err
:=
io
.
WriteString
(
w
,
"
\n
"
);
err
!=
nil
{
...
@@ -5125,6 +5313,7 @@ func (s *GatewayService) handleNonStreamingResponseAnthropicAPIKeyPassthrough(
...
@@ -5125,6 +5313,7 @@ func (s *GatewayService) handleNonStreamingResponseAnthropicAPIKeyPassthrough(
if
contentType
==
""
{
if
contentType
==
""
{
contentType
=
"application/json"
contentType
=
"application/json"
}
}
body
=
reverseToolNamesIfPresent
(
c
,
body
)
c
.
Data
(
resp
.
StatusCode
,
contentType
,
body
)
c
.
Data
(
resp
.
StatusCode
,
contentType
,
body
)
return
usage
,
nil
return
usage
,
nil
}
}
...
@@ -5580,13 +5769,19 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
...
@@ -5580,13 +5769,19 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
setHeaderRaw
(
req
.
Header
,
"x-api-key"
,
token
)
setHeaderRaw
(
req
.
Header
,
"x-api-key"
,
token
)
}
}
// 白名单透传headers(恢复真实 wire casing)
// 白名单透传 headers
for
key
,
values
:=
range
clientHeaders
{
// OAuth mimicry 路径:跳过客户端 header 透传,与 Parrot 对齐。
lowerKey
:=
strings
.
ToLower
(
key
)
// Parrot 的 build_upstream_headers 只发 9 个精确 header,不透传任何客户端 header。
if
allowedHeaders
[
lowerKey
]
{
// 透传客户端 header 会引入不一致的 x-stainless-* / anthropic-beta / user-agent /
wireKey
:=
resolveWireCasing
(
key
)
// x-claude-code-session-id 等值,和我们注入的伪装 header 冲突,被 Anthropic 判 third-party。
for
_
,
v
:=
range
values
{
if
!
(
tokenType
==
"oauth"
&&
mimicClaudeCode
)
{
addHeaderRaw
(
req
.
Header
,
wireKey
,
v
)
for
key
,
values
:=
range
clientHeaders
{
lowerKey
:=
strings
.
ToLower
(
key
)
if
allowedHeaders
[
lowerKey
]
{
wireKey
:=
resolveWireCasing
(
key
)
for
_
,
v
:=
range
values
{
addHeaderRaw
(
req
.
Header
,
wireKey
,
v
)
}
}
}
}
}
}
}
...
@@ -5627,7 +5822,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
...
@@ -5627,7 +5822,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
// Haiku models are exempt from third-party detection and don't need it.
// Haiku models are exempt from third-party detection and don't need it.
requiredBetas
:=
[]
string
{
claude
.
BetaOAuth
,
claude
.
BetaInterleavedThinking
}
requiredBetas
:=
[]
string
{
claude
.
BetaOAuth
,
claude
.
BetaInterleavedThinking
}
if
!
strings
.
Contains
(
strings
.
ToLower
(
modelID
),
"haiku"
)
{
if
!
strings
.
Contains
(
strings
.
ToLower
(
modelID
),
"haiku"
)
{
requiredBetas
=
[]
string
{
claude
.
Beta
ClaudeCode
,
claude
.
BetaOAuth
,
claude
.
BetaInterleavedThinking
}
requiredBetas
=
claude
.
Full
ClaudeCode
MimicryBetas
()
}
}
setHeaderRaw
(
req
.
Header
,
"anthropic-beta"
,
mergeAnthropicBetaDropping
(
requiredBetas
,
incomingBeta
,
effectiveDropSet
))
setHeaderRaw
(
req
.
Header
,
"anthropic-beta"
,
mergeAnthropicBetaDropping
(
requiredBetas
,
incomingBeta
,
effectiveDropSet
))
}
else
{
}
else
{
...
@@ -6099,6 +6294,11 @@ func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) {
...
@@ -6099,6 +6294,11 @@ func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) {
if
isStream
{
if
isStream
{
setHeaderRaw
(
req
.
Header
,
"x-stainless-helper-method"
,
"stream"
)
setHeaderRaw
(
req
.
Header
,
"x-stainless-helper-method"
,
"stream"
)
}
}
// Real Claude CLI 每个请求都会生成一个新的 UUID 放在 x-client-request-id。
// 上游会以此作为会话/请求指纹的一部分,缺失或重复都可能触发第三方判定。
if
getHeaderRaw
(
req
.
Header
,
"x-client-request-id"
)
==
""
{
setHeaderRaw
(
req
.
Header
,
"x-client-request-id"
,
uuid
.
NewString
())
}
}
}
func
truncateForLog
(
b
[]
byte
,
maxBytes
int
)
string
{
func
truncateForLog
(
b
[]
byte
,
maxBytes
int
)
string
{
...
@@ -6864,7 +7064,8 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
...
@@ -6864,7 +7064,8 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
for
_
,
block
:=
range
outputBlocks
{
for
_
,
block
:=
range
outputBlocks
{
if
!
clientDisconnected
{
if
!
clientDisconnected
{
if
_
,
werr
:=
fmt
.
Fprint
(
w
,
block
);
werr
!=
nil
{
restored
:=
reverseToolNamesIfPresent
(
c
,
[]
byte
(
block
))
if
_
,
werr
:=
fmt
.
Fprint
(
w
,
string
(
restored
));
werr
!=
nil
{
clientDisconnected
=
true
clientDisconnected
=
true
logger
.
LegacyPrintf
(
"service.gateway"
,
"Client disconnected during streaming, continuing to drain upstream for billing"
)
logger
.
LegacyPrintf
(
"service.gateway"
,
"Client disconnected during streaming, continuing to drain upstream for billing"
)
break
break
...
@@ -7206,6 +7407,8 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h
...
@@ -7206,6 +7407,8 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h
}
}
}
}
body
=
reverseToolNamesIfPresent
(
c
,
body
)
// 写入响应
// 写入响应
c
.
Data
(
resp
.
StatusCode
,
contentType
,
body
)
c
.
Data
(
resp
.
StatusCode
,
contentType
,
body
)
...
@@ -8194,12 +8397,19 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
...
@@ -8194,12 +8397,19 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
// Pre-filter: strip empty text blocks to prevent upstream 400.
// Pre-filter: strip empty text blocks to prevent upstream 400.
body
=
StripEmptyTextBlocks
(
body
)
body
=
StripEmptyTextBlocks
(
body
)
isClaudeCode
:=
isClaudeCodeRequest
(
ctx
,
c
,
parsed
)
shouldMimicClaudeCode
:=
account
.
IsOAuth
()
shouldMimicClaudeCode
:=
account
.
IsOAuth
()
&&
!
isClaudeCode
if
shouldMimicClaudeCode
{
if
shouldMimicClaudeCode
{
normalizeOpts
:=
claudeOAuthNormalizeOptions
{
stripSystemCacheControl
:
true
}
normalizeOpts
:=
claudeOAuthNormalizeOptions
{
stripSystemCacheControl
:
true
}
body
,
reqModel
=
normalizeClaudeOAuthRequestBody
(
body
,
reqModel
,
normalizeOpts
)
body
,
reqModel
=
normalizeClaudeOAuthRequestBody
(
body
,
reqModel
,
normalizeOpts
)
body
=
stripMessageCacheControl
(
body
)
body
=
addMessageCacheBreakpoints
(
body
)
if
rw
:=
buildToolNameRewriteFromBody
(
body
);
rw
!=
nil
{
body
=
applyToolNameRewriteToBody
(
body
,
rw
)
}
else
{
body
=
applyToolsLastCacheBreakpoint
(
body
)
}
}
}
// Antigravity 账户不支持 count_tokens,返回 404 让客户端 fallback 到本地估算。
// Antigravity 账户不支持 count_tokens,返回 404 让客户端 fallback 到本地估算。
...
@@ -8623,7 +8833,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
...
@@ -8623,7 +8833,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
applyClaudeCodeMimicHeaders
(
req
,
false
)
applyClaudeCodeMimicHeaders
(
req
,
false
)
incomingBeta
:=
getHeaderRaw
(
req
.
Header
,
"anthropic-beta"
)
incomingBeta
:=
getHeaderRaw
(
req
.
Header
,
"anthropic-beta"
)
requiredBetas
:=
[]
string
{
claude
.
Beta
ClaudeCode
,
claude
.
BetaOAuth
,
claude
.
BetaInterleavedThinking
,
claude
.
BetaTokenCounting
}
requiredBetas
:=
append
(
claude
.
Full
ClaudeCode
MimicryBetas
()
,
claude
.
BetaTokenCounting
)
setHeaderRaw
(
req
.
Header
,
"anthropic-beta"
,
mergeAnthropicBetaDropping
(
requiredBetas
,
incomingBeta
,
ctEffectiveDropSet
))
setHeaderRaw
(
req
.
Header
,
"anthropic-beta"
,
mergeAnthropicBetaDropping
(
requiredBetas
,
incomingBeta
,
ctEffectiveDropSet
))
}
else
{
}
else
{
clientBetaHeader
:=
getHeaderRaw
(
req
.
Header
,
"anthropic-beta"
)
clientBetaHeader
:=
getHeaderRaw
(
req
.
Header
,
"anthropic-beta"
)
...
...
backend/internal/service/gateway_tool_rewrite.go
0 → 100644
View file @
6d20ab80
package
service
import
(
"fmt"
"hash/fnv"
"math/rand"
"sort"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// toolNameRewriteKey 是 gin.Context 上存 ToolNameRewrite 映射的 key。
// 请求阶段写入,响应阶段读取,用于 bytes 级逆向还原假名 → 真名。
const
toolNameRewriteKey
=
"claude_tool_name_rewrite"
// staticToolNameRewrites 是"静态前缀映射",与 Parrot src/transform/cc_mimicry.py
// TOOL_NAME_REWRITES 完全一致。只有以这些前缀开头的工具会被重写。
var
staticToolNameRewrites
=
map
[
string
]
string
{
"sessions_"
:
"cc_sess_"
,
"session_"
:
"cc_ses_"
,
}
// fakeToolNamePrefixes 是"动态映射"的前缀池,与 Parrot _FAKE_PREFIXES 一致。
// 当 tools 数量 > dynamicToolMapThreshold 时随机选用其中前缀生成可读假名。
var
fakeToolNamePrefixes
=
[]
string
{
"analyze_"
,
"compute_"
,
"fetch_"
,
"generate_"
,
"lookup_"
,
"modify_"
,
"process_"
,
"query_"
,
"render_"
,
"resolve_"
,
"sync_"
,
"update_"
,
"validate_"
,
"convert_"
,
"extract_"
,
"manage_"
,
"monitor_"
,
"parse_"
,
"review_"
,
"search_"
,
"transform_"
,
"handle_"
,
"invoke_"
,
"notify_"
,
}
// dynamicToolMapThreshold 与 Parrot 一致:tools 数量超过 5 才启用动态映射。
// 少量工具不需要混淆(一般是 Claude Code 自己的核心工具 bash/edit/read 等)。
const
dynamicToolMapThreshold
=
5
// ToolNameRewrite 是单次请求内的工具名混淆映射。
// - Forward: real → fake,请求阶段在 body 上应用。
// - Reverse: fake → real,响应阶段对每个 chunk 做 bytes.Replace 还原。
//
// ReverseOrdered 是按假名长度倒序的 (fake, real) 列表,用于防止短假名是长假名的
// 子串时 bytes.Replace 先被吃掉(对齐 Parrot _restore_tool_names_in_chunk 的
// `sorted(..., key=lambda x: len(x[1]), reverse=True)`)。
type
ToolNameRewrite
struct
{
Forward
map
[
string
]
string
Reverse
map
[
string
]
string
ReverseOrdered
[][
2
]
string
}
// buildDynamicToolMap 构造 tools 的动态假名映射。
//
// 与 Parrot _build_dynamic_tool_map 语义等价:
// - tools 数量 ≤ dynamicToolMapThreshold 时返回 nil(不做动态映射,走静态 fallback)
// - 同一组 tool_names 在同进程内映射稳定(保证 cache 命中)
//
// Parrot 用 `random.Random(hash(tuple(tool_names)))` 作 seed + shuffle 前缀池;
// Go 无法字节级复刻 Python hash,但"稳定性"和"前缀池打散"两个不变量都保留:
// 用 fnv64a(strings.Join(names, "\x00")) 作 seed 喂 math/rand.New。
// 字节级不同不影响上游判定(Anthropic 不会验证我们的随机种子算法)。
func
buildDynamicToolMap
(
toolNames
[]
string
)
map
[
string
]
string
{
if
len
(
toolNames
)
<=
dynamicToolMapThreshold
{
return
nil
}
h
:=
fnv
.
New64a
()
for
i
,
n
:=
range
toolNames
{
if
i
>
0
{
_
,
_
=
h
.
Write
([]
byte
{
0
})
}
_
,
_
=
h
.
Write
([]
byte
(
n
))
}
rng
:=
rand
.
New
(
rand
.
NewSource
(
int64
(
h
.
Sum64
())))
available
:=
make
([]
string
,
len
(
fakeToolNamePrefixes
))
copy
(
available
,
fakeToolNamePrefixes
)
rng
.
Shuffle
(
len
(
available
),
func
(
i
,
j
int
)
{
available
[
i
],
available
[
j
]
=
available
[
j
],
available
[
i
]
})
mapping
:=
make
(
map
[
string
]
string
,
len
(
toolNames
))
for
i
,
name
:=
range
toolNames
{
prefix
:=
available
[
i
%
len
(
available
)]
headLen
:=
3
if
len
(
name
)
<
3
{
headLen
=
len
(
name
)
}
fake
:=
fmt
.
Sprintf
(
"%s%s%02d"
,
prefix
,
name
[
:
headLen
],
i
)
mapping
[
name
]
=
fake
}
return
mapping
}
// sanitizeToolName 把真名转成假名。
// 与 Parrot _sanitize_tool_name 语义一致:动态映射优先,再走静态前缀映射。
func
sanitizeToolName
(
name
string
,
dynamic
map
[
string
]
string
)
string
{
if
dynamic
!=
nil
{
if
fake
,
ok
:=
dynamic
[
name
];
ok
{
return
fake
}
}
for
prefix
,
replacement
:=
range
staticToolNameRewrites
{
if
strings
.
HasPrefix
(
name
,
prefix
)
{
return
replacement
+
name
[
len
(
prefix
)
:
]
}
}
return
name
}
// shouldMimicToolName 指示某个 tool 是否需要重命名。
// server tool(type != "" 且不是 "function" / "custom")是 Anthropic 协议语义的一部分,
// 比如 "web_search_20250305" / "computer_20250124";误改会导致上游拒绝。
func
shouldMimicToolName
(
toolType
string
)
bool
{
if
toolType
==
""
||
toolType
==
"function"
||
toolType
==
"custom"
{
return
true
}
return
false
}
// buildToolNameRewriteFromBody 扫描 body 的 tools[*].name,构造 ToolNameRewrite
// 并返回它。若不需要混淆(tools 数量不足 + 没有匹配静态前缀的工具)返回 nil。
//
// 注意:只扫描,不改 body。真正的 body 改写在 applyToolNameRewriteToBody。
func
buildToolNameRewriteFromBody
(
body
[]
byte
)
*
ToolNameRewrite
{
tools
:=
gjson
.
GetBytes
(
body
,
"tools"
)
if
!
tools
.
IsArray
()
{
return
nil
}
mimicableNames
:=
make
([]
string
,
0
)
toolsArr
:=
tools
.
Array
()
for
_
,
t
:=
range
toolsArr
{
if
!
shouldMimicToolName
(
t
.
Get
(
"type"
)
.
String
())
{
continue
}
name
:=
t
.
Get
(
"name"
)
.
String
()
if
name
==
""
{
continue
}
mimicableNames
=
append
(
mimicableNames
,
name
)
}
dynamic
:=
buildDynamicToolMap
(
mimicableNames
)
rw
:=
&
ToolNameRewrite
{
Forward
:
make
(
map
[
string
]
string
),
Reverse
:
make
(
map
[
string
]
string
),
}
for
_
,
name
:=
range
mimicableNames
{
fake
:=
sanitizeToolName
(
name
,
dynamic
)
if
fake
==
name
{
continue
}
rw
.
Forward
[
name
]
=
fake
rw
.
Reverse
[
fake
]
=
name
}
if
len
(
rw
.
Forward
)
==
0
{
return
nil
}
rw
.
ReverseOrdered
=
make
([][
2
]
string
,
0
,
len
(
rw
.
Reverse
))
for
fake
,
real
:=
range
rw
.
Reverse
{
rw
.
ReverseOrdered
=
append
(
rw
.
ReverseOrdered
,
[
2
]
string
{
fake
,
real
})
}
sort
.
SliceStable
(
rw
.
ReverseOrdered
,
func
(
i
,
j
int
)
bool
{
return
len
(
rw
.
ReverseOrdered
[
i
][
0
])
>
len
(
rw
.
ReverseOrdered
[
j
][
0
])
})
return
rw
}
// applyToolNameRewriteToBody 把已构造的 ToolNameRewrite 应用到 body 上:
// - 改写 $.tools[*].name(仅对 shouldMimicToolName 通过的 tool)
// - 在 $.tools[last].cache_control 上打 ephemeral 缓存断点(Parrot 行为对齐,
// ttl 客户端已有则透传,否则默认 claude.DefaultCacheControlTTL)
// - 改写 $.tool_choice.name(仅当 $.tool_choice.type == "tool")
//
// 历史 $.messages[*].content[*].name(tool_use)不在请求侧改写——这与 Parrot 一致;
// 响应侧 bytes.Replace 会连带还原它们。
func
applyToolNameRewriteToBody
(
body
[]
byte
,
rw
*
ToolNameRewrite
)
[]
byte
{
if
rw
==
nil
||
len
(
rw
.
Forward
)
==
0
{
body
=
applyToolsLastCacheBreakpoint
(
body
)
return
body
}
tools
:=
gjson
.
GetBytes
(
body
,
"tools"
)
if
tools
.
IsArray
()
{
idx
:=
-
1
tools
.
ForEach
(
func
(
_
,
t
gjson
.
Result
)
bool
{
idx
++
if
!
shouldMimicToolName
(
t
.
Get
(
"type"
)
.
String
())
{
return
true
}
name
:=
t
.
Get
(
"name"
)
.
String
()
if
name
==
""
{
return
true
}
fake
,
ok
:=
rw
.
Forward
[
name
]
if
!
ok
{
return
true
}
if
next
,
err
:=
sjson
.
SetBytes
(
body
,
fmt
.
Sprintf
(
"tools.%d.name"
,
idx
),
fake
);
err
==
nil
{
body
=
next
}
return
true
})
}
if
tc
:=
gjson
.
GetBytes
(
body
,
"tool_choice"
);
tc
.
Exists
()
&&
tc
.
Get
(
"type"
)
.
String
()
==
"tool"
{
name
:=
tc
.
Get
(
"name"
)
.
String
()
if
fake
,
ok
:=
rw
.
Forward
[
name
];
ok
{
if
next
,
err
:=
sjson
.
SetBytes
(
body
,
"tool_choice.name"
,
fake
);
err
==
nil
{
body
=
next
}
}
}
body
=
applyToolsLastCacheBreakpoint
(
body
)
return
body
}
// applyToolsLastCacheBreakpoint 在 tools 数组最后一个工具上注入 cache_control
// 断点,对齐 Parrot `tools[-1]["cache_control"] = {"type":"ephemeral","ttl":"1h"}`
// 行为,但 ttl 按本仓规则:
// - 客户端已为该 tool 显式设置 cache_control.ttl → 完全透传不覆盖
// - 否则注入 {"type":"ephemeral","ttl": claude.DefaultCacheControlTTL}
//
// 纯副作用函数,tools 不存在或为空数组时 no-op。
func
applyToolsLastCacheBreakpoint
(
body
[]
byte
)
[]
byte
{
tools
:=
gjson
.
GetBytes
(
body
,
"tools"
)
if
!
tools
.
IsArray
()
{
return
body
}
arr
:=
tools
.
Array
()
if
len
(
arr
)
==
0
{
return
body
}
lastIdx
:=
len
(
arr
)
-
1
existingCC
:=
arr
[
lastIdx
]
.
Get
(
"cache_control"
)
if
existingCC
.
Exists
()
&&
existingCC
.
Get
(
"ttl"
)
.
String
()
!=
""
{
return
body
}
if
existingCC
.
Exists
()
{
if
next
,
err
:=
sjson
.
SetBytes
(
body
,
fmt
.
Sprintf
(
"tools.%d.cache_control.ttl"
,
lastIdx
),
claude
.
DefaultCacheControlTTL
);
err
==
nil
{
body
=
next
}
return
body
}
raw
:=
fmt
.
Sprintf
(
`{"type":"ephemeral","ttl":%q}`
,
claude
.
DefaultCacheControlTTL
)
if
next
,
err
:=
sjson
.
SetRawBytes
(
body
,
fmt
.
Sprintf
(
"tools.%d.cache_control"
,
lastIdx
),
[]
byte
(
raw
));
err
==
nil
{
body
=
next
}
return
body
}
// restoreToolNamesInBytes 对 bytes chunk 做逆向还原:假名 → 真名。
// 按 ReverseOrdered 的假名长度倒序逐个 bytes.Replace,防止子串冲突
// (与 Parrot _restore_tool_names_in_chunk 的 sorted(..., reverse=True) 等价)。
// 再做静态前缀还原(cc_sess_ → sessions_ / cc_ses_ → session_)。
//
// rw 可为 nil;nil 时仍会做静态前缀还原。
func
restoreToolNamesInBytes
(
data
[]
byte
,
rw
*
ToolNameRewrite
)
[]
byte
{
if
rw
!=
nil
{
for
_
,
pair
:=
range
rw
.
ReverseOrdered
{
fake
,
real
:=
pair
[
0
],
pair
[
1
]
if
fake
==
""
||
fake
==
real
{
continue
}
data
=
replaceAllBytes
(
data
,
fake
,
real
)
}
}
for
prefix
,
replacement
:=
range
staticToolNameRewrites
{
data
=
replaceAllBytes
(
data
,
replacement
,
prefix
)
}
return
data
}
// replaceAllBytes 是 bytes.ReplaceAll 的便捷封装,避免每个调用点各自做 []byte 转换。
func
replaceAllBytes
(
data
[]
byte
,
from
,
to
string
)
[]
byte
{
if
len
(
data
)
==
0
||
from
==
to
||
!
strings
.
Contains
(
string
(
data
),
from
)
{
return
data
}
return
[]
byte
(
strings
.
ReplaceAll
(
string
(
data
),
from
,
to
))
}
// toolNameRewriteFromContext 从 gin.Context 取出请求阶段保存的工具名映射。
// 找不到(c==nil 或 key 不存在或类型不对)时返回 nil;调用方必须能处理 nil。
func
toolNameRewriteFromContext
(
c
interface
{
Get
(
string
)
(
any
,
bool
)
})
*
ToolNameRewrite
{
if
c
==
nil
{
return
nil
}
raw
,
ok
:=
c
.
Get
(
toolNameRewriteKey
)
if
!
ok
||
raw
==
nil
{
return
nil
}
rw
,
_
:=
raw
.
(
*
ToolNameRewrite
)
return
rw
}
// reverseToolNamesIfPresent 是响应侧 5 处注入点的统一封装:从 c 取出 mapping
// 并对 chunk 做 bytes 级假名→真名替换。c 没有 mapping 时仍会做静态前缀还原。
func
reverseToolNamesIfPresent
(
c
interface
{
Get
(
string
)
(
any
,
bool
)
},
chunk
[]
byte
)
[]
byte
{
rw
:=
toolNameRewriteFromContext
(
c
)
if
rw
==
nil
&&
len
(
staticToolNameRewrites
)
==
0
{
return
chunk
}
return
restoreToolNamesInBytes
(
chunk
,
rw
)
}
backend/internal/service/gateway_tool_rewrite_test.go
0 → 100644
View file @
6d20ab80
package
service
import
(
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func
TestBuildDynamicToolMap_BelowThreshold
(
t
*
testing
.
T
)
{
// Parrot 行为:tools 数量 ≤ 5 时不做动态映射。
names
:=
[]
string
{
"bash"
,
"edit"
,
"read"
,
"write"
,
"search"
}
require
.
Nil
(
t
,
buildDynamicToolMap
(
names
))
}
func
TestBuildDynamicToolMap_AboveThresholdIsStable
(
t
*
testing
.
T
)
{
// Parrot 不变量:同一组 tool_names 在同进程内映射稳定(保证 cache 命中)。
names
:=
[]
string
{
"alpha"
,
"beta"
,
"gamma"
,
"delta"
,
"epsilon"
,
"zeta"
}
a
:=
buildDynamicToolMap
(
names
)
b
:=
buildDynamicToolMap
(
names
)
require
.
NotNil
(
t
,
a
)
require
.
Equal
(
t
,
a
,
b
,
"same input tool_names must yield identical mapping"
)
require
.
Len
(
t
,
a
,
6
)
for
_
,
name
:=
range
names
{
require
.
Contains
(
t
,
a
,
name
)
require
.
NotEqual
(
t
,
name
,
a
[
name
])
}
}
func
TestSanitizeToolName_StaticPrefix
(
t
*
testing
.
T
)
{
require
.
Equal
(
t
,
"cc_sess_list"
,
sanitizeToolName
(
"sessions_list"
,
nil
))
require
.
Equal
(
t
,
"cc_ses_get"
,
sanitizeToolName
(
"session_get"
,
nil
))
require
.
Equal
(
t
,
"bash"
,
sanitizeToolName
(
"bash"
,
nil
))
}
func
TestSanitizeToolName_DynamicTakesPrecedence
(
t
*
testing
.
T
)
{
dyn
:=
map
[
string
]
string
{
"sessions_list"
:
"analyze_ses00"
}
got
:=
sanitizeToolName
(
"sessions_list"
,
dyn
)
require
.
Equal
(
t
,
"analyze_ses00"
,
got
,
"dynamic mapping wins over static prefix"
)
}
func
TestRestoreToolNamesInBytes_LongestFirst
(
t
*
testing
.
T
)
{
// 当假名 "abc_12" 是另一个更长假名的子串(真实场景极少但算法必须防御)时,
// 长的必须先替换。本测试用显式构造的映射来验证排序不变量。
rw
:=
&
ToolNameRewrite
{
Forward
:
map
[
string
]
string
{
"foo"
:
"abc_12"
,
"bar"
:
"abc_12_ext"
},
Reverse
:
map
[
string
]
string
{
"abc_12"
:
"foo"
,
"abc_12_ext"
:
"bar"
},
}
// 手工构造 ReverseOrdered:长的在前
rw
.
ReverseOrdered
=
[][
2
]
string
{
{
"abc_12_ext"
,
"bar"
},
{
"abc_12"
,
"foo"
},
}
data
:=
[]
byte
(
`{"tool":"abc_12_ext","other":"abc_12"}`
)
restored
:=
string
(
restoreToolNamesInBytes
(
data
,
rw
))
require
.
Equal
(
t
,
`{"tool":"bar","other":"foo"}`
,
restored
)
}
func
TestRestoreToolNamesInBytes_StaticPrefixRollback
(
t
*
testing
.
T
)
{
data
:=
[]
byte
(
`{"name":"sessions_list","id":"cc_ses_xyz"}`
)
got
:=
string
(
restoreToolNamesInBytes
(
data
,
nil
))
require
.
Equal
(
t
,
`{"name":"sessions_list","id":"session_xyz"}`
,
got
)
}
func
TestApplyToolNameRewriteToBody_RenamesToolsAndToolChoice
(
t
*
testing
.
T
)
{
body
:=
[]
byte
(
`{"tools":[{"name":"sessions_list","input_schema":{}},{"name":"session_get","input_schema":{}},{"name":"web_search","type":"web_search_20250305"}],"tool_choice":{"type":"tool","name":"sessions_list"}}`
)
rw
:=
buildToolNameRewriteFromBody
(
body
)
require
.
NotNil
(
t
,
rw
)
require
.
Contains
(
t
,
rw
.
Forward
,
"sessions_list"
)
require
.
Contains
(
t
,
rw
.
Forward
,
"session_get"
)
// web_search is a server tool, not rewritten
require
.
NotContains
(
t
,
rw
.
Forward
,
"web_search"
)
out
:=
applyToolNameRewriteToBody
(
body
,
rw
)
// tools[0].name and tools[1].name rewritten; tools[2].name untouched
require
.
Equal
(
t
,
"cc_sess_list"
,
gjson
.
GetBytes
(
out
,
"tools.0.name"
)
.
String
())
require
.
Equal
(
t
,
"cc_ses_get"
,
gjson
.
GetBytes
(
out
,
"tools.1.name"
)
.
String
())
require
.
Equal
(
t
,
"web_search"
,
gjson
.
GetBytes
(
out
,
"tools.2.name"
)
.
String
())
// tool_choice.name rewritten
require
.
Equal
(
t
,
"cc_sess_list"
,
gjson
.
GetBytes
(
out
,
"tool_choice.name"
)
.
String
())
require
.
Equal
(
t
,
"tool"
,
gjson
.
GetBytes
(
out
,
"tool_choice.type"
)
.
String
())
}
func
TestApplyToolsLastCacheBreakpoint_InjectsDefault
(
t
*
testing
.
T
)
{
body
:=
[]
byte
(
`{"tools":[{"name":"a","input_schema":{}},{"name":"b","input_schema":{}}]}`
)
out
:=
applyToolsLastCacheBreakpoint
(
body
)
require
.
Equal
(
t
,
"ephemeral"
,
gjson
.
GetBytes
(
out
,
"tools.1.cache_control.type"
)
.
String
())
require
.
Equal
(
t
,
"5m"
,
gjson
.
GetBytes
(
out
,
"tools.1.cache_control.ttl"
)
.
String
())
// First tool untouched
require
.
False
(
t
,
gjson
.
GetBytes
(
out
,
"tools.0.cache_control"
)
.
Exists
())
}
func
TestApplyToolsLastCacheBreakpoint_PassesThroughClientTTL
(
t
*
testing
.
T
)
{
body
:=
[]
byte
(
`{"tools":[{"name":"a","input_schema":{},"cache_control":{"type":"ephemeral","ttl":"1h"}}]}`
)
out
:=
applyToolsLastCacheBreakpoint
(
body
)
// User-provided ttl must be preserved.
require
.
Equal
(
t
,
"1h"
,
gjson
.
GetBytes
(
out
,
"tools.0.cache_control.ttl"
)
.
String
())
}
func
TestStripMessageCacheControl
(
t
*
testing
.
T
)
{
body
:=
[]
byte
(
`{"messages":[{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral"}}]}]}`
)
out
:=
stripMessageCacheControl
(
body
)
require
.
False
(
t
,
gjson
.
GetBytes
(
out
,
"messages.0.content.0.cache_control"
)
.
Exists
())
}
func
TestAddMessageCacheBreakpoints_LastMessageOnly
(
t
*
testing
.
T
)
{
body
:=
[]
byte
(
`{"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`
)
out
:=
addMessageCacheBreakpoints
(
body
)
require
.
Equal
(
t
,
"ephemeral"
,
gjson
.
GetBytes
(
out
,
"messages.0.content.0.cache_control.type"
)
.
String
())
require
.
Equal
(
t
,
"5m"
,
gjson
.
GetBytes
(
out
,
"messages.0.content.0.cache_control.ttl"
)
.
String
())
}
func
TestAddMessageCacheBreakpoints_SecondToLastUserTurn
(
t
*
testing
.
T
)
{
// Parrot 不变量:messages ≥ 4 时才打第二个断点,且位置是"倒数第二个 user turn"。
body
:=
[]
byte
(
`{"messages":[
{"role":"user","content":[{"type":"text","text":"q1"}]},
{"role":"assistant","content":[{"type":"text","text":"a1"}]},
{"role":"user","content":[{"type":"text","text":"q2"}]},
{"role":"assistant","content":[{"type":"text","text":"a2"}]}
]}`
)
out
:=
addMessageCacheBreakpoints
(
body
)
// 最后一条 assistant 被打断点
require
.
Equal
(
t
,
"ephemeral"
,
gjson
.
GetBytes
(
out
,
"messages.3.content.0.cache_control.type"
)
.
String
())
// 倒数第二个 user turn = index 0(唯一另一个 user)
require
.
Equal
(
t
,
"ephemeral"
,
gjson
.
GetBytes
(
out
,
"messages.0.content.0.cache_control.type"
)
.
String
())
// 其他不打断点
require
.
False
(
t
,
gjson
.
GetBytes
(
out
,
"messages.1.content.0.cache_control"
)
.
Exists
())
require
.
False
(
t
,
gjson
.
GetBytes
(
out
,
"messages.2.content.0.cache_control"
)
.
Exists
())
}
func
TestAddMessageCacheBreakpoints_StringContentPromoted
(
t
*
testing
.
T
)
{
body
:=
[]
byte
(
`{"messages":[{"role":"user","content":"hi"}]}`
)
out
:=
addMessageCacheBreakpoints
(
body
)
// content 升级成数组
require
.
True
(
t
,
gjson
.
GetBytes
(
out
,
"messages.0.content"
)
.
IsArray
())
require
.
Equal
(
t
,
"text"
,
gjson
.
GetBytes
(
out
,
"messages.0.content.0.type"
)
.
String
())
require
.
Equal
(
t
,
"hi"
,
gjson
.
GetBytes
(
out
,
"messages.0.content.0.text"
)
.
String
())
require
.
Equal
(
t
,
"5m"
,
gjson
.
GetBytes
(
out
,
"messages.0.content.0.cache_control.ttl"
)
.
String
())
}
func
TestBuildToolNameRewriteFromBody_ReverseOrderedByLengthDesc
(
t
*
testing
.
T
)
{
// 超过阈值触发动态映射,验证 ReverseOrdered 按假名长度倒序排列
body
:=
[]
byte
(
`{"tools":[
{"name":"t1","input_schema":{}},
{"name":"t2","input_schema":{}},
{"name":"t3","input_schema":{}},
{"name":"t4","input_schema":{}},
{"name":"t5","input_schema":{}},
{"name":"t6","input_schema":{}}
]}`
)
rw
:=
buildToolNameRewriteFromBody
(
body
)
require
.
NotNil
(
t
,
rw
)
require
.
NotEmpty
(
t
,
rw
.
ReverseOrdered
)
for
i
:=
1
;
i
<
len
(
rw
.
ReverseOrdered
);
i
++
{
require
.
GreaterOrEqual
(
t
,
len
(
rw
.
ReverseOrdered
[
i
-
1
][
0
]),
len
(
rw
.
ReverseOrdered
[
i
][
0
]),
"ReverseOrdered must be sorted by fake-name length descending"
)
}
}
func
TestRestoreToolNamesInBytes_NoMapping_NoStaticMatch_IsNoop
(
t
*
testing
.
T
)
{
data
:=
[]
byte
(
"plain text without any tool names"
)
require
.
Equal
(
t
,
string
(
data
),
string
(
restoreToolNamesInBytes
(
data
,
nil
)))
}
// Ensure the fake name format follows Parrot's "{prefix}{name[:3]}{i:02d}".
func
TestBuildDynamicToolMap_FakeNameShape
(
t
*
testing
.
T
)
{
names
:=
[]
string
{
"alphabet"
,
"bravo"
,
"charlie"
,
"delta"
,
"echo"
,
"foxtrot"
}
m
:=
buildDynamicToolMap
(
names
)
require
.
NotNil
(
t
,
m
)
for
_
,
name
:=
range
names
{
fake
,
ok
:=
m
[
name
]
require
.
True
(
t
,
ok
)
// fake = prefix + head3 + "%02d"
// ends with two decimal digits
require
.
Regexp
(
t
,
`^[a-z]+_[a-z0-9]{1,3}\d{2}$`
,
fake
)
head
:=
name
if
len
(
head
)
>
3
{
head
=
head
[
:
3
]
}
require
.
True
(
t
,
strings
.
Contains
(
fake
,
head
),
"fake %q should contain head3 %q of %q"
,
fake
,
head
,
name
)
}
}
backend/internal/service/identity_service.go
View file @
6d20ab80
...
@@ -26,7 +26,7 @@ var (
...
@@ -26,7 +26,7 @@ var (
// 默认指纹值(当客户端未提供时使用)
// 默认指纹值(当客户端未提供时使用)
var
defaultFingerprint
=
Fingerprint
{
var
defaultFingerprint
=
Fingerprint
{
UserAgent
:
"claude-cli/2.1.
2
2 (external, cli)"
,
UserAgent
:
"claude-cli/2.1.
9
2 (external, cli)"
,
StainlessLang
:
"js"
,
StainlessLang
:
"js"
,
StainlessPackageVersion
:
"0.70.0"
,
StainlessPackageVersion
:
"0.70.0"
,
StainlessOS
:
"Linux"
,
StainlessOS
:
"Linux"
,
...
...
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