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, "low": 1280,
"medium": 2048, "medium": 2048,
"high": 4096, "high": 4096,
"xhigh": 8192,
} }
# thinking budget_tokens -> OpenAI reasoning_effort反向映射取最近区间 # thinking budget_tokens -> OpenAI reasoning_effort反向映射取最近区间
THINKING_BUDGET_TO_REASONING_EFFORT: list[tuple[int, str]] = [ THINKING_BUDGET_TO_REASONING_EFFORT: list[tuple[int, str]] = [
(1664, "low"), # <= 1664 -> low (midpoint of 1280..2048) (1664, "low"), # <= 1664 -> low (midpoint of 1280..2048)
(3072, "medium"), # <= 3072 -> medium (midpoint of 2048..4096) (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 # OpenAI web_search_options.search_context_size -> Claude web_search max_uses
WEB_SEARCH_CONTEXT_SIZE_TO_MAX_USES: dict[str, int] = { WEB_SEARCH_CONTEXT_SIZE_TO_MAX_USES: dict[str, int] = {
@@ -190,6 +209,8 @@ __all__ = [
"RETRYABLE_ERROR_TYPES", "RETRYABLE_ERROR_TYPES",
"REASONING_EFFORT_TO_THINKING_BUDGET", "REASONING_EFFORT_TO_THINKING_BUDGET",
"THINKING_BUDGET_TO_REASONING_EFFORT", "THINKING_BUDGET_TO_REASONING_EFFORT",
"CLAUDE_EFFORT_TO_REASONING_EFFORT",
"REASONING_EFFORT_TO_CLAUDE_EFFORT",
"WEB_SEARCH_CONTEXT_SIZE_TO_MAX_USES", "WEB_SEARCH_CONTEXT_SIZE_TO_MAX_USES",
"CLAUDE_DEFAULT_MAX_TOKENS", "CLAUDE_DEFAULT_MAX_TOKENS",
"THINKING_BUDGET_TOKENS_PERCENTAGE", "THINKING_BUDGET_TOKENS_PERCENTAGE",

View File

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

View File

@@ -216,6 +216,7 @@ class OpenAINormalizer(FormatNormalizer):
budget_tokens=REASONING_EFFORT_TO_THINKING_BUDGET[reasoning_effort], budget_tokens=REASONING_EFFORT_TO_THINKING_BUDGET[reasoning_effort],
extra={"reasoning_effort": reasoning_effort}, extra={"reasoning_effort": reasoning_effort},
) )
extra["reasoning_effort"] = reasoning_effort
# web_search_options 存入 extra 供目标 normalizer 使用 # web_search_options 存入 extra 供目标 normalizer 使用
web_search_options = request.get("web_search_options") 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) result["tool_choice"] = self._tool_choice_to_openai(internal.tool_choice)
# thinking -> reasoning_effort # thinking -> reasoning_effort
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: if not effort and internal.thinking.budget_tokens is not None:
@@ -351,8 +353,14 @@ class OpenAINormalizer(FormatNormalizer):
if internal.thinking.budget_tokens <= threshold: if internal.thinking.budget_tokens <= threshold:
effort = level effort = level
break break
if effort: # 兜底: 从 internal.extra 读取 (支持 output_config.effort 独立于 thinking 的场景)
result["reasoning_effort"] = effort 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 # parallel_tool_calls
if internal.parallel_tool_calls is not None: 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 return internal
def request_from_internal( def request_from_internal(
@@ -259,11 +263,13 @@ class OpenAICliNormalizer(FormatNormalizer):
result["tool_choice"] = self._tool_choice_to_openai(internal.tool_choice) result["tool_choice"] = self._tool_choice_to_openai(internal.tool_choice)
# thinking -> reasoning (Responses API) # thinking -> reasoning (Responses API)
effort: str | None = None
if internal.thinking and internal.thinking.enabled: if internal.thinking and internal.thinking.enabled:
# 优先还原原始 reasoning 对象 # 优先还原原始 reasoning 对象
original_reasoning = internal.thinking.extra.get("reasoning") original_reasoning = internal.thinking.extra.get("reasoning")
if isinstance(original_reasoning, dict): if isinstance(original_reasoning, dict):
result["reasoning"] = original_reasoning result["reasoning"] = original_reasoning
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: if not effort and internal.thinking.budget_tokens is not None:
@@ -271,8 +277,14 @@ class OpenAICliNormalizer(FormatNormalizer):
if internal.thinking.budget_tokens <= threshold: if internal.thinking.budget_tokens <= threshold:
effort = level effort = level
break break
if effort: # 兜底: 从 internal.extra 读取
result["reasoning"] = {"effort": effort} 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 # parallel_tool_calls
if internal.parallel_tool_calls is not None: if internal.parallel_tool_calls is not None: