test: 添加 Claude signature 场景 e2e 测试

- 新增 TestClaudeMessagesWithClaudeSignature 测试
- 验证历史 thinking block 带有 Claude signature 时的处理
- 修复配额刷新服务的次要问题
This commit is contained in:
song
2025-12-29 00:44:07 +08:00
parent 08ce6de4db
commit 995adaeee4
4 changed files with 161 additions and 25 deletions

View File

@@ -730,3 +730,104 @@ func testClaudeWithNoSignature(t *testing.T, model string) {
}
t.Logf("✅ 无 signature thinking 处理测试通过, id=%v", result["id"])
}
// TestClaudeMessagesWithClaudeSignature 测试历史 thinking block 带有 Claude signature 的场景
// 验证Claude 的 signature 格式与 Gemini 不兼容,发送到 Gemini 模型时应忽略(不传递)
func TestClaudeMessagesWithClaudeSignature(t *testing.T) {
models := []string{
"claude-haiku-4-5-20251001", // 映射到 gemini-3-flash
}
for i, model := range models {
if i > 0 {
time.Sleep(testInterval)
}
t.Run(model+"_带Claude_signature", func(t *testing.T) {
testClaudeWithClaudeSignature(t, model)
})
}
}
func testClaudeWithClaudeSignature(t *testing.T, model string) {
url := baseURL + "/v1/messages"
// 模拟历史对话包含 thinking block 且带有 Claude 格式的 signature
// 这个 signature 是 Claude API 返回的格式,对 Gemini 无效
payload := map[string]any{
"model": model,
"max_tokens": 200,
"stream": false,
// 开启 thinking 模式
"thinking": map[string]any{
"type": "enabled",
"budget_tokens": 1024,
},
"messages": []any{
map[string]any{
"role": "user",
"content": "What is 2+2?",
},
// assistant 消息包含 thinking block 和 Claude 格式的 signature
map[string]any{
"role": "assistant",
"content": []map[string]any{
{
"type": "thinking",
"thinking": "Let me calculate 2+2. This is a simple arithmetic problem.",
// Claude API 返回的 signature 格式base64 编码的加密数据)
"signature": "zbbJDG5qqgNXD/BVLwwxxT3gVaAY2hQ6CcB+hVLZWPi8r6vvlRBQKMfFPE3x5...",
},
{
"type": "text",
"text": "2+2 equals 4.",
},
},
},
map[string]any{
"role": "user",
"content": "What is 3+3?",
},
},
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+claudeAPIKey)
req.Header.Set("anthropic-version", "2023-06-01")
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
// 400 错误说明 signature 未被正确忽略
if resp.StatusCode == 400 {
t.Fatalf("Claude signature 未被正确忽略,收到 400 错误: %s", string(respBody))
}
if resp.StatusCode == 503 {
t.Skipf("账号暂时不可用 (503): %s", string(respBody))
}
if resp.StatusCode == 429 {
t.Skipf("请求被限流 (429): %s", string(respBody))
}
if resp.StatusCode != 200 {
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result map[string]any
if err := json.Unmarshal(respBody, &result); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if result["type"] != "message" {
t.Errorf("期望 type=message, 得到 %v", result["type"])
}
t.Logf("✅ Claude signature 忽略测试通过, id=%v", result["id"])
}

View File

@@ -34,6 +34,9 @@ func RegisterAdminRoutes(
// Gemini OAuth
registerGeminiOAuthRoutes(admin, h)
// Antigravity OAuth
registerAntigravityOAuthRoutes(admin, h)
// 代理管理
registerProxyRoutes(admin, h)

View File

@@ -131,15 +131,9 @@ func (r *AntigravityQuotaRefresher) refreshAccountQuota(ctx context.Context, acc
return nil // 没有有效凭证,跳过
}
// 检查 token 是否过期,过期则刷新
// token 过期则跳过,由 TokenRefreshService 负责刷新
if r.isTokenExpired(account) {
tokenInfo, err := r.oauthSvc.RefreshAccountToken(ctx, account)
if err != nil {
return err
}
accessToken = tokenInfo.AccessToken
// 更新凭证
account.Credentials = r.oauthSvc.BuildAccountCredentials(tokenInfo)
return nil
}
// 获取代理 URL

View File

@@ -18,7 +18,9 @@
? 'from-green-500 to-green-600'
: isGemini
? 'from-blue-500 to-blue-600'
: 'from-orange-500 to-orange-600'
: isAntigravity
? 'from-purple-500 to-purple-600'
: 'from-orange-500 to-orange-600'
]"
>
<svg
@@ -45,7 +47,9 @@
? t('admin.accounts.openaiAccount')
: isGemini
? t('admin.accounts.geminiAccount')
: t('admin.accounts.claudeCodeAccount')
: isAntigravity
? t('admin.accounts.antigravityAccount')
: t('admin.accounts.claudeCodeAccount')
}}
</span>
</div>
@@ -201,7 +205,7 @@
:show-cookie-option="isAnthropic"
:allow-multiple="false"
:method-label="t('admin.accounts.inputMethod')"
:platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : 'anthropic'"
:platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
:show-project-id="isGemini && geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
@@ -264,6 +268,7 @@ import {
} from '@/composables/useAccountOAuth'
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
import type { Account } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
@@ -293,10 +298,11 @@ const emit = defineEmits<{
const appStore = useAppStore()
const { t } = useI18n()
// OAuth composables - use both Claude and OpenAI
// OAuth composables
const claudeOAuth = useAccountOAuth()
const openaiOAuth = useOpenAIOAuth()
const geminiOAuth = useGeminiOAuth()
const antigravityOAuth = useAntigravityOAuth()
// Refs
const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
@@ -306,51 +312,48 @@ const addMethod = ref<AddMethod>('oauth')
const geminiOAuthType = ref<'code_assist' | 'ai_studio'>('code_assist')
const geminiAIStudioOAuthEnabled = ref(false)
// Computed - check if this is an OpenAI account
// Computed - check platform
const isOpenAI = computed(() => props.account?.platform === 'openai')
const isGemini = computed(() => props.account?.platform === 'gemini')
const isAnthropic = computed(() => props.account?.platform === 'anthropic')
const isAntigravity = computed(() => props.account?.platform === 'antigravity')
// Computed - current OAuth state based on platform
const currentAuthUrl = computed(() => {
if (isOpenAI.value) return openaiOAuth.authUrl.value
if (isGemini.value) return geminiOAuth.authUrl.value
if (isAntigravity.value) return antigravityOAuth.authUrl.value
return claudeOAuth.authUrl.value
})
const currentSessionId = computed(() => {
if (isOpenAI.value) return openaiOAuth.sessionId.value
if (isGemini.value) return geminiOAuth.sessionId.value
if (isAntigravity.value) return antigravityOAuth.sessionId.value
return claudeOAuth.sessionId.value
})
const currentLoading = computed(() => {
if (isOpenAI.value) return openaiOAuth.loading.value
if (isGemini.value) return geminiOAuth.loading.value
if (isAntigravity.value) return antigravityOAuth.loading.value
return claudeOAuth.loading.value
})
const currentError = computed(() => {
if (isOpenAI.value) return openaiOAuth.error.value
if (isGemini.value) return geminiOAuth.error.value
if (isAntigravity.value) return antigravityOAuth.error.value
return claudeOAuth.error.value
})
// Computed
const isManualInputMethod = computed(() => {
// OpenAI always uses manual input (no cookie auth option)
return isOpenAI.value || isGemini.value || oauthFlowRef.value?.inputMethod === 'manual'
// OpenAI/Gemini/Antigravity always use manual input (no cookie auth option)
return isOpenAI.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
})
const canExchangeCode = computed(() => {
const authCode = oauthFlowRef.value?.authCode || ''
const sessionId = isOpenAI.value
? openaiOAuth.sessionId.value
: isGemini.value
? geminiOAuth.sessionId.value
: claudeOAuth.sessionId.value
const loading = isOpenAI.value
? openaiOAuth.loading.value
: isGemini.value
? geminiOAuth.loading.value
: claudeOAuth.loading.value
const sessionId = currentSessionId.value
const loading = currentLoading.value
return authCode.trim() && sessionId && !loading
})
@@ -392,6 +395,7 @@ const resetState = () => {
claudeOAuth.resetState()
openaiOAuth.resetState()
geminiOAuth.resetState()
antigravityOAuth.resetState()
oauthFlowRef.value?.reset()
}
@@ -415,6 +419,8 @@ const handleGenerateUrl = async () => {
} else if (isGemini.value) {
const projectId = geminiOAuthType.value === 'code_assist' ? oauthFlowRef.value?.projectId : undefined
await geminiOAuth.generateAuthUrl(props.account.proxy_id, projectId, geminiOAuthType.value)
} else if (isAntigravity.value) {
await antigravityOAuth.generateAuthUrl(props.account.proxy_id)
} else {
await claudeOAuth.generateAuthUrl(addMethod.value, props.account.proxy_id)
}
@@ -492,6 +498,38 @@ const handleExchangeCode = async () => {
geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(geminiOAuth.error.value)
}
} else if (isAntigravity.value) {
// Antigravity OAuth flow
const sessionId = antigravityOAuth.sessionId.value
if (!sessionId) return
const stateFromInput = oauthFlowRef.value?.oauthState || ''
const stateToUse = stateFromInput || antigravityOAuth.state.value
if (!stateToUse) return
const tokenInfo = await antigravityOAuth.exchangeAuthCode({
code: authCode.trim(),
sessionId,
state: stateToUse,
proxyId: props.account.proxy_id
})
if (!tokenInfo) return
const credentials = antigravityOAuth.buildCredentials(tokenInfo)
try {
await adminAPI.accounts.update(props.account.id, {
type: 'oauth',
credentials
})
await adminAPI.accounts.clearError(props.account.id)
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized')
handleClose()
} catch (error: any) {
antigravityOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(antigravityOAuth.error.value)
}
} else {
// Claude OAuth flow
const sessionId = claudeOAuth.sessionId.value