Compare commits

...

30 Commits

Author SHA1 Message Date
CJACK.
b1f8d6192f Merge pull request #105 from CJackHwang/codex/fix-issues-found-in-review
Merge pull request #104 from CJackHwang/codex/revert-to-commit-efb484b

Restore tool-call parsing and repair logic; remove accidental split files
2026-03-20 02:38:35 +08:00
CJACK.
acfb3b225d Split toolcall input parsing to satisfy line gate 2026-03-20 02:37:23 +08:00
CJACK.
99a6164000 Fix path corruption when parsing tool call JSON strings 2026-03-20 02:31:37 +08:00
CJACK.
e49d9d33e2 Merge pull request #104 from CJackHwang/codex/revert-to-commit-efb484b
Restore tool-call parsing and repair logic; remove accidental split files
2026-03-20 02:17:52 +08:00
CJACK.
184a3d1e4e Sync Node tool-call parsing with aggressive fenced/mixed policy 2026-03-20 02:16:37 +08:00
CJACK.
c4ec14f49a Fix refactor line gate for toolcalls_parse 2026-03-20 02:12:34 +08:00
CJACK.
fb5fc0e885 Default to aggressive tool-call interception in mixed/fenced text 2026-03-20 02:03:46 +08:00
CJACK.
20b603666d Allow standalone parser to detect mixed prose tool JSON 2026-03-20 02:03:32 +08:00
CJACK.
4d549b7102 Revert "Merge branch 'dev' into codex/fix-issues-found-in-review"
This reverts commit 33b0d1d144, reversing
changes made to efb484ba4f.
2026-03-20 01:38:11 +08:00
CJACK.
33b0d1d144 Merge branch 'dev' into codex/fix-issues-found-in-review 2026-03-20 01:23:00 +08:00
CJACK.
efb484ba4f Merge pull request #103 from CJackHwang/codex/fix-threshold-issue-and-audit-pr
fix: unblock PR #101 line gate and improve PoW/token retry handling
2026-03-20 01:16:46 +08:00
CJACK.
145501d4a5 fix(tool-sieve): allow mixed prose + tool json interception 2026-03-20 01:15:32 +08:00
CJACK.
2d5103997b fix(tool-sieve): keep mixed prose tool json in strict text mode 2026-03-20 01:15:15 +08:00
CJACK.
52e7e7aae8 fix: unblock line gate and harden pow token recovery 2026-03-20 00:50:05 +08:00
CJACK.
5b5a4000d7 Merge pull request #99 from CJackHwang/codex/refactor-toolcalls_parse.go-for-line-limits
Codex-generated pull request
2026-03-19 21:06:45 +08:00
CJACK.
2bbf603148 fix: address PR #97 review findings 2026-03-18 00:52:24 +08:00
CJACK.
d14b8a0664 Stabilize tool-call parsing and pass refactor gate 2026-03-18 00:45:28 +08:00
CJACK.
f16e0b579e Merge pull request #92 from valkryhx/main
fix(toolcall): fix deepseek function calling bug and add json repair
2026-03-18 00:15:47 +08:00
huangxun
cf569f4749 docs: add testing documentation for tool call debugging
- Add targeted test commands to TESTING.md for debugging tool call issues
- Add quick test commands reference in README.md
- Document specific test cases for DeepSeek tool call parsing
2026-03-17 16:41:16 +08:00
huangxun
c9c59f2490 refactor(toolcall): enhance tool call extraction with multiple keywords and safety limits
- Add support for multiple keywords: tool_calls, function.name:, [tool_call_history]
- Add OOM protection with search limits in extractToolCallObjects
- Add max scan length limit in extractJSONObject to prevent OOM on unclosed objects
- Update tool_sieve to handle more tool call patterns
- Add loose JSON repair in parseToolCallPayload for better error recovery

This improves DeepSeek tool call parsing robustness.
2026-03-17 16:28:27 +08:00
huangxun
16216cc2ca fix(toolcalls): support nested objects in missing array brackets repair
- Upgrade missingArrayBracketsPattern regex to support single-level nested {} objects
- This fixes DeepSeek's list hallucination where tool call JSON objects contain nested fields like {"input": {"q": "value"}}
- Add comprehensive test cases covering 2-5 nested objects, mixed nested/primitive fields, and real DeepSeek 8-queen output patterns
- Add RepairLooseJSON function to repair unquoted keys and missing array brackets

Fixes: DeepSeek tool call parsing with nested JSON objects
2026-03-17 16:24:16 +08:00
CJACK.
de50fd3954 Merge pull request #96 from CJackHwang/codex/update-ci-line-count-limits-cihke3
ci: ignore test files in line gate and raise frontend limit to 500
2026-03-16 23:16:22 +08:00
CJACK.
7648d5f192 ci: keep entry line cap precedence over frontend cap 2026-03-16 23:06:58 +08:00
CJACK.
d35e5eab25 ci: ignore tests in line gate and raise frontend limit 2026-03-16 22:58:13 +08:00
CJACK.
90610a52ce Merge pull request #93 from latticeon/feature/session-management
feat: 添加会话管理功能
2026-03-16 22:12:00 +08:00
latticeon
f6296d506f fix: 修改批量删除会话方式
- 从逐条单个删除改为官方的批量删除接口
- 单个删除函数保留备用
2026-03-16 16:23:39 +08:00
latticeon
dfea092583 fix: 更新测试 mock 结构体以实现新增的接口方法
会话管理功能新增接口方法后,同步更新测试 mock 结构体:
- mockOpenAIConfig: 添加 AutoDeleteSessions() 方法
- streamStatusDSStub: 添加 DeleteAllSessionsForToken() 方法
- testingDSMock: 添加 DeleteAllSessionsForToken() 和 GetSessionCountForToken() 方法

同时修复 client_session_delete.go 中 fmt.Errorf 使用非常量格式字符串的编译错误,改用 errors.New()
2026-03-16 11:58:07 +08:00
latticeon
af7dc134bb fix: 修复会话管理相关问题并拆分文件
1. 修复无限循环问题
   - DeleteAllSessions/DeleteAllSessionsForToken 添加无进度检测
   - 连续 3 轮删除失败则退出循环
   - DeleteAllSessionsForToken 添加 cursor 推进逻辑

2. 修复字段语义不准确
   - TotalCount 重命名为 FirstPageCount
   - 明确该值仅统计第一页,多页账户需关注 HasMore

3. 修复 defer 执行顺序问题
   - 合并两个 defer,确保先删除会话再释放账号
   - 使用同步删除避免并发截断风险

4. 文件拆分
   - 新建 client_session_delete.go 处理会话删除
   - client_session.go 专注于会话查询
2026-03-16 01:44:21 +08:00
latticeon
2657d37f76 添加会话数量显示与清除功能
添加会话清除功能,增强安全性,避免账号被盗等情况泄露源代码
账号列表点击测试后显示账号的会话数量
设置页添加自动清除开关,每次调用后清除被调用账号的所有会话
2026-03-16 00:50:31 +08:00
huangxun
7318d1f4a8 fix(toolcall): fix deepseek function calling bug and add json repair
- Fix: Expand stream sieve keywords to support function.name: and [TOOL_CALL_HISTORY]

- Fix: Add repairInvalidJSONBackslashes to handle unescaped backslashes in Windows paths

- Sync: Update JS stream sieve to match Go implementation

- Test: Add unit tests for backslash repair and deepseek format parsing

- Tool: Move repair json test tool to tests/repair_json_tool.go
2026-03-13 13:47:40 +08:00
55 changed files with 2641 additions and 1167 deletions

View File

@@ -476,6 +476,23 @@ go run ./cmd/ds2api-tests \
npm ci --prefix webui && npm run build --prefix webui npm ci --prefix webui && npm run build --prefix webui
``` ```
## 测试
详细测试指南请参阅 [TESTING.md](TESTING.md)。
### 快速测试命令
```bash
# 运行所有单元测试
go test ./...
# 运行 tool calls 相关测试(调试工具调用问题)
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/
# 运行端到端测试
./tests/scripts/run-live.sh
```
## Release 自动构建GitHub Actions ## Release 自动构建GitHub Actions
工作流文件:`.github/workflows/release-artifacts.yml` 工作流文件:`.github/workflows/release-artifacts.yml`

View File

@@ -173,6 +173,50 @@ rg "<trace_id>" artifacts/testsuite/<run_id>/server.log
go test ./... go test ./...
``` ```
### 运行特定模块的单元测试
```bash
# 运行 tool calls 相关测试(推荐用于调试 tool call 解析问题)
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/
# 运行单个测试用例
go test -v -run TestParseToolCallsWithDeepSeekHallucination ./internal/util/
# 运行 format 相关测试
go test -v ./internal/format/...
# 运行 adapter 相关测试
go test -v ./internal/adapter/openai/...
```
### 调试 Tool Call 问题 | Debugging Tool Call Issues
当遇到 DeepSeek 工具调用解析问题时,可以使用以下方法:
```bash
# 1. 运行 tool calls 相关的所有测试
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/
# 2. 查看测试输出中的详细调试信息
go test -v -run TestParseToolCallsWithDeepSeekHallucination ./internal/util/ 2>&1
# 3. 检查具体测试用例的修复效果
# 测试用例位于 internal/util/toolcalls_test.go包含
# - TestParseToolCallsWithDeepSeekHallucination: DeepSeek 典型幻觉输出
# - TestRepairLooseJSONWithNestedObjects: 嵌套对象的方括号修复
# - TestParseToolCallsWithMixedWindowsPaths: Windows 路径处理
```
### 运行 Node.js 测试
```bash
# 运行 Node 测试
node --test tests/node/stream-tool-sieve.test.js
# 或使用脚本
./tests/scripts/run-unit-node.sh
```
### 跑端到端测试(跳过 preflight ### 跑端到端测试(跳过 preflight
```bash ```bash

View File

@@ -358,7 +358,7 @@ func TestHandleClaudeStreamRealtimeToolSafetyAcrossStructuredFormats(t *testing.
} }
} }
func TestHandleClaudeStreamRealtimeDoesNotStopOnUnclosedFencedToolExample(t *testing.T) { func TestHandleClaudeStreamRealtimePromotesUnclosedFencedToolExample(t *testing.T) {
h := &Handler{} h := &Handler{}
resp := makeClaudeSSEHTTPResponse( resp := makeClaudeSSEHTTPResponse(
"data: {\"p\":\"response/content\",\"v\":\"Here is an example:\\n```json\\n{\\\"tool_calls\\\":[{\\\"name\\\":\\\"Bash\\\",\\\"input\\\":{\\\"command\\\":\\\"pwd\\\"}}]}\"}", "data: {\"p\":\"response/content\",\"v\":\"Here is an example:\\n```json\\n{\\\"tool_calls\\\":[{\\\"name\\\":\\\"Bash\\\",\\\"input\\\":{\\\"command\\\":\\\"pwd\\\"}}]}\"}",
@@ -371,22 +371,27 @@ func TestHandleClaudeStreamRealtimeDoesNotStopOnUnclosedFencedToolExample(t *tes
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "show example only"}}, false, false, []string{"Bash"}) h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "show example only"}}, false, false, []string{"Bash"})
frames := parseClaudeFrames(t, rec.Body.String()) frames := parseClaudeFrames(t, rec.Body.String())
foundToolUse := false
for _, f := range findClaudeFrames(frames, "content_block_start") { for _, f := range findClaudeFrames(frames, "content_block_start") {
contentBlock, _ := f.Payload["content_block"].(map[string]any) contentBlock, _ := f.Payload["content_block"].(map[string]any)
if contentBlock["type"] == "tool_use" { if contentBlock["type"] == "tool_use" {
t.Fatalf("unexpected tool_use for fenced example, body=%s", rec.Body.String()) foundToolUse = true
}
}
foundEndTurn := false
for _, f := range findClaudeFrames(frames, "message_delta") {
delta, _ := f.Payload["delta"].(map[string]any)
if delta["stop_reason"] == "end_turn" {
foundEndTurn = true
break break
} }
} }
if !foundEndTurn { if !foundToolUse {
t.Fatalf("expected stop_reason=end_turn, body=%s", rec.Body.String()) t.Fatalf("expected tool_use for fenced example, body=%s", rec.Body.String())
}
foundToolStop := false
for _, f := range findClaudeFrames(frames, "message_delta") {
delta, _ := f.Payload["delta"].(map[string]any)
if delta["stop_reason"] == "tool_use" {
foundToolStop = true
break
}
}
if !foundToolStop {
t.Fatalf("expected stop_reason=tool_use, body=%s", rec.Body.String())
} }
} }

View File

