fix(apicompat): preserve refusals when converting Responses to Chat

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 <noreply@anthropic.com>
This commit is contained in:
SPHINX998
2026-10-01 22:03:51 +08:00
co-authored by Claude Opus 5.5
parent d6adebd22d
commit 775c1a3c1d
3 changed files with 153 additions and 12 deletions
@@ -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,
})
}
@@ -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"])
}
+4 -1
View File
@@ -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"`