Skip to content

Commit b570aaa

Browse files
committed
fix: 补全 OpenAI 工具参数 schema 中缺失的 properties 字段
OpenAI API 要求 type=object 的 schema 节点必须声明 properties, 否则会拒绝请求。在 request_from_internal 出口处对工具参数 schema 进行深拷贝并递归补全缺失的空 properties,不影响内部表示。
1 parent 086efe6 commit b570aaa

5 files changed

Lines changed: 110 additions & 6 deletions

File tree

src/core/api_format/conversion/normalizers/openai.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@
7777
ToolCallDeltaEvent,
7878
)
7979
from src.core.api_format.conversion.stream_state import StreamState
80+
from src.core.api_format.schema_utils import (
81+
clone_openai_tool_with_fixed_parameters as _clone_openai_tool_with_fixed_parameters,
82+
)
83+
from src.core.api_format.schema_utils import (
84+
clone_schema_with_openai_object_fixes as _clone_schema_with_openai_object_fixes,
85+
)
8086
from src.core.logger import logger
8187

8288

@@ -344,7 +350,7 @@ def request_from_internal(
344350
continue
345351
raw_chat_tool = t.extra.get("openai_chat_raw_tool")
346352
if isinstance(raw_chat_tool, dict):
347-
openai_tools.append(raw_chat_tool)
353+
openai_tools.append(_clone_openai_tool_with_fixed_parameters(raw_chat_tool))
348354
continue
349355
raw_responses_tool = t.extra.get("openai_cli_raw_tool")
350356
if isinstance(raw_responses_tool, dict):
@@ -353,7 +359,7 @@ def request_from_internal(
353359
):
354360
result["web_search_options"] = web_search_options
355361
if chat_tool := _responses_tool_to_chat_tool(raw_responses_tool):
356-
openai_tools.append(chat_tool)
362+
openai_tools.append(_clone_openai_tool_with_fixed_parameters(chat_tool))
357363
continue
358364
func: dict[str, Any] = {
359365
**(t.extra.get("openai_function") or {}),
@@ -362,7 +368,7 @@ def request_from_internal(
362368
if t.description is not None:
363369
func["description"] = t.description
364370
if t.parameters is not None:
365-
func["parameters"] = t.parameters
371+
func["parameters"] = _clone_schema_with_openai_object_fixes(t.parameters)
366372
openai_tools.append(
367373
{
368374
"type": "function",

src/core/api_format/conversion/normalizers/openai_cli.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@
7474
UnknownStreamEvent,
7575
)
7676
from src.core.api_format.conversion.stream_state import StreamState
77+
from src.core.api_format.schema_utils import (
78+
clone_openai_tool_with_fixed_parameters as _clone_openai_tool_with_fixed_parameters,
79+
)
80+
from src.core.api_format.schema_utils import (
81+
clone_schema_with_openai_object_fixes as _clone_schema_with_openai_object_fixes,
82+
)
7783
from src.core.logger import logger
7884

7985

@@ -294,11 +300,13 @@ def request_from_internal(
294300
# 非 function 类型(如 custom/web_search):直接还原原始 dict
295301
raw_tool = t.extra.get("openai_cli_raw_tool")
296302
if isinstance(raw_tool, dict):
297-
rebuilt_tools.append(raw_tool)
303+
rebuilt_tools.append(_clone_openai_tool_with_fixed_parameters(raw_tool))
298304
elif isinstance(t.extra.get("openai_chat_raw_tool"), dict):
299305
chat_tool = t.extra["openai_chat_raw_tool"]
300306
if translated_tool := _chat_tool_to_responses_tool(chat_tool):
301-
rebuilt_tools.append(translated_tool)
307+
rebuilt_tools.append(
308+
_clone_openai_tool_with_fixed_parameters(translated_tool)
309+
)
302310
else:
303311
rebuilt_tool: dict[str, Any] = {
304312
"type": "function",
@@ -310,7 +318,9 @@ def request_from_internal(
310318
if t.description is not None:
311319
rebuilt_tool["description"] = t.description
312320
if t.parameters is not None:
313-
rebuilt_tool["parameters"] = t.parameters
321+
rebuilt_tool["parameters"] = _clone_schema_with_openai_object_fixes(
322+
t.parameters
323+
)
314324
rebuilt_tools.append(rebuilt_tool)
315325
if rebuilt_tools:
316326
result["tools"] = rebuilt_tools

src/core/api_format/schema_utils.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,35 @@ def clean_gemini_schema(schema: dict[str, Any]) -> None:
9696
_clean_recursive(schema, is_schema_node=True)
9797

9898

99+
def clone_schema_with_openai_object_fixes(schema: dict[str, Any]) -> dict[str, Any]:
100+
"""Clone a schema and add missing properties for object nodes.
101+
102+
OpenAI function tools reject object-typed parameter schemas when the object
103+
node does not declare a ``properties`` object. Keep the schema otherwise
104+
unchanged and only backfill empty ``properties`` where needed.
105+
"""
106+
cloned = copy.deepcopy(schema)
107+
_ensure_object_properties_recursive(cloned)
108+
return cloned
109+
110+
111+
def clone_openai_tool_with_fixed_parameters(tool: dict[str, Any]) -> dict[str, Any]:
112+
"""Clone an OpenAI Chat/Responses tool and repair function parameter schemas."""
113+
cloned = copy.deepcopy(tool)
114+
115+
function = cloned.get("function")
116+
if isinstance(function, dict):
117+
params = function.get("parameters")
118+
if isinstance(params, dict):
119+
_ensure_object_properties_recursive(params)
120+
121+
params = cloned.get("parameters")
122+
if isinstance(params, dict):
123+
_ensure_object_properties_recursive(params)
124+
125+
return cloned
126+
127+
99128
# ---------------------------------------------------------------------------
100129
# Phase 1: $defs 收集
101130
# ---------------------------------------------------------------------------
@@ -504,7 +533,31 @@ def _append_hint(obj: dict[str, Any], hint: str) -> None:
504533
obj["description"] = f"{desc} {hint}".strip() if desc else hint
505534

506535

536+
def _schema_type_includes_object(type_value: Any) -> bool:
537+
if isinstance(type_value, str):
538+
return type_value.lower() == "object"
539+
if isinstance(type_value, list):
540+
return any(isinstance(item, str) and item.lower() == "object" for item in type_value)
541+
return False
542+
543+
544+
def _ensure_object_properties_recursive(value: Any) -> None:
545+
if isinstance(value, dict):
546+
if _schema_type_includes_object(value.get("type")) and not isinstance(
547+
value.get("properties"), dict
548+
):
549+
value["properties"] = {}
550+
for item in value.values():
551+
_ensure_object_properties_recursive(item)
552+
return
553+
if isinstance(value, list):
554+
for item in value:
555+
_ensure_object_properties_recursive(item)
556+
557+
507558
__all__ = [
508559
"GEMINI_FORBIDDEN_SCHEMA_FIELDS",
509560
"clean_gemini_schema",
561+
"clone_openai_tool_with_fixed_parameters",
562+
"clone_schema_with_openai_object_fixes",
510563
]

tests/core/api_format/conversion/test_cli_conversion.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,21 @@ def test_openai_cli_request_from_internal_keeps_natural_insertion_order() -> Non
776776
assert list(out.keys())[:4] == ["model", "input", "max_output_tokens", "tools"]
777777

778778

779+
def test_openai_cli_request_from_internal_fixes_empty_object_tool_parameters() -> None:
780+
normalizer = OpenAICliNormalizer()
781+
internal = normalizer.request_to_internal(
782+
{
783+
"model": "gpt-test",
784+
"input": [],
785+
"tools": [{"type": "function", "name": "read_file", "parameters": {"type": "object"}}],
786+
}
787+
)
788+
789+
out = normalizer.request_from_internal(internal)
790+
791+
assert out["tools"][0]["parameters"] == {"type": "object", "properties": {}}
792+
793+
779794
def test_claude_explicit_effort_preserved_in_openai_cli() -> None:
780795
reg = _make_registry_with_cli()
781796

tests/core/api_format/conversion/test_openai_normalizer.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,26 @@ def test_openai_request_from_internal_keeps_natural_insertion_order() -> None:
258258
assert list(out.keys())[:4] == ["model", "messages", "max_tokens", "tools"]
259259

260260

261+
def test_openai_request_from_internal_fixes_empty_object_tool_parameters() -> None:
262+
n = OpenAINormalizer()
263+
264+
req = {
265+
"model": "gpt-4o-mini",
266+
"messages": [{"role": "user", "content": "ping"}],
267+
"tools": [
268+
{
269+
"type": "function",
270+
"function": {"name": "noop", "parameters": {"type": "object"}},
271+
}
272+
],
273+
}
274+
275+
internal = n.request_to_internal(req)
276+
out = n.request_from_internal(internal)
277+
278+
assert out["tools"][0]["function"]["parameters"] == {"type": "object", "properties": {}}
279+
280+
261281
def test_openai_request_content_image_and_unknown_drop() -> None:
262282
n = OpenAINormalizer()
263283

0 commit comments

Comments
 (0)