diff --git a/API.en.md b/API.en.md index 73d6eaa..69c65a6 100644 --- a/API.en.md +++ b/API.en.md @@ -643,7 +643,7 @@ Reads runtime settings and status, including: - `admin` (`has_password_hash`, `jwt_expire_hours`, `jwt_valid_after_unix`, `default_password_warning`) - `runtime` (`account_max_inflight`, `account_max_queue`, `global_max_inflight`, `token_refresh_interval_hours`) - `responses` / `embeddings` -- `auto_delete` (`sessions`) +- `auto_delete` (`mode`: `none` / `single` / `all`; legacy `sessions=true` is still treated as `all`) - `claude_mapping` / `model_aliases` - `env_backed`, `needs_vercel_sync` - `toolcall` policy is fixed to `feature_match + high` and is no longer returned or editable via settings @@ -656,7 +656,7 @@ Hot-updates runtime settings. Supported fields: - `runtime.account_max_inflight` / `runtime.account_max_queue` / `runtime.global_max_inflight` / `runtime.token_refresh_interval_hours` - `responses.store_ttl_seconds` - `embeddings.provider` -- `auto_delete.sessions` +- `auto_delete.mode` - `claude_mapping` - `model_aliases` - `toolcall` policy is fixed and is no longer writable through settings diff --git a/API.md b/API.md index 061529e..1f4cdae 100644 --- a/API.md +++ b/API.md @@ -652,7 +652,7 @@ data: {"type":"message_stop"} - `admin`(`has_password_hash`、`jwt_expire_hours`、`jwt_valid_after_unix`、`default_password_warning`) - `runtime`(`account_max_inflight`、`account_max_queue`、`global_max_inflight`、`token_refresh_interval_hours`) - `responses` / `embeddings` -- `auto_delete`(`sessions`) +- `auto_delete`(`mode`:`none` / `single` / `all`;旧配置 `sessions=true` 仍按 `all` 处理) - `claude_mapping` / `model_aliases` - `env_backed`、`needs_vercel_sync` - `toolcall` 策略已固定为 `feature_match + high`,不再通过 settings 返回或修改 @@ -665,7 +665,7 @@ data: {"type":"message_stop"} - `runtime.account_max_inflight` / `runtime.account_max_queue` / `runtime.global_max_inflight` / `runtime.token_refresh_interval_hours` - `responses.store_ttl_seconds` - `embeddings.provider` -- `auto_delete.sessions` +- `auto_delete.mode` - `claude_mapping` - `model_aliases` - `toolcall` 策略已固定,不再作为可写入字段 diff --git a/README.MD b/README.MD index 0e1a0dc..09e6af5 100644 --- a/README.MD +++ b/README.MD @@ -328,7 +328,7 @@ cp opencode.json.example opencode.json - `claude_mapping`:字典中 `fast`/`slow` 后缀映射到对应 DeepSeek 模型(兼容读取 `claude_model_mapping`) - `admin`:管理后台设置(JWT 过期时间、密码哈希等),可通过 Admin Settings API 热更新 - `runtime`:运行时参数(并发限制、队列大小、托管账号 token 刷新间隔),可通过 Admin Settings API 热更新;`account_max_queue=0`/`global_max_inflight=0` 表示按推荐值自动计算,`token_refresh_interval_hours=6` 为默认强制重登间隔 -- `auto_delete.sessions`:是否在请求结束后自动清理 DeepSeek 会话(默认 `false`,可在 Settings 热更新) +- `auto_delete.mode`:请求结束后如何清理 DeepSeek 远端聊天记录,支持 `none`(默认,不删除)、`single`(仅删除当前会话)、`all`(清空全部会话);旧配置里的 `auto_delete.sessions=true` 仍会被视为 `all` ### 环境变量 diff --git a/README.en.md b/README.en.md index 7ccc826..2a82e4d 100644 --- a/README.en.md +++ b/README.en.md @@ -328,7 +328,7 @@ cp opencode.json.example opencode.json - `claude_mapping`: Maps `fast`/`slow` suffixes to corresponding DeepSeek models (still compatible with `claude_model_mapping`) - `admin`: Admin panel settings (JWT expiry, password hash, etc.), hot-reloadable via Admin Settings API - `runtime`: Runtime parameters (concurrency limits, queue sizes, managed token refresh interval), hot-reloadable via Admin Settings API; `account_max_queue=0`/`global_max_inflight=0` means auto-calculate from recommended values, `token_refresh_interval_hours=6` is the default forced re-login interval -- `auto_delete.sessions`: Whether to auto-delete DeepSeek sessions after request completion (default `false`, hot-reloadable via Settings) +- `auto_delete.mode`: How to clean up DeepSeek remote chat records after each request completes. Supported values: `none` (default, no deletion), `single` (delete only the current session), `all` (delete all sessions); legacy `auto_delete.sessions=true` is still treated as `all` ### Environment Variables diff --git a/internal/adapter/openai/deps.go b/internal/adapter/openai/deps.go index 7883916..22b1ff1 100644 --- a/internal/adapter/openai/deps.go +++ b/internal/adapter/openai/deps.go @@ -19,6 +19,7 @@ type DeepSeekCaller interface { CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error) + DeleteSessionForToken(ctx context.Context, token string, sessionID string) (*deepseek.DeleteSessionResult, error) DeleteAllSessionsForToken(ctx context.Context, token string) error } @@ -30,6 +31,7 @@ type ConfigReader interface { ToolcallEarlyEmitConfidence() string ResponsesStoreTTLSeconds() int EmbeddingsProvider() string + AutoDeleteMode() string AutoDeleteSessions() bool } diff --git a/internal/adapter/openai/deps_injection_test.go b/internal/adapter/openai/deps_injection_test.go index 126a18a..2364540 100644 --- a/internal/adapter/openai/deps_injection_test.go +++ b/internal/adapter/openai/deps_injection_test.go @@ -3,12 +3,13 @@ package openai import "testing" type mockOpenAIConfig struct { - aliases map[string]string - wideInput bool - toolMode string - earlyEmit string - responsesTTL int - embedProv string + aliases map[string]string + wideInput bool + autoDeleteMode string + toolMode string + earlyEmit string + responsesTTL int + embedProv string } func (m mockOpenAIConfig) ModelAliases() map[string]string { return m.aliases } @@ -20,7 +21,13 @@ func (m mockOpenAIConfig) ToolcallMode() string { return m.toolMo func (m mockOpenAIConfig) ToolcallEarlyEmitConfidence() string { return m.earlyEmit } func (m mockOpenAIConfig) ResponsesStoreTTLSeconds() int { return m.responsesTTL } func (m mockOpenAIConfig) EmbeddingsProvider() string { return m.embedProv } -func (m mockOpenAIConfig) AutoDeleteSessions() bool { return false } +func (m mockOpenAIConfig) AutoDeleteMode() string { + if m.autoDeleteMode == "" { + return "none" + } + return m.autoDeleteMode +} +func (m mockOpenAIConfig) AutoDeleteSessions() bool { return false } func TestNormalizeOpenAIChatRequestWithConfigInterface(t *testing.T) { cfg := mockOpenAIConfig{ diff --git a/internal/adapter/openai/handler_chat.go b/internal/adapter/openai/handler_chat.go index 18d9fae..58a7cb0 100644 --- a/internal/adapter/openai/handler_chat.go +++ b/internal/adapter/openai/handler_chat.go @@ -35,22 +35,9 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) { writeOpenAIError(w, status, detail) return } + var sessionID string defer func() { - // 自动删除会话(同步) - // 必须在 Release 之前同步删除,否则: - // 1. 异步删除时账号已被 Release - // 2. 新请求可能获取到同一账号并开始使用 - // 3. 异步删除仍在进行,会截断新请求正在使用的会话 - if h.Store.AutoDeleteSessions() && a.DeepSeekToken != "" { - deleteCtx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - err := h.DS.DeleteAllSessionsForToken(deleteCtx, a.DeepSeekToken) - if err != nil { - config.Logger.Warn("[auto_delete_sessions] failed", "account", a.AccountID, "error", err) - } else { - config.Logger.Debug("[auto_delete_sessions] success", "account", a.AccountID) - } - } + h.autoDeleteRemoteSession(r.Context(), a, sessionID) h.Auth.Release(a) }() @@ -67,7 +54,7 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) { return } - sessionID, err := h.DS.CreateSession(r.Context(), a, 3) + sessionID, err = h.DS.CreateSession(r.Context(), a, 3) if err != nil { if a.UseConfigToken { writeOpenAIError(w, http.StatusUnauthorized, "Account token is invalid. Please re-login the account in admin.") @@ -94,6 +81,38 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) { h.handleNonStream(w, r.Context(), resp, sessionID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.ToolNames) } +func (h *Handler) autoDeleteRemoteSession(ctx context.Context, a *auth.RequestAuth, sessionID string) { + mode := h.Store.AutoDeleteMode() + if mode == "none" || a.DeepSeekToken == "" { + return + } + + deleteCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + switch mode { + case "single": + if sessionID == "" { + config.Logger.Warn("[auto_delete_sessions] skipped single-session delete because session_id is empty", "account", a.AccountID) + return + } + _, err := h.DS.DeleteSessionForToken(deleteCtx, a.DeepSeekToken, sessionID) + if err != nil { + config.Logger.Warn("[auto_delete_sessions] failed", "account", a.AccountID, "mode", mode, "session_id", sessionID, "error", err) + return + } + config.Logger.Debug("[auto_delete_sessions] success", "account", a.AccountID, "mode", mode, "session_id", sessionID) + case "all": + if err := h.DS.DeleteAllSessionsForToken(deleteCtx, a.DeepSeekToken); err != nil { + config.Logger.Warn("[auto_delete_sessions] failed", "account", a.AccountID, "mode", mode, "error", err) + return + } + config.Logger.Debug("[auto_delete_sessions] success", "account", a.AccountID, "mode", mode) + default: + config.Logger.Warn("[auto_delete_sessions] unknown mode", "account", a.AccountID, "mode", mode) + } +} + func (h *Handler) handleNonStream(w http.ResponseWriter, ctx context.Context, resp *http.Response, completionID, model, finalPrompt string, thinkingEnabled bool, toolNames []string) { if resp.StatusCode != http.StatusOK { defer resp.Body.Close() diff --git a/internal/adapter/openai/handler_chat_auto_delete_test.go b/internal/adapter/openai/handler_chat_auto_delete_test.go new file mode 100644 index 0000000..fbeca15 --- /dev/null +++ b/internal/adapter/openai/handler_chat_auto_delete_test.go @@ -0,0 +1,95 @@ +package openai + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "ds2api/internal/auth" + "ds2api/internal/deepseek" +) + +type autoDeleteModeDSStub struct { + resp *http.Response + singleCalls int + allCalls int + lastSessionID string +} + +func (m *autoDeleteModeDSStub) CreateSession(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) { + return "session-id", nil +} + +func (m *autoDeleteModeDSStub) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) { + return "pow", nil +} + +func (m *autoDeleteModeDSStub) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) { + return m.resp, nil +} + +func (m *autoDeleteModeDSStub) DeleteSessionForToken(_ context.Context, _ string, sessionID string) (*deepseek.DeleteSessionResult, error) { + m.singleCalls++ + m.lastSessionID = sessionID + return &deepseek.DeleteSessionResult{SessionID: sessionID, Success: true}, nil +} + +func (m *autoDeleteModeDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) error { + m.allCalls++ + return nil +} + +func TestChatCompletionsAutoDeleteModes(t *testing.T) { + tests := []struct { + name string + mode string + wantSingle int + wantAll int + }{ + {name: "none", mode: "none"}, + {name: "single", mode: "single", wantSingle: 1}, + {name: "all", mode: "all", wantAll: 1}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ds := &autoDeleteModeDSStub{ + resp: makeOpenAISSEHTTPResponse( + `data: {"p":"response/content","v":"hello"}`, + "data: [DONE]", + ), + } + h := &Handler{ + Store: mockOpenAIConfig{ + wideInput: true, + autoDeleteMode: tc.mode, + }, + Auth: streamStatusAuthStub{}, + DS: ds, + } + + reqBody := `{"model":"deepseek-chat","messages":[{"role":"user","content":"hi"}],"stream":false}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody)) + req.Header.Set("Authorization", "Bearer direct-token") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + h.ChatCompletions(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if ds.singleCalls != tc.wantSingle { + t.Fatalf("single delete calls=%d want=%d", ds.singleCalls, tc.wantSingle) + } + if ds.allCalls != tc.wantAll { + t.Fatalf("all delete calls=%d want=%d", ds.allCalls, tc.wantAll) + } + if tc.wantSingle > 0 && ds.lastSessionID != "session-id" { + t.Fatalf("expected single delete for session-id, got %q", ds.lastSessionID) + } + }) + } +} diff --git a/internal/adapter/openai/stream_status_test.go b/internal/adapter/openai/stream_status_test.go index 2a3584b..6352141 100644 --- a/internal/adapter/openai/stream_status_test.go +++ b/internal/adapter/openai/stream_status_test.go @@ -13,6 +13,7 @@ import ( chimw "github.com/go-chi/chi/v5/middleware" "ds2api/internal/auth" + "ds2api/internal/deepseek" ) type streamStatusAuthStub struct{} @@ -53,6 +54,10 @@ func (m streamStatusDSStub) CallCompletion(_ context.Context, _ *auth.RequestAut return m.resp, nil } +func (m streamStatusDSStub) DeleteSessionForToken(_ context.Context, _ string, _ string) (*deepseek.DeleteSessionResult, error) { + return &deepseek.DeleteSessionResult{Success: true}, nil +} + func (m streamStatusDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) error { return nil } diff --git a/internal/admin/deps.go b/internal/admin/deps.go index 8686038..6b083fc 100644 --- a/internal/admin/deps.go +++ b/internal/admin/deps.go @@ -32,6 +32,7 @@ type ConfigStore interface { RuntimeAccountMaxQueue(defaultSize int) int RuntimeGlobalMaxInflight(defaultSize int) int RuntimeTokenRefreshIntervalHours() int + AutoDeleteMode() string CompatStripReferenceMarkers() bool AutoDeleteSessions() bool } diff --git a/internal/admin/handler_settings_parse.go b/internal/admin/handler_settings_parse.go index 695dc73..2cefb77 100644 --- a/internal/admin/handler_settings_parse.go +++ b/internal/admin/handler_settings_parse.go @@ -141,6 +141,17 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if raw, ok := req["auto_delete"].(map[string]any); ok { cfg := &config.AutoDeleteConfig{} + if v, exists := raw["mode"]; exists { + mode := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v))) + switch mode { + case "", "none": + cfg.Mode = "none" + case "single", "all": + cfg.Mode = mode + default: + return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("auto_delete.mode must be one of none, single, all") + } + } if v, exists := raw["sessions"]; exists { cfg.Sessions = boolFrom(v) } diff --git a/internal/admin/handler_settings_test.go b/internal/admin/handler_settings_test.go index feb2996..159e86f 100644 --- a/internal/admin/handler_settings_test.go +++ b/internal/admin/handler_settings_test.go @@ -132,6 +132,31 @@ func TestUpdateSettingsWithoutRuntimeSkipsMergedRuntimeValidation(t *testing.T) } } +func TestUpdateSettingsAutoDeleteMode(t *testing.T) { + h := newAdminTestHandler(t, `{"keys":["k1"],"auto_delete":{"sessions":true}}`) + + payload := map[string]any{ + "auto_delete": map[string]any{ + "mode": "single", + }, + } + 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("status=%d body=%s", rec.Code, rec.Body.String()) + } + + snap := h.Store.Snapshot() + if got := snap.AutoDelete.Mode; got != "single" { + t.Fatalf("auto_delete.mode=%q want=single", got) + } + if got := h.Store.AutoDeleteMode(); got != "single" { + t.Fatalf("AutoDeleteMode()=%q want=single", got) + } +} + func TestUpdateSettingsHotReloadRuntime(t *testing.T) { h := newAdminTestHandler(t, `{ "keys":["k1"], diff --git a/internal/admin/handler_settings_write.go b/internal/admin/handler_settings_write.go index e4751bc..776e6b9 100644 --- a/internal/admin/handler_settings_write.go +++ b/internal/admin/handler_settings_write.go @@ -64,6 +64,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) { c.Embeddings.Provider = strings.TrimSpace(embeddingsCfg.Provider) } if autoDeleteCfg != nil { + c.AutoDelete.Mode = autoDeleteCfg.Mode c.AutoDelete.Sessions = autoDeleteCfg.Sessions } if claudeMap != nil { diff --git a/internal/config/config.go b/internal/config/config.go index e18c4b4..17230bb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -77,5 +77,6 @@ type EmbeddingsConfig struct { } type AutoDeleteConfig struct { - Sessions bool `json:"sessions"` + Mode string `json:"mode,omitempty"` + Sessions bool `json:"sessions,omitempty"` } diff --git a/internal/config/config_edge_test.go b/internal/config/config_edge_test.go index bac1cb4..61a4a1b 100644 --- a/internal/config/config_edge_test.go +++ b/internal/config/config_edge_test.go @@ -106,6 +106,9 @@ func TestConfigJSONRoundtrip(t *testing.T) { "fast": "deepseek-chat", "slow": "deepseek-reasoner", }, + AutoDelete: AutoDeleteConfig{ + Mode: "single", + }, Runtime: RuntimeConfig{ TokenRefreshIntervalHours: 12, }, @@ -142,6 +145,9 @@ func TestConfigJSONRoundtrip(t *testing.T) { if decoded.Runtime.TokenRefreshIntervalHours != 12 { t.Fatalf("unexpected runtime refresh interval: %#v", decoded.Runtime.TokenRefreshIntervalHours) } + if decoded.AutoDelete.Mode != "single" { + t.Fatalf("unexpected auto delete mode: %#v", decoded.AutoDelete.Mode) + } if decoded.Compat.WideInputStrictOutput == nil || !*decoded.Compat.WideInputStrictOutput { t.Fatalf("unexpected compat wide_input_strict_output: %#v", decoded.Compat.WideInputStrictOutput) } @@ -156,6 +162,29 @@ func TestConfigJSONRoundtrip(t *testing.T) { } } +func TestAutoDeleteModeResolution(t *testing.T) { + tests := []struct { + name string + cfg AutoDeleteConfig + want string + }{ + {name: "default", cfg: AutoDeleteConfig{}, want: "none"}, + {name: "legacy all", cfg: AutoDeleteConfig{Sessions: true}, want: "all"}, + {name: "single", cfg: AutoDeleteConfig{Mode: "single"}, want: "single"}, + {name: "all", cfg: AutoDeleteConfig{Mode: "all"}, want: "all"}, + {name: "none", cfg: AutoDeleteConfig{Mode: "none"}, want: "none"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + store := &Store{cfg: Config{AutoDelete: tc.cfg}} + if got := store.AutoDeleteMode(); got != tc.want { + t.Fatalf("AutoDeleteMode()=%q want=%q", got, tc.want) + } + }) + } +} + func TestConfigUnmarshalJSONPreservesUnknownFields(t *testing.T) { raw := `{"keys":["k1"],"accounts":[],"my_custom_field":"hello","number_field":42}` var cfg Config diff --git a/internal/config/store_accessors.go b/internal/config/store_accessors.go index 2d6e189..4225672 100644 --- a/internal/config/store_accessors.go +++ b/internal/config/store_accessors.go @@ -74,6 +74,20 @@ func (s *Store) EmbeddingsProvider() string { return strings.TrimSpace(s.cfg.Embeddings.Provider) } +func (s *Store) AutoDeleteMode() string { + s.mu.RLock() + defer s.mu.RUnlock() + mode := strings.ToLower(strings.TrimSpace(s.cfg.AutoDelete.Mode)) + switch mode { + case "none", "single", "all": + return mode + } + if s.cfg.AutoDelete.Sessions { + return "all" + } + return "none" +} + func (s *Store) AdminPasswordHash() string { s.mu.RLock() defer s.mu.RUnlock() @@ -173,7 +187,5 @@ func (s *Store) RuntimeTokenRefreshIntervalHours() int { } func (s *Store) AutoDeleteSessions() bool { - s.mu.RLock() - defer s.mu.RUnlock() - return s.cfg.AutoDelete.Sessions + return s.AutoDeleteMode() != "none" } diff --git a/webui/src/features/settings/AutoDeleteSection.jsx b/webui/src/features/settings/AutoDeleteSection.jsx index 81c091f..9587860 100644 --- a/webui/src/features/settings/AutoDeleteSection.jsx +++ b/webui/src/features/settings/AutoDeleteSection.jsx @@ -1,6 +1,13 @@ import { Trash2 } from 'lucide-react' export default function AutoDeleteSection({ t, form, setForm }) { + const mode = form.auto_delete?.mode || 'none' + const descKey = mode === 'single' + ? 'settings.autoDeleteSingleDesc' + : mode === 'all' + ? 'settings.autoDeleteAllDesc' + : 'settings.autoDeleteNoneDesc' + return (
{t('settings.autoDeleteDesc')}
-