mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-29 02:54:11 +08:00
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
This commit is contained in:
@@ -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...)
|
||||
}
|
||||
|
||||
178
internal/runtime/executor/helps/xai_version.go
Normal file
178
internal/runtime/executor/helps/xai_version.go
Normal file
@@ -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()
|
||||
}
|
||||
143
internal/runtime/executor/helps/xai_version_test.go
Normal file
143
internal/runtime/executor/helps/xai_version_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user