diff --git a/.gitignore b/.gitignore index 20ed12275e..5dafefb36b 100644 --- a/.gitignore +++ b/.gitignore @@ -133,6 +133,7 @@ deploy/docker-compose.override.yml .gocache/ vite.config.js docs/* +!docs/ANTIGRAVITY_ATTRIBUTION_429.md !docs/PAYMENT.md !docs/PAYMENT_CN.md !docs/ADMIN_PAYMENT_INTEGRATION_API.md diff --git a/backend/internal/pkg/antigravity/attribution_test.go b/backend/internal/pkg/antigravity/attribution_test.go new file mode 100644 index 0000000000..8a1274e95d --- /dev/null +++ b/backend/internal/pkg/antigravity/attribution_test.go @@ -0,0 +1,88 @@ +package antigravity + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTransformClaudeToGemini_AttributionSystemText(t *testing.T) { + const attribution = "x-anthropic-billing-header: cc_version=2.1.271.4bf; cc_entrypoint=claude-desktop-3p;" + tests := []struct { + name string + text string + want string + }{ + {"metadata only", attribution, ""}, + {"metadata with newline", attribution + "\n", ""}, + {"leading whitespace", " \t\n" + attribution, ""}, + {"following instructions", attribution + "\nKeep these instructions.", "Keep these instructions."}, + {"CRLF", attribution + "\r\n Keep indentation.\n", " Keep indentation.\n"}, + {"CR", attribution + "\rKeep these instructions.", "Keep these instructions."}, + {"blank lines preserved", attribution + "\n\nKeep these instructions.", "\nKeep these instructions."}, + {"ordinary text", " Keep these instructions.\n", " Keep these instructions.\n"}, + {"no colon", "x-anthropic-billing-header keep", "x-anthropic-billing-header keep"}, + {"different field", "x-anthropic-billing-header-extra: keep", "x-anthropic-billing-header-extra: keep"}, + {"quoted within instructions", "Explain this metadata: " + attribution, "Explain this metadata: " + attribution}, + {"later line", "Example:\n" + attribution, "Example:\n" + attribution}, + } + for _, tt := range tests { + for _, arrayForm := range []bool{false, true} { + name := tt.name + "/string" + var system any = tt.text + if arrayForm { + name = tt.name + "/array" + system = []SystemBlock{{Type: "text", Text: tt.text}, {Type: "text", Text: "Keep the next block."}} + } + t.Run(name, func(t *testing.T) { + systemJSON, err := json.Marshal(system) + require.NoError(t, err) + userJSON, err := json.Marshal(attribution) + require.NoError(t, err) + input := &ClaudeRequest{ + Model: "gemini-3.8-flash-high", System: systemJSON, + Messages: []ClaudeMessage{{Role: "user", Content: userJSON}}, + Tools: []ClaudeTool{{Name: "example", Description: attribution, + InputSchema: map[string]any{"type": "object", "properties": map[string]any{"query": map[string]any{"type": "string"}}}}}, + } + before, err := json.Marshal(input) + require.NoError(t, err) + 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) + } + } + var want []string + if tt.want != "" { + want = append(want, tt.want) + } + if arrayForm { + want = append(want, "Keep the next block.") + } + require.Equal(t, want, texts) + // The same literal text in user content and tool descriptions is not metadata. + require.Equal(t, attribution, got.Request.Contents[0].Parts[0].Text) + require.Equal(t, attribution, got.Request.Tools[0].FunctionDeclarations[0].Description) + after, err := json.Marshal(input) + require.NoError(t, err) + require.JSONEq(t, string(before), string(after), "do not mutate the incoming request") + }) + } + } +} + +func TestTransformClaudeToGemini_AttributionDoesNotHideIdentity(t *testing.T) { + system, err := json.Marshal("x-anthropic-billing-header: cc_version=example;\nYou are Antigravity. Keep this identity.") + require.NoError(t, err) + got := buildSystemInstruction(system, "gemini-3.8-flash-high", DefaultTransformOptions(), nil) + require.Len(t, got.Parts, 1) + require.Equal(t, "You are Antigravity. Keep this identity.", got.Parts[0].Text) + require.False(t, strings.Contains(got.Parts[0].Text, "cc_version=")) +} diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go index 192c4cda39..c4af10dcd8 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -293,6 +293,26 @@ func filterOpenCodePrompt(text string) string { return "" } +// stripClaudeAttribution removes the leading Claude Code attribution metadata line +// 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. +func stripClaudeAttribution(text string) string { + trimmed := strings.TrimLeft(text, " \t\r\n") + if !strings.HasPrefix(trimmed, "x-anthropic-billing-header:") { + return text + } + end := strings.IndexAny(trimmed, "\r\n") + if end < 0 { + return "" + } + rest := trimmed[end+1:] + if trimmed[end] == '\r' { + rest = strings.TrimPrefix(rest, "\n") + } + return rest +} + // buildSystemInstruction 构建 systemInstruction(与 Antigravity-Manager 保持一致) func buildSystemInstruction(system json.RawMessage, modelName string, opts TransformOptions, tools []ClaudeTool) *GeminiContent { var parts []GeminiPart @@ -305,6 +325,7 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans // 尝试解析为字符串 var sysStr string if err := json.Unmarshal(system, &sysStr); err == nil { + sysStr = stripClaudeAttribution(sysStr) if strings.TrimSpace(sysStr) != "" { if strings.Contains(sysStr, "You are Antigravity") { userHasAntigravityIdentity = true @@ -320,6 +341,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) 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 new file mode 100644 index 0000000000..1a50a20ebe --- /dev/null +++ b/docs/ANTIGRAVITY_ATTRIBUTION_429.md @@ -0,0 +1,61 @@ +# Antigravity:Claude Desktop attribution 元数据触发 429 的排查记录 + +本文记录一次实际问题的定位和修复,适用于 Claude Desktop / Claude Code 经 Anthropic Messages 协议转到 Antigravity 的场景。它不是所有 `429 RESOURCE_EXHAUSTED` 的通用解释:额度耗尽、请求频率、上游容量等仍应独立排查。 + +## 现象与环境 + +- 实测部署为 Sub2API 0.2.0,Claude Desktop 2.110.0,内嵌 Claude Code 2.1.271,CC Switch 3.20.3。 +- 请求链路:Claude Desktop → CC Switch 本地代理 → Sub2API Antigravity → Google 上游;映射模型为 `gemini-3.8-flash-high`。 +- 新对话中的简单问候也返回 `429 RESOURCE_EXHAUSTED`,上游信息为 `Resource has been exhausted (e.g. check quota).`。 +- 随后账号进入模型冷却,单账号组还可能出现 `503 No available accounts`。 +- 独立构造的极简请求成功,但真实客户端请求失败。客户端即使只发送一句问候,也会附带 system 提示词和工具定义。 + +## 定位方法与对照结果 + +保留一次失败请求,在相同账号、模型和上游端点下逐项删减内容。不要仅用手写的短请求判断完整客户端链路已经恢复,也不要在公开 issue / PR 上传原始请求或访问令牌。 + +| 对照项 | 本次观察 | +| --- | --- | +| 原始客户端请求(约 187 KB、106 个工具) | 429 | +| 移除工具定义,保留 system 内容 | 429 | +| 移除 messages 中的 system 角色消息 | 429 | +| 直接请求 Google,保留原始顶层 system 内容 | 429 | +| 同一 Google 请求改用简单 system 内容 | 200 | +| 分别测试 system 文本块 | attribution 元数据块返回 429,其余指令块返回 200 | +| 原始请求仅移除 attribution 元数据块,保留模型、工具及其他内容 | 200,收到完整回复 | + +触发问题的文本形如: + +```text +x-anthropic-billing-header: cc_version=2.1.271.4bf; cc_entrypoint=claude-desktop-3p; +``` + +这里的 “header” 是请求 JSON 的 `system` 文本中的一行,**不是 HTTP 请求头**。因此,不需要在 Claude Desktop 的 “Custom inference headers” 里增删同名字段;也不应通过修改正常工作的 Bearer 认证来处理这个问题。 + +这些对照支持“该元数据文本在本次 Google 请求中触发拒绝”的判断,但不能据此确定 Google 内部策略,也不能推断所有账号或模型都具有相同行为。 + +## 本次修复 + +在 Antigravity 的 Claude → Gemini 请求转换中,移除顶层 `system` 字符串或文本块**开头**的 `x-anthropic-billing-header:` 行: + +- 支持字符串和文本块数组,兼容 LF / CRLF / CR 换行及前导空白。 +- 如果同一文本块后面还有指令,原样保留后续内容;仅元数据的空块不再发送。 +- 不匹配缺少冒号的普通文本、其他字段名,以及正常指令中间引用的相同文本。 +- 不修改用户消息、工具定义、模型映射、认证和账号额度状态。 +- 处理限于 Antigravity 转换器,不修改原生 Anthropic 转发路径。 + +上线前的临时修复采用相同范围的反向代理 JSON 过滤,并通过原始完整请求和真实 Claude Desktop 会话验证成功。本文 PR 将处理放入转换器,避免用户必须自行维护代理脚本。原部署曾尝试降低账号并发,但未解决问题;定位后已恢复原配置。 + +如需在旧版本临时处理,应只对目标 Antigravity Messages 路径修改 JSON 的顶层 system 文本。不要用全局字符串替换,否则可能误改消息、工具参数或代码示例。代理实现还需保持空数组和空对象类型、更新请求体长度,并覆盖实际使用的 token-count 路径。面板重新生成代理配置时也可能覆盖自定义过滤规则。 + +## 不要全局禁用 attribution + +Claude Code 提供 [`CLAUDE_CODE_ATTRIBUTION_HEADER`](https://code.claude.com/docs/en/env-vars) 环境变量,但桌面端是否继承该配置需要实测。本次仅修改全局 Claude 设置并未消除 Desktop 请求中的元数据。 + +更重要的是,原生 Anthropic OAuth 线路有相反的报告:禁用 attribution 会造成 429,见 [issue #6344](https://github.com/Wei-Shaw/sub2api/issues/6344)。因此,不建议为这个 Antigravity 问题统一设置 `CLAUDE_CODE_ATTRIBUTION_HEADER=0` 或在所有上游请求中删除该文本。 + +## 验证与仍需排查的情况 + +回归测试覆盖两种 system 格式、换行与空白、保留后续指令、保留普通文本和用户 / 工具内容,以及不修改原请求。已有的无冒号 billing-header 文本保留测试继续适用。 + +修复后应使用真实客户端新建会话验证完整响应,并确认已有冷却已到期。如果仍返回 429,继续检查原始上游错误、实际额度、并发和模型映射;不要用无限重试或反复清空冷却替代定位。