Add tests for JSON response formatting and schema validation in OpenAI provider

- Introduced TestOpenAIJSONMode and TestOpenAIJSONModePost to validate JSON response formatting for streaming and non-streaming requests.
- Added TestOpenAIJSONSchema and TestOpenAIJSONSchemaPost to ensure compliance with strict JSON schema validation.
- Enhanced request body construction in the OpenAI provider to support JSON schema details in response formats.
This commit is contained in:
Max 2025-11-15 11:31:17 +08:00
parent 68ed9ece6d
commit d705fcc484
3 changed files with 439 additions and 11 deletions

View file

@ -559,7 +559,17 @@ func (p *Provider) buildRequestBody(messages []context.Message, options *context
} }
if options.ResponseFormat != nil { if options.ResponseFormat != nil {
body["response_format"] = options.ResponseFormat // Build response_format according to OpenAI API requirements
responseFormat := map[string]interface{}{
"type": options.ResponseFormat.Type,
}
// For json_schema type, include the schema details
if options.ResponseFormat.Type == context.ResponseFormatJSONSchema && options.ResponseFormat.JSONSchema != nil {
responseFormat["json_schema"] = options.ResponseFormat.JSONSchema
}
body["response_format"] = responseFormat
} }
if options.Seed != nil { if options.Seed != nil {

View file

@ -803,6 +803,424 @@ func TestOpenAIToolCallValidationRetry(t *testing.T) {
t.Log("Automatic tool call validation retry test completed") t.Log("Automatic tool call validation retry test completed")
} }
// TestOpenAIJSONMode tests JSON mode response formatting
func TestOpenAIJSONMode(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
conn, err := connector.Select("openai.gpt-4o")
if err != nil {
t.Fatalf("Failed to select connector: %v", err)
}
trueVal := true
options := &context.CompletionOptions{
Capabilities: &context.ModelCapabilities{
Streaming: &trueVal,
ToolCalls: &trueVal,
},
ResponseFormat: &context.ResponseFormat{
Type: context.ResponseFormatJSON,
},
}
llmInstance, err := llm.New(conn, options)
if err != nil {
t.Fatalf("Failed to create LLM instance: %v", err)
}
messages := []context.Message{
{
Role: context.RoleUser,
Content: "Generate a JSON object with fields: name (string), age (number), city (string). Use values: John, 30, New York",
},
}
ctx := newTestContext("test-json-mode", "openai.gpt-4o")
// Test streaming with JSON mode
response, err := llmInstance.Stream(ctx, messages, options, nil)
if err != nil {
t.Fatalf("Stream with JSON mode failed: %v", err)
}
if response == nil {
t.Fatal("Response is nil")
}
// Validate response
contentStr, ok := response.Content.(string)
if !ok || contentStr == "" {
t.Error("Response content is empty or not a string")
}
// Try to parse as JSON
var jsonData map[string]interface{}
if err := json.Unmarshal([]byte(contentStr), &jsonData); err != nil {
t.Errorf("Response is not valid JSON: %v\nContent: %s", err, contentStr)
} else {
t.Logf("✓ Response is valid JSON: %+v", jsonData)
// Verify expected fields exist
if _, hasName := jsonData["name"]; !hasName {
t.Error("JSON response missing 'name' field")
}
if _, hasAge := jsonData["age"]; !hasAge {
t.Error("JSON response missing 'age' field")
}
if _, hasCity := jsonData["city"]; !hasCity {
t.Error("JSON response missing 'city' field")
}
}
// Validate metadata
if response.ID == "" {
t.Error("Response ID is empty")
}
if response.Model == "" {
t.Error("Response Model is empty")
}
if response.Usage == nil {
t.Error("Response Usage is nil")
} else {
t.Logf("Usage: prompt=%d, completion=%d, total=%d",
response.Usage.PromptTokens, response.Usage.CompletionTokens, response.Usage.TotalTokens)
}
t.Log("JSON mode test completed successfully")
}
// TestOpenAIJSONModePost tests JSON mode with non-streaming
func TestOpenAIJSONModePost(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
conn, err := connector.Select("openai.gpt-4o")
if err != nil {
t.Fatalf("Failed to select connector: %v", err)
}
trueVal := true
options := &context.CompletionOptions{
Capabilities: &context.ModelCapabilities{
ToolCalls: &trueVal,
},
ResponseFormat: &context.ResponseFormat{
Type: context.ResponseFormatJSON,
},
}
llmInstance, err := llm.New(conn, options)
if err != nil {
t.Fatalf("Failed to create LLM instance: %v", err)
}
messages := []context.Message{
{
Role: context.RoleUser,
Content: "Return a JSON with: status='success', count=42",
},
}
ctx := newTestContext("test-json-mode-post", "openai.gpt-4o")
// Test non-streaming with JSON mode
response, err := llmInstance.Post(ctx, messages, options)
if err != nil {
t.Fatalf("Post with JSON mode failed: %v", err)
}
if response == nil {
t.Fatal("Response is nil")
}
// Validate response content is JSON
contentStr, ok := response.Content.(string)
if !ok || contentStr == "" {
t.Error("Response content is empty or not a string")
}
var jsonData map[string]interface{}
if err := json.Unmarshal([]byte(contentStr), &jsonData); err != nil {
t.Errorf("Response is not valid JSON: %v\nContent: %s", err, contentStr)
} else {
t.Logf("✓ Response is valid JSON: %+v", jsonData)
}
// Validate metadata
if response.Usage == nil {
t.Error("Response Usage is nil")
} else {
if response.Usage.TotalTokens == 0 {
t.Error("Response Usage.TotalTokens is 0")
}
t.Logf("Usage: prompt=%d, completion=%d, total=%d",
response.Usage.PromptTokens, response.Usage.CompletionTokens, response.Usage.TotalTokens)
}
t.Log("JSON mode Post test completed successfully")
}
// TestOpenAIJSONSchema tests JSON mode with strict schema validation
func TestOpenAIJSONSchema(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
conn, err := connector.Select("openai.gpt-4o")
if err != nil {
t.Fatalf("Failed to select connector: %v", err)
}
trueVal := true
// Define a strict JSON schema
// Note: For OpenAI strict mode, 'required' must include ALL properties
schema := map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"user": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{
"type": "string",
"description": "User's full name",
},
"email": map[string]interface{}{
"type": "string",
"description": "User's email address",
},
"age": map[string]interface{}{
"type": "integer",
"description": "User's age",
},
"isActive": map[string]interface{}{
"type": "boolean",
"description": "Whether user is active",
},
},
"required": []string{"name", "email", "age", "isActive"},
"additionalProperties": false,
},
},
"required": []string{"user"},
"additionalProperties": false,
}
options := &context.CompletionOptions{
Capabilities: &context.ModelCapabilities{
Streaming: &trueVal,
ToolCalls: &trueVal,
},
ResponseFormat: &context.ResponseFormat{
Type: context.ResponseFormatJSONSchema,
JSONSchema: &context.JSONSchema{
Name: "user_info",
Description: "User information schema",
Schema: schema,
Strict: &trueVal,
},
},
}
llmInstance, err := llm.New(conn, options)
if err != nil {
t.Fatalf("Failed to create LLM instance: %v", err)
}
messages := []context.Message{
{
Role: context.RoleUser,
Content: "Create user info for: Alice Smith, alice@example.com, age 28, active user",
},
}
ctx := newTestContext("test-json-schema", "openai.gpt-4o")
// Test streaming with JSON schema
response, err := llmInstance.Stream(ctx, messages, options, nil)
if err != nil {
t.Fatalf("Stream with JSON schema failed: %v", err)
}
if response == nil {
t.Fatal("Response is nil")
}
// Validate response content
contentStr, ok := response.Content.(string)
if !ok || contentStr == "" {
t.Fatal("Response content is empty or not a string")
}
// Parse as JSON
var jsonData map[string]interface{}
if err := json.Unmarshal([]byte(contentStr), &jsonData); err != nil {
t.Fatalf("Response is not valid JSON: %v\nContent: %s", err, contentStr)
}
t.Logf("✓ Response is valid JSON: %+v", jsonData)
// Verify structure matches schema
user, hasUser := jsonData["user"].(map[string]interface{})
if !hasUser {
t.Fatal("JSON response missing 'user' object")
}
// Verify required fields
if _, hasName := user["name"]; !hasName {
t.Error("User object missing required 'name' field")
}
if _, hasEmail := user["email"]; !hasEmail {
t.Error("User object missing required 'email' field")
}
// Verify field types
if name, ok := user["name"].(string); ok {
t.Logf("✓ name: %s (string)", name)
} else {
t.Error("name is not a string")
}
if email, ok := user["email"].(string); ok {
t.Logf("✓ email: %s (string)", email)
} else {
t.Error("email is not a string")
}
if age, ok := user["age"].(float64); ok {
if age < 0 || age > 150 {
t.Errorf("age %v is out of range [0, 150]", age)
}
t.Logf("✓ age: %v (integer, in range)", age)
}
if isActive, ok := user["isActive"].(bool); ok {
t.Logf("✓ isActive: %v (boolean)", isActive)
}
// Validate metadata
if response.Usage == nil {
t.Error("Response Usage is nil")
} else {
t.Logf("Usage: prompt=%d, completion=%d, total=%d",
response.Usage.PromptTokens, response.Usage.CompletionTokens, response.Usage.TotalTokens)
}
t.Log("JSON schema test completed successfully")
}
// TestOpenAIJSONSchemaPost tests JSON schema with non-streaming
func TestOpenAIJSONSchemaPost(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
conn, err := connector.Select("openai.gpt-4o")
if err != nil {
t.Fatalf("Failed to select connector: %v", err)
}
trueVal := true
// Simple schema for testing
// Note: For OpenAI strict mode, 'required' must include ALL properties
schema := map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"status": map[string]interface{}{
"type": "string",
"enum": []string{"success", "error", "pending"},
},
"message": map[string]interface{}{
"type": "string",
},
"code": map[string]interface{}{
"type": "integer",
},
},
"required": []string{"status", "message", "code"},
"additionalProperties": false,
}
options := &context.CompletionOptions{
Capabilities: &context.ModelCapabilities{
ToolCalls: &trueVal,
},
ResponseFormat: &context.ResponseFormat{
Type: context.ResponseFormatJSONSchema,
JSONSchema: &context.JSONSchema{
Name: "api_response",
Description: "API response format",
Schema: schema,
Strict: &trueVal,
},
},
}
llmInstance, err := llm.New(conn, options)
if err != nil {
t.Fatalf("Failed to create LLM instance: %v", err)
}
messages := []context.Message{
{
Role: context.RoleUser,
Content: "Generate an API response with status 'success', message 'Operation completed', and code 200",
},
}
ctx := newTestContext("test-json-schema-post", "openai.gpt-4o")
// Test non-streaming with JSON schema
response, err := llmInstance.Post(ctx, messages, options)
if err != nil {
t.Fatalf("Post with JSON schema failed: %v", err)
}
if response == nil {
t.Fatal("Response is nil")
}
// Validate response content
contentStr, ok := response.Content.(string)
if !ok || contentStr == "" {
t.Fatal("Response content is empty or not a string")
}
// Parse and validate JSON
var jsonData map[string]interface{}
if err := json.Unmarshal([]byte(contentStr), &jsonData); err != nil {
t.Fatalf("Response is not valid JSON: %v\nContent: %s", err, contentStr)
}
t.Logf("✓ Response is valid JSON: %+v", jsonData)
// Verify required fields
status, hasStatus := jsonData["status"].(string)
if !hasStatus {
t.Fatal("Missing required 'status' field")
}
// Verify enum constraint
validStatuses := map[string]bool{"success": true, "error": true, "pending": true}
if !validStatuses[status] {
t.Errorf("status '%s' is not in enum [success, error, pending]", status)
}
if _, hasMessage := jsonData["message"].(string); !hasMessage {
t.Error("Missing required 'message' field")
}
// Validate metadata
if response.Usage == nil {
t.Error("Response Usage is nil")
} else {
t.Logf("Usage: prompt=%d, completion=%d, total=%d",
response.Usage.PromptTokens, response.Usage.CompletionTokens, response.Usage.TotalTokens)
}
t.Log("JSON schema Post test completed successfully")
}
// TestOpenAIProxySupport tests that HTTP proxy configuration is respected // TestOpenAIProxySupport tests that HTTP proxy configuration is respected
func TestOpenAIProxySupport(t *testing.T) { func TestOpenAIProxySupport(t *testing.T) {
test.Prepare(t, config.Conf) test.Prepare(t, config.Conf)

View file

@ -29,23 +29,23 @@ func TestVision_Make(t *testing.T) {
// Error is expected because connector is not loaded in test environment // Error is expected because connector is not loaded in test environment
}) })
t.Run("option with connector should return error due to missing connector", func(t *testing.T) { t.Run("option with non-existent connector should return error", func(t *testing.T) {
option := &kbtypes.ProviderOption{ option := &kbtypes.ProviderOption{
Properties: map[string]interface{}{ Properties: map[string]interface{}{
"connector": "openai.gpt-4o-mini", "connector": "non-existent.connector",
}, },
} }
_, err := vision.Make(option) _, err := vision.Make(option)
if err == nil { if err == nil {
t.Error("Expected error due to missing connector") t.Error("Expected error for non-existent connector")
} }
// Error is expected because openai.gpt-4o-mini connector is not loaded // Error is expected because non-existent.connector is not loaded
}) })
t.Run("option with all properties should return error due to missing connector", func(t *testing.T) { t.Run("option with all properties but non-existent connector should return error", func(t *testing.T) {
option := &kbtypes.ProviderOption{ option := &kbtypes.ProviderOption{
Properties: map[string]interface{}{ Properties: map[string]interface{}{
"connector": "openai.gpt-4o", "connector": "fake.vision.connector",
"model": "gpt-4o", "model": "gpt-4o",
"prompt": "Describe this image", "prompt": "Describe this image",
"compress_size": 1024, "compress_size": 1024,
@ -58,21 +58,21 @@ func TestVision_Make(t *testing.T) {
} }
_, err := vision.Make(option) _, err := vision.Make(option)
if err == nil { if err == nil {
t.Error("Expected error due to missing connector") t.Error("Expected error for non-existent connector")
} }
// Error is expected because openai.gpt-4o connector is not loaded // Error is expected because fake.vision.connector is not loaded
}) })
t.Run("compress_size as float64 should be converted to int64 but still return error", func(t *testing.T) { t.Run("compress_size as float64 should be converted to int64 but still return error", func(t *testing.T) {
option := &kbtypes.ProviderOption{ option := &kbtypes.ProviderOption{
Properties: map[string]interface{}{ Properties: map[string]interface{}{
"connector": "openai.gpt-4o-mini", "connector": "invalid.test.connector",
"compress_size": 512.0, // float64 "compress_size": 512.0, // float64
}, },
} }
_, err := vision.Make(option) _, err := vision.Make(option)
if err == nil { if err == nil {
t.Error("Expected error due to missing connector") t.Error("Expected error for invalid connector")
} }
// Error is expected because connector is not loaded // Error is expected because connector is not loaded
}) })