mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
fix: 修复跨格式转换时 usage 提取不准确的问题
- CLI Handler: 跨格式转换时使用 Provider 格式的解析器提取原始 usage - CLI Handler: 新增从转换后事件中提取 usage 的补充方法 - Chat Handler: 移除不必要的格式自动检测转换代码(跨格式转换由 CLI Handler 统一处理) - OpenAI Normalizer: 流式请求自动添加 stream_options 以获取 usage 统计
This commit is contained in:
@@ -1481,6 +1481,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
event_type = event_name or data.get("type", "")
|
event_type = event_name or data.get("type", "")
|
||||||
|
|
||||||
# 调用格式特定的处理逻辑
|
# 调用格式特定的处理逻辑
|
||||||
|
# 注意:跨格式转换时,_process_event_data 会自动选择正确的 Provider 解析器
|
||||||
self._process_event_data(ctx, event_type, data)
|
self._process_event_data(ctx, event_type, data)
|
||||||
|
|
||||||
def _process_event_data(
|
def _process_event_data(
|
||||||
@@ -1505,7 +1506,27 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
# 使用解析器提取 usage
|
# 使用解析器提取 usage
|
||||||
# Claude/CLI 流式响应的 usage 可能在首个 chunk 或最后一个 chunk 中
|
# Claude/CLI 流式响应的 usage 可能在首个 chunk 或最后一个 chunk 中
|
||||||
# 首个 chunk 可能部分为 0,最后一个 chunk 包含完整值,因此取最大值确保正确计费
|
# 首个 chunk 可能部分为 0,最后一个 chunk 包含完整值,因此取最大值确保正确计费
|
||||||
usage = self.parser.extract_usage_from_response(data)
|
#
|
||||||
|
# 重要:当跨格式转换时,收到的数据是 Provider 格式,需要使用 Provider 格式的解析器
|
||||||
|
# 而不是客户端格式的解析器(self.parser)
|
||||||
|
parser = self.parser
|
||||||
|
if ctx.provider_api_format and ctx.provider_api_format != ctx.client_api_format:
|
||||||
|
# 跨格式转换:使用 Provider 格式的解析器
|
||||||
|
try:
|
||||||
|
provider_parser = get_parser_for_format(ctx.provider_api_format)
|
||||||
|
if provider_parser:
|
||||||
|
parser = provider_parser
|
||||||
|
logger.debug(
|
||||||
|
f"[{getattr(ctx, 'request_id', 'unknown')}] 使用 Provider 解析器: "
|
||||||
|
f"{ctx.provider_api_format} (client={ctx.client_api_format})"
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
logger.debug(
|
||||||
|
f"[{getattr(ctx, 'request_id', 'unknown')}] 未找到 Provider 格式解析器: "
|
||||||
|
f"{ctx.provider_api_format}, 回退使用客户端格式解析器"
|
||||||
|
)
|
||||||
|
|
||||||
|
usage = parser.extract_usage_from_response(data)
|
||||||
if usage:
|
if usage:
|
||||||
new_input = usage.get("input_tokens", 0)
|
new_input = usage.get("input_tokens", 0)
|
||||||
new_output = usage.get("output_tokens", 0)
|
new_output = usage.get("output_tokens", 0)
|
||||||
@@ -1526,8 +1547,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
if any([new_input, new_output, new_cached, new_cache_creation]):
|
if any([new_input, new_output, new_cached, new_cache_creation]):
|
||||||
ctx.final_usage = usage
|
ctx.final_usage = usage
|
||||||
|
|
||||||
# 提取文本内容
|
# 提取文本内容(同样使用正确的解析器)
|
||||||
text = self.parser.extract_text_content(data)
|
text = parser.extract_text_content(data)
|
||||||
if text:
|
if text:
|
||||||
ctx.append_text(text)
|
ctx.append_text(text)
|
||||||
|
|
||||||
@@ -1549,6 +1570,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
当需要格式转换时,记录的是转换后的数据(客户端实际收到的格式);
|
当需要格式转换时,记录的是转换后的数据(客户端实际收到的格式);
|
||||||
同时更新 data_count、has_completion 等统计信息。
|
同时更新 data_count、has_completion 等统计信息。
|
||||||
|
|
||||||
|
重要:此方法也从转换后的事件中提取 usage 信息,作为 _process_event_data
|
||||||
|
从原始数据提取的补充。这确保即使原始 Provider 数据中没有 usage(如 OpenAI
|
||||||
|
未设置 stream_options),也能从转换后的格式中获取。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ctx: 流上下文
|
ctx: 流上下文
|
||||||
converted_events: 转换后的事件列表
|
converted_events: 转换后的事件列表
|
||||||
@@ -1573,6 +1598,79 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
ctx.has_completion = True
|
ctx.has_completion = True
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# 从转换后的事件中提取 usage(补充 _process_event_data 的提取)
|
||||||
|
# Claude 格式: message_delta.usage 或 message_start.message.usage
|
||||||
|
# OpenAI 格式: chunk.usage
|
||||||
|
self._extract_usage_from_converted_event(ctx, evt, event_type)
|
||||||
|
|
||||||
|
def _extract_usage_from_converted_event(
|
||||||
|
self,
|
||||||
|
ctx: StreamContext,
|
||||||
|
evt: Dict[str, Any],
|
||||||
|
event_type: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
从转换后的事件中提取 usage 信息
|
||||||
|
|
||||||
|
支持多种格式:
|
||||||
|
- Claude: message_delta.usage, message_start.message.usage
|
||||||
|
- OpenAI: chunk.usage
|
||||||
|
- Gemini: usageMetadata
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: 流上下文
|
||||||
|
evt: 转换后的事件
|
||||||
|
event_type: 事件类型
|
||||||
|
"""
|
||||||
|
usage: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
# Claude 格式: message_delta 或 message_start
|
||||||
|
if event_type == "message_delta":
|
||||||
|
usage = evt.get("usage")
|
||||||
|
elif event_type == "message_start":
|
||||||
|
message = evt.get("message", {})
|
||||||
|
if isinstance(message, dict):
|
||||||
|
usage = message.get("usage")
|
||||||
|
# OpenAI 格式: 直接在 chunk 中
|
||||||
|
elif "usage" in evt:
|
||||||
|
usage = evt.get("usage")
|
||||||
|
# Gemini 格式: usageMetadata
|
||||||
|
elif "usageMetadata" in evt:
|
||||||
|
meta = evt.get("usageMetadata", {})
|
||||||
|
if isinstance(meta, dict):
|
||||||
|
usage = {
|
||||||
|
"input_tokens": meta.get("promptTokenCount", 0),
|
||||||
|
"output_tokens": meta.get("candidatesTokenCount", 0),
|
||||||
|
"cache_read_tokens": meta.get("cachedContentTokenCount", 0),
|
||||||
|
"cache_creation_tokens": 0, # Gemini 目前不支持缓存创建
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage and isinstance(usage, dict):
|
||||||
|
new_input = usage.get("input_tokens", 0) or 0
|
||||||
|
new_output = usage.get("output_tokens", 0) or 0
|
||||||
|
new_cached = usage.get("cache_read_tokens") or usage.get("cache_read_input_tokens") or 0
|
||||||
|
new_cache_creation = usage.get("cache_creation_tokens") or usage.get("cache_creation_input_tokens") or 0
|
||||||
|
|
||||||
|
# 取最大值更新(与 _process_event_data 相同的策略)
|
||||||
|
if new_input > ctx.input_tokens:
|
||||||
|
ctx.input_tokens = new_input
|
||||||
|
logger.debug(
|
||||||
|
f"[{ctx.request_id}] 从转换后事件更新 input_tokens: {new_input}"
|
||||||
|
)
|
||||||
|
if new_output > ctx.output_tokens:
|
||||||
|
ctx.output_tokens = new_output
|
||||||
|
logger.debug(
|
||||||
|
f"[{ctx.request_id}] 从转换后事件更新 output_tokens: {new_output}"
|
||||||
|
)
|
||||||
|
if new_cached > ctx.cached_tokens:
|
||||||
|
ctx.cached_tokens = new_cached
|
||||||
|
if new_cache_creation > ctx.cache_creation_tokens:
|
||||||
|
ctx.cache_creation_tokens = new_cache_creation
|
||||||
|
|
||||||
|
# 保存最后一个非空 usage
|
||||||
|
if any([new_input, new_output, new_cached, new_cache_creation]):
|
||||||
|
ctx.final_usage = usage
|
||||||
|
|
||||||
def _finalize_stream_metadata(self, ctx: StreamContext) -> None:
|
def _finalize_stream_metadata(self, ctx: StreamContext) -> None:
|
||||||
"""
|
"""
|
||||||
在记录统计前从 parsed_chunks 中提取额外的元数据 - 子类可覆盖
|
在记录统计前从 parsed_chunks 中提取额外的元数据 - 子类可覆盖
|
||||||
|
|||||||
@@ -66,43 +66,26 @@ class ClaudeChatHandler(ChatHandlerBase):
|
|||||||
|
|
||||||
async def _convert_request(self, request: Any) -> Any:
|
async def _convert_request(self, request: Any) -> Any:
|
||||||
"""
|
"""
|
||||||
将请求转换为 Claude 格式
|
将请求转换为 Claude 格式的 Pydantic 对象
|
||||||
|
|
||||||
|
注意:此方法只做类型转换(dict → Pydantic),不做跨格式转换。
|
||||||
|
跨格式转换由 FallbackOrchestrator 在选中候选后、发送请求前执行,
|
||||||
|
并受全局开关和端点配置控制。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: 原始请求对象
|
request: 原始请求对象(应已是 Claude 格式)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ClaudeMessagesRequest 对象
|
ClaudeMessagesRequest 对象
|
||||||
"""
|
"""
|
||||||
from src.core.api_format.conversion.registry import (
|
|
||||||
format_conversion_registry,
|
|
||||||
register_default_normalizers,
|
|
||||||
)
|
|
||||||
from src.models.claude import ClaudeMessagesRequest
|
from src.models.claude import ClaudeMessagesRequest
|
||||||
from src.models.openai import OpenAIRequest
|
|
||||||
|
|
||||||
register_default_normalizers()
|
# 如果已经是 Claude 格式 Pydantic 对象,直接返回
|
||||||
|
|
||||||
# 如果已经是 Claude 格式,直接返回
|
|
||||||
if isinstance(request, ClaudeMessagesRequest):
|
if isinstance(request, ClaudeMessagesRequest):
|
||||||
return request
|
return request
|
||||||
|
|
||||||
# 如果是 OpenAI 格式,转换为 Claude 格式
|
# 如果是字典,转换为 Pydantic 对象(假设已是 Claude 格式)
|
||||||
if isinstance(request, OpenAIRequest):
|
|
||||||
req_dict = request.model_dump() if hasattr(request, "model_dump") else request.dict()
|
|
||||||
claude_dict = format_conversion_registry.convert_request(req_dict, "OPENAI", "CLAUDE")
|
|
||||||
return ClaudeMessagesRequest(**claude_dict)
|
|
||||||
|
|
||||||
# 如果是字典,根据内容判断格式
|
|
||||||
if isinstance(request, dict):
|
if isinstance(request, dict):
|
||||||
if "messages" in request and len(request["messages"]) > 0:
|
|
||||||
first_msg = request["messages"][0]
|
|
||||||
if "role" in first_msg and "content" in first_msg:
|
|
||||||
# 可能是 OpenAI 格式
|
|
||||||
claude_dict = format_conversion_registry.convert_request(request, "OPENAI", "CLAUDE")
|
|
||||||
return ClaudeMessagesRequest(**claude_dict)
|
|
||||||
|
|
||||||
# 否则假设已经是 Claude 格式
|
|
||||||
return ClaudeMessagesRequest(**request)
|
return ClaudeMessagesRequest(**request)
|
||||||
|
|
||||||
return request
|
return request
|
||||||
|
|||||||
@@ -50,66 +50,26 @@ class GeminiChatHandler(ChatHandlerBase):
|
|||||||
|
|
||||||
async def _convert_request(self, request):
|
async def _convert_request(self, request):
|
||||||
"""
|
"""
|
||||||
将请求转换为 Gemini 格式
|
将请求转换为 Gemini 格式的 Pydantic 对象
|
||||||
|
|
||||||
支持自动转换:
|
注意:此方法只做类型转换(dict → Pydantic),不做跨格式转换。
|
||||||
- Claude 格式 → Gemini 格式
|
跨格式转换由 FallbackOrchestrator 在选中候选后、发送请求前执行,
|
||||||
- OpenAI 格式 → Gemini 格式
|
并受全局开关和端点配置控制。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: 原始请求对象(可能是 Gemini/Claude/OpenAI 格式)
|
request: 原始请求对象(应已是 Gemini 格式)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
GeminiRequest 对象
|
GeminiRequest 对象
|
||||||
"""
|
"""
|
||||||
from src.core.api_format.conversion.registry import (
|
|
||||||
format_conversion_registry,
|
|
||||||
register_default_normalizers,
|
|
||||||
)
|
|
||||||
from src.models.claude import ClaudeMessagesRequest
|
|
||||||
from src.models.gemini import GeminiRequest
|
from src.models.gemini import GeminiRequest
|
||||||
from src.models.openai import OpenAIRequest
|
|
||||||
|
|
||||||
register_default_normalizers()
|
# 如果已经是 Gemini 格式 Pydantic 对象,直接返回
|
||||||
|
|
||||||
# 如果已经是 Gemini 格式,直接返回
|
|
||||||
if isinstance(request, GeminiRequest):
|
if isinstance(request, GeminiRequest):
|
||||||
return request
|
return request
|
||||||
|
|
||||||
# 如果是 Claude 格式,转换为 Gemini 格式
|
# 如果是字典,转换为 Pydantic 对象(假设已是 Gemini 格式)
|
||||||
if isinstance(request, ClaudeMessagesRequest):
|
|
||||||
req_dict = request.model_dump() if hasattr(request, "model_dump") else request.dict()
|
|
||||||
gemini_dict = format_conversion_registry.convert_request(req_dict, "CLAUDE", "GEMINI")
|
|
||||||
return GeminiRequest(**gemini_dict)
|
|
||||||
|
|
||||||
# 如果是 OpenAI 格式,转换为 Gemini 格式
|
|
||||||
if isinstance(request, OpenAIRequest):
|
|
||||||
req_dict = request.model_dump() if hasattr(request, "model_dump") else request.dict()
|
|
||||||
gemini_dict = format_conversion_registry.convert_request(req_dict, "OPENAI", "GEMINI")
|
|
||||||
return GeminiRequest(**gemini_dict)
|
|
||||||
|
|
||||||
# 如果是字典,根据内容判断格式并转换
|
|
||||||
if isinstance(request, dict):
|
if isinstance(request, dict):
|
||||||
# 检测 Gemini 格式特征: contents 字段
|
|
||||||
if "contents" in request:
|
|
||||||
return GeminiRequest(**request)
|
|
||||||
|
|
||||||
# 检测 Claude 格式特征: messages + 没有 choices
|
|
||||||
if "messages" in request and "choices" not in request:
|
|
||||||
# 进一步区分 Claude 和 OpenAI
|
|
||||||
# Claude 使用 max_tokens,OpenAI 也可能有
|
|
||||||
# Claude 的 messages[].content 可以是数组,OpenAI 通常是字符串
|
|
||||||
messages = request.get("messages", [])
|
|
||||||
if messages and isinstance(messages[0].get("content"), list):
|
|
||||||
# 可能是 Claude 格式
|
|
||||||
gemini_dict = format_conversion_registry.convert_request(request, "CLAUDE", "GEMINI")
|
|
||||||
return GeminiRequest(**gemini_dict)
|
|
||||||
else:
|
|
||||||
# 可能是 OpenAI 格式
|
|
||||||
gemini_dict = format_conversion_registry.convert_request(request, "OPENAI", "GEMINI")
|
|
||||||
return GeminiRequest(**gemini_dict)
|
|
||||||
|
|
||||||
# 默认尝试作为 Gemini 格式
|
|
||||||
return GeminiRequest(**request)
|
return GeminiRequest(**request)
|
||||||
|
|
||||||
return request
|
return request
|
||||||
|
|||||||
@@ -65,43 +65,27 @@ class OpenAIChatHandler(ChatHandlerBase):
|
|||||||
|
|
||||||
async def _convert_request(self, request):
|
async def _convert_request(self, request):
|
||||||
"""
|
"""
|
||||||
将请求转换为 OpenAI 格式
|
将请求转换为 OpenAI 格式的 Pydantic 对象
|
||||||
|
|
||||||
|
注意:此方法只做类型转换(dict → Pydantic),不做跨格式转换。
|
||||||
|
跨格式转换由 FallbackOrchestrator 在选中候选后、发送请求前执行,
|
||||||
|
并受全局开关和端点配置控制。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: 原始请求对象
|
request: 原始请求对象(应已是 OpenAI 格式)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
OpenAIRequest 对象
|
OpenAIRequest 对象
|
||||||
"""
|
"""
|
||||||
from src.core.api_format.conversion.registry import (
|
|
||||||
format_conversion_registry,
|
|
||||||
register_default_normalizers,
|
|
||||||
)
|
|
||||||
from src.models.claude import ClaudeMessagesRequest
|
|
||||||
from src.models.openai import OpenAIRequest
|
from src.models.openai import OpenAIRequest
|
||||||
|
|
||||||
register_default_normalizers()
|
# 如果已经是 OpenAI 格式 Pydantic 对象,直接返回
|
||||||
|
|
||||||
# 如果已经是 OpenAI 格式,直接返回
|
|
||||||
if isinstance(request, OpenAIRequest):
|
if isinstance(request, OpenAIRequest):
|
||||||
return request
|
return request
|
||||||
|
|
||||||
# 如果是 Claude 格式,转换为 OpenAI 格式
|
# 如果是字典,转换为 Pydantic 对象(假设已是 OpenAI 格式)
|
||||||
if isinstance(request, ClaudeMessagesRequest):
|
|
||||||
req_dict = request.model_dump() if hasattr(request, "model_dump") else request.dict()
|
|
||||||
openai_dict = format_conversion_registry.convert_request(req_dict, "CLAUDE", "OPENAI")
|
|
||||||
return OpenAIRequest(**openai_dict)
|
|
||||||
|
|
||||||
# 如果是字典,尝试判断格式
|
|
||||||
if isinstance(request, dict):
|
if isinstance(request, dict):
|
||||||
try:
|
return OpenAIRequest(**request)
|
||||||
return OpenAIRequest(**request)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
openai_dict = format_conversion_registry.convert_request(request, "CLAUDE", "OPENAI")
|
|
||||||
return OpenAIRequest(**openai_dict)
|
|
||||||
except Exception:
|
|
||||||
return OpenAIRequest(**request)
|
|
||||||
|
|
||||||
return request
|
return request
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,8 @@ class OpenAINormalizer(FormatNormalizer):
|
|||||||
result["stop"] = list(internal.stop_sequences)
|
result["stop"] = list(internal.stop_sequences)
|
||||||
if internal.stream:
|
if internal.stream:
|
||||||
result["stream"] = True
|
result["stream"] = True
|
||||||
|
# 启用流式响应的 usage 统计(OpenAI 默认不返回流式 usage)
|
||||||
|
result["stream_options"] = {"include_usage": True}
|
||||||
|
|
||||||
if internal.tools:
|
if internal.tools:
|
||||||
result["tools"] = [
|
result["tools"] = [
|
||||||
|
|||||||
Reference in New Issue
Block a user