From 5fcbe7e3070875a92d05299eafe30f67e897ad7f Mon Sep 17 00:00:00 2001 From: CHOS1N Date: Sun, 5 Jul 2026 13:58:08 +0800 Subject: [PATCH] Add response_format compatibility mapping --- .../chatcompletions_responses_bridge.go | 3 + .../chatcompletions_responses_bridge_test.go | 54 +++++++++++ .../chatcompletions_responses_test.go | 56 +++++++++++ .../apicompat/chatcompletions_to_responses.go | 7 ++ .../internal/pkg/apicompat/response_format.go | 92 +++++++++++++++++++ backend/internal/pkg/apicompat/types.go | 2 + 6 files changed, 214 insertions(+) create mode 100644 backend/internal/pkg/apicompat/response_format.go diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index f0570b58ec..eeeedd29aa 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -38,6 +38,9 @@ func ResponsesToChatCompletionsRequest(req *ResponsesRequest) (*ChatCompletionsR if len(req.ToolChoice) > 0 { out.ToolChoice = responsesToolChoiceToChatToolChoice(req.ToolChoice) } + if req.Text != nil { + out.ResponseFormat = responsesTextFormatToChatResponseFormat(req.Text.Format) + } return out, nil } diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go index 3e55e23a81..b194d88141 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go @@ -73,6 +73,60 @@ func TestResponsesToChatCompletionsRequest_InstructionsAndInputDeveloperRole(t * assert.JSONEq(t, `"Hello"`, string(out.Messages[2].Content)) } +func TestResponsesToChatCompletionsRequest_TextFormatJsonObject(t *testing.T) { + req := &ResponsesRequest{ + Model: "gpt-4o", + Input: json.RawMessage(`[ + {"role":"user","content":"Return JSON"} + ]`), + Text: &ResponsesText{ + Format: json.RawMessage(`{"type":"json_object"}`), + }, + } + + out, err := ResponsesToChatCompletionsRequest(req) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"json_object"}`, string(out.ResponseFormat)) +} + +func TestResponsesToChatCompletionsRequest_TextFormatJsonSchema(t *testing.T) { + req := &ResponsesRequest{ + Model: "gpt-4o", + Input: json.RawMessage(`[ + {"role":"user","content":"Return structured JSON"} + ]`), + Text: &ResponsesText{ + Format: json.RawMessage(`{ + "type":"json_schema", + "name":"answer", + "schema":{ + "type":"object", + "properties":{"ok":{"type":"boolean"}}, + "required":["ok"], + "additionalProperties":false + }, + "strict":true + }`), + }, + } + + out, err := ResponsesToChatCompletionsRequest(req) + require.NoError(t, err) + assert.JSONEq(t, `{ + "type":"json_schema", + "json_schema":{ + "name":"answer", + "schema":{ + "type":"object", + "properties":{"ok":{"type":"boolean"}}, + "required":["ok"], + "additionalProperties":false + }, + "strict":true + } + }`, string(out.ResponseFormat)) +} + func chatMessageRoles(messages []ChatMessage) []string { roles := make([]string, 0, len(messages)) for _, message := range messages { diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index 795a73938e..b30330863c 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -242,6 +242,62 @@ func TestChatCompletionsToResponses_ReasoningEffort(t *testing.T) { assert.Equal(t, "auto", resp.Reasoning.Summary) } +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)) + + payload, err := json.Marshal(resp) + require.NoError(t, err) + var serialized struct { + Text ResponsesText `json:"text"` + } + require.NoError(t, json.Unmarshal(payload, &serialized)) + assert.JSONEq(t, `{"type":"json_object"}`, string(serialized.Text.Format)) +} + +func TestChatCompletionsToResponses_ResponseFormatJsonSchema(t *testing.T) { + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Return structured JSON"`)}}, + ResponseFormat: json.RawMessage(`{ + "type":"json_schema", + "json_schema":{ + "name":"answer", + "schema":{ + "type":"object", + "properties":{"ok":{"type":"boolean"}}, + "required":["ok"], + "additionalProperties":false + }, + "strict":true + } + }`), + } + + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + require.NotNil(t, resp.Text) + assert.JSONEq(t, `{ + "type":"json_schema", + "name":"answer", + "schema":{ + "type":"object", + "properties":{"ok":{"type":"boolean"}}, + "required":["ok"], + "additionalProperties":false + }, + "strict":true + }`, 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 7cbb4f5f20..07c557ab3b 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -69,6 +69,13 @@ func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, } } + if format := chatResponseFormatToResponsesTextFormat(req.ResponseFormat); len(format) > 0 { + if out.Text == nil { + out.Text = &ResponsesText{} + } + out.Text.Format = format + } + // 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/response_format.go b/backend/internal/pkg/apicompat/response_format.go new file mode 100644 index 0000000000..afb5c3e2fd --- /dev/null +++ b/backend/internal/pkg/apicompat/response_format.go @@ -0,0 +1,92 @@ +package apicompat + +import "encoding/json" + +func chatResponseFormatToResponsesTextFormat(raw json.RawMessage) json.RawMessage { + raw = normalizedRawJSON(raw) + if len(raw) == 0 { + return nil + } + + obj, ok := rawJSONObject(raw) + if !ok || rawString(obj["type"]) != "json_schema" { + return raw + } + + schemaRaw := normalizedRawJSON(obj["json_schema"]) + if len(schemaRaw) == 0 { + return raw + } + + var schema map[string]json.RawMessage + if err := json.Unmarshal(schemaRaw, &schema); err != nil { + return raw + } + schema["type"] = rawJSONString("json_schema") + + out, err := json.Marshal(schema) + if err != nil { + return raw + } + return out +} + +func responsesTextFormatToChatResponseFormat(raw json.RawMessage) json.RawMessage { + raw = normalizedRawJSON(raw) + if len(raw) == 0 { + return nil + } + + obj, ok := rawJSONObject(raw) + if !ok || rawString(obj["type"]) != "json_schema" { + return raw + } + if _, alreadyChatShape := obj["json_schema"]; alreadyChatShape { + return raw + } + + schema := make(map[string]json.RawMessage, len(obj)) + for key, value := range obj { + if key == "type" { + continue + } + schema[key] = value + } + if len(schema) == 0 { + return raw + } + + schemaRaw, err := json.Marshal(schema) + if err != nil { + return raw + } + out, err := json.Marshal(map[string]json.RawMessage{ + "type": rawJSONString("json_schema"), + "json_schema": schemaRaw, + }) + if err != nil { + return raw + } + return out +} + +func normalizedRawJSON(raw json.RawMessage) json.RawMessage { + raw = bytesTrimSpace(raw) + if len(raw) == 0 || string(raw) == "null" { + return nil + } + return append(json.RawMessage(nil), raw...) +} + +func rawJSONObject(raw json.RawMessage) (map[string]json.RawMessage, bool) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return nil, false + } + return obj, true +} + +func rawJSONString(value string) json.RawMessage { + data, _ := json.Marshal(value) + return data +} diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index d293780278..cf5bf106bc 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -216,6 +216,7 @@ type ResponsesReasoning struct { // ResponsesText configures text output options in the Responses API. type ResponsesText struct { + Format json.RawMessage `json:"format,omitempty"` Verbosity string `json:"verbosity,omitempty"` // "low" | "medium" | "high" } @@ -438,6 +439,7 @@ type ChatCompletionsRequest struct { ReasoningEffort string `json:"reasoning_effort,omitempty"` // "low" | "medium" | "high" | "xhigh" ServiceTier string `json:"service_tier,omitempty"` Stop json.RawMessage `json:"stop,omitempty"` // string or []string + ResponseFormat json.RawMessage `json:"response_format,omitempty"` // Legacy function calling (deprecated but still supported) Functions []ChatFunction `json:"functions,omitempty"`