diff --git a/ai_connection/README.rst b/ai_connection/README.rst new file mode 100644 index 0000000..7a4a953 --- /dev/null +++ b/ai_connection/README.rst @@ -0,0 +1,89 @@ +============= +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 +* SDi + +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 0000000..7defbaf --- /dev/null +++ b/ai_connection/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import wizards +from .client import AiConnectionClient diff --git a/ai_connection/__manifest__.py b/ai_connection/__manifest__.py new file mode 100644 index 0000000..94fc03c --- /dev/null +++ b/ai_connection/__manifest__.py @@ -0,0 +1,22 @@ +# 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, 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/client.py b/ai_connection/client.py new file mode 100644 index 0000000..7265708 --- /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/data/cron.xml b/ai_connection/data/cron.xml new file mode 100644 index 0000000..6b68035 --- /dev/null +++ b/ai_connection/data/cron.xml @@ -0,0 +1,13 @@ + + + + + AI Connection: Autovacuum Iterations + + code + model._autovacuum() + 1 + days + + diff --git a/ai_connection/models/__init__.py b/ai_connection/models/__init__.py new file mode 100644 index 0000000..e3fdfe5 --- /dev/null +++ b/ai_connection/models/__init__.py @@ -0,0 +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 new file mode 100644 index 0000000..c95f538 --- /dev/null +++ b/ai_connection/models/ai_connection.py @@ -0,0 +1,275 @@ +# Copyright 2026 Dixmit +# Copyright 2026 SDi +# 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=None, + tools=None, + record=None, + system_prompt=None, + messages=None, + max_iterations=None, + attachments=None, + store=False, + stepwise=False, + debug=False, + stream=False, + stream_batch_size=40, + ): + """ + 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: + messages.append({"role": "system", "content": system_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) + + 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, + } + 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, + stream=stream, + stream_batch_size=stream_batch_size, + ) + + 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 + + accumulated_content, tool_calls, usage = self._execute_ai_call( + messages, tools, stream, stream_batch_size, execution_record + ) + + 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 ( + accumulated_content, + prompt_tokens, + completion_tokens, + iteration, + ) + + 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): + 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}", + 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/models/ai_connection_execution.py b/ai_connection/models/ai_connection_execution.py new file mode 100644 index 0000000..18db414 --- /dev/null +++ b/ai_connection/models/ai_connection_execution.py @@ -0,0 +1,245 @@ +# 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) + 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" + 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 + 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 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 0000000..9e9f8da --- /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/pyproject.toml b/ai_connection/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /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 0000000..40b6d14 --- /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 0000000..2c066ba --- /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 0000000..255f5ce --- /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 0000000..cf2b6db --- /dev/null +++ b/ai_connection/security/ir.model.access.csv @@ -0,0 +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/icon.png b/ai_connection/static/description/icon.png new file mode 100644 index 0000000..3a0328b Binary files /dev/null and b/ai_connection/static/description/icon.png differ diff --git a/ai_connection/static/description/index.html b/ai_connection/static/description/index.html new file mode 100644 index 0000000..f732fa1 --- /dev/null +++ b/ai_connection/static/description/index.html @@ -0,0 +1,436 @@ + + + + + +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
  • +
  • SDi
  • +
+
+
+

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 0000000..643e1a1 --- /dev/null +++ b/ai_connection/tests/__init__.py @@ -0,0 +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 new file mode 100644 index 0000000..32be1da --- /dev/null +++ b/ai_connection/tests/fake_models.py @@ -0,0 +1,63 @@ +import base64 + +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] + 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", + "content": f"This is a demo response to the prompt: {content}", + }, + "tool_calls": [ + { + "name": content, + "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" + + 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 0000000..55cdcc7 --- /dev/null +++ b/ai_connection/tests/test_connection.py @@ -0,0 +1,450 @@ +# 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 +from .fake_models import AiConnection + + +class TestConnection(TransactionCase): + def setUp(self): + super().setUp() + self.loader = FakeModelLoader(self.env, self.__module__) + self.loader.backup_registry() + + 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_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( + { + "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) + + 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]) diff --git a/ai_connection/tests/test_run_wizard.py b/ai_connection/tests/test_run_wizard.py new file mode 100644 index 0000000..4b283fc --- /dev/null +++ b/ai_connection/tests/test_run_wizard.py @@ -0,0 +1,66 @@ +# 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_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 new file mode 100644 index 0000000..1d047e9 --- /dev/null +++ b/ai_connection/views/ai_connection.xml @@ -0,0 +1,62 @@ + + + + + ai.connection + +
+
+
+ + + + + + + +
+
+
+ + + ai.connection + + + + + + + + + ai.connection + + + + + + + + + + AI Connection + ai.connection + list,form + [] + {} + + + + 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 0000000..2542d77 --- /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 0000000..977b74d --- /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 0000000..78a0201 --- /dev/null +++ b/ai_connection/wizards/ai_connection_run_wizard.py @@ -0,0 +1,54 @@ +# 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) + 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, + ) + 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 0000000..fb479a1 --- /dev/null +++ b/ai_connection/wizards/ai_connection_run_wizard_views.xml @@ -0,0 +1,49 @@ + + + + ai.connection.run.wizard.form + ai.connection.run.wizard + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + Test Connection + ai.connection.run.wizard + form + new + +