-
Notifications
You must be signed in to change notification settings - Fork 302
Add Anthropic Messages API (/v1/messages) endpoint #1369
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -300,6 +300,66 @@ async def completions(raw_request: Request): | |
| """ | ||
| return await handle_openai_request(raw_request, endpoint="/completions") | ||
|
|
||
| @app.post("/v1/messages") | ||
| async def anthropic_messages(raw_request: Request): | ||
| """Anthropic-compatible Messages API endpoint.""" | ||
|
Comment on lines
+303
to
+305
|
||
| try: | ||
| request_json = await raw_request.json() | ||
|
|
||
| if _global_inference_engine_client is None: | ||
| return JSONResponse( | ||
| content={"error": {"message": "Inference engine client not initialized", "type": "internal_error"}}, | ||
| status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, | ||
| ) | ||
| if "model" not in request_json: | ||
| return JSONResponse( | ||
| content={"error": {"message": "The field `model` is required", "type": "invalid_request_error"}}, | ||
| status_code=HTTPStatus.BAD_REQUEST.value, | ||
| ) | ||
| messages = request_json.get("messages") | ||
| if not isinstance(messages, list) or not messages: | ||
| return JSONResponse( | ||
| content={"error": {"message": "The field `messages` is required, must be a non-empty list", "type": "invalid_request_error"}}, | ||
| status_code=HTTPStatus.BAD_REQUEST.value, | ||
| ) | ||
|
|
||
| payload = { | ||
| "json": request_json, | ||
| "headers": dict(raw_request.headers) if hasattr(raw_request, "headers") else {}, | ||
| } | ||
| anthropic_response = await _global_inference_engine_client.anthropic_messages(payload) | ||
|
|
||
| if "error" in anthropic_response or anthropic_response.get("object", "") == "error": | ||
| if "error" in anthropic_response: | ||
| error = anthropic_response["error"] | ||
| error_code = error.get("code") | ||
| error_type = error.get("type", "internal_error") | ||
| else: | ||
| error_code = anthropic_response.get("code") | ||
| error_type = anthropic_response.get("type", "internal_error") | ||
| # Prefer numeric error code if available, fall back to type-based mapping | ||
| if isinstance(error_code, int): | ||
| status_code = error_code | ||
| elif isinstance(error_code, str) and error_code.isdigit(): | ||
| status_code = int(error_code) | ||
| else: | ||
| status_code = HTTPStatus.BAD_REQUEST.value if error_type == "invalid_request_error" else HTTPStatus.INTERNAL_SERVER_ERROR.value | ||
| return JSONResponse(content=anthropic_response, status_code=status_code) | ||
|
|
||
| return JSONResponse(content=anthropic_response) | ||
|
|
||
| except json.JSONDecodeError as e: | ||
| return JSONResponse( | ||
| content={"error": {"message": f"Invalid JSON: {str(e)}", "type": "invalid_request_error"}}, | ||
| status_code=HTTPStatus.BAD_REQUEST.value, | ||
| ) | ||
| except Exception as e: | ||
| logger.error(f"Error in /v1/messages: {e}\n{traceback.format_exc()}") | ||
| return JSONResponse( | ||
| content={"error": {"message": f"Internal error: {str(e)}", "type": "internal_error"}}, | ||
| status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, | ||
| ) | ||
|
|
||
| # Health check endpoint | ||
| # All inference engine replicas are initialized before creating `InferenceEngineClient`, and thus | ||
| # we can start receiving requests as soon as the FastAPI server starts | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -228,6 +228,17 @@ async def completion(self, request_payload: Dict[str, Any]) -> Dict[str, Any]: | |||||
|
|
||||||
| return response | ||||||
|
|
||||||
| async def anthropic_messages(self, request_payload: Dict[str, Any]) -> Dict[str, Any]: | ||||||
| """Call Anthropic Messages API endpoint (/v1/messages).""" | ||||||
| body = request_payload.get("json", {}) | ||||||
| headers = {"Content-Type": "application/json"} | ||||||
| async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=None)) as session: | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Disabling the timeout by setting
Suggested change
|
||||||
| request_url = f"{self.url}/v1/messages" | ||||||
| async with session.post(request_url, json=body, headers=headers) as resp: | ||||||
| response = await resp.json() | ||||||
|
|
||||||
| return response | ||||||
|
|
||||||
| async def wake_up(self, *args: Any, **kwargs: Any): | ||||||
| async with aiohttp.ClientSession() as session: | ||||||
| resp = await session.post(f"{self.url}/wake_up", json={"tags": kwargs.get("tags", 1)}) | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -275,6 +275,10 @@ async def completion(self, request_payload: Dict[str, Any]) -> Dict[str, Any]: | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Only supported in AsyncVLLMInferenceEngine.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| raise NotImplementedError("`completion` is only supported in AsyncVLLMInferenceEngine.") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async def anthropic_messages(self, request_payload: Dict[str, Any]) -> Dict[str, Any]: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Only supported in AsyncVLLMInferenceEngine.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| raise NotImplementedError("`anthropic_messages` is only supported in AsyncVLLMInferenceEngine.") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async def wake_up(self, *args: Any, **kwargs: Any): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await asyncio.to_thread(self.llm.wake_up, tags=kwargs.get("tags", None)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -642,6 +646,108 @@ async def completion(self, request_payload: Dict[str, Any]) -> Dict[str, Any]: | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return await self._handle_openai_request(request_payload, endpoint="/completions") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async def anthropic_messages(self, request_payload: Dict[str, Any]) -> Dict[str, Any]: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Convert Anthropic Messages format to OpenAI chat completions and back.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| request_json = request_payload.get("json", {}) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| headers = request_payload.get("headers", {}) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if "model" not in request_json: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return {"error": {"message": "The field `model` is required", "type": "invalid_request_error"}} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| messages = request_json.get("messages") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not isinstance(messages, list) or not messages: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return {"error": {"message": "The field `messages` is required, must be a non-empty list", "type": "invalid_request_error"}} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| openai_request = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "model": request_json["model"], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "messages": [], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "stream": False, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if "system" in request_json and request_json["system"]: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| system_content = request_json["system"] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if isinstance(system_content, list): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| text_parts = [] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for block in system_content: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if block.get("type") == "text": | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| text_parts.append(block["text"]) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| system_content = "\n".join(text_parts) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| openai_request["messages"].append({"role": "system", "content": system_content}) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for msg in request_json["messages"]: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| content = msg["content"] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if isinstance(content, list): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| text_parts = [] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for block in content: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if block.get("type") == "text": | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| text_parts.append(block["text"]) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| content = "\n".join(text_parts) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+680
to
+684
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| openai_msg = {"role": msg["role"], "content": content} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| openai_request["messages"].append(openai_msg) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if "max_tokens" in request_json: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| openai_request["max_tokens"] = request_json["max_tokens"] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if "temperature" in request_json: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| openai_request["temperature"] = request_json["temperature"] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if "top_p" in request_json: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| openai_request["top_p"] = request_json["top_p"] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if "stop_sequences" in request_json: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| openai_request["stop"] = request_json["stop_sequences"] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| payload = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "json": openai_request, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "headers": headers, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| openai_response = await self.chat_completion(payload) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if "error" in openai_response or openai_response.get("object") == "error": | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Normalize to Anthropic error schema for consistent HTTP handler detection | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if "error" not in openai_response: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| openai_response = {"error": { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "message": openai_response.get("message", "Unknown upstream error"), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "type": openai_response.get("type", "internal_error"), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "code": openai_response.get("code"), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return openai_response | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+711
to
+712
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return openai_response | |
| # Normalize OpenAI/vLLM-style error into Anthropic Messages-style schema | |
| error_obj = openai_response.get("error") | |
| status: Optional[int] = None | |
| code: Optional[Any] = None | |
| err_type: Optional[str] = None | |
| message: str = "Unknown error from upstream OpenAI/vLLM backend." | |
| # Extract fields from nested "error" object if present | |
| if isinstance(error_obj, dict): | |
| message = error_obj.get("message", message) | |
| err_type = error_obj.get("type") or error_obj.get("error_type") or err_type | |
| code = error_obj.get("code") or error_obj.get("error_code") | |
| status = ( | |
| error_obj.get("status") | |
| or error_obj.get("status_code") | |
| ) | |
| # Fallback: look for error fields at the top level | |
| if isinstance(openai_response, dict): | |
| if message == "Unknown error from upstream OpenAI/vLLM backend.": | |
| message = openai_response.get("message", message) | |
| err_type = ( | |
| err_type | |
| or openai_response.get("type") | |
| or openai_response.get("error_type") | |
| ) | |
| code = code or openai_response.get("code") or openai_response.get("error_code") | |
| status = ( | |
| status | |
| or openai_response.get("status") | |
| or openai_response.get("status_code") | |
| ) | |
| if status is None: | |
| status = int(HTTPStatus.INTERNAL_SERVER_ERROR) | |
| if err_type is None: | |
| err_type = "internal_error" | |
| normalized_error: Dict[str, Any] = { | |
| "error": { | |
| "message": message, | |
| "type": err_type, | |
| }, | |
| "status": status, | |
| } | |
| if code is not None: | |
| normalized_error["error"]["code"] = code | |
| return normalized_error |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using
assertfor validating input data is risky because assertions can be disabled in production (with Python's-Oflag), which would silently bypass this check. This could lead to unexpected behavior. For validating user-provided data likesession_id, it's better to perform an explicit check and return an error that results in a 400 Bad Request. Following the error handling pattern seen elsewhere in this PR, you could return an error dictionary.