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
1 change: 1 addition & 0 deletions API.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,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`
- `context_engine.mode` / `context_engine.strategy`
- `model_aliases`
- `toolcall` policy is fixed and is no longer writable through settings

Expand Down
1 change: 1 addition & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,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`
- `context_engine.mode` / `context_engine.strategy`
- `model_aliases`
- `toolcall` 策略已固定,不再作为可写入字段

Expand Down
72 changes: 72 additions & 0 deletions internal/httpapi/admin/handler_settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,78 @@ func TestUpdateSettingsThinkingInjectionPartialEnabledPreservesPrompt(t *testing
}
}

func TestUpdateSettingsContextEngine(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
payload := map[string]any{
"context_engine": map[string]any{
"mode": "shadow",
"strategy": "context_capsule",
},
}
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.ContextEngine.Mode != "shadow" {
t.Fatalf("expected context_engine.mode=shadow, got %#v", snap.ContextEngine)
}
if snap.ContextEngine.Strategy != "context_capsule" {
t.Fatalf("expected context_engine.strategy=context_capsule, got %#v", snap.ContextEngine)
}
if got := h.Store.ContextEngineMode(); got != "shadow" {
t.Fatalf("ContextEngineMode()=%q want=shadow", got)
}
if got := h.Store.ContextEngineStrategy(); got != "context_capsule" {
t.Fatalf("ContextEngineStrategy()=%q want=context_capsule", got)
}
}

func TestUpdateSettingsContextEnginePartialUpdatePreservesStrategy(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"],"context_engine":{"mode":"enforce","strategy":"natural_context"}}`)
payload := map[string]any{
"context_engine": map[string]any{
"mode": "off",
},
}
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.ContextEngine.Mode != "off" {
t.Fatalf("expected context_engine.mode=off, got %#v", snap.ContextEngine)
}
if snap.ContextEngine.Strategy != "natural_context" {
t.Fatalf("expected context_engine.strategy to be preserved, got %#v", snap.ContextEngine)
}
}

func TestUpdateSettingsContextEngineRejectsInvalidMode(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
payload := map[string]any{
"context_engine": map[string]any{
"mode": "maybe",
},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.updateSettings(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte("context_engine.mode")) {
t.Fatalf("expected context engine validation detail, got %s", rec.Body.String())
}
}

func TestUpdateSettingsAutoDeleteMode(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"],"auto_delete":{"sessions":true}}`)

Expand Down
65 changes: 40 additions & 25 deletions internal/httpapi/admin/settings/handler_settings_parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,25 +33,26 @@ func stringFrom(v any) string {
}
}

func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, *config.CurrentInputFileConfig, *config.ThinkingInjectionConfig, map[string]string, *config.LogConfig, error) {
func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, *config.CurrentInputFileConfig, *config.ThinkingInjectionConfig, *config.ContextEngineConfig, map[string]string, *config.LogConfig, error) {
var (
adminCfg *config.AdminConfig
runtimeCfg *config.RuntimeConfig
respCfg *config.ResponsesConfig
embCfg *config.EmbeddingsConfig
autoDeleteCfg *config.AutoDeleteConfig
currentInputCfg *config.CurrentInputFileConfig
thinkingInjCfg *config.ThinkingInjectionConfig
aliasMap map[string]string
logCfg *config.LogConfig
adminCfg *config.AdminConfig
runtimeCfg *config.RuntimeConfig
respCfg *config.ResponsesConfig
embCfg *config.EmbeddingsConfig
autoDeleteCfg *config.AutoDeleteConfig
currentInputCfg *config.CurrentInputFileConfig
thinkingInjCfg *config.ThinkingInjectionConfig
contextEngineCfg *config.ContextEngineConfig
aliasMap map[string]string
logCfg *config.LogConfig
)

if raw, ok := req["admin"].(map[string]any); ok {
cfg := &config.AdminConfig{}
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, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.JWTExpireHours = n
}
Expand All @@ -63,33 +64,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, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.AccountMaxInflight = n
}
if v, exists := raw["account_max_queue"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("runtime.account_max_queue", n, 1, 200000, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.AccountMaxQueue = n
}
if v, exists := raw["global_max_inflight"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("runtime.global_max_inflight", n, 1, 200000, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.GlobalMaxInflight = n
}
if v, exists := raw["token_refresh_interval_hours"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("runtime.token_refresh_interval_hours", n, 1, 720, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.TokenRefreshIntervalHours = n
}
if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight {
return nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
}
runtimeCfg = cfg
}
Expand All @@ -99,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, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.StoreTTLSeconds = n
}
Expand All @@ -111,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, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.Provider = p
}
Expand All @@ -137,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, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
if mode == "" {
mode = "none"
Expand All @@ -159,22 +160,22 @@ 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, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.MinChars = n
}
if v, exists := raw["inline_max_tokens"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("current_input_file.inline_max_tokens", n, 0, 100000000, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.InlineMaxTokens = n
}
if v, exists := raw["filename_policy"]; exists {
cfg.FilenamePolicy = strings.TrimSpace(stringFrom(v))
}
if err := config.ValidateCurrentInputFileConfig(*cfg); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
currentInputCfg = cfg
}
Expand All @@ -191,6 +192,20 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
thinkingInjCfg = cfg
}

