diff --git a/backend/internal/pkg/xai/billing.go b/backend/internal/pkg/xai/billing.go index 44e4772d4..39e1a2c8b 100644 --- a/backend/internal/pkg/xai/billing.go +++ b/backend/internal/pkg/xai/billing.go @@ -19,7 +19,7 @@ const ( // repository and service layers build their own client identity from it, so // one bump here covers OAuth traffic and billing probes together. // Keep in sync with https://x.ai/cli/stable. - CLIClientVersion = "0.2.114" + CLIClientVersion = "0.2.120" // billingCLIUserAgent is the legacy pager/shell UA used by billing probes. // Distinct from CLIUserAgent() in cli_identity.go (workspace-style UA). billingCLIUserAgent = "grok-pager/" + CLIClientVersion + " grok-shell/" + CLIClientVersion + " (macos; aarch64)" diff --git a/backend/internal/pkg/xai/cli_identity_test.go b/backend/internal/pkg/xai/cli_identity_test.go index 521df7483..2057b9eff 100644 --- a/backend/internal/pkg/xai/cli_identity_test.go +++ b/backend/internal/pkg/xai/cli_identity_test.go @@ -41,7 +41,7 @@ func TestApplyCLIProxyHeaders(t *testing.T) { req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) - req.Header.Set("User-Agent", "sub2api-grok/1.0") + req.Header.Set("User-Agent", "legacy-client/1.0") ApplyCLIProxyHeaders(req) @@ -56,12 +56,12 @@ func TestApplyCLIProxyHeadersLeavesAPIHostUnchanged(t *testing.T) { req, err := http.NewRequest(http.MethodPost, "https://api.x.ai/v1/responses", nil) require.NoError(t, err) - req.Header.Set("User-Agent", "sub2api-grok/1.0") + req.Header.Set("User-Agent", "direct-api-client/1.0") ApplyCLIProxyHeaders(req) require.Empty(t, req.Header.Get("x-grok-client-version")) require.Empty(t, req.Header.Get("x-grok-client-identifier")) require.Empty(t, req.Header.Get("X-XAI-Token-Auth")) - require.Equal(t, "sub2api-grok/1.0", req.Header.Get("User-Agent")) + require.Equal(t, "direct-api-client/1.0", req.Header.Get("User-Agent")) } diff --git a/backend/internal/repository/http_upstream_test.go b/backend/internal/repository/http_upstream_test.go index ac0c064b2..3c7840dc4 100644 --- a/backend/internal/repository/http_upstream_test.go +++ b/backend/internal/repository/http_upstream_test.go @@ -17,6 +17,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -228,16 +229,16 @@ func TestHTTPUpstreamDoAppliesGrokCLIIdentityBeforeOAuthRoundTrip(t *testing.T) req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/"+endpoint, nil) require.NoError(t, err) - req.Header.Set("User-Agent", "sub2api-grok/1.0") + req.Header.Set("User-Agent", "legacy-client/1.0") resp, err := svc.Do(req, "", accountID, 1) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) require.NoError(t, resp.Body.Close()) - require.Equal(t, "0.2.114", capturedHeaders.Get("x-grok-client-version")) + require.Equal(t, xai.CLIClientVersion, capturedHeaders.Get("x-grok-client-version")) require.Equal(t, "xai-grok-cli", capturedHeaders.Get("X-XAI-Token-Auth")) - require.Equal(t, "xai-grok-workspace/0.2.114", capturedHeaders.Get("User-Agent")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), capturedHeaders.Get("User-Agent")) }) } } @@ -454,67 +455,67 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) { t.Setenv("XAI_GROK_CLI_VERSION", "") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) - req.Header.Set("User-Agent", "sub2api-grok/1.0") + req.Header.Set("User-Agent", "legacy-client/1.0") applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version")) require.Equal(t, "xai-grok-cli", req.Header.Get("X-XAI-Token-Auth")) - require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent")) }) t.Run("accepts a valid operator override", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.115-alpha.1") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.121-alpha.1") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/chat/completions", nil) require.NoError(t, err) applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.115-alpha.1", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.115-alpha.1", req.Header.Get("User-Agent")) + require.Equal(t, "0.2.121-alpha.1", req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIUserAgent("0.2.121-alpha.1"), req.Header.Get("User-Agent")) }) t.Run("rejects an unsafe override", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.115\r\nX-Injected: true") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.121\r\nX-Injected: true") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version")) require.Empty(t, req.Header.Get("X-Injected")) }) t.Run("rejects an override below the supported minimum", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.113") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.119") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent")) + require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent")) }) t.Run("rejects a prerelease override at the minimum version", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.114-beta.1") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.120-beta.1") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent")) + require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent")) }) // Every entry sits above the pinned minimum, so a rejection here can only be // caused by the malformed semver and never by the version being too old. for _, version := range []string{ - "0.2.0115", - "0.2.115-alpha..1", + "0.2.0121", + "0.2.121-alpha..1", "0.3", "1", - "0.2.115+build.1", + "0.2.121+build.1", } { t.Run("rejects invalid semver "+version, func(t *testing.T) { t.Setenv("XAI_GROK_CLI_VERSION", version) @@ -523,8 +524,8 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) { applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent")) + require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent")) }) } @@ -532,13 +533,13 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) { t.Setenv("XAI_GROK_CLI_VERSION", "0.2.95") req, err := http.NewRequest(http.MethodPost, "https://api.x.ai/v1/responses", nil) require.NoError(t, err) - req.Header.Set("User-Agent", "sub2api-grok/1.0") + req.Header.Set("User-Agent", "direct-api-client/1.0") applyGrokCLIProxyHeaders(req) require.Empty(t, req.Header.Get("x-grok-client-version")) require.Empty(t, req.Header.Get("X-XAI-Token-Auth")) - require.Equal(t, "sub2api-grok/1.0", req.Header.Get("User-Agent")) + require.Equal(t, "direct-api-client/1.0", req.Header.Get("User-Agent")) }) } diff --git a/backend/internal/service/grok_observed_models.go b/backend/internal/service/grok_observed_models.go index 480ba457b..3dba5f001 100644 --- a/backend/internal/service/grok_observed_models.go +++ b/backend/internal/service/grok_observed_models.go @@ -92,7 +92,7 @@ func (s *GrokQuotaService) syncGrokObservedModels(ctx context.Context, account * } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", grokUpstreamUserAgent) + req.Header.Set("User-Agent", defaultGrokUpstreamUserAgent()) if account.IsGrokOAuth() { applyGrokCLIHeaders(req.Header) if isGrokCLIProxyTarget(req.URL.String()) { diff --git a/backend/internal/service/grok_quota_service_test.go b/backend/internal/service/grok_quota_service_test.go index 9d94d1b98..af27b67f1 100644 --- a/backend/internal/service/grok_quota_service_test.go +++ b/backend/internal/service/grok_quota_service_test.go @@ -133,8 +133,9 @@ func TestSyncGrokObservedModelsUsesCLIIdentityAndAccountHeaders(t *testing.T) { 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.CLIClientVersion, upstream.lastReq.Header.Get("x-grok-client-version")) require.Equal(t, xai.CLIClientIdentifier, upstream.lastReq.Header.Get("x-grok-client-identifier")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), upstream.lastReq.Header.Get("User-Agent")) 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")) diff --git a/backend/internal/service/grok_upstream_headers.go b/backend/internal/service/grok_upstream_headers.go index cdfb54bcb..4b24e4c13 100644 --- a/backend/internal/service/grok_upstream_headers.go +++ b/backend/internal/service/grok_upstream_headers.go @@ -10,10 +10,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/xai" ) -// grokUpstreamUserAgent is kept for compatibility with older Grok request -// tests. Current requests use the pinned default UA from this package. -const grokUpstreamUserAgent = "sub2api-grok/1.0" - // Fixed CLI identity aliases — single source of truth is internal/pkg/xai. const ( grokClientVersionHeader = xai.CLIStableVersion diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 923033c4d..7427c6fc9 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -175,7 +175,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( SetActualOpenAIUpstreamEndpoint(c, grokChatRawEndpoint) customUA := account.GetOpenAIUserAgent() if customUA == "" && account.IsGrokOAuth() { - customUA = "sub2api-grok/1.0" + customUA = defaultGrokUpstreamUserAgent() } resp, err := s.sendCCUpstreamRequest(ctx, c, account, targetURL, upstreamBody, clientStream, token, customUA, grokCacheIdentity) if err != nil { diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index 10a2731fb..21c734235 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -22,14 +22,13 @@ import ( const ( grokComposerImageBridgeVisionModel = "grok-build-0.1" grokComposerImageBridgeMaxOutputTokens = 512 - // grokUpstreamUserAgent lives in grok_upstream_headers.go (shared with TLS header helpers). - grokCLIVersion = xai.CLIClientVersion - grokDefaultResponsesModel = "grok-4.5" - grokRateLimitFallbackCooldown = 2 * time.Minute - grokRateLimitRepeatCooldown = 10 * time.Minute - grokRateLimitSustainedCooldown = 30 * time.Minute - grokRateLimitMaxAdaptiveCooldown = time.Hour - grokRateLimitBackoffQuietPeriod = time.Hour + grokCLIVersion = xai.CLIClientVersion + grokDefaultResponsesModel = "grok-4.5" + grokRateLimitFallbackCooldown = 2 * time.Minute + grokRateLimitRepeatCooldown = 10 * time.Minute + grokRateLimitSustainedCooldown = 30 * time.Minute + grokRateLimitMaxAdaptiveCooldown = time.Hour + grokRateLimitBackoffQuietPeriod = time.Hour ) func (s *OpenAIGatewayService) forwardGrokResponses( diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index e11954a4b..d1c966ac0 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -828,7 +828,7 @@ func TestBuildGrokResponsesRequestAllowsPublicAPIKeyBaseURLByDefault(t *testing. require.Equal(t, "https://grok.example.test/v1/responses", req.URL.String()) require.Equal(t, "Bearer api-key", req.Header.Get("Authorization")) require.Empty(t, req.Header.Get("X-Grok-Client-Version")) - require.NotEqual(t, grokUpstreamUserAgent, req.Header.Get("User-Agent")) + require.NotEqual(t, defaultGrokUpstreamUserAgent(), req.Header.Get("User-Agent")) } func TestBuildGrokResponsesRequestHonorsOAuthOfficialEndpointSwitch(t *testing.T) { @@ -1132,7 +1132,7 @@ func TestForwardGrokMediaImagesGenerationNormalizesImagineAlias(t *testing.T) { require.Equal(t, "Bearer api-key", upstream.lastReq.Header.Get("Authorization")) require.Equal(t, "application/json", upstream.lastReq.Header.Get("Content-Type")) require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version")) - require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent")) + require.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent")) require.JSONEq(t, `{"model":"grok-imagine-image-quality","prompt":"draw a cat"}`, string(upstream.lastBody)) require.Equal(t, http.StatusOK, recorder.Code) require.JSONEq(t, `{"data":[{"url":"https://images.test/cat.png"}]}`, recorder.Body.String()) @@ -1646,7 +1646,7 @@ func TestForwardGrokMediaVideoStatusUsesGETWithoutBody(t *testing.T) { require.Equal(t, http.MethodGet, upstream.lastReq.Method) require.Equal(t, "Bearer api-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.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent")) require.Empty(t, upstream.lastReq.Header.Get("Content-Type")) require.Empty(t, upstream.lastBody) require.Equal(t, http.StatusOK, recorder.Code) @@ -1982,7 +1982,7 @@ func TestForwardGrokResponsesAPIKeyUsesXAIResponses(t *testing.T) { 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")) require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version")) - require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent")) + require.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent")) 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) @@ -2259,7 +2259,7 @@ func TestForwardAsChatCompletionsForGrokAPIKeyUsesConfiguredRawEndpointWithoutOA require.Equal(t, "https://grok.example.test/v1/chat/completions", upstream.lastReq.URL.String()) require.Equal(t, "Bearer third-party-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.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent")) } func TestForwardAsChatCompletionsForGrokAPIKeyRejectsNonStreamingResponseWithoutUsage(t *testing.T) {