mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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()`: 注册默认 Normalizers(OPENAI/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",
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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"]
|
||||
133
src/core/api_format/conversion/field_mappings.py
Normal file
133
src/core/api_format/conversion/field_mappings.py
Normal 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",
|
||||
]
|
||||
|
||||
297
src/core/api_format/conversion/internal.py
Normal file
297
src/core/api_format/conversion/internal.py
Normal 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
|
||||
# 工具输出可能是纯文本,也可能是结构化 JSON(Gemini 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",
|
||||
]
|
||||
|
||||
84
src/core/api_format/conversion/normalizer.py
Normal file
84
src/core/api_format/conversion/normalizer.py
Normal 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",
|
||||
]
|
||||
|
||||
12
src/core/api_format/conversion/normalizers/__init__.py
Normal file
12
src/core/api_format/conversion/normalizers/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Normalizers
|
||||
|
||||
实现各格式 <-> internal 的标准化器。
|
||||
|
||||
本目录在 Phase 1 仅创建结构;具体实现将在 Phase 2+ 补齐。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__: list[str] = []
|
||||
|
||||
993
src/core/api_format/conversion/normalizers/claude.py
Normal file
993
src/core/api_format/conversion/normalizers/claude.py
Normal 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"]
|
||||
18
src/core/api_format/conversion/normalizers/claude_cli.py
Normal file
18
src/core/api_format/conversion/normalizers/claude_cli.py
Normal 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"]
|
||||
|
||||
944
src/core/api_format/conversion/normalizers/gemini.py
Normal file
944
src/core/api_format/conversion/normalizers/gemini.py
Normal file
@@ -0,0 +1,944 @@
|
||||
"""
|
||||
Gemini (GenerateContent / streamGenerateContent) Normalizer
|
||||
|
||||
负责:
|
||||
- Gemini request/response <-> Internal 表示转换
|
||||
- 可选:Gemini streaming chunk <-> InternalStreamEvent
|
||||
- 可选:Gemini error <-> InternalError
|
||||
|
||||
说明:
|
||||
- 请求体字段在本项目中同时兼容 snake_case(历史转换器产物)与 camelCase(官方/客户端输入)。
|
||||
- 响应/流式通常为 camelCase(candidates/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
|
||||
|
||||
# functionCall(stream 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_id,response 固定包一层 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"]
|
||||
18
src/core/api_format/conversion/normalizers/gemini_cli.py
Normal file
18
src/core/api_format/conversion/normalizers/gemini_cli.py
Normal 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"]
|
||||
|
||||
1065
src/core/api_format/conversion/normalizers/openai.py
Normal file
1065
src/core/api_format/conversion/normalizers/openai.py
Normal file
File diff suppressed because it is too large
Load Diff
880
src/core/api_format/conversion/normalizers/openai_cli.py
Normal file
880
src/core/api_format/conversion/normalizers/openai_cli.py
Normal 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
|
||||
|
||||
# reasoning(UnknownBlock 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
|
||||
|
||||
# 普通 message(TextBlock)
|
||||
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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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=True):target -> 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:
|
||||
"""注册默认 Normalizers(OPENAI/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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
147
src/core/api_format/conversion/stream_events.py
Normal file
147
src/core/api_format/conversion/stream_events.py
Normal 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",
|
||||
]
|
||||
50
src/core/api_format/conversion/stream_state.py
Normal file
50
src/core/api_format/conversion/stream_state.py
Normal 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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user