refactor: handle upstream thinking-only responses as errors and sanitize dangling think tags in output

This commit is contained in:
CJACK
2026-04-13 01:55:14 +08:00
parent aa41bae044
commit daa636e040
10 changed files with 165 additions and 31 deletions

View File

@@ -313,6 +313,25 @@ func TestHandleNonStreamReturnsContentFilterErrorWhenUpstreamFilteredWithoutOutp
}
}
func TestHandleNonStreamReturns429WhenUpstreamHasOnlyThinking(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/thinking_content","v":"Only thinking"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
h.handleNonStream(rec, context.Background(), resp, "cid-thinking-only", "deepseek-reasoner", "prompt", true, nil)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("expected status 429 for thinking-only upstream output, got %d body=%s", rec.Code, rec.Body.String())
}
out := decodeJSONBody(t, rec.Body.String())
errObj, _ := out["error"].(map[string]any)
if asString(errObj["code"]) != "upstream_empty_output" {
t.Fatalf("expected code=upstream_empty_output, got %#v", out)
}
}
func TestHandleStreamToolCallInterceptsWithoutRawContentLeak(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(

View File

@@ -2,13 +2,15 @@ package openai
import (
"regexp"
"strings"
)
var emptyJSONFencePattern = regexp.MustCompile("(?is)```json\\s*```")
var leakedToolCallArrayPattern = regexp.MustCompile(`(?is)\[\{\s*"function"\s*:\s*\{[\s\S]*?\}\s*,\s*"id"\s*:\s*"call[^"]*"\s*,\s*"type"\s*:\s*"function"\s*}\]`)
var leakedToolResultBlobPattern = regexp.MustCompile(`(?is)<\s*\|\s*tool\s*\|\s*>\s*\{[\s\S]*?"tool_call_id"\s*:\s*"call[^"]*"\s*}`)
var leakedThinkTagPattern = regexp.MustCompile(`(?i)</?think>`)
var leakedDanglingThinkOpenPattern = regexp.MustCompile(`(?is)<\s*think\b[^>]*>[\s\S]*$`)
var leakedThinkTagPattern = regexp.MustCompile(`(?is)</?\s*think\s*>`)
// leakedBOSMarkerPattern matches DeepSeek BOS markers in BOTH forms:
// - ASCII underscore: <begin_of_sentence>
@@ -42,6 +44,7 @@ func sanitizeLeakedOutput(text string) string {
out := emptyJSONFencePattern.ReplaceAllString(text, "")
out = leakedToolCallArrayPattern.ReplaceAllString(out, "")
out = leakedToolResultBlobPattern.ReplaceAllString(out, "")
out = stripDanglingThinkSuffix(out)
out = leakedThinkTagPattern.ReplaceAllString(out, "")
out = leakedBOSMarkerPattern.ReplaceAllString(out, "")
out = leakedMetaMarkerPattern.ReplaceAllString(out, "")
@@ -49,6 +52,40 @@ func sanitizeLeakedOutput(text string) string {
return out
}
func stripDanglingThinkSuffix(text string) string {
matches := leakedThinkTagPattern.FindAllStringIndex(text, -1)
if len(matches) == 0 {
return text
}
depth := 0
lastOpen := -1
for _, loc := range matches {
tag := strings.ToLower(text[loc[0]:loc[1]])
compact := strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(tag), " ", ""), "\t", "")
if strings.HasPrefix(compact, "</") {
if depth > 0 {
depth--
if depth == 0 {
lastOpen = -1
}
}
continue
}
if depth == 0 {
lastOpen = loc[0]
}
depth++
}
if depth == 0 || lastOpen < 0 {
return text
}
prefix := text[:lastOpen]
if strings.TrimSpace(prefix) == "" {
return ""
}
return prefix
}
func sanitizeLeakedAgentXMLBlocks(text string) string {
out := text
for _, pattern := range leakedAgentXMLBlockPatterns {

View File

@@ -34,6 +34,14 @@ func TestSanitizeLeakedOutputRemovesThinkAndBosMarkers(t *testing.T) {
}
}
func TestSanitizeLeakedOutputRemovesDanglingThinkBlock(t *testing.T) {
raw := "Answer prefix<think>internal reasoning that never closes"
got := sanitizeLeakedOutput(raw)
if got != "Answer prefix" {
t.Fatalf("unexpected sanitize result for dangling think block: %q", got)
}
}
func TestSanitizeLeakedOutputRemovesAgentXMLLeaks(t *testing.T) {
raw := "Done.<attempt_completion><result>Some final answer</result></attempt_completion>"
got := sanitizeLeakedOutput(raw)

View File

@@ -99,6 +99,30 @@ func newResponsesStreamRuntime(
}
}
func (s *responsesStreamRuntime) failResponse(message, code string) {
s.failed = true
failedResp := map[string]any{
"id": s.responseID,
"type": "response",
"object": "response",
"model": s.model,
"status": "failed",
"output": []any{},
"output_text": "",
"error": map[string]any{
"message": message,
"type": "invalid_request_error",
"code": code,
"param": nil,
},
}
if s.persistResponse != nil {
s.persistResponse(failedResp)
}
s.sendEvent("response.failed", openaifmt.BuildResponsesFailedPayload(s.responseID, s.model, message, code))
s.sendDone()
}
func (s *responsesStreamRuntime) finalize() {
finalThinking := s.thinking.String()
finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers)
@@ -121,28 +145,16 @@ func (s *responsesStreamRuntime) finalize() {
s.closeMessageItem()
if s.toolChoice.IsRequired() && len(detected) == 0 {
s.failed = true
message := "tool_choice requires at least one valid tool call."
failedResp := map[string]any{
"id": s.responseID,
"type": "response",
"object": "response",
"model": s.model,
"status": "failed",
"output": []any{},
"output_text": "",
"error": map[string]any{
"message": message,
"type": "invalid_request_error",
"code": "tool_choice_violation",
"param": nil,
},
s.failResponse("tool_choice requires at least one valid tool call.", "tool_choice_violation")
return
}
if len(detected) == 0 && strings.TrimSpace(finalText) == "" {
code := "upstream_empty_output"
message := "Upstream model returned empty output."
if finalThinking != "" {
message = "Upstream model returned reasoning without visible output."
}
if s.persistResponse != nil {
s.persistResponse(failedResp)
}
s.sendEvent("response.failed", openaifmt.BuildResponsesFailedPayload(s.responseID, s.model, message, "tool_choice_violation"))
s.sendDone()
s.failResponse(message, code)
return
}
s.closeIncompleteFunctionItems()

View File

@@ -518,6 +518,44 @@ func TestHandleResponsesStreamRequiredMalformedToolPayloadFails(t *testing.T) {
}
}
func TestHandleResponsesStreamFailsWhenUpstreamHasOnlyThinking(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
rec := httptest.NewRecorder()
sseLine := func(path, value string) string {
b, _ := json.Marshal(map[string]any{
"p": path,
"v": value,
})
return "data: " + string(b) + "\n"
}
streamBody := sseLine("response/thinking_content", "Only thinking") + "data: [DONE]\n"
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(streamBody)),
}
h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-reasoner", "prompt", true, false, nil, util.DefaultToolChoicePolicy(), "")
body := rec.Body.String()
if !strings.Contains(body, "event: response.failed") {
t.Fatalf("expected response.failed event, body=%s", body)
}
if strings.Contains(body, "event: response.completed") {
t.Fatalf("did not expect response.completed, body=%s", body)
}
payload, ok := extractSSEEventPayload(body, "response.failed")
if !ok {
t.Fatalf("expected response.failed payload, body=%s", body)
}
errObj, _ := payload["error"].(map[string]any)
if asString(errObj["code"]) != "upstream_empty_output" {
t.Fatalf("expected code=upstream_empty_output, got %#v", payload)
}
}
func TestHandleResponsesStreamAllowsUnknownToolName(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
@@ -671,6 +709,28 @@ func TestHandleResponsesNonStreamReturnsContentFilterErrorWhenUpstreamFilteredWi
}
}
func TestHandleResponsesNonStreamReturns429WhenUpstreamHasOnlyThinking(t *testing.T) {
h := &Handler{}
rec := httptest.NewRecorder()
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(
`data: {"p":"response/thinking_content","v":"Only thinking"}` + "\n" +
`data: [DONE]` + "\n",
)),
}
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-reasoner", "prompt", true, nil, util.DefaultToolChoicePolicy(), "")
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429 for thinking-only upstream output, got %d body=%s", rec.Code, rec.Body.String())
}
out := decodeJSONBody(t, rec.Body.String())
errObj, _ := out["error"].(map[string]any)
if asString(errObj["code"]) != "upstream_empty_output" {
t.Fatalf("expected code=upstream_empty_output, got %#v", out)
}
}
func extractSSEEventPayload(body, targetEvent string) (map[string]any, bool) {
scanner := bufio.NewScanner(strings.NewReader(body))
matched := false

View File

@@ -3,7 +3,7 @@ package openai
import "net/http"
func writeUpstreamEmptyOutputError(w http.ResponseWriter, thinking, text string, contentFilter bool) bool {
if thinking != "" || text != "" {
if text != "" {
return false
}
if contentFilter {

View File

@@ -145,11 +145,6 @@ func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req Upload
func buildUploadMultipartBody(filename, contentType, purpose string, data []byte) ([]byte, string, error) {
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
if strings.TrimSpace(purpose) != "" {
if err := writer.WriteField("purpose", purpose); err != nil {
return nil, "", err
}
}
partHeader := textproto.MIMEHeader{}
partHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename=%q`, escapeMultipartFilename(filename)))
partHeader.Set("Content-Type", contentType)

View File

@@ -14,7 +14,7 @@ import (
powpkg "ds2api/pow"
)
func TestBuildUploadMultipartBodyIncludesPurposeAndFilePart(t *testing.T) {
func TestBuildUploadMultipartBodyOmitsPurposeAndIncludesFilePart(t *testing.T) {
body, contentType, err := buildUploadMultipartBody(`../demo.txt`, "text/plain", "assistants", []byte("hello"))
if err != nil {
t.Fatalf("buildUploadMultipartBody error: %v", err)
@@ -23,8 +23,8 @@ func TestBuildUploadMultipartBodyIncludesPurposeAndFilePart(t *testing.T) {
t.Fatalf("unexpected content type: %q", contentType)
}
payload := string(body)
if !strings.Contains(payload, `name="purpose"`) || !strings.Contains(payload, "assistants") {
t.Fatalf("expected purpose field in payload: %q", payload)
if strings.Contains(payload, `name="purpose"`) || strings.Contains(payload, "assistants") {
t.Fatalf("expected purpose to be omitted from payload: %q", payload)
}
if !strings.Contains(payload, `name="file"; filename="demo.txt"`) {
t.Fatalf("expected sanitized filename in payload: %q", payload)

View File

@@ -14,6 +14,8 @@ export default function ApiTesterContainer({ config, onMessage, authFetch }) {
setModel,
message,
setMessage,
attachedFiles,
setAttachedFiles,
apiKey,
setApiKey,
selectedAccount,

View File

@@ -13,6 +13,7 @@ export function useApiTesterState({ t }) {
const [isStreaming, setIsStreaming] = useState(false)
const [streamingMode, setStreamingMode] = useState(true)
const [attachedFiles, setAttachedFiles] = useState([])
const [configExpanded, setConfigExpanded] = useState(false)
const abortControllerRef = useRef(null)
const defaultMessageRef = useRef(defaultMessage)