mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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/high,xhigh 降级为 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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user