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

2
aether-hub/Cargo.lock generated
View File

@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "aether-hub" name = "aether-hub"
version = "0.1.6" version = "0.1.7"
dependencies = [ dependencies = [
"axum", "axum",
"clap", "clap",

View File

@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "aether-proxy" name = "aether-proxy"
version = "0.2.3" version = "0.2.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",

View File

@@ -1,10 +1,244 @@
"""格式转换层常量定义。 """格式转换层常量定义 & 跨格式工具转换函数
将跨层共享的常量集中在 core 层,避免 core -> services 的反向依赖。 将跨层共享的常量集中在 core 层,避免 core -> services 的反向依赖。
OpenAI Chat <-> Responses API 的工具 / tool_choice / web_search 双向转换
由 openai.py 和 openai_cli.py 共享,避免两端维护不一致。
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any
# Thinking 签名验证的跳过标记 # Thinking 签名验证的跳过标记
# 当无法获取真实签名时,使用此值作为占位符 # 当无法获取真实签名时,使用此值作为占位符
DUMMY_THOUGHT_SIGNATURE = "skip_thought_signature_validator" 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" generation_config["responseMimeType"] = "application/json"
schema = dict(internal.response_format.json_schema) 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) _clean_gemini_schema(schema)
generation_config["responseSchema"] = schema generation_config["responseSchema"] = schema
elif internal.response_format.type == "json_object": elif internal.response_format.type == "json_object":
@@ -551,6 +556,19 @@ class GeminiNormalizer(FormatNormalizer):
if response_modalities: if response_modalities:
generation_config["responseModalities"] = 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 场景) # 从 internal.extra["gemini"] 读取原生 Gemini 配置Gemini -> Gemini 场景)
gemini_extra = internal.extra.get("gemini", {}) gemini_extra = internal.extra.get("gemini", {})
if isinstance(gemini_extra, dict): if isinstance(gemini_extra, dict):

View File

