diff --git a/internal/handler/chat_session.go b/internal/handler/chat_session.go index 306f948b7d..bf56ce0bce 100644 --- a/internal/handler/chat_session.go +++ b/internal/handler/chat_session.go @@ -127,6 +127,8 @@ type ChatCompletionsRequest struct { LLMID string `json:"llm_id,omitempty"` PassAllHistoryMessages *bool `json:"pass_all_history_messages,omitempty"` PassAllHistory *bool `json:"pass_all_history,omitempty"` + StoreHistoryMessages *bool `json:"store_history_messages,omitempty"` + StoreHistory *bool `json:"store_history,omitempty"` Legacy bool `json:"legacy,omitempty"` Stream *bool `json:"stream"` Temperature *float64 `json:"temperature,omitempty"` @@ -205,12 +207,26 @@ func (h *ChatSessionHandler) ChatCompletions(c *gin.Context) { passAllHistory = *req.PassAllHistoryMessages } + // Resolve store_history from either alias (default true, mirrors Python) + storeHistory := true + if req.StoreHistory != nil { + storeHistory = *req.StoreHistory + } + if req.StoreHistoryMessages != nil { + storeHistory = *req.StoreHistoryMessages + } + if !storeHistory && !passAllHistory { + common.ErrorWithCode(c, common.CodeDataError, "`pass_all_history_messages` must be true when `store_history_messages` is false.") + return + } + // Remove known keys from rawBody; what remains is passthrough kwargs knownKeys := []string{ "chat_id", "session_id", "conversation_id", "messages", "question", "files", "llm_id", "pass_all_history_messages", "pass_all_history", + "store_history_messages", "store_history", "legacy", "stream", "temperature", "top_p", "frequency_penalty", "presence_penalty", "max_tokens", } @@ -241,7 +257,7 @@ func (h *ChatSessionHandler) ChatCompletions(c *gin.Context) { req.ChatID, sessionID, req.Messages, req.Question, req.Files, req.LLMID, genConfig, kwargs, - passAllHistory, req.Legacy, + passAllHistory, storeHistory, req.Legacy, true, streamChan, ) }() @@ -261,7 +277,7 @@ func (h *ChatSessionHandler) ChatCompletions(c *gin.Context) { req.ChatID, sessionID, req.Messages, req.Question, req.Files, req.LLMID, genConfig, kwargs, - passAllHistory, req.Legacy, + passAllHistory, storeHistory, req.Legacy, false, nil, ) if err != nil { diff --git a/internal/service/chat_channel.go b/internal/service/chat_channel.go index 586e9e6aef..3aad418138 100644 --- a/internal/service/chat_channel.go +++ b/internal/service/chat_channel.go @@ -321,6 +321,7 @@ func (s *ChatChannelService) HandleIncomingMessage(ctx context.Context, msg Chat nil, map[string]interface{}{"quote": false}, false, + true, false, false, nil, diff --git a/internal/service/chat_session.go b/internal/service/chat_session.go index 77ef1445e1..b386a5f726 100644 --- a/internal/service/chat_session.go +++ b/internal/service/chat_session.go @@ -1449,7 +1449,7 @@ func (s *ChatSessionService) ChatCompletions( chatID string, sessionID string, messages []map[string]interface{}, question string, files []interface{}, llmID string, genConfig map[string]interface{}, kwargs map[string]interface{}, - passAllHistory bool, legacy bool, + passAllHistory bool, storeHistory bool, legacy bool, stream bool, streamChan chan<- string, ) (map[string]interface{}, error) { if ctx == nil { @@ -1505,7 +1505,7 @@ func (s *ChatSessionService) ChatCompletions( return fail(common.NewCodedError(common.CodeDataError, "Session does not belong to this chat!")) } } else { - session, err = s.createSessionForCompletion(ctx, chatID, dialog, userID) + session, err = s.createSessionForCompletion(ctx, chatID, dialog, userID, storeHistory) if err != nil { return fail(err) } @@ -1669,7 +1669,7 @@ func (s *ChatSessionService) ChatCompletions( sendOrCancel(fmt.Sprintf("data:%s\n\n", marshalJSONWithSpaces(wrapper))) // Persist session state (matches Python's update_by_id after loop) - if session != nil { + if session != nil && storeHistory { s.updateSessionMessages(ctx, session, s.getSessionMessagesAsSlice(session), reference) } } else { @@ -1679,7 +1679,9 @@ func (s *ChatSessionService) ChatCompletions( if chatID != "" { result["chat_id"] = chatID } - s.updateSessionMessages(ctx, session, s.getSessionMessagesAsSlice(session), reference) + if storeHistory { + s.updateSessionMessages(ctx, session, s.getSessionMessagesAsSlice(session), reference) + } return sanitizeJSONFloats(result).(map[string]interface{}), nil } ans["id"] = messageID @@ -1830,8 +1832,7 @@ func (s *ChatSessionService) buildDefaultCompletionDialog(tenantID string) *enti } } -func (s *ChatSessionService) createSessionForCompletion(ctx context.Context, chatID string, dialog *entity.Chat, userID string) (*entity.ChatSession, error) { - newID := utility.GenerateUUID() +func (s *ChatSessionService) createSessionForCompletion(ctx context.Context, chatID string, dialog *entity.Chat, userID string, saveSession bool) (*entity.ChatSession, error) { name := "New session" prologue := "Hi! I'm your assistant. What can I do for you?" @@ -1848,13 +1849,18 @@ func (s *ChatSessionService) createSessionForCompletion(ctx context.Context, cha refJSON, _ := json.Marshal([]interface{}{}) session := &entity.ChatSession{ - ID: newID, DialogID: chatID, Name: &name, Message: msgJSON, UserID: &userID, Reference: refJSON, } + if !saveSession { + // Multi-model comparison sends store_history_messages=false; the + // ephemeral session must not be persisted (mirrors Python). + return session, nil + } + session.ID = utility.GenerateUUID() if err := s.chatSessionDAO.Create(ctx, dao.DB, session); err != nil { return nil, err } diff --git a/internal/service/chat_session_test.go b/internal/service/chat_session_test.go index 93b4da5a03..4b11ec711b 100644 --- a/internal/service/chat_session_test.go +++ b/internal/service/chat_session_test.go @@ -930,6 +930,7 @@ func TestChatCompletionsPassesRequestUserIDToPipeline(t *testing.T) { nil, nil, false, + true, false, false, nil, @@ -1010,6 +1011,7 @@ func TestChatCompletionsStreamFinalCarriesDecoratedReference(t *testing.T) { nil, nil, false, + true, false, true, streamChan, @@ -1106,6 +1108,7 @@ func TestChatCompletionsModelIDOverrideUsesModelResolver(t *testing.T) { nil, nil, false, + true, false, false, nil, @@ -1121,6 +1124,69 @@ func TestChatCompletionsModelIDOverrideUsesModelResolver(t *testing.T) { } } +// Multi-model comparison sends store_history_messages=false; the ephemeral +// session must never be persisted, otherwise every model card adds a +// "New session" row to the session list. +func TestChatCompletionsStoreHistoryFalseDoesNotPersistSession(t *testing.T) { + store := newFakeSessionStore() + store.dialogs["dialog-1"] = &entity.Chat{ + ID: "dialog-1", + TenantID: "tenant-owner", + LLMID: "chat@factory", + LLMSetting: entity.JSONMap{}, + PromptConfig: entity.JSONMap{ + "parameters": []interface{}{}, + "prologue": "Welcome!", + }, + } + store.dialogExists["tenant-owner|dialog-1"] = true + + pipeline := &fakePipeline{ + resultChan: makeResultChan( + AsyncChatResult{Answer: "ok", Final: true, Reference: map[string]interface{}{"chunks": []interface{}{}}}, + ), + } + + svc := &ChatSessionService{ + chatSessionDAO: store, + userTenantDAO: &fakeTenantStore{tenantIDs: []string{"tenant-owner"}}, + pipeline: pipeline, + } + + result, err := svc.ChatCompletions( + context.Background(), + "user-1", + "dialog-1", + "", + []map[string]interface{}{{"role": "user", "content": "hi"}}, + "", + nil, + "", + nil, + nil, + true, + false, + false, + false, + nil, + ) + if err != nil { + t.Fatalf("ChatCompletions failed: %v", err) + } + if result == nil { + t.Fatal("expected a non-nil answer") + } + if len(store.createCalled) != 0 { + t.Fatalf("session must not be created when store_history_messages=false, got %d creates", len(store.createCalled)) + } + if len(store.updateCalled) != 0 { + t.Fatalf("session must not be updated when store_history_messages=false, got %d updates", len(store.updateCalled)) + } + if len(store.sessions) != 0 { + t.Fatalf("no session should be stored, got %d", len(store.sessions)) + } +} + func TestCompletion_EmptyMessages(t *testing.T) { svc := &ChatSessionService{ chatSessionDAO: &fakeSessionStore{},