873 lines
30 KiB
Go
873 lines
30 KiB
Go
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))
|
|
}
|
|
}
|