fix: 补全 OpenAI 工具参数 schema 中缺失的 properties 字段

OpenAI API 要求 type=object 的 schema 节点必须声明 properties,
否则会拒绝请求。在 request_from_internal 出口处对工具参数 schema
进行深拷贝并递归补全缺失的空 properties,不影响内部表示。
This commit is contained in:
fawney19
2026-03-19 02:36:22 +08:00
parent 086efe6efe
commit b570aaac48
5 changed files with 110 additions and 6 deletions

View File

@@ -77,6 +77,12 @@ from src.core.api_format.conversion.stream_events import (
ToolCallDeltaEvent,
)
from src.core.api_format.conversion.stream_state import StreamState
from src.core.api_format.schema_utils import (
clone_openai_tool_with_fixed_parameters as _clone_openai_tool_with_fixed_parameters,
)
from src.core.api_format.schema_utils import (
clone_schema_with_openai_object_fixes as _clone_schema_with_openai_object_fixes,
)
from src.core.logger import logger
@@ -344,7 +350,7 @@ class OpenAINormalizer(FormatNormalizer):
continue
raw_chat_tool = t.extra.get("openai_chat_raw_tool")
if isinstance(raw_chat_tool, dict):
openai_tools.append(raw_chat_tool)
openai_tools.append(_clone_openai_tool_with_fixed_parameters(raw_chat_tool))
continue
raw_responses_tool = t.extra.get("openai_cli_raw_tool")
if isinstance(raw_responses_tool, dict):
@@ -353,7 +359,7 @@ class OpenAINormalizer(FormatNormalizer):
):
result["web_search_options"] = web_search_options
if chat_tool := _responses_tool_to_chat_tool(raw_responses_tool):
openai_tools.append(chat_tool)
openai_tools.append(_clone_openai_tool_with_fixed_parameters(chat_tool))
continue
func: dict[str, Any] = {
**(t.extra.get("openai_function") or {}),
@@ -362,7 +368,7 @@ class OpenAINormalizer(FormatNormalizer):
if t.description is not None:
func["description"] = t.description
if t.parameters is not None:
func["parameters"] = t.parameters
func["parameters"] = _clone_schema_with_openai_object_fixes(t.parameters)
openai_tools.append(
{
"type": "function",

View File

@@ -74,6 +74,12 @@ from src.core.api_format.conversion.stream_events import (
UnknownStreamEvent,
)
from src.core.api_format.conversion.stream_state import StreamState
from src.core.api_format.schema_utils import (
clone_openai_tool_with_fixed_parameters as _clone_openai_tool_with_fixed_parameters,
)
from src.core.api_format.schema_utils import (
clone_schema_with_openai_object_fixes as _clone_schema_with_openai_object_fixes,
)
from src.core.logger import logger
@@ -294,11 +300,13 @@ class OpenAICliNormalizer(FormatNormalizer):
# 非 function 类型(如 custom/web_search直接还原原始 dict
raw_tool = t.extra.get("openai_cli_raw_tool")
if isinstance(raw_tool, dict):
rebuilt_tools.append(raw_tool)
rebuilt_tools.append(_clone_openai_tool_with_fixed_parameters(raw_tool))
elif isinstance(t.extra.get("openai_chat_raw_tool"), dict):
chat_tool = t.extra["openai_chat_raw_tool"]
if translated_tool := _chat_tool_to_responses_tool(chat_tool):
rebuilt_tools.append(translated_tool)
rebuilt_tools.append(
_clone_openai_tool_with_fixed_parameters(translated_tool)
)
else:
rebuilt_tool: dict[str, Any] = {
"type": "function",
@@ -310,7 +318,9 @@ class OpenAICliNormalizer(FormatNormalizer):
if t.description is not None:
rebuilt_tool["description"] = t.description
if t.parameters is not None:
rebuilt_tool["parameters"] = t.parameters
rebuilt_tool["parameters"] = _clone_schema_with_openai_object_fixes(
t.parameters
)
rebuilt_tools.append(rebuilt_tool)
if rebuilt_tools:
result["tools"] = rebuilt_tools

View File

@@ -96,6 +96,35 @@ def clean_gemini_schema(schema: dict[str, Any]) -> None:
_clean_recursive(schema, is_schema_node=True)
def clone_schema_with_openai_object_fixes(schema: dict[str, Any]) -> dict[str, Any]:
"""Clone a schema and add missing properties for object nodes.
OpenAI function tools reject object-typed parameter schemas when the object
node does not declare a ``properties`` object. Keep the schema otherwise
unchanged and only backfill empty ``properties`` where needed.
"""
cloned = copy.deepcopy(schema)
_ensure_object_properties_recursive(cloned)
return cloned
def clone_openai_tool_with_fixed_parameters(tool: dict[str, Any]) -> dict[str, Any]:
"""Clone an OpenAI Chat/Responses tool and repair function parameter schemas."""
cloned = copy.deepcopy(tool)
function = cloned.get("function")
if isinstance(function, dict):
params = function.get("parameters")
if isinstance(params, dict):
_ensure_object_properties_recursive(params)
params = cloned.get("parameters")
if isinstance(params, dict):
_ensure_object_properties_recursive(params)
return cloned
# ---------------------------------------------------------------------------
# Phase 1: $defs 收集
# ---------------------------------------------------------------------------
@@ -504,7 +533,31 @@ def _append_hint(obj: dict[str, Any], hint: str) -> None:
obj["description"] = f"{desc} {hint}".strip() if desc else hint
def _schema_type_includes_object(type_value: Any) -> bool:
if isinstance(type_value, str):
return type_value.lower() == "object"
if isinstance(type_value, list):
return any(isinstance(item, str) and item.lower() == "object" for item in type_value)
return False
def _ensure_object_properties_recursive(value: Any) -> None:
if isinstance(value, dict):
if _schema_type_includes_object(value.get("type")) and not isinstance(
value.get("properties"), dict
):
value["properties"] = {}
for item in value.values():
_ensure_object_properties_recursive(item)
return
if isinstance(value, list):
for item in value:
_ensure_object_properties_recursive(item)
__all__ = [
"GEMINI_FORBIDDEN_SCHEMA_FIELDS",
"clean_gemini_schema",
"clone_openai_tool_with_fixed_parameters",
"clone_schema_with_openai_object_fixes",
]

View File

@@ -776,6 +776,21 @@ def test_openai_cli_request_from_internal_keeps_natural_insertion_order() -> Non
assert list(out.keys())[:4] == ["model", "input", "max_output_tokens", "tools"]
def test_openai_cli_request_from_internal_fixes_empty_object_tool_parameters() -> None:
normalizer = OpenAICliNormalizer()
internal = normalizer.request_to_internal(
{
"model": "gpt-test",
"input": [],
"tools": [{"type": "function", "name": "read_file", "parameters": {"type": "object"}}],
}
)
out = normalizer.request_from_internal(internal)
assert out["tools"][0]["parameters"] == {"type": "object", "properties": {}}
def test_claude_explicit_effort_preserved_in_openai_cli() -> None:
reg = _make_registry_with_cli()

View File

@@ -258,6 +258,26 @@ def test_openai_request_from_internal_keeps_natural_insertion_order() -> None:
assert list(out.keys())[:4] == ["model", "messages", "max_tokens", "tools"]
def test_openai_request_from_internal_fixes_empty_object_tool_parameters() -> None:
n = OpenAINormalizer()
req = {
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "ping"}],
"tools": [
{
"type": "function",
"function": {"name": "noop", "parameters": {"type": "object"}},
}
],
}
internal = n.request_to_internal(req)
out = n.request_from_internal(internal)
assert out["tools"][0]["function"]["parameters"] == {"type": "object", "properties": {}}
def test_openai_request_content_image_and_unknown_drop() -> None:
n = OpenAINormalizer()