Skip to content

Commit 4feacf2

Browse files
committed
fix(apicompat): support array content for system and tool messages
1 parent 186e367 commit 4feacf2

2 files changed

Lines changed: 146 additions & 38 deletions

File tree

backend/internal/pkg/apicompat/chatcompletions_responses_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,35 @@ func TestChatCompletionsToResponses_ImageURL(t *testing.T) {
181181
assert.Equal(t, "data:image/png;base64,abc123", parts[1].ImageURL)
182182
}
183183

184+
func TestChatCompletionsToResponses_SystemArrayContent(t *testing.T) {
185+
req := &ChatCompletionsRequest{
186+
Model: "gpt-4o",
187+
Messages: []ChatMessage{
188+
{Role: "system", Content: json.RawMessage(`[{"type":"text","text":"You are a careful visual assistant."}]`)},
189+
{Role: "user", Content: json.RawMessage(`[{"type":"text","text":"Describe this image"},{"type":"image_url","image_url":{"url":"data:image/png;base64,abc123"}}]`)},
190+
},
191+
}
192+
193+
resp, err := ChatCompletionsToResponses(req)
194+
require.NoError(t, err)
195+
196+
var items []ResponsesInputItem
197+
require.NoError(t, json.Unmarshal(resp.Input, &items))
198+
require.Len(t, items, 2)
199+
200+
var systemParts []ResponsesContentPart
201+
require.NoError(t, json.Unmarshal(items[0].Content, &systemParts))
202+
require.Len(t, systemParts, 1)
203+
assert.Equal(t, "input_text", systemParts[0].Type)
204+
assert.Equal(t, "You are a careful visual assistant.", systemParts[0].Text)
205+
206+
var userParts []ResponsesContentPart
207+
require.NoError(t, json.Unmarshal(items[1].Content, &userParts))
208+
require.Len(t, userParts, 2)
209+
assert.Equal(t, "input_image", userParts[1].Type)
210+
assert.Equal(t, "data:image/png;base64,abc123", userParts[1].ImageURL)
211+
}
212+
184213
func TestChatCompletionsToResponses_LegacyFunctions(t *testing.T) {
185214
req := &ChatCompletionsRequest{
186215
Model: "gpt-4o",
@@ -398,6 +427,45 @@ func TestResponsesToChatCompletions_Reasoning(t *testing.T) {
398427
assert.Equal(t, "I thought about it.", chat.Choices[0].Message.ReasoningContent)
399428
}
400429

430+
func TestChatCompletionsToResponses_ToolArrayContent(t *testing.T) {
431+
req := &ChatCompletionsRequest{
432+
Model: "gpt-4o",
433+
Messages: []ChatMessage{
434+
{Role: "user", Content: json.RawMessage(`"Use the tool"`)},
435+
{
436+
Role: "assistant",
437+
ToolCalls: []ChatToolCall{
438+
{
439+
ID: "call_1",
440+
Type: "function",
441+
Function: ChatFunctionCall{
442+
Name: "inspect_image",
443+
Arguments: `{}`,
444+
},
445+
},
446+
},
447+
},
448+
{
449+
Role: "tool",
450+
ToolCallID: "call_1",
451+
Content: json.RawMessage(
452+
`[{"type":"text","text":"image width: 100"},{"type":"image_url","image_url":{"url":"data:image/png;base64,ignored"}},{"type":"text","text":"; image height: 200"}]`,
453+
),
454+
},
455+
},
456+
}
457+
458+
resp, err := ChatCompletionsToResponses(req)
459+
require.NoError(t, err)
460+
461+
var items []ResponsesInputItem
462+
require.NoError(t, json.Unmarshal(resp.Input, &items))
463+
require.Len(t, items, 3)
464+
assert.Equal(t, "function_call_output", items[2].Type)
465+
assert.Equal(t, "call_1", items[2].CallID)
466+
assert.Equal(t, "image width: 100; image height: 200", items[2].Output)
467+
}
468+
401469
func TestResponsesToChatCompletions_Incomplete(t *testing.T) {
402470
resp := &ResponsesResponse{
403471
ID: "resp_inc",

backend/internal/pkg/apicompat/chatcompletions_to_responses.go

Lines changed: 78 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import (
66
"strings"
77
)
88

9+
type chatMessageContent struct {
10+
Text *string
11+
Parts []ChatContentPart
12+
}
13+
914
// ChatCompletionsToResponses converts a Chat Completions request into a
1015
// Responses API request. The upstream always streams, so Stream is forced to
1116
// true. store is always false and reasoning.encrypted_content is always
@@ -113,11 +118,11 @@ func chatMessageToResponsesItems(m ChatMessage) ([]ResponsesInputItem, error) {
113118

114119
// chatSystemToResponses converts a system message.
115120
func chatSystemToResponses(m ChatMessage) ([]ResponsesInputItem, error) {
116-
text, err := parseChatContent(m.Content)
121+
parsed, err := parseChatMessageContent(m.Content)
117122
if err != nil {
118123
return nil, err
119124
}
120-
content, err := json.Marshal(text)
125+
content, err := marshalChatInputContent(parsed)
121126
if err != nil {
122127
return nil, err
123128
}
@@ -127,39 +132,11 @@ func chatSystemToResponses(m ChatMessage) ([]ResponsesInputItem, error) {
127132
// chatUserToResponses converts a user message, handling both plain strings and
128133
// multi-modal content arrays.
129134
func chatUserToResponses(m ChatMessage) ([]ResponsesInputItem, error) {
130-
// Try plain string first.
131-
var s string
132-
if err := json.Unmarshal(m.Content, &s); err == nil {
133-
content, _ := json.Marshal(s)
134-
return []ResponsesInputItem{{Role: "user", Content: content}}, nil
135-
}
136-
137-
var parts []ChatContentPart
138-
if err := json.Unmarshal(m.Content, &parts); err != nil {
135+
parsed, err := parseChatMessageContent(m.Content)
136+
if err != nil {
139137
return nil, fmt.Errorf("parse user content: %w", err)
140138
}
141-
142-
var responseParts []ResponsesContentPart
143-
for _, p := range parts {
144-
switch p.Type {
145-
case "text":
146-
if p.Text != "" {
147-
responseParts = append(responseParts, ResponsesContentPart{
148-
Type: "input_text",
149-
Text: p.Text,
150-
})
151-
}
152-
case "image_url":
153-
if p.ImageURL != nil && p.ImageURL.URL != "" {
154-
responseParts = append(responseParts, ResponsesContentPart{
155-
Type: "input_image",
156-
ImageURL: p.ImageURL.URL,
157-
})
158-
}
159-
}
160-
}
161-
162-
content, err := json.Marshal(responseParts)
139+
content, err := marshalChatInputContent(parsed)
163140
if err != nil {
164141
return nil, err
165142
}
@@ -312,16 +289,79 @@ func chatFunctionToResponses(m ChatMessage) ([]ResponsesInputItem, error) {
312289
}
313290

314291
// parseChatContent returns the string value of a ChatMessage Content field.
315-
// Content must be a JSON string. Returns "" if content is null or empty.
292+
// Content can be a JSON string or an array of typed parts. Array content is
293+
// flattened to text by concatenating text parts and ignoring non-text parts.
316294
func parseChatContent(raw json.RawMessage) (string, error) {
295+
parsed, err := parseChatMessageContent(raw)
296+
if err != nil {
297+
return "", err
298+
}
299+
if parsed.Text != nil {
300+
return *parsed.Text, nil
301+
}
302+
return flattenChatContentParts(parsed.Parts), nil
303+
}
304+
305+
func parseChatMessageContent(raw json.RawMessage) (chatMessageContent, error) {
317306
if len(raw) == 0 {
318-
return "", nil
307+
return chatMessageContent{Text: stringPtr("")}, nil
319308
}
309+
320310
var s string
321-
if err := json.Unmarshal(raw, &s); err != nil {
322-
return "", fmt.Errorf("parse content as string: %w", err)
311+
if err := json.Unmarshal(raw, &s); err == nil {
312+
return chatMessageContent{Text: &s}, nil
323313
}
324-
return s, nil
314+
315+
var parts []ChatContentPart
316+
if err := json.Unmarshal(raw, &parts); err == nil {
317+
return chatMessageContent{Parts: parts}, nil
318+
}
319+
320+
return chatMessageContent{}, fmt.Errorf("parse content as string or parts array")
321+
}
322+
323+
func marshalChatInputContent(content chatMessageContent) (json.RawMessage, error) {
324+
if content.Text != nil {
325+
return json.Marshal(*content.Text)
326+
}
327+
return json.Marshal(convertChatContentPartsToResponses(content.Parts))
328+
}
329+
330+
func convertChatContentPartsToResponses(parts []ChatContentPart) []ResponsesContentPart {
331+
var responseParts []ResponsesContentPart
332+
for _, p := range parts {
333+
switch p.Type {
334+
case "text":
335+
if p.Text != "" {
336+
responseParts = append(responseParts, ResponsesContentPart{
337+
Type: "input_text",
338+
Text: p.Text,
339+
})
340+
}
341+
case "image_url":
342+
if p.ImageURL != nil && p.ImageURL.URL != "" {
343+
responseParts = append(responseParts, ResponsesContentPart{
344+
Type: "input_image",
345+
ImageURL: p.ImageURL.URL,
346+
})
347+
}
348+
}
349+
}
350+
return responseParts
351+
}
352+
353+
func flattenChatContentParts(parts []ChatContentPart) string {
354+
var textParts []string
355+
for _, p := range parts {
356+
if p.Type == "text" && p.Text != "" {
357+
textParts = append(textParts, p.Text)
358+
}
359+
}
360+
return strings.Join(textParts, "")
361+
}
362+
363+
func stringPtr(s string) *string {
364+
return &s
325365
}
326366

327367
// convertChatToolsToResponses maps Chat Completions tool definitions and legacy

0 commit comments

Comments
 (0)