if raw, ok := req["context_engine"].(map[string]any); ok {
cfg := &config.ContextEngineConfig{}
if v, exists := raw["mode"]; exists {
cfg.Mode = strings.ToLower(strings.TrimSpace(stringFrom(v)))
}
if v, exists := raw["strategy"]; exists {
cfg.Strategy = config.NormalizeContextEngineStrategy(stringFrom(v))
}
if err := config.ValidateContextEngineConfig(*cfg); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
contextEngineCfg = cfg
}

if raw, ok := req["log"].(map[string]any); ok {
cfg := &config.LogConfig{}
if v, exists := raw["level"]; exists {
Expand All @@ -201,7 +216,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
case "debug", "info", "warn", "error":
cfg.Level = level
default:
return nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("log.level must be one of: debug, info, warn, error")
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("log.level must be one of: debug, info, warn, error")
}
}
if v, exists := raw["file"]; exists {
Expand All @@ -213,7 +228,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["max_size_mb"]; exists {
n := intFrom(v)
if n > 1024 {
return nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("log.max_size_mb must be <= 1024")
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("log.max_size_mb must be <= 1024")
}
if n > 0 {
cfg.MaxSizeMB = n
Expand All @@ -228,5 +243,5 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
logCfg = cfg
}

return adminCfg, runtimeCfg, respCfg, embCfg, autoDeleteCfg, currentInputCfg, thinkingInjCfg, aliasMap, logCfg, nil
return adminCfg, runtimeCfg, respCfg, embCfg, autoDeleteCfg, currentInputCfg, thinkingInjCfg, contextEngineCfg, aliasMap, logCfg, nil
}
12 changes: 11 additions & 1 deletion internal/httpapi/admin/settings/handler_settings_write.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
return
}

adminCfg, runtimeCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, currentInputCfg, thinkingInjCfg, aliasMap, logCfg, err := parseSettingsUpdateRequest(req)
adminCfg, runtimeCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, currentInputCfg, thinkingInjCfg, contextEngineCfg, aliasMap, logCfg, err := parseSettingsUpdateRequest(req)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
Expand All @@ -34,6 +34,8 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
currentInputFilenamePolicySet := hasNestedSettingsKey(req, "current_input_file", "filename_policy")
thinkingInjectionEnabledSet := hasNestedSettingsKey(req, "thinking_injection", "enabled")
thinkingInjectionPromptSet := hasNestedSettingsKey(req, "thinking_injection", "prompt")
contextEngineModeSet := hasNestedSettingsKey(req, "context_engine", "mode")
contextEngineStrategySet := hasNestedSettingsKey(req, "context_engine", "strategy")
logLevelSet := hasNestedSettingsKey(req, "log", "level")
logFileSet := hasNestedSettingsKey(req, "log", "file")
logFileEnabledSet := hasNestedSettingsKey(req, "log", "file_enabled")
Expand Down Expand Up @@ -93,6 +95,14 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
c.ThinkingInjection.Prompt = thinkingInjCfg.Prompt
}
}
if contextEngineCfg != nil {
if contextEngineModeSet {
c.ContextEngine.Mode = contextEngineCfg.Mode
}
if contextEngineStrategySet {
c.ContextEngine.Strategy = contextEngineCfg.Strategy
}
}
if aliasMap != nil {
c.ModelAliases = aliasMap
}
Expand Down
37 changes: 37 additions & 0 deletions webui/src/features/settings/CurrentInputFileSection.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,43 @@ export default function CurrentInputFileSection({ t, form, setForm }) {
/>
<p className="text-xs text-muted-foreground">{t('settings.currentInputFileHelp')}</p>
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.currentInputFileInlineMaxTokens')}</span>
<input
type="number"
min={0}
max={100000000}
value={form.current_input_file?.inline_max_tokens ?? 30000}
onChange={(e) => setForm((prev) => ({
...prev,
current_input_file: {
...prev.current_input_file,
inline_max_tokens: Number(e.target.value || 0),
},
}))}
className="w-full bg-background border border-border rounded-lg px-3 py-2"
/>
<p className="text-xs text-muted-foreground">{t('settings.currentInputFileInlineMaxTokensHelp')}</p>
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.currentInputFileFilenamePolicy')}</span>
<select
value={form.current_input_file?.filename_policy || 'neutral_random'}
onChange={(e) => setForm((prev) => ({
...prev,
current_input_file: {
...prev.current_input_file,
filename_policy: e.target.value,
},
}))}
className="w-full bg-background border border-border rounded-lg px-3 py-2"
>
<option value="neutral_random">{t('settings.filenamePolicyNeutralRandom')}</option>
<option value="neutral">{t('settings.filenamePolicyNeutral')}</option>
<option value="legacy">{t('settings.filenamePolicyLegacy')}</option>
</select>
<p className="text-xs text-muted-foreground">{t('settings.currentInputFileFilenamePolicyHelp')}</p>
</label>
</div>
</div>
)
Expand Down
Loading
Loading