From 775c1a3c1dcd23744b234623162149aa565b8eb0 Mon Sep 17 00:00:00 2001 From: SPHINX998 <51872346+SPHINX998@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:58:20 +0800 Subject: [PATCH] fix(apicompat): preserve refusals when converting Responses to Chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Responses refusal arrives as a {"type":"refusal","refusal":...} content part (streamed as response.refusal.delta), not as output_text. The Responses → Chat Completions converters only read output_text, and ResponsesContentPart had no refusal field, so Chat clients received an empty assistant message when the model refused. Map refusal parts to choices[0].message.refusal, response.refusal.delta to delta.refusal, and let BufferedResponseAccumulator keep refusal deltas so non-streaming replies rebuilt from deltas keep them too. Co-Authored-By: Claude Opus 5.5 --- .../apicompat/responses_to_chatcompletions.go | 46 +++++-- ...sponses_to_chatcompletions_refusal_test.go | 114 ++++++++++++++++++ backend/internal/pkg/apicompat/types.go | 5 +- 3 files changed, 153 insertions(+), 12 deletions(-) create mode 100644 backend/internal/pkg/apicompat/responses_to_chatcompletions_refusal_test.go diff --git a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go index f402184f27..2b8f2b8117 100644 --- a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go +++ b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go @@ -15,7 +15,8 @@ import ( // ResponsesToChatCompletions converts a Responses API response into a Chat // Completions response. Text output items are concatenated into -// choices[0].message.content; function_call items become tool_calls. +// choices[0].message.content, refusal parts into choices[0].message.refusal; +// function_call items become tool_calls. func ResponsesToChatCompletions(resp *ResponsesResponse, model string) *ChatCompletionsResponse { id := resp.ID if id == "" { @@ -31,6 +32,7 @@ func ResponsesToChatCompletions(resp *ResponsesResponse, model string) *ChatComp } var contentText string + var refusalText string var reasoningText string var toolCalls []ChatToolCall @@ -38,8 +40,11 @@ func ResponsesToChatCompletions(resp *ResponsesResponse, model string) *ChatComp switch item.Type { case "message": for _, part := range item.Content { - if part.Type == "output_text" && part.Text != "" { + switch { + case part.Type == "output_text" && part.Text != "": contentText += part.Text + case part.Type == "refusal" && part.Refusal != "": + refusalText += part.Refusal } } case "function_call": @@ -62,7 +67,7 @@ func ResponsesToChatCompletions(resp *ResponsesResponse, model string) *ChatComp } } - msg := ChatMessage{Role: "assistant"} + msg := ChatMessage{Role: "assistant", Refusal: refusalText} if len(toolCalls) > 0 { msg.ToolCalls = toolCalls } @@ -149,6 +154,8 @@ func ResponsesEventToChatChunks(evt *ResponsesStreamEvent, state *ResponsesEvent return resToChatHandleCreated(evt, state) case "response.output_text.delta": return resToChatHandleTextDelta(evt, state) + case "response.refusal.delta": + return resToChatHandleRefusalDelta(evt, state) case "response.output_item.added": return resToChatHandleOutputItemAdded(evt, state) case "response.function_call_arguments.delta", @@ -248,6 +255,14 @@ func resToChatHandleTextDelta(evt *ResponsesStreamEvent, state *ResponsesEventTo return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{Content: &content})} } +func resToChatHandleRefusalDelta(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk { + if evt.Delta == "" { + return nil + } + refusal := evt.Delta + return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{Refusal: &refusal})} +} + func resToChatHandleOutputItemAdded(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk { // function_call 与 custom_tool_call(custom/freeform 工具)均按工具调用注册, // 以便后续 *_input.delta / *_arguments.delta 能映射到正确的工具索引。 @@ -496,6 +511,7 @@ type bufferedFuncCall struct { // (response.completed / response.done) carries an empty output array. type BufferedResponseAccumulator struct { text strings.Builder + refusal strings.Builder reasoning strings.Builder funcCalls []bufferedFuncCall outputIndexToFuncIdx map[int]int @@ -517,6 +533,10 @@ func (a *BufferedResponseAccumulator) ProcessEvent(event *ResponsesStreamEvent) if event.Delta != "" { _, _ = a.text.WriteString(event.Delta) } + case "response.refusal.delta": + if event.Delta != "" { + _, _ = a.refusal.WriteString(event.Delta) + } case "response.output_item.added": if event.Item != nil && (event.Item.Type == "function_call" || event.Item.Type == "custom_tool_call") { idx := len(a.funcCalls) @@ -553,7 +573,7 @@ func (a *BufferedResponseAccumulator) ProcessEvent(event *ResponsesStreamEvent) // HasContent reports whether any content has been accumulated. func (a *BufferedResponseAccumulator) HasContent() bool { - return a.text.Len() > 0 || len(a.funcCalls) > 0 || a.reasoning.Len() > 0 + return a.text.Len() > 0 || a.refusal.Len() > 0 || len(a.funcCalls) > 0 || a.reasoning.Len() > 0 } // BuildOutput constructs a []ResponsesOutput from the accumulated delta @@ -572,14 +592,18 @@ func (a *BufferedResponseAccumulator) BuildOutput() []ResponsesOutput { }) } - if a.text.Len() > 0 { + if a.text.Len() > 0 || a.refusal.Len() > 0 { + var content []ResponsesContentPart + if a.text.Len() > 0 { + content = append(content, ResponsesContentPart{Type: "output_text", Text: a.text.String()}) + } + if a.refusal.Len() > 0 { + content = append(content, ResponsesContentPart{Type: "refusal", Refusal: a.refusal.String()}) + } out = append(out, ResponsesOutput{ - Type: "message", - Role: "assistant", - Content: []ResponsesContentPart{{ - Type: "output_text", - Text: a.text.String(), - }}, + Type: "message", + Role: "assistant", + Content: content, }) } diff --git a/backend/internal/pkg/apicompat/responses_to_chatcompletions_refusal_test.go b/backend/internal/pkg/apicompat/responses_to_chatcompletions_refusal_test.go new file mode 100644 index 0000000000..cf8f0bfa4a --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_to_chatcompletions_refusal_test.go @@ -0,0 +1,114 @@ +package apicompat + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const refusalText = "I can't help with that." + +func decodeResponsesEvent(t *testing.T, raw string) ResponsesStreamEvent { + t.Helper() + var evt ResponsesStreamEvent + require.NoError(t, json.Unmarshal([]byte(raw), &evt)) + return evt +} + +func chatMessageJSON(t *testing.T, resp *ChatCompletionsResponse) map[string]any { + t.Helper() + raw, err := json.Marshal(resp) + require.NoError(t, err) + var decoded struct { + Choices []struct { + Message map[string]any `json:"message"` + } `json:"choices"` + } + require.NoError(t, json.Unmarshal(raw, &decoded)) + require.Len(t, decoded.Choices, 1) + return decoded.Choices[0].Message +} + +// A refusal arrives as a "refusal" content part, not output_text. Dropping it +// leaves the Chat client with an empty assistant message. +func TestResponsesToChatCompletions_RefusalPreserved(t *testing.T) { + var resp ResponsesResponse + require.NoError(t, json.Unmarshal([]byte(`{ + "id":"resp_refusal", + "status":"completed", + "output":[{"type":"message","role":"assistant","content":[{"type":"refusal","refusal":"`+refusalText+`"}]}] + }`), &resp)) + + chat := ResponsesToChatCompletions(&resp, "gpt-4o") + + msg := chatMessageJSON(t, chat) + require.Equal(t, refusalText, msg["refusal"]) + require.NotContains(t, msg, "content") + require.Equal(t, "stop", chat.Choices[0].FinishReason) +} + +func TestResponsesEventToChatChunks_RefusalDelta(t *testing.T) { + state := NewResponsesEventToChatState() + events := []string{ + `{"type":"response.created","response":{"id":"resp_refusal","model":"gpt-4o"}}`, + `{"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant"}}`, + `{"type":"response.refusal.delta","output_index":0,"content_index":0,"delta":"I can't "}`, + `{"type":"response.refusal.delta","output_index":0,"content_index":0,"delta":"help with that."}`, + `{"type":"response.refusal.done","output_index":0,"content_index":0,"refusal":"` + refusalText + `"}`, + `{"type":"response.completed","response":{"id":"resp_refusal","status":"completed"}}`, + } + + var refusal strings.Builder + var finishReason string + for _, raw := range events { + evt := decodeResponsesEvent(t, raw) + for _, chunk := range ResponsesEventToChatChunks(&evt, state) { + encoded, err := json.Marshal(chunk) + require.NoError(t, err) + var decoded struct { + Choices []struct { + Delta struct { + Refusal *string `json:"refusal"` + } `json:"delta"` + FinishReason *string `json:"finish_reason"` + } `json:"choices"` + } + require.NoError(t, json.Unmarshal(encoded, &decoded)) + for _, choice := range decoded.Choices { + if choice.Delta.Refusal != nil { + _, _ = refusal.WriteString(*choice.Delta.Refusal) + } + if choice.FinishReason != nil { + finishReason = *choice.FinishReason + } + } + } + } + + require.Equal(t, refusalText, refusal.String()) + require.Equal(t, "stop", finishReason) +} + +// Non-streaming Chat requests are served from a forced upstream stream; when +// the terminal event carries an empty output array the message is rebuilt from +// the accumulated deltas, which must include refusal deltas. +func TestBufferedResponseAccumulator_RefusalRebuiltFromDeltas(t *testing.T) { + acc := NewBufferedResponseAccumulator() + for _, raw := range []string{ + `{"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant"}}`, + `{"type":"response.refusal.delta","output_index":0,"content_index":0,"delta":"I can't "}`, + `{"type":"response.refusal.delta","output_index":0,"content_index":0,"delta":"help with that."}`, + } { + evt := decodeResponsesEvent(t, raw) + acc.ProcessEvent(&evt) + } + require.True(t, acc.HasContent()) + + final := &ResponsesResponse{ID: "resp_refusal", Status: "completed"} + acc.SupplementResponseOutput(final) + chat := ResponsesToChatCompletions(final, "gpt-4o") + + require.Equal(t, refusalText, chatMessageJSON(t, chat)["refusal"]) +} diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index 859cb1dcb4..c0a833f107 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -312,8 +312,9 @@ func (i *ResponsesInputItem) UnmarshalJSON(data []byte) error { // ResponsesContentPart is a typed content part in a Responses message. type ResponsesContentPart struct { PromptCacheBreakpoint json.RawMessage `json:"prompt_cache_breakpoint,omitempty"` - Type string `json:"type"` // "input_text" | "output_text" | "input_image" | "input_file" + Type string `json:"type"` // "input_text" | "output_text" | "refusal" | "input_image" | "input_file" Text string `json:"text,omitempty"` + Refusal string `json:"refusal,omitempty"` // type=refusal ImageURL string `json:"image_url,omitempty"` // data URI for input_image // input_file fields. @@ -690,6 +691,7 @@ type ChatStreamOptions struct { type ChatMessage struct { Role string `json:"role"` // "system" | "user" | "assistant" | "tool" | "function" Content json.RawMessage `json:"content,omitempty"` + Refusal string `json:"refusal,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"` Reasoning string `json:"reasoning,omitempty"` Name string `json:"name,omitempty"` @@ -830,6 +832,7 @@ type ChatChunkChoice struct { type ChatDelta struct { Role string `json:"role,omitempty"` Content *string `json:"content,omitempty"` // pointer: omit when not present, null vs "" matters + Refusal *string `json:"refusal,omitempty"` ReasoningContent *string `json:"reasoning_content,omitempty"` Reasoning *string `json:"reasoning,omitempty"` ToolCalls []ChatToolCall `json:"tool_calls,omitempty"`