feat: 新增 thinking 注入配置支持,扩展设置管理与前端交互

新增 promptcompat 和 OpenAI shared 层的 thinking 注入逻辑,
完善配置系统的编解码与校验,更新设置管理 API 与前端 UI。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
CJACK
2026-04-26 13:35:20 +08:00
parent 3627c7366d
commit c09a4b51a5
34 changed files with 1038 additions and 94 deletions

View File

@@ -58,12 +58,23 @@ func TestGetSettingsIncludesHistorySplitDefaults(t *testing.T) {
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 := boolFrom(historySplit["enabled"]); got {
t.Fatalf("expected history_split.enabled=false, 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)
}
currentInputFile, _ := body["current_input_file"].(map[string]any)
if got := boolFrom(currentInputFile["enabled"]); !got {
t.Fatalf("expected current_input_file.enabled=true, body=%v", body)
}
if got := intFrom(currentInputFile["min_chars"]); got != 0 {
t.Fatalf("expected current_input_file.min_chars=0, got %d body=%v", got, body)
}
thinkingInjection, _ := body["thinking_injection"].(map[string]any)
if got := boolFrom(thinkingInjection["enabled"]); !got {
t.Fatalf("expected thinking_injection.enabled=true, body=%v", body)
}
}
func TestUpdateSettingsValidation(t *testing.T) {
@@ -177,7 +188,7 @@ func TestUpdateSettingsHistorySplit(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
payload := map[string]any{
"history_split": map[string]any{
"enabled": false,
"enabled": true,
"trigger_after_turns": 3,
},
}
@@ -190,11 +201,85 @@ func TestUpdateSettingsHistorySplit(t *testing.T) {
}
snap := h.Store.Snapshot()
if snap.HistorySplit.Enabled == nil || !*snap.HistorySplit.Enabled {
t.Fatalf("expected history_split.enabled to be forced true, got %#v", snap.HistorySplit.Enabled)
t.Fatalf("expected history_split.enabled=true, 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)
}
if snap.CurrentInputFile.Enabled == nil || *snap.CurrentInputFile.Enabled {
t.Fatalf("expected history split to disable current_input_file, got %#v", snap.CurrentInputFile.Enabled)
}
}
func TestUpdateSettingsCurrentInputFile(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"],"history_split":{"enabled":true,"trigger_after_turns":2}}`)
payload := map[string]any{
"current_input_file": map[string]any{
"enabled": true,
"min_chars": 12345,
},
}
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.CurrentInputFile.Enabled == nil || !*snap.CurrentInputFile.Enabled {
t.Fatalf("expected current_input_file.enabled=true, got %#v", snap.CurrentInputFile)
}
if snap.CurrentInputFile.MinChars != 12345 {
t.Fatalf("expected current_input_file.min_chars=12345, got %#v", snap.CurrentInputFile)
}
if snap.HistorySplit.Enabled == nil || *snap.HistorySplit.Enabled {
t.Fatalf("expected current input file to disable history_split, got %#v", snap.HistorySplit.Enabled)
}
}
func TestUpdateSettingsRejectsTwoSplitModesEnabled(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
payload := map[string]any{
"history_split": map[string]any{
"enabled": true,
"trigger_after_turns": 3,
},
"current_input_file": map[string]any{
"enabled": true,
"min_chars": 0,
},
}
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.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestUpdateSettingsThinkingInjection(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
payload := map[string]any{
"thinking_injection": map[string]any{
"enabled": false,
},
}
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.ThinkingInjection.Enabled == nil || *snap.ThinkingInjection.Enabled {
t.Fatalf("expected thinking_injection.enabled=false, got %#v", snap.ThinkingInjection.Enabled)
}
if h.Store.ThinkingInjectionEnabled() {
t.Fatal("expected thinking injection accessor to reflect disabled config")
}
}
func TestUpdateSettingsAutoDeleteMode(t *testing.T) {

View File

@@ -21,7 +21,7 @@ func boolFrom(v any) bool {
}
}
func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.CompatConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, *config.HistorySplitConfig, map[string]string, error) {
func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.CompatConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, *config.HistorySplitConfig, *config.CurrentInputFileConfig, *config.ThinkingInjectionConfig, map[string]string, error) {
var (
adminCfg *config.AdminConfig
runtimeCfg *config.RuntimeConfig
@@ -30,6 +30,8 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
embCfg *config.EmbeddingsConfig
autoDeleteCfg *config.AutoDeleteConfig
historySplitCfg *config.HistorySplitConfig
currentInputCfg *config.CurrentInputFileConfig
thinkingInjCfg *config.ThinkingInjectionConfig
aliasMap map[string]string
)
@@ -38,7 +40,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, nil, err
}
cfg.JWTExpireHours = n
}
@@ -50,33 +52,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, 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, 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, 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, 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, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
}
runtimeCfg = cfg
}
@@ -99,7 +101,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, nil, err
}
cfg.StoreTTLSeconds = n
}
@@ -111,7 +113,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, nil, err
}
cfg.Provider = p
}
@@ -137,7 +139,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, nil, err
}
if mode == "" {
mode = "none"
@@ -152,20 +154,71 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if raw, ok := req["history_split"].(map[string]any); ok {
cfg := &config.HistorySplitConfig{}
enabled := true
cfg.Enabled = &enabled
if v, exists := raw["enabled"]; exists {
enabled := boolFrom(v)
cfg.Enabled = &enabled
}
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, err
return nil, 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, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
historySplitCfg = cfg
}
return adminCfg, runtimeCfg, compatCfg, respCfg, embCfg, autoDeleteCfg, historySplitCfg, aliasMap, nil
if raw, ok := req["current_input_file"].(map[string]any); ok {
cfg := &config.CurrentInputFileConfig{}
if v, exists := raw["enabled"]; exists {
enabled := boolFrom(v)
cfg.Enabled = &enabled
}
if v, exists := raw["min_chars"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("current_input_file.min_chars", n, 0, 100000000, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.MinChars = n
}
if err := config.ValidateCurrentInputFileConfig(*cfg); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
currentInputCfg = cfg
}
if boolPtrValue(historySplitCfgEnabled(historySplitCfg)) && boolPtrValue(currentInputCfgEnabled(currentInputCfg)) {
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("history_split.enabled and current_input_file.enabled cannot both be true")
}
if raw, ok := req["thinking_injection"].(map[string]any); ok {
cfg := &config.ThinkingInjectionConfig{}
if v, exists := raw["enabled"]; exists {
b := boolFrom(v)
cfg.Enabled = &b
}
thinkingInjCfg = cfg
}
return adminCfg, runtimeCfg, compatCfg, respCfg, embCfg, autoDeleteCfg, historySplitCfg, currentInputCfg, thinkingInjCfg, aliasMap, nil
}
func historySplitCfgEnabled(cfg *config.HistorySplitConfig) *bool {
if cfg == nil {
return nil
}
return cfg.Enabled
}
func currentInputCfgEnabled(cfg *config.CurrentInputFileConfig) *bool {
if cfg == nil {
return nil
}
return cfg.Enabled
}
func boolPtrValue(v *bool) bool {
return v != nil && *v
}

View File

@@ -34,6 +34,13 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
"enabled": h.Store.HistorySplitEnabled(),
"trigger_after_turns": h.Store.HistorySplitTriggerAfterTurns(),
},
"current_input_file": map[string]any{
"enabled": h.Store.CurrentInputFileEnabled(),
"min_chars": h.Store.CurrentInputFileMinChars(),
},
"thinking_injection": map[string]any{
"enabled": h.Store.ThinkingInjectionEnabled(),
},
"model_aliases": snap.ModelAliases,
"env_backed": h.Store.IsEnvBacked(),
"needs_vercel_sync": needsSync,

