mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 15:38:22 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 临时不可调度缓存接口
|
||||
|
||||
@@ -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' }
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user