diff --git a/internal/adapter/openai/chat_stream_runtime.go b/internal/adapter/openai/chat_stream_runtime.go index 80a9419..a5ff195 100644 --- a/internal/adapter/openai/chat_stream_runtime.go +++ b/internal/adapter/openai/chat_stream_runtime.go @@ -98,6 +98,19 @@ func (s *chatStreamRuntime) sendDone() { } } +func (s *chatStreamRuntime) sendFailedChunk(status int, message, code string) { + s.sendChunk(map[string]any{ + "status_code": status, + "error": map[string]any{ + "message": message, + "type": openAIErrorType(status), + "code": code, + "param": nil, + }, + }) + s.sendDone() +} + func (s *chatStreamRuntime) finalize(finishReason string) { finalThinking := s.thinking.String() finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers) @@ -168,6 +181,21 @@ func (s *chatStreamRuntime) finalize(finishReason string) { if len(detected.Calls) > 0 || s.toolCallsEmitted { finishReason = "tool_calls" } + if len(detected.Calls) == 0 && !s.toolCallsEmitted && strings.TrimSpace(finalText) == "" { + status := http.StatusTooManyRequests + message := "Upstream model returned empty output." + code := "upstream_empty_output" + if strings.TrimSpace(finalThinking) != "" { + message = "Upstream model returned reasoning without visible output." + } + if finishReason == "content_filter" { + status = http.StatusBadRequest + message = "Upstream content filtered the response and returned no output." + code = "content_filter" + } + s.sendFailedChunk(status, message, code) + return + } usage := openaifmt.BuildChatUsage(s.finalPrompt, finalThinking, finalText) s.sendChunk(openaifmt.BuildChatStreamChunk( s.completionID, @@ -184,6 +212,9 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD return streamengine.ParsedDecision{} } if parsed.ContentFilter { + if strings.TrimSpace(s.text.String()) == "" { + return streamengine.ParsedDecision{Stop: true, StopReason: streamengine.StopReason("content_filter")} + } return streamengine.ParsedDecision{Stop: true, StopReason: streamengine.StopReasonHandlerRequested} } if parsed.ErrorMessage != "" { diff --git a/internal/adapter/openai/stream_status_test.go b/internal/adapter/openai/stream_status_test.go index b1d3455..0734c4d 100644 --- a/internal/adapter/openai/stream_status_test.go +++ b/internal/adapter/openai/stream_status_test.go @@ -243,6 +243,49 @@ func TestChatCompletionsStreamContentFilterStopsNormallyWithoutLeak(t *testing.T } } +func TestChatCompletionsStreamEmitsFailureFrameWhenUpstreamOutputEmpty(t *testing.T) { + statuses := make([]int, 0, 1) + h := &Handler{ + Store: mockOpenAIConfig{wideInput: true}, + Auth: streamStatusAuthStub{}, + DS: streamStatusDSStub{resp: makeOpenAISSEHTTPResponse("data: [DONE]")}, + } + r := chi.NewRouter() + r.Use(captureStatusMiddleware(&statuses)) + RegisterRoutes(r, h) + + reqBody := `{"model":"deepseek-chat","messages":[{"role":"user","content":"hi"}],"stream":true}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody)) + req.Header.Set("Authorization", "Bearer direct-token") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + if len(statuses) != 1 || statuses[0] != http.StatusOK { + t.Fatalf("expected captured status 200, got %#v", statuses) + } + + frames, done := parseSSEDataFrames(t, rec.Body.String()) + if !done { + t.Fatalf("expected [DONE], body=%s", rec.Body.String()) + } + if len(frames) != 1 { + t.Fatalf("expected one failure frame, got %#v body=%s", frames, rec.Body.String()) + } + last := frames[0] + statusCode, ok := last["status_code"].(float64) + if !ok || int(statusCode) != http.StatusTooManyRequests { + t.Fatalf("expected status_code=429, got %#v body=%s", last["status_code"], rec.Body.String()) + } + errObj, _ := last["error"].(map[string]any) + if asString(errObj["code"]) != "upstream_empty_output" { + t.Fatalf("expected code=upstream_empty_output, got %#v", last) + } +} + func TestResponsesStreamUsageIgnoresBatchAccumulatedTokenUsage(t *testing.T) { statuses := make([]int, 0, 1) h := &Handler{ diff --git a/webui/src/features/apiTester/ChatPanel.jsx b/webui/src/features/apiTester/ChatPanel.jsx index 86f865f..5da6684 100644 --- a/webui/src/features/apiTester/ChatPanel.jsx +++ b/webui/src/features/apiTester/ChatPanel.jsx @@ -133,7 +133,9 @@ export default function ChatPanel({ )}