diff --git a/backend/internal/handler/grok_audio.go b/backend/internal/handler/grok_audio.go index 5c856c80e4..08a8ff3c6c 100644 --- a/backend/internal/handler/grok_audio.go +++ b/backend/internal/handler/grok_audio.go @@ -57,9 +57,15 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) { var release func() var token string var upstream *service.GrokRealtimeUpstream + var candidateSeen bool for attempts := 0; attempts < 4; attempts++ { + // Realtime's voice model is not a text-model capability. Passing a + // concrete text model here would reject accounts mapped only to an + // older/default text model before the upstream handshake can decide. + // An empty requested model keeps account selection capability-based; + // the actual voice model remains in the upstream WS query below. candidate, _, selectErr := h.gatewayService.SelectAccountWithSchedulerForCapability( - c.Request.Context(), apiKey.GroupID, "", "", "grok-4.6", failed, + c.Request.Context(), apiKey.GroupID, "", "", "", failed, service.OpenAIUpstreamTransportHTTPSSE, service.OpenAIEndpointCapabilityChatCompletions, false, false, false, service.PlatformGrok, @@ -67,6 +73,7 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) { if selectErr != nil || candidate == nil || candidate.Account == nil { break } + candidateSeen = true account := candidate.Account var streamStarted bool var slotStatus openAISlotAcquireResult @@ -86,7 +93,7 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) { failed[account.ID] = struct{}{} continue } - probeCtx, cancelProbe := context.WithTimeout(c.Request.Context(), 15*time.Second) + probeCtx, cancelProbe := context.WithTimeout(c.Request.Context(), service.DefaultGrokRealtimeDialTimeout) candidateUpstream, openErr := h.gatewayService.OpenGrokRealtime(probeCtx, account, token, model) cancelProbe() if openErr != nil { @@ -100,7 +107,11 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) { break } if selection == nil || selection.Account == nil || release == nil || upstream == nil { - h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Grok realtime upstream unavailable") + if !candidateSeen { + h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available Grok accounts") + } else { + h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Grok realtime upstream unavailable") + } return } defer release() diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index 898449cc29..bd9cef056b 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -672,7 +672,7 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) { Platform: service.PlatformGrok, }, } - require.Equal(t, "grok-4.5", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-sonnet-4-5")) + require.Equal(t, "grok-4.6", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-sonnet-4-5")) require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "grok")) }) diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 49d5c76295..68c59f25af 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -101,7 +101,7 @@ const ( AccountTestModeGrokRealtime = "realtime" defaultGrokRealtimeTestModel = "grok-voice-latest" - grokRealtimeProbeTimeout = 12 * time.Second + grokRealtimeProbeTimeout = DefaultGrokRealtimeDialTimeout ) // isOpenAIImageModel checks if the model is an OpenAI image generation model (e.g. gpt-image-2). diff --git a/backend/internal/service/grok_audio.go b/backend/internal/service/grok_audio.go index 7aeb80236b..a2fb33f12d 100644 --- a/backend/internal/service/grok_audio.go +++ b/backend/internal/service/grok_audio.go @@ -16,6 +16,10 @@ import ( "github.com/tidwall/gjson" ) +// DefaultGrokRealtimeDialTimeout bounds the pre-accept upstream handshake. +// The timeout only covers dialing; an established session is not interrupted. +const DefaultGrokRealtimeDialTimeout = 12 * time.Second + // supportedGrokVoiceHTTPEndpoints are xAI Voice HTTP paths we forward as-is. var supportedGrokVoiceHTTPEndpoints = map[string]struct{}{ "tts": {}, diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge_test.go b/backend/internal/service/openai_gateway_grok_chat_bridge_test.go index 373907f0d7..5904c8ce3c 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge_test.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge_test.go @@ -251,7 +251,7 @@ func TestForwardGrokChatViaResponsesNonStreamingCachesAndReturnsChat(t *testing. require.NotNil(t, result) require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String()) require.Equal(t, grokChatResponsesEndpoint, result.UpstreamEndpoint) - require.Equal(t, "grok-4.5", result.UpstreamModel) + require.Equal(t, "grok-4.6", result.UpstreamModel) require.Equal(t, 9908, result.Usage.InputTokens) require.Equal(t, 12, result.Usage.OutputTokens) require.Equal(t, 9856, result.Usage.CacheReadInputTokens) @@ -352,7 +352,7 @@ func TestForwardGrokChatViaResponsesCodeBuddyUsesStableConversationHeader(t *tes c.Request.Header.Set("X-Request-ID", "generic-"+tt.requestID) c.Set("api_key", &APIKey{ID: 7111}) - identity := resolveGrokCacheIdentity(c, tt.body, "", "grok-4.5") + identity := resolveGrokCacheIdentity(c, tt.body, "", "grok-4.6") require.NotEmpty(t, identity) if index == 0 { stableIdentity = identity @@ -406,8 +406,8 @@ func TestForwardGrokChatViaResponsesTraeToolHistoryKeepsCacheRoute(t *testing.T) accountRepo: repo, } - firstTurnIdentity := resolveGrokCacheIdentity(c, firstTurnBody, "", "grok-4.5") - extendedTurnIdentity := resolveGrokCacheIdentity(c, body, "", "grok-4.5") + firstTurnIdentity := resolveGrokCacheIdentity(c, firstTurnBody, "", "grok-4.6") + extendedTurnIdentity := resolveGrokCacheIdentity(c, body, "", "grok-4.6") require.NotEmpty(t, firstTurnIdentity) require.Equal(t, firstTurnIdentity, extendedTurnIdentity) @@ -463,8 +463,8 @@ func TestForwardGrokChatViaResponsesTraeCompatibilityFieldsKeepCacheRoute(t *tes accountRepo: repo, } - firstTurnIdentity := resolveGrokCacheIdentity(c, firstTurnBody, "", "grok-4.5") - extendedTurnIdentity := resolveGrokCacheIdentity(c, body, "", "grok-4.5") + firstTurnIdentity := resolveGrokCacheIdentity(c, firstTurnBody, "", "grok-4.6") + extendedTurnIdentity := resolveGrokCacheIdentity(c, body, "", "grok-4.6") require.NotEmpty(t, firstTurnIdentity) require.Equal(t, firstTurnIdentity, extendedTurnIdentity) @@ -534,7 +534,7 @@ func TestForwardGrokChatRuntimeGateFallsBackToRaw(t *testing.T) { mappedModel string wantUpstream string }{ - {name: "missing cache identity", wantUpstream: "grok-4.5"}, + {name: "missing cache identity", wantUpstream: "grok-4.6"}, {name: "non cache capable mapped model", setAPIKey: true, mappedModel: "grok-4.3", wantUpstream: "grok-4.3"}, } diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index cbeacdff6e..541e3a3216 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -1791,7 +1791,7 @@ func TestForwardAsChatCompletionsForGrokStopFallsBackToXAIChatCompletions(t *tes require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization")) require.NotEmpty(t, upstream.lastReq.Header.Get(grokConversationIDHeader)) require.NotEqual(t, "raw-client-cache-key", upstream.lastReq.Header.Get(grokConversationIDHeader)) - require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String()) require.False(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").Exists()) require.Equal(t, "grok", result.Model) require.Equal(t, "grok-4.5", result.UpstreamModel) @@ -1848,7 +1848,7 @@ func TestForwardGrokResponsesStreamingDefaultsEmptyModelTo45AndSnapshots(t *test require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String()) require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization")) require.Equal(t, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta")) - require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String()) require.NotEmpty(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) require.Equal(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String(), upstream.lastReq.Header.Get(grokConversationIDHeader)) require.Equal(t, "web_search", gjson.GetBytes(upstream.lastBody, "tools.0.type").String()) @@ -1908,7 +1908,7 @@ func TestForwardGrokResponsesAPIKeyUsesXAIResponses(t *testing.T) { require.Equal(t, "Bearer xai-test-key", upstream.lastReq.Header.Get("Authorization")) require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version")) require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent")) - require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String()) require.Equal(t, "resp_grok_api_key", result.ResponseID) require.Equal(t, 2, result.Usage.InputTokens) require.Equal(t, 1, result.Usage.OutputTokens) @@ -2410,7 +2410,7 @@ func TestForwardGrokResponsesNonStreamingUsesCacheIdentityAndCachedUsage(t *test require.Equal(t, 7, result.Usage.InputTokens) require.Equal(t, 2, result.Usage.OutputTokens) require.Equal(t, 4, result.Usage.CacheReadInputTokens) - require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String()) identity := gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String() require.NotEmpty(t, identity) require.Equal(t, identity, upstream.lastReq.Header.Get(grokConversationIDHeader)) diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 49b4c70735..72bcb061cf 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -1187,7 +1187,7 @@ func TestProxyResponsesWebSocketFromClientForGrokUsesXAIHTTPBridgeAndPreservesMa require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization")) require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, grokCLIVersion, upstream.lastReq.Header.Get("X-Grok-Client-Version")) - require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.bodies[0], "model").String()) + require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.bodies[0], "model").String()) require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.bodies[1], "model").String()) require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.bodies[2], "model").String()) require.NotEmpty(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String())