feat: 多项功能优化与问题修复

- 添加高频轮询端点日志抑制,减少 debug 日志噪音
- Gemini 格式转换支持 responseModalities、thinkingConfig 透传和图片生成输出
- OpenAI 格式转换支持流式图片内容块,区分 URL 引用和 base64 内嵌图片
- Provider 余额缓存认证失败时使用短 TTL,避免前端无限加载中
- Usage 服务支持更新 pending/streaming 记录,处理重复 request_id 冲突
- Usage 超时检测增强,避免错误标记已完成请求为超时
This commit is contained in:
fawney19
2026-01-28 23:07:58 +08:00
parent 91986baa57
commit 6b34a7e3f5
9 changed files with 540 additions and 57 deletions

View File

@@ -151,6 +151,22 @@ class GeminiNormalizer(FormatNormalizer):
else request.get("toolConfig")
)
# 构建 extra保留原始 gemini 字段
extra: Dict[str, Any] = {"gemini": self._extract_extra(request, {"contents"})}
# 保留 generationConfig 中的特殊字段responseModalities, thinkingConfig 等)
# 这些字段在 _get_generation_config 中已提取,需要单独存储以便转换时使用
if isinstance(generation_config, dict):
response_modalities = generation_config.get("response_modalities")
thinking_config = generation_config.get("thinking_config")
if response_modalities or thinking_config:
google_extra: Dict[str, Any] = {}
if response_modalities:
google_extra["response_modalities"] = response_modalities
if thinking_config:
google_extra["thinking_config"] = thinking_config
extra["google"] = google_extra
internal = InternalRequest(
model=model,
messages=messages,
@@ -164,7 +180,7 @@ class GeminiNormalizer(FormatNormalizer):
stream=bool(request.get("stream") or False),
tools=tools,
tool_choice=tool_choice,
extra={"gemini": self._extract_extra(request, {"contents"})},
extra=extra,
)
if dropped:
@@ -208,6 +224,47 @@ class GeminiNormalizer(FormatNormalizer):
if internal.stop_sequences:
generation_config["stop_sequences"] = list(internal.stop_sequences)
# 从 internal.extra["google"] 读取 OpenAI extra_body.google 透传的配置
google_extra = internal.extra.get("google", {})
if isinstance(google_extra, dict):
# 处理 thinking_config -> thinkingConfig
thinking_config = google_extra.get("thinking_config")
if isinstance(thinking_config, dict):
# snake_case -> camelCase 转换
gemini_thinking: Dict[str, Any] = {}
if "thinking_budget" in thinking_config:
gemini_thinking["thinkingBudget"] = thinking_config["thinking_budget"]
if "include_thoughts" in thinking_config:
gemini_thinking["includeThoughts"] = thinking_config["include_thoughts"]
# 保留其他可能的字段
for k, v in thinking_config.items():
if k not in ("thinking_budget", "include_thoughts"):
gemini_thinking[k] = v
if gemini_thinking:
generation_config["thinkingConfig"] = gemini_thinking
# 处理 response_modalities -> responseModalities
response_modalities = google_extra.get("response_modalities")
if response_modalities:
generation_config["responseModalities"] = response_modalities
# 从 internal.extra["gemini"] 读取原生 Gemini 配置Gemini -> Gemini 场景)
gemini_extra = internal.extra.get("gemini", {})
if isinstance(gemini_extra, dict):
# 保留原生 Gemini generationConfig 中的额外字段
orig_gc = gemini_extra.get("generation_config") or gemini_extra.get("generationConfig")
if isinstance(orig_gc, dict):
# responseModalities
if "responseModalities" in orig_gc and "responseModalities" not in generation_config:
generation_config["responseModalities"] = orig_gc["responseModalities"]
if "response_modalities" in orig_gc and "responseModalities" not in generation_config:
generation_config["responseModalities"] = orig_gc["response_modalities"]
# thinkingConfig
if "thinkingConfig" in orig_gc and "thinkingConfig" not in generation_config:
generation_config["thinkingConfig"] = orig_gc["thinkingConfig"]
if "thinking_config" in orig_gc and "thinkingConfig" not in generation_config:
generation_config["thinkingConfig"] = orig_gc["thinking_config"]
contents: List[Dict[str, Any]] = []
for msg in internal.messages:
contents.append(self._internal_message_to_content(msg))
@@ -431,6 +488,35 @@ class GeminiNormalizer(FormatNormalizer):
events.append(ContentBlockStopEvent(block_index=block_index))
continue
# inlineData图片生成等多模态输出
inline_data = part.get("inlineData")
if inline_data is None:
inline_data = part.get("inline_data")
if isinstance(inline_data, dict):
mime_type = str(inline_data.get("mimeType") or inline_data.get("mime_type") or "").strip()
data = str(inline_data.get("data") or "").strip()
# 确保 mime_type 和 data 都非空
if mime_type and data and len(data) > 10: # base64 图片数据至少几十个字符
block_index = int(ss.get("next_block_index") or 1)
ss["next_block_index"] = block_index + 1
# 使用 ContentBlockStartEvent 传递图片数据
# 图片数据存储在 extra 中,供 target normalizer 处理
events.append(
ContentBlockStartEvent(
block_index=block_index,
block_type=ContentType.IMAGE,
extra={
"image_data": data,
"image_media_type": mime_type,
},
)
)
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)
@@ -488,6 +574,25 @@ class GeminiNormalizer(FormatNormalizer):
}
return out
# 图片内容块(来自其他格式的图像生成输出)
if isinstance(event, ContentBlockStartEvent) and event.block_type == ContentType.IMAGE:
image_data = event.extra.get("image_data")
image_media_type = event.extra.get("image_media_type")
if image_data and image_media_type:
out.append(
base_chunk(
[
{
"inlineData": {
"mimeType": image_media_type,
"data": image_data,
}
}
]
)
)
return out
if isinstance(event, ToolCallDeltaEvent):
tool_blocks = ss.get("tool_blocks")
if isinstance(tool_blocks, dict):
@@ -784,6 +889,17 @@ class GeminiNormalizer(FormatNormalizer):
normalized["top_p"] = pick("top_p", "topP")
normalized["top_k"] = pick("top_k", "topK")
normalized["stop_sequences"] = pick("stop_sequences", "stopSequences")
# 保留 responseModalities图像生成等多模态输出必需
response_modalities = pick("response_modalities", "responseModalities")
if response_modalities:
normalized["response_modalities"] = response_modalities
# 保留 thinkingConfig思考模式配置
thinking_config = pick("thinking_config", "thinkingConfig")
if thinking_config:
normalized["thinking_config"] = thinking_config
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]]:

