Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions API.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -772,6 +772,7 @@ Reads runtime settings and status, including:
- `auto_delete` (`mode`: `none` / `single` / `all`; legacy `sessions=true` is still treated as `all`)
- `current_input_file` (`enabled` defaults to `true`, plus `min_chars`; `inline_max_tokens` defaults to `30000`; `filename_policy` defaults to `neutral_random`)
- `thinking_injection` (`enabled` defaults to `false`, `prompt`, and `default_prompt`)
- `tool_calling` (`enabled` defaults to `true`; `disabled_behavior` supports `reject` / `ignore_tools`, defaulting to `reject`)
- `model_aliases`
- `env_backed`, `needs_vercel_sync`
- `context_engine` (`mode`: `off` / `shadow` / `enforce`, defaults to `enforce`; `strategy`: `raw_transcript` / `natural_context` / `context_capsule` / `hybrid_recent` / `auto`, defaults to `hybrid_recent`; `env_override`: whether an env var is active)
Expand All @@ -789,6 +790,7 @@ Hot-updates runtime settings. Supported fields:
- `auto_delete.mode`
- `current_input_file.enabled` / `current_input_file.min_chars` / `current_input_file.inline_max_tokens` / `current_input_file.filename_policy`
- `thinking_injection.enabled` / `thinking_injection.prompt`
- `tool_calling.enabled` / `tool_calling.disabled_behavior`
- `context_engine.mode` / `context_engine.strategy`
- `model_aliases`
- `toolcall` policy is fixed and is no longer writable through settings
Expand Down
2 changes: 2 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,7 @@ data: {"type":"message_stop"}
- `auto_delete`(`mode`:`none` / `single` / `all`;旧配置 `sessions=true` 仍按 `all` 处理)
- `current_input_file`(`enabled` 默认返回 `true`、`min_chars`、`inline_max_tokens` 默认返回 `30000`、`filename_policy` 默认返回 `neutral_random`)
- `thinking_injection`(`enabled` 默认返回 `false`、`prompt`、`default_prompt`)
- `tool_calling`(`enabled` 默认返回 `true`;`disabled_behavior` 支持 `reject` / `ignore_tools`,默认 `reject`)
- `model_aliases`
- `env_backed`、`needs_vercel_sync`
- `context_engine`(`mode`:`off` / `shadow` / `enforce`,默认 `enforce`;`strategy`:`raw_transcript` / `natural_context` / `context_capsule` / `hybrid_recent` / `auto`,默认 `hybrid_recent`;`env_override`:是否被环境变量覆盖)
Expand All @@ -795,6 +796,7 @@ data: {"type":"message_stop"}
- `auto_delete.mode`
- `current_input_file.enabled` / `current_input_file.min_chars` / `current_input_file.inline_max_tokens` / `current_input_file.filename_policy`
- `thinking_injection.enabled` / `thinking_injection.prompt`
- `tool_calling.enabled` / `tool_calling.disabled_behavior`
- `context_engine.mode` / `context_engine.strategy`
- `model_aliases`
- `toolcall` 策略已固定,不再作为可写入字段
Expand Down
1 change: 1 addition & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ Common fields:
- When the full context exceeds the inline threshold and the latest user turn satisfies `min_chars`, DS2API generates conversation context / tool reference files. The default `filename_policy` is `neutral_random`; legacy implementation filenames are used only when `filename_policy=legacy`.
- If you turn off `current_input_file`, requests pass through directly without uploading any split context file.
- `thinking_injection`: disabled by default. It appends the enhanced reasoning prompt to the latest user message only when `thinking_injection.enabled=true`.
- `tool_calling`: enabled by default. When disabled, requests with `tools` or forced `tool_choice` are rejected by default; `disabled_behavior=ignore_tools` strips tools and continues as plain chat.
- `parser_v2.mode`: Tool Parser v2 gradual switch, supporting `off` / `shadow` / `enforce`; it is safely off by default and can be overridden with `DS2API_PARSER_V2`.
- `context_engine.mode`: Context Engine gradual switch, supporting `off` / `shadow` / `enforce`; it defaults to `enforce`, with `context_engine.strategy=hybrid_recent` by default.

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ go run ./cmd/ds2api
- 当整体上下文超过 inline 阈值且最新 user turn 满足 `min_chars` 时,才会生成 conversation context / tool reference 文件;默认 `filename_policy=neutral_random`,只在显式设为 `legacy` 时使用旧文件名。
- 如果关闭 `current_input_file`,请求会直接透传,不上传拆分上下文文件。
- `thinking_injection`:默认关闭;只有显式设置 `thinking_injection.enabled=true` 时,才会在最新 user 消息末尾追加增强提示词;`prompt` 留空时使用内置默认提示词。
- `tool_calling`:默认开启。关闭后默认拒绝携带 `tools` / 强制 `tool_choice` 的请求;如设置 `disabled_behavior=ignore_tools`,会剥离 tools 并按普通对话继续。
- `parser_v2.mode`:Tool Parser v2 渐进开关,支持 `off` / `shadow` / `enforce`,默认安全关闭;可用环境变量 `DS2API_PARSER_V2` 覆盖。
- `context_engine.mode`:Context Engine 渐进开关,支持 `off` / `shadow` / `enforce`,默认 `enforce`;`context_engine.strategy` 默认 `hybrid_recent`,可用环境变量 `DS2API_CONTEXT_ENGINE` / `DS2API_CONTEXT_ENGINE_STRATEGY` 覆盖。
- `log`:日志文件输出与轮转配置;`file` 为空时使用默认 `logs/ds2api.log`,`max_size_mb` 有上限校验。
Expand Down
4 changes: 4 additions & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@
"enabled": false,
"prompt": ""
},
"tool_calling": {
"enabled": true,
"disabled_behavior": "reject"
},
"embeddings": {
"provider": "deterministic"
},
Expand Down
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
- [API -> 网页对话纯文本兼容主链路说明](./prompt-compatibility.md)
- [Context Engine 策略验收说明](./context-engine-strategies.md)
- [Tool Calling 统一语义](./toolcall-semantics.md)
- [Feature Switch Roadmap](./feature-switch-roadmap.md)
- [DeepSeek SSE 行为结构说明(逆向观察)](./DeepSeekSSE行为结构说明-2026-04-05.md)

