Files
sub2api/backend/internal/pkg/apicompat/responses_to_chatcompletions_refusal_test.go
T
SPHINX998andClaude Opus 5.5 775c1a3c1d 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>
2026-10-01 22:03:51 +08:00

115 lines
3.9 KiB
Go

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"])
}