diff --git a/backend/internal/service/account_test_prompt.go b/backend/internal/service/account_test_prompt.go index fb21a3db9666..567a4007d4f9 100644 --- a/backend/internal/service/account_test_prompt.go +++ b/backend/internal/service/account_test_prompt.go @@ -20,6 +20,14 @@ func accountTextTestOutputLimit(prompts ...string) int { } const accountTestPromptContextKey = "account_test_user_prompt" +const accountTestScheduledDefaultsContextKey = "account_test_scheduled_defaults" + +func resolveAntigravityTestPrompt(prompts ...string) string { + if len(prompts) == 0 { + return "." + } + return resolveAccountTestPrompt(prompts...) +} func ValidateAccountTextTestPrompt(prompt, model, mode string) error { switch strings.ToLower(strings.TrimSpace(mode)) { diff --git a/backend/internal/service/account_test_prompt_test.go b/backend/internal/service/account_test_prompt_test.go index 1d4f2d7140b9..633ea6940f7b 100644 --- a/backend/internal/service/account_test_prompt_test.go +++ b/backend/internal/service/account_test_prompt_test.go @@ -40,6 +40,37 @@ func TestAccountTestPromptBuildersPreserveUserText(t *testing.T) { } } +func TestAccountTestPromptAntigravityScheduledDefaults(t *testing.T) { + svc := &AntigravityGatewayService{} + for _, tc := range []struct { + name, model string + build func(string, string, ...string) ([]byte, error) + }{ + {"gemini", "gemini-3.1-pro", svc.buildGeminiTestRequest}, + {"claude", "claude-sonnet-4-6", svc.buildClaudeTestRequest}, + } { + t.Run(tc.name, func(t *testing.T) { + for _, input := range []struct { + name string + prompts []string + want string + limit int64 + }{ + {"scheduled", nil, ".", 1}, + {"blank manual", []string{""}, "hi", 1}, + {"custom manual", []string{"custom question"}, "custom question", 1024}, + } { + t.Run(input.name, func(t *testing.T) { + raw, err := tc.build("project", tc.model, input.prompts...) + require.NoError(t, err) + require.Equal(t, input.want, gjson.GetBytes(raw, "request.contents.0.parts.0.text").String()) + require.Equal(t, input.limit, gjson.GetBytes(raw, "request.generationConfig.maxOutputTokens").Int()) + }) + } + }) + } +} + func TestAccountTestPromptAdaptiveAndOpenCodeFinalRequests(t *testing.T) { prompt := " preserve 用户原文\n" account := adaptiveCNAccountTestAccount(991, PlatformDeepseek) diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 89a9a02520c2..4388c9ac83f6 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -2495,7 +2495,11 @@ func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, ac s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID}) // 调用 AntigravityGatewayService.TestConnection(复用协议转换逻辑) - result, err := s.antigravityGatewayService.TestConnection(ctx, account, testModelID, c.GetString(accountTestPromptContextKey)) + prompts := []string{c.GetString(accountTestPromptContextKey)} + if c.GetBool(accountTestScheduledDefaultsContextKey) { + prompts = nil + } + result, err := s.antigravityGatewayService.TestConnection(ctx, account, testModelID, prompts...) if err != nil { return s.sendErrorAndEnd(c, err.Error()) } @@ -3372,7 +3376,12 @@ func (s *AccountTestService) runTestBackground(ctx context.Context, accountID in ginCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) ginCtx.Request = (&http.Request{}).WithContext(ctx) ginCtx.Set("account_test_event_collector", collector) - testErr := s.TestAccountConnection(ginCtx, accountID, modelID, resolveAccountTestPrompt(prompts...), AccountTestModeDefault, AccountTestOptions{requireSupportedModel: requireSupported}) + ginCtx.Set(accountTestScheduledDefaultsContextKey, !requireSupported) + prompt := "" + if len(prompts) > 0 { + prompt = prompts[0] + } + testErr := s.TestAccountConnection(ginCtx, accountID, modelID, prompt, AccountTestModeDefault, AccountTestOptions{requireSupportedModel: requireSupported}) if testErr == nil { testErr = ctx.Err() } diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index 8861d3542d65..48da7efe77f1 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -457,7 +457,7 @@ func (s *AntigravityGatewayService) buildGeminiTestRequest(projectID, model stri { "role": "user", "parts": []map[string]any{ - {"text": resolveAccountTestPrompt(prompts...)}, + {"text": resolveAntigravityTestPrompt(prompts...)}, }, }, }, @@ -478,7 +478,7 @@ func (s *AntigravityGatewayService) buildGeminiTestRequest(projectID, model stri // buildClaudeTestRequest 构建 Claude 格式测试请求并转换为 Gemini 格式 // 使用最小 token 消耗:输入 "." + MaxTokens: 1 func (s *AntigravityGatewayService) buildClaudeTestRequest(projectID, mappedModel string, prompts ...string) ([]byte, error) { - content, err := json.Marshal(resolveAccountTestPrompt(prompts...)) + content, err := json.Marshal(resolveAntigravityTestPrompt(prompts...)) if err != nil { return nil, err } diff --git a/backend/internal/service/codex_ticket_lifecycle.go b/backend/internal/service/codex_ticket_lifecycle.go index 08020e8feeaf..cb9b93ffd282 100644 --- a/backend/internal/service/codex_ticket_lifecycle.go +++ b/backend/internal/service/codex_ticket_lifecycle.go @@ -112,6 +112,8 @@ func CodexTicketFailure(code string) CodexTicketResult { "ticket_proxy_protocol": "代理协议握手失败,请核对HTTP或SOCKS协议", "ticket_proxy_dns": "代理主机DNS解析失败", "ticket_proxy_connect": "代理连接被拒绝或CONNECT失败", + "ticket_proxy_eof": "连接被对端提前关闭(EOF)", + "ticket_proxy_reset": "连接被对端重置", "ticket_proxy_tls": "代理TLS证书验证失败,请测试代理连接", } message, ok := messages[code] diff --git a/backend/internal/service/codex_ticket_proxy.go b/backend/internal/service/codex_ticket_proxy.go index 5586db127099..a9247f4fa566 100644 --- a/backend/internal/service/codex_ticket_proxy.go +++ b/backend/internal/service/codex_ticket_proxy.go @@ -8,6 +8,7 @@ import ( "encoding/hex" "errors" "fmt" + "io" "net" "net/http" "net/http/httptrace" @@ -42,6 +43,7 @@ type CodexTicketProxyStage struct { } type CodexTicketProxyTestResult struct { + FailureDetail string `json:"failure_detail,omitempty"` Success bool `json:"success"` NetworkReachable bool `json:"network_reachable"` Protocol string `json:"protocol"` @@ -327,6 +329,7 @@ func (s *OpenAIGatewayService) prepareCodexTicketProxy(ctx context.Context, raw if err != nil { result.Code = codexTicketTransportFailureCode(err) result.Message = CodexTicketFailure(result.Code).Message + result.FailureDetail = codexTicketProxyFailureDetail(err, normal) if strings.HasPrefix(u.Scheme, "socks") && errors.Is(err, context.DeadlineExceeded) { result.ProtocolSuggestion = "尝试确认该端口是否为HTTP CONNECT代理" } @@ -382,6 +385,7 @@ func (s *OpenAIGatewayService) TestCodexTicketProxy(ctx context.Context, raw str result.Success = false result.Code = codexTicketTransportFailureCode(followErr) result.Message = CodexTicketFailure(result.Code).Message + result.FailureDetail = codexTicketProxyFailureDetail(followErr, normalized) result.Stages = append(result.Stages, CodexTicketProxyStage{Name: "pinned_connection", Success: false, DurationMS: time.Since(started).Milliseconds(), Message: result.Message}) return result, nil } @@ -428,9 +432,28 @@ func codexTicketTransportFailureCode(err error) string { if strings.Contains(t, "connection refused") || strings.Contains(t, "connect rejected") { return "ticket_proxy_connect" } + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return "ticket_proxy_eof" + } + if strings.Contains(t, "connection reset") || strings.Contains(t, "forcibly closed") { + return "ticket_proxy_reset" + } return "ticket_transport" } +func codexTicketProxyFailureDetail(err error, proxyURL string) string { + secrets := []string{proxyURL} + if parsed, parseErr := url.Parse(proxyURL); parseErr == nil && parsed.User != nil { + password, _ := parsed.User.Password() + secrets = append(secrets, parsed.User.Username(), password) + } + text := []rune(codexTicketSafeUpstreamMessage(err.Error(), secrets...)) + if len(text) > 256 { + text = text[:256] + } + return string(text) +} + var ticketSensitiveValuePattern = regexp.MustCompile(`(?i)(?:bearer\s+\S+|(?:https?|socks5h?)://\S+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+|sk-[A-Za-z0-9_-]+|gAAAAA[A-Za-z0-9_=-]+)`) func codexTicketSafeUpstreamMessage(raw string, secretValues ...string) string { diff --git a/backend/internal/service/codex_ticket_proxy_test.go b/backend/internal/service/codex_ticket_proxy_test.go index e523e51247f3..adbe005acf04 100644 --- a/backend/internal/service/codex_ticket_proxy_test.go +++ b/backend/internal/service/codex_ticket_proxy_test.go @@ -7,6 +7,7 @@ import ( "crypto/tls" "crypto/x509" "crypto/x509/pkix" + "errors" "io" "math/big" "net" @@ -196,3 +197,15 @@ func TestCodexTicketProxyAuthenticationFailureAndErrorRedaction(t *testing.T) { require.NotContains(t, msg, "secret-value") require.False(t, strings.Contains(msg, "gAAAAA")) } + +func TestCodexTicketProxyDisconnectCauseIsRedacted(t *testing.T) { + proxyURL := "http://diagnostic-user:diagnostic-password@proxy.example.com:8080" + err := &url.Error{Op: "Head", URL: proxyURL, Err: io.EOF} + require.Equal(t, "ticket_proxy_eof", codexTicketTransportFailureCode(err)) + detail := codexTicketProxyFailureDetail(err, proxyURL) + require.Contains(t, detail, "EOF") + require.NotContains(t, detail, "diagnostic-user") + require.NotContains(t, detail, "diagnostic-password") + require.Equal(t, "ticket_proxy_reset", codexTicketTransportFailureCode(errors.New("read: connection reset by peer"))) + require.LessOrEqual(t, len([]rune(codexTicketProxyFailureDetail(errors.New(strings.Repeat("failure ", 100)), proxyURL))), 256) +} diff --git a/frontend/src/api/admin/codexTickets.ts b/frontend/src/api/admin/codexTickets.ts index 50890d86d19e..5443188f28cd 100644 --- a/frontend/src/api/admin/codexTickets.ts +++ b/frontend/src/api/admin/codexTickets.ts @@ -7,6 +7,7 @@ export interface TicketResult { duration_ms?: number; expires_at?: string; fingerprint?: string } export interface ProxyTestResult { + failure_detail?: string success: boolean; network_reachable: boolean; protocol: string; http_status?: number; code: string; message: string stages: Array<{ name: string; success: boolean; duration_ms: number; message?: string }> certificate_trust?: string; certificate_fingerprint?: string; protocol_suggestion?: string diff --git a/frontend/src/components/admin/CodexTicketProxyEditor.vue b/frontend/src/components/admin/CodexTicketProxyEditor.vue index 5be42492160e..d2378ab16b45 100644 --- a/frontend/src/components/admin/CodexTicketProxyEditor.vue +++ b/frontend/src/components/admin/CodexTicketProxyEditor.vue @@ -14,6 +14,7 @@
{{ error }}
{{ result.message }}
+{{ result.failure_detail }}
{{ t('admin.accounts.ticketProxy.stages.' + stage.name) }} · {{ stage.success ? '✓' : '✗' }} {{ stage.message }} {{ stage.duration_ms }} ms
{{ t('admin.accounts.ticketProxy.trust') }}: {{ result.certificate_trust }} {{ result.certificate_fingerprint }}
{{ result.protocol_suggestion }}
diff --git a/frontend/src/components/admin/__tests__/CodexTicketProxyEditor.spec.ts b/frontend/src/components/admin/__tests__/CodexTicketProxyEditor.spec.ts index 25d5dc3f5702..b8a5123e0a49 100644 --- a/frontend/src/components/admin/__tests__/CodexTicketProxyEditor.spec.ts +++ b/frontend/src/components/admin/__tests__/CodexTicketProxyEditor.spec.ts @@ -27,4 +27,11 @@ describe('draft ticket proxy editor', () => { expect(wrapper.get('[role="alert"]').text()).toContain('duplicate_field') wrapper.unmount() }) + it('shows the safe transport cause supplied by the server', async () => { + const wrapper = mount(CodexTicketProxyEditor, { props: { modelValue: 'http://proxy.example.com:8080' } }) + mocks.testProxy.mockResolvedValue({ success: false, network_reachable: false, code: 'ticket_proxy_eof', message: 'Connection closed', failure_detail: 'Head [redacted]: EOF', stages: [] }) + await wrapper.findAll('button').find(b => b.text().endsWith('.test'))!.trigger('click'); await flushPromises() + expect(wrapper.get('[role="status"]').text()).toContain('Head [redacted]: EOF') + wrapper.unmount() + }) })