### v2 开发规划
Expand Down Expand Up @@ -67,6 +68,7 @@ Recommended reading order:
- [API -> pure-text web-chat compatibility pipeline](./prompt-compatibility.md)
- [Context Engine strategy acceptance guide](./context-engine-strategies.md)
- [Tool-calling unified semantics](./toolcall-semantics.md)
- [Feature Switch Roadmap](./feature-switch-roadmap.md)
- [DeepSeek SSE behavior notes (reverse-engineered)](./DeepSeekSSE行为结构说明-2026-04-05.md)

### v2 development planning (Chinese only)
Expand Down
40 changes: 40 additions & 0 deletions docs/feature-switch-roadmap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Feature Switch Roadmap

文档导航:[文档索引](./README.md) / [M4/M5 执行规划](./m4-development-plan.md) / [Tool Calling 统一语义](./toolcall-semantics.md)

本文把近期讨论的“后续需要补充开关”拆成可堆叠 PR 的开发清单。原则是先补高风险、可回滚、影响面清晰的开关,再推进能力矩阵和诊断产品化。

## 当前 PR:Tool Calling 全局开关

目标:管理员可在配置和 WebUI 中关闭 tools 主链路。

范围:

- 新增 `tool_calling.enabled`,默认 `true`。
- 新增 `tool_calling.disabled_behavior`,支持 `reject` / `ignore_tools`,默认 `reject`。
- 覆盖 OpenAI Chat、OpenAI Responses、Claude、Gemini 的请求标准化。
- 关闭后同步禁止非流式和流式收尾的工具调用解析。
- WebUI Settings 增加可编辑配置。

验收:

