mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +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:
2
aether-hub/Cargo.lock
generated
2
aether-hub/Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
2
aether-proxy/Cargo.lock
generated
2
aether-proxy/Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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/high,xhigh 降级为 high
|
# OpenAI Chat Completions 仅支持 low/medium/high,xhigh 降级为 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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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'")
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) == []
|
||||||
|
|||||||
Reference in New Issue
Block a user