diff --git a/internal/auth/xai/types.go b/internal/auth/xai/types.go index 0a2b8208..c41f7ebc 100644 --- a/internal/auth/xai/types.go +++ b/internal/auth/xai/types.go @@ -4,8 +4,12 @@ package xai import "time" const ( - // DefaultAPIBaseURL is the default xAI Responses API base URL. + // DefaultAPIBaseURL is the default official xAI API base URL. + // Used for OAuth credential defaults, websocket, and media (image/video). + // Non-media HTTP chat is rewritten to CLIChatProxyBaseURL by the executor. DefaultAPIBaseURL = "https://api.x.ai/v1" + // CLIChatProxyBaseURL is the Grok CLI chat-proxy base URL for non-image/video HTTP chat. + CLIChatProxyBaseURL = "https://cli-chat-proxy.grok.com/v1" // Issuer is xAI's OAuth issuer. Issuer = "https://auth.x.ai" // DiscoveryURL is the OIDC discovery endpoint used to resolve OAuth endpoints. diff --git a/internal/runtime/executor/xai_executor.go b/internal/runtime/executor/xai_executor.go index 2118aae1..432b9a83 100644 --- a/internal/runtime/executor/xai_executor.go +++ b/internal/runtime/executor/xai_executor.go @@ -60,6 +60,11 @@ const ( xaiVideosPath = "/videos" xaiIdempotencyKeyMetaKey = "idempotency_key" xaiComposerModelPrefix = "grok-composer-" + xaiTokenAuthHeader = "X-XAI-Token-Auth" + xaiTokenAuthValue = "xai-grok-cli" + xaiClientVersionHeader = "x-grok-client-version" + // Keep in sync with the current Grok CLI client version that chat-proxy expects. + xaiClientVersionValue = "0.2.93" ) // XAIExecutor is a stateless executor for xAI Grok's Responses API. @@ -121,10 +126,8 @@ func (e *XAIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req return e.executeVideos(ctx, auth, req, opts) } - token, baseURL := xaiCreds(auth) - if baseURL == "" { - baseURL = xaiauth.DefaultAPIBaseURL - } + token, _ := xaiCreds(auth) + baseURL := xaiChatBaseURL(auth) prepared, err := e.prepareResponsesRequest(ctx, req, opts, true) if err != nil { @@ -140,7 +143,7 @@ func (e *XAIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req if err != nil { return resp, err } - applyXAIHeaders(httpReq, auth, token, true, prepared.sessionID) + applyXAIChatHeaders(httpReq, auth, token, true, prepared.sessionID) e.recordXAIRequest(ctx, auth, url, httpReq.Header.Clone(), prepared.body) httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) @@ -212,10 +215,8 @@ func (e *XAIExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.Aut } func (e *XAIExecutor) executeCompactRequest(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*xaiPreparedRequest, []byte, http.Header, error) { - token, baseURL := xaiCreds(auth) - if baseURL == "" { - baseURL = xaiauth.DefaultAPIBaseURL - } + token, _ := xaiCreds(auth) + baseURL := xaiChatBaseURL(auth) prepared, err := e.prepareResponsesRequestTo(ctx, req, opts, false, sdktranslator.FormatOpenAIResponse) if err != nil { @@ -234,7 +235,7 @@ func (e *XAIExecutor) executeCompactRequest(ctx context.Context, auth *cliproxya if err != nil { return nil, nil, nil, err } - applyXAIHeaders(httpReq, auth, token, false, prepared.sessionID) + applyXAIChatHeaders(httpReq, auth, token, false, prepared.sessionID) e.recordXAIRequest(ctx, auth, requestURL, httpReq.Header.Clone(), prepared.body) httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) @@ -579,10 +580,8 @@ func (e *XAIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth return e.executeCompactionTriggerStream(ctx, auth, req, opts) } - token, baseURL := xaiCreds(auth) - if baseURL == "" { - baseURL = xaiauth.DefaultAPIBaseURL - } + token, _ := xaiCreds(auth) + baseURL := xaiChatBaseURL(auth) prepared, err := e.prepareResponsesRequest(ctx, req, opts, true) if err != nil { @@ -598,7 +597,7 @@ func (e *XAIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth if err != nil { return nil, err } - applyXAIHeaders(httpReq, auth, token, true, prepared.sessionID) + applyXAIChatHeaders(httpReq, auth, token, true, prepared.sessionID) e.recordXAIRequest(ctx, auth, url, httpReq.Header.Clone(), prepared.body) httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) @@ -915,6 +914,31 @@ func xaiCreds(auth *cliproxyauth.Auth) (token, baseURL string) { return token, baseURL } +// xaiChatBaseURL returns the base URL for non-image/video xAI HTTP chat requests. +// Empty or official default base_url is rewritten to the CLI chat-proxy endpoint. +// An explicit non-default base_url (tests / custom gateways) is still honored. +// Websocket transport intentionally does not use this helper: cli-chat-proxy only +// accepts HTTP POST and returns 405 for websocket upgrades. +func xaiChatBaseURL(auth *cliproxyauth.Auth) string { + _, baseURL := xaiCreds(auth) + if baseURL != "" && !xaiIsDefaultAPIBaseURL(baseURL) { + return baseURL + } + return xaiauth.CLIChatProxyBaseURL +} + +func xaiNormalizeBaseURL(baseURL string) string { + return strings.TrimRight(strings.TrimSpace(baseURL), "/") +} + +func xaiIsDefaultAPIBaseURL(baseURL string) bool { + return xaiNormalizeBaseURL(baseURL) == xaiNormalizeBaseURL(xaiauth.DefaultAPIBaseURL) +} + +func xaiIsCLIChatProxyBaseURL(baseURL string) bool { + return xaiNormalizeBaseURL(baseURL) == xaiNormalizeBaseURL(xaiauth.CLIChatProxyBaseURL) +} + func applyXAIHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, sessionID string) { r.Header.Set("Content-Type", "application/json") if strings.TrimSpace(token) != "" { @@ -936,6 +960,18 @@ func applyXAIHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, str util.ApplyCustomHeadersFromAttrs(r, attrs) } +// applyXAIChatHeaders applies standard xAI headers for non-image/video chat +// requests. CLI chat-proxy identity headers are only attached when the resolved +// chat base URL is the official CLI chat-proxy endpoint. +func applyXAIChatHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, sessionID string) { + applyXAIHeaders(r, auth, token, stream, sessionID) + if !xaiIsCLIChatProxyBaseURL(xaiChatBaseURL(auth)) { + return + } + r.Header.Set(xaiTokenAuthHeader, xaiTokenAuthValue) + r.Header.Set(xaiClientVersionHeader, xaiClientVersionValue) +} + func xaiResolveComposerSessionID(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, baseModel string) (string, error) { if sessionID := xaiExecutionSessionID(req, opts); sessionID != "" { return sessionID, nil diff --git a/internal/runtime/executor/xai_executor_test.go b/internal/runtime/executor/xai_executor_test.go index 77d3d650..4becf49e 100644 --- a/internal/runtime/executor/xai_executor_test.go +++ b/internal/runtime/executor/xai_executor_test.go @@ -13,6 +13,7 @@ import ( "testing" "github.com/google/uuid" + xaiauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/xai" internalcache "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator" @@ -806,11 +807,15 @@ func TestXAIExecutorExecuteImagesUsesImagesEndpoint(t *testing.T) { var gotPath string var gotAuth string var gotAccept string + var gotTokenAuth string + var gotClientVersion string var gotBody []byte server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path gotAuth = r.Header.Get("Authorization") gotAccept = r.Header.Get("Accept") + gotTokenAuth = r.Header.Get(xaiTokenAuthHeader) + gotClientVersion = r.Header.Get(xaiClientVersionHeader) var errRead error gotBody, errRead = io.ReadAll(r.Body) if errRead != nil { @@ -853,6 +858,12 @@ func TestXAIExecutorExecuteImagesUsesImagesEndpoint(t *testing.T) { if gotAccept != "application/json" { t.Fatalf("Accept = %q, want application/json", gotAccept) } + if gotTokenAuth != "" { + t.Fatalf("%s = %q, want empty on media path", xaiTokenAuthHeader, gotTokenAuth) + } + if gotClientVersion != "" { + t.Fatalf("%s = %q, want empty on media path", xaiClientVersionHeader, gotClientVersion) + } if string(gotBody) != `{"model":"grok-imagine-image","prompt":"draw"}` { t.Fatalf("body = %s", string(gotBody)) } @@ -1562,6 +1573,158 @@ func TestXAIExecutorReasoningReplayCacheReplaysFunctionCallForClaudeToolResult(t } } +func TestXAIChatBaseURL(t *testing.T) { + tests := []struct { + name string + auth *cliproxyauth.Auth + want string + }{ + { + name: "nil auth defaults to chat proxy", + auth: nil, + want: xaiauth.CLIChatProxyBaseURL, + }, + { + name: "empty base url defaults to chat proxy", + auth: &cliproxyauth.Auth{Provider: "xai"}, + want: xaiauth.CLIChatProxyBaseURL, + }, + { + name: "official default rewrites to chat proxy", + auth: &cliproxyauth.Auth{ + Attributes: map[string]string{"base_url": xaiauth.DefaultAPIBaseURL}, + }, + want: xaiauth.CLIChatProxyBaseURL, + }, + { + name: "official default with trailing slash rewrites to chat proxy", + auth: &cliproxyauth.Auth{ + Attributes: map[string]string{"base_url": xaiauth.DefaultAPIBaseURL + "/"}, + }, + want: xaiauth.CLIChatProxyBaseURL, + }, + { + name: "metadata official default rewrites to chat proxy", + auth: &cliproxyauth.Auth{ + Metadata: map[string]any{"base_url": xaiauth.DefaultAPIBaseURL}, + }, + want: xaiauth.CLIChatProxyBaseURL, + }, + { + name: "custom base url is honored", + auth: &cliproxyauth.Auth{ + Attributes: map[string]string{"base_url": "https://gateway.example.com/v1"}, + }, + want: "https://gateway.example.com/v1", + }, + { + name: "explicit chat proxy base url is preserved", + auth: &cliproxyauth.Auth{ + Attributes: map[string]string{"base_url": xaiauth.CLIChatProxyBaseURL}, + }, + want: xaiauth.CLIChatProxyBaseURL, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := xaiChatBaseURL(tt.auth); got != tt.want { + t.Fatalf("xaiChatBaseURL() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestApplyXAIChatHeaders(t *testing.T) { + t.Run("cli chat proxy headers on default base", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "https://example.invalid/responses", nil) + auth := &cliproxyauth.Auth{ + Attributes: map[string]string{"base_url": xaiauth.DefaultAPIBaseURL}, + } + applyXAIChatHeaders(req, auth, "xai-token", true, "conv-1") + + if got := req.Header.Get("Authorization"); got != "Bearer xai-token" { + t.Fatalf("Authorization = %q, want Bearer xai-token", got) + } + if got := req.Header.Get("x-grok-conv-id"); got != "conv-1" { + t.Fatalf("x-grok-conv-id = %q, want conv-1", got) + } + if got := req.Header.Get(xaiTokenAuthHeader); got != xaiTokenAuthValue { + t.Fatalf("%s = %q, want %q", xaiTokenAuthHeader, got, xaiTokenAuthValue) + } + if got := req.Header.Get(xaiClientVersionHeader); got != xaiClientVersionValue { + t.Fatalf("%s = %q, want %q", xaiClientVersionHeader, got, xaiClientVersionValue) + } + }) + + t.Run("no cli headers on custom gateway", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "https://gateway.example.com/responses", nil) + auth := &cliproxyauth.Auth{ + Attributes: map[string]string{"base_url": "https://gateway.example.com/v1"}, + } + applyXAIChatHeaders(req, auth, "xai-token", false, "") + + if got := req.Header.Get(xaiTokenAuthHeader); got != "" { + t.Fatalf("%s = %q, want empty for custom gateway", xaiTokenAuthHeader, got) + } + if got := req.Header.Get(xaiClientVersionHeader); got != "" { + t.Fatalf("%s = %q, want empty for custom gateway", xaiClientVersionHeader, got) + } + }) + + t.Run("cli headers on explicit chat proxy base", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, xaiauth.CLIChatProxyBaseURL+"/responses", nil) + auth := &cliproxyauth.Auth{ + Attributes: map[string]string{"base_url": xaiauth.CLIChatProxyBaseURL + "/"}, + } + applyXAIChatHeaders(req, auth, "xai-token", true, "") + + if got := req.Header.Get(xaiTokenAuthHeader); got != xaiTokenAuthValue { + t.Fatalf("%s = %q, want %q", xaiTokenAuthHeader, got, xaiTokenAuthValue) + } + if got := req.Header.Get(xaiClientVersionHeader); got != xaiClientVersionValue { + t.Fatalf("%s = %q, want %q", xaiClientVersionHeader, got, xaiClientVersionValue) + } + }) +} + +func TestXAIExecutorExecuteChatUsesProxyHeadersOnlyForChatProxy(t *testing.T) { + var gotTokenAuth string + var gotClientVersion string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotTokenAuth = r.Header.Get(xaiTokenAuthHeader) + gotClientVersion = r.Header.Get(xaiClientVersionHeader) + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"created_at\":0,\"status\":\"completed\",\"model\":\"grok-4.3\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n")) + })) + defer server.Close() + + exec := NewXAIExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "xai", + Attributes: map[string]string{ + "base_url": server.URL, + }, + Metadata: map[string]any{"access_token": "xai-token"}, + } + + _, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "grok-4.3", + Payload: []byte(`{"model":"grok-4.3","input":[{"role":"user","content":"hello"}]}`), + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatOpenAIResponse, + }) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if gotTokenAuth != "" { + t.Fatalf("%s = %q, want empty for custom chat gateway", xaiTokenAuthHeader, gotTokenAuth) + } + if gotClientVersion != "" { + t.Fatalf("%s = %q, want empty for custom chat gateway", xaiClientVersionHeader, gotClientVersion) + } +} + func testValidGrokEncryptedContentForSeed(seed byte) string { buf := make([]byte, 0, 256) for i := 0; len(buf) < 256; i++ { diff --git a/internal/runtime/executor/xai_websockets_executor.go b/internal/runtime/executor/xai_websockets_executor.go index 05784544..287a7cd5 100644 --- a/internal/runtime/executor/xai_websockets_executor.go +++ b/internal/runtime/executor/xai_websockets_executor.go @@ -402,6 +402,9 @@ func (e *XAIWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *cliprox return e.executeCompactionTriggerFromWebsocketContext(ctx, auth, req, opts, idMapper) } + // Keep websocket on the official API base URL (or an explicit non-default + // base_url). Do not reuse xaiChatBaseURL: cli-chat-proxy only accepts HTTP + // POST and returns 405 for websocket upgrades. token, baseURL := xaiCreds(auth) if baseURL == "" { baseURL = xaiauth.DefaultAPIBaseURL