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
This commit is contained in:
CJACK.
2026-03-20 01:16:46 +08:00
committed by GitHub
7 changed files with 142 additions and 113 deletions

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -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', () => {