From fdce5b8d01ccd2dc523543fda05940288912d574 Mon Sep 17 00:00:00 2001 From: lyen1688 Date: Thu, 1 Oct 2026 12:04:39 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Grok=20CLI=20=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E9=97=A8=E6=A7=9B=E5=B9=B6=E5=AF=B9=E9=BD=90=E5=AE=98?= =?UTF-8?q?=E6=96=B9=E6=97=A0=E7=95=8C=E9=9D=A2=E8=AF=B7=E6=B1=82=E5=A4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/pkg/xai/billing.go | 8 ++- backend/internal/pkg/xai/billing_test.go | 3 +- backend/internal/pkg/xai/cli_identity.go | 25 +++++++-- backend/internal/pkg/xai/cli_identity_test.go | 51 +++++++++++++++---- backend/internal/repository/http_upstream.go | 4 ++ .../internal/repository/http_upstream_test.go | 30 ++++++----- .../service/grok_quota_service_test.go | 2 +- .../internal/service/grok_upstream_headers.go | 2 +- .../service/grok_upstream_headers_test.go | 6 +-- .../internal/service/openai_gateway_grok.go | 4 +- .../internal/service/upstream_models_test.go | 2 +- 11 files changed, 97 insertions(+), 40 deletions(-) diff --git a/backend/internal/pkg/xai/billing.go b/backend/internal/pkg/xai/billing.go index 39e1a2c8b7..735baa86db 100644 --- a/backend/internal/pkg/xai/billing.go +++ b/backend/internal/pkg/xai/billing.go @@ -19,10 +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.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)" + CLIClientVersion = "1.0.46" BillingWeeklyPath = "/billing?format=credits" BillingMonthlyPath = "/billing" @@ -148,7 +145,8 @@ func ApplyCLIBillingHeaders(req *http.Request, accessToken string) { req.Header.Set("Content-Type", "application/json") req.Header.Set(CLITokenAuthHeader, CLITokenAuthValue) req.Header.Set(CLIClientVersionHeader, CLIClientVersion) - req.Header.Set("User-Agent", billingCLIUserAgent) + req.Header.Set("User-Agent", CLIUserAgent(CLIClientVersion)) + req.Header.Set("x-grok-client-mode", CLIClientMode) } // ParseBillingPayload unmarshals a billing API response body. diff --git a/backend/internal/pkg/xai/billing_test.go b/backend/internal/pkg/xai/billing_test.go index 3dbfc89bf2..26bb256912 100644 --- a/backend/internal/pkg/xai/billing_test.go +++ b/backend/internal/pkg/xai/billing_test.go @@ -38,7 +38,8 @@ func TestApplyCLIBillingHeaders(t *testing.T) { require.Equal(t, "Bearer token", req.Header.Get("Authorization")) require.Equal(t, CLITokenAuthValue, req.Header.Get(CLITokenAuthHeader)) require.Equal(t, CLIClientVersion, req.Header.Get(CLIClientVersionHeader)) - require.Equal(t, "grok-pager/"+CLIClientVersion+" grok-shell/"+CLIClientVersion+" (macos; aarch64)", req.UserAgent()) + require.Equal(t, CLIUserAgent(CLIClientVersion), req.UserAgent()) + require.Equal(t, "headless", req.Header.Get("x-grok-client-mode")) } func TestBuildBillingSummaryWeeklyAndMonthly(t *testing.T) { diff --git a/backend/internal/pkg/xai/cli_identity.go b/backend/internal/pkg/xai/cli_identity.go index 480e2e40af..038a9f21ce 100644 --- a/backend/internal/pkg/xai/cli_identity.go +++ b/backend/internal/pkg/xai/cli_identity.go @@ -3,6 +3,7 @@ package xai import ( "net/http" "os" + "runtime" "strings" "golang.org/x/mod/semver" @@ -16,7 +17,7 @@ const ( CLIProxyHost = "cli-chat-proxy.grok.com" // CLIStableVersion is the known-good minimum client version accepted by cli-chat-proxy. - CLIStableVersion = "0.2.93" + CLIStableVersion = "1.0.13" // CLIVersionEnv is the optional operator override for CLIStableVersion. CLIVersionEnv = "XAI_GROK_CLI_VERSION" @@ -27,8 +28,8 @@ const ( // CLIClientIdentifier is the x-grok-client-identifier value used by Grok shell/CLI. CLIClientIdentifier = "grok-shell" - // CLIClientMode is used by billing / quota probes on the CLI surface. - CLIClientMode = "cli" + // CLIClientMode 对齐官方 CLI 无界面运行模式。 + CLIClientMode = "headless" ) // ResolveCLIVersion returns a supported CLI client version. @@ -54,12 +55,24 @@ func IsSupportedCLIVersion(version string) bool { semver.Compare(canonical, minimum) >= 0 } -// CLIUserAgent builds the workspace-style User-Agent for a CLI client version. +// CLIUserAgent 对齐官方 CLI 无界面模式的 UA,平台名称使用 Rust 的格式。 func CLIUserAgent(version string) string { if strings.TrimSpace(version) == "" { version = CLIClientVersion } - return "xai-grok-workspace/" + version + platform, arch := runtime.GOOS, runtime.GOARCH + if platform == "darwin" { + platform = "macos" + } + switch arch { + case "amd64": + arch = "x86_64" + case "arm64": + arch = "aarch64" + case "386": + arch = "x86" + } + return "grok-shell/" + version + " (" + platform + "; " + arch + ")" } // ApplyCLIProxyHeaders stamps the fixed Grok CLI identity when the request @@ -75,5 +88,7 @@ func ApplyCLIProxyHeaders(req *http.Request) { req.Header.Set("X-XAI-Token-Auth", CLITokenAuth) req.Header.Set("x-grok-client-version", version) req.Header.Set("x-grok-client-identifier", CLIClientIdentifier) + req.Header.Set("x-grok-client-mode", CLIClientMode) + req.Header.Set("x-authenticateresponse", "authenticate-response") req.Header.Set("User-Agent", CLIUserAgent(version)) } diff --git a/backend/internal/pkg/xai/cli_identity_test.go b/backend/internal/pkg/xai/cli_identity_test.go index 2057b9effd..8566ce6e1f 100644 --- a/backend/internal/pkg/xai/cli_identity_test.go +++ b/backend/internal/pkg/xai/cli_identity_test.go @@ -2,11 +2,40 @@ package xai import ( "net/http" + "runtime" "testing" "github.com/stretchr/testify/require" + "golang.org/x/mod/semver" ) +func TestApplyCLIProxyHeadersMeetsUpstreamMinimumVersion(t *testing.T) { + // 上游 426 明确要求至少 1.0.13;断言独立于生产版本常量,防止旧覆盖值绕过下限。 + for _, override := range []string{"", "0.2.93", "0.2.120", "1.0.12", "1.0.13-beta.1", "1.0.13", "1.0.14-alpha.1"} { + t.Run("override="+override, func(t *testing.T) { + t.Setenv(CLIVersionEnv, override) + req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) + require.NoError(t, err) + + ApplyCLIProxyHeaders(req) + + version := req.Header.Get("x-grok-client-version") + require.True(t, semver.IsValid("v"+version)) + require.GreaterOrEqual(t, semver.Compare("v"+version, "v1.0.13"), 0, + "Grok Responses 会以 426 拒绝版本 %s,最低要求为 1.0.13", version) + require.Contains(t, req.Header.Get("User-Agent"), "grok-shell/"+version+" (") + }) + } +} + +func TestCLIUserAgentMatchesOfficialHeadlessCapture(t *testing.T) { + // 来源:官方 CLI 1.0.46 在 Linux x86_64 的本地 Responses 抓包。 + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + t.Skip("该抓包样本来自 Linux x86_64") + } + require.Equal(t, "grok-shell/1.0.46 (linux; x86_64)", CLIUserAgent("1.0.46")) +} + func TestResolveCLIVersionDefaultsToPinnedClientVersion(t *testing.T) { t.Setenv(CLIVersionEnv, "") // Default advertise pin is CLIClientVersion; CLIStableVersion is only the floor. @@ -16,18 +45,18 @@ func TestResolveCLIVersionDefaultsToPinnedClientVersion(t *testing.T) { } func TestResolveCLIVersionAcceptsValidOverride(t *testing.T) { - t.Setenv(CLIVersionEnv, "0.2.95-alpha.1") - require.Equal(t, "0.2.95-alpha.1", ResolveCLIVersion()) + t.Setenv(CLIVersionEnv, "1.0.14-alpha.1") + require.Equal(t, "1.0.14-alpha.1", ResolveCLIVersion()) } func TestResolveCLIVersionRejectsUnsafeOrTooOld(t *testing.T) { for _, version := range []string{ - "0.2.92", - "0.2.93-beta.1", - "0.2.95\r\nX-Injected: true", - "0.2.093", - "0.3", - "1", + "1.0.12", + "1.0.13-beta.1", + "1.0.14\r\nX-Injected: true", + "1.0.014", + "1.1", + "2", } { t.Run(version, func(t *testing.T) { t.Setenv(CLIVersionEnv, version) @@ -48,11 +77,13 @@ func TestApplyCLIProxyHeaders(t *testing.T) { require.Equal(t, CLIClientVersion, req.Header.Get("x-grok-client-version")) require.Equal(t, CLIClientIdentifier, req.Header.Get("x-grok-client-identifier")) require.Equal(t, CLITokenAuth, req.Header.Get("X-XAI-Token-Auth")) + require.Equal(t, "headless", req.Header.Get("x-grok-client-mode")) + require.Equal(t, "authenticate-response", req.Header.Get("x-authenticateresponse")) require.Equal(t, CLIUserAgent(CLIClientVersion), req.Header.Get("User-Agent")) } func TestApplyCLIProxyHeadersLeavesAPIHostUnchanged(t *testing.T) { - t.Setenv(CLIVersionEnv, "0.2.95") + t.Setenv(CLIVersionEnv, "1.0.14") req, err := http.NewRequest(http.MethodPost, "https://api.x.ai/v1/responses", nil) require.NoError(t, err) @@ -63,5 +94,7 @@ func TestApplyCLIProxyHeadersLeavesAPIHostUnchanged(t *testing.T) { 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.Empty(t, req.Header.Get("x-grok-client-mode")) + require.Empty(t, req.Header.Get("x-authenticateresponse")) require.Equal(t, "direct-api-client/1.0", req.Header.Get("User-Agent")) } diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index b27f20ece3..ff19b03d83 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -466,6 +466,8 @@ func newGrokOfficialAPIFallbackRequest(req *http.Request) (*http.Request, error) for _, header := range []string{ "X-XAI-Token-Auth", "X-Grok-Client-Version", + "X-Grok-Client-Mode", + "X-Authenticateresponse", "X-Grok-Client-Surface", "X-UserID", "X-Email", @@ -531,6 +533,8 @@ func applyGrokCLIProxyHeaders(req *http.Request) { req.Header.Set("X-XAI-Token-Auth", xai.CLITokenAuth) req.Header.Set("x-grok-client-version", version) req.Header.Set("x-grok-client-identifier", xai.CLIClientIdentifier) + req.Header.Set("x-grok-client-mode", xai.CLIClientMode) + req.Header.Set("x-authenticateresponse", "authenticate-response") req.Header.Set("User-Agent", xai.CLIUserAgent(version)) } diff --git a/backend/internal/repository/http_upstream_test.go b/backend/internal/repository/http_upstream_test.go index 49881ae511..729aa270d1 100644 --- a/backend/internal/repository/http_upstream_test.go +++ b/backend/internal/repository/http_upstream_test.go @@ -237,6 +237,10 @@ func TestHTTPUpstreamDoAppliesGrokCLIIdentityBeforeOAuthRoundTrip(t *testing.T) require.NoError(t, resp.Body.Close()) require.Equal(t, xai.CLIClientVersion, capturedHeaders.Get("x-grok-client-version")) + require.Equal(t, "1.0.46", capturedHeaders.Get("x-grok-client-version")) + require.Equal(t, "grok-shell", capturedHeaders.Get("x-grok-client-identifier")) + require.Equal(t, "headless", capturedHeaders.Get("x-grok-client-mode")) + require.Equal(t, "authenticate-response", capturedHeaders.Get("x-authenticateresponse")) require.Equal(t, "xai-grok-cli", capturedHeaders.Get("X-XAI-Token-Auth")) require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), capturedHeaders.Get("User-Agent")) }) @@ -309,6 +313,8 @@ func TestHTTPUpstreamDoFallsBackToOfficialGrokAPIOnCLIAccessDenied(t *testing.T) require.Equal(t, "Bearer oauth-token", fallbackHeaders.Get("Authorization")) require.Empty(t, fallbackHeaders.Get("X-XAI-Token-Auth")) require.Empty(t, fallbackHeaders.Get("x-grok-client-version")) + require.Empty(t, fallbackHeaders.Get("x-grok-client-mode")) + require.Empty(t, fallbackHeaders.Get("x-authenticateresponse")) require.Empty(t, fallbackHeaders.Get("User-Agent")) } @@ -465,18 +471,18 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) { }) t.Run("accepts a valid operator override", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.121-alpha.1") + t.Setenv("XAI_GROK_CLI_VERSION", "1.0.47-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.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")) + require.Equal(t, "1.0.47-alpha.1", req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIUserAgent("1.0.47-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.121\r\nX-Injected: true") + t.Setenv("XAI_GROK_CLI_VERSION", "1.0.47\r\nX-Injected: true") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) @@ -487,7 +493,7 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) { }) t.Run("rejects an override below the supported minimum", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.119") + t.Setenv("XAI_GROK_CLI_VERSION", "1.0.45") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) @@ -498,7 +504,7 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) { }) t.Run("rejects a prerelease override at the minimum version", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.120-beta.1") + t.Setenv("XAI_GROK_CLI_VERSION", "1.0.46-beta.1") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) @@ -511,11 +517,11 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) { // 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.0121", - "0.2.121-alpha..1", - "0.3", - "1", - "0.2.121+build.1", + "1.0.047", + "1.0.47-alpha..1", + "1.1", + "2", + "1.0.47+build.1", } { t.Run("rejects invalid semver "+version, func(t *testing.T) { t.Setenv("XAI_GROK_CLI_VERSION", version) @@ -530,7 +536,7 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) { } t.Run("leaves direct xAI API requests unchanged", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.95") + t.Setenv("XAI_GROK_CLI_VERSION", "1.0.47") req, err := http.NewRequest(http.MethodPost, "https://api.x.ai/v1/responses", nil) require.NoError(t, err) req.Header.Set("User-Agent", "direct-api-client/1.0") diff --git a/backend/internal/service/grok_quota_service_test.go b/backend/internal/service/grok_quota_service_test.go index af27b67f12..8605cdeee9 100644 --- a/backend/internal/service/grok_quota_service_test.go +++ b/backend/internal/service/grok_quota_service_test.go @@ -136,7 +136,7 @@ func TestSyncGrokObservedModelsUsesCLIIdentityAndAccountHeaders(t *testing.T) { 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, "headless", 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) diff --git a/backend/internal/service/grok_upstream_headers.go b/backend/internal/service/grok_upstream_headers.go index 4b24e4c13b..3592af402b 100644 --- a/backend/internal/service/grok_upstream_headers.go +++ b/backend/internal/service/grok_upstream_headers.go @@ -17,7 +17,7 @@ const ( grokClientModeHeader = xai.CLIClientMode ) -// defaultGrokUpstreamUserAgent is the pinned Grok CLI / workspace UA. +// defaultGrokUpstreamUserAgent 使用固定版本的官方 CLI 无界面模式 UA。 // Grok upstream must not forward Claude Code / Codex / browser client UAs. func defaultGrokUpstreamUserAgent() string { return xai.CLIUserAgent(xai.ResolveCLIVersion()) diff --git a/backend/internal/service/grok_upstream_headers_test.go b/backend/internal/service/grok_upstream_headers_test.go index 18972f4801..0e06ac94d8 100644 --- a/backend/internal/service/grok_upstream_headers_test.go +++ b/backend/internal/service/grok_upstream_headers_test.go @@ -28,7 +28,7 @@ func TestApplyDefaultGrokUpstreamHeadersUsesCLIUserAgent(t *testing.T) { } func TestApplyDefaultGrokUpstreamHeadersHonorsCLIVersionOverride(t *testing.T) { - t.Setenv(xai.CLIVersionEnv, "0.2.95") + t.Setenv(xai.CLIVersionEnv, "1.0.14") req, err := http.NewRequest(http.MethodGet, "https://api.x.ai/v1/responses", nil) require.NoError(t, err) @@ -36,8 +36,8 @@ func TestApplyDefaultGrokUpstreamHeadersHonorsCLIVersionOverride(t *testing.T) { applyDefaultGrokUpstreamHeaders(req) - require.Equal(t, "0.2.95", req.Header.Get("x-grok-client-version")) - require.Equal(t, xai.CLIUserAgent("0.2.95"), req.Header.Get("User-Agent")) + require.Equal(t, "1.0.14", req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIUserAgent("1.0.14"), req.Header.Get("User-Agent")) require.Equal(t, "grok-shell", req.Header.Get("x-grok-client-identifier")) } diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index 06a17a961e..f165ee074b 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -1613,8 +1613,8 @@ func applyGrokCLIHeaders(headers http.Header) { headers.Set("X-Grok-Client-Version", version) headers.Set("x-grok-client-version", version) headers.Set("x-grok-client-identifier", xai.CLIClientIdentifier) - // Historical mode value expected by some unit tests / older CLI probes. - headers.Set("X-Grok-Client-Mode", "interactive") + // 对齐官方 CLI 无界面模式,网关请求与额度探测共用身份。 + headers.Set("X-Grok-Client-Mode", xai.CLIClientMode) } func (s *OpenAIGatewayService) updateGrokUsageSnapshot(ctx context.Context, account *Account, snapshot *xai.QuotaSnapshot) { diff --git a/backend/internal/service/upstream_models_test.go b/backend/internal/service/upstream_models_test.go index e0e9f263ec..53e8bb3f35 100644 --- a/backend/internal/service/upstream_models_test.go +++ b/backend/internal/service/upstream_models_test.go @@ -334,7 +334,7 @@ func TestBuildUpstreamModelsRequestSupportsGrokOAuth(t *testing.T) { require.Equal(t, "https://cli-chat-proxy.grok.com/v1/models", req.URL.String()) require.Equal(t, "Bearer oauth-access-token", req.Header.Get("Authorization")) require.Equal(t, grokCLIVersion, req.Header.Get("X-Grok-Client-Version")) - require.Equal(t, "interactive", req.Header.Get("X-Grok-Client-Mode")) + require.Equal(t, "headless", req.Header.Get("X-Grok-Client-Mode")) require.Equal(t, defaultGrokUpstreamUserAgent(), req.Header.Get("User-Agent")) require.Equal(t, "grok-user-id", req.Header.Get("X-UserID")) require.Equal(t, "grok-user@example.com", req.Header.Get("X-Email"))