mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-08 02:15:27 +08:00
refactor: replace history_split with current_input_file configuration
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -712,7 +712,7 @@ Reads runtime settings and status, including:
|
||||
- `compat` (`wide_input_strict_output`, `strip_reference_markers`)
|
||||
- `responses` / `embeddings`
|
||||
- `auto_delete` (`mode`: `none` / `single` / `all`; legacy `sessions=true` is still treated as `all`)
|
||||
- `history_split` (`enabled` always returns `true`, `trigger_after_turns`)
|
||||
- `current_input_file` (`enabled` defaults to `true`, plus `min_chars`)
|
||||
- `model_aliases`
|
||||
- `env_backed`, `needs_vercel_sync`
|
||||
- `toolcall` policy is fixed to `feature_match + high` and is no longer returned or editable via settings
|
||||
@@ -727,8 +727,9 @@ Hot-updates runtime settings. Supported fields:
|
||||
- `responses.store_ttl_seconds`
|
||||
- `embeddings.provider`
|
||||
- `auto_delete.mode`
|
||||
- `history_split.trigger_after_turns` (`history_split.enabled` is forced on globally; legacy client writes are stored as `true`)
|
||||
- `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`
|
||||
@@ -752,9 +753,9 @@ 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 `history_split` via `/admin/settings` or the config file; 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; `history_split` remains only for legacy compatibility; legacy `toolcall` fields are ignored.
|
||||
|
||||
> Note: `merge` mode does not update `compat`, `auto_delete`, or `history_split`.
|
||||
> Note: `merge` mode does not update `compat`, `auto_delete`, or `current_input_file`.
|
||||
|
||||
### `GET /admin/config/export`
|
||||
|
||||
|
||||
9
API.md
9
API.md
@@ -726,7 +726,7 @@ data: {"type":"message_stop"}
|
||||
- `compat`(`wide_input_strict_output`、`strip_reference_markers`)
|
||||
- `responses` / `embeddings`
|
||||
- `auto_delete`(`mode`:`none` / `single` / `all`;旧配置 `sessions=true` 仍按 `all` 处理)
|
||||
- `history_split`(`enabled` 固定返回 `true`、`trigger_after_turns`)
|
||||
- `current_input_file`(`enabled` 默认返回 `true`、`min_chars`)
|
||||
- `model_aliases`
|
||||
- `env_backed`、`needs_vercel_sync`
|
||||
- `toolcall` 策略已固定为 `feature_match + high`,不再通过 settings 返回或修改
|
||||
@@ -741,8 +741,9 @@ data: {"type":"message_stop"}
|
||||
- `responses.store_ttl_seconds`
|
||||
- `embeddings.provider`
|
||||
- `auto_delete.mode`
|
||||
- `history_split.trigger_after_turns`(`history_split.enabled` 已全局强制开启;旧客户端传入时会被保存为 `true`)
|
||||
- `current_input_file.enabled` / `current_input_file.min_chars`
|
||||
- `model_aliases`
|
||||
- `history_split` 仅作为旧配置兼容字段保留,不再影响请求处理
|
||||
- `toolcall` 策略已固定,不再作为可写入字段
|
||||
|
||||
### `POST /admin/settings/password`
|
||||
@@ -766,9 +767,9 @@ 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`、`history_split` 建议通过 `/admin/settings` 或配置文件管理;`toolcall` 相关字段会被忽略。
|
||||
`replace` 模式会按完整配置结构替换(保留 Vercel 同步元信息);`merge` 模式会合并 `keys`、`api_keys`、`accounts`、`model_aliases`,并覆盖 `admin`、`runtime`、`responses`、`embeddings` 中的非空字段。`compat`、`auto_delete`、`current_input_file` 建议通过 `/admin/settings` 或配置文件管理;`history_split` 仅保留为旧配置兼容字段;`toolcall` 相关字段会被忽略。
|
||||
|
||||
> 注意:`merge` 模式不会更新 `compat`、`auto_delete`、`history_split`。
|
||||
> 注意:`merge` 模式不会更新 `compat`、`auto_delete`、`current_input_file`。
|
||||
|
||||
### `GET /admin/config/export`
|
||||
|
||||
|
||||
@@ -290,9 +290,9 @@ go run ./cmd/ds2api
|
||||
- `model_aliases`:OpenAI / Claude / Gemini 共用的模型 alias 映射。
|
||||
- `runtime`:账号并发、队列与 token 刷新策略,可通过 Admin Settings 热更新。
|
||||
- `auto_delete.mode`:请求结束后的远端会话清理策略,支持 `none` / `single` / `all`。
|
||||
- `history_split`:轮次拆分策略;默认关闭,开启后默认从第二轮开始将旧历史上传为 `HISTORY.txt`。
|
||||
- `current_input_file`:独立拆分策略;默认开启且阈值为 `0`,触发时将完整上下文合并上传为隐藏上下文文件,并跳过 `HISTORY.txt`。
|
||||
- `history_split` 与 `current_input_file` 互斥,最多启用一个;两者都关闭时请求直接透传。
|
||||
- `history_split`:旧轮次拆分字段,已废弃并忽略,仅保留兼容旧配置。
|
||||
- `current_input_file`:唯一生效的独立拆分策略;默认开启且阈值为 `0`,触发时将完整上下文合并上传为隐藏上下文文件。
|
||||
- 如果关闭 `current_input_file`,请求会直接透传,不上传拆分上下文文件。
|
||||
- `thinking_injection`:默认开启;在最新 user 消息末尾追加思考增强提示词,提高高强度推理与工具调用前的思考稳定性;`prompt` 留空时使用内置默认提示词。
|
||||
|
||||
环境变量完整列表见 [部署指南](docs/DEPLOY.md),接口鉴权规则见 [API.md](API.md#鉴权规则)。
|
||||
|
||||
@@ -278,7 +278,9 @@ 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`: multi-turn history split policy, now forced on globally; tune its trigger threshold to avoid inlining all long history into the prompt.
|
||||
- `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 hidden 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).
|
||||
|
||||
|
||||
@@ -50,10 +50,6 @@
|
||||
"responses": {
|
||||
"store_ttl_seconds": 900
|
||||
},
|
||||
"history_split": {
|
||||
"enabled": false,
|
||||
"trigger_after_turns": 1
|
||||
},
|
||||
"current_input_file": {
|
||||
"enabled": true,
|
||||
"min_chars": 0
|
||||
|
||||
@@ -43,7 +43,7 @@ ds2api/
|
||||
│ │ ├── responses/ # Responses API and response store
|
||||
│ │ ├── files/ # Files API and inline-file preprocessing
|
||||
│ │ ├── embeddings/ # Embeddings API
|
||||
│ │ ├── history/ # OpenAI history split
|
||||
│ │ ├── history/ # OpenAI context file handling
|
||||
│ │ └── shared/ # OpenAI HTTP errors/models/tool formatting
|
||||
│ ├── js/ # Node runtime related logic
|
||||
│ │ ├── chat-stream/ # Node streaming bridge
|
||||
|
||||
@@ -43,7 +43,7 @@ ds2api/
|
||||
│ │ ├── responses/ # Responses API 与 response store
|
||||
│ │ ├── files/ # Files API 与 inline file 预处理
|
||||
│ │ ├── embeddings/ # Embeddings API
|
||||
│ │ ├── history/ # OpenAI history split
|
||||
│ │ ├── history/ # OpenAI context file handling
|
||||
│ │ └── shared/ # OpenAI HTTP 公共错误/模型/工具格式
|
||||
│ ├── js/ # Node Runtime 相关逻辑
|
||||
│ │ ├── chat-stream/ # Node 流式输出桥接
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
文档导航:[总览](../README.MD) / [架构说明](./ARCHITECTURE.md) / [接口文档](../API.md) / [测试指南](./TESTING.md)
|
||||
|
||||
> 本文档是 DS2API“把 OpenAI / Claude / Gemini 风格 API 请求兼容成 DeepSeek 网页对话纯文本上下文”的专项说明。
|
||||
> 这是项目最重要的兼容产物之一。凡是修改消息标准化、tool prompt 注入、tool history 保留、文件引用、history split、下游 completion payload 组装等行为,都必须同步更新本文档。
|
||||
> 这是项目最重要的兼容产物之一。凡是修改消息标准化、tool prompt 注入、tool history 保留、文件引用、current input file / legacy history_split、下游 completion payload 组装等行为,都必须同步更新本文档。
|
||||
|
||||
## 1. 核心结论
|
||||
|
||||
@@ -45,7 +45,7 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools`
|
||||
-> promptcompat 统一消息标准化
|
||||
-> tool prompt 注入
|
||||
-> DeepSeek 风格 prompt 拼装
|
||||
-> 文件收集 / inline 上传 / history split(OpenAI 链路)
|
||||
-> 文件收集 / inline 上传 / current input file(OpenAI 链路)
|
||||
-> completion payload
|
||||
-> 下游网页对话接口
|
||||
```
|
||||
@@ -107,12 +107,11 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools`
|
||||
|
||||
## 5. prompt 是怎么拼出来的
|
||||
|
||||
OpenAI Chat / Responses 在标准化后、history split / current input file 之前,会默认执行 `thinking_injection` 增强。它参考 DeepSeek V4 “把控制指令放在 user 消息末尾更稳定”的用法,在最新 user message 后追加思考增强提示词。当前内置默认提示词以 `Reasoning Effort: Absolute maximum with no shortcuts permitted.` 开头,并继续要求模型充分分解问题、覆盖潜在路径与边界条件、把完整推演过程显式写出。该开关默认启用,可通过 `thinking_injection.enabled=false` 关闭;也可以通过 `thinking_injection.prompt` 自定义提示词,留空时使用内置默认提示词。
|
||||
OpenAI Chat / Responses 在标准化后、current input file 之前,会默认执行 `thinking_injection` 增强。它参考 DeepSeek V4 “把控制指令放在 user 消息末尾更稳定”的用法,在最新 user message 后追加思考增强提示词。当前内置默认提示词以 `Reasoning Effort: Absolute maximum with no shortcuts permitted.` 开头,并继续要求模型充分分解问题、覆盖潜在路径与边界条件、把完整推演过程显式写出。该开关默认启用,可通过 `thinking_injection.enabled=false` 关闭;也可以通过 `thinking_injection.prompt` 自定义提示词,留空时使用内置默认提示词。
|
||||
|
||||
这段增强属于 prompt 可见上下文:
|
||||
|
||||
- 普通请求会直接出现在最终 `prompt` 的最新 user block 末尾。
|
||||
- 如果触发 `HISTORY.txt`,它会保留在 live context 的最新 user turn 中。
|
||||
- 如果触发 current input file,它会进入完整上下文文件中。
|
||||
|
||||
### 5.1 角色标记
|
||||
@@ -241,51 +240,22 @@ OpenAI 文件相关实现:
|
||||
|
||||
## 9. 多轮历史为什么不会一直完整内联在 prompt
|
||||
|
||||
兼容层提供两种拆分策略:
|
||||
兼容层现在只保留 `current_input_file` 这一种拆分方式;旧的 `history_split` 已废弃,只保留为兼容旧配置的字段,不再参与请求处理。
|
||||
|
||||
- `history_split` 是轮次拆分,默认关闭;开启后默认从第 2 个 user turn 起触发,可通过 `history_split.trigger_after_turns` 调整阈值。
|
||||
- `current_input_file` 是独立拆分,默认开启;它用于把“完整上下文”合并进隐藏上下文文件。当最新 user turn 的纯文本长度达到 `current_input_file.min_chars`(默认 `0`)时,兼容层会上传一个文件名为 `IGNORE.txt` 的上下文文件,并在 live prompt 中只保留一个中性的 user 消息要求模型直接回答最新请求,不再暴露文件名或要求模型读取本地文件。
|
||||
|
||||
两个策略互斥,最多只能启用一个。如果两个开关都关闭,请求会直接透传,不上传 `HISTORY.txt` 或 current input file。
|
||||
- `current_input_file` 默认开启;它用于把“完整上下文”合并进隐藏上下文文件。当最新 user turn 的纯文本长度达到 `current_input_file.min_chars`(默认 `0`)时,兼容层会上传一个文件名为 `IGNORE.txt` 的上下文文件,并在 live prompt 中只保留一个中性的 user 消息要求模型直接回答最新请求,不再暴露文件名或要求模型读取本地文件。
|
||||
- 如果 `current_input_file.enabled=false`,请求会直接透传,不上传任何拆分上下文文件。
|
||||
- 旧的 `history_split.enabled` / `history_split.trigger_after_turns` 会被读取进配置对象以保持兼容,但不会触发拆分上传,也不会影响 `current_input_file` 的默认开启。
|
||||
|
||||
相关实现:
|
||||
|
||||
- 配置访问器:
|
||||
[internal/config/store_accessors.go](../internal/config/store_accessors.go)
|
||||
- 历史拆分:
|
||||
[internal/httpapi/openai/history/history_split.go](../internal/httpapi/openai/history/history_split.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)
|
||||
|
||||
history split 触发后行为:
|
||||
|
||||
1. 旧历史消息被切出去。
|
||||
2. 旧历史会被重新序列化成一个文本文件。
|
||||
3. 真正上传的文件名固定是 `HISTORY.txt`。
|
||||
4. 文件内容内部会使用 `IGNORE` 这层包装名来闭合 DeepSeek 官网原生文件标记。
|
||||
5. 该文件上传后,其 `file_id` 会排在 `ref_file_ids` 最前面。
|
||||
6. live prompt 只保留:
|
||||
- system / developer
|
||||
- 最新 user turn 起的上下文
|
||||
|
||||
历史文件内容不是普通自由文本,而是用同一套角色标记再次序列化出的 transcript:
|
||||
|
||||
```text
|
||||
[uploaded filename]: HISTORY.txt
|
||||
[file content end]
|
||||
|
||||
<|begin▁of▁sentence|><|User|>...<|Assistant|>...<|Tool|>...
|
||||
|
||||
[file name]: IGNORE
|
||||
[file content begin]
|
||||
```
|
||||
|
||||
所以“完整上下文”在当前实现里,其实通常分散在两处:
|
||||
|
||||
- `prompt` 里的 live context
|
||||
- `ref_file_ids` 指向的 history transcript file
|
||||
|
||||
当前输入转文件启用并触发时,不会同时启用 history split,也不会上传 `HISTORY.txt`。上传文件的真实文件名是 `IGNORE.txt`,文件内容是完整 `messages` 上下文;它仍会先用 OpenAI 消息标准化和 DeepSeek 角色标记序列化,再包进 `IGNORE` 文件边界里:
|
||||
当前输入转文件启用并触发时,上传文件的真实文件名是 `IGNORE.txt`,文件内容是完整 `messages` 上下文;它仍会先用 OpenAI 消息标准化和 DeepSeek 角色标记序列化,再包进 `IGNORE` 文件边界里:
|
||||
|
||||
```text
|
||||
[uploaded filename]: IGNORE.txt
|
||||
@@ -309,7 +279,7 @@ history split 触发后行为:
|
||||
- Responses `instructions` 会 prepend 为 system message
|
||||
- `tools` 会注入 system prompt
|
||||
- `attachments` / `input_file` / inline 文件会进入 `ref_file_ids`
|
||||
- history split 主要在这条链路里生效
|
||||
- current input file 主要在这条链路里生效,旧 `history_split` 仅作兼容字段保留
|
||||
|
||||
### 10.2 Claude Messages
|
||||
|
||||
@@ -340,15 +310,15 @@ history split 触发后行为:
|
||||
- 有 tools
|
||||
- 有一个文件型 systemprompt 附件
|
||||
- 有历史 assistant tool call / tool result
|
||||
- history split 已触发
|
||||
- current input file 已触发
|
||||
|
||||
那么最终上下文更接近:
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "<|begin▁of▁sentence|><|System|>原 system / developer\n\nYou have access to these tools: ...<|end▁of▁instructions|><|User|>最新问题<|Assistant|>",
|
||||
"prompt": "<|begin▁of▁sentence|><|System|>原 system / developer\n\nYou have access to these tools: ...<|end▁of▁instructions|><|User|>The current request and prior conversation context have already been provided. Answer the latest user request directly.<|Assistant|>",
|
||||
"ref_file_ids": [
|
||||
"file-history-ignore",
|
||||
"file-current-input-ignore",
|
||||
"file-systemprompt",
|
||||
"file-other-attachment"
|
||||
],
|
||||
@@ -361,7 +331,7 @@ history split 触发后行为:
|
||||
|
||||
- 大部分结构化语义被压进 `prompt`
|
||||
- 文件保持文件
|
||||
- 历史必要时拆文件
|
||||
- 需要时把完整上下文拆进隐藏上下文文件
|
||||
|
||||
## 12. 修改时必须同步本文档的场景
|
||||
|
||||
@@ -374,7 +344,8 @@ history split 触发后行为:
|
||||
- tool result 注入方式变更
|
||||
- tool prompt 模板或 tool_choice 约束变更
|
||||
- inline 文件上传 / 文件引用收集规则变更
|
||||
- history split 触发条件、上传格式、`IGNORE` 包装格式变更
|
||||
- current input file 触发条件、上传格式、`IGNORE` 包装格式变更
|
||||
- 旧 `history_split` 兼容逻辑的读取、忽略或退化行为变更
|
||||
- completion payload 字段语义变更
|
||||
- Claude / Gemini 对这套统一语义的复用关系变更
|
||||
|
||||
|
||||
@@ -164,30 +164,16 @@ func (s *Store) AutoDeleteSessions() bool {
|
||||
}
|
||||
|
||||
func (s *Store) HistorySplitEnabled() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.cfg.HistorySplit.Enabled == nil {
|
||||
return false
|
||||
}
|
||||
return *s.cfg.HistorySplit.Enabled
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Store) HistorySplitTriggerAfterTurns() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.cfg.HistorySplit.TriggerAfterTurns == nil || *s.cfg.HistorySplit.TriggerAfterTurns <= 0 {
|
||||
return 1
|
||||
}
|
||||
return *s.cfg.HistorySplit.TriggerAfterTurns
|
||||
return 1
|
||||
}
|
||||
|
||||
func (s *Store) CurrentInputFileEnabled() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
historySplitEnabled := s.cfg.HistorySplit.Enabled != nil && *s.cfg.HistorySplit.Enabled
|
||||
if historySplitEnabled {
|
||||
return false
|
||||
}
|
||||
if s.cfg.CurrentInputFile.Enabled == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -3,41 +3,17 @@ package config
|
||||
import "testing"
|
||||
|
||||
func TestStoreHistorySplitAccessors(t *testing.T) {
|
||||
store := &Store{cfg: Config{}}
|
||||
if store.HistorySplitEnabled() {
|
||||
t.Fatal("expected history split disabled by default")
|
||||
}
|
||||
if got := store.HistorySplitTriggerAfterTurns(); got != 1 {
|
||||
t.Fatalf("default history split trigger_after_turns=%d want=1", got)
|
||||
}
|
||||
|
||||
enabled := true
|
||||
turns := 3
|
||||
store.cfg.HistorySplit = HistorySplitConfig{
|
||||
store := &Store{cfg: Config{HistorySplit: HistorySplitConfig{
|
||||
Enabled: &enabled,
|
||||
TriggerAfterTurns: &turns,
|
||||
}
|
||||
|
||||
if !store.HistorySplitEnabled() {
|
||||
t.Fatal("expected history split enabled")
|
||||
}
|
||||
if got := store.HistorySplitTriggerAfterTurns(); got != 3 {
|
||||
t.Fatalf("history split trigger_after_turns=%d want=3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreHistorySplitDisabledConfigStaysDisabled(t *testing.T) {
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"history_split":{"enabled":false,"trigger_after_turns":2}}`)
|
||||
store := LoadStore()
|
||||
}}}
|
||||
if store.HistorySplitEnabled() {
|
||||
t.Fatal("expected history split disabled when config disables it")
|
||||
t.Fatal("expected history split to stay disabled")
|
||||
}
|
||||
snap := store.Snapshot()
|
||||
if snap.HistorySplit.Enabled == nil || *snap.HistorySplit.Enabled {
|
||||
t.Fatalf("expected history_split.enabled=false, got %#v", snap.HistorySplit.Enabled)
|
||||
}
|
||||
if got := store.HistorySplitTriggerAfterTurns(); got != 2 {
|
||||
t.Fatalf("history split trigger_after_turns=%d want=2", got)
|
||||
if got := store.HistorySplitTriggerAfterTurns(); got != 1 {
|
||||
t.Fatalf("history split trigger_after_turns=%d want=1", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,8 +43,8 @@ func TestStoreCurrentInputFileAccessors(t *testing.T) {
|
||||
|
||||
historyEnabled := true
|
||||
store.cfg.HistorySplit.Enabled = &historyEnabled
|
||||
if store.CurrentInputFileEnabled() {
|
||||
t.Fatal("expected history split to suppress current input file mode")
|
||||
if !store.CurrentInputFileEnabled() {
|
||||
t.Fatal("expected history split config to not suppress current input file mode")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,15 +24,9 @@ func ValidateConfig(c Config) error {
|
||||
if err := ValidateAutoDeleteConfig(c.AutoDelete); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateHistorySplitConfig(c.HistorySplit); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateCurrentInputFileConfig(c.CurrentInputFile); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.HistorySplit.Enabled != nil && *c.HistorySplit.Enabled && c.CurrentInputFile.Enabled != nil && *c.CurrentInputFile.Enabled {
|
||||
return fmt.Errorf("history_split.enabled and current_input_file.enabled cannot both be true")
|
||||
}
|
||||
if err := ValidateAccountProxyReferences(c.Accounts, c.Proxies); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -120,15 +114,6 @@ func ValidateAutoDeleteConfig(autoDelete AutoDeleteConfig) error {
|
||||
return ValidateAutoDeleteMode(autoDelete.Mode)
|
||||
}
|
||||
|
||||
func ValidateHistorySplitConfig(historySplit HistorySplitConfig) error {
|
||||
if historySplit.TriggerAfterTurns != nil {
|
||||
if err := ValidateIntRange("history_split.trigger_after_turns", *historySplit.TriggerAfterTurns, 1, 1000, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateCurrentInputFileConfig(currentInputFile CurrentInputFileConfig) error {
|
||||
if currentInputFile.MinChars != 0 {
|
||||
return ValidateIntRange("current_input_file.min_chars", currentInputFile.MinChars, 1, 100000000, true)
|
||||
|
||||
@@ -39,26 +39,11 @@ func TestValidateConfigRejectsInvalidValues(t *testing.T) {
|
||||
cfg: Config{AutoDelete: AutoDeleteConfig{Mode: "maybe"}},
|
||||
want: "auto_delete.mode",
|
||||
},
|
||||
{
|
||||
name: "history split",
|
||||
cfg: Config{HistorySplit: HistorySplitConfig{
|
||||
TriggerAfterTurns: intPtr(0),
|
||||
}},
|
||||
want: "history_split.trigger_after_turns",
|
||||
},
|
||||
{
|
||||
name: "current input file",
|
||||
cfg: Config{CurrentInputFile: CurrentInputFileConfig{MinChars: -1}},
|
||||
want: "current_input_file.min_chars",
|
||||
},
|
||||
{
|
||||
name: "split modes mutually exclusive",
|
||||
cfg: Config{
|
||||
HistorySplit: HistorySplitConfig{Enabled: boolPtr(true)},
|
||||
CurrentInputFile: CurrentInputFileConfig{Enabled: boolPtr(true)},
|
||||
},
|
||||
want: "cannot both be true",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
@@ -79,7 +64,3 @@ func TestValidateConfigAcceptsLegacyAutoDeleteSessions(t *testing.T) {
|
||||
t.Fatalf("expected legacy auto_delete.sessions config to remain valid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func intPtr(v int) *int { return &v }
|
||||
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestGetSettingsIncludesTokenRefreshInterval(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSettingsIncludesHistorySplitDefaults(t *testing.T) {
|
||||
func TestGetSettingsIncludesCurrentInputFileDefaults(t *testing.T) {
|
||||
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -57,13 +57,6 @@ func TestGetSettingsIncludesHistorySplitDefaults(t *testing.T) {
|
||||
}
|
||||
var body map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
historySplit, _ := body["history_split"].(map[string]any)
|
||||
if got := boolFrom(historySplit["enabled"]); got {
|
||||
t.Fatalf("expected history_split.enabled=false, body=%v", body)
|
||||
}
|
||||
if got := intFrom(historySplit["trigger_after_turns"]); got != 1 {
|
||||
t.Fatalf("expected history_split.trigger_after_turns=1, got %d body=%v", got, body)
|
||||
}
|
||||
currentInputFile, _ := body["current_input_file"].(map[string]any)
|
||||
if got := boolFrom(currentInputFile["enabled"]); !got {
|
||||
t.Fatalf("expected current_input_file.enabled=true, body=%v", body)
|
||||
@@ -190,33 +183,6 @@ func TestUpdateSettingsWithoutRuntimeSkipsMergedRuntimeValidation(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettingsHistorySplit(t *testing.T) {
|
||||
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
|
||||
payload := map[string]any{
|
||||
"history_split": map[string]any{
|
||||
"enabled": true,
|
||||
"trigger_after_turns": 3,
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
|
||||
rec := httptest.NewRecorder()
|
||||
h.updateSettings(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
snap := h.Store.Snapshot()
|
||||
if snap.HistorySplit.Enabled == nil || !*snap.HistorySplit.Enabled {
|
||||
t.Fatalf("expected history_split.enabled=true, got %#v", snap.HistorySplit.Enabled)
|
||||
}
|
||||
if snap.HistorySplit.TriggerAfterTurns == nil || *snap.HistorySplit.TriggerAfterTurns != 3 {
|
||||
t.Fatalf("expected history_split.trigger_after_turns=3, got %#v", snap.HistorySplit.TriggerAfterTurns)
|
||||
}
|
||||
if snap.CurrentInputFile.Enabled == nil || *snap.CurrentInputFile.Enabled {
|
||||
t.Fatalf("expected history split to disable current_input_file, got %#v", snap.CurrentInputFile.Enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettingsCurrentInputFile(t *testing.T) {
|
||||
h := newAdminTestHandler(t, `{"keys":["k1"],"history_split":{"enabled":true,"trigger_after_turns":2}}`)
|
||||
payload := map[string]any{
|
||||
@@ -239,8 +205,11 @@ func TestUpdateSettingsCurrentInputFile(t *testing.T) {
|
||||
if snap.CurrentInputFile.MinChars != 12345 {
|
||||
t.Fatalf("expected current_input_file.min_chars=12345, got %#v", snap.CurrentInputFile)
|
||||
}
|
||||
if snap.HistorySplit.Enabled == nil || *snap.HistorySplit.Enabled {
|
||||
t.Fatalf("expected current input file to disable history_split, got %#v", snap.HistorySplit.Enabled)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +259,7 @@ func TestUpdateSettingsCurrentInputFilePartialUpdatePreservesMinChars(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettingsRejectsTwoSplitModesEnabled(t *testing.T) {
|
||||
func TestUpdateSettingsIgnoresHistorySplitPayload(t *testing.T) {
|
||||
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
|
||||
payload := map[string]any{
|
||||
"history_split": map[string]any{
|
||||
@@ -306,8 +275,12 @@ func TestUpdateSettingsRejectsTwoSplitModesEnabled(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
|
||||
rec := httptest.NewRecorder()
|
||||
h.updateSettings(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
snap := h.Store.Snapshot()
|
||||
if snap.CurrentInputFile.Enabled == nil || !*snap.CurrentInputFile.Enabled {
|
||||
t.Fatalf("expected current_input_file to remain enabled, got %#v", snap.CurrentInputFile.Enabled)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ func boolFrom(v any) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.CompatConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, *config.HistorySplitConfig, *config.CurrentInputFileConfig, *config.ThinkingInjectionConfig, map[string]string, error) {
|
||||
func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.CompatConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, *config.CurrentInputFileConfig, *config.ThinkingInjectionConfig, map[string]string, error) {
|
||||
var (
|
||||
adminCfg *config.AdminConfig
|
||||
runtimeCfg *config.RuntimeConfig
|
||||
@@ -29,7 +29,6 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
||||
respCfg *config.ResponsesConfig
|
||||
embCfg *config.EmbeddingsConfig
|
||||
autoDeleteCfg *config.AutoDeleteConfig
|
||||
historySplitCfg *config.HistorySplitConfig
|
||||
currentInputCfg *config.CurrentInputFileConfig
|
||||
thinkingInjCfg *config.ThinkingInjectionConfig
|
||||
aliasMap map[string]string
|
||||
@@ -40,7 +39,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
||||
if v, exists := raw["jwt_expire_hours"]; exists {
|
||||
n := intFrom(v)
|
||||
if err := config.ValidateIntRange("admin.jwt_expire_hours", n, 1, 720, true); err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
cfg.JWTExpireHours = n
|
||||
}
|
||||
@@ -52,33 +51,33 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
||||
if v, exists := raw["account_max_inflight"]; exists {
|
||||
n := intFrom(v)
|
||||
if err := config.ValidateIntRange("runtime.account_max_inflight", n, 1, 256, true); err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
cfg.AccountMaxInflight = n
|
||||
}
|
||||
if v, exists := raw["account_max_queue"]; exists {
|
||||
n := intFrom(v)
|
||||
if err := config.ValidateIntRange("runtime.account_max_queue", n, 1, 200000, true); err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
cfg.AccountMaxQueue = n
|
||||
}
|
||||
if v, exists := raw["global_max_inflight"]; exists {
|
||||
n := intFrom(v)
|
||||
if err := config.ValidateIntRange("runtime.global_max_inflight", n, 1, 200000, true); err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
cfg.GlobalMaxInflight = n
|
||||
}
|
||||
if v, exists := raw["token_refresh_interval_hours"]; exists {
|
||||
n := intFrom(v)
|
||||
if err := config.ValidateIntRange("runtime.token_refresh_interval_hours", n, 1, 720, true); err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
cfg.TokenRefreshIntervalHours = n
|
||||
}
|
||||
if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
|
||||
}
|
||||
runtimeCfg = cfg
|
||||
}
|
||||
@@ -101,7 +100,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
||||
if v, exists := raw["store_ttl_seconds"]; exists {
|
||||
n := intFrom(v)
|
||||
if err := config.ValidateIntRange("responses.store_ttl_seconds", n, 30, 86400, true); err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
cfg.StoreTTLSeconds = n
|
||||
}
|
||||
@@ -113,7 +112,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
||||
if v, exists := raw["provider"]; exists {
|
||||
p := strings.TrimSpace(fmt.Sprintf("%v", v))
|
||||
if err := config.ValidateTrimmedString("embeddings.provider", p, false); err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
cfg.Provider = p
|
||||
}
|
||||
@@ -139,7 +138,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
||||
if v, exists := raw["mode"]; exists {
|
||||
mode := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v)))
|
||||
if err := config.ValidateAutoDeleteMode(mode); err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
if mode == "" {
|
||||
mode = "none"
|
||||
@@ -152,25 +151,6 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
||||
autoDeleteCfg = cfg
|
||||
}
|
||||
|
||||
if raw, ok := req["history_split"].(map[string]any); ok {
|
||||
cfg := &config.HistorySplitConfig{}
|
||||
if v, exists := raw["enabled"]; exists {
|
||||
enabled := boolFrom(v)
|
||||
cfg.Enabled = &enabled
|
||||
}
|
||||
if v, exists := raw["trigger_after_turns"]; exists {
|
||||
n := intFrom(v)
|
||||
if err := config.ValidateIntRange("history_split.trigger_after_turns", n, 1, 1000, true); err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
cfg.TriggerAfterTurns = &n
|
||||
}
|
||||
if err := config.ValidateHistorySplitConfig(*cfg); err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
historySplitCfg = cfg
|
||||
}
|
||||
|
||||
if raw, ok := req["current_input_file"].(map[string]any); ok {
|
||||
cfg := &config.CurrentInputFileConfig{}
|
||||
if v, exists := raw["enabled"]; exists {
|
||||
@@ -180,18 +160,15 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
||||
if v, exists := raw["min_chars"]; exists {
|
||||
n := intFrom(v)
|
||||
if err := config.ValidateIntRange("current_input_file.min_chars", n, 0, 100000000, true); err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
cfg.MinChars = n
|
||||
}
|
||||
if err := config.ValidateCurrentInputFileConfig(*cfg); err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
currentInputCfg = cfg
|
||||
}
|
||||
if boolPtrValue(historySplitCfgEnabled(historySplitCfg)) && boolPtrValue(currentInputCfgEnabled(currentInputCfg)) {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("history_split.enabled and current_input_file.enabled cannot both be true")
|
||||
}
|
||||
|
||||
if raw, ok := req["thinking_injection"].(map[string]any); ok {
|
||||
cfg := &config.ThinkingInjectionConfig{}
|
||||
@@ -205,23 +182,5 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
||||
thinkingInjCfg = cfg
|
||||
}
|
||||
|
||||
return adminCfg, runtimeCfg, compatCfg, respCfg, embCfg, autoDeleteCfg, historySplitCfg, currentInputCfg, thinkingInjCfg, aliasMap, nil
|
||||
}
|
||||
|
||||
func historySplitCfgEnabled(cfg *config.HistorySplitConfig) *bool {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
return cfg.Enabled
|
||||
}
|
||||
|
||||
func currentInputCfgEnabled(cfg *config.CurrentInputFileConfig) *bool {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
return cfg.Enabled
|
||||
}
|
||||
|
||||
func boolPtrValue(v *bool) bool {
|
||||
return v != nil && *v
|
||||
return adminCfg, runtimeCfg, compatCfg, respCfg, embCfg, autoDeleteCfg, currentInputCfg, thinkingInjCfg, aliasMap, nil
|
||||
}
|
||||
|
||||
@@ -31,10 +31,6 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
"responses": snap.Responses,
|
||||
"embeddings": snap.Embeddings,
|
||||
"auto_delete": snap.AutoDelete,
|
||||
"history_split": map[string]any{
|
||||
"enabled": h.Store.HistorySplitEnabled(),
|
||||
"trigger_after_turns": h.Store.HistorySplitTriggerAfterTurns(),
|
||||
},
|
||||
"current_input_file": map[string]any{
|
||||
"enabled": h.Store.CurrentInputFileEnabled(),
|
||||
"min_chars": h.Store.CurrentInputFileMinChars(),
|
||||
|
||||
@@ -17,7 +17,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
adminCfg, runtimeCfg, compatCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, historySplitCfg, currentInputCfg, thinkingInjCfg, aliasMap, err := parseSettingsUpdateRequest(req)
|
||||
adminCfg, runtimeCfg, compatCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, currentInputCfg, thinkingInjCfg, aliasMap, err := parseSettingsUpdateRequest(req)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
@@ -71,26 +71,10 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
c.AutoDelete.Mode = autoDeleteCfg.Mode
|
||||
c.AutoDelete.Sessions = autoDeleteCfg.Sessions
|
||||
}
|
||||
if historySplitCfg != nil {
|
||||
if historySplitCfg.Enabled != nil {
|
||||
c.HistorySplit.Enabled = historySplitCfg.Enabled
|
||||
if *historySplitCfg.Enabled {
|
||||
disabled := false
|
||||
c.CurrentInputFile.Enabled = &disabled
|
||||
}
|
||||
}
|
||||
if historySplitCfg.TriggerAfterTurns != nil {
|
||||
c.HistorySplit.TriggerAfterTurns = historySplitCfg.TriggerAfterTurns
|
||||
}
|
||||
}
|
||||
if currentInputCfg != nil {
|
||||
if currentInputEnabledSet {
|
||||
c.CurrentInputFile.Enabled = currentInputCfg.Enabled
|
||||
}
|
||||
if currentInputEnabledSet && currentInputCfg.Enabled != nil && *currentInputCfg.Enabled {
|
||||
disabled := false
|
||||
c.HistorySplit.Enabled = &disabled
|
||||
}
|
||||
if currentInputMinCharsSet {
|
||||
c.CurrentInputFile.MinChars = currentInputCfg.MinChars
|
||||
}
|
||||
|
||||
@@ -272,14 +272,13 @@ func TestChatCompletionsSkipsHistoryWhenDisabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsHistorySplitPersistsHistoryText(t *testing.T) {
|
||||
func TestChatCompletionsCurrentInputFilePersistsNeutralPrompt(t *testing.T) {
|
||||
historyStore := newTestChatHistoryStore(t)
|
||||
ds := &inlineUploadDSStub{}
|
||||
h := &Handler{
|
||||
Store: mockOpenAIConfig{
|
||||
wideInput: true,
|
||||
historySplitEnabled: true,
|
||||
historySplitTurns: 1,
|
||||
currentInputEnabled: true,
|
||||
},
|
||||
Auth: streamStatusAuthStub{},
|
||||
DS: ds,
|
||||
@@ -308,19 +307,19 @@ func TestChatCompletionsHistorySplitPersistsHistoryText(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected detail item, got %v", err)
|
||||
}
|
||||
if full.HistoryText == "" {
|
||||
t.Fatalf("expected history text to be persisted")
|
||||
}
|
||||
if !strings.Contains(full.HistoryText, "first user turn") || !strings.Contains(full.HistoryText, "tool result") {
|
||||
t.Fatalf("expected earlier turns in history text, got %q", full.HistoryText)
|
||||
}
|
||||
if strings.Contains(full.HistoryText, "latest user turn") {
|
||||
t.Fatalf("expected latest turn to stay out of persisted history text, got %q", full.HistoryText)
|
||||
if full.HistoryText != "" {
|
||||
t.Fatalf("expected current input file flow to leave history text empty, got %q", full.HistoryText)
|
||||
}
|
||||
if len(ds.uploadCalls) != 1 {
|
||||
t.Fatalf("expected history upload to happen, got %d", len(ds.uploadCalls))
|
||||
t.Fatalf("expected current input upload to happen, got %d", len(ds.uploadCalls))
|
||||
}
|
||||
if full.HistoryText != string(ds.uploadCalls[0].Data) {
|
||||
t.Fatalf("expected persisted history text to match uploaded HISTORY.txt contents")
|
||||
if ds.uploadCalls[0].Filename != "IGNORE.txt" {
|
||||
t.Fatalf("expected IGNORE.txt upload, got %q", ds.uploadCalls[0].Filename)
|
||||
}
|
||||
if len(full.Messages) != 1 {
|
||||
t.Fatalf("expected neutral prompt to be the only persisted message, got %#v", full.Messages)
|
||||
}
|
||||
if !strings.Contains(full.Messages[0].Content, "Answer the latest user request directly.") {
|
||||
t.Fatalf("expected neutral prompt to be persisted, got %#v", full.Messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,20 +42,17 @@ func (h *Handler) compatStripReferenceMarkers() bool {
|
||||
return shared.CompatStripReferenceMarkers(h.Store)
|
||||
}
|
||||
|
||||
func (h *Handler) applyHistorySplit(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
|
||||
func (h *Handler) applyCurrentInputFile(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
|
||||
if h == nil {
|
||||
return stdReq, nil
|
||||
}
|
||||
stdReq = shared.ApplyThinkingInjection(h.Store, stdReq)
|
||||
svc := history.Service{Store: h.Store, DS: h.DS}
|
||||
out, err := svc.ApplyCurrentInputFile(ctx, a, stdReq)
|
||||
if err != nil {
|
||||
return stdReq, err
|
||||
if err != nil || out.CurrentInputFileApplied {
|
||||
return out, err
|
||||
}
|
||||
if out.CurrentInputFileApplied {
|
||||
return out, nil
|
||||
}
|
||||
return svc.Apply(ctx, a, out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (h *Handler) preprocessInlineFileInputs(ctx context.Context, a *auth.RequestAuth, req map[string]any) error {
|
||||
@@ -91,7 +88,7 @@ func writeOpenAIInlineFileError(w http.ResponseWriter, err error) {
|
||||
files.WriteInlineFileError(w, err)
|
||||
}
|
||||
|
||||
func mapHistorySplitError(err error) (int, string) {
|
||||
func mapCurrentInputFileError(err error) (int, string) {
|
||||
return history.MapError(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -68,9 +68,9 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
writeOpenAIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
stdReq, err = h.applyHistorySplit(r.Context(), a, stdReq)
|
||||
stdReq, err = h.applyCurrentInputFile(r.Context(), a, stdReq)
|
||||
if err != nil {
|
||||
status, message := mapHistorySplitError(err)
|
||||
status, message := mapCurrentInputFileError(err)
|
||||
writeOpenAIError(w, status, message)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ func TestStreamLeaseTTL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVercelStreamPrepareAppliesHistorySplit(t *testing.T) {
|
||||
func TestHandleVercelStreamPrepareAppliesCurrentInputFile(t *testing.T) {
|
||||
t.Setenv("VERCEL", "1")
|
||||
t.Setenv("DS2API_VERCEL_INTERNAL_SECRET", "stream-secret")
|
||||
|
||||
@@ -95,8 +95,7 @@ func TestHandleVercelStreamPrepareAppliesHistorySplit(t *testing.T) {
|
||||
h := &Handler{
|
||||
Store: mockOpenAIConfig{
|
||||
wideInput: true,
|
||||
historySplitEnabled: true,
|
||||
historySplitTurns: 1,
|
||||
currentInputEnabled: true,
|
||||
},
|
||||
Auth: streamStatusAuthStub{},
|
||||
DS: ds,
|
||||
@@ -119,7 +118,7 @@ func TestHandleVercelStreamPrepareAppliesHistorySplit(t *testing.T) {
|
||||
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(ds.uploadCalls) != 1 {
|
||||
t.Fatalf("expected 1 history upload, got %d", len(ds.uploadCalls))
|
||||
t.Fatalf("expected 1 current input upload, got %d", len(ds.uploadCalls))
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
@@ -131,11 +130,11 @@ func TestHandleVercelStreamPrepareAppliesHistorySplit(t *testing.T) {
|
||||
t.Fatalf("expected payload object, got %#v", body["payload"])
|
||||
}
|
||||
promptText, _ := payload["prompt"].(string)
|
||||
if !strings.Contains(promptText, "latest user turn") {
|
||||
t.Fatalf("expected latest user turn in prompt, got %s", promptText)
|
||||
if !strings.Contains(promptText, "Answer the latest user request directly.") {
|
||||
t.Fatalf("expected neutral prompt, got %s", promptText)
|
||||
}
|
||||
if strings.Contains(promptText, "first user turn") {
|
||||
t.Fatalf("expected historical turns removed from prompt, got %s", promptText)
|
||||
if strings.Contains(promptText, "first user turn") || strings.Contains(promptText, "latest user turn") {
|
||||
t.Fatalf("expected original turns hidden from prompt, got %s", promptText)
|
||||
}
|
||||
refIDs, _ := payload["ref_file_ids"].([]any)
|
||||
if len(refIDs) == 0 || refIDs[0] != "file-inline-1" {
|
||||
@@ -143,7 +142,7 @@ func TestHandleVercelStreamPrepareAppliesHistorySplit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVercelStreamPrepareMapsHistorySplitManagedAuthFailureTo401(t *testing.T) {
|
||||
func TestHandleVercelStreamPrepareMapsCurrentInputFileManagedAuthFailureTo401(t *testing.T) {
|
||||
t.Setenv("VERCEL", "1")
|
||||
t.Setenv("DS2API_VERCEL_INTERNAL_SECRET", "stream-secret")
|
||||
|
||||
@@ -153,8 +152,7 @@ func TestHandleVercelStreamPrepareMapsHistorySplitManagedAuthFailureTo401(t *tes
|
||||
h := &Handler{
|
||||
Store: mockOpenAIConfig{
|
||||
wideInput: true,
|
||||
historySplitEnabled: true,
|
||||
historySplitTurns: 1,
|
||||
currentInputEnabled: true,
|
||||
},
|
||||
Auth: streamStatusManagedAuthStub{},
|
||||
DS: ds,
|
||||
|
||||
@@ -69,9 +69,9 @@ func (h *Handler) handleVercelStreamPrepare(w http.ResponseWriter, r *http.Reque
|
||||
writeOpenAIError(w, http.StatusBadRequest, "stream must be true")
|
||||
return
|
||||
}
|
||||
stdReq, err = h.applyHistorySplit(r.Context(), a, stdReq)
|
||||
stdReq, err = h.applyCurrentInputFile(r.Context(), a, stdReq)
|
||||
if err != nil {
|
||||
status, message := mapHistorySplitError(err)
|
||||
status, message := mapCurrentInputFileError(err)
|
||||
writeOpenAIError(w, status, message)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -28,8 +28,7 @@ func (s Service) ApplyCurrentInputFile(ctx context.Context, a *auth.RequestAuth,
|
||||
if index < 0 {
|
||||
return stdReq, nil
|
||||
}
|
||||
historySplitReached := s.Store.HistorySplitEnabled() && wouldSplitHistory(stdReq.Messages, s.Store.HistorySplitTriggerAfterTurns())
|
||||
if len([]rune(text)) < threshold && !historySplitReached {
|
||||
if len([]rune(text)) < threshold {
|
||||
return stdReq, nil
|
||||
}
|
||||
fileText := promptcompat.BuildOpenAICurrentInputContextTranscript(stdReq.Messages)
|
||||
@@ -84,11 +83,6 @@ func latestUserInputForFile(messages []any) (int, string) {
|
||||
return -1, ""
|
||||
}
|
||||
|
||||
func wouldSplitHistory(messages []any, triggerAfterTurns int) bool {
|
||||
_, historyMessages := SplitOpenAIHistoryMessages(messages, triggerAfterTurns)
|
||||
return len(historyMessages) > 0
|
||||
}
|
||||
|
||||
func currentInputFilePrompt() string {
|
||||
return "The current request and prior conversation context have already been provided. Answer the latest user request directly."
|
||||
}
|
||||
|
||||
@@ -2,60 +2,21 @@ package history
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/auth"
|
||||
dsclient "ds2api/internal/deepseek/client"
|
||||
"ds2api/internal/httpapi/openai/shared"
|
||||
"ds2api/internal/promptcompat"
|
||||
)
|
||||
|
||||
const (
|
||||
historySplitFilename = "HISTORY.txt"
|
||||
historySplitContentType = "text/plain; charset=utf-8"
|
||||
historySplitPurpose = "assistants"
|
||||
)
|
||||
|
||||
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) {
|
||||
if s.DS == nil || s.Store == nil || a == nil || !s.Store.HistorySplitEnabled() {
|
||||
return stdReq, nil
|
||||
}
|
||||
|
||||
promptMessages, historyMessages := SplitOpenAIHistoryMessages(stdReq.Messages, s.Store.HistorySplitTriggerAfterTurns())
|
||||
if len(historyMessages) == 0 {
|
||||
return stdReq, nil
|
||||
}
|
||||
|
||||
historyText := promptcompat.BuildOpenAIHistoryTranscript(historyMessages)
|
||||
if strings.TrimSpace(historyText) == "" {
|
||||
return stdReq, errors.New("history split produced empty transcript")
|
||||
}
|
||||
|
||||
result, err := s.DS.UploadFile(ctx, a, dsclient.UploadFileRequest{
|
||||
Filename: historySplitFilename,
|
||||
ContentType: historySplitContentType,
|
||||
Purpose: historySplitPurpose,
|
||||
Data: []byte(historyText),
|
||||
}, 3)
|
||||
if err != nil {
|
||||
return stdReq, fmt.Errorf("upload history file: %w", err)
|
||||
}
|
||||
fileID := strings.TrimSpace(result.ID)
|
||||
if fileID == "" {
|
||||
return stdReq, errors.New("upload history file returned empty file id")
|
||||
}
|
||||
|
||||
stdReq.Messages = promptMessages
|
||||
stdReq.HistoryText = historyText
|
||||
stdReq.RefFileIDs = prependUniqueRefFileID(stdReq.RefFileIDs, fileID)
|
||||
stdReq.FinalPrompt, stdReq.ToolNames = promptcompat.BuildOpenAIPrompt(promptMessages, stdReq.ToolsRaw, "", stdReq.ToolChoice, stdReq.Thinking)
|
||||
return stdReq, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -60,9 +60,9 @@ func (streamStatusManagedAuthStub) DetermineCaller(_ *http.Request) (*auth.Reque
|
||||
|
||||
func (streamStatusManagedAuthStub) Release(_ *auth.RequestAuth) {}
|
||||
|
||||
func TestBuildOpenAIHistoryTranscriptUsesInjectedFileWrapper(t *testing.T) {
|
||||
func TestBuildOpenAICurrentInputContextTranscriptUsesInjectedFileWrapper(t *testing.T) {
|
||||
_, historyMessages := splitOpenAIHistoryMessages(historySplitTestMessages(), 1)
|
||||
transcript := buildOpenAIHistoryTranscript(historyMessages)
|
||||
transcript := buildOpenAICurrentInputContextTranscript(historyMessages)
|
||||
|
||||
if !strings.HasPrefix(transcript, "[file content end]\n\n") {
|
||||
t.Fatalf("expected injected file wrapper prefix, got %q", transcript)
|
||||
@@ -107,7 +107,7 @@ func TestSplitOpenAIHistoryMessagesUsesLatestUserTurn(t *testing.T) {
|
||||
t.Fatalf("expected middle user turn to be moved into history, got %s", promptText)
|
||||
}
|
||||
|
||||
historyText := buildOpenAIHistoryTranscript(historyMessages)
|
||||
historyText := buildOpenAICurrentInputContextTranscript(historyMessages)
|
||||
if !strings.Contains(historyText, "middle user turn") {
|
||||
t.Fatalf("expected middle user turn in split history, got %s", historyText)
|
||||
}
|
||||
@@ -116,13 +116,13 @@ func TestSplitOpenAIHistoryMessagesUsesLatestUserTurn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHistorySplitSkipsFirstTurn(t *testing.T) {
|
||||
func TestApplyCurrentInputFileSkipsShortInputWhenThresholdNotReached(t *testing.T) {
|
||||
ds := &inlineUploadDSStub{}
|
||||
h := &openAITestSurface{
|
||||
Store: mockOpenAIConfig{
|
||||
wideInput: true,
|
||||
historySplitEnabled: true,
|
||||
historySplitTurns: 1,
|
||||
currentInputEnabled: true,
|
||||
currentInputMin: 10,
|
||||
},
|
||||
DS: ds,
|
||||
}
|
||||
@@ -137,9 +137,9 @@ func TestApplyHistorySplitSkipsFirstTurn(t *testing.T) {
|
||||
t.Fatalf("normalize failed: %v", err)
|
||||
}
|
||||
|
||||
out, err := h.applyHistorySplit(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
|
||||
out, err := h.applyCurrentInputFile(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
|
||||
if err != nil {
|
||||
t.Fatalf("apply history split failed: %v", err)
|
||||
t.Fatalf("apply current input file failed: %v", err)
|
||||
}
|
||||
if len(ds.uploadCalls) != 0 {
|
||||
t.Fatalf("expected no upload on first turn, got %d", len(ds.uploadCalls))
|
||||
@@ -153,10 +153,8 @@ func TestApplyThinkingInjectionAppendsLatestUserPrompt(t *testing.T) {
|
||||
ds := &inlineUploadDSStub{}
|
||||
h := &openAITestSurface{
|
||||
Store: mockOpenAIConfig{
|
||||
wideInput: true,
|
||||
historySplitEnabled: true,
|
||||
historySplitTurns: 1,
|
||||
thinkingInjection: boolPtr(true),
|
||||
wideInput: true,
|
||||
thinkingInjection: boolPtr(true),
|
||||
},
|
||||
DS: ds,
|
||||
}
|
||||
@@ -171,7 +169,7 @@ func TestApplyThinkingInjectionAppendsLatestUserPrompt(t *testing.T) {
|
||||
t.Fatalf("normalize failed: %v", err)
|
||||
}
|
||||
|
||||
out, err := h.applyHistorySplit(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
|
||||
out, err := h.applyCurrentInputFile(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
|
||||
if err != nil {
|
||||
t.Fatalf("apply thinking injection failed: %v", err)
|
||||
}
|
||||
@@ -204,7 +202,7 @@ func TestApplyThinkingInjectionUsesCustomPrompt(t *testing.T) {
|
||||
t.Fatalf("normalize failed: %v", err)
|
||||
}
|
||||
|
||||
out, err := h.applyHistorySplit(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
|
||||
out, err := h.applyCurrentInputFile(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
|
||||
if err != nil {
|
||||
t.Fatalf("apply thinking injection failed: %v", err)
|
||||
}
|
||||
@@ -213,12 +211,11 @@ func TestApplyThinkingInjectionUsesCustomPrompt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHistorySplitDirectPassThroughWhenBothSplitsDisabled(t *testing.T) {
|
||||
func TestApplyCurrentInputFileDisabledPassThrough(t *testing.T) {
|
||||
ds := &inlineUploadDSStub{}
|
||||
h := &openAITestSurface{
|
||||
Store: mockOpenAIConfig{
|
||||
wideInput: true,
|
||||
historySplitEnabled: false,
|
||||
currentInputEnabled: false,
|
||||
},
|
||||
DS: ds,
|
||||
@@ -232,9 +229,9 @@ func TestApplyHistorySplitDirectPassThroughWhenBothSplitsDisabled(t *testing.T)
|
||||
t.Fatalf("normalize failed: %v", err)
|
||||
}
|
||||
|
||||
out, err := h.applyHistorySplit(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
|
||||
out, err := h.applyCurrentInputFile(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
|
||||
if err != nil {
|
||||
t.Fatalf("apply history split failed: %v", err)
|
||||
t.Fatalf("apply current input file failed: %v", err)
|
||||
}
|
||||
if len(ds.uploadCalls) != 0 {
|
||||
t.Fatalf("expected no uploads when both split modes are disabled, got %d", len(ds.uploadCalls))
|
||||
@@ -252,8 +249,6 @@ func TestApplyCurrentInputFileUploadsFirstTurnWithInjectedWrapper(t *testing.T)
|
||||
h := &openAITestSurface{
|
||||
Store: mockOpenAIConfig{
|
||||
wideInput: true,
|
||||
historySplitEnabled: true,
|
||||
historySplitTurns: 1,
|
||||
currentInputEnabled: true,
|
||||
currentInputMin: 10,
|
||||
thinkingInjection: boolPtr(true),
|
||||
@@ -271,7 +266,7 @@ func TestApplyCurrentInputFileUploadsFirstTurnWithInjectedWrapper(t *testing.T)
|
||||
t.Fatalf("normalize failed: %v", err)
|
||||
}
|
||||
|
||||
out, err := h.applyHistorySplit(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
|
||||
out, err := h.applyCurrentInputFile(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
|
||||
if err != nil {
|
||||
t.Fatalf("apply current input file failed: %v", err)
|
||||
}
|
||||
@@ -309,15 +304,13 @@ func TestApplyCurrentInputFileUploadsFirstTurnWithInjectedWrapper(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCurrentInputFileReplacesHistorySplitWithFullContextFile(t *testing.T) {
|
||||
func TestApplyCurrentInputFileUploadsFullContextFile(t *testing.T) {
|
||||
ds := &inlineUploadDSStub{}
|
||||
h := &openAITestSurface{
|
||||
Store: mockOpenAIConfig{
|
||||
wideInput: true,
|
||||
historySplitEnabled: true,
|
||||
historySplitTurns: 1,
|
||||
currentInputEnabled: true,
|
||||
currentInputMin: 1000,
|
||||
currentInputMin: 0,
|
||||
thinkingInjection: boolPtr(true),
|
||||
},
|
||||
DS: ds,
|
||||
@@ -331,12 +324,12 @@ func TestApplyCurrentInputFileReplacesHistorySplitWithFullContextFile(t *testing
|
||||
t.Fatalf("normalize failed: %v", err)
|
||||
}
|
||||
|
||||
out, err := h.applyHistorySplit(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
|
||||
out, err := h.applyCurrentInputFile(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
|
||||
if err != nil {
|
||||
t.Fatalf("apply current input file failed: %v", err)
|
||||
}
|
||||
if !out.CurrentInputFileApplied {
|
||||
t.Fatalf("expected current input file to replace history split")
|
||||
t.Fatalf("expected current input file to apply")
|
||||
}
|
||||
if len(ds.uploadCalls) != 1 {
|
||||
t.Fatalf("expected one current input upload, got %d", len(ds.uploadCalls))
|
||||
@@ -351,9 +344,6 @@ func TestApplyCurrentInputFileReplacesHistorySplitWithFullContextFile(t *testing
|
||||
t.Fatalf("expected full context file to contain %q, got %q", want, uploadedText)
|
||||
}
|
||||
}
|
||||
if out.HistoryText != "" {
|
||||
t.Fatalf("expected no HISTORY transcript when current input file replaces split, got %q", out.HistoryText)
|
||||
}
|
||||
if strings.Contains(out.FinalPrompt, "first user turn") || strings.Contains(out.FinalPrompt, "latest user turn") || strings.Contains(out.FinalPrompt, "CURRENT_USER_INPUT.txt") || strings.Contains(out.FinalPrompt, "IGNORE.txt") || strings.Contains(out.FinalPrompt, "Read that file") {
|
||||
t.Fatalf("expected live prompt to use only a neutral continuation instruction, got %s", out.FinalPrompt)
|
||||
}
|
||||
@@ -362,13 +352,12 @@ func TestApplyCurrentInputFileReplacesHistorySplitWithFullContextFile(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHistorySplitCarriesHistoryText(t *testing.T) {
|
||||
func TestApplyCurrentInputFileLeavesHistoryTextEmpty(t *testing.T) {
|
||||
ds := &inlineUploadDSStub{}
|
||||
h := &openAITestSurface{
|
||||
Store: mockOpenAIConfig{
|
||||
wideInput: true,
|
||||
historySplitEnabled: true,
|
||||
historySplitTurns: 1,
|
||||
currentInputEnabled: true,
|
||||
},
|
||||
DS: ds,
|
||||
}
|
||||
@@ -381,25 +370,24 @@ func TestApplyHistorySplitCarriesHistoryText(t *testing.T) {
|
||||
t.Fatalf("normalize failed: %v", err)
|
||||
}
|
||||
|
||||
out, err := h.applyHistorySplit(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
|
||||
out, err := h.applyCurrentInputFile(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
|
||||
if err != nil {
|
||||
t.Fatalf("apply history split failed: %v", err)
|
||||
t.Fatalf("apply current input file failed: %v", err)
|
||||
}
|
||||
if len(ds.uploadCalls) != 1 {
|
||||
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
|
||||
}
|
||||
if out.HistoryText != string(ds.uploadCalls[0].Data) {
|
||||
t.Fatalf("expected history text to be preserved on normalized request")
|
||||
if out.HistoryText != "" {
|
||||
t.Fatalf("expected current input file flow to leave history text empty, got %q", out.HistoryText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsHistorySplitUploadsHistoryFileAndKeepsLatestPrompt(t *testing.T) {
|
||||
func TestChatCompletionsCurrentInputFileUploadsContextAndKeepsNeutralPrompt(t *testing.T) {
|
||||
ds := &inlineUploadDSStub{}
|
||||
h := &openAITestSurface{
|
||||
Store: mockOpenAIConfig{
|
||||
wideInput: true,
|
||||
historySplitEnabled: true,
|
||||
historySplitTurns: 1,
|
||||
currentInputEnabled: true,
|
||||
},
|
||||
Auth: streamStatusAuthStub{},
|
||||
DS: ds,
|
||||
@@ -423,7 +411,7 @@ func TestChatCompletionsHistorySplitUploadsHistoryFileAndKeepsLatestPrompt(t *te
|
||||
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
|
||||
}
|
||||
upload := ds.uploadCalls[0]
|
||||
if upload.Filename != "HISTORY.txt" {
|
||||
if upload.Filename != "IGNORE.txt" {
|
||||
t.Fatalf("unexpected upload filename: %q", upload.Filename)
|
||||
}
|
||||
if upload.Purpose != "assistants" {
|
||||
@@ -433,32 +421,31 @@ func TestChatCompletionsHistorySplitUploadsHistoryFileAndKeepsLatestPrompt(t *te
|
||||
if !strings.Contains(historyText, "[file content end]") || !strings.Contains(historyText, "[file name]: IGNORE") {
|
||||
t.Fatalf("expected injected IGNORE wrapper, got %s", historyText)
|
||||
}
|
||||
if strings.Contains(historyText, "latest user turn") {
|
||||
t.Fatalf("expected latest turn to remain live, got %s", historyText)
|
||||
if !strings.Contains(historyText, "latest user turn") {
|
||||
t.Fatalf("expected full context to include latest turn, got %s", historyText)
|
||||
}
|
||||
if ds.completionReq == nil {
|
||||
t.Fatal("expected completion payload to be captured")
|
||||
}
|
||||
promptText, _ := ds.completionReq["prompt"].(string)
|
||||
if !strings.Contains(promptText, "latest user turn") {
|
||||
t.Fatalf("expected latest turn in completion prompt, got %s", promptText)
|
||||
if !strings.Contains(promptText, "Answer the latest user request directly.") {
|
||||
t.Fatalf("expected neutral completion prompt, got %s", promptText)
|
||||
}
|
||||
if strings.Contains(promptText, "first user turn") {
|
||||
t.Fatalf("expected historical turns removed from completion prompt, got %s", promptText)
|
||||
if strings.Contains(promptText, "first user turn") || strings.Contains(promptText, "latest user turn") {
|
||||
t.Fatalf("expected prompt to hide original turns, got %s", promptText)
|
||||
}
|
||||
refIDs, _ := ds.completionReq["ref_file_ids"].([]any)
|
||||
if len(refIDs) == 0 || refIDs[0] != "file-inline-1" {
|
||||
t.Fatalf("expected uploaded history file to be first ref_file_id, got %#v", ds.completionReq["ref_file_ids"])
|
||||
t.Fatalf("expected uploaded current input file to be first ref_file_id, got %#v", ds.completionReq["ref_file_ids"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesHistorySplitUploadsHistoryAndKeepsLatestPrompt(t *testing.T) {
|
||||
func TestResponsesCurrentInputFileUploadsContextAndKeepsNeutralPrompt(t *testing.T) {
|
||||
ds := &inlineUploadDSStub{}
|
||||
h := &openAITestSurface{
|
||||
Store: mockOpenAIConfig{
|
||||
wideInput: true,
|
||||
historySplitEnabled: true,
|
||||
historySplitTurns: 1,
|
||||
currentInputEnabled: true,
|
||||
},
|
||||
Auth: streamStatusAuthStub{},
|
||||
DS: ds,
|
||||
@@ -487,23 +474,22 @@ func TestResponsesHistorySplitUploadsHistoryAndKeepsLatestPrompt(t *testing.T) {
|
||||
t.Fatal("expected completion payload to be captured")
|
||||
}
|
||||
promptText, _ := ds.completionReq["prompt"].(string)
|
||||
if !strings.Contains(promptText, "latest user turn") {
|
||||
t.Fatalf("expected latest turn in completion prompt, got %s", promptText)
|
||||
if !strings.Contains(promptText, "Answer the latest user request directly.") {
|
||||
t.Fatalf("expected neutral completion prompt, got %s", promptText)
|
||||
}
|
||||
if strings.Contains(promptText, "first user turn") {
|
||||
t.Fatalf("expected historical turns removed from completion prompt, got %s", promptText)
|
||||
if strings.Contains(promptText, "first user turn") || strings.Contains(promptText, "latest user turn") {
|
||||
t.Fatalf("expected prompt to hide original turns, got %s", promptText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsHistorySplitMapsManagedAuthFailureTo401(t *testing.T) {
|
||||
func TestChatCompletionsCurrentInputFileMapsManagedAuthFailureTo401(t *testing.T) {
|
||||
ds := &inlineUploadDSStub{
|
||||
uploadErr: &dsclient.RequestFailure{Op: "upload file", Kind: dsclient.FailureManagedUnauthorized, Message: "expired token"},
|
||||
}
|
||||
h := &openAITestSurface{
|
||||
Store: mockOpenAIConfig{
|
||||
wideInput: true,
|
||||
historySplitEnabled: true,
|
||||
historySplitTurns: 1,
|
||||
currentInputEnabled: true,
|
||||
},
|
||||
Auth: streamStatusManagedAuthStub{},
|
||||
DS: ds,
|
||||
@@ -528,15 +514,14 @@ func TestChatCompletionsHistorySplitMapsManagedAuthFailureTo401(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesHistorySplitMapsDirectAuthFailureTo401(t *testing.T) {
|
||||
func TestResponsesCurrentInputFileMapsDirectAuthFailureTo401(t *testing.T) {
|
||||
ds := &inlineUploadDSStub{
|
||||
uploadErr: &dsclient.RequestFailure{Op: "upload file", Kind: dsclient.FailureDirectUnauthorized, Message: "invalid token"},
|
||||
}
|
||||
h := &openAITestSurface{
|
||||
Store: mockOpenAIConfig{
|
||||
wideInput: true,
|
||||
historySplitEnabled: true,
|
||||
historySplitTurns: 1,
|
||||
currentInputEnabled: true,
|
||||
},
|
||||
Auth: streamStatusAuthStub{},
|
||||
DS: ds,
|
||||
@@ -563,13 +548,12 @@ func TestResponsesHistorySplitMapsDirectAuthFailureTo401(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsHistorySplitUploadFailureReturnsInternalServerError(t *testing.T) {
|
||||
func TestChatCompletionsCurrentInputFileUploadFailureReturnsInternalServerError(t *testing.T) {
|
||||
ds := &inlineUploadDSStub{uploadErr: errors.New("boom")}
|
||||
h := &openAITestSurface{
|
||||
Store: mockOpenAIConfig{
|
||||
wideInput: true,
|
||||
historySplitEnabled: true,
|
||||
historySplitTurns: 1,
|
||||
currentInputEnabled: true,
|
||||
},
|
||||
Auth: streamStatusAuthStub{},
|
||||
DS: ds,
|
||||
@@ -591,7 +575,7 @@ func TestChatCompletionsHistorySplitUploadFailureReturnsInternalServerError(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistorySplitWorksAcrossAutoDeleteModes(t *testing.T) {
|
||||
func TestCurrentInputFileWorksAcrossAutoDeleteModes(t *testing.T) {
|
||||
for _, mode := range []string{"none", "single", "all"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
ds := &inlineUploadDSStub{}
|
||||
@@ -599,8 +583,7 @@ func TestHistorySplitWorksAcrossAutoDeleteModes(t *testing.T) {
|
||||
Store: mockOpenAIConfig{
|
||||
wideInput: true,
|
||||
autoDeleteMode: mode,
|
||||
historySplitEnabled: true,
|
||||
historySplitTurns: 1,
|
||||
currentInputEnabled: true,
|
||||
},
|
||||
Auth: streamStatusAuthStub{},
|
||||
DS: ds,
|
||||
@@ -621,13 +604,13 @@ func TestHistorySplitWorksAcrossAutoDeleteModes(t *testing.T) {
|
||||
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(ds.uploadCalls) != 1 {
|
||||
t.Fatalf("expected history split upload for mode=%s, got %d", mode, len(ds.uploadCalls))
|
||||
t.Fatalf("expected current input upload for mode=%s, got %d", mode, len(ds.uploadCalls))
|
||||
}
|
||||
if ds.completionReq == nil {
|
||||
t.Fatalf("expected completion payload for mode=%s", mode)
|
||||
}
|
||||
promptText, _ := ds.completionReq["prompt"].(string)
|
||||
if !strings.Contains(promptText, "latest user turn") || strings.Contains(promptText, "first user turn") {
|
||||
if !strings.Contains(promptText, "Answer the latest user request directly.") || strings.Contains(promptText, "first user turn") || strings.Contains(promptText, "latest user turn") {
|
||||
t.Fatalf("unexpected prompt for mode=%s: %s", mode, promptText)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -36,20 +36,17 @@ func (h *Handler) compatStripReferenceMarkers() bool {
|
||||
return shared.CompatStripReferenceMarkers(h.Store)
|
||||
}
|
||||
|
||||
func (h *Handler) applyHistorySplit(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
|
||||
func (h *Handler) applyCurrentInputFile(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
|
||||
if h == nil {
|
||||
return stdReq, nil
|
||||
}
|
||||
stdReq = shared.ApplyThinkingInjection(h.Store, stdReq)
|
||||
svc := history.Service{Store: h.Store, DS: h.DS}
|
||||
out, err := svc.ApplyCurrentInputFile(ctx, a, stdReq)
|
||||
if err != nil {
|
||||
return stdReq, err
|
||||
if err != nil || out.CurrentInputFileApplied {
|
||||
return out, err
|
||||
}
|
||||
if out.CurrentInputFileApplied {
|
||||
return out, nil
|
||||
}
|
||||
return svc.Apply(ctx, a, out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (h *Handler) preprocessInlineFileInputs(ctx context.Context, a *auth.RequestAuth, req map[string]any) error {
|
||||
@@ -89,7 +86,7 @@ func writeOpenAIInlineFileError(w http.ResponseWriter, err error) {
|
||||
files.WriteInlineFileError(w, err)
|
||||
}
|
||||
|
||||
func mapHistorySplitError(err error) (int, string) {
|
||||
func mapCurrentInputFileError(err error) (int, string) {
|
||||
return history.MapError(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -85,9 +85,9 @@ func (h *Handler) Responses(w http.ResponseWriter, r *http.Request) {
|
||||
writeOpenAIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
stdReq, err = h.applyHistorySplit(r.Context(), a, stdReq)
|
||||
stdReq, err = h.applyCurrentInputFile(r.Context(), a, stdReq)
|
||||
if err != nil {
|
||||
status, message := mapHistorySplitError(err)
|
||||
status, message := mapCurrentInputFileError(err)
|
||||
writeOpenAIError(w, status, message)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -83,17 +83,14 @@ func (h *openAITestSurface) ChatCompletions(w http.ResponseWriter, r *http.Reque
|
||||
h.chatHandler().ChatCompletions(w, r)
|
||||
}
|
||||
|
||||
func (h *openAITestSurface) applyHistorySplit(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
|
||||
func (h *openAITestSurface) applyCurrentInputFile(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
|
||||
stdReq = shared.ApplyThinkingInjection(h.Store, stdReq)
|
||||
svc := history.Service{Store: h.Store, DS: h.DS}
|
||||
out, err := svc.ApplyCurrentInputFile(ctx, a, stdReq)
|
||||
if err != nil {
|
||||
return stdReq, err
|
||||
if err != nil || out.CurrentInputFileApplied {
|
||||
return out, err
|
||||
}
|
||||
if out.CurrentInputFileApplied {
|
||||
return out, nil
|
||||
}
|
||||
return svc.Apply(ctx, a, out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (h *openAITestSurface) preprocessInlineFileInputs(ctx context.Context, a *auth.RequestAuth, req map[string]any) error {
|
||||
@@ -114,8 +111,8 @@ func splitOpenAIHistoryMessages(messages []any, triggerAfterTurns int) ([]any, [
|
||||
return history.SplitOpenAIHistoryMessages(messages, triggerAfterTurns)
|
||||
}
|
||||
|
||||
func buildOpenAIHistoryTranscript(messages []any) string {
|
||||
return promptcompat.BuildOpenAIHistoryTranscript(messages)
|
||||
func buildOpenAICurrentInputContextTranscript(messages []any) string {
|
||||
return promptcompat.BuildOpenAICurrentInputContextTranscript(messages)
|
||||
}
|
||||
|
||||
func writeOpenAIError(w http.ResponseWriter, status int, message string) {
|
||||
|
||||
@@ -1,51 +1,9 @@
|
||||
export default function HistorySplitSection({ t, form, setForm }) {
|
||||
export default function CurrentInputFileSection({ t, form, setForm }) {
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold">{t('settings.historySplitTitle')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('settings.historySplitDesc')}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label className="flex items-start gap-3 rounded-lg border border-border bg-background/60 p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(form.history_split?.enabled)}
|
||||
onChange={(e) => setForm((prev) => ({
|
||||
...prev,
|
||||
history_split: {
|
||||
...prev.history_split,
|
||||
enabled: e.target.checked,
|
||||
},
|
||||
current_input_file: {
|
||||
...prev.current_input_file,
|
||||
enabled: e.target.checked ? false : Boolean(prev.current_input_file?.enabled),
|
||||
},
|
||||
}))}
|
||||
className="mt-1 h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-medium block">{t('settings.historySplitEnabled')}</span>
|
||||
<span className="text-xs text-muted-foreground block">{t('settings.historySplitEnabledDesc')}</span>
|
||||
</div>
|
||||
</label>
|
||||
<label className="text-sm space-y-2">
|
||||
<span className="text-muted-foreground">{t('settings.historySplitTriggerAfterTurns')}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={form.history_split?.trigger_after_turns || 1}
|
||||
onChange={(e) => setForm((prev) => ({
|
||||
...prev,
|
||||
history_split: {
|
||||
...prev.history_split,
|
||||
trigger_after_turns: Number(e.target.value || 1),
|
||||
},
|
||||
}))}
|
||||
className="w-full bg-background border border-border rounded-lg px-3 py-2"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.historySplitTriggerHelp')}</p>
|
||||
</label>
|
||||
<h3 className="font-semibold">{t('settings.currentInputFileTitle')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('settings.currentInputFileDesc')}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label className="flex items-start gap-3 rounded-lg border border-border bg-background/60 p-4">
|
||||
@@ -54,10 +12,6 @@ export default function HistorySplitSection({ t, form, setForm }) {
|
||||
checked={Boolean(form.current_input_file?.enabled)}
|
||||
onChange={(e) => setForm((prev) => ({
|
||||
...prev,
|
||||
history_split: {
|
||||
...prev.history_split,
|
||||
enabled: e.target.checked ? false : Boolean(prev.history_split?.enabled),
|
||||
},
|
||||
current_input_file: {
|
||||
...prev.current_input_file,
|
||||
enabled: e.target.checked,
|
||||
@@ -89,7 +43,6 @@ export default function HistorySplitSection({ t, form, setForm }) {
|
||||
<p className="text-xs text-muted-foreground">{t('settings.currentInputFileHelp')}</p>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.splitPassThroughHelp')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSettingsForm } from './useSettingsForm'
|
||||
import SecuritySection from './SecuritySection'
|
||||
import RuntimeSection from './RuntimeSection'
|
||||
import BehaviorSection from './BehaviorSection'
|
||||
import HistorySplitSection from './HistorySplitSection'
|
||||
import CurrentInputFileSection from './HistorySplitSection'
|
||||
import CompatibilitySection from './CompatibilitySection'
|
||||
import AutoDeleteSection from './AutoDeleteSection'
|
||||
import ModelSection from './ModelSection'
|
||||
@@ -96,7 +96,7 @@ export default function SettingsContainer({ onRefresh, onMessage, authFetch, onF
|
||||
|
||||
<BehaviorSection t={t} form={form} setForm={setForm} />
|
||||
|
||||
<HistorySplitSection t={t} form={form} setForm={setForm} />
|
||||
<CurrentInputFileSection t={t} form={form} setForm={setForm} />
|
||||
|
||||
<CompatibilitySection t={t} form={form} setForm={setForm} />
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ const DEFAULT_FORM = {
|
||||
responses: { store_ttl_seconds: 900 },
|
||||
embeddings: { provider: '' },
|
||||
auto_delete: { mode: 'none' },
|
||||
history_split: { enabled: false, trigger_after_turns: 1 },
|
||||
current_input_file: { enabled: true, min_chars: 0 },
|
||||
thinking_injection: { enabled: true, prompt: '', default_prompt: '' },
|
||||
model_aliases_text: '{}',
|
||||
@@ -52,8 +51,7 @@ function normalizeAutoDeleteMode(raw) {
|
||||
}
|
||||
|
||||
function fromServerForm(data) {
|
||||
const historySplitEnabled = Boolean(data.history_split?.enabled)
|
||||
const currentInputFileEnabled = historySplitEnabled ? false : (data.current_input_file?.enabled ?? true)
|
||||
const currentInputFileEnabled = data.current_input_file?.enabled ?? true
|
||||
return {
|
||||
admin: { jwt_expire_hours: Number(data.admin?.jwt_expire_hours || 24) },
|
||||
runtime: {
|
||||
@@ -74,10 +72,6 @@ function fromServerForm(data) {
|
||||
auto_delete: {
|
||||
mode: normalizeAutoDeleteMode(data.auto_delete),
|
||||
},
|
||||
history_split: {
|
||||
enabled: historySplitEnabled,
|
||||
trigger_after_turns: Number(data.history_split?.trigger_after_turns || 1),
|
||||
},
|
||||
current_input_file: {
|
||||
enabled: currentInputFileEnabled,
|
||||
min_chars: Number(data.current_input_file?.min_chars ?? 0),
|
||||
@@ -92,8 +86,7 @@ function fromServerForm(data) {
|
||||
}
|
||||
|
||||
function toServerPayload(form) {
|
||||
const historySplitEnabled = Boolean(form.history_split?.enabled)
|
||||
const currentInputFileEnabled = historySplitEnabled ? false : Boolean(form.current_input_file?.enabled)
|
||||
const currentInputFileEnabled = Boolean(form.current_input_file?.enabled)
|
||||
return {
|
||||
admin: { jwt_expire_hours: Number(form.admin.jwt_expire_hours) },
|
||||
runtime: {
|
||||
@@ -108,10 +101,6 @@ function toServerPayload(form) {
|
||||
responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) },
|
||||
embeddings: { provider: String(form.embeddings.provider || '').trim() },
|
||||
auto_delete: { mode: normalizeAutoDeleteMode(form.auto_delete) },
|
||||
history_split: {
|
||||
enabled: historySplitEnabled,
|
||||
trigger_after_turns: Number(form.history_split?.trigger_after_turns || 1),
|
||||
},
|
||||
current_input_file: {
|
||||
enabled: currentInputFileEnabled,
|
||||
min_chars: Number(form.current_input_file?.min_chars ?? 0),
|
||||
|
||||
@@ -385,17 +385,11 @@
|
||||
"thinkingInjectionDesc": "Append a structured <think> checklist to the latest user message before prompt assembly.",
|
||||
"thinkingInjectionPrompt": "Thinking format prompt",
|
||||
"thinkingInjectionPromptHelp": "Leave empty to use the built-in default prompt shown as the input placeholder.",
|
||||
"historySplitTitle": "Context Split",
|
||||
"historySplitDesc": "Choose one context-splitting mode to avoid inlining very long prompts.",
|
||||
"historySplitEnabled": "Turn split (second turn by default)",
|
||||
"historySplitEnabledDesc": "After the configured user-turn threshold, pack earlier conversation into HISTORY.txt.",
|
||||
"historySplitTriggerAfterTurns": "Trigger threshold (user turns)",
|
||||
"historySplitTriggerHelp": "Default is 1, which means history split starts from the second turn.",
|
||||
"currentInputFileTitle": "Independent Split",
|
||||
"currentInputFileEnabled": "Independent split (by size)",
|
||||
"currentInputFileDesc": "After the character threshold is reached, upload the full context as a hidden context file and skip HISTORY.txt.",
|
||||
"currentInputFileDesc": "Enabled by default. Once the character threshold is reached, upload the full context as a hidden context file.",
|
||||
"currentInputFileMinChars": "Current input threshold (characters)",
|
||||
"currentInputFileHelp": "Default is 0, which uses independent split whenever there is input.",
|
||||
"splitPassThroughHelp": "Turn split and independent split are mutually exclusive; choose at most one. If both are unchecked, requests pass through directly without uploading split context files.",
|
||||
"currentInputFileHelp": "Default is 0, which uses independent split for any non-empty input.",
|
||||
"compatibilityTitle": "Compatibility",
|
||||
"compatibilityDesc": "Compatibility controls that keep stream output closer to the wire format or safer for the web UI.",
|
||||
"stripReferenceMarkers": "Strip [reference:N] markers",
|
||||
|
||||
@@ -385,17 +385,11 @@
|
||||
"thinkingInjectionDesc": "在组装 prompt 前,将结构化 <think> 检查清单追加到最新用户消息末尾。",
|
||||
"thinkingInjectionPrompt": "思考格式提示词",
|
||||
"thinkingInjectionPromptHelp": "留空时使用内置默认提示词;默认内容会显示在输入框占位文本中。",
|
||||
"historySplitTitle": "上下文拆分",
|
||||
"historySplitDesc": "选择一种上下文拆分方式,减少超长 prompt 直接内联。",
|
||||
"historySplitEnabled": "轮次拆分(默认第二轮)",
|
||||
"historySplitEnabledDesc": "从配置的用户回合数之后,将更早的对话整理成 HISTORY.txt。",
|
||||
"historySplitTriggerAfterTurns": "触发阈值(用户回合数)",
|
||||
"historySplitTriggerHelp": "默认值为 1,表示从第二轮开始拆分历史。",
|
||||
"currentInputFileTitle": "独立拆分",
|
||||
"currentInputFileEnabled": "独立拆分(按量)",
|
||||
"currentInputFileDesc": "达到字符阈值后,将完整上下文上传为隐藏上下文文件,并跳过 HISTORY.txt。",
|
||||
"currentInputFileDesc": "默认开启。达到字符阈值后,将完整上下文上传为隐藏上下文文件。",
|
||||
"currentInputFileMinChars": "当前输入阈值(字符数)",
|
||||
"currentInputFileHelp": "默认 0,表示有输入时就使用独立拆分。",
|
||||
"splitPassThroughHelp": "轮次拆分和独立拆分互斥,只能选择一种;如果都不勾选,请求会直接透传,不上传拆分上下文文件。",
|
||||
"currentInputFileHelp": "默认 0,表示只要有输入就会使用独立拆分。",
|
||||
"compatibilityTitle": "兼容性设置",
|
||||
"compatibilityDesc": "用于控制输出格式兼容性,避免把模型原始流里的标记直接暴露到前端。",
|
||||
"stripReferenceMarkers": "移除 [reference:N] 标记",
|
||||
|
||||
Reference in New Issue
Block a user