@@ -98,11 +98,11 @@ func (s *chatStreamRuntime) sendDone() {
func (s *chatStreamRuntime) finalize(finishReason string) { func (s *chatStreamRuntime) finalize(finishReason string) {
finalThinking := s.thinking.String() finalThinking := s.thinking.String()
finalText := s.text.String() finalText := s.text.String()
detected := util.ParseStandaloneToolCalls(finalText, s.toolNames) detected := util.ParseStandaloneToolCallsDetailed(finalText, s.toolNames)
if len(detected) > 0 && !s.toolCallsDoneEmitted { if len(detected.Calls) > 0 && !s.toolCallsDoneEmitted {
finishReason = "tool_calls" finishReason = "tool_calls"
delta := map[string]any{ delta := map[string]any{
"tool_calls": formatFinalStreamToolCallsWithStableIDs(detected, s.streamToolCallIDs), "tool_calls": formatFinalStreamToolCallsWithStableIDs(detected.Calls, s.streamToolCallIDs),
} }
if !s.firstChunkSent { if !s.firstChunkSent {
delta["role"] = "assistant" delta["role"] = "assistant"
@@ -158,7 +158,7 @@ func (s *chatStreamRuntime) finalize(finishReason string) {
} }
} }
if len(detected) > 0 || s.toolCallsEmitted { if len(detected.Calls) > 0 || s.toolCallsEmitted {
finishReason = "tool_calls" finishReason = "tool_calls"
} }
s.sendChunk(openaifmt.BuildChatStreamChunk( s.sendChunk(openaifmt.BuildChatStreamChunk(

View File

@@ -19,6 +19,7 @@ type DeepSeekCaller interface {
CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error) CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error)
DeleteAllSessionsForToken(ctx context.Context, token string) error
} }
type ConfigReader interface { type ConfigReader interface {
@@ -28,6 +29,7 @@ type ConfigReader interface {
ToolcallEarlyEmitConfidence() string ToolcallEarlyEmitConfidence() string
ResponsesStoreTTLSeconds() int ResponsesStoreTTLSeconds() int
EmbeddingsProvider() string EmbeddingsProvider() string
AutoDeleteSessions() bool
} }
var _ AuthResolver = (*auth.Resolver)(nil) var _ AuthResolver = (*auth.Resolver)(nil)

View File

@@ -19,6 +19,7 @@ func (m mockOpenAIConfig) ToolcallMode() string { return m.toolMo
func (m mockOpenAIConfig) ToolcallEarlyEmitConfidence() string { return m.earlyEmit } func (m mockOpenAIConfig) ToolcallEarlyEmitConfidence() string { return m.earlyEmit }
func (m mockOpenAIConfig) ResponsesStoreTTLSeconds() int { return m.responsesTTL } func (m mockOpenAIConfig) ResponsesStoreTTLSeconds() int { return m.responsesTTL }
func (m mockOpenAIConfig) EmbeddingsProvider() string { return m.embedProv } func (m mockOpenAIConfig) EmbeddingsProvider() string { return m.embedProv }
func (m mockOpenAIConfig) AutoDeleteSessions() bool { return false }
func TestNormalizeOpenAIChatRequestWithConfigInterface(t *testing.T) { func TestNormalizeOpenAIChatRequestWithConfigInterface(t *testing.T) {
cfg := mockOpenAIConfig{ cfg := mockOpenAIConfig{

View File

@@ -35,7 +35,25 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
writeOpenAIError(w, status, detail) writeOpenAIError(w, status, detail)
return return
} }
defer h.Auth.Release(a) defer func() {
// 自动删除会话(同步)
// 必须在 Release 之前同步删除,否则:
// 1. 异步删除时账号已被 Release
// 2. 新请求可能获取到同一账号并开始使用
// 3. 异步删除仍在进行,会截断新请求正在使用的会话
if h.Store.AutoDeleteSessions() && a.DeepSeekToken != "" {
deleteCtx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
err := h.DS.DeleteAllSessionsForToken(deleteCtx, a.DeepSeekToken)
if err != nil {
config.Logger.Warn("[auto_delete_sessions] failed", "account", a.AccountID, "error", err)
} else {
config.Logger.Debug("[auto_delete_sessions] success", "account", a.AccountID)
}
}
h.Auth.Release(a)
}()
r = r.WithContext(auth.WithAuth(r.Context(), a)) r = r.WithContext(auth.WithAuth(r.Context(), a))
var req map[string]any var req map[string]any

View File

@@ -53,7 +53,7 @@ func injectToolPrompt(messages []map[string]any, tools []any, policy util.ToolCh
if len(toolSchemas) == 0 { if len(toolSchemas) == 0 {
return messages, names return messages, names
} }
toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\nWhen you need to use tools, output ONLY this JSON format (no other text):\n{\"tool_calls\": [{\"name\": \"tool_name\", \"input\": {\"param\": \"value\"}}]}\n\nHistory markers in conversation:\n- [TOOL_CALL_HISTORY]...[/TOOL_CALL_HISTORY] means a tool call you already made earlier.\n- [TOOL_RESULT_HISTORY]...[/TOOL_RESULT_HISTORY] means the runtime returned a tool result (not user input).\n\nIMPORTANT:\n1) If calling tools, output ONLY the JSON. The response must start with { and end with }.\n2) After receiving a tool result, you MUST use it to produce the final answer.\n3) Only call another tool when the previous result is missing required data or returned an error.\n4) Do not repeat a tool call that is already satisfied by an existing [TOOL_RESULT_HISTORY] block." toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\nWhen you need to use tools, output ONLY a JSON code block like this:\n```json\n{\"tool_calls\": [{\"name\": \"tool_name\", \"input\": {\"param\": \"value\"}}]}\n```\n\n【EXAMPLE】\nUser: Please check the weather in Beijing and Shanghai, and update my todo list.\nAssistant:\n```json\n{\"tool_calls\": [\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Beijing\"}},\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Shanghai\"}},\n {\"name\": \"update_todo\", \"input\": {\"todos\": [{\"content\": \"Buy milk\"}, {\"content\": \"Write report\"}]}}\n]}\n```\n\nHistory markers in conversation:\n- [TOOL_CALL_HISTORY]...[/TOOL_CALL_HISTORY] means a tool call you already made earlier.\n- [TOOL_RESULT_HISTORY]...[/TOOL_RESULT_HISTORY] means the runtime returned a tool result (not user input).\n\nIMPORTANT:\n1) If calling tools, output ONLY the JSON code block. The response must start with ```json and end with ```.\n2) After receiving a tool result, you MUST use it to produce the final answer.\n3) Only call another tool when the previous result is missing required data or returned an error.\n4) Do not repeat a tool call that is already satisfied by an existing [TOOL_RESULT_HISTORY] block.\n5) JSON SYNTAX STRICTLY REQUIRED: All property names MUST be enclosed in double quotes (e.g., \"name\", not name).\n6) ARRAY FORMAT: If providing a list of items, you MUST enclose them in square brackets `[]` (e.g., \"todos\": [{\"item\": \"a\"}, {\"item\": \"b\"}]). DO NOT output comma-separated objects without brackets."
if policy.Mode == util.ToolChoiceRequired { if policy.Mode == util.ToolChoiceRequired {
toolPrompt += "\n5) For this response, you MUST call at least one tool from the allowed list." toolPrompt += "\n5) For this response, you MUST call at least one tool from the allowed list."
} }

View File

@@ -211,7 +211,7 @@ func TestHandleNonStreamUnknownToolNotIntercepted(t *testing.T) {
} }
} }
func TestHandleNonStreamEmbeddedToolCallExampleRemainsText(t *testing.T) { func TestHandleNonStreamEmbeddedToolCallExamplePromotesToolCall(t *testing.T) {
h := &Handler{} h := &Handler{}
resp := makeSSEHTTPResponse( resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"下面是示例:"}`, `data: {"p":"response/content","v":"下面是示例:"}`,
@@ -229,20 +229,21 @@ func TestHandleNonStreamEmbeddedToolCallExampleRemainsText(t *testing.T) {
out := decodeJSONBody(t, rec.Body.String()) out := decodeJSONBody(t, rec.Body.String())
choices, _ := out["choices"].([]any) choices, _ := out["choices"].([]any)
choice, _ := choices[0].(map[string]any) choice, _ := choices[0].(map[string]any)
if choice["finish_reason"] != "stop" { if choice["finish_reason"] != "tool_calls" {
t.Fatalf("expected finish_reason=stop, got %#v", choice["finish_reason"]) t.Fatalf("expected finish_reason=tool_calls, got %#v", choice["finish_reason"])
} }
msg, _ := choice["message"].(map[string]any) msg, _ := choice["message"].(map[string]any)
if _, ok := msg["tool_calls"]; ok { toolCalls, _ := msg["tool_calls"].([]any)
t.Fatalf("did not expect tool_calls field for embedded example: %#v", msg["tool_calls"]) if len(toolCalls) != 1 {
t.Fatalf("expected one tool_call field for embedded example: %#v", msg["tool_calls"])
} }
content, _ := msg["content"].(string) content, _ := msg["content"].(string)
if !strings.Contains(content, "下面是示例:") || !strings.Contains(content, "请勿执行。") || !strings.Contains(content, `"tool_calls"`) { if strings.Contains(content, `"tool_calls"`) {
t.Fatalf("expected embedded example to remain plain text, got %#v", content) t.Fatalf("expected raw tool_calls json stripped from content, got %#v", content)
} }
} }
func TestHandleNonStreamFencedToolCallExampleNotIntercepted(t *testing.T) { func TestHandleNonStreamFencedToolCallExamplePromotesToolCall(t *testing.T) {
h := &Handler{} h := &Handler{}
resp := makeSSEHTTPResponse( resp := makeSSEHTTPResponse(
"data: {\"p\":\"response/content\",\"v\":\"```json\\n{\\\"tool_calls\\\":[{\\\"name\\\":\\\"search\\\",\\\"input\\\":{\\\"q\\\":\\\"go\\\"}}]}\\n```\"}", "data: {\"p\":\"response/content\",\"v\":\"```json\\n{\\\"tool_calls\\\":[{\\\"name\\\":\\\"search\\\",\\\"input\\\":{\\\"q\\\":\\\"go\\\"}}]}\\n```\"}",
@@ -258,16 +259,17 @@ func TestHandleNonStreamFencedToolCallExampleNotIntercepted(t *testing.T) {
out := decodeJSONBody(t, rec.Body.String()) out := decodeJSONBody(t, rec.Body.String())
choices, _ := out["choices"].([]any) choices, _ := out["choices"].([]any)
choice, _ := choices[0].(map[string]any) choice, _ := choices[0].(map[string]any)
if choice["finish_reason"] != "stop" { if choice["finish_reason"] != "tool_calls" {
t.Fatalf("expected finish_reason=stop, got %#v", choice["finish_reason"]) t.Fatalf("expected finish_reason=tool_calls, got %#v", choice["finish_reason"])
} }
msg, _ := choice["message"].(map[string]any) msg, _ := choice["message"].(map[string]any)
if _, ok := msg["tool_calls"]; ok { toolCalls, _ := msg["tool_calls"].([]any)
t.Fatalf("did not expect tool_calls field for fenced example: %#v", msg["tool_calls"]) if len(toolCalls) != 1 {
t.Fatalf("expected one tool_call field for fenced example: %#v", msg["tool_calls"])
} }
content, _ := msg["content"].(string) content, _ := msg["content"].(string)
if !strings.Contains(content, "```json") || !strings.Contains(content, `"tool_calls"`) { if strings.Contains(content, `"tool_calls"`) {
t.Fatalf("expected fenced tool example to pass through as text, got %q", content) t.Fatalf("expected raw tool_calls json stripped from content, got %q", content)
} }
} }
@@ -615,7 +617,7 @@ func TestHandleStreamToolCallWithSameChunkTrailingTextRemainsText(t *testing.T)
} }
} }
func TestHandleStreamFencedToolCallSnippetRemainsText(t *testing.T) { func TestHandleStreamFencedToolCallSnippetPromotesToolCall(t *testing.T) {
h := &Handler{} h := &Handler{}
resp := makeSSEHTTPResponse( resp := makeSSEHTTPResponse(
fmt.Sprintf(`data: {"p":"response/content","v":%q}`, "下面是调用示例:\n```json\n"), fmt.Sprintf(`data: {"p":"response/content","v":%q}`, "下面是调用示例:\n```json\n"),
@@ -631,8 +633,8 @@ func TestHandleStreamFencedToolCallSnippetRemainsText(t *testing.T) {
if !done { if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String()) t.Fatalf("expected [DONE], body=%s", rec.Body.String())
} }
if streamHasToolCallsDelta(frames) { if !streamHasToolCallsDelta(frames) {
t.Fatalf("did not expect tool_calls delta for fenced snippet, body=%s", rec.Body.String()) t.Fatalf("expected tool_calls delta for fenced snippet, body=%s", rec.Body.String())
} }
content := strings.Builder{} content := strings.Builder{}
for _, frame := range frames { for _, frame := range frames {
@@ -646,11 +648,11 @@ func TestHandleStreamFencedToolCallSnippetRemainsText(t *testing.T) {
} }
} }
got := content.String() got := content.String()
if !strings.Contains(got, "```json") || !strings.Contains(strings.ToLower(got), "tool_calls") { if strings.Contains(strings.ToLower(got), "tool_calls") {
t.Fatalf("expected fenced tool snippet in content, got=%q", got) t.Fatalf("expected raw fenced tool_calls snippet stripped from content, got=%q", got)
} }
if streamFinishReason(frames) != "stop" { if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=stop, body=%s", rec.Body.String()) t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
} }
} }

View File

@@ -297,7 +297,7 @@ func TestHandleResponsesStreamOutputTextDeltaCarriesItemIndexes(t *testing.T) {
} }
} }
func TestHandleResponsesStreamThinkingAndMixedToolExampleRemainMessageOnly(t *testing.T) { func TestHandleResponsesStreamThinkingAndMixedToolExampleEmitsFunctionCall(t *testing.T) {
h := &Handler{} h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -333,6 +333,7 @@ func TestHandleResponsesStreamThinkingAndMixedToolExampleRemainMessageOnly(t *te
responseObj, _ := completedPayload["response"].(map[string]any) responseObj, _ := completedPayload["response"].(map[string]any)
output, _ := responseObj["output"].([]any) output, _ := responseObj["output"].([]any)
hasMessage := false hasMessage := false
hasFunctionCall := false
for _, item := range output { for _, item := range output {
m, _ := item.(map[string]any) m, _ := item.(map[string]any)
if m == nil { if m == nil {
@@ -342,12 +343,15 @@ func TestHandleResponsesStreamThinkingAndMixedToolExampleRemainMessageOnly(t *te
hasMessage = true hasMessage = true
} }
if asString(m["type"]) == "function_call" { if asString(m["type"]) == "function_call" {
t.Fatalf("did not expect function_call output for mixed prose tool example, output=%#v", output) hasFunctionCall = true
} }
} }
if !hasMessage { if !hasMessage {
t.Fatalf("expected message output for mixed prose tool example, output=%#v", output) t.Fatalf("expected message output for mixed prose tool example, output=%#v", output)
} }
if !hasFunctionCall {
t.Fatalf("expected function_call output for mixed prose tool example, output=%#v", output)
}
} }
func TestHandleResponsesStreamToolChoiceNoneRejectsFunctionCall(t *testing.T) { func TestHandleResponsesStreamToolChoiceNoneRejectsFunctionCall(t *testing.T) {

View File

@@ -53,6 +53,10 @@ func (m streamStatusDSStub) CallCompletion(_ context.Context, _ *auth.RequestAut
return m.resp, nil return m.resp, nil
} }
func (m streamStatusDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) error {
return nil
}
func makeOpenAISSEHTTPResponse(lines ...string) *http.Response { func makeOpenAISSEHTTPResponse(lines ...string) *http.Response {
body := strings.Join(lines, "\n") body := strings.Join(lines, "\n")
if !strings.HasSuffix(body, "\n") { if !strings.HasSuffix(body, "\n") {
@@ -167,15 +171,15 @@ func TestResponsesNonStreamMixedProseToolPayloadHandlerPath(t *testing.T) {
t.Fatalf("decode response failed: %v body=%s", err, rec.Body.String()) t.Fatalf("decode response failed: %v body=%s", err, rec.Body.String())
} }
outputText, _ := out["output_text"].(string) outputText, _ := out["output_text"].(string)
if outputText == "" { if outputText != "" {
t.Fatalf("expected output_text preserved for mixed prose payload") t.Fatalf("expected output_text hidden for mixed prose tool payload, got %q", outputText)
} }
output, _ := out["output"].([]any) output, _ := out["output"].([]any)
if len(output) != 1 { if len(output) != 1 {
t.Fatalf("expected one output item, got %#v", output) t.Fatalf("expected one output item, got %#v", output)
} }
first, _ := output[0].(map[string]any) first, _ := output[0].(map[string]any)
if first["type"] != "message" { if first["type"] != "function_call" {
t.Fatalf("expected message output item, got %#v", output) t.Fatalf("expected function_call output item, got %#v", output)
} }
} }

View File

@@ -167,22 +167,22 @@ func findToolSegmentStart(s string) int {
return -1 return -1
} }
lower := strings.ToLower(s) lower := strings.ToLower(s)
offset := 0 keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"}
for { bestKeyIdx := -1
keyRel := strings.Index(lower[offset:], "tool_calls") for _, kw := range keywords {
if keyRel < 0 { idx := strings.Index(lower, kw)
return -1 if idx >= 0 && (bestKeyIdx < 0 || idx < bestKeyIdx) {
bestKeyIdx = idx
} }
keyIdx := offset + keyRel
start := strings.LastIndex(s[:keyIdx], "{")
if start < 0 {
start = keyIdx
}
if !insideCodeFence(s[:start]) {
return start
}
offset = keyIdx + len("tool_calls")
} }
if bestKeyIdx < 0 {
return -1
}
start := strings.LastIndex(s[:bestKeyIdx], "{")
if start < 0 {
start = bestKeyIdx
}
return start
} }
func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix string, calls []util.ParsedToolCall, suffix string, ready bool) { func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix string, calls []util.ParsedToolCall, suffix string, ready bool) {
@@ -191,13 +191,22 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
return "", nil, "", false return "", nil, "", false
} }
lower := strings.ToLower(captured) lower := strings.ToLower(captured)
keyIdx := strings.Index(lower, "tool_calls")
keyIdx := -1
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"}
for _, kw := range keywords {
idx := strings.Index(lower, kw)
if idx >= 0 && (keyIdx < 0 || idx < keyIdx) {
keyIdx = idx
}
}
if keyIdx < 0 { if keyIdx < 0 {
return "", nil, "", false return "", nil, "", false
} }
start := strings.LastIndex(captured[:keyIdx], "{") start := strings.LastIndex(captured[:keyIdx], "{")
if start < 0 { if start < 0 {
return "", nil, "", false start = keyIdx
} }
obj, end, ok := extractJSONObjectFrom(captured, start) obj, end, ok := extractJSONObjectFrom(captured, start)
if !ok { if !ok {
@@ -205,9 +214,6 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
} }
prefixPart := captured[:start] prefixPart := captured[:start]
suffixPart := captured[end:] suffixPart := captured[end:]
if insideCodeFence(state.recentTextTail + prefixPart) {
return captured, nil, "", true
}
parsed := util.ParseStandaloneToolCallsDetailed(obj, toolNames) parsed := util.ParseStandaloneToolCallsDetailed(obj, toolNames)
if len(parsed.Calls) == 0 { if len(parsed.Calls) == 0 {
if parsed.SawToolCallSyntax && parsed.RejectedByPolicy { if parsed.SawToolCallSyntax && parsed.RejectedByPolicy {
@@ -215,6 +221,9 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
// consume it to avoid leaking raw tool_calls JSON to user content. // consume it to avoid leaking raw tool_calls JSON to user content.
return prefixPart, nil, suffixPart, true return prefixPart, nil, suffixPart, true
} }
// If it has obvious keywords but failed to parse even after loose repair,
// we still might want to intercept it if it looks like an attempt at tool call.
// For now, keep the original logic but rely on loose JSON repair.
return captured, nil, "", true return captured, nil, "", true
} }
return prefixPart, parsed.Calls, suffixPart, true return prefixPart, parsed.Calls, suffixPart, true

View File

