refactor(conversion): OpenAI Chat/Responses API 跨格式字段双向转换统一化

- 将工具/tool_choice/web_search/custom tool 的双向转换函数提取到 constants.py,
  openai.py 和 openai_cli.py 共享,消除两端逻辑不一致
- 新增 Chat <-> Responses 的 passthrough 字段白名单,支持 metadata/user/
  service_tier/prompt_cache_key 等字段跨格式透传
- 支持 text config (response_format + verbosity) 在 Chat/Responses 间互转
- 修复 Gemini json_schema 解包:OpenAI 的 {name, schema, strict} 包装层
  不再被整体传入 Gemini responseSchema
- 修复 reasoning_effort 优先级:显式 effort 优先于 budget_tokens 反推,
  避免 Claude output_config.effort 被覆盖
- Gemini 格式输出增加 web_search_options -> googleSearch 工具映射
- Claude schema validator 增加 web_search 类型工具的宽松校验
- 新增覆盖测试:custom tool/tool_choice、allowed_tools、web_search 双向转换、
  text config 映射、passthrough 字段保留、跨格式 schema 校验
This commit is contained in:
fawney19
2026-03-16 14:21:14 +08:00
parent 025e979935
commit 791c9c98dc
9 changed files with 894 additions and 57 deletions

View File

