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 0000000..31bd447 Binary files /dev/null and b/openwebui_base/static/description/icon.png differ 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