feat(translator): add test and logic to ensure object schemas include properties field

- Added `TestConvertClaudeRequestToOpenAI_ToolSchemaAddsMissingObjectProperties` to validate automatic addition of missing `properties` in `object` schemas.
- Introduced `normalizeObjectSchemaProperties` to recursively ensure schemas of type `object` include an empty `properties` field if absent.
- Updated `ConvertClaudeRequestToOpenAI` to apply schema normalization for improved compatibility with OpenAI schema expectations.

Closes: #3165
This commit is contained in:
Luis Pater
2026-06-18 22:54:20 +08:00
parent ac8fb9706f
commit c13dbcc24e
2 changed files with 64 additions and 1 deletions

View File

@@ -313,7 +313,7 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
// Convert Anthropic input_schema to OpenAI function parameters
if inputSchema := tool.Get("input_schema"); inputSchema.Exists() {
openAIToolJSON, _ = sjson.SetBytes(openAIToolJSON, "function.parameters", inputSchema.Value())
openAIToolJSON, _ = sjson.SetBytes(openAIToolJSON, "function.parameters", normalizeObjectSchemaProperties(inputSchema.Value()))
}
toolsJSON, _ = sjson.SetRawBytes(toolsJSON, "-1", openAIToolJSON)
@@ -352,6 +352,28 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
return out
}
func normalizeObjectSchemaProperties(schema any) any {
switch value := schema.(type) {
case map[string]any:
if schemaType, ok := value["type"].(string); ok && schemaType == "object" {
if _, ok := value["properties"]; !ok {
value["properties"] = map[string]any{}
}
}
for key, child := range value {
value[key] = normalizeObjectSchemaProperties(child)
}
return value
case []any:
for i, child := range value {
value[i] = normalizeObjectSchemaProperties(child)
}
return value
default:
return schema
}
}
func shouldMapClaudeThinkingToGPTReasoning(part gjson.Result) bool {
signature := part.Get("signature")
if !signature.Exists() || strings.TrimSpace(signature.String()) == "" {

View File

@@ -496,6 +496,47 @@ func TestConvertClaudeRequestToOpenAI_SystemMessageScenarios(t *testing.T) {
}
}
func TestConvertClaudeRequestToOpenAI_ToolSchemaAddsMissingObjectProperties(t *testing.T) {
inputJSON := []byte(`{
"model": "claude-3-opus",
"tools": [
{
"name": "empty_params",
"description": "No args",
"input_schema": {"type": "object"}
},
{
"name": "nested_params",
"description": "Nested args",
"input_schema": {
"type": "object",
"properties": {
"nested": {"type": "object"},
"items": {
"type": "array",
"items": {"type": "object"}
}
}
}
}
],
"messages": [{"role": "user", "content": "hello"}]
}`)
output := ConvertClaudeRequestToOpenAI("test-model", inputJSON, false)
outputJSON := gjson.ParseBytes(output)
if got := outputJSON.Get("tools.0.function.parameters.properties"); !got.Exists() || !got.IsObject() {
t.Fatalf("root object properties missing or invalid: %s", outputJSON.Get("tools.0.function.parameters").Raw)
}
if got := outputJSON.Get("tools.1.function.parameters.properties.nested.properties"); !got.Exists() || !got.IsObject() {
t.Fatalf("nested object properties missing or invalid: %s", outputJSON.Get("tools.1.function.parameters").Raw)
}
if got := outputJSON.Get("tools.1.function.parameters.properties.items.items.properties"); !got.Exists() || !got.IsObject() {
t.Fatalf("array item object properties missing or invalid: %s", outputJSON.Get("tools.1.function.parameters").Raw)
}
}
func TestConvertClaudeRequestToOpenAI_ToolResultOrderAndContent(t *testing.T) {
inputJSON := `{
"model": "claude-3-opus",