refactor: 重构格式转换系统为 Hub-and-Spoke Normalizer 架构

- 移除旧的 converters 模块,改用基于 Internal 中间表示的 Normalizer 模式
- 新增 internal.py 定义统一的内部数据结构(InternalRequest/Response/Error)
- 新增 normalizer.py 定义 FormatNormalizer 基类接口
- 实现 OpenAI/Claude/Gemini 及其 CLI 格式的 Normalizer
- 新增 stream_events.py 和 stream_state.py 支持流式转换
- 重构 registry.py 使用 Normalizer 进行格式转换
- 更新 handlers 适配新的转换架构
- 前端移除 CLI 格式转换按钮的限制
- 添加 golden data 测试确保转换正确性
This commit is contained in:
fawney19
2026-01-27 02:17:18 +08:00
parent 6cf251b19a
commit add477fece
84 changed files with 9169 additions and 3932 deletions

View File

@@ -1,7 +1,7 @@
"""
API 格式核心模块
统一管理 API 格式相关的枚举、元数据、工具函数和格式转换功能
统一管理 API 格式相关的枚举、元数据、工具函数
模块组成:
- enums.py: APIFormat 枚举定义
@@ -9,29 +9,7 @@ API 格式核心模块
- headers.py: 请求头处理(构建、过滤、脱敏)
- utils.py: 工具函数is_cli_format, get_base_format 等)
- detection.py: 格式检测(从请求头、响应内容检测格式)
- conversion/: 格式转换子模块
"""
from src.core.api_format.conversion import (
ClaudeStreamConversionState,
ClaudeToGeminiConverter,
ClaudeToOpenAIConverter,
FormatConversionError,
FormatConverterRegistry,
GeminiStreamConversionState,
GeminiToClaudeConverter,
GeminiToOpenAIConverter,
OpenAIStreamConversionState,
OpenAIToClaudeConverter,
OpenAIToGeminiConverter,
RequestConverter,
ResponseConverter,
StreamChunkConverter,
StreamConversionState,
converter_registry,
is_format_compatible,
register_all_converters,
)
from src.core.api_format.detection import (
detect_cli_format_from_path,
detect_format_and_key_from_starlette,
@@ -128,33 +106,9 @@ __all__ = [
"get_adapter_protected_keys",
"extract_set_headers_from_rules",
"get_extra_headers_from_endpoint",
# Registry
"FormatConverterRegistry",
"converter_registry",
"register_all_converters",
# Protocols
"RequestConverter",
"ResponseConverter",
"StreamChunkConverter",
# State
"StreamConversionState",
"GeminiStreamConversionState",
"ClaudeStreamConversionState",
"OpenAIStreamConversionState",
# Exceptions
"FormatConversionError",
# Compatibility
"is_format_compatible",
# Detection
"detect_format_from_request",
"detect_format_and_key_from_starlette",
"detect_format_from_response",
"detect_cli_format_from_path",
# Converters
"OpenAIToClaudeConverter",
"ClaudeToOpenAIConverter",
"ClaudeToGeminiConverter",
"GeminiToClaudeConverter",
"OpenAIToGeminiConverter",
"GeminiToOpenAIConverter",
]

View File

@@ -1,90 +1,30 @@
"""
格式转换核心模块
API 格式转换子模块Canonical
该目录用于承载与「API 格式转换」相关的核心能力(不依赖 FastAPI/Handler 层),
以便 services/core 可复用并避免出现 services -> api 的反向依赖。
模块组成:
- registry.py: 转换器注册表,管理转换器实例和能力查询
- protocols.py: 转换器协议定义Protocol
- state.py: 流式转换状态类
- exceptions.py: 转换异常定义
- compatibility.py: 格式兼容性检查函数
- converters/: 内置格式转换器实现
对外提供:
- `format_conversion_registry`: 全局转换注册表Hub-and-Spoke
- `register_default_normalizers()`: 注册默认 NormalizersOPENAI/CLAUDE/GEMINI
- `StreamState`: 统一流式状态容器
"""
from src.core.api_format.conversion.compatibility import is_format_compatible
from src.core.api_format.conversion.converters import (
ClaudeToGeminiConverter,
ClaudeToOpenAIConverter,
GeminiToClaudeConverter,
GeminiToOpenAIConverter,
OpenAIToClaudeConverter,
OpenAIToGeminiConverter,
)
from src.core.api_format.conversion.exceptions import FormatConversionError
from src.core.api_format.conversion.protocols import (
RequestConverter,
ResponseConverter,
StreamChunkConverter,
)
from src.core.api_format.conversion.registry import (
FormatConverterRegistry,
converter_registry,
FormatConversionRegistry,
format_conversion_registry,
register_default_normalizers,
)
from src.core.api_format.conversion.state import (
ClaudeStreamConversionState,
GeminiStreamConversionState,
OpenAIStreamConversionState,
StreamConversionState,
)
from src.core.logger import logger
def register_all_converters() -> None:
"""
注册所有内置的格式转换器
在应用启动时调用此函数
"""
# Claude <-> OpenAI
converter_registry.register("OPENAI", "CLAUDE", OpenAIToClaudeConverter())
converter_registry.register("CLAUDE", "OPENAI", ClaudeToOpenAIConverter())
# Claude <-> Gemini
converter_registry.register("CLAUDE", "GEMINI", ClaudeToGeminiConverter())
converter_registry.register("GEMINI", "CLAUDE", GeminiToClaudeConverter())
# OpenAI <-> Gemini
converter_registry.register("OPENAI", "GEMINI", OpenAIToGeminiConverter())
converter_registry.register("GEMINI", "OPENAI", GeminiToOpenAIConverter())
logger.info(f"[ConverterRegistry] 已注册 {len(converter_registry.list_converters())} 个格式转换器")
from src.core.api_format.conversion.stream_state import StreamState
__all__ = [
# Registry
"FormatConverterRegistry",
"converter_registry",
"register_all_converters",
# Protocols
"RequestConverter",
"ResponseConverter",
"StreamChunkConverter",
# State
"StreamConversionState",
"GeminiStreamConversionState",
"ClaudeStreamConversionState",
"OpenAIStreamConversionState",
"FormatConversionRegistry",
"format_conversion_registry",
"register_default_normalizers",
# Stream state
"StreamState",
# Exceptions
"FormatConversionError",
# Compatibility
"is_format_compatible",
# Converters
"OpenAIToClaudeConverter",
"ClaudeToOpenAIConverter",
"ClaudeToGeminiConverter",
"GeminiToClaudeConverter",
"OpenAIToGeminiConverter",
"GeminiToOpenAIConverter",
]

View File

@@ -9,10 +9,10 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Optional, Tuple
from src.core.api_format.utils import is_cli_format
if TYPE_CHECKING:
from src.core.api_format.conversion.registry import FormatConverterRegistry
from src.core.api_format.conversion.registry import FormatConversionRegistry
from src.core.api_format.utils import get_base_format
logger = logging.getLogger(__name__)
@@ -23,7 +23,7 @@ def is_format_compatible(
endpoint_format_acceptance_config: Optional[dict],
is_stream: bool,
global_conversion_enabled: bool,
registry: Optional["FormatConverterRegistry"] = None,
registry: Optional["FormatConversionRegistry"] = None,
) -> Tuple[bool, bool, Optional[str]]:
"""
检查端点是否兼容客户端格式
@@ -44,9 +44,13 @@ def is_format_compatible(
"""
# 延迟导入避免循环依赖
if registry is None:
from src.core.api_format.conversion.registry import converter_registry
from src.core.api_format.conversion.registry import (
format_conversion_registry,
register_default_normalizers,
)
registry = converter_registry
register_default_normalizers()
registry = format_conversion_registry
provider_format = endpoint_api_format.upper()
client_format_upper = client_format.upper()
@@ -55,11 +59,12 @@ def is_format_compatible(
if provider_format == client_format_upper:
return True, False, None
# 2. CLI 格式不参与转换
if is_cli_format(client_format_upper):
return False, False, "CLI 格式不支持转换"
if is_cli_format(provider_format):
return False, False, "Provider 为 CLI 格式,不支持转换"
# 2. 同族格式匹配CLAUDE 和 CLAUDE_CLI、OPENAI 和 OPENAI_CLI 等)
# 这些格式在响应层面是兼容的,只是认证方式不同
provider_base = get_base_format(provider_format)
client_base = get_base_format(client_format_upper)
if provider_base == client_base:
return True, False, None
# 3. 检查全局开关
if not global_conversion_enabled:

View File

@@ -1,26 +0,0 @@
"""
格式转换器集合
将 Claude/OpenAI/Gemini 格式之间的转换器统一导出。
"""
from .claude_to_openai import ClaudeToOpenAIConverter
from .gemini import (
ClaudeToGeminiConverter,
GeminiToClaudeConverter,
GeminiToOpenAIConverter,
OpenAIToGeminiConverter,
)
from .openai_to_claude import OpenAIToClaudeConverter
__all__ = [
# OpenAI <-> Claude
"OpenAIToClaudeConverter",
"ClaudeToOpenAIConverter",
# Claude <-> Gemini
"ClaudeToGeminiConverter",
"GeminiToClaudeConverter",
# OpenAI <-> Gemini
"OpenAIToGeminiConverter",
"GeminiToOpenAIConverter",
]

View File

@@ -1,450 +0,0 @@
"""
Claude -> OpenAI 格式转换器
将 Claude Messages API 格式转换为 OpenAI Chat Completions API 格式。
"""
from __future__ import annotations
import json
import time
import uuid
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
if TYPE_CHECKING:
from src.core.api_format.conversion.state import StreamConversionState
class ClaudeToOpenAIConverter:
"""
Claude -> OpenAI 格式转换器
支持:
- 请求转换Claude Request -> OpenAI Chat Request
- 响应转换Claude Response -> OpenAI Chat Response
- 流式转换Claude SSE -> OpenAI SSE
"""
# 内容类型常量
CONTENT_TYPE_TEXT = "text"
CONTENT_TYPE_IMAGE = "image"
CONTENT_TYPE_TOOL_USE = "tool_use"
CONTENT_TYPE_TOOL_RESULT = "tool_result"
# 停止原因映射
STOP_REASON_MAP = {
"end_turn": "stop",
"max_tokens": "length",
"stop_sequence": "stop",
"tool_use": "tool_calls",
}
def __init__(self, model_mapping: Optional[Dict[str, str]] = None):
"""
Args:
model_mapping: Claude 模型到 OpenAI 模型的映射
"""
self._model_mapping = model_mapping or {}
# ==================== 请求转换 ====================
def convert_request(self, request: Union[Dict[str, Any], Any]) -> Dict[str, Any]:
"""
将 Claude 请求转换为 OpenAI 格式
Args:
request: Claude 请求Dict 或 Pydantic 模型)
Returns:
OpenAI 格式的请求字典
"""
if hasattr(request, "model_dump"):
data = request.model_dump(exclude_none=True)
else:
data = dict(request)
# 模型映射
model = data.get("model", "")
openai_model = self._model_mapping.get(model, model)
# 构建消息列表
messages: List[Dict[str, Any]] = []
# 处理 system 消息
system_content = self._extract_text_content(data.get("system"))
if system_content:
messages.append({"role": "system", "content": system_content})
# 处理对话消息
for message in data.get("messages", []):
converted = self._convert_message(message)
if converted:
messages.append(converted)
# 构建 OpenAI 请求
result: Dict[str, Any] = {
"model": openai_model,
"messages": messages,
}
# 可选参数
if data.get("max_tokens"):
result["max_tokens"] = data["max_tokens"]
if data.get("temperature") is not None:
result["temperature"] = data["temperature"]
if data.get("top_p") is not None:
result["top_p"] = data["top_p"]
if data.get("stream"):
result["stream"] = data["stream"]
if data.get("stop_sequences"):
result["stop"] = data["stop_sequences"]
# 工具转换
tools = self._convert_tools(data.get("tools"))
if tools:
result["tools"] = tools
tool_choice = self._convert_tool_choice(data.get("tool_choice"))
if tool_choice:
result["tool_choice"] = tool_choice
return result
def _convert_message(self, message: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""转换单条消息"""
role = message.get("role")
if role == "user":
return self._convert_user_message(message)
if role == "assistant":
return self._convert_assistant_message(message)
return None
def _convert_user_message(self, message: Dict[str, Any]) -> Dict[str, Any]:
"""转换用户消息"""
content = message.get("content")
if isinstance(content, str):
return {"role": "user", "content": content}
openai_content: List[Dict[str, Any]] = []
for block in content or []:
block_type = block.get("type")
if block_type == self.CONTENT_TYPE_TEXT:
openai_content.append({"type": "text", "text": block.get("text", "")})
elif block_type == self.CONTENT_TYPE_IMAGE:
source = block.get("source", {})
media_type = source.get("media_type", "image/jpeg")
data = source.get("data", "")
openai_content.append(
{"type": "image_url", "image_url": {"url": f"data:{media_type};base64,{data}"}}
)
elif block_type == self.CONTENT_TYPE_TOOL_RESULT:
tool_content = block.get("content", "")
rendered = self._render_tool_content(tool_content)
openai_content.append({"type": "text", "text": f"Tool result: {rendered}"})
# 简化单文本内容
if len(openai_content) == 1 and openai_content[0]["type"] == "text":
return {"role": "user", "content": openai_content[0]["text"]}
return {"role": "user", "content": openai_content or ""}
def _convert_assistant_message(self, message: Dict[str, Any]) -> Dict[str, Any]:
"""转换助手消息"""
content = message.get("content")
text_parts: List[str] = []
tool_calls: List[Dict[str, Any]] = []
if isinstance(content, str):
text_parts.append(content)
else:
for idx, block in enumerate(content or []):
block_type = block.get("type")
if block_type == self.CONTENT_TYPE_TEXT:
text_parts.append(block.get("text", ""))
elif block_type == self.CONTENT_TYPE_TOOL_USE:
tool_calls.append(
{
"id": block.get("id", f"call_{idx}"),
"type": "function",
"function": {
"name": block.get("name", ""),
"arguments": json.dumps(block.get("input", {}), ensure_ascii=False),
},
}
)
result: Dict[str, Any] = {"role": "assistant"}
message_content = "\n".join([p for p in text_parts if p]) or None
if message_content:
result["content"] = message_content
if tool_calls:
result["tool_calls"] = tool_calls
return result
def _convert_tools(
self, tools: Optional[List[Dict[str, Any]]]
) -> Optional[List[Dict[str, Any]]]:
"""转换工具定义"""
if not tools:
return None
result: List[Dict[str, Any]] = []
for tool in tools:
result.append(
{
"type": "function",
"function": {
"name": tool.get("name", ""),
"description": tool.get("description"),
"parameters": tool.get("input_schema", {}),
},
}
)
return result
def _convert_tool_choice(
self, tool_choice: Optional[Dict[str, Any]]
) -> Optional[Union[str, Dict[str, Any]]]:
"""转换工具选择"""
if tool_choice is None:
return None
choice_type = tool_choice.get("type")
if choice_type in ("tool", "tool_use"):
return {"type": "function", "function": {"name": tool_choice.get("name", "")}}
if choice_type == "any":
return "required"
if choice_type == "auto":
return "auto"
return tool_choice
# ==================== 响应转换 ====================
def convert_response(self, response: Dict[str, Any]) -> Dict[str, Any]:
"""
将 Claude 响应转换为 OpenAI 格式
Args:
response: Claude 响应字典
Returns:
OpenAI 格式的响应字典
"""
# 提取内容
content_parts: List[str] = []
tool_calls: List[Dict[str, Any]] = []
for idx, block in enumerate(response.get("content", [])):
block_type = block.get("type")
if block_type == self.CONTENT_TYPE_TEXT:
content_parts.append(block.get("text", ""))
elif block_type == self.CONTENT_TYPE_TOOL_USE:
tool_calls.append(
{
"id": block.get("id", f"call_{idx}"),
"type": "function",
"function": {
"name": block.get("name", ""),
"arguments": json.dumps(block.get("input", {}), ensure_ascii=False),
},
}
)
# 构建消息
message: Dict[str, Any] = {"role": "assistant"}
text_content = "\n".join([p for p in content_parts if p]) or None
if text_content:
message["content"] = text_content
if tool_calls:
message["tool_calls"] = tool_calls
# 转换停止原因
stop_reason = response.get("stop_reason")
finish_reason = self.STOP_REASON_MAP.get(stop_reason, stop_reason) if stop_reason else None
# 转换 usage
usage = response.get("usage", {})
openai_usage = {
"prompt_tokens": usage.get("input_tokens", 0),
"completion_tokens": usage.get("output_tokens", 0),
"total_tokens": (usage.get("input_tokens", 0) + usage.get("output_tokens", 0)),
}
return {
"id": f"chatcmpl-{response.get('id', uuid.uuid4().hex[:8])}",
"object": "chat.completion",
"created": int(time.time()),
"model": response.get("model", ""),
"choices": [
{
"index": 0,
"message": message,
"finish_reason": finish_reason,
}
],
"usage": openai_usage,
}
# ==================== 流式转换 ====================
def convert_stream_chunk(
self,
chunk: Dict[str, Any],
state: Optional["StreamConversionState"] = None,
) -> List[Dict[str, Any]]:
"""
将 Claude SSE 事件转换为 OpenAI 格式
Args:
chunk: Claude SSE 事件
state: 流式转换状态
Returns:
OpenAI 格式的 SSE chunk 列表
"""
from src.core.api_format.conversion.state import StreamConversionState
if state is None:
state = StreamConversionState()
result = self._convert_single_event(chunk, state.model, state.message_id)
if result is None:
return []
return [result]
def _convert_single_event(
self,
event: Dict[str, Any],
model: str = "",
message_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""
转换单个 Claude SSE 事件为 OpenAI 格式
Args:
event: Claude SSE 事件
model: 模型名称
message_id: 消息 ID
Returns:
OpenAI 格式的 SSE chunk如果无法转换返回 None
"""
event_type = event.get("type")
chunk_id = f"chatcmpl-{(message_id or 'stream')[-8:]}"
if event_type == "message_start":
message = event.get("message", {})
return self._base_chunk(
chunk_id,
model or message.get("model", ""),
{"role": "assistant"},
)
if event_type == "content_block_start":
content_block = event.get("content_block", {})
if content_block.get("type") == self.CONTENT_TYPE_TOOL_USE:
delta = {
"tool_calls": [
{
"index": event.get("index", 0),
"id": content_block.get("id", ""),
"type": "function",
"function": {
"name": content_block.get("name", ""),
"arguments": "",
},
}
]
}
return self._base_chunk(chunk_id, model, delta)
return None
if event_type == "content_block_delta":
delta_payload = event.get("delta") or {}
delta_type = delta_payload.get("type")
if delta_type == "text_delta":
delta = {"content": delta_payload.get("text", "")}
return self._base_chunk(chunk_id, model, delta)
if delta_type == "input_json_delta":
delta = {
"tool_calls": [
{
"index": event.get("index", 0),
"function": {"arguments": delta_payload.get("partial_json", "")},
}
]
}
return self._base_chunk(chunk_id, model, delta)
return None
if event_type == "message_delta":
delta = event.get("delta") or {}
stop_reason = delta.get("stop_reason")
finish_reason = self.STOP_REASON_MAP.get(stop_reason, stop_reason)
return self._base_chunk(chunk_id, model, {}, finish_reason=finish_reason)
if event_type == "message_stop":
return self._base_chunk(chunk_id, model, {}, finish_reason="stop")
return None
def _base_chunk(
self,
chunk_id: str,
model: str,
delta: Dict[str, Any],
finish_reason: Optional[str] = None,
) -> Dict[str, Any]:
"""构建基础 OpenAI chunk"""
return {
"id": chunk_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"system_fingerprint": None,
"choices": [
{
"index": 0,
"delta": delta,
"finish_reason": finish_reason,
}
],
}
# ==================== 工具方法 ====================
def _extract_text_content(
self, content: Optional[Union[str, List[Dict[str, Any]]]]
) -> Optional[str]:
"""提取文本内容"""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [
block.get("text", "")
for block in content
if block.get("type") == self.CONTENT_TYPE_TEXT
]
return "\n\n".join(filter(None, parts)) or None
return None
def _render_tool_content(self, tool_content: Any) -> str:
"""渲染工具内容"""
if isinstance(tool_content, list):
return json.dumps(tool_content, ensure_ascii=False)
return str(tool_content)
__all__ = ["ClaudeToOpenAIConverter"]

File diff suppressed because it is too large Load Diff

View File

@@ -1,492 +0,0 @@
"""
OpenAI -> Claude 格式转换器
将 OpenAI Chat Completions API 格式转换为 Claude Messages API 格式。
"""
from __future__ import annotations
import json
import uuid
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
if TYPE_CHECKING:
from src.core.api_format.conversion.state import StreamConversionState
class OpenAIToClaudeConverter:
"""
OpenAI -> Claude 格式转换器
支持:
- 请求转换OpenAI Chat Request -> Claude Request
- 响应转换OpenAI Chat Response -> Claude Response
- 流式转换OpenAI SSE -> Claude SSE
"""
# 内容类型常量
CONTENT_TYPE_TEXT = "text"
CONTENT_TYPE_IMAGE = "image"
CONTENT_TYPE_TOOL_USE = "tool_use"
CONTENT_TYPE_TOOL_RESULT = "tool_result"
# 停止原因映射OpenAI -> Claude
FINISH_REASON_MAP = {
"stop": "end_turn",
"length": "max_tokens",
"tool_calls": "tool_use",
"function_call": "tool_use",
"content_filter": "end_turn",
}
def __init__(self, model_mapping: Optional[Dict[str, str]] = None):
"""
Args:
model_mapping: OpenAI 模型到 Claude 模型的映射
"""
self._model_mapping = model_mapping or {}
# ==================== 请求转换 ====================
def convert_request(self, request: Union[Dict[str, Any], Any]) -> Dict[str, Any]:
"""
将 OpenAI 请求转换为 Claude 格式
Args:
request: OpenAI 请求Dict 或 Pydantic 模型)
Returns:
Claude 格式的请求字典
"""
if hasattr(request, "model_dump"):
data = request.model_dump(exclude_none=True)
else:
data = dict(request)
# 模型映射
model = data.get("model", "")
claude_model = self._model_mapping.get(model, model)
# 处理消息
system_content: Optional[str] = None
claude_messages: List[Dict[str, Any]] = []
for message in data.get("messages", []):
role = message.get("role")
# 提取 system 消息
if role == "system":
system_content = self._collapse_content(message.get("content"))
continue
# 转换其他消息
converted = self._convert_message(message)
if converted:
claude_messages.append(converted)
# 构建 Claude 请求
result: Dict[str, Any] = {
"model": claude_model,
"messages": claude_messages,
"max_tokens": data.get("max_tokens") or 4096,
}
# 可选参数
if data.get("temperature") is not None:
result["temperature"] = data["temperature"]
if data.get("top_p") is not None:
result["top_p"] = data["top_p"]
if data.get("stream"):
result["stream"] = data["stream"]
if data.get("stop"):
result["stop_sequences"] = self._convert_stop(data["stop"])
if system_content:
result["system"] = system_content
# 工具转换
tools = self._convert_tools(data.get("tools"))
if tools:
result["tools"] = tools
tool_choice = self._convert_tool_choice(data.get("tool_choice"))
if tool_choice:
result["tool_choice"] = tool_choice
return result
def _convert_message(self, message: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""转换单条消息"""
role = message.get("role")
if role == "user":
return self._convert_user_message(message)
if role == "assistant":
return self._convert_assistant_message(message)
if role == "tool":
return self._convert_tool_message(message)
return None
def _convert_user_message(self, message: Dict[str, Any]) -> Dict[str, Any]:
"""转换用户消息"""
content = message.get("content")
if isinstance(content, str) or content is None:
return {"role": "user", "content": content or ""}
# 转换内容数组
claude_content: List[Dict[str, Any]] = []
for item in content:
item_type = item.get("type")
if item_type == "text":
claude_content.append(
{"type": self.CONTENT_TYPE_TEXT, "text": item.get("text", "")}
)
elif item_type == "image_url":
image_url = (item.get("image_url") or {}).get("url", "")
claude_content.append(self._convert_image_url(image_url))
return {"role": "user", "content": claude_content}
def _convert_assistant_message(self, message: Dict[str, Any]) -> Dict[str, Any]:
"""转换助手消息"""
content_blocks: List[Dict[str, Any]] = []
# 处理文本内容
content = message.get("content")
if isinstance(content, str):
content_blocks.append({"type": self.CONTENT_TYPE_TEXT, "text": content})
elif isinstance(content, list):
for part in content:
if part.get("type") == "text":
content_blocks.append(
{"type": self.CONTENT_TYPE_TEXT, "text": part.get("text", "")}
)
# 处理工具调用
for tool_call in message.get("tool_calls") or []:
if tool_call.get("type") == "function":
function = tool_call.get("function", {})
arguments = function.get("arguments", "{}")
try:
input_data = json.loads(arguments)
except json.JSONDecodeError:
input_data = {"raw": arguments}
content_blocks.append(
{
"type": self.CONTENT_TYPE_TOOL_USE,
"id": tool_call.get("id", ""),
"name": function.get("name", ""),
"input": input_data,
}
)
# 简化单文本内容
if not content_blocks:
return {"role": "assistant", "content": ""}
if len(content_blocks) == 1 and content_blocks[0]["type"] == self.CONTENT_TYPE_TEXT:
return {"role": "assistant", "content": content_blocks[0]["text"]}
return {"role": "assistant", "content": content_blocks}
def _convert_tool_message(self, message: Dict[str, Any]) -> Dict[str, Any]:
"""转换工具结果消息"""
tool_content = message.get("content", "")
# 尝试解析 JSON
parsed_content = tool_content
if isinstance(tool_content, str):
try:
parsed_content = json.loads(tool_content)
except json.JSONDecodeError:
pass
tool_block = {
"type": self.CONTENT_TYPE_TOOL_RESULT,
"tool_use_id": message.get("tool_call_id", ""),
"content": parsed_content,
}
return {"role": "user", "content": [tool_block]}
def _convert_tools(
self, tools: Optional[List[Dict[str, Any]]]
) -> Optional[List[Dict[str, Any]]]:
"""转换工具定义"""
if not tools:
return None
result: List[Dict[str, Any]] = []
for tool in tools:
if tool.get("type") != "function":
continue
function = tool.get("function", {})
result.append(
{
"name": function.get("name", ""),
"description": function.get("description"),
"input_schema": function.get("parameters") or {},
}
)
return result if result else None
def _convert_tool_choice(
self, tool_choice: Optional[Union[str, Dict[str, Any]]]
) -> Optional[Dict[str, Any]]:
"""转换工具选择"""
if tool_choice is None:
return None
if tool_choice == "none":
return {"type": "none"}
if tool_choice == "auto":
return {"type": "auto"}
if tool_choice == "required":
return {"type": "any"}
if isinstance(tool_choice, dict) and tool_choice.get("type") == "function":
function = tool_choice.get("function", {})
return {"type": "tool_use", "name": function.get("name", "")}
return {"type": "auto"}
def _convert_image_url(self, image_url: str) -> Dict[str, Any]:
"""转换图片 URL"""
if image_url.startswith("data:"):
header, _, data = image_url.partition(",")
media_type = "image/jpeg"
if ";" in header:
media_type = header.split(";")[0].split(":")[-1]
return {
"type": self.CONTENT_TYPE_IMAGE,
"source": {
"type": "base64",
"media_type": media_type,
"data": data,
},
}
return {"type": self.CONTENT_TYPE_TEXT, "text": f"[Image: {image_url}]"}
def _convert_stop(self, stop: Optional[Union[str, List[str]]]) -> Optional[List[str]]:
"""转换停止序列"""
if stop is None:
return None
if isinstance(stop, str):
return [stop]
return stop
# ==================== 响应转换 ====================
def convert_response(self, response: Dict[str, Any]) -> Dict[str, Any]:
"""
将 OpenAI 响应转换为 Claude 格式
Args:
response: OpenAI 响应字典
Returns:
Claude 格式的响应字典
"""
choices = response.get("choices", [])
if not choices:
return self._empty_claude_response(response)
choice = choices[0]
message = choice.get("message", {})
# 构建 content 数组
content: List[Dict[str, Any]] = []
# 处理文本
text_content = message.get("content")
if text_content:
content.append(
{
"type": self.CONTENT_TYPE_TEXT,
"text": text_content,
}
)
# 处理工具调用
for tool_call in message.get("tool_calls") or []:
if tool_call.get("type") == "function":
function = tool_call.get("function", {})
arguments = function.get("arguments", "{}")
try:
input_data = json.loads(arguments)
except json.JSONDecodeError:
input_data = {"raw": arguments}
content.append(
{
"type": self.CONTENT_TYPE_TOOL_USE,
"id": tool_call.get("id", ""),
"name": function.get("name", ""),
"input": input_data,
}
)
# 转换 finish_reason
finish_reason = choice.get("finish_reason")
stop_reason = self.FINISH_REASON_MAP.get(finish_reason, "end_turn")
# 转换 usage
usage = response.get("usage", {})
claude_usage = {
"input_tokens": usage.get("prompt_tokens", 0),
"output_tokens": usage.get("completion_tokens", 0),
}
return {
"id": f"msg_{response.get('id', uuid.uuid4().hex[:8])}",
"type": "message",
"role": "assistant",
"model": response.get("model", ""),
"content": content,
"stop_reason": stop_reason,
"stop_sequence": None,
"usage": claude_usage,
}
def _empty_claude_response(self, response: Dict[str, Any]) -> Dict[str, Any]:
"""构建空的 Claude 响应"""
return {
"id": f"msg_{response.get('id', uuid.uuid4().hex[:8])}",
"type": "message",
"role": "assistant",
"model": response.get("model", ""),
"content": [],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 0, "output_tokens": 0},
}
# ==================== 流式转换 ====================
def convert_stream_chunk(
self,
chunk: Dict[str, Any],
state: Optional["StreamConversionState"] = None,
) -> List[Dict[str, Any]]:
"""
将 OpenAI SSE chunk 转换为 Claude SSE 事件
Args:
chunk: OpenAI SSE chunk
state: 流式转换状态
Returns:
Claude SSE 事件列表
"""
from src.core.api_format.conversion.state import StreamConversionState
if state is None:
state = StreamConversionState()
events: List[Dict[str, Any]] = []
choices = chunk.get("choices") or []
if not choices:
return events
choice = choices[0]
delta = choice.get("delta") or {}
finish_reason = choice.get("finish_reason")
# 处理角色(第一个 chunk
role = delta.get("role")
if role and not state.message_started:
msg_id = state.message_id or f"msg_{uuid.uuid4().hex[:8]}"
events.append(
{
"type": "message_start",
"message": {
"id": msg_id,
"type": "message",
"role": role,
"model": state.model,
"content": [],
"stop_reason": None,
"stop_sequence": None,
},
}
)
state.message_started = True
# 处理文本内容
content_delta = delta.get("content")
if isinstance(content_delta, str):
events.append(
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": content_delta},
}
)
# 处理工具调用
tool_calls = delta.get("tool_calls") or []
for tool_call in tool_calls:
index = tool_call.get("index", 0)
# 工具调用开始
if "id" in tool_call:
function = tool_call.get("function", {})
events.append(
{
"type": "content_block_start",
"index": index,
"content_block": {
"type": self.CONTENT_TYPE_TOOL_USE,
"id": tool_call["id"],
"name": function.get("name", ""),
},
}
)
# 工具调用参数增量
function = tool_call.get("function", {})
if "arguments" in function:
events.append(
{
"type": "content_block_delta",
"index": index,
"delta": {
"type": "input_json_delta",
"partial_json": function.get("arguments", ""),
},
}
)
# 处理结束
if finish_reason:
stop_reason = self.FINISH_REASON_MAP.get(finish_reason, "end_turn")
events.append(
{
"type": "message_delta",
"delta": {"stop_reason": stop_reason},
}
)
return events
# ==================== 工具方法 ====================
def _collapse_content(
self, content: Optional[Union[str, List[Dict[str, Any]]]]
) -> Optional[str]:
"""折叠内容为字符串"""
if isinstance(content, str):
return content
if not content:
return None
text_parts = [part.get("text", "") for part in content if part.get("type") == "text"]
return "\n\n".join(filter(None, text_parts)) or None
__all__ = ["OpenAIToClaudeConverter"]

View File

@@ -0,0 +1,133 @@
"""
字段映射配置(集中定义)
该文件用于承载:
- role/stop_reason/usage/error 的常见映射表
注意:
- conversion 层只负责 body 结构转换,不维护 model_in_body/stream_in_body/auth_header 等元数据;
这些应复用 `src/core/api_format/metadata.py`API_FORMAT_DEFINITIONS作为单一事实来源。
"""
from __future__ import annotations
from typing import Dict, Set
# 角色映射仅作为辅助system/tool 的具体落点以 Normalizer 规则为准)
ROLE_MAPPINGS: Dict[str, Dict[str, str]] = {
"OPENAI": {
"user": "user",
"assistant": "assistant",
"system": "system",
"developer": "developer",
"tool": "tool",
},
"CLAUDE": {"user": "user", "assistant": "assistant"},
"GEMINI": {"user": "user", "assistant": "model"},
}
# 停止原因映射internal -> provider未知值使用 UNKNOWN 并写入 extra/raw
STOP_REASON_MAPPINGS: Dict[str, Dict[str, str]] = {
"CLAUDE": {
"end_turn": "end_turn",
"max_tokens": "max_tokens",
"stop_sequence": "stop_sequence",
"tool_use": "tool_use",
# Claude 通常以错误/阻断体现,这里仅兜底
"content_filtered": "end_turn",
"unknown": "end_turn",
},
"OPENAI": {
"end_turn": "stop",
"max_tokens": "length",
"stop_sequence": "stop",
"tool_use": "tool_calls",
"content_filtered": "content_filter",
"unknown": "stop",
},
"GEMINI": {
"end_turn": "STOP",
"max_tokens": "MAX_TOKENS",
"stop_sequence": "STOP",
# Gemini finishReason 对工具调用并没有稳定等价枚举,这里保守兜底为 STOP
"tool_use": "STOP",
"content_filtered": "SAFETY",
"unknown": "OTHER",
},
}
# 使用量字段映射provider usage field -> internal UsageInfo field
USAGE_FIELD_MAPPINGS: Dict[str, Dict[str, str]] = {
"CLAUDE": {
"input_tokens": "input_tokens",
"output_tokens": "output_tokens",
"cache_read_input_tokens": "cache_read_tokens",
"cache_creation_input_tokens": "cache_write_tokens",
},
"OPENAI": {
"prompt_tokens": "input_tokens",
"completion_tokens": "output_tokens",
"total_tokens": "total_tokens",
},
"GEMINI": {
"promptTokenCount": "input_tokens",
"candidatesTokenCount": "output_tokens",
"totalTokenCount": "total_tokens",
"cachedContentTokenCount": "cache_read_tokens",
},
}
# 错误类型映射provider -> internal ErrorType.value
ERROR_TYPE_MAPPINGS: Dict[str, Dict[str, str]] = {
"CLAUDE": {
"invalid_request_error": "invalid_request",
"authentication_error": "authentication",
"permission_error": "permission_denied",
"not_found_error": "not_found",
"rate_limit_error": "rate_limit",
"timeout_error": "server_error",
"overloaded_error": "overloaded",
"billing_error": "permission_denied",
"api_error": "server_error",
},
"OPENAI": {
"invalid_request_error": "invalid_request",
"invalid_api_key": "authentication",
"insufficient_quota": "rate_limit",
"rate_limit_exceeded": "rate_limit",
"server_error": "server_error",
"context_length_exceeded": "context_length_exceeded",
"content_policy_violation": "content_filtered",
},
"GEMINI": {
"INVALID_ARGUMENT": "invalid_request",
"UNAUTHENTICATED": "authentication",
"PERMISSION_DENIED": "permission_denied",
"NOT_FOUND": "not_found",
"RESOURCE_EXHAUSTED": "rate_limit",
"INTERNAL": "server_error",
"UNAVAILABLE": "overloaded",
},
}
# 可重试的错误类型internal ErrorType.value
RETRYABLE_ERROR_TYPES: Set[str] = {
"rate_limit",
"overloaded",
"server_error",
}
__all__ = [
"ROLE_MAPPINGS",
"STOP_REASON_MAPPINGS",
"USAGE_FIELD_MAPPINGS",
"ERROR_TYPE_MAPPINGS",
"RETRYABLE_ERROR_TYPES",
]

View File

@@ -0,0 +1,297 @@
"""
格式转换内部表示Internal / Canonical Format
该模块定义 Hub-and-Spoke 架构的“中间表示法”,用于把不同 Provider 的请求/响应/流式事件
统一映射到稳定的内部结构,再转换为目标格式。
设计原则:
- 类型安全:尽量用 dataclass + Enum 表达语义,便于 IDE/静态检查
- 可扩展:未知/不可逆字段写入 extra/raw避免静默丢失
- 兼容优先UnknownBlock 在内部保留,但默认在输出阶段丢弃(可观测、可随时调整策略)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, FrozenSet, List, Optional, Union
class Role(str, Enum):
USER = "user"
ASSISTANT = "assistant"
SYSTEM = "system"
DEVELOPER = "developer"
TOOL = "tool"
UNKNOWN = "unknown"
class ContentType(str, Enum):
TEXT = "text"
IMAGE = "image"
TOOL_USE = "tool_use"
TOOL_RESULT = "tool_result"
UNKNOWN = "unknown"
class StopReason(str, Enum):
END_TURN = "end_turn"
MAX_TOKENS = "max_tokens"
STOP_SEQUENCE = "stop_sequence"
TOOL_USE = "tool_use"
# Claude streaming 里会出现官方文档枚举pause_turn / refusal
PAUSE_TURN = "pause_turn"
REFUSAL = "refusal"
CONTENT_FILTERED = "content_filtered"
UNKNOWN = "unknown"
class ErrorType(str, Enum):
INVALID_REQUEST = "invalid_request"
AUTHENTICATION = "authentication"
PERMISSION_DENIED = "permission_denied"
NOT_FOUND = "not_found"
RATE_LIMIT = "rate_limit"
OVERLOADED = "overloaded"
SERVER_ERROR = "server_error"
CONTENT_FILTERED = "content_filtered"
CONTEXT_LENGTH_EXCEEDED = "context_length_exceeded"
UNKNOWN = "unknown"
@dataclass
class TextBlock:
"""文本内容块"""
type: ContentType = field(default=ContentType.TEXT, init=False)
text: str = ""
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ImageBlock:
"""图片内容块"""
type: ContentType = field(default=ContentType.IMAGE, init=False)
# base64 编码的图片数据(二选一)
data: Optional[str] = None
media_type: Optional[str] = None
# 或者 URL 引用
url: Optional[str] = None
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ToolUseBlock:
"""工具调用内容块"""
type: ContentType = field(default=ContentType.TOOL_USE, init=False)
tool_id: str = ""
tool_name: str = ""
tool_input: Dict[str, Any] = field(default_factory=dict)
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ToolResultBlock:
"""工具结果内容块"""
type: ContentType = field(default=ContentType.TOOL_RESULT, init=False)
tool_use_id: str = "" # 对应的 ToolUseBlock.tool_id
# 工具输出可能是纯文本,也可能是结构化 JSONGemini functionResponse 等)
output: Any = None
content_text: Optional[str] = None
is_error: bool = False
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class UnknownBlock:
"""未知内容块(用于前向兼容)"""
type: ContentType = field(default=ContentType.UNKNOWN, init=False)
raw_type: str = "" # 原始的类型字符串(各格式不一致)
payload: Dict[str, Any] = field(default_factory=dict) # 原始结构(尽量保持)
extra: Dict[str, Any] = field(default_factory=dict)
ContentBlock = Union[TextBlock, ImageBlock, ToolUseBlock, ToolResultBlock, UnknownBlock]
@dataclass
class InternalMessage:
"""统一的消息表示"""
role: Role
content: List[ContentBlock] # 统一使用列表,纯文本用单个 TextBlock
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ToolDefinition:
"""统一的工具定义"""
name: str
description: Optional[str] = None
parameters: Optional[Dict[str, Any]] = None # JSON Schema
extra: Dict[str, Any] = field(default_factory=dict)
class ToolChoiceType(str, Enum):
AUTO = "auto"
NONE = "none"
REQUIRED = "required"
TOOL = "tool"
@dataclass
class ToolChoice:
"""统一的工具选择"""
type: ToolChoiceType
tool_name: Optional[str] = None
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class InstructionSegment:
"""系统/开发者指令段(用于保留 OpenAI system/developer 结构与顺序)"""
role: Role # 仅允许 Role.SYSTEM / Role.DEVELOPER
text: str = ""
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class InternalRequest:
"""统一的请求表示"""
model: str
messages: List[InternalMessage]
# 指令层:保留 system/developer 结构与顺序
instructions: List[InstructionSegment] = field(default_factory=list)
# 兼容字段instructions 的 join 文本(无 role 标签),用于 Claude/Gemini 这类仅接受字符串 system 的格式
system: Optional[str] = None
max_tokens: Optional[int] = None
temperature: Optional[float] = None
top_p: Optional[float] = None
top_k: Optional[int] = None
stop_sequences: Optional[List[str]] = None
stream: bool = False
tools: Optional[List[ToolDefinition]] = None
tool_choice: Optional[ToolChoice] = None # auto/none/required 或指定 tool_name
extra: Dict[str, Any] = field(default_factory=dict) # 未识别字段透传
def to_debug_dict(self) -> Dict[str, Any]:
"""用于日志和调试的简化表示"""
return {
"model": self.model,
"instruction_count": len(self.instructions),
"message_count": len(self.messages),
"has_system": bool(self.instructions) or bool(self.system),
"max_tokens": self.max_tokens,
"stream": self.stream,
"tool_count": len(self.tools) if self.tools else 0,
"extra_keys": list(self.extra.keys()),
}
@dataclass
class UsageInfo:
"""统一的使用量信息"""
input_tokens: int = 0
output_tokens: int = 0
total_tokens: int = 0
cache_read_tokens: int = 0
cache_write_tokens: int = 0
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class InternalResponse:
"""统一的响应表示"""
id: str
model: str
content: List[ContentBlock]
stop_reason: Optional[StopReason] = None
usage: Optional[UsageInfo] = None
extra: Dict[str, Any] = field(default_factory=dict)
def to_debug_dict(self) -> Dict[str, Any]:
"""用于日志和调试的简化表示"""
usage = None
if self.usage:
usage = {
"input": self.usage.input_tokens,
"output": self.usage.output_tokens,
}
return {
"id": self.id,
"model": self.model,
"content_block_count": len(self.content),
"stop_reason": self.stop_reason.value if self.stop_reason else None,
"usage": usage,
"extra_keys": list(self.extra.keys()),
}
@dataclass
class InternalError:
"""统一的错误表示"""
type: ErrorType
message: str
code: Optional[str] = None # 原始错误码
param: Optional[str] = None # 导致错误的参数
retryable: bool = False # 是否可重试
extra: Dict[str, Any] = field(default_factory=dict)
def to_debug_dict(self) -> Dict[str, Any]:
"""用于日志和调试"""
return {
"type": self.type.value,
"message": self.message,
"code": self.code,
"param": self.param,
"retryable": self.retryable,
"extra": self.extra,
}
@dataclass(frozen=True)
class FormatCapabilities:
supports_stream: bool = True
supports_error_conversion: bool = True
supports_tools: bool = True
supports_images: bool = False
supported_features: FrozenSet[str] = field(default_factory=frozenset)
__all__ = [
"Role",
"ContentType",
"StopReason",
"ErrorType",
"ToolChoiceType",
"TextBlock",
"ImageBlock",
"ToolUseBlock",
"ToolResultBlock",
"UnknownBlock",
"ContentBlock",
"InternalMessage",
"InstructionSegment",
"ToolDefinition",
"ToolChoice",
"InternalRequest",
"UsageInfo",
"InternalResponse",
"InternalError",
"FormatCapabilities",
]

View File

@@ -0,0 +1,84 @@
"""
格式标准化器接口FormatNormalizer
每个格式OpenAI/Claude/Gemini实现一个 Normalizer将 provider 结构转换到 internal
再从 internal 输出到目标格式。
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Dict, List
from .internal import FormatCapabilities, InternalError, InternalRequest, InternalResponse
from .stream_events import InternalStreamEvent
from .stream_state import StreamState
class FormatNormalizer(ABC):
"""格式标准化器基类"""
FORMAT_ID: str # 如 "CLAUDE", "OPENAI", "GEMINI"
capabilities: FormatCapabilities
# ============ 请求转换 ============
@abstractmethod
def request_to_internal(self, request: Dict[str, Any]) -> InternalRequest:
"""将格式特定请求转换为内部表示"""
raise NotImplementedError
@abstractmethod
def request_from_internal(self, internal: InternalRequest) -> Dict[str, Any]:
"""将内部表示转换为格式特定请求"""
raise NotImplementedError
# ============ 响应转换 ============
@abstractmethod
def response_to_internal(self, response: Dict[str, Any]) -> InternalResponse:
"""将格式特定响应转换为内部表示"""
raise NotImplementedError
@abstractmethod
def response_from_internal(self, internal: InternalResponse) -> Dict[str, Any]:
"""将内部表示转换为格式特定响应"""
raise NotImplementedError
# ============ 流式转换(可选) ============
def stream_chunk_to_internal(
self,
chunk: Dict[str, Any],
state: StreamState,
) -> List[InternalStreamEvent]:
"""将格式特定流式块转换为内部事件"""
raise NotImplementedError
def stream_event_from_internal(
self,
event: InternalStreamEvent,
state: StreamState,
) -> List[Dict[str, Any]]:
"""将内部事件转换为格式特定流式块"""
raise NotImplementedError
# ============ 错误转换(可选) ============
def is_error_response(self, response: Dict[str, Any]) -> bool:
"""基于 body 的兜底判断(不可靠),子类可覆盖"""
return False
def error_to_internal(self, error_response: Dict[str, Any]) -> InternalError:
"""将格式特定错误转换为内部表示"""
raise NotImplementedError
def error_from_internal(self, internal: InternalError) -> Dict[str, Any]:
"""将内部错误表示转换为格式特定错误"""
raise NotImplementedError
__all__ = [
"FormatNormalizer",
]

View File

@@ -0,0 +1,12 @@
"""
Normalizers
实现各格式 <-> internal 的标准化器。
本目录在 Phase 1 仅创建结构;具体实现将在 Phase 2+ 补齐。
"""
from __future__ import annotations
__all__: list[str] = []

View File

@@ -0,0 +1,993 @@
"""
Claude Messages API Normalizer
负责:
- Claude Messages request/response <-> Internal 表示转换
- 可选Claude streaming event <-> InternalStreamEvent
- 可选Claude error <-> InternalError
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional, Tuple
from src.core.api_format.conversion.field_mappings import (
ERROR_TYPE_MAPPINGS,
RETRYABLE_ERROR_TYPES,
STOP_REASON_MAPPINGS,
USAGE_FIELD_MAPPINGS,
)
from src.core.api_format.conversion.internal import (
ContentBlock,
ContentType,
ErrorType,
FormatCapabilities,
ImageBlock,
InstructionSegment,
InternalError,
InternalMessage,
InternalRequest,
InternalResponse,
Role,
StopReason,
TextBlock,
ToolChoice,
ToolChoiceType,
ToolDefinition,
ToolResultBlock,
ToolUseBlock,
UnknownBlock,
UsageInfo,
)
from src.core.api_format.conversion.normalizer import FormatNormalizer
from src.core.api_format.conversion.stream_events import (
ContentBlockStartEvent,
ContentBlockStopEvent,
ContentDeltaEvent,
ErrorEvent,
InternalStreamEvent,
MessageStartEvent,
MessageStopEvent,
ToolCallDeltaEvent,
)
from src.core.api_format.conversion.stream_state import StreamState
class ClaudeNormalizer(FormatNormalizer):
FORMAT_ID = "CLAUDE"
capabilities = FormatCapabilities(
supports_stream=True,
supports_error_conversion=True,
supports_tools=True,
supports_images=True,
)
_CLAUDE_STOP_TO_INTERNAL: Dict[str, StopReason] = {
"end_turn": StopReason.END_TURN,
"max_tokens": StopReason.MAX_TOKENS,
"stop_sequence": StopReason.STOP_SEQUENCE,
"tool_use": StopReason.TOOL_USE,
"pause_turn": StopReason.PAUSE_TURN,
"refusal": StopReason.REFUSAL,
"content_filtered": StopReason.CONTENT_FILTERED,
}
_ERROR_TYPE_TO_CLAUDE: Dict[ErrorType, str] = {
ErrorType.INVALID_REQUEST: "invalid_request_error",
ErrorType.AUTHENTICATION: "authentication_error",
ErrorType.PERMISSION_DENIED: "permission_error",
ErrorType.NOT_FOUND: "not_found_error",
ErrorType.RATE_LIMIT: "rate_limit_error",
ErrorType.OVERLOADED: "overloaded_error",
ErrorType.SERVER_ERROR: "api_error",
ErrorType.CONTENT_FILTERED: "invalid_request_error",
ErrorType.CONTEXT_LENGTH_EXCEEDED: "invalid_request_error",
ErrorType.UNKNOWN: "api_error",
}
# =========================
# Requests
# =========================
def request_to_internal(self, request: Dict[str, Any]) -> InternalRequest:
model = str(request.get("model") or "")
dropped: Dict[str, int] = {}
instructions: List[InstructionSegment] = []
# 顶层 system 先进入 instructions保持确定性优先级
sys_value = request.get("system")
sys_text, sys_dropped = self._collapse_claude_system(sys_value)
self._merge_dropped(dropped, sys_dropped)
if sys_text:
instructions.append(InstructionSegment(role=Role.SYSTEM, text=sys_text))
messages: List[InternalMessage] = []
for msg in request.get("messages") or []:
if not isinstance(msg, dict):
dropped["claude_message_non_dict"] = dropped.get("claude_message_non_dict", 0) + 1
continue
role = str(msg.get("role") or "unknown")
# 兼容:少数客户端可能把 system/developer 混进 messages[]
if role in ("system", "developer"):
text, md = self._collapse_claude_system(msg.get("content"))
self._merge_dropped(dropped, md)
if text:
instructions.append(
InstructionSegment(
role=Role.SYSTEM if role == "system" else Role.DEVELOPER,
text=text,
extra=self._extract_extra(msg, {"role", "content"}),
)
)
continue
imsg, md = self._claude_message_to_internal(msg)
self._merge_dropped(dropped, md)
if imsg is not None:
messages.append(imsg)
system_text = self._join_instructions(instructions)
tools = self._claude_tools_to_internal(request.get("tools"))
tool_choice = self._claude_tool_choice_to_internal(request.get("tool_choice"))
internal = InternalRequest(
model=model,
messages=messages,
instructions=instructions,
system=system_text,
max_tokens=self._optional_int(request.get("max_tokens")),
temperature=self._optional_float(request.get("temperature")),
top_p=self._optional_float(request.get("top_p")),
top_k=self._optional_int(request.get("top_k")),
stop_sequences=self._coerce_str_list(request.get("stop_sequences")),
stream=bool(request.get("stream") or False),
tools=tools,
tool_choice=tool_choice,
extra={"claude": self._extract_extra(request, {"messages"})},
)
if dropped:
internal.extra.setdefault("raw", {})["dropped_blocks"] = dropped
return internal
def request_from_internal(self, internal: InternalRequest) -> Dict[str, Any]:
system_text = internal.system or self._join_instructions(internal.instructions)
# Claude Messages API: messages[] 仅允许 user/assistant且需要交替这里做最小修复
fixed_messages = self._coerce_claude_message_sequence(internal.messages)
out_messages: List[Dict[str, Any]] = [self._internal_message_to_claude(m) for m in fixed_messages]
result: Dict[str, Any] = {
"model": internal.model,
"messages": out_messages,
"max_tokens": internal.max_tokens if internal.max_tokens is not None else 4096,
}
if system_text:
result["system"] = system_text
if internal.temperature is not None:
result["temperature"] = internal.temperature
if internal.top_p is not None:
result["top_p"] = internal.top_p
if internal.top_k is not None:
result["top_k"] = internal.top_k
if internal.stop_sequences:
result["stop_sequences"] = list(internal.stop_sequences)
if internal.stream:
result["stream"] = True
if internal.tools:
result["tools"] = [
{
"name": t.name,
"description": t.description,
"input_schema": t.parameters or {},
**(t.extra.get("claude") or {}),
}
for t in internal.tools
]
if internal.tool_choice:
result["tool_choice"] = self._tool_choice_to_claude(internal.tool_choice)
# 恢复 Claude 特有字段(如 metadata
claude_extra = internal.extra.get("claude") if isinstance(internal.extra, dict) else None
if isinstance(claude_extra, dict):
if "metadata" in claude_extra:
result["metadata"] = claude_extra["metadata"]
return result
# =========================
# Responses
# =========================
def response_to_internal(self, response: Dict[str, Any]) -> InternalResponse:
rid = str(response.get("id") or "")
model = str(response.get("model") or "")
blocks, dropped = self._claude_content_to_blocks(response.get("content"))
raw_stop = response.get("stop_reason")
stop_reason: Optional[StopReason] = None
if raw_stop is not None:
stop_reason = self._CLAUDE_STOP_TO_INTERNAL.get(str(raw_stop), StopReason.UNKNOWN)
usage_info = self._claude_usage_to_internal(response.get("usage"))
extra: Dict[str, Any] = {}
if raw_stop is not None:
extra.setdefault("raw", {})["stop_reason"] = raw_stop
internal = InternalResponse(
id=rid,
model=model,
content=blocks,
stop_reason=stop_reason,
usage=usage_info,
extra=extra,
)
if dropped:
internal.extra.setdefault("raw", {})["dropped_blocks"] = dropped
return internal
def response_from_internal(self, internal: InternalResponse) -> Dict[str, Any]:
cid = internal.id or "unknown"
if not cid.startswith("msg_"):
cid = f"msg_{cid}"
content: List[Dict[str, Any]] = []
for b in internal.content:
if isinstance(b, TextBlock):
if b.text:
content.append({"type": "text", "text": b.text})
continue
if isinstance(b, ToolUseBlock):
content.append(
{
"type": "tool_use",
"id": b.tool_id,
"name": b.tool_name,
"input": b.tool_input or {},
}
)
continue
if isinstance(b, ImageBlock):
if b.data and b.media_type:
content.append(
{
"type": "image",
"source": {
"type": "base64",
"media_type": b.media_type,
"data": b.data,
},
}
)
elif b.url:
content.append({"type": "text", "text": f"[Image: {b.url}]"})
continue
# Unknown/ToolResult 默认丢弃
stop_reason = None
if internal.stop_reason is not None:
stop_reason = STOP_REASON_MAPPINGS.get("CLAUDE", {}).get(internal.stop_reason.value, "end_turn")
usage: Dict[str, Any] = {"input_tokens": 0, "output_tokens": 0}
if internal.usage:
usage = {
"input_tokens": int(internal.usage.input_tokens),
"output_tokens": int(internal.usage.output_tokens),
}
if internal.usage.cache_read_tokens:
usage["cache_read_input_tokens"] = int(internal.usage.cache_read_tokens)
if internal.usage.cache_write_tokens:
usage["cache_creation_input_tokens"] = int(internal.usage.cache_write_tokens)
return {
"id": cid,
"type": "message",
"role": "assistant",
"model": internal.model,
"content": content,
"stop_reason": stop_reason,
"stop_sequence": None,
"usage": usage,
}
# =========================
# Streaming (Claude SSE events)
# =========================
def stream_chunk_to_internal(
self,
chunk: Dict[str, Any],
state: StreamState,
) -> List[InternalStreamEvent]:
ss = state.substate(self.FORMAT_ID)
events: List[InternalStreamEvent] = []
event_type = chunk.get("type")
if event_type is None:
return events
event_type = str(event_type)
if event_type == "ping":
return events
if event_type == "message_start":
message_raw = chunk.get("message")
message: Dict[str, Any] = message_raw if isinstance(message_raw, dict) else {}
msg_id = str(message.get("id") or "")
model = str(message.get("model") or "")
state.message_id = msg_id or state.message_id
state.model = model or state.model
ss["message_started"] = True
ss.setdefault("block_index_to_tool_id", {})
events.append(MessageStartEvent(message_id=msg_id, model=model))
return events
if event_type == "content_block_start":
index = int(chunk.get("index") or 0)
block_raw = chunk.get("content_block")
block: Dict[str, Any] = block_raw if isinstance(block_raw, dict) else {}
btype = str(block.get("type") or "unknown")
if btype == "text":
events.append(ContentBlockStartEvent(block_index=index, block_type=ContentType.TEXT))
return events
if btype == "tool_use":
tool_id = str(block.get("id") or "")
tool_name = str(block.get("name") or "")
mapping = ss.get("block_index_to_tool_id")
if isinstance(mapping, dict):
mapping[index] = tool_id
events.append(
ContentBlockStartEvent(
block_index=index,
block_type=ContentType.TOOL_USE,
tool_id=tool_id or None,
tool_name=tool_name or None,
)
)
return events
events.append(
ContentBlockStartEvent(
block_index=index,
block_type=ContentType.UNKNOWN,
extra={"raw": {"claude_block_type": btype, "content_block": block}},
)
)
return events
if event_type == "content_block_delta":
index = int(chunk.get("index") or 0)
delta_raw = chunk.get("delta")
delta: Dict[str, Any] = delta_raw if isinstance(delta_raw, dict) else {}
dtype = str(delta.get("type") or "unknown")
if dtype == "text_delta":
text = delta.get("text")
if text is None:
return events
events.append(ContentDeltaEvent(block_index=index, text_delta=str(text)))
return events
if dtype == "input_json_delta":
partial = delta.get("partial_json")
if partial is None:
return events
mapping = ss.get("block_index_to_tool_id")
tool_id = ""
if isinstance(mapping, dict):
tool_id = str(mapping.get(index) or "")
events.append(ToolCallDeltaEvent(block_index=index, tool_id=tool_id, input_delta=str(partial)))
return events
return events
if event_type == "content_block_stop":
index = int(chunk.get("index") or 0)
events.append(ContentBlockStopEvent(block_index=index))
return events
if event_type == "message_delta":
delta_raw2 = chunk.get("delta")
delta2: Dict[str, Any] = delta_raw2 if isinstance(delta_raw2, dict) else {}
raw_stop = delta2.get("stop_reason")
if raw_stop is not None:
ss["stop_reason"] = str(raw_stop)
usage = chunk.get("usage")
if isinstance(usage, dict):
ss["usage"] = usage
return events
if event_type == "message_stop":
raw_stop = ss.get("stop_reason")
stop_reason: Optional[StopReason] = None
if raw_stop is not None:
stop_reason = self._CLAUDE_STOP_TO_INTERNAL.get(str(raw_stop), StopReason.UNKNOWN)
usage_info = self._claude_usage_to_internal(ss.get("usage"))
events.append(MessageStopEvent(stop_reason=stop_reason, usage=usage_info))
return events
if event_type == "error":
internal_error = self.error_to_internal(chunk)
events.append(ErrorEvent(error=internal_error))
return events
return events
def stream_event_from_internal(
self,
event: InternalStreamEvent,
state: StreamState,
) -> List[Dict[str, Any]]:
ss = state.substate(self.FORMAT_ID)
out: List[Dict[str, Any]] = []
if isinstance(event, MessageStartEvent):
state.message_id = event.message_id or state.message_id
state.model = event.model or state.model
ss.setdefault("block_index_to_tool_id", {})
message_obj: Dict[str, Any] = {
"id": state.message_id or "msg_stream",
"type": "message",
"role": "assistant",
"model": state.model or "",
"content": [],
"stop_reason": None,
"stop_sequence": None,
}
# Claude CLI 客户端对接口格式要求严格,必须包含 usage 字段
# 即使没有 usage 信息也要提供默认值
if event.usage:
message_obj["usage"] = self._usage_to_claude(event.usage)
else:
message_obj["usage"] = {"input_tokens": 0, "output_tokens": 0}
out.append({"type": "message_start", "message": message_obj})
return out
if isinstance(event, ContentBlockStartEvent):
if event.block_type == ContentType.TEXT:
out.append(
{
"type": "content_block_start",
"index": int(event.block_index),
"content_block": {"type": "text", "text": ""},
}
)
return out
if event.block_type == ContentType.TOOL_USE:
tool_id = event.tool_id or ""
tool_name = event.tool_name or ""
mapping = ss.get("block_index_to_tool_id")
if isinstance(mapping, dict):
mapping[int(event.block_index)] = tool_id
out.append(
{
"type": "content_block_start",
"index": int(event.block_index),
"content_block": {"type": "tool_use", "id": tool_id, "name": tool_name},
}
)
return out
return out
if isinstance(event, ContentDeltaEvent):
if event.text_delta:
out.append(
{
"type": "content_block_delta",
"index": int(event.block_index),
"delta": {"type": "text_delta", "text": event.text_delta},
}
)
return out
if isinstance(event, ToolCallDeltaEvent):
if event.input_delta:
out.append(
{
"type": "content_block_delta",
"index": int(event.block_index),
"delta": {"type": "input_json_delta", "partial_json": event.input_delta},
}
)
return out
if isinstance(event, ContentBlockStopEvent):
out.append({"type": "content_block_stop", "index": int(event.block_index)})
return out
if isinstance(event, MessageStopEvent):
stop_reason = None
if event.stop_reason is not None:
stop_reason = STOP_REASON_MAPPINGS.get("CLAUDE", {}).get(event.stop_reason.value, "end_turn")
msg_delta: Dict[str, Any] = {
"type": "message_delta",
"delta": {"stop_reason": stop_reason},
}
if event.usage:
msg_delta["usage"] = self._usage_to_claude(event.usage)
out.append(msg_delta)
out.append({"type": "message_stop"})
return out
if isinstance(event, ErrorEvent):
out.append(self.error_from_internal(event.error))
return out
return out
# =========================
# Error conversion
# =========================
def is_error_response(self, response: Dict[str, Any]) -> bool:
if not isinstance(response, dict):
return False
if response.get("type") == "error":
return True
return "error" in response
def error_to_internal(self, error_response: Dict[str, Any]) -> InternalError:
err: Dict[str, Any] = {}
if isinstance(error_response, dict):
err_raw = error_response.get("error")
err = err_raw if isinstance(err_raw, dict) else {}
raw_type = err.get("type")
mapped = ERROR_TYPE_MAPPINGS.get("CLAUDE", {}).get(str(raw_type), ErrorType.UNKNOWN.value)
internal_type = self._error_type_from_value(mapped)
retryable = internal_type.value in RETRYABLE_ERROR_TYPES
return InternalError(
type=internal_type,
message=str(err.get("message") or ""),
code=err.get("code") if err.get("code") is None else str(err.get("code")),
param=err.get("param") if err.get("param") is None else str(err.get("param")),
retryable=retryable,
extra={"claude": {"error": err}, "raw": {"type": raw_type}},
)
def error_from_internal(self, internal: InternalError) -> Dict[str, Any]:
type_str = self._ERROR_TYPE_TO_CLAUDE.get(internal.type, "api_error")
payload: Dict[str, Any] = {"type": type_str, "message": internal.message}
if internal.param is not None:
payload["param"] = internal.param
if internal.code is not None:
payload["code"] = internal.code
return {"type": "error", "error": payload}
# =========================
# Helpers
# =========================
def _claude_message_to_internal(self, msg: Dict[str, Any]) -> Tuple[Optional[InternalMessage], Dict[str, int]]:
dropped: Dict[str, int] = {}
role_raw = str(msg.get("role") or "unknown")
if role_raw == "user":
role = Role.USER
elif role_raw == "assistant":
role = Role.ASSISTANT
else:
role = Role.UNKNOWN
blocks, bd = self._claude_content_to_blocks(msg.get("content"))
self._merge_dropped(dropped, bd)
return (
InternalMessage(
role=role,
content=blocks,
extra=self._extract_extra(msg, {"role", "content"}),
),
dropped,
)
def _claude_content_to_blocks(self, content: Any) -> Tuple[List[ContentBlock], Dict[str, int]]:
dropped: Dict[str, int] = {}
if content is None:
return [], dropped
if isinstance(content, str):
return ([TextBlock(text=content)] if content else []), dropped
if not isinstance(content, list):
dropped["claude_content_non_list"] = dropped.get("claude_content_non_list", 0) + 1
return [], dropped
blocks: List[ContentBlock] = []
for block in content:
if not isinstance(block, dict):
dropped["claude_block_non_dict"] = dropped.get("claude_block_non_dict", 0) + 1
continue
btype = str(block.get("type") or "unknown")
if btype == "text":
text = str(block.get("text") or "")
if text:
blocks.append(TextBlock(text=text, extra=self._extract_extra(block, {"type", "text"})))
continue
if btype == "image":
src_raw = block.get("source")
src: Dict[str, Any] = src_raw if isinstance(src_raw, dict) else {}
stype = src.get("type")
if stype == "base64":
data = src.get("data")
media_type = src.get("media_type")
if isinstance(data, str) and data and isinstance(media_type, str) and media_type:
blocks.append(ImageBlock(data=data, media_type=media_type))
continue
dropped["claude_image_unsupported"] = dropped.get("claude_image_unsupported", 0) + 1
blocks.append(UnknownBlock(raw_type="image", payload=block))
continue
if btype == "tool_use":
tool_id = str(block.get("id") or "")
tool_name = str(block.get("name") or "")
tool_input = block.get("input")
if not isinstance(tool_input, dict):
tool_input = {"raw": tool_input}
blocks.append(
ToolUseBlock(
tool_id=tool_id,
tool_name=tool_name,
tool_input=tool_input,
extra={"claude": self._extract_extra(block, {"type", "id", "name", "input"})},
)
)
continue
if btype == "tool_result":
tool_use_id = str(block.get("tool_use_id") or "")
is_error = bool(block.get("is_error") or False)
raw_content = block.get("content")
blocks.append(self._tool_result_from_claude(tool_use_id, raw_content, is_error, block))
continue
dropped_key = f"claude_block:{btype}"
dropped[dropped_key] = dropped.get(dropped_key, 0) + 1
blocks.append(UnknownBlock(raw_type=btype, payload=block))
return blocks, dropped
def _tool_result_from_claude(
self,
tool_use_id: str,
raw_content: Any,
is_error: bool,
raw_block: Dict[str, Any],
) -> ToolResultBlock:
if raw_content is None:
return ToolResultBlock(
tool_use_id=tool_use_id,
output=None,
content_text=None,
is_error=is_error,
extra={"claude": raw_block},
)
if isinstance(raw_content, str):
parsed: Any = None
try:
parsed = json.loads(raw_content)
except json.JSONDecodeError:
parsed = None
if parsed is not None:
return ToolResultBlock(
tool_use_id=tool_use_id,
output=parsed,
content_text=None,
is_error=is_error,
extra={"raw": {"content": raw_content}, "claude": raw_block},
)
return ToolResultBlock(
tool_use_id=tool_use_id,
output=None,
content_text=raw_content,
is_error=is_error,
extra={"claude": raw_block},
)
if isinstance(raw_content, list):
text_parts: List[str] = []
for part in raw_content:
if isinstance(part, dict) and part.get("type") == "text":
text = part.get("text")
if text:
text_parts.append(str(text))
collapsed = "\n\n".join(text_parts) if text_parts else None
return ToolResultBlock(
tool_use_id=tool_use_id,
output=None,
content_text=collapsed,
is_error=is_error,
extra={"raw": {"content": raw_content}, "claude": raw_block},
)
return ToolResultBlock(
tool_use_id=tool_use_id,
output=raw_content,
content_text=None,
is_error=is_error,
extra={"claude": raw_block},
)
def _collapse_claude_system(self, system_value: Any) -> Tuple[Optional[str], Dict[str, int]]:
dropped: Dict[str, int] = {}
if system_value is None:
return None, dropped
if isinstance(system_value, str):
return (system_value or None), dropped
if isinstance(system_value, list):
texts: List[str] = []
for item in system_value:
if not isinstance(item, dict):
dropped["claude_system_item_non_dict"] = dropped.get("claude_system_item_non_dict", 0) + 1
continue
if item.get("type") == "text":
text = item.get("text")
if text:
texts.append(str(text))
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
dropped["claude_system_unsupported"] = dropped.get("claude_system_unsupported", 0) + 1
return None, dropped
def _join_instructions(self, instructions: List[InstructionSegment]) -> Optional[str]:
parts = [seg.text for seg in instructions if seg.text]
joined = "\n\n".join(parts)
return joined or None
def _claude_tools_to_internal(self, tools: Any) -> Optional[List[ToolDefinition]]:
if not tools or not isinstance(tools, list):
return None
out: List[ToolDefinition] = []
for tool in tools:
if not isinstance(tool, dict):
continue
name = str(tool.get("name") or "")
if not name:
continue
out.append(
ToolDefinition(
name=name,
description=tool.get("description"),
parameters=tool.get("input_schema") if isinstance(tool.get("input_schema"), dict) else None,
extra={"claude": self._extract_extra(tool, {"name", "description", "input_schema"})},
)
)
return out or None
def _claude_tool_choice_to_internal(self, tool_choice: Any) -> Optional[ToolChoice]:
if tool_choice is None:
return None
if not isinstance(tool_choice, dict):
return ToolChoice(type=ToolChoiceType.AUTO, extra={"raw": tool_choice})
ctype = str(tool_choice.get("type") or "auto")
if ctype == "none":
return ToolChoice(type=ToolChoiceType.NONE, extra={"claude": tool_choice})
if ctype == "auto":
return ToolChoice(type=ToolChoiceType.AUTO, extra={"claude": tool_choice})
if ctype in ("any", "required"):
return ToolChoice(type=ToolChoiceType.REQUIRED, extra={"claude": tool_choice})
if ctype in ("tool_use", "tool"):
name = str(tool_choice.get("name") or "")
return ToolChoice(type=ToolChoiceType.TOOL, tool_name=name, extra={"claude": tool_choice})
return ToolChoice(type=ToolChoiceType.AUTO, extra={"claude": tool_choice})
def _tool_choice_to_claude(self, tool_choice: ToolChoice) -> Dict[str, Any]:
if tool_choice.type == ToolChoiceType.NONE:
return {"type": "none"}
if tool_choice.type == ToolChoiceType.AUTO:
return {"type": "auto"}
if tool_choice.type == ToolChoiceType.REQUIRED:
return {"type": "any"}
if tool_choice.type == ToolChoiceType.TOOL:
return {"type": "tool_use", "name": tool_choice.tool_name or ""}
return {"type": "auto"}
def _internal_message_to_claude(self, msg: InternalMessage) -> Dict[str, Any]:
role = "user" if msg.role == Role.USER else "assistant"
blocks: List[Dict[str, Any]] = []
text_parts: List[str] = []
for b in msg.content:
if isinstance(b, UnknownBlock):
continue
if isinstance(b, TextBlock):
if b.text:
text_parts.append(b.text)
continue
if isinstance(b, ImageBlock):
if role != "user":
if b.url:
text_parts.append(f"[Image: {b.url}]")
elif b.media_type and b.data:
text_parts.append("[Image]")
continue
if b.data and b.media_type:
blocks.append(
{
"type": "image",
"source": {"type": "base64", "media_type": b.media_type, "data": b.data},
}
)
elif b.url:
text_parts.append(f"[Image: {b.url}]")
continue
if isinstance(b, ToolUseBlock) and role == "assistant":
blocks.append(
{
"type": "tool_use",
"id": b.tool_id,
"name": b.tool_name,
"input": b.tool_input or {},
}
)
continue
if isinstance(b, ToolResultBlock) and role == "user":
if b.content_text is not None:
content: Any = b.content_text
elif b.output is None:
content = ""
elif isinstance(b.output, str):
content = b.output
else:
content = b.output
blocks.append(
{
"type": "tool_result",
"tool_use_id": b.tool_use_id,
"content": content,
"is_error": bool(b.is_error),
}
)
continue
if text_parts:
if blocks:
blocks = [{"type": "text", "text": "\n".join(text_parts)}] + blocks
else:
return {"role": role, "content": "\n".join(text_parts)}
return {"role": role, "content": blocks}
def _coerce_claude_message_sequence(self, messages: List[InternalMessage]) -> List[InternalMessage]:
normalized: List[InternalMessage] = []
for m in messages:
role = m.role
if role not in (Role.USER, Role.ASSISTANT):
role = Role.USER
normalized.append(InternalMessage(role=role, content=m.content, extra=m.extra))
if not normalized:
return []
if normalized[0].role != Role.USER:
normalized = [InternalMessage(role=Role.USER, content=[])] + normalized
merged: List[InternalMessage] = []
for m in normalized:
if merged and merged[-1].role == m.role:
merged[-1].content.extend(m.content)
continue
merged.append(m)
return merged
def _claude_usage_to_internal(self, usage: Any) -> Optional[UsageInfo]:
if not isinstance(usage, dict):
return None
mapping = USAGE_FIELD_MAPPINGS.get("CLAUDE", {})
fields: Dict[str, int] = {}
extra = self._extract_extra(usage, set(mapping.keys()))
for provider_key, internal_key in mapping.items():
if provider_key in usage and usage.get(provider_key) is not None:
try:
fields[internal_key] = int(usage.get(provider_key) or 0)
except (TypeError, ValueError):
continue
if "total_tokens" not in fields:
fields["total_tokens"] = int(fields.get("input_tokens", 0) + fields.get("output_tokens", 0))
return UsageInfo(
input_tokens=int(fields.get("input_tokens", 0)),
output_tokens=int(fields.get("output_tokens", 0)),
total_tokens=int(fields.get("total_tokens", 0)),
cache_read_tokens=int(fields.get("cache_read_tokens", 0)),
cache_write_tokens=int(fields.get("cache_write_tokens", 0)),
extra={"claude": extra} if extra else {},
)
def _usage_to_claude(self, usage: UsageInfo) -> Dict[str, Any]:
result: Dict[str, Any] = {
"input_tokens": int(usage.input_tokens),
"output_tokens": int(usage.output_tokens),
}
if usage.cache_read_tokens:
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)
return result
def _error_type_from_value(self, value: str) -> ErrorType:
try:
return ErrorType(value)
except ValueError:
return ErrorType.UNKNOWN
def _optional_int(self, value: Any) -> Optional[int]:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _optional_float(self, value: Any) -> Optional[float]:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _coerce_str_list(self, value: Any) -> Optional[List[str]]:
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

@@ -0,0 +1,18 @@
"""
Claude CLI Normalizer
CLAUDE_CLI 的请求/响应 body 与 CLAUDE 一致Anthropic Messages API差异主要在认证头。
因此这里复用 ClaudeNormalizer 的转换逻辑,仅更换 FORMAT_ID。
"""
from __future__ import annotations
from src.core.api_format.conversion.normalizers.claude import ClaudeNormalizer
class ClaudeCliNormalizer(ClaudeNormalizer):
FORMAT_ID = "CLAUDE_CLI"
__all__ = ["ClaudeCliNormalizer"]

View File

@@ -0,0 +1,944 @@
"""
Gemini (GenerateContent / streamGenerateContent) Normalizer
负责:
- Gemini request/response <-> Internal 表示转换
- 可选Gemini streaming chunk <-> InternalStreamEvent
- 可选Gemini error <-> InternalError
说明:
- 请求体字段在本项目中同时兼容 snake_case历史转换器产物与 camelCase官方/客户端输入)。
- 响应/流式通常为 camelCasecandidates/finishReason/usageMetadata/modelVersion
"""
from __future__ import annotations
import json
import time
from typing import Any, Dict, List, Optional, Tuple
from src.core.api_format.conversion.field_mappings import (
ERROR_TYPE_MAPPINGS,
RETRYABLE_ERROR_TYPES,
STOP_REASON_MAPPINGS,
USAGE_FIELD_MAPPINGS,
)
from src.core.api_format.conversion.internal import (
ContentBlock,
ContentType,
ErrorType,
FormatCapabilities,
ImageBlock,
InstructionSegment,
InternalError,
InternalMessage,
InternalRequest,
InternalResponse,
Role,
StopReason,
TextBlock,
ToolChoice,
ToolChoiceType,
ToolDefinition,
ToolResultBlock,
ToolUseBlock,
UnknownBlock,
UsageInfo,
)
from src.core.api_format.conversion.normalizer import FormatNormalizer
from src.core.api_format.conversion.stream_events import (
ContentBlockStartEvent,
ContentBlockStopEvent,
ContentDeltaEvent,
ErrorEvent,
InternalStreamEvent,
MessageStartEvent,
MessageStopEvent,
ToolCallDeltaEvent,
)
from src.core.api_format.conversion.stream_state import StreamState
class GeminiNormalizer(FormatNormalizer):
FORMAT_ID = "GEMINI"
capabilities = FormatCapabilities(
supports_stream=True,
supports_error_conversion=True,
supports_tools=True,
supports_images=True,
)
_FINISH_REASON_TO_STOP: Dict[str, StopReason] = {
"STOP": StopReason.END_TURN,
"MAX_TOKENS": StopReason.MAX_TOKENS,
"SAFETY": StopReason.CONTENT_FILTERED,
"RECITATION": StopReason.CONTENT_FILTERED,
"MALFORMED_FUNCTION_CALL": StopReason.TOOL_USE,
"OTHER": StopReason.UNKNOWN,
}
_ERROR_TYPE_TO_GEMINI_STATUS: Dict[ErrorType, str] = {
ErrorType.INVALID_REQUEST: "INVALID_ARGUMENT",
ErrorType.AUTHENTICATION: "UNAUTHENTICATED",
ErrorType.PERMISSION_DENIED: "PERMISSION_DENIED",
ErrorType.NOT_FOUND: "NOT_FOUND",
ErrorType.RATE_LIMIT: "RESOURCE_EXHAUSTED",
ErrorType.OVERLOADED: "UNAVAILABLE",
ErrorType.SERVER_ERROR: "INTERNAL",
ErrorType.CONTENT_FILTERED: "FAILED_PRECONDITION",
ErrorType.CONTEXT_LENGTH_EXCEEDED: "INVALID_ARGUMENT",
ErrorType.UNKNOWN: "INTERNAL",
}
# =========================
# Requests
# =========================
def request_to_internal(self, request: Dict[str, Any]) -> InternalRequest:
model = str(request.get("model") or "")
dropped: Dict[str, int] = {}
instructions: List[InstructionSegment] = []
system_text, sys_dropped = self._collapse_system_instruction(
request.get("system_instruction")
if "system_instruction" in request
else request.get("systemInstruction")
)
self._merge_dropped(dropped, sys_dropped)
if system_text:
instructions.append(InstructionSegment(role=Role.SYSTEM, text=system_text))
messages: List[InternalMessage] = []
contents = request.get("contents") or []
if isinstance(contents, list):
for content in contents:
if not isinstance(content, dict):
dropped["gemini_content_non_dict"] = dropped.get("gemini_content_non_dict", 0) + 1
continue
imsg, md = self._content_to_internal_message(content)
self._merge_dropped(dropped, md)
if imsg is not None:
messages.append(imsg)
else:
dropped["gemini_contents_non_list"] = dropped.get("gemini_contents_non_list", 0) + 1
generation_config = self._get_generation_config(request)
max_tokens = self._optional_int(
generation_config.get("max_output_tokens")
if isinstance(generation_config, dict)
else None
)
temperature = self._optional_float(
generation_config.get("temperature")
if isinstance(generation_config, dict)
else None
)
top_p = self._optional_float(
generation_config.get("top_p") if isinstance(generation_config, dict) else None
)
top_k = self._optional_int(
generation_config.get("top_k") if isinstance(generation_config, dict) else None
)
stop_sequences = None
if isinstance(generation_config, dict):
stop_sequences = self._coerce_str_list(generation_config.get("stop_sequences"))
tools = self._gemini_tools_to_internal(request.get("tools"))
tool_choice = self._gemini_tool_config_to_tool_choice(
request.get("tool_config")
if "tool_config" in request
else request.get("toolConfig")
)
internal = InternalRequest(
model=model,
messages=messages,
instructions=instructions,
system=self._join_instructions(instructions),
max_tokens=max_tokens,
temperature=temperature,
top_p=top_p,
top_k=top_k,
stop_sequences=stop_sequences,
stream=bool(request.get("stream") or False),
tools=tools,
tool_choice=tool_choice,
extra={"gemini": self._extract_extra(request, {"contents"})},
)
if dropped:
internal.extra.setdefault("raw", {})["dropped_blocks"] = dropped
return internal
def request_from_internal(self, internal: InternalRequest) -> Dict[str, Any]:
system_text = internal.system or self._join_instructions(internal.instructions)
# tools/tool_choice
tools = None
if internal.tools:
tools = [
{
"function_declarations": [
{
"name": t.name,
"description": t.description,
"parameters": t.parameters or {},
**(t.extra.get("gemini_function_declaration") or {}),
}
for t in internal.tools
]
}
]
tool_config = None
if internal.tool_choice:
tool_config = self._tool_choice_to_gemini_tool_config(internal.tool_choice)
generation_config: Dict[str, Any] = {}
if internal.max_tokens is not None:
generation_config["max_output_tokens"] = internal.max_tokens
if internal.temperature is not None:
generation_config["temperature"] = internal.temperature
if internal.top_p is not None:
generation_config["top_p"] = internal.top_p
if internal.top_k is not None:
generation_config["top_k"] = internal.top_k
if internal.stop_sequences:
generation_config["stop_sequences"] = list(internal.stop_sequences)
contents: List[Dict[str, Any]] = []
for msg in internal.messages:
contents.append(self._internal_message_to_content(msg))
result: Dict[str, Any] = {
"contents": contents,
}
# Gemini Chat 模式 model 可能在 URL 路径中;这里仅在 internal.model 存在时回写
if internal.model:
result["model"] = internal.model
if system_text:
result["system_instruction"] = {"parts": [{"text": system_text}]}
if generation_config:
result["generation_config"] = generation_config
if tools:
result["tools"] = tools
if tool_config:
result["tool_config"] = tool_config
return result
# =========================
# Responses
# =========================
def response_to_internal(self, response: Dict[str, Any]) -> InternalResponse:
rid = str(response.get("id") or "")
model = str(response.get("modelVersion") or response.get("model") or "")
candidates = response.get("candidates") or []
candidate0 = candidates[0] if isinstance(candidates, list) and candidates else {}
candidate0 = candidate0 if isinstance(candidate0, dict) else {}
content = candidate0.get("content") if isinstance(candidate0, dict) else None
content = content if isinstance(content, dict) else {}
blocks, dropped = self._parts_to_blocks(content.get("parts"))
finish_reason = candidate0.get("finishReason")
stop_reason = None
if finish_reason is not None:
stop_reason = self._FINISH_REASON_TO_STOP.get(str(finish_reason), StopReason.UNKNOWN)
usage_info = self._usage_metadata_to_internal(response.get("usageMetadata"))
extra: Dict[str, Any] = {}
if finish_reason is not None:
extra.setdefault("raw", {})["finishReason"] = finish_reason
internal = InternalResponse(
id=rid,
model=model,
content=blocks,
stop_reason=stop_reason,
usage=usage_info,
extra=extra,
)
if dropped:
internal.extra.setdefault("raw", {})["dropped_blocks"] = dropped
return internal
def response_from_internal(self, internal: InternalResponse) -> Dict[str, Any]:
parts: List[Dict[str, Any]] = []
for b in internal.content:
if isinstance(b, TextBlock):
if b.text:
parts.append({"text": b.text})
continue
if isinstance(b, ToolUseBlock):
parts.append(
{
"functionCall": {
"name": b.tool_name,
"args": b.tool_input or {},
}
}
)
continue
if isinstance(b, ImageBlock):
if b.data and b.media_type:
parts.append(
{
"inlineData": {
"mimeType": b.media_type,
"data": b.data,
}
}
)
elif b.url:
parts.append({"text": f"[Image: {b.url}]"})
continue
# Unknown/ToolResult 默认丢弃
finish_reason = None
if internal.stop_reason is not None:
finish_reason = STOP_REASON_MAPPINGS.get("GEMINI", {}).get(internal.stop_reason.value, "OTHER")
usage_metadata: Dict[str, Any] = {}
if internal.usage:
usage_metadata = {
"promptTokenCount": int(internal.usage.input_tokens),
"candidatesTokenCount": int(internal.usage.output_tokens),
"totalTokenCount": int(internal.usage.total_tokens or (internal.usage.input_tokens + internal.usage.output_tokens)),
}
if internal.usage.cache_read_tokens:
usage_metadata["cachedContentTokenCount"] = int(internal.usage.cache_read_tokens)
candidate: Dict[str, Any] = {
"content": {"parts": parts, "role": "model"},
"index": 0,
}
if finish_reason is not None:
candidate["finishReason"] = finish_reason
out: Dict[str, Any] = {
"candidates": [candidate],
"modelVersion": internal.model or "gemini",
}
if usage_metadata:
out["usageMetadata"] = usage_metadata
# id 不是 Gemini 标准字段,但内部可能携带;保守保留
if internal.id:
out["id"] = internal.id
return out
# =========================
# Streaming
# =========================
def stream_chunk_to_internal(self, chunk: Dict[str, Any], state: StreamState) -> List[InternalStreamEvent]:
ss = state.substate(self.FORMAT_ID)
events: List[InternalStreamEvent] = []
if not ss.get("message_started"):
# 保留初始化时设置的 model客户端请求的模型仅在空时用上游值
model = state.model or str(chunk.get("modelVersion") or "")
if not state.model:
state.model = model
state.message_id = state.message_id or "gemini"
ss["message_started"] = True
ss.setdefault("text_block_started", False)
ss.setdefault("accumulated_text", "")
ss.setdefault("next_block_index", 1) # 0 预留给文本
events.append(MessageStartEvent(message_id=state.message_id, model=model))
candidates = chunk.get("candidates") or []
if not isinstance(candidates, list) or not candidates:
return events
candidate0 = candidates[0] if candidates else {}
candidate0 = candidate0 if isinstance(candidate0, dict) else {}
content = candidate0.get("content") if isinstance(candidate0, dict) else None
content = content if isinstance(content, dict) else {}
parts = content.get("parts") or []
# parts -> events
if isinstance(parts, list):
for part in parts:
if not isinstance(part, dict):
continue
# text兼容delta 或累积)
text = part.get("text")
if isinstance(text, str) and text:
prev = str(ss.get("accumulated_text") or "")
if text.startswith(prev):
delta = text[len(prev) :]
ss["accumulated_text"] = text
else:
delta = text
ss["accumulated_text"] = prev + delta
if delta:
if not ss.get("text_block_started"):
ss["text_block_started"] = True
events.append(ContentBlockStartEvent(block_index=0, block_type=ContentType.TEXT))
events.append(ContentDeltaEvent(block_index=0, text_delta=delta))
continue
# functionCallstream response 常见 camelCase
func_call = part.get("functionCall")
if func_call is None:
func_call = part.get("function_call")
if isinstance(func_call, dict):
name = str(func_call.get("name") or "")
args = func_call.get("args")
if not isinstance(args, dict):
args = {}
block_index = int(ss.get("next_block_index") or 1)
ss["next_block_index"] = block_index + 1
events.append(
ContentBlockStartEvent(
block_index=block_index,
block_type=ContentType.TOOL_USE,
tool_id=None,
tool_name=name or None,
)
)
if args:
events.append(
ToolCallDeltaEvent(
block_index=block_index,
tool_id="",
input_delta=json.dumps(args, ensure_ascii=False),
)
)
events.append(ContentBlockStopEvent(block_index=block_index))
continue
finish_reason = candidate0.get("finishReason")
if finish_reason is not None:
stop_reason = self._FINISH_REASON_TO_STOP.get(str(finish_reason), StopReason.UNKNOWN)
usage_info = self._usage_metadata_to_internal(chunk.get("usageMetadata"))
# 先补齐 content_block_stop仅 text block再发送 MessageStop
if ss.get("text_block_started") and not ss.get("text_block_stopped"):
ss["text_block_stopped"] = True
events.append(ContentBlockStopEvent(block_index=0))
events.append(MessageStopEvent(stop_reason=stop_reason, usage=usage_info))
if "error" in chunk:
events.append(ErrorEvent(error=self.error_to_internal(chunk)))
return events
def stream_event_from_internal(
self,
event: InternalStreamEvent,
state: StreamState,
) -> List[Dict[str, Any]]:
ss = state.substate(self.FORMAT_ID)
out: List[Dict[str, Any]] = []
def base_chunk(parts: List[Dict[str, Any]]) -> Dict[str, Any]:
return {
"candidates": [
{
"content": {"parts": parts, "role": "model"},
"index": 0,
}
],
"modelVersion": state.model or "",
}
if isinstance(event, MessageStartEvent):
state.message_id = event.message_id or state.message_id
state.model = event.model or state.model
ss.setdefault("tool_blocks", {})
return out
if isinstance(event, ContentDeltaEvent):
if event.text_delta:
out.append(base_chunk([{"text": event.text_delta}]))
return out
if isinstance(event, ContentBlockStartEvent) and event.block_type == ContentType.TOOL_USE:
tool_blocks = ss.get("tool_blocks")
if not isinstance(tool_blocks, dict):
tool_blocks = {}
ss["tool_blocks"] = tool_blocks
tool_blocks[int(event.block_index)] = {
"name": event.tool_name or "",
"json": "",
}
return out
if isinstance(event, ToolCallDeltaEvent):
tool_blocks = ss.get("tool_blocks")
if isinstance(tool_blocks, dict):
entry = tool_blocks.get(int(event.block_index))
if isinstance(entry, dict):
entry["json"] = str(entry.get("json") or "") + (event.input_delta or "")
return out
if isinstance(event, ContentBlockStopEvent):
tool_blocks = ss.get("tool_blocks")
if not isinstance(tool_blocks, dict):
return out
entry = tool_blocks.get(int(event.block_index))
if not isinstance(entry, dict):
return out
name = str(entry.get("name") or "")
raw_json = str(entry.get("json") or "")
args: Dict[str, Any] = {}
if raw_json:
try:
parsed = json.loads(raw_json)
if isinstance(parsed, dict):
args = parsed
except json.JSONDecodeError:
args = {}
out.append(base_chunk([{"functionCall": {"name": name, "args": args}}]))
return out
if isinstance(event, MessageStopEvent):
finish_reason = None
if event.stop_reason is not None:
finish_reason = STOP_REASON_MAPPINGS.get("GEMINI", {}).get(event.stop_reason.value, "OTHER")
chunk: Dict[str, Any] = base_chunk([])
if finish_reason is not None:
chunk["candidates"][0]["finishReason"] = finish_reason
if event.usage:
chunk["usageMetadata"] = {
"promptTokenCount": int(event.usage.input_tokens),
"candidatesTokenCount": int(event.usage.output_tokens),
"totalTokenCount": int(event.usage.total_tokens or (event.usage.input_tokens + event.usage.output_tokens)),
}
if event.usage.cache_read_tokens:
chunk["usageMetadata"]["cachedContentTokenCount"] = int(event.usage.cache_read_tokens)
out.append(chunk)
return out
if isinstance(event, ErrorEvent):
out.append(self.error_from_internal(event.error))
return out
return out
# =========================
# Error conversion
# =========================
def is_error_response(self, response: Dict[str, Any]) -> bool:
return isinstance(response, dict) and "error" in response
def error_to_internal(self, error_response: Dict[str, Any]) -> InternalError:
err = error_response.get("error") if isinstance(error_response, dict) else None
err = err if isinstance(err, dict) else {}
raw_status = err.get("status")
mapped = ERROR_TYPE_MAPPINGS.get("GEMINI", {}).get(str(raw_status), ErrorType.UNKNOWN.value)
internal_type = self._error_type_from_value(mapped)
retryable = internal_type.value in RETRYABLE_ERROR_TYPES
code_value = err.get("code")
code_str = None
if code_value is not None:
code_str = str(code_value)
return InternalError(
type=internal_type,
message=str(err.get("message") or ""),
code=code_str,
param=None,
retryable=retryable,
extra={"gemini": {"error": err}, "raw": {"status": raw_status}},
)
def error_from_internal(self, internal: InternalError) -> Dict[str, Any]:
status = self._ERROR_TYPE_TO_GEMINI_STATUS.get(internal.type, "INTERNAL")
payload: Dict[str, Any] = {
"code": 400 if internal.type == ErrorType.INVALID_REQUEST else 500,
"message": internal.message,
"status": status,
}
return {"error": payload}
# =========================
# Helpers
# =========================
def _content_to_internal_message(self, content: Dict[str, Any]) -> Tuple[Optional[InternalMessage], Dict[str, int]]:
dropped: Dict[str, int] = {}
role_raw = str(content.get("role") or "user")
if role_raw == "model":
role = Role.ASSISTANT
elif role_raw == "user":
role = Role.USER
else:
role = Role.UNKNOWN
blocks, bd = self._parts_to_blocks(content.get("parts"))
self._merge_dropped(dropped, bd)
return (
InternalMessage(
role=role,
content=blocks,
extra=self._extract_extra(content, {"role", "parts"}),
),
dropped,
)
def _parts_to_blocks(self, parts: Any) -> Tuple[List[ContentBlock], Dict[str, int]]:
dropped: Dict[str, int] = {}
if parts is None:
return [], dropped
if not isinstance(parts, list):
dropped["gemini_parts_non_list"] = dropped.get("gemini_parts_non_list", 0) + 1
return [], dropped
blocks: List[ContentBlock] = []
for part in parts:
if not isinstance(part, dict):
dropped["gemini_part_non_dict"] = dropped.get("gemini_part_non_dict", 0) + 1
continue
if "text" in part:
text = part.get("text")
if isinstance(text, str) and text:
blocks.append(TextBlock(text=text, extra=self._extract_extra(part, {"text"})))
continue
inline = part.get("inline_data")
if inline is None:
inline = part.get("inlineData")
if isinstance(inline, dict):
mime_type = inline.get("mime_type") if "mime_type" in inline else inline.get("mimeType")
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))
else:
dropped["gemini_inline_data_invalid"] = dropped.get("gemini_inline_data_invalid", 0) + 1
blocks.append(UnknownBlock(raw_type="inline_data", payload=part))
continue
func_call = part.get("function_call")
if func_call is None:
func_call = part.get("functionCall")
if isinstance(func_call, dict):
name = str(func_call.get("name") or "")
args = func_call.get("args")
if not isinstance(args, dict):
args = {}
blocks.append(
ToolUseBlock(
tool_id=f"toolu_{name}" if name else "toolu_0",
tool_name=name,
tool_input=args,
extra={"gemini": part},
)
)
continue
func_resp = part.get("function_response")
if func_resp is None:
func_resp = part.get("functionResponse")
if isinstance(func_resp, dict):
name = str(func_resp.get("name") or "")
response = func_resp.get("response")
output: Any = None
content_text: Optional[str] = None
# 兼容历史response 常见结构为 {"result": ...}
if isinstance(response, dict) and "result" in response:
output = response.get("result")
if isinstance(output, str):
content_text = output
output = None
else:
output = response
blocks.append(
ToolResultBlock(
tool_use_id=name,
output=output,
content_text=content_text,
is_error=False,
extra={"gemini": part},
)
)
continue
# 其它Unknown
raw_type = next(iter(part.keys()), "unknown")
dropped_key = f"gemini_part:{raw_type}"
dropped[dropped_key] = dropped.get(dropped_key, 0) + 1
blocks.append(UnknownBlock(raw_type=str(raw_type), payload=part))
return blocks, dropped
def _internal_message_to_content(self, msg: InternalMessage) -> Dict[str, Any]:
role = "model" if msg.role == Role.ASSISTANT else "user"
parts: List[Dict[str, Any]] = []
for b in msg.content:
if isinstance(b, UnknownBlock):
continue
if isinstance(b, TextBlock):
if b.text:
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}]"})
continue
if isinstance(b, ToolUseBlock) and role == "model":
parts.append({"function_call": {"name": b.tool_name, "args": b.tool_input or {}}})
continue
if isinstance(b, ToolResultBlock) and role == "user":
# 兼容旧转换器name 直接使用 tool_use_idresponse 固定包一层 result
value: Any
if b.content_text is not None:
value = b.content_text
elif b.output is None:
value = ""
else:
value = b.output
parts.append(
{
"function_response": {
"name": b.tool_use_id,
"response": {"result": value},
}
}
)
continue
return {"role": role, "parts": parts}
def _collapse_system_instruction(self, system_instruction: Any) -> Tuple[Optional[str], Dict[str, int]]:
dropped: Dict[str, int] = {}
if system_instruction is None:
return None, dropped
# 支持 {"parts": [{"text": ...}, ...]}
if isinstance(system_instruction, dict):
parts = system_instruction.get("parts")
if isinstance(parts, list):
texts: List[str] = []
for part in parts:
if isinstance(part, dict) and "text" in part and part.get("text"):
texts.append(str(part.get("text")))
joined = "".join(texts)
return (joined or None), dropped
dropped["gemini_system_instruction_unsupported"] = dropped.get("gemini_system_instruction_unsupported", 0) + 1
return None, dropped
def _get_generation_config(self, request: Dict[str, Any]) -> Dict[str, Any]:
# 兼容 snake_case 与 camelCase
gc = request.get("generation_config") if "generation_config" in request else request.get("generationConfig")
if not isinstance(gc, dict):
return {}
# 统一内部使用 snake_case key
def pick(*keys: str) -> Any:
for k in keys:
if k in gc:
return gc.get(k)
return None
normalized: Dict[str, Any] = {}
normalized["max_output_tokens"] = pick("max_output_tokens", "maxOutputTokens")
normalized["temperature"] = pick("temperature")
normalized["top_p"] = pick("top_p", "topP")
normalized["top_k"] = pick("top_k", "topK")
normalized["stop_sequences"] = pick("stop_sequences", "stopSequences")
return {k: v for k, v in normalized.items() if v is not None}
def _gemini_tools_to_internal(self, tools: Any) -> Optional[List[ToolDefinition]]:
if not tools or not isinstance(tools, list):
return None
out: List[ToolDefinition] = []
for tool in tools:
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"})},
)
)
return out or None
def _gemini_tool_config_to_tool_choice(self, tool_config: Any) -> Optional[ToolChoice]:
if tool_config is None:
return None
if not isinstance(tool_config, dict):
return ToolChoice(type=ToolChoiceType.AUTO, extra={"raw": tool_config})
cfg = tool_config.get("function_calling_config")
if cfg is None:
cfg = tool_config.get("functionCallingConfig")
if not isinstance(cfg, dict):
return ToolChoice(type=ToolChoiceType.AUTO, extra={"gemini": tool_config})
mode = str(cfg.get("mode") or "AUTO").upper()
allowed = cfg.get("allowed_function_names")
if allowed is None:
allowed = cfg.get("allowedFunctionNames")
if mode == "NONE":
return ToolChoice(type=ToolChoiceType.NONE, extra={"gemini": tool_config})
if mode in ("ANY", "REQUIRED"):
return ToolChoice(type=ToolChoiceType.REQUIRED, extra={"gemini": tool_config})
if isinstance(allowed, list) and len(allowed) == 1:
return ToolChoice(type=ToolChoiceType.TOOL, tool_name=str(allowed[0] or ""), extra={"gemini": tool_config})
return ToolChoice(type=ToolChoiceType.AUTO, extra={"gemini": tool_config})
def _tool_choice_to_gemini_tool_config(self, tool_choice: ToolChoice) -> Dict[str, Any]:
mode = "AUTO"
cfg: Dict[str, Any] = {}
if tool_choice.type == ToolChoiceType.NONE:
mode = "NONE"
elif tool_choice.type == ToolChoiceType.REQUIRED:
mode = "ANY"
elif tool_choice.type == ToolChoiceType.TOOL:
mode = "ANY"
cfg["allowed_function_names"] = [tool_choice.tool_name or ""]
cfg["mode"] = mode
return {"function_calling_config": cfg}
def _usage_metadata_to_internal(self, usage_metadata: Any) -> Optional[UsageInfo]:
if not isinstance(usage_metadata, dict):
return None
mapping = USAGE_FIELD_MAPPINGS.get("GEMINI", {})
fields: Dict[str, int] = {}
extra = self._extract_extra(usage_metadata, set(mapping.keys()))
# promptTokenCount/candidatesTokenCount/totalTokenCount/cachedContentTokenCount
for provider_key, internal_key in mapping.items():
if provider_key in usage_metadata and usage_metadata.get(provider_key) is not None:
try:
fields[internal_key] = int(usage_metadata.get(provider_key) or 0)
except (TypeError, ValueError):
continue
# thoughtsTokenCount如果存在按 handler 的口径并入 output_tokens
thoughts = usage_metadata.get("thoughtsTokenCount")
if thoughts is not None:
try:
fields["output_tokens"] = int(fields.get("output_tokens", 0) + int(thoughts or 0))
except (TypeError, ValueError):
pass
if "total_tokens" not in fields:
fields["total_tokens"] = int(fields.get("input_tokens", 0) + fields.get("output_tokens", 0))
return UsageInfo(
input_tokens=int(fields.get("input_tokens", 0)),
output_tokens=int(fields.get("output_tokens", 0)),
total_tokens=int(fields.get("total_tokens", 0)),
cache_read_tokens=int(fields.get("cache_read_tokens", 0)),
cache_write_tokens=0,
extra={"gemini": extra} if extra else {},
)
def _join_instructions(self, instructions: List[InstructionSegment]) -> Optional[str]:
parts = [seg.text for seg in instructions if seg.text]
joined = "\n\n".join(parts)
return joined or None
def _error_type_from_value(self, value: str) -> ErrorType:
try:
return ErrorType(value)
except ValueError:
return ErrorType.UNKNOWN
def _optional_int(self, value: Any) -> Optional[int]:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _optional_float(self, value: Any) -> Optional[float]:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _coerce_str_list(self, value: Any) -> Optional[List[str]]:
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

