mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-15 13:45:10 +08:00
Merge pull request #171 from CJackHwang/codex/fix-issue-#170-in-ds2api
Enable env-backed config writeback and bootstrap missing config file
This commit is contained in:
@@ -587,6 +587,9 @@ Returns sanitized config.
|
|||||||
{
|
{
|
||||||
"keys": ["k1", "k2"],
|
"keys": ["k1", "k2"],
|
||||||
"env_backed": false,
|
"env_backed": false,
|
||||||
|
"env_source_present": true,
|
||||||
|
"env_writeback_enabled": true,
|
||||||
|
"config_path": "/data/config.json",
|
||||||
"accounts": [
|
"accounts": [
|
||||||
{
|
{
|
||||||
"identifier": "user@example.com",
|
"identifier": "user@example.com",
|
||||||
|
|||||||
3
API.md
3
API.md
@@ -596,6 +596,9 @@ data: {"type":"message_stop"}
|
|||||||
{
|
{
|
||||||
"keys": ["k1", "k2"],
|
"keys": ["k1", "k2"],
|
||||||
"env_backed": false,
|
"env_backed": false,
|
||||||
|
"env_source_present": true,
|
||||||
|
"env_writeback_enabled": true,
|
||||||
|
"config_path": "/data/config.json",
|
||||||
"accounts": [
|
"accounts": [
|
||||||
{
|
{
|
||||||
"identifier": "user@example.com",
|
"identifier": "user@example.com",
|
||||||
|
|||||||
@@ -320,6 +320,7 @@ cp opencode.json.example opencode.json
|
|||||||
| `DS2API_CONFIG_PATH` | 配置文件路径 | `config.json` |
|
| `DS2API_CONFIG_PATH` | 配置文件路径 | `config.json` |
|
||||||
| `DS2API_CONFIG_JSON` | 直接注入配置(JSON 或 Base64) | — |
|
| `DS2API_CONFIG_JSON` | 直接注入配置(JSON 或 Base64) | — |
|
||||||
| `CONFIG_JSON` | 旧版兼容配置注入 | — |
|
| `CONFIG_JSON` | 旧版兼容配置注入 | — |
|
||||||
|
| `DS2API_ENV_WRITEBACK` | 环境变量模式下自动写回配置文件并切换文件模式(`1/true/yes/on`) | 关闭 |
|
||||||
| `DS2API_WASM_PATH` | PoW WASM 文件路径 | 自动查找 |
|
| `DS2API_WASM_PATH` | PoW WASM 文件路径 | 自动查找 |
|
||||||
| `DS2API_STATIC_ADMIN_DIR` | 管理台静态文件目录 | `static/admin` |
|
| `DS2API_STATIC_ADMIN_DIR` | 管理台静态文件目录 | `static/admin` |
|
||||||
| `DS2API_AUTO_BUILD_WEBUI` | 启动时自动构建 WebUI | 本地开启,Vercel 关闭 |
|
| `DS2API_AUTO_BUILD_WEBUI` | 启动时自动构建 WebUI | 本地开启,Vercel 关闭 |
|
||||||
@@ -342,6 +343,8 @@ cp opencode.json.example opencode.json
|
|||||||
| `VERCEL_TEAM_ID` | Vercel 团队 ID | — |
|
| `VERCEL_TEAM_ID` | Vercel 团队 ID | — |
|
||||||
| `DS2API_VERCEL_PROTECTION_BYPASS` | Vercel 部署保护绕过密钥(内部 Node→Go 调用) | — |
|
| `DS2API_VERCEL_PROTECTION_BYPASS` | Vercel 部署保护绕过密钥(内部 Node→Go 调用) | — |
|
||||||
|
|
||||||
|
> 提示:当检测到 `DS2API_CONFIG_JSON/CONFIG_JSON` 时,管理台会显示当前模式风险与自动持久化状态(含 `DS2API_CONFIG_PATH` 路径与模式切换说明)。
|
||||||
|
|
||||||
## 鉴权模式
|
## 鉴权模式
|
||||||
|
|
||||||
调用业务接口(`/v1/*`、`/anthropic/*`、Gemini 路由)时支持两种模式:
|
调用业务接口(`/v1/*`、`/anthropic/*`、Gemini 路由)时支持两种模式:
|
||||||
|
|||||||
@@ -320,6 +320,7 @@ cp opencode.json.example opencode.json
|
|||||||
| `DS2API_CONFIG_PATH` | Config file path | `config.json` |
|
| `DS2API_CONFIG_PATH` | Config file path | `config.json` |
|
||||||
| `DS2API_CONFIG_JSON` | Inline config (JSON or Base64) | — |
|
| `DS2API_CONFIG_JSON` | Inline config (JSON or Base64) | — |
|
||||||
| `CONFIG_JSON` | Legacy compatibility config input | — |
|
| `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_WASM_PATH` | PoW WASM file path | Auto-detect |
|
||||||
| `DS2API_STATIC_ADMIN_DIR` | Admin static assets dir | `static/admin` |
|
| `DS2API_STATIC_ADMIN_DIR` | Admin static assets dir | `static/admin` |
|
||||||
| `DS2API_AUTO_BUILD_WEBUI` | Auto-build WebUI on startup | Enabled locally, disabled on Vercel |
|
| `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 | — |
|
| `VERCEL_TEAM_ID` | Vercel team ID | — |
|
||||||
| `DS2API_VERCEL_PROTECTION_BYPASS` | Vercel deployment protection bypass for internal Node→Go calls | — |
|
| `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
|
## Authentication Modes
|
||||||
|
|
||||||
For business endpoints (`/v1/*`, `/anthropic/*`, Gemini routes), DS2API supports two modes:
|
For business endpoints (`/v1/*`, `/anthropic/*`, Gemini routes), DS2API supports two modes:
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ VERCEL_TEAM_ID=team_xxxxxxxxxxxx # optional for personal accounts
|
|||||||
| `DS2API_ACCOUNT_QUEUE_SIZE` | Alias (legacy compat) | — |
|
| `DS2API_ACCOUNT_QUEUE_SIZE` | Alias (legacy compat) | — |
|
||||||
| `DS2API_GLOBAL_MAX_INFLIGHT` | Global inflight limit | `recommended_concurrency` |
|
| `DS2API_GLOBAL_MAX_INFLIGHT` | Global inflight limit | `recommended_concurrency` |
|
||||||
| `DS2API_MAX_INFLIGHT` | Alias (legacy compat) | — |
|
| `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_INTERNAL_SECRET` | Hybrid streaming internal auth | Falls back to `DS2API_ADMIN_KEY` |
|
||||||
| `DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS` | Stream lease TTL | `900` |
|
| `DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS` | Stream lease TTL | `900` |
|
||||||
| `VERCEL_TOKEN` | Vercel sync token | — |
|
| `VERCEL_TOKEN` | Vercel sync token | — |
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ VERCEL_TEAM_ID=team_xxxxxxxxxxxx # 个人账号可留空
|
|||||||
| `DS2API_ACCOUNT_QUEUE_SIZE` | 同上(兼容别名) | — |
|
| `DS2API_ACCOUNT_QUEUE_SIZE` | 同上(兼容别名) | — |
|
||||||
| `DS2API_GLOBAL_MAX_INFLIGHT` | 全局并发上限 | `recommended_concurrency` |
|
| `DS2API_GLOBAL_MAX_INFLIGHT` | 全局并发上限 | `recommended_concurrency` |
|
||||||
| `DS2API_MAX_INFLIGHT` | 同上(兼容别名) | — |
|
| `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_INTERNAL_SECRET` | 混合流式内部鉴权 | 回退用 `DS2API_ADMIN_KEY` |
|
||||||
| `DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS` | 流式 lease TTL | `900` |
|
| `DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS` | 流式 lease TTL | `900` |
|
||||||
| `VERCEL_TOKEN` | Vercel 同步 token | — |
|
| `VERCEL_TOKEN` | Vercel 同步 token | — |
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ type ConfigStore interface {
|
|||||||
Update(mutator func(*config.Config) error) error
|
Update(mutator func(*config.Config) error) error
|
||||||
ExportJSONAndBase64() (string, string, error)
|
ExportJSONAndBase64() (string, string, error)
|
||||||
IsEnvBacked() bool
|
IsEnvBacked() bool
|
||||||
|
IsEnvWritebackEnabled() bool
|
||||||
|
HasEnvConfigSource() bool
|
||||||
|
ConfigPath() string
|
||||||
SetVercelSync(hash string, ts int64) error
|
SetVercelSync(hash string, ts int64) error
|
||||||
AdminPasswordHash() string
|
AdminPasswordHash() string
|
||||||
AdminJWTExpireHours() int
|
AdminJWTExpireHours() int
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import (
|
|||||||
func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
|
func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
|
||||||
snap := h.Store.Snapshot()
|
snap := h.Store.Snapshot()
|
||||||
safe := map[string]any{
|
safe := map[string]any{
|
||||||
"keys": snap.Keys,
|
"keys": snap.Keys,
|
||||||
"accounts": []map[string]any{},
|
"accounts": []map[string]any{},
|
||||||
"env_backed": h.Store.IsEnvBacked(),
|
"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 {
|
"claude_mapping": func() map[string]string {
|
||||||
if len(snap.ClaudeMapping) > 0 {
|
if len(snap.ClaudeMapping) > 0 {
|
||||||
return snap.ClaudeMapping
|
return snap.ClaudeMapping
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -79,6 +80,80 @@ 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 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) {
|
func TestRuntimeTokenRefreshIntervalHoursDefaultsToSix(t *testing.T) {
|
||||||
t.Setenv("DS2API_CONFIG_JSON", `{
|
t.Setenv("DS2API_CONFIG_JSON", `{
|
||||||
"keys":["k1"],
|
"keys":["k1"],
|
||||||
|
|||||||
@@ -40,8 +40,29 @@ func loadConfig() (Config, bool, error) {
|
|||||||
}
|
}
|
||||||
if rawCfg != "" {
|
if rawCfg != "" {
|
||||||
cfg, err := parseConfigString(rawCfg)
|
cfg, err := parseConfigString(rawCfg)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, true, err
|
||||||
|
}
|
||||||
cfg.ClearAccountTokens()
|
cfg.ClearAccountTokens()
|
||||||
cfg.DropInvalidAccounts()
|
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
|
return cfg, true, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +198,7 @@ func (s *Store) Update(mutator func(*Config) error) error {
|
|||||||
func (s *Store) Save() error {
|
func (s *Store) Save() error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
if s.fromEnv {
|
if s.fromEnv && (IsVercel() || !envWritebackEnabled()) {
|
||||||
Logger.Info("[save_config] source from env, skip write")
|
Logger.Info("[save_config] source from env, skip write")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -187,11 +208,15 @@ func (s *Store) Save() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
func (s *Store) saveLocked() error {
|
||||||
if s.fromEnv {
|
if s.fromEnv && (IsVercel() || !envWritebackEnabled()) {
|
||||||
Logger.Info("[save_config] source from env, skip write")
|
Logger.Info("[save_config] source from env, skip write")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -201,7 +226,11 @@ func (s *Store) saveLocked() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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) IsEnvBacked() bool {
|
func (s *Store) IsEnvBacked() bool {
|
||||||
|
|||||||
51
internal/config/store_env_writeback.go
Normal file
51
internal/config/store_env_writeback.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
30
internal/sse/content_filter_leak.go
Normal file
30
internal/sse/content_filter_leak.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ func ParseDeepSeekContentLine(raw []byte, thinkingEnabled bool, currentType stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
parts, finished, nextType := ParseSSEChunkForContent(chunk, thinkingEnabled, currentType)
|
parts, finished, nextType := ParseSSEChunkForContent(chunk, thinkingEnabled, currentType)
|
||||||
|
parts = filterLeakedContentFilterParts(parts)
|
||||||
return LineResult{
|
return LineResult{
|
||||||
Parsed: true,
|
Parsed: true,
|
||||||
Stop: finished,
|
Stop: finished,
|
||||||
|
|||||||
@@ -35,3 +35,23 @@ func TestParseDeepSeekContentLineContent(t *testing.T) {
|
|||||||
t.Fatalf("unexpected parts: %#v", res.Parts)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,6 +64,27 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{Boolean(config?.env_source_present) && (
|
||||||
|
<div className={`rounded-xl border px-4 py-3 text-sm ${
|
||||||
|
config?.env_writeback_enabled
|
||||||
|
? (config?.env_backed ? 'border-amber-500/30 bg-amber-500/10 text-amber-600' : 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600')
|
||||||
|
: 'border-amber-500/30 bg-amber-500/10 text-amber-600'
|
||||||
|
}`}>
|
||||||
|
<p className="font-medium">
|
||||||
|
{config?.env_writeback_enabled
|
||||||
|
? (config?.env_backed
|
||||||
|
? t('accountManager.envModeWritebackPendingTitle')
|
||||||
|
: t('accountManager.envModeWritebackActiveTitle'))
|
||||||
|
: t('accountManager.envModeRiskTitle')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs opacity-90">
|
||||||
|
{config?.env_writeback_enabled
|
||||||
|
? t('accountManager.envModeWritebackDesc', { path: config?.config_path || 'config.json' })
|
||||||
|
: t('accountManager.envModeRiskDesc')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<QueueCards queueStatus={queueStatus} t={t} />
|
<QueueCards queueStatus={queueStatus} t={t} />
|
||||||
|
|
||||||
<ApiKeysPanel
|
<ApiKeysPanel
|
||||||
|
|||||||
@@ -138,7 +138,12 @@
|
|||||||
"sessionCount": "Sessions: {count}",
|
"sessionCount": "Sessions: {count}",
|
||||||
"deleteAllSessions": "Delete all sessions",
|
"deleteAllSessions": "Delete all sessions",
|
||||||
"deleteAllSessionsConfirm": "Are you sure you want to delete all sessions for this account? This action cannot be undone.",
|
"deleteAllSessionsConfirm": "Are you sure you want to delete all sessions for this account? This action cannot be undone.",
|
||||||
"deleteAllSessionsSuccess": "Successfully deleted all sessions"
|
"deleteAllSessionsSuccess": "Successfully deleted all sessions",
|
||||||
|
"envModeRiskTitle": "Environment-variable config mode detected (persistence risk)",
|
||||||
|
"envModeRiskDesc": "Detected DS2API_CONFIG_JSON/CONFIG_JSON. If DS2API_ENV_WRITEBACK is not enabled, Admin UI edits are in-memory only and may be lost after restart.",
|
||||||
|
"envModeWritebackPendingTitle": "Env mode + auto-persistence enabled (pending file handoff)",
|
||||||
|
"envModeWritebackActiveTitle": "Env mode + auto-persistence active",
|
||||||
|
"envModeWritebackDesc": "The app will auto-create/write the config file and transition to file-backed mode. Current persistence path: {path}"
|
||||||
},
|
},
|
||||||
"apiTester": {
|
"apiTester": {
|
||||||
"defaultMessage": "Hello, please introduce yourself in one sentence.",
|
"defaultMessage": "Hello, please introduce yourself in one sentence.",
|
||||||
|
|||||||
@@ -138,7 +138,12 @@
|
|||||||
"sessionCount": "会话: {count}",
|
"sessionCount": "会话: {count}",
|
||||||
"deleteAllSessions": "删除所有会话",
|
"deleteAllSessions": "删除所有会话",
|
||||||
"deleteAllSessionsConfirm": "确定要删除该账号的所有会话吗?此操作不可恢复。",
|
"deleteAllSessionsConfirm": "确定要删除该账号的所有会话吗?此操作不可恢复。",
|
||||||
"deleteAllSessionsSuccess": "删除成功"
|
"deleteAllSessionsSuccess": "删除成功",
|
||||||
|
"envModeRiskTitle": "当前为环境变量配置模式(有持久化风险)",
|
||||||
|
"envModeRiskDesc": "检测到 DS2API_CONFIG_JSON/CONFIG_JSON。若未开启 DS2API_ENV_WRITEBACK,管理台改动仅在内存生效,重启可能丢失。",
|
||||||
|
"envModeWritebackPendingTitle": "环境变量模式 + 自动持久化已开启(等待落盘)",
|
||||||
|
"envModeWritebackActiveTitle": "环境变量模式 + 自动持久化已生效",
|
||||||
|
"envModeWritebackDesc": "程序会自动创建/写入配置文件并在后续切换为文件模式。当前持久化路径:{path}"
|
||||||
},
|
},
|
||||||
"apiTester": {
|
"apiTester": {
|
||||||
"defaultMessage": "你好,请用一句话介绍你自己。",
|
"defaultMessage": "你好,请用一句话介绍你自己。",
|
||||||
|
|||||||
Reference in New Issue
Block a user