From 165b072908004bbffd2b465137f46dc0bd767617 Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Sat, 8 Aug 2026 01:07:27 +0800 Subject: [PATCH] fix(grok): gateway media/voice routing, models, and status UI polish Align gateway Grok media/voice paths and model lists, harden upstream failure and quota handling, clear non-Grok video generation config migration, and polish temp-unsched/status indicators with model whitelist updates. --- backend/internal/pkg/xai/models.go | 11 +- backend/internal/pkg/xai/models_test.go | 3 +- backend/internal/server/api_contract_test.go | 2 + backend/internal/server/routes/gateway.go | 16 ++ .../internal/server/routes/gateway_test.go | 28 +++ .../prompt_audit_route_coverage_test.go | 1 + backend/internal/service/domain_constants.go | 4 + backend/internal/service/gateway_service.go | 2 +- backend/internal/service/grok_media.go | 115 +++++++++--- .../internal/service/grok_observed_models.go | 30 ++- backend/internal/service/grok_p2_test.go | 10 +- .../internal/service/grok_quota_service.go | 43 +++-- .../service/grok_quota_service_test.go | 77 ++++++++ backend/internal/service/grok_search_count.go | 43 ++++- .../service/grok_search_count_test.go | 23 +++ .../internal/service/grok_spending_reauth.go | 20 +- .../internal/service/grok_upstream_failure.go | 25 +-- .../service/grok_upstream_failure_test.go | 22 ++- .../openai_gateway_chat_completions_raw.go | 6 +- .../internal/service/openai_gateway_grok.go | 174 +++++++++++++++++- .../openai_gateway_grok_chat_bridge.go | 2 +- .../service/openai_gateway_grok_test.go | 127 ++++++++++--- .../service/openai_gateway_messages.go | 4 +- .../internal/service/openai_ws_http_bridge.go | 2 +- backend/internal/service/temp_unsched.go | 15 +- backend/internal/service/upstream_models.go | 6 +- ...clear_non_grok_video_generation_config.sql | 16 ++ .../account/AccountStatusIndicator.vue | 29 ++- .../account/TempUnschedStatusModal.vue | 28 ++- .../__tests__/useModelWhitelist.spec.ts | 3 +- frontend/src/composables/useModelWhitelist.ts | 10 +- 31 files changed, 744 insertions(+), 153 deletions(-) create mode 100644 backend/migrations/220_clear_non_grok_video_generation_config.sql diff --git a/backend/internal/pkg/xai/models.go b/backend/internal/pkg/xai/models.go index ae4b8ac9d5..e108e5deb3 100644 --- a/backend/internal/pkg/xai/models.go +++ b/backend/internal/pkg/xai/models.go @@ -88,7 +88,6 @@ var defaultModels = []Model{ {ID: "grok-3-mini", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 3 Mini"}, {ID: "grok-3-mini-fast", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 3 Mini Fast"}, {ID: "grok-build-0.1", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Build 0.1"}, - {ID: "grok-code-fast-1-0825", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Code Fast"}, {ID: "grok-composer-2.5-fast", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Composer 2.5 Fast"}, {ID: "grok-4.20-0309-reasoning", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Reasoning"}, {ID: "grok-4.20-0309-non-reasoning", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Non Reasoning"}, @@ -96,7 +95,6 @@ var defaultModels = []Model{ // Imagine {ID: DefaultImagineImageQualityModel, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Image Quality"}, {ID: DefaultImagineImageFastModel, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Image"}, - {ID: "grok-imagine-edit", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Edit"}, {ID: DefaultImagineVideoModel, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video"}, {ID: DefaultImagineVideo15Model, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video 1.5 Preview"}, {ID: DefaultImagineVideo15LegacyModel, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video 1.5 Legacy"}, @@ -120,9 +118,6 @@ var grokTextResponsesModelAliases = map[string]string{ "grok-composer-2.5-fast": "grok-composer-2.5-fast", "grok-composer": "grok-composer-2.5-fast", "composer-2.5": "grok-composer-2.5-fast", - "grok-code-fast": "grok-code-fast-1-0825", - "grok-code-fast-1": "grok-code-fast-1-0825", - "grok-code-fast-1-0825": "grok-code-fast-1-0825", "grok-4.20-reasoning": "grok-4.20-0309-reasoning", "grok-4.20-0309-reasoning": "grok-4.20-0309-reasoning", "grok-4.20-non-reasoning": "grok-4.20-0309-non-reasoning", @@ -173,9 +168,9 @@ func ModelMappingWithOptions(opts ModelMappingOptions) map[string]string { // Imagine aliases / legacy IDs → official catalog. mapping["grok-imagine"] = DefaultImagineImageQualityModel mapping["grok-imagine-1"] = DefaultImagineImageQualityModel - // edit keeps its own id when listed; alias bare names to quality for clients - // that only send grok-imagine-edit without catalog awareness. - mapping["grok-imagine-edit"] = "grok-imagine-edit" + // Backward-compatible client alias; xAI exposes image editing through the + // image-quality model rather than a separate grok-imagine-edit model. + mapping["grok-imagine-edit"] = DefaultImagineImageQualityModel mapping["grok-imagine-image"] = DefaultImagineImageFastModel mapping["grok-imagine-image-quality"] = DefaultImagineImageQualityModel // Keep official IDs as identity so client-requested model strings are not diff --git a/backend/internal/pkg/xai/models_test.go b/backend/internal/pkg/xai/models_test.go index 84d147fdb9..98aef08cfc 100644 --- a/backend/internal/pkg/xai/models_test.go +++ b/backend/internal/pkg/xai/models_test.go @@ -15,7 +15,8 @@ func TestDefaultModelMappingExcludesCrossClientWildcards(t *testing.T) { require.Equal(t, "grok-4.5", mapping["grok"]) require.Equal(t, "grok-4.5", mapping["grok-latest"]) require.Equal(t, "grok-build-0.1", mapping["grok-build"]) - require.Equal(t, "grok-4.5", mapping["grok-build-latest"]) + require.Equal(t, DefaultTextModel, mapping["grok-build-latest"]) + require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine-edit"]) require.Equal(t, DefaultImagineVideo15LegacyModel, mapping["grok-imagine-video-1.5"]) require.Equal(t, DefaultImagineVideo15Model, mapping["grok-imagine-video-1.5-preview"]) require.Equal(t, "grok-4.5", mapping["xai/grok"]) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 5d832c655e..fe89084829 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -885,6 +885,7 @@ func TestAPIContracts(t *testing.T) { "home_content": "", "hide_ccs_import_button": false, "grok_default_text_model": "grok-4.5", + "grok_default_base_url_mode": "cli", "grok_cross_client_model_map_enabled": false, "purchase_subscription_enabled": false, "purchase_subscription_url": "", @@ -1163,6 +1164,7 @@ func TestAPIContracts(t *testing.T) { "home_content": "", "hide_ccs_import_button": false, "grok_default_text_model": "grok-4.5", + "grok_default_base_url_mode": "cli", "grok_cross_client_model_map_enabled": false, "purchase_subscription_enabled": false, "purchase_subscription_url": "", diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index b5d5649143..fd8ce8d5c3 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -256,9 +256,18 @@ func RegisterGatewayRoutes( gateway.POST("/images/batches/:id/cancel", h.BatchImage.Cancel) gateway.DELETE("/images/batches/:id", h.BatchImage.DeleteRecord) gateway.DELETE("/images/batches/:id/outputs", h.BatchImage.DeleteOutputs) + // OpenAI-compatible clients may create through /videos; xAI receives the + // canonical /videos/generations route inside the Grok media forwarder. + gateway.POST("/videos", videoGenerationHandler) gateway.POST("/videos/generations", videoGenerationHandler) gateway.POST("/videos/edits", videoEditHandler) gateway.POST("/videos/extensions", videoExtensionHandler) + gateway.GET("/videos/generations/:request_id/content", videoContentHandler) + gateway.GET("/videos/edits/:request_id/content", videoContentHandler) + gateway.GET("/videos/extensions/:request_id/content", videoContentHandler) + gateway.GET("/videos/generations/:request_id", videoStatusHandler) + gateway.GET("/videos/edits/:request_id", videoStatusHandler) + gateway.GET("/videos/extensions/:request_id", videoStatusHandler) gateway.GET("/videos/:request_id", videoStatusHandler) gateway.GET("/videos/:request_id/content", videoContentHandler) @@ -366,9 +375,16 @@ func RegisterGatewayRoutes( r.POST("/images/generations/async", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.AsyncImage.Submit) r.POST("/images/edits/async", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.AsyncImage.Submit) r.GET("/images/tasks/:task_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.AsyncImage.Get) + r.POST("/videos", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoGenerationHandler) r.POST("/videos/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoGenerationHandler) r.POST("/videos/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoEditHandler) r.POST("/videos/extensions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoExtensionHandler) + r.GET("/videos/generations/:request_id/content", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoContentHandler) + r.GET("/videos/edits/:request_id/content", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoContentHandler) + r.GET("/videos/extensions/:request_id/content", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoContentHandler) + r.GET("/videos/generations/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoStatusHandler) + r.GET("/videos/edits/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoStatusHandler) + r.GET("/videos/extensions/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoStatusHandler) r.GET("/videos/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoStatusHandler) r.GET("/videos/:request_id/content", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoContentHandler) diff --git a/backend/internal/server/routes/gateway_test.go b/backend/internal/server/routes/gateway_test.go index ada931e3a5..0fefb73adf 100644 --- a/backend/internal/server/routes/gateway_test.go +++ b/backend/internal/server/routes/gateway_test.go @@ -152,6 +152,8 @@ func TestGatewayRoutesGrokImagesAndVideosPathsAreRegistered(t *testing.T) { "/images/generations", "/images/edits", "/v1/videos/generations", + "/v1/videos", + "/videos", "/videos/generations", "/v1/videos/edits", "/videos/edits", @@ -170,8 +172,20 @@ func TestGatewayRoutesGrokImagesAndVideosPathsAreRegistered(t *testing.T) { for _, path := range []string{ "/v1/videos/request-123", "/videos/request-123", + "/v1/videos/generations/request-123", + "/videos/generations/request-123", + "/v1/videos/edits/request-123", + "/videos/edits/request-123", + "/v1/videos/extensions/request-123", + "/videos/extensions/request-123", "/v1/videos/request-123/content", "/videos/request-123/content", + "/v1/videos/generations/request-123/content", + "/videos/generations/request-123/content", + "/v1/videos/edits/request-123/content", + "/videos/edits/request-123/content", + "/v1/videos/extensions/request-123/content", + "/videos/extensions/request-123/content", } { req := httptest.NewRequest(http.MethodGet, path, nil) w := httptest.NewRecorder() @@ -241,6 +255,8 @@ func TestGatewayRoutesNonGrokVideosAreRejectedAtPlatformGate(t *testing.T) { body string }{ {http.MethodPost, "/v1/videos/generations", `{"model":"grok-imagine-video-1.5","prompt":"waves"}`}, + {http.MethodPost, "/v1/videos", `{"model":"grok-imagine-video-1.5","prompt":"waves"}`}, + {http.MethodPost, "/videos", `{"model":"grok-imagine-video-1.5","prompt":"waves"}`}, {http.MethodPost, "/videos/generations", `{"model":"grok-imagine-video-1.5","prompt":"waves"}`}, {http.MethodPost, "/v1/videos/edits", `{"model":"grok-imagine-video","prompt":"waves","video":{"url":"https://example.com/in.mp4"}}`}, {http.MethodPost, "/videos/edits", `{"model":"grok-imagine-video","prompt":"waves","video":{"url":"https://example.com/in.mp4"}}`}, @@ -248,8 +264,20 @@ func TestGatewayRoutesNonGrokVideosAreRejectedAtPlatformGate(t *testing.T) { {http.MethodPost, "/videos/extensions", `{"model":"grok-imagine-video","prompt":"waves","video":{"url":"https://example.com/in.mp4"}}`}, {http.MethodGet, "/v1/videos/request-123", ""}, {http.MethodGet, "/videos/request-123", ""}, + {http.MethodGet, "/v1/videos/generations/request-123", ""}, + {http.MethodGet, "/videos/generations/request-123", ""}, + {http.MethodGet, "/v1/videos/edits/request-123", ""}, + {http.MethodGet, "/videos/edits/request-123", ""}, + {http.MethodGet, "/v1/videos/extensions/request-123", ""}, + {http.MethodGet, "/videos/extensions/request-123", ""}, {http.MethodGet, "/v1/videos/request-123/content", ""}, {http.MethodGet, "/videos/request-123/content", ""}, + {http.MethodGet, "/v1/videos/generations/request-123/content", ""}, + {http.MethodGet, "/videos/generations/request-123/content", ""}, + {http.MethodGet, "/v1/videos/edits/request-123/content", ""}, + {http.MethodGet, "/videos/edits/request-123/content", ""}, + {http.MethodGet, "/v1/videos/extensions/request-123/content", ""}, + {http.MethodGet, "/videos/extensions/request-123/content", ""}, } { req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) req.Header.Set("Content-Type", "application/json") diff --git a/backend/internal/server/routes/prompt_audit_route_coverage_test.go b/backend/internal/server/routes/prompt_audit_route_coverage_test.go index 4c0b583980..e28e2760d4 100644 --- a/backend/internal/server/routes/prompt_audit_route_coverage_test.go +++ b/backend/internal/server/routes/prompt_audit_route_coverage_test.go @@ -41,6 +41,7 @@ func TestEveryGatewayPOSTRouteIsClassifiedForPromptAuditCoverage(t *testing.T) { "/images/generations/async": {"image_task_handler.go"}, "/images/edits/async": {"image_task_handler.go"}, "/images/batches": {"batch_image_handler.go"}, + "/videos": {"grok_media.go"}, "/videos/generations": {"grok_media.go"}, "/videos/edits": {"grok_media.go"}, "/videos/extensions": {"grok_media.go"}, diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 333f3ffe57..3557f1d2a1 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -417,6 +417,10 @@ const ( // can reach Grok groups. Default false (no silent cross-vendor rewrite). SettingKeyGrokCrossClientModelMapEnabled = "grok_cross_client_model_map_enabled" + // SettingKeyGrokDefaultBaseURLMode controls the default text upstream for + // Grok accounts without an explicit credentials.base_url. + SettingKeyGrokDefaultBaseURLMode = "grok_default_base_url_mode" + // SettingKeyAvailableChannelsEnabled is a DB-backed soft switch for the "Available Channels" // user-facing aggregate view. When false: user endpoint returns an empty list and the // sidebar entry is hidden. Defaults to false (opt-in feature). diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index d6f71e3e64..dd8f775bfe 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -1284,7 +1284,7 @@ func (s *GatewayService) DoGrokNativeResponsesJSON(ctx context.Context, account Reason: GatewayFailureReason("grok_search_token"), } } - targetURL, err := buildGrokResponsesURL(account, nil) + targetURL, err := buildGrokResponsesURL(account, nil, s.settingService) if err != nil { return nil, err } diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index ad5316b1b9..e0f6aa3d77 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -31,6 +31,9 @@ const ( GrokMediaEndpointVideosExtensions GrokMediaEndpoint = "videos_extensions" GrokMediaEndpointVideoStatus GrokMediaEndpoint = "video_status" GrokMediaEndpointVideoContent GrokMediaEndpoint = "video_content" + + // Official xAI Imagine image-edit limit. + grokMediaMaxEditSourceImages = 3 ) func (e GrokMediaEndpoint) RequiresRequestBody() bool { @@ -153,28 +156,12 @@ func parseGrokMediaJSONRequest(body []byte, info *GrokMediaRequestInfo) { switch { case value.IsArray(): for _, item := range value.Array() { - if imageURL := grokMediaJSONImageURL(item); imageURL != "" { - info.InputImageURLs = append(info.InputImageURLs, imageURL) - continue - } - if item.Type == gjson.String { - imageURL := strings.TrimSpace(item.String()) - if imageURL == "" { - continue - } + if imageURL := extractGrokMediaImageURL(item); imageURL != "" { info.InputImageURLs = append(info.InputImageURLs, imageURL) } } default: - if imageURL := grokMediaJSONImageURL(value); imageURL != "" { - info.InputImageURLs = append(info.InputImageURLs, imageURL) - return - } - if value.Type == gjson.String { - imageURL := strings.TrimSpace(value.String()) - if imageURL == "" { - return - } + if imageURL := extractGrokMediaImageURL(value); imageURL != "" { info.InputImageURLs = append(info.InputImageURLs, imageURL) } } @@ -182,16 +169,34 @@ func parseGrokMediaJSONRequest(body []byte, info *GrokMediaRequestInfo) { appendJSONImageURLs(gjson.GetBytes(body, "image")) appendJSONImageURLs(gjson.GetBytes(body, "images")) appendJSONImageURLs(gjson.GetBytes(body, "reference_images")) - info.MaskImageURL = grokMediaJSONImageURL(gjson.GetBytes(body, "mask")) + info.MaskImageURL = extractGrokMediaImageURL(gjson.GetBytes(body, "mask")) } -func grokMediaJSONImageURL(value gjson.Result) string { +func extractGrokMediaImageURL(value gjson.Result) string { + if !value.Exists() { + return "" + } + if value.Type == gjson.String { + return strings.TrimSpace(value.String()) + } if imageURL := strings.TrimSpace(value.Get("url").String()); imageURL != "" { return imageURL } + if nested := value.Get("image_url"); nested.Exists() { + if nested.Type == gjson.String { + return strings.TrimSpace(nested.String()) + } + if imageURL := strings.TrimSpace(nested.Get("url").String()); imageURL != "" { + return imageURL + } + } return strings.TrimSpace(value.Get("image_url").String()) } +func grokMediaImageObject(imageURL string) map[string]string { + return map[string]string{"url": imageURL, "type": "image_url"} +} + func parseGrokMediaMultipartRequest(contentType string, body []byte, info *GrokMediaRequestInfo) { if info == nil { return @@ -872,9 +877,13 @@ func isGrokCLIProxyTarget(rawURL string) bool { } func prepareGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, contentType string) ([]byte, string, error) { - if endpoint != GrokMediaEndpointImagesEdits || gjson.ValidBytes(body) { + if endpoint != GrokMediaEndpointImagesEdits { return body, contentType, nil } + if gjson.ValidBytes(body) { + out, err := normalizeGrokMediaJSONImageRefs(body) + return out, contentType, err + } mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(contentType)) if err != nil || !strings.EqualFold(mediaType, "multipart/form-data") { return body, contentType, nil @@ -898,7 +907,7 @@ func prepareGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, conten 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{"url": imageURL}) + images = append(images, grokMediaImageObject(imageURL)) } } for _, upload := range info.Uploads { @@ -906,7 +915,10 @@ func prepareGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, conten if err != nil { return nil, "", err } - images = append(images, map[string]string{"url": dataURL}) + images = append(images, grokMediaImageObject(dataURL)) + } + if len(images) > grokMediaMaxEditSourceImages { + return nil, "", fmt.Errorf("a maximum of %d source images is supported for image edits", grokMediaMaxEditSourceImages) } if len(images) > 0 { payload["image"] = images[0] @@ -924,7 +936,7 @@ func prepareGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, conten maskImageURL = dataURL } if maskImageURL != "" { - payload["mask"] = map[string]string{"url": maskImageURL} + payload["mask"] = grokMediaImageObject(maskImageURL) } out, err := marshalOpenAIUpstreamJSON(payload) @@ -934,6 +946,53 @@ func prepareGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, conten return out, "application/json", nil } +func normalizeGrokMediaJSONImageRefs(body []byte) ([]byte, error) { + info := ParseGrokMediaRequest("application/json", body) + if len(info.InputImageURLs) > grokMediaMaxEditSourceImages { + return nil, fmt.Errorf("a maximum of %d source images is supported for image edits", grokMediaMaxEditSourceImages) + } + out := body + var err error + for _, field := range []string{"image", "images", "mask"} { + out, err = rewriteGrokMediaJSONImageField(out, field) + if err != nil { + return nil, err + } + } + return out, nil +} + +func rewriteGrokMediaJSONImageField(body []byte, path string) ([]byte, error) { + value := gjson.GetBytes(body, path) + if !value.Exists() { + return body, nil + } + if value.IsArray() { + rewritten := make([]map[string]string, 0, len(value.Array())) + for _, item := range value.Array() { + imageURL := extractGrokMediaImageURL(item) + if imageURL == "" { + return body, nil + } + rewritten = append(rewritten, grokMediaImageObject(imageURL)) + } + out, err := sjson.SetBytes(body, path, rewritten) + if err != nil { + return nil, fmt.Errorf("rewrite grok media %s: %w", path, err) + } + return out, nil + } + imageURL := extractGrokMediaImageURL(value) + if imageURL == "" { + return body, nil + } + out, err := sjson.SetBytes(body, path, grokMediaImageObject(imageURL)) + if err != nil { + return nil, fmt.Errorf("rewrite grok media %s: %w", path, err) + } + return out, nil +} + func normalizeGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, contentType string) ([]byte, string, error) { if !endpoint.RequiresRequestBody() || !gjson.ValidBytes(body) { return body, contentType, nil @@ -1043,9 +1102,11 @@ func NormalizeGrokMediaModelForEndpoint(endpoint GrokMediaEndpoint, model string return "grok-imagine-image-quality" } case GrokMediaEndpointVideosGenerations: - if model == "grok-imagine-video-1.5" && !hasInputImage { - return "grok-imagine-video" - } + // xAI's 1.5 model is image-to-video only. Keep the requested model + // unchanged when the image is missing so the upstream returns its + // documented invalid-argument response instead of silently switching + // models and pricing. + _ = hasInputImage } return model } diff --git a/backend/internal/service/grok_observed_models.go b/backend/internal/service/grok_observed_models.go index 247b98ff42..9d554b5674 100644 --- a/backend/internal/service/grok_observed_models.go +++ b/backend/internal/service/grok_observed_models.go @@ -72,23 +72,39 @@ func (s *GrokQuotaService) syncGrokObservedModels(ctx context.Context, account * return nil } baseURL := strings.TrimSpace(account.GetGrokBaseURL()) + if s.settingService != nil { + baseURL = strings.TrimSpace(s.settingService.ResolveGrokBaseURL(ctx, account)) + } if baseURL == "" { baseURL = xai.DefaultCLIBaseURL } - // DefaultCLIBaseURL already ends with /v1; other bases may be bare hosts. - url := strings.TrimRight(baseURL, "/") - if strings.HasSuffix(url, "/v1") { - url += "/models" - } else { - url += "/v1/models" + validator, err := grokBaseURLValidator(account, s.cfg) + if err != nil { + return err } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + validatedBaseURL, err := validator(baseURL) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, buildOpenAIModelsURL(validatedBaseURL), nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", grokUpstreamUserAgent) + if account.IsGrokOAuth() { + applyGrokCLIHeaders(req.Header) + if isGrokCLIProxyTarget(req.URL.String()) { + if userID := strings.TrimSpace(account.GetCredential("sub")); userID != "" { + req.Header.Set("X-UserID", userID) + } + if email := strings.TrimSpace(account.GetCredential("email")); email != "" { + req.Header.Set("X-Email", email) + } + } + } + account.ApplyHeaderOverrides(req.Header) proxyURL := "" if s.proxyRepo != nil && account.ProxyID != nil { diff --git a/backend/internal/service/grok_p2_test.go b/backend/internal/service/grok_p2_test.go index a3b4daa1bf..b9bb4f5020 100644 --- a/backend/internal/service/grok_p2_test.go +++ b/backend/internal/service/grok_p2_test.go @@ -87,7 +87,7 @@ func TestApplyGrokUpstreamFailure_ModelSpecificFreeUsage(t *testing.T) { require.False(t, isGrokModelQuotaBlocked(account.ID, "grok-4.3", time.Now())) } -func TestApplyGrokUpstreamFailure_SpendingLimitMarksReauth(t *testing.T) { +func TestApplyGrokUpstreamFailure_SpendingLimitRemainsRecoverable(t *testing.T) { repo := &grokQuotaAccountRepo{} svc := &OpenAIGatewayService{accountRepo: repo} account := &Account{ID: 9110, Platform: PlatformGrok, Type: AccountTypeOAuth} @@ -95,8 +95,8 @@ func TestApplyGrokUpstreamFailure_SpendingLimitMarksReauth(t *testing.T) { svc.handleGrokAccountUpstreamError(context.Background(), account, 403, nil, body) - require.Equal(t, 1, repo.tempUnschedCalls) - require.Equal(t, "grok spending limit", repo.lastTempUnschedReason) - // Long cool for spending - require.Greater(t, repo.lastTempUnschedUntil, time.Now().Add(23*time.Hour)) + require.Equal(t, 1, repo.rateLimitedCalls) + require.Zero(t, repo.tempUnschedCalls) + // Without a billing-period snapshot, use a short recoverable probe cooldown. + require.WithinDuration(t, time.Now().Add(grokSpendingLimitProbeCooldown), repo.lastRateLimitResetAt, 2*time.Second) } diff --git a/backend/internal/service/grok_quota_service.go b/backend/internal/service/grok_quota_service.go index e037fed26a..6658888a59 100644 --- a/backend/internal/service/grok_quota_service.go +++ b/backend/internal/service/grok_quota_service.go @@ -50,13 +50,14 @@ type GrokQuotaResetResult struct { } type GrokQuotaService struct { - accountRepo AccountRepository - proxyRepo ProxyRepository - tokenProvider *GrokTokenProvider - httpUpstream HTTPUpstream - usageLogRepo UsageLogRepository - cfg *config.Config - probeFlight singleflight.Group + accountRepo AccountRepository + proxyRepo ProxyRepository + tokenProvider *GrokTokenProvider + httpUpstream HTTPUpstream + usageLogRepo UsageLogRepository + settingService *SettingService + cfg *config.Config + probeFlight singleflight.Group } func NewGrokQuotaService( @@ -81,6 +82,12 @@ func NewGrokQuotaService( } } +func (s *GrokQuotaService) SetSettingService(settingService *SettingService) { + if s != nil { + s.settingService = settingService + } +} + // QueryQuota combines xAI billing data with an active quota-header probe for // Free accounts, whose billing response does not include usage_percent. func (s *GrokQuotaService) QueryQuota(ctx context.Context, accountID int64) (*GrokQuotaProbeResult, error) { @@ -147,7 +154,7 @@ func (s *GrokQuotaService) probeUsage(ctx context.Context, accountID int64) (*Gr if err != nil { return nil, infraerrors.Newf(http.StatusBadRequest, "GROK_QUOTA_PROBE_BODY_ERROR", "failed to build probe body: %v", err) } - targetURL, err := buildGrokResponsesURL(account, s.cfg) + targetURL, err := buildGrokResponsesURL(account, s.cfg, s.settingService) if err != nil { return nil, infraerrors.Newf(http.StatusBadRequest, "GROK_QUOTA_BASE_URL_INVALID", "invalid Grok base_url: %v", err) } @@ -178,9 +185,21 @@ func (s *GrokQuotaService) probeUsage(ctx context.Context, accountID int64) (*Gr if limited { normalizeGrokExhaustedWindowResets(snapshot, resetAt, time.Now()) } - persistErr := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ - grokQuotaSnapshotExtraKey: snapshot, - }) + // A failed probe must not erase a previously observed snapshot. 401/403 and + // transport/server errors commonly carry no quota headers; only successful + // responses, or 429 responses with useful rate-limit headers, are safe to + // persist. A successful 200 with no headers is still persisted as an + // explicit "no headers" observation so the UI can distinguish it from never + // probed. + persistErr := error(nil) + persisted := false + shouldPersist := resp.StatusCode < 400 || resp.StatusCode == http.StatusTooManyRequests + if shouldPersist && (snapshot.HeadersObserved || resp.StatusCode == http.StatusOK) { + persistErr = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ + grokQuotaSnapshotExtraKey: snapshot, + }) + persisted = persistErr == nil + } if limited { persistGrokRateLimit(ctx, s.accountRepo, account, resetAt) } else if isSuccessfulGrokRateLimitRecovery(account, snapshot) { @@ -195,7 +214,7 @@ func (s *GrokQuotaService) probeUsage(ctx context.Context, accountID int64) (*Gr HeadersObserved: snapshot.HeadersObserved, ResetSupported: false, FetchedAt: time.Now().Unix(), - Persisted: persistErr == nil, + Persisted: persisted, } if resp.StatusCode == http.StatusTooManyRequests { return result, nil diff --git a/backend/internal/service/grok_quota_service_test.go b/backend/internal/service/grok_quota_service_test.go index 9ffb70f000..70ee2c8d72 100644 --- a/backend/internal/service/grok_quota_service_test.go +++ b/backend/internal/service/grok_quota_service_test.go @@ -15,6 +15,7 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" @@ -86,6 +87,60 @@ func (r *grokQuotaAccountRepo) SetTempUnschedulable(_ context.Context, id int64, return nil } +func TestSyncGrokObservedModelsRejectsOAuthCustomURLOutsideOperatorPolicy(t *testing.T) { + account := &Account{ + ID: 901, + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "secret-token", + "base_url": "https://blocked.example.test/v1", + }, + } + repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{account.ID: account}, + }} + upstream := &httpUpstreamRecorder{} + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = true + cfg.Security.URLAllowlist.UpstreamHosts = []string{"allowed.example.test"} + svc := &GrokQuotaService{accountRepo: repo, httpUpstream: upstream, cfg: cfg} + + err := svc.syncGrokObservedModels(context.Background(), account) + require.ErrorContains(t, err, "base URL rejected by URL security policy") + require.Nil(t, upstream.lastReq) +} + +func TestSyncGrokObservedModelsUsesCLIIdentityAndAccountHeaders(t *testing.T) { + account := &Account{ + ID: 902, + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "secret-token", + "sub": "user-902", + "email": "user902@example.test", + }, + } + repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{account.ID: account}, + }} + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"grok-4.5"}]}`)), + }} + svc := &GrokQuotaService{accountRepo: repo, httpUpstream: upstream, cfg: &config.Config{}} + + require.NoError(t, svc.syncGrokObservedModels(context.Background(), account)) + require.Equal(t, xai.DefaultCLIBaseURL+"/models", upstream.lastReq.URL.String()) + require.NotEmpty(t, upstream.lastReq.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIClientIdentifier, upstream.lastReq.Header.Get("x-grok-client-identifier")) + require.Equal(t, "interactive", upstream.lastReq.Header.Get("X-Grok-Client-Mode")) + require.Equal(t, "user-902", upstream.lastReq.Header.Get("X-UserID")) + require.Equal(t, "user902@example.test", upstream.lastReq.Header.Get("X-Email")) + require.Contains(t, repo.updates[account.ID], grokObservedModelsExtraKey) +} + type grokQuotaProxyRepo struct { proxyRepoStub proxies map[int64]*Proxy @@ -610,6 +665,28 @@ func TestGrokQuotaServiceProbeUsageStoresNoHeadersState(t *testing.T) { require.Equal(t, observedResetAt, repo.recoveryObservedReset) } +func TestGrokQuotaServiceProbeUsageDoesNotOverwriteSnapshotOnUnauthorized(t *testing.T) { + t.Parallel() + + account := healthyGrokQuotaOAuthAccount(44) + previous := &xai.QuotaSnapshot{StatusCode: http.StatusOK, HeadersObserved: true} + account.Extra = map[string]any{grokQuotaSnapshotExtraKey: previous} + repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{account.ID: account}, + }} + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: http.Header{}, + Body: io.NopCloser(strings.NewReader(`{"error":"unauthorized"}`)), + }} + svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream, nil) + + _, err := svc.ProbeUsage(context.Background(), account.ID) + require.Error(t, err) + require.Equal(t, 0, repo.updateCalls) + require.Same(t, previous, account.Extra[grokQuotaSnapshotExtraKey]) +} + func TestGrokQuotaServiceProbeUsageReturnsRateLimitedSnapshot(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/grok_search_count.go b/backend/internal/service/grok_search_count.go index 70c46c835a..fdcba0ed23 100644 --- a/backend/internal/service/grok_search_count.go +++ b/backend/internal/service/grok_search_count.go @@ -1,6 +1,7 @@ package service import ( + "strconv" "strings" "github.com/tidwall/gjson" @@ -14,10 +15,14 @@ func countGrokNativeSearchCallsFromJSONBytes(body []byte) int { if len(body) == 0 || !gjson.ValidBytes(body) { return 0 } - count := 0 - count += countGrokNativeSearchCallsInOutputArray(gjson.GetBytes(body, "output")) - count += countGrokNativeSearchCallsInOutputArray(gjson.GetBytes(body, "response.output")) - return count + // Responses envelopes normally expose either top-level output (JSON mode) + // or response.output (terminal SSE payload). Compatibility layers can retain + // both copies; counting both would bill the same search twice. Prefer the + // canonical nested response when present and fall back to top-level output. + if nested := gjson.GetBytes(body, "response.output"); nested.IsArray() { + return countGrokNativeSearchCallsInOutputArray(nested) + } + return countGrokNativeSearchCallsInOutputArray(gjson.GetBytes(body, "output")) } func countGrokNativeSearchCallsFromSSEBody(body string) int { @@ -65,6 +70,7 @@ func countGrokNativeSearchCallsInSSEDataDedup(data []byte, seen map[string]struc } added := 0 local := make(map[string]struct{}, len(keys)) + isItemDone := strings.TrimSpace(gjson.GetBytes(data, "type").String()) == "response.output_item.done" for _, k := range keys { if k == "" { continue @@ -74,7 +80,23 @@ func countGrokNativeSearchCallsInSSEDataDedup(data []byte, seen map[string]struc } local[k] = struct{}{} if _, ok := seen[k]; ok { - continue + if !isItemDone || !strings.HasPrefix(k, "synth:") { + continue + } + // Each id-less item.done is a distinct completed invocation. Advance + // its ordinal so interrupted streams remain accurately billable. + separator := strings.LastIndexByte(k, ':') + if separator < 0 { + continue + } + base := k[:separator] + for ordinal := 2; ; ordinal++ { + candidate := base + ":" + strconv.Itoa(ordinal) + if _, exists := seen[candidate]; !exists { + k = candidate + break + } + } } seen[k] = struct{}{} added++ @@ -95,6 +117,7 @@ func collectGrokNativeSearchCallKeys(data []byte) []string { } } var keys []string + syntheticOrdinals := make(map[string]int) consider := func(item gjson.Result) { if !isGrokNativeSearchOutputItem(item) { return @@ -106,10 +129,14 @@ func collectGrokNativeSearchCallKeys(data []byte) []string { strings.TrimSpace(item.Get("item.id").String()), ) if key == "" { - // Synthetic fingerprint: type + name is stable across done/completed - // for the same tool invocation when upstream omits call_id. - key = "synth:" + strings.ToLower(strings.TrimSpace(item.Get("type").String())) + + // Include the ordinal among same-kind calls. A plain type:name key + // collapses two id-less web searches in one completed response into + // one charge. The ordinal remains stable between ordered item.done + // events and response.completed output. + base := "synth:" + strings.ToLower(strings.TrimSpace(item.Get("type").String())) + ":" + strings.ToLower(strings.TrimSpace(item.Get("name").String())) + syntheticOrdinals[base]++ + key = base + ":" + strconv.Itoa(syntheticOrdinals[base]) } keys = append(keys, key) } diff --git a/backend/internal/service/grok_search_count_test.go b/backend/internal/service/grok_search_count_test.go index 799bd051d7..8bb0a2c79e 100644 --- a/backend/internal/service/grok_search_count_test.go +++ b/backend/internal/service/grok_search_count_test.go @@ -19,6 +19,18 @@ func TestCountGrokNativeSearchCallsFromJSONBytes(t *testing.T) { require.Equal(t, 3, countGrokNativeSearchCallsFromJSONBytes(body)) } +func TestCountGrokNativeSearchCallsFromJSONBytes_PrefersNestedResponse(t *testing.T) { + t.Parallel() + body := []byte(`{"output":[{"type":"web_search_call","id":"duplicate"}],"response":{"output":[{"type":"web_search_call","id":"duplicate"},{"type":"x_search_call","id":"xs1"}]}}`) + require.Equal(t, 2, countGrokNativeSearchCallsFromJSONBytes(body)) +} + +func TestCountGrokNativeSearchCallsFromJSONBytes_FallsBackWhenNestedOutputNull(t *testing.T) { + t.Parallel() + body := []byte(`{"output":[{"type":"web_search_call","id":"ws1"}],"response":{"output":null}}`) + require.Equal(t, 1, countGrokNativeSearchCallsFromJSONBytes(body)) +} + func TestCountGrokNativeSearchCallsFromSSEBodyDedups(t *testing.T) { t.Parallel() sse := stringsJoin( @@ -53,6 +65,17 @@ func TestCountGrokNativeSearchCallsInSSEDataDedup_NoIDStillDedups(t *testing.T) require.Equal(t, 0, countGrokNativeSearchCallsInSSEDataDedup(completed, seen)) } +func TestCountGrokNativeSearchCallsInSSEDataDedup_MultipleNoIDCalls(t *testing.T) { + t.Parallel() + seen := make(map[string]struct{}) + firstDone := []byte(`{"type":"response.output_item.done","item":{"type":"web_search_call"}}`) + secondDone := []byte(`{"type":"response.output_item.done","item":{"type":"web_search_call"}}`) + completed := []byte(`{"type":"response.completed","response":{"output":[{"type":"web_search_call"},{"type":"web_search_call"}]}}`) + require.Equal(t, 1, countGrokNativeSearchCallsInSSEDataDedup(firstDone, seen)) + require.Equal(t, 1, countGrokNativeSearchCallsInSSEDataDedup(secondDone, seen)) + require.Equal(t, 0, countGrokNativeSearchCallsInSSEDataDedup(completed, seen)) +} + func stringsJoin(lines ...string) string { out := "" for _, l := range lines { diff --git a/backend/internal/service/grok_spending_reauth.go b/backend/internal/service/grok_spending_reauth.go index 14adf62cde..f17de36e13 100644 --- a/backend/internal/service/grok_spending_reauth.go +++ b/backend/internal/service/grok_spending_reauth.go @@ -7,13 +7,27 @@ import ( "time" ) -// Spending-limit cools stay long (billing period), and the account is marked -// StatusError with a stable reauth-oriented message so the admin UI surfaces -// needs_reauth / ReAuth entry points. We do not hard-delete accounts. +// Spending-limit is recoverable at the end of the observed billing period. +// When no billing snapshot is available, use a short probe rather than +// fabricating a 24h boundary from the error arrival time. const grokSpendingLimitCooldown = 24 * time.Hour +const grokSpendingLimitProbeCooldown = 10 * time.Minute const grokSpendingLimitErrorMessage = "Grok spending limit reached; reauthorize or wait for billing reset" +func grokSpendingLimitResetAt(account *Account, now time.Time) time.Time { + if account != nil { + if billing, err := grokBillingSnapshotFromExtra(account.Extra); err == nil && billing != nil { + for _, raw := range []string{billing.PeriodEnd, billing.BillingPeriodEnd} { + if resetAt, err := time.Parse(time.RFC3339, strings.TrimSpace(raw)); err == nil && resetAt.After(now) { + return resetAt + } + } + } + } + return now.Add(grokSpendingLimitProbeCooldown) +} + // markGrokSpendingLimitReauth applies a long temp-unsched cool and durable // SetError so ops sees reauth-required without wiping OAuth credentials. func (s *OpenAIGatewayService) markGrokSpendingLimitReauth(ctx context.Context, account *Account) { diff --git a/backend/internal/service/grok_upstream_failure.go b/backend/internal/service/grok_upstream_failure.go index 04075b4a7c..c76c24f99b 100644 --- a/backend/internal/service/grok_upstream_failure.go +++ b/backend/internal/service/grok_upstream_failure.go @@ -406,22 +406,15 @@ func isGrokEmptyModelOutputCode(code string) bool { } func grokFreeUsageCooldownDuration(low string) time.Duration { - if m := reGrokResetsWindow.FindStringSubmatch(low); len(m) == 2 { - hours, _ := strconv.Atoi(m[1]) - if hours > 0 { - d := time.Duration(hours) * time.Hour / 12 - if d < 20*time.Minute { - d = 20 * time.Minute - } - if d > 6*time.Hour { - d = 6 * time.Hour - } - return d - } - } - return 2 * time.Hour + // "rolling 24-hour" describes the upstream usage window, not a cooldown + // that starts when this proxy observes a 429. Without an upstream reset + // timestamp we cannot know when the oldest usage exits that window, so use + // a short probe interval and let a successful probe clear the block. + return grokFreeUsageProbeCooldown } +const grokFreeUsageProbeCooldown = 10 * time.Minute + func parseGrokTokenPair(errText string) (actual, limit int64, ok bool) { m := reGrokTokenPair.FindStringSubmatch(errText) if len(m) != 3 { @@ -488,7 +481,9 @@ func (s *OpenAIGatewayService) applyGrokUpstreamFailureDecision( case GrokFailureBilling: low := strings.ToLower(decision.Reason) if strings.Contains(low, "spending") || strings.Contains(low, "credits") { - s.markGrokSpendingLimitReauth(ctx, account) + // Spending-limit/credit exhaustion is a billing-window condition. Keep + // the account recoverable and let the normal rate-limit recovery clear it. + s.rateLimitGrok(ctx, account, grokSpendingLimitResetAt(account, time.Now())) return true } // Keep the historical 402/payment reason for ops UI + regression tests. diff --git a/backend/internal/service/grok_upstream_failure_test.go b/backend/internal/service/grok_upstream_failure_test.go index c51b4053a6..6f0a916a78 100644 --- a/backend/internal/service/grok_upstream_failure_test.go +++ b/backend/internal/service/grok_upstream_failure_test.go @@ -40,7 +40,7 @@ func TestClassifyGrokUpstreamFailure_FreeUsage(t *testing.T) { require.True(t, d.ShouldCooldown) require.True(t, d.ShouldFailover) require.False(t, d.BlockModel, "free-usage must not soft-block models") - require.GreaterOrEqual(t, d.Cooldown, 20*time.Minute) + require.Equal(t, grokFreeUsageProbeCooldown, d.Cooldown) }) } } @@ -98,9 +98,23 @@ func TestHandleGrokAccountUpstreamError_FreeUsageBodyCoolsAccount(t *testing.T) require.Equal(t, 1, repo.tempUnschedCalls) require.Equal(t, "grok free usage exhausted", repo.lastTempUnschedReason) - // 24h rolling → 2h cool - require.Greater(t, repo.lastTempUnschedUntil, before.Add(119*time.Minute)) - require.Less(t, repo.lastTempUnschedUntil, before.Add(121*time.Minute)) + // Rolling-window exhaustion must use a short probe cooldown when no + // upstream absolute reset is available; it must not start a 24h lock here. + require.Greater(t, repo.lastTempUnschedUntil, before.Add(grokFreeUsageProbeCooldown-time.Second)) + require.Less(t, repo.lastTempUnschedUntil, before.Add(grokFreeUsageProbeCooldown+time.Second)) +} + +func TestHandleGrokAccountUpstreamError_FreeUsageUsesUpstreamReset(t *testing.T) { + repo := &grokQuotaAccountRepo{} + svc := &OpenAIGatewayService{accountRepo: repo} + account := &Account{ID: 9102, Platform: PlatformGrok, Type: AccountTypeOAuth} + body := []byte(`{"error":{"code":"subscription:free-usage-exhausted","message":"free usage exhausted; rolling 24-hour window"}}`) + + svc.handleGrokAccountUpstreamError(context.Background(), account, http.StatusTooManyRequests, + http.Header{"Retry-After": []string{"3600"}}, body) + + require.Zero(t, repo.tempUnschedCalls) + require.WithinDuration(t, time.Now().Add(time.Hour), repo.lastRateLimitResetAt, 2*time.Second) } func TestHandleGrokAccountUpstreamError_EmptyOutputCoolsAccount(t *testing.T) { diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 49cd591829..65b3eae7f8 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -144,6 +144,10 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( if err != nil { return nil, fmt.Errorf("remove Responses-only Grok prompt cache key: %w", err) } + upstreamBody, err = normalizeGrokChatReasoningEffort(upstreamBody, upstreamModel) + if err != nil { + return nil, fmt.Errorf("normalize Grok chat reasoning effort: %w", err) + } } logger.L().Debug("openai chat_completions raw: forwarding without protocol conversion", @@ -225,7 +229,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( func (s *OpenAIGatewayService) rawChatCompletionsURL(account *Account) (string, error) { if account.Platform == PlatformGrok { - targetURL, err := buildGrokChatCompletionsURL(account, s.cfg) + targetURL, err := buildGrokChatCompletionsURL(account, s.cfg, s.settingService) if err != nil { return "", fmt.Errorf("invalid grok base_url: %w", err) } diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index 13b0f121b5..3a737f72d5 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -49,6 +49,10 @@ func (s *OpenAIGatewayService) forwardGrokResponses( if strings.TrimSpace(upstreamModel) == "" { upstreamModel = grokDefaultResponsesModel } + // Account mappings are optional. Canonicalize client aliases even when the + // account has no model_mapping, matching the Chat Completions path and xAI's + // actual Responses model IDs. + upstreamModel = xai.ResolveGrokTextResponsesModelID(upstreamModel, grokDefaultResponsesModel) if isGrokImageGenerationModel(upstreamModel) { return nil, fmt.Errorf("model %s is an image model and is not available on the Responses endpoint; use /v1/images/generations instead", upstreamModel) } @@ -101,7 +105,7 @@ func (s *OpenAIGatewayService) forwardGrokResponses( upstreamStart := time.Now() var resp *http.Response for attempt := 0; ; attempt++ { - upstreamReq, buildErr := buildGrokResponsesRequest(upstreamCtx, c, account, patchedBody, token, cacheIdentity, s.cfg) + upstreamReq, buildErr := buildGrokResponsesRequest(upstreamCtx, c, account, patchedBody, token, cacheIdentity, s.cfg, s.settingService) if buildErr != nil { return nil, buildErr } @@ -437,7 +441,13 @@ func patchGrokResponsesBodyBase(body []byte, upstreamModel string) ([]byte, erro if !json.Valid(body) { return nil, fmt.Errorf("invalid json request body") } - out, err := sjson.SetBytes(body, "model", upstreamModel) + // sjson may reuse the input backing array; keep the caller's request bytes + // unchanged because the same body can be inspected for billing/retry paths. + out, err := sjson.SetBytes(append([]byte(nil), body...), "model", upstreamModel) + if err != nil { + return nil, err + } + out, err = normalizeGrokResponsesReasoningEffort(out, upstreamModel) if err != nil { return nil, err } @@ -463,6 +473,16 @@ func patchGrokResponsesBodyBase(body []byte, upstreamModel string) ([]byte, erro } } } + if grokModelRejectsLogprobs(upstreamModel) { + for _, unsupportedField := range []string{"logprobs", "top_logprobs"} { + if gjson.GetBytes(out, unsupportedField).Exists() { + out, err = sjson.DeleteBytes(out, unsupportedField) + if err != nil { + return nil, err + } + } + } + } out, err = sanitizeGrokResponsesUnsupportedFields(out) if err != nil { return nil, err @@ -486,6 +506,17 @@ func patchGrokResponsesBodyBase(body []byte, upstreamModel string) ([]byte, erro return out, nil } +// xAI's Grok 4.20 family and newer models do not support OpenAI's logprobs +// fields. Remove them before egress instead of forwarding a request the +// upstream rejects. Older Grok models retain the fields for compatibility. +func grokModelRejectsLogprobs(model string) bool { + model = strings.ToLower(strings.TrimSpace(model)) + if slash := strings.LastIndex(model, "/"); slash >= 0 { + model = strings.TrimSpace(model[slash+1:]) + } + return strings.HasPrefix(model, "grok-4.20") +} + func sanitizeGrokResponsesModelCapabilities(body []byte, upstreamModel string) ([]byte, error) { if !grokModelRejectsReasoningEffort(upstreamModel) { return body, nil @@ -518,6 +549,98 @@ func grokModelRejectsReasoningEffort(model string) bool { } } +func normalizeGrokResponsesReasoningEffort(body []byte, upstreamModel string) ([]byte, error) { + supportsEffort := grokSupportsReasoningEffort(upstreamModel) + out := body + var err error + for _, field := range []string{"reasoning.effort", "reasoning_effort"} { + value := gjson.GetBytes(out, field) + if !value.Exists() { + continue + } + normalized, keep := normalizeGrokReasoningEffortValue(value.String()) + if !supportsEffort || !keep { + out, err = sjson.DeleteBytes(out, field) + } else { + out, err = sjson.SetBytes(out, field, normalized) + } + if err != nil { + return nil, fmt.Errorf("normalize Grok reasoning field %s: %w", field, err) + } + } + if camel := gjson.GetBytes(out, "reasoningEffort"); camel.Exists() { + normalized, keep := normalizeGrokReasoningEffortValue(camel.String()) + out, err = sjson.DeleteBytes(out, "reasoningEffort") + if err != nil { + return nil, fmt.Errorf("remove Grok reasoningEffort: %w", err) + } + if supportsEffort && keep && !gjson.GetBytes(out, "reasoning_effort").Exists() { + out, err = sjson.SetBytes(out, "reasoning_effort", normalized) + if err != nil { + return nil, fmt.Errorf("set Grok reasoning_effort: %w", err) + } + } + } + if reasoning := gjson.GetBytes(out, "reasoning"); reasoning.Exists() && reasoning.IsObject() && len(reasoning.Map()) == 0 { + out, err = sjson.DeleteBytes(out, "reasoning") + if err != nil { + return nil, fmt.Errorf("remove empty Grok reasoning: %w", err) + } + } + return out, nil +} + +func normalizeGrokChatReasoningEffort(body []byte, upstreamModel string) ([]byte, error) { + raw := strings.TrimSpace(gjson.GetBytes(body, "reasoning_effort").String()) + if raw == "" { + raw = strings.TrimSpace(gjson.GetBytes(body, "reasoningEffort").String()) + } + normalized, keep := normalizeGrokReasoningEffortValue(raw) + keep = keep && grokSupportsReasoningEffort(upstreamModel) + out := body + var err error + if gjson.GetBytes(out, "reasoningEffort").Exists() { + out, err = sjson.DeleteBytes(out, "reasoningEffort") + if err != nil { + return nil, err + } + } + if !keep { + if gjson.GetBytes(out, "reasoning_effort").Exists() { + out, err = sjson.DeleteBytes(out, "reasoning_effort") + } + return out, err + } + out, err = sjson.SetBytes(out, "reasoning_effort", normalized) + return out, err +} + +func normalizeGrokReasoningEffortValue(raw string) (string, bool) { + value := strings.NewReplacer("-", "", "_", "", " ", "").Replace(strings.ToLower(strings.TrimSpace(raw))) + switch value { + case "none", "low", "medium", "high": + return value, true + case "minimal": + return "low", true + case "xhigh", "extrahigh", "max", "ultra": + return "high", true + default: + return "", false + } +} + +func grokSupportsReasoningEffort(model string) bool { + model = strings.ToLower(xai.StripGrokProviderPrefix(strings.TrimSpace(model))) + switch model { + case xai.DefaultTextModel, "grok-4.5-latest", "grok-4.3", "grok-4.3-latest", + "grok-3-mini", "grok-3-mini-fast", "grok-4.20-0309-reasoning", + "grok-4.20-reasoning", "grok-4.20-multi-agent-0309": + return true + default: + return false + } +} + var grokResponsesUnsupportedRecursiveFields = map[string]struct{}{ "external_web_access": {}, } @@ -703,15 +826,30 @@ func sanitizeGrokResponsesTools(body []byte) ([]byte, error) { rawTools := tools.Array() filteredTools := make([]json.RawMessage, 0, len(rawTools)) + toolsChanged := false for _, tool := range rawTools { toolType := strings.TrimSpace(tool.Get("type").String()) if _, ok := grokResponsesSupportedToolTypes[toolType]; ok { - filteredTools = append(filteredTools, json.RawMessage(tool.Raw)) + raw := json.RawMessage(tool.Raw) + if toolType == "function" && (!tool.Get("parameters").Exists() || tool.Get("parameters").Type == gjson.Null) { + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, err + } + payload["parameters"] = map[string]any{"type": "object", "properties": map[string]any{}} + encoded, err := json.Marshal(payload) + if err != nil { + return nil, err + } + raw = encoded + toolsChanged = true + } + filteredTools = append(filteredTools, raw) } } var err error - if len(filteredTools) != len(rawTools) { + if len(filteredTools) != len(rawTools) || toolsChanged { if len(filteredTools) == 0 { body, err = sjson.DeleteBytes(body, "tools") } else { @@ -919,7 +1057,7 @@ func (s *OpenAIGatewayService) describeGrokComposerImage( upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) // Image-description probes are auxiliary requests, not conversation turns. // Do not bind them to the caller's Grok prompt-cache identity. - upstreamReq, err := buildGrokResponsesRequest(upstreamCtx, c, account, body, token, "", s.cfg) + upstreamReq, err := buildGrokResponsesRequest(upstreamCtx, c, account, body, token, "", s.cfg, s.settingService) releaseUpstreamCtx() if err != nil { return "", OpenAIUsage{}, fmt.Errorf("build grok composer image bridge request: %w", err) @@ -1089,8 +1227,8 @@ func addOpenAIUsage(dst *OpenAIUsage, usage OpenAIUsage) { dst.ImageOutputTokens += usage.ImageOutputTokens } -func buildGrokResponsesRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, cacheIdentity string, cfg *config.Config) (*http.Request, error) { - targetURL, err := buildGrokResponsesURL(account, cfg) +func buildGrokResponsesRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, cacheIdentity string, cfg *config.Config, settings ...*SettingService) (*http.Request, error) { + targetURL, err := buildGrokResponsesURL(account, cfg, settings...) if err != nil { return nil, err } @@ -1505,8 +1643,24 @@ func (s *OpenAIGatewayService) handleGrokAccountUpstreamError(ctx context.Contex if decision.ShouldCooldown && decision.Class != GrokFailureNone && decision.Class != GrokFailureRateLimit { if account.IsPoolMode() { // Allow configured temp rules (403) below; skip default body cools. - } else if s.applyGrokUpstreamFailureDecision(ctx, account, decision) { - return + } else { + // A free-tier exhaustion message describes a rolling usage window. Use + // an upstream absolute reset (or Retry-After) when available; otherwise + // apply only a short probe cooldown. Never start a fabricated 24h window + // at the instant this error was received. + if decision.Class == GrokFailureFreeUsage { + if resetAt, limited := grokRateLimitResetAtForAccount(account, parseGrokQuotaSnapshot(headers, statusCode, now), now); limited && resetAt.After(now) { + if decision.Model != "" && isGrokModelSpecificFreeUsage(strings.ToLower(decision.Reason), decision.Model) { + markGrokModelQuotaBlock(account.ID, decision.Model, resetAt) + return + } + s.rateLimitGrok(ctx, account, resetAt) + return + } + } + if s.applyGrokUpstreamFailureDecision(ctx, account, decision) { + return + } } } @@ -1526,7 +1680,7 @@ func (s *OpenAIGatewayService) handleGrokAccountUpstreamError(ctx context.Contex case http.StatusForbidden: // Spending-limit already handled by body classifier when phrasing matches. if isGrokSpendingLimitError(responseBody) { - s.markGrokSpendingLimitReauth(ctx, account) + s.rateLimitGrok(ctx, account, grokSpendingLimitResetAt(account, time.Now())) return } s.tempUnscheduleGrok(ctx, account, 30*time.Minute, "grok access or entitlement denied") diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge.go b/backend/internal/service/openai_gateway_grok_chat_bridge.go index 19aa4a8fce..ed910eab43 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge.go @@ -586,7 +586,7 @@ func (s *OpenAIGatewayService) forwardGrokChatCompletionsViaResponses( return nil, fmt.Errorf("get grok access token: %w", err) } upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) - upstreamReq, err := buildGrokResponsesRequest(upstreamCtx, c, account, responsesBody, token, cacheIdentity, s.cfg) + upstreamReq, err := buildGrokResponsesRequest(upstreamCtx, c, account, responsesBody, token, cacheIdentity, s.cfg, s.settingService) releaseUpstreamCtx() if err != nil { return nil, fmt.Errorf("build grok responses bridge request: %w", err) diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 10d0a483a3..e7dcf7038a 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -58,7 +58,7 @@ func TestPatchGrokResponsesBodySanitizesComposerReasoningParameters(t *testing.T {name: "grok 4.5", upstreamModel: "grok-4.5", wantReasoning: true}, } - body := []byte(`{ + bodyTemplate := []byte(`{ "model": "grok", "input": "hello", "reasoning": {"effort": "medium", "summary": "auto"}, @@ -68,7 +68,7 @@ func TestPatchGrokResponsesBodySanitizesComposerReasoningParameters(t *testing.T for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - patched, err := patchGrokResponsesBody(body, tt.upstreamModel) + patched, err := patchGrokResponsesBody(append([]byte(nil), bodyTemplate...), tt.upstreamModel) require.NoError(t, err) require.True(t, json.Valid(patched)) require.Equal(t, tt.upstreamModel, gjson.GetBytes(patched, "model").String()) @@ -76,7 +76,7 @@ func TestPatchGrokResponsesBodySanitizesComposerReasoningParameters(t *testing.T if tt.wantReasoning { require.Equal(t, "medium", gjson.GetBytes(patched, "reasoning.effort").String()) require.Equal(t, "medium", gjson.GetBytes(patched, "reasoning_effort").String()) - require.Equal(t, "medium", gjson.GetBytes(patched, "reasoningEffort").String()) + require.False(t, gjson.GetBytes(patched, "reasoningEffort").Exists()) return } @@ -142,6 +142,61 @@ func TestPatchGrokResponsesBodyKeepsPenaltyAndStopFieldsForNon45Models(t *testin require.Len(t, gjson.GetBytes(patched, "stop").Array(), 1) } +func TestPatchGrokResponsesBodyDropsLogprobsForGrok420Family(t *testing.T) { + t.Parallel() + body := []byte(`{"model":"grok-4.20-0309-reasoning","input":"hello","logprobs":true,"top_logprobs":5}`) + patched, err := patchGrokResponsesBody(body, "grok-4.20-0309-reasoning") + require.NoError(t, err) + require.False(t, gjson.GetBytes(patched, "logprobs").Exists()) + require.False(t, gjson.GetBytes(patched, "top_logprobs").Exists()) +} + +func TestPatchGrokResponsesBodyNormalizesReasoningEffortAliases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + path string + want string + }{ + {name: "minimal nested", body: `{"input":"hi","reasoning":{"effort":"minimal"}}`, path: "reasoning.effort", want: "low"}, + {name: "xhigh snake", body: `{"input":"hi","reasoning_effort":"xhigh"}`, path: "reasoning_effort", want: "high"}, + {name: "max camel", body: `{"input":"hi","reasoningEffort":"max"}`, path: "reasoning_effort", want: "high"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + patched, err := patchGrokResponsesBody([]byte(tt.body), "grok-4.5") + require.NoError(t, err) + require.Equal(t, tt.want, gjson.GetBytes(patched, tt.path).String(), string(patched)) + require.False(t, gjson.GetBytes(patched, "reasoningEffort").Exists()) + }) + } +} + +func TestPatchGrokResponsesBodyAddsDefaultFunctionParameters(t *testing.T) { + patched, err := patchGrokResponsesBody( + []byte(`{"input":"hi","tools":[{"type":"function","name":"lookup"},{"type":"function","name":"wait","parameters":null}]}`), + "grok-4.5", + ) + require.NoError(t, err) + for _, tool := range gjson.GetBytes(patched, "tools").Array() { + require.Equal(t, "object", tool.Get("parameters.type").String(), string(patched)) + require.True(t, tool.Get("parameters.properties").IsObject(), string(patched)) + } +} + +func TestNormalizeGrokChatReasoningEffort(t *testing.T) { + patched, err := normalizeGrokChatReasoningEffort([]byte(`{"reasoningEffort":"ultra"}`), "grok-4.3") + require.NoError(t, err) + require.Equal(t, "high", gjson.GetBytes(patched, "reasoning_effort").String()) + require.False(t, gjson.GetBytes(patched, "reasoningEffort").Exists()) + + patched, err = normalizeGrokChatReasoningEffort([]byte(`{"reasoning_effort":"high"}`), "grok-composer-2.5-fast") + require.NoError(t, err) + require.False(t, gjson.GetBytes(patched, "reasoning_effort").Exists()) +} + func TestPatchGrokResponsesBodyDropsNestedUnsupportedFields(t *testing.T) { t.Parallel() @@ -857,6 +912,33 @@ func TestCanonicalizeGrokMediaImageURLFieldsReplacesEmptyOfficialURL(t *testing. require.False(t, gjson.GetBytes(out, "image.image_url").Exists()) } +func TestPrepareGrokImageEditNormalizesOfficialImageObjects(t *testing.T) { + body := []byte(`{ + "model":"grok-imagine-image-quality", + "image":{"image_url":{"url":"https://example.com/first.png"}}, + "images":["https://example.com/second.png"], + "mask":{"image_url":"https://example.com/mask.png"} + }`) + + out, contentType, err := prepareGrokMediaForwardBody(GrokMediaEndpointImagesEdits, body, "application/json") + require.NoError(t, err) + require.Equal(t, "application/json", contentType) + for _, path := range []string{"image", "images.0", "mask"} { + require.Equal(t, "image_url", gjson.GetBytes(out, path+".type").String()) + require.NotEmpty(t, gjson.GetBytes(out, path+".url").String()) + require.False(t, gjson.GetBytes(out, path+".image_url").Exists()) + } +} + +func TestPrepareGrokImageEditRejectsMoreThanThreeSources(t *testing.T) { + body := []byte(`{"images":["https://example.com/1.png","https://example.com/2.png","https://example.com/3.png","https://example.com/4.png"]}`) + + out, _, err := prepareGrokMediaForwardBody(GrokMediaEndpointImagesEdits, body, "application/json") + require.Error(t, err) + require.Nil(t, out) + require.Contains(t, err.Error(), "maximum of 3 source images") +} + func TestNormalizeGrokMediaModelForEndpoint(t *testing.T) { tests := []struct { name string @@ -870,7 +952,7 @@ func TestNormalizeGrokMediaModelForEndpoint(t *testing.T) { {name: "image quality passthrough", endpoint: GrokMediaEndpointImagesGenerations, model: "grok-imagine-image-quality", want: "grok-imagine-image-quality"}, {name: "image fast passthrough", endpoint: GrokMediaEndpointImagesGenerations, model: "grok-imagine-image", want: "grok-imagine-image"}, {name: "video passthrough", endpoint: GrokMediaEndpointVideosGenerations, model: "grok-imagine-video", want: "grok-imagine-video"}, - {name: "video 1.5 text-only fallback", endpoint: GrokMediaEndpointVideosGenerations, model: "grok-imagine-video-1.5", want: "grok-imagine-video"}, + {name: "video 1.5 text-only remains explicit", endpoint: GrokMediaEndpointVideosGenerations, model: "grok-imagine-video-1.5", want: "grok-imagine-video-1.5"}, {name: "video 1.5 image-to-video passthrough", endpoint: GrokMediaEndpointVideosGenerations, model: "grok-imagine-video-1.5", hasInputImage: true, want: "grok-imagine-video-1.5"}, } @@ -962,9 +1044,9 @@ func TestForwardGrokMediaAppliesAccountModelMappingAfterEndpointNormalization(t path: "/v1/videos/generations", body: `{"model":"grok-imagine-video-1.5","prompt":"waves"}`, modelMapping: map[string]any{"grok-imagine-video": "grok-image-video"}, - wantRequestModel: "grok-imagine-video", - wantUpstream: "grok-image-video", - wantBody: `{"model":"grok-image-video","prompt":"waves"}`, + wantRequestModel: "grok-imagine-video-1.5", + wantUpstream: "grok-imagine-video-1.5", + wantBody: `{"model":"grok-imagine-video-1.5","prompt":"waves"}`, responseBody: `{"request_id":"video-request-mapped"}`, }, { @@ -1204,9 +1286,9 @@ func TestForwardGrokMediaVideoGenerationReturnsUsageAndResponseID(t *testing.T) 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.JSONEq(t, `{"model":"grok-imagine-video","prompt":"waves","resolution":"720p","duration":10}`, string(upstream.lastBody)) + require.JSONEq(t, `{"model":"grok-imagine-video-1.5","prompt":"waves","resolution":"720p","duration":10}`, string(upstream.lastBody)) require.Equal(t, "video-request-123", result.ResponseID) - require.Equal(t, "grok-imagine-video", result.BillingModel) + require.Equal(t, "grok-imagine-video-1.5", result.BillingModel) require.Equal(t, 3, result.Usage.InputTokens) require.Equal(t, 4, result.Usage.OutputTokens) // Create accepts the job only — VideoCount stays 0 until status returns video.url. @@ -1965,7 +2047,7 @@ func TestAccountTestServiceGrokAPIKeyUsesXAIResponses(t *testing.T) { c, _ := gin.CreateTestContext(recorder) c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/54/test", nil) - err := svc.testGrokAccountConnection(c, account, "grok") + err := svc.testGrokAccountConnection(c, account, "grok", "", AccountTestModeDefault, AccountTestOptions{}) require.NoError(t, err) require.Equal(t, "https://api.x.ai/v1/responses", upstream.lastReq.URL.String()) require.Equal(t, "Bearer xai-test-key", upstream.lastReq.Header.Get("Authorization")) @@ -1999,7 +2081,7 @@ func TestAccountTestServiceGrokAPIKeyAllowsConfiguredHTTPWhenGlobalPolicyDoes(t c, _ := gin.CreateTestContext(recorder) c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/55/test", nil) - err := svc.testGrokAccountConnection(c, account, "grok") + err := svc.testGrokAccountConnection(c, account, "grok", "", AccountTestModeDefault, AccountTestOptions{}) require.NoError(t, err) require.Equal(t, "http://grok.example.test/v1/responses", upstream.lastReq.URL.String()) require.Equal(t, "Bearer third-party-key", upstream.lastReq.Header.Get("Authorization")) @@ -2027,7 +2109,7 @@ func TestAccountTestServiceGrokOAuthPaymentRequiredTemporarilyUnschedulesAccount c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/56/test", nil) before := time.Now() - err := svc.testGrokAccountConnection(c, account, "grok") + err := svc.testGrokAccountConnection(c, account, "grok", "", AccountTestModeDefault, AccountTestOptions{}) require.Error(t, err) require.Equal(t, 1, repo.tempUnschedCalls) @@ -2502,9 +2584,7 @@ func grokMessagesSSECompletedResponse(responseID string, cachedTokens int) *http } } -func TestHandleGrokAccountUpstreamErrorSpendingLimitUsesLongCool(t *testing.T) { - // Spending-limit now uses a 24h cool + reauth mark (was 30m). Keep a focused - // regression so ops timing changes are intentional. +func TestHandleGrokAccountUpstreamErrorSpendingLimitUsesRecoverableProbeCool(t *testing.T) { repo := &grokQuotaAccountRepo{} svc := &OpenAIGatewayService{accountRepo: repo} account := &Account{ID: 2570, Platform: PlatformGrok, Type: AccountTypeOAuth} @@ -2513,10 +2593,9 @@ func TestHandleGrokAccountUpstreamErrorSpendingLimitUsesLongCool(t *testing.T) { svc.handleGrokAccountUpstreamError(context.Background(), account, http.StatusForbidden, nil, body) - require.Equal(t, 1, repo.tempUnschedCalls) - require.Equal(t, "grok spending limit", repo.lastTempUnschedReason) - require.Greater(t, repo.lastTempUnschedUntil, before.Add(23*time.Hour)) - require.Less(t, repo.lastTempUnschedUntil, before.Add(25*time.Hour)) + require.Equal(t, 1, repo.rateLimitedCalls) + require.WithinDuration(t, before.Add(grokSpendingLimitProbeCooldown), repo.lastRateLimitResetAt, 2*time.Second) + require.Zero(t, repo.tempUnschedCalls) } func TestHandleGrokAccountUpstreamErrorTempUnschedulesNonRateLimitStates(t *testing.T) { @@ -2578,7 +2657,7 @@ func TestHandleGrokAccountUpstreamErrorTempUnschedulesNonRateLimitStates(t *test } } -func TestHandleGrokAccountUpstreamErrorSpendingLimit403TempUnschedules(t *testing.T) { +func TestHandleGrokAccountUpstreamErrorSpendingLimit403RateLimits(t *testing.T) { account := &Account{ID: 614, Platform: PlatformGrok, Type: AccountTypeOAuth} repo := &grokQuotaAccountRepo{} svc := &OpenAIGatewayService{accountRepo: repo} @@ -2588,10 +2667,10 @@ func TestHandleGrokAccountUpstreamErrorSpendingLimit403TempUnschedules(t *testin svc.handleGrokAccountUpstreamError(context.Background(), account, http.StatusForbidden, nil, body) require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) - require.Equal(t, 1, repo.tempUnschedCalls) - require.Equal(t, account.ID, repo.lastTempUnschedID) - require.Equal(t, "grok spending limit", repo.lastTempUnschedReason) - require.WithinDuration(t, before.Add(24*time.Hour), repo.lastTempUnschedUntil, 2*time.Second) + require.Equal(t, 1, repo.rateLimitedCalls) + require.Equal(t, account.ID, repo.lastRateLimitedID) + require.WithinDuration(t, before.Add(grokSpendingLimitProbeCooldown), repo.lastRateLimitResetAt, 2*time.Second) + require.Zero(t, repo.tempUnschedCalls) require.True(t, isGrokSpendingLimitError(body)) } diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index f5af55559e..712fe9a2f1 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -303,7 +303,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) var upstreamReq *http.Request if account.Platform == PlatformGrok { - upstreamReq, err = buildGrokResponsesRequest(upstreamCtx, c, account, responsesBody, token, grokCacheIdentity, s.cfg) + upstreamReq, err = buildGrokResponsesRequest(upstreamCtx, c, account, responsesBody, token, grokCacheIdentity, s.cfg, s.settingService) } else { upstreamReq, err = s.buildUpstreamRequest(upstreamCtx, c, account, responsesBody, token, isStream, promptCacheKey, false) } @@ -355,7 +355,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( break } upstreamCtxRetry, releaseRetry := detachUpstreamContext(ctx) - upstreamReq, err = buildGrokResponsesRequest(upstreamCtxRetry, c, account, responsesBody, token, grokCacheIdentity, s.cfg) + upstreamReq, err = buildGrokResponsesRequest(upstreamCtxRetry, c, account, responsesBody, token, grokCacheIdentity, s.cfg, s.settingService) releaseRetry() if err != nil { return nil, fmt.Errorf("build grok retry request: %w", err) diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index b2c7c67f64..5e898a74f5 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -207,7 +207,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( releaseUpstreamCtx() return nil, fmt.Errorf("apply grok Free function-tool cache route: %w", err) } - upstreamReq, err = buildGrokResponsesRequest(upstreamCtx, c, account, body, token, grokCacheIdentity, s.cfg) + upstreamReq, err = buildGrokResponsesRequest(upstreamCtx, c, account, body, token, grokCacheIdentity, s.cfg, s.settingService) } else { upstreamReq, err = s.buildUpstreamRequestOpenAIPassthrough(upstreamCtx, c, account, body, token) } diff --git a/backend/internal/service/temp_unsched.go b/backend/internal/service/temp_unsched.go index 3871b72b58..06ae726df9 100644 --- a/backend/internal/service/temp_unsched.go +++ b/backend/internal/service/temp_unsched.go @@ -7,12 +7,15 @@ import ( // TempUnschedState 临时不可调度状态 type TempUnschedState struct { - UntilUnix int64 `json:"until_unix"` // 解除时间(Unix 时间戳) - TriggeredAtUnix int64 `json:"triggered_at_unix"` // 触发时间(Unix 时间戳) - StatusCode int `json:"status_code"` // 触发的错误码 - MatchedKeyword string `json:"matched_keyword"` // 匹配的关键词 - RuleIndex int `json:"rule_index"` // 触发的规则索引 - ErrorMessage string `json:"error_message"` // 错误消息 + UntilUnix int64 `json:"until_unix"` // 解除时间(Unix 时间戳) + TriggeredAtUnix int64 `json:"triggered_at_unix"` // 触发时间(Unix 时间戳) + StatusCode int `json:"status_code"` // 触发的错误码 + MatchedKeyword string `json:"matched_keyword"` // 匹配的关键词 + RuleIndex int `json:"rule_index"` // 触发的规则索引 + ErrorMessage string `json:"error_message"` // 错误消息 + TriggerCount int64 `json:"trigger_count,omitempty"` // 本次触发累计命中次数 + TriggerThreshold int `json:"trigger_threshold,omitempty"` // 触发阈值 + TriggerWindowMinutes int `json:"trigger_window_minutes,omitempty"` // 计数窗口(分钟) } // TempUnschedCache 临时不可调度缓存接口 diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 393d57870c..9a4e4311b0 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -193,7 +193,11 @@ func (s *AccountTestService) buildGrokUpstreamModelsRequest(ctx context.Context, if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid Grok base URL", err) } - validatedBaseURL, err := validator(account.GetGrokBaseURL()) + baseURL := account.GetGrokBaseURL() + if s.settingService != nil { + baseURL = s.settingService.ResolveGrokBaseURL(ctx, account) + } + validatedBaseURL, err := validator(baseURL) if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid Grok base URL", err) } diff --git a/backend/migrations/220_clear_non_grok_video_generation_config.sql b/backend/migrations/220_clear_non_grok_video_generation_config.sql new file mode 100644 index 0000000000..ad483f8782 --- /dev/null +++ b/backend/migrations/220_clear_non_grok_video_generation_config.sql @@ -0,0 +1,16 @@ +-- Videos are Grok/xAI-only. Clear stale video pricing from non-Grok groups. +-- Columns match migrations 170/217 (video_price_* / video_model_prices), not a +-- separate allow_video_generation flag which was never applied on this branch. + +UPDATE groups +SET video_price_480p = NULL, + video_price_720p = NULL, + video_price_1080p = NULL, + video_model_prices = NULL +WHERE platform IS DISTINCT FROM 'grok' + AND ( + video_price_480p IS NOT NULL + OR video_price_720p IS NOT NULL + OR video_price_1080p IS NOT NULL + OR video_model_prices IS NOT NULL + ); diff --git a/frontend/src/components/account/AccountStatusIndicator.vue b/frontend/src/components/account/AccountStatusIndicator.vue index 9c1d25f635..4130ecd8ec 100644 --- a/frontend/src/components/account/AccountStatusIndicator.vue +++ b/frontend/src/components/account/AccountStatusIndicator.vue @@ -14,15 +14,19 @@