From 09eb7d444ac831d119f553c9fb65fa974cf414b5 Mon Sep 17 00:00:00 2001 From: Enric Tobella Date: Mon, 1 Jun 2026 19:06:50 +0200 Subject: [PATCH 1/3] [ADD] ai_connection --- ai_connection/README.rst | 88 ++++ ai_connection/__init__.py | 2 + ai_connection/__manifest__.py | 19 + ai_connection/client.py | 19 + ai_connection/models/__init__.py | 1 + ai_connection/models/ai_connection.py | 90 ++++ ai_connection/pyproject.toml | 3 + ai_connection/readme/CONTEXT.md | 3 + ai_connection/readme/CONTRIBUTORS.md | 2 + ai_connection/readme/DESCRIPTION.md | 3 + ai_connection/security/ir.model.access.csv | 3 + ai_connection/static/description/icon.png | Bin 0 -> 9455 bytes ai_connection/static/description/index.html | 435 ++++++++++++++++++++ ai_connection/tests/__init__.py | 1 + ai_connection/tests/fake_models.py | 45 ++ ai_connection/tests/test_connection.py | 58 +++ ai_connection/views/ai_connection.xml | 54 +++ 17 files changed, 826 insertions(+) create mode 100644 ai_connection/README.rst create mode 100644 ai_connection/__init__.py create mode 100644 ai_connection/__manifest__.py create mode 100644 ai_connection/client.py create mode 100644 ai_connection/models/__init__.py create mode 100644 ai_connection/models/ai_connection.py create mode 100644 ai_connection/pyproject.toml create mode 100644 ai_connection/readme/CONTEXT.md create mode 100644 ai_connection/readme/CONTRIBUTORS.md create mode 100644 ai_connection/readme/DESCRIPTION.md create mode 100644 ai_connection/security/ir.model.access.csv create mode 100644 ai_connection/static/description/icon.png create mode 100644 ai_connection/static/description/index.html create mode 100644 ai_connection/tests/__init__.py create mode 100644 ai_connection/tests/fake_models.py create mode 100644 ai_connection/tests/test_connection.py create mode 100644 ai_connection/views/ai_connection.xml diff --git a/ai_connection/README.rst b/ai_connection/README.rst new file mode 100644 index 00000000..3ae55caf --- /dev/null +++ b/ai_connection/README.rst @@ -0,0 +1,88 @@ +============= +Ai Connection +============= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:55879f38672930678ed61631886590084592fb3717aea06b117f3b816e35e826 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fai-lightgray.png?logo=github + :target: https://github.com/OCA/ai/tree/18.0/ai_connection + :alt: OCA/ai +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/ai-18-0/ai-18-0-ai_connection + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/ai&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This is a module that defines the basic structure of AI Connections. + +However, it does not include any extra configurations. + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +This module allows to create basic configuration of AI connections. It +is left for child modules the specific connection, however, it creates a +basic structure that will be used from it. Check on existent modules to +know how to handle it. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Dixmit + +Contributors +------------ + +- `Dixmit `__ + + - Enric Tobella + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/ai `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/ai_connection/__init__.py b/ai_connection/__init__.py new file mode 100644 index 00000000..177c8ec6 --- /dev/null +++ b/ai_connection/__init__.py @@ -0,0 +1,2 @@ +from . import models +from .client import AiConnectionClient diff --git a/ai_connection/__manifest__.py b/ai_connection/__manifest__.py new file mode 100644 index 00000000..9ff1a51e --- /dev/null +++ b/ai_connection/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Ai Connection", + "summary": """Creates connections to AI systems""", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Dixmit,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/ai", + "depends": [ + "ai_tool", + ], + "data": [ + "security/ir.model.access.csv", + "views/ai_connection.xml", + ], + "demo": [], +} diff --git a/ai_connection/client.py b/ai_connection/client.py new file mode 100644 index 00000000..72657081 --- /dev/null +++ b/ai_connection/client.py @@ -0,0 +1,19 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +class AiConnectionClient: + def handle_message(self, messages=None, **kwargs): + """Handle a message from the AI system. + Will return a dict with the following information: + { + "message": "", + "tool_calls": [ + { + "name": "", + "arguments": {}, + } + ] + } + """ + raise NotImplementedError("Subclasses must implement this method") diff --git a/ai_connection/models/__init__.py b/ai_connection/models/__init__.py new file mode 100644 index 00000000..9f35a7aa --- /dev/null +++ b/ai_connection/models/__init__.py @@ -0,0 +1 @@ +from . import ai_connection diff --git a/ai_connection/models/ai_connection.py b/ai_connection/models/ai_connection.py new file mode 100644 index 00000000..2f3ba808 --- /dev/null +++ b/ai_connection/models/ai_connection.py @@ -0,0 +1,90 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +import json + +from odoo import fields, models +from odoo.exceptions import UserError + + +class AiConnection(models.Model): + _name = "ai.connection" + _description = "AI Connection" + _max_iterations = 50 + + name = fields.Char(required=True) + kind = fields.Selection([], required=True) + active = fields.Boolean(default=True) + url = fields.Char(groups="base.group_system") + model = fields.Char(groups="base.group_system") + temperature = fields.Float(default=0.8) + + def _run( + self, + prompt, + tools=None, + record=None, + system_prompt=None, + messages=None, + max_iterations=None, + ): + if messages is None: + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + if prompt: + messages.append({"role": "user", "content": prompt}) + return self._run_ai( + messages=messages, tools=tools, record=record, max_iterations=max_iterations + ) + + def _run_ai(self, messages, tools=None, record=None, max_iterations=None): + client = getattr(self, f"_get_client_{self.kind}")(tools) + # Shallow copying messages to avoid edition of the messages + messages = list(messages) + if max_iterations is None: + max_iterations = self._max_iterations + iteration = 0 + prompt_tokens = 0 + completion_tokens = 0 + while iteration < max_iterations: + iteration += 1 + response = client.handle_message( + messages=messages, temperature=self.temperature + ) + messages.append(response["message"]) + prompt_tokens += response.get("usage", {}).get("prompt_tokens", 0) + completion_tokens += response.get("usage", {}).get("completion_tokens", 0) + if not response.get("tool_calls"): + return ( + response["message"]["content"], + prompt_tokens, + completion_tokens, + iteration, + ) + for tool_call in response["tool_calls"]: + tool = tools.filtered( + lambda t, tool_call=tool_call: t.name == tool_call["name"] + ) + if tool: + messages.append(self._process_tool_call(tool, tool_call, record)) + raise UserError( + self.env._("Iterations reached the maximum allowed (%s)", max_iterations) + ) + + def _process_tool_call(self, tool, tool_call, record): + tool_response = tool._execute_tool(**tool_call["arguments"], record=record) + return getattr( + self, + f"_process_tool_call_result_{self.kind}", + self._process_tool_call_result, + )(tool, tool_response, tool_call) + + def _process_tool_call_result(self, tool, tool_response, tool_call): + return { + "role": "tool", + "name": tool.name, + "tool_call_id": tool_call.get("id"), + "content": json.dumps(tool_response), + } diff --git a/ai_connection/pyproject.toml b/ai_connection/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/ai_connection/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/ai_connection/readme/CONTEXT.md b/ai_connection/readme/CONTEXT.md new file mode 100644 index 00000000..40b6d147 --- /dev/null +++ b/ai_connection/readme/CONTEXT.md @@ -0,0 +1,3 @@ +This module allows to create basic configuration of AI connections. +It is left for child modules the specific connection, however, it creates a basic structure that will be used from it. +Check on existent modules to know how to handle it. diff --git a/ai_connection/readme/CONTRIBUTORS.md b/ai_connection/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..2c066ba7 --- /dev/null +++ b/ai_connection/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Dixmit](https://www.dixmit.com) + - Enric Tobella diff --git a/ai_connection/readme/DESCRIPTION.md b/ai_connection/readme/DESCRIPTION.md new file mode 100644 index 00000000..255f5ce6 --- /dev/null +++ b/ai_connection/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +This is a module that defines the basic structure of AI Connections. + +However, it does not include any extra configurations. diff --git a/ai_connection/security/ir.model.access.csv b/ai_connection/security/ir.model.access.csv new file mode 100644 index 00000000..9d762bd9 --- /dev/null +++ b/ai_connection/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_ai_connection,access_ai_connection,model_ai_connection,base.group_user,1,0,0,0 +manage_ai_connection,manage_ai_connection,model_ai_connection,base.group_system,1,1,1,0 diff --git a/ai_connection/static/description/icon.png b/ai_connection/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z z?n?eq1XP>p-IM$Z^C;2L3itnbJZAip*Zo0aw2bs8@(s^~*8T9go!%dHcAz2lM;`yp zD=7&xjFV$S&5uDaiScyD?B-i1ze`+CoRtz`Wn+Zl&#s4&}MO{@N!ufrzjG$B79)Y2d3tBk&)TxUTw@QS0TEL_?njX|@vq?Uz(nBFK5Pq7*xj#u*R&i|?7+6# z+|r_n#SW&LXhtheZdah{ZVoqwyT{D>MC3nkFF#N)xLi{p7J1jXlmVeb;cP5?e(=f# zuT7fvjSbjS781v?7{)-X3*?>tq?)Yd)~|1{BDS(pqC zC}~H#WXlkUW*H5CDOo<)#x7%RY)A;ShGhI5s*#cRDA8YgqG(HeKDx+#(ZQ?386dv! zlXCO)w91~Vw4AmOcATuV653fa9R$fyK8ul%rG z-wfS zihugoZyr38Im?Zuh6@RcF~t1anQu7>#lPpb#}4cOA!EM11`%f*07RqOVkmX{p~KJ9 z^zP;K#|)$`^Rb{rnHGH{~>1(fawV0*Z#)}M`m8-?ZJV<+e}s9wE# z)l&az?w^5{)`S(%MRzxdNqrs1n*-=jS^_jqE*5XDrA0+VE`5^*p3CuM<&dZEeCjoz zR;uu_H9ZPZV|fQq`Cyw4nscrVwi!fE6ciMmX$!_hN7uF;jjKG)d2@aC4ropY)8etW=xJvni)8eHi`H$%#zn^WJ5NLc-rqk|u&&4Z6fD_m&JfSI1Bvb?b<*n&sfl0^t z=HnmRl`XrFvMKB%9}>PaA`m-fK6a0(8=qPkWS5bb4=v?XcWi&hRY?O5HdulRi4?fN zlsJ*N-0Qw+Yic@s0(2uy%F@ib;GjXt01Fmx5XbRo6+n|pP(&nodMoap^z{~q ziEeaUT@Mxe3vJSfI6?uLND(CNr=#^W<1b}jzW58bIfyWTDle$mmS(|x-0|2UlX+9k zQ^EX7Nw}?EzVoBfT(-LT|=9N@^hcn-_p&sqG z&*oVs2JSU+N4ZD`FhCAWaS;>|wH2G*Id|?pa#@>tyxX`+4HyIArWDvVrX)2WAOQff z0qyHu&-S@i^MS-+j--!pr4fPBj~_8({~e1bfcl0wI1kaoN>mJL6KUPQm5N7lB(ui1 zE-o%kq)&djzWJ}ob<-GfDlkB;F31j-VHKvQUGQ3sp`CwyGJk_i!y^sD0fqC@$9|jO zOqN!r!8-p==F@ZVP=U$qSpY(gQ0)59P1&t@y?5rvg<}E+GB}26NYPp4f2YFQrQtot5mn3wu_qprZ=>Ig-$ zbW26Ws~IgY>}^5w`vTB(G`PTZaDiGBo5o(tp)qli|NeV( z@H_=R8V39rt5J5YB2Ky?4eJJ#b`_iBe2ot~6%7mLt5t8Vwi^Jy7|jWXqa3amOIoRb zOr}WVFP--DsS`1WpN%~)t3R!arKF^Q$e12KEqU36AWwnCBICpH4XCsfnyrHr>$I$4 z!DpKX$OKLWarN7nv@!uIA+~RNO)l$$w}p(;b>mx8pwYvu;dD_unryX_NhT8*Tj>BTrTTL&!?O+%Rv;b?B??gSzdp?6Uug9{ zd@V08Z$BdI?fpoCS$)t4mg4rT8Q_I}h`0d-vYZ^|dOB*Q^S|xqTV*vIg?@fVFSmMpaw0qtTRbx} z({Pg?#{2`sc9)M5N$*N|4;^t$+QP?#mov zGVC@I*lBVrOU-%2y!7%)fAKjpEFsgQc4{amtiHb95KQEwvf<(3T<9-Zm$xIew#P22 zc2Ix|App^>v6(3L_MCU0d3W##AB0M~3D00EWoKZqsJYT(#@w$Y_H7G22M~ApVFTRHMI_3be)Lkn#0F*V8Pq zc}`Cjy$bE;FJ6H7p=0y#R>`}-m4(0F>%@P|?7fx{=R^uFdISRnZ2W_xQhD{YuR3t< z{6yxu=4~JkeA;|(J6_nv#>Nvs&FuLA&PW^he@t(UwFFE8)|a!R{`E`K`i^ZnyE4$k z;(749Ix|oi$c3QbEJ3b~D_kQsPz~fIUKym($a_7dJ?o+40*OLl^{=&oq$<#Q(yyrp z{J-FAniyAw9tPbe&IhQ|a`DqFTVQGQ&Gq3!C2==4x{6EJwiPZ8zub-iXoUtkJiG{} zPaR&}_fn8_z~(=;5lD-aPWD3z8PZS@AaUiomF!G8I}Mf>e~0g#BelA-5#`cj;O5>N Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/ai_connection/static/description/index.html b/ai_connection/static/description/index.html new file mode 100644 index 00000000..a9995ca8 --- /dev/null +++ b/ai_connection/static/description/index.html @@ -0,0 +1,435 @@ + + + + + +Ai Connection + + + +
+

