refac
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user