@@ -19,9 +19,6 @@ func buildIncrementalToolDeltas(state *toolStreamSieveState) []toolCallDelta {
if start < 0 { if start < 0 {
return nil return nil
} }
if insideCodeFence(state.recentTextTail + captured[:start]) {
return nil
}
certainSingle, hasMultiple := classifyToolCallsIncrementalSafety(captured, keyIdx) certainSingle, hasMultiple := classifyToolCallsIncrementalSafety(captured, keyIdx)
if hasMultiple { if hasMultiple {
state.disableDeltas = true state.disableDeltas = true

View File

@@ -27,6 +27,7 @@ type ConfigStore interface {
RuntimeAccountMaxInflight() int RuntimeAccountMaxInflight() int
RuntimeAccountMaxQueue(defaultSize int) int RuntimeAccountMaxQueue(defaultSize int) int
RuntimeGlobalMaxInflight(defaultSize int) int RuntimeGlobalMaxInflight(defaultSize int) int
AutoDeleteSessions() bool
} }
type PoolController interface { type PoolController interface {
@@ -40,6 +41,8 @@ type DeepSeekCaller interface {
CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error) CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error)
GetSessionCountForToken(ctx context.Context, token string) (*deepseek.SessionStats, error)
DeleteAllSessionsForToken(ctx context.Context, token string) error
} }
var _ ConfigStore = (*config.Store)(nil) var _ ConfigStore = (*config.Store)(nil)

View File

@@ -31,6 +31,7 @@ func RegisterRoutes(r chi.Router, h *Handler) {
pr.Get("/queue/status", h.queueStatus) pr.Get("/queue/status", h.queueStatus)
pr.Post("/accounts/test", h.testSingleAccount) pr.Post("/accounts/test", h.testSingleAccount)
pr.Post("/accounts/test-all", h.testAllAccounts) pr.Post("/accounts/test-all", h.testAllAccounts)
pr.Post("/accounts/sessions/delete-all", h.deleteAllSessions)
pr.Post("/import", h.batchImport) pr.Post("/import", h.batchImport)
pr.Post("/test", h.testAPI) pr.Post("/test", h.testAPI)
pr.Post("/vercel/sync", h.syncVercel) pr.Post("/vercel/sync", h.syncVercel)

View File

@@ -89,7 +89,7 @@ func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int,
func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any { func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any {
start := time.Now() start := time.Now()
identifier := acc.Identifier() identifier := acc.Identifier()
result := map[string]any{"account": identifier, "success": false, "response_time": 0, "message": "", "model": model} result := map[string]any{"account": identifier, "success": false, "response_time": 0, "message": "", "model": model, "session_count": 0}
defer func() { defer func() {
status := "failed" status := "failed"
if ok, _ := result["success"].(bool); ok { if ok, _ := result["success"].(bool); ok {
@@ -124,6 +124,13 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
return result return result
} }
} }
// 获取会话数量
sessionStats, sessionErr := h.DS.GetSessionCountForToken(ctx, token)
if sessionErr == nil && sessionStats != nil {
result["session_count"] = sessionStats.FirstPageCount
}
if strings.TrimSpace(message) == "" { if strings.TrimSpace(message) == "" {
result["success"] = true result["success"] = true
result["message"] = "API 测试成功(仅会话创建)" result["message"] = "API 测试成功(仅会话创建)"
@@ -210,3 +217,49 @@ func (h *Handler) testAPI(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, http.StatusOK, map[string]any{"success": false, "status_code": resp.StatusCode, "response": string(body)}) writeJSON(w, http.StatusOK, map[string]any{"success": false, "status_code": resp.StatusCode, "response": string(body)})
} }
func (h *Handler) deleteAllSessions(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
identifier, _ := req["identifier"].(string)
if strings.TrimSpace(identifier) == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要账号标识identifier / email / mobile"})
return
}
acc, ok := findAccountByIdentifier(h.Store, identifier)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "账号不存在"})
return
}
// 获取 token
token := strings.TrimSpace(acc.Token)
if token == "" {
newToken, err := h.DS.Login(r.Context(), acc)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "登录失败: " + err.Error()})
return
}
token = newToken
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
}
// 删除所有会话
err := h.DS.DeleteAllSessionsForToken(r.Context(), token)
if err != nil {
// token 可能过期,尝试重新登录并重试一次
newToken, loginErr := h.DS.Login(r.Context(), acc)
if loginErr != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + err.Error()})
return
}
token = newToken
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
if retryErr := h.DS.DeleteAllSessionsForToken(r.Context(), token); retryErr != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + retryErr.Error()})
return
}
}
writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "删除成功"})
}

View File

@@ -1,21 +1,28 @@
package admin package admin
import ( import (
"bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"net/http" "net/http"
"net/http/httptest"
"strings" "strings"
"testing" "testing"
"ds2api/internal/auth" "ds2api/internal/auth"
"ds2api/internal/config" "ds2api/internal/config"
"ds2api/internal/deepseek"
) )
type testingDSMock struct { type testingDSMock struct {
loginCalls int loginCalls int
createSessionCalls int createSessionCalls int
getPowCalls int getPowCalls int
callCompletionCalls int callCompletionCalls int
deleteAllSessionsCalls int
deleteAllSessionsError error
deleteAllSessionsErrorOnce bool
} }
func (m *testingDSMock) Login(_ context.Context, _ config.Account) (string, error) { func (m *testingDSMock) Login(_ context.Context, _ config.Account) (string, error) {
@@ -38,6 +45,22 @@ func (m *testingDSMock) CallCompletion(_ context.Context, _ *auth.RequestAuth, _
return nil, errors.New("should not call CallCompletion in this test") return nil, errors.New("should not call CallCompletion in this test")
} }
func (m *testingDSMock) DeleteAllSessionsForToken(_ context.Context, _ string) error {
m.deleteAllSessionsCalls++
if m.deleteAllSessionsError != nil {
err := m.deleteAllSessionsError
if m.deleteAllSessionsErrorOnce {
m.deleteAllSessionsError = nil
}
return err
}
return nil
}
func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*deepseek.SessionStats, error) {
return &deepseek.SessionStats{Success: true}, nil
}
func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) { func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":""}]}`) t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":""}]}`)
store := config.LoadStore() store := config.LoadStore()
@@ -74,3 +97,38 @@ func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) {
t.Fatalf("expected test status ok, got %q", updated.TestStatus) t.Fatalf("expected test status ok, got %q", updated.TestStatus)
} }
} }
func TestDeleteAllSessions_RetryWithReloginOnDeleteFailure(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":"expired-token"}]}`)
store := config.LoadStore()
ds := &testingDSMock{deleteAllSessionsError: errors.New("token expired"), deleteAllSessionsErrorOnce: true}
h := &Handler{Store: store, DS: ds}
req := httptest.NewRequest(http.MethodPost, "/delete-all", bytes.NewBufferString(`{"identifier":"batch@example.com"}`))
rec := httptest.NewRecorder()
h.deleteAllSessions(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if ok, _ := resp["success"].(bool); !ok {
t.Fatalf("expected success response, got %#v", resp)
}
if ds.loginCalls != 1 {
t.Fatalf("expected relogin once, got %d", ds.loginCalls)
}
if ds.deleteAllSessionsCalls != 2 {
t.Fatalf("expected delete called twice, got %d", ds.deleteAllSessionsCalls)
}
updated, ok := store.FindAccount("batch@example.com")
if !ok {
t.Fatal("expected account")
}
if updated.Token != "new-token" {
t.Fatalf("expected refreshed token persisted, got %q", updated.Token)
}
}

View File

@@ -7,15 +7,30 @@ import (
"ds2api/internal/config" "ds2api/internal/config"
) )
func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.ToolcallConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, map[string]string, map[string]string, error) { func boolFrom(v any) bool {
if v == nil {
return false
}
switch x := v.(type) {
case bool:
return x
case string:
return strings.ToLower(strings.TrimSpace(x)) == "true"
default:
return false
}
}
func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.ToolcallConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, map[string]string, map[string]string, error) {
var ( var (
adminCfg *config.AdminConfig adminCfg *config.AdminConfig
runtimeCfg *config.RuntimeConfig runtimeCfg *config.RuntimeConfig
toolcallCfg *config.ToolcallConfig toolcallCfg *config.ToolcallConfig
respCfg *config.ResponsesConfig respCfg *config.ResponsesConfig
embCfg *config.EmbeddingsConfig embCfg *config.EmbeddingsConfig
claudeMap map[string]string autoDeleteCfg *config.AutoDeleteConfig
aliasMap map[string]string claudeMap map[string]string
aliasMap map[string]string
) )
if raw, ok := req["admin"].(map[string]any); ok { if raw, ok := req["admin"].(map[string]any); ok {
@@ -23,7 +38,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["jwt_expire_hours"]; exists { if v, exists := raw["jwt_expire_hours"]; exists {
n := intFrom(v) n := intFrom(v)
if n < 1 || n > 720 { if n < 1 || n > 720 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("admin.jwt_expire_hours must be between 1 and 720") return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("admin.jwt_expire_hours must be between 1 and 720")
} }
cfg.JWTExpireHours = n cfg.JWTExpireHours = n
} }
@@ -35,26 +50,26 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["account_max_inflight"]; exists { if v, exists := raw["account_max_inflight"]; exists {
n := intFrom(v) n := intFrom(v)
if n < 1 || n > 256 { if n < 1 || n > 256 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_inflight must be between 1 and 256") return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_inflight must be between 1 and 256")
} }
cfg.AccountMaxInflight = n cfg.AccountMaxInflight = n
} }
if v, exists := raw["account_max_queue"]; exists { if v, exists := raw["account_max_queue"]; exists {
n := intFrom(v) n := intFrom(v)
if n < 1 || n > 200000 { if n < 1 || n > 200000 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_queue must be between 1 and 200000") return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_queue must be between 1 and 200000")
} }
cfg.AccountMaxQueue = n cfg.AccountMaxQueue = n
} }
if v, exists := raw["global_max_inflight"]; exists { if v, exists := raw["global_max_inflight"]; exists {
n := intFrom(v) n := intFrom(v)
if n < 1 || n > 200000 { if n < 1 || n > 200000 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be between 1 and 200000") return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be between 1 and 200000")
} }
cfg.GlobalMaxInflight = n cfg.GlobalMaxInflight = n
} }
if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight { if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight {
return 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, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
} }
runtimeCfg = cfg runtimeCfg = cfg
} }
@@ -67,7 +82,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
case "feature_match", "off": case "feature_match", "off":
cfg.Mode = mode cfg.Mode = mode
default: default:
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.mode must be feature_match or off") return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.mode must be feature_match or off")
} }
} }
if v, exists := raw["early_emit_confidence"]; exists { if v, exists := raw["early_emit_confidence"]; exists {
@@ -76,7 +91,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
case "high", "low", "off": case "high", "low", "off":
cfg.EarlyEmitConfidence = level cfg.EarlyEmitConfidence = level
default: default:
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.early_emit_confidence must be high, low or off") return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.early_emit_confidence must be high, low or off")
} }
} }
toolcallCfg = cfg toolcallCfg = cfg
@@ -87,7 +102,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["store_ttl_seconds"]; exists { if v, exists := raw["store_ttl_seconds"]; exists {
n := intFrom(v) n := intFrom(v)
if n < 30 || n > 86400 { if n < 30 || n > 86400 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("responses.store_ttl_seconds must be between 30 and 86400") return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("responses.store_ttl_seconds must be between 30 and 86400")
} }
cfg.StoreTTLSeconds = n cfg.StoreTTLSeconds = n
} }
@@ -98,9 +113,6 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
cfg := &config.EmbeddingsConfig{} cfg := &config.EmbeddingsConfig{}
if v, exists := raw["provider"]; exists { if v, exists := raw["provider"]; exists {
p := strings.TrimSpace(fmt.Sprintf("%v", v)) p := strings.TrimSpace(fmt.Sprintf("%v", v))
if p == "" {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("embeddings.provider cannot be empty")
}
cfg.Provider = p cfg.Provider = p
} }
embCfg = cfg embCfg = cfg
@@ -130,5 +142,13 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
} }
} }
return adminCfg, runtimeCfg, toolcallCfg, respCfg, embCfg, claudeMap, aliasMap, nil if raw, ok := req["auto_delete"].(map[string]any); ok {
cfg := &config.AutoDeleteConfig{}
if v, exists := raw["sessions"]; exists {
cfg.Sessions = boolFrom(v)
}
autoDeleteCfg = cfg
}
return adminCfg, runtimeCfg, toolcallCfg, respCfg, embCfg, autoDeleteCfg, claudeMap, aliasMap, nil
} }

View File

@@ -28,6 +28,7 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
"toolcall": snap.Toolcall, "toolcall": snap.Toolcall,
"responses": snap.Responses, "responses": snap.Responses,
"embeddings": snap.Embeddings, "embeddings": snap.Embeddings,
"auto_delete": snap.AutoDelete,
"claude_mapping": settingsClaudeMapping(snap), "claude_mapping": settingsClaudeMapping(snap),
"model_aliases": snap.ModelAliases, "model_aliases": snap.ModelAliases,
"env_backed": h.Store.IsEnvBacked(), "env_backed": h.Store.IsEnvBacked(),

View File

