From 8ff923cd7700c4446875ded11bb7f9ea030667e0 Mon Sep 17 00:00:00 2001 From: "CJACK." <155826701+CJackHwang@users.noreply.github.com> Date: Wed, 22 Apr 2026 01:43:20 +0800 Subject: [PATCH 01/15] feat(account): add key/account name and remark metadata --- README.MD | 23 +++++- config.example.json | 15 ++++ internal/admin/handler_accounts_crud.go | 4 + internal/admin/handler_config_read.go | 3 + internal/admin/handler_config_write.go | 46 ++++++++--- internal/admin/helpers.go | 42 ++++++++++ internal/config/codec.go | 9 +++ internal/config/config.go | 79 +++++++++++++++++++ internal/config/store.go | 4 + webui/src/features/account/AccountsTable.jsx | 4 + .../src/features/account/AddAccountModal.jsx | 20 +++++ webui/src/features/account/AddKeyModal.jsx | 26 +++++- webui/src/features/account/ApiKeysPanel.jsx | 27 ++++--- .../src/features/account/useAccountActions.js | 12 +-- webui/src/locales/en.json | 4 + webui/src/locales/zh.json | 4 + 16 files changed, 291 insertions(+), 31 deletions(-) diff --git a/README.MD b/README.MD index b72c364..d027477 100644 --- a/README.MD +++ b/README.MD @@ -271,8 +271,17 @@ go run ./cmd/ds2api ```json { "keys": ["your-api-key-1", "your-api-key-2"], + "api_keys": [ + { + "key": "your-api-key-1", + "name": "主 Key", + "remark": "生产流量" + } + ], "accounts": [ { + "name": "账号 A", + "remark": "主账号", "email": "user@example.com", "password": "your-password" }, @@ -320,7 +329,8 @@ go run ./cmd/ds2api ``` - `keys`:API 访问密钥列表,客户端通过 `Authorization: Bearer ` 鉴权 -- `accounts`:DeepSeek 账号列表,支持 `email` 或 `mobile` 登录 +- `api_keys`:推荐使用的新结构化密钥列表,支持 `key` + `name` + `remark`(`keys` 仍兼容) +- `accounts`:DeepSeek 账号列表,支持 `email` 或 `mobile` 登录;可额外填写 `name` / `remark` 便于管理 - `token`:配置文件中即使填写也会在加载时被清空(不会从 `config.json` 读取 token);实际 token 仅在运行时内存中维护并自动刷新 - `model_aliases`:常见模型名(如 GPT/Codex/Claude)到 DeepSeek 模型的映射 - `compat.wide_input_strict_output`:建议保持 `true`(当前实现默认宽进严出) @@ -335,6 +345,8 @@ go run ./cmd/ds2api ### 环境变量 +> 建议:长期维护请优先以 `config.json`(或其 Base64)为单一配置源。环境变量仅保留部署必需项;`DS2API_CONFIG_JSON` 主要用于 Vercel/无持久盘场景,后续可能进一步收敛。 + | 变量 | 用途 | 默认值 | | --- | --- | --- | | `PORT` | 服务端口 | `5001` | @@ -362,6 +374,15 @@ go run ./cmd/ds2api > 提示:当检测到 `DS2API_CONFIG_JSON` 时,管理台会显示当前模式风险与自动持久化状态(含 `DS2API_CONFIG_PATH` 路径与模式切换说明)。 +#### 必填 / 可选(按部署方式) + +- **所有部署都必填**:`DS2API_ADMIN_KEY` +- **配置来源二选一(推荐前者)**: + - `config.json` 文件(推荐,持久化更直观) + - `DS2API_CONFIG_JSON`(可选,适合 Vercel;支持 JSON 或 Base64) +- **仅在环境变量配置模式建议开启**:`DS2API_ENV_WRITEBACK=1`(避免管理台改动重启后丢失) +- 其余环境变量均为可选调优项。 + ## 鉴权模式 调用业务接口(`/v1/*`、`/anthropic/*`、Gemini 路由)时支持两种模式: diff --git a/config.example.json b/config.example.json index f914050..0f40a45 100644 --- a/config.example.json +++ b/config.example.json @@ -5,14 +5,29 @@ "your-api-key-1", "your-api-key-2" ], + "api_keys": [ + { + "key": "your-api-key-1", + "name": "主 API Key", + "remark": "给 OpenAI 客户端使用" + }, + { + "key": "your-api-key-2", + "name": "备用 API Key", + "remark": "压测或临时调试" + } + ], "accounts": [ { "_comment": "邮箱登录方式", + "name": "主账号", + "remark": "优先用于生产流量", "email": "example1@example.com", "password": "your-password-1" }, { "_comment": "邮箱登录方式 - 账号2", + "name": "备用账号", "email": "example2@example.com", "password": "your-password-2" }, diff --git a/internal/admin/handler_accounts_crud.go b/internal/admin/handler_accounts_crud.go index 403e4cb..9b888e7 100644 --- a/internal/admin/handler_accounts_crud.go +++ b/internal/admin/handler_accounts_crud.go @@ -32,6 +32,8 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) { for _, acc := range accounts { id := strings.ToLower(acc.Identifier()) if strings.Contains(id, q) || + strings.Contains(strings.ToLower(acc.Name), q) || + strings.Contains(strings.ToLower(acc.Remark), q) || strings.Contains(strings.ToLower(acc.Email), q) || strings.Contains(strings.ToLower(acc.Mobile), q) { filtered = append(filtered, acc) @@ -66,6 +68,8 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) { } items = append(items, map[string]any{ "identifier": acc.Identifier(), + "name": acc.Name, + "remark": acc.Remark, "email": acc.Email, "mobile": acc.Mobile, "proxy_id": acc.ProxyID, diff --git a/internal/admin/handler_config_read.go b/internal/admin/handler_config_read.go index 8ee1fcc..ceeb523 100644 --- a/internal/admin/handler_config_read.go +++ b/internal/admin/handler_config_read.go @@ -11,6 +11,7 @@ func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) { snap := h.Store.Snapshot() safe := map[string]any{ "keys": snap.Keys, + "api_keys": snap.APIKeys, "accounts": []map[string]any{}, "proxies": []map[string]any{}, "env_backed": h.Store.IsEnvBacked(), @@ -37,6 +38,8 @@ func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) { } accounts = append(accounts, map[string]any{ "identifier": acc.Identifier(), + "name": acc.Name, + "remark": acc.Remark, "email": acc.Email, "mobile": acc.Mobile, "proxy_id": acc.ProxyID, diff --git a/internal/admin/handler_config_write.go b/internal/admin/handler_config_write.go index c371e13..adddfad 100644 --- a/internal/admin/handler_config_write.go +++ b/internal/admin/handler_config_write.go @@ -19,8 +19,18 @@ func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) { } old := h.Store.Snapshot() err := h.Store.Update(func(c *config.Config) error { + if apiKeys, ok := toAPIKeys(req["api_keys"]); ok { + c.APIKeys = apiKeys + } if keys, ok := toStringSlice(req["keys"]); ok { - c.Keys = keys + legacy := make([]config.APIKey, 0, len(keys)) + for _, key := range keys { + if key == "" { + continue + } + legacy = append(legacy, config.APIKey{Key: key}) + } + c.APIKeys = legacy } if accountsRaw, ok := req["accounts"].([]any); ok { existing := map[string]config.Account{} @@ -78,17 +88,19 @@ func (h *Handler) addKey(w http.ResponseWriter, r *http.Request) { _ = json.NewDecoder(r.Body).Decode(&req) key, _ := req["key"].(string) key = strings.TrimSpace(key) + name := fieldString(req, "name") + remark := fieldString(req, "remark") if key == "" { writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Key 不能为空"}) return } err := h.Store.Update(func(c *config.Config) error { - for _, k := range c.Keys { - if k == key { + for _, item := range c.APIKeys { + if item.Key == key { return fmt.Errorf("key 已存在") } } - c.Keys = append(c.Keys, key) + c.APIKeys = append(c.APIKeys, config.APIKey{Key: key, Name: name, Remark: remark}) return nil }) if err != nil { @@ -102,8 +114,8 @@ func (h *Handler) deleteKey(w http.ResponseWriter, r *http.Request) { key := chi.URLParam(r, "key") err := h.Store.Update(func(c *config.Config) error { idx := -1 - for i, k := range c.Keys { - if k == key { + for i, item := range c.APIKeys { + if item.Key == key { idx = i break } @@ -111,7 +123,7 @@ func (h *Handler) deleteKey(w http.ResponseWriter, r *http.Request) { if idx < 0 { return fmt.Errorf("key 不存在") } - c.Keys = append(c.Keys[:idx], c.Keys[idx+1:]...) + c.APIKeys = append(c.APIKeys[:idx], c.APIKeys[idx+1:]...) return nil }) if err != nil { @@ -129,17 +141,31 @@ func (h *Handler) batchImport(w http.ResponseWriter, r *http.Request) { } importedKeys, importedAccounts := 0, 0 err := h.Store.Update(func(c *config.Config) error { + if apiKeys, ok := toAPIKeys(req["api_keys"]); ok { + existing := map[string]bool{} + for _, item := range c.APIKeys { + existing[item.Key] = true + } + for _, item := range apiKeys { + if item.Key == "" || existing[item.Key] { + continue + } + c.APIKeys = append(c.APIKeys, item) + existing[item.Key] = true + importedKeys++ + } + } if keys, ok := req["keys"].([]any); ok { existing := map[string]bool{} - for _, k := range c.Keys { - existing[k] = true + for _, item := range c.APIKeys { + existing[item.Key] = true } for _, k := range keys { key := strings.TrimSpace(fmt.Sprintf("%v", k)) if key == "" || existing[key] { continue } - c.Keys = append(c.Keys, key) + c.APIKeys = append(c.APIKeys, config.APIKey{Key: key}) existing[key] = true importedKeys++ } diff --git a/internal/admin/helpers.go b/internal/admin/helpers.go index 31abbc0..5af8388 100644 --- a/internal/admin/helpers.go +++ b/internal/admin/helpers.go @@ -62,6 +62,8 @@ func toAccount(m map[string]any) config.Account { email := fieldString(m, "email") mobile := config.NormalizeMobileForStorage(fieldString(m, "mobile")) return config.Account{ + Name: fieldString(m, "name"), + Remark: fieldString(m, "remark"), Email: email, Mobile: mobile, Password: fieldString(m, "password"), @@ -69,6 +71,44 @@ func toAccount(m map[string]any) config.Account { } } +func toAPIKeys(v any) ([]config.APIKey, bool) { + arr, ok := v.([]any) + if !ok { + return nil, false + } + out := make([]config.APIKey, 0, len(arr)) + seen := map[string]struct{}{} + for _, item := range arr { + switch x := item.(type) { + case map[string]any: + key := fieldString(x, "key") + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, config.APIKey{ + Key: key, + Name: fieldString(x, "name"), + Remark: fieldString(x, "remark"), + }) + default: + key := strings.TrimSpace(fmt.Sprintf("%v", item)) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, config.APIKey{Key: key}) + } + } + return out, true +} + func fieldString(m map[string]any, key string) string { v, ok := m[key] if !ok || v == nil { @@ -99,6 +139,8 @@ func accountMatchesIdentifier(acc config.Account, identifier string) bool { } func normalizeAccountForStorage(acc config.Account) config.Account { + acc.Name = strings.TrimSpace(acc.Name) + acc.Remark = strings.TrimSpace(acc.Remark) acc.Email = strings.TrimSpace(acc.Email) acc.Mobile = config.NormalizeMobileForStorage(acc.Mobile) acc.ProxyID = strings.TrimSpace(acc.ProxyID) diff --git a/internal/config/codec.go b/internal/config/codec.go index bca8747..744b9b7 100644 --- a/internal/config/codec.go +++ b/internal/config/codec.go @@ -17,6 +17,9 @@ func (c Config) MarshalJSON() ([]byte, error) { if len(c.Keys) > 0 { m["keys"] = c.Keys } + if len(c.APIKeys) > 0 { + m["api_keys"] = c.APIKeys + } if len(c.Accounts) > 0 { m["accounts"] = c.Accounts } @@ -69,6 +72,10 @@ func (c *Config) UnmarshalJSON(b []byte) error { if err := json.Unmarshal(v, &c.Keys); err != nil { return fmt.Errorf("invalid field %q: %w", k, err) } + case "api_keys": + if err := json.Unmarshal(v, &c.APIKeys); err != nil { + return fmt.Errorf("invalid field %q: %w", k, err) + } case "accounts": if err := json.Unmarshal(v, &c.Accounts); err != nil { return fmt.Errorf("invalid field %q: %w", k, err) @@ -130,12 +137,14 @@ func (c *Config) UnmarshalJSON(b []byte) error { } } } + c.NormalizeCredentials() return nil } func (c Config) Clone() Config { clone := Config{ Keys: slices.Clone(c.Keys), + APIKeys: slices.Clone(c.APIKeys), Accounts: slices.Clone(c.Accounts), Proxies: slices.Clone(c.Proxies), ClaudeMapping: cloneStringMap(c.ClaudeMapping), diff --git a/internal/config/config.go b/internal/config/config.go index f8c7529..3566f2e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,6 +9,7 @@ import ( type Config struct { Keys []string `json:"keys,omitempty"` + APIKeys []APIKey `json:"api_keys,omitempty"` Accounts []Account `json:"accounts,omitempty"` Proxies []Proxy `json:"proxies,omitempty"` ClaudeMapping map[string]string `json:"claude_mapping,omitempty"` @@ -26,6 +27,8 @@ type Config struct { } type Account struct { + Name string `json:"name,omitempty"` + Remark string `json:"remark,omitempty"` Email string `json:"email,omitempty"` Mobile string `json:"mobile,omitempty"` Password string `json:"password,omitempty"` @@ -33,6 +36,12 @@ type Account struct { ProxyID string `json:"proxy_id,omitempty"` } +type APIKey struct { + Key string `json:"key"` + Name string `json:"name,omitempty"` + Remark string `json:"remark,omitempty"` +} + type Proxy struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` @@ -73,6 +82,76 @@ func (c *Config) ClearAccountTokens() { } } +func (c *Config) NormalizeCredentials() { + if c == nil { + return + } + normalizedAPIKeys := make([]APIKey, 0, len(c.APIKeys)) + metaByKey := make(map[string]APIKey, len(c.APIKeys)) + for _, item := range c.APIKeys { + key := strings.TrimSpace(item.Key) + if key == "" { + continue + } + if _, ok := metaByKey[key]; ok { + continue + } + metaByKey[key] = APIKey{ + Key: key, + Name: strings.TrimSpace(item.Name), + Remark: strings.TrimSpace(item.Remark), + } + } + if len(c.Keys) > 0 { + seen := make(map[string]struct{}, len(c.Keys)) + for _, key := range c.Keys { + key = strings.TrimSpace(key) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + if item, ok := metaByKey[key]; ok { + normalizedAPIKeys = append(normalizedAPIKeys, item) + } else { + normalizedAPIKeys = append(normalizedAPIKeys, APIKey{Key: key}) + } + } + } else { + normalizedAPIKeys = make([]APIKey, 0, len(c.APIKeys)) + seen := make(map[string]struct{}, len(c.APIKeys)) + for _, item := range c.APIKeys { + key := strings.TrimSpace(item.Key) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + normalizedAPIKeys = append(normalizedAPIKeys, APIKey{ + Key: key, + Name: strings.TrimSpace(item.Name), + Remark: strings.TrimSpace(item.Remark), + }) + } + } + c.APIKeys = normalizedAPIKeys + + keys := make([]string, 0, len(c.APIKeys)) + for _, item := range c.APIKeys { + keys = append(keys, item.Key) + } + c.Keys = keys + + for i := range c.Accounts { + c.Accounts[i].Name = strings.TrimSpace(c.Accounts[i].Name) + c.Accounts[i].Remark = strings.TrimSpace(c.Accounts[i].Remark) + } +} + // DropInvalidAccounts removes accounts that cannot be addressed by admin APIs // (no email and no normalizable mobile). This prevents legacy token-only // records from becoming orphaned empty entries after token stripping. diff --git a/internal/config/store.go b/internal/config/store.go index ebee6b0..3e397ef 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -43,6 +43,7 @@ func LoadStoreWithError() (*Store, error) { func loadStore() (*Store, error) { cfg, fromEnv, err := loadConfig() + cfg.NormalizeCredentials() if validateErr := ValidateConfig(cfg); validateErr != nil { err = errors.Join(err, validateErr) } @@ -112,6 +113,7 @@ func loadConfigFromFile(path string) (Config, error) { if err := json.Unmarshal(content, &cfg); err != nil { return Config{}, err } + cfg.NormalizeCredentials() cfg.DropInvalidAccounts() if strings.Contains(string(content), `"test_status"`) && !IsVercel() { if b, err := json.MarshalIndent(cfg, "", " "); err == nil { @@ -207,6 +209,7 @@ func (s *Store) UpdateAccountToken(identifier, token string) error { func (s *Store) Replace(cfg Config) error { s.mu.Lock() defer s.mu.Unlock() + cfg.NormalizeCredentials() s.cfg = cfg.Clone() s.rebuildIndexes() return s.saveLocked() @@ -219,6 +222,7 @@ func (s *Store) Update(mutator func(*Config) error) error { if err := mutator(&cfg); err != nil { return err } + cfg.NormalizeCredentials() s.cfg = cfg s.rebuildIndexes() return s.saveLocked() diff --git a/webui/src/features/account/AccountsTable.jsx b/webui/src/features/account/AccountsTable.jsx index b8a0829..f01e47f 100644 --- a/webui/src/features/account/AccountsTable.jsx +++ b/webui/src/features/account/AccountsTable.jsx @@ -118,6 +118,7 @@ export default function AccountsTable({ runtimeUnknown ? "bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]" : "bg-amber-500" )} />
+
{acc.name || '-'}
copyId(id)} @@ -128,6 +129,9 @@ export default function AccountsTable({ : }
+ {acc.remark && ( +
{acc.remark}
+ )}
{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : isActive ? t('accountManager.sessionActive') : runtimeUnknown ? t('accountManager.runtimeStatusUnknown') : t('accountManager.reauthRequired')} {acc.token_preview && ( diff --git a/webui/src/features/account/AddAccountModal.jsx b/webui/src/features/account/AddAccountModal.jsx index 69f3d88..ed97257 100644 --- a/webui/src/features/account/AddAccountModal.jsx +++ b/webui/src/features/account/AddAccountModal.jsx @@ -23,6 +23,26 @@ export default function AddAccountModal({
+
+ + setNewAccount({ ...newAccount, name: e.target.value })} + /> +
+
+ + setNewAccount({ ...newAccount, remark: e.target.value })} + /> +
setNewKey(e.target.value)} + value={newKey.key} + onChange={e => setNewKey({ ...newKey, key: e.target.value })} autoFocus />

{t('accountManager.generateHint')}

+
+ + setNewKey({ ...newKey, name: e.target.value })} + /> +
+
+ + setNewKey({ ...newKey, remark: e.target.value })} + /> +
- {copiedKey === key && ( +
{item.remark || '-'}
+ {copiedKey === item.key && ( {t('accountManager.copied')} )} - {failedKey === key && ( + {failedKey === item.key && ( {t('accountManager.copyFailed')} )}
+ )} +
+ ) +} + +function ListModeIcon() { + return ( + + ) +} + +function MergeModeIcon() { + return ( + + ) +} + +function RequestMessages({ item, t }) { + const messages = Array.isArray(item?.messages) && item.messages.length > 0 + ? item.messages + : [{ role: 'user', content: item?.user_input || t('chatHistory.emptyUserInput') }] + + return ( +
+ {messages.map((message, index) => { + const role = message.role || 'user' + const isUser = role === 'user' + const isAssistant = role === 'assistant' + const isTool = role === 'tool' + const label = isUser + ? t('chatHistory.role.user') + : (isAssistant ? t('chatHistory.role.assistant') : (isTool ? t('chatHistory.role.tool') : t('chatHistory.role.system'))) + return ( +
+
+ {isUser + ? + : } +
+
+
+ {label} +
+
+
+ {message.content || t('chatHistory.emptyUserInput')} +
+
+
+
+ ) + })} +
+ ) +} + +function MergedPromptView({ item, t }) { + const merged = item?.final_prompt || '' + return ( +
+
+ {t('chatHistory.mergedInput')} +
+
+ +
+
+ ) +} + +function DetailConversation({ selectedItem, t, viewMode, detailScrollRef, assistantStartRef, bottomButtonClassName }) { + if (!selectedItem) return null + + return ( + <> + {viewMode === 'list' + ? + : } + +
+
+ +
+
+ {(selectedItem.reasoning_content || '').trim() && ( +
+
+ + {t('chatHistory.reasoningTrace')} +
+
+ {selectedItem.reasoning_content} +
+
+ )} + +
+ {selectedItem.status === 'error' + ? {selectedItem.error || t('chatHistory.failedOutput')} + : (selectedItem.content || t('chatHistory.emptyAssistantOutput'))} +
+
+
+ +
+
{t('chatHistory.metaTitle')}
+
+
+
{t('chatHistory.metaAccount')}
+
{selectedItem.account_id || t('chatHistory.metaUnknown')}
+
+
+
{t('chatHistory.metaElapsed')}
+
+ + {formatElapsed(selectedItem.elapsed_ms, t)} +
+
+
+
{t('chatHistory.metaModel')}
+
{selectedItem.model || t('chatHistory.metaUnknown')}
+
+
+
{t('chatHistory.metaStatusCode')}
+
{selectedItem.status_code || '-'}
+
+
+
{t('chatHistory.metaStream')}
+
{selectedItem.stream ? t('chatHistory.streamMode') : t('chatHistory.nonStreamMode')}
+
+
+
{t('chatHistory.metaCaller')}
+
{selectedItem.caller_id || t('chatHistory.metaUnknown')}
+
+
+
+ + + + ) +} + +export default function ChatHistoryContainer({ authFetch, onMessage }) { + const { t, lang } = useI18n() + const apiFetch = authFetch || fetch + const [items, setItems] = useState([]) + const [limit, setLimit] = useState(20) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [selectedId, setSelectedId] = useState('') + const [selectedDetail, setSelectedDetail] = useState(null) + const [savingLimit, setSavingLimit] = useState(false) + const [clearing, setClearing] = useState(false) + const [deletingId, setDeletingId] = useState('') + const [detail, setDetail] = useState('') + const [confirmClearOpen, setConfirmClearOpen] = useState(false) + const [autoRefreshReady, setAutoRefreshReady] = useState(false) + const [viewMode, setViewMode] = useState(() => { + if (typeof localStorage === 'undefined') return 'list' + const stored = localStorage.getItem(VIEW_MODE_KEY) + return stored === 'merged' ? 'merged' : 'list' + }) + const [isMobileView, setIsMobileView] = useState(() => typeof window !== 'undefined' ? window.innerWidth < 1024 : false) + const [mobileDetailOpen, setMobileDetailOpen] = useState(false) + const [mobileDetailVisible, setMobileDetailVisible] = useState(false) + const [mobileOrigin, setMobileOrigin] = useState({ x: 50, y: 50 }) + const [pendingJumpToAssistant, setPendingJumpToAssistant] = useState(false) + + const inFlightRef = useRef(false) + const detailInFlightRef = useRef(false) + const listETagRef = useRef('') + const detailETagRef = useRef('') + const assistantStartRef = useRef(null) + const detailScrollRef = useRef(null) + const mobileCloseTimerRef = useRef(null) + + const selectedSummary = items.find(item => item.id === selectedId) || items[0] || null + const selectedItem = selectedDetail && selectedDetail.id === selectedId ? selectedDetail : null + + const syncItems = (nextItems) => { + setItems(nextItems) + setSelectedId(prev => { + if (!nextItems.length) return '' + if (prev && nextItems.some(item => item.id === prev)) return prev + return nextItems[0].id + }) + } + + const loadList = async ({ mode = 'silent', announceError = false } = {}) => { + if (inFlightRef.current) return + inFlightRef.current = true + if (mode === 'manual') { + setRefreshing(true) + } else if (mode === 'initial') { + setLoading(true) + } + if (announceError) { + setDetail('') + } + try { + const headers = {} + if (listETagRef.current) { + headers['If-None-Match'] = listETagRef.current + } + const res = await apiFetch('/admin/chat-history', { headers }) + if (res.status === 304) { + return + } + const data = await res.json() + if (!res.ok) { + throw new Error(data?.detail || t('chatHistory.loadFailed')) + } + listETagRef.current = res.headers.get('ETag') || '' + setLimit(typeof data.limit === 'number' ? data.limit : 20) + syncItems(Array.isArray(data.items) ? data.items : []) + } catch (error) { + setDetail(error.message || t('chatHistory.loadFailed')) + if (announceError) { + onMessage?.('error', error.message || t('chatHistory.loadFailed')) + } + } finally { + if (mode === 'initial') { + setLoading(false) + } + if (mode === 'manual') { + setRefreshing(false) + } + inFlightRef.current = false + } + } + + const loadDetail = async (id, { announceError = false } = {}) => { + if (!id || detailInFlightRef.current) return + detailInFlightRef.current = true + try { + const headers = {} + if (detailETagRef.current) { + headers['If-None-Match'] = detailETagRef.current + } + const res = await apiFetch(`/admin/chat-history/${encodeURIComponent(id)}`, { headers }) + if (res.status === 304) { + return + } + const data = await res.json() + if (!res.ok) { + throw new Error(data?.detail || t('chatHistory.loadFailed')) + } + detailETagRef.current = res.headers.get('ETag') || '' + setSelectedDetail(data.item || null) + } catch (error) { + if (announceError) { + onMessage?.('error', error.message || t('chatHistory.loadFailed')) + } + } finally { + detailInFlightRef.current = false + } + } + + useEffect(() => { + loadList({ mode: 'initial', announceError: true }).finally(() => { + setAutoRefreshReady(true) + }) + }, []) + + useEffect(() => { + if (!autoRefreshReady) return undefined + const timer = window.setInterval(() => { + loadList({ mode: 'silent', announceError: false }) + }, 5000) + return () => window.clearInterval(timer) + }, [autoRefreshReady]) + + useEffect(() => { + if (!autoRefreshReady || !selectedId || selectedSummary?.status !== 'streaming') return undefined + const timer = window.setInterval(() => { + loadDetail(selectedId, { announceError: false }) + }, 1000) + return () => window.clearInterval(timer) + }, [autoRefreshReady, selectedId, selectedSummary?.status]) + + useEffect(() => { + if (!selectedId) return undefined + detailETagRef.current = '' + setSelectedDetail(null) + loadDetail(selectedId, { announceError: false }) + }, [selectedId, mobileDetailOpen]) + + useEffect(() => { + if (!pendingJumpToAssistant || !selectedItem || selectedItem.id !== selectedId) return undefined + const frame = window.requestAnimationFrame(() => { + assistantStartRef.current?.scrollIntoView({ behavior: 'auto', block: 'start' }) + setPendingJumpToAssistant(false) + }) + return () => window.cancelAnimationFrame(frame) + }, [pendingJumpToAssistant, selectedId, selectedItem?.id, selectedItem?.revision, mobileDetailOpen, viewMode]) + + useEffect(() => { + if (typeof localStorage === 'undefined') return + localStorage.setItem(VIEW_MODE_KEY, viewMode) + }, [viewMode]) + + useEffect(() => { + if (typeof window === 'undefined') return undefined + const handleResize = () => setIsMobileView(window.innerWidth < 1024) + handleResize() + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, []) + + useEffect(() => { + if (!isMobileView) { + setMobileDetailOpen(false) + setMobileDetailVisible(false) + } + }, [isMobileView]) + + useEffect(() => { + return () => { + if (mobileCloseTimerRef.current) { + window.clearTimeout(mobileCloseTimerRef.current) + } + } + }, []) + + const handleRefresh = async ({ manual = true } = {}) => { + await loadList({ mode: manual ? 'manual' : 'silent', announceError: manual }) + if (selectedId) { + detailETagRef.current = '' + await loadDetail(selectedId, { announceError: manual }) + } + } + + const handleLimitChange = async (nextLimit) => { + if (nextLimit === limit || savingLimit) return + setSavingLimit(true) + try { + const res = await apiFetch('/admin/chat-history/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ limit: nextLimit }), + }) + const data = await res.json() + if (!res.ok) { + throw new Error(data?.detail || t('chatHistory.updateLimitFailed')) + } + const resolvedLimit = typeof data.limit === 'number' ? data.limit : nextLimit + setLimit(resolvedLimit) + listETagRef.current = '' + syncItems(Array.isArray(data.items) ? data.items : []) + onMessage?.('success', t('chatHistory.limitUpdated', { limit: resolvedLimit === DISABLED_LIMIT ? t('chatHistory.off') : resolvedLimit })) + } catch (error) { + onMessage?.('error', error.message || t('chatHistory.updateLimitFailed')) + } finally { + setSavingLimit(false) + } + } + + const handleDeleteItem = async (id) => { + if (!id || deletingId) return + setDeletingId(id) + try { + const res = await apiFetch(`/admin/chat-history/${encodeURIComponent(id)}`, { method: 'DELETE' }) + const data = await res.json() + if (!res.ok) { + throw new Error(data?.detail || t('chatHistory.deleteFailed')) + } + if (selectedId === id) { + detailETagRef.current = '' + setSelectedDetail(null) + } + syncItems(items.filter(item => item.id !== id)) + onMessage?.('success', t('chatHistory.deleteSuccess')) + } catch (error) { + onMessage?.('error', error.message || t('chatHistory.deleteFailed')) + } finally { + setDeletingId('') + } + } + + const handleClear = async () => { + if (clearing || !items.length) return + setClearing(true) + try { + const res = await apiFetch('/admin/chat-history', { method: 'DELETE' }) + const data = await res.json() + if (!res.ok) { + throw new Error(data?.detail || t('chatHistory.clearFailed')) + } + listETagRef.current = '' + detailETagRef.current = '' + setSelectedDetail(null) + syncItems([]) + onMessage?.('success', t('chatHistory.clearSuccess')) + } catch (error) { + onMessage?.('error', error.message || t('chatHistory.clearFailed')) + } finally { + setClearing(false) + } + } + + const openMobileDetail = (itemId, event) => { + const x = typeof window !== 'undefined' && event?.clientX ? (event.clientX / window.innerWidth) * 100 : 50 + const y = typeof window !== 'undefined' && event?.clientY ? (event.clientY / window.innerHeight) * 100 : 50 + setMobileOrigin({ x, y }) + setPendingJumpToAssistant(true) + setSelectedId(itemId) + setMobileDetailOpen(true) + setMobileDetailVisible(false) + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => setMobileDetailVisible(true)) + }) + } + + const closeMobileDetail = () => { + setMobileDetailVisible(false) + if (mobileCloseTimerRef.current) { + window.clearTimeout(mobileCloseTimerRef.current) + } + mobileCloseTimerRef.current = window.setTimeout(() => { + setMobileDetailOpen(false) + }, 180) + } + + const handleSelectItem = (itemId, event) => { + if (isMobileView) { + openMobileDetail(itemId, event) + return + } + setPendingJumpToAssistant(true) + setSelectedId(itemId) + } + + if (loading) { + return ( +
+
+ + {t('chatHistory.loading')} +
+
+ ) + } + + return ( +
+
+
+
{t('chatHistory.retentionTitle')}
+
{t('chatHistory.retentionDesc')}
+
+
+ {LIMIT_OPTIONS.map(option => ( + + ))} + + +
+
+ + {detail && ( +
+ {detail} +
+ )} + +
+
+
+
{t('chatHistory.listTitle')}
+
{items.length}
+
+
+ {!items.length && ( +
+ +
{t('chatHistory.emptyTitle')}
+
{t('chatHistory.emptyDesc')}
+
+ )} + + {items.map(item => ( + +
+
+
+ {previewText(item) || t('chatHistory.noPreview')} +
+
+ {formatDateTime(item.completed_at || item.updated_at || item.created_at, lang)} +
+ + ))} +
+
+ +
+
+
+
{t('chatHistory.detailTitle')}
+
+ {selectedSummary ? formatDateTime(selectedSummary.completed_at || selectedSummary.updated_at || selectedSummary.created_at, lang) : t('chatHistory.selectPrompt')} +
+
+
+
+ + +
+ + {selectedSummary && ( + + {t(`chatHistory.status.${selectedSummary.status || 'streaming'}`)} + + )} +
+
+ +
+ {!selectedItem && ( +
+ {t('chatHistory.selectPrompt')} +
+ )} + + {selectedItem && ( + + )} +
+
+ + + {isMobileView && mobileDetailOpen && selectedItem && ( +
+
event.stopPropagation()} + className={clsx( + 'w-full h-full rounded-2xl border border-border bg-card shadow-2xl overflow-hidden flex flex-col transition-transform duration-200 ease-out', + mobileDetailVisible ? 'scale-100' : 'scale-90' + )} + style={{ transformOrigin: `${mobileOrigin.x}% ${mobileOrigin.y}%` }} + > +
+
+
{t('chatHistory.detailTitle')}
+
+ {formatDateTime(selectedItem.completed_at || selectedItem.updated_at || selectedItem.created_at, lang)} +
+
+
+
+ + +
+ + +
+
+ +
+ +
+
+
+ )} + + {confirmClearOpen && ( +
+
+
+
+
+ +
+
+
{t('chatHistory.confirmClearTitle')}
+
{t('chatHistory.confirmClearDesc')}
+
+
+ +
+
+ + +
+
+
+ )} + + ) +} diff --git a/webui/src/layout/DashboardShell.jsx b/webui/src/layout/DashboardShell.jsx index 5c1f5c7..b0542a6 100644 --- a/webui/src/layout/DashboardShell.jsx +++ b/webui/src/layout/DashboardShell.jsx @@ -10,12 +10,14 @@ import { X, Server, Users, - Globe + Globe, + History } from 'lucide-react' import clsx from 'clsx' import AccountManagerContainer from '../features/account/AccountManagerContainer' import ApiTesterContainer from '../features/apiTester/ApiTesterContainer' +import ChatHistoryContainer from '../features/chatHistory/ChatHistoryContainer' import BatchImport from '../components/BatchImport' import VercelSyncContainer from '../features/vercel/VercelSyncContainer' import SettingsContainer from '../features/settings/SettingsContainer' @@ -33,6 +35,7 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s { id: 'accounts', label: t('nav.accounts.label'), icon: Users, description: t('nav.accounts.desc') }, { id: 'proxies', label: t('nav.proxies.label'), icon: Globe, description: t('nav.proxies.desc') }, { id: 'test', label: t('nav.test.label'), icon: Server, description: t('nav.test.desc') }, + { id: 'history', label: t('nav.history.label'), icon: History, description: t('nav.history.desc') }, { id: 'import', label: t('nav.import.label'), icon: Upload, description: t('nav.import.desc') }, { id: 'vercel', label: t('nav.vercel.label'), icon: Cloud, description: t('nav.vercel.desc') }, { id: 'settings', label: t('nav.settings.label'), icon: SettingsIcon, description: t('nav.settings.desc') }, @@ -98,6 +101,8 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s return case 'test': return + case 'history': + return case 'import': return case 'vercel': diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index e9883dc..73a1186 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -17,6 +17,10 @@ "label": "API Test", "desc": "Test API connectivity and responses" }, + "history": { + "label": "Conversations", + "desc": "Browse server-side external chat history" + }, "import": { "label": "Batch Import", "desc": "Bulk import account configuration" @@ -234,6 +238,66 @@ "enterMessage": "Enter a message...", "adminConsoleLabel": "DeepSeek admin console" }, + "chatHistory": { + "loading": "Loading conversation history...", + "loadFailed": "Failed to load conversation history.", + "retentionTitle": "Retention", + "retentionDesc": "The server keeps only the latest N external /v1/chat/completions conversations.", + "off": "OFF", + "refresh": "Refresh", + "clearAll": "Clear all", + "clearSuccess": "Conversation history cleared.", + "clearFailed": "Failed to clear conversation history.", + "deleteSuccess": "Conversation deleted.", + "deleteFailed": "Failed to delete conversation.", + "updateLimitFailed": "Failed to update retention limit.", + "limitUpdated": "Retention limit updated to {limit}", + "listTitle": "History", + "detailTitle": "Details", + "viewModeList": "List mode", + "viewModeMerged": "Merged mode", + "emptyTitle": "No conversation history yet", + "emptyDesc": "When external clients call /v1/chat/completions, the server will save the results here automatically.", + "untitled": "Untitled conversation", + "noPreview": "No preview available.", + "selectPrompt": "Select a record on the left to view details.", + "mergedInput": "Final message sent to DeepSeek", + "emptyMergedPrompt": "No merged prompt is available.", + "expand": "Expand", + "collapse": "Collapse", + "reasoningTrace": "Reasoning Trace", + "failedOutput": "The request failed and no assistant output is available.", + "emptyAssistantOutput": "No assistant output is available.", + "emptyUserInput": "No user input is available.", + "confirmClearTitle": "Clear all records?", + "confirmClearDesc": "This deletes every server-side conversation record and cannot be undone.", + "confirmClearAction": "Clear all", + "metaTitle": "Metadata", + "metaAccount": "Account", + "metaElapsed": "Elapsed", + "metaModel": "Model", + "metaStatusCode": "Status code", + "metaStream": "Output mode", + "metaCaller": "Caller fingerprint", + "metaTime": "Completed at", + "metaUnknown": "Unknown", + "backToTop": "Back to top", + "backToBottom": "Jump to bottom", + "streamMode": "Streaming", + "nonStreamMode": "Non-streaming", + "status": { + "streaming": "Streaming", + "success": "Success", + "error": "Error", + "stopped": "Stopped" + }, + "role": { + "user": "User", + "assistant": "Assistant", + "tool": "Tool", + "system": "System" + } + }, "batchImport": { "templates": { "full": { diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index ca7acb7..84bbeac 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -17,6 +17,10 @@ "label": "API 测试", "desc": "测试 API 连接与响应" }, + "history": { + "label": "对话记录", + "desc": "查看服务器保存的外部对话历史" + }, "import": { "label": "批量导入", "desc": "批量导入账号配置" @@ -234,6 +238,66 @@ "enterMessage": "输入消息...", "adminConsoleLabel": "DeepSeek 管理员界面" }, + "chatHistory": { + "loading": "正在加载对话记录...", + "loadFailed": "加载对话记录失败", + "retentionTitle": "保留条数", + "retentionDesc": "服务器端只保留最新 N 条外部 /v1/chat/completions 对话记录。", + "off": "OFF", + "refresh": "刷新", + "clearAll": "清空全部", + "clearSuccess": "对话记录已清空", + "clearFailed": "清空对话记录失败", + "deleteSuccess": "对话记录已删除", + "deleteFailed": "删除对话记录失败", + "updateLimitFailed": "更新保留条数失败", + "limitUpdated": "保留条数已更新为 {limit}", + "listTitle": "历史列表", + "detailTitle": "对话详情", + "viewModeList": "列表模式", + "viewModeMerged": "合并模式", + "emptyTitle": "还没有可用的对话记录", + "emptyDesc": "当外部客户端调用 /v1/chat/completions 时,服务端会自动把结果写入这里。", + "untitled": "未命名对话", + "noPreview": "暂无预览内容", + "selectPrompt": "从左侧选择一条记录查看详情。", + "mergedInput": "最终发送给 DeepSeek 的完整消息", + "emptyMergedPrompt": "没有可展示的完整消息。", + "expand": "展开全部", + "collapse": "收起", + "reasoningTrace": "思维链过程", + "failedOutput": "请求失败,未生成可展示的回答。", + "emptyAssistantOutput": "没有可展示的生成内容。", + "emptyUserInput": "没有可展示的用户输入。", + "confirmClearTitle": "确认清空全部记录?", + "confirmClearDesc": "此操作会删除服务器里的全部对话记录,无法恢复。", + "confirmClearAction": "确认清空", + "metaTitle": "元信息", + "metaAccount": "使用账号", + "metaElapsed": "耗时", + "metaModel": "模型", + "metaStatusCode": "状态码", + "metaStream": "输出模式", + "metaCaller": "调用方指纹", + "metaTime": "完成时间", + "metaUnknown": "未知", + "backToTop": "回到顶部", + "backToBottom": "跳到底部", + "streamMode": "流式", + "nonStreamMode": "非流式", + "status": { + "streaming": "进行中", + "success": "成功", + "error": "失败", + "stopped": "已停止" + }, + "role": { + "user": "用户", + "assistant": "助手", + "tool": "工具", + "system": "系统" + } + }, "batchImport": { "templates": { "full": { From 6052a8d1e20b8fa6e60a49d82aff3c4b0094dc9b Mon Sep 17 00:00:00 2001 From: songguoliang <957057673@qq.com> Date: Wed, 22 Apr 2026 19:03:07 +0800 Subject: [PATCH 03/15] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E5=9C=BA=E6=99=AF=20citation=20=E5=81=B6=E5=8F=91=E6=9C=AA?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/sse/consumer.go | 9 ++++++++- internal/sse/consumer_edge_test.go | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/internal/sse/consumer.go b/internal/sse/consumer.go index 83d66e9..11dc291 100644 --- a/internal/sse/consumer.go +++ b/internal/sse/consumer.go @@ -29,6 +29,7 @@ func CollectStream(resp *http.Response, thinkingEnabled bool, closeBody bool) Co text := strings.Builder{} thinking := strings.Builder{} contentFilter := false + stopped := false collector := newCitationLinkCollector() currentType := "text" if thinkingEnabled { @@ -38,6 +39,9 @@ func CollectStream(resp *http.Response, thinkingEnabled bool, closeBody bool) Co if chunk, done, parsed := ParseDeepSeekSSELine(line); parsed && !done { collector.ingestChunk(chunk) } + if stopped { + return true + } result := ParseDeepSeekContentLine(line, thinkingEnabled, currentType) currentType = result.NextType if !result.Parsed { @@ -47,7 +51,10 @@ func CollectStream(resp *http.Response, thinkingEnabled bool, closeBody bool) Co if result.ContentFilter { contentFilter = true } - return false + // Keep scanning to collect late-arriving citation metadata lines + // that can appear after response/status=FINISHED. + stopped = true + return true } for _, p := range result.Parts { if p.Type == "thinking" { diff --git a/internal/sse/consumer_edge_test.go b/internal/sse/consumer_edge_test.go index d720414..9e751c7 100644 --- a/internal/sse/consumer_edge_test.go +++ b/internal/sse/consumer_edge_test.go @@ -185,6 +185,24 @@ func TestCollectStreamExtractsCitationLinksWithRepeatedURLsAndNilIndices(t *test } } +func TestCollectStreamCollectsCitationLinksAfterFinished(t *testing.T) { + resp := makeHTTPResponse( + "data: {\"p\":\"response/content\",\"v\":\"结论[citation:1]\"}\n" + + "data: {\"p\":\"response/status\",\"v\":\"FINISHED\"}\n" + + "data: {\"p\":\"response/fragments/-1/results\",\"v\":[{\"url\":\"https://example.com/a\",\"cite_index\":1}]}\n" + + "data: {\"p\":\"response/content\",\"v\":\"should-not-append\"}\n" + + "data: [DONE]\n", + ) + + result := CollectStream(resp, false, false) + if result.Text != "结论[citation:1]" { + t.Fatalf("expected text to freeze after finished, got %q", result.Text) + } + if got := result.CitationLinks[1]; got != "https://example.com/a" { + t.Fatalf("expected citation 1 link, got %q", got) + } +} + func TestCollectStreamMultipleThinkingChunks(t *testing.T) { resp := makeHTTPResponse( "data: {\"p\":\"response/thinking_content\",\"v\":\"part1\"}\n" + From 4422f989be24b017e50c67027297733751250c66 Mon Sep 17 00:00:00 2001 From: ouqiting Date: Wed, 22 Apr 2026 20:28:08 +0800 Subject: [PATCH 04/15] fix: satisfy staticcheck QF1007 --- internal/server/router.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/internal/server/router.go b/internal/server/router.go index a2a078e..e1bf6f4 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -108,10 +108,7 @@ func timeout(d time.Duration) func(http.Handler) http.Handler { } func filteredLogger() func(http.Handler) http.Handler { - color := true - if isWindowsRuntime() { - color = false - } + color := !isWindowsRuntime() base := &middleware.DefaultLogFormatter{ Logger: log.New(os.Stdout, "", log.LstdFlags), NoColor: !color, From 8f09e3b3812a616dea9410c7d379960e6340ffb9 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Wed, 22 Apr 2026 15:51:43 +0000 Subject: [PATCH 05/15] feat: implement API key management with reconciliation and add update key endpoint --- API.en.md | 30 +++- API.md | 30 +++- VERSION | 2 +- internal/admin/handler.go | 1 + internal/admin/handler_config_import.go | 16 +- internal/admin/handler_config_write.go | 44 ++++- internal/admin/handler_keys_test.go | 76 +++++++++ internal/admin/handler_settings_test.go | 71 ++++++++ internal/admin/helpers.go | 8 + internal/config/config.go | 63 +------ internal/config/config_edge_test.go | 95 +++++++++++ internal/config/credentials.go | 158 ++++++++++++++++++ internal/config/store.go | 4 +- .../account/AccountManagerContainer.jsx | 11 +- webui/src/features/account/AddKeyModal.jsx | 40 +++-- webui/src/features/account/ApiKeysPanel.jsx | 14 +- .../src/features/account/useAccountActions.js | 57 ++++++- webui/src/locales/en.json | 8 + webui/src/locales/zh.json | 8 + 19 files changed, 629 insertions(+), 107 deletions(-) create mode 100644 internal/admin/handler_keys_test.go create mode 100644 internal/config/credentials.go diff --git a/API.en.md b/API.en.md index 1d6fe6c..9e6b539 100644 --- a/API.en.md +++ b/API.en.md @@ -130,7 +130,8 @@ Gemini-compatible clients can also send `x-goog-api-key`, `?key=`, or `?api_key= | POST | `/admin/settings/password` | Admin | Update admin password and invalidate old JWTs | | POST | `/admin/config/import` | Admin | Import config (merge/replace) | | GET | `/admin/config/export` | Admin | Export full config (`config`/`json`/`base64`) | -| POST | `/admin/keys` | Admin | Add API key | +| POST | `/admin/keys` | Admin | Add API key (optional `name`/`remark`) | +| PUT | `/admin/keys/{key}` | Admin | Update API key metadata | | DELETE | `/admin/keys/{key}` | Admin | Delete API key | | GET | `/admin/proxies` | Admin | List proxies | | POST | `/admin/proxies` | Admin | Add proxy | @@ -643,11 +644,15 @@ Returns Vercel preconfiguration status. ### `GET /admin/config` -Returns sanitized config. +Returns sanitized config, including both `keys` and `api_keys`. ```json { "keys": ["k1", "k2"], + "api_keys": [ + {"key": "k1", "name": "Primary", "remark": "Production"}, + {"key": "k2", "name": "Backup", "remark": "Load test"} + ], "env_backed": false, "env_source_present": true, "env_writeback_enabled": true, @@ -671,13 +676,18 @@ Returns sanitized config. ### `POST /admin/config` -Only updates `keys`, `accounts`, and `claude_mapping`. +Only updates `keys`, `api_keys`, `accounts`, and `claude_mapping`. +If both `api_keys` and `keys` are sent, the structured `api_keys` entries win so `name` / `remark` metadata is preserved; `keys` remains a legacy fallback. **Request**: ```json { "keys": ["k1", "k2"], + "api_keys": [ + {"key": "k1", "name": "Primary", "remark": "Production"}, + {"key": "k2", "name": "Backup", "remark": "Load test"} + ], "accounts": [ {"email": "user@example.com", "password": "pwd", "token": ""} ], @@ -737,7 +747,7 @@ Imports full config with: The request can send config directly, or wrapped as `{"config": {...}, "mode":"merge"}`. Query params `?mode=merge` / `?mode=replace` are also supported. -Import accepts `keys`, `accounts`, `claude_mapping` / `claude_model_mapping`, `model_aliases`, `admin`, `runtime`, `responses`, `embeddings`, and `auto_delete`; legacy `toolcall` fields are ignored. +Import accepts `keys`, `api_keys`, `accounts`, `claude_mapping` / `claude_model_mapping`, `model_aliases`, `admin`, `runtime`, `responses`, `embeddings`, and `auto_delete`; legacy `toolcall` fields are ignored. > `compat` fields are managed via `/admin/settings` or the config file; this import endpoint does not update `compat`. @@ -748,7 +758,17 @@ Exports full config in three forms: `config`, `json`, and `base64`. ### `POST /admin/keys` ```json -{"key": "new-api-key"} +{"key": "new-api-key", "name": "Primary", "remark": "Production"} +``` + +**Response**: `{"success": true, "total_keys": 3}` + +### `PUT /admin/keys/{key}` + +Updates the `name` / `remark` of the specified API key. The path `key` is read-only and cannot be changed. + +```json +{"name": "Backup", "remark": "Load test"} ``` **Response**: `{"success": true, "total_keys": 3}` diff --git a/API.md b/API.md index 1f9bcf5..af61e70 100644 --- a/API.md +++ b/API.md @@ -130,7 +130,8 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=` | POST | `/admin/settings/password` | Admin | 更新 Admin 密码并使旧 JWT 失效 | | POST | `/admin/config/import` | Admin | 导入配置(merge/replace) | | GET | `/admin/config/export` | Admin | 导出完整配置(含 `config`/`json`/`base64`) | -| POST | `/admin/keys` | Admin | 添加 API key | +| POST | `/admin/keys` | Admin | 添加 API key(可附 name/remark) | +| PUT | `/admin/keys/{key}` | Admin | 更新 API key 备注信息 | | DELETE | `/admin/keys/{key}` | Admin | 删除 API key | | GET | `/admin/proxies` | Admin | 代理列表 | | POST | `/admin/proxies` | Admin | 添加代理 | @@ -644,11 +645,15 @@ data: {"type":"message_stop"} ### `GET /admin/config` -返回脱敏后的配置。 +返回脱敏后的配置,包含 `keys` 与 `api_keys`。 ```json { "keys": ["k1", "k2"], + "api_keys": [ + {"key": "k1", "name": "主 Key", "remark": "生产流量"}, + {"key": "k2", "name": "备用 Key", "remark": "压测"} + ], "env_backed": false, "env_source_present": true, "env_writeback_enabled": true, @@ -672,13 +677,18 @@ data: {"type":"message_stop"} ### `POST /admin/config` -只更新 `keys`、`accounts`、`claude_mapping`。 +只更新 `keys`、`api_keys`、`accounts`、`claude_mapping`。 +如果同时发送 `api_keys` 与 `keys`,优先保留 `api_keys` 中的结构化 `name` / `remark`;`keys` 仅作为旧格式兼容回退。 **请求**: ```json { "keys": ["k1", "k2"], + "api_keys": [ + {"key": "k1", "name": "主 Key", "remark": "生产流量"}, + {"key": "k2", "name": "备用 Key", "remark": "压测"} + ], "accounts": [ {"email": "user@example.com", "password": "pwd", "token": ""} ], @@ -738,7 +748,7 @@ data: {"type":"message_stop"} 请求可直接传配置对象,或使用 `{"config": {...}, "mode":"merge"}` 包裹格式。 也支持在查询参数里传 `?mode=merge` / `?mode=replace`。 -导入时会接受 `keys`、`accounts`、`claude_mapping` / `claude_model_mapping`、`model_aliases`、`admin`、`runtime`、`responses`、`embeddings`、`auto_delete` 等字段;`toolcall` 相关字段会被忽略。 +导入时会接受 `keys`、`api_keys`、`accounts`、`claude_mapping` / `claude_model_mapping`、`model_aliases`、`admin`、`runtime`、`responses`、`embeddings`、`auto_delete` 等字段;`toolcall` 相关字段会被忽略。 > `compat` 相关字段请通过 `/admin/settings` 或配置文件管理;该导入接口不会更新 `compat`。 @@ -749,7 +759,17 @@ data: {"type":"message_stop"} ### `POST /admin/keys` ```json -{"key": "new-api-key"} +{"key": "new-api-key", "name": "主 Key", "remark": "生产流量"} +``` + +**响应**:`{"success": true, "total_keys": 3}` + +### `PUT /admin/keys/{key}` + +更新指定 API key 的 `name` / `remark`,路径参数中的 `key` 为只读标识,不可修改。 + +```json +{"name": "备用 Key", "remark": "压测"} ``` **响应**:`{"success": true, "total_keys": 3}` diff --git a/VERSION b/VERSION index 87ce492..40c341b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.5.2 +3.6.0 diff --git a/internal/admin/handler.go b/internal/admin/handler.go index bed3894..02bd69e 100644 --- a/internal/admin/handler.go +++ b/internal/admin/handler.go @@ -25,6 +25,7 @@ func RegisterRoutes(r chi.Router, h *Handler) { pr.Post("/config/import", h.configImport) pr.Get("/config/export", h.configExport) pr.Post("/keys", h.addKey) + pr.Put("/keys/{key}", h.updateKey) pr.Delete("/keys/{key}", h.deleteKey) pr.Get("/proxies", h.listProxies) pr.Post("/proxies", h.addProxy) diff --git a/internal/admin/handler_config_import.go b/internal/admin/handler_config_import.go index a28b4e5..c238428 100644 --- a/internal/admin/handler_config_import.go +++ b/internal/admin/handler_config_import.go @@ -53,15 +53,19 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) { next.Accounts = normalizeAndDedupeAccounts(next.Accounts) next.VercelSyncHash = c.VercelSyncHash next.VercelSyncTime = c.VercelSyncTime - importedKeys = len(next.Keys) + importedKeys = len(next.APIKeys) importedAccounts = len(next.Accounts) } else { existingKeys := map[string]struct{}{} - for _, k := range next.Keys { - existingKeys[k] = struct{}{} + for _, item := range next.APIKeys { + key := strings.TrimSpace(item.Key) + if key == "" { + continue + } + existingKeys[key] = struct{}{} } - for _, k := range incoming.Keys { - key := strings.TrimSpace(k) + for _, item := range incoming.APIKeys { + key := strings.TrimSpace(item.Key) if key == "" { continue } @@ -69,7 +73,7 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) { continue } existingKeys[key] = struct{}{} - next.Keys = append(next.Keys, key) + next.APIKeys = append(next.APIKeys, item) importedKeys++ } diff --git a/internal/admin/handler_config_write.go b/internal/admin/handler_config_write.go index adddfad..ae696bc 100644 --- a/internal/admin/handler_config_write.go +++ b/internal/admin/handler_config_write.go @@ -21,8 +21,7 @@ func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) { err := h.Store.Update(func(c *config.Config) error { if apiKeys, ok := toAPIKeys(req["api_keys"]); ok { c.APIKeys = apiKeys - } - if keys, ok := toStringSlice(req["keys"]); ok { + } else if keys, ok := toStringSlice(req["keys"]); ok { legacy := make([]config.APIKey, 0, len(keys)) for _, key := range keys { if key == "" { @@ -110,6 +109,47 @@ func (h *Handler) addKey(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_keys": len(h.Store.Snapshot().Keys)}) } +func (h *Handler) updateKey(w http.ResponseWriter, r *http.Request) { + key := strings.TrimSpace(chi.URLParam(r, "key")) + if key == "" { + writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "key 不能为空"}) + return + } + + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json"}) + return + } + name, nameOK := fieldStringOptional(req, "name") + remark, remarkOK := fieldStringOptional(req, "remark") + + err := h.Store.Update(func(c *config.Config) error { + idx := -1 + for i, item := range c.APIKeys { + if item.Key == key { + idx = i + break + } + } + if idx < 0 { + return fmt.Errorf("key 不存在") + } + if nameOK { + c.APIKeys[idx].Name = name + } + if remarkOK { + c.APIKeys[idx].Remark = remark + } + return nil + }) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]any{"detail": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_keys": len(h.Store.Snapshot().Keys)}) +} + func (h *Handler) deleteKey(w http.ResponseWriter, r *http.Request) { key := chi.URLParam(r, "key") err := h.Store.Update(func(c *config.Config) error { diff --git a/internal/admin/handler_keys_test.go b/internal/admin/handler_keys_test.go new file mode 100644 index 0000000..82ff5e2 --- /dev/null +++ b/internal/admin/handler_keys_test.go @@ -0,0 +1,76 @@ +package admin + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" +) + +func TestKeyEndpointsPreserveStructuredMetadata(t *testing.T) { + h := newAdminTestHandler(t, `{ + "api_keys":[{"key":"k1","name":"primary","remark":"prod"}] + }`) + + r := chi.NewRouter() + r.Post("/admin/keys", h.addKey) + r.Put("/admin/keys/{key}", h.updateKey) + r.Delete("/admin/keys/{key}", h.deleteKey) + + addBody := []byte(`{"key":"k2","name":"secondary","remark":"staging"}`) + addReq := httptest.NewRequest(http.MethodPost, "/admin/keys", bytes.NewReader(addBody)) + addRec := httptest.NewRecorder() + r.ServeHTTP(addRec, addReq) + if addRec.Code != http.StatusOK { + t.Fatalf("add status=%d body=%s", addRec.Code, addRec.Body.String()) + } + + snap := h.Store.Snapshot() + if len(snap.APIKeys) != 2 { + t.Fatalf("unexpected api keys after add: %#v", snap.APIKeys) + } + if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" { + t.Fatalf("existing metadata was lost after add: %#v", snap.APIKeys[0]) + } + if snap.APIKeys[1].Name != "secondary" || snap.APIKeys[1].Remark != "staging" { + t.Fatalf("new metadata was lost after add: %#v", snap.APIKeys[1]) + } + + updateBody := map[string]any{ + "name": "primary-updated", + "remark": "prod-updated", + } + updateBytes, _ := json.Marshal(updateBody) + updateReq := httptest.NewRequest(http.MethodPut, "/admin/keys/k1", bytes.NewReader(updateBytes)) + updateRec := httptest.NewRecorder() + r.ServeHTTP(updateRec, updateReq) + if updateRec.Code != http.StatusOK { + t.Fatalf("update status=%d body=%s", updateRec.Code, updateRec.Body.String()) + } + + snap = h.Store.Snapshot() + if len(snap.APIKeys) != 2 { + t.Fatalf("unexpected api keys after update: %#v", snap.APIKeys) + } + if snap.APIKeys[0].Key != "k1" || snap.APIKeys[0].Name != "primary-updated" || snap.APIKeys[0].Remark != "prod-updated" { + t.Fatalf("metadata update did not persist: %#v", snap.APIKeys[0]) + } + + deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/keys/k1", nil) + deleteRec := httptest.NewRecorder() + r.ServeHTTP(deleteRec, deleteReq) + if deleteRec.Code != http.StatusOK { + t.Fatalf("delete status=%d body=%s", deleteRec.Code, deleteRec.Body.String()) + } + + snap = h.Store.Snapshot() + if len(snap.APIKeys) != 1 || snap.APIKeys[0].Key != "k2" { + t.Fatalf("unexpected api keys after delete: %#v", snap.APIKeys) + } + if len(snap.Keys) != 1 || snap.Keys[0] != "k2" { + t.Fatalf("unexpected legacy keys after delete: %#v", snap.Keys) + } +} diff --git a/internal/admin/handler_settings_test.go b/internal/admin/handler_settings_test.go index d698b67..9aeba35 100644 --- a/internal/admin/handler_settings_test.go +++ b/internal/admin/handler_settings_test.go @@ -234,6 +234,43 @@ func TestUpdateSettingsHotReloadTokenRefreshInterval(t *testing.T) { } } +func TestUpdateConfigPreservesStructuredAPIKeysWhenBothFieldsPresent(t *testing.T) { + h := newAdminTestHandler(t, `{ + "keys":["legacy"], + "api_keys":[{"key":"legacy","name":"primary","remark":"prod"}], + "accounts":[] + }`) + + payload := map[string]any{ + "keys": []any{"legacy", "new-key"}, + "api_keys": []any{ + map[string]any{"key": "legacy", "name": "primary-updated", "remark": "prod-updated"}, + map[string]any{"key": "new-key", "name": "secondary", "remark": "staging"}, + }, + } + b, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/admin/config", bytes.NewReader(b)) + rec := httptest.NewRecorder() + h.updateConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + + snap := h.Store.Snapshot() + if len(snap.Keys) != 2 || snap.Keys[0] != "legacy" || snap.Keys[1] != "new-key" { + t.Fatalf("unexpected keys after config update: %#v", snap.Keys) + } + if len(snap.APIKeys) != 2 { + t.Fatalf("unexpected api keys after config update: %#v", snap.APIKeys) + } + if snap.APIKeys[0].Name != "primary-updated" || snap.APIKeys[0].Remark != "prod-updated" { + t.Fatalf("structured metadata for existing key was not preserved: %#v", snap.APIKeys[0]) + } + if snap.APIKeys[1].Name != "secondary" || snap.APIKeys[1].Remark != "staging" { + t.Fatalf("structured metadata for new key was not preserved: %#v", snap.APIKeys[1]) + } +} + func TestUpdateSettingsPasswordInvalidatesOldJWT(t *testing.T) { hash := authn.HashAdminPassword("old-password") h := newAdminTestHandler(t, `{"admin":{"password_hash":"`+hash+`"}}`) @@ -315,6 +352,40 @@ func TestConfigImportMergeAndReplace(t *testing.T) { } } +func TestConfigImportMergePreservesStructuredAPIKeys(t *testing.T) { + h := newAdminTestHandler(t, `{ + "api_keys":[{"key":"k1","name":"primary","remark":"prod"}] + }`) + + merge := map[string]any{ + "mode": "merge", + "config": map[string]any{ + "api_keys": []any{ + map[string]any{"key": "k1", "name": "should-not-overwrite", "remark": "ignored"}, + map[string]any{"key": "k2", "name": "secondary", "remark": "staging"}, + }, + }, + } + mergeBytes, _ := json.Marshal(merge) + mergeReq := httptest.NewRequest(http.MethodPost, "/admin/config/import?mode=merge", bytes.NewReader(mergeBytes)) + mergeRec := httptest.NewRecorder() + h.configImport(mergeRec, mergeReq) + if mergeRec.Code != http.StatusOK { + t.Fatalf("merge status=%d body=%s", mergeRec.Code, mergeRec.Body.String()) + } + + snap := h.Store.Snapshot() + if len(snap.APIKeys) != 2 { + t.Fatalf("unexpected api keys after structured merge: %#v", snap.APIKeys) + } + if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" { + t.Fatalf("existing structured metadata was overwritten: %#v", snap.APIKeys[0]) + } + if snap.APIKeys[1].Name != "secondary" || snap.APIKeys[1].Remark != "staging" { + t.Fatalf("new structured metadata was lost: %#v", snap.APIKeys[1]) + } +} + func TestConfigImportAppliesTokenRefreshInterval(t *testing.T) { h := newAdminTestHandler(t, `{"keys":["k1"]}`) diff --git a/internal/admin/helpers.go b/internal/admin/helpers.go index 5af8388..8411b3b 100644 --- a/internal/admin/helpers.go +++ b/internal/admin/helpers.go @@ -117,6 +117,14 @@ func fieldString(m map[string]any, key string) string { return strings.TrimSpace(fmt.Sprintf("%v", v)) } +func fieldStringOptional(m map[string]any, key string) (string, bool) { + v, ok := m[key] + if !ok || v == nil { + return "", false + } + return strings.TrimSpace(fmt.Sprintf("%v", v)), true +} + func statusOr(v int, d int) int { if v == 0 { return d diff --git a/internal/config/config.go b/internal/config/config.go index 3566f2e..05879c2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -86,65 +86,14 @@ func (c *Config) NormalizeCredentials() { if c == nil { return } - normalizedAPIKeys := make([]APIKey, 0, len(c.APIKeys)) - metaByKey := make(map[string]APIKey, len(c.APIKeys)) - for _, item := range c.APIKeys { - key := strings.TrimSpace(item.Key) - if key == "" { - continue - } - if _, ok := metaByKey[key]; ok { - continue - } - metaByKey[key] = APIKey{ - Key: key, - Name: strings.TrimSpace(item.Name), - Remark: strings.TrimSpace(item.Remark), - } - } - if len(c.Keys) > 0 { - seen := make(map[string]struct{}, len(c.Keys)) - for _, key := range c.Keys { - key = strings.TrimSpace(key) - if key == "" { - continue - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - if item, ok := metaByKey[key]; ok { - normalizedAPIKeys = append(normalizedAPIKeys, item) - } else { - normalizedAPIKeys = append(normalizedAPIKeys, APIKey{Key: key}) - } - } + normalizedAPIKeys := normalizeAPIKeys(c.APIKeys) + if len(normalizedAPIKeys) > 0 { + c.APIKeys = normalizedAPIKeys + c.Keys = apiKeysToStrings(c.APIKeys) } else { - normalizedAPIKeys = make([]APIKey, 0, len(c.APIKeys)) - seen := make(map[string]struct{}, len(c.APIKeys)) - for _, item := range c.APIKeys { - key := strings.TrimSpace(item.Key) - if key == "" { - continue - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - normalizedAPIKeys = append(normalizedAPIKeys, APIKey{ - Key: key, - Name: strings.TrimSpace(item.Name), - Remark: strings.TrimSpace(item.Remark), - }) - } + c.Keys = normalizeKeys(c.Keys) + c.APIKeys = apiKeysFromStrings(c.Keys, nil) } - c.APIKeys = normalizedAPIKeys - - keys := make([]string, 0, len(c.APIKeys)) - for _, item := range c.APIKeys { - keys = append(keys, item.Key) - } - c.Keys = keys for i := range c.Accounts { c.Accounts[i].Name = strings.TrimSpace(c.Accounts[i].Name) diff --git a/internal/config/config_edge_test.go b/internal/config/config_edge_test.go index 0de0815..b70cf11 100644 --- a/internal/config/config_edge_test.go +++ b/internal/config/config_edge_test.go @@ -529,6 +529,101 @@ func TestStoreUpdate(t *testing.T) { } } +func TestStoreUpdateReconcilesAPIKeyMutations(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{ + "keys":["k1"], + "api_keys":[{"key":"k1","name":"primary","remark":"prod"}], + "accounts":[] + }`) + store := LoadStore() + + if err := store.Update(func(cfg *Config) error { + cfg.APIKeys = append(cfg.APIKeys, APIKey{Key: "k2", Name: "secondary", Remark: "staging"}) + return nil + }); err != nil { + t.Fatalf("add api key failed: %v", err) + } + + snap := store.Snapshot() + if len(snap.Keys) != 2 || snap.Keys[0] != "k1" || snap.Keys[1] != "k2" { + t.Fatalf("unexpected keys after api key add: %#v", snap.Keys) + } + if len(snap.APIKeys) != 2 { + t.Fatalf("unexpected api keys length after add: %#v", snap.APIKeys) + } + if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" { + t.Fatalf("metadata for existing key was lost: %#v", snap.APIKeys[0]) + } + if snap.APIKeys[1].Name != "secondary" || snap.APIKeys[1].Remark != "staging" { + t.Fatalf("metadata for new key was lost: %#v", snap.APIKeys[1]) + } + + if err := store.Update(func(cfg *Config) error { + cfg.APIKeys = append([]APIKey(nil), cfg.APIKeys[1:]...) + return nil + }); err != nil { + t.Fatalf("delete api key failed: %v", err) + } + + snap = store.Snapshot() + if len(snap.Keys) != 1 || snap.Keys[0] != "k2" { + t.Fatalf("unexpected keys after api key delete: %#v", snap.Keys) + } + if len(snap.APIKeys) != 1 || snap.APIKeys[0].Key != "k2" { + t.Fatalf("unexpected api keys after delete: %#v", snap.APIKeys) + } +} + +func TestStoreUpdateReconcilesLegacyKeyMutations(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{ + "keys":["k1"], + "api_keys":[{"key":"k1","name":"primary","remark":"prod"}], + "accounts":[] + }`) + store := LoadStore() + + if err := store.Update(func(cfg *Config) error { + cfg.Keys = append(cfg.Keys, "k2") + return nil + }); err != nil { + t.Fatalf("legacy key update failed: %v", err) + } + + snap := store.Snapshot() + if len(snap.Keys) != 2 || snap.Keys[0] != "k1" || snap.Keys[1] != "k2" { + t.Fatalf("unexpected keys after legacy update: %#v", snap.Keys) + } + if len(snap.APIKeys) != 2 { + t.Fatalf("unexpected api keys after legacy update: %#v", snap.APIKeys) + } + if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" { + t.Fatalf("metadata for preserved key was lost: %#v", snap.APIKeys[0]) + } + if snap.APIKeys[1].Key != "k2" || snap.APIKeys[1].Name != "" || snap.APIKeys[1].Remark != "" { + t.Fatalf("new legacy key should stay metadata-free: %#v", snap.APIKeys[1]) + } +} + +func TestNormalizeCredentialsPrefersStructuredAPIKeys(t *testing.T) { + cfg := Config{ + Keys: []string{"legacy-key"}, + APIKeys: []APIKey{ + {Key: "structured-key", Name: "primary", Remark: "prod"}, + }, + } + cfg.NormalizeCredentials() + + if len(cfg.Keys) != 1 || cfg.Keys[0] != "structured-key" { + t.Fatalf("unexpected normalized keys: %#v", cfg.Keys) + } + if len(cfg.APIKeys) != 1 { + t.Fatalf("unexpected normalized api keys: %#v", cfg.APIKeys) + } + if cfg.APIKeys[0].Key != "structured-key" || cfg.APIKeys[0].Name != "primary" || cfg.APIKeys[0].Remark != "prod" { + t.Fatalf("unexpected structured api key metadata: %#v", cfg.APIKeys[0]) + } +} + func TestStoreClaudeMapping(t *testing.T) { t.Setenv("DS2API_CONFIG_JSON", `{"keys":[],"accounts":[],"claude_mapping":{"fast":"deepseek-chat","slow":"deepseek-reasoner"}}`) store := LoadStore() diff --git a/internal/config/credentials.go b/internal/config/credentials.go new file mode 100644 index 0000000..a29f314 --- /dev/null +++ b/internal/config/credentials.go @@ -0,0 +1,158 @@ +package config + +import ( + "slices" + "strings" +) + +func (c *Config) ReconcileCredentials(base Config) { + if c == nil { + return + } + currKeys := normalizeKeys(c.Keys) + currAPIKeys := normalizeAPIKeys(c.APIKeys) + baseKeys := normalizeKeys(base.Keys) + baseAPIKeys := normalizeAPIKeys(base.APIKeys) + + keysChanged := !slices.Equal(currKeys, baseKeys) + apiKeysChanged := !equalAPIKeys(currAPIKeys, baseAPIKeys) + + if keysChanged && !apiKeysChanged { + c.APIKeys = apiKeysFromStrings(currKeys, apiKeyMap(baseAPIKeys)) + } else { + c.APIKeys = currAPIKeys + } + c.Keys = apiKeysToStrings(c.APIKeys) +} + +func normalizeKeys(keys []string) []string { + if len(keys) == 0 { + return nil + } + out := make([]string, 0, len(keys)) + seen := make(map[string]struct{}, len(keys)) + for _, key := range keys { + key = strings.TrimSpace(key) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, key) + } + if len(out) == 0 { + return nil + } + return out +} + +func normalizeAPIKeys(items []APIKey) []APIKey { + if len(items) == 0 { + return nil + } + out := make([]APIKey, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + key := strings.TrimSpace(item.Key) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, APIKey{ + Key: key, + Name: strings.TrimSpace(item.Name), + Remark: strings.TrimSpace(item.Remark), + }) + } + if len(out) == 0 { + return nil + } + return out +} + +func apiKeysFromStrings(keys []string, meta map[string]APIKey) []APIKey { + if len(keys) == 0 { + return nil + } + out := make([]APIKey, 0, len(keys)) + seen := make(map[string]struct{}, len(keys)) + for _, key := range keys { + key = strings.TrimSpace(key) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + if item, ok := meta[key]; ok { + out = append(out, APIKey{ + Key: key, + Name: strings.TrimSpace(item.Name), + Remark: strings.TrimSpace(item.Remark), + }) + continue + } + out = append(out, APIKey{Key: key}) + } + if len(out) == 0 { + return nil + } + return out +} + +func apiKeysToStrings(items []APIKey) []string { + if len(items) == 0 { + return nil + } + keys := make([]string, 0, len(items)) + for _, item := range items { + key := strings.TrimSpace(item.Key) + if key == "" { + continue + } + keys = append(keys, key) + } + if len(keys) == 0 { + return nil + } + return keys +} + +func apiKeyMap(items []APIKey) map[string]APIKey { + if len(items) == 0 { + return nil + } + out := make(map[string]APIKey, len(items)) + for _, item := range items { + key := strings.TrimSpace(item.Key) + if key == "" { + continue + } + if _, ok := out[key]; ok { + continue + } + out[key] = APIKey{ + Key: key, + Name: strings.TrimSpace(item.Name), + Remark: strings.TrimSpace(item.Remark), + } + } + return out +} + +func equalAPIKeys(a, b []APIKey) bool { + if len(a) != len(b) { + return false + } + return slices.EqualFunc(a, b, func(x, y APIKey) bool { + return strings.TrimSpace(x.Key) == strings.TrimSpace(y.Key) && + strings.TrimSpace(x.Name) == strings.TrimSpace(y.Name) && + strings.TrimSpace(x.Remark) == strings.TrimSpace(y.Remark) + }) +} diff --git a/internal/config/store.go b/internal/config/store.go index 3e397ef..a54e012 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -218,10 +218,12 @@ func (s *Store) Replace(cfg Config) error { func (s *Store) Update(mutator func(*Config) error) error { s.mu.Lock() defer s.mu.Unlock() - cfg := s.cfg.Clone() + base := s.cfg.Clone() + cfg := base.Clone() if err := mutator(&cfg); err != nil { return err } + cfg.ReconcileCredentials(base) cfg.NormalizeCredentials() s.cfg = cfg s.rebuildIndexes() diff --git a/webui/src/features/account/AccountManagerContainer.jsx b/webui/src/features/account/AccountManagerContainer.jsx index 3e00241..9b88ca1 100644 --- a/webui/src/features/account/AccountManagerContainer.jsx +++ b/webui/src/features/account/AccountManagerContainer.jsx @@ -30,7 +30,10 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage, const { showAddKey, - setShowAddKey, + openAddKey, + openEditKey, + closeKeyModal, + editingKey, showAddAccount, setShowAddAccount, newKey, @@ -94,7 +97,8 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage, config={config} keysExpanded={keysExpanded} setKeysExpanded={setKeysExpanded} - setShowAddKey={setShowAddKey} + onAddKey={openAddKey} + onEditKey={openEditKey} copiedKey={copiedKey} setCopiedKey={setCopiedKey} onDeleteKey={deleteKey} @@ -133,10 +137,11 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage, setShowAddKey(false)} + onClose={closeKeyModal} onAdd={addKey} /> diff --git a/webui/src/features/account/AddKeyModal.jsx b/webui/src/features/account/AddKeyModal.jsx index 1311b78..875101a 100644 --- a/webui/src/features/account/AddKeyModal.jsx +++ b/webui/src/features/account/AddKeyModal.jsx @@ -1,40 +1,47 @@ import { X } from 'lucide-react' -export default function AddKeyModal({ show, t, newKey, setNewKey, loading, onClose, onAdd }) { +export default function AddKeyModal({ show, t, editingKey, newKey, setNewKey, loading, onClose, onAdd }) { if (!show) { return null } + const isEditing = Boolean(editingKey?.key) + return (
-

{t('accountManager.modalAddKeyTitle')}

+

{isEditing ? t('accountManager.modalEditKeyTitle') : t('accountManager.modalAddKeyTitle')}

- +
setNewKey({ ...newKey, key: e.target.value })} - autoFocus + autoFocus={!isEditing} + readOnly={isEditing} /> - + {!isEditing && ( + + )}
-

{t('accountManager.generateHint')}

+

+ {isEditing ? t('accountManager.keyReadonlyHint') : t('accountManager.generateHint')} +

@@ -44,6 +51,7 @@ export default function AddKeyModal({ show, t, newKey, setNewKey, loading, onClo placeholder={t('accountManager.namePlaceholder')} value={newKey.name} onChange={e => setNewKey({ ...newKey, name: e.target.value })} + autoFocus={isEditing} />
@@ -59,7 +67,9 @@ export default function AddKeyModal({ show, t, newKey, setNewKey, loading, onClo
diff --git a/webui/src/features/account/ApiKeysPanel.jsx b/webui/src/features/account/ApiKeysPanel.jsx index 17f8c71..7030d8b 100644 --- a/webui/src/features/account/ApiKeysPanel.jsx +++ b/webui/src/features/account/ApiKeysPanel.jsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Check, ChevronDown, Copy, Plus, Trash2 } from 'lucide-react' +import { Check, ChevronDown, Copy, Pencil, Plus, Trash2 } from 'lucide-react' import clsx from 'clsx' function fallbackCopyText(text) { @@ -31,7 +31,8 @@ export default function ApiKeysPanel({ config, keysExpanded, setKeysExpanded, - setShowAddKey, + onAddKey, + onEditKey, copiedKey, setCopiedKey, onDeleteKey, @@ -81,7 +82,7 @@ export default function ApiKeysPanel({
+
) } diff --git a/webui/src/features/account/AccountsTable.jsx b/webui/src/features/account/AccountsTable.jsx index f01e47f..14915ed 100644 --- a/webui/src/features/account/AccountsTable.jsx +++ b/webui/src/features/account/AccountsTable.jsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { ChevronLeft, ChevronRight, Check, Copy, Play, Plus, Trash2, FolderX } from 'lucide-react' +import { ChevronLeft, ChevronRight, Check, Copy, Pencil, Play, Plus, Trash2, FolderX } from 'lucide-react' import clsx from 'clsx' export default function AccountsTable({ @@ -20,6 +20,7 @@ export default function AccountsTable({ proxies, onTestAll, onShowAddAccount, + onEditAccount, onTestAccount, onDeleteAccount, onDeleteAllSessions, @@ -180,6 +181,14 @@ export default function AccountsTable({ ))} + + +
+
+
{t('accountManager.accountIdentifierLabel')}
+ {editingAccount.identifier} +
+
+ + setEditAccount({ ...editAccount, name: e.target.value })} + autoFocus + /> +
+
+ + setEditAccount({ ...editAccount, remark: e.target.value })} + /> +
+
+ + +
+
+ + + ) +} diff --git a/webui/src/features/account/useAccountActions.js b/webui/src/features/account/useAccountActions.js index 4c4a612..f859eeb 100644 --- a/webui/src/features/account/useAccountActions.js +++ b/webui/src/features/account/useAccountActions.js @@ -4,9 +4,12 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f const [showAddKey, setShowAddKey] = useState(false) const [editingKey, setEditingKey] = useState(null) const [showAddAccount, setShowAddAccount] = useState(false) + const [showEditAccount, setShowEditAccount] = useState(false) + const [editingAccount, setEditingAccount] = useState(null) const [newKey, setNewKey] = useState({ key: '', name: '', remark: '' }) const [copiedKey, setCopiedKey] = useState(null) const [newAccount, setNewAccount] = useState({ name: '', remark: '', email: '', mobile: '', password: '' }) + const [editAccount, setEditAccount] = useState({ name: '', remark: '' }) const [loading, setLoading] = useState(false) const [testing, setTesting] = useState({}) const [testingAll, setTestingAll] = useState(false) @@ -38,6 +41,42 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f setNewKey({ key: '', name: '', remark: '' }) } + const openAddAccount = () => { + setShowEditAccount(false) + setEditingAccount(null) + setEditAccount({ name: '', remark: '' }) + setNewAccount({ name: '', remark: '', email: '', mobile: '', password: '' }) + setShowAddAccount(true) + } + + const closeAddAccount = () => { + setShowAddAccount(false) + setNewAccount({ name: '', remark: '', email: '', mobile: '', password: '' }) + } + + const openEditAccount = (account) => { + const identifier = resolveAccountIdentifier(account) + if (!identifier) { + onMessage('error', t('accountManager.invalidIdentifier')) + return + } + setShowAddAccount(false) + setEditingAccount({ + identifier, + }) + setEditAccount({ + name: account?.name || '', + remark: account?.remark || '', + }) + setShowEditAccount(true) + } + + const closeEditAccount = () => { + setShowEditAccount(false) + setEditingAccount(null) + setEditAccount({ name: '', remark: '' }) + } + const addKey = async () => { const isEditing = Boolean(editingKey?.key) if (!isEditing && !newKey.key.trim()) { @@ -104,8 +143,7 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f }) if (res.ok) { onMessage('success', t('accountManager.addAccountSuccess')) - setNewAccount({ name: '', remark: '', email: '', mobile: '', password: '' }) - setShowAddAccount(false) + closeAddAccount() fetchAccounts(1) onRefresh() } else { @@ -119,6 +157,35 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f } } + const updateAccount = async () => { + const identifier = String(editingAccount?.identifier || '').trim() + if (!identifier) { + onMessage('error', t('accountManager.invalidIdentifier')) + return + } + setLoading(true) + try { + const res = await apiFetch(`/admin/accounts/${encodeURIComponent(identifier)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(editAccount), + }) + if (res.ok) { + onMessage('success', t('accountManager.updateAccountSuccess')) + closeEditAccount() + fetchAccounts() + onRefresh() + } else { + const data = await res.json() + onMessage('error', data.detail || t('messages.requestFailed')) + } + } catch (e) { + onMessage('error', t('messages.networkError')) + } finally { + setLoading(false) + } + } + const deleteAccount = async (id) => { const identifier = String(id || '').trim() if (!identifier) { @@ -285,7 +352,14 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f closeKeyModal, editingKey, showAddAccount, - setShowAddAccount, + openAddAccount, + closeAddAccount, + showEditAccount, + editingAccount, + editAccount, + setEditAccount, + openEditAccount, + closeEditAccount, newKey, setNewKey, copiedKey, @@ -302,6 +376,7 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f addKey, deleteKey, addAccount, + updateAccount, deleteAccount, testAccount, testAllAccounts, diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index 8bfaaf1..79622df 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -98,6 +98,7 @@ "addKeySuccess": "API key added successfully.", "updateKeySuccess": "API key updated successfully.", "addAccountSuccess": "Account added successfully.", + "updateAccountSuccess": "Account metadata updated successfully.", "requiredFields": "Password and email/mobile are required.", "deleteKeyConfirm": "Are you sure you want to delete this API key?", "deleteAccountConfirm": "Are you sure you want to delete this account?", @@ -111,16 +112,17 @@ "accountsUnit": "accounts", "threadsUnit": "threads", "apiKeysTitle": "API Keys", - "apiKeysDesc": "Manage the API access key pool", + "apiKeysDesc": "Manage the API access key pool. Click the pencil icon on each row to edit name and remark.", "addKey": "Add key", "editKeyTitle": "Edit key", + "editAccountTitle": "Edit account", "copied": "Copied", "copyFailed": "Copy failed", "copyKeyTitle": "Copy key", "deleteKeyTitle": "Delete key", "noApiKeys": "No API keys found.", "accountsTitle": "DeepSeek Accounts", - "accountsDesc": "Manage the DeepSeek account pool", + "accountsDesc": "Manage the DeepSeek account pool and edit name/remark.", "testAll": "Refresh all tokens", "addAccount": "Add account", "testingAllAccounts": "Refreshing tokens for all accounts...", @@ -131,6 +133,7 @@ "noAccounts": "No accounts found.", "modalAddKeyTitle": "Add API key", "modalEditKeyTitle": "Edit API key", + "modalEditAccountTitle": "Edit account details", "newKeyLabel": "New key value", "newKeyPlaceholder": "Enter a custom API key", "keyLabel": "Key value", @@ -142,6 +145,10 @@ "addKeyAction": "Add key", "editKeyLoading": "Saving...", "editKeyAction": "Save changes", + "editAccountHint": "Only name and remark can be changed here. The account identifier stays the same.", + "accountIdentifierLabel": "Account identifier", + "editAccountLoading": "Saving...", + "editAccountAction": "Save changes", "modalAddAccountTitle": "Add DeepSeek account", "nameOptional": "Name (optional)", "namePlaceholder": "e.g. Primary Account A", diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index 4406ec8..443cc29 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -98,6 +98,7 @@ "addKeySuccess": "API 密钥添加成功", "updateKeySuccess": "API 密钥更新成功", "addAccountSuccess": "账号添加成功", + "updateAccountSuccess": "账号信息更新成功", "requiredFields": "需要填写密码以及邮箱或手机号", "deleteKeyConfirm": "确定要删除此 API 密钥吗?", "deleteAccountConfirm": "确定要删除此账号吗?", @@ -111,16 +112,17 @@ "accountsUnit": "个账号", "threadsUnit": "线程", "apiKeysTitle": "API 密钥", - "apiKeysDesc": "管理 API 访问密钥池", + "apiKeysDesc": "管理 API 访问密钥池,点每行右侧铅笔可修改名称和备注", "addKey": "添加密钥", "editKeyTitle": "编辑密钥", + "editAccountTitle": "编辑账号", "copied": "已复制", "copyFailed": "复制失败", "copyKeyTitle": "复制密钥", "deleteKeyTitle": "删除密钥", "noApiKeys": "未找到 API 密钥", "accountsTitle": "DeepSeek 账号", - "accountsDesc": "管理 DeepSeek 账号池", + "accountsDesc": "管理 DeepSeek 账号池,支持修改名称和备注", "testAll": "刷新全部 Token", "addAccount": "添加账号", "testingAllAccounts": "正在刷新所有账号 Token...", @@ -131,6 +133,7 @@ "noAccounts": "未找到任何账号", "modalAddKeyTitle": "添加 API 密钥", "modalEditKeyTitle": "编辑 API 密钥", + "modalEditAccountTitle": "编辑账号信息", "newKeyLabel": "新密钥值", "newKeyPlaceholder": "输入自定义 API 密钥", "keyLabel": "密钥值", @@ -142,6 +145,10 @@ "addKeyAction": "添加密钥", "editKeyLoading": "保存中...", "editKeyAction": "保存修改", + "editAccountHint": "这里只能修改名称和备注,账号标识保持不变。", + "accountIdentifierLabel": "账号标识", + "editAccountLoading": "保存中...", + "editAccountAction": "保存修改", "modalAddAccountTitle": "添加 DeepSeek 账号", "nameOptional": "名称(可选)", "namePlaceholder": "例如:主账号 A", From e8407432953b9bdb3706a9b6be394960aa7961e2 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Wed, 22 Apr 2026 17:30:39 +0000 Subject: [PATCH 09/15] refactor: centralize batch import templates and enable config file access in Vite --- webui/src/components/BatchImport.jsx | 51 +------------------------ webui/src/locales/en.json | 2 +- webui/src/locales/zh.json | 2 +- webui/src/utils/batchImportTemplates.js | 42 ++++++++++++++++++++ webui/vite.config.js | 8 ++++ 5 files changed, 54 insertions(+), 51 deletions(-) create mode 100644 webui/src/utils/batchImportTemplates.js diff --git a/webui/src/components/BatchImport.jsx b/webui/src/components/BatchImport.jsx index f06da69..06e6313 100644 --- a/webui/src/components/BatchImport.jsx +++ b/webui/src/components/BatchImport.jsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { FileCode, Download, Upload, Copy, Check, AlertTriangle } from 'lucide-react' import clsx from 'clsx' import { useI18n } from '../i18n' +import { getBatchImportTemplates } from '../utils/batchImportTemplates' export default function BatchImport({ onRefresh, onMessage, authFetch }) { const { t } = useI18n() @@ -11,55 +12,7 @@ export default function BatchImport({ onRefresh, onMessage, authFetch }) { const [copied, setCopied] = useState(false) const apiFetch = authFetch || fetch - const templates = { - full: { - name: t('batchImport.templates.full.name'), - desc: t('batchImport.templates.full.desc'), - config: { - keys: ["your-api-key-1", "your-api-key-2"], - accounts: [ - { email: "user1@example.com", password: "password1", token: "" }, - { email: "user2@example.com", password: "password2", token: "" }, - { mobile: "+8613800138001", password: "password3", token: "" } - ], - claude_model_mapping: { - fast: "deepseek-chat", - slow: "deepseek-reasoner" - } - } - }, - email_only: { - name: t('batchImport.templates.emailOnly.name'), - desc: t('batchImport.templates.emailOnly.desc'), - config: { - keys: ["your-api-key"], - accounts: [ - { email: "account1@example.com", password: "pass1", token: "" }, - { email: "account2@example.com", password: "pass2", token: "" }, - { email: "account3@example.com", password: "pass3", token: "" } - ] - } - }, - mobile_only: { - name: t('batchImport.templates.mobileOnly.name'), - desc: t('batchImport.templates.mobileOnly.desc'), - config: { - keys: ["your-api-key"], - accounts: [ - { mobile: "+8613800000001", password: "pass1", token: "" }, - { mobile: "+8613800000002", password: "pass2", token: "" }, - { mobile: "+8613800000003", password: "pass3", token: "" } - ] - } - }, - keys_only: { - name: t('batchImport.templates.keysOnly.name'), - desc: t('batchImport.templates.keysOnly.desc'), - config: { - keys: ["key-1", "key-2", "key-3"] - } - } - } + const templates = getBatchImportTemplates(t) const handleImport = async () => { if (!jsonInput.trim()) { diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index 79622df..7548a30 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -321,7 +321,7 @@ "templates": { "full": { "name": "Full configuration template", - "desc": "Includes keys, accounts, and model mapping" + "desc": "Loaded from config.example.json with keys, accounts, and defaults" }, "emailOnly": { "name": "Email-only accounts", diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index 443cc29..9477c5f 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -321,7 +321,7 @@ "templates": { "full": { "name": "全量配置模板", - "desc": "包含密钥、账号及模型映射" + "desc": "直接复用 config.example.json,包含密钥、账号和默认配置" }, "emailOnly": { "name": "仅邮箱账号", diff --git a/webui/src/utils/batchImportTemplates.js b/webui/src/utils/batchImportTemplates.js new file mode 100644 index 0000000..652640d --- /dev/null +++ b/webui/src/utils/batchImportTemplates.js @@ -0,0 +1,42 @@ +import exampleConfig from '../../../config.example.json' + +export function getBatchImportTemplates(t) { + return { + full: { + name: t('batchImport.templates.full.name'), + desc: t('batchImport.templates.full.desc'), + config: exampleConfig, + }, + email_only: { + name: t('batchImport.templates.emailOnly.name'), + desc: t('batchImport.templates.emailOnly.desc'), + config: { + keys: ['your-api-key'], + accounts: [ + { email: 'account1@example.com', password: 'pass1', token: '' }, + { email: 'account2@example.com', password: 'pass2', token: '' }, + { email: 'account3@example.com', password: 'pass3', token: '' }, + ], + }, + }, + mobile_only: { + name: t('batchImport.templates.mobileOnly.name'), + desc: t('batchImport.templates.mobileOnly.desc'), + config: { + keys: ['your-api-key'], + accounts: [ + { mobile: '+8613800000001', password: 'pass1', token: '' }, + { mobile: '+8613800000002', password: 'pass2', token: '' }, + { mobile: '+8613800000003', password: 'pass3', token: '' }, + ], + }, + }, + keys_only: { + name: t('batchImport.templates.keysOnly.name'), + desc: t('batchImport.templates.keysOnly.desc'), + config: { + keys: ['key-1', 'key-2', 'key-3'], + }, + }, + } +} diff --git a/webui/vite.config.js b/webui/vite.config.js index f874531..1dc770a 100644 --- a/webui/vite.config.js +++ b/webui/vite.config.js @@ -1,5 +1,10 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const webuiDir = dirname(fileURLToPath(import.meta.url)) +const repoRoot = resolve(webuiDir, '..') export default defineConfig(({ mode }) => ({ plugins: [ @@ -7,6 +12,9 @@ export default defineConfig(({ mode }) => ({ ], server: { port: 5173, + fs: { + allow: [repoRoot], + }, proxy: { // 代理 /admin 下的 API 请求到后端 '/admin': { From f178000d69482c70c687b7051a45f88b372e273d Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Wed, 22 Apr 2026 17:36:18 +0000 Subject: [PATCH 10/15] docs: clarify config template synchronization and archive contents in README files --- README.MD | 4 +++- README.en.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.MD b/README.MD index 459a5ba..3aeccb8 100644 --- a/README.MD +++ b/README.MD @@ -175,6 +175,8 @@ cp config.example.json config.json - 本地运行:直接读取 `config.json` - Docker / Vercel:由 `config.json` 生成 `DS2API_CONFIG_JSON`(Base64)注入环境变量,也可以直接写原始 JSON +WebUI 管理台里的“全量配置模板”也直接复用同一份 `config.example.json`,所以更新示例文件后,前端模板会自动保持一致。 + ### 方式一:下载 Release 构建包 每次发布 Release 时,GitHub Actions 会自动构建多平台二进制包: @@ -513,7 +515,7 @@ go test -v -run 'TestParseToolCalls|TestRepair' ./internal/toolcall/ - **触发条件**:仅在 GitHub Release `published` 时触发(普通 push 不会触发) - **构建产物**:多平台二进制包(`linux/amd64`、`linux/arm64`、`darwin/amd64`、`darwin/arm64`、`windows/amd64`)+ `sha256sums.txt` - **容器镜像发布**:仅推送到 GHCR(`ghcr.io/cjackhwang/ds2api`) -- **每个压缩包包含**:`ds2api` 可执行文件、`static/admin`、WASM 文件(同时支持内置 fallback)、配置示例、README、LICENSE +- **每个压缩包包含**:`ds2api` 可执行文件、`static/admin`、WASM 文件(同时支持内置 fallback)、`config.example.json` 配置示例、README、LICENSE ## 免责声明 diff --git a/README.en.md b/README.en.md index 61c4c90..b1a4a7a 100644 --- a/README.en.md +++ b/README.en.md @@ -173,6 +173,8 @@ Recommended per deployment mode: - Local run: read `config.json` directly - Docker / Vercel: generate Base64 from `config.json` and inject as `DS2API_CONFIG_JSON`, or paste raw JSON directly +The WebUI admin panel’s “Full configuration template” is loaded from the same `config.example.json`, so updating that file keeps the frontend template in sync. + ### Option 1: Download Release Binaries GitHub Actions automatically builds multi-platform archives on each Release: @@ -471,7 +473,7 @@ Workflow: `.github/workflows/release-artifacts.yml` - **Trigger**: only on GitHub Release `published` (normal pushes do not trigger builds) - **Outputs**: multi-platform archives (`linux/amd64`, `linux/arm64`, `darwin/amd64`, `darwin/arm64`, `windows/amd64`) + `sha256sums.txt` - **Container publishing**: GHCR only (`ghcr.io/cjackhwang/ds2api`) -- **Each archive includes**: `ds2api` executable, `static/admin`, WASM file (with embedded fallback support), config template, README, LICENSE +- **Each archive includes**: `ds2api` executable, `static/admin`, WASM file (with embedded fallback support), `config.example.json`-based config template, README, LICENSE ## Disclaimer From 2788e20f05a1381df2fdabcdf04da1c301f538ad Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Wed, 22 Apr 2026 18:23:09 +0000 Subject: [PATCH 11/15] feat: implement history split functionality to optimize context usage and add corresponding UI settings --- config.example.json | 4 + internal/adapter/openai/deps.go | 2 + .../adapter/openai/deps_injection_test.go | 25 +- internal/adapter/openai/handler_chat.go | 7 +- internal/adapter/openai/history_split.go | 213 ++++++++++++ internal/adapter/openai/history_split_test.go | 317 ++++++++++++++++++ internal/adapter/openai/responses_handler.go | 5 + internal/adapter/openai/standard_request.go | 2 + internal/adapter/openai/upstream_empty.go | 4 +- internal/admin/deps.go | 2 + internal/admin/handler_settings_parse.go | 58 ++-- internal/admin/handler_settings_read.go | 12 +- internal/admin/handler_settings_test.go | 43 +++ internal/admin/handler_settings_write.go | 10 +- internal/config/codec.go | 25 +- internal/config/config.go | 38 ++- internal/config/config_edge_test.go | 28 ++ internal/config/store_accessors.go | 18 + internal/config/store_accessors_test.go | 27 ++ internal/config/validation.go | 12 + internal/config/validation_test.go | 9 + internal/util/standard_request.go | 1 + .../features/settings/HistorySplitSection.jsx | 48 +++ .../features/settings/SettingsContainer.jsx | 3 + .../src/features/settings/useSettingsForm.js | 9 + webui/src/locales/en.json | 6 + webui/src/locales/zh.json | 6 + 27 files changed, 880 insertions(+), 54 deletions(-) create mode 100644 internal/adapter/openai/history_split.go create mode 100644 internal/adapter/openai/history_split_test.go create mode 100644 internal/config/store_accessors_test.go create mode 100644 webui/src/features/settings/HistorySplitSection.jsx diff --git a/config.example.json b/config.example.json index 0f40a45..0c13de4 100644 --- a/config.example.json +++ b/config.example.json @@ -49,6 +49,10 @@ "responses": { "store_ttl_seconds": 900 }, + "history_split": { + "enabled": true, + "trigger_after_turns": 1 + }, "embeddings": { "provider": "deterministic" }, diff --git a/internal/adapter/openai/deps.go b/internal/adapter/openai/deps.go index 351a13c..50118ff 100644 --- a/internal/adapter/openai/deps.go +++ b/internal/adapter/openai/deps.go @@ -34,6 +34,8 @@ type ConfigReader interface { EmbeddingsProvider() string AutoDeleteMode() string AutoDeleteSessions() bool + HistorySplitEnabled() bool + HistorySplitTriggerAfterTurns() int } var _ AuthResolver = (*auth.Resolver)(nil) diff --git a/internal/adapter/openai/deps_injection_test.go b/internal/adapter/openai/deps_injection_test.go index 2364540..f3c9741 100644 --- a/internal/adapter/openai/deps_injection_test.go +++ b/internal/adapter/openai/deps_injection_test.go @@ -3,13 +3,15 @@ package openai import "testing" type mockOpenAIConfig struct { - aliases map[string]string - wideInput bool - autoDeleteMode string - toolMode string - earlyEmit string - responsesTTL int - embedProv string + aliases map[string]string + wideInput bool + autoDeleteMode string + toolMode string + earlyEmit string + responsesTTL int + embedProv string + historySplitEnabled bool + historySplitTurns int } func (m mockOpenAIConfig) ModelAliases() map[string]string { return m.aliases } @@ -27,7 +29,14 @@ func (m mockOpenAIConfig) AutoDeleteMode() string { } return m.autoDeleteMode } -func (m mockOpenAIConfig) AutoDeleteSessions() bool { return false } +func (m mockOpenAIConfig) AutoDeleteSessions() bool { return false } +func (m mockOpenAIConfig) HistorySplitEnabled() bool { return m.historySplitEnabled } +func (m mockOpenAIConfig) HistorySplitTriggerAfterTurns() int { + if m.historySplitTurns <= 0 { + return 1 + } + return m.historySplitTurns +} func TestNormalizeOpenAIChatRequestWithConfigInterface(t *testing.T) { cfg := mockOpenAIConfig{ diff --git a/internal/adapter/openai/handler_chat.go b/internal/adapter/openai/handler_chat.go index ed9d2c7..b7d76ba 100644 --- a/internal/adapter/openai/handler_chat.go +++ b/internal/adapter/openai/handler_chat.go @@ -63,6 +63,11 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) { writeOpenAIError(w, http.StatusBadRequest, err.Error()) return } + stdReq, err = h.applyHistorySplit(r.Context(), a, stdReq) + if err != nil { + writeOpenAIError(w, http.StatusInternalServerError, err.Error()) + return + } historySession := startChatHistory(h.ChatHistory, r, a, stdReq) sessionID, err = h.DS.CreateSession(r.Context(), a, 3) @@ -155,7 +160,7 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, resp *http.Response, co if searchEnabled { finalText = replaceCitationMarkersWithLinks(finalText, result.CitationLinks) } - if shouldWriteUpstreamEmptyOutputError(finalText, result.ContentFilter) { + if shouldWriteUpstreamEmptyOutputError(finalText) { status, message, code := upstreamEmptyOutputDetail(result.ContentFilter, finalText, finalThinking) if historySession != nil { historySession.error(status, message, code, finalThinking, finalText) diff --git a/internal/adapter/openai/history_split.go b/internal/adapter/openai/history_split.go new file mode 100644 index 0000000..b65e2e2 --- /dev/null +++ b/internal/adapter/openai/history_split.go @@ -0,0 +1,213 @@ +package openai + +import ( + "context" + "errors" + "fmt" + "strings" + + "ds2api/internal/auth" + "ds2api/internal/deepseek" + "ds2api/internal/util" +) + +const ( + historySplitFilename = "HISTORY.txt" + historySplitContentType = "text/plain; charset=utf-8" + historySplitPurpose = "assistants" +) + +func (h *Handler) applyHistorySplit(ctx context.Context, a *auth.RequestAuth, stdReq util.StandardRequest) (util.StandardRequest, error) { + if h == nil || h.DS == nil || h.Store == nil || a == nil { + return stdReq, nil + } + if !h.Store.HistorySplitEnabled() { + return stdReq, nil + } + + promptMessages, historyMessages := splitOpenAIHistoryMessages(stdReq.Messages, h.Store.HistorySplitTriggerAfterTurns()) + if len(historyMessages) == 0 { + return stdReq, nil + } + + historyText := buildOpenAIHistoryTranscript(historyMessages) + if strings.TrimSpace(historyText) == "" { + return stdReq, errors.New("history split produced empty transcript") + } + + result, err := h.DS.UploadFile(ctx, a, deepseek.UploadFileRequest{ + Filename: historySplitFilename, + ContentType: historySplitContentType, + Purpose: historySplitPurpose, + Data: []byte(historyText), + }, 3) + if err != nil { + return stdReq, fmt.Errorf("upload history file: %w", err) + } + fileID := strings.TrimSpace(result.ID) + if fileID == "" { + return stdReq, errors.New("upload history file returned empty file id") + } + + stdReq.Messages = promptMessages + stdReq.RefFileIDs = prependUniqueRefFileID(stdReq.RefFileIDs, fileID) + stdReq.FinalPrompt, stdReq.ToolNames = buildHistorySplitPrompt(promptMessages, stdReq.ToolsRaw, stdReq.ToolChoice, stdReq.Thinking) + return stdReq, nil +} + +func buildHistorySplitPrompt(messages []any, toolsRaw any, toolPolicy util.ToolChoicePolicy, thinkingEnabled bool) (string, []string) { + if len(messages) == 0 { + return "", nil + } + instruction := historySplitPromptInstruction() + withInstruction := make([]any, 0, len(messages)+1) + withInstruction = append(withInstruction, map[string]any{ + "role": "system", + "content": instruction, + }) + withInstruction = append(withInstruction, messages...) + return buildOpenAIFinalPromptWithPolicy(withInstruction, toolsRaw, "", toolPolicy, thinkingEnabled) +} + +func historySplitPromptInstruction() string { + return "An attached HISTORY.txt file contains prior conversation history and tool progress. Read it first, then answer the latest user request using that history as context." +} + +func splitOpenAIHistoryMessages(messages []any, triggerAfterTurns int) ([]any, []any) { + if triggerAfterTurns <= 0 { + triggerAfterTurns = 1 + } + lastUserIndex := -1 + userTurns := 0 + for i, raw := range messages { + msg, ok := raw.(map[string]any) + if !ok { + continue + } + role := strings.ToLower(strings.TrimSpace(asString(msg["role"]))) + if role != "user" { + continue + } + userTurns++ + lastUserIndex = i + } + if userTurns <= triggerAfterTurns || lastUserIndex < 0 { + return messages, nil + } + + promptMessages := make([]any, 0, len(messages)-lastUserIndex) + historyMessages := make([]any, 0, lastUserIndex) + for i, raw := range messages { + msg, ok := raw.(map[string]any) + if !ok { + if i >= lastUserIndex { + promptMessages = append(promptMessages, raw) + } else { + historyMessages = append(historyMessages, raw) + } + continue + } + role := strings.ToLower(strings.TrimSpace(asString(msg["role"]))) + switch role { + case "system", "developer": + promptMessages = append(promptMessages, raw) + default: + if i >= lastUserIndex { + promptMessages = append(promptMessages, raw) + } else { + historyMessages = append(historyMessages, raw) + } + } + } + if len(promptMessages) == 0 { + return messages, nil + } + return promptMessages, historyMessages +} + +func buildOpenAIHistoryTranscript(messages []any) string { + var b strings.Builder + b.WriteString("# HISTORY.txt\n") + b.WriteString("Prior conversation history and tool progress.\n\n") + + entry := 0 + for _, raw := range messages { + msg, ok := raw.(map[string]any) + if !ok { + continue + } + role := strings.ToLower(strings.TrimSpace(asString(msg["role"]))) + content := buildOpenAIHistoryEntry(role, msg) + if strings.TrimSpace(content) == "" { + continue + } + entry++ + fmt.Fprintf(&b, "=== %d. %s ===\n%s\n\n", entry, strings.ToUpper(roleLabelForHistory(role)), content) + } + return strings.TrimSpace(b.String()) + "\n" +} + +func buildOpenAIHistoryEntry(role string, msg map[string]any) string { + switch role { + case "assistant": + return strings.TrimSpace(buildAssistantContentForPrompt(msg)) + case "tool", "function": + return strings.TrimSpace(buildToolHistoryContent(msg)) + case "user": + return strings.TrimSpace(normalizeOpenAIContentForPrompt(msg["content"])) + default: + return strings.TrimSpace(normalizeOpenAIContentForPrompt(msg["content"])) + } +} + +func buildToolHistoryContent(msg map[string]any) string { + content := strings.TrimSpace(normalizeOpenAIContentForPrompt(msg["content"])) + parts := make([]string, 0, 2) + if name := strings.TrimSpace(asString(msg["name"])); name != "" { + parts = append(parts, "name="+name) + } + if callID := strings.TrimSpace(asString(msg["tool_call_id"])); callID != "" { + parts = append(parts, "tool_call_id="+callID) + } + header := "" + if len(parts) > 0 { + header = "[" + strings.Join(parts, " ") + "]" + } + switch { + case header != "" && content != "": + return header + "\n" + content + case header != "": + return header + default: + return content + } +} + +func roleLabelForHistory(role string) string { + role = strings.ToLower(strings.TrimSpace(role)) + switch role { + case "function": + return "tool" + case "": + return "unknown" + default: + return role + } +} + +func prependUniqueRefFileID(existing []string, fileID string) []string { + fileID = strings.TrimSpace(fileID) + if fileID == "" { + return existing + } + out := make([]string, 0, len(existing)+1) + out = append(out, fileID) + for _, id := range existing { + trimmed := strings.TrimSpace(id) + if trimmed == "" || strings.EqualFold(trimmed, fileID) { + continue + } + out = append(out, trimmed) + } + return out +} diff --git a/internal/adapter/openai/history_split_test.go b/internal/adapter/openai/history_split_test.go new file mode 100644 index 0000000..46fa366 --- /dev/null +++ b/internal/adapter/openai/history_split_test.go @@ -0,0 +1,317 @@ +package openai + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + "ds2api/internal/auth" + "ds2api/internal/util" +) + +func historySplitTestMessages() []any { + toolCalls := []any{ + map[string]any{ + "name": "search", + "arguments": map[string]any{"query": "docs"}, + }, + } + return []any{ + map[string]any{"role": "system", "content": "system instructions"}, + map[string]any{"role": "user", "content": "first user turn"}, + map[string]any{ + "role": "assistant", + "content": "", + "reasoning_content": "hidden reasoning", + "tool_calls": toolCalls, + }, + map[string]any{ + "role": "tool", + "name": "search", + "tool_call_id": "call-1", + "content": "tool result", + }, + map[string]any{"role": "user", "content": "latest user turn"}, + } +} + +func TestBuildOpenAIHistoryTranscriptPreservesOrderAndToolHistory(t *testing.T) { + promptMessages, historyMessages := splitOpenAIHistoryMessages(historySplitTestMessages(), 1) + if len(promptMessages) != 2 { + t.Fatalf("expected 2 prompt messages, got %d", len(promptMessages)) + } + if len(historyMessages) != 3 { + t.Fatalf("expected 3 history messages, got %d", len(historyMessages)) + } + + transcript := buildOpenAIHistoryTranscript(historyMessages) + if !strings.Contains(transcript, "first user turn") { + t.Fatalf("expected user history in transcript, got %s", transcript) + } + if !strings.Contains(transcript, "") { + t.Fatalf("expected assistant tool_calls in transcript, got %s", transcript) + } + if !strings.Contains(transcript, "tool_call_id=call-1") { + t.Fatalf("expected tool call id in transcript, got %s", transcript) + } + if strings.Contains(transcript, "hidden reasoning") { + t.Fatalf("did not expect hidden reasoning in transcript, got %s", transcript) + } + + userIdx := strings.Index(transcript, "=== 1. USER ===") + assistantIdx := strings.Index(transcript, "=== 2. ASSISTANT ===") + toolIdx := strings.Index(transcript, "=== 3. TOOL ===") + if userIdx < 0 || assistantIdx < 0 || toolIdx < 0 { + t.Fatalf("expected ordered role sections, got %s", transcript) + } + if userIdx >= assistantIdx || assistantIdx >= toolIdx { + t.Fatalf("expected USER -> ASSISTANT -> TOOL order, got %s", transcript) + } + + finalPrompt, _ := buildHistorySplitPrompt(promptMessages, nil, util.DefaultToolChoicePolicy(), false) + if !strings.Contains(finalPrompt, "latest user turn") { + t.Fatalf("expected latest user turn in final prompt, got %s", finalPrompt) + } + if strings.Contains(finalPrompt, "first user turn") { + t.Fatalf("expected earlier history to be removed from final prompt, got %s", finalPrompt) + } + if !strings.Contains(finalPrompt, "HISTORY.txt") { + t.Fatalf("expected history instruction in final prompt, got %s", finalPrompt) + } +} + +func TestSplitOpenAIHistoryMessagesUsesLatestUserTurn(t *testing.T) { + toolCalls := []any{ + map[string]any{ + "name": "search", + "arguments": map[string]any{"query": "docs"}, + }, + } + messages := []any{ + map[string]any{"role": "system", "content": "system instructions"}, + map[string]any{"role": "user", "content": "first user turn"}, + map[string]any{ + "role": "assistant", + "content": "", + "tool_calls": toolCalls, + }, + map[string]any{ + "role": "tool", + "name": "search", + "tool_call_id": "call-1", + "content": "tool result", + }, + map[string]any{"role": "user", "content": "middle user turn"}, + map[string]any{ + "role": "assistant", + "content": "middle assistant turn", + }, + map[string]any{"role": "user", "content": "latest user turn"}, + } + + promptMessages, historyMessages := splitOpenAIHistoryMessages(messages, 1) + if len(promptMessages) == 0 || len(historyMessages) == 0 { + t.Fatalf("expected both prompt and history messages, got prompt=%d history=%d", len(promptMessages), len(historyMessages)) + } + + promptText := buildOpenAIFinalPromptForSplitTest(promptMessages) + if !strings.Contains(promptText, "latest user turn") { + t.Fatalf("expected latest user turn in prompt, got %s", promptText) + } + if strings.Contains(promptText, "middle user turn") { + t.Fatalf("expected middle user turn to be split into history, got %s", promptText) + } + + historyText := buildOpenAIHistoryTranscript(historyMessages) + if !strings.Contains(historyText, "middle user turn") { + t.Fatalf("expected middle user turn in HISTORY.txt, got %s", historyText) + } + if strings.Contains(historyText, "latest user turn") { + t.Fatalf("expected latest user turn to remain in prompt, got %s", historyText) + } +} + +func buildOpenAIFinalPromptForSplitTest(messages []any) string { + prompt, _ := buildHistorySplitPrompt(messages, nil, util.DefaultToolChoicePolicy(), false) + return prompt +} + +func TestApplyHistorySplitSkipsFirstTurn(t *testing.T) { + ds := &inlineUploadDSStub{} + h := &Handler{ + Store: mockOpenAIConfig{ + wideInput: true, + historySplitEnabled: true, + historySplitTurns: 1, + }, + DS: ds, + } + req := map[string]any{ + "model": "deepseek-chat", + "messages": []any{ + map[string]any{"role": "user", "content": "hello"}, + }, + } + stdReq, err := normalizeOpenAIChatRequest(h.Store, req, "") + if err != nil { + t.Fatalf("normalize failed: %v", err) + } + + out, err := h.applyHistorySplit(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq) + if err != nil { + t.Fatalf("apply history split failed: %v", err) + } + if len(ds.uploadCalls) != 0 { + t.Fatalf("expected no upload on first turn, got %d", len(ds.uploadCalls)) + } + if out.FinalPrompt != stdReq.FinalPrompt { + t.Fatalf("expected prompt unchanged on first turn") + } + if len(out.RefFileIDs) != len(stdReq.RefFileIDs) { + t.Fatalf("expected ref files unchanged on first turn") + } +} + +func TestChatCompletionsHistorySplitUploadsHistoryAndKeepsLatestPrompt(t *testing.T) { + ds := &inlineUploadDSStub{} + h := &Handler{ + Store: mockOpenAIConfig{ + wideInput: true, + historySplitEnabled: true, + historySplitTurns: 1, + }, + Auth: streamStatusAuthStub{}, + DS: ds, + } + reqBody, _ := json.Marshal(map[string]any{ + "model": "deepseek-chat", + "messages": historySplitTestMessages(), + "stream": false, + }) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(string(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("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + if len(ds.uploadCalls) != 1 { + t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls)) + } + upload := ds.uploadCalls[0] + if upload.Filename != "HISTORY.txt" { + t.Fatalf("unexpected upload filename: %q", upload.Filename) + } + if upload.ContentType != "text/plain; charset=utf-8" { + t.Fatalf("unexpected content type: %q", upload.ContentType) + } + if upload.Purpose != "assistants" { + t.Fatalf("unexpected purpose: %q", upload.Purpose) + } + historyText := string(upload.Data) + if !strings.Contains(historyText, "first user turn") || !strings.Contains(historyText, "tool result") { + t.Fatalf("expected older turns in HISTORY.txt, got %s", historyText) + } + if strings.Contains(historyText, "latest user turn") { + t.Fatalf("expected latest turn to remain in prompt, got %s", historyText) + } + if ds.completionReq == nil { + t.Fatal("expected completion payload to be captured") + } + promptText, _ := ds.completionReq["prompt"].(string) + if !strings.Contains(promptText, "latest user turn") { + t.Fatalf("expected latest turn in completion prompt, got %s", promptText) + } + if strings.Contains(promptText, "first user turn") { + t.Fatalf("expected historical turns removed from completion prompt, got %s", promptText) + } + if !strings.Contains(promptText, "HISTORY.txt") { + t.Fatalf("expected history instruction in completion prompt, got %s", promptText) + } + refIDs, _ := ds.completionReq["ref_file_ids"].([]any) + if len(refIDs) == 0 || refIDs[0] != "file-inline-1" { + t.Fatalf("expected uploaded history file to be first ref_file_id, got %#v", ds.completionReq["ref_file_ids"]) + } +} + +func TestResponsesHistorySplitUploadsHistoryAndKeepsLatestPrompt(t *testing.T) { + ds := &inlineUploadDSStub{} + h := &Handler{ + Store: mockOpenAIConfig{ + wideInput: true, + historySplitEnabled: true, + historySplitTurns: 1, + }, + Auth: streamStatusAuthStub{}, + DS: ds, + } + r := chi.NewRouter() + RegisterRoutes(r, h) + reqBody, _ := json.Marshal(map[string]any{ + "model": "deepseek-chat", + "messages": historySplitTestMessages(), + "stream": false, + }) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(string(reqBody))) + req.Header.Set("Authorization", "Bearer direct-token") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + if len(ds.uploadCalls) != 1 { + t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls)) + } + if ds.completionReq == nil { + t.Fatal("expected completion payload to be captured") + } + promptText, _ := ds.completionReq["prompt"].(string) + if !strings.Contains(promptText, "latest user turn") { + t.Fatalf("expected latest turn in completion prompt, got %s", promptText) + } + if strings.Contains(promptText, "first user turn") { + t.Fatalf("expected historical turns removed from completion prompt, got %s", promptText) + } +} + +func TestChatCompletionsHistorySplitUploadFailureReturnsInternalServerError(t *testing.T) { + ds := &inlineUploadDSStub{uploadErr: context.DeadlineExceeded} + h := &Handler{ + Store: mockOpenAIConfig{ + wideInput: true, + historySplitEnabled: true, + historySplitTurns: 1, + }, + Auth: streamStatusAuthStub{}, + DS: ds, + } + reqBody, _ := json.Marshal(map[string]any{ + "model": "deepseek-chat", + "messages": historySplitTestMessages(), + "stream": false, + }) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(string(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.StatusInternalServerError { + t.Fatalf("expected 500, got %d body=%s", rec.Code, rec.Body.String()) + } + if ds.completionReq != nil { + t.Fatalf("did not expect completion payload on upload failure") + } +} diff --git a/internal/adapter/openai/responses_handler.go b/internal/adapter/openai/responses_handler.go index 35c616b..2994088 100644 --- a/internal/adapter/openai/responses_handler.go +++ b/internal/adapter/openai/responses_handler.go @@ -85,6 +85,11 @@ func (h *Handler) Responses(w http.ResponseWriter, r *http.Request) { writeOpenAIError(w, http.StatusBadRequest, err.Error()) return } + stdReq, err = h.applyHistorySplit(r.Context(), a, stdReq) + if err != nil { + writeOpenAIError(w, http.StatusInternalServerError, err.Error()) + return + } sessionID, err := h.DS.CreateSession(r.Context(), a, 3) if err != nil { diff --git a/internal/adapter/openai/standard_request.go b/internal/adapter/openai/standard_request.go index 3cdf640..4270c6e 100644 --- a/internal/adapter/openai/standard_request.go +++ b/internal/adapter/openai/standard_request.go @@ -35,6 +35,7 @@ func normalizeOpenAIChatRequest(store ConfigReader, req map[string]any, traceID ResolvedModel: resolvedModel, ResponseModel: responseModel, Messages: messagesRaw, + ToolsRaw: req["tools"], FinalPrompt: finalPrompt, ToolNames: toolNames, ToolChoice: toolPolicy, @@ -90,6 +91,7 @@ func normalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra ResolvedModel: resolvedModel, ResponseModel: model, Messages: messagesRaw, + ToolsRaw: req["tools"], FinalPrompt: finalPrompt, ToolNames: toolNames, ToolChoice: toolPolicy, diff --git a/internal/adapter/openai/upstream_empty.go b/internal/adapter/openai/upstream_empty.go index 8b3d07f..bb2da1f 100644 --- a/internal/adapter/openai/upstream_empty.go +++ b/internal/adapter/openai/upstream_empty.go @@ -2,7 +2,7 @@ package openai import "net/http" -func shouldWriteUpstreamEmptyOutputError(text string, contentFilter bool) bool { +func shouldWriteUpstreamEmptyOutputError(text string) bool { return text == "" } @@ -18,7 +18,7 @@ func upstreamEmptyOutputDetail(contentFilter bool, text, thinking string) (int, } func writeUpstreamEmptyOutputError(w http.ResponseWriter, text string, contentFilter bool) bool { - if !shouldWriteUpstreamEmptyOutputError(text, contentFilter) { + if !shouldWriteUpstreamEmptyOutputError(text) { return false } status, message, code := upstreamEmptyOutputDetail(contentFilter, text, "") diff --git a/internal/admin/deps.go b/internal/admin/deps.go index 6b083fc..436775c 100644 --- a/internal/admin/deps.go +++ b/internal/admin/deps.go @@ -33,6 +33,8 @@ type ConfigStore interface { RuntimeGlobalMaxInflight(defaultSize int) int RuntimeTokenRefreshIntervalHours() int AutoDeleteMode() string + HistorySplitEnabled() bool + HistorySplitTriggerAfterTurns() int CompatStripReferenceMarkers() bool AutoDeleteSessions() bool } diff --git a/internal/admin/handler_settings_parse.go b/internal/admin/handler_settings_parse.go index a9bd699..c02d421 100644 --- a/internal/admin/handler_settings_parse.go +++ b/internal/admin/handler_settings_parse.go @@ -21,16 +21,17 @@ func boolFrom(v any) bool { } } -func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.CompatConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, map[string]string, map[string]string, error) { +func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.CompatConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, *config.HistorySplitConfig, map[string]string, map[string]string, error) { var ( - adminCfg *config.AdminConfig - runtimeCfg *config.RuntimeConfig - compatCfg *config.CompatConfig - respCfg *config.ResponsesConfig - embCfg *config.EmbeddingsConfig - autoDeleteCfg *config.AutoDeleteConfig - claudeMap map[string]string - aliasMap map[string]string + adminCfg *config.AdminConfig + runtimeCfg *config.RuntimeConfig + compatCfg *config.CompatConfig + respCfg *config.ResponsesConfig + embCfg *config.EmbeddingsConfig + autoDeleteCfg *config.AutoDeleteConfig + historySplitCfg *config.HistorySplitConfig + claudeMap map[string]string + aliasMap map[string]string ) if raw, ok := req["admin"].(map[string]any); ok { @@ -38,7 +39,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["jwt_expire_hours"]; exists { n := intFrom(v) if err := config.ValidateIntRange("admin.jwt_expire_hours", n, 1, 720, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.JWTExpireHours = n } @@ -50,33 +51,33 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["account_max_inflight"]; exists { n := intFrom(v) if err := config.ValidateIntRange("runtime.account_max_inflight", n, 1, 256, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.AccountMaxInflight = n } if v, exists := raw["account_max_queue"]; exists { n := intFrom(v) if err := config.ValidateIntRange("runtime.account_max_queue", n, 1, 200000, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.AccountMaxQueue = n } if v, exists := raw["global_max_inflight"]; exists { n := intFrom(v) if err := config.ValidateIntRange("runtime.global_max_inflight", n, 1, 200000, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.GlobalMaxInflight = n } if v, exists := raw["token_refresh_interval_hours"]; exists { n := intFrom(v) if err := config.ValidateIntRange("runtime.token_refresh_interval_hours", n, 1, 720, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err } 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") + return nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight") } runtimeCfg = cfg } @@ -99,7 +100,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["store_ttl_seconds"]; exists { n := intFrom(v) if err := config.ValidateIntRange("responses.store_ttl_seconds", n, 30, 86400, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.StoreTTLSeconds = n } @@ -111,7 +112,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["provider"]; exists { p := strings.TrimSpace(fmt.Sprintf("%v", v)) if err := config.ValidateTrimmedString("embeddings.provider", p, false); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.Provider = p } @@ -147,7 +148,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["mode"]; exists { mode := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v))) if err := config.ValidateAutoDeleteMode(mode); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err } if mode == "" { mode = "none" @@ -160,5 +161,24 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi autoDeleteCfg = cfg } - return adminCfg, runtimeCfg, compatCfg, respCfg, embCfg, autoDeleteCfg, claudeMap, aliasMap, nil + if raw, ok := req["history_split"].(map[string]any); ok { + cfg := &config.HistorySplitConfig{} + if v, exists := raw["enabled"]; exists { + b := boolFrom(v) + cfg.Enabled = &b + } + if v, exists := raw["trigger_after_turns"]; exists { + n := intFrom(v) + if err := config.ValidateIntRange("history_split.trigger_after_turns", n, 1, 1000, true); err != nil { + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + } + cfg.TriggerAfterTurns = &n + } + if err := config.ValidateHistorySplitConfig(*cfg); err != nil { + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + } + historySplitCfg = cfg + } + + return adminCfg, runtimeCfg, compatCfg, respCfg, embCfg, autoDeleteCfg, historySplitCfg, claudeMap, aliasMap, nil } diff --git a/internal/admin/handler_settings_read.go b/internal/admin/handler_settings_read.go index d881148..dc060a8 100644 --- a/internal/admin/handler_settings_read.go +++ b/internal/admin/handler_settings_read.go @@ -26,10 +26,14 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) { "global_max_inflight": h.Store.RuntimeGlobalMaxInflight(recommended), "token_refresh_interval_hours": h.Store.RuntimeTokenRefreshIntervalHours(), }, - "compat": snap.Compat, - "responses": snap.Responses, - "embeddings": snap.Embeddings, - "auto_delete": snap.AutoDelete, + "compat": snap.Compat, + "responses": snap.Responses, + "embeddings": snap.Embeddings, + "auto_delete": snap.AutoDelete, + "history_split": map[string]any{ + "enabled": h.Store.HistorySplitEnabled(), + "trigger_after_turns": h.Store.HistorySplitTriggerAfterTurns(), + }, "claude_mapping": settingsClaudeMapping(snap), "model_aliases": snap.ModelAliases, "env_backed": h.Store.IsEnvBacked(), diff --git a/internal/admin/handler_settings_test.go b/internal/admin/handler_settings_test.go index e3ec356..4300cfe 100644 --- a/internal/admin/handler_settings_test.go +++ b/internal/admin/handler_settings_test.go @@ -47,6 +47,25 @@ func TestGetSettingsIncludesTokenRefreshInterval(t *testing.T) { } } +func TestGetSettingsIncludesHistorySplitDefaults(t *testing.T) { + h := newAdminTestHandler(t, `{"keys":["k1"]}`) + 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) + historySplit, _ := body["history_split"].(map[string]any) + if got := boolFrom(historySplit["enabled"]); !got { + t.Fatalf("expected history_split.enabled=true, body=%v", body) + } + if got := intFrom(historySplit["trigger_after_turns"]); got != 1 { + t.Fatalf("expected history_split.trigger_after_turns=1, got %d body=%v", got, body) + } +} + func TestUpdateSettingsValidation(t *testing.T) { h := newAdminTestHandler(t, `{"keys":["k1"]}`) payload := map[string]any{ @@ -154,6 +173,30 @@ func TestUpdateSettingsWithoutRuntimeSkipsMergedRuntimeValidation(t *testing.T) } } +func TestUpdateSettingsHistorySplit(t *testing.T) { + h := newAdminTestHandler(t, `{"keys":["k1"]}`) + payload := map[string]any{ + "history_split": map[string]any{ + "enabled": false, + "trigger_after_turns": 3, + }, + } + 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("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + snap := h.Store.Snapshot() + if snap.HistorySplit.Enabled == nil || *snap.HistorySplit.Enabled { + t.Fatalf("expected history_split.enabled=false, got %#v", snap.HistorySplit.Enabled) + } + if snap.HistorySplit.TriggerAfterTurns == nil || *snap.HistorySplit.TriggerAfterTurns != 3 { + t.Fatalf("expected history_split.trigger_after_turns=3, got %#v", snap.HistorySplit.TriggerAfterTurns) + } +} + func TestUpdateSettingsAutoDeleteMode(t *testing.T) { h := newAdminTestHandler(t, `{"keys":["k1"],"auto_delete":{"sessions":true}}`) diff --git a/internal/admin/handler_settings_write.go b/internal/admin/handler_settings_write.go index 776e6b9..ee4105a 100644 --- a/internal/admin/handler_settings_write.go +++ b/internal/admin/handler_settings_write.go @@ -17,7 +17,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) { return } - adminCfg, runtimeCfg, compatCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req) + adminCfg, runtimeCfg, compatCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, historySplitCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()}) return @@ -67,6 +67,14 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) { c.AutoDelete.Mode = autoDeleteCfg.Mode c.AutoDelete.Sessions = autoDeleteCfg.Sessions } + if historySplitCfg != nil { + if historySplitCfg.Enabled != nil { + c.HistorySplit.Enabled = historySplitCfg.Enabled + } + if historySplitCfg.TriggerAfterTurns != nil { + c.HistorySplit.TriggerAfterTurns = historySplitCfg.TriggerAfterTurns + } + } if claudeMap != nil { c.ClaudeMapping = claudeMap c.ClaudeModelMap = nil diff --git a/internal/config/codec.go b/internal/config/codec.go index 744b9b7..11bf1d6 100644 --- a/internal/config/codec.go +++ b/internal/config/codec.go @@ -51,6 +51,9 @@ func (c Config) MarshalJSON() ([]byte, error) { m["embeddings"] = c.Embeddings } m["auto_delete"] = c.AutoDelete + if c.HistorySplit.Enabled != nil || c.HistorySplit.TriggerAfterTurns != nil { + m["history_split"] = c.HistorySplit + } if c.VercelSyncHash != "" { m["_vercel_sync_hash"] = c.VercelSyncHash } @@ -122,6 +125,10 @@ func (c *Config) UnmarshalJSON(b []byte) error { if err := json.Unmarshal(v, &c.AutoDelete); err != nil { return fmt.Errorf("invalid field %q: %w", k, err) } + case "history_split": + if err := json.Unmarshal(v, &c.HistorySplit); err != nil { + return fmt.Errorf("invalid field %q: %w", k, err) + } case "_vercel_sync_hash": if err := json.Unmarshal(v, &c.VercelSyncHash); err != nil { return fmt.Errorf("invalid field %q: %w", k, err) @@ -156,9 +163,13 @@ func (c Config) Clone() Config { WideInputStrictOutput: cloneBoolPtr(c.Compat.WideInputStrictOutput), StripReferenceMarkers: cloneBoolPtr(c.Compat.StripReferenceMarkers), }, - Responses: c.Responses, - Embeddings: c.Embeddings, - AutoDelete: c.AutoDelete, + Responses: c.Responses, + Embeddings: c.Embeddings, + AutoDelete: c.AutoDelete, + HistorySplit: HistorySplitConfig{ + Enabled: cloneBoolPtr(c.HistorySplit.Enabled), + TriggerAfterTurns: cloneIntPtr(c.HistorySplit.TriggerAfterTurns), + }, VercelSyncHash: c.VercelSyncHash, VercelSyncTime: c.VercelSyncTime, AdditionalFields: map[string]any{}, @@ -188,6 +199,14 @@ func cloneBoolPtr(in *bool) *bool { return &v } +func cloneIntPtr(in *int) *int { + if in == nil { + return nil + } + v := *in + return &v +} + func parseConfigString(raw string) (Config, error) { var cfg Config candidates := []string{raw} diff --git a/internal/config/config.go b/internal/config/config.go index 05879c2..dd1d5df 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,22 +8,23 @@ import ( ) type Config struct { - Keys []string `json:"keys,omitempty"` - APIKeys []APIKey `json:"api_keys,omitempty"` - Accounts []Account `json:"accounts,omitempty"` - Proxies []Proxy `json:"proxies,omitempty"` - ClaudeMapping map[string]string `json:"claude_mapping,omitempty"` - ClaudeModelMap map[string]string `json:"claude_model_mapping,omitempty"` - ModelAliases map[string]string `json:"model_aliases,omitempty"` - Admin AdminConfig `json:"admin,omitempty"` - Runtime RuntimeConfig `json:"runtime,omitempty"` - Compat CompatConfig `json:"compat,omitempty"` - Responses ResponsesConfig `json:"responses,omitempty"` - Embeddings EmbeddingsConfig `json:"embeddings,omitempty"` - AutoDelete AutoDeleteConfig `json:"auto_delete"` - VercelSyncHash string `json:"_vercel_sync_hash,omitempty"` - VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"` - AdditionalFields map[string]any `json:"-"` + Keys []string `json:"keys,omitempty"` + APIKeys []APIKey `json:"api_keys,omitempty"` + Accounts []Account `json:"accounts,omitempty"` + Proxies []Proxy `json:"proxies,omitempty"` + ClaudeMapping map[string]string `json:"claude_mapping,omitempty"` + ClaudeModelMap map[string]string `json:"claude_model_mapping,omitempty"` + ModelAliases map[string]string `json:"model_aliases,omitempty"` + Admin AdminConfig `json:"admin,omitempty"` + Runtime RuntimeConfig `json:"runtime,omitempty"` + Compat CompatConfig `json:"compat,omitempty"` + Responses ResponsesConfig `json:"responses,omitempty"` + Embeddings EmbeddingsConfig `json:"embeddings,omitempty"` + AutoDelete AutoDeleteConfig `json:"auto_delete"` + HistorySplit HistorySplitConfig `json:"history_split"` + VercelSyncHash string `json:"_vercel_sync_hash,omitempty"` + VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"` + AdditionalFields map[string]any `json:"-"` } type Account struct { @@ -148,3 +149,8 @@ type AutoDeleteConfig struct { Mode string `json:"mode,omitempty"` Sessions bool `json:"sessions,omitempty"` } + +type HistorySplitConfig struct { + Enabled *bool `json:"enabled,omitempty"` + TriggerAfterTurns *int `json:"trigger_after_turns,omitempty"` +} diff --git a/internal/config/config_edge_test.go b/internal/config/config_edge_test.go index b70cf11..95a6eba 100644 --- a/internal/config/config_edge_test.go +++ b/internal/config/config_edge_test.go @@ -154,6 +154,10 @@ func TestConfigJSONRoundtrip(t *testing.T) { AutoDelete: AutoDeleteConfig{ Mode: "single", }, + HistorySplit: HistorySplitConfig{ + Enabled: &trueVal, + TriggerAfterTurns: func() *int { v := 2; return &v }(), + }, Runtime: RuntimeConfig{ TokenRefreshIntervalHours: 12, }, @@ -193,6 +197,12 @@ func TestConfigJSONRoundtrip(t *testing.T) { if decoded.AutoDelete.Mode != "single" { t.Fatalf("unexpected auto delete mode: %#v", decoded.AutoDelete.Mode) } + if decoded.HistorySplit.Enabled == nil || !*decoded.HistorySplit.Enabled { + t.Fatalf("unexpected history split enabled: %#v", decoded.HistorySplit.Enabled) + } + if decoded.HistorySplit.TriggerAfterTurns == nil || *decoded.HistorySplit.TriggerAfterTurns != 2 { + t.Fatalf("unexpected history split trigger_after_turns: %#v", decoded.HistorySplit.TriggerAfterTurns) + } if decoded.Compat.WideInputStrictOutput == nil || !*decoded.Compat.WideInputStrictOutput { t.Fatalf("unexpected compat wide_input_strict_output: %#v", decoded.Compat.WideInputStrictOutput) } @@ -249,6 +259,8 @@ func TestConfigUnmarshalJSONPreservesUnknownFields(t *testing.T) { func TestConfigCloneIsDeepCopy(t *testing.T) { falseVal := false + trueVal := true + turns := 2 cfg := Config{ Keys: []string{"key1"}, Accounts: []Account{{Email: "user@test.com", Token: "token"}}, @@ -258,6 +270,10 @@ func TestConfigCloneIsDeepCopy(t *testing.T) { Compat: CompatConfig{ StripReferenceMarkers: &falseVal, }, + HistorySplit: HistorySplitConfig{ + Enabled: &trueVal, + TriggerAfterTurns: &turns, + }, AdditionalFields: map[string]any{"custom": "value"}, } @@ -270,6 +286,12 @@ func TestConfigCloneIsDeepCopy(t *testing.T) { if cfg.Compat.StripReferenceMarkers != nil { *cfg.Compat.StripReferenceMarkers = true } + if cfg.HistorySplit.Enabled != nil { + *cfg.HistorySplit.Enabled = false + } + if cfg.HistorySplit.TriggerAfterTurns != nil { + *cfg.HistorySplit.TriggerAfterTurns = 5 + } // Cloned should not be affected if cloned.Keys[0] != "key1" { @@ -284,6 +306,12 @@ func TestConfigCloneIsDeepCopy(t *testing.T) { if cloned.Compat.StripReferenceMarkers == nil || *cloned.Compat.StripReferenceMarkers { t.Fatalf("clone compat was affected: %#v", cloned.Compat.StripReferenceMarkers) } + if cloned.HistorySplit.Enabled == nil || !*cloned.HistorySplit.Enabled { + t.Fatalf("clone history split enabled was affected: %#v", cloned.HistorySplit.Enabled) + } + if cloned.HistorySplit.TriggerAfterTurns == nil || *cloned.HistorySplit.TriggerAfterTurns != 2 { + t.Fatalf("clone history split trigger was affected: %#v", cloned.HistorySplit.TriggerAfterTurns) + } } func TestConfigCloneNilMaps(t *testing.T) { diff --git a/internal/config/store_accessors.go b/internal/config/store_accessors.go index ff152a7..4b8c003 100644 --- a/internal/config/store_accessors.go +++ b/internal/config/store_accessors.go @@ -174,3 +174,21 @@ func (s *Store) RuntimeTokenRefreshIntervalHours() int { func (s *Store) AutoDeleteSessions() bool { return s.AutoDeleteMode() != "none" } + +func (s *Store) HistorySplitEnabled() bool { + s.mu.RLock() + defer s.mu.RUnlock() + if s.cfg.HistorySplit.Enabled == nil { + return true + } + return *s.cfg.HistorySplit.Enabled +} + +func (s *Store) HistorySplitTriggerAfterTurns() int { + s.mu.RLock() + defer s.mu.RUnlock() + if s.cfg.HistorySplit.TriggerAfterTurns == nil || *s.cfg.HistorySplit.TriggerAfterTurns <= 0 { + return 1 + } + return *s.cfg.HistorySplit.TriggerAfterTurns +} diff --git a/internal/config/store_accessors_test.go b/internal/config/store_accessors_test.go new file mode 100644 index 0000000..6939602 --- /dev/null +++ b/internal/config/store_accessors_test.go @@ -0,0 +1,27 @@ +package config + +import "testing" + +func TestStoreHistorySplitAccessors(t *testing.T) { + store := &Store{cfg: Config{}} + if !store.HistorySplitEnabled() { + t.Fatal("expected history split enabled by default") + } + if got := store.HistorySplitTriggerAfterTurns(); got != 1 { + t.Fatalf("default history split trigger_after_turns=%d want=1", got) + } + + enabled := false + turns := 3 + store.cfg.HistorySplit = HistorySplitConfig{ + Enabled: &enabled, + TriggerAfterTurns: &turns, + } + + if store.HistorySplitEnabled() { + t.Fatal("expected history split disabled after override") + } + if got := store.HistorySplitTriggerAfterTurns(); got != 3 { + t.Fatalf("history split trigger_after_turns=%d want=3", got) + } +} diff --git a/internal/config/validation.go b/internal/config/validation.go index e7314e6..3e8954c 100644 --- a/internal/config/validation.go +++ b/internal/config/validation.go @@ -24,6 +24,9 @@ func ValidateConfig(c Config) error { if err := ValidateAutoDeleteConfig(c.AutoDelete); err != nil { return err } + if err := ValidateHistorySplitConfig(c.HistorySplit); err != nil { + return err + } if err := ValidateAccountProxyReferences(c.Accounts, c.Proxies); err != nil { return err } @@ -111,6 +114,15 @@ func ValidateAutoDeleteConfig(autoDelete AutoDeleteConfig) error { return ValidateAutoDeleteMode(autoDelete.Mode) } +func ValidateHistorySplitConfig(historySplit HistorySplitConfig) error { + if historySplit.TriggerAfterTurns != nil { + if err := ValidateIntRange("history_split.trigger_after_turns", *historySplit.TriggerAfterTurns, 1, 1000, true); err != nil { + return err + } + } + return nil +} + func ValidateIntRange(name string, value, min, max int, required bool) error { if value == 0 && !required { return nil diff --git a/internal/config/validation_test.go b/internal/config/validation_test.go index 00b2929..cf4a68e 100644 --- a/internal/config/validation_test.go +++ b/internal/config/validation_test.go @@ -39,6 +39,13 @@ func TestValidateConfigRejectsInvalidValues(t *testing.T) { cfg: Config{AutoDelete: AutoDeleteConfig{Mode: "maybe"}}, want: "auto_delete.mode", }, + { + name: "history split", + cfg: Config{HistorySplit: HistorySplitConfig{ + TriggerAfterTurns: intPtr(0), + }}, + want: "history_split.trigger_after_turns", + }, } for _, tc := range tests { @@ -59,3 +66,5 @@ func TestValidateConfigAcceptsLegacyAutoDeleteSessions(t *testing.T) { t.Fatalf("expected legacy auto_delete.sessions config to remain valid, got %v", err) } } + +func intPtr(v int) *int { return &v } diff --git a/internal/util/standard_request.go b/internal/util/standard_request.go index 2071fbe..be3eeaa 100644 --- a/internal/util/standard_request.go +++ b/internal/util/standard_request.go @@ -8,6 +8,7 @@ type StandardRequest struct { ResolvedModel string ResponseModel string Messages []any + ToolsRaw any FinalPrompt string ToolNames []string ToolChoice ToolChoicePolicy diff --git a/webui/src/features/settings/HistorySplitSection.jsx b/webui/src/features/settings/HistorySplitSection.jsx new file mode 100644 index 0000000..d9db63c --- /dev/null +++ b/webui/src/features/settings/HistorySplitSection.jsx @@ -0,0 +1,48 @@ +export default function HistorySplitSection({ t, form, setForm }) { + return ( +
+
+

{t('settings.historySplitTitle')}

+

{t('settings.historySplitDesc')}

+
+
+ + +
+
+ ) +} diff --git a/webui/src/features/settings/SettingsContainer.jsx b/webui/src/features/settings/SettingsContainer.jsx index 3b2da85..98a9c6a 100644 --- a/webui/src/features/settings/SettingsContainer.jsx +++ b/webui/src/features/settings/SettingsContainer.jsx @@ -5,6 +5,7 @@ import { useSettingsForm } from './useSettingsForm' import SecuritySection from './SecuritySection' import RuntimeSection from './RuntimeSection' import BehaviorSection from './BehaviorSection' +import HistorySplitSection from './HistorySplitSection' import CompatibilitySection from './CompatibilitySection' import AutoDeleteSection from './AutoDeleteSection' import ModelSection from './ModelSection' @@ -95,6 +96,8 @@ export default function SettingsContainer({ onRefresh, onMessage, authFetch, onF + + diff --git a/webui/src/features/settings/useSettingsForm.js b/webui/src/features/settings/useSettingsForm.js index e44c0bc..96aa1b5 100644 --- a/webui/src/features/settings/useSettingsForm.js +++ b/webui/src/features/settings/useSettingsForm.js @@ -17,6 +17,7 @@ const DEFAULT_FORM = { responses: { store_ttl_seconds: 900 }, embeddings: { provider: '' }, auto_delete: { mode: 'none' }, + history_split: { enabled: true, trigger_after_turns: 1 }, claude_mapping_text: '{\n "fast": "deepseek-chat",\n "slow": "deepseek-reasoner"\n}', model_aliases_text: '{}', } @@ -70,6 +71,10 @@ function fromServerForm(data) { auto_delete: { mode: normalizeAutoDeleteMode(data.auto_delete), }, + history_split: { + enabled: data.history_split?.enabled ?? true, + trigger_after_turns: Number(data.history_split?.trigger_after_turns || 1), + }, claude_mapping_text: JSON.stringify(data.claude_mapping || {}, null, 2), model_aliases_text: JSON.stringify(data.model_aliases || {}, null, 2), } @@ -90,6 +95,10 @@ function toServerPayload(form) { responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) }, embeddings: { provider: String(form.embeddings.provider || '').trim() }, auto_delete: { mode: normalizeAutoDeleteMode(form.auto_delete) }, + history_split: { + enabled: Boolean(form.history_split?.enabled ?? true), + trigger_after_turns: Number(form.history_split?.trigger_after_turns || 1), + }, } } diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index 7548a30..f37530c 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -379,6 +379,12 @@ "behaviorTitle": "Behavior", "responsesTTL": "Responses store TTL (seconds)", "embeddingsProvider": "Embeddings provider", + "historySplitTitle": "History Split", + "historySplitDesc": "Pack earlier turns into an attached HISTORY.txt so the model reads the file first and then continues from the latest user request.", + "historySplitEnabled": "Enable history split", + "historySplitEnabledDesc": "Enabled by default. Turning this off falls back to normal full-context requests.", + "historySplitTriggerAfterTurns": "Trigger threshold (user turns)", + "historySplitTriggerHelp": "Default is 1, which means history split starts from the second turn.", "compatibilityTitle": "Compatibility", "compatibilityDesc": "Compatibility controls that keep stream output closer to the wire format or safer for the web UI.", "stripReferenceMarkers": "Strip [reference:N] markers", diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index 9477c5f..d3ead83 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -379,6 +379,12 @@ "behaviorTitle": "行为设置", "responsesTTL": "Responses 缓存 TTL(秒)", "embeddingsProvider": "Embeddings Provider", + "historySplitTitle": "历史拆分", + "historySplitDesc": "将更早的对话整理成 HISTORY.txt 上传,让模型优先读取历史文件,再结合最新一轮继续回答。", + "historySplitEnabled": "启用历史拆分", + "historySplitEnabledDesc": "默认开启。关闭后会恢复为普通的完整上下文提交。", + "historySplitTriggerAfterTurns": "触发阈值(用户回合数)", + "historySplitTriggerHelp": "默认值为 1,表示从第二轮开始拆分历史。", "compatibilityTitle": "兼容性设置", "compatibilityDesc": "用于控制输出格式兼容性,避免把模型原始流里的标记直接暴露到前端。", "stripReferenceMarkers": "移除 [reference:N] 标记", From c291d333c4ed65492f71d04e2f1d11b1719c9bd4 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Wed, 22 Apr 2026 19:56:28 +0000 Subject: [PATCH 12/15] feat: extract and inject assistant reasoning content into history split prompts --- internal/adapter/openai/history_split.go | 77 +++++++++++++- internal/adapter/openai/history_split_test.go | 36 +++++-- internal/adapter/openai/message_normalize.go | 100 ++++++++++++++++-- .../adapter/openai/message_normalize_test.go | 28 +++++ internal/adapter/openai/tool_sieve_state.go | 16 --- .../adapter/openai/tool_sieve_xml_test.go | 43 ++++++++ .../js/helpers/stream-tool-sieve/state.js | 20 ---- tests/node/stream-tool-sieve.test.js | 20 ++++ 8 files changed, 280 insertions(+), 60 deletions(-) diff --git a/internal/adapter/openai/history_split.go b/internal/adapter/openai/history_split.go index b65e2e2..39c9d79 100644 --- a/internal/adapter/openai/history_split.go +++ b/internal/adapter/openai/history_split.go @@ -30,6 +30,7 @@ func (h *Handler) applyHistorySplit(ctx context.Context, a *auth.RequestAuth, st return stdReq, nil } + reasoningContent := extractHistorySplitReasoningContent(historyMessages) historyText := buildOpenAIHistoryTranscript(historyMessages) if strings.TrimSpace(historyText) == "" { return stdReq, errors.New("history split produced empty transcript") @@ -51,12 +52,12 @@ func (h *Handler) applyHistorySplit(ctx context.Context, a *auth.RequestAuth, st stdReq.Messages = promptMessages stdReq.RefFileIDs = prependUniqueRefFileID(stdReq.RefFileIDs, fileID) - stdReq.FinalPrompt, stdReq.ToolNames = buildHistorySplitPrompt(promptMessages, stdReq.ToolsRaw, stdReq.ToolChoice, stdReq.Thinking) + stdReq.FinalPrompt, stdReq.ToolNames = buildHistorySplitPrompt(promptMessages, reasoningContent, stdReq.ToolsRaw, stdReq.ToolChoice, stdReq.Thinking) return stdReq, nil } -func buildHistorySplitPrompt(messages []any, toolsRaw any, toolPolicy util.ToolChoicePolicy, thinkingEnabled bool) (string, []string) { - if len(messages) == 0 { +func buildHistorySplitPrompt(messages []any, reasoningContent string, toolsRaw any, toolPolicy util.ToolChoicePolicy, thinkingEnabled bool) (string, []string) { + if len(messages) == 0 && strings.TrimSpace(reasoningContent) == "" { return "", nil } instruction := historySplitPromptInstruction() @@ -65,7 +66,7 @@ func buildHistorySplitPrompt(messages []any, toolsRaw any, toolPolicy util.ToolC "role": "system", "content": instruction, }) - withInstruction = append(withInstruction, messages...) + withInstruction = append(withInstruction, injectHistorySplitReasoningMessage(messages, reasoningContent)...) return buildOpenAIFinalPromptWithPolicy(withInstruction, toolsRaw, "", toolPolicy, thinkingEnabled) } @@ -150,7 +151,7 @@ func buildOpenAIHistoryTranscript(messages []any) string { func buildOpenAIHistoryEntry(role string, msg map[string]any) string { switch role { case "assistant": - return strings.TrimSpace(buildAssistantContentForPrompt(msg)) + return strings.TrimSpace(buildAssistantHistoryContent(msg)) case "tool", "function": return strings.TrimSpace(buildToolHistoryContent(msg)) case "user": @@ -160,6 +161,10 @@ func buildOpenAIHistoryEntry(role string, msg map[string]any) string { } } +func buildAssistantHistoryContent(msg map[string]any) string { + return strings.TrimSpace(buildAssistantContentForPrompt(msg)) +} + func buildToolHistoryContent(msg map[string]any) string { content := strings.TrimSpace(normalizeOpenAIContentForPrompt(msg["content"])) parts := make([]string, 0, 2) @@ -183,6 +188,68 @@ func buildToolHistoryContent(msg map[string]any) string { } } +func extractHistorySplitReasoningContent(messages []any) string { + for i := len(messages) - 1; i >= 0; i-- { + msg, ok := messages[i].(map[string]any) + if !ok { + continue + } + role := strings.ToLower(strings.TrimSpace(asString(msg["role"]))) + if role != "assistant" { + continue + } + reasoning := strings.TrimSpace(normalizeOpenAIReasoningContentForPrompt(msg["reasoning_content"])) + if reasoning == "" { + reasoning = strings.TrimSpace(extractOpenAIReasoningContentFromMessage(msg["content"])) + } + if reasoning != "" { + return reasoning + } + } + return "" +} + +func injectHistorySplitReasoningMessage(messages []any, reasoningContent string) []any { + reasoningContent = strings.TrimSpace(reasoningContent) + if reasoningContent == "" { + return messages + } + reasoningMsg := map[string]any{ + "role": "assistant", + "content": "", + "reasoning_content": reasoningContent, + } + lastUserIndex := lastOpenAIUserMessageIndex(messages) + if lastUserIndex < 0 { + out := make([]any, 0, len(messages)+1) + out = append(out, reasoningMsg) + out = append(out, messages...) + return out + } + out := make([]any, 0, len(messages)+1) + for i, raw := range messages { + if i == lastUserIndex { + out = append(out, reasoningMsg) + } + out = append(out, raw) + } + return out +} + +func lastOpenAIUserMessageIndex(messages []any) int { + last := -1 + for i, raw := range messages { + msg, ok := raw.(map[string]any) + if !ok { + continue + } + if strings.ToLower(strings.TrimSpace(asString(msg["role"]))) == "user" { + last = i + } + } + return last +} + func roleLabelForHistory(role string) string { role = strings.ToLower(strings.TrimSpace(role)) switch role { diff --git a/internal/adapter/openai/history_split_test.go b/internal/adapter/openai/history_split_test.go index 46fa366..864c763 100644 --- a/internal/adapter/openai/history_split_test.go +++ b/internal/adapter/openai/history_split_test.go @@ -59,8 +59,11 @@ func TestBuildOpenAIHistoryTranscriptPreservesOrderAndToolHistory(t *testing.T) if !strings.Contains(transcript, "tool_call_id=call-1") { t.Fatalf("expected tool call id in transcript, got %s", transcript) } - if strings.Contains(transcript, "hidden reasoning") { - t.Fatalf("did not expect hidden reasoning in transcript, got %s", transcript) + if !strings.Contains(transcript, "[reasoning_content]") { + t.Fatalf("expected reasoning block in HISTORY.txt, got %s", transcript) + } + if !strings.Contains(transcript, "hidden reasoning") { + t.Fatalf("expected reasoning text in HISTORY.txt, got %s", transcript) } userIdx := strings.Index(transcript, "=== 1. USER ===") @@ -72,14 +75,24 @@ func TestBuildOpenAIHistoryTranscriptPreservesOrderAndToolHistory(t *testing.T) if userIdx >= assistantIdx || assistantIdx >= toolIdx { t.Fatalf("expected USER -> ASSISTANT -> TOOL order, got %s", transcript) } + if reasoningIdx := strings.Index(transcript, "[reasoning_content]"); reasoningIdx < 0 || reasoningIdx > strings.Index(transcript, "") { + t.Fatalf("expected reasoning block before tool calls, got %s", transcript) + } + reasoning := extractHistorySplitReasoningContent(historyMessages) + if reasoning != "hidden reasoning" { + t.Fatalf("expected latest assistant reasoning to be extracted, got %q", reasoning) + } - finalPrompt, _ := buildHistorySplitPrompt(promptMessages, nil, util.DefaultToolChoicePolicy(), false) + finalPrompt, _ := buildHistorySplitPrompt(promptMessages, reasoning, nil, util.DefaultToolChoicePolicy(), false) if !strings.Contains(finalPrompt, "latest user turn") { t.Fatalf("expected latest user turn in final prompt, got %s", finalPrompt) } if strings.Contains(finalPrompt, "first user turn") { t.Fatalf("expected earlier history to be removed from final prompt, got %s", finalPrompt) } + if !strings.Contains(finalPrompt, "[reasoning_content]") || !strings.Contains(finalPrompt, "hidden reasoning") { + t.Fatalf("expected latest assistant reasoning to be attached to prompt, got %s", finalPrompt) + } if !strings.Contains(finalPrompt, "HISTORY.txt") { t.Fatalf("expected history instruction in final prompt, got %s", finalPrompt) } @@ -118,8 +131,12 @@ func TestSplitOpenAIHistoryMessagesUsesLatestUserTurn(t *testing.T) { if len(promptMessages) == 0 || len(historyMessages) == 0 { t.Fatalf("expected both prompt and history messages, got prompt=%d history=%d", len(promptMessages), len(historyMessages)) } + reasoning := extractHistorySplitReasoningContent(historyMessages) + if reasoning != "" { + t.Fatalf("expected no reasoning in this fixture, got %q", reasoning) + } - promptText := buildOpenAIFinalPromptForSplitTest(promptMessages) + promptText, _ := buildHistorySplitPrompt(promptMessages, reasoning, nil, util.DefaultToolChoicePolicy(), false) if !strings.Contains(promptText, "latest user turn") { t.Fatalf("expected latest user turn in prompt, got %s", promptText) } @@ -136,11 +153,6 @@ func TestSplitOpenAIHistoryMessagesUsesLatestUserTurn(t *testing.T) { } } -func buildOpenAIFinalPromptForSplitTest(messages []any) string { - prompt, _ := buildHistorySplitPrompt(messages, nil, util.DefaultToolChoicePolicy(), false) - return prompt -} - func TestApplyHistorySplitSkipsFirstTurn(t *testing.T) { ds := &inlineUploadDSStub{} h := &Handler{ @@ -233,6 +245,9 @@ func TestChatCompletionsHistorySplitUploadsHistoryAndKeepsLatestPrompt(t *testin if strings.Contains(promptText, "first user turn") { t.Fatalf("expected historical turns removed from completion prompt, got %s", promptText) } + if !strings.Contains(promptText, "[reasoning_content]") || !strings.Contains(promptText, "hidden reasoning") { + t.Fatalf("expected latest assistant reasoning to be attached to completion prompt, got %s", promptText) + } if !strings.Contains(promptText, "HISTORY.txt") { t.Fatalf("expected history instruction in completion prompt, got %s", promptText) } @@ -283,6 +298,9 @@ func TestResponsesHistorySplitUploadsHistoryAndKeepsLatestPrompt(t *testing.T) { if strings.Contains(promptText, "first user turn") { t.Fatalf("expected historical turns removed from completion prompt, got %s", promptText) } + if !strings.Contains(promptText, "[reasoning_content]") || !strings.Contains(promptText, "hidden reasoning") { + t.Fatalf("expected latest assistant reasoning to be attached to completion prompt, got %s", promptText) + } } func TestChatCompletionsHistorySplitUploadFailureReturnsInternalServerError(t *testing.T) { diff --git a/internal/adapter/openai/message_normalize.go b/internal/adapter/openai/message_normalize.go index 94c67ad..906c377 100644 --- a/internal/adapter/openai/message_normalize.go +++ b/internal/adapter/openai/message_normalize.go @@ -6,6 +6,8 @@ import ( "ds2api/internal/prompt" ) +const assistantReasoningLabel = "reasoning_content" + func normalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]any { _ = traceID out := make([]map[string]any, 0, len(raw)) @@ -55,17 +57,95 @@ func normalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]an func buildAssistantContentForPrompt(msg map[string]any) string { content := strings.TrimSpace(normalizeOpenAIContentForPrompt(msg["content"])) - toolHistory := prompt.FormatToolCallsForPrompt(msg["tool_calls"]) - switch { - case content == "" && toolHistory == "": - return "" - case content == "": - return toolHistory - case toolHistory == "": - return content - default: - return content + "\n\n" + toolHistory + reasoning := strings.TrimSpace(normalizeOpenAIReasoningContentForPrompt(msg["reasoning_content"])) + if reasoning == "" { + reasoning = strings.TrimSpace(extractOpenAIReasoningContentFromMessage(msg["content"])) } + toolHistory := prompt.FormatToolCallsForPrompt(msg["tool_calls"]) + parts := make([]string, 0, 3) + if reasoning != "" { + parts = append(parts, formatPromptLabeledBlock(assistantReasoningLabel, reasoning)) + } + if content != "" { + parts = append(parts, content) + } + if toolHistory != "" { + parts = append(parts, toolHistory) + } + switch len(parts) { + case 0: + return "" + case 1: + return parts[0] + default: + return strings.Join(parts, "\n\n") + } +} + +func normalizeOpenAIReasoningContentForPrompt(v any) string { + switch x := v.(type) { + case string: + return x + case []any: + return strings.Join(extractOpenAIReasoningPartsFromItems(x), "\n") + case map[string]any: + return extractOpenAIReasoningTextFromItem(x) + default: + return "" + } +} + +func extractOpenAIReasoningContentFromMessage(v any) string { + switch x := v.(type) { + case []any: + return strings.Join(extractOpenAIReasoningPartsFromItems(x), "\n") + case map[string]any: + return extractOpenAIReasoningTextFromItem(x) + default: + return "" + } +} + +func extractOpenAIReasoningPartsFromItems(items []any) []string { + parts := make([]string, 0, len(items)) + for _, item := range items { + if text := extractOpenAIReasoningTextFromItemMap(item); text != "" { + parts = append(parts, text) + } + } + return parts +} + +func extractOpenAIReasoningTextFromItemMap(item any) string { + m, ok := item.(map[string]any) + if !ok { + return "" + } + return extractOpenAIReasoningTextFromItem(m) +} + +func extractOpenAIReasoningTextFromItem(m map[string]any) string { + if m == nil { + return "" + } + switch strings.ToLower(strings.TrimSpace(asString(m["type"]))) { + case "reasoning", "thinking": + for _, key := range []string{"text", "thinking", "content"} { + if text := strings.TrimSpace(asString(m[key])); text != "" { + return text + } + } + } + return "" +} + +func formatPromptLabeledBlock(label, text string) string { + label = strings.TrimSpace(label) + text = strings.TrimSpace(text) + if label == "" { + return text + } + return "[" + label + "]\n" + text + "\n[/" + label + "]" } func buildToolContentForPrompt(msg map[string]any) string { diff --git a/internal/adapter/openai/message_normalize_test.go b/internal/adapter/openai/message_normalize_test.go index 00b3ef4..564fea7 100644 --- a/internal/adapter/openai/message_normalize_test.go +++ b/internal/adapter/openai/message_normalize_test.go @@ -296,3 +296,31 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantArrayContentFallbackWhenTextE t.Fatalf("expected content fallback text preserved, got %q", content) } } + +func TestNormalizeOpenAIMessagesForPrompt_AssistantReasoningContentPreserved(t *testing.T) { + raw := []any{ + map[string]any{ + "role": "assistant", + "content": "visible answer", + "reasoning_content": "internal reasoning", + }, + } + + normalized := normalizeOpenAIMessagesForPrompt(raw, "") + if len(normalized) != 1 { + t.Fatalf("expected one normalized assistant message, got %#v", normalized) + } + content, _ := normalized[0]["content"].(string) + if !strings.Contains(content, "[reasoning_content]") { + t.Fatalf("expected labeled reasoning block in assistant content, got %q", content) + } + if !strings.Contains(content, "internal reasoning") { + t.Fatalf("expected reasoning text in assistant content, got %q", content) + } + if !strings.Contains(content, "visible answer") { + t.Fatalf("expected visible answer in assistant content, got %q", content) + } + if reasoningIdx := strings.Index(content, "[reasoning_content]"); reasoningIdx < 0 || reasoningIdx > strings.Index(content, "visible answer") { + t.Fatalf("expected reasoning block before visible answer, got %q", content) + } +} diff --git a/internal/adapter/openai/tool_sieve_state.go b/internal/adapter/openai/tool_sieve_state.go index 09de2a5..8128f8c 100644 --- a/internal/adapter/openai/tool_sieve_state.go +++ b/internal/adapter/openai/tool_sieve_state.go @@ -12,7 +12,6 @@ type toolStreamSieveState struct { codeFenceStack []int codeFencePendingTicks int codeFenceLineStart bool - recentTextTail string pendingToolRaw string pendingToolCalls []toolcall.ParsedToolCall disableDeltas bool @@ -36,9 +35,6 @@ type toolCallDelta struct { Arguments string } -// Keep in sync with JS TOOL_SIEVE_CONTEXT_TAIL_LIMIT. -const toolSieveContextTailLimit = 2048 - func (s *toolStreamSieveState) resetIncrementalToolState() { s.disableDeltas = false s.toolNameSent = false @@ -54,18 +50,6 @@ func (s *toolStreamSieveState) noteText(content string) { return } updateCodeFenceState(s, content) - s.recentTextTail = appendTail(s.recentTextTail, content, toolSieveContextTailLimit) -} - -func appendTail(prev, next string, max int) string { - if max <= 0 { - return "" - } - combined := prev + next - if len(combined) <= max { - return combined - } - return combined[len(combined)-max:] } func hasMeaningfulText(text string) bool { diff --git a/internal/adapter/openai/tool_sieve_xml_test.go b/internal/adapter/openai/tool_sieve_xml_test.go index 7fd123d..16827cc 100644 --- a/internal/adapter/openai/tool_sieve_xml_test.go +++ b/internal/adapter/openai/tool_sieve_xml_test.go @@ -42,6 +42,49 @@ func TestProcessToolSieveInterceptsXMLToolCallWithoutLeak(t *testing.T) { } } +func TestProcessToolSieveHandlesLongXMLToolCall(t *testing.T) { + var state toolStreamSieveState + const toolName = "write_to_file" + payload := strings.Repeat("x", 4096) + splitAt := len(payload) / 2 + chunks := []string{ + "\n \n " + toolName + "\n \n \n \n \n", + } + + var events []toolStreamEvent + for _, c := range chunks { + events = append(events, processToolSieveChunk(&state, c, []string{toolName})...) + } + events = append(events, flushToolSieve(&state, []string{toolName})...) + + var textContent strings.Builder + toolCalls := 0 + var gotPayload any + for _, evt := range events { + if evt.Content != "" { + textContent.WriteString(evt.Content) + } + if len(evt.ToolCalls) > 0 && gotPayload == nil { + gotPayload = evt.ToolCalls[0].Input["content"] + } + toolCalls += len(evt.ToolCalls) + } + + if toolCalls != 1 { + t.Fatalf("expected one long XML tool call, got %d events=%#v", toolCalls, events) + } + if textContent.Len() != 0 { + t.Fatalf("expected no leaked text for long XML tool call, got %q", textContent.String()) + } + got, _ := gotPayload.(string) + if got != payload { + t.Fatalf("expected long XML payload to survive intact, got len=%d want=%d", len(got), len(payload)) + } +} + func TestProcessToolSieveXMLWithLeadingText(t *testing.T) { var state toolStreamSieveState // Model outputs some prose then an XML tool call. diff --git a/internal/js/helpers/stream-tool-sieve/state.js b/internal/js/helpers/stream-tool-sieve/state.js index 9a5b1c3..447ecdf 100644 --- a/internal/js/helpers/stream-tool-sieve/state.js +++ b/internal/js/helpers/stream-tool-sieve/state.js @@ -1,14 +1,10 @@ 'use strict'; -// Keep in sync with Go toolSieveContextTailLimit. -const TOOL_SIEVE_CONTEXT_TAIL_LIMIT = 2048; - function createToolSieveState() { return { pending: '', capture: '', capturing: false, - recentTextTail: '', codeFenceStack: [], codeFencePendingTicks: 0, codeFenceLineStart: true, @@ -39,20 +35,6 @@ function noteText(state, text) { return; } updateCodeFenceState(state, text); - state.recentTextTail = appendTail(state.recentTextTail, text, TOOL_SIEVE_CONTEXT_TAIL_LIMIT); -} - -function appendTail(prev, next, max) { - const left = typeof prev === 'string' ? prev : ''; - const right = typeof next === 'string' ? next : ''; - if (!Number.isFinite(max) || max <= 0) { - return ''; - } - const combined = left + right; - if (combined.length <= max) { - return combined; - } - return combined.slice(combined.length - max); } function looksLikeToolExampleContext(text) { @@ -171,11 +153,9 @@ function toStringSafe(v) { } module.exports = { - TOOL_SIEVE_CONTEXT_TAIL_LIMIT, createToolSieveState, resetIncrementalToolState, noteText, - appendTail, looksLikeToolExampleContext, insideCodeFence, insideCodeFenceWithState, diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index 57c29f0..a5f29ac 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -98,6 +98,26 @@ test('sieve emits tool_calls when XML tag spans multiple chunks', () => { assert.equal(finalCalls[0].name, 'read_file'); }); +test('sieve keeps long XML tool calls buffered until the closing tag arrives', () => { + const longContent = 'x'.repeat(4096); + const splitAt = longContent.length / 2; + const events = runSieve( + [ + '\n \n write_to_file\n \n \n \n \n', + ], + ['write_to_file'], + ); + const leakedText = collectText(events); + const finalCalls = events.filter((evt) => evt.type === 'tool_calls').flatMap((evt) => evt.calls || []); + assert.equal(leakedText, ''); + assert.equal(finalCalls.length, 1); + assert.equal(finalCalls[0].name, 'write_to_file'); + assert.equal(finalCalls[0].input.content, longContent); +}); + test('sieve passes JSON tool_calls payload through as text (XML-only)', () => { const events = runSieve( ['{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}'], From 5cf56e7628bf6c0fba7c138d379a7b3fbcf29051 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Wed, 22 Apr 2026 20:10:06 +0000 Subject: [PATCH 13/15] fix: reset tool call state between separate tool blocks to ensure unique IDs across stream segments --- .../adapter/openai/chat_stream_runtime.go | 7 ++ .../adapter/openai/handler_toolcall_test.go | 48 ++++++++++++ .../openai/responses_stream_runtime_core.go | 4 +- .../openai/responses_stream_runtime_events.go | 5 +- .../responses_stream_runtime_toolcalls.go | 10 +++ .../adapter/openai/responses_stream_test.go | 78 +++++++++++++++++++ internal/js/chat-stream/index.js | 2 + internal/js/chat-stream/toolcall_policy.js | 10 +++ internal/js/chat-stream/vercel_stream_impl.js | 3 + tests/node/chat-stream.test.js | 11 +++ 10 files changed, 175 insertions(+), 3 deletions(-) diff --git a/internal/adapter/openai/chat_stream_runtime.go b/internal/adapter/openai/chat_stream_runtime.go index 176dca4..1d7fff6 100644 --- a/internal/adapter/openai/chat_stream_runtime.go +++ b/internal/adapter/openai/chat_stream_runtime.go @@ -122,6 +122,11 @@ func (s *chatStreamRuntime) sendFailedChunk(status int, message, code string) { s.sendDone() } +func (s *chatStreamRuntime) resetStreamToolCallState() { + s.streamToolCallIDs = map[int]string{} + s.streamToolNames = map[int]string{} +} + func (s *chatStreamRuntime) finalize(finishReason string) { finalThinking := s.thinking.String() finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers) @@ -166,6 +171,7 @@ func (s *chatStreamRuntime) finalize(finishReason string) { []map[string]any{openaifmt.BuildChatStreamDeltaChoice(0, tcDelta)}, nil, )) + s.resetStreamToolCallState() } if evt.Content == "" { continue @@ -309,6 +315,7 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD s.firstChunkSent = true } newChoices = append(newChoices, openaifmt.BuildChatStreamDeltaChoice(0, tcDelta)) + s.resetStreamToolCallState() continue } if evt.Content != "" { diff --git a/internal/adapter/openai/handler_toolcall_test.go b/internal/adapter/openai/handler_toolcall_test.go index d168fca..e0f11ba 100644 --- a/internal/adapter/openai/handler_toolcall_test.go +++ b/internal/adapter/openai/handler_toolcall_test.go @@ -213,3 +213,51 @@ func TestHandleStreamIncompleteCapturedToolJSONFlushesAsTextOnFinalize(t *testin t.Fatalf("expected incomplete capture to flush as plain text instead of stalling, got=%q", content.String()) } } + +func TestHandleStreamEmitsDistinctToolCallIDsAcrossSeparateToolBlocks(t *testing.T) { + h := &Handler{} + resp := makeSSEHTTPResponse( + `data: {"p":"response/content","v":"前置文本\n\n \n read_file\n {\"path\":\"README.MD\"}\n \n"}`, + `data: {"p":"response/content","v":"中间文本\n\n \n search\n {\"q\":\"golang\"}\n \n"}`, + `data: [DONE]`, + ) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + + h.handleStream(rec, req, resp, "cid-multi", "deepseek-chat", "prompt", false, false, []string{"read_file", "search"}, nil) + + frames, done := parseSSEDataFrames(t, rec.Body.String()) + if !done { + t.Fatalf("expected [DONE], body=%s", rec.Body.String()) + } + + ids := make([]string, 0, 2) + seen := make(map[string]struct{}) + for _, frame := range frames { + choices, _ := frame["choices"].([]any) + for _, item := range choices { + choice, _ := item.(map[string]any) + delta, _ := choice["delta"].(map[string]any) + toolCalls, _ := delta["tool_calls"].([]any) + for _, rawCall := range toolCalls { + call, _ := rawCall.(map[string]any) + id := asString(call["id"]) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + } + } + + if len(ids) != 2 { + t.Fatalf("expected two distinct tool call ids, got %#v body=%s", ids, rec.Body.String()) + } + if ids[0] == ids[1] { + t.Fatalf("expected distinct tool call ids across blocks, got %#v body=%s", ids, rec.Body.String()) + } +} diff --git a/internal/adapter/openai/responses_stream_runtime_core.go b/internal/adapter/openai/responses_stream_runtime_core.go index 45863dc..af7eb8e 100644 --- a/internal/adapter/openai/responses_stream_runtime_core.go +++ b/internal/adapter/openai/responses_stream_runtime_core.go @@ -128,7 +128,7 @@ func (s *responsesStreamRuntime) finalize() { finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers) if s.bufferToolContent { - s.processToolStreamEvents(flushToolSieve(&s.sieve, s.toolNames), true) + s.processToolStreamEvents(flushToolSieve(&s.sieve, s.toolNames), true, true) } textParsed := toolcall.ParseStandaloneToolCallsDetailed(finalText, s.toolNames) @@ -224,7 +224,7 @@ func (s *responsesStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Pa s.emitTextDelta(trimmed) continue } - s.processToolStreamEvents(processToolSieveChunk(&s.sieve, trimmed, s.toolNames), true) + s.processToolStreamEvents(processToolSieveChunk(&s.sieve, trimmed, s.toolNames), true, true) } return streamengine.ParsedDecision{ContentSeen: contentSeen} diff --git a/internal/adapter/openai/responses_stream_runtime_events.go b/internal/adapter/openai/responses_stream_runtime_events.go index 21e15d1..a010236 100644 --- a/internal/adapter/openai/responses_stream_runtime_events.go +++ b/internal/adapter/openai/responses_stream_runtime_events.go @@ -39,7 +39,7 @@ func (s *responsesStreamRuntime) sendDone() { } } -func (s *responsesStreamRuntime) processToolStreamEvents(events []toolStreamEvent, emitContent bool) { +func (s *responsesStreamRuntime) processToolStreamEvents(events []toolStreamEvent, emitContent bool, resetAfterToolCalls bool) { for _, evt := range events { if emitContent && evt.Content != "" { s.emitTextDelta(evt.Content) @@ -56,6 +56,9 @@ func (s *responsesStreamRuntime) processToolStreamEvents(events []toolStreamEven } if len(evt.ToolCalls) > 0 { s.emitFunctionCallDoneEvents(evt.ToolCalls) + if resetAfterToolCalls { + s.resetStreamToolCallState() + } } } } diff --git a/internal/adapter/openai/responses_stream_runtime_toolcalls.go b/internal/adapter/openai/responses_stream_runtime_toolcalls.go index 0e1188e..639a6d0 100644 --- a/internal/adapter/openai/responses_stream_runtime_toolcalls.go +++ b/internal/adapter/openai/responses_stream_runtime_toolcalls.go @@ -152,6 +152,16 @@ func (s *responsesStreamRuntime) ensureToolCallID(callIndex int) string { return id } +func (s *responsesStreamRuntime) resetStreamToolCallState() { + s.streamToolCallIDs = map[int]string{} + s.functionItemIDs = map[int]string{} + s.functionOutputIDs = map[int]int{} + s.functionArgs = map[int]string{} + s.functionDone = map[int]bool{} + s.functionAdded = map[int]bool{} + s.functionNames = map[int]string{} +} + func (s *responsesStreamRuntime) ensureFunctionOutputIndex(callIndex int) int { if idx, ok := s.functionOutputIDs[callIndex]; ok { return idx diff --git a/internal/adapter/openai/responses_stream_test.go b/internal/adapter/openai/responses_stream_test.go index f9f170e..078b03d 100644 --- a/internal/adapter/openai/responses_stream_test.go +++ b/internal/adapter/openai/responses_stream_test.go @@ -109,6 +109,57 @@ func TestHandleResponsesStreamOutputTextDeltaCarriesItemIndexes(t *testing.T) { } } +func TestHandleResponsesStreamEmitsDistinctToolCallIDsAcrossSeparateToolBlocks(t *testing.T) { + h := &Handler{} + req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + rec := httptest.NewRecorder() + + sseLine := func(v string) string { + b, _ := json.Marshal(map[string]any{ + "p": "response/content", + "v": v, + }) + return "data: " + string(b) + "\n" + } + + streamBody := sseLine("前置文本\n\n \n read_file\n {\"path\":\"README.MD\"}\n \n") + + sseLine("中间文本\n\n \n search\n {\"q\":\"golang\"}\n \n") + + "data: [DONE]\n" + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(streamBody)), + } + + h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, false, []string{"read_file", "search"}, util.DefaultToolChoicePolicy(), "") + + body := rec.Body.String() + doneEvents := extractSSEEventPayloads(body, "response.function_call_arguments.done") + if len(doneEvents) < 2 { + t.Fatalf("expected at least two function call done events, got %d body=%s", len(doneEvents), body) + } + + ids := make([]string, 0, 2) + seen := make(map[string]struct{}) + for _, payload := range doneEvents { + callID := asString(payload["call_id"]) + if callID == "" { + continue + } + if _, ok := seen[callID]; ok { + continue + } + seen[callID] = struct{}{} + ids = append(ids, callID) + } + + if len(ids) != 2 { + t.Fatalf("expected two distinct call ids, got %#v body=%s", ids, body) + } + if ids[0] == ids[1] { + t.Fatalf("expected distinct call ids across blocks, got %#v body=%s", ids, body) + } +} + func TestHandleResponsesStreamRequiredToolChoiceFailure(t *testing.T) { h := &Handler{} req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) @@ -325,3 +376,30 @@ func extractSSEEventPayload(body, targetEvent string) (map[string]any, bool) { } return nil, false } + +func extractSSEEventPayloads(body, targetEvent string) []map[string]any { + scanner := bufio.NewScanner(strings.NewReader(body)) + matched := false + out := make([]map[string]any, 0, 4) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "event: ") { + evt := strings.TrimSpace(strings.TrimPrefix(line, "event: ")) + matched = evt == targetEvent + continue + } + if !matched || !strings.HasPrefix(line, "data: ") { + continue + } + raw := strings.TrimSpace(strings.TrimPrefix(line, "data: ")) + if raw == "" || raw == "[DONE]" { + continue + } + var payload map[string]any + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + continue + } + out = append(out, payload) + } + return out +} diff --git a/internal/js/chat-stream/index.js b/internal/js/chat-stream/index.js index 6c0eff9..57740fd 100644 --- a/internal/js/chat-stream/index.js +++ b/internal/js/chat-stream/index.js @@ -18,6 +18,7 @@ const { normalizePreparedToolNames, boolDefaultTrue, filterIncrementalToolCallDeltasByAllowed, + resetStreamToolCallState, } = require('./toolcall_policy'); const { estimateTokens, @@ -115,6 +116,7 @@ module.exports.__test = { normalizePreparedToolNames, boolDefaultTrue, filterIncrementalToolCallDeltasByAllowed, + resetStreamToolCallState, estimateTokens, buildUsage, filterLeakedContentFilterParts, diff --git a/internal/js/chat-stream/toolcall_policy.js b/internal/js/chat-stream/toolcall_policy.js index 077c859..f3fa01e 100644 --- a/internal/js/chat-stream/toolcall_policy.js +++ b/internal/js/chat-stream/toolcall_policy.js @@ -98,6 +98,15 @@ function filterIncrementalToolCallDeltasByAllowed(deltas, allowedNames, seenName return out; } +function resetStreamToolCallState(idStore, seenNames) { + if (idStore instanceof Map) { + idStore.clear(); + } + if (seenNames instanceof Map) { + seenNames.clear(); + } +} + function ensureStreamToolCallID(idStore, index) { const key = Number.isInteger(index) ? index : 0; const existing = idStore.get(key); @@ -135,4 +144,5 @@ module.exports = { boolDefaultTrue, formatIncrementalToolCallDeltas, filterIncrementalToolCallDeltasByAllowed, + resetStreamToolCallState, }; diff --git a/internal/js/chat-stream/vercel_stream_impl.js b/internal/js/chat-stream/vercel_stream_impl.js index 38927c8..b28ecb0 100644 --- a/internal/js/chat-stream/vercel_stream_impl.js +++ b/internal/js/chat-stream/vercel_stream_impl.js @@ -18,6 +18,7 @@ const { formatIncrementalToolCallDeltas, filterIncrementalToolCallDeltasByAllowed, boolDefaultTrue, + resetStreamToolCallState, } = require('./toolcall_policy'); const { createChatCompletionEmitter } = require('./stream_emitter'); const { @@ -161,6 +162,7 @@ async function handleVercelStream(req, res, rawBody, payload) { if (evt.type === 'tool_calls' && Array.isArray(evt.calls) && evt.calls.length > 0) { toolCallsEmitted = true; sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs) }); + resetStreamToolCallState(streamToolCallIDs, streamToolNames); continue; } if (evt.text) { @@ -283,6 +285,7 @@ async function handleVercelStream(req, res, rawBody, payload) { if (evt.type === 'tool_calls') { toolCallsEmitted = true; sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs) }); + resetStreamToolCallState(streamToolCallIDs, streamToolNames); continue; } if (evt.text) { diff --git a/tests/node/chat-stream.test.js b/tests/node/chat-stream.test.js index 6bc13a6..4f78374 100644 --- a/tests/node/chat-stream.test.js +++ b/tests/node/chat-stream.test.js @@ -17,6 +17,7 @@ const { normalizePreparedToolNames, boolDefaultTrue, filterIncrementalToolCallDeltasByAllowed, + resetStreamToolCallState, buildUsage, estimateTokens, shouldSkipPath, @@ -107,6 +108,16 @@ test('incremental and final tool formatting share stable id via idStore', () => assert.equal(incremental[0].id, finalCalls[0].id); }); +test('resetStreamToolCallState gives each completed block a fresh id', () => { + const idStore = new Map(); + const first = formatIncrementalToolCallDeltas([{ index: 0, name: 'read_file' }], idStore); + resetStreamToolCallState(idStore); + const second = formatIncrementalToolCallDeltas([{ index: 0, name: 'search' }], idStore); + assert.equal(first.length, 1); + assert.equal(second.length, 1); + assert.notEqual(first[0].id, second[0].id); +}); + test('formatIncrementalToolCallDeltas drops empty deltas (Go parity)', () => { const idStore = new Map(); const formatted = formatIncrementalToolCallDeltas([{ index: 0 }], idStore); From e8d1aee7ad9558d94bc6b0931914b7c65bad228b Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Wed, 22 Apr 2026 20:23:32 +0000 Subject: [PATCH 14/15] chore: update gitignore and documentation files --- .gitignore | 1 + API.md | 5 +++++ docs/DEPLOY.md | 12 +++++++++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 622de74..c7c3919 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,4 @@ CLAUDE.local.md # Chat history data/ .codex +.roomodes diff --git a/API.md b/API.md index 0793d51..c86876f 100644 --- a/API.md +++ b/API.md @@ -761,6 +761,11 @@ data: {"type":"message_stop"} 导出完整配置,返回 `config`、`json`、`base64` 三种格式。 +响应示例: + + +> 注:`_vercel_sync_hash` 和 `_vercel_sync_time` 为内部同步元数据字段,用于 Vercel 配置漂移检测。 + ### `POST /admin/keys` ```json diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index cf2b67c..f0e0068 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -258,12 +258,22 @@ VERCEL_TEAM_ID=team_xxxxxxxxxxxx # 个人账号可留空 | `DS2API_GLOBAL_MAX_INFLIGHT` | 全局并发上限 | `recommended_concurrency` | | `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` | +| `DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS` | 流式 lease TTL | 默认与 `responses.store_ttl_seconds` 同步,若未设置则为 `900` | | `VERCEL_TOKEN` | Vercel 同步 token | — | | `VERCEL_PROJECT_ID` | Vercel 项目 ID | — | | `VERCEL_TEAM_ID` | Vercel 团队 ID | — | | `DS2API_VERCEL_PROTECTION_BYPASS` | 部署保护绕过密钥(内部 Node→Go 调用) | — | +### 3.3 运行时行为配置(通过 Admin API 设置) + +部分运行时行为无法通过环境变量直接配置,需要在部署后通过 Admin API 设置,例如: + +- **自动删除会话模式** (`auto_delete.mode`):支持 `none` / `single` / `all`,默认为 `none`。可通过 `PUT /admin/settings` 更新。 +- **每账号并发上限** (`account_max_inflight`):环境变量已支持,但也可通过 Admin API 热更新。 +- **全局并发上限** (`global_max_inflight`):同上。 + +详细说明参见 [API.md](../API.md#admin-接口) 中 `/admin/settings` 部分。 + ### 3.3 Vercel 架构说明 ```text From b6fba47bcf193fea6acbd06bd7eb1748e3138b59 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Wed, 22 Apr 2026 20:53:35 +0000 Subject: [PATCH 15/15] feat: prepend strong instruction override to history prompt to ensure context adherence --- internal/adapter/openai/history_split.go | 17 +++++++++++++---- internal/adapter/openai/history_split_test.go | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/internal/adapter/openai/history_split.go b/internal/adapter/openai/history_split.go index 39c9d79..4364728 100644 --- a/internal/adapter/openai/history_split.go +++ b/internal/adapter/openai/history_split.go @@ -60,18 +60,27 @@ func buildHistorySplitPrompt(messages []any, reasoningContent string, toolsRaw a if len(messages) == 0 && strings.TrimSpace(reasoningContent) == "" { return "", nil } - instruction := historySplitPromptInstruction() + instruction := historySplitPromptInstruction(thinkingEnabled) withInstruction := make([]any, 0, len(messages)+1) withInstruction = append(withInstruction, map[string]any{ "role": "system", "content": instruction, }) withInstruction = append(withInstruction, injectHistorySplitReasoningMessage(messages, reasoningContent)...) - return buildOpenAIFinalPromptWithPolicy(withInstruction, toolsRaw, "", toolPolicy, thinkingEnabled) + return buildOpenAIFinalPromptWithPolicy(withInstruction, toolsRaw, "", toolPolicy, false) } -func historySplitPromptInstruction() string { - return "An attached HISTORY.txt file contains prior conversation history and tool progress. Read it first, then answer the latest user request using that history as context." +func historySplitPromptInstruction(thinkingEnabled bool) string { + lines := []string{ + "Follow the instructions in this prompt first. If earlier conversation instructions conflict with this prompt, this prompt wins.", + "An attached HISTORY.txt file contains prior conversation history and tool progress; read it first, then answer the latest user request using that history as context.", + "Continue the conversation from the full prior context and the latest tool results.", + "Treat earlier messages as binding context; answer the user's current request as a continuation, not a restart.", + } + if thinkingEnabled { + lines = append(lines, "Keep reasoning internal. Do not leave the final user-facing answer only in reasoning; always provide the answer in visible assistant content.") + } + return strings.Join(lines, "\n") } func splitOpenAIHistoryMessages(messages []any, triggerAfterTurns int) ([]any, []any) { diff --git a/internal/adapter/openai/history_split_test.go b/internal/adapter/openai/history_split_test.go index 864c763..9ec9025 100644 --- a/internal/adapter/openai/history_split_test.go +++ b/internal/adapter/openai/history_split_test.go @@ -96,6 +96,12 @@ func TestBuildOpenAIHistoryTranscriptPreservesOrderAndToolHistory(t *testing.T) if !strings.Contains(finalPrompt, "HISTORY.txt") { t.Fatalf("expected history instruction in final prompt, got %s", finalPrompt) } + if !strings.Contains(finalPrompt, "Follow the instructions in this prompt first") { + t.Fatalf("expected stronger prompt override in final prompt, got %s", finalPrompt) + } + if strings.Index(finalPrompt, "Follow the instructions in this prompt first") > strings.Index(finalPrompt, "Continue the conversation") { + t.Fatalf("expected history split instruction before continuity instructions, got %s", finalPrompt) + } } func TestSplitOpenAIHistoryMessagesUsesLatestUserTurn(t *testing.T) { @@ -251,6 +257,12 @@ func TestChatCompletionsHistorySplitUploadsHistoryAndKeepsLatestPrompt(t *testin if !strings.Contains(promptText, "HISTORY.txt") { t.Fatalf("expected history instruction in completion prompt, got %s", promptText) } + if !strings.Contains(promptText, "Follow the instructions in this prompt first") { + t.Fatalf("expected stronger prompt override in completion prompt, got %s", promptText) + } + if strings.Index(promptText, "Follow the instructions in this prompt first") > strings.Index(promptText, "Continue the conversation") { + t.Fatalf("expected history split instruction before continuity instructions, got %s", promptText) + } refIDs, _ := ds.completionReq["ref_file_ids"].([]any) if len(refIDs) == 0 || refIDs[0] != "file-inline-1" { t.Fatalf("expected uploaded history file to be first ref_file_id, got %#v", ds.completionReq["ref_file_ids"]) @@ -301,6 +313,12 @@ func TestResponsesHistorySplitUploadsHistoryAndKeepsLatestPrompt(t *testing.T) { if !strings.Contains(promptText, "[reasoning_content]") || !strings.Contains(promptText, "hidden reasoning") { t.Fatalf("expected latest assistant reasoning to be attached to completion prompt, got %s", promptText) } + if !strings.Contains(promptText, "Follow the instructions in this prompt first") { + t.Fatalf("expected stronger prompt override in completion prompt, got %s", promptText) + } + if strings.Index(promptText, "Follow the instructions in this prompt first") > strings.Index(promptText, "Continue the conversation") { + t.Fatalf("expected history split instruction before continuity instructions, got %s", promptText) + } } func TestChatCompletionsHistorySplitUploadFailureReturnsInternalServerError(t *testing.T) {