From eff17d674dd8f4a80d17915c27a7a7743ebf0679 Mon Sep 17 00:00:00 2001 From: Marc Durepos Date: Thu, 17 Jul 2025 16:14:57 -0400 Subject: [PATCH] openwebui_base: new module for using openwebui with Odoo --- openwebui_base/__init__.py | 1 + openwebui_base/__manifest__.py | 30 ++++++ openwebui_base/models/__init__.py | 4 + openwebui_base/models/openwebui_model.py | 29 +++++ openwebui_base/models/openwebui_provider.py | 66 ++++++++++++ openwebui_base/models/res_company.py | 15 +++ openwebui_base/models/res_config_settings.py | 19 ++++ openwebui_base/security/ir.model.access.csv | 5 + openwebui_base/static/description/icon.png | Bin 0 -> 2776 bytes openwebui_base/static/description/icon.svg | 7 ++ openwebui_base/tests/__init__.py | 1 + openwebui_base/tests/test_openwebui.py | 52 +++++++++ .../views/openwebui_model_views.xml | 78 ++++++++++++++ .../views/openwebui_provider_views.xml | 99 ++++++++++++++++++ openwebui_base/views/res_config_settings.xml | 31 ++++++ 15 files changed, 437 insertions(+) create mode 100644 openwebui_base/__init__.py create mode 100644 openwebui_base/__manifest__.py create mode 100644 openwebui_base/models/__init__.py create mode 100644 openwebui_base/models/openwebui_model.py create mode 100644 openwebui_base/models/openwebui_provider.py create mode 100644 openwebui_base/models/res_company.py create mode 100644 openwebui_base/models/res_config_settings.py create mode 100644 openwebui_base/security/ir.model.access.csv create mode 100644 openwebui_base/static/description/icon.png create mode 100644 openwebui_base/static/description/icon.svg create mode 100644 openwebui_base/tests/__init__.py create mode 100644 openwebui_base/tests/test_openwebui.py create mode 100644 openwebui_base/views/openwebui_model_views.xml create mode 100644 openwebui_base/views/openwebui_provider_views.xml create mode 100644 openwebui_base/views/res_config_settings.xml diff --git a/openwebui_base/__init__.py b/openwebui_base/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/openwebui_base/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/openwebui_base/__manifest__.py b/openwebui_base/__manifest__.py new file mode 100644 index 0000000..4d58970 --- /dev/null +++ b/openwebui_base/__manifest__.py @@ -0,0 +1,30 @@ +# Bemade Inc. +# +# Copyright (C) 2023-June Bemade Inc. (). +# Author: Marc Durepos (Contact : mdurepos@durpro.com) +# +# This program is under the terms of the GNU Lesser General Public License (LGPL-3) +# For details, visit https://www.gnu.org/licenses/lgpl-3.0.en.html + +{ + "name": "OpenWebUI Base", + "version": "18.0.0.1.0", + "license": "LGPL-3", + "category": "Tools", + "summary": "Base module for OpenWebUI integration", + "author": "Bemade Inc.", + "website": "https://www.bemade.org", + "depends": ["base", "base_setup"], + "external_dependencies": { + "python": ["openwebui-client>=0.3.0"], + }, + "data": [ + "security/ir.model.access.csv", + "views/openwebui_provider_views.xml", + "views/openwebui_model_views.xml", + "views/res_config_settings.xml", + ], + "installable": True, + "application": False, + "auto_install": False, +} diff --git a/openwebui_base/models/__init__.py b/openwebui_base/models/__init__.py new file mode 100644 index 0000000..4af230d --- /dev/null +++ b/openwebui_base/models/__init__.py @@ -0,0 +1,4 @@ +from . import res_company +from . import res_config_settings +from . import openwebui_model +from . import openwebui_provider diff --git a/openwebui_base/models/openwebui_model.py b/openwebui_base/models/openwebui_model.py new file mode 100644 index 0000000..cf534b8 --- /dev/null +++ b/openwebui_base/models/openwebui_model.py @@ -0,0 +1,29 @@ +from odoo import models, fields, api + + +class OpenWebUIModel(models.Model): + _name = "openwebui.model" + _description = "OpenWebUI Model" + + technical_name = fields.Char( + required=True, + help="Technical name of the model on the OpenWebUI server.", + readonly=True, + ) + name = fields.Char( + required=True, + help="User-friendly name of the model.", + readonly=True, + ) + provider_id = fields.Many2one( + "openwebui.provider", + required=True, + ) + + _sql_constraints = [ + ( + "technical_name_provider_unique", + "unique(technical_name, provider_id)", + "Technical name must be unique per provider", + ) + ] diff --git a/openwebui_base/models/openwebui_provider.py b/openwebui_base/models/openwebui_provider.py new file mode 100644 index 0000000..9d7a213 --- /dev/null +++ b/openwebui_base/models/openwebui_provider.py @@ -0,0 +1,66 @@ +from odoo import models, fields, api +from .openwebui_model import OpenWebUIModel +import openwebui_client +from typing import Optional + + +class OpenWebUIProvider(models.Model): + _name = "openwebui.provider" + _description = "OpenWebUI Provider" + + name = fields.Char(string="Name", required=True) + base_url = fields.Char( + string="Base URL", + required=True, + help="Base URL of the OpenWebUI server.", + ) + api_key = fields.Char( + string="API Key", + required=True, + help="API key for authentication.", + groups="base.group_system", + ) + model_ids = fields.One2many( + comodel_name="openwebui.model", + inverse_name="provider_id", + ) + default_model_id = fields.Many2one( + comodel_name="openwebui.model", + ) + + def get_client( + self, model: Optional[OpenWebUIModel] = None + ) -> openwebui_client.OpenWebUIClient: + if not self.base_url or not self.api_key: + raise ValueError("Base URL and API key are required") + model = model or self.default_model_id + return openwebui_client.OpenWebUIClient( + base_url=self.base_url, + api_key=self.api_key, + default_model=model.technical_name if model else None, + ) + + def sync_models(self): + client = self.get_client() + local_models = self.model_ids + remote_models = {model.id: model.name for model in client.models.list()} + for model in local_models: + if model.technical_name not in remote_models.keys(): + model.unlink() + elif model.name != remote_models[model.technical_name]: + model.name = remote_models[model.technical_name] + for model_id, model_name in remote_models.items(): + if model_id not in local_models.mapped("technical_name"): + self.env["openwebui.model"].create( + { + "name": model_name, + "technical_name": model_id, + "provider_id": self.id, + } + ) + + def create(self, vals_list): + providers = super().create(vals_list) + for provider in providers: + provider.sync_models() + return providers diff --git a/openwebui_base/models/res_company.py b/openwebui_base/models/res_company.py new file mode 100644 index 0000000..989cd52 --- /dev/null +++ b/openwebui_base/models/res_company.py @@ -0,0 +1,15 @@ +from odoo import models, fields + + +class Company(models.Model): + _inherit = "res.company" + + openwebui_provider_id = fields.Many2one( + "openwebui.provider", + string="OpenWebUI Provider", + ) + openwebui_default_model_id = fields.Many2one( + "openwebui.model", + string="OpenWebUI Default Model", + domain=[("provider_id", "=", "openwebui_provider_id")], + ) diff --git a/openwebui_base/models/res_config_settings.py b/openwebui_base/models/res_config_settings.py new file mode 100644 index 0000000..0fdeff4 --- /dev/null +++ b/openwebui_base/models/res_config_settings.py @@ -0,0 +1,19 @@ +from odoo import models, fields + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + openwebui_provider_id = fields.Many2one( + comodel_name="openwebui.provider", + related="company_id.openwebui_provider_id", + readonly=False, + company_dependent=True, + ) + + openwebui_default_model_id = fields.Many2one( + comodel_name="openwebui.model", + related="company_id.openwebui_default_model_id", + readonly=False, + company_dependent=True, + ) diff --git a/openwebui_base/security/ir.model.access.csv b/openwebui_base/security/ir.model.access.csv new file mode 100644 index 0000000..c88fb1c --- /dev/null +++ b/openwebui_base/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_openwebui_provider_admin,openwebui.provider admin,model_openwebui_provider,base.group_system,1,1,1,1 +access_openwebui_provider_user,openwebui.provider user,model_openwebui_provider,base.group_user,1,0,0,0 +access_openwebui_model_admin,openwebui.model admin,model_openwebui_model,base.group_system,1,1,1,1 +access_openwebui_model_user,openwebui.model user,model_openwebui_model,base.group_user,1,0,0,0 diff --git a/openwebui_base/static/description/icon.png b/openwebui_base/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..31bd44705a79bd1e83ef1f313c8b7d9e1f3efa18 GIT binary patch literal 2776 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 M8ihgTU$G7Q7cb^0kpKVy literal 0 HcmV?d00001 diff --git a/openwebui_base/static/description/icon.svg b/openwebui_base/static/description/icon.svg new file mode 100644 index 0000000..bea1e77 --- /dev/null +++ b/openwebui_base/static/description/icon.svg @@ -0,0 +1,7 @@ + + + + + + OI + \ No newline at end of file diff --git a/openwebui_base/tests/__init__.py b/openwebui_base/tests/__init__.py new file mode 100644 index 0000000..1da068f --- /dev/null +++ b/openwebui_base/tests/__init__.py @@ -0,0 +1 @@ +from . import test_openwebui diff --git a/openwebui_base/tests/test_openwebui.py b/openwebui_base/tests/test_openwebui.py new file mode 100644 index 0000000..8451a95 --- /dev/null +++ b/openwebui_base/tests/test_openwebui.py @@ -0,0 +1,52 @@ +from odoo.tests import TransactionCase, Form +import os + + +class TestOpenWebUI(TransactionCase): + def setUp(self): + super().setUp() + self.provider = self.env["openwebui.provider"].create( + { + "name": "Test Provider", + "base_url": os.getenv("OPENWEBUI_BASE_URL"), + "api_key": os.getenv("OPENWEBUI_API_KEY"), + } + ) + + def test_sync_models(self): + self.assertTrue(self.provider.model_ids) + self.assertIn( + "MS.qwen3:32b-q8_0", self.provider.model_ids.mapped("technical_name") + ) + + def test_get_client(self): + client = self.provider.get_client() + self.assertTrue(client) + + def test_settings(self): + model = self.provider.model_ids.filtered( + lambda model: model.technical_name == "MS.qwen3:32b-q8_0" + ) + wizard = self.env["res.config.settings"].create({}) + with Form(wizard) as form: + form.openwebui_provider_id = self.provider + form.openwebui_default_model_id = model + self.assertEqual(self.env.company.openwebui_provider_id, self.provider) + self.assertEqual(self.env.company.openwebui_default_model_id, model) + + def test_openwebui_chat(self): + model = self.provider.model_ids.filtered( + lambda model: model.technical_name == "MS.qwen3:32b-q8_0" + ) + self.env.company.openwebui_default_model_id = model + self.env.company.openwebui_provider_id = self.provider + response = self.provider.get_client().chat.completions.create( + model=model.technical_name, + messages=[ + { + "role": "user", + "content": "Hello, how are you?", + } + ], + ) + self.assertTrue(response and response.choices) diff --git a/openwebui_base/views/openwebui_model_views.xml b/openwebui_base/views/openwebui_model_views.xml new file mode 100644 index 0000000..e3c1c2b --- /dev/null +++ b/openwebui_base/views/openwebui_model_views.xml @@ -0,0 +1,78 @@ + + + + + openwebui.model.form + openwebui.model + +
+ +
+

