diff --git a/backend/internal/pkg/antigravity/attribution_test.go b/backend/internal/pkg/antigravity/attribution_test.go index 8a1274e95d..79cc3c7ab3 100644 --- a/backend/internal/pkg/antigravity/attribution_test.go +++ b/backend/internal/pkg/antigravity/attribution_test.go @@ -86,3 +86,98 @@ func TestTransformClaudeToGemini_AttributionDoesNotHideIdentity(t *testing.T) { require.Equal(t, "You are Antigravity. Keep this identity.", got.Parts[0].Text) require.False(t, strings.Contains(got.Parts[0].Text, "cc_version=")) } + +func TestNeutralizeClaudeIdentity(t *testing.T) { + tests := []struct { + name string + text string + want string + }{ + { + name: "agent sdk opener", + text: "You are a Claude agent, built on Anthropic's Claude Agent SDK. You are naming a coding session.", + want: "You are an AI agent. You are naming a coding session.", + }, + { + name: "cli opener", + text: "You are Claude Code, Anthropic's official CLI for Claude. Help with engineering tasks.", + want: "You are an AI agent. Help with engineering tasks.", + }, + { + name: "leading whitespace", + text: "\n You are a Claude agent, built on Anthropic's Claude Agent SDK.\nRest.", + want: "You are an AI agent.\nRest.", + }, + // User instructions that merely mention the vendor must stay untouched: + // they are content, not a client identity declaration. + { + name: "mention inside instructions", + text: "Follow CLAUDE.md. Prefer claude-sonnet-4-6 for long tasks.", + want: "Follow CLAUDE.md. Prefer claude-sonnet-4-6 for long tasks.", + }, + { + name: "opener not at start", + text: "Example: You are a Claude agent, built on Anthropic's Claude Agent SDK.", + want: "Example: You are a Claude agent, built on Anthropic's Claude Agent SDK.", + }, + {name: "ordinary text", text: "Keep these instructions.", want: "Keep these instructions."}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, neutralizeClaudeIdentity(tt.text)) + }) + } +} + +// The attribution line and the identity sentence arrive as separate system +// blocks in real Claude Code traffic; both have to be handled. +func TestAttributionAndIdentityTogether(t *testing.T) { + attribution := "x-anthropic-billing-header: cc_version=2.1.278.0a9; cc_entrypoint=claude-vscode;" + identity := "You are a Claude agent, built on Anthropic's Claude Agent SDK." + require.Equal(t, "", stripClaudeAttribution(attribution)) + require.Equal(t, "You are an AI agent.", neutralizeClaudeIdentity(identity)) +} + +// End-to-end through the transformer: the attribution block and the identity +// sentence arrive as separate system blocks in real Claude Code traffic. The +// same words inside user content must survive untouched, because there they are +// conversation content rather than a client identity declaration. +func TestTransformClaudeToGemini_IdentitySystemBlock(t *testing.T) { + const ( + attribution = "x-anthropic-billing-header: cc_version=2.1.278.0a9; cc_entrypoint=claude-vscode;" + identity = "You are a Claude agent, built on Anthropic's Claude Agent SDK." + task = "You are naming a coding session." + userText = "Does Claude Code support claude-sonnet-4-6?" + ) + system := []SystemBlock{ + {Type: "text", Text: attribution}, + {Type: "text", Text: identity}, + {Type: "text", Text: task}, + } + systemJSON, err := json.Marshal(system) + require.NoError(t, err) + userJSON, err := json.Marshal(userText) + require.NoError(t, err) + input := &ClaudeRequest{ + Model: "gemini-3.8-flash-high", + System: systemJSON, + Messages: []ClaudeMessage{{Role: "user", Content: userJSON}}, + } + body, err := TransformClaudeToGeminiWithOptions(input, "test-project", input.Model, TransformOptions{}) + require.NoError(t, err) + var got V1InternalRequest + require.NoError(t, json.Unmarshal(body, &got)) + + var texts []string + for _, part := range got.Request.SystemInstruction.Parts { + if part.Text != "\n--- [SYSTEM_PROMPT_END] ---" { + texts = append(texts, part.Text) + } + } + require.Equal(t, []string{"You are an AI agent.", task}, texts) + for _, text := range texts { + require.NotContains(t, text, "Claude") + require.NotContains(t, text, "Anthropic") + } + require.Equal(t, userText, got.Request.Contents[0].Parts[0].Text) +} diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go index c4af10dcd8..f9499c1cca 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -7,6 +7,7 @@ import ( "fmt" "log" "math/rand" + "regexp" "strconv" "strings" "sync" @@ -297,6 +298,31 @@ func filterOpenCodePrompt(text string) string { // from Antigravity system text. This is prompt metadata, not an HTTP header; it // can trigger RESOURCE_EXHAUSTED on the Google upstream. Keep this scoped to the // Antigravity transformer: native Anthropic OAuth may require the attribution. +// claudeIdentityOpeners are the Claude Agent SDK identity sentences that some +// Anthropic clients put at the start of a system block. Stripping the +// x-anthropic-billing-header line alone is not enough: a request whose system +// text still opens with this sentence is still rejected by the Google upstream +// with 429 RESOURCE_EXHAUSTED (see docs/ANTIGRAVITY_ATTRIBUTION_429.md). +// +// The patterns are anchored at the start of a system block on purpose, so user +// instructions that merely mention Claude or Anthropic are left untouched. +var claudeIdentityOpeners = []*regexp.Regexp{ + regexp.MustCompile(`(?i)^[ \t\r\n]*You are a Claude agent, built on Anthropic'?s Claude Agent SDK\.?`), + regexp.MustCompile(`(?i)^[ \t\r\n]*You are Claude Code, Anthropic'?s official CLI for Claude\.?`), +} + +// neutralizeClaudeIdentity rewrites a leading vendor identity sentence into a +// vendor-neutral one. It keeps the rest of the block, including any user +// instructions that follow. +func neutralizeClaudeIdentity(text string) string { + for _, re := range claudeIdentityOpeners { + if loc := re.FindStringIndex(text); loc != nil { + return "You are an AI agent." + text[loc[1]:] + } + } + return text +} + func stripClaudeAttribution(text string) string { trimmed := strings.TrimLeft(text, " \t\r\n") if !strings.HasPrefix(trimmed, "x-anthropic-billing-header:") { @@ -325,7 +351,7 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans // 尝试解析为字符串 var sysStr string if err := json.Unmarshal(system, &sysStr); err == nil { - sysStr = stripClaudeAttribution(sysStr) + sysStr = neutralizeClaudeIdentity(stripClaudeAttribution(sysStr)) if strings.TrimSpace(sysStr) != "" { if strings.Contains(sysStr, "You are Antigravity") { userHasAntigravityIdentity = true @@ -341,7 +367,7 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans var sysBlocks []SystemBlock if err := json.Unmarshal(system, &sysBlocks); err == nil { for _, block := range sysBlocks { - block.Text = stripClaudeAttribution(block.Text) + block.Text = neutralizeClaudeIdentity(stripClaudeAttribution(block.Text)) if block.Type == "text" && strings.TrimSpace(block.Text) != "" { if strings.Contains(block.Text, "You are Antigravity") { userHasAntigravityIdentity = true diff --git a/docs/ANTIGRAVITY_ATTRIBUTION_429.md b/docs/ANTIGRAVITY_ATTRIBUTION_429.md index 1a50a20ebe..e4b3cc1822 100644 --- a/docs/ANTIGRAVITY_ATTRIBUTION_429.md +++ b/docs/ANTIGRAVITY_ATTRIBUTION_429.md @@ -59,3 +59,48 @@ Claude Code 提供 [`CLAUDE_CODE_ATTRIBUTION_HEADER`](https://code.claude.com/do 回归测试覆盖两种 system 格式、换行与空白、保留后续指令、保留普通文本和用户 / 工具内容,以及不修改原请求。已有的无冒号 billing-header 文本保留测试继续适用。 修复后应使用真实客户端新建会话验证完整响应,并确认已有冷却已到期。如果仍返回 429,继续检查原始上游错误、实际额度、并发和模型映射;不要用无限重试或反复清空冷却替代定位。 + +## 补充:仅移除 attribution 行仍不足(2026-09-20) + +在另一套部署上(**Sub2API 0.2.7,已包含上一节的 `stripClaudeAttribution` 修复**, +Claude Code 2.1.278 / claude-vscode 入口,映射模型 `gemini-3.8-flash-high`), +同一类 429 再次出现在 Claude Code 的**会话标题生成**请求上。 +这类请求很小:约 4 KB、system 3201 字符、0 个工具、1 条消息。 + +也就是说,下面的“原始请求回放”本身就是在**已经移除 attribution 行**的代码上跑的。 + +对照实验在一个只绑定单个账号的隔离分组里进行,每次请求之前都等模型冷却到期, +避免上一次失败造成的冷却把后续结果污染成 503: + +| 对照项 | 结果 | +| --- | --- | +| 原始请求回放 | 429(8/8) | +| 仅把 system 换成普通提示词,其余不变 | 200(8/8) | +| 回放前先由客户端侧移除 attribution 块(与网关行为叠加,确认无额外影响) | **429(3/3)** | +| 在上一行基础上再中性化身份开场白 | **200(3/3)** | +| 仅把 system 里的 `Claude` 替换为中性词 | 200(2/2) | +| 仅把 system 里的 `Anthropic` 移除 | 200(2/2) | +| 去掉 `output_config` / 改小 `max_tokens` / 改非流式 | 429(各 1/1,无影响) | + +该请求的 system 是三个文本块: + +1. 仅 attribution 元数据(本文上一节的修复会整块丢弃); +2. `You are a Claude agent, built on Anthropic's Claude Agent SDK.`; +3. 其余任务说明,不含厂商名。 + +也就是说,触发拒绝的不只是 attribution 元数据,**第 2 块的身份声明同样会触发**。 +Claude Code 的主对话提示词以 `You are an interactive CLI tool` 开头,已被 +`filterOpenCodePrompt` 处理,所以只有 Agent SDK 这类提示词漏网。 + +### 本次修复 + +在 attribution 处理之后,追加一次**开头锚定**的身份句中性化: + +- 命中 `You are a Claude agent, built on Anthropic's Claude Agent SDK.` 与 + `You are Claude Code, Anthropic's official CLI for Claude.` 两种开场白,替换为 + `You are an AI agent.`,块内其余内容原样保留。 +- 只在 system 文本块**开头**匹配:正文中间提到 Claude/Anthropic 的用户指令不受影响, + `claude-sonnet-4-6` 这类模型名也不会被改写。 +- 不修改用户消息、工具定义、模型映射与认证;仅作用于 Antigravity 转换器。 + +同样地,这些对照只支持“这段文本在本次请求中触发拒绝”的判断,不能推断 Google 的内部策略。