feat(conversion): 支持 Claude output_config.effort 跨格式转换,新增 xhigh 档位

- 新增 Claude output_config.effort 与标准化 reasoning_effort 的双向映射
- 新增 xhigh 档位(budget_tokens=8192),对应 Claude effort=max
- reasoning_effort 独立于 thinking 存入 extra,支持无 thinking 场景的跨格式传递
- OpenAI/Responses API 输出时 xhigh 自动降级为 high
This commit is contained in:
fawney19
2026-03-15 17:57:12 +08:00
parent 8cc70934da
commit 900e54d740
4 changed files with 61 additions and 5 deletions

View File

@@ -130,15 +130,34 @@ REASONING_EFFORT_TO_THINKING_BUDGET: dict[str, int] = {
"low": 1280,
"medium": 2048,
"high": 4096,
"xhigh": 8192,
}
# thinking budget_tokens -> OpenAI reasoning_effort反向映射取最近区间
THINKING_BUDGET_TO_REASONING_EFFORT: list[tuple[int, str]] = [
(1664, "low"), # <= 1664 -> low (midpoint of 1280..2048)
(3072, "medium"), # <= 3072 -> medium (midpoint of 2048..4096)
(2**31, "high"), # > 3072 -> high
(6144, "high"), # <= 6144 -> high (midpoint of 4096..8192)
(2**31, "xhigh"), # > 6144 -> xhigh
]
# Claude output_config.effort -> 标准化 reasoning_effort
CLAUDE_EFFORT_TO_REASONING_EFFORT: dict[str, str] = {
"low": "low",
"medium": "medium",
"high": "high",
"max": "xhigh",
# "auto" 不映射,让目标格式使用默认行为
}
# 标准化 reasoning_effort -> Claude output_config.effort
REASONING_EFFORT_TO_CLAUDE_EFFORT: dict[str, str] = {
"low": "low",
"medium": "medium",
"high": "high",
"xhigh": "max",
}
# OpenAI web_search_options.search_context_size -> Claude web_search max_uses
WEB_SEARCH_CONTEXT_SIZE_TO_MAX_USES: dict[str, int] = {
@@ -190,6 +209,8 @@ __all__ = [
"RETRYABLE_ERROR_TYPES",
"REASONING_EFFORT_TO_THINKING_BUDGET",
"THINKING_BUDGET_TO_REASONING_EFFORT",
"CLAUDE_EFFORT_TO_REASONING_EFFORT",
"REASONING_EFFORT_TO_CLAUDE_EFFORT",
"WEB_SEARCH_CONTEXT_SIZE_TO_MAX_USES",
"CLAUDE_DEFAULT_MAX_TOKENS",
"THINKING_BUDGET_TOKENS_PERCENTAGE",

View File

@@ -11,7 +11,9 @@ import json
from typing import Any
from src.core.api_format.conversion.field_mappings import (
CLAUDE_EFFORT_TO_REASONING_EFFORT,
ERROR_TYPE_MAPPINGS,
REASONING_EFFORT_TO_CLAUDE_EFFORT,
RETRYABLE_ERROR_TYPES,
STOP_REASON_MAPPINGS,
THINKING_BUDGET_TOKENS_MIN,
@@ -187,11 +189,19 @@ class ClaudeNormalizer(FormatNormalizer):
"tools",
"tool_choice",
"thinking",
"output_config",
},
)
},
)
# output_config.effort -> reasoning_effort (独立于 thinking)
output_config = request.get("output_config")
if isinstance(output_config, dict):
effort = output_config.get("effort")
if isinstance(effort, str) and effort in CLAUDE_EFFORT_TO_REASONING_EFFORT:
internal.extra["reasoning_effort"] = CLAUDE_EFFORT_TO_REASONING_EFFORT[effort]
if dropped:
internal.extra.setdefault("raw", {})["dropped_blocks"] = dropped
@@ -314,6 +324,11 @@ class ClaudeNormalizer(FormatNormalizer):
result["max_tokens"] = bt + 1
result["thinking"] = thinking_out
# reasoning_effort -> output_config.effort (独立于 thinking)
effort = internal.extra.get("reasoning_effort") if internal.extra else None
if isinstance(effort, str) and effort in REASONING_EFFORT_TO_CLAUDE_EFFORT:
result["output_config"] = {"effort": REASONING_EFFORT_TO_CLAUDE_EFFORT[effort]}
# web_search_options -> Claude web_search tool
web_search_opts = internal.extra.get("web_search_options") if internal.extra else None
if isinstance(web_search_opts, dict):

View File

@@ -216,6 +216,7 @@ class OpenAINormalizer(FormatNormalizer):
budget_tokens=REASONING_EFFORT_TO_THINKING_BUDGET[reasoning_effort],
extra={"reasoning_effort": reasoning_effort},
)
extra["reasoning_effort"] = reasoning_effort
# web_search_options 存入 extra 供目标 normalizer 使用
web_search_options = request.get("web_search_options")
@@ -344,6 +345,7 @@ class OpenAINormalizer(FormatNormalizer):
result["tool_choice"] = self._tool_choice_to_openai(internal.tool_choice)
# thinking -> reasoning_effort
effort: str | None = None
if internal.thinking and internal.thinking.enabled:
effort = internal.thinking.extra.get("reasoning_effort")
if not effort and internal.thinking.budget_tokens is not None:
@@ -351,8 +353,14 @@ class OpenAINormalizer(FormatNormalizer):
if internal.thinking.budget_tokens <= threshold:
effort = level
break
if effort:
result["reasoning_effort"] = effort
# 兜底: 从 internal.extra 读取 (支持 output_config.effort 独立于 thinking 的场景)
if not effort and internal.extra:
effort = internal.extra.get("reasoning_effort")
if effort:
# OpenAI Chat Completions 仅支持 low/medium/highxhigh 降级为 high
if effort == "xhigh":
effort = "high"
result["reasoning_effort"] = effort
# parallel_tool_calls
if internal.parallel_tool_calls is not None:

View File

@@ -196,6 +196,10 @@ class OpenAICliNormalizer(FormatNormalizer):
},
)
# reasoning_effort 同步存入 extra (支持独立于 thinking 的跨格式转换)
if thinking and thinking.extra.get("reasoning_effort"):
internal.extra["reasoning_effort"] = thinking.extra["reasoning_effort"]
return internal
def request_from_internal(
@@ -259,11 +263,13 @@ class OpenAICliNormalizer(FormatNormalizer):
result["tool_choice"] = self._tool_choice_to_openai(internal.tool_choice)
# thinking -> reasoning (Responses API)
effort: str | None = None
if internal.thinking and internal.thinking.enabled:
# 优先还原原始 reasoning 对象
original_reasoning = internal.thinking.extra.get("reasoning")
if isinstance(original_reasoning, dict):
result["reasoning"] = original_reasoning
effort = None # 已还原,不需要再构造
else:
effort = internal.thinking.extra.get("reasoning_effort")
if not effort and internal.thinking.budget_tokens is not None:
@@ -271,8 +277,14 @@ class OpenAICliNormalizer(FormatNormalizer):
if internal.thinking.budget_tokens <= threshold:
effort = level
break
if effort:
result["reasoning"] = {"effort": effort}
# 兜底: 从 internal.extra 读取
if not effort and internal.extra and "reasoning" not in result:
effort = internal.extra.get("reasoning_effort")
if effort:
# xhigh 降级为 high (Responses API 也仅支持 low/medium/high)
if effort == "xhigh":
effort = "high"
result["reasoning"] = {"effort": effort}
# parallel_tool_calls
if internal.parallel_tool_calls is not None: