Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions backend/internal/pkg/apicompat/chatcompletions_responses_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,55 @@ 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)

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) {
content := `[{"type":"text","text":"Describe this"},{"type":"image_url","image_url":{"url":"data:image/png;base64,abc123"}}]`
req := &ChatCompletionsRequest{
Expand Down
60 changes: 60 additions & 0 deletions backend/internal/pkg/apicompat/chatcompletions_to_responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,6 +83,17 @@ func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest,
}
}

// Chat Completions structured outputs use response_format; Responses uses
// text.format with a different shape. Convert json_schema requests from
// response_format.json_schema.* into text.format.*.
if len(req.ResponseFormat) > 0 {
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[]
if len(req.Tools) > 0 || len(req.Functions) > 0 {
out.Tools = convertChatToolsToResponses(req.Tools, req.Functions)
Expand All @@ -83,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) {
Expand Down
7 changes: 7 additions & 0 deletions backend/internal/pkg/apicompat/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,19 @@ 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"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
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"
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions backend/internal/service/openai_gateway_chat_completions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
Loading