mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-05-05 05:30:44 +08:00
fix: 修复非CC客户端OAuth伪装被Anthropic检测为第三方应用的问题
commit f3aa54b 的 rewriteSystemForNonClaudeCode 未能通过 Anthropic 第三方检测,
根因是两个关键信号与真实 Claude Code 不一致:
1. anthropic-beta 头缺少 claude-code-20250219:伪装路径主动将该 beta
加入 drop set 并移除,但 Anthropic 依赖此 beta 识别 Claude Code 请求。
修复:非 haiku 模型的伪装请求强制包含 claude-code beta。
2. system 字段使用 string 格式而非 array+cache_control:真实 Claude Code
始终以 [{type,text,cache_control:{type:"ephemeral"}}] 发送 system,
string 格式成为第三方检测信号。
修复:rewriteSystemForNonClaudeCode 改为注入 array 格式。
附带调整:stripSystemCacheControl 按 system 是否被重写动态决定,
重写时保留 CC prompt 的 cache_control,未重写时(haiku/已含CC前缀)
保持原有剥离行为。
This commit is contained in:
@@ -761,7 +761,9 @@ 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.Equal(t, claudeCodeSystemPrompt, system.String())
|
require.True(t, system.IsArray(), "system should be an array")
|
||||||
|
require.Equal(t, claudeCodeSystemPrompt, system.Array()[0].Get("text").String())
|
||||||
|
require.Equal(t, "ephemeral", system.Array()[0].Get("cache_control.type").String())
|
||||||
|
|
||||||
// 原始 system prompt 应迁移至 messages 中
|
// 原始 system prompt 应迁移至 messages 中
|
||||||
messages := gjson.GetBytes(upstream.lastBody, "messages")
|
messages := gjson.GetBytes(upstream.lastBody, "messages")
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
body string
|
body string
|
||||||
system any
|
system any
|
||||||
wantSystemStr string // system 应为纯字符串
|
wantSystemText string // system array 第一个 block 的 text
|
||||||
wantMessagesLen int // messages 数组长度
|
wantMessagesLen int // messages 数组长度
|
||||||
wantFirstMsgRole string // 第一条消息的 role
|
wantFirstMsgRole string // 第一条消息的 role
|
||||||
wantFirstMsgText string // 第一条消息的 content[0].text
|
wantFirstMsgText string // 第一条消息的 content[0].text
|
||||||
@@ -294,21 +294,21 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) {
|
|||||||
name: "nil system - no messages injected",
|
name: "nil system - no messages injected",
|
||||||
body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
|
body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
|
||||||
system: nil,
|
system: nil,
|
||||||
wantSystemStr: claudeCodeSystemPrompt,
|
wantSystemText: claudeCodeSystemPrompt,
|
||||||
wantMessagesLen: 1, // 原始 1 条消息,不注入
|
wantMessagesLen: 1, // 原始 1 条消息,不注入
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty string system - no messages injected",
|
name: "empty string system - no messages injected",
|
||||||
body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
|
body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
|
||||||
system: "",
|
system: "",
|
||||||
wantSystemStr: claudeCodeSystemPrompt,
|
wantSystemText: claudeCodeSystemPrompt,
|
||||||
wantMessagesLen: 1,
|
wantMessagesLen: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "custom string system - migrated to messages",
|
name: "custom string system - migrated to messages",
|
||||||
body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
|
body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
|
||||||
system: "You are a personal assistant running inside OpenClaw.",
|
system: "You are a personal assistant running inside OpenClaw.",
|
||||||
wantSystemStr: claudeCodeSystemPrompt,
|
wantSystemText: claudeCodeSystemPrompt,
|
||||||
wantMessagesLen: 3, // instruction + ack + original
|
wantMessagesLen: 3, // instruction + ack + original
|
||||||
wantFirstMsgRole: "user",
|
wantFirstMsgRole: "user",
|
||||||
wantFirstMsgText: "[System Instructions]\nYou are a personal assistant running inside OpenClaw.",
|
wantFirstMsgText: "[System Instructions]\nYou are a personal assistant running inside OpenClaw.",
|
||||||
@@ -318,7 +318,7 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) {
|
|||||||
name: "system equals Claude Code prompt - no messages injected",
|
name: "system equals Claude Code prompt - no messages injected",
|
||||||
body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
|
body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
|
||||||
system: claudeCodeSystemPrompt,
|
system: claudeCodeSystemPrompt,
|
||||||
wantSystemStr: claudeCodeSystemPrompt,
|
wantSystemText: claudeCodeSystemPrompt,
|
||||||
wantMessagesLen: 1,
|
wantMessagesLen: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -328,7 +328,7 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) {
|
|||||||
map[string]any{"type": "text", "text": "First instruction"},
|
map[string]any{"type": "text", "text": "First instruction"},
|
||||||
map[string]any{"type": "text", "text": "Second instruction"},
|
map[string]any{"type": "text", "text": "Second instruction"},
|
||||||
},
|
},
|
||||||
wantSystemStr: claudeCodeSystemPrompt,
|
wantSystemText: claudeCodeSystemPrompt,
|
||||||
wantMessagesLen: 3,
|
wantMessagesLen: 3,
|
||||||
wantFirstMsgRole: "user",
|
wantFirstMsgRole: "user",
|
||||||
wantFirstMsgText: "[System Instructions]\nFirst instruction\n\nSecond instruction",
|
wantFirstMsgText: "[System Instructions]\nFirst instruction\n\nSecond instruction",
|
||||||
@@ -338,14 +338,14 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) {
|
|||||||
name: "empty array system - no messages injected",
|
name: "empty array system - no messages injected",
|
||||||
body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
|
body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
|
||||||
system: []any{},
|
system: []any{},
|
||||||
wantSystemStr: claudeCodeSystemPrompt,
|
wantSystemText: claudeCodeSystemPrompt,
|
||||||
wantMessagesLen: 1,
|
wantMessagesLen: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "json.RawMessage string system",
|
name: "json.RawMessage string system",
|
||||||
body: `{"model":"claude-3","system":"Custom prompt","messages":[{"role":"user","content":"hello"}]}`,
|
body: `{"model":"claude-3","system":"Custom prompt","messages":[{"role":"user","content":"hello"}]}`,
|
||||||
system: json.RawMessage(`"Custom prompt"`),
|
system: json.RawMessage(`"Custom prompt"`),
|
||||||
wantSystemStr: claudeCodeSystemPrompt,
|
wantSystemText: claudeCodeSystemPrompt,
|
||||||
wantMessagesLen: 3,
|
wantMessagesLen: 3,
|
||||||
wantFirstMsgRole: "user",
|
wantFirstMsgRole: "user",
|
||||||
wantFirstMsgText: "[System Instructions]\nCustom prompt",
|
wantFirstMsgText: "[System Instructions]\nCustom prompt",
|
||||||
@@ -355,14 +355,14 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) {
|
|||||||
name: "json.RawMessage nil system",
|
name: "json.RawMessage nil system",
|
||||||
body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
|
body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
|
||||||
system: json.RawMessage(nil),
|
system: json.RawMessage(nil),
|
||||||
wantSystemStr: claudeCodeSystemPrompt,
|
wantSystemText: claudeCodeSystemPrompt,
|
||||||
wantMessagesLen: 1,
|
wantMessagesLen: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multiple original messages preserved",
|
name: "multiple original messages preserved",
|
||||||
body: `{"model":"claude-3","messages":[{"role":"user","content":"msg1"},{"role":"assistant","content":"resp1"},{"role":"user","content":"msg2"}]}`,
|
body: `{"model":"claude-3","messages":[{"role":"user","content":"msg1"},{"role":"assistant","content":"resp1"},{"role":"user","content":"msg2"}]}`,
|
||||||
system: "Be helpful",
|
system: "Be helpful",
|
||||||
wantSystemStr: claudeCodeSystemPrompt,
|
wantSystemText: claudeCodeSystemPrompt,
|
||||||
wantMessagesLen: 5, // 2 injected + 3 original
|
wantMessagesLen: 5, // 2 injected + 3 original
|
||||||
wantFirstMsgRole: "user",
|
wantFirstMsgRole: "user",
|
||||||
wantFirstMsgText: "[System Instructions]\nBe helpful",
|
wantFirstMsgText: "[System Instructions]\nBe helpful",
|
||||||
@@ -378,10 +378,17 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) {
|
|||||||
err := json.Unmarshal(result, &parsed)
|
err := json.Unmarshal(result, &parsed)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// system 应为纯字符串
|
// system 应为 array 格式: [{type: "text", text: "...", cache_control: {type: "ephemeral"}}]
|
||||||
systemVal, ok := parsed["system"].(string)
|
systemArr, ok := parsed["system"].([]any)
|
||||||
require.True(t, ok, "system should be a string, got %T", parsed["system"])
|
require.True(t, ok, "system should be an array, got %T", parsed["system"])
|
||||||
require.Equal(t, tt.wantSystemStr, systemVal)
|
require.Len(t, systemArr, 1, "system array should have exactly 1 block")
|
||||||
|
systemBlock, ok := systemArr[0].(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "text", systemBlock["type"])
|
||||||
|
require.Equal(t, tt.wantSystemText, systemBlock["text"])
|
||||||
|
cc, ok := systemBlock["cache_control"].(map[string]any)
|
||||||
|
require.True(t, ok, "system block should have cache_control")
|
||||||
|
require.Equal(t, "ephemeral", cc["type"])
|
||||||
|
|
||||||
// 检查 messages
|
// 检查 messages
|
||||||
messages, ok := parsed["messages"].([]any)
|
messages, ok := parsed["messages"].([]any)
|
||||||
|
|||||||
@@ -3739,8 +3739,17 @@ func rewriteSystemForNonClaudeCode(body []byte, system any) []byte {
|
|||||||
originalSystemText = strings.Join(parts, "\n\n")
|
originalSystemText = strings.Join(parts, "\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 将 system 替换为 Claude Code 标准提示词(纯字符串,通过 Anthropic 检测)
|
// 2. 将 system 替换为 Claude Code 标准提示词(array 格式,与真实 Claude Code 一致)
|
||||||
out, ok := setJSONValueBytes(body, "system", claudeCodeSystemPrompt)
|
// 真实 Claude Code 始终以 [{type: "text", text: "...", cache_control: {type: "ephemeral"}}] 发送 system。
|
||||||
|
// 使用 string 格式会被 Anthropic 检测为第三方应用。
|
||||||
|
claudeCodeSystemBlock := []map[string]any{
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": claudeCodeSystemPrompt,
|
||||||
|
"cache_control": map[string]string{"type": "ephemeral"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out, ok := setJSONValueBytes(body, "system", claudeCodeSystemBlock)
|
||||||
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
|
||||||
@@ -3978,12 +3987,17 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
|||||||
if shouldMimicClaudeCode {
|
if shouldMimicClaudeCode {
|
||||||
// 非 Claude Code 客户端:将 system 替换为 Claude Code 标识,原始 system 迁移至 messages
|
// 非 Claude Code 客户端:将 system 替换为 Claude Code 标识,原始 system 迁移至 messages
|
||||||
// 条件:1) OAuth/SetupToken 账号 2) 不是 Claude Code 客户端 3) 不是 Haiku 模型 4) system 中还没有 Claude Code 提示词
|
// 条件:1) OAuth/SetupToken 账号 2) 不是 Claude Code 客户端 3) 不是 Haiku 模型 4) system 中还没有 Claude Code 提示词
|
||||||
|
systemRewritten := false
|
||||||
if !strings.Contains(strings.ToLower(reqModel), "haiku") &&
|
if !strings.Contains(strings.ToLower(reqModel), "haiku") &&
|
||||||
!systemIncludesClaudeCodePrompt(parsed.System) {
|
!systemIncludesClaudeCodePrompt(parsed.System) {
|
||||||
body = rewriteSystemForNonClaudeCode(body, parsed.System)
|
body = rewriteSystemForNonClaudeCode(body, parsed.System)
|
||||||
|
systemRewritten = true
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true}
|
// system 被重写时保留 CC prompt 的 cache_control: ephemeral(匹配真实 Claude Code 行为);
|
||||||
|
// 未重写时(haiku / 已含 CC 前缀)剥离客户端 cache_control,与原有行为一致。
|
||||||
|
// 两种情况下 enforceCacheControlLimit 都会兜底处理上限。
|
||||||
|
normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten}
|
||||||
if s.identityService != nil {
|
if s.identityService != nil {
|
||||||
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header)
|
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header)
|
||||||
if err == nil && fp != nil {
|
if err == nil && fp != nil {
|
||||||
@@ -5605,7 +5619,6 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
|
|||||||
// Build effective drop set: merge static defaults with dynamic beta policy filter rules
|
// Build effective drop set: merge static defaults with dynamic beta policy filter rules
|
||||||
policyFilterSet := s.getBetaPolicyFilterSet(ctx, c, account, modelID)
|
policyFilterSet := s.getBetaPolicyFilterSet(ctx, c, account, modelID)
|
||||||
effectiveDropSet := mergeDropSets(policyFilterSet)
|
effectiveDropSet := mergeDropSets(policyFilterSet)
|
||||||
effectiveDropWithClaudeCodeSet := mergeDropSets(policyFilterSet, claude.BetaClaudeCode)
|
|
||||||
|
|
||||||
// 处理 anthropic-beta header(OAuth 账号需要包含 oauth beta)
|
// 处理 anthropic-beta header(OAuth 账号需要包含 oauth beta)
|
||||||
if tokenType == "oauth" {
|
if tokenType == "oauth" {
|
||||||
@@ -5616,11 +5629,16 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
|
|||||||
applyClaudeCodeMimicHeaders(req, reqStream)
|
applyClaudeCodeMimicHeaders(req, reqStream)
|
||||||
|
|
||||||
incomingBeta := getHeaderRaw(req.Header, "anthropic-beta")
|
incomingBeta := getHeaderRaw(req.Header, "anthropic-beta")
|
||||||
// Match real Claude CLI traffic (per mitmproxy reports):
|
// Claude Code OAuth credentials are scoped to Claude Code.
|
||||||
// messages requests typically use only oauth + interleaved-thinking.
|
// Non-haiku models MUST include claude-code beta for Anthropic to recognize
|
||||||
// Also drop claude-code beta if a downstream client added it.
|
// this as a legitimate Claude Code request; without it, the request is
|
||||||
|
// rejected as third-party ("out of extra usage").
|
||||||
|
// 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}
|
||||||
setHeaderRaw(req.Header, "anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, effectiveDropWithClaudeCodeSet))
|
if !strings.Contains(strings.ToLower(modelID), "haiku") {
|
||||||
|
requiredBetas = []string{claude.BetaClaudeCode, claude.BetaOAuth, claude.BetaInterleavedThinking}
|
||||||
|
}
|
||||||
|
setHeaderRaw(req.Header, "anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, effectiveDropSet))
|
||||||
} else {
|
} else {
|
||||||
// Claude Code 客户端:尽量透传原始 header,仅补齐 oauth beta
|
// Claude Code 客户端:尽量透传原始 header,仅补齐 oauth beta
|
||||||
clientBetaHeader := getHeaderRaw(req.Header, "anthropic-beta")
|
clientBetaHeader := getHeaderRaw(req.Header, "anthropic-beta")
|
||||||
|
|||||||
Reference in New Issue
Block a user