From 5b86d08f9d97d8b8666b8766161ef22f6e3f11c6 Mon Sep 17 00:00:00 2001 From: zhuhaow Date: Sat, 4 Apr 2026 08:38:56 +0800 Subject: [PATCH 1/3] fix(openai): preserve Chat Completions response_format when converting to Responses --- .../chatcompletions_responses_test.go | 26 ++++ .../apicompat/chatcompletions_to_responses.go | 7 + backend/internal/pkg/apicompat/types.go | 7 + .../openai_gateway_chat_completions_test.go | 144 ++++++++++++++++++ 4 files changed, 184 insertions(+) create mode 100644 backend/internal/service/openai_gateway_chat_completions_test.go diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index f54a4a027f..b082a66f37 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -157,6 +157,32 @@ func TestChatCompletionsToResponses_ReasoningEffort(t *testing.T) { assert.Equal(t, "auto", resp.Reasoning.Summary) } +func TestChatCompletionsToResponses_ResponseFormat(t *testing.T) { + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + Messages: []ChatMessage{ + {Role: "user", Content: json.RawMessage(`"Return structured data"`)}}, + ResponseFormat: json.RawMessage(`{ + "type":"json_schema", + "json_schema":{ + "name":"weather", + "strict":true, + "schema":{ + "type":"object", + "properties":{"city":{"type":"string"}}, + "required":["city"], + "additionalProperties":false + } + } + }`), + } + + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + require.NotNil(t, resp.Text) + assert.JSONEq(t, string(req.ResponseFormat), string(resp.Text.Format)) +} + func TestChatCompletionsToResponses_ImageURL(t *testing.T) { content := `[{"type":"text","text":"Describe this"},{"type":"image_url","image_url":{"url":"data:image/png;base64,abc123"}}]` req := &ChatCompletionsRequest{ diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index 6cdd012a49..1973404d73 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -63,6 +63,13 @@ func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, } } + // Chat Completions structured outputs use response_format; Responses uses + // text.format. Preserve the raw schema payload so Zod/json_schema requests + // survive the compatibility conversion unchanged. + if len(req.ResponseFormat) > 0 { + out.Text = &ResponsesText{Format: req.ResponseFormat} + } + // tools[] and legacy functions[] → ResponsesTool[] if len(req.Tools) > 0 || len(req.Functions) > 0 { out.Tools = convertChatToolsToResponses(req.Tools, req.Functions) diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index b724a5ed96..d00b06788a 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -158,6 +158,7 @@ type ResponsesRequest struct { TopP *float64 `json:"top_p,omitempty"` Stream bool `json:"stream,omitempty"` Tools []ResponsesTool `json:"tools,omitempty"` + Text *ResponsesText `json:"text,omitempty"` Include []string `json:"include,omitempty"` Store *bool `json:"store,omitempty"` Reasoning *ResponsesReasoning `json:"reasoning,omitempty"` @@ -165,6 +166,11 @@ type ResponsesRequest struct { ServiceTier string `json:"service_tier,omitempty"` } +// ResponsesText configures text output formatting in the Responses API. +type ResponsesText struct { + Format json.RawMessage `json:"format,omitempty"` +} + // ResponsesReasoning configures reasoning effort in the Responses API. type ResponsesReasoning struct { Effort string `json:"effort"` // "low" | "medium" | "high" @@ -345,6 +351,7 @@ type ChatCompletionsRequest struct { StreamOptions *ChatStreamOptions `json:"stream_options,omitempty"` Tools []ChatTool `json:"tools,omitempty"` ToolChoice json.RawMessage `json:"tool_choice,omitempty"` + ResponseFormat json.RawMessage `json:"response_format,omitempty"` ReasoningEffort string `json:"reasoning_effort,omitempty"` // "low" | "medium" | "high" ServiceTier string `json:"service_tier,omitempty"` Stop json.RawMessage `json:"stop,omitempty"` // string or []string diff --git a/backend/internal/service/openai_gateway_chat_completions_test.go b/backend/internal/service/openai_gateway_chat_completions_test.go new file mode 100644 index 0000000000..a99c5491ce --- /dev/null +++ b/backend/internal/service/openai_gateway_chat_completions_test.go @@ -0,0 +1,144 @@ +package service + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestOpenAIGatewayService_ForwardAsChatCompletions_APIKeyPreservesStructuredOutput(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(nil)) + c.Request.Header.Set("Content-Type", "application/json") + + reqBody := []byte(`{ + "model":"gpt-5.4", + "messages":[{"role":"user","content":"Return weather as JSON"}], + "response_format":{ + "type":"json_schema", + "json_schema":{ + "name":"weather", + "strict":true, + "schema":{ + "type":"object", + "properties":{"city":{"type":"string"}}, + "required":["city"], + "additionalProperties":false + } + } + } + }`) + + upstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid"}}, + Body: io.NopCloser(bytes.NewBufferString( + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"{\\\"city\\\":\\\"Paris\\\"}\"}]}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + + "data: [DONE]\n\n", + )), + }, + } + + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + } + + account := &Account{ + ID: 1, + Name: "apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + }, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, reqBody, "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "https://api.openai.com/v1/responses", upstream.lastReq.URL.String()) + require.Equal(t, "json_schema", gjson.GetBytes(upstream.lastBody, "text.format.type").String()) + require.Equal(t, "weather", gjson.GetBytes(upstream.lastBody, "text.format.json_schema.name").String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "text.format.json_schema.strict").Bool()) +} + +func TestOpenAIGatewayService_ForwardAsChatCompletions_OAuthPreservesStructuredOutput(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(nil)) + c.Request.Header.Set("Content-Type", "application/json") + + reqBody := []byte(`{ + "model":"gpt-5.4", + "messages":[{"role":"user","content":"Return weather as JSON"}], + "response_format":{ + "type":"json_schema", + "json_schema":{ + "name":"weather", + "strict":true, + "schema":{ + "type":"object", + "properties":{"city":{"type":"string"}}, + "required":["city"], + "additionalProperties":false + } + } + } + }`) + + upstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid"}}, + Body: io.NopCloser(bytes.NewBufferString( + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"{\\\"city\\\":\\\"Paris\\\"}\"}]}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + + "data: [DONE]\n\n", + )), + }, + } + + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + } + + account := &Account{ + ID: 2, + Name: "oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + }, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, reqBody, "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, chatgptCodexURL, upstream.lastReq.URL.String()) + require.Equal(t, "json_schema", gjson.GetBytes(upstream.lastBody, "text.format.type").String()) + require.Equal(t, "weather", gjson.GetBytes(upstream.lastBody, "text.format.json_schema.name").String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "text.format.json_schema.strict").Bool()) +} From 58474248388ccbabb788e3e9a47c901f647e6a9f Mon Sep 17 00:00:00 2001 From: zhuhaow Date: Sat, 4 Apr 2026 09:27:56 +0800 Subject: [PATCH 2/3] fix(openai): map Chat Completions json_schema to Responses text.format --- .../chatcompletions_responses_test.go | 25 +++++++- .../apicompat/chatcompletions_to_responses.go | 59 ++++++++++++++++++- .../openai_gateway_chat_completions_test.go | 10 ++-- 3 files changed, 86 insertions(+), 8 deletions(-) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index b082a66f37..87a5293f80 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -180,7 +180,30 @@ func TestChatCompletionsToResponses_ResponseFormat(t *testing.T) { resp, err := ChatCompletionsToResponses(req) require.NoError(t, err) require.NotNil(t, resp.Text) - assert.JSONEq(t, string(req.ResponseFormat), string(resp.Text.Format)) + + var format map[string]any + require.NoError(t, json.Unmarshal(resp.Text.Format, &format)) + assert.Equal(t, "json_schema", format["type"]) + assert.Equal(t, "weather", format["name"]) + assert.Equal(t, true, format["strict"]) + + schema, ok := format["schema"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "object", schema["type"]) +} + +func TestChatCompletionsToResponses_ResponseFormatJSONObject(t *testing.T) { + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + Messages: []ChatMessage{ + {Role: "user", Content: json.RawMessage(`"Return JSON"`)}}, + ResponseFormat: json.RawMessage(`{"type":"json_object"}`), + } + + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + require.NotNil(t, resp.Text) + assert.JSONEq(t, `{"type":"json_object"}`, string(resp.Text.Format)) } func TestChatCompletionsToResponses_ImageURL(t *testing.T) { diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index 1973404d73..643021e614 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -11,6 +11,26 @@ type chatMessageContent struct { Parts []ChatContentPart } +type chatResponseFormat struct { + Type string `json:"type"` + JSONSchema *chatResponseJSONSchema `json:"json_schema,omitempty"` +} + +type chatResponseJSONSchema struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Schema json.RawMessage `json:"schema,omitempty"` + Strict *bool `json:"strict,omitempty"` +} + +type responsesTextFormat struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Schema json.RawMessage `json:"schema,omitempty"` + Strict *bool `json:"strict,omitempty"` +} + // ChatCompletionsToResponses converts a Chat Completions request into a // Responses API request. The upstream always streams, so Stream is forced to // true. store is always false and reasoning.encrypted_content is always @@ -64,10 +84,14 @@ func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, } // Chat Completions structured outputs use response_format; Responses uses - // text.format. Preserve the raw schema payload so Zod/json_schema requests - // survive the compatibility conversion unchanged. + // text.format with a different shape. Convert json_schema requests from + // response_format.json_schema.* into text.format.*. if len(req.ResponseFormat) > 0 { - out.Text = &ResponsesText{Format: req.ResponseFormat} + format, err := convertChatResponseFormat(req.ResponseFormat) + if err != nil { + return nil, fmt.Errorf("convert response_format: %w", err) + } + out.Text = &ResponsesText{Format: format} } // tools[] and legacy functions[] → ResponsesTool[] @@ -90,6 +114,35 @@ func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, return out, nil } +func convertChatResponseFormat(raw json.RawMessage) (json.RawMessage, error) { + var format chatResponseFormat + if err := json.Unmarshal(raw, &format); err != nil { + return nil, err + } + + switch format.Type { + case "json_schema": + if format.JSONSchema == nil { + return nil, fmt.Errorf("response_format.json_schema is required") + } + return json.Marshal(responsesTextFormat{ + Type: "json_schema", + Name: format.JSONSchema.Name, + Description: format.JSONSchema.Description, + Schema: format.JSONSchema.Schema, + Strict: format.JSONSchema.Strict, + }) + case "json_object": + return json.Marshal(struct { + Type string `json:"type"` + }{Type: "json_object"}) + default: + // Keep unknown formats untouched so upstream validation remains the + // source of truth for any newer response_format shapes. + return raw, nil + } +} + // convertChatMessagesToResponsesInput converts the Chat Completions messages // array into a Responses API input items array. func convertChatMessagesToResponsesInput(msgs []ChatMessage) ([]ResponsesInputItem, error) { diff --git a/backend/internal/service/openai_gateway_chat_completions_test.go b/backend/internal/service/openai_gateway_chat_completions_test.go index a99c5491ce..2da02bf68c 100644 --- a/backend/internal/service/openai_gateway_chat_completions_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_test.go @@ -74,8 +74,9 @@ func TestOpenAIGatewayService_ForwardAsChatCompletions_APIKeyPreservesStructured require.NotNil(t, result) require.Equal(t, "https://api.openai.com/v1/responses", upstream.lastReq.URL.String()) require.Equal(t, "json_schema", gjson.GetBytes(upstream.lastBody, "text.format.type").String()) - require.Equal(t, "weather", gjson.GetBytes(upstream.lastBody, "text.format.json_schema.name").String()) - require.True(t, gjson.GetBytes(upstream.lastBody, "text.format.json_schema.strict").Bool()) + require.Equal(t, "weather", gjson.GetBytes(upstream.lastBody, "text.format.name").String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "text.format.strict").Bool()) + require.Equal(t, "object", gjson.GetBytes(upstream.lastBody, "text.format.schema.type").String()) } func TestOpenAIGatewayService_ForwardAsChatCompletions_OAuthPreservesStructuredOutput(t *testing.T) { @@ -139,6 +140,7 @@ func TestOpenAIGatewayService_ForwardAsChatCompletions_OAuthPreservesStructuredO require.NotNil(t, result) require.Equal(t, chatgptCodexURL, upstream.lastReq.URL.String()) require.Equal(t, "json_schema", gjson.GetBytes(upstream.lastBody, "text.format.type").String()) - require.Equal(t, "weather", gjson.GetBytes(upstream.lastBody, "text.format.json_schema.name").String()) - require.True(t, gjson.GetBytes(upstream.lastBody, "text.format.json_schema.strict").Bool()) + require.Equal(t, "weather", gjson.GetBytes(upstream.lastBody, "text.format.name").String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "text.format.strict").Bool()) + require.Equal(t, "object", gjson.GetBytes(upstream.lastBody, "text.format.schema.type").String()) } From 570a78f64571f135c120e1ba2015707446527c41 Mon Sep 17 00:00:00 2001 From: zhuhaow Date: Sun, 5 Apr 2026 17:28:58 +0800 Subject: [PATCH 3/3] fix(openai): preserve text.format order through oauth transform --- .../openai_gateway_chat_completions.go | 5 ++ .../openai_gateway_chat_completions_test.go | 71 +++++++++++++++++++ .../service/openai_gateway_messages.go | 5 ++ .../service/openai_gateway_service.go | 15 ++++ 4 files changed, 96 insertions(+) diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 1d5bf0d0a4..2e1bdd68b9 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -85,6 +85,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( } if account.Type == AccountTypeOAuth { + textFormatRaw := extractResponsesTextFormatRaw(responsesBody) var reqBody map[string]any if err := json.Unmarshal(responsesBody, &reqBody); err != nil { return nil, fmt.Errorf("unmarshal for codex transform: %w", err) @@ -102,6 +103,10 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( if err != nil { return nil, fmt.Errorf("remarshal after codex transform: %w", err) } + responsesBody, err = restoreResponsesTextFormatRaw(responsesBody, textFormatRaw) + if err != nil { + return nil, fmt.Errorf("restore text.format after codex transform: %w", err) + } } // 5. Get access token diff --git a/backend/internal/service/openai_gateway_chat_completions_test.go b/backend/internal/service/openai_gateway_chat_completions_test.go index 2da02bf68c..df3fd35fed 100644 --- a/backend/internal/service/openai_gateway_chat_completions_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" "github.com/Wei-Shaw/sub2api/internal/config" @@ -144,3 +145,73 @@ func TestOpenAIGatewayService_ForwardAsChatCompletions_OAuthPreservesStructuredO require.True(t, gjson.GetBytes(upstream.lastBody, "text.format.strict").Bool()) require.Equal(t, "object", gjson.GetBytes(upstream.lastBody, "text.format.schema.type").String()) } + +func TestOpenAIGatewayService_ForwardAsChatCompletions_OAuthPreservesStructuredOutputSchemaOrder(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(nil)) + c.Request.Header.Set("Content-Type", "application/json") + + reqBody := []byte(`{ + "model":"gpt-5.4", + "messages":[{"role":"user","content":"Return weather as JSON"}], + "response_format":{ + "type":"json_schema", + "json_schema":{ + "name":"weather", + "strict":true, + "schema":{ + "zeta":{"type":"string"}, + "alpha":{"type":"string"}, + "mid":{"type":"object","properties":{"k2":{"type":"string"},"k1":{"type":"string"}}}, + "arr":["b","a"] + } + } + } + }`) + + upstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid"}}, + Body: io.NopCloser(bytes.NewBufferString( + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"{}\"}]}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + + "data: [DONE]\n\n", + )), + }, + } + + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + } + + account := &Account{ + ID: 2, + Name: "oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + }, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, reqBody, "", "") + require.NoError(t, err) + require.NotNil(t, result) + schemaRaw := gjson.GetBytes(upstream.lastBody, "text.format.schema").Raw + require.NotEqual(t, -1, strings.Index(schemaRaw, `"zeta"`)) + require.NotEqual(t, -1, strings.Index(schemaRaw, `"alpha"`)) + require.NotEqual(t, -1, strings.Index(schemaRaw, `"mid"`)) + require.NotEqual(t, -1, strings.Index(schemaRaw, `"arr"`)) + require.Less(t, strings.Index(schemaRaw, `"zeta"`), strings.Index(schemaRaw, `"alpha"`)) + require.Less(t, strings.Index(schemaRaw, `"alpha"`), strings.Index(schemaRaw, `"mid"`)) + require.Less(t, strings.Index(schemaRaw, `"mid"`), strings.Index(schemaRaw, `"arr"`)) + require.Less(t, strings.Index(schemaRaw, `"k2"`), strings.Index(schemaRaw, `"k1"`)) +} diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 8c389556f2..696dc16693 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -81,6 +81,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( } if account.Type == AccountTypeOAuth { + textFormatRaw := extractResponsesTextFormatRaw(responsesBody) var reqBody map[string]any if err := json.Unmarshal(responsesBody, &reqBody); err != nil { return nil, fmt.Errorf("unmarshal for codex transform: %w", err) @@ -101,6 +102,10 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( if err != nil { return nil, fmt.Errorf("remarshal after codex transform: %w", err) } + responsesBody, err = restoreResponsesTextFormatRaw(responsesBody, textFormatRaw) + if err != nil { + return nil, fmt.Errorf("restore text.format after codex transform: %w", err) + } } // 5. Get access token diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index e85f0705aa..34667330f5 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -4618,6 +4618,21 @@ func normalizeOpenAIPassthroughOAuthBody(body []byte, compact bool) ([]byte, boo return normalized, changed, nil } +func extractResponsesTextFormatRaw(body []byte) json.RawMessage { + format := gjson.GetBytes(body, "text.format") + if !format.Exists() || strings.TrimSpace(format.Raw) == "" { + return nil + } + return json.RawMessage(format.Raw) +} + +func restoreResponsesTextFormatRaw(body []byte, format json.RawMessage) ([]byte, error) { + if len(format) == 0 { + return body, nil + } + return sjson.SetRawBytes(body, "text.format", format) +} + func detectOpenAIPassthroughInstructionsRejectReason(reqModel string, body []byte) string { model := strings.ToLower(strings.TrimSpace(reqModel)) if !strings.Contains(model, "codex") {