From 3b5d812f7adc329c75a17dfd23ec8fe385777e72 Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Wed, 1 Jul 2026 10:30:38 +0800 Subject: [PATCH 1/8] fix: route grok media endpoints --- backend/internal/handler/endpoint.go | 8 +- backend/internal/handler/endpoint_test.go | 4 + backend/internal/handler/grok_media.go | 366 ++++++++++++++++++ backend/internal/pkg/xai/oauth.go | 36 ++ backend/internal/pkg/xai/oauth_test.go | 21 + backend/internal/server/routes/gateway.go | 102 +++-- .../internal/server/routes/gateway_test.go | 56 +++ backend/internal/service/grok_media.go | 220 +++++++++++ .../service/openai_gateway_grok_test.go | 98 +++++ 9 files changed, 858 insertions(+), 53 deletions(-) create mode 100644 backend/internal/handler/grok_media.go create mode 100644 backend/internal/service/grok_media.go diff --git a/backend/internal/handler/endpoint.go b/backend/internal/handler/endpoint.go index e33d88241c..f8689a6e3a 100644 --- a/backend/internal/handler/endpoint.go +++ b/backend/internal/handler/endpoint.go @@ -21,6 +21,8 @@ const ( EndpointResponses = "/v1/responses" EndpointImagesGenerations = "/v1/images/generations" EndpointImagesEdits = "/v1/images/edits" + EndpointVideosGenerations = "/v1/videos/generations" + EndpointVideos = "/v1/videos" EndpointGeminiModels = "/v1beta/models" ) @@ -53,6 +55,10 @@ func NormalizeInboundEndpoint(path string) string { return EndpointImagesGenerations case strings.Contains(path, EndpointImagesEdits) || strings.Contains(path, "/images/edits"): return EndpointImagesEdits + case strings.Contains(path, EndpointVideosGenerations) || strings.Contains(path, "/videos/generations"): + return EndpointVideosGenerations + case strings.Contains(path, EndpointVideos) || strings.Contains(path, "/videos/"): + return EndpointVideos case strings.Contains(path, EndpointResponses): return EndpointResponses case strings.Contains(path, EndpointGeminiModels): @@ -78,7 +84,7 @@ func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string { switch platform { case service.PlatformOpenAI, service.PlatformGrok: - if inbound == EndpointEmbeddings || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits { + if inbound == EndpointEmbeddings || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits || inbound == EndpointVideosGenerations || inbound == EndpointVideos { return inbound } // OpenAI forwards everything to the Responses API. diff --git a/backend/internal/handler/endpoint_test.go b/backend/internal/handler/endpoint_test.go index 42b6d6e71b..55e3845ed4 100644 --- a/backend/internal/handler/endpoint_test.go +++ b/backend/internal/handler/endpoint_test.go @@ -28,6 +28,8 @@ func TestNormalizeInboundEndpoint(t *testing.T) { {"/v1/responses", EndpointResponses}, {"/v1/images/generations", EndpointImagesGenerations}, {"/v1/images/edits", EndpointImagesEdits}, + {"/v1/videos/generations", EndpointVideosGenerations}, + {"/v1/videos/req_123", EndpointVideos}, {"/v1beta/models", EndpointGeminiModels}, // Prefixed paths (antigravity, openai). @@ -81,6 +83,8 @@ func TestDeriveUpstreamEndpoint(t *testing.T) { {"openai embeddings", EndpointEmbeddings, "/v1/embeddings", service.PlatformOpenAI, EndpointEmbeddings}, {"openai image generations", EndpointImagesGenerations, "/v1/images/generations", service.PlatformOpenAI, EndpointImagesGenerations}, {"openai image edits", EndpointImagesEdits, "/openai/v1/images/edits", service.PlatformOpenAI, EndpointImagesEdits}, + {"grok video generations", EndpointVideosGenerations, "/v1/videos/generations", service.PlatformGrok, EndpointVideosGenerations}, + {"grok video status", EndpointVideos, "/videos/req_123", service.PlatformGrok, EndpointVideos}, // Antigravity — uses inbound to pick Claude vs Gemini upstream. {"antigravity claude", EndpointMessages, "/antigravity/v1/messages", service.PlatformAntigravity, EndpointMessages}, diff --git a/backend/internal/handler/grok_media.go b/backend/internal/handler/grok_media.go new file mode 100644 index 0000000000..6122e76812 --- /dev/null +++ b/backend/internal/handler/grok_media.go @@ -0,0 +1,366 @@ +package handler + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + "time" + + pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "go.uber.org/zap" +) + +// GrokImages handles xAI image generation/editing through Grok groups. +func (h *OpenAIGatewayHandler) GrokImages(c *gin.Context) { + endpoint := service.GrokMediaEndpointImagesGenerations + if strings.Contains(c.Request.URL.Path, "/images/edits") { + endpoint = service.GrokMediaEndpointImagesEdits + } + h.handleGrokMedia(c, endpoint, "") +} + +// GrokVideoGeneration handles xAI video generation through Grok groups. +func (h *OpenAIGatewayHandler) GrokVideoGeneration(c *gin.Context) { + h.handleGrokMedia(c, service.GrokMediaEndpointVideosGenerations, "") +} + +// GrokVideoStatus handles xAI video status retrieval through Grok groups. +func (h *OpenAIGatewayHandler) GrokVideoStatus(c *gin.Context) { + h.handleGrokMedia(c, service.GrokMediaEndpointVideoStatus, c.Param("request_id")) +} + +func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.GrokMediaEndpoint, requestID string) { + streamStarted := false + defer h.recoverResponsesPanic(c, &streamStarted) + + requestStart := time.Now() + apiKey, ok := middleware2.GetAPIKeyFromContext(c) + if !ok { + h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key") + return + } + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found") + return + } + + reqLog := requestLogger( + c, + "handler.openai_gateway.grok_media", + zap.Int64("user_id", subject.UserID), + zap.Int64("api_key_id", apiKey.ID), + zap.Any("group_id", apiKey.GroupID), + zap.String("endpoint", string(endpoint)), + ) + if !h.ensureResponsesDependencies(c, reqLog) { + return + } + + var body []byte + var err error + if endpoint.RequiresRequestBody() { + body, err = pkghttputil.ReadRequestBodyWithPrealloc(c.Request) + if err != nil { + if maxErr, ok := extractMaxBytesError(err); ok { + h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) + return + } + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") + return + } + if len(body) == 0 { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty") + return + } + } + + contentType := c.GetHeader("Content-Type") + requestModel := service.ExtractGrokMediaModel(contentType, body) + if endpoint.IsGenerationRequest() && strings.TrimSpace(requestModel) == "" { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required") + return + } + if endpoint == service.GrokMediaEndpointVideoStatus && strings.TrimSpace(requestID) == "" { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "request_id is required") + return + } + + reqLog = reqLog.With(zap.String("model", requestModel)) + setOpsRequestContext(c, requestModel, false) + setOpsEndpointContext(c, "", int16(service.RequestTypeSync)) + + if endpoint.IsGenerationRequest() { + if !service.GroupAllowsImageGeneration(apiKey.Group) { + h.errorResponse(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage()) + return + } + if moderationBody := grokMediaModerationBody(body); len(moderationBody) > 0 { + decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIImages, requestModel, moderationBody) + if decision != nil && decision.Blocked { + h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message) + return + } + } + imageReleaseFunc, acquired := h.acquireImageGenerationSlot(c, streamStarted) + if !acquired { + return + } + if imageReleaseFunc != nil { + defer imageReleaseFunc() + } + } + + if h.errorPassthroughService != nil { + service.BindErrorPassthroughService(c, h.errorPassthroughService) + } + + subscription, _ := middleware2.GetSubscriptionFromContext(c) + service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds()) + + userReleaseFunc, acquired := h.acquireResponsesUserSlot(c, subject.UserID, subject.Concurrency, false, &streamStarted, reqLog) + if !acquired { + return + } + if userReleaseFunc != nil { + defer userReleaseFunc() + } + + if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil { + reqLog.Info("grok_media.billing_eligibility_check_failed", zap.Error(err)) + status, code, message, retryAfter := billingErrorDetails(err) + if retryAfter > 0 { + c.Header("Retry-After", strconv.Itoa(retryAfter)) + } + h.errorResponse(c, status, code, message) + return + } + + sessionSeed := body + if len(sessionSeed) == 0 && strings.TrimSpace(requestID) != "" { + sessionSeed = []byte(requestID) + } + sessionHash := h.gatewayService.GenerateExplicitSessionHash(c, sessionSeed) + requestCtx := c.Request.Context() + failedAccountIDs := make(map[int64]struct{}) + sameAccountRetryCount := make(map[int64]int) + var lastFailoverErr *service.UpstreamFailoverError + switchCount := 0 + maxAccountSwitches := h.maxAccountSwitches + if maxAccountSwitches <= 0 { + maxAccountSwitches = 3 + } + routingStart := time.Now() + + for { + selection, scheduleDecision, err := h.gatewayService.SelectAccountWithSchedulerForCapability( + requestCtx, + apiKey.GroupID, + "", + sessionHash, + requestModel, + failedAccountIDs, + service.OpenAIUpstreamTransportHTTPSSE, + "", + false, + service.PlatformGrok, + ) + if err != nil { + reqLog.Warn("grok_media.account_select_failed", + zap.Error(err), + zap.Int("excluded_account_count", len(failedAccountIDs)), + ) + if len(failedAccountIDs) == 0 { + cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, requestModel, service.PlatformGrok) + if !cls.ModelNotFound { + markOpsRoutingCapacityLimitedIfNoAvailable(c, err) + } + h.errorResponse(c, cls.Status, cls.ErrType, cls.Message) + return + } + if lastFailoverErr != nil { + h.handleFailoverExhausted(c, lastFailoverErr, false) + } else { + h.errorResponse(c, http.StatusBadGateway, "api_error", "Upstream request failed") + } + return + } + if selection == nil || selection.Account == nil { + cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, requestModel, service.PlatformGrok) + if !cls.ModelNotFound { + markOpsRoutingCapacityLimited(c) + } + h.errorResponse(c, cls.Status, cls.ErrType, cls.Message) + return + } + + reqLog.Debug("grok_media.account_schedule_decision", + zap.String("layer", scheduleDecision.Layer), + zap.Bool("sticky_session_hit", scheduleDecision.StickySessionHit), + zap.Int("candidate_count", scheduleDecision.CandidateCount), + zap.Int("top_k", scheduleDecision.TopK), + zap.Int64("latency_ms", scheduleDecision.LatencyMs), + zap.Float64("load_skew", scheduleDecision.LoadSkew), + ) + + account := selection.Account + sessionHash = ensureOpenAIPoolModeSessionHash(sessionHash, account) + setOpsSelectedAccount(c, account.ID, account.Platform) + + accountReleaseFunc, accountAcquired := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, false, &streamStarted, reqLog) + if !accountAcquired { + return + } + + service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds()) + forwardStart := time.Now() + writerSizeBeforeForward := c.Writer.Size() + result, err := func() (*service.OpenAIForwardResult, error) { + defer func() { + if accountReleaseFunc != nil { + accountReleaseFunc() + } + }() + return h.gatewayService.ForwardGrokMedia(requestCtx, c, account, endpoint, requestID, body, contentType) + }() + + forwardDurationMs := time.Since(forwardStart).Milliseconds() + upstreamLatencyMs, _ := getContextInt64(c, service.OpsUpstreamLatencyMsKey) + responseLatencyMs := forwardDurationMs + if upstreamLatencyMs > 0 && forwardDurationMs > upstreamLatencyMs { + responseLatencyMs = forwardDurationMs - upstreamLatencyMs + } + service.SetOpsLatencyMs(c, service.OpsResponseLatencyMsKey, responseLatencyMs) + + if err != nil { + var failoverErr *service.UpstreamFailoverError + if errors.As(err, &failoverErr) { + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + if c.Writer.Size() != writerSizeBeforeForward { + h.handleFailoverExhausted(c, failoverErr, true) + return + } + if failoverErr.RetryableOnSameAccount { + retryLimit := account.GetPoolModeRetryCount() + if sameAccountRetryCount[account.ID] < retryLimit { + sameAccountRetryCount[account.ID]++ + reqLog.Warn("grok_media.pool_mode_same_account_retry", + zap.Int64("account_id", account.ID), + zap.Int("upstream_status", failoverErr.StatusCode), + zap.Int("retry_limit", retryLimit), + zap.Int("retry_count", sameAccountRetryCount[account.ID]), + ) + select { + case <-requestCtx.Done(): + return + case <-time.After(sameAccountRetryDelay): + } + continue + } + } + h.gatewayService.RecordOpenAIAccountSwitch() + failedAccountIDs[account.ID] = struct{}{} + lastFailoverErr = failoverErr + if switchCount >= maxAccountSwitches { + h.handleFailoverExhausted(c, failoverErr, false) + return + } + switchCount++ + reqLog.Warn("grok_media.upstream_failover_switching", + zap.Int64("account_id", account.ID), + zap.Int("upstream_status", failoverErr.StatusCode), + zap.Int("switch_count", switchCount), + zap.Int("max_switches", maxAccountSwitches), + ) + continue + } + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + if c.Writer.Size() == writerSizeBeforeForward { + h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed") + } + reqLog.Warn("grok_media.forward_failed", + zap.Int64("account_id", account.ID), + zap.Error(err), + ) + return + } + + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil) + recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, result, requestModel, body, requestID) + reqLog.Debug("grok_media.request_completed", + zap.Int64("account_id", account.ID), + zap.Int("switch_count", switchCount), + ) + return + } +} + +func grokMediaModerationBody(body []byte) []byte { + if gjson.ValidBytes(body) { + return body + } + return nil +} + +func recordGrokMediaUsage( + c *gin.Context, + h *OpenAIGatewayHandler, + reqLog *zap.Logger, + apiKey *service.APIKey, + subject middleware2.AuthSubject, + subscription *service.UserSubscription, + account *service.Account, + result *service.OpenAIForwardResult, + requestModel string, + body []byte, + requestID string, +) { + userAgent := c.GetHeader("User-Agent") + clientIP := ip.GetClientIP(c) + payloadForHash := body + if len(payloadForHash) == 0 && strings.TrimSpace(requestID) != "" { + payloadForHash = []byte(requestID) + } + inboundEndpoint := GetInboundEndpoint(c) + upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) + quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + channelUsageFields := service.ChannelUsageFields{ + OriginalModel: requestModel, + ChannelMappedModel: requestModel, + } + h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) { + if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{ + Result: result, + APIKey: apiKey, + User: apiKey.User, + Account: account, + Subscription: subscription, + InboundEndpoint: inboundEndpoint, + UpstreamEndpoint: upstreamEndpoint, + UserAgent: userAgent, + IPAddress: clientIP, + RequestPayloadHash: service.HashUsageRequestPayload(payloadForHash), + APIKeyService: h.apiKeyService, + QuotaPlatform: quotaPlatform, + ChannelUsageFields: channelUsageFields, + }); err != nil { + logger.L().With( + zap.String("component", "handler.openai_gateway.grok_media"), + zap.Int64("user_id", subject.UserID), + zap.Int64("api_key_id", apiKey.ID), + zap.Any("group_id", apiKey.GroupID), + zap.String("model", requestModel), + zap.Int64("account_id", account.ID), + ).Error("grok_media.record_usage_failed", zap.Error(err)) + reqLog.Debug("grok_media.record_usage_failed", zap.Error(err)) + } + }) +} diff --git a/backend/internal/pkg/xai/oauth.go b/backend/internal/pkg/xai/oauth.go index 449b5cb865..1b3aadc08b 100644 --- a/backend/internal/pkg/xai/oauth.go +++ b/backend/internal/pkg/xai/oauth.go @@ -437,6 +437,42 @@ func BuildChatCompletionsURL(baseURL string) (string, error) { return validatedBaseURL + "/chat/completions", nil } +func BuildImagesGenerationsURL(baseURL string) (string, error) { + validatedBaseURL, err := ValidatedBaseURL(baseURL) + if err != nil { + return "", fmt.Errorf("invalid base url: %w", err) + } + return validatedBaseURL + "/images/generations", nil +} + +func BuildImagesEditsURL(baseURL string) (string, error) { + validatedBaseURL, err := ValidatedBaseURL(baseURL) + if err != nil { + return "", fmt.Errorf("invalid base url: %w", err) + } + return validatedBaseURL + "/images/edits", nil +} + +func BuildVideosGenerationsURL(baseURL string) (string, error) { + validatedBaseURL, err := ValidatedBaseURL(baseURL) + if err != nil { + return "", fmt.Errorf("invalid base url: %w", err) + } + return validatedBaseURL + "/videos/generations", nil +} + +func BuildVideoURL(baseURL, requestID string) (string, error) { + validatedBaseURL, err := ValidatedBaseURL(baseURL) + if err != nil { + return "", fmt.Errorf("invalid base url: %w", err) + } + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return "", fmt.Errorf("request id is required") + } + return validatedBaseURL + "/videos/" + url.PathEscape(requestID), nil +} + // TokenResponse represents xAI OAuth token responses. type TokenResponse struct { AccessToken string `json:"access_token"` diff --git a/backend/internal/pkg/xai/oauth_test.go b/backend/internal/pkg/xai/oauth_test.go index fc48182fe7..c6d601ca5d 100644 --- a/backend/internal/pkg/xai/oauth_test.go +++ b/backend/internal/pkg/xai/oauth_test.go @@ -116,6 +116,27 @@ func TestValidateXAIURLsAllowOfficialOAuthAndGatewayHosts(t *testing.T) { require.Equal(t, DefaultCLIBaseURL+"/chat/completions", chatURL) } +func TestBuildGrokMediaURLs(t *testing.T) { + imagesURL, err := BuildImagesGenerationsURL(DefaultBaseURL + "/") + require.NoError(t, err) + require.Equal(t, DefaultBaseURL+"/images/generations", imagesURL) + + editsURL, err := BuildImagesEditsURL(DefaultBaseURL) + require.NoError(t, err) + require.Equal(t, DefaultBaseURL+"/images/edits", editsURL) + + videosURL, err := BuildVideosGenerationsURL(DefaultBaseURL) + require.NoError(t, err) + require.Equal(t, DefaultBaseURL+"/videos/generations", videosURL) + + videoURL, err := BuildVideoURL(DefaultBaseURL, "req 123") + require.NoError(t, err) + require.Equal(t, DefaultBaseURL+"/videos/req%20123", videoURL) + + _, err = BuildVideoURL(DefaultBaseURL, " ") + require.Error(t, err) +} + func TestValidateXAIURLsRejectArbitraryHostsByDefault(t *testing.T) { _, err := ValidateOAuthEndpointURL("https://auth.example.test/oauth2/token") require.Error(t, err) diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index 54fb5c4d68..9522578051 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -42,6 +42,48 @@ func RegisterGatewayRoutes( isOpenAIGatewayPlatform := func(c *gin.Context) bool { return getGroupPlatform(c) == service.PlatformOpenAI } + imagesHandler := func(c *gin.Context) { + switch getGroupPlatform(c) { + case service.PlatformOpenAI: + h.OpenAIGateway.Images(c) + case service.PlatformGrok: + h.OpenAIGateway.GrokImages(c) + default: + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) + c.JSON(http.StatusNotFound, gin.H{ + "error": gin.H{ + "type": "not_found_error", + "message": "Images API is not supported for this platform", + }, + }) + } + } + videoGenerationHandler := func(c *gin.Context) { + if getGroupPlatform(c) == service.PlatformGrok { + h.OpenAIGateway.GrokVideoGeneration(c) + return + } + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) + c.JSON(http.StatusNotFound, gin.H{ + "error": gin.H{ + "type": "not_found_error", + "message": "Videos API is not supported for this platform", + }, + }) + } + videoStatusHandler := func(c *gin.Context) { + if getGroupPlatform(c) == service.PlatformGrok { + h.OpenAIGateway.GrokVideoStatus(c) + return + } + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) + c.JSON(http.StatusNotFound, gin.H{ + "error": gin.H{ + "type": "not_found_error", + "message": "Videos API is not supported for this platform", + }, + }) + } // API网关(Claude API兼容) gateway := r.Group("/v1") gateway.Use(bodyLimit) @@ -120,32 +162,10 @@ func RegisterGatewayRoutes( } h.OpenAIGateway.Embeddings(c) }) - gateway.POST("/images/generations", func(c *gin.Context) { - if getGroupPlatform(c) != service.PlatformOpenAI { - service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) - c.JSON(http.StatusNotFound, gin.H{ - "error": gin.H{ - "type": "not_found_error", - "message": "Images API is not supported for this platform", - }, - }) - return - } - h.OpenAIGateway.Images(c) - }) - gateway.POST("/images/edits", func(c *gin.Context) { - if getGroupPlatform(c) != service.PlatformOpenAI { - service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) - c.JSON(http.StatusNotFound, gin.H{ - "error": gin.H{ - "type": "not_found_error", - "message": "Images API is not supported for this platform", - }, - }) - return - } - h.OpenAIGateway.Images(c) - }) + gateway.POST("/images/generations", imagesHandler) + gateway.POST("/images/edits", imagesHandler) + gateway.POST("/videos/generations", videoGenerationHandler) + gateway.GET("/videos/:request_id", videoStatusHandler) } // Gemini 原生 API 兼容层(Gemini SDK/CLI 直连) @@ -206,32 +226,10 @@ func RegisterGatewayRoutes( } h.OpenAIGateway.Embeddings(c) }) - r.POST("/images/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) { - if getGroupPlatform(c) != service.PlatformOpenAI { - service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) - c.JSON(http.StatusNotFound, gin.H{ - "error": gin.H{ - "type": "not_found_error", - "message": "Images API is not supported for this platform", - }, - }) - return - } - h.OpenAIGateway.Images(c) - }) - r.POST("/images/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) { - if getGroupPlatform(c) != service.PlatformOpenAI { - service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) - c.JSON(http.StatusNotFound, gin.H{ - "error": gin.H{ - "type": "not_found_error", - "message": "Images API is not supported for this platform", - }, - }) - return - } - h.OpenAIGateway.Images(c) - }) + r.POST("/images/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, imagesHandler) + r.POST("/images/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, imagesHandler) + r.POST("/videos/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, videoGenerationHandler) + r.GET("/videos/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, videoStatusHandler) // Antigravity 模型列表 r.GET("/antigravity/models", gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.Gateway.AntigravityModels) diff --git a/backend/internal/server/routes/gateway_test.go b/backend/internal/server/routes/gateway_test.go index 0d8b6bd67a..2779dd8f01 100644 --- a/backend/internal/server/routes/gateway_test.go +++ b/backend/internal/server/routes/gateway_test.go @@ -83,6 +83,62 @@ func TestGatewayRoutesOpenAIImagesPathsAreRegistered(t *testing.T) { } } +func TestGatewayRoutesGrokImagesAndVideosPathsAreRegistered(t *testing.T) { + router := newGatewayRoutesTestRouter(service.PlatformGrok) + + for _, path := range []string{ + "/v1/images/generations", + "/v1/images/edits", + "/images/generations", + "/images/edits", + "/v1/videos/generations", + "/videos/generations", + } { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"model":"grok-imagine","prompt":"draw a cat"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + require.NotEqual(t, http.StatusNotFound, w.Code, "path=%s should hit Grok media handler", path) + require.NotContains(t, w.Body.String(), "not supported for this platform") + } + + for _, path := range []string{ + "/v1/videos/request-123", + "/videos/request-123", + } { + req := httptest.NewRequest(http.MethodGet, path, nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + require.NotEqual(t, http.StatusNotFound, w.Code, "path=%s should hit Grok video handler", path) + require.NotContains(t, w.Body.String(), "not supported for this platform") + } +} + +func TestGatewayRoutesNonGrokVideosAreRejectedAtPlatformGate(t *testing.T) { + router := newGatewayRoutesTestRouter(service.PlatformOpenAI) + + for _, tc := range []struct { + method string + path string + body string + }{ + {http.MethodPost, "/v1/videos/generations", `{"model":"grok-imagine-video-1.5","prompt":"waves"}`}, + {http.MethodPost, "/videos/generations", `{"model":"grok-imagine-video-1.5","prompt":"waves"}`}, + {http.MethodGet, "/v1/videos/request-123", ""}, + {http.MethodGet, "/videos/request-123", ""}, + } { + req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + require.Equal(t, http.StatusNotFound, w.Code, "method=%s path=%s", tc.method, tc.path) + require.Contains(t, w.Body.String(), "Videos API is not supported for this platform") + } +} + func TestGatewayRoutesGrokAllowsCLICompatibilityEntrypoints(t *testing.T) { router := newGatewayRoutesTestRouter(service.PlatformGrok) diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go new file mode 100644 index 0000000000..9269cacf11 --- /dev/null +++ b/backend/internal/service/grok_media.go @@ -0,0 +1,220 @@ +package service + +import ( + "bytes" + "context" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" + "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" +) + +type GrokMediaEndpoint string + +const ( + GrokMediaEndpointImagesGenerations GrokMediaEndpoint = "images_generations" + GrokMediaEndpointImagesEdits GrokMediaEndpoint = "images_edits" + GrokMediaEndpointVideosGenerations GrokMediaEndpoint = "videos_generations" + GrokMediaEndpointVideoStatus GrokMediaEndpoint = "video_status" +) + +func (e GrokMediaEndpoint) RequiresRequestBody() bool { + return e != GrokMediaEndpointVideoStatus +} + +func (e GrokMediaEndpoint) IsGenerationRequest() bool { + switch e { + case GrokMediaEndpointImagesGenerations, GrokMediaEndpointImagesEdits, GrokMediaEndpointVideosGenerations: + return true + default: + return false + } +} + +func (e GrokMediaEndpoint) httpMethod() string { + if e == GrokMediaEndpointVideoStatus { + return http.MethodGet + } + return http.MethodPost +} + +func ExtractGrokMediaModel(contentType string, body []byte) string { + if model := strings.TrimSpace(gjson.GetBytes(body, "model").String()); model != "" { + return model + } + return extractGrokMediaMultipartModel(contentType, body) +} + +func extractGrokMediaMultipartModel(contentType string, body []byte) string { + mediaType, params, err := mime.ParseMediaType(strings.TrimSpace(contentType)) + if err != nil || !strings.EqualFold(mediaType, "multipart/form-data") { + return "" + } + boundary := strings.TrimSpace(params["boundary"]) + if boundary == "" { + return "" + } + reader := multipart.NewReader(bytes.NewReader(body), boundary) + for { + part, err := reader.NextPart() + if err == io.EOF { + return "" + } + if err != nil { + return "" + } + if part.FormName() != "model" || part.FileName() != "" { + continue + } + data, err := io.ReadAll(part) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) + } +} + +func (e GrokMediaEndpoint) upstreamURL(baseURL, requestID string) (string, error) { + switch e { + case GrokMediaEndpointImagesGenerations: + return xai.BuildImagesGenerationsURL(baseURL) + case GrokMediaEndpointImagesEdits: + return xai.BuildImagesEditsURL(baseURL) + case GrokMediaEndpointVideosGenerations: + return xai.BuildVideosGenerationsURL(baseURL) + case GrokMediaEndpointVideoStatus: + return xai.BuildVideoURL(baseURL, requestID) + default: + return "", fmt.Errorf("unsupported grok media endpoint: %s", e) + } +} + +func (s *OpenAIGatewayService) ForwardGrokMedia( + ctx context.Context, + c *gin.Context, + account *Account, + endpoint GrokMediaEndpoint, + requestID string, + body []byte, + contentType string, +) (*OpenAIForwardResult, error) { + startTime := time.Now() + if account == nil { + return nil, fmt.Errorf("grok account is required") + } + if account.Platform != PlatformGrok { + return nil, fmt.Errorf("account platform %s is not supported for grok media", account.Platform) + } + + token, _, err := s.GetAccessToken(ctx, account) + if err != nil { + return nil, err + } + targetURL, err := endpoint.upstreamURL(account.GetGrokBaseURL(), requestID) + if err != nil { + return nil, err + } + + var bodyReader io.Reader + if endpoint.RequiresRequestBody() { + bodyReader = bytes.NewReader(body) + } + upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) + defer releaseUpstreamCtx() + upstreamReq, err := http.NewRequestWithContext(upstreamCtx, endpoint.httpMethod(), targetURL, bodyReader) + if err != nil { + return nil, err + } + upstreamReq.Header.Set("Authorization", "Bearer "+token) + upstreamReq.Header.Set("Accept", "application/json") + upstreamReq.Header.Set("User-Agent", "sub2api-grok/1.0") + if endpoint.RequiresRequestBody() { + contentType = strings.TrimSpace(contentType) + if contentType == "" { + contentType = "application/json" + } + upstreamReq.Header.Set("Content-Type", contentType) + } + + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + upstreamStart := time.Now() + resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds()) + if err != nil { + return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) + } + defer func() { _ = resp.Body.Close() }() + + requestIDHeader := firstNonEmpty(resp.Header.Get("x-request-id"), resp.Header.Get("xai-request-id")) + requestModel := ExtractGrokMediaModel(contentType, body) + if resp.StatusCode >= 400 { + respBody := s.readUpstreamErrorBody(resp) + s.updateGrokUsageSnapshot(ctx, account.ID, xai.ParseQuotaHeaders(resp.Header, resp.StatusCode)) + upstreamMsg := sanitizeUpstreamErrorMessage(extractUpstreamErrorMessage(respBody)) + if upstreamMsg == "" { + upstreamMsg = fmt.Sprintf("xAI upstream returned status %d", resp.StatusCode) + } + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: requestIDHeader, + Kind: "failover", + Message: upstreamMsg, + }) + s.handleGrokAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + if s.shouldFailoverUpstreamError(resp.StatusCode) { + return nil, &UpstreamFailoverError{ + StatusCode: resp.StatusCode, + ResponseBody: respBody, + RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + } + } + writeGrokMediaResponse(c, resp, respBody, s.responseHeaderFilter) + return &OpenAIForwardResult{ + RequestID: requestIDHeader, + Model: requestModel, + UpstreamModel: requestModel, + ResponseHeaders: resp.Header.Clone(), + Duration: time.Since(startTime), + }, nil + } + + s.updateGrokUsageSnapshot(ctx, account.ID, xai.ParseQuotaHeaders(resp.Header, resp.StatusCode)) + respBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) + if err != nil { + return nil, err + } + writeGrokMediaResponse(c, resp, respBody, s.responseHeaderFilter) + return &OpenAIForwardResult{ + RequestID: requestIDHeader, + Model: requestModel, + UpstreamModel: requestModel, + ResponseHeaders: resp.Header.Clone(), + Duration: time.Since(startTime), + }, nil +} + +func writeGrokMediaResponse(c *gin.Context, resp *http.Response, body []byte, filter *responseheaders.CompiledHeaderFilter) { + if c == nil || resp == nil { + return + } + writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, filter) + contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) + if contentType == "" { + contentType = "application/json" + } + c.Data(resp.StatusCode, contentType, body) +} diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 4f6601e843..268d07f9ec 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "io" + "mime/multipart" "net/http" "net/http/httptest" "strings" @@ -144,6 +145,103 @@ func TestBuildGrokResponsesRequestRejectsUnsafeAccountBaseURL(t *testing.T) { require.Contains(t, err.Error(), "invalid base url") } +func TestExtractGrokMediaModelSupportsJSONAndMultipart(t *testing.T) { + require.Equal(t, "grok-imagine", ExtractGrokMediaModel("application/json", []byte(`{"model":"grok-imagine"}`))) + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + require.NoError(t, writer.WriteField("prompt", "draw a cat")) + require.NoError(t, writer.WriteField("model", "grok-imagine-edit")) + require.NoError(t, writer.Close()) + + require.Equal(t, "grok-imagine-edit", ExtractGrokMediaModel(writer.FormDataContentType(), buf.Bytes())) +} + +func TestForwardGrokMediaImagesGenerationPassthrough(t *testing.T) { + t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") + gin.SetMode(gin.TestMode) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + body := []byte(`{"model":"grok-imagine","prompt":"draw a cat"}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + account := &Account{ + ID: 61, + Name: "grok", + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "api-key", + "base_url": "https://xai.test/v1", + }, + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "Xai-Request-Id": []string{"xai-image-req"}, + }, + Body: io.NopCloser(strings.NewReader(`{"data":[]}`)), + }} + svc := &OpenAIGatewayService{httpUpstream: upstream} + + result, err := svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointImagesGenerations, "", body, "application/json") + require.NoError(t, err) + require.Equal(t, "https://xai.test/v1/images/generations", upstream.lastReq.URL.String()) + require.Equal(t, http.MethodPost, upstream.lastReq.Method) + require.Equal(t, "Bearer api-key", upstream.lastReq.Header.Get("Authorization")) + require.Equal(t, "application/json", upstream.lastReq.Header.Get("Content-Type")) + require.JSONEq(t, string(body), string(upstream.lastBody)) + require.Equal(t, http.StatusOK, recorder.Code) + require.JSONEq(t, `{"data":[]}`, recorder.Body.String()) + require.Equal(t, "xai-image-req", result.RequestID) + require.Equal(t, "grok-imagine", result.Model) +} + +func TestForwardGrokMediaVideoStatusUsesGETWithoutBody(t *testing.T) { + t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") + gin.SetMode(gin.TestMode) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/videos/request-123", nil) + + account := &Account{ + ID: 62, + Name: "grok", + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "api-key", + "base_url": "https://xai.test/v1", + }, + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "Xai-Request-Id": []string{"xai-video-req"}, + }, + Body: io.NopCloser(strings.NewReader(`{"id":"request-123","status":"completed"}`)), + }} + svc := &OpenAIGatewayService{httpUpstream: upstream} + + result, err := svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointVideoStatus, "request-123", nil, "") + require.NoError(t, err) + require.Equal(t, "https://xai.test/v1/videos/request-123", upstream.lastReq.URL.String()) + require.Equal(t, http.MethodGet, upstream.lastReq.Method) + require.Equal(t, "Bearer api-key", upstream.lastReq.Header.Get("Authorization")) + require.Empty(t, upstream.lastReq.Header.Get("Content-Type")) + require.Empty(t, upstream.lastBody) + require.Equal(t, http.StatusOK, recorder.Code) + require.JSONEq(t, `{"id":"request-123","status":"completed"}`, recorder.Body.String()) + require.Equal(t, "xai-video-req", result.RequestID) +} + func TestForwardAsChatCompletionsForGrokUsesXAIChatCompletionsAndSnapshots(t *testing.T) { gin.SetMode(gin.TestMode) From 2fe756e4be2954e8c8feb7cfd963c47087e6267e Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Wed, 1 Jul 2026 10:54:46 +0800 Subject: [PATCH 2/8] fix: recognize grok media models --- backend/internal/handler/grok_media.go | 8 ++- backend/internal/handler/grok_media_test.go | 54 +++++++++++++++++++++ backend/internal/pkg/xai/models.go | 3 ++ backend/internal/pkg/xai/oauth_test.go | 3 ++ 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 backend/internal/handler/grok_media_test.go diff --git a/backend/internal/handler/grok_media.go b/backend/internal/handler/grok_media.go index 6122e76812..96023f452e 100644 --- a/backend/internal/handler/grok_media.go +++ b/backend/internal/handler/grok_media.go @@ -294,7 +294,9 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. } h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil) - recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, result, requestModel, body, requestID) + if shouldRecordGrokMediaUsage(endpoint, requestModel) { + recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, result, requestModel, body, requestID) + } reqLog.Debug("grok_media.request_completed", zap.Int64("account_id", account.ID), zap.Int("switch_count", switchCount), @@ -310,6 +312,10 @@ func grokMediaModerationBody(body []byte) []byte { return nil } +func shouldRecordGrokMediaUsage(endpoint service.GrokMediaEndpoint, requestModel string) bool { + return endpoint.IsGenerationRequest() && strings.TrimSpace(requestModel) != "" +} + func recordGrokMediaUsage( c *gin.Context, h *OpenAIGatewayHandler, diff --git a/backend/internal/handler/grok_media_test.go b/backend/internal/handler/grok_media_test.go new file mode 100644 index 0000000000..1b82f8fa6f --- /dev/null +++ b/backend/internal/handler/grok_media_test.go @@ -0,0 +1,54 @@ +package handler + +import ( + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestShouldRecordGrokMediaUsage(t *testing.T) { + tests := []struct { + name string + endpoint service.GrokMediaEndpoint + model string + want bool + }{ + { + name: "image generation records usage", + endpoint: service.GrokMediaEndpointImagesGenerations, + model: "grok-imagine", + want: true, + }, + { + name: "image edit records usage", + endpoint: service.GrokMediaEndpointImagesEdits, + model: "grok-imagine-edit", + want: true, + }, + { + name: "video generation records usage", + endpoint: service.GrokMediaEndpointVideosGenerations, + model: "grok-imagine-video-1.5", + want: true, + }, + { + name: "video status skips empty model usage", + endpoint: service.GrokMediaEndpointVideoStatus, + model: "", + want: false, + }, + { + name: "generation skips usage without model", + endpoint: service.GrokMediaEndpointImagesGenerations, + model: " ", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, shouldRecordGrokMediaUsage(tt.endpoint, tt.model)) + }) + } +} diff --git a/backend/internal/pkg/xai/models.go b/backend/internal/pkg/xai/models.go index 0d289274fb..74c760136d 100644 --- a/backend/internal/pkg/xai/models.go +++ b/backend/internal/pkg/xai/models.go @@ -15,6 +15,9 @@ var defaultModels = []Model{ {ID: "grok-4.20-0309-reasoning", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Reasoning"}, {ID: "grok-4.20-0309-non-reasoning", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Non Reasoning"}, {ID: "grok-4.20-multi-agent-0309", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Multi Agent"}, + {ID: "grok-imagine", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine"}, + {ID: "grok-imagine-edit", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Edit"}, + {ID: "grok-imagine-video-1.5", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video 1.5"}, } func DefaultModels() []Model { diff --git a/backend/internal/pkg/xai/oauth_test.go b/backend/internal/pkg/xai/oauth_test.go index c6d601ca5d..5a728fb3ab 100644 --- a/backend/internal/pkg/xai/oauth_test.go +++ b/backend/internal/pkg/xai/oauth_test.go @@ -213,4 +213,7 @@ func TestDefaultModelMappingIncludesGrokAliases(t *testing.T) { require.Equal(t, "grok-4.20-0309-reasoning", mapping["grok-4.20-reasoning"]) require.Equal(t, "grok-4.20-0309-non-reasoning", mapping["grok-4.20-non-reasoning"]) require.Equal(t, "grok-4.20-multi-agent-0309", mapping["grok-4.20-multi-agent-0309"]) + require.Equal(t, "grok-imagine", mapping["grok-imagine"]) + require.Equal(t, "grok-imagine-edit", mapping["grok-imagine-edit"]) + require.Equal(t, "grok-imagine-video-1.5", mapping["grok-imagine-video-1.5"]) } From c3e860607d56c5e830165f4990153871ab41b8a1 Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Wed, 1 Jul 2026 11:35:34 +0800 Subject: [PATCH 3/8] fix: include official grok media model ids --- backend/internal/pkg/xai/models.go | 3 +++ backend/internal/pkg/xai/oauth_test.go | 3 +++ 2 files changed, 6 insertions(+) diff --git a/backend/internal/pkg/xai/models.go b/backend/internal/pkg/xai/models.go index 74c760136d..4902fcb94f 100644 --- a/backend/internal/pkg/xai/models.go +++ b/backend/internal/pkg/xai/models.go @@ -16,7 +16,10 @@ var defaultModels = []Model{ {ID: "grok-4.20-0309-non-reasoning", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Non Reasoning"}, {ID: "grok-4.20-multi-agent-0309", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Multi Agent"}, {ID: "grok-imagine", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine"}, + {ID: "grok-imagine-image", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Image"}, + {ID: "grok-imagine-image-quality", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Image Quality"}, {ID: "grok-imagine-edit", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Edit"}, + {ID: "grok-imagine-video", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video"}, {ID: "grok-imagine-video-1.5", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video 1.5"}, } diff --git a/backend/internal/pkg/xai/oauth_test.go b/backend/internal/pkg/xai/oauth_test.go index 5a728fb3ab..68a4fea240 100644 --- a/backend/internal/pkg/xai/oauth_test.go +++ b/backend/internal/pkg/xai/oauth_test.go @@ -214,6 +214,9 @@ func TestDefaultModelMappingIncludesGrokAliases(t *testing.T) { require.Equal(t, "grok-4.20-0309-non-reasoning", mapping["grok-4.20-non-reasoning"]) require.Equal(t, "grok-4.20-multi-agent-0309", mapping["grok-4.20-multi-agent-0309"]) require.Equal(t, "grok-imagine", mapping["grok-imagine"]) + require.Equal(t, "grok-imagine-image", mapping["grok-imagine-image"]) + require.Equal(t, "grok-imagine-image-quality", mapping["grok-imagine-image-quality"]) require.Equal(t, "grok-imagine-edit", mapping["grok-imagine-edit"]) + require.Equal(t, "grok-imagine-video", mapping["grok-imagine-video"]) require.Equal(t, "grok-imagine-video-1.5", mapping["grok-imagine-video-1.5"]) } From a34d4967e6ef616adfc38848fb845f48a87118c4 Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Wed, 1 Jul 2026 15:12:48 +0800 Subject: [PATCH 4/8] feat: add LLM media tester --- backend/internal/server/router.go | 1 + backend/internal/server/routes/llm_tester.go | 310 +++++ frontend/src/api/__tests__/llmTester.spec.ts | 178 +++ frontend/src/api/llmTester.ts | 932 +++++++++++++ frontend/src/components/layout/AppSidebar.vue | 17 + frontend/src/composables/useModelWhitelist.ts | 16 +- frontend/src/i18n/locales/en.ts | 74 ++ frontend/src/i18n/locales/zh.ts | 74 ++ frontend/src/router/index.ts | 14 +- frontend/src/views/user/LLMTesterView.vue | 1157 +++++++++++++++++ frontend/vite.config.ts | 204 ++- 11 files changed, 2973 insertions(+), 4 deletions(-) create mode 100644 backend/internal/server/routes/llm_tester.go create mode 100644 frontend/src/api/__tests__/llmTester.spec.ts create mode 100644 frontend/src/api/llmTester.ts create mode 100644 frontend/src/views/user/LLMTesterView.vue diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 3d86373779..35f2c3949e 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -107,6 +107,7 @@ func registerRoutes( v1 := r.Group("/api/v1") // 注册各模块路由 + routes.RegisterLLMTesterRoutes(v1) routes.RegisterAuthRoutes(v1, h, jwtAuth, redisClient, settingService) routes.RegisterUserRoutes(v1, h, jwtAuth, settingService) routes.RegisterAdminRoutes(v1, h, adminAuth, settingService) diff --git a/backend/internal/server/routes/llm_tester.go b/backend/internal/server/routes/llm_tester.go new file mode 100644 index 0000000000..684b86d729 --- /dev/null +++ b/backend/internal/server/routes/llm_tester.go @@ -0,0 +1,310 @@ +package routes + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" + "github.com/gin-gonic/gin" +) + +const ( + llmTesterMaxRequestBytes = 12 << 20 + llmTesterMaxResponseBytes = 12 << 20 +) + +var ( + llmTesterVersionPathPattern = regexp.MustCompile(`/v\d+$`) + llmTesterHTTPClient = &http.Client{ + Timeout: 300 * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: llmTesterSafeDialContext, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 240 * time.Second, + IdleConnTimeout: 30 * time.Second, + }, + } + llmTesterDialer = &net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + } + llmTesterBlockedCIDRs = mustParseLLMTesterCIDRs([]string{ + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.168.0.0/16", + "::/128", + "::1/128", + "fc00::/7", + "fe80::/10", + }) +) + +type llmTesterProxyRequest struct { + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +func RegisterLLMTesterRoutes(v1 *gin.RouterGroup) { + tester := v1.Group("/llm-tester") + { + tester.POST("/models", llmTesterProxyModels) + tester.POST("/chat/completions", llmTesterProxyChatCompletions) + tester.POST("/images/generations", llmTesterProxyImageGenerations) + tester.POST("/videos/generations", llmTesterProxyVideoGenerations) + tester.POST("/responses", llmTesterProxyResponses) + } +} + +func llmTesterProxyModels(c *gin.Context) { + var req llmTesterProxyRequest + if !bindLLMTesterProxyRequest(c, &req) { + return + } + forwardLLMTesterRequest(c, req, http.MethodGet, "models", nil) +} + +func llmTesterProxyChatCompletions(c *gin.Context) { + var req llmTesterProxyRequest + if !bindLLMTesterProxyRequest(c, &req) { + return + } + if len(bytes.TrimSpace(req.Payload)) == 0 { + response.BadRequest(c, "payload is required") + return + } + forwardLLMTesterRequest(c, req, http.MethodPost, "chat/completions", bytes.NewReader(req.Payload)) +} + +func llmTesterProxyImageGenerations(c *gin.Context) { + var req llmTesterProxyRequest + if !bindLLMTesterProxyRequest(c, &req) { + return + } + if len(bytes.TrimSpace(req.Payload)) == 0 { + response.BadRequest(c, "payload is required") + return + } + forwardLLMTesterRequest(c, req, http.MethodPost, "images/generations", bytes.NewReader(req.Payload)) +} + +func llmTesterProxyVideoGenerations(c *gin.Context) { + var req llmTesterProxyRequest + if !bindLLMTesterProxyRequest(c, &req) { + return + } + if len(bytes.TrimSpace(req.Payload)) == 0 { + response.BadRequest(c, "payload is required") + return + } + forwardLLMTesterRequest(c, req, http.MethodPost, "videos/generations", bytes.NewReader(req.Payload)) +} + +func llmTesterProxyResponses(c *gin.Context) { + var req llmTesterProxyRequest + if !bindLLMTesterProxyRequest(c, &req) { + return + } + if len(bytes.TrimSpace(req.Payload)) == 0 { + response.BadRequest(c, "payload is required") + return + } + forwardLLMTesterRequest(c, req, http.MethodPost, "responses", bytes.NewReader(req.Payload)) +} + +func bindLLMTesterProxyRequest(c *gin.Context, req *llmTesterProxyRequest) bool { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, llmTesterMaxRequestBytes) + if err := json.NewDecoder(c.Request.Body).Decode(req); err != nil { + response.BadRequest(c, "invalid request body") + return false + } + if strings.TrimSpace(req.BaseURL) == "" { + response.BadRequest(c, "base_url is required") + return false + } + if strings.TrimSpace(req.APIKey) == "" { + response.BadRequest(c, "api_key is required") + return false + } + if len(req.APIKey) > 8192 { + response.BadRequest(c, "api_key is too long") + return false + } + return true +} + +func forwardLLMTesterRequest(c *gin.Context, req llmTesterProxyRequest, method, resource string, body io.Reader) { + endpoint, err := buildLLMTesterEndpoint(req.BaseURL, resource) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + + upstreamReq, err := http.NewRequestWithContext(c.Request.Context(), method, endpoint, body) + if err != nil { + response.BadRequest(c, "invalid upstream request") + return + } + upstreamReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(req.APIKey)) + upstreamReq.Header.Set("Accept", "application/json") + upstreamReq.Header.Set("User-Agent", "Sub2API-LLM-Tester/1.0") + upstreamReq.Header.Set("X-Title", "Sub2API LLM Tester") + if method == http.MethodPost { + upstreamReq.Header.Set("Content-Type", "application/json") + } + if origin := c.GetHeader("Origin"); origin != "" { + upstreamReq.Header.Set("HTTP-Referer", origin) + } + + upstreamResp, err := llmTesterHTTPClient.Do(upstreamReq) + if err != nil { + response.Error(c, http.StatusBadGateway, fmt.Sprintf("upstream request failed: %s", err.Error())) + return + } + defer upstreamResp.Body.Close() + + payload, err := readLLMTesterResponseBody(upstreamResp.Body) + if err != nil { + response.Error(c, http.StatusBadGateway, err.Error()) + return + } + + contentType := upstreamResp.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/json" + } + c.Data(upstreamResp.StatusCode, contentType, payload) +} + +func buildLLMTesterEndpoint(baseURL, resource string) (string, error) { + normalized, err := urlvalidator.ValidateHTTPSURL(baseURL, urlvalidator.ValidationOptions{}) + if err != nil { + return "", err + } + parsed, err := url.Parse(normalized) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "", errors.New("invalid base_url") + } + if parsed.User != nil { + return "", errors.New("base_url must not include user info") + } + if err := urlvalidator.ValidateResolvedIP(parsed.Hostname()); err != nil { + return "", err + } + parsed.RawQuery = "" + parsed.Fragment = "" + parsed.Path = strings.TrimRight(parsed.Path, "/") + if !llmTesterVersionPathPattern.MatchString(parsed.Path) { + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/v1" + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/" + strings.TrimLeft(resource, "/") + return parsed.String(), nil +} + +func readLLMTesterResponseBody(body io.Reader) ([]byte, error) { + limited := io.LimitReader(body, llmTesterMaxResponseBytes+1) + payload, err := io.ReadAll(limited) + if err != nil { + return nil, fmt.Errorf("failed to read upstream response: %w", err) + } + if len(payload) > llmTesterMaxResponseBytes { + return nil, errors.New("upstream response is too large") + } + return payload, nil +} + +func llmTesterSafeDialContext(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + if llmTesterBlockedHost(host) { + return nil, &net.AddrError{Err: "blocked by SSRF policy", Addr: address} + } + if ip := net.ParseIP(host); ip != nil { + if llmTesterBlockedIP(ip) { + return nil, &net.AddrError{Err: "blocked by SSRF policy", Addr: address} + } + return llmTesterDialer.DialContext(ctx, network, address) + } + + addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + if len(addrs) == 0 { + return nil, &net.AddrError{Err: "no addresses for host", Addr: host} + } + + var lastErr error + for _, addr := range addrs { + if llmTesterBlockedIP(addr.IP) { + lastErr = &net.AddrError{Err: "blocked by SSRF policy", Addr: addr.IP.String()} + continue + } + conn, err := llmTesterDialer.DialContext(ctx, network, net.JoinHostPort(addr.IP.String(), port)) + if err == nil { + return conn, nil + } + lastErr = err + } + if lastErr == nil { + lastErr = &net.AddrError{Err: "no usable addresses", Addr: host} + } + return nil, lastErr +} + +func llmTesterBlockedHost(host string) bool { + host = strings.ToLower(strings.TrimSpace(host)) + return host == "" || + host == "localhost" || + strings.HasSuffix(host, ".localhost") || + host == "metadata" || + host == "metadata.google.internal" || + host == "metadata.goog" || + host == "instance-data" || + host == "instance-data.ec2.internal" +} + +func llmTesterBlockedIP(ip net.IP) bool { + if ip == nil { + return true + } + if ip.IsUnspecified() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() || ip.IsPrivate() { + return true + } + for _, cidr := range llmTesterBlockedCIDRs { + if cidr.Contains(ip) { + return true + } + } + return false +} + +func mustParseLLMTesterCIDRs(raw []string) []*net.IPNet { + out := make([]*net.IPNet, 0, len(raw)) + for _, value := range raw { + _, cidr, err := net.ParseCIDR(value) + if err != nil { + panic("llm_tester: invalid blocked CIDR " + value + ": " + err.Error()) + } + out = append(out, cidr) + } + return out +} diff --git a/frontend/src/api/__tests__/llmTester.spec.ts b/frontend/src/api/__tests__/llmTester.spec.ts new file mode 100644 index 0000000000..e076f3ff0b --- /dev/null +++ b/frontend/src/api/__tests__/llmTester.spec.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest' +import { + extractImageGenerationResult, + extractVideoGenerationResult, + getLLMTesterModelCapabilities, + isLikelyChatCompletionModelId, + parseModelList, +} from '@/api/llmTester' + +describe('LLM tester model filtering', () => { + it('keeps text chat and vision chat models from provider metadata', () => { + const models = parseModelList({ + data: [ + { + id: 'openai/gpt-4o', + name: 'GPT-4o', + architecture: { + modality: 'text+image->text', + input_modalities: ['text', 'image'], + output_modalities: ['text'], + }, + }, + { + id: 'anthropic/claude-sonnet', + architecture: { + modality: 'text->text', + output_modalities: ['text'], + }, + }, + ], + }) + + expect(models.map((model) => model.id)).toEqual([ + 'anthropic/claude-sonnet', + 'openai/gpt-4o', + ]) + }) + + it('keeps image-generation models while removing unsupported utility models', () => { + const models = parseModelList({ + data: [ + { + id: 'gpt-image-2', + architecture: { + modality: 'text+image->image', + output_modalities: ['image'], + }, + }, + { + id: 'text-embedding-3-small', + architecture: { + modality: 'text->embedding', + }, + }, + { + id: 'grok', + architecture: { + modality: 'text->text', + output_modalities: ['text'], + }, + }, + ], + }) + + expect(models.map((model) => model.id)).toEqual(['gpt-image-2', 'grok']) + expect(getLLMTesterModelCapabilities(models[0])).toContain('image_generation') + }) + + it('keeps Grok media models and classifies them by route capability', () => { + const models = parseModelList({ + data: [ + { id: 'grok-imagine', owned_by: 'xai' }, + { id: 'grok-imagine-image', owned_by: 'xai' }, + { id: 'grok-imagine-image-quality', owned_by: 'xai' }, + { id: 'grok-imagine-edit', owned_by: 'xai' }, + { id: 'grok-imagine-video', owned_by: 'xai' }, + { id: 'grok-imagine-video-1.5', owned_by: 'xai' }, + ], + }) + + expect(models.map((model) => model.id)).toEqual([ + 'grok-imagine', + 'grok-imagine-edit', + 'grok-imagine-image', + 'grok-imagine-image-quality', + 'grok-imagine-video', + 'grok-imagine-video-1.5', + ]) + expect(getLLMTesterModelCapabilities(models[0])).toEqual(['image_generation']) + expect(getLLMTesterModelCapabilities(models[4])).toEqual(['video_generation']) + }) + + it('uses id heuristics when simple OpenAI-compatible model rows omit metadata', () => { + expect(isLikelyChatCompletionModelId('gpt-5.4')).toBe(true) + expect(isLikelyChatCompletionModelId('gpt-image-2')).toBe(false) + expect(isLikelyChatCompletionModelId('grok-imagine-video-1.5')).toBe(false) + expect(isLikelyChatCompletionModelId('text-embedding-3-small')).toBe(false) + }) + + it('converts image generation responses into assistant attachments', () => { + const result = extractImageGenerationResult({ + data: [ + { + b64_json: 'abc123', + revised_prompt: 'A bright test image', + }, + ], + }) + + expect(result.text).toContain('Generated 1 image') + expect(result.text).toContain('A bright test image') + expect(result.attachments).toHaveLength(1) + expect(result.attachments[0].dataUrl).toBe('data:image/png;base64,abc123') + }) + + it('converts Responses image_generation_call results into assistant attachments', () => { + const result = extractImageGenerationResult({ + output: [ + { + type: 'image_generation_call', + result: 'a'.repeat(120), + }, + ], + }) + + expect(result.text).toContain('Generated 1 image') + expect(result.attachments).toHaveLength(1) + expect(result.attachments[0].dataUrl).toBe(`data:image/png;base64,${'a'.repeat(120)}`) + }) + + it('keeps generated image URLs from provider responses', () => { + const result = extractImageGenerationResult({ + output: [ + { + type: 'image_generation_call', + image_url: 'https://example.com/generated.png', + }, + ], + }) + + expect(result.attachments).toHaveLength(1) + expect(result.attachments[0].dataUrl).toBe('https://example.com/generated.png') + }) + + it('converts Responses SSE image output events into assistant attachments', () => { + const result = extractImageGenerationResult([ + 'data: {"type":"response.output_item.done","item":{"id":"ig_123","type":"image_generation_call","result":"aGVsbG8=","revised_prompt":"draw a cat","output_format":"png"}}', + '', + 'data: {"type":"response.completed","response":{"output":[]}}', + '', + 'data: [DONE]', + '', + ].join('\n')) + + expect(result.text).toContain('Generated 1 image') + expect(result.text).toContain('draw a cat') + expect(result.attachments).toHaveLength(1) + expect(result.attachments[0].dataUrl).toBe('data:image/png;base64,aGVsbG8=') + }) + + it('converts video generation responses into media attachments', () => { + const result = extractVideoGenerationResult({ + id: 'video_req_123', + status: 'completed', + data: [ + { + url: 'https://example.com/generated.mp4', + }, + ], + }) + + expect(result.text).toContain('Generated 1 video') + expect(result.text).toContain('Request ID: video_req_123') + expect(result.attachments).toHaveLength(1) + expect(result.attachments[0].kind).toBe('media') + expect(result.attachments[0].dataUrl).toBe('https://example.com/generated.mp4') + }) +}) diff --git a/frontend/src/api/llmTester.ts b/frontend/src/api/llmTester.ts new file mode 100644 index 0000000000..960d2624ad --- /dev/null +++ b/frontend/src/api/llmTester.ts @@ -0,0 +1,932 @@ +import { buildApiUrl } from '@/api/client' + +export interface LLMTesterProfile { + id: string + name: string + provider: 'openrouter' | 'sub2api' | 'custom' + baseUrl: string + apiKey: string + selectedModel: string + lastFetchedAt?: string +} + +export interface LLMTesterModel { + id: string + name: string + ownedBy?: string + contextLength?: number + raw?: Record +} + +export type LLMTesterModelCapability = 'chat' | 'vision' | 'image_generation' | 'video_generation' + +export interface LLMTesterAttachment { + id: string + name: string + type: string + size: number + kind: 'image' | 'text' | 'media' | 'file' + dataUrl?: string + text?: string +} + +export interface LLMTesterMessage { + id: string + role: 'user' | 'assistant' + content: string + attachments?: LLMTesterAttachment[] +} + +export interface ChatCompletionOptions { + baseUrl: string + apiKey: string + model: string + messages: LLMTesterMessage[] + systemInstruction?: string + temperature?: number + maxTokens?: number + signal?: AbortSignal +} + +export interface ImageGenerationOptions { + baseUrl: string + apiKey: string + model: string + messages: LLMTesterMessage[] + systemInstruction?: string + signal?: AbortSignal +} + +export interface ImageGenerationResult { + text: string + attachments: LLMTesterAttachment[] + raw: unknown +} + +export type MediaGenerationResult = ImageGenerationResult + +interface OpenAIContentTextPart { + type: 'text' + text: string +} + +interface OpenAIContentImagePart { + type: 'image_url' + image_url: { + url: string + } +} + +type OpenAIMessageContent = string | Array + +interface OpenAIChatMessage { + role: 'system' | 'user' | 'assistant' + content: OpenAIMessageContent +} + +export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1' + +export function defaultSub2APIBaseUrl(): string { + return '/v1' +} + +export function normalizeBaseUrl(input: string): string { + const trimmed = input.trim().replace(/\/+$/, '') + if (!trimmed) return '' + if (/^https?:\/\//i.test(trimmed) || trimmed.startsWith('/')) return trimmed + return `https://${trimmed}` +} + +export type LLMTesterProxyPath = 'models' | 'chat/completions' | 'images/generations' | 'videos/generations' | 'responses' + +export function buildOpenAIEndpoint(baseUrl: string, path: LLMTesterProxyPath): string { + const normalized = normalizeBaseUrl(baseUrl) + if (!normalized) return '' + const resource = path.replace(/^v\d+\//, '') + if (/\/v\d+$/i.test(normalized)) return `${normalized}/${resource}` + return `${normalized}/v1/${resource}` +} + +function getHeaderSafeSiteTitle(): string { + if (typeof document === 'undefined') return 'Sub2API LLM Tester' + return document.title || 'Sub2API LLM Tester' +} + +function buildHeaders(apiKey: string): HeadersInit { + return { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'X-Title': getHeaderSafeSiteTitle(), + } +} + +function buildJsonHeaders(): HeadersInit { + return { + 'Content-Type': 'application/json', + } +} + +function getObject(value: unknown): Record | undefined { + return value && typeof value === 'object' ? value as Record : undefined +} + +function getString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined +} + +function getNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function getStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value + .map((item) => typeof item === 'string' ? item.trim().toLowerCase() : '') + .filter(Boolean) +} + +export function isLikelyChatCompletionModelId(modelId: string): boolean { + const id = modelId.trim().toLowerCase() + if (!id) return false + if (/(^|[/:-])(?:text-)?embedding/.test(id) || id.includes('embedding')) return false + if (/(^|[/:-])(?:gpt-)?image(?:-|$)/.test(id) || id.includes('/image-')) return false + if (isLikelyImageGenerationModelId(id) || isLikelyVideoGenerationModelId(id)) return false + if (id.includes('dall-e') || id.includes('whisper') || id.includes('tts')) return false + if (id.includes('moderation') || id.includes('omni-moderation')) return false + if (id.includes('transcribe') || id.includes('realtime')) return false + return true +} + +const GROK_IMAGE_MODEL_IDS = new Set([ + 'grok-imagine', + 'grok-imagine-image', + 'grok-imagine-image-quality', + 'grok-imagine-edit', +]) + +const GROK_VIDEO_MODEL_IDS = new Set([ + 'grok-imagine-video', + 'grok-imagine-video-1.5', +]) + +export function isLikelyImageGenerationModelId(modelId: string): boolean { + const id = modelId.trim().toLowerCase() + if (!id) return false + return ( + GROK_IMAGE_MODEL_IDS.has(id) || + /(^|[/:-])(?:gpt-)?image(?:-|$)/.test(id) || + id.includes('/image-') || + id.includes('dall-e') || + id.includes('imagen') + ) +} + +export function isLikelyVideoGenerationModelId(modelId: string): boolean { + const id = modelId.trim().toLowerCase() + if (!id) return false + return GROK_VIDEO_MODEL_IDS.has(id) || id.includes('video-generation') || /(^|[/:-])video(?:-|$)/.test(id) +} + +function splitModalities(value: string): string[] { + return value + .split(/[+,]/) + .map((part) => part.trim().toLowerCase()) + .filter(Boolean) +} + +function getModelModalities(model: LLMTesterModel): { input: string[]; output: string[] } { + const architecture = getObject(model.raw?.architecture) + const input = new Set(getStringArray(architecture?.input_modalities)) + const output = new Set(getStringArray(architecture?.output_modalities)) + + const modality = getString(architecture?.modality)?.toLowerCase() + if (modality?.includes('->')) { + const [inputSide, outputSide] = modality.split('->') + splitModalities(inputSide || '').forEach((item) => input.add(item)) + splitModalities(outputSide || '').forEach((item) => output.add(item)) + } + + return { + input: Array.from(input), + output: Array.from(output), + } +} + +function isKnownUnsupportedModelId(modelId: string): boolean { + const id = modelId.trim().toLowerCase() + return ( + /(^|[/:-])(?:text-)?embedding/.test(id) || + id.includes('embedding') || + id.includes('moderation') || + id.includes('omni-moderation') || + id.includes('whisper') || + id.includes('tts') || + id.includes('transcribe') || + id.includes('realtime') + ) +} + +export function getLLMTesterModelCapabilities(model: LLMTesterModel): LLMTesterModelCapability[] { + const capabilities = new Set() + const modalities = getModelModalities(model) + const hasOutputMetadata = modalities.output.length > 0 + const outputsText = modalities.output.includes('text') + const outputsImage = modalities.output.includes('image') || isLikelyImageGenerationModelId(model.id) + const outputsVideo = modalities.output.includes('video') || isLikelyVideoGenerationModelId(model.id) + const unsupportedByTester = isKnownUnsupportedModelId(model.id) + + if (outputsImage) { + capabilities.add('image_generation') + } + + if (outputsVideo) { + capabilities.add('video_generation') + } + + if (!unsupportedByTester && !outputsImage && !outputsVideo && (!hasOutputMetadata || outputsText)) { + capabilities.add('chat') + } + + if (capabilities.has('chat') && modalities.input.includes('image')) { + capabilities.add('vision') + } + + return Array.from(capabilities) +} + +export function isChatCompletionModel(model: LLMTesterModel): boolean { + return getLLMTesterModelCapabilities(model).includes('chat') +} + +export function isImageGenerationModel(model: LLMTesterModel): boolean { + return getLLMTesterModelCapabilities(model).includes('image_generation') +} + +export function isVideoGenerationModel(model: LLMTesterModel): boolean { + return getLLMTesterModelCapabilities(model).includes('video_generation') +} + +export function isLLMTesterSupportedModel(model: LLMTesterModel): boolean { + const capabilities = getLLMTesterModelCapabilities(model) + return capabilities.includes('chat') || capabilities.includes('image_generation') || capabilities.includes('video_generation') +} + +function extractErrorMessage(payload: unknown, fallback: string): string { + const obj = getObject(payload) + const errorObj = getObject(obj?.error) + return ( + getString(errorObj?.message) || + getString(obj?.message) || + getString(obj?.detail) || + fallback + ) +} + +async function parseResponsePayload(response: Response): Promise { + const contentType = response.headers.get('content-type') || '' + if (contentType.includes('application/json')) return response.json() + const text = await response.text() + try { + return JSON.parse(text) + } catch { + return text + } +} + +function unwrapApiEnvelope(payload: unknown): unknown { + const obj = getObject(payload) + if (!obj || !('code' in obj) || !('data' in obj)) return payload + return obj.data +} + +function shouldUseTesterProxy(baseUrl: string): boolean { + const normalized = normalizeBaseUrl(baseUrl) + if (!normalized || normalized.startsWith('/')) return false + if (typeof window === 'undefined') return true + try { + return new URL(normalized).origin !== window.location.origin + } catch { + return true + } +} + +async function postTesterProxy(path: LLMTesterProxyPath, body: Record, signal?: AbortSignal): Promise { + const response = await fetch(buildApiUrl(`/llm-tester/${path}`), { + method: 'POST', + headers: buildJsonHeaders(), + body: JSON.stringify(body), + signal, + }) + const payload = await parseResponsePayload(response) + if (!response.ok) { + const fallback = path === 'models' + ? `Failed to fetch models (${response.status})` + : path === 'videos/generations' + ? `Video generation failed (${response.status})` + : path === 'images/generations' || path === 'responses' + ? `Image generation failed (${response.status})` + : `Chat request failed (${response.status})` + throw new Error(extractErrorMessage(payload, fallback)) + } + return unwrapApiEnvelope(payload) +} + +export function parseModelList(payload: unknown): LLMTesterModel[] { + const obj = getObject(payload) + const data = Array.isArray(obj?.data) ? obj.data : Array.isArray(payload) ? payload : [] + + return data + .map((item): LLMTesterModel | null => { + const raw = getObject(item) + if (!raw) return null + + const id = getString(raw.id) || getString(raw.name) + if (!id) return null + + const topProvider = getObject(raw.top_provider) + return { + id, + name: getString(raw.name) || id, + ownedBy: getString(raw.owned_by) || getString(raw.ownedBy), + contextLength: getNumber(raw.context_length) || getNumber(raw.contextLength) || getNumber(topProvider?.context_length), + raw, + } + }) + .filter((model): model is LLMTesterModel => model !== null) + .filter(isLLMTesterSupportedModel) + .sort((a, b) => a.id.localeCompare(b.id)) +} + +export async function fetchLLMModels(baseUrl: string, apiKey: string, signal?: AbortSignal): Promise { + const endpoint = buildOpenAIEndpoint(baseUrl, 'models') + if (!endpoint) throw new Error('Base URL is required') + + if (shouldUseTesterProxy(baseUrl)) { + const payload = await postTesterProxy('models', { + base_url: normalizeBaseUrl(baseUrl), + api_key: apiKey, + }, signal) + return parseModelList(payload) + } + + const response = await fetch(endpoint, { + method: 'GET', + headers: buildHeaders(apiKey), + signal, + }) + const payload = await parseResponsePayload(response) + if (!response.ok) { + throw new Error(extractErrorMessage(payload, `Failed to fetch models (${response.status})`)) + } + + return parseModelList(payload) +} + +function inferLanguage(filename: string, type: string): string { + const lower = filename.toLowerCase() + const ext = lower.includes('.') ? lower.split('.').pop() || '' : '' + const byExt: Record = { + js: 'javascript', + jsx: 'jsx', + ts: 'typescript', + tsx: 'tsx', + vue: 'vue', + py: 'python', + go: 'go', + rs: 'rust', + java: 'java', + c: 'c', + cpp: 'cpp', + cs: 'csharp', + html: 'html', + css: 'css', + json: 'json', + md: 'markdown', + sh: 'bash', + sql: 'sql', + yml: 'yaml', + yaml: 'yaml', + xml: 'xml', + toml: 'toml', + csv: 'csv', + } + if (byExt[ext]) return byExt[ext] + if (type.includes('json')) return 'json' + if (type.includes('markdown')) return 'markdown' + if (type.includes('html')) return 'html' + return '' +} + +function formatTextAttachment(attachment: LLMTesterAttachment): string { + const language = inferLanguage(attachment.name, attachment.type) + return [ + `Attached file: ${attachment.name}`, + `\`\`\`${language}`, + attachment.text || '', + '```', + ].join('\n') +} + +function buildImageGenerationPrompt(messages: LLMTesterMessage[], systemInstruction = ''): string { + const latestUserMessage = [...messages].reverse().find((message) => message.role === 'user') + const attachments = latestUserMessage?.attachments || [] + const textAttachments = attachments.filter((attachment) => attachment.kind === 'text' && attachment.text) + const mediaAttachments = attachments.filter((attachment) => attachment.kind !== 'text') + + const sections = [ + systemInstruction.trim(), + latestUserMessage?.content.trim() || '', + ...textAttachments.map(formatTextAttachment), + ...mediaAttachments.map((attachment) => `Attached reference file: ${attachment.name} (${attachment.type || 'unknown type'}, ${attachment.size} bytes).`), + ].filter(Boolean) + + return sections.join('\n\n') +} + +function buildMediaGenerationPrompt(messages: LLMTesterMessage[], systemInstruction = ''): string { + return buildImageGenerationPrompt(messages, systemInstruction) +} + +function buildUserContent(message: LLMTesterMessage): OpenAIMessageContent { + const attachments = message.attachments || [] + const imageAttachments = attachments.filter((attachment) => attachment.kind === 'image' && attachment.dataUrl) + const textAttachments = attachments.filter((attachment) => attachment.kind === 'text' && attachment.text) + const otherAttachments = attachments.filter((attachment) => attachment.kind !== 'image' && attachment.kind !== 'text') + + const textParts = [ + message.content.trim(), + ...textAttachments.map(formatTextAttachment), + ...otherAttachments.map((attachment) => `Attached media: ${attachment.name} (${attachment.type || 'unknown type'}, ${attachment.size} bytes).`), + ].filter(Boolean) + + if (imageAttachments.length === 0) return textParts.join('\n\n') + + const content: Array = [] + content.push({ + type: 'text', + text: textParts.join('\n\n') || 'Please analyze the attached image.', + }) + + for (const attachment of imageAttachments) { + if (!attachment.dataUrl) continue + content.push({ + type: 'image_url', + image_url: { url: attachment.dataUrl }, + }) + } + + return content +} + +export function buildChatCompletionMessages(messages: LLMTesterMessage[], systemInstruction = ''): OpenAIChatMessage[] { + const out: OpenAIChatMessage[] = [] + const system = systemInstruction.trim() + if (system) { + out.push({ role: 'system', content: system }) + } + + for (const message of messages) { + out.push({ + role: message.role, + content: message.role === 'user' ? buildUserContent(message) : message.content, + }) + } + + return out +} + +export function extractChatCompletionText(payload: unknown): string { + const obj = getObject(payload) + const choices = Array.isArray(obj?.choices) ? obj.choices : [] + const firstChoice = getObject(choices[0]) + const message = getObject(firstChoice?.message) + const content = message?.content + + if (typeof content === 'string') return content + if (Array.isArray(content)) { + return content + .map((part) => { + const partObj = getObject(part) + return getString(partObj?.text) || getString(partObj?.content) || '' + }) + .filter(Boolean) + .join('\n') + } + + const text = getString(firstChoice?.text) + if (text) return text + + return JSON.stringify(payload, null, 2) +} + +export function extractImageGenerationResult(payload: unknown): ImageGenerationResult { + const attachments: LLMTesterAttachment[] = [] + const lines: string[] = [] + + const pushImageAttachment = (rawValue: unknown, index: number) => { + const value = normalizeGeneratedImageValue(rawValue) + if (!value) return + attachments.push({ + id: `generated-image-${Date.now()}-${index}`, + name: `generated-image-${index + 1}.png`, + type: 'image/png', + size: 0, + kind: 'image', + dataUrl: value, + }) + } + + const explicitImageResult = (value: unknown): unknown => { + const text = getString(value) + if (!text) return value + if (/^(?:data:image\/|https?:\/\/)/i.test(text)) return text + return `data:image/png;base64,${text}` + } + + const processOutputItem = (item: unknown) => { + const outputItem = getObject(item) + if (!outputItem) return + const type = getString(outputItem.type) + + if (type === 'image_generation_call') { + const b64 = getString(outputItem.b64_json) + pushImageAttachment(b64 ? `data:image/png;base64,${b64}` : explicitImageResult(outputItem.result) || outputItem.image_url || outputItem.url, attachments.length) + const revisedPrompt = getString(outputItem.revised_prompt) + if (revisedPrompt) { + lines.push(`Revised prompt: ${revisedPrompt}`) + } + } + + const content = Array.isArray(outputItem.content) ? outputItem.content : [] + content.forEach((part) => { + const partObj = getObject(part) + if (!partObj) return + const partType = getString(partObj.type) + const text = getString(partObj.text) + if (text && (partType === 'output_text' || partType === 'text')) { + lines.push(text) + } + const b64 = getString(partObj.b64_json) + pushImageAttachment(b64 ? `data:image/png;base64,${b64}` : explicitImageResult(partObj.result) || partObj.image_url || partObj.url, attachments.length) + }) + + const outputText = getString(outputItem.text) + if (outputText && type !== 'image_generation_call') { + lines.push(outputText) + } + } + + const processPayload = (rawPayload: unknown) => { + const obj = getObject(rawPayload) + if (!obj) return + + if (obj.item) { + processOutputItem(obj.item) + } + if (obj.response) { + processPayload(obj.response) + } + + const data = Array.isArray(obj.data) ? obj.data : [] + data.forEach((item, index) => { + const image = getObject(item) + if (!image) return + + const revisedPrompt = getString(image.revised_prompt) + if (revisedPrompt) { + lines.push(`Revised prompt: ${revisedPrompt}`) + } + + const b64 = getString(image.b64_json) + const url = getString(image.url) + pushImageAttachment(b64 ? `data:image/png;base64,${b64}` : url, index) + }) + + const output = Array.isArray(obj.output) ? obj.output : [] + output.forEach(processOutputItem) + } + + const payloads = typeof payload === 'string' ? parseEventStreamPayload(payload) : [payload] + payloads.forEach(processPayload) + + if (attachments.length > 0) { + lines.unshift(`Generated ${attachments.length} image${attachments.length === 1 ? '' : 's'}.`) + } + + return { + text: lines.join('\n\n') || JSON.stringify(payload, null, 2), + attachments, + raw: payload, + } +} + +function parseEventStreamPayload(payload: string): unknown[] { + const events: unknown[] = [] + const dataLines: string[] = [] + + const flush = () => { + const data = dataLines.join('\n').trim() + dataLines.length = 0 + if (!data || data === '[DONE]') return + try { + events.push(JSON.parse(data)) + } catch { + events.push(data) + } + } + + for (const line of payload.split(/\r?\n/)) { + if (line.startsWith('data:')) { + dataLines.push(line.slice(5).trimStart()) + continue + } + if (!line.trim()) { + flush() + } + } + flush() + + if (events.length > 0) return events + try { + return [JSON.parse(payload)] + } catch { + return [] + } +} + +function normalizeGeneratedImageValue(value: unknown): string { + if (typeof value === 'object' && value !== null) { + const obj = getObject(value) + return normalizeGeneratedImageValue(obj?.url || obj?.b64_json || obj?.result) + } + const text = getString(value) + if (!text) return '' + if (/^data:image\//i.test(text)) return text + if (/^https?:\/\//i.test(text)) return text + const compact = text.replace(/\s+/g, '') + if (compact.length > 100 && /^[A-Za-z0-9+/=]+$/.test(compact)) { + return `data:image/png;base64,${compact}` + } + return '' +} + +function normalizeGeneratedMediaValue(value: unknown): string { + if (typeof value === 'object' && value !== null) { + const obj = getObject(value) + return normalizeGeneratedMediaValue( + obj?.url || + obj?.video_url || + obj?.download_url || + obj?.b64_json || + obj?.base64 || + obj?.result + ) + } + const text = getString(value) + if (!text) return '' + if (/^data:video\//i.test(text)) return text + if (/^https?:\/\//i.test(text)) return text + const compact = text.replace(/\s+/g, '') + if (compact.length > 100 && /^[A-Za-z0-9+/=]+$/.test(compact)) { + return `data:video/mp4;base64,${compact}` + } + return '' +} + +export function extractVideoGenerationResult(payload: unknown): MediaGenerationResult { + const attachments: LLMTesterAttachment[] = [] + const lines: string[] = [] + + const pushVideoAttachment = (rawValue: unknown, index: number) => { + const value = normalizeGeneratedMediaValue(rawValue) + if (!value) return + attachments.push({ + id: `generated-video-${Date.now()}-${index}`, + name: `generated-video-${index + 1}.mp4`, + type: 'video/mp4', + size: 0, + kind: 'media', + dataUrl: value, + }) + } + + const processObject = (value: unknown) => { + const obj = getObject(value) + if (!obj) return + + const status = getString(obj.status) + if (status) lines.push(`Status: ${status}`) + const id = getString(obj.id) || getString(obj.request_id) + if (id) lines.push(`Request ID: ${id}`) + const revisedPrompt = getString(obj.revised_prompt) + if (revisedPrompt) lines.push(`Revised prompt: ${revisedPrompt}`) + + pushVideoAttachment(obj, attachments.length) + + const data = Array.isArray(obj.data) ? obj.data : [] + data.forEach((item) => { + processObject(item) + }) + + const output = Array.isArray(obj.output) ? obj.output : [] + output.forEach((item) => { + processObject(item) + }) + + const content = Array.isArray(obj.content) ? obj.content : [] + content.forEach((item) => { + const itemObj = getObject(item) + const text = getString(itemObj?.text) + if (text) lines.push(text) + processObject(item) + }) + } + + const payloads = typeof payload === 'string' ? parseEventStreamPayload(payload) : [payload] + payloads.forEach(processObject) + + const uniqueLines = Array.from(new Set(lines)) + if (attachments.length > 0) { + uniqueLines.unshift(`Generated ${attachments.length} video${attachments.length === 1 ? '' : 's'}.`) + } + + return { + text: uniqueLines.join('\n\n') || JSON.stringify(payload, null, 2), + attachments, + raw: payload, + } +} + +function imageToolModelId(model: string): string { + const trimmed = model.trim() + if (!trimmed) return 'gpt-image-2' + const parts = trimmed.split('/').filter(Boolean) + return parts[parts.length - 1] || trimmed +} + +function imageResponsesDriverModel(model: string): string { + return isLikelyImageGenerationModelId(model) ? 'gpt-5.4' : model +} + +function buildResponsesImageGenerationBody(model: string, prompt: string): Record { + return { + model: imageResponsesDriverModel(model), + stream: true, + tools: [ + { + type: 'image_generation', + model: imageToolModelId(model), + }, + ], + input: [ + { + role: 'user', + content: [ + { + type: 'input_text', + text: prompt, + }, + ], + }, + ], + } +} + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === 'AbortError' +} + +async function postOpenAIResource( + baseUrl: string, + apiKey: string, + path: LLMTesterProxyPath, + body: Record, + signal?: AbortSignal +): Promise { + const endpoint = buildOpenAIEndpoint(baseUrl, path) + if (!endpoint) throw new Error('Base URL is required') + + if (shouldUseTesterProxy(baseUrl)) { + return postTesterProxy(path, { + base_url: normalizeBaseUrl(baseUrl), + api_key: apiKey, + payload: body, + }, signal) + } + + const response = await fetch(endpoint, { + method: 'POST', + headers: buildHeaders(apiKey), + body: JSON.stringify(body), + signal, + }) + const payload = await parseResponsePayload(response) + if (!response.ok) { + const fallback = path === 'chat/completions' + ? `Chat request failed (${response.status})` + : path === 'videos/generations' + ? `Video generation failed (${response.status})` + : `Image generation failed (${response.status})` + throw new Error(extractErrorMessage(payload, fallback)) + } + + return payload +} + +export async function sendLLMChatCompletion(options: ChatCompletionOptions): Promise<{ text: string; raw: unknown }> { + const endpoint = buildOpenAIEndpoint(options.baseUrl, 'chat/completions') + if (!endpoint) throw new Error('Base URL is required') + + const body: Record = { + model: options.model, + messages: buildChatCompletionMessages(options.messages, options.systemInstruction), + stream: false, + } + + if (typeof options.temperature === 'number' && Number.isFinite(options.temperature)) { + body.temperature = options.temperature + } + if (typeof options.maxTokens === 'number' && Number.isFinite(options.maxTokens) && options.maxTokens > 0) { + body.max_tokens = Math.floor(options.maxTokens) + } + + if (shouldUseTesterProxy(options.baseUrl)) { + const payload = await postTesterProxy('chat/completions', { + base_url: normalizeBaseUrl(options.baseUrl), + api_key: options.apiKey, + payload: body, + }, options.signal) + return { + text: extractChatCompletionText(payload), + raw: payload, + } + } + + const response = await fetch(endpoint, { + method: 'POST', + headers: buildHeaders(options.apiKey), + body: JSON.stringify(body), + signal: options.signal, + }) + const payload = await parseResponsePayload(response) + if (!response.ok) { + throw new Error(extractErrorMessage(payload, `Chat request failed (${response.status})`)) + } + + return { + text: extractChatCompletionText(payload), + raw: payload, + } +} + +export async function sendLLMImageGeneration(options: ImageGenerationOptions): Promise { + const prompt = buildImageGenerationPrompt(options.messages, options.systemInstruction) + if (!prompt) throw new Error('Prompt is required for image generation') + + const body: Record = { + model: options.model, + prompt, + n: 1, + } + if (/^gpt-image-/i.test(imageToolModelId(options.model))) { + body.stream = true + } + + try { + const payload = await postOpenAIResource(options.baseUrl, options.apiKey, 'images/generations', body, options.signal) + return extractImageGenerationResult(payload) + } catch (primaryError) { + if (isAbortError(primaryError)) throw primaryError + + try { + const fallbackPayload = await postOpenAIResource( + options.baseUrl, + options.apiKey, + 'responses', + buildResponsesImageGenerationBody(options.model, prompt), + options.signal + ) + const fallbackResult = extractImageGenerationResult(fallbackPayload) + if (fallbackResult.attachments.length > 0) return fallbackResult + throw new Error('Responses image tool returned no image output') + } catch (fallbackError) { + if (isAbortError(fallbackError)) throw fallbackError + const primaryMessage = primaryError instanceof Error ? primaryError.message : 'Image endpoint failed' + const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : 'Responses fallback failed' + throw new Error(`${primaryMessage}; responses fallback failed: ${fallbackMessage}`) + } + } +} + +export async function sendLLMVideoGeneration(options: ImageGenerationOptions): Promise { + const prompt = buildMediaGenerationPrompt(options.messages, options.systemInstruction) + if (!prompt) throw new Error('Prompt is required for video generation') + + const body: Record = { + model: options.model, + prompt, + } + + const payload = await postOpenAIResource(options.baseUrl, options.apiKey, 'videos/generations', body, options.signal) + return extractVideoGenerationResult(payload) +} diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 3d7f1604c7..8d48591fa0 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -278,6 +278,21 @@ const KeyIcon = { ) } +const TesterIcon = { + render: () => + h( + 'svg', + { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, + [ + h('path', { + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + d: 'M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z' + }) + ] + ) +} + const ChartIcon = { render: () => h( @@ -666,6 +681,7 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] { } items.push( { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, + { path: '/llm-tester', label: t('nav.llmTester'), icon: TesterIcon, hideInSimpleMode: true }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true }, { path: '/available-channels', label: t('nav.availableChannels'), icon: ChannelIcon, hideInSimpleMode: true, featureFlag: flagAvailableChannels }, { path: '/monitor', label: t('nav.channelStatus'), icon: SignalIcon, featureFlag: flagChannelMonitor }, @@ -773,6 +789,7 @@ const adminNavItems = computed((): NavItem[] => { if (authStore.isSimpleMode) { const filtered = visible.filter(item => !item.hideInSimpleMode) filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }) + filtered.push({ path: '/llm-tester', label: t('nav.llmTester'), icon: TesterIcon }) filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon }) for (const cm of customMenuItemsForAdmin.value) { filtered.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg }) diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index 244c6c8db2..b430d99f76 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -141,7 +141,13 @@ const xaiModels = [ 'grok-latest', 'grok-build', 'grok-4.20-reasoning', - 'grok-4.20-non-reasoning' + 'grok-4.20-non-reasoning', + 'grok-imagine', + 'grok-imagine-image', + 'grok-imagine-image-quality', + 'grok-imagine-edit', + 'grok-imagine-video', + 'grok-imagine-video-1.5' ] // Cohere @@ -286,7 +292,13 @@ const grokPresetMappings = [ { label: 'Grok Latest', from: 'grok-latest', to: 'grok-4.3', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' }, { label: 'Build 0.1', from: 'grok-build', to: 'grok-build-0.1', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' }, { label: '4.20 Reasoning', from: 'grok-4.20-reasoning', to: 'grok-4.20-0309-reasoning', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' }, - { label: '4.20 Non Reasoning', from: 'grok-4.20-non-reasoning', to: 'grok-4.20-0309-non-reasoning', color: 'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400' } + { label: '4.20 Non Reasoning', from: 'grok-4.20-non-reasoning', to: 'grok-4.20-0309-non-reasoning', color: 'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400' }, + { label: 'Imagine', from: 'grok-imagine', to: 'grok-imagine', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-300' }, + { label: 'Image', from: 'grok-imagine-image', to: 'grok-imagine-image', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-300' }, + { label: 'Image Quality', from: 'grok-imagine-image-quality', to: 'grok-imagine-image-quality', color: 'bg-fuchsia-100 text-fuchsia-700 hover:bg-fuchsia-200 dark:bg-fuchsia-900/30 dark:text-fuchsia-300' }, + { label: 'Edit', from: 'grok-imagine-edit', to: 'grok-imagine-edit', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-300' }, + { label: 'Video', from: 'grok-imagine-video', to: 'grok-imagine-video', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300' }, + { label: 'Video 1.5', from: 'grok-imagine-video-1.5', to: 'grok-imagine-video-1.5', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-300' } ] // Antigravity 预设映射(支持通配符) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 941c2d71ae..4b31f6fa5e 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -394,6 +394,7 @@ export default { dashboard: 'Dashboard', announcements: 'Announcements', apiKeys: 'API Keys', + llmTester: 'LLM Tester', usage: 'Usage', redeem: 'Redeem', affiliate: 'Affiliate Rebates', @@ -1129,6 +1130,79 @@ export default { } }, + llmTester: { + title: 'LLM Tester', + description: 'Save OpenAI-compatible endpoints, fetch models, and run multimodal chat tests', + profile: 'Profile', + newProfile: 'New profile', + provider: 'Provider', + customProvider: 'Custom', + profileNamePlaceholder: 'OpenRouter staging', + baseUrl: 'Base URL', + apiKey: 'API Key', + showKey: 'Show key', + hideKey: 'Hide key', + model: 'Model', + selectModel: 'Select a model', + searchModels: 'Search fetched models...', + fetchModels: 'Fetch Models', + modelCount: '{count} models fetched', + lastFetched: 'Fetched {time}', + localStorageNotice: 'Keys stay in this browser', + savedProfiles: 'Saved Profiles', + noProfiles: 'No saved profiles', + requestOptions: 'Request Options', + temperature: 'Temperature', + maxTokens: 'Max Tokens', + systemInstruction: 'System Instruction', + systemInstructionPlaceholder: 'Optional', + chat: 'Chat', + noModelSelected: 'No model selected', + clearChat: 'Clear', + cancel: 'Cancel', + emptyChatTitle: 'Ready for a test message', + emptyChatDescription: 'Select a model, attach images or code, and send a prompt.', + you: 'You', + assistant: 'Assistant', + thinking: 'Thinking...', + attachFiles: 'Attach files', + openAttachment: 'Open', + downloadAttachment: 'Download', + removeAttachment: 'Remove', + promptPlaceholder: 'Ask anything, paste code, or attach an image...', + imagePromptPlaceholder: 'Describe the image you want to generate...', + videoPromptPlaceholder: 'Describe the video you want to generate...', + send: 'Send', + profileSaved: 'Profile saved', + profileDeleted: 'Profile deleted', + modelsFetched: 'Fetched {count} models', + capabilities: { + chat: 'Chat', + vision: 'Vision chat', + imageGeneration: 'Image generation', + videoGeneration: 'Video generation' + }, + errors: { + loadFailed: 'Failed to load saved profiles', + saveFailed: 'Failed to save profiles', + nameRequired: 'Profile name is required', + baseUrlRequired: 'Base URL is required', + apiKeyRequired: 'API Key is required', + modelsFailed: 'Failed to fetch models', + chatFailed: 'Chat request failed', + unsupportedModel: 'This model is not supported by the tester yet.', + unsupportedChatModel: 'This tester only supports text chat models. Pick a chat-capable model.', + imagePromptRequired: 'Add a text prompt before generating an image.', + videoPromptRequired: 'Add a text prompt before generating a video.', + openUnavailable: 'Unable to open this attachment', + downloadUnavailable: 'Unable to download this attachment', + cancelled: 'Request cancelled', + imageTooLarge: '{name} is larger than the 5 MB image limit', + textTooLarge: '{name} is larger than the 240 KB text limit', + fileReadFailed: 'Failed to read {name}' + } + }, + affiliate: { title: 'Affiliate Rebates', description: 'Invite new users and convert your rebate quota into account balance', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index c9d2c6fbd4..5f38f67a00 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -394,6 +394,7 @@ export default { dashboard: '仪表盘', announcements: '公告', apiKeys: 'API 密钥', + llmTester: 'LLM 测试器', usage: '使用记录', redeem: '兑换', affiliate: '邀请返利', @@ -1133,6 +1134,79 @@ export default { } }, + llmTester: { + title: 'LLM 测试器', + description: '保存 OpenAI 兼容端点,拉取模型列表,并进行多模态聊天测试', + profile: '配置', + newProfile: '新配置', + provider: '服务商', + customProvider: '自定义', + profileNamePlaceholder: 'OpenRouter 测试', + baseUrl: 'Base URL', + apiKey: 'API Key', + showKey: '显示密钥', + hideKey: '隐藏密钥', + model: '模型', + selectModel: '选择模型', + searchModels: '搜索已拉取模型...', + fetchModels: '拉取模型', + modelCount: '已拉取 {count} 个模型', + lastFetched: '拉取时间 {time}', + localStorageNotice: '密钥仅保存在此浏览器', + savedProfiles: '已保存配置', + noProfiles: '暂无保存配置', + requestOptions: '请求选项', + temperature: 'Temperature', + maxTokens: 'Max Tokens', + systemInstruction: 'System Instruction', + systemInstructionPlaceholder: '可选', + chat: '聊天', + noModelSelected: '未选择模型', + clearChat: '清空', + cancel: '取消', + emptyChatTitle: '可以开始测试', + emptyChatDescription: '选择模型,附加图片或代码,然后发送提示词。', + you: '你', + assistant: '助手', + thinking: '思考中...', + attachFiles: '附加文件', + openAttachment: '打开', + downloadAttachment: '下载', + removeAttachment: '移除', + promptPlaceholder: '输入问题、粘贴代码,或附加图片...', + imagePromptPlaceholder: '描述你想生成的图片...', + videoPromptPlaceholder: '描述你想生成的视频...', + send: '发送', + profileSaved: '配置已保存', + profileDeleted: '配置已删除', + modelsFetched: '已拉取 {count} 个模型', + capabilities: { + chat: '聊天', + vision: '视觉聊天', + imageGeneration: '图片生成', + videoGeneration: '视频生成' + }, + errors: { + loadFailed: '加载保存配置失败', + saveFailed: '保存配置失败', + nameRequired: '请输入配置名称', + baseUrlRequired: '请输入 Base URL', + apiKeyRequired: '请输入 API Key', + modelsFailed: '拉取模型失败', + chatFailed: '聊天请求失败', + unsupportedModel: '此测试器暂不支持该模型。', + unsupportedChatModel: '此测试器仅支持文本聊天模型,请选择可聊天的模型。', + imagePromptRequired: '生成图片前请先输入文本提示词。', + videoPromptRequired: '生成视频前请先输入文本提示词。', + openUnavailable: '无法打开此附件', + downloadUnavailable: '无法下载此附件', + cancelled: '请求已取消', + imageTooLarge: '{name} 超过 5 MB 图片限制', + textTooLarge: '{name} 超过 240 KB 文本限制', + fileReadFailed: '读取 {name} 失败' + } + }, + affiliate: { title: '邀请返利', description: '邀请新用户注册,并将返利额度转入账户余额', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 8721efd70a..069371d841 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -205,6 +205,18 @@ const routes: RouteRecordRaw[] = [ descriptionKey: 'keys.description' } }, + { + path: '/llm-tester', + name: 'LLMTester', + component: () => import('@/views/user/LLMTesterView.vue'), + meta: { + requiresAuth: false, + requiresAdmin: false, + title: 'LLM Tester', + titleKey: 'llmTester.title', + descriptionKey: 'llmTester.description' + } + }, { path: '/usage', name: 'Usage', @@ -690,7 +702,7 @@ let authInitialized = false const navigationLoading = useNavigationLoadingState() // 延迟初始化预加载,传入 router 实例 let routePrefetch: ReturnType | null = null -const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup', '/payment/result', '/payment/airwallex', '/legal'] +const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/llm-tester', '/setup', '/payment/result', '/payment/airwallex', '/legal'] const BACKEND_MODE_CALLBACK_PATHS = [ '/auth/callback', '/auth/linuxdo/callback', diff --git a/frontend/src/views/user/LLMTesterView.vue b/frontend/src/views/user/LLMTesterView.vue new file mode 100644 index 0000000000..56eab507b2 --- /dev/null +++ b/frontend/src/views/user/LLMTesterView.vue @@ -0,0 +1,1157 @@ + + + + + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 3877070453..430ed952b6 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,6 +2,10 @@ import { defineConfig, loadEnv, Plugin } from 'vite' import vue from '@vitejs/plugin-vue' import checker from 'vite-plugin-checker' import { resolve } from 'path' +import { Buffer } from 'node:buffer' +import { lookup } from 'node:dns/promises' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { isIP } from 'node:net' /** * Vite 插件:开发模式下注入公开配置到 index.html @@ -34,6 +38,203 @@ function injectPublicSettings(backendUrl: string): Plugin { } } +const LLM_TESTER_MAX_BODY_BYTES = 12 * 1024 * 1024 +const LLM_TESTER_TIMEOUT_MS = 300000 + +function llmTesterDevProxy(): Plugin { + return { + name: 'llm-tester-dev-proxy', + apply: 'serve', + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + const pathname = new URL(req.url || '/', 'http://localhost').pathname + if (req.method !== 'POST' || !pathname.startsWith('/api/v1/llm-tester/')) { + next() + return + } + + try { + const body = await readDevProxyJson(req) + const route = pathname.slice('/api/v1/llm-tester/'.length) + if (route === 'models') { + await forwardDevLLMTesterRequest(res, body, 'GET', 'models') + return + } + if (route === 'chat/completions') { + await forwardDevLLMTesterRequest(res, body, 'POST', 'chat/completions') + return + } + if (route === 'images/generations') { + await forwardDevLLMTesterRequest(res, body, 'POST', 'images/generations') + return + } + if (route === 'responses') { + await forwardDevLLMTesterRequest(res, body, 'POST', 'responses') + return + } + next() + } catch (error) { + writeDevProxyError(res, 502, devProxyErrorMessage(error)) + } + }) + } + } +} + +async function readDevProxyJson(req: IncomingMessage): Promise> { + const chunks: Buffer[] = [] + let total = 0 + for await (const chunk of req) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + total += buffer.length + if (total > LLM_TESTER_MAX_BODY_BYTES) { + throw new Error('request body is too large') + } + chunks.push(buffer) + } + + try { + const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')) + return parsed && typeof parsed === 'object' ? parsed : {} + } catch { + throw new Error('invalid request body') + } +} + +async function forwardDevLLMTesterRequest( + res: ServerResponse, + body: Record, + method: 'GET' | 'POST', + resource: 'models' | 'chat/completions' | 'images/generations' | 'responses' +) { + const baseUrl = String(body.base_url || '').trim() + const apiKey = String(body.api_key || '').trim() + if (!baseUrl) { + writeDevProxyError(res, 400, 'base_url is required') + return + } + if (!apiKey) { + writeDevProxyError(res, 400, 'api_key is required') + return + } + if (apiKey.length > 8192) { + writeDevProxyError(res, 400, 'api_key is too long') + return + } + if (method === 'POST' && !body.payload) { + writeDevProxyError(res, 400, 'payload is required') + return + } + + const endpoint = await buildDevLLMTesterEndpoint(baseUrl, resource) + const upstream = await fetch(endpoint, { + method, + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': 'Sub2API-LLM-Tester/1.0', + 'X-Title': 'Sub2API LLM Tester' + }, + body: method === 'POST' ? JSON.stringify(body.payload || {}) : undefined, + signal: AbortSignal.timeout(LLM_TESTER_TIMEOUT_MS) + }) + const payload = Buffer.from(await upstream.arrayBuffer()) + if (payload.length > LLM_TESTER_MAX_BODY_BYTES) { + writeDevProxyError(res, 502, 'upstream response is too large') + return + } + + res.statusCode = upstream.status + res.setHeader('Content-Type', upstream.headers.get('content-type') || 'application/json') + res.end(payload) +} + +async function buildDevLLMTesterEndpoint(baseUrl: string, resource: 'models' | 'chat/completions' | 'images/generations' | 'responses'): Promise { + const url = new URL(baseUrl.replace(/\/+$/, '')) + if (url.protocol !== 'https:') { + throw new Error('base_url must use https') + } + if (url.username || url.password) { + throw new Error('base_url must not include user info') + } + await assertDevProxyPublicHost(url.hostname) + url.search = '' + url.hash = '' + if (!/\/v\d+$/i.test(url.pathname)) { + url.pathname = `${url.pathname.replace(/\/+$/, '')}/v1` + } + url.pathname = `${url.pathname.replace(/\/+$/, '')}/${resource}` + return url.toString() +} + +async function assertDevProxyPublicHost(hostname: string) { + const host = hostname.trim().toLowerCase() + if (isBlockedDevProxyHost(host)) { + throw new Error(`host is not allowed: ${hostname}`) + } + if (isIP(host)) { + if (isBlockedDevProxyIP(host)) throw new Error(`host is not allowed: ${hostname}`) + return + } + const addrs = await lookup(host, { all: true, verbatim: false }) + if (!addrs.length) { + throw new Error(`host did not resolve: ${hostname}`) + } + for (const addr of addrs) { + if (isBlockedDevProxyIP(addr.address)) { + throw new Error(`resolved ip is not allowed: ${addr.address}`) + } + } +} + +function isBlockedDevProxyHost(host: string): boolean { + return ( + !host || + host === 'localhost' || + host.endsWith('.localhost') || + host === 'metadata' || + host === 'metadata.google.internal' || + host === 'metadata.goog' || + host === 'instance-data' || + host === 'instance-data.ec2.internal' + ) +} + +function isBlockedDevProxyIP(address: string): boolean { + if (address.includes(':')) { + const lower = address.toLowerCase() + return lower === '::' || lower === '::1' || lower.startsWith('fc') || lower.startsWith('fd') || lower.startsWith('fe80') + } + const parts = address.split('.').map((part) => Number(part)) + if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) return true + const [a, b] = parts + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) + ) +} + +function writeDevProxyError(res: ServerResponse, status: number, message: string) { + res.statusCode = status + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ code: status, message })) +} + +function devProxyErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : 'LLM tester proxy failed' + const cause = error instanceof Error ? (error as Error & { cause?: unknown }).cause : undefined + if (cause instanceof Error && cause.message && cause.message !== message) { + return `${message}: ${cause.message}` + } + return message || 'LLM tester proxy failed' +} + export default defineConfig(({ mode }) => { // 加载环境变量 const env = loadEnv(mode, process.cwd(), '') @@ -46,7 +247,8 @@ export default defineConfig(({ mode }) => { checker({ vueTsc: true }), - injectPublicSettings(backendUrl) + injectPublicSettings(backendUrl), + llmTesterDevProxy() ], resolve: { alias: { From 42e471f59ad0ea5b5dfe1120eeea881f6a480f1b Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Wed, 1 Jul 2026 15:36:08 +0800 Subject: [PATCH 5/8] fix: harden grok media routing --- backend/internal/handler/grok_media.go | 25 +- backend/internal/server/routes/llm_tester.go | 2 +- backend/internal/service/grok_media.go | 400 +++++++++++++++--- .../service/openai_gateway_grok_test.go | 125 ++++++ 4 files changed, 492 insertions(+), 60 deletions(-) diff --git a/backend/internal/handler/grok_media.go b/backend/internal/handler/grok_media.go index 96023f452e..8e236ea49f 100644 --- a/backend/internal/handler/grok_media.go +++ b/backend/internal/handler/grok_media.go @@ -14,7 +14,6 @@ import ( middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" - "github.com/tidwall/gjson" "go.uber.org/zap" ) @@ -84,7 +83,8 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. } contentType := c.GetHeader("Content-Type") - requestModel := service.ExtractGrokMediaModel(contentType, body) + requestInfo := service.ParseGrokMediaRequest(contentType, body) + requestModel := requestInfo.Model if endpoint.IsGenerationRequest() && strings.TrimSpace(requestModel) == "" { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required") return @@ -103,7 +103,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. h.errorResponse(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage()) return } - if moderationBody := grokMediaModerationBody(body); len(moderationBody) > 0 { + if moderationBody := requestInfo.ModerationBody(); len(moderationBody) > 0 { decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIImages, requestModel, moderationBody) if decision != nil && decision.Blocked { h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message) @@ -149,6 +149,9 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. sessionSeed = []byte(requestID) } sessionHash := h.gatewayService.GenerateExplicitSessionHash(c, sessionSeed) + if endpoint == service.GrokMediaEndpointVideoStatus { + sessionHash = service.GrokMediaVideoRequestSessionHash(requestID) + } requestCtx := c.Request.Context() failedAccountIDs := make(map[int64]struct{}) sameAccountRetryCount := make(map[int64]int) @@ -294,6 +297,15 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. } h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil) + if endpoint == service.GrokMediaEndpointVideosGenerations && strings.TrimSpace(result.ResponseID) != "" { + if err := h.gatewayService.BindGrokMediaVideoRequestAccount(requestCtx, apiKey.GroupID, result.ResponseID, account.ID); err != nil { + reqLog.Warn("grok_media.bind_video_request_account_failed", + zap.Int64("account_id", account.ID), + zap.String("request_id", result.ResponseID), + zap.Error(err), + ) + } + } if shouldRecordGrokMediaUsage(endpoint, requestModel) { recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, result, requestModel, body, requestID) } @@ -305,13 +317,6 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. } } -func grokMediaModerationBody(body []byte) []byte { - if gjson.ValidBytes(body) { - return body - } - return nil -} - func shouldRecordGrokMediaUsage(endpoint service.GrokMediaEndpoint, requestModel string) bool { return endpoint.IsGenerationRequest() && strings.TrimSpace(requestModel) != "" } diff --git a/backend/internal/server/routes/llm_tester.go b/backend/internal/server/routes/llm_tester.go index 684b86d729..dd347177ee 100644 --- a/backend/internal/server/routes/llm_tester.go +++ b/backend/internal/server/routes/llm_tester.go @@ -177,7 +177,7 @@ func forwardLLMTesterRequest(c *gin.Context, req llmTesterProxyRequest, method, response.Error(c, http.StatusBadGateway, fmt.Sprintf("upstream request failed: %s", err.Error())) return } - defer upstreamResp.Body.Close() + defer func() { _ = upstreamResp.Body.Close() }() payload, err := readLLMTesterResponseBody(upstreamResp.Body) if err != nil { diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index 9269cacf11..3b76b9c274 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -3,11 +3,13 @@ package service import ( "bytes" "context" + "encoding/json" "fmt" "io" "mime" "mime/multipart" "net/http" + "strconv" "strings" "time" @@ -39,6 +41,56 @@ func (e GrokMediaEndpoint) IsGenerationRequest() bool { } } +type GrokMediaRequestInfo struct { + Model string + Prompt string + N int + Size string + SizeTier string + InputImageURLs []string + MaskImageURL string + Uploads []OpenAIImagesUpload + MaskUpload *OpenAIImagesUpload +} + +func (r GrokMediaRequestInfo) ModerationBody() []byte { + payload := map[string]any{} + if prompt := strings.TrimSpace(r.Prompt); prompt != "" { + payload["prompt"] = prompt + } + + images := make([]map[string]string, 0, len(r.InputImageURLs)+len(r.Uploads)+1) + for _, imageURL := range r.InputImageURLs { + if imageURL = strings.TrimSpace(imageURL); imageURL != "" { + images = append(images, map[string]string{"image_url": imageURL}) + } + } + for _, upload := range r.Uploads { + if dataURL := upload.ModerationDataURL(); dataURL != "" { + images = append(images, map[string]string{"image_url": dataURL}) + } + } + if maskURL := strings.TrimSpace(r.MaskImageURL); maskURL != "" { + images = append(images, map[string]string{"image_url": maskURL}) + } + if r.MaskUpload != nil { + if dataURL := r.MaskUpload.ModerationDataURL(); dataURL != "" { + images = append(images, map[string]string{"image_url": dataURL}) + } + } + if len(images) > 0 { + payload["images"] = images + } + if len(payload) == 0 { + return nil + } + body, err := json.Marshal(payload) + if err != nil { + return nil + } + return body +} + func (e GrokMediaEndpoint) httpMethod() string { if e == GrokMediaEndpointVideoStatus { return http.MethodGet @@ -47,41 +99,158 @@ func (e GrokMediaEndpoint) httpMethod() string { } func ExtractGrokMediaModel(contentType string, body []byte) string { - if model := strings.TrimSpace(gjson.GetBytes(body, "model").String()); model != "" { - return model - } - return extractGrokMediaMultipartModel(contentType, body) + return ParseGrokMediaRequest(contentType, body).Model } -func extractGrokMediaMultipartModel(contentType string, body []byte) string { +func ParseGrokMediaRequest(contentType string, body []byte) GrokMediaRequestInfo { + info := GrokMediaRequestInfo{N: 1} + if gjson.ValidBytes(body) { + parseGrokMediaJSONRequest(body, &info) + } else { + parseGrokMediaMultipartRequest(contentType, body, &info) + } + info.Model = strings.TrimSpace(info.Model) + info.Prompt = strings.TrimSpace(info.Prompt) + info.Size = strings.TrimSpace(info.Size) + info.SizeTier = NormalizeImageBillingTierOrDefault(info.Size) + if info.N <= 0 { + info.N = 1 + } + return info +} + +func parseGrokMediaJSONRequest(body []byte, info *GrokMediaRequestInfo) { + if info == nil { + return + } + info.Model = strings.TrimSpace(gjson.GetBytes(body, "model").String()) + info.Prompt = strings.TrimSpace(gjson.GetBytes(body, "prompt").String()) + info.Size = strings.TrimSpace(gjson.GetBytes(body, "size").String()) + if n := gjson.GetBytes(body, "n"); n.Exists() && n.Type == gjson.Number { + info.N = int(n.Int()) + } + appendJSONImageURLs := func(value gjson.Result) { + if !value.Exists() { + return + } + switch { + case value.IsArray(): + for _, item := range value.Array() { + if imageURL := strings.TrimSpace(item.Get("image_url").String()); imageURL != "" { + info.InputImageURLs = append(info.InputImageURLs, imageURL) + continue + } + if item.Type == gjson.String { + imageURL := strings.TrimSpace(item.String()) + if imageURL == "" { + continue + } + info.InputImageURLs = append(info.InputImageURLs, imageURL) + } + } + default: + if imageURL := strings.TrimSpace(value.Get("image_url").String()); imageURL != "" { + info.InputImageURLs = append(info.InputImageURLs, imageURL) + return + } + if value.Type == gjson.String { + imageURL := strings.TrimSpace(value.String()) + if imageURL == "" { + return + } + info.InputImageURLs = append(info.InputImageURLs, imageURL) + } + } + } + appendJSONImageURLs(gjson.GetBytes(body, "image")) + appendJSONImageURLs(gjson.GetBytes(body, "images")) + info.MaskImageURL = strings.TrimSpace(gjson.GetBytes(body, "mask.image_url").String()) +} + +func parseGrokMediaMultipartRequest(contentType string, body []byte, info *GrokMediaRequestInfo) { + if info == nil { + return + } mediaType, params, err := mime.ParseMediaType(strings.TrimSpace(contentType)) if err != nil || !strings.EqualFold(mediaType, "multipart/form-data") { - return "" + return } boundary := strings.TrimSpace(params["boundary"]) if boundary == "" { - return "" + return } reader := multipart.NewReader(bytes.NewReader(body), boundary) for { part, err := reader.NextPart() if err == io.EOF { - return "" + return } if err != nil { - return "" + return } - if part.FormName() != "model" || part.FileName() != "" { + name := strings.TrimSpace(part.FormName()) + if name == "" { + _ = part.Close() continue } - data, err := io.ReadAll(part) + data, err := io.ReadAll(io.LimitReader(part, openAIImageMaxUploadPartSize)) + _ = part.Close() if err != nil { - return "" + return + } + fileName := strings.TrimSpace(part.FileName()) + partContentType := strings.TrimSpace(part.Header.Get("Content-Type")) + if fileName != "" { + upload := OpenAIImagesUpload{ + FieldName: name, + FileName: fileName, + ContentType: partContentType, + Data: data, + } + if name == "mask" { + info.MaskUpload = &upload + continue + } + if name == "image" || strings.HasPrefix(name, "image[") { + info.Uploads = append(info.Uploads, upload) + } + continue + } + + value := strings.TrimSpace(string(data)) + switch name { + case "model": + info.Model = value + case "prompt": + info.Prompt = value + case "size": + info.Size = value + case "n": + if n, err := strconv.Atoi(value); err == nil { + info.N = n + } + case "image", "image_url": + if value != "" { + info.InputImageURLs = append(info.InputImageURLs, value) + } + case "mask", "mask_image_url": + info.MaskImageURL = value } - return strings.TrimSpace(string(data)) } } +func GrokMediaVideoRequestSessionHash(requestID string) string { + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return "" + } + return "grok-video:" + DeriveSessionHashFromSeed(requestID) +} + +func (s *OpenAIGatewayService) BindGrokMediaVideoRequestAccount(ctx context.Context, groupID *int64, requestID string, accountID int64) error { + return s.BindStickySession(ctx, groupID, GrokMediaVideoRequestSessionHash(requestID), accountID) +} + func (e GrokMediaEndpoint) upstreamURL(baseURL, requestID string) (string, error) { switch e { case GrokMediaEndpointImagesGenerations: @@ -157,39 +326,11 @@ func (s *OpenAIGatewayService) ForwardGrokMedia( defer func() { _ = resp.Body.Close() }() requestIDHeader := firstNonEmpty(resp.Header.Get("x-request-id"), resp.Header.Get("xai-request-id")) - requestModel := ExtractGrokMediaModel(contentType, body) + requestInfo := ParseGrokMediaRequest(contentType, body) + requestModel := requestInfo.Model if resp.StatusCode >= 400 { - respBody := s.readUpstreamErrorBody(resp) s.updateGrokUsageSnapshot(ctx, account.ID, xai.ParseQuotaHeaders(resp.Header, resp.StatusCode)) - upstreamMsg := sanitizeUpstreamErrorMessage(extractUpstreamErrorMessage(respBody)) - if upstreamMsg == "" { - upstreamMsg = fmt.Sprintf("xAI upstream returned status %d", resp.StatusCode) - } - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: requestIDHeader, - Kind: "failover", - Message: upstreamMsg, - }) - s.handleGrokAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) - if s.shouldFailoverUpstreamError(resp.StatusCode) { - return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: respBody, - RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), - } - } - writeGrokMediaResponse(c, resp, respBody, s.responseHeaderFilter) - return &OpenAIForwardResult{ - RequestID: requestIDHeader, - Model: requestModel, - UpstreamModel: requestModel, - ResponseHeaders: resp.Header.Clone(), - Duration: time.Since(startTime), - }, nil + return s.handleGrokMediaErrorResponse(ctx, resp, c, account, requestIDHeader, requestModel) } s.updateGrokUsageSnapshot(ctx, account.ID, xai.ParseQuotaHeaders(resp.Header, resp.StatusCode)) @@ -198,15 +339,176 @@ func (s *OpenAIGatewayService) ForwardGrokMedia( return nil, err } writeGrokMediaResponse(c, resp, respBody, s.responseHeaderFilter) + usage := grokMediaUsageFromResponse(endpoint, requestInfo, respBody) return &OpenAIForwardResult{ - RequestID: requestIDHeader, - Model: requestModel, - UpstreamModel: requestModel, - ResponseHeaders: resp.Header.Clone(), - Duration: time.Since(startTime), + RequestID: requestIDHeader, + ResponseID: usage.ResponseID, + Usage: usage.Usage, + Model: requestModel, + BillingModel: requestModel, + UpstreamModel: requestModel, + ResponseHeaders: resp.Header.Clone(), + Duration: time.Since(startTime), + ImageCount: usage.ImageCount, + ImageSize: usage.ImageSize, + ImageInputSize: usage.ImageInputSize, + ImageOutputSizes: usage.ImageOutputSizes, }, nil } +type grokMediaUsageMetadata struct { + ResponseID string + Usage OpenAIUsage + ImageCount int + ImageSize string + ImageInputSize string + ImageOutputSizes []string +} + +func grokMediaUsageFromResponse(endpoint GrokMediaEndpoint, requestInfo GrokMediaRequestInfo, responseBody []byte) grokMediaUsageMetadata { + usage, _ := extractOpenAIUsageFromJSONBytes(responseBody) + meta := grokMediaUsageMetadata{Usage: usage} + switch endpoint { + case GrokMediaEndpointImagesGenerations, GrokMediaEndpointImagesEdits: + imageCount := countOpenAIResponseImageOutputsFromJSONBytes(responseBody) + if imageCount <= 0 { + imageCount = requestInfo.N + } + if imageCount <= 0 { + imageCount = 1 + } + meta.ImageCount = imageCount + meta.ImageSize = requestInfo.SizeTier + meta.ImageInputSize = requestInfo.Size + meta.ImageOutputSizes = collectOpenAIResponseImageOutputSizesFromJSONBytes(responseBody) + case GrokMediaEndpointVideosGenerations: + meta.ResponseID = extractGrokMediaVideoRequestID(responseBody) + meta.ImageCount = 1 + meta.ImageSize = requestInfo.SizeTier + meta.ImageInputSize = requestInfo.Size + } + return meta +} + +func extractGrokMediaVideoRequestID(body []byte) string { + if len(body) == 0 || !gjson.ValidBytes(body) { + return "" + } + for _, path := range []string{"request_id", "id", "data.request_id", "data.id", "video.request_id", "video.id"} { + if id := strings.TrimSpace(gjson.GetBytes(body, path).String()); id != "" { + return id + } + } + return "" +} + +func (s *OpenAIGatewayService) handleGrokMediaErrorResponse( + ctx context.Context, + resp *http.Response, + c *gin.Context, + account *Account, + requestIDHeader string, + requestedModel string, +) (*OpenAIForwardResult, error) { + body := s.readUpstreamErrorBody(resp) + upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(body))) + if upstreamMsg == "" { + upstreamMsg = fmt.Sprintf("xAI upstream returned status %d", resp.StatusCode) + } + + upstreamDetail := "" + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes + if maxBytes <= 0 { + maxBytes = 2048 + } + upstreamDetail = truncateString(string(body), maxBytes) + } + setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) + + if status, errType, errMsg, matched := applyErrorPassthroughRule( + c, + account.Platform, + resp.StatusCode, + body, + http.StatusBadGateway, + "upstream_error", + "Upstream request failed", + ); matched { + MarkResponseCommitted(c) + writeGrokMediaErrorResponse(c, status, errType, errMsg) + return nil, fmt.Errorf("upstream error: %d (passthrough rule matched) message=%s", resp.StatusCode, upstreamMsg) + } + + if !account.ShouldHandleErrorCode(resp.StatusCode) { + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: requestIDHeader, + Kind: "http_error", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + MarkResponseCommitted(c) + writeGrokMediaErrorResponse(c, http.StatusInternalServerError, "upstream_error", "Upstream gateway error") + return nil, fmt.Errorf("upstream error: %d (not in custom error codes) message=%s", resp.StatusCode, upstreamMsg) + } + + s.handleGrokAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body) + kind := "http_error" + if s.shouldFailoverUpstreamError(resp.StatusCode) { + kind = "failover" + } + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: requestIDHeader, + Kind: kind, + Message: upstreamMsg, + Detail: upstreamDetail, + }) + if kind == "failover" { + return nil, &UpstreamFailoverError{ + StatusCode: resp.StatusCode, + ResponseBody: body, + RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + } + } + + MarkResponseCommitted(c) + writeGrokMediaErrorResponse(c, resp.StatusCode, grokMediaErrorType(resp.StatusCode), upstreamMsg) + return nil, fmt.Errorf("upstream error: %d %s", resp.StatusCode, upstreamMsg) +} + +func grokMediaErrorType(statusCode int) string { + switch { + case statusCode == http.StatusBadRequest: + return "invalid_request_error" + case statusCode == http.StatusNotFound: + return "not_found_error" + case statusCode == http.StatusTooManyRequests: + return "rate_limit_error" + default: + return "upstream_error" + } +} + +func writeGrokMediaErrorResponse(c *gin.Context, statusCode int, errType, message string) { + if c == nil || c.Writer == nil || c.Writer.Written() { + return + } + c.JSON(statusCode, gin.H{ + "error": gin.H{ + "type": strings.TrimSpace(errType), + "message": strings.TrimSpace(message), + }, + }) +} + func writeGrokMediaResponse(c *gin.Context, resp *http.Response, body []byte, filter *responseheaders.CompiledHeaderFilter) { if c == nil || resp == nil { return diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 268d07f9ec..a095243f57 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -10,6 +10,7 @@ import ( "mime/multipart" "net/http" "net/http/httptest" + "net/textproto" "strings" "testing" "time" @@ -157,6 +158,30 @@ func TestExtractGrokMediaModelSupportsJSONAndMultipart(t *testing.T) { require.Equal(t, "grok-imagine-edit", ExtractGrokMediaModel(writer.FormDataContentType(), buf.Bytes())) } +func TestParseGrokMediaRequestBuildsMultipartModerationBody(t *testing.T) { + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + require.NoError(t, writer.WriteField("prompt", "edit this private image")) + require.NoError(t, writer.WriteField("model", "grok-imagine-edit")) + partHeader := textproto.MIMEHeader{} + partHeader.Set("Content-Disposition", `form-data; name="image"; filename="input.png"`) + partHeader.Set("Content-Type", "image/png") + part, err := writer.CreatePart(partHeader) + require.NoError(t, err) + _, err = part.Write([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + info := ParseGrokMediaRequest(writer.FormDataContentType(), buf.Bytes()) + require.Equal(t, "grok-imagine-edit", info.Model) + require.Equal(t, "edit this private image", info.Prompt) + + moderationBody := info.ModerationBody() + require.NotEmpty(t, moderationBody) + require.Equal(t, "edit this private image", gjson.GetBytes(moderationBody, "prompt").String()) + require.True(t, strings.HasPrefix(gjson.GetBytes(moderationBody, "images.0.image_url").String(), "data:image/")) +} + func TestForwardGrokMediaImagesGenerationPassthrough(t *testing.T) { t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") gin.SetMode(gin.TestMode) @@ -199,6 +224,50 @@ func TestForwardGrokMediaImagesGenerationPassthrough(t *testing.T) { require.JSONEq(t, `{"data":[]}`, recorder.Body.String()) require.Equal(t, "xai-image-req", result.RequestID) require.Equal(t, "grok-imagine", result.Model) + require.Equal(t, "grok-imagine", result.BillingModel) + require.Equal(t, 1, result.ImageCount) + require.Equal(t, ImageBillingSize2K, result.ImageSize) +} + +func TestForwardGrokMediaVideoGenerationReturnsUsageAndResponseID(t *testing.T) { + t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") + gin.SetMode(gin.TestMode) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + body := []byte(`{"model":"grok-imagine-video-1.5","prompt":"waves"}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos/generations", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + account := &Account{ + ID: 63, + Name: "grok", + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "api-key", + "base_url": "https://xai.test/v1", + }, + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "Xai-Request-Id": []string{"xai-video-generate-req"}, + }, + Body: io.NopCloser(strings.NewReader(`{"request_id":"video-request-123","usage":{"prompt_tokens":3,"completion_tokens":4}}`)), + }} + svc := &OpenAIGatewayService{httpUpstream: upstream} + + result, err := svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointVideosGenerations, "", body, "application/json") + require.NoError(t, err) + require.Equal(t, "https://xai.test/v1/videos/generations", upstream.lastReq.URL.String()) + require.Equal(t, "video-request-123", result.ResponseID) + require.Equal(t, "grok-imagine-video-1.5", result.BillingModel) + require.Equal(t, 3, result.Usage.InputTokens) + require.Equal(t, 4, result.Usage.OutputTokens) + require.Equal(t, 1, result.ImageCount) } func TestForwardGrokMediaVideoStatusUsesGETWithoutBody(t *testing.T) { @@ -242,6 +311,62 @@ func TestForwardGrokMediaVideoStatusUsesGETWithoutBody(t *testing.T) { require.Equal(t, "xai-video-req", result.RequestID) } +func TestBindGrokMediaVideoRequestAccountUsesRequestIDStickyHash(t *testing.T) { + ctx := context.Background() + groupID := int64(7) + cache := &stubGatewayCache{} + svc := &OpenAIGatewayService{cache: cache} + + hash := GrokMediaVideoRequestSessionHash("video-request-123") + require.NotEmpty(t, hash) + require.NoError(t, svc.BindGrokMediaVideoRequestAccount(ctx, &groupID, "video-request-123", 63)) + + accountID, err := svc.getStickySessionAccountID(ctx, &groupID, hash) + require.NoError(t, err) + require.Equal(t, int64(63), accountID) +} + +func TestForwardGrokMediaErrorHonorsCustomErrorCodes(t *testing.T) { + t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") + gin.SetMode(gin.TestMode) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + body := []byte(`{"model":"grok-imagine","prompt":"draw a cat"}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + account := &Account{ + ID: 64, + Name: "grok", + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "api-key", + "base_url": "https://xai.test/v1", + "custom_error_codes_enabled": true, + "custom_error_codes": []any{float64(http.StatusTooManyRequests)}, + }, + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "Xai-Request-Id": []string{"xai-error-req"}, + }, + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"do not expose this upstream detail"}}`)), + }} + svc := &OpenAIGatewayService{httpUpstream: upstream} + + result, err := svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointImagesGenerations, "", body, "application/json") + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, http.StatusInternalServerError, recorder.Code) + require.Contains(t, recorder.Body.String(), "Upstream gateway error") + require.NotContains(t, recorder.Body.String(), "do not expose") +} + func TestForwardAsChatCompletionsForGrokUsesXAIChatCompletionsAndSnapshots(t *testing.T) { gin.SetMode(gin.TestMode) From c9fb221a31a3c24059b1305e641dbd0f9eaf4228 Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Wed, 1 Jul 2026 15:42:00 +0800 Subject: [PATCH 6/8] fix: satisfy grok media lint --- backend/internal/service/grok_media.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index 3b76b9c274..07e8c57bc1 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -485,12 +485,12 @@ func (s *OpenAIGatewayService) handleGrokMediaErrorResponse( } func grokMediaErrorType(statusCode int) string { - switch { - case statusCode == http.StatusBadRequest: + switch statusCode { + case http.StatusBadRequest: return "invalid_request_error" - case statusCode == http.StatusNotFound: + case http.StatusNotFound: return "not_found_error" - case statusCode == http.StatusTooManyRequests: + case http.StatusTooManyRequests: return "rate_limit_error" default: return "upstream_error" From f77cf6b4771d9f0cbe3ca7b93fb0076d4d1d7de5 Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Wed, 1 Jul 2026 16:08:21 +0800 Subject: [PATCH 7/8] Revert "feat: add LLM media tester" This reverts commit a34d4967e6ef616adfc38848fb845f48a87118c4. --- backend/internal/server/router.go | 1 - backend/internal/server/routes/llm_tester.go | 310 ----- frontend/src/api/__tests__/llmTester.spec.ts | 178 --- frontend/src/api/llmTester.ts | 932 ------------- frontend/src/components/layout/AppSidebar.vue | 17 - frontend/src/composables/useModelWhitelist.ts | 16 +- frontend/src/i18n/locales/en.ts | 74 -- frontend/src/i18n/locales/zh.ts | 74 -- frontend/src/router/index.ts | 14 +- frontend/src/views/user/LLMTesterView.vue | 1157 ----------------- frontend/vite.config.ts | 204 +-- 11 files changed, 4 insertions(+), 2973 deletions(-) delete mode 100644 backend/internal/server/routes/llm_tester.go delete mode 100644 frontend/src/api/__tests__/llmTester.spec.ts delete mode 100644 frontend/src/api/llmTester.ts delete mode 100644 frontend/src/views/user/LLMTesterView.vue diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 35f2c3949e..3d86373779 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -107,7 +107,6 @@ func registerRoutes( v1 := r.Group("/api/v1") // 注册各模块路由 - routes.RegisterLLMTesterRoutes(v1) routes.RegisterAuthRoutes(v1, h, jwtAuth, redisClient, settingService) routes.RegisterUserRoutes(v1, h, jwtAuth, settingService) routes.RegisterAdminRoutes(v1, h, adminAuth, settingService) diff --git a/backend/internal/server/routes/llm_tester.go b/backend/internal/server/routes/llm_tester.go deleted file mode 100644 index dd347177ee..0000000000 --- a/backend/internal/server/routes/llm_tester.go +++ /dev/null @@ -1,310 +0,0 @@ -package routes - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/http" - "net/url" - "regexp" - "strings" - "time" - - "github.com/Wei-Shaw/sub2api/internal/pkg/response" - "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" - "github.com/gin-gonic/gin" -) - -const ( - llmTesterMaxRequestBytes = 12 << 20 - llmTesterMaxResponseBytes = 12 << 20 -) - -var ( - llmTesterVersionPathPattern = regexp.MustCompile(`/v\d+$`) - llmTesterHTTPClient = &http.Client{ - Timeout: 300 * time.Second, - Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, - DialContext: llmTesterSafeDialContext, - TLSHandshakeTimeout: 10 * time.Second, - ResponseHeaderTimeout: 240 * time.Second, - IdleConnTimeout: 30 * time.Second, - }, - } - llmTesterDialer = &net.Dialer{ - Timeout: 10 * time.Second, - KeepAlive: 30 * time.Second, - } - llmTesterBlockedCIDRs = mustParseLLMTesterCIDRs([]string{ - "0.0.0.0/8", - "10.0.0.0/8", - "100.64.0.0/10", - "127.0.0.0/8", - "169.254.0.0/16", - "172.16.0.0/12", - "192.168.0.0/16", - "::/128", - "::1/128", - "fc00::/7", - "fe80::/10", - }) -) - -type llmTesterProxyRequest struct { - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` - Payload json.RawMessage `json:"payload,omitempty"` -} - -func RegisterLLMTesterRoutes(v1 *gin.RouterGroup) { - tester := v1.Group("/llm-tester") - { - tester.POST("/models", llmTesterProxyModels) - tester.POST("/chat/completions", llmTesterProxyChatCompletions) - tester.POST("/images/generations", llmTesterProxyImageGenerations) - tester.POST("/videos/generations", llmTesterProxyVideoGenerations) - tester.POST("/responses", llmTesterProxyResponses) - } -} - -func llmTesterProxyModels(c *gin.Context) { - var req llmTesterProxyRequest - if !bindLLMTesterProxyRequest(c, &req) { - return - } - forwardLLMTesterRequest(c, req, http.MethodGet, "models", nil) -} - -func llmTesterProxyChatCompletions(c *gin.Context) { - var req llmTesterProxyRequest - if !bindLLMTesterProxyRequest(c, &req) { - return - } - if len(bytes.TrimSpace(req.Payload)) == 0 { - response.BadRequest(c, "payload is required") - return - } - forwardLLMTesterRequest(c, req, http.MethodPost, "chat/completions", bytes.NewReader(req.Payload)) -} - -func llmTesterProxyImageGenerations(c *gin.Context) { - var req llmTesterProxyRequest - if !bindLLMTesterProxyRequest(c, &req) { - return - } - if len(bytes.TrimSpace(req.Payload)) == 0 { - response.BadRequest(c, "payload is required") - return - } - forwardLLMTesterRequest(c, req, http.MethodPost, "images/generations", bytes.NewReader(req.Payload)) -} - -func llmTesterProxyVideoGenerations(c *gin.Context) { - var req llmTesterProxyRequest - if !bindLLMTesterProxyRequest(c, &req) { - return - } - if len(bytes.TrimSpace(req.Payload)) == 0 { - response.BadRequest(c, "payload is required") - return - } - forwardLLMTesterRequest(c, req, http.MethodPost, "videos/generations", bytes.NewReader(req.Payload)) -} - -func llmTesterProxyResponses(c *gin.Context) { - var req llmTesterProxyRequest - if !bindLLMTesterProxyRequest(c, &req) { - return - } - if len(bytes.TrimSpace(req.Payload)) == 0 { - response.BadRequest(c, "payload is required") - return - } - forwardLLMTesterRequest(c, req, http.MethodPost, "responses", bytes.NewReader(req.Payload)) -} - -func bindLLMTesterProxyRequest(c *gin.Context, req *llmTesterProxyRequest) bool { - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, llmTesterMaxRequestBytes) - if err := json.NewDecoder(c.Request.Body).Decode(req); err != nil { - response.BadRequest(c, "invalid request body") - return false - } - if strings.TrimSpace(req.BaseURL) == "" { - response.BadRequest(c, "base_url is required") - return false - } - if strings.TrimSpace(req.APIKey) == "" { - response.BadRequest(c, "api_key is required") - return false - } - if len(req.APIKey) > 8192 { - response.BadRequest(c, "api_key is too long") - return false - } - return true -} - -func forwardLLMTesterRequest(c *gin.Context, req llmTesterProxyRequest, method, resource string, body io.Reader) { - endpoint, err := buildLLMTesterEndpoint(req.BaseURL, resource) - if err != nil { - response.BadRequest(c, err.Error()) - return - } - - upstreamReq, err := http.NewRequestWithContext(c.Request.Context(), method, endpoint, body) - if err != nil { - response.BadRequest(c, "invalid upstream request") - return - } - upstreamReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(req.APIKey)) - upstreamReq.Header.Set("Accept", "application/json") - upstreamReq.Header.Set("User-Agent", "Sub2API-LLM-Tester/1.0") - upstreamReq.Header.Set("X-Title", "Sub2API LLM Tester") - if method == http.MethodPost { - upstreamReq.Header.Set("Content-Type", "application/json") - } - if origin := c.GetHeader("Origin"); origin != "" { - upstreamReq.Header.Set("HTTP-Referer", origin) - } - - upstreamResp, err := llmTesterHTTPClient.Do(upstreamReq) - if err != nil { - response.Error(c, http.StatusBadGateway, fmt.Sprintf("upstream request failed: %s", err.Error())) - return - } - defer func() { _ = upstreamResp.Body.Close() }() - - payload, err := readLLMTesterResponseBody(upstreamResp.Body) - if err != nil { - response.Error(c, http.StatusBadGateway, err.Error()) - return - } - - contentType := upstreamResp.Header.Get("Content-Type") - if contentType == "" { - contentType = "application/json" - } - c.Data(upstreamResp.StatusCode, contentType, payload) -} - -func buildLLMTesterEndpoint(baseURL, resource string) (string, error) { - normalized, err := urlvalidator.ValidateHTTPSURL(baseURL, urlvalidator.ValidationOptions{}) - if err != nil { - return "", err - } - parsed, err := url.Parse(normalized) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return "", errors.New("invalid base_url") - } - if parsed.User != nil { - return "", errors.New("base_url must not include user info") - } - if err := urlvalidator.ValidateResolvedIP(parsed.Hostname()); err != nil { - return "", err - } - parsed.RawQuery = "" - parsed.Fragment = "" - parsed.Path = strings.TrimRight(parsed.Path, "/") - if !llmTesterVersionPathPattern.MatchString(parsed.Path) { - parsed.Path = strings.TrimRight(parsed.Path, "/") + "/v1" - } - parsed.Path = strings.TrimRight(parsed.Path, "/") + "/" + strings.TrimLeft(resource, "/") - return parsed.String(), nil -} - -func readLLMTesterResponseBody(body io.Reader) ([]byte, error) { - limited := io.LimitReader(body, llmTesterMaxResponseBytes+1) - payload, err := io.ReadAll(limited) - if err != nil { - return nil, fmt.Errorf("failed to read upstream response: %w", err) - } - if len(payload) > llmTesterMaxResponseBytes { - return nil, errors.New("upstream response is too large") - } - return payload, nil -} - -func llmTesterSafeDialContext(ctx context.Context, network, address string) (net.Conn, error) { - host, port, err := net.SplitHostPort(address) - if err != nil { - return nil, err - } - if llmTesterBlockedHost(host) { - return nil, &net.AddrError{Err: "blocked by SSRF policy", Addr: address} - } - if ip := net.ParseIP(host); ip != nil { - if llmTesterBlockedIP(ip) { - return nil, &net.AddrError{Err: "blocked by SSRF policy", Addr: address} - } - return llmTesterDialer.DialContext(ctx, network, address) - } - - addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) - if err != nil { - return nil, err - } - if len(addrs) == 0 { - return nil, &net.AddrError{Err: "no addresses for host", Addr: host} - } - - var lastErr error - for _, addr := range addrs { - if llmTesterBlockedIP(addr.IP) { - lastErr = &net.AddrError{Err: "blocked by SSRF policy", Addr: addr.IP.String()} - continue - } - conn, err := llmTesterDialer.DialContext(ctx, network, net.JoinHostPort(addr.IP.String(), port)) - if err == nil { - return conn, nil - } - lastErr = err - } - if lastErr == nil { - lastErr = &net.AddrError{Err: "no usable addresses", Addr: host} - } - return nil, lastErr -} - -func llmTesterBlockedHost(host string) bool { - host = strings.ToLower(strings.TrimSpace(host)) - return host == "" || - host == "localhost" || - strings.HasSuffix(host, ".localhost") || - host == "metadata" || - host == "metadata.google.internal" || - host == "metadata.goog" || - host == "instance-data" || - host == "instance-data.ec2.internal" -} - -func llmTesterBlockedIP(ip net.IP) bool { - if ip == nil { - return true - } - if ip.IsUnspecified() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() || ip.IsPrivate() { - return true - } - for _, cidr := range llmTesterBlockedCIDRs { - if cidr.Contains(ip) { - return true - } - } - return false -} - -func mustParseLLMTesterCIDRs(raw []string) []*net.IPNet { - out := make([]*net.IPNet, 0, len(raw)) - for _, value := range raw { - _, cidr, err := net.ParseCIDR(value) - if err != nil { - panic("llm_tester: invalid blocked CIDR " + value + ": " + err.Error()) - } - out = append(out, cidr) - } - return out -} diff --git a/frontend/src/api/__tests__/llmTester.spec.ts b/frontend/src/api/__tests__/llmTester.spec.ts deleted file mode 100644 index e076f3ff0b..0000000000 --- a/frontend/src/api/__tests__/llmTester.spec.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - extractImageGenerationResult, - extractVideoGenerationResult, - getLLMTesterModelCapabilities, - isLikelyChatCompletionModelId, - parseModelList, -} from '@/api/llmTester' - -describe('LLM tester model filtering', () => { - it('keeps text chat and vision chat models from provider metadata', () => { - const models = parseModelList({ - data: [ - { - id: 'openai/gpt-4o', - name: 'GPT-4o', - architecture: { - modality: 'text+image->text', - input_modalities: ['text', 'image'], - output_modalities: ['text'], - }, - }, - { - id: 'anthropic/claude-sonnet', - architecture: { - modality: 'text->text', - output_modalities: ['text'], - }, - }, - ], - }) - - expect(models.map((model) => model.id)).toEqual([ - 'anthropic/claude-sonnet', - 'openai/gpt-4o', - ]) - }) - - it('keeps image-generation models while removing unsupported utility models', () => { - const models = parseModelList({ - data: [ - { - id: 'gpt-image-2', - architecture: { - modality: 'text+image->image', - output_modalities: ['image'], - }, - }, - { - id: 'text-embedding-3-small', - architecture: { - modality: 'text->embedding', - }, - }, - { - id: 'grok', - architecture: { - modality: 'text->text', - output_modalities: ['text'], - }, - }, - ], - }) - - expect(models.map((model) => model.id)).toEqual(['gpt-image-2', 'grok']) - expect(getLLMTesterModelCapabilities(models[0])).toContain('image_generation') - }) - - it('keeps Grok media models and classifies them by route capability', () => { - const models = parseModelList({ - data: [ - { id: 'grok-imagine', owned_by: 'xai' }, - { id: 'grok-imagine-image', owned_by: 'xai' }, - { id: 'grok-imagine-image-quality', owned_by: 'xai' }, - { id: 'grok-imagine-edit', owned_by: 'xai' }, - { id: 'grok-imagine-video', owned_by: 'xai' }, - { id: 'grok-imagine-video-1.5', owned_by: 'xai' }, - ], - }) - - expect(models.map((model) => model.id)).toEqual([ - 'grok-imagine', - 'grok-imagine-edit', - 'grok-imagine-image', - 'grok-imagine-image-quality', - 'grok-imagine-video', - 'grok-imagine-video-1.5', - ]) - expect(getLLMTesterModelCapabilities(models[0])).toEqual(['image_generation']) - expect(getLLMTesterModelCapabilities(models[4])).toEqual(['video_generation']) - }) - - it('uses id heuristics when simple OpenAI-compatible model rows omit metadata', () => { - expect(isLikelyChatCompletionModelId('gpt-5.4')).toBe(true) - expect(isLikelyChatCompletionModelId('gpt-image-2')).toBe(false) - expect(isLikelyChatCompletionModelId('grok-imagine-video-1.5')).toBe(false) - expect(isLikelyChatCompletionModelId('text-embedding-3-small')).toBe(false) - }) - - it('converts image generation responses into assistant attachments', () => { - const result = extractImageGenerationResult({ - data: [ - { - b64_json: 'abc123', - revised_prompt: 'A bright test image', - }, - ], - }) - - expect(result.text).toContain('Generated 1 image') - expect(result.text).toContain('A bright test image') - expect(result.attachments).toHaveLength(1) - expect(result.attachments[0].dataUrl).toBe('data:image/png;base64,abc123') - }) - - it('converts Responses image_generation_call results into assistant attachments', () => { - const result = extractImageGenerationResult({ - output: [ - { - type: 'image_generation_call', - result: 'a'.repeat(120), - }, - ], - }) - - expect(result.text).toContain('Generated 1 image') - expect(result.attachments).toHaveLength(1) - expect(result.attachments[0].dataUrl).toBe(`data:image/png;base64,${'a'.repeat(120)}`) - }) - - it('keeps generated image URLs from provider responses', () => { - const result = extractImageGenerationResult({ - output: [ - { - type: 'image_generation_call', - image_url: 'https://example.com/generated.png', - }, - ], - }) - - expect(result.attachments).toHaveLength(1) - expect(result.attachments[0].dataUrl).toBe('https://example.com/generated.png') - }) - - it('converts Responses SSE image output events into assistant attachments', () => { - const result = extractImageGenerationResult([ - 'data: {"type":"response.output_item.done","item":{"id":"ig_123","type":"image_generation_call","result":"aGVsbG8=","revised_prompt":"draw a cat","output_format":"png"}}', - '', - 'data: {"type":"response.completed","response":{"output":[]}}', - '', - 'data: [DONE]', - '', - ].join('\n')) - - expect(result.text).toContain('Generated 1 image') - expect(result.text).toContain('draw a cat') - expect(result.attachments).toHaveLength(1) - expect(result.attachments[0].dataUrl).toBe('data:image/png;base64,aGVsbG8=') - }) - - it('converts video generation responses into media attachments', () => { - const result = extractVideoGenerationResult({ - id: 'video_req_123', - status: 'completed', - data: [ - { - url: 'https://example.com/generated.mp4', - }, - ], - }) - - expect(result.text).toContain('Generated 1 video') - expect(result.text).toContain('Request ID: video_req_123') - expect(result.attachments).toHaveLength(1) - expect(result.attachments[0].kind).toBe('media') - expect(result.attachments[0].dataUrl).toBe('https://example.com/generated.mp4') - }) -}) diff --git a/frontend/src/api/llmTester.ts b/frontend/src/api/llmTester.ts deleted file mode 100644 index 960d2624ad..0000000000 --- a/frontend/src/api/llmTester.ts +++ /dev/null @@ -1,932 +0,0 @@ -import { buildApiUrl } from '@/api/client' - -export interface LLMTesterProfile { - id: string - name: string - provider: 'openrouter' | 'sub2api' | 'custom' - baseUrl: string - apiKey: string - selectedModel: string - lastFetchedAt?: string -} - -export interface LLMTesterModel { - id: string - name: string - ownedBy?: string - contextLength?: number - raw?: Record -} - -export type LLMTesterModelCapability = 'chat' | 'vision' | 'image_generation' | 'video_generation' - -export interface LLMTesterAttachment { - id: string - name: string - type: string - size: number - kind: 'image' | 'text' | 'media' | 'file' - dataUrl?: string - text?: string -} - -export interface LLMTesterMessage { - id: string - role: 'user' | 'assistant' - content: string - attachments?: LLMTesterAttachment[] -} - -export interface ChatCompletionOptions { - baseUrl: string - apiKey: string - model: string - messages: LLMTesterMessage[] - systemInstruction?: string - temperature?: number - maxTokens?: number - signal?: AbortSignal -} - -export interface ImageGenerationOptions { - baseUrl: string - apiKey: string - model: string - messages: LLMTesterMessage[] - systemInstruction?: string - signal?: AbortSignal -} - -export interface ImageGenerationResult { - text: string - attachments: LLMTesterAttachment[] - raw: unknown -} - -export type MediaGenerationResult = ImageGenerationResult - -interface OpenAIContentTextPart { - type: 'text' - text: string -} - -interface OpenAIContentImagePart { - type: 'image_url' - image_url: { - url: string - } -} - -type OpenAIMessageContent = string | Array - -interface OpenAIChatMessage { - role: 'system' | 'user' | 'assistant' - content: OpenAIMessageContent -} - -export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1' - -export function defaultSub2APIBaseUrl(): string { - return '/v1' -} - -export function normalizeBaseUrl(input: string): string { - const trimmed = input.trim().replace(/\/+$/, '') - if (!trimmed) return '' - if (/^https?:\/\//i.test(trimmed) || trimmed.startsWith('/')) return trimmed - return `https://${trimmed}` -} - -export type LLMTesterProxyPath = 'models' | 'chat/completions' | 'images/generations' | 'videos/generations' | 'responses' - -export function buildOpenAIEndpoint(baseUrl: string, path: LLMTesterProxyPath): string { - const normalized = normalizeBaseUrl(baseUrl) - if (!normalized) return '' - const resource = path.replace(/^v\d+\//, '') - if (/\/v\d+$/i.test(normalized)) return `${normalized}/${resource}` - return `${normalized}/v1/${resource}` -} - -function getHeaderSafeSiteTitle(): string { - if (typeof document === 'undefined') return 'Sub2API LLM Tester' - return document.title || 'Sub2API LLM Tester' -} - -function buildHeaders(apiKey: string): HeadersInit { - return { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - 'X-Title': getHeaderSafeSiteTitle(), - } -} - -function buildJsonHeaders(): HeadersInit { - return { - 'Content-Type': 'application/json', - } -} - -function getObject(value: unknown): Record | undefined { - return value && typeof value === 'object' ? value as Record : undefined -} - -function getString(value: unknown): string | undefined { - return typeof value === 'string' && value.trim() ? value : undefined -} - -function getNumber(value: unknown): number | undefined { - return typeof value === 'number' && Number.isFinite(value) ? value : undefined -} - -function getStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value - .map((item) => typeof item === 'string' ? item.trim().toLowerCase() : '') - .filter(Boolean) -} - -export function isLikelyChatCompletionModelId(modelId: string): boolean { - const id = modelId.trim().toLowerCase() - if (!id) return false - if (/(^|[/:-])(?:text-)?embedding/.test(id) || id.includes('embedding')) return false - if (/(^|[/:-])(?:gpt-)?image(?:-|$)/.test(id) || id.includes('/image-')) return false - if (isLikelyImageGenerationModelId(id) || isLikelyVideoGenerationModelId(id)) return false - if (id.includes('dall-e') || id.includes('whisper') || id.includes('tts')) return false - if (id.includes('moderation') || id.includes('omni-moderation')) return false - if (id.includes('transcribe') || id.includes('realtime')) return false - return true -} - -const GROK_IMAGE_MODEL_IDS = new Set([ - 'grok-imagine', - 'grok-imagine-image', - 'grok-imagine-image-quality', - 'grok-imagine-edit', -]) - -const GROK_VIDEO_MODEL_IDS = new Set([ - 'grok-imagine-video', - 'grok-imagine-video-1.5', -]) - -export function isLikelyImageGenerationModelId(modelId: string): boolean { - const id = modelId.trim().toLowerCase() - if (!id) return false - return ( - GROK_IMAGE_MODEL_IDS.has(id) || - /(^|[/:-])(?:gpt-)?image(?:-|$)/.test(id) || - id.includes('/image-') || - id.includes('dall-e') || - id.includes('imagen') - ) -} - -export function isLikelyVideoGenerationModelId(modelId: string): boolean { - const id = modelId.trim().toLowerCase() - if (!id) return false - return GROK_VIDEO_MODEL_IDS.has(id) || id.includes('video-generation') || /(^|[/:-])video(?:-|$)/.test(id) -} - -function splitModalities(value: string): string[] { - return value - .split(/[+,]/) - .map((part) => part.trim().toLowerCase()) - .filter(Boolean) -} - -function getModelModalities(model: LLMTesterModel): { input: string[]; output: string[] } { - const architecture = getObject(model.raw?.architecture) - const input = new Set(getStringArray(architecture?.input_modalities)) - const output = new Set(getStringArray(architecture?.output_modalities)) - - const modality = getString(architecture?.modality)?.toLowerCase() - if (modality?.includes('->')) { - const [inputSide, outputSide] = modality.split('->') - splitModalities(inputSide || '').forEach((item) => input.add(item)) - splitModalities(outputSide || '').forEach((item) => output.add(item)) - } - - return { - input: Array.from(input), - output: Array.from(output), - } -} - -function isKnownUnsupportedModelId(modelId: string): boolean { - const id = modelId.trim().toLowerCase() - return ( - /(^|[/:-])(?:text-)?embedding/.test(id) || - id.includes('embedding') || - id.includes('moderation') || - id.includes('omni-moderation') || - id.includes('whisper') || - id.includes('tts') || - id.includes('transcribe') || - id.includes('realtime') - ) -} - -export function getLLMTesterModelCapabilities(model: LLMTesterModel): LLMTesterModelCapability[] { - const capabilities = new Set() - const modalities = getModelModalities(model) - const hasOutputMetadata = modalities.output.length > 0 - const outputsText = modalities.output.includes('text') - const outputsImage = modalities.output.includes('image') || isLikelyImageGenerationModelId(model.id) - const outputsVideo = modalities.output.includes('video') || isLikelyVideoGenerationModelId(model.id) - const unsupportedByTester = isKnownUnsupportedModelId(model.id) - - if (outputsImage) { - capabilities.add('image_generation') - } - - if (outputsVideo) { - capabilities.add('video_generation') - } - - if (!unsupportedByTester && !outputsImage && !outputsVideo && (!hasOutputMetadata || outputsText)) { - capabilities.add('chat') - } - - if (capabilities.has('chat') && modalities.input.includes('image')) { - capabilities.add('vision') - } - - return Array.from(capabilities) -} - -export function isChatCompletionModel(model: LLMTesterModel): boolean { - return getLLMTesterModelCapabilities(model).includes('chat') -} - -export function isImageGenerationModel(model: LLMTesterModel): boolean { - return getLLMTesterModelCapabilities(model).includes('image_generation') -} - -export function isVideoGenerationModel(model: LLMTesterModel): boolean { - return getLLMTesterModelCapabilities(model).includes('video_generation') -} - -export function isLLMTesterSupportedModel(model: LLMTesterModel): boolean { - const capabilities = getLLMTesterModelCapabilities(model) - return capabilities.includes('chat') || capabilities.includes('image_generation') || capabilities.includes('video_generation') -} - -function extractErrorMessage(payload: unknown, fallback: string): string { - const obj = getObject(payload) - const errorObj = getObject(obj?.error) - return ( - getString(errorObj?.message) || - getString(obj?.message) || - getString(obj?.detail) || - fallback - ) -} - -async function parseResponsePayload(response: Response): Promise { - const contentType = response.headers.get('content-type') || '' - if (contentType.includes('application/json')) return response.json() - const text = await response.text() - try { - return JSON.parse(text) - } catch { - return text - } -} - -function unwrapApiEnvelope(payload: unknown): unknown { - const obj = getObject(payload) - if (!obj || !('code' in obj) || !('data' in obj)) return payload - return obj.data -} - -function shouldUseTesterProxy(baseUrl: string): boolean { - const normalized = normalizeBaseUrl(baseUrl) - if (!normalized || normalized.startsWith('/')) return false - if (typeof window === 'undefined') return true - try { - return new URL(normalized).origin !== window.location.origin - } catch { - return true - } -} - -async function postTesterProxy(path: LLMTesterProxyPath, body: Record, signal?: AbortSignal): Promise { - const response = await fetch(buildApiUrl(`/llm-tester/${path}`), { - method: 'POST', - headers: buildJsonHeaders(), - body: JSON.stringify(body), - signal, - }) - const payload = await parseResponsePayload(response) - if (!response.ok) { - const fallback = path === 'models' - ? `Failed to fetch models (${response.status})` - : path === 'videos/generations' - ? `Video generation failed (${response.status})` - : path === 'images/generations' || path === 'responses' - ? `Image generation failed (${response.status})` - : `Chat request failed (${response.status})` - throw new Error(extractErrorMessage(payload, fallback)) - } - return unwrapApiEnvelope(payload) -} - -export function parseModelList(payload: unknown): LLMTesterModel[] { - const obj = getObject(payload) - const data = Array.isArray(obj?.data) ? obj.data : Array.isArray(payload) ? payload : [] - - return data - .map((item): LLMTesterModel | null => { - const raw = getObject(item) - if (!raw) return null - - const id = getString(raw.id) || getString(raw.name) - if (!id) return null - - const topProvider = getObject(raw.top_provider) - return { - id, - name: getString(raw.name) || id, - ownedBy: getString(raw.owned_by) || getString(raw.ownedBy), - contextLength: getNumber(raw.context_length) || getNumber(raw.contextLength) || getNumber(topProvider?.context_length), - raw, - } - }) - .filter((model): model is LLMTesterModel => model !== null) - .filter(isLLMTesterSupportedModel) - .sort((a, b) => a.id.localeCompare(b.id)) -} - -export async function fetchLLMModels(baseUrl: string, apiKey: string, signal?: AbortSignal): Promise { - const endpoint = buildOpenAIEndpoint(baseUrl, 'models') - if (!endpoint) throw new Error('Base URL is required') - - if (shouldUseTesterProxy(baseUrl)) { - const payload = await postTesterProxy('models', { - base_url: normalizeBaseUrl(baseUrl), - api_key: apiKey, - }, signal) - return parseModelList(payload) - } - - const response = await fetch(endpoint, { - method: 'GET', - headers: buildHeaders(apiKey), - signal, - }) - const payload = await parseResponsePayload(response) - if (!response.ok) { - throw new Error(extractErrorMessage(payload, `Failed to fetch models (${response.status})`)) - } - - return parseModelList(payload) -} - -function inferLanguage(filename: string, type: string): string { - const lower = filename.toLowerCase() - const ext = lower.includes('.') ? lower.split('.').pop() || '' : '' - const byExt: Record = { - js: 'javascript', - jsx: 'jsx', - ts: 'typescript', - tsx: 'tsx', - vue: 'vue', - py: 'python', - go: 'go', - rs: 'rust', - java: 'java', - c: 'c', - cpp: 'cpp', - cs: 'csharp', - html: 'html', - css: 'css', - json: 'json', - md: 'markdown', - sh: 'bash', - sql: 'sql', - yml: 'yaml', - yaml: 'yaml', - xml: 'xml', - toml: 'toml', - csv: 'csv', - } - if (byExt[ext]) return byExt[ext] - if (type.includes('json')) return 'json' - if (type.includes('markdown')) return 'markdown' - if (type.includes('html')) return 'html' - return '' -} - -function formatTextAttachment(attachment: LLMTesterAttachment): string { - const language = inferLanguage(attachment.name, attachment.type) - return [ - `Attached file: ${attachment.name}`, - `\`\`\`${language}`, - attachment.text || '', - '```', - ].join('\n') -} - -function buildImageGenerationPrompt(messages: LLMTesterMessage[], systemInstruction = ''): string { - const latestUserMessage = [...messages].reverse().find((message) => message.role === 'user') - const attachments = latestUserMessage?.attachments || [] - const textAttachments = attachments.filter((attachment) => attachment.kind === 'text' && attachment.text) - const mediaAttachments = attachments.filter((attachment) => attachment.kind !== 'text') - - const sections = [ - systemInstruction.trim(), - latestUserMessage?.content.trim() || '', - ...textAttachments.map(formatTextAttachment), - ...mediaAttachments.map((attachment) => `Attached reference file: ${attachment.name} (${attachment.type || 'unknown type'}, ${attachment.size} bytes).`), - ].filter(Boolean) - - return sections.join('\n\n') -} - -function buildMediaGenerationPrompt(messages: LLMTesterMessage[], systemInstruction = ''): string { - return buildImageGenerationPrompt(messages, systemInstruction) -} - -function buildUserContent(message: LLMTesterMessage): OpenAIMessageContent { - const attachments = message.attachments || [] - const imageAttachments = attachments.filter((attachment) => attachment.kind === 'image' && attachment.dataUrl) - const textAttachments = attachments.filter((attachment) => attachment.kind === 'text' && attachment.text) - const otherAttachments = attachments.filter((attachment) => attachment.kind !== 'image' && attachment.kind !== 'text') - - const textParts = [ - message.content.trim(), - ...textAttachments.map(formatTextAttachment), - ...otherAttachments.map((attachment) => `Attached media: ${attachment.name} (${attachment.type || 'unknown type'}, ${attachment.size} bytes).`), - ].filter(Boolean) - - if (imageAttachments.length === 0) return textParts.join('\n\n') - - const content: Array = [] - content.push({ - type: 'text', - text: textParts.join('\n\n') || 'Please analyze the attached image.', - }) - - for (const attachment of imageAttachments) { - if (!attachment.dataUrl) continue - content.push({ - type: 'image_url', - image_url: { url: attachment.dataUrl }, - }) - } - - return content -} - -export function buildChatCompletionMessages(messages: LLMTesterMessage[], systemInstruction = ''): OpenAIChatMessage[] { - const out: OpenAIChatMessage[] = [] - const system = systemInstruction.trim() - if (system) { - out.push({ role: 'system', content: system }) - } - - for (const message of messages) { - out.push({ - role: message.role, - content: message.role === 'user' ? buildUserContent(message) : message.content, - }) - } - - return out -} - -export function extractChatCompletionText(payload: unknown): string { - const obj = getObject(payload) - const choices = Array.isArray(obj?.choices) ? obj.choices : [] - const firstChoice = getObject(choices[0]) - const message = getObject(firstChoice?.message) - const content = message?.content - - if (typeof content === 'string') return content - if (Array.isArray(content)) { - return content - .map((part) => { - const partObj = getObject(part) - return getString(partObj?.text) || getString(partObj?.content) || '' - }) - .filter(Boolean) - .join('\n') - } - - const text = getString(firstChoice?.text) - if (text) return text - - return JSON.stringify(payload, null, 2) -} - -export function extractImageGenerationResult(payload: unknown): ImageGenerationResult { - const attachments: LLMTesterAttachment[] = [] - const lines: string[] = [] - - const pushImageAttachment = (rawValue: unknown, index: number) => { - const value = normalizeGeneratedImageValue(rawValue) - if (!value) return - attachments.push({ - id: `generated-image-${Date.now()}-${index}`, - name: `generated-image-${index + 1}.png`, - type: 'image/png', - size: 0, - kind: 'image', - dataUrl: value, - }) - } - - const explicitImageResult = (value: unknown): unknown => { - const text = getString(value) - if (!text) return value - if (/^(?:data:image\/|https?:\/\/)/i.test(text)) return text - return `data:image/png;base64,${text}` - } - - const processOutputItem = (item: unknown) => { - const outputItem = getObject(item) - if (!outputItem) return - const type = getString(outputItem.type) - - if (type === 'image_generation_call') { - const b64 = getString(outputItem.b64_json) - pushImageAttachment(b64 ? `data:image/png;base64,${b64}` : explicitImageResult(outputItem.result) || outputItem.image_url || outputItem.url, attachments.length) - const revisedPrompt = getString(outputItem.revised_prompt) - if (revisedPrompt) { - lines.push(`Revised prompt: ${revisedPrompt}`) - } - } - - const content = Array.isArray(outputItem.content) ? outputItem.content : [] - content.forEach((part) => { - const partObj = getObject(part) - if (!partObj) return - const partType = getString(partObj.type) - const text = getString(partObj.text) - if (text && (partType === 'output_text' || partType === 'text')) { - lines.push(text) - } - const b64 = getString(partObj.b64_json) - pushImageAttachment(b64 ? `data:image/png;base64,${b64}` : explicitImageResult(partObj.result) || partObj.image_url || partObj.url, attachments.length) - }) - - const outputText = getString(outputItem.text) - if (outputText && type !== 'image_generation_call') { - lines.push(outputText) - } - } - - const processPayload = (rawPayload: unknown) => { - const obj = getObject(rawPayload) - if (!obj) return - - if (obj.item) { - processOutputItem(obj.item) - } - if (obj.response) { - processPayload(obj.response) - } - - const data = Array.isArray(obj.data) ? obj.data : [] - data.forEach((item, index) => { - const image = getObject(item) - if (!image) return - - const revisedPrompt = getString(image.revised_prompt) - if (revisedPrompt) { - lines.push(`Revised prompt: ${revisedPrompt}`) - } - - const b64 = getString(image.b64_json) - const url = getString(image.url) - pushImageAttachment(b64 ? `data:image/png;base64,${b64}` : url, index) - }) - - const output = Array.isArray(obj.output) ? obj.output : [] - output.forEach(processOutputItem) - } - - const payloads = typeof payload === 'string' ? parseEventStreamPayload(payload) : [payload] - payloads.forEach(processPayload) - - if (attachments.length > 0) { - lines.unshift(`Generated ${attachments.length} image${attachments.length === 1 ? '' : 's'}.`) - } - - return { - text: lines.join('\n\n') || JSON.stringify(payload, null, 2), - attachments, - raw: payload, - } -} - -function parseEventStreamPayload(payload: string): unknown[] { - const events: unknown[] = [] - const dataLines: string[] = [] - - const flush = () => { - const data = dataLines.join('\n').trim() - dataLines.length = 0 - if (!data || data === '[DONE]') return - try { - events.push(JSON.parse(data)) - } catch { - events.push(data) - } - } - - for (const line of payload.split(/\r?\n/)) { - if (line.startsWith('data:')) { - dataLines.push(line.slice(5).trimStart()) - continue - } - if (!line.trim()) { - flush() - } - } - flush() - - if (events.length > 0) return events - try { - return [JSON.parse(payload)] - } catch { - return [] - } -} - -function normalizeGeneratedImageValue(value: unknown): string { - if (typeof value === 'object' && value !== null) { - const obj = getObject(value) - return normalizeGeneratedImageValue(obj?.url || obj?.b64_json || obj?.result) - } - const text = getString(value) - if (!text) return '' - if (/^data:image\//i.test(text)) return text - if (/^https?:\/\//i.test(text)) return text - const compact = text.replace(/\s+/g, '') - if (compact.length > 100 && /^[A-Za-z0-9+/=]+$/.test(compact)) { - return `data:image/png;base64,${compact}` - } - return '' -} - -function normalizeGeneratedMediaValue(value: unknown): string { - if (typeof value === 'object' && value !== null) { - const obj = getObject(value) - return normalizeGeneratedMediaValue( - obj?.url || - obj?.video_url || - obj?.download_url || - obj?.b64_json || - obj?.base64 || - obj?.result - ) - } - const text = getString(value) - if (!text) return '' - if (/^data:video\//i.test(text)) return text - if (/^https?:\/\//i.test(text)) return text - const compact = text.replace(/\s+/g, '') - if (compact.length > 100 && /^[A-Za-z0-9+/=]+$/.test(compact)) { - return `data:video/mp4;base64,${compact}` - } - return '' -} - -export function extractVideoGenerationResult(payload: unknown): MediaGenerationResult { - const attachments: LLMTesterAttachment[] = [] - const lines: string[] = [] - - const pushVideoAttachment = (rawValue: unknown, index: number) => { - const value = normalizeGeneratedMediaValue(rawValue) - if (!value) return - attachments.push({ - id: `generated-video-${Date.now()}-${index}`, - name: `generated-video-${index + 1}.mp4`, - type: 'video/mp4', - size: 0, - kind: 'media', - dataUrl: value, - }) - } - - const processObject = (value: unknown) => { - const obj = getObject(value) - if (!obj) return - - const status = getString(obj.status) - if (status) lines.push(`Status: ${status}`) - const id = getString(obj.id) || getString(obj.request_id) - if (id) lines.push(`Request ID: ${id}`) - const revisedPrompt = getString(obj.revised_prompt) - if (revisedPrompt) lines.push(`Revised prompt: ${revisedPrompt}`) - - pushVideoAttachment(obj, attachments.length) - - const data = Array.isArray(obj.data) ? obj.data : [] - data.forEach((item) => { - processObject(item) - }) - - const output = Array.isArray(obj.output) ? obj.output : [] - output.forEach((item) => { - processObject(item) - }) - - const content = Array.isArray(obj.content) ? obj.content : [] - content.forEach((item) => { - const itemObj = getObject(item) - const text = getString(itemObj?.text) - if (text) lines.push(text) - processObject(item) - }) - } - - const payloads = typeof payload === 'string' ? parseEventStreamPayload(payload) : [payload] - payloads.forEach(processObject) - - const uniqueLines = Array.from(new Set(lines)) - if (attachments.length > 0) { - uniqueLines.unshift(`Generated ${attachments.length} video${attachments.length === 1 ? '' : 's'}.`) - } - - return { - text: uniqueLines.join('\n\n') || JSON.stringify(payload, null, 2), - attachments, - raw: payload, - } -} - -function imageToolModelId(model: string): string { - const trimmed = model.trim() - if (!trimmed) return 'gpt-image-2' - const parts = trimmed.split('/').filter(Boolean) - return parts[parts.length - 1] || trimmed -} - -function imageResponsesDriverModel(model: string): string { - return isLikelyImageGenerationModelId(model) ? 'gpt-5.4' : model -} - -function buildResponsesImageGenerationBody(model: string, prompt: string): Record { - return { - model: imageResponsesDriverModel(model), - stream: true, - tools: [ - { - type: 'image_generation', - model: imageToolModelId(model), - }, - ], - input: [ - { - role: 'user', - content: [ - { - type: 'input_text', - text: prompt, - }, - ], - }, - ], - } -} - -function isAbortError(error: unknown): boolean { - return error instanceof DOMException && error.name === 'AbortError' -} - -async function postOpenAIResource( - baseUrl: string, - apiKey: string, - path: LLMTesterProxyPath, - body: Record, - signal?: AbortSignal -): Promise { - const endpoint = buildOpenAIEndpoint(baseUrl, path) - if (!endpoint) throw new Error('Base URL is required') - - if (shouldUseTesterProxy(baseUrl)) { - return postTesterProxy(path, { - base_url: normalizeBaseUrl(baseUrl), - api_key: apiKey, - payload: body, - }, signal) - } - - const response = await fetch(endpoint, { - method: 'POST', - headers: buildHeaders(apiKey), - body: JSON.stringify(body), - signal, - }) - const payload = await parseResponsePayload(response) - if (!response.ok) { - const fallback = path === 'chat/completions' - ? `Chat request failed (${response.status})` - : path === 'videos/generations' - ? `Video generation failed (${response.status})` - : `Image generation failed (${response.status})` - throw new Error(extractErrorMessage(payload, fallback)) - } - - return payload -} - -export async function sendLLMChatCompletion(options: ChatCompletionOptions): Promise<{ text: string; raw: unknown }> { - const endpoint = buildOpenAIEndpoint(options.baseUrl, 'chat/completions') - if (!endpoint) throw new Error('Base URL is required') - - const body: Record = { - model: options.model, - messages: buildChatCompletionMessages(options.messages, options.systemInstruction), - stream: false, - } - - if (typeof options.temperature === 'number' && Number.isFinite(options.temperature)) { - body.temperature = options.temperature - } - if (typeof options.maxTokens === 'number' && Number.isFinite(options.maxTokens) && options.maxTokens > 0) { - body.max_tokens = Math.floor(options.maxTokens) - } - - if (shouldUseTesterProxy(options.baseUrl)) { - const payload = await postTesterProxy('chat/completions', { - base_url: normalizeBaseUrl(options.baseUrl), - api_key: options.apiKey, - payload: body, - }, options.signal) - return { - text: extractChatCompletionText(payload), - raw: payload, - } - } - - const response = await fetch(endpoint, { - method: 'POST', - headers: buildHeaders(options.apiKey), - body: JSON.stringify(body), - signal: options.signal, - }) - const payload = await parseResponsePayload(response) - if (!response.ok) { - throw new Error(extractErrorMessage(payload, `Chat request failed (${response.status})`)) - } - - return { - text: extractChatCompletionText(payload), - raw: payload, - } -} - -export async function sendLLMImageGeneration(options: ImageGenerationOptions): Promise { - const prompt = buildImageGenerationPrompt(options.messages, options.systemInstruction) - if (!prompt) throw new Error('Prompt is required for image generation') - - const body: Record = { - model: options.model, - prompt, - n: 1, - } - if (/^gpt-image-/i.test(imageToolModelId(options.model))) { - body.stream = true - } - - try { - const payload = await postOpenAIResource(options.baseUrl, options.apiKey, 'images/generations', body, options.signal) - return extractImageGenerationResult(payload) - } catch (primaryError) { - if (isAbortError(primaryError)) throw primaryError - - try { - const fallbackPayload = await postOpenAIResource( - options.baseUrl, - options.apiKey, - 'responses', - buildResponsesImageGenerationBody(options.model, prompt), - options.signal - ) - const fallbackResult = extractImageGenerationResult(fallbackPayload) - if (fallbackResult.attachments.length > 0) return fallbackResult - throw new Error('Responses image tool returned no image output') - } catch (fallbackError) { - if (isAbortError(fallbackError)) throw fallbackError - const primaryMessage = primaryError instanceof Error ? primaryError.message : 'Image endpoint failed' - const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : 'Responses fallback failed' - throw new Error(`${primaryMessage}; responses fallback failed: ${fallbackMessage}`) - } - } -} - -export async function sendLLMVideoGeneration(options: ImageGenerationOptions): Promise { - const prompt = buildMediaGenerationPrompt(options.messages, options.systemInstruction) - if (!prompt) throw new Error('Prompt is required for video generation') - - const body: Record = { - model: options.model, - prompt, - } - - const payload = await postOpenAIResource(options.baseUrl, options.apiKey, 'videos/generations', body, options.signal) - return extractVideoGenerationResult(payload) -} diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 8d48591fa0..3d7f1604c7 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -278,21 +278,6 @@ const KeyIcon = { ) } -const TesterIcon = { - render: () => - h( - 'svg', - { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, - [ - h('path', { - 'stroke-linecap': 'round', - 'stroke-linejoin': 'round', - d: 'M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z' - }) - ] - ) -} - const ChartIcon = { render: () => h( @@ -681,7 +666,6 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] { } items.push( { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, - { path: '/llm-tester', label: t('nav.llmTester'), icon: TesterIcon, hideInSimpleMode: true }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true }, { path: '/available-channels', label: t('nav.availableChannels'), icon: ChannelIcon, hideInSimpleMode: true, featureFlag: flagAvailableChannels }, { path: '/monitor', label: t('nav.channelStatus'), icon: SignalIcon, featureFlag: flagChannelMonitor }, @@ -789,7 +773,6 @@ const adminNavItems = computed((): NavItem[] => { if (authStore.isSimpleMode) { const filtered = visible.filter(item => !item.hideInSimpleMode) filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }) - filtered.push({ path: '/llm-tester', label: t('nav.llmTester'), icon: TesterIcon }) filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon }) for (const cm of customMenuItemsForAdmin.value) { filtered.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg }) diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index b430d99f76..244c6c8db2 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -141,13 +141,7 @@ const xaiModels = [ 'grok-latest', 'grok-build', 'grok-4.20-reasoning', - 'grok-4.20-non-reasoning', - 'grok-imagine', - 'grok-imagine-image', - 'grok-imagine-image-quality', - 'grok-imagine-edit', - 'grok-imagine-video', - 'grok-imagine-video-1.5' + 'grok-4.20-non-reasoning' ] // Cohere @@ -292,13 +286,7 @@ const grokPresetMappings = [ { label: 'Grok Latest', from: 'grok-latest', to: 'grok-4.3', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' }, { label: 'Build 0.1', from: 'grok-build', to: 'grok-build-0.1', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' }, { label: '4.20 Reasoning', from: 'grok-4.20-reasoning', to: 'grok-4.20-0309-reasoning', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' }, - { label: '4.20 Non Reasoning', from: 'grok-4.20-non-reasoning', to: 'grok-4.20-0309-non-reasoning', color: 'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400' }, - { label: 'Imagine', from: 'grok-imagine', to: 'grok-imagine', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-300' }, - { label: 'Image', from: 'grok-imagine-image', to: 'grok-imagine-image', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-300' }, - { label: 'Image Quality', from: 'grok-imagine-image-quality', to: 'grok-imagine-image-quality', color: 'bg-fuchsia-100 text-fuchsia-700 hover:bg-fuchsia-200 dark:bg-fuchsia-900/30 dark:text-fuchsia-300' }, - { label: 'Edit', from: 'grok-imagine-edit', to: 'grok-imagine-edit', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-300' }, - { label: 'Video', from: 'grok-imagine-video', to: 'grok-imagine-video', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300' }, - { label: 'Video 1.5', from: 'grok-imagine-video-1.5', to: 'grok-imagine-video-1.5', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-300' } + { label: '4.20 Non Reasoning', from: 'grok-4.20-non-reasoning', to: 'grok-4.20-0309-non-reasoning', color: 'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400' } ] // Antigravity 预设映射(支持通配符) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 4b31f6fa5e..941c2d71ae 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -394,7 +394,6 @@ export default { dashboard: 'Dashboard', announcements: 'Announcements', apiKeys: 'API Keys', - llmTester: 'LLM Tester', usage: 'Usage', redeem: 'Redeem', affiliate: 'Affiliate Rebates', @@ -1130,79 +1129,6 @@ export default { } }, - llmTester: { - title: 'LLM Tester', - description: 'Save OpenAI-compatible endpoints, fetch models, and run multimodal chat tests', - profile: 'Profile', - newProfile: 'New profile', - provider: 'Provider', - customProvider: 'Custom', - profileNamePlaceholder: 'OpenRouter staging', - baseUrl: 'Base URL', - apiKey: 'API Key', - showKey: 'Show key', - hideKey: 'Hide key', - model: 'Model', - selectModel: 'Select a model', - searchModels: 'Search fetched models...', - fetchModels: 'Fetch Models', - modelCount: '{count} models fetched', - lastFetched: 'Fetched {time}', - localStorageNotice: 'Keys stay in this browser', - savedProfiles: 'Saved Profiles', - noProfiles: 'No saved profiles', - requestOptions: 'Request Options', - temperature: 'Temperature', - maxTokens: 'Max Tokens', - systemInstruction: 'System Instruction', - systemInstructionPlaceholder: 'Optional', - chat: 'Chat', - noModelSelected: 'No model selected', - clearChat: 'Clear', - cancel: 'Cancel', - emptyChatTitle: 'Ready for a test message', - emptyChatDescription: 'Select a model, attach images or code, and send a prompt.', - you: 'You', - assistant: 'Assistant', - thinking: 'Thinking...', - attachFiles: 'Attach files', - openAttachment: 'Open', - downloadAttachment: 'Download', - removeAttachment: 'Remove', - promptPlaceholder: 'Ask anything, paste code, or attach an image...', - imagePromptPlaceholder: 'Describe the image you want to generate...', - videoPromptPlaceholder: 'Describe the video you want to generate...', - send: 'Send', - profileSaved: 'Profile saved', - profileDeleted: 'Profile deleted', - modelsFetched: 'Fetched {count} models', - capabilities: { - chat: 'Chat', - vision: 'Vision chat', - imageGeneration: 'Image generation', - videoGeneration: 'Video generation' - }, - errors: { - loadFailed: 'Failed to load saved profiles', - saveFailed: 'Failed to save profiles', - nameRequired: 'Profile name is required', - baseUrlRequired: 'Base URL is required', - apiKeyRequired: 'API Key is required', - modelsFailed: 'Failed to fetch models', - chatFailed: 'Chat request failed', - unsupportedModel: 'This model is not supported by the tester yet.', - unsupportedChatModel: 'This tester only supports text chat models. Pick a chat-capable model.', - imagePromptRequired: 'Add a text prompt before generating an image.', - videoPromptRequired: 'Add a text prompt before generating a video.', - openUnavailable: 'Unable to open this attachment', - downloadUnavailable: 'Unable to download this attachment', - cancelled: 'Request cancelled', - imageTooLarge: '{name} is larger than the 5 MB image limit', - textTooLarge: '{name} is larger than the 240 KB text limit', - fileReadFailed: 'Failed to read {name}' - } - }, - affiliate: { title: 'Affiliate Rebates', description: 'Invite new users and convert your rebate quota into account balance', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 5f38f67a00..c9d2c6fbd4 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -394,7 +394,6 @@ export default { dashboard: '仪表盘', announcements: '公告', apiKeys: 'API 密钥', - llmTester: 'LLM 测试器', usage: '使用记录', redeem: '兑换', affiliate: '邀请返利', @@ -1134,79 +1133,6 @@ export default { } }, - llmTester: { - title: 'LLM 测试器', - description: '保存 OpenAI 兼容端点,拉取模型列表,并进行多模态聊天测试', - profile: '配置', - newProfile: '新配置', - provider: '服务商', - customProvider: '自定义', - profileNamePlaceholder: 'OpenRouter 测试', - baseUrl: 'Base URL', - apiKey: 'API Key', - showKey: '显示密钥', - hideKey: '隐藏密钥', - model: '模型', - selectModel: '选择模型', - searchModels: '搜索已拉取模型...', - fetchModels: '拉取模型', - modelCount: '已拉取 {count} 个模型', - lastFetched: '拉取时间 {time}', - localStorageNotice: '密钥仅保存在此浏览器', - savedProfiles: '已保存配置', - noProfiles: '暂无保存配置', - requestOptions: '请求选项', - temperature: 'Temperature', - maxTokens: 'Max Tokens', - systemInstruction: 'System Instruction', - systemInstructionPlaceholder: '可选', - chat: '聊天', - noModelSelected: '未选择模型', - clearChat: '清空', - cancel: '取消', - emptyChatTitle: '可以开始测试', - emptyChatDescription: '选择模型,附加图片或代码,然后发送提示词。', - you: '你', - assistant: '助手', - thinking: '思考中...', - attachFiles: '附加文件', - openAttachment: '打开', - downloadAttachment: '下载', - removeAttachment: '移除', - promptPlaceholder: '输入问题、粘贴代码,或附加图片...', - imagePromptPlaceholder: '描述你想生成的图片...', - videoPromptPlaceholder: '描述你想生成的视频...', - send: '发送', - profileSaved: '配置已保存', - profileDeleted: '配置已删除', - modelsFetched: '已拉取 {count} 个模型', - capabilities: { - chat: '聊天', - vision: '视觉聊天', - imageGeneration: '图片生成', - videoGeneration: '视频生成' - }, - errors: { - loadFailed: '加载保存配置失败', - saveFailed: '保存配置失败', - nameRequired: '请输入配置名称', - baseUrlRequired: '请输入 Base URL', - apiKeyRequired: '请输入 API Key', - modelsFailed: '拉取模型失败', - chatFailed: '聊天请求失败', - unsupportedModel: '此测试器暂不支持该模型。', - unsupportedChatModel: '此测试器仅支持文本聊天模型,请选择可聊天的模型。', - imagePromptRequired: '生成图片前请先输入文本提示词。', - videoPromptRequired: '生成视频前请先输入文本提示词。', - openUnavailable: '无法打开此附件', - downloadUnavailable: '无法下载此附件', - cancelled: '请求已取消', - imageTooLarge: '{name} 超过 5 MB 图片限制', - textTooLarge: '{name} 超过 240 KB 文本限制', - fileReadFailed: '读取 {name} 失败' - } - }, - affiliate: { title: '邀请返利', description: '邀请新用户注册,并将返利额度转入账户余额', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 069371d841..8721efd70a 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -205,18 +205,6 @@ const routes: RouteRecordRaw[] = [ descriptionKey: 'keys.description' } }, - { - path: '/llm-tester', - name: 'LLMTester', - component: () => import('@/views/user/LLMTesterView.vue'), - meta: { - requiresAuth: false, - requiresAdmin: false, - title: 'LLM Tester', - titleKey: 'llmTester.title', - descriptionKey: 'llmTester.description' - } - }, { path: '/usage', name: 'Usage', @@ -702,7 +690,7 @@ let authInitialized = false const navigationLoading = useNavigationLoadingState() // 延迟初始化预加载,传入 router 实例 let routePrefetch: ReturnType | null = null -const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/llm-tester', '/setup', '/payment/result', '/payment/airwallex', '/legal'] +const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup', '/payment/result', '/payment/airwallex', '/legal'] const BACKEND_MODE_CALLBACK_PATHS = [ '/auth/callback', '/auth/linuxdo/callback', diff --git a/frontend/src/views/user/LLMTesterView.vue b/frontend/src/views/user/LLMTesterView.vue deleted file mode 100644 index 56eab507b2..0000000000 --- a/frontend/src/views/user/LLMTesterView.vue +++ /dev/null @@ -1,1157 +0,0 @@ - - - - - diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 430ed952b6..3877070453 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,10 +2,6 @@ import { defineConfig, loadEnv, Plugin } from 'vite' import vue from '@vitejs/plugin-vue' import checker from 'vite-plugin-checker' import { resolve } from 'path' -import { Buffer } from 'node:buffer' -import { lookup } from 'node:dns/promises' -import type { IncomingMessage, ServerResponse } from 'node:http' -import { isIP } from 'node:net' /** * Vite 插件:开发模式下注入公开配置到 index.html @@ -38,203 +34,6 @@ function injectPublicSettings(backendUrl: string): Plugin { } } -const LLM_TESTER_MAX_BODY_BYTES = 12 * 1024 * 1024 -const LLM_TESTER_TIMEOUT_MS = 300000 - -function llmTesterDevProxy(): Plugin { - return { - name: 'llm-tester-dev-proxy', - apply: 'serve', - configureServer(server) { - server.middlewares.use(async (req, res, next) => { - const pathname = new URL(req.url || '/', 'http://localhost').pathname - if (req.method !== 'POST' || !pathname.startsWith('/api/v1/llm-tester/')) { - next() - return - } - - try { - const body = await readDevProxyJson(req) - const route = pathname.slice('/api/v1/llm-tester/'.length) - if (route === 'models') { - await forwardDevLLMTesterRequest(res, body, 'GET', 'models') - return - } - if (route === 'chat/completions') { - await forwardDevLLMTesterRequest(res, body, 'POST', 'chat/completions') - return - } - if (route === 'images/generations') { - await forwardDevLLMTesterRequest(res, body, 'POST', 'images/generations') - return - } - if (route === 'responses') { - await forwardDevLLMTesterRequest(res, body, 'POST', 'responses') - return - } - next() - } catch (error) { - writeDevProxyError(res, 502, devProxyErrorMessage(error)) - } - }) - } - } -} - -async function readDevProxyJson(req: IncomingMessage): Promise> { - const chunks: Buffer[] = [] - let total = 0 - for await (const chunk of req) { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) - total += buffer.length - if (total > LLM_TESTER_MAX_BODY_BYTES) { - throw new Error('request body is too large') - } - chunks.push(buffer) - } - - try { - const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')) - return parsed && typeof parsed === 'object' ? parsed : {} - } catch { - throw new Error('invalid request body') - } -} - -async function forwardDevLLMTesterRequest( - res: ServerResponse, - body: Record, - method: 'GET' | 'POST', - resource: 'models' | 'chat/completions' | 'images/generations' | 'responses' -) { - const baseUrl = String(body.base_url || '').trim() - const apiKey = String(body.api_key || '').trim() - if (!baseUrl) { - writeDevProxyError(res, 400, 'base_url is required') - return - } - if (!apiKey) { - writeDevProxyError(res, 400, 'api_key is required') - return - } - if (apiKey.length > 8192) { - writeDevProxyError(res, 400, 'api_key is too long') - return - } - if (method === 'POST' && !body.payload) { - writeDevProxyError(res, 400, 'payload is required') - return - } - - const endpoint = await buildDevLLMTesterEndpoint(baseUrl, resource) - const upstream = await fetch(endpoint, { - method, - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - 'User-Agent': 'Sub2API-LLM-Tester/1.0', - 'X-Title': 'Sub2API LLM Tester' - }, - body: method === 'POST' ? JSON.stringify(body.payload || {}) : undefined, - signal: AbortSignal.timeout(LLM_TESTER_TIMEOUT_MS) - }) - const payload = Buffer.from(await upstream.arrayBuffer()) - if (payload.length > LLM_TESTER_MAX_BODY_BYTES) { - writeDevProxyError(res, 502, 'upstream response is too large') - return - } - - res.statusCode = upstream.status - res.setHeader('Content-Type', upstream.headers.get('content-type') || 'application/json') - res.end(payload) -} - -async function buildDevLLMTesterEndpoint(baseUrl: string, resource: 'models' | 'chat/completions' | 'images/generations' | 'responses'): Promise { - const url = new URL(baseUrl.replace(/\/+$/, '')) - if (url.protocol !== 'https:') { - throw new Error('base_url must use https') - } - if (url.username || url.password) { - throw new Error('base_url must not include user info') - } - await assertDevProxyPublicHost(url.hostname) - url.search = '' - url.hash = '' - if (!/\/v\d+$/i.test(url.pathname)) { - url.pathname = `${url.pathname.replace(/\/+$/, '')}/v1` - } - url.pathname = `${url.pathname.replace(/\/+$/, '')}/${resource}` - return url.toString() -} - -async function assertDevProxyPublicHost(hostname: string) { - const host = hostname.trim().toLowerCase() - if (isBlockedDevProxyHost(host)) { - throw new Error(`host is not allowed: ${hostname}`) - } - if (isIP(host)) { - if (isBlockedDevProxyIP(host)) throw new Error(`host is not allowed: ${hostname}`) - return - } - const addrs = await lookup(host, { all: true, verbatim: false }) - if (!addrs.length) { - throw new Error(`host did not resolve: ${hostname}`) - } - for (const addr of addrs) { - if (isBlockedDevProxyIP(addr.address)) { - throw new Error(`resolved ip is not allowed: ${addr.address}`) - } - } -} - -function isBlockedDevProxyHost(host: string): boolean { - return ( - !host || - host === 'localhost' || - host.endsWith('.localhost') || - host === 'metadata' || - host === 'metadata.google.internal' || - host === 'metadata.goog' || - host === 'instance-data' || - host === 'instance-data.ec2.internal' - ) -} - -function isBlockedDevProxyIP(address: string): boolean { - if (address.includes(':')) { - const lower = address.toLowerCase() - return lower === '::' || lower === '::1' || lower.startsWith('fc') || lower.startsWith('fd') || lower.startsWith('fe80') - } - const parts = address.split('.').map((part) => Number(part)) - if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) return true - const [a, b] = parts - return ( - a === 0 || - a === 10 || - a === 127 || - (a === 100 && b >= 64 && b <= 127) || - (a === 169 && b === 254) || - (a === 172 && b >= 16 && b <= 31) || - (a === 192 && b === 168) - ) -} - -function writeDevProxyError(res: ServerResponse, status: number, message: string) { - res.statusCode = status - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify({ code: status, message })) -} - -function devProxyErrorMessage(error: unknown): string { - const message = error instanceof Error ? error.message : 'LLM tester proxy failed' - const cause = error instanceof Error ? (error as Error & { cause?: unknown }).cause : undefined - if (cause instanceof Error && cause.message && cause.message !== message) { - return `${message}: ${cause.message}` - } - return message || 'LLM tester proxy failed' -} - export default defineConfig(({ mode }) => { // 加载环境变量 const env = loadEnv(mode, process.cwd(), '') @@ -247,8 +46,7 @@ export default defineConfig(({ mode }) => { checker({ vueTsc: true }), - injectPublicSettings(backendUrl), - llmTesterDevProxy() + injectPublicSettings(backendUrl) ], resolve: { alias: { From aac3261c69d8a1613c8c9017e143f642cbd910e3 Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Wed, 1 Jul 2026 16:31:33 +0800 Subject: [PATCH 8/8] fix: convert grok image edit uploads --- backend/internal/service/grok_media.go | 68 +++++++++++++++++++ .../service/openai_gateway_grok_test.go | 52 ++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index 07e8c57bc1..fb05b64b01 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -292,6 +292,11 @@ func (s *OpenAIGatewayService) ForwardGrokMedia( return nil, err } + body, contentType, err = prepareGrokMediaForwardBody(endpoint, body, contentType) + if err != nil { + return nil, err + } + var bodyReader io.Reader if endpoint.RequiresRequestBody() { bodyReader = bytes.NewReader(body) @@ -356,6 +361,69 @@ func (s *OpenAIGatewayService) ForwardGrokMedia( }, nil } +func prepareGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, contentType string) ([]byte, string, error) { + if endpoint != GrokMediaEndpointImagesEdits || gjson.ValidBytes(body) { + return body, contentType, nil + } + mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(contentType)) + if err != nil || !strings.EqualFold(mediaType, "multipart/form-data") { + return body, contentType, nil + } + + info := ParseGrokMediaRequest(contentType, body) + payload := make(map[string]any) + if info.Model != "" { + payload["model"] = info.Model + } + if info.Prompt != "" { + payload["prompt"] = info.Prompt + } + if info.N > 1 { + payload["n"] = info.N + } + if info.Size != "" { + payload["size"] = info.Size + } + + images := make([]map[string]string, 0, len(info.InputImageURLs)+len(info.Uploads)) + for _, imageURL := range info.InputImageURLs { + if imageURL = strings.TrimSpace(imageURL); imageURL != "" { + images = append(images, map[string]string{"image_url": imageURL}) + } + } + for _, upload := range info.Uploads { + dataURL, err := openAIImageUploadToDataURL(upload) + if err != nil { + return nil, "", err + } + images = append(images, map[string]string{"image_url": dataURL}) + } + if len(images) > 0 { + payload["image"] = images[0] + if len(images) > 1 { + payload["images"] = images + } + } + + maskImageURL := strings.TrimSpace(info.MaskImageURL) + if info.MaskUpload != nil { + dataURL, err := openAIImageUploadToDataURL(*info.MaskUpload) + if err != nil { + return nil, "", err + } + maskImageURL = dataURL + } + if maskImageURL != "" { + payload["mask"] = map[string]string{"image_url": maskImageURL} + } + + out, err := marshalOpenAIUpstreamJSON(payload) + if err != nil { + return nil, "", err + } + return out, "application/json", nil +} + type grokMediaUsageMetadata struct { ResponseID string Usage OpenAIUsage diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index a095243f57..ad088be725 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -229,6 +229,58 @@ func TestForwardGrokMediaImagesGenerationPassthrough(t *testing.T) { require.Equal(t, ImageBillingSize2K, result.ImageSize) } +func TestForwardGrokMediaImagesEditMultipartConvertsToJSON(t *testing.T) { + t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") + gin.SetMode(gin.TestMode) + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + require.NoError(t, writer.WriteField("model", "grok-imagine-edit")) + require.NoError(t, writer.WriteField("prompt", "edit this private image")) + partHeader := textproto.MIMEHeader{} + partHeader.Set("Content-Disposition", `form-data; name="image"; filename="input.png"`) + partHeader.Set("Content-Type", "image/png") + part, err := writer.CreatePart(partHeader) + require.NoError(t, err) + _, err = part.Write([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewReader(buf.Bytes())) + c.Request.Header.Set("Content-Type", writer.FormDataContentType()) + + account := &Account{ + ID: 62, + Name: "grok", + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "api-key", + "base_url": "https://xai.test/v1", + }, + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + Body: io.NopCloser(strings.NewReader(`{"data":[]}`)), + }} + svc := &OpenAIGatewayService{httpUpstream: upstream} + + _, err = svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointImagesEdits, "", buf.Bytes(), writer.FormDataContentType()) + require.NoError(t, err) + require.Equal(t, "https://xai.test/v1/images/edits", upstream.lastReq.URL.String()) + require.Equal(t, "application/json", upstream.lastReq.Header.Get("Content-Type")) + require.True(t, json.Valid(upstream.lastBody)) + require.Equal(t, "grok-imagine-edit", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "edit this private image", gjson.GetBytes(upstream.lastBody, "prompt").String()) + require.True(t, strings.HasPrefix(gjson.GetBytes(upstream.lastBody, "image.image_url").String(), "data:image/png;base64,")) +} + func TestForwardGrokMediaVideoGenerationReturnsUsageAndResponseID(t *testing.T) { t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") gin.SetMode(gin.TestMode)