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

@@ -270,7 +270,7 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
update_data["rpm_limit"] = self.key_data.rpm_limit
if self.key_data.rpm_limit is None:
update_data["learned_rpm_limit"] = None
logger.info("Key %s 切换为自适应 RPM 模式", self.key_id)
logger.info("Key {} 切换为自适应 RPM 模式", self.key_id)
# 统一处理 allowed_models空列表 -> None表示不限制
if "allowed_models" in update_data:
@@ -305,7 +305,7 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
# 处理 auto_fetch_models 的开启和关闭
if not auto_fetch_enabled_before and auto_fetch_enabled_after:
# 刚刚开启了 auto_fetch_models同步执行模型获取
logger.info("[AUTO_FETCH] Key %s 开启自动获取模型,同步执行模型获取", self.key_id)
logger.info("[AUTO_FETCH] Key {} 开启自动获取模型,同步执行模型获取", self.key_id)
try:
from src.services.model.fetch_scheduler import get_model_fetch_scheduler
@@ -321,14 +321,14 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
if locked:
key.allowed_models = locked
logger.info(
"[AUTO_FETCH] Key %s 关闭自动获取模型,保留 %d 个锁定模型",
"[AUTO_FETCH] Key {} 关闭自动获取模型,保留 {} 个锁定模型",
self.key_id,
len(locked),
)
else:
key.allowed_models = None
logger.info(
"[AUTO_FETCH] Key %s 关闭自动获取模型,无锁定模型,清空 allowed_models",
"[AUTO_FETCH] Key {} 关闭自动获取模型,无锁定模型,清空 allowed_models",
self.key_id,
)
db.commit()
@@ -344,7 +344,7 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
if patterns_changed:
# 过滤规则变更,重新应用过滤(使用缓存的上游模型数据)
logger.info(
"[AUTO_FETCH] Key %s 过滤规则变更,重新应用过滤",
"[AUTO_FETCH] Key {} 过滤规则变更,重新应用过滤",
self.key_id,
)
try:
@@ -373,7 +373,7 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
# allowed_models 未变化时,仍需清除 /v1/models 缓存is_active、api_formats 变更会影响模型可用性)
await invalidate_models_list_cache()
logger.info("[OK] 更新 Key: ID=%s, Updates=%s", self.key_id, list(update_data.keys()))
logger.info("[OK] 更新 Key: ID={}, Updates={}", self.key_id, list(update_data.keys()))
return _build_key_response(key)
@@ -794,7 +794,7 @@ class AdminCreateProviderKeyAdapter(AdminApiAdapter):
# 如果开启了 auto_fetch_models同步执行模型获取
if self.key_data.auto_fetch_models:
logger.info("[AUTO_FETCH] 新 Key %s 开启自动获取模型,同步执行模型获取", new_key.id)
logger.info("[AUTO_FETCH] 新 Key {} 开启自动获取模型,同步执行模型获取", new_key.id)
try:
from src.services.model.fetch_scheduler import get_model_fetch_scheduler

View File