@@ -1,10 +1,244 @@
"""格式转换层常量定义。
"""格式转换层常量定义 & 跨格式工具转换函数
将跨层共享的常量集中在 core 层,避免 core -> services 的反向依赖。
OpenAI Chat <-> Responses API 的工具 / tool_choice / web_search 双向转换
由 openai.py 和 openai_cli.py 共享,避免两端维护不一致。
"""
from __future__ import annotations
from typing import Any
# Thinking 签名验证的跳过标记
# 当无法获取真实签名时,使用此值作为占位符
DUMMY_THOUGHT_SIGNATURE = "skip_thought_signature_validator"
# ---------------------------------------------------------------------------
# OpenAI Chat / Responses API 跨格式透传字段白名单
# 由 openai.py 和 openai_cli.py 共享,避免两端维护不一致。
# ---------------------------------------------------------------------------
# 已由 normalizer 显式处理的字段 — 不需要从 extra 还原
OPENAI_HANDLED_KEYS: frozenset[str] = frozenset(
{
"messages",
"model",
"max_tokens",
"max_completion_tokens",
"temperature",
"top_p",
"stop",
"stream",
"tools",
"tool_choice",
"parallel_tool_calls",
"reasoning",
"reasoning_effort",
"n",
"presence_penalty",
"frequency_penalty",
"seed",
"logprobs",
"top_logprobs",
"response_format",
"verbosity",
"text",
"input",
"instructions",
"max_output_tokens",
"web_search_options",
"stream_options",
# 已废弃的 Chat API 字段,不需要透传
"function_call",
"functions",
}
)
# Chat Completions 允许透传的字段
OPENAI_CHAT_PASSTHROUGH_KEYS: frozenset[str] = frozenset(
{
"metadata",
"user",
"safety_identifier",
"prompt_cache_key",
"service_tier",
"prompt_cache_retention",
"modalities",
"audio",
"store",
"prediction",
"logit_bias",
}
)
# Responses API 允许透传的字段
OPENAI_RESPONSES_PASSTHROUGH_KEYS: frozenset[str] = frozenset(
{
"include",
"conversation",
"context_management",
"previous_response_id",
"background",
"max_tool_calls",
"prompt",
"truncation",
"metadata",
"user",
"safety_identifier",
"prompt_cache_key",
"service_tier",
"prompt_cache_retention",
"store",
}
)
# ---------------------------------------------------------------------------
# OpenAI Chat <-> Responses API 工具 / tool_choice / web_search 双向转换
# ---------------------------------------------------------------------------
def responses_tool_to_chat_tool(tool: dict[str, Any]) -> dict[str, Any] | None:
"""Responses API tool -> Chat Completions tool (嵌套结构)。"""
tool_type = str(tool.get("type") or "")
if tool_type == "function":
name = str(tool.get("name") or "")
if not name:
return None
function: dict[str, Any] = {"name": name}
if isinstance(tool.get("description"), str):
function["description"] = tool["description"]
if isinstance(tool.get("parameters"), dict):
function["parameters"] = tool["parameters"]
if tool.get("strict") is not None:
function["strict"] = tool.get("strict")
return {"type": "function", "function": function}
if tool_type == "custom":
name = str(tool.get("name") or "")
if not name:
return None
custom: dict[str, Any] = {"name": name}
if isinstance(tool.get("description"), str):
custom["description"] = tool["description"]
if isinstance(tool.get("format"), dict):
custom["format"] = tool["format"]
return {"type": "custom", "custom": custom}
return None
def chat_tool_to_responses_tool(tool: dict[str, Any]) -> dict[str, Any] | None:
"""Chat Completions tool (嵌套结构) -> Responses API tool (扁平结构)。"""
tool_type = str(tool.get("type") or "")
if tool_type == "function" and isinstance(tool.get("function"), dict):
function = tool["function"]
name = str(function.get("name") or "")
if not name:
return None
translated: dict[str, Any] = {"type": "function", "name": name}
if isinstance(function.get("description"), str):
translated["description"] = function["description"]
if isinstance(function.get("parameters"), dict):
translated["parameters"] = function["parameters"]
if function.get("strict") is not None:
translated["strict"] = function.get("strict")
return translated
if tool_type == "custom" and isinstance(tool.get("custom"), dict):
custom = tool["custom"]
name = str(custom.get("name") or "")
if not name:
return None
translated = {"type": "custom", "name": name}
if isinstance(custom.get("description"), str):
translated["description"] = custom["description"]
if isinstance(custom.get("format"), dict):
translated["format"] = custom["format"]
return translated
return None
def responses_web_search_tool_to_chat_options(
tool: dict[str, Any],
) -> dict[str, Any] | None:
"""Responses API web_search tool -> Chat Completions web_search_options。"""
tool_type = str(tool.get("type") or "")
if not tool_type.startswith("web_search"):
return None
options: dict[str, Any] = {}
user_location = tool.get("user_location")
if isinstance(user_location, dict):
approximate = dict(user_location)
approximate.pop("type", None)
options["user_location"] = {"type": "approximate", "approximate": approximate}
search_context_size = tool.get("search_context_size")
if isinstance(search_context_size, str) and search_context_size:
options["search_context_size"] = search_context_size
return options or None
def chat_web_search_options_to_responses_tools(
web_search_options: Any,
) -> list[dict[str, Any]] | None:
"""Chat Completions web_search_options -> Responses API web_search tool 列表。"""
if not isinstance(web_search_options, dict):
return None
tool: dict[str, Any] = {"type": "web_search"}
user_location = web_search_options.get("user_location")
if isinstance(user_location, dict):
approximate = user_location.get("approximate")
if isinstance(approximate, dict):
tool["user_location"] = {"type": "approximate", **approximate}
search_context_size = web_search_options.get("search_context_size")
if isinstance(search_context_size, str) and search_context_size:
tool["search_context_size"] = search_context_size
return [tool]
def responses_tool_choice_to_chat(
tool_choice: dict[str, Any],
) -> dict[str, Any] | None:
"""Responses API tool_choice dict -> Chat Completions tool_choice dict。"""
choice_type = str(tool_choice.get("type") or "")
if choice_type == "allowed_tools":
mode = tool_choice.get("mode")
tools = tool_choice.get("tools")
if isinstance(mode, str) and isinstance(tools, list):
return {"type": "allowed_tools", "allowed_tools": {"mode": mode, "tools": tools}}
if choice_type == "function":
fn = tool_choice.get("function")
name = str(
tool_choice.get("name") or (fn.get("name") if isinstance(fn, dict) else "") or ""
)
if name:
return {"type": "function", "function": {"name": name}}
if choice_type == "custom":
custom = tool_choice.get("custom")
name = str(
tool_choice.get("name")
or (custom.get("name") if isinstance(custom, dict) else "")
or ""
)
if name:
return {"type": "custom", "custom": {"name": name}}
return None
def chat_tool_choice_to_responses(
tool_choice: dict[str, Any],
) -> dict[str, Any] | None:
"""Chat Completions tool_choice dict -> Responses API tool_choice dict。"""
choice_type = str(tool_choice.get("type") or "")
if choice_type == "allowed_tools" and isinstance(tool_choice.get("allowed_tools"), dict):
allowed_tools = tool_choice["allowed_tools"]
mode = allowed_tools.get("mode")
tools = allowed_tools.get("tools")
if isinstance(mode, str) and isinstance(tools, list):
return {"type": "allowed_tools", "mode": mode, "tools": tools}
if choice_type == "function" and isinstance(tool_choice.get("function"), dict):
name = str(tool_choice["function"].get("name") or "")
if name:
return {"type": "function", "name": name}
if choice_type == "custom" and isinstance(tool_choice.get("custom"), dict):
name = str(tool_choice["custom"].get("name") or "")
if name:
return {"type": "custom", "name": name}
return None

View File

@@ -518,6 +518,11 @@ class GeminiNormalizer(FormatNormalizer):
):
generation_config["responseMimeType"] = "application/json"
schema = dict(internal.response_format.json_schema)
# OpenAI response_format.json_schema may wrap the actual schema
# under {"name", "schema", "strict"}; Gemini only wants the schema body.
wrapped_schema = schema.get("schema")
if isinstance(wrapped_schema, dict):
schema = dict(wrapped_schema)
_clean_gemini_schema(schema)
generation_config["responseSchema"] = schema
elif internal.response_format.type == "json_object":
@@ -551,6 +556,19 @@ class GeminiNormalizer(FormatNormalizer):
if response_modalities:
generation_config["responseModalities"] = response_modalities
# OpenAI web_search_options 语义上最接近 Gemini 的 googleSearch 内置工具。
web_search_opts = internal.extra.get("web_search_options") if internal.extra else None
if isinstance(web_search_opts, dict):
has_google_search = any(
isinstance(tool, dict)
and any(key in tool for key in ("googleSearch", "google_search"))
for tool in (tools or [])
)
if not has_google_search:
if tools is None:
tools = []
tools.append({"googleSearch": {}})
# 从 internal.extra["gemini"] 读取原生 Gemini 配置Gemini -> Gemini 场景)
gemini_extra = internal.extra.get("gemini", {})
if isinstance(gemini_extra, dict):

View File

@@ -12,6 +12,19 @@ import time
from datetime import datetime, timezone
from typing import Any
from src.core.api_format.conversion.constants import (
OPENAI_CHAT_PASSTHROUGH_KEYS as _CHAT_PASSTHROUGH_KEYS,
)
from src.core.api_format.conversion.constants import OPENAI_HANDLED_KEYS as _HANDLED_KEYS
from src.core.api_format.conversion.constants import (
responses_tool_choice_to_chat as _responses_tool_choice_to_chat,
)
from src.core.api_format.conversion.constants import (
responses_tool_to_chat_tool as _responses_tool_to_chat_tool,
)
from src.core.api_format.conversion.constants import (
responses_web_search_tool_to_chat_options as _responses_web_search_tool_to_chat_options,
)
from src.core.api_format.conversion.field_mappings import (
ERROR_TYPE_MAPPINGS,
REASONING_EFFORT_TO_THINKING_BUDGET,
@@ -197,6 +210,10 @@ class OpenAINormalizer(FormatNormalizer):
# 构建 extra保留未识别字段
extra: dict[str, Any] = {"openai": self._extract_extra(request, {"messages"})}
verbosity = request.get("verbosity")
if isinstance(verbosity, str) and verbosity:
extra["verbosity"] = verbosity
# 处理 extra_body.google (用于 Gemini 特定功能透传,如 thinkingConfig, responseModalities)
extra_body = request.get("extra_body")
if isinstance(extra_body, dict):
@@ -324,6 +341,19 @@ class OpenAINormalizer(FormatNormalizer):
# 跳过 Gemini 内置工具(在 OpenAI 中无对应物)
if t.extra.get("gemini_builtin_tool"):
continue
raw_chat_tool = t.extra.get("openai_chat_raw_tool")
if isinstance(raw_chat_tool, dict):
openai_tools.append(raw_chat_tool)
continue
raw_responses_tool = t.extra.get("openai_cli_raw_tool")
if isinstance(raw_responses_tool, dict):
if web_search_options := _responses_web_search_tool_to_chat_options(
raw_responses_tool
):
result["web_search_options"] = web_search_options
if chat_tool := _responses_tool_to_chat_tool(raw_responses_tool):
openai_tools.append(chat_tool)
continue
func: dict[str, Any] = {
"name": t.name,
"parameters": t.parameters or {},
@@ -348,14 +378,19 @@ class OpenAINormalizer(FormatNormalizer):
effort: str | None = None
if internal.thinking and internal.thinking.enabled:
effort = internal.thinking.extra.get("reasoning_effort")
if not effort and internal.thinking.budget_tokens is not None:
for threshold, level in THINKING_BUDGET_TO_REASONING_EFFORT:
if internal.thinking.budget_tokens <= threshold:
effort = level
break
# 兜底: 从 internal.extra 读取 (支持 output_config.effort 独立于 thinking 的场景)
# 显式 reasoning_effort 应优先于 budget 反推,避免覆盖 Claude output_config.effort
if not effort and internal.extra:
effort = internal.extra.get("reasoning_effort")
if (
not effort
and internal.thinking
and internal.thinking.enabled
and internal.thinking.budget_tokens is not None
):
for threshold, level in THINKING_BUDGET_TO_REASONING_EFFORT:
if internal.thinking.budget_tokens <= threshold:
effort = level
break
if effort:
# OpenAI Chat Completions 仅支持 low/medium/highxhigh 降级为 high
if effort == "xhigh":
@@ -387,6 +422,35 @@ class OpenAINormalizer(FormatNormalizer):
rf["json_schema"] = internal.response_format.json_schema
result["response_format"] = rf
verbosity = internal.extra.get("verbosity") if internal.extra else None
if isinstance(verbosity, str) and verbosity:
result["verbosity"] = verbosity
openai_extra = internal.extra.get("openai", {}) if internal.extra else {}
openai_cli_extra = internal.extra.get("openai_cli", {}) if internal.extra else {}
if isinstance(openai_cli_extra, dict):
text_config = openai_cli_extra.get("text")
if isinstance(text_config, dict):
if "response_format" not in result:
text_format = text_config.get("format")
if isinstance(text_format, dict) and text_format.get("type"):
result["response_format"] = text_format
if "verbosity" not in result:
text_verbosity = text_config.get("verbosity")
if isinstance(text_verbosity, str) and text_verbosity:
result["verbosity"] = text_verbosity
# 还原 OpenAI Chat 特有的透传字段(先到先得,与 openai_cli 一致)
if isinstance(openai_extra, dict):
for key, value in openai_extra.items():
if key in _CHAT_PASSTHROUGH_KEYS and key not in result and key not in _HANDLED_KEYS:
result[key] = value
if isinstance(openai_cli_extra, dict):
for key, value in openai_cli_extra.items():
if key in _CHAT_PASSTHROUGH_KEYS and key not in result and key not in _HANDLED_KEYS:
result[key] = value
return result
# =========================
@@ -1344,6 +1408,22 @@ class OpenAINormalizer(FormatNormalizer):
if not isinstance(tool, dict):
continue
if tool.get("type") != "function":
if tool.get("type") == "custom" and isinstance(tool.get("custom"), dict):
custom = tool["custom"]
name = str(custom.get("name") or "")
if not name:
continue
out.append(
ToolDefinition(
name=name,
description=custom.get("description"),
parameters=None,
extra={
"openai_chat_raw_tool": tool,
"openai_tool_kind": "custom",
},
)
)
continue
function_raw = tool.get("function")
@@ -1390,10 +1470,24 @@ class OpenAINormalizer(FormatNormalizer):
return ToolChoice(
type=ToolChoiceType.TOOL, tool_name=name, extra={"openai": tool_choice}
)
if isinstance(tool_choice, dict) and tool_choice.get("type") == "custom":
custom_raw = tool_choice.get("custom")
custom: dict[str, Any] = custom_raw if isinstance(custom_raw, dict) else {}
name = str(custom.get("name") or "")
return ToolChoice(
type=ToolChoiceType.TOOL, tool_name=name, extra={"openai": tool_choice}
)
return ToolChoice(type=ToolChoiceType.AUTO, extra={"openai": tool_choice})
def _tool_choice_to_openai(self, tool_choice: ToolChoice) -> str | dict[str, Any]:
raw_chat_choice = tool_choice.extra.get("openai")
if isinstance(raw_chat_choice, dict):
return raw_chat_choice
raw_responses_choice = tool_choice.extra.get("openai_cli")
if isinstance(raw_responses_choice, dict) and "type" in raw_responses_choice:
if chat_choice := _responses_tool_choice_to_chat(raw_responses_choice):
return chat_choice
if tool_choice.type == ToolChoiceType.NONE:
return "none"
if tool_choice.type == ToolChoiceType.AUTO:

View File

@@ -15,6 +15,19 @@ import time
from collections.abc import Callable
from typing import Any
from src.core.api_format.conversion.constants import OPENAI_HANDLED_KEYS as _HANDLED_KEYS
from src.core.api_format.conversion.constants import (
OPENAI_RESPONSES_PASSTHROUGH_KEYS as _RESPONSES_PASSTHROUGH_KEYS,
)
from src.core.api_format.conversion.constants import (
chat_tool_choice_to_responses as _chat_tool_choice_to_responses,
)
from src.core.api_format.conversion.constants import (
chat_tool_to_responses_tool as _chat_tool_to_responses_tool,
)
from src.core.api_format.conversion.constants import (
chat_web_search_options_to_responses_tools as _chat_web_search_options_to_responses_tools,
)
from src.core.api_format.conversion.field_mappings import (
ERROR_TYPE_MAPPINGS,
REASONING_EFFORT_TO_THINKING_BUDGET,
@@ -33,6 +46,7 @@ from src.core.api_format.conversion.internal import (
InternalMessage,
InternalRequest,
InternalResponse,
ResponseFormatConfig,
Role,
StopReason,
TextBlock,
@@ -141,6 +155,7 @@ class OpenAICliNormalizer(FormatNormalizer):
tool_choice = self._tool_choice_to_internal(request.get("tool_choice"))
max_tokens = self._optional_int(request.get("max_output_tokens", request.get("max_tokens")))
top_logprobs = self._optional_int(request.get("top_logprobs"))
# parallel_tool_calls
parallel_tool_calls: bool | None = None
@@ -160,6 +175,46 @@ class OpenAICliNormalizer(FormatNormalizer):
extra={"reasoning_effort": effort, "reasoning": reasoning},
)
text_config = request.get("text")
response_format: ResponseFormatConfig | None = None
extra: dict[str, Any] = {
"openai_cli": self._extract_extra(
request,
{
"model",
"input",
"instructions",
"max_output_tokens",
"max_tokens",
"temperature",
"top_p",
"top_logprobs",
"stop",
"stream",
"tools",
"tool_choice",
"parallel_tool_calls",
"reasoning",
},
)
}
if isinstance(text_config, dict):
fmt = text_config.get("format")
if isinstance(fmt, dict):
fmt_type = str(fmt.get("type") or "text")
fmt_schema = (
fmt.get("json_schema") if isinstance(fmt.get("json_schema"), dict) else None
)
if fmt_type != "text":
response_format = ResponseFormatConfig(
type=fmt_type,
json_schema=fmt_schema,
extra=self._extract_extra(fmt, {"type", "json_schema"}),
)
verbosity = text_config.get("verbosity")
if isinstance(verbosity, str) and verbosity:
extra["verbosity"] = verbosity
internal = InternalRequest(
model=model,
messages=messages,
@@ -174,26 +229,9 @@ class OpenAICliNormalizer(FormatNormalizer):
tool_choice=tool_choice,
thinking=thinking,
parallel_tool_calls=parallel_tool_calls,
extra={
"openai_cli": self._extract_extra(
request,
{
"model",
"input",
"instructions",
"max_output_tokens",
"max_tokens",
"temperature",
"top_p",
"stop",
"stream",
"tools",
"tool_choice",
"parallel_tool_calls",
"reasoning",
},
)
},
top_logprobs=top_logprobs,
response_format=response_format,
extra=extra,
)
# reasoning_effort 同步存入 extra (支持独立于 thinking 的跨格式转换)
@@ -209,6 +247,7 @@ class OpenAICliNormalizer(FormatNormalizer):
target_variant: str | None = None,
) -> dict[str, Any]:
_ = target_variant
openai_extra = internal.extra.get("openai", {})
openai_cli_extra = internal.extra.get("openai_cli", {})
result: dict[str, Any] = {
@@ -247,6 +286,10 @@ class OpenAICliNormalizer(FormatNormalizer):
raw_tool = t.extra.get("openai_cli_raw_tool")
if isinstance(raw_tool, dict):
rebuilt_tools.append(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)
else:
rebuilt_tools.append(
{
@@ -257,7 +300,8 @@ class OpenAICliNormalizer(FormatNormalizer):
**(t.extra.get("openai_tool") or {}),
}
)
result["tools"] = rebuilt_tools
if rebuilt_tools:
result["tools"] = rebuilt_tools
if internal.tool_choice:
result["tool_choice"] = self._tool_choice_to_openai(internal.tool_choice)
@@ -272,14 +316,20 @@ class OpenAICliNormalizer(FormatNormalizer):
effort = None # 已还原,不需要再构造
else:
effort = internal.thinking.extra.get("reasoning_effort")
if not effort and internal.thinking.budget_tokens is not None:
for threshold, level in THINKING_BUDGET_TO_REASONING_EFFORT:
if internal.thinking.budget_tokens <= threshold:
effort = level
break
# 兜底: 从 internal.extra 读取
# 显式 reasoning_effort 应优先于 budget 反推,避免覆盖 Claude output_config.effort
if not effort and internal.extra and "reasoning" not in result:
effort = internal.extra.get("reasoning_effort")
if (
not effort
and "reasoning" not in result
and internal.thinking
and internal.thinking.enabled
and internal.thinking.budget_tokens is not None
):
for threshold, level in THINKING_BUDGET_TO_REASONING_EFFORT:
if internal.thinking.budget_tokens <= threshold:
effort = level
break
if effort:
# xhigh 降级为 high (Responses API 也仅支持 low/medium/high)
if effort == "xhigh":
@@ -290,25 +340,47 @@ class OpenAICliNormalizer(FormatNormalizer):
if internal.parallel_tool_calls is not None:
result["parallel_tool_calls"] = internal.parallel_tool_calls
# 还原 OpenAI Responses API 的其他字段(黑名单:已单独处理的字段不还原)
handled_keys = {
"model",
"input",
"instructions",
"max_output_tokens",
"max_tokens",
"temperature",
"top_p",
"stop",
"stream",
"tools",
"tool_choice",
"parallel_tool_calls",
"reasoning",
}
for key, value in openai_cli_extra.items():
if key not in handled_keys and key not in result:
result[key] = value
text_config: dict[str, Any] = {}
if internal.response_format:
text_config["format"] = {"type": internal.response_format.type}
if internal.response_format.json_schema:
text_config["format"]["json_schema"] = internal.response_format.json_schema
verbosity = internal.extra.get("verbosity") if internal.extra else None
if isinstance(verbosity, str) and verbosity:
text_config["verbosity"] = verbosity
if text_config:
result["text"] = text_config
if internal.top_logprobs is not None:
result["top_logprobs"] = internal.top_logprobs
if isinstance(openai_extra, dict):
mapped_tools = _chat_web_search_options_to_responses_tools(
openai_extra.get("web_search_options")
)
if mapped_tools:
existing_tools = result.setdefault("tools", [])
if isinstance(existing_tools, list) and not any(
isinstance(tool, dict) and str(tool.get("type") or "").startswith("web_search")
for tool in existing_tools
):
existing_tools.extend(mapped_tools)
for key, value in openai_extra.items():
if (
key in _RESPONSES_PASSTHROUGH_KEYS
and key not in result
and key not in _HANDLED_KEYS
):
result[key] = value
if isinstance(openai_cli_extra, dict):
for key, value in openai_cli_extra.items():
if (
key in _RESPONSES_PASSTHROUGH_KEYS
and key not in result
and key not in _HANDLED_KEYS
):
result[key] = value
# 标准 Responses API 默认设置 store=false
if "store" not in result:
@@ -1794,13 +1866,19 @@ class OpenAICliNormalizer(FormatNormalizer):
# 非 function 类型(如 type: "custom", "web_search" 等):保留原始 dict 以便透传还原
if tool_type and tool_type != "function":
if not tool.get("name"):
name = str(tool.get("name") or "")
if tool_type == "custom" and not name:
custom_raw = tool.get("custom")
if isinstance(custom_raw, dict):
name = str(custom_raw.get("name") or "")
if not name and isinstance(tool_type, str) and tool_type:
name = tool_type
if not name:
logger.debug(
"[OpenAICliNormalizer] 跳过无 name 的非 function tool: type={}",
tool_type,
)
continue
name = str(tool["name"])
out.append(
ToolDefinition(
name=name,
@@ -1864,11 +1942,24 @@ class OpenAICliNormalizer(FormatNormalizer):
return ToolChoice(
type=ToolChoiceType.TOOL, tool_name=name, extra={"openai_cli": tool_choice}
)
if tool_choice.get("type") == "custom":
name = str(tool_choice.get("name") or "")
return ToolChoice(
type=ToolChoiceType.TOOL, tool_name=name, extra={"openai_cli": tool_choice}
)
return ToolChoice(type=ToolChoiceType.AUTO, extra={"openai_cli": tool_choice})
return ToolChoice(type=ToolChoiceType.AUTO, extra={"raw": tool_choice})
def _tool_choice_to_openai(self, tool_choice: ToolChoice) -> str | dict[str, Any]:
raw_responses_choice = tool_choice.extra.get("openai_cli")
if isinstance(raw_responses_choice, dict) and "type" in raw_responses_choice:
return raw_responses_choice
raw_chat_choice = tool_choice.extra.get("openai")
if isinstance(raw_chat_choice, dict):
converted = _chat_tool_choice_to_responses(raw_chat_choice)
if converted is not None:
return converted
if tool_choice.type == ToolChoiceType.NONE:
return "none"
if tool_choice.type == ToolChoiceType.AUTO: