Skip to content

Commit db5eda9

Browse files
committed
fix(openai): align responses reasoning and metadata compatibility
Keep OpenAI Responses reasoning output aligned with Codex semantics by sending raw reasoning only through content fields and continue stripping unsupported metadata from OpenAI streaming requests.
1 parent b299429 commit db5eda9

8 files changed

Lines changed: 216 additions & 81 deletions

internal/translator/openai/openai/responses/openai_openai-responses_response.go

Lines changed: 6 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -655,11 +655,7 @@ func buildResponsesCompletedEvent(st *oaiToResponsesState, originalRequestRawJSO
655655
}
656656
}
657657
if st.ReasoningID != "" {
658-
summary := st.ReasoningBuf.String()
659-
if st.ReasoningSummary == "none" {
660-
summary = ""
661-
}
662-
item := buildResponsesReasoningItem(st.ReasoningID, st.ReasoningBuf.String(), summary)
658+
item := buildResponsesReasoningItem(st.ReasoningID, st.ReasoningBuf.String(), "")
663659
outputItems = append(outputItems, completedOutputItem{index: st.ReasoningIndex, raw: item})
664660
}
665661
if len(st.MsgItemAdded) > 0 {
@@ -738,7 +734,6 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
738734
FuncOutputIx: make(map[string]int),
739735
FuncCustom: make(map[string]bool),
740736
FuncItemIDs: make(map[string]string),
741-
ReasoningSummary: responseSummaryReasoningText(requestRawJSON),
742737
MsgOutputIx: make(map[int]int),
743738
MsgTextBuf: make(map[int]*strings.Builder),
744739
MsgRefusalBuf: make(map[int]*strings.Builder),
@@ -839,7 +834,6 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
839834
st.FuncOutputIx = make(map[string]int)
840835
st.FuncCustom = make(map[string]bool)
841836
st.FuncItemIDs = make(map[string]string)
842-
st.ReasoningSummary = responseSummaryReasoningText(requestRawJSON)
843837
st.MsgOutputIx = make(map[int]int)
844838
st.NextOutputIx = 0
845839
st.MsgItemAdded = make(map[int]bool)
@@ -873,32 +867,13 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
873867
}
874868

