mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
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:
@@ -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:
|
||||
|
||||
@@ -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 {},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user