From 995adaeee418476cd604c9fee1029c2497b1d150 Mon Sep 17 00:00:00 2001 From: song Date: Mon, 29 Dec 2025 00:44:07 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=B7=BB=E5=8A=A0=20Claude=20signature?= =?UTF-8?q?=20=E5=9C=BA=E6=99=AF=20e2e=20=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 TestClaudeMessagesWithClaudeSignature 测试 - 验证历史 thinking block 带有 Claude signature 时的处理 - 修复配额刷新服务的次要问题 --- .../internal/integration/e2e_gateway_test.go | 101 ++++++++++++++++++ backend/internal/server/routes/admin.go | 3 + .../service/antigravity_quota_refresher.go | 10 +- .../components/account/ReAuthAccountModal.vue | 72 ++++++++++--- 4 files changed, 161 insertions(+), 25 deletions(-) diff --git a/backend/internal/integration/e2e_gateway_test.go b/backend/internal/integration/e2e_gateway_test.go index 81f5974a..b1bb965d 100644 --- a/backend/internal/integration/e2e_gateway_test.go +++ b/backend/internal/integration/e2e_gateway_test.go @@ -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"]) +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index cf157f8e..604d14df 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -34,6 +34,9 @@ func RegisterAdminRoutes( // Gemini OAuth registerGeminiOAuthRoutes(admin, h) + // Antigravity OAuth + registerAntigravityOAuthRoutes(admin, h) + // 代理管理 registerProxyRoutes(admin, h) diff --git a/backend/internal/service/antigravity_quota_refresher.go b/backend/internal/service/antigravity_quota_refresher.go index 61b21977..ed3ca61a 100644 --- a/backend/internal/service/antigravity_quota_refresher.go +++ b/backend/internal/service/antigravity_quota_refresher.go @@ -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 diff --git a/frontend/src/components/account/ReAuthAccountModal.vue b/frontend/src/components/account/ReAuthAccountModal.vue index ab983e26..7638b7a2 100644 --- a/frontend/src/components/account/ReAuthAccountModal.vue +++ b/frontend/src/components/account/ReAuthAccountModal.vue @@ -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' ]" > @@ -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(null) @@ -306,51 +312,48 @@ const addMethod = ref('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