mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-05 08:55:28 +08:00
407 lines
14 KiB
Go
407 lines
14 KiB
Go
package chat
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"ds2api/internal/auth"
|
|
"ds2api/internal/chathistory"
|
|
"ds2api/internal/promptcompat"
|
|
)
|
|
|
|
func newTestChatHistoryStore(t *testing.T) *chathistory.Store {
|
|
t.Helper()
|
|
store := chathistory.New(filepath.Join(t.TempDir(), "chat_history.json"))
|
|
if err := store.Err(); err != nil {
|
|
t.Fatalf("chat history store unavailable: %v", err)
|
|
}
|
|
return store
|
|
}
|
|
|
|
func blockChatHistoryDetailDir(t *testing.T, detailDir string) func() {
|
|
t.Helper()
|
|
blockedDir := detailDir + ".blocked"
|
|
if err := os.RemoveAll(blockedDir); err != nil {
|
|
t.Fatalf("remove blocked detail dir failed: %v", err)
|
|
}
|
|
if err := os.Rename(detailDir, blockedDir); err != nil {
|
|
t.Fatalf("move detail dir aside failed: %v", err)
|
|
}
|
|
if err := os.RemoveAll(detailDir); err != nil {
|
|
t.Fatalf("remove blocked detail path failed: %v", err)
|
|
}
|
|
if err := os.WriteFile(detailDir, []byte("blocked"), 0o644); err != nil {
|
|
t.Fatalf("write blocked detail path failed: %v", err)
|
|
}
|
|
var once sync.Once
|
|
return func() {
|
|
t.Helper()
|
|
once.Do(func() {
|
|
if err := os.RemoveAll(detailDir); err != nil {
|
|
t.Fatalf("remove blocking detail path failed: %v", err)
|
|
}
|
|
if err := os.Rename(blockedDir, detailDir); err != nil {
|
|
t.Fatalf("restore detail dir failed: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsNonStreamPersistsHistory(t *testing.T) {
|
|
historyStore := newTestChatHistoryStore(t)
|
|
h := &Handler{
|
|
Store: mockOpenAIConfig{},
|
|
Auth: streamStatusAuthStub{},
|
|
DS: streamStatusDSStub{resp: makeOpenAISSEHTTPResponse(`data: {"p":"response/content","v":"hello world"}`, `data: [DONE]`)},
|
|
ChatHistory: historyStore,
|
|
}
|
|
|
|
reqBody := `{"model":"deepseek-v4-flash","messages":[{"role":"system","content":"be precise"},{"role":"user","content":"hi there"},{"role":"assistant","content":"previous answer"}],"stream":false}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(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())
|
|
}
|
|
snapshot, err := historyStore.Snapshot()
|
|
if err != nil {
|
|
t.Fatalf("snapshot failed: %v", err)
|
|
}
|
|
if len(snapshot.Items) != 1 {
|
|
t.Fatalf("expected one history item, got %d", len(snapshot.Items))
|
|
}
|
|
item := snapshot.Items[0]
|
|
if item.Status != "success" || item.UserInput != "hi there" {
|
|
t.Fatalf("unexpected persisted history summary: %#v", item)
|
|
}
|
|
full, err := historyStore.Get(item.ID)
|
|
if err != nil {
|
|
t.Fatalf("expected detail item, got %v", err)
|
|
}
|
|
if full.Content != "hello world" {
|
|
t.Fatalf("expected detail content persisted, got %#v", full)
|
|
}
|
|
if len(full.Messages) != 3 {
|
|
t.Fatalf("expected all request messages persisted, got %#v", full.Messages)
|
|
}
|
|
if full.FinalPrompt == "" {
|
|
t.Fatalf("expected final prompt to be persisted")
|
|
}
|
|
if item.CallerID != "caller:test" {
|
|
t.Fatalf("expected caller hash persisted in summary, got %#v", item.CallerID)
|
|
}
|
|
}
|
|
|
|
func TestChatHistoryNonStreamArchivesRawToolCallMarkup(t *testing.T) {
|
|
historyStore := newTestChatHistoryStore(t)
|
|
entry, err := historyStore.Start(chathistory.StartParams{
|
|
CallerID: "caller:test",
|
|
Model: "deepseek-v4-flash",
|
|
UserInput: "call tool",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("start history failed: %v", err)
|
|
}
|
|
session := &chatHistorySession{
|
|
store: historyStore,
|
|
entryID: entry.ID,
|
|
startedAt: time.Now(),
|
|
lastPersist: time.Now().Add(-time.Second),
|
|
finalPrompt: "call tool",
|
|
}
|
|
rawToolCall := `<tool_calls><invoke name="search"><parameter name="q">golang</parameter></invoke></tool_calls>`
|
|
|
|
h := &Handler{}
|
|
rec := httptest.NewRecorder()
|
|
resp := makeOpenAISSEHTTPResponse(`data: {"p":"response/content","v":`+strconv.Quote(rawToolCall)+`}`, `data: [DONE]`)
|
|
h.handleNonStream(rec, resp, "cid-tool-history", "deepseek-v4-flash", "prompt", 0, false, false, []string{"search"}, nil, session)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
full, err := historyStore.Get(entry.ID)
|
|
if err != nil {
|
|
t.Fatalf("get detail failed: %v", err)
|
|
}
|
|
if full.Content != rawToolCall {
|
|
t.Fatalf("expected raw tool markup archived, got %q", full.Content)
|
|
}
|
|
if full.FinishReason != "tool_calls" {
|
|
t.Fatalf("expected tool_calls finish reason, got %#v", full.FinishReason)
|
|
}
|
|
}
|
|
|
|
func TestChatHistoryStreamArchivesRawToolCallMarkup(t *testing.T) {
|
|
historyStore := newTestChatHistoryStore(t)
|
|
entry, err := historyStore.Start(chathistory.StartParams{
|
|
CallerID: "caller:test",
|
|
Model: "deepseek-v4-flash",
|
|
Stream: true,
|
|
UserInput: "call tool",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("start history failed: %v", err)
|
|
}
|
|
session := &chatHistorySession{
|
|
store: historyStore,
|
|
entryID: entry.ID,
|
|
startedAt: time.Now(),
|
|
lastPersist: time.Now().Add(-time.Second),
|
|
finalPrompt: "call tool",
|
|
}
|
|
rawToolCall := `<tool_calls><invoke name="search"><parameter name="q">golang</parameter></invoke></tool_calls>`
|
|
|
|
h := &Handler{}
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
|
rec := httptest.NewRecorder()
|
|
resp := makeOpenAISSEHTTPResponse(`data: {"p":"response/content","v":`+strconv.Quote(rawToolCall)+`}`, `data: [DONE]`)
|
|
h.handleStream(rec, req, resp, "cid-stream-tool-history", "deepseek-v4-flash", "prompt", 0, false, false, []string{"search"}, nil, session)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
full, err := historyStore.Get(entry.ID)
|
|
if err != nil {
|
|
t.Fatalf("get detail failed: %v", err)
|
|
}
|
|
if full.Content != rawToolCall {
|
|
t.Fatalf("expected raw streamed tool markup archived, got %q", full.Content)
|
|
}
|
|
if full.FinishReason != "tool_calls" {
|
|
t.Fatalf("expected tool_calls finish reason, got %#v", full.FinishReason)
|
|
}
|
|
}
|
|
|
|
func TestStartChatHistoryRecoversFromTransientWriteFailure(t *testing.T) {
|
|
historyStore := newTestChatHistoryStore(t)
|
|
restore := blockChatHistoryDetailDir(t, historyStore.DetailDir())
|
|
t.Cleanup(restore)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
|
req.Header.Set("Authorization", "Bearer direct-token")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
a := &auth.RequestAuth{
|
|
CallerID: "caller:test",
|
|
AccountID: "acct:test",
|
|
}
|
|
stdReq := promptcompat.StandardRequest{
|
|
ResponseModel: "deepseek-v4-flash",
|
|
Stream: true,
|
|
Messages: []any{
|
|
map[string]any{"role": "user", "content": "hello"},
|
|
},
|
|
FinalPrompt: "hello",
|
|
}
|
|
|
|
session := startChatHistory(historyStore, req, a, stdReq)
|
|
if session == nil {
|
|
t.Fatalf("expected session even when initial persistence fails")
|
|
return
|
|
}
|
|
if session.disabled {
|
|
t.Fatalf("expected session to remain active after transient start failure")
|
|
}
|
|
if session.entryID == "" {
|
|
t.Fatalf("expected session entry id to be retained")
|
|
}
|
|
if err := historyStore.Err(); err != nil {
|
|
t.Fatalf("transient start failure should not latch store error: %v", err)
|
|
}
|
|
|
|
session.lastPersist = time.Now().Add(-time.Second)
|
|
session.progress("thinking", "partial")
|
|
if session.disabled {
|
|
t.Fatalf("expected session to remain active after transient update failure")
|
|
}
|
|
if session.entryID == "" {
|
|
t.Fatalf("expected session entry id to remain set after update failure")
|
|
}
|
|
if err := historyStore.Err(); err != nil {
|
|
t.Fatalf("transient update failure should not latch store error: %v", err)
|
|
}
|
|
|
|
restore()
|
|
|
|
session.success(http.StatusOK, "thinking", "final answer", "stop", map[string]any{"total_tokens": 7})
|
|
snapshot, err := historyStore.Snapshot()
|
|
if err != nil {
|
|
t.Fatalf("snapshot failed after restore: %v", err)
|
|
}
|
|
if len(snapshot.Items) != 1 {
|
|
t.Fatalf("expected one persisted item after restore, got %#v", snapshot.Items)
|
|
}
|
|
full, err := historyStore.Get(session.entryID)
|
|
if err != nil {
|
|
t.Fatalf("get restored entry failed: %v", err)
|
|
}
|
|
if full.Status != "success" || full.Content != "final answer" {
|
|
t.Fatalf("expected restored entry to persist final success, got %#v", full)
|
|
}
|
|
}
|
|
|
|
func TestHandleStreamContextCancelledMarksHistoryStopped(t *testing.T) {
|
|
historyStore := newTestChatHistoryStore(t)
|
|
entry, err := historyStore.Start(chathistory.StartParams{
|
|
CallerID: "caller:test",
|
|
Model: "deepseek-v4-flash",
|
|
Stream: true,
|
|
UserInput: "hello",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("start history failed: %v", err)
|
|
}
|
|
session := &chatHistorySession{
|
|
store: historyStore,
|
|
entryID: entry.ID,
|
|
startedAt: time.Now(),
|
|
lastPersist: time.Now(),
|
|
finalPrompt: "hello",
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
h := &Handler{}
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(ctx)
|
|
rec := httptest.NewRecorder()
|
|
resp := makeOpenAISSEHTTPResponse(`data: {"p":"response/content","v":"hello"}`, `data: [DONE]`)
|
|
|
|
h.handleStream(rec, req, resp, "cid-stop", "deepseek-v4-flash", "prompt", 0, false, false, nil, nil, session)
|
|
|
|
snapshot, err := historyStore.Snapshot()
|
|
if err != nil {
|
|
t.Fatalf("snapshot failed: %v", err)
|
|
}
|
|
if len(snapshot.Items) != 1 {
|
|
t.Fatalf("expected one history item, got %d", len(snapshot.Items))
|
|
}
|
|
full, err := historyStore.Get(snapshot.Items[0].ID)
|
|
if err != nil {
|
|
t.Fatalf("get detail failed: %v", err)
|
|
}
|
|
if full.Status != "stopped" {
|
|
t.Fatalf("expected stopped status, got %#v", full)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsRecordsAdminWebUISource(t *testing.T) {
|
|
historyStore := newTestChatHistoryStore(t)
|
|
h := &Handler{
|
|
Store: mockOpenAIConfig{},
|
|
Auth: streamStatusAuthStub{},
|
|
DS: streamStatusDSStub{resp: makeOpenAISSEHTTPResponse(`data: {"p":"response/content","v":"hello world"}`, `data: [DONE]`)},
|
|
ChatHistory: historyStore,
|
|
}
|
|
|
|
reqBody := `{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi there"}],"stream":false}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody))
|
|
req.Header.Set("Authorization", "Bearer direct-token")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Ds2-Source", "admin-webui-api-tester")
|
|
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())
|
|
}
|
|
snapshot, err := historyStore.Snapshot()
|
|
if err != nil {
|
|
t.Fatalf("snapshot failed: %v", err)
|
|
}
|
|
if len(snapshot.Items) != 1 {
|
|
t.Fatalf("expected admin webui source to be recorded, got %#v", snapshot.Items)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsSkipsHistoryWhenDisabled(t *testing.T) {
|
|
historyStore := newTestChatHistoryStore(t)
|
|
if _, err := historyStore.SetLimit(chathistory.DisabledLimit); err != nil {
|
|
t.Fatalf("disable history store failed: %v", err)
|
|
}
|
|
h := &Handler{
|
|
Store: mockOpenAIConfig{},
|
|
Auth: streamStatusAuthStub{},
|
|
DS: streamStatusDSStub{resp: makeOpenAISSEHTTPResponse(`data: {"p":"response/content","v":"hello world"}`, `data: [DONE]`)},
|
|
ChatHistory: historyStore,
|
|
}
|
|
|
|
reqBody := `{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi there"}],"stream":false}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(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())
|
|
}
|
|
snapshot, err := historyStore.Snapshot()
|
|
if err != nil {
|
|
t.Fatalf("snapshot failed: %v", err)
|
|
}
|
|
if len(snapshot.Items) != 0 {
|
|
t.Fatalf("expected disabled history to stay empty, got %#v", snapshot.Items)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsCurrentInputFilePersistsNeutralPrompt(t *testing.T) {
|
|
historyStore := newTestChatHistoryStore(t)
|
|
ds := &inlineUploadDSStub{}
|
|
h := &Handler{
|
|
Store: mockOpenAIConfig{
|
|
currentInputEnabled: true,
|
|
},
|
|
Auth: streamStatusAuthStub{},
|
|
DS: ds,
|
|
ChatHistory: historyStore,
|
|
}
|
|
|
|
reqBody := `{"model":"deepseek-v4-flash","messages":[{"role":"system","content":"system instructions"},{"role":"user","content":"first user turn"},{"role":"assistant","content":"","reasoning_content":"hidden reasoning","tool_calls":[{"name":"search","arguments":{"query":"docs"}}]},{"role":"tool","name":"search","tool_call_id":"call-1","content":"tool result"},{"role":"user","content":"latest user turn"}],"stream":false}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(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())
|
|
}
|
|
snapshot, err := historyStore.Snapshot()
|
|
if err != nil {
|
|
t.Fatalf("snapshot failed: %v", err)
|
|
}
|
|
if len(snapshot.Items) != 1 {
|
|
t.Fatalf("expected one history item, got %d", len(snapshot.Items))
|
|
}
|
|
full, err := historyStore.Get(snapshot.Items[0].ID)
|
|
if err != nil {
|
|
t.Fatalf("expected detail item, got %v", err)
|
|
}
|
|
if len(ds.uploadCalls) != 1 {
|
|
t.Fatalf("expected current input upload to happen, got %d", len(ds.uploadCalls))
|
|
}
|
|
if ds.uploadCalls[0].Filename != "DS2API_HISTORY.txt" {
|
|
t.Fatalf("expected DS2API_HISTORY.txt upload, got %q", ds.uploadCalls[0].Filename)
|
|
}
|
|
if full.HistoryText != string(ds.uploadCalls[0].Data) {
|
|
t.Fatalf("expected uploaded current input file to be persisted in history text")
|
|
}
|
|
if len(full.Messages) != 1 {
|
|
t.Fatalf("expected continuation prompt to be the only persisted message, got %#v", full.Messages)
|
|
}
|
|
if !strings.Contains(full.Messages[0].Content, "Continue from the latest state in the attached DS2API_HISTORY.txt context.") {
|
|
t.Fatalf("expected continuation prompt to be persisted, got %#v", full.Messages[0])
|
|
}
|
|
}
|