feat: add unified response history session management across Claude, Gemini, and OpenAI API backends

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
CJACK
2026-05-03 17:24:38 +08:00
parent 5e55cf36d8
commit c099a6f7bf
28 changed files with 776 additions and 57 deletions

View File

@@ -2,6 +2,7 @@ package gemini
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
@@ -14,8 +15,10 @@ import (
"ds2api/internal/assistantturn"
"ds2api/internal/auth"
"ds2api/internal/completionruntime"
"ds2api/internal/httpapi/openai/history"
"ds2api/internal/httpapi/requestbody"
"ds2api/internal/promptcompat"
"ds2api/internal/responsehistory"
"ds2api/internal/sse"
"ds2api/internal/toolcall"
"ds2api/internal/translatorcliproxy"
@@ -76,8 +79,21 @@ func (h *Handler) handleGeminiDirect(w http.ResponseWriter, r *http.Request, str
return true
}
defer h.Auth.Release(a)
stdReq, err = h.applyCurrentInputFile(r.Context(), a, stdReq)
if err != nil {
status, message := mapCurrentInputFileError(err)
writeGeminiError(w, status, message)
return true
}
historySession := responsehistory.Start(responsehistory.StartParams{
Store: h.ChatHistory,
Request: r,
Auth: a,
Surface: "gemini.generate_content",
Standard: stdReq,
})
if stream {
h.handleGeminiDirectStream(w, r, a, stdReq)
h.handleGeminiDirectStream(w, r, a, stdReq, historySession)
return true
}
result, outErr := completionruntime.ExecuteNonStreamWithRetry(r.Context(), h.DS, a, stdReq, completionruntime.Options{
@@ -86,23 +102,43 @@ func (h *Handler) handleGeminiDirect(w http.ResponseWriter, r *http.Request, str
CurrentInputFile: h.Store,
})
if outErr != nil {
if historySession != nil {
historySession.ErrorTurn(outErr.Status, outErr.Message, outErr.Code, result.Turn)
}
writeGeminiError(w, outErr.Status, outErr.Message)
return true
}
if historySession != nil {
historySession.SuccessTurn(http.StatusOK, result.Turn, responsehistory.GenericUsage(result.Turn))
}
writeJSON(w, http.StatusOK, buildGeminiGenerateContentResponseFromTurn(result.Turn))
return true
}
func (h *Handler) handleGeminiDirectStream(w http.ResponseWriter, r *http.Request, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) {
func (h *Handler) applyCurrentInputFile(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
if h == nil {
return stdReq, nil
}
return (history.Service{Store: h.Store, DS: h.DS}).ApplyCurrentInputFile(ctx, a, stdReq)
}
func mapCurrentInputFileError(err error) (int, string) {
return history.MapError(err)
}
func (h *Handler) handleGeminiDirectStream(w http.ResponseWriter, r *http.Request, a *auth.RequestAuth, stdReq promptcompat.StandardRequest, historySession *responsehistory.Session) {
start, outErr := completionruntime.StartCompletion(r.Context(), h.DS, a, stdReq, completionruntime.Options{
CurrentInputFile: h.Store,
})
if outErr != nil {
if historySession != nil {
historySession.Error(outErr.Status, outErr.Message, outErr.Code, "", "")
}
writeGeminiError(w, outErr.Status, outErr.Message)
return
}
streamReq := start.Request
h.handleStreamGenerateContent(w, r, start.Response, streamReq.ResponseModel, streamReq.PromptTokenText, streamReq.Thinking, streamReq.Search, streamReq.ToolNames, streamReq.ToolsRaw)
h.handleStreamGenerateContent(w, r, start.Response, streamReq.ResponseModel, streamReq.PromptTokenText, streamReq.Thinking, streamReq.Search, streamReq.ToolNames, streamReq.ToolsRaw, historySession)
}
func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, stream bool) bool {

View File

@@ -5,6 +5,7 @@ import (
"github.com/go-chi/chi/v5"
"ds2api/internal/chathistory"
"ds2api/internal/textclean"
"ds2api/internal/util"
)
@@ -12,10 +13,11 @@ import (
var writeJSON = util.WriteJSON
type Handler struct {
Store ConfigReader
Auth AuthResolver
DS DeepSeekCaller
OpenAI OpenAIChatRunner
Store ConfigReader
Auth AuthResolver
DS DeepSeekCaller
OpenAI OpenAIChatRunner
ChatHistory *chathistory.Store
}
//nolint:unused // used by native Gemini stream/non-stream runtime helpers.

View File

@@ -9,15 +9,23 @@ import (
"ds2api/internal/assistantturn"
dsprotocol "ds2api/internal/deepseek/protocol"
"ds2api/internal/responsehistory"
"ds2api/internal/sse"
streamengine "ds2api/internal/stream"
)
//nolint:unused // retained for native Gemini stream handling path.
func (h *Handler) handleStreamGenerateContent(w http.ResponseWriter, r *http.Request, resp *http.Response, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any) {
func (h *Handler) handleStreamGenerateContent(w http.ResponseWriter, r *http.Request, resp *http.Response, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any, historySessions ...*responsehistory.Session) {
var historySession *responsehistory.Session
if len(historySessions) > 0 {
historySession = historySessions[0]
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
if historySession != nil {
historySession.Error(resp.StatusCode, strings.TrimSpace(string(body)), "error", "", "")
}
writeGeminiError(w, resp.StatusCode, strings.TrimSpace(string(body)))
return
}
@@ -29,7 +37,7 @@ func (h *Handler) handleStreamGenerateContent(w http.ResponseWriter, r *http.Req
rc := http.NewResponseController(w)
_, canFlush := w.(http.Flusher)
runtime := newGeminiStreamRuntime(w, rc, canFlush, model, finalPrompt, thinkingEnabled, searchEnabled, stripReferenceMarkersEnabled(), toolNames, toolsRaw)
runtime := newGeminiStreamRuntime(w, rc, canFlush, model, finalPrompt, thinkingEnabled, searchEnabled, stripReferenceMarkersEnabled(), toolNames, toolsRaw, historySession)
initialType := "text"
if thinkingEnabled {
@@ -70,6 +78,7 @@ type geminiStreamRuntime struct {
accumulator *assistantturn.Accumulator
contentFilter bool
responseMessageID int
history *responsehistory.Session
}
//nolint:unused // retained for native Gemini stream handling path.
@@ -84,6 +93,7 @@ func newGeminiStreamRuntime(
stripReferenceMarkers bool,
toolNames []string,
toolsRaw any,
history *responsehistory.Session,
) *geminiStreamRuntime {
return &geminiStreamRuntime{
w: w,
@@ -97,6 +107,7 @@ func newGeminiStreamRuntime(
stripReferenceMarkers: stripReferenceMarkers,
toolNames: toolNames,
toolsRaw: toolsRaw,
history: history,
accumulator: assistantturn.NewAccumulator(assistantturn.AccumulatorOptions{
ThinkingEnabled: thinkingEnabled,
SearchEnabled: searchEnabled,
@@ -170,6 +181,13 @@ func (s *geminiStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse
"modelVersion": s.model,
})
}
if s.history != nil {
rawText, text, rawThinking, thinking, detectionThinking := s.accumulator.Snapshot()
s.history.Progress(
responsehistory.ThinkingForArchive(rawThinking, detectionThinking, thinking),
responsehistory.TextForArchive(rawText, text),
)
}
return streamengine.ParsedDecision{ContentSeen: accumulated.ContentSeen}
}
@@ -193,6 +211,15 @@ func (s *geminiStreamRuntime) finalize() {
ToolsRaw: s.toolsRaw,
})
outcome := assistantturn.FinalizeTurn(turn, assistantturn.FinalizeOptions{})
if s.history != nil {
s.history.Success(
http.StatusOK,
responsehistory.ThinkingForArchive(turn.RawThinking, turn.DetectionThinking, turn.Thinking),
responsehistory.TextForArchive(turn.RawText, turn.Text),
assistantturn.FinishReason(turn),
responsehistory.GenericUsage(turn),
)
}
if s.bufferContent {
parts := buildGeminiPartsFromTurn(turn)

View File

@@ -7,12 +7,14 @@ import (
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"ds2api/internal/auth"
"ds2api/internal/chathistory"
dsclient "ds2api/internal/deepseek/client"
)
@@ -138,10 +140,12 @@ func TestGeminiDirectAppliesCurrentInputFile(t *testing.T) {
ds := &testGeminiDS{
resp: makeGeminiUpstreamResponse(`data: {"p":"response/content","v":"ok"}`),
}
historyStore := chathistory.New(filepath.Join(t.TempDir(), "history.json"))
h := &Handler{
Store: testGeminiConfig{},
Auth: testGeminiAuth{},
DS: ds,
Store: testGeminiConfig{},
Auth: testGeminiAuth{},
DS: ds,
ChatHistory: historyStore,
}
reqBody := `{"contents":[{"role":"user","parts":[{"text":"hello from gemini"}]}]}`
req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-pro:generateContent", strings.NewReader(reqBody))
@@ -172,6 +176,29 @@ func TestGeminiDirectAppliesCurrentInputFile(t *testing.T) {
if !strings.Contains(prompt, "Continue from the latest state in the attached DS2API_HISTORY.txt context.") {
t.Fatalf("expected continuation prompt, got %q", prompt)
}
snapshot, err := historyStore.Snapshot()
if err != nil {
t.Fatalf("snapshot history: %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 history item: %v", err)
}
if full.Surface != "gemini.generate_content" {
t.Fatalf("unexpected surface: %q", full.Surface)
}
if full.Content != "ok" {
t.Fatalf("expected raw upstream content, got %q", full.Content)
}
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 || !strings.Contains(full.Messages[0].Content, "Continue from the latest state in the attached DS2API_HISTORY.txt context.") {
t.Fatalf("expected persisted message to match upstream continuation prompt, got %#v", full.Messages)
}
}
func TestGeminiRoutesRegistered(t *testing.T) {