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"
2026-02-19 15:09:58 +08:00
"net/url"
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
"sync"
"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-02-19 20:04:10 +08:00
"github.com/Wei-Shaw/sub2api/internal/util/soraerror"
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"
2026-01-30 14:08:04 +08:00
soraMeAPIURL = "https://sora.chatgpt.com/backend/me" // Sora 用户信息接口,用于测试连接
2026-02-19 08:02:56 +08:00
soraBillingAPIURL = "https://sora.chatgpt.com/backend/billing/subscriptions"
2026-02-19 08:29:51 +08:00
soraInviteMineURL = "https://sora.chatgpt.com/backend/project_y/invite/mine"
soraBootstrapURL = "https://sora.chatgpt.com/backend/m/bootstrap"
soraRemainingURL = "https://sora.chatgpt.com/backend/nf/check"
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-02-19 20:04:10 +08:00
soraTestGuardMu sync . Mutex
soraTestLastRun map [ int64 ] time . Time
soraTestCooldown time . Duration
2025-12-18 13:50:39 +08:00
}
2026-02-19 20:04:10 +08:00
const defaultSoraTestCooldown = 10 * time . Second
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 ,
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-02-19 20:04:10 +08:00
soraTestLastRun : make ( map [ int64 ] time . Time ) ,
soraTestCooldown : defaultSoraTestCooldown ,
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
}
2025-12-18 13:50:39 +08:00
// generateSessionString generates a Claude Code style session string
2025-12-20 15:29:52 +08:00
func generateSessionString ( ) ( string , error ) {
2025-12-18 13:50:39 +08:00
bytes := make ( [ ] byte , 32 )
2025-12-20 15:29:52 +08:00
if _ , err := rand . Read ( bytes ) ; err != nil {
return "" , err
}
2025-12-18 13:50:39 +08:00
hex64 := hex . EncodeToString ( bytes )
sessionUUID := uuid . New ( ) . String ( )
2025-12-20 15:29:52 +08:00
return fmt . Sprintf ( "user_%s_account__session_%s" , hex64 , sessionUUID ) , 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
}
2026-01-30 14:08:04 +08:00
if account . Platform == PlatformSora {
return s . testSoraAccountConnection ( c , account )
}
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
}
// For API Key accounts with model mapping, map the model
if account . Type == "apikey" {
mapping := account . GetModelMapping ( )
2025-12-20 15:29:52 +08:00
if len ( mapping ) > 0 {
2025-12-19 15:59:39 +08:00
if mappedModel , exists := mapping [ testModelID ] ; exists {
testModelID = mappedModel
}
}
}
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-01-18 20:06:56 +08:00
resp , err := s . httpUpstream . DoWithTLS ( req , proxyURL , account . ID , account . Concurrency , account . IsTLSFingerprintEnabled ( ) )
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 )
return s . sendErrorAndEnd ( c , fmt . Sprintf ( "API returned %d: %s" , resp . StatusCode , string ( body ) ) )
}
// Process SSE stream
return s . processClaudeStream ( c , resp . Body )
}
// 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-01-18 20:06:56 +08:00
resp , err := s . httpUpstream . DoWithTLS ( req , proxyURL , account . ID , account . Concurrency , account . IsTLSFingerprintEnabled ( ) )
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
}
}
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-01-18 20:06:56 +08:00
resp , err := s . httpUpstream . DoWithTLS ( req , proxyURL , account . ID , account . Concurrency , account . IsTLSFingerprintEnabled ( ) )
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-02-19 20:04:10 +08:00
type soraProbeStep struct {
Name string ` json:"name" `
Status string ` json:"status" `
HTTPStatus int ` json:"http_status,omitempty" `
ErrorCode string ` json:"error_code,omitempty" `
Message string ` json:"message,omitempty" `
}
type soraProbeSummary struct {
Status string ` json:"status" `
Steps [ ] soraProbeStep ` json:"steps" `
}
type soraProbeRecorder struct {
steps [ ] soraProbeStep
}
func ( r * soraProbeRecorder ) addStep ( name , status string , httpStatus int , errorCode , message string ) {
r . steps = append ( r . steps , soraProbeStep {
Name : name ,
Status : status ,
HTTPStatus : httpStatus ,
ErrorCode : strings . TrimSpace ( errorCode ) ,
Message : strings . TrimSpace ( message ) ,
} )
}
func ( r * soraProbeRecorder ) finalize ( ) soraProbeSummary {
meSuccess := false
partial := false
for _ , step := range r . steps {
if step . Name == "me" {
meSuccess = strings . EqualFold ( step . Status , "success" )
continue
}
if strings . EqualFold ( step . Status , "failed" ) {
partial = true
}
}
status := "success"
if ! meSuccess {
status = "failed"
} else if partial {
status = "partial_success"
}
return soraProbeSummary {
Status : status ,
Steps : append ( [ ] soraProbeStep ( nil ) , r . steps ... ) ,
}
}
func ( s * AccountTestService ) emitSoraProbeSummary ( c * gin . Context , rec * soraProbeRecorder ) {
if rec == nil {
return
}
summary := rec . finalize ( )
code := ""
for _ , step := range summary . Steps {
if strings . EqualFold ( step . Status , "failed" ) && strings . TrimSpace ( step . ErrorCode ) != "" {
code = step . ErrorCode
break
}
}
s . sendEvent ( c , TestEvent {
Type : "sora_test_result" ,
Status : summary . Status ,
Code : code ,
Data : summary ,
} )
}
func ( s * AccountTestService ) acquireSoraTestPermit ( accountID int64 ) ( time . Duration , bool ) {
if accountID <= 0 {
return 0 , true
}
s . soraTestGuardMu . Lock ( )
defer s . soraTestGuardMu . Unlock ( )
if s . soraTestLastRun == nil {
s . soraTestLastRun = make ( map [ int64 ] time . Time )
}
cooldown := s . soraTestCooldown
if cooldown <= 0 {
cooldown = defaultSoraTestCooldown
}
now := time . Now ( )
if lastRun , ok := s . soraTestLastRun [ accountID ] ; ok {
elapsed := now . Sub ( lastRun )
if elapsed < cooldown {
return cooldown - elapsed , false
}
}
s . soraTestLastRun [ accountID ] = now
return 0 , true
}
func ceilSeconds ( d time . Duration ) int {
if d <= 0 {
return 1
}
sec := int ( d / time . Second )
if d % time . Second != 0 {
sec ++
}
if sec < 1 {
sec = 1
}
return sec
}
2026-02-28 15:01:20 +08:00
// testSoraAPIKeyAccountConnection 测试 Sora apikey 类型账号的连通性。
// 向上游 base_url 发送轻量级 prompt-enhance 请求验证连通性和 API Key 有效性。
func ( s * AccountTestService ) testSoraAPIKeyAccountConnection ( c * gin . Context , account * Account ) error {
ctx := c . Request . Context ( )
apiKey := account . GetCredential ( "api_key" )
if apiKey == "" {
return s . sendErrorAndEnd ( c , "Sora apikey 账号缺少 api_key 凭证" )
}
baseURL := account . GetBaseURL ( )
if baseURL == "" {
return s . sendErrorAndEnd ( c , "Sora apikey 账号缺少 base_url" )
}
// 验证 base_url 格式
normalizedBaseURL , err := s . validateUpstreamBaseURL ( baseURL )
if err != nil {
return s . sendErrorAndEnd ( c , fmt . Sprintf ( "base_url 无效: %s" , err . Error ( ) ) )
}
upstreamURL := strings . TrimSuffix ( normalizedBaseURL , "/" ) + "/sora/v1/chat/completions"
// 设置 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 ( )
if wait , ok := s . acquireSoraTestPermit ( account . ID ) ; ! ok {
msg := fmt . Sprintf ( "Sora 账号测试过于频繁,请 %d 秒后重试" , ceilSeconds ( wait ) )
return s . sendErrorAndEnd ( c , msg )
}
s . sendEvent ( c , TestEvent { Type : "test_start" , Model : "sora-upstream" } )
// 构建轻量级 prompt-enhance 请求作为连通性测试
testPayload := map [ string ] any {
"model" : "prompt-enhance-short-10s" ,
"messages" : [ ] map [ string ] string { { "role" : "user" , "content" : "test" } } ,
"stream" : false ,
}
payloadBytes , _ := json . Marshal ( testPayload )
req , err := http . NewRequestWithContext ( ctx , http . MethodPost , upstreamURL , bytes . NewReader ( payloadBytes ) )
if err != nil {
return s . sendErrorAndEnd ( c , "构建测试请求失败" )
}
req . Header . Set ( "Content-Type" , "application/json" )
req . Header . Set ( "Authorization" , "Bearer " + apiKey )
// 获取代理 URL
proxyURL := ""
if account . ProxyID != nil && account . Proxy != nil {
proxyURL = account . Proxy . URL ( )
}
resp , err := s . httpUpstream . Do ( req , proxyURL , account . ID , account . Concurrency )
if err != nil {
return s . sendErrorAndEnd ( c , fmt . Sprintf ( "上游连接失败: %s" , err . Error ( ) ) )
}
defer func ( ) { _ = resp . Body . Close ( ) } ( )
respBody , _ := io . ReadAll ( io . LimitReader ( resp . Body , 64 * 1024 ) )
if resp . StatusCode == http . StatusOK {
s . sendEvent ( c , TestEvent { Type : "content" , Text : fmt . Sprintf ( "上游连接成功 (%s)" , upstreamURL ) } )
s . sendEvent ( c , TestEvent { Type : "content" , Text : fmt . Sprintf ( "API Key 有效 (HTTP %d)" , resp . StatusCode ) } )
s . sendEvent ( c , TestEvent { Type : "test_complete" , Success : true } )
return nil
}
if resp . StatusCode == http . StatusUnauthorized || resp . StatusCode == http . StatusForbidden {
return s . sendErrorAndEnd ( c , fmt . Sprintf ( "上游认证失败 (HTTP %d),请检查 API Key 是否正确" , resp . StatusCode ) )
}
// 其他错误但能连通(如 400 参数错误)也算连通性测试通过
if resp . StatusCode == http . StatusBadRequest {
s . sendEvent ( c , TestEvent { Type : "content" , Text : fmt . Sprintf ( "上游连接成功 (%s)" , upstreamURL ) } )
s . sendEvent ( c , TestEvent { Type : "content" , Text : fmt . Sprintf ( "API Key 有效(上游返回 %d, 参数校验错误属正常) " , resp . StatusCode ) } )
s . sendEvent ( c , TestEvent { Type : "test_complete" , Success : true } )
return nil
}
return s . sendErrorAndEnd ( c , fmt . Sprintf ( "上游返回异常 HTTP %d: %s" , resp . StatusCode , truncateSoraErrorBody ( respBody , 256 ) ) )
}
2026-01-30 14:08:04 +08:00
// testSoraAccountConnection 测试 Sora 账号的连接
2026-02-28 15:01:20 +08:00
// OAuth 类型:调用 /backend/me 接口验证 access_token 有效性
// APIKey 类型:向上游 base_url 发送轻量级 prompt-enhance 请求验证连通性
2026-01-30 14:08:04 +08:00
func ( s * AccountTestService ) testSoraAccountConnection ( c * gin . Context , account * Account ) error {
2026-02-28 15:01:20 +08:00
// apikey 类型走独立测试流程
if account . Type == AccountTypeAPIKey {
return s . testSoraAPIKeyAccountConnection ( c , account )
}
2026-01-30 14:08:04 +08:00
ctx := c . Request . Context ( )
2026-02-19 20:04:10 +08:00
recorder := & soraProbeRecorder { }
2026-01-30 14:08:04 +08:00
authToken := account . GetCredential ( "access_token" )
if authToken == "" {
2026-02-19 20:04:10 +08:00
recorder . addStep ( "me" , "failed" , http . StatusUnauthorized , "missing_access_token" , "No access token available" )
s . emitSoraProbeSummary ( c , recorder )
2026-01-30 14:08:04 +08:00
return s . sendErrorAndEnd ( c , "No access token available" )
}
// 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 ( )
2026-02-19 20:04:10 +08:00
if wait , ok := s . acquireSoraTestPermit ( account . ID ) ; ! ok {
msg := fmt . Sprintf ( "Sora 账号测试过于频繁,请 %d 秒后重试" , ceilSeconds ( wait ) )
recorder . addStep ( "rate_limit" , "failed" , http . StatusTooManyRequests , "test_rate_limited" , msg )
s . emitSoraProbeSummary ( c , recorder )
return s . sendErrorAndEnd ( c , msg )
}
2026-01-30 14:08:04 +08:00
// Send test_start event
s . sendEvent ( c , TestEvent { Type : "test_start" , Model : "sora" } )
req , err := http . NewRequestWithContext ( ctx , "GET" , soraMeAPIURL , nil )
if err != nil {
2026-02-19 20:04:10 +08:00
recorder . addStep ( "me" , "failed" , 0 , "request_build_failed" , err . Error ( ) )
s . emitSoraProbeSummary ( c , recorder )
2026-01-30 14:08:04 +08:00
return s . sendErrorAndEnd ( c , "Failed to create request" )
}
2026-02-01 21:37:10 +08:00
// 使用 Sora 客户端标准请求头
2026-01-30 14:08:04 +08:00
req . Header . Set ( "Authorization" , "Bearer " + authToken )
req . Header . Set ( "User-Agent" , "Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)" )
req . Header . Set ( "Accept" , "application/json" )
2026-02-19 08:29:51 +08:00
req . Header . Set ( "Accept-Language" , "en-US,en;q=0.9" )
req . Header . Set ( "Origin" , "https://sora.chatgpt.com" )
req . Header . Set ( "Referer" , "https://sora.chatgpt.com/" )
2026-01-30 14:08:04 +08:00
// Get proxy URL
proxyURL := ""
if account . ProxyID != nil && account . Proxy != nil {
proxyURL = account . Proxy . URL ( )
}
2026-02-19 08:02:56 +08:00
enableSoraTLSFingerprint := s . shouldEnableSoraTLSFingerprint ( )
2026-01-30 14:08:04 +08:00
2026-02-19 08:02:56 +08:00
resp , err := s . httpUpstream . DoWithTLS ( req , proxyURL , account . ID , account . Concurrency , enableSoraTLSFingerprint )
2026-01-30 14:08:04 +08:00
if err != nil {
2026-02-19 20:04:10 +08:00
recorder . addStep ( "me" , "failed" , 0 , "network_error" , err . Error ( ) )
s . emitSoraProbeSummary ( c , recorder )
2026-01-30 14:08:04 +08:00
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 {
2026-02-19 20:04:10 +08:00
if isCloudflareChallengeResponse ( resp . StatusCode , resp . Header , body ) {
recorder . addStep ( "me" , "failed" , resp . StatusCode , "cf_challenge" , "Cloudflare challenge detected" )
s . emitSoraProbeSummary ( c , recorder )
2026-02-19 15:09:58 +08:00
s . logSoraCloudflareChallenge ( account , proxyURL , soraMeAPIURL , resp . Header , body )
2026-02-19 20:04:10 +08:00
return s . sendErrorAndEnd ( c , formatCloudflareChallengeMessage ( fmt . Sprintf ( "Sora request blocked by Cloudflare challenge (HTTP %d). Please switch to a clean proxy/network and retry." , resp . StatusCode ) , resp . Header , body ) )
}
upstreamCode , upstreamMessage := soraerror . ExtractUpstreamErrorCodeAndMessage ( body )
switch {
case resp . StatusCode == http . StatusUnauthorized && strings . EqualFold ( upstreamCode , "token_invalidated" ) :
recorder . addStep ( "me" , "failed" , resp . StatusCode , "token_invalidated" , "Sora token invalidated" )
s . emitSoraProbeSummary ( c , recorder )
return s . sendErrorAndEnd ( c , "Sora token 已失效( token_invalidated) , 请重新授权账号" )
case strings . EqualFold ( upstreamCode , "unsupported_country_code" ) :
recorder . addStep ( "me" , "failed" , resp . StatusCode , "unsupported_country_code" , "Sora is unavailable in current egress region" )
s . emitSoraProbeSummary ( c , recorder )
return s . sendErrorAndEnd ( c , "Sora 在当前网络出口地区不可用( unsupported_country_code) , 请切换到支持地区后重试" )
case strings . TrimSpace ( upstreamMessage ) != "" :
recorder . addStep ( "me" , "failed" , resp . StatusCode , upstreamCode , upstreamMessage )
s . emitSoraProbeSummary ( c , recorder )
return s . sendErrorAndEnd ( c , fmt . Sprintf ( "Sora API returned %d: %s" , resp . StatusCode , upstreamMessage ) )
default :
recorder . addStep ( "me" , "failed" , resp . StatusCode , upstreamCode , "Sora me endpoint failed" )
s . emitSoraProbeSummary ( c , recorder )
return s . sendErrorAndEnd ( c , fmt . Sprintf ( "Sora API returned %d: %s" , resp . StatusCode , truncateSoraErrorBody ( body , 512 ) ) )
2026-02-19 08:02:56 +08:00
}
2026-01-30 14:08:04 +08:00
}
2026-02-19 20:04:10 +08:00
recorder . addStep ( "me" , "success" , resp . StatusCode , "" , "me endpoint ok" )
2026-01-30 14:08:04 +08:00
// 解析 /me 响应,提取用户信息
var meResp map [ string ] any
if err := json . Unmarshal ( body , & meResp ) ; err != nil {
// 能收到 200 就说明 token 有效
s . sendEvent ( c , TestEvent { Type : "content" , Text : "Sora connection OK (token valid)" } )
} else {
// 尝试提取用户名或邮箱信息
info := "Sora connection OK"
if name , ok := meResp [ "name" ] . ( string ) ; ok && name != "" {
info = fmt . Sprintf ( "Sora connection OK - User: %s" , name )
} else if email , ok := meResp [ "email" ] . ( string ) ; ok && email != "" {
info = fmt . Sprintf ( "Sora connection OK - Email: %s" , email )
}
s . sendEvent ( c , TestEvent { Type : "content" , Text : info } )
}
2026-02-19 08:02:56 +08:00
// 追加轻量能力检查:订阅信息查询(失败仅告警,不中断连接测试)
subReq , err := http . NewRequestWithContext ( ctx , "GET" , soraBillingAPIURL , nil )
if err == nil {
subReq . Header . Set ( "Authorization" , "Bearer " + authToken )
subReq . Header . Set ( "User-Agent" , "Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)" )
subReq . Header . Set ( "Accept" , "application/json" )
2026-02-19 08:29:51 +08:00
subReq . Header . Set ( "Accept-Language" , "en-US,en;q=0.9" )
subReq . Header . Set ( "Origin" , "https://sora.chatgpt.com" )
subReq . Header . Set ( "Referer" , "https://sora.chatgpt.com/" )
2026-02-19 08:02:56 +08:00
subResp , subErr := s . httpUpstream . DoWithTLS ( subReq , proxyURL , account . ID , account . Concurrency , enableSoraTLSFingerprint )
if subErr != nil {
2026-02-19 20:04:10 +08:00
recorder . addStep ( "subscription" , "failed" , 0 , "network_error" , subErr . Error ( ) )
2026-02-19 08:02:56 +08:00
s . sendEvent ( c , TestEvent { Type : "content" , Text : fmt . Sprintf ( "Subscription check skipped: %s" , subErr . Error ( ) ) } )
} else {
subBody , _ := io . ReadAll ( subResp . Body )
_ = subResp . Body . Close ( )
if subResp . StatusCode == http . StatusOK {
2026-02-19 20:04:10 +08:00
recorder . addStep ( "subscription" , "success" , subResp . StatusCode , "" , "subscription endpoint ok" )
2026-02-19 08:02:56 +08:00
if summary := parseSoraSubscriptionSummary ( subBody ) ; summary != "" {
s . sendEvent ( c , TestEvent { Type : "content" , Text : summary } )
} else {
s . sendEvent ( c , TestEvent { Type : "content" , Text : "Subscription check OK" } )
}
} else {
2026-02-19 20:04:10 +08:00
if isCloudflareChallengeResponse ( subResp . StatusCode , subResp . Header , subBody ) {
recorder . addStep ( "subscription" , "failed" , subResp . StatusCode , "cf_challenge" , "Cloudflare challenge detected" )
2026-02-19 15:09:58 +08:00
s . logSoraCloudflareChallenge ( account , proxyURL , soraBillingAPIURL , subResp . Header , subBody )
2026-02-19 20:04:10 +08:00
s . sendEvent ( c , TestEvent { Type : "content" , Text : formatCloudflareChallengeMessage ( fmt . Sprintf ( "Subscription check blocked by Cloudflare challenge (HTTP %d)" , subResp . StatusCode ) , subResp . Header , subBody ) } )
2026-02-19 08:02:56 +08:00
} else {
2026-02-19 20:04:10 +08:00
upstreamCode , upstreamMessage := soraerror . ExtractUpstreamErrorCodeAndMessage ( subBody )
recorder . addStep ( "subscription" , "failed" , subResp . StatusCode , upstreamCode , upstreamMessage )
2026-02-19 08:02:56 +08:00
s . sendEvent ( c , TestEvent { Type : "content" , Text : fmt . Sprintf ( "Subscription check returned %d" , subResp . StatusCode ) } )
}
}
}
}
2026-02-19 08:29:51 +08:00
// 追加 Sora2 能力探测(对齐 sora2api 的测试思路):邀请码 + 剩余额度。
2026-02-19 20:04:10 +08:00
s . testSora2Capabilities ( c , ctx , account , authToken , proxyURL , enableSoraTLSFingerprint , recorder )
2026-02-19 08:29:51 +08:00
2026-02-19 20:04:10 +08:00
s . emitSoraProbeSummary ( c , recorder )
2026-01-30 14:08:04 +08:00
s . sendEvent ( c , TestEvent { Type : "test_complete" , Success : true } )
return nil
}
2026-02-19 08:29:51 +08:00
func ( s * AccountTestService ) testSora2Capabilities (
c * gin . Context ,
ctx context . Context ,
account * Account ,
authToken string ,
proxyURL string ,
enableTLSFingerprint bool ,
2026-02-19 20:04:10 +08:00
recorder * soraProbeRecorder ,
2026-02-19 08:29:51 +08:00
) {
inviteStatus , inviteHeader , inviteBody , err := s . fetchSoraTestEndpoint (
ctx ,
account ,
authToken ,
soraInviteMineURL ,
proxyURL ,
enableTLSFingerprint ,
)
if err != nil {
2026-02-19 20:04:10 +08:00
if recorder != nil {
recorder . addStep ( "sora2_invite" , "failed" , 0 , "network_error" , err . Error ( ) )
}
2026-02-19 08:29:51 +08:00
s . sendEvent ( c , TestEvent { Type : "content" , Text : fmt . Sprintf ( "Sora2 invite check skipped: %s" , err . Error ( ) ) } )
return
}
if inviteStatus == http . StatusUnauthorized {
bootstrapStatus , _ , _ , bootstrapErr := s . fetchSoraTestEndpoint (
ctx ,
account ,
authToken ,
soraBootstrapURL ,
proxyURL ,
enableTLSFingerprint ,
)
if bootstrapErr == nil && bootstrapStatus == http . StatusOK {
2026-02-19 20:04:10 +08:00
if recorder != nil {
recorder . addStep ( "sora2_bootstrap" , "success" , bootstrapStatus , "" , "bootstrap endpoint ok" )
}
2026-02-19 08:29:51 +08:00
s . sendEvent ( c , TestEvent { Type : "content" , Text : "Sora2 bootstrap OK, retry invite check" } )
inviteStatus , inviteHeader , inviteBody , err = s . fetchSoraTestEndpoint (
ctx ,
account ,
authToken ,
soraInviteMineURL ,
proxyURL ,
enableTLSFingerprint ,
)
if err != nil {
2026-02-19 20:04:10 +08:00
if recorder != nil {
recorder . addStep ( "sora2_invite" , "failed" , 0 , "network_error" , err . Error ( ) )
}
2026-02-19 08:29:51 +08:00
s . sendEvent ( c , TestEvent { Type : "content" , Text : fmt . Sprintf ( "Sora2 invite retry failed: %s" , err . Error ( ) ) } )
return
}
2026-02-19 20:04:10 +08:00
} else if recorder != nil {
code := ""
msg := ""
if bootstrapErr != nil {
code = "network_error"
msg = bootstrapErr . Error ( )
}
recorder . addStep ( "sora2_bootstrap" , "failed" , bootstrapStatus , code , msg )
2026-02-19 08:29:51 +08:00
}
}
if inviteStatus != http . StatusOK {
2026-02-19 20:04:10 +08:00
if isCloudflareChallengeResponse ( inviteStatus , inviteHeader , inviteBody ) {
if recorder != nil {
recorder . addStep ( "sora2_invite" , "failed" , inviteStatus , "cf_challenge" , "Cloudflare challenge detected" )
}
s . logSoraCloudflareChallenge ( account , proxyURL , soraInviteMineURL , inviteHeader , inviteBody )
s . sendEvent ( c , TestEvent { Type : "content" , Text : formatCloudflareChallengeMessage ( fmt . Sprintf ( "Sora2 invite check blocked by Cloudflare challenge (HTTP %d)" , inviteStatus ) , inviteHeader , inviteBody ) } )
2026-02-19 08:29:51 +08:00
return
}
2026-02-19 20:04:10 +08:00
upstreamCode , upstreamMessage := soraerror . ExtractUpstreamErrorCodeAndMessage ( inviteBody )
if recorder != nil {
recorder . addStep ( "sora2_invite" , "failed" , inviteStatus , upstreamCode , upstreamMessage )
}
2026-02-19 08:29:51 +08:00
s . sendEvent ( c , TestEvent { Type : "content" , Text : fmt . Sprintf ( "Sora2 invite check returned %d" , inviteStatus ) } )
return
}
2026-02-19 20:04:10 +08:00
if recorder != nil {
recorder . addStep ( "sora2_invite" , "success" , inviteStatus , "" , "invite endpoint ok" )
}
2026-02-19 08:29:51 +08:00
if summary := parseSoraInviteSummary ( inviteBody ) ; summary != "" {
s . sendEvent ( c , TestEvent { Type : "content" , Text : summary } )
} else {
s . sendEvent ( c , TestEvent { Type : "content" , Text : "Sora2 invite check OK" } )
}
remainingStatus , remainingHeader , remainingBody , remainingErr := s . fetchSoraTestEndpoint (
ctx ,
account ,
authToken ,
soraRemainingURL ,
proxyURL ,
enableTLSFingerprint ,
)
if remainingErr != nil {
2026-02-19 20:04:10 +08:00
if recorder != nil {
recorder . addStep ( "sora2_remaining" , "failed" , 0 , "network_error" , remainingErr . Error ( ) )
}
2026-02-19 08:29:51 +08:00
s . sendEvent ( c , TestEvent { Type : "content" , Text : fmt . Sprintf ( "Sora2 remaining check skipped: %s" , remainingErr . Error ( ) ) } )
return
}
if remainingStatus != http . StatusOK {
2026-02-19 20:04:10 +08:00
if isCloudflareChallengeResponse ( remainingStatus , remainingHeader , remainingBody ) {
if recorder != nil {
recorder . addStep ( "sora2_remaining" , "failed" , remainingStatus , "cf_challenge" , "Cloudflare challenge detected" )
}
s . logSoraCloudflareChallenge ( account , proxyURL , soraRemainingURL , remainingHeader , remainingBody )
s . sendEvent ( c , TestEvent { Type : "content" , Text : formatCloudflareChallengeMessage ( fmt . Sprintf ( "Sora2 remaining check blocked by Cloudflare challenge (HTTP %d)" , remainingStatus ) , remainingHeader , remainingBody ) } )
2026-02-19 08:29:51 +08:00
return
}
2026-02-19 20:04:10 +08:00
upstreamCode , upstreamMessage := soraerror . ExtractUpstreamErrorCodeAndMessage ( remainingBody )
if recorder != nil {
recorder . addStep ( "sora2_remaining" , "failed" , remainingStatus , upstreamCode , upstreamMessage )
}
2026-02-19 08:29:51 +08:00
s . sendEvent ( c , TestEvent { Type : "content" , Text : fmt . Sprintf ( "Sora2 remaining check returned %d" , remainingStatus ) } )
return
}
2026-02-19 20:04:10 +08:00
if recorder != nil {
recorder . addStep ( "sora2_remaining" , "success" , remainingStatus , "" , "remaining endpoint ok" )
}
2026-02-19 08:29:51 +08:00
if summary := parseSoraRemainingSummary ( remainingBody ) ; summary != "" {
s . sendEvent ( c , TestEvent { Type : "content" , Text : summary } )
} else {
s . sendEvent ( c , TestEvent { Type : "content" , Text : "Sora2 remaining check OK" } )
}
}
func ( s * AccountTestService ) fetchSoraTestEndpoint (
ctx context . Context ,
account * Account ,
authToken string ,
url string ,
proxyURL string ,
enableTLSFingerprint bool ,
) ( int , http . Header , [ ] byte , error ) {
req , err := http . NewRequestWithContext ( ctx , "GET" , url , nil )
if err != nil {
return 0 , nil , nil , err
}
req . Header . Set ( "Authorization" , "Bearer " + authToken )
req . Header . Set ( "User-Agent" , "Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)" )
req . Header . Set ( "Accept" , "application/json" )
req . Header . Set ( "Accept-Language" , "en-US,en;q=0.9" )
req . Header . Set ( "Origin" , "https://sora.chatgpt.com" )
req . Header . Set ( "Referer" , "https://sora.chatgpt.com/" )
resp , err := s . httpUpstream . DoWithTLS ( req , proxyURL , account . ID , account . Concurrency , enableTLSFingerprint )
if err != nil {
return 0 , nil , nil , err
}
defer func ( ) { _ = resp . Body . Close ( ) } ( )
body , readErr := io . ReadAll ( resp . Body )
if readErr != nil {
return resp . StatusCode , resp . Header , nil , readErr
}
return resp . StatusCode , resp . Header , body , nil
}
2026-02-19 08:02:56 +08:00
func parseSoraSubscriptionSummary ( body [ ] byte ) string {
var subResp struct {
Data [ ] struct {
Plan struct {
ID string ` json:"id" `
Title string ` json:"title" `
} ` json:"plan" `
EndTS string ` json:"end_ts" `
} ` json:"data" `
}
if err := json . Unmarshal ( body , & subResp ) ; err != nil {
return ""
}
if len ( subResp . Data ) == 0 {
return ""
}
first := subResp . Data [ 0 ]
parts := make ( [ ] string , 0 , 3 )
if first . Plan . Title != "" {
parts = append ( parts , first . Plan . Title )
}
if first . Plan . ID != "" {
parts = append ( parts , first . Plan . ID )
}
if first . EndTS != "" {
parts = append ( parts , "end=" + first . EndTS )
}
if len ( parts ) == 0 {
return ""
}
return "Subscription: " + strings . Join ( parts , " | " )
}
2026-02-19 08:29:51 +08:00
func parseSoraInviteSummary ( body [ ] byte ) string {
var inviteResp struct {
InviteCode string ` json:"invite_code" `
RedeemedCount int64 ` json:"redeemed_count" `
TotalCount int64 ` json:"total_count" `
}
if err := json . Unmarshal ( body , & inviteResp ) ; err != nil {
return ""
}
parts := [ ] string { "Sora2: supported" }
if inviteResp . InviteCode != "" {
parts = append ( parts , "invite=" + inviteResp . InviteCode )
}
if inviteResp . TotalCount > 0 {
parts = append ( parts , fmt . Sprintf ( "used=%d/%d" , inviteResp . RedeemedCount , inviteResp . TotalCount ) )
}
return strings . Join ( parts , " | " )
}
func parseSoraRemainingSummary ( body [ ] byte ) string {
var remainingResp struct {
RateLimitAndCreditBalance struct {
EstimatedNumVideosRemaining int64 ` json:"estimated_num_videos_remaining" `
RateLimitReached bool ` json:"rate_limit_reached" `
AccessResetsInSeconds int64 ` json:"access_resets_in_seconds" `
} ` json:"rate_limit_and_credit_balance" `
}
if err := json . Unmarshal ( body , & remainingResp ) ; err != nil {
return ""
}
info := remainingResp . RateLimitAndCreditBalance
parts := [ ] string { fmt . Sprintf ( "Sora2 remaining: %d" , info . EstimatedNumVideosRemaining ) }
if info . RateLimitReached {
parts = append ( parts , "rate_limited=true" )
}
if info . AccessResetsInSeconds > 0 {
parts = append ( parts , fmt . Sprintf ( "reset_in=%ds" , info . AccessResetsInSeconds ) )
}
return strings . Join ( parts , " | " )
}
2026-02-19 08:02:56 +08:00
func ( s * AccountTestService ) shouldEnableSoraTLSFingerprint ( ) bool {
if s == nil || s . cfg == nil {
2026-02-19 08:30:54 +08:00
return true
2026-02-19 08:02:56 +08:00
}
2026-02-19 08:30:54 +08:00
return ! s . cfg . Sora . Client . DisableTLSFingerprint
2026-02-19 08:02:56 +08:00
}
2026-02-19 20:04:10 +08:00
func isCloudflareChallengeResponse ( statusCode int , headers http . Header , body [ ] byte ) bool {
return soraerror . IsCloudflareChallengeResponse ( statusCode , headers , body )
2026-02-19 08:02:56 +08:00
}
func formatCloudflareChallengeMessage ( base string , headers http . Header , body [ ] byte ) string {
2026-02-19 20:04:10 +08:00
return soraerror . FormatCloudflareChallengeMessage ( base , headers , body )
2026-02-19 08:02:56 +08:00
}
func extractCloudflareRayID ( headers http . Header , body [ ] byte ) string {
2026-02-19 20:04:10 +08:00
return soraerror . ExtractCloudflareRayID ( headers , body )
2026-02-19 08:02:56 +08:00
}
2026-02-19 15:09:58 +08:00
func extractSoraEgressIPHint ( headers http . Header ) string {
if headers == nil {
return "unknown"
}
candidates := [ ] string {
"x-openai-public-ip" ,
"x-envoy-external-address" ,
"cf-connecting-ip" ,
"x-forwarded-for" ,
}
for _ , key := range candidates {
if value := strings . TrimSpace ( headers . Get ( key ) ) ; value != "" {
return value
}
}
return "unknown"
}
func sanitizeProxyURLForLog ( raw string ) string {
raw = strings . TrimSpace ( raw )
if raw == "" {
return ""
}
u , err := url . Parse ( raw )
if err != nil {
return "<invalid_proxy_url>"
}
if u . User != nil {
u . User = nil
}
return u . String ( )
}
func endpointPathForLog ( endpoint string ) string {
parsed , err := url . Parse ( strings . TrimSpace ( endpoint ) )
if err != nil || parsed . Path == "" {
return endpoint
}
return parsed . Path
}
func ( s * AccountTestService ) logSoraCloudflareChallenge ( account * Account , proxyURL , endpoint string , headers http . Header , body [ ] byte ) {
accountID := int64 ( 0 )
platform := ""
proxyID := "none"
if account != nil {
accountID = account . ID
platform = account . Platform
if account . ProxyID != nil {
proxyID = fmt . Sprintf ( "%d" , * account . ProxyID )
}
}
cfRay := extractCloudflareRayID ( headers , body )
if cfRay == "" {
cfRay = "unknown"
}
log . Printf (
"[SoraCFChallenge] account_id=%d platform=%s endpoint=%s path=%s proxy_id=%s proxy_url=%s cf_ray=%s egress_ip_hint=%s" ,
accountID ,
platform ,
endpoint ,
endpointPathForLog ( endpoint ) ,
proxyID ,
sanitizeProxyURLForLog ( proxyURL ) ,
cfRay ,
extractSoraEgressIPHint ( headers ) ,
)
}
2026-02-19 08:02:56 +08:00
func truncateSoraErrorBody ( body [ ] byte , max int ) string {
2026-02-19 20:04:10 +08:00
return soraerror . TruncateBody ( body , max )
2026-02-19 08:02:56 +08:00
}
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
}