2025-12-18 13:50:39 +08:00
|
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bufio"
|
|
|
|
|
|
"bytes"
|
2025-12-25 21:24:44 -08:00
|
|
|
|
"context"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
"crypto/rand"
|
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
|
"encoding/json"
|
2026-01-02 17:40:57 +08:00
|
|
|
|
"errors"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
|
|
|
|
|
"log"
|
|
|
|
|
|
"net/http"
|
2026-03-05 16:06:05 +08:00
|
|
|
|
"net/http/httptest"
|
2025-12-26 03:49:55 -08:00
|
|
|
|
"regexp"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
"strings"
|
2026-02-19 20:04:10 +08:00
|
|
|
|
"time"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-01-02 17:40:57 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
2025-12-24 21:07:21 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
2025-12-25 21:24:44 -08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
2025-12-24 21:07:21 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
2026-01-02 17:40:57 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
|
"github.com/google/uuid"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-26 03:49:55 -08:00
|
|
|
|
// sseDataPrefix matches SSE data lines with optional whitespace after colon.
|
|
|
|
|
|
// Some upstream APIs return non-standard "data:" without space (should be "data: ").
|
|
|
|
|
|
var sseDataPrefix = regexp.MustCompile(`^data:\s*`)
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
const (
|
2026-03-05 14:59:12 +08:00
|
|
|
|
testClaudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true"
|
2025-12-22 22:58:31 +08:00
|
|
|
|
chatgptCodexAPIURL = "https://chatgpt.com/backend-api/codex/responses"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// TestEvent represents a SSE event for account testing
|
|
|
|
|
|
type TestEvent struct {
|
2026-03-11 17:12:57 +08:00
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
|
Text string `json:"text,omitempty"`
|
|
|
|
|
|
Model string `json:"model,omitempty"`
|
|
|
|
|
|
Status string `json:"status,omitempty"`
|
|
|
|
|
|
Code string `json:"code,omitempty"`
|
|
|
|
|
|
ImageURL string `json:"image_url,omitempty"`
|
|
|
|
|
|
MimeType string `json:"mime_type,omitempty"`
|
|
|
|
|
|
Data any `json:"data,omitempty"`
|
|
|
|
|
|
Success bool `json:"success,omitempty"`
|
|
|
|
|
|
Error string `json:"error,omitempty"`
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 17:12:57 +08:00
|
|
|
|
const (
|
|
|
|
|
|
defaultGeminiTextTestPrompt = "hi"
|
|
|
|
|
|
defaultGeminiImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background."
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// AccountTestService handles account testing operations
|
|
|
|
|
|
type AccountTestService struct {
|
2025-12-30 22:42:00 +08:00
|
|
|
|
accountRepo AccountRepository
|
|
|
|
|
|
geminiTokenProvider *GeminiTokenProvider
|
|
|
|
|
|
antigravityGatewayService *AntigravityGatewayService
|
|
|
|
|
|
httpUpstream HTTPUpstream
|
2026-01-02 17:40:57 +08:00
|
|
|
|
cfg *config.Config
|
2026-03-27 14:23:28 +08:00
|
|
|
|
tlsFPProfileService *TLSFingerprintProfileService
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NewAccountTestService creates a new AccountTestService
|
2025-12-25 21:24:44 -08:00
|
|
|
|
func NewAccountTestService(
|
|
|
|
|
|
accountRepo AccountRepository,
|
|
|
|
|
|
geminiTokenProvider *GeminiTokenProvider,
|
2025-12-30 22:42:00 +08:00
|
|
|
|
antigravityGatewayService *AntigravityGatewayService,
|
2025-12-25 21:24:44 -08:00
|
|
|
|
httpUpstream HTTPUpstream,
|
2026-01-02 17:40:57 +08:00
|
|
|
|
cfg *config.Config,
|
2026-03-27 14:23:28 +08:00
|
|
|
|
tlsFPProfileService *TLSFingerprintProfileService,
|
2025-12-25 21:24:44 -08:00
|
|
|
|
) *AccountTestService {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return &AccountTestService{
|
2025-12-30 22:42:00 +08:00
|
|
|
|
accountRepo: accountRepo,
|
|
|
|
|
|
geminiTokenProvider: geminiTokenProvider,
|
|
|
|
|
|
antigravityGatewayService: antigravityGatewayService,
|
|
|
|
|
|
httpUpstream: httpUpstream,
|
2026-01-02 17:40:57 +08:00
|
|
|
|
cfg: cfg,
|
2026-03-27 14:23:28 +08:00
|
|
|
|
tlsFPProfileService: tlsFPProfileService,
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-02 17:40:57 +08:00
|
|
|
|
func (s *AccountTestService) validateUpstreamBaseURL(raw string) (string, error) {
|
|
|
|
|
|
if s.cfg == nil {
|
|
|
|
|
|
return "", errors.New("config is not available")
|
|
|
|
|
|
}
|
2026-01-05 13:54:43 +08:00
|
|
|
|
if !s.cfg.Security.URLAllowlist.Enabled {
|
2026-01-05 14:41:08 +08:00
|
|
|
|
return urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
|
2026-01-05 13:54:43 +08:00
|
|
|
|
}
|
2026-01-02 17:40:57 +08:00
|
|
|
|
normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
|
|
|
|
|
|
AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts,
|
|
|
|
|
|
RequireAllowlist: true,
|
|
|
|
|
|
AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts,
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
return normalized, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 11:08:58 +08:00
|
|
|
|
// generateSessionString generates a Claude Code style session string.
|
|
|
|
|
|
// The output format is determined by the UA version in claude.DefaultHeaders,
|
|
|
|
|
|
// ensuring consistency between the user_id format and the UA sent to upstream.
|
2025-12-20 15:29:52 +08:00
|
|
|
|
func generateSessionString() (string, error) {
|
2026-03-18 11:08:58 +08:00
|
|
|
|
b := make([]byte, 32)
|
|
|
|
|
|
if _, err := rand.Read(b); err != nil {
|
2025-12-20 15:29:52 +08:00
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
2026-03-18 11:08:58 +08:00
|
|
|
|
hex64 := hex.EncodeToString(b)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
sessionUUID := uuid.New().String()
|
2026-03-18 11:08:58 +08:00
|
|
|
|
uaVersion := ExtractCLIVersion(claude.DefaultHeaders["User-Agent"])
|
|
|
|
|
|
return FormatMetadataUserID(hex64, "", sessionUUID, uaVersion), nil
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 15:22:52 +08:00
|
|
|
|
// createTestPayload creates a Claude Code style test request payload
|
2025-12-20 16:19:40 +08:00
|
|
|
|
func createTestPayload(modelID string) (map[string]any, error) {
|
2025-12-20 15:29:52 +08:00
|
|
|
|
sessionID, err := generateSessionString()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 16:19:40 +08:00
|
|
|
|
return map[string]any{
|
2025-12-19 15:59:39 +08:00
|
|
|
|
"model": modelID,
|
2025-12-20 16:19:40 +08:00
|
|
|
|
"messages": []map[string]any{
|
2025-12-18 13:50:39 +08:00
|
|
|
|
{
|
|
|
|
|
|
"role": "user",
|
2025-12-20 16:19:40 +08:00
|
|
|
|
"content": []map[string]any{
|
2025-12-18 13:50:39 +08:00
|
|
|
|
{
|
|
|
|
|
|
"type": "text",
|
|
|
|
|
|
"text": "hi",
|
|
|
|
|
|
"cache_control": map[string]string{
|
|
|
|
|
|
"type": "ephemeral",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2025-12-20 16:19:40 +08:00
|
|
|
|
"system": []map[string]any{
|
2025-12-18 13:50:39 +08:00
|
|
|
|
{
|
|
|
|
|
|
"type": "text",
|
2026-01-29 01:28:43 +08:00
|
|
|
|
"text": claudeCodeSystemPrompt,
|
2025-12-18 13:50:39 +08:00
|
|
|
|
"cache_control": map[string]string{
|
|
|
|
|
|
"type": "ephemeral",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
"metadata": map[string]string{
|
2025-12-20 15:29:52 +08:00
|
|
|
|
"user_id": sessionID,
|
2025-12-18 13:50:39 +08:00
|
|
|
|
},
|
2025-12-19 00:01:43 +08:00
|
|
|
|
"max_tokens": 1024,
|
2025-12-18 13:50:39 +08:00
|
|
|
|
"temperature": 1,
|
2025-12-19 00:01:43 +08:00
|
|
|
|
"stream": true,
|
2025-12-20 15:29:52 +08:00
|
|
|
|
}, nil
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestAccountConnection tests an account's connection by sending a test request
|
2025-12-19 15:22:52 +08:00
|
|
|
|
// All account types use full Claude Code client characteristics, only auth header differs
|
2025-12-19 15:59:39 +08:00
|
|
|
|
// modelID is optional - if empty, defaults to claude.DefaultTestModel
|
2026-03-11 17:12:57 +08:00
|
|
|
|
func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int64, modelID string, prompt string) error {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
|
|
|
|
|
|
|
|
// Get account
|
2025-12-19 21:26:19 +08:00
|
|
|
|
account, err := s.accountRepo.GetByID(ctx, accountID)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, "Account not found")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-22 22:58:31 +08:00
|
|
|
|
// Route to platform-specific test method
|
|
|
|
|
|
if account.IsOpenAI() {
|
|
|
|
|
|
return s.testOpenAIAccountConnection(c, account, modelID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-25 21:24:44 -08:00
|
|
|
|
if account.IsGemini() {
|
2026-03-11 17:12:57 +08:00
|
|
|
|
return s.testGeminiAccountConnection(c, account, modelID, prompt)
|
2025-12-25 21:24:44 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-30 22:42:00 +08:00
|
|
|
|
if account.Platform == PlatformAntigravity {
|
2026-03-11 17:12:57 +08:00
|
|
|
|
return s.routeAntigravityTest(c, account, modelID, prompt)
|
2025-12-30 22:42:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-22 22:58:31 +08:00
|
|
|
|
return s.testClaudeAccountConnection(c, account, modelID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// testClaudeAccountConnection tests an Anthropic Claude account's connection
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account *Account, modelID string) error {
|
2025-12-22 22:58:31 +08:00
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
|
|
|
2025-12-19 15:59:39 +08:00
|
|
|
|
// Determine the model to use
|
|
|
|
|
|
testModelID := modelID
|
|
|
|
|
|
if testModelID == "" {
|
|
|
|
|
|
testModelID = claude.DefaultTestModel
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 18:42:02 +08:00
|
|
|
|
// API Key 账号测试连接时也需要应用通配符模型映射。
|
2025-12-19 15:59:39 +08:00
|
|
|
|
if account.Type == "apikey" {
|
2026-03-12 18:42:02 +08:00
|
|
|
|
testModelID = account.GetMappedModel(testModelID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Bedrock accounts use a separate test path
|
|
|
|
|
|
if account.IsBedrock() {
|
|
|
|
|
|
return s.testBedrockAccountConnection(c, ctx, account, testModelID)
|
2025-12-19 15:59:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 15:22:52 +08:00
|
|
|
|
// Determine authentication method and API URL
|
2025-12-18 13:50:39 +08:00
|
|
|
|
var authToken string
|
2025-12-19 15:22:52 +08:00
|
|
|
|
var useBearer bool
|
2025-12-18 13:50:39 +08:00
|
|
|
|
var apiURL string
|
|
|
|
|
|
|
|
|
|
|
|
if account.IsOAuth() {
|
2025-12-19 15:22:52 +08:00
|
|
|
|
// OAuth or Setup Token - use Bearer token
|
|
|
|
|
|
useBearer = true
|
2025-12-18 13:50:39 +08:00
|
|
|
|
apiURL = testClaudeAPIURL
|
|
|
|
|
|
authToken = account.GetCredential("access_token")
|
|
|
|
|
|
if authToken == "" {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, "No access token available")
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if account.Type == "apikey" {
|
2025-12-19 15:22:52 +08:00
|
|
|
|
// API Key - use x-api-key header
|
|
|
|
|
|
useBearer = false
|
2025-12-18 13:50:39 +08:00
|
|
|
|
authToken = account.GetCredential("api_key")
|
|
|
|
|
|
if authToken == "" {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, "No API key available")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-02 17:40:57 +08:00
|
|
|
|
baseURL := account.GetBaseURL()
|
|
|
|
|
|
if baseURL == "" {
|
|
|
|
|
|
baseURL = "https://api.anthropic.com"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
2026-01-02 17:40:57 +08:00
|
|
|
|
normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid base URL: %s", err.Error()))
|
|
|
|
|
|
}
|
2026-03-05 14:59:12 +08:00
|
|
|
|
apiURL = strings.TrimSuffix(normalizedBaseURL, "/") + "/v1/messages?beta=true"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported account type: %s", account.Type))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Set SSE headers
|
|
|
|
|
|
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
|
|
|
|
|
c.Writer.Header().Set("Cache-Control", "no-cache")
|
|
|
|
|
|
c.Writer.Header().Set("Connection", "keep-alive")
|
|
|
|
|
|
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
|
|
|
|
|
c.Writer.Flush()
|
|
|
|
|
|
|
2025-12-19 15:22:52 +08:00
|
|
|
|
// Create Claude Code style payload (same for all account types)
|
2025-12-20 15:29:52 +08:00
|
|
|
|
payload, err := createTestPayload(testModelID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, "Failed to create test payload")
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
payloadBytes, _ := json.Marshal(payload)
|
|
|
|
|
|
|
2025-12-19 15:22:52 +08:00
|
|
|
|
// Send test_start event
|
2025-12-19 15:59:39 +08:00
|
|
|
|
s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID})
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(payloadBytes))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, "Failed to create request")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 15:22:52 +08:00
|
|
|
|
// Set common headers
|
2025-12-18 13:50:39 +08:00
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
req.Header.Set("anthropic-version", "2023-06-01")
|
2025-12-19 15:22:52 +08:00
|
|
|
|
|
|
|
|
|
|
// Apply Claude Code client headers
|
|
|
|
|
|
for key, value := range claude.DefaultHeaders {
|
|
|
|
|
|
req.Header.Set(key, value)
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2025-12-19 15:22:52 +08:00
|
|
|
|
// Set authentication header
|
|
|
|
|
|
if useBearer {
|
2026-02-08 13:26:28 +08:00
|
|
|
|
req.Header.Set("anthropic-beta", claude.DefaultBetaHeader)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
req.Header.Set("Authorization", "Bearer "+authToken)
|
|
|
|
|
|
} else {
|
2026-02-08 13:26:28 +08:00
|
|
|
|
req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
req.Header.Set("x-api-key", authToken)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 11:56:11 +08:00
|
|
|
|
// Get proxy URL
|
|
|
|
|
|
proxyURL := ""
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if account.ProxyID != nil && account.Proxy != nil {
|
2025-12-20 11:56:11 +08:00
|
|
|
|
proxyURL = account.Proxy.URL()
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 14:23:28 +08:00
|
|
|
|
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
2025-12-22 22:58:31 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
|
|
|
|
|
}
|
|
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
2026-03-17 15:25:51 +08:00
|
|
|
|
errMsg := fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body))
|
|
|
|
|
|
|
|
|
|
|
|
// 403 表示账号被上游封禁,标记为 error 状态
|
|
|
|
|
|
if resp.StatusCode == http.StatusForbidden {
|
|
|
|
|
|
_ = s.accountRepo.SetError(ctx, account.ID, errMsg)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return s.sendErrorAndEnd(c, errMsg)
|
2025-12-22 22:58:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Process SSE stream
|
|
|
|
|
|
return s.processClaudeStream(c, resp.Body)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 18:42:02 +08:00
|
|
|
|
// testBedrockAccountConnection tests a Bedrock (SigV4 or API Key) account using non-streaming invoke
|
|
|
|
|
|
func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx context.Context, account *Account, testModelID string) error {
|
|
|
|
|
|
region := bedrockRuntimeRegion(account)
|
|
|
|
|
|
resolvedModelID, ok := ResolveBedrockModelID(account, testModelID)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported Bedrock model: %s", testModelID))
|
|
|
|
|
|
}
|
|
|
|
|
|
testModelID = resolvedModelID
|
|
|
|
|
|
|
|
|
|
|
|
// Set SSE headers (test UI expects SSE)
|
|
|
|
|
|
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
|
|
|
|
|
c.Writer.Header().Set("Cache-Control", "no-cache")
|
|
|
|
|
|
c.Writer.Header().Set("Connection", "keep-alive")
|
|
|
|
|
|
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
|
|
|
|
|
c.Writer.Flush()
|
|
|
|
|
|
|
|
|
|
|
|
// Create a minimal Bedrock-compatible payload (no stream, no cache_control)
|
|
|
|
|
|
bedrockPayload := map[string]any{
|
|
|
|
|
|
"anthropic_version": "bedrock-2023-05-31",
|
|
|
|
|
|
"messages": []map[string]any{
|
|
|
|
|
|
{
|
|
|
|
|
|
"role": "user",
|
|
|
|
|
|
"content": []map[string]any{
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "text",
|
|
|
|
|
|
"text": "hi",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
"max_tokens": 256,
|
|
|
|
|
|
"temperature": 1,
|
|
|
|
|
|
}
|
|
|
|
|
|
bedrockBody, _ := json.Marshal(bedrockPayload)
|
|
|
|
|
|
|
|
|
|
|
|
// Use non-streaming endpoint (response is standard Claude JSON)
|
|
|
|
|
|
apiURL := BuildBedrockURL(region, testModelID, false)
|
|
|
|
|
|
|
|
|
|
|
|
s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID})
|
|
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(bedrockBody))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, "Failed to create request")
|
|
|
|
|
|
}
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
|
|
|
|
|
|
// Sign or set auth based on account type
|
|
|
|
|
|
if account.IsBedrockAPIKey() {
|
|
|
|
|
|
apiKey := account.GetCredential("api_key")
|
|
|
|
|
|
if apiKey == "" {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, "No API key available")
|
|
|
|
|
|
}
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
signer, err := NewBedrockSignerFromAccount(account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to create Bedrock signer: %s", err.Error()))
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := signer.SignRequest(ctx, req, bedrockBody); err != nil {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to sign request: %s", err.Error()))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
proxyURL := ""
|
|
|
|
|
|
if account.ProxyID != nil && account.Proxy != nil {
|
|
|
|
|
|
proxyURL = account.Proxy.URL()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 14:23:28 +08:00
|
|
|
|
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, nil)
|
2026-03-12 18:42:02 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
|
|
|
|
|
}
|
|
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
|
|
|
|
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body)))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Bedrock non-streaming response is standard Claude JSON, extract the text
|
|
|
|
|
|
var result struct {
|
|
|
|
|
|
Content []struct {
|
|
|
|
|
|
Text string `json:"text"`
|
|
|
|
|
|
} `json:"content"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to parse response: %s", err.Error()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
text := ""
|
|
|
|
|
|
if len(result.Content) > 0 {
|
|
|
|
|
|
text = result.Content[0].Text
|
|
|
|
|
|
}
|
|
|
|
|
|
if text == "" {
|
|
|
|
|
|
text = "(empty response)"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
s.sendEvent(c, TestEvent{Type: "content", Text: text})
|
|
|
|
|
|
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-22 22:58:31 +08:00
|
|
|
|
// testOpenAIAccountConnection tests an OpenAI account's connection
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account *Account, modelID string) error {
|
2025-12-22 22:58:31 +08:00
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
|
|
|
|
|
|
|
|
// Default to openai.DefaultTestModel for OpenAI testing
|
|
|
|
|
|
testModelID := modelID
|
|
|
|
|
|
if testModelID == "" {
|
|
|
|
|
|
testModelID = openai.DefaultTestModel
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// For API Key accounts with model mapping, map the model
|
|
|
|
|
|
if account.Type == "apikey" {
|
|
|
|
|
|
mapping := account.GetModelMapping()
|
|
|
|
|
|
if len(mapping) > 0 {
|
|
|
|
|
|
if mappedModel, exists := mapping[testModelID]; exists {
|
|
|
|
|
|
testModelID = mappedModel
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Determine authentication method and API URL
|
|
|
|
|
|
var authToken string
|
|
|
|
|
|
var apiURL string
|
|
|
|
|
|
var isOAuth bool
|
|
|
|
|
|
var chatgptAccountID string
|
|
|
|
|
|
|
|
|
|
|
|
if account.IsOAuth() {
|
|
|
|
|
|
isOAuth = true
|
|
|
|
|
|
// OAuth - use Bearer token with ChatGPT internal API
|
|
|
|
|
|
authToken = account.GetOpenAIAccessToken()
|
|
|
|
|
|
if authToken == "" {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, "No access token available")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// OAuth uses ChatGPT internal API
|
|
|
|
|
|
apiURL = chatgptCodexAPIURL
|
|
|
|
|
|
chatgptAccountID = account.GetChatGPTAccountID()
|
|
|
|
|
|
} else if account.Type == "apikey" {
|
|
|
|
|
|
// API Key - use Platform API
|
|
|
|
|
|
authToken = account.GetOpenAIApiKey()
|
|
|
|
|
|
if authToken == "" {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, "No API key available")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
baseURL := account.GetOpenAIBaseURL()
|
|
|
|
|
|
if baseURL == "" {
|
|
|
|
|
|
baseURL = "https://api.openai.com"
|
|
|
|
|
|
}
|
2026-01-02 17:40:57 +08:00
|
|
|
|
normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid base URL: %s", err.Error()))
|
|
|
|
|
|
}
|
|
|
|
|
|
apiURL = strings.TrimSuffix(normalizedBaseURL, "/") + "/responses"
|
2025-12-22 22:58:31 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported account type: %s", account.Type))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Set SSE headers
|
|
|
|
|
|
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
|
|
|
|
|
c.Writer.Header().Set("Cache-Control", "no-cache")
|
|
|
|
|
|
c.Writer.Header().Set("Connection", "keep-alive")
|
|
|
|
|
|
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
|
|
|
|
|
c.Writer.Flush()
|
|
|
|
|
|
|
|
|
|
|
|
// Create OpenAI Responses API payload
|
|
|
|
|
|
payload := createOpenAITestPayload(testModelID, isOAuth)
|
|
|
|
|
|
payloadBytes, _ := json.Marshal(payload)
|
|
|
|
|
|
|
|
|
|
|
|
// Send test_start event
|
|
|
|
|
|
s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID})
|
|
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(payloadBytes))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, "Failed to create request")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Set common headers
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+authToken)
|
|
|
|
|
|
|
|
|
|
|
|
// Set OAuth-specific headers for ChatGPT internal API
|
|
|
|
|
|
if isOAuth {
|
|
|
|
|
|
req.Host = "chatgpt.com"
|
|
|
|
|
|
req.Header.Set("accept", "text/event-stream")
|
|
|
|
|
|
if chatgptAccountID != "" {
|
|
|
|
|
|
req.Header.Set("chatgpt-account-id", chatgptAccountID)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get proxy URL
|
|
|
|
|
|
proxyURL := ""
|
|
|
|
|
|
if account.ProxyID != nil && account.Proxy != nil {
|
|
|
|
|
|
proxyURL = account.Proxy.URL()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 14:23:28 +08:00
|
|
|
|
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
|
|
|
|
|
}
|
2025-12-20 15:29:52 +08:00
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2026-03-08 04:37:03 +08:00
|
|
|
|
if isOAuth && s.accountRepo != nil {
|
|
|
|
|
|
if updates, err := extractOpenAICodexProbeUpdates(resp); err == nil && len(updates) > 0 {
|
|
|
|
|
|
_ = s.accountRepo.UpdateExtra(ctx, account.ID, updates)
|
|
|
|
|
|
mergeAccountExtra(account, updates)
|
|
|
|
|
|
}
|
|
|
|
|
|
if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil {
|
|
|
|
|
|
if resetAt := codexRateLimitResetAtFromSnapshot(snapshot, time.Now()); resetAt != nil {
|
|
|
|
|
|
_ = s.accountRepo.SetRateLimited(ctx, account.ID, *resetAt)
|
|
|
|
|
|
account.RateLimitResetAt = resetAt
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
2026-03-07 20:02:58 +08:00
|
|
|
|
if isOAuth && s.accountRepo != nil {
|
|
|
|
|
|
if resetAt := (&RateLimitService{}).calculateOpenAI429ResetTime(resp.Header); resetAt != nil {
|
|
|
|
|
|
_ = s.accountRepo.SetRateLimited(ctx, account.ID, *resetAt)
|
|
|
|
|
|
account.RateLimitResetAt = resetAt
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-02 20:44:05 +08:00
|
|
|
|
// 401 Unauthorized: 标记账号为永久错误
|
|
|
|
|
|
if resp.StatusCode == http.StatusUnauthorized && s.accountRepo != nil {
|
|
|
|
|
|
errMsg := fmt.Sprintf("Authentication failed (401): %s", string(body))
|
|
|
|
|
|
_ = s.accountRepo.SetError(ctx, account.ID, errMsg)
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return s.sendErrorAndEnd(c, fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body)))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Process SSE stream
|
2025-12-22 22:58:31 +08:00
|
|
|
|
return s.processOpenAIStream(c, resp.Body)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-25 21:24:44 -08:00
|
|
|
|
// testGeminiAccountConnection tests a Gemini account's connection
|
2026-03-11 17:12:57 +08:00
|
|
|
|
func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account *Account, modelID string, prompt string) error {
|
2025-12-25 21:24:44 -08:00
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
|
|
|
|
|
|
|
|
// Determine the model to use
|
|
|
|
|
|
testModelID := modelID
|
|
|
|
|
|
if testModelID == "" {
|
|
|
|
|
|
testModelID = geminicli.DefaultTestModel
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// For API Key accounts with model mapping, map the model
|
2026-01-04 19:27:53 +08:00
|
|
|
|
if account.Type == AccountTypeAPIKey {
|
2025-12-25 21:24:44 -08:00
|
|
|
|
mapping := account.GetModelMapping()
|
|
|
|
|
|
if len(mapping) > 0 {
|
|
|
|
|
|
if mappedModel, exists := mapping[testModelID]; exists {
|
|
|
|
|
|
testModelID = mappedModel
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Set SSE headers
|
|
|
|
|
|
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
|
|
|
|
|
c.Writer.Header().Set("Cache-Control", "no-cache")
|
|
|
|
|
|
c.Writer.Header().Set("Connection", "keep-alive")
|
|
|
|
|
|
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
|
|
|
|
|
c.Writer.Flush()
|
|
|
|
|
|
|
|
|
|
|
|
// Create test payload (Gemini format)
|
2026-03-11 17:12:57 +08:00
|
|
|
|
payload := createGeminiTestPayload(testModelID, prompt)
|
2025-12-25 21:24:44 -08:00
|
|
|
|
|
|
|
|
|
|
// Build request based on account type
|
|
|
|
|
|
var req *http.Request
|
|
|
|
|
|
var err error
|
|
|
|
|
|
|
|
|
|
|
|
switch account.Type {
|
2026-01-04 19:27:53 +08:00
|
|
|
|
case AccountTypeAPIKey:
|
2025-12-25 21:24:44 -08:00
|
|
|
|
req, err = s.buildGeminiAPIKeyRequest(ctx, account, testModelID, payload)
|
2025-12-26 22:07:55 +08:00
|
|
|
|
case AccountTypeOAuth:
|
2025-12-25 21:24:44 -08:00
|
|
|
|
req, err = s.buildGeminiOAuthRequest(ctx, account, testModelID, payload)
|
|
|
|
|
|
default:
|
|
|
|
|
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported account type: %s", account.Type))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to build request: %s", err.Error()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Send test_start event
|
|
|
|
|
|
s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID})
|
|
|
|
|
|
|
|
|
|
|
|
// Get proxy and execute request
|
|
|
|
|
|
proxyURL := ""
|
|
|
|
|
|
if account.ProxyID != nil && account.Proxy != nil {
|
|
|
|
|
|
proxyURL = account.Proxy.URL()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 14:23:28 +08:00
|
|
|
|
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
2025-12-25 21:24:44 -08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
|
|
|
|
|
}
|
|
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
|
|
return s.sendErrorAndEnd(c, fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body)))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Process SSE stream
|
|
|
|
|
|
return s.processGeminiStream(c, resp.Body)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 22:10:00 +08:00
|
|
|
|
// routeAntigravityTest 路由 Antigravity 账号的测试请求。
|
|
|
|
|
|
// APIKey 类型走原生协议(与 gateway_handler 路由一致),OAuth/Upstream 走 CRS 中转。
|
2026-03-11 17:12:57 +08:00
|
|
|
|
func (s *AccountTestService) routeAntigravityTest(c *gin.Context, account *Account, modelID string, prompt string) error {
|
2026-03-05 22:10:00 +08:00
|
|
|
|
if account.Type == AccountTypeAPIKey {
|
|
|
|
|
|
if strings.HasPrefix(modelID, "gemini-") {
|
2026-03-11 17:12:57 +08:00
|
|
|
|
return s.testGeminiAccountConnection(c, account, modelID, prompt)
|
2026-03-05 22:10:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
return s.testClaudeAccountConnection(c, account, modelID)
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.testAntigravityAccountConnection(c, account, modelID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-30 22:42:00 +08:00
|
|
|
|
// testAntigravityAccountConnection tests an Antigravity account's connection
|
|
|
|
|
|
// 支持 Claude 和 Gemini 两种协议,使用非流式请求
|
|
|
|
|
|
func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, account *Account, modelID string) error {
|
|
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
|
|
|
|
|
|
|
|
// 默认模型:Claude 使用 claude-sonnet-4-5,Gemini 使用 gemini-3-pro-preview
|
|
|
|
|
|
testModelID := modelID
|
|
|
|
|
|
if testModelID == "" {
|
|
|
|
|
|
testModelID = "claude-sonnet-4-5"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if s.antigravityGatewayService == nil {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, "Antigravity gateway service not configured")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Set SSE headers
|
|
|
|
|
|
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
|
|
|
|
|
c.Writer.Header().Set("Cache-Control", "no-cache")
|
|
|
|
|
|
c.Writer.Header().Set("Connection", "keep-alive")
|
|
|
|
|
|
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
|
|
|
|
|
c.Writer.Flush()
|
|
|
|
|
|
|
|
|
|
|
|
// Send test_start event
|
|
|
|
|
|
s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID})
|
|
|
|
|
|
|
|
|
|
|
|
// 调用 AntigravityGatewayService.TestConnection(复用协议转换逻辑)
|
|
|
|
|
|
result, err := s.antigravityGatewayService.TestConnection(ctx, account, testModelID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return s.sendErrorAndEnd(c, err.Error())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 发送响应内容
|
|
|
|
|
|
if result.Text != "" {
|
|
|
|
|
|
s.sendEvent(c, TestEvent{Type: "content", Text: result.Text})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-25 21:24:44 -08:00
|
|
|
|
// buildGeminiAPIKeyRequest builds request for Gemini API Key accounts
|
2025-12-26 22:07:55 +08:00
|
|
|
|
func (s *AccountTestService) buildGeminiAPIKeyRequest(ctx context.Context, account *Account, modelID string, payload []byte) (*http.Request, error) {
|
2025-12-25 21:24:44 -08:00
|
|
|
|
apiKey := account.GetCredential("api_key")
|
|
|
|
|
|
if strings.TrimSpace(apiKey) == "" {
|
2025-12-25 21:35:30 -08:00
|
|
|
|
return nil, fmt.Errorf("no API key available")
|
2025-12-25 21:24:44 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
baseURL := account.GetCredential("base_url")
|
|
|
|
|
|
if baseURL == "" {
|
|
|
|
|
|
baseURL = geminicli.AIStudioBaseURL
|
|
|
|
|
|
}
|
2026-01-02 17:40:57 +08:00
|
|
|
|
normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
2025-12-25 21:24:44 -08:00
|
|
|
|
|
|
|
|
|
|
// Use streamGenerateContent for real-time feedback
|
|
|
|
|
|
fullURL := fmt.Sprintf("%s/v1beta/models/%s:streamGenerateContent?alt=sse",
|
2026-01-02 17:40:57 +08:00
|
|
|
|
strings.TrimRight(normalizedBaseURL, "/"), modelID)
|
2025-12-25 21:24:44 -08:00
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", fullURL, bytes.NewReader(payload))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
req.Header.Set("x-goog-api-key", apiKey)
|
|
|
|
|
|
|
|
|
|
|
|
return req, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// buildGeminiOAuthRequest builds request for Gemini OAuth accounts
|
2025-12-26 22:07:55 +08:00
|
|
|
|
func (s *AccountTestService) buildGeminiOAuthRequest(ctx context.Context, account *Account, modelID string, payload []byte) (*http.Request, error) {
|
2025-12-25 21:24:44 -08:00
|
|
|
|
if s.geminiTokenProvider == nil {
|
2025-12-25 21:35:30 -08:00
|
|
|
|
return nil, fmt.Errorf("gemini token provider not configured")
|
2025-12-25 21:24:44 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get access token (auto-refreshes if needed)
|
|
|
|
|
|
accessToken, err := s.geminiTokenProvider.GetAccessToken(ctx, account)
|
|
|
|
|
|
if err != nil {
|
2025-12-25 21:35:30 -08:00
|
|
|
|
return nil, fmt.Errorf("failed to get access token: %w", err)
|
2025-12-25 21:24:44 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
projectID := strings.TrimSpace(account.GetCredential("project_id"))
|
|
|
|
|
|
if projectID == "" {
|
|
|
|
|
|
// AI Studio OAuth mode (no project_id): call generativelanguage API directly with Bearer token.
|
|
|
|
|
|
baseURL := account.GetCredential("base_url")
|
|
|
|
|
|
if strings.TrimSpace(baseURL) == "" {
|
|
|
|
|
|
baseURL = geminicli.AIStudioBaseURL
|
|
|
|
|
|
}
|
2026-01-02 17:40:57 +08:00
|
|
|
|
normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
fullURL := fmt.Sprintf("%s/v1beta/models/%s:streamGenerateContent?alt=sse", strings.TrimRight(normalizedBaseURL, "/"), modelID)
|
2025-12-25 21:24:44 -08:00
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(payload))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
|
|
|
|
return req, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-30 22:42:00 +08:00
|
|
|
|
// Code Assist mode (with project_id)
|
|
|
|
|
|
return s.buildCodeAssistRequest(ctx, accessToken, projectID, modelID, payload)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// buildCodeAssistRequest builds request for Google Code Assist API (used by Gemini CLI and Antigravity)
|
|
|
|
|
|
func (s *AccountTestService) buildCodeAssistRequest(ctx context.Context, accessToken, projectID, modelID string, payload []byte) (*http.Request, error) {
|
2025-12-25 21:24:44 -08:00
|
|
|
|
var inner map[string]any
|
|
|
|
|
|
if err := json.Unmarshal(payload, &inner); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
wrapped := map[string]any{
|
|
|
|
|
|
"model": modelID,
|
|
|
|
|
|
"project": projectID,
|
|
|
|
|
|
"request": inner,
|
|
|
|
|
|
}
|
|
|
|
|
|
wrappedBytes, _ := json.Marshal(wrapped)
|
|
|
|
|
|
|
2026-01-02 17:40:57 +08:00
|
|
|
|
normalizedBaseURL, err := s.validateUpstreamBaseURL(geminicli.GeminiCliBaseURL)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
fullURL := fmt.Sprintf("%s/v1internal:streamGenerateContent?alt=sse", normalizedBaseURL)
|
2025-12-25 21:24:44 -08:00
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", fullURL, bytes.NewReader(wrappedBytes))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
|
|
|
|
req.Header.Set("User-Agent", geminicli.GeminiCLIUserAgent)
|
|
|
|
|
|
|
|
|
|
|
|
return req, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 17:12:57 +08:00
|
|
|
|
// createGeminiTestPayload creates a minimal test payload for Gemini API.
|
|
|
|
|
|
// Image models use the image-generation path so the frontend can preview the returned image.
|
|
|
|
|
|
func createGeminiTestPayload(modelID string, prompt string) []byte {
|
|
|
|
|
|
if isImageGenerationModel(modelID) {
|
|
|
|
|
|
imagePrompt := strings.TrimSpace(prompt)
|
|
|
|
|
|
if imagePrompt == "" {
|
|
|
|
|
|
imagePrompt = defaultGeminiImageTestPrompt
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
payload := map[string]any{
|
|
|
|
|
|
"contents": []map[string]any{
|
|
|
|
|
|
{
|
|
|
|
|
|
"role": "user",
|
|
|
|
|
|
"parts": []map[string]any{
|
|
|
|
|
|
{"text": imagePrompt},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
"generationConfig": map[string]any{
|
|
|
|
|
|
"responseModalities": []string{"TEXT", "IMAGE"},
|
|
|
|
|
|
"imageConfig": map[string]any{
|
|
|
|
|
|
"aspectRatio": "1:1",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
bytes, _ := json.Marshal(payload)
|
|
|
|
|
|
return bytes
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
textPrompt := strings.TrimSpace(prompt)
|
|
|
|
|
|
if textPrompt == "" {
|
|
|
|
|
|
textPrompt = defaultGeminiTextTestPrompt
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-25 21:24:44 -08:00
|
|
|
|
payload := map[string]any{
|
|
|
|
|
|
"contents": []map[string]any{
|
|
|
|
|
|
{
|
|
|
|
|
|
"role": "user",
|
|
|
|
|
|
"parts": []map[string]any{
|
2026-03-11 17:12:57 +08:00
|
|
|
|
{"text": textPrompt},
|
2025-12-25 21:24:44 -08:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
"systemInstruction": map[string]any{
|
|
|
|
|
|
"parts": []map[string]any{
|
|
|
|
|
|
{"text": "You are a helpful AI assistant."},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
bytes, _ := json.Marshal(payload)
|
|
|
|
|
|
return bytes
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// processGeminiStream processes SSE stream from Gemini API
|
|
|
|
|
|
func (s *AccountTestService) processGeminiStream(c *gin.Context, body io.Reader) error {
|
|
|
|
|
|
reader := bufio.NewReader(body)
|
|
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
|
line, err := reader.ReadString('\n')
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
if err == io.EOF {
|
|
|
|
|
|
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Stream read error: %s", err.Error()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
line = strings.TrimSpace(line)
|
|
|
|
|
|
if line == "" || !strings.HasPrefix(line, "data: ") {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
jsonStr := strings.TrimPrefix(line, "data: ")
|
|
|
|
|
|
if jsonStr == "[DONE]" {
|
|
|
|
|
|
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var data map[string]any
|
|
|
|
|
|
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 22:31:12 +08:00
|
|
|
|
// Support two Gemini response formats:
|
|
|
|
|
|
// - AI Studio: {"candidates": [...]}
|
|
|
|
|
|
// - Gemini CLI: {"response": {"candidates": [...]}}
|
|
|
|
|
|
if resp, ok := data["response"].(map[string]any); ok && resp != nil {
|
|
|
|
|
|
data = resp
|
|
|
|
|
|
}
|
2025-12-25 21:24:44 -08:00
|
|
|
|
if candidates, ok := data["candidates"].([]any); ok && len(candidates) > 0 {
|
|
|
|
|
|
if candidate, ok := candidates[0].(map[string]any); ok {
|
2026-01-08 23:47:29 +08:00
|
|
|
|
// Extract content first (before checking completion)
|
2025-12-25 21:24:44 -08:00
|
|
|
|
if content, ok := candidate["content"].(map[string]any); ok {
|
|
|
|
|
|
if parts, ok := content["parts"].([]any); ok {
|
|
|
|
|
|
for _, part := range parts {
|
|
|
|
|
|
if partMap, ok := part.(map[string]any); ok {
|
|
|
|
|
|
if text, ok := partMap["text"].(string); ok && text != "" {
|
|
|
|
|
|
s.sendEvent(c, TestEvent{Type: "content", Text: text})
|
|
|
|
|
|
}
|
2026-03-11 17:12:57 +08:00
|
|
|
|
if inlineData, ok := partMap["inlineData"].(map[string]any); ok {
|
|
|
|
|
|
mimeType, _ := inlineData["mimeType"].(string)
|
|
|
|
|
|
data, _ := inlineData["data"].(string)
|
|
|
|
|
|
if strings.HasPrefix(strings.ToLower(mimeType), "image/") && data != "" {
|
|
|
|
|
|
s.sendEvent(c, TestEvent{
|
|
|
|
|
|
Type: "image",
|
|
|
|
|
|
ImageURL: fmt.Sprintf("data:%s;base64,%s", mimeType, data),
|
|
|
|
|
|
MimeType: mimeType,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-25 21:24:44 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-08 23:47:29 +08:00
|
|
|
|
|
|
|
|
|
|
// Check for completion after extracting content
|
|
|
|
|
|
if finishReason, ok := candidate["finishReason"].(string); ok && finishReason != "" {
|
|
|
|
|
|
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2025-12-25 21:24:44 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Handle errors
|
|
|
|
|
|
if errData, ok := data["error"].(map[string]any); ok {
|
|
|
|
|
|
errorMsg := "Unknown error"
|
|
|
|
|
|
if msg, ok := errData["message"].(string); ok {
|
|
|
|
|
|
errorMsg = msg
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.sendErrorAndEnd(c, errorMsg)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-22 22:58:31 +08:00
|
|
|
|
// createOpenAITestPayload creates a test payload for OpenAI Responses API
|
|
|
|
|
|
func createOpenAITestPayload(modelID string, isOAuth bool) map[string]any {
|
|
|
|
|
|
payload := map[string]any{
|
|
|
|
|
|
"model": modelID,
|
|
|
|
|
|
"input": []map[string]any{
|
|
|
|
|
|
{
|
|
|
|
|
|
"role": "user",
|
|
|
|
|
|
"content": []map[string]any{
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "input_text",
|
|
|
|
|
|
"text": "hi",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
"stream": true,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-25 14:45:17 +08:00
|
|
|
|
// OAuth accounts using ChatGPT internal API require store: false
|
2025-12-22 22:58:31 +08:00
|
|
|
|
if isOAuth {
|
|
|
|
|
|
payload["store"] = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-25 14:45:17 +08:00
|
|
|
|
// All accounts require instructions for Responses API
|
|
|
|
|
|
payload["instructions"] = openai.DefaultInstructions
|
|
|
|
|
|
|
2025-12-22 22:58:31 +08:00
|
|
|
|
return payload
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-22 22:58:31 +08:00
|
|
|
|
// processClaudeStream processes the SSE stream from Claude API
|
|
|
|
|
|
func (s *AccountTestService) processClaudeStream(c *gin.Context, body io.Reader) error {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
reader := bufio.NewReader(body)
|
|
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
|
line, err := reader.ReadString('\n')
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
if err == io.EOF {
|
|
|
|
|
|
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Stream read error: %s", err.Error()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
line = strings.TrimSpace(line)
|
2025-12-26 03:49:55 -08:00
|
|
|
|
if line == "" || !sseDataPrefix.MatchString(line) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 03:49:55 -08:00
|
|
|
|
jsonStr := sseDataPrefix.ReplaceAllString(line, "")
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if jsonStr == "[DONE]" {
|
|
|
|
|
|
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 16:19:40 +08:00
|
|
|
|
var data map[string]any
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
eventType, _ := data["type"].(string)
|
|
|
|
|
|
|
|
|
|
|
|
switch eventType {
|
|
|
|
|
|
case "content_block_delta":
|
2025-12-20 16:19:40 +08:00
|
|
|
|
if delta, ok := data["delta"].(map[string]any); ok {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if text, ok := delta["text"].(string); ok {
|
|
|
|
|
|
s.sendEvent(c, TestEvent{Type: "content", Text: text})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
case "message_stop":
|
|
|
|
|
|
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
|
|
|
|
|
return nil
|
|
|
|
|
|
case "error":
|
|
|
|
|
|
errorMsg := "Unknown error"
|
2025-12-20 16:19:40 +08:00
|
|
|
|
if errData, ok := data["error"].(map[string]any); ok {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if msg, ok := errData["message"].(string); ok {
|
|
|
|
|
|
errorMsg = msg
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.sendErrorAndEnd(c, errorMsg)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-22 22:58:31 +08:00
|
|
|
|
// processOpenAIStream processes the SSE stream from OpenAI Responses API
|
|
|
|
|
|
func (s *AccountTestService) processOpenAIStream(c *gin.Context, body io.Reader) error {
|
|
|
|
|
|
reader := bufio.NewReader(body)
|
|
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
|
line, err := reader.ReadString('\n')
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
if err == io.EOF {
|
|
|
|
|
|
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Stream read error: %s", err.Error()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
line = strings.TrimSpace(line)
|
2025-12-26 03:49:55 -08:00
|
|
|
|
if line == "" || !sseDataPrefix.MatchString(line) {
|
2025-12-22 22:58:31 +08:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 03:49:55 -08:00
|
|
|
|
jsonStr := sseDataPrefix.ReplaceAllString(line, "")
|
2025-12-22 22:58:31 +08:00
|
|
|
|
if jsonStr == "[DONE]" {
|
|
|
|
|
|
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var data map[string]any
|
|
|
|
|
|
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
eventType, _ := data["type"].(string)
|
|
|
|
|
|
|
|
|
|
|
|
switch eventType {
|
|
|
|
|
|
case "response.output_text.delta":
|
|
|
|
|
|
// OpenAI Responses API uses "delta" field for text content
|
|
|
|
|
|
if delta, ok := data["delta"].(string); ok && delta != "" {
|
|
|
|
|
|
s.sendEvent(c, TestEvent{Type: "content", Text: delta})
|
|
|
|
|
|
}
|
|
|
|
|
|
case "response.completed":
|
|
|
|
|
|
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
|
|
|
|
|
return nil
|
|
|
|
|
|
case "error":
|
|
|
|
|
|
errorMsg := "Unknown error"
|
|
|
|
|
|
if errData, ok := data["error"].(map[string]any); ok {
|
|
|
|
|
|
if msg, ok := errData["message"].(string); ok {
|
|
|
|
|
|
errorMsg = msg
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.sendErrorAndEnd(c, errorMsg)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// sendEvent sends a SSE event to the client
|
|
|
|
|
|
func (s *AccountTestService) sendEvent(c *gin.Context, event TestEvent) {
|
|
|
|
|
|
eventJSON, _ := json.Marshal(event)
|
2025-12-20 15:29:52 +08:00
|
|
|
|
if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", eventJSON); err != nil {
|
|
|
|
|
|
log.Printf("failed to write SSE event: %v", err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
c.Writer.Flush()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// sendErrorAndEnd sends an error event and ends the stream
|
|
|
|
|
|
func (s *AccountTestService) sendErrorAndEnd(c *gin.Context, errorMsg string) error {
|
|
|
|
|
|
log.Printf("Account test error: %s", errorMsg)
|
|
|
|
|
|
s.sendEvent(c, TestEvent{Type: "error", Error: errorMsg})
|
2025-12-19 00:01:43 +08:00
|
|
|
|
return fmt.Errorf("%s", errorMsg)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
2026-03-05 16:06:05 +08:00
|
|
|
|
|
|
|
|
|
|
// RunTestBackground executes an account test in-memory (no real HTTP client),
|
|
|
|
|
|
// capturing SSE output via httptest.NewRecorder, then parses the result.
|
2026-03-05 16:37:07 +08:00
|
|
|
|
func (s *AccountTestService) RunTestBackground(ctx context.Context, accountID int64, modelID string) (*ScheduledTestResult, error) {
|
2026-03-05 16:06:05 +08:00
|
|
|
|
startedAt := time.Now()
|
|
|
|
|
|
|
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
ginCtx, _ := gin.CreateTestContext(w)
|
|
|
|
|
|
ginCtx.Request = (&http.Request{}).WithContext(ctx)
|
|
|
|
|
|
|
2026-03-11 17:12:57 +08:00
|
|
|
|
testErr := s.TestAccountConnection(ginCtx, accountID, modelID, "")
|
2026-03-05 16:06:05 +08:00
|
|
|
|
|
|
|
|
|
|
finishedAt := time.Now()
|
|
|
|
|
|
body := w.Body.String()
|
|
|
|
|
|
responseText, errMsg := parseTestSSEOutput(body)
|
|
|
|
|
|
|
2026-03-05 16:37:07 +08:00
|
|
|
|
status := "success"
|
2026-03-05 16:06:05 +08:00
|
|
|
|
if testErr != nil || errMsg != "" {
|
2026-03-05 16:37:07 +08:00
|
|
|
|
status = "failed"
|
2026-03-05 16:06:05 +08:00
|
|
|
|
if errMsg == "" && testErr != nil {
|
2026-03-05 16:37:07 +08:00
|
|
|
|
errMsg = testErr.Error()
|
2026-03-05 16:06:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 16:37:07 +08:00
|
|
|
|
return &ScheduledTestResult{
|
|
|
|
|
|
Status: status,
|
|
|
|
|
|
ResponseText: responseText,
|
|
|
|
|
|
ErrorMessage: errMsg,
|
|
|
|
|
|
LatencyMs: finishedAt.Sub(startedAt).Milliseconds(),
|
|
|
|
|
|
StartedAt: startedAt,
|
|
|
|
|
|
FinishedAt: finishedAt,
|
|
|
|
|
|
}, nil
|
2026-03-05 16:06:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// parseTestSSEOutput extracts response text and error message from captured SSE output.
|
|
|
|
|
|
func parseTestSSEOutput(body string) (responseText, errMsg string) {
|
|
|
|
|
|
var texts []string
|
|
|
|
|
|
for _, line := range strings.Split(body, "\n") {
|
|
|
|
|
|
line = strings.TrimSpace(line)
|
|
|
|
|
|
if !strings.HasPrefix(line, "data: ") {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
jsonStr := strings.TrimPrefix(line, "data: ")
|
|
|
|
|
|
var event TestEvent
|
|
|
|
|
|
if err := json.Unmarshal([]byte(jsonStr), &event); err != nil {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
switch event.Type {
|
|
|
|
|
|
case "content":
|
|
|
|
|
|
if event.Text != "" {
|
|
|
|
|
|
texts = append(texts, event.Text)
|
|
|
|
|
|
}
|
|
|
|
|
|
case "error":
|
|
|
|
|
|
errMsg = event.Error
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
responseText = strings.Join(texts, "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|