Files
sub2api/backend/internal/service/account_test_service.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

3151 lines
111 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:<mime>;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:<mime>;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/<type>;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-5Gemini 使用 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
}