From 52e7e7aae81b715628b6985affea7cd135c5453d Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Fri, 20 Mar 2026 00:50:05 +0800 Subject: [PATCH 1/3] fix: unblock line gate and harden pow token recovery --- internal/deepseek/client_auth.go | 48 +++++++++---- internal/deepseek/client_session.go | 30 ++++---- internal/deepseek/client_session_delete.go | 9 ++- internal/util/toolcalls_json_repair.go | 79 ++++++++++++++++++++++ internal/util/toolcalls_parse.go | 74 -------------------- 5 files changed, 133 insertions(+), 107 deletions(-) create mode 100644 internal/util/toolcalls_json_repair.go diff --git a/internal/deepseek/client_auth.go b/internal/deepseek/client_auth.go index efa96ba..bedb75e 100644 --- a/internal/deepseek/client_auth.go +++ b/internal/deepseek/client_auth.go @@ -62,8 +62,8 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte attempts++ continue } - code := intFrom(resp["code"]) - if status == http.StatusOK && code == 0 { + 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) sessionID, _ := bizData["id"].(string) @@ -71,10 +71,9 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte return sessionID, nil } } - msg, _ := resp["msg"].(string) - config.Logger.Warn("[create_session] failed", "status", status, "code", code, "msg", msg, "use_config_token", a.UseConfigToken, "account", a.AccountID) + 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) if a.UseConfigToken { - if isTokenInvalid(status, code, msg) && !refreshed { + if isTokenInvalid(status, code, bizCode, msg, bizMsg) && !refreshed { if c.Auth.RefreshToken(ctx, a) { refreshed = true continue @@ -96,6 +95,7 @@ func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts in maxAttempts = c.maxRetries } attempts := 0 + refreshed := false for attempts < maxAttempts { headers := c.authHeaders(a.DeepSeekToken) 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++ continue } - code := intFrom(resp["code"]) - if status == http.StatusOK && code == 0 { + 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) 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) } - msg, _ := resp["msg"].(string) - config.Logger.Warn("[get_pow] failed", "status", status, "code", code, "msg", msg, "use_config_token", a.UseConfigToken, "account", a.AccountID) + 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) if a.UseConfigToken { - if isTokenInvalid(status, code, msg) { + 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 } @@ -143,15 +144,34 @@ func (c *Client) authHeaders(token string) map[string]string { return headers } -func isTokenInvalid(status int, code int, msg string) bool { - msg = strings.ToLower(msg) +func isTokenInvalid(status int, code int, bizCode int, msg string, bizMsg string) bool { + msg = strings.ToLower(strings.TrimSpace(msg) + " " + strings.TrimSpace(bizMsg)) if status == http.StatusUnauthorized || status == http.StatusForbidden { 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 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) { diff --git a/internal/deepseek/client_session.go b/internal/deepseek/client_session.go index fa422b7..f347ba6 100644 --- a/internal/deepseek/client_session.go +++ b/internal/deepseek/client_session.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/url" + "strings" "ds2api/internal/auth" "ds2api/internal/config" @@ -22,12 +23,12 @@ type SessionInfo struct { // SessionStats 会话统计结果 type SessionStats struct { - AccountID string // 账号标识 (email 或 mobile) - FirstPageCount int // 第一页会话数量(当 HasMore 为 true 时,真实总数可能更大) - PinnedCount int // 置顶会话数量 - HasMore bool // 是否还有更多页 - Success bool // 请求是否成功 - ErrorMessage string // 错误信息 + AccountID string // 账号标识 (email 或 mobile) + FirstPageCount int // 第一页会话数量(当 HasMore 为 true 时,真实总数可能更大) + PinnedCount int // 置顶会话数量 + HasMore bool // 是否还有更多页 + Success bool // 请求是否成功 + ErrorMessage string // 错误信息 } // GetSessionCount 获取单个账号的会话数量 @@ -56,8 +57,8 @@ func (c *Client) GetSessionCount(ctx context.Context, a *auth.RequestAuth, maxAt continue } - code := intFrom(resp["code"]) - if status == http.StatusOK && code == 0 { + 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) @@ -79,12 +80,11 @@ func (c *Client) GetSessionCount(ctx context.Context, a *auth.RequestAuth, maxAt return stats, nil } - msg, _ := resp["msg"].(string) stats.ErrorMessage = fmt.Sprintf("status=%d, code=%d, msg=%s", status, code, msg) - config.Logger.Warn("[get_session_count] failed", "status", status, "code", code, "msg", msg, "account", a.AccountID) + 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, msg) && !refreshed { + if isTokenInvalid(status, code, bizCode, msg, bizMsg) && !refreshed { if c.Auth.RefreshToken(ctx, a) { refreshed = true continue @@ -114,9 +114,11 @@ func (c *Client) GetSessionCountForToken(ctx context.Context, token string) (*Se return nil, err } - code := intFrom(resp["code"]) - if status != http.StatusOK || code != 0 { - msg, _ := resp["msg"].(string) + 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) } diff --git a/internal/deepseek/client_session_delete.go b/internal/deepseek/client_session_delete.go index e4814bf..06e32d1 100644 --- a/internal/deepseek/client_session_delete.go +++ b/internal/deepseek/client_session_delete.go @@ -49,18 +49,17 @@ func (c *Client) DeleteSession(ctx context.Context, a *auth.RequestAuth, session continue } - code := intFrom(resp["code"]) - if status == http.StatusOK && code == 0 { + code, bizCode, msg, bizMsg := extractResponseStatus(resp) + if status == http.StatusOK && code == 0 && bizCode == 0 { result.Success = true return result, nil } - msg, _ := resp["msg"].(string) result.ErrorMessage = fmt.Sprintf("status=%d, code=%d, msg=%s", status, code, msg) - config.Logger.Warn("[delete_session] failed", "status", status, "code", code, "msg", msg, "session_id", sessionID) + 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, msg) && !refreshed { + if isTokenInvalid(status, code, bizCode, msg, bizMsg) && !refreshed { if c.Auth.RefreshToken(ctx, a) { refreshed = true continue diff --git a/internal/util/toolcalls_json_repair.go b/internal/util/toolcalls_json_repair.go new file mode 100644 index 0000000..6585d79 --- /dev/null +++ b/internal/util/toolcalls_json_repair.go @@ -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 +} diff --git a/internal/util/toolcalls_parse.go b/internal/util/toolcalls_parse.go index 4830e97..726009c 100644 --- a/internal/util/toolcalls_parse.go +++ b/internal/util/toolcalls_parse.go @@ -2,7 +2,6 @@ package util import ( "encoding/json" - "regexp" "strings" ) @@ -298,76 +297,3 @@ func parseToolCallInput(v any) map[string]any { return map[string]any{} } } - -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 -} From 2d5103997b849d31bcf0de11b42aa57d3e738e3d Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Fri, 20 Mar 2026 01:15:15 +0800 Subject: [PATCH 2/3] fix(tool-sieve): keep mixed prose tool json in strict text mode --- .../js/helpers/stream-tool-sieve/sieve.js | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/internal/js/helpers/stream-tool-sieve/sieve.js b/internal/js/helpers/stream-tool-sieve/sieve.js index a3b7fd8..ef12e8b 100644 --- a/internal/js/helpers/stream-tool-sieve/sieve.js +++ b/internal/js/helpers/stream-tool-sieve/sieve.js @@ -235,6 +235,17 @@ function consumeToolCapture(state, toolNames) { }; } + // Strict standalone mode: if the stream already produced meaningful prose, + // treat later tool-looking JSON as plain text instead of intercepting. + if ((state.recentTextTail || '').trim() !== '' && prefixPart.trim() === '') { + return { + ready: true, + prefix: captured, + calls: [], + suffix: '', + }; + } + const parsed = parseStandaloneToolCallsDetailed(captured.slice(actualStart, obj.end), toolNames); if (!Array.isArray(parsed.calls) || parsed.calls.length === 0) { if (parsed.sawToolCallSyntax && parsed.rejectedByPolicy) { @@ -253,6 +264,18 @@ function consumeToolCapture(state, toolNames) { }; } + // Strict standalone mode: only intercept when the tool payload stands alone + // (allowing only surrounding whitespace). If there is non-whitespace prose + // before/after the JSON object, keep everything as normal text. + if (prefixPart.trim() !== '' || suffixPart.trim() !== '') { + return { + ready: true, + prefix: captured, + calls: [], + suffix: '', + }; + } + return { ready: true, prefix: prefixPart, From 145501d4a5e5a973b21c29b9b5806e2c4f153f9c Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Fri, 20 Mar 2026 01:15:32 +0800 Subject: [PATCH 3/3] fix(tool-sieve): allow mixed prose + tool json interception --- .../js/helpers/stream-tool-sieve/sieve.js | 26 +++---------------- tests/node/stream-tool-sieve.test.js | 12 ++++----- 2 files changed, 9 insertions(+), 29 deletions(-) diff --git a/internal/js/helpers/stream-tool-sieve/sieve.js b/internal/js/helpers/stream-tool-sieve/sieve.js index ef12e8b..ae95ffd 100644 --- a/internal/js/helpers/stream-tool-sieve/sieve.js +++ b/internal/js/helpers/stream-tool-sieve/sieve.js @@ -46,6 +46,9 @@ function processToolSieveChunk(state, chunk, toolNames) { if (Array.isArray(consumed.calls) && consumed.calls.length > 0) { state.pendingToolRaw = captured; state.pendingToolCalls = consumed.calls; + if (consumed.suffix) { + state.pending = consumed.suffix + state.pending; + } continue; } if (consumed.prefix) { @@ -235,17 +238,6 @@ function consumeToolCapture(state, toolNames) { }; } - // Strict standalone mode: if the stream already produced meaningful prose, - // treat later tool-looking JSON as plain text instead of intercepting. - if ((state.recentTextTail || '').trim() !== '' && prefixPart.trim() === '') { - return { - ready: true, - prefix: captured, - calls: [], - suffix: '', - }; - } - const parsed = parseStandaloneToolCallsDetailed(captured.slice(actualStart, obj.end), toolNames); if (!Array.isArray(parsed.calls) || parsed.calls.length === 0) { if (parsed.sawToolCallSyntax && parsed.rejectedByPolicy) { @@ -264,18 +256,6 @@ function consumeToolCapture(state, toolNames) { }; } - // Strict standalone mode: only intercept when the tool payload stands alone - // (allowing only surrounding whitespace). If there is non-whitespace prose - // before/after the JSON object, keep everything as normal text. - if (prefixPart.trim() !== '' || suffixPart.trim() !== '') { - return { - ready: true, - prefix: captured, - calls: [], - suffix: '', - }; - } - return { ready: true, prefix: prefixPart, diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index 61d72d6..7c703a1 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -259,28 +259,28 @@ test('sieve emits final tool_calls for split arguments payload without increment 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( ['我将调用工具。', '{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}'], ['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 leakedText = collectText(events); - assert.equal(hasTool, false); + assert.equal(hasTool, 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( ['{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}然后继续解释。'], ['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 leakedText = collectText(events); - assert.equal(hasTool, false); + assert.equal(hasTool, 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', () => {