@@ -17,7 +17,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
return return
} }
adminCfg, runtimeCfg, toolcallCfg, responsesCfg, embeddingsCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req) adminCfg, runtimeCfg, toolcallCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req)
if err != nil { if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()}) writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return return
@@ -60,6 +60,9 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
if embeddingsCfg != nil && strings.TrimSpace(embeddingsCfg.Provider) != "" { if embeddingsCfg != nil && strings.TrimSpace(embeddingsCfg.Provider) != "" {
c.Embeddings.Provider = strings.TrimSpace(embeddingsCfg.Provider) c.Embeddings.Provider = strings.TrimSpace(embeddingsCfg.Provider)
} }
if autoDeleteCfg != nil {
c.AutoDelete.Sessions = autoDeleteCfg.Sessions
}
if claudeMap != nil { if claudeMap != nil {
c.ClaudeMapping = claudeMap c.ClaudeMapping = claudeMap
c.ClaudeModelMap = nil c.ClaudeModelMap = nil

View File

@@ -47,6 +47,7 @@ func (c Config) MarshalJSON() ([]byte, error) {
if strings.TrimSpace(c.Embeddings.Provider) != "" { if strings.TrimSpace(c.Embeddings.Provider) != "" {
m["embeddings"] = c.Embeddings m["embeddings"] = c.Embeddings
} }
m["auto_delete"] = c.AutoDelete
if c.VercelSyncHash != "" { if c.VercelSyncHash != "" {
m["_vercel_sync_hash"] = c.VercelSyncHash m["_vercel_sync_hash"] = c.VercelSyncHash
} }
@@ -108,6 +109,10 @@ func (c *Config) UnmarshalJSON(b []byte) error {
if err := json.Unmarshal(v, &c.Embeddings); err != nil { if err := json.Unmarshal(v, &c.Embeddings); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err) return fmt.Errorf("invalid field %q: %w", k, err)
} }
case "auto_delete":
if err := json.Unmarshal(v, &c.AutoDelete); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "_vercel_sync_hash": case "_vercel_sync_hash":
if err := json.Unmarshal(v, &c.VercelSyncHash); err != nil { if err := json.Unmarshal(v, &c.VercelSyncHash); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err) return fmt.Errorf("invalid field %q: %w", k, err)
@@ -141,6 +146,7 @@ func (c Config) Clone() Config {
Toolcall: c.Toolcall, Toolcall: c.Toolcall,
Responses: c.Responses, Responses: c.Responses,
Embeddings: c.Embeddings, Embeddings: c.Embeddings,
AutoDelete: c.AutoDelete,
VercelSyncHash: c.VercelSyncHash, VercelSyncHash: c.VercelSyncHash,
VercelSyncTime: c.VercelSyncTime, VercelSyncTime: c.VercelSyncTime,
AdditionalFields: map[string]any{}, AdditionalFields: map[string]any{},

View File

@@ -12,7 +12,8 @@ type Config struct {
Toolcall ToolcallConfig `json:"toolcall,omitempty"` Toolcall ToolcallConfig `json:"toolcall,omitempty"`
Responses ResponsesConfig `json:"responses,omitempty"` Responses ResponsesConfig `json:"responses,omitempty"`
Embeddings EmbeddingsConfig `json:"embeddings,omitempty"` Embeddings EmbeddingsConfig `json:"embeddings,omitempty"`
VercelSyncHash string `json:"_vercel_sync_hash,omitempty"` AutoDelete AutoDeleteConfig `json:"auto_delete"`
VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"` VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"`
AdditionalFields map[string]any `json:"-"` AdditionalFields map[string]any `json:"-"`
} }
@@ -53,3 +54,7 @@ type ResponsesConfig struct {
type EmbeddingsConfig struct { type EmbeddingsConfig struct {
Provider string `json:"provider,omitempty"` Provider string `json:"provider,omitempty"`
} }
type AutoDeleteConfig struct {
Sessions bool `json:"sessions"`
}

View File

@@ -165,3 +165,9 @@ func (s *Store) RuntimeGlobalMaxInflight(defaultSize int) int {
} }
return defaultSize return defaultSize
} }
func (s *Store) AutoDeleteSessions() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg.AutoDelete.Sessions
}

View File

@@ -62,8 +62,8 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte
attempts++ attempts++
continue continue
} }
code := intFrom(resp["code"]) code, bizCode, msg, bizMsg := extractResponseStatus(resp)
if status == http.StatusOK && code == 0 { if status == http.StatusOK && code == 0 && bizCode == 0 {
data, _ := resp["data"].(map[string]any) data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any) bizData, _ := data["biz_data"].(map[string]any)
sessionID, _ := bizData["id"].(string) sessionID, _ := bizData["id"].(string)
@@ -71,10 +71,9 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte
return sessionID, nil return sessionID, nil
} }
} }
msg, _ := resp["msg"].(string) config.Logger.Warn("[create_session] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "use_config_token", a.UseConfigToken, "account", a.AccountID)
config.Logger.Warn("[create_session] failed", "status", status, "code", code, "msg", msg, "use_config_token", a.UseConfigToken, "account", a.AccountID)
if a.UseConfigToken { if a.UseConfigToken {
if isTokenInvalid(status, code, msg) && !refreshed { if isTokenInvalid(status, code, bizCode, msg, bizMsg) && !refreshed {
if c.Auth.RefreshToken(ctx, a) { if c.Auth.RefreshToken(ctx, a) {
refreshed = true refreshed = true
continue continue
@@ -96,6 +95,7 @@ func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts in
maxAttempts = c.maxRetries maxAttempts = c.maxRetries
} }
attempts := 0 attempts := 0
refreshed := false
for attempts < maxAttempts { for attempts < maxAttempts {
headers := c.authHeaders(a.DeepSeekToken) headers := c.authHeaders(a.DeepSeekToken)
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekCreatePowURL, headers, map[string]any{"target_path": "/api/v0/chat/completion"}) resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekCreatePowURL, headers, map[string]any{"target_path": "/api/v0/chat/completion"})
@@ -104,8 +104,8 @@ func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts in
attempts++ attempts++
continue continue
} }
code := intFrom(resp["code"]) code, bizCode, msg, bizMsg := extractResponseStatus(resp)
if status == http.StatusOK && code == 0 { if status == http.StatusOK && code == 0 && bizCode == 0 {
data, _ := resp["data"].(map[string]any) data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any) bizData, _ := data["biz_data"].(map[string]any)
challenge, _ := bizData["challenge"].(map[string]any) challenge, _ := bizData["challenge"].(map[string]any)
@@ -116,15 +116,16 @@ func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts in
} }
return BuildPowHeader(challenge, answer) return BuildPowHeader(challenge, answer)
} }
msg, _ := resp["msg"].(string) config.Logger.Warn("[get_pow] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "use_config_token", a.UseConfigToken, "account", a.AccountID)
config.Logger.Warn("[get_pow] failed", "status", status, "code", code, "msg", msg, "use_config_token", a.UseConfigToken, "account", a.AccountID)
if a.UseConfigToken { if a.UseConfigToken {
if isTokenInvalid(status, code, msg) { if isTokenInvalid(status, code, bizCode, msg, bizMsg) && !refreshed {
if c.Auth.RefreshToken(ctx, a) { if c.Auth.RefreshToken(ctx, a) {
refreshed = true
continue continue
} }
} }
if c.Auth.SwitchAccount(ctx, a) { if c.Auth.SwitchAccount(ctx, a) {
refreshed = false
attempts++ attempts++
continue continue
} }
@@ -143,15 +144,34 @@ func (c *Client) authHeaders(token string) map[string]string {
return headers return headers
} }
func isTokenInvalid(status int, code int, msg string) bool { func isTokenInvalid(status int, code int, bizCode int, msg string, bizMsg string) bool {
msg = strings.ToLower(msg) msg = strings.ToLower(strings.TrimSpace(msg) + " " + strings.TrimSpace(bizMsg))
if status == http.StatusUnauthorized || status == http.StatusForbidden { if status == http.StatusUnauthorized || status == http.StatusForbidden {
return true return true
} }
if code == 40001 || code == 40002 || code == 40003 { if code == 40001 || code == 40002 || code == 40003 || bizCode == 40001 || bizCode == 40002 || bizCode == 40003 {
return true return true
} }
return strings.Contains(msg, "token") || strings.Contains(msg, "unauthorized") return strings.Contains(msg, "token") ||
strings.Contains(msg, "unauthorized") ||
strings.Contains(msg, "expired") ||
strings.Contains(msg, "not login") ||
strings.Contains(msg, "login required") ||
strings.Contains(msg, "invalid jwt")
}
func extractResponseStatus(resp map[string]any) (code int, bizCode int, msg string, bizMsg string) {
code = intFrom(resp["code"])
msg, _ = resp["msg"].(string)
data, _ := resp["data"].(map[string]any)
bizCode = intFrom(data["biz_code"])
bizMsg, _ = data["biz_msg"].(string)
if strings.TrimSpace(bizMsg) == "" {
if bizData, ok := data["biz_data"].(map[string]any); ok {
bizMsg, _ = bizData["msg"].(string)
}
}
return code, bizCode, msg, bizMsg
} }
func normalizeMobileForLogin(raw string) (mobile string, areaCode any) { func normalizeMobileForLogin(raw string) (mobile string, areaCode any) {

View File

@@ -62,3 +62,51 @@ func (c *Client) postJSONWithStatus(ctx context.Context, doer trans.Doer, url st
} }
return out, resp.StatusCode, nil return out, resp.StatusCode, nil
} }
func (c *Client) getJSON(ctx context.Context, doer trans.Doer, url string, headers map[string]string) (map[string]any, error) {
body, status, err := c.getJSONWithStatus(ctx, doer, url, headers)
if err != nil {
return nil, err
}
if status == 0 {
return nil, errors.New("request failed")
}
return body, nil
}
func (c *Client) getJSONWithStatus(ctx context.Context, doer trans.Doer, url string, headers map[string]string) (map[string]any, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := doer.Do(req)
if err != nil {
config.Logger.Warn("[deepseek] fingerprint GET request failed, fallback to std transport", "url", url, "error", err)
req2, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if reqErr != nil {
return nil, 0, err
}
for k, v := range headers {
req2.Header.Set(k, v)
}
resp, err = c.fallback.Do(req2)
if err != nil {
return nil, 0, err
}
}
defer resp.Body.Close()
payloadBytes, err := readResponseBody(resp)
if err != nil {
return nil, resp.StatusCode, err
}
out := map[string]any{}
if len(payloadBytes) > 0 {
if err := json.Unmarshal(payloadBytes, &out); err != nil {
config.Logger.Warn("[deepseek] json parse failed", "url", url, "status", resp.StatusCode, "content_encoding", resp.Header.Get("Content-Encoding"), "preview", preview(payloadBytes))
}
}
return out, resp.StatusCode, nil
}

View File

@@ -0,0 +1,256 @@
package deepseek
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"ds2api/internal/auth"
"ds2api/internal/config"
)
// SessionInfo 会话信息
type SessionInfo struct {
ID string `json:"id"`
Title string `json:"title"`
TitleType string `json:"title_type"`
Pinned bool `json:"pinned"`
UpdatedAt float64 `json:"updated_at"`
}
// SessionStats 会话统计结果
type SessionStats struct {
AccountID string // 账号标识 (email 或 mobile)
FirstPageCount int // 第一页会话数量(当 HasMore 为 true 时,真实总数可能更大)
PinnedCount int // 置顶会话数量
HasMore bool // 是否还有更多页
Success bool // 请求是否成功
ErrorMessage string // 错误信息
}
// GetSessionCount 获取单个账号的会话数量
func (c *Client) GetSessionCount(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (*SessionStats, error) {
if maxAttempts <= 0 {
maxAttempts = c.maxRetries
}
stats := &SessionStats{
AccountID: a.AccountID,
}
attempts := 0
refreshed := false
for attempts < maxAttempts {
headers := c.authHeaders(a.DeepSeekToken)
// 构建请求 URL
reqURL := DeepSeekFetchSessionURL + "?lte_cursor.pinned=false"
resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
if err != nil {
config.Logger.Warn("[get_session_count] request error", "error", err, "account", a.AccountID)
attempts++
continue
}
code, bizCode, msg, bizMsg := extractResponseStatus(resp)
if status == http.StatusOK && code == 0 && bizCode == 0 {
data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any)
chatSessions, _ := bizData["chat_sessions"].([]any)
hasMore, _ := bizData["has_more"].(bool)
stats.FirstPageCount = len(chatSessions)
stats.HasMore = hasMore
stats.Success = true
// 统计置顶会话数量
for _, session := range chatSessions {
if s, ok := session.(map[string]any); ok {
if pinned, ok := s["pinned"].(bool); ok && pinned {
stats.PinnedCount++
}
}
}
return stats, nil
}
stats.ErrorMessage = fmt.Sprintf("status=%d, code=%d, msg=%s", status, code, msg)
config.Logger.Warn("[get_session_count] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "account", a.AccountID)
if a.UseConfigToken {
if isTokenInvalid(status, code, bizCode, msg, bizMsg) && !refreshed {
if c.Auth.RefreshToken(ctx, a) {
refreshed = true
continue
}
}
if c.Auth.SwitchAccount(ctx, a) {
refreshed = false
attempts++
continue
}
}
attempts++
}
stats.Success = false
stats.ErrorMessage = "get session count failed after retries"
return stats, errors.New(stats.ErrorMessage)
}
// GetSessionCountForToken 直接使用 token 获取会话数量(直通模式)
func (c *Client) GetSessionCountForToken(ctx context.Context, token string) (*SessionStats, error) {
headers := c.authHeaders(token)
reqURL := DeepSeekFetchSessionURL + "?lte_cursor.pinned=false"
resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
if err != nil {
return nil, err
}
code, bizCode, msg, bizMsg := extractResponseStatus(resp)
if status != http.StatusOK || code != 0 || bizCode != 0 {
if strings.TrimSpace(bizMsg) != "" {
msg = bizMsg
}
return nil, fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
}
data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any)
chatSessions, _ := bizData["chat_sessions"].([]any)
hasMore, _ := bizData["has_more"].(bool)
stats := &SessionStats{
FirstPageCount: len(chatSessions),
HasMore: hasMore,
Success: true,
}
// 统计置顶会话数量
for _, session := range chatSessions {
if s, ok := session.(map[string]any); ok {
if pinned, ok := s["pinned"].(bool); ok && pinned {
stats.PinnedCount++
}
}
}
return stats, nil
}
// GetSessionCountAll 获取所有账号的会话数量统计
func (c *Client) GetSessionCountAll(ctx context.Context) []*SessionStats {
accounts := c.Store.Accounts()
results := make([]*SessionStats, 0, len(accounts))
for _, acc := range accounts {
token := acc.Token
accountID := acc.Email
if accountID == "" {
accountID = acc.Mobile
}
// 如果没有 token尝试登录获取
if token == "" {
var err error
token, err = c.Login(ctx, acc)
if err != nil {
results = append(results, &SessionStats{
AccountID: accountID,
Success: false,
ErrorMessage: fmt.Sprintf("login failed: %v", err),
})
continue
}
}
stats, err := c.GetSessionCountForToken(ctx, token)
if err != nil {
results = append(results, &SessionStats{
AccountID: accountID,
Success: false,
ErrorMessage: err.Error(),
})
continue
}
stats.AccountID = accountID
results = append(results, stats)
}
return results
}
// FetchSessionPage 获取会话列表(支持分页)
func (c *Client) FetchSessionPage(ctx context.Context, a *auth.RequestAuth, cursor string) ([]SessionInfo, bool, error) {
headers := c.authHeaders(a.DeepSeekToken)
// 构建请求 URL
params := url.Values{}
params.Set("lte_cursor.pinned", "false")
if cursor != "" {
params.Set("lte_cursor", cursor)
}
reqURL := DeepSeekFetchSessionURL + "?" + params.Encode()
resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
if err != nil {
return nil, false, err
}
code := intFrom(resp["code"])
if status != http.StatusOK || code != 0 {
msg, _ := resp["msg"].(string)
return nil, false, fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
}
data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any)
chatSessions, _ := bizData["chat_sessions"].([]any)
hasMore, _ := bizData["has_more"].(bool)
sessions := make([]SessionInfo, 0, len(chatSessions))
for _, s := range chatSessions {
if m, ok := s.(map[string]any); ok {
session := SessionInfo{
ID: stringFromMap(m, "id"),
Title: stringFromMap(m, "title"),
TitleType: stringFromMap(m, "title_type"),
Pinned: boolFromMap(m, "pinned"),
UpdatedAt: floatFromMap(m, "updated_at"),
}
sessions = append(sessions, session)
}
}
return sessions, hasMore, nil
}
// 辅助函数
func stringFromMap(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}
func boolFromMap(m map[string]any, key string) bool {
if v, ok := m[key].(bool); ok {
return v
}
return false
}
func floatFromMap(m map[string]any, key string) float64 {
if v, ok := m[key].(float64); ok {
return v
}
return 0
}

View File

@@ -0,0 +1,155 @@
package deepseek
import (
"context"
"errors"
"fmt"
"net/http"
"ds2api/internal/auth"
"ds2api/internal/config"
)
// DeleteSessionResult 删除会话结果
type DeleteSessionResult struct {
SessionID string // 会话 ID
Success bool // 是否成功
ErrorMessage string // 错误信息
}
// DeleteSession 删除单个会话
func (c *Client) DeleteSession(ctx context.Context, a *auth.RequestAuth, sessionID string, maxAttempts int) (*DeleteSessionResult, error) {
if maxAttempts <= 0 {
maxAttempts = c.maxRetries
}
result := &DeleteSessionResult{
SessionID: sessionID,
}
if sessionID == "" {
result.ErrorMessage = "session_id is required"
return result, errors.New(result.ErrorMessage)
}
attempts := 0
refreshed := false
for attempts < maxAttempts {
headers := c.authHeaders(a.DeepSeekToken)
payload := map[string]any{
"chat_session_id": sessionID,
}
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteSessionURL, headers, payload)
if err != nil {
config.Logger.Warn("[delete_session] request error", "error", err, "session_id", sessionID)
attempts++
continue
}
code, bizCode, msg, bizMsg := extractResponseStatus(resp)
if status == http.StatusOK && code == 0 && bizCode == 0 {
result.Success = true
return result, nil
}
result.ErrorMessage = fmt.Sprintf("status=%d, code=%d, msg=%s", status, code, msg)
config.Logger.Warn("[delete_session] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "session_id", sessionID)
if a.UseConfigToken {
if isTokenInvalid(status, code, bizCode, msg, bizMsg) && !refreshed {
if c.Auth.RefreshToken(ctx, a) {
refreshed = true
continue
}
}
if c.Auth.SwitchAccount(ctx, a) {
refreshed = false
attempts++
continue
}
}
attempts++
}
result.Success = false
result.ErrorMessage = "delete session failed after retries"
return result, errors.New(result.ErrorMessage)
}
// DeleteSessionForToken 直接使用 token 删除会话(直通模式)
func (c *Client) DeleteSessionForToken(ctx context.Context, token string, sessionID string) (*DeleteSessionResult, error) {
result := &DeleteSessionResult{
SessionID: sessionID,
}
if sessionID == "" {
result.ErrorMessage = "session_id is required"
return result, errors.New(result.ErrorMessage)
}
headers := c.authHeaders(token)
payload := map[string]any{
"chat_session_id": sessionID,
}
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteSessionURL, headers, payload)
if err != nil {
result.ErrorMessage = err.Error()
return result, err
}
code := intFrom(resp["code"])
if status != http.StatusOK || code != 0 {
msg, _ := resp["msg"].(string)
result.ErrorMessage = fmt.Sprintf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
return result, errors.New(result.ErrorMessage)
}
result.Success = true
return result, nil
}
// DeleteAllSessions 删除所有会话(谨慎使用)
func (c *Client) DeleteAllSessions(ctx context.Context, a *auth.RequestAuth) error {
headers := c.authHeaders(a.DeepSeekToken)
payload := map[string]any{}
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteAllSessionsURL, headers, payload)
if err != nil {
config.Logger.Warn("[delete_all_sessions] request error", "error", err)
return err
}
code := intFrom(resp["code"])
if status != http.StatusOK || code != 0 {
msg, _ := resp["msg"].(string)
config.Logger.Warn("[delete_all_sessions] failed", "status", status, "code", code, "msg", msg)
return fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
}
return nil
}
// DeleteAllSessionsForToken 直接使用 token 删除所有会话(直通模式)
func (c *Client) DeleteAllSessionsForToken(ctx context.Context, token string) error {
headers := c.authHeaders(token)
payload := map[string]any{}
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteAllSessionsURL, headers, payload)
if err != nil {
config.Logger.Warn("[delete_all_sessions_for_token] request error", "error", err)
return err
}
code := intFrom(resp["code"])
if status != http.StatusOK || code != 0 {
msg, _ := resp["msg"].(string)
config.Logger.Warn("[delete_all_sessions_for_token] failed", "status", status, "code", code, "msg", msg)
return fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
}
return nil
}

View File

@@ -11,6 +11,9 @@ const (
DeepSeekCreateSessionURL = "https://chat.deepseek.com/api/v0/chat_session/create" DeepSeekCreateSessionURL = "https://chat.deepseek.com/api/v0/chat_session/create"
DeepSeekCreatePowURL = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge" DeepSeekCreatePowURL = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge"
DeepSeekCompletionURL = "https://chat.deepseek.com/api/v0/chat/completion" DeepSeekCompletionURL = "https://chat.deepseek.com/api/v0/chat/completion"
DeepSeekFetchSessionURL = "https://chat.deepseek.com/api/v0/chat_session/fetch_page"
DeepSeekDeleteSessionURL = "https://chat.deepseek.com/api/v0/chat_session/delete"
DeepSeekDeleteAllSessionsURL = "https://chat.deepseek.com/api/v0/chat_session/delete_all"
) )
var defaultBaseHeaders = map[string]string{ var defaultBaseHeaders = map[string]string{

View File

@@ -8,15 +8,15 @@ import (
) )
func BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any { func BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any {
detected := util.ParseStandaloneToolCalls(finalText, toolNames) detected := util.ParseStandaloneToolCallsDetailed(finalText, toolNames)
finishReason := "stop" finishReason := "stop"
messageObj := map[string]any{"role": "assistant", "content": finalText} messageObj := map[string]any{"role": "assistant", "content": finalText}
if strings.TrimSpace(finalThinking) != "" { if strings.TrimSpace(finalThinking) != "" {
messageObj["reasoning_content"] = finalThinking messageObj["reasoning_content"] = finalThinking
} }
if len(detected) > 0 { if len(detected.Calls) > 0 {
finishReason = "tool_calls" finishReason = "tool_calls"
messageObj["tool_calls"] = util.FormatOpenAIToolCalls(detected) messageObj["tool_calls"] = util.FormatOpenAIToolCalls(detected.Calls)
messageObj["content"] = nil messageObj["content"] = nil
} }

View File

@@ -13,12 +13,12 @@ import (
func BuildResponseObject(responseID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any { func BuildResponseObject(responseID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any {
// Strict mode: only standalone, structured tool-call payloads are treated // Strict mode: only standalone, structured tool-call payloads are treated
// as executable tool calls. // as executable tool calls.
detected := util.ParseStandaloneToolCalls(finalText, toolNames) detected := util.ParseStandaloneToolCallsDetailed(finalText, toolNames)
exposedOutputText := finalText exposedOutputText := finalText
output := make([]any, 0, 2) output := make([]any, 0, 2)
if len(detected) > 0 { if len(detected.Calls) > 0 {
exposedOutputText = "" exposedOutputText = ""
output = append(output, toResponsesFunctionCallItems(detected)...) output = append(output, toResponsesFunctionCallItems(detected.Calls)...)
} else { } else {
content := make([]any, 0, 2) content := make([]any, 0, 2)
if finalThinking != "" { if finalThinking != "" {

View File

@@ -45,7 +45,7 @@ func TestBuildResponseObjectToolCallsFollowChatShape(t *testing.T) {
} }
} }
func TestBuildResponseObjectTreatsMixedProseToolPayloadAsText(t *testing.T) { func TestBuildResponseObjectPromotesMixedProseToolPayloadToFunctionCall(t *testing.T) {
obj := BuildResponseObject( obj := BuildResponseObject(
"resp_test", "resp_test",
"gpt-4o", "gpt-4o",
@@ -56,20 +56,20 @@ func TestBuildResponseObjectTreatsMixedProseToolPayloadAsText(t *testing.T) {
) )
outputText, _ := obj["output_text"].(string) outputText, _ := obj["output_text"].(string)
if outputText == "" { if outputText != "" {
t.Fatalf("expected output_text preserved for mixed prose payload") t.Fatalf("expected output_text hidden for mixed prose tool payload, got %q", outputText)
} }
output, _ := obj["output"].([]any) output, _ := obj["output"].([]any)
if len(output) != 1 { if len(output) != 1 {
t.Fatalf("expected one message output item, got %#v", obj["output"]) t.Fatalf("expected one function_call output item, got %#v", obj["output"])
} }
first, _ := output[0].(map[string]any) first, _ := output[0].(map[string]any)
if first["type"] != "message" { if first["type"] != "function_call" {
t.Fatalf("expected message output type, got %#v", first["type"]) t.Fatalf("expected function_call output type, got %#v", first["type"])
} }
} }
func TestBuildResponseObjectFencedToolPayloadRemainsText(t *testing.T) { func TestBuildResponseObjectPromotesFencedToolPayloadToFunctionCall(t *testing.T) {
obj := BuildResponseObject( obj := BuildResponseObject(
"resp_test", "resp_test",
"gpt-4o", "gpt-4o",
@@ -80,16 +80,16 @@ func TestBuildResponseObjectFencedToolPayloadRemainsText(t *testing.T) {
) )
outputText, _ := obj["output_text"].(string) outputText, _ := obj["output_text"].(string)
if outputText == "" { if outputText != "" {
t.Fatalf("expected output_text preserved for fenced example") t.Fatalf("expected output_text hidden for fenced tool payload, got %q", outputText)
} }
output, _ := obj["output"].([]any) output, _ := obj["output"].([]any)
if len(output) != 1 { if len(output) != 1 {
t.Fatalf("expected one message output item, got %#v", obj["output"]) t.Fatalf("expected one function_call output item, got %#v", obj["output"])
} }
first, _ := output[0].(map[string]any) first, _ := output[0].(map[string]any)
if first["type"] != "message" { if first["type"] != "function_call" {
t.Fatalf("expected message output type, got %#v", first["type"]) t.Fatalf("expected function_call output type, got %#v", first["type"])
} }
} }

View File

@@ -2,10 +2,8 @@
const { const {
toStringSafe, toStringSafe,
looksLikeToolExampleContext,
} = require('./state'); } = require('./state');
const { const {
stripFencedCodeBlocks,
buildToolCallCandidates, buildToolCallCandidates,
parseToolCallsPayload, parseToolCallsPayload,
parseMarkupToolCalls, parseMarkupToolCalls,
@@ -38,16 +36,13 @@ function parseToolCalls(text, toolNames) {
function parseToolCallsDetailed(text, toolNames) { function parseToolCallsDetailed(text, toolNames) {
const result = emptyParseResult(); const result = emptyParseResult();
if (!toStringSafe(text)) { const normalized = toStringSafe(text);
if (!normalized) {
return result; return result;
} }
const sanitized = stripFencedCodeBlocks(text); result.sawToolCallSyntax = looksLikeToolCallSyntax(normalized);
if (!toStringSafe(sanitized)) {
return result;
}
result.sawToolCallSyntax = looksLikeToolCallSyntax(sanitized);
const candidates = buildToolCallCandidates(sanitized); const candidates = buildToolCallCandidates(normalized);
let parsed = []; let parsed = [];
for (const c of candidates) { for (const c of candidates) {
parsed = parseToolCallsPayload(c); parsed = parseToolCallsPayload(c);
@@ -63,9 +58,9 @@ function parseToolCallsDetailed(text, toolNames) {
} }
} }
if (parsed.length === 0) { if (parsed.length === 0) {
parsed = parseMarkupToolCalls(sanitized); parsed = parseMarkupToolCalls(normalized);
if (parsed.length === 0) { if (parsed.length === 0) {
parsed = parseTextKVToolCalls(sanitized); parsed = parseTextKVToolCalls(normalized);
if (parsed.length === 0) { if (parsed.length === 0) {
return result; return result;
} }
@@ -90,22 +85,29 @@ function parseStandaloneToolCallsDetailed(text, toolNames) {
if (!trimmed) { if (!trimmed) {
return result; return result;
} }
if (trimmed.includes('```')) {
return result;
}
if (looksLikeToolExampleContext(trimmed)) {
return result;
}
result.sawToolCallSyntax = looksLikeToolCallSyntax(trimmed); result.sawToolCallSyntax = looksLikeToolCallSyntax(trimmed);
let parsed = parseToolCallsPayload(trimmed); const candidates = buildToolCallCandidates(trimmed);
let parsed = [];
for (const c of candidates) {
parsed = parseToolCallsPayload(c);
if (parsed.length === 0) {
parsed = parseMarkupToolCalls(c);
}
if (parsed.length === 0) {
parsed = parseTextKVToolCalls(c);
}
if (parsed.length > 0) {
break;
}
}
if (parsed.length === 0) { if (parsed.length === 0) {
parsed = parseMarkupToolCalls(trimmed); parsed = parseMarkupToolCalls(trimmed);
} if (parsed.length === 0) {
if (parsed.length === 0) { parsed = parseTextKVToolCalls(trimmed);
parsed = parseTextKVToolCalls(trimmed); if (parsed.length === 0) {
} return result;
if (parsed.length === 0) { }
return result; }
} }
result.sawToolCallSyntax = true; result.sawToolCallSyntax = true;

View File

@@ -46,6 +46,9 @@ function processToolSieveChunk(state, chunk, toolNames) {
if (Array.isArray(consumed.calls) && consumed.calls.length > 0) { if (Array.isArray(consumed.calls) && consumed.calls.length > 0) {
state.pendingToolRaw = captured; state.pendingToolRaw = captured;
state.pendingToolCalls = consumed.calls; state.pendingToolCalls = consumed.calls;
if (consumed.suffix) {
state.pending = consumed.suffix + state.pending;
}
continue; continue;
} }
if (consumed.prefix) { if (consumed.prefix) {
@@ -165,19 +168,34 @@ function findToolSegmentStart(s) {
return -1; return -1;
} }
const lower = s.toLowerCase(); const lower = s.toLowerCase();
const keywords = ['tool_calls', 'function.name:', '[tool_call_history]'];
let offset = 0; let offset = 0;
// eslint-disable-next-line no-constant-condition // eslint-disable-next-line no-constant-condition
while (true) { while (true) {
const keyIdx = lower.indexOf('tool_calls', offset); let bestKeyIdx = -1;
if (keyIdx < 0) { let matchedKeyword = '';
for (const kw of keywords) {
const idx = lower.indexOf(kw, offset);
if (idx >= 0) {
if (bestKeyIdx < 0 || idx < bestKeyIdx) {
bestKeyIdx = idx;
matchedKeyword = kw;
}
}
}
if (bestKeyIdx < 0) {
return -1; return -1;
} }
const keyIdx = bestKeyIdx;
const start = s.slice(0, keyIdx).lastIndexOf('{'); const start = s.slice(0, keyIdx).lastIndexOf('{');
const candidateStart = start >= 0 ? start : keyIdx; const candidateStart = start >= 0 ? start : keyIdx;
if (!insideCodeFence(s.slice(0, candidateStart))) { if (!insideCodeFence(s.slice(0, candidateStart))) {
return candidateStart; return candidateStart;
} }
offset = keyIdx + 'tool_calls'.length; offset = keyIdx + matchedKeyword.length;
} }
} }
@@ -187,20 +205,28 @@ function consumeToolCapture(state, toolNames) {
return { ready: false, prefix: '', calls: [], suffix: '' }; return { ready: false, prefix: '', calls: [], suffix: '' };
} }
const lower = captured.toLowerCase(); const lower = captured.toLowerCase();
const keyIdx = lower.indexOf('tool_calls');
let keyIdx = -1;
const keywords = ['tool_calls', 'function.name:', '[tool_call_history]'];
for (const kw of keywords) {
const idx = lower.indexOf(kw);
if (idx >= 0 && (keyIdx < 0 || idx < keyIdx)) {
keyIdx = idx;
}
}
if (keyIdx < 0) { if (keyIdx < 0) {
return { ready: false, prefix: '', calls: [], suffix: '' }; return { ready: false, prefix: '', calls: [], suffix: '' };
} }
const start = captured.slice(0, keyIdx).lastIndexOf('{'); const start = captured.slice(0, keyIdx).lastIndexOf('{');
if (start < 0) { const actualStart = start >= 0 ? start : keyIdx;
return { ready: false, prefix: '', calls: [], suffix: '' };
} const obj = extractJSONObjectFrom(captured, actualStart);
const obj = extractJSONObjectFrom(captured, start);
if (!obj.ok) { if (!obj.ok) {
return { ready: false, prefix: '', calls: [], suffix: '' }; return { ready: false, prefix: '', calls: [], suffix: '' };
} }
const prefixPart = captured.slice(0, start); const prefixPart = captured.slice(0, actualStart);
const suffixPart = captured.slice(obj.end); const suffixPart = captured.slice(obj.end);
if (insideCodeFence((state.recentTextTail || '') + prefixPart)) { if (insideCodeFence((state.recentTextTail || '') + prefixPart)) {
@@ -212,16 +238,7 @@ function consumeToolCapture(state, toolNames) {
}; };
} }
if ((state.recentTextTail || '').trim() !== '' || prefixPart.trim() !== '' || suffixPart.trim() !== '') { const parsed = parseStandaloneToolCallsDetailed(captured.slice(actualStart, obj.end), toolNames);
return {
ready: true,
prefix: captured,
calls: [],
suffix: '',
};
}
const parsed = parseStandaloneToolCallsDetailed(captured.slice(start, obj.end), toolNames);
if (!Array.isArray(parsed.calls) || parsed.calls.length === 0) { if (!Array.isArray(parsed.calls) || parsed.calls.length === 0) {
if (parsed.sawToolCallSyntax && parsed.rejectedByPolicy) { if (parsed.sawToolCallSyntax && parsed.rejectedByPolicy) {
return { return {

View File

@@ -20,7 +20,7 @@ func buildToolCallCandidates(text string) []string {
} }
} }
// best-effort extraction around "tool_calls" key in mixed text payloads. // best-effort extraction around tool call keywords in mixed text payloads.
candidates = append(candidates, extractToolCallObjects(trimmed)...) candidates = append(candidates, extractToolCallObjects(trimmed)...)
// best-effort object slice: from first '{' to last '}' // best-effort object slice: from first '{' to last '}'
@@ -57,25 +57,65 @@ func extractToolCallObjects(text string) []string {
lower := strings.ToLower(text) lower := strings.ToLower(text)
out := []string{} out := []string{}
offset := 0 offset := 0
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"}
for { for {
idx := strings.Index(lower[offset:], "tool_calls") bestIdx := -1
if idx < 0 { matchedKeyword := ""
for _, kw := range keywords {
idx := strings.Index(lower[offset:], kw)
if idx >= 0 {
absIdx := offset + idx
if bestIdx < 0 || absIdx < bestIdx {
bestIdx = absIdx
matchedKeyword = kw
}
}
}
if bestIdx < 0 {
break break
} }
idx += offset
start := strings.LastIndex(text[:idx], "{") idx := bestIdx
for start >= 0 { // Avoid backtracking too far to prevent OOM on malicious or very long strings
searchLimit := idx - 2000
if searchLimit < offset {
searchLimit = offset
}
start := strings.LastIndex(text[searchLimit:idx], "{")
if start >= 0 {
start += searchLimit
}
if start < 0 {
offset = idx + len(matchedKeyword)
continue
}
foundObj := false
for start >= searchLimit {
candidate, end, ok := extractJSONObject(text, start) candidate, end, ok := extractJSONObject(text, start)
if ok { if ok {
// Move forward to avoid repeatedly matching the same object. // Move forward to avoid repeatedly matching the same object.
offset = end offset = end
out = append(out, strings.TrimSpace(candidate)) out = append(out, strings.TrimSpace(candidate))
foundObj = true
break break
} }
start = strings.LastIndex(text[:start], "{") // Try previous '{'
if start > searchLimit {
prevStart := strings.LastIndex(text[searchLimit:start], "{")
if prevStart >= 0 {
start = searchLimit + prevStart
continue
}
}
break
} }
if start < 0 {
offset = idx + len("tool_calls") if !foundObj {
offset = idx + len(matchedKeyword)
} }
} }
return out return out
@@ -88,7 +128,12 @@ func extractJSONObject(text string, start int) (string, int, bool) {
depth := 0 depth := 0
quote := byte(0) quote := byte(0)
escaped := false escaped := false
for i := start; i < len(text); i++ { // Limit scan length to avoid OOM on unclosed objects
maxLen := start + 50000
if maxLen > len(text) {
maxLen = len(text)
}
for i := start; i < maxLen; i++ {
ch := text[i] ch := text[i]
if quote != 0 { if quote != 0 {
if escaped { if escaped {

View File

@@ -0,0 +1,106 @@
package util
import (
"encoding/json"
"strings"
"unicode"
)
func parseToolCallInput(v any) map[string]any {
switch x := v.(type) {
case nil:
return map[string]any{}
case map[string]any:
return x
case string:
raw := strings.TrimSpace(x)
if raw == "" {
return map[string]any{}
}
var parsed map[string]any
if err := json.Unmarshal([]byte(raw), &parsed); err == nil && parsed != nil {
repairPathLikeControlChars(parsed)
return parsed
}
// Try to repair invalid backslashes (common in Windows paths output by models)
repaired := repairInvalidJSONBackslashes(raw)
if repaired != raw {
if err := json.Unmarshal([]byte(repaired), &parsed); err == nil && parsed != nil {
return parsed
}
}
// Try to repair loose JSON in string argument as well
repairedLoose := RepairLooseJSON(raw)
if repairedLoose != raw {
if err := json.Unmarshal([]byte(repairedLoose), &parsed); err == nil && parsed != nil {
return parsed
}
}
return map[string]any{"_raw": raw}
default:
b, err := json.Marshal(x)
if err != nil {
return map[string]any{}
}
var parsed map[string]any
if err := json.Unmarshal(b, &parsed); err == nil && parsed != nil {
return parsed
}
return map[string]any{}
}
}
func repairPathLikeControlChars(m map[string]any) {
for k, v := range m {
switch vv := v.(type) {
case map[string]any:
repairPathLikeControlChars(vv)
case []any:
for _, item := range vv {
if child, ok := item.(map[string]any); ok {
repairPathLikeControlChars(child)
}
}
case string:
if isPathLikeKey(k) && containsControlRune(vv) {
m[k] = escapeControlRunes(vv)
}
}
}
}
func isPathLikeKey(key string) bool {
k := strings.ToLower(strings.TrimSpace(key))
return strings.Contains(k, "path") || strings.Contains(k, "file")
}
func containsControlRune(s string) bool {
for _, r := range s {
if unicode.IsControl(r) {
return true
}
}
return false
}
func escapeControlRunes(s string) string {
var b strings.Builder
b.Grow(len(s) + 8)
for _, r := range s {
switch r {
case '\b':
b.WriteString(`\b`)
case '\f':
b.WriteString(`\f`)
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
case '\t':
b.WriteString(`\t`)
default:
b.WriteRune(r)
}
}
return b.String()
}

View File

@@ -0,0 +1,79 @@
package util
import (
"regexp"
"strings"
)
func repairInvalidJSONBackslashes(s string) string {
if !strings.Contains(s, "\\") {
return s
}
var out strings.Builder
out.Grow(len(s) + 10)
runes := []rune(s)
for i := 0; i < len(runes); i++ {
if runes[i] == '\\' {
if i+1 < len(runes) {
next := runes[i+1]
switch next {
case '"', '\\', '/', 'b', 'f', 'n', 'r', 't':
out.WriteRune('\\')
out.WriteRune(next)
i++
continue
case 'u':
if i+5 < len(runes) {
isHex := true
for j := 1; j <= 4; j++ {
r := runes[i+1+j]
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
isHex = false
break
}
}
if isHex {
out.WriteRune('\\')
out.WriteRune('u')
for j := 1; j <= 4; j++ {
out.WriteRune(runes[i+1+j])
}
i += 5
continue
}
}
}
}
// Not a valid escape sequence, double it
out.WriteString("\\\\")
} else {
out.WriteRune(runes[i])
}
}
return out.String()
}
var unquotedKeyPattern = regexp.MustCompile(`([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:`)
// missingArrayBracketsPattern identifies a sequence of two or more JSON objects separated by commas
// that immediately follow a colon, which indicates a missing array bracket `[` `]`.
// E.g., "key": {"a": 1}, {"b": 2} -> "key": [{"a": 1}, {"b": 2}]
// NOTE: The pattern uses (?:[^{}]|\{[^{}]*\})* to support single-level nested {} objects,
// which handles cases like {"content": "x", "input": {"q": "y"}}
var missingArrayBracketsPattern = regexp.MustCompile(`(:\s*)(\{(?:[^{}]|\{[^{}]*\})*\}(?:\s*,\s*\{(?:[^{}]|\{[^{}]*\})*\})+)`)
func RepairLooseJSON(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return s
}
// 1. Replace unquoted keys: {key: -> {"key":
s = unquotedKeyPattern.ReplaceAllString(s, `$1"$2":`)
// 2. Heuristic: Fix missing array brackets for list of objects
// e.g., : {obj1}, {obj2} -> : [{obj1}, {obj2}]
// This specifically addresses DeepSeek's "list hallucination"
s = missingArrayBracketsPattern.ReplaceAllString(s, `$1[$2]`)
return s
}

View File

@@ -16,7 +16,6 @@ type ToolCallParseResult struct {
RejectedByPolicy bool RejectedByPolicy bool
RejectedToolNames []string RejectedToolNames []string
} }
func ParseToolCalls(text string, availableToolNames []string) []ParsedToolCall { func ParseToolCalls(text string, availableToolNames []string) []ParsedToolCall {
return ParseToolCallsDetailed(text, availableToolNames).Calls return ParseToolCallsDetailed(text, availableToolNames).Calls
} }
@@ -26,10 +25,6 @@ func ParseToolCallsDetailed(text string, availableToolNames []string) ToolCallPa
if strings.TrimSpace(text) == "" { if strings.TrimSpace(text) == "" {
return result return result
} }
text = stripFencedCodeBlocks(text)
if strings.TrimSpace(text) == "" {
return result
}
result.SawToolCallSyntax = looksLikeToolCallSyntax(text) result.SawToolCallSyntax = looksLikeToolCallSyntax(text)
candidates := buildToolCallCandidates(text) candidates := buildToolCallCandidates(text)
@@ -68,7 +63,6 @@ func ParseToolCallsDetailed(text string, availableToolNames []string) ToolCallPa
result.RejectedByPolicy = len(rejectedNames) > 0 && len(calls) == 0 result.RejectedByPolicy = len(rejectedNames) > 0 && len(calls) == 0
return result return result
} }
func ParseStandaloneToolCalls(text string, availableToolNames []string) []ParsedToolCall { func ParseStandaloneToolCalls(text string, availableToolNames []string) []ParsedToolCall {
return ParseStandaloneToolCallsDetailed(text, availableToolNames).Calls return ParseStandaloneToolCallsDetailed(text, availableToolNames).Calls
} }
@@ -79,17 +73,15 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string)
if trimmed == "" { if trimmed == "" {
return result return result
} }
if looksLikeToolExampleContext(trimmed) {
return result
}
result.SawToolCallSyntax = looksLikeToolCallSyntax(trimmed) result.SawToolCallSyntax = looksLikeToolCallSyntax(trimmed)
candidates := []string{trimmed} candidates := buildToolCallCandidates(trimmed)
var parsed []ParsedToolCall
for _, candidate := range candidates { for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate) candidate = strings.TrimSpace(candidate)
if candidate == "" { if candidate == "" {
continue continue
} }
parsed := parseToolCallsPayload(candidate) parsed = parseToolCallsPayload(candidate)
if len(parsed) == 0 { if len(parsed) == 0 {
parsed = parseXMLToolCalls(candidate) parsed = parseXMLToolCalls(candidate)
} }
@@ -100,14 +92,23 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string)
parsed = parseTextKVToolCalls(candidate) parsed = parseTextKVToolCalls(candidate)
} }
if len(parsed) > 0 { if len(parsed) > 0 {
result.SawToolCallSyntax = true break
calls, rejectedNames := filterToolCallsDetailed(parsed, availableToolNames)
result.Calls = calls
result.RejectedToolNames = rejectedNames
result.RejectedByPolicy = len(rejectedNames) > 0 && len(calls) == 0
return result
} }
} }
if len(parsed) == 0 {
parsed = parseXMLToolCalls(trimmed)
if len(parsed) == 0 {
parsed = parseTextKVToolCalls(trimmed)
if len(parsed) == 0 {
return result
}
}
}
result.SawToolCallSyntax = true
calls, rejectedNames := filterToolCallsDetailed(parsed, availableToolNames)
result.Calls = calls
result.RejectedToolNames = rejectedNames
result.RejectedByPolicy = len(rejectedNames) > 0 && len(calls) == 0
return result return result
} }
@@ -171,7 +172,13 @@ func resolveAllowedToolName(name string, allowed map[string]struct{}, allowedCan
func parseToolCallsPayload(payload string) []ParsedToolCall { func parseToolCallsPayload(payload string) []ParsedToolCall {
var decoded any var decoded any
if err := json.Unmarshal([]byte(payload), &decoded); err != nil { if err := json.Unmarshal([]byte(payload), &decoded); err != nil {
return nil // Try to repair backslashes first! Because LLMs often mix these two problems.
repaired := repairInvalidJSONBackslashes(payload)
// Try loose repair on top of that
repaired = RepairLooseJSON(repaired)
if err := json.Unmarshal([]byte(repaired), &decoded); err != nil {
return nil
}
} }
switch v := decoded.(type) { switch v := decoded.(type) {
case map[string]any: case map[string]any:
@@ -248,32 +255,3 @@ func parseToolCallItem(m map[string]any) (ParsedToolCall, bool) {
Input: parseToolCallInput(inputRaw), Input: parseToolCallInput(inputRaw),
}, true }, true
} }
func parseToolCallInput(v any) map[string]any {
switch x := v.(type) {
case nil:
return map[string]any{}
case map[string]any:
return x
case string:
raw := strings.TrimSpace(x)
if raw == "" {
return map[string]any{}
}
var parsed map[string]any
if err := json.Unmarshal([]byte(raw), &parsed); err == nil && parsed != nil {
return parsed
}
return map[string]any{"_raw": raw}
default:
b, err := json.Marshal(x)
if err != nil {
return map[string]any{}
}
var parsed map[string]any
if err := json.Unmarshal(b, &parsed); err == nil && parsed != nil {
return parsed
}
return map[string]any{}
}
}

View File

@@ -1,6 +1,9 @@
package util package util
import "testing" import (
"strings"
"testing"
)
func TestParseToolCalls(t *testing.T) { func TestParseToolCalls(t *testing.T) {
text := `prefix {"tool_calls":[{"name":"search","input":{"q":"golang"}}]} suffix` text := `prefix {"tool_calls":[{"name":"search","input":{"q":"golang"}}]} suffix`
@@ -19,8 +22,8 @@ func TestParseToolCalls(t *testing.T) {
func TestParseToolCallsFromFencedJSON(t *testing.T) { func TestParseToolCallsFromFencedJSON(t *testing.T) {
text := "I will call tools now\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"news\"}}]}\n```" text := "I will call tools now\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"news\"}}]}\n```"
calls := ParseToolCalls(text, []string{"search"}) calls := ParseToolCalls(text, []string{"search"})
if len(calls) != 0 { if len(calls) != 1 {
t.Fatalf("expected fenced tool_call example to be ignored, got %#v", calls) t.Fatalf("expected fenced tool_call payload to be parsed, got %#v", calls)
} }
} }
@@ -96,10 +99,10 @@ func TestFormatOpenAIToolCalls(t *testing.T) {
} }
} }
func TestParseStandaloneToolCallsOnlyMatchesStandalonePayload(t *testing.T) { func TestParseStandaloneToolCallsSupportsMixedProsePayload(t *testing.T) {
mixed := `这里是示例:{"tool_calls":[{"name":"search","input":{"q":"go"}}]}` mixed := `这里是示例:{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`
if calls := ParseStandaloneToolCalls(mixed, []string{"search"}); len(calls) != 0 { if calls := ParseStandaloneToolCalls(mixed, []string{"search"}); len(calls) != 1 {
t.Fatalf("expected standalone parser to ignore mixed prose, got %#v", calls) t.Fatalf("expected standalone parser to parse mixed prose payload, got %#v", calls)
} }
standalone := `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}` standalone := `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`
@@ -109,10 +112,10 @@ func TestParseStandaloneToolCallsOnlyMatchesStandalonePayload(t *testing.T) {
} }
} }
func TestParseStandaloneToolCallsIgnoresFencedCodeBlock(t *testing.T) { func TestParseStandaloneToolCallsParsesFencedCodeBlock(t *testing.T) {
fenced := "```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```" fenced := "```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```"
if calls := ParseStandaloneToolCalls(fenced, []string{"search"}); len(calls) != 0 { if calls := ParseStandaloneToolCalls(fenced, []string{"search"}); len(calls) != 1 {
t.Fatalf("expected fenced tool_call example to be ignored, got %#v", calls) t.Fatalf("expected fenced tool_call payload to be parsed, got %#v", calls)
} }
} }
@@ -279,3 +282,238 @@ func TestParseToolCallsDoesNotAcceptMismatchedMarkupTags(t *testing.T) {
t.Fatalf("expected mismatched tags to be rejected, got %#v", calls) t.Fatalf("expected mismatched tags to be rejected, got %#v", calls)
} }
} }
func TestRepairInvalidJSONBackslashes(t *testing.T) {
tests := []struct {
input string
expected string
}{
{`{"path": "C:\Users\name"}`, `{"path": "C:\\Users\name"}`},
{`{"cmd": "cd D:\git_codes"}`, `{"cmd": "cd D:\\git_codes"}`},
{`{"text": "line1\nline2"}`, `{"text": "line1\nline2"}`},
{`{"path": "D:\\back\\slash"}`, `{"path": "D:\\back\\slash"}`},
{`{"unicode": "\u2705"}`, `{"unicode": "\u2705"}`},
{`{"invalid_u": "\u123"}`, `{"invalid_u": "\\u123"}`},
}
for _, tt := range tests {
got := repairInvalidJSONBackslashes(tt.input)
if got != tt.expected {
t.Errorf("repairInvalidJSONBackslashes(%s) = %s; want %s", tt.input, got, tt.expected)
}
}
}
func TestRepairLooseJSON(t *testing.T) {
tests := []struct {
input string
expected string
}{
{`{tool_calls: [{"name": "search", "input": {"q": "go"}}]}`, `{"tool_calls": [{"name": "search", "input": {"q": "go"}}]}`},
{`{name: "search", input: {q: "go"}}`, `{"name": "search", "input": {"q": "go"}}`},
}
for _, tt := range tests {
got := RepairLooseJSON(tt.input)
if got != tt.expected {
t.Errorf("RepairLooseJSON(%s) = %s; want %s", tt.input, got, tt.expected)
}
}
}
func TestParseToolCallsWithUnquotedKeys(t *testing.T) {
text := `这里是列表:{tool_calls: [{"name": "todowrite", "input": {"todos": "test"}}]}`
availableTools := []string{"todowrite"}
parsed := ParseToolCalls(text, availableTools)
if len(parsed) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(parsed))
}
if parsed[0].Name != "todowrite" {
t.Errorf("expected tool todowrite, got %s", parsed[0].Name)
}
}
func TestParseToolCallsWithInvalidBackslashes(t *testing.T) {
// DeepSeek sometimes outputs Windows paths with single backslashes in JSON strings
// Note: using raw string to simulate what AI actually sends in the stream
text := `好的,执行以下命令:{"name": "execute_command", "input": "{\"command\": \"cd D:\git_codes && dir\"}"}`
availableTools := []string{"execute_command"}
parsed := ParseToolCalls(text, availableTools)
// If standard JSON fails, buildToolCallCandidates should still extract the object,
// and parseToolCallsPayload should repair it.
if len(parsed) != 1 {
// If it still fails, let's see why
candidates := buildToolCallCandidates(text)
t.Logf("Candidates: %v", candidates)
t.Fatalf("expected 1 tool call, got %d", len(parsed))
}
cmd, ok := parsed[0].Input["command"].(string)
if !ok {
t.Fatalf("expected command string in input, got %v", parsed[0].Input)
}
expected := "cd D:\\git_codes && dir"
if cmd != expected {
t.Errorf("expected command %q, got %q", expected, cmd)
}
}
func TestParseToolCallsWithDeepSeekHallucination(t *testing.T) {
// 模拟 DeepSeek 典型的幻觉输出:未加引号的键名 + 包含 Windows 路径的嵌套 JSON 字符串 + 漏掉列表的方括号
text := `检测到实施意图——实现经典算法。需在misc/目录创建Python文件。
关键约束:
1. Windows UTF-8编码处理
2. 必须用绝对路径导入
3. 禁止write覆盖已有文件misc/目录允许创建新文件)
将任务分解并委托:
- 研究8皇后算法模式并行探索
- 实现带可视化输出的解决方案unspecified-high
先创建todo列表追踪步骤。
{tool_calls: [{"name": "todowrite", "input": {"todos": {"content": "研究8皇后问题算法模式回溯法和输出格式", "status": "pending", "priority": "high"}, {"content": "在misc/目录创建8皇后Python脚本包含完整解决方案和可视化输出", "status": "pending", "priority": "high"}, {"content": "验证脚本正确性(运行测试)", "status": "pending", "priority": "medium"}}}]}`
availableTools := []string{"todowrite"}
parsed := ParseToolCalls(text, availableTools)
if len(parsed) != 1 {
cands := buildToolCallCandidates(text)
for i, c := range cands {
t.Logf("CAND %d: %s", i, c)
repaired := RepairLooseJSON(c)
t.Logf(" REPAIRED: %s", repaired)
}
t.Fatalf("expected 1 tool call, got %d. Candidates: %v", len(parsed), buildToolCallCandidates(text))
}
if parsed[0].Name != "todowrite" {
t.Errorf("expected tool name 'todowrite', got %q", parsed[0].Name)
}
todos, ok := parsed[0].Input["todos"].([]any)
if !ok {
t.Fatalf("expected 'todos' to be parsed as a list, got %T: %#v", parsed[0].Input["todos"], parsed[0].Input["todos"])
}
if len(todos) != 3 {
t.Errorf("expected 3 todo items, got %d", len(todos))
}
}
func TestParseToolCallsWithMixedWindowsPaths(t *testing.T) {
// 更复杂的案例:嵌套 JSON 字符串中的反斜杠未转义
text := `关键约束: 1. Windows UTF-8编码处理 2. 必须用绝对路径导入 D:\git_codes\ds2api\misc
{tool_calls: [{"name": "write_file", "input": "{\"path\": \"D:\\git_codes\\ds2api\\misc\\queens.py\", \"content\": \"print('hello')\"}"}]}`
availableTools := []string{"write_file"}
parsed := ParseToolCalls(text, availableTools)
if len(parsed) != 1 {
t.Fatalf("expected 1 tool call from mixed text with paths, got %d", len(parsed))
}
path, _ := parsed[0].Input["path"].(string)
// 在解析后的 Go map 中,反斜杠应该被还原
if !strings.Contains(path, "D:\\git_codes") && !strings.Contains(path, "D:/git_codes") {
t.Errorf("expected path to contain Windows style separators, got %q", path)
}
}
func TestParseToolCallInputRepairsControlCharsInPath(t *testing.T) {
in := `{"path":"D:\tmp\new\readme.txt","content":"line1\nline2"}`
parsed := parseToolCallInput(in)
path, ok := parsed["path"].(string)
if !ok {
t.Fatalf("expected path string in parsed input, got %#v", parsed["path"])
}
if path != `D:\tmp\new\readme.txt` {
t.Fatalf("expected repaired windows path, got %q", path)
}
content, ok := parsed["content"].(string)
if !ok {
t.Fatalf("expected content string in parsed input, got %#v", parsed["content"])
}
if content != "line1\nline2" {
t.Fatalf("expected non-path field to keep decoded escapes, got %q", content)
}
}
func TestRepairLooseJSONWithNestedObjects(t *testing.T) {
// 测试嵌套对象的修复DeepSeek 幻觉输出,每个元素内部包含嵌套 {}
// 注意:正则只支持单层嵌套,不支持更深层次的嵌套
tests := []struct {
name string
input string
expected string
}{
// 1. 单层嵌套对象(核心修复目标)
{
name: "单层嵌套 - 2个元素",
input: `"todos": {"content": "研究算法", "input": {"q": "8 queens"}}, {"content": "实现", "input": {"path": "queens.py"}}`,
expected: `"todos": [{"content": "研究算法", "input": {"q": "8 queens"}}, {"content": "实现", "input": {"path": "queens.py"}}]`,
},
// 2. 3个单层嵌套对象
{
name: "3个单层嵌套对象",
input: `"items": {"a": {"x":1}}, {"b": {"y":2}}, {"c": {"z":3}}`,
expected: `"items": [{"a": {"x":1}}, {"b": {"y":2}}, {"c": {"z":3}}]`,
},
// 3. 混合嵌套:有些字段是对象,有些是原始值
{
name: "混合嵌套 - 对象和原始值混合",
input: `"items": {"name": "test", "config": {"timeout": 30}}, {"name": "test2", "config": {"timeout": 60}}`,
expected: `"items": [{"name": "test", "config": {"timeout": 30}}, {"name": "test2", "config": {"timeout": 60}}]`,
},
// 4. 4个嵌套对象边界测试
{
name: "4个嵌套对象",
input: `"todos": {"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}`,
expected: `"todos": [{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}]`,
},
// 5. DeepSeek 典型幻觉:无空格逗号分隔
{
name: "无空格逗号分隔",
input: `"results": {"name": "a"}, {"name": "b"}, {"name": "c"}`,
expected: `"results": [{"name": "a"}, {"name": "b"}, {"name": "c"}]`,
},
// 6. 嵌套数组(数组在对象内,不是深层嵌套)
{
name: "对象内包含数组",
input: `"data": {"items": [1,2,3]}, {"items": [4,5,6]}`,
expected: `"data": [{"items": [1,2,3]}, {"items": [4,5,6]}]`,
},
// 7. 真实的 DeepSeek 8皇后问题输出
{
name: "DeepSeek 8皇后真实输出",
input: `"todos": {"content": "研究8皇后算法", "status": "pending"}, {"content": "实现Python脚本", "status": "pending"}, {"content": "验证结果", "status": "pending"}`,
expected: `"todos": [{"content": "研究8皇后算法", "status": "pending"}, {"content": "实现Python脚本", "status": "pending"}, {"content": "验证结果", "status": "pending"}]`,
},
// 8. 简单无嵌套对象(回归测试)
{
name: "简单无嵌套对象",
input: `"items": {"a": 1}, {"b": 2}`,
expected: `"items": [{"a": 1}, {"b": 2}]`,
},
// 9. 更复杂的单层嵌套
{
name: "复杂单层嵌套",
input: `"functions": {"name": "execute", "input": {"command": "ls"}}, {"name": "read", "input": {"file": "a.txt"}}`,
expected: `"functions": [{"name": "execute", "input": {"command": "ls"}}, {"name": "read", "input": {"file": "a.txt"}}]`,
},
// 10. 5个嵌套对象
{
name: "5个嵌套对象",
input: `"tasks": {"id":1}, {"id":2}, {"id":3}, {"id":4}, {"id":5}`,
expected: `"tasks": [{"id":1}, {"id":2}, {"id":3}, {"id":4}, {"id":5}]`,
},
}
for _, tt := range tests {
got := RepairLooseJSON(tt.input)
if got != tt.expected {
t.Errorf("[%s] RepairLooseJSON with nested objects:\n input: %s\n got: %s\n expected: %s", tt.name, tt.input, got, tt.expected)
}
}
}

