diff --git a/CHANGELOG.md b/CHANGELOG.md index 48a60634..2a35d723 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.103] - UNRELEASED + +### Added + +- **Built-in LiteLLM Proxy**: Open WebUI now ships with LiteLLM Proxy. +- **Image Generation Enhancements**: Advanced Settings + Image Preview Feature. + +### Fixed + +- Issue with RAG scan that stops loading documents as soon as it reaches a file with unsupported mime type (or any other exceptions). (#866) + +### Changed + +- Ollama is no longer required to run Open WebUI. +- Our documentation can be found here https://docs.openwebui.com/ + ## [0.1.102] - 2024-02-22 ### Added diff --git a/backend/.gitignore b/backend/.gitignore index 16180123..ea83b34f 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -6,6 +6,11 @@ uploads *.db _test Pipfile -data/* +!/data +/data/* +!/data/litellm +/data/litellm/* +!data/litellm/config.yaml + !data/config.json .webui_secret_key \ No newline at end of file diff --git a/backend/apps/images/main.py b/backend/apps/images/main.py index 39d3f96a..9322ea7f 100644 --- a/backend/apps/images/main.py +++ b/backend/apps/images/main.py @@ -49,7 +49,7 @@ async def toggle_enabled(request: Request, user=Depends(get_admin_user)): app.state.ENABLED = not app.state.ENABLED return app.state.ENABLED except Exception as e: - raise HTTPException(status_code=r.status_code, detail=ERROR_MESSAGES.DEFAULT(e)) + raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e)) class UrlUpdateForm(BaseModel): @@ -109,7 +109,8 @@ def get_models(user=Depends(get_current_user)): models = r.json() return models except Exception as e: - raise HTTPException(status_code=r.status_code, detail=ERROR_MESSAGES.DEFAULT(e)) + app.state.ENABLED = False + raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e)) @app.get("/models/default") @@ -120,7 +121,8 @@ async def get_default_model(user=Depends(get_admin_user)): return {"model": options["sd_model_checkpoint"]} except Exception as e: - raise HTTPException(status_code=r.status_code, detail=ERROR_MESSAGES.DEFAULT(e)) + app.state.ENABLED = False + raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e)) class UpdateModelForm(BaseModel): @@ -190,4 +192,4 @@ def generate_image( return r.json() except Exception as e: print(e) - raise HTTPException(status_code=r.status_code, detail=ERROR_MESSAGES.DEFAULT(e)) + raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e)) diff --git a/backend/config.py b/backend/config.py index fadae68c..b16913c1 100644 --- a/backend/config.py +++ b/backend/config.py @@ -83,8 +83,6 @@ for version in soup.find_all("h2"): # Find the next sibling that is a h3 tag (section title) current = version.find_next_sibling() - print(current) - while current and current.name != "h2": if current.name == "h3": section_title = current.get_text().lower() # e.g., "added", "fixed" diff --git a/backend/data/litellm/config.yaml b/backend/data/litellm/config.yaml new file mode 100644 index 00000000..7d9d2b72 --- /dev/null +++ b/backend/data/litellm/config.yaml @@ -0,0 +1,4 @@ +general_settings: {} +litellm_settings: {} +model_list: [] +router_settings: {} diff --git a/backend/main.py b/backend/main.py index 0be56752..a432e9ed 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,25 +2,31 @@ from bs4 import BeautifulSoup import json import markdown import time +import os +import sys - -from fastapi import FastAPI, Request +from fastapi import FastAPI, Request, Depends from fastapi.staticfiles import StaticFiles from fastapi import HTTPException +from fastapi.responses import JSONResponse from fastapi.middleware.wsgi import WSGIMiddleware from fastapi.middleware.cors import CORSMiddleware from starlette.exceptions import HTTPException as StarletteHTTPException +from litellm.proxy.proxy_server import ProxyConfig, initialize +from litellm.proxy.proxy_server import app as litellm_app + from apps.ollama.main import app as ollama_app from apps.openai.main import app as openai_app from apps.audio.main import app as audio_app from apps.images.main import app as images_app from apps.rag.main import app as rag_app - from apps.web.main import app as webui_app + from config import WEBUI_NAME, ENV, VERSION, CHANGELOG, FRONTEND_BUILD_DIR +from utils.utils import get_http_authorization_cred, get_current_user class SPAStaticFiles(StaticFiles): @@ -34,6 +40,21 @@ class SPAStaticFiles(StaticFiles): raise ex +proxy_config = ProxyConfig() + + +async def config(): + router, model_list, general_settings = await proxy_config.load_config( + router=None, config_file_path="./data/litellm/config.yaml" + ) + + await initialize(config="./data/litellm/config.yaml", telemetry=False) + + +async def startup(): + await config() + + app = FastAPI(docs_url="/docs" if ENV == "dev" else None, redoc_url=None) origins = ["*"] @@ -47,6 +68,11 @@ app.add_middleware( ) +@app.on_event("startup") +async def on_startup(): + await startup() + + @app.middleware("http") async def check_url(request: Request, call_next): start_time = int(time.time()) @@ -57,7 +83,23 @@ async def check_url(request: Request, call_next): return response +@litellm_app.middleware("http") +async def auth_middleware(request: Request, call_next): + auth_header = request.headers.get("Authorization", "") + + if ENV != "dev": + try: + user = get_current_user(get_http_authorization_cred(auth_header)) + print(user) + except Exception as e: + return JSONResponse(status_code=400, content={"detail": str(e)}) + + response = await call_next(request) + return response + + app.mount("/api/v1", webui_app) +app.mount("/litellm/api", litellm_app) app.mount("/ollama/api", ollama_app) app.mount("/openai/api", openai_app) diff --git a/backend/requirements.txt b/backend/requirements.txt index 56e1d36e..341369fc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -16,6 +16,9 @@ aiohttp peewee bcrypt +litellm +apscheduler + langchain langchain-community chromadb diff --git a/backend/utils/utils.py b/backend/utils/utils.py index c6d01814..7ae7e694 100644 --- a/backend/utils/utils.py +++ b/backend/utils/utils.py @@ -58,6 +58,17 @@ def extract_token_from_auth_header(auth_header: str): return auth_header[len("Bearer ") :] +def get_http_authorization_cred(auth_header: str): + try: + scheme, credentials = auth_header.split(" ") + return { + "scheme": scheme, + "credentials": credentials, + } + except: + raise ValueError(ERROR_MESSAGES.INVALID_TOKEN) + + def get_current_user( auth_token: HTTPAuthorizationCredentials = Depends(bearer_security), ): diff --git a/package.json b/package.json index 30549fdd..7938558d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.1.102", + "version": "0.1.103", "private": true, "scripts": { "dev": "vite dev --host", diff --git a/src/lib/apis/litellm/index.ts b/src/lib/apis/litellm/index.ts new file mode 100644 index 00000000..6466ee35 --- /dev/null +++ b/src/lib/apis/litellm/index.ts @@ -0,0 +1,148 @@ +import { LITELLM_API_BASE_URL } from '$lib/constants'; + +export const getLiteLLMModels = async (token: string = '') => { + let error = null; + + const res = await fetch(`${LITELLM_API_BASE_URL}/v1/models`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = `LiteLLM: ${err?.error?.message ?? 'Network Problem'}`; + return []; + }); + + if (error) { + throw error; + } + + const models = Array.isArray(res) ? res : res?.data ?? null; + + return models + ? models + .map((model) => ({ + id: model.id, + name: model.name ?? model.id, + external: true, + source: 'litellm' + })) + .sort((a, b) => { + return a.name.localeCompare(b.name); + }) + : models; +}; + +export const getLiteLLMModelInfo = async (token: string = '') => { + let error = null; + + const res = await fetch(`${LITELLM_API_BASE_URL}/model/info`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = `LiteLLM: ${err?.error?.message ?? 'Network Problem'}`; + return []; + }); + + if (error) { + throw error; + } + + const models = Array.isArray(res) ? res : res?.data ?? null; + + return models; +}; + +type AddLiteLLMModelForm = { + name: string; + model: string; + api_base: string; + api_key: string; + rpm: string; +}; + +export const addLiteLLMModel = async (token: string = '', payload: AddLiteLLMModelForm) => { + let error = null; + + const res = await fetch(`${LITELLM_API_BASE_URL}/model/new`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }, + body: JSON.stringify({ + model_name: payload.name, + litellm_params: { + model: payload.model, + ...(payload.api_base === '' ? {} : { api_base: payload.api_base }), + ...(payload.api_key === '' ? {} : { api_key: payload.api_key }), + ...(isNaN(parseInt(payload.rpm)) ? {} : { rpm: parseInt(payload.rpm) }) + } + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = `LiteLLM: ${err?.error?.message ?? 'Network Problem'}`; + return []; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const deleteLiteLLMModel = async (token: string = '', id: string) => { + let error = null; + + const res = await fetch(`${LITELLM_API_BASE_URL}/model/delete`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }, + body: JSON.stringify({ + id: id + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = `LiteLLM: ${err?.error?.message ?? 'Network Problem'}`; + return []; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/apis/ollama/index.ts b/src/lib/apis/ollama/index.ts index 954956ac..5fc8a5fe 100644 --- a/src/lib/apis/ollama/index.ts +++ b/src/lib/apis/ollama/index.ts @@ -128,9 +128,11 @@ export const getOllamaModels = async (token: string = '') => { throw error; } - return (res?.models ?? []).sort((a, b) => { - return a.name.localeCompare(b.name); - }); + return (res?.models ?? []) + .map((model) => ({ id: model.model, name: model.name ?? model.model, ...model })) + .sort((a, b) => { + return a.name.localeCompare(b.name); + }); }; // TODO: migrate to backend diff --git a/src/lib/apis/openai/index.ts b/src/lib/apis/openai/index.ts index f9173f70..41445581 100644 --- a/src/lib/apis/openai/index.ts +++ b/src/lib/apis/openai/index.ts @@ -163,7 +163,7 @@ export const getOpenAIModels = async (token: string = '') => { return models ? models - .map((model) => ({ name: model.id, external: true })) + .map((model) => ({ id: model.id, name: model.name ?? model.id, external: true })) .sort((a, b) => { return a.name.localeCompare(b.name); }) @@ -200,17 +200,21 @@ export const getOpenAIModelsDirect = async ( const models = Array.isArray(res) ? res : res?.data ?? null; return models - .map((model) => ({ name: model.id, external: true })) + .map((model) => ({ id: model.id, name: model.name ?? model.id, external: true })) .filter((model) => (base_url.includes('openai') ? model.name.includes('gpt') : true)) .sort((a, b) => { return a.name.localeCompare(b.name); }); }; -export const generateOpenAIChatCompletion = async (token: string = '', body: object) => { +export const generateOpenAIChatCompletion = async ( + token: string = '', + body: object, + url: string = OPENAI_API_BASE_URL +) => { let error = null; - const res = await fetch(`${OPENAI_API_BASE_URL}/chat/completions`, { + const res = await fetch(`${url}/chat/completions`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, diff --git a/src/lib/components/chat/ModelSelector.svelte b/src/lib/components/chat/ModelSelector.svelte index 81304cb4..8f478916 100644 --- a/src/lib/components/chat/ModelSelector.svelte +++ b/src/lib/components/chat/ModelSelector.svelte @@ -25,7 +25,7 @@ $: if (selectedModels.length > 0 && $models.length > 0) { selectedModels = selectedModels.map((model) => - $models.map((m) => m.name).includes(model) ? model : '' + $models.map((m) => m.id).includes(model) ? model : '' ); } @@ -45,7 +45,7 @@ {#if model.name === 'hr'}
{:else} - diff --git a/src/lib/components/chat/Settings/About.svelte b/src/lib/components/chat/Settings/About.svelte index 6933ed3b..60c0d716 100644 --- a/src/lib/components/chat/Settings/About.svelte +++ b/src/lib/components/chat/Settings/About.svelte @@ -38,16 +38,18 @@ -
+ {#if ollamaVersion} +
-
-
Ollama Version
-
-
- {ollamaVersion ?? 'N/A'} +
+
Ollama Version
+
+
+ {ollamaVersion ?? 'N/A'} +
-
+ {/if}
diff --git a/src/lib/components/chat/Settings/Connections.svelte b/src/lib/components/chat/Settings/Connections.svelte index 462d3005..db24c8a0 100644 --- a/src/lib/components/chat/Settings/Connections.svelte +++ b/src/lib/components/chat/Settings/Connections.svelte @@ -3,7 +3,7 @@ import { createEventDispatcher, onMount } from 'svelte'; const dispatch = createEventDispatcher(); - import { getOllamaAPIUrl, updateOllamaAPIUrl } from '$lib/apis/ollama'; + import { getOllamaAPIUrl, getOllamaVersion, updateOllamaAPIUrl } from '$lib/apis/ollama'; import { getOpenAIKey, getOpenAIUrl, updateOpenAIKey, updateOpenAIUrl } from '$lib/apis/openai'; import toast from 'svelte-french-toast'; @@ -15,6 +15,9 @@ let OPENAI_API_KEY = ''; let OPENAI_API_BASE_URL = ''; + let showOpenAI = false; + let showLiteLLM = false; + const updateOpenAIHandler = async () => { OPENAI_API_BASE_URL = await updateOpenAIUrl(localStorage.token, OPENAI_API_BASE_URL); OPENAI_API_KEY = await updateOpenAIKey(localStorage.token, OPENAI_API_KEY); @@ -24,11 +27,14 @@ const updateOllamaAPIUrlHandler = async () => { API_BASE_URL = await updateOllamaAPIUrl(localStorage.token, API_BASE_URL); - const _models = await getModels('ollama'); - if (_models.length > 0) { + const ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => { + return null; + }); + + if (ollamaVersion) { toast.success('Server connection verified'); - await models.set(_models); + await models.set(await getModels()); } }; @@ -42,7 +48,7 @@
{ updateOpenAIHandler(); dispatch('save'); @@ -53,81 +59,100 @@ // }); }} > -
-
Ollama API URL
-
-
- +
+
+
+
+
OpenAI API
+ +
+ + {#if showOpenAI} +
+
API Key
+
+
+ +
+
+
+ +
+
API Base URL
+
+
+ +
+
+
+ WebUI will make requests to '{OPENAI_API_BASE_URL}/chat' +
+
+ {/if}
- -
- -
- Trouble accessing Ollama? - - Click here for help. - -
-
- -
- -
-
-
OpenAI API Key
-
-
- -
+ + + +
-
-
-
OpenAI API Base URL
-
-
- -
-
- WebUI will make requests to '{OPENAI_API_BASE_URL}/chat' + Trouble accessing Ollama? + + Click here for help. +
diff --git a/src/lib/components/chat/Settings/Images.svelte b/src/lib/components/chat/Settings/Images.svelte index 94cb3213..cc827454 100644 --- a/src/lib/components/chat/Settings/Images.svelte +++ b/src/lib/components/chat/Settings/Images.svelte @@ -32,9 +32,11 @@ const getModels = async () => { models = await getDiffusionModels(localStorage.token).catch((error) => { toast.error(error); - return null; + return []; + }); + selectedModel = await getDefaultDiffusionModel(localStorage.token).catch((error) => { + return ''; }); - selectedModel = await getDefaultDiffusionModel(localStorage.token); }; const updateAUTOMATIC1111UrlHandler = async () => { diff --git a/src/lib/components/chat/Settings/Models.svelte b/src/lib/components/chat/Settings/Models.svelte index c7ca7908..455927df 100644 --- a/src/lib/components/chat/Settings/Models.svelte +++ b/src/lib/components/chat/Settings/Models.svelte @@ -2,14 +2,33 @@ import queue from 'async/queue'; import toast from 'svelte-french-toast'; - import { createModel, deleteModel, pullModel } from '$lib/apis/ollama'; + import { createModel, deleteModel, getOllamaVersion, pullModel } from '$lib/apis/ollama'; import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; import { WEBUI_NAME, models, user } from '$lib/stores'; import { splitStream } from '$lib/utils'; + import { onMount } from 'svelte'; + import { addLiteLLMModel, deleteLiteLLMModel, getLiteLLMModelInfo } from '$lib/apis/litellm'; export let getModels: Function; + let showLiteLLM = false; + let showLiteLLMParams = false; + + let liteLLMModelInfo = []; + + let liteLLMModel = ''; + let liteLLMModelName = ''; + let liteLLMAPIBase = ''; + let liteLLMAPIKey = ''; + let liteLLMRPM = ''; + + let deleteLiteLLMModelId = ''; + + $: liteLLMModelName = liteLLMModel; + // Models + let showExperimentalOllama = false; + let ollamaVersion = ''; const MAX_PARALLEL_DOWNLOADS = 3; const modelDownloadQueue = queue( (task: { modelName: string }, cb) => @@ -286,256 +305,184 @@ opts.callback({ success: true, modelName: opts.modelName }); } }; + + const addLiteLLMModelHandler = async () => { + if (!liteLLMModelInfo.find((info) => info.model_name === liteLLMModelName)) { + const res = await addLiteLLMModel(localStorage.token, { + name: liteLLMModelName, + model: liteLLMModel, + api_base: liteLLMAPIBase, + api_key: liteLLMAPIKey, + rpm: liteLLMRPM + }).catch((error) => { + toast.error(error); + return null; + }); + + if (res) { + if (res.message) { + toast.success(res.message); + } + } + } else { + toast.error(`Model ${liteLLMModelName} already exists.`); + } + + liteLLMModelName = ''; + liteLLMModel = ''; + liteLLMAPIBase = ''; + liteLLMAPIKey = ''; + liteLLMRPM = ''; + + liteLLMModelInfo = await getLiteLLMModelInfo(localStorage.token); + models.set(await getModels()); + }; + + const deleteLiteLLMModelHandler = async () => { + const res = await deleteLiteLLMModel(localStorage.token, deleteLiteLLMModelId).catch( + (error) => { + toast.error(error); + return null; + } + ); + + if (res) { + if (res.message) { + toast.success(res.message); + } + } + + deleteLiteLLMModelId = ''; + liteLLMModelInfo = await getLiteLLMModelInfo(localStorage.token); + models.set(await getModels()); + }; + + onMount(async () => { + ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => false); + liteLLMModelInfo = await getLiteLLMModelInfo(localStorage.token); + });
-
-
-
Pull a model from Ollama.com
-
-
- -
- -
- -
- To access the available model names for downloading, click here. -
- - {#if Object.keys(modelDownloadStatus).length > 0} - {#each Object.keys(modelDownloadStatus) as model} -
-
{model}
-
-
- {modelDownloadStatus[model].pullProgress ?? 0}% -
-
- {modelDownloadStatus[model].digest} -
-
-
- {/each} - {/if} -
- -
- -
-
Delete a model
-
-
- -
- -
-
- -
- - { - uploadModelHandler(); - }} - > -
-
- Upload a GGUF model (Experimental) -
- - -
- -
-
- {#if modelUploadMode === 'file'} -
- { - console.log(modelInputFile); - }} - accept=".gguf" - required - hidden - /> - - -
- {:else} -
- -
- {/if} -
- - {#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')} - +
+ +
+ To access the available model names for downloading, click here. +
+ + {#if Object.keys(modelDownloadStatus).length > 0} + {#each Object.keys(modelDownloadStatus) as model} +
+
{model}
+
+
+ {modelDownloadStatus[model].pullProgress ?? 0}% +
+
+ {modelDownloadStatus[model].digest} +
+
- {:else} + {/each} + {/if} +
+ +
+
Delete a model
+
+
+ +
+ +
+
+ +
+
+
Experimental
+ +
+
+ + {#if showExperimentalOllama} + { + uploadModelHandler(); + }} + > +
+
Upload a GGUF model
+ + +
+ +
+
+ {#if modelUploadMode === 'file'} +
+ { + console.log(modelInputFile); + }} + accept=".gguf" + required + hidden + /> + + +
+ {:else} +
+ +
+ {/if} +
+ + {#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')} + + {/if} +
+ + {#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')} +
+
+
Modelfile Content
+