Ai Connection

+ + +

Beta License: AGPL-3 OCA/ai Translate me on Weblate Try me on Runboat

+

This is a module that defines the basic structure of AI Connections.

+

However, it does not include any extra configurations.

+

Table of contents

+ +
+

Use Cases / Context

+

This module allows to create basic configuration of AI connections. It +is left for child modules the specific connection, however, it creates a +basic structure that will be used from it. Check on existent modules to +know how to handle it.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Dixmit
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/ai project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/ai_connection/tests/__init__.py b/ai_connection/tests/__init__.py new file mode 100644 index 00000000..dab0e049 --- /dev/null +++ b/ai_connection/tests/__init__.py @@ -0,0 +1 @@ +from . import test_connection diff --git a/ai_connection/tests/fake_models.py b/ai_connection/tests/fake_models.py new file mode 100644 index 00000000..3c021cff --- /dev/null +++ b/ai_connection/tests/fake_models.py @@ -0,0 +1,45 @@ +from odoo import fields, models + +from odoo.addons.ai_connection.client import AiConnectionClient + + +class AiClientDemo(AiConnectionClient): + def __init__(self, tools): + super().__init__() + self.tools = tools or [] + + def handle_message(self, messages, **kwargs): + last_message = messages[-1] + if any(tool.name == last_message["content"] for tool in self.tools): + content = last_message["content"] + return { + "message": { + "role": "assistant", + "content": f"This is a demo response to the prompt: {content}", + }, + "tool_calls": [ + { + "name": content, + "arguments": {}, + } + ], + } + return { + "message": { + "role": "assistant", + "content": "This is a demo response to the prompt: " + f"{last_message['content']}", + }, + } + + +class AiConnection(models.Model): + _inherit = "ai.connection" + + kind = fields.Selection( + selection_add=[("demo", "Demo")], + ondelete={"demo": "cascade"}, + ) + + def _get_client_demo(self, tools): + return AiClientDemo(tools) diff --git a/ai_connection/tests/test_connection.py b/ai_connection/tests/test_connection.py new file mode 100644 index 00000000..2e52b7e1 --- /dev/null +++ b/ai_connection/tests/test_connection.py @@ -0,0 +1,58 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from freezegun import freeze_time +from odoo_test_helper import FakeModelLoader + +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase + + +class TestConnection(TransactionCase): + def setUp(self): + super().setUp() + self.loader = FakeModelLoader(self.env, self.__module__) + self.loader.backup_registry() + from .fake_models import AiConnection + + self.loader.update_registry((AiConnection,)) + self.addCleanup(self.loader.restore_registry) + + def test_demo_connection(self): + connection = self.env["ai.connection"].create( + { + "name": "Demo Connection", + "kind": "demo", + } + ) + response = connection._run("Hello, AI!") + self.assertEqual( + response[0], "This is a demo response to the prompt: Hello, AI!" + ) + self.assertEqual(response[3], 1) + + def test_demo_connection_with_tool(self): + tool = self.env.ref("ai_tool.current_date") + connection = self.env["ai.connection"].create( + { + "name": "Demo Connection", + "kind": "demo", + } + ) + with freeze_time("2024-01-01"): + response = connection._run("get_date", tools=tool) + self.assertEqual( + response[0], 'This is a demo response to the prompt: {"date": "2024-01-01"}' + ) + self.assertEqual(response[3], 2) + + def test_demo_connection_max_iterations(self): + tool = self.env.ref("ai_tool.current_date") + connection = self.env["ai.connection"].create( + { + "name": "Demo Connection", + "kind": "demo", + } + ) + with self.assertRaises(UserError): + connection._run("get_date", tools=tool, max_iterations=1) diff --git a/ai_connection/views/ai_connection.xml b/ai_connection/views/ai_connection.xml new file mode 100644 index 00000000..08086828 --- /dev/null +++ b/ai_connection/views/ai_connection.xml @@ -0,0 +1,54 @@ + + + + + ai.connection + +
+
+ + + + + + + + + + + + + ai.connection + + + + + + + + + ai.connection + + + + + + + + + + AI Connection + ai.connection + list,form + [] + {} + + + + AI Connection + + + + + From 39b7e9c8832d0a6db597ae11f928669e19d5754b Mon Sep 17 00:00:00 2001 From: Enric Tobella Date: Fri, 26 Jun 2026 09:13:10 +0200 Subject: [PATCH 2/3] [IMP] ai_connection: Add file management --- ai_connection/models/ai_connection.py | 17 ++++++++++++++--- ai_connection/tests/fake_models.py | 13 +++++++++---- ai_connection/tests/test_connection.py | 20 ++++++++++++++++++++ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/ai_connection/models/ai_connection.py b/ai_connection/models/ai_connection.py index 2f3ba808..03209195 100644 --- a/ai_connection/models/ai_connection.py +++ b/ai_connection/models/ai_connection.py @@ -22,19 +22,30 @@ class AiConnection(models.Model): def _run( self, - prompt, + prompt=None, tools=None, record=None, system_prompt=None, messages=None, max_iterations=None, + attachments=None, ): if messages is None: messages = [] if system_prompt: messages.append({"role": "system", "content": system_prompt}) - if prompt: - messages.append({"role": "user", "content": prompt}) + if prompt or attachments: + message = {"role": "user", "content": prompt or ""} + if attachments: + message["files"] = [ + { + "name": attachment.name, + "content": attachment.datas.decode("utf-8"), + "mimetype": attachment.mimetype, + } + for attachment in attachments + ] + messages.append(message) return self._run_ai( messages=messages, tools=tools, record=record, max_iterations=max_iterations ) diff --git a/ai_connection/tests/fake_models.py b/ai_connection/tests/fake_models.py index 3c021cff..b1c725d7 100644 --- a/ai_connection/tests/fake_models.py +++ b/ai_connection/tests/fake_models.py @@ -1,3 +1,5 @@ +import base64 + from odoo import fields, models from odoo.addons.ai_connection.client import AiConnectionClient @@ -10,8 +12,12 @@ def __init__(self, tools): def handle_message(self, messages, **kwargs): last_message = messages[-1] - if any(tool.name == last_message["content"] for tool in self.tools): - content = last_message["content"] + content = last_message["content"] + if last_message.get("files"): + content = base64.b64decode(last_message["files"][0]["content"]).decode( + "utf-8" + ) + if any(tool.name == content for tool in self.tools): return { "message": { "role": "assistant", @@ -27,8 +33,7 @@ def handle_message(self, messages, **kwargs): return { "message": { "role": "assistant", - "content": "This is a demo response to the prompt: " - f"{last_message['content']}", + "content": "This is a demo response to the prompt: " f"{content}", }, } diff --git a/ai_connection/tests/test_connection.py b/ai_connection/tests/test_connection.py index 2e52b7e1..3dcc9eb3 100644 --- a/ai_connection/tests/test_connection.py +++ b/ai_connection/tests/test_connection.py @@ -31,6 +31,26 @@ def test_demo_connection(self): ) self.assertEqual(response[3], 1) + def test_demo_connection_with_attachment(self): + attachment = self.env["ir.attachment"].create( + { + "name": "test.txt", + "datas": "SGVsbG8sIEFJIQ==", # Base64 for "Hello, AI!" + "mimetype": "text/plain", + } + ) + connection = self.env["ai.connection"].create( + { + "name": "Demo Connection", + "kind": "demo", + } + ) + response = connection._run(attachments=attachment) + self.assertEqual( + response[0], "This is a demo response to the prompt: Hello, AI!" + ) + self.assertEqual(response[3], 1) + def test_demo_connection_with_tool(self): tool = self.env.ref("ai_tool.current_date") connection = self.env["ai.connection"].create( From ef2c82a19bd87d465bde33b1055d56c88e10271c Mon Sep 17 00:00:00 2001 From: amoya Date: Fri, 3 Jul 2026 10:44:12 +0200 Subject: [PATCH 3/3] [REF] ai_connection: refactor execution flow with iterator and persistence This commit introduces a deep refactoring of the LLM execution core, superseding previous implementations. Key improvements: - Unifies the core execution loop around a generator/iterator architecture. - Introduces for robust tracking of conversational state (draft, running, paused, pending_tool_approval, done, failed). - Wraps tool execution in try/except blocks to return errors safely to the LLM instead of crashing the Odoo transaction. - Optimizes payloads by dynamically extracting base64 file data into records. - Adds an extensible hook to allow custom streaming transports (e.g., temporary files) without blocking transactions. - Introduces mode, allowing each iteration to be executed in an isolated transaction, preventing long-running database locks. - Introduces a nested model for deep JSON debugging of payloads, vacuumed by a cron. - Extensive test coverage for persistence, streaming, tools, and attachments. --- ai_connection/README.rst | 1 + ai_connection/__init__.py | 1 + ai_connection/__manifest__.py | 5 +- ai_connection/data/cron.xml | 23 + ai_connection/models/__init__.py | 2 + ai_connection/models/ai_connection.py | 210 +++++++- .../models/ai_connection_execution.py | 284 +++++++++++ .../ai_connection_execution_iteration.py | 64 +++ ai_connection/security/ir.model.access.csv | 3 + ai_connection/static/description/index.html | 1 + ai_connection/tests/__init__.py | 1 + ai_connection/tests/fake_models.py | 13 + ai_connection/tests/test_connection.py | 460 ++++++++++++++++++ ai_connection/tests/test_run_wizard.py | 103 ++++ ai_connection/views/ai_connection.xml | 10 +- .../views/ai_connection_execution.xml | 134 +++++ ai_connection/wizards/__init__.py | 1 + .../wizards/ai_connection_run_wizard.py | 56 +++ .../ai_connection_run_wizard_views.xml | 50 ++ 19 files changed, 1403 insertions(+), 19 deletions(-) create mode 100644 ai_connection/data/cron.xml create mode 100644 ai_connection/models/ai_connection_execution.py create mode 100644 ai_connection/models/ai_connection_execution_iteration.py create mode 100644 ai_connection/tests/test_run_wizard.py create mode 100644 ai_connection/views/ai_connection_execution.xml create mode 100644 ai_connection/wizards/__init__.py create mode 100644 ai_connection/wizards/ai_connection_run_wizard.py create mode 100644 ai_connection/wizards/ai_connection_run_wizard_views.xml diff --git a/ai_connection/README.rst b/ai_connection/README.rst index 3ae55caf..7a4a9536 100644 --- a/ai_connection/README.rst +++ b/ai_connection/README.rst @@ -62,6 +62,7 @@ Authors ------- * Dixmit +* SDi Contributors ------------ diff --git a/ai_connection/__init__.py b/ai_connection/__init__.py index 177c8ec6..7defbaf6 100644 --- a/ai_connection/__init__.py +++ b/ai_connection/__init__.py @@ -1,2 +1,3 @@ from . import models +from . import wizards from .client import AiConnectionClient diff --git a/ai_connection/__manifest__.py b/ai_connection/__manifest__.py index 9ff1a51e..94fc03c3 100644 --- a/ai_connection/__manifest__.py +++ b/ai_connection/__manifest__.py @@ -6,14 +6,17 @@ "summary": """Creates connections to AI systems""", "version": "18.0.1.0.0", "license": "AGPL-3", - "author": "Dixmit,Odoo Community Association (OCA)", + "author": "Dixmit, SDi, Odoo Community Association (OCA)", "website": "https://github.com/OCA/ai", "depends": [ "ai_tool", ], "data": [ "security/ir.model.access.csv", + "data/cron.xml", + "wizards/ai_connection_run_wizard_views.xml", "views/ai_connection.xml", + "views/ai_connection_execution.xml", ], "demo": [], } diff --git a/ai_connection/data/cron.xml b/ai_connection/data/cron.xml new file mode 100644 index 00000000..943b824b --- /dev/null +++ b/ai_connection/data/cron.xml @@ -0,0 +1,23 @@ + + + + + AI Connection: Autovacuum Iterations + + code + model._autovacuum() + 1 + days + + + + AI Connection: Process Step + + code + model._cron_process_single_step() + + 1 + days + + diff --git a/ai_connection/models/__init__.py b/ai_connection/models/__init__.py index 9f35a7aa..e3fdfe5b 100644 --- a/ai_connection/models/__init__.py +++ b/ai_connection/models/__init__.py @@ -1 +1,3 @@ from . import ai_connection +from . import ai_connection_execution +from . import ai_connection_execution_iteration diff --git a/ai_connection/models/ai_connection.py b/ai_connection/models/ai_connection.py index 03209195..2467dac3 100644 --- a/ai_connection/models/ai_connection.py +++ b/ai_connection/models/ai_connection.py @@ -1,4 +1,5 @@ # Copyright 2026 Dixmit +# Copyright 2026 SDi # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). @@ -29,7 +30,42 @@ def _run( messages=None, max_iterations=None, attachments=None, + store=False, + stepwise=False, + debug=False, + stream=False, + stream_batch_size=40, + is_async=False, ): + """ + Executes a conversation or prompt using the configured AI client. + + :param prompt: Initial text prompt from the user. + :param tools: `ai.tool` recordset of allowed tools. + :param record: Odoo record the AI is running against. + :param system_prompt: System prompt for instructions. + :param messages: List of message history dictionaries. + :param max_iterations: Maximum allowed loops (tool calls). + :param attachments: `ir.attachment` recordset to send to the AI. + + Execution mode parameters: + :param store: (bool) If True, creates an `ai.connection.execution` record + to persist the state, messages, and results in the database. + If False, the execution runs in memory and returns a tuple. + :param stepwise: (bool) Requires `store=True`. If True, pauses the execution + after every tool call (or step), allowing manual resumption. + :param debug: (bool) Requires `store=True`. If True, creates `iteration` + records for every step, saving the exact JSON payloads sent. + :param stream: (bool) Triggers the `_on_stream_batch` hook incrementally as + the LLM generates text. Used with `store=True` so the + execution record can emit bus notifications for the frontend. + :param stream_batch_size: (int) Number of chunks to accumulate before firing + the stream hook. + + :return: If `store=True`, returns the created `ai.connection.execution` record. + If `store=False`, returns a tuple: + (accumulated_content, prompt_tokens, comp_tokens, iter_count) + """ if messages is None: messages = [] if system_prompt: @@ -46,46 +82,186 @@ def _run( for attachment in attachments ] messages.append(message) + + if store: + call_vals = { + "connection_id": self.id, + "prompt": prompt, + "system_prompt": system_prompt, + "messages_json": messages, + "tool_ids": [(6, 0, tools.ids)] if tools else False, + "res_model": record._name if record else False, + "res_id": record.id if record else False, + "max_iterations": max_iterations or self._max_iterations, + "debug": debug, + "stepwise": stepwise, + "stream": stream, + "stream_batch_size": stream_batch_size, + "is_async": is_async, + } + call = self.env["ai.connection.execution"].create(call_vals) + call.action_start() + return call + return self._run_ai( - messages=messages, tools=tools, record=record, max_iterations=max_iterations + messages=messages, + tools=tools, + record=record, + max_iterations=max_iterations, + stream=stream, + stream_batch_size=stream_batch_size, ) - def _run_ai(self, messages, tools=None, record=None, max_iterations=None): - client = getattr(self, f"_get_client_{self.kind}")(tools) + def _run_ai( # noqa: C901 + self, + messages, + tools=None, + record=None, + max_iterations=None, + stream=False, + stream_batch_size=40, + execution_record=None, + ): # Shallow copying messages to avoid edition of the messages messages = list(messages) if max_iterations is None: max_iterations = self._max_iterations + iteration = 0 prompt_tokens = 0 completion_tokens = 0 + while iteration < max_iterations: iteration += 1 - response = client.handle_message( - messages=messages, temperature=self.temperature + + accumulated_content, tool_calls, usage = self._execute_ai_call( + messages, tools, stream, stream_batch_size, execution_record ) - messages.append(response["message"]) - prompt_tokens += response.get("usage", {}).get("prompt_tokens", 0) - completion_tokens += response.get("usage", {}).get("completion_tokens", 0) - if not response.get("tool_calls"): + + response_message = {"role": "assistant", "content": accumulated_content} + if tool_calls: + response_message["tool_calls"] = tool_calls + + messages.append(response_message) + prompt_tokens += usage.get("prompt_tokens", 0) + completion_tokens += usage.get("completion_tokens", 0) + + if not tool_calls: return ( - response["message"]["content"], + accumulated_content, prompt_tokens, completion_tokens, iteration, ) - for tool_call in response["tool_calls"]: - tool = tools.filtered( - lambda t, tool_call=tool_call: t.name == tool_call["name"] - ) - if tool: - messages.append(self._process_tool_call(tool, tool_call, record)) + + new_messages, pending_approval = self._process_tool_calls( + tool_calls, tools, record, execution_record=execution_record + ) + messages.extend(new_messages) + if pending_approval: + break + raise UserError( self.env._("Iterations reached the maximum allowed (%s)", max_iterations) ) + def _get_stream_method(self, client): + if hasattr(client, "handle_message_stream"): + return client.handle_message_stream + + def stream_method(msgs, temperature=None): + res = client.handle_message(msgs, temperature=temperature) + if res.get("message", {}).get("content"): + yield {"type": "content", "content": res["message"]["content"]} + if res.get("tool_calls"): + yield {"type": "tool_calls", "tool_calls": res["tool_calls"]} + if res.get("usage"): + yield {"type": "usage", "usage": res["usage"]} + + return stream_method + + def _execute_ai_call( + self, + messages, + tools=None, + stream=False, + stream_batch_size=40, + execution_record=None, + ): + """ + Executes a single call to the AI model, processing streams if available. + """ + client = getattr(self, f"_get_client_{self.kind}")(tools) + stream_method = self._get_stream_method(client) + + accumulated_content = "" + tool_calls = [] + usage = {} + chunk_count = 0 + + response_iterator = stream_method(messages, temperature=self.temperature) + for chunk in response_iterator: + chunk_type = chunk.get("type") + if chunk_type == "content": + content_chunk = chunk.get("content", "") + accumulated_content += content_chunk + chunk_count += 1 + if stream and chunk_count % stream_batch_size == 0: + self._on_stream_batch( + accumulated_content, + is_done=False, + execution_record=execution_record, + ) + elif chunk_type == "tool_calls": + tool_calls.extend(chunk.get("tool_calls", [])) + elif chunk_type == "usage": + usage.update(chunk.get("usage", {})) + + if stream and chunk_count > 0: + self._on_stream_batch( + accumulated_content, is_done=True, execution_record=execution_record + ) + + return accumulated_content, tool_calls, usage + + def _on_stream_batch(self, accumulated_text, is_done=False, execution_record=None): + """Hook meant to be overwritten or delegated to the active execution.""" + if execution_record: + execution_record._on_stream_batch(accumulated_text, is_done=is_done) + + def _process_tool_calls(self, tool_calls, tools, record, execution_record=None): + """ + Executes a list of tool calls and returns their messages. + If an execution record is provided, it checks for approval + and extracts attachments. + Returns: (new_messages, pending_approval) + """ + new_messages = [] + pending_approval = False + + for tool_call in tool_calls: + tool = tools.filtered(lambda t, tc=tool_call: t.name == tc["name"]) + if tool: + if execution_record and not execution_record._should_execute_tool( + tool, tool_call + ): + pending_approval = True + break + + tool_msg = self._process_tool_call(tool, tool_call, record) + + if execution_record: + tool_msg = execution_record._extract_attachments(tool_msg) + + new_messages.append(tool_msg) + + return new_messages, pending_approval + def _process_tool_call(self, tool, tool_call, record): - tool_response = tool._execute_tool(**tool_call["arguments"], record=record) + try: + tool_response = tool._execute_tool(**tool_call["arguments"], record=record) + except Exception as e: + tool_response = {"error": str(e)} return getattr( self, f"_process_tool_call_result_{self.kind}", diff --git a/ai_connection/models/ai_connection_execution.py b/ai_connection/models/ai_connection_execution.py new file mode 100644 index 00000000..3c95d4c6 --- /dev/null +++ b/ai_connection/models/ai_connection_execution.py @@ -0,0 +1,284 @@ +# Copyright 2026 SDi +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class AiConnectionExecution(models.Model): + _name = "ai.connection.execution" + _description = "AI Connection Execution" + _order = "id desc" + + connection_id = fields.Many2one( + "ai.connection", string="Connection", required=True, ondelete="cascade" + ) + name = fields.Char(default="AI Execution") + state = fields.Selection( + [ + ("draft", "Draft"), + ("running", "Running"), + ("paused", "Paused"), + ("pending_tool_approval", "Pending Tool Approval"), + ("done", "Done"), + ("failed", "Failed"), + ], + default="draft", + required=True, + ) + + prompt = fields.Text(string="Initial Prompt") + system_prompt = fields.Text() + messages_json = fields.Json(string="Messages History", default=list) + tool_ids = fields.Many2many("ai.tool", string="Available Tools") + + res_model = fields.Char(string="Related Model") + res_id = fields.Integer(string="Related ID") + + max_iterations = fields.Integer(default=50) + current_iteration = fields.Integer(default=0) + + prompt_tokens = fields.Integer(default=0) + completion_tokens = fields.Integer(default=0) + total_tokens = fields.Integer(compute="_compute_total_tokens", store=True) + + result_content = fields.Text() + error_message = fields.Text() + + debug = fields.Boolean(string="Debug Mode", default=False) + is_async = fields.Boolean(string="Asynchronous Mode", default=False) + stepwise = fields.Boolean(string="Step-by-Step Mode", default=False) + stream = fields.Boolean(string="Stream Mode", default=False) + stream_batch_size = fields.Integer(default=40) + + iteration_ids = fields.One2many( + "ai.connection.execution.iteration", "execution_id", string="Iterations" + ) + + @api.depends("prompt_tokens", "completion_tokens") + def _compute_total_tokens(self): + for rec in self: + rec.total_tokens = rec.prompt_tokens + rec.completion_tokens + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + for rec in records: + if rec.messages_json: + extracted = [] + for msg in rec.messages_json: + extracted.append(rec._extract_attachments(msg)) + rec.messages_json = extracted + return records + + def action_start(self): + self.ensure_one() + if self.state not in ("draft", "paused"): + return + self.state = "running" + if self.is_async: + cron = self.env.ref( + "ai_connection.cron_ai_step_processor", raise_if_not_found=False + ) + if cron: + cron.with_context(ai_execution_id=self.id)._trigger() + return + return self._execute() + + def action_step(self): + self.ensure_one() + if self.state not in ("draft", "paused", "pending_tool_approval"): + return + self.state = "running" + return self._execute_step() + + def action_resume(self): + self.ensure_one() + if self.state not in ("draft", "paused", "pending_tool_approval"): + return + self.state = "running" + self.stepwise = False + if self.is_async: + cron = self.env.ref( + "ai_connection.cron_ai_step_processor", raise_if_not_found=False + ) + if cron: + cron.with_context(ai_execution_id=self.id)._trigger() + return + return self._execute() + + def _execute(self): + while self.state == "running": + self._execute_step() + if self.stepwise and self.state == "paused": + break + + def _execute_step(self): # noqa: C901 + import logging + + _logger = logging.getLogger(__name__) + + self.ensure_one() + if self.current_iteration >= self.max_iterations: + self.state = "failed" + self.error_message = "Max iterations reached." + return + + self.current_iteration += 1 + connection = self.connection_id + tools = self.tool_ids + record = ( + self.env[self.res_model].browse(self.res_id) + if self.res_model and self.res_id + else None + ) + + # Rehydrate attachments if needed before sending to LLM + messages = self._rehydrate_messages(list(self.messages_json or [])) + + try: + stream = self.stream + stream_batch_size = self.stream_batch_size + + initial_messages = list(self.messages_json or []) + + accumulated_content, tool_calls, usage = connection._execute_ai_call( + messages, tools, stream, stream_batch_size, self + ) + + response_message = {"role": "assistant", "content": accumulated_content} + if tool_calls: + response_message["tool_calls"] = tool_calls + + messages_json = list(self.messages_json or []) + messages_json.append(response_message) + + p_tok = usage.get("prompt_tokens", 0) + c_tok = usage.get("completion_tokens", 0) + self.prompt_tokens += p_tok + self.completion_tokens += c_tok + + tool_results = [] + pending_approval = False + + if tool_calls: + tool_results, pending_approval = connection._process_tool_calls( + tool_calls, tools, record, execution_record=self + ) + messages_json.extend(tool_results) + + if self.debug: + self.env["ai.connection.execution.iteration"].create( + { + "execution_id": self.id, + "step_number": self.current_iteration, + "request_messages_json": initial_messages, + "response_message_json": response_message, + "tool_calls_json": tool_calls, + "tool_results_json": tool_results, + } + ) + + self.messages_json = messages_json + + if pending_approval: + self.state = "pending_tool_approval" + elif not tool_calls: + self.state = "done" + self.result_content = accumulated_content + else: + self.state = "paused" if self.stepwise else "running" + + except Exception as e: + self.state = "failed" + self.error_message = str(e) + _logger.exception("AI Execution failed") + + def _should_execute_tool(self, tool, tool_call): + return True + + def _on_stream_batch(self, accumulated_text, is_done=False): + pass + + def _extract_attachments(self, message): + import base64 + + if not message.get("files"): + return message + new_files = [] + for file_data in message["files"]: + if "content" in file_data and "attachment_id" not in file_data: + # Ensure we handle bytes vs strings properly + content = file_data["content"] + if isinstance(content, str): + content = content.encode("utf-8") + datas = base64.b64encode(content) + att = self.env["ir.attachment"].create( + { + "name": file_data.get("name", "file"), + "datas": datas, + "res_model": self._name, + "res_id": self.id, + } + ) + new_files.append( + { + "name": file_data.get("name"), + "mimetype": file_data.get("mimetype"), + "attachment_id": att.id, + } + ) + else: + new_files.append(file_data) + message["files"] = new_files + return message + + def _rehydrate_messages(self, messages): + import base64 + + res = [] + for msg in messages: + if not msg.get("files"): + res.append(msg) + continue + new_msg = dict(msg) + new_files = [] + for file_data in new_msg["files"]: + if "attachment_id" in file_data: + att = self.env["ir.attachment"].browse(file_data["attachment_id"]) + if att.exists(): + new_files.append( + { + "name": file_data.get("name", att.name), + "mimetype": file_data.get("mimetype", att.mimetype), + "content": base64.b64decode(att.datas).decode("utf-8"), + } + ) + else: + new_files.append(file_data) + new_msg["files"] = new_files + res.append(new_msg) + return res + + @api.model + def _cron_process_single_step(self): + """ + Executed by the cron. Finds one running execution and processes its next step. + If we passed an ai_execution_id in context, we prioritize it. + """ + domain = [("state", "=", "running")] + ai_execution_id = self.env.context.get("ai_execution_id") + if ai_execution_id: + domain.append(("id", "=", ai_execution_id)) + + execution = self.search(domain, limit=1) + if not execution: + return + + execution._execute_step() + + if execution.state == "running": + cron = self.env.ref( + "ai_connection.cron_ai_step_processor", raise_if_not_found=False + ) + if cron: + cron.with_context(ai_execution_id=execution.id)._trigger() diff --git a/ai_connection/models/ai_connection_execution_iteration.py b/ai_connection/models/ai_connection_execution_iteration.py new file mode 100644 index 00000000..9e9f8da0 --- /dev/null +++ b/ai_connection/models/ai_connection_execution_iteration.py @@ -0,0 +1,64 @@ +# Copyright 2026 SDi +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models + + +class AiConnectionExecutionIteration(models.Model): + _name = "ai.connection.execution.iteration" + _description = "AI Connection Execution Iteration" + _order = "step_number asc, id asc" + + execution_id = fields.Many2one( + "ai.connection.execution", string="Execution", required=True, ondelete="cascade" + ) + step_number = fields.Integer(required=True) + + request_messages_json = fields.Json(string="Request Messages") + response_message_json = fields.Json(string="Response Message") + tool_calls_json = fields.Json(string="Tool Calls") + tool_results_json = fields.Json(string="Tool Results") + + request_messages_txt = fields.Text(compute="_compute_txt_fields") + response_message_txt = fields.Text(compute="_compute_txt_fields") + tool_calls_txt = fields.Text(compute="_compute_txt_fields") + tool_results_txt = fields.Text(compute="_compute_txt_fields") + + @api.depends( + "request_messages_json", + "response_message_json", + "tool_calls_json", + "tool_results_json", + ) + def _compute_txt_fields(self): + for rec in self: + rec.request_messages_txt = ( + json.dumps(rec.request_messages_json, indent=2) + if rec.request_messages_json + else "" + ) + rec.response_message_txt = ( + json.dumps(rec.response_message_json, indent=2) + if rec.response_message_json + else "" + ) + rec.tool_calls_txt = ( + json.dumps(rec.tool_calls_json, indent=2) if rec.tool_calls_json else "" + ) + rec.tool_results_txt = ( + json.dumps(rec.tool_results_json, indent=2) + if rec.tool_results_json + else "" + ) + + @api.model + def _autovacuum(self): + """Delete iteration records older than 15 days to prevent bloat.""" + date_limit = fields.Datetime.now() - relativedelta(days=15) + old_records = self.search([("create_date", "<", date_limit)]) + if old_records: + old_records.unlink() diff --git a/ai_connection/security/ir.model.access.csv b/ai_connection/security/ir.model.access.csv index 9d762bd9..cf2b6dbb 100644 --- a/ai_connection/security/ir.model.access.csv +++ b/ai_connection/security/ir.model.access.csv @@ -1,3 +1,6 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_ai_connection,access_ai_connection,model_ai_connection,base.group_user,1,0,0,0 manage_ai_connection,manage_ai_connection,model_ai_connection,base.group_system,1,1,1,0 +access_ai_connection_execution,access_ai_connection_execution,model_ai_connection_execution,base.group_user,1,1,1,1 +access_ai_connection_execution_iteration,access_ai_connection_execution_iteration,model_ai_connection_execution_iteration,base.group_user,1,1,1,1 +access_ai_connection_run_wizard,access_ai_connection_run_wizard,model_ai_connection_run_wizard,base.group_user,1,1,1,1 diff --git a/ai_connection/static/description/index.html b/ai_connection/static/description/index.html index a9995ca8..f732fa16 100644 --- a/ai_connection/static/description/index.html +++ b/ai_connection/static/description/index.html @@ -406,6 +406,7 @@

Credits

Authors

  • Dixmit
  • +
  • SDi
diff --git a/ai_connection/tests/__init__.py b/ai_connection/tests/__init__.py index dab0e049..643e1a1f 100644 --- a/ai_connection/tests/__init__.py +++ b/ai_connection/tests/__init__.py @@ -1 +1,2 @@ from . import test_connection +from . import test_run_wizard diff --git a/ai_connection/tests/fake_models.py b/ai_connection/tests/fake_models.py index b1c725d7..32be1da4 100644 --- a/ai_connection/tests/fake_models.py +++ b/ai_connection/tests/fake_models.py @@ -29,14 +29,27 @@ def handle_message(self, messages, **kwargs): "arguments": {}, } ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5}, } return { "message": { "role": "assistant", "content": "This is a demo response to the prompt: " f"{content}", }, + "usage": {"prompt_tokens": 10, "completion_tokens": 5}, } + def handle_message_stream(self, messages, **kwargs): + res = self.handle_message(messages, **kwargs) + content = res.get("message", {}).get("content", "") + if content: + yield {"type": "content", "content": content[: len(content) // 2]} + yield {"type": "content", "content": content[len(content) // 2 :]} + if res.get("tool_calls"): + yield {"type": "tool_calls", "tool_calls": res["tool_calls"]} + if res.get("usage"): + yield {"type": "usage", "usage": res["usage"]} + class AiConnection(models.Model): _inherit = "ai.connection" diff --git a/ai_connection/tests/test_connection.py b/ai_connection/tests/test_connection.py index 3dcc9eb3..4cad82b8 100644 --- a/ai_connection/tests/test_connection.py +++ b/ai_connection/tests/test_connection.py @@ -1,11 +1,19 @@ # Copyright 2026 Dixmit +# Copyright 2026 SDi # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from unittest.mock import patch + +from dateutil.relativedelta import relativedelta from freezegun import freeze_time from odoo_test_helper import FakeModelLoader +from odoo import fields from odoo.exceptions import UserError from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger + +from ..client import AiConnectionClient class TestConnection(TransactionCase): @@ -13,6 +21,7 @@ def setUp(self): super().setUp() self.loader = FakeModelLoader(self.env, self.__module__) self.loader.backup_registry() + from .fake_models import AiConnection self.loader.update_registry((AiConnection,)) @@ -76,3 +85,454 @@ def test_demo_connection_max_iterations(self): ) with self.assertRaises(UserError): connection._run("get_date", tools=tool, max_iterations=1) + + def test_demo_connection_persistent(self): + connection = self.env["ai.connection"].create( + { + "name": "Demo Connection", + "kind": "demo", + } + ) + execution = connection._run("Hello, AI!", store=True) + self.assertEqual(execution.state, "done") + self.assertEqual( + execution.result_content, + "This is a demo response to the prompt: Hello, AI!", + ) + self.assertTrue(execution.messages_json) + self.assertEqual(execution.current_iteration, 1) + + def test_demo_connection_stepwise(self): + tool = self.env.ref("ai_tool.current_date") + connection = self.env["ai.connection"].create( + { + "name": "Demo Connection", + "kind": "demo", + } + ) + with freeze_time("2024-01-01"): + execution = connection._run( + "get_date", tools=tool, store=True, stepwise=True + ) + self.assertEqual(execution.state, "paused") + self.assertEqual(execution.current_iteration, 1) + + # Resume + execution.action_step() + self.assertEqual(execution.state, "done") + self.assertEqual(execution.current_iteration, 2) + self.assertEqual( + execution.result_content, + 'This is a demo response to the prompt: {"date": "2024-01-01"}', + ) + + def test_demo_connection_streaming(self): + connection = self.env["ai.connection"].create( + { + "name": "Demo Connection", + "kind": "demo", + } + ) + execution = connection._run("Hello, AI!", store=True, stream=True) + self.assertEqual(execution.state, "done") + + def test_demo_connection_attachment_extraction(self): + attachment = self.env["ir.attachment"].create( + { + "name": "test.txt", + "datas": "SGVsbG8sIEFJIQ==", + "mimetype": "text/plain", + } + ) + connection = self.env["ai.connection"].create( + { + "name": "Demo Connection", + "kind": "demo", + } + ) + execution = connection._run("dummy", attachments=attachment, store=True) + self.assertEqual(execution.state, "done") + + messages = execution.messages_json + user_message = next(msg for msg in messages if msg["role"] == "user") + self.assertIn("attachment_id", user_message["files"][0]) + self.assertNotIn("content", user_message["files"][0]) + + def test_execution_actions_early_returns(self): + execution = self.env["ai.connection.execution"].create( + { + "connection_id": self.env["ai.connection"] + .create({"name": "C", "kind": "demo"}) + .id, + "state": "done", + } + ) + self.assertIsNone(execution.action_start()) + self.assertIsNone(execution.action_step()) + self.assertIsNone(execution.action_resume()) + + def test_execution_resume(self): + tool = self.env.ref("ai_tool.current_date") + connection = self.env["ai.connection"].create({"name": "C", "kind": "demo"}) + with freeze_time("2024-01-01"): + execution = connection._run( + "get_date", tools=tool, store=True, stepwise=True + ) + self.assertEqual(execution.state, "paused") + execution.action_resume() + self.assertEqual(execution.state, "done") + self.assertFalse(execution.stepwise) + + def test_execution_max_iterations_failed(self): + tool = self.env.ref("ai_tool.current_date") + connection = self.env["ai.connection"].create({"name": "C", "kind": "demo"}) + execution = self.env["ai.connection.execution"].create( + { + "connection_id": connection.id, + "prompt": "get_date", + "messages_json": [{"role": "user", "content": "get_date"}], + "max_iterations": 1, + "tool_ids": [(6, 0, tool.ids)], + } + ) + execution.action_start() + # The mock tool runs, then we hit iteration > max + # Wait, if max_iteration is 1, it runs iteration 1, calls tool, tool returns. + # Then next step it sees current_iteration >= max_iterations and fails. + if execution.state == "paused": + execution.action_resume() + self.assertEqual(execution.state, "failed") + self.assertEqual(execution.error_message, "Max iterations reached.") + + def test_compute_total_tokens(self): + execution = self.env["ai.connection.execution"].create( + { + "connection_id": self.env["ai.connection"] + .create({"name": "C", "kind": "demo"}) + .id, + "prompt_tokens": 10, + "completion_tokens": 5, + } + ) + self.assertEqual(execution.total_tokens, 15) + + def test_pending_tool_approval(self): + # Override _should_execute_tool to return False + connection = self.env["ai.connection"].create({"name": "C", "kind": "demo"}) + tool = self.env.ref("ai_tool.current_date") + execution = self.env["ai.connection.execution"].create( + { + "connection_id": connection.id, + "prompt": "get_date", + "messages_json": [{"role": "user", "content": "get_date"}], + "tool_ids": [(6, 0, tool.ids)], + } + ) + + with patch.object(type(execution), "_should_execute_tool", return_value=False): + execution.action_start() + + self.assertEqual(execution.state, "pending_tool_approval") + + def test_execution_iteration_autovacuum(self): + execution = self.env["ai.connection.execution"].create( + { + "connection_id": self.env["ai.connection"] + .create({"name": "C", "kind": "demo"}) + .id + } + ) + it = self.env["ai.connection.execution.iteration"].create( + { + "execution_id": execution.id, + "step_number": 1, + } + ) + # Mock create_date + self.env.cr.execute( + "UPDATE ai_connection_execution_iteration SET create_date=%s WHERE id=%s", + (fields.Datetime.now() - relativedelta(days=20), it.id), + ) + self.env["ai.connection.execution.iteration"]._autovacuum() + self.assertFalse(it.exists()) + + @mute_logger("odoo.addons.ai_connection.models.ai_connection") + def test_tool_execution_error_caught(self): + tool = self.env.ref("ai_tool.current_date") + connection = self.env["ai.connection"].create({"name": "C", "kind": "demo"}) + + with patch.object( + type(tool), "_execute_tool", side_effect=ValueError("Tool failed!") + ): + response = connection._run("get_date", tools=tool) + self.assertIn("Tool failed!", response[0]) + + def test_demo_connection_streaming_batch(self): + connection = self.env["ai.connection"].create({"name": "C", "kind": "demo"}) + with patch.object( + type(self.env["ai.connection.execution"]), "_on_stream_batch" + ) as mock_stream: + execution = connection._run( + "test", store=True, stream=True, stream_batch_size=1 + ) + self.assertEqual(execution.state, "done") + # Should have been called multiple times + # since batch size is 1 and chunks = 2 + self.assertTrue(mock_stream.call_count >= 2) + + @mute_logger("odoo.addons.ai_connection.models.ai_connection_execution") + def test_execution_step_error_caught(self): + connection = self.env["ai.connection"].create({"name": "C", "kind": "demo"}) + execution = self.env["ai.connection.execution"].create( + { + "connection_id": connection.id, + "prompt": "test", + } + ) + with patch.object( + type(connection), + "_execute_ai_call", + side_effect=ValueError("AI exploded!"), + ): + execution.action_start() + self.assertEqual(execution.state, "failed") + self.assertEqual(execution.error_message, "AI exploded!") + + def test_rehydrate_missing_attachment(self): + connection = self.env["ai.connection"].create({"name": "C", "kind": "demo"}) + execution = self.env["ai.connection.execution"].create( + { + "connection_id": connection.id, + "messages_json": [ + { + "role": "user", + "content": "test", + "files": [{"attachment_id": 9999999, "name": "gone.txt"}], + } + ], + } + ) + # The rehydrate should ignore the missing attachment + rehydrated = execution._rehydrate_messages(execution.messages_json) + self.assertEqual(len(rehydrated[0]["files"]), 0) + + def test_extract_attachments_bytes_and_strings(self): + execution = self.env["ai.connection.execution"].create( + { + "connection_id": self.env["ai.connection"] + .create({"name": "C", "kind": "demo"}) + .id, + } + ) + msg = { + "files": [ + {"name": "str.txt", "content": "string content"}, + {"name": "bytes.txt", "content": b"bytes content"}, + ] + } + res = execution._extract_attachments(msg) + self.assertEqual(len(res["files"]), 2) + self.assertTrue(all("attachment_id" in f for f in res["files"])) + + def test_client_not_implemented(self): + client = AiConnectionClient() + with self.assertRaises(NotImplementedError): + client.handle_message() + + def test_execution_iteration_txt_fields(self): + execution = self.env["ai.connection.execution"].create( + { + "connection_id": self.env["ai.connection"] + .create({"name": "C", "kind": "demo"}) + .id + } + ) + it = self.env["ai.connection.execution.iteration"].create( + { + "execution_id": execution.id, + "step_number": 1, + "request_messages_json": [{"role": "user", "content": "hi"}], + "response_message_json": {"role": "assistant", "content": "hello"}, + "tool_calls_json": [{"name": "tool"}], + "tool_results_json": [{"role": "tool", "content": "result"}], + } + ) + self.assertIn('"hi"', it.request_messages_txt) + self.assertIn('"hello"', it.response_message_txt) + self.assertIn('"tool"', it.tool_calls_txt) + self.assertIn('"result"', it.tool_results_txt) + + # test empty ones + it2 = self.env["ai.connection.execution.iteration"].create( + { + "execution_id": execution.id, + "step_number": 2, + } + ) + self.assertEqual(it2.request_messages_txt, "") + self.assertEqual(it2.response_message_txt, "") + self.assertEqual(it2.tool_calls_txt, "") + self.assertEqual(it2.tool_results_txt, "") + + def test_client_fallback_stream(self): + connection = self.env["ai.connection"].create({"name": "C", "kind": "demo"}) + + class NoStreamClient: + def handle_message(self, messages, temperature=None): + return { + "message": {"content": "hello"}, + "tool_calls": [{"name": "tool"}], + "usage": {"prompt_tokens": 1}, + } + + no_stream = NoStreamClient() + stream_method = connection._get_stream_method(no_stream) + chunks = list(stream_method([{"role": "user", "content": "hi"}])) + self.assertEqual(len(chunks), 3) + self.assertEqual(chunks[0]["content"], "hello") + self.assertEqual(chunks[1]["tool_calls"][0]["name"], "tool") + self.assertEqual(chunks[2]["usage"]["prompt_tokens"], 1) + + def test_system_prompt_handling(self): + connection = self.env["ai.connection"].create({"name": "C", "kind": "demo"}) + execution = connection._run("Hello", system_prompt="Be nice", store=True) + messages = execution.messages_json + self.assertEqual(messages[0]["role"], "system") + self.assertEqual(messages[0]["content"], "Be nice") + self.assertEqual(messages[1]["role"], "user") + self.assertEqual(messages[1]["content"], "Hello") + + def test_run_with_record(self): + connection = self.env["ai.connection"].create({"name": "C", "kind": "demo"}) + execution = connection._run("Hello", record=connection, store=True) + self.assertEqual(execution.res_model, connection._name) + self.assertEqual(execution.res_id, connection.id) + + def test_only_system_prompt(self): + connection = self.env["ai.connection"].create({"name": "C", "kind": "demo"}) + execution = connection._run(system_prompt="Be nice", store=True) + self.assertEqual(len(execution.messages_json), 2) + self.assertEqual(execution.messages_json[0]["role"], "system") + self.assertEqual(execution.messages_json[1]["role"], "assistant") + + def test_run_ai_pending_approval(self): + connection = self.env["ai.connection"].create({"name": "C", "kind": "demo"}) + tool = self.env.ref("ai_tool.current_date") + execution = self.env["ai.connection.execution"].create( + {"connection_id": connection.id} + ) + with patch.object(type(execution), "_should_execute_tool", return_value=False): + with self.assertRaises(UserError): + connection._run_ai( + [{"role": "user", "content": "get_date"}], + tools=tool, + execution_record=execution, + ) + + def test_run_with_debug(self): + connection = self.env["ai.connection"].create({"name": "C", "kind": "demo"}) + execution = connection._run("Hello", debug=True, store=True) + self.assertEqual(execution.state, "done") + self.assertTrue(len(execution.iteration_ids) > 0) + + def test_rehydrate_without_attachment_id(self): + execution = self.env["ai.connection.execution"].create( + { + "connection_id": self.env["ai.connection"] + .create({"name": "C", "kind": "demo"}) + .id, + } + ) + msg = { + "files": [ + {"name": "str.txt", "content": "string content"}, + ] + } + res = execution._rehydrate_messages([msg]) + self.assertEqual(len(res[0]["files"]), 1) + self.assertNotIn("attachment_id", res[0]["files"][0]) + + def test_execution_async_start_and_resume(self): + connection = self.env["ai.connection"].create({"name": "C", "kind": "demo"}) + execution = self.env["ai.connection.execution"].create( + { + "connection_id": connection.id, + "is_async": True, + } + ) + + with patch("odoo.addons.base.models.ir_cron.ir_cron._trigger") as mock_trigger: + execution.action_start() + self.assertEqual(execution.state, "running") + self.assertEqual(execution.current_iteration, 0) + mock_trigger.assert_called_once() + + execution.state = "pending_tool_approval" + with patch("odoo.addons.base.models.ir_cron.ir_cron._trigger") as mock_trigger: + execution.action_resume() + self.assertEqual(execution.state, "running") + self.assertEqual(execution.current_iteration, 0) + mock_trigger.assert_called_once() + + def test_cron_process_single_step(self): + connection = self.env["ai.connection"].create({"name": "C", "kind": "demo"}) + execution = self.env["ai.connection.execution"].create( + { + "connection_id": connection.id, + "state": "running", + } + ) + + # Test it processes one step and triggers again + with ( + patch.object(type(execution), "_execute_step") as mock_step, + patch("odoo.addons.base.models.ir_cron.ir_cron._trigger") as mock_trigger, + ): + # It will still be running after the mock returns + self.env["ai.connection.execution"]._cron_process_single_step() + + mock_step.assert_called_once() + mock_trigger.assert_called_once() + + # Test it halts if state is not running + execution.state = "done" + execution2 = self.env["ai.connection.execution"].create( + { + "connection_id": connection.id, + "state": "running", + } + ) + with ( + patch.object(type(execution2), "_execute_step") as mock_step, + patch("odoo.addons.base.models.ir_cron.ir_cron._trigger") as mock_trigger, + ): + + def side_effect(): + execution2.state = "done" + + mock_step.side_effect = side_effect + + self.env["ai.connection.execution"]._cron_process_single_step() + mock_step.assert_called_once() + mock_trigger.assert_not_called() + + @mute_logger("odoo.addons.ai_connection.models.ai_connection_execution") + def test_cron_process_single_step_failure(self): + connection = self.env["ai.connection"].create({"name": "C", "kind": "demo"}) + execution = self.env["ai.connection.execution"].create( + { + "connection_id": connection.id, + "state": "running", + } + ) + + with patch.object( + type(connection), + "_execute_ai_call", + side_effect=ValueError("Async exploded!"), + ): + self.env["ai.connection.execution"]._cron_process_single_step() + + # _execute_step catches the exception and sets state to failed + self.assertEqual(execution.state, "failed") + self.assertEqual(execution.error_message, "Async exploded!") diff --git a/ai_connection/tests/test_run_wizard.py b/ai_connection/tests/test_run_wizard.py new file mode 100644 index 00000000..ba258dae --- /dev/null +++ b/ai_connection/tests/test_run_wizard.py @@ -0,0 +1,103 @@ +# Copyright 2026 SDi +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from odoo.tests.common import TransactionCase + + +class TestAiConnectionRunWizard(TransactionCase): + def setUp(self): + super().setUp() + from odoo_test_helper import FakeModelLoader + + self.loader = FakeModelLoader(self.env, self.__module__) + self.loader.backup_registry() + from .fake_models import AiConnection + + self.loader.update_registry((AiConnection,)) + self.addCleanup(self.loader.restore_registry) + + self.connection = self.env["ai.connection"].create( + { + "name": "Test Connection", + "kind": "demo", + } + ) + + def test_action_run_store(self): + wizard = self.env["ai.connection.run.wizard"].create( + { + "connection_id": self.connection.id, + "prompt": "Test prompt", + "store": True, + } + ) + with patch( + "odoo.addons.ai_connection.models.ai_connection.AiConnection._run" + ) as mock_run: + mock_execution = self.env["ai.connection.execution"].create( + { + "connection_id": self.connection.id, + "prompt": "Test prompt", + } + ) + mock_run.return_value = mock_execution + action = wizard.action_run() + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "ai.connection.execution") + self.assertEqual(action["res_id"], mock_execution.id) + + def test_action_run_async(self): + wizard = self.env["ai.connection.run.wizard"].create( + { + "connection_id": self.connection.id, + "prompt": "Test async prompt", + "store": True, + "is_async": True, + } + ) + with patch( + "odoo.addons.ai_connection.models.ai_connection.AiConnection._run" + ) as mock_run: + mock_execution = self.env["ai.connection.execution"].create( + { + "connection_id": self.connection.id, + "prompt": "Test async prompt", + "is_async": True, + } + ) + mock_run.return_value = mock_execution + action = wizard.action_run() + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "ai.connection.execution") + self.assertEqual(action["res_id"], mock_execution.id) + mock_run.assert_called_once_with( + prompt="Test async prompt", + system_prompt=False, + tools=self.env["ai.tool"], + store=True, + stepwise=False, + debug=False, + stream=False, + stream_batch_size=40, + max_iterations=50, + is_async=True, + ) + + def test_action_run_no_store(self): + wizard = self.env["ai.connection.run.wizard"].create( + { + "connection_id": self.connection.id, + "prompt": "Test prompt", + "store": False, + } + ) + with patch( + "odoo.addons.ai_connection.models.ai_connection.AiConnection._run" + ) as mock_run: + mock_run.return_value = ("Test response", 10, 20, 1) + action = wizard.action_run() + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "ai.connection.run.wizard") + self.assertEqual(wizard.response_content, "Test response") diff --git a/ai_connection/views/ai_connection.xml b/ai_connection/views/ai_connection.xml index 08086828..1d047e9f 100644 --- a/ai_connection/views/ai_connection.xml +++ b/ai_connection/views/ai_connection.xml @@ -6,7 +6,15 @@ ai.connection
-
+
+
diff --git a/ai_connection/views/ai_connection_execution.xml b/ai_connection/views/ai_connection_execution.xml new file mode 100644 index 00000000..2542d771 --- /dev/null +++ b/ai_connection/views/ai_connection_execution.xml @@ -0,0 +1,134 @@ + + + + + ai.connection.execution.form + ai.connection.execution + + +
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + ai.connection.execution.list + ai.connection.execution + + + + + + + + + + + + + + AI Executions + ai.connection.execution + list,form + + + +
diff --git a/ai_connection/wizards/__init__.py b/ai_connection/wizards/__init__.py new file mode 100644 index 00000000..977b74db --- /dev/null +++ b/ai_connection/wizards/__init__.py @@ -0,0 +1 @@ +from . import ai_connection_run_wizard diff --git a/ai_connection/wizards/ai_connection_run_wizard.py b/ai_connection/wizards/ai_connection_run_wizard.py new file mode 100644 index 00000000..2db9c8d0 --- /dev/null +++ b/ai_connection/wizards/ai_connection_run_wizard.py @@ -0,0 +1,56 @@ +# Copyright 2026 SDi +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class AiConnectionRunWizard(models.TransientModel): + _name = "ai.connection.run.wizard" + _description = "Wizard to test AI Connection" + + connection_id = fields.Many2one("ai.connection", required=True) + prompt = fields.Text(required=True) + system_prompt = fields.Text() + tool_ids = fields.Many2many("ai.tool", string="Tools") + + store = fields.Boolean(string="Store Execution", default=True) + is_async = fields.Boolean(string="Asynchronous Mode") + stepwise = fields.Boolean(string="Step-by-Step Mode") + debug = fields.Boolean(string="Debug Mode") + stream = fields.Boolean(string="Stream Mode") + stream_batch_size = fields.Integer(default=40) + max_iterations = fields.Integer(default=50) + + response_content = fields.Text(string="Response", readonly=True) + + def action_run(self): + self.ensure_one() + res = self.connection_id._run( + prompt=self.prompt, + system_prompt=self.system_prompt, + tools=self.tool_ids, + store=self.store, + stepwise=self.stepwise, + debug=self.debug, + stream=self.stream, + stream_batch_size=self.stream_batch_size, + max_iterations=self.max_iterations, + is_async=self.is_async, + ) + if self.store: + return { + "type": "ir.actions.act_window", + "res_model": "ai.connection.execution", + "res_id": res.id, + "view_mode": "form", + "target": "current", + } + else: + self.response_content = res[0] + return { + "type": "ir.actions.act_window", + "res_model": "ai.connection.run.wizard", + "res_id": self.id, + "view_mode": "form", + "target": "new", + } diff --git a/ai_connection/wizards/ai_connection_run_wizard_views.xml b/ai_connection/wizards/ai_connection_run_wizard_views.xml new file mode 100644 index 00000000..597dca50 --- /dev/null +++ b/ai_connection/wizards/ai_connection_run_wizard_views.xml @@ -0,0 +1,50 @@ + + + + ai.connection.run.wizard.form + ai.connection.run.wizard + +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + Test Connection + ai.connection.run.wizard + form + new + +