fix: 修复跨格式转换时 usage 提取不准确的问题

- CLI Handler: 跨格式转换时使用 Provider 格式的解析器提取原始 usage
- CLI Handler: 新增从转换后事件中提取 usage 的补充方法
- Chat Handler: 移除不必要的格式自动检测转换代码(跨格式转换由 CLI Handler 统一处理)
- OpenAI Normalizer: 流式请求自动添加 stream_options 以获取 usage 统计
This commit is contained in:
fawney19
2026-01-27 12:39:36 +08:00
parent add477fece
commit 6f340b7b91
5 changed files with 127 additions and 100 deletions

View File

@@ -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 中提取额外的元数据 - 子类可覆盖

View File

@@ -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

View File

@@ -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_tokensOpenAI 也可能有
# 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

View File

@@ -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

View File

@@ -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"] = [