feat: 实现跨 API 格式自动转换功能

- 新增端点级 format_acceptance_config 配置,控制是否接受跨格式请求
- 重构 EndpointFormDialog 为卡片式布局,支持内联编辑和格式转换开关
- StreamProcessor 实现流式响应的跨格式转换,支持 OpenAI/Claude/Gemini 互转
- CacheAwareScheduler 按端点格式筛选候选,同格式优先于跨格式
- 健康度/熔断按 Provider 端点格式分桶,而非客户端请求格式
- 新增 format_conversion_total 和 format_conversion_duration_seconds 指标
- 新增全局配置 format_conversion_enabled 控制总开关
- Input 组件新增 size="sm" 尺寸选项
This commit is contained in:
fawney19
2026-01-22 01:48:56 +08:00
parent 99388bfa33
commit cc5db20c58
25 changed files with 1818 additions and 547 deletions

View File

@@ -248,6 +248,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
self,
source_model: str,
provider_id: str,
api_format: Optional[str] = None,
) -> Optional[str]:
"""
获取模型映射后的实际模型名
@@ -255,6 +256,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
Args:
source_model: 用户请求的模型名
provider_id: Provider ID
api_format: Provider 侧 API 格式(用于过滤映射作用域,默认使用 handler FORMAT_ID
Returns:
映射后的 provider_model_name没有映射则返回 None
@@ -269,8 +271,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# 传入 api_key.id 作为 affinity_key实现相同用户稳定选择同一映射
# 传入 api_format 用于过滤适用的映射作用域
affinity_key = self.api_key.id if self.api_key else None
effective_format = api_format or self.FORMAT_ID
mapped_name = mapping.model.select_provider_model_name(
affinity_key, api_format=self.FORMAT_ID
affinity_key, api_format=effective_format
)
logger.debug(f"[Chat] 模型映射: {source_model} -> {mapped_name}")
return mapped_name
@@ -297,6 +300,8 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# 创建类型安全的流式上下文
ctx = StreamContext(model=model, api_format=api_format)
ctx.request_id = self.request_id
ctx.client_api_format = api_format.value if hasattr(api_format, "value") else str(api_format)
# 创建更新状态的回调闭包(可以访问 ctx
def update_streaming_status() -> None:
@@ -430,12 +435,24 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
provider_api_format=str(endpoint.api_format) if endpoint.api_format else None,
)
# ctx.api_format 是枚举,需要取 value 作为字符串
_api_format_str = (
ctx.api_format.value if hasattr(ctx.api_format, "value") else str(ctx.api_format)
)
provider_api_format = ctx.provider_api_format or _api_format_str
client_api_format = ctx.client_api_format or _api_format_str
needs_conversion = (
bool(getattr(candidate, "needs_conversion", False)) if candidate else False
)
ctx.needs_conversion = needs_conversion
# 获取模型映射(优先使用映射匹配到的模型,其次是 Provider 级别的映射)
mapped_model = candidate.mapping_matched_model if candidate else None
if not mapped_model:
mapped_model = await self._get_mapped_model(
source_model=ctx.model,
provider_id=str(provider.id),
api_format=provider_api_format,
)
# 应用模型映射到请求体
@@ -445,8 +462,18 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
else:
request_body = dict(original_request_body)
# 准备发送给 Provider 的请求体
request_body = self.prepare_provider_request_body(request_body)
# 跨格式:先做请求体转换(严格模式,失败触发 failover
if needs_conversion:
from src.core.api_format import converter_registry
request_body = converter_registry.convert_request_strict(
request_body,
str(client_api_format),
str(provider_api_format),
)
else:
# 同格式:按原逻辑做轻量清理(子类可覆盖以移除不需要的字段)
request_body = self.prepare_provider_request_body(request_body)
# 构建请求
provider_payload, provider_headers = self._request_builder.build(
@@ -663,6 +690,12 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
nonlocal provider_request_headers, provider_request_body, mapped_model_result
provider_name = str(provider.name)
provider_api_format = str(endpoint.api_format or api_format)
# 客户端格式(与流式处理保持一致的命名)
client_api_format = (
api_format.value if hasattr(api_format, "value") else str(api_format)
)
needs_conversion = bool(getattr(candidate, "needs_conversion", False))
# 获取模型映射(优先使用映射匹配到的模型,其次是 Provider 级别的映射)
mapped_model = candidate.mapping_matched_model if candidate else None
@@ -670,6 +703,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
mapped_model = await self._get_mapped_model(
source_model=model,
provider_id=str(provider.id),
api_format=provider_api_format,
)
# 应用模型映射
@@ -679,8 +713,18 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
else:
request_body = dict(original_request_body)
# 准备发送给 Provider 的请求体(子类可覆盖以移除不需要的字段
request_body = self.prepare_provider_request_body(request_body)
# 跨格式:先做请求体转换(严格模式,失败触发 failover
if needs_conversion:
from src.core.api_format import converter_registry
request_body = converter_registry.convert_request_strict(
request_body,
client_api_format,
provider_api_format,
)
else:
# 同格式:按原逻辑做轻量清理(子类可覆盖以移除不需要的字段)
request_body = self.prepare_provider_request_body(request_body)
# 构建请求
provider_payload, provider_hdrs = self._request_builder.build(
@@ -789,9 +833,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
raw_content = repr(resp.content[:500]) if resp.content else "(empty)"
except Exception:
raw_content = "(unable to read)"
logger.error(
f"[{self.request_id}] 无法解析响应 JSON: {e}, 原始内容: {raw_content}"
)
logger.error(f"[{self.request_id}] 无法解析响应 JSON: {e}, 原始内容: {raw_content}")
# 判断错误类型,生成友好的客户端错误消息(不暴露提供商信息)
if raw_content == "(empty)" or not raw_content.strip():
client_message = "上游服务返回了空响应"
@@ -808,7 +850,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# 检查响应体中的嵌套错误HTTP 200 但响应体包含错误)
if isinstance(response_json, dict):
parser = get_parser_for_format(api_format)
parser = get_parser_for_format(provider_api_format)
if parser.is_error_response(response_json):
parsed = parser.parse_response(response_json, 200)
logger.warning(
@@ -825,6 +867,16 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
error_status=parsed.error_type,
)
# 跨格式:响应转换回 client_format严格模式失败触发 failover
if needs_conversion and isinstance(response_json, dict):
from src.core.api_format import converter_registry
response_json = converter_registry.convert_response_strict(
response_json,
provider_api_format,
str(api_format),
)
return response_json if isinstance(response_json, dict) else {}
try:

View File

@@ -76,6 +76,7 @@ class StreamContext:
# 格式转换信息CLI handler 需要)
client_api_format: str = ""
needs_conversion: bool = False # 是否需要跨格式转换(由 handler 层设置)
# Provider 响应元数据CLI handler 需要)
response_metadata: Dict[str, Any] = field(default_factory=dict)
@@ -118,6 +119,7 @@ class StreamContext:
self.final_usage = None
self.final_response = None
self.stream_conversion_state = None
self.needs_conversion = False
@property
def collected_text(self) -> str:

View File

@@ -328,14 +328,12 @@ class StreamProcessor:
raise
except (OSError, IOError) as e:
# 网络 I/O 异常:记录警告,可能需要重试
logger.warning(
f" [{self.request_id}] 预读流时发生网络异常: {type(e).__name__}: {e}"
)
logger.warning(f" [{self.request_id}] 预读流时发生网络异常: {type(e).__name__}: {e}")
except Exception as e:
# 未预期的严重异常:记录错误并重新抛出,避免掩盖问题
logger.error(
f" [{self.request_id}] 预读流时发生严重异常: {type(e).__name__}: {e}",
exc_info=True
exc_info=True,
)
raise
@@ -374,19 +372,269 @@ class StreamProcessor:
# 使用增量解码器处理跨 chunk 的 UTF-8 字符
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
# 处理预读数据
if prefetched_chunks:
for chunk in prefetched_chunks:
# 记录首字时间 (TTFB) - 在 yield 之前记录
if start_time is not None:
ctx.record_first_byte_time(start_time)
start_time = None # 只记录一次
# 首次输出前触发 streaming 回调(确保 TTFB 已写入 ctx
if not streaming_started and self.on_streaming_start:
self.on_streaming_start()
streaming_started = True
# ctx.api_format 可能是 APIFormat 枚举,需要取 value
_api_format_str = (
ctx.api_format.value
if hasattr(ctx.api_format, "value")
else str(ctx.api_format or "")
)
client_format = (ctx.client_api_format or _api_format_str).upper()
provider_format = (ctx.provider_api_format or _api_format_str).upper()
# 使用 handler 层预计算的 needs_conversion由 candidate 决定)
needs_conversion = ctx.needs_conversion
# 把原始数据转发给客户端
# 安全检查needs_conversion 为 True 时provider_format 必须有值
if needs_conversion and not provider_format:
logger.warning(
f"[{self.request_id}] needs_conversion=True 但 provider_format 为空,回退到透传模式"
)
needs_conversion = False
def _mark_stream_started() -> None:
nonlocal start_time, streaming_started
# 记录首字时间 (TTFB) - 在 yield 之前记录
if start_time is not None:
ctx.record_first_byte_time(start_time)
start_time = None # 只记录一次
# 首次输出前触发 streaming 回调(确保 TTFB 已写入 ctx
if not streaming_started and self.on_streaming_start:
self.on_streaming_start()
streaming_started = True
def _build_stream_error_payload(message: str) -> dict:
if client_format.startswith("OPENAI"):
return {
"error": {
"message": message,
"type": "format_conversion_error",
}
}
# Claude 及其他格式使用统一的错误结构
return {
"type": "error",
"error": {
"type": "format_conversion_error",
"message": message,
},
}
# 处理预读数据
if needs_conversion:
# 延迟导入:仅在需要转换时加载转换器模块
from src.core.api_format import (
GeminiStreamConversionState,
StreamConversionState,
converter_registry,
)
# 初始化流式转换状态(首次使用时,根据 Provider 格式选择状态类)
if ctx.stream_conversion_state is None:
if provider_format == "GEMINI":
ctx.stream_conversion_state = GeminiStreamConversionState(
model=ctx.mapped_model or ctx.model or "",
message_id=ctx.response_id or ctx.request_id or "",
)
else:
ctx.stream_conversion_state = StreamConversionState(
model=ctx.mapped_model or ctx.model or "",
message_id=ctx.response_id or ctx.request_id or "",
)
skip_next_blank_line = False
empty_yield_count = 0 # 空转计数(防护异常情况)
def _emit_converted_line(normalized_line: str) -> list[bytes]:
nonlocal skip_next_blank_line
# 空行:事件分隔符(避免重复输出)
if normalized_line == "":
if skip_next_blank_line:
skip_next_blank_line = False
return []
return [b"\n"]
# 丢弃 Provider 的 event 行,避免泄漏/污染目标格式
if normalized_line.startswith("event:"):
return []
# OpenAI done 信号
if (
normalized_line.startswith("data:")
and normalized_line[5:].strip() == "[DONE]"
):
skip_next_blank_line = True
if client_format.startswith("OPENAI"):
return [b"data: [DONE]\n\n"]
return []
# 非 data 行:在跨格式场景下统一丢弃(避免泄露 Provider 格式细节)
if not normalized_line.startswith("data:"):
return []
data_content = normalized_line[5:].strip()
# Gemini 可能包含 JSON 数组包装符,直接忽略
if data_content in ("", "[", "]", ","):
return []
try:
data_obj = json.loads(data_content)
except json.JSONDecodeError:
# 跨格式转换时JSON 解析失败应跳过而不是透传(避免泄漏 Provider 格式)
logger.warning(
f"[{self.request_id}] JSON 解析失败,跳过该行: {data_content[:100]}"
)
return []
if not isinstance(data_obj, dict):
return []
try:
converted_events = converter_registry.convert_stream_chunk_strict(
data_obj,
provider_format,
client_format,
state=ctx.stream_conversion_state,
)
except Exception as conv_err:
# 首字节后无法 failover输出目标格式错误事件并终止流
# 使用 502 表示上游返回了非预期格式Bad Gateway
ctx.status_code = 502
ctx.error_message = "format_conversion_failed"
# 日志记录完整错误(内部排查),客户端只返回脱敏消息
logger.warning(f"[{self.request_id}] 流式格式转换失败: {conv_err}")
payload = _build_stream_error_payload("响应格式转换失败,请稍后重试")
error_bytes = f"data: {json.dumps(payload, ensure_ascii=False)}\n\n".encode(
"utf-8"
)
done_bytes = (
b"data: [DONE]\n\n" if client_format.startswith("OPENAI") else b""
)
return [error_bytes, done_bytes]
skip_next_blank_line = True
out: list[bytes] = []
for evt in converted_events:
out.append(
f"data: {json.dumps(evt, ensure_ascii=False)}\n\n".encode("utf-8")
)
return out
# 统一处理 prefetched + iterator
if prefetched_chunks:
for chunk in prefetched_chunks:
buffer += chunk
while b"\n" in buffer:
line_bytes, buffer = buffer.split(b"\n", 1)
try:
line = decoder.decode(line_bytes + b"\n", False)
except Exception as e:
logger.warning(
f"[{self.request_id}] UTF-8 解码失败: {e}, bytes={line_bytes[:50]!r}"
)
line = ""
if line:
self._process_line(ctx, sse_parser, line)
normalized_line = line.rstrip("\r\n") if line else ""
out_chunks = _emit_converted_line(normalized_line)
if not out_chunks:
empty_yield_count += 1
if empty_yield_count == StreamDefaults.MAX_EMPTY_YIELDS_WARNING:
logger.warning(
f"[{self.request_id}] 流式转换连续 {empty_yield_count} 次空产出"
)
else:
empty_yield_count = 0
for out in out_chunks:
if not out:
continue
_mark_stream_started()
yield out
# 转换失败:已输出 error可能还包含 done直接终止
if ctx.error_message == "format_conversion_failed":
return
async for chunk in byte_iterator:
buffer += chunk
while b"\n" in buffer:
line_bytes, buffer = buffer.split(b"\n", 1)
try:
line = decoder.decode(line_bytes + b"\n", False)
except Exception as e:
logger.warning(
f"[{self.request_id}] UTF-8 解码失败: {e}, bytes={line_bytes[:50]!r}"
)
line = ""
if line:
self._process_line(ctx, sse_parser, line)
normalized_line = line.rstrip("\r\n") if line else ""
out_chunks = _emit_converted_line(normalized_line)
if not out_chunks:
empty_yield_count += 1
if empty_yield_count == StreamDefaults.MAX_EMPTY_YIELDS_WARNING:
logger.warning(
f"[{self.request_id}] 流式转换连续 {empty_yield_count} 次空产出"
)
else:
empty_yield_count = 0
for out in out_chunks:
if not out:
continue
_mark_stream_started()
yield out
if ctx.error_message == "format_conversion_failed":
return
# 处理剩余缓冲区needs_conversion 分支内,可复用 _emit_converted_line
if buffer:
try:
line = decoder.decode(buffer, True)
except Exception as e:
logger.warning(
f"[{self.request_id}] 处理剩余缓冲区失败: {e}, bytes={buffer[:50]!r}"
)
line = ""
if line:
self._process_line(ctx, sse_parser, line)
normalized_line = line.rstrip("\r\n")
out_chunks = _emit_converted_line(normalized_line)
for out in out_chunks:
if out:
_mark_stream_started()
yield out
# 转换失败:已输出 error直接终止
if ctx.error_message == "format_conversion_failed":
return
else:
if prefetched_chunks:
for chunk in prefetched_chunks:
_mark_stream_started()
yield chunk
buffer += chunk
# 处理缓冲区中的完整行
while b"\n" in buffer:
line_bytes, buffer = buffer.split(b"\n", 1)
try:
# 使用增量解码器,可以正确处理跨 chunk 的多字节字符
line = decoder.decode(line_bytes + b"\n", False)
self._process_line(ctx, sse_parser, line)
except Exception as e:
# 解码失败,记录警告但继续处理
logger.warning(
f"[{self.request_id}] UTF-8 解码失败: {e}, "
f"bytes={line_bytes[:50]!r}"
)
continue
# 处理剩余的流数据
if not needs_conversion:
async for chunk in byte_iterator:
_mark_stream_started()
# 原始数据透传
yield chunk
buffer += chunk
@@ -405,46 +653,15 @@ class StreamProcessor:
)
continue
# 处理剩余的流数据
async for chunk in byte_iterator:
# 记录首字时间 (TTFB) - 在 yield 之前记录(如果预读数据为空)
if start_time is not None:
ctx.record_first_byte_time(start_time)
start_time = None # 只记录一次
# 首次输出前触发 streaming 回调(确保 TTFB 已写入 ctx
if not streaming_started and self.on_streaming_start:
self.on_streaming_start()
streaming_started = True
# 原始数据透传
yield chunk
buffer += chunk
# 处理缓冲区中的完整行
while b"\n" in buffer:
line_bytes, buffer = buffer.split(b"\n", 1)
try:
# 使用增量解码器,可以正确处理跨 chunk 的多字节字符
line = decoder.decode(line_bytes + b"\n", False)
self._process_line(ctx, sse_parser, line)
except Exception as e:
# 解码失败,记录警告但继续处理
logger.warning(
f"[{self.request_id}] UTF-8 解码失败: {e}, "
f"bytes={line_bytes[:50]!r}"
)
continue
# 处理剩余的缓冲区数据(如果有未完成的行)
if buffer:
# 处理剩余的缓冲区数据(仅非转换分支,转换分支已在内部处理)
if not needs_conversion and buffer:
try:
# 使用 final=True 处理最后的不完整字符
line = decoder.decode(buffer, True)
self._process_line(ctx, sse_parser, line)
except Exception as e:
logger.warning(
f"[{self.request_id}] 处理剩余缓冲区失败: {e}, "
f"bytes={buffer[:50]!r}"
f"[{self.request_id}] 处理剩余缓冲区失败: {e}, bytes={buffer[:50]!r}"
)
# 处理剩余事件