diff --git a/internal/adapter/openai/handler_toolcall_test.go b/internal/adapter/openai/handler_toolcall_test.go index e595f53..6092461 100644 --- a/internal/adapter/openai/handler_toolcall_test.go +++ b/internal/adapter/openai/handler_toolcall_test.go @@ -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( diff --git a/internal/adapter/openai/leaked_output_sanitize.go b/internal/adapter/openai/leaked_output_sanitize.go index bcd8227..662d7e2 100644 --- a/internal/adapter/openai/leaked_output_sanitize.go +++ b/internal/adapter/openai/leaked_output_sanitize.go @@ -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)`) +var leakedDanglingThinkOpenPattern = regexp.MustCompile(`(?is)<\s*think\b[^>]*>[\s\S]*$`) +var leakedThinkTagPattern = regexp.MustCompile(`(?is)`) // 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, " 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 { diff --git a/internal/adapter/openai/leaked_output_sanitize_test.go b/internal/adapter/openai/leaked_output_sanitize_test.go index 6fd1485..e72bf02 100644 --- a/internal/adapter/openai/leaked_output_sanitize_test.go +++ b/internal/adapter/openai/leaked_output_sanitize_test.go @@ -34,6 +34,14 @@ func TestSanitizeLeakedOutputRemovesThinkAndBosMarkers(t *testing.T) { } } +func TestSanitizeLeakedOutputRemovesDanglingThinkBlock(t *testing.T) { + raw := "Answer prefixinternal 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.Some final answer" got := sanitizeLeakedOutput(raw) diff --git a/internal/adapter/openai/responses_stream_runtime_core.go b/internal/adapter/openai/responses_stream_runtime_core.go index ec4bdb1..45863dc 100644 --- a/internal/adapter/openai/responses_stream_runtime_core.go +++ b/internal/adapter/openai/responses_stream_runtime_core.go @@ -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() diff --git a/internal/adapter/openai/responses_stream_test.go b/internal/adapter/openai/responses_stream_test.go index 138e9d0..1014d78 100644 --- a/internal/adapter/openai/responses_stream_test.go +++ b/internal/adapter/openai/responses_stream_test.go @@ -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 diff --git a/internal/adapter/openai/upstream_empty.go b/internal/adapter/openai/upstream_empty.go index 071ffce..7cabc3e 100644 --- a/internal/adapter/openai/upstream_empty.go +++ b/internal/adapter/openai/upstream_empty.go @@ -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 { diff --git a/internal/deepseek/client_upload.go b/internal/deepseek/client_upload.go index 80218c3..0673712 100644 --- a/internal/deepseek/client_upload.go +++ b/internal/deepseek/client_upload.go @@ -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) diff --git a/internal/deepseek/client_upload_test.go b/internal/deepseek/client_upload_test.go index e3ae494..d505b4f 100644 --- a/internal/deepseek/client_upload_test.go +++ b/internal/deepseek/client_upload_test.go @@ -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) diff --git a/webui/src/features/apiTester/ApiTesterContainer.jsx b/webui/src/features/apiTester/ApiTesterContainer.jsx index 0752231..b5e4be8 100644 --- a/webui/src/features/apiTester/ApiTesterContainer.jsx +++ b/webui/src/features/apiTester/ApiTesterContainer.jsx @@ -14,6 +14,8 @@ export default function ApiTesterContainer({ config, onMessage, authFetch }) { setModel, message, setMessage, + attachedFiles, + setAttachedFiles, apiKey, setApiKey, selectedAccount, diff --git a/webui/src/features/apiTester/useApiTesterState.js b/webui/src/features/apiTester/useApiTesterState.js index 1f5c744..96f168b 100644 --- a/webui/src/features/apiTester/useApiTesterState.js +++ b/webui/src/features/apiTester/useApiTesterState.js @@ -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)