875869
stopReasoning := func(text string) {
876-
textDone := []byte(`{"type":"response.reasoning_summary_text.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"text":""}`)
877-
textDone, _ = sjson.SetBytes(textDone, "sequence_number", nextSeq())
878-
textDone, _ = sjson.SetBytes(textDone, "item_id", st.ReasoningID)
879-
textDone, _ = sjson.SetBytes(textDone, "output_index", st.ReasoningIndex)
880-
textDone, _ = sjson.SetBytes(textDone, "text", text)
881-
out = append(out, emitRespEvent("response.reasoning_summary_text.done", textDone))
882-
883-
partDone := []byte(`{"type":"response.reasoning_summary_part.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
884-
partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq())
885-
partDone, _ = sjson.SetBytes(partDone, "item_id", st.ReasoningID)
886-
partDone, _ = sjson.SetBytes(partDone, "output_index", st.ReasoningIndex)
887-
partDone, _ = sjson.SetBytes(partDone, "part.text", text)
888-
out = append(out, emitRespEvent("response.reasoning_summary_part.done", partDone))
889-
890-
outputItemDone := []byte(`{"type":"response.output_item.done","item":{"id":"","type":"reasoning","encrypted_content":"","summary":[{"type":"summary_text","text":""}]},"output_index":0,"sequence_number":0}`)
870+
outputItemDone := []byte(`{"type":"response.output_item.done","item":{"id":"","type":"reasoning","status":"completed","summary":[],"content":[]},"output_index":0,"sequence_number":0}`)
891871
outputItemDone, _ = sjson.SetBytes(outputItemDone, "sequence_number", nextSeq())
892-
outputItemDone, _ = sjson.SetBytes(outputItemDone, "item.id", st.ReasoningID)
893872
outputItemDone, _ = sjson.SetBytes(outputItemDone, "output_index", st.ReasoningIndex)
894-
outputItemDone, _ = sjson.SetBytes(outputItemDone, "item.summary.text", text)
873+
outputItemDone, _ = sjson.SetRawBytes(outputItemDone, "item", buildResponsesReasoningItem(st.ReasoningID, text, ""))
895874
out = append(out, emitRespEvent("response.output_item.done", outputItemDone))
896875

897-
summary := text
898-
if st.ReasoningSummary == "none" {
899-
summary = ""
900-
}
901-
st.Reasonings = append(st.Reasonings, oaiToResponsesStateReasoning{ReasoningID: st.ReasoningID, ReasoningData: text, ReasoningSummary: summary, OutputIndex: st.ReasoningIndex})
876+
st.Reasonings = append(st.Reasonings, oaiToResponsesStateReasoning{ReasoningID: st.ReasoningID, ReasoningData: text, ReasoningSummary: "", OutputIndex: st.ReasoningIndex})
902877
st.ReasoningID = ""
903878
}
904879

@@ -1049,25 +1024,13 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
10491024
if st.ReasoningID == "" {
10501025
st.ReasoningID = fmt.Sprintf("rs_%s_%d", st.ResponseID, idx)
10511026
st.ReasoningIndex = allocOutputIndex()
1052-
item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","summary":[]}}`)
1027+
item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","summary":[],"content":[]}}`)
10531028
item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
10541029
item, _ = sjson.SetBytes(item, "output_index", st.ReasoningIndex)
10551030
item, _ = sjson.SetBytes(item, "item.id", st.ReasoningID)
10561031
out = append(out, emitRespEvent("response.output_item.added", item))
1057-
1058-
part := []byte(`{"type":"response.reasoning_summary_part.added","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
1059-
part, _ = sjson.SetBytes(part, "sequence_number", nextSeq())
1060-
part, _ = sjson.SetBytes(part, "item_id", st.ReasoningID)
1061-
part, _ = sjson.SetBytes(part, "output_index", st.ReasoningIndex)
1062-
out = append(out, emitRespEvent("response.reasoning_summary_part.added", part))
10631032
}
10641033
st.ReasoningBuf.WriteString(rc.String())
1065-
msg := []byte(`{"type":"response.reasoning_summary_text.delta","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"delta":""}`)
1066-
msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq())
1067-
msg, _ = sjson.SetBytes(msg, "item_id", st.ReasoningID)
1068-
msg, _ = sjson.SetBytes(msg, "output_index", st.ReasoningIndex)
1069-
msg, _ = sjson.SetBytes(msg, "delta", rc.String())
1070-
out = append(out, emitRespEvent("response.reasoning_summary_text.delta", msg))
10711034
out = append(out, buildResponsesReasoningDeltaEvent(st.ReasoningID, st.ReasoningIndex, rc.String(), nextSeq))
10721035
}
10731036

@@ -1200,11 +1163,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co
12001163
if strings.HasPrefix(rid, "resp_") {
12011164
rid = strings.TrimPrefix(rid, "resp_")
12021165
}
1203-
summary := rcText
1204-
if responseSummaryReasoningText(requestRawJSON) == "none" {
1205-
summary = ""
1206-
}
1207-
reasoningItem := buildResponsesReasoningItem(fmt.Sprintf("rs_%s", rid), rcText, summary)
1166+
reasoningItem := buildResponsesReasoningItem(fmt.Sprintf("rs_%s", rid), rcText, "")
12081167
outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", reasoningItem)
12091168
}
12101169

internal/translator/openai/openai/responses/openai_openai-responses_response_test.go

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,38 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_Preserve
590590
}
591591
}
592592

