mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
fix: 补全 OpenAI 工具参数 schema 中缺失的 properties 字段
OpenAI API 要求 type=object 的 schema 节点必须声明 properties, 否则会拒绝请求。在 request_from_internal 出口处对工具参数 schema 进行深拷贝并递归补全缺失的空 properties,不影响内部表示。
This commit is contained in:
@@ -77,6 +77,12 @@ from src.core.api_format.conversion.stream_events import (
|
|||||||
ToolCallDeltaEvent,
|
ToolCallDeltaEvent,
|
||||||
)
|
)
|
||||||
from src.core.api_format.conversion.stream_state import StreamState
|
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
|
from src.core.logger import logger
|
||||||
|
|
||||||
|
|
||||||
@@ -344,7 +350,7 @@ class OpenAINormalizer(FormatNormalizer):
|
|||||||
continue
|
continue
|
||||||
raw_chat_tool = t.extra.get("openai_chat_raw_tool")
|
raw_chat_tool = t.extra.get("openai_chat_raw_tool")
|
||||||
if isinstance(raw_chat_tool, dict):
|
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
|
continue
|
||||||
raw_responses_tool = t.extra.get("openai_cli_raw_tool")
|
raw_responses_tool = t.extra.get("openai_cli_raw_tool")
|
||||||
if isinstance(raw_responses_tool, dict):
|
if isinstance(raw_responses_tool, dict):
|
||||||
@@ -353,7 +359,7 @@ class OpenAINormalizer(FormatNormalizer):
|
|||||||
):
|
):
|
||||||
result["web_search_options"] = web_search_options
|
result["web_search_options"] = web_search_options
|
||||||
if chat_tool := _responses_tool_to_chat_tool(raw_responses_tool):
|
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
|
continue
|
||||||
func: dict[str, Any] = {
|
func: dict[str, Any] = {
|
||||||
**(t.extra.get("openai_function") or {}),
|
**(t.extra.get("openai_function") or {}),
|
||||||
@@ -362,7 +368,7 @@ class OpenAINormalizer(FormatNormalizer):
|
|||||||
if t.description is not None:
|
if t.description is not None:
|
||||||
func["description"] = t.description
|
func["description"] = t.description
|
||||||
if t.parameters is not None:
|
if t.parameters is not None:
|
||||||
func["parameters"] = t.parameters
|
func["parameters"] = _clone_schema_with_openai_object_fixes(t.parameters)
|
||||||
openai_tools.append(
|
openai_tools.append(
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ from src.core.api_format.conversion.stream_events import (
|
|||||||
UnknownStreamEvent,
|
UnknownStreamEvent,
|
||||||
)
|
)
|
||||||
from src.core.api_format.conversion.stream_state import StreamState
|
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
|
from src.core.logger import logger
|
||||||
|
|
||||||
|
|
||||||
@@ -294,11 +300,13 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
# 非 function 类型(如 custom/web_search):直接还原原始 dict
|
# 非 function 类型(如 custom/web_search):直接还原原始 dict
|
||||||
raw_tool = t.extra.get("openai_cli_raw_tool")
|
raw_tool = t.extra.get("openai_cli_raw_tool")
|
||||||
if isinstance(raw_tool, dict):
|
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):
|
elif isinstance(t.extra.get("openai_chat_raw_tool"), dict):
|
||||||
chat_tool = t.extra["openai_chat_raw_tool"]
|
chat_tool = t.extra["openai_chat_raw_tool"]
|
||||||
if translated_tool := _chat_tool_to_responses_tool(chat_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:
|
else:
|
||||||
rebuilt_tool: dict[str, Any] = {
|
rebuilt_tool: dict[str, Any] = {
|
||||||
"type": "function",
|
"type": "function",
|
||||||
@@ -310,7 +318,9 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
if t.description is not None:
|
if t.description is not None:
|
||||||
rebuilt_tool["description"] = t.description
|
rebuilt_tool["description"] = t.description
|
||||||
if t.parameters is not None:
|
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)
|
rebuilt_tools.append(rebuilt_tool)
|
||||||
if rebuilt_tools:
|
if rebuilt_tools:
|
||||||
result["tools"] = rebuilt_tools
|
result["tools"] = rebuilt_tools
|
||||||
|
|||||||
@@ -96,6 +96,35 @@ def clean_gemini_schema(schema: dict[str, Any]) -> None:
|
|||||||
_clean_recursive(schema, is_schema_node=True)
|
_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 收集
|
# 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
|
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__ = [
|
__all__ = [
|
||||||
"GEMINI_FORBIDDEN_SCHEMA_FIELDS",
|
"GEMINI_FORBIDDEN_SCHEMA_FIELDS",
|
||||||
"clean_gemini_schema",
|
"clean_gemini_schema",
|
||||||
|
"clone_openai_tool_with_fixed_parameters",
|
||||||
|
"clone_schema_with_openai_object_fixes",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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"]
|
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:
|
def test_claude_explicit_effort_preserved_in_openai_cli() -> None:
|
||||||
reg = _make_registry_with_cli()
|
reg = _make_registry_with_cli()
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
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:
|
def test_openai_request_content_image_and_unknown_drop() -> None:
|
||||||
n = OpenAINormalizer()
|
n = OpenAINormalizer()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user