- 普通对话在关闭 tools 后仍可执行。
- 携带 tools 的请求默认被拒绝。
- `ignore_tools` 模式会剥离 tools,不向模型暴露工具说明,也不解析输出工具调用。
- API / README / prompt compatibility / toolcall 语义文档同步。

## 后续 PR 队列

| 优先级 | 开关组 | 建议字段 | 默认值 | 目的 |
|---|---|---|---|---|
| P1 | 能力开关 | `capabilities.search.enabled`、`capabilities.thinking.enabled` | `true` | 允许部署方按账号/环境关闭高风险或高成本能力 |
| P1 | 输入能力 | `capabilities.file_upload.enabled`、`capabilities.vision.enabled` | `true` | 在无真实账号或文件链路不稳定时一键回退 |
| P2 | Runtime 安全 | `runtime.empty_output_retry.enabled`、`runtime.account_switch_retry.enabled` | `true` | 将已存在的重试行为显式化,便于排障 |
| P2 | 观测采样 | `observability.raw_sample_capture.enabled`、`observability.response_history.enabled` | 当前行为 | 让隐私/存储敏感部署可集中控制数据留存 |
| P3 | M4/M5 新能力 | `auto_continue.mode`、`capability_router.mode`、`agent_memory.mode` | `off` | 新主链路能力先 shadow,再 enforce |

执行约束:

