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.
This commit is contained in:
IanShaw027
2026-08-08 01:07:27 +08:00
parent 2526a04226
commit 165b072908
31 changed files with 744 additions and 153 deletions
+3 -8
View File
@@ -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
+2 -1
View File
@@ -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"])
@@ -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": "",
+16
View File
@@ -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)
@@ -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")
@@ -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"},
@@ -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).
+1 -1
View File
@@ -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
}
+88 -27
View File
@@ -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
}
@@ -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 {
+5 -5
View File
@@ -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)
}
+31 -12
View File
@@ -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
@@ -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()
+35 -8
View File
@@ -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)
}
@@ -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 {
@@ -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) {
@@ -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.
@@ -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) {
@@ -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)
}
+164 -10
View File
@@ -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")
@@ -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)
@@ -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))
}
@@ -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)
@@ -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)
}
+9 -6
View File
@@ -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 临时不可调度缓存接口
+5 -1
View File
@@ -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)
}
@@ -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
);
@@ -14,15 +14,19 @@
<!-- Main Status Badge (shown when not rate limited/overloaded) -->
<template v-else>
<button
v-if="isTempUnschedulable"
type="button"
:class="['badge text-xs', statusClass, 'cursor-pointer']"
:title="t('admin.accounts.status.viewTempUnschedDetails')"
@click="handleTempUnschedClick"
>
{{ statusText }}
</button>
<div v-if="isTempUnschedulable" class="flex flex-col items-center gap-1">
<button
type="button"
:class="['badge text-xs', statusClass, 'cursor-pointer']"
:title="t('admin.accounts.status.viewTempUnschedDetails')"
@click="handleTempUnschedClick"
>
{{ statusText }}
</button>
<span class="max-w-[180px] text-center text-[11px] leading-4 text-gray-500 dark:text-gray-400">
{{ tempUnschedRecoveryText }}
</span>
</div>
<span v-else :class="['badge text-xs', statusClass]">
{{ statusText }}
</span>
@@ -302,6 +306,13 @@ const overloadCountdown = computed(() => {
return formatCountdownWithSuffix(props.account.overload_until)
})
const tempUnschedRecoveryText = computed(() => {
if (!isTempUnschedulable.value || !props.account.temp_unschedulable_until) return ''
return t('admin.accounts.status.tempUnschedulableUntil', {
time: formatDateTime(props.account.temp_unschedulable_until)
})
})
// Computed: status badge class
const statusClass = computed(() => {
if (hasError.value) {
@@ -101,6 +101,14 @@
{{ state?.error_message || '-' }}
</div>
</div>
<div
v-if="hasThresholdEvidence"
class="rounded-lg border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800 dark:border-blue-500/30 dark:bg-blue-500/10 dark:text-blue-300"
data-testid="temp-unsched-trigger-evidence"
>
{{ triggerEvidenceText }}
</div>
</div>
</div>
@@ -176,10 +184,28 @@ const isActive = computed(() => {
})
const ruleIndexDisplay = computed(() => {
if (!state.value) return '-'
if (!state.value || !state.value.matched_keyword || state.value.rule_index < 0) return '-'
return state.value.rule_index + 1
})
const hasThresholdEvidence = computed(() => (state.value?.trigger_count || 0) > 1)
const triggerEvidenceText = computed(() => {
const count = state.value?.trigger_count || 0
const threshold = state.value?.trigger_threshold || 0
const minutes = state.value?.trigger_window_minutes || 0
if (threshold > 0 && minutes > 0) {
return t('admin.accounts.tempUnschedulable.multipleErrorTrigger', { count, threshold, minutes })
}
if (threshold > 0) {
return t('admin.accounts.tempUnschedulable.multipleErrorTriggerNoWindow', { count, threshold })
}
if (minutes > 0) {
return t('admin.accounts.tempUnschedulable.multipleErrorCountInWindow', { count, minutes })
}
return t('admin.accounts.tempUnschedulable.multipleErrorCount', { count })
})
const triggeredAtText = computed(() => {
if (!state.value?.triggered_at_unix) return '-'
return formatDateTime(new Date(state.value.triggered_at_unix * 1000))
@@ -49,6 +49,7 @@ describe('useModelWhitelist', () => {
expect(models).toContain('grok-4.5')
expect(models).toContain('grok-4.5-latest')
expect(models).toContain('grok-build-latest')
expect(models).toContain('grok-imagine-video-1.5-preview')
})
it('combined 模式支持 Grok 4.5 官方别名映射', () => {
@@ -74,7 +75,7 @@ describe('useModelWhitelist', () => {
const models = getModelsByPlatform('grok')
expect(models).toContain('grok-composer-2.5-fast')
expect(models).toContain('grok-composer')
expect(models).not.toContain('grok-composer')
expect(models).toContain('composer-2.5')
})
@@ -143,20 +143,20 @@ const xaiModels = [
'grok-4.20-0309-reasoning',
'grok-4.20-0309-non-reasoning',
'grok-4.20-multi-agent-0309',
'grok',
'grok-4.20-multi-agent',
'grok-4.20-multi-agent-latest',
'grok-4.3-latest',
'grok-latest',
'grok-4.5-latest',
'grok-build',
'grok-build-latest',
'grok-composer',
'composer-2.5',
'grok-4.20-reasoning',
'grok-4.20-non-reasoning',
'grok-imagine',
'grok-imagine-image-quality',
'grok-imagine-image',
'grok-imagine-edit',
'grok-imagine-video',
'grok-imagine-video-1.5-preview',
'grok-imagine-video-1.5'
]
@@ -314,7 +314,7 @@ const grokPresetMappings = [
{ 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 Image', from: 'grok-imagine', to: 'grok-imagine-image-quality', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
{ label: 'Imagine Edit', from: 'grok-imagine-edit', to: 'grok-imagine-edit', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' },
{ label: 'Imagine Edit', from: 'grok-imagine-edit', to: 'grok-imagine-image-quality', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' },
{ label: 'Imagine Video', from: 'grok-imagine-video-1.5', to: 'grok-imagine-video-1.5', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }
]