593+
func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_ReasoningItemUsesContentOnly(t *testing.T) {
594+
originalRequest := []byte(`{"model":"gpt-4.1","reasoning":{"effort":"high","summary":"auto"}}`)
595+
request := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("gpt-4.1", originalRequest, false)
596+
response := []byte(`{
597+
"id":"chatcmpl-reasoning",
598+
"object":"chat.completion",
599+
"created":1773896263,
600+
"model":"gpt-4.1",
601+
"choices":[{
602+
"index":0,
603+
"message":{"role":"assistant","content":"hello","reasoning_content":"step-1"},
604+
"finish_reason":"stop"
605+
}],
606+
"usage":{"prompt_tokens":11,"completion_tokens":7,"total_tokens":18}
607+
}`)
608+
609+
out := ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "gpt-4.1", originalRequest, request, response, nil)
610+
611+
if got := gjson.GetBytes(out, "output.0.type").String(); got != "reasoning" {
612+
t.Fatalf("output.0.type = %q, want %q", got, "reasoning")
613+
}
614+
if got := gjson.GetBytes(out, "output.0.content.0.text").String(); got != "step-1" {
615+
t.Fatalf("output.0.content.0.text = %q, want %q", got, "step-1")
616+
}
617+
if got := gjson.GetBytes(out, "output.0.summary.#").Int(); got != 0 {
618+
t.Fatalf("output.0.summary length = %d, want 0", got)
619+
}
620+
if got := gjson.GetBytes(out, "output.1.type").String(); got != "message" {
621+
t.Fatalf("output.1.type = %q, want %q", got, "message")
622+
}
623+
}
624+
593625
func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_EmitsReasoningTextDelta(t *testing.T) {
594626
in := []string{
595627
`data: {"id":"resp_reasoning","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":"step-1","tool_calls":null},"finish_reason":null}]}`,
@@ -599,24 +631,45 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_EmitsReasoningTex
599631

600632
var param any
601633
var sawDelta bool
634+
var sawSummaryDelta bool
635+
var completed gjson.Result
602636
for _, line := range in {
603637
for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", request, request, []byte(line), &param) {
604638
event, data := parseOpenAIResponsesSSEEvent(t, chunk)
605-
if event != "response.reasoning_text.delta" {
606-
continue
607-
}
608-
sawDelta = true
609-
if got := data.Get("delta").String(); got != "step-1" {
610-
t.Fatalf("delta = %q, want %q", got, "step-1")
611-
}
612-
if got := data.Get("content_index").Int(); got != 0 {
613-
t.Fatalf("content_index = %d, want 0", got)
639+
switch event {
640+
case "response.reasoning_text.delta":
641+
sawDelta = true
642+
if got := data.Get("delta").String(); got != "step-1" {
643+
t.Fatalf("delta = %q, want %q", got, "step-1")
644+
}
645+
if got := data.Get("content_index").Int(); got != 0 {
646+
t.Fatalf("content_index = %d, want 0", got)
647+
}
648+
case "response.reasoning_summary_text.delta":
649+
sawSummaryDelta = true
650+
case "response.completed":
651+
completed = data.Get("response")
614652
}
615653
}
616654
}
617655
if !sawDelta {
618656
t.Fatal("expected response.reasoning_text.delta event")
619657
}
658+
if sawSummaryDelta {
659+
t.Fatal("did not expect response.reasoning_summary_text.delta event")
660+
}
661+
if !completed.Exists() {
662+
t.Fatal("expected response.completed event")
663+
}
664+
if got := completed.Get("output.0.type").String(); got != "reasoning" {
665+
t.Fatalf("output.0.type = %q, want %q", got, "reasoning")
666+
}
667+
if got := completed.Get("output.0.content.0.text").String(); got != "step-1" {
668+
t.Fatalf("output.0.content.0.text = %q, want %q", got, "step-1")
669+
}
670+
if got := completed.Get("output.0.summary.#").Int(); got != 0 {
671+
t.Fatalf("output.0.summary length = %d, want 0", got)
672+
}
620673
}
621674

622675
func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_EmitsCustomToolCallInputDelta(t *testing.T) {

sdk/api/handlers/openai/openai_handlers.go

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ func (h *OpenAIAPIHandler) ChatCompletions(c *gin.Context) {
111111
// Check if the client requested a streaming response.
112112
streamResult := gjson.GetBytes(rawJSON, "stream")
113113
stream := streamResult.Type == gjson.True
114+
if stream {
115+
rawJSON = dropTopLevelMetadata(rawJSON)
116+
}
114117

115118
if shouldRejectResponsesFormat(rawJSON) {
116119
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
@@ -167,6 +170,17 @@ func copyRawJSONFieldsIfExists(dst []byte, root gjson.Result, fields ...string)
167170
return dst
168171
}
169172

173+
func dropTopLevelMetadata(rawJSON []byte) []byte {
174+
if !gjson.GetBytes(rawJSON, "metadata").Exists() {
175+
return rawJSON
176+
}
177+
updated, err := sjson.DeleteBytes(rawJSON, "metadata")
178+
if err != nil {
179+
return rawJSON
180+
}
181+
return updated
182+
}
183+
170184
// Completions handles the /v1/completions endpoint.
171185
// It determines whether the request is for a streaming or non-streaming response
172186
// and calls the appropriate handler based on the model provider.
@@ -205,7 +219,7 @@ func (h *OpenAIAPIHandler) Completions(c *gin.Context) {
205219
//
206220
// Returns:
207221
// - []byte: The converted chat completions request
208-
func convertCompletionsRequestToChatCompletions(rawJSON []byte) []byte {
222+
func convertCompletionsRequestToChatCompletions(rawJSON []byte, preserveMetadata bool) []byte {
209223
root := gjson.ParseBytes(rawJSON)
210224

211225
// Extract prompt from completions request
@@ -266,8 +280,7 @@ func convertCompletionsRequestToChatCompletions(rawJSON []byte) []byte {
266280
out, _ = sjson.SetBytes(out, "echo", echo.Bool())
267281
}
268282

269-
out = copyRawJSONFieldsIfExists(out, root,
270-
"metadata",
283+
fields := []string{
271284
"service_tier",
272285
"store",
273286
"seed",
@@ -281,8 +294,11 @@ func convertCompletionsRequestToChatCompletions(rawJSON []byte) []byte {
281294
"extra_headers",
282295
"extra_query",
283296
"extra_body",
284-
)
285-
297+
}
298+
if preserveMetadata {
299+
fields = append([]string{"metadata"}, fields...)
300+
}
301+
out = copyRawJSONFieldsIfExists(out, root, fields...)
286302
return out
287303
}
288304

@@ -580,7 +596,7 @@ func (h *OpenAIAPIHandler) handleCompletionsNonStreamingResponse(c *gin.Context,
580596
c.Header("Content-Type", "application/json")
581597

582598
// Convert completions request to chat completions format
583-
chatCompletionsJSON := convertCompletionsRequestToChatCompletions(rawJSON)
599+
chatCompletionsJSON := convertCompletionsRequestToChatCompletions(rawJSON, true)
584600

585601
modelName := gjson.GetBytes(chatCompletionsJSON, "model").String()
586602
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
@@ -619,7 +635,7 @@ func (h *OpenAIAPIHandler) handleCompletionsStreamingResponse(c *gin.Context, ra
619635
}
620636

621637
// Convert completions request to chat completions format
622-
chatCompletionsJSON := convertCompletionsRequestToChatCompletions(rawJSON)
638+
chatCompletionsJSON := convertCompletionsRequestToChatCompletions(rawJSON, false)
623639

624640
modelName := gjson.GetBytes(chatCompletionsJSON, "model").String()
625641
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())

sdk/api/handlers/openai/openai_handlers_test.go

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,30 @@ import (
1818
)
1919

2020
type chatCaptureExecutor struct {
21-
calls int
22-
sourceFormat string
21+
calls int
22+
streamCalls int
23+
sourceFormat string
24+
payloads [][]byte
25+
streamPayloads [][]byte
2326
}
2427

2528
func (e *chatCaptureExecutor) Identifier() string { return "test-provider" }
2629

27-
func (e *chatCaptureExecutor) Execute(_ context.Context, _ *coreauth.Auth, _ coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) {
30+
func (e *chatCaptureExecutor) Execute(_ context.Context, _ *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) {
2831
e.calls++
2932
e.sourceFormat = opts.SourceFormat.String()
33+
e.payloads = append(e.payloads, append([]byte(nil), req.Payload...))
3034
return coreexecutor.Response{Payload: []byte(`{"id":"chatcmpl-1","object":"chat.completion","choices":[],"usage":{"prompt_tokens":1,"completion_tokens":0,"total_tokens":1}}`)}, nil
3135
}
3236

33-
func (e *chatCaptureExecutor) ExecuteStream(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (*coreexecutor.StreamResult, error) {
34-
return nil, errors.New("not implemented")
37+
func (e *chatCaptureExecutor) ExecuteStream(_ context.Context, _ *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (*coreexecutor.StreamResult, error) {
38+
e.streamCalls++
39+
e.sourceFormat = opts.SourceFormat.String()
40+
e.streamPayloads = append(e.streamPayloads, append([]byte(nil), req.Payload...))
41+
chunks := make(chan coreexecutor.StreamChunk, 1)
42+
chunks <- coreexecutor.StreamChunk{Payload: []byte(`{"id":"chatcmpl-1","object":"chat.completion.chunk","model":"test-model","choices":[{"index":0,"delta":{"content":"hello"},"finish_reason":null}]}`)}
43+
close(chunks)
44+
return &coreexecutor.StreamResult{Chunks: chunks}, nil
3545
}
3646

3747
func (e *chatCaptureExecutor) Refresh(ctx context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) {
@@ -66,6 +76,7 @@ func newOpenAIChatTestRouter(t *testing.T, executor *chatCaptureExecutor) http.H
6676
h := NewOpenAIAPIHandler(base)
6777
router := gin.New()
6878
router.POST("/v1/chat/completions", h.ChatCompletions)
79+
router.POST("/v1/completions", h.Completions)
6980
return router
7081
}
7182

@@ -130,7 +141,7 @@ func TestConvertCompletionsRequestToChatCompletionsPreservesCompatibleFields(t *
130141
"extra_body":{"user":"abc"}
131142
}`)
132143

133-
out := convertCompletionsRequestToChatCompletions(raw)
144+
out := convertCompletionsRequestToChatCompletions(raw, true)
134145

135146
if got := gjson.GetBytes(out, "model").String(); got != "gpt-4.1" {
136147
t.Fatalf("model = %q, want %q", got, "gpt-4.1")
@@ -208,3 +219,63 @@ func TestShouldRejectResponsesFormat(t *testing.T) {
208219
})
209220
}
210221
}
222+
223+
func TestChatCompletionsStreamingDropsMetadata(t *testing.T) {
224+
executor := &chatCaptureExecutor{}
225+
router := newOpenAIChatTestRouter(t, executor)
226+
227+
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"test-model","stream":true,"messages":[{"role":"user","content":"hello"}],"metadata":{"source":"explicit"}}`))
228+
req.Header.Set("Content-Type", "application/json")
229+
resp := httptest.NewRecorder()
230+
router.ServeHTTP(resp, req)
231+
232+
if resp.Code != http.StatusOK {
233+
t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK)
234+
}
235+
if executor.streamCalls != 1 {
236+
t.Fatalf("stream calls = %d, want 1", executor.streamCalls)
237+
}
238+
if gjson.GetBytes(executor.streamPayloads[0], "metadata").Exists() {
239+
t.Fatalf("metadata leaked into chat streaming payload: %s", executor.streamPayloads[0])
240+
}
241+
}
242+
243+
func TestCompletionsNonStreamingPreservesMetadata(t *testing.T) {
244+
executor := &chatCaptureExecutor{}
245+
router := newOpenAIChatTestRouter(t, executor)
246+
247+
req := httptest.NewRequest(http.MethodPost, "/v1/completions", strings.NewReader(`{"model":"test-model","prompt":"hello","metadata":{"source":"explicit"}}`))
248+
req.Header.Set("Content-Type", "application/json")
249+
resp := httptest.NewRecorder()
250+
router.ServeHTTP(resp, req)
251+
252+
if resp.Code != http.StatusOK {
253+
t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK)
254+
}
255+
if executor.calls != 1 {
256+
t.Fatalf("executor calls = %d, want 1", executor.calls)
257+
}
258+
if got := gjson.GetBytes(executor.payloads[0], "metadata.source").String(); got != "explicit" {
259+
t.Fatalf("metadata.source = %q, want %q; payload=%s", got, "explicit", executor.payloads[0])
260+
}
261+
}
262+
263+
func TestCompletionsStreamingDropsMetadata(t *testing.T) {
264+
executor := &chatCaptureExecutor{}
265+
router := newOpenAIChatTestRouter(t, executor)
266+
267+
req := httptest.NewRequest(http.MethodPost, "/v1/completions", strings.NewReader(`{"model":"test-model","prompt":"hello","stream":true,"metadata":{"source":"explicit"}}`))
268+
req.Header.Set("Content-Type", "application/json")
269+
resp := httptest.NewRecorder()
270+
router.ServeHTTP(resp, req)
271+
272+
if resp.Code != http.StatusOK {
273+
t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK)
274+
}
275+
if executor.streamCalls != 1 {
276+
t.Fatalf("stream calls = %d, want 1", executor.streamCalls)
277+
}
278+
if gjson.GetBytes(executor.streamPayloads[0], "metadata").Exists() {
279+
t.Fatalf("metadata leaked into completions streaming payload: %s", executor.streamPayloads[0])
280+
}
281+
}

0 commit comments

Comments
 (0)