fix(gateway): apply full Claude Code mimicry on /chat/completions and /responses
Before: the OpenAI-compat forwarders only called injectClaudeCodePrompt,
which prepends the Claude Code banner but leaves the rest of the body
in its original non-Claude-Code shape. The codebase already admits this
is insufficient (see the comment on rewriteSystemForNonClaudeCode in
gateway_service.go: "仅前置追加 Claude Code 提示词无法通过检测").
Effect: OAuth accounts served through /v1/chat/completions or /v1/responses
were detected as third-party apps and bled plan quota with:
Third-party apps now draw from your extra usage, not your plan limits.
Fix:
- apicompat.AnthropicRequest: add Metadata json.RawMessage so metadata
survives the OpenAI->Anthropic->Marshal round trip; without it the
downstream rewrite has no user_id to work with.
- service: extract applyClaudeCodeOAuthMimicryToBody, a ParsedRequest-free
variant of the /v1/messages mimicry pipeline
(rewriteSystemForNonClaudeCode + normalizeClaudeOAuthRequestBody +
metadata.user_id injection) so the OpenAI-compat forwarders can reuse it.
- service: add buildOAuthMetadataUserIDFromBody + hashBodyForSessionSeed
for the same reason (no ParsedRequest at the call site).
- ForwardAsChatCompletions / ForwardAsResponses: replace the 3-line
prompt-prepend with the full mimicry pipeline.
- applyClaudeCodeMimicHeaders: set x-client-request-id per-request
(real Claude CLI always does); missing/duplicated values are one more
third-party fingerprint signal.
No change to the native /v1/messages path: it already called the full
pipeline, we only lift those helpers into a reusable function.
Tests:
- go build ./... passes
- go test ./internal/service/... ./internal/pkg/apicompat/... passes
- lsp_diagnostics clean on all touched files
- pre-existing failures in internal/config are unrelated (env-sensitive
tests that also fail on upstream main)
This commit is contained in:
@@ -12,17 +12,23 @@ import "encoding/json"
|
||||
|
||||
// AnthropicRequest is the request body for POST /v1/messages.
|
||||
type AnthropicRequest struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
System json.RawMessage `json:"system,omitempty"` // string or []AnthropicContentBlock
|
||||
Messages []AnthropicMessage `json:"messages"`
|
||||
Tools []AnthropicTool `json:"tools,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
StopSeqs []string `json:"stop_sequences,omitempty"`
|
||||
Thinking *AnthropicThinking `json:"thinking,omitempty"`
|
||||
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
System json.RawMessage `json:"system,omitempty"` // string or []AnthropicContentBlock
|
||||
Messages []AnthropicMessage `json:"messages"`
|
||||
Tools []AnthropicTool `json:"tools,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
StopSeqs []string `json:"stop_sequences,omitempty"`
|
||||
Thinking *AnthropicThinking `json:"thinking,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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -85,15 +85,16 @@ func (s *GatewayService) ForwardAsChatCompletions(
|
||||
return nil, fmt.Errorf("marshal anthropic request: %w", err)
|
||||
}
|
||||
|
||||
// 6. Apply Claude Code mimicry for OAuth accounts
|
||||
isClaudeCode := false // CC API is never Claude Code
|
||||
// 6. Apply Claude Code mimicry for OAuth accounts.
|
||||
// Chat Completions 协议进来的请求永远不是 Claude Code 客户端,所以对 OAuth 账号
|
||||
// 必须完整执行 /v1/messages 主路径上的伪装链路(system 重写 + normalize + metadata 注入),
|
||||
// 否则会被 Anthropic 判为第三方应用并扣 extra usage。
|
||||
// 见 applyClaudeCodeOAuthMimicryToBody 的 godoc。
|
||||
isClaudeCode := false
|
||||
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
|
||||
|
||||
if shouldMimicClaudeCode {
|
||||
if !strings.Contains(strings.ToLower(mappedModel), "haiku") &&
|
||||
!systemIncludesClaudeCodePrompt(anthropicReq.System) {
|
||||
anthropicBody = injectClaudeCodePrompt(anthropicBody, anthropicReq.System)
|
||||
}
|
||||
anthropicBody = s.applyClaudeCodeOAuthMimicryToBody(ctx, c, account, anthropicBody, anthropicReq.System, mappedModel)
|
||||
}
|
||||
|
||||
// 7. Enforce cache_control block limit
|
||||
|
||||
@@ -82,15 +82,16 @@ func (s *GatewayService) ForwardAsResponses(
|
||||
return nil, fmt.Errorf("marshal anthropic request: %w", err)
|
||||
}
|
||||
|
||||
// 6. Apply Claude Code mimicry for OAuth accounts (non-Claude-Code endpoints)
|
||||
isClaudeCode := false // Responses API is never Claude Code
|
||||
// 6. Apply Claude Code mimicry for OAuth accounts (non-Claude-Code endpoints).
|
||||
// OpenAI Responses 协议进来的请求永远不是 Claude Code 客户端,所以对 OAuth 账号
|
||||
// 必须完整执行 /v1/messages 主路径上的伪装链路(system 重写 + normalize + metadata 注入),
|
||||
// 否则会被 Anthropic 判为第三方应用并扣 extra usage。
|
||||
// 见 applyClaudeCodeOAuthMimicryToBody 的 godoc。
|
||||
isClaudeCode := false
|
||||
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
|
||||
|
||||
if shouldMimicClaudeCode {
|
||||
if !strings.Contains(strings.ToLower(mappedModel), "haiku") &&
|
||||
!systemIncludesClaudeCodePrompt(anthropicReq.System) {
|
||||
anthropicBody = injectClaudeCodePrompt(anthropicBody, anthropicReq.System)
|
||||
}
|
||||
anthropicBody = s.applyClaudeCodeOAuthMimicryToBody(ctx, c, account, anthropicBody, anthropicReq.System, mappedModel)
|
||||
}
|
||||
|
||||
// 7. Enforce cache_control block limit
|
||||
|
||||
@@ -1128,6 +1128,117 @@ func (s *GatewayService) buildOAuthMetadataUserID(parsed *ParsedRequest, account
|
||||
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") &&
|
||||
!systemIncludesClaudeCodePrompt(systemRaw) {
|
||||
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)
|
||||
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.
|
||||
func GenerateSessionUUID(seed string) string {
|
||||
return generateSessionUUID(seed)
|
||||
@@ -6099,6 +6210,11 @@ func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) {
|
||||
if isStream {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user