View File

@@ -17,7 +17,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
return
}
adminCfg, runtimeCfg, compatCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, historySplitCfg, aliasMap, err := parseSettingsUpdateRequest(req)
adminCfg, runtimeCfg, compatCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, historySplitCfg, currentInputCfg, thinkingInjCfg, aliasMap, err := parseSettingsUpdateRequest(req)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
@@ -70,11 +70,26 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
if historySplitCfg != nil {
if historySplitCfg.Enabled != nil {
c.HistorySplit.Enabled = historySplitCfg.Enabled
if *historySplitCfg.Enabled {
disabled := false
c.CurrentInputFile.Enabled = &disabled
}
}
if historySplitCfg.TriggerAfterTurns != nil {
c.HistorySplit.TriggerAfterTurns = historySplitCfg.TriggerAfterTurns
}
}
if currentInputCfg != nil {
c.CurrentInputFile.Enabled = currentInputCfg.Enabled
if currentInputCfg.Enabled != nil && *currentInputCfg.Enabled {
disabled := false
c.HistorySplit.Enabled = &disabled
}
c.CurrentInputFile.MinChars = currentInputCfg.MinChars
}
if thinkingInjCfg != nil {
c.ThinkingInjection.Enabled = thinkingInjCfg.Enabled
}
if aliasMap != nil {
c.ModelAliases = aliasMap
}

