mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-11 03:37:40 +08:00
refactor: remove legacy history split configuration and integrate current input file handling into the completion runtime pipeline.
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
3
API.md
3
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`。
|
||||
|
||||
|
||||
@@ -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` 留空时使用内置默认提示词。
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
85
internal/httpapi/claude/current_input_file_test.go
Normal file
85
internal/httpapi/claude/current_input_file_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -43,8 +43,6 @@ type ConfigReader interface {
|
||||
EmbeddingsProvider() string
|
||||
AutoDeleteMode() string
|
||||
AutoDeleteSessions() bool
|
||||
HistorySplitEnabled() bool
|
||||
HistorySplitTriggerAfterTurns() int
|
||||
CurrentInputFileEnabled() bool
|
||||
CurrentInputFileMinChars() int
|
||||
ThinkingInjectionEnabled() bool
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user