diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_instructions_test.go b/backend/internal/pkg/apicompat/responses_to_anthropic_instructions_test.go new file mode 100644 index 0000000000..b63787cd44 --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_instructions_test.go @@ -0,0 +1,126 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResponsesToAnthropicRequest_Instructions(t *testing.T) { + t.Run("instructions_becomes_system", func(t *testing.T) { + req := &ResponsesRequest{ + Model: "claude-sonnet-4-20250514", + Instructions: "You are a helpful assistant.", + Input: json.RawMessage(`[{"role":"user","content":"hello"}]`), + } + + result, err := ResponsesToAnthropicRequest(req) + require.NoError(t, err) + + var system string + require.NoError(t, json.Unmarshal(result.System, &system)) + assert.Equal(t, "You are a helpful assistant.", system) + assert.NotEmpty(t, result.Messages) + }) + + t.Run("empty_instructions_no_system", func(t *testing.T) { + req := &ResponsesRequest{ + Model: "claude-sonnet-4-20250514", + Input: json.RawMessage(`[{"role":"user","content":"hello"}]`), + } + + result, err := ResponsesToAnthropicRequest(req) + require.NoError(t, err) + assert.Nil(t, result.System) + }) + + t.Run("instructions_and_system_item_concatenated", func(t *testing.T) { + req := &ResponsesRequest{ + Model: "claude-sonnet-4-20250514", + Instructions: "Top-level instruction.", + Input: json.RawMessage(`[ + {"role":"system","content":"Input-level system prompt."}, + {"role":"user","content":"hello"} + ]`), + } + + result, err := ResponsesToAnthropicRequest(req) + require.NoError(t, err) + + var system string + require.NoError(t, json.Unmarshal(result.System, &system)) + assert.Contains(t, system, "Top-level instruction.") + assert.Contains(t, system, "Input-level system prompt.") + }) + + t.Run("instructions_with_string_input", func(t *testing.T) { + req := &ResponsesRequest{ + Model: "claude-sonnet-4-20250514", + Instructions: "Be concise.", + Input: json.RawMessage(`"What is Go?"`), + } + + result, err := ResponsesToAnthropicRequest(req) + require.NoError(t, err) + + var system string + require.NoError(t, json.Unmarshal(result.System, &system)) + assert.Equal(t, "Be concise.", system) + require.Len(t, result.Messages, 1) + assert.Equal(t, "user", result.Messages[0].Role) + }) +} + +func TestConvertResponsesInputToAnthropic_DeveloperRole(t *testing.T) { + t.Run("developer_becomes_system", func(t *testing.T) { + input := `[ + {"role":"developer","content":[{"type":"input_text","text":"You are a code reviewer."}]}, + {"role":"user","content":"review this code"} + ]` + + system, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input)) + require.NoError(t, err) + + var systemText string + require.NoError(t, json.Unmarshal(system, &systemText)) + assert.Equal(t, "You are a code reviewer.", systemText) + + require.Len(t, messages, 1) + assert.Equal(t, "user", messages[0].Role) + }) + + t.Run("developer_does_not_become_user", func(t *testing.T) { + input := `[ + {"role":"developer","content":[{"type":"input_text","text":"System prompt."}]}, + {"role":"user","content":"hi"} + ]` + + _, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input)) + require.NoError(t, err) + + for _, m := range messages { + if m.Role == "user" { + var s string + if json.Unmarshal(m.Content, &s) == nil { + assert.NotContains(t, s, "System prompt.") + } + } + } + }) + + t.Run("instructions_and_developer_concatenated_in_order", func(t *testing.T) { + input := `[ + {"role":"developer","content":"Extra context."}, + {"role":"user","content":"hello"} + ]` + + system, _, err := convertResponsesInputToAnthropic("Main instruction.", json.RawMessage(input)) + require.NoError(t, err) + + var systemText string + require.NoError(t, json.Unmarshal(system, &systemText)) + assert.Equal(t, "Main instruction.\n\nExtra context.", systemText) + }) +} diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go index 6da249ed25..46f57d0ca6 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go @@ -11,7 +11,7 @@ import ( // enables Anthropic platform groups to accept OpenAI Responses API requests // by converting them to the native /v1/messages format before forwarding upstream. func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, error) { - system, messages, err := convertResponsesInputToAnthropic(req.Input) + system, messages, err := convertResponsesInputToAnthropic(req.Instructions, req.Input) if err != nil { return nil, err } @@ -98,14 +98,23 @@ func mapResponsesEffortToAnthropic(effort string) string { } // convertResponsesInputToAnthropic extracts system prompt and messages from -// a Responses API input array. Returns the system as raw JSON (for Anthropic's -// polymorphic system field) and a list of Anthropic messages. -func convertResponsesInputToAnthropic(inputRaw json.RawMessage) (json.RawMessage, []AnthropicMessage, error) { +// a Responses API instructions + input array. Returns the system as raw JSON +// (for Anthropic's polymorphic system field) and a list of Anthropic messages. +func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMessage) (json.RawMessage, []AnthropicMessage, error) { + var systemParts []string + if strings.TrimSpace(instructions) != "" { + systemParts = append(systemParts, strings.TrimSpace(instructions)) + } + // Try as plain string input. var inputStr string if err := json.Unmarshal(inputRaw, &inputStr); err == nil { content, _ := json.Marshal(inputStr) - return nil, []AnthropicMessage{{Role: "user", Content: content}}, nil + var system json.RawMessage + if len(systemParts) > 0 { + system, _ = json.Marshal(strings.Join(systemParts, "\n\n")) + } + return system, []AnthropicMessage{{Role: "user", Content: content}}, nil } var items []ResponsesInputItem @@ -113,16 +122,14 @@ func convertResponsesInputToAnthropic(inputRaw json.RawMessage) (json.RawMessage return nil, nil, fmt.Errorf("parse responses input: %w", err) } - var system json.RawMessage var messages []AnthropicMessage for _, item := range items { switch { - case item.Role == "system": - // System prompt → Anthropic system field + case item.Role == "system" || item.Role == "developer": text := extractTextFromContent(item.Content) if text != "" { - system, _ = json.Marshal(text) + systemParts = append(systemParts, text) } case item.Type == "function_call": @@ -201,6 +208,11 @@ func convertResponsesInputToAnthropic(inputRaw json.RawMessage) (json.RawMessage messages = normalizeAnthropicToolPairing(messages) messages = mergeConsecutiveMessages(messages) + var system json.RawMessage + if len(systemParts) > 0 { + system, _ = json.Marshal(strings.Join(systemParts, "\n\n")) + } + return system, messages, nil } diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go b/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go index b2522f274b..1a51b5478d 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go @@ -58,7 +58,7 @@ func hasToolResult(blocks []AnthropicContentBlock, toolUseID string) bool { func convertAnthropic(t *testing.T, input string) []AnthropicMessage { t.Helper() - _, messages, err := convertResponsesInputToAnthropic(json.RawMessage(input)) + _, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input)) require.NoError(t, err) assertAnthropicPairing(t, messages) return messages