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

@@ -0,0 +1,74 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from src.services.candidate.schema import CandidateKey
from src.services.candidate.submit import SubmitOutcome
from src.services.task.context import TaskMode
from src.services.task.protocol import AttemptKind
from src.services.task.service import TaskService
@pytest.mark.asyncio
async def test_task_service_execute_async_requires_extract_external_task_id() -> None:
svc = TaskService(MagicMock())
with pytest.raises(ValueError):
await svc.execute(
task_type="video",
task_mode=TaskMode.ASYNC,
api_format="openai:video",
model_name="m",
user_api_key=MagicMock(id="u", user_id="user"),
request_func=AsyncMock(),
request_id="rid",
)
@pytest.mark.asyncio
async def test_task_service_execute_async_returns_execution_result() -> None:
db = MagicMock()
svc = TaskService(db)
candidate = SimpleNamespace(
provider=SimpleNamespace(id="p1", name="prov"),
endpoint=SimpleNamespace(id="e1"),
key=SimpleNamespace(id="k1"),
)
outcome = SubmitOutcome(
candidate=candidate,
candidate_keys=[{"index": 0, "provider_id": "p1"}],
external_task_id="task_123",
rule_lookup=None,
upstream_payload={"id": "x"},
upstream_headers={"x-test": "1"},
upstream_status_code=200,
)
svc.submit_with_failover = AsyncMock(return_value=outcome) # type: ignore[method-assign]
svc._recorder.get_candidate_keys = MagicMock( # type: ignore[attr-defined]
return_value=[
CandidateKey(candidate_index=0, retry_index=0, status="success", provider_id="p1")
]
)
result = await svc.execute(
task_type="video",
task_mode=TaskMode.ASYNC,
api_format="openai:video",
model_name="m",
user_api_key=MagicMock(id="u", user_id="user"),
request_func=AsyncMock(),
request_id="rid",
extract_external_task_id=MagicMock(),
allow_format_conversion=True,
)
assert result.success is True
assert result.attempt_result is not None
assert result.attempt_result.kind == AttemptKind.ASYNC_SUBMIT
assert result.provider_task_id == "task_123"
assert result.provider_id == "p1"
assert result.candidate_index == 0