mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Merge pull request #7256 from zhudaguaneren/fix/antigravity-attribution-429
fix(antigravity): 过滤 Claude attribution 元数据,修复特定请求的 429
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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="))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,继续检查原始上游错误、实际额度、并发和模型映射;不要用无限重试或反复清空冷却替代定位。
|
||||
Reference in New Issue
Block a user