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:
CJACK.
2026-03-31 00:01:27 +08:00
committed by GitHub
17 changed files with 266 additions and 9 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -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 路由)时支持两种模式:

View File

@@ -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:

View File

@@ -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 | — |

View File

@@ -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 | — |

View File

@@ -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

View File

@@ -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

View File

@@ -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"],

View File

@@ -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 {

View 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)
}

View 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")
}

View File

@@ -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,

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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.",

View File

@@ -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": "你好,请用一句话介绍你自己。",