first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

View 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))
}
}