Skip to content

Commit 9108095

Browse files
authored
fix(openai): avoid duplicate responses stream text and normalize mini reasoning (#598)
1 parent 9a534b4 commit 9108095

5 files changed

Lines changed: 126 additions & 29 deletions

File tree

core/relay/adaptor/openai/chat.go

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,9 @@ func (s *chatCompletionStreamState) handleFunctionCallArgumentsDelta(
186186
// handleOutputItemDone handles response.output_item.done event for ChatCompletion
187187
func (s *chatCompletionStreamState) handleOutputItemDone(
188188
event *relaymodel.ResponseStreamEvent,
189-
) *relaymodel.ChatCompletionsStreamResponse {
189+
) {
190190
if event.Item == nil {
191-
return nil
191+
return
192192
}
193193

194194
// Handle function call completion
@@ -205,32 +205,8 @@ func (s *chatCompletionStreamState) handleOutputItemDone(
205205
s.toolCallArgs = ""
206206

207207
// No need to send another chunk - arguments already streamed
208-
return nil
209-
}
210-
211-
// Handle message content
212-
if len(event.Item.Content) > 0 {
213-
for _, content := range event.Item.Content {
214-
if (content.Type == "text" || content.Type == "output_text") && content.Text != "" {
215-
return &relaymodel.ChatCompletionsStreamResponse{
216-
ID: s.messageID,
217-
Object: relaymodel.ChatCompletionChunkObject,
218-
Created: time.Now().Unix(),
219-
Model: s.meta.ActualModel,
220-
Choices: []*relaymodel.ChatCompletionsStreamResponseChoice{
221-
{
222-
Index: 0,
223-
Delta: relaymodel.Message{
224-
Content: content.Text,
225-
},
226-
},
227-
},
228-
}
229-
}
230-
}
208+
return
231209
}
232-
233-
return nil
234210
}
235211

236212
// handleResponseCompleted handles response.completed/done event for ChatCompletion
@@ -1221,7 +1197,7 @@ func ConvertResponsesToChatCompletionStreamResponse(
12211197
case relaymodel.EventFunctionCallArgumentsDelta:
12221198
chatStreamResp = state.handleFunctionCallArgumentsDelta(&event)
12231199
case relaymodel.EventOutputItemDone:
1224-
chatStreamResp = state.handleOutputItemDone(&event)
1200+
state.handleOutputItemDone(&event)
12251201
case relaymodel.EventResponseCompleted, relaymodel.EventResponseDone:
12261202
chatStreamResp = state.handleResponseCompleted(&event)
12271203
}

core/relay/adaptor/openai/chat_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"net/http"
88
"net/http/httptest"
9+
"strings"
910
"testing"
1011

1112
"github.com/gin-gonic/gin"
@@ -515,6 +516,81 @@ func TestConvertResponsesToChatCompletionResponse(t *testing.T) {
515516
}
516517
}
517518

519+
func TestConvertResponsesToChatCompletionStreamResponseSkipsOutputItemDoneContent(t *testing.T) {
520+
gin.SetMode(gin.TestMode)
521+
522+
stream := strings.Join([]string{
523+
`data: {"type":"response.created","response":{"id":"resp_123","object":"response","created_at":1780731105,"status":"in_progress","model":"gpt-5.1","output":[],"parallel_tool_calls":true,"store":false}}`,
524+
"",
525+
`data: {"type":"response.output_item.added","item":{"id":"msg_123","type":"message","role":"assistant","content":[]}}`,
526+
"",
527+
`data: {"type":"response.output_text.delta","item_id":"msg_123","output_index":0,"content_index":0,"delta":"Hello! What would you like to discuss or work on?"}`,
528+
"",
529+
`data: {"type":"response.output_item.done","item":{"id":"msg_123","type":"message","role":"assistant","content":[{"type":"output_text","text":"Hello! What would you like to discuss or work on?"}]}}`,
530+
"",
531+
`data: {"type":"response.completed","response":{"id":"resp_123","object":"response","created_at":1780731105,"status":"completed","model":"gpt-5.1","output":[{"id":"msg_123","type":"message","role":"assistant","content":[{"type":"output_text","text":"Hello! What would you like to discuss or work on?"}]}],"parallel_tool_calls":true,"store":false,"usage":{"input_tokens":7,"output_tokens":22,"total_tokens":29}}}`,
532+
"",
533+
`data: [DONE]`,
534+
"",
535+
}, "\n")
536+
537+
httpResp := &http.Response{
538+
StatusCode: http.StatusOK,
539+
Body: &mockReadCloser{Reader: bytes.NewReader([]byte(stream))},
540+
Header: make(http.Header),
541+
}
542+
543+
w := httptest.NewRecorder()
544+
c, _ := gin.CreateTestContext(w)
545+
c.Request = httptest.NewRequestWithContext(
546+
t.Context(),
547+
http.MethodPost,
548+
"/v1/chat/completions",
549+
nil,
550+
)
551+
552+
m := &meta.Meta{
553+
ActualModel: "gpt-5.1",
554+
}
555+
556+
_, err := openai.ConvertResponsesToChatCompletionStreamResponse(m, c, httpResp)
557+
require.Nil(t, err)
558+
559+
content := collectChatCompletionStreamContent(t, w.Body.String())
560+
assert.Equal(t, "Hello! What would you like to discuss or work on?", content)
561+
assert.Equal(
562+
t,
563+
1,
564+
strings.Count(w.Body.String(), "Hello! What would you like to discuss or work on?"),
565+
)
566+
}
567+
568+
func collectChatCompletionStreamContent(t *testing.T, body string) string {
569+
t.Helper()
570+
571+
var builder strings.Builder
572+
573+
for line := range strings.SplitSeq(body, "\n") {
574+
line = strings.TrimSpace(line)
575+
if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" {
576+
continue
577+
}
578+
579+
var chunk relaymodel.ChatCompletionsStreamResponse
580+
581+
err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &chunk)
582+
require.NoError(t, err)
583+
584+
for _, choice := range chunk.Choices {
585+
if content, ok := choice.Delta.Content.(string); ok {
586+
builder.WriteString(content)
587+
}
588+
}
589+
}
590+
591+
return builder.String()
592+
}
593+
518594
// mockReadCloser is a helper to create a ReadCloser from a Reader
519595
type mockReadCloser struct {
520596
*bytes.Reader

core/relay/adaptor/openai/gemini_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,20 @@ func TestConvertGeminiRequest_MapsThinkingConfigToReasoningEffort(t *testing.T)
6161
}`,
6262
expectedEffort: "low",
6363
},
64+
{
65+
name: "gpt-5.4 mini snapshot does not receive minimal",
66+
actualModel: "gpt-5.4-mini-2026-03-17",
67+
requestJSON: `{
68+
"generationConfig": {
69+
"thinkingConfig": {
70+
"thinkingBudget": 512,
71+
"includeThoughts": true
72+
}
73+
},
74+
"contents": [{"role":"user","parts":[{"text":"hello"}]}]
75+
}`,
76+
expectedEffort: "low",
77+
},
6478
{
6579
name: "gpt-5 does not receive xhigh",
6680
actualModel: "gpt-5",

core/relay/adaptor/openai/reasoning.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,19 @@ func isKnownOpenAIModelSuffix(suffix string) bool {
332332
}
333333

334334
if matched, ok := strings.CutPrefix(suffix, "-"); ok {
335-
return isDateSuffix(matched)
335+
if isDateSuffix(matched) {
336+
return true
337+
}
338+
339+
for _, variant := range []string{"mini", "nano", "chat-latest"} {
340+
if matched == variant {
341+
return true
342+
}
343+
344+
if dateSuffix, ok := strings.CutPrefix(matched, variant+"-"); ok {
345+
return isDateSuffix(dateSuffix)
346+
}
347+
}
336348
}
337349

338350
return false

core/relay/adaptor/openai/reasoning_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,18 @@ func TestOpenAIReasoningEffortForModel(t *testing.T) {
127127
effort: relaymodel.ReasoningEffortMinimal,
128128
want: relaymodel.ReasoningEffortLow,
129129
},
130+
{
131+
name: "series keyword matching handles mini dated snapshot names",
132+
originModel: "gpt-5.4-mini-2026-03-17",
133+
effort: relaymodel.ReasoningEffortMinimal,
134+
want: relaymodel.ReasoningEffortLow,
135+
},
136+
{
137+
name: "series keyword matching handles nano dated snapshot names",
138+
originModel: "gpt-5.4-nano-2026-03-17",
139+
effort: relaymodel.ReasoningEffortMinimal,
140+
want: relaymodel.ReasoningEffortLow,
141+
},
130142
}
131143

132144
for _, tt := range tests {
@@ -171,6 +183,13 @@ func TestConvertRequest_OpenAIReasoningEffortCompatibility(t *testing.T) {
171183
body: `{"model":"alias","input":"hi","reasoning":{"effort":"xhigh"}}`,
172184
wantEffort: "high",
173185
},
186+
{
187+
name: "native responses gpt-5.4 mini snapshot minimal to low",
188+
mode: mode.Responses,
189+
actualModel: "gpt-5.4-mini-2026-03-17",
190+
body: `{"model":"alias","input":"hi","reasoning":{"effort":"minimal"}}`,
191+
wantEffort: "low",
192+
},
174193
{
175194
name: "native chat origin match beats actual fallback",
176195
mode: mode.ChatCompletions,

0 commit comments

Comments
 (0)