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:
fawney19
2026-02-19 10:40:49 +08:00
parent 11f2ddbba2
commit 0d2cafaec3
20 changed files with 1405 additions and 234 deletions

View File

@@ -706,11 +706,12 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# 跨格式:先做请求体转换(失败触发 failover
registry = get_format_converter_registry()
if needs_conversion:
request_body = registry.convert_request(
request_body = await registry.convert_request_async(
request_body,
str(client_api_format),
str(provider_api_format),
target_variant=cross_format_variant,
output_limit=candidate.output_limit if candidate else None,
)
# 格式转换后,为需要 model 字段的格式设置模型名
self._set_model_after_conversion(

View File

@@ -155,7 +155,7 @@ class CliHandlerProtocol(Protocol):
mapped_model: str | None,
) -> str | None: ...
def _convert_request_for_cross_format(
async def _convert_request_for_cross_format(
self,
request_body: dict[str, Any],
client_api_format: str,
@@ -165,6 +165,7 @@ class CliHandlerProtocol(Protocol):
is_stream: bool,
*,
target_variant: str | None = ...,
output_limit: int | None = ...,
) -> tuple[dict[str, Any], str]: ...
def _extract_response_metadata(

View File

@@ -228,7 +228,7 @@ class CliRequestMixin:
stream_options["include_usage"] = True
request_body["stream_options"] = stream_options
def _convert_request_for_cross_format(
async def _convert_request_for_cross_format(
self,
request_body: dict[str, Any],
client_api_format: str,
@@ -238,6 +238,7 @@ class CliRequestMixin:
is_stream: bool,
*,
target_variant: str | None = None,
output_limit: int | None = None,
) -> tuple[dict[str, Any], str]:
"""
跨格式请求转换的公共逻辑
@@ -252,16 +253,18 @@ class CliRequestMixin:
fallback_model: 备用模型名(通常是原始请求的 model
is_stream: 是否流式请求
target_variant: 目标变体(如 "codex"),用于同格式但有细微差异的上游
output_limit: GlobalModel 配置的模型输出上限
Returns:
(转换后的请求体, 用于 URL 的模型名)
"""
registry = get_format_converter_registry()
converted_body = registry.convert_request(
converted_body = await registry.convert_request_async(
request_body,
str(client_api_format),
str(provider_api_format),
target_variant=target_variant,
output_limit=output_limit,
)
# 先计算 URL 模型(在清理 body 中的 model 字段之前)

View File

@@ -317,7 +317,7 @@ class CliStreamMixin:
# 跨格式:先做请求体转换(失败触发 failover
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,
client_api_format,
provider_api_format,
@@ -325,6 +325,7 @@ class CliStreamMixin:
ctx.model,
is_stream=upstream_is_stream,
target_variant=conversion_variant,
output_limit=candidate.output_limit if candidate else None,
)
else:
# 同格式:按原逻辑做轻量清理(子类可覆盖)

View File

@@ -150,7 +150,7 @@ class CliSyncMixin:
# 跨格式:先做请求体转换(失败触发 failover
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,
client_api_format,
provider_api_format,
@@ -158,6 +158,7 @@ class CliSyncMixin:
model,
is_stream=upstream_is_stream,
target_variant=conversion_variant,
output_limit=candidate.output_limit if candidate else None,
)
else:
# 同格式:按原逻辑做轻量清理(子类可覆盖)

View File

@@ -30,6 +30,8 @@ STOP_REASON_MAPPINGS: dict[str, dict[str, str]] = {
"max_tokens": "max_tokens",
"stop_sequence": "stop_sequence",
"tool_use": "tool_use",
"pause_turn": "end_turn",
"refusal": "end_turn",
# Claude 通常以错误/阻断体现,这里仅兜底
"content_filtered": "end_turn",
"unknown": "end_turn",
@@ -40,6 +42,8 @@ STOP_REASON_MAPPINGS: dict[str, dict[str, str]] = {
"stop_sequence": "stop",
"tool_use": "tool_calls",
"content_filtered": "content_filter",
"refusal": "content_filter",
"pause_turn": "stop",
"unknown": "stop",
},
"GEMINI": {
@@ -49,6 +53,8 @@ STOP_REASON_MAPPINGS: dict[str, dict[str, str]] = {
# Gemini finishReason 对工具调用并没有稳定等价枚举,这里保守兜底为 STOP
"tool_use": "STOP",
"content_filtered": "SAFETY",
"refusal": "SAFETY",
"pause_turn": "OTHER",
"unknown": "OTHER",
},
}
@@ -118,10 +124,77 @@ RETRYABLE_ERROR_TYPES: set[str] = {
}
# OpenAI reasoning_effort -> thinking budget_tokens
# 参考 new-api relay-claude.go:178-196
REASONING_EFFORT_TO_THINKING_BUDGET: dict[str, int] = {
"low": 1280,
"medium": 2048,
"high": 4096,
}
# thinking budget_tokens -> OpenAI reasoning_effort反向映射取最近区间
THINKING_BUDGET_TO_REASONING_EFFORT: list[tuple[int, str]] = [
(1664, "low"), # <= 1664 -> low (midpoint of 1280..2048)
(3072, "medium"), # <= 3072 -> medium (midpoint of 2048..4096)
(2**31, "high"), # > 3072 -> high
]
# OpenAI web_search_options.search_context_size -> Claude web_search max_uses
WEB_SEARCH_CONTEXT_SIZE_TO_MAX_USES: dict[str, int] = {
"low": 1,
"medium": 5,
"high": 10,
}
# Claude max_tokens 兜底默认值(仅在 GlobalModel.output_limit 和请求 max_tokens 均为空时使用)
# 参考 new-api setting/model_setting/claude.go 的 DefaultMaxTokens["default"]
CLAUDE_DEFAULT_MAX_TOKENS: int = 8192
def get_claude_default_max_tokens(_model: str) -> int:
"""获取 Claude 的 max_tokens 兜底默认值。
正常情况下应优先使用 GlobalModel.config.output_limit通过 InternalRequest.output_limit 传入),
此函数仅在 output_limit 不可用时作为最终兜底。
"""
return CLAUDE_DEFAULT_MAX_TOKENS
# thinking budget_tokens 占 max_tokens 的比例(参考 new-api: 0.8
THINKING_BUDGET_TOKENS_PERCENTAGE: float = 0.8
# thinking budget_tokens 最小值Claude API 要求 >= 1024
THINKING_BUDGET_TOKENS_MIN: int = 1280
def get_claude_default_thinking_budget(model: str) -> int:
"""根据模型名称计算 thinking budget_tokens 默认值。
budget = max(max_tokens * THINKING_BUDGET_TOKENS_PERCENTAGE, THINKING_BUDGET_TOKENS_MIN)
"""
max_tokens = get_claude_default_max_tokens(model)
return max(int(max_tokens * THINKING_BUDGET_TOKENS_PERCENTAGE), THINKING_BUDGET_TOKENS_MIN)
# 跨格式 thinking 转换时,非 Claude 模型的默认 budget_tokens 安全值
CROSS_FORMAT_THINKING_BUDGET_DEFAULT: int = 8192
__all__ = [
"ROLE_MAPPINGS",
"STOP_REASON_MAPPINGS",
"USAGE_FIELD_MAPPINGS",
"ERROR_TYPE_MAPPINGS",
"RETRYABLE_ERROR_TYPES",
"REASONING_EFFORT_TO_THINKING_BUDGET",
"THINKING_BUDGET_TO_REASONING_EFFORT",
"WEB_SEARCH_CONTEXT_SIZE_TO_MAX_USES",
"CLAUDE_DEFAULT_MAX_TOKENS",
"THINKING_BUDGET_TOKENS_PERCENTAGE",
"THINKING_BUDGET_TOKENS_MIN",
"CROSS_FORMAT_THINKING_BUDGET_DEFAULT",
"get_claude_default_max_tokens",
"get_claude_default_thinking_budget",
]

View 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"]

View File

@@ -28,6 +28,8 @@ class ContentType(str, Enum):
TEXT = "text"
THINKING = "thinking"
IMAGE = "image"
FILE = "file"
AUDIO = "audio"
TOOL_USE = "tool_use"
TOOL_RESULT = "tool_result"
UNKNOWN = "unknown"
@@ -115,6 +117,30 @@ class ToolResultBlock:
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class FileBlock:
"""文件内容块PDF、文档等"""
type: ContentType = field(default=ContentType.FILE, init=False)
data: str | None = None # base64 编码
media_type: str | None = None
file_id: str | None = None # OpenAI file reference
file_url: str | None = None # Gemini fileData URI
filename: str | None = None
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class AudioBlock:
"""音频内容块"""
type: ContentType = field(default=ContentType.AUDIO, init=False)
data: str | None = None # base64 编码
media_type: str | None = None # 完整 MIME如 audio/mp3
format: str | None = None # 简短格式名(如 mp3, wav
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class UnknownBlock:
"""未知内容块(用于前向兼容)"""
@@ -126,7 +152,14 @@ class UnknownBlock:
ContentBlock = (
TextBlock | ThinkingBlock | ImageBlock | ToolUseBlock | ToolResultBlock | UnknownBlock
TextBlock
| ThinkingBlock
| ImageBlock
| FileBlock
| AudioBlock
| ToolUseBlock
| ToolResultBlock
| UnknownBlock
)
@@ -174,6 +207,24 @@ class InstructionSegment:
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class ThinkingConfig:
"""统一的思考/推理配置(对齐 Claude thinking / Gemini thinkingConfig / OpenAI reasoning_effort"""
enabled: bool = False
budget_tokens: int | None = None # None = provider 默认
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class ResponseFormatConfig:
"""统一的响应格式配置JSON mode / structured output"""
type: str = "text" # "text" | "json_object" | "json_schema"
json_schema: dict[str, Any] | None = None
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class InternalRequest:
"""统一的请求表示"""
@@ -195,6 +246,27 @@ class InternalRequest:
stream: bool = False
tools: list[ToolDefinition] | None = None
tool_choice: ToolChoice | None = None # auto/none/required 或指定 tool_name
# 思考/推理配置
thinking: ThinkingConfig | None = None
# 并行工具调用控制
parallel_tool_calls: bool | None = None
# 采样参数
n: int | None = None
presence_penalty: float | None = None
frequency_penalty: float | None = None
seed: int | None = None
logprobs: bool | None = None
top_logprobs: int | None = None
# 响应格式
response_format: ResponseFormatConfig | None = None
# 模型输出上限(来自 GlobalModel.config.output_limit用于跨格式转换时的 max_tokens 默认值)
output_limit: int | None = None
extra: dict[str, Any] = field(default_factory=dict) # 未识别字段透传
def to_debug_dict(self) -> dict[str, Any]:
@@ -293,6 +365,8 @@ __all__ = [
"TextBlock",
"ThinkingBlock",
"ImageBlock",
"FileBlock",
"AudioBlock",
"ToolUseBlock",
"ToolResultBlock",
"UnknownBlock",
@@ -301,6 +375,8 @@ __all__ = [
"InstructionSegment",
"ToolDefinition",
"ToolChoice",
"ThinkingConfig",
"ResponseFormatConfig",
"InternalRequest",
"UsageInfo",
"InternalResponse",

View File

@@ -115,6 +115,40 @@ class FormatNormalizer(ABC):
return ImageBlock(data=data, media_type=media_type)
return ImageBlock(url=url)
# ============ 通用解析工具 ============
def _optional_int(self, value: Any) -> int | None:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _optional_float(self, value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _coerce_str_list(self, value: Any) -> list[str] | None:
if value is None:
return None
if isinstance(value, str):
return [value]
if isinstance(value, list):
return [str(x) for x in value if x is not None]
return None
def _extract_extra(self, payload: dict[str, Any], known_keys: set[str]) -> dict[str, Any]:
return {k: v for k, v in payload.items() if k not in known_keys}
def _merge_dropped(self, target: dict[str, int], source: dict[str, int]) -> None:
for k, v in source.items():
target[k] = target.get(k, 0) + int(v)
# ============ 视频转换(可选) ============
def video_request_to_internal(self, request: dict[str, Any]) -> InternalVideoRequest:

View File

@@ -14,12 +14,18 @@ from src.core.api_format.conversion.field_mappings import (
ERROR_TYPE_MAPPINGS,
RETRYABLE_ERROR_TYPES,
STOP_REASON_MAPPINGS,
THINKING_BUDGET_TOKENS_MIN,
THINKING_BUDGET_TOKENS_PERCENTAGE,
USAGE_FIELD_MAPPINGS,
WEB_SEARCH_CONTEXT_SIZE_TO_MAX_USES,
get_claude_default_max_tokens,
)
from src.core.api_format.conversion.internal import (
AudioBlock,
ContentBlock,
ContentType,
ErrorType,
FileBlock,
FormatCapabilities,
ImageBlock,
InstructionSegment,
@@ -31,6 +37,7 @@ from src.core.api_format.conversion.internal import (
StopReason,
TextBlock,
ThinkingBlock,
ThinkingConfig,
ToolChoice,
ToolChoiceType,
ToolDefinition,
@@ -97,9 +104,12 @@ class ClaudeNormalizer(FormatNormalizer):
# 顶层 system 先进入 instructions保持确定性优先级
sys_value = request.get("system")
sys_text, sys_dropped = self._collapse_claude_system(sys_value)
sys_text, sys_dropped, sys_segments = self._collapse_claude_system(sys_value)
self._merge_dropped(dropped, sys_dropped)
if sys_text:
if sys_segments:
# 数组格式含 cache_control保留逐段 InstructionSegment
instructions.extend(sys_segments)
elif sys_text:
instructions.append(InstructionSegment(role=Role.SYSTEM, text=sys_text))
messages: list[InternalMessage] = []
@@ -112,7 +122,7 @@ class ClaudeNormalizer(FormatNormalizer):
# 兼容:少数客户端可能把 system/developer 混进 messages[]
if role in ("system", "developer"):
text, md = self._collapse_claude_system(msg.get("content"))
text, md, _ = self._collapse_claude_system(msg.get("content"))
self._merge_dropped(dropped, md)
if text:
instructions.append(
@@ -134,6 +144,19 @@ class ClaudeNormalizer(FormatNormalizer):
tools = self._claude_tools_to_internal(request.get("tools"))
tool_choice = self._claude_tool_choice_to_internal(request.get("tool_choice"))
# 解析 Claude 原生 thinking 配置
thinking: ThinkingConfig | None = None
thinking_raw = request.get("thinking")
if isinstance(thinking_raw, dict):
thinking_type = str(thinking_raw.get("type") or "")
if thinking_type in ("enabled", "adaptive"):
budget = self._optional_int(thinking_raw.get("budget_tokens"))
thinking = ThinkingConfig(
enabled=True,
budget_tokens=budget,
extra={"claude_thinking": thinking_raw},
)
internal = InternalRequest(
model=model,
messages=messages,
@@ -147,6 +170,7 @@ class ClaudeNormalizer(FormatNormalizer):
stream=bool(request.get("stream") or False),
tools=tools,
tool_choice=tool_choice,
thinking=thinking,
extra={"claude": self._extract_extra(request, {"messages"})},
)
@@ -161,7 +185,26 @@ class ClaudeNormalizer(FormatNormalizer):
*,
target_variant: str | None = None,
) -> dict[str, Any]:
system_text = internal.system or self._join_instructions(internal.instructions)
# system: 如果任一 InstructionSegment 有 cache_control输出数组格式
has_cache_control = any(seg.extra.get("cache_control") for seg in internal.instructions)
if has_cache_control and internal.instructions:
system_value: str | list[dict[str, Any]] | None = [
{
"type": "text",
"text": seg.text,
**(
{"cache_control": seg.extra["cache_control"]}
if seg.extra.get("cache_control")
else {}
),
}
for seg in internal.instructions
if seg.text
]
if not system_value:
system_value = None
else:
system_value = internal.system or self._join_instructions(internal.instructions)
# Claude Messages API: messages[] 仅允许 user/assistant且需要交替这里做最小修复
fixed_messages = self._coerce_claude_message_sequence(internal.messages)
@@ -170,14 +213,23 @@ class ClaudeNormalizer(FormatNormalizer):
self._internal_message_to_claude(m) for m in fixed_messages
]
# max_tokens: 优先使用请求中的值, 其次 GlobalModel.output_limit, 最后硬编码默认值
effective_max_tokens: int
if internal.max_tokens is not None:
effective_max_tokens = internal.max_tokens
elif internal.output_limit is not None:
effective_max_tokens = internal.output_limit
else:
effective_max_tokens = get_claude_default_max_tokens(internal.model)
result: dict[str, Any] = {
"model": internal.model,
"messages": out_messages,
"max_tokens": internal.max_tokens if internal.max_tokens is not None else 4096,
"max_tokens": effective_max_tokens,
}
if system_text:
result["system"] = system_text
if system_value:
result["system"] = system_value
if internal.temperature is not None:
result["temperature"] = internal.temperature
@@ -191,18 +243,75 @@ class ClaudeNormalizer(FormatNormalizer):
result["stream"] = True
if internal.tools:
result["tools"] = [
{
claude_tools: list[dict[str, Any]] = []
for t in internal.tools:
# 跳过 Gemini 内置工具(在 Claude 中无对应物)
if t.extra.get("gemini_builtin_tool"):
continue
tool_def: dict[str, Any] = {
"name": t.name,
"description": t.description,
"input_schema": t.parameters or {},
**(t.extra.get("claude") or {}),
}
for t in internal.tools
]
if t.description is not None:
tool_def["description"] = t.description
claude_tools.append(tool_def)
if claude_tools:
result["tools"] = claude_tools
if internal.tool_choice:
result["tool_choice"] = self._tool_choice_to_claude(internal.tool_choice)
tc = self._tool_choice_to_claude(internal.tool_choice)
# parallel_tool_calls=False -> disable_parallel_tool_use=True
if internal.parallel_tool_calls is False and tc.get("type") != "none":
tc["disable_parallel_tool_use"] = True
result["tool_choice"] = tc
# thinking 配置
if internal.thinking and internal.thinking.enabled:
# 优先使用原始 Claude thinking 配置round-trip 透传)
claude_thinking = internal.thinking.extra.get("claude_thinking")
if isinstance(claude_thinking, dict):
result["thinking"] = claude_thinking
else:
thinking_out: dict[str, Any] = {"type": "enabled"}
# Claude API 要求 budget_tokens 必须提供
# 跨格式时 internal.model 可能是非 Claude 模型名,
# 仅当模型名以 claude- 开头时用模型感知默认值,否则用固定安全值
if internal.thinking.budget_tokens is not None:
thinking_out["budget_tokens"] = internal.thinking.budget_tokens
else:
# 基于 effective_max_tokens 计算兜底 budget避免覆盖用户显式指定的 max_tokens
thinking_out["budget_tokens"] = max(
int(effective_max_tokens * THINKING_BUDGET_TOKENS_PERCENTAGE),
THINKING_BUDGET_TOKENS_MIN,
)
# 确保 budget_tokens >= 最小值(参考 new-api: 1280
bt = thinking_out["budget_tokens"]
if bt < THINKING_BUDGET_TOKENS_MIN:
thinking_out["budget_tokens"] = THINKING_BUDGET_TOKENS_MIN
bt = THINKING_BUDGET_TOKENS_MIN
# Claude API 要求 budget_tokens < max_tokens确保不违反约束
max_t = result.get("max_tokens")
if max_t is not None and bt >= max_t:
result["max_tokens"] = bt + 1
result["thinking"] = thinking_out
# web_search_options -> Claude web_search tool
web_search_opts = internal.extra.get("web_search_options") if internal.extra else None
if isinstance(web_search_opts, dict):
context_size = str(web_search_opts.get("search_context_size") or "medium")
max_uses = WEB_SEARCH_CONTEXT_SIZE_TO_MAX_USES.get(context_size, 5)
ws_tool: dict[str, Any] = {
"type": "web_search_20250305",
"name": "web_search",
"max_uses": max_uses,
}
user_location = web_search_opts.get("user_location")
if isinstance(user_location, dict):
ws_tool["user_location"] = user_location
if "tools" not in result:
result["tools"] = []
result["tools"].append(ws_tool)
# 恢复 Claude 特有字段(如 metadata
claude_extra = internal.extra.get("claude") if isinstance(internal.extra, dict) else None
@@ -296,7 +405,45 @@ class ClaudeNormalizer(FormatNormalizer):
}
)
elif b.url:
content.append({"type": "text", "text": f"[Image: {b.url}]"})
content.append(
{
"type": "image",
"source": {"type": "url", "url": b.url},
}
)
continue
if isinstance(b, FileBlock):
if b.data and b.media_type:
content.append(
{
"type": "document",
"source": {
"type": "base64",
"media_type": b.media_type,
"data": b.data,
},
}
)
elif b.file_url:
content.append(
{
"type": "document",
"source": {"type": "url", "url": b.file_url},
}
)
continue
if isinstance(b, AudioBlock):
if b.data and b.media_type:
content.append(
{
"type": "document",
"source": {
"type": "base64",
"media_type": b.media_type,
"data": b.data,
},
}
)
continue
# Unknown/ToolResult 默认丢弃
@@ -740,9 +887,12 @@ class ClaudeNormalizer(FormatNormalizer):
if btype == "text":
text = str(block.get("text") or "")
if text:
blocks.append(
TextBlock(text=text, extra=self._extract_extra(block, {"type", "text"}))
)
block_extra = self._extract_extra(block, {"type", "text"})
# cache_control 透传
cc = block.get("cache_control")
if isinstance(cc, dict):
block_extra["cache_control"] = cc
blocks.append(TextBlock(text=text, extra=block_extra))
continue
if btype == "image":
@@ -760,10 +910,42 @@ class ClaudeNormalizer(FormatNormalizer):
):
blocks.append(ImageBlock(data=data, media_type=media_type))
continue
elif stype == "url":
url = src.get("url")
if isinstance(url, str) and url:
blocks.append(ImageBlock(url=url))
continue
dropped["claude_image_unsupported"] = dropped.get("claude_image_unsupported", 0) + 1
blocks.append(UnknownBlock(raw_type="image", payload=block))
continue
if btype == "document":
doc_src = block.get("source") or {}
if isinstance(doc_src, dict):
doc_stype = doc_src.get("type")
if doc_stype == "base64":
blocks.append(
FileBlock(
data=doc_src.get("data"),
media_type=doc_src.get("media_type"),
extra=self._extract_extra(block, {"type", "source"}),
)
)
continue
elif doc_stype == "url":
blocks.append(
FileBlock(
file_url=doc_src.get("url"),
media_type=doc_src.get("media_type"),
extra=self._extract_extra(block, {"type", "source"}),
)
)
continue
dropped_key = f"claude_block:{btype}"
dropped[dropped_key] = dropped.get(dropped_key, 0) + 1
blocks.append(UnknownBlock(raw_type=btype, payload=block))
continue
if btype == "tool_use":
tool_id = str(block.get("id") or "")
tool_name = str(block.get("name") or "")
@@ -862,15 +1044,21 @@ class ClaudeNormalizer(FormatNormalizer):
extra={"claude": raw_block},
)
def _collapse_claude_system(self, system_value: Any) -> tuple[str | None, dict[str, int]]:
def _collapse_claude_system(
self, system_value: Any
) -> tuple[str | None, dict[str, int], list[InstructionSegment] | None]:
"""解析 Claude system 字段。返回 (text, dropped, segments)。
segments 仅在 system 为数组且含 cache_control 时非空。"""
dropped: dict[str, int] = {}
if system_value is None:
return None, dropped
return None, dropped, None
if isinstance(system_value, str):
return (system_value or None), dropped
return (system_value or None), dropped, None
if isinstance(system_value, list):
texts: list[str] = []
segments: list[InstructionSegment] = []
has_cache_control = False
for item in system_value:
if not isinstance(item, dict):
dropped["claude_system_item_non_dict"] = (
@@ -881,14 +1069,26 @@ class ClaudeNormalizer(FormatNormalizer):
text = item.get("text")
if text:
texts.append(str(text))
seg_extra: dict[str, Any] = {}
cc = item.get("cache_control")
if isinstance(cc, dict):
seg_extra["cache_control"] = cc
has_cache_control = True
segments.append(
InstructionSegment(role=Role.SYSTEM, text=str(text), extra=seg_extra)
)
else:
dropped_key = f"claude_system_item:{item.get('type')}"
dropped[dropped_key] = dropped.get(dropped_key, 0) + 1
joined = "\n\n".join(texts)
return (joined or None), dropped
return (
(joined or None),
dropped,
segments if has_cache_control else None,
)
dropped["claude_system_unsupported"] = dropped.get("claude_system_unsupported", 0) + 1
return None, dropped
return None, dropped, None
def _join_instructions(self, instructions: list[InstructionSegment]) -> str | None:
parts = [seg.text for seg in instructions if seg.text]
@@ -957,6 +1157,11 @@ class ClaudeNormalizer(FormatNormalizer):
def _internal_message_to_claude(self, msg: InternalMessage) -> dict[str, Any]:
role = "user" if msg.role == Role.USER else "assistant"
# 预扫描:如果任一 TextBlock 带 cache_control所有 TextBlock 都走结构化路径以保持顺序
force_structured_text = any(
isinstance(b, TextBlock) and b.extra.get("cache_control") for b in msg.content
)
blocks: list[dict[str, Any]] = []
text_parts: list[str] = []
@@ -966,7 +1171,14 @@ class ClaudeNormalizer(FormatNormalizer):
if isinstance(b, TextBlock):
if b.text:
text_parts.append(b.text)
if force_structured_text:
text_block: dict[str, Any] = {"type": "text", "text": b.text}
cc = b.extra.get("cache_control") if b.extra else None
if isinstance(cc, dict):
text_block["cache_control"] = cc
blocks.append(text_block)
else:
text_parts.append(b.text)
continue
if isinstance(b, ImageBlock):
@@ -989,7 +1201,52 @@ class ClaudeNormalizer(FormatNormalizer):
}
)
elif b.url:
text_parts.append(f"[Image: {b.url}]")
blocks.append(
{
"type": "image",
"source": {"type": "url", "url": b.url},
}
)
continue
if isinstance(b, FileBlock):
if b.data and b.media_type:
blocks.append(
{
"type": "document",
"source": {
"type": "base64",
"media_type": b.media_type,
"data": b.data,
},
}
)
elif b.file_url:
blocks.append(
{
"type": "document",
"source": {
"type": "url",
"url": b.file_url,
},
}
)
elif b.file_id:
text_parts.append(f"[File: {b.file_id}]")
continue
if isinstance(b, AudioBlock):
if b.data and b.media_type:
blocks.append(
{
"type": "document",
"source": {
"type": "base64",
"media_type": b.media_type,
"data": b.data,
},
}
)
continue
if isinstance(b, ToolUseBlock) and role == "assistant":
@@ -1062,7 +1319,11 @@ class ClaudeNormalizer(FormatNormalizer):
mapping = USAGE_FIELD_MAPPINGS.get("CLAUDE", {})
fields: dict[str, int] = {}
extra = self._extract_extra(usage, set(mapping.keys()))
extra = self._extract_extra(
usage,
set(mapping.keys())
| {"cache_creation_input_tokens_5m", "cache_creation_input_tokens_1h"},
)
for provider_key, internal_key in mapping.items():
if provider_key in usage and usage.get(provider_key) is not None:
@@ -1076,6 +1337,21 @@ class ClaudeNormalizer(FormatNormalizer):
fields.get("input_tokens", 0) + fields.get("output_tokens", 0)
)
# Claude cache_creation 5m/1h 细分(存入 extra
cache_details: dict[str, int] = {}
for detail_key in (
"cache_creation_input_tokens_5m",
"cache_creation_input_tokens_1h",
):
val = usage.get(detail_key)
if val is not None:
try:
cache_details[detail_key] = int(val)
except (TypeError, ValueError):
pass
if cache_details:
extra["cache_creation_details"] = cache_details
return UsageInfo(
input_tokens=int(fields.get("input_tokens", 0)),
output_tokens=int(fields.get("output_tokens", 0)),
@@ -1094,6 +1370,16 @@ class ClaudeNormalizer(FormatNormalizer):
result["cache_read_input_tokens"] = int(usage.cache_read_tokens)
if usage.cache_write_tokens:
result["cache_creation_input_tokens"] = int(usage.cache_write_tokens)
# 回写 5m/1h 细分
claude_extra = usage.extra.get("claude", {})
if isinstance(claude_extra, dict):
cache_details = claude_extra.get("cache_creation_details")
if isinstance(cache_details, dict):
for k, v in cache_details.items():
try:
result[k] = int(v)
except (TypeError, ValueError):
pass
return result
def _error_type_from_value(self, value: str) -> ErrorType:
@@ -1102,37 +1388,5 @@ class ClaudeNormalizer(FormatNormalizer):
except ValueError:
return ErrorType.UNKNOWN
def _optional_int(self, value: Any) -> int | None:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _optional_float(self, value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _coerce_str_list(self, value: Any) -> list[str] | None:
if value is None:
return None
if isinstance(value, str):
return [value]
if isinstance(value, list):
return [str(x) for x in value if x is not None]
return None
def _extract_extra(self, payload: dict[str, Any], known_keys: set[str]) -> dict[str, Any]:
return {k: v for k, v in payload.items() if k not in known_keys}
def _merge_dropped(self, target: dict[str, int], source: dict[str, int]) -> None:
for k, v in source.items():
target[k] = target.get(k, 0) + int(v)
__all__ = ["ClaudeNormalizer"]

View File

@@ -12,6 +12,7 @@ Gemini (GenerateContent / streamGenerateContent) Normalizer
"""
import json
import re
from typing import Any
from src.core.api_format.conversion.field_mappings import (
@@ -21,9 +22,11 @@ from src.core.api_format.conversion.field_mappings import (
USAGE_FIELD_MAPPINGS,
)
from src.core.api_format.conversion.internal import (
AudioBlock,
ContentBlock,
ContentType,
ErrorType,
FileBlock,
FormatCapabilities,
ImageBlock,
InstructionSegment,
@@ -31,10 +34,12 @@ from src.core.api_format.conversion.internal import (
InternalMessage,
InternalRequest,
InternalResponse,
ResponseFormatConfig,
Role,
StopReason,
TextBlock,
ThinkingBlock,
ThinkingConfig,
ToolChoice,
ToolChoiceType,
ToolDefinition,
@@ -131,6 +136,60 @@ def compact_gemini_contents(contents: list[dict[str, Any]]) -> list[dict[str, An
return merged
# Gemini 内置工具名称映射输入侧camelCase/snake_case key -> 标准化 ToolDefinition name
_BUILTIN_TOOL_KEYS: dict[str, str] = {
"googleSearch": "googleSearch",
"google_search": "googleSearch",
"codeExecution": "codeExecution",
"code_execution": "codeExecution",
"urlContext": "urlContext",
"url_context": "urlContext",
}
# Gemini 内置工具名称集合输出侧ToolDefinition.name -> 特殊处理)
_GEMINI_BUILTIN_TOOLS: frozenset[str] = frozenset(
{
"google_search",
"googleSearch",
"code_execution",
"codeExecution",
"url_context",
"urlContext",
}
)
# Markdown 内嵌 base64 图片正则:![alt](data:image/xxx;base64,...)
# 使用 [^)]+ 贪婪匹配至右括号,避免字符类量词的回溯风险
_MD_IMAGE_RE = re.compile(r"!\[[^\]]*\]\(data:(image/[a-zA-Z0-9.+-]+);base64,([^)]+)\)")
def _extract_markdown_images(
text: str,
) -> tuple[str, list[dict[str, Any]]]:
"""从文本中提取 Markdown 内嵌 base64 图片,返回 (剩余文本, inlineData parts)。
如果没有匹配到任何内嵌图片,返回 (原文本, [])。
"""
# 快速预检:避免对纯文本执行正则
if "data:image/" not in text:
return text, []
image_parts: list[dict[str, Any]] = []
def _collect(m: re.Match[str]) -> str:
mime_type = m.group(1)
b64_data = m.group(2).replace("\n", "").replace(" ", "")
image_parts.append({"inline_data": {"mime_type": mime_type, "data": b64_data}})
return ""
remaining = _MD_IMAGE_RE.sub(_collect, text)
if not image_parts:
return text, []
return remaining, image_parts
class GeminiNormalizer(FormatNormalizer):
FORMAT_ID = "gemini:chat"
capabilities = FormatCapabilities(
@@ -226,9 +285,28 @@ class GeminiNormalizer(FormatNormalizer):
# 保留 generationConfig 中的特殊字段responseModalities, thinkingConfig 等)
# 这些字段在 _get_generation_config 中已提取,需要单独存储以便转换时使用
thinking: ThinkingConfig | None = None
n: int | None = None
response_format: ResponseFormatConfig | None = None
if isinstance(generation_config, dict):
response_modalities = generation_config.get("response_modalities")
thinking_config = generation_config.get("thinking_config")
# 解析 thinkingConfig -> ThinkingConfig
if isinstance(thinking_config, dict):
include_thoughts = thinking_config.get(
"include_thoughts", thinking_config.get("includeThoughts")
)
thinking_budget = thinking_config.get(
"thinking_budget", thinking_config.get("thinkingBudget")
)
thinking = ThinkingConfig(
enabled=bool(include_thoughts) if include_thoughts is not None else True,
budget_tokens=self._optional_int(thinking_budget),
extra={"gemini_thinking_config": thinking_config},
)
if response_modalities or thinking_config:
google_extra: dict[str, Any] = {}
if response_modalities:
@@ -237,6 +315,32 @@ class GeminiNormalizer(FormatNormalizer):
google_extra["thinking_config"] = thinking_config
extra["google"] = google_extra
# 解析 candidateCount -> n
n = self._optional_int(
generation_config.get("candidate_count", generation_config.get("candidateCount"))
)
# 解析 response_format (responseMimeType / responseSchema)
resp_mime = generation_config.get(
"response_mime_type", generation_config.get("responseMimeType")
)
resp_schema = generation_config.get(
"response_schema", generation_config.get("responseSchema")
)
if resp_mime or resp_schema:
if resp_schema and isinstance(resp_schema, dict):
response_format = ResponseFormatConfig(
type="json_schema",
json_schema=resp_schema,
extra={"response_mime_type": resp_mime} if resp_mime else {},
)
elif resp_mime and "json" in str(resp_mime).lower():
response_format = ResponseFormatConfig(type="json_object")
elif resp_mime:
response_format = ResponseFormatConfig(
type="text", extra={"response_mime_type": resp_mime}
)
internal = InternalRequest(
model=model,
messages=messages,
@@ -250,6 +354,9 @@ class GeminiNormalizer(FormatNormalizer):
stream=bool(request.get("stream") or False),
tools=tools,
tool_choice=tool_choice,
thinking=thinking,
n=n,
response_format=response_format,
extra=extra,
)
@@ -276,21 +383,36 @@ class GeminiNormalizer(FormatNormalizer):
)
# tools/tool_choice — clean unsupported JSON Schema fields from parameters
# Gemini 特殊内置工具名称需要单独处理
tools = None
if internal.tools:
func_decls: list[dict[str, Any]] = []
builtin_tools: list[dict[str, Any]] = []
for t in internal.tools:
# 检测 Gemini 内置工具
if t.name in _GEMINI_BUILTIN_TOOLS:
canonical = _BUILTIN_TOOL_KEYS.get(t.name, t.name)
builtin_tools.append({canonical: {}})
continue
params = dict(t.parameters) if t.parameters else {}
if params:
_clean_gemini_schema(params)
decl: dict[str, Any] = {
"name": t.name,
"description": t.description,
"parameters": params,
**(t.extra.get("gemini_function_declaration") or {}),
}
if t.description is not None:
decl["description"] = t.description
func_decls.append(decl)
tools = [{"function_declarations": func_decls}]
if func_decls:
tools = [{"function_declarations": func_decls}]
else:
tools = []
tools.extend(builtin_tools)
if not tools:
tools = None
tool_config = None
if internal.tool_choice:
@@ -308,12 +430,40 @@ class GeminiNormalizer(FormatNormalizer):
if internal.stop_sequences:
generation_config["stop_sequences"] = list(internal.stop_sequences)
# 从 internal.thinking 输出 thinkingConfig跨格式转换的标准路径
if (
internal.thinking
and internal.thinking.enabled
and "thinkingConfig" not in generation_config
):
gemini_tc: dict[str, Any] = {"includeThoughts": True}
if internal.thinking.budget_tokens is not None:
gemini_tc["thinkingBudget"] = internal.thinking.budget_tokens
generation_config["thinkingConfig"] = gemini_tc
# 从 internal.response_format 输出 responseMimeType / responseSchema
if internal.response_format and "responseMimeType" not in generation_config:
if (
internal.response_format.type == "json_schema"
and internal.response_format.json_schema
):
generation_config["responseMimeType"] = "application/json"
schema = dict(internal.response_format.json_schema)
_clean_gemini_schema(schema)
generation_config["responseSchema"] = schema
elif internal.response_format.type == "json_object":
generation_config["responseMimeType"] = "application/json"
# 从 internal.n 输出 candidateCount
if internal.n is not None and internal.n > 1 and "candidateCount" not in generation_config:
generation_config["candidateCount"] = internal.n
# 从 internal.extra["google"] 读取 OpenAI extra_body.google 透传的配置
google_extra = internal.extra.get("google", {})
if isinstance(google_extra, dict):
# 处理 thinking_config -> thinkingConfig
# 处理 thinking_config -> thinkingConfig(仅当上面标准路径未设置时)
thinking_config = google_extra.get("thinking_config")
if isinstance(thinking_config, dict):
if isinstance(thinking_config, dict) and "thinkingConfig" not in generation_config:
# snake_case -> camelCase 转换
gemini_thinking: dict[str, Any] = {}
if "thinking_budget" in thinking_config:
@@ -472,6 +622,13 @@ class GeminiNormalizer(FormatNormalizer):
) -> dict[str, Any]:
parts: list[dict[str, Any]] = []
for b in internal.content:
if isinstance(b, ThinkingBlock):
if b.thinking:
thought_part: dict[str, Any] = {"text": b.thinking, "thought": True}
if b.signature:
thought_part["thoughtSignature"] = b.signature
parts.append(thought_part)
continue
if isinstance(b, TextBlock):
if b.text:
parts.append({"text": b.text})
@@ -497,7 +654,28 @@ class GeminiNormalizer(FormatNormalizer):
}
)
elif b.url:
parts.append({"text": f"[Image: {b.url}]"})
parts.append(
{"fileData": {"fileUri": b.url, "mimeType": b.media_type or "image/jpeg"}}
)
continue
if isinstance(b, FileBlock):
if b.data and b.media_type:
parts.append({"inlineData": {"mimeType": b.media_type, "data": b.data}})
elif b.file_url:
parts.append(
{
"fileData": {
"fileUri": b.file_url,
"mimeType": b.media_type or "application/octet-stream",
}
}
)
continue
if isinstance(b, AudioBlock):
if b.data and b.media_type:
parts.append({"inlineData": {"mimeType": b.media_type, "data": b.data}})
elif b.data and b.format:
parts.append({"inlineData": {"mimeType": f"audio/{b.format}", "data": b.data}})
continue
# Unknown/ToolResult 默认丢弃
@@ -1319,7 +1497,15 @@ class GeminiNormalizer(FormatNormalizer):
)
data = inline.get("data")
if isinstance(mime_type, str) and mime_type and isinstance(data, str) and data:
blocks.append(ImageBlock(data=data, media_type=mime_type))
if mime_type.startswith("audio/"):
# 音频 -> AudioBlock
fmt = mime_type.split("/", 1)[1] if "/" in mime_type else None
blocks.append(AudioBlock(data=data, media_type=mime_type, format=fmt))
elif mime_type.startswith("image/"):
blocks.append(ImageBlock(data=data, media_type=mime_type))
else:
# 其他类型 (PDF 等) -> FileBlock
blocks.append(FileBlock(data=data, media_type=mime_type))
else:
dropped["gemini_inline_data_invalid"] = (
dropped.get("gemini_inline_data_invalid", 0) + 1
@@ -1327,6 +1513,27 @@ class GeminiNormalizer(FormatNormalizer):
blocks.append(UnknownBlock(raw_type="inline_data", payload=part))
continue
# fileData / file_data
file_data = part.get("fileData")
if file_data is None:
file_data = part.get("file_data")
if isinstance(file_data, dict):
file_uri = file_data.get("fileUri") or file_data.get("file_uri") or ""
file_mime = file_data.get("mimeType") or file_data.get("mime_type") or ""
if isinstance(file_mime, str) and file_mime.startswith("image/"):
# 图片 MIME -> ImageBlock
blocks.append(ImageBlock(url=str(file_uri), media_type=file_mime))
else:
# 非图片 -> FileBlock
blocks.append(
FileBlock(
file_url=str(file_uri),
media_type=str(file_mime) if file_mime else None,
extra={"gemini": part},
)
)
continue
func_call = part.get("function_call")
if func_call is None:
func_call = part.get("functionCall")
@@ -1457,14 +1664,47 @@ class GeminiNormalizer(FormatNormalizer):
if isinstance(b, TextBlock):
if b.text:
parts.append({"text": b.text})
# 检测 Markdown 内嵌 base64 图片并提取为独立 inlineData part
remaining_text, image_parts = _extract_markdown_images(b.text)
if image_parts:
if remaining_text.strip():
parts.append({"text": remaining_text})
parts.extend(image_parts)
else:
parts.append({"text": b.text})
continue
if isinstance(b, ImageBlock):
if b.data and b.media_type:
parts.append({"inline_data": {"mime_type": b.media_type, "data": b.data}})
elif b.url:
parts.append({"text": f"[Image: {b.url}]"})
# Gemini 支持 fileData URI 引用
parts.append(
{"fileData": {"fileUri": b.url, "mimeType": b.media_type or "image/jpeg"}}
)
continue
if isinstance(b, FileBlock):
if b.data and b.media_type:
parts.append({"inline_data": {"mime_type": b.media_type, "data": b.data}})
elif b.file_url:
parts.append(
{
"fileData": {
"fileUri": b.file_url,
"mimeType": b.media_type or "application/octet-stream",
}
}
)
continue
if isinstance(b, AudioBlock):
if b.data and b.media_type:
parts.append({"inline_data": {"mime_type": b.media_type, "data": b.data}})
elif b.data and b.format:
parts.append(
{"inline_data": {"mime_type": f"audio/{b.format}", "data": b.data}}
)
continue
if isinstance(b, ToolUseBlock) and role == "model":
@@ -1646,6 +1886,19 @@ class GeminiNormalizer(FormatNormalizer):
if thinking_config:
normalized["thinking_config"] = thinking_config
# 保留 candidateCount
candidate_count = pick("candidate_count", "candidateCount")
if candidate_count is not None:
normalized["candidate_count"] = candidate_count
# 保留 responseMimeType / responseSchema
resp_mime = pick("response_mime_type", "responseMimeType")
if resp_mime is not None:
normalized["response_mime_type"] = resp_mime
resp_schema = pick("response_schema", "responseSchema")
if resp_schema is not None:
normalized["response_schema"] = resp_schema
return {k: v for k, v in normalized.items() if v is not None}
def _gemini_tools_to_internal(self, tools: Any) -> list[ToolDefinition] | None:
@@ -1657,35 +1910,49 @@ class GeminiNormalizer(FormatNormalizer):
if not isinstance(tool, dict):
continue
decls = tool.get("function_declarations")
if decls is None:
decls = tool.get("functionDeclarations")
if not isinstance(decls, list):
continue
for decl in decls:
if not isinstance(decl, dict):
continue
name = str(decl.get("name") or "")
if not name:
continue
out.append(
ToolDefinition(
name=name,
description=decl.get("description"),
parameters=(
decl.get("parameters")
if isinstance(decl.get("parameters"), dict)
else None
),
extra={
"gemini_function_declaration": self._extract_extra(
decl, {"name", "description", "parameters"}
)
},
# 检测 Gemini 内置工具(如 {"googleSearch": {}}, {"codeExecution": {}}
for key, canonical_name in _BUILTIN_TOOL_KEYS.items():
if key in tool:
out.append(
ToolDefinition(
name=canonical_name,
description=None,
parameters=None,
extra={"gemini_builtin_tool": True},
)
)
break
else:
# 非内置工具:解析 functionDeclarations
decls = tool.get("function_declarations")
if decls is None:
decls = tool.get("functionDeclarations")
if not isinstance(decls, list):
continue
for decl in decls:
if not isinstance(decl, dict):
continue
name = str(decl.get("name") or "")
if not name:
continue
out.append(
ToolDefinition(
name=name,
description=decl.get("description"),
parameters=(
decl.get("parameters")
if isinstance(decl.get("parameters"), dict)
else None
),
extra={
"gemini_function_declaration": self._extract_extra(
decl, {"name", "description", "parameters"}
)
},
)
)
)
return out or None
@@ -1784,37 +2051,5 @@ class GeminiNormalizer(FormatNormalizer):
except ValueError:
return ErrorType.UNKNOWN
def _optional_int(self, value: Any) -> int | None:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _optional_float(self, value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _coerce_str_list(self, value: Any) -> list[str] | None:
if value is None:
return None
if isinstance(value, str):
return [value]
if isinstance(value, list):
return [str(x) for x in value if x is not None]
return None
def _extract_extra(self, payload: dict[str, Any], known_keys: set[str]) -> dict[str, Any]:
return {k: v for k, v in payload.items() if k not in known_keys}
def _merge_dropped(self, target: dict[str, int], source: dict[str, int]) -> None:
for k, v in source.items():
target[k] = target.get(k, 0) + int(v)
__all__ = ["GeminiNormalizer"]

View File

@@ -14,12 +14,16 @@ from typing import Any
from src.core.api_format.conversion.field_mappings import (
ERROR_TYPE_MAPPINGS,
REASONING_EFFORT_TO_THINKING_BUDGET,
RETRYABLE_ERROR_TYPES,
THINKING_BUDGET_TO_REASONING_EFFORT,
)
from src.core.api_format.conversion.internal import (
AudioBlock,
ContentBlock,
ContentType,
ErrorType,
FileBlock,
FormatCapabilities,
ImageBlock,
InstructionSegment,
@@ -27,10 +31,12 @@ from src.core.api_format.conversion.internal import (
InternalMessage,
InternalRequest,
InternalResponse,
ResponseFormatConfig,
Role,
StopReason,
TextBlock,
ThinkingBlock,
ThinkingConfig,
ToolChoice,
ToolChoiceType,
ToolDefinition,
@@ -86,6 +92,8 @@ class OpenAINormalizer(FormatNormalizer):
StopReason.STOP_SEQUENCE: "stop",
StopReason.TOOL_USE: "tool_calls",
StopReason.CONTENT_FILTERED: "content_filter",
StopReason.REFUSAL: "content_filter",
StopReason.PAUSE_TURN: "stop",
StopReason.UNKNOWN: "stop",
}
@@ -181,6 +189,52 @@ class OpenAINormalizer(FormatNormalizer):
if isinstance(google_extra, dict) and google_extra:
extra["google"] = google_extra
# reasoning_effort -> ThinkingConfig
thinking: ThinkingConfig | None = None
reasoning_effort = request.get("reasoning_effort")
if (
isinstance(reasoning_effort, str)
and reasoning_effort in REASONING_EFFORT_TO_THINKING_BUDGET
):
thinking = ThinkingConfig(
enabled=True,
budget_tokens=REASONING_EFFORT_TO_THINKING_BUDGET[reasoning_effort],
extra={"reasoning_effort": reasoning_effort},
)
# web_search_options 存入 extra 供目标 normalizer 使用
web_search_options = request.get("web_search_options")
if isinstance(web_search_options, dict):
extra["web_search_options"] = web_search_options
# parallel_tool_calls
parallel_tool_calls: bool | None = None
ptc = request.get("parallel_tool_calls")
if ptc is not None:
parallel_tool_calls = bool(ptc)
# 采样参数
n_value = self._optional_int(request.get("n"))
presence_penalty = self._optional_float(request.get("presence_penalty"))
frequency_penalty = self._optional_float(request.get("frequency_penalty"))
seed = self._optional_int(request.get("seed"))
logprobs = request.get("logprobs")
logprobs_val: bool | None = bool(logprobs) if logprobs is not None else None
top_logprobs = self._optional_int(request.get("top_logprobs"))
# response_format
response_format: ResponseFormatConfig | None = None
rf = request.get("response_format")
if isinstance(rf, dict):
rf_type = str(rf.get("type") or "text")
rf_schema = rf.get("json_schema") if isinstance(rf.get("json_schema"), dict) else None
if rf_type != "text":
response_format = ResponseFormatConfig(
type=rf_type,
json_schema=rf_schema,
extra=self._extract_extra(rf, {"type", "json_schema"}),
)
internal = InternalRequest(
model=model,
messages=messages,
@@ -193,6 +247,15 @@ class OpenAINormalizer(FormatNormalizer):
stream=bool(request.get("stream") or False),
tools=tools,
tool_choice=tool_choice,
thinking=thinking,
parallel_tool_calls=parallel_tool_calls,
n=n_value,
presence_penalty=presence_penalty,
frequency_penalty=frequency_penalty,
seed=seed,
logprobs=logprobs_val,
top_logprobs=top_logprobs,
response_format=response_format,
extra=extra,
)
@@ -240,24 +303,67 @@ class OpenAINormalizer(FormatNormalizer):
result["stream_options"] = {"include_usage": True}
if internal.tools:
result["tools"] = [
{
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": t.parameters or {},
**(t.extra.get("openai_function") or {}),
},
**(t.extra.get("openai_tool") or {}),
openai_tools: list[dict[str, Any]] = []
for t in internal.tools:
# 跳过 Gemini 内置工具(在 OpenAI 中无对应物)
if t.extra.get("gemini_builtin_tool"):
continue
func: dict[str, Any] = {
"name": t.name,
"parameters": t.parameters or {},
**(t.extra.get("openai_function") or {}),
}
for t in internal.tools
]
if t.description is not None:
func["description"] = t.description
openai_tools.append(
{
"type": "function",
"function": func,
**(t.extra.get("openai_tool") or {}),
}
)
if openai_tools:
result["tools"] = openai_tools
if internal.tool_choice:
result["tool_choice"] = self._tool_choice_to_openai(internal.tool_choice)
# 其余字段:保留在 internal.extra 中,但不默认回写到 OpenAI body兼容优先
# thinking -> reasoning_effort
if internal.thinking and internal.thinking.enabled:
effort = internal.thinking.extra.get("reasoning_effort")
if not effort and internal.thinking.budget_tokens is not None:
for threshold, level in THINKING_BUDGET_TO_REASONING_EFFORT:
if internal.thinking.budget_tokens <= threshold:
effort = level
break
if effort:
result["reasoning_effort"] = effort
# parallel_tool_calls
if internal.parallel_tool_calls is not None:
result["parallel_tool_calls"] = internal.parallel_tool_calls
# 采样参数
if internal.n is not None and internal.n > 1:
result["n"] = internal.n
for attr, key in (
("presence_penalty", "presence_penalty"),
("frequency_penalty", "frequency_penalty"),
("seed", "seed"),
("logprobs", "logprobs"),
("top_logprobs", "top_logprobs"),
):
val = getattr(internal, attr, None)
if val is not None:
result[key] = val
# response_format
if internal.response_format and internal.response_format.type != "text":
rf: dict[str, Any] = {"type": internal.response_format.type}
if internal.response_format.json_schema:
rf["json_schema"] = internal.response_format.json_schema
result["response_format"] = rf
return result
# =========================
@@ -344,6 +450,8 @@ class OpenAINormalizer(FormatNormalizer):
content_value = self._blocks_to_openai_content(content_blocks)
if content_value is not None:
message["content"] = content_value
else:
message["content"] = None
if tool_blocks:
message["tool_calls"] = [
@@ -366,11 +474,23 @@ class OpenAINormalizer(FormatNormalizer):
total = internal.usage.total_tokens or (
internal.usage.input_tokens + internal.usage.output_tokens
)
out["usage"] = {
usage_out: dict[str, Any] = {
"prompt_tokens": int(internal.usage.input_tokens),
"completion_tokens": int(internal.usage.output_tokens),
"total_tokens": int(total),
}
# 回写 prompt_tokens_detailscached_tokens
if internal.usage.cache_read_tokens:
usage_out["prompt_tokens_details"] = {
"cached_tokens": int(internal.usage.cache_read_tokens),
}
# 回写 completion_tokens_details
openai_extra = internal.usage.extra.get("openai", {})
if isinstance(openai_extra, dict):
ctd = openai_extra.get("completion_tokens_details")
if isinstance(ctd, dict):
usage_out["completion_tokens_details"] = ctd
out["usage"] = usage_out
return out
@@ -1061,6 +1181,45 @@ class OpenAINormalizer(FormatNormalizer):
blocks.append(UnknownBlock(raw_type="image_url", payload=part))
continue
if ptype == "file":
file_data = part.get("file_data")
file_id = part.get("file_id")
if isinstance(file_data, dict):
blocks.append(
FileBlock(
data=file_data.get("data"),
media_type=file_data.get("mime_type"),
filename=file_data.get("filename"),
extra=self._extract_extra(part, {"type", "file_data"}),
)
)
elif isinstance(file_id, str) and file_id:
blocks.append(
FileBlock(
file_id=file_id,
extra=self._extract_extra(part, {"type", "file_id"}),
)
)
else:
blocks.append(UnknownBlock(raw_type="file", payload=part))
continue
if ptype == "input_audio":
audio_data = part.get("input_audio") or {}
if isinstance(audio_data, dict):
audio_fmt = str(audio_data.get("format") or "")
blocks.append(
AudioBlock(
data=audio_data.get("data"),
media_type=f"audio/{audio_fmt}" if audio_fmt else None,
format=audio_fmt or None,
extra=self._extract_extra(part, {"type", "input_audio"}),
)
)
else:
blocks.append(UnknownBlock(raw_type="input_audio", payload=part))
continue
# 其他类型UnknownBlock
dropped_key = f"openai_part:{ptype}"
dropped[dropped_key] = dropped.get(dropped_key, 0) + 1
@@ -1288,14 +1447,35 @@ class OpenAINormalizer(FormatNormalizer):
if input_tokens == 0 and output_tokens == 0 and total_tokens_int == 0:
return None
# prompt_tokens_details -> cache_read_tokens
cache_read = 0
ptd = usage.get("prompt_tokens_details")
if isinstance(ptd, dict):
cache_read = int(ptd.get("cached_tokens") or 0)
# completion_tokens_detailsreasoning_tokens 等存入 extra
ctd = usage.get("completion_tokens_details")
extra = self._extract_extra(
usage,
{"prompt_tokens", "completion_tokens", "total_tokens"},
{
"prompt_tokens",
"completion_tokens",
"total_tokens",
"prompt_tokens_details",
"completion_tokens_details",
},
)
# 保留 completion_tokens_details 细分到 extra
if isinstance(ctd, dict):
extra["completion_tokens_details"] = ctd
return UsageInfo(
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens_int,
cache_read_tokens=cache_read,
extra={"openai": extra} if extra else {},
)
@@ -1326,9 +1506,47 @@ class OpenAINormalizer(FormatNormalizer):
parts.append({"type": "image_url", "image_url": {"url": b.url}})
continue
if isinstance(b, FileBlock):
if b.data and b.media_type:
# OpenAI file content part
file_part: dict[str, Any] = {
"type": "file",
"file_data": {
"mime_type": b.media_type,
"data": b.data,
},
}
if b.filename:
file_part["file_data"]["filename"] = b.filename
parts.append(file_part)
elif b.file_id:
parts.append({"type": "file", "file_id": b.file_id})
elif b.file_url:
# 回退为文本描述
text_parts.append(f"[File: {b.file_url}]")
continue
if isinstance(b, AudioBlock):
if b.data:
audio_fmt = b.format or (
b.media_type.split("/", 1)[1]
if b.media_type and "/" in b.media_type
else "mp3"
)
parts.append(
{
"type": "input_audio",
"input_audio": {
"data": b.data,
"format": audio_fmt,
},
}
)
continue
# Unknown / Tool blocks 不进入 OpenAI content
# 如果有 OpenAI 原生格式的图片URL 引用),使用 multipart content
# 如果有 OpenAI 原生格式的图片/文件/音频multipart content parts),使用 multipart content
if parts:
if text_parts:
parts = [{"type": "text", "text": "\n".join(text_parts)}] + parts
@@ -1355,10 +1573,10 @@ class OpenAINormalizer(FormatNormalizer):
tool_blocks.append(b)
continue
if isinstance(b, ToolResultBlock):
# InternalResponse.content 不应该包含 tool_result忽略
continue
if isinstance(b, UnknownBlock):
continue
# TextBlock, ImageBlock, FileBlock, AudioBlock -> content
content_blocks.append(b)
return thinking_blocks, content_blocks, tool_blocks
@@ -1495,38 +1713,6 @@ class OpenAINormalizer(FormatNormalizer):
except ValueError:
return ErrorType.UNKNOWN
def _optional_int(self, value: Any) -> int | None:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _optional_float(self, value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _coerce_str_list(self, value: Any) -> list[str] | None:
if value is None:
return None
if isinstance(value, str):
return [value]
if isinstance(value, list):
return [str(x) for x in value if x is not None]
return None
def _extract_extra(self, payload: dict[str, Any], known_keys: set[str]) -> dict[str, Any]:
return {k: v for k, v in payload.items() if k not in known_keys}
def _merge_dropped(self, target: dict[str, int], source: dict[str, int]) -> None:
for k, v in source.items():
target[k] = target.get(k, 0) + int(v)
def _ensure_tool_block_index(self, ss: dict[str, Any], tool_key: str) -> int:
mapping = ss.get("tool_id_to_block_index")
if not isinstance(mapping, dict):

View File

@@ -1343,22 +1343,6 @@ class OpenAICliNormalizer(FormatNormalizer):
return "tool"
return "user"
def _optional_int(self, value: Any) -> int | None:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _optional_float(self, value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _coerce_str_list(self, value: Any) -> list[str] | None:
if value is None:
return None

View File

@@ -16,6 +16,7 @@ from contextlib import contextmanager
from typing import Any
from src.core.api_format.conversion.exceptions import FormatConversionError
from src.core.api_format.conversion.image_resolver import resolve_image_urls
from src.core.api_format.conversion.internal import InternalRequest, ToolResultBlock, ToolUseBlock
from src.core.api_format.conversion.normalizer import FormatNormalizer
from src.core.api_format.conversion.stream_state import StreamState
@@ -104,6 +105,7 @@ class FormatConversionRegistry:
target_format: str,
*,
target_variant: str | None = None,
output_limit: int | None = None,
) -> dict[str, Any]:
if str(source_format).upper() == str(target_format).upper() and not target_variant:
return request
@@ -116,11 +118,43 @@ class FormatConversionRegistry:
):
try:
internal = src.request_to_internal(request)
internal.output_limit = output_limit
self._repair_internal_tool_call_ids(internal)
return tgt.request_from_internal(internal, target_variant=target_variant)
except Exception as e:
raise FormatConversionError(source_format, target_format, str(e)) from e
async def convert_request_async(
self,
request: dict[str, Any],
source_format: str,
target_format: str,
*,
target_variant: str | None = None,
output_limit: int | None = None,
) -> dict[str, Any]:
"""异步版本的 convert_request在 internal 阶段执行图片 URL 下载等异步操作。"""
if str(source_format).upper() == str(target_format).upper() and not target_variant:
return request
src = self._require_normalizer(source_format)
tgt = self._require_normalizer(target_format)
with _track_conversion_metrics(
"request", str(source_format).upper(), str(target_format).upper()
):
try:
internal = src.request_to_internal(request)
internal.output_limit = output_limit
self._repair_internal_tool_call_ids(internal)
# 异步阶段:解析图片 URL -> base64仅在目标格式需要时
await resolve_image_urls(internal, str(target_format).upper())
return tgt.request_from_internal(internal, target_variant=target_variant)
except Exception as e:
raise FormatConversionError(source_format, target_format, str(e)) from e
def convert_response(
self,
response: dict[str, Any],

View File

@@ -20,41 +20,9 @@ from src.services.provider.adapters.kiro.constants import (
def map_model(model: str) -> str | None:
"""Map an Anthropic model name to a Kiro-compatible model ID.
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.
"""
"""Pass through the model name as-is to Kiro upstream."""
raw = str(model or "").strip()
if not raw:
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
return raw or None
def _extract_session_id(user_id: str) -> str | None:

View File

@@ -35,6 +35,12 @@ PRESET_MODELS: dict[str, list[dict[str, Any]]] = {
"owned_by": "anthropic",
"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",
"object": "model",

View File

@@ -386,6 +386,17 @@ class CandidateBuilder:
client_format_str = normalize_endpoint_signature(client_format)
client_sig = parse_signature_key(client_format_str)
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 等不跨类回退
if client_kind in {EndpointKind.CHAT, EndpointKind.CLI}:
allowed_kinds = {EndpointKind.CHAT, EndpointKind.CLI}
@@ -581,6 +592,7 @@ class CandidateBuilder:
mapping_matched_model=mapping_matched_model,
needs_conversion=needs_conversion,
provider_api_format=str(endpoint_format_str or ""),
output_limit=output_limit,
)
if needs_conversion:

View File

@@ -28,6 +28,7 @@ class ProviderCandidate:
mapping_matched_model: str | None = None # 通过映射匹配到的模型名(用于实际请求)
needs_conversion: bool = False # 是否需要格式转换
provider_api_format: str = "" # Provider 端点实际格式(用于健康度/熔断 bucket
output_limit: int | None = None # GlobalModel 配置的模型输出上限
def _stable_order_key(self) -> tuple[int, int, str, str, str]:
"""