diff --git a/backend/internal/pkg/xai/models.go b/backend/internal/pkg/xai/models.go index 990a6d28e0..d392249617 100644 --- a/backend/internal/pkg/xai/models.go +++ b/backend/internal/pkg/xai/models.go @@ -57,9 +57,10 @@ const DefaultTextModel = "grok-4.5" const ( DefaultImagineImageQualityModel = "grok-imagine-image-quality" DefaultImagineImageFastModel = "grok-imagine-image" + DefaultImagineImage20Model = "grok-imagine-image-2.0" DefaultImagineVideoModel = "grok-imagine-video" - DefaultImagineVideo15LegacyModel = "grok-imagine-video-1.5" - DefaultImagineVideo15Model = "grok-imagine-video-1.5-preview" + DefaultImagineVideo15Model = "grok-imagine-video-1.5" + DefaultImagineVideo15LegacyModel = "grok-imagine-video-1.5-preview" ) // ModelMappingOptions controls optional expansions of the default mapping. @@ -86,8 +87,6 @@ var defaultModels = []Model{ {ID: "grok-4.6", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.6"}, {ID: "grok-4.5", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.5"}, {ID: "grok-4.3", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.3"}, - {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-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"}, @@ -96,9 +95,9 @@ 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: DefaultImagineImage20Model, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Image 2.0"}, {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"}, + {ID: DefaultImagineVideo15Model, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video 1.5"}, } // grokTextResponsesModelAliases is the source of truth for Grok text models @@ -109,8 +108,8 @@ var grokTextResponsesModelAliases = map[string]string{ "grok-latest": DefaultTextModel, "grok-4.6": "grok-4.6", "grok-4.6-latest": "grok-4.6", - "grok-4.5": DefaultTextModel, - "grok-4.5-latest": DefaultTextModel, + "grok-4.5": "grok-4.5", + "grok-4.5-latest": "grok-4.5", "grok-4.3": "grok-4.3", "grok-4.3-latest": "grok-4.3", "grok-3-mini": "grok-3-mini", @@ -162,7 +161,7 @@ func ModelMappingWithOptions(opts ModelMappingOptions) map[string]string { } for alias, canonical := range grokTextResponsesModelAliases { // Remap aliases that pointed at DefaultTextModel constant to runtime default. - if canonical == DefaultTextModel { + if (alias == "grok" || alias == "grok-latest" || alias == "grok-build-latest") && canonical == DefaultTextModel { mapping[alias] = defaultText } else { mapping[alias] = canonical @@ -179,7 +178,7 @@ func ModelMappingWithOptions(opts ModelMappingOptions) map[string]string { // Keep official IDs as identity so client-requested model strings are not // rewritten on the wire (pricing still canonicalizes 1.5* via CanonicalImagineVideoModel). mapping["grok-imagine-video"] = DefaultImagineVideoModel - mapping["grok-imagine-video-1.5"] = DefaultImagineVideo15LegacyModel + mapping["grok-imagine-video-1.5"] = DefaultImagineVideo15Model mapping["grok-imagine-video-1.5-preview"] = DefaultImagineVideo15Model // Informal alias only: mapping["grok-video-1.5"] = DefaultImagineVideo15Model @@ -273,7 +272,7 @@ func ResolveGrokTextResponsesModelID(model string, defaultText ...string) string } normalized := strings.ToLower(StripGrokProviderPrefix(trimmed)) if canonical, ok := grokTextResponsesModelAliases[normalized]; ok { - if canonical == DefaultTextModel { + if (normalized == "grok" || normalized == "grok-latest" || normalized == "grok-build-latest") && canonical == DefaultTextModel { return fallback } return canonical diff --git a/backend/internal/pkg/xai/models_test.go b/backend/internal/pkg/xai/models_test.go index a01d993ccb..0fc463814b 100644 --- a/backend/internal/pkg/xai/models_test.go +++ b/backend/internal/pkg/xai/models_test.go @@ -17,7 +17,7 @@ func TestDefaultModelMappingExcludesCrossClientWildcards(t *testing.T) { require.Equal(t, "grok-build-0.1", mapping["grok-build"]) 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"]) require.Equal(t, DefaultImagineVideo15Model, mapping["grok-imagine-video-1.5-preview"]) require.Equal(t, "grok-4.5", mapping["xai/grok"]) @@ -72,3 +72,9 @@ func TestResolveGrokTextResponsesModelID(t *testing.T) { require.Equal(t, "grok-4.3", ResolveGrokTextResponsesModelID("grok", "grok-4.3")) require.Equal(t, "grok-4.20-multi-agent-0309", ResolveGrokTextResponsesModelID("grok-4.20-multi-agent")) } + +func TestExplicitGrok45DoesNotFollowRuntimeDefault(t *testing.T) { + require.Equal(t, "grok-4.5", ResolveGrokTextResponsesModelID("grok-4.5", "grok-4.6")) + require.Equal(t, "grok-4.5", ResolveGrokTextResponsesModelID("grok-4.5-latest", "grok-4.6")) + require.Equal(t, "grok-4.6", ResolveGrokTextResponsesModelID("grok", "grok-4.6")) +} diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index af8c694495..2447c31719 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -704,6 +704,21 @@ func (s *BillingService) initFallbackPricing() { LongContextInputMultiplier: 2, LongContextOutputMultiplier: 2, } + + // Keep legacy Grok 3 Mini requests on their own historical xAI price card; + // otherwise the generic Grok fallback bills them as Grok 4.5. + s.fallbackPrices["grok-3-mini"] = &ModelPricing{ + InputPricePerToken: 0.30e-6, + OutputPricePerToken: 0.50e-6, + CacheReadPricePerToken: 0.075e-6, + SupportsCacheBreakdown: false, + } + s.fallbackPrices["grok-3-mini-fast"] = &ModelPricing{ + InputPricePerToken: 0.60e-6, + OutputPricePerToken: 4e-6, + CacheReadPricePerToken: 0.15e-6, + SupportsCacheBreakdown: false, + } // xAI Grok Build 0.1 (official docs: $1 input / $0.20 cached input / // $2 output per MTok). Composer is available only through Grok Build and // has no standalone public API rate card, so its aliases use this coding @@ -913,6 +928,10 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { return s.fallbackPrices["grok-4.5"] case "grok-4.6", "grok-4.6-latest": return s.fallbackPrices["grok-4.6"] + case "grok-3-mini": + return s.fallbackPrices["grok-3-mini"] + case "grok-3-mini-fast": + return s.fallbackPrices["grok-3-mini-fast"] case "grok-4.3", "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index d9c7c5b0e4..9cb3b01246 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -173,11 +173,14 @@ func (s *OpenAIGatewayService) forwardGrokResponses( markGrokTeamModelRateLimit(account, upstreamModel, resolveGrokTeamRateLimitUntil(time.Now().Add(grokTeamRateLimitDefaultTTL), time.Now())) } if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) { + retryable, retryDelay, retryDeadline := grokSameAccountRetryMetadata(account, resp.StatusCode, respBody) return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: respBody, - ResponseHeaders: resp.Header.Clone(), - RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + StatusCode: resp.StatusCode, + ResponseBody: respBody, + ResponseHeaders: resp.Header.Clone(), + RetryableOnSameAccount: retryable, + SameAccountRetryDelay: retryDelay, + SameAccountRetryDeadline: retryDeadline, } } return s.handleErrorResponse(ctx, resp, c, account, patchedBody, upstreamModel) @@ -899,7 +902,14 @@ func sanitizeGrokResponsesTools(body []byte) ([]byte, error) { return body, nil } if !tools.IsArray() { - return body, nil + // xAI rejects tool_choice when tools is null/object. Treat malformed + // tool collections as absent at egress rather than forwarding a pair + // that cannot be interpreted by the Grok Responses endpoint. + body, err := sjson.DeleteBytes(body, "tools") + if err != nil { + return nil, err + } + return sjson.DeleteBytes(body, "tool_choice") } rawTools := tools.Array() @@ -1173,11 +1183,14 @@ func (s *OpenAIGatewayService) describeGrokComposerImage( }) s.handleGrokAccountUpstreamError(withGrokTeamRateLimitModel(ctx, grokComposerImageBridgeVisionModel), account, resp.StatusCode, resp.Header, respBody) if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) { + retryable, retryDelay, retryDeadline := grokSameAccountRetryMetadata(account, resp.StatusCode, respBody) return "", OpenAIUsage{}, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: respBody, - ResponseHeaders: resp.Header.Clone(), - RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + StatusCode: resp.StatusCode, + ResponseBody: respBody, + ResponseHeaders: resp.Header.Clone(), + RetryableOnSameAccount: retryable, + SameAccountRetryDelay: retryDelay, + SameAccountRetryDeadline: retryDeadline, } } return "", OpenAIUsage{}, fmt.Errorf("grok composer image bridge upstream error: %s", upstreamMsg) @@ -1314,6 +1327,7 @@ func buildGrokResponsesRequest(ctx context.Context, c *gin.Context, account *Acc if err != nil { return nil, err } + req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileGrok)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 7b30e683aa..cbeacdff6e 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -406,10 +406,10 @@ func TestSanitizeGrokResponsesToolsKeepsToolChoiceOnlyWithSupportedTools(t *test wantToolChoice: true, }, { - name: "malformed non-array tools remain untouched", + name: "malformed non-array tools are removed", body: `{"input":"hello","tools":{"type":"function","name":"lookup"},"tool_choice":"auto"}`, - wantTools: true, - wantToolChoice: true, + wantTools: false, + wantToolChoice: false, }, }