mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
291 lines
9.6 KiB
Python
291 lines
9.6 KiB
Python
import asyncio
|
|
import uuid
|
|
from typing import AsyncIterator, List, Optional
|
|
|
|
from fastapi import APIRouter, Depends, Header, Security
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from app.schemas.openai import AnthropicErrorDetail as _SchemaAnthropicErrorDetail
|
|
from app.schemas.openai import AnthropicErrorResponse as _SchemaAnthropicErrorResponse
|
|
from app.schemas.openai import AnthropicMessagesRequest as _SchemaAnthropicMessagesRequest
|
|
from app.schemas.openai import AnthropicMessagesResponse as _SchemaAnthropicMessagesResponse
|
|
from app.schemas.openai import AnthropicTextBlock as _SchemaAnthropicTextBlock
|
|
from app.api.endpoints.openai import (
|
|
MODEL_ID,
|
|
_is_manager_unavailable,
|
|
_is_manager_queue_full,
|
|
_run_managed_agent,
|
|
)
|
|
from app.api.openai_utils import (
|
|
build_anthropic_messages,
|
|
build_prompt,
|
|
build_session_id,
|
|
)
|
|
from app.api.presentation.sse import build_sse_response, encode_named_event
|
|
from app.application.agent import get_running_agent_manager
|
|
from app.application.configuration import get_api_runtime_config_snapshot
|
|
from app.adapters.web.security.access import anthropic_api_key_header
|
|
from app.api.context import (
|
|
get_background_task_registry_compat,
|
|
resolve_background_task_registry,
|
|
)
|
|
from app.runtime.tasks import TaskRegistry
|
|
|
|
ANTHROPIC_ERROR_RESPONSES = {
|
|
400: {"model": _SchemaAnthropicErrorResponse, "description": "请求格式错误"},
|
|
401: {"model": _SchemaAnthropicErrorResponse, "description": "认证失败"},
|
|
422: {"model": _SchemaAnthropicErrorResponse, "description": "请求参数校验失败"},
|
|
500: {"model": _SchemaAnthropicErrorResponse, "description": "服务内部错误"},
|
|
503: {"model": _SchemaAnthropicErrorResponse, "description": "AI Agent 不可用"},
|
|
}
|
|
|
|
router = APIRouter(responses=ANTHROPIC_ERROR_RESPONSES)
|
|
|
|
SESSION_PREFIX = "anthropic:"
|
|
|
|
|
|
def _anthropic_error_response(
|
|
message: str,
|
|
status_code: int,
|
|
error_type: str = "invalid_request_error",
|
|
) -> JSONResponse:
|
|
return JSONResponse(
|
|
status_code=status_code,
|
|
content=_SchemaAnthropicErrorResponse(
|
|
error=_SchemaAnthropicErrorDetail(type=error_type, message=message)
|
|
).model_dump(),
|
|
)
|
|
|
|
|
|
def _check_auth(api_key: Optional[str]) -> Optional[JSONResponse]:
|
|
"""
|
|
Anthropic 兼容接口以 API_TOKEN 认证受信客户端,认证通过即按管理员级 Agent 集成处理。
|
|
"""
|
|
if not api_key or api_key != get_api_runtime_config_snapshot().api_token:
|
|
return _anthropic_error_response(
|
|
"invalid x-api-key",
|
|
401,
|
|
error_type="authentication_error",
|
|
)
|
|
return None
|
|
|
|
|
|
def _manager_execution_error(error: BaseException) -> JSONResponse:
|
|
"""把 AgentManager 稳定错误映射为 Anthropic 兼容错误响应。"""
|
|
if _is_manager_unavailable(error):
|
|
return _anthropic_error_response(
|
|
"MoviePilot AI agent is unavailable.",
|
|
503,
|
|
error_type="api_error",
|
|
)
|
|
if _is_manager_queue_full(error):
|
|
return _anthropic_error_response(
|
|
str(error),
|
|
429,
|
|
error_type="rate_limit_error",
|
|
)
|
|
return _anthropic_error_response(str(error), 500, error_type="api_error")
|
|
|
|
|
|
async def _stream_anthropic_response(
|
|
manager,
|
|
session_id: str,
|
|
user_id: str,
|
|
prompt: str,
|
|
images: List[str],
|
|
task_registry: TaskRegistry | None = None,
|
|
) -> AsyncIterator[str]:
|
|
event_queue: asyncio.Queue = asyncio.Queue()
|
|
|
|
message_id = f"msg_{uuid.uuid4().hex}"
|
|
|
|
async def _run_agent():
|
|
try:
|
|
await _run_managed_agent(
|
|
manager=manager,
|
|
session_id=session_id,
|
|
user_id=user_id,
|
|
username="anthropic-client",
|
|
source="anthropic",
|
|
prompt=prompt,
|
|
images=images,
|
|
stream_mode=True,
|
|
event_queue=event_queue,
|
|
)
|
|
except asyncio.CancelledError:
|
|
await event_queue.put({"error": "MoviePilot AI agent is unavailable."})
|
|
except Exception as exc:
|
|
await event_queue.put({"error": str(exc)})
|
|
finally:
|
|
await event_queue.put(None)
|
|
|
|
task = resolve_background_task_registry(task_registry).create(
|
|
_run_agent(),
|
|
owner="api.anthropic.stream",
|
|
)
|
|
try:
|
|
yield encode_named_event(
|
|
"message_start",
|
|
{
|
|
"type": "message_start",
|
|
"message": {
|
|
"id": message_id,
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [],
|
|
"model": MODEL_ID,
|
|
"stop_reason": None,
|
|
"stop_sequence": None,
|
|
"usage": {"input_tokens": 0, "output_tokens": 0},
|
|
},
|
|
},
|
|
)
|
|
yield encode_named_event(
|
|
"content_block_start",
|
|
{
|
|
"type": "content_block_start",
|
|
"index": 0,
|
|
"content_block": {"type": "text", "text": ""},
|
|
},
|
|
)
|
|
while True:
|
|
item = await event_queue.get()
|
|
if item is None:
|
|
break
|
|
if isinstance(item, dict) and item.get("error"):
|
|
yield encode_named_event(
|
|
"error",
|
|
{
|
|
"type": "error",
|
|
"error": {
|
|
"type": "api_error",
|
|
"message": str(item["error"]),
|
|
},
|
|
},
|
|
)
|
|
yield encode_named_event("message_stop", {"type": "message_stop"})
|
|
return
|
|
text = str(item or "")
|
|
if not text:
|
|
continue
|
|
yield encode_named_event(
|
|
"content_block_delta",
|
|
{
|
|
"type": "content_block_delta",
|
|
"index": 0,
|
|
"delta": {"type": "text_delta", "text": text},
|
|
},
|
|
)
|
|
yield encode_named_event(
|
|
"content_block_stop",
|
|
{"type": "content_block_stop", "index": 0},
|
|
)
|
|
yield encode_named_event(
|
|
"message_delta",
|
|
{
|
|
"type": "message_delta",
|
|
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
|
|
"usage": {"output_tokens": 0},
|
|
},
|
|
)
|
|
yield encode_named_event("message_stop", {"type": "message_stop"})
|
|
finally:
|
|
await manager.clear_session(session_id=session_id, user_id=user_id)
|
|
if not task.done():
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
|
|
@router.post(
|
|
"/messages",
|
|
summary="Anthropic compatible messages",
|
|
response_model=_SchemaAnthropicMessagesResponse,
|
|
responses={
|
|
200: {
|
|
"description": "Anthropic message 或 SSE 数据流",
|
|
"content": {
|
|
"text/event-stream": {"schema": {"type": "string"}},
|
|
},
|
|
}
|
|
},
|
|
)
|
|
async def messages(
|
|
payload: _SchemaAnthropicMessagesRequest,
|
|
x_api_key: Optional[str] = Security(anthropic_api_key_header),
|
|
anthropic_version: Optional[str] = Header(default=None, alias="anthropic-version"),
|
|
task_registry: TaskRegistry = Depends(get_background_task_registry_compat),
|
|
):
|
|
auth_error = _check_auth(x_api_key)
|
|
if auth_error:
|
|
return auth_error
|
|
|
|
if not get_api_runtime_config_snapshot().ai_agent_enable:
|
|
return _anthropic_error_response(
|
|
"MoviePilot AI agent is disabled.",
|
|
503,
|
|
error_type="api_error",
|
|
)
|
|
manager = get_running_agent_manager()
|
|
if manager is None:
|
|
return _anthropic_error_response(
|
|
"MoviePilot AI agent is unavailable.",
|
|
503,
|
|
error_type="api_error",
|
|
)
|
|
|
|
normalized_messages = build_anthropic_messages(payload.system, payload.messages)
|
|
try:
|
|
prompt, images = build_prompt(normalized_messages, use_server_session=False)
|
|
except ValueError as exc:
|
|
return _anthropic_error_response(str(exc), 400)
|
|
|
|
session_seed = anthropic_version or "anthropic"
|
|
session_id = build_session_id(f"{session_seed}:{uuid.uuid4().hex}", SESSION_PREFIX)
|
|
if payload.stream:
|
|
return build_sse_response(
|
|
_stream_anthropic_response(
|
|
manager=manager,
|
|
session_id=session_id,
|
|
user_id=session_id,
|
|
prompt=prompt,
|
|
images=images,
|
|
task_registry=task_registry,
|
|
),
|
|
)
|
|
|
|
collected_messages = []
|
|
try:
|
|
result, collected_messages = await _run_managed_agent(
|
|
manager=manager,
|
|
session_id=session_id,
|
|
user_id=session_id,
|
|
username="anthropic-client",
|
|
source="anthropic",
|
|
prompt=prompt,
|
|
images=images,
|
|
stream_mode=False,
|
|
)
|
|
except Exception as exc:
|
|
return _manager_execution_error(exc)
|
|
finally:
|
|
await manager.clear_session(session_id=session_id, user_id=session_id)
|
|
|
|
content = "\n\n".join(
|
|
message.strip()
|
|
for message in collected_messages
|
|
if message and message.strip()
|
|
).strip()
|
|
if not content and result:
|
|
content = str(result).strip()
|
|
if not content:
|
|
content = "未获得有效回复。"
|
|
|
|
return _SchemaAnthropicMessagesResponse(
|
|
id=f"msg_{uuid.uuid4().hex}",
|
|
content=[_SchemaAnthropicTextBlock(text=content)],
|
|
model=MODEL_ID,
|
|
)
|