first commit
This commit is contained in:
872
core/providers/openai/responses_marshal_test.go
Normal file
872
core/providers/openai/responses_marshal_test.go
Normal file
@@ -0,0 +1,872 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/maximhq/bifrost/core/schemas"
|
||||
)
|
||||
|
||||
func TestOpenAIResponsesRequest_MarshalJSON_ReasoningMaxTokensAbsent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
request *OpenAIResponsesRequest
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "reasoning with MaxTokens set should omit max_tokens from output",
|
||||
request: &OpenAIResponsesRequest{
|
||||
Model: "gpt-4o",
|
||||
Input: OpenAIResponsesRequestInput{
|
||||
OpenAIResponsesRequestInputStr: schemas.Ptr("test input"),
|
||||
},
|
||||
ResponsesParameters: schemas.ResponsesParameters{
|
||||
Reasoning: &schemas.ResponsesParametersReasoning{
|
||||
Effort: schemas.Ptr("high"),
|
||||
MaxTokens: schemas.Ptr(1000),
|
||||
Summary: schemas.Ptr("detailed"),
|
||||
},
|
||||
},
|
||||
},
|
||||
description: "When Reasoning.MaxTokens is set, it should be absent from JSON output",
|
||||
},
|
||||
{
|
||||
name: "reasoning with all fields set should omit only max_tokens",
|
||||
request: &OpenAIResponsesRequest{
|
||||
Model: "gpt-4o",
|
||||
Input: OpenAIResponsesRequestInput{
|
||||
OpenAIResponsesRequestInputStr: schemas.Ptr("test"),
|
||||
},
|
||||
ResponsesParameters: schemas.ResponsesParameters{
|
||||
Reasoning: &schemas.ResponsesParametersReasoning{
|
||||
Effort: schemas.Ptr("medium"),
|
||||
GenerateSummary: schemas.Ptr("auto"),
|
||||
Summary: schemas.Ptr("concise"),
|
||||
MaxTokens: schemas.Ptr(500),
|
||||
},
|
||||
},
|
||||
},
|
||||
description: "All reasoning fields except MaxTokens should be present in output",
|
||||
},
|
||||
{
|
||||
name: "reasoning with nil MaxTokens should not include max_tokens",
|
||||
request: &OpenAIResponsesRequest{
|
||||
Model: "gpt-4o",
|
||||
Input: OpenAIResponsesRequestInput{
|
||||
OpenAIResponsesRequestInputStr: schemas.Ptr("test"),
|
||||
},
|
||||
ResponsesParameters: schemas.ResponsesParameters{
|
||||
Reasoning: &schemas.ResponsesParametersReasoning{
|
||||
Effort: schemas.Ptr("low"),
|
||||
MaxTokens: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
description: "When Reasoning.MaxTokens is nil, max_tokens should not appear in output",
|
||||
},
|
||||
{
|
||||
name: "nil reasoning should not include reasoning field",
|
||||
request: &OpenAIResponsesRequest{
|
||||
Model: "gpt-4o",
|
||||
Input: OpenAIResponsesRequestInput{
|
||||
OpenAIResponsesRequestInputStr: schemas.Ptr("test"),
|
||||
},
|
||||
ResponsesParameters: schemas.ResponsesParameters{
|
||||
Reasoning: nil,
|
||||
},
|
||||
},
|
||||
description: "When Reasoning is nil, reasoning field should not appear in output",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
jsonBytes, err := tt.request.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal JSON: %v", err)
|
||||
}
|
||||
|
||||
// Parse the JSON to check structure
|
||||
var jsonMap map[string]interface{}
|
||||
if err := sonic.Unmarshal(jsonBytes, &jsonMap); err != nil {
|
||||
t.Fatalf("Failed to unmarshal marshaled JSON: %v", err)
|
||||
}
|
||||
|
||||
// Check that reasoning.max_tokens is absent
|
||||
if reasoning, ok := jsonMap["reasoning"].(map[string]interface{}); ok {
|
||||
if maxTokens, exists := reasoning["max_tokens"]; exists {
|
||||
t.Errorf("%s: reasoning.max_tokens should be absent from JSON output, but found: %v", tt.description, maxTokens)
|
||||
}
|
||||
|
||||
// Verify other reasoning fields are present when they should be
|
||||
if tt.request.Reasoning != nil {
|
||||
if tt.request.Reasoning.Effort != nil {
|
||||
if _, exists := reasoning["effort"]; !exists {
|
||||
t.Error("reasoning.effort should be present in output")
|
||||
}
|
||||
}
|
||||
if tt.request.Reasoning.Summary != nil {
|
||||
if _, exists := reasoning["summary"]; !exists {
|
||||
t.Error("reasoning.summary should be present in output")
|
||||
}
|
||||
}
|
||||
if tt.request.Reasoning.GenerateSummary != nil {
|
||||
if _, exists := reasoning["generate_summary"]; !exists {
|
||||
t.Error("reasoning.generate_summary should be present in output")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if tt.request.Reasoning != nil {
|
||||
// If reasoning is set, it should appear in JSON (unless all fields are nil/omitted)
|
||||
if tt.request.Reasoning.Effort != nil || tt.request.Reasoning.Summary != nil || tt.request.Reasoning.GenerateSummary != nil {
|
||||
t.Error("reasoning field should be present in JSON when Reasoning is set with non-nil fields")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesRequest_MarshalJSON_InputStringForm(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
request *OpenAIResponsesRequest
|
||||
expected string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "input as string is correctly marshaled",
|
||||
request: &OpenAIResponsesRequest{
|
||||
Model: "gpt-4o",
|
||||
Input: OpenAIResponsesRequestInput{
|
||||
OpenAIResponsesRequestInputStr: schemas.Ptr("Hello, world!"),
|
||||
},
|
||||
},
|
||||
expected: "Hello, world!",
|
||||
description: "Input field should be marshaled as a string when OpenAIResponsesRequestInputStr is set",
|
||||
},
|
||||
{
|
||||
name: "input as empty string is correctly marshaled",
|
||||
request: &OpenAIResponsesRequest{
|
||||
Model: "gpt-4o",
|
||||
Input: OpenAIResponsesRequestInput{
|
||||
OpenAIResponsesRequestInputStr: schemas.Ptr(""),
|
||||
},
|
||||
},
|
||||
expected: "",
|
||||
description: "Input field should be marshaled as empty string when set to empty string",
|
||||
},
|
||||
{
|
||||
name: "input as string with special characters",
|
||||
request: &OpenAIResponsesRequest{
|
||||
Model: "gpt-4o",
|
||||
Input: OpenAIResponsesRequestInput{
|
||||
OpenAIResponsesRequestInputStr: schemas.Ptr(`{"key": "value"}`),
|
||||
},
|
||||
},
|
||||
expected: `{"key": "value"}`,
|
||||
description: "Input field should correctly marshal strings with special characters",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
jsonBytes, err := tt.request.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal JSON: %v", err)
|
||||
}
|
||||
|
||||
// Parse the JSON to check input field
|
||||
var jsonMap map[string]interface{}
|
||||
if err := sonic.Unmarshal(jsonBytes, &jsonMap); err != nil {
|
||||
t.Fatalf("Failed to unmarshal marshaled JSON: %v", err)
|
||||
}
|
||||
|
||||
// Check that input is a string
|
||||
inputValue, exists := jsonMap["input"]
|
||||
if !exists {
|
||||
t.Fatalf("%s: input field should be present in JSON", tt.description)
|
||||
}
|
||||
|
||||
inputStr, ok := inputValue.(string)
|
||||
if !ok {
|
||||
t.Errorf("%s: input field should be a string, got type %T", tt.description, inputValue)
|
||||
}
|
||||
|
||||
if inputStr != tt.expected {
|
||||
t.Errorf("%s: expected input to be %q, got %q", tt.description, tt.expected, inputStr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesRequest_MarshalJSON_InputArrayForm(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
request *OpenAIResponsesRequest
|
||||
validate func(t *testing.T, inputValue interface{})
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "input as array is correctly marshaled",
|
||||
request: &OpenAIResponsesRequest{
|
||||
Model: "gpt-4o",
|
||||
Input: OpenAIResponsesRequestInput{
|
||||
OpenAIResponsesRequestInputArray: []schemas.ResponsesMessage{
|
||||
{
|
||||
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
|
||||
Content: &schemas.ResponsesMessageContent{
|
||||
ContentStr: schemas.Ptr("Hello"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
validate: func(t *testing.T, inputValue interface{}) {
|
||||
inputArray, ok := inputValue.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected input to be an array, got type %T", inputValue)
|
||||
}
|
||||
if len(inputArray) != 1 {
|
||||
t.Errorf("Expected 1 message in array, got %d", len(inputArray))
|
||||
}
|
||||
},
|
||||
description: "Input field should be marshaled as an array when OpenAIResponsesRequestInputArray is set",
|
||||
},
|
||||
{
|
||||
name: "input as empty array is correctly marshaled",
|
||||
request: &OpenAIResponsesRequest{
|
||||
Model: "gpt-4o",
|
||||
Input: OpenAIResponsesRequestInput{
|
||||
OpenAIResponsesRequestInputArray: []schemas.ResponsesMessage{},
|
||||
},
|
||||
},
|
||||
validate: func(t *testing.T, inputValue interface{}) {
|
||||
inputArray, ok := inputValue.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected input to be an array, got type %T", inputValue)
|
||||
}
|
||||
if len(inputArray) != 0 {
|
||||
t.Errorf("Expected empty array, got %d elements", len(inputArray))
|
||||
}
|
||||
},
|
||||
description: "Input field should be marshaled as empty array when set to empty array",
|
||||
},
|
||||
{
|
||||
name: "input as array with multiple messages",
|
||||
request: &OpenAIResponsesRequest{
|
||||
Model: "gpt-4o",
|
||||
Input: OpenAIResponsesRequestInput{
|
||||
OpenAIResponsesRequestInputArray: []schemas.ResponsesMessage{
|
||||
{
|
||||
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleSystem),
|
||||
Content: &schemas.ResponsesMessageContent{
|
||||
ContentStr: schemas.Ptr("You are a helpful assistant."),
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
|
||||
Content: &schemas.ResponsesMessageContent{
|
||||
ContentStr: schemas.Ptr("What is 2+2?"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
validate: func(t *testing.T, inputValue interface{}) {
|
||||
inputArray, ok := inputValue.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected input to be an array, got type %T", inputValue)
|
||||
}
|
||||
if len(inputArray) != 2 {
|
||||
t.Errorf("Expected 2 messages in array, got %d", len(inputArray))
|
||||
}
|
||||
},
|
||||
description: "Input field should correctly marshal arrays with multiple messages",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
jsonBytes, err := tt.request.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal JSON: %v", err)
|
||||
}
|
||||
|
||||
// Parse the JSON to check input field
|
||||
var jsonMap map[string]interface{}
|
||||
if err := sonic.Unmarshal(jsonBytes, &jsonMap); err != nil {
|
||||
t.Fatalf("Failed to unmarshal marshaled JSON: %v", err)
|
||||
}
|
||||
|
||||
// Check that input is present
|
||||
inputValue, exists := jsonMap["input"]
|
||||
if !exists {
|
||||
t.Fatalf("%s: input field should be present in JSON", tt.description)
|
||||
}
|
||||
|
||||
// Validate using the provided function
|
||||
tt.validate(t, inputValue)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToOpenAIResponsesRequest_FireworksPreservesNativeFields(t *testing.T) {
|
||||
bifrostReq := &schemas.BifrostResponsesRequest{
|
||||
Provider: schemas.Fireworks,
|
||||
Model: "accounts/fireworks/models/deepseek-v3p2",
|
||||
Input: []schemas.ResponsesMessage{
|
||||
{
|
||||
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
|
||||
Content: &schemas.ResponsesMessageContent{
|
||||
ContentStr: schemas.Ptr("hello"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Params: &schemas.ResponsesParameters{
|
||||
PreviousResponseID: schemas.Ptr("resp_previous"),
|
||||
MaxToolCalls: schemas.Ptr(2),
|
||||
Store: schemas.Ptr(true),
|
||||
},
|
||||
}
|
||||
|
||||
request := ToOpenAIResponsesRequest(bifrostReq)
|
||||
if request == nil {
|
||||
t.Fatal("expected non-nil request")
|
||||
}
|
||||
|
||||
jsonBytes, err := request.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal responses request: %v", err)
|
||||
}
|
||||
|
||||
var jsonMap map[string]interface{}
|
||||
if err := sonic.Unmarshal(jsonBytes, &jsonMap); err != nil {
|
||||
t.Fatalf("failed to parse marshaled JSON: %v", err)
|
||||
}
|
||||
|
||||
if got, ok := jsonMap["previous_response_id"].(string); !ok || got != "resp_previous" {
|
||||
t.Fatalf("expected previous_response_id to be preserved, got %#v", jsonMap["previous_response_id"])
|
||||
}
|
||||
if got, ok := jsonMap["max_tool_calls"].(float64); !ok || got != 2 {
|
||||
t.Fatalf("expected max_tool_calls to be preserved, got %#v", jsonMap["max_tool_calls"])
|
||||
}
|
||||
if got, ok := jsonMap["store"].(bool); !ok || !got {
|
||||
t.Fatalf("expected store=true to be preserved, got %#v", jsonMap["store"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesRequest_MarshalJSON_FieldShadowingBehavior(t *testing.T) {
|
||||
// This test verifies that the field shadowing pattern works correctly
|
||||
// by ensuring that the aux struct properly shadows Input and Reasoning fields
|
||||
t.Run("field shadowing preserves other fields", func(t *testing.T) {
|
||||
request := &OpenAIResponsesRequest{
|
||||
Model: "gpt-4o",
|
||||
Input: OpenAIResponsesRequestInput{
|
||||
OpenAIResponsesRequestInputStr: schemas.Ptr("test input"),
|
||||
},
|
||||
ResponsesParameters: schemas.ResponsesParameters{
|
||||
MaxOutputTokens: schemas.Ptr(100),
|
||||
Temperature: schemas.Ptr(0.7),
|
||||
Reasoning: &schemas.ResponsesParametersReasoning{
|
||||
Effort: schemas.Ptr("high"),
|
||||
MaxTokens: schemas.Ptr(500), // This should be omitted
|
||||
Summary: schemas.Ptr("detailed"),
|
||||
},
|
||||
},
|
||||
Stream: schemas.Ptr(true),
|
||||
Fallbacks: []string{"fallback1", "fallback2"},
|
||||
}
|
||||
|
||||
jsonBytes, err := request.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal JSON: %v", err)
|
||||
}
|
||||
|
||||
var jsonMap map[string]interface{}
|
||||
if err := sonic.Unmarshal(jsonBytes, &jsonMap); err != nil {
|
||||
t.Fatalf("Failed to unmarshal marshaled JSON: %v", err)
|
||||
}
|
||||
|
||||
// Verify base fields are present
|
||||
if jsonMap["model"] != "gpt-4o" {
|
||||
t.Errorf("Expected model to be 'gpt-4o', got %v", jsonMap["model"])
|
||||
}
|
||||
|
||||
if jsonMap["stream"] != true {
|
||||
t.Errorf("Expected stream to be true, got %v", jsonMap["stream"])
|
||||
}
|
||||
|
||||
fallbacks, ok := jsonMap["fallbacks"].([]interface{})
|
||||
if !ok || len(fallbacks) != 2 {
|
||||
t.Errorf("Expected fallbacks to have 2 elements, got %v", jsonMap["fallbacks"])
|
||||
}
|
||||
|
||||
// Verify ResponsesParameters fields are present
|
||||
if jsonMap["max_output_tokens"] != float64(100) {
|
||||
t.Errorf("Expected max_output_tokens to be 100, got %v", jsonMap["max_output_tokens"])
|
||||
}
|
||||
|
||||
if jsonMap["temperature"] != 0.7 {
|
||||
t.Errorf("Expected temperature to be 0.7, got %v", jsonMap["temperature"])
|
||||
}
|
||||
|
||||
// Verify reasoning.max_tokens is absent
|
||||
if reasoning, ok := jsonMap["reasoning"].(map[string]interface{}); ok {
|
||||
if _, exists := reasoning["max_tokens"]; exists {
|
||||
t.Error("reasoning.max_tokens should be absent from JSON output")
|
||||
}
|
||||
if reasoning["effort"] != "high" {
|
||||
t.Errorf("Expected reasoning.effort to be 'high', got %v", reasoning["effort"])
|
||||
}
|
||||
if reasoning["summary"] != "detailed" {
|
||||
t.Errorf("Expected reasoning.summary to be 'detailed', got %v", reasoning["summary"])
|
||||
}
|
||||
} else {
|
||||
t.Error("reasoning field should be present in JSON")
|
||||
}
|
||||
|
||||
// Verify input is correctly marshaled
|
||||
if jsonMap["input"] != "test input" {
|
||||
t.Errorf("Expected input to be 'test input', got %v", jsonMap["input"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesRequest_MarshalJSON_RoundTrip(t *testing.T) {
|
||||
// Test that marshaling and unmarshaling preserves all fields except reasoning.max_tokens
|
||||
t.Run("round trip preserves fields except reasoning.max_tokens", func(t *testing.T) {
|
||||
original := &OpenAIResponsesRequest{
|
||||
Model: "gpt-4o",
|
||||
Input: OpenAIResponsesRequestInput{
|
||||
OpenAIResponsesRequestInputArray: []schemas.ResponsesMessage{
|
||||
{
|
||||
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
|
||||
Content: &schemas.ResponsesMessageContent{
|
||||
ContentStr: schemas.Ptr("Test message"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ResponsesParameters: schemas.ResponsesParameters{
|
||||
MaxOutputTokens: schemas.Ptr(200),
|
||||
Temperature: schemas.Ptr(0.8),
|
||||
Reasoning: &schemas.ResponsesParametersReasoning{
|
||||
Effort: schemas.Ptr("medium"),
|
||||
MaxTokens: schemas.Ptr(1000), // Should be omitted
|
||||
Summary: schemas.Ptr("auto"),
|
||||
},
|
||||
},
|
||||
Stream: schemas.Ptr(false),
|
||||
}
|
||||
|
||||
// Marshal
|
||||
jsonBytes, err := original.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
// Verify reasoning.max_tokens is absent in the JSON string
|
||||
jsonStr := string(jsonBytes)
|
||||
if strings.Contains(jsonStr, `"max_tokens"`) {
|
||||
// Check if it's inside reasoning object
|
||||
if strings.Contains(jsonStr, `"reasoning"`) {
|
||||
// Parse to verify it's not in reasoning
|
||||
var jsonMap map[string]interface{}
|
||||
if err := json.Unmarshal(jsonBytes, &jsonMap); err == nil {
|
||||
if reasoning, ok := jsonMap["reasoning"].(map[string]interface{}); ok {
|
||||
if _, exists := reasoning["max_tokens"]; exists {
|
||||
t.Error("reasoning.max_tokens should not be present in marshaled JSON")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unmarshal back
|
||||
var unmarshaled OpenAIResponsesRequest
|
||||
if err := sonic.Unmarshal(jsonBytes, &unmarshaled); err != nil {
|
||||
t.Fatalf("Failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
// Verify fields are preserved
|
||||
if unmarshaled.Model != original.Model {
|
||||
t.Errorf("Model not preserved: expected %q, got %q", original.Model, unmarshaled.Model)
|
||||
}
|
||||
|
||||
if unmarshaled.Stream == nil || *unmarshaled.Stream != *original.Stream {
|
||||
t.Error("Stream not preserved")
|
||||
}
|
||||
|
||||
if unmarshaled.MaxOutputTokens == nil || *unmarshaled.MaxOutputTokens != *original.MaxOutputTokens {
|
||||
t.Error("MaxOutputTokens not preserved")
|
||||
}
|
||||
|
||||
if unmarshaled.Temperature == nil || *unmarshaled.Temperature != *original.Temperature {
|
||||
t.Error("Temperature not preserved")
|
||||
}
|
||||
|
||||
// Verify reasoning fields except MaxTokens
|
||||
if unmarshaled.Reasoning == nil {
|
||||
t.Fatal("Reasoning should be present")
|
||||
}
|
||||
if unmarshaled.Reasoning.Effort == nil || *unmarshaled.Reasoning.Effort != *original.Reasoning.Effort {
|
||||
t.Error("Reasoning.Effort not preserved")
|
||||
}
|
||||
if unmarshaled.Reasoning.Summary == nil || *unmarshaled.Reasoning.Summary != *original.Reasoning.Summary {
|
||||
t.Error("Reasoning.Summary not preserved")
|
||||
}
|
||||
// MaxTokens should be nil after unmarshaling (since it wasn't in JSON)
|
||||
if unmarshaled.Reasoning.MaxTokens != nil {
|
||||
t.Error("Reasoning.MaxTokens should be nil after unmarshaling (was omitted from JSON)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Regression test for multi-turn Anthropic tool_result with array-form content.
|
||||
// The OpenAI Responses API defines function_call_output.output as a string (see
|
||||
// https://platform.openai.com/docs/api-reference/responses/create). When an
|
||||
// Anthropic client sends a tool_result whose content is an array of text blocks,
|
||||
// Bifrost's Anthropic→Responses translator populates
|
||||
// ResponsesToolMessageOutputStruct.ResponsesFunctionToolCallOutputBlocks.
|
||||
// Historically, that array was marshaled verbatim onto the wire, which some
|
||||
// strict OpenAI-compat upstreams (e.g. Ollama Cloud) reject with an error like
|
||||
//
|
||||
// json: cannot unmarshal array into Go struct field ResponsesFunctionCallOutput.output of type string
|
||||
//
|
||||
// The outgoing OpenAI Responses request must emit `output` as a string for
|
||||
// text-only tool outputs.
|
||||
func TestOpenAIResponsesRequestInput_MarshalJSON_FunctionCallOutputFlattensTextBlocksToString(t *testing.T) {
|
||||
outputText := "line1"
|
||||
callID := "toolu_abc123"
|
||||
functionName := "read_file"
|
||||
|
||||
input := &OpenAIResponsesRequestInput{
|
||||
OpenAIResponsesRequestInputArray: []schemas.ResponsesMessage{
|
||||
{
|
||||
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
|
||||
Content: &schemas.ResponsesMessageContent{
|
||||
ContentStr: schemas.Ptr("Read /tmp/test.txt and tell me what it contains."),
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
|
||||
Status: schemas.Ptr("completed"),
|
||||
ResponsesToolMessage: &schemas.ResponsesToolMessage{
|
||||
CallID: schemas.Ptr(callID),
|
||||
Name: schemas.Ptr(functionName),
|
||||
Arguments: schemas.Ptr(`{"path":"/tmp/test.txt"}`),
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput),
|
||||
Status: schemas.Ptr("completed"),
|
||||
ResponsesToolMessage: &schemas.ResponsesToolMessage{
|
||||
CallID: schemas.Ptr(callID),
|
||||
Output: &schemas.ResponsesToolMessageOutputStruct{
|
||||
ResponsesFunctionToolCallOutputBlocks: []schemas.ResponsesMessageContentBlock{
|
||||
{
|
||||
Type: schemas.ResponsesInputMessageContentBlockTypeText,
|
||||
Text: schemas.Ptr(outputText),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonBytes, err := input.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal OpenAIResponsesRequestInput: %v", err)
|
||||
}
|
||||
|
||||
var messages []map[string]interface{}
|
||||
if err := sonic.Unmarshal(jsonBytes, &messages); err != nil {
|
||||
t.Fatalf("Failed to unmarshal marshaled input as array: %v\nraw=%s", err, string(jsonBytes))
|
||||
}
|
||||
|
||||
var fcoMsg map[string]interface{}
|
||||
for _, m := range messages {
|
||||
if t, ok := m["type"].(string); ok && t == string(schemas.ResponsesMessageTypeFunctionCallOutput) {
|
||||
fcoMsg = m
|
||||
break
|
||||
}
|
||||
}
|
||||
if fcoMsg == nil {
|
||||
t.Fatalf("did not find function_call_output message in marshaled JSON: %s", string(jsonBytes))
|
||||
}
|
||||
|
||||
outputVal, ok := fcoMsg["output"]
|
||||
if !ok {
|
||||
t.Fatalf("function_call_output message has no `output` field: %s", string(jsonBytes))
|
||||
}
|
||||
|
||||
outputStr, isString := outputVal.(string)
|
||||
if !isString {
|
||||
t.Fatalf("function_call_output.output must be a string (OpenAI Responses API spec); got %T: %v\nraw=%s", outputVal, outputVal, string(jsonBytes))
|
||||
}
|
||||
if outputStr != outputText {
|
||||
t.Fatalf("function_call_output.output mismatch: want %q, got %q", outputText, outputStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Flattening must concatenate multiple text blocks with newline separators so
|
||||
// every character from the upstream tool response reaches the model.
|
||||
func TestOpenAIResponsesRequestInput_MarshalJSON_FunctionCallOutputConcatenatesMultipleTextBlocks(t *testing.T) {
|
||||
callID := "toolu_multi"
|
||||
input := &OpenAIResponsesRequestInput{
|
||||
OpenAIResponsesRequestInputArray: []schemas.ResponsesMessage{
|
||||
{
|
||||
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput),
|
||||
Status: schemas.Ptr("completed"),
|
||||
ResponsesToolMessage: &schemas.ResponsesToolMessage{
|
||||
CallID: schemas.Ptr(callID),
|
||||
Output: &schemas.ResponsesToolMessageOutputStruct{
|
||||
ResponsesFunctionToolCallOutputBlocks: []schemas.ResponsesMessageContentBlock{
|
||||
{Type: schemas.ResponsesInputMessageContentBlockTypeText, Text: schemas.Ptr("line1")},
|
||||
{Type: schemas.ResponsesInputMessageContentBlockTypeText, Text: schemas.Ptr("line2")},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonBytes, err := input.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal: %v", err)
|
||||
}
|
||||
var messages []map[string]interface{}
|
||||
if err := sonic.Unmarshal(jsonBytes, &messages); err != nil {
|
||||
t.Fatalf("Failed to unmarshal: %v\nraw=%s", err, string(jsonBytes))
|
||||
}
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(messages))
|
||||
}
|
||||
got, ok := messages[0]["output"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("output must be string, got %T", messages[0]["output"])
|
||||
}
|
||||
if want := "line1\nline2"; got != want {
|
||||
t.Fatalf("flattened output mismatch: want %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// When the tool result contains a non-text block (e.g. an image), flattening is
|
||||
// unsafe — preserve the array form and let the upstream handle it. This keeps
|
||||
// the fix scoped to the common text-only case without dropping rich content.
|
||||
func TestOpenAIResponsesRequestInput_MarshalJSON_FunctionCallOutputPreservesNonTextBlocks(t *testing.T) {
|
||||
callID := "toolu_with_image"
|
||||
imageURL := "https://example.com/screenshot.png"
|
||||
input := &OpenAIResponsesRequestInput{
|
||||
OpenAIResponsesRequestInputArray: []schemas.ResponsesMessage{
|
||||
{
|
||||
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput),
|
||||
Status: schemas.Ptr("completed"),
|
||||
ResponsesToolMessage: &schemas.ResponsesToolMessage{
|
||||
CallID: schemas.Ptr(callID),
|
||||
Output: &schemas.ResponsesToolMessageOutputStruct{
|
||||
ResponsesFunctionToolCallOutputBlocks: []schemas.ResponsesMessageContentBlock{
|
||||
{Type: schemas.ResponsesInputMessageContentBlockTypeText, Text: schemas.Ptr("here is the screenshot:")},
|
||||
{
|
||||
Type: schemas.ResponsesInputMessageContentBlockTypeImage,
|
||||
ResponsesInputMessageContentBlockImage: &schemas.ResponsesInputMessageContentBlockImage{
|
||||
ImageURL: &imageURL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
jsonBytes, err := input.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal: %v", err)
|
||||
}
|
||||
var messages []map[string]interface{}
|
||||
if err := sonic.Unmarshal(jsonBytes, &messages); err != nil {
|
||||
t.Fatalf("Failed to unmarshal: %v\nraw=%s", err, string(jsonBytes))
|
||||
}
|
||||
if _, isString := messages[0]["output"].(string); isString {
|
||||
t.Fatalf("non-text blocks must not be flattened to string; raw=%s", string(jsonBytes))
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIResponsesRequest_MarshalJSON_StripsAnthropicToolFlags ensures the
|
||||
// Responses serializer drops the four Anthropic-native tool flags
|
||||
// (defer_loading, allowed_callers, input_examples, eager_input_streaming)
|
||||
// along with CacheControl before forwarding to OpenAI — mirroring the Chat
|
||||
// path's behavior so Anthropic-flavored tools cannot 400 OpenAI via Responses.
|
||||
func TestOpenAIResponsesRequest_MarshalJSON_StripsAnthropicToolFlags(t *testing.T) {
|
||||
req := &OpenAIResponsesRequest{
|
||||
Model: "gpt-4o",
|
||||
Input: OpenAIResponsesRequestInput{
|
||||
OpenAIResponsesRequestInputArray: []schemas.ResponsesMessage{
|
||||
{
|
||||
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
|
||||
Content: &schemas.ResponsesMessageContent{
|
||||
ContentStr: schemas.Ptr("hello"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ResponsesParameters: schemas.ResponsesParameters{
|
||||
Tools: []schemas.ResponsesTool{
|
||||
{
|
||||
Type: schemas.ResponsesToolTypeFunction,
|
||||
Name: schemas.Ptr("lookup"),
|
||||
Description: schemas.Ptr("lookup something"),
|
||||
CacheControl: &schemas.CacheControl{Type: "ephemeral"},
|
||||
DeferLoading: schemas.Ptr(true),
|
||||
AllowedCallers: []string{"direct", "agent"},
|
||||
EagerInputStreaming: schemas.Ptr(false),
|
||||
InputExamples: []schemas.ChatToolInputExample{
|
||||
{Input: json.RawMessage(`{"q":"hi"}`)},
|
||||
},
|
||||
ResponsesToolFunction: &schemas.ResponsesToolFunction{},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonBytes, err := req.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
raw := string(jsonBytes)
|
||||
|
||||
// None of the five Anthropic-only tool keys must survive on the wire.
|
||||
for _, key := range []string{`"cache_control"`, `"defer_loading"`, `"allowed_callers"`, `"input_examples"`, `"eager_input_streaming"`} {
|
||||
if strings.Contains(raw, key) {
|
||||
t.Errorf("OpenAI Responses serializer must strip %s; raw=%s", key, raw)
|
||||
}
|
||||
}
|
||||
// Function tool identity should be preserved.
|
||||
if !strings.Contains(raw, `"name":"lookup"`) {
|
||||
t.Errorf("tool identity lost after strip; raw=%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIResponsesRequest_MarshalJSON_DropsAnthropicOnlyToolTypes verifies
|
||||
// that Anthropic-only tool types (web_fetch, memory) are dropped entirely when
|
||||
// serializing for OpenAI Responses. Per OpenAI's OpenAPI spec the Responses
|
||||
// Tool discriminator union does not include web_fetch or memory, so forwarding
|
||||
// them would trigger a 400 schema-validation error. Mirrors the Chat path's
|
||||
// isAnthropicServerToolShape drop behavior.
|
||||
func TestOpenAIResponsesRequest_MarshalJSON_DropsAnthropicOnlyToolTypes(t *testing.T) {
|
||||
req := &OpenAIResponsesRequest{
|
||||
Model: "gpt-4o",
|
||||
Input: OpenAIResponsesRequestInput{
|
||||
OpenAIResponsesRequestInputArray: []schemas.ResponsesMessage{
|
||||
{
|
||||
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
|
||||
Content: &schemas.ResponsesMessageContent{
|
||||
ContentStr: schemas.Ptr("hello"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ResponsesParameters: schemas.ResponsesParameters{
|
||||
Tools: []schemas.ResponsesTool{
|
||||
// Kept: function (OpenAI-native).
|
||||
{
|
||||
Type: schemas.ResponsesToolTypeFunction,
|
||||
Name: schemas.Ptr("keeper_func"),
|
||||
ResponsesToolFunction: &schemas.ResponsesToolFunction{},
|
||||
},
|
||||
// Dropped: web_fetch (Anthropic-only).
|
||||
{
|
||||
Type: schemas.ResponsesToolTypeWebFetch,
|
||||
Name: schemas.Ptr("anthropic_webfetch"),
|
||||
ResponsesToolWebFetch: &schemas.ResponsesToolWebFetch{},
|
||||
},
|
||||
// Kept: web_search (both support).
|
||||
{
|
||||
Type: schemas.ResponsesToolTypeWebSearch,
|
||||
ResponsesToolWebSearch: &schemas.ResponsesToolWebSearch{},
|
||||
},
|
||||
// Dropped: memory (Anthropic-only).
|
||||
{
|
||||
Type: schemas.ResponsesToolTypeMemory,
|
||||
Name: schemas.Ptr("anthropic_memory"),
|
||||
},
|
||||
// Kept: tool_search (both support per OpenAI OpenAPI spec).
|
||||
{
|
||||
Type: schemas.ResponsesToolTypeToolSearch,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonBytes, err := req.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
raw := string(jsonBytes)
|
||||
|
||||
// Dropped types must not appear on the wire.
|
||||
for _, dropped := range []string{`"web_fetch"`, `"memory"`, `"anthropic_webfetch"`, `"anthropic_memory"`} {
|
||||
if strings.Contains(raw, dropped) {
|
||||
t.Errorf("Anthropic-only tool must be dropped; found %s in raw=%s", dropped, raw)
|
||||
}
|
||||
}
|
||||
// Kept types must still appear.
|
||||
for _, kept := range []string{`"function"`, `"web_search"`, `"tool_search"`, `"keeper_func"`} {
|
||||
if !strings.Contains(raw, kept) {
|
||||
t.Errorf("supported tool %s should be preserved; raw=%s", kept, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm the tools array is present and has exactly 3 entries (2 dropped of 5).
|
||||
var decoded struct {
|
||||
Tools []map[string]interface{} `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(jsonBytes, &decoded); err != nil {
|
||||
t.Fatalf("decode failed: %v", err)
|
||||
}
|
||||
if len(decoded.Tools) != 3 {
|
||||
t.Errorf("expected 3 tools after drop (function, web_search, tool_search), got %d; tools=%+v", len(decoded.Tools), decoded.Tools)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIResponsesRequest_MarshalJSON_KeepsAllWhenAllSupported verifies the
|
||||
// no-reshape fast path: if every tool is OpenAI-compatible with no
|
||||
// Anthropic-only flags, the tools slice passes through unchanged (no copy,
|
||||
// no drop).
|
||||
func TestOpenAIResponsesRequest_MarshalJSON_KeepsAllWhenAllSupported(t *testing.T) {
|
||||
req := &OpenAIResponsesRequest{
|
||||
Model: "gpt-4o",
|
||||
Input: OpenAIResponsesRequestInput{
|
||||
OpenAIResponsesRequestInputArray: []schemas.ResponsesMessage{
|
||||
{
|
||||
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
|
||||
Content: &schemas.ResponsesMessageContent{ContentStr: schemas.Ptr("hi")},
|
||||
},
|
||||
},
|
||||
},
|
||||
ResponsesParameters: schemas.ResponsesParameters{
|
||||
Tools: []schemas.ResponsesTool{
|
||||
{Type: schemas.ResponsesToolTypeFunction, Name: schemas.Ptr("f"), ResponsesToolFunction: &schemas.ResponsesToolFunction{}},
|
||||
{Type: schemas.ResponsesToolTypeWebSearch, ResponsesToolWebSearch: &schemas.ResponsesToolWebSearch{}},
|
||||
{Type: schemas.ResponsesToolTypeCodeInterpreter, ResponsesToolCodeInterpreter: &schemas.ResponsesToolCodeInterpreter{}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonBytes, err := req.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
var decoded struct {
|
||||
Tools []map[string]interface{} `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(jsonBytes, &decoded); err != nil {
|
||||
t.Fatalf("decode failed: %v", err)
|
||||
}
|
||||
if len(decoded.Tools) != 3 {
|
||||
t.Errorf("expected 3 tools preserved, got %d", len(decoded.Tools))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user