+ +

+
+ + + + + + +
+
+
+
+ + + + openwebui.model.tree + openwebui.model + + + + + + + + + + + + openwebui.model.search + openwebui.model + + + + + + + + + + + + + + + AI Models + openwebui.model + tree,form + +

+ No AI models found +

+

+ AI models are synchronized from your providers. + Add a provider and sync models to see them here. +

+
+
+ + + +
diff --git a/openwebui_base/views/openwebui_provider_views.xml b/openwebui_base/views/openwebui_provider_views.xml new file mode 100644 index 0000000..d2dd353 --- /dev/null +++ b/openwebui_base/views/openwebui_provider_views.xml @@ -0,0 +1,99 @@ + + + + + openwebui.provider.form + openwebui.provider + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+ + + + openwebui.provider.tree + openwebui.provider + + + + + + + + + + + openwebui.provider.search + openwebui.provider + + + + + + + + + + + AI Providers + openwebui.provider + tree,form + +

+ Create your first AI provider +

+

+ Configure AI providers to connect with OpenWebUI. +

+
+
+ + + + + + + +
diff --git a/openwebui_base/views/res_config_settings.xml b/openwebui_base/views/res_config_settings.xml new file mode 100644 index 0000000..94fc8af --- /dev/null +++ b/openwebui_base/views/res_config_settings.xml @@ -0,0 +1,31 @@ + + + + res.config.settings.view.form.inherit.openwebui.base + res.config.settings + + + + + + + + + + + + + + + + + + + Settings + res.config.settings + + form + inline + {'module' : 'openwebui_base', 'bin_size': False} + + \ No newline at end of file