diff --git a/api/helpers/stream-tool-sieve.js b/api/helpers/stream-tool-sieve.js index 07d8cad..0643ce5 100644 --- a/api/helpers/stream-tool-sieve.js +++ b/api/helpers/stream-tool-sieve.js @@ -129,12 +129,9 @@ function splitSafeContentForToolDetection(s) { if (suspiciousStart > 0) { return [text.slice(0, suspiciousStart), text.slice(suspiciousStart)]; } - const chars = Array.from(text); - const maxHold = 128; - if (chars.length <= maxHold) { - return ['', text]; - } - return [chars.slice(0, chars.length - maxHold).join(''), chars.slice(chars.length - maxHold).join('')]; + // If suspicious content starts at the beginning, keep holding until we can + // either parse a full tool JSON block or reach stream flush. + return ['', text]; } function findSuspiciousPrefixStart(s) { @@ -168,23 +165,14 @@ function consumeToolCapture(captured, toolNames) { const lower = captured.toLowerCase(); const keyIdx = lower.indexOf('tool_calls'); if (keyIdx < 0) { - if (Array.from(captured).length >= 256) { - return { ready: true, prefix: captured, calls: [], suffix: '' }; - } return { ready: false, prefix: '', calls: [], suffix: '' }; } const start = captured.slice(0, keyIdx).lastIndexOf('{'); if (start < 0) { - if (Array.from(captured).length >= 512) { - return { ready: true, prefix: captured, calls: [], suffix: '' }; - } return { ready: false, prefix: '', calls: [], suffix: '' }; } const obj = extractJSONObjectFrom(captured, start); if (!obj.ok) { - if (Array.from(captured).length >= 4096) { - return { ready: true, prefix: captured, calls: [], suffix: '' }; - } return { ready: false, prefix: '', calls: [], suffix: '' }; } const parsed = parseToolCalls(captured.slice(start, obj.end), toolNames); diff --git a/internal/adapter/openai/handler_toolcall_test.go b/internal/adapter/openai/handler_toolcall_test.go index e3cfc7d..9089f69 100644 --- a/internal/adapter/openai/handler_toolcall_test.go +++ b/internal/adapter/openai/handler_toolcall_test.go @@ -416,3 +416,50 @@ func TestHandleStreamToolCallMixedWithPlainTextSegments(t *testing.T) { t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String()) } } + +func TestHandleStreamToolCallKeyAppearsLateStillNoPrefixLeak(t *testing.T) { + h := &Handler{} + spaces := strings.Repeat(" ", 200) + resp := makeSSEHTTPResponse( + `data: {"p":"response/content","v":"{`+spaces+`"}`, + `data: {"p":"response/content","v":"\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}"}`, + `data: {"p":"response/content","v":"后置正文C。"}`, + `data: [DONE]`, + ) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + + h.handleStream(rec, req, resp, "cid8", "deepseek-chat", "prompt", false, false, []string{"search"}) + + frames, done := parseSSEDataFrames(t, rec.Body.String()) + if !done { + t.Fatalf("expected [DONE], body=%s", rec.Body.String()) + } + if !streamHasToolCallsDelta(frames) { + t.Fatalf("expected tool_calls delta, body=%s", rec.Body.String()) + } + if streamHasRawToolJSONContent(frames) { + t.Fatalf("raw tool_calls JSON leaked in content delta: %s", rec.Body.String()) + } + content := strings.Builder{} + for _, frame := range frames { + choices, _ := frame["choices"].([]any) + for _, item := range choices { + choice, _ := item.(map[string]any) + delta, _ := choice["delta"].(map[string]any) + if c, ok := delta["content"].(string); ok { + content.WriteString(c) + } + } + } + got := content.String() + if strings.Contains(got, "{") { + t.Fatalf("unexpected suspicious prefix leak in content: %q", got) + } + if !strings.Contains(got, "后置正文C。") { + t.Fatalf("expected stream to continue after tool json convergence, got=%q", got) + } + if streamFinishReason(frames) != "tool_calls" { + t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String()) + } +} diff --git a/internal/adapter/openai/tool_sieve.go b/internal/adapter/openai/tool_sieve.go index 3fd7262..6790840 100644 --- a/internal/adapter/openai/tool_sieve.go +++ b/internal/adapter/openai/tool_sieve.go @@ -122,12 +122,9 @@ func splitSafeContentForToolDetection(s string) (safe, hold string) { if suspiciousStart > 0 { return s[:suspiciousStart], s[suspiciousStart:] } - runes := []rune(s) - const maxHold = 128 - if len(runes) <= maxHold { - return "", s - } - return string(runes[:len(runes)-maxHold]), string(runes[len(runes)-maxHold:]) + // If suspicious content starts at position 0, keep holding until we can + // parse a complete tool JSON block or reach stream flush. + return "", s } func findSuspiciousPrefixStart(s string) int { @@ -167,23 +164,14 @@ func consumeToolCapture(captured string, toolNames []string) (prefix string, cal lower := strings.ToLower(captured) keyIdx := strings.Index(lower, "tool_calls") if keyIdx < 0 { - if len([]rune(captured)) >= 256 { - return captured, nil, "", true - } return "", nil, "", false } start := strings.LastIndex(captured[:keyIdx], "{") if start < 0 { - if len([]rune(captured)) >= 512 { - return captured, nil, "", true - } return "", nil, "", false } obj, end, ok := extractJSONObjectFrom(captured, start) if !ok { - if len([]rune(captured)) >= 4096 { - return captured, nil, "", true - } return "", nil, "", false } parsed := util.ParseToolCalls(obj, toolNames)