This commit is contained in:
Timothy Jaeryang Baek 2024-12-11 03:38:45 -08:00
parent 4819199650
commit df48eac22b
2 changed files with 204 additions and 186 deletions

View File

@ -372,6 +372,7 @@ app.state.config.ENABLE_OLLAMA_API = ENABLE_OLLAMA_API
app.state.config.OLLAMA_BASE_URLS = OLLAMA_BASE_URLS app.state.config.OLLAMA_BASE_URLS = OLLAMA_BASE_URLS
app.state.config.OLLAMA_API_CONFIGS = OLLAMA_API_CONFIGS app.state.config.OLLAMA_API_CONFIGS = OLLAMA_API_CONFIGS
app.state.OLLAMA_MODELS = {}
######################################## ########################################
# #
@ -384,6 +385,7 @@ app.state.config.OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS
app.state.config.OPENAI_API_KEYS = OPENAI_API_KEYS app.state.config.OPENAI_API_KEYS = OPENAI_API_KEYS
app.state.config.OPENAI_API_CONFIGS = OPENAI_API_CONFIGS app.state.config.OPENAI_API_CONFIGS = OPENAI_API_CONFIGS
app.state.OPENAI_MODELS = {}
######################################## ########################################
# #
@ -607,6 +609,14 @@ app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = (
) )
########################################
#
# WEBUI
#
########################################
app.state.MODELS = {}
################################## ##################################
# #
# ChatCompletion Middleware # ChatCompletion Middleware
@ -1437,7 +1447,7 @@ async def get_all_base_models():
openai_models = openai_models["data"] openai_models = openai_models["data"]
if app.state.config.ENABLE_OLLAMA_API: if app.state.config.ENABLE_OLLAMA_API:
ollama_models = await get_ollama_models() ollama_models = await ollama.get_all_models()
ollama_models = [ ollama_models = [
{ {
"id": model["model"], "id": model["model"],

View File

@ -1,3 +1,7 @@
# TODO: Implement a more intelligent load balancing mechanism for distributing requests among multiple backend instances.
# Current implementation uses a simple round-robin approach (random.choice). Consider incorporating algorithms like weighted round-robin,
# least connections, or least response time for better resource utilization and performance optimization.
import asyncio import asyncio
import json import json
import logging import logging
@ -29,6 +33,16 @@ from starlette.background import BackgroundTask
from open_webui.models.models import Models from open_webui.models.models import Models
from open_webui.utils.misc import (
calculate_sha256,
)
from open_webui.utils.payload import (
apply_model_params_to_body_ollama,
apply_model_params_to_body_openai,
apply_model_system_prompt_to_body,
)
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access
from open_webui.config import ( from open_webui.config import (
@ -41,29 +55,114 @@ from open_webui.env import (
AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST, AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST,
BYPASS_MODEL_ACCESS_CONTROL, BYPASS_MODEL_ACCESS_CONTROL,
) )
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.misc import (
calculate_sha256,
)
from open_webui.utils.payload import (
apply_model_params_to_body_ollama,
apply_model_params_to_body_openai,
apply_model_system_prompt_to_body,
)
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["OLLAMA"]) log.setLevel(SRC_LOG_LEVELS["OLLAMA"])
router = APIRouter() ##########################################
#
# Utility functions
#
##########################################
# TODO: Implement a more intelligent load balancing mechanism for distributing requests among multiple backend instances.
# Current implementation uses a simple round-robin approach (random.choice). Consider incorporating algorithms like weighted round-robin, async def send_get_request(url, key=None):
# least connections, or least response time for better resource utilization and performance optimization. timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST)
try:
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get(
url, headers={**({"Authorization": f"Bearer {key}"} if key else {})}
) as response:
return await response.json()
except Exception as e:
# Handle connection error here
log.error(f"Connection error: {e}")
return None
async def send_post_request(
url: str,
payload: Union[str, bytes],
stream: bool = True,
key: Optional[str] = None,
content_type: Optional[str] = None,
):
async def cleanup_response(
response: Optional[aiohttp.ClientResponse],
session: Optional[aiohttp.ClientSession],
):
if response:
response.close()
if session:
await session.close()
r = None
try:
session = aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
)
r = await session.post(
url,
data=payload,
headers={
"Content-Type": "application/json",
**({"Authorization": f"Bearer {key}"} if key else {}),
},
)
r.raise_for_status()
if stream:
response_headers = dict(r.headers)
if content_type:
response_headers["Content-Type"] = content_type
return StreamingResponse(
r.content,
status_code=r.status,
headers=response_headers,
background=BackgroundTask(
cleanup_response, response=r, session=session
),
)
else:
res = await r.json()
await cleanup_response(r, session)
return res
except Exception as e:
detail = None
if r is not None:
try:
res = await r.json()
if "error" in res:
detail = f"Ollama: {res.get('error', 'Unknown error')}"
except Exception:
detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status if r else 500,
detail=detail if detail else "Open WebUI: Server Connection Error",
)
def get_api_key(url, configs):
parsed_url = urlparse(url)
base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
return configs.get(base_url, {}).get("key", None)
##########################################
#
# API routes
#
##########################################
router = APIRouter()
@router.head("/") @router.head("/")
@ -84,35 +183,31 @@ async def verify_connection(
url = form_data.url url = form_data.url
key = form_data.key key = form_data.key
headers = {} async with aiohttp.ClientSession(
if key: timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST)
headers["Authorization"] = f"Bearer {key}" ) as session:
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST)
async with aiohttp.ClientSession(timeout=timeout) as session:
try: try:
async with session.get(f"{url}/api/version", headers=headers) as r: async with session.get(
f"{url}/api/version",
headers={**({"Authorization": f"Bearer {key}"} if key else {})},
) as r:
if r.status != 200: if r.status != 200:
# Extract response error details if available detail = f"HTTP Error: {r.status}"
error_detail = f"HTTP Error: {r.status}"
res = await r.json() res = await r.json()
if "error" in res: if "error" in res:
error_detail = f"External Error: {res['error']}" detail = f"External Error: {res['error']}"
raise Exception(error_detail) raise Exception(detail)
response_data = await r.json()
return response_data
data = await r.json()
return data
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
# ClientError covers all aiohttp requests issues
log.exception(f"Client error: {str(e)}") log.exception(f"Client error: {str(e)}")
# Handle aiohttp-specific connection issues, timeout etc.
raise HTTPException( raise HTTPException(
status_code=500, detail="Open WebUI: Server Connection Error" status_code=500, detail="Open WebUI: Server Connection Error"
) )
except Exception as e: except Exception as e:
log.exception(f"Unexpected error: {e}") log.exception(f"Unexpected error: {e}")
# Generic error handler in case parsing JSON or other steps fail
error_detail = f"Unexpected error: {str(e)}" error_detail = f"Unexpected error: {str(e)}"
raise HTTPException(status_code=500, detail=error_detail) raise HTTPException(status_code=500, detail=error_detail)
@ -137,8 +232,8 @@ async def update_config(
request: Request, form_data: OllamaConfigForm, user=Depends(get_admin_user) request: Request, form_data: OllamaConfigForm, user=Depends(get_admin_user)
): ):
request.app.state.config.ENABLE_OLLAMA_API = form_data.ENABLE_OLLAMA_API request.app.state.config.ENABLE_OLLAMA_API = form_data.ENABLE_OLLAMA_API
request.app.state.config.OLLAMA_BASE_URLS = form_data.OLLAMA_BASE_URLS
request.app.state.config.OLLAMA_BASE_URLS = form_data.OLLAMA_BASE_URLS
request.app.state.config.OLLAMA_API_CONFIGS = form_data.OLLAMA_API_CONFIGS request.app.state.config.OLLAMA_API_CONFIGS = form_data.OLLAMA_API_CONFIGS
# Remove any extra configs # Remove any extra configs
@ -154,127 +249,26 @@ async def update_config(
} }
async def aiohttp_get(url, key=None):
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST)
try:
headers = {"Authorization": f"Bearer {key}"} if key else {}
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get(url, headers=headers) as response:
return await response.json()
except Exception as e:
# Handle connection error here
log.error(f"Connection error: {e}")
return None
def get_api_key(url, configs):
parsed_url = urlparse(url)
base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
return configs.get(base_url, {}).get("key", None)
async def cleanup_response(
response: Optional[aiohttp.ClientResponse],
session: Optional[aiohttp.ClientSession],
):
if response:
response.close()
if session:
await session.close()
async def post_streaming_url(
url: str,
payload: Union[str, bytes],
stream: bool = True,
key: Optional[str] = None,
content_type=None,
):
r = None
try:
session = aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
)
headers = {"Content-Type": "application/json"}
if key:
headers["Authorization"] = f"Bearer {key}"
r = await session.post(
url,
data=payload,
headers=headers,
)
r.raise_for_status()
if stream:
response_headers = dict(r.headers)
if content_type:
response_headers["Content-Type"] = content_type
return StreamingResponse(
r.content,
status_code=r.status,
headers=response_headers,
background=BackgroundTask(
cleanup_response, response=r, session=session
),
)
else:
res = await r.json()
await cleanup_response(r, session)
return res
except Exception as e:
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = await r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except Exception:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status if r else 500,
detail=error_detail,
)
def merge_models_lists(model_lists):
merged_models = {}
for idx, model_list in enumerate(model_lists):
if model_list is not None:
for model in model_list:
id = model["model"]
if id not in merged_models:
model["urls"] = [idx]
merged_models[id] = model
else:
merged_models[id]["urls"].append(idx)
return list(merged_models.values())
@cached(ttl=3) @cached(ttl=3)
async def get_all_models(): async def get_all_models(request: Request):
log.info("get_all_models()") log.info("get_all_models()")
if request.app.state.config.ENABLE_OLLAMA_API: if request.app.state.config.ENABLE_OLLAMA_API:
tasks = [] request_tasks = []
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS): for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS):
if url not in request.app.state.config.OLLAMA_API_CONFIGS: if url not in request.app.state.config.OLLAMA_API_CONFIGS:
tasks.append(aiohttp_get(f"{url}/api/tags")) request_tasks.append(send_get_request(f"{url}/api/tags"))
else: else:
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}) api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {})
enable = api_config.get("enable", True) enable = api_config.get("enable", True)
key = api_config.get("key", None) key = api_config.get("key", None)
if enable: if enable:
tasks.append(aiohttp_get(f"{url}/api/tags", key)) request_tasks.append(send_get_request(f"{url}/api/tags", key))
else: else:
tasks.append(asyncio.ensure_future(asyncio.sleep(0, None))) request_tasks.append(asyncio.ensure_future(asyncio.sleep(0, None)))
responses = await asyncio.gather(*tasks) responses = await asyncio.gather(*request_tasks)
for idx, response in enumerate(responses): for idx, response in enumerate(responses):
if response: if response:
@ -296,6 +290,21 @@ async def get_all_models():
for model in response.get("models", []): for model in response.get("models", []):
model["model"] = f"{prefix_id}.{model['model']}" model["model"] = f"{prefix_id}.{model['model']}"
def merge_models_lists(model_lists):
merged_models = {}
for idx, model_list in enumerate(model_lists):
if model_list is not None:
for model in model_list:
id = model["model"]
if id not in merged_models:
model["urls"] = [idx]
merged_models[id] = model
else:
merged_models[id]["urls"].append(idx)
return list(merged_models.values())
models = { models = {
"models": merge_models_lists( "models": merge_models_lists(
map( map(
@ -311,50 +320,7 @@ async def get_all_models():
return models return models
@router.get("/api/tags") async def get_filtered_models(models, user):
@router.get("/api/tags/{url_idx}")
async def get_ollama_tags(
request: Request, url_idx: Optional[int] = None, user=Depends(get_verified_user)
):
models = []
if url_idx is None:
models = await get_all_models()
else:
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
parsed_url = urlparse(url)
base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(base_url, {})
key = api_config.get("key", None)
headers = {}
if key:
headers["Authorization"] = f"Bearer {key}"
r = None
try:
r = requests.request(method="GET", url=f"{url}/api/tags", headers=headers)
r.raise_for_status()
models = r.json()
except Exception as e:
log.exception(e)
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except Exception:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
if user.role == "user" and not BYPASS_MODEL_ACCESS_CONTROL:
# Filter models based on user access control # Filter models based on user access control
filtered_models = [] filtered_models = []
for model in models.get("models", []): for model in models.get("models", []):
@ -364,7 +330,51 @@ async def get_ollama_tags(
user.id, type="read", access_control=model_info.access_control user.id, type="read", access_control=model_info.access_control
): ):
filtered_models.append(model) filtered_models.append(model)
models["models"] = filtered_models return filtered_models
@router.get("/api/tags")
@router.get("/api/tags/{url_idx}")
async def get_ollama_tags(
request: Request, url_idx: Optional[int] = None, user=Depends(get_verified_user)
):
models = []
if url_idx is None:
models = await get_all_models()
else:
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS)
r = None
try:
r = requests.request(
method="GET",
url=f"{url}/api/tags",
headers={**({"Authorization": f"Bearer {key}"} if key else {})},
)
r.raise_for_status()
models = r.json()
except Exception as e:
log.exception(e)
detail = None
if r is not None:
try:
res = r.json()
if "error" in res:
detail = f"Ollama: {res['error']}"
except Exception:
detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=detail if detail else "Open WebUI: Server Connection Error",
)
if user.role == "user" and not BYPASS_MODEL_ACCESS_CONTROL:
models["models"] = get_filtered_models(models, user)
return models return models
@ -376,7 +386,7 @@ async def get_ollama_versions(request: Request, url_idx: Optional[int] = None):
if url_idx is None: if url_idx is None:
# returns lowest version # returns lowest version
tasks = [ tasks = [
aiohttp_get( send_get_request(
f"{url}/api/version", f"{url}/api/version",
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}).get( request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}).get(
"key", None "key", None
@ -412,18 +422,19 @@ async def get_ollama_versions(request: Request, url_idx: Optional[int] = None):
return r.json() return r.json()
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
error_detail = "Open WebUI: Server Connection Error"
detail = None
if r is not None: if r is not None:
try: try:
res = r.json() res = r.json()
if "error" in res: if "error" in res:
error_detail = f"Ollama: {res['error']}" detail = f"Ollama: {res['error']}"
except Exception: except Exception:
error_detail = f"Ollama: {e}" detail = f"Ollama: {e}"
raise HTTPException( raise HTTPException(
status_code=r.status_code if r else 500, status_code=r.status_code if r else 500,
detail=error_detail, detail=detail if detail else "Open WebUI: Server Connection Error",
) )
else: else:
return {"version": False} return {"version": False}
@ -436,7 +447,7 @@ async def get_ollama_loaded_models(request: Request, user=Depends(get_verified_u
""" """
if request.app.state.config.ENABLE_OLLAMA_API: if request.app.state.config.ENABLE_OLLAMA_API:
tasks = [ tasks = [
aiohttp_get( send_get_request(
f"{url}/api/ps", f"{url}/api/ps",
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}).get( request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}).get(
"key", None "key", None
@ -469,7 +480,7 @@ async def pull_model(
# Admin should be able to pull models from any source # Admin should be able to pull models from any source
payload = {**form_data.model_dump(exclude_none=True), "insecure": True} payload = {**form_data.model_dump(exclude_none=True), "insecure": True}
return await post_streaming_url( return await send_post_request(
url=f"{url}/api/pull", url=f"{url}/api/pull",
payload=json.dumps(payload), payload=json.dumps(payload),
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS), key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
@ -505,7 +516,7 @@ async def push_model(
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
log.debug(f"url: {url}") log.debug(f"url: {url}")
return await post_streaming_url( return await send_post_request(
url=f"{url}/api/push", url=f"{url}/api/push",
payload=form_data.model_dump_json(exclude_none=True).encode(), payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS), key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
@ -531,7 +542,7 @@ async def create_model(
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
log.info(f"url: {url}") log.info(f"url: {url}")
return await post_streaming_url( return await send_post_request(
url=f"{url}/api/create", url=f"{url}/api/create",
payload=form_data.model_dump_json(exclude_none=True).encode(), payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS), key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
@ -941,7 +952,7 @@ async def generate_completion(
form_data.model = form_data.model.replace(f"{prefix_id}.", "") form_data.model = form_data.model.replace(f"{prefix_id}.", "")
log.info(f"url: {url}") log.info(f"url: {url}")
return await post_streaming_url( return await send_post_request(
url=f"{url}/api/generate", url=f"{url}/api/generate",
payload=form_data.model_dump_json(exclude_none=True).encode(), payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS), key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
@ -966,15 +977,13 @@ class GenerateChatCompletionForm(BaseModel):
async def get_ollama_url(url_idx: Optional[int], model: str): async def get_ollama_url(url_idx: Optional[int], model: str):
if url_idx is None: if url_idx is None:
model_list = await get_all_models() models = request.app.state.OLLAMA_MODELS
models = {model["model"]: model for model in model_list["models"]}
if model not in models: if model not in models:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model), detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model),
) )
url_idx = random.choice(models[model]["urls"]) url_idx = random.choice(models[model].get("urls", []))
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
return url return url
@ -1037,7 +1046,6 @@ async def generate_chat_completion(
payload["model"] = f"{payload['model']}:latest" payload["model"] = f"{payload['model']}:latest"
url = await get_ollama_url(url_idx, payload["model"]) url = await get_ollama_url(url_idx, payload["model"])
log.info(f"url: {url}")
log.debug(f"generate_chat_completion() - 2.payload = {payload}") log.debug(f"generate_chat_completion() - 2.payload = {payload}")
parsed_url = urlparse(url) parsed_url = urlparse(url)
@ -1048,7 +1056,7 @@ async def generate_chat_completion(
if prefix_id: if prefix_id:
payload["model"] = payload["model"].replace(f"{prefix_id}.", "") payload["model"] = payload["model"].replace(f"{prefix_id}.", "")
return await post_streaming_url( return await send_post_request(
url=f"{url}/api/chat", url=f"{url}/api/chat",
payload=json.dumps(payload), payload=json.dumps(payload),
stream=form_data.stream, stream=form_data.stream,
@ -1149,7 +1157,7 @@ async def generate_openai_completion(
if prefix_id: if prefix_id:
payload["model"] = payload["model"].replace(f"{prefix_id}.", "") payload["model"] = payload["model"].replace(f"{prefix_id}.", "")
return await post_streaming_url( return await send_post_request(
url=f"{url}/v1/completions", url=f"{url}/v1/completions",
payload=json.dumps(payload), payload=json.dumps(payload),
stream=payload.get("stream", False), stream=payload.get("stream", False),
@ -1223,7 +1231,7 @@ async def generate_openai_chat_completion(
if prefix_id: if prefix_id:
payload["model"] = payload["model"].replace(f"{prefix_id}.", "") payload["model"] = payload["model"].replace(f"{prefix_id}.", "")
return await post_streaming_url( return await send_post_request(
url=f"{url}/v1/chat/completions", url=f"{url}/v1/chat/completions",
payload=json.dumps(payload), payload=json.dumps(payload),
stream=payload.get("stream", False), stream=payload.get("stream", False),