- 每个开关组单独分支和 PR,不与无关重构混合。
- 默认值必须在 config、API、README、WebUI、专题文档中保持一致。
- 涉及主请求链路的开关必须有单元测试覆盖开启、关闭、非法配置和局部更新。
10 changes: 10 additions & 0 deletions docs/prompt-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools`
- Go completion runtime:
[internal/completionruntime/nonstream.go](../internal/completionruntime/nonstream.go)

### Tool Calling 全局开关

`tool_calling.enabled` 默认开启,保持现有 API tools 兼容行为:接收上游 `tools` / `tool_choice`,把工具声明注入 prompt,并在输出侧解析模型生成的工具调用。

当 `tool_calling.enabled=false` 时:

- 默认 `disabled_behavior=reject`,携带 `tools` 或强制 `tool_choice` 的请求会在标准化阶段返回错误。
- 如果配置 `disabled_behavior=ignore_tools`,标准化阶段会移除工具声明,把 `tool_choice` 收敛为 `none`,后续 prompt 不注入工具说明,输出侧也不解析工具调用。
- 没有请求工具能力的普通对话仍可继续执行。

## 4. 下游真正收到的东西

在“完成标准化后”,下游 completion payload 的核心形态是:
Expand Down
6 changes: 6 additions & 0 deletions docs/toolcall-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@

文档导航:[总览](../README.md) / [架构说明](./ARCHITECTURE.md) / [测试指南](./TESTING.md)

## 0) 全局启停开关

`tool_calling.enabled` 是 tools 主链路的全局开关,默认 `true`。开启时,协议层会接受上游工具声明、注入 prompt-visible tool 指令,并在 assistant 输出侧解析工具调用。

当它关闭时,默认 `tool_calling.disabled_behavior=reject`:携带 `tools`、`tool_choice=required`、forced tool choice 或 allowed-tools 约束的请求会被拒绝。若设置为 `ignore_tools`,请求中的工具声明会被剥离并按普通对话继续;此时非流式和流式收尾都不会把 assistant 输出解析为工具调用。

## 1) 当前可执行格式

当前版本推荐模型输出半角管道符 DSML 外壳:
Expand Down
11 changes: 9 additions & 2 deletions internal/assistantturn/turn.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ type BuildOptions struct {
ToolNames []string
ToolsRaw any
ToolChoice promptcompat.ToolChoicePolicy
DisableToolCalling bool
ParserV2Mode string
// Ctx is the request context used for observability. May be nil.
Ctx context.Context
Expand All @@ -102,7 +103,10 @@ func BuildTurnFromCollected(result sse.CollectResult, opts BuildOptions) Turn {
text = shared.ReplaceCitationMarkersWithLinks(text, result.CitationLinks)
}

parsed := shared.DetectAssistantToolCalls(result.Text, text, result.Thinking, result.ToolDetectionThinking, opts.ToolNames)
parsed := toolcall.ToolCallParseResult{}
if !opts.DisableToolCalling {
parsed = shared.DetectAssistantToolCalls(result.Text, text, result.Thinking, result.ToolDetectionThinking, opts.ToolNames)
}
parsedBeforeNorm := parsed
calls := toolcall.NormalizeParsedToolCallsForSchemas(parsed.Calls, opts.ToolsRaw)
parsed.Calls = calls
Expand Down Expand Up @@ -154,7 +158,10 @@ func BuildTurnFromStreamSnapshot(snapshot StreamSnapshot, opts BuildOptions) Tur
text = shared.ReplaceCitationMarkersWithLinks(text, snapshot.CitationLinks)
}

parsed := shared.DetectAssistantToolCalls(snapshot.RawText, text, snapshot.RawThinking, snapshot.DetectionThinking, opts.ToolNames)
parsed := toolcall.ToolCallParseResult{}
if !opts.DisableToolCalling {
parsed = shared.DetectAssistantToolCalls(snapshot.RawText, text, snapshot.RawThinking, snapshot.DetectionThinking, opts.ToolNames)
}
parsedBeforeNorm := parsed
calls := parsed.Calls
if len(calls) == 0 && len(snapshot.AdditionalToolCalls) > 0 {
Expand Down
1 change: 1 addition & 0 deletions internal/completionruntime/nonstream.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ func buildOptions(ctx context.Context, stdReq promptcompat.StandardRequest, prom
ToolNames: stdReq.ToolNames,
ToolsRaw: stdReq.ToolsRaw,
ToolChoice: stdReq.ToolChoice,
DisableToolCalling: stdReq.ToolCallingDisabled,
ParserV2Mode: opts.ParserV2Mode,
Ctx: ctx,
}
Expand Down
11 changes: 11 additions & 0 deletions internal/config/codec.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ func (c Config) MarshalJSON() ([]byte, error) {
if c.ThinkingInjection.Enabled != nil || strings.TrimSpace(c.ThinkingInjection.Prompt) != "" {
m["thinking_injection"] = c.ThinkingInjection
}
if c.ToolCalling.Enabled != nil || strings.TrimSpace(c.ToolCalling.DisabledBehavior) != "" {
m["tool_calling"] = c.ToolCalling
}
if strings.TrimSpace(c.Vercel.Token) != "" || strings.TrimSpace(c.Vercel.ProjectID) != "" || strings.TrimSpace(c.Vercel.TeamID) != "" {
m["vercel"] = NormalizeVercelConfig(c.Vercel)
}
Expand Down Expand Up @@ -143,6 +146,10 @@ func (c *Config) UnmarshalJSON(b []byte) error {
if err := json.Unmarshal(v, &c.ThinkingInjection); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "tool_calling":
if err := json.Unmarshal(v, &c.ToolCalling); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "cors":
if err := json.Unmarshal(v, &c.CORS); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
Expand Down Expand Up @@ -213,6 +220,10 @@ func (c Config) Clone() Config {
Enabled: cloneBoolPtr(c.ThinkingInjection.Enabled),
Prompt: c.ThinkingInjection.Prompt,
},
ToolCalling: ToolCallingConfig{
Enabled: cloneBoolPtr(c.ToolCalling.Enabled),
DisabledBehavior: c.ToolCalling.DisabledBehavior,
},
Vercel: c.Vercel,
CORS: CORSConfig{AllowOrigins: slices.Clone(c.CORS.AllowOrigins)},
Auth: AuthConfig{AllowGeminiQueryKey: cloneBoolPtr(c.Auth.AllowGeminiQueryKey)},
Expand Down
8 changes: 8 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type Config struct {
AutoDelete AutoDeleteConfig `json:"auto_delete"`
CurrentInputFile CurrentInputFileConfig `json:"current_input_file,omitempty"`
ThinkingInjection ThinkingInjectionConfig `json:"thinking_injection,omitempty"`
ToolCalling ToolCallingConfig `json:"tool_calling,omitempty"`
Vercel VercelConfig `json:"vercel,omitempty"`
CORS CORSConfig `json:"cors,omitempty"`
Auth AuthConfig `json:"auth,omitempty"`
Expand Down Expand Up @@ -190,6 +191,13 @@ type ThinkingInjectionConfig struct {
Prompt string `json:"prompt,omitempty"`
}

// ToolCallingConfig controls whether prompt-visible tool schemas and parsed
// tool-call outputs are enabled.
type ToolCallingConfig struct {
Enabled *bool `json:"enabled,omitempty"`
DisabledBehavior string `json:"disabled_behavior,omitempty"`
}

type VercelConfig struct {
Token string `json:"token,omitempty"`
ProjectID string `json:"project_id,omitempty"`
Expand Down
15 changes: 15 additions & 0 deletions internal/config/store_accessors.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,21 @@ func (s *Store) ThinkingInjectionPrompt() string {
return strings.TrimSpace(s.cfg.ThinkingInjection.Prompt)
}

func (s *Store) ToolCallingEnabled() bool {
s.mu.RLock()
defer s.mu.RUnlock()
if s.cfg.ToolCalling.Enabled == nil {
return true
}
return *s.cfg.ToolCalling.Enabled
}

func (s *Store) ToolCallingDisabledBehavior() string {
s.mu.RLock()
defer s.mu.RUnlock()
return NormalizeToolCallingDisabledBehavior(s.cfg.ToolCalling.DisabledBehavior)
}

// ContextEngineMode returns the context engine feature flag value.
// Valid values: "off" | "shadow" | "enforce" (default).
// The DS2API_CONTEXT_ENGINE environment variable takes precedence over the
Expand Down
27 changes: 27 additions & 0 deletions internal/config/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ func ValidateConfig(c Config) error {
if err := ValidateCurrentInputFileConfig(c.CurrentInputFile); err != nil {
return err
}
if err := ValidateToolCallingConfig(c.ToolCalling); err != nil {
return err
}
if err := ValidateContextEngineConfig(c.ContextEngine); err != nil {
return err
}
Expand Down Expand Up @@ -134,6 +137,30 @@ func ValidateCurrentInputFileConfig(currentInputFile CurrentInputFileConfig) err
return nil
}

func ValidateToolCallingConfig(toolCalling ToolCallingConfig) error {
return ValidateToolCallingDisabledBehavior(toolCalling.DisabledBehavior)
}

func ValidateToolCallingDisabledBehavior(behavior string) error {
switch NormalizeToolCallingDisabledBehavior(behavior) {
case "reject", "ignore_tools":
return nil
default:
return fmt.Errorf("tool_calling.disabled_behavior must be one of reject, ignore_tools")
}
}

func NormalizeToolCallingDisabledBehavior(behavior string) string {
switch strings.ToLower(strings.TrimSpace(behavior)) {
case "", "reject":
return "reject"
case "ignore", "ignore_tools":
return "ignore_tools"
default:
return strings.ToLower(strings.TrimSpace(behavior))
}
}

func NormalizeCurrentInputFileFilenamePolicy(policy string) string {
switch strings.ToLower(strings.TrimSpace(policy)) {
case "legacy", "neutral", "neutral_random":
Expand Down
5 changes: 5 additions & 0 deletions internal/config/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ func TestValidateConfigRejectsInvalidValues(t *testing.T) {
cfg: Config{CurrentInputFile: CurrentInputFileConfig{FilenamePolicy: "random-ish"}},
want: "current_input_file.filename_policy",
},
{
name: "tool calling disabled behavior",
cfg: Config{ToolCalling: ToolCallingConfig{DisabledBehavior: "drop"}},
want: "tool_calling.disabled_behavior",
},
{
name: "context engine mode",
cfg: Config{ContextEngine: ContextEngineConfig{Mode: "observe"}},
Expand Down
Loading
Loading