From 90654da573200bc4d910982a35f2d3ca4da2f8a5 Mon Sep 17 00:00:00 2001 From: Jellyfish Date: Thu, 8 Oct 2026 00:56:02 +0800 Subject: [PATCH] feat(xai): dynamically fetch latest Grok CLI version from npm registry with fallback - Fetch latest @xai-official/grok client version from npm registry on startup - Periodically refresh Grok CLI version every 3 hours without multiple retries - Update hardcoded fallback version to 1.0.50 if npm registry is unreachable - Dynamically inject active version into x-grok-client-version and User-Agent headers --- cmd/server/main.go | 3 + .../runtime/executor/helps/xai_version.go | 178 ++++++++++++++++++ .../executor/helps/xai_version_test.go | 143 ++++++++++++++ .../executor/xai_client_version_test.go | 23 +++ internal/runtime/executor/xai_executor.go | 15 +- .../runtime/executor/xai_executor_request.go | 5 +- sdk/cliproxy/service_lifecycle.go | 2 + 7 files changed, 366 insertions(+), 3 deletions(-) create mode 100644 internal/runtime/executor/helps/xai_version.go create mode 100644 internal/runtime/executor/helps/xai_version_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 98139c7b4..c79e99d97 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -31,6 +31,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v8/internal/pluginhost" "github.com/router-for-me/CLIProxyAPI/v8/internal/redisqueue" "github.com/router-for-me/CLIProxyAPI/v8/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor" "github.com/router-for-me/CLIProxyAPI/v8/internal/safemode" "github.com/router-for-me/CLIProxyAPI/v8/internal/store" _ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator" @@ -749,6 +750,7 @@ func main() { // Standalone mode: start an embedded local server and connect TUI client to it. managementasset.StartAutoUpdater(context.Background(), configFilePath) misc.StartAntigravityVersionUpdater(context.Background()) + executor.StartXAIVersionUpdater(context.Background()) registry.SetLocalModelCatalogs(localModel) hook := tui.NewLogHook(2000) hook.SetFormatter(&logging.LogFormatter{}) @@ -824,6 +826,7 @@ func main() { // Start the main proxy service managementasset.StartAutoUpdater(context.Background(), configFilePath) misc.StartAntigravityVersionUpdater(context.Background()) + executor.StartXAIVersionUpdater(context.Background()) registry.SetLocalModelCatalogs(localModel) cmd.StartServiceWithPluginHost(cfg, configFilePath, password, pluginHost, serverOptions...) } diff --git a/internal/runtime/executor/helps/xai_version.go b/internal/runtime/executor/helps/xai_version.go new file mode 100644 index 000000000..9e70163ac --- /dev/null +++ b/internal/runtime/executor/helps/xai_version.go @@ -0,0 +1,178 @@ +package helps + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/tidwall/gjson" +) + +const ( + // DefaultXAIFallbackClientVersion is the fallback Grok CLI version when npm registry resolution fails. + DefaultXAIFallbackClientVersion = "1.0.50" + // XAIVersionRefreshInterval is the periodic interval to check for Grok CLI updates from npm. + XAIVersionRefreshInterval = 3 * time.Hour + // XAIVersionFetchTimeout is the maximum duration for a single npm registry lookup. + XAIVersionFetchTimeout = 10 * time.Second +) + +var ( + xaiNPMRegistryURL = "https://registry.npmjs.org/@xai-official/grok/latest" +) + +var ( + cachedXAIClientVersion = DefaultXAIFallbackClientVersion + xaiClientVersionMu sync.RWMutex + xaiVersionUpdaterOnce sync.Once +) + +// GetXAIClientVersion returns the current Grok CLI client version. +// If the background updater has fetched a newer version from npm, it returns that version; +// otherwise it returns DefaultXAIFallbackClientVersion. +func GetXAIClientVersion() string { + xaiClientVersionMu.RLock() + defer xaiClientVersionMu.RUnlock() + return cachedXAIClientVersion +} + +// StartXAIVersionUpdater starts a background goroutine that periodically refreshes the Grok CLI version from npm. +// It executes a single fetch on startup and then polls every 3 hours without multiple retries on failure. +func StartXAIVersionUpdater(ctx context.Context) { + xaiVersionUpdaterOnce.Do(func() { + go runXAIVersionUpdater(ctx) + }) +} + +func runXAIVersionUpdater(ctx context.Context) { + if ctx == nil { + ctx = context.Background() + } + + // Fetch once on startup + refreshXAIClientVersion(ctx) + + ticker := time.NewTicker(XAIVersionRefreshInterval) + defer ticker.Stop() + + log.Infof("periodic Grok CLI version refresh started (interval=%s)", XAIVersionRefreshInterval) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + refreshXAIClientVersion(ctx) + } + } +} + +func refreshXAIClientVersion(ctx context.Context) { + version, errFetch := FetchXAINPMLatestVersion(ctx, nil) + if errFetch != nil { + log.WithError(errFetch).Warn("failed to fetch latest Grok CLI version from npm, keeping fallback/cached version") + return + } + + if version == "" { + log.Warn("fetched empty Grok CLI version from npm, keeping fallback/cached version") + return + } + + xaiClientVersionMu.Lock() + cachedXAIClientVersion = version + xaiClientVersionMu.Unlock() + + log.WithField("version", version).Info("updated Grok CLI client version from npm") +} + +// FetchXAINPMLatestVersion performs a single request to the npm registry to query the latest version of @xai-official/grok. +func FetchXAINPMLatestVersion(ctx context.Context, client *http.Client) (string, error) { + if ctx == nil { + ctx = context.Background() + } + + fetchCtx, cancel := context.WithTimeout(ctx, XAIVersionFetchTimeout) + defer cancel() + + xaiClientVersionMu.RLock() + registryURL := xaiNPMRegistryURL + xaiClientVersionMu.RUnlock() + + req, errReq := http.NewRequestWithContext(fetchCtx, http.MethodGet, registryURL, nil) + if errReq != nil { + return "", fmt.Errorf("create npm registry request: %w", errReq) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "CLIProxyAPI") + + if client == nil { + client = &http.Client{Timeout: XAIVersionFetchTimeout} + } + + resp, errDo := client.Do(req) + if errDo != nil { + return "", fmt.Errorf("npm registry request failed: %w", errDo) + } + defer func() { + if errClose := resp.Body.Close(); errClose != nil { + log.Errorf("failed to close npm response body: %v", errClose) + } + }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("npm registry returned HTTP %d", resp.StatusCode) + } + + body, errRead := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if errRead != nil { + return "", fmt.Errorf("read npm registry response: %w", errRead) + } + + version := strings.TrimSpace(gjson.GetBytes(body, "version").String()) + if version == "" { + return "", errors.New("version not found in npm response") + } + + return version, nil +} + +// OverrideXAINPMRegistryURLForTest overrides the npm registry URL for testing purposes. +func OverrideXAINPMRegistryURLForTest(t *testing.T, url string) func() { + xaiClientVersionMu.Lock() + oldURL := xaiNPMRegistryURL + xaiNPMRegistryURL = url + xaiClientVersionMu.Unlock() + return func() { + xaiClientVersionMu.Lock() + xaiNPMRegistryURL = oldURL + xaiClientVersionMu.Unlock() + } +} + +// SetXAIClientVersionForTest sets the cached Grok CLI version directly for testing purposes. +func SetXAIClientVersionForTest(t *testing.T, version string) func() { + xaiClientVersionMu.Lock() + old := cachedXAIClientVersion + cachedXAIClientVersion = version + xaiClientVersionMu.Unlock() + return func() { + xaiClientVersionMu.Lock() + cachedXAIClientVersion = old + xaiClientVersionMu.Unlock() + } +} + +// ResetXAIVersionUpdaterOnceForTest resets the sync.Once for testing updater initialization. +func ResetXAIVersionUpdaterOnceForTest(t *testing.T) { + xaiClientVersionMu.Lock() + xaiVersionUpdaterOnce = sync.Once{} + xaiClientVersionMu.Unlock() +} diff --git a/internal/runtime/executor/helps/xai_version_test.go b/internal/runtime/executor/helps/xai_version_test.go new file mode 100644 index 000000000..700202ced --- /dev/null +++ b/internal/runtime/executor/helps/xai_version_test.go @@ -0,0 +1,143 @@ +package helps + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestGetXAIClientVersionDefault(t *testing.T) { + restore := SetXAIClientVersionForTest(t, DefaultXAIFallbackClientVersion) + defer restore() + + if got := GetXAIClientVersion(); got != DefaultXAIFallbackClientVersion { + t.Fatalf("GetXAIClientVersion() = %q, want %q", got, DefaultXAIFallbackClientVersion) + } +} + +func TestFetchXAINPMLatestVersionSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Accept"); got != "application/json" { + t.Errorf("Accept header = %q, want application/json", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"@xai-official/grok","version":"1.0.52"}`)) + })) + defer server.Close() + + restore := OverrideXAINPMRegistryURLForTest(t, server.URL) + defer restore() + + version, err := FetchXAINPMLatestVersion(context.Background(), server.Client()) + if err != nil { + t.Fatalf("FetchXAINPMLatestVersion() error = %v", err) + } + if version != "1.0.52" { + t.Fatalf("FetchXAINPMLatestVersion() = %q, want 1.0.52", version) + } +} + +func TestFetchXAINPMLatestVersionHttpError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "server error", http.StatusInternalServerError) + })) + defer server.Close() + + restore := OverrideXAINPMRegistryURLForTest(t, server.URL) + defer restore() + + _, err := FetchXAINPMLatestVersion(context.Background(), server.Client()) + if err == nil { + t.Fatal("FetchXAINPMLatestVersion() expected error for HTTP 500, got nil") + } +} + +func TestFetchXAINPMLatestVersionMissingVersion(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"@xai-official/grok"}`)) + })) + defer server.Close() + + restore := OverrideXAINPMRegistryURLForTest(t, server.URL) + defer restore() + + _, err := FetchXAINPMLatestVersion(context.Background(), server.Client()) + if err == nil { + t.Fatal("FetchXAINPMLatestVersion() expected error for missing version, got nil") + } +} + +func TestRefreshXAIClientVersionKeepsFallbackOnError(t *testing.T) { + restoreVersion := SetXAIClientVersionForTest(t, "1.0.50") + defer restoreVersion() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "offline", http.StatusBadGateway) + })) + defer server.Close() + + restoreURL := OverrideXAINPMRegistryURLForTest(t, server.URL) + defer restoreURL() + + refreshXAIClientVersion(context.Background()) + + if got := GetXAIClientVersion(); got != "1.0.50" { + t.Fatalf("GetXAIClientVersion() = %q, want fallback 1.0.50 preserved on error", got) + } +} + +func TestRefreshXAIClientVersionUpdatesOnSuccess(t *testing.T) { + restoreVersion := SetXAIClientVersionForTest(t, "1.0.50") + defer restoreVersion() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"@xai-official/grok","version":"1.0.55"}`)) + })) + defer server.Close() + + restoreURL := OverrideXAINPMRegistryURLForTest(t, server.URL) + defer restoreURL() + + refreshXAIClientVersion(context.Background()) + + if got := GetXAIClientVersion(); got != "1.0.55" { + t.Fatalf("GetXAIClientVersion() = %q, want updated version 1.0.55", got) + } +} + +func TestStartXAIVersionUpdaterLifecycle(t *testing.T) { + ResetXAIVersionUpdaterOnceForTest(t) + restoreVersion := SetXAIClientVersionForTest(t, "1.0.50") + defer restoreVersion() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"@xai-official/grok","version":"1.0.60"}`)) + })) + defer server.Close() + + restoreURL := OverrideXAINPMRegistryURLForTest(t, server.URL) + defer restoreURL() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + StartXAIVersionUpdater(ctx) + + // Wait briefly for the startup fetch to settle + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if GetXAIClientVersion() == "1.0.60" { + break + } + time.Sleep(20 * time.Millisecond) + } + + if got := GetXAIClientVersion(); got != "1.0.60" { + t.Fatalf("GetXAIClientVersion() = %q, want 1.0.60 after StartXAIVersionUpdater", got) + } +} diff --git a/internal/runtime/executor/xai_client_version_test.go b/internal/runtime/executor/xai_client_version_test.go index 784780875..6b153132a 100644 --- a/internal/runtime/executor/xai_client_version_test.go +++ b/internal/runtime/executor/xai_client_version_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" ) @@ -74,3 +75,25 @@ func TestXAIChatProxyCustomHeadersOverridePinnedVersion(t *testing.T) { t.Fatalf("%s = %q, want the per-auth custom header to override the pin", xaiClientVersionHeader, got) } } + +func TestXAIChatProxyIdentityHeadersFollowDynamicVersion(t *testing.T) { + restore := helps.SetXAIClientVersionForTest(t, "1.2.34") + defer restore() + + auth := &cliproxyauth.Auth{ + Provider: "xai", + Attributes: map[string]string{"auth_kind": "oauth"}, + } + req, err := http.NewRequest(http.MethodPost, xaiChatBaseURL(auth)+"/chat/completions", nil) + if err != nil { + t.Fatal(err) + } + applyXAIChatHeaders(req, auth, "token", false, "") + + if got := req.Header.Get(xaiClientVersionHeader); got != "1.2.34" { + t.Fatalf("%s = %q, want dynamic version 1.2.34", xaiClientVersionHeader, got) + } + if got, want := req.Header.Get("User-Agent"), "xai-grok-workspace/1.2.34"; got != want { + t.Fatalf("User-Agent = %q, want %q", got, want) + } +} diff --git a/internal/runtime/executor/xai_executor.go b/internal/runtime/executor/xai_executor.go index 3629697ff..7f97b2b54 100644 --- a/internal/runtime/executor/xai_executor.go +++ b/internal/runtime/executor/xai_executor.go @@ -53,7 +53,9 @@ const ( // Keep in sync with the current Grok CLI client version that chat-proxy // expects. The server rejects older versions with HTTP 426; it required // 1.0.13+ as of 2026-10-01 (#6249). - xaiClientVersionValue = "1.0.44" + // This hardcoded value serves as fallback if npm registry resolution fails. + xaiClientVersionFallback = helps.DefaultXAIFallbackClientVersion + xaiClientVersionValue = xaiClientVersionFallback xaiClientIdentifierHeader = "x-grok-client-identifier" xaiClientIdentifierValue = "grok-shell" xaiAuthenticateResponseHeader = "x-authenticateresponse" @@ -76,6 +78,17 @@ func NewXAIExecutor(cfg *config.Config) *XAIExecutor { return &XAIExecutor{cfg: cfg} } +// xaiClientVersion returns the active Grok CLI client version (dynamically fetched +// from npm or falling back to xaiClientVersionFallback). +func xaiClientVersion() string { + return helps.GetXAIClientVersion() +} + +// StartXAIVersionUpdater starts the periodic Grok CLI version updater from npm. +func StartXAIVersionUpdater(ctx context.Context) { + helps.StartXAIVersionUpdater(ctx) +} + // Identifier returns the provider identifier. func (e *XAIExecutor) Identifier() string { return "xai" diff --git a/internal/runtime/executor/xai_executor_request.go b/internal/runtime/executor/xai_executor_request.go index 1d3ab89b2..edd6bb02c 100644 --- a/internal/runtime/executor/xai_executor_request.go +++ b/internal/runtime/executor/xai_executor_request.go @@ -354,9 +354,10 @@ func applyXAIChatHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, } applyXAIDefaultHeaders(r, token, stream, sessionID) if xaiIsCLIChatProxyBaseURL(xaiChatBaseURL(auth)) { + clientVer := xaiClientVersion() r.Header.Set(xaiTokenAuthHeader, xaiTokenAuthValue) - r.Header.Set(xaiClientVersionHeader, xaiClientVersionValue) - r.Header.Set("User-Agent", "xai-grok-workspace/"+xaiClientVersionValue) + r.Header.Set(xaiClientVersionHeader, clientVer) + r.Header.Set("User-Agent", "xai-grok-workspace/"+clientVer) r.Header.Set(xaiClientIdentifierHeader, xaiClientIdentifierValue) r.Header.Set(xaiAuthenticateResponseHeader, xaiAuthenticateResponseValue) } diff --git a/sdk/cliproxy/service_lifecycle.go b/sdk/cliproxy/service_lifecycle.go index d2aa6af94..631e56f2c 100644 --- a/sdk/cliproxy/service_lifecycle.go +++ b/sdk/cliproxy/service_lifecycle.go @@ -11,6 +11,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v8/internal/home" "github.com/router-for-me/CLIProxyAPI/v8/internal/redisqueue" "github.com/router-for-me/CLIProxyAPI/v8/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor" sdkaccess "github.com/router-for-me/CLIProxyAPI/v8/sdk/access" sdkAuth "github.com/router-for-me/CLIProxyAPI/v8/sdk/auth" coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" @@ -397,4 +398,5 @@ func (s *Service) startModelCatalogUpdaters(ctx context.Context) { if cfg != nil { registry.StartModelCatalogUpdaters(ctx, cfg.Models, cfg.Home.Enabled) } + executor.StartXAIVersionUpdater(ctx) }