diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index d0f38c2334e..00000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' ---- - -# Bug Report - -## Important Notes - -- **Before submitting a bug report**: Please check the Issues or Discussions section to see if a similar issue or feature request has already been posted. It's likely we're already tracking it! If you’re unsure, start a discussion post first. This will help us efficiently focus on improving the project. - -- **Collaborate respectfully**: We value a constructive attitude, so please be mindful of your communication. If negativity is part of your approach, our capacity to engage may be limited. We’re here to help if you’re open to learning and communicating positively. Remember, Open WebUI is a volunteer-driven project managed by a single maintainer and supported by contributors who also have full-time jobs. We appreciate your time and ask that you respect ours. - -- **Contributing**: If you encounter an issue, we highly encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout to maintain the quality and continuity of Open WebUI. - -- **Bug reproducibility**: If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a pip install with Python 3.11, it may require additional help from the community. In such cases, we will move it to the "issues" Discussions section due to our limited resources. We encourage the community to assist with these issues. Remember, it’s not that the issue doesn’t exist; we need your help! - -Note: Please remove the notes above when submitting your post. Thank you for your understanding and support! - ---- - -## Installation Method - -[Describe the method you used to install the project, e.g., git clone, Docker, pip, etc.] - -## Environment - -- **Open WebUI Version:** [e.g., v0.3.11] -- **Ollama (if applicable):** [e.g., v0.2.0, v0.1.32-rc1] - -- **Operating System:** [e.g., Windows 10, macOS Big Sur, Ubuntu 20.04] -- **Browser (if applicable):** [e.g., Chrome 100.0, Firefox 98.0] - -**Confirmation:** - -- [ ] I have read and followed all the instructions provided in the README.md. -- [ ] I am on the latest version of both Open WebUI and Ollama. -- [ ] I have included the browser console logs. -- [ ] I have included the Docker container logs. -- [ ] I have provided the exact steps to reproduce the bug in the "Steps to Reproduce" section below. - -## Expected Behavior: - -[Describe what you expected to happen.] - -## Actual Behavior: - -[Describe what actually happened.] - -## Description - -**Bug Summary:** -[Provide a brief but clear summary of the bug] - -## Reproduction Details - -**Steps to Reproduce:** -[Outline the steps to reproduce the bug. Be as detailed as possible.] - -## Logs and Screenshots - -**Browser Console Logs:** -[Include relevant browser console logs, if applicable] - -**Docker Container Logs:** -[Include relevant Docker container logs, if applicable] - -**Screenshots/Screen Recordings (if applicable):** -[Attach any relevant screenshots to help illustrate the issue] - -## Additional Information - -[Include any additional details that may help in understanding and reproducing the issue. This could include specific configurations, error messages, or anything else relevant to the bug.] - -## Note - -If the bug report is incomplete or does not follow the provided instructions, it may not be addressed. Please ensure that you have followed the steps outlined in the README.md and troubleshooting.md documents, and provide all necessary information for us to reproduce and address the issue. Thank you! diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 00000000000..feecd16c747 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,146 @@ +name: Bug Report +description: Create a detailed bug report to help us improve Open WebUI. +title: 'issue: ' +labels: ['bug', 'triage'] +assignees: [] +body: + - type: markdown + attributes: + value: | + # Bug Report + + ## Important Notes + + - **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) or [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project. + + - **Respectful collaboration**: Open WebUI is a volunteer-driven project with a single maintainer and contributors who also have full-time jobs. Please be constructive and respectful in your communication. + + - **Contributing**: If you encounter an issue, consider submitting a pull request or forking the project. We prioritize preventing contributor burnout to maintain Open WebUI's quality. + + - **Bug Reproducibility**: If a bug cannot be reproduced using a `:main` or `:dev` Docker setup or with `pip install` on Python 3.11, community assistance may be required. In such cases, we will move it to the "[Issues](https://github.com/open-webui/open-webui/discussions/categories/issues)" Discussions section. Your help is appreciated! + + - type: checkboxes + id: issue-check + attributes: + label: Check Existing Issues + description: Confirm that you’ve checked for existing reports before submitting a new one. + options: + - label: I have searched the existing issues and discussions. + required: true + - label: I am using the latest version of Open WebUI. + required: true + + - type: dropdown + id: installation-method + attributes: + label: Installation Method + description: How did you install Open WebUI? + options: + - Git Clone + - Pip Install + - Docker + - Other + validations: + required: true + + - type: input + id: open-webui-version + attributes: + label: Open WebUI Version + description: Specify the version (e.g., v0.3.11) + validations: + required: true + + - type: input + id: ollama-version + attributes: + label: Ollama Version (if applicable) + description: Specify the version (e.g., v0.2.0, or v0.1.32-rc1) + validations: + required: false + + - type: input + id: operating-system + attributes: + label: Operating System + description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04) + validations: + required: true + + - type: input + id: browser + attributes: + label: Browser (if applicable) + description: Specify the browser/version (e.g., Chrome 100.0, Firefox 98.0) + validations: + required: false + + - type: checkboxes + id: confirmation + attributes: + label: Confirmation + description: Ensure the following prerequisites have been met. + options: + - label: I have read and followed all instructions in `README.md`. + required: true + - label: I am using the latest version of **both** Open WebUI and Ollama. + required: true + - label: I have included the browser console logs. + required: true + - label: I have included the Docker container logs. + required: true + - label: I have listed steps to reproduce the bug in detail. + required: true + + - type: textarea + id: expected-behavior + attributes: + label: Expected Behavior + description: Describe what should have happened. + validations: + required: true + + - type: textarea + id: actual-behavior + attributes: + label: Actual Behavior + description: Describe what actually happened. + validations: + required: true + + - type: textarea + id: reproduction-steps + attributes: + label: Steps to Reproduce + description: Providing clear, step-by-step instructions helps us reproduce and fix the issue faster. If we can't reproduce it, we can't fix it. + placeholder: | + 1. Go to '...' + 2. Click on '...' + 3. Scroll down to '...' + 4. See the error message '...' + validations: + required: true + + - type: textarea + id: logs-screenshots + attributes: + label: Logs & Screenshots + description: Include relevant logs, errors, or screenshots to help diagnose the issue. + placeholder: 'Attach logs from the browser console, Docker logs, or error messages.' + validations: + required: true + + - type: textarea + id: additional-info + attributes: + label: Additional Information + description: Provide any extra details that may assist in understanding the issue. + validations: + required: false + + - type: markdown + attributes: + value: | + ## Note + If the bug report is incomplete or does not follow instructions, it may not be addressed. Ensure that you've followed all the **README.md** and **troubleshooting.md** guidelines, and provide all necessary information for us to reproduce the issue. + Thank you for contributing to Open WebUI! diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000000..3ba13e0cec6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 5d6e9d708d6..00000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' ---- - -# Feature Request - -## Important Notes - -- **Before submitting a report**: Please check the Issues or Discussions section to see if a similar issue or feature request has already been posted. It's likely we're already tracking it! If you’re unsure, start a discussion post first. This will help us efficiently focus on improving the project. - -- **Collaborate respectfully**: We value a constructive attitude, so please be mindful of your communication. If negativity is part of your approach, our capacity to engage may be limited. We’re here to help if you’re open to learning and communicating positively. Remember, Open WebUI is a volunteer-driven project managed by a single maintainer and supported by contributors who also have full-time jobs. We appreciate your time and ask that you respect ours. - -- **Contributing**: If you encounter an issue, we highly encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout to maintain the quality and continuity of Open WebUI. - -- **Bug reproducibility**: If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a pip install with Python 3.11, it may require additional help from the community. In such cases, we will move it to the "issues" Discussions section due to our limited resources. We encourage the community to assist with these issues. Remember, it’s not that the issue doesn’t exist; we need your help! - -Note: Please remove the notes above when submitting your post. Thank you for your understanding and support! - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 00000000000..2a326f65e46 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -0,0 +1,64 @@ +name: Feature Request +description: Suggest an idea for this project +title: 'feat: ' +labels: ['triage'] +body: + - type: markdown + attributes: + value: | + ## Important Notes + ### Before submitting + Please check the [Issues](https://github.com/open-webui/open-webui/issues) or [Discussions](https://github.com/open-webui/open-webui/discussions) to see if a similar request has been posted. + It's likely we're already tracking it! If you’re unsure, start a discussion post first. + This will help us efficiently focus on improving the project. + + ### Collaborate respectfully + We value a **constructive attitude**, so please be mindful of your communication. If negativity is part of your approach, our capacity to engage may be limited. We're here to help if you're **open to learning** and **communicating positively**. + + Remember: + - Open WebUI is a **volunteer-driven project** + - It's managed by a **single maintainer** + - It's supported by contributors who also have **full-time jobs** + + We appreciate your time and ask that you **respect ours**. + + + ### Contributing + If you encounter an issue, we highly encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout to maintain the quality and continuity of Open WebUI. + + ### Bug reproducibility + If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a `pip install` with Python 3.11, it may require additional help from the community. In such cases, we will move it to the "[issues](https://github.com/open-webui/open-webui/discussions/categories/issues)" Discussions section due to our limited resources. We encourage the community to assist with these issues. Remember, it’s not that the issue doesn’t exist; we need your help! + + - type: checkboxes + id: existing-issue + attributes: + label: Check Existing Issues + description: Please confirm that you've checked for existing similar requests + options: + - label: I have searched the existing issues and discussions. + required: true + - type: textarea + id: problem-description + attributes: + label: Problem Description + description: Is your feature request related to a problem? Please provide a clear and concise description of what the problem is. + placeholder: "Ex. I'm always frustrated when..." + validations: + required: true + - type: textarea + id: solution-description + attributes: + label: Desired Solution you'd like + description: Clearly describe what you want to happen. + validations: + required: true + - type: textarea + id: alternatives-considered + attributes: + label: Alternatives Considered + description: A clear and concise description of any alternative solutions or features you've considered. + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index af0a8ed0ee4..ed93957ea4a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,12 +1,26 @@ version: 2 updates: + - package-ecosystem: uv + directory: '/' + schedule: + interval: monthly + target-branch: 'dev' + - package-ecosystem: pip directory: '/backend' schedule: interval: monthly target-branch: 'dev' + + - package-ecosystem: npm + directory: '/' + schedule: + interval: monthly + target-branch: 'dev' + - package-ecosystem: 'github-actions' directory: '/' schedule: # Check for updates to GitHub Actions every week interval: monthly + target-branch: 'dev' diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2a45c2c16e4..7fc17cd01fb 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -9,9 +9,9 @@ - [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description. - [ ] **Documentation:** Have you updated relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs), or other documentation sources? - [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation? -- [ ] **Testing:** Have you written and run sufficient tests for validating the changes? +- [ ] **Testing:** Have you written and run sufficient tests to validate the changes? - [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards? -- [ ] **Prefix:** To cleary categorize this pull request, prefix the pull request title, using one of the following: +- [ ] **Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following: - **BREAKING CHANGE**: Significant changes that may affect compatibility - **build**: Changes that affect the build system or external dependencies - **ci**: Changes to our continuous integration processes or workflows @@ -22,7 +22,7 @@ - **i18n**: Internationalization or localization changes - **perf**: Performance improvement - **refactor**: Code restructuring for better maintainability, readability, or scalability - - **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc.) + - **style**: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc.) - **test**: Adding missing tests or correcting existing tests - **WIP**: Work in progress, a temporary label for incomplete or ongoing work diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml index 03dcf845567..e61a69f33ae 100644 --- a/.github/workflows/docker-build.yaml +++ b/.github/workflows/docker-build.yaml @@ -14,7 +14,7 @@ env: jobs: build-main-image: - runs-on: ubuntu-latest + runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} permissions: contents: read packages: write @@ -111,7 +111,7 @@ jobs: retention-days: 1 build-cuda-image: - runs-on: ubuntu-latest + runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} permissions: contents: read packages: write @@ -211,7 +211,7 @@ jobs: retention-days: 1 build-ollama-image: - runs-on: ubuntu-latest + runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} permissions: contents: read packages: write diff --git a/.github/workflows/format-backend.yaml b/.github/workflows/format-backend.yaml index 44587669753..1bcdd92c1db 100644 --- a/.github/workflows/format-backend.yaml +++ b/.github/workflows/format-backend.yaml @@ -5,10 +5,18 @@ on: branches: - main - dev + paths: + - 'backend/**' + - 'pyproject.toml' + - 'uv.lock' pull_request: branches: - main - dev + paths: + - 'backend/**' + - 'pyproject.toml' + - 'uv.lock' jobs: build: @@ -17,7 +25,9 @@ jobs: strategy: matrix: - python-version: [3.11] + python-version: + - 3.11.x + - 3.12.x steps: - uses: actions/checkout@v4 @@ -25,7 +35,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: '${{ matrix.python-version }}' - name: Install dependencies run: | diff --git a/.github/workflows/format-build-frontend.yaml b/.github/workflows/format-build-frontend.yaml index 53d3aaa5ec8..9a007581ffe 100644 --- a/.github/workflows/format-build-frontend.yaml +++ b/.github/workflows/format-build-frontend.yaml @@ -5,10 +5,18 @@ on: branches: - main - dev + paths-ignore: + - 'backend/**' + - 'pyproject.toml' + - 'uv.lock' pull_request: branches: - main - dev + paths-ignore: + - 'backend/**' + - 'pyproject.toml' + - 'uv.lock' jobs: build: @@ -21,7 +29,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' # Or specify any other version you want to use + node-version: '22' - name: Install Dependencies run: npm install diff --git a/CHANGELOG.md b/CHANGELOG.md index 93d9865f828..a11c2848ee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,154 @@ 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.6.2] - 2025-04-06 + +### Added + +- 🌍 **Improved Global Language Support**: Expanded and refined translations across multiple languages to enhance clarity and consistency for international users. + +### Fixed + +- 🛠️ **Accurate Tool Descriptions from OpenAPI Servers**: External tools now use full endpoint descriptions instead of summaries when generating tool specifications—helping AI models understand tool purpose more precisely and choose the right tool more accurately in tool workflows. +- 🔧 **Precise Web Results Source Attribution**: Fixed a key issue where all web search results showed the same source ID—now each result gets its correct and distinct source, ensuring accurate citations and traceability. +- 🔍 **Clean Web Search Retrieval**: Web search now retains only results from URLs where real content was successfully fetched—improving accuracy and removing empty or broken links from citations. +- 🎵 **Audio File Upload Response Restored**: Resolved an issue where uploading audio files did not return valid responses, restoring smooth file handling for transcription and audio-based workflows. + +### Changed + +- 🧰 **General Backend Refactoring**: Multiple behind-the-scenes improvements streamline backend performance, reduce complexity, and ensure a more stable, maintainable system overall—making everything smoother without changing your workflow. + +## [0.6.1] - 2025-04-05 + +### Added + +- 🛠️ **Global Tool Servers Configuration**: Admins can now centrally configure global external tool servers from Admin Settings > Tools, allowing seamless sharing of tool integrations across all users without manual setup per user. +- 🔐 **Direct Tool Usage Permission for Users**: Introduced a new user-level permission toggle that grants non-admin users access to direct external tools, empowering broader team collaboration while maintaining control. +- 🧠 **Mistral OCR Content Extraction Support**: Added native support for Mistral OCR as a high-accuracy document loader, drastically improving text extraction from scanned documents in RAG workflows. +- 🖼️ **Tools Indicator UI Redesign**: Enhanced message input now smartly displays both built-in and external tools via a unified dropdown, making it simpler and more intuitive to activate tools during conversations. +- 📄 **RAG Prompt Improved and More Coherent**: Default RAG system prompt has been revised to be more clear and citation-focused—admins can leave the template field empty to use this new gold-standard prompt. +- 🧰 **Performance & Developer Improvements**: Major internal restructuring of several tool-related components, simplifying styling and merging external/internal handling logic, resulting in better maintainability and performance. +- 🌍 **Improved Translations**: Updated translations for Tibetan, Polish, Chinese (Simplified & Traditional), Arabic, Russian, Ukrainian, Dutch, Finnish, and French to improve clarity and consistency across the interface. + +### Fixed + +- 🔑 **External Tool Server API Key Bug Resolved**: Fixed a critical issue where authentication headers were not being sent when calling tools from external OpenAPI tool servers, ensuring full security and smooth tool operations. +- 🚫 **Conditional Export Button Visibility**: UI now gracefully hides export buttons when there's nothing to export in models, prompts, tools, or functions, improving visual clarity and reducing confusion. +- 🧪 **Hybrid Search Failure Recovery**: Resolved edge case in parallel hybrid search where empty or unindexed collections caused backend crashes—these are now cleanly skipped to ensure system stability. +- 📂 **Admin Folder Deletion Fix**: Addressed an issue where folders created in the admin workspace couldn't be deleted, restoring full organizational flexibility for admins. +- 🔐 **Improved Generic Error Feedback on Login**: Authentication errors now show simplified, non-revealing messages for privacy and improved UX, especially with federated logins. +- 📝 **Tool Message with Images Improved**: Enhanced how tool-generated messages with image outputs are shown in chat, making them more readable and consistent with the overall UI design. +- ⚙️ **Auto-Exclusion for Broken RAG Collections**: Auto-skips document collections that fail to fetch data or return "None", preventing silent errors and streamlining retrieval workflows. +- 📝 **Docling Text File Handling Fix**: Fixed file parsing inconsistency that broke docling-based RAG functionality for certain plain text files, ensuring wider file compatibility. + +## [0.6.0] - 2025-03-31 + +### Added + +- 🧩 **External Tool Server Support via OpenAPI**: Connect Open WebUI to any OpenAPI-compatible REST server instantly—offering immediate integration with thousands of developer tools, SDKs, and SaaS systems for powerful extensibility. Learn more: https://github.com/open-webui/openapi-servers +- 🛠️ **MCP Server Support via MCPO**: You can now convert and expose your internal MCP tools as interoperable OpenAPI HTTP servers within Open WebUI for seamless, plug-n-play AI toolchain creation. Learn more: https://github.com/open-webui/mcpo +- 📨 **/messages Chat API Endpoint Support**: For power users building external AI systems, new endpoints allow precise control of messages asynchronously—feed long-running external responses into Open WebUI chats without coupling with the frontend. +- 📝 **Client-Side PDF Generation**: PDF exports are now generated fully client-side for drastically improved output quality—perfect for saving conversations or documents. +- 💼 **Enforced Temporary Chats Mode**: Admins can now enforce temporary chat sessions by default to align with stringent data retention and compliance requirements. +- 🌍 **Public Resource Sharing Permission Controls**: Fine-grained user group permissions now allow enabling/disabling public sharing for models, knowledge, prompts, and tools—ideal for privacy, team control, and internal deployments. +- 📦 **Custom pip Options for Tools/Functions**: You can now specify custom pip installation options with "PIP_OPTIONS", "PIP_PACKAGE_INDEX_OPTIONS" environment variables—improving compatibility, support for private indexes, and better control over Python environments. +- 🔢 **Editable Message Counter**: You can now double-click the message count number and jump straight to editing the index—quickly navigate complex chats or regenerate specific messages precisely. +- 🧠 **Embedding Prefix Support Added**: Add custom prefixes to your embeddings for instruct-style tokens, enabling stronger model alignment and more consistent RAG performance. +- 🙈 **Ability to Hide Base Models**: Optionally hide base models from the UI, helping users streamline model visibility and limit access to only usable endpoints.. +- 📚 **Docling Content Extraction Support**: Open WebUI now supports Docling as a content extraction engine, enabling smarter and more accurate parsing of complex file formats—ideal for advanced document understanding and Retrieval-Augmented Generation (RAG) workflows. +- 🗃️ **Redis Sentinel Support Added**: Enhance deployment redundancy with support for Redis Sentinel for highly available, failover-safe Redis-based caching or pub/sub. +- 📚 **JSON Schema Format for Ollama**: Added support for defining the format using JSON schema in Ollama-compatible models, improving flexibility and validation of model outputs. +- 🔍 **Chat Sidebar Search "Clear” Button**: Quickly clear search filters in chat sidebar using the new ✖️ button—streamline your chat navigation with one click. +- 🗂️ **Auto-Focus + Enter Submit for Folder Name**: When creating a new folder, the system automatically enters rename mode with name preselected—simplifying your org workflow. +- 🧱 **Markdown Alerts Rendering**: Blockquotes with syntax hinting (e.g. ⚠️, ℹ️, ✅) now render styled Markdown alert banners, making messages and documentation more visually structured. +- 🔁 **Hybrid Search Runs in Parallel Now**: Hybrid (BM25 + embedding) search components now run in parallel—dramatically reducing response times and speeding up document retrieval. +- 📋 **Cleaner UI for Tool Call Display**: Optimized the visual layout of called tools inside chat messages for better clarity and reduced visual clutter. +- 🧪 **Playwright Timeout Now Configurable**: Default timeout for Playwright processes is now shorter and adjustable via environment variables—making web scraping more robust and tunable to environments. +- 📈 **OpenTelemetry Support for Observability**: Open WebUI now integrates with OpenTelemetry, allowing you to connect with tools like Grafana, Jaeger, or Prometheus for detailed performance insights and real-time visibility—entirely opt-in and fully self-hosted. Even if enabled, no data is ever sent to us, ensuring your privacy and ownership over all telemetry data. +- 🛠 **General UI Enhancements & UX Polish**: Numerous refinements across sidebar, code blocks, modal interactions, button alignment, scrollbar visibility, and folder behavior improve overall fluidity and usability of the interface. +- 🧱 **General Backend Refactoring**: Numerous backend components have been refactored to improve stability, maintainability, and performance—ensuring a more consistent and reliable system across all features. +- 🌍 **Internationalization Language Support Updates**: Added Estonian and Galician languages, improved Spanish (fully revised), Traditional Chinese, Simplified Chinese, Turkish, Catalan, Ukrainian, and German for a more localized and inclusive interface. + +### Fixed + +- 🧑‍💻 **Firefox Input Height Bug**: Text input in Firefox now maintains proper height, ensuring message boxes look consistent and behave predictably. +- 🧾 **Tika Blank Line Bug**: PDFs processed with Apache Tika 3.1.0.0 no longer introduce excessive blank lines—improving RAG output quality and visual cleanliness. +- 🧪 **CSV Loader Encoding Issues**: CSV files with unknown encodings now automatically detect character sets, resolving import errors in non-UTF-8 datasets. +- ✅ **LDAP Auth Config Fix**: Path to certificate file is now optional for LDAP setups, fixing authentication trouble for users without preconfigured cert paths. +- 📥 **File Deletion in Bypass Mode**: Resolved issue where files couldn’t be deleted from knowledge when “bypass embedding” mode was enabled. +- 🧩 **Hybrid Search Result Sorting & Deduplication Fixed**: Fixed citation and sorting issues in RAG hybrid and reranker modes, ensuring retrieved documents are shown in correct order per score. +- 🧷 **Model Export/Import Broken for a Single Model**: Fixed bug where individual models couldn’t be exported or re-imported, restoring full portability. +- 📫 **Auth Redirect Fix**: Logged-in users are now routed properly without unnecessary login prompts when already authenticated. + +### Changed + +- 🧠 **Prompt Autocompletion Disabled By Default**: Autocomplete suggestions while typing are now disabled unless explicitly re-enabled in user preferences—reduces distractions while composing prompts for advanced users. +- 🧾 **Normalize Citation Numbering**: Source citations now properly begin from "1" instead of "0"—improving consistency and professional presentation in AI outputs. +- 📚 **Improved Error Handling from Pipelines**: Pipelines now show the actual returned error message from failed tasks rather than generic "Connection closed"—making debugging far more user-friendly. + +### Removed + +- 🧾 **ENABLE_AUDIT_LOGS Setting Removed**: Deprecated setting “ENABLE_AUDIT_LOGS” has been fully removed—now controlled via “AUDIT_LOG_LEVEL” instead. + +## [0.5.20] - 2025-03-05 + +### Added + +- **⚡ Toggle Code Execution On/Off**: You can now enable or disable code execution, providing more control over security, ensuring a safer and more customizable experience. + +### Fixed + +- **📜 Pinyin Keyboard Enter Key Now Works Properly**: Resolved an issue where the Enter key for Pinyin keyboards was not functioning as expected, ensuring seamless input for Chinese users. +- **🖼️ Web Manifest Loading Issue Fixed**: Addressed inconsistencies with 'site.webmanifest', guaranteeing proper loading and representation of the app across different browsers and devices. +- **📦 Non-Root Container Issue Resolved**: Fixed a critical issue where the UI failed to load correctly in non-root containers, ensuring reliable deployment in various environments. + +## [0.5.19] - 2025-03-04 + +### Added + +- **📊 Logit Bias Parameter Support**: Fine-tune conversation dynamics by adjusting the Logit Bias parameter directly in chat settings, giving you more control over model responses. +- **⌨️ Customizable Enter Behavior**: You can now configure Enter to send messages only when combined with Ctrl (Ctrl+Enter) via Settings > Interface, preventing accidental message sends. +- **📝 Collapsible Code Blocks**: Easily collapse long code blocks to declutter your chat, making it easier to focus on important details. +- **🏷️ Tag Selector in Model Selector**: Quickly find and categorize models with the new tag filtering system in the Model Selector, streamlining model discovery. +- **📈 Experimental Elasticsearch Vector DB Support**: Now supports Elasticsearch as a vector database, offering more flexibility for data retrieval in Retrieval-Augmented Generation (RAG) workflows. +- **⚙️ General Reliability Enhancements**: Various stability improvements across the WebUI, ensuring a smoother, more consistent experience. +- **🌍 Updated Translations**: Refined multilingual support for better localization and accuracy across various languages. + +### Fixed + +- **🔄 "Stream" Hook Activation**: Fixed an issue where the "Stream" hook only worked when globally enabled, ensuring reliable real-time filtering. +- **📧 LDAP Email Case Sensitivity**: Resolved an issue where LDAP login failed due to email case sensitivity mismatches, improving authentication reliability. +- **💬 WebSocket Chat Event Registration**: Fixed a bug preventing chat event listeners from being registered upon sign-in, ensuring real-time updates work properly. + +## [0.5.18] - 2025-02-27 + +### Fixed + +- **🌐 Open WebUI Now Works Over LAN in Insecure Context**: Resolved an issue preventing Open WebUI from functioning when accessed over a local network in an insecure context, ensuring seamless connectivity. +- **🔄 UI Now Reflects Deleted Connections Instantly**: Fixed an issue where deleting a connection did not update the UI in real time, ensuring accurate system state visibility. +- **🛠️ Models Now Display Correctly with ENABLE_FORWARD_USER_INFO_HEADERS**: Addressed a bug where models were not visible when ENABLE_FORWARD_USER_INFO_HEADERS was set, restoring proper model listing. + +## [0.5.17] - 2025-02-27 + +### Added + +- **🚀 Instant Document Upload with Bypass Embedding & Retrieval**: Admins can now enable "Bypass Embedding & Retrieval" in Admin Settings > Documents, significantly speeding up document uploads and ensuring full document context is retained without chunking. +- **🔎 "Stream" Hook for Real-Time Filtering**: The new "stream" hook allows dynamic real-time message filtering. Learn more in our documentation (https://docs.openwebui.com/features/plugin/functions/filter). +- **☁️ OneDrive Integration**: Early support for OneDrive storage integration has been introduced, expanding file import options. +- **📈 Enhanced Logging with Loguru**: Backend logging has been improved with Loguru, making debugging and issue tracking far more efficient. +- **⚙️ General Stability Enhancements**: Backend and frontend refactoring improves performance, ensuring a smoother and more reliable user experience. +- **🌍 Updated Translations**: Refined multilingual support for better localization and accuracy across various languages. + +### Fixed + +- **🔄 Reliable Model Imports from the Community Platform**: Resolved import failures, allowing seamless integration of community-shared models without errors. +- **📊 OpenAI Usage Statistics Restored**: Fixed an issue where OpenAI usage metrics were not displaying correctly, ensuring accurate tracking of usage data. +- **🗂️ Deduplication for Retrieved Documents**: Documents retrieved during searches are now intelligently deduplicated, meaning no more redundant results—helping to keep information concise and relevant. + +### Changed + +- **📝 "Full Context Mode" Renamed for Clarity**: The "Full Context Mode" toggle in Web Search settings is now labeled "Bypass Embedding & Retrieval" for consistency across the UI. + ## [0.5.16] - 2025-02-20 ### Fixed diff --git a/Dockerfile b/Dockerfile index 03d8c687853..9d432b628f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -132,7 +132,7 @@ RUN if [ "$USE_OLLAMA" = "true" ]; then \ # install python dependencies COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt -RUN pip3 install uv && \ +RUN pip3 install --no-cache-dir uv && \ if [ "$USE_CUDA" = "true" ]; then \ # If you use CUDA the whisper and embedding model will be downloaded on first use pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \ diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 5e0e4f0a179..8238f8a87ee 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -3,13 +3,13 @@ import os import shutil import base64 +import redis from datetime import datetime from pathlib import Path from typing import Generic, Optional, TypeVar from urllib.parse import urlparse -import chromadb import requests from pydantic import BaseModel from sqlalchemy import JSON, Column, DateTime, Integer, func @@ -18,6 +18,9 @@ DATA_DIR, DATABASE_URL, ENV, + REDIS_URL, + REDIS_SENTINEL_HOSTS, + REDIS_SENTINEL_PORT, FRONTEND_BUILD_DIR, OFFLINE_MODE, OPEN_WEBUI_DIR, @@ -27,6 +30,7 @@ log, ) from open_webui.internal.db import Base, get_db +from open_webui.utils.redis import get_redis_connection class EndpointFilter(logging.Filter): @@ -44,7 +48,7 @@ def filter(self, record: logging.LogRecord) -> bool: # Function to run the alembic migrations def run_migrations(): - print("Running migrations") + log.info("Running migrations") try: from alembic import command from alembic.config import Config @@ -57,7 +61,7 @@ def run_migrations(): command.upgrade(alembic_cfg, "head") except Exception as e: - print(f"Error: {e}") + log.exception(f"Error running migrations: {e}") run_migrations() @@ -249,9 +253,17 @@ def save(self): class AppConfig: _state: dict[str, PersistentConfig] + _redis: Optional[redis.Redis] = None - def __init__(self): + def __init__( + self, redis_url: Optional[str] = None, redis_sentinels: Optional[list] = [] + ): super().__setattr__("_state", {}) + if redis_url: + super().__setattr__( + "_redis", + get_redis_connection(redis_url, redis_sentinels, decode_responses=True), + ) def __setattr__(self, key, value): if isinstance(value, PersistentConfig): @@ -260,7 +272,31 @@ def __setattr__(self, key, value): self._state[key].value = value self._state[key].save() + if self._redis: + redis_key = f"open-webui:config:{key}" + self._redis.set(redis_key, json.dumps(self._state[key].value)) + def __getattr__(self, key): + if key not in self._state: + raise AttributeError(f"Config key '{key}' not found") + + # If Redis is available, check for an updated value + if self._redis: + redis_key = f"open-webui:config:{key}" + redis_value = self._redis.get(redis_key) + + if redis_value is not None: + try: + decoded_value = json.loads(redis_value) + + # Update the in-memory value if different + if self._state[key].value != decoded_value: + self._state[key].value = decoded_value + log.info(f"Updated {key} from Redis: {decoded_value}") + + except json.JSONDecodeError: + log.error(f"Invalid JSON format in Redis for {key}: {redis_value}") + return self._state[key].value @@ -295,12 +331,14 @@ def __getattr__(self, key): # OAuth config #################################### + ENABLE_OAUTH_SIGNUP = PersistentConfig( "ENABLE_OAUTH_SIGNUP", "oauth.enable_signup", os.environ.get("ENABLE_OAUTH_SIGNUP", "False").lower() == "true", ) + OAUTH_MERGE_ACCOUNTS_BY_EMAIL = PersistentConfig( "OAUTH_MERGE_ACCOUNTS_BY_EMAIL", "oauth.merge_accounts_by_email", @@ -430,6 +468,7 @@ def __getattr__(self, key): os.environ.get("OAUTH_USERNAME_CLAIM", "name"), ) + OAUTH_PICTURE_CLAIM = PersistentConfig( "OAUTH_PICTURE_CLAIM", "oauth.oidc.avatar_claim", @@ -588,6 +627,17 @@ def oidc_oauth_register(client): STATIC_DIR = Path(os.getenv("STATIC_DIR", OPEN_WEBUI_DIR / "static")).resolve() +for file_path in (FRONTEND_BUILD_DIR / "static").glob("**/*"): + if file_path.is_file(): + target_path = STATIC_DIR / file_path.relative_to( + (FRONTEND_BUILD_DIR / "static") + ) + target_path.parent.mkdir(parents=True, exist_ok=True) + try: + shutil.copyfile(file_path, target_path) + except Exception as e: + logging.error(f"An error occurred: {e}") + frontend_favicon = FRONTEND_BUILD_DIR / "static" / "favicon.png" if frontend_favicon.exists(): @@ -660,11 +710,7 @@ def oidc_oauth_register(client): # LICENSE_KEY #################################### -LICENSE_KEY = PersistentConfig( - "LICENSE_KEY", - "license.key", - os.environ.get("LICENSE_KEY", ""), -) +LICENSE_KEY = os.environ.get("LICENSE_KEY", "") #################################### # STORAGE PROVIDER @@ -678,6 +724,10 @@ def oidc_oauth_register(client): S3_BUCKET_NAME = os.environ.get("S3_BUCKET_NAME", None) S3_KEY_PREFIX = os.environ.get("S3_KEY_PREFIX", None) S3_ENDPOINT_URL = os.environ.get("S3_ENDPOINT_URL", None) +S3_USE_ACCELERATE_ENDPOINT = ( + os.environ.get("S3_USE_ACCELERATE_ENDPOINT", "False").lower() == "true" +) +S3_ADDRESSING_STYLE = os.environ.get("S3_ADDRESSING_STYLE", None) GCS_BUCKET_NAME = os.environ.get("GCS_BUCKET_NAME", None) GOOGLE_APPLICATION_CREDENTIALS_JSON = os.environ.get( @@ -692,16 +742,16 @@ def oidc_oauth_register(client): # File Upload DIR #################################### -UPLOAD_DIR = f"{DATA_DIR}/uploads" -Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True) +UPLOAD_DIR = DATA_DIR / "uploads" +UPLOAD_DIR.mkdir(parents=True, exist_ok=True) #################################### # Cache DIR #################################### -CACHE_DIR = f"{DATA_DIR}/cache" -Path(CACHE_DIR).mkdir(parents=True, exist_ok=True) +CACHE_DIR = DATA_DIR / "cache" +CACHE_DIR.mkdir(parents=True, exist_ok=True) #################################### @@ -831,6 +881,17 @@ def oidc_oauth_register(client): pass OPENAI_API_BASE_URL = "https://api.openai.com/v1" +#################################### +# TOOL_SERVERS +#################################### + + +TOOL_SERVER_CONNECTIONS = PersistentConfig( + "TOOL_SERVER_CONNECTIONS", + "tool_server.connections", + [], +) + #################################### # WEBUI #################################### @@ -933,6 +994,35 @@ def oidc_oauth_register(client): os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS", "False").lower() == "true" ) +USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING = ( + os.environ.get( + "USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING", "False" + ).lower() + == "true" +) + +USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING = ( + os.environ.get( + "USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING", "False" + ).lower() + == "true" +) + +USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING = ( + os.environ.get( + "USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING", "False" + ).lower() + == "true" +) + +USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING = ( + os.environ.get( + "USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING", "False" + ).lower() + == "true" +) + + USER_PERMISSIONS_CHAT_CONTROLS = ( os.environ.get("USER_PERMISSIONS_CHAT_CONTROLS", "True").lower() == "true" ) @@ -953,6 +1043,16 @@ def oidc_oauth_register(client): os.environ.get("USER_PERMISSIONS_CHAT_TEMPORARY", "True").lower() == "true" ) +USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED = ( + os.environ.get("USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED", "False").lower() + == "true" +) + +USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS = ( + os.environ.get("USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS", "False").lower() + == "true" +) + USER_PERMISSIONS_FEATURES_WEB_SEARCH = ( os.environ.get("USER_PERMISSIONS_FEATURES_WEB_SEARCH", "True").lower() == "true" ) @@ -975,14 +1075,22 @@ def oidc_oauth_register(client): "prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS, "tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS, }, + "sharing": { + "public_models": USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING, + "public_knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING, + "public_prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING, + "public_tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING, + }, "chat": { "controls": USER_PERMISSIONS_CHAT_CONTROLS, "file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD, "delete": USER_PERMISSIONS_CHAT_DELETE, "edit": USER_PERMISSIONS_CHAT_EDIT, "temporary": USER_PERMISSIONS_CHAT_TEMPORARY, + "temporary_enforced": USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED, }, "features": { + "direct_tool_servers": USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS, "web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH, "image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION, "code_interpreter": USER_PERMISSIONS_FEATURES_CODE_INTERPRETER, @@ -1045,6 +1153,12 @@ def oidc_oauth_register(client): os.environ.get("ENABLE_MESSAGE_RATING", "True").lower() == "true", ) +ENABLE_USER_WEBHOOKS = PersistentConfig( + "ENABLE_USER_WEBHOOKS", + "ui.enable_user_webhooks", + os.environ.get("ENABLE_USER_WEBHOOKS", "True").lower() == "true", +) + def validate_cors_origins(origins): for origin in origins: @@ -1094,7 +1208,7 @@ class BannerModel(BaseModel): banners = json.loads(os.environ.get("WEBUI_BANNERS", "[]")) banners = [BannerModel(**banner) for banner in banners] except Exception as e: - print(f"Error loading WEBUI_BANNERS: {e}") + log.exception(f"Error loading WEBUI_BANNERS: {e}") banners = [] WEBUI_BANNERS = PersistentConfig("WEBUI_BANNERS", "ui.banners", banners) @@ -1266,7 +1380,7 @@ class BannerModel(BaseModel): ENABLE_AUTOCOMPLETE_GENERATION = PersistentConfig( "ENABLE_AUTOCOMPLETE_GENERATION", "task.autocomplete.enable", - os.environ.get("ENABLE_AUTOCOMPLETE_GENERATION", "True").lower() == "true", + os.environ.get("ENABLE_AUTOCOMPLETE_GENERATION", "False").lower() == "true", ) AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = PersistentConfig( @@ -1370,6 +1484,11 @@ class BannerModel(BaseModel): # Code Interpreter #################################### +ENABLE_CODE_EXECUTION = PersistentConfig( + "ENABLE_CODE_EXECUTION", + "code_execution.enable", + os.environ.get("ENABLE_CODE_EXECUTION", "True").lower() == "true", +) CODE_EXECUTION_ENGINE = PersistentConfig( "CODE_EXECUTION_ENGINE", @@ -1498,21 +1617,27 @@ class BannerModel(BaseModel): # Chroma CHROMA_DATA_PATH = f"{DATA_DIR}/vector_db" -CHROMA_TENANT = os.environ.get("CHROMA_TENANT", chromadb.DEFAULT_TENANT) -CHROMA_DATABASE = os.environ.get("CHROMA_DATABASE", chromadb.DEFAULT_DATABASE) -CHROMA_HTTP_HOST = os.environ.get("CHROMA_HTTP_HOST", "") -CHROMA_HTTP_PORT = int(os.environ.get("CHROMA_HTTP_PORT", "8000")) -CHROMA_CLIENT_AUTH_PROVIDER = os.environ.get("CHROMA_CLIENT_AUTH_PROVIDER", "") -CHROMA_CLIENT_AUTH_CREDENTIALS = os.environ.get("CHROMA_CLIENT_AUTH_CREDENTIALS", "") -# Comma-separated list of header=value pairs -CHROMA_HTTP_HEADERS = os.environ.get("CHROMA_HTTP_HEADERS", "") -if CHROMA_HTTP_HEADERS: - CHROMA_HTTP_HEADERS = dict( - [pair.split("=") for pair in CHROMA_HTTP_HEADERS.split(",")] + +if VECTOR_DB == "chroma": + import chromadb + + CHROMA_TENANT = os.environ.get("CHROMA_TENANT", chromadb.DEFAULT_TENANT) + CHROMA_DATABASE = os.environ.get("CHROMA_DATABASE", chromadb.DEFAULT_DATABASE) + CHROMA_HTTP_HOST = os.environ.get("CHROMA_HTTP_HOST", "") + CHROMA_HTTP_PORT = int(os.environ.get("CHROMA_HTTP_PORT", "8000")) + CHROMA_CLIENT_AUTH_PROVIDER = os.environ.get("CHROMA_CLIENT_AUTH_PROVIDER", "") + CHROMA_CLIENT_AUTH_CREDENTIALS = os.environ.get( + "CHROMA_CLIENT_AUTH_CREDENTIALS", "" ) -else: - CHROMA_HTTP_HEADERS = None -CHROMA_HTTP_SSL = os.environ.get("CHROMA_HTTP_SSL", "false").lower() == "true" + # Comma-separated list of header=value pairs + CHROMA_HTTP_HEADERS = os.environ.get("CHROMA_HTTP_HEADERS", "") + if CHROMA_HTTP_HEADERS: + CHROMA_HTTP_HEADERS = dict( + [pair.split("=") for pair in CHROMA_HTTP_HEADERS.split(",")] + ) + else: + CHROMA_HTTP_HEADERS = None + CHROMA_HTTP_SSL = os.environ.get("CHROMA_HTTP_SSL", "false").lower() == "true" # this uses the model defined in the Dockerfile ENV variable. If you dont use docker or docker based deployments such as k8s, the default embedding model will be used (sentence-transformers/all-MiniLM-L6-v2) # Milvus @@ -1527,11 +1652,24 @@ class BannerModel(BaseModel): # OpenSearch OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200") -OPENSEARCH_SSL = os.environ.get("OPENSEARCH_SSL", True) -OPENSEARCH_CERT_VERIFY = os.environ.get("OPENSEARCH_CERT_VERIFY", False) +OPENSEARCH_SSL = os.environ.get("OPENSEARCH_SSL", "true").lower() == "true" +OPENSEARCH_CERT_VERIFY = ( + os.environ.get("OPENSEARCH_CERT_VERIFY", "false").lower() == "true" +) OPENSEARCH_USERNAME = os.environ.get("OPENSEARCH_USERNAME", None) OPENSEARCH_PASSWORD = os.environ.get("OPENSEARCH_PASSWORD", None) +# ElasticSearch +ELASTICSEARCH_URL = os.environ.get("ELASTICSEARCH_URL", "https://localhost:9200") +ELASTICSEARCH_CA_CERTS = os.environ.get("ELASTICSEARCH_CA_CERTS", None) +ELASTICSEARCH_API_KEY = os.environ.get("ELASTICSEARCH_API_KEY", None) +ELASTICSEARCH_USERNAME = os.environ.get("ELASTICSEARCH_USERNAME", None) +ELASTICSEARCH_PASSWORD = os.environ.get("ELASTICSEARCH_PASSWORD", None) +ELASTICSEARCH_CLOUD_ID = os.environ.get("ELASTICSEARCH_CLOUD_ID", None) +SSL_ASSERT_FINGERPRINT = os.environ.get("SSL_ASSERT_FINGERPRINT", None) +ELASTICSEARCH_INDEX_PREFIX = os.environ.get( + "ELASTICSEARCH_INDEX_PREFIX", "open_webui_collections" +) # Pgvector PGVECTOR_DB_URL = os.environ.get("PGVECTOR_DB_URL", DATABASE_URL) if VECTOR_DB == "pgvector" and not PGVECTOR_DB_URL.startswith("postgres"): @@ -1566,6 +1704,18 @@ class BannerModel(BaseModel): os.environ.get("GOOGLE_DRIVE_API_KEY", ""), ) +ENABLE_ONEDRIVE_INTEGRATION = PersistentConfig( + "ENABLE_ONEDRIVE_INTEGRATION", + "onedrive.enable", + os.getenv("ENABLE_ONEDRIVE_INTEGRATION", "False").lower() == "true", +) + +ONEDRIVE_CLIENT_ID = PersistentConfig( + "ONEDRIVE_CLIENT_ID", + "onedrive.client_id", + os.environ.get("ONEDRIVE_CLIENT_ID", ""), +) + # RAG Content Extraction CONTENT_EXTRACTION_ENGINE = PersistentConfig( "CONTENT_EXTRACTION_ENGINE", @@ -1579,9 +1729,45 @@ class BannerModel(BaseModel): os.getenv("TIKA_SERVER_URL", "http://tika:9998"), # Default for sidecar deployment ) +DOCLING_SERVER_URL = PersistentConfig( + "DOCLING_SERVER_URL", + "rag.docling_server_url", + os.getenv("DOCLING_SERVER_URL", "http://docling:5001"), +) + +DOCUMENT_INTELLIGENCE_ENDPOINT = PersistentConfig( + "DOCUMENT_INTELLIGENCE_ENDPOINT", + "rag.document_intelligence_endpoint", + os.getenv("DOCUMENT_INTELLIGENCE_ENDPOINT", ""), +) + +DOCUMENT_INTELLIGENCE_KEY = PersistentConfig( + "DOCUMENT_INTELLIGENCE_KEY", + "rag.document_intelligence_key", + os.getenv("DOCUMENT_INTELLIGENCE_KEY", ""), +) + +MISTRAL_OCR_API_KEY = PersistentConfig( + "MISTRAL_OCR_API_KEY", + "rag.mistral_ocr_api_key", + os.getenv("MISTRAL_OCR_API_KEY", ""), +) + +BYPASS_EMBEDDING_AND_RETRIEVAL = PersistentConfig( + "BYPASS_EMBEDDING_AND_RETRIEVAL", + "rag.bypass_embedding_and_retrieval", + os.environ.get("BYPASS_EMBEDDING_AND_RETRIEVAL", "False").lower() == "true", +) + + RAG_TOP_K = PersistentConfig( "RAG_TOP_K", "rag.top_k", int(os.environ.get("RAG_TOP_K", "3")) ) +RAG_TOP_K_RERANKER = PersistentConfig( + "RAG_TOP_K_RERANKER", + "rag.top_k_reranker", + int(os.environ.get("RAG_TOP_K_RERANKER", "3")), +) RAG_RELEVANCE_THRESHOLD = PersistentConfig( "RAG_RELEVANCE_THRESHOLD", "rag.relevance_threshold", @@ -1663,6 +1849,14 @@ class BannerModel(BaseModel): ), ) +RAG_EMBEDDING_QUERY_PREFIX = os.environ.get("RAG_EMBEDDING_QUERY_PREFIX", None) + +RAG_EMBEDDING_CONTENT_PREFIX = os.environ.get("RAG_EMBEDDING_CONTENT_PREFIX", None) + +RAG_EMBEDDING_PREFIX_FIELD_NAME = os.environ.get( + "RAG_EMBEDDING_PREFIX_FIELD_NAME", None +) + RAG_RERANKING_MODEL = PersistentConfig( "RAG_RERANKING_MODEL", "rag.reranking_model", @@ -1706,7 +1900,7 @@ class BannerModel(BaseModel): ) DEFAULT_RAG_TEMPLATE = """### Task: -Respond to the user query using the provided context, incorporating inline citations in the format [source_id] **only when the tag is explicitly provided** in the context. +Respond to the user query using the provided context, incorporating inline citations in the format [id] **only when the tag includes an explicit id attribute** (e.g., ). ### Guidelines: - If you don't know the answer, clearly state that. @@ -1714,18 +1908,17 @@ class BannerModel(BaseModel): - Respond in the same language as the user's query. - If the context is unreadable or of poor quality, inform the user and provide the best possible answer. - If the answer isn't present in the context but you possess the knowledge, explain this to the user and provide the answer using your own understanding. -- **Only include inline citations using [source_id] (e.g., [1], [2]) when a `` tag is explicitly provided in the context.** -- Do not cite if the tag is not provided in the context. +- **Only include inline citations using [id] (e.g., [1], [2]) when the tag includes an id attribute.** +- Do not cite if the tag does not contain an id attribute. - Do not use XML tags in your response. - Ensure citations are concise and directly related to the information provided. ### Example of Citation: -If the user asks about a specific topic and the information is found in "whitepaper.pdf" with a provided , the response should include the citation like so: -* "According to the study, the proposed method increases efficiency by 20% [whitepaper.pdf]." -If no is present, the response should omit the citation. +If the user asks about a specific topic and the information is found in a source with a provided id attribute, the response should include the citation like in the following example: +* "According to the study, the proposed method increases efficiency by 20% [1]." ### Output: -Provide a clear and direct response to the user's query, including inline citations in the format [source_id] only when the tag is present in the context. +Provide a clear and direct response to the user's query, including inline citations in the format [id] only when the tag with id attribute is present in the context. {{CONTEXT}} @@ -1795,10 +1988,10 @@ class BannerModel(BaseModel): os.getenv("RAG_WEB_SEARCH_ENGINE", ""), ) -RAG_WEB_SEARCH_FULL_CONTEXT = PersistentConfig( - "RAG_WEB_SEARCH_FULL_CONTEXT", - "rag.web.search.full_context", - os.getenv("RAG_WEB_SEARCH_FULL_CONTEXT", "False").lower() == "true", +BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = PersistentConfig( + "BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL", + "rag.web.search.bypass_embedding_and_retrieval", + os.getenv("BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL", "False").lower() == "true", ) # You can provide a list of your own websites to filter after performing a web search. @@ -1886,6 +2079,12 @@ class BannerModel(BaseModel): os.getenv("TAVILY_API_KEY", ""), ) +TAVILY_EXTRACT_DEPTH = PersistentConfig( + "TAVILY_EXTRACT_DEPTH", + "rag.web.search.tavily_extract_depth", + os.getenv("TAVILY_EXTRACT_DEPTH", "basic"), +) + JINA_API_KEY = PersistentConfig( "JINA_API_KEY", "rag.web.search.jina_api_key", @@ -1936,6 +2135,12 @@ class BannerModel(BaseModel): os.getenv("EXA_API_KEY", ""), ) +PERPLEXITY_API_KEY = PersistentConfig( + "PERPLEXITY_API_KEY", + "rag.web.search.perplexity_api_key", + os.getenv("PERPLEXITY_API_KEY", ""), +) + RAG_WEB_SEARCH_RESULT_COUNT = PersistentConfig( "RAG_WEB_SEARCH_RESULT_COUNT", "rag.web.search.result_count", @@ -1966,6 +2171,12 @@ class BannerModel(BaseModel): os.environ.get("PLAYWRIGHT_WS_URI", None), ) +PLAYWRIGHT_TIMEOUT = PersistentConfig( + "PLAYWRIGHT_TIMEOUT", + "rag.web.loader.engine.playwright.timeout", + int(os.environ.get("PLAYWRIGHT_TIMEOUT", "10")), +) + FIRECRAWL_API_KEY = PersistentConfig( "FIRECRAWL_API_KEY", "firecrawl.api_key", @@ -2375,7 +2586,7 @@ class BannerModel(BaseModel): LDAP_SEARCH_FILTERS = PersistentConfig( "LDAP_SEARCH_FILTER", "ldap.server.search_filter", - os.environ.get("LDAP_SEARCH_FILTER", ""), + os.environ.get("LDAP_SEARCH_FILTER", os.environ.get("LDAP_SEARCH_FILTERS", "")), ) LDAP_USE_TLS = PersistentConfig( diff --git a/backend/open_webui/constants.py b/backend/open_webui/constants.py index 48db56ea015..c47661f1c97 100644 --- a/backend/open_webui/constants.py +++ b/backend/open_webui/constants.py @@ -1,9 +1,24 @@ from enum import Enum import os +from pathlib import Path +#################################### +# Load .env file +#################################### +OPEN_WEBUI_DIR = Path(__file__).parent # the path containing this file -GOOGLE_SHEET_CREDENTIALS = os.environ['GOOGLE_SHEET_CREDENTIALS'] +BACKEND_DIR = OPEN_WEBUI_DIR.parent # the path containing this file +BASE_DIR = BACKEND_DIR.parent # the path containing the backend/ + +try: + from dotenv import find_dotenv, load_dotenv + + load_dotenv(find_dotenv(str(BASE_DIR / ".env"))) +except ImportError: + print("dotenv not installed, skipping...") + +GOOGLE_SHEET_CREDENTIALS = os.environ["GOOGLE_SHEET_CREDENTIALS"] class MESSAGES(str, Enum): diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 96e288d7779..e3819fdc5ed 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -65,10 +65,8 @@ # LOGGING #################################### -log_levels = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"] - GLOBAL_LOG_LEVEL = os.environ.get("GLOBAL_LOG_LEVEL", "").upper() -if GLOBAL_LOG_LEVEL in log_levels: +if GLOBAL_LOG_LEVEL in logging.getLevelNamesMapping(): logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL, force=True) else: GLOBAL_LOG_LEVEL = "INFO" @@ -78,6 +76,7 @@ if "cuda_error" in locals(): log.exception(cuda_error) + del cuda_error log_sources = [ "AUDIO", @@ -100,13 +99,12 @@ for source in log_sources: log_env_var = source + "_LOG_LEVEL" SRC_LOG_LEVELS[source] = os.environ.get(log_env_var, "").upper() - if SRC_LOG_LEVELS[source] not in log_levels: + if SRC_LOG_LEVELS[source] not in logging.getLevelNamesMapping(): SRC_LOG_LEVELS[source] = GLOBAL_LOG_LEVEL log.info(f"{log_env_var}: {SRC_LOG_LEVELS[source]}") log.setLevel(SRC_LOG_LEVELS["CONFIG"]) - WEBUI_NAME = os.environ.get("WEBUI_NAME", "Open WebUI") if WEBUI_NAME != "Open WebUI": WEBUI_NAME += " (Open WebUI)" @@ -131,7 +129,6 @@ except Exception: PACKAGE_DATA = {"version": "0.0.0"} - VERSION = PACKAGE_DATA["version"] @@ -162,7 +159,6 @@ def parse_section(section): except Exception: changelog_content = (pkgutil.get_data("open_webui", "CHANGELOG.md") or b"").decode() - # Convert markdown content to HTML html_content = markdown.markdown(changelog_content) @@ -193,7 +189,6 @@ def parse_section(section): changelog_json[version_number] = version_data - CHANGELOG = changelog_json #################################### @@ -210,7 +205,6 @@ def parse_section(section): os.environ.get("ENABLE_FORWARD_USER_INFO_HEADERS", "False").lower() == "true" ) - #################################### # WEBUI_BUILD_HASH #################################### @@ -245,7 +239,6 @@ def parse_section(section): DATA_DIR = Path(os.getenv("DATA_DIR", OPEN_WEBUI_DIR / "data")) - STATIC_DIR = Path(os.getenv("STATIC_DIR", OPEN_WEBUI_DIR / "static")) FONTS_DIR = Path(os.getenv("FONTS_DIR", OPEN_WEBUI_DIR / "static" / "fonts")) @@ -257,7 +250,6 @@ def parse_section(section): os.getenv("FRONTEND_BUILD_DIR", OPEN_WEBUI_DIR / "frontend") ).resolve() - #################################### # Database #################################### @@ -322,7 +314,6 @@ def parse_section(section): os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true" ) - ENABLE_REALTIME_CHAT_SAVE = ( os.environ.get("ENABLE_REALTIME_CHAT_SAVE", "False").lower() == "true" ) @@ -331,7 +322,9 @@ def parse_section(section): # REDIS #################################### -REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0") +REDIS_URL = os.environ.get("REDIS_URL", "") +REDIS_SENTINEL_HOSTS = os.environ.get("REDIS_SENTINEL_HOSTS", "") +REDIS_SENTINEL_PORT = os.environ.get("REDIS_SENTINEL_PORT", "26379") #################################### # WEBUI_AUTH (Required for security) @@ -386,6 +379,11 @@ def parse_section(section): WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "") WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL) +WEBSOCKET_REDIS_LOCK_TIMEOUT = os.environ.get("WEBSOCKET_REDIS_LOCK_TIMEOUT", 60) + +WEBSOCKET_SENTINEL_HOSTS = os.environ.get("WEBSOCKET_SENTINEL_HOSTS", "") + +WEBSOCKET_SENTINEL_PORT = os.environ.get("WEBSOCKET_SENTINEL_PORT", "26379") AIOHTTP_CLIENT_TIMEOUT = os.environ.get("AIOHTTP_CLIENT_TIMEOUT", "") @@ -397,19 +395,18 @@ def parse_section(section): except Exception: AIOHTTP_CLIENT_TIMEOUT = 300 -AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = os.environ.get( - "AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST", "" +AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = os.environ.get( + "AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST", + os.environ.get("AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST", "10"), ) -if AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST == "": - AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = None +if AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST == "": + AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = None else: try: - AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = int( - AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST - ) + AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = int(AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) except Exception: - AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = 5 + AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = 10 #################################### # OFFLINE_MODE @@ -419,3 +416,47 @@ def parse_section(section): if OFFLINE_MODE: os.environ["HF_HUB_OFFLINE"] = "1" + +#################################### +# AUDIT LOGGING +#################################### +# Where to store log file +AUDIT_LOGS_FILE_PATH = f"{DATA_DIR}/audit.log" +# Maximum size of a file before rotating into a new log file +AUDIT_LOG_FILE_ROTATION_SIZE = os.getenv("AUDIT_LOG_FILE_ROTATION_SIZE", "10MB") +# METADATA | REQUEST | REQUEST_RESPONSE +AUDIT_LOG_LEVEL = os.getenv("AUDIT_LOG_LEVEL", "NONE").upper() +try: + MAX_BODY_LOG_SIZE = int(os.environ.get("MAX_BODY_LOG_SIZE") or 2048) +except ValueError: + MAX_BODY_LOG_SIZE = 2048 + +# Comma separated list for urls to exclude from audit +AUDIT_EXCLUDED_PATHS = os.getenv("AUDIT_EXCLUDED_PATHS", "/chats,/chat,/folders").split( + "," +) +AUDIT_EXCLUDED_PATHS = [path.strip() for path in AUDIT_EXCLUDED_PATHS] +AUDIT_EXCLUDED_PATHS = [path.lstrip("/") for path in AUDIT_EXCLUDED_PATHS] + +#################################### +# OPENTELEMETRY +#################################### + +ENABLE_OTEL = os.environ.get("ENABLE_OTEL", "False").lower() == "true" +OTEL_EXPORTER_OTLP_ENDPOINT = os.environ.get( + "OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317" +) +OTEL_SERVICE_NAME = os.environ.get("OTEL_SERVICE_NAME", "open-webui") +OTEL_RESOURCE_ATTRIBUTES = os.environ.get( + "OTEL_RESOURCE_ATTRIBUTES", "" +) # e.g. key1=val1,key2=val2 +OTEL_TRACES_SAMPLER = os.environ.get( + "OTEL_TRACES_SAMPLER", "parentbased_always_on" +).lower() + +#################################### +# TOOLS/FUNCTIONS PIP OPTIONS +#################################### + +PIP_OPTIONS = os.getenv("PIP_OPTIONS", "").split() +PIP_PACKAGE_INDEX_OPTIONS = os.getenv("PIP_PACKAGE_INDEX_OPTIONS", "").split() diff --git a/backend/open_webui/functions.py b/backend/open_webui/functions.py index 274be56ec09..340b60ba47d 100644 --- a/backend/open_webui/functions.py +++ b/backend/open_webui/functions.py @@ -2,6 +2,7 @@ import sys import inspect import json +import asyncio from pydantic import BaseModel from typing import AsyncGenerator, Generator, Iterator @@ -76,11 +77,13 @@ async def get_function_models(request): if hasattr(function_module, "pipes"): sub_pipes = [] - # Check if pipes is a function or a list - + # Handle pipes being a list, sync function, or async function try: if callable(function_module.pipes): - sub_pipes = function_module.pipes() + if asyncio.iscoroutinefunction(function_module.pipes): + sub_pipes = await function_module.pipes() + else: + sub_pipes = function_module.pipes() else: sub_pipes = function_module.pipes except Exception as e: @@ -220,6 +223,9 @@ def get_function_params(function_module, form_data, user, extra_params=None): extra_params = { "__event_emitter__": __event_emitter__, "__event_call__": __event_call__, + "__chat_id__": metadata.get("chat_id", None), + "__session_id__": metadata.get("session_id", None), + "__message_id__": metadata.get("message_id", None), "__task__": __task__, "__task_body__": __task_body__, "__files__": files, diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 346d28d6c3d..c9ca059c223 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -45,6 +45,9 @@ from starlette.responses import Response, StreamingResponse +from open_webui.utils import logger +from open_webui.utils.audit import AuditLevel, AuditLoggingMiddleware +from open_webui.utils.logger import start_logger from open_webui.socket.main import ( app as socket_app, periodic_usage_pool_cleanup, @@ -81,11 +84,12 @@ get_rf, ) -from open_webui.internal.db import Session +from open_webui.internal.db import Session, engine from open_webui.models.functions import Functions from open_webui.models.models import Models from open_webui.models.users import UserModel, Users +from open_webui.models.chats import Chats from open_webui.config import ( LICENSE_KEY, @@ -95,12 +99,16 @@ OLLAMA_API_CONFIGS, # OpenAI ENABLE_OPENAI_API, + ONEDRIVE_CLIENT_ID, OPENAI_API_BASE_URLS, OPENAI_API_KEYS, OPENAI_API_CONFIGS, # Direct Connections ENABLE_DIRECT_CONNECTIONS, + # Tool Server Configs + TOOL_SERVER_CONNECTIONS, # Code Execution + ENABLE_CODE_EXECUTION, CODE_EXECUTION_ENGINE, CODE_EXECUTION_JUPYTER_URL, CODE_EXECUTION_JUPYTER_AUTH, @@ -150,6 +158,7 @@ AUDIO_TTS_AZURE_SPEECH_REGION, AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT, PLAYWRIGHT_WS_URI, + PLAYWRIGHT_TIMEOUT, FIRECRAWL_API_BASE_URL, FIRECRAWL_API_KEY, RAG_WEB_LOADER_ENGINE, @@ -161,6 +170,7 @@ RAG_TEMPLATE, DEFAULT_RAG_TEMPLATE, RAG_FULL_CONTEXT, + BYPASS_EMBEDDING_AND_RETRIEVAL, RAG_EMBEDDING_MODEL, RAG_EMBEDDING_MODEL_AUTO_UPDATE, RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE, @@ -180,7 +190,12 @@ CHUNK_SIZE, CONTENT_EXTRACTION_ENGINE, TIKA_SERVER_URL, + DOCLING_SERVER_URL, + DOCUMENT_INTELLIGENCE_ENDPOINT, + DOCUMENT_INTELLIGENCE_KEY, + MISTRAL_OCR_API_KEY, RAG_TOP_K, + RAG_TOP_K_RERANKER, RAG_TEXT_SPLITTER, TIKTOKEN_ENCODING_NAME, PDF_EXTRACT_IMAGES, @@ -188,7 +203,7 @@ YOUTUBE_LOADER_PROXY_URL, # Retrieval (Web Search) RAG_WEB_SEARCH_ENGINE, - RAG_WEB_SEARCH_FULL_CONTEXT, + BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, RAG_WEB_SEARCH_RESULT_COUNT, RAG_WEB_SEARCH_CONCURRENT_REQUESTS, RAG_WEB_SEARCH_TRUST_ENV, @@ -204,10 +219,12 @@ SERPSTACK_API_KEY, SERPSTACK_HTTPS, TAVILY_API_KEY, + TAVILY_EXTRACT_DEPTH, BING_SEARCH_V7_ENDPOINT, BING_SEARCH_V7_SUBSCRIPTION_KEY, BRAVE_SEARCH_API_KEY, EXA_API_KEY, + PERPLEXITY_API_KEY, KAGI_SEARCH_API_KEY, MOJEEK_SEARCH_API_KEY, BOCHA_SEARCH_API_KEY, @@ -215,11 +232,13 @@ GOOGLE_PSE_ENGINE_ID, GOOGLE_DRIVE_CLIENT_ID, GOOGLE_DRIVE_API_KEY, + ONEDRIVE_CLIENT_ID, ENABLE_RAG_HYBRID_SEARCH, ENABLE_RAG_LOCAL_WEB_FETCH, ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION, ENABLE_RAG_WEB_SEARCH, ENABLE_GOOGLE_DRIVE_INTEGRATION, + ENABLE_ONEDRIVE_INTEGRATION, UPLOAD_DIR, # WebUI WEBUI_AUTH, @@ -237,6 +256,7 @@ ENABLE_CHANNELS, ENABLE_COMMUNITY_SHARING, ENABLE_MESSAGE_RATING, + ENABLE_USER_WEBHOOKS, ENABLE_EVALUATION_ARENA_MODELS, USER_PERMISSIONS, DEFAULT_USER_ROLE, @@ -298,8 +318,14 @@ reset_config, ) from open_webui.env import ( + AUDIT_EXCLUDED_PATHS, + AUDIT_LOG_LEVEL, CHANGELOG, + REDIS_URL, + REDIS_SENTINEL_HOSTS, + REDIS_SENTINEL_PORT, GLOBAL_LOG_LEVEL, + MAX_BODY_LOG_SIZE, SAFE_MODE, SRC_LOG_LEVELS, VERSION, @@ -313,6 +339,7 @@ BYPASS_MODEL_ACCESS_CONTROL, RESET_CONFIG_ON_START, OFFLINE_MODE, + ENABLE_OTEL, ) @@ -331,6 +358,7 @@ from open_webui.utils.auth import ( get_license_data, + get_http_authorization_cred, decode_token, get_admin_user, get_verified_user, @@ -340,6 +368,8 @@ from open_webui.tasks import stop_task, list_tasks # Import from tasks.py +from open_webui.utils.redis import get_sentinels_from_env + if SAFE_MODE: print("SAFE MODE ENABLED") @@ -384,11 +414,12 @@ async def get_response(self, path: str, scope): @asynccontextmanager async def lifespan(app: FastAPI): + start_logger() if RESET_CONFIG_ON_START: reset_config() - if app.state.config.LICENSE_KEY: - get_license_data(app, app.state.config.LICENSE_KEY) + if LICENSE_KEY: + get_license_data(app, LICENSE_KEY) asyncio.create_task(periodic_usage_pool_cleanup()) yield @@ -403,10 +434,26 @@ async def lifespan(app: FastAPI): oauth_manager = OAuthManager(app) -app.state.config = AppConfig() +app.state.config = AppConfig( + redis_url=REDIS_URL, + redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT), +) app.state.WEBUI_NAME = WEBUI_NAME -app.state.config.LICENSE_KEY = LICENSE_KEY +app.state.LICENSE_METADATA = None + + +######################################## +# +# OPENTELEMETRY +# +######################################## + +if ENABLE_OTEL: + from open_webui.utils.telemetry.setup import setup as setup_opentelemetry + + setup_opentelemetry(app=app, db_engine=engine) + ######################################## # @@ -434,6 +481,15 @@ async def lifespan(app: FastAPI): app.state.OPENAI_MODELS = {} +######################################## +# +# TOOL SERVERS +# +######################################## + +app.state.config.TOOL_SERVER_CONNECTIONS = TOOL_SERVER_CONNECTIONS +app.state.TOOL_SERVERS = [] + ######################################## # # DIRECT CONNECTIONS @@ -477,6 +533,7 @@ async def lifespan(app: FastAPI): app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING app.state.config.ENABLE_MESSAGE_RATING = ENABLE_MESSAGE_RATING +app.state.config.ENABLE_USER_WEBHOOKS = ENABLE_USER_WEBHOOKS app.state.config.ENABLE_EVALUATION_ARENA_MODELS = ENABLE_EVALUATION_ARENA_MODELS app.state.config.EVALUATION_ARENA_MODELS = EVALUATION_ARENA_MODELS @@ -520,12 +577,14 @@ async def lifespan(app: FastAPI): app.state.config.TOP_K = RAG_TOP_K +app.state.config.TOP_K_RERANKER = RAG_TOP_K_RERANKER app.state.config.RELEVANCE_THRESHOLD = RAG_RELEVANCE_THRESHOLD app.state.config.FILE_MAX_SIZE = RAG_FILE_MAX_SIZE app.state.config.FILE_MAX_COUNT = RAG_FILE_MAX_COUNT app.state.config.RAG_FULL_CONTEXT = RAG_FULL_CONTEXT +app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL = BYPASS_EMBEDDING_AND_RETRIEVAL app.state.config.ENABLE_RAG_HYBRID_SEARCH = ENABLE_RAG_HYBRID_SEARCH app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = ( ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION @@ -533,6 +592,10 @@ async def lifespan(app: FastAPI): app.state.config.CONTENT_EXTRACTION_ENGINE = CONTENT_EXTRACTION_ENGINE app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL +app.state.config.DOCLING_SERVER_URL = DOCLING_SERVER_URL +app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = DOCUMENT_INTELLIGENCE_ENDPOINT +app.state.config.DOCUMENT_INTELLIGENCE_KEY = DOCUMENT_INTELLIGENCE_KEY +app.state.config.MISTRAL_OCR_API_KEY = MISTRAL_OCR_API_KEY app.state.config.TEXT_SPLITTER = RAG_TEXT_SPLITTER app.state.config.TIKTOKEN_ENCODING_NAME = TIKTOKEN_ENCODING_NAME @@ -560,10 +623,13 @@ async def lifespan(app: FastAPI): app.state.config.ENABLE_RAG_WEB_SEARCH = ENABLE_RAG_WEB_SEARCH app.state.config.RAG_WEB_SEARCH_ENGINE = RAG_WEB_SEARCH_ENGINE -app.state.config.RAG_WEB_SEARCH_FULL_CONTEXT = RAG_WEB_SEARCH_FULL_CONTEXT +app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = ( + BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL +) app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST = RAG_WEB_SEARCH_DOMAIN_FILTER_LIST app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ENABLE_GOOGLE_DRIVE_INTEGRATION +app.state.config.ENABLE_ONEDRIVE_INTEGRATION = ENABLE_ONEDRIVE_INTEGRATION app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL app.state.config.GOOGLE_PSE_API_KEY = GOOGLE_PSE_API_KEY app.state.config.GOOGLE_PSE_ENGINE_ID = GOOGLE_PSE_ENGINE_ID @@ -584,14 +650,17 @@ async def lifespan(app: FastAPI): app.state.config.BING_SEARCH_V7_ENDPOINT = BING_SEARCH_V7_ENDPOINT app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY = BING_SEARCH_V7_SUBSCRIPTION_KEY app.state.config.EXA_API_KEY = EXA_API_KEY +app.state.config.PERPLEXITY_API_KEY = PERPLEXITY_API_KEY app.state.config.RAG_WEB_SEARCH_RESULT_COUNT = RAG_WEB_SEARCH_RESULT_COUNT app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS = RAG_WEB_SEARCH_CONCURRENT_REQUESTS app.state.config.RAG_WEB_LOADER_ENGINE = RAG_WEB_LOADER_ENGINE app.state.config.RAG_WEB_SEARCH_TRUST_ENV = RAG_WEB_SEARCH_TRUST_ENV app.state.config.PLAYWRIGHT_WS_URI = PLAYWRIGHT_WS_URI +app.state.config.PLAYWRIGHT_TIMEOUT = PLAYWRIGHT_TIMEOUT app.state.config.FIRECRAWL_API_BASE_URL = FIRECRAWL_API_BASE_URL app.state.config.FIRECRAWL_API_KEY = FIRECRAWL_API_KEY +app.state.config.TAVILY_EXTRACT_DEPTH = TAVILY_EXTRACT_DEPTH app.state.EMBEDDING_FUNCTION = None app.state.ef = None @@ -639,6 +708,7 @@ async def lifespan(app: FastAPI): # ######################################## +app.state.config.ENABLE_CODE_EXECUTION = ENABLE_CODE_EXECUTION app.state.config.CODE_EXECUTION_ENGINE = CODE_EXECUTION_ENGINE app.state.config.CODE_EXECUTION_JUPYTER_URL = CODE_EXECUTION_JUPYTER_URL app.state.config.CODE_EXECUTION_JUPYTER_AUTH = CODE_EXECUTION_JUPYTER_AUTH @@ -806,6 +876,10 @@ async def commit_session_after_request(request: Request, call_next): @app.middleware("http") async def check_url(request: Request, call_next): start_time = int(time.time()) + request.state.token = get_http_authorization_cred( + request.headers.get("Authorization") + ) + request.state.enable_api_key = app.state.config.ENABLE_API_KEY response = await call_next(request) process_time = int(time.time()) - start_time @@ -879,6 +953,19 @@ async def inspect_websocket(request: Request, call_next): app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"]) +try: + audit_level = AuditLevel(AUDIT_LOG_LEVEL) +except ValueError as e: + logger.error(f"Invalid audit level: {AUDIT_LOG_LEVEL}. Error: {e}") + audit_level = AuditLevel.NONE + +if audit_level != AuditLevel.NONE: + app.add_middleware( + AuditLoggingMiddleware, + audit_level=audit_level, + excluded_paths=AUDIT_EXCLUDED_PATHS, + max_body_size=MAX_BODY_LOG_SIZE, + ) ################################## # # Chat Endpoints @@ -911,14 +998,24 @@ def get_filtered_models(models, user): return filtered_models - models = await get_all_models(request) + all_models = await get_all_models(request, user=user) - # Filter out filter pipelines - models = [ - model - for model in models - if "pipeline" not in model or model["pipeline"].get("type", None) != "filter" - ] + models = [] + for model in all_models: + # Filter out filter pipelines + if "pipeline" in model and model["pipeline"].get("type", None) == "filter": + continue + + model_tags = [ + tag.get("name") + for tag in model.get("info", {}).get("meta", {}).get("tags", []) + ] + tags = [tag.get("name") for tag in model.get("tags", [])] + + tags = list(set(model_tags + tags)) + model["tags"] = [{"name": tag} for tag in tags] + + models.append(model) model_order_list = request.app.state.config.MODEL_ORDER_LIST if model_order_list: @@ -940,7 +1037,7 @@ def get_filtered_models(models, user): @app.get("/api/models/base") async def get_base_models(request: Request, user=Depends(get_admin_user)): - models = await get_all_base_models(request) + models = await get_all_base_models(request, user=user) return {"data": models} @@ -951,7 +1048,7 @@ async def chat_completion( user=Depends(get_verified_user), ): if not request.app.state.MODELS: - await get_all_models(request) + await get_all_models(request, user=user) model_item = form_data.pop("model_item", {}) tasks = form_data.pop("background_tasks", None) @@ -984,10 +1081,11 @@ async def chat_completion( "message_id": form_data.pop("id", None), "session_id": form_data.pop("session_id", None), "tool_ids": form_data.get("tool_ids", None), + "tool_servers": form_data.pop("tool_servers", None), "files": form_data.get("files", None), "features": form_data.get("features", None), "variables": form_data.get("variables", None), - "model": model_info.model_dump() if model_info else model, + "model": model, "direct": model_item.get("direct", False), **( {"function_calling": "native"} @@ -1005,11 +1103,19 @@ async def chat_completion( form_data["metadata"] = metadata form_data, metadata, events = await process_chat_payload( - request, form_data, metadata, user, model + request, form_data, user, metadata, model ) except Exception as e: log.debug(f"Error processing chat payload: {e}") + Chats.upsert_message_to_chat_by_id_and_message_id( + metadata["chat_id"], + metadata["message_id"], + { + "error": {"content": str(e)}, + }, + ) + raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), @@ -1019,7 +1125,7 @@ async def chat_completion( response = await chat_completion_handler(request, form_data, user) return await process_chat_response( - request, response, form_data, user, events, metadata, tasks + request, response, form_data, user, metadata, model, events, tasks ) except Exception as e: raise HTTPException( @@ -1108,9 +1214,10 @@ async def get_app_config(request: Request): if data is not None and "id" in data: user = Users.get_user_by_id(data["id"]) + user_count = Users.get_num_users() onboarding = False + if user is None: - user_count = Users.get_num_users() onboarding = user_count == 0 return { @@ -1138,14 +1245,17 @@ async def get_app_config(request: Request): "enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS, "enable_channels": app.state.config.ENABLE_CHANNELS, "enable_web_search": app.state.config.ENABLE_RAG_WEB_SEARCH, + "enable_code_execution": app.state.config.ENABLE_CODE_EXECUTION, "enable_code_interpreter": app.state.config.ENABLE_CODE_INTERPRETER, "enable_image_generation": app.state.config.ENABLE_IMAGE_GENERATION, "enable_autocomplete_generation": app.state.config.ENABLE_AUTOCOMPLETE_GENERATION, "enable_community_sharing": app.state.config.ENABLE_COMMUNITY_SHARING, "enable_message_rating": app.state.config.ENABLE_MESSAGE_RATING, + "enable_user_webhooks": app.state.config.ENABLE_USER_WEBHOOKS, "enable_admin_export": ENABLE_ADMIN_EXPORT, "enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS, "enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, + "enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION, } if user is not None else {} @@ -1155,6 +1265,7 @@ async def get_app_config(request: Request): { "default_models": app.state.config.DEFAULT_MODELS, "default_prompt_suggestions": app.state.config.DEFAULT_PROMPT_SUGGESTIONS, + "user_count": user_count, "code": { "engine": app.state.config.CODE_EXECUTION_ENGINE, }, @@ -1177,6 +1288,15 @@ async def get_app_config(request: Request): "client_id": GOOGLE_DRIVE_CLIENT_ID.value, "api_key": GOOGLE_DRIVE_API_KEY.value, }, + "onedrive": {"client_id": ONEDRIVE_CLIENT_ID.value}, + "license_metadata": app.state.LICENSE_METADATA, + **( + { + "active_entries": app.state.USER_COUNT, + } + if user.role == "admin" + else {} + ), } if user is not None else {} diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 9e0a5865e97..a222d221c0a 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -1,3 +1,4 @@ +import logging import json import time import uuid @@ -5,7 +6,7 @@ from open_webui.internal.db import Base, get_db from open_webui.models.tags import TagModel, Tag, Tags - +from open_webui.env import SRC_LOG_LEVELS from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON @@ -16,6 +17,9 @@ # Chat DB Schema #################### +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MODELS"]) + class Chat(Base): __tablename__ = "chat" @@ -670,7 +674,7 @@ def get_chats_by_user_id_and_search_text( # Perform pagination at the SQL level all_chats = query.offset(skip).limit(limit).all() - print(len(all_chats)) + log.info(f"The number of chats: {len(all_chats)}") # Validate and return chats return [ChatModel.model_validate(chat) for chat in all_chats] @@ -731,7 +735,7 @@ def get_chat_list_by_user_id_and_tag_name( query = db.query(Chat).filter_by(user_id=user_id) tag_id = tag_name.replace(" ", "_").lower() - print(db.bind.dialect.name) + log.info(f"DB dialect name: {db.bind.dialect.name}") if db.bind.dialect.name == "sqlite": # SQLite JSON1 querying for tags within the meta JSON field query = query.filter( @@ -752,7 +756,7 @@ def get_chat_list_by_user_id_and_tag_name( ) all_chats = query.all() - print("all_chats", all_chats) + log.debug(f"all_chats: {all_chats}") return [ChatModel.model_validate(chat) for chat in all_chats] def add_chat_tag_by_id_and_user_id_and_tag_name( @@ -810,7 +814,7 @@ def count_chats_by_tag_name_and_user_id(self, tag_name: str, user_id: str) -> in count = query.count() # Debugging output for inspection - print(f"Count of chats for tag '{tag_name}':", count) + log.info(f"Count of chats for tag '{tag_name}': {count}") return count diff --git a/backend/open_webui/models/feedbacks.py b/backend/open_webui/models/feedbacks.py index 7ff5c454081..215e36aa241 100644 --- a/backend/open_webui/models/feedbacks.py +++ b/backend/open_webui/models/feedbacks.py @@ -118,7 +118,7 @@ def insert_new_feedback( else: return None except Exception as e: - print(e) + log.exception(f"Error creating a new feedback: {e}") return None def get_feedback_by_id(self, id: str) -> Optional[FeedbackModel]: diff --git a/backend/open_webui/models/files.py b/backend/open_webui/models/files.py index 91dea544439..6f1511cd137 100644 --- a/backend/open_webui/models/files.py +++ b/backend/open_webui/models/files.py @@ -119,7 +119,7 @@ def insert_new_file(self, user_id: str, form_data: FileForm) -> Optional[FileMod else: return None except Exception as e: - print(f"Error creating tool: {e}") + log.exception(f"Error inserting a new file: {e}") return None def get_file_by_id(self, id: str) -> Optional[FileModel]: diff --git a/backend/open_webui/models/folders.py b/backend/open_webui/models/folders.py index 040774196bd..1c97de26c96 100644 --- a/backend/open_webui/models/folders.py +++ b/backend/open_webui/models/folders.py @@ -9,6 +9,8 @@ from open_webui.env import SRC_LOG_LEVELS from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Column, Text, JSON, Boolean +from open_webui.utils.access_control import get_permissions + log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["MODELS"]) @@ -82,7 +84,7 @@ def insert_new_folder( else: return None except Exception as e: - print(e) + log.exception(f"Error inserting a new folder: {e}") return None def get_folder_by_id_and_user_id( @@ -234,15 +236,18 @@ def update_folder_is_expanded_by_id_and_user_id( log.error(f"update_folder: {e}") return - def delete_folder_by_id_and_user_id(self, id: str, user_id: str) -> bool: + def delete_folder_by_id_and_user_id( + self, id: str, user_id: str, delete_chats=True + ) -> bool: try: with get_db() as db: folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() if not folder: return False - # Delete all chats in the folder - Chats.delete_chats_by_user_id_and_folder_id(user_id, folder.id) + if delete_chats: + # Delete all chats in the folder + Chats.delete_chats_by_user_id_and_folder_id(user_id, folder.id) # Delete all children folders def delete_children(folder): @@ -250,9 +255,11 @@ def delete_children(folder): folder.id, user_id ) for folder_child in folder_children: - Chats.delete_chats_by_user_id_and_folder_id( - user_id, folder_child.id - ) + if delete_chats: + Chats.delete_chats_by_user_id_and_folder_id( + user_id, folder_child.id + ) + delete_children(folder_child) folder = db.query(Folder).filter_by(id=folder_child.id).first() diff --git a/backend/open_webui/models/functions.py b/backend/open_webui/models/functions.py index 6c6aed86236..8cbfc5de7d2 100644 --- a/backend/open_webui/models/functions.py +++ b/backend/open_webui/models/functions.py @@ -105,7 +105,7 @@ def insert_new_function( else: return None except Exception as e: - print(f"Error creating tool: {e}") + log.exception(f"Error creating a new function: {e}") return None def get_function_by_id(self, id: str) -> Optional[FunctionModel]: @@ -170,7 +170,7 @@ def get_function_valves_by_id(self, id: str) -> Optional[dict]: function = db.get(Function, id) return function.valves if function.valves else {} except Exception as e: - print(f"An error occurred: {e}") + log.exception(f"Error getting function valves by id {id}: {e}") return None def update_function_valves_by_id( @@ -202,7 +202,9 @@ def get_user_valves_by_id_and_user_id( return user_settings["functions"]["valves"].get(id, {}) except Exception as e: - print(f"An error occurred: {e}") + log.exception( + f"Error getting user values by id {id} and user id {user_id}: {e}" + ) return None def update_user_valves_by_id_and_user_id( @@ -225,7 +227,9 @@ def update_user_valves_by_id_and_user_id( return user_settings["functions"]["valves"][id] except Exception as e: - print(f"An error occurred: {e}") + log.exception( + f"Error updating user valves by id {id} and user_id {user_id}: {e}" + ) return None def update_function_by_id(self, id: str, updated: dict) -> Optional[FunctionModel]: diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py old mode 100644 new mode 100755 index f2f59d7c49d..7df8d8656b6 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -166,7 +166,7 @@ def insert_new_model( else: return None except Exception as e: - print(e) + log.exception(f"Failed to insert a new model: {e}") return None def get_all_models(self) -> list[ModelModel]: @@ -246,8 +246,7 @@ def update_model_by_id(self, id: str, model: ModelForm) -> Optional[ModelModel]: db.refresh(model) return ModelModel.model_validate(model) except Exception as e: - print(e) - + log.exception(f"Failed to update the model by id {id}: {e}") return None def delete_model_by_id(self, id: str) -> bool: diff --git a/backend/open_webui/models/tags.py b/backend/open_webui/models/tags.py index 3e812db95db..279dc624d52 100644 --- a/backend/open_webui/models/tags.py +++ b/backend/open_webui/models/tags.py @@ -61,7 +61,7 @@ def insert_new_tag(self, name: str, user_id: str) -> Optional[TagModel]: else: return None except Exception as e: - print(e) + log.exception(f"Error inserting a new tag: {e}") return None def get_tag_by_name_and_user_id( diff --git a/backend/open_webui/models/tools.py b/backend/open_webui/models/tools.py index a5f13ebb713..68a83ea42c8 100644 --- a/backend/open_webui/models/tools.py +++ b/backend/open_webui/models/tools.py @@ -131,7 +131,7 @@ def insert_new_tool( else: return None except Exception as e: - print(f"Error creating tool: {e}") + log.exception(f"Error creating a new tool: {e}") return None def get_tool_by_id(self, id: str) -> Optional[ToolModel]: @@ -175,7 +175,7 @@ def get_tool_valves_by_id(self, id: str) -> Optional[dict]: tool = db.get(Tool, id) return tool.valves if tool.valves else {} except Exception as e: - print(f"An error occurred: {e}") + log.exception(f"Error getting tool valves by id {id}: {e}") return None def update_tool_valves_by_id(self, id: str, valves: dict) -> Optional[ToolValves]: @@ -204,7 +204,9 @@ def get_user_valves_by_id_and_user_id( return user_settings["tools"]["valves"].get(id, {}) except Exception as e: - print(f"An error occurred: {e}") + log.exception( + f"Error getting user values by id {id} and user_id {user_id}: {e}" + ) return None def update_user_valves_by_id_and_user_id( @@ -227,7 +229,9 @@ def update_user_valves_by_id_and_user_id( return user_settings["tools"]["valves"][id] except Exception as e: - print(f"An error occurred: {e}") + log.exception( + f"Error updating user valves by id {id} and user_id {user_id}: {e}" + ) return None def update_tool_by_id(self, id: str, updated: dict) -> Optional[ToolModel]: diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py index a9372f65a60..24944bd8a44 100644 --- a/backend/open_webui/retrieval/loaders/main.py +++ b/backend/open_webui/retrieval/loaders/main.py @@ -4,6 +4,7 @@ import sys from langchain_community.document_loaders import ( + AzureAIDocumentIntelligenceLoader, BSHTMLLoader, CSVLoader, Docx2txtLoader, @@ -19,6 +20,9 @@ YoutubeLoader, ) from langchain_core.documents import Document + +from open_webui.retrieval.loaders.mistral import MistralLoader + from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) @@ -76,6 +80,7 @@ "jsx", "hs", "lhs", + "json", ] @@ -103,7 +108,7 @@ def load(self) -> list[Document]: if r.ok: raw_metadata = r.json() - text = raw_metadata.get("X-TIKA:content", "") + text = raw_metadata.get("X-TIKA:content", "").strip() if "Content-Type" in raw_metadata: headers["Content-Type"] = raw_metadata["Content-Type"] @@ -115,6 +120,52 @@ def load(self) -> list[Document]: raise Exception(f"Error calling Tika: {r.reason}") +class DoclingLoader: + def __init__(self, url, file_path=None, mime_type=None): + self.url = url.rstrip("/") + self.file_path = file_path + self.mime_type = mime_type + + def load(self) -> list[Document]: + with open(self.file_path, "rb") as f: + files = { + "files": ( + self.file_path, + f, + self.mime_type or "application/octet-stream", + ) + } + + params = { + "image_export_mode": "placeholder", + "table_mode": "accurate", + } + + endpoint = f"{self.url}/v1alpha/convert/file" + r = requests.post(endpoint, files=files, data=params) + + if r.ok: + result = r.json() + document_data = result.get("document", {}) + text = document_data.get("md_content", "") + + metadata = {"Content-Type": self.mime_type} if self.mime_type else {} + + log.debug("Docling extracted text: %s", text) + + return [Document(page_content=text, metadata=metadata)] + else: + error_msg = f"Error calling Docling API: {r.reason}" + if r.text: + try: + error_data = r.json() + if "detail" in error_data: + error_msg += f" - {error_data['detail']}" + except Exception: + error_msg += f" - {r.text}" + raise Exception(f"Error calling Docling: {error_msg}") + + class Loader: def __init__(self, engine: str = "", **kwargs): self.engine = engine @@ -133,13 +184,16 @@ def load( for doc in docs ] + def _is_text_file(self, file_ext: str, file_content_type: str) -> bool: + return file_ext in known_source_ext or ( + file_content_type and file_content_type.find("text/") >= 0 + ) + def _get_loader(self, filename: str, file_content_type: str, file_path: str): file_ext = filename.split(".")[-1].lower() if self.engine == "tika" and self.kwargs.get("TIKA_SERVER_URL"): - if file_ext in known_source_ext or ( - file_content_type and file_content_type.find("text/") >= 0 - ): + if self._is_text_file(file_ext, file_content_type): loader = TextLoader(file_path, autodetect_encoding=True) else: loader = TikaLoader( @@ -147,13 +201,52 @@ def _get_loader(self, filename: str, file_content_type: str, file_path: str): file_path=file_path, mime_type=file_content_type, ) + elif self.engine == "docling" and self.kwargs.get("DOCLING_SERVER_URL"): + if self._is_text_file(file_ext, file_content_type): + loader = TextLoader(file_path, autodetect_encoding=True) + else: + loader = DoclingLoader( + url=self.kwargs.get("DOCLING_SERVER_URL"), + file_path=file_path, + mime_type=file_content_type, + ) + elif ( + self.engine == "document_intelligence" + and self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT") != "" + and self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY") != "" + and ( + file_ext in ["pdf", "xls", "xlsx", "docx", "ppt", "pptx"] + or file_content_type + in [ + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ] + ) + ): + loader = AzureAIDocumentIntelligenceLoader( + file_path=file_path, + api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"), + api_key=self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY"), + ) + elif ( + self.engine == "mistral_ocr" + and self.kwargs.get("MISTRAL_OCR_API_KEY") != "" + and file_ext + in ["pdf"] # Mistral OCR currently only supports PDF and images + ): + loader = MistralLoader( + api_key=self.kwargs.get("MISTRAL_OCR_API_KEY"), file_path=file_path + ) else: if file_ext == "pdf": loader = PyPDFLoader( file_path, extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES") ) elif file_ext == "csv": - loader = CSVLoader(file_path) + loader = CSVLoader(file_path, autodetect_encoding=True) elif file_ext == "rst": loader = UnstructuredRSTLoader(file_path, mode="elements") elif file_ext == "xml": @@ -182,9 +275,7 @@ def _get_loader(self, filename: str, file_content_type: str, file_path: str): loader = UnstructuredPowerPointLoader(file_path) elif file_ext == "msg": loader = OutlookMessageLoader(file_path) - elif file_ext in known_source_ext or ( - file_content_type and file_content_type.find("text/") >= 0 - ): + elif self._is_text_file(file_ext, file_content_type): loader = TextLoader(file_path, autodetect_encoding=True) else: loader = TextLoader(file_path, autodetect_encoding=True) diff --git a/backend/open_webui/retrieval/loaders/mistral.py b/backend/open_webui/retrieval/loaders/mistral.py new file mode 100644 index 00000000000..8f3a960a283 --- /dev/null +++ b/backend/open_webui/retrieval/loaders/mistral.py @@ -0,0 +1,225 @@ +import requests +import logging +import os +import sys +from typing import List, Dict, Any + +from langchain_core.documents import Document +from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL + +logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +class MistralLoader: + """ + Loads documents by processing them through the Mistral OCR API. + """ + + BASE_API_URL = "https://api.mistral.ai/v1" + + def __init__(self, api_key: str, file_path: str): + """ + Initializes the loader. + + Args: + api_key: Your Mistral API key. + file_path: The local path to the PDF file to process. + """ + if not api_key: + raise ValueError("API key cannot be empty.") + if not os.path.exists(file_path): + raise FileNotFoundError(f"File not found at {file_path}") + + self.api_key = api_key + self.file_path = file_path + self.headers = {"Authorization": f"Bearer {self.api_key}"} + + def _handle_response(self, response: requests.Response) -> Dict[str, Any]: + """Checks response status and returns JSON content.""" + try: + response.raise_for_status() # Raises HTTPError for bad responses (4xx or 5xx) + # Handle potential empty responses for certain successful requests (e.g., DELETE) + if response.status_code == 204 or not response.content: + return {} # Return empty dict if no content + return response.json() + except requests.exceptions.HTTPError as http_err: + log.error(f"HTTP error occurred: {http_err} - Response: {response.text}") + raise + except requests.exceptions.RequestException as req_err: + log.error(f"Request exception occurred: {req_err}") + raise + except ValueError as json_err: # Includes JSONDecodeError + log.error(f"JSON decode error: {json_err} - Response: {response.text}") + raise # Re-raise after logging + + def _upload_file(self) -> str: + """Uploads the file to Mistral for OCR processing.""" + log.info("Uploading file to Mistral API") + url = f"{self.BASE_API_URL}/files" + file_name = os.path.basename(self.file_path) + + try: + with open(self.file_path, "rb") as f: + files = {"file": (file_name, f, "application/pdf")} + data = {"purpose": "ocr"} + + upload_headers = self.headers.copy() # Avoid modifying self.headers + + response = requests.post( + url, headers=upload_headers, files=files, data=data + ) + + response_data = self._handle_response(response) + file_id = response_data.get("id") + if not file_id: + raise ValueError("File ID not found in upload response.") + log.info(f"File uploaded successfully. File ID: {file_id}") + return file_id + except Exception as e: + log.error(f"Failed to upload file: {e}") + raise + + def _get_signed_url(self, file_id: str) -> str: + """Retrieves a temporary signed URL for the uploaded file.""" + log.info(f"Getting signed URL for file ID: {file_id}") + url = f"{self.BASE_API_URL}/files/{file_id}/url" + params = {"expiry": 1} + signed_url_headers = {**self.headers, "Accept": "application/json"} + + try: + response = requests.get(url, headers=signed_url_headers, params=params) + response_data = self._handle_response(response) + signed_url = response_data.get("url") + if not signed_url: + raise ValueError("Signed URL not found in response.") + log.info("Signed URL received.") + return signed_url + except Exception as e: + log.error(f"Failed to get signed URL: {e}") + raise + + def _process_ocr(self, signed_url: str) -> Dict[str, Any]: + """Sends the signed URL to the OCR endpoint for processing.""" + log.info("Processing OCR via Mistral API") + url = f"{self.BASE_API_URL}/ocr" + ocr_headers = { + **self.headers, + "Content-Type": "application/json", + "Accept": "application/json", + } + payload = { + "model": "mistral-ocr-latest", + "document": { + "type": "document_url", + "document_url": signed_url, + }, + "include_image_base64": False, + } + + try: + response = requests.post(url, headers=ocr_headers, json=payload) + ocr_response = self._handle_response(response) + log.info("OCR processing done.") + log.debug("OCR response: %s", ocr_response) + return ocr_response + except Exception as e: + log.error(f"Failed during OCR processing: {e}") + raise + + def _delete_file(self, file_id: str) -> None: + """Deletes the file from Mistral storage.""" + log.info(f"Deleting uploaded file ID: {file_id}") + url = f"{self.BASE_API_URL}/files/{file_id}" + # No specific Accept header needed, default or Authorization is usually sufficient + + try: + response = requests.delete(url, headers=self.headers) + delete_response = self._handle_response( + response + ) # Check status, ignore response body unless needed + log.info( + f"File deleted successfully: {delete_response}" + ) # Log the response if available + except Exception as e: + # Log error but don't necessarily halt execution if deletion fails + log.error(f"Failed to delete file ID {file_id}: {e}") + # Depending on requirements, you might choose to raise the error here + + def load(self) -> List[Document]: + """ + Executes the full OCR workflow: upload, get URL, process OCR, delete file. + + Returns: + A list of Document objects, one for each page processed. + """ + file_id = None + try: + # 1. Upload file + file_id = self._upload_file() + + # 2. Get Signed URL + signed_url = self._get_signed_url(file_id) + + # 3. Process OCR + ocr_response = self._process_ocr(signed_url) + + # 4. Process results + pages_data = ocr_response.get("pages") + if not pages_data: + log.warning("No pages found in OCR response.") + return [Document(page_content="No text content found", metadata={})] + + documents = [] + total_pages = len(pages_data) + for page_data in pages_data: + page_content = page_data.get("markdown") + page_index = page_data.get("index") # API uses 0-based index + + if page_content is not None and page_index is not None: + documents.append( + Document( + page_content=page_content, + metadata={ + "page": page_index, # 0-based index from API + "page_label": page_index + + 1, # 1-based label for convenience + "total_pages": total_pages, + # Add other relevant metadata from page_data if available/needed + # e.g., page_data.get('width'), page_data.get('height') + }, + ) + ) + else: + log.warning( + f"Skipping page due to missing 'markdown' or 'index'. Data: {page_data}" + ) + + if not documents: + # Case where pages existed but none had valid markdown/index + log.warning( + "OCR response contained pages, but none had valid content/index." + ) + return [ + Document( + page_content="No text content found in valid pages", metadata={} + ) + ] + + return documents + + except Exception as e: + log.error(f"An error occurred during the loading process: {e}") + # Return an empty list or a specific error document on failure + return [Document(page_content=f"Error during processing: {e}", metadata={})] + finally: + # 5. Delete file (attempt even if prior steps failed after upload) + if file_id: + try: + self._delete_file(file_id) + except Exception as del_e: + # Log deletion error, but don't overwrite original error if one occurred + log.error( + f"Cleanup error: Could not delete file ID {file_id}. Reason: {del_e}" + ) diff --git a/backend/open_webui/retrieval/loaders/tavily.py b/backend/open_webui/retrieval/loaders/tavily.py new file mode 100644 index 00000000000..15a3d7f97fe --- /dev/null +++ b/backend/open_webui/retrieval/loaders/tavily.py @@ -0,0 +1,93 @@ +import requests +import logging +from typing import Iterator, List, Literal, Union + +from langchain_core.document_loaders import BaseLoader +from langchain_core.documents import Document +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +class TavilyLoader(BaseLoader): + """Extract web page content from URLs using Tavily Extract API. + + This is a LangChain document loader that uses Tavily's Extract API to + retrieve content from web pages and return it as Document objects. + + Args: + urls: URL or list of URLs to extract content from. + api_key: The Tavily API key. + extract_depth: Depth of extraction, either "basic" or "advanced". + continue_on_failure: Whether to continue if extraction of a URL fails. + """ + + def __init__( + self, + urls: Union[str, List[str]], + api_key: str, + extract_depth: Literal["basic", "advanced"] = "basic", + continue_on_failure: bool = True, + ) -> None: + """Initialize Tavily Extract client. + + Args: + urls: URL or list of URLs to extract content from. + api_key: The Tavily API key. + include_images: Whether to include images in the extraction. + extract_depth: Depth of extraction, either "basic" or "advanced". + advanced extraction retrieves more data, including tables and + embedded content, with higher success but may increase latency. + basic costs 1 credit per 5 successful URL extractions, + advanced costs 2 credits per 5 successful URL extractions. + continue_on_failure: Whether to continue if extraction of a URL fails. + """ + if not urls: + raise ValueError("At least one URL must be provided.") + + self.api_key = api_key + self.urls = urls if isinstance(urls, list) else [urls] + self.extract_depth = extract_depth + self.continue_on_failure = continue_on_failure + self.api_url = "https://api.tavily.com/extract" + + def lazy_load(self) -> Iterator[Document]: + """Extract and yield documents from the URLs using Tavily Extract API.""" + batch_size = 20 + for i in range(0, len(self.urls), batch_size): + batch_urls = self.urls[i : i + batch_size] + try: + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + } + # Use string for single URL, array for multiple URLs + urls_param = batch_urls[0] if len(batch_urls) == 1 else batch_urls + payload = {"urls": urls_param, "extract_depth": self.extract_depth} + # Make the API call + response = requests.post(self.api_url, headers=headers, json=payload) + response.raise_for_status() + response_data = response.json() + # Process successful results + for result in response_data.get("results", []): + url = result.get("url", "") + content = result.get("raw_content", "") + if not content: + log.warning(f"No content extracted from {url}") + continue + # Add URLs as metadata + metadata = {"source": url} + yield Document( + page_content=content, + metadata=metadata, + ) + for failed in response_data.get("failed_results", []): + url = failed.get("url", "") + error = failed.get("error", "Unknown error") + log.error(f"Failed to extract content from {url}: {error}") + except Exception as e: + if self.continue_on_failure: + log.error(f"Error extracting content from batch {batch_urls}: {e}") + else: + raise e diff --git a/backend/open_webui/retrieval/models/colbert.py b/backend/open_webui/retrieval/models/colbert.py index ea3204cb8bf..5b7499fd18b 100644 --- a/backend/open_webui/retrieval/models/colbert.py +++ b/backend/open_webui/retrieval/models/colbert.py @@ -1,13 +1,19 @@ import os +import logging import torch import numpy as np from colbert.infra import ColBERTConfig from colbert.modeling.checkpoint import Checkpoint +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + class ColBERT: def __init__(self, name, **kwargs) -> None: - print("ColBERT: Loading model", name) + log.info("ColBERT: Loading model", name) self.device = "cuda" if torch.cuda.is_available() else "cpu" DOCKER = kwargs.get("env") == "docker" diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index f83d09d9c5d..12d48f86903 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -1,28 +1,35 @@ import logging import os -import uuid from typing import Optional, Union -import asyncio import requests +import hashlib +from concurrent.futures import ThreadPoolExecutor from huggingface_hub import snapshot_download from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriever from langchain_community.retrievers import BM25Retriever from langchain_core.documents import Document - from open_webui.config import VECTOR_DB from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT -from open_webui.utils.misc import get_last_user_message, calculate_sha256_string from open_webui.models.users import UserModel +from open_webui.models.files import Files + +from open_webui.retrieval.vector.main import GetResult + from open_webui.env import ( SRC_LOG_LEVELS, OFFLINE_MODE, ENABLE_FORWARD_USER_INFO_HEADERS, ) +from open_webui.config import ( + RAG_EMBEDDING_QUERY_PREFIX, + RAG_EMBEDDING_CONTENT_PREFIX, + RAG_EMBEDDING_PREFIX_FIELD_NAME, +) log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["RAG"]) @@ -47,7 +54,7 @@ def _get_relevant_documents( ) -> list[Document]: result = VECTOR_DB_CLIENT.search( collection_name=self.collection_name, - vectors=[self.embedding_function(query)], + vectors=[self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX)], limit=self.top_k, ) @@ -81,7 +88,7 @@ def query_doc( return result except Exception as e: - print(e) + log.exception(f"Error querying doc {collection_name} with limit {k}: {e}") raise e @@ -94,24 +101,24 @@ def get_doc(collection_name: str, user: UserModel = None): return result except Exception as e: - print(e) + log.exception(f"Error getting doc {collection_name}: {e}") raise e def query_doc_with_hybrid_search( collection_name: str, + collection_result: GetResult, query: str, embedding_function, k: int, reranking_function, + k_reranker: int, r: float, ) -> dict: try: - result = VECTOR_DB_CLIENT.get(collection_name=collection_name) - bm25_retriever = BM25Retriever.from_texts( - texts=result.documents[0], - metadatas=result.metadatas[0], + texts=collection_result.documents[0], + metadatas=collection_result.metadatas[0], ) bm25_retriever.k = k @@ -126,7 +133,7 @@ def query_doc_with_hybrid_search( ) compressor = RerankCompressor( embedding_function=embedding_function, - top_n=k, + top_n=k_reranker, reranking_function=reranking_function, r_score=r, ) @@ -136,10 +143,23 @@ def query_doc_with_hybrid_search( ) result = compression_retriever.invoke(query) + + distances = [d.metadata.get("score") for d in result] + documents = [d.page_content for d in result] + metadatas = [d.metadata for d in result] + + # retrieve only min(k, k_reranker) items, sort and cut by distance if k < k_reranker + if k < k_reranker: + sorted_items = sorted( + zip(distances, metadatas, documents), key=lambda x: x[0], reverse=True + ) + sorted_items = sorted_items[:k] + distances, documents, metadatas = map(list, zip(*sorted_items)) + result = { - "distances": [[d.metadata.get("score") for d in result]], - "documents": [[d.page_content for d in result]], - "metadatas": [[d.metadata for d in result]], + "distances": [distances], + "documents": [documents], + "metadatas": [metadatas], } log.info( @@ -172,47 +192,44 @@ def merge_get_results(get_results: list[dict]) -> dict: return result -def merge_and_sort_query_results( - query_results: list[dict], k: int, reverse: bool = False -) -> list[dict]: +def merge_and_sort_query_results(query_results: list[dict], k: int) -> dict: # Initialize lists to store combined data - combined_distances = [] - combined_documents = [] - combined_metadatas = [] + combined = dict() # To store documents with unique document hashes for data in query_results: - combined_distances.extend(data["distances"][0]) - combined_documents.extend(data["documents"][0]) - combined_metadatas.extend(data["metadatas"][0]) + distances = data["distances"][0] + documents = data["documents"][0] + metadatas = data["metadatas"][0] - # Create a list of tuples (distance, document, metadata) - combined = list(zip(combined_distances, combined_documents, combined_metadatas)) + for distance, document, metadata in zip(distances, documents, metadatas): + if isinstance(document, str): + doc_hash = hashlib.md5( + document.encode() + ).hexdigest() # Compute a hash for uniqueness - # Sort the list based on distances - combined.sort(key=lambda x: x[0], reverse=reverse) + if doc_hash not in combined.keys(): + combined[doc_hash] = (distance, document, metadata) + continue # if doc is new, no further comparison is needed - # We don't have anything :-( - if not combined: - sorted_distances = [] - sorted_documents = [] - sorted_metadatas = [] - else: - # Unzip the sorted list - sorted_distances, sorted_documents, sorted_metadatas = zip(*combined) + # if doc is alredy in, but new distance is better, update + if distance > combined[doc_hash][0]: + combined[doc_hash] = (distance, document, metadata) - # Slicing the lists to include only k elements - sorted_distances = list(sorted_distances)[:k] - sorted_documents = list(sorted_documents)[:k] - sorted_metadatas = list(sorted_metadatas)[:k] + combined = list(combined.values()) + # Sort the list based on distances + combined.sort(key=lambda x: x[0], reverse=True) - # Create the output dictionary - result = { - "distances": [sorted_distances], - "documents": [sorted_documents], - "metadatas": [sorted_metadatas], - } + # Slice to keep only the top k elements + sorted_distances, sorted_documents, sorted_metadatas = ( + zip(*combined[:k]) if combined else ([], [], []) + ) - return result + # Create and return the output dictionary + return { + "distances": [list(sorted_distances)], + "documents": [list(sorted_documents)], + "metadatas": [list(sorted_metadatas)], + } def get_all_items_from_collections(collection_names: list[str]) -> dict: @@ -240,7 +257,7 @@ def query_collection( ) -> dict: results = [] for query in queries: - query_embedding = embedding_function(query) + query_embedding = embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX) for collection_name in collection_names: if collection_name: try: @@ -256,12 +273,7 @@ def query_collection( else: pass - if VECTOR_DB == "chroma": - # Chroma uses unconventional cosine similarity, so we don't need to reverse the results - # https://docs.trychroma.com/docs/collections/configure#configuring-chroma-collections - return merge_and_sort_query_results(results, k=k, reverse=False) - else: - return merge_and_sort_query_results(results, k=k, reverse=True) + return merge_and_sort_query_results(results, k=k) def query_collection_with_hybrid_search( @@ -270,39 +282,69 @@ def query_collection_with_hybrid_search( embedding_function, k: int, reranking_function, + k_reranker: int, r: float, ) -> dict: results = [] error = False + # Fetch collection data once per collection sequentially + # Avoid fetching the same data multiple times later + collection_results = {} for collection_name in collection_names: try: - for query in queries: - result = query_doc_with_hybrid_search( - collection_name=collection_name, - query=query, - embedding_function=embedding_function, - k=k, - reranking_function=reranking_function, - r=r, - ) - results.append(result) + collection_results[collection_name] = VECTOR_DB_CLIENT.get( + collection_name=collection_name + ) except Exception as e: - log.exception( - "Error when querying the collection with " f"hybrid_search: {e}" + log.exception(f"Failed to fetch collection {collection_name}: {e}") + collection_results[collection_name] = None + + log.info( + f"Starting hybrid search for {len(queries)} queries in {len(collection_names)} collections..." + ) + + def process_query(collection_name, query): + try: + result = query_doc_with_hybrid_search( + collection_name=collection_name, + collection_result=collection_results[collection_name], + query=query, + embedding_function=embedding_function, + k=k, + reranking_function=reranking_function, + k_reranker=k_reranker, + r=r, ) + return result, None + except Exception as e: + log.exception(f"Error when querying the collection with hybrid_search: {e}") + return None, e + + # Prepare tasks for all collections and queries + # Avoid running any tasks for collections that failed to fetch data (have assigned None) + tasks = [ + (cn, q) + for cn in collection_names + if collection_results[cn] is not None + for q in queries + ] + + with ThreadPoolExecutor() as executor: + future_results = [executor.submit(process_query, cn, q) for cn, q in tasks] + task_results = [future.result() for future in future_results] + + for result, err in task_results: + if err is not None: error = True + elif result is not None: + results.append(result) - if error: + if error and not results: raise Exception( - "Hybrid search failed for all collections. Using Non hybrid search as fallback." + "Hybrid search failed for all collections. Using Non-hybrid search as fallback." ) - if VECTOR_DB == "chroma": - # Chroma uses unconventional cosine similarity, so we don't need to reverse the results - # https://docs.trychroma.com/docs/collections/configure#configuring-chroma-collections - return merge_and_sort_query_results(results, k=k, reverse=False) - else: - return merge_and_sort_query_results(results, k=k, reverse=True) + return merge_and_sort_query_results(results, k=k) def get_embedding_function( @@ -314,39 +356,50 @@ def get_embedding_function( embedding_batch_size, ): if embedding_engine == "": - return lambda query, user=None: embedding_function.encode(query).tolist() + return lambda query, prefix=None, user=None: embedding_function.encode( + query, **({"prompt": prefix} if prefix else {}) + ).tolist() elif embedding_engine in ["ollama", "openai"]: - func = lambda query, user=None: generate_embeddings( + func = lambda query, prefix=None, user=None: generate_embeddings( engine=embedding_engine, model=embedding_model, text=query, + prefix=prefix, url=url, key=key, user=user, ) - def generate_multiple(query, user, func): + def generate_multiple(query, prefix, user, func): if isinstance(query, list): embeddings = [] for i in range(0, len(query), embedding_batch_size): embeddings.extend( - func(query[i : i + embedding_batch_size], user=user) + func( + query[i : i + embedding_batch_size], + prefix=prefix, + user=user, + ) ) return embeddings else: - return func(query, user) + return func(query, prefix, user) - return lambda query, user=None: generate_multiple(query, user, func) + return lambda query, prefix=None, user=None: generate_multiple( + query, prefix, user, func + ) else: raise ValueError(f"Unknown embedding engine: {embedding_engine}") def get_sources_from_files( + request, files, queries, embedding_function, k, reranking_function, + k_reranker, r, hybrid_search, full_context=False, @@ -359,19 +412,71 @@ def get_sources_from_files( relevant_contexts = [] for file in files: + + context = None if file.get("docs"): + # BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL context = { "documents": [[doc.get("content") for doc in file.get("docs")]], "metadatas": [[doc.get("metadata") for doc in file.get("docs")]], } elif file.get("context") == "full": + # Manual Full Mode Toggle context = { "documents": [[file.get("file").get("data", {}).get("content")]], "metadatas": [[{"file_id": file.get("id"), "name": file.get("name")}]], } - else: - context = None + elif ( + file.get("type") != "web_search" + and request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL + ): + # BYPASS_EMBEDDING_AND_RETRIEVAL + if file.get("type") == "collection": + file_ids = file.get("data", {}).get("file_ids", []) + + documents = [] + metadatas = [] + for file_id in file_ids: + file_object = Files.get_file_by_id(file_id) + + if file_object: + documents.append(file_object.data.get("content", "")) + metadatas.append( + { + "file_id": file_id, + "name": file_object.filename, + "source": file_object.filename, + } + ) + + context = { + "documents": [documents], + "metadatas": [metadatas], + } + elif file.get("id"): + file_object = Files.get_file_by_id(file.get("id")) + if file_object: + context = { + "documents": [[file_object.data.get("content", "")]], + "metadatas": [ + [ + { + "file_id": file.get("id"), + "name": file_object.filename, + "source": file_object.filename, + } + ] + ], + } + elif file.get("file").get("data"): + context = { + "documents": [[file.get("file").get("data", {}).get("content")]], + "metadatas": [ + [file.get("file").get("data", {}).get("metadata", {})] + ], + } + else: collection_names = [] if file.get("type") == "collection": if file.get("legacy"): @@ -411,6 +516,7 @@ def get_sources_from_files( embedding_function=embedding_function, k=k, reranking_function=reranking_function, + k_reranker=k_reranker, r=r, ) except Exception as e: @@ -434,6 +540,7 @@ def get_sources_from_files( if context: if "data" in file: del file["data"] + relevant_contexts.append({**context, "file": file}) sources = [] @@ -502,9 +609,14 @@ def generate_openai_batch_embeddings( texts: list[str], url: str = "https://api.openai.com/v1", key: str = "", + prefix: str = None, user: UserModel = None, ) -> Optional[list[list[float]]]: try: + json_data = {"input": texts, "model": model} + if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): + json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix + r = requests.post( f"{url}/embeddings", headers={ @@ -521,7 +633,7 @@ def generate_openai_batch_embeddings( else {} ), }, - json={"input": texts, "model": model}, + json=json_data, ) r.raise_for_status() data = r.json() @@ -530,14 +642,23 @@ def generate_openai_batch_embeddings( else: raise "Something went wrong :/" except Exception as e: - print(e) + log.exception(f"Error generating openai batch embeddings: {e}") return None def generate_ollama_batch_embeddings( - model: str, texts: list[str], url: str, key: str = "", user: UserModel = None + model: str, + texts: list[str], + url: str, + key: str = "", + prefix: str = None, + user: UserModel = None, ) -> Optional[list[list[float]]]: try: + json_data = {"input": texts, "model": model} + if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): + json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix + r = requests.post( f"{url}/api/embed", headers={ @@ -554,7 +675,7 @@ def generate_ollama_batch_embeddings( else {} ), }, - json={"input": texts, "model": model}, + json=json_data, ) r.raise_for_status() data = r.json() @@ -564,19 +685,38 @@ def generate_ollama_batch_embeddings( else: raise "Something went wrong :/" except Exception as e: - print(e) + log.exception(f"Error generating ollama batch embeddings: {e}") return None -def generate_embeddings(engine: str, model: str, text: Union[str, list[str]], **kwargs): +def generate_embeddings( + engine: str, + model: str, + text: Union[str, list[str]], + prefix: Union[str, None] = None, + **kwargs, +): url = kwargs.get("url", "") key = kwargs.get("key", "") user = kwargs.get("user") + if prefix is not None and RAG_EMBEDDING_PREFIX_FIELD_NAME is None: + if isinstance(text, list): + text = [f"{prefix}{text_element}" for text_element in text] + else: + text = f"{prefix}{text}" + if engine == "ollama": if isinstance(text, list): embeddings = generate_ollama_batch_embeddings( - **{"model": model, "texts": text, "url": url, "key": key, "user": user} + **{ + "model": model, + "texts": text, + "url": url, + "key": key, + "prefix": prefix, + "user": user, + } ) else: embeddings = generate_ollama_batch_embeddings( @@ -585,16 +725,20 @@ def generate_embeddings(engine: str, model: str, text: Union[str, list[str]], ** "texts": [text], "url": url, "key": key, + "prefix": prefix, "user": user, } ) return embeddings[0] if isinstance(text, str) else embeddings elif engine == "openai": if isinstance(text, list): - embeddings = generate_openai_batch_embeddings(model, text, url, key, user) + embeddings = generate_openai_batch_embeddings( + model, text, url, key, prefix, user + ) else: - embeddings = generate_openai_batch_embeddings(model, [text], url, key, user) - + embeddings = generate_openai_batch_embeddings( + model, [text], url, key, prefix, user + ) return embeddings[0] if isinstance(text, str) else embeddings @@ -630,9 +774,9 @@ def compress_documents( else: from sentence_transformers import util - query_embedding = self.embedding_function(query) + query_embedding = self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX) document_embedding = self.embedding_function( - [doc.page_content for doc in documents] + [doc.page_content for doc in documents], RAG_EMBEDDING_CONTENT_PREFIX ) scores = util.cos_sim(query_embedding, document_embedding)[0] diff --git a/backend/open_webui/retrieval/vector/connector.py b/backend/open_webui/retrieval/vector/connector.py index bf97bc7b1d5..ac8884c043e 100644 --- a/backend/open_webui/retrieval/vector/connector.py +++ b/backend/open_webui/retrieval/vector/connector.py @@ -16,6 +16,10 @@ from open_webui.retrieval.vector.dbs.pgvector import PgvectorClient VECTOR_DB_CLIENT = PgvectorClient() +elif VECTOR_DB == "elasticsearch": + from open_webui.retrieval.vector.dbs.elasticsearch import ElasticsearchClient + + VECTOR_DB_CLIENT = ElasticsearchClient() else: from open_webui.retrieval.vector.dbs.chroma import ChromaClient diff --git a/backend/open_webui/retrieval/vector/dbs/chroma.py b/backend/open_webui/retrieval/vector/dbs/chroma.py old mode 100644 new mode 100755 index c40618fcc5b..a6b97df3e93 --- a/backend/open_webui/retrieval/vector/dbs/chroma.py +++ b/backend/open_webui/retrieval/vector/dbs/chroma.py @@ -1,4 +1,5 @@ import chromadb +import logging from chromadb import Settings from chromadb.utils.batch_utils import create_batches @@ -16,6 +17,10 @@ CHROMA_CLIENT_AUTH_PROVIDER, CHROMA_CLIENT_AUTH_CREDENTIALS, ) +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) class ChromaClient: @@ -70,10 +75,16 @@ def search( n_results=limit, ) + # chromadb has cosine distance, 2 (worst) -> 0 (best). Re-odering to 0 -> 1 + # https://docs.trychroma.com/docs/collections/configure cosine equation + distances: list = result["distances"][0] + distances = [2 - dist for dist in distances] + distances = [[dist / 2 for dist in distances]] + return SearchResult( **{ "ids": result["ids"], - "distances": result["distances"], + "distances": distances, "documents": result["documents"], "metadatas": result["metadatas"], } @@ -102,8 +113,7 @@ def query( } ) return None - except Exception as e: - print(e) + except: return None def get(self, collection_name: str) -> Optional[GetResult]: @@ -162,12 +172,19 @@ def delete( filter: Optional[dict] = None, ): # Delete the items from the collection based on the ids. - collection = self.client.get_collection(name=collection_name) - if collection: - if ids: - collection.delete(ids=ids) - elif filter: - collection.delete(where=filter) + try: + collection = self.client.get_collection(name=collection_name) + if collection: + if ids: + collection.delete(ids=ids) + elif filter: + collection.delete(where=filter) + except Exception as e: + # If collection doesn't exist, that's fine - nothing to delete + log.debug( + f"Attempted to delete from non-existent collection {collection_name}. Ignoring." + ) + pass def reset(self): # Resets the database. This will delete all collections and item entries. diff --git a/backend/open_webui/retrieval/vector/dbs/elasticsearch.py b/backend/open_webui/retrieval/vector/dbs/elasticsearch.py new file mode 100644 index 00000000000..c896284946e --- /dev/null +++ b/backend/open_webui/retrieval/vector/dbs/elasticsearch.py @@ -0,0 +1,295 @@ +from elasticsearch import Elasticsearch, BadRequestError +from typing import Optional +import ssl +from elasticsearch.helpers import bulk, scan +from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult +from open_webui.config import ( + ELASTICSEARCH_URL, + ELASTICSEARCH_CA_CERTS, + ELASTICSEARCH_API_KEY, + ELASTICSEARCH_USERNAME, + ELASTICSEARCH_PASSWORD, + ELASTICSEARCH_CLOUD_ID, + ELASTICSEARCH_INDEX_PREFIX, + SSL_ASSERT_FINGERPRINT, +) + + +class ElasticsearchClient: + """ + Important: + in order to reduce the number of indexes and since the embedding vector length is fixed, we avoid creating + an index for each file but store it as a text field, while seperating to different index + baesd on the embedding length. + """ + + def __init__(self): + self.index_prefix = ELASTICSEARCH_INDEX_PREFIX + self.client = Elasticsearch( + hosts=[ELASTICSEARCH_URL], + ca_certs=ELASTICSEARCH_CA_CERTS, + api_key=ELASTICSEARCH_API_KEY, + cloud_id=ELASTICSEARCH_CLOUD_ID, + basic_auth=( + (ELASTICSEARCH_USERNAME, ELASTICSEARCH_PASSWORD) + if ELASTICSEARCH_USERNAME and ELASTICSEARCH_PASSWORD + else None + ), + ssl_assert_fingerprint=SSL_ASSERT_FINGERPRINT, + ) + + # Status: works + def _get_index_name(self, dimension: int) -> str: + return f"{self.index_prefix}_d{str(dimension)}" + + # Status: works + def _scan_result_to_get_result(self, result) -> GetResult: + if not result: + return None + ids = [] + documents = [] + metadatas = [] + + for hit in result: + ids.append(hit["_id"]) + documents.append(hit["_source"].get("text")) + metadatas.append(hit["_source"].get("metadata")) + + return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas]) + + # Status: works + def _result_to_get_result(self, result) -> GetResult: + if not result["hits"]["hits"]: + return None + ids = [] + documents = [] + metadatas = [] + + for hit in result["hits"]["hits"]: + ids.append(hit["_id"]) + documents.append(hit["_source"].get("text")) + metadatas.append(hit["_source"].get("metadata")) + + return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas]) + + # Status: works + def _result_to_search_result(self, result) -> SearchResult: + ids = [] + distances = [] + documents = [] + metadatas = [] + + for hit in result["hits"]["hits"]: + ids.append(hit["_id"]) + distances.append(hit["_score"]) + documents.append(hit["_source"].get("text")) + metadatas.append(hit["_source"].get("metadata")) + + return SearchResult( + ids=[ids], + distances=[distances], + documents=[documents], + metadatas=[metadatas], + ) + + # Status: works + def _create_index(self, dimension: int): + body = { + "mappings": { + "dynamic_templates": [ + { + "strings": { + "match_mapping_type": "string", + "mapping": {"type": "keyword"}, + } + } + ], + "properties": { + "collection": {"type": "keyword"}, + "id": {"type": "keyword"}, + "vector": { + "type": "dense_vector", + "dims": dimension, # Adjust based on your vector dimensions + "index": True, + "similarity": "cosine", + }, + "text": {"type": "text"}, + "metadata": {"type": "object"}, + }, + } + } + self.client.indices.create(index=self._get_index_name(dimension), body=body) + + # Status: works + + def _create_batches(self, items: list[VectorItem], batch_size=100): + for i in range(0, len(items), batch_size): + yield items[i : min(i + batch_size, len(items))] + + # Status: works + def has_collection(self, collection_name) -> bool: + query_body = {"query": {"bool": {"filter": []}}} + query_body["query"]["bool"]["filter"].append( + {"term": {"collection": collection_name}} + ) + + try: + result = self.client.count(index=f"{self.index_prefix}*", body=query_body) + + return result.body["count"] > 0 + except Exception as e: + return None + + def delete_collection(self, collection_name: str): + query = {"query": {"term": {"collection": collection_name}}} + self.client.delete_by_query(index=f"{self.index_prefix}*", body=query) + + # Status: works + def search( + self, collection_name: str, vectors: list[list[float]], limit: int + ) -> Optional[SearchResult]: + query = { + "size": limit, + "_source": ["text", "metadata"], + "query": { + "script_score": { + "query": { + "bool": {"filter": [{"term": {"collection": collection_name}}]} + }, + "script": { + "source": "cosineSimilarity(params.vector, 'vector') + 1.0", + "params": { + "vector": vectors[0] + }, # Assuming single query vector + }, + } + }, + } + + result = self.client.search( + index=self._get_index_name(len(vectors[0])), body=query + ) + + return self._result_to_search_result(result) + + # Status: only tested halfwat + def query( + self, collection_name: str, filter: dict, limit: Optional[int] = None + ) -> Optional[GetResult]: + if not self.has_collection(collection_name): + return None + + query_body = { + "query": {"bool": {"filter": []}}, + "_source": ["text", "metadata"], + } + + for field, value in filter.items(): + query_body["query"]["bool"]["filter"].append({"term": {field: value}}) + query_body["query"]["bool"]["filter"].append( + {"term": {"collection": collection_name}} + ) + size = limit if limit else 10 + + try: + result = self.client.search( + index=f"{self.index_prefix}*", + body=query_body, + size=size, + ) + + return self._result_to_get_result(result) + + except Exception as e: + return None + + # Status: works + def _has_index(self, dimension: int): + return self.client.indices.exists( + index=self._get_index_name(dimension=dimension) + ) + + def get_or_create_index(self, dimension: int): + if not self._has_index(dimension=dimension): + self._create_index(dimension=dimension) + + # Status: works + def get(self, collection_name: str) -> Optional[GetResult]: + # Get all the items in the collection. + query = { + "query": {"bool": {"filter": [{"term": {"collection": collection_name}}]}}, + "_source": ["text", "metadata"], + } + results = list(scan(self.client, index=f"{self.index_prefix}*", query=query)) + + return self._scan_result_to_get_result(results) + + # Status: works + def insert(self, collection_name: str, items: list[VectorItem]): + if not self._has_index(dimension=len(items[0]["vector"])): + self._create_index(dimension=len(items[0]["vector"])) + + for batch in self._create_batches(items): + actions = [ + { + "_index": self._get_index_name(dimension=len(items[0]["vector"])), + "_id": item["id"], + "_source": { + "collection": collection_name, + "vector": item["vector"], + "text": item["text"], + "metadata": item["metadata"], + }, + } + for item in batch + ] + bulk(self.client, actions) + + # Upsert documents using the update API with doc_as_upsert=True. + def upsert(self, collection_name: str, items: list[VectorItem]): + if not self._has_index(dimension=len(items[0]["vector"])): + self._create_index(dimension=len(items[0]["vector"])) + for batch in self._create_batches(items): + actions = [ + { + "_op_type": "update", + "_index": self._get_index_name(dimension=len(item["vector"])), + "_id": item["id"], + "doc": { + "collection": collection_name, + "vector": item["vector"], + "text": item["text"], + "metadata": item["metadata"], + }, + "doc_as_upsert": True, + } + for item in batch + ] + bulk(self.client, actions) + + # Delete specific documents from a collection by filtering on both collection and document IDs. + def delete( + self, + collection_name: str, + ids: Optional[list[str]] = None, + filter: Optional[dict] = None, + ): + + query = { + "query": {"bool": {"filter": [{"term": {"collection": collection_name}}]}} + } + # logic based on chromaDB + if ids: + query["query"]["bool"]["filter"].append({"terms": {"_id": ids}}) + elif filter: + for field, value in filter.items(): + query["query"]["bool"]["filter"].append( + {"term": {f"metadata.{field}": value}} + ) + + self.client.delete_by_query(index=f"{self.index_prefix}*", body=query) + + def reset(self): + indices = self.client.indices.get(index=f"{self.index_prefix}*") + for index in indices: + self.client.indices.delete(index=index) diff --git a/backend/open_webui/retrieval/vector/dbs/milvus.py b/backend/open_webui/retrieval/vector/dbs/milvus.py index 43c3f3d1a1d..26b4dd5ed26 100644 --- a/backend/open_webui/retrieval/vector/dbs/milvus.py +++ b/backend/open_webui/retrieval/vector/dbs/milvus.py @@ -1,7 +1,7 @@ from pymilvus import MilvusClient as Client from pymilvus import FieldSchema, DataType import json - +import logging from typing import Optional from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult @@ -10,15 +10,19 @@ MILVUS_DB, MILVUS_TOKEN, ) +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) class MilvusClient: def __init__(self): self.collection_prefix = "open_webui" if MILVUS_TOKEN is None: - self.client = Client(uri=MILVUS_URI, database=MILVUS_DB) + self.client = Client(uri=MILVUS_URI, db_name=MILVUS_DB) else: - self.client = Client(uri=MILVUS_URI, database=MILVUS_DB, token=MILVUS_TOKEN) + self.client = Client(uri=MILVUS_URI, db_name=MILVUS_DB, token=MILVUS_TOKEN) def _result_to_get_result(self, result) -> GetResult: ids = [] @@ -60,7 +64,10 @@ def _result_to_search_result(self, result) -> SearchResult: for item in match: _ids.append(item.get("id")) - _distances.append(item.get("distance")) + # normalize milvus score from [-1, 1] to [0, 1] range + # https://milvus.io/docs/de/metric.md + _dist = (item.get("distance") + 1.0) / 2.0 + _distances.append(_dist) _documents.append(item.get("entity", {}).get("data", {}).get("text")) _metadatas.append(item.get("entity", {}).get("metadata")) @@ -168,7 +175,7 @@ def query(self, collection_name: str, filter: dict, limit: Optional[int] = None) try: # Loop until there are no more items to fetch or the desired limit is reached while remaining > 0: - print("remaining", remaining) + log.info(f"remaining: {remaining}") current_fetch = min( max_limit, remaining ) # Determine how many items to fetch in this iteration @@ -195,10 +202,12 @@ def query(self, collection_name: str, filter: dict, limit: Optional[int] = None) if results_count < current_fetch: break - print(all_results) + log.debug(all_results) return self._result_to_get_result([all_results]) except Exception as e: - print(e) + log.exception( + f"Error querying collection {collection_name} with limit {limit}: {e}" + ) return None def get(self, collection_name: str) -> Optional[GetResult]: diff --git a/backend/open_webui/retrieval/vector/dbs/opensearch.py b/backend/open_webui/retrieval/vector/dbs/opensearch.py index b8186b3f932..432bcef412e 100644 --- a/backend/open_webui/retrieval/vector/dbs/opensearch.py +++ b/backend/open_webui/retrieval/vector/dbs/opensearch.py @@ -1,4 +1,5 @@ from opensearchpy import OpenSearch +from opensearchpy.helpers import bulk from typing import Optional from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult @@ -21,7 +22,13 @@ def __init__(self): http_auth=(OPENSEARCH_USERNAME, OPENSEARCH_PASSWORD), ) + def _get_index_name(self, collection_name: str) -> str: + return f"{self.index_prefix}_{collection_name}" + def _result_to_get_result(self, result) -> GetResult: + if not result["hits"]["hits"]: + return None + ids = [] documents = [] metadatas = [] @@ -31,9 +38,12 @@ def _result_to_get_result(self, result) -> GetResult: documents.append(hit["_source"].get("text")) metadatas.append(hit["_source"].get("metadata")) - return GetResult(ids=ids, documents=documents, metadatas=metadatas) + return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas]) def _result_to_search_result(self, result) -> SearchResult: + if not result["hits"]["hits"]: + return None + ids = [] distances = [] documents = [] @@ -46,72 +56,88 @@ def _result_to_search_result(self, result) -> SearchResult: metadatas.append(hit["_source"].get("metadata")) return SearchResult( - ids=ids, distances=distances, documents=documents, metadatas=metadatas + ids=[ids], + distances=[distances], + documents=[documents], + metadatas=[metadatas], ) - def _create_index(self, index_name: str, dimension: int): + def _create_index(self, collection_name: str, dimension: int): body = { + "settings": {"index": {"knn": True}}, "mappings": { "properties": { "id": {"type": "keyword"}, "vector": { - "type": "dense_vector", - "dims": dimension, # Adjust based on your vector dimensions - "index": true, + "type": "knn_vector", + "dimension": dimension, # Adjust based on your vector dimensions + "index": True, "similarity": "faiss", "method": { "name": "hnsw", - "space_type": "ip", # Use inner product to approximate cosine similarity + "space_type": "innerproduct", # Use inner product to approximate cosine similarity "engine": "faiss", - "ef_construction": 128, - "m": 16, + "parameters": { + "ef_construction": 128, + "m": 16, + }, }, }, "text": {"type": "text"}, "metadata": {"type": "object"}, } - } + }, } - self.client.indices.create(index=f"{self.index_prefix}_{index_name}", body=body) + self.client.indices.create( + index=self._get_index_name(collection_name), body=body + ) def _create_batches(self, items: list[VectorItem], batch_size=100): for i in range(0, len(items), batch_size): yield items[i : i + batch_size] - def has_collection(self, index_name: str) -> bool: + def has_collection(self, collection_name: str) -> bool: # has_collection here means has index. # We are simply adapting to the norms of the other DBs. - return self.client.indices.exists(index=f"{self.index_prefix}_{index_name}") + return self.client.indices.exists(index=self._get_index_name(collection_name)) - def delete_colleciton(self, index_name: str): + def delete_collection(self, collection_name: str): # delete_collection here means delete index. # We are simply adapting to the norms of the other DBs. - self.client.indices.delete(index=f"{self.index_prefix}_{index_name}") + self.client.indices.delete(index=self._get_index_name(collection_name)) def search( - self, index_name: str, vectors: list[list[float]], limit: int + self, collection_name: str, vectors: list[list[float | int]], limit: int ) -> Optional[SearchResult]: - query = { - "size": limit, - "_source": ["text", "metadata"], - "query": { - "script_score": { - "query": {"match_all": {}}, - "script": { - "source": "cosineSimilarity(params.vector, 'vector') + 1.0", - "params": { - "vector": vectors[0] - }, # Assuming single query vector - }, - } - }, - } + try: + if not self.has_collection(collection_name): + return None + + query = { + "size": limit, + "_source": ["text", "metadata"], + "query": { + "script_score": { + "query": {"match_all": {}}, + "script": { + "source": "(cosineSimilarity(params.query_value, doc[params.field]) + 1.0) / 2.0", + "params": { + "field": "vector", + "query_value": vectors[0], + }, # Assuming single query vector + }, + } + }, + } - result = self.client.search( - index=f"{self.index_prefix}_{index_name}", body=query - ) + result = self.client.search( + index=self._get_index_name(collection_name), body=query + ) - return self._result_to_search_result(result) + return self._result_to_search_result(result) + + except Exception as e: + return None def query( self, collection_name: str, filter: dict, limit: Optional[int] = None @@ -125,13 +151,15 @@ def query( } for field, value in filter.items(): - query_body["query"]["bool"]["filter"].append({"term": {field: value}}) + query_body["query"]["bool"]["filter"].append( + {"match": {"metadata." + str(field): value}} + ) size = limit if limit else 10 try: result = self.client.search( - index=f"{self.index_prefix}_{collection_name}", + index=self._get_index_name(collection_name), body=query_body, size=size, ) @@ -141,64 +169,88 @@ def query( except Exception as e: return None - def get_or_create_index(self, index_name: str, dimension: int): - if not self.has_index(index_name): - self._create_index(index_name, dimension) + def _create_index_if_not_exists(self, collection_name: str, dimension: int): + if not self.has_collection(collection_name): + self._create_index(collection_name, dimension) - def get(self, index_name: str) -> Optional[GetResult]: + def get(self, collection_name: str) -> Optional[GetResult]: query = {"query": {"match_all": {}}, "_source": ["text", "metadata"]} result = self.client.search( - index=f"{self.index_prefix}_{index_name}", body=query + index=self._get_index_name(collection_name), body=query ) return self._result_to_get_result(result) - def insert(self, index_name: str, items: list[VectorItem]): - if not self.has_index(index_name): - self._create_index(index_name, dimension=len(items[0]["vector"])) + def insert(self, collection_name: str, items: list[VectorItem]): + self._create_index_if_not_exists( + collection_name=collection_name, dimension=len(items[0]["vector"]) + ) for batch in self._create_batches(items): actions = [ { - "index": { - "_id": item["id"], - "_source": { - "vector": item["vector"], - "text": item["text"], - "metadata": item["metadata"], - }, - } + "_op_type": "index", + "_index": self._get_index_name(collection_name), + "_id": item["id"], + "_source": { + "vector": item["vector"], + "text": item["text"], + "metadata": item["metadata"], + }, } for item in batch ] - self.client.bulk(actions) + bulk(self.client, actions) - def upsert(self, index_name: str, items: list[VectorItem]): - if not self.has_index(index_name): - self._create_index(index_name, dimension=len(items[0]["vector"])) + def upsert(self, collection_name: str, items: list[VectorItem]): + self._create_index_if_not_exists( + collection_name=collection_name, dimension=len(items[0]["vector"]) + ) for batch in self._create_batches(items): actions = [ { - "index": { - "_id": item["id"], - "_source": { - "vector": item["vector"], - "text": item["text"], - "metadata": item["metadata"], - }, - } + "_op_type": "update", + "_index": self._get_index_name(collection_name), + "_id": item["id"], + "doc": { + "vector": item["vector"], + "text": item["text"], + "metadata": item["metadata"], + }, + "doc_as_upsert": True, } for item in batch ] - self.client.bulk(actions) - - def delete(self, index_name: str, ids: list[str]): - actions = [ - {"delete": {"_index": f"{self.index_prefix}_{index_name}", "_id": id}} - for id in ids - ] - self.client.bulk(body=actions) + bulk(self.client, actions) + + def delete( + self, + collection_name: str, + ids: Optional[list[str]] = None, + filter: Optional[dict] = None, + ): + if ids: + actions = [ + { + "_op_type": "delete", + "_index": self._get_index_name(collection_name), + "_id": id, + } + for id in ids + ] + bulk(self.client, actions) + elif filter: + query_body = { + "query": {"bool": {"filter": []}}, + } + for field, value in filter.items(): + query_body["query"]["bool"]["filter"].append( + {"match": {"metadata." + str(field): value}} + ) + self.client.delete_by_query( + index=self._get_index_name(collection_name), body=query_body + ) def reset(self): indices = self.client.indices.get(index=f"{self.index_prefix}_*") diff --git a/backend/open_webui/retrieval/vector/dbs/pgvector.py b/backend/open_webui/retrieval/vector/dbs/pgvector.py index 341b3056faf..c38dbb03679 100644 --- a/backend/open_webui/retrieval/vector/dbs/pgvector.py +++ b/backend/open_webui/retrieval/vector/dbs/pgvector.py @@ -1,4 +1,5 @@ from typing import Optional, List, Dict, Any +import logging from sqlalchemy import ( cast, column, @@ -24,9 +25,14 @@ from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult from open_webui.config import PGVECTOR_DB_URL, PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH +from open_webui.env import SRC_LOG_LEVELS + VECTOR_LENGTH = PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH Base = declarative_base() +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + class DocumentChunk(Base): __tablename__ = "document_chunk" @@ -82,10 +88,10 @@ def __init__(self) -> None: ) ) self.session.commit() - print("Initialization complete.") + log.info("Initialization complete.") except Exception as e: self.session.rollback() - print(f"Error during initialization: {e}") + log.exception(f"Error during initialization: {e}") raise def check_vector_length(self) -> None: @@ -150,12 +156,12 @@ def insert(self, collection_name: str, items: List[VectorItem]) -> None: new_items.append(new_chunk) self.session.bulk_save_objects(new_items) self.session.commit() - print( + log.info( f"Inserted {len(new_items)} items into collection '{collection_name}'." ) except Exception as e: self.session.rollback() - print(f"Error during insert: {e}") + log.exception(f"Error during insert: {e}") raise def upsert(self, collection_name: str, items: List[VectorItem]) -> None: @@ -184,10 +190,12 @@ def upsert(self, collection_name: str, items: List[VectorItem]) -> None: ) self.session.add(new_chunk) self.session.commit() - print(f"Upserted {len(items)} items into collection '{collection_name}'.") + log.info( + f"Upserted {len(items)} items into collection '{collection_name}'." + ) except Exception as e: self.session.rollback() - print(f"Error during upsert: {e}") + log.exception(f"Error during upsert: {e}") raise def search( @@ -270,7 +278,9 @@ def vector_expr(vector): for row in results: qid = int(row.qid) ids[qid].append(row.id) - distances[qid].append(row.distance) + # normalize and re-orders pgvec distance from [2, 0] to [0, 1] score range + # https://github.com/pgvector/pgvector?tab=readme-ov-file#querying + distances[qid].append((2.0 - row.distance) / 2.0) documents[qid].append(row.text) metadatas[qid].append(row.vmetadata) @@ -278,7 +288,7 @@ def vector_expr(vector): ids=ids, distances=distances, documents=documents, metadatas=metadatas ) except Exception as e: - print(f"Error during search: {e}") + log.exception(f"Error during search: {e}") return None def query( @@ -310,7 +320,7 @@ def query( metadatas=metadatas, ) except Exception as e: - print(f"Error during query: {e}") + log.exception(f"Error during query: {e}") return None def get( @@ -334,7 +344,7 @@ def get( return GetResult(ids=ids, documents=documents, metadatas=metadatas) except Exception as e: - print(f"Error during get: {e}") + log.exception(f"Error during get: {e}") return None def delete( @@ -356,22 +366,22 @@ def delete( ) deleted = query.delete(synchronize_session=False) self.session.commit() - print(f"Deleted {deleted} items from collection '{collection_name}'.") + log.info(f"Deleted {deleted} items from collection '{collection_name}'.") except Exception as e: self.session.rollback() - print(f"Error during delete: {e}") + log.exception(f"Error during delete: {e}") raise def reset(self) -> None: try: deleted = self.session.query(DocumentChunk).delete() self.session.commit() - print( + log.info( f"Reset complete. Deleted {deleted} items from 'document_chunk' table." ) except Exception as e: self.session.rollback() - print(f"Error during reset: {e}") + log.exception(f"Error during reset: {e}") raise def close(self) -> None: @@ -387,9 +397,9 @@ def has_collection(self, collection_name: str) -> bool: ) return exists except Exception as e: - print(f"Error checking collection existence: {e}") + log.exception(f"Error checking collection existence: {e}") return False def delete_collection(self, collection_name: str) -> None: self.delete(collection_name) - print(f"Collection '{collection_name}' deleted.") + log.info(f"Collection '{collection_name}' deleted.") diff --git a/backend/open_webui/retrieval/vector/dbs/qdrant.py b/backend/open_webui/retrieval/vector/dbs/qdrant.py index f077ae45aca..be0df6c6acb 100644 --- a/backend/open_webui/retrieval/vector/dbs/qdrant.py +++ b/backend/open_webui/retrieval/vector/dbs/qdrant.py @@ -1,4 +1,5 @@ from typing import Optional +import logging from qdrant_client import QdrantClient as Qclient from qdrant_client.http.models import PointStruct @@ -6,9 +7,13 @@ from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult from open_webui.config import QDRANT_URI, QDRANT_API_KEY +from open_webui.env import SRC_LOG_LEVELS NO_LIMIT = 999999999 +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + class QdrantClient: def __init__(self): @@ -49,7 +54,7 @@ def _create_collection(self, collection_name: str, dimension: int): ), ) - print(f"collection {collection_name_with_prefix} successfully created!") + log.info(f"collection {collection_name_with_prefix} successfully created!") def _create_collection_if_not_exists(self, collection_name, dimension): if not self.has_collection(collection_name=collection_name): @@ -94,7 +99,8 @@ def search( ids=get_result.ids, documents=get_result.documents, metadatas=get_result.metadatas, - distances=[[point.score for point in query_response.points]], + # qdrant distance is [-1, 1], normalize to [0, 1] + distances=[[(point.score + 1.0) / 2.0 for point in query_response.points]], ) def query(self, collection_name: str, filter: dict, limit: Optional[int] = None): @@ -120,7 +126,7 @@ def query(self, collection_name: str, filter: dict, limit: Optional[int] = None) ) return self._result_to_get_result(points.points) except Exception as e: - print(e) + log.exception(f"Error querying a collection '{collection_name}': {e}") return None def get(self, collection_name: str) -> Optional[GetResult]: diff --git a/backend/open_webui/retrieval/web/perplexity.py b/backend/open_webui/retrieval/web/perplexity.py new file mode 100644 index 00000000000..e5314eb1f73 --- /dev/null +++ b/backend/open_webui/retrieval/web/perplexity.py @@ -0,0 +1,87 @@ +import logging +from typing import Optional, List +import requests + +from open_webui.retrieval.web.main import SearchResult, get_filtered_results +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +def search_perplexity( + api_key: str, + query: str, + count: int, + filter_list: Optional[list[str]] = None, +) -> list[SearchResult]: + """Search using Perplexity API and return the results as a list of SearchResult objects. + + Args: + api_key (str): A Perplexity API key + query (str): The query to search for + count (int): Maximum number of results to return + + """ + + # Handle PersistentConfig object + if hasattr(api_key, "__str__"): + api_key = str(api_key) + + try: + url = "https://api.perplexity.ai/chat/completions" + + # Create payload for the API call + payload = { + "model": "sonar", + "messages": [ + { + "role": "system", + "content": "You are a search assistant. Provide factual information with citations.", + }, + {"role": "user", "content": query}, + ], + "temperature": 0.2, # Lower temperature for more factual responses + "stream": False, + } + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + # Make the API request + response = requests.request("POST", url, json=payload, headers=headers) + + # Parse the JSON response + json_response = response.json() + + # Extract citations from the response + citations = json_response.get("citations", []) + + # Create search results from citations + results = [] + for i, citation in enumerate(citations[:count]): + # Extract content from the response to use as snippet + content = "" + if "choices" in json_response and json_response["choices"]: + if i == 0: + content = json_response["choices"][0]["message"]["content"] + + result = {"link": citation, "title": f"Source {i+1}", "snippet": content} + results.append(result) + + if filter_list: + + results = get_filtered_results(results, filter_list) + + return [ + SearchResult( + link=result["link"], title=result["title"], snippet=result["snippet"] + ) + for result in results[:count] + ] + + except Exception as e: + log.error(f"Error searching with Perplexity API: {e}") + return [] diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index fd94a1a32f6..942cb8483fa 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -24,13 +24,17 @@ from langchain_community.document_loaders.firecrawl import FireCrawlLoader from langchain_community.document_loaders.base import BaseLoader from langchain_core.documents import Document +from open_webui.retrieval.loaders.tavily import TavilyLoader from open_webui.constants import ERROR_MESSAGES from open_webui.config import ( ENABLE_RAG_LOCAL_WEB_FETCH, PLAYWRIGHT_WS_URI, + PLAYWRIGHT_TIMEOUT, RAG_WEB_LOADER_ENGINE, FIRECRAWL_API_BASE_URL, FIRECRAWL_API_KEY, + TAVILY_API_KEY, + TAVILY_EXTRACT_DEPTH, ) from open_webui.env import SRC_LOG_LEVELS @@ -113,7 +117,47 @@ def verify_ssl_cert(url: str) -> bool: return False -class SafeFireCrawlLoader(BaseLoader): +class RateLimitMixin: + async def _wait_for_rate_limit(self): + """Wait to respect the rate limit if specified.""" + if self.requests_per_second and self.last_request_time: + min_interval = timedelta(seconds=1.0 / self.requests_per_second) + time_since_last = datetime.now() - self.last_request_time + if time_since_last < min_interval: + await asyncio.sleep((min_interval - time_since_last).total_seconds()) + self.last_request_time = datetime.now() + + def _sync_wait_for_rate_limit(self): + """Synchronous version of rate limit wait.""" + if self.requests_per_second and self.last_request_time: + min_interval = timedelta(seconds=1.0 / self.requests_per_second) + time_since_last = datetime.now() - self.last_request_time + if time_since_last < min_interval: + time.sleep((min_interval - time_since_last).total_seconds()) + self.last_request_time = datetime.now() + + +class URLProcessingMixin: + def _verify_ssl_cert(self, url: str) -> bool: + """Verify SSL certificate for a URL.""" + return verify_ssl_cert(url) + + async def _safe_process_url(self, url: str) -> bool: + """Perform safety checks before processing a URL.""" + if self.verify_ssl and not self._verify_ssl_cert(url): + raise ValueError(f"SSL certificate verification failed for {url}") + await self._wait_for_rate_limit() + return True + + def _safe_process_url_sync(self, url: str) -> bool: + """Synchronous version of safety checks.""" + if self.verify_ssl and not self._verify_ssl_cert(url): + raise ValueError(f"SSL certificate verification failed for {url}") + self._sync_wait_for_rate_limit() + return True + + +class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin): def __init__( self, web_paths, @@ -184,7 +228,7 @@ def lazy_load(self) -> Iterator[Document]: yield from loader.lazy_load() except Exception as e: if self.continue_on_failure: - log.exception(e, "Error loading %s", url) + log.exception(f"Error loading {url}: {e}") continue raise e @@ -204,47 +248,124 @@ async def alazy_load(self): yield document except Exception as e: if self.continue_on_failure: - log.exception(e, "Error loading %s", url) + log.exception(f"Error loading {url}: {e}") continue raise e - def _verify_ssl_cert(self, url: str) -> bool: - return verify_ssl_cert(url) - async def _wait_for_rate_limit(self): - """Wait to respect the rate limit if specified.""" - if self.requests_per_second and self.last_request_time: - min_interval = timedelta(seconds=1.0 / self.requests_per_second) - time_since_last = datetime.now() - self.last_request_time - if time_since_last < min_interval: - await asyncio.sleep((min_interval - time_since_last).total_seconds()) - self.last_request_time = datetime.now() +class SafeTavilyLoader(BaseLoader, RateLimitMixin, URLProcessingMixin): + def __init__( + self, + web_paths: Union[str, List[str]], + api_key: str, + extract_depth: Literal["basic", "advanced"] = "basic", + continue_on_failure: bool = True, + requests_per_second: Optional[float] = None, + verify_ssl: bool = True, + trust_env: bool = False, + proxy: Optional[Dict[str, str]] = None, + ): + """Initialize SafeTavilyLoader with rate limiting and SSL verification support. - def _sync_wait_for_rate_limit(self): - """Synchronous version of rate limit wait.""" - if self.requests_per_second and self.last_request_time: - min_interval = timedelta(seconds=1.0 / self.requests_per_second) - time_since_last = datetime.now() - self.last_request_time - if time_since_last < min_interval: - time.sleep((min_interval - time_since_last).total_seconds()) - self.last_request_time = datetime.now() + Args: + web_paths: List of URLs/paths to process. + api_key: The Tavily API key. + extract_depth: Depth of extraction ("basic" or "advanced"). + continue_on_failure: Whether to continue if extraction of a URL fails. + requests_per_second: Number of requests per second to limit to. + verify_ssl: If True, verify SSL certificates. + trust_env: If True, use proxy settings from environment variables. + proxy: Optional proxy configuration. + """ + # Initialize proxy configuration if using environment variables + proxy_server = proxy.get("server") if proxy else None + if trust_env and not proxy_server: + env_proxies = urllib.request.getproxies() + env_proxy_server = env_proxies.get("https") or env_proxies.get("http") + if env_proxy_server: + if proxy: + proxy["server"] = env_proxy_server + else: + proxy = {"server": env_proxy_server} - async def _safe_process_url(self, url: str) -> bool: - """Perform safety checks before processing a URL.""" - if self.verify_ssl and not self._verify_ssl_cert(url): - raise ValueError(f"SSL certificate verification failed for {url}") - await self._wait_for_rate_limit() - return True + # Store parameters for creating TavilyLoader instances + self.web_paths = web_paths if isinstance(web_paths, list) else [web_paths] + self.api_key = api_key + self.extract_depth = extract_depth + self.continue_on_failure = continue_on_failure + self.verify_ssl = verify_ssl + self.trust_env = trust_env + self.proxy = proxy - def _safe_process_url_sync(self, url: str) -> bool: - """Synchronous version of safety checks.""" - if self.verify_ssl and not self._verify_ssl_cert(url): - raise ValueError(f"SSL certificate verification failed for {url}") - self._sync_wait_for_rate_limit() - return True + # Add rate limiting + self.requests_per_second = requests_per_second + self.last_request_time = None + def lazy_load(self) -> Iterator[Document]: + """Load documents with rate limiting support, delegating to TavilyLoader.""" + valid_urls = [] + for url in self.web_paths: + try: + self._safe_process_url_sync(url) + valid_urls.append(url) + except Exception as e: + log.warning(f"SSL verification failed for {url}: {str(e)}") + if not self.continue_on_failure: + raise e + if not valid_urls: + if self.continue_on_failure: + log.warning("No valid URLs to process after SSL verification") + return + raise ValueError("No valid URLs to process after SSL verification") + try: + loader = TavilyLoader( + urls=valid_urls, + api_key=self.api_key, + extract_depth=self.extract_depth, + continue_on_failure=self.continue_on_failure, + ) + yield from loader.lazy_load() + except Exception as e: + if self.continue_on_failure: + log.exception(f"Error extracting content from URLs: {e}") + else: + raise e -class SafePlaywrightURLLoader(PlaywrightURLLoader): + async def alazy_load(self) -> AsyncIterator[Document]: + """Async version with rate limiting and SSL verification.""" + valid_urls = [] + for url in self.web_paths: + try: + await self._safe_process_url(url) + valid_urls.append(url) + except Exception as e: + log.warning(f"SSL verification failed for {url}: {str(e)}") + if not self.continue_on_failure: + raise e + + if not valid_urls: + if self.continue_on_failure: + log.warning("No valid URLs to process after SSL verification") + return + raise ValueError("No valid URLs to process after SSL verification") + + try: + loader = TavilyLoader( + urls=valid_urls, + api_key=self.api_key, + extract_depth=self.extract_depth, + continue_on_failure=self.continue_on_failure, + ) + async for document in loader.alazy_load(): + yield document + except Exception as e: + if self.continue_on_failure: + log.exception(f"Error loading URLs: {e}") + else: + raise e + + +class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessingMixin): """Load HTML pages safely with Playwright, supporting SSL verification, rate limiting, and remote browser connection. Attributes: @@ -256,6 +377,7 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader): headless (bool): If True, the browser will run in headless mode. proxy (dict): Proxy override settings for the Playwright session. playwright_ws_url (Optional[str]): WebSocket endpoint URI for remote browser connection. + playwright_timeout (Optional[int]): Maximum operation time in milliseconds. """ def __init__( @@ -269,6 +391,7 @@ def __init__( remove_selectors: Optional[List[str]] = None, proxy: Optional[Dict[str, str]] = None, playwright_ws_url: Optional[str] = None, + playwright_timeout: Optional[int] = 10000, ): """Initialize with additional safety parameters and remote browser support.""" @@ -295,6 +418,7 @@ def __init__( self.last_request_time = None self.playwright_ws_url = playwright_ws_url self.trust_env = trust_env + self.playwright_timeout = playwright_timeout def lazy_load(self) -> Iterator[Document]: """Safely load URLs synchronously with support for remote browser.""" @@ -311,7 +435,7 @@ def lazy_load(self) -> Iterator[Document]: try: self._safe_process_url_sync(url) page = browser.new_page() - response = page.goto(url) + response = page.goto(url, timeout=self.playwright_timeout) if response is None: raise ValueError(f"page.goto() returned None for url {url}") @@ -320,7 +444,7 @@ def lazy_load(self) -> Iterator[Document]: yield Document(page_content=text, metadata=metadata) except Exception as e: if self.continue_on_failure: - log.exception(e, "Error loading %s", url) + log.exception(f"Error loading {url}: {e}") continue raise e browser.close() @@ -342,7 +466,7 @@ async def alazy_load(self) -> AsyncIterator[Document]: try: await self._safe_process_url(url) page = await browser.new_page() - response = await page.goto(url) + response = await page.goto(url, timeout=self.playwright_timeout) if response is None: raise ValueError(f"page.goto() returned None for url {url}") @@ -351,46 +475,11 @@ async def alazy_load(self) -> AsyncIterator[Document]: yield Document(page_content=text, metadata=metadata) except Exception as e: if self.continue_on_failure: - log.exception(e, "Error loading %s", url) + log.exception(f"Error loading {url}: {e}") continue raise e await browser.close() - def _verify_ssl_cert(self, url: str) -> bool: - return verify_ssl_cert(url) - - async def _wait_for_rate_limit(self): - """Wait to respect the rate limit if specified.""" - if self.requests_per_second and self.last_request_time: - min_interval = timedelta(seconds=1.0 / self.requests_per_second) - time_since_last = datetime.now() - self.last_request_time - if time_since_last < min_interval: - await asyncio.sleep((min_interval - time_since_last).total_seconds()) - self.last_request_time = datetime.now() - - def _sync_wait_for_rate_limit(self): - """Synchronous version of rate limit wait.""" - if self.requests_per_second and self.last_request_time: - min_interval = timedelta(seconds=1.0 / self.requests_per_second) - time_since_last = datetime.now() - self.last_request_time - if time_since_last < min_interval: - time.sleep((min_interval - time_since_last).total_seconds()) - self.last_request_time = datetime.now() - - async def _safe_process_url(self, url: str) -> bool: - """Perform safety checks before processing a URL.""" - if self.verify_ssl and not self._verify_ssl_cert(url): - raise ValueError(f"SSL certificate verification failed for {url}") - await self._wait_for_rate_limit() - return True - - def _safe_process_url_sync(self, url: str) -> bool: - """Synchronous version of safety checks.""" - if self.verify_ssl and not self._verify_ssl_cert(url): - raise ValueError(f"SSL certificate verification failed for {url}") - self._sync_wait_for_rate_limit() - return True - class SafeWebBaseLoader(WebBaseLoader): """WebBaseLoader with enhanced error handling for URLs.""" @@ -472,7 +561,7 @@ def lazy_load(self) -> Iterator[Document]: yield Document(page_content=text, metadata=metadata) except Exception as e: # Log the error and continue with the next URL - log.exception(e, "Error loading %s", path) + log.exception(f"Error loading {path}: {e}") async def alazy_load(self) -> AsyncIterator[Document]: """Async lazy load text from the url(s) in web_path.""" @@ -499,6 +588,7 @@ async def aload(self) -> list[Document]: RAG_WEB_LOADER_ENGINES["playwright"] = SafePlaywrightURLLoader RAG_WEB_LOADER_ENGINES["safe_web"] = SafeWebBaseLoader RAG_WEB_LOADER_ENGINES["firecrawl"] = SafeFireCrawlLoader +RAG_WEB_LOADER_ENGINES["tavily"] = SafeTavilyLoader def get_web_loader( @@ -518,13 +608,19 @@ def get_web_loader( "trust_env": trust_env, } - if PLAYWRIGHT_WS_URI.value: - web_loader_args["playwright_ws_url"] = PLAYWRIGHT_WS_URI.value + if RAG_WEB_LOADER_ENGINE.value == "playwright": + web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value * 1000 + if PLAYWRIGHT_WS_URI.value: + web_loader_args["playwright_ws_url"] = PLAYWRIGHT_WS_URI.value if RAG_WEB_LOADER_ENGINE.value == "firecrawl": web_loader_args["api_key"] = FIRECRAWL_API_KEY.value web_loader_args["api_url"] = FIRECRAWL_API_BASE_URL.value + if RAG_WEB_LOADER_ENGINE.value == "tavily": + web_loader_args["api_key"] = TAVILY_API_KEY.value + web_loader_args["extract_depth"] = TAVILY_EXTRACT_DEPTH.value + # Create the appropriate WebLoader based on the configuration WebLoaderClass = RAG_WEB_LOADER_ENGINES[RAG_WEB_LOADER_ENGINE.value] web_loader = WebLoaderClass(**web_loader_args) diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index a970366d1ed..ea13726235e 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -54,7 +54,7 @@ log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["AUDIO"]) -SPEECH_CACHE_DIR = Path(CACHE_DIR).joinpath("./audio/speech/") +SPEECH_CACHE_DIR = CACHE_DIR / "audio" / "speech" SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True) @@ -71,7 +71,7 @@ def is_mp4_audio(file_path): """Check if the given file is an MP4 audio file.""" if not os.path.isfile(file_path): - print(f"File not found: {file_path}") + log.error(f"File not found: {file_path}") return False info = mediainfo(file_path) @@ -88,7 +88,7 @@ def convert_mp4_to_wav(file_path, output_path): """Convert MP4 audio file to WAV format.""" audio = AudioSegment.from_file(file_path, format="mp4") audio.export(output_path, format="wav") - print(f"Converted {file_path} to {output_path}") + log.info(f"Converted {file_path} to {output_path}") def set_faster_whisper_model(model: str, auto_update: bool = False): @@ -266,7 +266,6 @@ async def speech(request: Request, user=Depends(get_verified_user)): payload["model"] = request.app.state.config.TTS_MODEL try: - # print(payload) timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) async with aiohttp.ClientSession( timeout=timeout, trust_env=True @@ -468,7 +467,7 @@ async def speech(request: Request, user=Depends(get_verified_user)): def transcribe(request: Request, file_path): - print("transcribe", file_path) + log.info(f"transcribe: {file_path}") filename = os.path.basename(file_path) file_dir = os.path.dirname(file_path) id = filename.split(".")[0] @@ -626,7 +625,9 @@ def transcription( ): log.info(f"file.content_type: {file.content_type}") - if file.content_type not in ["audio/mpeg", "audio/wav", "audio/ogg", "audio/x-m4a"]: + supported_filetypes = ("audio/mpeg", "audio/wav", "audio/ogg", "audio/x-m4a") + + if not file.content_type.startswith(supported_filetypes): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.FILE_NOT_SUPPORTED, @@ -680,7 +681,22 @@ def transcription( def get_available_models(request: Request) -> list[dict]: available_models = [] if request.app.state.config.TTS_ENGINE == "openai": - available_models = [{"id": "tts-1"}, {"id": "tts-1-hd"}] + # Use custom endpoint if not using the official OpenAI API URL + if not request.app.state.config.TTS_OPENAI_API_BASE_URL.startswith( + "https://api.openai.com" + ): + try: + response = requests.get( + f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/models" + ) + response.raise_for_status() + data = response.json() + available_models = data.get("models", []) + except Exception as e: + log.error(f"Error fetching models from custom endpoint: {str(e)}") + available_models = [{"id": "tts-1"}, {"id": "tts-1-hd"}] + else: + available_models = [{"id": "tts-1"}, {"id": "tts-1-hd"}] elif request.app.state.config.TTS_ENGINE == "elevenlabs": try: response = requests.get( @@ -711,14 +727,37 @@ def get_available_voices(request) -> dict: """Returns {voice_id: voice_name} dict""" available_voices = {} if request.app.state.config.TTS_ENGINE == "openai": - available_voices = { - "alloy": "alloy", - "echo": "echo", - "fable": "fable", - "onyx": "onyx", - "nova": "nova", - "shimmer": "shimmer", - } + # Use custom endpoint if not using the official OpenAI API URL + if not request.app.state.config.TTS_OPENAI_API_BASE_URL.startswith( + "https://api.openai.com" + ): + try: + response = requests.get( + f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/voices" + ) + response.raise_for_status() + data = response.json() + voices_list = data.get("voices", []) + available_voices = {voice["id"]: voice["name"] for voice in voices_list} + except Exception as e: + log.error(f"Error fetching voices from custom endpoint: {str(e)}") + available_voices = { + "alloy": "alloy", + "echo": "echo", + "fable": "fable", + "onyx": "onyx", + "nova": "nova", + "shimmer": "shimmer", + } + else: + available_voices = { + "alloy": "alloy", + "echo": "echo", + "fable": "fable", + "onyx": "onyx", + "nova": "nova", + "shimmer": "shimmer", + } elif request.app.state.config.TTS_ENGINE == "elevenlabs": try: available_voices = get_elevenlabs_voices( diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index afdaf436ec1..9738829102d 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -35,10 +35,7 @@ ) from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import RedirectResponse, Response -from open_webui.config import ( - OPENID_PROVIDER_URL, - ENABLE_OAUTH_SIGNUP, -) +from open_webui.config import OPENID_PROVIDER_URL, ENABLE_OAUTH_SIGNUP, ENABLE_LDAP from pydantic import BaseModel from open_webui.utils.misc import parse_duration, validate_email_format from open_webui.utils.auth import ( @@ -55,8 +52,10 @@ from typing import Optional, List from ssl import CERT_REQUIRED, PROTOCOL_TLS -from ldap3 import Server, Connection, NONE, Tls -from ldap3.utils.conv import escape_filter_chars + +if ENABLE_LDAP.value: + from ldap3 import Server, Connection, NONE, Tls + from ldap3.utils.conv import escape_filter_chars router = APIRouter() @@ -219,8 +218,8 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): ciphers=LDAP_CIPHERS, ) except Exception as e: - log.error(f"An error occurred on TLS: {str(e)}") - raise HTTPException(400, detail=str(e)) + log.error(f"TLS configuration error: {str(e)}") + raise HTTPException(400, detail="Failed to configure TLS for LDAP connection.") try: server = Server( @@ -235,7 +234,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): LDAP_APP_DN, LDAP_APP_PASSWORD, auto_bind="NONE", - authentication="SIMPLE", + authentication="SIMPLE" if LDAP_APP_DN else "ANONYMOUS", ) if not connection_app.bind(): raise HTTPException(400, detail="Application account bind failed") @@ -255,9 +254,12 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): entry = connection_app.entries[0] username = str(entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"]).lower() - mail = str(entry[f"{LDAP_ATTRIBUTE_FOR_MAIL}"]) - if not mail or mail == "" or mail == "[]": - raise HTTPException(400, f"User {form_data.user} does not have mail.") + email = str(entry[f"{LDAP_ATTRIBUTE_FOR_MAIL}"]) + if not email or email == "" or email == "[]": + raise HTTPException(400, "User does not have a valid email address.") + else: + email = email.lower() + cn = str(entry["cn"]) user_dn = entry.entry_dn @@ -270,20 +272,12 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): authentication="SIMPLE", ) if not connection_user.bind(): - raise HTTPException(400, f"Authentication failed for {form_data.user}") + raise HTTPException(400, "Authentication failed.") - user = Users.get_user_by_email(mail) + user = Users.get_user_by_email(email) if not user: try: user_count = Users.get_num_users() - if ( - request.app.state.USER_COUNT - and user_count >= request.app.state.USER_COUNT - ): - raise HTTPException( - status.HTTP_403_FORBIDDEN, - detail=ERROR_MESSAGES.ACCESS_PROHIBITED, - ) role = ( "admin" @@ -292,7 +286,10 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): ) user = Auths.insert_new_auth( - email=mail, password=str(uuid.uuid4()), name=cn, role=role + email=email, + password=str(uuid.uuid4()), + name=cn, + role=role, ) if not user: @@ -303,9 +300,12 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): except HTTPException: raise except Exception as err: - raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err)) + log.error(f"LDAP user creation error: {str(err)}") + raise HTTPException( + 500, detail="Internal error occurred during LDAP user creation." + ) - user = Auths.authenticate_user_by_trusted_header(mail) + user = Auths.authenticate_user_by_trusted_header(email) if user: token = create_token( @@ -339,12 +339,10 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): else: raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) else: - raise HTTPException( - 400, - f"User {form_data.user} does not match the record. Search result: {str(entry[f'{LDAP_ATTRIBUTE_FOR_USERNAME}'])}", - ) + raise HTTPException(400, "User record mismatch.") except Exception as e: - raise HTTPException(400, detail=str(e)) + log.error(f"LDAP authentication error: {str(e)}") + raise HTTPException(400, detail="LDAP authentication failed.") ############################ @@ -463,11 +461,6 @@ async def signup(request: Request, response: Response, form_data: SignupForm): ) user_count = Users.get_num_users() - if request.app.state.USER_COUNT and user_count >= request.app.state.USER_COUNT: - raise HTTPException( - status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED - ) - if not validate_email_format(form_data.email.lower()): raise HTTPException( status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT @@ -555,7 +548,8 @@ async def signup(request: Request, response: Response, form_data: SignupForm): else: raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR) except Exception as err: - raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err)) + log.error(f"Signup error: {str(err)}") + raise HTTPException(500, detail="An internal error occurred during signup.") @router.get("/signout") @@ -583,7 +577,11 @@ async def signout(request: Request, response: Response): detail="Failed to fetch OpenID configuration", ) except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + log.error(f"OpenID signout error: {str(e)}") + raise HTTPException( + status_code=500, + detail="Failed to sign out from the OpenID provider.", + ) return {"status": True} @@ -627,7 +625,10 @@ async def add_user(form_data: AddUserForm, user=Depends(get_admin_user)): else: raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR) except Exception as err: - raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err)) + log.error(f"Add user error: {str(err)}") + raise HTTPException( + 500, detail="An internal error occurred while adding the user." + ) ############################ @@ -641,7 +642,7 @@ async def get_admin_details(request: Request, user=Depends(get_current_user)): admin_email = request.app.state.config.ADMIN_EMAIL admin_name = None - print(admin_email, admin_name) + log.info(f"Admin details - Email: {admin_email}, Name: {admin_name}") if admin_email: admin = Users.get_user_by_email(admin_email) @@ -675,11 +676,12 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)): "ENABLE_API_KEY": request.app.state.config.ENABLE_API_KEY, "ENABLE_API_KEY_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS, "API_KEY_ALLOWED_ENDPOINTS": request.app.state.config.API_KEY_ALLOWED_ENDPOINTS, - "ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS, "DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE, "JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN, "ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING, "ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING, + "ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS, + "ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS, } @@ -690,11 +692,12 @@ class AdminConfig(BaseModel): ENABLE_API_KEY: bool ENABLE_API_KEY_ENDPOINT_RESTRICTIONS: bool API_KEY_ALLOWED_ENDPOINTS: str - ENABLE_CHANNELS: bool DEFAULT_USER_ROLE: str JWT_EXPIRES_IN: str ENABLE_COMMUNITY_SHARING: bool ENABLE_MESSAGE_RATING: bool + ENABLE_CHANNELS: bool + ENABLE_USER_WEBHOOKS: bool @router.post("/admin/config") @@ -729,6 +732,8 @@ async def update_admin_config( ) request.app.state.config.ENABLE_MESSAGE_RATING = form_data.ENABLE_MESSAGE_RATING + request.app.state.config.ENABLE_USER_WEBHOOKS = form_data.ENABLE_USER_WEBHOOKS + return { "SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS, "WEBUI_URL": request.app.state.config.WEBUI_URL, @@ -741,6 +746,7 @@ async def update_admin_config( "JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN, "ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING, "ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING, + "ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS, } @@ -795,11 +801,6 @@ async def update_ldap_server( if not value: raise HTTPException(400, detail=f"Required field {key} is empty") - if form_data.use_tls and not form_data.certificate_path: - raise HTTPException( - 400, detail="TLS is enabled but certificate file path is missing" - ) - request.app.state.config.LDAP_SERVER_LABEL = form_data.label request.app.state.config.LDAP_SERVER_HOST = form_data.host request.app.state.config.LDAP_SERVER_PORT = form_data.port diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 2efd043efe3..74bb96c9472 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -2,6 +2,8 @@ import logging from typing import Optional + +from open_webui.socket.main import get_event_emitter from open_webui.models.chats import ( ChatForm, ChatImportForm, @@ -372,6 +374,107 @@ async def update_chat_by_id( ) +############################ +# UpdateChatMessageById +############################ +class MessageForm(BaseModel): + content: str + + +@router.post("/{id}/messages/{message_id}", response_model=Optional[ChatResponse]) +async def update_chat_message_by_id( + id: str, message_id: str, form_data: MessageForm, user=Depends(get_verified_user) +): + chat = Chats.get_chat_by_id(id) + + if not chat: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + if chat.user_id != user.id and user.role != "admin": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + chat = Chats.upsert_message_to_chat_by_id_and_message_id( + id, + message_id, + { + "content": form_data.content, + }, + ) + + event_emitter = get_event_emitter( + { + "user_id": user.id, + "chat_id": id, + "message_id": message_id, + }, + False, + ) + + if event_emitter: + await event_emitter( + { + "type": "chat:message", + "data": { + "chat_id": id, + "message_id": message_id, + "content": form_data.content, + }, + } + ) + + return ChatResponse(**chat.model_dump()) + + +############################ +# SendChatMessageEventById +############################ +class EventForm(BaseModel): + type: str + data: dict + + +@router.post("/{id}/messages/{message_id}/event", response_model=Optional[bool]) +async def send_chat_message_event_by_id( + id: str, message_id: str, form_data: EventForm, user=Depends(get_verified_user) +): + chat = Chats.get_chat_by_id(id) + + if not chat: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + if chat.user_id != user.id and user.role != "admin": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + event_emitter = get_event_emitter( + { + "user_id": user.id, + "chat_id": id, + "message_id": message_id, + } + ) + + try: + if event_emitter: + await event_emitter(form_data.model_dump()) + else: + return False + return True + except: + return False + + ############################ # DeleteChatById ############################ diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index 388c44f9c61..44b2ef40cfb 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -1,5 +1,5 @@ -from fastapi import APIRouter, Depends, Request -from pydantic import BaseModel +from fastapi import APIRouter, Depends, Request, HTTPException +from pydantic import BaseModel, ConfigDict from typing import Optional @@ -7,6 +7,8 @@ from open_webui.config import get_config, save_config from open_webui.config import BannerModel +from open_webui.utils.tools import get_tool_server_data, get_tool_servers_data + router = APIRouter() @@ -66,10 +68,80 @@ async def set_direct_connections_config( } +############################ +# ToolServers Config +############################ + + +class ToolServerConnection(BaseModel): + url: str + path: str + auth_type: Optional[str] + key: Optional[str] + config: Optional[dict] + + model_config = ConfigDict(extra="allow") + + +class ToolServersConfigForm(BaseModel): + TOOL_SERVER_CONNECTIONS: list[ToolServerConnection] + + +@router.get("/tool_servers", response_model=ToolServersConfigForm) +async def get_tool_servers_config(request: Request, user=Depends(get_admin_user)): + return { + "TOOL_SERVER_CONNECTIONS": request.app.state.config.TOOL_SERVER_CONNECTIONS, + } + + +@router.post("/tool_servers", response_model=ToolServersConfigForm) +async def set_tool_servers_config( + request: Request, + form_data: ToolServersConfigForm, + user=Depends(get_admin_user), +): + request.app.state.config.TOOL_SERVER_CONNECTIONS = [ + connection.model_dump() for connection in form_data.TOOL_SERVER_CONNECTIONS + ] + + request.app.state.TOOL_SERVERS = await get_tool_servers_data( + request.app.state.config.TOOL_SERVER_CONNECTIONS + ) + + return { + "TOOL_SERVER_CONNECTIONS": request.app.state.config.TOOL_SERVER_CONNECTIONS, + } + + +@router.post("/tool_servers/verify") +async def verify_tool_servers_config( + request: Request, form_data: ToolServerConnection, user=Depends(get_admin_user) +): + """ + Verify the connection to the tool server. + """ + try: + + token = None + if form_data.auth_type == "bearer": + token = form_data.key + elif form_data.auth_type == "session": + token = request.state.token.credentials + + url = f"{form_data.url}/{form_data.path}" + return await get_tool_server_data(token, url) + except Exception as e: + raise HTTPException( + status_code=400, + detail=f"Failed to connect to the tool server: {str(e)}", + ) + + ############################ # CodeInterpreterConfig ############################ class CodeInterpreterConfigForm(BaseModel): + ENABLE_CODE_EXECUTION: bool CODE_EXECUTION_ENGINE: str CODE_EXECUTION_JUPYTER_URL: Optional[str] CODE_EXECUTION_JUPYTER_AUTH: Optional[str] @@ -89,6 +161,7 @@ class CodeInterpreterConfigForm(BaseModel): @router.get("/code_execution", response_model=CodeInterpreterConfigForm) async def get_code_execution_config(request: Request, user=Depends(get_admin_user)): return { + "ENABLE_CODE_EXECUTION": request.app.state.config.ENABLE_CODE_EXECUTION, "CODE_EXECUTION_ENGINE": request.app.state.config.CODE_EXECUTION_ENGINE, "CODE_EXECUTION_JUPYTER_URL": request.app.state.config.CODE_EXECUTION_JUPYTER_URL, "CODE_EXECUTION_JUPYTER_AUTH": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH, @@ -111,6 +184,8 @@ async def set_code_execution_config( request: Request, form_data: CodeInterpreterConfigForm, user=Depends(get_admin_user) ): + request.app.state.config.ENABLE_CODE_EXECUTION = form_data.ENABLE_CODE_EXECUTION + request.app.state.config.CODE_EXECUTION_ENGINE = form_data.CODE_EXECUTION_ENGINE request.app.state.config.CODE_EXECUTION_JUPYTER_URL = ( form_data.CODE_EXECUTION_JUPYTER_URL @@ -153,6 +228,7 @@ async def set_code_execution_config( ) return { + "ENABLE_CODE_EXECUTION": request.app.state.config.ENABLE_CODE_EXECUTION, "CODE_EXECUTION_ENGINE": request.app.state.config.CODE_EXECUTION_ENGINE, "CODE_EXECUTION_JUPYTER_URL": request.app.state.config.CODE_EXECUTION_JUPYTER_URL, "CODE_EXECUTION_JUPYTER_AUTH": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH, diff --git a/backend/open_webui/routers/evaluations.py b/backend/open_webui/routers/evaluations.py index f0c4a6b0656..8597fa28635 100644 --- a/backend/open_webui/routers/evaluations.py +++ b/backend/open_webui/routers/evaluations.py @@ -56,8 +56,19 @@ async def update_config( } +class FeedbackUserReponse(BaseModel): + id: str + name: str + email: str + role: str = "pending" + + last_active_at: int # timestamp in epoch + updated_at: int # timestamp in epoch + created_at: int # timestamp in epoch + + class FeedbackUserResponse(FeedbackResponse): - user: Optional[UserModel] = None + user: Optional[FeedbackUserReponse] = None @router.get("/feedbacks/all", response_model=list[FeedbackUserResponse]) @@ -65,7 +76,10 @@ async def get_all_feedbacks(user=Depends(get_admin_user)): feedbacks = Feedbacks.get_all_feedbacks() return [ FeedbackUserResponse( - **feedback.model_dump(), user=Users.get_user_by_id(feedback.user_id) + **feedback.model_dump(), + user=FeedbackUserReponse( + **Users.get_user_by_id(feedback.user_id).model_dump() + ), ) for feedback in feedbacks ] diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 504baa60dc1..c30366545e2 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -5,7 +5,16 @@ from typing import Optional from urllib.parse import quote -from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status +from fastapi import ( + APIRouter, + Depends, + File, + HTTPException, + Request, + UploadFile, + status, + Query, +) from fastapi.responses import FileResponse, StreamingResponse from open_webui.constants import ERROR_MESSAGES from open_webui.env import SRC_LOG_LEVELS @@ -15,7 +24,11 @@ FileModelResponse, Files, ) +from open_webui.models.knowledge import Knowledges + +from open_webui.routers.knowledge import get_knowledge, get_knowledge_list from open_webui.routers.retrieval import ProcessFileForm, process_file +from open_webui.routers.audio import transcribe from open_webui.storage.provider import Storage from open_webui.utils.auth import get_admin_user, get_verified_user from pydantic import BaseModel @@ -26,6 +39,39 @@ router = APIRouter() + +############################ +# Check if the current user has access to a file through any knowledge bases the user may be in. +############################ + + +def has_access_to_file( + file_id: Optional[str], access_type: str, user=Depends(get_verified_user) +) -> bool: + file = Files.get_file_by_id(file_id) + log.debug(f"Checking if user has {access_type} access to file") + + if not file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + has_access = False + knowledge_base_id = file.meta.get("collection_name") if file.meta else None + + if knowledge_base_id: + knowledge_bases = Knowledges.get_knowledge_bases_by_user_id( + user.id, access_type + ) + for knowledge_base in knowledge_bases: + if knowledge_base.id == knowledge_base_id: + has_access = True + break + + return has_access + + ############################ # Upload File ############################ @@ -37,6 +83,7 @@ def upload_file( file: UploadFile = File(...), user=Depends(get_verified_user), file_metadata: dict = {}, + process: bool = Query(True), ): log.info(f"file.content_type: {file.content_type}") try: @@ -65,19 +112,35 @@ def upload_file( } ), ) + if process: + try: + if file.content_type in [ + "audio/mpeg", + "audio/wav", + "audio/ogg", + "audio/x-m4a", + ]: + file_path = Storage.get_file(file_path) + result = transcribe(request, file_path) + + process_file( + request, + ProcessFileForm(file_id=id, content=result.get("text", "")), + user=user, + ) + elif file.content_type not in ["image/png", "image/jpeg", "image/gif"]: + process_file(request, ProcessFileForm(file_id=id), user=user) - try: - process_file(request, ProcessFileForm(file_id=id), user=user) - file_item = Files.get_file_by_id(id=id) - except Exception as e: - log.exception(e) - log.error(f"Error processing file: {file_item.id}") - file_item = FileModelResponse( - **{ - **file_item.model_dump(), - "error": str(e.detail) if hasattr(e, "detail") else str(e), - } - ) + file_item = Files.get_file_by_id(id=id) + except Exception as e: + log.exception(e) + log.error(f"Error processing file: {file_item.id}") + file_item = FileModelResponse( + **{ + **file_item.model_dump(), + "error": str(e.detail) if hasattr(e, "detail") else str(e), + } + ) if file_item: return file_item @@ -101,11 +164,16 @@ def upload_file( @router.get("/", response_model=list[FileModelResponse]) -async def list_files(user=Depends(get_verified_user)): +async def list_files(user=Depends(get_verified_user), content: bool = Query(True)): if user.role == "admin": files = Files.get_files() else: files = Files.get_files_by_user_id(user.id) + + if not content: + for file in files: + del file.data["content"] + return files @@ -144,7 +212,17 @@ async def delete_all_files(user=Depends(get_admin_user)): async def get_file_by_id(id: str, user=Depends(get_verified_user)): file = Files.get_file_by_id(id) - if file and (file.user_id == user.id or user.role == "admin"): + if not file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + file.user_id == user.id + or user.role == "admin" + or has_access_to_file(id, "read", user) + ): return file else: raise HTTPException( @@ -162,7 +240,17 @@ async def get_file_by_id(id: str, user=Depends(get_verified_user)): async def get_file_data_content_by_id(id: str, user=Depends(get_verified_user)): file = Files.get_file_by_id(id) - if file and (file.user_id == user.id or user.role == "admin"): + if not file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + file.user_id == user.id + or user.role == "admin" + or has_access_to_file(id, "read", user) + ): return {"content": file.data.get("content", "")} else: raise HTTPException( @@ -186,7 +274,17 @@ async def update_file_data_content_by_id( ): file = Files.get_file_by_id(id) - if file and (file.user_id == user.id or user.role == "admin"): + if not file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + file.user_id == user.id + or user.role == "admin" + or has_access_to_file(id, "write", user) + ): try: process_file( request, @@ -212,9 +310,22 @@ async def update_file_data_content_by_id( @router.get("/{id}/content") -async def get_file_content_by_id(id: str, user=Depends(get_verified_user)): +async def get_file_content_by_id( + id: str, user=Depends(get_verified_user), attachment: bool = Query(False) +): file = Files.get_file_by_id(id) - if file and (file.user_id == user.id or user.role == "admin"): + + if not file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + file.user_id == user.id + or user.role == "admin" + or has_access_to_file(id, "read", user) + ): try: file_path = Storage.get_file(file.path) file_path = Path(file_path) @@ -230,17 +341,22 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)): encoded_filename = quote(filename) headers = {} - if content_type == "application/pdf" or filename.lower().endswith( - ".pdf" - ): - headers["Content-Disposition"] = ( - f"inline; filename*=UTF-8''{encoded_filename}" - ) - content_type = "application/pdf" - elif content_type != "text/plain": + if attachment: headers["Content-Disposition"] = ( f"attachment; filename*=UTF-8''{encoded_filename}" ) + else: + if content_type == "application/pdf" or filename.lower().endswith( + ".pdf" + ): + headers["Content-Disposition"] = ( + f"inline; filename*=UTF-8''{encoded_filename}" + ) + content_type = "application/pdf" + elif content_type != "text/plain": + headers["Content-Disposition"] = ( + f"attachment; filename*=UTF-8''{encoded_filename}" + ) return FileResponse(file_path, headers=headers, media_type=content_type) @@ -266,14 +382,25 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)): @router.get("/{id}/content/html") async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)): file = Files.get_file_by_id(id) - if file and (file.user_id == user.id or user.role == "admin"): + + if not file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + file.user_id == user.id + or user.role == "admin" + or has_access_to_file(id, "read", user) + ): try: file_path = Storage.get_file(file.path) file_path = Path(file_path) # Check if the file already exists in the cache if file_path.is_file(): - print(f"file_path: {file_path}") + log.info(f"file_path: {file_path}") return FileResponse(file_path) else: raise HTTPException( @@ -298,7 +425,17 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)): async def get_file_content_by_id(id: str, user=Depends(get_verified_user)): file = Files.get_file_by_id(id) - if file and (file.user_id == user.id or user.role == "admin"): + if not file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + file.user_id == user.id + or user.role == "admin" + or has_access_to_file(id, "read", user) + ): file_path = file.path # Handle Unicode filenames @@ -349,7 +486,18 @@ def generator(): @router.delete("/{id}") async def delete_file_by_id(id: str, user=Depends(get_verified_user)): file = Files.get_file_by_id(id) - if file and (file.user_id == user.id or user.role == "admin"): + + if not file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + file.user_id == user.id + or user.role == "admin" + or has_access_to_file(id, "write", user) + ): # We should add Chroma cleanup here result = Files.delete_file_by_id(id) diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index ca2fbd2132c..2c41c92854b 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -20,11 +20,13 @@ from open_webui.constants import ERROR_MESSAGES -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request from fastapi.responses import FileResponse, StreamingResponse from open_webui.utils.auth import get_admin_user, get_verified_user +from open_webui.utils.access_control import has_permission + log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["MODELS"]) @@ -228,7 +230,19 @@ async def update_folder_is_expanded_by_id( @router.delete("/{id}") -async def delete_folder_by_id(id: str, user=Depends(get_verified_user)): +async def delete_folder_by_id( + request: Request, id: str, user=Depends(get_verified_user) +): + chat_delete_permission = has_permission( + user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS + ) + + if user.role != "admin" and not chat_delete_permission: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + folder = Folders.get_folder_by_id_and_user_id(id, user.id) if folder: try: diff --git a/backend/open_webui/routers/functions.py b/backend/open_webui/routers/functions.py index 7f3305f25a9..206610138e0 100644 --- a/backend/open_webui/routers/functions.py +++ b/backend/open_webui/routers/functions.py @@ -1,4 +1,5 @@ import os +import logging from pathlib import Path from typing import Optional @@ -13,6 +14,11 @@ from open_webui.constants import ERROR_MESSAGES from fastapi import APIRouter, Depends, HTTPException, Request, status from open_webui.utils.auth import get_admin_user, get_verified_user +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MAIN"]) + router = APIRouter() @@ -68,7 +74,7 @@ async def create_new_function( function = Functions.insert_new_function(user.id, function_type, form_data) - function_cache_dir = Path(CACHE_DIR) / "functions" / form_data.id + function_cache_dir = CACHE_DIR / "functions" / form_data.id function_cache_dir.mkdir(parents=True, exist_ok=True) if function: @@ -79,7 +85,7 @@ async def create_new_function( detail=ERROR_MESSAGES.DEFAULT("Error creating function"), ) except Exception as e: - print(e) + log.exception(f"Failed to create a new function: {e}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(e), @@ -183,7 +189,7 @@ async def update_function_by_id( FUNCTIONS[id] = function_module updated = {**form_data.model_dump(exclude={"id"}), "type": function_type} - print(updated) + log.debug(updated) function = Functions.update_function_by_id(id, updated) @@ -299,7 +305,7 @@ async def update_function_valves_by_id( Functions.update_function_valves_by_id(id, valves.model_dump()) return valves.model_dump() except Exception as e: - print(e) + log.exception(f"Error updating function values by id {id}: {e}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(e), @@ -388,7 +394,7 @@ async def update_function_user_valves_by_id( ) return user_valves.model_dump() except Exception as e: - print(e) + log.exception(f"Error updating function user valves by id {id}: {e}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(e), diff --git a/backend/open_webui/routers/groups.py b/backend/open_webui/routers/groups.py old mode 100644 new mode 100755 index 5b5130f71dd..ae822c0d006 --- a/backend/open_webui/routers/groups.py +++ b/backend/open_webui/routers/groups.py @@ -1,7 +1,7 @@ import os from pathlib import Path from typing import Optional - +import logging from open_webui.models.users import Users from open_webui.models.groups import ( @@ -14,7 +14,13 @@ from open_webui.config import CACHE_DIR from open_webui.constants import ERROR_MESSAGES from fastapi import APIRouter, Depends, HTTPException, Request, status + from open_webui.utils.auth import get_admin_user, get_verified_user +from open_webui.env import SRC_LOG_LEVELS + + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MAIN"]) router = APIRouter() @@ -37,7 +43,7 @@ async def get_groups(user=Depends(get_verified_user)): @router.post("/create", response_model=Optional[GroupResponse]) -async def create_new_function(form_data: GroupForm, user=Depends(get_admin_user)): +async def create_new_group(form_data: GroupForm, user=Depends(get_admin_user)): try: group = Groups.insert_new_group(user.id, form_data) if group: @@ -48,7 +54,7 @@ async def create_new_function(form_data: GroupForm, user=Depends(get_admin_user) detail=ERROR_MESSAGES.DEFAULT("Error creating group"), ) except Exception as e: - print(e) + log.exception(f"Error creating a new group: {e}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(e), @@ -94,7 +100,7 @@ async def update_group_by_id( detail=ERROR_MESSAGES.DEFAULT("Error updating group"), ) except Exception as e: - print(e) + log.exception(f"Error updating group {id}: {e}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(e), @@ -118,7 +124,7 @@ async def delete_group_by_id(id: str, user=Depends(get_admin_user)): detail=ERROR_MESSAGES.DEFAULT("Error deleting group"), ) except Exception as e: - print(e) + log.exception(f"Error deleting group {id}: {e}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(e), diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 3288ec6d846..275704f3411 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -25,7 +25,7 @@ log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["IMAGES"]) -IMAGE_CACHE_DIR = Path(CACHE_DIR).joinpath("./image/generations/") +IMAGE_CACHE_DIR = CACHE_DIR / "image" / "generations" IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True) @@ -144,6 +144,8 @@ async def update_config( request.app.state.config.COMFYUI_BASE_URL = ( form_data.comfyui.COMFYUI_BASE_URL.strip("/") ) + request.app.state.config.COMFYUI_API_KEY = form_data.comfyui.COMFYUI_API_KEY + request.app.state.config.COMFYUI_WORKFLOW = form_data.comfyui.COMFYUI_WORKFLOW request.app.state.config.COMFYUI_WORKFLOW_NODES = ( form_data.comfyui.COMFYUI_WORKFLOW_NODES @@ -203,9 +205,17 @@ async def verify_url(request: Request, user=Depends(get_admin_user)): request.app.state.config.ENABLE_IMAGE_GENERATION = False raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL) elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui": + + headers = None + if request.app.state.config.COMFYUI_API_KEY: + headers = { + "Authorization": f"Bearer {request.app.state.config.COMFYUI_API_KEY}" + } + try: r = requests.get( - url=f"{request.app.state.config.COMFYUI_BASE_URL}/object_info" + url=f"{request.app.state.config.COMFYUI_BASE_URL}/object_info", + headers=headers, ) r.raise_for_status() return True @@ -351,7 +361,7 @@ def get_models(request: Request, user=Depends(get_verified_user)): if model_node_id: model_list_key = None - print(workflow[model_node_id]["class_type"]) + log.info(workflow[model_node_id]["class_type"]) for key in info[workflow[model_node_id]["class_type"]]["input"][ "required" ]: @@ -507,7 +517,11 @@ async def image_generations( images = [] for image in res["data"]: - image_data, content_type = load_b64_image_data(image["b64_json"]) + if image_url := image.get("url", None): + image_data, content_type = load_url_image_data(image_url, headers) + else: + image_data, content_type = load_b64_image_data(image["b64_json"]) + url = upload_image(request, data, image_data, content_type, user) images.append({"url": url}) return images diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 0ba6191a2a8..bc1e2429e9d 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -437,14 +437,24 @@ def remove_file_from_knowledge_by_id( ) # Remove content from the vector database - VECTOR_DB_CLIENT.delete( - collection_name=knowledge.id, filter={"file_id": form_data.file_id} - ) + try: + VECTOR_DB_CLIENT.delete( + collection_name=knowledge.id, filter={"file_id": form_data.file_id} + ) + except Exception as e: + log.debug("This was most likely caused by bypassing embedding processing") + log.debug(e) + pass - # Remove the file's collection from vector database - file_collection = f"file-{form_data.file_id}" - if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection): - VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection) + try: + # Remove the file's collection from vector database + file_collection = f"file-{form_data.file_id}" + if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection): + VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection) + except Exception as e: + log.debug("This was most likely caused by bypassing embedding processing") + log.debug(e) + pass # Delete file from database Files.delete_file_by_id(form_data.file_id) @@ -614,7 +624,7 @@ def add_files_to_knowledge_batch( ) # Get files content - print(f"files/batch/add - {len(form_data)} files") + log.info(f"files/batch/add - {len(form_data)} files") files: List[FileModel] = [] for form in form_data: file = Files.get_file_by_id(form.file_id) diff --git a/backend/open_webui/routers/memories.py b/backend/open_webui/routers/memories.py index c55a6a9cc94..e660ef852bd 100644 --- a/backend/open_webui/routers/memories.py +++ b/backend/open_webui/routers/memories.py @@ -57,7 +57,9 @@ async def add_memory( { "id": memory.id, "text": memory.content, - "vector": request.app.state.EMBEDDING_FUNCTION(memory.content, user), + "vector": request.app.state.EMBEDDING_FUNCTION( + memory.content, user=user + ), "metadata": {"created_at": memory.created_at}, } ], @@ -82,7 +84,7 @@ async def query_memory( ): results = VECTOR_DB_CLIENT.search( collection_name=f"user-memory-{user.id}", - vectors=[request.app.state.EMBEDDING_FUNCTION(form_data.content, user)], + vectors=[request.app.state.EMBEDDING_FUNCTION(form_data.content, user=user)], limit=form_data.k, ) @@ -105,7 +107,9 @@ async def reset_memory_from_vector_db( { "id": memory.id, "text": memory.content, - "vector": request.app.state.EMBEDDING_FUNCTION(memory.content, user), + "vector": request.app.state.EMBEDDING_FUNCTION( + memory.content, user=user + ), "metadata": { "created_at": memory.created_at, "updated_at": memory.updated_at, @@ -161,7 +165,7 @@ async def update_memory_by_id( "id": memory.id, "text": memory.content, "vector": request.app.state.EMBEDDING_FUNCTION( - memory.content, user + memory.content, user=user ), "metadata": { "created_at": memory.created_at, diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 732dd36f9c8..775cd044656 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -14,6 +14,11 @@ import aiohttp from aiocache import cached import requests +from open_webui.models.users import UserModel + +from open_webui.env import ( + ENABLE_FORWARD_USER_INFO_HEADERS, +) from fastapi import ( Depends, @@ -50,7 +55,7 @@ ENV, SRC_LOG_LEVELS, AIOHTTP_CLIENT_TIMEOUT, - AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST, + AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, BYPASS_MODEL_ACCESS_CONTROL, ) from open_webui.constants import ERROR_MESSAGES @@ -66,12 +71,26 @@ ########################################## -async def send_get_request(url, key=None): - timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST) +async def send_get_request(url, key=None, user: UserModel = None): + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_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 {})} + url, + headers={ + "Content-Type": "application/json", + **({"Authorization": f"Bearer {key}"} if key else {}), + **( + { + "X-OpenWebUI-User-Name": user.name, + "X-OpenWebUI-User-Id": user.id, + "X-OpenWebUI-User-Email": user.email, + "X-OpenWebUI-User-Role": user.role, + } + if ENABLE_FORWARD_USER_INFO_HEADERS and user + else {} + ), + }, ) as response: return await response.json() except Exception as e: @@ -96,6 +115,7 @@ async def send_post_request( stream: bool = True, key: Optional[str] = None, content_type: Optional[str] = None, + user: UserModel = None, ): r = None @@ -110,6 +130,16 @@ async def send_post_request( headers={ "Content-Type": "application/json", **({"Authorization": f"Bearer {key}"} if key else {}), + **( + { + "X-OpenWebUI-User-Name": user.name, + "X-OpenWebUI-User-Id": user.id, + "X-OpenWebUI-User-Email": user.email, + "X-OpenWebUI-User-Role": user.role, + } + if ENABLE_FORWARD_USER_INFO_HEADERS and user + else {} + ), }, ) r.raise_for_status() @@ -186,12 +216,24 @@ async def verify_connection( key = form_data.key async with aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST) + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) ) as session: try: async with session.get( f"{url}/api/version", - headers={**({"Authorization": f"Bearer {key}"} if key else {})}, + headers={ + **({"Authorization": f"Bearer {key}"} if key else {}), + **( + { + "X-OpenWebUI-User-Name": user.name, + "X-OpenWebUI-User-Id": user.id, + "X-OpenWebUI-User-Email": user.email, + "X-OpenWebUI-User-Role": user.role, + } + if ENABLE_FORWARD_USER_INFO_HEADERS and user + else {} + ), + }, ) as r: if r.status != 200: detail = f"HTTP Error: {r.status}" @@ -253,8 +295,8 @@ async def update_config( } -@cached(ttl=3) -async def get_all_models(request: Request): +@cached(ttl=1) +async def get_all_models(request: Request, user: UserModel = None): log.info("get_all_models()") if request.app.state.config.ENABLE_OLLAMA_API: request_tasks = [] @@ -262,7 +304,7 @@ async def get_all_models(request: Request): if (str(idx) not in request.app.state.config.OLLAMA_API_CONFIGS) and ( url not in request.app.state.config.OLLAMA_API_CONFIGS # Legacy support ): - request_tasks.append(send_get_request(f"{url}/api/tags")) + request_tasks.append(send_get_request(f"{url}/api/tags", user=user)) else: api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( str(idx), @@ -275,7 +317,9 @@ async def get_all_models(request: Request): key = api_config.get("key", None) if enable: - request_tasks.append(send_get_request(f"{url}/api/tags", key)) + request_tasks.append( + send_get_request(f"{url}/api/tags", key, user=user) + ) else: request_tasks.append(asyncio.ensure_future(asyncio.sleep(0, None))) @@ -292,6 +336,7 @@ async def get_all_models(request: Request): ) prefix_id = api_config.get("prefix_id", None) + tags = api_config.get("tags", []) model_ids = api_config.get("model_ids", []) if len(model_ids) != 0 and "models" in response: @@ -306,6 +351,10 @@ async def get_all_models(request: Request): for model in response.get("models", []): model["model"] = f"{prefix_id}.{model['model']}" + if tags: + for model in response.get("models", []): + model["tags"] = tags + def merge_models_lists(model_lists): merged_models = {} @@ -360,7 +409,7 @@ async def get_ollama_tags( models = [] if url_idx is None: - models = await get_all_models(request) + models = await get_all_models(request, user=user) else: url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) @@ -370,7 +419,19 @@ async def get_ollama_tags( r = requests.request( method="GET", url=f"{url}/api/tags", - headers={**({"Authorization": f"Bearer {key}"} if key else {})}, + headers={ + **({"Authorization": f"Bearer {key}"} if key else {}), + **( + { + "X-OpenWebUI-User-Name": user.name, + "X-OpenWebUI-User-Id": user.id, + "X-OpenWebUI-User-Email": user.email, + "X-OpenWebUI-User-Role": user.role, + } + if ENABLE_FORWARD_USER_INFO_HEADERS and user + else {} + ), + }, ) r.raise_for_status() @@ -404,18 +465,27 @@ async def get_ollama_versions(request: Request, url_idx: Optional[int] = None): if request.app.state.config.ENABLE_OLLAMA_API: if url_idx is None: # returns lowest version - request_tasks = [ - send_get_request( - f"{url}/api/version", + request_tasks = [] + + for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS): + api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( + str(idx), request.app.state.config.OLLAMA_API_CONFIGS.get( - str(idx), - request.app.state.config.OLLAMA_API_CONFIGS.get( - url, {} - ), # Legacy support - ).get("key", None), + url, {} + ), # Legacy support ) - for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS) - ] + + enable = api_config.get("enable", True) + key = api_config.get("key", None) + + if enable: + request_tasks.append( + send_get_request( + f"{url}/api/version", + key, + ) + ) + responses = await asyncio.gather(*request_tasks) responses = list(filter(lambda x: x is not None, responses)) @@ -477,6 +547,7 @@ async def get_ollama_loaded_models(request: Request, user=Depends(get_verified_u url, {} ), # Legacy support ).get("key", None), + user=user, ) for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS) ] @@ -509,6 +580,7 @@ async def pull_model( url=f"{url}/api/pull", payload=json.dumps(payload), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), + user=user, ) @@ -527,7 +599,7 @@ async def push_model( user=Depends(get_admin_user), ): if url_idx is None: - await get_all_models(request) + await get_all_models(request, user=user) models = request.app.state.OLLAMA_MODELS if form_data.name in models: @@ -545,6 +617,7 @@ async def push_model( url=f"{url}/api/push", payload=form_data.model_dump_json(exclude_none=True).encode(), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), + user=user, ) @@ -571,6 +644,7 @@ async def create_model( url=f"{url}/api/create", payload=form_data.model_dump_json(exclude_none=True).encode(), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), + user=user, ) @@ -588,7 +662,7 @@ async def copy_model( user=Depends(get_admin_user), ): if url_idx is None: - await get_all_models(request) + await get_all_models(request, user=user) models = request.app.state.OLLAMA_MODELS if form_data.source in models: @@ -609,6 +683,16 @@ async def copy_model( headers={ "Content-Type": "application/json", **({"Authorization": f"Bearer {key}"} if key else {}), + **( + { + "X-OpenWebUI-User-Name": user.name, + "X-OpenWebUI-User-Id": user.id, + "X-OpenWebUI-User-Email": user.email, + "X-OpenWebUI-User-Role": user.role, + } + if ENABLE_FORWARD_USER_INFO_HEADERS and user + else {} + ), }, data=form_data.model_dump_json(exclude_none=True).encode(), ) @@ -643,7 +727,7 @@ async def delete_model( user=Depends(get_admin_user), ): if url_idx is None: - await get_all_models(request) + await get_all_models(request, user=user) models = request.app.state.OLLAMA_MODELS if form_data.name in models: @@ -665,6 +749,16 @@ async def delete_model( headers={ "Content-Type": "application/json", **({"Authorization": f"Bearer {key}"} if key else {}), + **( + { + "X-OpenWebUI-User-Name": user.name, + "X-OpenWebUI-User-Id": user.id, + "X-OpenWebUI-User-Email": user.email, + "X-OpenWebUI-User-Role": user.role, + } + if ENABLE_FORWARD_USER_INFO_HEADERS and user + else {} + ), }, ) r.raise_for_status() @@ -693,7 +787,7 @@ async def delete_model( async def show_model_info( request: Request, form_data: ModelNameForm, user=Depends(get_verified_user) ): - await get_all_models(request) + await get_all_models(request, user=user) models = request.app.state.OLLAMA_MODELS if form_data.name not in models: @@ -714,6 +808,16 @@ async def show_model_info( headers={ "Content-Type": "application/json", **({"Authorization": f"Bearer {key}"} if key else {}), + **( + { + "X-OpenWebUI-User-Name": user.name, + "X-OpenWebUI-User-Id": user.id, + "X-OpenWebUI-User-Email": user.email, + "X-OpenWebUI-User-Role": user.role, + } + if ENABLE_FORWARD_USER_INFO_HEADERS and user + else {} + ), }, data=form_data.model_dump_json(exclude_none=True).encode(), ) @@ -757,7 +861,7 @@ async def embed( log.info(f"generate_ollama_batch_embeddings {form_data}") if url_idx is None: - await get_all_models(request) + await get_all_models(request, user=user) models = request.app.state.OLLAMA_MODELS model = form_data.model @@ -783,6 +887,16 @@ async def embed( headers={ "Content-Type": "application/json", **({"Authorization": f"Bearer {key}"} if key else {}), + **( + { + "X-OpenWebUI-User-Name": user.name, + "X-OpenWebUI-User-Id": user.id, + "X-OpenWebUI-User-Email": user.email, + "X-OpenWebUI-User-Role": user.role, + } + if ENABLE_FORWARD_USER_INFO_HEADERS and user + else {} + ), }, data=form_data.model_dump_json(exclude_none=True).encode(), ) @@ -826,7 +940,7 @@ async def embeddings( log.info(f"generate_ollama_embeddings {form_data}") if url_idx is None: - await get_all_models(request) + await get_all_models(request, user=user) models = request.app.state.OLLAMA_MODELS model = form_data.model @@ -852,6 +966,16 @@ async def embeddings( headers={ "Content-Type": "application/json", **({"Authorization": f"Bearer {key}"} if key else {}), + **( + { + "X-OpenWebUI-User-Name": user.name, + "X-OpenWebUI-User-Id": user.id, + "X-OpenWebUI-User-Email": user.email, + "X-OpenWebUI-User-Role": user.role, + } + if ENABLE_FORWARD_USER_INFO_HEADERS and user + else {} + ), }, data=form_data.model_dump_json(exclude_none=True).encode(), ) @@ -901,7 +1025,7 @@ async def generate_completion( user=Depends(get_verified_user), ): if url_idx is None: - await get_all_models(request) + await get_all_models(request, user=user) models = request.app.state.OLLAMA_MODELS model = form_data.model @@ -931,6 +1055,7 @@ async def generate_completion( url=f"{url}/api/generate", payload=form_data.model_dump_json(exclude_none=True).encode(), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), + user=user, ) @@ -1053,13 +1178,14 @@ async def generate_chat_completion( prefix_id = api_config.get("prefix_id", None) if prefix_id: payload["model"] = payload["model"].replace(f"{prefix_id}.", "") - + # payload["keep_alive"] = -1 # keep alive forever return await send_post_request( url=f"{url}/api/chat", payload=json.dumps(payload), stream=form_data.stream, key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), content_type="application/x-ndjson", + user=user, ) @@ -1071,7 +1197,7 @@ class OpenAIChatMessageContent(BaseModel): class OpenAIChatMessage(BaseModel): role: str - content: Union[str, list[OpenAIChatMessageContent]] + content: Union[Optional[str], list[OpenAIChatMessageContent]] model_config = ConfigDict(extra="allow") @@ -1162,6 +1288,7 @@ async def generate_openai_completion( payload=json.dumps(payload), stream=payload.get("stream", False), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), + user=user, ) @@ -1240,6 +1367,7 @@ async def generate_openai_chat_completion( payload=json.dumps(payload), stream=payload.get("stream", False), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), + user=user, ) @@ -1253,7 +1381,7 @@ async def get_openai_models( models = [] if url_idx is None: - model_list = await get_all_models(request) + model_list = await get_all_models(request, user=user) models = [ { "id": model["model"], diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index afda362373f..0310014cf5e 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -22,10 +22,11 @@ ) from open_webui.env import ( AIOHTTP_CLIENT_TIMEOUT, - AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST, + AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, ENABLE_FORWARD_USER_INFO_HEADERS, BYPASS_MODEL_ACCESS_CONTROL, ) +from open_webui.models.users import UserModel from open_webui.constants import ERROR_MESSAGES from open_webui.env import ENV, SRC_LOG_LEVELS @@ -35,6 +36,9 @@ apply_model_params_to_body_openai, apply_model_system_prompt_to_body, ) +from open_webui.utils.misc import ( + convert_logit_bias_input_to_json, +) from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_access @@ -51,12 +55,25 @@ ########################################## -async def send_get_request(url, key=None): - timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST) +async def send_get_request(url, key=None, user: UserModel = None): + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_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 {})} + url, + headers={ + **({"Authorization": f"Bearer {key}"} if key else {}), + **( + { + "X-OpenWebUI-User-Name": user.name, + "X-OpenWebUI-User-Id": user.id, + "X-OpenWebUI-User-Email": user.email, + "X-OpenWebUI-User-Role": user.role, + } + if ENABLE_FORWARD_USER_INFO_HEADERS and user + else {} + ), + }, ) as response: return await response.json() except Exception as e: @@ -84,9 +101,15 @@ def openai_o1_o3_handler(payload): payload["max_completion_tokens"] = payload["max_tokens"] del payload["max_tokens"] - # Fix: O1 does not support the "system" parameter, Modify "system" to "user" + # Fix: o1 and o3 do not support the "system" role directly. + # For older models like "o1-mini" or "o1-preview", use role "user". + # For newer o1/o3 models, replace "system" with "developer". if payload["messages"][0]["role"] == "system": - payload["messages"][0]["role"] = "user" + model_lower = payload["model"].lower() + if model_lower.startswith("o1-mini") or model_lower.startswith("o1-preview"): + payload["messages"][0]["role"] = "user" + else: + payload["messages"][0]["role"] = "developer" return payload @@ -172,7 +195,7 @@ async def speech(request: Request, user=Depends(get_verified_user)): body = await request.body() name = hashlib.sha256(body).hexdigest() - SPEECH_CACHE_DIR = Path(CACHE_DIR).joinpath("./audio/speech/") + SPEECH_CACHE_DIR = CACHE_DIR / "audio" / "speech" SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True) file_path = SPEECH_CACHE_DIR.joinpath(f"{name}.mp3") file_body_path = SPEECH_CACHE_DIR.joinpath(f"{name}.json") @@ -247,7 +270,7 @@ async def speech(request: Request, user=Depends(get_verified_user)): raise HTTPException(status_code=401, detail=ERROR_MESSAGES.OPENAI_NOT_FOUND) -async def get_all_models_responses(request: Request) -> list: +async def get_all_models_responses(request: Request, user: UserModel) -> list: if not request.app.state.config.ENABLE_OPENAI_API: return [] @@ -271,7 +294,9 @@ async def get_all_models_responses(request: Request) -> list: ): request_tasks.append( send_get_request( - f"{url}/models", request.app.state.config.OPENAI_API_KEYS[idx] + f"{url}/models", + request.app.state.config.OPENAI_API_KEYS[idx], + user=user, ) ) else: @@ -291,6 +316,7 @@ async def get_all_models_responses(request: Request) -> list: send_get_request( f"{url}/models", request.app.state.config.OPENAI_API_KEYS[idx], + user=user, ) ) else: @@ -327,6 +353,7 @@ async def get_all_models_responses(request: Request) -> list: ) prefix_id = api_config.get("prefix_id", None) + tags = api_config.get("tags", []) if prefix_id: for model in ( @@ -334,6 +361,12 @@ async def get_all_models_responses(request: Request) -> list: ): model["id"] = f"{prefix_id}.{model['id']}" + if tags: + for model in ( + response if isinstance(response, list) else response.get("data", []) + ): + model["tags"] = tags + log.debug(f"get_all_models:responses() {responses}") return responses @@ -351,14 +384,14 @@ async def get_filtered_models(models, user): return filtered_models -@cached(ttl=3) -async def get_all_models(request: Request) -> dict[str, list]: +@cached(ttl=1) +async def get_all_models(request: Request, user: UserModel) -> dict[str, list]: log.info("get_all_models()") if not request.app.state.config.ENABLE_OPENAI_API: return {"data": []} - responses = await get_all_models_responses(request) + responses = await get_all_models_responses(request, user=user) def extract_data(response): if response and "data" in response: @@ -373,6 +406,7 @@ def merge_models_lists(model_lists): for idx, models in enumerate(model_lists): if models is not None and "error" not in models: + merged_list.extend( [ { @@ -383,18 +417,21 @@ def merge_models_lists(model_lists): "urlIdx": idx, } for model in models - if "api.openai.com" - not in request.app.state.config.OPENAI_API_BASE_URLS[idx] - or not any( - name in model["id"] - for name in [ - "babbage", - "dall-e", - "davinci", - "embedding", - "tts", - "whisper", - ] + if (model.get("id") or model.get("name")) + and ( + "api.openai.com" + not in request.app.state.config.OPENAI_API_BASE_URLS[idx] + or not any( + name in model["id"] + for name in [ + "babbage", + "dall-e", + "davinci", + "embedding", + "tts", + "whisper", + ] + ) ) ] ) @@ -418,16 +455,14 @@ async def get_models( } if url_idx is None: - models = await get_all_models(request) + models = await get_all_models(request, user=user) else: url = request.app.state.config.OPENAI_API_BASE_URLS[url_idx] key = request.app.state.config.OPENAI_API_KEYS[url_idx] r = None async with aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout( - total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST - ) + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) ) as session: try: async with session.get( @@ -507,7 +542,7 @@ async def verify_connection( key = form_data.key async with aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST) + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) ) as session: try: async with session.get( @@ -515,6 +550,16 @@ async def verify_connection( headers={ "Authorization": f"Bearer {key}", "Content-Type": "application/json", + **( + { + "X-OpenWebUI-User-Name": user.name, + "X-OpenWebUI-User-Id": user.id, + "X-OpenWebUI-User-Email": user.email, + "X-OpenWebUI-User-Role": user.role, + } + if ENABLE_FORWARD_USER_INFO_HEADERS + else {} + ), }, ) as r: if r.status != 200: @@ -587,7 +632,7 @@ async def generate_chat_completion( detail="Model not found", ) - await get_all_models(request) + await get_all_models(request, user=user) model = request.app.state.OPENAI_MODELS.get(model_id) if model: idx = model["urlIdx"] @@ -635,6 +680,11 @@ async def generate_chat_completion( del payload["max_tokens"] # Convert the modified body back to JSON + if "logit_bias" in payload: + payload["logit_bias"] = json.loads( + convert_logit_bias_input_to_json(payload["logit_bias"]) + ) + payload = json.dumps(payload) r = None @@ -777,7 +827,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): if r is not None: try: res = await r.json() - print(res) + log.error(res) if "error" in res: detail = f"External: {res['error']['message'] if 'message' in res['error'] else res['error']}" except Exception: diff --git a/backend/open_webui/routers/pipelines.py b/backend/open_webui/routers/pipelines.py index ad280b65c14..10c8e9b2ec9 100644 --- a/backend/open_webui/routers/pipelines.py +++ b/backend/open_webui/routers/pipelines.py @@ -90,8 +90,8 @@ async def process_pipeline_inlet_filter(request, payload, user, models): headers=headers, json=request_data, ) as response: - response.raise_for_status() payload = await response.json() + response.raise_for_status() except aiohttp.ClientResponseError as e: res = ( await response.json() @@ -101,7 +101,7 @@ async def process_pipeline_inlet_filter(request, payload, user, models): if "detail" in res: raise Exception(response.status, res["detail"]) except Exception as e: - print(f"Connection error: {e}") + log.exception(f"Connection error: {e}") return payload @@ -139,8 +139,8 @@ async def process_pipeline_outlet_filter(request, payload, user, models): headers=headers, json=request_data, ) as response: - response.raise_for_status() payload = await response.json() + response.raise_for_status() except aiohttp.ClientResponseError as e: try: res = ( @@ -153,7 +153,7 @@ async def process_pipeline_outlet_filter(request, payload, user, models): except Exception: pass except Exception as e: - print(f"Connection error: {e}") + log.exception(f"Connection error: {e}") return payload @@ -169,7 +169,7 @@ async def process_pipeline_outlet_filter(request, payload, user, models): @router.get("/list") async def get_pipelines_list(request: Request, user=Depends(get_admin_user)): - responses = await get_all_models_responses(request) + responses = await get_all_models_responses(request, user) log.debug(f"get_pipelines_list: get_openai_models_responses returned {responses}") urlIdxs = [ @@ -196,7 +196,7 @@ async def upload_pipeline( file: UploadFile = File(...), user=Depends(get_admin_user), ): - print("upload_pipeline", urlIdx, file.filename) + log.info(f"upload_pipeline: urlIdx={urlIdx}, filename={file.filename}") # Check if the uploaded file is a python file if not (file.filename and file.filename.endswith(".py")): raise HTTPException( @@ -231,7 +231,7 @@ async def upload_pipeline( return {**data} except Exception as e: # Handle connection error here - print(f"Connection error: {e}") + log.exception(f"Connection error: {e}") detail = None status_code = status.HTTP_404_NOT_FOUND @@ -282,7 +282,7 @@ async def add_pipeline( return {**data} except Exception as e: # Handle connection error here - print(f"Connection error: {e}") + log.exception(f"Connection error: {e}") detail = None if r is not None: @@ -327,7 +327,7 @@ async def delete_pipeline( return {**data} except Exception as e: # Handle connection error here - print(f"Connection error: {e}") + log.exception(f"Connection error: {e}") detail = None if r is not None: @@ -361,7 +361,7 @@ async def get_pipelines( return {**data} except Exception as e: # Handle connection error here - print(f"Connection error: {e}") + log.exception(f"Connection error: {e}") detail = None if r is not None: @@ -400,7 +400,7 @@ async def get_pipeline_valves( return {**data} except Exception as e: # Handle connection error here - print(f"Connection error: {e}") + log.exception(f"Connection error: {e}") detail = None if r is not None: @@ -440,7 +440,7 @@ async def get_pipeline_valves_spec( return {**data} except Exception as e: # Handle connection error here - print(f"Connection error: {e}") + log.exception(f"Connection error: {e}") detail = None if r is not None: @@ -482,7 +482,7 @@ async def update_pipeline_valves( return {**data} except Exception as e: # Handle connection error here - print(f"Connection error: {e}") + log.exception(f"Connection error: {e}") detail = None diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index e69d2ce9634..f31abd9ff09 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -59,7 +59,7 @@ from open_webui.retrieval.web.tavily import search_tavily from open_webui.retrieval.web.bing import search_bing from open_webui.retrieval.web.exa import search_exa - +from open_webui.retrieval.web.perplexity import search_perplexity from open_webui.retrieval.utils import ( get_embedding_function, @@ -74,7 +74,6 @@ ) from open_webui.utils.auth import get_admin_user, get_verified_user - from open_webui.config import ( ENV, RAG_EMBEDDING_MODEL_AUTO_UPDATE, @@ -83,6 +82,8 @@ RAG_RERANKING_MODEL_TRUST_REMOTE_CODE, UPLOAD_DIR, DEFAULT_LOCALE, + RAG_EMBEDDING_CONTENT_PREFIX, + RAG_EMBEDDING_QUERY_PREFIX, ) from open_webui.env import ( SRC_LOG_LEVELS, @@ -123,7 +124,7 @@ def get_ef( def get_rf( - reranking_model: str, + reranking_model: Optional[str] = None, auto_update: bool = False, ): rf = None @@ -149,8 +150,8 @@ def get_rf( device=DEVICE_TYPE, trust_remote_code=RAG_RERANKING_MODEL_TRUST_REMOTE_CODE, ) - except: - log.error("CrossEncoder error") + except Exception as e: + log.error(f"CrossEncoder: {e}") raise Exception(ERROR_MESSAGES.DEFAULT("CrossEncoder error")) return rf @@ -173,7 +174,7 @@ class ProcessUrlForm(CollectionNameForm): url: str -class SearchForm(CollectionNameForm): +class SearchForm(BaseModel): query: str @@ -352,10 +353,20 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): "status": True, "pdf_extract_images": request.app.state.config.PDF_EXTRACT_IMAGES, "RAG_FULL_CONTEXT": request.app.state.config.RAG_FULL_CONTEXT, + "BYPASS_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL, "enable_google_drive_integration": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, + "enable_onedrive_integration": request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION, "content_extraction": { "engine": request.app.state.config.CONTENT_EXTRACTION_ENGINE, "tika_server_url": request.app.state.config.TIKA_SERVER_URL, + "docling_server_url": request.app.state.config.DOCLING_SERVER_URL, + "document_intelligence_config": { + "endpoint": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, + "key": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, + }, + "mistral_ocr_config": { + "api_key": request.app.state.config.MISTRAL_OCR_API_KEY, + }, }, "chunk": { "text_splitter": request.app.state.config.TEXT_SPLITTER, @@ -373,10 +384,11 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): }, "web": { "ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION, - "RAG_WEB_SEARCH_FULL_CONTEXT": request.app.state.config.RAG_WEB_SEARCH_FULL_CONTEXT, + "BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, "search": { "enabled": request.app.state.config.ENABLE_RAG_WEB_SEARCH, "drive": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, + "onedrive": request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION, "engine": request.app.state.config.RAG_WEB_SEARCH_ENGINE, "searxng_query_url": request.app.state.config.SEARXNG_QUERY_URL, "google_pse_api_key": request.app.state.config.GOOGLE_PSE_API_KEY, @@ -398,7 +410,9 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): "bing_search_v7_endpoint": request.app.state.config.BING_SEARCH_V7_ENDPOINT, "bing_search_v7_subscription_key": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY, "exa_api_key": request.app.state.config.EXA_API_KEY, + "perplexity_api_key": request.app.state.config.PERPLEXITY_API_KEY, "result_count": request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, + "trust_env": request.app.state.config.RAG_WEB_SEARCH_TRUST_ENV, "concurrent_requests": request.app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS, "domain_filter_list": request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, }, @@ -411,9 +425,21 @@ class FileConfig(BaseModel): max_count: Optional[int] = None +class DocumentIntelligenceConfigForm(BaseModel): + endpoint: str + key: str + + +class MistralOCRConfigForm(BaseModel): + api_key: str + + class ContentExtractionConfig(BaseModel): engine: str = "" tika_server_url: Optional[str] = None + docling_server_url: Optional[str] = None + document_intelligence_config: Optional[DocumentIntelligenceConfigForm] = None + mistral_ocr_config: Optional[MistralOCRConfigForm] = None class ChunkParamUpdateForm(BaseModel): @@ -451,6 +477,7 @@ class WebSearchConfig(BaseModel): bing_search_v7_endpoint: Optional[str] = None bing_search_v7_subscription_key: Optional[str] = None exa_api_key: Optional[str] = None + perplexity_api_key: Optional[str] = None result_count: Optional[int] = None concurrent_requests: Optional[int] = None trust_env: Optional[bool] = None @@ -460,13 +487,15 @@ class WebSearchConfig(BaseModel): class WebConfig(BaseModel): search: WebSearchConfig ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION: Optional[bool] = None - RAG_WEB_SEARCH_FULL_CONTEXT: Optional[bool] = None + BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None class ConfigUpdateForm(BaseModel): RAG_FULL_CONTEXT: Optional[bool] = None + BYPASS_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None pdf_extract_images: Optional[bool] = None enable_google_drive_integration: Optional[bool] = None + enable_onedrive_integration: Optional[bool] = None file: Optional[FileConfig] = None content_extraction: Optional[ContentExtractionConfig] = None chunk: Optional[ChunkParamUpdateForm] = None @@ -490,24 +519,52 @@ async def update_rag_config( else request.app.state.config.RAG_FULL_CONTEXT ) + request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL = ( + form_data.BYPASS_EMBEDDING_AND_RETRIEVAL + if form_data.BYPASS_EMBEDDING_AND_RETRIEVAL is not None + else request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL + ) + request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ( form_data.enable_google_drive_integration if form_data.enable_google_drive_integration is not None else request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION ) + request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION = ( + form_data.enable_onedrive_integration + if form_data.enable_onedrive_integration is not None + else request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION + ) + if form_data.file is not None: request.app.state.config.FILE_MAX_SIZE = form_data.file.max_size request.app.state.config.FILE_MAX_COUNT = form_data.file.max_count if form_data.content_extraction is not None: - log.info(f"Updating text settings: {form_data.content_extraction}") + log.info( + f"Updating content extraction: {request.app.state.config.CONTENT_EXTRACTION_ENGINE} to {form_data.content_extraction.engine}" + ) request.app.state.config.CONTENT_EXTRACTION_ENGINE = ( form_data.content_extraction.engine ) request.app.state.config.TIKA_SERVER_URL = ( form_data.content_extraction.tika_server_url ) + request.app.state.config.DOCLING_SERVER_URL = ( + form_data.content_extraction.docling_server_url + ) + if form_data.content_extraction.document_intelligence_config is not None: + request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = ( + form_data.content_extraction.document_intelligence_config.endpoint + ) + request.app.state.config.DOCUMENT_INTELLIGENCE_KEY = ( + form_data.content_extraction.document_intelligence_config.key + ) + if form_data.content_extraction.mistral_ocr_config is not None: + request.app.state.config.MISTRAL_OCR_API_KEY = ( + form_data.content_extraction.mistral_ocr_config.api_key + ) if form_data.chunk is not None: request.app.state.config.TEXT_SPLITTER = form_data.chunk.text_splitter @@ -528,8 +585,8 @@ async def update_rag_config( request.app.state.config.ENABLE_RAG_WEB_SEARCH = form_data.web.search.enabled request.app.state.config.RAG_WEB_SEARCH_ENGINE = form_data.web.search.engine - request.app.state.config.RAG_WEB_SEARCH_FULL_CONTEXT = ( - form_data.web.RAG_WEB_SEARCH_FULL_CONTEXT + request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = ( + form_data.web.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL ) request.app.state.config.SEARXNG_QUERY_URL = ( @@ -580,6 +637,10 @@ async def update_rag_config( request.app.state.config.EXA_API_KEY = form_data.web.search.exa_api_key + request.app.state.config.PERPLEXITY_API_KEY = ( + form_data.web.search.perplexity_api_key + ) + request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT = ( form_data.web.search.result_count ) @@ -597,6 +658,7 @@ async def update_rag_config( "status": True, "pdf_extract_images": request.app.state.config.PDF_EXTRACT_IMAGES, "RAG_FULL_CONTEXT": request.app.state.config.RAG_FULL_CONTEXT, + "BYPASS_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL, "file": { "max_size": request.app.state.config.FILE_MAX_SIZE, "max_count": request.app.state.config.FILE_MAX_COUNT, @@ -604,6 +666,14 @@ async def update_rag_config( "content_extraction": { "engine": request.app.state.config.CONTENT_EXTRACTION_ENGINE, "tika_server_url": request.app.state.config.TIKA_SERVER_URL, + "docling_server_url": request.app.state.config.DOCLING_SERVER_URL, + "document_intelligence_config": { + "endpoint": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, + "key": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, + }, + "mistral_ocr_config": { + "api_key": request.app.state.config.MISTRAL_OCR_API_KEY, + }, }, "chunk": { "text_splitter": request.app.state.config.TEXT_SPLITTER, @@ -617,7 +687,7 @@ async def update_rag_config( }, "web": { "ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION, - "RAG_WEB_SEARCH_FULL_CONTEXT": request.app.state.config.RAG_WEB_SEARCH_FULL_CONTEXT, + "BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, "search": { "enabled": request.app.state.config.ENABLE_RAG_WEB_SEARCH, "engine": request.app.state.config.RAG_WEB_SEARCH_ENGINE, @@ -641,6 +711,7 @@ async def update_rag_config( "bing_search_v7_endpoint": request.app.state.config.BING_SEARCH_V7_ENDPOINT, "bing_search_v7_subscription_key": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY, "exa_api_key": request.app.state.config.EXA_API_KEY, + "perplexity_api_key": request.app.state.config.PERPLEXITY_API_KEY, "result_count": request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, "concurrent_requests": request.app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS, "trust_env": request.app.state.config.RAG_WEB_SEARCH_TRUST_ENV, @@ -664,6 +735,7 @@ async def get_query_settings(request: Request, user=Depends(get_admin_user)): "status": True, "template": request.app.state.config.RAG_TEMPLATE, "k": request.app.state.config.TOP_K, + "k_reranker": request.app.state.config.TOP_K_RERANKER, "r": request.app.state.config.RELEVANCE_THRESHOLD, "hybrid": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, } @@ -671,6 +743,7 @@ async def get_query_settings(request: Request, user=Depends(get_admin_user)): class QuerySettingsForm(BaseModel): k: Optional[int] = None + k_reranker: Optional[int] = None r: Optional[float] = None template: Optional[str] = None hybrid: Optional[bool] = None @@ -682,16 +755,21 @@ async def update_query_settings( ): request.app.state.config.RAG_TEMPLATE = form_data.template request.app.state.config.TOP_K = form_data.k if form_data.k else 4 + request.app.state.config.TOP_K_RERANKER = form_data.k_reranker or 4 request.app.state.config.RELEVANCE_THRESHOLD = form_data.r if form_data.r else 0.0 request.app.state.config.ENABLE_RAG_HYBRID_SEARCH = ( form_data.hybrid if form_data.hybrid else False ) + if not request.app.state.config.ENABLE_RAG_HYBRID_SEARCH: + request.app.state.rf = None + return { "status": True, "template": request.app.state.config.RAG_TEMPLATE, "k": request.app.state.config.TOP_K, + "k_reranker": request.app.state.config.TOP_K_RERANKER, "r": request.app.state.config.RELEVANCE_THRESHOLD, "hybrid": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, } @@ -832,7 +910,9 @@ def _get_docs_info(docs: list[Document]) -> str: ) embeddings = embedding_function( - list(map(lambda x: x.replace("\n", " "), texts)), user=user + list(map(lambda x: x.replace("\n", " "), texts)), + prefix=RAG_EMBEDDING_CONTENT_PREFIX, + user=user, ) items = [ @@ -878,9 +958,14 @@ def process_file( if form_data.content: # Update the content in the file - # Usage: /files/{file_id}/data/content/update + # Usage: /files/{file_id}/data/content/update, /files/ (audio file upload pipeline) - VECTOR_DB_CLIENT.delete_collection(collection_name=f"file-{file.id}") + try: + # /files/{file_id}/data/content/update + VECTOR_DB_CLIENT.delete_collection(collection_name=f"file-{file.id}") + except: + # Audio file upload pipeline + pass docs = [ Document( @@ -936,7 +1021,11 @@ def process_file( loader = Loader( engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE, TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL, + DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL, PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES, + DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, + DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, + MISTRAL_OCR_API_KEY=request.app.state.config.MISTRAL_OCR_API_KEY, ) docs = loader.load( file.filename, file.meta.get("content_type"), file_path @@ -979,36 +1068,45 @@ def process_file( hash = calculate_sha256_string(text_content) Files.update_file_hash_by_id(file.id, hash) - try: - result = save_docs_to_vector_db( - request, - docs=docs, - collection_name=collection_name, - metadata={ - "file_id": file.id, - "name": file.filename, - "hash": hash, - }, - add=(True if form_data.collection_name else False), - user=user, - ) - - if result: - Files.update_file_metadata_by_id( - file.id, - { - "collection_name": collection_name, + if not request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL: + try: + result = save_docs_to_vector_db( + request, + docs=docs, + collection_name=collection_name, + metadata={ + "file_id": file.id, + "name": file.filename, + "hash": hash, }, + add=(True if form_data.collection_name else False), + user=user, ) - return { - "status": True, - "collection_name": collection_name, - "filename": file.filename, - "content": text_content, - } - except Exception as e: - raise e + if result: + Files.update_file_metadata_by_id( + file.id, + { + "collection_name": collection_name, + }, + ) + + return { + "status": True, + "collection_name": collection_name, + "filename": file.filename, + "content": text_content, + } + except Exception as e: + raise e + else: + return { + "status": True, + "collection_name": None, + "filename": file.filename, + "content": text_content, + } + except Exception as e: log.exception(e) if "No pandoc was found" in str(e): @@ -1124,9 +1222,13 @@ def process_web( content = " ".join([doc.page_content for doc in docs]) log.debug(f"text_content: {content}") - save_docs_to_vector_db( - request, docs, collection_name, overwrite=True, user=user - ) + + if not request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: + save_docs_to_vector_db( + request, docs, collection_name, overwrite=True, user=user + ) + else: + collection_name = None return { "status": True, @@ -1138,6 +1240,7 @@ def process_web( }, "meta": { "name": form_data.url, + "source": form_data.url, }, }, } @@ -1163,6 +1266,7 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: - SERPLY_API_KEY - TAVILY_API_KEY - EXA_API_KEY + - PERPLEXITY_API_KEY - SEARCHAPI_API_KEY + SEARCHAPI_ENGINE (by default `google`) - SERPAPI_API_KEY + SERPAPI_ENGINE (by default `google`) Args: @@ -1327,6 +1431,13 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, ) + elif engine == "perplexity": + return search_perplexity( + request.app.state.config.PERPLEXITY_API_KEY, + query, + request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, + request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, + ) else: raise Exception("No search engine API key found in environment variables") @@ -1353,12 +1464,6 @@ async def process_web_search( log.debug(f"web_results: {web_results}") try: - collection_name = form_data.collection_name - if collection_name == "" or collection_name is None: - collection_name = f"web-search-{calculate_sha256_string(form_data.query)}"[ - :63 - ] - urls = [result.link for result in web_results] loader = get_web_loader( urls, @@ -1367,10 +1472,15 @@ async def process_web_search( trust_env=request.app.state.config.RAG_WEB_SEARCH_TRUST_ENV, ) docs = await loader.aload() + urls = [ + doc.metadata["source"] for doc in docs + ] # only keep URLs which could be retrieved - if request.app.state.config.RAG_WEB_SEARCH_FULL_CONTEXT: + if request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: return { "status": True, + "collection_name": None, + "filenames": urls, "docs": [ { "content": doc.page_content, @@ -1378,22 +1488,29 @@ async def process_web_search( } for doc in docs ], - "filenames": urls, "loaded_count": len(docs), } else: - await run_in_threadpool( - save_docs_to_vector_db, - request, - docs, - collection_name, - overwrite=True, - user=user, - ) + collection_names = [] + for doc_idx, doc in enumerate(docs): + collection_name = f"web-search-{calculate_sha256_string(form_data.query + '-' + urls[doc_idx])}"[ + :63 + ] + + collection_names.append(collection_name) + + await run_in_threadpool( + save_docs_to_vector_db, + request, + [doc], + collection_name, + overwrite=True, + user=user, + ) return { "status": True, - "collection_name": collection_name, + "collection_names": collection_names, "filenames": urls, "loaded_count": len(docs), } @@ -1409,6 +1526,7 @@ class QueryDocForm(BaseModel): collection_name: str query: str k: Optional[int] = None + k_reranker: Optional[int] = None r: Optional[float] = None hybrid: Optional[bool] = None @@ -1421,14 +1539,21 @@ def query_doc_handler( ): try: if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH: + collection_results = {} + collection_results[form_data.collection_name] = VECTOR_DB_CLIENT.get( + collection_name=form_data.collection_name + ) return query_doc_with_hybrid_search( collection_name=form_data.collection_name, + collection_result=collection_results[form_data.collection_name], query=form_data.query, - embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION( - query, user=user + embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION( + query, prefix=prefix, user=user ), k=form_data.k if form_data.k else request.app.state.config.TOP_K, reranking_function=request.app.state.rf, + k_reranker=form_data.k_reranker + or request.app.state.config.TOP_K_RERANKER, r=( form_data.r if form_data.r @@ -1440,7 +1565,7 @@ def query_doc_handler( return query_doc( collection_name=form_data.collection_name, query_embedding=request.app.state.EMBEDDING_FUNCTION( - form_data.query, user=user + form_data.query, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user ), k=form_data.k if form_data.k else request.app.state.config.TOP_K, user=user, @@ -1457,6 +1582,7 @@ class QueryCollectionsForm(BaseModel): collection_names: list[str] query: str k: Optional[int] = None + k_reranker: Optional[int] = None r: Optional[float] = None hybrid: Optional[bool] = None @@ -1472,11 +1598,13 @@ def query_collection_handler( return query_collection_with_hybrid_search( collection_names=form_data.collection_names, queries=[form_data.query], - embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION( - query, user=user + embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION( + query, prefix=prefix, user=user ), k=form_data.k if form_data.k else request.app.state.config.TOP_K, reranking_function=request.app.state.rf, + k_reranker=form_data.k_reranker + or request.app.state.config.TOP_K_RERANKER, r=( form_data.r if form_data.r @@ -1487,8 +1615,8 @@ def query_collection_handler( return query_collection( collection_names=form_data.collection_names, queries=[form_data.query], - embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION( - query, user=user + embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION( + query, prefix=prefix, user=user ), k=form_data.k if form_data.k else request.app.state.config.TOP_K, ) @@ -1553,11 +1681,11 @@ def reset_upload_dir(user=Depends(get_admin_user)) -> bool: elif os.path.isdir(file_path): shutil.rmtree(file_path) # Remove the directory except Exception as e: - print(f"Failed to delete {file_path}. Reason: {e}") + log.exception(f"Failed to delete {file_path}. Reason: {e}") else: - print(f"The directory {folder} does not exist") + log.warning(f"The directory {folder} does not exist") except Exception as e: - print(f"Failed to process the directory {folder}. Reason: {e}") + log.exception(f"Failed to process the directory {folder}. Reason: {e}") return True @@ -1565,7 +1693,11 @@ def reset_upload_dir(user=Depends(get_admin_user)) -> bool: @router.get("/ef/{text}") async def get_embeddings(request: Request, text: Optional[str] = "Hello World!"): - return {"result": request.app.state.EMBEDDING_FUNCTION(text)} + return { + "result": request.app.state.EMBEDDING_FUNCTION( + text, prefix=RAG_EMBEDDING_QUERY_PREFIX + ) + } class BatchProcessFilesForm(BaseModel): diff --git a/backend/open_webui/routers/tasks.py b/backend/open_webui/routers/tasks.py index 0328cefe049..39fca43d3ef 100644 --- a/backend/open_webui/routers/tasks.py +++ b/backend/open_webui/routers/tasks.py @@ -20,6 +20,10 @@ from open_webui.constants import TASKS from open_webui.routers.pipelines import process_pipeline_inlet_filter +from open_webui.utils.filter import ( + get_sorted_filter_ids, + process_filter_functions, +) from open_webui.utils.task import get_task_model_id from open_webui.config import ( @@ -221,6 +225,12 @@ async def generate_title( }, } + # Process the payload through the pipeline + try: + payload = await process_pipeline_inlet_filter(request, payload, user, models) + except Exception as e: + raise e + try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: @@ -290,6 +300,12 @@ async def generate_chat_tags( }, } + # Process the payload through the pipeline + try: + payload = await process_pipeline_inlet_filter(request, payload, user, models) + except Exception as e: + raise e + try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: @@ -356,6 +372,12 @@ async def generate_image_prompt( }, } + # Process the payload through the pipeline + try: + payload = await process_pipeline_inlet_filter(request, payload, user, models) + except Exception as e: + raise e + try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: @@ -433,6 +455,12 @@ async def generate_queries( }, } + # Process the payload through the pipeline + try: + payload = await process_pipeline_inlet_filter(request, payload, user, models) + except Exception as e: + raise e + try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: @@ -514,6 +542,12 @@ async def generate_autocompletion( }, } + # Process the payload through the pipeline + try: + payload = await process_pipeline_inlet_filter(request, payload, user, models) + except Exception as e: + raise e + try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: @@ -584,6 +618,12 @@ async def generate_emoji( }, } + # Process the payload through the pipeline + try: + payload = await process_pipeline_inlet_filter(request, payload, user, models) + except Exception as e: + raise e + try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: @@ -613,17 +653,6 @@ async def generate_moa_response( detail="Model not found", ) - # Check if the user has a custom task model - # If the user has a custom task model, use that model - task_model_id = get_task_model_id( - model_id, - request.app.state.config.TASK_MODEL, - request.app.state.config.TASK_MODEL_EXTERNAL, - models, - ) - - log.debug(f"generating MOA model {task_model_id} for user {user.email} ") - template = DEFAULT_MOA_GENERATION_PROMPT_TEMPLATE content = moa_response_generation_template( @@ -633,7 +662,7 @@ async def generate_moa_response( ) payload = { - "model": task_model_id, + "model": model_id, "messages": [{"role": "user", "content": content}], "stream": form_data.get("stream", False), "metadata": { @@ -644,6 +673,12 @@ async def generate_moa_response( }, } + # Process the payload through the pipeline + try: + payload = await process_pipeline_inlet_filter(request, payload, user, models) + except Exception as e: + raise e + try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index d6a5c5532fb..8a98b4e2023 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -1,5 +1,7 @@ +import logging from pathlib import Path from typing import Optional +import time from open_webui.models.tools import ( ToolForm, @@ -15,6 +17,12 @@ from open_webui.utils.tools import get_tools_specs from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_access, has_permission +from open_webui.env import SRC_LOG_LEVELS + +from open_webui.utils.tools import get_tool_servers_data + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MAIN"]) router = APIRouter() @@ -25,11 +33,51 @@ @router.get("/", response_model=list[ToolUserResponse]) -async def get_tools(user=Depends(get_verified_user)): - if user.role == "admin": - tools = Tools.get_tools() - else: - tools = Tools.get_tools_by_user_id(user.id, "read") +async def get_tools(request: Request, user=Depends(get_verified_user)): + + if not request.app.state.TOOL_SERVERS: + # If the tool servers are not set, we need to set them + # This is done only once when the server starts + # This is done to avoid loading the tool servers every time + + request.app.state.TOOL_SERVERS = await get_tool_servers_data( + request.app.state.config.TOOL_SERVER_CONNECTIONS + ) + + tools = Tools.get_tools() + for idx, server in enumerate(request.app.state.TOOL_SERVERS): + tools.append( + ToolUserResponse( + **{ + "id": f"server:{server['idx']}", + "user_id": f"server:{server['idx']}", + "name": server["openapi"] + .get("info", {}) + .get("title", "Tool Server"), + "meta": { + "description": server["openapi"] + .get("info", {}) + .get("description", ""), + }, + "access_control": request.app.state.config.TOOL_SERVER_CONNECTIONS[ + idx + ] + .get("config", {}) + .get("access_control", None), + "updated_at": int(time.time()), + "created_at": int(time.time()), + } + ) + ) + + if user.role != "admin": + tools = [ + tool + for tool in tools + if tool.user_id == user.id + or has_access(user.id, "read", tool.access_control) + ] + return tools @@ -100,7 +148,7 @@ async def create_new_tools( specs = get_tools_specs(TOOLS[form_data.id]) tools = Tools.insert_new_tool(user.id, form_data, specs) - tool_cache_dir = Path(CACHE_DIR) / "tools" / form_data.id + tool_cache_dir = CACHE_DIR / "tools" / form_data.id tool_cache_dir.mkdir(parents=True, exist_ok=True) if tools: @@ -111,7 +159,7 @@ async def create_new_tools( detail=ERROR_MESSAGES.DEFAULT("Error creating tools"), ) except Exception as e: - print(e) + log.exception(f"Failed to load the tool by id {form_data.id}: {e}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(str(e)), @@ -193,7 +241,7 @@ async def update_tools_by_id( "specs": specs, } - print(updated) + log.debug(updated) tools = Tools.update_tool_by_id(id, updated) if tools: @@ -343,7 +391,7 @@ async def update_tools_valves_by_id( Tools.update_tool_valves_by_id(id, valves.model_dump()) return valves.model_dump() except Exception as e: - print(e) + log.exception(f"Failed to update tool valves by id {id}: {e}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(str(e)), @@ -421,7 +469,7 @@ async def update_tools_user_valves_by_id( ) return user_valves.model_dump() except Exception as e: - print(e) + log.exception(f"Failed to update user valves by id {id}: {e}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(str(e)), diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 872212d3ce1..d1046bcedb8 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -2,6 +2,7 @@ from typing import Optional from open_webui.models.auths import Auths +from open_webui.models.groups import Groups from open_webui.models.chats import Chats from open_webui.models.users import ( UserModel, @@ -17,7 +18,10 @@ from open_webui.env import SRC_LOG_LEVELS from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel + from open_webui.utils.auth import get_admin_user, get_password_hash, get_verified_user +from open_webui.utils.access_control import get_permissions + log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["MODELS"]) @@ -45,7 +49,7 @@ async def get_users( @router.get("/groups") async def get_user_groups(user=Depends(get_verified_user)): - return Users.get_user_groups(user.id) + return Groups.get_groups_by_member_id(user.id) ############################ @@ -54,8 +58,12 @@ async def get_user_groups(user=Depends(get_verified_user)): @router.get("/permissions") -async def get_user_permissisions(user=Depends(get_verified_user)): - return Users.get_user_groups(user.id) +async def get_user_permissisions(request: Request, user=Depends(get_verified_user)): + user_permissions = get_permissions( + user.id, request.app.state.config.USER_PERMISSIONS + ) + + return user_permissions ############################ @@ -68,15 +76,24 @@ class WorkspacePermissions(BaseModel): tools: bool = False +class SharingPermissions(BaseModel): + public_models: bool = True + public_knowledge: bool = True + public_prompts: bool = True + public_tools: bool = True + + class ChatPermissions(BaseModel): controls: bool = True file_upload: bool = True delete: bool = True edit: bool = True temporary: bool = True + temporary_enforced: bool = False class FeaturesPermissions(BaseModel): + direct_tool_servers: bool = False web_search: bool = True image_generation: bool = True code_interpreter: bool = True @@ -84,16 +101,20 @@ class FeaturesPermissions(BaseModel): class UserPermissions(BaseModel): workspace: WorkspacePermissions + sharing: SharingPermissions chat: ChatPermissions features: FeaturesPermissions @router.get("/default/permissions", response_model=UserPermissions) -async def get_user_permissions(request: Request, user=Depends(get_admin_user)): +async def get_default_user_permissions(request: Request, user=Depends(get_admin_user)): return { "workspace": WorkspacePermissions( **request.app.state.config.USER_PERMISSIONS.get("workspace", {}) ), + "sharing": SharingPermissions( + **request.app.state.config.USER_PERMISSIONS.get("sharing", {}) + ), "chat": ChatPermissions( **request.app.state.config.USER_PERMISSIONS.get("chat", {}) ), @@ -104,7 +125,7 @@ async def get_user_permissions(request: Request, user=Depends(get_admin_user)): @router.post("/default/permissions") -async def update_user_permissions( +async def update_default_user_permissions( request: Request, form_data: UserPermissions, user=Depends(get_admin_user) ): request.app.state.config.USER_PERMISSIONS = form_data.model_dump() diff --git a/backend/open_webui/routers/utils.py b/backend/open_webui/routers/utils.py index fb1dc82725c..b64adafb442 100644 --- a/backend/open_webui/routers/utils.py +++ b/backend/open_webui/routers/utils.py @@ -1,4 +1,5 @@ import black +import logging import markdown from open_webui.models.chats import ChatTitleMessagesForm @@ -13,8 +14,12 @@ from open_webui.utils.pdf_generator import PDFGenerator from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.code_interpreter import execute_code_jupyter +from open_webui.env import SRC_LOG_LEVELS +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MAIN"]) + router = APIRouter() @@ -96,7 +101,7 @@ async def download_chat_as_pdf( headers={"Content-Disposition": "attachment;filename=chat.pdf"}, ) except Exception as e: - print(e) + log.exception(f"Error generating PDF: {e}") raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 6f591512272..83dd74fff1e 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -3,15 +3,24 @@ import logging import sys import time +from redis import asyncio as aioredis from open_webui.models.users import Users, UserNameResponse from open_webui.models.channels import Channels from open_webui.models.chats import Chats +from open_webui.utils.redis import ( + parse_redis_sentinel_url, + get_sentinels_from_env, + AsyncRedisSentinelManager, +) from open_webui.env import ( ENABLE_WEBSOCKET_SUPPORT, WEBSOCKET_MANAGER, WEBSOCKET_REDIS_URL, + WEBSOCKET_REDIS_LOCK_TIMEOUT, + WEBSOCKET_SENTINEL_PORT, + WEBSOCKET_SENTINEL_HOSTS, ) from open_webui.utils.auth import decode_token from open_webui.socket.utils import RedisDict, RedisLock @@ -28,7 +37,19 @@ if WEBSOCKET_MANAGER == "redis": - mgr = socketio.AsyncRedisManager(WEBSOCKET_REDIS_URL) + if WEBSOCKET_SENTINEL_HOSTS: + redis_config = parse_redis_sentinel_url(WEBSOCKET_REDIS_URL) + mgr = AsyncRedisSentinelManager( + WEBSOCKET_SENTINEL_HOSTS.split(","), + sentinel_port=int(WEBSOCKET_SENTINEL_PORT), + redis_port=redis_config["port"], + service=redis_config["service"], + db=redis_config["db"], + username=redis_config["username"], + password=redis_config["password"], + ) + else: + mgr = socketio.AsyncRedisManager(WEBSOCKET_REDIS_URL) sio = socketio.AsyncServer( cors_allowed_origins=[], async_mode="asgi", @@ -54,14 +75,30 @@ if WEBSOCKET_MANAGER == "redis": log.debug("Using Redis to manage websockets.") - SESSION_POOL = RedisDict("open-webui:session_pool", redis_url=WEBSOCKET_REDIS_URL) - USER_POOL = RedisDict("open-webui:user_pool", redis_url=WEBSOCKET_REDIS_URL) - USAGE_POOL = RedisDict("open-webui:usage_pool", redis_url=WEBSOCKET_REDIS_URL) + redis_sentinels = get_sentinels_from_env( + WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT + ) + SESSION_POOL = RedisDict( + "open-webui:session_pool", + redis_url=WEBSOCKET_REDIS_URL, + redis_sentinels=redis_sentinels, + ) + USER_POOL = RedisDict( + "open-webui:user_pool", + redis_url=WEBSOCKET_REDIS_URL, + redis_sentinels=redis_sentinels, + ) + USAGE_POOL = RedisDict( + "open-webui:usage_pool", + redis_url=WEBSOCKET_REDIS_URL, + redis_sentinels=redis_sentinels, + ) clean_up_lock = RedisLock( redis_url=WEBSOCKET_REDIS_URL, lock_name="usage_cleanup_lock", - timeout_secs=TIMEOUT_DURATION * 2, + timeout_secs=WEBSOCKET_REDIS_LOCK_TIMEOUT, + redis_sentinels=redis_sentinels, ) aquire_func = clean_up_lock.aquire_lock renew_func = clean_up_lock.renew_lock @@ -268,11 +305,19 @@ async def disconnect(sid): # print(f"Unknown session ID {sid} disconnected") -def get_event_emitter(request_info): +def get_event_emitter(request_info, update_db=True): async def __event_emitter__(event_data): user_id = request_info["user_id"] + session_ids = list( - set(USER_POOL.get(user_id, []) + [request_info["session_id"]]) + set( + USER_POOL.get(user_id, []) + + ( + [request_info.get("session_id")] + if request_info.get("session_id") + else [] + ) + ) ) for session_id in session_ids: @@ -286,40 +331,41 @@ async def __event_emitter__(event_data): to=session_id, ) - if "type" in event_data and event_data["type"] == "status": - Chats.add_message_status_to_chat_by_id_and_message_id( - request_info["chat_id"], - request_info["message_id"], - event_data.get("data", {}), - ) - - if "type" in event_data and event_data["type"] == "message": - message = Chats.get_message_by_id_and_message_id( - request_info["chat_id"], - request_info["message_id"], - ) - - content = message.get("content", "") - content += event_data.get("data", {}).get("content", "") - - Chats.upsert_message_to_chat_by_id_and_message_id( - request_info["chat_id"], - request_info["message_id"], - { - "content": content, - }, - ) - - if "type" in event_data and event_data["type"] == "replace": - content = event_data.get("data", {}).get("content", "") - - Chats.upsert_message_to_chat_by_id_and_message_id( - request_info["chat_id"], - request_info["message_id"], - { - "content": content, - }, - ) + if update_db: + if "type" in event_data and event_data["type"] == "status": + Chats.add_message_status_to_chat_by_id_and_message_id( + request_info["chat_id"], + request_info["message_id"], + event_data.get("data", {}), + ) + + if "type" in event_data and event_data["type"] == "message": + message = Chats.get_message_by_id_and_message_id( + request_info["chat_id"], + request_info["message_id"], + ) + + content = message.get("content", "") + content += event_data.get("data", {}).get("content", "") + + Chats.upsert_message_to_chat_by_id_and_message_id( + request_info["chat_id"], + request_info["message_id"], + { + "content": content, + }, + ) + + if "type" in event_data and event_data["type"] == "replace": + content = event_data.get("data", {}).get("content", "") + + Chats.upsert_message_to_chat_by_id_and_message_id( + request_info["chat_id"], + request_info["message_id"], + { + "content": content, + }, + ) return __event_emitter__ diff --git a/backend/open_webui/socket/utils.py b/backend/open_webui/socket/utils.py index 46fafbb9e7e..85a8bb7909b 100644 --- a/backend/open_webui/socket/utils.py +++ b/backend/open_webui/socket/utils.py @@ -1,15 +1,17 @@ import json -import redis import uuid +from open_webui.utils.redis import get_redis_connection class RedisLock: - def __init__(self, redis_url, lock_name, timeout_secs): + def __init__(self, redis_url, lock_name, timeout_secs, redis_sentinels=[]): self.lock_name = lock_name self.lock_id = str(uuid.uuid4()) self.timeout_secs = timeout_secs self.lock_obtained = False - self.redis = redis.Redis.from_url(redis_url, decode_responses=True) + self.redis = get_redis_connection( + redis_url, redis_sentinels, decode_responses=True + ) def aquire_lock(self): # nx=True will only set this key if it _hasn't_ already been set @@ -31,9 +33,11 @@ def release_lock(self): class RedisDict: - def __init__(self, name, redis_url): + def __init__(self, name, redis_url, redis_sentinels=[]): self.name = name - self.redis = redis.Redis.from_url(redis_url, decode_responses=True) + self.redis = get_redis_connection( + redis_url, redis_sentinels, decode_responses=True + ) def __setitem__(self, key, value): serialized_value = json.dumps(value) diff --git a/backend/open_webui/static/apple-touch-icon.png b/backend/open_webui/static/apple-touch-icon.png new file mode 100644 index 00000000000..ece4b85dbc8 Binary files /dev/null and b/backend/open_webui/static/apple-touch-icon.png differ diff --git a/backend/open_webui/static/favicon-96x96.png b/backend/open_webui/static/favicon-96x96.png new file mode 100644 index 00000000000..2ebdffebe51 Binary files /dev/null and b/backend/open_webui/static/favicon-96x96.png differ diff --git a/backend/open_webui/static/favicon-dark.png b/backend/open_webui/static/favicon-dark.png new file mode 100644 index 00000000000..08627a23f79 Binary files /dev/null and b/backend/open_webui/static/favicon-dark.png differ diff --git a/backend/open_webui/static/favicon.ico b/backend/open_webui/static/favicon.ico new file mode 100644 index 00000000000..14c5f9c6d43 Binary files /dev/null and b/backend/open_webui/static/favicon.ico differ diff --git a/backend/open_webui/static/favicon.svg b/backend/open_webui/static/favicon.svg new file mode 100644 index 00000000000..0aa909745ac --- /dev/null +++ b/backend/open_webui/static/favicon.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/static/favicon/site.webmanifest b/backend/open_webui/static/site.webmanifest similarity index 76% rename from static/favicon/site.webmanifest rename to backend/open_webui/static/site.webmanifest index 0e59bbb2823..95915ae2bca 100644 --- a/static/favicon/site.webmanifest +++ b/backend/open_webui/static/site.webmanifest @@ -3,13 +3,13 @@ "short_name": "WebUI", "icons": [ { - "src": "/favicon/web-app-manifest-192x192.png", + "src": "/static/web-app-manifest-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/favicon/web-app-manifest-512x512.png", + "src": "/static/web-app-manifest-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/backend/open_webui/static/splash-dark.png b/backend/open_webui/static/splash-dark.png new file mode 100644 index 00000000000..202c03f8e46 Binary files /dev/null and b/backend/open_webui/static/splash-dark.png differ diff --git a/backend/open_webui/static/web-app-manifest-192x192.png b/backend/open_webui/static/web-app-manifest-192x192.png new file mode 100644 index 00000000000..fbd2eab6e2b Binary files /dev/null and b/backend/open_webui/static/web-app-manifest-192x192.png differ diff --git a/backend/open_webui/static/web-app-manifest-512x512.png b/backend/open_webui/static/web-app-manifest-512x512.png new file mode 100644 index 00000000000..afebe2cd082 Binary files /dev/null and b/backend/open_webui/static/web-app-manifest-512x512.png differ diff --git a/backend/open_webui/storage/provider.py b/backend/open_webui/storage/provider.py index 160a45153ac..c5c0056cc45 100644 --- a/backend/open_webui/storage/provider.py +++ b/backend/open_webui/storage/provider.py @@ -1,10 +1,12 @@ import os import shutil import json +import logging from abc import ABC, abstractmethod from typing import BinaryIO, Tuple import boto3 +from botocore.config import Config from botocore.exceptions import ClientError from open_webui.config import ( S3_ACCESS_KEY_ID, @@ -13,6 +15,8 @@ S3_KEY_PREFIX, S3_REGION_NAME, S3_SECRET_ACCESS_KEY, + S3_USE_ACCELERATE_ENDPOINT, + S3_ADDRESSING_STYLE, GCS_BUCKET_NAME, GOOGLE_APPLICATION_CREDENTIALS_JSON, AZURE_STORAGE_ENDPOINT, @@ -27,6 +31,11 @@ from azure.identity import DefaultAzureCredential from azure.storage.blob import BlobServiceClient from azure.core.exceptions import ResourceNotFoundError +from open_webui.env import SRC_LOG_LEVELS + + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MAIN"]) class StorageProvider(ABC): @@ -71,7 +80,7 @@ def delete_file(file_path: str) -> None: if os.path.isfile(file_path): os.remove(file_path) else: - print(f"File {file_path} not found in local storage.") + log.warning(f"File {file_path} not found in local storage.") @staticmethod def delete_all_files() -> None: @@ -85,20 +94,40 @@ def delete_all_files() -> None: elif os.path.isdir(file_path): shutil.rmtree(file_path) # Remove the directory except Exception as e: - print(f"Failed to delete {file_path}. Reason: {e}") + log.exception(f"Failed to delete {file_path}. Reason: {e}") else: - print(f"Directory {UPLOAD_DIR} not found in local storage.") + log.warning(f"Directory {UPLOAD_DIR} not found in local storage.") class S3StorageProvider(StorageProvider): def __init__(self): - self.s3_client = boto3.client( - "s3", - region_name=S3_REGION_NAME, - endpoint_url=S3_ENDPOINT_URL, - aws_access_key_id=S3_ACCESS_KEY_ID, - aws_secret_access_key=S3_SECRET_ACCESS_KEY, + config = Config( + s3={ + "use_accelerate_endpoint": S3_USE_ACCELERATE_ENDPOINT, + "addressing_style": S3_ADDRESSING_STYLE, + }, ) + + # If access key and secret are provided, use them for authentication + if S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY: + self.s3_client = boto3.client( + "s3", + region_name=S3_REGION_NAME, + endpoint_url=S3_ENDPOINT_URL, + aws_access_key_id=S3_ACCESS_KEY_ID, + aws_secret_access_key=S3_SECRET_ACCESS_KEY, + config=config, + ) + else: + # If no explicit credentials are provided, fall back to default AWS credentials + # This supports workload identity (IAM roles for EC2, EKS, etc.) + self.s3_client = boto3.client( + "s3", + region_name=S3_REGION_NAME, + endpoint_url=S3_ENDPOINT_URL, + config=config, + ) + self.bucket_name = S3_BUCKET_NAME self.key_prefix = S3_KEY_PREFIX if S3_KEY_PREFIX else "" diff --git a/backend/open_webui/test/apps/webui/storage/test_provider.py b/backend/open_webui/test/apps/webui/storage/test_provider.py index a5ef1350437..3c874592fe4 100644 --- a/backend/open_webui/test/apps/webui/storage/test_provider.py +++ b/backend/open_webui/test/apps/webui/storage/test_provider.py @@ -187,6 +187,17 @@ def test_delete_all_files(self, monkeypatch, tmp_path): assert not (upload_dir / self.filename).exists() assert not (upload_dir / self.filename_extra).exists() + def test_init_without_credentials(self, monkeypatch): + """Test that S3StorageProvider can initialize without explicit credentials.""" + # Temporarily unset the environment variables + monkeypatch.setattr(provider, "S3_ACCESS_KEY_ID", None) + monkeypatch.setattr(provider, "S3_SECRET_ACCESS_KEY", None) + + # Should not raise an exception + storage = provider.S3StorageProvider() + assert storage.s3_client is not None + assert storage.bucket_name == provider.S3_BUCKET_NAME + class TestGCSStorageProvider: Storage = provider.GCSStorageProvider() diff --git a/backend/open_webui/utils/audit.py b/backend/open_webui/utils/audit.py new file mode 100644 index 00000000000..2d7ceabcb89 --- /dev/null +++ b/backend/open_webui/utils/audit.py @@ -0,0 +1,249 @@ +from contextlib import asynccontextmanager +from dataclasses import asdict, dataclass +from enum import Enum +import re +from typing import ( + TYPE_CHECKING, + Any, + AsyncGenerator, + Dict, + MutableMapping, + Optional, + cast, +) +import uuid + +from asgiref.typing import ( + ASGI3Application, + ASGIReceiveCallable, + ASGIReceiveEvent, + ASGISendCallable, + ASGISendEvent, + Scope as ASGIScope, +) +from loguru import logger +from starlette.requests import Request + +from open_webui.env import AUDIT_LOG_LEVEL, MAX_BODY_LOG_SIZE +from open_webui.utils.auth import get_current_user, get_http_authorization_cred +from open_webui.models.users import UserModel + + +if TYPE_CHECKING: + from loguru import Logger + + +@dataclass(frozen=True) +class AuditLogEntry: + # `Metadata` audit level properties + id: str + user: dict[str, Any] + audit_level: str + verb: str + request_uri: str + user_agent: Optional[str] = None + source_ip: Optional[str] = None + # `Request` audit level properties + request_object: Any = None + # `Request Response` level + response_object: Any = None + response_status_code: Optional[int] = None + + +class AuditLevel(str, Enum): + NONE = "NONE" + METADATA = "METADATA" + REQUEST = "REQUEST" + REQUEST_RESPONSE = "REQUEST_RESPONSE" + + +class AuditLogger: + """ + A helper class that encapsulates audit logging functionality. It uses Loguru’s logger with an auditable binding to ensure that audit log entries are filtered correctly. + + Parameters: + logger (Logger): An instance of Loguru’s logger. + """ + + def __init__(self, logger: "Logger"): + self.logger = logger.bind(auditable=True) + + def write( + self, + audit_entry: AuditLogEntry, + *, + log_level: str = "INFO", + extra: Optional[dict] = None, + ): + + entry = asdict(audit_entry) + + if extra: + entry["extra"] = extra + + self.logger.log( + log_level, + "", + **entry, + ) + + +class AuditContext: + """ + Captures and aggregates the HTTP request and response bodies during the processing of a request. It ensures that only a configurable maximum amount of data is stored to prevent excessive memory usage. + + Attributes: + request_body (bytearray): Accumulated request payload. + response_body (bytearray): Accumulated response payload. + max_body_size (int): Maximum number of bytes to capture. + metadata (Dict[str, Any]): A dictionary to store additional audit metadata (user, http verb, user agent, etc.). + """ + + def __init__(self, max_body_size: int = MAX_BODY_LOG_SIZE): + self.request_body = bytearray() + self.response_body = bytearray() + self.max_body_size = max_body_size + self.metadata: Dict[str, Any] = {} + + def add_request_chunk(self, chunk: bytes): + if len(self.request_body) < self.max_body_size: + self.request_body.extend( + chunk[: self.max_body_size - len(self.request_body)] + ) + + def add_response_chunk(self, chunk: bytes): + if len(self.response_body) < self.max_body_size: + self.response_body.extend( + chunk[: self.max_body_size - len(self.response_body)] + ) + + +class AuditLoggingMiddleware: + """ + ASGI middleware that intercepts HTTP requests and responses to perform audit logging. It captures request/response bodies (depending on audit level), headers, HTTP methods, and user information, then logs a structured audit entry at the end of the request cycle. + """ + + AUDITED_METHODS = {"PUT", "PATCH", "DELETE", "POST"} + + def __init__( + self, + app: ASGI3Application, + *, + excluded_paths: Optional[list[str]] = None, + max_body_size: int = MAX_BODY_LOG_SIZE, + audit_level: AuditLevel = AuditLevel.NONE, + ) -> None: + self.app = app + self.audit_logger = AuditLogger(logger) + self.excluded_paths = excluded_paths or [] + self.max_body_size = max_body_size + self.audit_level = audit_level + + async def __call__( + self, + scope: ASGIScope, + receive: ASGIReceiveCallable, + send: ASGISendCallable, + ) -> None: + if scope["type"] != "http": + return await self.app(scope, receive, send) + + request = Request(scope=cast(MutableMapping, scope)) + + if self._should_skip_auditing(request): + return await self.app(scope, receive, send) + + async with self._audit_context(request) as context: + + async def send_wrapper(message: ASGISendEvent) -> None: + if self.audit_level == AuditLevel.REQUEST_RESPONSE: + await self._capture_response(message, context) + + await send(message) + + original_receive = receive + + async def receive_wrapper() -> ASGIReceiveEvent: + nonlocal original_receive + message = await original_receive() + + if self.audit_level in ( + AuditLevel.REQUEST, + AuditLevel.REQUEST_RESPONSE, + ): + await self._capture_request(message, context) + + return message + + await self.app(scope, receive_wrapper, send_wrapper) + + @asynccontextmanager + async def _audit_context( + self, request: Request + ) -> AsyncGenerator[AuditContext, None]: + """ + async context manager that ensures that an audit log entry is recorded after the request is processed. + """ + context = AuditContext() + try: + yield context + finally: + await self._log_audit_entry(request, context) + + async def _get_authenticated_user(self, request: Request) -> UserModel: + + auth_header = request.headers.get("Authorization") + assert auth_header + user = get_current_user(request, None, get_http_authorization_cred(auth_header)) + + return user + + def _should_skip_auditing(self, request: Request) -> bool: + if ( + request.method not in {"POST", "PUT", "PATCH", "DELETE"} + or AUDIT_LOG_LEVEL == "NONE" + or not request.headers.get("authorization") + ): + return True + # match either /api//...(for the endpoint /api/chat case) or /api/v1//... + pattern = re.compile( + r"^/api(?:/v1)?/(" + "|".join(self.excluded_paths) + r")\b" + ) + if pattern.match(request.url.path): + return True + + return False + + async def _capture_request(self, message: ASGIReceiveEvent, context: AuditContext): + if message["type"] == "http.request": + body = message.get("body", b"") + context.add_request_chunk(body) + + async def _capture_response(self, message: ASGISendEvent, context: AuditContext): + if message["type"] == "http.response.start": + context.metadata["response_status_code"] = message["status"] + + elif message["type"] == "http.response.body": + body = message.get("body", b"") + context.add_response_chunk(body) + + async def _log_audit_entry(self, request: Request, context: AuditContext): + try: + user = await self._get_authenticated_user(request) + + entry = AuditLogEntry( + id=str(uuid.uuid4()), + user=user.model_dump(include={"id", "name", "email", "role"}), + audit_level=self.audit_level.value, + verb=request.method, + request_uri=str(request.url), + response_status_code=context.metadata.get("response_status_code", None), + source_ip=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + request_object=context.request_body.decode("utf-8", errors="replace"), + response_object=context.response_body.decode("utf-8", errors="replace"), + ) + + self.audit_logger.write(entry) + except Exception as e: + logger.error(f"Failed to log audit entry: {str(e)}") diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index e478284a69b..118ac049e2f 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -8,20 +8,30 @@ import os -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta +import pytz +from pytz import UTC from typing import Optional, Union, List, Dict from open_webui.models.users import Users from open_webui.constants import ERROR_MESSAGES -from open_webui.env import WEBUI_SECRET_KEY, TRUSTED_SIGNATURE_KEY, STATIC_DIR - -from fastapi import Depends, HTTPException, Request, Response, status +from open_webui.env import ( + WEBUI_SECRET_KEY, + TRUSTED_SIGNATURE_KEY, + STATIC_DIR, + SRC_LOG_LEVELS, +) + +from fastapi import BackgroundTasks, Depends, HTTPException, Request, Response, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from passlib.context import CryptContext + logging.getLogger("passlib").setLevel(logging.ERROR) +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["OAUTH"]) SESSION_SECRET = WEBUI_SECRET_KEY ALGORITHM = "HS256" @@ -50,7 +60,7 @@ def verify_signature(payload: str, signature: str) -> bool: def override_static(path: str, content: str): # Ensure path is safe if "/" in path or ".." in path: - print(f"Invalid path: {path}") + log.error(f"Invalid path: {path}") return file_path = os.path.join(STATIC_DIR, path) @@ -64,7 +74,7 @@ def get_license_data(app, key): if key: try: res = requests.post( - "https://api.openwebui.com/api/v1/license", + "https://api.openwebui.com/api/v1/license/", json={"key": key, "version": "1"}, timeout=5, ) @@ -75,18 +85,19 @@ def get_license_data(app, key): if k == "resources": for p, c in v.items(): globals().get("override_static", lambda a, b: None)(p, c) - elif k == "user_count": + elif k == "count": setattr(app.state, "USER_COUNT", v) - elif k == "webui_name": + elif k == "name": setattr(app.state, "WEBUI_NAME", v) - + elif k == "metadata": + setattr(app.state, "LICENSE_METADATA", v) return True else: - print( + log.error( f"License: retrieval issue: {getattr(res, 'text', 'unknown error')}" ) except Exception as ex: - print(f"License: Uncaught Exception: {ex}") + log.exception(f"License: Uncaught Exception: {ex}") return False @@ -132,16 +143,19 @@ def create_api_key(): return f"sk-{key}" -def get_http_authorization_cred(auth_header: str): +def get_http_authorization_cred(auth_header: Optional[str]): + if not auth_header: + return None try: scheme, credentials = auth_header.split(" ") return HTTPAuthorizationCredentials(scheme=scheme, credentials=credentials) except Exception: - raise ValueError(ERROR_MESSAGES.INVALID_TOKEN) + return None def get_current_user( request: Request, + background_tasks: BackgroundTasks, auth_token: HTTPAuthorizationCredentials = Depends(bearer_security), ): token = None @@ -170,7 +184,12 @@ def get_current_user( ).split(",") ] - if request.url.path not in allowed_paths: + # Check if the request path matches any allowed endpoint. + if not any( + request.url.path == allowed + or request.url.path.startswith(allowed + "/") + for allowed in allowed_paths + ): raise HTTPException( status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED ) @@ -194,7 +213,10 @@ def get_current_user( detail=ERROR_MESSAGES.INVALID_TOKEN, ) else: - Users.update_user_last_active_by_id(user.id) + # Refresh the user's last active timestamp asynchronously + # to prevent blocking the request + if background_tasks: + background_tasks.add_task(Users.update_user_last_active_by_id, user.id) return user else: raise HTTPException( diff --git a/backend/open_webui/utils/chat.py b/backend/open_webui/utils/chat.py index 209fb02dc42..a6a06c522d4 100644 --- a/backend/open_webui/utils/chat.py +++ b/backend/open_webui/utils/chat.py @@ -66,7 +66,7 @@ async def generate_direct_chat_completion( user: Any, models: dict, ): - print("generate_direct_chat_completion") + log.info("generate_direct_chat_completion") metadata = form_data.pop("metadata", {}) @@ -103,7 +103,7 @@ async def message_listener(sid, data): } ) - print("res", res) + log.info(f"res: {res}") if res.get("status", False): # Define a generator to stream responses @@ -149,7 +149,7 @@ async def background(): } ) - if "error" in res: + if "error" in res and res["error"]: raise Exception(res["error"]) return res @@ -285,7 +285,7 @@ async def stream_wrapper(stream): async def chat_completed(request: Request, form_data: dict, user: Any): if not request.app.state.MODELS: - await get_all_models(request) + await get_all_models(request, user=user) if getattr(request.state, "direct", False) and hasattr(request.state, "model"): models = { @@ -328,9 +328,14 @@ async def chat_completed(request: Request, form_data: dict, user: Any): } try: + filter_functions = [ + Functions.get_function_by_id(filter_id) + for filter_id in get_sorted_filter_ids(model) + ] + result, _ = await process_filter_functions( request=request, - filter_ids=get_sorted_filter_ids(model), + filter_functions=filter_functions, filter_type="outlet", form_data=data, extra_params=extra_params, @@ -351,7 +356,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A raise Exception(f"Action not found: {action_id}") if not request.app.state.MODELS: - await get_all_models(request) + await get_all_models(request, user=user) if getattr(request.state, "direct", False) and hasattr(request.state, "model"): models = { @@ -432,7 +437,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A ) ) except Exception as e: - print(e) + log.exception(f"Failed to get user values: {e}") params = {**params, "__user__": __user__} diff --git a/backend/open_webui/utils/code_interpreter.py b/backend/open_webui/utils/code_interpreter.py index 0a74da9c77c..312baff241e 100644 --- a/backend/open_webui/utils/code_interpreter.py +++ b/backend/open_webui/utils/code_interpreter.py @@ -1,148 +1,210 @@ import asyncio import json +import logging import uuid +from typing import Optional + +import aiohttp import websockets -import requests -from urllib.parse import urljoin +from pydantic import BaseModel +from open_webui.env import SRC_LOG_LEVELS -async def execute_code_jupyter( - jupyter_url, code, token=None, password=None, timeout=10 -): +logger = logging.getLogger(__name__) +logger.setLevel(SRC_LOG_LEVELS["MAIN"]) + + +class ResultModel(BaseModel): """ - Executes Python code in a Jupyter kernel. - Supports authentication with a token or password. - :param jupyter_url: Jupyter server URL (e.g., "http://localhost:8888") - :param code: Code to execute - :param token: Jupyter authentication token (optional) - :param password: Jupyter password (optional) - :param timeout: WebSocket timeout in seconds (default: 10s) - :return: Dictionary with stdout, stderr, and result - - Images are prefixed with "base64:image/png," and separated by newlines if multiple. + Execute Code Result Model """ - session = requests.Session() # Maintain cookies - headers = {} # Headers for requests - # Authenticate using password - if password and not token: - try: - login_url = urljoin(jupyter_url, "/login") - response = session.get(login_url) - response.raise_for_status() - xsrf_token = session.cookies.get("_xsrf") - if not xsrf_token: - raise ValueError("Failed to fetch _xsrf token") + stdout: Optional[str] = "" + stderr: Optional[str] = "" + result: Optional[str] = "" - login_data = {"_xsrf": xsrf_token, "password": password} - login_response = session.post( - login_url, data=login_data, cookies=session.cookies - ) - login_response.raise_for_status() - headers["X-XSRFToken"] = xsrf_token - except Exception as e: - return { - "stdout": "", - "stderr": f"Authentication Error: {str(e)}", - "result": "", - } - - # Construct API URLs with authentication token if provided - params = f"?token={token}" if token else "" - kernel_url = urljoin(jupyter_url, f"/api/kernels{params}") - try: - response = session.post(kernel_url, headers=headers, cookies=session.cookies) - response.raise_for_status() - kernel_id = response.json()["id"] +class JupyterCodeExecuter: + """ + Execute code in jupyter notebook + """ - websocket_url = urljoin( - jupyter_url.replace("http", "ws"), - f"/api/kernels/{kernel_id}/channels{params}", - ) + def __init__( + self, + base_url: str, + code: str, + token: str = "", + password: str = "", + timeout: int = 60, + ): + """ + :param base_url: Jupyter server URL (e.g., "http://localhost:8888") + :param code: Code to execute + :param token: Jupyter authentication token (optional) + :param password: Jupyter password (optional) + :param timeout: WebSocket timeout in seconds (default: 60s) + """ + self.base_url = base_url.rstrip("/") + self.code = code + self.token = token + self.password = password + self.timeout = timeout + self.kernel_id = "" + self.session = aiohttp.ClientSession(base_url=self.base_url) + self.params = {} + self.result = ResultModel() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if self.kernel_id: + try: + async with self.session.delete( + f"/api/kernels/{self.kernel_id}", params=self.params + ) as response: + response.raise_for_status() + except Exception as err: + logger.exception("close kernel failed, %s", err) + await self.session.close() + + async def run(self) -> ResultModel: + try: + await self.sign_in() + await self.init_kernel() + await self.execute_code() + except Exception as err: + logger.exception("execute code failed, %s", err) + self.result.stderr = f"Error: {err}" + return self.result + + async def sign_in(self) -> None: + # password authentication + if self.password and not self.token: + async with self.session.get("/login") as response: + response.raise_for_status() + xsrf_token = response.cookies["_xsrf"].value + if not xsrf_token: + raise ValueError("_xsrf token not found") + self.session.cookie_jar.update_cookies(response.cookies) + self.session.headers.update({"X-XSRFToken": xsrf_token}) + async with self.session.post( + "/login", + data={"_xsrf": xsrf_token, "password": self.password}, + allow_redirects=False, + ) as response: + response.raise_for_status() + self.session.cookie_jar.update_cookies(response.cookies) + + # token authentication + if self.token: + self.params.update({"token": self.token}) + + async def init_kernel(self) -> None: + async with self.session.post( + url="/api/kernels", params=self.params + ) as response: + response.raise_for_status() + kernel_data = await response.json() + self.kernel_id = kernel_data["id"] + def init_ws(self) -> (str, dict): + ws_base = self.base_url.replace("http", "ws") + ws_params = "?" + "&".join([f"{key}={val}" for key, val in self.params.items()]) + websocket_url = f"{ws_base}/api/kernels/{self.kernel_id}/channels{ws_params if len(ws_params) > 1 else ''}" ws_headers = {} - if password and not token: - ws_headers["X-XSRFToken"] = session.cookies.get("_xsrf") - cookies = {name: value for name, value in session.cookies.items()} - ws_headers["Cookie"] = "; ".join( - [f"{name}={value}" for name, value in cookies.items()] - ) + if self.password and not self.token: + ws_headers = { + "Cookie": "; ".join( + [ + f"{cookie.key}={cookie.value}" + for cookie in self.session.cookie_jar + ] + ), + **self.session.headers, + } + return websocket_url, ws_headers + async def execute_code(self) -> None: + # initialize ws + websocket_url, ws_headers = self.init_ws() + # execute async with websockets.connect( websocket_url, additional_headers=ws_headers ) as ws: - msg_id = str(uuid.uuid4()) - execute_request = { - "header": { - "msg_id": msg_id, - "msg_type": "execute_request", - "username": "user", - "session": str(uuid.uuid4()), - "date": "", - "version": "5.3", - }, - "parent_header": {}, - "metadata": {}, - "content": { - "code": code, - "silent": False, - "store_history": True, - "user_expressions": {}, - "allow_stdin": False, - "stop_on_error": True, - }, - "channel": "shell", - } - await ws.send(json.dumps(execute_request)) - - stdout, stderr, result = "", "", [] - - while True: - try: - message = await asyncio.wait_for(ws.recv(), timeout) - message_data = json.loads(message) - if message_data.get("parent_header", {}).get("msg_id") == msg_id: - msg_type = message_data.get("msg_type") - - if msg_type == "stream": - if message_data["content"]["name"] == "stdout": - stdout += message_data["content"]["text"] - elif message_data["content"]["name"] == "stderr": - stderr += message_data["content"]["text"] - - elif msg_type in ("execute_result", "display_data"): - data = message_data["content"]["data"] - if "image/png" in data: - result.append( - f"data:image/png;base64,{data['image/png']}" - ) - elif "text/plain" in data: - result.append(data["text/plain"]) - - elif msg_type == "error": - stderr += "\n".join(message_data["content"]["traceback"]) - - elif ( - msg_type == "status" - and message_data["content"]["execution_state"] == "idle" - ): + await self.execute_in_jupyter(ws) + + async def execute_in_jupyter(self, ws) -> None: + # send message + msg_id = uuid.uuid4().hex + await ws.send( + json.dumps( + { + "header": { + "msg_id": msg_id, + "msg_type": "execute_request", + "username": "user", + "session": uuid.uuid4().hex, + "date": "", + "version": "5.3", + }, + "parent_header": {}, + "metadata": {}, + "content": { + "code": self.code, + "silent": False, + "store_history": True, + "user_expressions": {}, + "allow_stdin": False, + "stop_on_error": True, + }, + "channel": "shell", + } + ) + ) + # parse message + stdout, stderr, result = "", "", [] + while True: + try: + # wait for message + message = await asyncio.wait_for(ws.recv(), self.timeout) + message_data = json.loads(message) + # msg id not match, skip + if message_data.get("parent_header", {}).get("msg_id") != msg_id: + continue + # check message type + msg_type = message_data.get("msg_type") + match msg_type: + case "stream": + if message_data["content"]["name"] == "stdout": + stdout += message_data["content"]["text"] + elif message_data["content"]["name"] == "stderr": + stderr += message_data["content"]["text"] + case "execute_result" | "display_data": + data = message_data["content"]["data"] + if "image/png" in data: + result.append(f"data:image/png;base64,{data['image/png']}") + elif "text/plain" in data: + result.append(data["text/plain"]) + case "error": + stderr += "\n".join(message_data["content"]["traceback"]) + case "status": + if message_data["content"]["execution_state"] == "idle": break - except asyncio.TimeoutError: - stderr += "\nExecution timed out." - break + except asyncio.TimeoutError: + stderr += "\nExecution timed out." + break + self.result.stdout = stdout.strip() + self.result.stderr = stderr.strip() + self.result.result = "\n".join(result).strip() if result else "" - except Exception as e: - return {"stdout": "", "stderr": f"Error: {str(e)}", "result": ""} - finally: - if kernel_id: - requests.delete( - f"{kernel_url}/{kernel_id}", headers=headers, cookies=session.cookies - ) - - return { - "stdout": stdout.strip(), - "stderr": stderr.strip(), - "result": "\n".join(result).strip() if result else "", - } +async def execute_code_jupyter( + base_url: str, code: str, token: str = "", password: str = "", timeout: int = 60 +) -> dict: + async with JupyterCodeExecuter( + base_url, code, token, password, timeout + ) as executor: + result = await executor.run() + return result.model_dump() diff --git a/backend/open_webui/utils/filter.py b/backend/open_webui/utils/filter.py index de51bd46e57..76c9db9eb1a 100644 --- a/backend/open_webui/utils/filter.py +++ b/backend/open_webui/utils/filter.py @@ -1,14 +1,20 @@ import inspect +import logging + from open_webui.utils.plugin import load_function_module_by_id from open_webui.models.functions import Functions +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MAIN"]) -def get_sorted_filter_ids(model): +def get_sorted_filter_ids(model: dict): def get_priority(function_id): function = Functions.get_function_by_id(function_id) - if function is not None and hasattr(function, "valves"): - # TODO: Fix FunctionModel to include vavles - return (function.valves if function.valves else {}).get("priority", 0) + if function is not None: + valves = Functions.get_function_valves_by_id(function_id) + return valves.get("priority", 0) if valves else 0 return 0 filter_ids = [function.id for function in Functions.get_global_filter_functions()] @@ -27,12 +33,13 @@ def get_priority(function_id): async def process_filter_functions( - request, filter_ids, filter_type, form_data, extra_params + request, filter_functions, filter_type, form_data, extra_params ): skip_files = None - for filter_id in filter_ids: - filter = Functions.get_function_by_id(filter_id) + for function in filter_functions: + filter = function + filter_id = function.id if not filter: continue @@ -42,6 +49,11 @@ async def process_filter_functions( function_module, _, _ = load_function_module_by_id(filter_id) request.app.state.FUNCTIONS[filter_id] = function_module + # Prepare handler function + handler = getattr(function_module, filter_type, None) + if not handler: + continue + # Check if the function has a file_handler variable if filter_type == "inlet" and hasattr(function_module, "file_handler"): skip_files = function_module.file_handler @@ -53,15 +65,15 @@ async def process_filter_functions( **(valves if valves else {}) ) - # Prepare handler function - handler = getattr(function_module, filter_type, None) - if not handler: - continue - try: # Prepare parameters sig = inspect.signature(handler) - params = {"body": form_data} | { + + params = {"body": form_data} + if filter_type == "stream": + params = {"event": form_data} + + params = params | { k: v for k, v in { **extra_params, @@ -80,7 +92,7 @@ async def process_filter_functions( ) ) except Exception as e: - print(e) + log.exception(f"Failed to get user values: {e}") # Execute handler if inspect.iscoroutinefunction(handler): @@ -89,11 +101,12 @@ async def process_filter_functions( form_data = handler(**params) except Exception as e: - print(f"Error in {filter_type} handler {filter_id}: {e}") + log.debug(f"Error in {filter_type} handler {filter_id}: {e}") raise e # Handle file cleanup for inlet if skip_files and "files" in form_data.get("metadata", {}): + del form_data["files"] del form_data["metadata"]["files"] return form_data, {} diff --git a/backend/open_webui/utils/logger.py b/backend/open_webui/utils/logger.py new file mode 100644 index 00000000000..2557610060f --- /dev/null +++ b/backend/open_webui/utils/logger.py @@ -0,0 +1,140 @@ +import json +import logging +import sys +from typing import TYPE_CHECKING + +from loguru import logger + +from open_webui.env import ( + AUDIT_LOG_FILE_ROTATION_SIZE, + AUDIT_LOG_LEVEL, + AUDIT_LOGS_FILE_PATH, + GLOBAL_LOG_LEVEL, +) + + +if TYPE_CHECKING: + from loguru import Record + + +def stdout_format(record: "Record") -> str: + """ + Generates a formatted string for log records that are output to the console. This format includes a timestamp, log level, source location (module, function, and line), the log message, and any extra data (serialized as JSON). + + Parameters: + record (Record): A Loguru record that contains logging details including time, level, name, function, line, message, and any extra context. + Returns: + str: A formatted log string intended for stdout. + """ + record["extra"]["extra_json"] = json.dumps(record["extra"]) + return ( + "{time:YYYY-MM-DD HH:mm:ss.SSS} | " + "{level: <8} | " + "{name}:{function}:{line} - " + "{message} - {extra[extra_json]}" + "\n{exception}" + ) + + +class InterceptHandler(logging.Handler): + """ + Intercepts log records from Python's standard logging module + and redirects them to Loguru's logger. + """ + + def emit(self, record): + """ + Called by the standard logging module for each log event. + It transforms the standard `LogRecord` into a format compatible with Loguru + and passes it to Loguru's logger. + """ + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + + frame, depth = sys._getframe(6), 6 + while frame and frame.f_code.co_filename == logging.__file__: + frame = frame.f_back + depth += 1 + + logger.opt(depth=depth, exception=record.exc_info).log( + level, record.getMessage() + ) + + +def file_format(record: "Record"): + """ + Formats audit log records into a structured JSON string for file output. + + Parameters: + record (Record): A Loguru record containing extra audit data. + Returns: + str: A JSON-formatted string representing the audit data. + """ + + audit_data = { + "id": record["extra"].get("id", ""), + "timestamp": int(record["time"].timestamp()), + "user": record["extra"].get("user", dict()), + "audit_level": record["extra"].get("audit_level", ""), + "verb": record["extra"].get("verb", ""), + "request_uri": record["extra"].get("request_uri", ""), + "response_status_code": record["extra"].get("response_status_code", 0), + "source_ip": record["extra"].get("source_ip", ""), + "user_agent": record["extra"].get("user_agent", ""), + "request_object": record["extra"].get("request_object", b""), + "response_object": record["extra"].get("response_object", b""), + "extra": record["extra"].get("extra", {}), + } + + record["extra"]["file_extra"] = json.dumps(audit_data, default=str) + return "{extra[file_extra]}\n" + + +def start_logger(): + """ + Initializes and configures Loguru's logger with distinct handlers: + + A console (stdout) handler for general log messages (excluding those marked as auditable). + An optional file handler for audit logs if audit logging is enabled. + Additionally, this function reconfigures Python’s standard logging to route through Loguru and adjusts logging levels for Uvicorn. + + Parameters: + enable_audit_logging (bool): Determines whether audit-specific log entries should be recorded to file. + """ + logger.remove() + + logger.add( + sys.stdout, + level=GLOBAL_LOG_LEVEL, + format=stdout_format, + filter=lambda record: "auditable" not in record["extra"], + ) + + if AUDIT_LOG_LEVEL != "NONE": + try: + logger.add( + AUDIT_LOGS_FILE_PATH, + level="INFO", + rotation=AUDIT_LOG_FILE_ROTATION_SIZE, + compression="zip", + format=file_format, + filter=lambda record: record["extra"].get("auditable") is True, + ) + except Exception as e: + logger.error(f"Failed to initialize audit log file handler: {str(e)}") + + logging.basicConfig( + handlers=[InterceptHandler()], level=GLOBAL_LOG_LEVEL, force=True + ) + for uvicorn_logger_name in ["uvicorn", "uvicorn.error"]: + uvicorn_logger = logging.getLogger(uvicorn_logger_name) + uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL) + uvicorn_logger.handlers = [] + for uvicorn_logger_name in ["uvicorn.access"]: + uvicorn_logger = logging.getLogger(uvicorn_logger_name) + uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL) + uvicorn_logger.handlers = [InterceptHandler()] + + logger.info(f"GLOBAL_LOG_LEVEL: {GLOBAL_LOG_LEVEL}") diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 7ec764fc010..badae990651 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -18,9 +18,7 @@ from concurrent.futures import ThreadPoolExecutor -from fastapi import Request -from fastapi import BackgroundTasks - +from fastapi import Request, HTTPException from starlette.responses import Response, StreamingResponse @@ -68,6 +66,7 @@ get_last_user_message, get_last_assistant_message, prepend_to_first_user_message_content, + convert_logit_bias_input_to_json, ) from open_webui.utils.tools import get_tools from open_webui.utils.plugin import load_function_module_by_id @@ -99,7 +98,7 @@ async def chat_completion_tools_handler( - request: Request, body: dict, user: UserModel, models, tools + request: Request, body: dict, extra_params: dict, user: UserModel, models, tools ) -> tuple[dict, dict]: async def get_content_from_response(response) -> Optional[str]: content = None @@ -134,6 +133,9 @@ def get_tools_function_calling_payload(messages, task_model_id, content): "metadata": {"task": str(TASKS.FUNCTION_CALLING)}, } + event_caller = extra_params["__event_call__"] + metadata = extra_params["__metadata__"] + task_model_id = get_task_model_id( body["model"], request.app.state.config.TASK_MODEL, @@ -155,7 +157,6 @@ def get_tools_function_calling_payload(messages, task_model_id, content): tools_function_calling_prompt = tools_function_calling_generation_template( template, tools_specs ) - log.info(f"{tools_function_calling_prompt=}") payload = get_tools_function_calling_payload( body["messages"], task_model_id, tools_function_calling_prompt ) @@ -188,34 +189,73 @@ async def tool_call_handler(tool_call): tool_function_params = tool_call.get("parameters", {}) try: - required_params = ( - tools[tool_function_name] - .get("spec", {}) - .get("parameters", {}) - .get("required", []) + tool = tools[tool_function_name] + + spec = tool.get("spec", {}) + allowed_params = ( + spec.get("parameters", {}).get("properties", {}).keys() ) - tool_function = tools[tool_function_name]["callable"] tool_function_params = { k: v for k, v in tool_function_params.items() - if k in required_params + if k in allowed_params } - tool_output = await tool_function(**tool_function_params) + + if tool.get("direct", False): + tool_result = await event_caller( + { + "type": "execute:tool", + "data": { + "id": str(uuid4()), + "name": tool_function_name, + "params": tool_function_params, + "server": tool.get("server", {}), + "session_id": metadata.get("session_id", None), + }, + } + ) + else: + tool_function = tool["callable"] + tool_result = await tool_function(**tool_function_params) except Exception as e: - tool_output = str(e) + tool_result = str(e) + + tool_result_files = [] + if isinstance(tool_result, list): + for item in tool_result: + # check if string + if isinstance(item, str) and item.startswith("data:"): + tool_result_files.append(item) + tool_result.remove(item) + + if isinstance(tool_result, dict) or isinstance(tool_result, list): + tool_result = json.dumps(tool_result, indent=2) + + if isinstance(tool_result, str): + tool = tools[tool_function_name] + tool_id = tool.get("tool_id", "") + if tool.get("metadata", {}).get("citation", False) or tool.get( + "direct", False + ): - if isinstance(tool_output, str): - if tools[tool_function_name]["citation"]: sources.append( { "source": { - "name": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}" + "name": ( + f"TOOL:" + f"{tool_id}/{tool_function_name}" + if tool_id + else f"{tool_function_name}" + ), }, - "document": [tool_output], + "document": [tool_result, *tool_result_files], "metadata": [ { - "source": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}" + "source": ( + f"TOOL:" + f"{tool_id}/{tool_function_name}" + if tool_id + else f"{tool_function_name}" + ) } ], } @@ -224,16 +264,24 @@ async def tool_call_handler(tool_call): sources.append( { "source": {}, - "document": [tool_output], + "document": [tool_result, *tool_result_files], "metadata": [ { - "source": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}" + "source": ( + f"TOOL:" + f"{tool_id}/{tool_function_name}" + if tool_id + else f"{tool_function_name}" + ) } ], } ) - if tools[tool_function_name]["file_handler"]: + if ( + tools[tool_function_name] + .get("metadata", {}) + .get("file_handler", False) + ): skip_files = True # check if "tool_calls" in result @@ -244,10 +292,10 @@ async def tool_call_handler(tool_call): await tool_call_handler(result) except Exception as e: - log.exception(f"Error: {e}") + log.debug(f"Error: {e}") content = None except Exception as e: - log.exception(f"Error: {e}") + log.debug(f"Error: {e}") content = None log.debug(f"tool_contexts: {sources}") @@ -351,24 +399,45 @@ async def chat_web_search_handler( all_results.append(results) files = form_data.get("files", []) - if request.app.state.config.RAG_WEB_SEARCH_FULL_CONTEXT: - files.append( - { - "docs": results.get("docs", []), - "name": searchQuery, - "type": "web_search_docs", - "urls": results["filenames"], - } - ) - else: - files.append( - { - "collection_name": results["collection_name"], - "name": searchQuery, - "type": "web_search_results", - "urls": results["filenames"], - } - ) + if results.get("collection_names"): + for col_idx, collection_name in enumerate( + results.get("collection_names") + ): + files.append( + { + "collection_name": collection_name, + "name": searchQuery, + "type": "web_search", + "urls": [results["filenames"][col_idx]], + } + ) + elif results.get("docs"): + # Invoked when bypass embedding and retrieval is set to True + docs = results["docs"] + + if len(docs) == len(results["filenames"]): + # the number of docs and filenames (urls) should be the same + for doc_idx, doc in enumerate(docs): + files.append( + { + "docs": [doc], + "name": searchQuery, + "type": "web_search", + "urls": [results["filenames"][doc_idx]], + } + ) + else: + # edge case when the number of docs and filenames (urls) are not the same + # this should not happen, but if it does, we will just append the docs + files.append( + { + "docs": results.get("docs", []), + "name": searchQuery, + "type": "web_search", + "urls": results["filenames"], + } + ) + form_data["files"] = files except Exception as e: log.exception(e) @@ -518,6 +587,7 @@ async def chat_completion_files_handler( sources = [] if files := body.get("metadata", {}).get("files", None): + queries = [] try: queries_response = await generate_queries( request, @@ -543,8 +613,8 @@ async def chat_completion_files_handler( queries_response = {"queries": [queries_response]} queries = queries_response.get("queries", []) - except Exception as e: - queries = [] + except: + pass if len(queries) == 0: queries = [get_last_user_message(body["messages"])] @@ -556,13 +626,15 @@ async def chat_completion_files_handler( sources = await loop.run_in_executor( executor, lambda: get_sources_from_files( + request=request, files=files, queries=queries, - embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION( - query, user=user + embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION( + query, prefix=prefix, user=user ), k=request.app.state.config.TOP_K, reranking_function=request.app.state.rf, + k_reranker=request.app.state.config.TOP_K_RERANKER, r=request.app.state.config.RELEVANCE_THRESHOLD, hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, full_context=request.app.state.config.RAG_FULL_CONTEXT, @@ -587,31 +659,39 @@ def apply_params_to_form_data(form_data, model): if "keep_alive" in params: form_data["keep_alive"] = params["keep_alive"] else: - if "seed" in params: + if "seed" in params and params["seed"] is not None: form_data["seed"] = params["seed"] - if "stop" in params: + if "stop" in params and params["stop"] is not None: form_data["stop"] = params["stop"] - if "temperature" in params: + if "temperature" in params and params["temperature"] is not None: form_data["temperature"] = params["temperature"] - if "max_tokens" in params: + if "max_tokens" in params and params["max_tokens"] is not None: form_data["max_tokens"] = params["max_tokens"] - if "top_p" in params: + if "top_p" in params and params["top_p"] is not None: form_data["top_p"] = params["top_p"] - if "frequency_penalty" in params: + if "frequency_penalty" in params and params["frequency_penalty"] is not None: form_data["frequency_penalty"] = params["frequency_penalty"] - if "reasoning_effort" in params: + if "reasoning_effort" in params and params["reasoning_effort"] is not None: form_data["reasoning_effort"] = params["reasoning_effort"] + if "logit_bias" in params and params["logit_bias"] is not None: + try: + form_data["logit_bias"] = json.loads( + convert_logit_bias_input_to_json(params["logit_bias"]) + ) + except Exception as e: + print(f"Error parsing logit_bias: {e}") + return form_data -async def process_chat_payload(request, form_data, metadata, user, model): +async def process_chat_payload(request, form_data, user, metadata, model): form_data = apply_params_to_form_data(form_data, model) log.debug(f"form_data: {form_data}") @@ -704,9 +784,14 @@ async def process_chat_payload(request, form_data, metadata, user, model): raise e try: + filter_functions = [ + Functions.get_function_by_id(filter_id) + for filter_id in get_sorted_filter_ids(model) + ] + form_data, flags = await process_filter_functions( request=request, - filter_ids=get_sorted_filter_ids(model), + filter_functions=filter_functions, filter_type="inlet", form_data=form_data, extra_params=extra_params, @@ -738,6 +823,7 @@ async def process_chat_payload(request, form_data, metadata, user, model): tool_ids = form_data.pop("tool_ids", None) files = form_data.pop("files", None) + # Remove files duplicates if files: files = list({json.dumps(f, sort_keys=True): f for f in files}.values()) @@ -749,12 +835,18 @@ async def process_chat_payload(request, form_data, metadata, user, model): } form_data["metadata"] = metadata + # Server side tools tool_ids = metadata.get("tool_ids", None) + # Client side tools + tool_servers = metadata.get("tool_servers", None) + log.debug(f"{tool_ids=}") + log.debug(f"{tool_servers=}") + + tools_dict = {} if tool_ids: - # If tool_ids field is present, then get the tools - tools = get_tools( + tools_dict = get_tools( request, tool_ids, user, @@ -765,20 +857,31 @@ async def process_chat_payload(request, form_data, metadata, user, model): "__files__": metadata.get("files", []), }, ) - log.info(f"{tools=}") + if tool_servers: + for tool_server in tool_servers: + tool_specs = tool_server.pop("specs", []) + + for tool in tool_specs: + tools_dict[tool["name"]] = { + "spec": tool, + "direct": True, + "server": tool_server, + } + + if tools_dict: if metadata.get("function_calling") == "native": # If the function calling is native, then call the tools function calling handler - metadata["tools"] = tools + metadata["tools"] = tools_dict form_data["tools"] = [ {"type": "function", "function": tool.get("spec", {})} - for tool in tools.values() + for tool in tools_dict.values() ] else: # If the function calling is not native, then call the tools function calling handler try: form_data, flags = await chat_completion_tools_handler( - request, form_data, user, models, tools + request, form_data, extra_params, user, models, tools_dict ) sources.extend(flags.get("sources", [])) @@ -795,11 +898,11 @@ async def process_chat_payload(request, form_data, metadata, user, model): if len(sources) > 0: context_string = "" for source_idx, source in enumerate(sources): - source_id = source.get("source", {}).get("name", "") - if "document" in source: for doc_idx, doc_context in enumerate(source["document"]): - context_string += f"{source_idx}{doc_context}\n" + context_string += ( + f'{doc_context}\n' + ) context_string = context_string.strip() prompt = get_last_user_message(form_data["messages"]) @@ -854,7 +957,7 @@ async def process_chat_payload(request, form_data, metadata, user, model): async def process_chat_response( - request, response, form_data, user, events, metadata, tasks + request, response, form_data, user, metadata, model, events, tasks ): async def background_tasks_handler(): message_map = Chats.get_messages_by_chat_id(metadata["chat_id"]) @@ -976,6 +1079,16 @@ async def background_tasks_handler(): # Non-streaming response if not isinstance(response, StreamingResponse): if event_emitter: + if "error" in response: + error = response["error"].get("detail", response["error"]) + Chats.upsert_message_to_chat_by_id_and_message_id( + metadata["chat_id"], + metadata["message_id"], + { + "error": {"content": error}, + }, + ) + if "selected_model_id" in response: Chats.upsert_message_to_chat_by_id_and_message_id( metadata["chat_id"], @@ -985,7 +1098,8 @@ async def background_tasks_handler(): }, ) - if response.get("choices", [])[0].get("message", {}).get("content"): + choices = response.get("choices", []) + if choices and choices[0].get("message", {}).get("content"): content = response["choices"][0]["message"]["content"] if content: @@ -1048,6 +1162,24 @@ async def background_tasks_handler(): ): return response + extra_params = { + "__event_emitter__": event_emitter, + "__event_call__": event_caller, + "__user__": { + "id": user.id, + "email": user.email, + "name": user.name, + "role": user.role, + }, + "__metadata__": metadata, + "__request__": request, + "__model__": model, + } + filter_functions = [ + Functions.get_function_by_id(filter_id) + for filter_id in get_sorted_filter_ids(model) + ] + # Streaming response if event_emitter and event_caller: task_id = str(uuid4()) # Create a unique task ID. @@ -1086,36 +1218,53 @@ def serialize_content_blocks(content_blocks, raw=False): elif block["type"] == "tool_calls": attributes = block.get("attributes", {}) - block_content = block.get("content", []) + tool_calls = block.get("content", []) results = block.get("results", []) if results: - result_display_content = "" + tool_calls_display_content = "" + for tool_call in tool_calls: - for result in results: - tool_call_id = result.get("tool_call_id", "") - tool_name = "" + tool_call_id = tool_call.get("id", "") + tool_name = tool_call.get("function", {}).get( + "name", "" + ) + tool_arguments = tool_call.get("function", {}).get( + "arguments", "" + ) - for tool_call in block_content: - if tool_call.get("id", "") == tool_call_id: - tool_name = tool_call.get("function", {}).get( - "name", "" - ) + tool_result = None + tool_result_files = None + for result in results: + if tool_call_id == result.get("tool_call_id", ""): + tool_result = result.get("content", None) + tool_result_files = result.get("files", None) break - result_display_content = f"{result_display_content}\n> {tool_name}: {result.get('content', '')}" + if tool_result: + tool_calls_display_content = f'{tool_calls_display_content}\n
\nTool Executed\n
\n' + else: + tool_calls_display_content = f'{tool_calls_display_content}\n
\nExecuting...\n
' if not raw: - content = f'{content}\n
\nTool Executed\n{result_display_content}\n
\n' + content = f"{content}\n{tool_calls_display_content}\n\n" else: tool_calls_display_content = "" - for tool_call in block_content: - tool_calls_display_content = f"{tool_calls_display_content}\n> Executing {tool_call.get('function', {}).get('name', '')}" + for tool_call in tool_calls: + tool_call_id = tool_call.get("id", "") + tool_name = tool_call.get("function", {}).get( + "name", "" + ) + tool_arguments = tool_call.get("function", {}).get( + "arguments", "" + ) + + tool_calls_display_content = f'{tool_calls_display_content}\n
\nExecuting...\n
' if not raw: - content = f'{content}\n
\nTool Executing...\n{tool_calls_display_content}\n
\n' + content = f"{content}\n{tool_calls_display_content}\n\n" elif block["type"] == "reasoning": reasoning_display_content = "\n".join( @@ -1127,12 +1276,12 @@ def serialize_content_blocks(content_blocks, raw=False): if reasoning_duration is not None: if raw: - content = f'{content}\n<{block["tag"]}>{block["content"]}\n' + content = f'{content}\n<{block["start_tag"]}>{block["content"]}<{block["end_tag"]}>\n' else: content = f'{content}\n
\nThought for {reasoning_duration} seconds\n{reasoning_display_content}\n
\n' else: if raw: - content = f'{content}\n<{block["tag"]}>{block["content"]}\n' + content = f'{content}\n<{block["start_tag"]}>{block["content"]}<{block["end_tag"]}>\n' else: content = f'{content}\n
\nThinking…\n{reasoning_display_content}\n
\n' @@ -1228,9 +1377,9 @@ def extract_attributes(tag_content): return attributes if content_blocks[-1]["type"] == "text": - for tag in tags: + for start_tag, end_tag in tags: # Match start tag e.g., or - start_tag_pattern = rf"<{tag}(\s.*?)?>" + start_tag_pattern = rf"<{re.escape(start_tag)}(\s.*?)?>" match = re.search(start_tag_pattern, content) if match: attr_content = ( @@ -1263,7 +1412,8 @@ def extract_attributes(tag_content): content_blocks.append( { "type": content_type, - "tag": tag, + "start_tag": start_tag, + "end_tag": end_tag, "attributes": attributes, "content": "", "started_at": time.time(), @@ -1275,9 +1425,10 @@ def extract_attributes(tag_content): break elif content_blocks[-1]["type"] == content_type: - tag = content_blocks[-1]["tag"] + start_tag = content_blocks[-1]["start_tag"] + end_tag = content_blocks[-1]["end_tag"] # Match end tag e.g., - end_tag_pattern = rf"" + end_tag_pattern = rf"<{re.escape(end_tag)}>" # Check if the content has the end tag if re.search(end_tag_pattern, content): @@ -1285,7 +1436,7 @@ def extract_attributes(tag_content): block_content = content_blocks[-1]["content"] # Strip start and end tags from the content - start_tag_pattern = rf"<{tag}(.*?)>" + start_tag_pattern = rf"<{re.escape(start_tag)}(.*?)>" block_content = re.sub( start_tag_pattern, "", block_content ).strip() @@ -1350,7 +1501,7 @@ def extract_attributes(tag_content): # Clean processed content content = re.sub( - rf"<{tag}(.*?)>(.|\n)*?", + rf"<{re.escape(start_tag)}(.*?)>(.|\n)*?<{re.escape(end_tag)}>", "", content, flags=re.DOTALL, @@ -1388,19 +1539,24 @@ def extract_attributes(tag_content): # We might want to disable this by default DETECT_REASONING = True + DETECT_SOLUTION = True DETECT_CODE_INTERPRETER = metadata.get("features", {}).get( "code_interpreter", False ) reasoning_tags = [ - "think", - "thinking", - "reason", - "reasoning", - "thought", - "Thought", + ("think", "/think"), + ("thinking", "/thinking"), + ("reason", "/reason"), + ("reasoning", "/reasoning"), + ("thought", "/thought"), + ("Thought", "/Thought"), + ("|begin_of_thought|", "|end_of_thought|"), ] - code_interpreter_tags = ["code_interpreter"] + + code_interpreter_tags = [("code_interpreter", "/code_interpreter")] + + solution_tags = [("|begin_of_solution|", "|end_of_solution|")] try: for event in events: @@ -1444,119 +1600,216 @@ async def stream_body_handler(response): try: data = json.loads(data) - if "selected_model_id" in data: - model_id = data["selected_model_id"] - Chats.upsert_message_to_chat_by_id_and_message_id( - metadata["chat_id"], - metadata["message_id"], - { - "selectedModelId": model_id, - }, - ) - else: - choices = data.get("choices", []) - if not choices: - continue - - delta = choices[0].get("delta", {}) - delta_tool_calls = delta.get("tool_calls", None) - - if delta_tool_calls: - for delta_tool_call in delta_tool_calls: - tool_call_index = delta_tool_call.get("index") - - if tool_call_index is not None: - if ( - len(response_tool_calls) - <= tool_call_index - ): - response_tool_calls.append( - delta_tool_call - ) - else: - delta_name = delta_tool_call.get( - "function", {} - ).get("name") - delta_arguments = delta_tool_call.get( - "function", {} - ).get("arguments") - - if delta_name: - response_tool_calls[ - tool_call_index - ]["function"]["name"] += delta_name - - if delta_arguments: - response_tool_calls[ - tool_call_index - ]["function"][ - "arguments" - ] += delta_arguments - - value = delta.get("content") - - if value: - content = f"{content}{value}" - - if not content_blocks: - content_blocks.append( - { - "type": "text", + data, _ = await process_filter_functions( + request=request, + filter_functions=filter_functions, + filter_type="stream", + form_data=data, + extra_params=extra_params, + ) + + if data: + if "selected_model_id" in data: + model_id = data["selected_model_id"] + Chats.upsert_message_to_chat_by_id_and_message_id( + metadata["chat_id"], + metadata["message_id"], + { + "selectedModelId": model_id, + }, + ) + else: + choices = data.get("choices", []) + if not choices: + error = data.get("error", {}) + if error: + await event_emitter( + { + "type": "chat:completion", + "data": { + "error": error, + }, + } + ) + usage = data.get("usage", {}) + if usage: + await event_emitter( + { + "type": "chat:completion", + "data": { + "usage": usage, + }, + } + ) + continue + + delta = choices[0].get("delta", {}) + delta_tool_calls = delta.get("tool_calls", None) + + if delta_tool_calls: + for delta_tool_call in delta_tool_calls: + tool_call_index = delta_tool_call.get( + "index" + ) + + if tool_call_index is not None: + if ( + len(response_tool_calls) + <= tool_call_index + ): + response_tool_calls.append( + delta_tool_call + ) + else: + delta_name = delta_tool_call.get( + "function", {} + ).get("name") + delta_arguments = ( + delta_tool_call.get( + "function", {} + ).get("arguments") + ) + + if delta_name: + response_tool_calls[ + tool_call_index + ]["function"][ + "name" + ] += delta_name + + if delta_arguments: + response_tool_calls[ + tool_call_index + ]["function"][ + "arguments" + ] += delta_arguments + + value = delta.get("content") + + reasoning_content = delta.get( + "reasoning_content" + ) or delta.get("reasoning") + if reasoning_content: + if ( + not content_blocks + or content_blocks[-1]["type"] != "reasoning" + ): + reasoning_block = { + "type": "reasoning", + "start_tag": "think", + "end_tag": "/think", + "attributes": { + "type": "reasoning_content" + }, "content": "", + "started_at": time.time(), } - ) + content_blocks.append(reasoning_block) + else: + reasoning_block = content_blocks[-1] - content_blocks[-1]["content"] = ( - content_blocks[-1]["content"] + value - ) + reasoning_block["content"] += reasoning_content - if DETECT_REASONING: - content, content_blocks, _ = ( - tag_content_handler( - "reasoning", - reasoning_tags, - content, - content_blocks, + data = { + "content": serialize_content_blocks( + content_blocks + ) + } + + if value: + if ( + content_blocks + and content_blocks[-1]["type"] + == "reasoning" + and content_blocks[-1] + .get("attributes", {}) + .get("type") + == "reasoning_content" + ): + reasoning_block = content_blocks[-1] + reasoning_block["ended_at"] = time.time() + reasoning_block["duration"] = int( + reasoning_block["ended_at"] + - reasoning_block["started_at"] + ) + + content_blocks.append( + { + "type": "text", + "content": "", + } ) - ) - if DETECT_CODE_INTERPRETER: - content, content_blocks, end = ( - tag_content_handler( - "code_interpreter", - code_interpreter_tags, - content, - content_blocks, + content = f"{content}{value}" + if not content_blocks: + content_blocks.append( + { + "type": "text", + "content": "", + } ) + + content_blocks[-1]["content"] = ( + content_blocks[-1]["content"] + value ) - if end: - break + if DETECT_REASONING: + content, content_blocks, _ = ( + tag_content_handler( + "reasoning", + reasoning_tags, + content, + content_blocks, + ) + ) + + if DETECT_CODE_INTERPRETER: + content, content_blocks, end = ( + tag_content_handler( + "code_interpreter", + code_interpreter_tags, + content, + content_blocks, + ) + ) + + if end: + break + + if DETECT_SOLUTION: + content, content_blocks, _ = ( + tag_content_handler( + "solution", + solution_tags, + content, + content_blocks, + ) + ) - if ENABLE_REALTIME_CHAT_SAVE: - # Save message in the database - Chats.upsert_message_to_chat_by_id_and_message_id( - metadata["chat_id"], - metadata["message_id"], - { + if ENABLE_REALTIME_CHAT_SAVE: + # Save message in the database + Chats.upsert_message_to_chat_by_id_and_message_id( + metadata["chat_id"], + metadata["message_id"], + { + "content": serialize_content_blocks( + content_blocks + ), + }, + ) + else: + data = { "content": serialize_content_blocks( content_blocks ), - }, - ) - else: - data = { - "content": serialize_content_blocks( - content_blocks - ), - } + } - await event_emitter( - { - "type": "chat:completion", - "data": data, - } - ) + await event_emitter( + { + "type": "chat:completion", + "data": data, + } + ) except Exception as e: done = "data: [DONE]" in line if done: @@ -1591,7 +1844,7 @@ async def stream_body_handler(response): await stream_body_handler(response) - MAX_TOOL_CALL_RETRIES = 5 + MAX_TOOL_CALL_RETRIES = 10 tool_call_retries = 0 while len(tool_calls) > 0 and tool_call_retries < MAX_TOOL_CALL_RETRIES: @@ -1630,6 +1883,15 @@ async def stream_body_handler(response): ) except Exception as e: log.debug(e) + # Fallback to JSON parsing + try: + tool_function_params = json.loads( + tool_call.get("function", {}).get("arguments", "{}") + ) + except Exception as e: + log.debug( + f"Error parsing tool call arguments: {tool_call.get('function', {}).get('arguments', '{}')}" + ) tool_result = None @@ -1638,25 +1900,65 @@ async def stream_body_handler(response): spec = tool.get("spec", {}) try: - required_params = spec.get("parameters", {}).get( - "required", [] + allowed_params = ( + spec.get("parameters", {}) + .get("properties", {}) + .keys() ) - tool_function = tool["callable"] + tool_function_params = { k: v for k, v in tool_function_params.items() - if k in required_params + if k in allowed_params } - tool_result = await tool_function( - **tool_function_params - ) + + if tool.get("direct", False): + tool_result = await event_caller( + { + "type": "execute:tool", + "data": { + "id": str(uuid4()), + "name": tool_name, + "params": tool_function_params, + "server": tool.get("server", {}), + "session_id": metadata.get( + "session_id", None + ), + }, + } + ) + + else: + tool_function = tool["callable"] + tool_result = await tool_function( + **tool_function_params + ) + except Exception as e: tool_result = str(e) + tool_result_files = [] + if isinstance(tool_result, list): + for item in tool_result: + # check if string + if isinstance(item, str) and item.startswith("data:"): + tool_result_files.append(item) + tool_result.remove(item) + + if isinstance(tool_result, dict) or isinstance( + tool_result, list + ): + tool_result = json.dumps(tool_result, indent=2) + results.append( { "tool_call_id": tool_call_id, "content": tool_result, + **( + {"files": tool_result_files} + if tool_result_files + else {} + ), } ) @@ -1855,8 +2157,6 @@ async def stream_body_handler(response): } ) - print(content_blocks, serialize_content_blocks(content_blocks)) - try: res = await generate_chat_completion( request, @@ -1926,7 +2226,7 @@ async def stream_body_handler(response): await background_tasks_handler() except asyncio.CancelledError: - print("Task was cancelled!") + log.warning("Task was cancelled!") await event_emitter({"type": "task-cancelled"}) if not ENABLE_REALTIME_CHAT_SAVE: @@ -1947,17 +2247,34 @@ async def stream_body_handler(response): return {"status": True, "task_id": task_id} else: - # Fallback to the original response async def stream_wrapper(original_generator, events): def wrap_item(item): return f"data: {item}\n\n" for event in events: - yield wrap_item(json.dumps(event)) + event, _ = await process_filter_functions( + request=request, + filter_functions=filter_functions, + filter_type="stream", + form_data=event, + extra_params=extra_params, + ) + + if event: + yield wrap_item(json.dumps(event)) async for data in original_generator: - yield data + data, _ = await process_filter_functions( + request=request, + filter_functions=filter_functions, + filter_type="stream", + form_data=data, + extra_params=extra_params, + ) + + if data: + yield data return StreamingResponse( stream_wrapper(response.body_iterator, events), diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index f79b6268437..98938dfea0b 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -2,12 +2,18 @@ import re import time import uuid +import logging from datetime import timedelta from pathlib import Path from typing import Callable, Optional +import json import collections.abc +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MAIN"]) def deep_update(d, u): @@ -412,7 +418,7 @@ def parse_ollama_modelfile(model_text): elif param_type is bool: value = value.lower() == "true" except Exception as e: - print(e) + log.exception(f"Failed to parse parameter {param}: {e}") continue data["params"][param] = value @@ -445,3 +451,15 @@ def parse_ollama_modelfile(model_text): data["params"]["messages"] = messages return data + + +def convert_logit_bias_input_to_json(user_input): + logit_bias_pairs = user_input.split(",") + logit_bias_json = {} + for pair in logit_bias_pairs: + token, bias = pair.split(":") + token = str(token.strip()) + bias = int(bias.strip()) + bias = 100 if bias > 100 else -100 if bias < -100 else bias + logit_bias_json[token] = bias + return json.dumps(logit_bias_json) diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index a2c0eadcaf2..b631c2ae33e 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -22,6 +22,7 @@ ) from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL +from open_webui.models.users import UserModel logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) @@ -29,17 +30,17 @@ log.setLevel(SRC_LOG_LEVELS["MAIN"]) -async def get_all_base_models(request: Request): +async def get_all_base_models(request: Request, user: UserModel = None): function_models = [] openai_models = [] ollama_models = [] if request.app.state.config.ENABLE_OPENAI_API: - openai_models = await openai.get_all_models(request) + openai_models = await openai.get_all_models(request, user=user) openai_models = openai_models["data"] if request.app.state.config.ENABLE_OLLAMA_API: - ollama_models = await ollama.get_all_models(request) + ollama_models = await ollama.get_all_models(request, user=user) ollama_models = [ { "id": model["model"], @@ -48,6 +49,7 @@ async def get_all_base_models(request: Request): "created": int(time.time()), "owned_by": "ollama", "ollama": model, + "tags": model.get("tags", []), } for model in ollama_models["models"] ] @@ -58,8 +60,8 @@ async def get_all_base_models(request: Request): return models -async def get_all_models(request): - models = await get_all_base_models(request) +async def get_all_models(request, user: UserModel = None): + models = await get_all_base_models(request, user=user) # If there are no models, return an empty list if len(models) == 0: diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 13835e78471..9ebe0e6dcb5 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -94,7 +94,7 @@ def get_user_role(self, user, user_data): oauth_claim = auth_manager_config.OAUTH_ROLES_CLAIM oauth_allowed_roles = auth_manager_config.OAUTH_ALLOWED_ROLES oauth_admin_roles = auth_manager_config.OAUTH_ADMIN_ROLES - oauth_roles = None + oauth_roles = [] # Default/fallback role if no matching roles are found role = auth_manager_config.DEFAULT_USER_ROLE @@ -104,7 +104,7 @@ def get_user_role(self, user, user_data): nested_claims = oauth_claim.split(".") for nested_claim in nested_claims: claim_data = claim_data.get(nested_claim, {}) - oauth_roles = claim_data if isinstance(claim_data, list) else None + oauth_roles = claim_data if isinstance(claim_data, list) else [] log.debug(f"Oauth Roles claim: {oauth_claim}") log.debug(f"User roles from oauth: {oauth_roles}") @@ -140,13 +140,14 @@ def update_user_groups(self, user, user_data, default_permissions): log.debug("Running OAUTH Group management") oauth_claim = auth_manager_config.OAUTH_GROUPS_CLAIM + user_oauth_groups = [] # Nested claim search for groups claim if oauth_claim: claim_data = user_data nested_claims = oauth_claim.split(".") for nested_claim in nested_claims: claim_data = claim_data.get(nested_claim, {}) - user_oauth_groups = claim_data if isinstance(claim_data, list) else None + user_oauth_groups = claim_data if isinstance(claim_data, list) else [] user_current_groups: list[GroupModel] = Groups.get_groups_by_member_id(user.id) all_available_groups: list[GroupModel] = Groups.get_groups() @@ -160,7 +161,7 @@ def update_user_groups(self, user, user_data, default_permissions): # Remove groups that user is no longer a part of for group_model in user_current_groups: - if group_model.name not in user_oauth_groups: + if user_oauth_groups and group_model.name not in user_oauth_groups: # Remove group from user log.debug( f"Removing user from group {group_model.name} as it is no longer in their oauth groups" @@ -186,8 +187,10 @@ def update_user_groups(self, user, user_data, default_permissions): # Add user to new groups for group_model in all_available_groups: - if group_model.name in user_oauth_groups and not any( - gm.name == group_model.name for gm in user_current_groups + if ( + user_oauth_groups + and group_model.name in user_oauth_groups + and not any(gm.name == group_model.name for gm in user_current_groups) ): # Add user to group log.debug( @@ -234,7 +237,7 @@ async def handle_callback(self, request, provider, response): log.warning(f"OAuth callback error: {e}") raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) user_data: UserInfo = token.get("userinfo") - if not user_data or "email" not in user_data: + if not user_data or auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data: user_data: UserInfo = await client.userinfo(token=token) if not user_data: log.warning(f"OAuth callback failed, user data is missing: {token}") @@ -315,15 +318,6 @@ async def handle_callback(self, request, provider, response): if not user: user_count = Users.get_num_users() - if ( - request.app.state.USER_COUNT - and user_count >= request.app.state.USER_COUNT - ): - raise HTTPException( - 403, - detail=ERROR_MESSAGES.ACCESS_PROHIBITED, - ) - # If the user does not exist, check if signups are enabled if auth_manager_config.ENABLE_OAUTH_SIGNUP: # Check if an existing user with the same email already exists @@ -332,40 +326,45 @@ async def handle_callback(self, request, provider, response): raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN) picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM - picture_url = user_data.get( - picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "") - ) - if picture_url: - # Download the profile image into a base64 string - try: - access_token = token.get("access_token") - get_kwargs = {} - if access_token: - get_kwargs["headers"] = { - "Authorization": f"Bearer {access_token}", - } - async with aiohttp.ClientSession() as session: - async with session.get(picture_url, **get_kwargs) as resp: - if resp.ok: - picture = await resp.read() - base64_encoded_picture = base64.b64encode( - picture - ).decode("utf-8") - guessed_mime_type = mimetypes.guess_type( - picture_url - )[0] - if guessed_mime_type is None: - # assume JPG, browsers are tolerant enough of image formats - guessed_mime_type = "image/jpeg" - picture_url = f"data:{guessed_mime_type};base64,{base64_encoded_picture}" - else: - picture_url = "/user.png" - except Exception as e: - log.error( - f"Error downloading profile image '{picture_url}': {e}" - ) + if picture_claim: + picture_url = user_data.get( + picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "") + ) + if picture_url: + # Download the profile image into a base64 string + try: + access_token = token.get("access_token") + get_kwargs = {} + if access_token: + get_kwargs["headers"] = { + "Authorization": f"Bearer {access_token}", + } + async with aiohttp.ClientSession() as session: + async with session.get( + picture_url, **get_kwargs + ) as resp: + if resp.ok: + picture = await resp.read() + base64_encoded_picture = base64.b64encode( + picture + ).decode("utf-8") + guessed_mime_type = mimetypes.guess_type( + picture_url + )[0] + if guessed_mime_type is None: + # assume JPG, browsers are tolerant enough of image formats + guessed_mime_type = "image/jpeg" + picture_url = f"data:{guessed_mime_type};base64,{base64_encoded_picture}" + else: + picture_url = "/user.png" + except Exception as e: + log.error( + f"Error downloading profile image '{picture_url}': {e}" + ) + picture_url = "/user.png" + if not picture_url: picture_url = "/user.png" - if not picture_url: + else: picture_url = "/user.png" username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM diff --git a/backend/open_webui/utils/payload.py b/backend/open_webui/utils/payload.py index 51e8d50ccb1..5f8aafb785e 100644 --- a/backend/open_webui/utils/payload.py +++ b/backend/open_webui/utils/payload.py @@ -62,6 +62,8 @@ def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict: "reasoning_effort": str, "seed": lambda x: x, "stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x], + "logit_bias": lambda x: x, + "response_format": dict, } return apply_model_params_to_body(params, form_data, mappings) @@ -109,6 +111,15 @@ def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict: "num_thread": int, } + # Extract keep_alive from options if it exists + if "options" in form_data and "keep_alive" in form_data["options"]: + form_data["keep_alive"] = form_data["options"]["keep_alive"] + del form_data["options"]["keep_alive"] + + if "options" in form_data and "format" in form_data["options"]: + form_data["format"] = form_data["options"]["format"] + del form_data["options"]["format"] + return apply_model_params_to_body(params, form_data, mappings) @@ -124,7 +135,7 @@ def convert_messages_openai_to_ollama(messages: list[dict]) -> list[dict]: tool_call_id = message.get("tool_call_id", None) # Check if the content is a string (just a simple message) - if isinstance(content, str): + if isinstance(content, str) and not tool_calls: # If the content is a string, it's pure text new_message["content"] = content @@ -230,7 +241,27 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict: "system" ] # To prevent Ollama warning of invalid option provided + # Extract keep_alive from options if it exists + if "keep_alive" in ollama_options: + ollama_payload["keep_alive"] = ollama_options["keep_alive"] + del ollama_options["keep_alive"] + + # If there is the "stop" parameter in the openai_payload, remap it to the ollama_payload.options + if "stop" in openai_payload: + ollama_options = ollama_payload.get("options", {}) + ollama_options["stop"] = openai_payload.get("stop") + ollama_payload["options"] = ollama_options + if "metadata" in openai_payload: ollama_payload["metadata"] = openai_payload["metadata"] + if "response_format" in openai_payload: + response_format = openai_payload["response_format"] + format_type = response_format.get("type", None) + + schema = response_format.get(format_type, None) + if schema: + format = schema.get("schema", None) + ollama_payload["format"] = format + return ollama_payload diff --git a/backend/open_webui/utils/pdf_generator.py b/backend/open_webui/utils/pdf_generator.py index 8b04dd81bc0..c137b49da00 100644 --- a/backend/open_webui/utils/pdf_generator.py +++ b/backend/open_webui/utils/pdf_generator.py @@ -110,7 +110,7 @@ def generate_chat_pdf(self) -> bytes: # When running using `pip install -e .` the static directory is in the site packages. # This path only works if `open-webui serve` is run from the root of this project. if not FONTS_DIR.exists(): - FONTS_DIR = Path("./backend/static/fonts") + FONTS_DIR = Path(".") / "backend" / "static" / "fonts" pdf.add_font("NotoSans", "", f"{FONTS_DIR}/NotoSans-Regular.ttf") pdf.add_font("NotoSans", "b", f"{FONTS_DIR}/NotoSans-Bold.ttf") diff --git a/backend/open_webui/utils/plugin.py b/backend/open_webui/utils/plugin.py index d6e24d6b938..f0746da7792 100644 --- a/backend/open_webui/utils/plugin.py +++ b/backend/open_webui/utils/plugin.py @@ -7,7 +7,7 @@ import tempfile import logging -from open_webui.env import SRC_LOG_LEVELS +from open_webui.env import SRC_LOG_LEVELS, PIP_OPTIONS, PIP_PACKAGE_INDEX_OPTIONS from open_webui.models.functions import Functions from open_webui.models.tools import Tools @@ -45,7 +45,7 @@ def extract_frontmatter(content): frontmatter[key.strip()] = value.strip() except Exception as e: - print(f"An error occurred: {e}") + log.exception(f"Failed to extract frontmatter: {e}") return {} return frontmatter @@ -68,23 +68,23 @@ def replace_imports(content): return content -def load_tools_module_by_id(toolkit_id, content=None): +def load_tools_module_by_id(tool_id, content=None): if content is None: - tool = Tools.get_tool_by_id(toolkit_id) + tool = Tools.get_tool_by_id(tool_id) if not tool: - raise Exception(f"Toolkit not found: {toolkit_id}") + raise Exception(f"Toolkit not found: {tool_id}") content = tool.content content = replace_imports(content) - Tools.update_tool_by_id(toolkit_id, {"content": content}) + Tools.update_tool_by_id(tool_id, {"content": content}) else: frontmatter = extract_frontmatter(content) # Install required packages found within the frontmatter install_frontmatter_requirements(frontmatter.get("requirements", "")) - module_name = f"tool_{toolkit_id}" + module_name = f"tool_{tool_id}" module = types.ModuleType(module_name) sys.modules[module_name] = module @@ -108,7 +108,7 @@ def load_tools_module_by_id(toolkit_id, content=None): else: raise Exception("No Tools class found in the module") except Exception as e: - log.error(f"Error loading module: {toolkit_id}: {e}") + log.error(f"Error loading module: {tool_id}: {e}") del sys.modules[module_name] # Clean up raise e finally: @@ -165,15 +165,19 @@ def load_function_module_by_id(function_id, content=None): os.unlink(temp_file.name) -def install_frontmatter_requirements(requirements): +def install_frontmatter_requirements(requirements: str): if requirements: try: req_list = [req.strip() for req in requirements.split(",")] - for req in req_list: - log.info(f"Installing requirement: {req}") - subprocess.check_call([sys.executable, "-m", "pip", "install", req]) + log.info(f"Installing requirements: {' '.join(req_list)}") + subprocess.check_call( + [sys.executable, "-m", "pip", "install"] + + PIP_OPTIONS + + req_list + + PIP_PACKAGE_INDEX_OPTIONS + ) except Exception as e: - log.error(f"Error installing package: {req}") + log.error(f"Error installing packages: {' '.join(req_list)}") raise e else: diff --git a/backend/open_webui/utils/redis.py b/backend/open_webui/utils/redis.py new file mode 100644 index 00000000000..baccb16ad60 --- /dev/null +++ b/backend/open_webui/utils/redis.py @@ -0,0 +1,109 @@ +import socketio +import redis +from redis import asyncio as aioredis +from urllib.parse import urlparse + + +def parse_redis_sentinel_url(redis_url): + parsed_url = urlparse(redis_url) + if parsed_url.scheme != "redis": + raise ValueError("Invalid Redis URL scheme. Must be 'redis'.") + + return { + "username": parsed_url.username or None, + "password": parsed_url.password or None, + "service": parsed_url.hostname or "mymaster", + "port": parsed_url.port or 6379, + "db": int(parsed_url.path.lstrip("/") or 0), + } + + +def get_redis_connection(redis_url, redis_sentinels, decode_responses=True): + if redis_sentinels: + redis_config = parse_redis_sentinel_url(redis_url) + sentinel = redis.sentinel.Sentinel( + redis_sentinels, + port=redis_config["port"], + db=redis_config["db"], + username=redis_config["username"], + password=redis_config["password"], + decode_responses=decode_responses, + ) + + # Get a master connection from Sentinel + return sentinel.master_for(redis_config["service"]) + else: + # Standard Redis connection + return redis.Redis.from_url(redis_url, decode_responses=decode_responses) + + +def get_sentinels_from_env(sentinel_hosts_env, sentinel_port_env): + if sentinel_hosts_env: + sentinel_hosts = sentinel_hosts_env.split(",") + sentinel_port = int(sentinel_port_env) + return [(host, sentinel_port) for host in sentinel_hosts] + return [] + + +class AsyncRedisSentinelManager(socketio.AsyncRedisManager): + def __init__( + self, + sentinel_hosts, + sentinel_port=26379, + redis_port=6379, + service="mymaster", + db=0, + username=None, + password=None, + channel="socketio", + write_only=False, + logger=None, + redis_options=None, + ): + """ + Initialize the Redis Sentinel Manager. + This implementation mostly replicates the __init__ of AsyncRedisManager and + overrides _redis_connect() with a version that uses Redis Sentinel + + :param sentinel_hosts: List of Sentinel hosts + :param sentinel_port: Sentinel Port + :param redis_port: Redis Port (currently unsupported by aioredis!) + :param service: Master service name in Sentinel + :param db: Redis database to use + :param username: Redis username (if any) (currently unsupported by aioredis!) + :param password: Redis password (if any) + :param channel: The channel name on which the server sends and receives + notifications. Must be the same in all the servers. + :param write_only: If set to ``True``, only initialize to emit events. The + default of ``False`` initializes the class for emitting + and receiving. + :param redis_options: additional keyword arguments to be passed to + ``aioredis.from_url()``. + """ + self._sentinels = [(host, sentinel_port) for host in sentinel_hosts] + self._redis_port = redis_port + self._service = service + self._db = db + self._username = username + self._password = password + self._channel = channel + self.redis_options = redis_options or {} + + # connect and call grandparent constructor + self._redis_connect() + super(socketio.AsyncRedisManager, self).__init__( + channel=channel, write_only=write_only, logger=logger + ) + + def _redis_connect(self): + """Establish connections to Redis through Sentinel.""" + sentinel = aioredis.sentinel.Sentinel( + self._sentinels, + port=self._redis_port, + db=self._db, + password=self._password, + **self.redis_options, + ) + + self.redis = sentinel.master_for(self._service) + self.pubsub = self.redis.pubsub(ignore_subscribe_messages=True) diff --git a/backend/open_webui/utils/response.py b/backend/open_webui/utils/response.py index bc47e1e1361..8c3f1a58ebc 100644 --- a/backend/open_webui/utils/response.py +++ b/backend/open_webui/utils/response.py @@ -104,7 +104,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response) data = json.loads(data) model = data.get("model", "ollama") - message_content = data.get("message", {}).get("content", "") + message_content = data.get("message", {}).get("content", None) tool_calls = data.get("message", {}).get("tool_calls", None) openai_tool_calls = None @@ -118,7 +118,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response) usage = convert_ollama_usage_to_openai(data) data = openai_chat_chunk_message_template( - model, message_content if not done else None, openai_tool_calls, usage + model, message_content, openai_tool_calls, usage ) line = f"data: {json.dumps(data)}\n\n" diff --git a/backend/open_webui/utils/task.py b/backend/open_webui/utils/task.py index 5663ce2acbe..3a8b4b0a422 100644 --- a/backend/open_webui/utils/task.py +++ b/backend/open_webui/utils/task.py @@ -104,7 +104,7 @@ def replacement_function(match): def replace_messages_variable( - template: str, messages: Optional[list[str]] = None + template: str, messages: Optional[list[dict]] = None ) -> str: def replacement_function(match): full_match = match.group(0) diff --git a/backend/open_webui/utils/telemetry/__init__.py b/backend/open_webui/utils/telemetry/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/backend/open_webui/utils/telemetry/constants.py b/backend/open_webui/utils/telemetry/constants.py new file mode 100644 index 00000000000..6ef511f934b --- /dev/null +++ b/backend/open_webui/utils/telemetry/constants.py @@ -0,0 +1,26 @@ +from opentelemetry.semconv.trace import SpanAttributes as _SpanAttributes + +# Span Tags +SPAN_DB_TYPE = "mysql" +SPAN_REDIS_TYPE = "redis" +SPAN_DURATION = "duration" +SPAN_SQL_STR = "sql" +SPAN_SQL_EXPLAIN = "explain" +SPAN_ERROR_TYPE = "error" + + +class SpanAttributes(_SpanAttributes): + """ + Span Attributes + """ + + DB_INSTANCE = "db.instance" + DB_TYPE = "db.type" + DB_IP = "db.ip" + DB_PORT = "db.port" + ERROR_KIND = "error.kind" + ERROR_OBJECT = "error.object" + ERROR_MESSAGE = "error.message" + RESULT_CODE = "result.code" + RESULT_MESSAGE = "result.message" + RESULT_ERRORS = "result.errors" diff --git a/backend/open_webui/utils/telemetry/exporters.py b/backend/open_webui/utils/telemetry/exporters.py new file mode 100644 index 00000000000..4bf166e655f --- /dev/null +++ b/backend/open_webui/utils/telemetry/exporters.py @@ -0,0 +1,31 @@ +import threading + +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.trace.export import BatchSpanProcessor + + +class LazyBatchSpanProcessor(BatchSpanProcessor): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.done = True + with self.condition: + self.condition.notify_all() + self.worker_thread.join() + self.done = False + self.worker_thread = None + + def on_end(self, span: ReadableSpan) -> None: + if self.worker_thread is None: + self.worker_thread = threading.Thread( + name=self.__class__.__name__, target=self.worker, daemon=True + ) + self.worker_thread.start() + super().on_end(span) + + def shutdown(self) -> None: + self.done = True + with self.condition: + self.condition.notify_all() + if self.worker_thread: + self.worker_thread.join() + self.span_exporter.shutdown() diff --git a/backend/open_webui/utils/telemetry/instrumentors.py b/backend/open_webui/utils/telemetry/instrumentors.py new file mode 100644 index 00000000000..0ba42efd4b8 --- /dev/null +++ b/backend/open_webui/utils/telemetry/instrumentors.py @@ -0,0 +1,202 @@ +import logging +import traceback +from typing import Collection, Union + +from aiohttp import ( + TraceRequestStartParams, + TraceRequestEndParams, + TraceRequestExceptionParams, +) +from chromadb.telemetry.opentelemetry.fastapi import instrument_fastapi +from fastapi import FastAPI +from opentelemetry.instrumentation.httpx import ( + HTTPXClientInstrumentor, + RequestInfo, + ResponseInfo, +) +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.logging import LoggingInstrumentor +from opentelemetry.instrumentation.redis import RedisInstrumentor +from opentelemetry.instrumentation.requests import RequestsInstrumentor +from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor +from opentelemetry.instrumentation.aiohttp_client import AioHttpClientInstrumentor +from opentelemetry.trace import Span, StatusCode +from redis import Redis +from requests import PreparedRequest, Response +from sqlalchemy import Engine +from fastapi import status + +from open_webui.utils.telemetry.constants import SPAN_REDIS_TYPE, SpanAttributes + +from open_webui.env import SRC_LOG_LEVELS + +logger = logging.getLogger(__name__) +logger.setLevel(SRC_LOG_LEVELS["MAIN"]) + + +def requests_hook(span: Span, request: PreparedRequest): + """ + Http Request Hook + """ + + span.update_name(f"{request.method} {request.url}") + span.set_attributes( + attributes={ + SpanAttributes.HTTP_URL: request.url, + SpanAttributes.HTTP_METHOD: request.method, + } + ) + + +def response_hook(span: Span, request: PreparedRequest, response: Response): + """ + HTTP Response Hook + """ + + span.set_attributes( + attributes={ + SpanAttributes.HTTP_STATUS_CODE: response.status_code, + } + ) + span.set_status(StatusCode.ERROR if response.status_code >= 400 else StatusCode.OK) + + +def redis_request_hook(span: Span, instance: Redis, args, kwargs): + """ + Redis Request Hook + """ + + try: + connection_kwargs: dict = instance.connection_pool.connection_kwargs + host = connection_kwargs.get("host") + port = connection_kwargs.get("port") + db = connection_kwargs.get("db") + span.set_attributes( + { + SpanAttributes.DB_INSTANCE: f"{host}/{db}", + SpanAttributes.DB_NAME: f"{host}/{db}", + SpanAttributes.DB_TYPE: SPAN_REDIS_TYPE, + SpanAttributes.DB_PORT: port, + SpanAttributes.DB_IP: host, + SpanAttributes.DB_STATEMENT: " ".join([str(i) for i in args]), + SpanAttributes.DB_OPERATION: str(args[0]), + } + ) + except Exception: # pylint: disable=W0718 + logger.error(traceback.format_exc()) + + +def httpx_request_hook(span: Span, request: RequestInfo): + """ + HTTPX Request Hook + """ + + span.update_name(f"{request.method.decode()} {str(request.url)}") + span.set_attributes( + attributes={ + SpanAttributes.HTTP_URL: str(request.url), + SpanAttributes.HTTP_METHOD: request.method.decode(), + } + ) + + +def httpx_response_hook(span: Span, request: RequestInfo, response: ResponseInfo): + """ + HTTPX Response Hook + """ + + span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, response.status_code) + span.set_status( + StatusCode.ERROR + if response.status_code >= status.HTTP_400_BAD_REQUEST + else StatusCode.OK + ) + + +async def httpx_async_request_hook(span: Span, request: RequestInfo): + """ + Async Request Hook + """ + + httpx_request_hook(span, request) + + +async def httpx_async_response_hook( + span: Span, request: RequestInfo, response: ResponseInfo +): + """ + Async Response Hook + """ + + httpx_response_hook(span, request, response) + + +def aiohttp_request_hook(span: Span, request: TraceRequestStartParams): + """ + Aiohttp Request Hook + """ + + span.update_name(f"{request.method} {str(request.url)}") + span.set_attributes( + attributes={ + SpanAttributes.HTTP_URL: str(request.url), + SpanAttributes.HTTP_METHOD: request.method, + } + ) + + +def aiohttp_response_hook( + span: Span, response: Union[TraceRequestExceptionParams, TraceRequestEndParams] +): + """ + Aiohttp Response Hook + """ + + if isinstance(response, TraceRequestEndParams): + span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, response.response.status) + span.set_status( + StatusCode.ERROR + if response.response.status >= status.HTTP_400_BAD_REQUEST + else StatusCode.OK + ) + elif isinstance(response, TraceRequestExceptionParams): + span.set_status(StatusCode.ERROR) + span.set_attribute(SpanAttributes.ERROR_MESSAGE, str(response.exception)) + + +class Instrumentor(BaseInstrumentor): + """ + Instrument OT + """ + + def __init__(self, app: FastAPI, db_engine: Engine): + self.app = app + self.db_engine = db_engine + + def instrumentation_dependencies(self) -> Collection[str]: + return [] + + def _instrument(self, **kwargs): + instrument_fastapi(app=self.app) + SQLAlchemyInstrumentor().instrument(engine=self.db_engine) + RedisInstrumentor().instrument(request_hook=redis_request_hook) + RequestsInstrumentor().instrument( + request_hook=requests_hook, response_hook=response_hook + ) + LoggingInstrumentor().instrument() + HTTPXClientInstrumentor().instrument( + request_hook=httpx_request_hook, + response_hook=httpx_response_hook, + async_request_hook=httpx_async_request_hook, + async_response_hook=httpx_async_response_hook, + ) + AioHttpClientInstrumentor().instrument( + request_hook=aiohttp_request_hook, + response_hook=aiohttp_response_hook, + ) + + def _uninstrument(self, **kwargs): + if getattr(self, "instrumentors", None) is None: + return + for instrumentor in self.instrumentors: + instrumentor.uninstrument() diff --git a/backend/open_webui/utils/telemetry/setup.py b/backend/open_webui/utils/telemetry/setup.py new file mode 100644 index 00000000000..eb6a238c8d7 --- /dev/null +++ b/backend/open_webui/utils/telemetry/setup.py @@ -0,0 +1,23 @@ +from fastapi import FastAPI +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.resources import SERVICE_NAME, Resource +from opentelemetry.sdk.trace import TracerProvider +from sqlalchemy import Engine + +from open_webui.utils.telemetry.exporters import LazyBatchSpanProcessor +from open_webui.utils.telemetry.instrumentors import Instrumentor +from open_webui.env import OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT + + +def setup(app: FastAPI, db_engine: Engine): + # set up trace + trace.set_tracer_provider( + TracerProvider( + resource=Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME}) + ) + ) + # otlp export + exporter = OTLPSpanExporter(endpoint=OTEL_EXPORTER_OTLP_ENDPOINT) + trace.get_tracer_provider().add_span_processor(LazyBatchSpanProcessor(exporter)) + Instrumentor(app=app, db_engine=db_engine).instrument() diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index c44c30402d7..734c23e1b04 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -1,7 +1,11 @@ import inspect import logging import re -from typing import Any, Awaitable, Callable, get_type_hints +import inspect +import aiohttp +import asyncio + +from typing import Any, Awaitable, Callable, get_type_hints, Dict, List, Union, Optional from functools import update_wrapper, partial @@ -14,95 +18,162 @@ from open_webui.models.users import UserModel from open_webui.utils.plugin import load_tools_module_by_id +import copy + log = logging.getLogger(__name__) -def apply_extra_params_to_tool_function( +def get_async_tool_function_and_apply_extra_params( function: Callable, extra_params: dict ) -> Callable[..., Awaitable]: sig = inspect.signature(function) extra_params = {k: v for k, v in extra_params.items() if k in sig.parameters} partial_func = partial(function, **extra_params) + if inspect.iscoroutinefunction(function): update_wrapper(partial_func, function) return partial_func + else: + # Make it a coroutine function + async def new_function(*args, **kwargs): + return partial_func(*args, **kwargs) - async def new_function(*args, **kwargs): - return partial_func(*args, **kwargs) - - update_wrapper(new_function, function) - return new_function + update_wrapper(new_function, function) + return new_function -# Mutation on extra_params def get_tools( request: Request, tool_ids: list[str], user: UserModel, extra_params: dict ) -> dict[str, dict]: tools_dict = {} for tool_id in tool_ids: - tools = Tools.get_tool_by_id(tool_id) - if tools is None: - continue - - module = request.app.state.TOOLS.get(tool_id, None) - if module is None: - module, _ = load_tools_module_by_id(tool_id) - request.app.state.TOOLS[tool_id] = module - - extra_params["__id__"] = tool_id - if hasattr(module, "valves") and hasattr(module, "Valves"): - valves = Tools.get_tool_valves_by_id(tool_id) or {} - module.valves = module.Valves(**valves) - - if hasattr(module, "UserValves"): - extra_params["__user__"]["valves"] = module.UserValves( # type: ignore - **Tools.get_user_valves_by_id_and_user_id(tool_id, user.id) - ) - - for spec in tools.specs: - # TODO: Fix hack for OpenAI API - # Some times breaks OpenAI but others don't. Leaving the comment - for val in spec.get("parameters", {}).get("properties", {}).values(): - if val["type"] == "str": - val["type"] = "string" - - # Remove internal parameters - spec["parameters"]["properties"] = { - key: val - for key, val in spec["parameters"]["properties"].items() - if not key.startswith("__") - } - - function_name = spec["name"] - - # convert to function that takes only model params and inserts custom params - original_func = getattr(module, function_name) - callable = apply_extra_params_to_tool_function(original_func, extra_params) - - if callable.__doc__ and callable.__doc__.strip() != "": - s = re.split(":(param|return)", callable.__doc__, 1) - spec["description"] = s[0] + tool = Tools.get_tool_by_id(tool_id) + if tool is None: + if tool_id.startswith("server:"): + server_idx = int(tool_id.split(":")[1]) + tool_server_connection = ( + request.app.state.config.TOOL_SERVER_CONNECTIONS[server_idx] + ) + tool_server_data = request.app.state.TOOL_SERVERS[server_idx] + specs = tool_server_data.get("specs", []) + + for spec in specs: + function_name = spec["name"] + + auth_type = tool_server_connection.get("auth_type", "bearer") + token = None + + if auth_type == "bearer": + token = tool_server_connection.get("key", "") + elif auth_type == "session": + token = request.state.token.credentials + + def make_tool_function(function_name, token, tool_server_data): + async def tool_function(**kwargs): + print( + f"Executing tool function {function_name} with params: {kwargs}" + ) + return await execute_tool_server( + token=token, + url=tool_server_data["url"], + name=function_name, + params=kwargs, + server_data=tool_server_data, + ) + + return tool_function + + tool_function = make_tool_function( + function_name, token, tool_server_data + ) + + callable = get_async_tool_function_and_apply_extra_params( + tool_function, + {}, + ) + + tool_dict = { + "tool_id": tool_id, + "callable": callable, + "spec": spec, + } + + # TODO: if collision, prepend toolkit name + if function_name in tools_dict: + log.warning( + f"Tool {function_name} already exists in another tools!" + ) + log.warning(f"Discarding {tool_id}.{function_name}") + else: + tools_dict[function_name] = tool_dict else: - spec["description"] = function_name - - # TODO: This needs to be a pydantic model - tool_dict = { - "toolkit_id": tool_id, - "callable": callable, - "spec": spec, - "pydantic_model": function_to_pydantic_model(callable), - "file_handler": hasattr(module, "file_handler") and module.file_handler, - "citation": hasattr(module, "citation") and module.citation, - } - - # TODO: if collision, prepend toolkit name - if function_name in tools_dict: - log.warning(f"Tool {function_name} already exists in another tools!") - log.warning(f"Collision between {tools} and {tool_id}.") - log.warning(f"Discarding {tools}.{function_name}") - else: - tools_dict[function_name] = tool_dict + continue + else: + module = request.app.state.TOOLS.get(tool_id, None) + if module is None: + module, _ = load_tools_module_by_id(tool_id) + request.app.state.TOOLS[tool_id] = module + + extra_params["__id__"] = tool_id + + # Set valves for the tool + if hasattr(module, "valves") and hasattr(module, "Valves"): + valves = Tools.get_tool_valves_by_id(tool_id) or {} + module.valves = module.Valves(**valves) + if hasattr(module, "UserValves"): + extra_params["__user__"]["valves"] = module.UserValves( # type: ignore + **Tools.get_user_valves_by_id_and_user_id(tool_id, user.id) + ) + + for spec in tool.specs: + # TODO: Fix hack for OpenAI API + # Some times breaks OpenAI but others don't. Leaving the comment + for val in spec.get("parameters", {}).get("properties", {}).values(): + if val["type"] == "str": + val["type"] = "string" + + # Remove internal reserved parameters (e.g. __id__, __user__) + spec["parameters"]["properties"] = { + key: val + for key, val in spec["parameters"]["properties"].items() + if not key.startswith("__") + } + + # convert to function that takes only model params and inserts custom params + function_name = spec["name"] + tool_function = getattr(module, function_name) + callable = get_async_tool_function_and_apply_extra_params( + tool_function, extra_params + ) + + # TODO: Support Pydantic models as parameters + if callable.__doc__ and callable.__doc__.strip() != "": + s = re.split(":(param|return)", callable.__doc__, 1) + spec["description"] = s[0] + else: + spec["description"] = function_name + + tool_dict = { + "tool_id": tool_id, + "callable": callable, + "spec": spec, + # Misc info + "metadata": { + "file_handler": hasattr(module, "file_handler") + and module.file_handler, + "citation": hasattr(module, "citation") and module.citation, + }, + } + + # TODO: if collision, prepend toolkit name + if function_name in tools_dict: + log.warning( + f"Tool {function_name} already exists in another tools!" + ) + log.warning(f"Discarding {tool_id}.{function_name}") + else: + tools_dict[function_name] = tool_dict return tools_dict @@ -210,6 +281,273 @@ def get_callable_attributes(tool: object) -> list[Callable]: def get_tools_specs(tool_class: object) -> list[dict]: - function_list = get_callable_attributes(tool_class) - models = map(function_to_pydantic_model, function_list) - return [convert_to_openai_function(tool) for tool in models] + function_model_list = map( + function_to_pydantic_model, get_callable_attributes(tool_class) + ) + return [ + convert_to_openai_function(function_model) + for function_model in function_model_list + ] + + +def resolve_schema(schema, components): + """ + Recursively resolves a JSON schema using OpenAPI components. + """ + if not schema: + return {} + + if "$ref" in schema: + ref_path = schema["$ref"] + ref_parts = ref_path.strip("#/").split("/") + resolved = components + for part in ref_parts[1:]: # Skip the initial 'components' + resolved = resolved.get(part, {}) + return resolve_schema(resolved, components) + + resolved_schema = copy.deepcopy(schema) + + # Recursively resolve inner schemas + if "properties" in resolved_schema: + for prop, prop_schema in resolved_schema["properties"].items(): + resolved_schema["properties"][prop] = resolve_schema( + prop_schema, components + ) + + if "items" in resolved_schema: + resolved_schema["items"] = resolve_schema(resolved_schema["items"], components) + + return resolved_schema + + +def convert_openapi_to_tool_payload(openapi_spec): + """ + Converts an OpenAPI specification into a custom tool payload structure. + + Args: + openapi_spec (dict): The OpenAPI specification as a Python dict. + + Returns: + list: A list of tool payloads. + """ + tool_payload = [] + + for path, methods in openapi_spec.get("paths", {}).items(): + for method, operation in methods.items(): + tool = { + "type": "function", + "name": operation.get("operationId"), + "description": operation.get( + "description", operation.get("summary", "No description available.") + ), + "parameters": {"type": "object", "properties": {}, "required": []}, + } + + # Extract path and query parameters + for param in operation.get("parameters", []): + param_name = param["name"] + param_schema = param.get("schema", {}) + tool["parameters"]["properties"][param_name] = { + "type": param_schema.get("type"), + "description": param_schema.get("description", ""), + } + if param.get("required"): + tool["parameters"]["required"].append(param_name) + + # Extract and resolve requestBody if available + request_body = operation.get("requestBody") + if request_body: + content = request_body.get("content", {}) + json_schema = content.get("application/json", {}).get("schema") + if json_schema: + resolved_schema = resolve_schema( + json_schema, openapi_spec.get("components", {}) + ) + + if resolved_schema.get("properties"): + tool["parameters"]["properties"].update( + resolved_schema["properties"] + ) + if "required" in resolved_schema: + tool["parameters"]["required"] = list( + set( + tool["parameters"]["required"] + + resolved_schema["required"] + ) + ) + elif resolved_schema.get("type") == "array": + tool["parameters"] = resolved_schema # special case for array + + tool_payload.append(tool) + + return tool_payload + + +async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]: + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + } + if token: + headers["Authorization"] = f"Bearer {token}" + + error = None + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers) as response: + if response.status != 200: + error_body = await response.json() + raise Exception(error_body) + res = await response.json() + except Exception as err: + print("Error:", err) + if isinstance(err, dict) and "detail" in err: + error = err["detail"] + else: + error = str(err) + raise Exception(error) + + data = { + "openapi": res, + "info": res.get("info", {}), + "specs": convert_openapi_to_tool_payload(res), + } + + print("Fetched data:", data) + return data + + +async def get_tool_servers_data( + servers: List[Dict[str, Any]], session_token: Optional[str] = None +) -> List[Dict[str, Any]]: + # Prepare list of enabled servers along with their original index + server_entries = [] + for idx, server in enumerate(servers): + if server.get("config", {}).get("enable"): + url_path = server.get("path", "openapi.json") + full_url = f"{server.get('url')}/{url_path}" + + auth_type = server.get("auth_type", "bearer") + token = None + + if auth_type == "bearer": + token = server.get("key", "") + elif auth_type == "session": + token = session_token + server_entries.append((idx, server, full_url, token)) + + # Create async tasks to fetch data + tasks = [get_tool_server_data(token, url) for (_, _, url, token) in server_entries] + + # Execute tasks concurrently + responses = await asyncio.gather(*tasks, return_exceptions=True) + + # Build final results with index and server metadata + results = [] + for (idx, server, url, _), response in zip(server_entries, responses): + if isinstance(response, Exception): + print(f"Failed to connect to {url} OpenAPI tool server") + continue + + results.append( + { + "idx": idx, + "url": server.get("url"), + "openapi": response.get("openapi"), + "info": response.get("info"), + "specs": response.get("specs"), + } + ) + + return results + + +async def execute_tool_server( + token: str, url: str, name: str, params: Dict[str, Any], server_data: Dict[str, Any] +) -> Any: + error = None + try: + openapi = server_data.get("openapi", {}) + paths = openapi.get("paths", {}) + + matching_route = None + for route_path, methods in paths.items(): + for http_method, operation in methods.items(): + if isinstance(operation, dict) and operation.get("operationId") == name: + matching_route = (route_path, methods) + break + if matching_route: + break + + if not matching_route: + raise Exception(f"No matching route found for operationId: {name}") + + route_path, methods = matching_route + + method_entry = None + for http_method, operation in methods.items(): + if operation.get("operationId") == name: + method_entry = (http_method.lower(), operation) + break + + if not method_entry: + raise Exception(f"No matching method found for operationId: {name}") + + http_method, operation = method_entry + + path_params = {} + query_params = {} + body_params = {} + + for param in operation.get("parameters", []): + param_name = param["name"] + param_in = param["in"] + if param_name in params: + if param_in == "path": + path_params[param_name] = params[param_name] + elif param_in == "query": + query_params[param_name] = params[param_name] + + final_url = f"{url}{route_path}" + for key, value in path_params.items(): + final_url = final_url.replace(f"{{{key}}}", str(value)) + + if query_params: + query_string = "&".join(f"{k}={v}" for k, v in query_params.items()) + final_url = f"{final_url}?{query_string}" + + if operation.get("requestBody", {}).get("content"): + if params: + body_params = params + else: + raise Exception( + f"Request body expected for operation '{name}' but none found." + ) + + headers = {"Content-Type": "application/json"} + + if token: + headers["Authorization"] = f"Bearer {token}" + + async with aiohttp.ClientSession() as session: + request_method = getattr(session, http_method.lower()) + + if http_method in ["post", "put", "patch"]: + async with request_method( + final_url, json=body_params, headers=headers + ) as response: + if response.status >= 400: + text = await response.text() + raise Exception(f"HTTP error {response.status}: {text}") + return await response.json() + else: + async with request_method(final_url, headers=headers) as response: + if response.status >= 400: + text = await response.text() + raise Exception(f"HTTP error {response.status}: {text}") + return await response.json() + + except Exception as err: + error = str(err) + print("API Request Error:", error) + return {"error": error} diff --git a/backend/requirements.txt b/backend/requirements.txt index 1afe1fffefd..9495b9dced4 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,114 +1,312 @@ -fastapi==0.115.7 -uvicorn[standard]==0.30.6 -pydantic==2.10.6 -python-multipart==0.0.18 - -python-socketio==5.11.3 -python-jose==3.4.0 -passlib[bcrypt]==1.7.4 - -requests==2.32.3 +aiocache==0.12.3 +aiofiles==24.1.0 +aiohappyeyeballs==2.4.6 aiohttp==3.11.11 -async-timeout -aiocache -aiofiles - -sqlalchemy==2.0.32 +aiosignal==1.3.2 alembic==1.14.0 -peewee==3.17.8 -peewee-migrate==1.12.2 -psycopg2-binary==2.9.9 -pgvector==0.3.5 -PyMySQL==1.1.1 +annotated-types==0.7.0 +anthropic==0.46.0 +anyio==4.8.0 +APScheduler==3.10.4 +argon2-cffi==23.1.0 +argon2-cffi-bindings==21.2.0 +asgiref==3.8.1 +async-timeout==5.0.1 +attrs==25.1.0 +Authlib==1.4.1 +av==14.1.0 +azure-core==1.32.0 +azure-identity==1.20.0 +azure-storage-blob==12.24.1 +backoff==2.2.1 bcrypt==4.2.0 - -pymongo -redis +beautifulsoup4==4.13.3 +bidict==0.23.1 +bitarray==3.1.0 +black==24.8.0 +blinker==1.9.0 boto3==1.35.53 - -argon2-cffi==23.1.0 -APScheduler==3.10.4 - -RestrictedPython==8.0 - -# AI libraries -openai -anthropic -google-generativeai==0.7.2 -tiktoken - -langchain==0.3.7 -langchain-community==0.3.7 - -fake-useragent==1.5.1 +botocore==1.35.99 +build==1.2.2.post1 +cachetools==5.5.2 +certifi==2025.1.31 +cffi==1.17.1 +chardet==5.2.0 +charset-normalizer==3.4.1 +chroma-hnswlib==0.7.6 chromadb==0.6.2 -pymilvus==2.5.0 -qdrant-client~=1.12.0 -opensearch-py==2.8.0 -playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml - -transformers -sentence-transformers==3.3.1 +click==8.1.8 colbert-ai==0.2.21 +colorclass==2.2.2 +coloredlogs==15.0.1 +compressed_rtf==1.0.6 +cryptography==44.0.1 +ctranslate2==4.5.0 +dataclasses-json==0.6.7 +datasets==3.3.2 +defusedxml==0.7.1 +Deprecated==1.2.18 +dill==0.3.8 +distro==1.9.0 +dnspython==2.7.0 +docker==7.1.0 +docx2txt==0.8 +duckduckgo_search==7.3.2 +durationpy==0.9 +easygui==0.98.3 +ebcdic==1.1.1 +ecdsa==0.19.0 einops==0.8.0 - - -ftfy==6.2.3 -pypdf==4.3.1 +emoji==2.14.1 +et_xmlfile==2.0.0 +eval_type_backport==0.2.2 +Events==0.5 +extract-msg==0.53.1 +fake-useragent==1.5.1 +fastapi==0.115.7 +faster-whisper==1.1.1 +filelock==3.17.0 +filetype==1.2.0 +firecrawl-py==1.12.0 +Flask==3.1.0 +flatbuffers==25.2.10 +fonttools==4.56.0 fpdf2==2.8.2 -pymdown-extensions==10.14.2 -docx2txt==0.8 -python-pptx==1.0.0 -unstructured==0.16.17 -nltk==3.9.1 +frozenlist==1.5.0 +fsspec==2024.12.0 +ftfy==6.2.3 +git-python==1.0.3 +gitdb==4.0.12 +GitPython==3.1.44 +google-ai-generativelanguage==0.6.6 +google-api-core==2.24.1 +google-api-python-client==2.161.0 +google-auth==2.38.0 +google-auth-httplib2==0.2.0 +google-auth-oauthlib==1.2.1 +google-cloud-core==2.4.2 +google-cloud-storage==2.19.0 +google-crc32c==1.6.0 +google-generativeai==0.7.2 +google-resumable-media==2.7.2 +googleapis-common-protos==1.63.2 +greenlet==3.1.1 +grpcio==1.67.1 +grpcio-status==1.62.3 +grpcio-tools==1.62.3 +gspread==6.1.4 +h11==0.14.0 +h2==4.2.0 +hpack==4.1.0 +html5lib==1.1 +httpcore==1.0.7 +httplib2==0.22.0 +httptools==0.6.4 +httpx==0.28.1 +httpx-sse==0.4.0 +huggingface-hub==0.29.1 +humanfriendly==10.0 +hyperframe==6.1.0 +idna==3.10 +importlib_metadata==8.4.0 +importlib_resources==6.5.2 +iniconfig==2.0.0 +isodate==0.7.2 +itsdangerous==2.2.0 +Jinja2==3.1.5 +jiter==0.8.2 +jmespath==1.0.1 +joblib==1.4.2 +jsonpatch==1.33 +jsonpointer==3.0.0 +kubernetes==32.0.1 +langchain==0.3.7 +langchain-community==0.3.7 +langchain-core==0.3.37 +langchain-text-splitters==0.3.6 +langdetect==1.0.9 +langfuse==2.44.0 +langsmith==0.1.147 +lark==1.1.9 +ldap3==2.9.1 +loguru==0.7.3 +lxml==5.3.1 +Mako==1.3.9 Markdown==3.7 -pypandoc==1.13 -pandas==2.2.3 +markdown-it-py==3.0.0 +MarkupSafe==3.0.2 +marshmallow==3.26.1 +mdurl==0.1.2 +milvus-lite==2.4.11 +mmh3==5.1.0 +monotonic==1.6 +mpmath==1.3.0 +msal==1.31.1 +msal-extensions==1.2.0 +msoffcrypto-tool==5.4.2 +multidict==6.1.0 +multiprocess==0.70.16 +mypy-extensions==1.0.0 +nest-asyncio==1.6.0 +networkx==3.4.2 +ninja==1.11.1.3 +nltk==3.9.1 +numpy==1.26.4 +nvidia-cublas-cu12==12.4.5.8 +nvidia-cuda-cupti-cu12==12.4.127 +nvidia-cuda-nvrtc-cu12==12.4.127 +nvidia-cuda-runtime-cu12==12.4.127 +nvidia-cudnn-cu12==9.1.0.70 +nvidia-cufft-cu12==11.2.1.3 +nvidia-curand-cu12==10.3.5.147 +nvidia-cusolver-cu12==11.6.1.9 +nvidia-cusparse-cu12==12.3.1.170 +nvidia-cusparselt-cu12==0.6.2 +nvidia-nccl-cu12==2.21.5 +nvidia-nvjitlink-cu12==12.4.127 +nvidia-nvtx-cu12==12.4.127 +oauthlib==3.2.2 +olefile==0.47 +oletools==0.60.2 +onnxruntime==1.20.1 +openai==1.64.0 +opencv-python==4.11.0.86 +opencv-python-headless==4.11.0.86 openpyxl==3.1.5 +opensearch-py==2.8.0 +opentelemetry-api==1.27.0 +opentelemetry-exporter-otlp-proto-common==1.27.0 +opentelemetry-exporter-otlp-proto-grpc==1.27.0 +opentelemetry-instrumentation==0.48b0 +opentelemetry-instrumentation-asgi==0.48b0 +opentelemetry-instrumentation-fastapi==0.48b0 +opentelemetry-proto==1.27.0 +opentelemetry-sdk==1.27.0 +opentelemetry-semantic-conventions==0.48b0 +opentelemetry-util-http==0.48b0 +orjson==3.10.15 +overrides==7.7.0 +packaging==23.2 +pandas==2.2.3 +passlib==1.7.4 +pathspec==0.12.1 +pcodedmp==1.2.6 +peewee==3.17.8 +peewee-migrate==1.12.2 +pgvector==0.3.5 +pillow==11.1.0 +platformdirs==4.3.6 +playwright==1.49.1 +pluggy==1.5.0 +portalocker==2.10.1 +posthog==3.15.1 +primp==0.14.0 +propcache==0.3.0 +proto-plus==1.26.0 +protobuf==4.25.6 +psutil==7.0.0 +psycopg2-binary==2.9.9 +pyarrow==19.0.1 +pyasn1==0.4.8 +pyasn1_modules==0.4.1 +pyclipper==1.3.0.post6 +pycparser==2.22 +pydantic==2.10.6 +pydantic-settings==2.8.0 +pydantic_core==2.27.2 +pydub==0.25.1 +pyee==12.0.0 +Pygments==2.19.1 +PyJWT==2.10.1 +pymdown-extensions==10.14.2 +pymilvus==2.5.0 +pymongo==4.11.1 +PyMySQL==1.1.1 +pypandoc==1.13 +pyparsing==3.2.1 +pypdf==4.3.1 +PyPika==0.48.9 +pyproject_hooks==1.2.0 +pytest==8.3.4 +pytest-docker==3.1.2 +python-dateutil==2.9.0.post0 +python-dotenv==1.0.1 +python-engineio==4.11.2 +python-iso639==2025.2.18 +python-jose==3.4.0 +python-magic==0.4.27 +python-multipart==0.0.18 +python-oxmsg==0.0.2 +python-pptx==1.0.0 +python-socketio==5.11.3 +pytube==15.0.0 +pytz==2025.1 pyxlsb==1.0.10 -xlrd==2.0.1 -validators==0.34.0 -psutil -sentencepiece -soundfile==0.13.1 - -opencv-python-headless==4.11.0.86 -rapidocr-onnxruntime==1.3.24 +PyYAML==6.0.2 +qdrant-client==1.12.2 rank-bm25==0.2.2 - -faster-whisper==1.1.1 - -PyJWT[crypto]==2.10.1 -authlib==1.4.1 - -black==24.8.0 -langfuse==2.44.0 +RapidFuzz==3.12.1 +rapidocr-onnxruntime==1.3.24 +red-black-tree-mod==1.22 +redis==5.2.1 +regex==2024.11.6 +requests==2.32.3 +requests-oauthlib==2.0.0 +requests-toolbelt==1.0.0 +RestrictedPython==8.0 +rich==13.9.4 +rsa==4.9 +RTFDE==0.1.2 +s3transfer==0.10.4 +safetensors==0.5.2 +scikit-learn==1.6.1 +scipy==1.15.2 +sentence-transformers==3.3.1 +sentencepiece==0.2.0 +setuptools==75.8.0 +shapely==2.0.7 +shellingham==1.5.4 +simple-websocket==1.1.0 +six==1.17.0 +smmap==5.0.2 +sniffio==1.3.1 +soundfile==0.13.1 +soupsieve==2.6 +SQLAlchemy==2.0.32 +starlette==0.45.3 +sympy==1.13.1 +tenacity==9.0.0 +threadpoolctl==3.5.0 +tiktoken==0.9.0 +tokenizers==0.21.0 +torch==2.6.0 +tqdm==4.67.1 +transformers==4.49.0 +triton==3.2.0 +typer==0.15.1 +typing-inspect==0.9.0 +typing_extensions==4.12.2 +tzdata==2025.1 +tzlocal==5.3 +ujson==5.10.0 +unstructured==0.16.17 +unstructured-client==0.30.3 +uritemplate==4.1.1 +urllib3==2.3.0 +uvicorn==0.30.6 +uvloop==0.21.0 +validators==0.34.0 +watchfiles==1.0.4 +wcwidth==0.2.13 +webencodings==0.5.1 +websocket-client==1.8.0 +websockets==15.0 +Werkzeug==3.1.3 +wrapt==1.17.2 +wsproto==1.2.0 +xlrd==2.0.1 +XlsxWriter==3.2.2 +xxhash==3.5.0 +yarl==1.18.3 youtube-transcript-api==0.6.3 -pytube==15.0.0 -gspread -extract_msg -pydub -duckduckgo-search~=7.3.2 - -## Google Drive -google-api-python-client -google-auth-httplib2 -google-auth-oauthlib - -## Tests -docker~=7.1.0 -pytest~=8.3.2 -pytest-docker~=3.1.1 - -googleapis-common-protos==1.63.2 -google-cloud-storage==2.19.0 - -azure-identity==1.20.0 -azure-storage-blob==12.24.1 - - -## LDAP -ldap3==2.9.1 - -## Firecrawl -firecrawl-py==1.12.0 +zipp==3.21.0 diff --git a/backend/start_windows.bat b/backend/start_windows.bat index 7049cd1b375..19f6f123c5a 100644 --- a/backend/start_windows.bat +++ b/backend/start_windows.bat @@ -41,4 +41,5 @@ IF "%WEBUI_SECRET_KEY%%WEBUI_JWT_SECRET_KEY%" == " " ( :: Execute uvicorn SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%" -uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' +uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' --ws auto +:: For ssl user uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' --ssl-keyfile "key.pem" --ssl-certfile "cert.pem" --ws auto diff --git a/docker-compose.yaml b/docker-compose.yaml index 5db32b2bf7f..f29cd305a85 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -12,7 +12,6 @@ services: - RAG_WEB_SEARCH_RESULT_COUNT=${RAG_WEB_SEARCH_RESULT_COUNT} - RAG_WEB_SEARCH_CONCURRENT_REQUESTS=${RAG_WEB_SEARCH_CONCURRENT_REQUESTS} - SEARXNG_QUERY_URL=${SEARXNG_QUERY_URL} - ports: - "3000:8080" volumes: @@ -20,34 +19,33 @@ services: restart: always env_file: - .env - # searxng: - # container_name: searxng - # image: searxng/searxng:latest - # environment: - # - SEARXNG_UWSGI_WORKERS=${SEARXNG_UWSGI_WORKERS} - # - SEARXNG_UWSGI_THREADS=${SEARXNG_UWSGI_THREADS} - # - SEARXNG_HOSTNAME=${SEARXNG_HOSTNAME} - # ports: - # - "8080:8080" - # volumes: - # - searxng-data:/etc/searxng:rw - # - ./searx-settings.yml:/usr/local/searxng/searx/settings.yml:ro - # env_file: - # - .env - # restart: always - # cap_drop: - # - ALL - # cap_add: - # - CHOWN - # - SETGID - # - SETUID - # - DAC_OVERRIDE - # logging: - # driver: "json-file" - # options: - # max-size: "1m" - # max-file: "1" + depends_on: + - searxng + networks: + - shared-network + + searxng: + container_name: searxng + image: searxng/searxng + ports: + - "8081:8080" + volumes: + - searxng-config:/etc/searxng:rw + env_file: + - .env + restart: unless-stopped + networks: + - shared-network volumes: open-webui-data: - # searxng-data: + searxng-config: + driver: local + driver_opts: + type: none + o: bind + device: ./searxng + +networks: + shared-network: + external: true diff --git a/package-lock.json b/package-lock.json index c6587077219..360d02a39fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { "name": "open-webui", - "version": "0.5.16", + "version": "0.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.5.16", + "version": "0.6.2", "dependencies": { + "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-python": "^6.1.6", "@codemirror/language-data": "^6.5.1", @@ -36,11 +37,14 @@ "file-saver": "^2.0.5", "fuse.js": "^7.0.0", "highlight.js": "^11.9.0", + "html-entities": "^2.5.3", + "html2canvas-pro": "^1.5.8", "i18next": "^23.10.0", "i18next-browser-languagedetector": "^7.2.0", "i18next-resources-to-backend": "^1.2.0", "idb": "^7.1.1", "js-sha256": "^0.10.1", + "jspdf": "^3.0.0", "katex": "^0.16.21", "kokoro-js": "^1.1.1", "marked": "^9.1.0", @@ -57,7 +61,7 @@ "prosemirror-schema-list": "^1.4.1", "prosemirror-state": "^1.4.3", "prosemirror-view": "^1.34.3", - "pyodide": "^0.27.2", + "pyodide": "^0.27.3", "socket.io-client": "^4.2.0", "sortablejs": "^1.15.2", "svelte-sonner": "^0.3.19", @@ -134,10 +138,32 @@ "node": ">=6.0.0" } }, + "node_modules/@azure/msal-browser": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.5.0.tgz", + "integrity": "sha512-H7mWmu8yI0n0XxhJobrgncXI6IU5h8DKMiWDHL5y+Dc58cdg26GbmaMUehbUkdKAQV2OTiFa4FUa6Fdu/wIxBg==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.2.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.2.0.tgz", + "integrity": "sha512-HiYfGAKthisUYqHG1nImCf/uzcyS31wng3o+CycWLIM9chnYJ9Lk6jZ30Y6YiYYpTQ9+z/FGUpiKKekd3Arc0A==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/@babel/runtime": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.1.tgz", - "integrity": "sha512-+BIznRzyqBf+2wCTxcKE3wDjfGeCoVE61KSHGpkzqrLi8qxqFwBeUFyId2cxkTmm55fzDGnm0+yCxaxygrLUnQ==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -601,371 +627,428 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz", + "integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz", + "integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz", + "integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz", + "integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz", + "integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz", + "integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz", + "integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz", + "integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz", + "integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz", + "integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz", + "integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz", + "integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==", "cpu": [ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz", + "integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==", "cpu": [ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz", + "integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz", + "integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz", + "integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz", + "integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz", + "integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz", + "integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz", + "integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz", + "integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz", + "integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz", + "integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz", + "integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz", + "integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -2267,9 +2350,9 @@ } }, "node_modules/@sveltejs/adapter-static": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.6.tgz", - "integrity": "sha512-MGJcesnJWj7FxDcB/GbrdYD3q24Uk0PIL4QIX149ku+hlJuj//nxUbb0HxUTpjkecWfHjVveSUnUaQWnPRXlpg==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.8.tgz", + "integrity": "sha512-YaDrquRpZwfcXbnlDsSrBQNCChVOT9MGuSg+dMAyfsAa1SmiAhrA5jUYUiIMC59G92kIbY/AaQOWcBdq+lh+zg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2277,24 +2360,22 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.12.1.tgz", - "integrity": "sha512-M3rPijGImeOkI0DBJSwjqz+YFX2DyOf6NzWgHVk3mqpT06dlYCpcv5xh1q4rYEqB58yQlk4QA1Y35PUqnUiFKw==", - "hasInstallScript": true, + "version": "2.20.2", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.20.2.tgz", + "integrity": "sha512-Dv8TOAZC9vyfcAB9TMsvUEJsRbklRTeNfcYBPaeH6KnABJ99i3CvCB2eNx8fiiliIqe+9GIchBg4RodRH5p1BQ==", "license": "MIT", "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^0.6.0", "devalue": "^5.1.0", - "esm-env": "^1.2.1", + "esm-env": "^1.2.2", "import-meta-resolve": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", - "sirv": "^3.0.0", - "tiny-glob": "^0.2.9" + "sirv": "^3.0.0" }, "bin": { "svelte-kit": "svelte-kit.js" @@ -3170,6 +3251,13 @@ "integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==", "dev": true }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -3199,6 +3287,13 @@ "integrity": "sha512-MQ1AnmTLOncwEf9IVU+B2e4Hchrku5N67NkgcAHW0p3sdzPe0FNMANxEm6OJUzPniEQGkeT3OROLlCwZJLWFZA==", "dev": true }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", @@ -3794,6 +3889,18 @@ "node": ">= 4.0.0" } }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, "node_modules/aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", @@ -3829,6 +3936,15 @@ "dev": true, "optional": true }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3933,7 +4049,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/brace-expansion": { "version": "2.0.1", @@ -4033,6 +4150,18 @@ "node": "10.* || >= 12.*" } }, + "node_modules/btoa": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", + "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "btoa": "bin/btoa.js" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -4131,6 +4260,33 @@ "node": ">=6" } }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/canvg/node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", @@ -4204,21 +4360,26 @@ } }, "node_modules/cheerio": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", - "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", + "integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==", "dev": true, + "license": "MIT", "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "htmlparser2": "^8.0.1", - "parse5": "^7.0.0", - "parse5-htmlparser2-tree-adapter": "^7.0.0" + "domutils": "^3.1.0", + "encoding-sniffer": "^0.2.0", + "htmlparser2": "^9.1.0", + "parse5": "^7.1.2", + "parse5-htmlparser2-tree-adapter": "^7.0.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^6.19.5", + "whatwg-mimetype": "^4.0.0" }, "engines": { - "node": ">= 6" + "node": ">=18.17" }, "funding": { "url": "https://github.com/cheeriojs/cheerio?sponsor=1" @@ -4229,6 +4390,7 @@ "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", @@ -4241,6 +4403,16 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cheerio/node_modules/undici": { + "version": "6.21.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.2.tgz", + "integrity": "sha512-uROZWze0R0itiAKVPsYhFov9LxrPMHLMEQFszeI2gCN6bnIIZ8twzBCJcN2LJrBBLfrP0t1FW0g+JmKVl8Vk1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -4599,6 +4771,18 @@ "node": ">= 0.6" } }, + "node_modules/core-js": { + "version": "3.41.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.41.0.tgz", + "integrity": "sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -4643,11 +4827,21 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css-select": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", @@ -4676,6 +4870,7 @@ "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">= 6" }, @@ -5453,6 +5648,7 @@ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", "dev": true, + "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", @@ -5472,13 +5668,15 @@ "type": "github", "url": "https://github.com/sponsors/fb55" } - ] + ], + "license": "BSD-2-Clause" }, "node_modules/domhandler": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.3.0" }, @@ -5495,10 +5693,11 @@ "integrity": "sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==" }, "node_modules/domutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", - "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -5533,6 +5732,20 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" }, + "node_modules/encoding-sniffer": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz", + "integrity": "sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -5640,41 +5853,44 @@ "dev": true }, "node_modules/esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz", + "integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@esbuild/aix-ppc64": "0.25.1", + "@esbuild/android-arm": "0.25.1", + "@esbuild/android-arm64": "0.25.1", + "@esbuild/android-x64": "0.25.1", + "@esbuild/darwin-arm64": "0.25.1", + "@esbuild/darwin-x64": "0.25.1", + "@esbuild/freebsd-arm64": "0.25.1", + "@esbuild/freebsd-x64": "0.25.1", + "@esbuild/linux-arm": "0.25.1", + "@esbuild/linux-arm64": "0.25.1", + "@esbuild/linux-ia32": "0.25.1", + "@esbuild/linux-loong64": "0.25.1", + "@esbuild/linux-mips64el": "0.25.1", + "@esbuild/linux-ppc64": "0.25.1", + "@esbuild/linux-riscv64": "0.25.1", + "@esbuild/linux-s390x": "0.25.1", + "@esbuild/linux-x64": "0.25.1", + "@esbuild/netbsd-arm64": "0.25.1", + "@esbuild/netbsd-x64": "0.25.1", + "@esbuild/openbsd-arm64": "0.25.1", + "@esbuild/openbsd-x64": "0.25.1", + "@esbuild/sunos-x64": "0.25.1", + "@esbuild/win32-arm64": "0.25.1", + "@esbuild/win32-ia32": "0.25.1", + "@esbuild/win32-x64": "0.25.1" } }, "node_modules/escape-string-regexp": { @@ -5880,9 +6096,9 @@ } }, "node_modules/esm-env": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.1.tgz", - "integrity": "sha512-U9JedYYjCnadUlXk7e1Kr+aENQhtUaoaV9+gZm1T8LC/YBAPJx3NSPIAurFOC0U5vrdSevnUJS2/wUVxGwPhng==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", "license": "MIT" }, "node_modules/espree": { @@ -6106,6 +6322,12 @@ "pend": "~1.2.0" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -6524,11 +6746,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globalyzer": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", - "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==" - }, "node_modules/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -6549,11 +6766,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globrex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", - "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==" - }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -6696,15 +6908,58 @@ "node": ">=12.0.0" } }, + "node_modules/html-entities": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.3.tgz", + "integrity": "sha512-D3AfvN7SjhTgBSA8L1BN4FpPzuEd06uy4lHwSoRWr0lndi9BKaNzPLKGOWZ2ocSGguozr08TTb2jhCLHaemruw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/html-escaper": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==" }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/html2canvas-pro": { + "version": "1.5.8", + "resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-1.5.8.tgz", + "integrity": "sha512-bVGAU7IvhBwBlRAmX6QhekX8lsaxmYoF6zIwf/HNlHscjx+KN8jw/U4PQRYqeEVm9+m13hcS1l5ChJB9/e29Lw==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/htmlparser2": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", - "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", "dev": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", @@ -6713,11 +6968,12 @@ "url": "https://github.com/sponsors/fb55" } ], + "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "entities": "^4.4.0" + "domutils": "^3.1.0", + "entities": "^4.5.0" } }, "node_modules/http-signature": { @@ -6774,34 +7030,35 @@ } }, "node_modules/i18next-parser": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/i18next-parser/-/i18next-parser-9.0.1.tgz", - "integrity": "sha512-/Pr93/yEBdwsMKRsk4Zn63K368ALhzh8BRVrM6JNGOHy86ZKpiNJI6m8l1S/4T4Ofy1J4dlwkD7N98M70GP4aA==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/i18next-parser/-/i18next-parser-9.3.0.tgz", + "integrity": "sha512-VaQqk/6nLzTFx1MDiCZFtzZXKKyBV6Dv0cJMFM/hOt4/BWHWRgYafzYfVQRUzotwUwjqeNCprWnutzD/YAGczg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.2", + "@babel/runtime": "^7.25.0", "broccoli-plugin": "^4.0.7", - "cheerio": "^1.0.0-rc.2", - "colors": "1.4.0", - "commander": "~12.1.0", + "cheerio": "^1.0.0", + "colors": "^1.4.0", + "commander": "^12.1.0", "eol": "^0.9.1", - "esbuild": "^0.20.1", - "fs-extra": "^11.1.0", + "esbuild": "^0.25.0", + "fs-extra": "^11.2.0", "gulp-sort": "^2.0.0", - "i18next": "^23.5.1", - "js-yaml": "4.1.0", - "lilconfig": "^3.0.0", - "rsvp": "^4.8.2", + "i18next": "^23.5.1 || ^24.2.0", + "js-yaml": "^4.1.0", + "lilconfig": "^3.1.3", + "rsvp": "^4.8.5", "sort-keys": "^5.0.0", "typescript": "^5.0.4", - "vinyl": "~3.0.0", + "vinyl": "^3.0.0", "vinyl-fs": "^4.0.0" }, "bin": { "i18next": "bin/cli.js" }, "engines": { - "node": ">=18.0.0 || >=20.0.0 || >=22.0.0", + "node": "^18.0.0 || ^20.0.0 || ^22.0.0", "npm": ">=6", "yarn": ">=1" } @@ -7213,6 +7470,34 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jspdf": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.1.tgz", + "integrity": "sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.7", + "atob": "^2.1.2", + "btoa": "^1.2.1", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.2.4", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/jspdf/node_modules/dompurify": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz", + "integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/jsprim": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", @@ -7569,10 +7854,11 @@ } }, "node_modules/lilconfig": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz", - "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "dev": true, + "license": "MIT", "engines": { "node": ">=14" }, @@ -8704,6 +8990,7 @@ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0" }, @@ -8910,24 +9197,39 @@ } }, "node_modules/parse5": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", - "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", + "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", "dev": true, + "license": "MIT", "dependencies": { - "entities": "^4.4.0" + "entities": "^4.5.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" } }, "node_modules/parse5-htmlparser2-tree-adapter": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", - "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", "dependencies": { - "domhandler": "^5.0.2", "parse5": "^7.0.0" }, "funding": { @@ -9029,7 +9331,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", - "dev": true + "devOptional": true }, "node_modules/periscopic": { "version": "3.1.0", @@ -9633,9 +9935,9 @@ } }, "node_modules/pyodide": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.27.2.tgz", - "integrity": "sha512-sfA2kiUuQVRpWI4BYnU3sX5PaTTt/xrcVEmRzRcId8DzZXGGtPgCBC0gCqjUTUYSa8ofPaSjXmzESc86yvvCHg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.27.3.tgz", + "integrity": "sha512-6NwKEbPk0M3Wic2T1TCZijgZH9VE4RkHp1VGljS1sou0NjGdsmY2R/fG5oLmdDkjTRMI1iW7WYaY9pofX8gg1g==", "license": "Apache-2.0", "dependencies": { "ws": "^8.5.0" @@ -9755,6 +10057,16 @@ "rimraf": "bin.js" } }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -9894,6 +10206,16 @@ "integrity": "sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==", "dev": true }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -10785,6 +11107,16 @@ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/std-env": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", @@ -11166,6 +11498,16 @@ "@types/estree": "*" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/symlink-or-copy": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/symlink-or-copy/-/symlink-or-copy-1.3.1.tgz", @@ -11258,6 +11600,15 @@ "streamx": "^2.12.5" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -11289,15 +11640,6 @@ "xtend": "~4.0.1" } }, - "node_modules/tiny-glob": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", - "integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==", - "dependencies": { - "globalyzer": "0.1.0", - "globrex": "^0.1.2" - } - }, "node_modules/tinybench": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", @@ -11597,6 +11939,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -11734,9 +12085,9 @@ } }, "node_modules/vite": { - "version": "5.4.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz", - "integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==", + "version": "5.4.15", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.15.tgz", + "integrity": "sha512-6ANcZRivqL/4WtwPGTKNaosuNJr5tWiftOC7liM7G9+rMb8+oeJeyzymDu4rTN93seySBmbjSfsS3Vzr19KNtA==", "license": "MIT", "dependencies": { "esbuild": "^0.21.3", @@ -12463,6 +12814,29 @@ "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.3.0.tgz", "integrity": "sha512-BSR9wyRsy/KOValMgd5kMyr3JzpdeoR9KVId8u5GVlTTAtNChlsE4yTxeY7zMdNSyOmoKBv8NH2qeRY9Tg+IaA==" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/wheel": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wheel/-/wheel-1.0.0.tgz", diff --git a/package.json b/package.json index 86568869ffb..f670644df93 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.5.16", + "version": "0.6.2", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", @@ -51,6 +51,7 @@ }, "type": "module", "dependencies": { + "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-python": "^6.1.6", "@codemirror/language-data": "^6.5.1", @@ -79,11 +80,14 @@ "file-saver": "^2.0.5", "fuse.js": "^7.0.0", "highlight.js": "^11.9.0", + "html-entities": "^2.5.3", + "html2canvas-pro": "^1.5.8", "i18next": "^23.10.0", "i18next-browser-languagedetector": "^7.2.0", "i18next-resources-to-backend": "^1.2.0", "idb": "^7.1.1", "js-sha256": "^0.10.1", + "jspdf": "^3.0.0", "katex": "^0.16.21", "kokoro-js": "^1.1.1", "marked": "^9.1.0", @@ -100,7 +104,7 @@ "prosemirror-schema-list": "^1.4.1", "prosemirror-state": "^1.4.3", "prosemirror-view": "^1.34.3", - "pyodide": "^0.27.2", + "pyodide": "^0.27.3", "socket.io-client": "^4.2.0", "sortablejs": "^1.15.2", "svelte-sonner": "^0.3.19", diff --git a/pyproject.toml b/pyproject.toml index 5cd54da64ff..52260e45e22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ authors = [ license = { file = "LICENSE" } dependencies = [ "fastapi==0.115.7", - "uvicorn[standard]==0.30.6", + "uvicorn[standard]==0.34.0", "pydantic==2.10.6", "python-multipart==0.0.18", @@ -21,14 +21,14 @@ dependencies = [ "aiocache", "aiofiles", - "sqlalchemy==2.0.32", + "sqlalchemy==2.0.38", "alembic==1.14.0", - "peewee==3.17.8", + "peewee==3.17.9", "peewee-migrate==1.12.2", "psycopg2-binary==2.9.9", "pgvector==0.3.5", "PyMySQL==1.1.1", - "bcrypt==4.2.0", + "bcrypt==4.3.0", "pymongo", "redis", @@ -40,25 +40,30 @@ dependencies = [ "RestrictedPython==8.0", + "loguru==0.7.2", + "asgiref==3.8.1", + "openai", "anthropic", - "google-generativeai==0.7.2", + "google-generativeai==0.8.4", "tiktoken", - "langchain==0.3.7", - "langchain-community==0.3.7", + "langchain==0.3.19", + "langchain-community==0.3.18", - "fake-useragent==1.5.1", + "fake-useragent==2.1.0", "chromadb==0.6.2", "pymilvus==2.5.0", "qdrant-client~=1.12.0", "opensearch-py==2.8.0", "playwright==1.49.1", + "elasticsearch==8.17.1", "transformers", "sentence-transformers==3.3.1", + "accelerate", "colbert-ai==0.2.21", - "einops==0.8.0", + "einops==0.8.1", "ftfy==6.2.3", "pypdf==4.3.1", @@ -69,7 +74,7 @@ dependencies = [ "unstructured==0.16.17", "nltk==3.9.1", "Markdown==3.7", - "pypandoc==1.13", + "pypandoc==1.15", "pandas==2.2.3", "openpyxl==3.1.5", "pyxlsb==1.0.10", @@ -78,17 +83,21 @@ dependencies = [ "psutil", "sentencepiece", "soundfile==0.13.1", + "azure-ai-documentintelligence==1.0.0", + "pillow==11.1.0", "opencv-python-headless==4.11.0.86", "rapidocr-onnxruntime==1.3.24", "rank-bm25==0.2.2", + "onnxruntime==1.20.1", + "faster-whisper==1.1.1", "PyJWT[crypto]==2.10.1", "authlib==1.4.1", - "black==24.8.0", + "black==25.1.0", "langfuse==2.44.0", "youtube-transcript-api==0.6.3", "pytube==15.0.0", diff --git a/searxng/settings.yml b/searxng/settings.yml new file mode 100644 index 00000000000..70a67d0b63e --- /dev/null +++ b/searxng/settings.yml @@ -0,0 +1,35 @@ +use_default_settings: true +server: + # Is overwritten by ${SEARXNG_PORT} and ${SEARXNG_BIND_ADDRESS} + port: 8080 + bind_address: "0.0.0.0" + # public URL of the instance, to ensure correct inbound links. Is overwritten + # by ${SEARXNG_URL}. + base_url: false # "http://example.com/location" + # rate limit the number of request on the instance, block some bots. + # Is overwritten by ${SEARXNG_LIMITER} + limiter: false + # enable features designed only for public instances. + # Is overwritten by ${SEARXNG_PUBLIC_INSTANCE} + public_instance: false + + # If your instance owns a /etc/searxng/settings.yml file, then set the following + # values there. + + secret_key: "ursecretkey" # Is overwritten by ${SEARXNG_SECRET} + # Proxy image results through SearXNG. Is overwritten by ${SEARXNG_IMAGE_PROXY} + image_proxy: false + # 1.0 and 1.1 are supported + http_protocol_version: "1.0" + # POST queries are more secure as they don't show up in history but may cause + # problems when using Firefox containers + method: "GET" + default_http_headers: + X-Content-Type-Options: nosniff + X-Download-Options: noopen + X-Robots-Tag: noindex, nofollow + Referrer-Policy: no-referrer + +search: + formats: + - json diff --git a/searxng/uwsgi.ini b/searxng/uwsgi.ini new file mode 100644 index 00000000000..9db3d762649 --- /dev/null +++ b/searxng/uwsgi.ini @@ -0,0 +1,54 @@ +[uwsgi] +# Who will run the code +uid = searxng +gid = searxng + +# Number of workers (usually CPU count) +# default value: %k (= number of CPU core, see Dockerfile) +workers = %k + +# Number of threads per worker +# default value: 4 (see Dockerfile) +threads = 4 + +# The right granted on the created socket +chmod-socket = 666 + +# Plugin to use and interpreter config +single-interpreter = true +master = true +plugin = python3 +lazy-apps = true +enable-threads = 4 + +# Module to import +module = searx.webapp + +# Virtualenv and python path +pythonpath = /usr/local/searxng/ +chdir = /usr/local/searxng/searx/ + +# automatically set processes name to something meaningful +auto-procname = true + +# Disable request logging for privacy +disable-logging = true +log-5xx = true + +# Set the max size of a request (request-body excluded) +buffer-size = 8192 + +# No keep alive +# See https://github.com/searx/searx-docker/issues/24 +add-header = Connection: close + +# Follow SIGTERM convention +# See https://github.com/searxng/searxng/issues/3427 +die-on-term + +# uwsgi serves the static files +static-map = /static=/usr/local/searxng/searx/static +# expires set to one day +static-expires = /* 86400 +static-gzip-all = True +offload-threads = 4 diff --git a/src/app.css b/src/app.css index 8bdc6f1ade0..86e8438f096 100644 --- a/src/app.css +++ b/src/app.css @@ -46,6 +46,14 @@ math { @apply rounded-lg; } +input::placeholder { + direction: auto; +} + +textarea::placeholder { + direction: auto; +} + .input-prose { @apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; } @@ -106,7 +114,7 @@ li p { } ::-webkit-scrollbar { - height: 0.4rem; + height: 0.8rem; width: 0.4rem; } diff --git a/src/app.html b/src/app.html index c8fbfca72c6..e64e6d583d7 100644 --- a/src/app.html +++ b/src/app.html @@ -2,12 +2,14 @@ - - - - + + + + + - + + { + logo.src = '/static/splash-dark.png'; + logo.style.filter = ''; // Ensure no inversion is applied if splash-dark.png exists + }; + + darkImage.onerror = () => { + logo.style.filter = 'invert(1)'; // Invert image if splash-dark.png is missing + }; + } + } + + // Runs after classes are assigned + window.onload = setSplashImage; })(); @@ -176,10 +200,6 @@ background: #000; } - html.dark #splash-screen img { - filter: invert(1); - } - html.her #splash-screen { background: #983724; } diff --git a/src/lib/apis/configs/index.ts b/src/lib/apis/configs/index.ts index f7f02c74054..5872303f6aa 100644 --- a/src/lib/apis/configs/index.ts +++ b/src/lib/apis/configs/index.ts @@ -115,6 +115,93 @@ export const setDirectConnectionsConfig = async (token: string, config: object) return res; }; +export const getToolServerConnections = async (token: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/tool_servers`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const setToolServerConnections = async (token: string, connections: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/tool_servers`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...connections + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const verifyToolServerConnection = async (token: string, connection: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/tool_servers/verify`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...connection + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const getCodeExecutionConfig = async (token: string) => { let error = null; diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index 3fb4a5d01be..cdd6887b2dc 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -1,6 +1,9 @@ import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; +import { convertOpenApiToToolPayload } from '$lib/utils'; import { getOpenAIModelsDirect } from './openai'; +import { toast } from 'svelte-sonner'; + export const getModels = async ( token: string = '', connections: object | null = null, @@ -114,6 +117,13 @@ export const getModels = async ( } } + const tags = apiConfig.tags; + if (tags) { + for (const model of models) { + model.tags = tags; + } + } + localModels = localModels.concat(models); } } @@ -249,6 +259,182 @@ export const stopTask = async (token: string, id: string) => { return res; }; +export const getToolServerData = async (token: string, url: string) => { + let error = null; + + const res = await fetch(`${url}`, { + 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); + if ('detail' in err) { + error = err.detail; + } else { + error = err; + } + return null; + }); + + if (error) { + throw error; + } + + const data = { + openapi: res, + info: res.info, + specs: convertOpenApiToToolPayload(res) + }; + + console.log(data); + return data; +}; + +export const getToolServersData = async (i18n, servers: object[]) => { + return ( + await Promise.all( + servers + .filter((server) => server?.config?.enable) + .map(async (server) => { + const data = await getToolServerData( + server?.key, + server?.url + '/' + (server?.path ?? 'openapi.json') + ).catch((err) => { + toast.error( + i18n.t(`Failed to connect to {{URL}} OpenAPI tool server`, { + URL: server?.url + '/' + (server?.path ?? 'openapi.json') + }) + ); + return null; + }); + + if (data) { + const { openapi, info, specs } = data; + return { + url: server?.url, + openapi: openapi, + info: info, + specs: specs + }; + } + }) + ) + ).filter((server) => server); +}; + +export const executeToolServer = async ( + token: string, + url: string, + name: string, + params: Record, + serverData: { openapi: any; info: any; specs: any } +) => { + let error = null; + + try { + // Find the matching operationId in the OpenAPI spec + const matchingRoute = Object.entries(serverData.openapi.paths).find(([_, methods]) => + Object.entries(methods as any).some(([__, operation]: any) => operation.operationId === name) + ); + + if (!matchingRoute) { + throw new Error(`No matching route found for operationId: ${name}`); + } + + const [routePath, methods] = matchingRoute; + + const methodEntry = Object.entries(methods as any).find( + ([_, operation]: any) => operation.operationId === name + ); + + if (!methodEntry) { + throw new Error(`No matching method found for operationId: ${name}`); + } + + const [httpMethod, operation]: [string, any] = methodEntry; + + // Split parameters by type + const pathParams: Record = {}; + const queryParams: Record = {}; + let bodyParams: any = {}; + + if (operation.parameters) { + operation.parameters.forEach((param: any) => { + const paramName = param.name; + const paramIn = param.in; + if (params.hasOwnProperty(paramName)) { + if (paramIn === 'path') { + pathParams[paramName] = params[paramName]; + } else if (paramIn === 'query') { + queryParams[paramName] = params[paramName]; + } + } + }); + } + + let finalUrl = `${url}${routePath}`; + + // Replace path parameters (`{param}`) + Object.entries(pathParams).forEach(([key, value]) => { + finalUrl = finalUrl.replace(new RegExp(`{${key}}`, 'g'), encodeURIComponent(value)); + }); + + // Append query parameters to URL if any + if (Object.keys(queryParams).length > 0) { + const queryString = new URLSearchParams( + Object.entries(queryParams).map(([k, v]) => [k, String(v)]) + ).toString(); + finalUrl += `?${queryString}`; + } + + // Handle requestBody composite + if (operation.requestBody && operation.requestBody.content) { + const contentType = Object.keys(operation.requestBody.content)[0]; + if (params !== undefined) { + bodyParams = params; + } else { + // Optional: Fallback or explicit error if body is expected but not provided + throw new Error(`Request body expected for operation '${name}' but none found.`); + } + } + + // Prepare headers and request options + const headers: Record = { + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }; + + let requestOptions: RequestInit = { + method: httpMethod.toUpperCase(), + headers + }; + + if (['post', 'put', 'patch'].includes(httpMethod.toLowerCase()) && operation.requestBody) { + requestOptions.body = JSON.stringify(bodyParams); + } + + const res = await fetch(finalUrl, requestOptions); + if (!res.ok) { + const resText = await res.text(); + throw new Error(`HTTP error! Status: ${res.status}. Message: ${resText}`); + } + + return await res.json(); + } catch (err: any) { + error = err.message; + console.error('API Request Error:', error); + return { error }; + } +}; + export const getTaskConfig = async (token: string = '') => { let error = null; diff --git a/src/lib/apis/retrieval/index.ts b/src/lib/apis/retrieval/index.ts index c35c37847b5..31317fe0b9c 100644 --- a/src/lib/apis/retrieval/index.ts +++ b/src/lib/apis/retrieval/index.ts @@ -32,9 +32,15 @@ type ChunkConfigForm = { chunk_overlap: number; }; +type DocumentIntelligenceConfigForm = { + key: string; + endpoint: string; +}; + type ContentExtractConfigForm = { engine: string; tika_server_url: string | null; + document_intelligence_config: DocumentIntelligenceConfigForm | null; }; type YoutubeConfigForm = { @@ -46,6 +52,7 @@ type YoutubeConfigForm = { type RAGConfigForm = { pdf_extract_images?: boolean; enable_google_drive_integration?: boolean; + enable_onedrive_integration?: boolean; chunk?: ChunkConfigForm; content_extraction?: ContentExtractConfigForm; web_loader_ssl_verification?: boolean; diff --git a/src/lib/components/AddConnectionModal.svelte b/src/lib/components/AddConnectionModal.svelte index cbd90b68daf..864d850a6ac 100644 --- a/src/lib/components/AddConnectionModal.svelte +++ b/src/lib/components/AddConnectionModal.svelte @@ -14,6 +14,7 @@ import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; import Switch from '$lib/components/common/Switch.svelte'; + import Tags from './common/Tags.svelte'; export let onSubmit: Function = () => {}; export let onDelete: Function = () => {}; @@ -31,6 +32,7 @@ let prefixId = ''; let enable = true; + let tags = []; let modelId = ''; let modelIds = []; @@ -77,17 +79,21 @@ const submitHandler = async () => { loading = true; - if (!ollama && (!url || !key)) { + if (!ollama && !url) { loading = false; - toast.error('URL and Key are required'); + toast.error('URL is required'); return; } + // remove trailing slash from url + url = url.replace(/\/$/, ''); + const connection = { url, key, config: { enable: enable, + tags: tags, prefix_id: prefixId, model_ids: modelIds } @@ -101,6 +107,7 @@ url = ''; key = ''; prefixId = ''; + tags = []; modelIds = []; }; @@ -110,6 +117,7 @@ key = connection.key; enable = connection.config?.enable ?? true; + tags = connection.config?.tags ?? []; prefixId = connection.config?.prefix_id ?? ''; modelIds = connection.config?.model_ids ?? []; } @@ -179,7 +187,7 @@ - + + + +
+
+
{ + e.preventDefault(); + submitHandler(); + }} + > +
+
+
+
+
{$i18n.t('URL')}
+
+ +
+ + + + + + + + + +
+ +
+
/
+ +
+
+
+ +
+ {$i18n.t(`WebUI will make requests to "{{url}}"`, { + url: `${url}/${path}` + })} +
+ +
+
+
{$i18n.t('Auth')}
+ +
+
+ +
+ +
+ {#if auth_type === 'bearer'} + + {:else if auth_type === 'session'} +
+ {$i18n.t('Forwards system user session credentials to authenticate')} +
+ {/if} +
+
+
+
+ + {#if !direct} +
+ +
+
+ +
+
+ {/if} +
+ +
+ {#if edit} + + {/if} + + +
+
+
+
+ + diff --git a/src/lib/components/OnBoarding.svelte b/src/lib/components/OnBoarding.svelte index e68a7f2c1ca..1976e5c6e22 100644 --- a/src/lib/components/OnBoarding.svelte +++ b/src/lib/components/OnBoarding.svelte @@ -1,5 +1,5 @@ {#if show} @@ -18,6 +44,7 @@
{feedbacks.length}
-
+ {#if feedbacks.length > 0}
-
+ {/if}
- + if (_functions) { + let blob = new Blob([JSON.stringify(_functions)], { + type: 'application/json' + }); + saveAs(blob, `functions-export-${Date.now()}.json`); + } + }} + > +
{$i18n.t('Export Functions')}
+ +
+ + + +
+ + {/if}
diff --git a/src/lib/components/admin/Functions/FunctionEditor.svelte b/src/lib/components/admin/Functions/FunctionEditor.svelte index cbdec242570..6da2a83f45b 100644 --- a/src/lib/components/admin/Functions/FunctionEditor.svelte +++ b/src/lib/components/admin/Functions/FunctionEditor.svelte @@ -1,8 +1,7 @@ { + showDeleteConfirmDialog = true; + }} onSubmit={(connection) => { url = connection.url; config = { ...connection.config, key: connection.key }; @@ -39,6 +43,14 @@ }} /> + { + onDelete(); + showConfigModal = false; + }} +/> +
diff --git a/src/lib/components/admin/Settings/Connections/OpenAIConnection.svelte b/src/lib/components/admin/Settings/Connections/OpenAIConnection.svelte index 6be4a0c9027..04769e6f6dd 100644 --- a/src/lib/components/admin/Settings/Connections/OpenAIConnection.svelte +++ b/src/lib/components/admin/Settings/Connections/OpenAIConnection.svelte @@ -6,6 +6,7 @@ import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; import Cog6 from '$lib/components/icons/Cog6.svelte'; import AddConnectionModal from '$lib/components/AddConnectionModal.svelte'; + import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import { connect } from 'socket.io-client'; @@ -19,8 +20,16 @@ export let config = {}; let showConfigModal = false; + let showDeleteConfirmDialog = false; + { + onDelete(); + }} +/> + { + showDeleteConfirmDialog = true; + }} onSubmit={(connection) => { url = connection.url; key = connection.key; diff --git a/src/lib/components/admin/Settings/Documents.svelte b/src/lib/components/admin/Settings/Documents.svelte index c7c1f0e8fe6..b105ebdb9f7 100644 --- a/src/lib/components/admin/Settings/Documents.svelte +++ b/src/lib/components/admin/Settings/Documents.svelte @@ -49,6 +49,13 @@ let contentExtractionEngine = 'default'; let tikaServerUrl = ''; let showTikaServerUrl = false; + let doclingServerUrl = ''; + let showDoclingServerUrl = false; + let documentIntelligenceEndpoint = ''; + let documentIntelligenceKey = ''; + let showDocumentIntelligenceConfig = false; + let mistralApiKey = ''; + let showMistralOcrConfig = false; let textSplitter = ''; let chunkSize = 0; @@ -56,8 +63,10 @@ let pdfExtractImages = true; let RAG_FULL_CONTEXT = false; + let BYPASS_EMBEDDING_AND_RETRIEVAL = false; let enableGoogleDriveIntegration = false; + let enableOneDriveIntegration = false; let OpenAIUrl = ''; let OpenAIKey = ''; @@ -69,6 +78,7 @@ template: '', r: 0.0, k: 4, + k_reranker: 4, hybrid: false }; @@ -166,24 +176,44 @@ }; const submitHandler = async () => { - await embeddingModelUpdateHandler(); - - if (querySettings.hybrid) { - await rerankingModelUpdateHandler(); - } - if (contentExtractionEngine === 'tika' && tikaServerUrl === '') { toast.error($i18n.t('Tika Server URL required.')); return; } + if (contentExtractionEngine === 'docling' && doclingServerUrl === '') { + toast.error($i18n.t('Docling Server URL required.')); + return; + } + if ( + contentExtractionEngine === 'document_intelligence' && + (documentIntelligenceEndpoint === '' || documentIntelligenceKey === '') + ) { + toast.error($i18n.t('Document Intelligence endpoint and key required.')); + return; + } + if (contentExtractionEngine === 'mistral_ocr' && mistralApiKey === '') { + toast.error($i18n.t('Mistral OCR API Key required.')); + return; + } + + if (!BYPASS_EMBEDDING_AND_RETRIEVAL) { + await embeddingModelUpdateHandler(); + + if (querySettings.hybrid) { + await rerankingModelUpdateHandler(); + } + } + const res = await updateRAGConfig(localStorage.token, { pdf_extract_images: pdfExtractImages, enable_google_drive_integration: enableGoogleDriveIntegration, + enable_onedrive_integration: enableOneDriveIntegration, file: { max_size: fileMaxSize === '' ? null : fileMaxSize, max_count: fileMaxCount === '' ? null : fileMaxCount }, RAG_FULL_CONTEXT: RAG_FULL_CONTEXT, + BYPASS_EMBEDDING_AND_RETRIEVAL: BYPASS_EMBEDDING_AND_RETRIEVAL, chunk: { text_splitter: textSplitter, chunk_overlap: chunkOverlap, @@ -191,7 +221,15 @@ }, content_extraction: { engine: contentExtractionEngine, - tika_server_url: tikaServerUrl + tika_server_url: tikaServerUrl, + docling_server_url: doclingServerUrl, + document_intelligence_config: { + key: documentIntelligenceKey, + endpoint: documentIntelligenceEndpoint + }, + mistral_ocr_config: { + api_key: mistralApiKey + } } }); @@ -225,7 +263,6 @@ }; const toggleHybridSearch = async () => { - querySettings.hybrid = !querySettings.hybrid; querySettings = await updateQuerySettings(localStorage.token, querySettings); }; @@ -245,15 +282,25 @@ chunkOverlap = res.chunk.chunk_overlap; RAG_FULL_CONTEXT = res.RAG_FULL_CONTEXT; + BYPASS_EMBEDDING_AND_RETRIEVAL = res.BYPASS_EMBEDDING_AND_RETRIEVAL; contentExtractionEngine = res.content_extraction.engine; tikaServerUrl = res.content_extraction.tika_server_url; + doclingServerUrl = res.content_extraction.docling_server_url; + showTikaServerUrl = contentExtractionEngine === 'tika'; + showDoclingServerUrl = contentExtractionEngine === 'docling'; + documentIntelligenceEndpoint = res.content_extraction.document_intelligence_config.endpoint; + documentIntelligenceKey = res.content_extraction.document_intelligence_config.key; + showDocumentIntelligenceConfig = contentExtractionEngine === 'document_intelligence'; + mistralApiKey = res.content_extraction.mistral_ocr_config.api_key; + showMistralOcrConfig = contentExtractionEngine === 'mistral_ocr'; fileMaxSize = res?.file.max_size ?? ''; fileMaxCount = res?.file.max_count ?? ''; enableGoogleDriveIntegration = res.enable_google_drive_integration; + enableOneDriveIntegration = res.enable_onedrive_integration; } }); @@ -293,473 +340,522 @@ }} >
-
-
{$i18n.t('General Settings')}
- -
-
{$i18n.t('Embedding Model Engine')}
-
- -
-
- - {#if embeddingEngine === 'openai'} -
- +
+
+
{$i18n.t('General')}
- -
- {:else if embeddingEngine === 'ollama'} -
- - - -
- {/if} +
- {#if embeddingEngine === 'ollama' || embeddingEngine === 'openai'} -
-
{$i18n.t('Embedding Batch Size')}
-
- -
-
- +
+
+
+ {$i18n.t('Content Extraction Engine')} +
+
+ +
-
- {/if} - -
-
{$i18n.t('Hybrid Search')}
- - -
- -
-
{$i18n.t('Full Context Mode')}
-
- - -
-
-
-
- -
-
-
{$i18n.t('Embedding Model')}
- - {#if embeddingEngine === 'ollama'} -
-
- -
-
- {:else} -
-
- + {#if contentExtractionEngine === ''} +
+
+ {$i18n.t('PDF Extract Images (OCR)')} +
+
+ +
+ {/if} - {#if embeddingEngine === ''} - - {/if} + + +
- {/if} - -
- {$i18n.t( - 'Warning: If you update or change your embedding model, you will need to re-import all documents.' - )} -
- - {#if querySettings.hybrid === true} -
-
{$i18n.t('Reranking Model')}
-
-
- + {#if !BYPASS_EMBEDDING_AND_RETRIEVAL} +
+
{$i18n.t('Text Splitter')}
+
+
-
-
- {/if} -
-
+
+
+
+
+ {$i18n.t('Chunk Size')} +
+
+ +
+
-
-
{$i18n.t('Content Extraction')}
- -
-
{$i18n.t('Engine')}
-
- -
-
+
+
+ {$i18n.t('Chunk Overlap')} +
- {#if showTikaServerUrl} -
-
- +
+ +
+
+
-
- {/if} -
- -
- -
{$i18n.t('Google Drive')}
- -
-
-
{$i18n.t('Enable Google Drive')}
-
- -
+ {/if}
-
- -
-
-
{$i18n.t('Query Params')}
- -
-
-
{$i18n.t('Top K')}
+ {#if !BYPASS_EMBEDDING_AND_RETRIEVAL} +
+
{$i18n.t('Embedding')}
+ +
+ +
+
+
+ {$i18n.t('Embedding Model Engine')} +
+
+ +
+
-
- + {#if embeddingEngine === 'openai'} +
+ + + +
+ {:else if embeddingEngine === 'ollama'} +
+ + + +
+ {/if}
-
- {#if querySettings.hybrid === true} -
-
- {$i18n.t('Minimum Score')} +
+
{$i18n.t('Embedding Model')}
+ +
+ {#if embeddingEngine === 'ollama'} +
+
+ +
+
+ {:else} +
+
+ +
+ + {#if embeddingEngine === ''} + + {/if} +
+ {/if}
-
- +
+ {$i18n.t( + 'Warning: If you update or change your embedding model, you will need to re-import all documents.' + )}
- {/if} -
- {#if querySettings.hybrid === true} -
- {$i18n.t( - 'Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.' - )} -
- {/if} - -
-
{$i18n.t('RAG Template')}
- -