mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/ - 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层 - 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构 - 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations) - 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image - 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
267 lines
10 KiB
Python
267 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from src.config.settings import config
|
|
from src.core.logger import logger
|
|
from src.models.database import ProviderAPIKey, ProviderEndpoint
|
|
from src.services.usage.service import UsageService
|
|
|
|
|
|
class VideoTaskCancelService:
|
|
"""视频任务取消服务(上游取消 + 本地状态与计费回写)。"""
|
|
|
|
def __init__(self, db: Session) -> None:
|
|
self.db = db
|
|
|
|
async def cancel_task(
|
|
self,
|
|
*,
|
|
task: Any,
|
|
task_id: str,
|
|
original_headers: dict[str, str] | None = None,
|
|
) -> Any:
|
|
"""
|
|
Cancel a video task (best-effort) and void its Usage (no charge).
|
|
|
|
Returns:
|
|
- None on success
|
|
- upstream httpx.Response when upstream returns an error (status >= 400)
|
|
"""
|
|
import json
|
|
from datetime import datetime, timezone
|
|
|
|
import httpx
|
|
from fastapi import HTTPException
|
|
|
|
from src.core.api_format import (
|
|
build_upstream_headers_for_endpoint,
|
|
get_extra_headers_from_endpoint,
|
|
make_signature_key,
|
|
)
|
|
from src.core.api_format.conversion.internal_video import VideoStatus
|
|
from src.core.crypto import crypto_service
|
|
from src.services.provider.auth import get_provider_auth
|
|
from src.services.provider.transport import build_provider_url
|
|
|
|
current_status = str(getattr(task, "status", "") or "")
|
|
non_cancellable_statuses = {
|
|
VideoStatus.COMPLETED.value,
|
|
VideoStatus.FAILED.value,
|
|
VideoStatus.CANCELLED.value,
|
|
VideoStatus.EXPIRED.value,
|
|
}
|
|
if current_status in non_cancellable_statuses:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"Task cannot be cancelled in status: {current_status}",
|
|
)
|
|
|
|
external_task_id = getattr(task, "external_task_id", None)
|
|
if not external_task_id:
|
|
raise HTTPException(status_code=500, detail="Task missing external_task_id")
|
|
|
|
endpoint = (
|
|
self.db.query(ProviderEndpoint).filter(ProviderEndpoint.id == task.endpoint_id).first()
|
|
)
|
|
key = self.db.query(ProviderAPIKey).filter(ProviderAPIKey.id == task.key_id).first()
|
|
if not endpoint or not key:
|
|
raise HTTPException(status_code=500, detail="Provider endpoint or key not found")
|
|
if not getattr(key, "api_key", None):
|
|
raise HTTPException(status_code=500, detail="Provider key not configured")
|
|
|
|
upstream_key = crypto_service.decrypt(key.api_key)
|
|
extra_headers = get_extra_headers_from_endpoint(endpoint)
|
|
|
|
raw_family = str(getattr(endpoint, "api_family", "") or "").strip().lower()
|
|
raw_kind = str(getattr(endpoint, "endpoint_kind", "") or "").strip().lower()
|
|
provider_format = (
|
|
make_signature_key(raw_family, raw_kind)
|
|
if raw_family and raw_kind
|
|
else str(
|
|
getattr(endpoint, "api_format", "")
|
|
or getattr(task, "provider_api_format", "")
|
|
or ""
|
|
)
|
|
)
|
|
provider_format_norm = provider_format.strip().lower()
|
|
|
|
headers = build_upstream_headers_for_endpoint(
|
|
original_headers or {},
|
|
provider_format,
|
|
upstream_key,
|
|
endpoint_headers=extra_headers,
|
|
header_rules=getattr(endpoint, "header_rules", None),
|
|
)
|
|
|
|
async def _try_rust_cancel_response(
|
|
*,
|
|
method: str,
|
|
url: str,
|
|
request_headers: dict[str, str],
|
|
body: Any,
|
|
content_type: str | None = None,
|
|
) -> httpx.Response:
|
|
from src.services.request.execution_runtime_plan import (
|
|
ExecutionPlan,
|
|
ExecutionPlanTimeouts,
|
|
build_execution_plan_body,
|
|
)
|
|
from src.services.request.execution_runtime_client import (
|
|
ExecutionRuntimeClient,
|
|
ExecutionRuntimeClientError,
|
|
)
|
|
|
|
if config.execution_runtime_backend != "rust":
|
|
return httpx.Response(
|
|
status_code=503,
|
|
request=httpx.Request(method, url, headers=request_headers),
|
|
json={"error": {"message": "Video 取消仅支持 Rust executor"}},
|
|
)
|
|
|
|
final_headers = dict(request_headers)
|
|
if (
|
|
body is not None
|
|
and content_type
|
|
and not any(str(key).lower() == "content-type" for key in final_headers)
|
|
):
|
|
final_headers["content-type"] = content_type
|
|
|
|
try:
|
|
result = await ExecutionRuntimeClient().execute_sync_json(
|
|
ExecutionPlan(
|
|
request_id=str(getattr(task, "request_id", "") or task_id),
|
|
candidate_id=None,
|
|
provider_name=provider_format_norm.split(":", 1)[0],
|
|
provider_id=str(getattr(endpoint, "provider_id", "") or ""),
|
|
endpoint_id=str(getattr(endpoint, "id", "") or ""),
|
|
key_id=str(getattr(key, "id", "") or ""),
|
|
method=method,
|
|
url=url,
|
|
headers=final_headers,
|
|
body=build_execution_plan_body(body, content_type=content_type),
|
|
stream=False,
|
|
provider_api_format=provider_format,
|
|
client_api_format=provider_format,
|
|
model_name=str(getattr(task, "model", "") or ""),
|
|
content_type=content_type,
|
|
timeouts=ExecutionPlanTimeouts(
|
|
connect_ms=30_000,
|
|
read_ms=300_000,
|
|
write_ms=300_000,
|
|
pool_ms=30_000,
|
|
total_ms=300_000,
|
|
),
|
|
)
|
|
)
|
|
except (ExecutionRuntimeClientError, httpx.HTTPError, json.JSONDecodeError) as exc:
|
|
logger.warning(
|
|
"[VideoCancel] Rust executor unavailable task={} method={} url={}: {}",
|
|
getattr(task, "id", task_id),
|
|
method,
|
|
url,
|
|
str(exc),
|
|
)
|
|
return httpx.Response(
|
|
status_code=503,
|
|
request=httpx.Request(method, url, headers=final_headers),
|
|
json={"error": {"message": "执行器暂时不可用,请稍后重试"}},
|
|
)
|
|
|
|
response_headers = dict(result.headers)
|
|
if result.response_json is not None:
|
|
response_headers.setdefault("content-type", "application/json")
|
|
response_body = json.dumps(result.response_json, ensure_ascii=False).encode("utf-8")
|
|
elif result.response_body_bytes is not None:
|
|
response_body = result.response_body_bytes
|
|
else:
|
|
response_body = b""
|
|
|
|
return httpx.Response(
|
|
status_code=result.status_code,
|
|
request=httpx.Request(method, url, headers=final_headers),
|
|
headers=response_headers,
|
|
content=response_body,
|
|
)
|
|
|
|
if provider_format_norm.startswith("openai:"):
|
|
upstream_url = build_provider_url(endpoint, is_stream=False, key=key)
|
|
upstream_url = f"{upstream_url.rstrip('/')}/{str(external_task_id).lstrip('/')}"
|
|
response = await _try_rust_cancel_response(
|
|
method="DELETE",
|
|
url=upstream_url,
|
|
request_headers=headers,
|
|
body=None,
|
|
)
|
|
if response.status_code >= 400:
|
|
return response
|
|
|
|
elif provider_format_norm.startswith("gemini:"):
|
|
# Gemini cancel endpoint supports both:
|
|
# - operations/{id}:cancel
|
|
# - models/{model}/operations/{id}:cancel
|
|
operation_name = str(external_task_id)
|
|
if not (
|
|
operation_name.startswith("operations/") or operation_name.startswith("models/")
|
|
):
|
|
operation_name = f"operations/{operation_name}"
|
|
|
|
base = (
|
|
getattr(endpoint, "base_url", None) or "https://generativelanguage.googleapis.com"
|
|
).rstrip("/")
|
|
if base.endswith("/v1beta"):
|
|
base = base[: -len("/v1beta")]
|
|
upstream_url = f"{base}/v1beta/{operation_name}:cancel"
|
|
|
|
auth_info = await get_provider_auth(endpoint, key)
|
|
if auth_info:
|
|
headers.pop("x-goog-api-key", None)
|
|
headers[auth_info.auth_header] = auth_info.auth_value
|
|
|
|
response = await _try_rust_cancel_response(
|
|
method="POST",
|
|
url=upstream_url,
|
|
request_headers=headers,
|
|
body={},
|
|
content_type="application/json",
|
|
)
|
|
if response.status_code >= 400:
|
|
return response
|
|
|
|
else:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Cancel not supported for provider format: {provider_format}",
|
|
)
|
|
|
|
now = datetime.now(timezone.utc)
|
|
task.status = VideoStatus.CANCELLED.value
|
|
task.completed_at = getattr(task, "completed_at", None) or now
|
|
task.updated_at = now
|
|
|
|
# Void Usage (no charge)
|
|
try:
|
|
voided = UsageService.finalize_void(
|
|
self.db,
|
|
request_id=task.request_id,
|
|
reason="cancelled_by_user",
|
|
finalized_at=task.completed_at,
|
|
)
|
|
if not voided:
|
|
logger.warning(
|
|
"Skip voiding video usage because billing is already terminal: task_id={} request_id={}",
|
|
getattr(task, "id", task_id),
|
|
getattr(task, "request_id", None),
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Failed to void usage for cancelled task={}: {}",
|
|
getattr(task, "id", task_id),
|
|
str(exc),
|
|
)
|
|
|
|
self.db.commit()
|
|
return None
|