diff --git a/API.en.md b/API.en.md index 85c4982..06d84ff 100644 --- a/API.en.md +++ b/API.en.md @@ -629,7 +629,7 @@ Reads runtime settings and status, including: - `success` - `admin` (`has_password_hash`, `jwt_expire_hours`, `jwt_valid_after_unix`, `default_password_warning`) -- `runtime` (`account_max_inflight`, `account_max_queue`, `global_max_inflight`) +- `runtime` (`account_max_inflight`, `account_max_queue`, `global_max_inflight`, `token_refresh_interval_hours`) - `toolcall` / `responses` / `embeddings` - `auto_delete` (`sessions`) - `claude_mapping` / `model_aliases` @@ -640,7 +640,7 @@ Reads runtime settings and status, including: Hot-updates runtime settings. Supported fields: - `admin.jwt_expire_hours` -- `runtime.account_max_inflight` / `runtime.account_max_queue` / `runtime.global_max_inflight` +- `runtime.account_max_inflight` / `runtime.account_max_queue` / `runtime.global_max_inflight` / `runtime.token_refresh_interval_hours` - `toolcall.mode` / `toolcall.early_emit_confidence` - `responses.store_ttl_seconds` - `embeddings.provider` diff --git a/API.md b/API.md index 484a192..7bb6763 100644 --- a/API.md +++ b/API.md @@ -638,7 +638,7 @@ data: {"type":"message_stop"} - `success` - `admin`(`has_password_hash`、`jwt_expire_hours`、`jwt_valid_after_unix`、`default_password_warning`) -- `runtime`(`account_max_inflight`、`account_max_queue`、`global_max_inflight`) +- `runtime`(`account_max_inflight`、`account_max_queue`、`global_max_inflight`、`token_refresh_interval_hours`) - `toolcall` / `responses` / `embeddings` - `auto_delete`(`sessions`) - `claude_mapping` / `model_aliases` @@ -649,7 +649,7 @@ data: {"type":"message_stop"} 热更新运行时设置。支持更新: - `admin.jwt_expire_hours` -- `runtime.account_max_inflight` / `runtime.account_max_queue` / `runtime.global_max_inflight` +- `runtime.account_max_inflight` / `runtime.account_max_queue` / `runtime.global_max_inflight` / `runtime.token_refresh_interval_hours` - `toolcall.mode` / `toolcall.early_emit_confidence` - `responses.store_ttl_seconds` - `embeddings.provider` diff --git a/README.MD b/README.MD index 82c4434..a178d9e 100644 --- a/README.MD +++ b/README.MD @@ -290,7 +290,8 @@ cp opencode.json.example opencode.json "runtime": { "account_max_inflight": 2, "account_max_queue": 0, - "global_max_inflight": 0 + "global_max_inflight": 0, + "token_refresh_interval_hours": 6 }, "auto_delete": { "sessions": false @@ -308,7 +309,7 @@ cp opencode.json.example opencode.json - `embeddings.provider`:embedding 提供方(当前内置 `deterministic/mock/builtin`) - `claude_mapping`:字典中 `fast`/`slow` 后缀映射到对应 DeepSeek 模型(兼容读取 `claude_model_mapping`) - `admin`:管理后台设置(JWT 过期时间、密码哈希等),可通过 Admin Settings API 热更新 -- `runtime`:运行时参数(并发限制、队列大小),可通过 Admin Settings API 热更新;`account_max_queue=0`/`global_max_inflight=0` 表示按推荐值自动计算 +- `runtime`:运行时参数(并发限制、队列大小、托管账号 token 刷新间隔),可通过 Admin Settings API 热更新;`account_max_queue=0`/`global_max_inflight=0` 表示按推荐值自动计算,`token_refresh_interval_hours=6` 为默认强制重登间隔 - `auto_delete.sessions`:是否在请求结束后自动清理 DeepSeek 会话(默认 `false`,可在 Settings 热更新) ### 环境变量 diff --git a/README.en.md b/README.en.md index a4cfacc..e837017 100644 --- a/README.en.md +++ b/README.en.md @@ -290,7 +290,8 @@ cp opencode.json.example opencode.json "runtime": { "account_max_inflight": 2, "account_max_queue": 0, - "global_max_inflight": 0 + "global_max_inflight": 0, + "token_refresh_interval_hours": 6 }, "auto_delete": { "sessions": false @@ -308,7 +309,7 @@ cp opencode.json.example opencode.json - `embeddings.provider`: Embeddings provider (`deterministic/mock/builtin` built-in) - `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), hot-reloadable via Admin Settings API; `account_max_queue=0`/`global_max_inflight=0` means auto-calculate from recommended values +- `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) ### Environment Variables diff --git a/internal/adapter/openai/leaked_output_sanitize.go b/internal/adapter/openai/leaked_output_sanitize.go index bb06d4d..2e7dcc1 100644 --- a/internal/adapter/openai/leaked_output_sanitize.go +++ b/internal/adapter/openai/leaked_output_sanitize.go @@ -13,9 +13,17 @@ var leakedToolResultBlobPattern = regexp.MustCompile(`(?is)<\s*\|\s*tool\s*\|\s* // - U+2581 variant: <|end▁of▁sentence|> (used in some DeepSeek outputs) var leakedMetaMarkerPattern = regexp.MustCompile(`(?i)<[|\|]\s*(?:assistant|tool|end[_▁]of[_▁]sentence|end[_▁]of[_▁]thinking)\s*[|\|]>`) -// leakedAgentXMLPattern catches agent-style XML tags that leak through when -// the sieve fails to capture them (e.g. incomplete blocks at stream end). -var leakedAgentXMLPattern = regexp.MustCompile(`(?is)`) +// leakedAgentXMLBlockPatterns catch agent-style XML blocks that leak through +// when the sieve fails to capture them. These are applied only to complete +// wrapper blocks so standalone "" examples in normal output remain +// untouched. +var leakedAgentXMLBlockPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?is)]*>(.*?)`), + regexp.MustCompile(`(?is)]*>(.*?)`), + regexp.MustCompile(`(?is)]*>(.*?)`), +} + +var leakedAgentResultTagPattern = regexp.MustCompile(`(?is)`) func sanitizeLeakedOutput(text string) string { if text == "" { @@ -25,6 +33,22 @@ func sanitizeLeakedOutput(text string) string { out = leakedToolCallArrayPattern.ReplaceAllString(out, "") out = leakedToolResultBlobPattern.ReplaceAllString(out, "") out = leakedMetaMarkerPattern.ReplaceAllString(out, "") - out = leakedAgentXMLPattern.ReplaceAllString(out, "") + out = sanitizeLeakedAgentXMLBlocks(out) + return out +} + +func sanitizeLeakedAgentXMLBlocks(text string) string { + out := text + for _, pattern := range leakedAgentXMLBlockPatterns { + out = pattern.ReplaceAllStringFunc(out, func(match string) string { + submatches := pattern.FindStringSubmatch(match) + if len(submatches) < 2 { + return match + } + // Preserve the inner text so leaked agent instructions do not erase + // the actual answer, but strip the wrapper/result markup itself. + return leakedAgentResultTagPattern.ReplaceAllString(submatches[1], "") + }) + } return out } diff --git a/internal/adapter/openai/leaked_output_sanitize_test.go b/internal/adapter/openai/leaked_output_sanitize_test.go index 12e3137..90ce9d1 100644 --- a/internal/adapter/openai/leaked_output_sanitize_test.go +++ b/internal/adapter/openai/leaked_output_sanitize_test.go @@ -33,3 +33,11 @@ func TestSanitizeLeakedOutputRemovesAgentXMLLeaks(t *testing.T) { t.Fatalf("unexpected sanitize result for agent XML leak: %q", got) } } + +func TestSanitizeLeakedOutputPreservesStandaloneResultTags(t *testing.T) { + raw := "Example XML: value" + got := sanitizeLeakedOutput(raw) + if got != raw { + t.Fatalf("unexpected sanitize result for standalone result tag: %q", got) + } +} diff --git a/internal/admin/deps.go b/internal/admin/deps.go index d95eecf..c7a8472 100644 --- a/internal/admin/deps.go +++ b/internal/admin/deps.go @@ -28,6 +28,7 @@ type ConfigStore interface { RuntimeAccountMaxInflight() int RuntimeAccountMaxQueue(defaultSize int) int RuntimeGlobalMaxInflight(defaultSize int) int + RuntimeTokenRefreshIntervalHours() int AutoDeleteSessions() bool } diff --git a/internal/admin/handler_config_import.go b/internal/admin/handler_config_import.go index b9dd1f6..baf8bfe 100644 --- a/internal/admin/handler_config_import.go +++ b/internal/admin/handler_config_import.go @@ -150,6 +150,9 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) { if incoming.Runtime.GlobalMaxInflight > 0 { next.Runtime.GlobalMaxInflight = incoming.Runtime.GlobalMaxInflight } + if incoming.Runtime.TokenRefreshIntervalHours > 0 { + next.Runtime.TokenRefreshIntervalHours = incoming.Runtime.TokenRefreshIntervalHours + } } normalizeSettingsConfig(&next) diff --git a/internal/admin/handler_settings_parse.go b/internal/admin/handler_settings_parse.go index c1d735a..e5d5611 100644 --- a/internal/admin/handler_settings_parse.go +++ b/internal/admin/handler_settings_parse.go @@ -23,14 +23,14 @@ func boolFrom(v any) bool { func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.ToolcallConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, map[string]string, map[string]string, error) { var ( - adminCfg *config.AdminConfig - runtimeCfg *config.RuntimeConfig - toolcallCfg *config.ToolcallConfig - respCfg *config.ResponsesConfig - embCfg *config.EmbeddingsConfig - autoDeleteCfg *config.AutoDeleteConfig - claudeMap map[string]string - aliasMap map[string]string + adminCfg *config.AdminConfig + runtimeCfg *config.RuntimeConfig + toolcallCfg *config.ToolcallConfig + respCfg *config.ResponsesConfig + embCfg *config.EmbeddingsConfig + autoDeleteCfg *config.AutoDeleteConfig + claudeMap map[string]string + aliasMap map[string]string ) if raw, ok := req["admin"].(map[string]any); ok { @@ -68,6 +68,13 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi } cfg.GlobalMaxInflight = n } + if v, exists := raw["token_refresh_interval_hours"]; exists { + n := intFrom(v) + if n < 1 || n > 720 { + return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.token_refresh_interval_hours must be between 1 and 720") + } + cfg.TokenRefreshIntervalHours = n + } if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight { return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight") } diff --git a/internal/admin/handler_settings_read.go b/internal/admin/handler_settings_read.go index 367e40e..3561ee6 100644 --- a/internal/admin/handler_settings_read.go +++ b/internal/admin/handler_settings_read.go @@ -21,9 +21,10 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) { "default_password_warning": authn.UsingDefaultAdminKey(h.Store), }, "runtime": map[string]any{ - "account_max_inflight": h.Store.RuntimeAccountMaxInflight(), - "account_max_queue": h.Store.RuntimeAccountMaxQueue(recommended), - "global_max_inflight": h.Store.RuntimeGlobalMaxInflight(recommended), + "account_max_inflight": h.Store.RuntimeAccountMaxInflight(), + "account_max_queue": h.Store.RuntimeAccountMaxQueue(recommended), + "global_max_inflight": h.Store.RuntimeGlobalMaxInflight(recommended), + "token_refresh_interval_hours": h.Store.RuntimeTokenRefreshIntervalHours(), }, "toolcall": snap.Toolcall, "responses": snap.Responses, diff --git a/internal/admin/handler_settings_runtime.go b/internal/admin/handler_settings_runtime.go index 6ff6902..091c5ae 100644 --- a/internal/admin/handler_settings_runtime.go +++ b/internal/admin/handler_settings_runtime.go @@ -14,6 +14,9 @@ func validateMergedRuntimeSettings(current config.RuntimeConfig, incoming *confi if incoming.GlobalMaxInflight > 0 { merged.GlobalMaxInflight = incoming.GlobalMaxInflight } + if incoming.TokenRefreshIntervalHours > 0 { + merged.TokenRefreshIntervalHours = incoming.TokenRefreshIntervalHours + } } return validateRuntimeSettings(merged) } diff --git a/internal/admin/handler_settings_test.go b/internal/admin/handler_settings_test.go index 2a606fb..feb2996 100644 --- a/internal/admin/handler_settings_test.go +++ b/internal/admin/handler_settings_test.go @@ -28,6 +28,25 @@ func TestGetSettingsDefaultPasswordWarning(t *testing.T) { } } +func TestGetSettingsIncludesTokenRefreshInterval(t *testing.T) { + h := newAdminTestHandler(t, `{ + "keys":["k1"], + "runtime":{"token_refresh_interval_hours":9} + }`) + req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil) + rec := httptest.NewRecorder() + h.getSettings(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var body map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &body) + runtime, _ := body["runtime"].(map[string]any) + if got := intFrom(runtime["token_refresh_interval_hours"]); got != 9 { + t.Fatalf("expected token_refresh_interval_hours=9, got %d body=%v", got, body) + } +} + func TestUpdateSettingsValidation(t *testing.T) { h := newAdminTestHandler(t, `{"keys":["k1"]}`) payload := map[string]any{ @@ -44,6 +63,25 @@ func TestUpdateSettingsValidation(t *testing.T) { } } +func TestUpdateSettingsValidationRejectsTokenRefreshInterval(t *testing.T) { + h := newAdminTestHandler(t, `{"keys":["k1"]}`) + payload := map[string]any{ + "runtime": map[string]any{ + "token_refresh_interval_hours": 0, + }, + } + 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("runtime.token_refresh_interval_hours")) { + t.Fatalf("expected token refresh validation detail, got %s", rec.Body.String()) + } +} + func TestUpdateSettingsValidationWithMergedRuntimeSnapshot(t *testing.T) { h := newAdminTestHandler(t, `{ "keys":["k1"], @@ -126,6 +164,29 @@ func TestUpdateSettingsHotReloadRuntime(t *testing.T) { } } +func TestUpdateSettingsHotReloadTokenRefreshInterval(t *testing.T) { + h := newAdminTestHandler(t, `{ + "keys":["k1"], + "runtime":{"token_refresh_interval_hours":6} + }`) + + payload := map[string]any{ + "runtime": map[string]any{ + "token_refresh_interval_hours": 12, + }, + } + 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()) + } + if got := h.Store.RuntimeTokenRefreshIntervalHours(); got != 12 { + t.Fatalf("token_refresh_interval_hours=%d want=12", got) + } +} + func TestUpdateSettingsPasswordInvalidatesOldJWT(t *testing.T) { hash := authn.HashAdminPassword("old-password") h := newAdminTestHandler(t, `{"admin":{"password_hash":"`+hash+`"}}`) @@ -207,6 +268,30 @@ func TestConfigImportMergeAndReplace(t *testing.T) { } } +func TestConfigImportAppliesTokenRefreshInterval(t *testing.T) { + h := newAdminTestHandler(t, `{"keys":["k1"]}`) + + replace := map[string]any{ + "mode": "replace", + "config": map[string]any{ + "keys": []any{"k9"}, + "runtime": map[string]any{ + "token_refresh_interval_hours": 11, + }, + }, + } + replaceBytes, _ := json.Marshal(replace) + replaceReq := httptest.NewRequest(http.MethodPost, "/admin/config/import?mode=replace", bytes.NewReader(replaceBytes)) + replaceRec := httptest.NewRecorder() + h.configImport(replaceRec, replaceReq) + if replaceRec.Code != http.StatusOK { + t.Fatalf("replace status=%d body=%s", replaceRec.Code, replaceRec.Body.String()) + } + if got := h.Store.RuntimeTokenRefreshIntervalHours(); got != 11 { + t.Fatalf("token_refresh_interval_hours=%d want=11", got) + } +} + func TestConfigImportRejectsInvalidRuntimeBounds(t *testing.T) { h := newAdminTestHandler(t, `{"keys":["k1"]}`) payload := map[string]any{ diff --git a/internal/admin/handler_settings_write.go b/internal/admin/handler_settings_write.go index 76d106b..7f24dc3 100644 --- a/internal/admin/handler_settings_write.go +++ b/internal/admin/handler_settings_write.go @@ -45,6 +45,9 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) { if runtimeCfg.GlobalMaxInflight > 0 { c.Runtime.GlobalMaxInflight = runtimeCfg.GlobalMaxInflight } + if runtimeCfg.TokenRefreshIntervalHours > 0 { + c.Runtime.TokenRefreshIntervalHours = runtimeCfg.TokenRefreshIntervalHours + } } if toolcallCfg != nil { if strings.TrimSpace(toolcallCfg.Mode) != "" { diff --git a/internal/admin/settings_validation.go b/internal/admin/settings_validation.go index f9d4c2f..fb4a461 100644 --- a/internal/admin/settings_validation.go +++ b/internal/admin/settings_validation.go @@ -57,6 +57,9 @@ func validateRuntimeSettings(runtime config.RuntimeConfig) error { if runtime.GlobalMaxInflight != 0 && (runtime.GlobalMaxInflight < 1 || runtime.GlobalMaxInflight > 200000) { return fmt.Errorf("runtime.global_max_inflight must be between 1 and 200000") } + if runtime.TokenRefreshIntervalHours != 0 && (runtime.TokenRefreshIntervalHours < 1 || runtime.TokenRefreshIntervalHours > 720) { + return fmt.Errorf("runtime.token_refresh_interval_hours must be between 1 and 720") + } if runtime.AccountMaxInflight > 0 && runtime.GlobalMaxInflight > 0 && runtime.GlobalMaxInflight < runtime.AccountMaxInflight { return fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight") } diff --git a/internal/auth/request.go b/internal/auth/request.go index ffcd980..fa39c61 100644 --- a/internal/auth/request.go +++ b/internal/auth/request.go @@ -40,18 +40,16 @@ type Resolver struct { Pool *account.Pool Login LoginFunc - mu sync.Mutex - tokenRefreshedAt map[string]time.Time - tokenRefreshInterval time.Duration + mu sync.Mutex + tokenRefreshedAt map[string]time.Time } func NewResolver(store *config.Store, pool *account.Pool, login LoginFunc) *Resolver { return &Resolver{ - Store: store, - Pool: pool, - Login: login, - tokenRefreshedAt: map[string]time.Time{}, - tokenRefreshInterval: 6 * time.Hour, + Store: store, + Pool: pool, + Login: login, + tokenRefreshedAt: map[string]time.Time{}, } } @@ -232,10 +230,14 @@ func (r *Resolver) ensureManagedToken(ctx context.Context, a *RequestAuth) error } func (r *Resolver) shouldForceRefresh(accountID string) bool { + if r == nil || r.Store == nil { + return false + } if strings.TrimSpace(accountID) == "" { return false } - if r.tokenRefreshInterval <= 0 { + intervalHours := r.Store.RuntimeTokenRefreshIntervalHours() + if intervalHours <= 0 { return false } now := time.Now() @@ -246,7 +248,7 @@ func (r *Resolver) shouldForceRefresh(accountID string) bool { r.tokenRefreshedAt[accountID] = now return false } - return now.Sub(last) >= r.tokenRefreshInterval + return now.Sub(last) >= time.Duration(intervalHours)*time.Hour } func (r *Resolver) markTokenRefreshedNow(accountID string) { diff --git a/internal/auth/request_test.go b/internal/auth/request_test.go index 3e31907..eab97a4 100644 --- a/internal/auth/request_test.go +++ b/internal/auth/request_test.go @@ -244,3 +244,60 @@ func TestDetermineManagedAccountForcesRefreshEverySixHours(t *testing.T) { t.Fatalf("expected exactly one forced refresh login, got %d", got) } } + +func TestDetermineManagedAccountUsesUpdatedRefreshInterval(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{ + "keys":["managed-key"], + "accounts":[{"email":"acc@example.com","password":"pwd","token":"seed-token"}], + "runtime":{"token_refresh_interval_hours":6} + }`) + store := config.LoadStore() + if err := store.UpdateAccountToken("acc@example.com", "seed-token"); err != nil { + t.Fatalf("update token failed: %v", err) + } + pool := account.NewPool(store) + + var loginCount int32 + resolver := NewResolver(store, pool, func(_ context.Context, _ config.Account) (string, error) { + n := atomic.AddInt32(&loginCount, 1) + return "fresh-token-" + string(rune('0'+n)), nil + }) + + req, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + req.Header.Set("x-api-key", "managed-key") + + a1, err := resolver.Determine(req) + if err != nil { + t.Fatalf("determine failed: %v", err) + } + if a1.DeepSeekToken != "seed-token" { + t.Fatalf("expected initial token without forced refresh, got %q", a1.DeepSeekToken) + } + resolver.Release(a1) + if got := atomic.LoadInt32(&loginCount); got != 0 { + t.Fatalf("expected no login before runtime update, got %d", got) + } + + if err := store.Update(func(c *config.Config) error { + c.Runtime.TokenRefreshIntervalHours = 1 + return nil + }); err != nil { + t.Fatalf("update runtime failed: %v", err) + } + + resolver.mu.Lock() + resolver.tokenRefreshedAt["acc@example.com"] = time.Now().Add(-2 * time.Hour) + resolver.mu.Unlock() + + a2, err := resolver.Determine(req) + if err != nil { + t.Fatalf("determine after runtime update failed: %v", err) + } + defer resolver.Release(a2) + if a2.DeepSeekToken != "fresh-token-1" { + t.Fatalf("expected refreshed token after runtime update, got %q", a2.DeepSeekToken) + } + if got := atomic.LoadInt32(&loginCount); got != 1 { + t.Fatalf("expected exactly one login after runtime update, got %d", got) + } +} diff --git a/internal/config/codec.go b/internal/config/codec.go index 24cc10e..c70e7d8 100644 --- a/internal/config/codec.go +++ b/internal/config/codec.go @@ -32,7 +32,7 @@ func (c Config) MarshalJSON() ([]byte, error) { if strings.TrimSpace(c.Admin.PasswordHash) != "" || c.Admin.JWTExpireHours > 0 || c.Admin.JWTValidAfterUnix > 0 { m["admin"] = c.Admin } - if c.Runtime.AccountMaxInflight > 0 || c.Runtime.AccountMaxQueue > 0 || c.Runtime.GlobalMaxInflight > 0 { + if c.Runtime.AccountMaxInflight > 0 || c.Runtime.AccountMaxQueue > 0 || c.Runtime.GlobalMaxInflight > 0 || c.Runtime.TokenRefreshIntervalHours > 0 { m["runtime"] = c.Runtime } if c.Compat.WideInputStrictOutput != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 7ab4587..91438f7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -62,9 +62,10 @@ type AdminConfig struct { } type RuntimeConfig struct { - AccountMaxInflight int `json:"account_max_inflight,omitempty"` - AccountMaxQueue int `json:"account_max_queue,omitempty"` - GlobalMaxInflight int `json:"global_max_inflight,omitempty"` + AccountMaxInflight int `json:"account_max_inflight,omitempty"` + AccountMaxQueue int `json:"account_max_queue,omitempty"` + GlobalMaxInflight int `json:"global_max_inflight,omitempty"` + TokenRefreshIntervalHours int `json:"token_refresh_interval_hours,omitempty"` } type ToolcallConfig struct { diff --git a/internal/config/config_edge_test.go b/internal/config/config_edge_test.go index 8a969df..791e54c 100644 --- a/internal/config/config_edge_test.go +++ b/internal/config/config_edge_test.go @@ -104,6 +104,9 @@ func TestConfigJSONRoundtrip(t *testing.T) { "fast": "deepseek-chat", "slow": "deepseek-reasoner", }, + Runtime: RuntimeConfig{ + TokenRefreshIntervalHours: 12, + }, VercelSyncHash: "hash123", VercelSyncTime: 1234567890, AdditionalFields: map[string]any{ @@ -130,6 +133,9 @@ func TestConfigJSONRoundtrip(t *testing.T) { if decoded.ClaudeMapping["fast"] != "deepseek-chat" { t.Fatalf("unexpected claude mapping: %#v", decoded.ClaudeMapping) } + if decoded.Runtime.TokenRefreshIntervalHours != 12 { + t.Fatalf("unexpected runtime refresh interval: %#v", decoded.Runtime.TokenRefreshIntervalHours) + } if decoded.VercelSyncHash != "hash123" { t.Fatalf("unexpected vercel sync hash: %q", decoded.VercelSyncHash) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5429bb8..f5a8d90 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -79,6 +79,31 @@ func TestLoadStorePreservesFileBackedTokensForRuntime(t *testing.T) { } } +func TestRuntimeTokenRefreshIntervalHoursDefaultsToSix(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{ + "keys":["k1"], + "accounts":[{"email":"u@example.com","password":"p"}] + }`) + + store := LoadStore() + if got := store.RuntimeTokenRefreshIntervalHours(); got != 6 { + t.Fatalf("expected default refresh interval 6, got %d", got) + } +} + +func TestRuntimeTokenRefreshIntervalHoursUsesConfigValue(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{ + "keys":["k1"], + "accounts":[{"email":"u@example.com","password":"p"}], + "runtime":{"token_refresh_interval_hours":9} + }`) + + store := LoadStore() + if got := store.RuntimeTokenRefreshIntervalHours(); got != 9 { + t.Fatalf("expected configured refresh interval 9, got %d", got) + } +} + func TestStoreUpdateAccountTokenKeepsIdentifierResolvable(t *testing.T) { t.Setenv("DS2API_CONFIG_JSON", `{ "accounts":[{"email":"user@example.com","password":"p"}] diff --git a/internal/config/store_accessors.go b/internal/config/store_accessors.go index 2817bad..d4bb342 100644 --- a/internal/config/store_accessors.go +++ b/internal/config/store_accessors.go @@ -166,6 +166,15 @@ func (s *Store) RuntimeGlobalMaxInflight(defaultSize int) int { return defaultSize } +func (s *Store) RuntimeTokenRefreshIntervalHours() int { + s.mu.RLock() + defer s.mu.RUnlock() + if s.cfg.Runtime.TokenRefreshIntervalHours > 0 { + return s.cfg.Runtime.TokenRefreshIntervalHours + } + return 6 +} + func (s *Store) AutoDeleteSessions() bool { s.mu.RLock() defer s.mu.RUnlock() diff --git a/webui/src/features/settings/RuntimeSection.jsx b/webui/src/features/settings/RuntimeSection.jsx index ddb605f..3c3d867 100644 --- a/webui/src/features/settings/RuntimeSection.jsx +++ b/webui/src/features/settings/RuntimeSection.jsx @@ -2,7 +2,7 @@ export default function RuntimeSection({ t, form, setForm }) { return (

{t('settings.runtimeTitle')}

-
+
+
) diff --git a/webui/src/features/settings/useSettingsForm.js b/webui/src/features/settings/useSettingsForm.js index 3cd785b..13a8d5f 100644 --- a/webui/src/features/settings/useSettingsForm.js +++ b/webui/src/features/settings/useSettingsForm.js @@ -12,7 +12,7 @@ const MAX_AUTO_FETCH_FAILURES = 3 const DEFAULT_FORM = { admin: { jwt_expire_hours: 24 }, - runtime: { account_max_inflight: 2, account_max_queue: 10, global_max_inflight: 10 }, + runtime: { account_max_inflight: 2, account_max_queue: 10, global_max_inflight: 10, token_refresh_interval_hours: 6 }, toolcall: { mode: 'feature_match', early_emit_confidence: 'high' }, responses: { store_ttl_seconds: 900 }, embeddings: { provider: '' }, @@ -45,6 +45,7 @@ function fromServerForm(data) { account_max_inflight: Number(data.runtime?.account_max_inflight || 2), account_max_queue: Number(data.runtime?.account_max_queue || 10), global_max_inflight: Number(data.runtime?.global_max_inflight || 10), + token_refresh_interval_hours: Number(data.runtime?.token_refresh_interval_hours || 6), }, toolcall: { mode: data.toolcall?.mode || 'feature_match', @@ -71,6 +72,7 @@ function toServerPayload(form) { account_max_inflight: Number(form.runtime.account_max_inflight), account_max_queue: Number(form.runtime.account_max_queue), global_max_inflight: Number(form.runtime.global_max_inflight), + token_refresh_interval_hours: Number(form.runtime.token_refresh_interval_hours), }, toolcall: { mode: String(form.toolcall.mode || '').trim(), diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index 5db6546..6127b63 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -222,10 +222,11 @@ "passwordTooShort": "Password must be at least 4 characters.", "passwordUpdated": "Password updated. Please sign in again.", "passwordUpdateFailed": "Failed to update password.", - "runtimeTitle": "Concurrency & Queue", + "runtimeTitle": "Runtime", "accountMaxInflight": "Per-account max inflight", "accountMaxQueue": "Account max queue size", "globalMaxInflight": "Global max inflight", + "tokenRefreshIntervalHours": "Managed token refresh interval (hours)", "behaviorTitle": "Behavior", "toolcallMode": "Toolcall mode", "earlyEmitConfidence": "Early emit confidence", diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index bc72328..9ff6633 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -222,10 +222,11 @@ "passwordTooShort": "新密码至少 4 位", "passwordUpdated": "密码已更新,需重新登录", "passwordUpdateFailed": "密码更新失败", - "runtimeTitle": "并发与队列", + "runtimeTitle": "运行时设置", "accountMaxInflight": "每账号并发上限", "accountMaxQueue": "账号等待队列上限", "globalMaxInflight": "全局并发上限", + "tokenRefreshIntervalHours": "托管账号 Token 刷新间隔(小时)", "behaviorTitle": "行为设置", "toolcallMode": "Toolcall 模式", "earlyEmitConfidence": "早发置信度",