mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
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:
co-authored by
Claude Opus 5.5
parent
d6adebd22d
commit
775c1a3c1d
@@ -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"])
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user