From 5f110e6910b7097ce9276c1a0fe988a011b1216c Mon Sep 17 00:00:00 2001 From: CJACK Date: Sun, 3 May 2026 01:50:50 +0800 Subject: [PATCH] refactor: remove legacy history split configuration and integrate current input file handling into the completion runtime pipeline. --- API.en.md | 3 +- API.md | 3 +- README.MD | 3 +- README.en.md | 3 +- docs/prompt-compatibility.md | 18 ++-- internal/completionruntime/nonstream.go | 31 ++++++- internal/completionruntime/nonstream_test.go | 54 +++++++++++ internal/config/codec.go | 19 +--- internal/config/config.go | 6 -- internal/config/config_edge_test.go | 46 ++++------ internal/config/store_accessors.go | 8 -- internal/config/store_accessors_test.go | 21 ----- .../httpapi/admin/handler_settings_test.go | 3 - internal/httpapi/admin/shared/deps.go | 2 - .../httpapi/claude/current_input_file_test.go | 85 ++++++++++++++++++ internal/httpapi/claude/deps.go | 3 + .../httpapi/claude/deps_injection_test.go | 2 + internal/httpapi/claude/handler_messages.go | 8 +- internal/httpapi/claude/proxy_vercel_test.go | 2 + internal/httpapi/claude/stream_status_test.go | 2 + internal/httpapi/gemini/deps.go | 3 + internal/httpapi/gemini/handler_generate.go | 8 +- internal/httpapi/gemini/handler_test.go | 62 +++++++++++-- internal/httpapi/openai/chat/handler_chat.go | 10 ++- .../httpapi/openai/chat/test_helpers_test.go | 11 +-- .../httpapi/openai/deps_injection_test.go | 11 +-- .../openai/history/current_input_file.go | 33 ++++++- .../httpapi/openai/history/history_split.go | 90 ------------------- internal/httpapi/openai/history_split_test.go | 48 ++-------- .../openai/responses/responses_handler.go | 10 ++- internal/httpapi/openai/shared/deps.go | 2 - internal/httpapi/openai/test_bridge_test.go | 4 - ...ection.jsx => CurrentInputFileSection.jsx} | 0 .../features/settings/SettingsContainer.jsx | 2 +- 34 files changed, 335 insertions(+), 281 deletions(-) create mode 100644 internal/httpapi/claude/current_input_file_test.go delete mode 100644 internal/httpapi/openai/history/history_split.go rename webui/src/features/settings/{HistorySplitSection.jsx => CurrentInputFileSection.jsx} (100%) diff --git a/API.en.md b/API.en.md index b207119..becb765 100644 --- a/API.en.md +++ b/API.en.md @@ -753,7 +753,6 @@ Hot-updates runtime settings. Supported fields: - `auto_delete.mode` - `current_input_file.enabled` / `current_input_file.min_chars` - `model_aliases` -- `history_split` is retained only for legacy config compatibility and no longer affects requests - `toolcall` policy is fixed and is no longer writable through settings ### `POST /admin/settings/password` @@ -777,7 +776,7 @@ Imports full config with: The request can send config directly, or wrapped as `{"config": {...}, "mode":"merge"}`. Query params `?mode=merge` / `?mode=replace` are also supported. -`replace` mode replaces the full config shape while preserving Vercel sync metadata. `merge` mode merges `keys`, `api_keys`, `accounts`, and `model_aliases`, and overwrites non-empty fields under `admin`, `runtime`, `responses`, and `embeddings`. Manage `compat`, `auto_delete`, and `current_input_file` via `/admin/settings` or the config file; `history_split` remains only for legacy compatibility; legacy `toolcall` fields are ignored. +`replace` mode replaces the full config shape while preserving Vercel sync metadata. `merge` mode merges `keys`, `api_keys`, `accounts`, and `model_aliases`, and overwrites non-empty fields under `admin`, `runtime`, `responses`, and `embeddings`. Manage `compat`, `auto_delete`, and `current_input_file` via `/admin/settings` or the config file; legacy `toolcall` fields are ignored. > Note: `merge` mode does not update `compat`, `auto_delete`, or `current_input_file`. diff --git a/API.md b/API.md index 0af8a2f..46f55e3 100644 --- a/API.md +++ b/API.md @@ -760,7 +760,6 @@ data: {"type":"message_stop"} - `auto_delete.mode` - `current_input_file.enabled` / `current_input_file.min_chars` - `model_aliases` -- `history_split` 仅作为旧配置兼容字段保留,不再影响请求处理 - `toolcall` 策略已固定,不再作为可写入字段 ### `POST /admin/settings/password` @@ -784,7 +783,7 @@ data: {"type":"message_stop"} 请求可直接传配置对象,或使用 `{"config": {...}, "mode":"merge"}` 包裹格式。 也支持在查询参数里传 `?mode=merge` / `?mode=replace`。 -`replace` 模式会按完整配置结构替换(保留 Vercel 同步元信息);`merge` 模式会合并 `keys`、`api_keys`、`accounts`、`model_aliases`,并覆盖 `admin`、`runtime`、`responses`、`embeddings` 中的非空字段。`compat`、`auto_delete`、`current_input_file` 建议通过 `/admin/settings` 或配置文件管理;`history_split` 仅保留为旧配置兼容字段;`toolcall` 相关字段会被忽略。 +`replace` 模式会按完整配置结构替换(保留 Vercel 同步元信息);`merge` 模式会合并 `keys`、`api_keys`、`accounts`、`model_aliases`,并覆盖 `admin`、`runtime`、`responses`、`embeddings` 中的非空字段。`compat`、`auto_delete`、`current_input_file` 建议通过 `/admin/settings` 或配置文件管理;`toolcall` 相关字段会被忽略。 > 注意:`merge` 模式不会更新 `compat`、`auto_delete`、`current_input_file`。 diff --git a/README.MD b/README.MD index 07508fd..2ef0143 100644 --- a/README.MD +++ b/README.MD @@ -321,8 +321,7 @@ go run ./cmd/ds2api - `model_aliases`:OpenAI / Claude / Gemini 共用的模型 alias 映射。 - `runtime`:账号并发、队列与 token 刷新策略,可通过 Admin Settings 热更新。 - `auto_delete.mode`:请求结束后的远端会话清理策略,支持 `none` / `single` / `all`。 -- `history_split`:旧轮次拆分字段,已废弃并忽略,仅保留兼容旧配置。 -- `current_input_file`:唯一生效的独立拆分策略;默认开启且阈值为 `0`,触发时将完整上下文合并上传为 `DS2API_HISTORY.txt` 上下文文件。 +- `current_input_file`:全局生效的上下文拆分上传策略;默认开启且阈值为 `0`,触发时将完整上下文合并上传为 `DS2API_HISTORY.txt` 上下文文件。 - 如果关闭 `current_input_file`,请求会直接透传,不上传拆分上下文文件。 - `thinking_injection`:默认开启;在最新 user 消息末尾追加思考增强提示词,提高高强度推理与工具调用前的思考稳定性;`prompt` 留空时使用内置默认提示词。 diff --git a/README.en.md b/README.en.md index 8bbedce..f7781ce 100644 --- a/README.en.md +++ b/README.en.md @@ -309,8 +309,7 @@ Common fields: - `model_aliases`: one shared alias map for OpenAI / Claude / Gemini model names. - `runtime`: account concurrency, queueing, and token refresh behavior, hot-reloadable via Admin Settings. - `auto_delete.mode`: remote session cleanup after each request, supporting `none` / `single` / `all`. -- `history_split`: legacy multi-turn history split field, now ignored and kept only for backward-compatible config loading. -- `current_input_file`: the only active split mode; it is enabled by default and uploads the full context as a `DS2API_HISTORY.txt` context file once the character threshold is reached. +- `current_input_file`: the global context split/upload mode; it is enabled by default and uploads the full context as a `DS2API_HISTORY.txt` context file once the character threshold is reached. - If you turn off `current_input_file`, requests pass through directly without uploading any split context file. For the full environment variable list, see [docs/DEPLOY.en.md](docs/DEPLOY.en.md). For auth behavior, see [API.en.md](API.en.md#authentication). diff --git a/docs/prompt-compatibility.md b/docs/prompt-compatibility.md index bb173c1..2d6b4be 100644 --- a/docs/prompt-compatibility.md +++ b/docs/prompt-compatibility.md @@ -3,7 +3,7 @@ 文档导航:[总览](../README.MD) / [架构说明](./ARCHITECTURE.md) / [接口文档](../API.md) / [测试指南](./TESTING.md) > 本文档是 DS2API“把 OpenAI / Claude / Gemini 风格 API 请求兼容成 DeepSeek 网页对话纯文本上下文”的专项说明。 -> 这是项目最重要的兼容产物之一。凡是修改消息标准化、tool prompt 注入、tool history 保留、文件引用、current input file / legacy history_split、下游 completion payload 组装等行为,都必须同步更新本文档。 +> 这是项目最重要的兼容产物之一。凡是修改消息标准化、tool prompt 注入、tool history 保留、文件引用、current input file、下游 completion payload 组装等行为,都必须同步更新本文档。 ## 1. 核心结论 @@ -267,11 +267,10 @@ OpenAI 的文件上传现在不再是“只传文件本体”的通用路径, ## 9. 多轮历史为什么不会一直完整内联在 prompt -兼容层现在只保留 `current_input_file` 这一种拆分方式;旧的 `history_split` 已废弃,只保留为兼容旧配置的字段,不再参与请求处理。 +兼容层现在只保留 `current_input_file` 这一种拆分方式;旧的 `history_split` 配置字段已移除,读取旧配置时会忽略它且不会再写回。 -- `current_input_file` 默认开启;它用于把“完整上下文”合并进 `DS2API_HISTORY.txt` 上下文文件。当最新 user turn 的纯文本长度达到 `current_input_file.min_chars`(默认 `0`)时,兼容层会上传一个文件名为 `DS2API_HISTORY.txt` 的上下文文件。文件内容会先做 OpenAI 消息标准化,再序列化成按轮次编号的 `DS2API_HISTORY.txt` 风格 transcript,带有 `# DS2API_HISTORY.txt` 标题和 `=== N. ROLE ===` 分段;live prompt 中则会给出一个 continuation 语气的 user 消息,引导模型从 `DS2API_HISTORY.txt` 的最新状态继续推进,并直接回答最新请求,避免把任务拉回起点。 +- `current_input_file` 默认开启;它在统一 completion runtime 入口全局生效,用于把“完整上下文”合并进 `DS2API_HISTORY.txt` 上下文文件。当最新 user turn 的纯文本长度达到 `current_input_file.min_chars`(默认 `0`)时,runtime 会上传一个文件名为 `DS2API_HISTORY.txt` 的上下文文件。文件内容会先经过各协议入口的标准化,再序列化成按轮次编号的 `DS2API_HISTORY.txt` 风格 transcript,带有 `# DS2API_HISTORY.txt` 标题和 `=== N. ROLE ===` 分段;live prompt 中则会给出一个 continuation 语气的 user 消息,引导模型从 `DS2API_HISTORY.txt` 的最新状态继续推进,并直接回答最新请求,避免把任务拉回起点。 - 如果 `current_input_file.enabled=false`,请求会直接透传,不上传任何拆分上下文文件。 -- 旧的 `history_split.enabled` / `history_split.trigger_after_turns` 会被读取进配置对象以保持兼容,但不会触发拆分上传,也不会影响 `current_input_file` 的默认开启。 - 即使触发 `current_input_file` 后 live prompt 被缩短,对客户端回包里的上下文 token 统计,仍会沿用**拆分前的完整 prompt 语义**做计数,而不是按缩短后的占位 prompt 计算;否则会把真实上下文显著算小。 相关实现: @@ -280,8 +279,8 @@ OpenAI 的文件上传现在不再是“只传文件本体”的通用路径, [internal/config/store_accessors.go](../internal/config/store_accessors.go) - 当前输入转文件: [internal/httpapi/openai/history/current_input_file.go](../internal/httpapi/openai/history/current_input_file.go) -- 旧历史拆分兼容壳: - [internal/httpapi/openai/history/history_split.go](../internal/httpapi/openai/history/history_split.go) +- 全局 completion runtime 应用点: + [internal/completionruntime/nonstream.go](../internal/completionruntime/nonstream.go) 当前输入转文件启用并触发时,上传文件的真实文件名是 `DS2API_HISTORY.txt`,文件内容是完整 `messages` 上下文;它仍会先用 OpenAI 消息标准化和 DeepSeek 角色标记序列化,再按轮次编号成 `DS2API_HISTORY.txt` 风格的 transcript(不再注入文件边界标签): @@ -315,7 +314,7 @@ Prior conversation history and tool progress. - Responses `instructions` 会 prepend 为 system message - `tools` 会注入 system prompt - `attachments` / `input_file` / inline 文件会进入 `ref_file_ids` -- current input file 主要在这条链路里生效,旧 `history_split` 仅作兼容字段保留 +- current input file 在统一 completion runtime 入口全局生效 ### 10.2 Claude Messages @@ -381,7 +380,7 @@ Prior conversation history and tool progress. - tool prompt 模板或 tool_choice 约束变更 - inline 文件上传 / 文件引用收集规则变更 - current input file 触发条件、上传格式、`DS2API_HISTORY.txt` transcript 结构变更 -- 旧 `history_split` 兼容逻辑的读取、忽略或退化行为变更 +- 旧 `history_split` 字段忽略/清理行为变更 - completion payload 字段语义变更 - Claude / Gemini 对这套统一语义的复用关系变更 @@ -393,7 +392,8 @@ Prior conversation history and tool progress. - `internal/promptcompat/tool_prompt.go` - `internal/httpapi/openai/files/file_inline_upload.go` - `internal/promptcompat/file_refs.go` -- `internal/httpapi/openai/history/history_split.go` +- `internal/httpapi/openai/history/current_input_file.go` +- `internal/completionruntime/nonstream.go` - `internal/promptcompat/responses_input_normalize.go` - `internal/httpapi/claude/standard_request.go` - `internal/httpapi/claude/handler_utils.go` diff --git a/internal/completionruntime/nonstream.go b/internal/completionruntime/nonstream.go index 1b32969..83709ca 100644 --- a/internal/completionruntime/nonstream.go +++ b/internal/completionruntime/nonstream.go @@ -10,6 +10,8 @@ import ( "ds2api/internal/assistantturn" "ds2api/internal/auth" "ds2api/internal/config" + dsclient "ds2api/internal/deepseek/client" + "ds2api/internal/httpapi/openai/history" "ds2api/internal/httpapi/openai/shared" "ds2api/internal/promptcompat" "ds2api/internal/sse" @@ -18,6 +20,7 @@ import ( type DeepSeekCaller interface { CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) + UploadFile(ctx context.Context, a *auth.RequestAuth, req dsclient.UploadFileRequest, maxAttempts int) (*dsclient.UploadFileResult, error) CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error) } @@ -26,6 +29,7 @@ type Options struct { MaxAttempts int RetryEnabled bool RetryMaxAttempts int + CurrentInputFile history.CurrentInputConfigReader } type NonStreamResult struct { @@ -40,6 +44,7 @@ type StartResult struct { Payload map[string]any Pow string Response *http.Response + Request promptcompat.StandardRequest } func StartCompletion(ctx context.Context, ds DeepSeekCaller, a *auth.RequestAuth, stdReq promptcompat.StandardRequest, opts Options) (StartResult, *assistantturn.OutputError) { @@ -47,20 +52,37 @@ func StartCompletion(ctx context.Context, ds DeepSeekCaller, a *auth.RequestAuth if maxAttempts <= 0 { maxAttempts = 3 } + var prepErr *assistantturn.OutputError + stdReq, prepErr = prepareCurrentInputFile(ctx, ds, a, stdReq, opts) + if prepErr != nil { + return StartResult{Request: stdReq}, prepErr + } sessionID, err := ds.CreateSession(ctx, a, maxAttempts) if err != nil { - return StartResult{}, authOutputError(a) + return StartResult{Request: stdReq}, authOutputError(a) } pow, err := ds.GetPow(ctx, a, maxAttempts) if err != nil { - return StartResult{SessionID: sessionID}, &assistantturn.OutputError{Status: http.StatusUnauthorized, Message: "Failed to get PoW (invalid token or unknown error).", Code: "error"} + return StartResult{SessionID: sessionID, Request: stdReq}, &assistantturn.OutputError{Status: http.StatusUnauthorized, Message: "Failed to get PoW (invalid token or unknown error).", Code: "error"} } payload := stdReq.CompletionPayload(sessionID) resp, err := ds.CallCompletion(ctx, a, payload, pow, maxAttempts) if err != nil { - return StartResult{SessionID: sessionID, Payload: payload, Pow: pow}, &assistantturn.OutputError{Status: http.StatusInternalServerError, Message: "Failed to get completion.", Code: "error"} + return StartResult{SessionID: sessionID, Payload: payload, Pow: pow, Request: stdReq}, &assistantturn.OutputError{Status: http.StatusInternalServerError, Message: "Failed to get completion.", Code: "error"} } - return StartResult{SessionID: sessionID, Payload: payload, Pow: pow, Response: resp}, nil + return StartResult{SessionID: sessionID, Payload: payload, Pow: pow, Response: resp, Request: stdReq}, nil +} + +func prepareCurrentInputFile(ctx context.Context, ds DeepSeekCaller, a *auth.RequestAuth, stdReq promptcompat.StandardRequest, opts Options) (promptcompat.StandardRequest, *assistantturn.OutputError) { + if opts.CurrentInputFile == nil || stdReq.CurrentInputFileApplied { + return stdReq, nil + } + out, err := (history.Service{Store: opts.CurrentInputFile, DS: ds}).ApplyCurrentInputFile(ctx, a, stdReq) + if err != nil { + status, message := history.MapError(err) + return out, &assistantturn.OutputError{Status: status, Message: message, Code: "error"} + } + return out, nil } func ExecuteNonStreamWithRetry(ctx context.Context, ds DeepSeekCaller, a *auth.RequestAuth, stdReq promptcompat.StandardRequest, opts Options) (NonStreamResult, *assistantturn.OutputError) { @@ -68,6 +90,7 @@ func ExecuteNonStreamWithRetry(ctx context.Context, ds DeepSeekCaller, a *auth.R if startErr != nil { return NonStreamResult{SessionID: start.SessionID, Payload: start.Payload}, startErr } + stdReq = start.Request maxAttempts := opts.MaxAttempts if maxAttempts <= 0 { maxAttempts = 3 diff --git a/internal/completionruntime/nonstream_test.go b/internal/completionruntime/nonstream_test.go index 1428fca..56e1d1e 100644 --- a/internal/completionruntime/nonstream_test.go +++ b/internal/completionruntime/nonstream_test.go @@ -8,14 +8,21 @@ import ( "testing" "ds2api/internal/auth" + dsclient "ds2api/internal/deepseek/client" "ds2api/internal/promptcompat" ) type fakeDeepSeekCaller struct { responses []*http.Response payloads []map[string]any + uploads []dsclient.UploadFileRequest } +type currentInputRuntimeConfig struct{} + +func (currentInputRuntimeConfig) CurrentInputFileEnabled() bool { return true } +func (currentInputRuntimeConfig) CurrentInputFileMinChars() int { return 0 } + func (f *fakeDeepSeekCaller) CreateSession(context.Context, *auth.RequestAuth, int) (string, error) { return "session-1", nil } @@ -24,6 +31,11 @@ func (f *fakeDeepSeekCaller) GetPow(context.Context, *auth.RequestAuth, int) (st return "pow", nil } +func (f *fakeDeepSeekCaller) UploadFile(_ context.Context, _ *auth.RequestAuth, req dsclient.UploadFileRequest, _ int) (*dsclient.UploadFileResult, error) { + f.uploads = append(f.uploads, req) + return &dsclient.UploadFileResult{ID: "file-runtime-1"}, nil +} + func (f *fakeDeepSeekCaller) CallCompletion(_ context.Context, _ *auth.RequestAuth, payload map[string]any, _ string, _ int) (*http.Response, error) { f.payloads = append(f.payloads, payload) if len(f.responses) == 0 { @@ -107,6 +119,48 @@ func TestExecuteNonStreamWithRetryUsesParentMessageForEmptyRetry(t *testing.T) { } } +func TestStartCompletionAppliesCurrentInputFileGlobally(t *testing.T) { + ds := &fakeDeepSeekCaller{responses: []*http.Response{sseHTTPResponse(http.StatusOK, `data: {"p":"response/content","v":"ok"}`)}} + stdReq := promptcompat.StandardRequest{ + Surface: "test_adapter", + RequestedModel: "deepseek-v4-flash", + ResolvedModel: "deepseek-v4-flash", + ResponseModel: "deepseek-v4-flash", + PromptTokenText: "first user turn", + FinalPrompt: "first user turn", + Messages: []any{ + map[string]any{"role": "user", "content": "first user turn"}, + }, + } + + start, outErr := StartCompletion(context.Background(), ds, &auth.RequestAuth{DeepSeekToken: "token"}, stdReq, Options{ + CurrentInputFile: currentInputRuntimeConfig{}, + }) + if outErr != nil { + t.Fatalf("unexpected output error: %#v", outErr) + } + if len(ds.uploads) != 1 { + t.Fatalf("expected current input upload, got %d", len(ds.uploads)) + } + if got := ds.uploads[0].Filename; got != "DS2API_HISTORY.txt" { + t.Fatalf("upload filename=%q want DS2API_HISTORY.txt", got) + } + if len(ds.payloads) != 1 { + t.Fatalf("expected one completion payload, got %d", len(ds.payloads)) + } + refIDs, _ := ds.payloads[0]["ref_file_ids"].([]any) + if len(refIDs) != 1 || refIDs[0] != "file-runtime-1" { + t.Fatalf("expected uploaded file id in ref_file_ids, got %#v", ds.payloads[0]["ref_file_ids"]) + } + prompt, _ := ds.payloads[0]["prompt"].(string) + if !strings.Contains(prompt, "Continue from the latest state in the attached DS2API_HISTORY.txt context.") { + t.Fatalf("expected continuation prompt, got %q", prompt) + } + if !start.Request.CurrentInputFileApplied || !strings.Contains(start.Request.PromptTokenText, "# DS2API_HISTORY.txt") { + t.Fatalf("expected prepared request to carry current input file state, got %#v", start.Request) + } +} + func sseHTTPResponse(status int, lines ...string) *http.Response { body := strings.Join(lines, "\n") if !strings.HasSuffix(body, "\n") { diff --git a/internal/config/codec.go b/internal/config/codec.go index 1cf078b..ac9086d 100644 --- a/internal/config/codec.go +++ b/internal/config/codec.go @@ -45,9 +45,6 @@ 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.CurrentInputFile.Enabled != nil || c.CurrentInputFile.MinChars != 0 { m["current_input_file"] = c.CurrentInputFile } @@ -121,9 +118,7 @@ func (c *Config) UnmarshalJSON(b []byte) error { 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) - } + // Removed legacy split field is ignored instead of persisted. case "current_input_file": if err := json.Unmarshal(v, &c.CurrentInputFile); err != nil { return fmt.Errorf("invalid field %q: %w", k, err) @@ -167,10 +162,6 @@ func (c Config) Clone() Config { Responses: c.Responses, Embeddings: c.Embeddings, AutoDelete: c.AutoDelete, - HistorySplit: HistorySplitConfig{ - Enabled: cloneBoolPtr(c.HistorySplit.Enabled), - TriggerAfterTurns: cloneIntPtr(c.HistorySplit.TriggerAfterTurns), - }, CurrentInputFile: CurrentInputFileConfig{ Enabled: cloneBoolPtr(c.CurrentInputFile.Enabled), MinChars: c.CurrentInputFile.MinChars, @@ -208,14 +199,6 @@ 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 cd0ae1c..8754197 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,7 +19,6 @@ type Config struct { Responses ResponsesConfig `json:"responses,omitempty"` Embeddings EmbeddingsConfig `json:"embeddings,omitempty"` AutoDelete AutoDeleteConfig `json:"auto_delete"` - HistorySplit HistorySplitConfig `json:"history_split"` CurrentInputFile CurrentInputFileConfig `json:"current_input_file,omitempty"` ThinkingInjection ThinkingInjectionConfig `json:"thinking_injection,omitempty"` VercelSyncHash string `json:"_vercel_sync_hash,omitempty"` @@ -173,11 +172,6 @@ type AutoDeleteConfig struct { Sessions bool `json:"sessions,omitempty"` } -type HistorySplitConfig struct { - Enabled *bool `json:"enabled,omitempty"` - TriggerAfterTurns *int `json:"trigger_after_turns,omitempty"` -} - type CurrentInputFileConfig struct { Enabled *bool `json:"enabled,omitempty"` MinChars int `json:"min_chars,omitempty"` diff --git a/internal/config/config_edge_test.go b/internal/config/config_edge_test.go index 41f3dd9..88cf740 100644 --- a/internal/config/config_edge_test.go +++ b/internal/config/config_edge_test.go @@ -172,10 +172,6 @@ func TestConfigJSONRoundtrip(t *testing.T) { AutoDelete: AutoDeleteConfig{ Mode: "single", }, - HistorySplit: HistorySplitConfig{ - Enabled: &trueVal, - TriggerAfterTurns: func() *int { v := 2; return &v }(), - }, Runtime: RuntimeConfig{ TokenRefreshIntervalHours: 12, }, @@ -215,12 +211,6 @@ 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) } @@ -290,12 +280,28 @@ func TestConfigUnmarshalJSONIgnoresRemovedLegacyModelMappings(t *testing.T) { } } +func TestConfigUnmarshalJSONIgnoresRemovedHistorySplit(t *testing.T) { + raw := `{"keys":["k1"],"history_split":{"enabled":true,"trigger_after_turns":2}}` + var cfg Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if _, ok := cfg.AdditionalFields["history_split"]; ok { + t.Fatalf("expected removed legacy field not to persist in additional fields: %#v", cfg.AdditionalFields) + } + out, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + if strings.Contains(string(out), "history_split") { + t.Fatalf("expected removed history_split field not to marshal, got %s", out) + } +} + // ─── Config.Clone ──────────────────────────────────────────────────── func TestConfigCloneIsDeepCopy(t *testing.T) { falseVal := false - trueVal := true - turns := 2 cfg := Config{ Keys: []string{"key1"}, Accounts: []Account{{Email: "user@test.com", Token: "token"}}, @@ -303,10 +309,6 @@ func TestConfigCloneIsDeepCopy(t *testing.T) { Compat: CompatConfig{ StripReferenceMarkers: &falseVal, }, - HistorySplit: HistorySplitConfig{ - Enabled: &trueVal, - TriggerAfterTurns: &turns, - }, AdditionalFields: map[string]any{"custom": "value"}, } @@ -319,12 +321,6 @@ 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" { @@ -339,12 +335,6 @@ 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 8f2e641..dc7ae57 100644 --- a/internal/config/store_accessors.go +++ b/internal/config/store_accessors.go @@ -163,14 +163,6 @@ func (s *Store) AutoDeleteSessions() bool { return s.AutoDeleteMode() != "none" } -func (s *Store) HistorySplitEnabled() bool { - return false -} - -func (s *Store) HistorySplitTriggerAfterTurns() int { - return 1 -} - func (s *Store) CurrentInputFileEnabled() bool { s.mu.RLock() defer s.mu.RUnlock() diff --git a/internal/config/store_accessors_test.go b/internal/config/store_accessors_test.go index 32ee741..7667d61 100644 --- a/internal/config/store_accessors_test.go +++ b/internal/config/store_accessors_test.go @@ -2,21 +2,6 @@ package config import "testing" -func TestStoreHistorySplitAccessors(t *testing.T) { - enabled := true - turns := 3 - store := &Store{cfg: Config{HistorySplit: HistorySplitConfig{ - Enabled: &enabled, - TriggerAfterTurns: &turns, - }}} - if store.HistorySplitEnabled() { - t.Fatal("expected history split to stay disabled") - } - if got := store.HistorySplitTriggerAfterTurns(); got != 1 { - t.Fatalf("history split trigger_after_turns=%d want=1", got) - } -} - func TestStoreCurrentInputFileAccessors(t *testing.T) { store := &Store{cfg: Config{}} if !store.CurrentInputFileEnabled() { @@ -40,12 +25,6 @@ func TestStoreCurrentInputFileAccessors(t *testing.T) { if got := store.CurrentInputFileMinChars(); got != 12345 { t.Fatalf("current input file min_chars=%d want=12345", got) } - - historyEnabled := true - store.cfg.HistorySplit.Enabled = &historyEnabled - if !store.CurrentInputFileEnabled() { - t.Fatal("expected history split config to not suppress current input file mode") - } } func TestStoreThinkingInjectionAccessors(t *testing.T) { diff --git a/internal/httpapi/admin/handler_settings_test.go b/internal/httpapi/admin/handler_settings_test.go index a06c2ff..4437642 100644 --- a/internal/httpapi/admin/handler_settings_test.go +++ b/internal/httpapi/admin/handler_settings_test.go @@ -208,9 +208,6 @@ func TestUpdateSettingsCurrentInputFile(t *testing.T) { if !h.Store.CurrentInputFileEnabled() { t.Fatal("expected current input file accessor to stay enabled") } - if h.Store.HistorySplitEnabled() { - t.Fatal("expected history split accessor to stay disabled") - } } func TestUpdateSettingsCurrentInputFilePartialUpdatePreservesEnabled(t *testing.T) { diff --git a/internal/httpapi/admin/shared/deps.go b/internal/httpapi/admin/shared/deps.go index 8ae8bf7..a2df124 100644 --- a/internal/httpapi/admin/shared/deps.go +++ b/internal/httpapi/admin/shared/deps.go @@ -33,8 +33,6 @@ type ConfigStore interface { RuntimeGlobalMaxInflight(defaultSize int) int RuntimeTokenRefreshIntervalHours() int AutoDeleteMode() string - HistorySplitEnabled() bool - HistorySplitTriggerAfterTurns() int CurrentInputFileEnabled() bool CurrentInputFileMinChars() int ThinkingInjectionEnabled() bool diff --git a/internal/httpapi/claude/current_input_file_test.go b/internal/httpapi/claude/current_input_file_test.go new file mode 100644 index 0000000..4a5be55 --- /dev/null +++ b/internal/httpapi/claude/current_input_file_test.go @@ -0,0 +1,85 @@ +package claude + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "ds2api/internal/auth" + dsclient "ds2api/internal/deepseek/client" +) + +type claudeCurrentInputAuth struct{} + +func (claudeCurrentInputAuth) Determine(*http.Request) (*auth.RequestAuth, error) { + return &auth.RequestAuth{ + DeepSeekToken: "direct-token", + CallerID: "caller:test", + TriedAccounts: map[string]bool{}, + }, nil +} + +func (claudeCurrentInputAuth) Release(*auth.RequestAuth) {} + +type claudeCurrentInputDS struct { + uploads []dsclient.UploadFileRequest + payload map[string]any +} + +func (d *claudeCurrentInputDS) CreateSession(context.Context, *auth.RequestAuth, int) (string, error) { + return "session-id", nil +} + +func (d *claudeCurrentInputDS) GetPow(context.Context, *auth.RequestAuth, int) (string, error) { + return "pow", nil +} + +func (d *claudeCurrentInputDS) UploadFile(_ context.Context, _ *auth.RequestAuth, req dsclient.UploadFileRequest, _ int) (*dsclient.UploadFileResult, error) { + d.uploads = append(d.uploads, req) + return &dsclient.UploadFileResult{ID: "file-claude-history"}, nil +} + +func (d *claudeCurrentInputDS) CallCompletion(_ context.Context, _ *auth.RequestAuth, payload map[string]any, _ string, _ int) (*http.Response, error) { + d.payload = payload + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("data: {\"p\":\"response/content\",\"v\":\"ok\"}\n")), + }, nil +} + +func TestClaudeDirectAppliesCurrentInputFile(t *testing.T) { + ds := &claudeCurrentInputDS{} + h := &Handler{ + Store: mockClaudeConfig{aliases: map[string]string{"claude-sonnet-4-6": "deepseek-v4-flash"}}, + Auth: claudeCurrentInputAuth{}, + DS: ds, + } + reqBody := `{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"hello from claude"}],"max_tokens":1024}` + req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + h.Messages(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + if len(ds.uploads) != 1 { + t.Fatalf("expected one current input upload, got %d", len(ds.uploads)) + } + if ds.uploads[0].Filename != "DS2API_HISTORY.txt" { + t.Fatalf("unexpected upload filename: %q", ds.uploads[0].Filename) + } + refIDs, _ := ds.payload["ref_file_ids"].([]any) + if len(refIDs) != 1 || refIDs[0] != "file-claude-history" { + t.Fatalf("expected uploaded history ref id, got %#v", ds.payload["ref_file_ids"]) + } + prompt, _ := ds.payload["prompt"].(string) + if !strings.Contains(prompt, "Continue from the latest state in the attached DS2API_HISTORY.txt context.") { + t.Fatalf("expected continuation prompt, got %q", prompt) + } +} diff --git a/internal/httpapi/claude/deps.go b/internal/httpapi/claude/deps.go index f5c27f9..8ca98b1 100644 --- a/internal/httpapi/claude/deps.go +++ b/internal/httpapi/claude/deps.go @@ -17,12 +17,15 @@ type AuthResolver interface { type DeepSeekCaller interface { CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) + UploadFile(ctx context.Context, a *auth.RequestAuth, req dsclient.UploadFileRequest, maxAttempts int) (*dsclient.UploadFileResult, error) CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error) } type ConfigReader interface { ModelAliases() map[string]string CompatStripReferenceMarkers() bool + CurrentInputFileEnabled() bool + CurrentInputFileMinChars() int } type OpenAIChatRunner interface { diff --git a/internal/httpapi/claude/deps_injection_test.go b/internal/httpapi/claude/deps_injection_test.go index 9178505..e6da543 100644 --- a/internal/httpapi/claude/deps_injection_test.go +++ b/internal/httpapi/claude/deps_injection_test.go @@ -8,6 +8,8 @@ type mockClaudeConfig struct { func (m mockClaudeConfig) ModelAliases() map[string]string { return m.aliases } func (mockClaudeConfig) CompatStripReferenceMarkers() bool { return true } +func (mockClaudeConfig) CurrentInputFileEnabled() bool { return true } +func (mockClaudeConfig) CurrentInputFileMinChars() int { return 0 } func TestNormalizeClaudeRequestUsesGlobalAliasMapping(t *testing.T) { req := map[string]any{ diff --git a/internal/httpapi/claude/handler_messages.go b/internal/httpapi/claude/handler_messages.go index 61f206e..d0bc8ca 100644 --- a/internal/httpapi/claude/handler_messages.go +++ b/internal/httpapi/claude/handler_messages.go @@ -86,6 +86,7 @@ func (h *Handler) handleClaudeDirect(w http.ResponseWriter, r *http.Request) boo result, outErr := completionruntime.ExecuteNonStreamWithRetry(r.Context(), h.DS, a, norm.Standard, completionruntime.Options{ StripReferenceMarkers: h.compatStripReferenceMarkers(), RetryEnabled: true, + CurrentInputFile: h.Store, }) if outErr != nil { writeClaudeError(w, outErr.Status, outErr.Message) @@ -101,12 +102,15 @@ func (h *Handler) handleClaudeDirect(w http.ResponseWriter, r *http.Request) boo } func (h *Handler) handleClaudeDirectStream(w http.ResponseWriter, r *http.Request, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) { - start, outErr := completionruntime.StartCompletion(r.Context(), h.DS, a, stdReq, completionruntime.Options{}) + start, outErr := completionruntime.StartCompletion(r.Context(), h.DS, a, stdReq, completionruntime.Options{ + CurrentInputFile: h.Store, + }) if outErr != nil { writeClaudeError(w, outErr.Status, outErr.Message) return } - h.handleClaudeStreamRealtime(w, r, start.Response, stdReq.ResponseModel, stdReq.Messages, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, stdReq.ToolsRaw) + streamReq := start.Request + h.handleClaudeStreamRealtime(w, r, start.Response, streamReq.ResponseModel, streamReq.Messages, streamReq.Thinking, streamReq.Search, streamReq.ToolNames, streamReq.ToolsRaw) } func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, store ConfigReader) bool { diff --git a/internal/httpapi/claude/proxy_vercel_test.go b/internal/httpapi/claude/proxy_vercel_test.go index 3624643..4fbbbfe 100644 --- a/internal/httpapi/claude/proxy_vercel_test.go +++ b/internal/httpapi/claude/proxy_vercel_test.go @@ -15,6 +15,8 @@ type claudeProxyStoreStub struct { func (s claudeProxyStoreStub) ModelAliases() map[string]string { return s.aliases } func (claudeProxyStoreStub) CompatStripReferenceMarkers() bool { return true } +func (claudeProxyStoreStub) CurrentInputFileEnabled() bool { return true } +func (claudeProxyStoreStub) CurrentInputFileMinChars() int { return 0 } type openAIProxyStub struct { status int diff --git a/internal/httpapi/claude/stream_status_test.go b/internal/httpapi/claude/stream_status_test.go index 2a2586f..96e5858 100644 --- a/internal/httpapi/claude/stream_status_test.go +++ b/internal/httpapi/claude/stream_status_test.go @@ -24,6 +24,8 @@ type streamStatusClaudeStoreStub struct{} func (streamStatusClaudeStoreStub) ModelAliases() map[string]string { return nil } func (streamStatusClaudeStoreStub) CompatStripReferenceMarkers() bool { return true } +func (streamStatusClaudeStoreStub) CurrentInputFileEnabled() bool { return true } +func (streamStatusClaudeStoreStub) CurrentInputFileMinChars() int { return 0 } func captureClaudeStatusMiddleware(statuses *[]int) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { diff --git a/internal/httpapi/gemini/deps.go b/internal/httpapi/gemini/deps.go index 326d56c..f99edbc 100644 --- a/internal/httpapi/gemini/deps.go +++ b/internal/httpapi/gemini/deps.go @@ -17,12 +17,15 @@ type AuthResolver interface { type DeepSeekCaller interface { CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) + UploadFile(ctx context.Context, a *auth.RequestAuth, req dsclient.UploadFileRequest, maxAttempts int) (*dsclient.UploadFileResult, error) CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error) } type ConfigReader interface { ModelAliases() map[string]string CompatStripReferenceMarkers() bool + CurrentInputFileEnabled() bool + CurrentInputFileMinChars() int } type OpenAIChatRunner interface { diff --git a/internal/httpapi/gemini/handler_generate.go b/internal/httpapi/gemini/handler_generate.go index 9eda3d5..53effe6 100644 --- a/internal/httpapi/gemini/handler_generate.go +++ b/internal/httpapi/gemini/handler_generate.go @@ -83,6 +83,7 @@ func (h *Handler) handleGeminiDirect(w http.ResponseWriter, r *http.Request, str result, outErr := completionruntime.ExecuteNonStreamWithRetry(r.Context(), h.DS, a, stdReq, completionruntime.Options{ StripReferenceMarkers: h.compatStripReferenceMarkers(), RetryEnabled: true, + CurrentInputFile: h.Store, }) if outErr != nil { writeGeminiError(w, outErr.Status, outErr.Message) @@ -93,12 +94,15 @@ func (h *Handler) handleGeminiDirect(w http.ResponseWriter, r *http.Request, str } func (h *Handler) handleGeminiDirectStream(w http.ResponseWriter, r *http.Request, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) { - start, outErr := completionruntime.StartCompletion(r.Context(), h.DS, a, stdReq, completionruntime.Options{}) + start, outErr := completionruntime.StartCompletion(r.Context(), h.DS, a, stdReq, completionruntime.Options{ + CurrentInputFile: h.Store, + }) if outErr != nil { writeGeminiError(w, outErr.Status, outErr.Message) return } - h.handleStreamGenerateContent(w, r, start.Response, stdReq.ResponseModel, stdReq.PromptTokenText, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, stdReq.ToolsRaw) + streamReq := start.Request + h.handleStreamGenerateContent(w, r, start.Response, streamReq.ResponseModel, streamReq.PromptTokenText, streamReq.Thinking, streamReq.Search, streamReq.ToolNames, streamReq.ToolsRaw) } func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, stream bool) bool { diff --git a/internal/httpapi/gemini/handler_test.go b/internal/httpapi/gemini/handler_test.go index 8dd98aa..d674485 100644 --- a/internal/httpapi/gemini/handler_test.go +++ b/internal/httpapi/gemini/handler_test.go @@ -13,12 +13,15 @@ import ( "github.com/go-chi/chi/v5" "ds2api/internal/auth" + dsclient "ds2api/internal/deepseek/client" ) type testGeminiConfig struct{} func (testGeminiConfig) ModelAliases() map[string]string { return nil } func (testGeminiConfig) CompatStripReferenceMarkers() bool { return true } +func (testGeminiConfig) CurrentInputFileEnabled() bool { return true } +func (testGeminiConfig) CurrentInputFileMinChars() int { return 0 } type testGeminiAuth struct { a *auth.RequestAuth @@ -44,22 +47,31 @@ func (testGeminiAuth) Release(_ *auth.RequestAuth) {} //nolint:unused // reserved test double for native Gemini DS-call path coverage. type testGeminiDS struct { - resp *http.Response - err error + resp *http.Response + err error + uploadCalls []dsclient.UploadFileRequest + payloads []map[string]any } //nolint:unused // reserved test double for native Gemini DS-call path coverage. -func (m testGeminiDS) CreateSession(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) { +func (m *testGeminiDS) CreateSession(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) { return "session-id", nil } //nolint:unused // reserved test double for native Gemini DS-call path coverage. -func (m testGeminiDS) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) { +func (m *testGeminiDS) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) { return "pow", nil } //nolint:unused // reserved test double for native Gemini DS-call path coverage. -func (m testGeminiDS) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) { +func (m *testGeminiDS) UploadFile(_ context.Context, _ *auth.RequestAuth, req dsclient.UploadFileRequest, _ int) (*dsclient.UploadFileResult, error) { + m.uploadCalls = append(m.uploadCalls, req) + return &dsclient.UploadFileResult{ID: "file-gemini-history"}, nil +} + +//nolint:unused // reserved test double for native Gemini DS-call path coverage. +func (m *testGeminiDS) CallCompletion(_ context.Context, _ *auth.RequestAuth, payload map[string]any, _ string, _ int) (*http.Response, error) { + m.payloads = append(m.payloads, payload) if m.err != nil { return nil, m.err } @@ -123,6 +135,46 @@ func makeGeminiUpstreamResponse(lines ...string) *http.Response { } } +func TestGeminiDirectAppliesCurrentInputFile(t *testing.T) { + ds := &testGeminiDS{ + resp: makeGeminiUpstreamResponse(`data: {"p":"response/content","v":"ok"}`), + } + h := &Handler{ + Store: testGeminiConfig{}, + Auth: testGeminiAuth{}, + DS: ds, + } + reqBody := `{"contents":[{"role":"user","parts":[{"text":"hello from gemini"}]}]}` + req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-pro:generateContent", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + r := chi.NewRouter() + RegisterRoutes(r, h) + + 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 one current input upload, got %d", len(ds.uploadCalls)) + } + if ds.uploadCalls[0].Filename != "DS2API_HISTORY.txt" { + t.Fatalf("unexpected upload filename: %q", ds.uploadCalls[0].Filename) + } + if len(ds.payloads) != 1 { + t.Fatalf("expected one completion payload, got %d", len(ds.payloads)) + } + refIDs, _ := ds.payloads[0]["ref_file_ids"].([]any) + if len(refIDs) != 1 || refIDs[0] != "file-gemini-history" { + t.Fatalf("expected uploaded history ref id, got %#v", ds.payloads[0]["ref_file_ids"]) + } + prompt, _ := ds.payloads[0]["prompt"].(string) + if !strings.Contains(prompt, "Continue from the latest state in the attached DS2API_HISTORY.txt context.") { + t.Fatalf("expected continuation prompt, got %q", prompt) + } +} + func TestGeminiRoutesRegistered(t *testing.T) { h := &Handler{ Store: testGeminiConfig{}, diff --git a/internal/httpapi/openai/chat/handler_chat.go b/internal/httpapi/openai/chat/handler_chat.go index bf1d16e..38174f6 100644 --- a/internal/httpapi/openai/chat/handler_chat.go +++ b/internal/httpapi/openai/chat/handler_chat.go @@ -81,6 +81,7 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) { result, outErr := completionruntime.ExecuteNonStreamWithRetry(r.Context(), h.DS, a, stdReq, completionruntime.Options{ StripReferenceMarkers: h.compatStripReferenceMarkers(), RetryEnabled: true, + CurrentInputFile: h.Store, }) sessionID = result.SessionID if outErr != nil { @@ -100,7 +101,9 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) { return } - start, outErr := completionruntime.StartCompletion(r.Context(), h.DS, a, stdReq, completionruntime.Options{}) + start, outErr := completionruntime.StartCompletion(r.Context(), h.DS, a, stdReq, completionruntime.Options{ + CurrentInputFile: h.Store, + }) sessionID = start.SessionID if outErr != nil { if historySession != nil { @@ -109,8 +112,9 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) { writeOpenAIErrorWithCode(w, outErr.Status, outErr.Message, outErr.Code) return } - refFileTokens := stdReq.RefFileTokens - h.handleStreamWithRetry(w, r, a, start.Response, start.Payload, start.Pow, sessionID, stdReq.ResponseModel, stdReq.PromptTokenText, refFileTokens, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, stdReq.ToolsRaw, stdReq.ToolChoice, historySession) + streamReq := start.Request + refFileTokens := streamReq.RefFileTokens + h.handleStreamWithRetry(w, r, a, start.Response, start.Payload, start.Pow, sessionID, streamReq.ResponseModel, streamReq.PromptTokenText, refFileTokens, streamReq.Thinking, streamReq.Search, streamReq.ToolNames, streamReq.ToolsRaw, streamReq.ToolChoice, historySession) } func (h *Handler) autoDeleteRemoteSession(ctx context.Context, a *auth.RequestAuth, sessionID string) { diff --git a/internal/httpapi/openai/chat/test_helpers_test.go b/internal/httpapi/openai/chat/test_helpers_test.go index e382a37..3760f21 100644 --- a/internal/httpapi/openai/chat/test_helpers_test.go +++ b/internal/httpapi/openai/chat/test_helpers_test.go @@ -18,8 +18,6 @@ type mockOpenAIConfig struct { earlyEmit string responsesTTL int embedProv string - historySplitEnabled bool - historySplitTurns int currentInputEnabled bool currentInputMin int thinkingInjection *bool @@ -41,14 +39,7 @@ func (m mockOpenAIConfig) AutoDeleteMode() string { } return m.autoDeleteMode } -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 (m mockOpenAIConfig) AutoDeleteSessions() bool { return false } func (m mockOpenAIConfig) CurrentInputFileEnabled() bool { return m.currentInputEnabled } func (m mockOpenAIConfig) CurrentInputFileMinChars() int { return m.currentInputMin diff --git a/internal/httpapi/openai/deps_injection_test.go b/internal/httpapi/openai/deps_injection_test.go index 1f199bb..2f9f445 100644 --- a/internal/httpapi/openai/deps_injection_test.go +++ b/internal/httpapi/openai/deps_injection_test.go @@ -14,8 +14,6 @@ type mockOpenAIConfig struct { earlyEmit string responsesTTL int embedProv string - historySplitEnabled bool - historySplitTurns int currentInputEnabled bool currentInputMin int thinkingInjection *bool @@ -37,14 +35,7 @@ func (m mockOpenAIConfig) AutoDeleteMode() string { } return m.autoDeleteMode } -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 (m mockOpenAIConfig) AutoDeleteSessions() bool { return false } func (m mockOpenAIConfig) CurrentInputFileEnabled() bool { return m.currentInputEnabled } func (m mockOpenAIConfig) CurrentInputFileMinChars() int { return m.currentInputMin diff --git a/internal/httpapi/openai/history/current_input_file.go b/internal/httpapi/openai/history/current_input_file.go index 1763276..9f5f8ee 100644 --- a/internal/httpapi/openai/history/current_input_file.go +++ b/internal/httpapi/openai/history/current_input_file.go @@ -19,8 +19,22 @@ const ( currentInputPurpose = "assistants" ) +type CurrentInputConfigReader interface { + CurrentInputFileEnabled() bool + CurrentInputFileMinChars() int +} + +type CurrentInputUploader interface { + UploadFile(ctx context.Context, a *auth.RequestAuth, req dsclient.UploadFileRequest, maxAttempts int) (*dsclient.UploadFileResult, error) +} + +type Service struct { + Store CurrentInputConfigReader + DS CurrentInputUploader +} + 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() { + if stdReq.CurrentInputFileApplied || s.DS == nil || s.Store == nil || a == nil || !s.Store.CurrentInputFileEnabled() { return stdReq, nil } threshold := s.Store.CurrentInputFileMinChars() @@ -95,3 +109,20 @@ func latestUserInputForFile(messages []any) (int, string) { func currentInputFilePrompt() string { return "Continue from the latest state in the attached DS2API_HISTORY.txt context. Treat it as the current working state and answer the latest user request directly." } + +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/httpapi/openai/history/history_split.go b/internal/httpapi/openai/history/history_split.go deleted file mode 100644 index 9282147..0000000 --- a/internal/httpapi/openai/history/history_split.go +++ /dev/null @@ -1,90 +0,0 @@ -package history - -import ( - "context" - "strings" - - "ds2api/internal/auth" - "ds2api/internal/httpapi/openai/shared" - "ds2api/internal/promptcompat" -) - -type Service struct { - Store shared.ConfigReader - DS shared.DeepSeekCaller -} - -// Apply is retained for legacy compatibility only. The active split path is -// current input file handling in ApplyCurrentInputFile. -func (s Service) Apply(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) { - return stdReq, nil -} - -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(shared.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(shared.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 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/httpapi/openai/history_split_test.go b/internal/httpapi/openai/history_split_test.go index 9e5bdd9..1be537f 100644 --- a/internal/httpapi/openai/history_split_test.go +++ b/internal/httpapi/openai/history_split_test.go @@ -62,8 +62,7 @@ func (streamStatusManagedAuthStub) DetermineCaller(_ *http.Request) (*auth.Reque func (streamStatusManagedAuthStub) Release(_ *auth.RequestAuth) {} func TestBuildOpenAICurrentInputContextTranscriptUsesNumberedHistorySections(t *testing.T) { - _, historyMessages := splitOpenAIHistoryMessages(historySplitTestMessages(), 1) - transcript := buildOpenAICurrentInputContextTranscript(historyMessages) + transcript := buildOpenAICurrentInputContextTranscript(historySplitTestMessages()) if strings.Contains(transcript, "[file content end]") || strings.Contains(transcript, "[file content begin]") || strings.Contains(transcript, "[file name]:") { t.Fatalf("expected transcript without file wrapper tags, got %q", transcript) @@ -75,11 +74,14 @@ func TestBuildOpenAICurrentInputContextTranscriptUsesNumberedHistorySections(t * t.Fatalf("expected history transcript description, got %q", transcript) } for _, want := range []string{ - "=== 1. USER ===", - "=== 2. ASSISTANT ===", - "=== 3. TOOL ===", + "=== 1. SYSTEM ===", + "=== 2. USER ===", + "=== 3. ASSISTANT ===", + "=== 4. TOOL ===", + "=== 5. USER ===", "first user turn", "tool result", + "latest user turn", "[reasoning_content]", "hidden reasoning", "<|DSML|tool_calls>", @@ -90,38 +92,6 @@ func TestBuildOpenAICurrentInputContextTranscriptUsesNumberedHistorySections(t * } } -func TestSplitOpenAIHistoryMessagesUsesLatestUserTurn(t *testing.T) { - 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": "first assistant turn"}, - 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, _ := promptcompat.BuildOpenAIPrompt(promptMessages, nil, "", defaultToolChoicePolicy(), true) - 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 moved into history, got %s", promptText) - } - - historyText := buildOpenAICurrentInputContextTranscript(historyMessages) - if !strings.Contains(historyText, "middle user turn") { - t.Fatalf("expected middle user turn in split history, got %s", historyText) - } - if strings.Contains(historyText, "latest user turn") { - t.Fatalf("expected latest user turn to remain live, got %s", historyText) - } -} - func TestApplyCurrentInputFileSkipsShortInputWhenThresholdNotReached(t *testing.T) { ds := &inlineUploadDSStub{} h := &openAITestSurface{ @@ -716,10 +686,6 @@ func TestCurrentInputFileWorksAcrossAutoDeleteModes(t *testing.T) { } } -func defaultToolChoicePolicy() promptcompat.ToolChoicePolicy { - return promptcompat.DefaultToolChoicePolicy() -} - func boolPtr(v bool) *bool { return &v } diff --git a/internal/httpapi/openai/responses/responses_handler.go b/internal/httpapi/openai/responses/responses_handler.go index 5ec5efe..5a47070 100644 --- a/internal/httpapi/openai/responses/responses_handler.go +++ b/internal/httpapi/openai/responses/responses_handler.go @@ -98,6 +98,7 @@ func (h *Handler) Responses(w http.ResponseWriter, r *http.Request) { result, outErr := completionruntime.ExecuteNonStreamWithRetry(r.Context(), h.DS, a, stdReq, completionruntime.Options{ StripReferenceMarkers: h.compatStripReferenceMarkers(), RetryEnabled: true, + CurrentInputFile: h.Store, }) if outErr != nil { writeOpenAIErrorWithCode(w, outErr.Status, outErr.Message, outErr.Code) @@ -110,14 +111,17 @@ func (h *Handler) Responses(w http.ResponseWriter, r *http.Request) { return } - start, outErr := completionruntime.StartCompletion(r.Context(), h.DS, a, stdReq, completionruntime.Options{}) + start, outErr := completionruntime.StartCompletion(r.Context(), h.DS, a, stdReq, completionruntime.Options{ + CurrentInputFile: h.Store, + }) if outErr != nil { writeOpenAIErrorWithCode(w, outErr.Status, outErr.Message, outErr.Code) return } - refFileTokens := stdReq.RefFileTokens - h.handleResponsesStreamWithRetry(w, r, a, start.Response, start.Payload, start.Pow, owner, responseID, stdReq.ResponseModel, stdReq.PromptTokenText, refFileTokens, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, stdReq.ToolsRaw, stdReq.ToolChoice, traceID) + streamReq := start.Request + refFileTokens := streamReq.RefFileTokens + h.handleResponsesStreamWithRetry(w, r, a, start.Response, start.Payload, start.Pow, owner, responseID, streamReq.ResponseModel, streamReq.PromptTokenText, refFileTokens, streamReq.Thinking, streamReq.Search, streamReq.ToolNames, streamReq.ToolsRaw, streamReq.ToolChoice, traceID) } func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Response, owner, responseID, model, finalPrompt string, refFileTokens int, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any, toolChoice promptcompat.ToolChoicePolicy, traceID string) { diff --git a/internal/httpapi/openai/shared/deps.go b/internal/httpapi/openai/shared/deps.go index 6315541..776abfa 100644 --- a/internal/httpapi/openai/shared/deps.go +++ b/internal/httpapi/openai/shared/deps.go @@ -43,8 +43,6 @@ type ConfigReader interface { EmbeddingsProvider() string AutoDeleteMode() string AutoDeleteSessions() bool - HistorySplitEnabled() bool - HistorySplitTriggerAfterTurns() int CurrentInputFileEnabled() bool CurrentInputFileMinChars() int ThinkingInjectionEnabled() bool diff --git a/internal/httpapi/openai/test_bridge_test.go b/internal/httpapi/openai/test_bridge_test.go index 080667d..f0e6205 100644 --- a/internal/httpapi/openai/test_bridge_test.go +++ b/internal/httpapi/openai/test_bridge_test.go @@ -108,10 +108,6 @@ func registerOpenAITestRoutes(r chi.Router, h *openAITestSurface) { r.Post("/v1/embeddings", h.embeddingsHandler().Embeddings) } -func splitOpenAIHistoryMessages(messages []any, triggerAfterTurns int) ([]any, []any) { - return history.SplitOpenAIHistoryMessages(messages, triggerAfterTurns) -} - func buildOpenAICurrentInputContextTranscript(messages []any) string { return promptcompat.BuildOpenAICurrentInputContextTranscript(messages) } diff --git a/webui/src/features/settings/HistorySplitSection.jsx b/webui/src/features/settings/CurrentInputFileSection.jsx similarity index 100% rename from webui/src/features/settings/HistorySplitSection.jsx rename to webui/src/features/settings/CurrentInputFileSection.jsx diff --git a/webui/src/features/settings/SettingsContainer.jsx b/webui/src/features/settings/SettingsContainer.jsx index 52374e8..f8d5e7b 100644 --- a/webui/src/features/settings/SettingsContainer.jsx +++ b/webui/src/features/settings/SettingsContainer.jsx @@ -5,7 +5,7 @@ import { useSettingsForm } from './useSettingsForm' import SecuritySection from './SecuritySection' import RuntimeSection from './RuntimeSection' import BehaviorSection from './BehaviorSection' -import CurrentInputFileSection from './HistorySplitSection' +import CurrentInputFileSection from './CurrentInputFileSection' import CompatibilitySection from './CompatibilitySection' import AutoDeleteSection from './AutoDeleteSection' import ModelSection from './ModelSection'