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"
2025-12-26 03:49:55 -08:00
"regexp"
2025-12-18 13:50:39 +08:00
"strings"
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* ` )
2026-02-19 08:02:56 +08:00
var cloudflareRayPattern = regexp . MustCompile ( ` (?i)cRay:\s*'([a-z0-9-]+)' ` )
2025-12-26 03:49:55 -08:00
2025-12-18 13:50:39 +08:00
const (
2025-12-22 22:58:31 +08:00
testClaudeAPIURL = "https://api.anthropic.com/v1/messages"
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"
2025-12-18 13:50:39 +08:00
)
// TestEvent represents a SSE event for account testing
type TestEvent struct {
Type string ` json:"type" `
Text string ` json:"text,omitempty" `
Model string ` json:"model,omitempty" `
Success bool ` json:"success,omitempty" `
Error string ` json:"error,omitempty" `
}
// 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
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 ,
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
func ( s * AccountTestService ) TestAccountConnection ( c * gin . Context , accountID int64 , modelID 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 ( ) {
return s . testGeminiAccountConnection ( c , account , modelID )
}
2025-12-30 22:42:00 +08:00
if account . Platform == PlatformAntigravity {
return s . testAntigravityAccountConnection ( c , account , modelID )
}
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 ( ) ) )
}
apiURL = strings . TrimSuffix ( normalizedBaseURL , "/" ) + "/v1/messages"
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
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
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
2025-12-26 22:07:55 +08:00
func ( s * AccountTestService ) testGeminiAccountConnection ( c * gin . Context , account * Account , modelID 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)
payload := createGeminiTestPayload ( )
// 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-01-30 14:08:04 +08:00
// testSoraAccountConnection 测试 Sora 账号的连接
// 调用 /backend/me 接口验证 access_token 有效性(不需要 Sentinel Token)
func ( s * AccountTestService ) testSoraAccountConnection ( c * gin . Context , account * Account ) error {
ctx := c . Request . Context ( )
authToken := account . GetCredential ( "access_token" )
if authToken == "" {
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 ( )
// Send test_start event
s . sendEvent ( c , TestEvent { Type : "test_start" , Model : "sora" } )
req , err := http . NewRequestWithContext ( ctx , "GET" , soraMeAPIURL , nil )
if err != nil {
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" )
// 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 {
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 08:02:56 +08:00
if isCloudflareChallengeResponse ( resp . StatusCode , body ) {
return s . sendErrorAndEnd ( c , formatCloudflareChallengeMessage ( "Sora request blocked by Cloudflare challenge (HTTP 403). Please switch to a clean proxy/network and retry." , resp . Header , body ) )
}
return s . sendErrorAndEnd ( c , fmt . Sprintf ( "Sora API returned %d: %s" , resp . StatusCode , truncateSoraErrorBody ( body , 512 ) ) )
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" )
subResp , subErr := s . httpUpstream . DoWithTLS ( subReq , proxyURL , account . ID , account . Concurrency , enableSoraTLSFingerprint )
if subErr != nil {
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 {
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 {
if isCloudflareChallengeResponse ( subResp . StatusCode , subBody ) {
s . sendEvent ( c , TestEvent { Type : "content" , Text : formatCloudflareChallengeMessage ( "Subscription check blocked by Cloudflare challenge (HTTP 403)" , subResp . Header , subBody ) } )
} else {
s . sendEvent ( c , TestEvent { Type : "content" , Text : fmt . Sprintf ( "Subscription check returned %d" , subResp . StatusCode ) } )
}
}
}
}
2026-01-30 14:08:04 +08:00
s . sendEvent ( c , TestEvent { Type : "test_complete" , Success : true } )
return 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 , " | " )
}
func ( s * AccountTestService ) shouldEnableSoraTLSFingerprint ( ) bool {
if s == nil || s . cfg == nil {
return false
}
return s . cfg . Gateway . TLSFingerprint . Enabled && ! s . cfg . Sora . Client . DisableTLSFingerprint
}
func isCloudflareChallengeResponse ( statusCode int , body [ ] byte ) bool {
if statusCode != http . StatusForbidden {
return false
}
preview := strings . ToLower ( truncateSoraErrorBody ( body , 4096 ) )
return strings . Contains ( preview , "window._cf_chl_opt" ) ||
strings . Contains ( preview , "just a moment" ) ||
strings . Contains ( preview , "enable javascript and cookies to continue" )
}
func formatCloudflareChallengeMessage ( base string , headers http . Header , body [ ] byte ) string {
rayID := extractCloudflareRayID ( headers , body )
if rayID == "" {
return base
}
return fmt . Sprintf ( "%s (cf-ray: %s)" , base , rayID )
}
func extractCloudflareRayID ( headers http . Header , body [ ] byte ) string {
if headers != nil {
rayID := strings . TrimSpace ( headers . Get ( "cf-ray" ) )
if rayID != "" {
return rayID
}
rayID = strings . TrimSpace ( headers . Get ( "Cf-Ray" ) )
if rayID != "" {
return rayID
}
}
preview := truncateSoraErrorBody ( body , 8192 )
matches := cloudflareRayPattern . FindStringSubmatch ( preview )
if len ( matches ) >= 2 {
return strings . TrimSpace ( matches [ 1 ] )
}
return ""
}
func truncateSoraErrorBody ( body [ ] byte , max int ) string {
if max <= 0 {
max = 512
}
raw := strings . TrimSpace ( string ( body ) )
if len ( raw ) <= max {
return raw
}
return raw [ : max ] + "...(truncated)"
}
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
}
// createGeminiTestPayload creates a minimal test payload for Gemini API
func createGeminiTestPayload ( ) [ ] byte {
payload := map [ string ] any {
"contents" : [ ] map [ string ] any {
{
"role" : "user" ,
"parts" : [ ] map [ string ] any {
{ "text" : "hi" } ,
} ,
} ,
} ,
"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-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
}