refactor: 统一任务框架 Phase 3 - 用 TaskService/FailoverEngine 替代 FallbackOrchestrator

核心重构:
- 移除 FallbackOrchestrator,用 TaskService + FailoverEngine 替代
- TaskService 作为统一入口,支持 SYNC/ASYNC 两种任务模式
- FailoverEngine 实现候选遍历、重试、故障转移逻辑
- 新增 AttemptFunc/AttemptResult 协议,统一尝试结果表示

功能改进:
- 流式响应首字节探测(30s 超时,空流触发故障转移)
- 流式取消归因优化(区分客户端断连 vs 服务端中断)
- 新增 OpenAI Sora 视频取消路由 POST /v1/videos/{task_id}/cancel
- OpenAI 流式请求自动添加 stream_options.include_usage

代码规范:
- 修复 loguru 日志格式(%s → {})
- 新增 FORMAT_CONVERSION_ENABLED 环境变量说明

测试覆盖:
- test_failover_engine.py: FailoverEngine 单元测试
- test_task_service_async_execute.py: TaskService ASYNC 模式测试
- test_video_cancel_e2e.py: 视频取消端到端测试
This commit is contained in:
fawney19
2026-02-02 21:16:28 +08:00
parent ebe1a8d3e3
commit ed68aebfb0
53 changed files with 3966 additions and 2256 deletions

View File

@@ -50,7 +50,7 @@ def is_format_compatible(
endpoint_api_format: 端点的 API 格式
endpoint_format_acceptance_config: 端点的格式接受配置
is_stream: 是否是流式请求
effective_conversion_enabled: 有效格式转换开关(全局 OR 提供商
effective_conversion_enabled: 格式转换开关(通常来自环境变量/Feature Flag
registry: 转换器注册表(可选,默认使用全局单例)
skip_endpoint_check: 是否跳过端点配置检查(当全局或提供商开关为 ON 时设为 True
@@ -79,9 +79,9 @@ def is_format_compatible(
return True, False, None
# 2. 格式不同 -> 需要检查格式转换开关
# 如果有效开关为 False(全局 OFF 且提供商 OFF,直接拒绝
# 如果开关为 False直接拒绝(禁用任何跨格式转换)
if not effective_conversion_enabled:
return False, False, "格式转换已禁用(全局和提供商开关均为关闭"
return False, False, "格式转换已禁用(FORMAT_CONVERSION_ENABLED=false"
# 3. 如果全局或提供商开关为 ON跳过端点配置检查
if not skip_endpoint_check:

View File

@@ -393,6 +393,14 @@ class OpenAINormalizer(FormatNormalizer):
choices = chunk.get("choices") or []
if not choices or not isinstance(choices, list):
# OpenAI streaming may send a final "usage-only" chunk when
# stream_options.include_usage=true, where `choices` is empty but `usage` exists.
usage_info = self._openai_usage_to_internal(chunk.get("usage"))
if usage_info is not None and (usage_info.total_tokens or usage_info.input_tokens or usage_info.output_tokens):
# For cross-format targets (e.g. Gemini), emitting usage as a late MessageStopEvent
# allows the target normalizer to surface usage metadata even if the stop chunk
# didn't carry it.
events.append(MessageStopEvent(stop_reason=None, usage=usage_info))
return events
c0 = choices[0] if choices else {}
@@ -1136,6 +1144,12 @@ class OpenAINormalizer(FormatNormalizer):
total_tokens = usage.get("total_tokens")
if total_tokens is None:
total_tokens = input_tokens + output_tokens
total_tokens_int = int(total_tokens)
# Some OpenAI-compatible providers may include a placeholder `usage` object with 0s
# (or omit fields); treat it as "missing usage" to avoid emitting misleading usageMetadata.
if input_tokens == 0 and output_tokens == 0 and total_tokens_int == 0:
return None
extra = self._extract_extra(
usage,
@@ -1144,7 +1158,7 @@ class OpenAINormalizer(FormatNormalizer):
return UsageInfo(
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=int(total_tokens),
total_tokens=total_tokens_int,
extra={"openai": extra} if extra else {},
)