From f3555ae9b08c36319861ee90c65c809be4f2d89a Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Mon, 30 Mar 2026 20:37:58 +0800 Subject: [PATCH 1/5] feat(config): bootstrap config.json when env writeback is enabled --- internal/config/config_test.go | 45 ++++++++++++++++++++++++ internal/config/store.go | 62 +++++++++++++++++++++++++++++++--- 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f5a8d90..e47c954 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -79,6 +79,51 @@ func TestLoadStorePreservesFileBackedTokensForRuntime(t *testing.T) { } } +func TestEnvBackedStoreWritebackBootstrapsMissingConfigFile(t *testing.T) { + tmp, err := os.CreateTemp(t.TempDir(), "config-*.json") + if err != nil { + t.Fatalf("create temp config: %v", err) + } + path := tmp.Name() + _ = tmp.Close() + _ = os.Remove(path) + + t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[{"email":"seed@example.com","password":"p"}]}`) + t.Setenv("CONFIG_JSON", "") + t.Setenv("DS2API_CONFIG_PATH", path) + t.Setenv("DS2API_ENV_WRITEBACK", "1") + + store := LoadStore() + if store.IsEnvBacked() { + t.Fatalf("expected writeback bootstrap to become file-backed immediately") + } + if err := store.Update(func(c *Config) error { + c.Accounts = append(c.Accounts, Account{Email: "new@example.com", Password: "p2"}) + return nil + }); err != nil { + t.Fatalf("update failed: %v", err) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read written config: %v", err) + } + if !strings.Contains(string(content), "seed@example.com") { + t.Fatalf("expected bootstrapped config to contain seed account, got: %s", content) + } + if !strings.Contains(string(content), "new@example.com") { + t.Fatalf("expected persisted config to contain added account, got: %s", content) + } + + reloaded := LoadStore() + if reloaded.IsEnvBacked() { + t.Fatalf("expected reloaded store to prefer persisted config file") + } + accounts := reloaded.Accounts() + if len(accounts) != 2 { + t.Fatalf("expected 2 accounts after reload, got %d", len(accounts)) + } +} + func TestRuntimeTokenRefreshIntervalHoursDefaultsToSix(t *testing.T) { t.Setenv("DS2API_CONFIG_JSON", `{ "keys":["k1"], diff --git a/internal/config/store.go b/internal/config/store.go index d212594..678390a 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -4,12 +4,19 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "os" + "path/filepath" "slices" "strings" "sync" ) +func envWritebackEnabled() bool { + v := strings.ToLower(strings.TrimSpace(os.Getenv("DS2API_ENV_WRITEBACK"))) + return v == "1" || v == "true" || v == "yes" || v == "on" +} + type Store struct { mu sync.RWMutex cfg Config @@ -42,6 +49,24 @@ func loadConfig() (Config, bool, error) { cfg, err := parseConfigString(rawCfg) cfg.ClearAccountTokens() cfg.DropInvalidAccounts() + if IsVercel() || !envWritebackEnabled() { + return cfg, true, err + } + content, fileErr := os.ReadFile(ConfigPath()) + if fileErr == nil { + var fileCfg Config + if unmarshalErr := json.Unmarshal(content, &fileCfg); unmarshalErr == nil { + fileCfg.DropInvalidAccounts() + return fileCfg, false, err + } + } + if errors.Is(fileErr, os.ErrNotExist) { + if writeErr := writeConfigFile(ConfigPath(), cfg.Clone()); writeErr == nil { + return cfg, false, err + } else { + Logger.Warn("[config] env writeback bootstrap failed", "error", writeErr) + } + } return cfg, true, err } @@ -177,7 +202,7 @@ func (s *Store) Update(mutator func(*Config) error) error { func (s *Store) Save() error { s.mu.Lock() defer s.mu.Unlock() - if s.fromEnv { + if s.fromEnv && (IsVercel() || !envWritebackEnabled()) { Logger.Info("[save_config] source from env, skip write") return nil } @@ -187,11 +212,15 @@ func (s *Store) Save() error { if err != nil { return err } - return os.WriteFile(s.path, b, 0o644) + if err := writeConfigBytes(s.path, b); err != nil { + return err + } + s.fromEnv = false + return nil } func (s *Store) saveLocked() error { - if s.fromEnv { + if s.fromEnv && (IsVercel() || !envWritebackEnabled()) { Logger.Info("[save_config] source from env, skip write") return nil } @@ -201,7 +230,32 @@ func (s *Store) saveLocked() error { if err != nil { return err } - return os.WriteFile(s.path, b, 0o644) + if err := writeConfigBytes(s.path, b); err != nil { + return err + } + s.fromEnv = false + return nil +} + +func writeConfigFile(path string, cfg Config) error { + persistCfg := cfg.Clone() + persistCfg.ClearAccountTokens() + b, err := json.MarshalIndent(persistCfg, "", " ") + if err != nil { + return err + } + return writeConfigBytes(path, b) +} + +func writeConfigBytes(path string, b []byte) error { + dir := filepath.Dir(path) + if dir == "." || dir == "" { + return os.WriteFile(path, b, 0o644) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir config dir: %w", err) + } + return os.WriteFile(path, b, 0o644) } func (s *Store) IsEnvBacked() bool { From 958e7a0d042911251ed556fc8ed323bc4ca600da Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Mon, 30 Mar 2026 21:02:36 +0800 Subject: [PATCH 2/5] fix(config): skip writeback bootstrap on invalid env config --- internal/config/config_test.go | 30 ++++++++++++++++++++++++++++++ internal/config/store.go | 3 +++ 2 files changed, 33 insertions(+) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e47c954..30a2e80 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2,6 +2,7 @@ package config import ( "encoding/base64" + "errors" "os" "strings" "testing" @@ -124,6 +125,35 @@ func TestEnvBackedStoreWritebackBootstrapsMissingConfigFile(t *testing.T) { } } +func TestEnvBackedStoreWritebackDoesNotBootstrapOnInvalidEnvJSON(t *testing.T) { + tmp, err := os.CreateTemp(t.TempDir(), "config-*.json") + if err != nil { + t.Fatalf("create temp config: %v", err) + } + path := tmp.Name() + _ = tmp.Close() + _ = os.Remove(path) + + t.Setenv("DS2API_CONFIG_JSON", "{invalid-json") + t.Setenv("CONFIG_JSON", "") + t.Setenv("DS2API_CONFIG_PATH", path) + t.Setenv("DS2API_ENV_WRITEBACK", "1") + + cfg, fromEnv, loadErr := loadConfig() + if loadErr == nil { + t.Fatalf("expected loadConfig error for invalid env json") + } + if !fromEnv { + t.Fatalf("expected fromEnv=true when parsing env config fails") + } + if len(cfg.Keys) != 0 || len(cfg.Accounts) != 0 { + t.Fatalf("expected empty config on parse failure, got keys=%d accounts=%d", len(cfg.Keys), len(cfg.Accounts)) + } + if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("expected no bootstrapped config file, stat err=%v", statErr) + } +} + func TestRuntimeTokenRefreshIntervalHoursDefaultsToSix(t *testing.T) { t.Setenv("DS2API_CONFIG_JSON", `{ "keys":["k1"], diff --git a/internal/config/store.go b/internal/config/store.go index 678390a..feebb99 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -47,6 +47,9 @@ func loadConfig() (Config, bool, error) { } if rawCfg != "" { cfg, err := parseConfigString(rawCfg) + if err != nil { + return cfg, true, err + } cfg.ClearAccountTokens() cfg.DropInvalidAccounts() if IsVercel() || !envWritebackEnabled() { From 836eaf5290b76fde102d96211df97bcdb55b782a Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Mon, 30 Mar 2026 21:18:56 +0800 Subject: [PATCH 3/5] feat(ui): show env mode persistence status and document writeback --- API.en.md | 3 +++ API.md | 3 +++ README.MD | 3 +++ README.en.md | 3 +++ docs/DEPLOY.en.md | 1 + docs/DEPLOY.md | 1 + internal/admin/deps.go | 3 +++ internal/admin/handler_config_read.go | 9 +++++--- internal/config/store.go | 16 ++++++++++++++ .../account/AccountManagerContainer.jsx | 21 +++++++++++++++++++ webui/src/locales/en.json | 7 ++++++- webui/src/locales/zh.json | 7 ++++++- 12 files changed, 72 insertions(+), 5 deletions(-) diff --git a/API.en.md b/API.en.md index 2db8d03..967c258 100644 --- a/API.en.md +++ b/API.en.md @@ -587,6 +587,9 @@ Returns sanitized config. { "keys": ["k1", "k2"], "env_backed": false, + "env_source_present": true, + "env_writeback_enabled": true, + "config_path": "/data/config.json", "accounts": [ { "identifier": "user@example.com", diff --git a/API.md b/API.md index af4a1d9..aac0e3b 100644 --- a/API.md +++ b/API.md @@ -596,6 +596,9 @@ data: {"type":"message_stop"} { "keys": ["k1", "k2"], "env_backed": false, + "env_source_present": true, + "env_writeback_enabled": true, + "config_path": "/data/config.json", "accounts": [ { "identifier": "user@example.com", diff --git a/README.MD b/README.MD index fc15630..d22025a 100644 --- a/README.MD +++ b/README.MD @@ -320,6 +320,7 @@ cp opencode.json.example opencode.json | `DS2API_CONFIG_PATH` | 配置文件路径 | `config.json` | | `DS2API_CONFIG_JSON` | 直接注入配置(JSON 或 Base64) | — | | `CONFIG_JSON` | 旧版兼容配置注入 | — | +| `DS2API_ENV_WRITEBACK` | 环境变量模式下自动写回配置文件并切换文件模式(`1/true/yes/on`) | 关闭 | | `DS2API_WASM_PATH` | PoW WASM 文件路径 | 自动查找 | | `DS2API_STATIC_ADMIN_DIR` | 管理台静态文件目录 | `static/admin` | | `DS2API_AUTO_BUILD_WEBUI` | 启动时自动构建 WebUI | 本地开启,Vercel 关闭 | @@ -342,6 +343,8 @@ cp opencode.json.example opencode.json | `VERCEL_TEAM_ID` | Vercel 团队 ID | — | | `DS2API_VERCEL_PROTECTION_BYPASS` | Vercel 部署保护绕过密钥(内部 Node→Go 调用) | — | +> 提示:当检测到 `DS2API_CONFIG_JSON/CONFIG_JSON` 时,管理台会显示当前模式风险与自动持久化状态(含 `DS2API_CONFIG_PATH` 路径与模式切换说明)。 + ## 鉴权模式 调用业务接口(`/v1/*`、`/anthropic/*`、Gemini 路由)时支持两种模式: diff --git a/README.en.md b/README.en.md index 2127aa0..cf2e376 100644 --- a/README.en.md +++ b/README.en.md @@ -320,6 +320,7 @@ cp opencode.json.example opencode.json | `DS2API_CONFIG_PATH` | Config file path | `config.json` | | `DS2API_CONFIG_JSON` | Inline config (JSON or Base64) | — | | `CONFIG_JSON` | Legacy compatibility config input | — | +| `DS2API_ENV_WRITEBACK` | Auto-write env-backed config to file and transition to file mode (`1/true/yes/on`) | Disabled | | `DS2API_WASM_PATH` | PoW WASM file path | Auto-detect | | `DS2API_STATIC_ADMIN_DIR` | Admin static assets dir | `static/admin` | | `DS2API_AUTO_BUILD_WEBUI` | Auto-build WebUI on startup | Enabled locally, disabled on Vercel | @@ -339,6 +340,8 @@ cp opencode.json.example opencode.json | `VERCEL_TEAM_ID` | Vercel team ID | — | | `DS2API_VERCEL_PROTECTION_BYPASS` | Vercel deployment protection bypass for internal Node→Go calls | — | +> Note: when `DS2API_CONFIG_JSON/CONFIG_JSON` is detected, the Admin UI shows mode risk and auto-persistence status (including `DS2API_CONFIG_PATH` and mode-transition hints). + ## Authentication Modes For business endpoints (`/v1/*`, `/anthropic/*`, Gemini routes), DS2API supports two modes: diff --git a/docs/DEPLOY.en.md b/docs/DEPLOY.en.md index 33bf0c7..917bd71 100644 --- a/docs/DEPLOY.en.md +++ b/docs/DEPLOY.en.md @@ -248,6 +248,7 @@ VERCEL_TEAM_ID=team_xxxxxxxxxxxx # optional for personal accounts | `DS2API_ACCOUNT_QUEUE_SIZE` | Alias (legacy compat) | — | | `DS2API_GLOBAL_MAX_INFLIGHT` | Global inflight limit | `recommended_concurrency` | | `DS2API_MAX_INFLIGHT` | Alias (legacy compat) | — | +| `DS2API_ENV_WRITEBACK` | When `DS2API_CONFIG_JSON` is present, auto-write to `DS2API_CONFIG_PATH` and switch to file-backed mode after success (`1/true/yes/on`) | Disabled | | `DS2API_VERCEL_INTERNAL_SECRET` | Hybrid streaming internal auth | Falls back to `DS2API_ADMIN_KEY` | | `DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS` | Stream lease TTL | `900` | | `VERCEL_TOKEN` | Vercel sync token | — | diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 01b64ac..61b50f0 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -248,6 +248,7 @@ VERCEL_TEAM_ID=team_xxxxxxxxxxxx # 个人账号可留空 | `DS2API_ACCOUNT_QUEUE_SIZE` | 同上(兼容别名) | — | | `DS2API_GLOBAL_MAX_INFLIGHT` | 全局并发上限 | `recommended_concurrency` | | `DS2API_MAX_INFLIGHT` | 同上(兼容别名) | — | +| `DS2API_ENV_WRITEBACK` | 检测到 `DS2API_CONFIG_JSON` 时自动写入 `DS2API_CONFIG_PATH`,并在成功后转为文件模式(`1/true/yes/on`) | 关闭 | | `DS2API_VERCEL_INTERNAL_SECRET` | 混合流式内部鉴权 | 回退用 `DS2API_ADMIN_KEY` | | `DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS` | 流式 lease TTL | `900` | | `VERCEL_TOKEN` | Vercel 同步 token | — | diff --git a/internal/admin/deps.go b/internal/admin/deps.go index c7a8472..054deb0 100644 --- a/internal/admin/deps.go +++ b/internal/admin/deps.go @@ -21,6 +21,9 @@ type ConfigStore interface { Update(mutator func(*config.Config) error) error ExportJSONAndBase64() (string, string, error) IsEnvBacked() bool + IsEnvWritebackEnabled() bool + HasEnvConfigSource() bool + ConfigPath() string SetVercelSync(hash string, ts int64) error AdminPasswordHash() string AdminJWTExpireHours() int diff --git a/internal/admin/handler_config_read.go b/internal/admin/handler_config_read.go index 9839887..73eda25 100644 --- a/internal/admin/handler_config_read.go +++ b/internal/admin/handler_config_read.go @@ -8,9 +8,12 @@ import ( func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) { snap := h.Store.Snapshot() safe := map[string]any{ - "keys": snap.Keys, - "accounts": []map[string]any{}, - "env_backed": h.Store.IsEnvBacked(), + "keys": snap.Keys, + "accounts": []map[string]any{}, + "env_backed": h.Store.IsEnvBacked(), + "env_source_present": h.Store.HasEnvConfigSource(), + "env_writeback_enabled": h.Store.IsEnvWritebackEnabled(), + "config_path": h.Store.ConfigPath(), "claude_mapping": func() map[string]string { if len(snap.ClaudeMapping) > 0 { return snap.ClaudeMapping diff --git a/internal/config/store.go b/internal/config/store.go index feebb99..cf02967 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -267,6 +267,22 @@ func (s *Store) IsEnvBacked() bool { return s.fromEnv } +func (s *Store) IsEnvWritebackEnabled() bool { + return envWritebackEnabled() +} + +func (s *Store) HasEnvConfigSource() bool { + rawCfg := strings.TrimSpace(os.Getenv("DS2API_CONFIG_JSON")) + if rawCfg == "" { + rawCfg = strings.TrimSpace(os.Getenv("CONFIG_JSON")) + } + return rawCfg != "" +} + +func (s *Store) ConfigPath() string { + return s.path +} + func (s *Store) SetVercelSync(hash string, ts int64) error { return s.Update(func(c *Config) error { c.VercelSyncHash = hash diff --git a/webui/src/features/account/AccountManagerContainer.jsx b/webui/src/features/account/AccountManagerContainer.jsx index 6f70b66..bfe77d7 100644 --- a/webui/src/features/account/AccountManagerContainer.jsx +++ b/webui/src/features/account/AccountManagerContainer.jsx @@ -64,6 +64,27 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage, return (
+ {Boolean(config?.env_source_present) && ( +
+

+ {config?.env_writeback_enabled + ? (config?.env_backed + ? t('accountManager.envModeWritebackPendingTitle') + : t('accountManager.envModeWritebackActiveTitle')) + : t('accountManager.envModeRiskTitle')} +

+

+ {config?.env_writeback_enabled + ? t('accountManager.envModeWritebackDesc', { path: config?.config_path || 'config.json' }) + : t('accountManager.envModeRiskDesc')} +

+
+ )} + Date: Mon, 30 Mar 2026 21:38:19 +0800 Subject: [PATCH 4/5] refactor(config): split writeback helpers out of store.go for CI gate --- internal/config/store.go | 44 ---------------------- internal/config/store_env_writeback.go | 51 ++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 44 deletions(-) create mode 100644 internal/config/store_env_writeback.go diff --git a/internal/config/store.go b/internal/config/store.go index cf02967..9375bcf 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -4,19 +4,12 @@ import ( "encoding/base64" "encoding/json" "errors" - "fmt" "os" - "path/filepath" "slices" "strings" "sync" ) -func envWritebackEnabled() bool { - v := strings.ToLower(strings.TrimSpace(os.Getenv("DS2API_ENV_WRITEBACK"))) - return v == "1" || v == "true" || v == "yes" || v == "on" -} - type Store struct { mu sync.RWMutex cfg Config @@ -240,49 +233,12 @@ func (s *Store) saveLocked() error { return nil } -func writeConfigFile(path string, cfg Config) error { - persistCfg := cfg.Clone() - persistCfg.ClearAccountTokens() - b, err := json.MarshalIndent(persistCfg, "", " ") - if err != nil { - return err - } - return writeConfigBytes(path, b) -} - -func writeConfigBytes(path string, b []byte) error { - dir := filepath.Dir(path) - if dir == "." || dir == "" { - return os.WriteFile(path, b, 0o644) - } - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("mkdir config dir: %w", err) - } - return os.WriteFile(path, b, 0o644) -} - func (s *Store) IsEnvBacked() bool { s.mu.RLock() defer s.mu.RUnlock() return s.fromEnv } -func (s *Store) IsEnvWritebackEnabled() bool { - return envWritebackEnabled() -} - -func (s *Store) HasEnvConfigSource() bool { - rawCfg := strings.TrimSpace(os.Getenv("DS2API_CONFIG_JSON")) - if rawCfg == "" { - rawCfg = strings.TrimSpace(os.Getenv("CONFIG_JSON")) - } - return rawCfg != "" -} - -func (s *Store) ConfigPath() string { - return s.path -} - func (s *Store) SetVercelSync(hash string, ts int64) error { return s.Update(func(c *Config) error { c.VercelSyncHash = hash diff --git a/internal/config/store_env_writeback.go b/internal/config/store_env_writeback.go new file mode 100644 index 0000000..35e315c --- /dev/null +++ b/internal/config/store_env_writeback.go @@ -0,0 +1,51 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +func envWritebackEnabled() bool { + v := strings.ToLower(strings.TrimSpace(os.Getenv("DS2API_ENV_WRITEBACK"))) + return v == "1" || v == "true" || v == "yes" || v == "on" +} + +func (s *Store) IsEnvWritebackEnabled() bool { + return envWritebackEnabled() +} + +func (s *Store) HasEnvConfigSource() bool { + rawCfg := strings.TrimSpace(os.Getenv("DS2API_CONFIG_JSON")) + if rawCfg == "" { + rawCfg = strings.TrimSpace(os.Getenv("CONFIG_JSON")) + } + return rawCfg != "" +} + +func (s *Store) ConfigPath() string { + return s.path +} + +func writeConfigFile(path string, cfg Config) error { + persistCfg := cfg.Clone() + persistCfg.ClearAccountTokens() + b, err := json.MarshalIndent(persistCfg, "", " ") + if err != nil { + return err + } + return writeConfigBytes(path, b) +} + +func writeConfigBytes(path string, b []byte) error { + dir := filepath.Dir(path) + if dir == "." || dir == "" { + return os.WriteFile(path, b, 0o644) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir config dir: %w", err) + } + return os.WriteFile(path, b, 0o644) +} From b54b418f96378febb122b17999a2767fa0af8eec Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Mon, 30 Mar 2026 23:39:47 +0800 Subject: [PATCH 5/5] fix(sse): globally strip leaked CONTENT_FILTER suffix from output --- internal/sse/content_filter_leak.go | 30 +++++++++++++++++++++++++++++ internal/sse/line.go | 1 + internal/sse/line_test.go | 20 +++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 internal/sse/content_filter_leak.go diff --git a/internal/sse/content_filter_leak.go b/internal/sse/content_filter_leak.go new file mode 100644 index 0000000..87dff7b --- /dev/null +++ b/internal/sse/content_filter_leak.go @@ -0,0 +1,30 @@ +package sse + +import "strings" + +func filterLeakedContentFilterParts(parts []ContentPart) []ContentPart { + if len(parts) == 0 { + return parts + } + out := make([]ContentPart, 0, len(parts)) + for _, p := range parts { + cleaned := stripLeakedContentFilterSuffix(p.Text) + if strings.TrimSpace(cleaned) == "" { + continue + } + p.Text = cleaned + out = append(out, p) + } + return out +} + +func stripLeakedContentFilterSuffix(text string) string { + if text == "" { + return text + } + idx := strings.Index(strings.ToUpper(text), "CONTENT_FILTER") + if idx < 0 { + return text + } + return strings.TrimRight(text[:idx], " \t\r\n") +} diff --git a/internal/sse/line.go b/internal/sse/line.go index b71b2b0..e63f378 100644 --- a/internal/sse/line.go +++ b/internal/sse/line.go @@ -40,6 +40,7 @@ func ParseDeepSeekContentLine(raw []byte, thinkingEnabled bool, currentType stri } } parts, finished, nextType := ParseSSEChunkForContent(chunk, thinkingEnabled, currentType) + parts = filterLeakedContentFilterParts(parts) return LineResult{ Parsed: true, Stop: finished, diff --git a/internal/sse/line_test.go b/internal/sse/line_test.go index 3292a54..8e27f73 100644 --- a/internal/sse/line_test.go +++ b/internal/sse/line_test.go @@ -35,3 +35,23 @@ func TestParseDeepSeekContentLineContent(t *testing.T) { t.Fatalf("unexpected parts: %#v", res.Parts) } } + +func TestParseDeepSeekContentLineStripsLeakedContentFilterSuffix(t *testing.T) { + res := ParseDeepSeekContentLine([]byte(`data: {"p":"response/content","v":"正常输出CONTENT_FILTER你好,这个问题我暂时无法回答"}`), false, "text") + if !res.Parsed || res.Stop { + t.Fatalf("expected parsed non-stop result: %#v", res) + } + if len(res.Parts) != 1 || res.Parts[0].Text != "正常输出" { + t.Fatalf("unexpected parts after filter: %#v", res.Parts) + } +} + +func TestParseDeepSeekContentLineDropsPureLeakedContentFilterChunk(t *testing.T) { + res := ParseDeepSeekContentLine([]byte(`data: {"p":"response/content","v":"CONTENT_FILTER你好,这个问题我暂时无法回答"}`), false, "text") + if !res.Parsed || res.Stop { + t.Fatalf("expected parsed non-stop result: %#v", res) + } + if len(res.Parts) != 0 { + t.Fatalf("expected empty parts, got %#v", res.Parts) + } +}