View File

@@ -13,6 +13,7 @@ import json
import time
from typing import Any, Dict, List, Optional, Tuple, Union
from src.core.logger import logger
from src.core.api_format.conversion.field_mappings import (
ERROR_TYPE_MAPPINGS,
RETRYABLE_ERROR_TYPES,
@@ -144,6 +145,16 @@ class OpenAINormalizer(FormatNormalizer):
mct if mct is not None else request.get("max_tokens")
)
# 构建 extra保留未识别字段
extra: Dict[str, Any] = {"openai": self._extract_extra(request, {"messages"})}
# 处理 extra_body.google (用于 Gemini 特定功能透传,如 thinkingConfig, responseModalities)
extra_body = request.get("extra_body")
if isinstance(extra_body, dict):
google_extra = extra_body.get("google")
if isinstance(google_extra, dict) and google_extra:
extra["google"] = google_extra
internal = InternalRequest(
model=model,
messages=messages,
@@ -156,7 +167,7 @@ class OpenAINormalizer(FormatNormalizer):
stream=bool(request.get("stream") or False),
tools=tools,
tool_choice=tool_choice,
extra={"openai": self._extract_extra(request, {"messages"})},
extra=extra,
)
if dropped:
@@ -470,6 +481,50 @@ class OpenAINormalizer(FormatNormalizer):
)
return out
# 图片内容块(来自 Gemini 图像生成等多模态输出)
if isinstance(event, ContentBlockStartEvent) and event.block_type == ContentType.IMAGE:
image_data = event.extra.get("image_data")
image_media_type = event.extra.get("image_media_type")
# 确保图片数据有效base64 数据至少有一定长度)
if image_data and image_media_type and isinstance(image_data, str) and len(image_data) > 10:
# 构造 data URL 格式的图片
data_url = f"data:{image_media_type};base64,{image_data}"
# 存储图片数据,在 ContentBlockStopEvent 时输出
image_blocks = ss.get("image_blocks")
if not isinstance(image_blocks, dict):
image_blocks = {}
ss["image_blocks"] = image_blocks
image_blocks[int(event.block_index)] = {
"url": data_url,
"media_type": image_media_type,
}
return out
# 图片内容块结束时输出
if isinstance(event, ContentBlockStopEvent):
image_blocks = ss.get("image_blocks")
if isinstance(image_blocks, dict):
entry = image_blocks.get(int(event.block_index))
if isinstance(entry, dict):
url = entry.get("url")
# 确保 URL 有效data URL 至少包含 "data:" 前缀 + 一些数据)
if url and isinstance(url, str) and len(url) > 20:
# OpenAI 流式响应中 delta.content 必须是字符串
# 使用 markdown 图片格式,兼容各种客户端渲染
# 注意base64 data URL 可能很长几百KB~几MB客户端需支持长内容
# 典型图片大小100KB 原图 ≈ 130KB base641MB 原图 ≈ 1.3MB base64
if len(url) > 1_000_000: # > 1MB
logger.warning(
f"Large image in stream response: {len(url)} bytes, "
"client may have rendering issues"
)
out.append(base_chunk({"content": f"![image]({url})"}))
# 清理已处理的图片
del image_blocks[int(event.block_index)]
return out
# 其他 ContentBlockStopEvent 不处理
return out
if isinstance(event, ToolCallDeltaEvent):
tool_index = self._ensure_tool_call_index(ss, event.tool_id)
out.append(
@@ -854,20 +909,30 @@ class OpenAINormalizer(FormatNormalizer):
text_parts.append(b.text)
continue
if isinstance(b, ImageBlock):
url = b.url
if not url and b.data and b.media_type:
url = f"data:{b.media_type};base64,{b.data}"
if url:
parts.append({"type": "image_url", "image_url": {"url": url}})
# 区分两种图片来源:
# 1. URL 引用OpenAI 原生格式)-> multipart content
# 2. base64 内嵌数据(格式转换来的)-> markdown 格式
if b.url and not b.data:
# OpenAI 原生格式URL 引用的图片,使用 multipart content
parts.append({"type": "image_url", "image_url": {"url": b.url}})
elif b.data and b.media_type:
# 格式转换来的图片base64 内嵌),使用 markdown 格式
data_url = f"data:{b.media_type};base64,{b.data}"
text_parts.append(f"![image]({data_url})")
elif b.url:
# 有 URL 也有 data优先使用 URL
parts.append({"type": "image_url", "image_url": {"url": b.url}})
continue
# Unknown / Tool blocks 不进入 OpenAI content
# 如果有 OpenAI 原生格式的图片URL 引用),使用 multipart content
if parts:
if text_parts:
parts = [{"type": "text", "text": "\n".join(text_parts)}] + parts
return parts
# 纯文本或包含 markdown 图片
if text_parts:
return "\n".join(text_parts)