mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-08 17:36:47 +08:00
feat(executor): add chat-proxy-specific headers and improve base URL handling
- Introduced `xaiChatBaseURL` helper to normalize base URLs for non-media chat requests. - Added `applyXAIChatHeaders` to handle CLI chat-proxy-specific headers. - Updated logic to differentiate between default, chat-proxy, and custom gateway base URLs. - Extended test coverage to validate behavior of headers and base URL handling across scenarios. Closes: #4182
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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++ {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user