mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10: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", "")
|
||||
|
||||
# 调用格式特定的处理逻辑
|
||||
# 注意:跨格式转换时,_process_event_data 会自动选择正确的 Provider 解析器
|
||||
self._process_event_data(ctx, event_type, data)
|
||||
|
||||
def _process_event_data(
|
||||
@@ -1505,7 +1506,27 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
# 使用解析器提取 usage
|
||||
# Claude/CLI 流式响应的 usage 可能在首个 chunk 或最后一个 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:
|
||||
new_input = usage.get("input_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]):
|
||||
ctx.final_usage = usage
|
||||
|
||||
# 提取文本内容
|
||||
text = self.parser.extract_text_content(data)
|
||||
# 提取文本内容(同样使用正确的解析器)
|
||||
text = parser.extract_text_content(data)
|
||||
if text:
|
||||
ctx.append_text(text)
|
||||
|
||||
@@ -1549,6 +1570,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
当需要格式转换时,记录的是转换后的数据(客户端实际收到的格式);
|
||||
同时更新 data_count、has_completion 等统计信息。
|
||||
|
||||
重要:此方法也从转换后的事件中提取 usage 信息,作为 _process_event_data
|
||||
从原始数据提取的补充。这确保即使原始 Provider 数据中没有 usage(如 OpenAI
|
||||
未设置 stream_options),也能从转换后的格式中获取。
|
||||
|
||||
Args:
|
||||
ctx: 流上下文
|
||||
converted_events: 转换后的事件列表
|
||||
@@ -1573,6 +1598,79 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
ctx.has_completion = True
|
||||
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:
|
||||
"""
|
||||
在记录统计前从 parsed_chunks 中提取额外的元数据 - 子类可覆盖
|
||||
|
||||
@@ -66,43 +66,26 @@ class ClaudeChatHandler(ChatHandlerBase):
|
||||
|
||||
async def _convert_request(self, request: Any) -> Any:
|
||||
"""
|
||||
将请求转换为 Claude 格式
|
||||
将请求转换为 Claude 格式的 Pydantic 对象
|
||||
|
||||
注意:此方法只做类型转换(dict → Pydantic),不做跨格式转换。
|
||||
跨格式转换由 FallbackOrchestrator 在选中候选后、发送请求前执行,
|
||||
并受全局开关和端点配置控制。
|
||||
|
||||
Args:
|
||||
request: 原始请求对象
|
||||
request: 原始请求对象(应已是 Claude 格式)
|
||||
|
||||
Returns:
|
||||
ClaudeMessagesRequest 对象
|
||||
"""
|
||||
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
|
||||
|
||||
register_default_normalizers()
|
||||
|
||||
# 如果已经是 Claude 格式,直接返回
|
||||
# 如果已经是 Claude 格式 Pydantic 对象,直接返回
|
||||
if isinstance(request, ClaudeMessagesRequest):
|
||||
return request
|
||||
|
||||
# 如果是 OpenAI 格式,转换为 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)
|
||||
|
||||
# 如果是字典,根据内容判断格式
|
||||
# 如果是字典,转换为 Pydantic 对象(假设已是 Claude 格式)
|
||||
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 request
|
||||
|
||||
@@ -50,66 +50,26 @@ class GeminiChatHandler(ChatHandlerBase):
|
||||
|
||||
async def _convert_request(self, request):
|
||||
"""
|
||||
将请求转换为 Gemini 格式
|
||||
将请求转换为 Gemini 格式的 Pydantic 对象
|
||||
|
||||
支持自动转换:
|
||||
- Claude 格式 → Gemini 格式
|
||||
- OpenAI 格式 → Gemini 格式
|
||||
注意:此方法只做类型转换(dict → Pydantic),不做跨格式转换。
|
||||
跨格式转换由 FallbackOrchestrator 在选中候选后、发送请求前执行,
|
||||
并受全局开关和端点配置控制。
|
||||
|
||||
Args:
|
||||
request: 原始请求对象(可能是 Gemini/Claude/OpenAI 格式)
|
||||
request: 原始请求对象(应已是 Gemini 格式)
|
||||
|
||||
Returns:
|
||||
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.openai import OpenAIRequest
|
||||
|
||||
register_default_normalizers()
|
||||
|
||||
# 如果已经是 Gemini 格式,直接返回
|
||||
# 如果已经是 Gemini 格式 Pydantic 对象,直接返回
|
||||
if isinstance(request, GeminiRequest):
|
||||
return request
|
||||
|
||||
# 如果是 Claude 格式,转换为 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)
|
||||
|
||||
# 如果是字典,根据内容判断格式并转换
|
||||
# 如果是字典,转换为 Pydantic 对象(假设已是 Gemini 格式)
|
||||
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 request
|
||||
|
||||
@@ -65,43 +65,27 @@ class OpenAIChatHandler(ChatHandlerBase):
|
||||
|
||||
async def _convert_request(self, request):
|
||||
"""
|
||||
将请求转换为 OpenAI 格式
|
||||
将请求转换为 OpenAI 格式的 Pydantic 对象
|
||||
|
||||
注意:此方法只做类型转换(dict → Pydantic),不做跨格式转换。
|
||||
跨格式转换由 FallbackOrchestrator 在选中候选后、发送请求前执行,
|
||||
并受全局开关和端点配置控制。
|
||||
|
||||
Args:
|
||||
request: 原始请求对象
|
||||
request: 原始请求对象(应已是 OpenAI 格式)
|
||||
|
||||
Returns:
|
||||
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
|
||||
|
||||
register_default_normalizers()
|
||||
|
||||
# 如果已经是 OpenAI 格式,直接返回
|
||||
# 如果已经是 OpenAI 格式 Pydantic 对象,直接返回
|
||||
if isinstance(request, OpenAIRequest):
|
||||
return request
|
||||
|
||||
# 如果是 Claude 格式,转换为 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)
|
||||
|
||||
# 如果是字典,尝试判断格式
|
||||
# 如果是字典,转换为 Pydantic 对象(假设已是 OpenAI 格式)
|
||||
if isinstance(request, dict):
|
||||
try:
|
||||
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 OpenAIRequest(**request)
|
||||
|
||||
return request
|
||||
|
||||
|
||||
@@ -188,6 +188,8 @@ class OpenAINormalizer(FormatNormalizer):
|
||||
result["stop"] = list(internal.stop_sequences)
|
||||
if internal.stream:
|
||||
result["stream"] = True
|
||||
# 启用流式响应的 usage 统计(OpenAI 默认不返回流式 usage)
|
||||
result["stream_options"] = {"include_usage": True}
|
||||
|
||||
if internal.tools:
|
||||
result["tools"] = [
|
||||
|
||||
Reference in New Issue
Block a user