mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 09:50:21 +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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,9 +1,6 @@
|
|||||||
# Created by https://www.toptal.com/developers/gitignore/api/python
|
# Created by https://www.toptal.com/developers/gitignore/api/python
|
||||||
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
||||||
|
|
||||||
CLIProxyAPI/
|
|
||||||
sub2api/
|
|
||||||
|
|
||||||
# AI Assistant Configuration
|
# AI Assistant Configuration
|
||||||
.claude/
|
.claude/
|
||||||
.serena/
|
.serena/
|
||||||
@@ -235,4 +232,5 @@ src/_version.py
|
|||||||
|
|
||||||
# Analysis folder (third-party code for reference)
|
# Analysis folder (third-party code for reference)
|
||||||
analysis/
|
analysis/
|
||||||
|
new-api/
|
||||||
/aether-proxy/target/
|
/aether-proxy/target/
|
||||||
|
|||||||
@@ -147,6 +147,36 @@
|
|||||||
@update:model-value="(v) => setConfigField('description', v || undefined)"
|
@update:model-value="(v) => setConfigField('description', v || undefined)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label
|
||||||
|
for="model-output-limit"
|
||||||
|
class="text-xs"
|
||||||
|
>最大输出 Token</Label>
|
||||||
|
<Input
|
||||||
|
id="model-output-limit"
|
||||||
|
:model-value="form.config?.output_limit ?? ''"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder="如 8192"
|
||||||
|
@update:model-value="(v) => setConfigField('output_limit', parseNumberInput(v, { allowFloat: false }))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label
|
||||||
|
for="model-context-limit"
|
||||||
|
class="text-xs"
|
||||||
|
>上下文窗口</Label>
|
||||||
|
<Input
|
||||||
|
id="model-context-limit"
|
||||||
|
:model-value="form.config?.context_limit ?? ''"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder="如 200000"
|
||||||
|
@update:model-value="(v) => setConfigField('context_limit', parseNumberInput(v, { allowFloat: false }))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Key 能力配置 -->
|
<!-- Key 能力配置 -->
|
||||||
|
|||||||
@@ -706,11 +706,12 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
|||||||
# 跨格式:先做请求体转换(失败触发 failover)
|
# 跨格式:先做请求体转换(失败触发 failover)
|
||||||
registry = get_format_converter_registry()
|
registry = get_format_converter_registry()
|
||||||
if needs_conversion:
|
if needs_conversion:
|
||||||
request_body = registry.convert_request(
|
request_body = await registry.convert_request_async(
|
||||||
request_body,
|
request_body,
|
||||||
str(client_api_format),
|
str(client_api_format),
|
||||||
str(provider_api_format),
|
str(provider_api_format),
|
||||||
target_variant=cross_format_variant,
|
target_variant=cross_format_variant,
|
||||||
|
output_limit=candidate.output_limit if candidate else None,
|
||||||
)
|
)
|
||||||
# 格式转换后,为需要 model 字段的格式设置模型名
|
# 格式转换后,为需要 model 字段的格式设置模型名
|
||||||
self._set_model_after_conversion(
|
self._set_model_after_conversion(
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ class CliHandlerProtocol(Protocol):
|
|||||||
mapped_model: str | None,
|
mapped_model: str | None,
|
||||||
) -> str | None: ...
|
) -> str | None: ...
|
||||||
|
|
||||||
def _convert_request_for_cross_format(
|
async def _convert_request_for_cross_format(
|
||||||
self,
|
self,
|
||||||
request_body: dict[str, Any],
|
request_body: dict[str, Any],
|
||||||
client_api_format: str,
|
client_api_format: str,
|
||||||
@@ -165,6 +165,7 @@ class CliHandlerProtocol(Protocol):
|
|||||||
is_stream: bool,
|
is_stream: bool,
|
||||||
*,
|
*,
|
||||||
target_variant: str | None = ...,
|
target_variant: str | None = ...,
|
||||||
|
output_limit: int | None = ...,
|
||||||
) -> tuple[dict[str, Any], str]: ...
|
) -> tuple[dict[str, Any], str]: ...
|
||||||
|
|
||||||
def _extract_response_metadata(
|
def _extract_response_metadata(
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ class CliRequestMixin:
|
|||||||
stream_options["include_usage"] = True
|
stream_options["include_usage"] = True
|
||||||
request_body["stream_options"] = stream_options
|
request_body["stream_options"] = stream_options
|
||||||
|
|
||||||
def _convert_request_for_cross_format(
|
async def _convert_request_for_cross_format(
|
||||||
self,
|
self,
|
||||||
request_body: dict[str, Any],
|
request_body: dict[str, Any],
|
||||||
client_api_format: str,
|
client_api_format: str,
|
||||||
@@ -238,6 +238,7 @@ class CliRequestMixin:
|
|||||||
is_stream: bool,
|
is_stream: bool,
|
||||||
*,
|
*,
|
||||||
target_variant: str | None = None,
|
target_variant: str | None = None,
|
||||||
|
output_limit: int | None = None,
|
||||||
) -> tuple[dict[str, Any], str]:
|
) -> tuple[dict[str, Any], str]:
|
||||||
"""
|
"""
|
||||||
跨格式请求转换的公共逻辑
|
跨格式请求转换的公共逻辑
|
||||||
@@ -252,16 +253,18 @@ class CliRequestMixin:
|
|||||||
fallback_model: 备用模型名(通常是原始请求的 model)
|
fallback_model: 备用模型名(通常是原始请求的 model)
|
||||||
is_stream: 是否流式请求
|
is_stream: 是否流式请求
|
||||||
target_variant: 目标变体(如 "codex"),用于同格式但有细微差异的上游
|
target_variant: 目标变体(如 "codex"),用于同格式但有细微差异的上游
|
||||||
|
output_limit: GlobalModel 配置的模型输出上限
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(转换后的请求体, 用于 URL 的模型名)
|
(转换后的请求体, 用于 URL 的模型名)
|
||||||
"""
|
"""
|
||||||
registry = get_format_converter_registry()
|
registry = get_format_converter_registry()
|
||||||
converted_body = registry.convert_request(
|
converted_body = await registry.convert_request_async(
|
||||||
request_body,
|
request_body,
|
||||||
str(client_api_format),
|
str(client_api_format),
|
||||||
str(provider_api_format),
|
str(provider_api_format),
|
||||||
target_variant=target_variant,
|
target_variant=target_variant,
|
||||||
|
output_limit=output_limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 先计算 URL 模型(在清理 body 中的 model 字段之前)
|
# 先计算 URL 模型(在清理 body 中的 model 字段之前)
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ class CliStreamMixin:
|
|||||||
|
|
||||||
# 跨格式:先做请求体转换(失败触发 failover)
|
# 跨格式:先做请求体转换(失败触发 failover)
|
||||||
if needs_conversion and provider_api_format:
|
if needs_conversion and provider_api_format:
|
||||||
request_body, url_model = self._convert_request_for_cross_format(
|
request_body, url_model = await self._convert_request_for_cross_format(
|
||||||
request_body,
|
request_body,
|
||||||
client_api_format,
|
client_api_format,
|
||||||
provider_api_format,
|
provider_api_format,
|
||||||
@@ -325,6 +325,7 @@ class CliStreamMixin:
|
|||||||
ctx.model,
|
ctx.model,
|
||||||
is_stream=upstream_is_stream,
|
is_stream=upstream_is_stream,
|
||||||
target_variant=conversion_variant,
|
target_variant=conversion_variant,
|
||||||
|
output_limit=candidate.output_limit if candidate else None,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 同格式:按原逻辑做轻量清理(子类可覆盖)
|
# 同格式:按原逻辑做轻量清理(子类可覆盖)
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ class CliSyncMixin:
|
|||||||
|
|
||||||
# 跨格式:先做请求体转换(失败触发 failover)
|
# 跨格式:先做请求体转换(失败触发 failover)
|
||||||
if needs_conversion and provider_api_format:
|
if needs_conversion and provider_api_format:
|
||||||
request_body, url_model = self._convert_request_for_cross_format(
|
request_body, url_model = await self._convert_request_for_cross_format(
|
||||||
request_body,
|
request_body,
|
||||||
client_api_format,
|
client_api_format,
|
||||||
provider_api_format,
|
provider_api_format,
|
||||||
@@ -158,6 +158,7 @@ class CliSyncMixin:
|
|||||||
model,
|
model,
|
||||||
is_stream=upstream_is_stream,
|
is_stream=upstream_is_stream,
|
||||||
target_variant=conversion_variant,
|
target_variant=conversion_variant,
|
||||||
|
output_limit=candidate.output_limit if candidate else None,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 同格式:按原逻辑做轻量清理(子类可覆盖)
|
# 同格式:按原逻辑做轻量清理(子类可覆盖)
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ STOP_REASON_MAPPINGS: dict[str, dict[str, str]] = {
|
|||||||
"max_tokens": "max_tokens",
|
"max_tokens": "max_tokens",
|
||||||
"stop_sequence": "stop_sequence",
|
"stop_sequence": "stop_sequence",
|
||||||
"tool_use": "tool_use",
|
"tool_use": "tool_use",
|
||||||
|
"pause_turn": "end_turn",
|
||||||
|
"refusal": "end_turn",
|
||||||
# Claude 通常以错误/阻断体现,这里仅兜底
|
# Claude 通常以错误/阻断体现,这里仅兜底
|
||||||
"content_filtered": "end_turn",
|
"content_filtered": "end_turn",
|
||||||
"unknown": "end_turn",
|
"unknown": "end_turn",
|
||||||
@@ -40,6 +42,8 @@ STOP_REASON_MAPPINGS: dict[str, dict[str, str]] = {
|
|||||||
"stop_sequence": "stop",
|
"stop_sequence": "stop",
|
||||||
"tool_use": "tool_calls",
|
"tool_use": "tool_calls",
|
||||||
"content_filtered": "content_filter",
|
"content_filtered": "content_filter",
|
||||||
|
"refusal": "content_filter",
|
||||||
|
"pause_turn": "stop",
|
||||||
"unknown": "stop",
|
"unknown": "stop",
|
||||||
},
|
},
|
||||||
"GEMINI": {
|
"GEMINI": {
|
||||||
@@ -49,6 +53,8 @@ STOP_REASON_MAPPINGS: dict[str, dict[str, str]] = {
|
|||||||
# Gemini finishReason 对工具调用并没有稳定等价枚举,这里保守兜底为 STOP
|
# Gemini finishReason 对工具调用并没有稳定等价枚举,这里保守兜底为 STOP
|
||||||
"tool_use": "STOP",
|
"tool_use": "STOP",
|
||||||
"content_filtered": "SAFETY",
|
"content_filtered": "SAFETY",
|
||||||
|
"refusal": "SAFETY",
|
||||||
|
"pause_turn": "OTHER",
|
||||||
"unknown": "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__ = [
|
__all__ = [
|
||||||
"ROLE_MAPPINGS",
|
"ROLE_MAPPINGS",
|
||||||
"STOP_REASON_MAPPINGS",
|
"STOP_REASON_MAPPINGS",
|
||||||
"USAGE_FIELD_MAPPINGS",
|
"USAGE_FIELD_MAPPINGS",
|
||||||
"ERROR_TYPE_MAPPINGS",
|
"ERROR_TYPE_MAPPINGS",
|
||||||
"RETRYABLE_ERROR_TYPES",
|
"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"
|
TEXT = "text"
|
||||||
THINKING = "thinking"
|
THINKING = "thinking"
|
||||||
IMAGE = "image"
|
IMAGE = "image"
|
||||||
|
FILE = "file"
|
||||||
|
AUDIO = "audio"
|
||||||
TOOL_USE = "tool_use"
|
TOOL_USE = "tool_use"
|
||||||
TOOL_RESULT = "tool_result"
|
TOOL_RESULT = "tool_result"
|
||||||
UNKNOWN = "unknown"
|
UNKNOWN = "unknown"
|
||||||
@@ -115,6 +117,30 @@ class ToolResultBlock:
|
|||||||
extra: dict[str, Any] = field(default_factory=dict)
|
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
|
@dataclass
|
||||||
class UnknownBlock:
|
class UnknownBlock:
|
||||||
"""未知内容块(用于前向兼容)"""
|
"""未知内容块(用于前向兼容)"""
|
||||||
@@ -126,7 +152,14 @@ class UnknownBlock:
|
|||||||
|
|
||||||
|
|
||||||
ContentBlock = (
|
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)
|
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
|
@dataclass
|
||||||
class InternalRequest:
|
class InternalRequest:
|
||||||
"""统一的请求表示"""
|
"""统一的请求表示"""
|
||||||
@@ -195,6 +246,27 @@ class InternalRequest:
|
|||||||
stream: bool = False
|
stream: bool = False
|
||||||
tools: list[ToolDefinition] | None = None
|
tools: list[ToolDefinition] | None = None
|
||||||
tool_choice: ToolChoice | None = None # auto/none/required 或指定 tool_name
|
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) # 未识别字段透传
|
extra: dict[str, Any] = field(default_factory=dict) # 未识别字段透传
|
||||||
|
|
||||||
def to_debug_dict(self) -> dict[str, Any]:
|
def to_debug_dict(self) -> dict[str, Any]:
|
||||||
@@ -293,6 +365,8 @@ __all__ = [
|
|||||||
"TextBlock",
|
"TextBlock",
|
||||||
"ThinkingBlock",
|
"ThinkingBlock",
|
||||||
"ImageBlock",
|
"ImageBlock",
|
||||||
|
"FileBlock",
|
||||||
|
"AudioBlock",
|
||||||
"ToolUseBlock",
|
"ToolUseBlock",
|
||||||
"ToolResultBlock",
|
"ToolResultBlock",
|
||||||
"UnknownBlock",
|
"UnknownBlock",
|
||||||
@@ -301,6 +375,8 @@ __all__ = [
|
|||||||
"InstructionSegment",
|
"InstructionSegment",
|
||||||
"ToolDefinition",
|
"ToolDefinition",
|
||||||
"ToolChoice",
|
"ToolChoice",
|
||||||
|
"ThinkingConfig",
|
||||||
|
"ResponseFormatConfig",
|
||||||
"InternalRequest",
|
"InternalRequest",
|
||||||
"UsageInfo",
|
"UsageInfo",
|
||||||
"InternalResponse",
|
"InternalResponse",
|
||||||
|
|||||||
@@ -115,6 +115,40 @@ class FormatNormalizer(ABC):
|
|||||||
return ImageBlock(data=data, media_type=media_type)
|
return ImageBlock(data=data, media_type=media_type)
|
||||||
return ImageBlock(url=url)
|
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:
|
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,
|
ERROR_TYPE_MAPPINGS,
|
||||||
RETRYABLE_ERROR_TYPES,
|
RETRYABLE_ERROR_TYPES,
|
||||||
STOP_REASON_MAPPINGS,
|
STOP_REASON_MAPPINGS,
|
||||||
|
THINKING_BUDGET_TOKENS_MIN,
|
||||||
|
THINKING_BUDGET_TOKENS_PERCENTAGE,
|
||||||
USAGE_FIELD_MAPPINGS,
|
USAGE_FIELD_MAPPINGS,
|
||||||
|
WEB_SEARCH_CONTEXT_SIZE_TO_MAX_USES,
|
||||||
|
get_claude_default_max_tokens,
|
||||||
)
|
)
|
||||||
from src.core.api_format.conversion.internal import (
|
from src.core.api_format.conversion.internal import (
|
||||||
|
AudioBlock,
|
||||||
ContentBlock,
|
ContentBlock,
|
||||||
ContentType,
|
ContentType,
|
||||||
ErrorType,
|
ErrorType,
|
||||||
|
FileBlock,
|
||||||
FormatCapabilities,
|
FormatCapabilities,
|
||||||
ImageBlock,
|
ImageBlock,
|
||||||
InstructionSegment,
|
InstructionSegment,
|
||||||
@@ -31,6 +37,7 @@ from src.core.api_format.conversion.internal import (
|
|||||||
StopReason,
|
StopReason,
|
||||||
TextBlock,
|
TextBlock,
|
||||||
ThinkingBlock,
|
ThinkingBlock,
|
||||||
|
ThinkingConfig,
|
||||||
ToolChoice,
|
ToolChoice,
|
||||||
ToolChoiceType,
|
ToolChoiceType,
|
||||||
ToolDefinition,
|
ToolDefinition,
|
||||||
@@ -97,9 +104,12 @@ class ClaudeNormalizer(FormatNormalizer):
|
|||||||
|
|
||||||
# 顶层 system 先进入 instructions(保持确定性优先级)
|
# 顶层 system 先进入 instructions(保持确定性优先级)
|
||||||
sys_value = request.get("system")
|
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)
|
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))
|
instructions.append(InstructionSegment(role=Role.SYSTEM, text=sys_text))
|
||||||
|
|
||||||
messages: list[InternalMessage] = []
|
messages: list[InternalMessage] = []
|
||||||
@@ -112,7 +122,7 @@ class ClaudeNormalizer(FormatNormalizer):
|
|||||||
|
|
||||||
# 兼容:少数客户端可能把 system/developer 混进 messages[]
|
# 兼容:少数客户端可能把 system/developer 混进 messages[]
|
||||||
if role in ("system", "developer"):
|
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)
|
self._merge_dropped(dropped, md)
|
||||||
if text:
|
if text:
|
||||||
instructions.append(
|
instructions.append(
|
||||||
@@ -134,6 +144,19 @@ class ClaudeNormalizer(FormatNormalizer):
|
|||||||
tools = self._claude_tools_to_internal(request.get("tools"))
|
tools = self._claude_tools_to_internal(request.get("tools"))
|
||||||
tool_choice = self._claude_tool_choice_to_internal(request.get("tool_choice"))
|
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(
|
internal = InternalRequest(
|
||||||
model=model,
|
model=model,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
@@ -147,6 +170,7 @@ class ClaudeNormalizer(FormatNormalizer):
|
|||||||
stream=bool(request.get("stream") or False),
|
stream=bool(request.get("stream") or False),
|
||||||
tools=tools,
|
tools=tools,
|
||||||
tool_choice=tool_choice,
|
tool_choice=tool_choice,
|
||||||
|
thinking=thinking,
|
||||||
extra={"claude": self._extract_extra(request, {"messages"})},
|
extra={"claude": self._extract_extra(request, {"messages"})},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -161,7 +185,26 @@ class ClaudeNormalizer(FormatNormalizer):
|
|||||||
*,
|
*,
|
||||||
target_variant: str | None = None,
|
target_variant: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> 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,且需要交替;这里做最小修复
|
# Claude Messages API: messages[] 仅允许 user/assistant,且需要交替;这里做最小修复
|
||||||
fixed_messages = self._coerce_claude_message_sequence(internal.messages)
|
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
|
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] = {
|
result: dict[str, Any] = {
|
||||||
"model": internal.model,
|
"model": internal.model,
|
||||||
"messages": out_messages,
|
"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:
|
if system_value:
|
||||||
result["system"] = system_text
|
result["system"] = system_value
|
||||||
|
|
||||||
if internal.temperature is not None:
|
if internal.temperature is not None:
|
||||||
result["temperature"] = internal.temperature
|
result["temperature"] = internal.temperature
|
||||||
@@ -191,18 +243,75 @@ class ClaudeNormalizer(FormatNormalizer):
|
|||||||
result["stream"] = True
|
result["stream"] = True
|
||||||
|
|
||||||
if internal.tools:
|
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,
|
"name": t.name,
|
||||||
"description": t.description,
|
|
||||||
"input_schema": t.parameters or {},
|
"input_schema": t.parameters or {},
|
||||||
**(t.extra.get("claude") 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:
|
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 特有字段(如 metadata)
|
||||||
claude_extra = internal.extra.get("claude") if isinstance(internal.extra, dict) else None
|
claude_extra = internal.extra.get("claude") if isinstance(internal.extra, dict) else None
|
||||||
@@ -296,7 +405,45 @@ class ClaudeNormalizer(FormatNormalizer):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif b.url:
|
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
|
continue
|
||||||
# Unknown/ToolResult 默认丢弃
|
# Unknown/ToolResult 默认丢弃
|
||||||
|
|
||||||
@@ -740,9 +887,12 @@ class ClaudeNormalizer(FormatNormalizer):
|
|||||||
if btype == "text":
|
if btype == "text":
|
||||||
text = str(block.get("text") or "")
|
text = str(block.get("text") or "")
|
||||||
if text:
|
if text:
|
||||||
blocks.append(
|
block_extra = self._extract_extra(block, {"type", "text"})
|
||||||
TextBlock(text=text, 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
|
continue
|
||||||
|
|
||||||
if btype == "image":
|
if btype == "image":
|
||||||
@@ -760,10 +910,42 @@ class ClaudeNormalizer(FormatNormalizer):
|
|||||||
):
|
):
|
||||||
blocks.append(ImageBlock(data=data, media_type=media_type))
|
blocks.append(ImageBlock(data=data, media_type=media_type))
|
||||||
continue
|
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
|
dropped["claude_image_unsupported"] = dropped.get("claude_image_unsupported", 0) + 1
|
||||||
blocks.append(UnknownBlock(raw_type="image", payload=block))
|
blocks.append(UnknownBlock(raw_type="image", payload=block))
|
||||||
continue
|
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":
|
if btype == "tool_use":
|
||||||
tool_id = str(block.get("id") or "")
|
tool_id = str(block.get("id") or "")
|
||||||
tool_name = str(block.get("name") or "")
|
tool_name = str(block.get("name") or "")
|
||||||
@@ -862,15 +1044,21 @@ class ClaudeNormalizer(FormatNormalizer):
|
|||||||
extra={"claude": raw_block},
|
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] = {}
|
dropped: dict[str, int] = {}
|
||||||
if system_value is None:
|
if system_value is None:
|
||||||
return None, dropped
|
return None, dropped, None
|
||||||
if isinstance(system_value, str):
|
if isinstance(system_value, str):
|
||||||
return (system_value or None), dropped
|
return (system_value or None), dropped, None
|
||||||
|
|
||||||
if isinstance(system_value, list):
|
if isinstance(system_value, list):
|
||||||
texts: list[str] = []
|
texts: list[str] = []
|
||||||
|
segments: list[InstructionSegment] = []
|
||||||
|
has_cache_control = False
|
||||||
for item in system_value:
|
for item in system_value:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
dropped["claude_system_item_non_dict"] = (
|
dropped["claude_system_item_non_dict"] = (
|
||||||
@@ -881,14 +1069,26 @@ class ClaudeNormalizer(FormatNormalizer):
|
|||||||
text = item.get("text")
|
text = item.get("text")
|
||||||
if text:
|
if text:
|
||||||
texts.append(str(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:
|
else:
|
||||||
dropped_key = f"claude_system_item:{item.get('type')}"
|
dropped_key = f"claude_system_item:{item.get('type')}"
|
||||||
dropped[dropped_key] = dropped.get(dropped_key, 0) + 1
|
dropped[dropped_key] = dropped.get(dropped_key, 0) + 1
|
||||||
joined = "\n\n".join(texts)
|
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
|
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:
|
def _join_instructions(self, instructions: list[InstructionSegment]) -> str | None:
|
||||||
parts = [seg.text for seg in instructions if seg.text]
|
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]:
|
def _internal_message_to_claude(self, msg: InternalMessage) -> dict[str, Any]:
|
||||||
role = "user" if msg.role == Role.USER else "assistant"
|
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]] = []
|
blocks: list[dict[str, Any]] = []
|
||||||
text_parts: list[str] = []
|
text_parts: list[str] = []
|
||||||
|
|
||||||
@@ -966,7 +1171,14 @@ class ClaudeNormalizer(FormatNormalizer):
|
|||||||
|
|
||||||
if isinstance(b, TextBlock):
|
if isinstance(b, TextBlock):
|
||||||
if b.text:
|
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
|
continue
|
||||||
|
|
||||||
if isinstance(b, ImageBlock):
|
if isinstance(b, ImageBlock):
|
||||||
@@ -989,7 +1201,52 @@ class ClaudeNormalizer(FormatNormalizer):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif b.url:
|
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
|
continue
|
||||||
|
|
||||||
if isinstance(b, ToolUseBlock) and role == "assistant":
|
if isinstance(b, ToolUseBlock) and role == "assistant":
|
||||||
@@ -1062,7 +1319,11 @@ class ClaudeNormalizer(FormatNormalizer):
|
|||||||
|
|
||||||
mapping = USAGE_FIELD_MAPPINGS.get("CLAUDE", {})
|
mapping = USAGE_FIELD_MAPPINGS.get("CLAUDE", {})
|
||||||
fields: dict[str, int] = {}
|
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():
|
for provider_key, internal_key in mapping.items():
|
||||||
if provider_key in usage and usage.get(provider_key) is not None:
|
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)
|
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(
|
return UsageInfo(
|
||||||
input_tokens=int(fields.get("input_tokens", 0)),
|
input_tokens=int(fields.get("input_tokens", 0)),
|
||||||
output_tokens=int(fields.get("output_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)
|
result["cache_read_input_tokens"] = int(usage.cache_read_tokens)
|
||||||
if usage.cache_write_tokens:
|
if usage.cache_write_tokens:
|
||||||
result["cache_creation_input_tokens"] = int(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
|
return result
|
||||||
|
|
||||||
def _error_type_from_value(self, value: str) -> ErrorType:
|
def _error_type_from_value(self, value: str) -> ErrorType:
|
||||||
@@ -1102,37 +1388,5 @@ class ClaudeNormalizer(FormatNormalizer):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return ErrorType.UNKNOWN
|
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"]
|
__all__ = ["ClaudeNormalizer"]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ Gemini (GenerateContent / streamGenerateContent) Normalizer
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
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 (
|
||||||
@@ -21,9 +22,11 @@ from src.core.api_format.conversion.field_mappings import (
|
|||||||
USAGE_FIELD_MAPPINGS,
|
USAGE_FIELD_MAPPINGS,
|
||||||
)
|
)
|
||||||
from src.core.api_format.conversion.internal import (
|
from src.core.api_format.conversion.internal import (
|
||||||
|
AudioBlock,
|
||||||
ContentBlock,
|
ContentBlock,
|
||||||
ContentType,
|
ContentType,
|
||||||
ErrorType,
|
ErrorType,
|
||||||
|
FileBlock,
|
||||||
FormatCapabilities,
|
FormatCapabilities,
|
||||||
ImageBlock,
|
ImageBlock,
|
||||||
InstructionSegment,
|
InstructionSegment,
|
||||||
@@ -31,10 +34,12 @@ from src.core.api_format.conversion.internal import (
|
|||||||
InternalMessage,
|
InternalMessage,
|
||||||
InternalRequest,
|
InternalRequest,
|
||||||
InternalResponse,
|
InternalResponse,
|
||||||
|
ResponseFormatConfig,
|
||||||
Role,
|
Role,
|
||||||
StopReason,
|
StopReason,
|
||||||
TextBlock,
|
TextBlock,
|
||||||
ThinkingBlock,
|
ThinkingBlock,
|
||||||
|
ThinkingConfig,
|
||||||
ToolChoice,
|
ToolChoice,
|
||||||
ToolChoiceType,
|
ToolChoiceType,
|
||||||
ToolDefinition,
|
ToolDefinition,
|
||||||
@@ -131,6 +136,60 @@ def compact_gemini_contents(contents: list[dict[str, Any]]) -> list[dict[str, An
|
|||||||
return merged
|
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):
|
class GeminiNormalizer(FormatNormalizer):
|
||||||
FORMAT_ID = "gemini:chat"
|
FORMAT_ID = "gemini:chat"
|
||||||
capabilities = FormatCapabilities(
|
capabilities = FormatCapabilities(
|
||||||
@@ -226,9 +285,28 @@ class GeminiNormalizer(FormatNormalizer):
|
|||||||
|
|
||||||
# 保留 generationConfig 中的特殊字段(responseModalities, thinkingConfig 等)
|
# 保留 generationConfig 中的特殊字段(responseModalities, thinkingConfig 等)
|
||||||
# 这些字段在 _get_generation_config 中已提取,需要单独存储以便转换时使用
|
# 这些字段在 _get_generation_config 中已提取,需要单独存储以便转换时使用
|
||||||
|
thinking: ThinkingConfig | None = None
|
||||||
|
n: int | None = None
|
||||||
|
response_format: ResponseFormatConfig | None = None
|
||||||
|
|
||||||
if isinstance(generation_config, dict):
|
if isinstance(generation_config, dict):
|
||||||
response_modalities = generation_config.get("response_modalities")
|
response_modalities = generation_config.get("response_modalities")
|
||||||
thinking_config = generation_config.get("thinking_config")
|
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:
|
if response_modalities or thinking_config:
|
||||||
google_extra: dict[str, Any] = {}
|
google_extra: dict[str, Any] = {}
|
||||||
if response_modalities:
|
if response_modalities:
|
||||||
@@ -237,6 +315,32 @@ class GeminiNormalizer(FormatNormalizer):
|
|||||||
google_extra["thinking_config"] = thinking_config
|
google_extra["thinking_config"] = thinking_config
|
||||||
extra["google"] = google_extra
|
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(
|
internal = InternalRequest(
|
||||||
model=model,
|
model=model,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
@@ -250,6 +354,9 @@ class GeminiNormalizer(FormatNormalizer):
|
|||||||
stream=bool(request.get("stream") or False),
|
stream=bool(request.get("stream") or False),
|
||||||
tools=tools,
|
tools=tools,
|
||||||
tool_choice=tool_choice,
|
tool_choice=tool_choice,
|
||||||
|
thinking=thinking,
|
||||||
|
n=n,
|
||||||
|
response_format=response_format,
|
||||||
extra=extra,
|
extra=extra,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -276,21 +383,36 @@ class GeminiNormalizer(FormatNormalizer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# tools/tool_choice — clean unsupported JSON Schema fields from parameters
|
# tools/tool_choice — clean unsupported JSON Schema fields from parameters
|
||||||
|
# Gemini 特殊内置工具名称需要单独处理
|
||||||
tools = None
|
tools = None
|
||||||
if internal.tools:
|
if internal.tools:
|
||||||
func_decls: list[dict[str, Any]] = []
|
func_decls: list[dict[str, Any]] = []
|
||||||
|
builtin_tools: list[dict[str, Any]] = []
|
||||||
for t in internal.tools:
|
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 {}
|
params = dict(t.parameters) if t.parameters else {}
|
||||||
if params:
|
if params:
|
||||||
_clean_gemini_schema(params)
|
_clean_gemini_schema(params)
|
||||||
decl: dict[str, Any] = {
|
decl: dict[str, Any] = {
|
||||||
"name": t.name,
|
"name": t.name,
|
||||||
"description": t.description,
|
|
||||||
"parameters": params,
|
"parameters": params,
|
||||||
**(t.extra.get("gemini_function_declaration") or {}),
|
**(t.extra.get("gemini_function_declaration") or {}),
|
||||||
}
|
}
|
||||||
|
if t.description is not None:
|
||||||
|
decl["description"] = t.description
|
||||||
func_decls.append(decl)
|
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
|
tool_config = None
|
||||||
if internal.tool_choice:
|
if internal.tool_choice:
|
||||||
@@ -308,12 +430,40 @@ class GeminiNormalizer(FormatNormalizer):
|
|||||||
if internal.stop_sequences:
|
if internal.stop_sequences:
|
||||||
generation_config["stop_sequences"] = list(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 透传的配置
|
# 从 internal.extra["google"] 读取 OpenAI extra_body.google 透传的配置
|
||||||
google_extra = internal.extra.get("google", {})
|
google_extra = internal.extra.get("google", {})
|
||||||
if isinstance(google_extra, dict):
|
if isinstance(google_extra, dict):
|
||||||
# 处理 thinking_config -> thinkingConfig
|
# 处理 thinking_config -> thinkingConfig(仅当上面标准路径未设置时)
|
||||||
thinking_config = google_extra.get("thinking_config")
|
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 转换
|
# snake_case -> camelCase 转换
|
||||||
gemini_thinking: dict[str, Any] = {}
|
gemini_thinking: dict[str, Any] = {}
|
||||||
if "thinking_budget" in thinking_config:
|
if "thinking_budget" in thinking_config:
|
||||||
@@ -472,6 +622,13 @@ class GeminiNormalizer(FormatNormalizer):
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
parts: list[dict[str, Any]] = []
|
parts: list[dict[str, Any]] = []
|
||||||
for b in internal.content:
|
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 isinstance(b, TextBlock):
|
||||||
if b.text:
|
if b.text:
|
||||||
parts.append({"text": b.text})
|
parts.append({"text": b.text})
|
||||||
@@ -497,7 +654,28 @@ class GeminiNormalizer(FormatNormalizer):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif b.url:
|
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
|
continue
|
||||||
# Unknown/ToolResult 默认丢弃
|
# Unknown/ToolResult 默认丢弃
|
||||||
|
|
||||||
@@ -1319,7 +1497,15 @@ class GeminiNormalizer(FormatNormalizer):
|
|||||||
)
|
)
|
||||||
data = inline.get("data")
|
data = inline.get("data")
|
||||||
if isinstance(mime_type, str) and mime_type and isinstance(data, str) and 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:
|
else:
|
||||||
dropped["gemini_inline_data_invalid"] = (
|
dropped["gemini_inline_data_invalid"] = (
|
||||||
dropped.get("gemini_inline_data_invalid", 0) + 1
|
dropped.get("gemini_inline_data_invalid", 0) + 1
|
||||||
@@ -1327,6 +1513,27 @@ class GeminiNormalizer(FormatNormalizer):
|
|||||||
blocks.append(UnknownBlock(raw_type="inline_data", payload=part))
|
blocks.append(UnknownBlock(raw_type="inline_data", payload=part))
|
||||||
continue
|
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")
|
func_call = part.get("function_call")
|
||||||
if func_call is None:
|
if func_call is None:
|
||||||
func_call = part.get("functionCall")
|
func_call = part.get("functionCall")
|
||||||
@@ -1457,14 +1664,47 @@ class GeminiNormalizer(FormatNormalizer):
|
|||||||
|
|
||||||
if isinstance(b, TextBlock):
|
if isinstance(b, TextBlock):
|
||||||
if b.text:
|
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
|
continue
|
||||||
|
|
||||||
if isinstance(b, ImageBlock):
|
if isinstance(b, ImageBlock):
|
||||||
if b.data and b.media_type:
|
if b.data and b.media_type:
|
||||||
parts.append({"inline_data": {"mime_type": b.media_type, "data": b.data}})
|
parts.append({"inline_data": {"mime_type": b.media_type, "data": b.data}})
|
||||||
elif b.url:
|
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
|
continue
|
||||||
|
|
||||||
if isinstance(b, ToolUseBlock) and role == "model":
|
if isinstance(b, ToolUseBlock) and role == "model":
|
||||||
@@ -1646,6 +1886,19 @@ class GeminiNormalizer(FormatNormalizer):
|
|||||||
if thinking_config:
|
if thinking_config:
|
||||||
normalized["thinking_config"] = 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}
|
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:
|
def _gemini_tools_to_internal(self, tools: Any) -> list[ToolDefinition] | None:
|
||||||
@@ -1657,35 +1910,49 @@ class GeminiNormalizer(FormatNormalizer):
|
|||||||
if not isinstance(tool, dict):
|
if not isinstance(tool, dict):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
decls = tool.get("function_declarations")
|
# 检测 Gemini 内置工具(如 {"googleSearch": {}}, {"codeExecution": {}})
|
||||||
if decls is None:
|
for key, canonical_name in _BUILTIN_TOOL_KEYS.items():
|
||||||
decls = tool.get("functionDeclarations")
|
if key in tool:
|
||||||
|
out.append(
|
||||||
if not isinstance(decls, list):
|
ToolDefinition(
|
||||||
continue
|
name=canonical_name,
|
||||||
|
description=None,
|
||||||
for decl in decls:
|
parameters=None,
|
||||||
if not isinstance(decl, dict):
|
extra={"gemini_builtin_tool": True},
|
||||||
continue
|
)
|
||||||
name = str(decl.get("name") or "")
|
)
|
||||||
if not name:
|
break
|
||||||
continue
|
else:
|
||||||
out.append(
|
# 非内置工具:解析 functionDeclarations
|
||||||
ToolDefinition(
|
decls = tool.get("function_declarations")
|
||||||
name=name,
|
if decls is None:
|
||||||
description=decl.get("description"),
|
decls = tool.get("functionDeclarations")
|
||||||
parameters=(
|
|
||||||
decl.get("parameters")
|
if not isinstance(decls, list):
|
||||||
if isinstance(decl.get("parameters"), dict)
|
continue
|
||||||
else None
|
|
||||||
),
|
for decl in decls:
|
||||||
extra={
|
if not isinstance(decl, dict):
|
||||||
"gemini_function_declaration": self._extract_extra(
|
continue
|
||||||
decl, {"name", "description", "parameters"}
|
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
|
return out or None
|
||||||
|
|
||||||
@@ -1784,37 +2051,5 @@ class GeminiNormalizer(FormatNormalizer):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return ErrorType.UNKNOWN
|
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"]
|
__all__ = ["GeminiNormalizer"]
|
||||||
|
|||||||
@@ -14,12 +14,16 @@ from typing import Any
|
|||||||
|
|
||||||
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,
|
||||||
RETRYABLE_ERROR_TYPES,
|
RETRYABLE_ERROR_TYPES,
|
||||||
|
THINKING_BUDGET_TO_REASONING_EFFORT,
|
||||||
)
|
)
|
||||||
from src.core.api_format.conversion.internal import (
|
from src.core.api_format.conversion.internal import (
|
||||||
|
AudioBlock,
|
||||||
ContentBlock,
|
ContentBlock,
|
||||||
ContentType,
|
ContentType,
|
||||||
ErrorType,
|
ErrorType,
|
||||||
|
FileBlock,
|
||||||
FormatCapabilities,
|
FormatCapabilities,
|
||||||
ImageBlock,
|
ImageBlock,
|
||||||
InstructionSegment,
|
InstructionSegment,
|
||||||
@@ -27,10 +31,12 @@ from src.core.api_format.conversion.internal import (
|
|||||||
InternalMessage,
|
InternalMessage,
|
||||||
InternalRequest,
|
InternalRequest,
|
||||||
InternalResponse,
|
InternalResponse,
|
||||||
|
ResponseFormatConfig,
|
||||||
Role,
|
Role,
|
||||||
StopReason,
|
StopReason,
|
||||||
TextBlock,
|
TextBlock,
|
||||||
ThinkingBlock,
|
ThinkingBlock,
|
||||||
|
ThinkingConfig,
|
||||||
ToolChoice,
|
ToolChoice,
|
||||||
ToolChoiceType,
|
ToolChoiceType,
|
||||||
ToolDefinition,
|
ToolDefinition,
|
||||||
@@ -86,6 +92,8 @@ class OpenAINormalizer(FormatNormalizer):
|
|||||||
StopReason.STOP_SEQUENCE: "stop",
|
StopReason.STOP_SEQUENCE: "stop",
|
||||||
StopReason.TOOL_USE: "tool_calls",
|
StopReason.TOOL_USE: "tool_calls",
|
||||||
StopReason.CONTENT_FILTERED: "content_filter",
|
StopReason.CONTENT_FILTERED: "content_filter",
|
||||||
|
StopReason.REFUSAL: "content_filter",
|
||||||
|
StopReason.PAUSE_TURN: "stop",
|
||||||
StopReason.UNKNOWN: "stop",
|
StopReason.UNKNOWN: "stop",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +189,52 @@ class OpenAINormalizer(FormatNormalizer):
|
|||||||
if isinstance(google_extra, dict) and google_extra:
|
if isinstance(google_extra, dict) and google_extra:
|
||||||
extra["google"] = 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(
|
internal = InternalRequest(
|
||||||
model=model,
|
model=model,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
@@ -193,6 +247,15 @@ class OpenAINormalizer(FormatNormalizer):
|
|||||||
stream=bool(request.get("stream") or False),
|
stream=bool(request.get("stream") or False),
|
||||||
tools=tools,
|
tools=tools,
|
||||||
tool_choice=tool_choice,
|
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,
|
extra=extra,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -240,24 +303,67 @@ class OpenAINormalizer(FormatNormalizer):
|
|||||||
result["stream_options"] = {"include_usage": True}
|
result["stream_options"] = {"include_usage": True}
|
||||||
|
|
||||||
if internal.tools:
|
if internal.tools:
|
||||||
result["tools"] = [
|
openai_tools: list[dict[str, Any]] = []
|
||||||
{
|
for t in internal.tools:
|
||||||
"type": "function",
|
# 跳过 Gemini 内置工具(在 OpenAI 中无对应物)
|
||||||
"function": {
|
if t.extra.get("gemini_builtin_tool"):
|
||||||
"name": t.name,
|
continue
|
||||||
"description": t.description,
|
func: dict[str, Any] = {
|
||||||
"parameters": t.parameters or {},
|
"name": t.name,
|
||||||
**(t.extra.get("openai_function") or {}),
|
"parameters": t.parameters or {},
|
||||||
},
|
**(t.extra.get("openai_function") or {}),
|
||||||
**(t.extra.get("openai_tool") 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:
|
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)
|
||||||
|
|
||||||
# 其余字段:保留在 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
|
return result
|
||||||
|
|
||||||
# =========================
|
# =========================
|
||||||
@@ -344,6 +450,8 @@ class OpenAINormalizer(FormatNormalizer):
|
|||||||
content_value = self._blocks_to_openai_content(content_blocks)
|
content_value = self._blocks_to_openai_content(content_blocks)
|
||||||
if content_value is not None:
|
if content_value is not None:
|
||||||
message["content"] = content_value
|
message["content"] = content_value
|
||||||
|
else:
|
||||||
|
message["content"] = None
|
||||||
|
|
||||||
if tool_blocks:
|
if tool_blocks:
|
||||||
message["tool_calls"] = [
|
message["tool_calls"] = [
|
||||||
@@ -366,11 +474,23 @@ class OpenAINormalizer(FormatNormalizer):
|
|||||||
total = internal.usage.total_tokens or (
|
total = internal.usage.total_tokens or (
|
||||||
internal.usage.input_tokens + internal.usage.output_tokens
|
internal.usage.input_tokens + internal.usage.output_tokens
|
||||||
)
|
)
|
||||||
out["usage"] = {
|
usage_out: dict[str, Any] = {
|
||||||
"prompt_tokens": int(internal.usage.input_tokens),
|
"prompt_tokens": int(internal.usage.input_tokens),
|
||||||
"completion_tokens": int(internal.usage.output_tokens),
|
"completion_tokens": int(internal.usage.output_tokens),
|
||||||
"total_tokens": int(total),
|
"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
|
return out
|
||||||
|
|
||||||
@@ -1061,6 +1181,45 @@ class OpenAINormalizer(FormatNormalizer):
|
|||||||
blocks.append(UnknownBlock(raw_type="image_url", payload=part))
|
blocks.append(UnknownBlock(raw_type="image_url", payload=part))
|
||||||
continue
|
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
|
# 其他类型:UnknownBlock
|
||||||
dropped_key = f"openai_part:{ptype}"
|
dropped_key = f"openai_part:{ptype}"
|
||||||
dropped[dropped_key] = dropped.get(dropped_key, 0) + 1
|
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:
|
if input_tokens == 0 and output_tokens == 0 and total_tokens_int == 0:
|
||||||
return None
|
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(
|
extra = self._extract_extra(
|
||||||
usage,
|
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(
|
return UsageInfo(
|
||||||
input_tokens=input_tokens,
|
input_tokens=input_tokens,
|
||||||
output_tokens=output_tokens,
|
output_tokens=output_tokens,
|
||||||
total_tokens=total_tokens_int,
|
total_tokens=total_tokens_int,
|
||||||
|
cache_read_tokens=cache_read,
|
||||||
extra={"openai": extra} if extra else {},
|
extra={"openai": extra} if extra else {},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1326,9 +1506,47 @@ class OpenAINormalizer(FormatNormalizer):
|
|||||||
parts.append({"type": "image_url", "image_url": {"url": b.url}})
|
parts.append({"type": "image_url", "image_url": {"url": b.url}})
|
||||||
continue
|
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
|
# Unknown / Tool blocks 不进入 OpenAI content
|
||||||
|
|
||||||
# 如果有 OpenAI 原生格式的图片(URL 引用),使用 multipart content
|
# 如果有 OpenAI 原生格式的图片/文件/音频(multipart content parts),使用 multipart content
|
||||||
if parts:
|
if parts:
|
||||||
if text_parts:
|
if text_parts:
|
||||||
parts = [{"type": "text", "text": "\n".join(text_parts)}] + parts
|
parts = [{"type": "text", "text": "\n".join(text_parts)}] + parts
|
||||||
@@ -1355,10 +1573,10 @@ class OpenAINormalizer(FormatNormalizer):
|
|||||||
tool_blocks.append(b)
|
tool_blocks.append(b)
|
||||||
continue
|
continue
|
||||||
if isinstance(b, ToolResultBlock):
|
if isinstance(b, ToolResultBlock):
|
||||||
# InternalResponse.content 不应该包含 tool_result;忽略
|
|
||||||
continue
|
continue
|
||||||
if isinstance(b, UnknownBlock):
|
if isinstance(b, UnknownBlock):
|
||||||
continue
|
continue
|
||||||
|
# TextBlock, ImageBlock, FileBlock, AudioBlock -> content
|
||||||
content_blocks.append(b)
|
content_blocks.append(b)
|
||||||
return thinking_blocks, content_blocks, tool_blocks
|
return thinking_blocks, content_blocks, tool_blocks
|
||||||
|
|
||||||
@@ -1495,38 +1713,6 @@ class OpenAINormalizer(FormatNormalizer):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return ErrorType.UNKNOWN
|
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:
|
def _ensure_tool_block_index(self, ss: dict[str, Any], tool_key: str) -> int:
|
||||||
mapping = ss.get("tool_id_to_block_index")
|
mapping = ss.get("tool_id_to_block_index")
|
||||||
if not isinstance(mapping, dict):
|
if not isinstance(mapping, dict):
|
||||||
|
|||||||
@@ -1343,22 +1343,6 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
return "tool"
|
return "tool"
|
||||||
return "user"
|
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:
|
def _coerce_str_list(self, value: Any) -> list[str] | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from contextlib import contextmanager
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from src.core.api_format.conversion.exceptions import FormatConversionError
|
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.internal import InternalRequest, ToolResultBlock, ToolUseBlock
|
||||||
from src.core.api_format.conversion.normalizer import FormatNormalizer
|
from src.core.api_format.conversion.normalizer import FormatNormalizer
|
||||||
from src.core.api_format.conversion.stream_state import StreamState
|
from src.core.api_format.conversion.stream_state import StreamState
|
||||||
@@ -104,6 +105,7 @@ class FormatConversionRegistry:
|
|||||||
target_format: str,
|
target_format: str,
|
||||||
*,
|
*,
|
||||||
target_variant: str | None = None,
|
target_variant: str | None = None,
|
||||||
|
output_limit: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if str(source_format).upper() == str(target_format).upper() and not target_variant:
|
if str(source_format).upper() == str(target_format).upper() and not target_variant:
|
||||||
return request
|
return request
|
||||||
@@ -116,11 +118,43 @@ class FormatConversionRegistry:
|
|||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
internal = src.request_to_internal(request)
|
internal = src.request_to_internal(request)
|
||||||
|
internal.output_limit = output_limit
|
||||||
self._repair_internal_tool_call_ids(internal)
|
self._repair_internal_tool_call_ids(internal)
|
||||||
return tgt.request_from_internal(internal, target_variant=target_variant)
|
return tgt.request_from_internal(internal, target_variant=target_variant)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise FormatConversionError(source_format, target_format, str(e)) from 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(
|
def convert_response(
|
||||||
self,
|
self,
|
||||||
response: dict[str, Any],
|
response: dict[str, Any],
|
||||||
|
|||||||
@@ -20,41 +20,9 @@ from src.services.provider.adapters.kiro.constants import (
|
|||||||
|
|
||||||
|
|
||||||
def map_model(model: str) -> str | None:
|
def map_model(model: str) -> str | None:
|
||||||
"""Map an Anthropic model name to a Kiro-compatible model ID.
|
"""Pass through the model name as-is to Kiro upstream."""
|
||||||
|
|
||||||
Kiro upstream expects specific model IDs (e.g. ``claude-sonnet-4.5``).
|
|
||||||
If *model* already matches a Kiro ID it is returned as-is; otherwise we
|
|
||||||
attempt a best-effort fuzzy mapping. Returns ``None`` when the model is
|
|
||||||
unrecognised.
|
|
||||||
"""
|
|
||||||
raw = str(model or "").strip()
|
raw = str(model or "").strip()
|
||||||
if not raw:
|
return raw or None
|
||||||
return None
|
|
||||||
|
|
||||||
model_lower = raw.lower()
|
|
||||||
|
|
||||||
# Already a Kiro-native ID?
|
|
||||||
_KIRO_NATIVE_IDS = {
|
|
||||||
"claude-sonnet-4.5",
|
|
||||||
"claude-opus-4.5",
|
|
||||||
"claude-opus-4.6",
|
|
||||||
"claude-haiku-4.5",
|
|
||||||
}
|
|
||||||
if model_lower in _KIRO_NATIVE_IDS:
|
|
||||||
return model_lower
|
|
||||||
|
|
||||||
# Fuzzy mapping from Anthropic-style model names
|
|
||||||
if "sonnet" in model_lower:
|
|
||||||
return "claude-sonnet-4.5"
|
|
||||||
if "opus" in model_lower:
|
|
||||||
if "4-5" in model_lower or "4.5" in model_lower:
|
|
||||||
return "claude-opus-4.5"
|
|
||||||
return "claude-opus-4.6"
|
|
||||||
if "haiku" in model_lower:
|
|
||||||
return "claude-haiku-4.5"
|
|
||||||
|
|
||||||
# Unrecognised — pass through as-is (let the upstream decide)
|
|
||||||
return raw
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_session_id(user_id: str) -> str | None:
|
def _extract_session_id(user_id: str) -> str | None:
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ PRESET_MODELS: dict[str, list[dict[str, Any]]] = {
|
|||||||
"owned_by": "anthropic",
|
"owned_by": "anthropic",
|
||||||
"display_name": "Claude Sonnet 4.5",
|
"display_name": "Claude Sonnet 4.5",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "claude-sonnet-4.6",
|
||||||
|
"object": "model",
|
||||||
|
"owned_by": "anthropic",
|
||||||
|
"display_name": "Claude Sonnet 4.6",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "claude-opus-4.5",
|
"id": "claude-opus-4.5",
|
||||||
"object": "model",
|
"object": "model",
|
||||||
|
|||||||
@@ -386,6 +386,17 @@ class CandidateBuilder:
|
|||||||
client_format_str = normalize_endpoint_signature(client_format)
|
client_format_str = normalize_endpoint_signature(client_format)
|
||||||
client_sig = parse_signature_key(client_format_str)
|
client_sig = parse_signature_key(client_format_str)
|
||||||
client_family, client_kind = client_sig.api_family, client_sig.endpoint_kind
|
client_family, client_kind = client_sig.api_family, client_sig.endpoint_kind
|
||||||
|
|
||||||
|
# 提取 GlobalModel 配置的 output_limit(用于跨格式转换时的 max_tokens 默认值)
|
||||||
|
output_limit: int | None = None
|
||||||
|
normalized_name = model_name.strip() if isinstance(model_name, str) else ""
|
||||||
|
if normalized_name:
|
||||||
|
gm = await ModelCacheService.get_global_model_by_name(db, normalized_name)
|
||||||
|
if gm and isinstance(gm.config, dict):
|
||||||
|
raw = gm.config.get("output_limit")
|
||||||
|
if isinstance(raw, int) and raw > 0:
|
||||||
|
output_limit = raw
|
||||||
|
|
||||||
# chat/cli 互相可回退(用于同协议族下的端点变体),video/image 等不跨类回退
|
# chat/cli 互相可回退(用于同协议族下的端点变体),video/image 等不跨类回退
|
||||||
if client_kind in {EndpointKind.CHAT, EndpointKind.CLI}:
|
if client_kind in {EndpointKind.CHAT, EndpointKind.CLI}:
|
||||||
allowed_kinds = {EndpointKind.CHAT, EndpointKind.CLI}
|
allowed_kinds = {EndpointKind.CHAT, EndpointKind.CLI}
|
||||||
@@ -581,6 +592,7 @@ class CandidateBuilder:
|
|||||||
mapping_matched_model=mapping_matched_model,
|
mapping_matched_model=mapping_matched_model,
|
||||||
needs_conversion=needs_conversion,
|
needs_conversion=needs_conversion,
|
||||||
provider_api_format=str(endpoint_format_str or ""),
|
provider_api_format=str(endpoint_format_str or ""),
|
||||||
|
output_limit=output_limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
if needs_conversion:
|
if needs_conversion:
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class ProviderCandidate:
|
|||||||
mapping_matched_model: str | None = None # 通过映射匹配到的模型名(用于实际请求)
|
mapping_matched_model: str | None = None # 通过映射匹配到的模型名(用于实际请求)
|
||||||
needs_conversion: bool = False # 是否需要格式转换
|
needs_conversion: bool = False # 是否需要格式转换
|
||||||
provider_api_format: str = "" # Provider 端点实际格式(用于健康度/熔断 bucket)
|
provider_api_format: str = "" # Provider 端点实际格式(用于健康度/熔断 bucket)
|
||||||
|
output_limit: int | None = None # GlobalModel 配置的模型输出上限
|
||||||
|
|
||||||
def _stable_order_key(self) -> tuple[int, int, str, str, str]:
|
def _stable_order_key(self) -> tuple[int, int, str, str, str]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user