mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-19 15:37:44 +08:00
feat: implement history split functionality to optimize context usage and add corresponding UI settings
This commit is contained in:
@@ -34,6 +34,8 @@ type ConfigReader interface {
|
||||
EmbeddingsProvider() string
|
||||
AutoDeleteMode() string
|
||||
AutoDeleteSessions() bool
|
||||
HistorySplitEnabled() bool
|
||||
HistorySplitTriggerAfterTurns() int
|
||||
}
|
||||
|
||||
var _ AuthResolver = (*auth.Resolver)(nil)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
213
internal/adapter/openai/history_split.go
Normal file
213
internal/adapter/openai/history_split.go
Normal file
@@ -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
|
||||
}
|
||||
317
internal/adapter/openai/history_split_test.go
Normal file
317
internal/adapter/openai/history_split_test.go
Normal file
@@ -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, "<tool_calls>") {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, "")
|
||||
|
||||
Reference in New Issue
Block a user