This commit is contained in:
Timothy Jaeryang Baek
2026-02-19 16:29:19 -06:00
parent 6ac593209c
commit 91a0301c9e
3 changed files with 447 additions and 0 deletions
+73
View File
@@ -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)
+370
View File
@@ -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()
+4
View File
@@ -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")