diff --git a/internal/runtime/executor/xai_executor.go b/internal/runtime/executor/xai_executor.go index ff9acd08b..c6795ef98 100644 --- a/internal/runtime/executor/xai_executor.go +++ b/internal/runtime/executor/xai_executor.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/google/uuid" xaiauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/xai" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" @@ -49,6 +50,7 @@ const ( xaiVideosExtensionsPath = "/videos/extensions" xaiVideosPath = "/videos" xaiIdempotencyKeyMetaKey = "idempotency_key" + xaiComposerModelPrefix = "grok-composer-" ) // XAIExecutor is a stateless executor for xAI Grok's Responses API. @@ -837,6 +839,9 @@ func (e *XAIExecutor) prepareResponsesRequestTo(ctx context.Context, req cliprox body = sanitizeXAIResponsesBody(body, baseModel) sessionID := xaiExecutionSessionID(req, opts) + if sessionID == "" && xaiRequiresIsolatedConversation(baseModel) { + sessionID = uuid.NewString() + } if sessionID != "" { body, _ = sjson.SetBytes(body, "prompt_cache_key", sessionID) } @@ -925,6 +930,10 @@ func xaiExecutionSessionID(req cliproxyexecutor.Request, opts cliproxyexecutor.O return "" } +func xaiRequiresIsolatedConversation(model string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), xaiComposerModelPrefix) +} + func xaiImageEndpointPath(opts cliproxyexecutor.Options) string { if opts.SourceFormat.String() != xaiImageHandlerType { return "" diff --git a/internal/runtime/executor/xai_executor_test.go b/internal/runtime/executor/xai_executor_test.go index 8ed24fe9c..5e7b371a2 100644 --- a/internal/runtime/executor/xai_executor_test.go +++ b/internal/runtime/executor/xai_executor_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/google/uuid" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" @@ -159,6 +160,100 @@ func TestXAIExecutorExecuteShapesResponsesRequest(t *testing.T) { } } +func TestXAIExecutorComposerSessionIsolation(t *testing.T) { + exec := NewXAIExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "xai", + Metadata: map[string]any{"access_token": "xai-token"}, + } + + tests := []struct { + name string + model string + payload []byte + wantGenerated bool + wantSession string + }{ + { + name: "composer_generates_fresh_session", + model: "grok-composer-2.5-fast", + payload: []byte(`{"model":"grok-composer-2.5-fast","input":"hello"}`), + wantGenerated: true, + }, + { + name: "grok_build_stays_stateless_without_session", + model: "grok-build-0.1", + payload: []byte(`{"model":"grok-build-0.1","input":"hello"}`), + }, + { + name: "explicit_prompt_cache_key_is_preserved", + model: "grok-composer-2.5-fast", + payload: []byte(`{"model":"grok-composer-2.5-fast","prompt_cache_key":"client-session","input":"hello"}`), + wantSession: "client-session", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prepared, err := exec.prepareResponsesRequest(context.Background(), cliproxyexecutor.Request{ + Model: tt.model, + Payload: tt.payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatOpenAIResponse, + Stream: true, + }, true) + if err != nil { + t.Fatalf("prepareResponsesRequest() error = %v", err) + } + + gotSession := prepared.sessionID + gotPromptCacheKey := gjson.GetBytes(prepared.body, "prompt_cache_key").String() + httpReq, errRequest := http.NewRequest(http.MethodPost, "https://example.test/responses", bytes.NewReader(prepared.body)) + if errRequest != nil { + t.Fatalf("NewRequest() error = %v", errRequest) + } + applyXAIHeaders(httpReq, auth, "xai-token", true, gotSession) + gotGrokConvID := httpReq.Header.Get("x-grok-conv-id") + + if tt.wantGenerated { + if _, errParse := uuid.Parse(gotSession); errParse != nil { + t.Fatalf("generated sessionID = %q, want UUID; body=%s", gotSession, string(prepared.body)) + } + if gotPromptCacheKey != gotSession { + t.Fatalf("prompt_cache_key = %q, want sessionID %q; body=%s", gotPromptCacheKey, gotSession, string(prepared.body)) + } + if gotGrokConvID != gotSession { + t.Fatalf("x-grok-conv-id = %q, want sessionID %q", gotGrokConvID, gotSession) + } + return + } + + if tt.wantSession != "" { + if gotSession != tt.wantSession { + t.Fatalf("sessionID = %q, want %q", gotSession, tt.wantSession) + } + if gotPromptCacheKey != tt.wantSession { + t.Fatalf("prompt_cache_key = %q, want %q; body=%s", gotPromptCacheKey, tt.wantSession, string(prepared.body)) + } + if gotGrokConvID != tt.wantSession { + t.Fatalf("x-grok-conv-id = %q, want %q", gotGrokConvID, tt.wantSession) + } + return + } + + if gotSession != "" { + t.Fatalf("sessionID = %q, want empty", gotSession) + } + if gotPromptCacheKey != "" { + t.Fatalf("prompt_cache_key = %q, want empty; body=%s", gotPromptCacheKey, string(prepared.body)) + } + if gotGrokConvID != "" { + t.Fatalf("x-grok-conv-id = %q, want empty", gotGrokConvID) + } + }) + } +} + func TestXAIExecutorCompactUsesCompactEndpoint(t *testing.T) { var gotPath string var gotAuth string