@@ -12,6 +12,19 @@ import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any 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 ( from src.core.api_format.conversion.field_mappings import (
ERROR_TYPE_MAPPINGS, ERROR_TYPE_MAPPINGS,
REASONING_EFFORT_TO_THINKING_BUDGET, REASONING_EFFORT_TO_THINKING_BUDGET,
@@ -197,6 +210,10 @@ class OpenAINormalizer(FormatNormalizer):
# 构建 extra保留未识别字段 # 构建 extra保留未识别字段
extra: dict[str, Any] = {"openai": self._extract_extra(request, {"messages"})} 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.google (用于 Gemini 特定功能透传,如 thinkingConfig, responseModalities)
extra_body = request.get("extra_body") extra_body = request.get("extra_body")
if isinstance(extra_body, dict): if isinstance(extra_body, dict):
@@ -324,6 +341,19 @@ class OpenAINormalizer(FormatNormalizer):
# 跳过 Gemini 内置工具(在 OpenAI 中无对应物) # 跳过 Gemini 内置工具(在 OpenAI 中无对应物)
if t.extra.get("gemini_builtin_tool"): if t.extra.get("gemini_builtin_tool"):
continue 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] = { func: dict[str, Any] = {
"name": t.name, "name": t.name,
"parameters": t.parameters or {}, "parameters": t.parameters or {},
@@ -348,14 +378,19 @@ class OpenAINormalizer(FormatNormalizer):
effort: str | None = None effort: str | None = None
if internal.thinking and internal.thinking.enabled: if internal.thinking and internal.thinking.enabled:
effort = internal.thinking.extra.get("reasoning_effort") effort = internal.thinking.extra.get("reasoning_effort")
if not effort and internal.thinking.budget_tokens is not None: # 显式 reasoning_effort 应优先于 budget 反推,避免覆盖 Claude output_config.effort
for threshold, level in THINKING_BUDGET_TO_REASONING_EFFORT:
if internal.thinking.budget_tokens <= threshold:
effort = level
break
# 兜底: 从 internal.extra 读取 (支持 output_config.effort 独立于 thinking 的场景)
if not effort and internal.extra: if not effort and internal.extra:
effort = internal.extra.get("reasoning_effort") 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: if effort:
# OpenAI Chat Completions 仅支持 low/medium/highxhigh 降级为 high # OpenAI Chat Completions 仅支持 low/medium/highxhigh 降级为 high
if effort == "xhigh": if effort == "xhigh":
@@ -387,6 +422,35 @@ class OpenAINormalizer(FormatNormalizer):
rf["json_schema"] = internal.response_format.json_schema rf["json_schema"] = internal.response_format.json_schema
result["response_format"] = rf 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 return result
# ========================= # =========================
@@ -1344,6 +1408,22 @@ class OpenAINormalizer(FormatNormalizer):
if not isinstance(tool, dict): if not isinstance(tool, dict):
continue continue
if tool.get("type") != "function": 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 continue
function_raw = tool.get("function") function_raw = tool.get("function")
@@ -1390,10 +1470,24 @@ class OpenAINormalizer(FormatNormalizer):
return ToolChoice( return ToolChoice(
type=ToolChoiceType.TOOL, tool_name=name, extra={"openai": tool_choice} 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}) return ToolChoice(type=ToolChoiceType.AUTO, extra={"openai": tool_choice})
def _tool_choice_to_openai(self, tool_choice: ToolChoice) -> str | dict[str, Any]: 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: if tool_choice.type == ToolChoiceType.NONE:
return "none" return "none"
if tool_choice.type == ToolChoiceType.AUTO: if tool_choice.type == ToolChoiceType.AUTO:

View File

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

View File

@@ -1023,6 +1023,16 @@ def _validate_claude_tool_def(tool: Any, index: int) -> list[str]:
if "name" not in tool: if "name" not in tool:
errors.append(f"{prefix}: tool missing 'name'") errors.append(f"{prefix}: tool missing 'name'")
tool_type = tool.get("type")
if isinstance(tool_type, str) and tool_type.startswith("web_search_"):
max_uses = tool.get("max_uses")
if max_uses is not None and not isinstance(max_uses, int):
errors.append(f"{prefix}: web_search 'max_uses' must be int")
user_location = tool.get("user_location")
if user_location is not None and not isinstance(user_location, dict):
errors.append(f"{prefix}: web_search 'user_location' must be dict")
return errors
if "input_schema" not in tool: if "input_schema" not in tool:
errors.append(f"{prefix}: tool missing 'input_schema'") errors.append(f"{prefix}: tool missing 'input_schema'")

View File

@@ -261,6 +261,253 @@ def test_openai_chat_empty_tool_call_id_repaired_when_convert_to_openai_cli() ->
assert function_call_output.get("call_id") == generated_id assert function_call_output.get("call_id") == generated_id
def test_openai_chat_prompt_cache_key_preserved_when_convert_to_openai_cli() -> None:
reg = _make_registry_with_cli()
openai_chat_req = {
"model": "gpt-5",
"messages": [{"role": "user", "content": "hi"}],
"prompt_cache_key": "cache-key-123",
}
out = reg.convert_request(openai_chat_req, "openai:chat", "openai:cli")
assert out["prompt_cache_key"] == "cache-key-123"
def test_openai_chat_text_config_maps_to_openai_cli_text_block() -> None:
reg = _make_registry_with_cli()
openai_chat_req = {
"model": "gpt-5",
"messages": [{"role": "user", "content": "hi"}],
"response_format": {
"type": "json_schema",
"json_schema": {"name": "answer", "schema": {"type": "object"}},
},
"verbosity": "low",
"logit_bias": {"42": 3},
}
out = reg.convert_request(openai_chat_req, "openai:chat", "openai:cli")
assert out["text"] == {
"format": {
"type": "json_schema",
"json_schema": {"name": "answer", "schema": {"type": "object"}},
},
"verbosity": "low",
}
assert "response_format" not in out
assert "verbosity" not in out
assert "logit_bias" not in out
def test_openai_cli_text_config_maps_to_openai_chat_fields() -> None:
reg = _make_registry_with_cli()
openai_cli_req = {
"model": "gpt-5",
"input": [{"role": "user", "content": [{"type": "input_text", "text": "hi"}]}],
"text": {
"format": {
"type": "json_schema",
"json_schema": {"name": "answer", "schema": {"type": "object"}},
},
"verbosity": "high",
},
"prompt_cache_key": "cache-key-456",
"service_tier": "flex",
"top_logprobs": 4,
}
out = reg.convert_request(openai_cli_req, "openai:cli", "openai:chat")
assert out["response_format"] == {
"type": "json_schema",
"json_schema": {"name": "answer", "schema": {"type": "object"}},
}
assert out["verbosity"] == "high"
assert out["prompt_cache_key"] == "cache-key-456"
assert out["service_tier"] == "flex"
assert out["top_logprobs"] == 4
assert "text" not in out
def test_openai_chat_custom_tool_and_choice_convert_to_openai_cli() -> None:
reg = _make_registry_with_cli()
openai_chat_req = {
"model": "gpt-5",
"messages": [{"role": "user", "content": "hi"}],
"tools": [
{
"type": "custom",
"custom": {
"name": "grep_repo",
"description": "Search repository text",
"format": {"type": "text"},
},
}
],
"tool_choice": {"type": "custom", "custom": {"name": "grep_repo"}},
}
out = reg.convert_request(openai_chat_req, "openai:chat", "openai:cli")
assert out["tools"] == [
{
"type": "custom",
"name": "grep_repo",
"description": "Search repository text",
"format": {"type": "text"},
}
]
assert out["tool_choice"] == {"type": "custom", "name": "grep_repo"}
def test_openai_cli_custom_tool_and_choice_convert_to_openai_chat() -> None:
reg = _make_registry_with_cli()
openai_cli_req = {
"model": "gpt-5",
"input": [{"role": "user", "content": [{"type": "input_text", "text": "hi"}]}],
"tools": [
{
"type": "custom",
"name": "grep_repo",
"description": "Search repository text",
"format": {"type": "text"},
}
],
"tool_choice": {"type": "custom", "name": "grep_repo"},
}
out = reg.convert_request(openai_cli_req, "openai:cli", "openai:chat")
assert out["tools"] == [
{
"type": "custom",
"custom": {
"name": "grep_repo",
"description": "Search repository text",
"format": {"type": "text"},
},
}
]
assert out["tool_choice"] == {"type": "custom", "custom": {"name": "grep_repo"}}
def test_openai_chat_allowed_tools_choice_convert_to_openai_cli() -> None:
reg = _make_registry_with_cli()
openai_chat_req = {
"model": "gpt-5",
"messages": [{"role": "user", "content": "hi"}],
"tool_choice": {
"type": "allowed_tools",
"allowed_tools": {
"mode": "required",
"tools": [{"type": "function", "function": {"name": "grep_repo"}}],
},
},
}
out = reg.convert_request(openai_chat_req, "openai:chat", "openai:cli")
assert out["tool_choice"] == {
"type": "allowed_tools",
"mode": "required",
"tools": [{"type": "function", "function": {"name": "grep_repo"}}],
}
def test_openai_cli_allowed_tools_choice_convert_to_openai_chat() -> None:
reg = _make_registry_with_cli()
openai_cli_req = {
"model": "gpt-5",
"input": [{"role": "user", "content": [{"type": "input_text", "text": "hi"}]}],
"tool_choice": {
"type": "allowed_tools",
"mode": "required",
"tools": [{"type": "function", "name": "grep_repo"}],
},
}
out = reg.convert_request(openai_cli_req, "openai:cli", "openai:chat")
assert out["tool_choice"] == {
"type": "allowed_tools",
"allowed_tools": {
"mode": "required",
"tools": [{"type": "function", "name": "grep_repo"}],
},
}
def test_openai_chat_web_search_options_convert_to_openai_cli_tools() -> None:
reg = _make_registry_with_cli()
openai_chat_req = {
"model": "gpt-5",
"messages": [{"role": "user", "content": "hi"}],
"web_search_options": {
"user_location": {
"type": "approximate",
"approximate": {"country": "US", "city": "San Francisco"},
},
"search_context_size": "high",
},
}
out = reg.convert_request(openai_chat_req, "openai:chat", "openai:cli")
assert out["tools"] == [
{
"type": "web_search",
"user_location": {
"type": "approximate",
"country": "US",
"city": "San Francisco",
},
"search_context_size": "high",
}
]
assert "web_search_options" not in out
def test_openai_cli_web_search_tool_convert_to_openai_chat_options() -> None:
reg = _make_registry_with_cli()
openai_cli_req = {
"model": "gpt-5",
"input": [{"role": "user", "content": [{"type": "input_text", "text": "hi"}]}],
"tools": [
{
"type": "web_search",
"user_location": {
"type": "approximate",
"country": "US",
"city": "San Francisco",
},
"search_context_size": "high",
}
],
}
out = reg.convert_request(openai_cli_req, "openai:cli", "openai:chat")
assert out["web_search_options"] == {
"user_location": {
"type": "approximate",
"approximate": {"country": "US", "city": "San Francisco"},
},
"search_context_size": "high",
}
assert "tools" not in out
def test_openai_cli_reasoning_preserved_in_roundtrip() -> None: def test_openai_cli_reasoning_preserved_in_roundtrip() -> None:
"""测试 OpenAI CLI 的 reasoning block 在 roundtrip 中被保留""" """测试 OpenAI CLI 的 reasoning block 在 roundtrip 中被保留"""
reg = _make_registry_with_cli() reg = _make_registry_with_cli()
@@ -354,6 +601,36 @@ def test_claude_tool_use_to_openai_cli() -> None:
assert fco_items[0]["output"] == "Hello World" assert fco_items[0]["output"] == "Hello World"
def test_claude_explicit_effort_preserved_in_openai_cli() -> None:
reg = _make_registry_with_cli()
claude_req = {
"model": "gpt-5.4",
"messages": [{"role": "user", "content": "hi"}],
"thinking": {"type": "enabled", "budget_tokens": 31999},
"output_config": {"effort": "medium"},
}
out = reg.convert_request(claude_req, "claude:chat", "openai:cli")
assert out["reasoning"] == {"effort": "medium"}
def test_claude_explicit_effort_preserved_in_openai_chat() -> None:
reg = _make_registry_with_cli()
claude_req = {
"model": "gpt-5.4",
"messages": [{"role": "user", "content": "hi"}],
"thinking": {"type": "enabled", "budget_tokens": 31999},
"output_config": {"effort": "medium"},
}
out = reg.convert_request(claude_req, "claude:chat", "openai:chat")
assert out["reasoning_effort"] == "medium"
def test_stream_openai_cli_in_progress_event() -> None: def test_stream_openai_cli_in_progress_event() -> None:
"""测试 OpenAI CLI 流式 response.in_progress 事件""" """测试 OpenAI CLI 流式 response.in_progress 事件"""
reg = _make_registry_with_cli() reg = _make_registry_with_cli()

View File

@@ -16,6 +16,8 @@ from src.core.api_format.conversion.normalizers.openai import OpenAINormalizer
from src.core.api_format.conversion.registry import FormatConversionRegistry from src.core.api_format.conversion.registry import FormatConversionRegistry
from src.core.api_format.conversion.stream_state import StreamState from src.core.api_format.conversion.stream_state import StreamState
from .fixtures.schema_validators import get_request_validator
def _make_registry() -> FormatConversionRegistry: def _make_registry() -> FormatConversionRegistry:
reg = FormatConversionRegistry() reg = FormatConversionRegistry()
@@ -105,3 +107,114 @@ def test_registry_canonical_stream_openai_to_claude() -> None:
types = [cast(dict[str, Any], e).get("type") for e in cast(list[dict[str, Any]], out_events)] types = [cast(dict[str, Any], e).get("type") for e in cast(list[dict[str, Any]], out_events)]
assert types[:3] == ["message_start", "content_block_start", "content_block_delta"] assert types[:3] == ["message_start", "content_block_start", "content_block_delta"]
def test_registry_canonical_request_openai_to_claude_preserves_supported_fields() -> None:
reg = _make_registry()
openai_req = {
"model": "gpt-5",
"messages": [{"role": "user", "content": "hi"}],
"reasoning_effort": "xhigh",
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "answer_schema",
"schema": {"type": "object", "properties": {"answer": {"type": "string"}}},
"strict": True,
},
},
"verbosity": "high",
"web_search_options": {
"search_context_size": "high",
"user_location": {"type": "approximate", "city": "Shanghai"},
},
"prompt_cache_key": "cache-key-123",
"service_tier": "priority",
"safety_identifier": "user-123",
}
out = reg.convert_request(openai_req, "openai:chat", "claude:chat")
assert out["output_config"] == {"effort": "max"}
assert "tools" in out
assert out["tools"][-1] == {
"type": "web_search_20250305",
"name": "web_search",
"max_uses": 10,
"user_location": {"type": "approximate", "city": "Shanghai"},
}
for dropped_field in (
"response_format",
"verbosity",
"reasoning_effort",
"web_search_options",
"prompt_cache_key",
"service_tier",
"safety_identifier",
):
assert dropped_field not in out
validator = get_request_validator("claude:chat")
assert validator is not None
assert validator(out) == []
def test_registry_canonical_request_openai_to_gemini_preserves_supported_fields() -> None:
reg = _make_registry()
openai_req = {
"model": "gpt-5",
"messages": [{"role": "user", "content": "hi"}],
"reasoning_effort": "medium",
"n": 3,
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "answer_schema",
"schema": {
"type": "object",
"properties": {"answer": {"type": "string"}},
"required": ["answer"],
},
"strict": True,
},
},
"verbosity": "low",
"web_search_options": {"search_context_size": "high"},
"prompt_cache_key": "cache-key-456",
"service_tier": "flex",
"safety_identifier": "user-456",
}
out = reg.convert_request(openai_req, "openai:chat", "gemini:chat")
generation_config = cast(dict[str, Any], out.get("generation_config") or {})
assert generation_config["thinkingConfig"] == {
"includeThoughts": True,
"thinkingBudget": 2048,
}
assert generation_config["candidateCount"] == 3
assert generation_config["responseMimeType"] == "application/json"
assert generation_config["responseSchema"] == {
"type": "object",
"properties": {"answer": {"type": "string"}},
"required": ["answer"],
}
assert out["tools"] == [{"googleSearch": {}}]
for dropped_field in (
"verbosity",
"reasoning_effort",
"response_format",
"web_search_options",
"prompt_cache_key",
"service_tier",
"safety_identifier",
):
assert dropped_field not in out
validator = get_request_validator("gemini:chat")
assert validator is not None
assert validator(out) == []