test: 添加 Claude signature 场景 e2e 测试
- 新增 TestClaudeMessagesWithClaudeSignature 测试 - 验证历史 thinking block 带有 Claude signature 时的处理 - 修复配额刷新服务的次要问题
This commit is contained in:
@@ -730,3 +730,104 @@ func testClaudeWithNoSignature(t *testing.T, model string) {
|
|||||||
}
|
}
|
||||||
t.Logf("✅ 无 signature thinking 处理测试通过, id=%v", result["id"])
|
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"])
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ func RegisterAdminRoutes(
|
|||||||
// Gemini OAuth
|
// Gemini OAuth
|
||||||
registerGeminiOAuthRoutes(admin, h)
|
registerGeminiOAuthRoutes(admin, h)
|
||||||
|
|
||||||
|
// Antigravity OAuth
|
||||||
|
registerAntigravityOAuthRoutes(admin, h)
|
||||||
|
|
||||||
// 代理管理
|
// 代理管理
|
||||||
registerProxyRoutes(admin, h)
|
registerProxyRoutes(admin, h)
|
||||||
|
|
||||||
|
|||||||
@@ -131,15 +131,9 @@ func (r *AntigravityQuotaRefresher) refreshAccountQuota(ctx context.Context, acc
|
|||||||
return nil // 没有有效凭证,跳过
|
return nil // 没有有效凭证,跳过
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查 token 是否过期,过期则刷新
|
// token 过期则跳过,由 TokenRefreshService 负责刷新
|
||||||
if r.isTokenExpired(account) {
|
if r.isTokenExpired(account) {
|
||||||
tokenInfo, err := r.oauthSvc.RefreshAccountToken(ctx, account)
|
return nil
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
accessToken = tokenInfo.AccessToken
|
|
||||||
// 更新凭证
|
|
||||||
account.Credentials = r.oauthSvc.BuildAccountCredentials(tokenInfo)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取代理 URL
|
// 获取代理 URL
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
? 'from-green-500 to-green-600'
|
? 'from-green-500 to-green-600'
|
||||||
: isGemini
|
: isGemini
|
||||||
? 'from-blue-500 to-blue-600'
|
? '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
|
<svg
|
||||||
@@ -45,7 +47,9 @@
|
|||||||
? t('admin.accounts.openaiAccount')
|
? t('admin.accounts.openaiAccount')
|
||||||
: isGemini
|
: isGemini
|
||||||
? t('admin.accounts.geminiAccount')
|
? t('admin.accounts.geminiAccount')
|
||||||
: t('admin.accounts.claudeCodeAccount')
|
: isAntigravity
|
||||||
|
? t('admin.accounts.antigravityAccount')
|
||||||
|
: t('admin.accounts.claudeCodeAccount')
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,7 +205,7 @@
|
|||||||
:show-cookie-option="isAnthropic"
|
:show-cookie-option="isAnthropic"
|
||||||
:allow-multiple="false"
|
:allow-multiple="false"
|
||||||
:method-label="t('admin.accounts.inputMethod')"
|
: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'"
|
:show-project-id="isGemini && geminiOAuthType === 'code_assist'"
|
||||||
@generate-url="handleGenerateUrl"
|
@generate-url="handleGenerateUrl"
|
||||||
@cookie-auth="handleCookieAuth"
|
@cookie-auth="handleCookieAuth"
|
||||||
@@ -264,6 +268,7 @@ import {
|
|||||||
} from '@/composables/useAccountOAuth'
|
} from '@/composables/useAccountOAuth'
|
||||||
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
|
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
|
||||||
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
|
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
|
||||||
|
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
|
||||||
import type { Account } from '@/types'
|
import type { Account } from '@/types'
|
||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
|
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
|
||||||
@@ -293,10 +298,11 @@ const emit = defineEmits<{
|
|||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
// OAuth composables - use both Claude and OpenAI
|
// OAuth composables
|
||||||
const claudeOAuth = useAccountOAuth()
|
const claudeOAuth = useAccountOAuth()
|
||||||
const openaiOAuth = useOpenAIOAuth()
|
const openaiOAuth = useOpenAIOAuth()
|
||||||
const geminiOAuth = useGeminiOAuth()
|
const geminiOAuth = useGeminiOAuth()
|
||||||
|
const antigravityOAuth = useAntigravityOAuth()
|
||||||
|
|
||||||
// Refs
|
// Refs
|
||||||
const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
|
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 geminiOAuthType = ref<'code_assist' | 'ai_studio'>('code_assist')
|
||||||
const geminiAIStudioOAuthEnabled = ref(false)
|
const geminiAIStudioOAuthEnabled = ref(false)
|
||||||
|
|
||||||
// Computed - check if this is an OpenAI account
|
// Computed - check platform
|
||||||
const isOpenAI = computed(() => props.account?.platform === 'openai')
|
const isOpenAI = computed(() => props.account?.platform === 'openai')
|
||||||
const isGemini = computed(() => props.account?.platform === 'gemini')
|
const isGemini = computed(() => props.account?.platform === 'gemini')
|
||||||
const isAnthropic = computed(() => props.account?.platform === 'anthropic')
|
const isAnthropic = computed(() => props.account?.platform === 'anthropic')
|
||||||
|
const isAntigravity = computed(() => props.account?.platform === 'antigravity')
|
||||||
|
|
||||||
// Computed - current OAuth state based on platform
|
// Computed - current OAuth state based on platform
|
||||||
const currentAuthUrl = computed(() => {
|
const currentAuthUrl = computed(() => {
|
||||||
if (isOpenAI.value) return openaiOAuth.authUrl.value
|
if (isOpenAI.value) return openaiOAuth.authUrl.value
|
||||||
if (isGemini.value) return geminiOAuth.authUrl.value
|
if (isGemini.value) return geminiOAuth.authUrl.value
|
||||||
|
if (isAntigravity.value) return antigravityOAuth.authUrl.value
|
||||||
return claudeOAuth.authUrl.value
|
return claudeOAuth.authUrl.value
|
||||||
})
|
})
|
||||||
const currentSessionId = computed(() => {
|
const currentSessionId = computed(() => {
|
||||||
if (isOpenAI.value) return openaiOAuth.sessionId.value
|
if (isOpenAI.value) return openaiOAuth.sessionId.value
|
||||||
if (isGemini.value) return geminiOAuth.sessionId.value
|
if (isGemini.value) return geminiOAuth.sessionId.value
|
||||||
|
if (isAntigravity.value) return antigravityOAuth.sessionId.value
|
||||||
return claudeOAuth.sessionId.value
|
return claudeOAuth.sessionId.value
|
||||||
})
|
})
|
||||||
const currentLoading = computed(() => {
|
const currentLoading = computed(() => {
|
||||||
if (isOpenAI.value) return openaiOAuth.loading.value
|
if (isOpenAI.value) return openaiOAuth.loading.value
|
||||||
if (isGemini.value) return geminiOAuth.loading.value
|
if (isGemini.value) return geminiOAuth.loading.value
|
||||||
|
if (isAntigravity.value) return antigravityOAuth.loading.value
|
||||||
return claudeOAuth.loading.value
|
return claudeOAuth.loading.value
|
||||||
})
|
})
|
||||||
const currentError = computed(() => {
|
const currentError = computed(() => {
|
||||||
if (isOpenAI.value) return openaiOAuth.error.value
|
if (isOpenAI.value) return openaiOAuth.error.value
|
||||||
if (isGemini.value) return geminiOAuth.error.value
|
if (isGemini.value) return geminiOAuth.error.value
|
||||||
|
if (isAntigravity.value) return antigravityOAuth.error.value
|
||||||
return claudeOAuth.error.value
|
return claudeOAuth.error.value
|
||||||
})
|
})
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const isManualInputMethod = computed(() => {
|
const isManualInputMethod = computed(() => {
|
||||||
// OpenAI always uses manual input (no cookie auth option)
|
// OpenAI/Gemini/Antigravity always use manual input (no cookie auth option)
|
||||||
return isOpenAI.value || isGemini.value || oauthFlowRef.value?.inputMethod === 'manual'
|
return isOpenAI.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
|
||||||
})
|
})
|
||||||
|
|
||||||
const canExchangeCode = computed(() => {
|
const canExchangeCode = computed(() => {
|
||||||
const authCode = oauthFlowRef.value?.authCode || ''
|
const authCode = oauthFlowRef.value?.authCode || ''
|
||||||
const sessionId = isOpenAI.value
|
const sessionId = currentSessionId.value
|
||||||
? openaiOAuth.sessionId.value
|
const loading = currentLoading.value
|
||||||
: isGemini.value
|
|
||||||
? geminiOAuth.sessionId.value
|
|
||||||
: claudeOAuth.sessionId.value
|
|
||||||
const loading = isOpenAI.value
|
|
||||||
? openaiOAuth.loading.value
|
|
||||||
: isGemini.value
|
|
||||||
? geminiOAuth.loading.value
|
|
||||||
: claudeOAuth.loading.value
|
|
||||||
return authCode.trim() && sessionId && !loading
|
return authCode.trim() && sessionId && !loading
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -392,6 +395,7 @@ const resetState = () => {
|
|||||||
claudeOAuth.resetState()
|
claudeOAuth.resetState()
|
||||||
openaiOAuth.resetState()
|
openaiOAuth.resetState()
|
||||||
geminiOAuth.resetState()
|
geminiOAuth.resetState()
|
||||||
|
antigravityOAuth.resetState()
|
||||||
oauthFlowRef.value?.reset()
|
oauthFlowRef.value?.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,6 +419,8 @@ const handleGenerateUrl = async () => {
|
|||||||
} else if (isGemini.value) {
|
} else if (isGemini.value) {
|
||||||
const projectId = geminiOAuthType.value === 'code_assist' ? oauthFlowRef.value?.projectId : undefined
|
const projectId = geminiOAuthType.value === 'code_assist' ? oauthFlowRef.value?.projectId : undefined
|
||||||
await geminiOAuth.generateAuthUrl(props.account.proxy_id, projectId, geminiOAuthType.value)
|
await geminiOAuth.generateAuthUrl(props.account.proxy_id, projectId, geminiOAuthType.value)
|
||||||
|
} else if (isAntigravity.value) {
|
||||||
|
await antigravityOAuth.generateAuthUrl(props.account.proxy_id)
|
||||||
} else {
|
} else {
|
||||||
await claudeOAuth.generateAuthUrl(addMethod.value, props.account.proxy_id)
|
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')
|
geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||||
appStore.showError(geminiOAuth.error.value)
|
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 {
|
} else {
|
||||||
// Claude OAuth flow
|
// Claude OAuth flow
|
||||||
const sessionId = claudeOAuth.sessionId.value
|
const sessionId = claudeOAuth.sessionId.value
|
||||||
|
|||||||
Reference in New Issue
Block a user