diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 6d877e0ca..3f1536244 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -1442,6 +1442,16 @@ async def check_url(request: Request, call_next): scheme="Bearer", credentials=request.cookies.get("token") ) + # Fallback to x-api-key header for Anthropic Messages API routes + if request.state.token is None and request.headers.get("x-api-key"): + request_path = request.url.path + if request_path in ("/api/message", "/api/v1/messages"): + from fastapi.security import HTTPAuthorizationCredentials + + request.state.token = HTTPAuthorizationCredentials( + scheme="Bearer", credentials=request.headers.get("x-api-key") + ) + request.state.enable_api_keys = app.state.config.ENABLE_API_KEYS response = await call_next(request) process_time = int(time.time()) - start_time @@ -1893,6 +1903,69 @@ generate_chat_completions = chat_completion generate_chat_completion = chat_completion +################################## +# +# Anthropic Messages API Compatible Endpoint +# +################################## + + +from open_webui.utils.anthropic import ( + convert_anthropic_to_openai_payload, + convert_openai_to_anthropic_response, + openai_stream_to_anthropic_stream, +) + + +@app.post("/api/message") +@app.post("/api/v1/messages") # Anthropic Messages API compatible endpoint +async def generate_messages( + request: Request, + form_data: dict, + user=Depends(get_verified_user), +): + """ + Anthropic Messages API compatible endpoint. + + Accepts the Anthropic Messages API format, converts internally to OpenAI + Chat Completions format, routes through the existing chat completion + pipeline, then converts the response back to Anthropic Messages format. + + Supports both streaming and non-streaming requests. + All models configured in Open WebUI are accessible via this endpoint. + + Authentication: Supports both standard Authorization header and + Anthropic's x-api-key header (via middleware translation). + """ + # Convert Anthropic payload to OpenAI format + requested_model = form_data.get("model", "") + + openai_payload = convert_anthropic_to_openai_payload(form_data) + + # Route through the existing chat_completion handler + response = await chat_completion(request, openai_payload, user) + + # Convert response back to Anthropic format + if isinstance(response, StreamingResponse): + # Streaming response: wrap the generator to convert SSE format + return StreamingResponse( + openai_stream_to_anthropic_stream( + response.body_iterator, model=requested_model + ), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + ) + elif isinstance(response, dict): + return convert_openai_to_anthropic_response(response, model=requested_model) + else: + # Passthrough for error responses (JSONResponse, PlainTextResponse, etc.) + return response + + + @app.post("/api/chat/completed") async def chat_completed( request: Request, form_data: dict, user=Depends(get_verified_user) diff --git a/backend/open_webui/utils/anthropic.py b/backend/open_webui/utils/anthropic.py index 59236c32d..ba2498440 100644 --- a/backend/open_webui/utils/anthropic.py +++ b/backend/open_webui/utils/anthropic.py @@ -1,3 +1,4 @@ +import json import logging import aiohttp @@ -78,3 +79,372 @@ async def get_anthropic_models(url: str, key: str, user: UserModel = None) -> di return None return {"object": "list", "data": all_models} + + +############################## +# +# Anthropic Messages API Conversion Utilities +# +############################## + + +def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict: + """ + Convert an Anthropic Messages API request to OpenAI Chat Completions format. + + Anthropic format: + {model, messages: [{role, content}], system, max_tokens, ...} + OpenAI format: + {model, messages: [{role, content}], max_tokens, ...} + """ + openai_payload = {} + + # Model + openai_payload["model"] = anthropic_payload.get("model", "") + + # Build messages list + messages = [] + + # System prompt (Anthropic has it as top-level, OpenAI as a system message) + system = anthropic_payload.get("system") + if system: + if isinstance(system, str): + messages.append({"role": "system", "content": system}) + elif isinstance(system, list): + # Anthropic supports system as list of content blocks + text_parts = [] + for block in system: + if isinstance(block, dict) and block.get("type") == "text": + text_parts.append(block.get("text", "")) + elif isinstance(block, str): + text_parts.append(block) + messages.append({"role": "system", "content": "\n".join(text_parts)}) + + # Convert messages + for msg in anthropic_payload.get("messages", []): + role = msg.get("role", "user") + content = msg.get("content") + + if isinstance(content, str): + messages.append({"role": role, "content": content}) + elif isinstance(content, list): + # Convert Anthropic content blocks to OpenAI format + openai_content = [] + tool_calls = [] + + for block in content: + block_type = block.get("type", "text") + + if block_type == "text": + openai_content.append({ + "type": "text", + "text": block.get("text", ""), + }) + elif block_type == "image": + source = block.get("source", {}) + if source.get("type") == "base64": + media_type = source.get("media_type", "image/png") + data = source.get("data", "") + openai_content.append({ + "type": "image_url", + "image_url": { + "url": f"data:{media_type};base64,{data}", + }, + }) + elif source.get("type") == "url": + openai_content.append({ + "type": "image_url", + "image_url": {"url": source.get("url", "")}, + }) + elif block_type == "tool_use": + tool_calls.append({ + "id": block.get("id", ""), + "type": "function", + "function": { + "name": block.get("name", ""), + "arguments": json.dumps(block.get("input", {})) + if isinstance(block.get("input"), dict) + else str(block.get("input", "{}")), + }, + }) + elif block_type == "tool_result": + # Tool results become separate tool messages in OpenAI format + tool_content = block.get("content", "") + if isinstance(tool_content, list): + tool_text_parts = [] + for tc in tool_content: + if isinstance(tc, dict) and tc.get("type") == "text": + tool_text_parts.append(tc.get("text", "")) + tool_content = "\n".join(tool_text_parts) + + messages.append({ + "role": "tool", + "tool_call_id": block.get("tool_use_id", ""), + "content": tool_content, + }) + + # Build the message + if tool_calls: + # Assistant message with tool calls + msg_dict = {"role": role} + if openai_content: + # If there's only text, flatten it + if len(openai_content) == 1 and openai_content[0]["type"] == "text": + msg_dict["content"] = openai_content[0]["text"] + else: + msg_dict["content"] = openai_content + else: + msg_dict["content"] = "" + msg_dict["tool_calls"] = tool_calls + messages.append(msg_dict) + elif openai_content: + # If there's only a single text block, flatten it to a string + if len(openai_content) == 1 and openai_content[0]["type"] == "text": + messages.append({"role": role, "content": openai_content[0]["text"]}) + else: + messages.append({"role": role, "content": openai_content}) + else: + messages.append({"role": role, "content": str(content) if content else ""}) + + openai_payload["messages"] = messages + + # max_tokens + if "max_tokens" in anthropic_payload: + openai_payload["max_tokens"] = anthropic_payload["max_tokens"] + + # Common parameters + for param in ("temperature", "top_p", "stop_sequences", "stream"): + if param in anthropic_payload: + if param == "stop_sequences": + openai_payload["stop"] = anthropic_payload[param] + else: + openai_payload[param] = anthropic_payload[param] + + # Tools conversion: Anthropic → OpenAI + if "tools" in anthropic_payload: + openai_tools = [] + for tool in anthropic_payload["tools"]: + openai_tools.append({ + "type": "function", + "function": { + "name": tool.get("name", ""), + "description": tool.get("description", ""), + "parameters": tool.get("input_schema", {}), + }, + }) + openai_payload["tools"] = openai_tools + + # tool_choice + if "tool_choice" in anthropic_payload: + tc = anthropic_payload["tool_choice"] + if isinstance(tc, dict): + tc_type = tc.get("type", "auto") + if tc_type == "auto": + openai_payload["tool_choice"] = "auto" + elif tc_type == "any": + openai_payload["tool_choice"] = "required" + elif tc_type == "tool": + openai_payload["tool_choice"] = { + "type": "function", + "function": {"name": tc.get("name", "")}, + } + + return openai_payload + + +def convert_openai_to_anthropic_response( + openai_response: dict, model: str = "" +) -> dict: + """ + Convert a non-streaming OpenAI Chat Completions response to Anthropic Messages format. + """ + import uuid as _uuid + + choice = {} + if openai_response.get("choices"): + choice = openai_response["choices"][0] + + message = choice.get("message", {}) + finish_reason = choice.get("finish_reason", "stop") + + # Map finish_reason to stop_reason + stop_reason_map = { + "stop": "end_turn", + "length": "max_tokens", + "tool_calls": "tool_use", + "content_filter": "end_turn", + } + stop_reason = stop_reason_map.get(finish_reason, "end_turn") + + # Build content blocks + content = [] + msg_content = message.get("content") + if msg_content: + content.append({"type": "text", "text": msg_content}) + + # Tool calls → tool_use blocks + tool_calls = message.get("tool_calls", []) + for tc in tool_calls: + func = tc.get("function", {}) + try: + tool_input = json.loads(func.get("arguments", "{}")) + except (json.JSONDecodeError, TypeError): + tool_input = {} + content.append({ + "type": "tool_use", + "id": tc.get("id", f"toolu_{_uuid.uuid4().hex[:24]}"), + "name": func.get("name", ""), + "input": tool_input, + }) + + # Usage + openai_usage = openai_response.get("usage", {}) + usage = { + "input_tokens": openai_usage.get("prompt_tokens", 0), + "output_tokens": openai_usage.get("completion_tokens", 0), + } + + return { + "id": openai_response.get("id", f"msg_{_uuid.uuid4().hex[:24]}"), + "type": "message", + "role": "assistant", + "content": content, + "model": model or openai_response.get("model", ""), + "stop_reason": stop_reason, + "stop_sequence": None, + "usage": usage, + } + + +async def openai_stream_to_anthropic_stream( + openai_stream_generator, model: str = "" +): + """ + Convert an OpenAI SSE streaming response to Anthropic Messages SSE format. + + OpenAI sends: data: {"choices": [{"delta": {"content": "..."}}]} + Anthropic sends: event: content_block_delta\\ndata: {"type": "content_block_delta", ...} + """ + import uuid as _uuid + + msg_id = f"msg_{_uuid.uuid4().hex[:24]}" + input_tokens = 0 + output_tokens = 0 + block_started = False + stop_reason = "end_turn" + + # Emit message_start + message_start = { + "type": "message_start", + "message": { + "id": msg_id, + "type": "message", + "role": "assistant", + "content": [], + "model": model, + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 0, "output_tokens": 0}, + }, + } + yield f"event: message_start\ndata: {json.dumps(message_start)}\n\n".encode() + + try: + async for chunk in openai_stream_generator: + if isinstance(chunk, bytes): + chunk = chunk.decode("utf-8", errors="ignore") + + for line in chunk.strip().split("\n"): + line = line.strip() + + if not line or not line.startswith("data:"): + continue + + data_str = line[5:].strip() + if data_str == "[DONE]": + continue + if data_str == "{}": + continue + + try: + data = json.loads(data_str) + except (json.JSONDecodeError, TypeError): + continue + + choices = data.get("choices", []) + if not choices: + # Check for usage in the final chunk + if data.get("usage"): + input_tokens = data["usage"].get("prompt_tokens", input_tokens) + output_tokens = data["usage"].get( + "completion_tokens", output_tokens + ) + continue + + delta = choices[0].get("delta", {}) + finish_reason = choices[0].get("finish_reason") + + # Update usage if present + if data.get("usage"): + input_tokens = data["usage"].get("prompt_tokens", input_tokens) + output_tokens = data["usage"].get( + "completion_tokens", output_tokens + ) + + content = delta.get("content") + if content is not None: + if not block_started: + # Start the content block + block_start = { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + } + yield f"event: content_block_start\ndata: {json.dumps(block_start)}\n\n".encode() + block_started = True + + # Send content delta + block_delta = { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": content}, + } + yield f"event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n".encode() + + # Handle tool calls in streaming + tool_calls = delta.get("tool_calls") + if tool_calls: + # Tool calls in streaming are more complex; + # for now we pass through the text content + pass + + if finish_reason is not None: + stop_reason_map = { + "stop": "end_turn", + "length": "max_tokens", + "tool_calls": "tool_use", + } + stop_reason = stop_reason_map.get(finish_reason, "end_turn") + + except Exception as e: + log.error(f"Error in Anthropic stream conversion: {e}") + + # Close content block if one was started + if block_started: + block_stop = {"type": "content_block_stop", "index": 0} + yield f"event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n".encode() + + # Emit message_delta with stop reason + message_delta = { + "type": "message_delta", + "delta": { + "stop_reason": stop_reason, + "stop_sequence": None, + }, + "usage": {"output_tokens": output_tokens}, + } + yield f"event: message_delta\ndata: {json.dumps(message_delta)}\n\n".encode() + + # Emit message_stop + yield f"event: message_stop\ndata: {json.dumps({'type': 'message_stop'})}\n\n".encode() + diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index 27af3631e..a12c6db88 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -290,6 +290,10 @@ async def get_current_user( if token is None and "token" in request.cookies: token = request.cookies.get("token") + # Fallback to request.state.token (set by middleware, e.g. for x-api-key) + if token is None and hasattr(request.state, "token") and request.state.token: + token = request.state.token.credentials + if token is None: raise HTTPException(status_code=401, detail="Not authenticated")