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.agent.runtime_loader 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, )