@@ -58,9 +58,9 @@ class ApiRequestPipeline:
# 高频轮询端点抑制 debug 日志
is_quiet = http_request.url.path in QUIET_POLLING_PATHS
if not is_quiet:
logger.debug("[Pipeline] START | path=%s", http_request.url.path)
logger.debug("[Pipeline] START | path={}", http_request.url.path)
logger.debug(
"[Pipeline] Running with mode=%s, adapter=%s, adapter.mode=%s, path=%s",
"[Pipeline] Running with mode={}, adapter={}, adapter.mode={}, path={}",
mode,
adapter.__class__.__name__,
adapter.mode,
@@ -85,7 +85,7 @@ class ApiRequestPipeline:
user, api_key = self._authenticate_client(http_request, db, adapter, quiet=is_quiet)
management_token = None
if not is_quiet:
logger.debug("[Pipeline] 认证完成 | user=%s", user.username if user else None)
logger.debug("[Pipeline] 认证完成 | user={}", user.username if user else None)
raw_body = None
if http_request.method in {"POST", "PUT", "PATCH"}:
@@ -98,7 +98,7 @@ class ApiRequestPipeline:
)
if not is_quiet:
logger.debug(
"[Pipeline] Raw body读取完成 | size=%d bytes",
"[Pipeline] Raw body读取完成 | size={} bytes",
len(raw_body) if raw_body is not None else 0,
)
except TimeoutError:
@@ -110,7 +110,7 @@ class ApiRequestPipeline:
)
else:
if not is_quiet:
logger.debug("[Pipeline] 非写请求跳过读取Body | method=%s", http_request.method)
logger.debug("[Pipeline] 非写请求跳过读取Body | method={}", http_request.method)
context = ApiRequestContext.build(
request=http_request,
@@ -129,7 +129,7 @@ class ApiRequestPipeline:
context.quiet_logging = is_quiet
if not is_quiet:
logger.debug(
"[Pipeline] Context构建完成 | adapter=%s | request_id=%s",
"[Pipeline] Context构建完成 | adapter={} | request_id={}",
adapter.name,
context.request_id,
)
@@ -138,9 +138,9 @@ class ApiRequestPipeline:
context.quota_remaining = self._calculate_quota_remaining(user)
if not is_quiet:
logger.debug("[Pipeline] Adapter=%s | RequestID=%s", adapter.name, context.request_id)
logger.debug("[Pipeline] Adapter={} | RequestID={}", adapter.name, context.request_id)
logger.debug(
"[Pipeline] Calling authorize on %s, user=%s",
"[Pipeline] Calling authorize on {}, user={}",
adapter.__class__.__name__,
context.user,
)
@@ -187,7 +187,7 @@ class ApiRequestPipeline:
client_api_key = adapter.extract_api_key(request)
if not quiet:
logger.debug(
"[Pipeline._authenticate_client] 提取API密钥完成 | key_prefix=%s...",
"[Pipeline._authenticate_client] 提取API密钥完成 | key_prefix={}...",
client_api_key[:8] if client_api_key else None,
)
if not client_api_key:
@@ -197,7 +197,7 @@ class ApiRequestPipeline:
logger.debug("[Pipeline._authenticate_client] 调用 auth_service.authenticate_api_key")
auth_result = self.auth_service.authenticate_api_key(db, client_api_key)
if not quiet:
logger.debug("[Pipeline._authenticate_client] 认证结果 | result=%s", bool(auth_result))
logger.debug("[Pipeline._authenticate_client] 认证结果 | result={}", bool(auth_result))
if not auth_result:
raise HTTPException(status_code=401, detail="无效的API密钥")

View File

@@ -44,7 +44,6 @@ from sqlalchemy.orm import Session
from src.clients.redis_client import get_redis_client_sync
from src.core.logger import logger
from src.services.orchestration.fallback_orchestrator import FallbackOrchestrator
from src.services.provider.format import normalize_endpoint_signature
from src.services.system.audit import audit_service
from src.services.usage.service import UsageService
@@ -397,7 +396,7 @@ class BaseMessageHandler:
self.adapter_detector = adapter_detector
redis_client = get_redis_client_sync()
self.orchestrator = FallbackOrchestrator(db, redis_client) # type: ignore[arg-type]
self.redis = redis_client
self.telemetry = MessageTelemetry(db, user, api_key, request_id, client_ip)
def elapsed_ms(self) -> int:

View File

@@ -213,7 +213,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
Chat Handler 基类
主要职责:
- 通过 FallbackOrchestrator 选择 Provider/Endpoint/Key
- 通过 TaskService/FailoverEngine 选择 Provider/Endpoint/Key
- 发送请求并处理响应
- 记录日志、审计、统计
- 错误处理
@@ -466,6 +466,17 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# 保守兜底:目标需要 stream 且当前缺失时写入
request_body["stream"] = is_stream
# OpenAI Chat Completions: request usage in streaming mode.
# When the client format doesn't carry a `stream` field (e.g. Gemini streaming endpoint),
# the normalizer won't see internal.stream=True, so we need to add this here.
provider_fmt = str(provider_api_format or "").strip().lower()
if is_stream and provider_fmt == "openai:chat":
stream_options = request_body.get("stream_options")
if not isinstance(stream_options, dict):
stream_options = {}
stream_options["include_usage"] = True
request_body["stream_options"] = stream_options
async def _get_mapped_model(
self,
source_model: str,
@@ -520,7 +531,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
model = getattr(converted_request, "model", original_request_body.get("model", "unknown"))
api_format = self.allowed_api_formats[0]
# 可变请求体容器:允许 orchestrator 在遇到 Thinking 签名错误时整流请求体后重试
# 可变请求体容器:允许 TaskService 在遇到 Thinking 签名错误时整流请求体后重试
# 结构: {"body": 实际请求体, "_rectified": 是否已整流, "_rectified_this_turn": 本轮是否整流}
request_body_ref: dict[str, Any] = {"body": original_request_body}
@@ -574,15 +585,13 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
request_body=original_request_body,
)
# 执行请求(通过 FallbackOrchestrator
(
stream_generator,
provider_name,
attempt_id,
provider_id,
endpoint_id,
key_id,
) = await self.orchestrator.execute_with_fallback(
# 统一入口:总是通过 TaskService
from src.services.task import TaskService
from src.services.task.context import TaskMode
exec_result = await TaskService(self.db, self.redis).execute(
task_type="chat",
task_mode=TaskMode.SYNC,
api_format=api_format,
model_name=model,
user_api_key=self.api_key,
@@ -591,8 +600,14 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
is_stream=True,
capability_requirements=capability_requirements or None,
preferred_key_ids=preferred_key_ids or None,
request_body_ref=request_body_ref, # 传递容器引用
request_body_ref=request_body_ref,
)
stream_generator = exec_result.response
provider_name = exec_result.provider_name or "unknown"
attempt_id = exec_result.request_candidate_id
provider_id = exec_result.provider_id
endpoint_id = exec_result.endpoint_id
key_id = exec_result.key_id
# 更新上下文
ctx.attempt_id = attempt_id
@@ -644,7 +659,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
)
except (ThinkingSignatureException, UpstreamClientException) as e:
# ThinkingSignatureException: orchestrator 层已处理整流重试但仍失败
# ThinkingSignatureException: TaskService 层已处理整流重试但仍失败
# UpstreamClientException: 上游客户端错误HTTP 4xx不重试直接返回给客户端
error_type = (
"签名错误" if isinstance(e, ThinkingSignatureException) else "上游客户端错误"
@@ -977,7 +992,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
model = getattr(converted_request, "model", original_request_body.get("model", "unknown"))
api_format = self.allowed_api_formats[0]
# 可变请求体容器:允许 orchestrator 在遇到 Thinking 签名错误时整流请求体后重试
# 可变请求体容器:允许 TaskService 在遇到 Thinking 签名错误时整流请求体后重试
# 结构: {"body": 实际请求体, "_rectified": 是否已整流, "_rectified_this_turn": 本轮是否整流}
request_body_ref: dict[str, Any] = {"body": original_request_body}
@@ -1121,7 +1136,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
status_code = resp.status_code
response_headers = dict(resp.headers)
# 统一使用 HTTPStatusErrororchestrator/error_classifier 负责分类(客户端错误/兼容性错误/限流等)
# 统一使用 HTTPStatusErrorTaskService/error_classifier 负责分类(客户端错误/兼容性错误/限流等)
try:
resp.raise_for_status()
except httpx.HTTPStatusError as e:
@@ -1205,23 +1220,29 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
request_body=original_request_body,
)
(
result,
actual_provider_name,
attempt_id,
provider_id,
endpoint_id,
key_id,
) = await self.orchestrator.execute_with_fallback(
# 统一入口:总是通过 TaskService
from src.services.task import TaskService
from src.services.task.context import TaskMode
exec_result = await TaskService(self.db, self.redis).execute(
task_type="chat",
task_mode=TaskMode.SYNC,
api_format=api_format,
model_name=model,
user_api_key=self.api_key,
request_func=sync_request_func,
request_id=self.request_id,
is_stream=False,
capability_requirements=capability_requirements or None,
preferred_key_ids=preferred_key_ids or None,
request_body_ref=request_body_ref, # 传递容器引用
request_body_ref=request_body_ref,
)
result = exec_result.response
actual_provider_name = exec_result.provider_name or "unknown"
attempt_id = exec_result.request_candidate_id
provider_id = exec_result.provider_id
endpoint_id = exec_result.endpoint_id
key_id = exec_result.key_id
provider_name = actual_provider_name
response_time_ms = self.elapsed_ms()
@@ -1290,7 +1311,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
)
except ThinkingSignatureException as e:
# Thinking 签名错误:orchestrator 层已处理整流重试但仍失败
# Thinking 签名错误:TaskService 层已处理整流重试但仍失败
# 记录实际发送给 Provider 的请求体,便于排查问题根因
response_time_ms = self.elapsed_ms()
actual_request_body = provider_request_body or original_request_body

View File

@@ -406,6 +406,15 @@ class CliMessageHandlerBase(BaseMessageHandler):
else:
request_body.pop("stream", None)
# OpenAI Chat Completions: request usage in streaming mode.
provider_fmt = str(provider_api_format or "").strip().lower()
if is_stream and provider_fmt == "openai:chat":
stream_options = request_body.get("stream_options")
if not isinstance(stream_options, dict):
stream_options = {}
stream_options["include_usage"] = True
request_body["stream_options"] = stream_options
def _convert_request_for_cross_format(
self,
request_body: dict[str, Any],
@@ -506,7 +515,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
通用流程:
1. 创建流上下文
2. 定义请求函数(供 FallbackOrchestrator 调用)
2. 定义请求函数(供 TaskService/FailoverEngine 调用)
3. 执行请求并返回 StreamingResponse
4. 后台任务记录统计信息
@@ -519,7 +528,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
"""
logger.debug(f"开始流式响应处理 ({self.FORMAT_ID})")
# 可变请求体容器:允许 orchestrator 在遇到 Thinking 签名错误时整流请求体后重试
# 可变请求体容器:允许 TaskService 在遇到 Thinking 签名错误时整流请求体后重试
# 结构: {"body": 实际请求体, "_rectified": 是否已整流, "_rectified_this_turn": 本轮是否整流}
request_body_ref: dict[str, Any] = {"body": original_request_body}
@@ -567,15 +576,13 @@ class CliMessageHandlerBase(BaseMessageHandler):
request_body=original_request_body,
)
# 执行请求(通过 FallbackOrchestrator
(
stream_generator,
provider_name,
attempt_id,
provider_id,
endpoint_id,
key_id,
) = await self.orchestrator.execute_with_fallback(
# 统一入口:总是通过 TaskService
from src.services.task import TaskService
from src.services.task.context import TaskMode
exec_result = await TaskService(self.db, self.redis).execute(
task_type="cli",
task_mode=TaskMode.SYNC,
api_format=ctx.api_format,
model_name=ctx.model,
user_api_key=self.api_key,
@@ -584,8 +591,14 @@ class CliMessageHandlerBase(BaseMessageHandler):
is_stream=True,
capability_requirements=capability_requirements or None,
preferred_key_ids=preferred_key_ids or None,
request_body_ref=request_body_ref, # 传递容器引用
request_body_ref=request_body_ref,
)
stream_generator = exec_result.response
provider_name = exec_result.provider_name or "unknown"
attempt_id = exec_result.request_candidate_id
provider_id = exec_result.provider_id
endpoint_id = exec_result.endpoint_id
key_id = exec_result.key_id
# 更新上下文(确保 provider 信息已设置,用于 streaming 状态更新)
ctx.attempt_id = attempt_id
@@ -628,7 +641,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
)
except ThinkingSignatureException as e:
# Thinking 签名错误:orchestrator 层已处理整流重试但仍失败
# Thinking 签名错误:TaskService 层已处理整流重试但仍失败
# 记录 original_request_body客户端原始请求便于排查问题根因
self._log_request_error("流式请求失败(签名错误)", e)
await self._record_stream_failure(ctx, e, original_headers, original_request_body)
@@ -1803,19 +1816,50 @@ class CliMessageHandlerBase(BaseMessageHandler):
yield chunk
except asyncio.CancelledError:
# 计算距离上次收到 chunk 的时间
# 注意CancelledError 不等于“用户手动取消”,它既可能是客户端断连触发,
# 也可能是服务端(重载/关停/内部取消)导致的协程取消。
# 这里尽量做一次“断连归因”:仅当能确认客户端已断开时才记为 499 cancelled。
time_since_last_chunk = time_module.time() - last_chunk_time
# 如果响应已完成,不标记为失败
is_client_disconnected = False
if http_request is not None:
try:
# shield + timeout: 避免在取消态下二次被 CancelledError 打断,尽力取到断连状态
# 限时 0.5s 防止极端情况下的阻塞
is_client_disconnected = await asyncio.wait_for(
asyncio.shield(http_request.is_disconnected()),
timeout=0.5,
)
except (asyncio.CancelledError, asyncio.TimeoutError):
# 无法在取消态/超时下完成断连检查,保守视为未知(不强行归因为客户端)
is_client_disconnected = False
except Exception as e:
logger.debug(f"ID:{ctx.request_id} | cancel 断连检测失败: {e}")
is_client_disconnected = False
# 如果响应已完成,不标记为失败/取消
if not ctx.has_completion:
ctx.status_code = 499
ctx.error_message = "Client disconnected"
logger.warning(
f"ID:{ctx.request_id} | Stream cancelled: "
f"chunks={chunk_count}, "
f"has_completion={ctx.has_completion}, "
f"time_since_last_chunk={time_since_last_chunk:.2f}s, "
f"output_tokens={ctx.output_tokens}"
)
if is_client_disconnected:
ctx.status_code = 499
ctx.error_message = "client_disconnected"
logger.warning(
f"ID:{ctx.request_id} | Stream cancelled by client: "
f"chunks={chunk_count}, "
f"has_completion={ctx.has_completion}, "
f"time_since_last_chunk={time_since_last_chunk:.2f}s, "
f"output_tokens={ctx.output_tokens}"
)
else:
# 服务端中断(例如重载/关停/内部取消)— 不应伪装成客户端取消
ctx.status_code = 503
ctx.error_message = "server_cancelled"
logger.error(
f"ID:{ctx.request_id} | Stream interrupted by server: "
f"chunks={chunk_count}, "
f"has_completion={ctx.has_completion}, "
f"time_since_last_chunk={time_since_last_chunk:.2f}s, "
f"output_tokens={ctx.output_tokens}"
)
raise
except httpx.TimeoutException as e:
ctx.status_code = 504
@@ -1883,45 +1927,73 @@ class CliMessageHandlerBase(BaseMessageHandler):
actual_request_body = ctx.provider_request_body or original_request_body
# 根据状态码决定记录成功还是失败
# 499 = 客户端断开连接503 = 服务不可用(如流中断)
# 499 = 客户端取消(不算系统失败);其他 4xx/5xx 视为失败
if ctx.status_code and ctx.status_code >= 400:
# 记录失败的 Usage但使用已收到的预估 token 信息(来自 message_start
# 这样即使请求中断,也能记录预估成本
# 失败时返回给客户端的是 JSON 错误响应,如果没有设置则使用默认值
client_response_headers = ctx.client_response_headers or {
"content-type": "application/json"
}
await bg_telemetry.record_failure(
provider=ctx.provider_name or "unknown",
model=ctx.model,
response_time_ms=response_time_ms,
status_code=ctx.status_code,
error_message=ctx.error_message or f"HTTP {ctx.status_code}",
request_headers=original_headers,
request_body=actual_request_body,
is_stream=True,
api_format=ctx.api_format,
provider_request_headers=ctx.provider_request_headers,
# 预估 token 信息(来自 message_start 事件)
input_tokens=actual_input_tokens,
output_tokens=ctx.output_tokens,
cache_creation_tokens=ctx.cache_creation_tokens,
cache_read_tokens=ctx.cached_tokens,
response_body=response_body,
response_headers=ctx.response_headers,
client_response_headers=client_response_headers,
# 格式转换追踪
endpoint_api_format=ctx.provider_api_format or None,
has_format_conversion=ctx.needs_conversion,
# 模型映射信息
target_model=ctx.mapped_model,
)
logger.debug(f"{self.FORMAT_ID} 流式响应中断")
# 简洁的请求失败摘要(包含预估 token 信息)
logger.info(
f"[FAIL] {self.request_id[:8]} | {ctx.model} | {ctx.provider_name} | {response_time_ms}ms | "
f"{ctx.status_code} | in:{actual_input_tokens} out:{ctx.output_tokens} cache:{ctx.cached_tokens}"
)
if ctx.is_client_disconnected():
# 客户端取消:记录为 cancelled不算系统失败
await bg_telemetry.record_cancelled(
provider=ctx.provider_name or "unknown",
model=ctx.model,
response_time_ms=response_time_ms,
first_byte_time_ms=ctx.first_byte_time_ms,
status_code=ctx.status_code,
request_headers=original_headers,
request_body=actual_request_body,
is_stream=True,
api_format=ctx.api_format,
provider_request_headers=ctx.provider_request_headers,
input_tokens=actual_input_tokens,
output_tokens=ctx.output_tokens,
cache_creation_tokens=ctx.cache_creation_tokens,
cache_read_tokens=ctx.cached_tokens,
response_body=response_body,
response_headers=ctx.response_headers,
client_response_headers=client_response_headers,
endpoint_api_format=ctx.provider_api_format or None,
has_format_conversion=ctx.needs_conversion,
target_model=ctx.mapped_model,
)
logger.debug(f"{self.FORMAT_ID} 流式响应被客户端取消")
logger.info(
f"[CANCEL] {self.request_id[:8]} | {ctx.model} | {ctx.provider_name} | {response_time_ms}ms | "
f"{ctx.status_code} | in:{actual_input_tokens} out:{ctx.output_tokens} cache:{ctx.cached_tokens}"
)
else:
# 服务端/上游异常:记录为失败
await bg_telemetry.record_failure(
provider=ctx.provider_name or "unknown",
model=ctx.model,
response_time_ms=response_time_ms,
status_code=ctx.status_code,
error_message=ctx.error_message or f"HTTP {ctx.status_code}",
request_headers=original_headers,
request_body=actual_request_body,
is_stream=True,
api_format=ctx.api_format,
provider_request_headers=ctx.provider_request_headers,
# 预估 token 信息(来自 message_start 事件)
input_tokens=actual_input_tokens,
output_tokens=ctx.output_tokens,
cache_creation_tokens=ctx.cache_creation_tokens,
cache_read_tokens=ctx.cached_tokens,
response_body=response_body,
response_headers=ctx.response_headers,
client_response_headers=client_response_headers,
# 格式转换追踪
endpoint_api_format=ctx.provider_api_format or None,
has_format_conversion=ctx.needs_conversion,
# 模型映射信息
target_model=ctx.mapped_model,
)
logger.debug(f"{self.FORMAT_ID} 流式响应中断")
logger.info(
f"[FAIL] {self.request_id[:8]} | {ctx.model} | {ctx.provider_name} | {response_time_ms}ms | "
f"{ctx.status_code} | in:{actual_input_tokens} out:{ctx.output_tokens} cache:{ctx.cached_tokens}"
)
else:
# 在记录统计前,允许子类从 parsed_chunks 中提取额外的元数据
self._finalize_stream_metadata(ctx)
@@ -2016,17 +2088,24 @@ class CliMessageHandlerBase(BaseMessageHandler):
}
if candidate_first_byte_time_ms is not None:
extra_data["first_byte_time_ms"] = candidate_first_byte_time_ms
RequestCandidateService.mark_candidate_failed(
db=bg_db,
candidate_id=ctx.attempt_id,
error_type=(
"client_disconnected" if ctx.status_code == 499 else "stream_error"
),
error_message=trace_error_message,
status_code=ctx.status_code,
latency_ms=response_time_ms,
extra_data=extra_data,
)
if ctx.is_client_disconnected():
RequestCandidateService.mark_candidate_cancelled(
db=bg_db,
candidate_id=ctx.attempt_id,
status_code=ctx.status_code,
latency_ms=response_time_ms,
extra_data=extra_data,
)
else:
RequestCandidateService.mark_candidate_failed(
db=bg_db,
candidate_id=ctx.attempt_id,
error_type="stream_error",
error_message=trace_error_message,
status_code=ctx.status_code,
latency_ms=response_time_ms,
extra_data=extra_data,
)
else:
extra_data = {
"stream_completed": True,
@@ -2115,7 +2194,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
通用流程:
1. 构建请求
2. 通过 FallbackOrchestrator 执行
2. 通过 TaskService/FailoverEngine 执行
3. 解析响应并记录统计
"""
logger.debug(f"开始非流式响应处理 ({self.FORMAT_ID})")
@@ -2139,7 +2218,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
response_metadata_result: dict[str, Any] = {} # Provider 响应元数据
needs_conversion = False # 是否需要格式转换(由 candidate 决定)
# 可变请求体容器:允许 orchestrator 在遇到 Thinking 签名错误时整流请求体后重试
# 可变请求体容器:允许 TaskService 在遇到 Thinking 签名错误时整流请求体后重试
# 结构: {"body": 实际请求体, "_rectified": 是否已整流, "_rectified_this_turn": 本轮是否整流}
request_body_ref: dict[str, Any] = {"body": original_request_body}
@@ -2333,23 +2412,29 @@ class CliMessageHandlerBase(BaseMessageHandler):
request_body=original_request_body,
)
(
result,
actual_provider_name,
attempt_id,
provider_id,
endpoint_id,
key_id,
) = await self.orchestrator.execute_with_fallback(
# 统一入口:总是通过 TaskService
from src.services.task import TaskService
from src.services.task.context import TaskMode
exec_result = await TaskService(self.db, self.redis).execute(
task_type="cli",
task_mode=TaskMode.SYNC,
api_format=api_format,
model_name=model,
user_api_key=self.api_key,
request_func=sync_request_func,
request_id=self.request_id,
is_stream=False,
capability_requirements=capability_requirements or None,
preferred_key_ids=preferred_key_ids or None,
request_body_ref=request_body_ref, # 传递容器引用
request_body_ref=request_body_ref,
)
result = exec_result.response
actual_provider_name = exec_result.provider_name or "unknown"
attempt_id = exec_result.request_candidate_id
provider_id = exec_result.provider_id
endpoint_id = exec_result.endpoint_id
key_id = exec_result.key_id
provider_name = actual_provider_name
response_time_ms = int((time.time() - sync_start_time) * 1000)
@@ -2434,7 +2519,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
)
except ThinkingSignatureException as e:
# Thinking 签名错误:orchestrator 层已处理整流重试但仍失败
# Thinking 签名错误:TaskService 层已处理整流重试但仍失败
# 记录实际发送给 Provider 的请求体,便于排查问题根因
response_time_ms = int((time.time() - sync_start_time) * 1000)
actual_request_body = provider_request_body or original_request_body

View File

@@ -256,7 +256,12 @@ class StreamContext:
用于请求完成/失败时的日志输出。
包含首字时间 (TTFB) 和总响应时间,分两行显示。
"""
status = "OK" if self.is_success() else "FAIL"
if self.is_success():
status = "OK"
elif self.is_client_disconnected():
status = "CANCEL"
else:
status = "FAIL"
# 第一行:基本信息 + 首字时间
line1 = (

View File

@@ -57,13 +57,11 @@ class VideoAdapterBase(ApiAdapter):
path = http_request.url.path.lower()
task_id = path_params.get("task_id")
if method in {"POST", "PUT", "PATCH"}:
original_request_body = context.ensure_json_body()
else:
original_request_body = {}
# Note: not every POST endpoint requires a body (e.g. cancel).
original_request_body: dict[str, Any] = {}
logger.debug(
"[VideoAdapter] dispatch method=%s path=%s task_id=%s",
"[VideoAdapter] dispatch method={} path={} task_id={}",
method,
path,
task_id,
@@ -105,6 +103,7 @@ class VideoAdapterBase(ApiAdapter):
# Remix task
if method == "POST" and path.endswith("/remix") and task_id:
original_request_body = context.ensure_json_body()
return await handler.handle_remix_task(
task_id=task_id,
http_request=http_request,
@@ -134,6 +133,8 @@ class VideoAdapterBase(ApiAdapter):
)
# Create task (default)
if method in {"POST", "PUT", "PATCH"}:
original_request_body = context.ensure_json_body()
return await handler.handle_create_task(
http_request=http_request,
original_headers=context.original_headers,

View File

@@ -315,7 +315,7 @@ class VideoHandlerBase(ABC):
)
except Exception as exc:
logger.warning(
"Failed to finalize usage on submit failure: request_id=%s, error=%s",
"Failed to finalize usage on submit failure: request_id={}, error={}",
self.request_id,
sanitize_error_message(str(exc)),
)
@@ -367,16 +367,20 @@ class VideoHandlerBase(ABC):
- 无可用候选 / 全部失败:抛 HTTPException(503)
"""
# 延迟导入,避免 handler 基类层引入过多依赖导致循环
from src.services.candidate.service import CandidateService
from src.services.candidate.submit import (
AllCandidatesFailedError,
SubmitOutcome,
UpstreamClientRequestError,
)
candidate_service = CandidateService(self.db)
# 统一入口:总是通过 TaskService内部可继续委托 CandidateService便于逐步内核统一
from src.services.task import TaskService
submitter: Any = TaskService(self.db)
submit_call = submitter.submit_with_failover
try:
return await candidate_service.submit_with_failover(
return await submit_call(
api_format=api_format,
model_name=model_name,
affinity_key=str(self.api_key.id),
@@ -402,7 +406,7 @@ class VideoHandlerBase(ABC):
detail = "No available provider with billing rule for video generation"
# 记录候选信息到日志
logger.warning(
"[VideoHandler] All candidates failed: reason=%s, candidate_keys=%s",
"[VideoHandler] All candidates failed: reason={}, candidate_keys={}",
exc.reason,
exc.candidate_keys,
)

View File

@@ -72,7 +72,7 @@ class ClaudeChatHandler(ChatHandlerBase):
将请求转换为 Claude 格式的 Pydantic 对象
注意此方法只做类型转换dict → Pydantic不做跨格式转换。
跨格式转换由 FallbackOrchestrator 在选中候选后、发送请求前执行,
跨格式转换由调度/执行层TaskService + RequestDispatcher在选中候选后、发送请求前执行,
并受全局开关和端点配置控制。
Args:

View File

@@ -19,7 +19,7 @@ class ClaudeCliMessageHandler(CliMessageHandlerBase):
Claude CLI Message Handler - 处理 Claude CLI API 格式
使用新三层架构 (Provider -> ProviderEndpoint -> ProviderAPIKey)
通过 FallbackOrchestrator 实现自动故障转移、健康监控和并发控制
通过 TaskService/FailoverEngine 实现自动故障转移、健康监控和并发控制
响应格式特点:
- 使用 content[] 数组

View File

@@ -111,7 +111,7 @@ class GeminiChatHandler(ChatHandlerBase):
将请求转换为 Gemini 格式的 Pydantic 对象
注意此方法只做类型转换dict → Pydantic不做跨格式转换。
跨格式转换由 FallbackOrchestrator 在选中候选后、发送请求前执行,
跨格式转换由调度/执行层TaskService + RequestDispatcher在选中候选后、发送请求前执行,
并受全局开关和端点配置控制。
Args:

View File

@@ -111,7 +111,7 @@ class GeminiVeoHandler(VideoHandlerBase):
)
except Exception as exc:
logger.warning(
"Failed to create pending usage for video request_id=%s: %s",
"Failed to create pending usage for video request_id={}: {}",
self.request_id,
sanitize_error_message(str(exc)),
)
@@ -171,7 +171,7 @@ class GeminiVeoHandler(VideoHandlerBase):
if "name" in payload:
value = payload.get("name")
logger.debug(
"[GeminiVeoHandler] Upstream response name=%s, keys=%s",
"[GeminiVeoHandler] Upstream response name={}, keys={}",
value,
list(payload.keys()) if isinstance(payload, dict) else type(payload),
)
@@ -222,7 +222,7 @@ class GeminiVeoHandler(VideoHandlerBase):
)
except Exception as e:
logger.warning(
"[GeminiVeoHandler] Failed to record converted request: %s",
"[GeminiVeoHandler] Failed to record converted request: {}",
sanitize_error_message(str(e)),
)
@@ -243,7 +243,7 @@ class GeminiVeoHandler(VideoHandlerBase):
self.db.commit()
self.db.refresh(task)
logger.debug(
"[GeminiVeoHandler] Task created: id=%s, external_task_id=%s",
"[GeminiVeoHandler] Task created: id={}, external_task_id={}",
task.id,
task.external_task_id,
)
@@ -292,7 +292,7 @@ class GeminiVeoHandler(VideoHandlerBase):
self.db.commit()
except Exception as exc:
logger.warning(
"Failed to finalize submitted usage for video request_id=%s: %s",
"Failed to finalize submitted usage for video request_id={}: {}",
self.request_id,
sanitize_error_message(str(exc)),
)
@@ -345,51 +345,16 @@ class GeminiVeoHandler(VideoHandlerBase):
query_params: dict[str, str] | None = None,
path_params: dict[str, Any] | None = None,
) -> JSONResponse:
task = self._get_task_by_external_id(task_id)
if not task.external_task_id:
raise HTTPException(status_code=500, detail="Task missing external_task_id")
endpoint, key = self._get_endpoint_and_key(task)
if not key.api_key:
raise HTTPException(status_code=500, detail="Provider key not configured")
upstream_key = crypto_service.decrypt(key.api_key)
from src.services.task.service import TaskService
operation_name = task.external_task_id
if not operation_name.startswith("operations/"):
operation_name = f"operations/{operation_name}"
upstream_url = self._build_cancel_url(endpoint.base_url, operation_name)
auth_info = await get_provider_auth(endpoint, key)
headers = self._build_upstream_headers(original_headers, upstream_key, endpoint, auth_info)
client = await HTTPClientPool.get_default_client_async()
response = await client.post(upstream_url, headers=headers, json={})
if response.status_code >= 400:
return self._build_error_response(response)
task.status = VideoStatus.CANCELLED.value
task.updated_at = datetime.now(timezone.utc)
# 将 Usage 作废(不收费)
# 尝试 finalize_void处理 pending和 void_settled处理已 settled
try:
voided = UsageService.finalize_void(
self.db,
request_id=task.request_id,
reason="cancelled_by_user",
)
if not voided:
# pending 状态未找到,尝试处理已 settled 的记录
UsageService.void_settled(
self.db,
request_id=task.request_id,
reason="cancelled_by_user",
)
except Exception as exc:
logger.warning(
"Failed to void usage for cancelled task=%s: %s",
task.id,
sanitize_error_message(str(exc)),
)
self.db.commit()
_ = (http_request, query_params, path_params) # reserved for future extensions
err_resp = await TaskService(self.db).cancel(
task_id,
user_id=str(self.user.id),
original_headers=original_headers,
)
if err_resp is not None:
return self._build_error_response(err_resp)
return JSONResponse({})
async def handle_download_content(
@@ -446,7 +411,7 @@ class GeminiVeoHandler(VideoHandlerBase):
download_headers[auth_info.auth_header] = auth_info.auth_value
except Exception as exc:
logger.warning(
"[VideoDownload] Failed to get auth for download task=%s: %s",
"[VideoDownload] Failed to get auth for download task={}: {}",
task.id,
sanitize_error_message(str(exc)),
)
@@ -464,7 +429,7 @@ class GeminiVeoHandler(VideoHandlerBase):
response = await client.get(task.video_url, headers=download_headers)
except Exception as exc:
logger.error(
"[VideoDownload] Upstream fetch failed user=%s task=%s: %s",
"[VideoDownload] Upstream fetch failed user={} task={}: {}",
self.user.id,
task.id,
sanitize_error_message(str(exc)),
@@ -496,7 +461,7 @@ class GeminiVeoHandler(VideoHandlerBase):
upstream_key = crypto_service.decrypt(candidate.key.api_key)
except Exception as exc:
logger.error(
"Failed to decrypt provider key id=%s: %s",
"Failed to decrypt provider key id={}: {}",
candidate.key.id,
sanitize_error_message(str(exc)),
)
@@ -684,7 +649,7 @@ class GeminiVeoHandler(VideoHandlerBase):
.first()
)
if not task:
logger.debug("[GeminiVeoHandler] Task not found: short_id=%s", short_id)
logger.debug("[GeminiVeoHandler] Task not found: short_id={}", short_id)
raise HTTPException(status_code=404, detail="Video task not found")
return task

View File

@@ -18,7 +18,7 @@ class GeminiCliMessageHandler(CliMessageHandlerBase):
Gemini CLI Message Handler - 处理 Gemini CLI API 格式
使用新三层架构 (Provider -> ProviderEndpoint -> ProviderAPIKey)
通过 FallbackOrchestrator 实现自动故障转移、健康监控和并发控制
通过 TaskService/FailoverEngine 实现自动故障转移、健康监控和并发控制
响应格式特点:
- Gemini 使用 JSON 数组格式流式响应(非 SSE

View File

@@ -75,7 +75,7 @@ class OpenAIChatHandler(ChatHandlerBase):
将请求转换为 OpenAI 格式的 Pydantic 对象
注意此方法只做类型转换dict → Pydantic不做跨格式转换。
跨格式转换由 FallbackOrchestrator 在选中候选后、发送请求前执行,
跨格式转换由调度/执行层TaskService + RequestDispatcher在选中候选后、发送请求前执行,
并受全局开关和端点配置控制。
Args:

View File

@@ -110,7 +110,7 @@ class OpenAIVideoHandler(VideoHandlerBase):
)
except Exception as exc:
logger.warning(
"Failed to create pending usage for video request_id=%s: %s",
"Failed to create pending usage for video request_id={}: {}",
self.request_id,
sanitize_error_message(str(exc)),
)
@@ -245,7 +245,7 @@ class OpenAIVideoHandler(VideoHandlerBase):
)
except Exception as e:
logger.warning(
"[OpenAIVideoHandler] Failed to record converted request: %s",
"[OpenAIVideoHandler] Failed to record converted request: {}",
sanitize_error_message(str(e)),
)
@@ -309,7 +309,7 @@ class OpenAIVideoHandler(VideoHandlerBase):
self.db.commit()
except Exception as exc:
logger.warning(
"Failed to finalize submitted usage for video request_id=%s: %s",
"Failed to finalize submitted usage for video request_id={}: {}",
self.request_id,
sanitize_error_message(str(exc)),
)
@@ -401,47 +401,16 @@ class OpenAIVideoHandler(VideoHandlerBase):
query_params: dict[str, str] | None = None,
path_params: dict[str, Any] | None = None,
) -> JSONResponse:
task = self._get_task(task_id)
if not task.external_task_id:
raise HTTPException(status_code=500, detail="Task missing external_task_id")
endpoint, key = self._get_endpoint_and_key(task)
if not key.api_key:
raise HTTPException(status_code=500, detail="Provider key not configured")
upstream_key = crypto_service.decrypt(key.api_key)
from src.services.task.service import TaskService
upstream_url = self._build_upstream_url(endpoint.base_url, task.external_task_id)
headers = self._build_upstream_headers(original_headers, upstream_key, endpoint)
client = await HTTPClientPool.get_default_client_async()
response = await client.delete(upstream_url, headers=headers)
if response.status_code >= 400:
return self._build_error_response(response)
task.status = VideoStatus.CANCELLED.value
task.updated_at = datetime.now(timezone.utc)
# 将 Usage 作废(不收费)
# 尝试 finalize_void处理 pending和 void_settled处理已 settled
try:
voided = UsageService.finalize_void(
self.db,
request_id=task.request_id,
reason="cancelled_by_user",
)
if not voided:
# pending 状态未找到,尝试处理已 settled 的记录
UsageService.void_settled(
self.db,
request_id=task.request_id,
reason="cancelled_by_user",
)
except Exception as exc:
logger.warning(
"Failed to void usage for cancelled task=%s: %s",
task.id,
sanitize_error_message(str(exc)),
)
self.db.commit()
_ = (http_request, query_params, path_params) # reserved for future extensions
err_resp = await TaskService(self.db).cancel(
task_id,
user_id=str(self.user.id),
original_headers=original_headers,
)
if err_resp is not None:
return self._build_error_response(err_resp)
return JSONResponse({})
async def handle_delete_task(
@@ -481,7 +450,7 @@ class OpenAIVideoHandler(VideoHandlerBase):
return self._build_error_response(response)
except Exception as exc:
logger.warning(
"Failed to delete video from upstream task=%s: %s",
"Failed to delete video from upstream task={}: {}",
task.id,
sanitize_error_message(str(exc)),
)
@@ -646,7 +615,7 @@ class OpenAIVideoHandler(VideoHandlerBase):
# 保持流式代理而非重定向,确保客户端行为与官方 OpenAI 一致
if variant == "video" and task.video_url and task.video_url.startswith("http"):
logger.debug(
"[VideoDownload] Proxying direct URL task=%s url=%s",
"[VideoDownload] Proxying direct URL task={} url={}",
task_id,
task.video_url,
)
@@ -673,7 +642,7 @@ class OpenAIVideoHandler(VideoHandlerBase):
client = await HTTPClientPool.get_default_client_async()
logger.debug(
"[VideoDownload] Requesting upstream url=%s task=%s external_task_id=%s",
"[VideoDownload] Requesting upstream url={} task={} external_task_id={}",
upstream_url,
task_id,
task.external_task_id,
@@ -685,7 +654,7 @@ class OpenAIVideoHandler(VideoHandlerBase):
response = await client.send(request, stream=True, timeout=300.0)
except Exception as exc:
logger.warning(
"[VideoDownload] Upstream connection failed task=%s url=%s: %s",
"[VideoDownload] Upstream connection failed task={} url={}: {}",
task_id,
upstream_url,
sanitize_error_message(str(exc)),
@@ -743,7 +712,7 @@ class OpenAIVideoHandler(VideoHandlerBase):
upstream_key = crypto_service.decrypt(candidate.key.api_key)
except Exception as exc:
logger.error(
"Failed to decrypt provider key id=%s: %s",
"Failed to decrypt provider key id={}: {}",
candidate.key.id,
sanitize_error_message(str(exc)),
)
@@ -758,7 +727,7 @@ class OpenAIVideoHandler(VideoHandlerBase):
response = await client.send(request, stream=True, timeout=300.0)
except Exception as exc:
logger.warning(
"[VideoDownload] Direct URL connection failed task=%s url=%s: %s",
"[VideoDownload] Direct URL connection failed task={} url={}: {}",
task_id,
url,
sanitize_error_message(str(exc)),
@@ -1016,7 +985,7 @@ class OpenAIVideoHandler(VideoHandlerBase):
target_model=None,
)
except Exception as exc:
logger.warning("Failed to record failed usage: %s", sanitize_error_message(str(exc)))
logger.warning("Failed to record failed usage: {}", sanitize_error_message(str(exc)))
async def _create_failed_task_and_usage(
self,
@@ -1089,7 +1058,7 @@ class OpenAIVideoHandler(VideoHandlerBase):
except Exception as exc:
self.db.rollback()
logger.warning(
"Failed to create failed task record: %s", sanitize_error_message(str(exc))
"Failed to create failed task record: {}", sanitize_error_message(str(exc))
)
# 即使任务记录失败,仍然尝试记录使用记录
task = None
@@ -1136,7 +1105,7 @@ class OpenAIVideoHandler(VideoHandlerBase):
target_model=None,
)
except Exception as exc:
logger.warning("Failed to record failed usage: %s", sanitize_error_message(str(exc)))
logger.warning("Failed to record failed usage: {}", sanitize_error_message(str(exc)))
__all__ = ["OpenAIVideoHandler"]

View File

@@ -19,7 +19,7 @@ class OpenAICliMessageHandler(CliMessageHandlerBase):
OpenAI CLI Message Handler - 处理 OpenAI CLI Responses API 格式
使用新三层架构 (Provider -> ProviderEndpoint -> ProviderAPIKey)
通过 FallbackOrchestrator 实现自动故障转移、健康监控和并发控制
通过 TaskService/FailoverEngine 实现自动故障转移、健康监控和并发控制
响应格式特点:
- 使用 output[] 数组而非 content[]

View File

@@ -310,7 +310,7 @@ async def _resolve_upstream_context(
try:
upstream_key = crypto_service.decrypt(candidate.key.api_key)
except Exception as exc:
logger.error("Failed to decrypt provider key for Gemini Files API: %s", exc)
logger.error("Failed to decrypt provider key for Gemini Files API: {}", exc)
raise HTTPException(
status_code=500,
detail={
@@ -451,12 +451,12 @@ async def _proxy_request(
mapped_count += 1
if mapped_count > 0:
logger.debug(
"Gemini list_files 批量映射已存储: %d 个文件 → key_id=%s",
"Gemini list_files 批量映射已存储: {} 个文件 → key_id={}",
mapped_count,
file_key_id,
)
except (ValueError, KeyError) as e:
logger.debug("Failed to store Gemini file mapping: %s", e)
logger.debug("Failed to store Gemini file mapping: {}", e)
return Response(
content=response.content,
@@ -467,7 +467,7 @@ async def _proxy_request(
except Exception as e:
sanitized_error = redact_url_for_log(str(e))
logger.error("Gemini Files API proxy error: %s", sanitized_error)
logger.error("Gemini Files API proxy error: {}", sanitized_error)
return JSONResponse(
status_code=502,
content={
@@ -533,7 +533,7 @@ async def upload_file(
headers = _build_upstream_headers(dict(request.headers), ctx.upstream_key)
logger.debug("Gemini Files upload proxy: POST %s", redact_url_for_log(upstream_url))
logger.debug("Gemini Files upload proxy: POST {}", redact_url_for_log(upstream_url))
return await _proxy_request(
"POST",
@@ -603,7 +603,7 @@ async def list_files(
upstream_url = _build_upstream_url(ctx.base_url, "/v1beta/files", query_params)
headers = _build_upstream_headers(dict(request.headers), ctx.upstream_key)
logger.debug("Gemini Files list proxy: GET %s", redact_url_for_log(upstream_url))
logger.debug("Gemini Files list proxy: GET {}", redact_url_for_log(upstream_url))
return await _proxy_request(
"GET", upstream_url, headers, file_key_id=ctx.file_key_id, user_id=ctx.user_id
@@ -633,7 +633,7 @@ async def _find_video_task_by_id(
from src.models.database import ProviderAPIKey, VideoTask
logger.debug(
"[Files Download] Searching video task: short_id=%s, user_id=%s", short_id, user_id
"[Files Download] Searching video task: short_id={}, user_id={}", short_id, user_id
)
# 通过 short_id 查找,同时验证用户权限
@@ -644,29 +644,29 @@ async def _find_video_task_by_id(
)
if not task:
logger.debug("[Files Download] No video task found: short_id=%s", short_id)
logger.debug("[Files Download] No video task found: short_id={}", short_id)
return None, None
if not task.video_url:
logger.debug("[Files Download] Task found but no video_url: short_id=%s", short_id)
logger.debug("[Files Download] Task found but no video_url: short_id={}", short_id)
return None, None
if not task.key_id:
logger.debug("[Files Download] Task found but no key_id: short_id=%s", short_id)
logger.debug("[Files Download] Task found but no key_id: short_id={}", short_id)
return None, task.video_url
# 获取 provider key
provider_key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == task.key_id).first()
if not provider_key or not provider_key.api_key:
logger.debug("[Files Download] Provider key not found: key_id=%s", task.key_id)
logger.debug("[Files Download] Provider key not found: key_id={}", task.key_id)
return None, task.video_url
try:
upstream_key = crypto_service.decrypt(provider_key.api_key)
logger.debug("[Files Download] Found key for task: short_id=%s", short_id)
logger.debug("[Files Download] Found key for task: short_id={}", short_id)
return upstream_key, task.video_url
except Exception as e:
logger.error("[Files Download] Failed to decrypt key: %s", e)
logger.error("[Files Download] Failed to decrypt key: {}", e)
return None, task.video_url
@@ -733,7 +733,7 @@ async def download_file(
if file_id.startswith("aev_"):
# 视频任务下载:使用短 ID 查找
short_id = file_id[4:] # 去掉 "aev_" 前缀
logger.debug("[Files Download] Video task: short_id=%s, user_id=%s", short_id, user.id)
logger.debug("[Files Download] Video task: short_id={}, user_id={}", short_id, user.id)
upstream_key, video_url = await _find_video_task_by_id(db, short_id, user.id)
if not upstream_key or not video_url:
raise HTTPException(
@@ -774,14 +774,14 @@ async def download_file(
# ========== 阶段 2HTTP 下载(不持有数据库连接)==========
headers = _build_upstream_headers(dict(request.headers), upstream_key)
logger.debug("Gemini Files download proxy: GET %s", redact_url_for_log(upstream_url))
logger.debug("Gemini Files download proxy: GET {}", redact_url_for_log(upstream_url))
# 使用 follow_redirects=True 跟随重定向Gemini 文件下载会重定向)
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=httpx.Timeout(300.0)) as client:
response = await client.get(upstream_url, headers=headers)
except Exception as exc:
logger.error("Gemini Files download failed: %s", exc)
logger.error("Gemini Files download failed: {}", exc)
raise HTTPException(status_code=502, detail="Failed to download file")
if response.status_code >= 400:
@@ -861,7 +861,7 @@ async def get_file(
)
headers = _build_upstream_headers(dict(request.headers), ctx.upstream_key)
logger.debug("Gemini Files get proxy: GET %s", redact_url_for_log(upstream_url))
logger.debug("Gemini Files get proxy: GET {}", redact_url_for_log(upstream_url))
return await _proxy_request(
"GET", upstream_url, headers, file_key_id=ctx.file_key_id, user_id=ctx.user_id
@@ -908,13 +908,13 @@ async def delete_file(
)
headers = _build_upstream_headers(dict(request.headers), ctx.upstream_key)
logger.debug("Gemini Files delete proxy: DELETE %s", redact_url_for_log(upstream_url))
logger.debug("Gemini Files delete proxy: DELETE {}", redact_url_for_log(upstream_url))
response = await _proxy_request("DELETE", upstream_url, headers)
if response.status_code < 300:
await delete_file_key_mapping(file_name)
else:
logger.debug(
"Gemini Files delete failed, skip mapping cleanup: status=%s", response.status_code
"Gemini Files delete failed, skip mapping cleanup: status={}", response.status_code
)
return response

View File

@@ -15,12 +15,12 @@ from sqlalchemy import func
from sqlalchemy.orm import Session, selectinload
from src.api.handlers.base.request_builder import PassthroughRequestBuilder
from src.clients.redis_client import get_redis_client, get_redis_client_sync
from src.api.handlers.base.request_builder import build_test_request_body, get_provider_auth
from src.clients.redis_client import get_redis_client
from src.core.logger import logger
from src.database import get_db
from src.database.database import get_pool_status
from src.models.database import Model, Provider
from src.services.orchestration.fallback_orchestrator import FallbackOrchestrator
from src.models.database import Model, Provider, ProviderAPIKey, ProviderEndpoint
from src.services.provider.transport import build_provider_url
from src.utils.ssl_utils import get_ssl_context
@@ -256,24 +256,58 @@ async def test_connection(
if not selected_provider:
raise HTTPException(status_code=503, detail="No active provider available")
# 构建测试请求体
payload = {
"model": model,
"messages": [{"role": "user", "content": "Health check"}],
"max_tokens": 5,
}
# Determine endpoint format: prefer explicit api_format; otherwise use the provider's first active endpoint.
active_endpoints: list[ProviderEndpoint] = [
ep for ep in (selected_provider.endpoints or []) if getattr(ep, "is_active", False)
]
if not active_endpoints:
raise HTTPException(status_code=503, detail="Provider has no active endpoints")
# 确定 API 格式
format_value = api_format or "claude:chat"
if api_format:
endpoint = next(
(ep for ep in active_endpoints if (ep.api_format or "") == api_format),
None,
)
if not endpoint:
raise HTTPException(
status_code=400,
detail=f"Provider has no active endpoint for api_format={api_format}",
)
format_value = api_format
else:
endpoint = active_endpoints[0]
format_value = endpoint.api_format or "claude:chat"
# 创建 FallbackOrchestrator
redis_client = get_redis_client_sync()
orchestrator = FallbackOrchestrator(db, redis_client)
# Pick an active ProviderAPIKey that supports this format (best-effort).
active_keys: list[ProviderAPIKey] = [
k for k in (selected_provider.api_keys or []) if getattr(k, "is_active", False)
]
if not active_keys:
raise HTTPException(status_code=503, detail="Provider has no active api keys")
# 定义请求函数
async def test_request_func(_prov: Any, endpoint: Any, key: str, _candidate: Any) -> Any:
from src.api.handlers.base.request_builder import get_provider_auth
def _key_supports_format(k: ProviderAPIKey) -> bool:
formats = getattr(k, "api_formats", None)
# None => supports all formats; [] => supports none
if formats is None:
return True
if isinstance(formats, list):
return str(format_value) in {str(x) for x in formats}
# unexpected type: be permissive
return True
key = next((k for k in active_keys if _key_supports_format(k)), active_keys[0])
# Build a safe test request body in the endpoint's format (via format conversion registry).
payload = build_test_request_body(
format_value,
request_data={
"model": model,
"messages": [{"role": "user", "content": "Health check"}],
"max_tokens": 5,
},
)
try:
# 获取认证信息(处理 Service Account 等异步认证场景)
auth_info = await get_provider_auth(endpoint, key)
@@ -299,19 +333,13 @@ async def test_connection(
async with httpx.AsyncClient(timeout=30.0, verify=get_ssl_context()) as client:
resp = await client.post(url, json=provider_payload, headers=provider_headers)
resp.raise_for_status()
return resp.json()
response = resp.json()
try:
response, actual_provider, *_ = await orchestrator.execute_with_fallback(
api_format=format_value,
model_name=model,
user_api_key=None,
request_func=test_request_func,
request_id=None,
)
return {
"status": "success",
"provider": actual_provider,
"provider": selected_provider.name,
"endpoint_id": getattr(endpoint, "id", None),
"api_format": format_value,
"timestamp": datetime.now(timezone.utc).isoformat(),
"response_id": response.get("id", "unknown"),
}

View File

@@ -33,6 +33,22 @@ async def create_video_sora(http_request: Request, db: Session = Depends(get_db)
)
@router.post("/v1/videos/{task_id}/cancel")
async def cancel_video_sora(
task_id: str, http_request: Request, db: Session = Depends(get_db)
) -> Any:
"""Cancel video task (OpenAI Sora style)."""
adapter = OpenAIVideoAdapter()
return await pipeline.run(
adapter=adapter,
http_request=http_request,
db=db,
mode=adapter.mode,
api_format_hint=adapter.allowed_api_formats[0],
path_params={"task_id": task_id, "action": "cancel"},
)
@router.get("/v1/videos/{task_id}")
async def get_video_task_sora(
task_id: str, http_request: Request, db: Session = Depends(get_db)