From 2788e20f05a1381df2fdabcdf04da1c301f538ad Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Wed, 22 Apr 2026 18:23:09 +0000 Subject: [PATCH] feat: implement history split functionality to optimize context usage and add corresponding UI settings --- config.example.json | 4 + internal/adapter/openai/deps.go | 2 + .../adapter/openai/deps_injection_test.go | 25 +- internal/adapter/openai/handler_chat.go | 7 +- internal/adapter/openai/history_split.go | 213 ++++++++++++ internal/adapter/openai/history_split_test.go | 317 ++++++++++++++++++ internal/adapter/openai/responses_handler.go | 5 + internal/adapter/openai/standard_request.go | 2 + internal/adapter/openai/upstream_empty.go | 4 +- internal/admin/deps.go | 2 + internal/admin/handler_settings_parse.go | 58 ++-- internal/admin/handler_settings_read.go | 12 +- internal/admin/handler_settings_test.go | 43 +++ internal/admin/handler_settings_write.go | 10 +- internal/config/codec.go | 25 +- internal/config/config.go | 38 ++- internal/config/config_edge_test.go | 28 ++ internal/config/store_accessors.go | 18 + internal/config/store_accessors_test.go | 27 ++ internal/config/validation.go | 12 + internal/config/validation_test.go | 9 + internal/util/standard_request.go | 1 + .../features/settings/HistorySplitSection.jsx | 48 +++ .../features/settings/SettingsContainer.jsx | 3 + .../src/features/settings/useSettingsForm.js | 9 + webui/src/locales/en.json | 6 + webui/src/locales/zh.json | 6 + 27 files changed, 880 insertions(+), 54 deletions(-) create mode 100644 internal/adapter/openai/history_split.go create mode 100644 internal/adapter/openai/history_split_test.go create mode 100644 internal/config/store_accessors_test.go create mode 100644 webui/src/features/settings/HistorySplitSection.jsx diff --git a/config.example.json b/config.example.json index 0f40a45..0c13de4 100644 --- a/config.example.json +++ b/config.example.json @@ -49,6 +49,10 @@ "responses": { "store_ttl_seconds": 900 }, + "history_split": { + "enabled": true, + "trigger_after_turns": 1 + }, "embeddings": { "provider": "deterministic" }, diff --git a/internal/adapter/openai/deps.go b/internal/adapter/openai/deps.go index 351a13c..50118ff 100644 --- a/internal/adapter/openai/deps.go +++ b/internal/adapter/openai/deps.go @@ -34,6 +34,8 @@ type ConfigReader interface { EmbeddingsProvider() string AutoDeleteMode() string AutoDeleteSessions() bool + HistorySplitEnabled() bool + HistorySplitTriggerAfterTurns() int } var _ AuthResolver = (*auth.Resolver)(nil) diff --git a/internal/adapter/openai/deps_injection_test.go b/internal/adapter/openai/deps_injection_test.go index 2364540..f3c9741 100644 --- a/internal/adapter/openai/deps_injection_test.go +++ b/internal/adapter/openai/deps_injection_test.go @@ -3,13 +3,15 @@ package openai import "testing" type mockOpenAIConfig struct { - aliases map[string]string - wideInput bool - autoDeleteMode string - toolMode string - earlyEmit string - responsesTTL int - embedProv string + aliases map[string]string + wideInput bool + autoDeleteMode string + toolMode string + earlyEmit string + responsesTTL int + embedProv string + historySplitEnabled bool + historySplitTurns int } func (m mockOpenAIConfig) ModelAliases() map[string]string { return m.aliases } @@ -27,7 +29,14 @@ func (m mockOpenAIConfig) AutoDeleteMode() string { } return m.autoDeleteMode } -func (m mockOpenAIConfig) AutoDeleteSessions() bool { return false } +func (m mockOpenAIConfig) AutoDeleteSessions() bool { return false } +func (m mockOpenAIConfig) HistorySplitEnabled() bool { return m.historySplitEnabled } +func (m mockOpenAIConfig) HistorySplitTriggerAfterTurns() int { + if m.historySplitTurns <= 0 { + return 1 + } + return m.historySplitTurns +} func TestNormalizeOpenAIChatRequestWithConfigInterface(t *testing.T) { cfg := mockOpenAIConfig{ diff --git a/internal/adapter/openai/handler_chat.go b/internal/adapter/openai/handler_chat.go index ed9d2c7..b7d76ba 100644 --- a/internal/adapter/openai/handler_chat.go +++ b/internal/adapter/openai/handler_chat.go @@ -63,6 +63,11 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) { writeOpenAIError(w, http.StatusBadRequest, err.Error()) return } + stdReq, err = h.applyHistorySplit(r.Context(), a, stdReq) + if err != nil { + writeOpenAIError(w, http.StatusInternalServerError, err.Error()) + return + } historySession := startChatHistory(h.ChatHistory, r, a, stdReq) sessionID, err = h.DS.CreateSession(r.Context(), a, 3) @@ -155,7 +160,7 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, resp *http.Response, co if searchEnabled { finalText = replaceCitationMarkersWithLinks(finalText, result.CitationLinks) } - if shouldWriteUpstreamEmptyOutputError(finalText, result.ContentFilter) { + if shouldWriteUpstreamEmptyOutputError(finalText) { status, message, code := upstreamEmptyOutputDetail(result.ContentFilter, finalText, finalThinking) if historySession != nil { historySession.error(status, message, code, finalThinking, finalText) diff --git a/internal/adapter/openai/history_split.go b/internal/adapter/openai/history_split.go new file mode 100644 index 0000000..b65e2e2 --- /dev/null +++ b/internal/adapter/openai/history_split.go @@ -0,0 +1,213 @@ +package openai + +import ( + "context" + "errors" + "fmt" + "strings" + + "ds2api/internal/auth" + "ds2api/internal/deepseek" + "ds2api/internal/util" +) + +const ( + historySplitFilename = "HISTORY.txt" + historySplitContentType = "text/plain; charset=utf-8" + historySplitPurpose = "assistants" +) + +func (h *Handler) applyHistorySplit(ctx context.Context, a *auth.RequestAuth, stdReq util.StandardRequest) (util.StandardRequest, error) { + if h == nil || h.DS == nil || h.Store == nil || a == nil { + return stdReq, nil + } + if !h.Store.HistorySplitEnabled() { + return stdReq, nil + } + + promptMessages, historyMessages := splitOpenAIHistoryMessages(stdReq.Messages, h.Store.HistorySplitTriggerAfterTurns()) + if len(historyMessages) == 0 { + return stdReq, nil + } + + historyText := buildOpenAIHistoryTranscript(historyMessages) + if strings.TrimSpace(historyText) == "" { + return stdReq, errors.New("history split produced empty transcript") + } + + result, err := h.DS.UploadFile(ctx, a, deepseek.UploadFileRequest{ + Filename: historySplitFilename, + ContentType: historySplitContentType, + Purpose: historySplitPurpose, + Data: []byte(historyText), + }, 3) + if err != nil { + return stdReq, fmt.Errorf("upload history file: %w", err) + } + fileID := strings.TrimSpace(result.ID) + if fileID == "" { + return stdReq, errors.New("upload history file returned empty file id") + } + + stdReq.Messages = promptMessages + stdReq.RefFileIDs = prependUniqueRefFileID(stdReq.RefFileIDs, fileID) + stdReq.FinalPrompt, stdReq.ToolNames = buildHistorySplitPrompt(promptMessages, stdReq.ToolsRaw, stdReq.ToolChoice, stdReq.Thinking) + return stdReq, nil +} + +func buildHistorySplitPrompt(messages []any, toolsRaw any, toolPolicy util.ToolChoicePolicy, thinkingEnabled bool) (string, []string) { + if len(messages) == 0 { + return "", nil + } + instruction := historySplitPromptInstruction() + withInstruction := make([]any, 0, len(messages)+1) + withInstruction = append(withInstruction, map[string]any{ + "role": "system", + "content": instruction, + }) + withInstruction = append(withInstruction, messages...) + return buildOpenAIFinalPromptWithPolicy(withInstruction, toolsRaw, "", toolPolicy, thinkingEnabled) +} + +func historySplitPromptInstruction() string { + return "An attached HISTORY.txt file contains prior conversation history and tool progress. Read it first, then answer the latest user request using that history as context." +} + +func splitOpenAIHistoryMessages(messages []any, triggerAfterTurns int) ([]any, []any) { + if triggerAfterTurns <= 0 { + triggerAfterTurns = 1 + } + lastUserIndex := -1 + userTurns := 0 + for i, raw := range messages { + msg, ok := raw.(map[string]any) + if !ok { + continue + } + role := strings.ToLower(strings.TrimSpace(asString(msg["role"]))) + if role != "user" { + continue + } + userTurns++ + lastUserIndex = i + } + if userTurns <= triggerAfterTurns || lastUserIndex < 0 { + return messages, nil + } + + promptMessages := make([]any, 0, len(messages)-lastUserIndex) + historyMessages := make([]any, 0, lastUserIndex) + for i, raw := range messages { + msg, ok := raw.(map[string]any) + if !ok { + if i >= lastUserIndex { + promptMessages = append(promptMessages, raw) + } else { + historyMessages = append(historyMessages, raw) + } + continue + } + role := strings.ToLower(strings.TrimSpace(asString(msg["role"]))) + switch role { + case "system", "developer": + promptMessages = append(promptMessages, raw) + default: + if i >= lastUserIndex { + promptMessages = append(promptMessages, raw) + } else { + historyMessages = append(historyMessages, raw) + } + } + } + if len(promptMessages) == 0 { + return messages, nil + } + return promptMessages, historyMessages +} + +func buildOpenAIHistoryTranscript(messages []any) string { + var b strings.Builder + b.WriteString("# HISTORY.txt\n") + b.WriteString("Prior conversation history and tool progress.\n\n") + + entry := 0 + for _, raw := range messages { + msg, ok := raw.(map[string]any) + if !ok { + continue + } + role := strings.ToLower(strings.TrimSpace(asString(msg["role"]))) + content := buildOpenAIHistoryEntry(role, msg) + if strings.TrimSpace(content) == "" { + continue + } + entry++ + fmt.Fprintf(&b, "=== %d. %s ===\n%s\n\n", entry, strings.ToUpper(roleLabelForHistory(role)), content) + } + return strings.TrimSpace(b.String()) + "\n" +} + +func buildOpenAIHistoryEntry(role string, msg map[string]any) string { + switch role { + case "assistant": + return strings.TrimSpace(buildAssistantContentForPrompt(msg)) + case "tool", "function": + return strings.TrimSpace(buildToolHistoryContent(msg)) + case "user": + return strings.TrimSpace(normalizeOpenAIContentForPrompt(msg["content"])) + default: + return strings.TrimSpace(normalizeOpenAIContentForPrompt(msg["content"])) + } +} + +func buildToolHistoryContent(msg map[string]any) string { + content := strings.TrimSpace(normalizeOpenAIContentForPrompt(msg["content"])) + parts := make([]string, 0, 2) + if name := strings.TrimSpace(asString(msg["name"])); name != "" { + parts = append(parts, "name="+name) + } + if callID := strings.TrimSpace(asString(msg["tool_call_id"])); callID != "" { + parts = append(parts, "tool_call_id="+callID) + } + header := "" + if len(parts) > 0 { + header = "[" + strings.Join(parts, " ") + "]" + } + switch { + case header != "" && content != "": + return header + "\n" + content + case header != "": + return header + default: + return content + } +} + +func roleLabelForHistory(role string) string { + role = strings.ToLower(strings.TrimSpace(role)) + switch role { + case "function": + return "tool" + case "": + return "unknown" + default: + return role + } +} + +func prependUniqueRefFileID(existing []string, fileID string) []string { + fileID = strings.TrimSpace(fileID) + if fileID == "" { + return existing + } + out := make([]string, 0, len(existing)+1) + out = append(out, fileID) + for _, id := range existing { + trimmed := strings.TrimSpace(id) + if trimmed == "" || strings.EqualFold(trimmed, fileID) { + continue + } + out = append(out, trimmed) + } + return out +} diff --git a/internal/adapter/openai/history_split_test.go b/internal/adapter/openai/history_split_test.go new file mode 100644 index 0000000..46fa366 --- /dev/null +++ b/internal/adapter/openai/history_split_test.go @@ -0,0 +1,317 @@ +package openai + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + "ds2api/internal/auth" + "ds2api/internal/util" +) + +func historySplitTestMessages() []any { + toolCalls := []any{ + map[string]any{ + "name": "search", + "arguments": map[string]any{"query": "docs"}, + }, + } + return []any{ + map[string]any{"role": "system", "content": "system instructions"}, + map[string]any{"role": "user", "content": "first user turn"}, + map[string]any{ + "role": "assistant", + "content": "", + "reasoning_content": "hidden reasoning", + "tool_calls": toolCalls, + }, + map[string]any{ + "role": "tool", + "name": "search", + "tool_call_id": "call-1", + "content": "tool result", + }, + map[string]any{"role": "user", "content": "latest user turn"}, + } +} + +func TestBuildOpenAIHistoryTranscriptPreservesOrderAndToolHistory(t *testing.T) { + promptMessages, historyMessages := splitOpenAIHistoryMessages(historySplitTestMessages(), 1) + if len(promptMessages) != 2 { + t.Fatalf("expected 2 prompt messages, got %d", len(promptMessages)) + } + if len(historyMessages) != 3 { + t.Fatalf("expected 3 history messages, got %d", len(historyMessages)) + } + + transcript := buildOpenAIHistoryTranscript(historyMessages) + if !strings.Contains(transcript, "first user turn") { + t.Fatalf("expected user history in transcript, got %s", transcript) + } + if !strings.Contains(transcript, "") { + t.Fatalf("expected assistant tool_calls in transcript, got %s", transcript) + } + if !strings.Contains(transcript, "tool_call_id=call-1") { + t.Fatalf("expected tool call id in transcript, got %s", transcript) + } + if strings.Contains(transcript, "hidden reasoning") { + t.Fatalf("did not expect hidden reasoning in transcript, got %s", transcript) + } + + userIdx := strings.Index(transcript, "=== 1. USER ===") + assistantIdx := strings.Index(transcript, "=== 2. ASSISTANT ===") + toolIdx := strings.Index(transcript, "=== 3. TOOL ===") + if userIdx < 0 || assistantIdx < 0 || toolIdx < 0 { + t.Fatalf("expected ordered role sections, got %s", transcript) + } + if userIdx >= assistantIdx || assistantIdx >= toolIdx { + t.Fatalf("expected USER -> ASSISTANT -> TOOL order, got %s", transcript) + } + + finalPrompt, _ := buildHistorySplitPrompt(promptMessages, nil, util.DefaultToolChoicePolicy(), false) + if !strings.Contains(finalPrompt, "latest user turn") { + t.Fatalf("expected latest user turn in final prompt, got %s", finalPrompt) + } + if strings.Contains(finalPrompt, "first user turn") { + t.Fatalf("expected earlier history to be removed from final prompt, got %s", finalPrompt) + } + if !strings.Contains(finalPrompt, "HISTORY.txt") { + t.Fatalf("expected history instruction in final prompt, got %s", finalPrompt) + } +} + +func TestSplitOpenAIHistoryMessagesUsesLatestUserTurn(t *testing.T) { + toolCalls := []any{ + map[string]any{ + "name": "search", + "arguments": map[string]any{"query": "docs"}, + }, + } + messages := []any{ + map[string]any{"role": "system", "content": "system instructions"}, + map[string]any{"role": "user", "content": "first user turn"}, + map[string]any{ + "role": "assistant", + "content": "", + "tool_calls": toolCalls, + }, + map[string]any{ + "role": "tool", + "name": "search", + "tool_call_id": "call-1", + "content": "tool result", + }, + map[string]any{"role": "user", "content": "middle user turn"}, + map[string]any{ + "role": "assistant", + "content": "middle assistant turn", + }, + map[string]any{"role": "user", "content": "latest user turn"}, + } + + promptMessages, historyMessages := splitOpenAIHistoryMessages(messages, 1) + if len(promptMessages) == 0 || len(historyMessages) == 0 { + t.Fatalf("expected both prompt and history messages, got prompt=%d history=%d", len(promptMessages), len(historyMessages)) + } + + promptText := buildOpenAIFinalPromptForSplitTest(promptMessages) + if !strings.Contains(promptText, "latest user turn") { + t.Fatalf("expected latest user turn in prompt, got %s", promptText) + } + if strings.Contains(promptText, "middle user turn") { + t.Fatalf("expected middle user turn to be split into history, got %s", promptText) + } + + historyText := buildOpenAIHistoryTranscript(historyMessages) + if !strings.Contains(historyText, "middle user turn") { + t.Fatalf("expected middle user turn in HISTORY.txt, got %s", historyText) + } + if strings.Contains(historyText, "latest user turn") { + t.Fatalf("expected latest user turn to remain in prompt, got %s", historyText) + } +} + +func buildOpenAIFinalPromptForSplitTest(messages []any) string { + prompt, _ := buildHistorySplitPrompt(messages, nil, util.DefaultToolChoicePolicy(), false) + return prompt +} + +func TestApplyHistorySplitSkipsFirstTurn(t *testing.T) { + ds := &inlineUploadDSStub{} + h := &Handler{ + Store: mockOpenAIConfig{ + wideInput: true, + historySplitEnabled: true, + historySplitTurns: 1, + }, + DS: ds, + } + req := map[string]any{ + "model": "deepseek-chat", + "messages": []any{ + map[string]any{"role": "user", "content": "hello"}, + }, + } + stdReq, err := normalizeOpenAIChatRequest(h.Store, req, "") + if err != nil { + t.Fatalf("normalize failed: %v", err) + } + + out, err := h.applyHistorySplit(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq) + if err != nil { + t.Fatalf("apply history split failed: %v", err) + } + if len(ds.uploadCalls) != 0 { + t.Fatalf("expected no upload on first turn, got %d", len(ds.uploadCalls)) + } + if out.FinalPrompt != stdReq.FinalPrompt { + t.Fatalf("expected prompt unchanged on first turn") + } + if len(out.RefFileIDs) != len(stdReq.RefFileIDs) { + t.Fatalf("expected ref files unchanged on first turn") + } +} + +func TestChatCompletionsHistorySplitUploadsHistoryAndKeepsLatestPrompt(t *testing.T) { + ds := &inlineUploadDSStub{} + h := &Handler{ + Store: mockOpenAIConfig{ + wideInput: true, + historySplitEnabled: true, + historySplitTurns: 1, + }, + Auth: streamStatusAuthStub{}, + DS: ds, + } + reqBody, _ := json.Marshal(map[string]any{ + "model": "deepseek-chat", + "messages": historySplitTestMessages(), + "stream": false, + }) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(string(reqBody))) + req.Header.Set("Authorization", "Bearer direct-token") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + h.ChatCompletions(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + if len(ds.uploadCalls) != 1 { + t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls)) + } + upload := ds.uploadCalls[0] + if upload.Filename != "HISTORY.txt" { + t.Fatalf("unexpected upload filename: %q", upload.Filename) + } + if upload.ContentType != "text/plain; charset=utf-8" { + t.Fatalf("unexpected content type: %q", upload.ContentType) + } + if upload.Purpose != "assistants" { + t.Fatalf("unexpected purpose: %q", upload.Purpose) + } + historyText := string(upload.Data) + if !strings.Contains(historyText, "first user turn") || !strings.Contains(historyText, "tool result") { + t.Fatalf("expected older turns in HISTORY.txt, got %s", historyText) + } + if strings.Contains(historyText, "latest user turn") { + t.Fatalf("expected latest turn to remain in prompt, got %s", historyText) + } + if ds.completionReq == nil { + t.Fatal("expected completion payload to be captured") + } + promptText, _ := ds.completionReq["prompt"].(string) + if !strings.Contains(promptText, "latest user turn") { + t.Fatalf("expected latest turn in completion prompt, got %s", promptText) + } + if strings.Contains(promptText, "first user turn") { + t.Fatalf("expected historical turns removed from completion prompt, got %s", promptText) + } + if !strings.Contains(promptText, "HISTORY.txt") { + t.Fatalf("expected history instruction in completion prompt, got %s", promptText) + } + refIDs, _ := ds.completionReq["ref_file_ids"].([]any) + if len(refIDs) == 0 || refIDs[0] != "file-inline-1" { + t.Fatalf("expected uploaded history file to be first ref_file_id, got %#v", ds.completionReq["ref_file_ids"]) + } +} + +func TestResponsesHistorySplitUploadsHistoryAndKeepsLatestPrompt(t *testing.T) { + ds := &inlineUploadDSStub{} + h := &Handler{ + Store: mockOpenAIConfig{ + wideInput: true, + historySplitEnabled: true, + historySplitTurns: 1, + }, + Auth: streamStatusAuthStub{}, + DS: ds, + } + r := chi.NewRouter() + RegisterRoutes(r, h) + reqBody, _ := json.Marshal(map[string]any{ + "model": "deepseek-chat", + "messages": historySplitTestMessages(), + "stream": false, + }) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(string(reqBody))) + req.Header.Set("Authorization", "Bearer direct-token") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + if len(ds.uploadCalls) != 1 { + t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls)) + } + if ds.completionReq == nil { + t.Fatal("expected completion payload to be captured") + } + promptText, _ := ds.completionReq["prompt"].(string) + if !strings.Contains(promptText, "latest user turn") { + t.Fatalf("expected latest turn in completion prompt, got %s", promptText) + } + if strings.Contains(promptText, "first user turn") { + t.Fatalf("expected historical turns removed from completion prompt, got %s", promptText) + } +} + +func TestChatCompletionsHistorySplitUploadFailureReturnsInternalServerError(t *testing.T) { + ds := &inlineUploadDSStub{uploadErr: context.DeadlineExceeded} + h := &Handler{ + Store: mockOpenAIConfig{ + wideInput: true, + historySplitEnabled: true, + historySplitTurns: 1, + }, + Auth: streamStatusAuthStub{}, + DS: ds, + } + reqBody, _ := json.Marshal(map[string]any{ + "model": "deepseek-chat", + "messages": historySplitTestMessages(), + "stream": false, + }) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(string(reqBody))) + req.Header.Set("Authorization", "Bearer direct-token") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + h.ChatCompletions(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d body=%s", rec.Code, rec.Body.String()) + } + if ds.completionReq != nil { + t.Fatalf("did not expect completion payload on upload failure") + } +} diff --git a/internal/adapter/openai/responses_handler.go b/internal/adapter/openai/responses_handler.go index 35c616b..2994088 100644 --- a/internal/adapter/openai/responses_handler.go +++ b/internal/adapter/openai/responses_handler.go @@ -85,6 +85,11 @@ func (h *Handler) Responses(w http.ResponseWriter, r *http.Request) { writeOpenAIError(w, http.StatusBadRequest, err.Error()) return } + stdReq, err = h.applyHistorySplit(r.Context(), a, stdReq) + if err != nil { + writeOpenAIError(w, http.StatusInternalServerError, err.Error()) + return + } sessionID, err := h.DS.CreateSession(r.Context(), a, 3) if err != nil { diff --git a/internal/adapter/openai/standard_request.go b/internal/adapter/openai/standard_request.go index 3cdf640..4270c6e 100644 --- a/internal/adapter/openai/standard_request.go +++ b/internal/adapter/openai/standard_request.go @@ -35,6 +35,7 @@ func normalizeOpenAIChatRequest(store ConfigReader, req map[string]any, traceID ResolvedModel: resolvedModel, ResponseModel: responseModel, Messages: messagesRaw, + ToolsRaw: req["tools"], FinalPrompt: finalPrompt, ToolNames: toolNames, ToolChoice: toolPolicy, @@ -90,6 +91,7 @@ func normalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra ResolvedModel: resolvedModel, ResponseModel: model, Messages: messagesRaw, + ToolsRaw: req["tools"], FinalPrompt: finalPrompt, ToolNames: toolNames, ToolChoice: toolPolicy, diff --git a/internal/adapter/openai/upstream_empty.go b/internal/adapter/openai/upstream_empty.go index 8b3d07f..bb2da1f 100644 --- a/internal/adapter/openai/upstream_empty.go +++ b/internal/adapter/openai/upstream_empty.go @@ -2,7 +2,7 @@ package openai import "net/http" -func shouldWriteUpstreamEmptyOutputError(text string, contentFilter bool) bool { +func shouldWriteUpstreamEmptyOutputError(text string) bool { return text == "" } @@ -18,7 +18,7 @@ func upstreamEmptyOutputDetail(contentFilter bool, text, thinking string) (int, } func writeUpstreamEmptyOutputError(w http.ResponseWriter, text string, contentFilter bool) bool { - if !shouldWriteUpstreamEmptyOutputError(text, contentFilter) { + if !shouldWriteUpstreamEmptyOutputError(text) { return false } status, message, code := upstreamEmptyOutputDetail(contentFilter, text, "") diff --git a/internal/admin/deps.go b/internal/admin/deps.go index 6b083fc..436775c 100644 --- a/internal/admin/deps.go +++ b/internal/admin/deps.go @@ -33,6 +33,8 @@ type ConfigStore interface { RuntimeGlobalMaxInflight(defaultSize int) int RuntimeTokenRefreshIntervalHours() int AutoDeleteMode() string + HistorySplitEnabled() bool + HistorySplitTriggerAfterTurns() int CompatStripReferenceMarkers() bool AutoDeleteSessions() bool } diff --git a/internal/admin/handler_settings_parse.go b/internal/admin/handler_settings_parse.go index a9bd699..c02d421 100644 --- a/internal/admin/handler_settings_parse.go +++ b/internal/admin/handler_settings_parse.go @@ -21,16 +21,17 @@ func boolFrom(v any) bool { } } -func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.CompatConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, map[string]string, map[string]string, error) { +func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.CompatConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, *config.HistorySplitConfig, map[string]string, map[string]string, error) { var ( - adminCfg *config.AdminConfig - runtimeCfg *config.RuntimeConfig - compatCfg *config.CompatConfig - respCfg *config.ResponsesConfig - embCfg *config.EmbeddingsConfig - autoDeleteCfg *config.AutoDeleteConfig - claudeMap map[string]string - aliasMap map[string]string + adminCfg *config.AdminConfig + runtimeCfg *config.RuntimeConfig + compatCfg *config.CompatConfig + respCfg *config.ResponsesConfig + embCfg *config.EmbeddingsConfig + autoDeleteCfg *config.AutoDeleteConfig + historySplitCfg *config.HistorySplitConfig + claudeMap map[string]string + aliasMap map[string]string ) if raw, ok := req["admin"].(map[string]any); ok { @@ -38,7 +39,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["jwt_expire_hours"]; exists { n := intFrom(v) if err := config.ValidateIntRange("admin.jwt_expire_hours", n, 1, 720, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.JWTExpireHours = n } @@ -50,33 +51,33 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["account_max_inflight"]; exists { n := intFrom(v) if err := config.ValidateIntRange("runtime.account_max_inflight", n, 1, 256, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.AccountMaxInflight = n } if v, exists := raw["account_max_queue"]; exists { n := intFrom(v) if err := config.ValidateIntRange("runtime.account_max_queue", n, 1, 200000, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.AccountMaxQueue = n } if v, exists := raw["global_max_inflight"]; exists { n := intFrom(v) if err := config.ValidateIntRange("runtime.global_max_inflight", n, 1, 200000, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.GlobalMaxInflight = n } if v, exists := raw["token_refresh_interval_hours"]; exists { n := intFrom(v) if err := config.ValidateIntRange("runtime.token_refresh_interval_hours", n, 1, 720, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.TokenRefreshIntervalHours = n } if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight { - return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight") + return nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight") } runtimeCfg = cfg } @@ -99,7 +100,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["store_ttl_seconds"]; exists { n := intFrom(v) if err := config.ValidateIntRange("responses.store_ttl_seconds", n, 30, 86400, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.StoreTTLSeconds = n } @@ -111,7 +112,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["provider"]; exists { p := strings.TrimSpace(fmt.Sprintf("%v", v)) if err := config.ValidateTrimmedString("embeddings.provider", p, false); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.Provider = p } @@ -147,7 +148,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["mode"]; exists { mode := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v))) if err := config.ValidateAutoDeleteMode(mode); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err } if mode == "" { mode = "none" @@ -160,5 +161,24 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi autoDeleteCfg = cfg } - return adminCfg, runtimeCfg, compatCfg, respCfg, embCfg, autoDeleteCfg, claudeMap, aliasMap, nil + if raw, ok := req["history_split"].(map[string]any); ok { + cfg := &config.HistorySplitConfig{} + if v, exists := raw["enabled"]; exists { + b := boolFrom(v) + cfg.Enabled = &b + } + if v, exists := raw["trigger_after_turns"]; exists { + n := intFrom(v) + if err := config.ValidateIntRange("history_split.trigger_after_turns", n, 1, 1000, true); err != nil { + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + } + cfg.TriggerAfterTurns = &n + } + if err := config.ValidateHistorySplitConfig(*cfg); err != nil { + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + } + historySplitCfg = cfg + } + + return adminCfg, runtimeCfg, compatCfg, respCfg, embCfg, autoDeleteCfg, historySplitCfg, claudeMap, aliasMap, nil } diff --git a/internal/admin/handler_settings_read.go b/internal/admin/handler_settings_read.go index d881148..dc060a8 100644 --- a/internal/admin/handler_settings_read.go +++ b/internal/admin/handler_settings_read.go @@ -26,10 +26,14 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) { "global_max_inflight": h.Store.RuntimeGlobalMaxInflight(recommended), "token_refresh_interval_hours": h.Store.RuntimeTokenRefreshIntervalHours(), }, - "compat": snap.Compat, - "responses": snap.Responses, - "embeddings": snap.Embeddings, - "auto_delete": snap.AutoDelete, + "compat": snap.Compat, + "responses": snap.Responses, + "embeddings": snap.Embeddings, + "auto_delete": snap.AutoDelete, + "history_split": map[string]any{ + "enabled": h.Store.HistorySplitEnabled(), + "trigger_after_turns": h.Store.HistorySplitTriggerAfterTurns(), + }, "claude_mapping": settingsClaudeMapping(snap), "model_aliases": snap.ModelAliases, "env_backed": h.Store.IsEnvBacked(), diff --git a/internal/admin/handler_settings_test.go b/internal/admin/handler_settings_test.go index e3ec356..4300cfe 100644 --- a/internal/admin/handler_settings_test.go +++ b/internal/admin/handler_settings_test.go @@ -47,6 +47,25 @@ func TestGetSettingsIncludesTokenRefreshInterval(t *testing.T) { } } +func TestGetSettingsIncludesHistorySplitDefaults(t *testing.T) { + h := newAdminTestHandler(t, `{"keys":["k1"]}`) + req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil) + rec := httptest.NewRecorder() + h.getSettings(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var body map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &body) + historySplit, _ := body["history_split"].(map[string]any) + if got := boolFrom(historySplit["enabled"]); !got { + t.Fatalf("expected history_split.enabled=true, body=%v", body) + } + if got := intFrom(historySplit["trigger_after_turns"]); got != 1 { + t.Fatalf("expected history_split.trigger_after_turns=1, got %d body=%v", got, body) + } +} + func TestUpdateSettingsValidation(t *testing.T) { h := newAdminTestHandler(t, `{"keys":["k1"]}`) payload := map[string]any{ @@ -154,6 +173,30 @@ func TestUpdateSettingsWithoutRuntimeSkipsMergedRuntimeValidation(t *testing.T) } } +func TestUpdateSettingsHistorySplit(t *testing.T) { + h := newAdminTestHandler(t, `{"keys":["k1"]}`) + payload := map[string]any{ + "history_split": map[string]any{ + "enabled": false, + "trigger_after_turns": 3, + }, + } + b, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b)) + rec := httptest.NewRecorder() + h.updateSettings(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + snap := h.Store.Snapshot() + if snap.HistorySplit.Enabled == nil || *snap.HistorySplit.Enabled { + t.Fatalf("expected history_split.enabled=false, got %#v", snap.HistorySplit.Enabled) + } + if snap.HistorySplit.TriggerAfterTurns == nil || *snap.HistorySplit.TriggerAfterTurns != 3 { + t.Fatalf("expected history_split.trigger_after_turns=3, got %#v", snap.HistorySplit.TriggerAfterTurns) + } +} + func TestUpdateSettingsAutoDeleteMode(t *testing.T) { h := newAdminTestHandler(t, `{"keys":["k1"],"auto_delete":{"sessions":true}}`) diff --git a/internal/admin/handler_settings_write.go b/internal/admin/handler_settings_write.go index 776e6b9..ee4105a 100644 --- a/internal/admin/handler_settings_write.go +++ b/internal/admin/handler_settings_write.go @@ -17,7 +17,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) { return } - adminCfg, runtimeCfg, compatCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req) + adminCfg, runtimeCfg, compatCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, historySplitCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()}) return @@ -67,6 +67,14 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) { c.AutoDelete.Mode = autoDeleteCfg.Mode c.AutoDelete.Sessions = autoDeleteCfg.Sessions } + if historySplitCfg != nil { + if historySplitCfg.Enabled != nil { + c.HistorySplit.Enabled = historySplitCfg.Enabled + } + if historySplitCfg.TriggerAfterTurns != nil { + c.HistorySplit.TriggerAfterTurns = historySplitCfg.TriggerAfterTurns + } + } if claudeMap != nil { c.ClaudeMapping = claudeMap c.ClaudeModelMap = nil diff --git a/internal/config/codec.go b/internal/config/codec.go index 744b9b7..11bf1d6 100644 --- a/internal/config/codec.go +++ b/internal/config/codec.go @@ -51,6 +51,9 @@ func (c Config) MarshalJSON() ([]byte, error) { m["embeddings"] = c.Embeddings } m["auto_delete"] = c.AutoDelete + if c.HistorySplit.Enabled != nil || c.HistorySplit.TriggerAfterTurns != nil { + m["history_split"] = c.HistorySplit + } if c.VercelSyncHash != "" { m["_vercel_sync_hash"] = c.VercelSyncHash } @@ -122,6 +125,10 @@ func (c *Config) UnmarshalJSON(b []byte) error { if err := json.Unmarshal(v, &c.AutoDelete); err != nil { return fmt.Errorf("invalid field %q: %w", k, err) } + case "history_split": + if err := json.Unmarshal(v, &c.HistorySplit); err != nil { + return fmt.Errorf("invalid field %q: %w", k, err) + } case "_vercel_sync_hash": if err := json.Unmarshal(v, &c.VercelSyncHash); err != nil { return fmt.Errorf("invalid field %q: %w", k, err) @@ -156,9 +163,13 @@ func (c Config) Clone() Config { WideInputStrictOutput: cloneBoolPtr(c.Compat.WideInputStrictOutput), StripReferenceMarkers: cloneBoolPtr(c.Compat.StripReferenceMarkers), }, - Responses: c.Responses, - Embeddings: c.Embeddings, - AutoDelete: c.AutoDelete, + Responses: c.Responses, + Embeddings: c.Embeddings, + AutoDelete: c.AutoDelete, + HistorySplit: HistorySplitConfig{ + Enabled: cloneBoolPtr(c.HistorySplit.Enabled), + TriggerAfterTurns: cloneIntPtr(c.HistorySplit.TriggerAfterTurns), + }, VercelSyncHash: c.VercelSyncHash, VercelSyncTime: c.VercelSyncTime, AdditionalFields: map[string]any{}, @@ -188,6 +199,14 @@ func cloneBoolPtr(in *bool) *bool { return &v } +func cloneIntPtr(in *int) *int { + if in == nil { + return nil + } + v := *in + return &v +} + func parseConfigString(raw string) (Config, error) { var cfg Config candidates := []string{raw} diff --git a/internal/config/config.go b/internal/config/config.go index 05879c2..dd1d5df 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,22 +8,23 @@ import ( ) type Config struct { - Keys []string `json:"keys,omitempty"` - APIKeys []APIKey `json:"api_keys,omitempty"` - Accounts []Account `json:"accounts,omitempty"` - Proxies []Proxy `json:"proxies,omitempty"` - ClaudeMapping map[string]string `json:"claude_mapping,omitempty"` - ClaudeModelMap map[string]string `json:"claude_model_mapping,omitempty"` - ModelAliases map[string]string `json:"model_aliases,omitempty"` - Admin AdminConfig `json:"admin,omitempty"` - Runtime RuntimeConfig `json:"runtime,omitempty"` - Compat CompatConfig `json:"compat,omitempty"` - Responses ResponsesConfig `json:"responses,omitempty"` - Embeddings EmbeddingsConfig `json:"embeddings,omitempty"` - AutoDelete AutoDeleteConfig `json:"auto_delete"` - VercelSyncHash string `json:"_vercel_sync_hash,omitempty"` - VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"` - AdditionalFields map[string]any `json:"-"` + Keys []string `json:"keys,omitempty"` + APIKeys []APIKey `json:"api_keys,omitempty"` + Accounts []Account `json:"accounts,omitempty"` + Proxies []Proxy `json:"proxies,omitempty"` + ClaudeMapping map[string]string `json:"claude_mapping,omitempty"` + ClaudeModelMap map[string]string `json:"claude_model_mapping,omitempty"` + ModelAliases map[string]string `json:"model_aliases,omitempty"` + Admin AdminConfig `json:"admin,omitempty"` + Runtime RuntimeConfig `json:"runtime,omitempty"` + Compat CompatConfig `json:"compat,omitempty"` + Responses ResponsesConfig `json:"responses,omitempty"` + Embeddings EmbeddingsConfig `json:"embeddings,omitempty"` + AutoDelete AutoDeleteConfig `json:"auto_delete"` + HistorySplit HistorySplitConfig `json:"history_split"` + VercelSyncHash string `json:"_vercel_sync_hash,omitempty"` + VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"` + AdditionalFields map[string]any `json:"-"` } type Account struct { @@ -148,3 +149,8 @@ type AutoDeleteConfig struct { Mode string `json:"mode,omitempty"` Sessions bool `json:"sessions,omitempty"` } + +type HistorySplitConfig struct { + Enabled *bool `json:"enabled,omitempty"` + TriggerAfterTurns *int `json:"trigger_after_turns,omitempty"` +} diff --git a/internal/config/config_edge_test.go b/internal/config/config_edge_test.go index b70cf11..95a6eba 100644 --- a/internal/config/config_edge_test.go +++ b/internal/config/config_edge_test.go @@ -154,6 +154,10 @@ func TestConfigJSONRoundtrip(t *testing.T) { AutoDelete: AutoDeleteConfig{ Mode: "single", }, + HistorySplit: HistorySplitConfig{ + Enabled: &trueVal, + TriggerAfterTurns: func() *int { v := 2; return &v }(), + }, Runtime: RuntimeConfig{ TokenRefreshIntervalHours: 12, }, @@ -193,6 +197,12 @@ func TestConfigJSONRoundtrip(t *testing.T) { if decoded.AutoDelete.Mode != "single" { t.Fatalf("unexpected auto delete mode: %#v", decoded.AutoDelete.Mode) } + if decoded.HistorySplit.Enabled == nil || !*decoded.HistorySplit.Enabled { + t.Fatalf("unexpected history split enabled: %#v", decoded.HistorySplit.Enabled) + } + if decoded.HistorySplit.TriggerAfterTurns == nil || *decoded.HistorySplit.TriggerAfterTurns != 2 { + t.Fatalf("unexpected history split trigger_after_turns: %#v", decoded.HistorySplit.TriggerAfterTurns) + } if decoded.Compat.WideInputStrictOutput == nil || !*decoded.Compat.WideInputStrictOutput { t.Fatalf("unexpected compat wide_input_strict_output: %#v", decoded.Compat.WideInputStrictOutput) } @@ -249,6 +259,8 @@ func TestConfigUnmarshalJSONPreservesUnknownFields(t *testing.T) { func TestConfigCloneIsDeepCopy(t *testing.T) { falseVal := false + trueVal := true + turns := 2 cfg := Config{ Keys: []string{"key1"}, Accounts: []Account{{Email: "user@test.com", Token: "token"}}, @@ -258,6 +270,10 @@ func TestConfigCloneIsDeepCopy(t *testing.T) { Compat: CompatConfig{ StripReferenceMarkers: &falseVal, }, + HistorySplit: HistorySplitConfig{ + Enabled: &trueVal, + TriggerAfterTurns: &turns, + }, AdditionalFields: map[string]any{"custom": "value"}, } @@ -270,6 +286,12 @@ func TestConfigCloneIsDeepCopy(t *testing.T) { if cfg.Compat.StripReferenceMarkers != nil { *cfg.Compat.StripReferenceMarkers = true } + if cfg.HistorySplit.Enabled != nil { + *cfg.HistorySplit.Enabled = false + } + if cfg.HistorySplit.TriggerAfterTurns != nil { + *cfg.HistorySplit.TriggerAfterTurns = 5 + } // Cloned should not be affected if cloned.Keys[0] != "key1" { @@ -284,6 +306,12 @@ func TestConfigCloneIsDeepCopy(t *testing.T) { if cloned.Compat.StripReferenceMarkers == nil || *cloned.Compat.StripReferenceMarkers { t.Fatalf("clone compat was affected: %#v", cloned.Compat.StripReferenceMarkers) } + if cloned.HistorySplit.Enabled == nil || !*cloned.HistorySplit.Enabled { + t.Fatalf("clone history split enabled was affected: %#v", cloned.HistorySplit.Enabled) + } + if cloned.HistorySplit.TriggerAfterTurns == nil || *cloned.HistorySplit.TriggerAfterTurns != 2 { + t.Fatalf("clone history split trigger was affected: %#v", cloned.HistorySplit.TriggerAfterTurns) + } } func TestConfigCloneNilMaps(t *testing.T) { diff --git a/internal/config/store_accessors.go b/internal/config/store_accessors.go index ff152a7..4b8c003 100644 --- a/internal/config/store_accessors.go +++ b/internal/config/store_accessors.go @@ -174,3 +174,21 @@ func (s *Store) RuntimeTokenRefreshIntervalHours() int { func (s *Store) AutoDeleteSessions() bool { return s.AutoDeleteMode() != "none" } + +func (s *Store) HistorySplitEnabled() bool { + s.mu.RLock() + defer s.mu.RUnlock() + if s.cfg.HistorySplit.Enabled == nil { + return true + } + return *s.cfg.HistorySplit.Enabled +} + +func (s *Store) HistorySplitTriggerAfterTurns() int { + s.mu.RLock() + defer s.mu.RUnlock() + if s.cfg.HistorySplit.TriggerAfterTurns == nil || *s.cfg.HistorySplit.TriggerAfterTurns <= 0 { + return 1 + } + return *s.cfg.HistorySplit.TriggerAfterTurns +} diff --git a/internal/config/store_accessors_test.go b/internal/config/store_accessors_test.go new file mode 100644 index 0000000..6939602 --- /dev/null +++ b/internal/config/store_accessors_test.go @@ -0,0 +1,27 @@ +package config + +import "testing" + +func TestStoreHistorySplitAccessors(t *testing.T) { + store := &Store{cfg: Config{}} + if !store.HistorySplitEnabled() { + t.Fatal("expected history split enabled by default") + } + if got := store.HistorySplitTriggerAfterTurns(); got != 1 { + t.Fatalf("default history split trigger_after_turns=%d want=1", got) + } + + enabled := false + turns := 3 + store.cfg.HistorySplit = HistorySplitConfig{ + Enabled: &enabled, + TriggerAfterTurns: &turns, + } + + if store.HistorySplitEnabled() { + t.Fatal("expected history split disabled after override") + } + if got := store.HistorySplitTriggerAfterTurns(); got != 3 { + t.Fatalf("history split trigger_after_turns=%d want=3", got) + } +} diff --git a/internal/config/validation.go b/internal/config/validation.go index e7314e6..3e8954c 100644 --- a/internal/config/validation.go +++ b/internal/config/validation.go @@ -24,6 +24,9 @@ func ValidateConfig(c Config) error { if err := ValidateAutoDeleteConfig(c.AutoDelete); err != nil { return err } + if err := ValidateHistorySplitConfig(c.HistorySplit); err != nil { + return err + } if err := ValidateAccountProxyReferences(c.Accounts, c.Proxies); err != nil { return err } @@ -111,6 +114,15 @@ func ValidateAutoDeleteConfig(autoDelete AutoDeleteConfig) error { return ValidateAutoDeleteMode(autoDelete.Mode) } +func ValidateHistorySplitConfig(historySplit HistorySplitConfig) error { + if historySplit.TriggerAfterTurns != nil { + if err := ValidateIntRange("history_split.trigger_after_turns", *historySplit.TriggerAfterTurns, 1, 1000, true); err != nil { + return err + } + } + return nil +} + func ValidateIntRange(name string, value, min, max int, required bool) error { if value == 0 && !required { return nil diff --git a/internal/config/validation_test.go b/internal/config/validation_test.go index 00b2929..cf4a68e 100644 --- a/internal/config/validation_test.go +++ b/internal/config/validation_test.go @@ -39,6 +39,13 @@ func TestValidateConfigRejectsInvalidValues(t *testing.T) { cfg: Config{AutoDelete: AutoDeleteConfig{Mode: "maybe"}}, want: "auto_delete.mode", }, + { + name: "history split", + cfg: Config{HistorySplit: HistorySplitConfig{ + TriggerAfterTurns: intPtr(0), + }}, + want: "history_split.trigger_after_turns", + }, } for _, tc := range tests { @@ -59,3 +66,5 @@ func TestValidateConfigAcceptsLegacyAutoDeleteSessions(t *testing.T) { t.Fatalf("expected legacy auto_delete.sessions config to remain valid, got %v", err) } } + +func intPtr(v int) *int { return &v } diff --git a/internal/util/standard_request.go b/internal/util/standard_request.go index 2071fbe..be3eeaa 100644 --- a/internal/util/standard_request.go +++ b/internal/util/standard_request.go @@ -8,6 +8,7 @@ type StandardRequest struct { ResolvedModel string ResponseModel string Messages []any + ToolsRaw any FinalPrompt string ToolNames []string ToolChoice ToolChoicePolicy diff --git a/webui/src/features/settings/HistorySplitSection.jsx b/webui/src/features/settings/HistorySplitSection.jsx new file mode 100644 index 0000000..d9db63c --- /dev/null +++ b/webui/src/features/settings/HistorySplitSection.jsx @@ -0,0 +1,48 @@ +export default function HistorySplitSection({ t, form, setForm }) { + return ( +
+
+