View File

@@ -409,8 +409,8 @@ func TestParseToolCallsWithFunctionWrapper(t *testing.T) {
func TestParseStandaloneToolCallsFencedCodeBlock(t *testing.T) { func TestParseStandaloneToolCallsFencedCodeBlock(t *testing.T) {
fenced := "Here's an example:\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```\nDon't execute this." fenced := "Here's an example:\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```\nDon't execute this."
calls := ParseStandaloneToolCalls(fenced, []string{"search"}) calls := ParseStandaloneToolCalls(fenced, []string{"search"})
if len(calls) != 0 { if len(calls) != 1 {
t.Fatalf("expected fenced code block ignored, got %d calls", len(calls)) t.Fatalf("expected fenced code block to be parsed, got %d calls", len(calls))
} }
} }

View File

@@ -1,6 +1,8 @@
# Line gate targets for large-file decoupling refactor. # Line gate targets for large-file decoupling refactor.
# Default limit: 300 lines # Backend default limit: 300 lines
# Frontend (webui/) default limit: 500 lines
# Entry/facade limit: 120 lines (enforced in script) # Entry/facade limit: 120 lines (enforced in script)
# Test files are ignored by the gate script.
internal/config/config.go internal/config/config.go
internal/config/logger.go internal/config/logger.go

View File

@@ -2,10 +2,11 @@
## Rules ## Rules
1. Production file default upper bound: `<= 300` lines. 1. Backend production files upper bound: `<= 300` lines.
2. Entry/facade files upper bound: `<= 120` lines. 2. Frontend (`webui/`) production files upper bound: `<= 500` lines.
3. Scope is limited to target files in `plans/refactor-line-gate-targets.txt`. 3. Entry/facade files upper bound: `<= 120` lines.
4. Test files are out of scope for this gate. 4. Scope is limited to target files in `plans/refactor-line-gate-targets.txt`.
5. Test files are out of scope for this gate.
## Command ## Command

View File

@@ -1,6 +1,13 @@
{ {
"calls": [], "calls": [
"sawToolCallSyntax": false, {
"name": "read_file",
"input": {
"path": "README.MD"
}
}
],
"sawToolCallSyntax": true,
"rejectedByPolicy": false, "rejectedByPolicy": false,
"rejectedToolNames": [] "rejectedToolNames": []
} }

View File

@@ -1,6 +1,13 @@
{ {
"calls": [], "calls": [
"sawToolCallSyntax": false, {
"name": "read_file",
"input": {
"path": "README.MD"
}
}
],
"sawToolCallSyntax": true,
"rejectedByPolicy": false, "rejectedByPolicy": false,
"rejectedToolNames": [] "rejectedToolNames": []
} }

View File

@@ -1,5 +1,12 @@
{ {
"calls": [], "calls": [
{
"name": "read_file",
"input": {
"path": "README.MD"
}
}
],
"sawToolCallSyntax": true, "sawToolCallSyntax": true,
"rejectedByPolicy": false, "rejectedByPolicy": false,
"rejectedToolNames": [] "rejectedToolNames": []

View File

@@ -91,7 +91,9 @@ test('parseToolCalls supports fenced json and function.arguments string payload'
'```', '```',
].join('\n'); ].join('\n');
const calls = parseToolCalls(text, ['read_file']); const calls = parseToolCalls(text, ['read_file']);
assert.equal(calls.length, 0); assert.equal(calls.length, 1);
assert.equal(calls[0].name, 'read_file');
assert.equal(calls[0].input.path, 'README.md');
}); });
test('parseToolCalls parses text-kv fallback payload', () => { test('parseToolCalls parses text-kv fallback payload', () => {
@@ -122,19 +124,19 @@ test('parseToolCalls parses multiple text-kv fallback payloads', () => {
assert.equal(calls[1].name, 'bash'); assert.equal(calls[1].name, 'bash');
}); });
test('parseStandaloneToolCalls only matches standalone payload and ignores mixed prose', () => { test('parseStandaloneToolCalls parses mixed prose payload', () => {
const mixed = '这里是示例:{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]},请勿执行。'; const mixed = '这里是示例:{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]},请勿执行。';
const standalone = '{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}'; const standalone = '{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}';
const mixedCalls = parseStandaloneToolCalls(mixed, ['read_file']); const mixedCalls = parseStandaloneToolCalls(mixed, ['read_file']);
const standaloneCalls = parseStandaloneToolCalls(standalone, ['read_file']); const standaloneCalls = parseStandaloneToolCalls(standalone, ['read_file']);
assert.equal(mixedCalls.length, 0); assert.equal(mixedCalls.length, 1);
assert.equal(standaloneCalls.length, 1); assert.equal(standaloneCalls.length, 1);
}); });
test('parseStandaloneToolCalls ignores fenced code block tool_call examples', () => { test('parseStandaloneToolCalls parses fenced code block tool_call payload', () => {
const fenced = ['```json', '{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}', '```'].join('\n'); const fenced = ['```json', '{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}', '```'].join('\n');
const calls = parseStandaloneToolCalls(fenced, ['read_file']); const calls = parseStandaloneToolCalls(fenced, ['read_file']);
assert.equal(calls.length, 0); assert.equal(calls.length, 1);
}); });
@@ -259,28 +261,28 @@ test('sieve emits final tool_calls for split arguments payload without increment
assert.deepEqual(finalCalls[0].input, { path: 'README.MD', mode: 'head' }); assert.deepEqual(finalCalls[0].input, { path: 'README.MD', mode: 'head' });
}); });
test('sieve keeps tool json as text when leading prose exists (strict mode)', () => { test('sieve still emits tool_calls when leading prose exists before tool json', () => {
const events = runSieve( const events = runSieve(
['我将调用工具。', '{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}'], ['我将调用工具。', '{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}'],
['read_file'], ['read_file'],
); );
const hasTool = events.some((evt) => (evt.type === 'tool_calls' && evt.calls?.length > 0) || (evt.type === 'tool_call_deltas' && evt.deltas?.length > 0)); const hasTool = events.some((evt) => (evt.type === 'tool_calls' && evt.calls?.length > 0) || (evt.type === 'tool_call_deltas' && evt.deltas?.length > 0));
const leakedText = collectText(events); const leakedText = collectText(events);
assert.equal(hasTool, false); assert.equal(hasTool, true);
assert.equal(leakedText.includes('我将调用工具。'), true); assert.equal(leakedText.includes('我将调用工具。'), true);
assert.equal(leakedText.toLowerCase().includes('tool_calls'), true); assert.equal(leakedText.toLowerCase().includes('tool_calls'), false);
}); });
test('sieve keeps same-chunk trailing prose payload as text in strict mode', () => { test('sieve emits tool_calls and keeps trailing prose when payload and prose share a chunk', () => {
const events = runSieve( const events = runSieve(
['{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}然后继续解释。'], ['{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}然后继续解释。'],
['read_file'], ['read_file'],
); );
const hasTool = events.some((evt) => (evt.type === 'tool_calls' && evt.calls?.length > 0) || (evt.type === 'tool_call_deltas' && evt.deltas?.length > 0)); const hasTool = events.some((evt) => (evt.type === 'tool_calls' && evt.calls?.length > 0) || (evt.type === 'tool_call_deltas' && evt.deltas?.length > 0));
const leakedText = collectText(events); const leakedText = collectText(events);
assert.equal(hasTool, false); assert.equal(hasTool, true);
assert.equal(leakedText.includes('然后继续解释。'), true); assert.equal(leakedText.includes('然后继续解释。'), true);
assert.equal(leakedText.toLowerCase().includes('tool_calls'), true); assert.equal(leakedText.toLowerCase().includes('tool_calls'), false);
}); });
test('formatOpenAIStreamToolCalls reuses ids with the same idStore', () => { test('formatOpenAIStreamToolCalls reuses ids with the same idStore', () => {

77
tests/repair_json_tool.go Normal file
View File

@@ -0,0 +1,77 @@
package main
import (
"fmt"
"strings"
)
func repairInvalidJSONBackslashes(s string) string {
if !strings.Contains(s, "\\") {
return s
}
var out strings.Builder
out.Grow(len(s) + 10)
runes := []rune(s)
for i := 0; i < len(runes); i++ {
if runes[i] == '\\' {
if i+1 < len(runes) {
next := runes[i+1]
switch next {
case '"', '\\', '/', 'b', 'f', 'n', 'r', 't':
out.WriteRune('\\')
out.WriteRune(next)
i++
continue
case 'u':
if i+5 < len(runes) {
isHex := true
for j := 1; j <= 4; j++ {
r := runes[i+1+j]
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
isHex = false
break
}
}
if isHex {
out.WriteRune('\\')
out.WriteRune('u')
for j := 1; j <= 4; j++ {
out.WriteRune(runes[i+1+j])
}
i += 5
continue
}
}
}
}
// Not a valid escape sequence, double it
out.WriteString("\\\\")
} else {
out.WriteRune(runes[i])
}
}
return out.String()
}
func main() {
tests := []struct {
input string
expected string
}{
{`{"path": "C:\Users\name"}`, `{"path": "C:\\Users\\name"}`},
{`{"cmd": "cd D:\git_codes"}`, `{"cmd": "cd D:\\git_codes"}`},
{`{"text": "line1\nline2"}`, `{"text": "line1\nline2"}`},
{`{"path": "D:\\back\\slash"}`, `{"path": "D:\\back\\slash"}`},
{`{"unicode": "\u2705"}`, `{"unicode": "\u2705"}`},
{`{"invalid_u": "\u123"}`, `{"invalid_u": "\\u123"}`},
}
for _, tt := range tests {
got := repairInvalidJSONBackslashes(tt.input)
if got != tt.expected {
fmt.Printf("FAIL: input=%s\n got=%s\n exp=%s\n", tt.input, got, tt.expected)
} else {
fmt.Printf("PASS: input=%s\n", tt.input)
}
}
}

View File

@@ -5,6 +5,7 @@ ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
TARGETS_FILE="$ROOT_DIR/plans/refactor-line-gate-targets.txt" TARGETS_FILE="$ROOT_DIR/plans/refactor-line-gate-targets.txt"
DEFAULT_MAX=300 DEFAULT_MAX=300
FRONTEND_MAX=500
ENTRY_MAX=120 ENTRY_MAX=120
is_entry_file() { is_entry_file() {
@@ -22,6 +23,27 @@ is_entry_file() {
return 1 return 1
} }
is_frontend_file() {
[[ "$1" == webui/* ]]
}
is_test_file() {
local file="$1"
local base
base="$(basename "$file")"
[[ "$file" == tests/* ]] && return 0
[[ "$file" == */tests/* ]] && return 0
[[ "$file" == */__tests__/* ]] && return 0
[[ "$base" == *_test.go ]] && return 0
[[ "$base" == *.test.js ]] && return 0
[[ "$base" == *.test.jsx ]] && return 0
[[ "$base" == *.test.ts ]] && return 0
[[ "$base" == *.test.tsx ]] && return 0
return 1
}
if [[ ! -f "$TARGETS_FILE" ]]; then if [[ ! -f "$TARGETS_FILE" ]]; then
echo "missing targets file: $TARGETS_FILE" >&2 echo "missing targets file: $TARGETS_FILE" >&2
exit 1 exit 1
@@ -35,6 +57,10 @@ while IFS= read -r file; do
[[ -z "$file" ]] && continue [[ -z "$file" ]] && continue
[[ "${file:0:1}" == "#" ]] && continue [[ "${file:0:1}" == "#" ]] && continue
if is_test_file "$file"; then
continue
fi
checked=$((checked + 1)) checked=$((checked + 1))
abs="$ROOT_DIR/$file" abs="$ROOT_DIR/$file"
if [[ ! -f "$abs" ]]; then if [[ ! -f "$abs" ]]; then
@@ -47,6 +73,8 @@ while IFS= read -r file; do
limit="$DEFAULT_MAX" limit="$DEFAULT_MAX"
if is_entry_file "$file"; then if is_entry_file "$file"; then
limit="$ENTRY_MAX" limit="$ENTRY_MAX"
elif is_frontend_file "$file"; then
limit="$FRONTEND_MAX"
fi fi
if (( lines > limit )); then if (( lines > limit )); then

View File

@@ -43,12 +43,15 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
testing, testing,
testingAll, testingAll,
batchProgress, batchProgress,
sessionCounts,
deletingSessions,
addKey, addKey,
deleteKey, deleteKey,
addAccount, addAccount,
deleteAccount, deleteAccount,
testAccount, testAccount,
testAllAccounts, testAllAccounts,
deleteAllSessions,
} = useAccountActions({ } = useAccountActions({
apiFetch, apiFetch,
t, t,
@@ -81,6 +84,8 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
testing={testing} testing={testing}
testingAll={testingAll} testingAll={testingAll}
batchProgress={batchProgress} batchProgress={batchProgress}
sessionCounts={sessionCounts}
deletingSessions={deletingSessions}
totalAccounts={totalAccounts} totalAccounts={totalAccounts}
page={page} page={page}
pageSize={pageSize} pageSize={pageSize}
@@ -90,6 +95,7 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
onShowAddAccount={() => setShowAddAccount(true)} onShowAddAccount={() => setShowAddAccount(true)}
onTestAccount={testAccount} onTestAccount={testAccount}
onDeleteAccount={deleteAccount} onDeleteAccount={deleteAccount}
onDeleteAllSessions={deleteAllSessions}
onPrevPage={() => fetchAccounts(page - 1)} onPrevPage={() => fetchAccounts(page - 1)}
onNextPage={() => fetchAccounts(page + 1)} onNextPage={() => fetchAccounts(page + 1)}
onPageSizeChange={changePageSize} onPageSizeChange={changePageSize}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { ChevronLeft, ChevronRight, Check, Copy, Play, Plus, Trash2 } from 'lucide-react' import { ChevronLeft, ChevronRight, Check, Copy, Play, Plus, Trash2, FolderX } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
export default function AccountsTable({ export default function AccountsTable({
@@ -9,6 +9,8 @@ export default function AccountsTable({
testing, testing,
testingAll, testingAll,
batchProgress, batchProgress,
sessionCounts,
deletingSessions,
totalAccounts, totalAccounts,
page, page,
pageSize, pageSize,
@@ -18,6 +20,7 @@ export default function AccountsTable({
onShowAddAccount, onShowAddAccount,
onTestAccount, onTestAccount,
onDeleteAccount, onDeleteAccount,
onDeleteAllSessions,
onPrevPage, onPrevPage,
onNextPage, onNextPage,
onPageSizeChange, onPageSizeChange,
@@ -125,6 +128,25 @@ export default function AccountsTable({
{acc.token_preview} {acc.token_preview}
</span> </span>
)} )}
{sessionCounts && sessionCounts[id] !== undefined && (
<span className="font-mono bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded text-[10px]">
{t('accountManager.sessionCount', { count: sessionCounts[id] })}
</span>
)}
{sessionCounts && sessionCounts[id] !== undefined && sessionCounts[id] > 0 && (
<button
onClick={() => onDeleteAllSessions(id)}
disabled={deletingSessions && deletingSessions[id]}
className="flex items-center gap-1 font-mono bg-red-500/10 text-red-500 hover:bg-red-500/20 px-1.5 py-0.5 rounded text-[10px] transition-colors disabled:opacity-50"
title={t('accountManager.deleteAllSessions')}
>
{deletingSessions && deletingSessions[id] ? (
<span className="animate-spin"></span>
) : (
<FolderX className="w-3 h-3" />
)}
</button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -10,6 +10,8 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
const [testing, setTesting] = useState({}) const [testing, setTesting] = useState({})
const [testingAll, setTestingAll] = useState(false) const [testingAll, setTestingAll] = useState(false)
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, results: [] }) const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, results: [] })
const [sessionCounts, setSessionCounts] = useState({})
const [deletingSessions, setDeletingSessions] = useState({})
const addKey = async () => { const addKey = async () => {
if (!newKey.trim()) return if (!newKey.trim()) return
@@ -115,6 +117,12 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
body: JSON.stringify({ identifier: accountID }), body: JSON.stringify({ identifier: accountID }),
}) })
const data = await res.json() const data = await res.json()
// 更新会话数
if (data.session_count !== undefined) {
setSessionCounts(prev => ({ ...prev, [accountID]: data.session_count }))
}
const statusMessage = data.success const statusMessage = data.success
? t('apiTester.testSuccess', { account: accountID, time: data.response_time }) ? t('apiTester.testSuccess', { account: accountID, time: data.response_time })
: `${accountID}: ${data.message}` : `${accountID}: ${data.message}`
@@ -170,6 +178,41 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
setTestingAll(false) setTestingAll(false)
} }
const deleteAllSessions = async (identifier) => {
const accountID = String(identifier || '').trim()
if (!accountID) {
onMessage('error', t('accountManager.invalidIdentifier'))
return
}
if (!confirm(t('accountManager.deleteAllSessionsConfirm'))) return
setDeletingSessions(prev => ({ ...prev, [accountID]: true }))
try {
const res = await apiFetch('/admin/accounts/sessions/delete-all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: accountID }),
})
const data = await res.json()
if (data.success) {
onMessage('success', t('accountManager.deleteAllSessionsSuccess'))
// 清除会话数显示
setSessionCounts(prev => {
const newCounts = { ...prev }
delete newCounts[accountID]
return newCounts
})
} else {
onMessage('error', data.message || t('messages.requestFailed'))
}
} catch (e) {
onMessage('error', t('messages.networkError'))
} finally {
setDeletingSessions(prev => ({ ...prev, [accountID]: false }))
}
}
return { return {
showAddKey, showAddKey,
setShowAddKey, setShowAddKey,
@@ -185,11 +228,14 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
testing, testing,
testingAll, testingAll,
batchProgress, batchProgress,
sessionCounts,
deletingSessions,
addKey, addKey,
deleteKey, deleteKey,
addAccount, addAccount,
deleteAccount, deleteAccount,
testAccount, testAccount,
testAllAccounts, testAllAccounts,
deleteAllSessions,
} }
} }

View File

@@ -0,0 +1,39 @@
import { Trash2 } from 'lucide-react'
export default function AutoDeleteSection({ t, form, setForm }) {
return (
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<div className="flex items-center gap-2">
<Trash2 className="w-4 h-4 text-muted-foreground" />
<h3 className="font-semibold">{t('settings.autoDeleteTitle')}</h3>
</div>
<p className="text-sm text-muted-foreground">{t('settings.autoDeleteDesc')}</p>
<div className="flex items-center justify-between">
<label className="text-sm font-medium">{t('settings.autoDeleteSessions')}</label>
<button
type="button"
role="switch"
aria-checked={form.auto_delete?.sessions || false}
onClick={() => setForm((prev) => ({
...prev,
auto_delete: { ...prev.auto_delete, sessions: !prev.auto_delete?.sessions },
}))}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
form.auto_delete?.sessions ? 'bg-primary' : 'bg-muted'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
form.auto_delete?.sessions ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{form.auto_delete?.sessions && (
<p className="text-xs text-amber-500 flex items-center gap-1">
{t('settings.autoDeleteWarning')}
</p>
)}
</div>
)
}

View File

@@ -5,6 +5,7 @@ import { useSettingsForm } from './useSettingsForm'
import SecuritySection from './SecuritySection' import SecuritySection from './SecuritySection'
import RuntimeSection from './RuntimeSection' import RuntimeSection from './RuntimeSection'
import BehaviorSection from './BehaviorSection' import BehaviorSection from './BehaviorSection'
import AutoDeleteSection from './AutoDeleteSection'
import ModelSection from './ModelSection' import ModelSection from './ModelSection'
import BackupSection from './BackupSection' import BackupSection from './BackupSection'
@@ -91,6 +92,8 @@ export default function SettingsContainer({ onRefresh, onMessage, authFetch, onF
<BehaviorSection t={t} form={form} setForm={setForm} /> <BehaviorSection t={t} form={form} setForm={setForm} />
<AutoDeleteSection t={t} form={form} setForm={setForm} />
<ModelSection t={t} form={form} setForm={setForm} /> <ModelSection t={t} form={form} setForm={setForm} />
<BackupSection <BackupSection

View File

@@ -16,6 +16,7 @@ const DEFAULT_FORM = {
toolcall: { mode: 'feature_match', early_emit_confidence: 'high' }, toolcall: { mode: 'feature_match', early_emit_confidence: 'high' },
responses: { store_ttl_seconds: 900 }, responses: { store_ttl_seconds: 900 },
embeddings: { provider: '' }, embeddings: { provider: '' },
auto_delete: { sessions: false },
claude_mapping_text: '{\n "fast": "deepseek-chat",\n "slow": "deepseek-reasoner"\n}', claude_mapping_text: '{\n "fast": "deepseek-chat",\n "slow": "deepseek-reasoner"\n}',
model_aliases_text: '{}', model_aliases_text: '{}',
} }
@@ -55,6 +56,9 @@ function fromServerForm(data) {
embeddings: { embeddings: {
provider: data.embeddings?.provider || '', provider: data.embeddings?.provider || '',
}, },
auto_delete: {
sessions: Boolean(data.auto_delete?.sessions || false),
},
claude_mapping_text: JSON.stringify(data.claude_mapping || {}, null, 2), claude_mapping_text: JSON.stringify(data.claude_mapping || {}, null, 2),
model_aliases_text: JSON.stringify(data.model_aliases || {}, null, 2), model_aliases_text: JSON.stringify(data.model_aliases || {}, null, 2),
} }
@@ -74,6 +78,7 @@ function toServerPayload(form) {
}, },
responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) }, responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) },
embeddings: { provider: String(form.embeddings.provider || '').trim() }, embeddings: { provider: String(form.embeddings.provider || '').trim() },
auto_delete: { sessions: Boolean(form.auto_delete?.sessions) },
} }
} }

View File

@@ -131,7 +131,11 @@
"addAccountAction": "Add account", "addAccountAction": "Add account",
"pageInfo": "Page {current}/{total}, {count} accounts total", "pageInfo": "Page {current}/{total}, {count} accounts total",
"searchPlaceholder": "Search accounts...", "searchPlaceholder": "Search accounts...",
"searchNoResults": "No accounts match your search" "searchNoResults": "No accounts match your search",
"sessionCount": "Sessions: {count}",
"deleteAllSessions": "Delete all sessions",
"deleteAllSessionsConfirm": "Are you sure you want to delete all sessions for this account? This action cannot be undone.",
"deleteAllSessionsSuccess": "Successfully deleted all sessions"
}, },
"apiTester": { "apiTester": {
"defaultMessage": "Hello, please introduce yourself in one sentence.", "defaultMessage": "Hello, please introduce yourself in one sentence.",
@@ -227,6 +231,10 @@
"modelTitle": "Model mapping", "modelTitle": "Model mapping",
"claudeMapping": "Claude mapping (JSON)", "claudeMapping": "Claude mapping (JSON)",
"modelAliases": "Model aliases (JSON)", "modelAliases": "Model aliases (JSON)",
"autoDeleteTitle": "Auto Delete Sessions",
"autoDeleteDesc": "When enabled, all sessions will be automatically deleted after each request completes.",
"autoDeleteSessions": "Auto delete sessions",
"autoDeleteWarning": "Warning: Enabling this will delete all session history after each request. Use with caution.",
"backupTitle": "Backup & Restore", "backupTitle": "Backup & Restore",
"loadExport": "Load current export", "loadExport": "Load current export",
"importModeMerge": "Merge import (default)", "importModeMerge": "Merge import (default)",

View File

@@ -131,7 +131,11 @@
"addAccountAction": "添加账号", "addAccountAction": "添加账号",
"pageInfo": "第 {current}/{total} 页,共 {count} 个账号", "pageInfo": "第 {current}/{total} 页,共 {count} 个账号",
"searchPlaceholder": "搜索账号...", "searchPlaceholder": "搜索账号...",
"searchNoResults": "未找到匹配的账号" "searchNoResults": "未找到匹配的账号",
"sessionCount": "会话: {count}",
"deleteAllSessions": "删除所有会话",
"deleteAllSessionsConfirm": "确定要删除该账号的所有会话吗?此操作不可恢复。",
"deleteAllSessionsSuccess": "删除成功"
}, },
"apiTester": { "apiTester": {
"defaultMessage": "你好,请用一句话介绍你自己。", "defaultMessage": "你好,请用一句话介绍你自己。",
@@ -227,6 +231,10 @@
"modelTitle": "模型映射", "modelTitle": "模型映射",
"claudeMapping": "Claude 映射JSON", "claudeMapping": "Claude 映射JSON",
"modelAliases": "模型别名JSON", "modelAliases": "模型别名JSON",
"autoDeleteTitle": "自动删除会话",
"autoDeleteDesc": "开启后,每次请求完成后会自动删除该账号的所有会话记录。",
"autoDeleteSessions": "自动删除会话",
"autoDeleteWarning": "开启此功能后,每次请求完成都会删除该账号的所有历史会话,请谨慎使用。",
"backupTitle": "备份与恢复", "backupTitle": "备份与恢复",
"loadExport": "加载当前导出", "loadExport": "加载当前导出",
"importModeMerge": "合并导入(默认)", "importModeMerge": "合并导入(默认)",