@@ -0,0 +1,18 @@
"""
Gemini CLI Normalizer
GEMINI_CLI 的请求/响应 body 与 GEMINI 一致Google Gemini API差异主要在鉴权/UA 等请求层。
因此这里复用 GeminiNormalizer 的转换逻辑,仅更换 FORMAT_ID。
"""
from __future__ import annotations
from src.core.api_format.conversion.normalizers.gemini import GeminiNormalizer
class GeminiCliNormalizer(GeminiNormalizer):
FORMAT_ID = "GEMINI_CLI"
__all__ = ["GeminiCliNormalizer"]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,880 @@
"""
OpenAI CLI / Responses Normalizer (OPENAI_CLI)
目标:
- 将 OpenAI Responses API/v1/responses映射到 InternalRequest / InternalResponse
- 支持流式事件response.output_text.delta / response.completed 等
说明:
- 这里实现的是“最佳努力”的最小可用映射,重点覆盖文本与 usage。
- 未识别的字段会进入 extra/raw未知内容块保留在 internal但默认输出阶段会丢弃。
"""
from __future__ import annotations
import json
import time
from typing import Any, Dict, List, Optional, Tuple, Union
from src.core.api_format.conversion.field_mappings import (
ERROR_TYPE_MAPPINGS,
RETRYABLE_ERROR_TYPES,
)
from src.core.api_format.conversion.internal import (
ContentBlock,
ContentType,
ErrorType,
FormatCapabilities,
InstructionSegment,
InternalError,
InternalMessage,
InternalRequest,
InternalResponse,
Role,
StopReason,
TextBlock,
ToolChoice,
ToolChoiceType,
ToolDefinition,
ToolResultBlock,
ToolUseBlock,
UnknownBlock,
UsageInfo,
)
from src.core.api_format.conversion.normalizer import FormatNormalizer
from src.core.api_format.conversion.stream_events import (
ContentBlockStartEvent,
ContentBlockStopEvent,
ContentDeltaEvent,
ErrorEvent,
InternalStreamEvent,
MessageStartEvent,
MessageStopEvent,
ToolCallDeltaEvent,
UnknownStreamEvent,
)
from src.core.api_format.conversion.stream_state import StreamState
class OpenAICliNormalizer(FormatNormalizer):
FORMAT_ID = "OPENAI_CLI"
capabilities = FormatCapabilities(
supports_stream=True,
supports_error_conversion=True,
supports_tools=True,
supports_images=True,
)
_ERROR_TYPE_TO_OPENAI: Dict[ErrorType, str] = {
ErrorType.INVALID_REQUEST: "invalid_request_error",
ErrorType.AUTHENTICATION: "invalid_api_key",
ErrorType.PERMISSION_DENIED: "invalid_request_error",
ErrorType.NOT_FOUND: "not_found",
ErrorType.RATE_LIMIT: "rate_limit_exceeded",
ErrorType.OVERLOADED: "server_error",
ErrorType.SERVER_ERROR: "server_error",
ErrorType.CONTENT_FILTERED: "content_policy_violation",
ErrorType.CONTEXT_LENGTH_EXCEEDED: "context_length_exceeded",
ErrorType.UNKNOWN: "server_error",
}
# =========================
# Requests
# =========================
def request_to_internal(self, request: Dict[str, Any]) -> InternalRequest:
model = str(request.get("model") or "")
instructions_text = request.get("instructions")
instructions: List[InstructionSegment] = []
system_text: Optional[str] = None
if isinstance(instructions_text, str) and instructions_text.strip():
system_text = instructions_text
instructions.append(InstructionSegment(role=Role.SYSTEM, text=instructions_text))
messages = self._input_to_internal_messages(request.get("input"))
tools = self._tools_to_internal(request.get("tools"))
tool_choice = self._tool_choice_to_internal(request.get("tool_choice"))
max_tokens = self._optional_int(
request.get("max_output_tokens", request.get("max_tokens"))
)
internal = InternalRequest(
model=model,
messages=messages,
instructions=instructions,
system=system_text,
max_tokens=max_tokens,
temperature=self._optional_float(request.get("temperature")),
top_p=self._optional_float(request.get("top_p")),
stop_sequences=self._coerce_str_list(request.get("stop")),
stream=bool(request.get("stream") or False),
tools=tools,
tool_choice=tool_choice,
extra={"openai_cli": self._extract_extra(request, {"input"})},
)
return internal
def request_from_internal(self, internal: InternalRequest) -> Dict[str, Any]:
result: Dict[str, Any] = {
"model": internal.model,
"input": self._internal_messages_to_input(internal.messages),
}
instructions_text = self._join_instructions(internal)
if instructions_text:
result["instructions"] = instructions_text
if internal.max_tokens is not None:
# Responses API 使用 max_output_tokens兼容层仍可能接受 max_tokens
result["max_output_tokens"] = internal.max_tokens
if internal.temperature is not None:
result["temperature"] = internal.temperature
if internal.top_p is not None:
result["top_p"] = internal.top_p
if internal.stop_sequences:
result["stop"] = list(internal.stop_sequences)
if internal.stream:
result["stream"] = 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 {}),
}
for t in internal.tools
]
if internal.tool_choice:
result["tool_choice"] = self._tool_choice_to_openai(internal.tool_choice)
return result
# =========================
# Responses
# =========================
def response_to_internal(self, response: Dict[str, Any]) -> InternalResponse:
payload = self._unwrap_response_object(response)
rid = str(payload.get("id") or "")
model = str(payload.get("model") or "")
blocks, extra = self._extract_output_text_blocks(payload)
usage = self._usage_to_internal(payload.get("usage"))
stop_reason = StopReason.UNKNOWN
status = payload.get("status")
if isinstance(status, str) and status == "completed":
stop_reason = StopReason.END_TURN
return InternalResponse(
id=rid,
model=model,
content=blocks,
stop_reason=stop_reason,
usage=usage,
extra=extra,
)
def response_from_internal(self, internal: InternalResponse) -> Dict[str, Any]:
text = self._collapse_internal_text(internal.content)
output_message = {
"type": "message",
"id": f"msg_{internal.id or 'stream'}",
"role": "assistant",
"content": [{"type": "output_text", "text": text}],
}
usage = internal.usage or UsageInfo()
usage_obj: Dict[str, Any] = {
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
"total_tokens": usage.total_tokens or (usage.input_tokens + usage.output_tokens),
}
return {
"id": internal.id or "resp",
"object": "response",
"created": int(time.time()),
"model": internal.model or "",
"status": "completed",
"output": [output_message],
"usage": usage_obj,
}
# =========================
# Stream conversion
# =========================
def stream_chunk_to_internal(
self,
chunk: Dict[str, Any],
state: StreamState,
) -> List[InternalStreamEvent]:
ss = state.substate(self.FORMAT_ID)
events: List[InternalStreamEvent] = []
# 统一错误结构(最佳努力)
if isinstance(chunk, dict) and "error" in chunk:
try:
events.append(ErrorEvent(error=self.error_to_internal(chunk)))
except Exception:
pass
return events
etype = str(chunk.get("type") or "")
# 尽量在首次事件补齐 message_start
if not ss.get("message_started"):
resp_obj = chunk.get("response")
resp_obj = resp_obj if isinstance(resp_obj, dict) else {}
msg_id = str(resp_obj.get("id") or chunk.get("id") or state.message_id or "")
model = str(resp_obj.get("model") or chunk.get("model") or state.model or "")
if msg_id or model or etype:
state.message_id = msg_id
state.model = model
ss["message_started"] = True
ss.setdefault("text_block_started", False)
ss.setdefault("text_block_stopped", False)
events.append(MessageStartEvent(message_id=msg_id, model=model))
# response.created响应创建事件message_start 已在上面处理
if etype == "response.created":
return events
# 文本增量response.output_text.delta
if etype in ("response.output_text.delta", "response.outtext.delta"):
delta = chunk.get("delta")
delta_text = ""
if isinstance(delta, str):
delta_text = delta
elif isinstance(delta, dict) and isinstance(delta.get("text"), str):
delta_text = str(delta.get("text") or "")
if delta_text:
if not ss.get("text_block_started"):
ss["text_block_started"] = True
events.append(ContentBlockStartEvent(block_index=0, block_type=ContentType.TEXT))
events.append(ContentDeltaEvent(block_index=0, text_delta=delta_text))
return events
# 文本完成response.output_text.done可选
if etype == "response.output_text.done":
if ss.get("text_block_started") and not ss.get("text_block_stopped"):
ss["text_block_stopped"] = True
events.append(ContentBlockStopEvent(block_index=0))
return events
# 完成response.completed包含 usage
if etype == "response.completed":
resp_obj = chunk.get("response")
resp_obj = resp_obj if isinstance(resp_obj, dict) else {}
usage = self._usage_to_internal(resp_obj.get("usage") or chunk.get("usage"))
if ss.get("text_block_started") and not ss.get("text_block_stopped"):
ss["text_block_stopped"] = True
events.append(ContentBlockStopEvent(block_index=0))
events.append(MessageStopEvent(stop_reason=StopReason.END_TURN, usage=usage))
return events
# 失败response.failed最佳努力
if etype == "response.failed":
try:
events.append(ErrorEvent(error=self.error_to_internal(chunk)))
except Exception:
pass
return events
# response.in_progress状态更新不产生内容事件
if etype == "response.in_progress":
# 更新 state 中的元数据(如果有)
resp_obj = chunk.get("response")
if isinstance(resp_obj, dict):
if resp_obj.get("id"):
state.message_id = str(resp_obj.get("id"))
if resp_obj.get("model"):
state.model = str(resp_obj.get("model"))
return []
# response.output_item.added新输出项添加如 message、function_call 等)
if etype == "response.output_item.added":
item = chunk.get("item")
if isinstance(item, dict):
item_type = item.get("type")
# function_call 输出项
if item_type == "function_call":
if not ss.get("tool_block_started"):
ss["tool_block_started"] = True
ss["current_tool_id"] = item.get("call_id") or item.get("id") or ""
ss["current_tool_name"] = item.get("name") or ""
events.append(ContentBlockStartEvent(
block_index=ss.get("block_index", 0),
block_type=ContentType.TOOL_USE,
extra={"tool_id": ss["current_tool_id"], "tool_name": ss["current_tool_name"]},
))
ss["block_index"] = ss.get("block_index", 0) + 1
# message 输出项
elif item_type == "message":
# 通常在 response.created 时已处理,这里可以忽略或更新状态
pass
return events
# response.output_item.done输出项完成
if etype == "response.output_item.done":
item = chunk.get("item")
if isinstance(item, dict):
item_type = item.get("type")
if item_type == "function_call" and ss.get("tool_block_started"):
ss["tool_block_started"] = False
events.append(ContentBlockStopEvent(block_index=ss.get("block_index", 1) - 1))
return events
# response.function_call_arguments.delta工具调用参数增量
if etype == "response.function_call_arguments.delta":
delta = chunk.get("delta") or ""
if delta:
events.append(ToolCallDeltaEvent(
block_index=ss.get("block_index", 1) - 1,
tool_id=ss.get("current_tool_id", ""),
input_delta=delta,
))
return events
# response.function_call_arguments.done工具调用参数完成
if etype == "response.function_call_arguments.done":
# 参数已完整,不产生额外事件
return []
# response.content_part.added / response.content_part.done内容部分事件
if etype in ("response.content_part.added", "response.content_part.done"):
# 通常伴随 output_text 事件,这里可以忽略
return []
# response.reasoning_summary_text.delta推理摘要增量
if etype == "response.reasoning_summary_text.delta":
# 保留为 UnknownStreamEvent让下游决定是否使用
return [UnknownStreamEvent(raw_type=etype, payload=chunk)]
# response.reasoning_summary_text.done推理摘要完成
if etype == "response.reasoning_summary_text.done":
return [UnknownStreamEvent(raw_type=etype, payload=chunk)]
if etype:
return [UnknownStreamEvent(raw_type=etype, payload=chunk)]
return [UnknownStreamEvent(raw_type="unknown", payload=chunk)]
def stream_event_from_internal(
self,
event: InternalStreamEvent,
state: StreamState,
) -> List[Dict[str, Any]]:
ss = state.substate(self.FORMAT_ID)
out: List[Dict[str, Any]] = []
def event_block(payload: Dict[str, Any]) -> Dict[str, Any]:
# OpenAI Responses SSE 的 payload 通常自带 type 字段;这里强制保证
return payload
if isinstance(event, MessageStartEvent):
state.message_id = event.message_id or state.message_id or "resp_stream"
state.model = event.model or state.model or ""
ss.setdefault("collected_text", "")
out.append(
event_block(
{
"type": "response.created",
"response": {
"id": state.message_id,
"object": "response",
"created": int(time.time()),
"model": state.model,
"status": "in_progress",
"output": [],
},
}
)
)
return out
if isinstance(event, ContentDeltaEvent):
if event.text_delta:
ss["collected_text"] = str(ss.get("collected_text") or "") + event.text_delta
out.append(
event_block(
{
"type": "response.output_text.delta",
"delta": event.text_delta,
}
)
)
return out
if isinstance(event, MessageStopEvent):
final_text = str(ss.get("collected_text") or "")
response_obj = self.response_from_internal(
InternalResponse(
id=state.message_id or "resp",
model=state.model or "",
content=[TextBlock(text=final_text)] if final_text else [],
stop_reason=event.stop_reason or StopReason.END_TURN,
usage=event.usage or UsageInfo(),
)
)
out.append(event_block({"type": "response.completed", "response": response_obj}))
return out
if isinstance(event, ErrorEvent):
err_payload = self.error_from_internal(event.error)
err_payload["type"] = "response.failed"
out.append(event_block(err_payload))
return out
# 其他事件Responses SSE 无直接对应,跳过
return out
# =========================
# Error conversion
# =========================
def is_error_response(self, response: Dict[str, Any]) -> bool:
return isinstance(response, dict) and "error" in response
def error_to_internal(self, error_response: Dict[str, Any]) -> InternalError:
err = error_response.get("error") if isinstance(error_response, dict) else None
err = err if isinstance(err, dict) else {}
raw_type = err.get("type")
mapped = ERROR_TYPE_MAPPINGS.get("OPENAI", {}).get(str(raw_type), ErrorType.UNKNOWN.value)
internal_type = self._error_type_from_value(mapped)
retryable = internal_type.value in RETRYABLE_ERROR_TYPES
return InternalError(
type=internal_type,
message=str(err.get("message") or ""),
code=err.get("code") if err.get("code") is None else str(err.get("code")),
param=err.get("param") if err.get("param") is None else str(err.get("param")),
retryable=retryable,
extra={"openai_cli": {"error": err}, "raw": {"type": raw_type}},
)
def error_from_internal(self, internal: InternalError) -> Dict[str, Any]:
type_str = self._ERROR_TYPE_TO_OPENAI.get(internal.type, "server_error")
payload: Dict[str, Any] = {"type": type_str, "message": internal.message}
if internal.code is not None:
payload["code"] = internal.code
if internal.param is not None:
payload["param"] = internal.param
return {"error": payload}
# =========================
# Helpers
# =========================
def _unwrap_response_object(self, response: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(response, dict):
return {}
resp_inner = response.get("response")
if isinstance(resp_inner, dict) and isinstance(response.get("type"), str):
# 例如:{"type": "response.completed", "response": {...}}
return resp_inner
return response
def _extract_output_text_blocks(self, payload: Dict[str, Any]) -> Tuple[List[ContentBlock], Dict[str, Any]]:
text_parts: List[str] = []
output = payload.get("output")
if isinstance(output, list):
for item in output:
if not isinstance(item, dict):
continue
if item.get("type") == "message":
content = item.get("content")
if isinstance(content, list):
for part in content:
if not isinstance(part, dict):
continue
ptype = str(part.get("type") or "")
if ptype in ("output_text", "text") and isinstance(part.get("text"), str):
text_parts.append(part.get("text") or "")
continue
if item.get("type") in ("output_text", "text") and isinstance(item.get("text"), str):
text_parts.append(item.get("text") or "")
# 兼容:部分实现可能直接给 output_text
if not text_parts and isinstance(payload.get("output_text"), str):
text_parts.append(payload.get("output_text") or "")
blocks: List[ContentBlock] = []
text = "".join(text_parts)
if text:
blocks.append(TextBlock(text=text))
extra: Dict[str, Any] = {"raw": {"openai_cli_output": output}} if output is not None else {}
return blocks, extra
def _usage_to_internal(self, usage: Any) -> UsageInfo:
if not isinstance(usage, dict):
return UsageInfo()
input_tokens = int(usage.get("input_tokens") or 0)
output_tokens = int(usage.get("output_tokens") or 0)
total_tokens = int(usage.get("total_tokens") or (input_tokens + output_tokens))
return UsageInfo(
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
extra={"openai_cli": {"usage": usage}},
)
def _collapse_internal_text(self, blocks: List[ContentBlock]) -> str:
parts: List[str] = []
for block in blocks:
if isinstance(block, TextBlock) and block.text:
parts.append(block.text)
return "".join(parts)
def _input_to_internal_messages(self, input_data: Any) -> List[InternalMessage]:
if input_data is None:
return []
# input: "text"
if isinstance(input_data, str):
return [InternalMessage(role=Role.USER, content=[TextBlock(text=input_data)])]
# input: {"messages": [...]}
if isinstance(input_data, dict) and isinstance(input_data.get("messages"), list):
input_data = input_data.get("messages")
if not isinstance(input_data, list):
return [InternalMessage(role=Role.USER, content=[UnknownBlock(raw_type="input", payload={"input": input_data})])]
messages: List[InternalMessage] = []
for item in input_data:
if not isinstance(item, dict):
continue
item_type = str(item.get("type") or "")
# 标准 message有 role 字段)
if item_type == "message" or item.get("role"):
role = self._role_from_value(item.get("role"))
blocks = self._responses_content_to_blocks(item.get("content"))
messages.append(InternalMessage(role=role, content=blocks, extra=self._extract_extra(item, {"type", "role", "content"})))
continue
# function_call -> assistant 消息 + ToolUseBlock
if item_type == "function_call":
tool_id = str(item.get("call_id") or item.get("id") or "")
tool_name = str(item.get("name") or "")
args_raw = item.get("arguments") or "{}"
try:
tool_input = json.loads(args_raw) if isinstance(args_raw, str) else (args_raw if isinstance(args_raw, dict) else {})
except (json.JSONDecodeError, TypeError):
tool_input = {"_raw": args_raw}
tool_block = ToolUseBlock(
tool_id=tool_id,
tool_name=tool_name,
tool_input=tool_input,
extra={"openai_cli": self._extract_extra(item, {"type", "call_id", "id", "name", "arguments"})},
)
messages.append(InternalMessage(role=Role.ASSISTANT, content=[tool_block]))
continue
# function_call_output -> tool 消息 + ToolResultBlock
if item_type == "function_call_output":
tool_use_id = str(item.get("call_id") or item.get("id") or "")
output = item.get("output")
# output 可能是字符串或结构化数据
content_text = output if isinstance(output, str) else None
result_block = ToolResultBlock(
tool_use_id=tool_use_id,
output=output,
content_text=content_text,
extra={"openai_cli": self._extract_extra(item, {"type", "call_id", "id", "output"})},
)
messages.append(InternalMessage(role=Role.TOOL, content=[result_block]))
continue
# reasoning -> assistant 消息,提取 summary 作为文本
if item_type == "reasoning":
summary_parts: List[str] = []
summary = item.get("summary")
if isinstance(summary, list):
for s in summary:
if isinstance(s, dict) and s.get("type") == "summary_text":
text = s.get("text")
if isinstance(text, str) and text:
summary_parts.append(text)
elif isinstance(s, str) and s:
summary_parts.append(s)
elif isinstance(summary, str) and summary:
summary_parts.append(summary)
# 如果有 summary 文本,创建一个 UnknownBlock 保留原始结构
reasoning_blocks: List[ContentBlock] = []
if summary_parts:
# 保留 reasoning 的 summary 作为 UnknownBlock便于输出时决策
reasoning_blocks.append(UnknownBlock(
raw_type="reasoning",
payload={"summary_text": "\n".join(summary_parts), "original": item},
))
else:
reasoning_blocks.append(UnknownBlock(raw_type="reasoning", payload=item))
messages.append(InternalMessage(
role=Role.ASSISTANT,
content=reasoning_blocks,
extra={"openai_cli": {"type": "reasoning"}},
))
continue
# 其他未知类型 -> 保留为 UnknownBlock
messages.append(InternalMessage(
role=Role.UNKNOWN,
content=[UnknownBlock(raw_type=item_type or "unknown", payload=item)],
))
return messages
def _responses_content_to_blocks(self, content: Any) -> List[ContentBlock]:
if content is None:
return []
if isinstance(content, str):
return [TextBlock(text=content)]
if isinstance(content, dict) and isinstance(content.get("text"), str):
return [TextBlock(text=str(content.get("text") or ""))]
if not isinstance(content, list):
return [UnknownBlock(raw_type="content", payload={"content": content})]
blocks: List[ContentBlock] = []
for part in content:
if isinstance(part, str):
if part:
blocks.append(TextBlock(text=part))
continue
if not isinstance(part, dict):
continue
ptype = str(part.get("type") or "")
if ptype in ("input_text", "output_text", "text") and isinstance(part.get("text"), str):
text = part.get("text") or ""
if text:
blocks.append(TextBlock(text=text))
continue
blocks.append(UnknownBlock(raw_type=ptype or "unknown", payload=part))
return blocks
def _internal_messages_to_input(self, messages: List[InternalMessage]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for msg in messages:
# ToolUseBlock -> function_call
for block in msg.content:
if isinstance(block, ToolUseBlock):
out.append({
"type": "function_call",
"call_id": block.tool_id,
"name": block.tool_name,
"arguments": json.dumps(block.tool_input, ensure_ascii=False) if block.tool_input else "{}",
})
continue
if isinstance(block, ToolResultBlock):
out.append({
"type": "function_call_output",
"call_id": block.tool_use_id,
"output": block.content_text if block.content_text is not None else block.output,
})
continue
# reasoningUnknownBlock with raw_type="reasoning"
if isinstance(block, UnknownBlock) and block.raw_type == "reasoning":
payload = block.payload or {}
original = payload.get("original")
if isinstance(original, dict):
# 尽量还原原始结构
out.append(original)
else:
summary_text = payload.get("summary_text", "")
out.append({
"type": "reasoning",
"summary": [{"type": "summary_text", "text": summary_text}] if summary_text else [],
})
continue
# 普通 messageTextBlock
role = self._role_to_openai(msg.role)
content_items: List[Dict[str, Any]] = []
has_text = False
for block in msg.content:
if isinstance(block, (ToolUseBlock, ToolResultBlock)):
continue # 已在上面处理
if isinstance(block, UnknownBlock) and block.raw_type == "reasoning":
continue # 已在上面处理
if isinstance(block, UnknownBlock):
continue # 跳过其他未知块
if isinstance(block, TextBlock) and block.text:
content_items.append({"type": "input_text", "text": block.text})
has_text = True
if has_text:
out.append({"type": "message", "role": role, "content": content_items})
return out
def _tools_to_internal(self, tools: Any) -> Optional[List[ToolDefinition]]:
if not isinstance(tools, list):
return None
out: List[ToolDefinition] = []
for tool in tools:
if not isinstance(tool, dict):
continue
if tool.get("type") == "function" and isinstance(tool.get("function"), dict):
fn = tool["function"]
name = str(fn.get("name") or "")
if not name:
continue
out.append(
ToolDefinition(
name=name,
description=fn.get("description"),
parameters=fn.get("parameters") if isinstance(fn.get("parameters"), dict) else None,
extra={"openai_tool": self._extract_extra(tool, {"type", "function"}), "openai_function": self._extract_extra(fn, {"name", "description", "parameters"})},
)
)
continue
# 兼容:部分实现可能直接给 {name, description, parameters}
name = str(tool.get("name") or "")
if name:
out.append(
ToolDefinition(
name=name,
description=tool.get("description"),
parameters=tool.get("parameters") if isinstance(tool.get("parameters"), dict) else None,
extra={"openai_cli": self._extract_extra(tool, {"name", "description", "parameters"})},
)
)
return out or None
def _tool_choice_to_internal(self, tool_choice: Any) -> Optional[ToolChoice]:
if tool_choice is None:
return None
if isinstance(tool_choice, str):
if tool_choice == "none":
return ToolChoice(type=ToolChoiceType.NONE, extra={"openai_cli": {"tool_choice": tool_choice}})
if tool_choice == "auto":
return ToolChoice(type=ToolChoiceType.AUTO, extra={"openai_cli": {"tool_choice": tool_choice}})
return ToolChoice(type=ToolChoiceType.AUTO, extra={"raw": tool_choice})
if isinstance(tool_choice, dict):
# OpenAI 兼容结构:{"type":"function","function":{"name":"..."}}
if tool_choice.get("type") == "function" and isinstance(tool_choice.get("function"), dict):
name = str(tool_choice["function"].get("name") or "")
return ToolChoice(type=ToolChoiceType.TOOL, tool_name=name, extra={"openai_cli": tool_choice})
return ToolChoice(type=ToolChoiceType.AUTO, extra={"openai_cli": tool_choice})
return ToolChoice(type=ToolChoiceType.AUTO, extra={"raw": tool_choice})
def _tool_choice_to_openai(self, tool_choice: ToolChoice) -> Union[str, Dict[str, Any]]:
if tool_choice.type == ToolChoiceType.NONE:
return "none"
if tool_choice.type == ToolChoiceType.AUTO:
return "auto"
if tool_choice.type == ToolChoiceType.REQUIRED:
return "required"
if tool_choice.type == ToolChoiceType.TOOL:
return {"type": "function", "function": {"name": tool_choice.tool_name or ""}}
return "auto"
def _role_from_value(self, role: Any) -> Role:
value = str(role or "").lower()
if value == "user":
return Role.USER
if value == "assistant":
return Role.ASSISTANT
if value == "system":
return Role.SYSTEM
if value == "developer":
return Role.DEVELOPER
if value == "tool":
return Role.TOOL
return Role.UNKNOWN
def _role_to_openai(self, role: Role) -> str:
if role == Role.USER:
return "user"
if role == Role.ASSISTANT:
return "assistant"
if role == Role.SYSTEM:
return "system"
if role == Role.DEVELOPER:
return "developer"
if role == Role.TOOL:
return "tool"
return "user"
def _optional_int(self, value: Any) -> Optional[int]:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _optional_float(self, value: Any) -> Optional[float]:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _coerce_str_list(self, value: Any) -> Optional[List[str]]:
if value is None:
return None
if isinstance(value, str):
return [value]
if isinstance(value, list):
out: List[str] = []
for item in value:
if item is None:
continue
out.append(str(item))
return out
return [str(value)]
def _extract_extra(self, payload: Dict[str, Any], keep_keys: set[str]) -> Dict[str, Any]:
if not isinstance(payload, dict):
return {}
return {k: v for k, v in payload.items() if k not in keep_keys}
def _join_instructions(self, internal: InternalRequest) -> str:
if internal.instructions:
parts: List[str] = []
for seg in internal.instructions:
if seg.text:
parts.append(seg.text)
return "\n\n".join(parts)
return internal.system or ""
def _error_type_from_value(self, value: str) -> ErrorType:
for t in ErrorType:
if t.value == value:
return t
return ErrorType.UNKNOWN
__all__ = ["OpenAICliNormalizer"]

View File

@@ -1,54 +0,0 @@
"""
转换器协议定义
定义转换器必须实现的方法签名,用于类型检查和文档说明。
"""
from __future__ import annotations
from typing import Any, Dict, List, Protocol, runtime_checkable
@runtime_checkable
class RequestConverter(Protocol):
"""请求转换器协议"""
def convert_request(self, request: Dict[str, Any]) -> Dict[str, Any]: ...
@runtime_checkable
class ResponseConverter(Protocol):
"""响应转换器协议"""
def convert_response(self, response: Dict[str, Any]) -> Dict[str, Any]: ...
@runtime_checkable
class StreamChunkConverter(Protocol):
"""
流式响应块转换器协议
统一签名:(chunk, state) -> List[Dict]
说明:
- chunk: 单个流式事件/块
- state: 跨 chunk 的转换状态StreamConversionState 或 GeminiStreamConversionState
- 返回: 转换后的事件列表(可能 0-N 个)
注意:
- 所有流式转换器必须实现此签名
- state 参数用于跨 chunk 维护状态(如累积文本、消息 ID 等)
"""
def convert_stream_chunk(
self,
chunk: Dict[str, Any],
state: Any,
) -> List[Dict[str, Any]]: ...
__all__ = [
"RequestConverter",
"ResponseConverter",
"StreamChunkConverter",
]

View File

@@ -1,120 +1,68 @@
"""
格式转换注册表(核心层
格式转换注册表(Canonical / Hub-and-Spoke
自动管理不同 API 格式之间的转换器,支持
- 请求转换:客户端格式 → Provider 格式
- 响应转换Provider 格式 → 客户端格式
实现路径
source -> internal -> target
说明:
- 该注册表位于 core 层,避免 services 依赖 api/handlers
- 具体转换器的注册(例如 Claude/OpenAI/Gemini应由应用启动层完成
或由 api 层的 bootstrap 逻辑完成以保持依赖方向api -> core。
- 旧 N×N converters 已移除;这里是唯一的格式转换实现
- 转换失败将抛出 `FormatConversionError`(不再静默回退)。
"""
from __future__ import annotations
import threading
import time
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, Dict, Generator, Optional, Tuple, Union
from typing import Any, Dict, Generator, List, Optional
from src.core.logger import logger
from src.core.metrics import format_conversion_duration_seconds, format_conversion_total
from .exceptions import FormatConversionError
if TYPE_CHECKING:
from .state import (
ClaudeStreamConversionState,
GeminiStreamConversionState,
OpenAIStreamConversionState,
StreamConversionState,
)
from src.core.api_format.conversion.exceptions import FormatConversionError
from src.core.api_format.conversion.normalizer import FormatNormalizer
from src.core.api_format.conversion.stream_state import StreamState
@contextmanager
def _track_conversion_metrics(
direction: str, source: str, target: str
direction: str,
source: str,
target: str,
) -> Generator[None, None, None]:
"""
跟踪转换指标的上下文管理器
Args:
direction: 转换方向request/response/stream
source: 源格式(大写)
target: 目标格式(大写)
Yields:
None - 执行转换逻辑
"""
start = time.perf_counter()
status = "success"
try:
yield
format_conversion_total.labels(direction, source, target, "success").inc()
except Exception:
status = "error"
format_conversion_total.labels(direction, source, target, "error").inc()
raise
finally:
format_conversion_total.labels(direction, source, target, status).inc()
format_conversion_duration_seconds.labels(direction, source, target).observe(
time.perf_counter() - start
)
class FormatConverterRegistry:
"""
格式转换器注册表
管理不同 API 格式之间的双向转换器
"""
class FormatConversionRegistry:
"""基于 Normalizer 的格式转换注册表"""
def __init__(self) -> None:
# key: (source_format, target_format), value: converter instance
self._converters: Dict[Tuple[str, str], Any] = {}
self._normalizers: Dict[str, FormatNormalizer] = {}
def register(
self,
source_format: str,
target_format: str,
converter: Any,
) -> None:
"""
注册格式转换器
def register(self, normalizer: FormatNormalizer) -> None:
self._normalizers[str(normalizer.FORMAT_ID).upper()] = normalizer
logger.info(f"[FormatConversionRegistry] 注册 normalizer: {normalizer.FORMAT_ID}")
Args:
source_format: 源格式(如 "CLAUDE", "OPENAI", "GEMINI"
target_format: 目标格式
converter: 转换器实例(需要有 convert_request/convert_response 方法)
"""
key = (source_format.upper(), target_format.upper())
self._converters[key] = converter
logger.info(f"[ConverterRegistry] 注册转换器: {source_format} -> {target_format}")
def get_normalizer(self, format_id: str) -> Optional[FormatNormalizer]:
return self._normalizers.get(str(format_id).upper())
def get_converter(
self,
source_format: str,
target_format: str,
) -> Optional[Any]:
"""
获取转换器
def _require_normalizer(self, format_id: str) -> FormatNormalizer:
normalizer = self.get_normalizer(format_id)
if normalizer is None:
raise FormatConversionError(format_id, format_id, f"未注册 Normalizer: {format_id}")
return normalizer
Args:
source_format: 源格式
target_format: 目标格式
Returns:
转换器实例,如果不存在返回 None
"""
key = (source_format.upper(), target_format.upper())
return self._converters.get(key)
def has_converter(
self,
source_format: str,
target_format: str,
) -> bool:
"""检查是否存在转换器"""
key = (source_format.upper(), target_format.upper())
return key in self._converters
# ==================== 请求/响应转换(严格) ====================
def convert_request(
self,
@@ -122,43 +70,18 @@ class FormatConverterRegistry:
source_format: str,
target_format: str,
) -> Dict[str, Any]:
"""
转换请求
Args:
request: 原始请求字典
source_format: 源格式(客户端格式)
target_format: 目标格式Provider 格式)
Returns:
转换后的请求字典,如果无需转换或没有转换器则返回原始请求
"""
# 同格式无需转换
if source_format.upper() == target_format.upper():
if str(source_format).upper() == str(target_format).upper():
return request
converter = self.get_converter(source_format, target_format)
if converter is None:
logger.warning(
f"[ConverterRegistry] 未找到请求转换器: {source_format} -> {target_format},返回原始请求"
)
return request
src = self._require_normalizer(source_format)
tgt = self._require_normalizer(target_format)
if not hasattr(converter, "convert_request"):
logger.warning(
f"[ConverterRegistry] 转换器缺少 convert_request 方法: {source_format} -> {target_format}"
)
return request
try:
converted: Dict[str, Any] = converter.convert_request(request)
logger.debug(f"[ConverterRegistry] 请求转换成功: {source_format} -> {target_format}")
return converted
except Exception as e:
logger.error(
f"[ConverterRegistry] 请求转换失败: {source_format} -> {target_format}: {e}"
)
return request
with _track_conversion_metrics("request", str(source_format).upper(), str(target_format).upper()):
try:
internal = src.request_to_internal(request)
return tgt.request_from_internal(internal)
except Exception as e:
raise FormatConversionError(source_format, target_format, str(e)) from e
def convert_response(
self,
@@ -166,292 +89,168 @@ class FormatConverterRegistry:
source_format: str,
target_format: str,
) -> Dict[str, Any]:
"""
转换响应
Args:
response: 原始响应字典
source_format: 源格式Provider 格式)
target_format: 目标格式(客户端格式)
Returns:
转换后的响应字典,如果无需转换或没有转换器则返回原始响应
"""
# 同格式无需转换
if source_format.upper() == target_format.upper():
if str(source_format).upper() == str(target_format).upper():
return response
converter = self.get_converter(source_format, target_format)
if converter is None:
logger.warning(
f"[ConverterRegistry] 未找到响应转换器: {source_format} -> {target_format},返回原始响应"
src = self._require_normalizer(source_format)
tgt = self._require_normalizer(target_format)
with _track_conversion_metrics("response", str(source_format).upper(), str(target_format).upper()):
try:
internal = src.response_to_internal(response)
return tgt.response_from_internal(internal)
except Exception as e:
raise FormatConversionError(source_format, target_format, str(e)) from e
def convert_error_response(
self,
error_response: Dict[str, Any],
source_format: str,
target_format: str,
) -> Dict[str, Any]:
if str(source_format).upper() == str(target_format).upper():
return error_response
src = self._require_normalizer(source_format)
tgt = self._require_normalizer(target_format)
if not (src.capabilities.supports_error_conversion and tgt.capabilities.supports_error_conversion):
raise FormatConversionError(
source_format,
target_format,
"source/target normalizer 不支持错误转换",
)
return response
if not hasattr(converter, "convert_response"):
logger.warning(
f"[ConverterRegistry] 转换器缺少 convert_response 方法: {source_format} -> {target_format}"
)
return response
with _track_conversion_metrics("error", str(source_format).upper(), str(target_format).upper()):
try:
internal = src.error_to_internal(error_response)
return tgt.error_from_internal(internal)
except Exception as e:
raise FormatConversionError(source_format, target_format, str(e)) from e
try:
converted: Dict[str, Any] = converter.convert_response(response)
logger.debug(f"[ConverterRegistry] 响应转换成功: {source_format} -> {target_format}")
return converted
except Exception as e:
logger.error(
f"[ConverterRegistry] 响应转换失败: {source_format} -> {target_format}: {e}"
)
return response
# ==================== 流式转换(严格) ====================
def convert_stream_chunk(
self,
chunk: Dict[str, Any],
source_format: str,
target_format: str,
state: Optional[Union["StreamConversionState", "GeminiStreamConversionState"]] = None,
) -> list[Dict[str, Any]]:
"""
转换流式响应块
Args:
chunk: 原始流式响应块
source_format: 源格式Provider 格式)
target_format: 目标格式(客户端格式)
state: 流式转换状态StreamConversionState 或 GeminiStreamConversionState
Returns:
转换后的事件列表(可能 0-N 个),失败时返回原始 chunk 的单元素列表
"""
# 同格式无需转换
if source_format.upper() == target_format.upper():
state: Optional[StreamState] = None,
) -> List[Dict[str, Any]]:
if str(source_format).upper() == str(target_format).upper():
return [chunk]
converter = self.get_converter(source_format, target_format)
if converter is None:
return [chunk]
src = self._require_normalizer(source_format)
tgt = self._require_normalizer(target_format)
# 使用流式转换方法
if hasattr(converter, "convert_stream_chunk"):
if not (src.capabilities.supports_stream and tgt.capabilities.supports_stream):
raise FormatConversionError(
source_format,
target_format,
"source/target normalizer 不支持流式转换",
)
if state is None:
state = StreamState()
with _track_conversion_metrics("stream", str(source_format).upper(), str(target_format).upper()):
try:
result: list[Dict[str, Any]] = converter.convert_stream_chunk(chunk, state)
return result
events = src.stream_chunk_to_internal(chunk, state)
out: List[Dict[str, Any]] = []
for event in events:
out.extend(tgt.stream_event_from_internal(event, state))
return out
except Exception as e:
logger.error(
f"[ConverterRegistry] 流式块转换失败: {source_format} -> {target_format}: {e}"
)
return [chunk]
raise FormatConversionError(source_format, target_format, str(e)) from e
# 降级到普通响应转换(作为单个事件返回)
if hasattr(converter, "convert_response"):
try:
converted: Dict[str, Any] = converter.convert_response(chunk)
return [converted]
except Exception:
return [chunk]
# ==================== 能力查询 ====================
return [chunk]
def can_convert_request(self, source_format: str, target_format: str) -> bool:
if str(source_format).upper() == str(target_format).upper():
return True
return self.get_normalizer(source_format) is not None and self.get_normalizer(target_format) is not None
def list_converters(self) -> list[Tuple[str, str]]:
"""列出所有已注册的转换器"""
return list(self._converters.keys())
def can_convert_response(self, source_format: str, target_format: str) -> bool:
return self.can_convert_request(source_format, target_format)
# ========== 能力查询方法 ==========
def can_convert_request(self, source: str, target: str) -> bool:
"""检查是否支持请求转换"""
converter = self.get_converter(source, target)
return converter is not None and hasattr(converter, "convert_request")
def can_convert_response(self, source: str, target: str) -> bool:
"""检查是否支持响应转换"""
converter = self.get_converter(source, target)
return converter is not None and hasattr(converter, "convert_response")
def can_convert_stream(self, source: str, target: str) -> bool:
"""检查是否支持流式转换"""
converter = self.get_converter(source, target)
if converter is None:
def can_convert_stream(self, source_format: str, target_format: str) -> bool:
if str(source_format).upper() == str(target_format).upper():
return True
src = self.get_normalizer(source_format)
tgt = self.get_normalizer(target_format)
if src is None or tgt is None:
return False
return hasattr(converter, "convert_stream_chunk")
return bool(src.capabilities.supports_stream and tgt.capabilities.supports_stream)
def can_convert_full(
self,
source: str,
target: str,
require_stream: bool = False,
) -> bool:
"""
检查是否支持完整的双向转换
def can_convert_error(self, source_format: str, target_format: str) -> bool:
if str(source_format).upper() == str(target_format).upper():
return True
src = self.get_normalizer(source_format)
tgt = self.get_normalizer(target_format)
if src is None or tgt is None:
return False
return bool(src.capabilities.supports_error_conversion and tgt.capabilities.supports_error_conversion)
对于跨格式请求,需要:
1. 请求转换source -> target
2. 响应转换target -> source注意方向相反
3. 流式转换(如果 require_stream=Truetarget -> source
Args:
source: 客户端格式
target: Provider 格式
require_stream: 是否要求支持流式转换
"""
# 请求client -> provider
if not self.can_convert_request(source, target):
def can_convert_full(self, format_a: str, format_b: str, *, require_stream: bool = False) -> bool:
if not self.can_convert_request(format_a, format_b):
return False
# 响应provider -> client方向相反
if not self.can_convert_response(target, source):
return False
# 流式provider -> client方向相反
if require_stream and not self.can_convert_stream(target, source):
if not self.can_convert_request(format_b, format_a):
return False
if require_stream:
return self.can_convert_stream(format_a, format_b) and self.can_convert_stream(format_b, format_a)
return True
def get_supported_targets(self, source: str) -> list[str]:
"""获取指定源格式支持转换到的目标格式列表"""
source_upper = source.upper()
return [target for (src, target) in self._converters.keys() if src == source_upper]
def list_normalizers(self) -> List[str]:
return sorted(self._normalizers.keys())
# ========== 严格模式方法 ==========
def convert_request_strict(
self,
request: Dict[str, Any],
source_format: str,
target_format: str,
) -> Dict[str, Any]:
"""
严格模式请求转换 - 失败时抛出异常
用于需要故障转移的场景:转换失败会抛出 FormatConversionError
让 Orchestrator 可以尝试下一个候选。
Raises:
FormatConversionError: 转换失败时抛出
"""
source_upper = source_format.upper()
target_upper = target_format.upper()
# 同格式无需转换
if source_upper == target_upper:
return request
converter = self.get_converter(source_format, target_format)
if converter is None:
raise FormatConversionError(source_format, target_format, "未找到转换器")
if not hasattr(converter, "convert_request"):
raise FormatConversionError(
source_format, target_format, "转换器缺少 convert_request 方法"
)
with _track_conversion_metrics("request", source_upper, target_upper):
try:
converted: Dict[str, Any] = converter.convert_request(request)
logger.debug(f"[ConverterRegistry] 请求转换成功: {source_format} -> {target_format}")
return converted
except FormatConversionError:
raise
except Exception as e:
raise FormatConversionError(source_format, target_format, str(e)) from e
def convert_response_strict(
self,
response: Dict[str, Any],
source_format: str,
target_format: str,
) -> Dict[str, Any]:
"""
严格模式响应转换 - 失败时抛出异常
Raises:
FormatConversionError: 转换失败时抛出
"""
source_upper = source_format.upper()
target_upper = target_format.upper()
if source_upper == target_upper:
return response
converter = self.get_converter(source_format, target_format)
if converter is None:
raise FormatConversionError(source_format, target_format, "未找到转换器")
if not hasattr(converter, "convert_response"):
raise FormatConversionError(
source_format, target_format, "转换器缺少 convert_response 方法"
)
with _track_conversion_metrics("response", source_upper, target_upper):
try:
converted: Dict[str, Any] = converter.convert_response(response)
logger.debug(f"[ConverterRegistry] 响应转换成功: {source_format} -> {target_format}")
return converted
except FormatConversionError:
raise
except Exception as e:
raise FormatConversionError(source_format, target_format, str(e)) from e
def convert_stream_chunk_strict(
self,
chunk: Dict[str, Any],
source_format: str,
target_format: str,
state: Optional[
Union[
"StreamConversionState",
"GeminiStreamConversionState",
"ClaudeStreamConversionState",
"OpenAIStreamConversionState",
]
] = None,
) -> list[Dict[str, Any]]:
"""
严格模式流式块转换 - 失败时抛出异常
Args:
chunk: 流式响应块
source_format: 源格式
target_format: 目标格式
state: 流式转换状态StreamConversionState 或 GeminiStreamConversionState
Returns:
转换后的事件列表(可能 0-N 个)
Raises:
FormatConversionError: 转换失败时抛出
"""
source_upper = source_format.upper()
target_upper = target_format.upper()
if source_upper == target_upper:
return [chunk]
converter = self.get_converter(source_format, target_format)
if converter is None:
raise FormatConversionError(source_format, target_format, "未找到转换器")
if not hasattr(converter, "convert_stream_chunk"):
raise FormatConversionError(
source_format, target_format, "转换器缺少 convert_stream_chunk 方法"
)
with _track_conversion_metrics("stream", source_upper, target_upper):
try:
result: list[Dict[str, Any]] = converter.convert_stream_chunk(chunk, state)
return result
except FormatConversionError:
raise
except Exception as e:
raise FormatConversionError(
source_format, target_format, f"流式块转换失败: {e}"
) from e
def get_supported_targets(self, source_format: str) -> List[str]:
src = str(source_format).upper()
if src not in self._normalizers:
return []
return [k for k in self._normalizers.keys() if k != src]
# 全局单例
converter_registry = FormatConverterRegistry()
# 全局注册表(唯一实现)
format_conversion_registry = FormatConversionRegistry()
_DEFAULT_NORMALIZERS_REGISTERED = False
_REGISTRATION_LOCK = threading.Lock()
def register_default_normalizers() -> None:
"""注册默认 NormalizersOPENAI/CLAUDE/GEMINI + *_CLI"""
global _DEFAULT_NORMALIZERS_REGISTERED # noqa: PLW0603 - module-level 缓存
# 快速路径:已注册则直接返回(无锁)
if _DEFAULT_NORMALIZERS_REGISTERED:
return
# 慢路径:加锁后双重检查
with _REGISTRATION_LOCK:
if _DEFAULT_NORMALIZERS_REGISTERED:
return
from src.core.api_format.conversion.normalizers.claude import ClaudeNormalizer
from src.core.api_format.conversion.normalizers.claude_cli import ClaudeCliNormalizer
from src.core.api_format.conversion.normalizers.gemini import GeminiNormalizer
from src.core.api_format.conversion.normalizers.gemini_cli import GeminiCliNormalizer
from src.core.api_format.conversion.normalizers.openai import OpenAINormalizer
from src.core.api_format.conversion.normalizers.openai_cli import OpenAICliNormalizer
format_conversion_registry.register(OpenAINormalizer())
format_conversion_registry.register(OpenAICliNormalizer())
format_conversion_registry.register(ClaudeNormalizer())
format_conversion_registry.register(ClaudeCliNormalizer())
format_conversion_registry.register(GeminiNormalizer())
format_conversion_registry.register(GeminiCliNormalizer())
_DEFAULT_NORMALIZERS_REGISTERED = True
logger.info(
f"[FormatConversionRegistry] 已注册 {len(format_conversion_registry.list_normalizers())} 个 normalizer"
)
__all__ = [
"FormatConverterRegistry",
"converter_registry",
"FormatConversionError",
"FormatConversionRegistry",
"format_conversion_registry",
"register_default_normalizers",
]

View File

@@ -1,114 +0,0 @@
"""
流式转换状态类
用于在多个 chunk 之间维护转换上下文,例如:
- 是否已发送 message_start 事件
- 累积的文本(用于计算增量)
- 当前内容块索引
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class StreamConversionState:
"""
SSE 流式转换状态Claude <-> OpenAI
用于跨 chunk 维护转换状态,确保生成正确的事件序列。
"""
message_id: str = ""
model: str = ""
message_started: bool = False
content_block_started: bool = False
current_tool_index: int = 0
def reset(self) -> None:
"""重置状态(重试时调用)"""
self.message_started = False
self.content_block_started = False
self.current_tool_index = 0
@dataclass
class GeminiStreamConversionState:
"""
Gemini 流式转换状态JSON 数组格式)
与 Claude/OpenAI 的 SSE 不同Gemini 需要追踪额外状态:
1. 累积文本(用于计算真正的增量)
2. 内容块索引(用于工具调用)
3. 是否已发送 message_start
"""
message_id: str = ""
model: str = ""
accumulated_text: str = "" # 累积的文本(用于计算增量)
message_started: bool = False
content_block_started: bool = False
current_block_index: int = 0
tool_call_index: int = 0 # 工具调用计数
has_sent_usage: bool = False # 是否已发送 usage
def reset(self) -> None:
"""重置状态(重试时调用)"""
self.accumulated_text = ""
self.message_started = False
self.content_block_started = False
self.current_block_index = 0
self.tool_call_index = 0
self.has_sent_usage = False
@dataclass
class ClaudeStreamConversionState:
"""
Claude -> Gemini 流式转换状态
用于将 Claude SSE 事件流转换为 Gemini JSON 流式响应
"""
message_id: str = ""
model: str = ""
current_block_type: str = "" # 当前内容块类型text/tool_use
current_block_index: int = 0
current_tool_name: str = ""
current_tool_id: str = ""
accumulated_tool_input: str = "" # 累积的工具输入 JSON
def reset(self) -> None:
"""重置状态(重试时调用)"""
self.current_block_type = ""
self.current_block_index = 0
self.current_tool_name = ""
self.current_tool_id = ""
self.accumulated_tool_input = ""
@dataclass
class OpenAIStreamConversionState:
"""
OpenAI -> Gemini 流式转换状态
用于将 OpenAI SSE 事件流转换为 Gemini JSON 流式响应
"""
model: str = ""
current_tool_name: str = ""
accumulated_tool_args: str = "" # 累积的工具参数 JSON
def reset(self) -> None:
"""重置状态(重试时调用)"""
self.current_tool_name = ""
self.accumulated_tool_args = ""
__all__ = [
"StreamConversionState",
"GeminiStreamConversionState",
"ClaudeStreamConversionState",
"OpenAIStreamConversionState",
]

View File

@@ -0,0 +1,147 @@
"""
类型安全的流式事件定义InternalStreamEvent
用于把 OpenAI/Claude/Gemini 的流式协议映射为统一事件序列,再由目标格式 Normalizer 输出。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, Optional, Union
from .internal import ContentType, InternalError, StopReason, UsageInfo
class StreamEventType(str, Enum):
"""流式事件类型"""
MESSAGE_START = "message_start"
CONTENT_BLOCK_START = "content_block_start"
CONTENT_DELTA = "content_delta"
TOOL_CALL_DELTA = "tool_call_delta"
CONTENT_BLOCK_STOP = "content_block_stop"
MESSAGE_STOP = "message_stop"
USAGE = "usage"
ERROR = "error"
UNKNOWN = "unknown"
@dataclass
class MessageStartEvent:
"""消息开始事件"""
type: StreamEventType = field(default=StreamEventType.MESSAGE_START, init=False)
message_id: str = ""
model: str = ""
usage: Optional[UsageInfo] = None # Claude 流式响应的 message_start 可能包含 usage
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ContentBlockStartEvent:
"""内容块开始事件"""
type: StreamEventType = field(default=StreamEventType.CONTENT_BLOCK_START, init=False)
block_index: int = 0
block_type: ContentType = ContentType.TEXT
# 工具调用时使用TOOL_USE block
tool_id: Optional[str] = None
tool_name: Optional[str] = None
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ContentDeltaEvent:
"""内容增量事件"""
type: StreamEventType = field(default=StreamEventType.CONTENT_DELTA, init=False)
block_index: int = 0
text_delta: str = ""
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ToolCallDeltaEvent:
"""工具调用增量事件(工具输入 JSON 的字符串片段)"""
type: StreamEventType = field(default=StreamEventType.TOOL_CALL_DELTA, init=False)
block_index: int = 0
tool_id: str = ""
input_delta: str = "" # JSON 字符串片段
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ContentBlockStopEvent:
"""内容块结束事件"""
type: StreamEventType = field(default=StreamEventType.CONTENT_BLOCK_STOP, init=False)
block_index: int = 0
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class MessageStopEvent:
"""消息结束事件"""
type: StreamEventType = field(default=StreamEventType.MESSAGE_STOP, init=False)
stop_reason: Optional[StopReason] = None
usage: Optional[UsageInfo] = None
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class UsageEvent:
"""使用量事件"""
type: StreamEventType = field(default=StreamEventType.USAGE, init=False)
usage: UsageInfo = field(default_factory=UsageInfo)
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ErrorEvent:
"""错误事件"""
type: StreamEventType = field(default=StreamEventType.ERROR, init=False)
error: InternalError
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class UnknownStreamEvent:
"""未知事件(用于前向兼容)"""
type: StreamEventType = field(default=StreamEventType.UNKNOWN, init=False)
raw_type: str = ""
payload: Dict[str, Any] = field(default_factory=dict)
extra: Dict[str, Any] = field(default_factory=dict)
InternalStreamEvent = Union[
MessageStartEvent,
ContentBlockStartEvent,
ContentDeltaEvent,
ToolCallDeltaEvent,
ContentBlockStopEvent,
MessageStopEvent,
UsageEvent,
ErrorEvent,
UnknownStreamEvent,
]
__all__ = [
"StreamEventType",
"MessageStartEvent",
"ContentBlockStartEvent",
"ContentDeltaEvent",
"ToolCallDeltaEvent",
"ContentBlockStopEvent",
"MessageStopEvent",
"UsageEvent",
"ErrorEvent",
"UnknownStreamEvent",
"InternalStreamEvent",
]

View File

@@ -0,0 +1,50 @@
"""
统一流式状态容器StreamState
目标:在多个 chunk 之间维护转换上下文,但避免把“某个格式特定的状态字段”固化在核心层。
每个 Normalizer 通过 `substate(format_id)` 获取自己的隔离状态字典。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict
@dataclass
class StreamState:
"""
统一的流式状态容器
关键点:
- 不把具体格式字段固化为属性,避免 source/target 互相污染
- 每个 Normalizer 只读写自己的隔离子状态:`state.substate(self.FORMAT_ID)`
"""
# 可选:便于调试与链路追踪(不强依赖)
model: str = ""
message_id: str = ""
# Registry/调用层的通用扩展信息(与具体格式无关)
extra: Dict[str, Any] = field(default_factory=dict)
# 各 Normalizer 的隔离状态key: FORMAT_ID
by_format: Dict[str, Dict[str, Any]] = field(default_factory=dict)
def substate(self, format_id: str) -> Dict[str, Any]:
"""获取指定格式的隔离子状态"""
key = str(format_id).upper()
return self.by_format.setdefault(key, {})
def reset(self) -> None:
"""重置状态(重试时调用)"""
self.model = ""
self.message_id = ""
self.extra.clear()
self.by_format.clear()
__all__ = [
"StreamState",
]

View File

@@ -16,7 +16,8 @@ def is_cli_format(format_id: Union[str, "APIFormat", None]) -> bool:
"""
判断是否为 CLI 透传格式
CLI 格式以 _CLI 结尾,不参与格式转换,请求直接透传
CLI 格式以 _CLI 结尾,表示该入口更偏向“CLI 兼容层”(鉴权/UA/路径差异等)
是否参与格式转换由转换层决定;当前项目已支持 CLI 格式参与转换。
Args:
format_id: 格式标识符(字符串或 APIFormat 枚举)
@@ -96,11 +97,13 @@ def is_same_format(
def is_convertible_format(format_id: Union[str, "APIFormat", None]) -> bool:
"""
判断是否为可转换格式(非 CLI
判断是否为可转换格式
可转换格式可以与其他格式进行双向转换。
CLI 格式为透传模式,不参与转换
.. deprecated::
此函数语义已退化(对非 None 输入总返回 True
真正的可转换性应通过 format_conversion_registry.can_convert_*() 查询。
保留此函数仅为向后兼容,不建议新代码使用。
"""
if format_id is None:
return False
return not is_cli_format(format_id)
return True