mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
refactor: 增强跨格式转换系统,支持 thinking/文件/音频/响应格式等完整转换
- 新增 ThinkingConfig/ResponseFormatConfig/FileBlock/AudioBlock 内部表示 - 实现 OpenAI reasoning_effort <-> Claude thinking <-> Gemini thinkingConfig 互转 - 支持 OpenAI web_search_options -> Claude web_search tool 转换 - 新增异步 convert_request_async,在转换阶段解析图片 URL 为 base64 - 将 GlobalModel.output_limit 传播至跨格式转换用于 max_tokens 默认值 - 前端模型表单增加最大输出 Token 和上下文窗口配置 - Claude normalizer 保留 cache_control 透传(system 数组和 content block) - Gemini normalizer 支持内置工具(googleSearch/codeExecution/urlContext) - OpenAI normalizer 补全采样参数、response_format、usage details 转换 - 各 normalizer 工具方法提取至基类消除重复代码 - 简化 Kiro model mapping 为直接透传 - 预置模型列表添加 claude-sonnet-4.6
This commit is contained in:
@@ -30,6 +30,8 @@ STOP_REASON_MAPPINGS: dict[str, dict[str, str]] = {
|
||||
"max_tokens": "max_tokens",
|
||||
"stop_sequence": "stop_sequence",
|
||||
"tool_use": "tool_use",
|
||||
"pause_turn": "end_turn",
|
||||
"refusal": "end_turn",
|
||||
# Claude 通常以错误/阻断体现,这里仅兜底
|
||||
"content_filtered": "end_turn",
|
||||
"unknown": "end_turn",
|
||||
@@ -40,6 +42,8 @@ STOP_REASON_MAPPINGS: dict[str, dict[str, str]] = {
|
||||
"stop_sequence": "stop",
|
||||
"tool_use": "tool_calls",
|
||||
"content_filtered": "content_filter",
|
||||
"refusal": "content_filter",
|
||||
"pause_turn": "stop",
|
||||
"unknown": "stop",
|
||||
},
|
||||
"GEMINI": {
|
||||
@@ -49,6 +53,8 @@ STOP_REASON_MAPPINGS: dict[str, dict[str, str]] = {
|
||||
# Gemini finishReason 对工具调用并没有稳定等价枚举,这里保守兜底为 STOP
|
||||
"tool_use": "STOP",
|
||||
"content_filtered": "SAFETY",
|
||||
"refusal": "SAFETY",
|
||||
"pause_turn": "OTHER",
|
||||
"unknown": "OTHER",
|
||||
},
|
||||
}
|
||||
@@ -118,10 +124,77 @@ RETRYABLE_ERROR_TYPES: set[str] = {
|
||||
}
|
||||
|
||||
|
||||
# OpenAI reasoning_effort -> thinking budget_tokens
|
||||
# 参考 new-api relay-claude.go:178-196
|
||||
REASONING_EFFORT_TO_THINKING_BUDGET: dict[str, int] = {
|
||||
"low": 1280,
|
||||
"medium": 2048,
|
||||
"high": 4096,
|
||||
}
|
||||
|
||||
# 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
|
||||
]
|
||||
|
||||
|
||||
# OpenAI web_search_options.search_context_size -> Claude web_search max_uses
|
||||
WEB_SEARCH_CONTEXT_SIZE_TO_MAX_USES: dict[str, int] = {
|
||||
"low": 1,
|
||||
"medium": 5,
|
||||
"high": 10,
|
||||
}
|
||||
|
||||
|
||||
# Claude max_tokens 兜底默认值(仅在 GlobalModel.output_limit 和请求 max_tokens 均为空时使用)
|
||||
# 参考 new-api setting/model_setting/claude.go 的 DefaultMaxTokens["default"]
|
||||
CLAUDE_DEFAULT_MAX_TOKENS: int = 8192
|
||||
|
||||
|
||||
def get_claude_default_max_tokens(_model: str) -> int:
|
||||
"""获取 Claude 的 max_tokens 兜底默认值。
|
||||
|
||||
正常情况下应优先使用 GlobalModel.config.output_limit(通过 InternalRequest.output_limit 传入),
|
||||
此函数仅在 output_limit 不可用时作为最终兜底。
|
||||
"""
|
||||
return CLAUDE_DEFAULT_MAX_TOKENS
|
||||
|
||||
|
||||
# thinking budget_tokens 占 max_tokens 的比例(参考 new-api: 0.8)
|
||||
THINKING_BUDGET_TOKENS_PERCENTAGE: float = 0.8
|
||||
|
||||
# thinking budget_tokens 最小值(Claude API 要求 >= 1024)
|
||||
THINKING_BUDGET_TOKENS_MIN: int = 1280
|
||||
|
||||
|
||||
def get_claude_default_thinking_budget(model: str) -> int:
|
||||
"""根据模型名称计算 thinking budget_tokens 默认值。
|
||||
|
||||
budget = max(max_tokens * THINKING_BUDGET_TOKENS_PERCENTAGE, THINKING_BUDGET_TOKENS_MIN)
|
||||
"""
|
||||
max_tokens = get_claude_default_max_tokens(model)
|
||||
return max(int(max_tokens * THINKING_BUDGET_TOKENS_PERCENTAGE), THINKING_BUDGET_TOKENS_MIN)
|
||||
|
||||
|
||||
# 跨格式 thinking 转换时,非 Claude 模型的默认 budget_tokens 安全值
|
||||
CROSS_FORMAT_THINKING_BUDGET_DEFAULT: int = 8192
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ROLE_MAPPINGS",
|
||||
"STOP_REASON_MAPPINGS",
|
||||
"USAGE_FIELD_MAPPINGS",
|
||||
"ERROR_TYPE_MAPPINGS",
|
||||
"RETRYABLE_ERROR_TYPES",
|
||||
"REASONING_EFFORT_TO_THINKING_BUDGET",
|
||||
"THINKING_BUDGET_TO_REASONING_EFFORT",
|
||||
"WEB_SEARCH_CONTEXT_SIZE_TO_MAX_USES",
|
||||
"CLAUDE_DEFAULT_MAX_TOKENS",
|
||||
"THINKING_BUDGET_TOKENS_PERCENTAGE",
|
||||
"THINKING_BUDGET_TOKENS_MIN",
|
||||
"CROSS_FORMAT_THINKING_BUDGET_DEFAULT",
|
||||
"get_claude_default_max_tokens",
|
||||
"get_claude_default_thinking_budget",
|
||||
]
|
||||
|
||||
273
src/core/api_format/conversion/image_resolver.py
Normal file
273
src/core/api_format/conversion/image_resolver.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
图片 URL 解析器
|
||||
|
||||
当跨格式转换时,目标格式需要 base64 图片数据(如 Claude 不原生支持 URL 图片引用),
|
||||
该模块负责自动下载图片 URL 并转换为 base64 内嵌数据。
|
||||
|
||||
使用方式:在跨格式转换前/后调用 resolve_image_urls()。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import ipaddress
|
||||
import mimetypes
|
||||
import socket
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.api_format.conversion.internal import (
|
||||
FileBlock,
|
||||
ImageBlock,
|
||||
InternalRequest,
|
||||
)
|
||||
from src.core.logger import logger
|
||||
|
||||
# 需要 base64 图片数据的目标格式前缀
|
||||
_FORMATS_REQUIRING_BASE64 = frozenset({"CLAUDE"})
|
||||
|
||||
# 图片下载超时(秒)
|
||||
_DOWNLOAD_TIMEOUT = 15.0
|
||||
|
||||
# 单张图片最大大小(字节,20MB)
|
||||
_MAX_IMAGE_SIZE = 20 * 1024 * 1024
|
||||
|
||||
# 并发下载数量限制
|
||||
_MAX_CONCURRENT_DOWNLOADS = 8
|
||||
|
||||
# 最大重定向次数
|
||||
_MAX_REDIRECTS = 5
|
||||
|
||||
|
||||
def _is_private_ip(addr: str) -> bool:
|
||||
"""检查 IP 地址是否为私有/内网地址。"""
|
||||
try:
|
||||
ip = ipaddress.ip_address(addr)
|
||||
return bool(ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved)
|
||||
except ValueError:
|
||||
return True
|
||||
|
||||
|
||||
async def _resolve_and_validate_host(hostname: str) -> list[str] | None:
|
||||
"""DNS 解析并校验所有 IP 均为公网地址(SSRF 防护)。
|
||||
|
||||
返回已校验的公网 IP 列表;如果任一 IP 为私有地址或解析失败,返回 None。
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
infos = await loop.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||
ips: list[str] = []
|
||||
for _family, _type, _proto, _canonname, sockaddr in infos:
|
||||
addr = sockaddr[0]
|
||||
if _is_private_ip(addr):
|
||||
return None
|
||||
ips.append(addr)
|
||||
return ips or None
|
||||
except (socket.gaierror, ValueError, OSError):
|
||||
# DNS 解析失败:安全默认拒绝
|
||||
return None
|
||||
|
||||
|
||||
async def _validate_url(url: str) -> bool:
|
||||
"""校验 URL 的 scheme 和主机地址,通过返回 True,否则返回 False。"""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
logger.warning("[ImageResolver] 不支持的 URL scheme: {}", url[:100])
|
||||
return False
|
||||
hostname = parsed.hostname or ""
|
||||
resolved = await _resolve_and_validate_host(hostname)
|
||||
if resolved is None:
|
||||
logger.warning("[ImageResolver] 拒绝下载私有网络地址: {}", url[:100])
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _validate_peer_ip(resp: httpx.Response) -> bool:
|
||||
"""校验 HTTP 响应的实际对端 IP 是否为公网地址(防 DNS rebinding TOCTOU 绕过)。
|
||||
|
||||
httpx 通过 extensions["network_stream"] 暴露底层连接,
|
||||
从中可获取对端地址进行二次校验。
|
||||
"""
|
||||
try:
|
||||
network_stream = resp.extensions.get("network_stream")
|
||||
if network_stream is None:
|
||||
logger.debug("[ImageResolver] network_stream 不可用, DNS rebinding 检测跳过")
|
||||
return True
|
||||
# asyncio transport 标准 extra info key 是 peername
|
||||
peername = network_stream.get_extra_info("peername")
|
||||
if peername is not None:
|
||||
peer_ip = peername[0] if isinstance(peername, tuple) else str(peername)
|
||||
if _is_private_ip(peer_ip):
|
||||
logger.warning("[ImageResolver] DNS rebinding 检测: 实际连接到私有 IP {}", peer_ip)
|
||||
return False
|
||||
except Exception as e:
|
||||
# 无法获取对端信息时放行(不阻塞正常功能),依赖前置 DNS 校验
|
||||
logger.debug("[ImageResolver] 无法获取对端 IP 信息(DNS rebinding 检测跳过): {}", e)
|
||||
return True
|
||||
|
||||
|
||||
async def _download_file(
|
||||
client: httpx.AsyncClient, url: str, semaphore: asyncio.Semaphore
|
||||
) -> tuple[str, str] | None:
|
||||
"""下载 URL 并返回 (base64_data, media_type),失败返回 None。
|
||||
|
||||
手动处理重定向,每一跳都检查目标地址(防止重定向到内网的 SSRF 绕过)。
|
||||
连接建立后二次校验对端 IP(防 DNS rebinding)。
|
||||
"""
|
||||
async with semaphore:
|
||||
try:
|
||||
if not await _validate_url(url):
|
||||
return None
|
||||
|
||||
current_url = url
|
||||
for _ in range(_MAX_REDIRECTS + 1):
|
||||
async with client.stream("GET", current_url) as resp:
|
||||
# DNS rebinding 防护:校验实际连接的对端 IP
|
||||
if not _validate_peer_ip(resp):
|
||||
return None
|
||||
|
||||
if resp.is_redirect:
|
||||
location = resp.headers.get("location", "")
|
||||
if not location:
|
||||
logger.warning("[ImageResolver] 重定向缺少 Location: {}", url[:100])
|
||||
return None
|
||||
redirect_url = urljoin(str(current_url), location)
|
||||
if not await _validate_url(redirect_url):
|
||||
return None
|
||||
current_url = redirect_url
|
||||
continue
|
||||
|
||||
resp.raise_for_status()
|
||||
|
||||
content_type = resp.headers.get("content-type", "")
|
||||
media_type = content_type.split(";")[0].strip().lower()
|
||||
if not media_type:
|
||||
media_type = _guess_media_type(current_url)
|
||||
|
||||
# 检查 MIME 类型是否为目标 API 可接受的类型
|
||||
if not any(media_type.startswith(p) for p in _ACCEPTED_MIME_PREFIXES):
|
||||
logger.warning(
|
||||
"[ImageResolver] 不支持的 MIME 类型 {}, 跳过: {}",
|
||||
media_type,
|
||||
url[:100],
|
||||
)
|
||||
return None
|
||||
|
||||
# 预检 Content-Length(如果有)
|
||||
content_length = resp.headers.get("content-length")
|
||||
if content_length:
|
||||
try:
|
||||
if int(content_length) > _MAX_IMAGE_SIZE:
|
||||
logger.warning(
|
||||
"[ImageResolver] Content-Length 超过大小限制: {}",
|
||||
url[:100],
|
||||
)
|
||||
return None
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# 流式累计读取并检查大小
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
async for chunk in resp.aiter_bytes():
|
||||
total += len(chunk)
|
||||
if total > _MAX_IMAGE_SIZE:
|
||||
logger.warning(
|
||||
"[ImageResolver] 文件超过大小限制 ({} bytes > {}): {}",
|
||||
total,
|
||||
_MAX_IMAGE_SIZE,
|
||||
url[:100],
|
||||
)
|
||||
return None
|
||||
chunks.append(chunk)
|
||||
|
||||
data = b"".join(chunks)
|
||||
b64 = base64.b64encode(data).decode("ascii")
|
||||
return b64, media_type
|
||||
|
||||
logger.warning("[ImageResolver] 超过最大重定向次数: {}", url[:100])
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("[ImageResolver] 下载文件失败: {} - {}", url[:100], e)
|
||||
return None
|
||||
|
||||
|
||||
def _guess_media_type(url: str) -> str:
|
||||
"""从 URL 路径猜测 MIME 类型。"""
|
||||
path = url.split("?")[0]
|
||||
mt, _ = mimetypes.guess_type(path)
|
||||
return mt or "application/octet-stream"
|
||||
|
||||
|
||||
# Claude API 支持的 MIME 类型前缀(图片/文档/音频等可直接作为 base64 内嵌的类型)
|
||||
_ACCEPTED_MIME_PREFIXES: tuple[str, ...] = (
|
||||
"image/",
|
||||
"application/pdf",
|
||||
"text/",
|
||||
"audio/",
|
||||
"video/",
|
||||
)
|
||||
|
||||
|
||||
def _is_data_url(url: str) -> bool:
|
||||
"""判断是否是 data: URL(已内嵌 base64)。"""
|
||||
return url.startswith("data:")
|
||||
|
||||
|
||||
async def resolve_image_urls(
|
||||
internal: InternalRequest,
|
||||
target_format: str,
|
||||
) -> None:
|
||||
"""遍历 InternalRequest 中所有 ImageBlock/FileBlock,对有 url 无 data 的进行下载转 base64。
|
||||
|
||||
仅当 target_format 需要 base64 时执行下载(如 CLAUDE)。
|
||||
直接修改 internal 对象,无返回值。
|
||||
"""
|
||||
target_upper = str(target_format).upper()
|
||||
|
||||
# 检查目标格式是否需要 base64
|
||||
needs_base64 = any(target_upper.startswith(prefix) for prefix in _FORMATS_REQUIRING_BASE64)
|
||||
if not needs_base64:
|
||||
return
|
||||
|
||||
# 收集所有需要下载的 block 及其 URL
|
||||
download_items: list[tuple[ImageBlock | FileBlock, str]] = []
|
||||
for msg in internal.messages:
|
||||
for block in msg.content:
|
||||
if isinstance(block, ImageBlock):
|
||||
if block.url and not block.data and not _is_data_url(block.url):
|
||||
download_items.append((block, block.url))
|
||||
elif isinstance(block, FileBlock):
|
||||
if block.file_url and not block.data and not _is_data_url(block.file_url):
|
||||
download_items.append((block, block.file_url))
|
||||
|
||||
if not download_items:
|
||||
return
|
||||
|
||||
semaphore = asyncio.Semaphore(_MAX_CONCURRENT_DOWNLOADS)
|
||||
|
||||
# 复用同一个 client 并发下载所有文件(禁用自动重定向,在 _download_file 中手动处理)
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=False,
|
||||
timeout=httpx.Timeout(_DOWNLOAD_TIMEOUT),
|
||||
limits=httpx.Limits(max_connections=_MAX_CONCURRENT_DOWNLOADS),
|
||||
) as client:
|
||||
tasks = [_download_file(client, url, semaphore) for _, url in download_items]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
# 回写结果
|
||||
for (block, url), result in zip(download_items, results):
|
||||
if result is not None:
|
||||
b64_data, media_type = result
|
||||
block.data = b64_data
|
||||
if not block.media_type:
|
||||
block.media_type = media_type
|
||||
# 保留原始 URL 以便调试,清除源 URL 字段避免语义模糊
|
||||
block.extra["original_url"] = url
|
||||
if isinstance(block, ImageBlock):
|
||||
block.url = None
|
||||
elif isinstance(block, FileBlock):
|
||||
block.file_url = None
|
||||
|
||||
|
||||
__all__ = ["resolve_image_urls"]
|
||||
@@ -28,6 +28,8 @@ class ContentType(str, Enum):
|
||||
TEXT = "text"
|
||||
THINKING = "thinking"
|
||||
IMAGE = "image"
|
||||
FILE = "file"
|
||||
AUDIO = "audio"
|
||||
TOOL_USE = "tool_use"
|
||||
TOOL_RESULT = "tool_result"
|
||||
UNKNOWN = "unknown"
|
||||
@@ -115,6 +117,30 @@ class ToolResultBlock:
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileBlock:
|
||||
"""文件内容块(PDF、文档等)"""
|
||||
|
||||
type: ContentType = field(default=ContentType.FILE, init=False)
|
||||
data: str | None = None # base64 编码
|
||||
media_type: str | None = None
|
||||
file_id: str | None = None # OpenAI file reference
|
||||
file_url: str | None = None # Gemini fileData URI
|
||||
filename: str | None = None
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioBlock:
|
||||
"""音频内容块"""
|
||||
|
||||
type: ContentType = field(default=ContentType.AUDIO, init=False)
|
||||
data: str | None = None # base64 编码
|
||||
media_type: str | None = None # 完整 MIME(如 audio/mp3)
|
||||
format: str | None = None # 简短格式名(如 mp3, wav)
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UnknownBlock:
|
||||
"""未知内容块(用于前向兼容)"""
|
||||
@@ -126,7 +152,14 @@ class UnknownBlock:
|
||||
|
||||
|
||||
ContentBlock = (
|
||||
TextBlock | ThinkingBlock | ImageBlock | ToolUseBlock | ToolResultBlock | UnknownBlock
|
||||
TextBlock
|
||||
| ThinkingBlock
|
||||
| ImageBlock
|
||||
| FileBlock
|
||||
| AudioBlock
|
||||
| ToolUseBlock
|
||||
| ToolResultBlock
|
||||
| UnknownBlock
|
||||
)
|
||||
|
||||
|
||||
@@ -174,6 +207,24 @@ class InstructionSegment:
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThinkingConfig:
|
||||
"""统一的思考/推理配置(对齐 Claude thinking / Gemini thinkingConfig / OpenAI reasoning_effort)"""
|
||||
|
||||
enabled: bool = False
|
||||
budget_tokens: int | None = None # None = provider 默认
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResponseFormatConfig:
|
||||
"""统一的响应格式配置(JSON mode / structured output)"""
|
||||
|
||||
type: str = "text" # "text" | "json_object" | "json_schema"
|
||||
json_schema: dict[str, Any] | None = None
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InternalRequest:
|
||||
"""统一的请求表示"""
|
||||
@@ -195,6 +246,27 @@ class InternalRequest:
|
||||
stream: bool = False
|
||||
tools: list[ToolDefinition] | None = None
|
||||
tool_choice: ToolChoice | None = None # auto/none/required 或指定 tool_name
|
||||
|
||||
# 思考/推理配置
|
||||
thinking: ThinkingConfig | None = None
|
||||
|
||||
# 并行工具调用控制
|
||||
parallel_tool_calls: bool | None = None
|
||||
|
||||
# 采样参数
|
||||
n: int | None = None
|
||||
presence_penalty: float | None = None
|
||||
frequency_penalty: float | None = None
|
||||
seed: int | None = None
|
||||
logprobs: bool | None = None
|
||||
top_logprobs: int | None = None
|
||||
|
||||
# 响应格式
|
||||
response_format: ResponseFormatConfig | None = None
|
||||
|
||||
# 模型输出上限(来自 GlobalModel.config.output_limit,用于跨格式转换时的 max_tokens 默认值)
|
||||
output_limit: int | None = None
|
||||
|
||||
extra: dict[str, Any] = field(default_factory=dict) # 未识别字段透传
|
||||
|
||||
def to_debug_dict(self) -> dict[str, Any]:
|
||||
@@ -293,6 +365,8 @@ __all__ = [
|
||||
"TextBlock",
|
||||
"ThinkingBlock",
|
||||
"ImageBlock",
|
||||
"FileBlock",
|
||||
"AudioBlock",
|
||||
"ToolUseBlock",
|
||||
"ToolResultBlock",
|
||||
"UnknownBlock",
|
||||
@@ -301,6 +375,8 @@ __all__ = [
|
||||
"InstructionSegment",
|
||||
"ToolDefinition",
|
||||
"ToolChoice",
|
||||
"ThinkingConfig",
|
||||
"ResponseFormatConfig",
|
||||
"InternalRequest",
|
||||
"UsageInfo",
|
||||
"InternalResponse",
|
||||
|
||||
@@ -115,6 +115,40 @@ class FormatNormalizer(ABC):
|
||||
return ImageBlock(data=data, media_type=media_type)
|
||||
return ImageBlock(url=url)
|
||||
|
||||
# ============ 通用解析工具 ============
|
||||
|
||||
def _optional_int(self, value: Any) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _optional_float(self, value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _coerce_str_list(self, value: Any) -> list[str] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
if isinstance(value, list):
|
||||
return [str(x) for x in value if x is not None]
|
||||
return None
|
||||
|
||||
def _extract_extra(self, payload: dict[str, Any], known_keys: set[str]) -> dict[str, Any]:
|
||||
return {k: v for k, v in payload.items() if k not in known_keys}
|
||||
|
||||
def _merge_dropped(self, target: dict[str, int], source: dict[str, int]) -> None:
|
||||
for k, v in source.items():
|
||||
target[k] = target.get(k, 0) + int(v)
|
||||
|
||||
# ============ 视频转换(可选) ============
|
||||
|
||||
def video_request_to_internal(self, request: dict[str, Any]) -> InternalVideoRequest:
|
||||
|
||||
@@ -14,12 +14,18 @@ from src.core.api_format.conversion.field_mappings import (
|
||||
ERROR_TYPE_MAPPINGS,
|
||||
RETRYABLE_ERROR_TYPES,
|
||||
STOP_REASON_MAPPINGS,
|
||||
THINKING_BUDGET_TOKENS_MIN,
|
||||
THINKING_BUDGET_TOKENS_PERCENTAGE,
|
||||
USAGE_FIELD_MAPPINGS,
|
||||
WEB_SEARCH_CONTEXT_SIZE_TO_MAX_USES,
|
||||
get_claude_default_max_tokens,
|
||||
)
|
||||
from src.core.api_format.conversion.internal import (
|
||||
AudioBlock,
|
||||
ContentBlock,
|
||||
ContentType,
|
||||
ErrorType,
|
||||
FileBlock,
|
||||
FormatCapabilities,
|
||||
ImageBlock,
|
||||
InstructionSegment,
|
||||
@@ -31,6 +37,7 @@ from src.core.api_format.conversion.internal import (
|
||||
StopReason,
|
||||
TextBlock,
|
||||
ThinkingBlock,
|
||||
ThinkingConfig,
|
||||
ToolChoice,
|
||||
ToolChoiceType,
|
||||
ToolDefinition,
|
||||
@@ -97,9 +104,12 @@ class ClaudeNormalizer(FormatNormalizer):
|
||||
|
||||
# 顶层 system 先进入 instructions(保持确定性优先级)
|
||||
sys_value = request.get("system")
|
||||
sys_text, sys_dropped = self._collapse_claude_system(sys_value)
|
||||
sys_text, sys_dropped, sys_segments = self._collapse_claude_system(sys_value)
|
||||
self._merge_dropped(dropped, sys_dropped)
|
||||
if sys_text:
|
||||
if sys_segments:
|
||||
# 数组格式含 cache_control:保留逐段 InstructionSegment
|
||||
instructions.extend(sys_segments)
|
||||
elif sys_text:
|
||||
instructions.append(InstructionSegment(role=Role.SYSTEM, text=sys_text))
|
||||
|
||||
messages: list[InternalMessage] = []
|
||||
@@ -112,7 +122,7 @@ class ClaudeNormalizer(FormatNormalizer):
|
||||
|
||||
# 兼容:少数客户端可能把 system/developer 混进 messages[]
|
||||
if role in ("system", "developer"):
|
||||
text, md = self._collapse_claude_system(msg.get("content"))
|
||||
text, md, _ = self._collapse_claude_system(msg.get("content"))
|
||||
self._merge_dropped(dropped, md)
|
||||
if text:
|
||||
instructions.append(
|
||||
@@ -134,6 +144,19 @@ class ClaudeNormalizer(FormatNormalizer):
|
||||
tools = self._claude_tools_to_internal(request.get("tools"))
|
||||
tool_choice = self._claude_tool_choice_to_internal(request.get("tool_choice"))
|
||||
|
||||
# 解析 Claude 原生 thinking 配置
|
||||
thinking: ThinkingConfig | None = None
|
||||
thinking_raw = request.get("thinking")
|
||||
if isinstance(thinking_raw, dict):
|
||||
thinking_type = str(thinking_raw.get("type") or "")
|
||||
if thinking_type in ("enabled", "adaptive"):
|
||||
budget = self._optional_int(thinking_raw.get("budget_tokens"))
|
||||
thinking = ThinkingConfig(
|
||||
enabled=True,
|
||||
budget_tokens=budget,
|
||||
extra={"claude_thinking": thinking_raw},
|
||||
)
|
||||
|
||||
internal = InternalRequest(
|
||||
model=model,
|
||||
messages=messages,
|
||||
@@ -147,6 +170,7 @@ class ClaudeNormalizer(FormatNormalizer):
|
||||
stream=bool(request.get("stream") or False),
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
thinking=thinking,
|
||||
extra={"claude": self._extract_extra(request, {"messages"})},
|
||||
)
|
||||
|
||||
@@ -161,7 +185,26 @@ class ClaudeNormalizer(FormatNormalizer):
|
||||
*,
|
||||
target_variant: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
system_text = internal.system or self._join_instructions(internal.instructions)
|
||||
# system: 如果任一 InstructionSegment 有 cache_control,输出数组格式
|
||||
has_cache_control = any(seg.extra.get("cache_control") for seg in internal.instructions)
|
||||
if has_cache_control and internal.instructions:
|
||||
system_value: str | list[dict[str, Any]] | None = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": seg.text,
|
||||
**(
|
||||
{"cache_control": seg.extra["cache_control"]}
|
||||
if seg.extra.get("cache_control")
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for seg in internal.instructions
|
||||
if seg.text
|
||||
]
|
||||
if not system_value:
|
||||
system_value = None
|
||||
else:
|
||||
system_value = internal.system or self._join_instructions(internal.instructions)
|
||||
|
||||
# Claude Messages API: messages[] 仅允许 user/assistant,且需要交替;这里做最小修复
|
||||
fixed_messages = self._coerce_claude_message_sequence(internal.messages)
|
||||
@@ -170,14 +213,23 @@ class ClaudeNormalizer(FormatNormalizer):
|
||||
self._internal_message_to_claude(m) for m in fixed_messages
|
||||
]
|
||||
|
||||
# max_tokens: 优先使用请求中的值, 其次 GlobalModel.output_limit, 最后硬编码默认值
|
||||
effective_max_tokens: int
|
||||
if internal.max_tokens is not None:
|
||||
effective_max_tokens = internal.max_tokens
|
||||
elif internal.output_limit is not None:
|
||||
effective_max_tokens = internal.output_limit
|
||||
else:
|
||||
effective_max_tokens = get_claude_default_max_tokens(internal.model)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"model": internal.model,
|
||||
"messages": out_messages,
|
||||
"max_tokens": internal.max_tokens if internal.max_tokens is not None else 4096,
|
||||
"max_tokens": effective_max_tokens,
|
||||
}
|
||||
|
||||
if system_text:
|
||||
result["system"] = system_text
|
||||
if system_value:
|
||||
result["system"] = system_value
|
||||
|
||||
if internal.temperature is not None:
|
||||
result["temperature"] = internal.temperature
|
||||
@@ -191,18 +243,75 @@ class ClaudeNormalizer(FormatNormalizer):
|
||||
result["stream"] = True
|
||||
|
||||
if internal.tools:
|
||||
result["tools"] = [
|
||||
{
|
||||
claude_tools: list[dict[str, Any]] = []
|
||||
for t in internal.tools:
|
||||
# 跳过 Gemini 内置工具(在 Claude 中无对应物)
|
||||
if t.extra.get("gemini_builtin_tool"):
|
||||
continue
|
||||
tool_def: dict[str, Any] = {
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"input_schema": t.parameters or {},
|
||||
**(t.extra.get("claude") or {}),
|
||||
}
|
||||
for t in internal.tools
|
||||
]
|
||||
if t.description is not None:
|
||||
tool_def["description"] = t.description
|
||||
claude_tools.append(tool_def)
|
||||
if claude_tools:
|
||||
result["tools"] = claude_tools
|
||||
|
||||
if internal.tool_choice:
|
||||
result["tool_choice"] = self._tool_choice_to_claude(internal.tool_choice)
|
||||
tc = self._tool_choice_to_claude(internal.tool_choice)
|
||||
# parallel_tool_calls=False -> disable_parallel_tool_use=True
|
||||
if internal.parallel_tool_calls is False and tc.get("type") != "none":
|
||||
tc["disable_parallel_tool_use"] = True
|
||||
result["tool_choice"] = tc
|
||||
|
||||
# thinking 配置
|
||||
if internal.thinking and internal.thinking.enabled:
|
||||
# 优先使用原始 Claude thinking 配置(round-trip 透传)
|
||||
claude_thinking = internal.thinking.extra.get("claude_thinking")
|
||||
if isinstance(claude_thinking, dict):
|
||||
result["thinking"] = claude_thinking
|
||||
else:
|
||||
thinking_out: dict[str, Any] = {"type": "enabled"}
|
||||
# Claude API 要求 budget_tokens 必须提供
|
||||
# 跨格式时 internal.model 可能是非 Claude 模型名,
|
||||
# 仅当模型名以 claude- 开头时用模型感知默认值,否则用固定安全值
|
||||
if internal.thinking.budget_tokens is not None:
|
||||
thinking_out["budget_tokens"] = internal.thinking.budget_tokens
|
||||
else:
|
||||
# 基于 effective_max_tokens 计算兜底 budget,避免覆盖用户显式指定的 max_tokens
|
||||
thinking_out["budget_tokens"] = max(
|
||||
int(effective_max_tokens * THINKING_BUDGET_TOKENS_PERCENTAGE),
|
||||
THINKING_BUDGET_TOKENS_MIN,
|
||||
)
|
||||
# 确保 budget_tokens >= 最小值(参考 new-api: 1280)
|
||||
bt = thinking_out["budget_tokens"]
|
||||
if bt < THINKING_BUDGET_TOKENS_MIN:
|
||||
thinking_out["budget_tokens"] = THINKING_BUDGET_TOKENS_MIN
|
||||
bt = THINKING_BUDGET_TOKENS_MIN
|
||||
# Claude API 要求 budget_tokens < max_tokens,确保不违反约束
|
||||
max_t = result.get("max_tokens")
|
||||
if max_t is not None and bt >= max_t:
|
||||
result["max_tokens"] = bt + 1
|
||||
result["thinking"] = thinking_out
|
||||
|
||||
# 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):
|
||||
context_size = str(web_search_opts.get("search_context_size") or "medium")
|
||||
max_uses = WEB_SEARCH_CONTEXT_SIZE_TO_MAX_USES.get(context_size, 5)
|
||||
ws_tool: dict[str, Any] = {
|
||||
"type": "web_search_20250305",
|
||||
"name": "web_search",
|
||||
"max_uses": max_uses,
|
||||
}
|
||||
user_location = web_search_opts.get("user_location")
|
||||
if isinstance(user_location, dict):
|
||||
ws_tool["user_location"] = user_location
|
||||
if "tools" not in result:
|
||||
result["tools"] = []
|
||||
result["tools"].append(ws_tool)
|
||||
|
||||
# 恢复 Claude 特有字段(如 metadata)
|
||||
claude_extra = internal.extra.get("claude") if isinstance(internal.extra, dict) else None
|
||||
@@ -296,7 +405,45 @@ class ClaudeNormalizer(FormatNormalizer):
|
||||
}
|
||||
)
|
||||
elif b.url:
|
||||
content.append({"type": "text", "text": f"[Image: {b.url}]"})
|
||||
content.append(
|
||||
{
|
||||
"type": "image",
|
||||
"source": {"type": "url", "url": b.url},
|
||||
}
|
||||
)
|
||||
continue
|
||||
if isinstance(b, FileBlock):
|
||||
if b.data and b.media_type:
|
||||
content.append(
|
||||
{
|
||||
"type": "document",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": b.media_type,
|
||||
"data": b.data,
|
||||
},
|
||||
}
|
||||
)
|
||||
elif b.file_url:
|
||||
content.append(
|
||||
{
|
||||
"type": "document",
|
||||
"source": {"type": "url", "url": b.file_url},
|
||||
}
|
||||
)
|
||||
continue
|
||||
if isinstance(b, AudioBlock):
|
||||
if b.data and b.media_type:
|
||||
content.append(
|
||||
{
|
||||
"type": "document",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": b.media_type,
|
||||
"data": b.data,
|
||||
},
|
||||
}
|
||||
)
|
||||
continue
|
||||
# Unknown/ToolResult 默认丢弃
|
||||
|
||||
@@ -740,9 +887,12 @@ class ClaudeNormalizer(FormatNormalizer):
|
||||
if btype == "text":
|
||||
text = str(block.get("text") or "")
|
||||
if text:
|
||||
blocks.append(
|
||||
TextBlock(text=text, extra=self._extract_extra(block, {"type", "text"}))
|
||||
)
|
||||
block_extra = self._extract_extra(block, {"type", "text"})
|
||||
# cache_control 透传
|
||||
cc = block.get("cache_control")
|
||||
if isinstance(cc, dict):
|
||||
block_extra["cache_control"] = cc
|
||||
blocks.append(TextBlock(text=text, extra=block_extra))
|
||||
continue
|
||||
|
||||
if btype == "image":
|
||||
@@ -760,10 +910,42 @@ class ClaudeNormalizer(FormatNormalizer):
|
||||
):
|
||||
blocks.append(ImageBlock(data=data, media_type=media_type))
|
||||
continue
|
||||
elif stype == "url":
|
||||
url = src.get("url")
|
||||
if isinstance(url, str) and url:
|
||||
blocks.append(ImageBlock(url=url))
|
||||
continue
|
||||
dropped["claude_image_unsupported"] = dropped.get("claude_image_unsupported", 0) + 1
|
||||
blocks.append(UnknownBlock(raw_type="image", payload=block))
|
||||
continue
|
||||
|
||||
if btype == "document":
|
||||
doc_src = block.get("source") or {}
|
||||
if isinstance(doc_src, dict):
|
||||
doc_stype = doc_src.get("type")
|
||||
if doc_stype == "base64":
|
||||
blocks.append(
|
||||
FileBlock(
|
||||
data=doc_src.get("data"),
|
||||
media_type=doc_src.get("media_type"),
|
||||
extra=self._extract_extra(block, {"type", "source"}),
|
||||
)
|
||||
)
|
||||
continue
|
||||
elif doc_stype == "url":
|
||||
blocks.append(
|
||||
FileBlock(
|
||||
file_url=doc_src.get("url"),
|
||||
media_type=doc_src.get("media_type"),
|
||||
extra=self._extract_extra(block, {"type", "source"}),
|
||||
)
|
||||
)
|
||||
continue
|
||||
dropped_key = f"claude_block:{btype}"
|
||||
dropped[dropped_key] = dropped.get(dropped_key, 0) + 1
|
||||
blocks.append(UnknownBlock(raw_type=btype, payload=block))
|
||||
continue
|
||||
|
||||
if btype == "tool_use":
|
||||
tool_id = str(block.get("id") or "")
|
||||
tool_name = str(block.get("name") or "")
|
||||
@@ -862,15 +1044,21 @@ class ClaudeNormalizer(FormatNormalizer):
|
||||
extra={"claude": raw_block},
|
||||
)
|
||||
|
||||
def _collapse_claude_system(self, system_value: Any) -> tuple[str | None, dict[str, int]]:
|
||||
def _collapse_claude_system(
|
||||
self, system_value: Any
|
||||
) -> tuple[str | None, dict[str, int], list[InstructionSegment] | None]:
|
||||
"""解析 Claude system 字段。返回 (text, dropped, segments)。
|
||||
segments 仅在 system 为数组且含 cache_control 时非空。"""
|
||||
dropped: dict[str, int] = {}
|
||||
if system_value is None:
|
||||
return None, dropped
|
||||
return None, dropped, None
|
||||
if isinstance(system_value, str):
|
||||
return (system_value or None), dropped
|
||||
return (system_value or None), dropped, None
|
||||
|
||||
if isinstance(system_value, list):
|
||||
texts: list[str] = []
|
||||
segments: list[InstructionSegment] = []
|
||||
has_cache_control = False
|
||||
for item in system_value:
|
||||
if not isinstance(item, dict):
|
||||
dropped["claude_system_item_non_dict"] = (
|
||||
@@ -881,14 +1069,26 @@ class ClaudeNormalizer(FormatNormalizer):
|
||||
text = item.get("text")
|
||||
if text:
|
||||
texts.append(str(text))
|
||||
seg_extra: dict[str, Any] = {}
|
||||
cc = item.get("cache_control")
|
||||
if isinstance(cc, dict):
|
||||
seg_extra["cache_control"] = cc
|
||||
has_cache_control = True
|
||||
segments.append(
|
||||
InstructionSegment(role=Role.SYSTEM, text=str(text), extra=seg_extra)
|
||||
)
|
||||
else:
|
||||
dropped_key = f"claude_system_item:{item.get('type')}"
|
||||
dropped[dropped_key] = dropped.get(dropped_key, 0) + 1
|
||||
joined = "\n\n".join(texts)
|
||||
return (joined or None), dropped
|
||||
return (
|
||||
(joined or None),
|
||||
dropped,
|
||||
segments if has_cache_control else None,
|
||||
)
|
||||
|
||||
dropped["claude_system_unsupported"] = dropped.get("claude_system_unsupported", 0) + 1
|
||||
return None, dropped
|
||||
return None, dropped, None
|
||||
|
||||
def _join_instructions(self, instructions: list[InstructionSegment]) -> str | None:
|
||||
parts = [seg.text for seg in instructions if seg.text]
|
||||
@@ -957,6 +1157,11 @@ class ClaudeNormalizer(FormatNormalizer):
|
||||
def _internal_message_to_claude(self, msg: InternalMessage) -> dict[str, Any]:
|
||||
role = "user" if msg.role == Role.USER else "assistant"
|
||||
|
||||
# 预扫描:如果任一 TextBlock 带 cache_control,所有 TextBlock 都走结构化路径以保持顺序
|
||||
force_structured_text = any(
|
||||
isinstance(b, TextBlock) and b.extra.get("cache_control") for b in msg.content
|
||||
)
|
||||
|
||||
blocks: list[dict[str, Any]] = []
|
||||
text_parts: list[str] = []
|
||||
|
||||
@@ -966,7 +1171,14 @@ class ClaudeNormalizer(FormatNormalizer):
|
||||
|
||||
if isinstance(b, TextBlock):
|
||||
if b.text:
|
||||
text_parts.append(b.text)
|
||||
if force_structured_text:
|
||||
text_block: dict[str, Any] = {"type": "text", "text": b.text}
|
||||
cc = b.extra.get("cache_control") if b.extra else None
|
||||
if isinstance(cc, dict):
|
||||
text_block["cache_control"] = cc
|
||||
blocks.append(text_block)
|
||||
else:
|
||||
text_parts.append(b.text)
|
||||
continue
|
||||
|
||||
if isinstance(b, ImageBlock):
|
||||
@@ -989,7 +1201,52 @@ class ClaudeNormalizer(FormatNormalizer):
|
||||
}
|
||||
)
|
||||
elif b.url:
|
||||
text_parts.append(f"[Image: {b.url}]")
|
||||
blocks.append(
|
||||
{
|
||||
"type": "image",
|
||||
"source": {"type": "url", "url": b.url},
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if isinstance(b, FileBlock):
|
||||
if b.data and b.media_type:
|
||||
blocks.append(
|
||||
{
|
||||
"type": "document",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": b.media_type,
|
||||
"data": b.data,
|
||||
},
|
||||
}
|
||||
)
|
||||
elif b.file_url:
|
||||
blocks.append(
|
||||
{
|
||||
"type": "document",
|
||||
"source": {
|
||||
"type": "url",
|
||||
"url": b.file_url,
|
||||
},
|
||||
}
|
||||
)
|
||||
elif b.file_id:
|
||||
text_parts.append(f"[File: {b.file_id}]")
|
||||
continue
|
||||
|
||||
if isinstance(b, AudioBlock):
|
||||
if b.data and b.media_type:
|
||||
blocks.append(
|
||||
{
|
||||
"type": "document",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": b.media_type,
|
||||
"data": b.data,
|
||||
},
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if isinstance(b, ToolUseBlock) and role == "assistant":
|
||||
@@ -1062,7 +1319,11 @@ class ClaudeNormalizer(FormatNormalizer):
|
||||
|
||||
mapping = USAGE_FIELD_MAPPINGS.get("CLAUDE", {})
|
||||
fields: dict[str, int] = {}
|
||||
extra = self._extract_extra(usage, set(mapping.keys()))
|
||||
extra = self._extract_extra(
|
||||
usage,
|
||||
set(mapping.keys())
|
||||
| {"cache_creation_input_tokens_5m", "cache_creation_input_tokens_1h"},
|
||||
)
|
||||
|
||||
for provider_key, internal_key in mapping.items():
|
||||
if provider_key in usage and usage.get(provider_key) is not None:
|
||||
@@ -1076,6 +1337,21 @@ class ClaudeNormalizer(FormatNormalizer):
|
||||
fields.get("input_tokens", 0) + fields.get("output_tokens", 0)
|
||||
)
|
||||
|
||||
# Claude cache_creation 5m/1h 细分(存入 extra)
|
||||
cache_details: dict[str, int] = {}
|
||||
for detail_key in (
|
||||
"cache_creation_input_tokens_5m",
|
||||
"cache_creation_input_tokens_1h",
|
||||
):
|
||||
val = usage.get(detail_key)
|
||||
if val is not None:
|
||||
try:
|
||||
cache_details[detail_key] = int(val)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if cache_details:
|
||||
extra["cache_creation_details"] = cache_details
|
||||
|
||||
return UsageInfo(
|
||||
input_tokens=int(fields.get("input_tokens", 0)),
|
||||
output_tokens=int(fields.get("output_tokens", 0)),
|
||||
@@ -1094,6 +1370,16 @@ class ClaudeNormalizer(FormatNormalizer):
|
||||
result["cache_read_input_tokens"] = int(usage.cache_read_tokens)
|
||||
if usage.cache_write_tokens:
|
||||
result["cache_creation_input_tokens"] = int(usage.cache_write_tokens)
|
||||
# 回写 5m/1h 细分
|
||||
claude_extra = usage.extra.get("claude", {})
|
||||
if isinstance(claude_extra, dict):
|
||||
cache_details = claude_extra.get("cache_creation_details")
|
||||
if isinstance(cache_details, dict):
|
||||
for k, v in cache_details.items():
|
||||
try:
|
||||
result[k] = int(v)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return result
|
||||
|
||||
def _error_type_from_value(self, value: str) -> ErrorType:
|
||||
@@ -1102,37 +1388,5 @@ class ClaudeNormalizer(FormatNormalizer):
|
||||
except ValueError:
|
||||
return ErrorType.UNKNOWN
|
||||
|
||||
def _optional_int(self, value: Any) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _optional_float(self, value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _coerce_str_list(self, value: Any) -> list[str] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
if isinstance(value, list):
|
||||
return [str(x) for x in value if x is not None]
|
||||
return None
|
||||
|
||||
def _extract_extra(self, payload: dict[str, Any], known_keys: set[str]) -> dict[str, Any]:
|
||||
return {k: v for k, v in payload.items() if k not in known_keys}
|
||||
|
||||
def _merge_dropped(self, target: dict[str, int], source: dict[str, int]) -> None:
|
||||
for k, v in source.items():
|
||||
target[k] = target.get(k, 0) + int(v)
|
||||
|
||||
|
||||
__all__ = ["ClaudeNormalizer"]
|
||||
|
||||
@@ -12,6 +12,7 @@ Gemini (GenerateContent / streamGenerateContent) Normalizer
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from src.core.api_format.conversion.field_mappings import (
|
||||
@@ -21,9 +22,11 @@ from src.core.api_format.conversion.field_mappings import (
|
||||
USAGE_FIELD_MAPPINGS,
|
||||
)
|
||||
from src.core.api_format.conversion.internal import (
|
||||
AudioBlock,
|
||||
ContentBlock,
|
||||
ContentType,
|
||||
ErrorType,
|
||||
FileBlock,
|
||||
FormatCapabilities,
|
||||
ImageBlock,
|
||||
InstructionSegment,
|
||||
@@ -31,10 +34,12 @@ from src.core.api_format.conversion.internal import (
|
||||
InternalMessage,
|
||||
InternalRequest,
|
||||
InternalResponse,
|
||||
ResponseFormatConfig,
|
||||
Role,
|
||||
StopReason,
|
||||
TextBlock,
|
||||
ThinkingBlock,
|
||||
ThinkingConfig,
|
||||
ToolChoice,
|
||||
ToolChoiceType,
|
||||
ToolDefinition,
|
||||
@@ -131,6 +136,60 @@ def compact_gemini_contents(contents: list[dict[str, Any]]) -> list[dict[str, An
|
||||
return merged
|
||||
|
||||
|
||||
# Gemini 内置工具名称映射(输入侧:camelCase/snake_case key -> 标准化 ToolDefinition name)
|
||||
_BUILTIN_TOOL_KEYS: dict[str, str] = {
|
||||
"googleSearch": "googleSearch",
|
||||
"google_search": "googleSearch",
|
||||
"codeExecution": "codeExecution",
|
||||
"code_execution": "codeExecution",
|
||||
"urlContext": "urlContext",
|
||||
"url_context": "urlContext",
|
||||
}
|
||||
|
||||
# Gemini 内置工具名称集合(输出侧:ToolDefinition.name -> 特殊处理)
|
||||
_GEMINI_BUILTIN_TOOLS: frozenset[str] = frozenset(
|
||||
{
|
||||
"google_search",
|
||||
"googleSearch",
|
||||
"code_execution",
|
||||
"codeExecution",
|
||||
"url_context",
|
||||
"urlContext",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Markdown 内嵌 base64 图片正则:
|
||||
# 使用 [^)]+ 贪婪匹配至右括号,避免字符类量词的回溯风险
|
||||
_MD_IMAGE_RE = re.compile(r"!\[[^\]]*\]\(data:(image/[a-zA-Z0-9.+-]+);base64,([^)]+)\)")
|
||||
|
||||
|
||||
def _extract_markdown_images(
|
||||
text: str,
|
||||
) -> tuple[str, list[dict[str, Any]]]:
|
||||
"""从文本中提取 Markdown 内嵌 base64 图片,返回 (剩余文本, inlineData parts)。
|
||||
|
||||
如果没有匹配到任何内嵌图片,返回 (原文本, [])。
|
||||
"""
|
||||
# 快速预检:避免对纯文本执行正则
|
||||
if "data:image/" not in text:
|
||||
return text, []
|
||||
|
||||
image_parts: list[dict[str, Any]] = []
|
||||
|
||||
def _collect(m: re.Match[str]) -> str:
|
||||
mime_type = m.group(1)
|
||||
b64_data = m.group(2).replace("\n", "").replace(" ", "")
|
||||
image_parts.append({"inline_data": {"mime_type": mime_type, "data": b64_data}})
|
||||
return ""
|
||||
|
||||
remaining = _MD_IMAGE_RE.sub(_collect, text)
|
||||
if not image_parts:
|
||||
return text, []
|
||||
|
||||
return remaining, image_parts
|
||||
|
||||
|
||||
class GeminiNormalizer(FormatNormalizer):
|
||||
FORMAT_ID = "gemini:chat"
|
||||
capabilities = FormatCapabilities(
|
||||
@@ -226,9 +285,28 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
|
||||
# 保留 generationConfig 中的特殊字段(responseModalities, thinkingConfig 等)
|
||||
# 这些字段在 _get_generation_config 中已提取,需要单独存储以便转换时使用
|
||||
thinking: ThinkingConfig | None = None
|
||||
n: int | None = None
|
||||
response_format: ResponseFormatConfig | None = None
|
||||
|
||||
if isinstance(generation_config, dict):
|
||||
response_modalities = generation_config.get("response_modalities")
|
||||
thinking_config = generation_config.get("thinking_config")
|
||||
|
||||
# 解析 thinkingConfig -> ThinkingConfig
|
||||
if isinstance(thinking_config, dict):
|
||||
include_thoughts = thinking_config.get(
|
||||
"include_thoughts", thinking_config.get("includeThoughts")
|
||||
)
|
||||
thinking_budget = thinking_config.get(
|
||||
"thinking_budget", thinking_config.get("thinkingBudget")
|
||||
)
|
||||
thinking = ThinkingConfig(
|
||||
enabled=bool(include_thoughts) if include_thoughts is not None else True,
|
||||
budget_tokens=self._optional_int(thinking_budget),
|
||||
extra={"gemini_thinking_config": thinking_config},
|
||||
)
|
||||
|
||||
if response_modalities or thinking_config:
|
||||
google_extra: dict[str, Any] = {}
|
||||
if response_modalities:
|
||||
@@ -237,6 +315,32 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
google_extra["thinking_config"] = thinking_config
|
||||
extra["google"] = google_extra
|
||||
|
||||
# 解析 candidateCount -> n
|
||||
n = self._optional_int(
|
||||
generation_config.get("candidate_count", generation_config.get("candidateCount"))
|
||||
)
|
||||
|
||||
# 解析 response_format (responseMimeType / responseSchema)
|
||||
resp_mime = generation_config.get(
|
||||
"response_mime_type", generation_config.get("responseMimeType")
|
||||
)
|
||||
resp_schema = generation_config.get(
|
||||
"response_schema", generation_config.get("responseSchema")
|
||||
)
|
||||
if resp_mime or resp_schema:
|
||||
if resp_schema and isinstance(resp_schema, dict):
|
||||
response_format = ResponseFormatConfig(
|
||||
type="json_schema",
|
||||
json_schema=resp_schema,
|
||||
extra={"response_mime_type": resp_mime} if resp_mime else {},
|
||||
)
|
||||
elif resp_mime and "json" in str(resp_mime).lower():
|
||||
response_format = ResponseFormatConfig(type="json_object")
|
||||
elif resp_mime:
|
||||
response_format = ResponseFormatConfig(
|
||||
type="text", extra={"response_mime_type": resp_mime}
|
||||
)
|
||||
|
||||
internal = InternalRequest(
|
||||
model=model,
|
||||
messages=messages,
|
||||
@@ -250,6 +354,9 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
stream=bool(request.get("stream") or False),
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
thinking=thinking,
|
||||
n=n,
|
||||
response_format=response_format,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
@@ -276,21 +383,36 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
)
|
||||
|
||||
# tools/tool_choice — clean unsupported JSON Schema fields from parameters
|
||||
# Gemini 特殊内置工具名称需要单独处理
|
||||
tools = None
|
||||
if internal.tools:
|
||||
func_decls: list[dict[str, Any]] = []
|
||||
builtin_tools: list[dict[str, Any]] = []
|
||||
for t in internal.tools:
|
||||
# 检测 Gemini 内置工具
|
||||
if t.name in _GEMINI_BUILTIN_TOOLS:
|
||||
canonical = _BUILTIN_TOOL_KEYS.get(t.name, t.name)
|
||||
builtin_tools.append({canonical: {}})
|
||||
continue
|
||||
|
||||
params = dict(t.parameters) if t.parameters else {}
|
||||
if params:
|
||||
_clean_gemini_schema(params)
|
||||
decl: dict[str, Any] = {
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"parameters": params,
|
||||
**(t.extra.get("gemini_function_declaration") or {}),
|
||||
}
|
||||
if t.description is not None:
|
||||
decl["description"] = t.description
|
||||
func_decls.append(decl)
|
||||
tools = [{"function_declarations": func_decls}]
|
||||
if func_decls:
|
||||
tools = [{"function_declarations": func_decls}]
|
||||
else:
|
||||
tools = []
|
||||
tools.extend(builtin_tools)
|
||||
if not tools:
|
||||
tools = None
|
||||
|
||||
tool_config = None
|
||||
if internal.tool_choice:
|
||||
@@ -308,12 +430,40 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
if internal.stop_sequences:
|
||||
generation_config["stop_sequences"] = list(internal.stop_sequences)
|
||||
|
||||
# 从 internal.thinking 输出 thinkingConfig(跨格式转换的标准路径)
|
||||
if (
|
||||
internal.thinking
|
||||
and internal.thinking.enabled
|
||||
and "thinkingConfig" not in generation_config
|
||||
):
|
||||
gemini_tc: dict[str, Any] = {"includeThoughts": True}
|
||||
if internal.thinking.budget_tokens is not None:
|
||||
gemini_tc["thinkingBudget"] = internal.thinking.budget_tokens
|
||||
generation_config["thinkingConfig"] = gemini_tc
|
||||
|
||||
# 从 internal.response_format 输出 responseMimeType / responseSchema
|
||||
if internal.response_format and "responseMimeType" not in generation_config:
|
||||
if (
|
||||
internal.response_format.type == "json_schema"
|
||||
and internal.response_format.json_schema
|
||||
):
|
||||
generation_config["responseMimeType"] = "application/json"
|
||||
schema = dict(internal.response_format.json_schema)
|
||||
_clean_gemini_schema(schema)
|
||||
generation_config["responseSchema"] = schema
|
||||
elif internal.response_format.type == "json_object":
|
||||
generation_config["responseMimeType"] = "application/json"
|
||||
|
||||
# 从 internal.n 输出 candidateCount
|
||||
if internal.n is not None and internal.n > 1 and "candidateCount" not in generation_config:
|
||||
generation_config["candidateCount"] = internal.n
|
||||
|
||||
# 从 internal.extra["google"] 读取 OpenAI extra_body.google 透传的配置
|
||||
google_extra = internal.extra.get("google", {})
|
||||
if isinstance(google_extra, dict):
|
||||
# 处理 thinking_config -> thinkingConfig
|
||||
# 处理 thinking_config -> thinkingConfig(仅当上面标准路径未设置时)
|
||||
thinking_config = google_extra.get("thinking_config")
|
||||
if isinstance(thinking_config, dict):
|
||||
if isinstance(thinking_config, dict) and "thinkingConfig" not in generation_config:
|
||||
# snake_case -> camelCase 转换
|
||||
gemini_thinking: dict[str, Any] = {}
|
||||
if "thinking_budget" in thinking_config:
|
||||
@@ -472,6 +622,13 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
) -> dict[str, Any]:
|
||||
parts: list[dict[str, Any]] = []
|
||||
for b in internal.content:
|
||||
if isinstance(b, ThinkingBlock):
|
||||
if b.thinking:
|
||||
thought_part: dict[str, Any] = {"text": b.thinking, "thought": True}
|
||||
if b.signature:
|
||||
thought_part["thoughtSignature"] = b.signature
|
||||
parts.append(thought_part)
|
||||
continue
|
||||
if isinstance(b, TextBlock):
|
||||
if b.text:
|
||||
parts.append({"text": b.text})
|
||||
@@ -497,7 +654,28 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
}
|
||||
)
|
||||
elif b.url:
|
||||
parts.append({"text": f"[Image: {b.url}]"})
|
||||
parts.append(
|
||||
{"fileData": {"fileUri": b.url, "mimeType": b.media_type or "image/jpeg"}}
|
||||
)
|
||||
continue
|
||||
if isinstance(b, FileBlock):
|
||||
if b.data and b.media_type:
|
||||
parts.append({"inlineData": {"mimeType": b.media_type, "data": b.data}})
|
||||
elif b.file_url:
|
||||
parts.append(
|
||||
{
|
||||
"fileData": {
|
||||
"fileUri": b.file_url,
|
||||
"mimeType": b.media_type or "application/octet-stream",
|
||||
}
|
||||
}
|
||||
)
|
||||
continue
|
||||
if isinstance(b, AudioBlock):
|
||||
if b.data and b.media_type:
|
||||
parts.append({"inlineData": {"mimeType": b.media_type, "data": b.data}})
|
||||
elif b.data and b.format:
|
||||
parts.append({"inlineData": {"mimeType": f"audio/{b.format}", "data": b.data}})
|
||||
continue
|
||||
# Unknown/ToolResult 默认丢弃
|
||||
|
||||
@@ -1319,7 +1497,15 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
)
|
||||
data = inline.get("data")
|
||||
if isinstance(mime_type, str) and mime_type and isinstance(data, str) and data:
|
||||
blocks.append(ImageBlock(data=data, media_type=mime_type))
|
||||
if mime_type.startswith("audio/"):
|
||||
# 音频 -> AudioBlock
|
||||
fmt = mime_type.split("/", 1)[1] if "/" in mime_type else None
|
||||
blocks.append(AudioBlock(data=data, media_type=mime_type, format=fmt))
|
||||
elif mime_type.startswith("image/"):
|
||||
blocks.append(ImageBlock(data=data, media_type=mime_type))
|
||||
else:
|
||||
# 其他类型 (PDF 等) -> FileBlock
|
||||
blocks.append(FileBlock(data=data, media_type=mime_type))
|
||||
else:
|
||||
dropped["gemini_inline_data_invalid"] = (
|
||||
dropped.get("gemini_inline_data_invalid", 0) + 1
|
||||
@@ -1327,6 +1513,27 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
blocks.append(UnknownBlock(raw_type="inline_data", payload=part))
|
||||
continue
|
||||
|
||||
# fileData / file_data
|
||||
file_data = part.get("fileData")
|
||||
if file_data is None:
|
||||
file_data = part.get("file_data")
|
||||
if isinstance(file_data, dict):
|
||||
file_uri = file_data.get("fileUri") or file_data.get("file_uri") or ""
|
||||
file_mime = file_data.get("mimeType") or file_data.get("mime_type") or ""
|
||||
if isinstance(file_mime, str) and file_mime.startswith("image/"):
|
||||
# 图片 MIME -> ImageBlock
|
||||
blocks.append(ImageBlock(url=str(file_uri), media_type=file_mime))
|
||||
else:
|
||||
# 非图片 -> FileBlock
|
||||
blocks.append(
|
||||
FileBlock(
|
||||
file_url=str(file_uri),
|
||||
media_type=str(file_mime) if file_mime else None,
|
||||
extra={"gemini": part},
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
func_call = part.get("function_call")
|
||||
if func_call is None:
|
||||
func_call = part.get("functionCall")
|
||||
@@ -1457,14 +1664,47 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
|
||||
if isinstance(b, TextBlock):
|
||||
if b.text:
|
||||
parts.append({"text": b.text})
|
||||
# 检测 Markdown 内嵌 base64 图片并提取为独立 inlineData part
|
||||
remaining_text, image_parts = _extract_markdown_images(b.text)
|
||||
if image_parts:
|
||||
if remaining_text.strip():
|
||||
parts.append({"text": remaining_text})
|
||||
parts.extend(image_parts)
|
||||
else:
|
||||
parts.append({"text": b.text})
|
||||
continue
|
||||
|
||||
if isinstance(b, ImageBlock):
|
||||
if b.data and b.media_type:
|
||||
parts.append({"inline_data": {"mime_type": b.media_type, "data": b.data}})
|
||||
elif b.url:
|
||||
parts.append({"text": f"[Image: {b.url}]"})
|
||||
# Gemini 支持 fileData URI 引用
|
||||
parts.append(
|
||||
{"fileData": {"fileUri": b.url, "mimeType": b.media_type or "image/jpeg"}}
|
||||
)
|
||||
continue
|
||||
|
||||
if isinstance(b, FileBlock):
|
||||
if b.data and b.media_type:
|
||||
parts.append({"inline_data": {"mime_type": b.media_type, "data": b.data}})
|
||||
elif b.file_url:
|
||||
parts.append(
|
||||
{
|
||||
"fileData": {
|
||||
"fileUri": b.file_url,
|
||||
"mimeType": b.media_type or "application/octet-stream",
|
||||
}
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if isinstance(b, AudioBlock):
|
||||
if b.data and b.media_type:
|
||||
parts.append({"inline_data": {"mime_type": b.media_type, "data": b.data}})
|
||||
elif b.data and b.format:
|
||||
parts.append(
|
||||
{"inline_data": {"mime_type": f"audio/{b.format}", "data": b.data}}
|
||||
)
|
||||
continue
|
||||
|
||||
if isinstance(b, ToolUseBlock) and role == "model":
|
||||
@@ -1646,6 +1886,19 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
if thinking_config:
|
||||
normalized["thinking_config"] = thinking_config
|
||||
|
||||
# 保留 candidateCount
|
||||
candidate_count = pick("candidate_count", "candidateCount")
|
||||
if candidate_count is not None:
|
||||
normalized["candidate_count"] = candidate_count
|
||||
|
||||
# 保留 responseMimeType / responseSchema
|
||||
resp_mime = pick("response_mime_type", "responseMimeType")
|
||||
if resp_mime is not None:
|
||||
normalized["response_mime_type"] = resp_mime
|
||||
resp_schema = pick("response_schema", "responseSchema")
|
||||
if resp_schema is not None:
|
||||
normalized["response_schema"] = resp_schema
|
||||
|
||||
return {k: v for k, v in normalized.items() if v is not None}
|
||||
|
||||
def _gemini_tools_to_internal(self, tools: Any) -> list[ToolDefinition] | None:
|
||||
@@ -1657,35 +1910,49 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
if not isinstance(tool, dict):
|
||||
continue
|
||||
|
||||
decls = tool.get("function_declarations")
|
||||
if decls is None:
|
||||
decls = tool.get("functionDeclarations")
|
||||
|
||||
if not isinstance(decls, list):
|
||||
continue
|
||||
|
||||
for decl in decls:
|
||||
if not isinstance(decl, dict):
|
||||
continue
|
||||
name = str(decl.get("name") or "")
|
||||
if not name:
|
||||
continue
|
||||
out.append(
|
||||
ToolDefinition(
|
||||
name=name,
|
||||
description=decl.get("description"),
|
||||
parameters=(
|
||||
decl.get("parameters")
|
||||
if isinstance(decl.get("parameters"), dict)
|
||||
else None
|
||||
),
|
||||
extra={
|
||||
"gemini_function_declaration": self._extract_extra(
|
||||
decl, {"name", "description", "parameters"}
|
||||
)
|
||||
},
|
||||
# 检测 Gemini 内置工具(如 {"googleSearch": {}}, {"codeExecution": {}})
|
||||
for key, canonical_name in _BUILTIN_TOOL_KEYS.items():
|
||||
if key in tool:
|
||||
out.append(
|
||||
ToolDefinition(
|
||||
name=canonical_name,
|
||||
description=None,
|
||||
parameters=None,
|
||||
extra={"gemini_builtin_tool": True},
|
||||
)
|
||||
)
|
||||
break
|
||||
else:
|
||||
# 非内置工具:解析 functionDeclarations
|
||||
decls = tool.get("function_declarations")
|
||||
if decls is None:
|
||||
decls = tool.get("functionDeclarations")
|
||||
|
||||
if not isinstance(decls, list):
|
||||
continue
|
||||
|
||||
for decl in decls:
|
||||
if not isinstance(decl, dict):
|
||||
continue
|
||||
name = str(decl.get("name") or "")
|
||||
if not name:
|
||||
continue
|
||||
out.append(
|
||||
ToolDefinition(
|
||||
name=name,
|
||||
description=decl.get("description"),
|
||||
parameters=(
|
||||
decl.get("parameters")
|
||||
if isinstance(decl.get("parameters"), dict)
|
||||
else None
|
||||
),
|
||||
extra={
|
||||
"gemini_function_declaration": self._extract_extra(
|
||||
decl, {"name", "description", "parameters"}
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return out or None
|
||||
|
||||
@@ -1784,37 +2051,5 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
except ValueError:
|
||||
return ErrorType.UNKNOWN
|
||||
|
||||
def _optional_int(self, value: Any) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _optional_float(self, value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _coerce_str_list(self, value: Any) -> list[str] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
if isinstance(value, list):
|
||||
return [str(x) for x in value if x is not None]
|
||||
return None
|
||||
|
||||
def _extract_extra(self, payload: dict[str, Any], known_keys: set[str]) -> dict[str, Any]:
|
||||
return {k: v for k, v in payload.items() if k not in known_keys}
|
||||
|
||||
def _merge_dropped(self, target: dict[str, int], source: dict[str, int]) -> None:
|
||||
for k, v in source.items():
|
||||
target[k] = target.get(k, 0) + int(v)
|
||||
|
||||
|
||||
__all__ = ["GeminiNormalizer"]
|
||||
|
||||
@@ -14,12 +14,16 @@ from typing import Any
|
||||
|
||||
from src.core.api_format.conversion.field_mappings import (
|
||||
ERROR_TYPE_MAPPINGS,
|
||||
REASONING_EFFORT_TO_THINKING_BUDGET,
|
||||
RETRYABLE_ERROR_TYPES,
|
||||
THINKING_BUDGET_TO_REASONING_EFFORT,
|
||||
)
|
||||
from src.core.api_format.conversion.internal import (
|
||||
AudioBlock,
|
||||
ContentBlock,
|
||||
ContentType,
|
||||
ErrorType,
|
||||
FileBlock,
|
||||
FormatCapabilities,
|
||||
ImageBlock,
|
||||
InstructionSegment,
|
||||
@@ -27,10 +31,12 @@ from src.core.api_format.conversion.internal import (
|
||||
InternalMessage,
|
||||
InternalRequest,
|
||||
InternalResponse,
|
||||
ResponseFormatConfig,
|
||||
Role,
|
||||
StopReason,
|
||||
TextBlock,
|
||||
ThinkingBlock,
|
||||
ThinkingConfig,
|
||||
ToolChoice,
|
||||
ToolChoiceType,
|
||||
ToolDefinition,
|
||||
@@ -86,6 +92,8 @@ class OpenAINormalizer(FormatNormalizer):
|
||||
StopReason.STOP_SEQUENCE: "stop",
|
||||
StopReason.TOOL_USE: "tool_calls",
|
||||
StopReason.CONTENT_FILTERED: "content_filter",
|
||||
StopReason.REFUSAL: "content_filter",
|
||||
StopReason.PAUSE_TURN: "stop",
|
||||
StopReason.UNKNOWN: "stop",
|
||||
}
|
||||
|
||||
@@ -181,6 +189,52 @@ class OpenAINormalizer(FormatNormalizer):
|
||||
if isinstance(google_extra, dict) and google_extra:
|
||||
extra["google"] = google_extra
|
||||
|
||||
# reasoning_effort -> ThinkingConfig
|
||||
thinking: ThinkingConfig | None = None
|
||||
reasoning_effort = request.get("reasoning_effort")
|
||||
if (
|
||||
isinstance(reasoning_effort, str)
|
||||
and reasoning_effort in REASONING_EFFORT_TO_THINKING_BUDGET
|
||||
):
|
||||
thinking = ThinkingConfig(
|
||||
enabled=True,
|
||||
budget_tokens=REASONING_EFFORT_TO_THINKING_BUDGET[reasoning_effort],
|
||||
extra={"reasoning_effort": reasoning_effort},
|
||||
)
|
||||
|
||||
# web_search_options 存入 extra 供目标 normalizer 使用
|
||||
web_search_options = request.get("web_search_options")
|
||||
if isinstance(web_search_options, dict):
|
||||
extra["web_search_options"] = web_search_options
|
||||
|
||||
# parallel_tool_calls
|
||||
parallel_tool_calls: bool | None = None
|
||||
ptc = request.get("parallel_tool_calls")
|
||||
if ptc is not None:
|
||||
parallel_tool_calls = bool(ptc)
|
||||
|
||||
# 采样参数
|
||||
n_value = self._optional_int(request.get("n"))
|
||||
presence_penalty = self._optional_float(request.get("presence_penalty"))
|
||||
frequency_penalty = self._optional_float(request.get("frequency_penalty"))
|
||||
seed = self._optional_int(request.get("seed"))
|
||||
logprobs = request.get("logprobs")
|
||||
logprobs_val: bool | None = bool(logprobs) if logprobs is not None else None
|
||||
top_logprobs = self._optional_int(request.get("top_logprobs"))
|
||||
|
||||
# response_format
|
||||
response_format: ResponseFormatConfig | None = None
|
||||
rf = request.get("response_format")
|
||||
if isinstance(rf, dict):
|
||||
rf_type = str(rf.get("type") or "text")
|
||||
rf_schema = rf.get("json_schema") if isinstance(rf.get("json_schema"), dict) else None
|
||||
if rf_type != "text":
|
||||
response_format = ResponseFormatConfig(
|
||||
type=rf_type,
|
||||
json_schema=rf_schema,
|
||||
extra=self._extract_extra(rf, {"type", "json_schema"}),
|
||||
)
|
||||
|
||||
internal = InternalRequest(
|
||||
model=model,
|
||||
messages=messages,
|
||||
@@ -193,6 +247,15 @@ class OpenAINormalizer(FormatNormalizer):
|
||||
stream=bool(request.get("stream") or False),
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
thinking=thinking,
|
||||
parallel_tool_calls=parallel_tool_calls,
|
||||
n=n_value,
|
||||
presence_penalty=presence_penalty,
|
||||
frequency_penalty=frequency_penalty,
|
||||
seed=seed,
|
||||
logprobs=logprobs_val,
|
||||
top_logprobs=top_logprobs,
|
||||
response_format=response_format,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
@@ -240,24 +303,67 @@ class OpenAINormalizer(FormatNormalizer):
|
||||
result["stream_options"] = {"include_usage": True}
|
||||
|
||||
if internal.tools:
|
||||
result["tools"] = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"parameters": t.parameters or {},
|
||||
**(t.extra.get("openai_function") or {}),
|
||||
},
|
||||
**(t.extra.get("openai_tool") or {}),
|
||||
openai_tools: list[dict[str, Any]] = []
|
||||
for t in internal.tools:
|
||||
# 跳过 Gemini 内置工具(在 OpenAI 中无对应物)
|
||||
if t.extra.get("gemini_builtin_tool"):
|
||||
continue
|
||||
func: dict[str, Any] = {
|
||||
"name": t.name,
|
||||
"parameters": t.parameters or {},
|
||||
**(t.extra.get("openai_function") or {}),
|
||||
}
|
||||
for t in internal.tools
|
||||
]
|
||||
if t.description is not None:
|
||||
func["description"] = t.description
|
||||
openai_tools.append(
|
||||
{
|
||||
"type": "function",
|
||||
"function": func,
|
||||
**(t.extra.get("openai_tool") or {}),
|
||||
}
|
||||
)
|
||||
if openai_tools:
|
||||
result["tools"] = openai_tools
|
||||
|
||||
if internal.tool_choice:
|
||||
result["tool_choice"] = self._tool_choice_to_openai(internal.tool_choice)
|
||||
|
||||
# 其余字段:保留在 internal.extra 中,但不默认回写到 OpenAI body(兼容优先)
|
||||
# thinking -> reasoning_effort
|
||||
if internal.thinking and internal.thinking.enabled:
|
||||
effort = internal.thinking.extra.get("reasoning_effort")
|
||||
if not effort and internal.thinking.budget_tokens is not None:
|
||||
for threshold, level in THINKING_BUDGET_TO_REASONING_EFFORT:
|
||||
if internal.thinking.budget_tokens <= threshold:
|
||||
effort = level
|
||||
break
|
||||
if effort:
|
||||
result["reasoning_effort"] = effort
|
||||
|
||||
# parallel_tool_calls
|
||||
if internal.parallel_tool_calls is not None:
|
||||
result["parallel_tool_calls"] = internal.parallel_tool_calls
|
||||
|
||||
# 采样参数
|
||||
if internal.n is not None and internal.n > 1:
|
||||
result["n"] = internal.n
|
||||
for attr, key in (
|
||||
("presence_penalty", "presence_penalty"),
|
||||
("frequency_penalty", "frequency_penalty"),
|
||||
("seed", "seed"),
|
||||
("logprobs", "logprobs"),
|
||||
("top_logprobs", "top_logprobs"),
|
||||
):
|
||||
val = getattr(internal, attr, None)
|
||||
if val is not None:
|
||||
result[key] = val
|
||||
|
||||
# response_format
|
||||
if internal.response_format and internal.response_format.type != "text":
|
||||
rf: dict[str, Any] = {"type": internal.response_format.type}
|
||||
if internal.response_format.json_schema:
|
||||
rf["json_schema"] = internal.response_format.json_schema
|
||||
result["response_format"] = rf
|
||||
|
||||
return result
|
||||
|
||||
# =========================
|
||||
@@ -344,6 +450,8 @@ class OpenAINormalizer(FormatNormalizer):
|
||||
content_value = self._blocks_to_openai_content(content_blocks)
|
||||
if content_value is not None:
|
||||
message["content"] = content_value
|
||||
else:
|
||||
message["content"] = None
|
||||
|
||||
if tool_blocks:
|
||||
message["tool_calls"] = [
|
||||
@@ -366,11 +474,23 @@ class OpenAINormalizer(FormatNormalizer):
|
||||
total = internal.usage.total_tokens or (
|
||||
internal.usage.input_tokens + internal.usage.output_tokens
|
||||
)
|
||||
out["usage"] = {
|
||||
usage_out: dict[str, Any] = {
|
||||
"prompt_tokens": int(internal.usage.input_tokens),
|
||||
"completion_tokens": int(internal.usage.output_tokens),
|
||||
"total_tokens": int(total),
|
||||
}
|
||||
# 回写 prompt_tokens_details(cached_tokens)
|
||||
if internal.usage.cache_read_tokens:
|
||||
usage_out["prompt_tokens_details"] = {
|
||||
"cached_tokens": int(internal.usage.cache_read_tokens),
|
||||
}
|
||||
# 回写 completion_tokens_details
|
||||
openai_extra = internal.usage.extra.get("openai", {})
|
||||
if isinstance(openai_extra, dict):
|
||||
ctd = openai_extra.get("completion_tokens_details")
|
||||
if isinstance(ctd, dict):
|
||||
usage_out["completion_tokens_details"] = ctd
|
||||
out["usage"] = usage_out
|
||||
|
||||
return out
|
||||
|
||||
@@ -1061,6 +1181,45 @@ class OpenAINormalizer(FormatNormalizer):
|
||||
blocks.append(UnknownBlock(raw_type="image_url", payload=part))
|
||||
continue
|
||||
|
||||
if ptype == "file":
|
||||
file_data = part.get("file_data")
|
||||
file_id = part.get("file_id")
|
||||
if isinstance(file_data, dict):
|
||||
blocks.append(
|
||||
FileBlock(
|
||||
data=file_data.get("data"),
|
||||
media_type=file_data.get("mime_type"),
|
||||
filename=file_data.get("filename"),
|
||||
extra=self._extract_extra(part, {"type", "file_data"}),
|
||||
)
|
||||
)
|
||||
elif isinstance(file_id, str) and file_id:
|
||||
blocks.append(
|
||||
FileBlock(
|
||||
file_id=file_id,
|
||||
extra=self._extract_extra(part, {"type", "file_id"}),
|
||||
)
|
||||
)
|
||||
else:
|
||||
blocks.append(UnknownBlock(raw_type="file", payload=part))
|
||||
continue
|
||||
|
||||
if ptype == "input_audio":
|
||||
audio_data = part.get("input_audio") or {}
|
||||
if isinstance(audio_data, dict):
|
||||
audio_fmt = str(audio_data.get("format") or "")
|
||||
blocks.append(
|
||||
AudioBlock(
|
||||
data=audio_data.get("data"),
|
||||
media_type=f"audio/{audio_fmt}" if audio_fmt else None,
|
||||
format=audio_fmt or None,
|
||||
extra=self._extract_extra(part, {"type", "input_audio"}),
|
||||
)
|
||||
)
|
||||
else:
|
||||
blocks.append(UnknownBlock(raw_type="input_audio", payload=part))
|
||||
continue
|
||||
|
||||
# 其他类型:UnknownBlock
|
||||
dropped_key = f"openai_part:{ptype}"
|
||||
dropped[dropped_key] = dropped.get(dropped_key, 0) + 1
|
||||
@@ -1288,14 +1447,35 @@ class OpenAINormalizer(FormatNormalizer):
|
||||
if input_tokens == 0 and output_tokens == 0 and total_tokens_int == 0:
|
||||
return None
|
||||
|
||||
# prompt_tokens_details -> cache_read_tokens
|
||||
cache_read = 0
|
||||
ptd = usage.get("prompt_tokens_details")
|
||||
if isinstance(ptd, dict):
|
||||
cache_read = int(ptd.get("cached_tokens") or 0)
|
||||
|
||||
# completion_tokens_details(reasoning_tokens 等存入 extra)
|
||||
ctd = usage.get("completion_tokens_details")
|
||||
|
||||
extra = self._extract_extra(
|
||||
usage,
|
||||
{"prompt_tokens", "completion_tokens", "total_tokens"},
|
||||
{
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"total_tokens",
|
||||
"prompt_tokens_details",
|
||||
"completion_tokens_details",
|
||||
},
|
||||
)
|
||||
|
||||
# 保留 completion_tokens_details 细分到 extra
|
||||
if isinstance(ctd, dict):
|
||||
extra["completion_tokens_details"] = ctd
|
||||
|
||||
return UsageInfo(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
total_tokens=total_tokens_int,
|
||||
cache_read_tokens=cache_read,
|
||||
extra={"openai": extra} if extra else {},
|
||||
)
|
||||
|
||||
@@ -1326,9 +1506,47 @@ class OpenAINormalizer(FormatNormalizer):
|
||||
parts.append({"type": "image_url", "image_url": {"url": b.url}})
|
||||
continue
|
||||
|
||||
if isinstance(b, FileBlock):
|
||||
if b.data and b.media_type:
|
||||
# OpenAI file content part
|
||||
file_part: dict[str, Any] = {
|
||||
"type": "file",
|
||||
"file_data": {
|
||||
"mime_type": b.media_type,
|
||||
"data": b.data,
|
||||
},
|
||||
}
|
||||
if b.filename:
|
||||
file_part["file_data"]["filename"] = b.filename
|
||||
parts.append(file_part)
|
||||
elif b.file_id:
|
||||
parts.append({"type": "file", "file_id": b.file_id})
|
||||
elif b.file_url:
|
||||
# 回退为文本描述
|
||||
text_parts.append(f"[File: {b.file_url}]")
|
||||
continue
|
||||
|
||||
if isinstance(b, AudioBlock):
|
||||
if b.data:
|
||||
audio_fmt = b.format or (
|
||||
b.media_type.split("/", 1)[1]
|
||||
if b.media_type and "/" in b.media_type
|
||||
else "mp3"
|
||||
)
|
||||
parts.append(
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": {
|
||||
"data": b.data,
|
||||
"format": audio_fmt,
|
||||
},
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Unknown / Tool blocks 不进入 OpenAI content
|
||||
|
||||
# 如果有 OpenAI 原生格式的图片(URL 引用),使用 multipart content
|
||||
# 如果有 OpenAI 原生格式的图片/文件/音频(multipart content parts),使用 multipart content
|
||||
if parts:
|
||||
if text_parts:
|
||||
parts = [{"type": "text", "text": "\n".join(text_parts)}] + parts
|
||||
@@ -1355,10 +1573,10 @@ class OpenAINormalizer(FormatNormalizer):
|
||||
tool_blocks.append(b)
|
||||
continue
|
||||
if isinstance(b, ToolResultBlock):
|
||||
# InternalResponse.content 不应该包含 tool_result;忽略
|
||||
continue
|
||||
if isinstance(b, UnknownBlock):
|
||||
continue
|
||||
# TextBlock, ImageBlock, FileBlock, AudioBlock -> content
|
||||
content_blocks.append(b)
|
||||
return thinking_blocks, content_blocks, tool_blocks
|
||||
|
||||
@@ -1495,38 +1713,6 @@ class OpenAINormalizer(FormatNormalizer):
|
||||
except ValueError:
|
||||
return ErrorType.UNKNOWN
|
||||
|
||||
def _optional_int(self, value: Any) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _optional_float(self, value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _coerce_str_list(self, value: Any) -> list[str] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
if isinstance(value, list):
|
||||
return [str(x) for x in value if x is not None]
|
||||
return None
|
||||
|
||||
def _extract_extra(self, payload: dict[str, Any], known_keys: set[str]) -> dict[str, Any]:
|
||||
return {k: v for k, v in payload.items() if k not in known_keys}
|
||||
|
||||
def _merge_dropped(self, target: dict[str, int], source: dict[str, int]) -> None:
|
||||
for k, v in source.items():
|
||||
target[k] = target.get(k, 0) + int(v)
|
||||
|
||||
def _ensure_tool_block_index(self, ss: dict[str, Any], tool_key: str) -> int:
|
||||
mapping = ss.get("tool_id_to_block_index")
|
||||
if not isinstance(mapping, dict):
|
||||
|
||||
@@ -1343,22 +1343,6 @@ class OpenAICliNormalizer(FormatNormalizer):
|
||||
return "tool"
|
||||
return "user"
|
||||
|
||||
def _optional_int(self, value: Any) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _optional_float(self, value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _coerce_str_list(self, value: Any) -> list[str] | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
@@ -16,6 +16,7 @@ from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
from src.core.api_format.conversion.exceptions import FormatConversionError
|
||||
from src.core.api_format.conversion.image_resolver import resolve_image_urls
|
||||
from src.core.api_format.conversion.internal import InternalRequest, ToolResultBlock, ToolUseBlock
|
||||
from src.core.api_format.conversion.normalizer import FormatNormalizer
|
||||
from src.core.api_format.conversion.stream_state import StreamState
|
||||
@@ -104,6 +105,7 @@ class FormatConversionRegistry:
|
||||
target_format: str,
|
||||
*,
|
||||
target_variant: str | None = None,
|
||||
output_limit: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if str(source_format).upper() == str(target_format).upper() and not target_variant:
|
||||
return request
|
||||
@@ -116,11 +118,43 @@ class FormatConversionRegistry:
|
||||
):
|
||||
try:
|
||||
internal = src.request_to_internal(request)
|
||||
internal.output_limit = output_limit
|
||||
self._repair_internal_tool_call_ids(internal)
|
||||
return tgt.request_from_internal(internal, target_variant=target_variant)
|
||||
except Exception as e:
|
||||
raise FormatConversionError(source_format, target_format, str(e)) from e
|
||||
|
||||
async def convert_request_async(
|
||||
self,
|
||||
request: dict[str, Any],
|
||||
source_format: str,
|
||||
target_format: str,
|
||||
*,
|
||||
target_variant: str | None = None,
|
||||
output_limit: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""异步版本的 convert_request,在 internal 阶段执行图片 URL 下载等异步操作。"""
|
||||
if str(source_format).upper() == str(target_format).upper() and not target_variant:
|
||||
return request
|
||||
|
||||
src = self._require_normalizer(source_format)
|
||||
tgt = self._require_normalizer(target_format)
|
||||
|
||||
with _track_conversion_metrics(
|
||||
"request", str(source_format).upper(), str(target_format).upper()
|
||||
):
|
||||
try:
|
||||
internal = src.request_to_internal(request)
|
||||
internal.output_limit = output_limit
|
||||
self._repair_internal_tool_call_ids(internal)
|
||||
|
||||
# 异步阶段:解析图片 URL -> base64(仅在目标格式需要时)
|
||||
await resolve_image_urls(internal, str(target_format).upper())
|
||||
|
||||
return tgt.request_from_internal(internal, target_variant=target_variant)
|
||||
except Exception as e:
|
||||
raise FormatConversionError(source_format, target_format, str(e)) from e
|
||||
|
||||
def convert_response(
|
||||
self,
|
||||
response: dict[str, Any],
|
||||
|
||||
Reference in New Issue
Block a user