package service import ( "bufio" "bytes" "context" "crypto/rand" "encoding/base64" "encoding/binary" "encoding/hex" "encoding/json" "errors" "fmt" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "io" "log" "mime/multipart" "net/http" "net/http/httptest" "net/url" "regexp" "strings" "sync" "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/tidwall/gjson" ) // 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*`) const ( testClaudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true" chatgptCodexAPIURL = "https://chatgpt.com/backend-api/codex/responses" ) // 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"` Status string `json:"status,omitempty"` Code string `json:"code,omitempty"` ImageURL string `json:"image_url,omitempty"` // AudioURL / VideoURL are data: or https URLs for in-browser media players. AudioURL string `json:"audio_url,omitempty"` VideoURL string `json:"video_url,omitempty"` MimeType string `json:"mime_type,omitempty"` Data any `json:"data,omitempty"` Success bool `json:"success,omitempty"` Error string `json:"error,omitempty"` } // AccountTestOptions carries optional media for admin connectivity tests. // ImageDataURL / AudioDataURL are full data URLs (data:;base64,...). type AccountTestOptions struct { ImageDataURL string AudioDataURL string } func firstAccountTestOptions(opts []AccountTestOptions) AccountTestOptions { if len(opts) == 0 { return AccountTestOptions{} } return opts[0] } // maxAccountTestMediaBytes caps inbound data-URL payloads for admin tests (~8 MiB). const maxAccountTestMediaBytes = 8 << 20 const ( defaultGeminiTextTestPrompt = "hi" defaultGeminiImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background." defaultOpenAIImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background." defaultGrokImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background." defaultGrokVideoTestPrompt = "A red ball bouncing once on a white floor, short simple motion." defaultGrokSearchTestQuery = "xAI Grok" defaultGrokTTSTestText = "Hello from Sub2API account connectivity test." // Grok account-test modes (admin UI). Empty / default / text = Responses probe. // image/video may also be inferred from model_id when mode is default. AccountTestModeGrokText = "text" AccountTestModeGrokImage = "image" AccountTestModeGrokVideo = "video" AccountTestModeGrokSearch = "search" AccountTestModeGrokTTS = "tts" AccountTestModeGrokSTT = "stt" AccountTestModeGrokRealtime = "realtime" defaultGrokRealtimeTestModel = "grok-voice-latest" grokRealtimeProbeTimeout = 12 * time.Second ) // isOpenAIImageModel checks if the model is an OpenAI image generation model (e.g. gpt-image-2). func isOpenAIImageModel(model string) bool { return strings.HasPrefix(strings.ToLower(model), "gpt-image-") } func isGrokVideoGenerationModel(model string) bool { return isGrokVideoBillingModel(model) || strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "grok-video") } func normalizeGrokAccountTestMode(mode string) string { switch strings.ToLower(strings.TrimSpace(mode)) { case AccountTestModeGrokText: return AccountTestModeGrokText case AccountTestModeGrokImage: return AccountTestModeGrokImage case AccountTestModeGrokVideo: return AccountTestModeGrokVideo case AccountTestModeGrokSearch: return AccountTestModeGrokSearch case AccountTestModeGrokTTS: return AccountTestModeGrokTTS case AccountTestModeGrokSTT: return AccountTestModeGrokSTT case AccountTestModeGrokRealtime: return AccountTestModeGrokRealtime default: return AccountTestModeDefault } } // AccountTestService handles account testing operations type AccountTestService struct { accountRepo AccountRepository geminiTokenProvider *GeminiTokenProvider claudeTokenProvider *ClaudeTokenProvider grokTokenProvider *GrokTokenProvider antigravityGatewayService *AntigravityGatewayService httpUpstream HTTPUpstream cfg *config.Config settingService *SettingService tlsFPProfileService *TLSFingerprintProfileService agentIdentityTaskMu sync.Mutex agentIdentityWS agentIdentityWSConnectionInvalidator // grokWSDialer is optional; realtime account tests use the default OpenAI-style // WS dialer when nil (supports proxy + coder/websocket handshake). grokWSDialer openAIWSClientDialer } func (s *AccountTestService) SetSettingService(settingService *SettingService) { if s != nil { s.settingService = settingService } } // NewAccountTestService creates a new AccountTestService func NewAccountTestService( accountRepo AccountRepository, geminiTokenProvider *GeminiTokenProvider, claudeTokenProvider *ClaudeTokenProvider, grokTokenProvider *GrokTokenProvider, antigravityGatewayService *AntigravityGatewayService, httpUpstream HTTPUpstream, cfg *config.Config, tlsFPProfileService *TLSFingerprintProfileService, ) *AccountTestService { return &AccountTestService{ accountRepo: accountRepo, geminiTokenProvider: geminiTokenProvider, claudeTokenProvider: claudeTokenProvider, grokTokenProvider: grokTokenProvider, antigravityGatewayService: antigravityGatewayService, httpUpstream: httpUpstream, cfg: cfg, tlsFPProfileService: tlsFPProfileService, } } func (s *AccountTestService) validateUpstreamBaseURL(raw string) (string, error) { if s.cfg == nil { return "", errors.New("config is not available") } if !s.cfg.Security.URLAllowlist.Enabled { return urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP) } 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 } // generateSessionString generates a Claude Code style session string. // The output format is determined by the UA version in claude.DefaultHeaders, // ensuring consistency between the user_id format and the UA sent to upstream. func generateSessionString() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } hex64 := hex.EncodeToString(b) sessionUUID := uuid.New().String() uaVersion := ExtractCLIVersion(claude.DefaultHeaders["User-Agent"]) return FormatMetadataUserID(hex64, "", sessionUUID, uaVersion), nil } // createTestPayload creates a Claude Code style test request payload func createTestPayload(modelID string) (map[string]any, error) { sessionID, err := generateSessionString() if err != nil { return nil, err } return map[string]any{ "model": modelID, "messages": []map[string]any{ { "role": "user", "content": []map[string]any{ { "type": "text", "text": "hi", "cache_control": map[string]string{ "type": "ephemeral", }, }, }, }, }, "system": []map[string]any{ { "type": "text", "text": claudeCodeSystemPrompt, "cache_control": map[string]string{ "type": "ephemeral", }, }, }, "metadata": map[string]string{ "user_id": sessionID, }, "max_tokens": 1024, "temperature": 1, "stream": true, }, nil } // TestAccountConnection tests an account's connection by sending a test request // All account types use full Claude Code client characteristics, only auth header differs // modelID is optional - if empty, defaults to claude.DefaultTestModel // mode is optional - "compact" routes OpenAI accounts to the /responses/compact probe path // opts is optional media (image/audio data URLs for real generation / STT). func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int64, modelID string, prompt string, mode string, opts ...AccountTestOptions) error { ctx := c.Request.Context() testOpts := firstAccountTestOptions(opts) // Get account account, err := s.accountRepo.GetByID(ctx, accountID) if err != nil { return s.sendErrorAndEnd(c, "Account not found") } // Synthetic UI load-test accounts exercise the real SSE parsing and modal // interactions, but intentionally do not send their placeholder credentials // to an upstream provider. if account.IsSyntheticUITest() { testModelID := modelID if testModelID == "" { testModelID = claude.DefaultTestModel } s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID}) s.sendEvent(c, TestEvent{Type: "content", Text: "Synthetic Anthropic OAuth account is healthy and interactive."}) s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) return nil } // Route to platform-specific test method if account.IsCNProvider() { switch account.GetAPIProtocol() { case APIProtocolAdaptive: return s.testCNProviderAdaptiveConnection(c, account, modelID, prompt) case APIProtocolChatCompletions: return s.testCNProviderChatCompletionsConnection(c, account, modelID, prompt) } } if account.IsOpenAI() { return s.testOpenAIAccountConnection(c, account, modelID, prompt, normalizeAccountTestMode(mode)) } if account.IsGemini() { return s.testGeminiAccountConnection(c, account, modelID, prompt) } if account.Platform == PlatformGrok { return s.testGrokAccountConnection(c, account, modelID, prompt, mode, testOpts) } if account.Platform == PlatformAntigravity { return s.routeAntigravityTest(c, account, modelID, prompt) } return s.testClaudeAccountConnection(c, account, modelID) } func (s *AccountTestService) testCNProviderChatCompletionsConnection(c *gin.Context, account *Account, modelID string, prompt string) error { testModelID := strings.TrimSpace(modelID) if testModelID == "" { testModelID = openai.DefaultTestModel } testModelID = account.GetMappedModel(testModelID) authToken := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) if authToken == "" { return s.sendErrorAndEnd(c, "No API key available") } baseURL := account.GetOpenAIBaseURL() normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid base URL: %s", err.Error())) } return s.testOpenAIChatCompletionsConnection(c, account, testModelID, prompt, normalizedBaseURL, authToken) } // testClaudeAccountConnection tests an Anthropic Claude account's connection func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account *Account, modelID string) error { ctx := c.Request.Context() // Determine the model to use testModelID := modelID if testModelID == "" { testModelID = claude.DefaultTestModel } // API Key 账号测试连接时也需要应用通配符模型映射。 if account.Type == "apikey" { testModelID = account.GetMappedModel(testModelID) } // Bedrock accounts use a separate test path if account.IsBedrock() { return s.testBedrockAccountConnection(c, ctx, account, testModelID) } if account.Type == AccountTypeServiceAccount { return s.testClaudeVertexServiceAccountConnection(c, ctx, account, testModelID) } // Determine authentication method and API URL var authToken string var apiURL string if account.IsOAuth() { apiURL = testClaudeAPIURL authToken = account.GetCredential("access_token") if authToken == "" { return s.sendErrorAndEnd(c, "No access token available") } } else if account.Type == "apikey" { authToken = account.GetCredential("api_key") if authToken == "" { return s.sendErrorAndEnd(c, "No API key available") } baseURL := account.GetBaseURL() if baseURL == "" { baseURL = "https://api.anthropic.com" } 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?beta=true" } 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 Claude Code style payload (same for all account types) payload, err := createTestPayload(testModelID) if err != nil { return s.sendErrorAndEnd(c, "Failed to create test payload") } 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("anthropic-version", "2023-06-01") // Apply Claude Code client headers for key, value := range claude.DefaultHeaders { req.Header.Set(key, value) } // Set authentication header if account.IsOAuth() { req.Header.Set("anthropic-beta", claude.DefaultBetaHeader) req.Header.Set("Authorization", "Bearer "+authToken) } else { req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader) setAnthropicAPIKeyAuthHeader(req.Header, account, authToken) } // 账号级请求头覆写:测试请求与真实转发保持一致的最终头 account.ApplyHeaderOverrides(req.Header) // Get proxy URL proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { proxyURL = account.Proxy.URL() } resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) 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) errMsg := fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body)) // 403 表示账号被上游封禁,标记为 error 状态 if resp.StatusCode == http.StatusForbidden { _ = s.accountRepo.SetError(ctx, account.ID, errMsg) } return s.sendErrorAndEnd(c, errMsg) } // Process SSE stream return s.processClaudeStream(c, resp.Body) } func (s *AccountTestService) testClaudeVertexServiceAccountConnection(c *gin.Context, ctx context.Context, account *Account, testModelID string) error { if mappedModel, matched := account.ResolveMappedModel(testModelID); matched { testModelID = mappedModel } else { testModelID = normalizeVertexAnthropicModelID(claude.NormalizeModelID(testModelID)) } 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() payload, err := createTestPayload(testModelID) if err != nil { return s.sendErrorAndEnd(c, "Failed to create test payload") } payloadBytes, _ := json.Marshal(payload) vertexBody, err := buildVertexAnthropicRequestBody(payloadBytes) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to create Vertex request body: %s", err.Error())) } if s.claudeTokenProvider == nil { return s.sendErrorAndEnd(c, "Claude token provider not configured") } accessToken, err := s.claudeTokenProvider.GetAccessToken(ctx, account) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to get service account access token: %s", err.Error())) } fullURL, err := buildVertexAnthropicURL(account.VertexProjectID(), account.VertexLocation(testModelID), testModelID, true) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to build Vertex URL: %s", err.Error())) } s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID}) req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(vertexBody)) if err != nil { return s.sendErrorAndEnd(c, "Failed to create request") } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+accessToken) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { proxyURL = account.Proxy.URL() } resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) 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) errMsg := fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body)) if resp.StatusCode == http.StatusForbidden { _ = s.accountRepo.SetError(ctx, account.ID, errMsg) } return s.sendErrorAndEnd(c, errMsg) } return s.processClaudeStream(c, resp.Body) } // testBedrockAccountConnection tests a Bedrock (SigV4 or API Key) account using non-streaming invoke func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx context.Context, account *Account, testModelID string) error { region := bedrockRuntimeRegion(account) resolvedModelID, ok := ResolveBedrockModelID(account, testModelID) if !ok { return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported Bedrock model: %s", testModelID)) } testModelID = resolvedModelID // Set SSE headers (test UI expects SSE) c.Writer.Header().Set("Content-Type", "text/event-stream") c.Writer.Header().Set("Cache-Control", "no-cache") c.Writer.Header().Set("Connection", "keep-alive") c.Writer.Header().Set("X-Accel-Buffering", "no") c.Writer.Flush() // Create a minimal Bedrock-compatible payload (no stream, no cache_control) bedrockPayload := map[string]any{ "anthropic_version": "bedrock-2023-05-31", "messages": []map[string]any{ { "role": "user", "content": []map[string]any{ { "type": "text", "text": "hi", }, }, }, }, "max_tokens": 256, "temperature": 1, } bedrockBody, _ := json.Marshal(bedrockPayload) // Use non-streaming endpoint (response is standard Claude JSON) apiURL := BuildBedrockURL(region, testModelID, false) s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID}) req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(bedrockBody)) if err != nil { return s.sendErrorAndEnd(c, "Failed to create request") } req.Header.Set("Content-Type", "application/json") // Sign or set auth based on account type if account.IsBedrockAPIKey() { apiKey := account.GetCredential("api_key") if apiKey == "" { return s.sendErrorAndEnd(c, "No API key available") } req.Header.Set("Authorization", "Bearer "+apiKey) } else { signer, err := NewBedrockSignerFromAccount(account) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to create Bedrock signer: %s", err.Error())) } if err := signer.SignRequest(ctx, req, bedrockBody); err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to sign request: %s", err.Error())) } } proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { proxyURL = account.Proxy.URL() } resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, nil) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error())) } defer func() { _ = resp.Body.Close() }() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return s.sendErrorAndEnd(c, fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body))) } // Bedrock non-streaming response is standard Claude JSON, extract the text var result struct { Content []struct { Text string `json:"text"` } `json:"content"` } if err := json.Unmarshal(body, &result); err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to parse response: %s", err.Error())) } text := "" if len(result.Content) > 0 { text = result.Content[0].Text } if text == "" { text = "(empty response)" } s.sendEvent(c, TestEvent{Type: "content", Text: text}) s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) return nil } // testOpenAIAccountConnection tests an OpenAI account's connection func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account *Account, modelID string, prompt string, mode string) error { ctx := c.Request.Context() mode = normalizeAccountTestMode(mode) // Default to openai.DefaultTestModel for OpenAI testing testModelID := modelID if testModelID == "" { testModelID = openai.DefaultTestModel } // Align test routing with gateway behavior: OpenAI accounts apply normal // account model mapping. Native remote compaction v2 rides the ordinary // /responses wire and does NOT apply the legacy compact-only mapping // (post-#5641 semantics: compact_model_mapping is /responses/compact-only). testModelID = account.GetMappedModel(testModelID) if mode == AccountTestModeCompact { return s.testOpenAICompactConnection(c, account, testModelID) } // Route to image generation test if an image model is selected if isOpenAIImageModel(testModelID) { imagePrompt := strings.TrimSpace(prompt) if imagePrompt == "" { imagePrompt = defaultOpenAIImageTestPrompt } if account.Type == "apikey" { return s.testOpenAIImageAPIKey(c, ctx, account, testModelID, imagePrompt) } return s.testOpenAIImageOAuth(c, ctx, account, testModelID, imagePrompt) } credentialAccount := account if account.IsCredentialShadow() { resolved, err := resolveCredentialAccount(ctx, s.accountRepo, account) if err != nil { return s.sendErrorAndEnd(c, err.Error()) } credentialAccount = resolved } // Determine authentication method and API URL var authToken string var apiURL string var isOAuth bool if credentialAccount.IsOAuth() { isOAuth = true // Agent Identity signs each request and does not retain the OAuth token. if !credentialAccount.IsOpenAIAgentIdentity() { authToken = credentialAccount.GetOpenAIAccessToken() } if authToken == "" && !credentialAccount.IsOpenAIAgentIdentity() { return s.sendErrorAndEnd(c, "No access token available") } // OAuth uses ChatGPT internal API apiURL = chatgptCodexAPIURL } else if credentialAccount.Type == "apikey" { // API Key - use Platform API authToken = credentialAccount.GetOpenAIApiKey() if authToken == "" { return s.sendErrorAndEnd(c, "No API key available") } baseURL := credentialAccount.GetOpenAIBaseURL() if baseURL == "" { baseURL = "https://api.openai.com" } normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid base URL: %s", err.Error())) } if !openai_compat.ShouldUseResponsesAPI(account.Extra) { return s.testOpenAIChatCompletionsConnection(c, account, testModelID, prompt, normalizedBaseURL, authToken) } apiURL = buildOpenAIResponsesURL(normalizedBaseURL) } 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. OAuth accounts use ChatGPT Codex // upstream and must apply the same model normalization as real forwarding. upstreamTestModelID := testModelID if isOAuth { upstreamTestModelID = normalizeOpenAIModelForUpstream(credentialAccount, testModelID) } payload := createOpenAITestPayload(upstreamTestModelID, isOAuth) payloadBytes, _ := json.Marshal(payload) // Send test_start event once. A task-invalid Agent Identity response may // restart this probe after registering a replacement task. if !agentIdentityTaskRecoveryWasTried(ctx) { 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") } req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI)) // Set common headers req.Header.Set("Content-Type", "application/json") if !isOAuth { applyOpenAICodexProbeHeaders(req.Header) } if credentialAccount.IsOpenAIAgentIdentity() { authHeaders, authErr := buildAgentIdentityAuthenticationHeaders(ctx, s.accountRepo, s.agentIdentityWS, &s.agentIdentityTaskMu, credentialAccount) if authErr != nil { return s.sendErrorAndEnd(c, "Failed to build Agent Identity authentication") } for key, values := range authHeaders { for _, value := range values { req.Header.Add(key, value) } } } else { 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") req.Header.Set("OpenAI-Beta", "responses=experimental") canonical := resolveCodexOutboundIdentity("") req.Header.Set("Originator", canonical.originator) if customUA := strings.TrimSpace(credentialAccount.GetOpenAIUserAgent()); customUA != "" { req.Header.Set("User-Agent", customUA) } else { req.Header.Set("User-Agent", canonical.userAgent) } setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) // 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入,否则测试用的身份 // 与该账号真实出站的身份不是同一个(issue #3901 的配对不变式由收口保证)。 enforceCodexIdentityHeadersWithUA(req.Header, credentialAccount.GetOpenAIUserAgent()) } // 账号级请求头覆写:测试请求与真实转发保持一致的最终头 credentialAccount.ApplyHeaderOverrides(req.Header) // Get proxy URL proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { proxyURL = account.Proxy.URL() } resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error())) } defer func() { _ = resp.Body.Close() }() 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 resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) body = redactAgentIdentitySensitiveBodyForAccount(ctx, s.accountRepo, credentialAccount, body) if !agentIdentityTaskRecoveryWasTried(ctx) && credentialAccount.IsOpenAIAgentIdentity() && isAgentIdentityTaskInvalidHTTPResponse(resp.StatusCode, body) { expectedTaskID := credentialAccount.GetCredential("task_id") if err := ensureAgentIdentityTaskForAccount(ctx, s.accountRepo, s.agentIdentityWS, &s.agentIdentityTaskMu, credentialAccount, expectedTaskID); err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Agent Identity task recovery failed: %s", err.Error())) } c.Request = c.Request.WithContext(markAgentIdentityTaskRecoveryTried(ctx)) return s.testOpenAIAccountConnection(c, account, modelID, prompt, mode) } if resp.StatusCode == http.StatusTooManyRequests { s.reconcileOpenAI429State(ctx, account, resp.Header, body) } // 401 Unauthorized: 标记账号为永久错误 if resp.StatusCode == http.StatusUnauthorized && s.accountRepo != nil { errMsg := fmt.Sprintf("Authentication failed (401): %s", string(body)) _ = s.accountRepo.SetError(ctx, account.ID, errMsg) } return s.sendErrorAndEnd(c, fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body))) } // Process SSE stream return s.processOpenAIStream(c, resp.Body) } // testGrokAccountConnection routes Grok admin connectivity tests by explicit mode first, // then by selected model family for media. Standalone modes (search/tts/stt) never share // the text Responses path; image/video never hit Responses either. // // Modes: // - default/text → Responses (optional model) // - image → /v1/images/generations (model optional; defaults to grok-imagine-image) // - video → /v1/videos/generations (model optional; defaults to grok-imagine-video) // - search → standalone web-search probe (gateway /v1/web_search semantics) // - tts → HTTP /v1/tts // - stt → HTTP /v1/stt (synthetic tiny wav probe) // - realtime → WS /v1/realtime dial + optional first server event // // When mode is default, image/video can still be inferred from model_id for backward compat. func (s *AccountTestService) testGrokAccountConnection(c *gin.Context, account *Account, modelID, prompt, mode string, opts AccountTestOptions) error { ctx := c.Request.Context() // Realtime is WebSocket-only and does not need HTTP upstream. mode = normalizeGrokAccountTestMode(mode) if mode != AccountTestModeGrokRealtime && s.httpUpstream == nil { return s.sendErrorAndEnd(c, "HTTP upstream not configured") } authToken, err := s.grokTestAccessToken(ctx, account) if err != nil { return s.sendErrorAndEnd(c, err.Error()) } // Explicit standalone / media modes always win over model id. switch mode { case AccountTestModeGrokSearch: return s.testGrokWebSearch(c, ctx, account, authToken, prompt) case AccountTestModeGrokTTS: return s.testGrokTTS(c, ctx, account, authToken, prompt) case AccountTestModeGrokSTT: return s.testGrokSTT(c, ctx, account, authToken, opts.AudioDataURL) case AccountTestModeGrokRealtime: return s.testGrokRealtime(c, ctx, account, authToken, modelID) case AccountTestModeGrokImage: return s.testGrokImageGeneration(c, ctx, account, authToken, resolveGrokImageTestModel(account, modelID), resolveGrokImagePrompt(prompt), opts.ImageDataURL) case AccountTestModeGrokVideo: return s.testGrokVideoGeneration(c, ctx, account, authToken, resolveGrokVideoTestModel(account, modelID), resolveGrokVideoPrompt(prompt), opts) case AccountTestModeGrokText: // Force text Responses even if model_id looks like media. testModelID := strings.TrimSpace(modelID) if testModelID == "" { testModelID = grokDefaultResponsesModel } if mapped := strings.TrimSpace(account.GetMappedModel(testModelID)); mapped != "" { testModelID = mapped } return s.testGrokResponsesConnection(c, ctx, account, authToken, testModelID) } // mode == default: infer from model family (legacy UI / API clients). testModelID := strings.TrimSpace(modelID) if testModelID == "" { testModelID = grokDefaultResponsesModel } if mapped := strings.TrimSpace(account.GetMappedModel(testModelID)); mapped != "" { testModelID = mapped } switch { case isGrokImageGenerationModel(testModelID): return s.testGrokImageGeneration(c, ctx, account, authToken, testModelID, resolveGrokImagePrompt(prompt), opts.ImageDataURL) case isGrokVideoGenerationModel(testModelID): return s.testGrokVideoGeneration(c, ctx, account, authToken, testModelID, resolveGrokVideoPrompt(prompt), opts) default: return s.testGrokResponsesConnection(c, ctx, account, authToken, testModelID) } } func resolveGrokImagePrompt(prompt string) string { if strings.TrimSpace(prompt) == "" { return defaultGrokImageTestPrompt } return strings.TrimSpace(prompt) } func resolveGrokVideoPrompt(prompt string) string { if strings.TrimSpace(prompt) == "" { return defaultGrokVideoTestPrompt } return strings.TrimSpace(prompt) } func resolveGrokImageTestModel(account *Account, modelID string) string { testModelID := strings.TrimSpace(modelID) if testModelID == "" { testModelID = "grok-imagine-image" } if mapped := strings.TrimSpace(account.GetMappedModel(testModelID)); mapped != "" { return mapped } return testModelID } func resolveGrokVideoTestModel(account *Account, modelID string) string { testModelID := strings.TrimSpace(modelID) if testModelID == "" { testModelID = "grok-imagine-video" } if mapped := strings.TrimSpace(account.GetMappedModel(testModelID)); mapped != "" { return mapped } return testModelID } func (s *AccountTestService) grokTestAccessToken(ctx context.Context, account *Account) (string, error) { switch account.Type { case AccountTypeOAuth: if s.grokTokenProvider == nil { return "", fmt.Errorf("grok token provider not configured") } // Manual tests skip production scheduling eligibility so paused/rate-limited // accounts can still be probed by admins (same as Codex/OpenAI tests). token, err := s.grokTokenProvider.GetAccessTokenForManualTest(ctx, account) if err != nil { return "", fmt.Errorf("failed to get grok access token: %s", err.Error()) } return token, nil case AccountTypeAPIKey: authToken := strings.TrimSpace(account.GetCredential("api_key")) if authToken == "" { return "", fmt.Errorf("grok api key is missing") } return authToken, nil default: return "", fmt.Errorf("unsupported grok account type: %s", account.Type) } } func (s *AccountTestService) grokTestProxyURL(account *Account) string { if account.ProxyID != nil && account.Proxy != nil { return account.Proxy.URL() } return "" } func (s *AccountTestService) prepareGrokTestSSE(c *gin.Context) { 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() } func (s *AccountTestService) applyGrokTestRequestHeaders(req *http.Request, account *Account, authToken string, accept string) { req.Header.Set("Content-Type", "application/json") if accept != "" { req.Header.Set("Accept", accept) } req.Header.Set("Authorization", "Bearer "+authToken) // Match gateway media/voice: CLI identity headers only on the CLI chat proxy. // api.x.ai media (images/videos) rejects or mistreats OAuth when CLI headers // are stamped on the official API host (e.g. ZDR upload_url false positives). if account.IsGrokOAuth() && req.URL != nil && isGrokCLIProxyTarget(req.URL.String()) { applyGrokCLIHeaders(req.Header) } account.ApplyHeaderOverrides(req.Header) } func (s *AccountTestService) observeGrokTestResponse(ctx context.Context, account *Account, resp *http.Response) { if resp == nil { return } now := time.Now() // Error bodies carry Grok's free-usage, billing, and content-policy // classifications when quota headers are absent. Read only non-success // responses here, then restore the body because the caller still needs it // for the user-facing test result. var responseBody []byte if resp.StatusCode >= http.StatusBadRequest && resp.Body != nil { responseBody, _ = io.ReadAll(resp.Body) _ = resp.Body.Close() resp.Body = io.NopCloser(bytes.NewReader(responseBody)) } snapshot := parseGrokQuotaSnapshot(resp.Header, resp.StatusCode, now) stampGrokQuotaSnapshotForPlan(account, snapshot, grokRequestedModelFromCtx(ctx)) if snapshot != nil && s.accountRepo != nil { resetAt, limited := grokRateLimitResetAtForAccount(account, snapshot, now) if limited { normalizeGrokExhaustedWindowResets(snapshot, resetAt, now) } _ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ grokQuotaSnapshotExtraKey: snapshot, }) if limited { persistGrokRateLimit(ctx, s.accountRepo, account, resetAt) } else if isSuccessfulGrokRateLimitRecovery(account, snapshot) { clearGrokRateLimitAfterRecovery(ctx, s.accountRepo, account) } } else if s.accountRepo != nil && isSuccessfulGrokRateLimitRecovery(account, &xai.QuotaSnapshot{StatusCode: resp.StatusCode}) { clearGrokRateLimitAfterRecovery(ctx, s.accountRepo, account) } if s.accountRepo == nil || len(responseBody) == 0 { if resp.StatusCode == http.StatusPaymentRequired && s.accountRepo != nil { stateCtx, cancel := openAIAccountStateContext(ctx) defer cancel() _ = s.accountRepo.SetTempUnschedulable(stateCtx, account.ID, now.Add(30*time.Minute), "grok payment required") } return } if isGrokContentPolicyRejection(resp.StatusCode, responseBody) { return } decision := classifyGrokUpstreamFailure(resp.StatusCode, responseBody, "") if decision.Class == GrokFailureFreeUsage { if resetAt, limited := grokRateLimitResetAtForAccount(account, snapshot, now); limited && resetAt.After(now) { persistGrokRateLimit(ctx, s.accountRepo, account, resetAt) } else { stateCtx, cancel := openAIAccountStateContext(ctx) _ = s.accountRepo.SetTempUnschedulable(stateCtx, account.ID, now.Add(grokFreeUsageProbeCooldown), "grok free usage exhausted") cancel() } return } if decision.Class == GrokFailureBilling && (isGrokSpendingLimitError(responseBody) || strings.Contains(strings.ToLower(decision.Reason), "credit")) { persistGrokRateLimit(ctx, s.accountRepo, account, grokSpendingLimitResetAt(account, now)) return } cooldown := time.Duration(0) reason := "" switch resp.StatusCode { case http.StatusUnauthorized: cooldown, reason = 10*time.Minute, "grok oauth token unauthorized" case http.StatusPaymentRequired: cooldown, reason = 30*time.Minute, "grok payment required" case http.StatusForbidden: cooldown, reason = 30*time.Minute, "grok entitlement or subscription tier denied" default: if resp.StatusCode >= 500 { cooldown, reason = 2*time.Minute, "grok upstream temporary error" } } if decision.Class == GrokFailureBilling && cooldown == 0 { cooldown, reason = 30*time.Minute, "grok payment required" } if cooldown > 0 { stateCtx, cancel := openAIAccountStateContext(ctx) defer cancel() until := now.Add(cooldown) if account.TempUnschedulableUntil != nil && account.TempUnschedulableUntil.After(until) { until = *account.TempUnschedulableUntil } _ = s.accountRepo.SetTempUnschedulable( stateCtx, account.ID, until, reason, ) } } func (s *AccountTestService) testGrokResponsesConnection(c *gin.Context, ctx context.Context, account *Account, authToken, testModelID string) error { apiURL, err := buildGrokResponsesURL(account, s.cfg, s.settingService) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Grok base URL: %s", err.Error())) } s.prepareGrokTestSSE(c) payloadBytes, err := buildGrokQuotaProbeBody(testModelID) if err != nil { return s.sendErrorAndEnd(c, "Failed to create Grok test payload") } if !agentIdentityTaskRecoveryWasTried(ctx) { s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID}) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes)) if err != nil { return s.sendErrorAndEnd(c, "Failed to create Grok request") } s.applyGrokTestRequestHeaders(req, account, authToken, "application/json, text/event-stream") resp, err := s.httpUpstream.Do(req, s.grokTestProxyURL(account), account.ID, account.Concurrency) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Grok Responses API request failed: %s", err.Error())) } defer func() { _ = resp.Body.Close() }() s.observeGrokTestResponse(withGrokTeamRateLimitModel(ctx, testModelID), account, resp) if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return s.sendErrorAndEnd(c, fmt.Sprintf("Grok Responses API returned %d: %s", resp.StatusCode, string(body))) } return s.processOpenAIStream(c, resp.Body) } func (s *AccountTestService) testGrokImageGeneration(c *gin.Context, ctx context.Context, account *Account, authToken, modelID, prompt, imageDataURL string) error { // With a source image, prefer /images/edits; otherwise /images/generations. endpoint := GrokMediaEndpointImagesGenerations imageDataURL = strings.TrimSpace(imageDataURL) hasSourceImage := imageDataURL != "" if hasSourceImage { endpoint = GrokMediaEndpointImagesEdits } // Align model aliases with gateway (e.g. grok-imagine → grok-imagine-image-quality). modelID = NormalizeGrokMediaModelForEndpoint(endpoint, modelID, hasSourceImage) if modelID == "" { modelID = "grok-imagine-image-quality" } apiURL, err := buildGrokMediaURL(account, s.cfg, endpoint, "") if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Grok media base URL: %s", err.Error())) } s.prepareGrokTestSSE(c) s.sendEvent(c, TestEvent{Type: "test_start", Model: modelID}) if endpoint == GrokMediaEndpointImagesEdits { s.sendEvent(c, TestEvent{Type: "status", Text: "Calling Grok /v1/images/edits with uploaded source image..."}) } else { s.sendEvent(c, TestEvent{Type: "status", Text: "Calling Grok /v1/images/generations..."}) } // Zero-data-retention teams reject URL format; always request base64 for admin tests. payload := map[string]any{ "model": modelID, "prompt": prompt, "n": 1, "response_format": "b64_json", } if hasSourceImage { normalized, err := normalizeAccountTestImageDataURL(imageDataURL) if err != nil { return s.sendErrorAndEnd(c, err.Error()) } // Match gateway prepareGrokMediaForwardBody shape: {url, type:image_url}. payload["image"] = grokMediaImageObject(normalized) s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("source image ready (%d chars data URL)\n", len(normalized))}) } payloadBytes, err := json.Marshal(payload) if err != nil { return s.sendErrorAndEnd(c, "Failed to marshal Grok image request") } req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes)) if err != nil { return s.sendErrorAndEnd(c, "Failed to create Grok image request") } s.applyGrokTestRequestHeaders(req, account, authToken, "application/json") req.ContentLength = int64(len(payloadBytes)) req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(payloadBytes)), nil } // One retry on transport EOF (proxies occasionally drop large edit payloads). var resp *http.Response var doErr error for attempt := 0; attempt < 2; attempt++ { if attempt > 0 { s.sendEvent(c, TestEvent{Type: "status", Text: "Retrying Grok image request after transport error..."}) req, err = http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes)) if err != nil { return s.sendErrorAndEnd(c, "Failed to create Grok image retry request") } s.applyGrokTestRequestHeaders(req, account, authToken, "application/json") req.ContentLength = int64(len(payloadBytes)) } resp, doErr = s.httpUpstream.Do(req, s.grokTestProxyURL(account), account.ID, account.Concurrency) if doErr == nil { break } if !isTransientGrokTransportError(doErr) || attempt == 1 { return s.sendErrorAndEnd(c, formatGrokImageTransportError(doErr, hasSourceImage, len(payloadBytes))) } } defer func() { _ = resp.Body.Close() }() s.observeGrokTestResponse(ctx, account, resp) body, err := io.ReadAll(resp.Body) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to read Grok image response: %s", err.Error())) } if resp.StatusCode != http.StatusOK { return s.sendErrorAndEnd(c, formatGrokImagesAPIError(resp.StatusCode, body, hasSourceImage)) } var result struct { Data []struct { URL string `json:"url"` B64JSON string `json:"b64_json"` RevisedPrompt string `json:"revised_prompt"` MimeType string `json:"mime_type"` } `json:"data"` } if err := json.Unmarshal(body, &result); err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to parse Grok image response: %s", err.Error())) } if len(result.Data) == 0 { return s.sendErrorAndEnd(c, "No images returned from Grok API") } for _, item := range result.Data { if item.RevisedPrompt != "" { s.sendEvent(c, TestEvent{Type: "content", Text: item.RevisedPrompt}) } mimeType := strings.TrimSpace(item.MimeType) if mimeType == "" { mimeType = "image/jpeg" } switch { case strings.TrimSpace(item.B64JSON) != "": s.sendEvent(c, TestEvent{ Type: "image", ImageURL: "data:" + mimeType + ";base64," + item.B64JSON, MimeType: mimeType, }) case strings.TrimSpace(item.URL) != "": s.sendEvent(c, TestEvent{Type: "image", ImageURL: item.URL, MimeType: mimeType}) } } s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) return nil } func (s *AccountTestService) testGrokVideoGeneration(c *gin.Context, ctx context.Context, account *Account, authToken, modelID, prompt string, opts AccountTestOptions) error { apiURL, err := buildGrokMediaURL(account, s.cfg, GrokMediaEndpointVideosGenerations, "") if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Grok media base URL: %s", err.Error())) } s.prepareGrokTestSSE(c) s.sendEvent(c, TestEvent{Type: "test_start", Model: modelID}) s.sendEvent(c, TestEvent{Type: "status", Text: "Calling Grok /v1/videos/generations..."}) payload := map[string]any{ "model": modelID, "prompt": prompt, "duration": 6, "aspect_ratio": "16:9", "resolution": "480p", } if img := strings.TrimSpace(opts.ImageDataURL); img != "" { normalized, err := normalizeAccountTestImageDataURL(img) if err != nil { return s.sendErrorAndEnd(c, err.Error()) } // First-frame / image-to-video input (xAI image field). payload["image"] = grokMediaImageObject(normalized) s.sendEvent(c, TestEvent{Type: "content", Text: "using uploaded first-frame / reference image\n"}) } payloadBytes, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes)) if err != nil { return s.sendErrorAndEnd(c, "Failed to create Grok video request") } s.applyGrokTestRequestHeaders(req, account, authToken, "application/json") resp, err := s.httpUpstream.Do(req, s.grokTestProxyURL(account), account.ID, account.Concurrency) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Grok video request failed: %s", err.Error())) } defer func() { _ = resp.Body.Close() }() s.observeGrokTestResponse(ctx, account, resp) body, err := io.ReadAll(resp.Body) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to read Grok video response: %s", err.Error())) } if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusCreated { return s.sendErrorAndEnd(c, fmt.Sprintf("Grok videos API returned %d: %s", resp.StatusCode, string(body))) } requestID := strings.TrimSpace(gjson.GetBytes(body, "request_id").String()) if requestID == "" { requestID = strings.TrimSpace(gjson.GetBytes(body, "id").String()) } if requestID == "" { return s.sendErrorAndEnd(c, fmt.Sprintf("Grok video create response missing request_id: %s", string(body))) } s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("video request accepted: %s\n", requestID)}) s.sendEvent(c, TestEvent{Type: "status", Text: "Polling video status until done (max ~60s)..."}) statusURL, err := buildGrokMediaURL(account, s.cfg, GrokMediaEndpointVideoStatus, requestID) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Grok video status URL: %s", err.Error())) } deadline := time.Now().Add(60 * time.Second) for time.Now().Before(deadline) { if ctx.Err() != nil { return s.sendErrorAndEnd(c, "Grok video poll canceled") } statusReq, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil) if err != nil { return s.sendErrorAndEnd(c, "Failed to create Grok video status request") } s.applyGrokTestRequestHeaders(statusReq, account, authToken, "application/json") statusResp, err := s.httpUpstream.Do(statusReq, s.grokTestProxyURL(account), account.ID, account.Concurrency) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Grok video status failed: %s", err.Error())) } statusBody, _ := io.ReadAll(statusResp.Body) _ = statusResp.Body.Close() if statusResp.StatusCode != http.StatusOK && statusResp.StatusCode != http.StatusAccepted { return s.sendErrorAndEnd(c, fmt.Sprintf("Grok video status returned %d: %s", statusResp.StatusCode, string(statusBody))) } st := strings.ToLower(strings.TrimSpace(gjson.GetBytes(statusBody, "status").String())) progress := gjson.GetBytes(statusBody, "progress") if progress.Exists() { s.sendEvent(c, TestEvent{Type: "status", Text: fmt.Sprintf("status=%s progress=%v", st, progress.Value())}) } else { s.sendEvent(c, TestEvent{Type: "status", Text: "status=" + st}) } switch st { case "done", "completed", "succeeded", "success": return s.emitGrokVideoResult(c, ctx, account, authToken, requestID, statusBody) case "failed", "error", "canceled", "cancelled": return s.sendErrorAndEnd(c, fmt.Sprintf("Grok video failed: %s", string(statusBody))) } select { case <-ctx.Done(): return s.sendErrorAndEnd(c, "Grok video poll canceled") case <-time.After(3 * time.Second): } } return s.sendErrorAndEnd(c, "Grok video still processing after 60s (request_id="+requestID+")") } // emitGrokVideoResult surfaces a playable video URL or downloads /content as data URL. func (s *AccountTestService) emitGrokVideoResult(c *gin.Context, ctx context.Context, account *Account, authToken, requestID string, statusBody []byte) error { videoURL := firstNonEmpty( strings.TrimSpace(gjson.GetBytes(statusBody, "video.url").String()), strings.TrimSpace(gjson.GetBytes(statusBody, "url").String()), strings.TrimSpace(gjson.GetBytes(statusBody, "video_url").String()), strings.TrimSpace(gjson.GetBytes(statusBody, "download_url").String()), ) if videoURL != "" && (strings.HasPrefix(videoURL, "http://") || strings.HasPrefix(videoURL, "https://") || strings.HasPrefix(videoURL, "data:")) { s.sendEvent(c, TestEvent{Type: "content", Text: "video ready: " + videoURL + "\n"}) s.sendEvent(c, TestEvent{Type: "video", VideoURL: videoURL, MimeType: "video/mp4"}) s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) return nil } // Fetch binary content via official /videos/{id}/content (Bearer-authenticated). contentURL, err := buildGrokMediaURL(account, s.cfg, GrokMediaEndpointVideoContent, requestID) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Grok video content URL: %s", err.Error())) } s.sendEvent(c, TestEvent{Type: "status", Text: "Downloading video content for preview..."}) req, err := http.NewRequestWithContext(ctx, http.MethodGet, contentURL, nil) if err != nil { return s.sendErrorAndEnd(c, "Failed to create Grok video content request") } s.applyGrokTestRequestHeaders(req, account, authToken, "video/*, application/octet-stream, */*") resp, err := s.httpUpstream.Do(req, s.grokTestProxyURL(account), account.ID, account.Concurrency) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Grok video content download failed: %s", err.Error())) } defer func() { _ = resp.Body.Close() }() body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<20)) // 64 MiB cap for admin preview if resp.StatusCode != http.StatusOK { // Fall back to status URL when binary content is unavailable. if videoURL != "" { s.sendEvent(c, TestEvent{Type: "content", Text: "video completed; content download unavailable, reported url=" + videoURL + "\n"}) s.sendEvent(c, TestEvent{Type: "video", VideoURL: videoURL, MimeType: "video/mp4"}) s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) return nil } return s.sendErrorAndEnd(c, fmt.Sprintf("Grok video content returned %d: %s", resp.StatusCode, truncateString(string(body), 300))) } ct := resp.Header.Get("Content-Type") if ct == "" || strings.HasPrefix(ct, "application/octet-stream") { ct = "video/mp4" } // Keep only type/subtype for data URL. if i := strings.Index(ct, ";"); i >= 0 { ct = strings.TrimSpace(ct[:i]) } dataURL := "data:" + ct + ";base64," + base64.StdEncoding.EncodeToString(body) s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("video content downloaded: content-type=%s bytes=%d\n", ct, len(body))}) s.sendEvent(c, TestEvent{Type: "video", VideoURL: dataURL, MimeType: ct}) s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) return nil } func (s *AccountTestService) testGrokWebSearch(c *gin.Context, ctx context.Context, account *Account, authToken, query string) error { query = strings.TrimSpace(query) if query == "" { query = defaultGrokSearchTestQuery } // Account-test "web_search" mode mirrors the standalone gateway endpoint // POST /v1/web_search (not a free-form chat with tools). Implementation still // uses the same DoGrokNativeResponsesJSON helper as the gateway handler so // results match production search. s.prepareGrokTestSSE(c) s.sendEvent(c, TestEvent{Type: "test_start", Model: "grok-web-search"}) s.sendEvent(c, TestEvent{Type: "status", Text: "Calling standalone web_search probe (same as gateway /v1/web_search)..."}) // Keep parity with handler.buildGrokWebSearchPrompt / include sources. const maxResults = 5 prompt := fmt.Sprintf( `Search the web for the user query below. Return ONLY valid JSON with this exact shape: {"results":[{"url":"https://...","title":"page title","snippet":"concise factual summary"}]}. Return at most %d unique results. Every URL must be an actual web_search source. Populate a non-empty title and snippet for every result. Do not wrap the JSON in markdown. User query: %s`, maxResults, query) payload := map[string]any{ "model": grokDefaultResponsesModel, "input": prompt, "tools": []map[string]any{{"type": "web_search"}}, "include": []string{"web_search_call.action.sources"}, "store": false, "stream": false, } payloadBytes, _ := json.Marshal(payload) apiURL, err := buildGrokResponsesURL(account, s.cfg, s.settingService) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Grok base URL: %s", err.Error())) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes)) if err != nil { return s.sendErrorAndEnd(c, "Failed to create standalone web_search probe request") } s.applyGrokTestRequestHeaders(req, account, authToken, "application/json") resp, err := s.httpUpstream.Do(req, s.grokTestProxyURL(account), account.ID, account.Concurrency) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("standalone web_search probe failed: %s", err.Error())) } defer func() { _ = resp.Body.Close() }() s.observeGrokTestResponse(withGrokTeamRateLimitModel(ctx, grokDefaultResponsesModel), account, resp) body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return s.sendErrorAndEnd(c, fmt.Sprintf("standalone web_search probe returned %d: %s", resp.StatusCode, string(body))) } // Normalize like gateway extractGrokWebSearchSources (URL-only sources are enough for connectivity). sourceCount := 0 gjson.GetBytes(body, "output").ForEach(func(_, item gjson.Result) bool { if item.Get("type").String() != "web_search_call" { return true } sources := item.Get("action.sources") if sources.IsArray() { sourceCount += len(sources.Array()) } return true }) searchCount := countGrokNativeSearchCallsFromJSONBytes(body) s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("web_search ok: query=%q tool_calls=%d sources=%d\n", query, searchCount, sourceCount)}) // Optional: first structured result title if model returned JSON text. gjson.GetBytes(body, "output").ForEach(func(_, item gjson.Result) bool { if item.Get("type").String() != "message" { return true } for _, part := range item.Get("content").Array() { text := strings.TrimSpace(part.Get("text").String()) if text == "" { continue } if len(text) > 300 { text = text[:300] + "..." } s.sendEvent(c, TestEvent{Type: "content", Text: text + "\n"}) return false } return true }) if searchCount == 0 && sourceCount == 0 { return s.sendErrorAndEnd(c, "standalone web_search probe completed but no search sources/tool calls were observed") } s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) return nil } func (s *AccountTestService) testGrokTTS(c *gin.Context, ctx context.Context, account *Account, authToken, text string) error { text = strings.TrimSpace(text) if text == "" { text = defaultGrokTTSTestText } apiURL, err := buildGrokVoiceURL(account, s.cfg, "tts") if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Grok TTS URL: %s", err.Error())) } s.prepareGrokTestSSE(c) s.sendEvent(c, TestEvent{Type: "test_start", Model: "grok-voice-tts"}) s.sendEvent(c, TestEvent{Type: "status", Text: "Calling standalone /v1/tts..."}) // xAI requires `language`; optional voice_id. Prefer the shape that matches // live gateway probes (text + language [+ voice_id]). payloads := []map[string]any{ {"text": text, "language": "en", "voice_id": "Ara"}, {"text": text, "language": "en"}, {"text": text, "language": "English", "voice_id": "Ara"}, } var lastBody string var lastCode int for _, payload := range payloads { payloadBytes, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes)) if err != nil { return s.sendErrorAndEnd(c, "Failed to create Grok TTS request") } s.applyGrokTestRequestHeaders(req, account, authToken, "audio/*, application/json, */*") resp, err := s.httpUpstream.Do(req, s.grokTestProxyURL(account), account.ID, account.Concurrency) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Grok TTS failed: %s", err.Error())) } body, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() s.observeGrokTestResponse(ctx, account, resp) lastCode = resp.StatusCode lastBody = string(body) if resp.StatusCode == http.StatusOK { ct := resp.Header.Get("Content-Type") if ct == "" { ct = "audio/mpeg" } if i := strings.Index(ct, ";"); i >= 0 { ct = strings.TrimSpace(ct[:i]) } // Cap preview size so SSE stays manageable (~4 MiB audio). if len(body) > 4<<20 { body = body[:4<<20] } audioURL := "data:" + ct + ";base64," + base64.StdEncoding.EncodeToString(body) s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("tts ok: content-type=%s bytes=%d\n", ct, len(body))}) s.sendEvent(c, TestEvent{Type: "audio", AudioURL: audioURL, MimeType: ct}) s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) return nil } if resp.StatusCode < 400 || resp.StatusCode >= 500 { break } } return s.sendErrorAndEnd(c, fmt.Sprintf("Grok TTS returned %d: %s", lastCode, lastBody)) } // testGrokSTT posts audio to /v1/stt. When audioDataURL is set, uses the // uploaded file; otherwise a tiny synthetic silent WAV for connectivity only. func (s *AccountTestService) testGrokSTT(c *gin.Context, ctx context.Context, account *Account, authToken, audioDataURL string) error { apiURL, err := buildGrokVoiceURL(account, s.cfg, "stt") if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Grok STT URL: %s", err.Error())) } s.prepareGrokTestSSE(c) s.sendEvent(c, TestEvent{Type: "test_start", Model: "grok-voice-stt"}) var audioBytes []byte filename := "probe.wav" if audioDataURL = strings.TrimSpace(audioDataURL); audioDataURL != "" { if err := validateAccountTestDataURL(audioDataURL, "audio/"); err != nil { return s.sendErrorAndEnd(c, err.Error()) } raw, mime, err := decodeAccountTestDataURL(audioDataURL) if err != nil { return s.sendErrorAndEnd(c, "Invalid audio data URL: "+err.Error()) } audioBytes = raw filename = sttFilenameForMIME(mime) s.sendEvent(c, TestEvent{Type: "status", Text: "Calling standalone /v1/stt with uploaded audio..."}) s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("uploaded audio: mime=%s bytes=%d\n", mime, len(audioBytes))}) } else { audioBytes = minimalSilentWAV() s.sendEvent(c, TestEvent{Type: "status", Text: "Calling standalone /v1/stt with a synthetic silent WAV..."}) } var bodyBuf bytes.Buffer w := multipart.NewWriter(&bodyBuf) part, err := w.CreateFormFile("file", filename) if err != nil { return s.sendErrorAndEnd(c, "Failed to build STT multipart body") } if _, err := part.Write(audioBytes); err != nil { return s.sendErrorAndEnd(c, "Failed to write STT audio part") } _ = w.WriteField("model", "grok-stt") _ = w.WriteField("language", "en") if err := w.Close(); err != nil { return s.sendErrorAndEnd(c, "Failed to finalize STT multipart body") } req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, &bodyBuf) if err != nil { return s.sendErrorAndEnd(c, "Failed to create Grok STT request") } req.Header.Set("Content-Type", w.FormDataContentType()) req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+authToken) if account.IsGrokOAuth() { applyGrokCLIHeaders(req.Header) } account.ApplyHeaderOverrides(req.Header) resp, err := s.httpUpstream.Do(req, s.grokTestProxyURL(account), account.ID, account.Concurrency) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Grok STT failed: %s", err.Error())) } defer func() { _ = resp.Body.Close() }() s.observeGrokTestResponse(ctx, account, resp) respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { // 4xx on synthetic audio still proves the STT endpoint is wired; report clearly. return s.sendErrorAndEnd(c, fmt.Sprintf("Grok STT returned %d: %s", resp.StatusCode, string(respBody))) } text := strings.TrimSpace(gjson.GetBytes(respBody, "text").String()) if text == "" { text = strings.TrimSpace(string(respBody)) if len(text) > 200 { text = text[:200] + "..." } } s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("stt ok: %s\n", text)}) s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) return nil } // testGrokRealtime dials the standalone xAI Voice Realtime WebSocket // (wss://api.x.ai/v1/realtime?model=...) to verify auth + endpoint reachability. // It does not run a full audio session — success is WS handshake, optionally // enriched with the first server event type when one arrives quickly. func (s *AccountTestService) testGrokRealtime(c *gin.Context, ctx context.Context, account *Account, authToken, modelID string) error { model := strings.TrimSpace(modelID) if model == "" { model = defaultGrokRealtimeTestModel } if mapped := strings.TrimSpace(account.GetMappedModel(model)); mapped != "" { model = mapped } base, err := buildGrokVoiceURL(account, s.cfg, "realtime") if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Grok Realtime URL: %s", err.Error())) } u, err := url.Parse(base) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Grok Realtime URL: %s", err.Error())) } switch strings.ToLower(u.Scheme) { case "https": u.Scheme = "wss" case "http": u.Scheme = "ws" case "wss", "ws": // already websocket default: return s.sendErrorAndEnd(c, "Invalid Grok Realtime URL scheme") } q := u.Query() if q.Get("model") == "" { q.Set("model", model) } u.RawQuery = q.Encode() wsURL := u.String() s.prepareGrokTestSSE(c) s.sendEvent(c, TestEvent{Type: "test_start", Model: model}) s.sendEvent(c, TestEvent{Type: "status", Text: "Dialing standalone wss /v1/realtime (connectivity probe)..."}) s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("realtime target: %s\n", redactGrokRealtimeURLForLog(wsURL))}) headers := http.Header{} headers.Set("Authorization", "Bearer "+authToken) if account.IsGrokOAuth() { applyGrokCLIHeaders(headers) } account.ApplyHeaderOverrides(headers) dialer := s.grokWSDialer if dialer == nil { dialer = newDefaultOpenAIWSClientDialer() } dialCtx, cancel := context.WithTimeout(ctx, grokRealtimeProbeTimeout) defer cancel() conn, status, _, dialErr := dialer.Dial(dialCtx, wsURL, headers, s.grokTestProxyURL(account)) if dialErr != nil { detail := dialErr.Error() var hs *openAIWSHandshakeError if errors.As(dialErr, &hs) && len(hs.Body) > 0 { body := strings.TrimSpace(string(hs.Body)) if len(body) > 300 { body = body[:300] + "..." } detail = fmt.Sprintf("%s body=%s", detail, body) } if status > 0 { return s.sendErrorAndEnd(c, fmt.Sprintf("Grok Realtime WS handshake failed (HTTP %d): %s", status, detail)) } return s.sendErrorAndEnd(c, fmt.Sprintf("Grok Realtime WS dial failed: %s", detail)) } defer func() { _ = conn.Close() }() s.sendEvent(c, TestEvent{Type: "content", Text: "realtime ws handshake ok\n"}) // Best-effort: read one server event if it arrives quickly (session.created etc.). // Handshake alone is enough for connectivity; missing first event is not a failure. readCtx, readCancel := context.WithTimeout(ctx, 3*time.Second) defer readCancel() if msg, readErr := conn.ReadMessage(readCtx); readErr == nil && len(msg) > 0 { eventType := strings.TrimSpace(gjson.GetBytes(msg, "type").String()) if eventType == "" { eventType = "unknown" } preview := strings.TrimSpace(string(msg)) if len(preview) > 240 { preview = preview[:240] + "..." } s.sendEvent(c, TestEvent{ Type: "content", Text: fmt.Sprintf("realtime first event: type=%s payload=%s\n", eventType, preview), }) } else { s.sendEvent(c, TestEvent{ Type: "content", Text: "realtime handshake succeeded (no server event within 3s; still connectivity OK)\n", }) } s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) return nil } // validateAccountTestDataURL ensures data URLs are well-formed and size-bounded. func validateAccountTestDataURL(raw, requiredPrefix string) error { raw = strings.TrimSpace(raw) if raw == "" { return fmt.Errorf("media data URL is empty") } if !strings.HasPrefix(raw, "data:") { return fmt.Errorf("media must be a data: URL (data:;base64,...)") } // Rough size check before decode (base64 expands ~4/3). if len(raw) > maxAccountTestMediaBytes*2 { return fmt.Errorf("media data URL exceeds size limit") } _, mime, err := decodeAccountTestDataURL(raw) if err != nil { return err } if requiredPrefix != "" && !strings.HasPrefix(strings.ToLower(mime), strings.ToLower(requiredPrefix)) { return fmt.Errorf("expected media type prefix %q, got %q", requiredPrefix, mime) } return nil } // normalizeAccountTestImageDataURL validates an image data URL, enforces xAI // minimum dimensions (8x8), and rewrites to a clean data:image/;base64,... form. func normalizeAccountTestImageDataURL(raw string) (string, error) { if err := validateAccountTestDataURL(raw, "image/"); err != nil { return "", err } data, mime, err := decodeAccountTestDataURL(raw) if err != nil { return "", err } // Soft cap decoded bytes (~4 MiB) for edit payloads to avoid upstream/proxy EOF. const maxDecodedImage = 4 << 20 if len(data) > maxDecodedImage { return "", fmt.Errorf( "source image is too large (%d bytes decoded). Please use a smaller image (under ~4 MB) for admin edit tests", len(data), ) } cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) if err != nil { // Keep raw data URL if decoder does not understand the codec (e.g. webp // without golang.org/x/image/webp); still send upstream and let xAI validate. return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil } if cfg.Width < 8 || cfg.Height < 8 { return "", fmt.Errorf( "source image is too small (%dx%d). xAI requires both width and height to be at least 8 pixels", cfg.Width, cfg.Height, ) } // Prefer a stable mime from config when known. if mime == "" || mime == "application/octet-stream" { mime = "image/png" } return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil } func isTransientGrokTransportError(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) return strings.Contains(msg, "unexpected eof") || strings.Contains(msg, "connection reset") || strings.Contains(msg, "broken pipe") || strings.Contains(msg, "i/o timeout") || strings.Contains(msg, "timeout awaiting response") } func formatGrokImageTransportError(err error, hasSourceImage bool, payloadBytes int) string { base := fmt.Sprintf("Grok image request failed: %s", err.Error()) if !hasSourceImage { return base } return base + fmt.Sprintf( " (edit payload ~%d bytes). Tips: use a smaller source image (<4 MB / lower resolution), ensure the account proxy is stable, and retry. xAI /images/edits expects image as {\"url\":\"data:image/...;base64,...\",\"type\":\"image_url\"}.", payloadBytes, ) } func formatGrokImagesAPIError(status int, body []byte, hasSourceImage bool) string { msg := strings.TrimSpace(string(body)) if len(msg) > 800 { msg = msg[:800] + "..." } prefix := fmt.Sprintf("Grok images API returned %d: %s", status, msg) lower := strings.ToLower(msg) if hasSourceImage && (strings.Contains(lower, "too small") || strings.Contains(lower, "at least 8")) { return prefix + " — upload a source image with both width and height ≥ 8 px." } return prefix } func decodeAccountTestDataURL(raw string) (data []byte, mime string, err error) { raw = strings.TrimSpace(raw) if !strings.HasPrefix(raw, "data:") { return nil, "", fmt.Errorf("not a data URL") } rest := strings.TrimPrefix(raw, "data:") comma := strings.Index(rest, ",") if comma < 0 { return nil, "", fmt.Errorf("invalid data URL (missing comma)") } meta := rest[:comma] payload := rest[comma+1:] mime = "application/octet-stream" if semi := strings.Index(meta, ";"); semi >= 0 { if t := strings.TrimSpace(meta[:semi]); t != "" { mime = t } } else if t := strings.TrimSpace(meta); t != "" { mime = t } if !strings.Contains(strings.ToLower(meta), ";base64") { return nil, "", fmt.Errorf("only base64 data URLs are supported") } decoded, err := base64.StdEncoding.DecodeString(payload) if err != nil { // Some browsers emit URL-safe base64 without padding. decoded, err = base64.RawStdEncoding.DecodeString(strings.TrimRight(payload, "=")) if err != nil { return nil, "", fmt.Errorf("base64 decode failed: %w", err) } } if len(decoded) == 0 { return nil, "", fmt.Errorf("decoded media is empty") } if len(decoded) > maxAccountTestMediaBytes { return nil, "", fmt.Errorf("media exceeds %d byte limit", maxAccountTestMediaBytes) } return decoded, mime, nil } func sttFilenameForMIME(mime string) string { switch strings.ToLower(strings.TrimSpace(mime)) { case "audio/mpeg", "audio/mp3": return "upload.mp3" case "audio/wav", "audio/x-wav", "audio/wave": return "upload.wav" case "audio/webm": return "upload.webm" case "audio/ogg", "audio/opus": return "upload.ogg" case "audio/mp4", "audio/m4a", "audio/x-m4a": return "upload.m4a" default: return "upload.bin" } } // redactGrokRealtimeURLForLog strips query secrets while keeping model for diagnostics. func redactGrokRealtimeURLForLog(raw string) string { u, err := url.Parse(strings.TrimSpace(raw)) if err != nil || u == nil { return raw } // Keep model query only. model := u.Query().Get("model") u.RawQuery = "" if model != "" { u.RawQuery = "model=" + url.QueryEscape(model) } // Never log bearer in fragment/userinfo. u.User = nil u.Fragment = "" return u.String() } // minimalSilentWAV returns a valid tiny mono 8kHz 16-bit PCM WAV (~0.05s silence). func minimalSilentWAV() []byte { // 400 samples * 2 bytes = 800 data bytes const sampleRate = 8000 const numSamples = 400 dataSize := numSamples * 2 buf := make([]byte, 44+dataSize) copy(buf[0:], []byte("RIFF")) binary.LittleEndian.PutUint32(buf[4:], uint32(36+dataSize)) copy(buf[8:], []byte("WAVE")) copy(buf[12:], []byte("fmt ")) binary.LittleEndian.PutUint32(buf[16:], 16) // PCM chunk size binary.LittleEndian.PutUint16(buf[20:], 1) // PCM binary.LittleEndian.PutUint16(buf[22:], 1) // mono binary.LittleEndian.PutUint32(buf[24:], sampleRate) binary.LittleEndian.PutUint32(buf[28:], sampleRate*2) // byte rate binary.LittleEndian.PutUint16(buf[32:], 2) // block align binary.LittleEndian.PutUint16(buf[34:], 16) // bits copy(buf[36:], []byte("data")) binary.LittleEndian.PutUint32(buf[40:], uint32(dataSize)) // samples already zero (silence) return buf } // testOpenAIChatCompletionsConnection tests an OpenAI-compatible APIKey account // through the raw /v1/chat/completions endpoint. func (s *AccountTestService) testOpenAIChatCompletionsConnection( c *gin.Context, account *Account, testModelID string, prompt string, normalizedBaseURL string, authToken string, ) error { ctx := c.Request.Context() apiURL := buildOpenAIChatCompletionsURL(normalizedBaseURL) 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() payload := createOpenAIChatCompletionsTestPayload(testModelID, prompt) payloadBytes, _ := json.Marshal(payload) s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID}) s.sendEvent(c, TestEvent{Type: "status", Text: "正在通过 /v1/chat/completions 测试连接"}) req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes)) if err != nil { return s.sendErrorAndEnd(c, "Failed to create Chat Completions request") } req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "text/event-stream") req.Header.Set("Authorization", "Bearer "+authToken) // 账号级请求头覆写:测试请求与真实转发保持一致的最终头 account.ApplyHeaderOverrides(req.Header) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { proxyURL = account.Proxy.URL() } resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Chat Completions API (/v1/chat/completions) request failed: %s", err.Error())) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) if resp.StatusCode == http.StatusTooManyRequests { s.reconcileOpenAI429State(ctx, account, resp.Header, body) } if resp.StatusCode == http.StatusUnauthorized && s.accountRepo != nil { errMsg := fmt.Sprintf("Chat Completions authentication failed (401): %s", string(body)) _ = s.accountRepo.SetError(ctx, account.ID, errMsg) } return s.sendErrorAndEnd(c, fmt.Sprintf("Chat Completions API (/v1/chat/completions) returned %d: %s", resp.StatusCode, string(body))) } return s.processOpenAIChatCompletionsStream(c, resp.Body) } // testOpenAICompactConnection probes native remote compaction v2 (streaming // /responses with a compaction_trigger input item) and persists the resulting // capability state on the account. The legacy unary /responses/compact // endpoint has been sunset upstream (404, #5598/#5624) and is no longer probed. func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account *Account, testModelID string) error { ctx := c.Request.Context() credentialAccount := account if account.IsShadow() { resolved, err := resolveCredentialAccount(ctx, s.accountRepo, account) if err != nil { return s.sendErrorAndEnd(c, "Failed to resolve account credentials") } credentialAccount = resolved } authToken := "" apiURL := "" isOAuth := false switch { case credentialAccount.IsOAuth(): isOAuth = true if !credentialAccount.IsOpenAIAgentIdentity() { authToken = credentialAccount.GetOpenAIAccessToken() } if authToken == "" && !credentialAccount.IsOpenAIAgentIdentity() { return s.sendErrorAndEnd(c, "No access token available") } apiURL = chatgptCodexAPIURL case account.Type == AccountTypeAPIKey: authToken = account.GetOpenAIApiKey() if authToken == "" { return s.sendErrorAndEnd(c, "No API key available") } baseURL := account.GetOpenAIBaseURL() if baseURL == "" { baseURL = "https://api.openai.com" } normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid base URL: %s", err.Error())) } apiURL = buildOpenAIResponsesURL(normalizedBaseURL) default: return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported account type: %s", account.Type)) } 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() // 原生 v2 走普通 /responses 线:OAuth 与真实转发一致做上游模型归一化。 if isOAuth { testModelID = normalizeOpenAIModelForUpstream(credentialAccount, testModelID) } payloadBytes, _ := json.Marshal(createOpenAICompactProbePayload(testModelID, isOAuth)) if !agentIdentityTaskRecoveryWasTried(ctx) { 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") } req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI)) req.Header.Set("Content-Type", "application/json") // v2 探测是流式请求;同时补注协商头,与真实 codex 出站线型一致。 req.Header.Set("Accept", "text/event-stream") ensureOpenAIRemoteCompactionV2BetaFeature(req.Header) if credentialAccount.IsOpenAIAgentIdentity() { authHeaders, authErr := buildAgentIdentityAuthenticationHeaders(ctx, s.accountRepo, s.agentIdentityWS, &s.agentIdentityTaskMu, credentialAccount) if authErr != nil { return s.sendErrorAndEnd(c, "Failed to build Agent Identity authentication") } for key, values := range authHeaders { for _, value := range values { req.Header.Add(key, value) } } } else { req.Header.Set("Authorization", "Bearer "+authToken) } applyOpenAICodexProbeHeaders(req.Header) if isOAuth { enforceCodexIdentityHeadersWithUA(req.Header, credentialAccount.GetOpenAIUserAgent()) } probeSessionID := compactProbeSessionID(account.ID) req.Header.Set("Session_ID", probeSessionID) req.Header.Set("Conversation_ID", probeSessionID) if isOAuth { req.Host = "chatgpt.com" setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) // 指纹收敛:探测与真实转发走同一个 /responses 端点,身份也必须同构, // 否则探测流量会以「缺 x-codex-installation-id + 非收敛 session」的 // 形态暴露在上游眼里。账号关闭收敛(off)时返回 nil,探测保持原样。 if fpIDs := resolveCodexFingerprintIDsFromRequest(account, req.Header); fpIDs != nil { applyCodexFingerprintHeaders(req.Header, fpIDs) } } // 账号级请求头覆写:测试请求与真实转发保持一致的最终头 account.ApplyHeaderOverrides(req.Header) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { proxyURL = account.Proxy.URL() } resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) if err != nil { if s.accountRepo != nil { updates := buildOpenAICompactProbeExtraUpdates(nil, nil, err, false, time.Now()) _ = s.accountRepo.UpdateExtra(ctx, account.ID, updates) mergeAccountExtra(account, updates) } return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error())) } defer func() { _ = resp.Body.Close() }() body, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) body = redactAgentIdentitySensitiveBodyForAccount(ctx, s.accountRepo, credentialAccount, body) if !agentIdentityTaskRecoveryWasTried(ctx) && credentialAccount.IsOpenAIAgentIdentity() && isAgentIdentityTaskInvalidHTTPResponse(resp.StatusCode, body) { expectedTaskID := credentialAccount.GetCredential("task_id") if err := ensureAgentIdentityTaskForAccount(ctx, s.accountRepo, s.agentIdentityWS, &s.agentIdentityTaskMu, credentialAccount, expectedTaskID); err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Agent Identity task recovery failed: %s", err.Error())) } c.Request = c.Request.WithContext(markAgentIdentityTaskRecoveryTried(ctx)) return s.testOpenAICompactConnection(c, account, testModelID) } compactionFound := openAICompactProbeFoundCompactionItem(body) if s.accountRepo != nil { updates := buildOpenAICompactProbeExtraUpdates(resp, body, nil, compactionFound, time.Now()) if codexUpdates, err := extractOpenAICodexProbeUpdates(resp); err == nil && len(codexUpdates) > 0 { updates = mergeExtraUpdates(updates, codexUpdates) } if len(updates) > 0 { _ = s.accountRepo.UpdateExtra(ctx, account.ID, updates) mergeAccountExtra(account, updates) } // 探测如返回 429,主动同步限流状态,避免后续短时间内继续选中。 if resp.StatusCode == http.StatusTooManyRequests { s.reconcileOpenAI429State(ctx, account, resp.Header, body) } } if resp.StatusCode != http.StatusOK { if resp.StatusCode == http.StatusUnauthorized && s.accountRepo != nil { errMsg := fmt.Sprintf("Authentication failed (401): %s", string(body)) _ = s.accountRepo.SetError(ctx, account.ID, errMsg) } return s.sendErrorAndEnd(c, fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body))) } if !compactionFound { return s.sendErrorAndEnd(c, "Upstream returned 2xx without a compaction output item (native remote compaction v2 unsupported on this chain)") } s.sendEvent(c, TestEvent{Type: "content", Text: "Compact probe succeeded (native remote compaction v2)"}) s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) return nil } func (s *AccountTestService) reconcileOpenAI429State(ctx context.Context, account *Account, headers http.Header, body []byte) { if s == nil || s.accountRepo == nil || account == nil { return } persistOpenAI429PlanType(ctx, s.accountRepo, account, body) var resetAt *time.Time if calculated := calculateOpenAI429ResetTime(headers); calculated != nil { resetAt = calculated } else if unixTs := parseOpenAIRateLimitResetTime(body); unixTs != nil { t := time.Unix(*unixTs, 0) resetAt = &t } if resetAt == nil { return } if err := s.accountRepo.SetRateLimited(ctx, account.ID, *resetAt); err != nil { return } now := time.Now() account.RateLimitedAt = &now account.RateLimitResetAt = resetAt if account.Status == StatusError { if err := s.accountRepo.ClearError(ctx, account.ID); err != nil { return } account.Status = StatusActive account.ErrorMessage = "" } } // testGeminiAccountConnection tests a Gemini account's connection func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account *Account, modelID string, prompt string) error { ctx := c.Request.Context() // Determine the model to use testModelID := modelID if testModelID == "" { testModelID = geminicli.DefaultTestModel } // For static upstream credentials with model mapping, map the model if account.Type == AccountTypeAPIKey || account.Type == AccountTypeServiceAccount { 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(testModelID, prompt) // Build request based on account type var req *http.Request var err error switch account.Type { case AccountTypeAPIKey: req, err = s.buildGeminiAPIKeyRequest(ctx, account, testModelID, payload) case AccountTypeOAuth: req, err = s.buildGeminiOAuthRequest(ctx, account, testModelID, payload) case AccountTypeServiceAccount: req, err = s.buildGeminiServiceAccountRequest(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() } resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) 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) } // routeAntigravityTest 路由 Antigravity 账号的测试请求。 // APIKey 类型走原生协议(与 gateway_handler 路由一致),OAuth/Upstream 走 CRS 中转。 func (s *AccountTestService) routeAntigravityTest(c *gin.Context, account *Account, modelID string, prompt string) error { if account.Type == AccountTypeAPIKey { if strings.HasPrefix(modelID, "gemini-") { return s.testGeminiAccountConnection(c, account, modelID, prompt) } return s.testClaudeAccountConnection(c, account, modelID) } return s.testAntigravityAccountConnection(c, account, modelID) } // 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 } // buildGeminiAPIKeyRequest builds request for Gemini API Key accounts func (s *AccountTestService) buildGeminiAPIKeyRequest(ctx context.Context, account *Account, modelID string, payload []byte) (*http.Request, error) { apiKey := account.GetCredential("api_key") if strings.TrimSpace(apiKey) == "" { return nil, fmt.Errorf("no API key available") } baseURL := account.GetCredential("base_url") if baseURL == "" { baseURL = geminicli.AIStudioBaseURL } normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { return nil, err } // Use streamGenerateContent for real-time feedback fullURL, err := buildGeminiAIStudioModelActionURL(normalizedBaseURL, modelID, "streamGenerateContent", true) if err != nil { return nil, err } 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 func (s *AccountTestService) buildGeminiOAuthRequest(ctx context.Context, account *Account, modelID string, payload []byte) (*http.Request, error) { if s.geminiTokenProvider == nil { return nil, fmt.Errorf("gemini token provider not configured") } // Get access token (auto-refreshes if needed) accessToken, err := s.geminiTokenProvider.GetAccessToken(ctx, account) if err != nil { return nil, fmt.Errorf("failed to get access token: %w", err) } 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 } normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { return nil, err } fullURL, err := buildGeminiAIStudioModelActionURL(normalizedBaseURL, modelID, "streamGenerateContent", true) if err != nil { return nil, err } 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 } // Code Assist mode (with project_id) return s.buildCodeAssistRequest(ctx, accessToken, projectID, modelID, payload) } func (s *AccountTestService) buildGeminiServiceAccountRequest(ctx context.Context, account *Account, modelID string, payload []byte) (*http.Request, error) { if s.geminiTokenProvider == nil { return nil, fmt.Errorf("gemini token provider not configured") } accessToken, err := s.geminiTokenProvider.GetAccessToken(ctx, account) if err != nil { return nil, fmt.Errorf("failed to get service account access token: %w", err) } fullURL, err := buildVertexGeminiURL(account.VertexProjectID(), account.VertexLocation(modelID), modelID, "streamGenerateContent", true) if err != nil { return nil, err } 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 } // 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) { 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) normalizedBaseURL, err := s.validateUpstreamBaseURL(geminicli.GeminiCliBaseURL) if err != nil { return nil, err } fullURL := fmt.Sprintf("%s/v1internal:streamGenerateContent?alt=sse", normalizedBaseURL) 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. // 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 } payload := map[string]any{ "contents": []map[string]any{ { "role": "user", "parts": []map[string]any{ {"text": textPrompt}, }, }, }, "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 } // 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 } if candidates, ok := data["candidates"].([]any); ok && len(candidates) > 0 { if candidate, ok := candidates[0].(map[string]any); ok { // Extract content first (before checking completion) 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}) } 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, }) } } } } } } // 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 } } } // 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) } } } // 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, } // OAuth accounts using ChatGPT internal API require store: false if isOAuth { payload["store"] = false } // All accounts require instructions for Responses API payload["instructions"] = openai.DefaultInstructions return payload } func createOpenAIChatCompletionsTestPayload(modelID string, prompt string) map[string]any { testPrompt := strings.TrimSpace(prompt) if testPrompt == "" { testPrompt = "hi" } return map[string]any{ "model": modelID, "messages": []map[string]any{ { "role": "user", "content": testPrompt, }, }, "stream": true, } } // processClaudeStream processes the SSE stream from Claude API func (s *AccountTestService) processClaudeStream(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 == "" || !sseDataPrefix.MatchString(line) { continue } jsonStr := sseDataPrefix.ReplaceAllString(line, "") 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 "content_block_delta": if delta, ok := data["delta"].(map[string]any); ok { 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" if errData, ok := data["error"].(map[string]any); ok { if msg, ok := errData["message"].(string); ok { errorMsg = msg } } return s.sendErrorAndEnd(c, errorMsg) } } } // processOpenAIChatCompletionsStream processes SSE chunks from the // OpenAI-compatible Chat Completions API. func (s *AccountTestService) processOpenAIChatCompletionsStream(c *gin.Context, body io.Reader) error { reader := bufio.NewReader(body) seenJSON := false seenFinish := false for { line, err := reader.ReadString('\n') if err != nil { if err == io.EOF { if seenFinish { s.sendEvent(c, TestEvent{Type: "status", Text: "已通过 /v1/chat/completions 验证"}) s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) return nil } if seenJSON { return s.sendErrorAndEnd(c, "Chat Completions stream from /v1/chat/completions ended before [DONE]") } return s.sendErrorAndEnd(c, "Invalid Chat Completions response from /v1/chat/completions: expected SSE JSON data") } return s.sendErrorAndEnd(c, fmt.Sprintf("Chat Completions stream read error from /v1/chat/completions: %s", err.Error())) } line = strings.TrimSpace(line) if line == "" || !sseDataPrefix.MatchString(line) { continue } jsonStr := sseDataPrefix.ReplaceAllString(line, "") if jsonStr == "[DONE]" { s.sendEvent(c, TestEvent{Type: "status", Text: "已通过 /v1/chat/completions 验证"}) 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 { return s.sendErrorAndEnd(c, "Invalid Chat Completions response from /v1/chat/completions: expected JSON data") } seenJSON = true if errData, ok := data["error"].(map[string]any); ok { errorMsg := "Chat Completions API (/v1/chat/completions) returned an error" if msg, ok := errData["message"].(string); ok && msg != "" { errorMsg = msg } return s.sendErrorAndEnd(c, fmt.Sprintf("Chat Completions API (/v1/chat/completions) error: %s", errorMsg)) } choices, ok := data["choices"].([]any) if !ok { continue } for _, choiceValue := range choices { choice, ok := choiceValue.(map[string]any) if !ok { continue } if delta, ok := choice["delta"].(map[string]any); ok { if text, ok := delta["content"].(string); ok && text != "" { s.sendEvent(c, TestEvent{Type: "content", Text: text}) } } if message, ok := choice["message"].(map[string]any); ok { if text, ok := message["content"].(string); ok && text != "" { s.sendEvent(c, TestEvent{Type: "content", Text: text}) } } if finishReason, ok := choice["finish_reason"].(string); ok && finishReason != "" { seenFinish = true } } } } // processOpenAIStream processes the SSE stream from OpenAI Responses API func (s *AccountTestService) processOpenAIStream(c *gin.Context, body io.Reader) error { reader := bufio.NewReader(body) seenCompleted := false for { line, err := reader.ReadString('\n') if err != nil { if err == io.EOF { if seenCompleted { s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) return nil } return s.sendErrorAndEnd(c, "Stream ended before response.completed") } return s.sendErrorAndEnd(c, fmt.Sprintf("Stream read error: %s", err.Error())) } line = strings.TrimSpace(line) if line == "" || !sseDataPrefix.MatchString(line) { continue } jsonStr := sseDataPrefix.ReplaceAllString(line, "") if jsonStr == "[DONE]" { if seenCompleted { s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) return nil } return s.sendErrorAndEnd(c, "Stream ended before response.completed") } 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", "response.done": s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) return nil case "response.failed": errorMsg := "OpenAI response failed" if responseData, ok := data["response"].(map[string]any); ok { if errData, ok := responseData["error"].(map[string]any); ok { if msg, ok := errData["message"].(string); ok && msg != "" { errorMsg = msg } } } return s.sendErrorAndEnd(c, errorMsg) 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) } } } // testOpenAIImageAPIKey tests OpenAI image generation using an API Key account. func (s *AccountTestService) testOpenAIImageAPIKey(c *gin.Context, ctx context.Context, account *Account, modelID, prompt string) error { authToken := account.GetOpenAIApiKey() if authToken == "" { return s.sendErrorAndEnd(c, "No API key available") } baseURL := account.GetOpenAIBaseURL() if baseURL == "" { baseURL = "https://api.openai.com" } normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid base URL: %s", err.Error())) } apiURL := buildOpenAIImagesURL(normalizedBaseURL, openAIImagesGenerationsEndpoint) // 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() s.sendEvent(c, TestEvent{Type: "test_start", Model: modelID}) payload := map[string]any{ "model": modelID, "prompt": prompt, "n": 1, "response_format": "b64_json", } payloadBytes, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(payloadBytes)) if err != nil { return s.sendErrorAndEnd(c, "Failed to create request") } req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+authToken) // 账号级请求头覆写:测试请求与真实转发保持一致的最终头 account.ApplyHeaderOverrides(req.Header) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { proxyURL = account.Proxy.URL() } resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error())) } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to read response: %s", err.Error())) } if resp.StatusCode != http.StatusOK { return s.sendErrorAndEnd(c, fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body))) } // Parse {"data": [{"b64_json": "...", "revised_prompt": "..."}]} var result struct { Data []struct { B64JSON string `json:"b64_json"` RevisedPrompt string `json:"revised_prompt"` } `json:"data"` } if err := json.Unmarshal(body, &result); err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to parse response: %s", err.Error())) } if len(result.Data) == 0 { return s.sendErrorAndEnd(c, "No images returned from API") } for _, item := range result.Data { if item.RevisedPrompt != "" { s.sendEvent(c, TestEvent{Type: "content", Text: item.RevisedPrompt}) } if item.B64JSON != "" { s.sendEvent(c, TestEvent{ Type: "image", ImageURL: "data:image/png;base64," + item.B64JSON, MimeType: "image/png", }) } } s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) return nil } // testOpenAIImageOAuth tests OpenAI image generation using an OAuth account via Codex /responses API. func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Context, account *Account, modelID, prompt string) error { credentialAccount := account if account.IsShadow() { resolved, err := resolveCredentialAccount(ctx, s.accountRepo, account) if err != nil { return s.sendErrorAndEnd(c, "Failed to resolve account credentials") } credentialAccount = resolved } authToken := "" if !credentialAccount.IsOpenAIAgentIdentity() { authToken = credentialAccount.GetOpenAIAccessToken() } if authToken == "" && !credentialAccount.IsOpenAIAgentIdentity() { 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() s.sendEvent(c, TestEvent{Type: "test_start", Model: modelID}) s.sendEvent(c, TestEvent{Type: "content", Text: "Calling Codex /responses image tool...\n"}) parsed := &OpenAIImagesRequest{ Endpoint: openAIImagesGenerationsEndpoint, Model: strings.TrimSpace(modelID), Prompt: prompt, } applyOpenAIImagesDefaults(parsed) responsesBody, err := buildOpenAIImagesResponsesRequest(parsed, parsed.Model) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to build image request: %s", err.Error())) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, chatgptCodexAPIURL, bytes.NewReader(responsesBody)) if err != nil { return s.sendErrorAndEnd(c, "Failed to create request") } req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI)) req.Host = "chatgpt.com" if credentialAccount.IsOpenAIAgentIdentity() { authHeaders, authErr := buildAgentIdentityAuthenticationHeaders(ctx, s.accountRepo, s.agentIdentityWS, &s.agentIdentityTaskMu, credentialAccount) if authErr != nil { return s.sendErrorAndEnd(c, "Failed to build Agent Identity authentication") } for key, values := range authHeaders { for _, value := range values { req.Header.Add(key, value) } } } else { req.Header.Set("Authorization", "Bearer "+authToken) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "text/event-stream") req.Header.Set("OpenAI-Beta", "responses=experimental") canonical := resolveCodexOutboundIdentity("") req.Header.Set("originator", canonical.originator) if customUA := strings.TrimSpace(credentialAccount.GetOpenAIUserAgent()); customUA != "" { req.Header.Set("User-Agent", customUA) } else { req.Header.Set("User-Agent", canonical.userAgent) } setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) // 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入,否则测试用的身份 // 与该账号真实出站的身份不是同一个(issue #3901 的配对不变式由收口保证)。 enforceCodexIdentityHeadersWithUA(req.Header, credentialAccount.GetOpenAIUserAgent()) 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("Responses API request failed: %s", err.Error())) } defer func() { if resp != nil && resp.Body != nil { _ = resp.Body.Close() } }() if resp.StatusCode >= 400 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) body = redactAgentIdentitySensitiveBodyForAccount(ctx, s.accountRepo, credentialAccount, body) message := strings.TrimSpace(extractUpstreamErrorMessage(body)) if message == "" { message = fmt.Sprintf("Responses API returned %d", resp.StatusCode) } return s.sendErrorAndEnd(c, message) } body, err := io.ReadAll(resp.Body) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to read image response: %s", err.Error())) } body = redactAgentIdentitySensitiveBodyForAccount(ctx, s.accountRepo, credentialAccount, body) results, _, _, _, _, err := collectOpenAIImagesFromResponsesBody(body) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to parse image response: %s", err.Error())) } if len(results) == 0 { return s.sendErrorAndEnd(c, "No images returned from responses API") } for _, item := range results { if item.RevisedPrompt != "" { s.sendEvent(c, TestEvent{Type: "content", Text: item.RevisedPrompt}) } mimeType := openAIImageOutputMIMEType(item.OutputFormat) s.sendEvent(c, TestEvent{ Type: "image", ImageURL: "data:" + mimeType + ";base64," + item.Result, MimeType: mimeType, }) } s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) return nil } func (s *AccountTestService) sendEvent(c *gin.Context, event TestEvent) { if event.Type == "test_complete" { if suppress, ok := c.Get(accountTestSuppressCompletionContextKey); ok { if suppressCompletion, _ := suppress.(bool); suppressCompletion { return } } } eventJSON, _ := json.Marshal(event) if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", eventJSON); err != nil { log.Printf("failed to write SSE event: %v", err) return } 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}) return fmt.Errorf("%s", errorMsg) } // RunTestBackground executes an account test in-memory (no real HTTP client), // capturing SSE output via httptest.NewRecorder, then parses the result. func (s *AccountTestService) RunTestBackground(ctx context.Context, accountID int64, modelID string) (*ScheduledTestResult, error) { startedAt := time.Now() w := httptest.NewRecorder() ginCtx, _ := gin.CreateTestContext(w) ginCtx.Request = (&http.Request{}).WithContext(ctx) testErr := s.TestAccountConnection(ginCtx, accountID, modelID, "", AccountTestModeDefault) finishedAt := time.Now() body := w.Body.String() responseText, errMsg := parseTestSSEOutput(body) status := "success" if testErr != nil || errMsg != "" { status = "failed" if errMsg == "" && testErr != nil { errMsg = testErr.Error() } } return &ScheduledTestResult{ Status: status, ResponseText: responseText, ErrorMessage: errMsg, LatencyMs: finishedAt.Sub(startedAt).Milliseconds(), StartedAt: startedAt, FinishedAt: finishedAt, }, nil } // 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 }