View File

@@ -35,6 +35,9 @@ type ConfigStore interface {
AutoDeleteMode() string
HistorySplitEnabled() bool
HistorySplitTriggerAfterTurns() int
CurrentInputFileEnabled() bool
CurrentInputFileMinChars() int
ThinkingInjectionEnabled() bool
CompatStripReferenceMarkers() bool
AutoDeleteSessions() bool
}

View File

@@ -46,7 +46,16 @@ func (h *Handler) applyHistorySplit(ctx context.Context, a *auth.RequestAuth, st
if h == nil {
return stdReq, nil
}
return history.Service{Store: h.Store, DS: h.DS}.Apply(ctx, a, stdReq)
stdReq = shared.ApplyThinkingInjection(h.Store, stdReq)
svc := history.Service{Store: h.Store, DS: h.DS}
out, err := svc.ApplyCurrentInputFile(ctx, a, stdReq)
if err != nil {
return stdReq, err
}
if out.CurrentInputFileApplied {
return out, nil
}
return svc.Apply(ctx, a, out)
}
func (h *Handler) preprocessInlineFileInputs(ctx context.Context, a *auth.RequestAuth, req map[string]any) error {

View File

@@ -20,6 +20,9 @@ type mockOpenAIConfig struct {
embedProv string
historySplitEnabled bool
historySplitTurns int
currentInputEnabled bool
currentInputMin int
thinkingInjection *bool
}
func (m mockOpenAIConfig) ModelAliases() map[string]string { return m.aliases }
@@ -45,6 +48,16 @@ func (m mockOpenAIConfig) HistorySplitTriggerAfterTurns() int {
}
return m.historySplitTurns
}
func (m mockOpenAIConfig) CurrentInputFileEnabled() bool { return m.currentInputEnabled }
func (m mockOpenAIConfig) CurrentInputFileMinChars() int {
return m.currentInputMin
}
func (m mockOpenAIConfig) ThinkingInjectionEnabled() bool {
if m.thinkingInjection == nil {
return false
}
return *m.thinkingInjection
}
type streamStatusAuthStub struct{}

View File