{t('settings.historySplitTitle')}

+

{t('settings.historySplitDesc')}

+
+
+ + +
+
+ ) +} diff --git a/webui/src/features/settings/SettingsContainer.jsx b/webui/src/features/settings/SettingsContainer.jsx index 3b2da85..98a9c6a 100644 --- a/webui/src/features/settings/SettingsContainer.jsx +++ b/webui/src/features/settings/SettingsContainer.jsx @@ -5,6 +5,7 @@ import { useSettingsForm } from './useSettingsForm' import SecuritySection from './SecuritySection' import RuntimeSection from './RuntimeSection' import BehaviorSection from './BehaviorSection' +import HistorySplitSection from './HistorySplitSection' import CompatibilitySection from './CompatibilitySection' import AutoDeleteSection from './AutoDeleteSection' import ModelSection from './ModelSection' @@ -95,6 +96,8 @@ export default function SettingsContainer({ onRefresh, onMessage, authFetch, onF + + diff --git a/webui/src/features/settings/useSettingsForm.js b/webui/src/features/settings/useSettingsForm.js index e44c0bc..96aa1b5 100644 --- a/webui/src/features/settings/useSettingsForm.js +++ b/webui/src/features/settings/useSettingsForm.js @@ -17,6 +17,7 @@ const DEFAULT_FORM = { responses: { store_ttl_seconds: 900 }, embeddings: { provider: '' }, auto_delete: { mode: 'none' }, + history_split: { enabled: true, trigger_after_turns: 1 }, claude_mapping_text: '{\n "fast": "deepseek-chat",\n "slow": "deepseek-reasoner"\n}', model_aliases_text: '{}', } @@ -70,6 +71,10 @@ function fromServerForm(data) { auto_delete: { mode: normalizeAutoDeleteMode(data.auto_delete), }, + history_split: { + enabled: data.history_split?.enabled ?? true, + trigger_after_turns: Number(data.history_split?.trigger_after_turns || 1), + }, claude_mapping_text: JSON.stringify(data.claude_mapping || {}, null, 2), model_aliases_text: JSON.stringify(data.model_aliases || {}, null, 2), } @@ -90,6 +95,10 @@ function toServerPayload(form) { responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) }, embeddings: { provider: String(form.embeddings.provider || '').trim() }, auto_delete: { mode: normalizeAutoDeleteMode(form.auto_delete) }, + history_split: { + enabled: Boolean(form.history_split?.enabled ?? true), + trigger_after_turns: Number(form.history_split?.trigger_after_turns || 1), + }, } } diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index 7548a30..f37530c 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -379,6 +379,12 @@ "behaviorTitle": "Behavior", "responsesTTL": "Responses store TTL (seconds)", "embeddingsProvider": "Embeddings provider", + "historySplitTitle": "History Split", + "historySplitDesc": "Pack earlier turns into an attached HISTORY.txt so the model reads the file first and then continues from the latest user request.", + "historySplitEnabled": "Enable history split", + "historySplitEnabledDesc": "Enabled by default. Turning this off falls back to normal full-context requests.", + "historySplitTriggerAfterTurns": "Trigger threshold (user turns)", + "historySplitTriggerHelp": "Default is 1, which means history split starts from the second turn.", "compatibilityTitle": "Compatibility", "compatibilityDesc": "Compatibility controls that keep stream output closer to the wire format or safer for the web UI.", "stripReferenceMarkers": "Strip [reference:N] markers", diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index 9477c5f..d3ead83 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -379,6 +379,12 @@ "behaviorTitle": "行为设置", "responsesTTL": "Responses 缓存 TTL(秒)", "embeddingsProvider": "Embeddings Provider", + "historySplitTitle": "历史拆分", + "historySplitDesc": "将更早的对话整理成 HISTORY.txt 上传,让模型优先读取历史文件,再结合最新一轮继续回答。", + "historySplitEnabled": "启用历史拆分", + "historySplitEnabledDesc": "默认开启。关闭后会恢复为普通的完整上下文提交。", + "historySplitTriggerAfterTurns": "触发阈值(用户回合数)", + "historySplitTriggerHelp": "默认值为 1,表示从第二轮开始拆分历史。", "compatibilityTitle": "兼容性设置", "compatibilityDesc": "用于控制输出格式兼容性,避免把模型原始流里的标记直接暴露到前端。", "stripReferenceMarkers": "移除 [reference:N] 标记",