diff --git a/internal/adapter/openai/handler_chat.go b/internal/adapter/openai/handler_chat.go index 1b2fec4..a0464de 100644 --- a/internal/adapter/openai/handler_chat.go +++ b/internal/adapter/openai/handler_chat.go @@ -5,6 +5,7 @@ import ( "encoding/json" "io" "net/http" + "strings" "time" "ds2api/internal/auth" @@ -106,6 +107,10 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, ctx context.Context, re finalThinking := result.Thinking finalText := sanitizeLeakedOutput(result.Text) + if strings.TrimSpace(finalThinking) == "" && strings.TrimSpace(finalText) == "" { + writeOpenAIError(w, http.StatusTooManyRequests, "Upstream model returned empty output; please retry.") + return + } respBody := openaifmt.BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText, toolNames) if result.OutputTokens > 0 { if usage, ok := respBody["usage"].(map[string]any); ok { diff --git a/internal/adapter/openai/handler_toolcall_test.go b/internal/adapter/openai/handler_toolcall_test.go index 41b4c9f..9507e03 100644 --- a/internal/adapter/openai/handler_toolcall_test.go +++ b/internal/adapter/openai/handler_toolcall_test.go @@ -275,6 +275,26 @@ func TestHandleNonStreamFencedToolCallExamplePromotesToolCall(t *testing.T) { TestHandleNonStreamFencedToolCallExampleDoesNotPromoteToolCall(t) } +func TestHandleNonStreamReturns429WhenUpstreamOutputEmpty(t *testing.T) { + h := &Handler{} + resp := makeSSEHTTPResponse( + `data: {"p":"response/content","v":""}`, + `data: [DONE]`, + ) + rec := httptest.NewRecorder() + + h.handleNonStream(rec, context.Background(), resp, "cid-empty", "deepseek-chat", "prompt", false, nil) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("expected status 429 for empty upstream output, got %d body=%s", rec.Code, rec.Body.String()) + } + out := decodeJSONBody(t, rec.Body.String()) + errObj, _ := out["error"].(map[string]any) + msg, _ := errObj["message"].(string) + if !strings.Contains(strings.ToLower(msg), "empty") { + t.Fatalf("expected empty-output hint in error message, got %#v", out) + } +} + func TestHandleStreamToolCallInterceptsWithoutRawContentLeak(t *testing.T) { h := &Handler{} resp := makeSSEHTTPResponse( diff --git a/internal/adapter/openai/responses_handler.go b/internal/adapter/openai/responses_handler.go index ed2c715..f18b2aa 100644 --- a/internal/adapter/openai/responses_handler.go +++ b/internal/adapter/openai/responses_handler.go @@ -114,6 +114,10 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res } result := sse.CollectStream(resp, thinkingEnabled, true) sanitizedText := sanitizeLeakedOutput(result.Text) + if strings.TrimSpace(result.Thinking) == "" && strings.TrimSpace(sanitizedText) == "" { + writeOpenAIError(w, http.StatusTooManyRequests, "Upstream model returned empty output; please retry.") + return + } textParsed := util.ParseStandaloneToolCallsDetailed(sanitizedText, toolNames) logResponsesToolPolicyRejection(traceID, toolChoice, textParsed, "text") diff --git a/internal/adapter/openai/responses_stream_test.go b/internal/adapter/openai/responses_stream_test.go index fb11ca1..331809c 100644 --- a/internal/adapter/openai/responses_stream_test.go +++ b/internal/adapter/openai/responses_stream_test.go @@ -627,6 +627,29 @@ func TestHandleResponsesNonStreamToolChoiceNoneStillAllowsFunctionCall(t *testin } } +func TestHandleResponsesNonStreamReturns429WhenUpstreamOutputEmpty(t *testing.T) { + h := &Handler{} + rec := httptest.NewRecorder() + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader( + `data: {"p":"response/content","v":""}` + "\n" + + `data: [DONE]` + "\n", + )), + } + + h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, nil, util.DefaultToolChoicePolicy(), "") + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("expected 429 for empty upstream output, got %d body=%s", rec.Code, rec.Body.String()) + } + out := decodeJSONBody(t, rec.Body.String()) + errObj, _ := out["error"].(map[string]any) + msg, _ := errObj["message"].(string) + if !strings.Contains(strings.ToLower(msg), "empty") { + t.Fatalf("expected empty-output message, got %#v", out) + } +} + func extractSSEEventPayload(body, targetEvent string) (map[string]any, bool) { scanner := bufio.NewScanner(strings.NewReader(body)) matched := false diff --git a/internal/adapter/openai/tool_sieve_xml.go b/internal/adapter/openai/tool_sieve_xml.go index 37cb8b3..f6d3de1 100644 --- a/internal/adapter/openai/tool_sieve_xml.go +++ b/internal/adapter/openai/tool_sieve_xml.go @@ -71,12 +71,31 @@ func consumeXMLToolCapture(captured string, toolNames []string) (prefix string, prefixPart, suffixPart = trimWrappingJSONFence(prefixPart, suffixPart) return prefixPart, parsed, suffixPart, true } + // If this block does not look like an executable tool-call payload, + // pass it through as normal content (e.g. user-requested XML snippets). + if !looksLikeExecutableXMLToolCallBlock(xmlBlock, pair.open) { + return captured, nil, "", true + } // Looks like XML tool syntax but failed to parse — consume it to avoid leak. return prefixPart, nil, suffixPart, true } return "", nil, "", false } +func looksLikeExecutableXMLToolCallBlock(xmlBlock, openTag string) bool { + lower := strings.ToLower(xmlBlock) + // Agent wrapper tags are always treated as internal tool-call wrappers. + switch openTag { + case "示例 XMLplain text xml payload` + events := processToolSieveChunk(&state, chunk, []string{"read_file"}) + events = append(events, flushToolSieve(&state, []string{"read_file"})...) + + var textContent strings.Builder + toolCalls := 0 + for _, evt := range events { + textContent.WriteString(evt.Content) + toolCalls += len(evt.ToolCalls) + } + if toolCalls != 0 { + t.Fatalf("expected no tool calls for plain XML payload, got %d events=%#v", toolCalls, events) + } + if textContent.String() != chunk { + t.Fatalf("expected XML payload to pass through unchanged, got %q", textContent.String()) + } +} + func TestProcessToolSievePartialXMLTagHeldBack(t *testing.T) { var state toolStreamSieveState // Chunk ends with a partial XML tool tag.