@@ -16,6 +16,9 @@ type mockOpenAIConfig struct {
embedProv string
historySplitEnabled bool
historySplitTurns int
currentInputEnabled bool
currentInputMin int
thinkingInjection *bool
}
func (m mockOpenAIConfig) ModelAliases() map[string]string { return m.aliases }
@@ -41,6 +44,16 @@ func (m mockOpenAIConfig) HistorySplitTriggerAfterTurns() int {
}
return m.historySplitTurns
}
func (m mockOpenAIConfig) CurrentInputFileEnabled() bool { return m.currentInputEnabled }
func (m mockOpenAIConfig) CurrentInputFileMinChars() int {
return m.currentInputMin
}
func (m mockOpenAIConfig) ThinkingInjectionEnabled() bool {
if m.thinkingInjection == nil {
return false
}
return *m.thinkingInjection
}
func TestNormalizeOpenAIChatRequestWithConfigInterface(t *testing.T) {
cfg := mockOpenAIConfig{

View File

@@ -0,0 +1,94 @@
package history
import (
"context"
"errors"
"fmt"
"strings"
"ds2api/internal/auth"
dsclient "ds2api/internal/deepseek/client"
"ds2api/internal/httpapi/openai/shared"
"ds2api/internal/promptcompat"
)
const (
currentInputFilename = "IGNORE.txt"
currentInputContentType = "text/plain; charset=utf-8"
currentInputPurpose = "assistants"
)
func (s Service) ApplyCurrentInputFile(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
if s.DS == nil || s.Store == nil || a == nil || !s.Store.CurrentInputFileEnabled() {
return stdReq, nil
}
threshold := s.Store.CurrentInputFileMinChars()
index, text := latestUserInputForFile(stdReq.Messages)
if index < 0 {
return stdReq, nil
}
historySplitReached := s.Store.HistorySplitEnabled() && wouldSplitHistory(stdReq.Messages, s.Store.HistorySplitTriggerAfterTurns())
if len([]rune(text)) < threshold && !historySplitReached {
return stdReq, nil
}
fileText := promptcompat.BuildOpenAICurrentInputContextTranscript(stdReq.Messages)
if strings.TrimSpace(fileText) == "" {
return stdReq, errors.New("current user input file produced empty transcript")
}
result, err := s.DS.UploadFile(ctx, a, dsclient.UploadFileRequest{
Filename: currentInputFilename,
ContentType: currentInputContentType,
Purpose: currentInputPurpose,
Data: []byte(fileText),
}, 3)
if err != nil {
return stdReq, fmt.Errorf("upload current user input file: %w", err)
}
fileID := strings.TrimSpace(result.ID)
if fileID == "" {
return stdReq, errors.New("upload current user input file returned empty file id")
}
messages := []any{
map[string]any{
"role": "user",
"content": currentInputFilePrompt(),
},
}
stdReq.Messages = messages
stdReq.CurrentInputFileApplied = true
stdReq.RefFileIDs = prependUniqueRefFileID(stdReq.RefFileIDs, fileID)
stdReq.FinalPrompt, stdReq.ToolNames = promptcompat.BuildOpenAIPrompt(messages, stdReq.ToolsRaw, "", stdReq.ToolChoice, stdReq.Thinking)
return stdReq, nil
}
func latestUserInputForFile(messages []any) (int, string) {
for i := len(messages) - 1; i >= 0; i-- {
msg, ok := messages[i].(map[string]any)
if !ok {
continue
}
role := strings.ToLower(strings.TrimSpace(shared.AsString(msg["role"])))
if role != "user" {
continue
}
text := promptcompat.NormalizeOpenAIContentForPrompt(msg["content"])
if strings.TrimSpace(text) == "" {
return -1, ""
}
return i, text
}
return -1, ""
}
func wouldSplitHistory(messages []any, triggerAfterTurns int) bool {
_, historyMessages := SplitOpenAIHistoryMessages(messages, triggerAfterTurns)
return len(historyMessages) > 0
}
func currentInputFilePrompt() string {
return "The current request and prior conversation context have already been provided. Answer the latest user request directly."
}

View File

@@ -24,7 +24,7 @@ type Service struct {
}
func (s Service) Apply(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
if s.DS == nil || s.Store == nil || a == nil {
if s.DS == nil || s.Store == nil || a == nil || !s.Store.HistorySplitEnabled() {
return stdReq, nil
}

View File

@@ -149,6 +149,189 @@ func TestApplyHistorySplitSkipsFirstTurn(t *testing.T) {
}
}
func TestApplyThinkingInjectionAppendsLatestUserPrompt(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &openAITestSurface{
Store: mockOpenAIConfig{
wideInput: true,
historySplitEnabled: true,
historySplitTurns: 1,
thinkingInjection: boolPtr(true),
},
DS: ds,
}
req := map[string]any{
"model": "deepseek-v4-flash",
"messages": []any{
map[string]any{"role": "user", "content": "hello"},
},
}
stdReq, err := promptcompat.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 thinking injection failed: %v", err)
}
if len(ds.uploadCalls) != 0 {
t.Fatalf("expected no upload for first short turn, got %d", len(ds.uploadCalls))
}
if !strings.Contains(out.FinalPrompt, "hello\n\n"+promptcompat.ThinkingInjectionMarker) {
t.Fatalf("expected thinking injection after latest user message, got %s", out.FinalPrompt)
}
}
func TestApplyHistorySplitDirectPassThroughWhenBothSplitsDisabled(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &openAITestSurface{
Store: mockOpenAIConfig{
wideInput: true,
historySplitEnabled: false,
currentInputEnabled: false,
},
DS: ds,
}
req := map[string]any{
"model": "deepseek-v4-flash",
"messages": historySplitTestMessages(),
}
stdReq, err := promptcompat.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 uploads when both split modes are disabled, got %d", len(ds.uploadCalls))
}
if out.CurrentInputFileApplied || out.HistoryText != "" {
t.Fatalf("expected direct pass-through, got current_input=%v history=%q", out.CurrentInputFileApplied, out.HistoryText)
}
if !strings.Contains(out.FinalPrompt, "first user turn") || !strings.Contains(out.FinalPrompt, "latest user turn") {
t.Fatalf("expected original prompt context to stay inline, got %s", out.FinalPrompt)
}
}
func TestApplyCurrentInputFileUploadsFirstTurnWithInjectedWrapper(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &openAITestSurface{
Store: mockOpenAIConfig{
wideInput: true,
historySplitEnabled: true,
historySplitTurns: 1,
currentInputEnabled: true,
currentInputMin: 10,
thinkingInjection: boolPtr(true),
},
DS: ds,
}
req := map[string]any{
"model": "deepseek-v4-flash",
"messages": []any{
map[string]any{"role": "user", "content": "first turn content that is long enough"},
},
}
stdReq, err := promptcompat.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 current input file failed: %v", err)
}
if len(ds.uploadCalls) != 1 {
t.Fatalf("expected 1 current input upload, got %d", len(ds.uploadCalls))
}
upload := ds.uploadCalls[0]
if upload.Filename != "IGNORE.txt" {
t.Fatalf("unexpected upload filename: %q", upload.Filename)
}
uploadedText := string(upload.Data)
if !strings.HasPrefix(uploadedText, "[file content end]\n\n") {
t.Fatalf("expected injected file wrapper prefix, got %q", uploadedText)
}
if !strings.Contains(uploadedText, "<begin▁of▁sentence><User>first turn content that is long enough") {
t.Fatalf("expected serialized current user turn markers, got %q", uploadedText)
}
if !strings.Contains(uploadedText, promptcompat.ThinkingInjectionMarker) {
t.Fatalf("expected thinking injection in current input file, got %q", uploadedText)
}
if !strings.HasSuffix(uploadedText, "\n[file name]: IGNORE\n[file content begin]\n") {
t.Fatalf("expected injected file wrapper suffix, got %q", uploadedText)
}
if strings.Contains(out.FinalPrompt, "first turn content that is long enough") {
t.Fatalf("expected current input text to be replaced in live prompt, got %s", out.FinalPrompt)
}
if strings.Contains(out.FinalPrompt, "CURRENT_USER_INPUT.txt") || strings.Contains(out.FinalPrompt, "IGNORE.txt") || strings.Contains(out.FinalPrompt, "Read that file") {
t.Fatalf("expected live prompt not to instruct file reads, got %s", out.FinalPrompt)
}
if !strings.Contains(out.FinalPrompt, "Answer the latest user request directly.") {
t.Fatalf("expected neutral continuation instruction in live prompt, got %s", out.FinalPrompt)
}
if len(out.RefFileIDs) != 1 || out.RefFileIDs[0] != "file-inline-1" {
t.Fatalf("expected current input file id in ref_file_ids, got %#v", out.RefFileIDs)
}
}
func TestApplyCurrentInputFileReplacesHistorySplitWithFullContextFile(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &openAITestSurface{
Store: mockOpenAIConfig{
wideInput: true,
historySplitEnabled: true,
historySplitTurns: 1,
currentInputEnabled: true,
currentInputMin: 1000,
thinkingInjection: boolPtr(true),
},
DS: ds,
}
req := map[string]any{
"model": "deepseek-v4-flash",
"messages": historySplitTestMessages(),
}
stdReq, err := promptcompat.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 current input file failed: %v", err)
}
if !out.CurrentInputFileApplied {
t.Fatalf("expected current input file to replace history split")
}
if len(ds.uploadCalls) != 1 {
t.Fatalf("expected one current input upload, got %d", len(ds.uploadCalls))
}
upload := ds.uploadCalls[0]
if upload.Filename != "IGNORE.txt" {
t.Fatalf("expected IGNORE.txt upload, got %q", upload.Filename)
}
uploadedText := string(upload.Data)
for _, want := range []string{"system instructions", "first user turn", "hidden reasoning", "tool result", "latest user turn", promptcompat.ThinkingInjectionMarker} {
if !strings.Contains(uploadedText, want) {
t.Fatalf("expected full context file to contain %q, got %q", want, uploadedText)
}
}
if out.HistoryText != "" {
t.Fatalf("expected no HISTORY transcript when current input file replaces split, got %q", out.HistoryText)
}
if strings.Contains(out.FinalPrompt, "first user turn") || strings.Contains(out.FinalPrompt, "latest user turn") || strings.Contains(out.FinalPrompt, "CURRENT_USER_INPUT.txt") || strings.Contains(out.FinalPrompt, "IGNORE.txt") || strings.Contains(out.FinalPrompt, "Read that file") {
t.Fatalf("expected live prompt to use only a neutral continuation instruction, got %s", out.FinalPrompt)
}
if !strings.Contains(out.FinalPrompt, "Answer the latest user request directly.") {
t.Fatalf("expected neutral continuation instruction in live prompt, got %s", out.FinalPrompt)
}
}
func TestApplyHistorySplitCarriesHistoryText(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &openAITestSurface{
@@ -424,3 +607,7 @@ func TestHistorySplitWorksAcrossAutoDeleteModes(t *testing.T) {
func defaultToolChoicePolicy() promptcompat.ToolChoicePolicy {
return promptcompat.DefaultToolChoicePolicy()
}
func boolPtr(v bool) *bool {
return &v
}

View File

@@ -39,7 +39,16 @@ func (h *Handler) applyHistorySplit(ctx context.Context, a *auth.RequestAuth, st
if h == nil {
return stdReq, nil
}
return history.Service{Store: h.Store, DS: h.DS}.Apply(ctx, a, stdReq)
stdReq = shared.ApplyThinkingInjection(h.Store, stdReq)
svc := history.Service{Store: h.Store, DS: h.DS}
out, err := svc.ApplyCurrentInputFile(ctx, a, stdReq)
if err != nil {
return stdReq, err
}
if out.CurrentInputFileApplied {
return out, nil
}
return svc.Apply(ctx, a, out)
}
func (h *Handler) preprocessInlineFileInputs(ctx context.Context, a *auth.RequestAuth, req map[string]any) error {

View File

@@ -45,6 +45,9 @@ type ConfigReader interface {
AutoDeleteSessions() bool
HistorySplitEnabled() bool
HistorySplitTriggerAfterTurns() int
CurrentInputFileEnabled() bool
CurrentInputFileMinChars() int
ThinkingInjectionEnabled() bool
}
type Deps struct {

View File

@@ -0,0 +1,21 @@
package shared
import "ds2api/internal/promptcompat"
func ApplyThinkingInjection(store ConfigReader, stdReq promptcompat.StandardRequest) promptcompat.StandardRequest {
if store == nil || !store.ThinkingInjectionEnabled() || !stdReq.Thinking {
return stdReq
}
messages, changed := promptcompat.AppendThinkingInjectionToLatestUser(stdReq.Messages)
if !changed {
return stdReq
}
finalPrompt, toolNames := promptcompat.BuildOpenAIPrompt(messages, stdReq.ToolsRaw, "", stdReq.ToolChoice, stdReq.Thinking)
if len(toolNames) == 0 && len(stdReq.ToolNames) > 0 {
toolNames = stdReq.ToolNames
}
stdReq.Messages = messages
stdReq.FinalPrompt = finalPrompt
stdReq.ToolNames = toolNames
return stdReq
}

View File

@@ -84,7 +84,16 @@ func (h *openAITestSurface) ChatCompletions(w http.ResponseWriter, r *http.Reque
}
func (h *openAITestSurface) applyHistorySplit(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
return history.Service{Store: h.Store, DS: h.DS}.Apply(ctx, a, stdReq)
stdReq = shared.ApplyThinkingInjection(h.Store, stdReq)
svc := history.Service{Store: h.Store, DS: h.DS}
out, err := svc.ApplyCurrentInputFile(ctx, a, stdReq)
if err != nil {
return stdReq, err
}
if out.CurrentInputFileApplied {
return out, nil
}
return svc.Apply(ctx, a, out)
}
func (h *openAITestSurface) preprocessInlineFileInputs(ctx context.Context, a *auth.RequestAuth, req map[string]any) error {